Compare commits

...

1 Commits

Author SHA1 Message Date
Derek Ross a7df27c754 port Agora Wallet (Spark) from Ditto's feat/spark-wallet
Bring in the Signal-style wallet redesign and settings refactor from
Ditto's feat/spark-wallet branch, rebranded as "Agora Wallet" throughout
the UI. The underlying SDK stays the Breez SDK Spark variant — internal
identifiers keep the 'spark' name.

Dashboard (/wallet)
- Signal-style layout: centered balance, three circular SEND / SCAN /
  RECEIVE actions, flat date-grouped transaction history with
  category-coloured rows.
- Tap-to-toggle between sats and USD on the balance, persisted in
  localStorage (agora:wallet:balance-mode).
- Pre-flight "Wallet not available" state when VITE_BREEZ_API_KEY is
  missing, so self-hosted builds without a key surface the problem
  before setup.

Send dialog
- Three sub-tabs: Recipient (new useNostrRecipientSearch hook backed by
  useSearchProfiles — NIP-50 + cached author fallback + debounce), Scan
  (camera QR via @capacitor/barcode-scanner), Keyboard (paste / type any
  LN address, invoice, or on-chain address).
- Recipient rows truncate display names at 32 chars + middle-ellipsis
  the LN address so pathologically long values can't push the row past
  the modal edge.

Receive dialog
- Two sub-tabs (Lightning / Bitcoin), rounded QR cards, COPY + ADD
  DETAILS pills, inline sheet for amount-specific bolt11 invoice
  generation. Lightning tab prefers the registered Lightning Address
  over a one-shot invoice.

Settings (/settings/wallet)
- Split the monolithic WalletSettings/WalletSettingsContent into three
  self-contained sections: SparkWalletSettings, WebLNSettings,
  NWCSettings.
- One-tap "Use <addr> on my Nostr profile" button publishes the wallet's
  Lightning Address to kind 0 lud16. Already-applied state shows a
  confirmation chip.
- "Change my Lightning Address" collapses behind a toggle when the user
  already has one; the claim form is primary otherwise.
- Hide-transactions-below threshold, Backup wallet sheet (relay sync /
  file export / view recovery phrase / delete relay backup), Restore
  existing wallet (with ?setup=restore URL deep-linking), CSV
  transaction export, Remove wallet (typed-DELETE confirmation).

Onboarding
- Insert a "wallet" step into InitialSyncGate's signup flow between
  Profile and Follows, with WASM-unsupported (iOS Lockdown Mode) and
  feature-disabled (no API key) fallbacks plus one-tap relay backup of
  the recovery phrase.

Zap integration
- useZaps tries the Agora Wallet first (when balance >= invoice amount)
  before falling back to NWC -> WebLN -> manual QR.

Dead code removal
- Drop WalletLockScreen + LockTimeoutSettings components and all lock
  state from SparkWalletContext (isLocked, lockWallet, unlockWallet,
  lockTimeout, shouldAutoLock, updateLastActivity, LockTimeoutMinutes).
- Existing users with lockTimeout in their persisted config blob have
  the field silently ignored on load (parseSparkWalletConfig does a
  JSON.parse + cast, unknown fields are dropped).

Library code
- Reusable presentational primitives in
  src/components/SparkWallet/primitives/: CircleAction, DateSeparator,
  TransactionRow, WalletTabBar, SettingsRow.
- src/lib/spark/{exportCsv,formatDateBucket,walletAvailability}.ts
- src/hooks/useNostrRecipientSearch.ts
- docs/SPARK-WALLET.md documenting architecture, backup formats,
  payment priority, onboarding placement, and the SDK upgrade-ceiling
  diagnostic.

Dependencies
- @breeztech/breez-sdk-spark pinned to 0.13.1 (exact, no caret). Bumped
  from the prior 0.13.2-dev1.
- @scure/bip32 ^2.0.1 and html5-qrcode ^2.3.8 added to match Ditto's
  dependency set.
- One SDK breaking change handled: prepareLnurlPay's amount_sats was
  renamed to amount and widened to bigint — breezService.ts:719 now
  calls BigInt(amountSats).

Build / native
- vite.config.ts: worker.format 'es' + optimizeDeps exclude for
  @breeztech/breez-sdk-spark (wasm loader).
- Android: CAMERA permission + uses-feature android.hardware.camera
  required=false.
- iOS: broaden NSCameraUsageDescription to mention scanning Lightning
  invoices / Bitcoin addresses.
- cap sync registers @capacitor/barcode-scanner for both platforms.

AppConfig
- New optional walletHideBelowSats?: number field (interface + Zod
  schema) controls the per-transaction hide threshold in the wallet
  history.

Help content
- "How do I connect a wallet?" FAQ leads with the built-in Agora Wallet
  and its one-tap profile setup before mentioning external NWC / WebLN
  options.

Settings page
- Add top-level Wallet entry, drop wallet description from Advanced
  (wallet settings are no longer nested under Advanced).
2026-04-21 13:45:40 -04:00
53 changed files with 4946 additions and 3459 deletions
+5 -1
View File
@@ -4,4 +4,8 @@ VITE_PLAUSIBLE_ENDPOINT="https://plausible.example.tld/api/event"
# Hex pubkey of the nostr-push server (found in nostr-push startup logs as "worker_pubkey")
VITE_NOSTR_PUSH_PUBKEY=""
# Set to "*" to allow any host in the Vite dev server (eg. when proxying through a custom domain)
# ALLOWED_HOSTS="*"
# ALLOWED_HOSTS="*"
# Breez SDK (Spark) API key - request one at https://breez.technology/request-api-key/
# Required for the self-custodial Agora Wallet to connect. Without this, the wallet UI falls back
# to a disabled state and users can still use NWC / WebLN for zaps.
VITE_BREEZ_API_KEY=""
+15 -5
View File
@@ -32,7 +32,8 @@ This project is a Nostr client application built with React 18.x, TailwindCSS 3.
- `/src/components/ui/`: shadcn/ui components (48+ components available)
- `/src/components/auth/`: Authentication-related components (LoginArea, LoginDialog, etc.)
- `/src/components/dm/`: Direct messaging UI components (DMMessagingInterface, DMConversationList, DMChatArea)
- Zap components: `ZapButton`, `ZapDialog`, `WalletModal` for Lightning payments
- `/src/components/SparkWallet/`: Self-custodial Lightning wallet UI (CreateWallet, RestoreWallet, SendPayment, ReceivePayment, WalletBalance, PaymentHistory, PaymentDetailDialog, UnclaimedDeposits, WasmUnsupportedError, MnemonicDisplay, MnemonicInput, plus `primitives/` with CircleAction, DateSeparator, TransactionRow, WalletTabBar, SettingsRow). **Branding note**: the user-facing name is "Agora Wallet"; everything in the codebase says "Spark" because that's the underlying SDK (`@breeztech/breez-sdk-spark`). When writing copy, say "Agora Wallet"; when writing code, keep the Spark naming.
- Zap / wallet components: `ZapButton`, `ZapDialog`, `SparkWalletSettings` (self-custodial wallet settings panel), `WebLNSettings`, `NWCSettings`, `WalletSetupPrompt` for Lightning payments
- `/src/hooks/`: Custom hooks including:
- `useNostr`: Core Nostr protocol integration
- `useAuthor`: Fetch user profile data by pubkey
@@ -46,16 +47,25 @@ This project is a Nostr client application built with React 18.x, TailwindCSS 3.
- `useLoggedInAccounts`: Manage multiple accounts
- `useLoginActions`: Authentication actions
- `useIsMobile`: Responsive design helper
- `useZaps`: Lightning zap functionality with payment processing
- `useWallet`: Unified wallet detection (WebLN + NWC)
- `useZaps`: Lightning zap functionality with payment processing (Spark → NWC → WebLN → manual QR fallback chain)
- `useWallet`: Unified wallet detection (Spark + WebLN + NWC)
- `useNWC`: Nostr Wallet Connect connection management
- `useNWCContext`: Access NWC context provider
- `useSparkWallet`: Self-custodial Breez SDK Spark wallet (balance, send, receive, backup, Lightning Address, on-chain deposits)
- `useNostrRecipientSearch`: Lightning-recipient picker backed by the profile search
- `useBarcodeScanner`: Native QR code scanner wrapping `@capacitor/barcode-scanner`
- `useExchangeRate` / `useSatsToUsd` / `useUsdToSats`: BTC/USD exchange rate with caching
- `usePaymentContext` / `useEnrichedPayment`: Enrich Spark payments with matching Nostr zap receipts
- `useShakespeare`: AI chat completions with Shakespeare AI API
- `/src/pages/`: Page components used by React Router (Index, NotFound)
- `/src/pages/`: Page components used by React Router (Index, NotFound, WalletPage at /wallet, WalletSettingsPage at /settings/wallet)
- `/src/lib/`: Utility functions and shared logic
- `/src/contexts/`: React context providers (AppContext, NWCContext, DMContext)
- `/src/lib/spark/`: Breez SDK service wrapper (`breezService`), NIP-44 encrypted localStorage (`store`), NIP-78 relay backups (`backup`), restore rate limiter (`rateLimiter`), CSV export (`exportCsv`), date bucketing (`formatDateBucket`), build-time API-key availability check (`walletAvailability`)
- `/src/lib/checkWasmSupport.ts`: Runtime WASM capability probe (iOS Lockdown Mode detection)
- `/src/lib/logger.ts`: Development-only logger wrapping `console.*`
- `/src/contexts/`: React context providers (AppContext, NWCContext, SparkWalletContext, DMContext)
- `useDMContext`: Hook exported from DMContext for direct messaging (NIP-04 & NIP-17)
- `useConversationMessages`: Hook exported from DMContext for paginated messages
- `SparkWalletProvider` / `useSparkWalletContext`: Self-custodial Lightning wallet state and actions
- `/src/test/`: Testing utilities including TestApp component
- `/public/`: Static assets
- `App.tsx`: Main app component with provider setup (**CRITICAL**: this file is **already configured** with `QueryClientProvider`, `NostrProvider`, `UnheadProvider` and other important providers - **read this file before making changes**. Changes are usually not necessary unless adding new providers. Changing this file may break the application)
+1
View File
@@ -10,6 +10,7 @@ android {
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {
implementation project(':capacitor-app')
implementation project(':capacitor-barcode-scanner')
implementation project(':capacitor-filesystem')
implementation project(':capacitor-haptics')
implementation project(':capacitor-keyboard')
+2
View File
@@ -58,4 +58,6 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
</manifest>
+3
View File
@@ -5,6 +5,9 @@ project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/
include ':capacitor-app'
project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android')
include ':capacitor-barcode-scanner'
project(':capacitor-barcode-scanner').projectDir = new File('../node_modules/@capacitor/barcode-scanner/android')
include ':capacitor-filesystem'
project(':capacitor-filesystem').projectDir = new File('../node_modules/@capacitor/filesystem/android')
+210
View File
@@ -0,0 +1,210 @@
# Spark Wallet (Breez SDK) Integration
Agora ships with a self-custodial Lightning wallet powered by the
[Breez SDK Spark](https://sdk-doc-spark.breez.technology/) variant. This
document summarises how the integration is structured and what you need to
know when working on it.
## What it is
A **self-custodial, on-device Lightning wallet** built on top of
`@breeztech/breez-sdk-spark`. The user's BIP39 recovery phrase lives on
their device (NIP-44 encrypted in `localStorage`) and is never sent to a
server. The SDK runs as a WebAssembly module in the user's browser /
WKWebView; there is no native Capacitor plugin involved.
The integration mirrors the implementation in
[Agora](https://gitlab.com/soapbox-pub/agora) so backup files and relay
backups are interoperable between the two apps and with Zap Cooking.
## API key requirement
The SDK requires a Breez API key to connect. Set `VITE_BREEZ_API_KEY` in
your environment (request one at <https://breez.technology/request-api-key/>).
Without it the wallet UI surfaces an error and zaps fall through to NWC /
WebLN / manual QR as before.
## SDK version pin (do not bump past 0.12.2 without testing)
`@breeztech/breez-sdk-spark` is **pinned exactly** to `0.12.2` in
`package.json` (no caret). Versions `0.12.3` through at least `0.13.2-dev2`
ship a broken WASM build: the bundled WASM module imports six new host
functions (`getTokenOutputs`, `insertTokenOutputs`, `listTokensOutputs`,
`reserveTokenOutputs`, `setTokensOutputs`,
`createPostgresTokenStoreWithPool`) that are **not implemented** by the
bundled `IndexedDBStorage` class, so `await initBreezSDK()` throws a
`LinkError: ... function import requires a callable` at startup and the
wallet is unusable.
We previously tried working around the gap with a runtime storage shim
that monkey-patched the missing methods. The WASM linker error went away
but the SDK's higher-level `buyBitcoin({ type: 'cashApp' })` path
silently routed to MoonPay with the wrong amount, so the user-visible
behaviour was still wrong. Sticking with 0.12.2 until upstream lands a
clean fix is the safer option.
Reproduce with:
```sh
npm install @breeztech/breez-sdk-spark@0.13.1
# Open the wallet → see "Failed to initialize Breez SDK WASM: LinkError"
```
Before bumping the SDK in the future, do this check first:
```sh
# 1. Install the candidate version
npm install @breeztech/breez-sdk-spark@<new-version> --no-save
# 2. List host imports the WASM expects
grep -oE '__wbg_[a-zA-Z]+_[a-f0-9]+' \
node_modules/@breeztech/breez-sdk-spark/web/breez_sdk_spark_wasm.js \
| sed 's/^__wbg_//;s/_[a-f0-9]\+$//' | sort -u > /tmp/wasm-imports.txt
# 3. Cross-check against the storage methods that exist
grep -oE '\b[a-zA-Z]+\s*\(' \
node_modules/@breeztech/breez-sdk-spark/web/storage/index.js \
| sed 's/\s*($//' | sort -u > /tmp/storage-methods.txt
# 4. Any imports the storage class doesn't implement need shimming
comm -23 /tmp/wasm-imports.txt /tmp/storage-methods.txt
# 5. Independently verify the on-ramp paths actually open the right
# provider (e.g. tap "Buy with Cash App" → URL must start with
# https://cash.app/launch/lightning/, not https://buy.moonpay.io/).
```
Once upstream packaging is fixed (the storage class implements every
`__wbg_*` host function the WASM imports AND `buyBitcoin({ type:
'cashApp' })` actually returns a `cash.app/launch/lightning/...` URL),
restore the caret range: `"@breeztech/breez-sdk-spark": "^0.12.2"`.
Track the upstream bug at <https://github.com/breez/spark-sdk/issues>.
## Architecture
```
src/lib/spark/
breezService.ts BreezWalletService singleton around the SDK
store.ts NIP-44 encrypted mnemonic + config storage
backup.ts NIP-78 (kind 30078) relay backup + v2 file backup
rateLimiter.ts Exponential backoff on mnemonic restore attempts
types.ts Shared TypeScript types
src/lib/checkWasmSupport.ts
Runtime probe that compiles a minimal WASM module to detect
iOS Lockdown Mode (which disables WASM entirely).
src/contexts/SparkWalletContext.tsx
Global provider exposing the full SparkWalletContextValue. Mounted
inside NWCProvider in src/App.tsx.
src/hooks/
useSparkWallet.ts Thin re-export of the context hook
useWallet.ts Unified status: Spark > NWC > WebLN > manual
useZaps.ts Spark is attempted first in the payment chain
useBarcodeScanner.ts @capacitor/barcode-scanner wrapper
useExchangeRate.ts Coinbase BTC/USD rate with caching
usePaymentContext.ts Zap-receipt enrichment for payment history
src/components/
SparkWallet/ Feature directory (create, restore, send,
receive, history, deposits, lock, etc.)
SparkWalletSettings.tsx Inline settings panel shown in
/settings/wallet
WalletSetupPrompt.tsx Dialog that nudges users to /wallet
src/pages/
WalletPage.tsx /wallet dashboard
WalletSettingsPage.tsx /settings/wallet (Spark + NWC combined)
```
## Storage layout
Per-user, scoped by Nostr pubkey:
| Key | Location | Contents |
|----------------------------------|--------------------|--------------------------------------------|
| `spark_seed_<pubkey>` | `localStorage` | NIP-44 encrypted mnemonic |
| `spark_config_<pubkey>` | `localStorage` | Balance cache, lock timeout, `isEnabled` |
| `spark-wallet-mnemonic-session` | `sessionStorage` | NIP-44 encrypted mnemonic (fast reconnect) |
| `spark-wallet-restore-rate-limit`| `sessionStorage` | Failed restore attempt counter |
Encryption uses the currently logged-in signer's
`nip44.encrypt(userPubkey, plaintext)` / `decrypt(userPubkey, ciphertext)`
— i.e. the user encrypts to themselves. The raw mnemonic is never
written to disk in plaintext.
## Backup formats
- **Relay backup**: NIP-78 (kind 30078) addressable event with
`d: "spark-wallet-backup"`, content NIP-44 encrypted. Fallback relays
include `relay.damus.io`, `relay.primal.net`, `nos.lol`. Compatible
with Zap Cooking / sparkihonne.
- **File backup**: v2 JSON format with `encryptedMnemonic` field
(NIP-44). Same format as Agora.
## Zap payment priority
`useZaps` attempts payment methods in this order. Any failure falls
through to the next:
1. **Spark** — if `sparkEnabled && sparkConnected && balance >= amount`
2. **NWC** — if an active connection is configured
3. **WebLN** — if `window.webln` is present
4. **Manual QR** — the invoice is shown with a "Open in Lightning
Wallet" button as a final fallback
## Onboarding integration
The signup flow in `InitialSyncGate` includes a wallet step between
`profile` and `follows`:
```
SIGNUP_STEPS = theme → keygen → download → profile → wallet → follows → outro
```
The step shows a 3-bullet pitch with a **Set up my wallet** primary
button and a **Skip for now** secondary button. On setup, it generates
a mnemonic, shows it via `<MnemonicDisplay>`, offers a one-tap
"Sync encrypted backup to my relays" action, and requires the user to
acknowledge they've saved the phrase before continuing.
Devices without WASM support (iOS Lockdown Mode) are shown a friendly
fallback screen with a single Continue button; they skip the wallet
step entirely.
`SETTINGS_STEPS` (the flow for returning users missing settings) is
unchanged — it never includes the wallet step.
## QR scanning
Agora previously generated QR codes (via `qrcode`) but had no scanner.
`useBarcodeScanner` now wraps `@capacitor/barcode-scanner` for native
builds; web builds expose `isSupported: false` and the Spark
`SendPayment` UI hides the scan button gracefully.
Required permissions:
- **iOS** (`ios/App/App/Info.plist`): `NSCameraUsageDescription`
- **Android** (`android/app/src/main/AndroidManifest.xml`):
`android.permission.CAMERA` and `<uses-feature android:name="android.hardware.camera" android:required="false" />`
After installing or updating the plugin, run `npm run cap:sync`.
## Vite / WASM configuration
`vite.config.ts` needs two tweaks so the WASM loader works:
```ts
optimizeDeps: {
exclude: ['@breeztech/breez-sdk-spark', ...],
},
worker: {
format: 'es',
},
```
`build.target: 'esnext'` is required for top-level await (used inside
the SDK's WASM bootstrap).
## Links
- Breez SDK Spark docs: <https://sdk-doc-spark.breez.technology/>
- Request an API key: <https://breez.technology/request-api-key/>
- Agora reference implementation: <https://gitlab.com/soapbox-pub/agora>
+1 -1
View File
@@ -50,7 +50,7 @@
<key>NSPhotoLibraryUsageDescription</key>
<string>Agora needs access to your photo library to upload images to your posts and profile.</string>
<key>NSCameraUsageDescription</key>
<string>Agora needs camera access to take photos and videos for your posts.</string>
<string>Agora needs camera access to take photos and videos for your posts, and to scan Lightning invoices and Bitcoin addresses for wallet payments.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Agora needs access to your microphone to record voice messages.</string>
<key>ITSAppUsesNonExemptEncryption</key>
+2
View File
@@ -13,6 +13,7 @@ let package = Package(
dependencies: [
.package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", exact: "8.2.0"),
.package(name: "CapacitorApp", path: "../../../node_modules/@capacitor/app"),
.package(name: "CapacitorBarcodeScanner", path: "../../../node_modules/@capacitor/barcode-scanner"),
.package(name: "CapacitorFilesystem", path: "../../../node_modules/@capacitor/filesystem"),
.package(name: "CapacitorHaptics", path: "../../../node_modules/@capacitor/haptics"),
.package(name: "CapacitorKeyboard", path: "../../../node_modules/@capacitor/keyboard"),
@@ -28,6 +29,7 @@ let package = Package(
.product(name: "Capacitor", package: "capacitor-swift-pm"),
.product(name: "Cordova", package: "capacitor-swift-pm"),
.product(name: "CapacitorApp", package: "CapacitorApp"),
.product(name: "CapacitorBarcodeScanner", package: "CapacitorBarcodeScanner"),
.product(name: "CapacitorFilesystem", package: "CapacitorFilesystem"),
.product(name: "CapacitorHaptics", package: "CapacitorHaptics"),
.product(name: "CapacitorKeyboard", package: "CapacitorKeyboard"),
+81 -28
View File
@@ -8,7 +8,7 @@
"name": "agora",
"version": "2.8.0",
"dependencies": {
"@breeztech/breez-sdk-spark": "^0.13.2-dev1",
"@breeztech/breez-sdk-spark": "0.13.1",
"@capacitor/app": "^8.0.0",
"@capacitor/barcode-scanner": "^3.0.2",
"@capacitor/core": "^8.1.0",
@@ -95,6 +95,7 @@
"@radix-ui/react-toggle": "^1.1.0",
"@radix-ui/react-toggle-group": "^1.1.0",
"@radix-ui/react-tooltip": "^1.2.8",
"@scure/bip32": "^2.0.1",
"@scure/bip39": "^1.6.0",
"@sentry/react": "^10.42.0",
"@tanstack/react-query": "^5.56.2",
@@ -114,6 +115,7 @@
"fflate": "^0.8.2",
"hls.js": "^1.6.15",
"html-to-image": "^1.11.13",
"html5-qrcode": "^2.3.8",
"i18next": "^26.0.5",
"i18next-browser-languagedetector": "^8.2.1",
"idb": "^8.0.3",
@@ -352,9 +354,9 @@
}
},
"node_modules/@breeztech/breez-sdk-spark": {
"version": "0.13.2-dev1",
"resolved": "https://registry.npmjs.org/@breeztech/breez-sdk-spark/-/breez-sdk-spark-0.13.2-dev1.tgz",
"integrity": "sha512-W7udRIz+ehjqzCFGCmzJ6fYhSPZ6AGsXyO/X3upOmbJdHXw2DtIVaRYz5sxHLlmIHre8MYAbNUFS3nRqMMVfVQ==",
"version": "0.13.1",
"resolved": "https://registry.npmjs.org/@breeztech/breez-sdk-spark/-/breez-sdk-spark-0.13.1.tgz",
"integrity": "sha512-ZVAs4VltIvLRGUgMB1YNi3Ei78SFFYueB6n8cvA+4lO97DrLw44fHSHxZDZiHLRA/Fdgp/WSvEAE3f3jUyycoA==",
"license": "MIT",
"engines": {
"node": ">=22"
@@ -5822,6 +5824,7 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5835,6 +5838,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5848,6 +5852,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5861,6 +5866,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5874,6 +5880,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5887,6 +5894,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5900,6 +5908,7 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5913,6 +5922,7 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5926,6 +5936,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5939,6 +5950,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5952,6 +5964,7 @@
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5965,6 +5978,7 @@
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5978,6 +5992,7 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5991,6 +6006,7 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -6004,6 +6020,7 @@
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -6017,6 +6034,7 @@
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -6030,6 +6048,7 @@
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -6043,6 +6062,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -6056,6 +6076,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -6069,6 +6090,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -6082,6 +6104,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -6095,6 +6118,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -6108,6 +6132,7 @@
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -6121,6 +6146,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -6134,6 +6160,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -6153,55 +6180,55 @@
"license": "MIT"
},
"node_modules/@scure/bip32": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.3.1.tgz",
"integrity": "sha512-osvveYtyzdEVbt3OfwwXFr4P2iVBL5u1Q3q4ONBfDY/UpOuXmOlbgwc1xECEboY8wIays8Yt6onaWMUdUbfl0A==",
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-2.0.1.tgz",
"integrity": "sha512-4Md1NI5BzoVP+bhyJaY3K6yMesEFzNS1sE/cP+9nuvE7p/b0kx9XbpDHHFl8dHtufcbdHRUUQdRqLIPHN/s7yA==",
"license": "MIT",
"dependencies": {
"@noble/curves": "~1.1.0",
"@noble/hashes": "~1.3.1",
"@scure/base": "~1.1.0"
"@noble/curves": "2.0.1",
"@noble/hashes": "2.0.1",
"@scure/base": "2.0.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/bip32/node_modules/@noble/curves": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.1.0.tgz",
"integrity": "sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA==",
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.0.1.tgz",
"integrity": "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "1.3.1"
"@noble/hashes": "2.0.1"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/bip32/node_modules/@noble/curves/node_modules/@noble/hashes": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.1.tgz",
"integrity": "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA==",
"license": "MIT",
"engines": {
"node": ">= 16"
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/bip32/node_modules/@noble/hashes": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.3.tgz",
"integrity": "sha512-V7/fPHgl+jsVPXqqeOzT8egNj2iBIVt+ECeMMG8TdcnTikP3oaBtUVqpT/gYCR68aEBJSF+XbYUxStjbFMqIIA==",
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz",
"integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==",
"license": "MIT",
"engines": {
"node": ">= 16"
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/bip32/node_modules/@scure/base": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz",
"integrity": "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==",
"license": "MIT",
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/bip39": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.6.0.tgz",
@@ -11649,6 +11676,32 @@
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/nostr-tools/node_modules/@scure/bip32": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.3.1.tgz",
"integrity": "sha512-osvveYtyzdEVbt3OfwwXFr4P2iVBL5u1Q3q4ONBfDY/UpOuXmOlbgwc1xECEboY8wIays8Yt6onaWMUdUbfl0A==",
"license": "MIT",
"dependencies": {
"@noble/curves": "~1.1.0",
"@noble/hashes": "~1.3.1",
"@scure/base": "~1.1.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/nostr-tools/node_modules/@scure/bip32/node_modules/@noble/curves": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.1.0.tgz",
"integrity": "sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "1.3.1"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/nostr-tools/node_modules/@scure/bip39": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.2.1.tgz",
+3 -1
View File
@@ -15,7 +15,7 @@
"node": ">=22"
},
"dependencies": {
"@breeztech/breez-sdk-spark": "^0.13.2-dev1",
"@breeztech/breez-sdk-spark": "0.13.1",
"@capacitor/app": "^8.0.0",
"@capacitor/barcode-scanner": "^3.0.2",
"@capacitor/core": "^8.1.0",
@@ -102,6 +102,7 @@
"@radix-ui/react-toggle": "^1.1.0",
"@radix-ui/react-toggle-group": "^1.1.0",
"@radix-ui/react-tooltip": "^1.2.8",
"@scure/bip32": "^2.0.1",
"@scure/bip39": "^1.6.0",
"@sentry/react": "^10.42.0",
"@tanstack/react-query": "^5.56.2",
@@ -121,6 +122,7 @@
"fflate": "^0.8.2",
"hls.js": "^1.6.15",
"html-to-image": "^1.11.13",
"html5-qrcode": "^2.3.8",
"i18next": "^26.0.5",
"i18next-browser-languagedetector": "^8.2.1",
"idb": "^8.0.3",
+256 -2
View File
@@ -9,8 +9,12 @@ import {
EyeOff,
Heart,
Loader2,
ShieldCheck,
Sparkles,
UserPlus,
Users,
Wallet as WalletIcon,
Zap,
} from "lucide-react";
import { generateSecretKey, getPublicKey, nip19 } from "nostr-tools";
import { saveNsec } from "@/lib/credentialManager";
@@ -40,12 +44,16 @@ import { useEncryptedSettings, getLocalSettingsSync } from "@/hooks/useEncrypted
import { type SyncPhase, useInitialSync } from "@/hooks/useInitialSync";
import { useLoginActions } from "@/hooks/useLoginActions";
import { useNostrPublish } from "@/hooks/useNostrPublish";
import { useSparkWallet } from "@/hooks/useSparkWallet";
import { OnboardingContext } from "@/hooks/useOnboarding";
import { useTheme } from "@/hooks/useTheme";
import { toast } from "@/hooks/useToast";
import { useUploadFile } from "@/hooks/useUploadFile";
import { genUserName } from "@/lib/genUserName";
import { getAvatarShape, isValidAvatarShape } from "@/lib/avatarShape";
import { checkWasmSupport } from "@/lib/checkWasmSupport";
import { isWalletConfigured } from "@/lib/spark/walletAvailability";
import { MnemonicDisplay } from "@/components/SparkWallet/MnemonicDisplay";
import { resolveTheme, resolveThemeConfig } from "@/themes";
import { cn } from "@/lib/utils";
@@ -233,7 +241,7 @@ const SUGGESTED_PACKS: { kind: number; pubkey: string; identifier: string }[] =
];
// Steps for signup (includes keygen + profile) vs. settings-only (existing login)
type SignupStep = "keygen" | "download" | "profile";
type SignupStep = "keygen" | "download" | "profile" | "wallet";
type SettingsStep = "theme" | "follows" | "outro";
type Step = SignupStep | SettingsStep;
@@ -242,6 +250,7 @@ const SIGNUP_STEPS: Step[] = [
"keygen",
"download",
"profile",
"wallet",
"follows",
"outro",
];
@@ -283,6 +292,16 @@ function SetupQuestionnaire({
}
}, [step, steps]);
// Auto-advance past the follows step for returning users who already have
// a follow list. Previously `handleSaveAndContinue` jumped directly to
// outro; now that the wallet step sits between profile and follows, we
// handle the skip here so the flow still terminates cleanly.
useEffect(() => {
if (step === "follows" && hasFollows === true) {
goTo("outro");
}
}, [step, hasFollows, goTo]);
const back = useCallback(() => {
const i = steps.indexOf(step);
if (i > 0) {
@@ -448,12 +467,21 @@ function SetupQuestionnaire({
setHasFollows(userHasFollows);
setIsSaving(false);
// In signup mode, route through the wallet step before the follows / outro
// fork. The WalletStep's own "Continue" button calls next(), which lands
// on the follows step; FollowsStep itself only renders when hasFollows
// is false, so returning users still skip straight to outro.
if (isSignup) {
goTo("wallet");
return;
}
if (userHasFollows) {
goTo("outro");
} else {
goTo("follows");
}
}, [updateConfig, updateSettings, user, nostr, goTo]);
}, [updateConfig, updateSettings, user, nostr, goTo, isSignup]);
return (
<div className="fixed inset-0 z-50 flex flex-col bg-background">
@@ -479,6 +507,8 @@ function SetupQuestionnaire({
<ProfileStep onNext={handleSaveAndContinue} isSaving={isSaving} />
)}
{step === "wallet" && <WalletStep onNext={next} />}
{/* Settings steps */}
{step === "theme" && (
<ThemeStep
@@ -1223,6 +1253,230 @@ function PackCardSkeleton() {
);
}
// ---------------------------------------------------------------------------
// Wallet Step (Spark self-custodial Lightning wallet)
// ---------------------------------------------------------------------------
type WalletSubStep = "intro" | "creating" | "backup" | "skipped";
function WalletStep({ onNext }: { onNext: () => void }) {
const sparkWallet = useSparkWallet();
const [subStep, setSubStep] = useState<WalletSubStep>("intro");
const [mnemonic, setMnemonic] = useState<string>("");
const [hasBackedUp, setHasBackedUp] = useState(false);
const [isSyncing, setIsSyncing] = useState(false);
const [wasmSupported, setWasmSupported] = useState<boolean | null>(null);
useEffect(() => {
checkWasmSupport()
.then((result) => setWasmSupported(result.supported))
.catch(() => setWasmSupported(false));
}, []);
const handleCreate = useCallback(async () => {
setSubStep("creating");
try {
const m = await sparkWallet.createWallet();
setMnemonic(m);
setSubStep("backup");
} catch (error) {
toast({
title: "Wallet creation failed",
description:
error instanceof Error ? error.message : "Please try again or skip.",
variant: "destructive",
});
setSubStep("intro");
}
}, [sparkWallet]);
const handleSyncToRelays = useCallback(async () => {
if (!mnemonic) return;
setIsSyncing(true);
try {
await sparkWallet.syncToRelays(mnemonic);
toast({ title: "Backup synced to relays" });
} catch (error) {
toast({
title: "Backup sync failed",
description: error instanceof Error ? error.message : String(error),
variant: "destructive",
});
} finally {
setIsSyncing(false);
}
}, [mnemonic, sparkWallet]);
// Skip state: wallet skipped, unsupported, or not enabled in this build
const featureDisabled = !isWalletConfigured();
if (subStep === "skipped" || wasmSupported === false || featureDisabled) {
return (
<div className="flex flex-col items-center text-center gap-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
<div className="relative">
<WalletIcon className="w-16 h-16 text-muted-foreground" />
</div>
<div className="space-y-3 max-w-xs">
<h2 className="text-2xl font-bold tracking-tight">
{wasmSupported === false || featureDisabled
? "Wallet unavailable"
: "No problem"}
</h2>
<p className="text-muted-foreground text-sm leading-relaxed">
{wasmSupported === false
? "This device can't run the Agora Wallet (WebAssembly is disabled, possibly by iOS Lockdown Mode). You can still zap via other Lightning wallets."
: featureDisabled
? "The built-in wallet isn't enabled in this build. You can still zap via other Lightning wallets — set them up later under Settings → Wallet."
: "You can set up a wallet later from Settings → Wallet whenever you're ready."}
</p>
</div>
<Button
size="lg"
className="w-full max-w-xs gap-2 rounded-full h-12"
onClick={onNext}
>
Continue
<ChevronRight className="w-4 h-4" />
</Button>
</div>
);
}
// Creating spinner
if (subStep === "creating") {
return (
<div className="flex flex-col items-center text-center gap-8 animate-in fade-in duration-500">
<Loader2 className="w-12 h-12 text-primary animate-spin" />
<div className="space-y-2 max-w-xs">
<h2 className="text-xl font-semibold">Creating your wallet...</h2>
<p className="text-muted-foreground text-sm">
Generating your recovery phrase and connecting to the network.
</p>
</div>
</div>
);
}
// Backup step — show mnemonic and ask user to acknowledge
if (subStep === "backup" && mnemonic) {
return (
<div className="flex flex-col gap-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
<div className="text-center space-y-2">
<div className="mx-auto w-14 h-14 rounded-full bg-primary/10 flex items-center justify-center">
<ShieldCheck className="w-7 h-7 text-primary" />
</div>
<h2 className="text-2xl font-bold tracking-tight">Back it up</h2>
<p className="text-muted-foreground text-sm leading-relaxed max-w-sm mx-auto">
Write down these 12 words and keep them somewhere safe. Anyone with
this phrase can spend your funds.
</p>
</div>
<MnemonicDisplay mnemonic={mnemonic} />
<div className="space-y-3">
<Button
variant="outline"
className="w-full rounded-full h-11"
onClick={handleSyncToRelays}
disabled={isSyncing}
>
{isSyncing ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<Sparkles className="w-4 h-4 mr-2" />
)}
Sync encrypted backup to my relays
</Button>
<label className="flex items-start gap-3 text-sm cursor-pointer rounded-lg border p-3 hover:bg-muted/40 transition-colors">
<input
type="checkbox"
checked={hasBackedUp}
onChange={(e) => setHasBackedUp(e.target.checked)}
className="mt-0.5"
/>
<span className="text-muted-foreground">
I've saved my recovery phrase somewhere safe.
</span>
</label>
<Button
size="lg"
className="w-full gap-2 rounded-full h-12"
disabled={!hasBackedUp}
onClick={onNext}
>
Continue
<ChevronRight className="w-4 h-4" />
</Button>
</div>
</div>
);
}
// Intro step — pitch + create / skip choice
return (
<div className="flex flex-col items-center text-center gap-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
<div className="relative">
<WalletIcon className="w-16 h-16 text-primary" />
<div className="absolute -bottom-1 -right-1 bg-primary/10 rounded-full p-1.5">
<Sparkles className="w-5 h-5 text-primary" />
</div>
</div>
<div className="space-y-3 max-w-sm">
<h2 className="text-2xl font-bold tracking-tight">
Add a Lightning wallet
</h2>
<p className="text-muted-foreground text-sm leading-relaxed">
Set up the self-custodial Agora Wallet for instant zaps and Bitcoin
payments. Your keys stay on your device.
</p>
</div>
<ul className="w-full max-w-xs space-y-3 text-left text-sm">
<li className="flex items-start gap-3">
<Zap className="w-4 h-4 mt-0.5 text-primary shrink-0" />
<span className="text-muted-foreground">
Zap people directly from any post.
</span>
</li>
<li className="flex items-start gap-3">
<ShieldCheck className="w-4 h-4 mt-0.5 text-primary shrink-0" />
<span className="text-muted-foreground">
Self-custodial. No sign-up, no KYC.
</span>
</li>
<li className="flex items-start gap-3">
<Sparkles className="w-4 h-4 mt-0.5 text-primary shrink-0" />
<span className="text-muted-foreground">
Get a personal Lightning Address.
</span>
</li>
</ul>
<div className="w-full max-w-xs space-y-2">
<Button
size="lg"
className="w-full gap-2 rounded-full h-12"
onClick={handleCreate}
>
Set up my wallet
<ChevronRight className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="lg"
className="w-full rounded-full h-11 text-muted-foreground"
onClick={() => setSubStep("skipped")}
>
Skip for now
</Button>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Outro Step
// ---------------------------------------------------------------------------
+248
View File
@@ -0,0 +1,248 @@
import { useState } from 'react';
import { Plus, Trash2, Zap, WalletMinimal, CheckCircle, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge';
import {
Dialog,
DialogContent,
DialogTitle,
} from '@/components/ui/dialog';
import { useNWC } from '@/hooks/useNWCContext';
import { useToast } from '@/hooks/useToast';
/**
* Nostr Wallet Connect (NWC) settings.
*
* Top-level status card showing how many NWC wallets are connected, followed
* by the connection list with per-wallet "set active" and "remove" controls
* and an "Add" button that opens the connection-URI dialog.
*
* Paired with WebLNSettings and SparkWalletSettings on the wallet settings
* page so each wallet type renders as its own cohesive section.
*/
export function NWCSettings() {
const [addDialogOpen, setAddDialogOpen] = useState(false);
const [connectionUri, setConnectionUri] = useState('');
const [alias, setAlias] = useState('');
const [isConnecting, setIsConnecting] = useState(false);
const {
connections,
activeConnection,
connectionInfo,
addConnection,
removeConnection,
setActiveConnection,
} = useNWC();
const hasNWC = connections.length > 0 && connections.some((c) => c.isConnected);
const { toast } = useToast();
const handleAddConnection = async () => {
if (!connectionUri.trim()) {
toast({
title: 'Connection URI required',
description: 'Please enter a valid NWC connection URI.',
variant: 'destructive',
});
return;
}
setIsConnecting(true);
try {
const success = await addConnection(connectionUri.trim(), alias.trim() || undefined);
if (success) {
setConnectionUri('');
setAlias('');
setAddDialogOpen(false);
}
} finally {
setIsConnecting(false);
}
};
const handleRemoveConnection = (connectionString: string) => {
removeConnection(connectionString);
};
const handleSetActive = (connectionString: string) => {
setActiveConnection(connectionString);
toast({
title: 'Active wallet changed',
description: 'The selected wallet is now active for zaps.',
});
};
return (
<>
<div className="space-y-5 px-1">
{/* Status row */}
<div className="flex items-center justify-between gap-3 rounded-2xl bg-muted/30 p-4">
<div className="flex items-center gap-3 min-w-0">
<div className="flex items-center justify-center size-10 rounded-full bg-background shrink-0">
<WalletMinimal className="size-4 text-muted-foreground" />
</div>
<div className="min-w-0">
<p className="text-sm font-medium">Nostr Wallet Connect</p>
<p className="text-xs text-muted-foreground">
{connections.length > 0
? `${connections.length} wallet${connections.length !== 1 ? 's' : ''} connected`
: 'Remote wallet connection'}
</p>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
{hasNWC && <CheckCircle className="size-4 text-green-500" />}
<Badge
variant={hasNWC ? 'default' : 'secondary'}
className="text-xs rounded-full"
>
{hasNWC ? 'Ready' : 'None'}
</Badge>
</div>
</div>
{/* Connection list + add */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
Connections
</h3>
<Button
size="sm"
variant="outline"
onClick={() => setAddDialogOpen(true)}
className="rounded-full"
>
<Plus className="size-4 mr-1" />
Add
</Button>
</div>
{connections.length === 0 ? (
<div className="rounded-2xl border border-dashed py-10 px-6 text-center">
<WalletMinimal className="size-8 mx-auto mb-3 text-muted-foreground/50" />
<p className="text-sm text-muted-foreground mb-1">No wallets connected</p>
<p className="text-xs text-muted-foreground/70">
Add an NWC connection to enable instant zaps from a remote
wallet.
</p>
</div>
) : (
<div className="space-y-2">
{connections.map((connection) => {
const info = connectionInfo[connection.connectionString];
const isActive = activeConnection === connection.connectionString;
return (
<div
key={connection.connectionString}
className={
'flex items-center justify-between gap-3 rounded-2xl bg-muted/30 p-4 ' +
(isActive ? 'ring-2 ring-primary' : '')
}
>
<div className="flex items-center gap-3 min-w-0">
<div className="flex items-center justify-center size-10 rounded-full bg-background shrink-0">
<WalletMinimal className="size-4 text-muted-foreground" />
</div>
<div className="min-w-0">
<p className="text-sm font-medium truncate">
{connection.alias || info?.alias || 'Lightning Wallet'}
</p>
<p className="text-xs text-muted-foreground">
{isActive ? 'Active' : 'NWC Connection'}
</p>
</div>
</div>
<div className="flex items-center gap-1 shrink-0">
{isActive && (
<CheckCircle className="size-4 text-green-500 mr-1" />
)}
{!isActive && (
<Button
size="sm"
variant="ghost"
onClick={() =>
handleSetActive(connection.connectionString)
}
className="rounded-full"
title="Set as active"
>
<Zap className="size-3.5" />
</Button>
)}
<Button
size="sm"
variant="ghost"
onClick={() =>
handleRemoveConnection(connection.connectionString)
}
className="rounded-full text-muted-foreground hover:text-destructive"
title="Remove wallet"
>
<Trash2 className="size-3.5" />
</Button>
</div>
</div>
);
})}
</div>
)}
</div>
</div>
{/* Add wallet dialog */}
<Dialog open={addDialogOpen} onOpenChange={setAddDialogOpen}>
<DialogContent className="max-w-[520px] rounded-2xl p-0 gap-0 border-border overflow-hidden [&>button]:hidden">
{/* Header */}
<div className="flex items-center justify-between px-4 h-12">
<DialogTitle className="text-base font-semibold">
Connect NWC Wallet
</DialogTitle>
<button
onClick={() => setAddDialogOpen(false)}
className="p-1.5 -mr-1.5 rounded-full text-muted-foreground hover:text-foreground hover:bg-secondary/60 transition-colors"
>
<X className="size-5" />
</button>
</div>
{/* Description */}
<p className="px-4 -mt-1 mb-2 text-sm text-muted-foreground">
Paste a connection string from your NWC-compatible wallet.
</p>
{/* Form fields */}
<div className="px-4 space-y-4">
<Input
placeholder="Wallet name (optional)"
value={alias}
onChange={(e) => setAlias(e.target.value)}
className="bg-transparent"
/>
<Textarea
placeholder="nostr+walletconnect://..."
value={connectionUri}
onChange={(e) => setConnectionUri(e.target.value)}
rows={3}
className="bg-transparent resize-none"
/>
</div>
{/* Footer */}
<div className="flex items-center justify-end px-4 py-3">
<Button
onClick={handleAddConnection}
disabled={isConnecting || !connectionUri.trim()}
className="rounded-full px-5 font-bold"
size="sm"
>
{isConnecting ? 'Connecting...' : 'Connect'}
</Button>
</div>
</DialogContent>
</Dialog>
</>
);
}
+66 -34
View File
@@ -4,7 +4,6 @@
*/
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import {
Loader2,
Wallet,
@@ -45,16 +44,16 @@ interface CreateWalletProps {
type Step = "create" | "backup" | "confirm" | "lightning-address";
/** Get step configuration for progress indicator with translations */
function getSteps(t: (key: string) => string): { id: Step; label: string; shortLabel: string }[] {
/** Get step configuration for progress indicator */
function getSteps(): { id: Step; label: string; shortLabel: string }[] {
return [
{ id: "create", label: t('wallet2.createWallet'), shortLabel: t('auth.generateKey') },
{ id: "backup", label: t('walletSettings.backupTitle'), shortLabel: t('auth.downloadKey') },
{ id: "confirm", label: t('dialogs.confirmBackup'), shortLabel: t('common.confirm') },
{ id: "create", label: "Create Wallet", shortLabel: "Create" },
{ id: "backup", label: "Backup", shortLabel: "Backup" },
{ id: "confirm", label: "Confirm Backup", shortLabel: "Confirm" },
{
id: "lightning-address",
label: t('walletSettings.lightningTitle'),
shortLabel: t('wallet.address'),
label: "Lightning Address",
shortLabel: "Address",
},
];
}
@@ -128,8 +127,7 @@ function StepIndicator({ currentStep, steps }: { currentStep: Step; steps: Retur
}
export function CreateWallet({ onComplete, onCancel }: CreateWalletProps) {
const { t } = useTranslation();
const STEPS = getSteps(t);
const STEPS = getSteps();
const [step, setStep] = useState<Step>("create");
const [mnemonic, setMnemonic] = useState<string>("");
const [isCreating, setIsCreating] = useState(false);
@@ -151,6 +149,11 @@ export function CreateWallet({ onComplete, onCancel }: CreateWalletProps) {
const [registeredAddress, setRegisteredAddress] = useState<string | null>(
null,
);
// Opt-in: add this new address to the user's Nostr profile (kind 0 lud16).
// Off by default so we never silently modify the user's profile. If they
// already have a different lud16 in their profile, default stays off so
// we don't clobber it without an explicit choice.
const [linkToProfile, setLinkToProfile] = useState(false);
const {
createWallet,
@@ -241,8 +244,11 @@ export function CreateWallet({ onComplete, onCancel }: CreateWalletProps) {
);
setRegisteredAddress(address);
// Optionally update user's Nostr profile with the new Lightning address
if (user && metadata) {
// Only publish kind 0 when the user explicitly opted in. Silently
// modifying a user's Nostr profile is surprising behavior — they
// should be the ones to decide whether this address replaces any
// existing lud16 they may have configured elsewhere.
if (linkToProfile && user && metadata) {
try {
const updatedMetadata = { ...metadata, lud16: address };
// Clean up empty values
@@ -294,7 +300,7 @@ export function CreateWallet({ onComplete, onCancel }: CreateWalletProps) {
// Show loading while checking WASM support
if (wasmSupported === null) {
return (
<Card>
<Card className="rounded-2xl">
<CardContent className="py-12">
<div className="flex flex-col items-center justify-center space-y-4">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
@@ -318,13 +324,13 @@ export function CreateWallet({ onComplete, onCancel }: CreateWalletProps) {
}
return (
<Card>
<Card className="rounded-2xl">
<CardHeader className="text-center">
<StepIndicator currentStep={step} steps={STEPS} />
<div className="mx-auto w-12 h-12 bg-primary/10 rounded-full flex items-center justify-center mb-4">
<Wallet className="h-6 w-6 text-primary" />
</div>
<CardTitle>{t('wallet2.createWallet')}</CardTitle>
<CardTitle>Create Wallet</CardTitle>
<CardDescription>
Create a new self-custodial Lightning wallet
</CardDescription>
@@ -334,14 +340,14 @@ export function CreateWallet({ onComplete, onCancel }: CreateWalletProps) {
<div className="flex items-start gap-3">
<Shield className="h-5 w-5 text-primary mt-0.5" />
<div>
<p className="font-medium text-foreground">{t('wallet2.selfCustodial')}</p>
<p>{t('wallet2.selfCustodialDesc')}</p>
<p className="font-medium text-foreground">Self-custodial</p>
<p>Your keys, your coins. The Agora Wallet stores your seed on your device and never sends it to a server.</p>
</div>
</div>
<div className="flex items-start gap-3">
<Wallet className="h-5 w-5 text-blue-600 mt-0.5" />
<div>
<p className="font-medium text-foreground">{t('wallet2.instantPayments')}</p>
<p className="font-medium text-foreground">Instant payments</p>
<p>
Send and receive Lightning payments instantly with low fees.
</p>
@@ -363,10 +369,10 @@ export function CreateWallet({ onComplete, onCancel }: CreateWalletProps) {
{isCreating ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
{t('common.loading')}
Creating...
</>
) : (
t('wallet2.createWallet')
"Create Wallet"
)}
</Button>
</div>
@@ -377,7 +383,7 @@ export function CreateWallet({ onComplete, onCancel }: CreateWalletProps) {
if (step === "backup") {
return (
<Card>
<Card className="rounded-2xl">
<CardHeader className="text-center">
<StepIndicator currentStep={step} steps={STEPS} />
<CardTitle>Backup Your Wallet</CardTitle>
@@ -410,7 +416,7 @@ export function CreateWallet({ onComplete, onCancel }: CreateWalletProps) {
if (step === "confirm") {
return (
<Card>
<Card className="rounded-2xl">
<CardHeader className="text-center">
<StepIndicator currentStep={step} steps={STEPS} />
<CardTitle>Confirm Backup</CardTitle>
@@ -495,12 +501,12 @@ export function CreateWallet({ onComplete, onCancel }: CreateWalletProps) {
// Lightning Address step
return (
<Card>
<CardHeader className="text-center">
<StepIndicator currentStep={step} steps={STEPS} />
<div className="mx-auto w-12 h-12 bg-yellow-500/10 rounded-full flex items-center justify-center mb-4">
<Zap className="h-6 w-6 text-yellow-500" />
</div>
<Card className="rounded-2xl">
<CardHeader className="text-center">
<StepIndicator currentStep={step} steps={STEPS} />
<div className="mx-auto w-12 h-12 bg-destructive/10 rounded-full flex items-center justify-center mb-4">
<X className="h-6 w-6 text-destructive" />
</div>
<CardTitle>Set Up Lightning Address</CardTitle>
<CardDescription>
Get a Lightning address so others can easily send you payments
@@ -527,11 +533,12 @@ export function CreateWallet({ onComplete, onCancel }: CreateWalletProps) {
</div>
<p className="text-sm text-muted-foreground text-center">
This address has been automatically added to your Nostr profile.
Anyone can now send you Lightning payments using this address.
{linkToProfile
? "This address has been added to your Nostr profile. Anyone can now zap you here."
: "Your address is active. You can add it to your Nostr profile anytime from Wallet settings."}
</p>
<Button onClick={handleFinalComplete} className="w-full">
<Button onClick={handleFinalComplete} className="w-full rounded-full">
<Check className="h-4 w-4 mr-2" />
Complete Setup
</Button>
@@ -596,18 +603,43 @@ export function CreateWallet({ onComplete, onCancel }: CreateWalletProps) {
)}
</div>
<div className="flex gap-2 pt-4">
{/* Opt-in: add to Nostr profile */}
{user && (
<label className="flex items-start gap-3 text-sm cursor-pointer rounded-xl border p-3 hover:bg-muted/40 transition-colors">
<Checkbox
checked={linkToProfile}
onCheckedChange={(v) => setLinkToProfile(!!v)}
className="mt-0.5"
/>
<span className="text-muted-foreground leading-relaxed">
Also set this as my default Lightning address on my
Nostr profile so others can zap me here.
{metadata?.lud16 && metadata.lud16.length > 0 && (
<>
{" "}
<span className="text-foreground font-medium">
This will replace your current address (
{metadata.lud16}
).
</span>
</>
)}
</span>
</label>
)}
<div className="flex gap-2 pt-2">
<Button
variant="outline"
onClick={handleFinalComplete}
className="flex-1"
className="flex-1 rounded-full"
>
Skip for now
</Button>
<Button
onClick={handleRegisterAddress}
disabled={!usernameAvailable || isRegistering}
className="flex-1"
className="flex-1 rounded-full"
>
{isRegistering ? (
<>
@@ -1,90 +0,0 @@
/**
* Lock Timeout Settings Component
* Allows users to configure auto-lock timeout and manually lock the wallet
*/
import { Lock, Clock } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { useSparkWallet } from '@/hooks/useSparkWallet';
import type { LockTimeoutMinutes } from '@/lib/spark/types';
const TIMEOUT_OPTIONS: { value: LockTimeoutMinutes; label: string }[] = [
{ value: 0, label: 'Never (disabled)' },
{ value: 1, label: '1 minute' },
{ value: 5, label: '5 minutes' },
{ value: 15, label: '15 minutes' },
{ value: 30, label: '30 minutes' },
{ value: 60, label: '1 hour' },
];
interface LockTimeoutSettingsProps {
className?: string;
}
export function LockTimeoutSettings({ className }: LockTimeoutSettingsProps) {
const { lockTimeout, setLockTimeout, lockWallet, isInitialized } = useSparkWallet();
const handleTimeoutChange = (value: string) => {
const timeout = parseInt(value, 10) as LockTimeoutMinutes;
setLockTimeout(timeout);
};
return (
<div className={className}>
<div className="space-y-4">
{/* Auto-lock timeout setting */}
<div className="space-y-2">
<Label htmlFor="lock-timeout" className="flex items-center gap-2">
<Clock className="h-4 w-4" />
Auto-lock timeout
</Label>
<Select
value={lockTimeout.toString()}
onValueChange={handleTimeoutChange}
>
<SelectTrigger id="lock-timeout" className="w-full">
<SelectValue placeholder="Select timeout" />
</SelectTrigger>
<SelectContent>
{TIMEOUT_OPTIONS.map(option => (
<SelectItem key={option.value} value={option.value.toString()}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{lockTimeout === 0
? 'Your wallet will stay unlocked until you manually lock it or close the browser.'
: `Your wallet will automatically lock after ${lockTimeout} minute${lockTimeout > 1 ? 's' : ''} of inactivity.`}
</p>
</div>
{/* Manual lock button */}
{isInitialized && (
<div className="pt-2 border-t">
<Button
variant="outline"
onClick={lockWallet}
className="w-full"
>
<Lock className="h-4 w-4 mr-2" />
Lock Wallet Now
</Button>
<p className="text-xs text-muted-foreground mt-2">
Manually lock your wallet. You'll need to authenticate with your Nostr key to unlock.
</p>
</div>
)}
</div>
</div>
);
}
+233 -186
View File
@@ -1,37 +1,41 @@
/**
* Payment Detail Dialog
*
* Signal-style sheet that shows the full detail of a single wallet payment.
* Renders as a Drawer on mobile (fills the screen bottom-up) and as a
* rounded-2xl Dialog on desktop. Content is vertically scrollable inside
* the dialog shell so nothing gets cut off on short viewports.
*/
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { nip19 } from 'nostr-tools';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Drawer,
DrawerContent,
DrawerDescription,
DrawerHeader,
DrawerTitle,
} from '@/components/ui/drawer';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { ScrollArea } from '@/components/ui/scroll-area';
import {
Zap,
User,
Clock,
Hash,
FileText,
ExternalLink,
Copy,
ArrowDown,
ArrowLeft,
ArrowUp,
Check,
ArrowDownLeft,
ArrowUpRight,
Receipt,
Clock,
Copy,
ExternalLink,
FileText,
Fingerprint,
Hash,
Receipt,
User,
Zap,
} from 'lucide-react';
import { useEnrichedPayment } from '@/hooks/usePaymentContext';
import { useIsMobile } from '@/hooks/useIsMobile';
@@ -47,44 +51,61 @@ interface PaymentDetailDialogProps {
onOpenChange: (open: boolean) => void;
}
/**
* Normalize a timestamp to milliseconds. The Breez SDK sometimes hands back
* seconds and sometimes ms; treat anything < 1e11 (year ~5138 in seconds)
* as seconds and multiply.
*/
function tsMs(ts: number): number {
if (!Number.isFinite(ts) || ts <= 0) return Date.now();
return ts > 1e11 ? ts : ts * 1000;
}
function truncateMiddle(s: string, keep: number): string {
if (s.length <= keep * 2 + 3) return s;
return `${s.slice(0, keep)}...${s.slice(-keep)}`;
}
function DetailRow({
label,
value,
copyValue,
icon: Icon,
copyable = false,
className
}: {
label: string;
value: string | number;
copyValue?: string;
icon: React.ComponentType<{ className?: string }>;
copyable?: boolean;
className?: string;
}) {
const [copied, setCopied] = useState(false);
const { toast } = useToast();
const handleCopy = async () => {
await navigator.clipboard.writeText(copyValue ?? String(value));
setCopied(true);
toast({ title: 'Copied', description: `${label} copied to clipboard` });
setTimeout(() => setCopied(false), 2000);
try {
await navigator.clipboard.writeText(copyValue ?? String(value));
setCopied(true);
toast({ title: 'Copied', description: `${label} copied to clipboard` });
setTimeout(() => setCopied(false), 2000);
} catch {
// ignore
}
};
return (
<div className={cn("flex items-start justify-between gap-4 py-2", className)}>
<div className="flex items-center gap-2 text-muted-foreground min-w-0">
<div className="flex items-start justify-between gap-3 py-2.5">
<div className="flex items-center gap-2 text-muted-foreground shrink-0">
<Icon className="h-4 w-4 shrink-0" />
<span className="text-sm">{label}</span>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1 min-w-0 justify-end">
<span className="text-sm font-mono text-right break-all">{value}</span>
{copyable && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0"
className="h-6 w-6 shrink-0 rounded-full"
onClick={handleCopy}
>
{copied ? (
@@ -99,7 +120,11 @@ function DetailRow({
);
}
export function PaymentDetailDialog({ payment, open, onOpenChange }: PaymentDetailDialogProps) {
export function PaymentDetailDialog({
payment,
open,
onOpenChange,
}: PaymentDetailDialogProps) {
const isMobile = useIsMobile();
const { toast } = useToast();
const { context, author } = useEnrichedPayment(payment);
@@ -113,78 +138,99 @@ export function PaymentDetailDialog({ payment, open, onOpenChange }: PaymentDeta
const metadata = author?.metadata;
const zapRequest = context?.zapRequest;
// Generate target link
let targetLink: string | undefined;
let targetNip19: string | undefined;
if (isZap && targetEvent) {
targetNip19 = nip19.noteEncode(targetEvent.id);
targetLink = `/${targetNip19}`;
targetLink = `/${nip19.noteEncode(targetEvent.id)}`;
} else if (isZap && targetProfile) {
targetNip19 = nip19.npubEncode(targetProfile);
targetLink = `/${targetNip19}`;
targetLink = `/${nip19.npubEncode(targetProfile)}`;
}
const displayName = getDisplayName(metadata, targetProfile || targetEvent?.pubkey || '');
const displayName = getDisplayName(
metadata,
targetProfile || targetEvent?.pubkey || '',
);
const lightningAddress = metadata?.lud16 || metadata?.lud06;
const zapComment = zapRequest?.tags.find(
([name]) => name === 'comment',
)?.[1];
// Extract comment from zap request
const zapComment = zapRequest?.tags.find(([name]) => name === 'comment')?.[1];
const headerTitle = isReceived ? 'Received' : isZap ? 'Zapped' : 'Sent';
const paymentMs = tsMs(payment.timestamp);
const content = (
<ScrollArea className="max-h-[60vh] px-1">
<div className="space-y-6">
{/* Payment Type Header */}
<Card className={cn(
"border-2",
isReceived ? "bg-primary/5 dark:bg-primary/10 border-primary/30 dark:border-primary/40" : "bg-red-50 dark:bg-red-950/20 border-red-200 dark:border-red-900"
)}>
<CardContent className="pt-4 pb-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className={cn(
"w-12 h-12 rounded-full flex items-center justify-center",
isReceived ? "bg-primary/10 text-primary" : "bg-red-100 text-red-600"
)}>
{isReceived ? (
<ArrowDownLeft className="h-6 w-6" />
) : (
<ArrowUpRight className="h-6 w-6" />
)}
</div>
<div>
<p className="font-semibold text-lg">
{isReceived ? "Received" : isZap ? "Zapped" : "Sent"}
</p>
<p className="text-sm text-muted-foreground">
{format(payment.timestamp * 1000, 'MMM d, yyyy h:mm a')}
</p>
</div>
// Inner body shared by both Drawer and Dialog
const body = (
<div className="flex flex-col h-full min-h-0">
{/* Header */}
<div className="relative flex items-center justify-center px-4 py-3 border-b shrink-0">
<button
type="button"
onClick={() => onOpenChange(false)}
aria-label="Back"
className="absolute left-3 p-2 rounded-full text-muted-foreground hover:text-foreground hover:bg-secondary"
>
<ArrowLeft className="size-5" />
</button>
<h2 className="text-base font-semibold">Payment Details</h2>
</div>
{/* Scrollable content */}
<div className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden">
<div className="p-4 space-y-6">
{/* Summary */}
<div
className={cn(
'rounded-2xl p-4 flex items-center justify-between gap-3',
isReceived
? 'bg-green-500/10 border border-green-500/30'
: 'bg-red-500/10 border border-red-500/30',
)}
>
<div className="flex items-center gap-3 min-w-0">
<div
className={cn(
'size-12 rounded-full flex items-center justify-center shrink-0',
isReceived
? 'bg-green-500/20 text-green-500'
: 'bg-red-500/20 text-red-500',
)}
>
{isReceived ? (
<ArrowDown className="h-6 w-6" />
) : (
<ArrowUp className="h-6 w-6" />
)}
</div>
<div className="text-right">
<p className={cn(
"text-2xl font-bold",
isReceived ? "text-primary" : "text-red-600"
)}>
{isReceived ? "+" : "-"}{payment.amount.toLocaleString()}
<div className="min-w-0">
<p className="font-semibold text-lg">{headerTitle}</p>
<p className="text-xs text-muted-foreground">
{format(paymentMs, 'MMM d, yyyy h:mm a')}
</p>
<p className="text-xs text-muted-foreground">sats</p>
</div>
</div>
</CardContent>
</Card>
<div className="text-right shrink-0">
<p
className={cn(
'text-xl font-bold tabular-nums',
isReceived ? 'text-green-500' : 'text-red-500',
)}
>
{isReceived ? '+' : '-'}
{payment.amount.toLocaleString()}
</p>
<p className="text-xs text-muted-foreground">sats</p>
</div>
</div>
{/* Zap Target Information */}
{isZap && (targetEvent || targetProfile) && (
<div className="space-y-3">
<h3 className="font-semibold text-sm flex items-center gap-2">
<Zap className="h-4 w-4 text-yellow-500" />
Zap Target
</h3>
<Card>
<CardContent className="pt-4 pb-4">
<div className="flex items-center gap-3 mb-4">
<Avatar className="h-12 w-12">
{/* Zap target */}
{isZap && (targetEvent || targetProfile) && (
<section className="space-y-3">
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide px-1 flex items-center gap-2">
<Zap className="h-3.5 w-3.5 text-orange-500" />
Zap target
</h3>
<div className="rounded-2xl bg-muted/30 p-4 space-y-3">
<div className="flex items-center gap-3 min-w-0">
<Avatar className="h-12 w-12 shrink-0">
<AvatarImage src={metadata?.picture} />
<AvatarFallback>
<User className="h-6 w-6" />
@@ -206,43 +252,55 @@ export function PaymentDetailDialog({ payment, open, onOpenChange }: PaymentDeta
</div>
{targetLink && (
<Link to={targetLink}>
<Button variant="outline" className="w-full gap-2" size="sm">
<Button
asChild
variant="outline"
className="w-full rounded-full gap-2"
size="sm"
>
<Link to={targetLink}>
<ExternalLink className="h-4 w-4" />
View {targetEvent ? 'Post' : 'Profile'}
</Button>
</Link>
View {targetEvent ? 'post' : 'profile'}
</Link>
</Button>
)}
{zapComment && (
<div className="mt-3 p-3 bg-muted/50 rounded-md">
<p className="text-xs text-muted-foreground mb-1">Your message:</p>
<p className="text-sm italic">"{zapComment}"</p>
<div className="rounded-xl bg-background/40 p-3">
<p className="text-xs text-muted-foreground mb-1">
Your message
</p>
<p className="text-sm italic break-words">
"{zapComment}"
</p>
</div>
)}
{targetEvent && (
<div className="mt-3 p-3 bg-muted/50 rounded-md">
<p className="text-xs text-muted-foreground mb-1">Submission preview:</p>
<p className="text-sm line-clamp-3">{targetEvent.content}</p>
<div className="rounded-xl bg-background/40 p-3">
<p className="text-xs text-muted-foreground mb-1">
Post preview
</p>
<p className="text-sm line-clamp-3 break-words">
{targetEvent.content}
</p>
</div>
)}
</CardContent>
</Card>
</div>
)}
</div>
</section>
)}
{/* Technical Details */}
<div className="space-y-3">
<h3 className="font-semibold text-sm flex items-center gap-2">
<Receipt className="h-4 w-4" />
Technical Details
</h3>
<Card>
<CardContent className="pt-4 pb-4 space-y-0 divide-y">
{/* Technical details */}
<section className="space-y-2">
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide px-1 flex items-center gap-2">
<Receipt className="h-3.5 w-3.5" />
Technical details
</h3>
<div className="rounded-2xl bg-muted/30 px-4 divide-y divide-border/30">
<DetailRow
label="Payment ID"
value={payment.id}
value={truncateMiddle(payment.id, 12)}
copyValue={payment.id}
icon={Fingerprint}
copyable
/>
@@ -260,8 +318,8 @@ export function PaymentDetailDialog({ payment, open, onOpenChange }: PaymentDeta
)}
<DetailRow
label="Type"
value={payment.paymentType === 'receive' ? 'Received' : 'Sent'}
icon={isReceived ? ArrowDownLeft : ArrowUpRight}
value={isReceived ? 'Received' : 'Sent'}
icon={isReceived ? ArrowDown : ArrowUp}
/>
<DetailRow
label="Status"
@@ -270,13 +328,13 @@ export function PaymentDetailDialog({ payment, open, onOpenChange }: PaymentDeta
/>
<DetailRow
label="Timestamp"
value={format(payment.timestamp * 1000, 'PPpp')}
value={format(paymentMs, 'PPpp')}
icon={Clock}
/>
{payment.paymentHash && (
<DetailRow
label="Payment Hash"
value={payment.paymentHash.slice(0, 16) + '...' + payment.paymentHash.slice(-16)}
label="Payment hash"
value={truncateMiddle(payment.paymentHash, 10)}
copyValue={payment.paymentHash}
icon={Hash}
copyable
@@ -285,7 +343,7 @@ export function PaymentDetailDialog({ payment, open, onOpenChange }: PaymentDeta
{payment.preimage && (
<DetailRow
label="Preimage"
value={payment.preimage.slice(0, 16) + '...' + payment.preimage.slice(-16)}
value={truncateMiddle(payment.preimage, 10)}
copyValue={payment.preimage}
icon={Fingerprint}
copyable
@@ -298,109 +356,106 @@ export function PaymentDetailDialog({ payment, open, onOpenChange }: PaymentDeta
icon={FileText}
/>
)}
</CardContent>
</Card>
</div>
</div>
</section>
{/* Zap Request Details (if available) */}
{isZap && zapRequest && (
<div className="space-y-3">
<h3 className="font-semibold text-sm flex items-center gap-2">
<Zap className="h-4 w-4 text-yellow-500" />
Zap Request Details
</h3>
<Card>
<CardContent className="pt-4 pb-4 space-y-0 divide-y">
{/* Zap request details */}
{isZap && zapRequest && (
<section className="space-y-2">
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide px-1 flex items-center gap-2">
<Zap className="h-3.5 w-3.5 text-orange-500" />
Zap request
</h3>
<div className="rounded-2xl bg-muted/30 px-4 divide-y divide-border/30">
<DetailRow
label="Event ID"
value={zapRequest.id.slice(0, 16) + '...' + zapRequest.id.slice(-16)}
value={truncateMiddle(zapRequest.id, 10)}
copyValue={zapRequest.id}
icon={Hash}
copyable
/>
<DetailRow
label="Created At"
value={format(zapRequest.created_at * 1000, 'PPpp')}
label="Created at"
value={format(tsMs(zapRequest.created_at), 'PPpp')}
icon={Clock}
/>
<DetailRow
label="Sender"
value={nip19.npubEncode(zapRequest.pubkey).slice(0, 16) + '...'}
value={truncateMiddle(nip19.npubEncode(zapRequest.pubkey), 10)}
copyValue={nip19.npubEncode(zapRequest.pubkey)}
icon={User}
copyable
/>
{targetEvent && (
<DetailRow
label="Zapped Event"
value={targetEvent.id.slice(0, 16) + '...' + targetEvent.id.slice(-16)}
label="Zapped event"
value={truncateMiddle(targetEvent.id, 10)}
copyValue={targetEvent.id}
icon={FileText}
copyable
/>
)}
{targetProfile && (
<DetailRow
label="Zapped Profile"
value={nip19.npubEncode(targetProfile).slice(0, 16) + '...'}
label="Zapped profile"
value={truncateMiddle(
nip19.npubEncode(targetProfile),
10,
)}
copyValue={nip19.npubEncode(targetProfile)}
icon={User}
copyable
/>
)}
</CardContent>
</Card>
</div>
)}
</div>
</section>
)}
{/* Invoice (if available) */}
{payment.invoice && (
<div className="space-y-3">
<h3 className="font-semibold text-sm flex items-center gap-2">
<Receipt className="h-4 w-4" />
Lightning Invoice
</h3>
<Card>
<CardContent className="pt-4 pb-4">
{/* Invoice */}
{payment.invoice && (
<section className="space-y-2">
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide px-1 flex items-center gap-2">
<Receipt className="h-3.5 w-3.5" />
Lightning invoice
</h3>
<div className="rounded-2xl bg-muted/30 p-3">
<div className="relative">
<pre className="text-xs font-mono bg-muted p-3 rounded-md overflow-x-auto break-all whitespace-pre-wrap">
<pre className="text-[11px] font-mono bg-background/40 p-3 rounded-xl overflow-x-auto break-all whitespace-pre-wrap max-h-40 pr-10">
{payment.invoice}
</pre>
<Button
variant="ghost"
size="icon"
className="absolute top-2 right-2 h-6 w-6"
className="absolute top-1.5 right-1.5 h-7 w-7 rounded-full"
onClick={async () => {
await navigator.clipboard.writeText(payment.invoice!);
toast({
title: 'Copied',
description: 'Invoice copied to clipboard'
});
try {
await navigator.clipboard.writeText(payment.invoice!);
toast({
title: 'Copied',
description: 'Invoice copied to clipboard',
});
} catch {
// ignore
}
}}
>
<Copy className="h-3 w-3" />
<Copy className="h-3.5 w-3.5" />
</Button>
</div>
</CardContent>
</Card>
</div>
)}
</div>
</section>
)}
</div>
</div>
</ScrollArea>
</div>
);
if (isMobile) {
return (
<Drawer open={open} onOpenChange={onOpenChange}>
<DrawerContent className="max-h-[90vh]">
<DrawerHeader>
<DrawerTitle className="flex items-center gap-2">
<Receipt className="h-5 w-5" />
Payment Details
</DrawerTitle>
<DrawerDescription>
Transaction information
</DrawerDescription>
</DrawerHeader>
<div className="px-4 pb-6">
{content}
</div>
<DrawerContent className="h-[92vh] flex flex-col p-0">
<DrawerTitle className="sr-only">Payment Details</DrawerTitle>
<div className="flex-1 min-h-0 flex flex-col">{body}</div>
</DrawerContent>
</Drawer>
);
@@ -408,17 +463,9 @@ export function PaymentDetailDialog({ payment, open, onOpenChange }: PaymentDeta
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-2xl max-h-[90vh]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Receipt className="h-5 w-5" />
Payment Details
</DialogTitle>
<DialogDescription>
Transaction information
</DialogDescription>
</DialogHeader>
{content}
<DialogContent className="sm:max-w-lg h-[min(90vh,780px)] overflow-hidden rounded-2xl p-0 gap-0 [&>button]:hidden">
<DialogTitle className="sr-only">Payment Details</DialogTitle>
{body}
</DialogContent>
</Dialog>
);
+175 -261
View File
@@ -1,207 +1,115 @@
/**
* Payment History Component
* Displays list of recent payments
* Payment History
*
* Signal-style transaction list. Payments are grouped by day with a
* centered date separator ("today", "yesterday", or a localized date),
* each row rendered as a TransactionRow with category-colored icon,
* title + timestamp, optional subtitle, and right-aligned amount with
* a colored up/down arrow.
*
* Optionally filters out payments below a configurable sats threshold
* (config.walletHideBelowSats).
*/
import { useState } from 'react';
import { ArrowDownLeft, ArrowUpRight, Clock, Loader2, Zap, User } from "lucide-react";
import { Link } from 'react-router-dom';
import { nip19 } from 'nostr-tools';
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { useSparkWallet } from "@/hooks/useSparkWallet";
import { useEnrichedPayment } from "@/hooks/usePaymentContext";
import { Fragment, useMemo, useState } from 'react';
import { Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { useSparkWallet } from '@/hooks/useSparkWallet';
import { useEnrichedPayment } from '@/hooks/usePaymentContext';
import { useAppContext } from '@/hooks/useAppContext';
import { PaymentDetailDialog } from '@/components/SparkWallet/PaymentDetailDialog';
import { getDisplayName } from "@/lib/genUserName";
import { cn } from "@/lib/utils";
import type { BreezPaymentInfo } from "@/lib/spark/breezService";
import {
DateSeparator,
TransactionRow,
formatDateBucket,
type PaymentCategory,
} from '@/components/SparkWallet/primitives';
import { getDisplayName } from '@/lib/genUserName';
import type { BreezPaymentInfo } from '@/lib/spark/breezService';
import { cn } from '@/lib/utils';
interface PaymentHistoryProps {
/** Truncate to this many entries (no Load More). */
limit?: number;
className?: string;
}
function formatDate(timestamp: number): string {
const date = new Date(timestamp * 1000);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return "Just now";
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString();
/**
* Normalize a payment timestamp to a millisecond value. The Breez SDK
* sometimes returns seconds and sometimes milliseconds; treat anything
* after the year 2100 (in seconds) as already being in ms.
*/
function tsMs(payment: BreezPaymentInfo): number {
const t = Number(payment.timestamp);
if (!Number.isFinite(t) || t <= 0) return Date.now();
// Heuristic: timestamps > 4 trillion are clearly already ms.
return t > 1e11 ? t : t * 1000;
}
function PaymentItem({ payment, onClick }: { payment: BreezPaymentInfo; onClick: () => void }) {
const isReceived = payment.paymentType === "receive";
const amount = Number(payment.amount);
const timestamp = Number(payment.timestamp);
/**
* Wraps TransactionRow with zap-receipt enrichment from usePaymentContext,
* so outgoing zaps show the recipient's name instead of a generic
* "Lightning" label and use the orange zap icon.
*/
function EnrichedRow({
payment,
onClick,
}: {
payment: BreezPaymentInfo;
onClick: () => void;
}) {
const { context, author } = useEnrichedPayment(payment);
const isReceived = payment.paymentType === 'receive';
let category: PaymentCategory | undefined;
let title: string | undefined;
let subtitle: string | undefined;
const isZap = context?.isZap && !isReceived;
const targetEvent = context?.targetEvent;
const targetProfile = context?.targetProfile;
const zapRequest = context?.zapRequest;
const metadata = author?.metadata;
// Determine zap type based on target
let zapType = "Zapped";
let targetLink: string | undefined;
let targetLabel: string | undefined;
if (isZap) {
// Determine type by event kind or lack thereof
if (targetEvent) {
if (targetEvent.kind === 1111) {
// Check if this is a challenge submission (has k:36639 tag)
const isSubmission = targetEvent.tags.some(
([name, value]) => name === 'k' && value === '36639'
);
zapType = isSubmission ? "Zapped submission" : "Zapped post";
} else if (targetEvent.kind === 1) {
zapType = "Zapped post";
} else if (targetEvent.kind === 30023) {
zapType = "Zapped article";
} else {
zapType = "Zapped event";
}
// Link to the event
const noteId = nip19.noteEncode(targetEvent.id);
targetLink = `/${noteId}`;
targetLabel = getDisplayName(metadata, targetEvent.pubkey);
} else if (targetProfile) {
// Profile zap (no event)
zapType = "Zapped profile";
const npub = nip19.npubEncode(targetProfile);
targetLink = `/${npub}`;
targetLabel = getDisplayName(metadata, targetProfile);
} else if (zapRequest) {
// We have zap request but no target event loaded yet
// Try to determine type from 'k' tag in zap request
const kindTag = zapRequest.tags.find(([name]) => name === 'k')?.[1];
const hasEventTarget = zapRequest.tags.some(([name]) => name === 'e' || name === 'a');
if (kindTag) {
const kind = parseInt(kindTag);
if (kind === 1111) {
// Check if parent is a challenge (k:36639 tag)
const isSubmission = kindTag === '36639';
zapType = isSubmission ? "Zapped submission" : "Zapped post";
} else if (kind === 1) {
zapType = "Zapped post";
} else if (kind === 30023) {
zapType = "Zapped article";
} else if (kind >= 30000 && kind < 40000) {
zapType = "Zapped event";
} else {
zapType = "Zapped post";
}
} else if (!hasEventTarget) {
zapType = "Zapped profile";
}
category = 'zap';
if (context?.targetEvent) {
const kind = context.targetEvent.kind;
if (kind === 30023) title = 'Zapped article';
else if (kind === 1 || kind === 1111) title = 'Zapped post';
else title = 'Zapped event';
} else if (context?.targetProfile) {
title = 'Zapped profile';
} else {
title = 'Zap';
}
const targetPubkey =
context?.targetEvent?.pubkey || context?.targetProfile;
if (targetPubkey) {
subtitle = getDisplayName(author?.metadata, targetPubkey);
}
}
return (
<div
<TransactionRow
payment={payment}
category={category}
title={title}
subtitle={subtitle}
onClick={onClick}
className="flex items-center justify-between py-3 border-b last:border-0 cursor-pointer hover:bg-muted/50 -mx-2 px-2 rounded-md transition-colors"
>
<div className="flex items-center gap-3 min-w-0 flex-1">
<div
className={cn(
"w-8 h-8 rounded-full flex items-center justify-center shrink-0",
isReceived
? "bg-primary/10 text-primary"
: "bg-red-100 text-red-600",
)}
>
{isReceived ? (
<ArrowDownLeft className="h-4 w-4" />
) : (
<ArrowUpRight className="h-4 w-4" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className="font-medium text-sm">
{isReceived ? "Received" : isZap ? zapType : "Sent"}
</p>
{isZap && <Zap className="h-3 w-3 text-yellow-500" />}
</div>
{/* Show author/target info for zaps */}
{isZap && targetLink && (
<Link
to={targetLink}
className="flex items-center gap-1.5 mt-1 hover:underline max-w-full"
>
{metadata?.picture ? (
<Avatar className="h-4 w-4">
<AvatarImage src={metadata.picture} />
<AvatarFallback>
<User className="h-2 w-2" />
</AvatarFallback>
</Avatar>
) : (
<User className="h-3 w-3 text-muted-foreground" />
)}
<span className="text-xs text-muted-foreground truncate">
{targetLabel}
</span>
</Link>
)}
{/* Fallback: show timestamp if not a zap or still loading */}
{(!isZap || !targetLink) && (
<p className="text-xs text-muted-foreground flex items-center gap-1 mt-0.5">
<Clock className="h-3 w-3" />
{formatDate(timestamp)}
</p>
)}
</div>
</div>
<div className="text-right shrink-0 ml-3">
<p
className={cn(
"font-medium",
isReceived ? "text-primary" : "text-red-600",
)}
>
{isReceived ? "+" : "-"}
{amount.toLocaleString()} sats
</p>
{payment.status && payment.status !== 'completed' && (
<p className="text-xs text-muted-foreground capitalize">
{payment.status}
</p>
)}
</div>
</div>
/>
);
}
function PaymentSkeleton() {
function RowSkeleton() {
return (
<div className="flex items-center justify-between py-3 border-b">
<div className="flex items-center gap-3">
<Skeleton className="w-8 h-8 rounded-full" />
<div>
<Skeleton className="h-4 w-20 mb-1" />
<Skeleton className="h-3 w-16" />
</div>
<div className="flex items-center gap-3 py-3 px-1">
<Skeleton className="size-10 rounded-full shrink-0" />
<div className="flex-1 space-y-1.5">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-3 w-24" />
</div>
<div className="text-right">
<Skeleton className="h-4 w-24 mb-1" />
<Skeleton className="h-3 w-16 ml-auto" />
<div className="text-right space-y-1.5">
<Skeleton className="h-4 w-16 ml-auto" />
<Skeleton className="h-3 w-10 ml-auto" />
</div>
</div>
);
@@ -213,111 +121,117 @@ export function PaymentHistory({ limit, className }: PaymentHistoryProps) {
isLoadingPayments,
hasMorePayments,
loadMorePayments,
refreshPayments,
isInitialized,
} = useSparkWallet();
const { config } = useAppContext();
const [selectedPayment, setSelectedPayment] = useState<BreezPaymentInfo | null>(null);
const [selectedPayment, setSelectedPayment] = useState<BreezPaymentInfo | null>(
null,
);
const [detailDialogOpen, setDetailDialogOpen] = useState(false);
const hideBelow = config.walletHideBelowSats ?? 0;
// Filter + truncate + group by date bucket. Memoized so we don't reshape
// the list on every parent re-render.
const groups = useMemo(() => {
const filtered = payments.filter((p) => {
// Always show pending and failed payments regardless of threshold so
// the user can see things like "Sending..." or "Failed".
if (p.status !== 'completed') return true;
return Number(p.amount) >= hideBelow;
});
const trimmed = limit ? filtered.slice(0, limit) : filtered;
const buckets = new Map<string, BreezPaymentInfo[]>();
for (const payment of trimmed) {
const bucket = formatDateBucket(tsMs(payment));
const existing = buckets.get(bucket);
if (existing) {
existing.push(payment);
} else {
buckets.set(bucket, [payment]);
}
}
return Array.from(buckets, ([date, items]) => ({ date, items }));
}, [payments, limit, hideBelow]);
const handlePaymentClick = (payment: BreezPaymentInfo) => {
setSelectedPayment(payment);
setDetailDialogOpen(true);
};
// If limit is specified, only show that many; otherwise show all loaded payments
const displayPayments = limit ? payments.slice(0, limit) : payments;
if (!isInitialized) {
return (
<Card className={className}>
<CardContent className="py-6 text-center">
<p className="text-muted-foreground text-sm">
Connect wallet to view history
</p>
</CardContent>
</Card>
<div className={cn('py-8 text-center', className)}>
<p className="text-sm text-muted-foreground">
Connect wallet to view history
</p>
</div>
);
}
if (isLoadingPayments && payments.length === 0) {
return (
<div className={cn('space-y-0', className)}>
<RowSkeleton />
<RowSkeleton />
<RowSkeleton />
</div>
);
}
if (groups.length === 0) {
return (
<div className={cn('py-12 text-center', className)}>
<p className="text-sm text-muted-foreground">No transactions yet</p>
<p className="text-xs text-muted-foreground mt-1">
Your transaction history will appear here
</p>
</div>
);
}
return (
<Card className={className}>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-lg">Recent Transactions</CardTitle>
{!limit && displayPayments.length > 0 && (
<p className="text-xs text-muted-foreground">
Showing {displayPayments.length} payments
</p>
)}
</div>
<button
onClick={() => refreshPayments()}
disabled={isLoadingPayments}
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
{isLoadingPayments ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
"Refresh"
)}
</button>
</div>
</CardHeader>
<CardContent>
{isLoadingPayments && payments.length === 0 ? (
<div className="space-y-0">
{[1, 2, 3].map((i) => (
<PaymentSkeleton key={i} />
))}
</div>
) : displayPayments.length === 0 ? (
<div className="py-8 text-center">
<p className="text-muted-foreground text-sm">No payments yet</p>
<p className="text-xs text-muted-foreground mt-1">
Your transaction history will appear here
</p>
</div>
) : (
<>
<div className="space-y-0">
{displayPayments.map((payment) => (
<PaymentItem
key={payment.id}
payment={payment}
onClick={() => handlePaymentClick(payment)}
/>
))}
</div>
{/* Load More button - only show when not limiting and there are more payments */}
{!limit && hasMorePayments && (
<Button
variant="ghost"
onClick={loadMorePayments}
disabled={isLoadingPayments}
className="w-full mt-3 text-sm"
>
{isLoadingPayments ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Loading...
</>
) : (
"Load More"
)}
</Button>
)}
</>
)}
</CardContent>
<div className={cn('space-y-0', className)}>
{groups.map((group) => (
<Fragment key={group.date}>
<DateSeparator>{group.date}</DateSeparator>
{group.items.map((payment) => (
<EnrichedRow
key={payment.id}
payment={payment}
onClick={() => handlePaymentClick(payment)}
/>
))}
</Fragment>
))}
{/* Load More — only when not capped and the SDK reports more pages. */}
{!limit && hasMorePayments && (
<Button
variant="ghost"
onClick={loadMorePayments}
disabled={isLoadingPayments}
className="w-full mt-4 rounded-full text-sm"
>
{isLoadingPayments ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Loading...
</>
) : (
'Load More'
)}
</Button>
)}
{/* Payment Detail Dialog */}
<PaymentDetailDialog
payment={selectedPayment}
open={detailDialogOpen}
onOpenChange={setDetailDialogOpen}
/>
</Card>
</div>
);
}
+515 -297
View File
@@ -1,32 +1,46 @@
/**
* Receive Payment Component
* Shows QR codes and addresses for receiving payments
* Auto-detects when invoice is paid via SDK events
* Receive Payment
*
* Signal-style receive UI with two sub-tabs:
* - lightning: "Receive via Lightning" — prefers the registered
* Lightning Address (static QR + bolded address). An "ADD DETAILS"
* sheet lets the user generate a specific-amount bolt11 invoice.
* - bitcoin: "Receive On-chain" — on-chain Bitcoin address with
* COPY + ADD DETAILS pills.
*
* Header is owned by this component (dialog strips DialogHeader) and
* the bottom WalletTabBar has two circular tabs (lightning / bitcoin),
* matching the Signal screenshots.
*
* Auto-detects when a Lightning invoice is paid via SDK events.
*/
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
Copy,
Check,
Zap,
Bitcoin,
Loader2,
CheckCircle2,
RefreshCw,
ArrowLeft,
AtSign,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { useSparkWallet } from "@/hooks/useSparkWallet";
import { useToast } from "@/hooks/useToast";
import { Link } from "react-router-dom";
import QRCode from "qrcode";
import type { SdkEvent } from "@breeztech/breez-sdk-spark/web";
Bitcoin,
Check,
CheckCircle2,
Copy,
Loader2,
Plus,
RefreshCw,
Zap,
} from 'lucide-react';
import { Link } from 'react-router-dom';
import QRCode from 'qrcode';
import type { SdkEvent } from '@breeztech/breez-sdk-spark/web';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { useSparkWallet } from '@/hooks/useSparkWallet';
import { useToast } from '@/hooks/useToast';
import { WalletTabBar } from '@/components/SparkWallet/primitives';
import { cn } from '@/lib/utils';
type ReceiveTab = 'lightning' | 'bitcoin';
interface ReceivePaymentProps {
defaultAmount?: number;
@@ -37,18 +51,20 @@ export function ReceivePayment({
defaultAmount,
onClose,
}: ReceivePaymentProps) {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState("lightning");
const [amount, setAmount] = useState(defaultAmount?.toString() ?? "");
const [description, setDescription] = useState("");
const [tab, setTab] = useState<ReceiveTab>('lightning');
const [detailsOpen, setDetailsOpen] = useState(false);
const [amount, setAmount] = useState(defaultAmount?.toString() ?? '');
const [description, setDescription] = useState('');
const [invoice, setInvoice] = useState<string | null>(null);
const [bitcoinAddress, setBitcoinAddress] = useState<string | null>(null);
const [qrCodeUrl, setQrCodeUrl] = useState<string | null>(null);
const [isGenerating, setIsGenerating] = useState(false);
const [copied, setCopied] = useState(false);
const [isPaid, setIsPaid] = useState(false);
const [paidAmount, setPaidAmount] = useState<number | null>(null);
const [lightningQr, setLightningQr] = useState<string | null>(null);
const [bitcoinQr, setBitcoinQr] = useState<string | null>(null);
const {
createInvoice,
getBitcoinAddress,
@@ -60,324 +76,526 @@ export function ReceivePayment({
} = useSparkWallet();
const { toast } = useToast();
// Fetch addresses on mount
// Fetch addresses once the wallet is ready
useEffect(() => {
if (isInitialized) {
getBitcoinAddress().then(setBitcoinAddress).catch(console.error);
// Fetch lightning address if not already loaded
if (!lightningAddress) {
getLightningAddress();
}
if (!lightningAddress) getLightningAddress().catch(() => {});
}
}, [isInitialized, getBitcoinAddress, lightningAddress, getLightningAddress]);
// Subscribe to payment events to detect when invoice is paid
// Subscribe to payment events to detect when a Lightning invoice is paid
useEffect(() => {
if (!isInitialized || !invoice) return;
const handlePaymentEvent = (event: SdkEvent) => {
console.log("[ReceivePayment] Got SDK event:", event.type);
if (event.type === "paymentSucceeded") {
// A payment was received! Check if it matches our invoice amount
const receivedAmount = parseInt(amount);
// Show paid state
const handleEvent = (event: SdkEvent) => {
if (event.type === 'paymentSucceeded') {
const received = parseInt(amount, 10) || null;
setIsPaid(true);
setPaidAmount(receivedAmount);
// Refresh balance
setPaidAmount(received);
refreshBalance();
// Show success toast
toast({
title: "Payment received!",
description: `You received ${receivedAmount.toLocaleString()} sats`,
title: 'Payment received!',
description: received
? `You received ${received.toLocaleString()} sats`
: 'You received a payment',
});
}
};
const unsubscribe = subscribeToPaymentEvents(handleEvent);
return () => unsubscribe();
}, [isInitialized, invoice, amount, subscribeToPaymentEvents, refreshBalance, toast]);
const unsubscribe = subscribeToPaymentEvents(handlePaymentEvent);
// Generate QR codes for the Lightning target and Bitcoin address
const lightningPayload = invoice ?? lightningAddress ?? null;
return () => {
unsubscribe();
};
}, [
isInitialized,
invoice,
amount,
subscribeToPaymentEvents,
refreshBalance,
toast,
]);
// Generate QR code when content changes
useEffect(() => {
const generateQR = async () => {
let content: string | null = null;
if (activeTab === "lightning" && invoice) {
content = invoice;
} else if (activeTab === "lnaddress" && lightningAddress) {
content = lightningAddress;
} else if (activeTab === "bitcoin" && bitcoinAddress) {
content = bitcoinAddress;
let cancelled = false;
(async () => {
if (!lightningPayload) {
setLightningQr(null);
return;
}
if (content) {
try {
const url = await QRCode.toDataURL(content.toUpperCase(), {
width: 256,
try {
const url = await QRCode.toDataURL(
// Static Lightning Address QR: use `lightning:` URI so wallets
// parse it as an LNURL-pay target. Dynamic bolt11 invoices
// already include their own scheme.
invoice
? invoice.toUpperCase()
: `lightning:${lightningPayload}`.toUpperCase(),
{
width: 512,
margin: 2,
color: { dark: "#000000", light: "#FFFFFF" },
});
setQrCodeUrl(url);
} catch (error) {
console.error("Failed to generate QR code:", error);
}
} else {
setQrCodeUrl(null);
color: { dark: '#000000', light: '#FFFFFF' },
},
);
if (!cancelled) setLightningQr(url);
} catch (error) {
console.error('Failed to generate Lightning QR:', error);
}
})();
return () => {
cancelled = true;
};
}, [lightningPayload, invoice]);
generateQR();
}, [activeTab, invoice, lightningAddress, bitcoinAddress]);
useEffect(() => {
let cancelled = false;
(async () => {
if (!bitcoinAddress) {
setBitcoinQr(null);
return;
}
try {
const url = await QRCode.toDataURL(
`bitcoin:${bitcoinAddress}`.toUpperCase(),
{
width: 512,
margin: 2,
color: { dark: '#000000', light: '#FFFFFF' },
},
);
if (!cancelled) setBitcoinQr(url);
} catch (error) {
console.error('Failed to generate Bitcoin QR:', error);
}
})();
return () => {
cancelled = true;
};
}, [bitcoinAddress]);
const handleGenerateInvoice = async () => {
const amountSat = parseInt(amount);
const amountSat = parseInt(amount, 10);
if (!amountSat || amountSat <= 0) {
toast({
title: "Invalid amount",
description: "Please enter a valid amount in sats",
variant: "destructive",
title: 'Invalid amount',
description: 'Please enter a valid amount in sats',
variant: 'destructive',
});
return;
}
setIsGenerating(true);
try {
const newInvoice = await createInvoice(
amountSat,
description || "Payment",
description || 'Payment',
);
setInvoice(newInvoice);
setDetailsOpen(false);
} catch (error) {
toast({
title: "Failed to create invoice",
description: error instanceof Error ? error.message : "Unknown error",
variant: "destructive",
title: 'Failed to create invoice',
description: error instanceof Error ? error.message : 'Unknown error',
variant: 'destructive',
});
} finally {
setIsGenerating(false);
}
};
const handleCopy = async (text: string) => {
await navigator.clipboard.writeText(text);
setCopied(true);
toast({ title: "Copied to clipboard" });
setTimeout(() => setCopied(false), 2000);
};
const handleCopy = useCallback(
async (text: string) => {
try {
await navigator.clipboard.writeText(text);
setCopied(true);
toast({ title: 'Copied to clipboard' });
setTimeout(() => setCopied(false), 2000);
} catch {
toast({
title: 'Could not copy',
description: 'Your browser blocked clipboard access.',
variant: 'destructive',
});
}
},
[toast],
);
const getCurrentAddress = () => {
if (activeTab === "lightning") return invoice;
if (activeTab === "lnaddress") return lightningAddress;
if (activeTab === "bitcoin") return bitcoinAddress;
return null;
};
const title = useMemo(() => {
if (tab === 'lightning') return 'Receive via Lightning';
return 'Receive On-chain';
}, [tab]);
const currentAddress = getCurrentAddress();
const tabs: { id: ReceiveTab; icon: React.ReactNode; label: string }[] = [
{ id: 'lightning', icon: <Zap />, label: 'Lightning' },
{ id: 'bitcoin', icon: <Bitcoin />, label: 'Bitcoin' },
];
return (
<div className="space-y-4">
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="lightning">
<Zap className="h-4 w-4 mr-1" />
Invoice
</TabsTrigger>
<TabsTrigger value="lnaddress">
<AtSign className="h-4 w-4 mr-1" />
Address
</TabsTrigger>
<TabsTrigger value="bitcoin">
<Bitcoin className="h-4 w-4 mr-1" />
Bitcoin
</TabsTrigger>
</TabsList>
<div className="flex flex-col h-[min(90vh,720px)] max-h-[720px] bg-background">
{/* Header */}
<div className="relative flex items-center justify-center px-4 py-3 border-b">
<button
type="button"
onClick={onClose}
aria-label="Back"
className="absolute left-3 p-2 rounded-full text-muted-foreground hover:text-foreground hover:bg-secondary"
>
<ArrowLeft className="size-5" />
</button>
<h2 className="text-base font-semibold truncate max-w-[70%]">{title}</h2>
</div>
<TabsContent value="lightning" className="space-y-4 mt-4">
{isPaid ? (
// Payment received - show success state
<div className="space-y-4">
<div className="text-center py-8">
<div className="flex justify-center mb-4">
<div className="rounded-full bg-primary/10 p-4">
<CheckCircle2 className="h-12 w-12 text-primary" />
</div>
</div>
<h3 className="text-xl font-bold text-primary mb-2">
Payment Received!
</h3>
<p className="text-3xl font-bold mb-1">
{(paidAmount ?? parseInt(amount)).toLocaleString()} sats
</p>
{description && (
<p className="text-muted-foreground text-sm">{description}</p>
)}
</div>
<Button
onClick={() => {
setIsPaid(false);
setPaidAmount(null);
setInvoice(null);
setAmount("");
setDescription("");
}}
className="w-full"
>
<RefreshCw className="h-4 w-4 mr-2" />
Receive Another Payment
</Button>
</div>
) : !invoice ? (
<div className="space-y-4">
<div>
<Label htmlFor="amount">{t('wallet2.amount')} (sats)</Label>
<Input
id="amount"
type="number"
value={amount}
onChange={(e) => setAmount(e.target.value)}
onWheel={(e) => (e.target as HTMLInputElement).blur()}
placeholder={t('forms.enterAmount')}
/>
</div>
<div>
<Label htmlFor="description">Description (optional)</Label>
<Input
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="What's this payment for?"
/>
</div>
<Button
onClick={handleGenerateInvoice}
disabled={isGenerating}
className="w-full"
>
{isGenerating ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Generating...
</>
) : (
t('wallet2.generateInvoice')
)}
</Button>
</div>
) : (
<div className="space-y-4">
<div className="text-center">
<p className="text-sm text-muted-foreground mb-1">
Waiting for payment...
</p>
<p className="text-2xl font-bold">
{parseInt(amount).toLocaleString()} sats
</p>
</div>
<Button
variant="outline"
onClick={() => setInvoice(null)}
className="w-full"
>
Create New Invoice
</Button>
</div>
)}
</TabsContent>
{/* Content */}
<div className="flex-1 overflow-y-auto overflow-x-hidden min-w-0">
{tab === 'lightning' ? (
<LightningTab
lightningAddress={lightningAddress}
invoice={invoice}
isPaid={isPaid}
paidAmount={paidAmount ?? (parseInt(amount, 10) || null)}
qr={lightningQr}
onCopy={handleCopy}
copied={copied}
onAddDetails={() => setDetailsOpen(true)}
onClearInvoice={() => {
setInvoice(null);
setAmount('');
setDescription('');
}}
onReceiveAnother={() => {
setIsPaid(false);
setPaidAmount(null);
setInvoice(null);
setAmount('');
setDescription('');
}}
/>
) : (
<BitcoinTab
bitcoinAddress={bitcoinAddress}
qr={bitcoinQr}
onCopy={handleCopy}
copied={copied}
/>
)}
</div>
<TabsContent value="lnaddress" className="mt-4">
{lightningAddress ? (
<p className="text-sm text-muted-foreground text-center mb-4">
Share your Lightning Address to receive payments
</p>
) : (
<div className="space-y-4">
<Alert>
<AtSign className="h-4 w-4" />
<AlertDescription>
You don't have a Lightning Address yet. Set one up in wallet
settings to receive payments easily.
</AlertDescription>
</Alert>
<Link to="/settings?tab=wallet">
<Button variant="outline" className="w-full">
Set Up Lightning Address
</Button>
</Link>
</div>
)}
</TabsContent>
{/* Footer tab bar */}
<WalletTabBar<ReceiveTab> tabs={tabs} active={tab} onChange={setTab} />
<TabsContent value="bitcoin" className="mt-4">
<p className="text-sm text-muted-foreground text-center mb-4">
Receive on-chain Bitcoin (may take longer to confirm)
</p>
</TabsContent>
</Tabs>
{/* QR Code Display - hide when paid */}
{!isPaid && (currentAddress || qrCodeUrl) && (
<Card>
<CardContent className="pt-4">
{qrCodeUrl ? (
<div className="flex justify-center">
<img src={qrCodeUrl} alt="QR Code" className="w-48 h-48" />
</div>
) : (
<div className="flex justify-center">
<div className="w-48 h-48 bg-muted animate-pulse rounded" />
</div>
)}
{currentAddress && (
<div className="mt-4 space-y-2">
<Input
value={currentAddress}
readOnly
className="font-mono text-xs"
onClick={(e) => e.currentTarget.select()}
/>
<Button
variant="outline"
onClick={() => handleCopy(currentAddress)}
className="w-full"
>
{copied ? (
<>
<Check className="h-4 w-4 mr-2 text-primary" />
Copied
</>
) : (
<>
<Copy className="h-4 w-4 mr-2" />
Copy Address
</>
)}
</Button>
</div>
)}
</CardContent>
</Card>
)}
{onClose && (
<Button variant="ghost" onClick={onClose} className="w-full">
Close
</Button>
)}
{/* ADD DETAILS sheet — amount + description → generate bolt11 */}
<DetailsSheet
open={detailsOpen}
onOpenChange={setDetailsOpen}
amount={amount}
setAmount={setAmount}
description={description}
setDescription={setDescription}
isGenerating={isGenerating}
onGenerate={handleGenerateInvoice}
/>
</div>
);
}
// ─── Lightning tab ────────────────────────────────────────────────────
function LightningTab({
lightningAddress,
invoice,
isPaid,
paidAmount,
qr,
onCopy,
copied,
onAddDetails,
onClearInvoice,
onReceiveAnother,
}: {
lightningAddress: string | null;
invoice: string | null;
isPaid: boolean;
paidAmount: number | null;
qr: string | null;
onCopy: (text: string) => void;
copied: boolean;
onAddDetails: () => void;
onClearInvoice: () => void;
onReceiveAnother: () => void;
}) {
// 1. Paid state
if (isPaid) {
return (
<div className="flex flex-col items-center justify-center py-10 px-6 text-center space-y-4">
<div className="rounded-full bg-primary/10 p-4">
<CheckCircle2 className="size-12 text-primary" />
</div>
<h3 className="text-xl font-bold text-primary">Payment received!</h3>
{paidAmount !== null && (
<p className="text-3xl font-bold tabular-nums">
{paidAmount.toLocaleString()}{' '}
<span className="text-base font-normal text-muted-foreground">
sats
</span>
</p>
)}
<Button onClick={onReceiveAnother} className="rounded-full h-11 mt-2">
<RefreshCw className="size-4 mr-2" />
Receive another
</Button>
</div>
);
}
const activeTarget = invoice ?? lightningAddress;
// 2. No LN address and no invoice — show a CTA to register + fallback
// invoice generation via ADD DETAILS.
if (!activeTarget) {
return (
<div className="py-10 px-6 text-center space-y-4">
<div className="rounded-full bg-muted p-4 mx-auto w-fit">
<AtSign className="size-8 text-muted-foreground" />
</div>
<p className="text-sm text-muted-foreground max-w-xs mx-auto">
You don't have a Lightning Address yet. Register one in wallet
settings or generate a one-time invoice.
</p>
<div className="flex flex-col items-center gap-2">
<Button asChild className="rounded-full h-11 px-5">
<Link to="/settings/wallet">Register Lightning Address</Link>
</Button>
<Button
variant="outline"
onClick={onAddDetails}
className="rounded-full h-11 px-5"
>
<Plus className="size-4 mr-2" /> Generate invoice
</Button>
</div>
</div>
);
}
const isInvoice = !!invoice;
const label = isInvoice ? 'Lightning invoice:' : 'Your Lightning Address:';
// Always truncate in the middle so Lightning Addresses (short) display in
// full while long bolt11 invoices or LNURLs get collapsed to first/last 18
// chars. truncateMiddle returns the string as-is when it's already short.
const addressDisplay = truncateMiddle(activeTarget, 18);
return (
<div className="flex flex-col items-center gap-6 py-8 px-6 w-full min-w-0">
{/* QR */}
<div
className={cn(
'rounded-2xl bg-white p-3 flex items-center justify-center',
'size-72 max-w-full',
)}
>
{qr ? (
<img
src={qr}
alt="Lightning QR code"
className="w-full h-full object-contain"
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
)}
</div>
{/* Address / invoice label + value. The value line uses min-w-0 +
overflow-hidden + truncate so it can never push the flex column
wider than the modal, even for pathological multi-hundred-char
LNURL strings. */}
<div className="text-center space-y-1 w-full max-w-[288px] min-w-0">
<p className="text-sm text-muted-foreground">{label}</p>
<p className="font-semibold leading-snug truncate">{addressDisplay}</p>
</div>
{/* Action pills — constrained to the same width as the QR so they never
spill outside the visual "box" on narrow screens. */}
<div className="flex items-center justify-center gap-3 w-full max-w-[288px]">
<Button
variant="secondary"
onClick={() => onCopy(activeTarget)}
className="flex-1 rounded-full h-11 px-4 bg-muted/60 hover:bg-muted uppercase tracking-wider text-xs font-semibold"
>
{copied ? (
<>
<Check className="size-4 mr-1.5" /> Copied
</>
) : (
<>
<Copy className="size-4 mr-1.5" /> Copy
</>
)}
</Button>
{isInvoice ? (
<Button
variant="secondary"
onClick={onClearInvoice}
className="flex-1 rounded-full h-11 px-4 bg-muted/60 hover:bg-muted uppercase tracking-wider text-xs font-semibold"
>
<RefreshCw className="size-4 mr-1.5" /> New
</Button>
) : (
<Button
variant="secondary"
onClick={onAddDetails}
className="flex-1 rounded-full h-11 px-4 bg-muted/60 hover:bg-muted uppercase tracking-wider text-xs font-semibold"
>
Add details
</Button>
)}
</div>
</div>
);
}
// ─── Bitcoin tab ──────────────────────────────────────────────────────
function BitcoinTab({
bitcoinAddress,
qr,
onCopy,
copied,
}: {
bitcoinAddress: string | null;
qr: string | null;
onCopy: (text: string) => void;
copied: boolean;
}) {
if (!bitcoinAddress) {
return (
<div className="py-10 text-center">
<Loader2 className="size-5 mx-auto animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="flex flex-col items-center gap-6 py-8 px-6 w-full min-w-0">
<div className="rounded-2xl bg-white p-3 flex items-center justify-center size-72 max-w-full">
{qr ? (
<img
src={qr}
alt="Bitcoin QR code"
className="w-full h-full object-contain"
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
)}
</div>
<div className="text-center space-y-1 w-full max-w-[288px] min-w-0">
<p className="text-sm text-muted-foreground">Your Bitcoin Address:</p>
<p className="font-semibold leading-snug truncate">
{truncateMiddle(bitcoinAddress, 12)}
</p>
</div>
<div className="flex items-center justify-center gap-3 w-full max-w-[288px]">
<Button
variant="secondary"
onClick={() => onCopy(bitcoinAddress)}
className="flex-1 rounded-full h-11 px-4 bg-muted/60 hover:bg-muted uppercase tracking-wider text-xs font-semibold"
>
{copied ? (
<>
<Check className="size-4 mr-1.5" /> Copied
</>
) : (
<>
<Copy className="size-4 mr-1.5" /> Copy
</>
)}
</Button>
</div>
</div>
);
}
// ─── ADD DETAILS sheet (inline dialog inside the receive dialog) ─────
function DetailsSheet({
open,
onOpenChange,
amount,
setAmount,
description,
setDescription,
isGenerating,
onGenerate,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
amount: string;
setAmount: (v: string) => void;
description: string;
setDescription: (v: string) => void;
isGenerating: boolean;
onGenerate: () => void;
}) {
if (!open) return null;
return (
<div className="absolute inset-0 bg-background flex flex-col animate-in fade-in duration-150">
{/* Header */}
<div className="relative flex items-center justify-center px-4 py-3 border-b">
<button
type="button"
onClick={() => onOpenChange(false)}
aria-label="Back"
className="absolute left-3 p-2 rounded-full text-muted-foreground hover:text-foreground hover:bg-secondary"
>
<ArrowLeft className="size-5" />
</button>
<h2 className="text-base font-semibold">Add details</h2>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-4">
<div className="space-y-2">
<Label htmlFor="receive-amount">Amount (sats)</Label>
<Input
id="receive-amount"
type="number"
inputMode="numeric"
value={amount}
onChange={(e) => setAmount(e.target.value)}
onWheel={(e) => (e.target as HTMLInputElement).blur()}
placeholder="0"
className="rounded-full h-12 text-lg text-center bg-muted/40 border-transparent"
/>
</div>
<div className="space-y-2">
<Label htmlFor="receive-description">Description (optional)</Label>
<Textarea
id="receive-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="What's this for?"
rows={2}
className="rounded-2xl resize-none bg-muted/40 border-transparent"
/>
</div>
<Button
onClick={onGenerate}
disabled={isGenerating || !amount.trim()}
className="w-full rounded-full h-12"
>
{isGenerating ? (
<>
<Loader2 className="size-4 mr-2 animate-spin" />
Generating...
</>
) : (
'Generate invoice'
)}
</Button>
</div>
</div>
);
}
// ─── helpers ──────────────────────────────────────────────────────────
function truncateMiddle(s: string, keep: number): string {
if (s.length <= keep * 2 + 3) return s;
return `${s.slice(0, keep)}...${s.slice(-keep)}`;
}
+7 -7
View File
@@ -224,7 +224,7 @@ export function RestoreWallet({ onComplete, onCancel }: RestoreWalletProps) {
// Show loading while checking WASM support
if (wasmSupported === null) {
return (
<Card>
<Card className="rounded-2xl">
<CardContent className="py-12">
<div className="flex flex-col items-center justify-center space-y-4">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
@@ -248,11 +248,11 @@ export function RestoreWallet({ onComplete, onCancel }: RestoreWalletProps) {
}
return (
<Card>
<Card className="rounded-2xl">
<CardHeader className="text-center">
<CardTitle>Restore Wallet</CardTitle>
<CardDescription>
Restore your existing Spark wallet using one of the methods below.
Restore your existing Agora Wallet using one of the methods below.
</CardDescription>
</CardHeader>
<CardContent>
@@ -317,7 +317,7 @@ export function RestoreWallet({ onComplete, onCancel }: RestoreWalletProps) {
isRateLimited ||
mnemonic.split(/\s+/).filter((w) => w).length !== 12
}
className="w-full"
className="w-full rounded-full h-12"
>
{isRestoring ? (
<>
@@ -370,7 +370,7 @@ export function RestoreWallet({ onComplete, onCancel }: RestoreWalletProps) {
<Button
onClick={handleRestoreFromRelay}
disabled={isRestoring || !hasBackup}
className="w-full"
className="w-full rounded-full h-12"
>
{isRestoring ? (
<>
@@ -419,7 +419,7 @@ export function RestoreWallet({ onComplete, onCancel }: RestoreWalletProps) {
<Button
onClick={() => fileInputRef.current?.click()}
disabled={isRestoring}
className="w-full"
className="w-full rounded-full h-12"
>
{isRestoring ? (
<>
@@ -439,7 +439,7 @@ export function RestoreWallet({ onComplete, onCancel }: RestoreWalletProps) {
</Tabs>
{onCancel && (
<Button variant="ghost" onClick={onCancel} className="w-full mt-4">
<Button variant="ghost" onClick={onCancel} className="w-full mt-4 rounded-full">
Cancel
</Button>
)}
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -75,7 +75,7 @@ export function WalletBalance({
if (isConnecting) {
return (
<Card className={className}>
<Card className={cn("rounded-2xl", className)}>
<CardContent className={compact ? "py-3" : "py-6"}>
<Skeleton className="h-8 w-32 mx-auto" />
<Skeleton className="h-4 w-20 mx-auto mt-2" />
@@ -86,7 +86,7 @@ export function WalletBalance({
if (!isInitialized) {
return (
<Card className={className}>
<Card className={cn("rounded-2xl", className)}>
<CardContent className={cn("text-center", compact ? "py-3" : "py-6")}>
<p className="text-muted-foreground text-sm">Wallet not connected</p>
</CardContent>
@@ -134,7 +134,7 @@ export function WalletBalance({
}
return (
<Card className={className}>
<Card className={cn("rounded-2xl bg-primary/5 border-primary/20", className)}>
<CardContent className="py-6">
<div className="text-center">
{/* Main balance display */}
@@ -1,87 +0,0 @@
/**
* Wallet Lock Screen Component
* Displays when the wallet is locked and allows unlocking via Nostr signer
*/
import { useState } from 'react';
import { Lock, Unlock, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { useSparkWallet } from '@/hooks/useSparkWallet';
import { useCurrentUser } from '@/hooks/useCurrentUser';
interface WalletLockScreenProps {
className?: string;
}
export function WalletLockScreen({ className }: WalletLockScreenProps) {
const [isUnlocking, setIsUnlocking] = useState(false);
const [error, setError] = useState<string | null>(null);
const { unlockWallet } = useSparkWallet();
const { user } = useCurrentUser();
const handleUnlock = async () => {
if (!user) {
setError('You must be logged in to unlock your wallet.');
return;
}
setIsUnlocking(true);
setError(null);
try {
await unlockWallet();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to unlock wallet');
} finally {
setIsUnlocking(false);
}
};
return (
<Card className={className}>
<CardHeader className="text-center">
<div className="mx-auto mb-4 h-16 w-16 rounded-full bg-muted flex items-center justify-center">
<Lock className="h-8 w-8 text-muted-foreground" />
</div>
<CardTitle>Wallet Locked</CardTitle>
<CardDescription>
Your wallet has been locked for security.
{!user && ' Please log in to unlock.'}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<Button
onClick={handleUnlock}
disabled={isUnlocking || !user}
className="w-full"
size="lg"
>
{isUnlocking ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Unlocking...
</>
) : (
<>
<Unlock className="h-4 w-4 mr-2" />
Unlock Wallet
</>
)}
</Button>
<p className="text-xs text-center text-muted-foreground">
Unlocking will decrypt your wallet using your Nostr key.
</p>
</CardContent>
</Card>
);
}
@@ -99,7 +99,7 @@ export function WasmUnsupportedError({
}
return (
<Card>
<Card className="rounded-2xl">
<CardHeader className="text-center">
<div className="mx-auto w-12 h-12 bg-destructive/10 rounded-full flex items-center justify-center mb-4">
<AlertTriangle className="h-6 w-6 text-destructive" />
@@ -0,0 +1,57 @@
/**
* CircleAction
*
* Large fully-circular action button with a centered icon and an uppercase
* label below. Used on the wallet dashboard for SEND / SCAN / RECEIVE,
* mirroring the Signal-style wallet UI.
*/
import type { ReactNode } from 'react';
import { cn } from '@/lib/utils';
interface CircleActionProps {
icon: ReactNode;
label: string;
onClick?: () => void;
disabled?: boolean;
/** Optional extra classes on the outer wrapper. */
className?: string;
title?: string;
}
export function CircleAction({
icon,
label,
onClick,
disabled,
className,
title,
}: CircleActionProps) {
return (
<div className={cn('flex flex-col items-center gap-2', className)}>
<button
type="button"
onClick={onClick}
disabled={disabled}
title={title ?? label}
aria-label={label}
className={cn(
'flex size-16 items-center justify-center rounded-full transition-colors',
'bg-muted/60 text-foreground hover:bg-muted',
'active:scale-95 transition-transform',
'disabled:opacity-40 disabled:pointer-events-none',
)}
>
<span className="[&>svg]:size-6">{icon}</span>
</button>
<span
className={cn(
'text-xs font-medium tracking-wider uppercase',
disabled ? 'text-muted-foreground/50' : 'text-muted-foreground',
)}
>
{label}
</span>
</div>
);
}
@@ -0,0 +1,33 @@
/**
* DateSeparator
*
* Centered text label flanked by horizontal rules. Used between groups of
* transactions in PaymentHistory to mark "today", "yesterday", or older
* date headings.
*
* The date-bucket formatting helper lives in `src/lib/spark/formatDateBucket.ts`
* so this file can export only React components (keeps Fast Refresh happy).
*/
import type { ReactNode } from 'react';
import { cn } from '@/lib/utils';
interface DateSeparatorProps {
children: ReactNode;
className?: string;
}
export function DateSeparator({ children, className }: DateSeparatorProps) {
return (
<div
className={cn(
'flex items-center gap-3 py-3 px-1 text-xs text-muted-foreground',
className,
)}
>
<div className="h-px flex-1 bg-border/40" />
<span className="shrink-0">{children}</span>
<div className="h-px flex-1 bg-border/40" />
</div>
);
}
@@ -0,0 +1,137 @@
/**
* SettingsRow
*
* Flat list row used throughout the wallet settings page. Bold title,
* optional muted description below, and a right-side affordance (chevron
* for navigation, custom icon for actions, switch for toggles, or arbitrary
* `right` content). Mirrors the Signal-style settings list.
*/
import type { ReactNode } from 'react';
import { ChevronRight } from 'lucide-react';
import { Switch } from '@/components/ui/switch';
import { cn } from '@/lib/utils';
interface SettingsRowProps {
/** Bold title (top line). */
title: ReactNode;
/** Optional muted description shown below the title. */
description?: ReactNode;
/** Optional value chip shown to the right of the title (small bold text). */
value?: ReactNode;
/** Click handler. When set and no other right slot is provided, a chevron renders. */
onClick?: () => void;
/** Custom icon to render on the right (replaces chevron). */
icon?: ReactNode;
/** Render a switch on the right. Mutually exclusive with icon. */
toggle?: { value: boolean; onChange: (value: boolean) => void; disabled?: boolean };
/** Arbitrary right-side content. Highest priority. */
right?: ReactNode;
/** Render in a destructive style (red title + icon). */
destructive?: boolean;
disabled?: boolean;
className?: string;
}
export function SettingsRow({
title,
description,
value,
onClick,
icon,
toggle,
right,
destructive,
disabled,
className,
}: SettingsRowProps) {
// Determine right slot
let rightSlot: ReactNode;
if (right !== undefined) {
rightSlot = right;
} else if (toggle) {
rightSlot = (
<Switch
checked={toggle.value}
onCheckedChange={toggle.onChange}
disabled={toggle.disabled}
/>
);
} else if (icon) {
rightSlot = (
<span className={cn(
'shrink-0 [&>svg]:size-5',
destructive ? 'text-destructive' : 'text-muted-foreground',
)}>
{icon}
</span>
);
} else if (onClick) {
rightSlot = <ChevronRight className="size-5 text-muted-foreground shrink-0" />;
}
const interactive = !!(onClick || toggle);
const content = (
<div className="flex items-start gap-4 w-full min-w-0">
<div className="flex-1 min-w-0 overflow-hidden">
<div className="flex items-center gap-2 flex-wrap min-w-0">
<span className={cn(
'text-base font-bold break-words',
destructive && 'text-destructive',
disabled && 'opacity-50',
)}>
{title}
</span>
{value !== undefined && (
<span className="text-sm font-medium text-muted-foreground break-words">
{value}
</span>
)}
</div>
{description && (
// break-words so long Lightning Addresses / npubs wrap instead of
// pushing the row past the modal edge.
<p className={cn(
'text-sm text-muted-foreground mt-1 leading-relaxed break-words',
disabled && 'opacity-50',
)}>
{description}
</p>
)}
</div>
{rightSlot && <div className="pt-0.5 shrink-0">{rightSlot}</div>}
</div>
);
if (interactive && !toggle) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className={cn(
'w-full text-left py-4 px-1 rounded-xl transition-colors',
'hover:bg-muted/30 active:bg-muted/40',
'disabled:pointer-events-none',
className,
)}
>
{content}
</button>
);
}
// Toggle rows: clicks on the title area shouldn't fire the toggle, so we render
// a non-button container (the Switch handles its own click target).
return (
<div
className={cn(
'w-full py-4 px-1',
className,
)}
>
{content}
</div>
);
}
@@ -0,0 +1,145 @@
/**
* TransactionRow
*
* Single payment row in the Signal-style wallet history. A circular
* category-coloured icon on the left, title + timestamp + subtitle in
* the middle, and a right-aligned amount with a coloured up/down arrow.
*/
import { ArrowDown, ArrowUp, Bitcoin, Loader2, Zap } from 'lucide-react';
import type { BreezPaymentInfo } from '@/lib/spark/breezService';
import { cn } from '@/lib/utils';
export type PaymentCategory = 'zap' | 'lightning' | 'onchain';
interface TransactionRowProps {
payment: BreezPaymentInfo;
/** Optional override category (e.g. when zap-receipt enrichment is known). */
category?: PaymentCategory;
/** Display title to use; defaults to a sensible auto-derived label. */
title?: string;
/** Subtitle line under the title (defaults to payment.description). */
subtitle?: string;
onClick?: () => void;
}
function defaultTitle(payment: BreezPaymentInfo, category: PaymentCategory): string {
if (category === 'zap') return 'Zap';
if (category === 'onchain') return 'Bitcoin';
return 'Lightning';
}
function detectCategory(payment: BreezPaymentInfo): PaymentCategory {
// On-chain payments don't have a bolt11 invoice
if (!payment.invoice) return 'onchain';
// Anything else with an invoice we treat as Lightning by default;
// zap-aware callers can pass `category="zap"` explicitly.
return 'lightning';
}
function formatTime(timestampMs: number): string {
return new Date(timestampMs).toLocaleTimeString(undefined, {
hour: '2-digit',
minute: '2-digit',
});
}
export function TransactionRow({
payment,
category,
title,
subtitle,
onClick,
}: TransactionRowProps) {
const cat = category ?? detectCategory(payment);
const isReceive = payment.paymentType === 'receive';
const isPending = payment.status === 'pending';
const isFailed = payment.status === 'failed';
const displayTitle = title ?? defaultTitle(payment, cat);
const displaySubtitle = subtitle ?? payment.description ?? '';
// Category icon + colour
const categoryStyles: Record<
PaymentCategory,
{ iconBg: string; iconColor: string; Icon: typeof Zap }
> = {
zap: {
iconBg: 'bg-orange-500/15',
iconColor: 'text-orange-500',
Icon: Zap,
},
lightning: {
iconBg: 'bg-blue-500/15',
iconColor: 'text-blue-500',
Icon: Zap,
},
onchain: {
iconBg: 'bg-amber-500/15',
iconColor: 'text-amber-500',
Icon: Bitcoin,
},
};
const { iconBg, iconColor, Icon } = categoryStyles[cat];
return (
<button
type="button"
onClick={onClick}
className={cn(
'w-full flex items-center gap-3 px-1 py-3 text-left',
'hover:bg-muted/30 active:bg-muted/40 transition-colors rounded-xl',
)}
>
<div
className={cn(
'flex items-center justify-center size-10 rounded-full shrink-0',
iconBg,
)}
>
{isPending ? (
<Loader2 className={cn('size-5 animate-spin', iconColor)} />
) : (
<Icon className={cn('size-5', iconColor)} />
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 text-sm">
<span className="font-bold truncate">{displayTitle}</span>
<span className="text-muted-foreground shrink-0">
| {formatTime(payment.timestamp)}
</span>
</div>
{displaySubtitle && (
<p className="text-sm text-muted-foreground truncate">
{displaySubtitle}
</p>
)}
{isFailed && (
<p className="text-xs text-destructive">Failed</p>
)}
</div>
<div className="flex items-center gap-1 shrink-0 text-right">
<div>
<p
className={cn(
'text-base font-semibold tabular-nums',
isFailed && 'line-through text-muted-foreground',
)}
>
{payment.amount.toLocaleString()}
</p>
<p className="text-xs text-muted-foreground">sats</p>
</div>
{isReceive ? (
<ArrowDown className="size-4 text-green-500" />
) : (
<ArrowUp className="size-4 text-red-500" />
)}
</div>
</button>
);
}
@@ -0,0 +1,69 @@
/**
* WalletTabBar
*
* Horizontal row of large circular tab buttons used inside Send / Receive
* dialogs. Active tab is filled white (or `bg-background`) over a muted
* outer ring, inactive tabs are subtle muted circles. Mirrors the bottom
* tab strip in Signal's wallet sub-screens.
*/
import type { ReactNode } from 'react';
import { cn } from '@/lib/utils';
export interface WalletTab<T extends string = string> {
id: T;
icon: ReactNode;
label: string;
}
interface WalletTabBarProps<T extends string> {
tabs: WalletTab<T>[];
active: T;
onChange: (id: T) => void;
className?: string;
}
export function WalletTabBar<T extends string>({
tabs,
active,
onChange,
className,
}: WalletTabBarProps<T>) {
// Use a CSS grid sized to the number of tabs so every tab gets equal width
// and no tab can ever be clipped or pushed off-screen by wrapping. This is
// a stronger guarantee than `flex justify-evenly` which can behave weirdly
// inside narrow parents.
return (
<div
className={cn(
'grid items-center py-3 px-4 border-t bg-background/50',
className,
)}
style={{
gridTemplateColumns: `repeat(${tabs.length}, minmax(0, 1fr))`,
}}
>
{tabs.map((tab) => {
const isActive = tab.id === active;
return (
<div key={tab.id} className="flex justify-center">
<button
type="button"
onClick={() => onChange(tab.id)}
aria-label={tab.label}
title={tab.label}
className={cn(
'flex size-12 items-center justify-center rounded-full transition-colors',
isActive
? 'bg-background text-foreground shadow-md ring-1 ring-border'
: 'bg-muted/60 text-muted-foreground hover:bg-muted',
)}
>
<span className="[&>svg]:size-5">{tab.icon}</span>
</button>
</div>
);
})}
</div>
);
}
@@ -0,0 +1,8 @@
export { CircleAction } from './CircleAction';
export { DateSeparator } from './DateSeparator';
export { TransactionRow, type PaymentCategory } from './TransactionRow';
export { WalletTabBar, type WalletTab } from './WalletTabBar';
export { SettingsRow } from './SettingsRow';
// `formatDateBucket` is re-exported here for ergonomics; it lives in
// src/lib/spark/formatDateBucket.ts so DateSeparator can stay component-only.
export { formatDateBucket } from '@/lib/spark/formatDateBucket';
File diff suppressed because it is too large Load Diff
-264
View File
@@ -1,264 +0,0 @@
import { useState } from 'react';
import { Plus, Trash2, Zap, Globe, WalletMinimal, CheckCircle, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import { Card, CardContent } from '@/components/ui/card';
import {
Dialog,
DialogContent,
DialogTitle,
} from '@/components/ui/dialog';
import { useNWC } from '@/hooks/useNWCContext';
import { useWallet } from '@/hooks/useWallet';
import { useToast } from '@/hooks/useToast';
export function WalletSettings() {
const [addDialogOpen, setAddDialogOpen] = useState(false);
const [connectionUri, setConnectionUri] = useState('');
const [alias, setAlias] = useState('');
const [isConnecting, setIsConnecting] = useState(false);
const {
connections,
activeConnection,
connectionInfo,
addConnection,
removeConnection,
setActiveConnection,
} = useNWC();
const { webln } = useWallet();
const hasNWC = connections.length > 0 && connections.some(c => c.isConnected);
const { toast } = useToast();
const handleAddConnection = async () => {
if (!connectionUri.trim()) {
toast({
title: 'Connection URI required',
description: 'Please enter a valid NWC connection URI.',
variant: 'destructive',
});
return;
}
setIsConnecting(true);
try {
const success = await addConnection(connectionUri.trim(), alias.trim() || undefined);
if (success) {
setConnectionUri('');
setAlias('');
setAddDialogOpen(false);
}
} finally {
setIsConnecting(false);
}
};
const handleRemoveConnection = (connectionString: string) => {
removeConnection(connectionString);
};
const handleSetActive = (connectionString: string) => {
setActiveConnection(connectionString);
toast({
title: 'Active wallet changed',
description: 'The selected wallet is now active for zaps.',
});
};
return (
<>
<div className="space-y-6">
{/* Connection status cards */}
<div className="space-y-3">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide px-1">Status</h2>
<div className="grid gap-3">
{/* WebLN */}
<Card className="overflow-hidden">
<CardContent className="flex items-center justify-between p-4">
<div className="flex items-center gap-3">
<div className="flex items-center justify-center size-9 rounded-full bg-secondary">
<Globe className="size-4 text-muted-foreground" />
</div>
<div>
<p className="text-sm font-medium">WebLN</p>
<p className="text-xs text-muted-foreground">Browser extension</p>
</div>
</div>
<div className="flex items-center gap-2">
{webln && <CheckCircle className="size-4 text-green-500" />}
<Badge variant={webln ? 'default' : 'secondary'} className="text-xs">
{webln ? 'Ready' : 'Not Found'}
</Badge>
</div>
</CardContent>
</Card>
{/* NWC */}
<Card className="overflow-hidden">
<CardContent className="flex items-center justify-between p-4">
<div className="flex items-center gap-3">
<div className="flex items-center justify-center size-9 rounded-full bg-secondary">
<WalletMinimal className="size-4 text-muted-foreground" />
</div>
<div>
<p className="text-sm font-medium">Nostr Wallet Connect</p>
<p className="text-xs text-muted-foreground">
{connections.length > 0
? `${connections.length} wallet${connections.length !== 1 ? 's' : ''} connected`
: 'Remote wallet connection'}
</p>
</div>
</div>
<div className="flex items-center gap-2">
{hasNWC && <CheckCircle className="size-4 text-green-500" />}
<Badge variant={hasNWC ? 'default' : 'secondary'} className="text-xs">
{hasNWC ? 'Ready' : 'None'}
</Badge>
</div>
</CardContent>
</Card>
</div>
</div>
<Separator />
{/* NWC Wallets */}
<div className="space-y-4">
<div className="flex items-center justify-between px-1">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">Nostr Wallet Connect</h2>
<Button size="sm" variant="outline" onClick={() => setAddDialogOpen(true)} className="rounded-full">
<Plus className="size-4 mr-1" />
Add
</Button>
</div>
{connections.length === 0 ? (
<Card className="border-dashed">
<CardContent className="py-10 text-center">
<WalletMinimal className="size-8 mx-auto mb-3 text-muted-foreground/50" />
<p className="text-sm text-muted-foreground mb-1">No wallets connected</p>
<p className="text-xs text-muted-foreground/70">Add an NWC connection to enable instant zaps.</p>
</CardContent>
</Card>
) : (
<div className="space-y-2">
{connections.map((connection) => {
const info = connectionInfo[connection.connectionString];
const isActive = activeConnection === connection.connectionString;
return (
<Card key={connection.connectionString} className={isActive ? 'ring-2 ring-primary' : ''}>
<CardContent className="flex items-center justify-between p-4">
<div className="flex items-center gap-3 min-w-0">
<div className="flex items-center justify-center size-9 rounded-full bg-secondary shrink-0">
<WalletMinimal className="size-4 text-muted-foreground" />
</div>
<div className="min-w-0">
<p className="text-sm font-medium truncate">
{connection.alias || info?.alias || 'Lightning Wallet'}
</p>
<p className="text-xs text-muted-foreground">
{isActive ? 'Active' : 'NWC Connection'}
</p>
</div>
</div>
<div className="flex items-center gap-1 shrink-0">
{isActive && <CheckCircle className="size-4 text-green-500 mr-1" />}
{!isActive && (
<Button
size="sm"
variant="ghost"
onClick={() => handleSetActive(connection.connectionString)}
className="rounded-full"
title="Set as active"
>
<Zap className="size-3.5" />
</Button>
)}
<Button
size="sm"
variant="ghost"
onClick={() => handleRemoveConnection(connection.connectionString)}
className="rounded-full text-muted-foreground hover:text-destructive"
title="Remove wallet"
>
<Trash2 className="size-3.5" />
</Button>
</div>
</CardContent>
</Card>
);
})}
</div>
)}
</div>
{/* Help text */}
{!webln && connections.length === 0 && (
<>
<Separator />
<div className="text-center py-4 space-y-2 px-4">
<p className="text-sm text-muted-foreground">
Install a WebLN browser extension or connect a NWC wallet to send zaps.
</p>
</div>
</>
)}
</div>
{/* Add wallet dialog */}
<Dialog open={addDialogOpen} onOpenChange={setAddDialogOpen}>
<DialogContent className="max-w-[520px] rounded-2xl p-0 gap-0 border-border overflow-hidden [&>button]:hidden">
{/* Header */}
<div className="flex items-center justify-between px-4 h-12">
<DialogTitle className="text-base font-semibold">
Connect NWC Wallet
</DialogTitle>
<button
onClick={() => setAddDialogOpen(false)}
className="p-1.5 -mr-1.5 rounded-full text-muted-foreground hover:text-foreground hover:bg-secondary/60 transition-colors"
>
<X className="size-5" />
</button>
</div>
{/* Description */}
<p className="px-4 -mt-1 mb-2 text-sm text-muted-foreground">
Paste a connection string from your NWC-compatible wallet.
</p>
{/* Form fields */}
<div className="px-4 space-y-4">
<Input
placeholder="Wallet name (optional)"
value={alias}
onChange={(e) => setAlias(e.target.value)}
className="bg-transparent"
/>
<Textarea
placeholder="nostr+walletconnect://..."
value={connectionUri}
onChange={(e) => setConnectionUri(e.target.value)}
rows={3}
className="bg-transparent resize-none"
/>
</div>
{/* Footer */}
<div className="flex items-center justify-end px-4 py-3">
<Button
onClick={handleAddConnection}
disabled={isConnecting || !connectionUri.trim()}
className="rounded-full px-5 font-bold"
size="sm"
>
{isConnecting ? 'Connecting...' : 'Connect'}
</Button>
</div>
</DialogContent>
</Dialog>
</>
);
}
File diff suppressed because it is too large Load Diff
+95
View File
@@ -0,0 +1,95 @@
import { useNavigate } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Drawer,
DrawerContent,
DrawerDescription,
DrawerHeader,
DrawerTitle,
} from '@/components/ui/drawer';
import { useIsMobile } from '@/hooks/useIsMobile';
import { Wallet, Zap } from 'lucide-react';
interface WalletSetupPromptProps {
open: boolean;
onOpenChange: (open: boolean) => void;
children?: React.ReactNode;
}
export function WalletSetupPrompt({ open, onOpenChange, children }: WalletSetupPromptProps) {
const navigate = useNavigate();
const isMobile = useIsMobile();
const handleSetupWallet = () => {
onOpenChange(false);
navigate('/wallet');
};
const content = (
<div className="space-y-6 p-4">
<div className="flex justify-center">
<div className="rounded-full bg-primary/10 p-4">
<Wallet className="h-8 w-8 text-primary" />
</div>
</div>
<div className="space-y-2 text-center">
<p className="text-sm text-muted-foreground">
To send zaps and support creators with Bitcoin, you need to set up a Lightning wallet.
</p>
<p className="text-sm text-muted-foreground">
It only takes a minute and you'll be ready to zap!
</p>
</div>
<div className="flex flex-col gap-3">
<Button onClick={handleSetupWallet} className="w-full gap-2 rounded-full h-12">
<Zap className="h-4 w-4" />
Set Up Wallet
</Button>
<Button variant="outline" onClick={() => onOpenChange(false)} className="w-full rounded-full h-11">
Maybe Later
</Button>
</div>
</div>
);
if (isMobile) {
return (
<Drawer open={open} onOpenChange={onOpenChange}>
{children}
<DrawerContent>
<DrawerHeader className="text-center">
<DrawerTitle>Lightning Wallet Required</DrawerTitle>
<DrawerDescription>
Send zaps to support creators
</DrawerDescription>
</DrawerHeader>
{content}
</DrawerContent>
</Drawer>
);
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
{children}
<DialogContent className="sm:max-w-md">
<DialogHeader className="text-center">
<DialogTitle>Lightning Wallet Required</DialogTitle>
<DialogDescription>
Send zaps to support creators
</DialogDescription>
</DialogHeader>
{content}
</DialogContent>
</Dialog>
);
}
+47
View File
@@ -0,0 +1,47 @@
import { Globe, CheckCircle } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { useWallet } from '@/hooks/useWallet';
/**
* WebLN status row.
*
* Shows whether a WebLN-compatible browser extension (Alby, etc.) is present
* on `window.webln`. There is nothing to configure — the section is either
* "Ready" or "Not Found" depending on the environment. Flat rounded-2xl
* container matching the rest of the wallet settings sections.
*/
export function WebLNSettings() {
const { webln } = useWallet();
return (
<div className="space-y-3 px-1">
<div className="flex items-center justify-between gap-3 rounded-2xl bg-muted/30 p-4">
<div className="flex items-center gap-3 min-w-0">
<div className="flex items-center justify-center size-10 rounded-full bg-background shrink-0">
<Globe className="size-4 text-muted-foreground" />
</div>
<div className="min-w-0">
<p className="text-sm font-medium">WebLN</p>
<p className="text-xs text-muted-foreground">Browser extension</p>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
{webln && <CheckCircle className="size-4 text-green-500" />}
<Badge
variant={webln ? 'default' : 'secondary'}
className="text-xs rounded-full"
>
{webln ? 'Ready' : 'Not Found'}
</Badge>
</div>
</div>
{!webln && (
<p className="text-xs text-muted-foreground leading-relaxed">
Install a WebLN-compatible browser extension (e.g. Alby) to let Agora
send zaps through your browser wallet.
</p>
)}
</div>
);
}
+7
View File
@@ -251,6 +251,13 @@ export interface AppConfig {
sandboxDomain: string;
/** Ordered list of right sidebar widget configs. Each entry is a widget type ID with optional display settings. */
sidebarWidgets: WidgetConfig[];
/**
* Hide wallet transactions whose amount (in sats) is below this threshold.
* 0 = show everything (default). Only affects the transaction history list
* on the wallet page; pending and failed payments are always shown so the
* user can see problems regardless of amount.
*/
walletHideBelowSats?: number;
}
/** Configuration for a single widget in the right sidebar. */
+13 -181
View File
@@ -23,7 +23,11 @@ import {
type UnclaimedDepositInfo,
type RecommendedFeesInfo,
} from "@/lib/spark/breezService";
import type { SdkEvent } from "@breeztech/breez-sdk-spark/web";
import type {
SdkEvent,
InputType,
LnurlPayRequestDetails,
} from "@breeztech/breez-sdk-spark/web";
import { logger } from "@/lib/logger";
/** Payment event handler type */
@@ -38,11 +42,7 @@ import {
storeMnemonicSession,
getMnemonicSession,
clearMnemonicSession,
setLockTimeout as setLockTimeoutStorage,
updateLastActivity,
shouldAutoLock,
} from "@/lib/spark/store";
import type { LockTimeoutMinutes } from "@/lib/spark/types";
import {
createBackupEvent,
decryptBackupEvent,
@@ -134,13 +134,6 @@ export interface SparkWalletContextValue {
// Last received payment (updated when a payment is received)
lastReceivedPayment: BreezPaymentInfo | null;
// Lock Management
isLocked: boolean;
lockTimeout: LockTimeoutMinutes;
lockWallet: () => void;
unlockWallet: () => Promise<void>;
setLockTimeout: (timeout: LockTimeoutMinutes) => void;
// On-chain Deposit Claiming
unclaimedDeposits: UnclaimedDepositInfo[];
isLoadingDeposits: boolean;
@@ -212,11 +205,6 @@ export function SparkWalletProvider({ children }: { children: ReactNode }) {
useState<BreezPaymentInfo | null>(null);
const [lightningAddress, setLightningAddress] = useState<string | null>(null);
// Lock state
const [isLocked, setIsLocked] = useState(false);
const [lockTimeout, setLockTimeoutState] = useState<LockTimeoutMinutes>(0);
const autoLockTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Unclaimed deposits state
const [unclaimedDeposits, setUnclaimedDeposits] = useState<
UnclaimedDepositInfo[]
@@ -239,19 +227,10 @@ export function SparkWalletProvider({ children }: { children: ReactNode }) {
if (config) {
setBalance(config.cachedBalance || 0);
setIsEnabledState(config.isEnabled !== false);
setLockTimeoutState(config.lockTimeout ?? 0);
}
// Check if wallet should be locked due to inactivity
if (configured && shouldAutoLock(user.pubkey)) {
setIsLocked(true);
clearMnemonicSession();
logger.debug("[SparkWallet] Wallet auto-locked due to inactivity");
}
} else {
setHasWallet(false);
setIsInitialized(false);
setIsLocked(false);
}
}, [user?.pubkey]);
@@ -263,8 +242,7 @@ export function SparkWalletProvider({ children }: { children: ReactNode }) {
!user?.signer ||
!hasWallet ||
isInitialized ||
isConnecting ||
isLocked
isConnecting
) {
return;
}
@@ -361,7 +339,6 @@ export function SparkWalletProvider({ children }: { children: ReactNode }) {
hasWallet,
isInitialized,
isConnecting,
isLocked,
]);
// Check for relay backup when user logs in (if no local wallet)
@@ -392,134 +369,6 @@ export function SparkWalletProvider({ children }: { children: ReactNode }) {
}
}, [user?.pubkey]);
// Auto-lock timer and activity tracking
useEffect(() => {
// Clear any existing timer
if (autoLockTimerRef.current) {
clearTimeout(autoLockTimerRef.current);
autoLockTimerRef.current = null;
}
// If wallet is not initialized, locked, or no timeout set, don't start timer
if (!isInitialized || isLocked || lockTimeout === 0 || !user?.pubkey) {
return;
}
const timeoutMs = lockTimeout * 60 * 1000;
// Start auto-lock timer
const startTimer = () => {
if (autoLockTimerRef.current) {
clearTimeout(autoLockTimerRef.current);
}
autoLockTimerRef.current = setTimeout(() => {
logger.debug("[SparkWallet] Auto-locking wallet due to inactivity");
setIsLocked(true);
clearMnemonicSession();
breezService.disconnect().catch(logger.error);
setIsInitialized(false);
}, timeoutMs);
};
// Reset timer on user activity
const handleActivity = () => {
if (user?.pubkey) {
updateLastActivity(user.pubkey);
}
startTimer();
};
// Start initial timer
startTimer();
// Track user activity
const events = ["mousedown", "keydown", "touchstart", "scroll"];
events.forEach((event) => {
window.addEventListener(event, handleActivity, { passive: true });
});
return () => {
if (autoLockTimerRef.current) {
clearTimeout(autoLockTimerRef.current);
autoLockTimerRef.current = null;
}
events.forEach((event) => {
window.removeEventListener(event, handleActivity);
});
};
}, [isInitialized, isLocked, lockTimeout, user?.pubkey]);
// Lock wallet function
const lockWallet = useCallback(() => {
if (!isInitialized) return;
logger.debug("[SparkWallet] Manually locking wallet");
setIsLocked(true);
clearMnemonicSession();
breezService.disconnect().catch(logger.error);
setIsInitialized(false);
setSparkAddress(null);
setBitcoinAddress(null);
toast({
title: "Wallet locked",
description: "Your wallet has been locked for security.",
});
}, [isInitialized, toast]);
// Unlock wallet function (re-authenticates with encrypted storage)
const unlockWallet = useCallback(async () => {
if (!isLocked || !user?.pubkey || !user?.signer) {
return;
}
try {
setIsConnecting(true);
const mnemonic = await loadEncryptedSeed(user.pubkey, user.signer);
if (!mnemonic) {
throw new Error("No wallet found. Please restore your wallet.");
}
await breezService.connect(mnemonic, "mainnet");
await storeMnemonicSession(mnemonic, user.pubkey, user.signer);
updateLastActivity(user.pubkey);
updateStateFromSDK();
setIsInitialized(true);
setIsLocked(false);
logger.debug("[SparkWallet] Wallet unlocked");
toast({
title: "Wallet unlocked",
description: "Your wallet is now accessible.",
});
} catch (error) {
logger.error("[SparkWallet] Failed to unlock wallet:", error);
toast({
title: "Unlock failed",
description:
error instanceof Error ? error.message : "Failed to unlock wallet",
variant: "destructive",
});
throw error;
} finally {
setIsConnecting(false);
}
}, [isLocked, user?.pubkey, user?.signer, toast, updateStateFromSDK]);
// Set lock timeout function
const handleSetLockTimeout = useCallback(
(timeout: LockTimeoutMinutes) => {
setLockTimeoutState(timeout);
if (user?.pubkey) {
setLockTimeoutStorage(timeout, user.pubkey);
updateLastActivity(user.pubkey);
}
logger.debug("[SparkWallet] Lock timeout set to", timeout, "minutes");
},
[user?.pubkey],
);
// Connect wallet with mnemonic
const connectWallet = useCallback(
async (mnemonic: string) => {
@@ -604,7 +453,7 @@ export function SparkWalletProvider({ children }: { children: ReactNode }) {
toast({
title: "Wallet restored",
description: "Your Spark wallet has been restored successfully.",
description: "Your Agora Wallet has been restored successfully.",
});
},
[connectWallet, toast],
@@ -710,7 +559,7 @@ export function SparkWalletProvider({ children }: { children: ReactNode }) {
}
const blob = await exportToFile(mnemonic, user.pubkey, user.signer);
downloadBackupFile(blob);
await downloadBackupFile(blob);
toast({
title: "Backup exported",
@@ -778,11 +627,10 @@ export function SparkWalletProvider({ children }: { children: ReactNode }) {
amountSat: number,
comment?: string,
): Promise<BreezPaymentInfo> => {
// Parse the lightning address
const parsed = (await breezService.parseInput(address)) as {
type: string;
payRequest?: unknown;
};
// Parse the lightning address. The SDK's InputType union resolves to
// `lightningAddress` (with a nested payRequest) or `lnurlPay` (which
// is itself an LnurlPayRequestDetails). Other variants are errors here.
const parsed = (await breezService.parseInput(address)) as InputType;
if (parsed.type !== "lnurlPay" && parsed.type !== "lightningAddress") {
throw new Error("Invalid Lightning address");
@@ -790,13 +638,9 @@ export function SparkWalletProvider({ children }: { children: ReactNode }) {
// For Lightning addresses, the pay request data is nested in parsed.payRequest
// For lnurlPay, the data is in parsed directly
const payRequest =
const payRequest: LnurlPayRequestDetails =
parsed.type === "lightningAddress" ? parsed.payRequest : parsed;
if (!payRequest) {
throw new Error("Could not resolve Lightning address");
}
// Prepare and execute LNURL pay
const prepareResponse = await breezService.prepareLnurlPay(
amountSat,
@@ -1440,13 +1284,6 @@ export function SparkWalletProvider({ children }: { children: ReactNode }) {
subscribeToPaymentEvents,
lastReceivedPayment,
// Lock Management
isLocked,
lockTimeout,
lockWallet,
unlockWallet,
setLockTimeout: handleSetLockTimeout,
// On-chain Deposit Claiming
unclaimedDeposits,
isLoadingDeposits,
@@ -1503,11 +1340,6 @@ export function SparkWalletProvider({ children }: { children: ReactNode }) {
getMnemonic,
subscribeToPaymentEvents,
lastReceivedPayment,
isLocked,
lockTimeout,
lockWallet,
unlockWallet,
handleSetLockTimeout,
unclaimedDeposits,
isLoadingDeposits,
isSyncing,
+8 -2
View File
@@ -1,6 +1,12 @@
/**
* Barcode Scanner Hook
* Wrapper for @capacitor/barcode-scanner plugin
* Barcode / QR Scanner Hook
*
* Wraps the @capacitor/barcode-scanner plugin on native platforms (Android / iOS)
* and exposes a simple promise-based API to get a single scanned value back.
*
* On web builds the native plugin is unavailable, so callers should inspect
* `isSupported` and either hide the scan button or wire up an alternative flow
* (e.g. the html5-qrcode based `ScanQrModal` fallback).
*/
import { useState } from 'react';
+148
View File
@@ -0,0 +1,148 @@
/**
* useNostrRecipientSearch
*
* Lightning-recipient picker backed by Agora's existing profile search.
*
* Behaviour:
* - Empty query: show the user's follows that have a lud16 / lud06.
* - Non-empty query: delegate to `useSearchProfiles` — the same hook that
* powers the sidebar search (NIP-50 relay search + cached-author
* fallback + 300ms debounce). Filter its results to those with a
* lud16 / lud06. Followed profiles naturally rank higher because
* useSearchProfiles already sorts followed ahead of non-followed.
*
* This gives the wallet send picker the same "type 'alex' and find Alex"
* behaviour as the rest of Agora instead of a local follows-only filter.
*/
import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useNostr } from '@nostrify/react';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAuthors } from '@/hooks/useAuthors';
import { useSearchProfiles } from '@/hooks/useSearchProfiles';
import { genUserName } from '@/lib/genUserName';
import type { NostrMetadata } from '@nostrify/nostrify';
export interface NostrRecipient {
pubkey: string;
/** Display name resolved via the standard fallback chain. */
name: string;
/** Lightning Address (lud16 preferred, lud06 fallback). */
lightningAddress: string;
/** Raw metadata, if any — useful for avatars. */
metadata?: NostrMetadata;
}
interface UseNostrRecipientSearchResult {
recipients: NostrRecipient[];
isLoading: boolean;
hasFollows: boolean;
}
/** Fetch the pubkey's follow list (kind 3) and return the followed pubkeys. */
function useFollowPubkeys(pubkey: string | undefined) {
const { nostr } = useNostr();
return useQuery({
queryKey: ['spark-recipient-follows', pubkey],
enabled: !!pubkey,
staleTime: 5 * 60 * 1000,
queryFn: async (c) => {
if (!pubkey) return [] as string[];
const signal = AbortSignal.any([
c.signal,
AbortSignal.timeout(5000),
]);
const events = await nostr.query(
[{ kinds: [3], authors: [pubkey], limit: 1 }],
{ signal },
);
if (events.length === 0) return [];
return events[0].tags
.filter(
([name, value]) =>
name === 'p' &&
typeof value === 'string' &&
/^[0-9a-f]{64}$/i.test(value),
)
.map(([, value]) => value);
},
});
}
/** Build a NostrRecipient from a metadata bundle; returns null when there's
* no lightning address to receive at. */
function toRecipient(
pubkey: string,
metadata: NostrMetadata | undefined,
): NostrRecipient | null {
const ln = metadata?.lud16 || metadata?.lud06 || '';
if (!ln) return null;
const name =
metadata?.display_name?.trim() ||
metadata?.name?.trim() ||
genUserName(pubkey);
return { pubkey, name, lightningAddress: ln, metadata };
}
export function useNostrRecipientSearch(
query: string,
): UseNostrRecipientSearchResult {
const { user } = useCurrentUser();
const followsQuery = useFollowPubkeys(user?.pubkey);
const followPubkeys = useMemo(
() => followsQuery.data ?? [],
[followsQuery.data],
);
// Batch-fetch kind 0 metadata for the follow list (used when the query
// is empty, to render the default "your contacts" list).
const authorsQuery = useAuthors(followPubkeys);
// Delegate text search to the shared profile search hook. It handles
// debouncing, NIP-50, cache fallback, and followed-first sorting for us.
const searchResults = useSearchProfiles(query);
const trimmedQuery = query.trim();
// Default list (no query): the user's follows that have a LN address.
const baseRecipients = useMemo<NostrRecipient[]>(() => {
if (followPubkeys.length === 0) return [];
const out: NostrRecipient[] = [];
for (const pubkey of followPubkeys) {
const author = authorsQuery.data?.get(pubkey);
const r = toRecipient(pubkey, author?.metadata);
if (r) out.push(r);
}
out.sort((a, b) => a.name.localeCompare(b.name));
return out;
}, [followPubkeys, authorsQuery.data]);
// Search list: useSearchProfiles results filtered down to those with a LN
// address. Deduplicated (search hook already does this, but belt-and-
// suspenders) and stable.
const searchRecipients = useMemo<NostrRecipient[]>(() => {
const data = searchResults.data;
if (!data || data.length === 0) return [];
const seen = new Set<string>();
const out: NostrRecipient[] = [];
for (const profile of data) {
if (seen.has(profile.pubkey)) continue;
seen.add(profile.pubkey);
const r = toRecipient(profile.pubkey, profile.metadata);
if (r) out.push(r);
}
return out;
}, [searchResults.data]);
const recipients = trimmedQuery.length > 0 ? searchRecipients : baseRecipients;
return {
recipients,
isLoading:
trimmedQuery.length > 0
? searchResults.isLoading
: followsQuery.isLoading || authorsQuery.isLoading,
hasFollows: followPubkeys.length > 0,
};
}
+42 -1
View File
@@ -4,6 +4,7 @@ import { useAuthor } from '@/hooks/useAuthor';
import { useAppContext } from '@/hooks/useAppContext';
import { useToast } from '@/hooks/useToast';
import { useNWC } from '@/hooks/useNWCContext';
import { useSparkWallet } from '@/hooks/useSparkWallet';
import type { NWCConnection } from '@/hooks/useNWC';
import { nip57 } from 'nostr-tools';
import type { Event } from 'nostr-tools';
@@ -28,6 +29,7 @@ export function useZaps(
const queryClient = useQueryClient();
const author = useAuthor(target?.pubkey);
const { sendPayment, getActiveConnection } = useNWC();
const sparkWallet = useSparkWallet();
const [isZapping, setIsZapping] = useState(false);
const [invoice, setInvoice] = useState<string | null>(null);
@@ -153,7 +155,46 @@ export function useZaps(
// Get the current active NWC connection dynamically
const currentNWCConnection = getActiveConnection();
// Try NWC first if available and properly connected
// Try self-custodial Agora Wallet first if it's enabled, initialized,
// and has enough balance to cover the invoice. Falls through on any error.
if (
sparkWallet.isEnabled &&
sparkWallet.isInitialized &&
sparkWallet.balance >= amount
) {
try {
await sparkWallet.payInvoice(newInvoice);
// Clear states immediately on success
setIsZapping(false);
setInvoice(null);
notificationSuccess();
toast({
title: 'Zap successful!',
description: `You sent ${amount} sats from your Agora Wallet.`,
});
// Invalidate zap queries to refresh counts
queryClient.invalidateQueries({ queryKey: ['zaps'] });
// Close dialog last to ensure clean state
onZapSuccess?.();
return;
} catch (sparkError) {
console.error('Agora Wallet payment failed, falling back:', sparkError);
// Show specific error to user but keep trying fallback methods
const errorMessage = sparkError instanceof Error ? sparkError.message : 'Unknown wallet error';
toast({
title: 'Wallet payment failed',
description: `${errorMessage}. Falling back to other payment methods...`,
variant: 'destructive',
});
}
}
// Try NWC next if available and properly connected
if (currentNWCConnection && currentNWCConnection.connectionString && currentNWCConnection.isConnected) {
try {
await sendPayment(currentNWCConnection, newInvoice);
+3 -3
View File
@@ -1,9 +1,9 @@
/**
* Check if WebAssembly is supported in the current environment.
*
*
* iOS Lockdown Mode disables WebAssembly, which breaks wallet functionality
* that relies on the Breez SDK (which uses WASM for cryptographic operations).
*
*
* This function performs a comprehensive check including:
* 1. Basic WebAssembly object existence
* 2. Ability to compile a minimal WASM module
@@ -48,7 +48,7 @@ export async function checkWasmSupport(): Promise<{
} catch (error) {
// Specific error messages for different failure modes
const errorMessage = error instanceof Error ? error.message : String(error);
// Check for common Lockdown Mode error patterns
if (
errorMessage.includes('WebAssembly') ||
+3 -2
View File
@@ -152,8 +152,9 @@ export const FAQ_CATEGORIES: FAQCategory[] = [
id: 'connect-wallet',
question: 'How do I connect a wallet?',
answer: [
'To send or receive zaps, you need a Lightning wallet. Great options for beginners include [Alby](https://getalby.com/), [Zeus](https://zeusln.com/), and [Wallet of Satoshi](https://www.walletofsatoshi.com/).',
'Once you have one, add your Lightning address to your profile settings, and you\'re ready to go.',
'The easiest option is to set up the **built-in Agora Wallet** that ships with the app — a self-custodial Lightning wallet powered by [Spark](https://breez.technology/spark/). Your keys stay on your device, there\'s no third-party signup, and you can claim a free Lightning Address that gets applied to your Nostr profile in one tap. Set it up under Settings > Wallet.',
'Prefer to bring your own wallet? You can also connect an external Lightning wallet via Nostr Wallet Connect (NWC) or a WebLN browser extension. Beginner-friendly options include [Alby](https://getalby.com/), [Zeus](https://zeusln.com/), and [Wallet of Satoshi](https://www.walletofsatoshi.com/).',
'Once a wallet is connected (Agora Wallet, NWC, or WebLN), zapping works everywhere in the app — Agora Wallet is tried first when it has a balance, then NWC, then WebLN.',
],
},
{
+1
View File
@@ -256,6 +256,7 @@ export const AppConfigSchema = z.object({
id: z.string(),
height: z.number().optional(),
})).optional(),
walletHideBelowSats: z.number().nonnegative().optional(),
});
// ─── BuildConfigSchema (build-time app config) ───────────────────────
+48 -17
View File
@@ -9,6 +9,7 @@
import type { NostrEvent, NostrSigner } from "@nostrify/nostrify";
import type { SparkBackupData } from "./types";
import { logger } from "@/lib/logger";
import { downloadTextFile } from "@/lib/downloadFile";
/** NIP-78 kind for application-specific data */
const BACKUP_KIND = 30078;
@@ -453,6 +454,21 @@ export async function exportToFile(
return new Blob([json], { type: "application/json" });
}
/**
* Loose shape for a parsed backup file. We accept v1 (legacy pathos) and v2
* (zapcooking/sparkihonne) blobs, both of which share these fields. Anything
* unrecognised will fail one of the validation steps below.
*/
interface ParsedBackupFile {
type?: unknown;
version?: unknown;
encryptedMnemonic?: unknown;
/** Legacy pathos field name. */
content?: unknown;
pubkey?: unknown;
encryption?: unknown;
}
/**
* Import backup from a JSON file
* Returns the decrypted mnemonic
@@ -465,16 +481,20 @@ export async function importFromFile(
): Promise<string> {
const text = await file.text();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let backupData: any;
let parsed: unknown;
try {
backupData = JSON.parse(text);
parsed = JSON.parse(text);
} catch {
throw new Error("Invalid backup file format");
}
if (typeof parsed !== "object" || parsed === null) {
throw new Error("Invalid backup file format");
}
const backupData = parsed as ParsedBackupFile;
// Validate type field if present
if (backupData.type && backupData.type !== "spark-wallet-backup") {
if (backupData.type !== undefined && backupData.type !== "spark-wallet-backup") {
throw new Error("Invalid backup type");
}
@@ -484,18 +504,27 @@ export async function importFromFile(
}
// Support both field names: new format (encryptedMnemonic) and old pathos format (content)
const encryptedContent = backupData.encryptedMnemonic || backupData.content;
const encryptedContent =
typeof backupData.encryptedMnemonic === "string"
? backupData.encryptedMnemonic
: typeof backupData.content === "string"
? backupData.content
: null;
if (!encryptedContent) {
throw new Error("Backup file is empty or corrupted");
}
// Check pubkey matches if present (NIP-44 will fail anyway if wrong key)
if (backupData.pubkey && backupData.pubkey !== pubkey) {
if (
typeof backupData.pubkey === "string" &&
backupData.pubkey !== pubkey
) {
throw new Error("This backup belongs to a different Nostr account");
}
// Determine encryption method (default nip44 for v2)
const encryption = backupData.encryption || "nip44";
const encryption =
typeof backupData.encryption === "string" ? backupData.encryption : "nip44";
if (encryption !== "nip44") {
throw new Error("Only NIP-44 encrypted backups are supported");
}
@@ -506,19 +535,21 @@ export async function importFromFile(
}
/**
* Trigger a file download in the browser
* Trigger a backup file download. Uses downloadTextFile from
* @/lib/downloadFile so it works on both web and Capacitor (iOS WKWebView
* silently drops the classic `<a download>` pattern; on native the file
* is written to the Documents directory and surfaced via the native
* share sheet by the helper).
*/
export function downloadBackupFile(blob: Blob, filename?: string): void {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
export async function downloadBackupFile(
blob: Blob,
filename?: string,
): Promise<void> {
// Use YYYY-MM-DD format like zapcooking/sparkihonne
const date = new Date().toISOString().split("T")[0];
a.download = filename ?? `spark-wallet-backup-${date}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
const name = filename ?? `spark-wallet-backup-${date}.json`;
const text = await blob.text();
await downloadTextFile(name, text);
}
/**
+19 -25
View File
@@ -5,6 +5,7 @@
*/
import { logger } from "@/lib/logger";
import { getBreezApiKey } from "@/lib/spark/walletAvailability";
import initBreezSDK, {
BreezSdk,
connect,
@@ -40,6 +41,9 @@ import initBreezSDK, {
Fee,
SendPaymentOptions,
OnchainConfirmationSpeed,
LnurlPayRequestDetails,
PrepareLnurlPayResponse,
LnurlPayResponse,
} from "@breeztech/breez-sdk-spark/web";
import { generateMnemonic, validateMnemonic } from "@scure/bip39";
import { wordlist } from "@scure/bip39/wordlists/english";
@@ -187,12 +191,12 @@ class BreezWalletService {
logger.debug("[BreezWallet] Step 2/4: Connecting to Breez SDK...");
// Get API key from environment or parameter
const key = apiKey || import.meta.env.VITE_BREEZ_API_KEY;
if (!key || key === "your-breez-api-key-here") {
// Get API key from environment or parameter (placeholder values are
// treated as missing — see src/lib/spark/walletAvailability.ts).
const key = apiKey || getBreezApiKey();
if (!key) {
throw new Error(
"Breez API key not configured. Please set VITE_BREEZ_API_KEY in .env",
"Wallet is not available in this build. Set VITE_BREEZ_API_KEY in .env (request one at https://breez.technology/request-api-key/).",
);
}
@@ -705,25 +709,18 @@ class BreezWalletService {
*/
async prepareLnurlPay(
amountSats: number,
payRequest: unknown,
payRequest: LnurlPayRequestDetails,
comment?: string,
): Promise<unknown> {
): Promise<PrepareLnurlPayResponse> {
await this.ensureConnected();
try {
const request: Record<string, unknown> = {
amountSats,
return await this.sdk!.prepareLnurlPay({
amount: BigInt(amountSats),
payRequest,
validateSuccessActionUrl: true,
};
// Only include comment if it's provided
if (comment) {
request.comment = comment;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return await this.sdk!.prepareLnurlPay(request as any);
...(comment ? { comment } : {}),
});
} catch (error) {
logger.error("[BreezWallet] Failed to prepare LNURL pay:", error);
throw error;
@@ -735,16 +732,13 @@ class BreezWalletService {
* @param prepareResponse - Response from prepareLnurlPay
* @returns LNURL pay response with payment info
*/
async lnurlPay(prepareResponse: unknown): Promise<unknown> {
async lnurlPay(
prepareResponse: PrepareLnurlPayResponse,
): Promise<LnurlPayResponse> {
await this.ensureConnected();
try {
const request = {
prepareResponse,
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return await this.sdk!.lnurlPay(request as any);
return await this.sdk!.lnurlPay({ prepareResponse });
} catch (error) {
logger.error("[BreezWallet] Failed to execute LNURL pay:", error);
throw error;
+77
View File
@@ -0,0 +1,77 @@
/**
* Export wallet transactions to a downloadable CSV file.
*
* Uses downloadTextFile from @/lib/downloadFile so it works on both web
* (anchor download) and native builds (Capacitor filesystem + share).
*/
import { downloadTextFile } from '@/lib/downloadFile';
import type { BreezPaymentInfo } from '@/lib/spark/breezService';
/** CSV-escape a single cell value (quote + double internal quotes). */
function csvCell(value: string | number | undefined | null): string {
if (value === undefined || value === null) return '';
const s = String(value);
if (/[",\n\r]/.test(s)) return `"${s.replace(/"/g, '""')}"`;
return s;
}
/**
* Normalize a timestamp to ISO-8601. Same heuristic as PaymentHistory:
* values > 1e11 are treated as ms, otherwise seconds.
*/
function toIsoDate(timestamp: number): string {
if (!Number.isFinite(timestamp) || timestamp <= 0) return '';
const ms = timestamp > 1e11 ? timestamp : timestamp * 1000;
return new Date(ms).toISOString();
}
/**
* Serialize the given payments list into a CSV string with a header row.
*/
export function paymentsToCsv(payments: BreezPaymentInfo[]): string {
const header = [
'date',
'type',
'status',
'amount_sats',
'fees_sats',
'description',
'invoice',
'payment_hash',
'preimage',
];
const rows = payments.map((p) =>
[
toIsoDate(Number(p.timestamp)),
p.paymentType,
p.status,
String(Number(p.amount) || 0),
String(Number(p.fees) || 0),
p.description ?? '',
p.invoice ?? '',
p.paymentHash ?? '',
p.preimage ?? '',
]
.map(csvCell)
.join(','),
);
return [header.map(csvCell).join(','), ...rows].join('\n');
}
/**
* Download the full payment list as a CSV named spark-transactions-YYYYMMDD.csv.
*/
export async function exportPaymentsCsv(
payments: BreezPaymentInfo[],
): Promise<void> {
const csv = paymentsToCsv(payments);
const today = new Date();
const y = today.getFullYear();
const m = String(today.getMonth() + 1).padStart(2, '0');
const d = String(today.getDate()).padStart(2, '0');
const filename = `spark-transactions-${y}${m}${d}.csv`;
await downloadTextFile(filename, csv);
}
+30
View File
@@ -0,0 +1,30 @@
/**
* Format a unix timestamp (ms) into a human-readable bucket key for grouping
* transactions: "today", "yesterday", or a localized date.
*
* Lives in lib/spark/ rather than alongside DateSeparator so the separator
* component file only exports React components (keeps Fast Refresh happy).
*/
export function formatDateBucket(timestampMs: number): string {
const now = new Date();
const target = new Date(timestampMs);
const startOfDay = (d: Date) =>
new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
const todayStart = startOfDay(now);
const targetStart = startOfDay(target);
const dayDiff = Math.round(
(todayStart - targetStart) / (24 * 60 * 60 * 1000),
);
if (dayDiff === 0) return 'today';
if (dayDiff === 1) return 'yesterday';
return target.toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
});
}
+1 -72
View File
@@ -8,7 +8,7 @@
* SECURITY: All sensitive data (mnemonic) is encrypted with NIP-44.
*/
import type { SparkWalletConfig, NostrSigner, LockTimeoutMinutes } from './types';
import type { SparkWalletConfig, NostrSigner } from './types';
import { logger } from '@/lib/logger';
const SPARK_SEED_KEY_PREFIX = 'spark_seed_';
@@ -337,77 +337,6 @@ export function validateMnemonicFormat(mnemonic: string): boolean {
// Legacy export for backward compatibility
export { validateMnemonicFormat as validateMnemonic };
/**
* Set the auto-lock timeout for the wallet
* @param timeout - Timeout in minutes (0 = disabled)
* @param pubkey - User's Nostr public key
*/
export function setLockTimeout(timeout: LockTimeoutMinutes, pubkey: string): void {
const config = loadSparkConfig(pubkey) || { hasWallet: true };
saveSparkConfig(pubkey, {
...config,
lockTimeout: timeout,
});
logger.debug('[SparkStorage] Lock timeout set to', timeout, 'minutes');
}
/**
* Get the current lock timeout setting
* @param pubkey - User's Nostr public key
* @returns Lock timeout in minutes (0 = disabled)
*/
export function getLockTimeout(pubkey: string): LockTimeoutMinutes {
const config = loadSparkConfig(pubkey);
return config?.lockTimeout ?? 0;
}
/**
* Update the last activity timestamp for auto-lock tracking
* @param pubkey - User's Nostr public key
*/
export function updateLastActivity(pubkey: string): void {
const config = loadSparkConfig(pubkey);
if (config) {
saveSparkConfig(pubkey, {
...config,
lastActivityAt: Date.now(),
});
}
}
/**
* Get the last activity timestamp
* @param pubkey - User's Nostr public key
* @returns Last activity timestamp or null if not set
*/
export function getLastActivity(pubkey: string): number | null {
const config = loadSparkConfig(pubkey);
return config?.lastActivityAt ?? null;
}
/**
* Check if wallet should be auto-locked based on inactivity
* @param pubkey - User's Nostr public key
* @returns True if wallet should be locked
*/
export function shouldAutoLock(pubkey: string): boolean {
const config = loadSparkConfig(pubkey);
const timeout = config?.lockTimeout;
if (timeout === undefined || timeout === 0) {
return false; // Auto-lock disabled
}
const lastActivity = config?.lastActivityAt;
if (!lastActivity) {
return false; // No activity recorded yet
}
const timeoutMs = timeout * 60 * 1000;
const timeSinceActivity = Date.now() - lastActivity;
return timeSinceActivity > timeoutMs;
}
// Legacy exports for backward compatibility
export {
loadSparkConfig as loadWalletConfig,
-7
View File
@@ -25,9 +25,6 @@ export interface SparkWalletState {
bitcoinAddress: string | null;
}
/** Auto-lock timeout options in minutes (0 = disabled) */
export type LockTimeoutMinutes = 0 | 1 | 5 | 15 | 30 | 60;
/** Wallet configuration stored locally */
export interface SparkWalletConfig {
hasWallet?: boolean;
@@ -38,10 +35,6 @@ export interface SparkWalletConfig {
createdAt?: number;
lud16?: string; // Lightning address if registered
encryptionVersion?: string;
/** Auto-lock timeout in minutes (0 = disabled) */
lockTimeout?: LockTimeoutMinutes;
/** Timestamp of last user activity (for auto-lock) */
lastActivityAt?: number;
}
/** File backup data structure (v2 format - compatible with zapcooking/sparkihonne) */
+31
View File
@@ -0,0 +1,31 @@
/**
* Wallet availability detection.
*
* The Breez SDK Spark wallet requires a runtime API key (VITE_BREEZ_API_KEY)
* to connect. Without it the SDK throws inside `connect()` with a generic
* error — which we used to surface as a confusing toast when the user
* actually tried to set up their wallet.
*
* `isWalletConfigured()` returns true when the build has a non-placeholder
* key wired up, so callers can short-circuit the wallet UI to a clear
* "feature disabled" state before any setup work begins.
*/
const PLACEHOLDER_KEYS = new Set([
'your-breez-api-key-here',
'YOUR_BREEZ_API_KEY',
'CHANGE_ME',
]);
export function getBreezApiKey(): string | undefined {
const key = import.meta.env?.VITE_BREEZ_API_KEY;
if (typeof key !== 'string') return undefined;
const trimmed = key.trim();
if (!trimmed) return undefined;
if (PLACEHOLDER_KEYS.has(trimmed)) return undefined;
return trimmed;
}
export function isWalletConfigured(): boolean {
return getBreezApiKey() !== undefined;
}
+4 -36
View File
@@ -2,22 +2,14 @@ import { useSeoMeta } from '@unhead/react';
import { PageHeader } from '@/components/PageHeader';
import { IntroImage } from '@/components/IntroImage';
import { AdvancedSettings } from '@/components/AdvancedSettings';
import { WalletSettings } from '@/components/WalletSettings';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAppContext } from '@/hooks/useAppContext';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { Button } from '@/components/ui/button';
import { ChevronDown, ChevronUp } from 'lucide-react';
import { useState } from 'react';
export function AdvancedSettingsPage() {
const { user } = useCurrentUser();
const { config } = useAppContext();
const [walletOpen, setWalletOpen] = useState(false);
useSeoMeta({
title: `Advanced | Settings | ${config.appName}`,
description: 'Advanced settings for wallet, system, and power user configuration',
description: 'Advanced system and power user configuration',
});
return (
@@ -30,7 +22,7 @@ export function AdvancedSettingsPage() {
<div className="flex-1 min-w-0">
<h1 className="text-xl font-bold">Advanced</h1>
<p className="text-sm text-muted-foreground mt-0.5">
Wallet connections, system configuration, and other advanced options for power users.
System configuration and other power user options.
</p>
</div>
}
@@ -43,36 +35,12 @@ export function AdvancedSettingsPage() {
<div className="min-w-0">
<h2 className="text-sm font-semibold">Power User Settings</h2>
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
Wallet connections, system configuration, and other advanced options.
System configuration and other power user options. Wallet
settings have moved to the top-level Wallet entry in Settings.
</p>
</div>
</div>
{/* Wallet collapsible — only when logged in */}
{user && (
<Collapsible open={walletOpen} onOpenChange={setWalletOpen}>
<CollapsibleTrigger asChild>
<Button
variant="ghost"
className="relative w-full justify-between px-3 py-3.5 h-auto hover:bg-muted/20 hover:text-foreground rounded-none"
>
<span className="text-base font-semibold">Wallet</span>
{walletOpen ? (
<ChevronUp className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
)}
<div className="absolute bottom-0 left-0 right-0 h-1 bg-primary rounded-full" />
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="pt-2 pb-4">
<WalletSettings />
</div>
</CollapsibleContent>
</Collapsible>
)}
<AdvancedSettings />
</div>
</main>
+8 -1
View File
@@ -62,10 +62,17 @@ const settingsSections: SettingsSection[] = [
path: '/settings/notifications',
requiresAuth: true,
},
{
id: 'wallet',
label: 'Wallet',
description: 'Self-custodial Agora Wallet, Lightning Address, and connected wallets',
path: '/settings/wallet',
requiresAuth: true,
},
{
id: 'advanced',
label: 'Advanced',
description: 'Wallet, system, and power user settings',
description: 'System and power user settings',
illustration: '/advanced-intro.png',
path: '/settings/advanced',
},
+325 -174
View File
@@ -1,72 +1,120 @@
/**
* Wallet Page
* Main wallet interface with balance, send, receive, and history.
*
* Ported from the legacy Agora (pathos) Wallet page. PageLayout is replaced
* with the agora-3 PageHeader + a 2xl content container.
* Signal-style dashboard:
* - Centered "Wallet" title with a Settings cog
* - Big balance number ("1 sats") rendered as text, no card wrapper
* - Three large circular action buttons: SEND / SCAN / RECEIVE
* - Date-grouped transaction history below
*
* Handles the full lifecycle: WASM capability check, login gate, first-run
* setup flow (create / restore), and the dashboard once a wallet is
* configured. Send and Receive open in dialogs; Scan opens the Send dialog
* pre-switched to the QR sub-tab.
*
* Respects a `?setup=create|restore` URL param so settings rows (e.g.
* "Restore existing wallet") can land directly on the right sub-flow
* even when a wallet is already configured on this device.
*/
import { useState, useEffect, type ReactNode } from "react";
import { useEffect, useState } from "react";
import { useSeoMeta } from "@unhead/react";
import { useTranslation } from "react-i18next";
import {
ArrowDownLeft,
ArrowUpRight,
Wallet as WalletIcon,
ArrowDown,
ArrowUp,
Loader2,
Plus,
RefreshCw,
Loader2,
ScanLine,
Settings,
Wallet as WalletIcon,
} from "lucide-react";
import { Link, useSearchParams } from "react-router-dom";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
Dialog,
DialogContent,
DialogTitle,
} from "@/components/ui/dialog";
import {
CreateWallet,
RestoreWallet,
WalletBalance,
ReceivePayment,
SendPayment,
PaymentHistory,
UnclaimedDeposits,
WasmUnsupportedError,
} from "@/components/SparkWallet";
import { WalletLockScreen } from "@/components/SparkWallet/WalletLockScreen";
import { CircleAction } from "@/components/SparkWallet/primitives";
import { PageHeader } from "@/components/PageHeader";
import { LoginArea } from "@/components/auth/LoginArea";
import { useSparkWallet } from "@/hooks/useSparkWallet";
import { useCurrentUser } from "@/hooks/useCurrentUser";
import { useAppContext } from "@/hooks/useAppContext";
import { LoginArea } from "@/components/auth/LoginArea";
import { PageHeader } from "@/components/PageHeader";
import { useLocalStorage } from "@/hooks/useLocalStorage";
import { useSatsToUsd } from "@/hooks/useExchangeRate";
import { checkWasmSupport } from "@/lib/checkWasmSupport";
import { isWalletConfigured } from "@/lib/spark/walletAvailability";
type SetupMode = "choice" | "create" | "restore" | null;
/** Which dialog is open. The Send dialog also accepts an initial sub-tab. */
type ActionDialog =
| { kind: "send"; initialTab?: "list" | "qr" | "keyboard" }
| { kind: "receive" }
| null;
function WalletShell({ children }: { children: ReactNode }) {
return (
<main>
<PageHeader title="Wallet" icon={<WalletIcon className="size-5" />} />
<div className="max-w-2xl mx-auto px-4 py-4 sm:py-6">{children}</div>
</main>
);
function formatUsd(value: number): string {
return value.toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
}
export function WalletPage() {
const { t } = useTranslation();
const { config } = useAppContext();
const [setupMode, setSetupMode] = useState<SetupMode>(null);
const [activeTab, setActiveTab] = useState("overview");
const [searchParams, setSearchParams] = useSearchParams();
// Honor ?setup=create|restore in the URL so settings rows can deep-link
// straight into the setup flow even when a wallet is already configured.
// The initial setupMode is derived synchronously from the URL param on
// first render — using a useEffect here would cause the dashboard to
// flash briefly before flipping to setup, which is what broke the
// "Restore existing wallet" entry point.
const [setupMode, setSetupMode] = useState<SetupMode>(() => {
const s = searchParams.get('setup');
return s === 'create' || s === 'restore' ? s : null;
});
// Clear the setup query param once consumed so back/forward and
// re-renders don't re-trigger the setup flow. Runs after the initial
// synchronous state above, so there's no race with setupMode.
useEffect(() => {
if (searchParams.get('setup')) {
const next = new URLSearchParams(searchParams);
next.delete('setup');
setSearchParams(next, { replace: true });
}
}, [searchParams, setSearchParams]);
const [actionDialog, setActionDialog] = useState<ActionDialog>(null);
// Currency display preference for the dashboard balance, persisted across
// sessions in localStorage. Defaults to sats since this is a Bitcoin
// wallet and most Lightning UIs default to sats. The same key is used
// by every account on this device — the choice is a personal display
// preference, not a per-pubkey setting.
const [balanceMode, setBalanceMode] = useLocalStorage<"sats" | "usd">(
"agora:wallet:balance-mode",
"sats",
);
// WASM support check
const [wasmSupported, setWasmSupported] = useState<boolean | null>(null);
const [wasmError, setWasmError] = useState<string | null>(null);
const { hasWallet, balance, hasBackup, isCheckingBackup, isLocked } =
useSparkWallet();
const { hasWallet, balance, hasBackup, isCheckingBackup } = useSparkWallet();
const { user } = useCurrentUser();
const usdValue = useSatsToUsd(balance);
useEffect(() => {
checkWasmSupport().then((result) => {
@@ -78,234 +126,337 @@ export function WalletPage() {
}, []);
useSeoMeta({
title: `${t("wallet.title")} | ${config.appName}`,
title: `Wallet | ${config.appName}`,
description:
"Manage your self-custodial Lightning wallet. Send and receive Bitcoin payments instantly.",
});
// Centered "Wallet" header with a Settings cog. Back arrow is always
// visible on the left so the page works as a standalone screen on
// mobile.
const header = (
<PageHeader
backTo="/"
alwaysShowBack
titleContent={
<div className="flex-1 min-w-0 text-center">
<h1 className="text-xl font-bold">Wallet</h1>
</div>
}
>
<Button asChild size="icon" variant="ghost" title="Wallet settings">
<Link to="/settings/wallet">
<Settings className="size-5" />
</Link>
</Button>
</PageHeader>
);
// Show loading while checking WASM support
if (wasmSupported === null) {
return (
<WalletShell>
<Card>
<CardContent className="py-12">
<main>
{header}
<div className="p-4">
<div className="rounded-2xl border bg-card/40 py-12 px-6">
<div className="flex flex-col items-center justify-center space-y-4">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
<p className="text-sm text-muted-foreground">
Checking browser compatibility...
</p>
</div>
</CardContent>
</Card>
</WalletShell>
</div>
</div>
</main>
);
}
// Show error if WASM is not supported (e.g. iOS Lockdown Mode)
if (wasmSupported === false) {
return (
<WalletShell>
<WasmUnsupportedError technicalDetails={wasmError ?? undefined} />
</WalletShell>
<main>
{header}
<div className="p-4">
<WasmUnsupportedError technicalDetails={wasmError ?? undefined} />
</div>
</main>
);
}
// The wallet feature requires a Breez API key at build time. If this build
// doesn't have one, surface a clear "feature unavailable" state instead of
// letting the user click through setup only to hit a connection error.
if (!isWalletConfigured()) {
return (
<main>
{header}
<div className="p-4">
<div className="rounded-2xl border bg-card/40 p-6 text-center space-y-4">
<div className="mx-auto w-14 h-14 rounded-full bg-muted flex items-center justify-center">
<WalletIcon className="size-7 text-muted-foreground" />
</div>
<div className="space-y-1">
<p className="font-semibold">Wallet not available</p>
<p className="text-sm text-muted-foreground max-w-sm mx-auto">
The built-in wallet isn't enabled in this build. You can still
send zaps using a Nostr Wallet Connect or WebLN wallet from
Settings &rarr; Wallet.
</p>
<p className="text-xs text-muted-foreground/80 max-w-sm mx-auto pt-2">
Self-hosting? Set <code className="font-mono">VITE_BREEZ_API_KEY</code>
{' '}in your environment to enable. Request one at{' '}
<a
href="https://breez.technology/request-api-key/"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
breez.technology/request-api-key
</a>.
</p>
</div>
<Button asChild variant="outline" size="lg" className="rounded-full">
<Link to="/settings/wallet">
<Settings className="size-4 mr-2" />
Wallet Settings
</Link>
</Button>
</div>
</div>
</main>
);
}
// Show login prompt if not logged in
if (!user) {
return (
<WalletShell>
<Card className="bg-primary/5 border-primary/20">
<CardContent className="pt-6">
<div className="text-center space-y-4">
<div className="w-16 h-16 mx-auto rounded-full bg-primary/20 flex items-center justify-center">
<WalletIcon className="h-8 w-8 text-primary" />
</div>
<h3 className="font-semibold text-lg">Wallet</h3>
<p className="text-muted-foreground">
You need to be logged in with your Nostr account to create or
access your wallet.
</p>
<LoginArea className="justify-center" />
<main>
{header}
<div className="p-4">
<div className="rounded-2xl border border-primary/20 bg-primary/5 p-6 text-center space-y-4">
<div className="w-16 h-16 mx-auto rounded-full bg-primary/20 flex items-center justify-center">
<WalletIcon className="h-8 w-8 text-primary" />
</div>
</CardContent>
</Card>
</WalletShell>
<h3 className="font-semibold text-lg">Wallet</h3>
<p className="text-muted-foreground">
You need to be logged in with your Nostr account to create or
access your wallet.
</p>
<LoginArea className="justify-center" />
</div>
</div>
</main>
);
}
if (hasWallet && isLocked) {
return (
<WalletShell>
<WalletLockScreen />
</WalletShell>
);
}
// Show setup flow when no wallet exists OR when actively in setup mode.
// Keep setup visible even after wallet is created so the user completes
// the backup flow (CreateWallet's internal steps clear setupMode on finish).
if (!hasWallet || setupMode === "create" || setupMode === "restore") {
if (setupMode === "create") {
return (
<WalletShell>
<CreateWallet
onComplete={() => setSetupMode(null)}
onCancel={() => setSetupMode("choice")}
/>
</WalletShell>
<main>
{header}
<div className="p-4">
<CreateWallet
onComplete={() => setSetupMode(null)}
onCancel={() => setSetupMode("choice")}
/>
</div>
</main>
);
}
if (setupMode === "restore") {
return (
<WalletShell>
<RestoreWallet
onComplete={() => setSetupMode(null)}
onCancel={() => setSetupMode("choice")}
/>
</WalletShell>
<main>
{header}
<div className="p-4">
<RestoreWallet
onComplete={() => setSetupMode(null)}
onCancel={() => setSetupMode("choice")}
/>
</div>
</main>
);
}
// Setup choice screen
return (
<WalletShell>
<Card className="bg-card/50 backdrop-blur-sm border-border/50">
<CardHeader className="text-center">
<div className="mx-auto w-16 h-16 bg-primary/10 rounded-full flex items-center justify-center mb-4">
<WalletIcon className="h-8 w-8 text-primary" />
<main>
{header}
<div className="p-4">
<div className="rounded-2xl border bg-card/40 p-6 space-y-5">
<div className="text-center space-y-2">
<div className="mx-auto w-16 h-16 bg-primary/10 rounded-full flex items-center justify-center mb-2">
<WalletIcon className="h-8 w-8 text-primary" />
</div>
<h2 className="text-lg font-semibold">Agora Wallet</h2>
<p className="text-sm text-muted-foreground max-w-sm mx-auto">
Your self-custodial Lightning wallet for instant Bitcoin
payments.
</p>
</div>
<CardTitle>{t('wallet.title')}</CardTitle>
<CardDescription>{t('wallet.selfCustodialWallet')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{isCheckingBackup ? (
<div className="py-4 text-center">
<Loader2 className="h-6 w-6 animate-spin mx-auto mb-2 text-muted-foreground" />
<p className="text-sm text-muted-foreground">
{t('wallet.checkingBackup')}
Checking for existing backups...
</p>
</div>
) : hasBackup ? (
<>
<Card className="bg-muted/50 border-dashed">
<CardContent className="py-4 text-center">
<p className="text-sm font-medium">
{t('wallet.backupFound')}
</p>
<p className="text-xs text-muted-foreground mt-1">
{t('wallet.canRestore')}
</p>
</CardContent>
</Card>
<div className="space-y-3">
<div className="rounded-2xl border-dashed border bg-muted/40 p-4 text-center">
<p className="text-sm font-medium">
Backup found on your relays
</p>
<p className="text-xs text-muted-foreground mt-1">
Restore to access your wallet
</p>
</div>
<Button
onClick={() => setSetupMode("restore")}
className="w-full"
className="w-full rounded-full h-12"
size="lg"
>
<RefreshCw className="h-4 w-4 mr-2" />
{t('wallet.restoreExistingWallet')}
Restore Existing Wallet
</Button>
<Button
onClick={() => setSetupMode("create")}
variant="outline"
className="w-full"
className="w-full rounded-full h-12"
size="lg"
>
<Plus className="h-4 w-4 mr-2" />
{t('wallet.createNewWallet')}
Create New Wallet
</Button>
</>
</div>
) : (
<>
<div className="space-y-3">
<Button
onClick={() => setSetupMode("create")}
className="w-full"
className="w-full rounded-full h-12"
size="lg"
>
<Plus className="h-4 w-4 mr-2" />
{t('wallet.createNewWallet')}
Create New Wallet
</Button>
<Button
onClick={() => setSetupMode("restore")}
variant="outline"
className="w-full"
className="w-full rounded-full h-12"
size="lg"
>
<RefreshCw className="h-4 w-4 mr-2" />
{t('wallet.restoreExistingWallet')}
Restore Existing Wallet
</Button>
</>
</div>
)}
</CardContent>
</Card>
</WalletShell>
</div>
</div>
</main>
);
}
// Main wallet interface — Signal-style dashboard
return (
<WalletShell>
<WalletBalance className="mb-6" />
<UnclaimedDeposits className="mb-6" />
<div className="grid grid-cols-2 gap-4 mb-6">
<Button
size="lg"
className="h-16 text-lg"
onClick={() => setActiveTab("receive")}
<main>
{header}
<div className="p-4 max-w-xl mx-auto w-full">
{/* Big centered balance. Tapping toggles between sats and USD. */}
<button
type="button"
onClick={() => setBalanceMode((m) => (m === "sats" ? "usd" : "sats"))}
className="block w-full py-10 text-center select-none"
title="Toggle between sats and USD"
>
<ArrowDownLeft className="h-5 w-5 mr-2" />
Receive
</Button>
<Button
size="lg"
variant="outline"
className="h-16 text-lg"
onClick={() => setActiveTab("send")}
disabled={balance === 0}
>
<ArrowUpRight className="h-5 w-5 mr-2" />
Send
</Button>
<div className="inline-flex items-baseline gap-2">
{balanceMode === "usd" && usdValue !== null ? (
<>
<span className="text-5xl font-semibold tabular-nums">
${formatUsd(usdValue)}
</span>
<span className="text-base text-muted-foreground">USD</span>
</>
) : (
<>
<span className="text-5xl font-semibold tabular-nums">
{balance.toLocaleString()}
</span>
<span className="text-base text-muted-foreground">sats</span>
</>
)}
</div>
{balanceMode === "sats" && usdValue !== null && (
<p className="mt-1 text-sm text-muted-foreground">
${formatUsd(usdValue)}
</p>
)}
</button>
{/* Three circular actions: SEND / SCAN / RECEIVE */}
<div className="flex items-start justify-center gap-10 mb-10">
<CircleAction
icon={<ArrowUp />}
label="SEND"
onClick={() => setActionDialog({ kind: "send", initialTab: "list" })}
disabled={balance === 0}
/>
<CircleAction
icon={<ScanLine />}
label="SCAN"
onClick={() => setActionDialog({ kind: "send", initialTab: "qr" })}
disabled={balance === 0}
/>
<CircleAction
icon={<ArrowDown />}
label="RECEIVE"
onClick={() => setActionDialog({ kind: "receive" })}
/>
</div>
{/* Unclaimed on-chain deposits (only rendered when there are any) */}
<UnclaimedDeposits className="mb-6" />
{/* Date-grouped transaction history */}
<PaymentHistory />
</div>
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="overview">{t('wallet.transactions')}</TabsTrigger>
<TabsTrigger value="receive">Receive</TabsTrigger>
<TabsTrigger value="send">Send</TabsTrigger>
</TabsList>
{/* Receive dialog */}
<Dialog
open={actionDialog?.kind === "receive"}
onOpenChange={(open) => !open && setActionDialog(null)}
>
<DialogContent className="sm:max-w-md max-h-[95vh] overflow-hidden rounded-2xl p-0 gap-0 [&>button]:hidden">
<DialogTitle className="sr-only">Receive payment</DialogTitle>
<ReceivePayment onClose={() => setActionDialog(null)} />
</DialogContent>
</Dialog>
<TabsContent value="overview" className="mt-4">
<PaymentHistory />
</TabsContent>
<TabsContent value="receive" className="mt-4">
<Card>
<CardHeader>
<CardTitle className="text-lg">Receive Payment</CardTitle>
<CardDescription>
Choose how you want to receive funds
</CardDescription>
</CardHeader>
<CardContent>
<ReceivePayment />
</CardContent>
</Card>
</TabsContent>
<TabsContent value="send" className="mt-4">
<Card>
<CardHeader>
<CardTitle className="text-lg">Send Payment</CardTitle>
<CardDescription>
Send Lightning payments to anyone
</CardDescription>
</CardHeader>
<CardContent>
<SendPayment onSuccess={() => setActiveTab("overview")} />
</CardContent>
</Card>
</TabsContent>
</Tabs>
</WalletShell>
{/* Send dialog */}
<Dialog
open={actionDialog?.kind === "send"}
onOpenChange={(open) => !open && setActionDialog(null)}
>
<DialogContent className="sm:max-w-md max-h-[95vh] overflow-hidden rounded-2xl p-0 gap-0 [&>button]:hidden">
<DialogTitle className="sr-only">Send payment</DialogTitle>
<SendPayment
initialTab={
actionDialog?.kind === "send" ? actionDialog.initialTab : undefined
}
onSuccess={() => setActionDialog(null)}
onClose={() => setActionDialog(null)}
/>
</DialogContent>
</Dialog>
</main>
);
}
+51 -13
View File
@@ -2,18 +2,30 @@ import { useSeoMeta } from '@unhead/react';
import { Navigate } from 'react-router-dom';
import { PageHeader } from '@/components/PageHeader';
import { HelpTip } from '@/components/HelpTip';
import { WalletSettings } from '@/components/WalletSettings';
import { WalletSettingsContent } from '@/components/WalletSettingsContent';
import { SparkWalletSettings } from '@/components/SparkWalletSettings';
import { WebLNSettings } from '@/components/WebLNSettings';
import { NWCSettings } from '@/components/NWCSettings';
import { useAppContext } from '@/hooks/useAppContext';
import { useCurrentUser } from '@/hooks/useCurrentUser';
/**
* Wallet Settings
*
* Lists each supported wallet type as its own self-contained section with a
* consistent header + underline treatment (matching NetworkSettingsPage).
* Users configure and manage each wallet independently:
* - Agora Wallet : self-custodial Lightning wallet (on-device seed,
* powered by Spark)
* - WebLN : status card for a browser extension wallet
* - NWC : Nostr Wallet Connect remote connections
*/
export function WalletSettingsPage() {
const { user } = useCurrentUser();
const { config } = useAppContext();
useSeoMeta({
title: `Wallet | Settings | ${config.appName}`,
description: 'Manage your Spark wallet, recovery phrase, lightning address, and external wallet connections.',
description: 'Manage your wallet connections',
});
if (!user) {
@@ -28,23 +40,49 @@ export function WalletSettingsPage() {
alwaysShowBack
titleContent={
<div className="flex-1 min-w-0">
<h1 className="text-xl font-bold flex items-center gap-1.5">Wallet <HelpTip faqId="connect-wallet" /></h1>
<h1 className="text-xl font-bold flex items-center gap-1.5">
Wallet <HelpTip faqId="connect-wallet" />
</h1>
<p className="text-sm text-muted-foreground mt-0.5">
Manage your built-in wallet, backups, and external connections
Manage wallet connections and payments
</p>
</div>
}
/>
<div className="p-4 space-y-8">
{/* Spark wallet: backup, recovery phrase, lightning address, security, danger zone */}
<WalletSettingsContent />
<div className="p-4">
{/* Agora Wallet (self-custodial, powered by Spark) */}
<div>
<div className="relative px-3 py-3.5">
<h2 className="text-base font-semibold">Agora Wallet</h2>
<div className="absolute bottom-0 left-0 right-0 h-1 bg-primary rounded-full" />
</div>
<div className="pt-4 pb-6">
<SparkWalletSettings />
</div>
</div>
{/* External wallet connections (NWC + WebLN) */}
<section>
<h2 className="text-lg font-semibold mb-3">External Connections</h2>
<WalletSettings />
</section>
{/* WebLN (browser extension) */}
<div>
<div className="relative px-3 py-3.5">
<h2 className="text-base font-semibold">WebLN</h2>
<div className="absolute bottom-0 left-0 right-0 h-1 bg-primary rounded-full" />
</div>
<div className="pt-4 pb-6">
<WebLNSettings />
</div>
</div>
{/* Nostr Wallet Connect */}
<div>
<div className="relative px-3 py-3.5">
<h2 className="text-base font-semibold">Nostr Wallet Connect</h2>
<div className="absolute bottom-0 left-0 right-0 h-1 bg-primary rounded-full" />
</div>
<div className="pt-4 pb-6">
<NWCSettings />
</div>
</div>
</div>
</main>
);
+4 -1
View File
@@ -171,7 +171,10 @@ export default defineConfig(({ mode }) => {
},
},
optimizeDeps: {
exclude: ['@capacitor/filesystem', '@capacitor/share'],
exclude: ['@capacitor/filesystem', '@capacitor/share', '@breeztech/breez-sdk-spark'],
},
worker: {
format: 'es',
},
resolve: {
alias: {