Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a7df27c754 |
+5
-1
@@ -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=""
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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"),
|
||||
|
||||
Generated
+81
-28
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)}`;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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') ||
|
||||
|
||||
@@ -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.',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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) */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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 → 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user