Compare commits
159 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0f4e69344c | |||
| 3500afb3f9 | |||
| 8503cea367 | |||
| 31ccaa2f99 | |||
| b0a81b5d94 | |||
| 035119091b | |||
| 741ec3ed09 | |||
| 30d3d85743 | |||
| 1a835a5fe6 | |||
| 4adbed2c1b | |||
| e9bc52030f | |||
| f3deb14c8b | |||
| 85601bdca8 | |||
| 1d35f1fe63 | |||
| 24842d5a05 | |||
| 48e18c16b6 | |||
| 5db139b930 | |||
| 4fd320d5c0 | |||
| 656ea70492 | |||
| ae8d3cea56 | |||
| 316f6dd8ec | |||
| c662db2ce0 | |||
| e562f7d0a2 | |||
| c2a80df9ed | |||
| 239c83f1a8 | |||
| 07927c1911 | |||
| e806b373d3 | |||
| fea8166472 | |||
| 6e017a88be | |||
| 0645a60f3a | |||
| ccf64f5906 | |||
| ab03489a3f | |||
| 29a5ede59a | |||
| 425923a4dd | |||
| 55a2321330 | |||
| 59196af579 | |||
| 6b01a24248 | |||
| 8f4bea1210 | |||
| 90e06e328f | |||
| fbed6aa0ff | |||
| f49ca00c09 | |||
| 23773d352c | |||
| 5d4b9cf2f5 | |||
| 72c7520a12 | |||
| 8a908cd11c | |||
| a60a757f0f | |||
| f2fa16c3bb | |||
| 0d334e89e7 | |||
| 886d3ece18 | |||
| 7ffaccb304 | |||
| e196227a23 | |||
| 818afe9bbf | |||
| c2179fef2b | |||
| d0d315a9b2 | |||
| debeaddba2 | |||
| 1324506b20 | |||
| 34556ceed2 | |||
| 706a3ef2eb | |||
| 43fecb6e6f | |||
| 33852f60fb | |||
| 2e5d160a9e | |||
| 21465ebc5f | |||
| 8f0a215d54 | |||
| 8bd8fe7d05 | |||
| 8f750a222f | |||
| 0c4465bed3 | |||
| e67c5dba75 | |||
| e8a9f679f9 | |||
| 59c0d25fa6 | |||
| b271c4e889 | |||
| f5398acb22 | |||
| 4db37f9217 | |||
| 10a1c53e6a | |||
| b20e49bf20 | |||
| c9d77d06a1 | |||
| ab1a4ba0e8 | |||
| 101926e961 | |||
| 9351d3e243 | |||
| 0233a75d5c | |||
| 450989f6ca | |||
| cf0caa8c85 | |||
| 2242692794 | |||
| 8473a4990f | |||
| e5277f004e | |||
| 93bcf04ae9 | |||
| f95ab1b422 | |||
| a4be9d9fbb | |||
| 1557e2fff9 | |||
| ba4a7f4e35 | |||
| b8c1bc7409 | |||
| 15718a575f | |||
| 7775c0477f | |||
| 07f77b8a99 | |||
| 872e8428d2 | |||
| a8b2fe5ddf | |||
| a2dd16fc94 | |||
| 0c455c6d6f | |||
| f0af799647 | |||
| 4cd725daf1 | |||
| e7439611b1 | |||
| 168ca2d067 | |||
| e9eebaeeca | |||
| 7a18d500ee | |||
| 54c711b3be | |||
| 79c6e7e516 | |||
| 9717a6827f | |||
| f6c7bc366d | |||
| 4c7d059b0b | |||
| eae5e1c3a7 | |||
| e7c488af63 | |||
| 4153792e54 | |||
| c731256efb | |||
| 702d374a06 | |||
| b174152566 | |||
| 6a5c426648 | |||
| f8547668b2 | |||
| 49049f98e7 | |||
| 048878b699 | |||
| 476a3856ec | |||
| d69cfa0862 | |||
| a5cc9c5163 | |||
| 42ac269a56 | |||
| caa8e70703 | |||
| 8f53e3e53b | |||
| 5d4d0825c6 | |||
| b9b7351361 | |||
| 6dcae6385a | |||
| 13386bf0fd | |||
| 2ae2a3da18 | |||
| 1c06e070cd | |||
| f0c3ff1a80 | |||
| 13a0bb3e3a | |||
| 646ed9777f | |||
| 437613641a | |||
| d0836328a4 | |||
| 123f53e7a6 | |||
| 977fd000ea | |||
| 5132141aa2 | |||
| b6dc57eb85 | |||
| 016a7b4a7d | |||
| 7ae63883e9 | |||
| d4cf4ba0d8 | |||
| 399dc53395 | |||
| 699e505fb5 | |||
| 20839f4de3 | |||
| 4e9da2d168 | |||
| 32b477bd01 | |||
| 564459e12d | |||
| c97d0723a6 | |||
| 53da626461 | |||
| c79699ca71 | |||
| e58c031a85 | |||
| bc80dba826 | |||
| 611f97488e | |||
| a948725245 | |||
| dde9865284 | |||
| 3d825aef04 | |||
| 575603554b | |||
| dfb0a52603 |
@@ -167,23 +167,33 @@ build-apk:
|
||||
# Write local.properties for Gradle
|
||||
- echo "sdk.dir=$ANDROID_SDK_ROOT" > android/local.properties
|
||||
|
||||
# Decode signing keystore and migrate JKS -> PKCS12 for Gradle compatibility
|
||||
# Decode signing keystore and migrate JKS -> PKCS12 for Gradle compatibility.
|
||||
# PKCS12 conceptually uses one password for the store and every entry; if the
|
||||
# store and key passwords differ, keytool protects the migrated entry with the
|
||||
# STORE password regardless of -destkeypass, so Gradle's later read with the
|
||||
# key password fails ("Given final block not properly padded"). Unlock the
|
||||
# source key with its own password ($KEY_PASSWORD), then write the PKCS12 with
|
||||
# a single uniform password ($KEY_PASSWORD) for both store and entry so the
|
||||
# key.properties below is internally consistent.
|
||||
- echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > android/app/my-upload-key.jks
|
||||
- keytool -importkeystore
|
||||
-srckeystore android/app/my-upload-key.jks
|
||||
-destkeystore android/app/my-upload-key.keystore
|
||||
-deststoretype pkcs12
|
||||
-srcstorepass "$KEYSTORE_PASSWORD"
|
||||
-deststorepass "$KEYSTORE_PASSWORD"
|
||||
-srcalias upload
|
||||
-destalias upload
|
||||
-srckeypass "$KEY_PASSWORD"
|
||||
-deststorepass "$KEY_PASSWORD"
|
||||
-destkeypass "$KEY_PASSWORD"
|
||||
-noprompt
|
||||
- rm android/app/my-upload-key.jks
|
||||
|
||||
# Write key.properties from CI/CD variables
|
||||
# Write key.properties from CI/CD variables. The PKCS12 above uses
|
||||
# $KEY_PASSWORD uniformly, so both storePassword and keyPassword point to it.
|
||||
- |
|
||||
cat > android/key.properties << EOF
|
||||
storePassword=$KEYSTORE_PASSWORD
|
||||
storePassword=$KEY_PASSWORD
|
||||
keyPassword=$KEY_PASSWORD
|
||||
keyAlias=upload
|
||||
storeFile=my-upload-key.keystore
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
# Project Overview
|
||||
|
||||
Agora is a Nostr client built with React 19.x, TailwindCSS 3.x, Vite, shadcn/ui, and Nostrify, wrapped as a native iOS/Android app via Capacitor.
|
||||
Agora is a peer-to-peer crowdfunding Nostr client built with React 19.x, TailwindCSS 3.x, Vite, shadcn/ui, and Nostrify, wrapped as a native iOS/Android app via Capacitor.
|
||||
|
||||
Donations are **on-chain Bitcoin** — donors pay a campaign's Bitcoin address directly. Agora ships an integrated **non-custodial HD Bitcoin wallet** (deterministically derived from the user's Nostr key) with BIP-86 Taproot and **BIP-352 silent-payment** support. The app never custodies or converts funds; it is a non-custodial UI that connects donors and campaigns peer-to-peer.
|
||||
|
||||
**This is not a Lightning project.** Lightning (`useZaps`, `useWallet`, `useNWC`, LNURL/NWC/WebLN) survives only as a secondary *tipping* path for notes/profiles and a deprecated Breez/Spark wallet in recovery-only mode — never for campaign donations. The crowdfunding core is strictly on-chain.
|
||||
|
||||
## Technology Stack
|
||||
|
||||
@@ -17,7 +21,7 @@ Agora is a Nostr client built with React 19.x, TailwindCSS 3.x, Vite, shadcn/ui,
|
||||
## Project Structure
|
||||
|
||||
- `/src/components/` — UI components. `ui/` holds shadcn primitives; `auth/` holds login components.
|
||||
- `/src/hooks/` — custom hooks. Discover the full set with `ls src/hooks/`. Key ones: `useNostr`, `useAuthor`, `useCurrentUser`, `useNostrPublish`, `useUploadFile`, `useAppContext`, `useTheme`, `useToast`, `useLoggedInAccounts`, `useLoginActions`, `useIsMobile`, `useZaps`, `useWallet`, `useNWC`, `useShakespeare`.
|
||||
- `/src/hooks/` — custom hooks. Discover the full set with `ls src/hooks/`. Core Nostr: `useNostr`, `useAuthor`, `useCurrentUser`, `useNostrPublish`, `useUploadFile`, `useAppContext`, `useTheme`, `useToast`, `useLoggedInAccounts`, `useLoginActions`, `useIsMobile`. **On-chain wallet & crowdfunding (the headline feature):** `useHdWallet`, `useHdWalletSp` (BIP-352 silent payments), `useBitcoinSigner`, `useDonateCampaign`, `useCampaign`/`useCampaigns`, `useCampaignDonations`, `useOnchainZap`. **Lightning (secondary tipping only, not campaigns):** `useZaps`, `useWallet` (NWC/WebLN status — *not* the on-chain wallet), `useNWC`.
|
||||
- `/src/pages/` — page components wired into `AppRouter.tsx`. The catch-all `/:nip19` route is handled by `NIP19Page.tsx` (see the `nip19-routing` skill).
|
||||
- `/src/lib/` — utility functions and shared logic.
|
||||
- `/src/contexts/` — React context providers (`AppContext`, `NWCContext`).
|
||||
|
||||
@@ -1,5 +1,90 @@
|
||||
# Changelog
|
||||
|
||||
## [2.8.9] - 2026-06-02
|
||||
|
||||
Adds an in-app prompt to grab the Android app from Zapstore, makes it easier to start or explore campaigns right from the home page, and irons out a batch of language and display fixes.
|
||||
|
||||
### Added
|
||||
|
||||
- A prompt to download the Android app from Zapstore, shown to mobile web visitors on the home page, in the account menu, and in the slide-out menu.
|
||||
- A "Start a campaign" button alongside "Browse all" in the middle of the home page.
|
||||
|
||||
### Changed
|
||||
|
||||
- The "Explore campaigns" button now appears for everyone, not just logged-out visitors.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Switching languages now takes effect immediately instead of showing stale text.
|
||||
- The reply box and the replies heading on a post now show up in your chosen language.
|
||||
- Account balances keep their Latin numerals regardless of display language.
|
||||
- Filled in missing translations on the "Why Agora" screen.
|
||||
|
||||
## [2.8.8] - 2026-06-02
|
||||
|
||||
Fixes the app icon proportions and updates the loading splash to the Agora bolt.
|
||||
|
||||
### Fixed
|
||||
|
||||
- App icon no longer appears squashed.
|
||||
- Loading splash now shows the Agora bolt instead of the old logo.
|
||||
|
||||
## [2.8.7] - 2026-06-02
|
||||
|
||||
Fixes the top navigation bar rendering behind the status bar on Android.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Top navigation bar now clears the system status bar on Android.
|
||||
|
||||
## [2.8.6] - 2026-06-02
|
||||
|
||||
Refreshes the app icon to the orange Agora bolt mark across Android, iOS, and the web.
|
||||
|
||||
### Changed
|
||||
|
||||
- Update the app icon to the current Agora bolt on a brand-orange background.
|
||||
|
||||
## [2.8.5] - 2026-06-02
|
||||
|
||||
A maintenance release that fixes the Android build so signed releases publish correctly. No user-facing changes.
|
||||
|
||||
## [2.8.4] - 2026-06-02
|
||||
|
||||
A maintenance release that fixes the Android build so signed releases publish correctly. No user-facing changes.
|
||||
|
||||
## [2.8.3] - 2026-06-02
|
||||
|
||||
A maintenance release that fixes the Android build so signed releases publish correctly. No user-facing changes.
|
||||
|
||||
## [2.8.2] - 2026-06-02
|
||||
|
||||
A maintenance release that fixes the Android build pipeline so signed releases publish correctly. No user-facing changes.
|
||||
|
||||
## [2.8.1] - 2026-06-02
|
||||
|
||||
Agora becomes a home for putting your money where your heart is. Launch and back fundraising campaigns, rally around organizations with their own events and pledges, and send support straight from a built-in Bitcoin and Lightning wallet. Explore the world through immersive country pages, chat with a new AI agent, and move through a faster, cleaner app with a fresh look throughout.
|
||||
|
||||
### Added
|
||||
|
||||
- Fundraising campaigns as the new home surface — create, edit, and back campaigns, set goals, add beneficiaries, and follow progress.
|
||||
- Organizations with their own events, pledges, members, and moderation tools.
|
||||
- Built-in wallet for sending Bitcoin and Lightning payments, with transaction history and balances shown in USD.
|
||||
- One-tap support: zap posts, profiles, campaigns, and organizations.
|
||||
- AI agent chat with a model selector, tool-calling, and slash commands.
|
||||
- Immersive country pages with anthems, flags, weather, and a country-scoped feed, plus a new Discover square for exploring the world.
|
||||
- Comments and reactions on campaigns, and donation receipts shown inline.
|
||||
|
||||
### Changed
|
||||
|
||||
- Refreshed Agora branding, navigation, and app icons throughout.
|
||||
- Streamlined onboarding with country and people follows.
|
||||
- Polished campaign, organization, and donation flows end to end.
|
||||
|
||||
### Removed
|
||||
|
||||
- Direct messaging and ephemeral geo chat.
|
||||
|
||||
## [1.0.0] - 2026-04-30
|
||||
|
||||
### Added
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
| 33863 | Campaign | Self-authored fundraising campaign with a single Bitcoin wallet endpoint (`bc1...` or `sp1...`) |
|
||||
| 30385 | Community Stats Snapshot | Pre-computed per-country / global community leaderboards |
|
||||
| 36639 | Pledge | Donor pledge for concrete submissions, stored as sats |
|
||||
| 14672 | Verifier Statement | Self-authored statement describing how the author verifies campaigns (one per user) |
|
||||
|
||||
### Agora Protocols
|
||||
|
||||
@@ -23,6 +24,7 @@
|
||||
| Flat Communities | 34550, 30009, 8, 1111, 1984 | One-level badge membership with explicit moderators (NIP-72 ext) |
|
||||
| Community Chat | 34550, 1311 | Realtime member chat scoped to a NIP-72 community |
|
||||
| Campaign Moderation | 33863, 34550, 36639, 1985, 39089 | Discovery curation (hidden + featured axes) via moderator-signed labels in the `agora.moderation` namespace, gated by a follow-pack moderator roster. Covers campaigns, organizations, and pledges identically. |
|
||||
| Campaign Verification | 33863, 1985 | Positive trust signal: moderator-signed NIP-32 labels in the `agora.verified` namespace (value `verified`) vouching for a campaign. Gated by the same moderator pack as hide/feature; retracted via kind 5 deletion. |
|
||||
| HD Wallet Derivation | — | BIP-39 mnemonic deterministically derived from the user's nsec via HKDF; seeds a BIP-86 Taproot + BIP-352 silent-payment wallet importable into any BIP-39-compatible wallet (see [Agora HD Wallet](#agora-hd-wallet-derivation) below). |
|
||||
|
||||
### Agora Content Marker
|
||||
@@ -282,11 +284,11 @@ The two zap kinds are complementary. Clients SHOULD sum verified amounts from bo
|
||||
|
||||
### Summary
|
||||
|
||||
Addressable event representing a **self-authored fundraising campaign**. A campaign carries marketing-style metadata (title, summary, banner image, markdown story, optional goal, optional deadline, optional country) and one or two Bitcoin wallet endpoints declared in `w` tags. Each wallet endpoint is either a public on-chain bech32(m) address (`bc1q…`, `bc1p…`) or a silent-payment code (`sp1…`, per BIP-352). The mode of each endpoint is inferred from the prefix — the client renders a QR code that combines the present endpoints and adjusts the donation-progress UI accordingly. A campaign MAY declare **at most one** endpoint per mode (at most one on-chain address and at most one silent-payment code).
|
||||
Addressable event representing a **self-authored fundraising campaign**. A campaign carries marketing-style metadata (title, summary, banner image, markdown story, optional goal, optional country) and one or two Bitcoin wallet endpoints declared in `w` tags. Each wallet endpoint is either a public on-chain bech32(m) address (`bc1q…`, `bc1p…`) or a silent-payment code (`sp1…`, per BIP-352). The mode of each endpoint is inferred from the prefix — the client renders a QR code that combines the present endpoints and adjusts the donation-progress UI accordingly. A campaign MAY declare **at most one** endpoint per mode (at most one on-chain address and at most one silent-payment code).
|
||||
|
||||
The author of the event is also the beneficiary. Campaigns are never authored on behalf of someone else; the event creator owns the wallet declared in `w` and receives the donations. To stop accepting donations, the creator publishes a NIP-09 kind 5 deletion request referencing the campaign's `a` coordinate.
|
||||
|
||||
The kind is addressable so the creator can edit the story, banner, goal, deadline, and wallet over the life of the campaign without minting new identifiers. The `d` tag is the campaign's slug.
|
||||
The kind is addressable so the creator can edit the story, banner, goal, and wallet over the life of the campaign without minting new identifiers. The `d` tag is the campaign's slug.
|
||||
|
||||
### Event Structure
|
||||
|
||||
@@ -315,7 +317,6 @@ The kind is addressable so the creator can edit the story, banner, goal, deadlin
|
||||
["w", "sp1qq...verylongsilentpaymentcode..."],
|
||||
|
||||
["goal", "25000"],
|
||||
["deadline", "1735689600"],
|
||||
|
||||
["i", "iso3166:US"],
|
||||
["k", "iso3166"],
|
||||
@@ -352,7 +353,6 @@ The `content` field is the **campaign story**, formatted as Markdown. Clients SH
|
||||
| `banner` | Recommended | HTTPS URL of the wide banner image. Clients MUST sanitize the URL (see `sanitizeUrl()` in `nostr-security`) before rendering, and SHOULD pair the URL with a NIP-92 `imeta` tag for dimensions, blurhash, MIME type, and SHA-256. |
|
||||
| `imeta` | Recommended | NIP-92 media metadata for the banner. The first `url <value>` pair MUST match the `banner` URL; clients SHOULD ignore an `imeta` whose URL does not match. |
|
||||
| `goal` | Optional | Fundraising goal in **integer US Dollars** (no unit suffix, no decimals). Clients MAY display an estimated sat-equivalent at view time using a live exchange rate. |
|
||||
| `deadline`| Optional | Unix timestamp (seconds) at which the campaign closes for new donations. After the deadline, clients SHOULD show the campaign as ended but MAY still accept donations. |
|
||||
| `i` | Recommended | NIP-73 country identifier. SHOULD be `iso3166:<code>` with an uppercase ISO 3166-1 alpha-2 country code (e.g. `iso3166:VE`). |
|
||||
| `k` | Recommended if `i` is present | NIP-73 external content kind. For country identifiers this SHOULD be `iso3166`. |
|
||||
| `t` | Optional | User-entered discovery/category tags. Agora also adds `t:agora` as the app marker; other `t` values are freeform topics such as `legal-defense` or `mutual-aid`. |
|
||||
@@ -697,8 +697,107 @@ Step 3 — fold by `(coord, axis)`, latest-`created_at`-wins, filtering to the r
|
||||
- Clients MAY display "Hidden" badges on hidden campaigns/organizations/pledges when viewed by a moderator, and SHOULD NOT render them at all to non-moderators.
|
||||
- Authors' own campaigns, organizations, and pledges are visible at their NIP-19 routes regardless of moderation state. The campaign URL remains live and donatable even when the campaign is not on the home page's Featured row.
|
||||
|
||||
#### Campaign Verification Labels (`agora.verified`)
|
||||
|
||||
Separately from the hide/feature moderation axes above, Agora supports a positive **verification** signal: a campaign moderator vouches for a specific campaign. Verification is a distinct NIP-32 label namespace, `agora.verified`, with a single value `verified`. It rides the same kind 1985 label kind and the **same moderator pack** as the hide/feature labels, but is otherwise independent of `agora.moderation` — no axes, no rank, purely additive.
|
||||
|
||||
A verification label points at one campaign coordinate (`33863:<pubkey>:<d>`):
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 1985,
|
||||
"content": "",
|
||||
"tags": [
|
||||
["L", "agora.verified"],
|
||||
["l", "verified", "agora.verified"],
|
||||
["a", "33863:<campaign-pubkey>:<campaign-d>"],
|
||||
["alt", "Campaign verification"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Trust model.** The set of pubkeys whose `agora.verified` labels are honored is the campaign moderator pack — the same allowlist that governs hide/feature labels (the Team Soapbox follow pack `p` tags). Clients MUST filter the read query by `authors: <moderators>` — a `verified` label signed by any pubkey outside the pack MUST be ignored, otherwise the badge is forgeable by anyone. As with moderation labels, clients MUST NOT run the query with an empty `authors:` filter.
|
||||
|
||||
**Reading.** One filter fetches every verification across all moderators:
|
||||
|
||||
```json
|
||||
{
|
||||
"kinds": [1985],
|
||||
"authors": ["<moderator-1>", "<moderator-2>"],
|
||||
"#L": ["agora.verified"],
|
||||
"#l": ["verified"],
|
||||
"limit": 2000
|
||||
}
|
||||
```
|
||||
|
||||
Fold by `(coord, moderator)`, keeping the newest label per pair. A campaign is "verified by" the set of moderators with a surviving label; clients SHOULD render the moderators' avatars stacked as a badge, with multiple moderators forming a stack.
|
||||
|
||||
**Retraction.** There is no `unverified` value. A moderator retracts a verification by publishing a NIP-09 kind 5 deletion of their own label event (referenced by `e` tag plus `k: 1985`). A kind 5 only takes effect on events authored by the signer, so a moderator can only remove their own verification.
|
||||
|
||||
**Client behavior.**
|
||||
- Verification is a moderator action: clients SHOULD render the verify / remove-verification control inside the campaign moderator menu (alongside hide / add-to-list), gated on moderator membership.
|
||||
- Verification is purely additive — it never hides or promotes a campaign on its own. It is a trust hint layered over whatever moderation/discovery state already applies.
|
||||
- The label kind 1985 read is routed to Agora's search relays (`relay.ditto.pub`, `relay.dreamith.to`) where these labels are published.
|
||||
|
||||
---
|
||||
|
||||
## Kind 14672: Verifier Statement
|
||||
|
||||
### Summary
|
||||
|
||||
Replaceable event kind for a **self-authored statement describing how the author verifies campaigns**. Anyone can "become a verifier" simply by publishing one of these events — there is no gatekeeper. The statement is a public, freeform explanation of the diligence process the author applies before vouching for a campaign, so donors can judge whether to trust that author's judgement.
|
||||
|
||||
Exactly one statement per user (replaceable, no `d` tag): publishing a new event replaces the previous one. Clients surface the statement prominently on the author's profile page.
|
||||
|
||||
This kind is **distinct from** the `agora.verified` campaign-verification labels (kind 1985, see Kind 33863 above). Those are moderator-signed, gated by the Team Soapbox follow pack, and vouch for one specific campaign. A kind 14672 statement is an open, self-published description of an author's *general* verification methodology and confers no special authority — it is a reputation signal donors read, not an access-control mechanism.
|
||||
|
||||
### Event Structure
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 14672,
|
||||
"pubkey": "<author-pubkey>",
|
||||
"content": "I personally visit each campaign organizer over video call, confirm their identity against a government ID, and cross-check the cause with at least two independent local sources before I vouch for it. ...",
|
||||
"tags": [
|
||||
["alt", "Verifier statement: how this account verifies campaigns"],
|
||||
["t", "agora"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Content
|
||||
|
||||
The `content` field is the verifier statement, formatted as **Markdown**. Clients SHOULD render it with the same Markdown renderer they use for other long-form Agora content (campaign stories, policy pages). Empty or whitespace-only content means the author has **withdrawn** their verifier statement — clients MUST treat an empty-content event the same as no event (the author is no longer a verifier) and MUST NOT render a verifier section for it.
|
||||
|
||||
### Tags
|
||||
|
||||
| Tag | Required | Description |
|
||||
|-------|-------------|-----------------------------------------------------------------------------|
|
||||
| `alt` | Recommended | NIP-31 human-readable fallback describing the event's purpose. |
|
||||
| `t` | Optional | Agora content marker (`t:agora`). Added at publish time via `withAgoraTag`. |
|
||||
|
||||
The statement carries no queryable fields beyond the author and kind — it is identified entirely by `(14672, pubkey)`.
|
||||
|
||||
### Querying
|
||||
|
||||
**Fetch a user's verifier statement:**
|
||||
|
||||
```json
|
||||
{ "kinds": [14672], "authors": ["<pubkey>"], "limit": 1 }
|
||||
```
|
||||
|
||||
Clients MUST filter by `authors` — a verifier statement only describes the diligence of the pubkey that signed it, so an unfiltered query would be meaningless (and would let anyone's statement be attributed to anyone).
|
||||
|
||||
### Client Behavior
|
||||
|
||||
- **Becoming a verifier:** a user publishes a kind 14672 event with their statement in `content`. No approval, allowlist, or moderation gate applies.
|
||||
- **Withdrawing:** a user republishes the event with empty `content`, or publishes a NIP-09 kind 5 deletion referencing the event. Either way clients stop rendering the verifier section.
|
||||
- **Rendering:** clients SHOULD surface the statement prominently on the author's profile (e.g. a dedicated "Verifier" section in the profile overview), rendering the Markdown content sanitized.
|
||||
- **Editing:** because the kind is replaceable, the latest event per `(14672, pubkey)` wins. Clients performing an edit SHOULD pass the previous event as `prev` so `published_at` is preserved (NIP-23 convention).
|
||||
|
||||
---
|
||||
|
||||
|
||||
## Kind 16769: Profile Tabs
|
||||
|
||||
### Summary
|
||||
|
||||
@@ -14,8 +14,15 @@ android {
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 1
|
||||
versionName "2.14.4"
|
||||
versionName "2.8.9"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
// The arti-mobile AAR bundles large native Rust libraries for every
|
||||
// ABI (~45 MB total). Restrict to the ABIs we actually ship/test:
|
||||
// arm64-v8a + armeabi-v7a (real devices) and x86_64 (emulators).
|
||||
// Drop x86_64 here if you only ever test on physical devices.
|
||||
ndk {
|
||||
abiFilters 'arm64-v8a', 'armeabi-v7a', 'x86_64'
|
||||
}
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
|
||||
@@ -51,10 +58,18 @@ repositories {
|
||||
dependencies {
|
||||
implementation fileTree(include: ['*.jar'], dir: 'libs')
|
||||
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
|
||||
implementation "androidx.core:core:$androidxCoreVersion"
|
||||
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
|
||||
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
|
||||
implementation project(':capacitor-android')
|
||||
implementation 'com.squareup.okhttp3:okhttp:4.12.0'
|
||||
// Tor in Rust (arti) — prebuilt AAR from Guardian Project's gpmaven repo.
|
||||
// Provides org.torproject.arti.ArtiProxy used by TorController.
|
||||
implementation 'org.torproject:arti-mobile:1.7.0.1'
|
||||
// arti pulls androidx.webkit in transitively but only at runtime; we
|
||||
// compile against ProxyController/WebViewFeature in TorController, so
|
||||
// declare it explicitly on the app's compile classpath.
|
||||
implementation "androidx.webkit:webkit:$androidxWebkitVersion"
|
||||
testImplementation "junit:junit:$junitVersion"
|
||||
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
|
||||
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
|
||||
|
||||
@@ -19,6 +19,18 @@
|
||||
-dontwarn okio.**
|
||||
-keep class okhttp3.** { *; }
|
||||
|
||||
# Barcode scanner plugin (@capacitor/barcode-scanner -> OutSystems ionbarcode)
|
||||
# references Gson's @SerializedName, but Gson isn't on the release classpath.
|
||||
# Suppress the missing-class warning, keep the annotation attribute, and keep
|
||||
# the plugin's model classes so R8 doesn't strip/rename serialized fields.
|
||||
-dontwarn com.google.gson.**
|
||||
-keepattributes *Annotation*
|
||||
-keep class com.outsystems.plugins.barcode.** { *; }
|
||||
|
||||
# Keep arti (Tor) classes — ArtiJNI declares native methods invoked from the
|
||||
# Rust .so via JNI, so its names must not be obfuscated/stripped.
|
||||
-keep class org.torproject.arti.** { *; }
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
#-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
@@ -8,6 +8,12 @@ import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.util.Log;
|
||||
import android.view.View;
|
||||
import android.webkit.WebView;
|
||||
|
||||
import androidx.core.graphics.Insets;
|
||||
import androidx.core.view.ViewCompat;
|
||||
import androidx.core.view.WindowInsetsCompat;
|
||||
|
||||
import com.getcapacitor.BridgeActivity;
|
||||
|
||||
@@ -19,6 +25,14 @@ public class MainActivity extends BridgeActivity {
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
// Register native plugins before super.onCreate.
|
||||
registerPlugin(DittoNotificationPlugin.class);
|
||||
registerPlugin(TorPlugin.class);
|
||||
|
||||
// If the user enabled Tor (apply on relaunch), start arti BEFORE
|
||||
// super.onCreate so the WebView SOCKS proxy override is installed
|
||||
// before the WebView issues any network request — no leak window.
|
||||
if (TorController.isEnabled(this)) {
|
||||
TorController.getInstance().start(getApplicationContext());
|
||||
}
|
||||
|
||||
super.onCreate(savedInstanceState);
|
||||
|
||||
@@ -47,6 +61,35 @@ public class MainActivity extends BridgeActivity {
|
||||
|
||||
// Handle notification tap deep link
|
||||
handleNotificationIntent(getIntent());
|
||||
|
||||
// The Android WebView reports env(safe-area-inset-*) as 0, so inject the
|
||||
// real system-bar insets as CSS variables (--safe-area-inset-top/bottom)
|
||||
// that the web layer consumes (see src/index.css). Without this, the top
|
||||
// nav renders behind the status bar in the APK.
|
||||
applySafeAreaInsets();
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the status-bar (top) and navigation-bar (bottom) insets and write
|
||||
* them into the WebView as CSS pixel variables. Re-applies on every inset
|
||||
* change (rotation, status-bar show/hide, etc.).
|
||||
*/
|
||||
private void applySafeAreaInsets() {
|
||||
final WebView webView = getBridge().getWebView();
|
||||
if (webView == null) return;
|
||||
|
||||
ViewCompat.setOnApplyWindowInsetsListener(webView, (v, insets) -> {
|
||||
Insets bars = insets.getInsets(WindowInsetsCompat.Type.systemBars());
|
||||
float density = getResources().getDisplayMetrics().density;
|
||||
int topPx = Math.round(bars.top / density);
|
||||
int bottomPx = Math.round(bars.bottom / density);
|
||||
String js =
|
||||
"document.documentElement.style.setProperty('--safe-area-inset-top','" + topPx + "px');" +
|
||||
"document.documentElement.style.setProperty('--safe-area-inset-bottom','" + bottomPx + "px');";
|
||||
v.post(() -> webView.evaluateJavascript(js, null));
|
||||
return insets;
|
||||
});
|
||||
ViewCompat.requestApplyInsets(webView);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
package spot.agora.app;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.webkit.ProxyConfig;
|
||||
import androidx.webkit.ProxyController;
|
||||
import androidx.webkit.WebViewFeature;
|
||||
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.Proxy;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.torproject.arti.ArtiProxy;
|
||||
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.Response;
|
||||
|
||||
/**
|
||||
* Process-wide controller for the optional Tor (arti) mode on Android.
|
||||
*
|
||||
* <p>When enabled, this starts a local SOCKS5 proxy backed by arti (Tor in
|
||||
* Rust) and — via {@link ArtiProxy.ArtiProxyBuilder#setWrapWebView(boolean)} —
|
||||
* installs an Android {@code ProxyController} override so that <em>all</em>
|
||||
* Capacitor WebView traffic (every {@code fetch} and relay {@code WebSocket})
|
||||
* is routed through Tor. No changes to the TypeScript HTTP layer are needed.
|
||||
*
|
||||
* <p>Activation is "apply on relaunch": the enabled flag is persisted to
|
||||
* {@link SharedPreferences} by {@link TorPlugin} and read here at startup from
|
||||
* {@link MainActivity}. arti is started <em>before</em> the WebView loads so
|
||||
* there is no pre-bootstrap leak window.
|
||||
*
|
||||
* <p>Pluggable transports (obfs4 via IPtProxy) are intentionally not wired up
|
||||
* yet — the builder already exposes {@code setObfs4Port}/{@code setBridgeLines}
|
||||
* for a future censorship-resistance layer.
|
||||
*/
|
||||
public class TorController {
|
||||
|
||||
private static final String TAG = "TorController";
|
||||
|
||||
/** Local SOCKS5 port arti listens on (arti's own default). */
|
||||
public static final int SOCKS_PORT = 9150;
|
||||
|
||||
static final String PREFS_NAME = "tor_config";
|
||||
static final String KEY_ENABLED = "enabled";
|
||||
|
||||
/** Endpoint used to confirm a working Tor circuit (small JSON response). */
|
||||
private static final String PROBE_URL = "https://check.torproject.org/api/ip";
|
||||
// Re-verify continuously (gently) so the status reflects current reality.
|
||||
private static final long PROBE_INTERVAL_SECONDS = 10;
|
||||
/** After this long without a successful probe, surface a soft "failed". */
|
||||
private static final long SOFT_TIMEOUT_SECONDS = 120;
|
||||
|
||||
// Status values mirrored to JS (see src/lib/tor.ts TorStatus).
|
||||
public static final String STATUS_DISABLED = "disabled";
|
||||
public static final String STATUS_CONNECTING = "connecting";
|
||||
public static final String STATUS_CONNECTED = "connected";
|
||||
public static final String STATUS_FAILED = "failed";
|
||||
|
||||
/** Receives status changes so the Capacitor plugin can forward them to JS. */
|
||||
public interface StatusListener {
|
||||
void onTorStatus(String status, int bootstrapPercent, @Nullable String error, @Nullable String exitIp);
|
||||
}
|
||||
|
||||
private static volatile TorController instance;
|
||||
|
||||
private final Object lock = new Object();
|
||||
private final AtomicBoolean started = new AtomicBoolean(false);
|
||||
|
||||
private ArtiProxy artiProxy;
|
||||
private ScheduledExecutorService scheduler;
|
||||
|
||||
private volatile String status = STATUS_DISABLED;
|
||||
private volatile int bootstrapPercent = 0;
|
||||
@Nullable private volatile String error = null;
|
||||
/** Tor exit-node IP from the last successful check (for verification UI). */
|
||||
@Nullable private volatile String exitIp = null;
|
||||
/** Consecutive failed probes; used to debounce CONNECTED -> reconnecting. */
|
||||
private int consecutiveFailures = 0;
|
||||
@Nullable private volatile StatusListener listener;
|
||||
private volatile long startedAtMs = 0;
|
||||
|
||||
private TorController() {}
|
||||
|
||||
public static TorController getInstance() {
|
||||
if (instance == null) {
|
||||
synchronized (TorController.class) {
|
||||
if (instance == null) {
|
||||
instance = new TorController();
|
||||
}
|
||||
}
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
/** Whether Tor is enabled in persisted preferences (read at next launch). */
|
||||
public static boolean isEnabled(Context context) {
|
||||
SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
|
||||
return prefs.getBoolean(KEY_ENABLED, false);
|
||||
}
|
||||
|
||||
/** Persist the enabled flag. Takes effect on the next app launch. */
|
||||
public static void setEnabled(Context context, boolean enabled) {
|
||||
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putBoolean(KEY_ENABLED, enabled)
|
||||
.apply();
|
||||
}
|
||||
|
||||
public void setListener(@Nullable StatusListener listener) {
|
||||
this.listener = listener;
|
||||
// Replay the current status so a freshly-attached listener is in sync.
|
||||
if (listener != null) {
|
||||
listener.onTorStatus(status, bootstrapPercent, error, exitIp);
|
||||
}
|
||||
}
|
||||
|
||||
public String getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public int getBootstrapPercent() {
|
||||
return bootstrapPercent;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public String getError() {
|
||||
return error;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public String getExitIp() {
|
||||
return exitIp;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start arti and install the WebView proxy override. Idempotent: a second
|
||||
* call while already running is a no-op. Heavy work runs off the caller's
|
||||
* thread so this is safe to invoke from {@code MainActivity.onCreate}.
|
||||
*/
|
||||
public void start(Context context) {
|
||||
if (!started.compareAndSet(false, true)) {
|
||||
return;
|
||||
}
|
||||
final Context appContext = context.getApplicationContext();
|
||||
exitIp = null;
|
||||
consecutiveFailures = 0;
|
||||
// Install the fail-closed WebView proxy override synchronously, BEFORE
|
||||
// the WebView loads (start() is called from MainActivity.onCreate ahead
|
||||
// of super.onCreate). With no direct fallback, any request that arti
|
||||
// can't carry fails instead of leaking out directly — even during the
|
||||
// bootstrap window when arti isn't connected yet.
|
||||
applyWebViewProxy();
|
||||
updateStatus(STATUS_CONNECTING, 0, null);
|
||||
startedAtMs = System.currentTimeMillis();
|
||||
|
||||
Thread t = new Thread(() -> {
|
||||
try {
|
||||
synchronized (lock) {
|
||||
// NB: we do NOT use setWrapWebView(true) — arti's helper
|
||||
// appends a DIRECT fallback (fail-open). We set our own
|
||||
// fail-closed override in applyWebViewProxy() instead.
|
||||
artiProxy = ArtiProxy.Builder(appContext)
|
||||
.setSocksPort(SOCKS_PORT)
|
||||
.setLogListener(this::onArtiLog)
|
||||
.build();
|
||||
artiProxy.start();
|
||||
}
|
||||
Log.d(TAG, "arti started on socks://127.0.0.1:" + SOCKS_PORT);
|
||||
beginConnectivityProbe();
|
||||
} catch (Throwable e) {
|
||||
Log.e(TAG, "Failed to start arti", e);
|
||||
updateStatus(STATUS_FAILED, bootstrapPercent, String.valueOf(e.getMessage()));
|
||||
}
|
||||
}, "arti-start");
|
||||
t.setDaemon(true);
|
||||
t.start();
|
||||
}
|
||||
|
||||
/** Stop arti and route the WebView back to a direct connection. Safe to
|
||||
* call live (toggle off) — clears the SOCKS proxy override so traffic
|
||||
* doesn't get stranded on the now-stopped proxy. */
|
||||
public void stop() {
|
||||
// Remove the WebView SOCKS override first so new requests go direct.
|
||||
clearWebViewProxy();
|
||||
synchronized (lock) {
|
||||
if (scheduler != null) {
|
||||
scheduler.shutdownNow();
|
||||
scheduler = null;
|
||||
}
|
||||
if (artiProxy != null) {
|
||||
try {
|
||||
artiProxy.stop();
|
||||
} catch (Throwable e) {
|
||||
Log.w(TAG, "Error stopping arti", e);
|
||||
}
|
||||
artiProxy = null;
|
||||
}
|
||||
}
|
||||
started.set(false);
|
||||
exitIp = null;
|
||||
updateStatus(STATUS_DISABLED, 0, null);
|
||||
}
|
||||
|
||||
/** Re-run the connectivity probe (used by a "Retry" action in the gate). */
|
||||
public void retry() {
|
||||
if (!started.get()) {
|
||||
return;
|
||||
}
|
||||
consecutiveFailures = 0;
|
||||
startedAtMs = System.currentTimeMillis();
|
||||
if (!STATUS_CONNECTED.equals(status)) {
|
||||
updateStatus(STATUS_CONNECTING, bootstrapPercent, null);
|
||||
}
|
||||
beginConnectivityProbe();
|
||||
}
|
||||
|
||||
// --- internals -------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Route the WebView through arti's SOCKS proxy, FAIL-CLOSED. There is no
|
||||
* {@code addDirect()} fallback, so when Tor can't carry a request it fails
|
||||
* rather than leaking to a direct connection. localhost is bypassed (it's
|
||||
* the local Capacitor asset server, never remote traffic).
|
||||
*/
|
||||
private void applyWebViewProxy() {
|
||||
try {
|
||||
if (WebViewFeature.isFeatureSupported(WebViewFeature.PROXY_OVERRIDE)) {
|
||||
ProxyConfig config = new ProxyConfig.Builder()
|
||||
.addProxyRule("socks://127.0.0.1:" + SOCKS_PORT)
|
||||
// No addDirect() — fail closed.
|
||||
.addBypassRule("localhost")
|
||||
.addBypassRule("127.0.0.1")
|
||||
.build();
|
||||
ProxyController.getInstance().setProxyOverride(config, Runnable::run, () -> {});
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
Log.w(TAG, "Error applying WebView proxy override", e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove the app-wide WebView SOCKS proxy override so the WebView reverts
|
||||
* to a direct connection. */
|
||||
private void clearWebViewProxy() {
|
||||
try {
|
||||
if (WebViewFeature.isFeatureSupported(WebViewFeature.PROXY_OVERRIDE)) {
|
||||
ProxyController.getInstance().clearProxyOverride(Runnable::run, () -> {});
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
Log.w(TAG, "Error clearing WebView proxy override", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static final Pattern PERCENT = Pattern.compile("(\\d{1,3})\\s*%");
|
||||
|
||||
private void onArtiLog(String line) {
|
||||
if (line == null) return;
|
||||
Log.d("artilog", line);
|
||||
// Best-effort bootstrap progress for the UI. arti's log format isn't a
|
||||
// stable API, so the connectivity probe (below) remains authoritative
|
||||
// for the definitive "connected" signal.
|
||||
Matcher m = PERCENT.matcher(line);
|
||||
if (m.find()) {
|
||||
try {
|
||||
int pct = Integer.parseInt(m.group(1));
|
||||
if (pct >= 0 && pct <= 100 && pct >= bootstrapPercent
|
||||
&& !STATUS_CONNECTED.equals(status)) {
|
||||
updateStatus(STATUS_CONNECTING, pct, null);
|
||||
}
|
||||
} catch (NumberFormatException ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void beginConnectivityProbe() {
|
||||
synchronized (lock) {
|
||||
if (scheduler != null) {
|
||||
scheduler.shutdownNow();
|
||||
}
|
||||
scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
|
||||
Thread th = new Thread(r, "tor-probe");
|
||||
th.setDaemon(true);
|
||||
return th;
|
||||
});
|
||||
final ScheduledExecutorService s = scheduler;
|
||||
final OkHttpClient client = new OkHttpClient.Builder()
|
||||
.proxy(new Proxy(Proxy.Type.SOCKS, new InetSocketAddress("127.0.0.1", SOCKS_PORT)))
|
||||
.connectTimeout(20, TimeUnit.SECONDS)
|
||||
.readTimeout(20, TimeUnit.SECONDS)
|
||||
.build();
|
||||
|
||||
// Probe continuously (no shutdown on success). check.torproject.org
|
||||
// reports whether the request actually exited via Tor, so we only
|
||||
// report CONNECTED when IsTor is true — and we keep re-verifying so a
|
||||
// dropped circuit downgrades the status instead of lying.
|
||||
s.scheduleWithFixedDelay(() -> {
|
||||
Request req = new Request.Builder()
|
||||
.url(PROBE_URL)
|
||||
.header("Accept", "application/json")
|
||||
.build();
|
||||
try (Response resp = client.newCall(req).execute()) {
|
||||
String body = resp.body() != null ? resp.body().string() : "";
|
||||
boolean isTor = false;
|
||||
String ip = null;
|
||||
try {
|
||||
JSONObject json = new JSONObject(body);
|
||||
isTor = json.optBoolean("IsTor", false);
|
||||
ip = json.has("IP") ? json.optString("IP", null) : null;
|
||||
} catch (JSONException ignored) {
|
||||
// Non-JSON response — treat as not-via-Tor below.
|
||||
}
|
||||
|
||||
if (resp.isSuccessful() && isTor) {
|
||||
consecutiveFailures = 0;
|
||||
exitIp = ip;
|
||||
updateStatus(STATUS_CONNECTED, 100, null);
|
||||
} else if (resp.isSuccessful()) {
|
||||
// Reached the internet but NOT through Tor — a leak/bypass.
|
||||
// This should not happen with the SOCKS proxy, but report
|
||||
// it honestly rather than claiming a Tor connection.
|
||||
consecutiveFailures = 0;
|
||||
exitIp = ip;
|
||||
updateStatus(STATUS_FAILED, bootstrapPercent,
|
||||
"Connected to the internet, but not through Tor.");
|
||||
} else {
|
||||
handleProbeFailure();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
handleProbeFailure();
|
||||
}
|
||||
}, 0, PROBE_INTERVAL_SECONDS, TimeUnit.SECONDS);
|
||||
}
|
||||
}
|
||||
|
||||
/** A probe couldn't reach Tor. Debounce CONNECTED, surface FAILED after the
|
||||
* soft timeout while still connecting. */
|
||||
private void handleProbeFailure() {
|
||||
consecutiveFailures++;
|
||||
if (STATUS_CONNECTED.equals(status)) {
|
||||
// Tolerate a couple of transient blips before downgrading.
|
||||
if (consecutiveFailures >= 3) {
|
||||
exitIp = null;
|
||||
updateStatus(STATUS_CONNECTING, bootstrapPercent,
|
||||
"Lost the Tor circuit; reconnecting…");
|
||||
}
|
||||
return;
|
||||
}
|
||||
long elapsed = (System.currentTimeMillis() - startedAtMs) / 1000;
|
||||
if (elapsed >= SOFT_TIMEOUT_SECONDS && !STATUS_FAILED.equals(status)) {
|
||||
updateStatus(STATUS_FAILED, bootstrapPercent,
|
||||
"Couldn't reach the Tor network. Your network may be blocking Tor.");
|
||||
}
|
||||
}
|
||||
|
||||
private void updateStatus(String newStatus, int percent, @Nullable String err) {
|
||||
this.status = newStatus;
|
||||
this.bootstrapPercent = percent;
|
||||
this.error = err;
|
||||
StatusListener l = this.listener;
|
||||
if (l != null) {
|
||||
l.onTorStatus(newStatus, percent, err, exitIp);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package spot.agora.app;
|
||||
|
||||
import com.getcapacitor.JSObject;
|
||||
import com.getcapacitor.Plugin;
|
||||
import com.getcapacitor.PluginCall;
|
||||
import com.getcapacitor.PluginMethod;
|
||||
import com.getcapacitor.annotation.CapacitorPlugin;
|
||||
|
||||
/**
|
||||
* Capacitor bridge for the Tor (arti) mode.
|
||||
*
|
||||
* <p>Mirrors {@link DittoNotificationPlugin}'s pattern: JS configures native
|
||||
* state, native owns the work. The enabled flag is persisted only — arti is
|
||||
* actually started at launch from {@link MainActivity} (apply on relaunch).
|
||||
* Live bootstrap status is pushed to JS via the {@code torStatus} event.
|
||||
*
|
||||
* <p>JS interface: see {@code src/lib/tor.ts}.
|
||||
*/
|
||||
@CapacitorPlugin(name = "Tor")
|
||||
public class TorPlugin extends Plugin {
|
||||
|
||||
private static final String EVENT_STATUS = "torStatus";
|
||||
|
||||
@Override
|
||||
public void load() {
|
||||
// Forward native status changes to JS listeners. Attaching also replays
|
||||
// the current status, keeping a newly-mounted JS gate in sync.
|
||||
TorController.getInstance().setListener((status, bootstrapPercent, error, exitIp) -> {
|
||||
JSObject data = new JSObject();
|
||||
data.put("status", status);
|
||||
data.put("bootstrapPercent", bootstrapPercent);
|
||||
data.put("error", error);
|
||||
data.put("exitIp", exitIp);
|
||||
notifyListeners(EVENT_STATUS, data);
|
||||
});
|
||||
}
|
||||
|
||||
/** Whether Tor is enabled in persisted preferences. */
|
||||
@PluginMethod
|
||||
public void isEnabled(PluginCall call) {
|
||||
JSObject ret = new JSObject();
|
||||
ret.put("enabled", TorController.isEnabled(getContext()));
|
||||
call.resolve(ret);
|
||||
}
|
||||
|
||||
/** Persist the enabled flag. Applied on the next app launch. */
|
||||
@PluginMethod
|
||||
public void setEnabled(PluginCall call) {
|
||||
Boolean enabled = call.getBoolean("enabled");
|
||||
if (enabled == null) {
|
||||
call.reject("Missing 'enabled' boolean");
|
||||
return;
|
||||
}
|
||||
TorController.setEnabled(getContext(), enabled);
|
||||
call.resolve();
|
||||
}
|
||||
|
||||
/** Start arti now (live activation). Also persists enabled=true so it
|
||||
* auto-starts on the next cold launch. */
|
||||
@PluginMethod
|
||||
public void start(PluginCall call) {
|
||||
TorController.setEnabled(getContext(), true);
|
||||
TorController.getInstance().start(getContext());
|
||||
call.resolve();
|
||||
}
|
||||
|
||||
/** Stop arti now (live deactivation) and clear the WebView proxy. Also
|
||||
* persists enabled=false. */
|
||||
@PluginMethod
|
||||
public void stop(PluginCall call) {
|
||||
TorController.setEnabled(getContext(), false);
|
||||
TorController.getInstance().stop();
|
||||
call.resolve();
|
||||
}
|
||||
|
||||
/** Current connection status (synchronous snapshot). */
|
||||
@PluginMethod
|
||||
public void getStatus(PluginCall call) {
|
||||
TorController controller = TorController.getInstance();
|
||||
JSObject ret = new JSObject();
|
||||
ret.put("enabled", TorController.isEnabled(getContext()));
|
||||
ret.put("status", controller.getStatus());
|
||||
ret.put("bootstrapPercent", controller.getBootstrapPercent());
|
||||
ret.put("error", controller.getError());
|
||||
ret.put("exitIp", controller.getExitIp());
|
||||
call.resolve(ret);
|
||||
}
|
||||
|
||||
/** Re-run the connectivity probe (for a "Retry" action in the gate). */
|
||||
@PluginMethod
|
||||
public void retry(PluginCall call) {
|
||||
TorController.getInstance().retry();
|
||||
call.resolve();
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 115 KiB |
@@ -1,50 +0,0 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="100"
|
||||
android:viewportHeight="100">
|
||||
|
||||
<!--
|
||||
Ditto logo from public/logo.svg.
|
||||
SVG viewBox is "-5 -10 100 100", so we shift all paths by (+5, +10)
|
||||
to place the origin at (0,0) for the 100x100 viewport.
|
||||
Then scale to 60% around the content center (50, 40) to fit within
|
||||
Android's adaptive icon safe zone (66% of 108dp).
|
||||
-->
|
||||
|
||||
<group
|
||||
android:translateX="5"
|
||||
android:translateY="10"
|
||||
android:scaleX="0.7"
|
||||
android:scaleY="0.7"
|
||||
android:pivotX="50"
|
||||
android:pivotY="40">
|
||||
|
||||
<!-- path1: bottom arc / bottom-right swash -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="m 71.719615,49.36907 -0.62891,0.37109 c -0.12891,0.07031 -0.26172,0.14844 -0.39062,0.21875 -3.9883,10.309 -14.008,17.617 -25.699,17.617 -4.1211,0 -8.0312,-0.89844 -11.539,-2.5391 -0.12891,0.03906 -0.26172,0.07031 -0.39063,0.10156 l -0.35156,0.08984 h -0.02734 l -0.25,0.05859 -0.07813,0.01953 -0.10938,0.03125 c -0.55859,0.12891 -1.1289,0.26172 -1.6992,0.39062 -0.10156,0.03125 -0.19922,0.05078 -0.30078,0.07031 l -0.30078,0.10156 -0.18359,0.0078 c -0.26953,0.05859 -1.3086,0.26953 -1.3086,0.26953 -0.28906,0.05859 -0.55859,0.10937 -0.82813,0.17187 4.9805,3.3086 10.961,5.2305 17.371,5.2305 15.059,0 27.699,-10.602 30.828,-24.738 -0.75,0.48828 -1.5195,0.96875 -2.2891,1.4414 -0.59375,0.36328 -1.2031,0.72656 -1.8242,1.0859 z" />
|
||||
|
||||
<!-- path2: small left accent dot/arc -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="m 30.926615,29.47807 c 0.36328,-0.48828 0.75,-0.95312 1.1523,-1.3828 0.75781,-0.80469 0.71484,-2.0703 -0.08984,-2.8281 -0.80469,-0.75781 -2.0703,-0.71484 -2.8281,0.08984 -0.50781,0.53906 -0.99219,1.125 -1.4492,1.7383 -0.65625,0.88672 -0.47266,2.1406 0.41406,2.7969 0.35938,0.26562 0.77344,0.39453 1.1875,0.39453 0.61719,0 1.2227,-0.27734 1.6133,-0.80859 z" />
|
||||
|
||||
<!-- path3: left vertical bar -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="m 26.742615,32.67807 c -1.0586,-0.3125 -2.1719,0.29687 -2.4805,1.3594 -0.55859,1.9062 -0.83984,3.9141 -0.83984,5.9609 0,2.3789 0.39062,4.7227 1.1602,6.9609 0.28516,0.82812 1.0625,1.3516 1.8906,1.3516 0.21484,0 0.43359,-0.03516 0.64844,-0.10938 1.043,-0.35938 1.6016,-1.4961 1.2422,-2.543 -0.625,-1.8203 -0.94141,-3.7227 -0.94141,-5.6602 0,-1.668 0.22656,-3.2969 0.67969,-4.8398 0.30859,-1.0586 -0.30078,-2.168 -1.3594,-2.4805 z" />
|
||||
|
||||
<!-- path4: main ring arc -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="m 14.691615,48.83807 c 0.10156,0.33984 0.19922,0.67969 0.32812,1.0195 0.42969,1.3516 0.94922,2.6484 1.5781,3.9102 0.10156,-0.01172 0.21094,-0.01172 0.32031,-0.01953 l 0.16016,-0.01172 0.80078,-0.07031 c 0.37109,-0.03906 0.67188,-0.07031 0.98047,-0.10156 0.51172,-0.05859 1.0195,-0.12109 1.5586,-0.19922 l 0.21875,-0.03125 c 0.07031,-0.01172 0.14062,-0.01953 0.21094,-0.03125 -1.2188,-2.2109 -2.1484,-4.6016 -2.7305,-7.1211 -0.16016,-0.71094 -0.30078,-1.4297 -0.39844,-2.1602 -0.19922,-1.3086 -0.30078,-2.6602 -0.30078,-4.0312 0,-0.89844 0.03906,-1.7812 0.12891,-2.6484 0.07031,-0.71094 0.16016,-1.4102 0.28906,-2.1016 2.25,-12.949 13.57,-22.828 27.16,-22.828 6.0508,0 11.648,1.9609 16.211,5.3008 0.57031,0.41016 1.1289,0.85938 1.6719,1.3203 1.6914,1.4219 3.2109,3.0703 4.5,4.8789 0.42969,0.60156 0.83984,1.2109 1.2188,1.8398 1.3203,2.1602 2.3398,4.5117 3.0195,7 0.23828,-0.17188 0.42969,-0.30859 0.62891,-0.46875 0.64844,-0.48047 1.2109,-0.92188 1.7383,-1.3398 0.28125,-0.23047 0.5,-0.41016 0.71094,-0.57812 0.10156,-0.07813 0.19141,-0.16016 0.28125,-0.23828 -0.42969,-1.3516 -0.96094,-2.6484 -1.5898,-3.8984 -0.14062,-0.32812 -0.30859,-0.64844 -0.48047,-0.96875 -0.32812,-0.64844 -0.69922,-1.2891 -1.0898,-1.9102 -1.6797,-2.7109 -3.7695,-5.1406 -6.1719,-7.2188 -0.57031,-0.48828 -1.1484,-0.96875 -1.7617,-1.4102 -0.55859,-0.42188 -1.1406,-0.82812 -1.7305,-1.2109 -4.9414,-3.2188 -10.852,-5.0898 -17.16,-5.0898 -14.961,0 -27.531,10.469 -30.75,24.469 -0.17188,0.67969 -0.30859,1.3711 -0.42188,2.0703 -0.12891,0.73828 -0.21875,1.5 -0.28906,2.2617 -0.07813,0.91016 -0.12109,1.8398 -0.12109,2.7812 0,2.3008 0.25,4.5508 0.71875,6.7109 0.17188,0.71484 0.35156,1.4258 0.5625,2.125 z" />
|
||||
|
||||
<!-- path5: top-right swash / outer arc with tail -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="m 90.441615,21.60007 c -2.1797,-5.3398 -9.4102,-7.3984 -21,-6.0391 1.8906,1.8906 3.5391,3.9688 4.9297,6.2109 0.28906,0.46094 0.55859,0.92187 0.80859,1.3789 5.5391,-0.12109 7.6094,1.0391 7.8398,1.4492 0.12891,0.46875 -0.55078,2.7305 -4.5898,6.4805 -0.01953,0.01953 -0.03125,0.03125 -0.03906,0.03906 -0.26172,0.23828 -0.51953,0.48047 -0.80078,0.71875 -0.19922,0.17969 -0.41016,0.35938 -0.62891,0.53906 -0.10938,0.10156 -0.21875,0.19141 -0.33984,0.28906 -0.23828,0.19922 -0.5,0.41016 -0.76172,0.62109 -0.12891,0.10156 -0.26172,0.21094 -0.39844,0.32031 -0.42969,0.33984 -0.89063,0.69141 -1.3711,1.0508 -0.26953,0.21094 -0.53906,0.41016 -0.82812,0.60938 -0.32031,0.23047 -0.64062,0.46875 -0.98047,0.69922 0,0.01172 -0.01172,0.01172 -0.01172,0.01172 -0.26953,0.19141 -0.55078,0.37891 -0.82812,0.57031 -0.28125,0.19141 -0.55859,0.37109 -0.85156,0.55859 -0.25,0.16016 -0.5,0.32812 -0.76172,0.48828 -6,3.8984 -13.48,7.7188 -21.379,10.922 -8.0117,3.2383 -15.871,5.6602 -22.93,7.0391 -0.30078,0.05859 -0.60156,0.12109 -0.89062,0.17188 -0.60938,0.12109 -1.2188,0.21875 -1.8203,0.32031 -0.07031,0.01172 -0.12891,0.01953 -0.19922,0.03125 h -0.01953 c -0.28906,0.05078 -0.57031,0.08984 -0.83984,0.12891 -0.30859,0.05078 -0.60938,0.08984 -0.91016,0.12891 -0.57031,0.07813 -1.1094,0.14844 -1.6406,0.21094 -0.35156,0.03906 -0.69141,0.07031 -1.0195,0.10156 -0.30078,0.03125 -0.58984,0.05078 -0.87891,0.07813 -0.48047,0.03125 -0.92969,0.05859 -1.3711,0.07813 -0.39844,0.01953 -0.78125,0.03125 -1.1484,0.03906 -5.5116996,0.10938 -7.5702996,-1.0391 -7.8007996,-1.4492 -0.12891,-0.48047 0.55078,-2.7383 4.6093996,-6.5 -0.12891,-0.48828 -0.26172,-1 -0.37891,-1.5391 -0.51953,-2.4219 -0.78906,-4.8906 -0.78906,-7.3594 0,-0.17969 0,-0.37109 0.01172,-0.55078 -9.2733996,7.082 -13.0229996,13.59 -10.8749996,18.949 1.7383,4.2695 6.7188,6.4492 14.5899996,6.4492 2.8594,0 6.1016,-0.28906 9.7109,-0.87109 0.17188,-0.03125 0.33984,-0.05859 0.51953,-0.08984 0.17188,-0.03125 0.35156,-0.05859 0.51953,-0.08984 l 1.2188,-0.21875 c 0.57031,-0.10156 1.1484,-0.21875 1.7305,-0.33984 0.53125,-0.10938 1.0508,-0.21094 1.5781,-0.32812 0.01172,0 0.03125,-0.01172 0.03906,-0.01172 0.05078,-0.01172 0.08984,-0.01953 0.14062,-0.03125 0.07031,-0.01172 0.12891,-0.03125 0.19922,-0.05078 0.57812,-0.12891 1.1602,-0.26172 1.7383,-0.39844 0.05078,-0.01172 0.10156,-0.03125 0.14844,-0.03906 0.21094,-0.05078 0.42188,-0.10156 0.64062,-0.14844 h 0.01172 c 6.0898,-1.5117 12.559,-3.6406 19.102,-6.2891 6.5508,-2.6602 12.68,-5.6289 18.109,-8.7812 0.21875,-0.12891 0.44141,-0.26172 0.66016,-0.39062 0.58984,-0.33984 1.1797,-0.69141 1.7617,-1.0508 1.5703,-0.96094 3.0586,-1.9297 4.4805,-2.9102 0.12891,-0.08984 0.26172,-0.17969 0.39062,-0.26953 11.242,-7.8477 15.941,-15.09 13.594,-20.938 z" />
|
||||
|
||||
</group>
|
||||
|
||||
</vector>
|
||||
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 12 KiB |
@@ -5,43 +5,26 @@
|
||||
android:viewportHeight="1200">
|
||||
|
||||
<!--
|
||||
Android 12 splash screen masks the icon to a circle at 2/3 of canvas (160dp of 240dp).
|
||||
The Ditto logo SVG has viewBox="-5 -10 100 100".
|
||||
We scale the 100x100 logo to fit ~800x800 in the center of 1200x1200,
|
||||
leaving ~200px padding on each side for the circular safe zone.
|
||||
Scale factor: 800/100 = 8. Translate: (200 + 5*8, 200 + 10*8) = (240, 280) to shift origin.
|
||||
Agora double-bolt logo from public/logo.svg (viewBox "0 0 720 880").
|
||||
Android 12 splash masks the icon to a circle at 2/3 of the canvas
|
||||
(~800dp of 1200dp). The logo is 720x880 (portrait); scale 880 -> 800
|
||||
(factor 800/880 = 0.9091) giving a scaled width of ~654, then center:
|
||||
translateX = (1200 - 720*0.9091) / 2 ≈ 273
|
||||
translateY = (1200 - 880*0.9091) / 2 = 200
|
||||
-->
|
||||
|
||||
<group
|
||||
android:translateX="240"
|
||||
android:translateY="280"
|
||||
android:scaleX="8"
|
||||
android:scaleY="8">
|
||||
android:translateX="273"
|
||||
android:translateY="200"
|
||||
android:scaleX="0.9091"
|
||||
android:scaleY="0.9091">
|
||||
|
||||
<!-- path1: bottom arc / bottom-right swash -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="m 71.719615,49.36907 -0.62891,0.37109 c -0.12891,0.07031 -0.26172,0.14844 -0.39062,0.21875 -3.9883,10.309 -14.008,17.617 -25.699,17.617 -4.1211,0 -8.0312,-0.89844 -11.539,-2.5391 -0.12891,0.03906 -0.26172,0.07031 -0.39063,0.10156 l -0.35156,0.08984 h -0.02734 l -0.25,0.05859 -0.07813,0.01953 -0.10938,0.03125 c -0.55859,0.12891 -1.1289,0.26172 -1.6992,0.39062 -0.10156,0.03125 -0.19922,0.05078 -0.30078,0.07031 l -0.30078,0.10156 -0.18359,0.0078 c -0.26953,0.05859 -1.3086,0.26953 -1.3086,0.26953 -0.28906,0.05859 -0.55859,0.10937 -0.82813,0.17187 4.9805,3.3086 10.961,5.2305 17.371,5.2305 15.059,0 27.699,-10.602 30.828,-24.738 -0.75,0.48828 -1.5195,0.96875 -2.2891,1.4414 -0.59375,0.36328 -1.2031,0.72656 -1.8242,1.0859 z" />
|
||||
|
||||
<!-- path2: small left accent dot/arc -->
|
||||
android:pathData="M415.596 0L0 417.12L284.123 702.287L346.922 468.3H189.533L415.596 0Z" />
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="m 30.926615,29.47807 c 0.36328,-0.48828 0.75,-0.95312 1.1523,-1.3828 0.75781,-0.80469 0.71484,-2.0703 -0.08984,-2.8281 -0.80469,-0.75781 -2.0703,-0.71484 -2.8281,0.08984 -0.50781,0.53906 -0.99219,1.125 -1.4492,1.7383 -0.65625,0.88672 -0.47266,2.1406 0.41406,2.7969 0.35938,0.26562 0.77344,0.39453 1.1875,0.39453 0.61719,0 1.2227,-0.27734 1.6133,-0.80859 z" />
|
||||
|
||||
<!-- path3: left vertical bar -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="m 26.742615,32.67807 c -1.0586,-0.3125 -2.1719,0.29687 -2.4805,1.3594 -0.55859,1.9062 -0.83984,3.9141 -0.83984,5.9609 0,2.3789 0.39062,4.7227 1.1602,6.9609 0.28516,0.82812 1.0625,1.3516 1.8906,1.3516 0.21484,0 0.43359,-0.03516 0.64844,-0.10938 1.043,-0.35938 1.6016,-1.4961 1.2422,-2.543 -0.625,-1.8203 -0.94141,-3.7227 -0.94141,-5.6602 0,-1.668 0.22656,-3.2969 0.67969,-4.8398 0.30859,-1.0586 -0.30078,-2.168 -1.3594,-2.4805 z" />
|
||||
|
||||
<!-- path4: main ring arc -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="m 14.691615,48.83807 c 0.10156,0.33984 0.19922,0.67969 0.32812,1.0195 0.42969,1.3516 0.94922,2.6484 1.5781,3.9102 0.10156,-0.01172 0.21094,-0.01172 0.32031,-0.01953 l 0.16016,-0.01172 0.80078,-0.07031 c 0.37109,-0.03906 0.67188,-0.07031 0.98047,-0.10156 0.51172,-0.05859 1.0195,-0.12109 1.5586,-0.19922 l 0.21875,-0.03125 c 0.07031,-0.01172 0.14062,-0.01953 0.21094,-0.03125 -1.2188,-2.2109 -2.1484,-4.6016 -2.7305,-7.1211 -0.16016,-0.71094 -0.30078,-1.4297 -0.39844,-2.1602 -0.19922,-1.3086 -0.30078,-2.6602 -0.30078,-4.0312 0,-0.89844 0.03906,-1.7812 0.12891,-2.6484 0.07031,-0.71094 0.16016,-1.4102 0.28906,-2.1016 2.25,-12.949 13.57,-22.828 27.16,-22.828 6.0508,0 11.648,1.9609 16.211,5.3008 0.57031,0.41016 1.1289,0.85938 1.6719,1.3203 1.6914,1.4219 3.2109,3.0703 4.5,4.8789 0.42969,0.60156 0.83984,1.2109 1.2188,1.8398 1.3203,2.1602 2.3398,4.5117 3.0195,7 0.23828,-0.17188 0.42969,-0.30859 0.62891,-0.46875 0.64844,-0.48047 1.2109,-0.92188 1.7383,-1.3398 0.28125,-0.23047 0.5,-0.41016 0.71094,-0.57812 0.10156,-0.07813 0.19141,-0.16016 0.28125,-0.23828 -0.42969,-1.3516 -0.96094,-2.6484 -1.5898,-3.8984 -0.14062,-0.32812 -0.30859,-0.64844 -0.48047,-0.96875 -0.32812,-0.64844 -0.69922,-1.2891 -1.0898,-1.9102 -1.6797,-2.7109 -3.7695,-5.1406 -6.1719,-7.2188 -0.57031,-0.48828 -1.1484,-0.96875 -1.7617,-1.4102 -0.55859,-0.42188 -1.1406,-0.82812 -1.7305,-1.2109 -4.9414,-3.2188 -10.852,-5.0898 -17.16,-5.0898 -14.961,0 -27.531,10.469 -30.75,24.469 -0.17188,0.67969 -0.30859,1.3711 -0.42188,2.0703 -0.12891,0.73828 -0.21875,1.5 -0.28906,2.2617 -0.07813,0.91016 -0.12109,1.8398 -0.12109,2.7812 0,2.3008 0.25,4.5508 0.71875,6.7109 0.17188,0.71484 0.35156,1.4258 0.5625,2.125 z" />
|
||||
|
||||
<!-- path5: top-right swash / outer arc with tail -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="m 90.441615,21.60007 c -2.1797,-5.3398 -9.4102,-7.3984 -21,-6.0391 1.8906,1.8906 3.5391,3.9688 4.9297,6.2109 0.28906,0.46094 0.55859,0.92187 0.80859,1.3789 5.5391,-0.12109 7.6094,1.0391 7.8398,1.4492 0.12891,0.46875 -0.55078,2.7305 -4.5898,6.4805 -0.01953,0.01953 -0.03125,0.03125 -0.03906,0.03906 -0.26172,0.23828 -0.51953,0.48047 -0.80078,0.71875 -0.19922,0.17969 -0.41016,0.35938 -0.62891,0.53906 -0.10938,0.10156 -0.21875,0.19141 -0.33984,0.28906 -0.23828,0.19922 -0.5,0.41016 -0.76172,0.62109 -0.12891,0.10156 -0.26172,0.21094 -0.39844,0.32031 -0.42969,0.33984 -0.89063,0.69141 -1.3711,1.0508 -0.26953,0.21094 -0.53906,0.41016 -0.82812,0.60938 -0.32031,0.23047 -0.64062,0.46875 -0.98047,0.69922 0,0.01172 -0.01172,0.01172 -0.01172,0.01172 -0.26953,0.19141 -0.55078,0.37891 -0.82812,0.57031 -0.28125,0.19141 -0.55859,0.37109 -0.85156,0.55859 -0.25,0.16016 -0.5,0.32812 -0.76172,0.48828 -6,3.8984 -13.48,7.7188 -21.379,10.922 -8.0117,3.2383 -15.871,5.6602 -22.93,7.0391 -0.30078,0.05859 -0.60156,0.12109 -0.89062,0.17188 -0.60938,0.12109 -1.2188,0.21875 -1.8203,0.32031 -0.07031,0.01172 -0.12891,0.01953 -0.19922,0.03125 h -0.01953 c -0.28906,0.05078 -0.57031,0.08984 -0.83984,0.12891 -0.30859,0.05078 -0.60938,0.08984 -0.91016,0.12891 -0.57031,0.07813 -1.1094,0.14844 -1.6406,0.21094 -0.35156,0.03906 -0.69141,0.07031 -1.0195,0.10156 -0.30078,0.03125 -0.58984,0.05078 -0.87891,0.07813 -0.48047,0.03125 -0.92969,0.05859 -1.3711,0.07813 -0.39844,0.01953 -0.78125,0.03125 -1.1484,0.03906 -5.5116996,0.10938 -7.5702996,-1.0391 -7.8007996,-1.4492 -0.12891,-0.48047 0.55078,-2.7383 4.6093996,-6.5 -0.12891,-0.48828 -0.26172,-1 -0.37891,-1.5391 -0.51953,-2.4219 -0.78906,-4.8906 -0.78906,-7.3594 0,-0.17969 0,-0.37109 0.01172,-0.55078 -9.2733996,7.082 -13.0229996,13.59 -10.8749996,18.949 1.7383,4.2695 6.7188,6.4492 14.5899996,6.4492 2.8594,0 6.1016,-0.28906 9.7109,-0.87109 0.17188,-0.03125 0.33984,-0.05859 0.51953,-0.08984 0.17188,-0.03125 0.35156,-0.05859 0.51953,-0.08984 l 1.2188,-0.21875 c 0.57031,-0.10156 1.1484,-0.21875 1.7305,-0.33984 0.53125,-0.10938 1.0508,-0.21094 1.5781,-0.32812 0.01172,0 0.03125,-0.01172 0.03906,-0.01172 0.05078,-0.01172 0.08984,-0.01953 0.14062,-0.03125 0.07031,-0.01172 0.12891,-0.03125 0.19922,-0.05078 0.57812,-0.12891 1.1602,-0.26172 1.7383,-0.39844 0.05078,-0.01172 0.10156,-0.03125 0.14844,-0.03906 0.21094,-0.05078 0.42188,-0.10156 0.64062,-0.14844 h 0.01172 c 6.0898,-1.5117 12.559,-3.6406 19.102,-6.2891 6.5508,-2.6602 12.68,-5.6289 18.109,-8.7812 0.21875,-0.12891 0.44141,-0.26172 0.66016,-0.39062 0.58984,-0.33984 1.1797,-0.69141 1.7617,-1.0508 1.5703,-0.96094 3.0586,-1.9297 4.4805,-2.9102 0.12891,-0.08984 0.26172,-0.17969 0.39062,-0.26953 11.242,-7.8477 15.941,-15.09 13.594,-20.938 z" />
|
||||
android:pathData="M431.879 127.936L363.762 381.328H527.876L258.808 880L720 417.114L431.879 127.936Z" />
|
||||
|
||||
</group>
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 5.3 KiB After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 5.3 KiB After Width: | Height: | Size: 839 B |
|
Before Width: | Height: | Size: 5.3 KiB After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 637 B |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 7.7 KiB After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 7.7 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 7.7 KiB After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 9.2 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 9.2 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 12 KiB |
@@ -1,4 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#ff6600</color>
|
||||
<color name="ic_launcher_background">#e9673f</color>
|
||||
</resources>
|
||||
|
||||
@@ -21,6 +21,9 @@ allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
// Guardian Project's experimental Maven repo, hosting the prebuilt
|
||||
// org.torproject:arti-mobile AAR (Tor in Rust) used for the optional Tor mode.
|
||||
maven { url "https://raw.githubusercontent.com/guardianproject/gpmaven/master" }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
ext {
|
||||
minSdkVersion = 24
|
||||
minSdkVersion = 26
|
||||
compileSdkVersion = 36
|
||||
targetSdkVersion = 36
|
||||
androidxActivityVersion = '1.11.0'
|
||||
|
||||
@@ -18,13 +18,6 @@ const config: CapacitorConfig = {
|
||||
contentInset: 'never',
|
||||
scheme: 'Agora'
|
||||
},
|
||||
plugins: {
|
||||
SystemBars: {
|
||||
// Inject --safe-area-inset-* CSS variables on Android to work around
|
||||
// a Chromium bug (<140) where env(safe-area-inset-*) reports 0.
|
||||
insetsHandling: 'css',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<title>Agora — Power to the people.</title>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content" />
|
||||
<meta name="description" content="Agora — a Nostr social client for communities, creativity, and ownership." />
|
||||
<meta name="description" content="Agora — a peer-to-peer crowdfunding app on Nostr with an integrated non-custodial on-chain Bitcoin wallet. Fund campaigns directly, no middlemen." />
|
||||
|
||||
<!-- Open Graph -->
|
||||
<meta property="og:title" content="Agora" />
|
||||
|
||||
@@ -323,7 +323,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.14.4;
|
||||
MARKETING_VERSION = 2.8.9;
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = spot.agora.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
@@ -347,7 +347,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.8.0;
|
||||
MARKETING_VERSION = 2.8.9;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = spot.agora.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||
|
||||
|
Before Width: | Height: | Size: 291 KiB After Width: | Height: | Size: 75 KiB |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "agora",
|
||||
"private": true,
|
||||
"version": "2.8.0",
|
||||
"version": "2.8.9",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "npm i --silent && vite",
|
||||
@@ -59,8 +59,8 @@
|
||||
"@milkdown/utils": "^7.20.0",
|
||||
"@noble/curves": "^2.2.0",
|
||||
"@noble/hashes": "^1.8.0",
|
||||
"@nostrify/nostrify": "^0.52.1",
|
||||
"@nostrify/react": "^0.6.1",
|
||||
"@nostrify/nostrify": "^0.52.2",
|
||||
"@nostrify/react": "^0.6.2",
|
||||
"@nostrify/types": "^0.37.0",
|
||||
"@plausible-analytics/tracker": "^0.4.4",
|
||||
"@radix-ui/react-accordion": "^1.2.0",
|
||||
@@ -163,7 +163,7 @@
|
||||
"typescript": "^5.5.3",
|
||||
"typescript-eslint": "^8.0.1",
|
||||
"vite": "^8.0.3",
|
||||
"vitest": "^3.1.4"
|
||||
"vitest": "^4.1.8"
|
||||
},
|
||||
"overrides": {
|
||||
"react": "$react",
|
||||
|
||||
@@ -1,5 +1,90 @@
|
||||
# Changelog
|
||||
|
||||
## [2.8.9] - 2026-06-02
|
||||
|
||||
Adds an in-app prompt to grab the Android app from Zapstore, makes it easier to start or explore campaigns right from the home page, and irons out a batch of language and display fixes.
|
||||
|
||||
### Added
|
||||
|
||||
- A prompt to download the Android app from Zapstore, shown to mobile web visitors on the home page, in the account menu, and in the slide-out menu.
|
||||
- A "Start a campaign" button alongside "Browse all" in the middle of the home page.
|
||||
|
||||
### Changed
|
||||
|
||||
- The "Explore campaigns" button now appears for everyone, not just logged-out visitors.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Switching languages now takes effect immediately instead of showing stale text.
|
||||
- The reply box and the replies heading on a post now show up in your chosen language.
|
||||
- Account balances keep their Latin numerals regardless of display language.
|
||||
- Filled in missing translations on the "Why Agora" screen.
|
||||
|
||||
## [2.8.8] - 2026-06-02
|
||||
|
||||
Fixes the app icon proportions and updates the loading splash to the Agora bolt.
|
||||
|
||||
### Fixed
|
||||
|
||||
- App icon no longer appears squashed.
|
||||
- Loading splash now shows the Agora bolt instead of the old logo.
|
||||
|
||||
## [2.8.7] - 2026-06-02
|
||||
|
||||
Fixes the top navigation bar rendering behind the status bar on Android.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Top navigation bar now clears the system status bar on Android.
|
||||
|
||||
## [2.8.6] - 2026-06-02
|
||||
|
||||
Refreshes the app icon to the orange Agora bolt mark across Android, iOS, and the web.
|
||||
|
||||
### Changed
|
||||
|
||||
- Update the app icon to the current Agora bolt on a brand-orange background.
|
||||
|
||||
## [2.8.5] - 2026-06-02
|
||||
|
||||
A maintenance release that fixes the Android build so signed releases publish correctly. No user-facing changes.
|
||||
|
||||
## [2.8.4] - 2026-06-02
|
||||
|
||||
A maintenance release that fixes the Android build so signed releases publish correctly. No user-facing changes.
|
||||
|
||||
## [2.8.3] - 2026-06-02
|
||||
|
||||
A maintenance release that fixes the Android build so signed releases publish correctly. No user-facing changes.
|
||||
|
||||
## [2.8.2] - 2026-06-02
|
||||
|
||||
A maintenance release that fixes the Android build pipeline so signed releases publish correctly. No user-facing changes.
|
||||
|
||||
## [2.8.1] - 2026-06-02
|
||||
|
||||
Agora becomes a home for putting your money where your heart is. Launch and back fundraising campaigns, rally around organizations with their own events and pledges, and send support straight from a built-in Bitcoin and Lightning wallet. Explore the world through immersive country pages, chat with a new AI agent, and move through a faster, cleaner app with a fresh look throughout.
|
||||
|
||||
### Added
|
||||
|
||||
- Fundraising campaigns as the new home surface — create, edit, and back campaigns, set goals, add beneficiaries, and follow progress.
|
||||
- Organizations with their own events, pledges, members, and moderation tools.
|
||||
- Built-in wallet for sending Bitcoin and Lightning payments, with transaction history and balances shown in USD.
|
||||
- One-tap support: zap posts, profiles, campaigns, and organizations.
|
||||
- AI agent chat with a model selector, tool-calling, and slash commands.
|
||||
- Immersive country pages with anthems, flags, weather, and a country-scoped feed, plus a new Discover square for exploring the world.
|
||||
- Comments and reactions on campaigns, and donation receipts shown inline.
|
||||
|
||||
### Changed
|
||||
|
||||
- Refreshed Agora branding, navigation, and app icons throughout.
|
||||
- Streamlined onboarding with country and people follows.
|
||||
- Polished campaign, organization, and donation flows end to end.
|
||||
|
||||
### Removed
|
||||
|
||||
- Direct messaging and ephemeral geo chat.
|
||||
|
||||
## [1.0.0] - 2026-04-30
|
||||
|
||||
### Added
|
||||
|
||||
|
Before Width: | Height: | Size: 8.3 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 226 KiB After Width: | Height: | Size: 36 KiB |
@@ -42,21 +42,28 @@ if [ ! -f "$SOURCE_SVG" ]; then
|
||||
fi
|
||||
|
||||
# Brand colors
|
||||
BG_COLOR="#7c52e0" # Ditto purple
|
||||
BG_COLOR="#e9673f" # Agora orange (hsl(14 79% 58%))
|
||||
|
||||
TMPDIR=$(mktemp -d)
|
||||
LOGO_WHITE_SVG="$TMPDIR/logo_white.svg"
|
||||
LOGO_WHITE="$TMPDIR/logo_white.png"
|
||||
|
||||
# Recolor the SVG fill to white before rasterizing.
|
||||
sed 's/#7c52e0/#ffffff/g' "$SOURCE_SVG" > "$LOGO_WHITE_SVG"
|
||||
# Recolor the SVG fill to white before rasterizing. logo.svg declares the
|
||||
# glyph with fill="black", so recolor both the attribute form and any hex.
|
||||
sed -e 's/fill="black"/fill="#ffffff"/g' \
|
||||
-e 's/#000000/#ffffff/g' \
|
||||
-e 's/#7c52e0/#ffffff/g' "$SOURCE_SVG" > "$LOGO_WHITE_SVG"
|
||||
|
||||
echo "Rendering white SVG at 512x512..."
|
||||
echo "Rendering white SVG (preserving aspect ratio)..."
|
||||
|
||||
# Render at 1024px tall and let the renderer derive the width from the SVG
|
||||
# viewBox, so the non-square logo (720x880) is NOT stretched into a square.
|
||||
# The composite steps below use -resize WxH which fits-inside (aspect-
|
||||
# preserving), keeping the glyph's true proportions.
|
||||
if [ "$SVG_RENDERER" = "inkscape" ]; then
|
||||
inkscape --export-type=png --export-filename="$LOGO_WHITE" -w 512 -h 512 "$LOGO_WHITE_SVG" 2>/dev/null
|
||||
inkscape --export-type=png --export-filename="$LOGO_WHITE" -h 1024 "$LOGO_WHITE_SVG" 2>/dev/null
|
||||
else
|
||||
rsvg-convert -w 512 -h 512 "$LOGO_WHITE_SVG" -o "$LOGO_WHITE"
|
||||
rsvg-convert -h 1024 "$LOGO_WHITE_SVG" -o "$LOGO_WHITE"
|
||||
fi
|
||||
|
||||
# ── Adaptive icon foreground PNGs (transparent bg, white logo, safe-zone padding) ──
|
||||
@@ -82,23 +89,27 @@ make_foreground 192 android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foregrou
|
||||
|
||||
# ── Legacy launcher icons (ic_launcher.png and ic_launcher_round.png) ──
|
||||
# These are used on pre-API-26 devices and as fallback on some launchers.
|
||||
# They must have the logo composited onto the purple background — NOT just
|
||||
# a solid color fill.
|
||||
# Both are the white logo composited onto an orange circle (brand mark).
|
||||
|
||||
echo "Generating legacy launcher icons (ic_launcher.png and ic_launcher_round.png)..."
|
||||
|
||||
# make_legacy_square: logo on flat purple square background
|
||||
# make_legacy_square: white logo on an orange circle (transparent corners)
|
||||
make_legacy_square() {
|
||||
local size=$1
|
||||
local content_size=$(echo "$size * 60 / 100" | bc)
|
||||
local dest=$2
|
||||
local mask="$TMPDIR/circle_mask_sq_${size}.png"
|
||||
$MAGICK -size "${size}x${size}" "xc:none" \
|
||||
-fill white -draw "circle $((size/2)),$((size/2)) $((size/2)),0" \
|
||||
"$mask"
|
||||
$MAGICK -size "${size}x${size}" "xc:${BG_COLOR}" \
|
||||
"$mask" -compose dst-in -composite \
|
||||
\( "$LOGO_WHITE" -resize "${content_size}x${content_size}" \) \
|
||||
-gravity center -compose over -composite \
|
||||
"$dest"
|
||||
}
|
||||
|
||||
# make_legacy_round: logo on circular purple background (alpha-masked circle)
|
||||
# make_legacy_round: white logo on circular orange background (alpha-masked circle)
|
||||
make_legacy_round() {
|
||||
local size=$1
|
||||
local content_size=$(echo "$size * 60 / 100" | bc)
|
||||
@@ -108,7 +119,7 @@ make_legacy_round() {
|
||||
$MAGICK -size "${size}x${size}" "xc:none" \
|
||||
-fill white -draw "circle $((size/2)),$((size/2)) $((size/2)),0" \
|
||||
"$mask"
|
||||
# Fill purple, apply circle mask, composite logo
|
||||
# Fill orange, apply circle mask, composite logo
|
||||
$MAGICK -size "${size}x${size}" "xc:${BG_COLOR}" \
|
||||
"$mask" -compose dst-in -composite \
|
||||
\( "$LOGO_WHITE" -resize "${content_size}x${content_size}" \) \
|
||||
@@ -134,11 +145,11 @@ mkdir -p android/app/src/main/res/values
|
||||
cat > "$BACKGROUND_COLOR_FILE" << 'EOF'
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#7c52e0</color>
|
||||
<color name="ic_launcher_background">#e9673f</color>
|
||||
</resources>
|
||||
EOF
|
||||
|
||||
# ── iOS App Icon (1024x1024, white logo on purple background) ──
|
||||
# ── iOS App Icon (1024x1024, white logo on orange background) ──
|
||||
|
||||
echo "Generating iOS app icon..."
|
||||
|
||||
@@ -146,7 +157,7 @@ IOS_ICON_DIR="ios/App/App/Assets.xcassets/AppIcon.appiconset"
|
||||
|
||||
if [ -d "$IOS_ICON_DIR" ]; then
|
||||
IOS_ICON="$IOS_ICON_DIR/AppIcon-512@2x.png"
|
||||
# Logo at ~60% of canvas, centered on purple background (matches legacy Android style)
|
||||
# Logo at ~60% of canvas, centered on orange background (matches Android style)
|
||||
$MAGICK -size "1024x1024" "xc:${BG_COLOR}" \
|
||||
\( "$LOGO_WHITE" -resize "614x614" \) \
|
||||
-gravity center -compose over -composite \
|
||||
@@ -160,7 +171,7 @@ fi
|
||||
rm -rf "$TMPDIR"
|
||||
|
||||
echo -e "\n${GREEN}App icons generated successfully!${NC}"
|
||||
echo -e "Icon: white Ditto logo on ${GREEN}${BG_COLOR}${NC} (Ditto purple)"
|
||||
echo -e "Icon: white Agora logo on ${GREEN}${BG_COLOR}${NC} (Agora orange)"
|
||||
echo -e "Generated:"
|
||||
echo -e " Android:"
|
||||
echo -e " - ic_launcher_foreground.png (adaptive, all densities)"
|
||||
|
||||
@@ -15,10 +15,14 @@ import { SentryProvider } from "@/components/SentryProvider";
|
||||
|
||||
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { useAppContext } from "@/hooks/useAppContext";
|
||||
import { useNsecPasteGuard } from "@/hooks/useNsecPasteGuard";
|
||||
import { useTor } from "@/hooks/useTor";
|
||||
import type { AppConfig } from "@/contexts/AppContext";
|
||||
import { AudioPlayerProvider } from "@/contexts/AudioPlayerContext";
|
||||
import { NWCProvider } from "@/contexts/NWCContext";
|
||||
import { OnboardingProvider } from "@/contexts/OnboardingProvider";
|
||||
import { HdWalletSpProvider } from "@/contexts/HdWalletSpProvider";
|
||||
import { BuildConfigSchema, type BuildConfig } from "@/lib/schemas";
|
||||
import { secureStorage } from "@/lib/secureStorage";
|
||||
import AppRouter from "./AppRouter";
|
||||
@@ -43,7 +47,7 @@ const hardcodedConfig: AppConfig = {
|
||||
appId: "agora",
|
||||
shareOrigin: import.meta.env.VITE_SHARE_ORIGIN || undefined,
|
||||
homePage: "campaigns",
|
||||
client: "naddr1qvzqqqru7cpzq7q6z5ns2hm5c8msyv83qwzxpxe52j8c4d4q5m92wsp9sflelkh9qqzkg6t5w3hswjl4yp",
|
||||
client: "naddr1qvzqqqru7cpzq7q6z5ns2hm5c8msyv83qwzxpxe52j8c4d4q5m92wsp9sflelkh9qqzkzem0wfssdl264k",
|
||||
theme: "system",
|
||||
useAppRelays: true,
|
||||
useUserRelays: false,
|
||||
@@ -145,6 +149,7 @@ const hardcodedConfig: AppConfig = {
|
||||
imageQuality: 'compressed',
|
||||
imageProxy: 'https://wsrv.nl',
|
||||
lowBandwidthMode: false,
|
||||
torEnabled: false,
|
||||
curatorPubkey: '932614571afcbad4d17a191ee281e39eebbb41b93fac8fd87829622aeb112f4d',
|
||||
esploraApis: [
|
||||
'https://mempool.emzy.de/api',
|
||||
@@ -192,6 +197,24 @@ const defaultConfig: AppConfig = {
|
||||
feedSettings: { ...hardcodedConfig.feedSettings, ...buildConfig.feedSettings },
|
||||
};
|
||||
|
||||
/**
|
||||
* Wraps NostrProvider with a key that changes when Tor routing changes, so the
|
||||
* relay layer remounts: existing connections close and reopen under the new
|
||||
* routing (direct ⇄ fail-closed Tor), and reconnect immediately once Tor is up
|
||||
* rather than waiting out the relay reconnect backoff. No-op off Android (the
|
||||
* key is always "direct").
|
||||
*/
|
||||
function RelayProvider({ children }: { children: React.ReactNode }) {
|
||||
const { config } = useAppContext();
|
||||
const { status } = useTor();
|
||||
const key = !config.torEnabled
|
||||
? "direct"
|
||||
: status === "connected"
|
||||
? "tor-connected"
|
||||
: "tor-pending";
|
||||
return <NostrProvider key={key}>{children}</NostrProvider>;
|
||||
}
|
||||
|
||||
export function App() {
|
||||
useNsecPasteGuard();
|
||||
|
||||
@@ -203,7 +226,7 @@ export function App() {
|
||||
<PlausibleProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<NostrLoginProvider storageKey="nostr:login" storage={secureStorage}>
|
||||
<NostrProvider>
|
||||
<RelayProvider>
|
||||
<NostrSync />
|
||||
<InitialSyncRunner />
|
||||
<NativeNotifications />
|
||||
@@ -211,11 +234,15 @@ export function App() {
|
||||
<NWCProvider>
|
||||
<OnboardingProvider>
|
||||
<TooltipProvider>
|
||||
<AppRouter />
|
||||
<HdWalletSpProvider>
|
||||
<AudioPlayerProvider>
|
||||
<AppRouter />
|
||||
</AudioPlayerProvider>
|
||||
</HdWalletSpProvider>
|
||||
</TooltipProvider>
|
||||
</OnboardingProvider>
|
||||
</NWCProvider>
|
||||
</NostrProvider>
|
||||
</RelayProvider>
|
||||
</NostrLoginProvider>
|
||||
</QueryClientProvider>
|
||||
</PlausibleProvider>
|
||||
|
||||
@@ -7,6 +7,9 @@ import { TopNav } from "./components/TopNav";
|
||||
import { OnboardingGate } from "./components/OnboardingGate";
|
||||
import { ScrollToTop } from "./components/ScrollToTop";
|
||||
import { VersionCheck } from "./components/VersionCheck";
|
||||
import { MinimizedAudioBar } from "./components/MinimizedAudioBar";
|
||||
import { AudioNavigationGuard } from "./components/AudioNavigationGuard";
|
||||
import { TorStatusBanner } from "./components/TorStatusBanner";
|
||||
import { useCurrentUser } from "./hooks/useCurrentUser";
|
||||
import { useProfileUrl } from "./hooks/useProfileUrl";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -39,12 +42,13 @@ const HashtagPage = lazy(() => import("./pages/HashtagPage").then(m => ({ defaul
|
||||
const MyDashboardPage = lazy(() => import("./pages/MyDashboardPage").then(m => ({ default: m.MyDashboardPage })));
|
||||
const AboutPage = lazy(() => import("./pages/AboutPage").then(m => ({ default: m.AboutPage })));
|
||||
const DonorGuidePage = lazy(() => import("./pages/DonorGuidePage").then(m => ({ default: m.DonorGuidePage })));
|
||||
const ActivistGuidePage = lazy(() => import("./pages/ActivistGuidePage").then(m => ({ default: m.ActivistGuidePage })));
|
||||
const RecipientGuidePage = lazy(() => import("./pages/RecipientGuidePage").then(m => ({ default: m.RecipientGuidePage })));
|
||||
const LanguageSettingsPage = lazy(() => import("./pages/LanguageSettingsPage").then(m => ({ default: m.LanguageSettingsPage })));
|
||||
const NetworkSettingsPage = lazy(() => import("./pages/NetworkSettingsPage").then(m => ({ default: m.NetworkSettingsPage })));
|
||||
const NIP19Page = lazy(() => import("./pages/NIP19Page").then(m => ({ default: m.NIP19Page })));
|
||||
const NotificationSettings = lazy(() => import("./pages/NotificationSettings").then(m => ({ default: m.NotificationSettings })));
|
||||
const NotificationsPage = lazy(() => import("./pages/NotificationsPage").then(m => ({ default: m.NotificationsPage })));
|
||||
const OrganizationsPage = lazy(() => import("./pages/OrganizationsPage").then(m => ({ default: m.OrganizationsPage })));
|
||||
const OrganizersPage = lazy(() => import("./pages/OrganizersPage").then(m => ({ default: m.OrganizersPage })));
|
||||
const EventDashboardPage = lazy(() => import("./pages/EventDashboardPage").then(m => ({ default: m.EventDashboardPage })));
|
||||
const PrivacyPolicyPage = lazy(() => import("./pages/PrivacyPolicyPage").then(m => ({ default: m.PrivacyPolicyPage })));
|
||||
@@ -84,7 +88,7 @@ function PageSkeleton() {
|
||||
function SiteFooter() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<footer className="bg-background mt-auto pt-12">
|
||||
<footer className="bg-background mt-auto pt-6 sm:pt-12">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 py-8 flex flex-col sm:flex-row items-center justify-between gap-4 text-xs text-muted-foreground">
|
||||
<button
|
||||
type="button"
|
||||
@@ -95,6 +99,7 @@ function SiteFooter() {
|
||||
</button>
|
||||
<nav className="flex items-center gap-5">
|
||||
<Link to="/about" className="hover:text-foreground motion-safe:transition-colors">{t('nav.about')}</Link>
|
||||
<Link to="/verify" className="hover:text-foreground motion-safe:transition-colors">{t('nav.verify')}</Link>
|
||||
<Link to="/privacy" className="hover:text-foreground motion-safe:transition-colors">{t('nav.privacy')}</Link>
|
||||
<Link to="/safety" className="hover:text-foreground motion-safe:transition-colors">{t('nav.safety')}</Link>
|
||||
<Link to="/changelog" className="hover:text-foreground motion-safe:transition-colors">{t('nav.changelog')}</Link>
|
||||
@@ -133,6 +138,11 @@ export function AppRouter() {
|
||||
<Toaster />
|
||||
<VersionCheck />
|
||||
<ScrollToTop />
|
||||
<AudioNavigationGuard />
|
||||
<MinimizedAudioBar />
|
||||
{/* App-wide Tor status banner. Must live inside BrowserRouter — it
|
||||
renders a <Link> to the Tor settings, which needs Router context. */}
|
||||
<TorStatusBanner />
|
||||
<OnboardingGate>
|
||||
<Routes>
|
||||
{/* Narrow layout — `max-w-3xl` center column. The default for
|
||||
@@ -174,11 +184,15 @@ export function AppRouter() {
|
||||
under the wide layout below. */}
|
||||
<Route path="/help" element={<Navigate to="/about" replace />} />
|
||||
<Route path="/help/donors" element={<Navigate to="/about/donors" replace />} />
|
||||
<Route path="/help/activists" element={<Navigate to="/about/activists" replace />} />
|
||||
<Route path="/help/activists" element={<Navigate to="/about/recipients" replace />} />
|
||||
<Route path="/help/recipients" element={<Navigate to="/about/recipients" replace />} />
|
||||
<Route path="/privacy" element={<PrivacyPolicyPage />} />
|
||||
<Route path="/safety" element={<CSAEPolicyPage />} />
|
||||
<Route path="/changelog" element={<ChangelogPage />} />
|
||||
<Route path="/organizers" element={<OrganizersPage />} />
|
||||
{/* `/settings/verifier` moved to the public `/verify` onboarding
|
||||
page. Keep the old path as a redirect so existing links resolve. */}
|
||||
<Route path="/settings/verifier" element={<Navigate to="/verify" replace />} />
|
||||
{/* Callback target for remote signers (e.g. Amber, Primal) after NIP-46 approval */}
|
||||
<Route path="/remoteloginsuccess" element={<RemoteLoginSuccessPage />} />
|
||||
</Route>
|
||||
@@ -202,11 +216,19 @@ export function AppRouter() {
|
||||
<Route path="/pledges/new" element={<CreateActionPage />} />
|
||||
<Route path="/dashboard" element={<EventDashboardPage />} />
|
||||
<Route path="/i/*" element={<ExternalContentPage />} />
|
||||
{/* About page + Donor / Activist guides. Full-bleed landing-style
|
||||
{/* About page + Donor / Recipient guides. Full-bleed landing-style
|
||||
layouts that render their own internal max-widths. */}
|
||||
<Route path="/about" element={<AboutPage />} />
|
||||
<Route path="/about/donors" element={<DonorGuidePage />} />
|
||||
<Route path="/about/activists" element={<ActivistGuidePage />} />
|
||||
<Route path="/about/recipients" element={<RecipientGuidePage />} />
|
||||
{/* Verification onboarding / marketing page. Wide layout so the
|
||||
hero and section backgrounds can span the viewport like /about. */}
|
||||
<Route path="/verify" element={<OrganizationsPage />} />
|
||||
<Route path="/organizations" element={<Navigate to="/verify" replace />} />
|
||||
{/* Legacy URL: the recipient guide lived at `/about/activists`
|
||||
before the "activist" → "recipient" copy change. Redirect so
|
||||
external links and bookmarks still resolve. */}
|
||||
<Route path="/about/activists" element={<Navigate to="/about/recipients" replace />} />
|
||||
{/* NIP-19 route for npub1, note1, naddr1, nevent1, nprofile1.
|
||||
Goes through the wide layout because the dispatch may resolve to
|
||||
a profile, campaign, action, or community page — all of which
|
||||
|
||||
@@ -20,6 +20,26 @@ const DEFAULT_AI_MODEL = 'google/gemma-4-26b';
|
||||
/** Build-time default translation worker URL from the environment variable. */
|
||||
const DEFAULT_TRANSLATE_WORKER_URL = import.meta.env.VITE_TRANSLATE_WORKER_URL || '';
|
||||
|
||||
/** Hardcoded defaults for the Bitcoin backend fields. Used for reset buttons. */
|
||||
const DEFAULT_ESPLORA_APIS = [
|
||||
'https://mempool.emzy.de/api',
|
||||
'https://mempool.space/api',
|
||||
'https://blockstream.info/api',
|
||||
];
|
||||
const DEFAULT_BLOCKBOOK_BASE_URL = 'https://btc.trezor.io';
|
||||
const DEFAULT_BIP352_INDEXER_URL = 'https://silentpayments.dev/blindbit/mainnet';
|
||||
|
||||
/** Validate an http(s) URL with no trailing slash. */
|
||||
function isValidEndpoint(url: string): boolean {
|
||||
if (!/^https?:\/\//i.test(url)) return false;
|
||||
try {
|
||||
new URL(url);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** The build-time default DSN from the environment variable. */
|
||||
const DEFAULT_SENTRY_DSN = import.meta.env.VITE_SENTRY_DSN || '';
|
||||
|
||||
@@ -31,6 +51,7 @@ export function AdvancedSettings() {
|
||||
const [systemOpen, setSystemOpen] = useState(true);
|
||||
const [aiOpen, setAiOpen] = useState(false);
|
||||
const [sentryOpen, setSentryOpen] = useState(false);
|
||||
const [bitcoinOpen, setBitcoinOpen] = useState(false);
|
||||
const [dangerOpen, setDangerOpen] = useState(false);
|
||||
const [vanishDialogOpen, setVanishDialogOpen] = useState(false);
|
||||
const [statsPubkey, setStatsPubkey] = useState(config.nip85StatsPubkey);
|
||||
@@ -45,6 +66,15 @@ export function AdvancedSettings() {
|
||||
const [showApiKey, setShowApiKey] = useState(false);
|
||||
const [systemPromptDraft, setSystemPromptDraft] = useState(config.aiSystemPrompt || DEFAULT_SYSTEM_PROMPT_TEMPLATE);
|
||||
|
||||
// Bitcoin backend drafts. `esploraApis` is an ordered array edited as one URL per line.
|
||||
const [esploraApisDraft, setEsploraApisDraft] = useState(config.esploraApis.join('\n'));
|
||||
const [blockbookDraft, setBlockbookDraft] = useState(config.blockbookBaseUrl);
|
||||
const [bip352Draft, setBip352Draft] = useState(config.bip352IndexerUrl);
|
||||
|
||||
useEffect(() => { setEsploraApisDraft(config.esploraApis.join('\n')); }, [config.esploraApis]);
|
||||
useEffect(() => { setBlockbookDraft(config.blockbookBaseUrl); }, [config.blockbookBaseUrl]);
|
||||
useEffect(() => { setBip352Draft(config.bip352IndexerUrl); }, [config.bip352IndexerUrl]);
|
||||
|
||||
useEffect(() => { setBaseUrlDraft(config.aiBaseURL); }, [config.aiBaseURL]);
|
||||
useEffect(() => { setApiKeyDraft(config.aiApiKey); }, [config.aiApiKey]);
|
||||
useEffect(() => { setModelDraft(config.aiModel); }, [config.aiModel]);
|
||||
@@ -118,6 +148,67 @@ export function AdvancedSettings() {
|
||||
}
|
||||
};
|
||||
|
||||
const commitEsploraApis = () => {
|
||||
const urls = esploraApisDraft
|
||||
.split('\n')
|
||||
.map((line) => line.trim().replace(/\/+$/, ''))
|
||||
.filter(Boolean);
|
||||
if (urls.length === 0) {
|
||||
setEsploraApisDraft(DEFAULT_ESPLORA_APIS.join('\n'));
|
||||
updateConfig((current) => ({ ...current, esploraApis: DEFAULT_ESPLORA_APIS }));
|
||||
toast({ title: 'Esplora endpoints reset to defaults' });
|
||||
return;
|
||||
}
|
||||
const invalid = urls.find((url) => !isValidEndpoint(url));
|
||||
if (invalid) {
|
||||
toast({ title: 'Invalid Esplora endpoint', description: invalid, variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
const changed =
|
||||
urls.length !== config.esploraApis.length ||
|
||||
urls.some((url, i) => url !== config.esploraApis[i]);
|
||||
if (changed) {
|
||||
updateConfig((current) => ({ ...current, esploraApis: urls }));
|
||||
toast({ title: 'Esplora endpoints updated' });
|
||||
}
|
||||
// Normalize the textarea to the cleaned list.
|
||||
setEsploraApisDraft(urls.join('\n'));
|
||||
};
|
||||
|
||||
const commitBlockbook = () => {
|
||||
const trimmed = blockbookDraft.trim().replace(/\/+$/, '');
|
||||
if (!trimmed) {
|
||||
setBlockbookDraft(DEFAULT_BLOCKBOOK_BASE_URL);
|
||||
if (config.blockbookBaseUrl !== DEFAULT_BLOCKBOOK_BASE_URL) {
|
||||
updateConfig((current) => ({ ...current, blockbookBaseUrl: DEFAULT_BLOCKBOOK_BASE_URL }));
|
||||
toast({ title: 'Blockbook URL reset to default' });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!isValidEndpoint(trimmed)) {
|
||||
toast({ title: 'Invalid Blockbook URL', variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
if (trimmed !== config.blockbookBaseUrl) {
|
||||
updateConfig((current) => ({ ...current, blockbookBaseUrl: trimmed }));
|
||||
toast({ title: 'Blockbook URL updated' });
|
||||
}
|
||||
setBlockbookDraft(trimmed);
|
||||
};
|
||||
|
||||
const commitBip352 = () => {
|
||||
const trimmed = bip352Draft.trim().replace(/\/+$/, '');
|
||||
if (trimmed && !isValidEndpoint(trimmed)) {
|
||||
toast({ title: 'Invalid indexer URL', variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
if (trimmed !== config.bip352IndexerUrl) {
|
||||
updateConfig((current) => ({ ...current, bip352IndexerUrl: trimmed }));
|
||||
toast({ title: trimmed ? 'Silent-payment indexer updated' : 'Silent-payment scanning disabled' });
|
||||
}
|
||||
setBip352Draft(trimmed);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Agent Section */}
|
||||
@@ -442,6 +533,143 @@ export function AdvancedSettings() {
|
||||
</Collapsible>
|
||||
</div>
|
||||
|
||||
{/* Bitcoin Section */}
|
||||
<div>
|
||||
<Collapsible open={bitcoinOpen} onOpenChange={setBitcoinOpen}>
|
||||
<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">Bitcoin</span>
|
||||
{bitcoinOpen ? (
|
||||
<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="px-3 pt-3 pb-4 space-y-5">
|
||||
|
||||
{/* Esplora API endpoints */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<Label htmlFor="esplora-apis" className="text-sm font-medium">
|
||||
Esplora API endpoints
|
||||
</Label>
|
||||
{esploraApisDraft.trim() !== DEFAULT_ESPLORA_APIS.join('\n') && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
title="Restore to defaults"
|
||||
onClick={() => {
|
||||
setEsploraApisDraft(DEFAULT_ESPLORA_APIS.join('\n'));
|
||||
updateConfig((current) => ({ ...current, esploraApis: DEFAULT_ESPLORA_APIS }));
|
||||
toast({ title: 'Esplora endpoints reset to defaults' });
|
||||
}}
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1 mb-2">
|
||||
Esplora-compatible REST roots used for on-chain zaps, donations, and Bitcoin address/tx pages. One URL per line, no trailing slash. Tried in order with failover when an endpoint is rate-limited or down.
|
||||
</p>
|
||||
<Textarea
|
||||
id="esplora-apis"
|
||||
value={esploraApisDraft}
|
||||
onChange={(e) => setEsploraApisDraft(e.target.value)}
|
||||
onBlur={commitEsploraApis}
|
||||
placeholder={DEFAULT_ESPLORA_APIS.join('\n')}
|
||||
className="min-h-[88px] max-h-[200px] resize-y font-mono text-base md:text-sm"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Blockbook base URL */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<Label htmlFor="blockbook-url" className="text-sm font-medium">
|
||||
Blockbook API URL
|
||||
</Label>
|
||||
{blockbookDraft.trim() !== DEFAULT_BLOCKBOOK_BASE_URL && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
title="Restore to default"
|
||||
onClick={() => {
|
||||
setBlockbookDraft(DEFAULT_BLOCKBOOK_BASE_URL);
|
||||
updateConfig((current) => ({ ...current, blockbookBaseUrl: DEFAULT_BLOCKBOOK_BASE_URL }));
|
||||
toast({ title: 'Blockbook URL reset to default' });
|
||||
}}
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1 mb-2">
|
||||
Trezor Blockbook instance used by the HD wallet to scan balances and history. <span className="font-medium text-foreground/80">Privacy note:</span> the wallet's full xpub is sent to this server. Self-host for maximum privacy.
|
||||
</p>
|
||||
<Input
|
||||
id="blockbook-url"
|
||||
type="url"
|
||||
value={blockbookDraft}
|
||||
onChange={(e) => setBlockbookDraft(e.target.value)}
|
||||
onBlur={commitBlockbook}
|
||||
placeholder={DEFAULT_BLOCKBOOK_BASE_URL}
|
||||
className="font-mono text-base md:text-sm"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* BIP-352 silent payment indexer */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<Label htmlFor="bip352-url" className="text-sm font-medium">
|
||||
Silent payment indexer
|
||||
</Label>
|
||||
{bip352Draft.trim() !== DEFAULT_BIP352_INDEXER_URL && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
title="Restore to default"
|
||||
onClick={() => {
|
||||
setBip352Draft(DEFAULT_BIP352_INDEXER_URL);
|
||||
updateConfig((current) => ({ ...current, bip352IndexerUrl: DEFAULT_BIP352_INDEXER_URL }));
|
||||
toast({ title: 'Silent-payment indexer reset to default' });
|
||||
}}
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1 mb-2">
|
||||
BIP-352 tweak-data indexer (BlindBit Oracle) used to detect incoming silent payments (<code className="bg-muted px-1 rounded">sp1…</code>). Your scan key never leaves the device. Leave empty to disable silent-payment scanning.
|
||||
</p>
|
||||
<Input
|
||||
id="bip352-url"
|
||||
type="url"
|
||||
value={bip352Draft}
|
||||
onChange={(e) => setBip352Draft(e.target.value)}
|
||||
onBlur={commitBip352}
|
||||
placeholder={DEFAULT_BIP352_INDEXER_URL}
|
||||
className="font-mono text-base md:text-sm"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
|
||||
{/* Error Reporting Section */}
|
||||
<div>
|
||||
<Collapsible open={sentryOpen} onOpenChange={setSentryOpen}>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useRef } from 'react';
|
||||
import { CalendarClock, HandHeart, MapPin, Megaphone, ShieldCheck, Users } from 'lucide-react';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { Link } from 'react-router-dom';
|
||||
@@ -8,6 +9,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { useBtcPrice } from '@/hooks/useBtcPrice';
|
||||
import { useCampaignDonations } from '@/hooks/useCampaignDonations';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useInView } from '@/hooks/useInView';
|
||||
import { parseAction } from '@/hooks/useActions';
|
||||
import { getGeoDisplayName } from '@/lib/countries';
|
||||
import { parseCampaign, getCampaignCountryLabel } from '@/lib/campaign';
|
||||
@@ -17,17 +19,6 @@ import { formatCompactPledgeDeadline, formatPledgeAmount } from '@/lib/pledges';
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function getDeadlineLabel(unixSeconds: number): { label: string; isPast: boolean } {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const diff = unixSeconds - now;
|
||||
if (diff <= 0) return { label: 'Ended', isPast: true };
|
||||
const days = Math.ceil(diff / 86_400);
|
||||
if (days <= 1) return { label: 'Ends today', isPast: false };
|
||||
if (days < 30) return { label: `${days} days left`, isPast: false };
|
||||
const months = Math.round(days / 30);
|
||||
return { label: `${months} mo left`, isPast: false };
|
||||
}
|
||||
|
||||
function InlineShell({
|
||||
image,
|
||||
fallbackIcon,
|
||||
@@ -66,7 +57,12 @@ function InlineShell({
|
||||
export function CampaignInlinePreview({ event }: { event: NostrEvent }) {
|
||||
const campaign = parseCampaign(event);
|
||||
const { data: btcPrice } = useBtcPrice();
|
||||
const { data: stats } = useCampaignDonations(campaign ?? undefined);
|
||||
// Defer the Esplora-backed donation lookup until the preview scrolls into
|
||||
// view — feeds can render many of these, and fetching all of them eagerly
|
||||
// contributed to the Esplora rate-limiting storm.
|
||||
const previewRef = useRef<HTMLAnchorElement>(null);
|
||||
const inView = useInView(previewRef);
|
||||
const { data: stats } = useCampaignDonations(campaign ?? undefined, { enabled: inView });
|
||||
const author = useAuthor(event.pubkey);
|
||||
if (!campaign) return null;
|
||||
|
||||
@@ -76,7 +72,6 @@ export function CampaignInlinePreview({ event }: { event: NostrEvent }) {
|
||||
?? sanitizeUrl(authorMetadata?.picture);
|
||||
const naddr = nip19.naddrEncode({ kind: event.kind, pubkey: event.pubkey, identifier: campaign.identifier });
|
||||
const countryLabel = getCampaignCountryLabel(campaign);
|
||||
const deadline = campaign.deadline ? getDeadlineLabel(campaign.deadline) : undefined;
|
||||
const isSilentPayment = !campaign.wallets.onchain;
|
||||
const goalLabel = campaign.goalUsd && campaign.goalUsd > 0 ? formatUsdGoal(campaign.goalUsd) : undefined;
|
||||
const raisedSats = stats?.totalSats ?? 0;
|
||||
@@ -87,7 +82,7 @@ export function CampaignInlinePreview({ event }: { event: NostrEvent }) {
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<Link to={`/${naddr}`} onClick={(e) => e.stopPropagation()} className="block rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background">
|
||||
<Link ref={previewRef} to={`/${naddr}`} onClick={(e) => e.stopPropagation()} className="block rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background">
|
||||
<InlineShell
|
||||
image={cover}
|
||||
fallbackIcon={<HandHeart className="size-12" />}
|
||||
@@ -113,16 +108,9 @@ export function CampaignInlinePreview({ event }: { event: NostrEvent }) {
|
||||
)}
|
||||
{countryLabel && (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<MapPin className="size-3.5" />
|
||||
{countryLabel}
|
||||
</span>
|
||||
)}
|
||||
{deadline && (
|
||||
<span className={cn('inline-flex items-center gap-1.5', deadline.isPast && 'text-destructive')}>
|
||||
<CalendarClock className="size-3.5" />
|
||||
{deadline.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { ZAPSTORE_URL } from '@/lib/zapstore';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* Zapstore download nudge — prompts mobile-web visitors to install the native
|
||||
* Android app. Hidden inside the native app (you're already in it) and on
|
||||
* desktop (`sm:hidden`), where downloading works differently.
|
||||
*/
|
||||
export function AppDownloadNudge({ className }: { className?: string }) {
|
||||
const { t } = useTranslation();
|
||||
const { config } = useAppContext();
|
||||
|
||||
if (Capacitor.isNativePlatform()) return null;
|
||||
|
||||
return (
|
||||
<div className={cn('sm:hidden px-4 pt-8 pb-4', className)}>
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-muted-foreground mb-3">
|
||||
{t('feed.getApp.eyebrow')}
|
||||
</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<img
|
||||
src="/logo.png"
|
||||
alt={config.appName}
|
||||
className="h-10 w-10 shrink-0 rounded-xl"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-semibold text-foreground">
|
||||
{t('feed.getApp.title', { appName: config.appName })}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('feed.getApp.subtitle', { appName: config.appName })}
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
href={ZAPSTORE_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="shrink-0 inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-primary/10 hover:bg-primary/20 text-primary text-xs font-medium transition-colors"
|
||||
>
|
||||
{t('feed.getApp.download')}
|
||||
<ArrowRight className="h-3 w-3" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useAudioPlayer } from '@/contexts/audioPlayerContextDef';
|
||||
|
||||
/**
|
||||
* Auto-minimizes the audio player when the user navigates to a different page.
|
||||
* No dialog — audio just keeps playing in the floating mini-bar.
|
||||
*/
|
||||
export function AudioNavigationGuard() {
|
||||
const { currentTrack, minimized, minimize } = useAudioPlayer();
|
||||
const location = useLocation();
|
||||
const prevPath = useRef(location.pathname);
|
||||
|
||||
useEffect(() => {
|
||||
if (location.pathname !== prevPath.current) {
|
||||
// Route changed — minimize if playing and expanded
|
||||
if (currentTrack && !minimized) {
|
||||
minimize();
|
||||
}
|
||||
prevPath.current = location.pathname;
|
||||
}
|
||||
}, [location.pathname, currentTrack, minimized, minimize]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -7,6 +7,10 @@ interface BitcoinAmountPickerProps {
|
||||
usdAmount: number | string;
|
||||
onUsdAmountChange: (amount: number | string) => void;
|
||||
presets: readonly number[];
|
||||
maxLabel?: string;
|
||||
maxSelected?: boolean;
|
||||
maxDisabled?: boolean;
|
||||
onMaxSelect?: () => void;
|
||||
insufficient?: boolean;
|
||||
satsLabel?: string;
|
||||
onAmountChangeStart?: () => void;
|
||||
@@ -16,6 +20,10 @@ export function BitcoinAmountPicker({
|
||||
usdAmount,
|
||||
onUsdAmountChange,
|
||||
presets,
|
||||
maxLabel = 'MAX',
|
||||
maxSelected = false,
|
||||
maxDisabled = false,
|
||||
onMaxSelect,
|
||||
insufficient = false,
|
||||
satsLabel,
|
||||
onAmountChangeStart,
|
||||
@@ -74,14 +82,25 @@ export function BitcoinAmountPicker({
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingAmount(true)}
|
||||
onClick={() => {
|
||||
onAmountChangeStart?.();
|
||||
setEditingAmount(true);
|
||||
}}
|
||||
aria-label="Edit amount"
|
||||
className="flex items-baseline justify-center rounded-md px-2 -mx-2 hover:bg-muted/50 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring transition-colors"
|
||||
>
|
||||
<span className={cn('text-4xl font-semibold', insufficient ? 'text-destructive' : 'text-muted-foreground')}>$</span>
|
||||
<span className={cn('text-4xl font-semibold tabular-nums', insufficient && 'text-destructive')}>
|
||||
{Number.isFinite(currentUsd) && currentUsd > 0 ? currentUsd : 0}
|
||||
</span>
|
||||
{maxSelected ? (
|
||||
<span className={cn('text-4xl font-semibold tracking-tight', insufficient && 'text-destructive')}>
|
||||
{maxLabel}
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<span className={cn('text-4xl font-semibold', insufficient ? 'text-destructive' : 'text-muted-foreground')}>$</span>
|
||||
<span className={cn('text-4xl font-semibold tabular-nums', insufficient && 'text-destructive')}>
|
||||
{Number.isFinite(currentUsd) && currentUsd > 0 ? currentUsd : 0}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{satsLabel && (
|
||||
@@ -93,10 +112,15 @@ export function BitcoinAmountPicker({
|
||||
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={presets.includes(Number(usdAmount)) ? String(usdAmount) : ''}
|
||||
value={maxSelected ? 'max' : presets.includes(Number(usdAmount)) ? String(usdAmount) : ''}
|
||||
onValueChange={(value) => {
|
||||
if (value) {
|
||||
onAmountChangeStart?.();
|
||||
if (value === 'max') {
|
||||
onMaxSelect?.();
|
||||
setEditingAmount(false);
|
||||
return;
|
||||
}
|
||||
onUsdAmountChange(Number(value));
|
||||
setEditingAmount(false);
|
||||
}
|
||||
@@ -112,6 +136,13 @@ export function BitcoinAmountPicker({
|
||||
${preset}
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
<ToggleGroupItem
|
||||
value="max"
|
||||
disabled={maxDisabled}
|
||||
className="h-8 min-w-0 text-xs font-semibold px-1"
|
||||
>
|
||||
{maxLabel}
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useMemo, useRef } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { CalendarClock, HandHeart, MapPin, ShieldCheck } from 'lucide-react';
|
||||
import { HandHeart, ShieldCheck } from 'lucide-react';
|
||||
|
||||
import { AuthorByline } from '@/components/AuthorByline';
|
||||
import { CampaignVerificationBadge } from '@/components/CampaignVerificationBadge';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
@@ -13,6 +14,7 @@ import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useBtcPrice } from '@/hooks/useBtcPrice';
|
||||
import { useCampaignDonations } from '@/hooks/useCampaignDonations';
|
||||
import { useEventTranslation } from '@/hooks/useEventTranslation';
|
||||
import { useInView } from '@/hooks/useInView';
|
||||
import {
|
||||
type ParsedCampaign,
|
||||
encodeCampaignNaddr,
|
||||
@@ -23,17 +25,6 @@ import { formatCampaignAmount, formatUsdGoal, satsToUsd } from '@/lib/formatCamp
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function formatDeadline(unixSeconds: number): { label: string; isPast: boolean } {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const diff = unixSeconds - now;
|
||||
if (diff <= 0) return { label: 'Ended', isPast: true };
|
||||
const days = Math.ceil(diff / 86_400);
|
||||
if (days <= 1) return { label: 'Ends today', isPast: false };
|
||||
if (days < 30) return { label: `${days} days left`, isPast: false };
|
||||
const months = Math.round(days / 30);
|
||||
return { label: `${months} mo left`, isPast: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Short helper rendered both inline (cards) and in the detail page.
|
||||
*
|
||||
@@ -47,13 +38,33 @@ function CampaignProgress({
|
||||
raisedSats,
|
||||
goalUsd,
|
||||
btcPrice,
|
||||
isLoading,
|
||||
className,
|
||||
}: {
|
||||
raisedSats: number;
|
||||
goalUsd?: number;
|
||||
btcPrice?: number;
|
||||
/**
|
||||
* True while the donation totals are still being fetched. The bar gets
|
||||
* its own skeleton — independent of the card, which paints immediately —
|
||||
* so we never flash a misleading "0 raised" before the on-chain balance
|
||||
* lands. Footprint matches the loaded state (bar row + one text row).
|
||||
*/
|
||||
isLoading?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={cn('space-y-1.5', className)}>
|
||||
<Skeleton className="h-2 w-full" />
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const hasGoal = !!goalUsd && goalUsd > 0;
|
||||
const raisedUsd = satsToUsd(raisedSats, btcPrice);
|
||||
const pct = hasGoal && raisedUsd !== undefined
|
||||
@@ -137,13 +148,22 @@ interface CampaignCardProps {
|
||||
className?: string;
|
||||
/** Optional footer affordance rendered opposite the author line. */
|
||||
footerBadge?: ReactNode;
|
||||
/**
|
||||
* When false, the moderator kebab inside the card overlay is suppressed.
|
||||
* The "Hidden" badge still renders so mods can see hide state, but the
|
||||
* menu trigger is omitted. Pass false when the card is wrapped by a
|
||||
* parent (e.g. `ListMemberCard`) that already provides its own combined
|
||||
* kebab containing the moderation actions — avoids two overlapping menus
|
||||
* in the same top-right corner.
|
||||
*/
|
||||
showModerationMenu?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single campaign as a clickable card. The whole card is a
|
||||
* `<Link>` to the campaign's naddr-based detail route.
|
||||
*/
|
||||
export function CampaignCard({ campaign, variant = 'compact', className, footerBadge }: CampaignCardProps) {
|
||||
export function CampaignCard({ campaign, variant = 'compact', className, footerBadge, showModerationMenu = true }: CampaignCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const { translatedEvent, translateAction } = useEventTranslation(campaign.event, {
|
||||
iconOnly: true,
|
||||
@@ -151,7 +171,18 @@ export function CampaignCard({ campaign, variant = 'compact', className, footerB
|
||||
});
|
||||
const displayCampaign = parseCampaign(translatedEvent) ?? campaign;
|
||||
const author = useAuthor(campaign.pubkey);
|
||||
const { data: stats } = useCampaignDonations(campaign);
|
||||
// Defer the (potentially Esplora-heavy) donation lookup until the card is
|
||||
// actually on screen. A campaigns grid mounts up to ~200 cards at once;
|
||||
// fetching donations for every one eagerly fired an Esplora `/address`
|
||||
// call per card plus a `/tx` call per donation receipt, all at once,
|
||||
// which rate-limited every configured backend. `rootMargin` pre-arms the
|
||||
// fetch just before the card scrolls into view so the number is usually
|
||||
// already there by the time the user sees it.
|
||||
const cardRef = useRef<HTMLAnchorElement>(null);
|
||||
const inView = useInView(cardRef);
|
||||
const { data: stats, isLoading: donationsLoading } = useCampaignDonations(campaign, {
|
||||
enabled: inView,
|
||||
});
|
||||
const { data: btcPrice } = useBtcPrice();
|
||||
|
||||
const naddr = useMemo(() => encodeCampaignNaddr(campaign), [campaign]);
|
||||
@@ -159,7 +190,6 @@ export function CampaignCard({ campaign, variant = 'compact', className, footerB
|
||||
const cover = sanitizeUrl(displayCampaign.banner)
|
||||
?? sanitizeUrl(authorMetadata?.banner)
|
||||
?? sanitizeUrl(authorMetadata?.picture);
|
||||
const deadline = campaign.deadline ? formatDeadline(campaign.deadline) : null;
|
||||
const raisedSats = stats?.totalSats ?? 0;
|
||||
const countryLabel = getCampaignCountryLabel(campaign);
|
||||
// SP-only campaigns hide aggregate totals; dual-endpoint campaigns
|
||||
@@ -171,6 +201,7 @@ export function CampaignCard({ campaign, variant = 'compact', className, footerB
|
||||
|
||||
return (
|
||||
<Link
|
||||
ref={cardRef}
|
||||
to={`/${naddr}`}
|
||||
className={cn(
|
||||
'group block rounded-xl overflow-hidden focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background motion-safe:transition-transform motion-safe:duration-200 motion-safe:hover:-translate-y-0.5',
|
||||
@@ -184,7 +215,7 @@ export function CampaignCard({ campaign, variant = 'compact', className, footerB
|
||||
isFeaturedVariant && 'sm:flex-row sm:items-stretch',
|
||||
)}
|
||||
>
|
||||
{/* Cover image. Optional metadata (country, deadline) is
|
||||
{/* Cover image. Optional metadata (country) is
|
||||
overlaid on the banner as glass chips so the body below can
|
||||
stay structurally deterministic. A bottom gradient keeps
|
||||
the chips legible against any photo; a top scrim does the
|
||||
@@ -211,7 +242,7 @@ export function CampaignCard({ campaign, variant = 'compact', className, footerB
|
||||
{/* Bottom gradient — only present when there are bottom chips
|
||||
to display, so a banner with no overlays stays visually
|
||||
clean. */}
|
||||
{(countryLabel || deadline) && (
|
||||
{(countryLabel) && (
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute inset-x-0 bottom-0 h-1/2 bg-gradient-to-t from-black/70 via-black/30 to-transparent"
|
||||
@@ -223,26 +254,24 @@ export function CampaignCard({ campaign, variant = 'compact', className, footerB
|
||||
className="absolute inset-x-0 top-0 h-16 bg-gradient-to-b from-black/30 to-transparent"
|
||||
/>
|
||||
|
||||
{/* Bottom-left meta chips — country + deadline. */}
|
||||
{(countryLabel || deadline) && (
|
||||
{/* Top-left verification badge — stacked moderator avatars for
|
||||
campaigns a moderator has verified. Renders nothing for
|
||||
unverified campaigns. Display-only; the verify action lives in
|
||||
the moderation kebab. */}
|
||||
<CampaignVerificationBadge
|
||||
coord={campaign.aTag}
|
||||
title={campaign.title}
|
||||
className="absolute top-3 left-3 z-10"
|
||||
/>
|
||||
|
||||
{/* Bottom-left meta chips — country. */}
|
||||
{(countryLabel) && (
|
||||
<div className="absolute bottom-3 left-3 z-10 flex flex-wrap items-center gap-1.5 [text-shadow:0_1px_2px_rgba(0,0,0,0.6)]">
|
||||
{countryLabel && (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-black/35 backdrop-blur-md px-2.5 py-1 text-[11px] font-medium text-white">
|
||||
<MapPin className="size-3.5" />
|
||||
{countryLabel}
|
||||
</span>
|
||||
)}
|
||||
{deadline && (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-full bg-black/35 backdrop-blur-md px-2.5 py-1 text-[11px] font-medium text-white',
|
||||
deadline.isPast && 'bg-destructive/60',
|
||||
)}
|
||||
>
|
||||
<CalendarClock className="size-3.5" />
|
||||
{deadline.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -252,6 +281,7 @@ export function CampaignCard({ campaign, variant = 'compact', className, footerB
|
||||
surface="campaign"
|
||||
axes={['hide']}
|
||||
badgeSize="default"
|
||||
showMenu={showModerationMenu}
|
||||
className="absolute top-3 right-3 z-10 flex items-center gap-2"
|
||||
/>
|
||||
</div>
|
||||
@@ -287,7 +317,12 @@ export function CampaignCard({ campaign, variant = 'compact', className, footerB
|
||||
{isSilentPayment ? (
|
||||
<CampaignPrivateNotice goalUsd={campaign.goalUsd} />
|
||||
) : (
|
||||
<CampaignProgress raisedSats={raisedSats} goalUsd={campaign.goalUsd} btcPrice={btcPrice} />
|
||||
<CampaignProgress
|
||||
raisedSats={raisedSats}
|
||||
goalUsd={campaign.goalUsd}
|
||||
btcPrice={btcPrice}
|
||||
isLoading={donationsLoading}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between gap-3 border-t border-border/60 pt-3 text-xs text-muted-foreground">
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { BadgeCheck } from 'lucide-react';
|
||||
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useProfileUrl } from '@/hooks/useProfileUrl';
|
||||
import { useCampaignVerifications } from '@/hooks/useCampaignVerifications';
|
||||
import type { CampaignVerification } from '@/lib/agoraVerification';
|
||||
import { getDisplayName } from '@/lib/genUserName';
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/** Stop card-`<Link>` navigation when the badge is interacted with. */
|
||||
function swallow(e: { preventDefault: () => void; stopPropagation: () => void }) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
/** One moderator avatar in the stacked badge. */
|
||||
function ModeratorAvatar({ pubkey, className }: { pubkey: string; className?: string }) {
|
||||
const author = useAuthor(pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
const picture = sanitizeUrl(metadata?.picture);
|
||||
const initials = getDisplayName(metadata, pubkey).slice(0, 2).toUpperCase();
|
||||
return (
|
||||
<Avatar className={cn('size-6 ring-2 ring-background', className)}>
|
||||
{picture && <AvatarImage src={picture} alt="" proxyWidth={48} />}
|
||||
<AvatarFallback className="bg-secondary text-[9px] text-secondary-foreground">
|
||||
{initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
);
|
||||
}
|
||||
|
||||
/** A single verifier row inside the popover — links to the moderator's profile. */
|
||||
function VerifierRow({ verification }: { verification: CampaignVerification }) {
|
||||
const navigate = useNavigate();
|
||||
const author = useAuthor(verification.pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
const displayName = getDisplayName(metadata, verification.pubkey);
|
||||
const profileUrl = useProfileUrl(verification.pubkey, metadata);
|
||||
const picture = sanitizeUrl(metadata?.picture);
|
||||
const initials = displayName.slice(0, 2).toUpperCase();
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
swallow(e);
|
||||
navigate(profileUrl);
|
||||
}}
|
||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
>
|
||||
<Avatar className="size-6 shrink-0">
|
||||
{picture && <AvatarImage src={picture} alt="" proxyWidth={48} />}
|
||||
<AvatarFallback className="bg-secondary text-[10px] text-secondary-foreground">
|
||||
{initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="truncate font-medium">{displayName}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
interface CampaignVerificationBadgeProps {
|
||||
/** Campaign coordinate `33863:<pubkey>:<d>`. */
|
||||
coord: string;
|
||||
/** Campaign title, used for accessible labels. */
|
||||
title?: string;
|
||||
/**
|
||||
* Visual variant.
|
||||
* - `overlay` (default): dark translucent pill, for placement over a
|
||||
* banner / cover image on campaign cards.
|
||||
* - `inline`: bordered chip that reads on a light page surface, with a
|
||||
* "Verified" label. Used on the campaign detail page heading.
|
||||
*/
|
||||
variant?: 'overlay' | 'inline';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Display-only badge: a stack of moderator avatars over a campaign — one
|
||||
* avatar per moderator that has issued an `agora.verified` label for
|
||||
* it. Hovering / clicking opens a popover listing the verifiers, each
|
||||
* linking to its profile.
|
||||
*
|
||||
* This is purely informational. The verify / remove-verification *actions*
|
||||
* live in the campaign moderation kebab menu (`ModerationMenu`), not here.
|
||||
*
|
||||
* Renders nothing when the campaign has no verifications — there's nothing
|
||||
* to show.
|
||||
*/
|
||||
export function CampaignVerificationBadge({ coord, title, variant = 'overlay', className }: CampaignVerificationBadgeProps) {
|
||||
const { t } = useTranslation();
|
||||
const { data } = useCampaignVerifications();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const verifications = data.byCoord.get(coord) ?? [];
|
||||
const count = verifications.length;
|
||||
|
||||
// Nothing to surface for an unverified campaign.
|
||||
if (count === 0) return null;
|
||||
|
||||
const shown = verifications.slice(0, 3);
|
||||
const extra = count - shown.length;
|
||||
const isInline = variant === 'inline';
|
||||
|
||||
const triggerLabel = t('campaignVerification.verifiedByCount', {
|
||||
count,
|
||||
defaultValue: 'Verified by {{count}} moderator',
|
||||
});
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${triggerLabel}${title ? ` — ${title}` : ''}`}
|
||||
onClick={swallow}
|
||||
onMouseEnter={() => setOpen(true)}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 rounded-full px-1.5 py-1 motion-safe:transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary',
|
||||
isInline
|
||||
? 'border border-border bg-background pr-2.5 text-foreground hover:bg-accent'
|
||||
: 'bg-black/40 backdrop-blur-md text-white hover:bg-black/55',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center -space-x-2">
|
||||
{shown.map((v) => (
|
||||
<ModeratorAvatar key={v.pubkey} pubkey={v.pubkey} />
|
||||
))}
|
||||
</span>
|
||||
<span className="ml-0.5 inline-flex items-center gap-1 pr-1 text-xs font-semibold">
|
||||
<BadgeCheck className={cn('size-4', isInline ? 'text-sky-500' : 'text-sky-300')} />
|
||||
{isInline
|
||||
? t('campaignVerification.verifiedLabel', 'Verified')
|
||||
: (extra > 0 ? `+${extra}` : null)}
|
||||
{isInline && extra > 0 ? ` +${extra}` : null}
|
||||
</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="end"
|
||||
className="w-64 p-2"
|
||||
onMouseLeave={() => setOpen(false)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="px-2 pb-1.5 pt-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{t('campaignVerification.verifiedBy', 'Verified by')}
|
||||
</div>
|
||||
<div className="max-h-56 space-y-0.5 overflow-y-auto">
|
||||
{verifications.map((v) => (
|
||||
<VerifierRow key={v.pubkey} verification={v} />
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -85,17 +85,22 @@ export function CampaignWalletDonatePanel({
|
||||
Error-correction level H tolerates the centered occlusion
|
||||
(~30% of modules can be missing and the code still scans). */}
|
||||
<div className="flex justify-center">
|
||||
<div className="relative rounded-2xl bg-white p-4 shadow-sm">
|
||||
<QRCodeCanvas value={qrPayload} size={280} level="H" />
|
||||
<div className="relative w-full max-w-[280px] rounded-2xl bg-white p-4 shadow-sm">
|
||||
<QRCodeCanvas
|
||||
value={qrPayload}
|
||||
size={280}
|
||||
level="H"
|
||||
className="block h-auto w-full"
|
||||
/>
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute inset-0 flex items-center justify-center pointer-events-none"
|
||||
>
|
||||
<div className="rounded-full bg-primary p-2 ring-[6px] ring-white">
|
||||
<div className="flex aspect-square w-[28%] items-center justify-center rounded-full bg-primary ring-[6px] ring-white">
|
||||
<img
|
||||
src="/logo.svg"
|
||||
alt=""
|
||||
className="size-16 object-contain brightness-0 invert"
|
||||
className="aspect-square w-3/5 object-contain brightness-0 invert"
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -22,7 +22,7 @@ interface GuideHeroProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact photo hero shared by the Donor Guide and Activist Guide pages.
|
||||
* Compact photo hero shared by the Donor Guide and Recipient Guide pages.
|
||||
*
|
||||
* Same structural recipe as the Organize / Actions homepage heroes
|
||||
* ({@link HeroBanner} + {@link HeroAtmosphere} + scrims + overlay copy),
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { BitcoinAmountPicker } from '@/components/BitcoinAmountPicker';
|
||||
import { BitcoinPublicDisclaimer } from '@/components/BitcoinPublicDisclaimer';
|
||||
@@ -48,19 +49,23 @@ import {
|
||||
classifyBroadcastError,
|
||||
type BroadcastErrorKind,
|
||||
} from '@/lib/bitcoinBroadcastError';
|
||||
import { isLargeAmount, satsToUSD } from '@/lib/bitcoin';
|
||||
import { formatSats, isLargeAmount, satsToUSD } from '@/lib/bitcoin';
|
||||
import {
|
||||
broadcastBlockbookTx,
|
||||
fetchAddressInfo,
|
||||
fetchFeeRates,
|
||||
} from '@/lib/hdwallet/blockbook';
|
||||
import {
|
||||
buildHdSpendPsbt,
|
||||
buildHdMaxSpendPsbt,
|
||||
finalizeHdPsbt,
|
||||
type HdInput,
|
||||
type HdSpendableSpUtxo,
|
||||
type HdSpendableUtxo,
|
||||
previewHdMaxSpend,
|
||||
previewHdFee,
|
||||
signHdPsbt,
|
||||
type WalletScope,
|
||||
} from '@/lib/hdwallet/transaction';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
@@ -68,7 +73,7 @@ import { useQuery } from '@tanstack/react-query';
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const USD_PRESETS = [1, 5, 10, 25, 100];
|
||||
const USD_PRESETS = [5, 10, 25, 100];
|
||||
|
||||
type FeeSpeed = BitcoinFeeSpeed;
|
||||
|
||||
@@ -79,6 +84,22 @@ type FeeSpeed = BitcoinFeeSpeed;
|
||||
interface HDSendBitcoinDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
/**
|
||||
* Which wallet this dialog spends from:
|
||||
*
|
||||
* - `'public'` — spends BIP-86 on-chain UTXOs. Change → BIP-86 change
|
||||
* address. Shows the public-ledger privacy disclaimer for raw
|
||||
* addresses.
|
||||
* - `'private'` — spends silent-payment UTXOs only. Change → the
|
||||
* wallet's own `sp1` address (stays private). Warns before sending to
|
||||
* a reused on-chain address or to the user's own public wallet.
|
||||
*
|
||||
* The two scopes never share UTXOs — that isolation is enforced in the
|
||||
* PSBT builder, but the dialog also filters its candidate input set so the
|
||||
* balance, fee preview, and "insufficient" detection all reflect a single
|
||||
* wallet.
|
||||
*/
|
||||
walletScope: WalletScope;
|
||||
/** BTC/USD price — passed in to avoid duplicate fetches. */
|
||||
btcPrice?: number;
|
||||
/**
|
||||
@@ -120,13 +141,15 @@ interface SendResult {
|
||||
* the HD wallet's UTXO set across many addresses, signs with per-input HD-derived
|
||||
* keys, and emits change to a fresh internal address.
|
||||
*/
|
||||
export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipient }: HDSendBitcoinDialogProps) {
|
||||
export function HDSendBitcoinDialog({ isOpen, onClose, walletScope, btcPrice, initialRecipient }: HDSendBitcoinDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const availability = useHdWalletAccess();
|
||||
const {
|
||||
scan,
|
||||
silentPaymentAddress,
|
||||
silentPaymentBalance,
|
||||
silentPaymentStorage,
|
||||
ownPublicAddresses,
|
||||
refetch: refetchWallet,
|
||||
pruneSpentSilentPaymentUtxos,
|
||||
} = useHdWallet();
|
||||
@@ -136,6 +159,7 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipien
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const isReady = availability.status === 'available';
|
||||
const isPrivate = walletScope === 'private';
|
||||
|
||||
const feeSpeedLabels: Record<FeeSpeed, string> = useMemo(
|
||||
() => ({
|
||||
@@ -154,6 +178,7 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipien
|
||||
// recipient (or null) to us. We only see the final picked destination.
|
||||
const [recipient, setRecipient] = useState<ResolvedRecipient | null>(null);
|
||||
const [usdAmount, setUsdAmount] = useState<number | string>(5);
|
||||
const [sendMax, setSendMax] = useState(false);
|
||||
const [feeSpeed, setFeeSpeed] = useState<FeeSpeed>('halfHour');
|
||||
/** Raw text for the custom sat/vB rate input (only used when feeSpeed === 'custom'). */
|
||||
const [customFeeRate, setCustomFeeRate] = useState('');
|
||||
@@ -190,23 +215,30 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipien
|
||||
[feeSpeed, feeRates, customFeeRate],
|
||||
);
|
||||
|
||||
// ── Owned UTXO set ───────────────────────────────────────────
|
||||
// ── Owned UTXO set (scoped to this wallet) ───────────────────
|
||||
//
|
||||
// Combines BIP-86 UTXOs scanned from Blockbook with silent-payment UTXOs
|
||||
// discovered by the BIP-352 scanner and persisted via NIP-78. Both can
|
||||
// fund a send; the PSBT builder dispatches per-input.
|
||||
const bip86Utxos: HdSpendableUtxo[] = useMemo(() => scan?.utxos ?? [], [scan]);
|
||||
// The public wallet spends ONLY BIP-86 UTXOs; the private wallet spends
|
||||
// ONLY silent-payment UTXOs. We filter to the active scope here so the
|
||||
// fee preview, "Max", and "insufficient" detection all reflect a single
|
||||
// wallet's balance. The PSBT builder re-applies the same scope filter as a
|
||||
// hard guarantee, but scoping here keeps the UI numbers honest.
|
||||
const bip86Utxos: HdSpendableUtxo[] = useMemo(
|
||||
() => (isPrivate ? [] : (scan?.utxos ?? [])),
|
||||
[scan, isPrivate],
|
||||
);
|
||||
const spUtxos: HdSpendableSpUtxo[] = useMemo(
|
||||
() =>
|
||||
(silentPaymentStorage?.utxos ?? []).map((u) => ({
|
||||
txid: u.txid,
|
||||
vout: u.vout,
|
||||
value: u.value,
|
||||
tweakHex: u.tweak,
|
||||
k: u.k,
|
||||
height: u.height,
|
||||
})),
|
||||
[silentPaymentStorage],
|
||||
isPrivate
|
||||
? (silentPaymentStorage?.utxos ?? []).map((u) => ({
|
||||
txid: u.txid,
|
||||
vout: u.vout,
|
||||
value: u.value,
|
||||
tweakHex: u.tweak,
|
||||
k: u.k,
|
||||
height: u.height,
|
||||
}))
|
||||
: [],
|
||||
[silentPaymentStorage, isPrivate],
|
||||
);
|
||||
const ownedInputs: HdInput[] = useMemo(
|
||||
() => [
|
||||
@@ -216,8 +248,11 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipien
|
||||
[bip86Utxos, spUtxos],
|
||||
);
|
||||
const totalBalance = useMemo(
|
||||
() => bip86Utxos.reduce((s, u) => s + u.value, 0) + silentPaymentBalance,
|
||||
[bip86Utxos, silentPaymentBalance],
|
||||
() =>
|
||||
isPrivate
|
||||
? silentPaymentBalance
|
||||
: bip86Utxos.reduce((s, u) => s + u.value, 0),
|
||||
[bip86Utxos, silentPaymentBalance, isPrivate],
|
||||
);
|
||||
|
||||
// ── USD → sats ───────────────────────────────────────────────
|
||||
@@ -228,6 +263,11 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipien
|
||||
return Math.round((usd / btcPrice) * 100_000_000);
|
||||
}, [usdAmount, btcPrice]);
|
||||
|
||||
const maxSpend = useMemo(
|
||||
() => (currentFeeRate ? previewHdMaxSpend(ownedInputs, currentFeeRate) : null),
|
||||
[ownedInputs, currentFeeRate],
|
||||
);
|
||||
|
||||
// ── Fee estimate (matches the actual coin selection) ────────
|
||||
//
|
||||
// Crucially we do NOT use `ownedInputs.length` as the input count: an HD
|
||||
@@ -240,17 +280,21 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipien
|
||||
return previewHdFee(ownedInputs, amountSats, currentFeeRate);
|
||||
}, [ownedInputs, currentFeeRate, amountSats]);
|
||||
|
||||
const totalSats = amountSats + estimatedFeeSats;
|
||||
const effectiveAmountSats = sendMax ? (maxSpend?.amountSats ?? 0) : amountSats;
|
||||
const effectiveFeeSats = sendMax ? (maxSpend?.fee ?? 0) : estimatedFeeSats;
|
||||
const totalSats = effectiveAmountSats + effectiveFeeSats;
|
||||
// `previewHdFee` returns 0 when the coin selector can't cover `amount + fee`.
|
||||
// Treat that as insufficient so the UI doesn't claim a 0-sat fee is fine.
|
||||
const selectionFailed =
|
||||
amountSats > 0 && !!currentFeeRate && ownedInputs.length > 0 && estimatedFeeSats === 0;
|
||||
const selectionFailed = sendMax
|
||||
? !!currentFeeRate && ownedInputs.length > 0 && !maxSpend
|
||||
: amountSats > 0 && !!currentFeeRate && ownedInputs.length > 0 && estimatedFeeSats === 0;
|
||||
const insufficient = selectionFailed || (totalBalance > 0 && totalSats > totalBalance);
|
||||
|
||||
// Auto-tune fee speed to keep fees < 40% of the send amount, unless the
|
||||
// user has manually overridden.
|
||||
useEffect(() => {
|
||||
if (feeSpeedUserChanged.current) return;
|
||||
if (sendMax) return;
|
||||
if (!ownedInputs.length || !feeRates || amountSats <= 0) return;
|
||||
|
||||
const uniqueSpeeds = getUniqueBitcoinFeeSpeeds(feeRates);
|
||||
@@ -263,7 +307,7 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipien
|
||||
if (fee > 0 && fee <= threshold) { target = speed; break; }
|
||||
}
|
||||
setFeeSpeed((prev) => (prev === target ? prev : target));
|
||||
}, [amountSats, feeRates, ownedInputs, totalBalance]);
|
||||
}, [amountSats, feeRates, ownedInputs, sendMax, totalBalance]);
|
||||
|
||||
const handleFeeSpeedChange = useCallback((speed: FeeSpeed) => {
|
||||
feeSpeedUserChanged.current = true;
|
||||
@@ -282,9 +326,94 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipien
|
||||
const isRawAddress = !!recipient && recipient.kind === 'address';
|
||||
const [confirmArmed, setConfirmArmed] = useState(false);
|
||||
|
||||
// ── Private-send address-reuse guard ─────────────────────────
|
||||
//
|
||||
// When the private (silent-payment) wallet sends to a bare on-chain
|
||||
// address we check whether that address would link the payment to an
|
||||
// existing identity: it has prior on-chain history, or it belongs to the
|
||||
// user's own public wallet. Either case warrants an explicit "Send anyway"
|
||||
// before we'll build the transaction. SP recipients are exempt — they're
|
||||
// unlinkable by construction.
|
||||
type ReuseWarning =
|
||||
| { kind: 'history' }
|
||||
| { kind: 'ownPublic' };
|
||||
const [reuseWarning, setReuseWarning] = useState<ReuseWarning | null>(null);
|
||||
const [reuseChecked, setReuseChecked] = useState(false);
|
||||
const [reuseChecking, setReuseChecking] = useState(false);
|
||||
const [reuseAcknowledged, setReuseAcknowledged] = useState(false);
|
||||
|
||||
// The private-send guard only applies to bare on-chain recipients.
|
||||
const needsReuseCheck = isPrivate && isRawAddress;
|
||||
|
||||
// ── Public-send to own silent-payment address: hard block ────
|
||||
//
|
||||
// The inverse of the private-send reuse guard. When the PUBLIC wallet is
|
||||
// about to pay the user's OWN silent-payment address, the silent-payment
|
||||
// UTXOs that result get co-mingled with funds the user already exposed on
|
||||
// the public ledger — linking the private wallet back to a known on-chain
|
||||
// identity and destroying the privacy the silent-payment wallet exists to
|
||||
// provide. Unlike the private-send guard there is no "send anyway" escape
|
||||
// hatch: this is disallowed outright. We still render the explanation so
|
||||
// the user understands why.
|
||||
const ownSilentPaymentBlock =
|
||||
!isPrivate &&
|
||||
!!recipient &&
|
||||
recipient.kind === 'sp' &&
|
||||
!!silentPaymentAddress &&
|
||||
recipient.address === silentPaymentAddress.address;
|
||||
|
||||
useEffect(() => {
|
||||
setConfirmArmed(false);
|
||||
}, [amountSats, currentFeeRate, btcPrice, recipient?.address]);
|
||||
}, [effectiveAmountSats, currentFeeRate, btcPrice, recipient?.address]);
|
||||
|
||||
// Run the private-send reuse check as soon as a bare on-chain recipient is
|
||||
// selected — not on the first Send tap — so the user sees the privacy
|
||||
// implication while reviewing the transaction. The Send button is disabled
|
||||
// while the probe is in flight. The check is reset and re-run whenever the
|
||||
// recipient changes; an in-flight probe for a previous recipient is ignored
|
||||
// via the `cancelled` guard.
|
||||
useEffect(() => {
|
||||
setReuseWarning(null);
|
||||
setReuseChecked(false);
|
||||
setReuseChecking(false);
|
||||
setReuseAcknowledged(false);
|
||||
|
||||
if (!needsReuseCheck || !recipient || recipient.kind !== 'address') {
|
||||
return;
|
||||
}
|
||||
|
||||
const addr = recipient.address;
|
||||
|
||||
// Synchronous: is this one of our own public-wallet addresses?
|
||||
if (ownPublicAddresses.has(addr)) {
|
||||
setReuseWarning({ kind: 'ownPublic' });
|
||||
setReuseChecked(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Asynchronous: does the address have on-chain history?
|
||||
let cancelled = false;
|
||||
setReuseChecking(true);
|
||||
fetchAddressInfo(blockbookBaseUrl, addr)
|
||||
.then((info) => {
|
||||
if (cancelled) return;
|
||||
setReuseChecking(false);
|
||||
setReuseChecked(true);
|
||||
if (info.hasHistory) setReuseWarning({ kind: 'history' });
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
// Inconclusive lookup. Fail safe: treat as a possible-reuse warning so
|
||||
// the user makes a deliberate choice.
|
||||
setReuseChecking(false);
|
||||
setReuseChecked(true);
|
||||
setReuseWarning({ kind: 'history' });
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [recipient, needsReuseCheck, ownPublicAddresses, blockbookBaseUrl, walletScope]);
|
||||
|
||||
// Track open transitions so we can re-key the picker on each
|
||||
// closed → open transition. Re-keying remounts the picker with a fresh
|
||||
@@ -312,31 +441,58 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipien
|
||||
if (!recipient) throw new Error(t('walletSend.errors.enterRecipient'));
|
||||
if (!ownedInputs.length) throw new Error(t('walletSend.errors.noSpendable'));
|
||||
if (feeSpeed !== 'custom' && !feeRates) throw new Error(t('walletSend.errors.feesNotLoaded'));
|
||||
if (amountSats <= 0) throw new Error(t('walletSend.errors.enterAmount'));
|
||||
if (effectiveAmountSats <= 0) throw new Error(t('walletSend.errors.enterAmount'));
|
||||
if (insufficient) throw new Error(t('walletSend.errors.insufficient'));
|
||||
|
||||
const rate = resolveBitcoinFeeRate(feeSpeed, feeRates, customFeeRate);
|
||||
if (!rate || rate < 1) throw new Error(t('walletSend.errors.feeRateTooLow'));
|
||||
const nextChangeIndex = scan?.change.firstUnusedIndex ?? 0;
|
||||
const resolvedRecipient = recipient.kind === 'sp'
|
||||
? { kind: 'sp' as const, spAddress: recipient.address }
|
||||
: { kind: 'address' as const, address: recipient.address };
|
||||
|
||||
setProgress('building');
|
||||
const built = buildHdSpendPsbt({
|
||||
account: availability.account,
|
||||
inputs: ownedInputs,
|
||||
recipient:
|
||||
recipient.kind === 'sp'
|
||||
? { kind: 'sp', spAddress: recipient.address }
|
||||
: { kind: 'address', address: recipient.address },
|
||||
amountSats,
|
||||
feeRate: rate,
|
||||
nextChangeIndex,
|
||||
seed: availability.seed,
|
||||
});
|
||||
let psbtHex: string;
|
||||
let fee: number;
|
||||
let sentAmountSats = effectiveAmountSats;
|
||||
let inputDescriptors: Parameters<typeof signHdPsbt>[1];
|
||||
let consumedSpUtxos: Array<{ txid: string; vout: number }>;
|
||||
|
||||
if (sendMax) {
|
||||
const built = buildHdMaxSpendPsbt({
|
||||
account: availability.account,
|
||||
walletScope,
|
||||
inputs: ownedInputs,
|
||||
recipient: resolvedRecipient,
|
||||
feeRate: rate,
|
||||
seed: availability.seed,
|
||||
});
|
||||
psbtHex = built.psbtHex;
|
||||
fee = built.fee;
|
||||
sentAmountSats = built.amountSats;
|
||||
inputDescriptors = built.inputDescriptors;
|
||||
consumedSpUtxos = built.consumedSpUtxos;
|
||||
} else {
|
||||
const built = buildHdSpendPsbt({
|
||||
account: availability.account,
|
||||
walletScope,
|
||||
inputs: ownedInputs,
|
||||
recipient: resolvedRecipient,
|
||||
amountSats: effectiveAmountSats,
|
||||
feeRate: rate,
|
||||
nextChangeIndex,
|
||||
seed: availability.seed,
|
||||
});
|
||||
psbtHex = built.psbtHex;
|
||||
fee = built.fee;
|
||||
inputDescriptors = built.inputDescriptors;
|
||||
consumedSpUtxos = built.consumedSpUtxos;
|
||||
}
|
||||
|
||||
setProgress('signing');
|
||||
const signedHex = signHdPsbt(
|
||||
built.psbtHex,
|
||||
built.inputDescriptors,
|
||||
psbtHex,
|
||||
inputDescriptors,
|
||||
availability.account,
|
||||
availability.seed,
|
||||
);
|
||||
@@ -345,12 +501,11 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipien
|
||||
setProgress('broadcasting');
|
||||
const txid = await broadcastBlockbookTx(blockbookBaseUrl, txHex);
|
||||
|
||||
return { txid, amountSats, fee: built.fee, consumedSpUtxos: built.consumedSpUtxos };
|
||||
return { txid, amountSats: sentAmountSats, fee, consumedSpUtxos };
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
notificationSuccess();
|
||||
setSuccess(result);
|
||||
queryClient.invalidateQueries({ queryKey: ['hdwallet-scan'] });
|
||||
// Remove the SP UTXOs we just spent from local storage and
|
||||
// republish the NIP-78 doc. Blockbook's xpub scan can't see SP
|
||||
// outputs, so without this the spent UTXOs would linger forever:
|
||||
@@ -362,6 +517,9 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipien
|
||||
if (result.consumedSpUtxos.length > 0) {
|
||||
pruneSpentSilentPaymentUtxos(result.consumedSpUtxos);
|
||||
}
|
||||
// Refresh after pruning so transaction history can classify mixed
|
||||
// BIP-86 + SP sends with the spent SP outpoints already archived.
|
||||
queryClient.invalidateQueries({ queryKey: ['hdwallet-scan'] });
|
||||
void refetchWallet();
|
||||
},
|
||||
onError: (err) => {
|
||||
@@ -393,7 +551,7 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipien
|
||||
// because that's just a passive refresh.
|
||||
useEffect(() => {
|
||||
setBroadcastError(null);
|
||||
}, [recipient?.address, amountSats, feeSpeed, customFeeRate]);
|
||||
}, [recipient?.address, effectiveAmountSats, feeSpeed, customFeeRate]);
|
||||
|
||||
/**
|
||||
* Recovery action for fee-related broadcast failures.
|
||||
@@ -466,7 +624,7 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipien
|
||||
}
|
||||
if (!recipient) { setError(t('walletSend.errors.enterRecipient')); return; }
|
||||
if (!btcPrice) { setError(t('walletSend.errors.waitingPrice')); return; }
|
||||
if (amountSats <= 0) { setError(t('walletSend.errors.enterAmount')); return; }
|
||||
if (effectiveAmountSats <= 0) { setError(t('walletSend.errors.enterAmount')); return; }
|
||||
if (!ownedInputs.length) { setError(t('walletSend.errors.noneYet')); return; }
|
||||
if (!currentFeeRate || currentFeeRate < 1) {
|
||||
setError(
|
||||
@@ -477,6 +635,20 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipien
|
||||
return;
|
||||
}
|
||||
if (insufficient) { setError(t('walletSend.errors.insufficient')); return; }
|
||||
|
||||
// Public wallet → own silent-payment address: disallowed outright. The
|
||||
// blocking notice is rendered inline; bail before building anything.
|
||||
if (ownSilentPaymentBlock) return;
|
||||
|
||||
// Private-wallet send to a bare on-chain address: the reuse check runs
|
||||
// automatically when the recipient is selected. Here we just enforce its
|
||||
// outcome — block until the probe settles, and require the explicit
|
||||
// acknowledgement when it flagged a warning.
|
||||
if (needsReuseCheck && recipient.kind === 'address') {
|
||||
if (reuseChecking || !reuseChecked) return; // probe not settled yet
|
||||
if (reuseWarning && !reuseAcknowledged) return;
|
||||
}
|
||||
|
||||
if (requiresArm && !confirmArmed) { setConfirmArmed(true); return; }
|
||||
sendMutation.mutate();
|
||||
}, [
|
||||
@@ -484,7 +656,7 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipien
|
||||
availability,
|
||||
recipient,
|
||||
btcPrice,
|
||||
amountSats,
|
||||
effectiveAmountSats,
|
||||
ownedInputs.length,
|
||||
currentFeeRate,
|
||||
feeSpeed,
|
||||
@@ -492,6 +664,12 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipien
|
||||
requiresArm,
|
||||
confirmArmed,
|
||||
sendMutation,
|
||||
needsReuseCheck,
|
||||
reuseChecking,
|
||||
reuseChecked,
|
||||
reuseWarning,
|
||||
reuseAcknowledged,
|
||||
ownSilentPaymentBlock,
|
||||
]);
|
||||
|
||||
// ── Reset on close ───────────────────────────────────────────
|
||||
@@ -502,12 +680,17 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipien
|
||||
setTimeout(() => {
|
||||
setRecipient(null);
|
||||
setUsdAmount(5);
|
||||
setSendMax(false);
|
||||
setError('');
|
||||
setFeeSpeed('halfHour');
|
||||
setCustomFeeRate('');
|
||||
setConfirmArmed(false);
|
||||
setSuccess(null);
|
||||
setBroadcastError(null);
|
||||
setReuseWarning(null);
|
||||
setReuseChecked(false);
|
||||
setReuseChecking(false);
|
||||
setReuseAcknowledged(false);
|
||||
feeSpeedUserChanged.current = false;
|
||||
}, 200);
|
||||
}, [onClose, sendMutation.isPending]);
|
||||
@@ -522,6 +705,8 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipien
|
||||
default: return t('walletSend.progress.sending');
|
||||
}
|
||||
}
|
||||
if (reuseChecking) return t('walletSend.reuse.checking');
|
||||
if (needsReuseCheck && reuseWarning && reuseAcknowledged) return t('walletSend.reuse.sendAnyway');
|
||||
if (confirmArmed) return t('walletSend.tapAgainToConfirm');
|
||||
if (insufficient) return t('walletSend.notEnoughBitcoin');
|
||||
return t('walletSend.send');
|
||||
@@ -529,19 +714,31 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipien
|
||||
|
||||
const sendDisabled =
|
||||
sendMutation.isPending ||
|
||||
reuseChecking ||
|
||||
!recipient ||
|
||||
!btcPrice ||
|
||||
amountSats <= 0 ||
|
||||
effectiveAmountSats <= 0 ||
|
||||
insufficient ||
|
||||
!ownedInputs.length ||
|
||||
!currentFeeRate ||
|
||||
currentFeeRate < 1;
|
||||
currentFeeRate < 1 ||
|
||||
// Private-send reuse guard: once a warning has surfaced, the user must
|
||||
// tick "Send anyway" before the button re-enables.
|
||||
(needsReuseCheck && !!reuseWarning && !reuseAcknowledged) ||
|
||||
// Public-send to own silent-payment address: disallowed, no override.
|
||||
ownSilentPaymentBlock;
|
||||
|
||||
const maxAmountLabel = sendMax && effectiveAmountSats > 0 && btcPrice
|
||||
? `${satsToUSD(effectiveAmountSats, btcPrice)} · ${t('walletSend.success.satsAmount', { sats: formatSats(effectiveAmountSats) })}`
|
||||
: undefined;
|
||||
|
||||
// ── Render ───────────────────────────────────────────────────
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(v) => { if (!v) handleClose(); }}>
|
||||
<DialogContent className="max-w-[425px] rounded-2xl p-0 gap-0 border-border overflow-hidden max-h-[95vh] [&>button]:hidden">
|
||||
<DialogTitle className="sr-only">{t('walletSend.title')}</DialogTitle>
|
||||
<DialogTitle className="sr-only">
|
||||
{isPrivate ? t('walletSend.titlePrivate') : t('walletSend.titlePublic')}
|
||||
</DialogTitle>
|
||||
|
||||
{success ? (
|
||||
<SuccessScreen
|
||||
@@ -555,7 +752,7 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipien
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 h-12">
|
||||
<h2 className="text-base font-semibold flex items-center gap-1.5">
|
||||
{t('walletSend.title')}
|
||||
{isPrivate ? t('walletSend.titlePrivate') : t('walletSend.titlePublic')}
|
||||
<HelpTip faqId="send-bitcoin-onchain" />
|
||||
</h2>
|
||||
<button
|
||||
@@ -570,10 +767,24 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipien
|
||||
<div className="grid gap-4 px-4 py-4 w-full overflow-y-auto">
|
||||
<BitcoinAmountPicker
|
||||
usdAmount={usdAmount}
|
||||
onUsdAmountChange={setUsdAmount}
|
||||
onUsdAmountChange={(amount) => {
|
||||
setSendMax(false);
|
||||
setUsdAmount(amount);
|
||||
}}
|
||||
presets={USD_PRESETS}
|
||||
maxLabel={t('walletSend.max')}
|
||||
maxSelected={sendMax}
|
||||
maxDisabled={!ownedInputs.length || !currentFeeRate || !maxSpend}
|
||||
onMaxSelect={() => {
|
||||
setError('');
|
||||
setSendMax(true);
|
||||
}}
|
||||
insufficient={insufficient}
|
||||
onAmountChangeStart={() => setError('')}
|
||||
satsLabel={maxAmountLabel}
|
||||
onAmountChangeStart={() => {
|
||||
setError('');
|
||||
setSendMax(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Recipient — text input + Popover dropdown surfacing the
|
||||
@@ -591,13 +802,63 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipien
|
||||
initialInput={initialRecipient}
|
||||
/>
|
||||
|
||||
{/* Privacy disclaimer for raw on-chain addresses. SP
|
||||
recipients produce a fresh unlinkable output per payment
|
||||
and don't need the warning. */}
|
||||
{isRawAddress && (
|
||||
{/* Privacy disclaimer for raw on-chain addresses on the PUBLIC
|
||||
wallet. SP recipients produce a fresh unlinkable output per
|
||||
payment and don't need the warning. The PRIVATE wallet uses
|
||||
the stronger reuse guard below instead of this soft notice. */}
|
||||
{isRawAddress && !isPrivate && (
|
||||
<BitcoinPublicDisclaimer tone="soft" />
|
||||
)}
|
||||
|
||||
{/* Private-wallet address-reuse guard. When sending a silent
|
||||
payment to a bare on-chain address that has prior history,
|
||||
or to the user's own public wallet, paying it would relink
|
||||
the private funds to a known identity — defeating the whole
|
||||
point of the private wallet. Require an explicit
|
||||
acknowledgement before the Send button re-enables. */}
|
||||
{needsReuseCheck && reuseWarning && (
|
||||
<Alert
|
||||
role="alert"
|
||||
className="border-destructive/50 bg-destructive/5 text-destructive dark:border-destructive py-3"
|
||||
>
|
||||
<AlertTriangle className="size-4 text-destructive" />
|
||||
<AlertDescription className="text-xs space-y-2">
|
||||
<p>
|
||||
{reuseWarning.kind === 'ownPublic'
|
||||
? t('walletSend.reuse.ownPublicWarning')
|
||||
: t('walletSend.reuse.historyWarning')}
|
||||
</p>
|
||||
<label className="flex items-start gap-2 cursor-pointer select-none">
|
||||
<Checkbox
|
||||
checked={reuseAcknowledged}
|
||||
onCheckedChange={(checked) => setReuseAcknowledged(checked === true)}
|
||||
className="mt-0.5 border-destructive data-[state=checked]:bg-destructive data-[state=checked]:text-destructive-foreground"
|
||||
aria-label={t('walletSend.reuse.acknowledge')}
|
||||
/>
|
||||
<span>{t('walletSend.reuse.acknowledge')}</span>
|
||||
</label>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Public-wallet → own silent-payment address. Disallowed
|
||||
outright: sending public funds to your own private wallet
|
||||
co-mingles the silent-payment UTXOs with coins already
|
||||
exposed on the public ledger, linking the private wallet to
|
||||
a known identity. No "send anyway" — the Send button stays
|
||||
disabled while this is the recipient. */}
|
||||
{ownSilentPaymentBlock && (
|
||||
<Alert
|
||||
role="alert"
|
||||
className="border-destructive/50 bg-destructive/5 text-destructive dark:border-destructive py-3"
|
||||
>
|
||||
<AlertTriangle className="size-4 text-destructive" />
|
||||
<AlertDescription className="text-xs">
|
||||
{t('walletSend.ownSilentPaymentBlocked')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<Alert variant="destructive" className="py-2">
|
||||
@@ -648,8 +909,8 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipien
|
||||
type="button"
|
||||
className="flex items-center gap-1.5 hover:text-foreground transition-colors text-muted-foreground tabular-nums"
|
||||
>
|
||||
{estimatedFeeSats > 0 && btcPrice ? (
|
||||
<>≈ {satsToUSD(estimatedFeeSats, btcPrice)}</>
|
||||
{effectiveFeeSats > 0 && btcPrice ? (
|
||||
<>≈ {satsToUSD(effectiveFeeSats, btcPrice)}</>
|
||||
) : currentFeeRate ? (
|
||||
<>{t('walletSend.satPerVB', { rate: currentFeeRate })}</>
|
||||
) : feeRatesLoading && feeSpeed !== 'custom' ? (
|
||||
|
||||
@@ -23,7 +23,8 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { useHdWalletSp } from '@/hooks/useHdWalletSp';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { useHdWalletSp } from '@/hooks/useHdWalletSpContext';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -283,6 +284,27 @@ export function HDSilentPaymentScanDialog({ open, onOpenChange }: HDSilentPaymen
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Automatic background scanning toggle. When on, the provider
|
||||
quietly resumes scanning from the last block and keeps up with
|
||||
the tip without the user opening this dialog. The manual
|
||||
controls below remain available for targeted/deep rescans. */}
|
||||
<div className="flex items-start justify-between gap-3 rounded-lg border bg-muted/30 px-3 py-2.5">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="sp-auto-scan" className="text-sm cursor-pointer">
|
||||
{t('spScan.autoScan.label')}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('spScan.autoScan.description')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="sp-auto-scan"
|
||||
checked={sp.autoScanEnabled}
|
||||
onCheckedChange={(v) => sp.setAutoScanEnabled(v)}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Primary control: relative time window. */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="sp-scan-since" className="text-xs">
|
||||
|
||||
@@ -27,10 +27,12 @@ interface ImageCropDialogProps {
|
||||
* encoded smaller — see `encodeImage` in `@/lib/resizeImage`). The
|
||||
* mime/extension on the file reflects the winning format.
|
||||
*/
|
||||
onCrop: (croppedFile: File) => void;
|
||||
onCrop: (croppedFile: File) => void | Promise<void>;
|
||||
/** Called when source decoding/cropping fails before `onCrop` receives a file. */
|
||||
onError?: (error: unknown) => void;
|
||||
}
|
||||
|
||||
export function ImageCropDialog({ open, imageSrc, aspect, title = 'Crop Image', maxOutputSize, onCancel, onCrop }: ImageCropDialogProps) {
|
||||
export function ImageCropDialog({ open, imageSrc, aspect, title = 'Crop Image', maxOutputSize, onCancel, onCrop, onError }: ImageCropDialogProps) {
|
||||
const [crop, setCrop] = useState<Point>({ x: 0, y: 0 });
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [croppedAreaPixels, setCroppedAreaPixels] = useState<Area | null>(null);
|
||||
@@ -60,7 +62,9 @@ export function ImageCropDialog({ open, imageSrc, aspect, title = 'Crop Image',
|
||||
maxOutputSize,
|
||||
filename: 'cropped',
|
||||
});
|
||||
onCrop(file);
|
||||
await onCrop(file);
|
||||
} catch (error) {
|
||||
onError?.(error);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Play, Pause, SkipBack, SkipForward, Maximize2, X, GripVertical } from 'lucide-react';
|
||||
import { useAudioPlayer } from '@/contexts/audioPlayerContextDef';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const POSITION_KEY = 'audio-minibar-position';
|
||||
const DRAG_THRESHOLD = 4;
|
||||
|
||||
function getStoredPosition(): { x: number; y: number } | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(POSITION_KEY);
|
||||
if (raw) return JSON.parse(raw);
|
||||
} catch { /* ignore */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
function getBottomOffset() {
|
||||
// On mobile (below sidebar breakpoint), reserve space for the bottom nav (56px)
|
||||
const hasSidebar = window.matchMedia('(min-width: 900px)').matches;
|
||||
return hasSidebar ? 0 : 56;
|
||||
}
|
||||
|
||||
function clampToViewport(x: number, y: number, w: number, h: number) {
|
||||
const maxX = window.innerWidth - w;
|
||||
const maxY = window.innerHeight - h - getBottomOffset();
|
||||
return {
|
||||
x: Math.max(0, Math.min(x, maxX)),
|
||||
y: Math.max(0, Math.min(y, maxY)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Floating draggable mini-pill audio player.
|
||||
* Uses PointerEvents drag with setPointerCapture, 4px threshold, viewport clamping.
|
||||
* Position persisted to localStorage.
|
||||
*/
|
||||
export function MinimizedAudioBar() {
|
||||
const player = useAudioPlayer();
|
||||
const { currentTrack, minimized, isPlaying, currentTime, duration, playlist, currentIndex } = player;
|
||||
|
||||
const navigate = useNavigate();
|
||||
const barRef = useRef<HTMLDivElement>(null);
|
||||
const [pos, setPos] = useState(() => {
|
||||
const stored = getStoredPosition();
|
||||
const defaultPos = { x: 16, y: window.innerHeight - 80 - getBottomOffset() };
|
||||
if (!stored) return defaultPos;
|
||||
// Clamp stored position in case viewport or bottom offset changed
|
||||
return clampToViewport(stored.x, stored.y, 300, 64);
|
||||
});
|
||||
|
||||
|
||||
// Drag state
|
||||
const dragging = useRef(false);
|
||||
const dragStarted = useRef(false);
|
||||
const startPointer = useRef({ x: 0, y: 0 });
|
||||
const startPos = useRef({ x: 0, y: 0 });
|
||||
|
||||
// Reclamp on resize
|
||||
useEffect(() => {
|
||||
const onResize = () => {
|
||||
setPos((p) => {
|
||||
const el = barRef.current;
|
||||
const w = el?.offsetWidth ?? 300;
|
||||
const h = el?.offsetHeight ?? 64;
|
||||
return clampToViewport(p.x, p.y, w, h);
|
||||
});
|
||||
};
|
||||
window.addEventListener('resize', onResize);
|
||||
return () => window.removeEventListener('resize', onResize);
|
||||
}, []);
|
||||
|
||||
// Persist position
|
||||
useEffect(() => {
|
||||
try { localStorage.setItem(POSITION_KEY, JSON.stringify(pos)); } catch { /* ignore */ }
|
||||
}, [pos]);
|
||||
|
||||
const onPointerDown = useCallback((e: React.PointerEvent) => {
|
||||
// Only drag from the grip handle
|
||||
if (!(e.target as HTMLElement).closest('[data-drag-handle]')) return;
|
||||
e.preventDefault();
|
||||
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||
dragging.current = true;
|
||||
dragStarted.current = false;
|
||||
startPointer.current = { x: e.clientX, y: e.clientY };
|
||||
startPos.current = { ...pos };
|
||||
}, [pos]);
|
||||
|
||||
const onPointerMove = useCallback((e: React.PointerEvent) => {
|
||||
if (!dragging.current) return;
|
||||
const dx = e.clientX - startPointer.current.x;
|
||||
const dy = e.clientY - startPointer.current.y;
|
||||
if (!dragStarted.current && Math.abs(dx) + Math.abs(dy) < DRAG_THRESHOLD) return;
|
||||
dragStarted.current = true;
|
||||
|
||||
const el = barRef.current;
|
||||
const w = el?.offsetWidth ?? 300;
|
||||
const h = el?.offsetHeight ?? 64;
|
||||
setPos(clampToViewport(startPos.current.x + dx, startPos.current.y + dy, w, h));
|
||||
}, []);
|
||||
|
||||
const onPointerUp = useCallback((e: React.PointerEvent) => {
|
||||
if (dragging.current) {
|
||||
(e.target as HTMLElement).releasePointerCapture(e.pointerId);
|
||||
dragging.current = false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (!currentTrack || !minimized) return null;
|
||||
|
||||
const progress = duration > 0 ? (currentTime / duration) * 100 : 0;
|
||||
const hasPlaylist = playlist.length > 1;
|
||||
const canPrev = hasPlaylist && (currentIndex > 0 || currentTime > 3);
|
||||
const canNext = hasPlaylist && currentIndex < playlist.length - 1;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={barRef}
|
||||
className="fixed z-30 select-none touch-none sidebar:block hidden"
|
||||
style={{ left: pos.x, top: pos.y }}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
>
|
||||
<div className="flex items-center gap-2 rounded-2xl bg-background/95 backdrop-blur-md border border-border shadow-lg px-2 py-1.5 min-w-[280px] max-w-[360px]">
|
||||
{/* Drag handle */}
|
||||
<div data-drag-handle className="cursor-grab active:cursor-grabbing shrink-0 p-1 -ml-0.5 text-muted-foreground/50 hover:text-muted-foreground">
|
||||
<GripVertical className="size-4" />
|
||||
</div>
|
||||
|
||||
{/* Artwork thumbnail */}
|
||||
{currentTrack.artwork ? (
|
||||
<img src={currentTrack.artwork} alt="" className="size-10 rounded-lg object-cover shrink-0" />
|
||||
) : (
|
||||
<div className="size-10 rounded-lg bg-primary/15 flex items-center justify-center shrink-0">
|
||||
<Play className="size-4 text-primary" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title + Artist */}
|
||||
<div className="flex-1 min-w-0 px-1">
|
||||
<p className="text-sm font-medium truncate leading-tight">{currentTrack.title}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">{currentTrack.artist}</p>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex items-center gap-0.5 shrink-0">
|
||||
{hasPlaylist && (
|
||||
<button
|
||||
onClick={() => player.prevTrack()}
|
||||
disabled={!canPrev}
|
||||
className="p-1.5 rounded-full hover:bg-secondary transition-colors disabled:opacity-30"
|
||||
aria-label="Previous"
|
||||
>
|
||||
<SkipBack className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => isPlaying ? player.pause() : player.resume()}
|
||||
className="p-1.5 rounded-full bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
aria-label={isPlaying ? 'Pause' : 'Play'}
|
||||
>
|
||||
{isPlaying ? <Pause className="size-3.5" fill="currentColor" /> : <Play className="size-3.5 ml-0.5" fill="currentColor" />}
|
||||
</button>
|
||||
|
||||
{hasPlaylist && (
|
||||
<button
|
||||
onClick={() => player.nextTrack()}
|
||||
disabled={!canNext}
|
||||
className="p-1.5 rounded-full hover:bg-secondary transition-colors disabled:opacity-30"
|
||||
aria-label="Next"
|
||||
>
|
||||
<SkipForward className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
player.expand();
|
||||
if (currentTrack.path) navigate(currentTrack.path);
|
||||
}}
|
||||
className="p-1.5 rounded-full hover:bg-secondary transition-colors"
|
||||
aria-label="Expand"
|
||||
>
|
||||
<Maximize2 className="size-3.5" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => player.stop()}
|
||||
className="p-1.5 rounded-full hover:bg-secondary transition-colors"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress bar at bottom */}
|
||||
<div className="mx-3 h-0.5 rounded-full bg-border overflow-hidden -mt-0.5">
|
||||
<div className={cn('h-full bg-primary transition-[width] duration-200')} style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -101,6 +101,14 @@ const NostrProvider: React.FC<NostrProviderProps> = (props) => {
|
||||
return new Map(DITTO_RELAYS.map(url => [url, filters]));
|
||||
}
|
||||
|
||||
// Route NIP-32 label requests (kind 1985) to the search relays.
|
||||
// Agora's moderation and verification labels live on relay.ditto.pub
|
||||
// / relay.dreamith.to; pinning the read here keeps label coverage
|
||||
// predictable and off the general read pool.
|
||||
if (filters.every((f) => f?.kinds?.length === 1 && f?.kinds[0] === 1985)) {
|
||||
return new Map(DITTO_RELAYS.map(url => [url, filters]));
|
||||
}
|
||||
|
||||
// Include divine relay for kind 34236 queries, which are addressable short videos
|
||||
if (filters.every((f) => f?.kinds?.length === 1 && f?.kinds[0] === 34236)) {
|
||||
return new Map([...DITTO_RELAYS, DIVINE_RELAY].map(url => [url, filters]));
|
||||
|
||||
@@ -44,9 +44,11 @@ import { EmojifiedText } from '@/components/CustomEmoji';
|
||||
import { ReportDialog } from '@/components/ReportDialog';
|
||||
import { CommunityReportDialog } from '@/components/CommunityReportDialog';
|
||||
import { AddToListDialog } from '@/components/AddToListDialog';
|
||||
import { CampaignListMembershipDialog } from '@/components/campaign-lists/CampaignListMembershipDialog';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useBookmarks } from '@/hooks/useBookmarks';
|
||||
import { usePinnedNotes } from '@/hooks/usePinnedNotes';
|
||||
import { useCampaignListActions } from '@/hooks/useCampaignListActions';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useMuteList } from '@/hooks/useMuteList';
|
||||
@@ -211,6 +213,7 @@ export function NoteMoreMenu({ event, open, onOpenChange }: NoteMoreMenuProps) {
|
||||
const [reportOpen, setReportOpen] = useState(false);
|
||||
const [banContentOpen, setBanContentOpen] = useState(false);
|
||||
const [addToListOpen, setAddToListOpen] = useState(false);
|
||||
const [addToCampaignListOpen, setAddToCampaignListOpen] = useState(false);
|
||||
const [eventJsonOpen, setEventJsonOpen] = useState(false);
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||
|
||||
@@ -233,6 +236,16 @@ export function NoteMoreMenu({ event, open, onOpenChange }: NoteMoreMenuProps) {
|
||||
|
||||
const nip19Id = encodeEventNip19(event);
|
||||
|
||||
// Campaign-specific membership-dialog inputs. Only meaningful when
|
||||
// `event.kind === CAMPAIGN_KIND`; the dialog row that uses them is
|
||||
// gated inside the menu content the same way.
|
||||
const isCampaign = event.kind === CAMPAIGN_KIND;
|
||||
const campaignDTag = event.tags.find(([n]) => n === 'd')?.[1] ?? '';
|
||||
const campaignCoord = isCampaign
|
||||
? `${CAMPAIGN_KIND}:${event.pubkey}:${campaignDTag}`
|
||||
: '';
|
||||
const campaignTitle = event.tags.find(([n]) => n === 'title')?.[1] ?? '';
|
||||
|
||||
const handleDelete = () => {
|
||||
const dTag = event.tags.find(([name]) => name === 'd')?.[1];
|
||||
deleteEvent(
|
||||
@@ -269,6 +282,10 @@ export function NoteMoreMenu({ event, open, onOpenChange }: NoteMoreMenuProps) {
|
||||
onOpenChange(false);
|
||||
setTimeout(() => setAddToListOpen(true), 150);
|
||||
}}
|
||||
onAddToCampaignList={() => {
|
||||
onOpenChange(false);
|
||||
setTimeout(() => setAddToCampaignListOpen(true), 150);
|
||||
}}
|
||||
onViewEventJson={() => {
|
||||
onOpenChange(false);
|
||||
setTimeout(() => setEventJsonOpen(true), 150);
|
||||
@@ -307,6 +324,15 @@ export function NoteMoreMenu({ event, open, onOpenChange }: NoteMoreMenuProps) {
|
||||
onOpenChange={setAddToListOpen}
|
||||
/>
|
||||
|
||||
{isCampaign && (
|
||||
<CampaignListMembershipDialog
|
||||
open={addToCampaignListOpen}
|
||||
onOpenChange={setAddToCampaignListOpen}
|
||||
campaignCoord={campaignCoord}
|
||||
campaignTitle={campaignTitle}
|
||||
/>
|
||||
)}
|
||||
|
||||
<EventJsonDialog
|
||||
event={event}
|
||||
nip19Id={nip19Id}
|
||||
@@ -347,11 +373,12 @@ interface NoteMoreMenuContentProps extends NoteMoreMenuProps {
|
||||
onReport: () => void;
|
||||
onBanContent: () => void;
|
||||
onAddToList: () => void;
|
||||
onAddToCampaignList: () => void;
|
||||
onViewEventJson: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
function NoteMoreMenuContent({ event, open, onOpenChange, communityContext, onReport, onBanContent, onAddToList, onViewEventJson, onDelete }: NoteMoreMenuContentProps) {
|
||||
function NoteMoreMenuContent({ event, open, onOpenChange, communityContext, onReport, onBanContent, onAddToList, onAddToCampaignList, onViewEventJson, onDelete }: NoteMoreMenuContentProps) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { user } = useCurrentUser();
|
||||
@@ -365,6 +392,12 @@ function NoteMoreMenuContent({ event, open, onOpenChange, communityContext, onRe
|
||||
// (kind 33863 — addressable, with their own dedicated UI). Hide them there.
|
||||
const isCampaign = event.kind === CAMPAIGN_KIND;
|
||||
|
||||
// Campaign moderators get a dedicated "Add to list" row that toggles
|
||||
// the campaign's membership in the curated topic lists. `isMod` is a
|
||||
// synchronous boolean — no loading state to handle.
|
||||
const campaignListActions = useCampaignListActions();
|
||||
const canManageCampaignLists = isCampaign && campaignListActions.isMod;
|
||||
|
||||
// Country-feed pin/unpin context (organizer/admin action). `useCountryFeed`
|
||||
// returns null outside of a country page; we only enable usePinnedPosts when
|
||||
// the viewer is actually authorized to pin so we avoid extra relay traffic
|
||||
@@ -551,6 +584,13 @@ function NoteMoreMenuContent({ event, open, onOpenChange, communityContext, onRe
|
||||
onClick={() => { onAddToList(); }}
|
||||
/>
|
||||
)}
|
||||
{canManageCampaignLists && (
|
||||
<MenuItem
|
||||
icon={<ListPlus className="size-5" />}
|
||||
label={t('campaigns.lists.membershipTitle')}
|
||||
onClick={() => { onAddToCampaignList(); }}
|
||||
/>
|
||||
)}
|
||||
{!isCampaign && (
|
||||
<MenuItem
|
||||
icon={isInSidebar ? <Trash2 className="size-5" /> : <PanelLeft className="size-5" />}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
BadgeCheck,
|
||||
Bitcoin,
|
||||
Download,
|
||||
Eye,
|
||||
@@ -23,6 +24,15 @@ import {
|
||||
import { generateSecretKey, getPublicKey, nip19 } from 'nostr-tools';
|
||||
|
||||
import { AgoraBoltIcon } from '@/components/icons/AgoraBoltIcon';
|
||||
import {
|
||||
VerifierIdentityStep,
|
||||
type OrgProfileDraft,
|
||||
} from '@/components/onboarding/VerifierIdentityStep';
|
||||
import { VerifierBioStep } from '@/components/onboarding/VerifierBioStep';
|
||||
import { VerifierStatementEditor } from '@/components/organizations/VerifierStatementEditor';
|
||||
import { VerifyTutorial } from '@/components/organizations/VerifyTutorial';
|
||||
import { usePublishOrgProfile } from '@/hooks/usePublishOrgProfile';
|
||||
import { useSetVerifierStatement } from '@/hooks/useVerifierStatement';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
@@ -35,26 +45,64 @@ import { cn } from '@/lib/utils';
|
||||
/**
|
||||
* Step state machine for the captive signup flow.
|
||||
*
|
||||
* Order:
|
||||
* Base order (creator / donor):
|
||||
* keygen → secure → role
|
||||
*
|
||||
* Three screens total. The old flow had a separate "wallet-coupling explainer"
|
||||
* step and a separate "outro" celebration screen; both were folded in. The
|
||||
* coupling explainer was redundant with `secure` (both screens are about the
|
||||
* key), so the secure step now carries the "this key is your account AND
|
||||
* your wallet" framing inline. The outro was a glorified tap-to-continue —
|
||||
* the role step's primary button already navigates somewhere meaningful, so
|
||||
* the role pick *is* the outro.
|
||||
* Picking the *verifier* role doesn't navigate away — it branches into a
|
||||
* captive sub-flow that continues from the role step:
|
||||
* role → orgIdentity → orgBio → orgStatement → orgVerifyHowto
|
||||
*
|
||||
* 1. orgIdentity — banner, avatar, org name, website (kind-0 identity)
|
||||
* 2. orgBio — the organization's bio (kind-0 about)
|
||||
* 3. orgStatement — publish the verifier statement (kind 14672)
|
||||
* 4. orgVerifyHowto— teach the verify gesture, then "View Campaigns"
|
||||
*
|
||||
* The old flow had a separate "wallet-coupling explainer" step and a
|
||||
* separate "outro" celebration screen; both were folded in. The coupling
|
||||
* explainer was redundant with `secure` (both screens are about the key), so
|
||||
* the secure step now carries the "this key is your account AND your wallet"
|
||||
* framing inline. For creator/donor the role pick *is* the outro.
|
||||
*
|
||||
* Login is handled by the existing `AuthDialog` modal — the captive flow is
|
||||
* only ever opened by an explicit `startSignup()` call (e.g. from
|
||||
* AuthDialog's "Create a new Nostr account" button), so the user has
|
||||
* already picked "signup" by the time we mount.
|
||||
*/
|
||||
type Step = 'keygen' | 'secure' | 'role';
|
||||
type Step =
|
||||
| 'keygen'
|
||||
| 'secure'
|
||||
| 'role'
|
||||
| 'orgIdentity'
|
||||
| 'orgBio'
|
||||
| 'orgStatement'
|
||||
| 'orgVerifyHowto';
|
||||
|
||||
/** Base steps that count toward the progress bar for creator/donor. */
|
||||
const SIGNUP_STEPS: Step[] = ['keygen', 'secure', 'role'];
|
||||
|
||||
/**
|
||||
* Steps that count toward the progress bar once the user has chosen the
|
||||
* verifier role. The role step is shared with the base flow, then the four
|
||||
* verifier sub-flow steps extend it.
|
||||
*/
|
||||
const VERIFIER_STEPS: Step[] = [
|
||||
'keygen',
|
||||
'secure',
|
||||
'role',
|
||||
'orgIdentity',
|
||||
'orgBio',
|
||||
'orgStatement',
|
||||
'orgVerifyHowto',
|
||||
];
|
||||
|
||||
/** Ordered verifier sub-flow steps, used for sequential next/back nav. */
|
||||
const VERIFIER_SUBFLOW: Step[] = [
|
||||
'orgIdentity',
|
||||
'orgBio',
|
||||
'orgStatement',
|
||||
'orgVerifyHowto',
|
||||
];
|
||||
|
||||
/**
|
||||
* The captive onboarding gate. Render this as a sibling of `<AppRouter />`;
|
||||
* it renders nothing when inactive and a fullscreen `fixed inset-0 z-50`
|
||||
@@ -105,12 +153,51 @@ function CaptiveOverlay() {
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [showKey, setShowKey] = useState(false);
|
||||
|
||||
// Linear progress bar position. Every step in the machine counts toward
|
||||
// the bar.
|
||||
const currentProgressIndex = SIGNUP_STEPS.indexOf(step);
|
||||
// Verifier sub-flow: the organization's kind-0 profile draft, accumulated
|
||||
// across the identity + bio steps and published once at the end. Held here
|
||||
// so back-navigation between sub-flow steps preserves what's entered.
|
||||
const [orgDraft, setOrgDraft] = useState<OrgProfileDraft>({
|
||||
name: '',
|
||||
website: '',
|
||||
picture: '',
|
||||
banner: '',
|
||||
about: '',
|
||||
});
|
||||
const patchOrgDraft = useCallback(
|
||||
(patch: Partial<OrgProfileDraft>) =>
|
||||
setOrgDraft((prev) => ({ ...prev, ...patch })),
|
||||
[],
|
||||
);
|
||||
|
||||
// Pubkey of the key generated in this captive flow, if any. Used as the
|
||||
// `expectedPubkey` guard when publishing the org profile so a failed
|
||||
// auto-login can't overwrite a different account's kind-0. Empty when the
|
||||
// user was already authenticated on entry (no guard needed then).
|
||||
const signupPubkey = useMemo(() => {
|
||||
if (!nsec) return undefined;
|
||||
try {
|
||||
const decoded = nip19.decode(nsec);
|
||||
if (decoded.type !== 'nsec') return undefined;
|
||||
return getPublicKey(decoded.data);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}, [nsec]);
|
||||
|
||||
const { mutateAsync: publishOrgProfile, isPending: isPublishingOrg } =
|
||||
usePublishOrgProfile();
|
||||
|
||||
// Linear progress bar position. Once the user has chosen the verifier
|
||||
// role, the bar tracks the extended verifier step list so the four
|
||||
// sub-flow screens are reflected; otherwise the base three-step list is
|
||||
// used (creator/donor progress math is unaffected).
|
||||
const isVerifierFlow =
|
||||
contextRole === 'verifier' || VERIFIER_SUBFLOW.includes(step);
|
||||
const progressSteps = isVerifierFlow ? VERIFIER_STEPS : SIGNUP_STEPS;
|
||||
const currentProgressIndex = progressSteps.indexOf(step);
|
||||
const progress = currentProgressIndex < 0
|
||||
? 0
|
||||
: ((currentProgressIndex + 1) / SIGNUP_STEPS.length) * 100;
|
||||
: ((currentProgressIndex + 1) / progressSteps.length) * 100;
|
||||
|
||||
// Navigation helpers ------------------------------------------------------
|
||||
const goTo = useCallback((target: Step) => {
|
||||
@@ -123,21 +210,41 @@ function CaptiveOverlay() {
|
||||
cancel();
|
||||
} else if (step === 'secure') {
|
||||
goTo('keygen');
|
||||
} else if (VERIFIER_SUBFLOW.includes(step)) {
|
||||
// Within the verifier sub-flow: step back one screen, or back to the
|
||||
// role picker from the first sub-flow step.
|
||||
const idx = VERIFIER_SUBFLOW.indexOf(step);
|
||||
goTo(idx <= 0 ? 'role' : VERIFIER_SUBFLOW[idx - 1]);
|
||||
} else {
|
||||
// role step
|
||||
if (user) cancel();
|
||||
else goTo('secure');
|
||||
}
|
||||
}, [step, user, cancel, goTo]);
|
||||
|
||||
// Role pick is the final step. Picking a role both records the choice
|
||||
// (used by the role-pick CTA labels) and navigates to the matching
|
||||
// surface: creator → campaign-creation form, donor → full campaign grid
|
||||
// (`/campaigns`, not `/`, so they land on the browse-everything view
|
||||
// rather than the curated home with its own marketing hero). No separate
|
||||
// outro / celebration screen.
|
||||
// Advance one screen within the verifier sub-flow. The first call (from
|
||||
// the role pick) enters at `orgIdentity`; subsequent calls walk the list.
|
||||
const goNextVerifierStep = useCallback(() => {
|
||||
const idx = VERIFIER_SUBFLOW.indexOf(step);
|
||||
if (idx < 0) {
|
||||
goTo(VERIFIER_SUBFLOW[0]);
|
||||
} else if (idx < VERIFIER_SUBFLOW.length - 1) {
|
||||
goTo(VERIFIER_SUBFLOW[idx + 1]);
|
||||
}
|
||||
}, [step, goTo]);
|
||||
|
||||
// Role pick. For creator/donor this is the final step: it records the
|
||||
// choice and navigates to the matching surface (creator → /campaigns/new,
|
||||
// donor → /campaigns). The verifier role does NOT navigate away — it
|
||||
// records the role and enters the captive verifier sub-flow, which
|
||||
// finishes on its own terms ("View Campaigns").
|
||||
const handleRolePick = useCallback(
|
||||
(next: 'creator' | 'donor') => {
|
||||
(next: 'creator' | 'donor' | 'verifier') => {
|
||||
setContextRole(next);
|
||||
if (next === 'verifier') {
|
||||
goTo('orgIdentity');
|
||||
return;
|
||||
}
|
||||
cancel();
|
||||
if (next === 'creator') {
|
||||
navigate('/campaigns/new');
|
||||
@@ -145,9 +252,33 @@ function CaptiveOverlay() {
|
||||
navigate('/campaigns');
|
||||
}
|
||||
},
|
||||
[setContextRole, cancel, navigate],
|
||||
[setContextRole, cancel, navigate, goTo],
|
||||
);
|
||||
|
||||
// Terminal CTA for the verifier sub-flow — drop the new verifier on the
|
||||
// campaign grid so they can immediately start vouching.
|
||||
const handleVerifierFinish = useCallback(() => {
|
||||
cancel();
|
||||
navigate('/campaigns');
|
||||
}, [cancel, navigate]);
|
||||
|
||||
// Leaving the bio step: publish the assembled kind-0 org profile, then
|
||||
// advance to the statement step. Publishing is best-effort — a failure
|
||||
// surfaces a non-fatal toast and the user still proceeds (they can fix the
|
||||
// profile later from settings), mirroring the InitialSyncGate behavior.
|
||||
const handleBioContinue = useCallback(async () => {
|
||||
try {
|
||||
await publishOrgProfile({ draft: orgDraft, expectedPubkey: signupPubkey });
|
||||
} catch {
|
||||
toast({
|
||||
title: t('onboarding.verifier.publishFailedTitle'),
|
||||
description: t('onboarding.verifier.publishFailedDescription'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
goNextVerifierStep();
|
||||
}, [publishOrgProfile, orgDraft, signupPubkey, toast, t, goNextVerifierStep]);
|
||||
|
||||
// Key generation ----------------------------------------------------------
|
||||
const handleGenerateKey = useCallback(() => {
|
||||
setIsGenerating(true);
|
||||
@@ -214,6 +345,38 @@ function CaptiveOverlay() {
|
||||
onPick={handleRolePick}
|
||||
/>
|
||||
);
|
||||
case 'orgIdentity':
|
||||
// Verifier sub-flow step 1 — organization identity (kind-0).
|
||||
return (
|
||||
<VerifierIdentityStep
|
||||
draft={orgDraft}
|
||||
onChange={patchOrgDraft}
|
||||
onContinue={goNextVerifierStep}
|
||||
/>
|
||||
);
|
||||
case 'orgBio':
|
||||
// Verifier sub-flow step 2 — organization bio (kind-0 about).
|
||||
return (
|
||||
<VerifierBioStep
|
||||
draft={orgDraft}
|
||||
onChange={patchOrgDraft}
|
||||
onContinue={handleBioContinue}
|
||||
isPublishing={isPublishingOrg}
|
||||
/>
|
||||
);
|
||||
case 'orgStatement':
|
||||
// Verifier sub-flow step 3 — publish the verifier statement
|
||||
// (kind 14672), reusing the shared editor.
|
||||
return (
|
||||
<VerifierStatementStep
|
||||
onContinue={goNextVerifierStep}
|
||||
/>
|
||||
);
|
||||
case 'orgVerifyHowto':
|
||||
// Verifier sub-flow step 4 — teach the verify gesture, then finish.
|
||||
return (
|
||||
<VerifierHowtoStep draft={orgDraft} onFinish={handleVerifierFinish} />
|
||||
);
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -258,7 +421,16 @@ function CaptiveOverlay() {
|
||||
<div className="flex-1 flex items-start sm:items-center justify-center px-6 pt-16 pb-12">
|
||||
<div
|
||||
key={step}
|
||||
className="w-full max-w-md mx-auto animate-in fade-in slide-in-from-bottom-2 duration-300"
|
||||
className={cn(
|
||||
'w-full mx-auto animate-in fade-in slide-in-from-bottom-2 duration-300',
|
||||
// Bio, statement & how-to steps host a text surface / markdown
|
||||
// editor / tutorial and want a slightly roomier column than the
|
||||
// narrow base screens — but not the full-width 3xl that left the
|
||||
// text boxes and tutorial feeling oversized.
|
||||
step === 'orgBio' || step === 'orgStatement' || step === 'orgVerifyHowto'
|
||||
? 'max-w-xl'
|
||||
: 'max-w-md',
|
||||
)}
|
||||
>
|
||||
{stepBody}
|
||||
</div>
|
||||
@@ -273,7 +445,7 @@ function CaptiveOverlay() {
|
||||
|
||||
interface RoleStepProps {
|
||||
role: OnboardingRole;
|
||||
onPick: (role: 'creator' | 'donor') => void;
|
||||
onPick: (role: 'creator' | 'donor' | 'verifier') => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -310,12 +482,134 @@ function RoleStep({ role, onPick }: RoleStepProps) {
|
||||
selected={role === 'donor'}
|
||||
onClick={() => onPick('donor')}
|
||||
/>
|
||||
<RoleCard
|
||||
icon={<BadgeCheck className="h-5 w-5 md:h-6 md:w-6 text-primary" />}
|
||||
title={t('onboarding.role.verifier.title')}
|
||||
description={t('onboarding.role.verifier.description')}
|
||||
finderNote={t('onboarding.role.verifier.finderNote')}
|
||||
selected={role === 'verifier'}
|
||||
onClick={() => onPick('verifier')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifier sub-flow step 4 — teach the verify gesture with the shared
|
||||
* {@link VerifyTutorial}, then offer the terminal "View campaigns" CTA.
|
||||
*/
|
||||
function VerifierHowtoStep({
|
||||
draft,
|
||||
onFinish,
|
||||
}: {
|
||||
draft: OrgProfileDraft;
|
||||
onFinish: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [hasSeenLoop, setHasSeenLoop] = useState(false);
|
||||
const handleLoopComplete = useCallback(() => setHasSeenLoop(true), []);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2 text-center">
|
||||
<h2 className="text-2xl font-bold tracking-tight">
|
||||
{t('onboarding.verifier.howto.title')}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('onboarding.verifier.howto.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<VerifyTutorial
|
||||
hideHeader
|
||||
bare
|
||||
stacked
|
||||
verifierName={draft.name}
|
||||
verifierPicture={draft.picture}
|
||||
onLoopComplete={handleLoopComplete}
|
||||
/>
|
||||
|
||||
<Button
|
||||
onClick={onFinish}
|
||||
disabled={!hasSeenLoop}
|
||||
className="w-full h-12 text-base rounded-full"
|
||||
>
|
||||
{t('onboarding.verifier.howto.finish')}
|
||||
<ArrowRight className="ml-2 h-4 w-4 rtl:rotate-180" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface VerifierStatementStepProps {
|
||||
onContinue: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifier sub-flow step 3 — publish the verifier statement (kind 14672).
|
||||
*
|
||||
* One header and one combined subtext sit above a borderless
|
||||
* {@link VerifierStatementEditor}. There's no separate publish button: the
|
||||
* primary button publishes the statement (when there's content) and then
|
||||
* advances. Withdrawing happens later from the profile's "How We Verify" card.
|
||||
*/
|
||||
function VerifierStatementStep({
|
||||
onContinue,
|
||||
}: VerifierStatementStepProps) {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
const { mutateAsync: setStatement, isPending } = useSetVerifierStatement();
|
||||
|
||||
const [value, setValue] = useState('');
|
||||
|
||||
const trimmed = value.trim();
|
||||
|
||||
const handleContinue = useCallback(async () => {
|
||||
try {
|
||||
await setStatement(trimmed);
|
||||
onContinue();
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t('verifier.errorToast'),
|
||||
description: error instanceof Error ? error.message : String(error),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
}, [setStatement, trimmed, toast, t, onContinue]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2 text-center">
|
||||
<h2 className="text-2xl font-bold tracking-tight">
|
||||
{t('onboarding.verifier.statement.title')}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('onboarding.verifier.statement.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<VerifierStatementEditor
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
/>
|
||||
|
||||
<Button
|
||||
onClick={handleContinue}
|
||||
disabled={!trimmed || isPending}
|
||||
className="w-full h-12 text-base rounded-full"
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : null}
|
||||
{t('common.continue')}
|
||||
{!isPending && <ArrowRight className="ml-2 h-4 w-4 rtl:rotate-180" />}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface RoleCardProps {
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
|
||||
@@ -19,6 +19,13 @@ interface PageHeaderProps {
|
||||
children?: React.ReactNode;
|
||||
/** Override the outer wrapper classes. */
|
||||
className?: string;
|
||||
/**
|
||||
* Classes applied to the inner flex row that holds the back button, title,
|
||||
* and children. Use to constrain the header to the same centered column as
|
||||
* the page content (e.g. `max-w-2xl mx-auto w-full`) so the title lines up
|
||||
* with the body instead of floating against the viewport edge.
|
||||
*/
|
||||
contentClassName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -27,27 +34,29 @@ interface PageHeaderProps {
|
||||
* Used by kind-feed pages, bookmarks, help, trends, and other sub-pages
|
||||
* to provide a consistent header layout.
|
||||
*/
|
||||
export function PageHeader({ title, icon, titleContent, backTo = '/', onBack, alwaysShowBack, children, className }: PageHeaderProps) {
|
||||
const backButtonClass = cn('p-2 -ml-2 rounded-full hover:bg-secondary transition-colors', !alwaysShowBack && 'sidebar:hidden');
|
||||
export function PageHeader({ title, icon, titleContent, backTo = '/', onBack, alwaysShowBack, children, className, contentClassName }: PageHeaderProps) {
|
||||
const backButtonClass = cn('p-2 -ml-2 rounded-full hover:bg-secondary transition-colors shrink-0', !alwaysShowBack && 'sidebar:hidden');
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-center gap-4 px-4 py-4 bg-background/85', className)}>
|
||||
{onBack ? (
|
||||
<button onClick={onBack} className={backButtonClass} aria-label="Go back">
|
||||
<ArrowLeft className="size-5" />
|
||||
</button>
|
||||
) : (
|
||||
<Link to={backTo} className={backButtonClass}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
)}
|
||||
{titleContent ?? (
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
{icon}
|
||||
<h1 className="text-xl font-bold truncate">{title}</h1>
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
<div className={cn('py-4 bg-background/85', className)}>
|
||||
<div className={cn('flex items-center gap-1.5 px-4', contentClassName)}>
|
||||
{onBack ? (
|
||||
<button onClick={onBack} className={backButtonClass} aria-label="Go back">
|
||||
<ArrowLeft className="size-5" />
|
||||
</button>
|
||||
) : (
|
||||
<Link to={backTo} className={backButtonClass}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
)}
|
||||
{titleContent ?? (
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
{icon}
|
||||
<h1 className="text-xl font-bold truncate">{title}</h1>
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState } from 'react';
|
||||
import type { NostrMetadata } from '@nostrify/nostrify';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { CheckCircle2, Pencil, Plus, Trash2, ChevronDown, ImagePlus, X as XIcon } from 'lucide-react';
|
||||
import { CheckCircle2, Pencil, Plus, Trash2, ChevronDown, ImagePlus, X as XIcon, Link as LinkIcon } from 'lucide-react';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { BioContent } from '@/components/BioContent';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -33,11 +33,13 @@ function EditableInput({
|
||||
value,
|
||||
placeholder,
|
||||
onChange,
|
||||
maxLength,
|
||||
className,
|
||||
}: {
|
||||
value: string;
|
||||
placeholder: string;
|
||||
onChange: (v: string) => void;
|
||||
maxLength?: number;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
@@ -45,6 +47,7 @@ function EditableInput({
|
||||
type="text"
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
maxLength={maxLength}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className={cn(editableBase, 'w-full min-w-0 py-0.5', className)}
|
||||
/>
|
||||
@@ -86,30 +89,111 @@ interface ProfileField {
|
||||
value: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared dropdown of image actions used by both the avatar and the banner.
|
||||
* Wraps the provided trigger element and surfaces "Upload file", an optional
|
||||
* "Paste URL", and an optional "Remove" (only shown when the image exists and
|
||||
* a remove handler is wired). Deduplicating this between avatar and banner
|
||||
* keeps the two menus identical and the actions in one place.
|
||||
*/
|
||||
function ImageEditMenu({
|
||||
trigger,
|
||||
hasImage,
|
||||
onUpload,
|
||||
onPasteUrl,
|
||||
onRemove,
|
||||
}: {
|
||||
trigger: React.ReactNode;
|
||||
hasImage: boolean;
|
||||
onUpload: () => void;
|
||||
onPasteUrl?: () => void;
|
||||
onRemove?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" sideOffset={6}>
|
||||
<DropdownMenuItem onClick={onUpload}>
|
||||
<ImagePlus className="size-4 mr-2" />
|
||||
Upload file
|
||||
</DropdownMenuItem>
|
||||
{onPasteUrl && (
|
||||
<DropdownMenuItem onClick={onPasteUrl}>
|
||||
<LinkIcon className="size-4 mr-2" />
|
||||
Paste URL
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{hasImage && onRemove && (
|
||||
<DropdownMenuItem onClick={onRemove} className="text-destructive focus:text-destructive">
|
||||
<XIcon className="size-4 mr-2" />
|
||||
Remove
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
interface ProfileCardProps {
|
||||
className?: string;
|
||||
pubkey?: string;
|
||||
metadata: Partial<NostrMetadata>;
|
||||
onChange?: (patch: Partial<NostrMetadata>) => void;
|
||||
onPickImage?: (field: 'picture' | 'banner') => void;
|
||||
/**
|
||||
* Called when the user chooses "Paste URL" for an image field. The handler
|
||||
* is expected to read the clipboard, validate the URL, and apply it. When
|
||||
* provided, the banner gains a dropdown menu (matching the avatar) so the
|
||||
* paste action is reachable for both images.
|
||||
*/
|
||||
onPasteUrl?: (field: 'picture' | 'banner') => void;
|
||||
/** Called when user removes their avatar picture. */
|
||||
onRemoveAvatar?: () => void;
|
||||
/** Called when user removes their banner image. */
|
||||
onRemoveBanner?: () => void;
|
||||
/** Show the banner area (default true). When false, only the avatar shows. */
|
||||
showBanner?: boolean;
|
||||
/** Show the avatar area (default true). */
|
||||
showAvatar?: boolean;
|
||||
/** Placeholder for the editable name input. */
|
||||
namePlaceholder?: string;
|
||||
/** Maximum length for the editable name input. */
|
||||
nameMaxLength?: number;
|
||||
/** Show NIP-05 row (default true) */
|
||||
showNip05?: boolean;
|
||||
/** Show NIP-58 badge showcase row (default true). */
|
||||
showBadges?: boolean;
|
||||
/**
|
||||
* Which kind-0 field the editable text slot below the name edits.
|
||||
* - `'about'` (default): the bio textarea.
|
||||
* - `'website'`: a single-line website input, replacing the bio entirely.
|
||||
* - `'none'`: hide the slot entirely (just name).
|
||||
*/
|
||||
bioField?: 'about' | 'website' | 'none';
|
||||
/** Placeholder for the bio textarea when `bioField` is `'about'`. */
|
||||
aboutPlaceholder?: string;
|
||||
/** When provided, render an editable profile fields section below bio */
|
||||
extraFields?: ProfileField[];
|
||||
onExtraFieldsChange?: (fields: ProfileField[]) => void;
|
||||
}
|
||||
|
||||
export function ProfileCard({
|
||||
className,
|
||||
pubkey,
|
||||
metadata,
|
||||
onChange,
|
||||
onPickImage,
|
||||
onPasteUrl,
|
||||
onRemoveAvatar,
|
||||
onRemoveBanner,
|
||||
showBanner = true,
|
||||
showAvatar = true,
|
||||
namePlaceholder = 'Your name',
|
||||
nameMaxLength,
|
||||
showNip05 = true,
|
||||
showBadges = true,
|
||||
bioField = 'about',
|
||||
aboutPlaceholder = 'Write a short bio…',
|
||||
extraFields,
|
||||
onExtraFieldsChange,
|
||||
}: ProfileCardProps) {
|
||||
@@ -138,48 +222,91 @@ export function ProfileCard({
|
||||
onExtraFieldsChange?.((extraFields ?? []).map((f, idx) => idx === i ? { ...f, [key]: val } : f));
|
||||
|
||||
return (
|
||||
<div className="bg-card border rounded-xl overflow-hidden">
|
||||
<div className={cn('bg-card border rounded-xl overflow-hidden', className)}>
|
||||
|
||||
{/* Banner */}
|
||||
<div
|
||||
className={cn('relative h-36 bg-secondary', editable && 'cursor-pointer group')}
|
||||
style={
|
||||
bannerUrl
|
||||
? { backgroundImage: `url("${bannerUrl}")`, backgroundSize: 'cover', backgroundPosition: 'center' }
|
||||
: undefined
|
||||
}
|
||||
onClick={() => editable && onPickImage?.('banner')}
|
||||
>
|
||||
{!metadata.banner && <div className="absolute inset-0 bg-gradient-to-br from-accent/10 via-transparent to-primary/5" />}
|
||||
{editable && !metadata.banner && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<Plus className="size-6 text-muted-foreground" strokeWidth={4} />
|
||||
</div>
|
||||
)}
|
||||
{editable && (
|
||||
<>
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/30 transition-colors flex items-center justify-center">
|
||||
<span className="opacity-0 group-hover:opacity-100 transition-opacity flex items-center gap-1.5 text-white text-xs font-medium bg-black/50 rounded-full px-3 py-1.5 backdrop-blur-sm">
|
||||
<Pencil className="size-3.5" /> {metadata.banner ? 'Change banner' : 'Add banner'}
|
||||
</span>
|
||||
</div>
|
||||
{metadata.banner && (
|
||||
<div className="absolute bottom-2 right-2 size-7 rounded-full bg-background border border-border shadow-sm flex items-center justify-center transition-opacity">
|
||||
<Pencil className="size-3.5 text-muted-foreground" />
|
||||
{showBanner && (editable && (onPasteUrl || onRemoveBanner) ? (
|
||||
// When a paste or remove action exists, the banner opens the shared
|
||||
// image menu instead of going straight to the file picker.
|
||||
<ImageEditMenu
|
||||
hasImage={!!metadata.banner}
|
||||
onUpload={() => onPickImage?.('banner')}
|
||||
onPasteUrl={onPasteUrl ? () => onPasteUrl('banner') : undefined}
|
||||
onRemove={onRemoveBanner}
|
||||
trigger={
|
||||
<button
|
||||
type="button"
|
||||
className="relative block w-full h-36 bg-secondary cursor-pointer group outline-none"
|
||||
style={
|
||||
bannerUrl
|
||||
? { backgroundImage: `url("${bannerUrl}")`, backgroundSize: 'cover', backgroundPosition: 'center' }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{!metadata.banner && <div className="absolute inset-0 bg-gradient-to-br from-accent/10 via-transparent to-primary/5" />}
|
||||
{!metadata.banner && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<Plus className="size-6 text-muted-foreground" strokeWidth={4} />
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/30 transition-colors flex items-center justify-center">
|
||||
<span className="opacity-0 group-hover:opacity-100 transition-opacity flex items-center gap-1.5 text-white text-xs font-medium bg-black/50 rounded-full px-3 py-1.5 backdrop-blur-sm">
|
||||
<Pencil className="size-3.5" /> {metadata.banner ? 'Change banner' : 'Add banner'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{metadata.banner && (
|
||||
<div className="absolute bottom-2 right-2 size-7 rounded-full bg-background border border-border shadow-sm flex items-center justify-center transition-opacity">
|
||||
<Pencil className="size-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={cn('relative h-36 bg-secondary', editable && 'cursor-pointer group')}
|
||||
style={
|
||||
bannerUrl
|
||||
? { backgroundImage: `url("${bannerUrl}")`, backgroundSize: 'cover', backgroundPosition: 'center' }
|
||||
: undefined
|
||||
}
|
||||
onClick={() => editable && onPickImage?.('banner')}
|
||||
>
|
||||
{!metadata.banner && <div className="absolute inset-0 bg-gradient-to-br from-accent/10 via-transparent to-primary/5" />}
|
||||
{editable && !metadata.banner && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<Plus className="size-6 text-muted-foreground" strokeWidth={4} />
|
||||
</div>
|
||||
)}
|
||||
{editable && (
|
||||
<>
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/30 transition-colors flex items-center justify-center">
|
||||
<span className="opacity-0 group-hover:opacity-100 transition-opacity flex items-center gap-1.5 text-white text-xs font-medium bg-black/50 rounded-full px-3 py-1.5 backdrop-blur-sm">
|
||||
<Pencil className="size-3.5" /> {metadata.banner ? 'Change banner' : 'Add banner'}
|
||||
</span>
|
||||
</div>
|
||||
{metadata.banner && (
|
||||
<div className="absolute bottom-2 right-2 size-7 rounded-full bg-background border border-border shadow-sm flex items-center justify-center transition-opacity">
|
||||
<Pencil className="size-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Profile info */}
|
||||
<div className="px-4 pb-4">
|
||||
<div className={cn('px-4 pb-4', !showAvatar && (showBanner ? 'pt-3' : 'pt-4'))}>
|
||||
|
||||
{/* Avatar */}
|
||||
<div className="flex justify-between items-start -mt-12 mb-3">
|
||||
{showAvatar && <div className={cn('flex justify-between items-start mb-3', showBanner ? '-mt-12' : 'mt-3')}>
|
||||
{editable ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<ImageEditMenu
|
||||
hasImage={!!metadata.picture}
|
||||
onUpload={() => onPickImage?.('picture')}
|
||||
onPasteUrl={onPasteUrl ? () => onPasteUrl('picture') : undefined}
|
||||
onRemove={onRemoveAvatar}
|
||||
trigger={
|
||||
<button type="button" className="relative shrink-0 cursor-pointer group outline-none">
|
||||
<Avatar className="shadow-sm size-24 border-4 border-background">
|
||||
<AvatarImage src={metadata.picture} alt={displayName} className="object-cover" />
|
||||
@@ -196,20 +323,8 @@ export function ProfileCard({
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" sideOffset={6}>
|
||||
<DropdownMenuItem onClick={() => onPickImage?.('picture')}>
|
||||
<ImagePlus className="size-4 mr-2" />
|
||||
Change avatar
|
||||
</DropdownMenuItem>
|
||||
{metadata.picture && (
|
||||
<DropdownMenuItem onClick={() => onRemoveAvatar?.()} className="text-destructive focus:text-destructive">
|
||||
<XIcon className="size-4 mr-2" />
|
||||
Remove avatar
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="relative shrink-0">
|
||||
<Avatar className="shadow-sm size-24 border-4 border-background">
|
||||
@@ -220,13 +335,14 @@ export function ProfileCard({
|
||||
</Avatar>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
{/* Name */}
|
||||
{editable ? (
|
||||
<EditableInput
|
||||
value={metadata.name ?? ''}
|
||||
placeholder="Your name"
|
||||
placeholder={namePlaceholder}
|
||||
maxLength={nameMaxLength}
|
||||
onChange={patch('name')}
|
||||
className="text-xl font-bold"
|
||||
/>
|
||||
@@ -268,12 +384,27 @@ export function ProfileCard({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bio */}
|
||||
{/* Bio — or, when `bioField` is `'website'`, a website input that
|
||||
takes the bio's place entirely; `'none'` hides the slot. */}
|
||||
{bioField !== 'none' && (
|
||||
<div className="mt-2">
|
||||
{editable ? (
|
||||
{bioField === 'website' ? (
|
||||
editable ? (
|
||||
<EditableInput
|
||||
value={(metadata.website as string) ?? ''}
|
||||
placeholder="https://your-website.com"
|
||||
onChange={patch('website')}
|
||||
className="text-sm"
|
||||
/>
|
||||
) : metadata.website ? (
|
||||
<p className="text-sm text-muted-foreground leading-relaxed truncate">
|
||||
{metadata.website}
|
||||
</p>
|
||||
) : null
|
||||
) : editable ? (
|
||||
<EditableTextarea
|
||||
value={metadata.about ?? ''}
|
||||
placeholder="Write a short bio…"
|
||||
placeholder={aboutPlaceholder}
|
||||
onChange={patch('about')}
|
||||
/>
|
||||
) : metadata.about ? (
|
||||
@@ -282,6 +413,7 @@ export function ProfileCard({
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Extra profile fields — collapsible, only when prop provided */}
|
||||
{extraFields !== undefined && (
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Loader2, CheckCircle2, PauseCircle } from 'lucide-react';
|
||||
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { useHdWalletSp } from '@/hooks/useHdWalletSpContext';
|
||||
|
||||
interface SilentPaymentScanStatusProps {
|
||||
/** Opens the scan options / advanced dialog. */
|
||||
onOpenScanDialog: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact, always-visible status line for the silent-payment background
|
||||
* scanner, rendered on the Private wallet tab.
|
||||
*
|
||||
* Scanning runs automatically in the `HdWalletSpProvider` regardless of which
|
||||
* page the user is on, so this surface is purely a *reflection* of that shared
|
||||
* state plus an escape hatch into the full scan dialog (manual ranges,
|
||||
* deep rescans, reconcile). It never starts a scan itself.
|
||||
*/
|
||||
export function SilentPaymentScanStatus({ onOpenScanDialog }: SilentPaymentScanStatusProps) {
|
||||
const { t } = useTranslation();
|
||||
const sp = useHdWalletSp();
|
||||
|
||||
if (!sp.enabled) return null;
|
||||
|
||||
const scanHeight = sp.storage?.scanHeight ?? 0;
|
||||
|
||||
const scanOptionsButton = (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenScanDialog}
|
||||
className="text-muted-foreground hover:text-foreground underline underline-offset-4 motion-safe:transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-sm cursor-pointer"
|
||||
>
|
||||
{t('spAutoScan.manualLink')}
|
||||
</button>
|
||||
);
|
||||
|
||||
// While scanning, surface a proper progress bar (matching the scan dialog)
|
||||
// instead of a lone spinner, so completeness is visible at a glance.
|
||||
if (sp.isScanning && sp.scanProgress) {
|
||||
const { currentHeight, fromHeight, toHeight } = sp.scanProgress;
|
||||
const progressPercent = Math.min(
|
||||
100,
|
||||
Math.round(
|
||||
((currentHeight - fromHeight + 1) /
|
||||
Math.max(1, toHeight - fromHeight + 1)) *
|
||||
100,
|
||||
),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Progress value={progressPercent} className="h-2" />
|
||||
<div className="flex items-center justify-between gap-2 text-xs">
|
||||
<span className="flex items-center gap-1.5 text-muted-foreground">
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
{t('spAutoScan.scanning', {
|
||||
current: currentHeight.toLocaleString(),
|
||||
to: toHeight.toLocaleString(),
|
||||
})}
|
||||
</span>
|
||||
{scanOptionsButton}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
let content: React.ReactNode;
|
||||
if (!sp.autoScanEnabled) {
|
||||
content = (
|
||||
<span className="flex items-center gap-1.5 text-muted-foreground">
|
||||
<PauseCircle className="size-3" />
|
||||
{t('spAutoScan.paused')}
|
||||
</span>
|
||||
);
|
||||
} else if (scanHeight > 0 && sp.tipHeight !== undefined && scanHeight >= sp.tipHeight) {
|
||||
content = (
|
||||
<span className="flex items-center gap-1.5 text-muted-foreground">
|
||||
<CheckCircle2 className="size-3 text-green-500" />
|
||||
{t('spAutoScan.caughtUp')}
|
||||
</span>
|
||||
);
|
||||
} else if (scanHeight > 0) {
|
||||
content = (
|
||||
<span className="text-muted-foreground">
|
||||
{t('spAutoScan.lastScanned', { height: scanHeight.toLocaleString() })}
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
content = (
|
||||
<span className="text-muted-foreground">{t('spAutoScan.neverScanned')}</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
{content}
|
||||
<span aria-hidden className="text-muted-foreground/40">
|
||||
·
|
||||
</span>
|
||||
{scanOptionsButton}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Activity,
|
||||
Bell,
|
||||
Download,
|
||||
HandHeart,
|
||||
Info,
|
||||
LayoutDashboard,
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
|
||||
import { LoginArea } from '@/components/auth/LoginArea';
|
||||
import { LogoIcon } from '@/components/icons/LogoIcon';
|
||||
@@ -25,6 +27,7 @@ import { useFeedSettings } from '@/hooks/useFeedSettings';
|
||||
import { useHdBtcPrice } from '@/hooks/useHdBtcPrice';
|
||||
import { useHdWallet } from '@/hooks/useHdWallet';
|
||||
import { satsToUSD } from '@/lib/bitcoin';
|
||||
import { ZAPSTORE_URL } from '@/lib/zapstore';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface NavItem {
|
||||
@@ -67,7 +70,7 @@ export function TopNav() {
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-40 w-full border-b border-border bg-background/85 backdrop-blur supports-[backdrop-filter]:bg-background/70">
|
||||
<header className="safe-area-top sticky top-0 z-40 w-full border-b border-border bg-background/85 backdrop-blur supports-[backdrop-filter]:bg-background/70">
|
||||
<div className="mx-auto flex h-16 max-w-7xl items-center gap-1 md:gap-4 px-4 sm:px-6">
|
||||
{/* Mobile menu trigger */}
|
||||
<button
|
||||
@@ -95,7 +98,7 @@ export function TopNav() {
|
||||
>
|
||||
<LogoIcon className="size-9" />
|
||||
<span
|
||||
className="font-display font-normal tracking-wide leading-none uppercase text-3xl inline-block -ml-0.5"
|
||||
className="latin-display font-display font-normal tracking-wide leading-none uppercase text-3xl inline-block -ml-0.5"
|
||||
style={{
|
||||
WebkitTextStroke: '0.022em currentColor',
|
||||
transform: 'skewX(-6deg) scaleX(1.1)',
|
||||
@@ -148,7 +151,7 @@ export function TopNav() {
|
||||
>
|
||||
<LogoIcon className="size-9" />
|
||||
<span
|
||||
className="font-display font-normal tracking-wide leading-none uppercase text-3xl inline-block -ml-0.5"
|
||||
className="latin-display font-display font-normal tracking-wide leading-none uppercase text-3xl inline-block -ml-0.5"
|
||||
style={{
|
||||
WebkitTextStroke: '0.022em currentColor',
|
||||
transform: 'skewX(-6deg) scaleX(1.1)',
|
||||
@@ -175,6 +178,18 @@ export function TopNav() {
|
||||
})}
|
||||
onClose={() => setMobileOpen(false)}
|
||||
/>
|
||||
{!Capacitor.isNativePlatform() && (
|
||||
<a
|
||||
href={ZAPSTORE_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-secondary motion-safe:transition-colors"
|
||||
>
|
||||
<Download className="size-4 shrink-0" />
|
||||
{t('nav.getApp')}
|
||||
</a>
|
||||
)}
|
||||
</nav>
|
||||
<div className="border-t border-border p-4 space-y-3">
|
||||
<MobileFooterLinks onClose={() => setMobileOpen(false)} />
|
||||
@@ -218,7 +233,7 @@ function WalletBalancePill() {
|
||||
title={t('nav.wallet')}
|
||||
>
|
||||
<span
|
||||
className="font-display font-normal tracking-wide leading-none uppercase text-xl inline-block tabular-nums"
|
||||
className="latin-display font-display font-normal tracking-wide leading-none uppercase text-xl inline-block tabular-nums"
|
||||
style={{
|
||||
WebkitTextStroke: '0.022em currentColor',
|
||||
transform: 'skewX(-6deg) scaleX(1.1)',
|
||||
@@ -282,7 +297,15 @@ function getProfileMenuItems({
|
||||
userPubkey?: string;
|
||||
showDashboard: boolean;
|
||||
}): MobileLinkItem[] {
|
||||
if (!userPubkey) return [];
|
||||
// Logged-out users still get Settings (appearance, language, network, etc.)
|
||||
// and About in the menu — the account-specific items are added below.
|
||||
if (!userPubkey) {
|
||||
return [
|
||||
{ labelKey: 'nav.search', to: '/search', icon: Search },
|
||||
{ labelKey: 'nav.settings', to: '/settings', icon: Settings },
|
||||
{ labelKey: 'nav.about', to: '/about', icon: Info },
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
...(showDashboard ? [{ labelKey: 'nav.dashboard', to: '/dashboard', icon: Activity }] : []),
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Loader2, ShieldAlert } from 'lucide-react';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { useTor } from '@/hooks/useTor';
|
||||
import { retryTor } from '@/lib/tor';
|
||||
|
||||
/**
|
||||
* Slim, non-blocking, app-wide banner shown while Tor is enabled but not yet
|
||||
* connected (Android only).
|
||||
*
|
||||
* Routing is fail-closed, so external content can't load — and can't leak —
|
||||
* until Tor connects. This tells the user that wherever they are in the app
|
||||
* (so switching away from Settings still surfaces the state). It replaces the
|
||||
* old full-screen gate.
|
||||
*/
|
||||
export function TorStatusBanner() {
|
||||
const { config } = useAppContext();
|
||||
const { supported, status, bootstrapPercent, error } = useTor();
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!supported || !config.torEnabled || status === 'connected') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const failed = status === 'failed';
|
||||
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className={`fixed inset-x-0 bottom-0 z-50 safe-area-bottom border-t px-4 py-2 ${
|
||||
failed
|
||||
? 'border-destructive bg-destructive text-destructive-foreground'
|
||||
: 'border-amber-600 bg-amber-500 text-black'
|
||||
}`}
|
||||
>
|
||||
<div className="mx-auto flex max-w-3xl items-center gap-3 text-xs">
|
||||
{failed ? (
|
||||
<ShieldAlert className="size-4 shrink-0" />
|
||||
) : (
|
||||
<Loader2 className="size-4 shrink-0 animate-spin" />
|
||||
)}
|
||||
<span className="flex-1 leading-snug">
|
||||
{failed
|
||||
? error || t('tor.banner.failed')
|
||||
: t('tor.banner.connecting') +
|
||||
(bootstrapPercent > 0 ? ` (${bootstrapPercent}%)` : '')}
|
||||
</span>
|
||||
{failed && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => retryTor()}
|
||||
className="shrink-0 font-semibold underline underline-offset-2"
|
||||
>
|
||||
{t('tor.banner.retry')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TorStatusBanner;
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { BadgeCheck, Loader2 } from 'lucide-react';
|
||||
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { getDisplayName } from '@/lib/genUserName';
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
|
||||
interface VerificationDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** Title of the campaign being verified, shown for context. */
|
||||
campaignTitle: string;
|
||||
/** Whether the verify publish is in flight. */
|
||||
isPending: boolean;
|
||||
/** Confirm handler — publishes the `agora.verified` label. */
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirmation dialog shown before a moderator publishes an
|
||||
* `agora.verified` label. Previews the moderator's own avatar with the same
|
||||
* checkmark badge used on verified campaign cards, and states the
|
||||
* attestation the moderator is making.
|
||||
*/
|
||||
export function VerificationDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
campaignTitle,
|
||||
isPending,
|
||||
onConfirm,
|
||||
}: VerificationDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useCurrentUser();
|
||||
const author = useAuthor(user?.pubkey ?? '');
|
||||
const metadata = author.data?.metadata;
|
||||
const picture = sanitizeUrl(metadata?.picture);
|
||||
const displayName = user ? getDisplayName(metadata, user.pubkey) : '';
|
||||
const initials = displayName.slice(0, 2).toUpperCase();
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('campaignVerification.dialogTitle')}</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
{t('campaignVerification.dialogTitle')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Preview: the moderator's avatar with the checkmark badge,
|
||||
mirroring how verifications render on campaign cards. */}
|
||||
<div className="flex flex-col items-center gap-2 py-2">
|
||||
<div className="relative">
|
||||
<Avatar className="size-20 ring-2 ring-border">
|
||||
{picture && <AvatarImage src={picture} alt="" proxyWidth={160} />}
|
||||
<AvatarFallback className="text-lg bg-secondary text-secondary-foreground">
|
||||
{initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="absolute -bottom-1 -right-1 inline-flex items-center justify-center rounded-full bg-background p-0.5">
|
||||
<BadgeCheck className="size-7 text-sky-500" fill="currentColor" stroke="white" />
|
||||
</span>
|
||||
</div>
|
||||
{campaignTitle && (
|
||||
<p className="text-sm font-medium text-center text-foreground line-clamp-2">
|
||||
{campaignTitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
{t('campaignVerification.attestation')}
|
||||
</p>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isPending}>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button onClick={onConfirm} disabled={isPending}>
|
||||
{isPending && <Loader2 className="mr-2 size-4 animate-spin" />}
|
||||
<BadgeCheck className="mr-1.5 size-4" />
|
||||
{t('campaignVerification.verifyCampaign')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -63,6 +63,8 @@ export interface WizardProps {
|
||||
launchNowLabel?: string;
|
||||
onSubmit: (e: FormEvent) => void;
|
||||
onClose: () => void;
|
||||
/** Optional back action for step 1 when there is a meaningful previous flow. */
|
||||
onBackFromFirstStep?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -103,6 +105,7 @@ export function Wizard({
|
||||
launchNowLabel,
|
||||
onSubmit,
|
||||
onClose,
|
||||
onBackFromFirstStep,
|
||||
}: WizardProps) {
|
||||
const { t } = useTranslation();
|
||||
const [step, setStep] = useState(1);
|
||||
@@ -124,6 +127,7 @@ export function Wizard({
|
||||
const canSubmit = isTerminal
|
||||
? !submitting && !isAdvancing
|
||||
: launchVisible && canAdvance && !submitting && !isAdvancing;
|
||||
const backVisible = step > 1 || !!onBackFromFirstStep;
|
||||
|
||||
const handleAdvance = async () => {
|
||||
if (submitting || isAdvancing || !canAdvance) return;
|
||||
@@ -169,12 +173,18 @@ export function Wizard({
|
||||
</button>
|
||||
|
||||
{/* Top-left back. Mirrors the close button so the user can step
|
||||
back through the wizard without scrolling to the footer. Only
|
||||
rendered from step 2 onward — step 1's escape route is the X. */}
|
||||
{step > 1 && (
|
||||
back through the wizard without scrolling to the footer. Step 1
|
||||
only renders it when the host provides an external back target. */}
|
||||
{backVisible && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStep((s) => Math.max(s - 1, 1))}
|
||||
onClick={() => {
|
||||
if (step === 1) {
|
||||
onBackFromFirstStep?.();
|
||||
} else {
|
||||
setStep((s) => Math.max(s - 1, 1));
|
||||
}
|
||||
}}
|
||||
disabled={submitting || isAdvancing}
|
||||
aria-label={t('common.back')}
|
||||
className="absolute left-4 top-4 z-20 sm:left-6 sm:top-6 inline-flex h-9 w-9 items-center justify-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground transition-colors disabled:opacity-50"
|
||||
|
||||
@@ -7,8 +7,10 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Activity, Bell, ChevronDown, CircleHelp, LayoutDashboard, LogOut, Search, Settings, User, UserIcon, UserPlus, Wallet } from 'lucide-react';
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
import { Activity, Bell, ChevronDown, CircleHelp, Download, LayoutDashboard, LogOut, Search, Settings, User, UserIcon, UserPlus, Wallet } from 'lucide-react';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { ZAPSTORE_URL } from '@/lib/zapstore';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -144,6 +146,14 @@ export function AccountSwitcher({ onAddAccountClick }: AccountSwitcherProps) {
|
||||
<span>{t('nav.about')}</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
{!Capacitor.isNativePlatform() && (
|
||||
<DropdownMenuItem asChild className='flex items-center gap-2 cursor-pointer p-2 rounded-md'>
|
||||
<a href={ZAPSTORE_URL} target="_blank" rel="noopener noreferrer">
|
||||
<Download className='w-4 h-4' />
|
||||
<span>{t('nav.getApp')}</span>
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={onAddAccountClick}
|
||||
|
||||
@@ -15,6 +15,7 @@ import { ListFormDialog } from './ListFormDialog';
|
||||
import { useCampaignLists } from '@/hooks/useCampaignLists';
|
||||
import { useCampaignListActions } from '@/hooks/useCampaignListActions';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
import { CAMPAIGN_CATEGORY_LABEL_KEYS_BY_SLUG } from '@/lib/campaignCategories';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface CampaignListMembershipDialogProps {
|
||||
@@ -183,6 +184,8 @@ export function CampaignListMembershipDialog({
|
||||
{lists.map((list) => {
|
||||
const member = isMember(list.slug, list.coords);
|
||||
const pending = pendingSlug === list.slug;
|
||||
const labelKey = CAMPAIGN_CATEGORY_LABEL_KEYS_BY_SLUG.get(list.slug);
|
||||
const title = labelKey ? t(labelKey) : list.title;
|
||||
return (
|
||||
<li
|
||||
key={list.aTag}
|
||||
@@ -193,7 +196,7 @@ export function CampaignListMembershipDialog({
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium truncate">
|
||||
{list.title}
|
||||
{title}
|
||||
</div>
|
||||
{list.description && (
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
|
||||
@@ -4,6 +4,8 @@ import { Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
ArrowUpToLine,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
MoreVertical,
|
||||
Pencil,
|
||||
Plus,
|
||||
@@ -34,11 +36,15 @@ import { useCampaignLists } from '@/hooks/useCampaignLists';
|
||||
import { useCampaignListActions } from '@/hooks/useCampaignListActions';
|
||||
import { useIsMobile } from '@/hooks/useIsMobile';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
import { CAMPAIGN_CATEGORY_LABEL_KEYS_BY_SLUG } from '@/lib/campaignCategories';
|
||||
import type { ParsedCampaignList } from '@/lib/campaignLists';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const DRAG_MIME = 'text/x-agora-campaign-list-coord';
|
||||
|
||||
/** How many pills to show before collapsing the rest behind a "Show more". */
|
||||
const COLLAPSED_COUNT = 5;
|
||||
|
||||
/**
|
||||
* Horizontal scrollable strip of moderator-curated campaign list pills.
|
||||
*
|
||||
@@ -69,6 +75,7 @@ export function CampaignListsStrip() {
|
||||
const [editTarget, setEditTarget] = useState<ParsedCampaignList | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<ParsedCampaignList | null>(null);
|
||||
const [optimisticOrder, setOptimisticOrder] = useState<readonly string[] | null>(null);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const lists = useMemo(() => data?.lists ?? [], [data]);
|
||||
const authoritativeCoords = useMemo(() => lists.map((l) => l.aTag), [lists]);
|
||||
@@ -180,6 +187,35 @@ export function CampaignListsStrip() {
|
||||
return null;
|
||||
}
|
||||
|
||||
const visible = displayed.slice(0, COLLAPSED_COUNT);
|
||||
const overflow = displayed.slice(COLLAPSED_COUNT);
|
||||
const canExpand = overflow.length > 0;
|
||||
|
||||
const renderPill = (list: ParsedCampaignList, idx: number) => (
|
||||
<ListPill
|
||||
key={list.aTag}
|
||||
list={list}
|
||||
index={idx}
|
||||
isMod={actions.isMod}
|
||||
isMobile={isMobile}
|
||||
onDropAt={(coord) => moveTo(coord, idx)}
|
||||
onEdit={() => setEditTarget(list)}
|
||||
onDelete={() => setDeleteTarget(list)}
|
||||
onMoveUp={() => moveTo(list.aTag, Math.max(0, idx - 1))}
|
||||
onMoveDown={() => moveTo(list.aTag, idx + 1)}
|
||||
onMoveToStart={() => moveTo(list.aTag, 0)}
|
||||
canMoveUp={idx > 0}
|
||||
canMoveDown={idx < displayed.length - 1}
|
||||
/>
|
||||
);
|
||||
|
||||
const deleteTargetLabelKey = deleteTarget
|
||||
? CAMPAIGN_CATEGORY_LABEL_KEYS_BY_SLUG.get(deleteTarget.slug)
|
||||
: undefined;
|
||||
const deleteTargetTitle = deleteTarget
|
||||
? deleteTargetLabelKey ? t(deleteTargetLabelKey) : deleteTarget.title
|
||||
: '';
|
||||
|
||||
return (
|
||||
<>
|
||||
<section
|
||||
@@ -187,23 +223,35 @@ export function CampaignListsStrip() {
|
||||
aria-label={t('campaigns.lists.stripAria')}
|
||||
>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{displayed.map((list, idx) => (
|
||||
<ListPill
|
||||
key={list.aTag}
|
||||
list={list}
|
||||
index={idx}
|
||||
isMod={actions.isMod}
|
||||
isMobile={isMobile}
|
||||
onDropAt={(coord) => moveTo(coord, idx)}
|
||||
onEdit={() => setEditTarget(list)}
|
||||
onDelete={() => setDeleteTarget(list)}
|
||||
onMoveUp={() => moveTo(list.aTag, Math.max(0, idx - 1))}
|
||||
onMoveDown={() => moveTo(list.aTag, idx + 1)}
|
||||
onMoveToStart={() => moveTo(list.aTag, 0)}
|
||||
canMoveUp={idx > 0}
|
||||
canMoveDown={idx < displayed.length - 1}
|
||||
/>
|
||||
))}
|
||||
{visible.map((list, i) => renderPill(list, i))}
|
||||
{expanded &&
|
||||
overflow.map((list, i) => renderPill(list, i + COLLAPSED_COUNT))}
|
||||
{canExpand && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
aria-expanded={expanded}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-full border px-3.5 py-2 text-sm whitespace-nowrap shrink-0',
|
||||
'border-border bg-background hover:border-primary/40 hover:bg-primary/5 text-muted-foreground hover:text-foreground',
|
||||
'motion-safe:transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||
)}
|
||||
>
|
||||
{expanded ? (
|
||||
<>
|
||||
<ChevronUp className="size-4 shrink-0" aria-hidden />
|
||||
<span>{t('campaigns.lists.showLess')}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronDown className="size-4 shrink-0" aria-hidden />
|
||||
<span>
|
||||
{t('campaigns.lists.showMore', { count: overflow.length })}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{actions.isMod && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -253,7 +301,7 @@ export function CampaignListsStrip() {
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t('campaigns.lists.deleteConfirmDesc', {
|
||||
title: deleteTarget?.title ?? '',
|
||||
title: deleteTargetTitle,
|
||||
})}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
@@ -306,12 +354,14 @@ function ListPill({
|
||||
}: ListPillProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isOver, setIsOver] = useState(false);
|
||||
const labelKey = CAMPAIGN_CATEGORY_LABEL_KEYS_BY_SLUG.get(list.slug);
|
||||
const title = labelKey ? t(labelKey) : list.title;
|
||||
|
||||
// Visible label + icon — same shape for mods and non-mods.
|
||||
const content: ReactNode = (
|
||||
<>
|
||||
<LucideIcon name={list.icon} className="size-4 shrink-0 text-primary" />
|
||||
<span className="whitespace-nowrap">{list.title}</span>
|
||||
<span className="whitespace-nowrap">{title}</span>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -394,7 +444,7 @@ function ListPill({
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('campaigns.lists.menuAria', { title: list.title })}
|
||||
aria-label={t('campaigns.lists.menuAria', { title })}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="inline-flex items-center px-2 rounded-r-full bg-background border border-l-0 border-border text-muted-foreground hover:text-foreground hover:bg-primary/5 motion-safe:transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
|
||||
@@ -26,7 +26,7 @@ const DONOR_ROW_IDS: { id: string; Icon: LucideIcon }[] = [
|
||||
{ id: 'settlement', Icon: Gauge },
|
||||
];
|
||||
|
||||
const ACTIVIST_ROW_IDS: { id: string; Icon: LucideIcon }[] = [
|
||||
const RECIPIENT_ROW_IDS: { id: string; Icon: LucideIcon }[] = [
|
||||
{ id: 'whatDonorsSee', Icon: Sparkles },
|
||||
{ id: 'receivingSpeed', Icon: Gauge },
|
||||
{ id: 'pushNotifications', Icon: Bell },
|
||||
@@ -44,7 +44,7 @@ const ACTIVIST_ROW_IDS: { id: string; Icon: LucideIcon }[] = [
|
||||
* - Mobile: collapses to two stacked tinted cards (one per option) with
|
||||
* the same row labels inside each card. No sideways scrolling.
|
||||
*
|
||||
* Row content is driven by the `audience` flag so donors and activists
|
||||
* Row content is driven by the `audience` flag so donors and recipients
|
||||
* get row copy tuned to what they care about. All strings are read from
|
||||
* i18n keyed by audience-specific row IDs in `helpContent.ts`'s
|
||||
* structural template.
|
||||
@@ -55,8 +55,8 @@ export function PaymentComparisonTable({
|
||||
block: GuidePaymentComparisonBlock;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const rowIds = block.audience === 'donor' ? DONOR_ROW_IDS : ACTIVIST_ROW_IDS;
|
||||
const audienceKey = block.audience === 'donor' ? 'donorRows' : 'activistRows';
|
||||
const rowIds = block.audience === 'donor' ? DONOR_ROW_IDS : RECIPIENT_ROW_IDS;
|
||||
const audienceKey = block.audience === 'donor' ? 'donorRows' : 'recipientRows';
|
||||
|
||||
const rows: Row[] = rowIds.map(({ id, Icon }) => ({
|
||||
label: t(`guides.shared.paymentComparison.${audienceKey}.${id}.label`),
|
||||
@@ -68,7 +68,7 @@ export function PaymentComparisonTable({
|
||||
const headerText = t(
|
||||
block.audience === 'donor'
|
||||
? 'guides.shared.paymentComparison.donorHeader'
|
||||
: 'guides.shared.paymentComparison.activistHeader',
|
||||
: 'guides.shared.paymentComparison.recipientHeader',
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Block primitives for the Donor Guide and Activist Guide pages. Each
|
||||
* Block primitives for the Donor Guide and Recipient Guide pages. Each
|
||||
* component takes the matching {@link GuideBlock} variant and renders it.
|
||||
* The page just dispatches on `block.kind`.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
|
||||
interface LinkDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
selectedText?: string;
|
||||
onSubmit: (text: string, url: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert-link dialog for the Milkdown editor. When the user has text
|
||||
* selected, we only ask for a URL; otherwise we ask for both link text
|
||||
* and URL. Bare hostnames are upgraded to `https://`.
|
||||
*/
|
||||
export function LinkDialog({ open, onOpenChange, selectedText, onSubmit }: LinkDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [text, setText] = useState('');
|
||||
const [url, setUrl] = useState('');
|
||||
|
||||
// Reset form when the dialog opens.
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setText(selectedText || '');
|
||||
setUrl('');
|
||||
}
|
||||
}, [open, selectedText]);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!url.trim()) return;
|
||||
|
||||
const finalText = text.trim() || url.trim();
|
||||
let finalUrl = url.trim();
|
||||
|
||||
// Add https:// if no protocol specified.
|
||||
if (!/^https?:\/\//i.test(finalUrl)) {
|
||||
finalUrl = 'https://' + finalUrl;
|
||||
}
|
||||
|
||||
onSubmit(finalText, finalUrl);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const hasSelectedText = !!selectedText;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('mdEditor.link.title')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="space-y-4 py-4">
|
||||
{!hasSelectedText ? (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="link-text">{t('mdEditor.link.textLabel')}</Label>
|
||||
<Input
|
||||
id="link-text"
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
placeholder={t('mdEditor.link.textPlaceholder')}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">{t('mdEditor.link.textLabel')}</Label>
|
||||
<p className="text-sm bg-muted px-3 py-2 rounded-md break-words">{selectedText}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="link-url">{t('mdEditor.link.urlLabel')}</Label>
|
||||
<Input
|
||||
id="link-url"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder="https://example.com"
|
||||
type="url"
|
||||
autoFocus={hasSelectedText}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
{t('mdEditor.link.cancel')}
|
||||
</Button>
|
||||
<Button type="submit" disabled={!url.trim()}>
|
||||
{t('mdEditor.link.insert')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
defaultValueCtx,
|
||||
Editor,
|
||||
editorViewCtx,
|
||||
rootCtx,
|
||||
} from '@milkdown/core';
|
||||
import { Milkdown, MilkdownProvider, useEditor } from '@milkdown/react';
|
||||
import {
|
||||
commonmark,
|
||||
insertHrCommand,
|
||||
toggleEmphasisCommand,
|
||||
toggleInlineCodeCommand,
|
||||
toggleStrongCommand,
|
||||
turnIntoTextCommand,
|
||||
wrapInBlockquoteCommand,
|
||||
wrapInBulletListCommand,
|
||||
wrapInHeadingCommand,
|
||||
wrapInOrderedListCommand,
|
||||
} from '@milkdown/preset-commonmark';
|
||||
import { gfm, toggleStrikethroughCommand } from '@milkdown/preset-gfm';
|
||||
import { history } from '@milkdown/plugin-history';
|
||||
import { clipboard } from '@milkdown/plugin-clipboard';
|
||||
import { listener, listenerCtx } from '@milkdown/plugin-listener';
|
||||
import { upload, uploadConfig } from '@milkdown/plugin-upload';
|
||||
import { Decoration } from '@milkdown/prose/view';
|
||||
import { callCommand, replaceAll } from '@milkdown/utils';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { LinkDialog } from './LinkDialog';
|
||||
import { MilkdownToolbar } from './MilkdownToolbar';
|
||||
|
||||
interface MilkdownEditorInnerProps {
|
||||
value: string;
|
||||
onChange: (markdown: string) => void;
|
||||
onUploadImage?: (file: File) => Promise<string | null>;
|
||||
onImageButtonClick?: () => void;
|
||||
placeholder?: string;
|
||||
showToolbar?: boolean;
|
||||
}
|
||||
|
||||
function MilkdownEditorInner({
|
||||
value,
|
||||
onChange,
|
||||
onUploadImage,
|
||||
onImageButtonClick,
|
||||
placeholder,
|
||||
showToolbar = true,
|
||||
}: MilkdownEditorInnerProps) {
|
||||
const initialValueRef = useRef(value);
|
||||
const editorRef = useRef<Editor | null>(null);
|
||||
const lastExternalValue = useRef(value);
|
||||
const onUploadImageRef = useRef(onUploadImage);
|
||||
|
||||
// Link dialog state
|
||||
const [linkDialogOpen, setLinkDialogOpen] = useState(false);
|
||||
const [selectedTextForLink, setSelectedTextForLink] = useState<string>('');
|
||||
const selectionRef = useRef<{ from: number; to: number } | null>(null);
|
||||
|
||||
// Keep the upload handler ref current without re-initializing the editor.
|
||||
useEffect(() => {
|
||||
onUploadImageRef.current = onUploadImage;
|
||||
}, [onUploadImage]);
|
||||
|
||||
const { get } = useEditor((root) => {
|
||||
const editor = Editor.make()
|
||||
.config((ctx) => {
|
||||
ctx.set(rootCtx, root);
|
||||
ctx.set(defaultValueCtx, initialValueRef.current);
|
||||
ctx.get(listenerCtx).markdownUpdated((_, markdown) => {
|
||||
lastExternalValue.current = markdown;
|
||||
onChange(markdown);
|
||||
});
|
||||
|
||||
// Configure the upload plugin (only meaningful when an upload
|
||||
// handler is provided; otherwise images fall back to base64).
|
||||
ctx.set(uploadConfig.key, {
|
||||
uploader: async (files, schema) => {
|
||||
const images: File[] = [];
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files.item(i);
|
||||
if (!file) continue;
|
||||
if (!file.type.includes('image')) continue;
|
||||
images.push(file);
|
||||
}
|
||||
|
||||
const nodes: ReturnType<typeof schema.nodes.image.createAndFill>[] = [];
|
||||
|
||||
for (const image of images) {
|
||||
try {
|
||||
if (onUploadImageRef.current) {
|
||||
const url = await onUploadImageRef.current(image);
|
||||
if (url) {
|
||||
const node = schema.nodes.image.createAndFill({
|
||||
src: url,
|
||||
alt: image.name,
|
||||
});
|
||||
if (node) nodes.push(node);
|
||||
}
|
||||
} else {
|
||||
const reader = new FileReader();
|
||||
const dataUrl = await new Promise<string>((resolve) => {
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.readAsDataURL(image);
|
||||
});
|
||||
const node = schema.nodes.image.createAndFill({
|
||||
src: dataUrl,
|
||||
alt: image.name,
|
||||
});
|
||||
if (node) nodes.push(node);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to upload image:', error);
|
||||
}
|
||||
}
|
||||
|
||||
return nodes.filter(
|
||||
(node): node is NonNullable<typeof node> => node !== null,
|
||||
);
|
||||
},
|
||||
enableHtmlFileUploader: true,
|
||||
uploadWidgetFactory: (pos, spec) => {
|
||||
const widgetEl = document.createElement('span');
|
||||
widgetEl.className = 'milkdown-upload-placeholder';
|
||||
return Decoration.widget(pos, widgetEl, spec);
|
||||
},
|
||||
});
|
||||
})
|
||||
.use(commonmark)
|
||||
.use(gfm)
|
||||
.use(history)
|
||||
.use(clipboard)
|
||||
.use(listener)
|
||||
.use(upload);
|
||||
|
||||
return editor;
|
||||
});
|
||||
|
||||
// Store the editor reference.
|
||||
useEffect(() => {
|
||||
editorRef.current = get() ?? null;
|
||||
}, [get]);
|
||||
|
||||
// Handle external value changes (e.g. resetting / loading a value).
|
||||
useEffect(() => {
|
||||
const editor = get();
|
||||
if (editor && value !== lastExternalValue.current) {
|
||||
editor.action(replaceAll(value));
|
||||
lastExternalValue.current = value;
|
||||
}
|
||||
}, [value, get]);
|
||||
|
||||
// Placeholder support via a CSS custom property on the ProseMirror DOM.
|
||||
useEffect(() => {
|
||||
const editor = get();
|
||||
if (editor && placeholder) {
|
||||
try {
|
||||
const view = editor.ctx.get(editorViewCtx);
|
||||
view.dom.style.setProperty('--ph', `"${placeholder.replace(/"/g, '\\"')}"`);
|
||||
} catch {
|
||||
// Editor not ready yet.
|
||||
}
|
||||
}
|
||||
}, [get, placeholder]);
|
||||
|
||||
// Toggle a `has-content` class on the ProseMirror DOM so the CSS
|
||||
// placeholder (`:not(.has-content)`) only shows while genuinely empty.
|
||||
useEffect(() => {
|
||||
const editor = get();
|
||||
if (!editor) return;
|
||||
try {
|
||||
const view = editor.ctx.get(editorViewCtx);
|
||||
view.dom.classList.toggle('has-content', value.trim().length > 0);
|
||||
} catch {
|
||||
// Editor not ready yet.
|
||||
}
|
||||
}, [get, value]);
|
||||
|
||||
const handleLinkButtonClick = useCallback(() => {
|
||||
const editor = get();
|
||||
if (!editor) return;
|
||||
|
||||
try {
|
||||
const view = editor.ctx.get(editorViewCtx);
|
||||
const { state } = view;
|
||||
const { from, to } = state.selection;
|
||||
const selectedText = state.doc.textBetween(from, to);
|
||||
|
||||
selectionRef.current = { from, to };
|
||||
setSelectedTextForLink(selectedText);
|
||||
setLinkDialogOpen(true);
|
||||
} catch (error) {
|
||||
console.error('Failed to get selection:', error);
|
||||
}
|
||||
}, [get]);
|
||||
|
||||
const handleLinkSubmit = useCallback(
|
||||
(text: string, url: string) => {
|
||||
const editor = get();
|
||||
if (!editor) return;
|
||||
|
||||
try {
|
||||
const view = editor.ctx.get(editorViewCtx);
|
||||
const { state, dispatch } = view;
|
||||
const { schema } = state;
|
||||
|
||||
const linkMark = schema.marks.link.create({ href: url });
|
||||
const linkNode = schema.text(text, [linkMark]);
|
||||
const tr = state.tr;
|
||||
|
||||
if (selectionRef.current) {
|
||||
const { from, to } = selectionRef.current;
|
||||
tr.replaceWith(from, to, linkNode);
|
||||
} else {
|
||||
const { from } = state.selection;
|
||||
tr.insert(from, linkNode);
|
||||
}
|
||||
|
||||
dispatch(tr);
|
||||
view.focus();
|
||||
} catch (error) {
|
||||
console.error('Failed to insert link:', error);
|
||||
}
|
||||
},
|
||||
[get],
|
||||
);
|
||||
|
||||
const handleCommand = useCallback(
|
||||
(command: string) => {
|
||||
const editor = get();
|
||||
if (!editor) return;
|
||||
|
||||
try {
|
||||
const view = editor.ctx.get(editorViewCtx);
|
||||
|
||||
switch (command) {
|
||||
case 'toggleBold':
|
||||
editor.action(callCommand(toggleStrongCommand.key));
|
||||
break;
|
||||
case 'toggleItalic':
|
||||
editor.action(callCommand(toggleEmphasisCommand.key));
|
||||
break;
|
||||
case 'toggleStrikethrough':
|
||||
editor.action(callCommand(toggleStrikethroughCommand.key));
|
||||
break;
|
||||
case 'toggleInlineCode':
|
||||
editor.action(callCommand(toggleInlineCodeCommand.key));
|
||||
break;
|
||||
case 'heading1':
|
||||
editor.action(callCommand(wrapInHeadingCommand.key, 1));
|
||||
break;
|
||||
case 'heading2':
|
||||
editor.action(callCommand(wrapInHeadingCommand.key, 2));
|
||||
break;
|
||||
case 'heading3':
|
||||
editor.action(callCommand(wrapInHeadingCommand.key, 3));
|
||||
break;
|
||||
case 'bulletList':
|
||||
editor.action(callCommand(wrapInBulletListCommand.key));
|
||||
break;
|
||||
case 'orderedList':
|
||||
editor.action(callCommand(wrapInOrderedListCommand.key));
|
||||
break;
|
||||
case 'blockquote':
|
||||
editor.action(callCommand(wrapInBlockquoteCommand.key));
|
||||
break;
|
||||
case 'link':
|
||||
handleLinkButtonClick();
|
||||
return; // Dialog handles refocus.
|
||||
case 'hr':
|
||||
editor.action(callCommand(insertHrCommand.key));
|
||||
break;
|
||||
case 'paragraph':
|
||||
editor.action(callCommand(turnIntoTextCommand.key));
|
||||
break;
|
||||
}
|
||||
|
||||
view.focus();
|
||||
} catch (error) {
|
||||
console.error('Command failed:', error);
|
||||
}
|
||||
},
|
||||
[get, handleLinkButtonClick],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{showToolbar && (
|
||||
<MilkdownToolbar
|
||||
onCommand={handleCommand}
|
||||
onImageUpload={onImageButtonClick}
|
||||
/>
|
||||
)}
|
||||
<div className="milkdown-content">
|
||||
<Milkdown />
|
||||
</div>
|
||||
<LinkDialog
|
||||
open={linkDialogOpen}
|
||||
onOpenChange={setLinkDialogOpen}
|
||||
selectedText={selectedTextForLink}
|
||||
onSubmit={handleLinkSubmit}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface MilkdownEditorProps {
|
||||
/** Current markdown value. */
|
||||
value: string;
|
||||
/** Called with the new markdown whenever the document changes. */
|
||||
onChange: (markdown: string) => void;
|
||||
/** Optional handler that uploads an image file and returns its URL. */
|
||||
onUploadImage?: (file: File) => Promise<string | null>;
|
||||
/** Optional handler for the toolbar image button (omit to hide it). */
|
||||
onImageButtonClick?: () => void;
|
||||
/** Placeholder shown while the editor is empty and unfocused. */
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
/** Show the formatting toolbar (default true). */
|
||||
showToolbar?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable WYSIWYG Markdown editor built on Milkdown (ProseMirror).
|
||||
*
|
||||
* Edits render as formatted rich text while the value flows back out as
|
||||
* CommonMark + GFM markdown via `onChange`. Pair with `PolicyMarkdown` /
|
||||
* `react-markdown` for read-only rendering elsewhere.
|
||||
*
|
||||
* Styling lives under `.milkdown-editor` in `src/index.css`.
|
||||
*/
|
||||
export function MilkdownEditor({
|
||||
value,
|
||||
onChange,
|
||||
onUploadImage,
|
||||
onImageButtonClick,
|
||||
placeholder,
|
||||
className,
|
||||
showToolbar = true,
|
||||
}: MilkdownEditorProps) {
|
||||
return (
|
||||
<div className={cn('milkdown-editor', className)}>
|
||||
<MilkdownProvider>
|
||||
<MilkdownEditorInner
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onUploadImage={onUploadImage}
|
||||
onImageButtonClick={onImageButtonClick}
|
||||
placeholder={placeholder}
|
||||
showToolbar={showToolbar}
|
||||
/>
|
||||
</MilkdownProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MilkdownEditor;
|
||||
@@ -0,0 +1,204 @@
|
||||
import {
|
||||
Bold,
|
||||
Code,
|
||||
Heading1,
|
||||
Heading2,
|
||||
Heading3,
|
||||
HelpCircle,
|
||||
Image,
|
||||
Italic,
|
||||
Link,
|
||||
List,
|
||||
ListOrdered,
|
||||
Minus,
|
||||
Quote,
|
||||
Strikethrough,
|
||||
} from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function MarkdownHelpPopover() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-muted-foreground hover:text-foreground"
|
||||
aria-label={t('mdEditor.help.title')}
|
||||
>
|
||||
<HelpCircle className="h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-72" align="end">
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium text-sm">{t('mdEditor.help.title')}</h4>
|
||||
<div className="text-xs space-y-1.5 font-mono text-muted-foreground">
|
||||
<div className="flex justify-between"><span>**bold**</span><span className="font-sans font-bold">{t('mdEditor.help.bold')}</span></div>
|
||||
<div className="flex justify-between"><span>*italic*</span><span className="font-sans italic">{t('mdEditor.help.italic')}</span></div>
|
||||
<div className="flex justify-between"><span># Heading 1</span><span className="font-sans">H1</span></div>
|
||||
<div className="flex justify-between"><span>## Heading 2</span><span className="font-sans">H2</span></div>
|
||||
<div className="flex justify-between"><span>- list item</span><span className="font-sans">{t('mdEditor.help.bulletList')}</span></div>
|
||||
<div className="flex justify-between"><span>1. numbered</span><span className="font-sans">{t('mdEditor.help.numberedList')}</span></div>
|
||||
<div className="flex justify-between"><span>[text](url)</span><span className="font-sans text-primary">{t('mdEditor.help.link')}</span></div>
|
||||
<div className="flex justify-between"><span>> quote</span><span className="font-sans border-l-2 pl-1">{t('mdEditor.help.quote')}</span></div>
|
||||
<div className="flex justify-between"><span>`code`</span><span className="font-sans bg-muted px-1 rounded">{t('mdEditor.help.code')}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
interface ToolbarButtonProps {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
shortcut?: string;
|
||||
onClick: () => void;
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
function ToolbarButton({ icon, label, shortcut, onClick, active }: ToolbarButtonProps) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onClick}
|
||||
aria-label={label}
|
||||
className={cn(
|
||||
'h-8 w-8 text-muted-foreground hover:text-foreground',
|
||||
active && 'bg-muted text-foreground',
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<span>{label}</span>
|
||||
{shortcut && <span className="ml-2 text-muted-foreground text-xs">{shortcut}</span>}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
interface MilkdownToolbarProps {
|
||||
onCommand: (command: string) => void;
|
||||
onImageUpload?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function MilkdownToolbar({ onCommand, onImageUpload, className }: MilkdownToolbarProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-0.5 p-1.5 border-b border-border bg-muted/40 flex-wrap',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{/* Text formatting */}
|
||||
<ToolbarButton
|
||||
icon={<Bold className="h-4 w-4" />}
|
||||
label={t('mdEditor.toolbar.bold')}
|
||||
shortcut="Ctrl+B"
|
||||
onClick={() => onCommand('toggleBold')}
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={<Italic className="h-4 w-4" />}
|
||||
label={t('mdEditor.toolbar.italic')}
|
||||
shortcut="Ctrl+I"
|
||||
onClick={() => onCommand('toggleItalic')}
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={<Strikethrough className="h-4 w-4" />}
|
||||
label={t('mdEditor.toolbar.strikethrough')}
|
||||
onClick={() => onCommand('toggleStrikethrough')}
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={<Code className="h-4 w-4" />}
|
||||
label={t('mdEditor.toolbar.inlineCode')}
|
||||
onClick={() => onCommand('toggleInlineCode')}
|
||||
/>
|
||||
|
||||
<Separator orientation="vertical" className="mx-1 h-6" />
|
||||
|
||||
{/* Headings */}
|
||||
<ToolbarButton
|
||||
icon={<Heading1 className="h-4 w-4" />}
|
||||
label={t('mdEditor.toolbar.heading1')}
|
||||
onClick={() => onCommand('heading1')}
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={<Heading2 className="h-4 w-4" />}
|
||||
label={t('mdEditor.toolbar.heading2')}
|
||||
onClick={() => onCommand('heading2')}
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={<Heading3 className="h-4 w-4" />}
|
||||
label={t('mdEditor.toolbar.heading3')}
|
||||
onClick={() => onCommand('heading3')}
|
||||
/>
|
||||
|
||||
<Separator orientation="vertical" className="mx-1 h-6" />
|
||||
|
||||
{/* Lists */}
|
||||
<ToolbarButton
|
||||
icon={<List className="h-4 w-4" />}
|
||||
label={t('mdEditor.toolbar.bulletList')}
|
||||
onClick={() => onCommand('bulletList')}
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={<ListOrdered className="h-4 w-4" />}
|
||||
label={t('mdEditor.toolbar.numberedList')}
|
||||
onClick={() => onCommand('orderedList')}
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={<Quote className="h-4 w-4" />}
|
||||
label={t('mdEditor.toolbar.blockquote')}
|
||||
onClick={() => onCommand('blockquote')}
|
||||
/>
|
||||
|
||||
<Separator orientation="vertical" className="mx-1 h-6" />
|
||||
|
||||
{/* Links and media */}
|
||||
<ToolbarButton
|
||||
icon={<Link className="h-4 w-4" />}
|
||||
label={t('mdEditor.toolbar.insertLink')}
|
||||
onClick={() => onCommand('link')}
|
||||
/>
|
||||
{onImageUpload && (
|
||||
<ToolbarButton
|
||||
icon={<Image className="h-4 w-4" />}
|
||||
label={t('mdEditor.toolbar.insertImage')}
|
||||
onClick={onImageUpload}
|
||||
/>
|
||||
)}
|
||||
<ToolbarButton
|
||||
icon={<Minus className="h-4 w-4" />}
|
||||
label={t('mdEditor.toolbar.horizontalRule')}
|
||||
onClick={() => onCommand('hr')}
|
||||
/>
|
||||
|
||||
<Separator orientation="vertical" className="mx-1 h-6" />
|
||||
|
||||
<MarkdownHelpPopover />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Check, EyeOff, Eye, ListPlus, MoreHorizontal,
|
||||
BadgeCheck, Check, EyeOff, Eye, ListPlus, MoreHorizontal,
|
||||
Sparkles, SparklesIcon,
|
||||
} from 'lucide-react';
|
||||
|
||||
@@ -15,10 +16,12 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { CampaignListMembershipDialog } from '@/components/campaign-lists/CampaignListMembershipDialog';
|
||||
import { VerificationDialog } from '@/components/VerificationDialog';
|
||||
import { useCampaignModerators } from '@/hooks/useCampaignModerators';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { useCampaignModeration } from '@/hooks/useCampaignModeration';
|
||||
import { useCampaignVerifications } from '@/hooks/useCampaignVerifications';
|
||||
import { useOrganizationModeration } from '@/hooks/useOrganizationModeration';
|
||||
import { usePledgeModeration } from '@/hooks/usePledgeModeration';
|
||||
import type { ModerationLabel } from '@/lib/agoraModeration';
|
||||
@@ -97,6 +100,7 @@ function ModerationItemsShell({
|
||||
moderate,
|
||||
getFeatureRank,
|
||||
onAddToList,
|
||||
leadingExtra,
|
||||
}: {
|
||||
coord: string;
|
||||
entityTitle: string;
|
||||
@@ -123,6 +127,14 @@ function ModerationItemsShell({
|
||||
* per-campaign membership modal in {@link CampaignItemsInner}.
|
||||
*/
|
||||
onAddToList?: () => void;
|
||||
/**
|
||||
* Optional extra rows rendered as the first moderator action(s), under
|
||||
* the "Moderator actions" label and above "Add to list…" / Hide. The
|
||||
* campaign surface passes its verify row here so verification reads as
|
||||
* a moderator action inside the same section, not a separate top-level
|
||||
* item.
|
||||
*/
|
||||
leadingExtra?: ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
@@ -164,6 +176,13 @@ function ModerationItemsShell({
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{leadingExtra && (
|
||||
<>
|
||||
{leadingExtra}
|
||||
{(onAddToList || hasHide || hasFeatured) && <DropdownMenuSeparator />}
|
||||
</>
|
||||
)}
|
||||
|
||||
{onAddToList && (
|
||||
<>
|
||||
<DropdownMenuItem onClick={() => onAddToList()}>
|
||||
@@ -232,13 +251,43 @@ function CampaignItemsInner(props: {
|
||||
* state instead — see {@link ModerationMenu}.
|
||||
*/
|
||||
onAddToList?: () => void;
|
||||
/**
|
||||
* Called when the user clicks "Verify this campaign". The host
|
||||
* owns the confirmation dialog (same reason as `onAddToList`). When
|
||||
* absent, the verify row is omitted — only hosts that render the
|
||||
* dialog ({@link ModerationMenu}) opt in.
|
||||
*/
|
||||
onRequestVerify?: () => void;
|
||||
}) {
|
||||
const { data, moderate } = useCampaignModeration();
|
||||
const { user } = useCurrentUser();
|
||||
const { data: moderators } = useCampaignModerators();
|
||||
const isMod = !!user && !!moderators && moderators.includes(user.pubkey);
|
||||
// The campaign surface stopped exposing the `featured` axis when
|
||||
// the curated Lists feature replaced campaign-level featuring, so
|
||||
// the rank computation is dead weight here. We still pass through
|
||||
// the shared shell because the shell drives the "Add to list…" row
|
||||
// plus Hide / Unhide.
|
||||
//
|
||||
// Verification is available to a broader set than the moderator pack:
|
||||
// moderators AND self-declared verifiers (kind 14672). The verify row
|
||||
// ({@link CampaignVerifyItem}) gates itself on `canVerify`, so for a
|
||||
// verifier who isn't a moderator we render ONLY the verify row — the
|
||||
// moderator-only shell (Hide / Add to list…) is skipped.
|
||||
const verifyRow = props.onRequestVerify ? (
|
||||
<CampaignVerifyItem
|
||||
coord={props.coord}
|
||||
entityTitle={props.entityTitle}
|
||||
onRequestVerify={props.onRequestVerify}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
if (!isMod) {
|
||||
// Non-moderators see nothing but the verify row (itself gated on
|
||||
// `canVerify`). No "Moderator actions" label, no Hide / Add to list.
|
||||
return <>{verifyRow}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ModerationItemsShell
|
||||
coord={props.coord}
|
||||
@@ -247,10 +296,73 @@ function CampaignItemsInner(props: {
|
||||
moderation={data}
|
||||
moderate={moderate}
|
||||
onAddToList={props.onAddToList}
|
||||
leadingExtra={verifyRow ?? undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify / remove-verification row for the campaign moderation menu.
|
||||
* Gated by {@link useCampaignVerifications}'s `canVerify` — renders for
|
||||
* moderators AND self-declared verifiers (kind 14672), `null` otherwise.
|
||||
*
|
||||
* Verifying opens a confirmation dialog (owned by {@link ModerationMenu}),
|
||||
* so this row only signals intent via `onRequestVerify`. Removing a
|
||||
* verification publishes a kind 5 deletion inline — no confirmation needed.
|
||||
*/
|
||||
function CampaignVerifyItem({
|
||||
coord,
|
||||
entityTitle,
|
||||
onRequestVerify,
|
||||
}: {
|
||||
coord: string;
|
||||
entityTitle: string;
|
||||
/** Open the verification confirmation dialog (hoisted to the menu host). */
|
||||
onRequestVerify: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
const { user } = useCurrentUser();
|
||||
const { data, canVerify, verify, unverify } = useCampaignVerifications();
|
||||
|
||||
if (!canVerify) return null;
|
||||
|
||||
const mine = user
|
||||
? (data.byCoord.get(coord) ?? []).find((v) => v.pubkey === user.pubkey)
|
||||
: undefined;
|
||||
const busy = verify.isPending || unverify.isPending;
|
||||
|
||||
const onUnverify = async () => {
|
||||
if (!mine) return;
|
||||
try {
|
||||
await unverify.mutateAsync({ verification: mine });
|
||||
toast({ title: t('campaignVerification.unverified'), description: entityTitle });
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t('campaignVerification.actionFailed'),
|
||||
description: error instanceof Error ? error.message : undefined,
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{mine ? (
|
||||
<DropdownMenuItem onClick={onUnverify} disabled={busy}>
|
||||
<BadgeCheck className="h-4 w-4 mr-2" />
|
||||
{t('campaignVerification.removeVerification')}
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem onClick={onRequestVerify} disabled={busy}>
|
||||
<BadgeCheck className="h-4 w-4 mr-2" />
|
||||
{t('campaignVerification.verifyCampaign')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PledgeItemsInner(props: {
|
||||
coord: string;
|
||||
entityTitle: string;
|
||||
@@ -303,8 +415,34 @@ function GroupItemsInner(props: {
|
||||
* to pass the callback if they want the row inline.
|
||||
*/
|
||||
export function ModerationMenuItems(
|
||||
props: ModerationItemsProps & { onAddToList?: () => void },
|
||||
props: ModerationItemsProps & { onAddToList?: () => void; onRequestVerify?: () => void },
|
||||
) {
|
||||
// The campaign surface has its own visibility rule (moderators OR
|
||||
// verifiers), computed inside its branch so non-campaign surfaces never
|
||||
// subscribe to the verification query. CampaignItemsInner returns the
|
||||
// verify row for verifiers and the full shell for moderators, and `null`
|
||||
// for everyone else.
|
||||
if (props.surface === 'campaign') {
|
||||
return (
|
||||
<CampaignItemsInner
|
||||
coord={props.coord}
|
||||
entityTitle={props.entityTitle}
|
||||
axes={props.axes}
|
||||
onAddToList={props.onAddToList}
|
||||
onRequestVerify={props.onRequestVerify}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <NonCampaignModerationItems {...props} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pledge / group moderator rows. These surfaces are strictly
|
||||
* moderator-gated (no verifier path), so we keep the early `!isMod`
|
||||
* bail-out that skips the moderation query for non-moderators.
|
||||
*/
|
||||
function NonCampaignModerationItems(props: ModerationItemsProps) {
|
||||
const { user } = useCurrentUser();
|
||||
const { data: moderators } = useCampaignModerators();
|
||||
const isMod = !!user && !!moderators && moderators.includes(user.pubkey);
|
||||
@@ -312,15 +450,6 @@ export function ModerationMenuItems(
|
||||
if (!isMod) return null;
|
||||
|
||||
switch (props.surface) {
|
||||
case 'campaign':
|
||||
return (
|
||||
<CampaignItemsInner
|
||||
coord={props.coord}
|
||||
entityTitle={props.entityTitle}
|
||||
axes={props.axes}
|
||||
onAddToList={props.onAddToList}
|
||||
/>
|
||||
);
|
||||
case 'pledge':
|
||||
return (
|
||||
<PledgeItemsInner
|
||||
@@ -337,6 +466,9 @@ export function ModerationMenuItems(
|
||||
axes={props.axes}
|
||||
/>
|
||||
);
|
||||
case 'campaign':
|
||||
// Handled by ModerationMenuItems before reaching here.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -363,12 +495,32 @@ export function ModerationMenuItems(
|
||||
*/
|
||||
export function ModerationMenu({ className, ...rest }: ModerationMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
const { user } = useCurrentUser();
|
||||
const { data: moderators } = useCampaignModerators();
|
||||
const isMod = !!user && !!moderators && moderators.includes(user.pubkey);
|
||||
const [membershipOpen, setMembershipOpen] = useState(false);
|
||||
const [verifyOpen, setVerifyOpen] = useState(false);
|
||||
const { canVerify, verify } = useCampaignVerifications();
|
||||
|
||||
if (!isMod) return null;
|
||||
// Campaign kebab is visible to moderators AND verifiers (verifiers see
|
||||
// only the verify row inside it). Other surfaces stay moderator-only.
|
||||
const visible = rest.surface === 'campaign' ? isMod || canVerify : isMod;
|
||||
if (!visible) return null;
|
||||
|
||||
const onConfirmVerify = async () => {
|
||||
try {
|
||||
await verify.mutateAsync({ coord: rest.coord });
|
||||
toast({ title: t('campaignVerification.verified'), description: rest.entityTitle });
|
||||
setVerifyOpen(false);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t('campaignVerification.actionFailed'),
|
||||
description: error instanceof Error ? error.message : undefined,
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -391,16 +543,37 @@ export function ModerationMenu({ className, ...rest }: ModerationMenuProps) {
|
||||
? () => setMembershipOpen(true)
|
||||
: undefined
|
||||
}
|
||||
onRequestVerify={
|
||||
rest.surface === 'campaign'
|
||||
? () => setVerifyOpen(true)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{rest.surface === 'campaign' && (
|
||||
<CampaignListMembershipDialog
|
||||
open={membershipOpen}
|
||||
onOpenChange={setMembershipOpen}
|
||||
campaignCoord={rest.coord}
|
||||
campaignTitle={rest.entityTitle}
|
||||
/>
|
||||
// The campaign card wraps everything in a <Link>. Radix dialogs portal
|
||||
// their DOM out of the card, but React synthetic events still bubble
|
||||
// through the component tree to the Link — so clicks inside the dialog
|
||||
// would navigate to the campaign page. Stop propagation here.
|
||||
<span
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<CampaignListMembershipDialog
|
||||
open={membershipOpen}
|
||||
onOpenChange={setMembershipOpen}
|
||||
campaignCoord={rest.coord}
|
||||
campaignTitle={rest.entityTitle}
|
||||
/>
|
||||
<VerificationDialog
|
||||
open={verifyOpen}
|
||||
onOpenChange={setVerifyOpen}
|
||||
campaignTitle={rest.entityTitle}
|
||||
isPending={verify.isPending}
|
||||
onConfirm={onConfirmVerify}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCampaignModeration } from '@/hooks/useCampaignModeration';
|
||||
import { useCampaignModerators } from '@/hooks/useCampaignModerators';
|
||||
import { useCampaignVerifications } from '@/hooks/useCampaignVerifications';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useOrganizationModeration } from '@/hooks/useOrganizationModeration';
|
||||
import { usePledgeModeration } from '@/hooks/usePledgeModeration';
|
||||
@@ -47,18 +48,29 @@ function OverlayBody({
|
||||
axes,
|
||||
badgeSize,
|
||||
showMenu = true,
|
||||
showHiddenBadge = true,
|
||||
className,
|
||||
}: Omit<ModerationOverlayProps, never> & { isHidden: boolean }) {
|
||||
}: Omit<ModerationOverlayProps, never> & {
|
||||
isHidden: boolean;
|
||||
/**
|
||||
* Whether to render the "Hidden" badge. The badge is a moderator-only
|
||||
* concept — a verifier who isn't a moderator gets the kebab (verify
|
||||
* row) but not the hidden-state chip. Defaults to true.
|
||||
*/
|
||||
showHiddenBadge?: boolean;
|
||||
}) {
|
||||
const wrapperClass = className ?? 'absolute top-2 right-2 z-10 flex items-center gap-1.5';
|
||||
|
||||
// When the menu is suppressed AND nothing is hidden, the overlay
|
||||
// would render an empty positioned div. Skip render entirely so the
|
||||
// banner stays clean.
|
||||
if (!showMenu && !isHidden) return null;
|
||||
const renderBadge = isHidden && showHiddenBadge;
|
||||
|
||||
// When the menu is suppressed AND nothing is rendered for the badge,
|
||||
// the overlay would render an empty positioned div. Skip render
|
||||
// entirely so the banner stays clean.
|
||||
if (!showMenu && !renderBadge) return null;
|
||||
|
||||
return (
|
||||
<div className={wrapperClass}>
|
||||
{isHidden && <HiddenBadge size={badgeSize ?? 'compact'} />}
|
||||
{renderBadge && <HiddenBadge size={badgeSize ?? 'compact'} />}
|
||||
{showMenu && (
|
||||
<ModerationMenu
|
||||
coord={coord}
|
||||
@@ -78,9 +90,16 @@ function OverlayBody({
|
||||
// dedicated components keeps the rules of hooks happy.
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
function CampaignOverlay(props: ModerationOverlayProps) {
|
||||
function CampaignOverlay(props: ModerationOverlayProps & { isMod: boolean }) {
|
||||
const { data } = useCampaignModeration();
|
||||
return <OverlayBody {...props} isHidden={data.hiddenCoords.has(props.coord)} />;
|
||||
const { isMod, ...rest } = props;
|
||||
return (
|
||||
<OverlayBody
|
||||
{...rest}
|
||||
isHidden={data.hiddenCoords.has(props.coord)}
|
||||
showHiddenBadge={isMod}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function PledgeOverlay(props: ModerationOverlayProps) {
|
||||
@@ -96,16 +115,48 @@ function GroupOverlay(props: ModerationOverlayProps) {
|
||||
/**
|
||||
* Absolutely-positioned overlay for cards: bundles the Hidden badge
|
||||
* (when the entity is hidden) and the moderator kebab in a single
|
||||
* top-right corner. Returns `null` for non-moderators so non-mod grids
|
||||
* never subscribe to the moderation label query at all.
|
||||
* top-right corner. Returns `null` for users with nothing to do so
|
||||
* non-mod grids never subscribe to the moderation label query at all.
|
||||
*
|
||||
* The campaign surface additionally surfaces the kebab to **verifiers**
|
||||
* (accounts with a kind 14672 verifier statement) so they can reach the
|
||||
* "Verify this campaign" action — see {@link CampaignModerationOverlay}.
|
||||
* Pledges and groups stay strictly moderator-gated.
|
||||
*
|
||||
* Consistent across campaigns, pledges, and groups — same chip, same
|
||||
* kebab placement, same moderator gating, same visual order.
|
||||
* kebab placement, same visual order.
|
||||
*
|
||||
* Card containers must be `relative` for the absolute positioning to
|
||||
* anchor correctly.
|
||||
*/
|
||||
export function ModerationOverlay(props: ModerationOverlayProps) {
|
||||
if (props.surface === 'campaign') {
|
||||
return <CampaignModerationOverlay {...props} />;
|
||||
}
|
||||
return <NonCampaignModerationOverlay {...props} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Campaign overlay gate: visible to moderators (full kebab + hidden
|
||||
* badge) and to verifiers (kebab carrying only the verify row). Mounts
|
||||
* the verification query so a verifier's eligibility is resolved.
|
||||
*/
|
||||
function CampaignModerationOverlay(props: ModerationOverlayProps) {
|
||||
const { user } = useCurrentUser();
|
||||
const { data: moderators } = useCampaignModerators();
|
||||
const { canVerify } = useCampaignVerifications();
|
||||
const isMod = !!user && !!moderators && moderators.includes(user.pubkey);
|
||||
|
||||
if (!isMod && !canVerify) return null;
|
||||
|
||||
return <CampaignOverlay {...props} isMod={isMod} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pledge / group overlay gate: strictly moderator-gated so non-mod grids
|
||||
* never subscribe to the moderation label query.
|
||||
*/
|
||||
function NonCampaignModerationOverlay(props: ModerationOverlayProps) {
|
||||
const { user } = useCurrentUser();
|
||||
const { data: moderators } = useCampaignModerators();
|
||||
const isMod = !!user && !!moderators && moderators.includes(user.pubkey);
|
||||
@@ -113,8 +164,10 @@ export function ModerationOverlay(props: ModerationOverlayProps) {
|
||||
if (!isMod) return null;
|
||||
|
||||
switch (props.surface) {
|
||||
case 'campaign': return <CampaignOverlay {...props} />;
|
||||
case 'pledge': return <PledgeOverlay {...props} />;
|
||||
case 'group': return <GroupOverlay {...props} />;
|
||||
case 'campaign':
|
||||
// Handled by ModerationOverlay before reaching here.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { ProfileCard } from '@/components/ProfileCard';
|
||||
import { ImageCropDialog } from '@/components/ImageCropDialog';
|
||||
import { useUploadFile } from '@/hooks/useUploadFile';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
import { fetchImageAsFile } from '@/lib/proxyImageUrl';
|
||||
|
||||
/**
|
||||
* The mutable kind-0 identity fields this editor manages. The host owns the
|
||||
* draft; the editor only emits patches.
|
||||
*/
|
||||
export interface ProfileIdentityDraft {
|
||||
/** kind-0 `name` (and `display_name`). */
|
||||
name: string;
|
||||
/** kind-0 `picture` (avatar) — a Blossom URL. */
|
||||
picture: string;
|
||||
/** kind-0 `banner` — a Blossom URL. */
|
||||
banner: string;
|
||||
/** kind-0 `website`. Used when `bioField` is `'website'`. */
|
||||
website: string;
|
||||
/** kind-0 `about` (bio). Used when `bioField` is `'about'`. */
|
||||
about: string;
|
||||
}
|
||||
|
||||
/** Which image field the crop dialog is currently editing. */
|
||||
type CropField = 'picture' | 'banner';
|
||||
|
||||
/** Aspect ratios: circular avatar crops square; banner crops 3:1. */
|
||||
const CROP_ASPECT: Record<CropField, number> = {
|
||||
picture: 1,
|
||||
banner: 3,
|
||||
};
|
||||
|
||||
interface ProfileIdentityEditorProps {
|
||||
draft: ProfileIdentityDraft;
|
||||
onChange: (patch: Partial<ProfileIdentityDraft>) => void;
|
||||
/**
|
||||
* Which kind-0 field the editable text slot below the name edits:
|
||||
* `'website'` for organizations, `'about'` (bio) for campaigners, or
|
||||
* `'none'` to show just the name.
|
||||
*/
|
||||
bioField: 'website' | 'about' | 'none';
|
||||
/** Placeholder for the bio textarea when `bioField` is `'about'`. */
|
||||
aboutPlaceholder?: string;
|
||||
/** Show the banner area (default true). */
|
||||
showBanner?: boolean;
|
||||
/** Show the avatar area (default true). */
|
||||
showAvatar?: boolean;
|
||||
/** Placeholder for the editable name input. */
|
||||
namePlaceholder?: string;
|
||||
/** Maximum length for the editable name input. */
|
||||
nameMaxLength?: number;
|
||||
/** Notifies the host with the Blossom/NIP-94 tags from a completed image upload. */
|
||||
onImageUploadComplete?: (field: 'picture' | 'banner', nip94Tags: string[][]) => void;
|
||||
/** Notifies the host of upload progress so it can gate its primary button. */
|
||||
onUploadingChange?: (uploading: boolean) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared editable identity card: banner, circular avatar, inline name, and a
|
||||
* configurable bio/website slot, with the full upload → crop → Blossom flow
|
||||
* (local file picker + paste-URL) and image removal. Used by the verifier
|
||||
* (organization) onboarding step and the campaign-creator wizard so both
|
||||
* surfaces present an identical identity-editing experience.
|
||||
*
|
||||
* Nothing is published here; patches flow back through `onChange` and the
|
||||
* host decides when to persist.
|
||||
*/
|
||||
export function ProfileIdentityEditor({
|
||||
draft,
|
||||
onChange,
|
||||
bioField,
|
||||
aboutPlaceholder,
|
||||
showBanner = true,
|
||||
showAvatar = true,
|
||||
namePlaceholder,
|
||||
nameMaxLength,
|
||||
onImageUploadComplete,
|
||||
onUploadingChange,
|
||||
className,
|
||||
}: ProfileIdentityEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
const { mutateAsync: uploadFile } = useUploadFile();
|
||||
const { config } = useAppContext();
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const pendingFieldRef = useRef<CropField | null>(null);
|
||||
const [cropState, setCropState] = useState<{
|
||||
field: CropField;
|
||||
imageSrc: string;
|
||||
objectUrl: boolean;
|
||||
} | null>(null);
|
||||
|
||||
// Open the OS file picker for the requested image field.
|
||||
const handlePickImage = useCallback((field: CropField) => {
|
||||
pendingFieldRef.current = field;
|
||||
fileInputRef.current?.click();
|
||||
}, []);
|
||||
|
||||
// Read an image URL from the clipboard, validate it, then fetch its bytes
|
||||
// (through the image proxy so the request is CORS-safe) into an object URL.
|
||||
// From there it joins the exact same crop → Blossom-upload flow as a local
|
||||
// file — the cropper only ever sees a same-origin `blob:` source, so the
|
||||
// canvas never taints and arbitrary remote hosts / SVGs work.
|
||||
const handlePasteUrl = useCallback(
|
||||
async (field: CropField) => {
|
||||
let text = '';
|
||||
try {
|
||||
text = (await navigator.clipboard.readText()).trim();
|
||||
} catch {
|
||||
toast({
|
||||
title: t('onboarding.verifier.identity.clipboardFailed'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const url = sanitizeUrl(text);
|
||||
if (!url) {
|
||||
toast({
|
||||
title: t('onboarding.verifier.identity.pasteUrlInvalid'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let file: File;
|
||||
try {
|
||||
file = await fetchImageAsFile(
|
||||
url,
|
||||
config.imageProxy,
|
||||
field === 'banner' ? 1500 : 1024,
|
||||
);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t('onboarding.verifier.identity.pasteUrlFetchFailed'),
|
||||
description: error instanceof Error ? error.message : undefined,
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setCropState({
|
||||
field,
|
||||
imageSrc: URL.createObjectURL(file),
|
||||
objectUrl: true,
|
||||
});
|
||||
},
|
||||
[config.imageProxy, t, toast],
|
||||
);
|
||||
|
||||
const handleFileChosen = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = '';
|
||||
const field = pendingFieldRef.current;
|
||||
pendingFieldRef.current = null;
|
||||
if (!file || !field) return;
|
||||
|
||||
if (!file.type.startsWith('image/')) {
|
||||
toast({ title: t('onboarding.profile.imageOnly'), variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
toast({ title: t('onboarding.profile.imageTooLarge'), variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
|
||||
const imageSrc = URL.createObjectURL(file);
|
||||
setCropState({ field, imageSrc, objectUrl: true });
|
||||
},
|
||||
[t, toast],
|
||||
);
|
||||
|
||||
const handleCropCancel = useCallback(() => {
|
||||
if (cropState?.objectUrl) URL.revokeObjectURL(cropState.imageSrc);
|
||||
setCropState(null);
|
||||
}, [cropState]);
|
||||
|
||||
const handleCropConfirm = useCallback(
|
||||
async (croppedFile: File) => {
|
||||
if (!cropState) return;
|
||||
const { field, imageSrc, objectUrl } = cropState;
|
||||
if (objectUrl) URL.revokeObjectURL(imageSrc);
|
||||
setCropState(null);
|
||||
onUploadingChange?.(true);
|
||||
try {
|
||||
const tags = await uploadFile(croppedFile);
|
||||
const url = tags[0]?.[1];
|
||||
if (url) {
|
||||
onChange({ [field]: url });
|
||||
onImageUploadComplete?.(field, tags);
|
||||
}
|
||||
} catch {
|
||||
toast({ title: t('onboarding.profile.uploadFailed'), variant: 'destructive' });
|
||||
} finally {
|
||||
onUploadingChange?.(false);
|
||||
}
|
||||
},
|
||||
[cropState, uploadFile, onChange, onImageUploadComplete, onUploadingChange, t, toast],
|
||||
);
|
||||
|
||||
const handleCropError = useCallback(
|
||||
(error: unknown) => {
|
||||
toast({
|
||||
title: t('onboarding.profile.uploadFailed'),
|
||||
description: error instanceof Error ? error.message : String(error),
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
[t, toast],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleFileChosen}
|
||||
/>
|
||||
|
||||
{cropState && (
|
||||
<ImageCropDialog
|
||||
open
|
||||
imageSrc={cropState.imageSrc}
|
||||
aspect={CROP_ASPECT[cropState.field]}
|
||||
title={
|
||||
cropState.field === 'picture'
|
||||
? t('onboarding.verifier.identity.cropAvatar')
|
||||
: t('onboarding.verifier.identity.cropBanner')
|
||||
}
|
||||
maxOutputSize={cropState.field === 'banner' ? 1500 : 512}
|
||||
onCancel={handleCropCancel}
|
||||
onCrop={handleCropConfirm}
|
||||
onError={handleCropError}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ProfileCard
|
||||
className="rounded-none border-0 bg-transparent"
|
||||
metadata={{
|
||||
name: draft.name,
|
||||
website: draft.website,
|
||||
about: draft.about,
|
||||
picture: draft.picture,
|
||||
banner: draft.banner,
|
||||
}}
|
||||
onChange={(patch) => {
|
||||
if (patch.name !== undefined) onChange({ name: patch.name });
|
||||
if (patch.website !== undefined) onChange({ website: patch.website as string });
|
||||
if (patch.about !== undefined) onChange({ about: patch.about });
|
||||
}}
|
||||
onPickImage={handlePickImage}
|
||||
onPasteUrl={handlePasteUrl}
|
||||
onRemoveAvatar={() => onChange({ picture: '' })}
|
||||
onRemoveBanner={() => onChange({ banner: '' })}
|
||||
bioField={bioField}
|
||||
aboutPlaceholder={aboutPlaceholder}
|
||||
showBanner={showBanner}
|
||||
showAvatar={showAvatar}
|
||||
namePlaceholder={namePlaceholder}
|
||||
nameMaxLength={nameMaxLength}
|
||||
showNip05={false}
|
||||
showBadges={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ProfileIdentityEditor;
|
||||
@@ -0,0 +1,97 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ArrowRight, Loader2 } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { OrgProfileDraft } from '@/components/onboarding/VerifierIdentityStep';
|
||||
|
||||
interface VerifierBioStepProps {
|
||||
draft: OrgProfileDraft;
|
||||
onChange: (patch: Partial<OrgProfileDraft>) => void;
|
||||
onContinue: () => void;
|
||||
/** True while the kind-0 profile is being published on continue. */
|
||||
isPublishing?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifier sub-flow step 2 — the organization's bio (kind-0 `about`).
|
||||
*
|
||||
* A single required textarea. The bio is added to the shared draft;
|
||||
* publishing of the assembled kind-0 profile happens when this step's
|
||||
* continue handler runs (wired in the gate).
|
||||
*/
|
||||
export function VerifierBioStep({
|
||||
draft,
|
||||
onChange,
|
||||
onContinue,
|
||||
isPublishing = false,
|
||||
}: VerifierBioStepProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const bioProvided = draft.about.trim().length > 0;
|
||||
const canContinue = bioProvided && !isPublishing;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2 text-center">
|
||||
<h2 className="text-2xl font-bold tracking-tight">
|
||||
{t('onboarding.verifier.bio.title')}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('onboarding.verifier.bio.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Textarea
|
||||
id="verifier-org-bio"
|
||||
value={draft.about}
|
||||
onChange={(e) => {
|
||||
onChange({ about: e.target.value });
|
||||
// Auto-grow: reset then size to content so the box expands
|
||||
// downward as the user types instead of scrolling internally.
|
||||
e.target.style.height = 'auto';
|
||||
e.target.style.height = `${e.target.scrollHeight}px`;
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.target.style.height = 'auto';
|
||||
e.target.style.height = `${e.target.scrollHeight}px`;
|
||||
}}
|
||||
placeholder={t('onboarding.verifier.bio.placeholder')}
|
||||
className={cn(
|
||||
'min-h-[200px] w-full resize-none overflow-hidden p-3',
|
||||
'text-lg leading-7 md:text-lg',
|
||||
// Match the muted, borderless look of the "Your name" field on
|
||||
// the previous identity step (ProfileCard's editable inputs).
|
||||
'rounded-lg border-2 border-transparent bg-muted/40',
|
||||
'hover:bg-muted/60 hover:border-border',
|
||||
'focus-visible:bg-transparent focus-visible:border-primary focus-visible:ring-0 focus-visible:ring-offset-0',
|
||||
'placeholder:text-muted-foreground/40 transition-colors duration-150',
|
||||
)}
|
||||
aria-required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={onContinue}
|
||||
disabled={!canContinue}
|
||||
className={cn('w-full h-12 text-base rounded-full')}
|
||||
>
|
||||
{isPublishing ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{t('onboarding.verifier.bio.publishing')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{t('common.continue')}
|
||||
<ArrowRight className="ml-2 h-4 w-4 rtl:rotate-180" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default VerifierBioStep;
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ArrowRight, Loader2 } from 'lucide-react';
|
||||
|
||||
import { ProfileIdentityEditor } from '@/components/onboarding/ProfileIdentityEditor';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* The mutable draft of the organization's kind-0 profile, shared across the
|
||||
* verifier sub-flow steps (identity here, bio next) and published once at
|
||||
* the end. Held by the captive overlay so back-navigation preserves entries.
|
||||
*/
|
||||
export interface OrgProfileDraft {
|
||||
/** Maps to kind-0 `name` (and `display_name`). */
|
||||
name: string;
|
||||
/** Maps to kind-0 `website`. */
|
||||
website: string;
|
||||
/** Maps to kind-0 `picture` (avatar) — a Blossom URL. */
|
||||
picture: string;
|
||||
/** Maps to kind-0 `banner` — a Blossom URL. */
|
||||
banner: string;
|
||||
/** Maps to kind-0 `about` (collected in the bio step). */
|
||||
about: string;
|
||||
}
|
||||
|
||||
interface VerifierIdentityStepProps {
|
||||
draft: OrgProfileDraft;
|
||||
onChange: (patch: Partial<OrgProfileDraft>) => void;
|
||||
onContinue: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifier sub-flow step 1 — the organization's identity.
|
||||
*
|
||||
* Wraps the shared {@link ProfileIdentityEditor} (circular avatar,
|
||||
* rectangular banner, inline name, and a website field that replaces the bio
|
||||
* slot). Avatar and name are required; banner and website are optional. When
|
||||
* a website is entered, it must be a well-formed `https:` URL.
|
||||
*
|
||||
* Nothing is published here; the draft is published as a single kind-0 event
|
||||
* at the end of the sub-flow, so stepping back and forth never republishes.
|
||||
*/
|
||||
export function VerifierIdentityStep({
|
||||
draft,
|
||||
onChange,
|
||||
onContinue,
|
||||
}: VerifierIdentityStepProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(patch: Partial<OrgProfileDraft>) => onChange(patch),
|
||||
[onChange],
|
||||
);
|
||||
|
||||
// ── Continue gating ──────────────────────────────────────────────────────
|
||||
// Avatar + name are required; banner is optional. Website is optional too,
|
||||
// but if entered it must be a valid https URL.
|
||||
const nameProvided = draft.name.trim().length > 0;
|
||||
const avatarProvided = draft.picture.trim().length > 0;
|
||||
const websiteTouched = draft.website.trim().length > 0;
|
||||
const websiteValid = !websiteTouched || !!sanitizeUrl(draft.website.trim());
|
||||
const canContinue = nameProvided && avatarProvided && websiteValid && !isUploading;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2 text-center">
|
||||
<h2 className="text-2xl font-bold tracking-tight">
|
||||
{t('onboarding.verifier.identity.title')}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('onboarding.verifier.identity.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ProfileIdentityEditor
|
||||
className={cn(isUploading && 'opacity-50 pointer-events-none')}
|
||||
draft={draft}
|
||||
onChange={handleChange}
|
||||
bioField="website"
|
||||
onUploadingChange={setIsUploading}
|
||||
/>
|
||||
|
||||
{/* Website is optional, but if entered it must be a valid https URL. */}
|
||||
{websiteTouched && !websiteValid && (
|
||||
<p className="text-xs text-destructive">
|
||||
{t('onboarding.verifier.identity.websiteInvalid')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{isUploading && (
|
||||
<div className="flex items-center justify-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
{t('onboarding.verifier.identity.uploading')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={onContinue}
|
||||
disabled={!canContinue}
|
||||
className="w-full h-12 text-base rounded-full"
|
||||
>
|
||||
{t('common.continue')}
|
||||
<ArrowRight className="ml-2 h-4 w-4 rtl:rotate-180" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default VerifierIdentityStep;
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
import { MilkdownEditor } from '@/components/markdown/MilkdownEditor';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useVerifierStatement } from '@/hooks/useVerifierStatement';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface VerifierStatementEditorProps {
|
||||
/** Current markdown value (controlled). */
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
/** Hydration callback — fired once with the user's existing statement. */
|
||||
onHydrated?: (statement: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The verifier-statement (kind 14672) markdown editing surface.
|
||||
*
|
||||
* A controlled, borderless WYSIWYG editor: the host owns the value and the
|
||||
* publish action (publishing is wired to the onboarding step's primary
|
||||
* button). The editor only renders the editing surface, hydrating once from
|
||||
* the user's existing statement. Withdrawing happens from the profile's
|
||||
* "How We Verify" card, not here.
|
||||
*/
|
||||
export function VerifierStatementEditor({
|
||||
value,
|
||||
onChange,
|
||||
onHydrated,
|
||||
className,
|
||||
}: VerifierStatementEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useCurrentUser();
|
||||
const { statement, isLoading } = useVerifierStatement(user?.pubkey);
|
||||
const [hydrated, setHydrated] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydrated && !isLoading) {
|
||||
onChange(statement ?? '');
|
||||
onHydrated?.(statement ?? '');
|
||||
setHydrated(true);
|
||||
}
|
||||
}, [hydrated, isLoading, statement, onChange, onHydrated]);
|
||||
|
||||
if (isLoading && !hydrated) {
|
||||
return (
|
||||
<div className={cn('flex items-center gap-2 text-sm text-muted-foreground', className)}>
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
{t('verifier.loading')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-4', className)}>
|
||||
{/* Muted, borderless WYSIWYG markdown editor that matches the "Tell us
|
||||
about your organization" bio box on the previous step — same muted
|
||||
fill, no border until focus, and the same min height. */}
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-lg border-2 border-transparent bg-muted/40 overflow-hidden transition-colors duration-150',
|
||||
'hover:bg-muted/60 hover:border-border',
|
||||
'focus-within:bg-transparent focus-within:border-primary',
|
||||
)}
|
||||
>
|
||||
<MilkdownEditor
|
||||
className="verifier-statement-editor"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={t('verifier.placeholder')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default VerifierStatementEditor;
|
||||
@@ -0,0 +1,345 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
BadgeCheck,
|
||||
MoreHorizontal,
|
||||
MousePointer2,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
|
||||
/**
|
||||
* An animated, interactive tutorial shown on /verify once an
|
||||
* organization has published its verifier statement. It demonstrates the
|
||||
* exact gesture a verifier uses to vouch for a campaign: tapping the
|
||||
* three-dots (kebab) button on a campaign card and choosing
|
||||
* "Verify this campaign".
|
||||
*
|
||||
* The component renders a faithful mock campaign card and drives a small
|
||||
* three-step state machine that mimics a cursor opening the kebab menu and
|
||||
* clicking the verify item. It auto-advances on a timer and loops forever so
|
||||
* users learn the gesture purely by watching. The cursor is gated behind a
|
||||
* `prefers-reduced-motion` check; the UI state replay itself is a simple
|
||||
* visibility sequence so the instruction still works without cursor motion.
|
||||
*/
|
||||
|
||||
type Phase = 'idle' | 'menuOpen' | 'verified';
|
||||
|
||||
const NEXT_PHASE: Record<Phase, Phase> = {
|
||||
idle: 'menuOpen',
|
||||
menuOpen: 'verified',
|
||||
verified: 'idle',
|
||||
};
|
||||
|
||||
const PHASE_DELAY: Record<Phase, number> = {
|
||||
idle: 2000,
|
||||
menuOpen: 2000,
|
||||
verified: 1200,
|
||||
};
|
||||
|
||||
function usePrefersReducedMotion(): boolean {
|
||||
const ref = useRef(false);
|
||||
if (typeof window !== 'undefined' && typeof window.matchMedia === 'function') {
|
||||
ref.current = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
}
|
||||
return ref.current;
|
||||
}
|
||||
|
||||
interface VerifyTutorialProps {
|
||||
className?: string;
|
||||
/** Hide the component's internal eyebrow/title/lede header (when the host
|
||||
* already provides one). */
|
||||
hideHeader?: boolean;
|
||||
/** Drop the bordered card chrome so it blends into the surrounding page. */
|
||||
bare?: boolean;
|
||||
/** Let the demo span the full available width in stacked onboarding flows. */
|
||||
stacked?: boolean;
|
||||
/**
|
||||
* When provided, the demo card's verified badge shows this organization's
|
||||
* avatar + name (the preview a verifier just configured) instead of the
|
||||
* generic "Verified by you" label — so the onboarding flow previews how the
|
||||
* org's own badge will surface on a campaign.
|
||||
*/
|
||||
verifierName?: string;
|
||||
verifierPicture?: string;
|
||||
/** Fired after the first full replay cycle completes and resets. */
|
||||
onLoopComplete?: () => void;
|
||||
}
|
||||
|
||||
export function VerifyTutorial({
|
||||
className,
|
||||
hideHeader = false,
|
||||
bare = false,
|
||||
stacked = false,
|
||||
verifierName,
|
||||
verifierPicture,
|
||||
onLoopComplete,
|
||||
}: VerifyTutorialProps) {
|
||||
const { t } = useTranslation();
|
||||
const reducedMotion = usePrefersReducedMotion();
|
||||
|
||||
const [phase, setPhase] = useState<Phase>('idle');
|
||||
|
||||
// Simple visibility loop: start with the card, reveal the menu after 2s,
|
||||
// reveal the badge after another 2s, then pause briefly and reset.
|
||||
useEffect(() => {
|
||||
const id = window.setTimeout(() => {
|
||||
if (phase === 'verified') {
|
||||
onLoopComplete?.();
|
||||
}
|
||||
setPhase((prev) => NEXT_PHASE[prev]);
|
||||
}, PHASE_DELAY[phase]);
|
||||
return () => window.clearTimeout(id);
|
||||
}, [phase, onLoopComplete]);
|
||||
|
||||
const menuVisible = phase === 'menuOpen';
|
||||
const verified = phase === 'verified';
|
||||
|
||||
return (
|
||||
<section
|
||||
className={cn(
|
||||
!bare &&
|
||||
'rounded-2xl border border-primary/20 bg-gradient-to-br from-primary/[0.07] via-background to-background p-6 sm:p-8 shadow-sm',
|
||||
className,
|
||||
)}
|
||||
aria-labelledby="verify-tutorial-title"
|
||||
>
|
||||
{/* Header */}
|
||||
{!hideHeader && (
|
||||
<div className="flex flex-wrap items-start justify-between gap-4 mb-8">
|
||||
<div className="max-w-md">
|
||||
<p className="inline-flex items-center gap-1.5 text-xs font-semibold tracking-widest uppercase text-primary mb-2">
|
||||
<BadgeCheck className="size-4" />
|
||||
{t('organizations.tutorial.eyebrow')}
|
||||
</p>
|
||||
<h3
|
||||
id="verify-tutorial-title"
|
||||
className="text-xl sm:text-2xl font-bold tracking-tight mb-2"
|
||||
>
|
||||
{t('organizations.tutorial.title')}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
{t('organizations.tutorial.lede')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DemoStage
|
||||
phase={phase}
|
||||
menuVisible={menuVisible}
|
||||
verified={verified}
|
||||
reducedMotion={reducedMotion}
|
||||
fullWidth={stacked}
|
||||
verifierName={verifierName}
|
||||
verifierPicture={verifierPicture}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ── The animated mock card ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A real published campaign (kind 33863) used as the demo subject so the
|
||||
* tutorial mirrors an actual card rather than invented placeholder copy.
|
||||
* Static by design — the tutorial is purely illustrative, so we read the
|
||||
* fields directly instead of fetching the event.
|
||||
*/
|
||||
const DEMO_CAMPAIGN = {
|
||||
title: 'Agora App Development Fund',
|
||||
organizer: 'Team Soapbox',
|
||||
organizerPicture:
|
||||
'https://blossom.primal.net/e93f617f8331509acdddde3df0c1cd23cda1803d92c70815fc96e2d5f8d48ac8.png',
|
||||
story: 'Help fund the development of Agora!',
|
||||
banner:
|
||||
'https://blossom.primal.net/aade02e86584a7ab269550992d0266bae31059a34e6e08fddba1f6f5acb6e7d6.jpg',
|
||||
goalLabel: '$1,000',
|
||||
raisedLabel: '$670',
|
||||
pct: 67,
|
||||
} as const;
|
||||
|
||||
interface DemoStageProps {
|
||||
phase: Phase;
|
||||
menuVisible: boolean;
|
||||
verified: boolean;
|
||||
reducedMotion: boolean;
|
||||
/** Span the full container width instead of the narrow `max-w-sm` card. */
|
||||
fullWidth?: boolean;
|
||||
/** Optional verifier identity to preview in the badge (see VerifyTutorial). */
|
||||
verifierName?: string;
|
||||
verifierPicture?: string;
|
||||
}
|
||||
|
||||
function DemoStage({
|
||||
phase,
|
||||
menuVisible,
|
||||
verified,
|
||||
reducedMotion,
|
||||
fullWidth = false,
|
||||
verifierName,
|
||||
verifierPicture,
|
||||
}: DemoStageProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// The badge replicates the live overlay `CampaignVerificationBadge`
|
||||
// (dark translucent pill, single ring-bordered avatar, sky check) so the
|
||||
// preview matches exactly how a verification surfaces on a real card.
|
||||
const badgePicture = sanitizeUrl(verifierPicture);
|
||||
const verifierInitials =
|
||||
(verifierName?.trim() || '')
|
||||
.slice(0, 2)
|
||||
.toUpperCase() || '?';
|
||||
|
||||
const bannerUrl = sanitizeUrl(DEMO_CAMPAIGN.banner);
|
||||
const organizerPicture = sanitizeUrl(DEMO_CAMPAIGN.organizerPicture);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative w-full select-none',
|
||||
fullWidth ? 'mx-0' : 'mx-auto max-w-sm',
|
||||
)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{/* Mock campaign card — mirrors CampaignCard's structure. */}
|
||||
<div className="overflow-hidden rounded-2xl border border-border/60 bg-card shadow-md">
|
||||
{/* Banner */}
|
||||
<div
|
||||
className="relative h-40 bg-gradient-to-br from-sky-500/80 via-cyan-500/70 to-emerald-500/80 bg-cover bg-center"
|
||||
style={bannerUrl ? { backgroundImage: `url("${bannerUrl}")` } : undefined}
|
||||
>
|
||||
{/* Top scrim for badge legibility — as on the real card. */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute inset-x-0 top-0 h-16 bg-gradient-to-b from-black/30 to-transparent"
|
||||
/>
|
||||
|
||||
{/* Verified badge (top-left) — appears in the final phase. A faithful
|
||||
copy of the live overlay CampaignVerificationBadge for a single
|
||||
verifier: the org's avatar + sky check, no count text. */}
|
||||
<div
|
||||
className={cn(
|
||||
'absolute left-3 top-3 z-10 inline-flex items-center gap-1 rounded-full bg-black/40 px-1.5 py-1 text-white backdrop-blur-md transition-all duration-500',
|
||||
verified
|
||||
? 'opacity-100 translate-y-0'
|
||||
: 'opacity-0 -translate-y-1 pointer-events-none',
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center -space-x-2">
|
||||
<Avatar className="size-6 ring-2 ring-background">
|
||||
{badgePicture && <AvatarImage src={badgePicture} alt="" className="object-cover" />}
|
||||
<AvatarFallback className="bg-secondary text-[9px] text-secondary-foreground">
|
||||
{verifierInitials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</span>
|
||||
<span className="ml-0.5 inline-flex items-center gap-1 pr-1 text-xs font-semibold">
|
||||
<BadgeCheck className="size-4 text-sky-300" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Three-dots button (top-right) */}
|
||||
<div className="absolute right-3 top-3">
|
||||
<div
|
||||
className={cn(
|
||||
'flex size-8 items-center justify-center rounded-md bg-background/80 text-muted-foreground backdrop-blur transition-all duration-300',
|
||||
phase === 'idle' &&
|
||||
!reducedMotion &&
|
||||
'motion-safe:animate-pulse ring-2 ring-primary/60',
|
||||
menuVisible && 'bg-background text-foreground ring-2 ring-primary/50',
|
||||
)}
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
</div>
|
||||
|
||||
{/* Dropdown menu */}
|
||||
<div
|
||||
className={cn(
|
||||
'absolute right-0 top-10 z-20 w-52 origin-top-right rounded-md border bg-popover p-1 text-popover-foreground shadow-lg transition-all duration-200',
|
||||
menuVisible
|
||||
? 'scale-100 opacity-100'
|
||||
: 'pointer-events-none scale-95 opacity-0',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-sm px-2 py-2 text-sm font-medium transition-colors',
|
||||
menuVisible
|
||||
? 'bg-primary/10 text-primary'
|
||||
: 'text-foreground',
|
||||
)}
|
||||
>
|
||||
<BadgeCheck className="size-4 shrink-0" />
|
||||
{t('organizations.tutorial.demo.menuVerify')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card body */}
|
||||
<div className="space-y-3 p-4">
|
||||
<div>
|
||||
<p className="font-semibold leading-snug truncate">
|
||||
{DEMO_CAMPAIGN.title}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{DEMO_CAMPAIGN.story}
|
||||
</p>
|
||||
</div>
|
||||
{/* Progress — mirrors CampaignProgress (bar + raised / goal). */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="h-2 w-full overflow-hidden rounded-full bg-foreground/15">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary"
|
||||
style={{ width: `${DEMO_CAMPAIGN.pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between gap-2 text-sm">
|
||||
<span className="font-semibold">{DEMO_CAMPAIGN.raisedLabel}</span>
|
||||
<span className="text-muted-foreground">of {DEMO_CAMPAIGN.goalLabel} goal</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Organizer footer — mirrors CampaignCard's AuthorByline row. */}
|
||||
<div className="flex items-center gap-2 border-t border-border/60 pt-3 text-xs text-muted-foreground">
|
||||
<Avatar className="size-5">
|
||||
{organizerPicture && <AvatarImage src={organizerPicture} alt="" className="object-cover" />}
|
||||
<AvatarFallback className="bg-secondary text-[9px] text-secondary-foreground">
|
||||
{DEMO_CAMPAIGN.organizer.slice(0, 1).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="truncate font-medium text-foreground/80">
|
||||
{DEMO_CAMPAIGN.organizer}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Animated cursor — hidden under reduced motion */}
|
||||
{!reducedMotion && (
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-none absolute z-30 transition-all duration-700 ease-out',
|
||||
// idle → hover the kebab (top-right); menuOpen/verified → hover the verify item
|
||||
phase === 'idle'
|
||||
? 'right-4 top-5'
|
||||
: 'right-8 top-[4.5rem]',
|
||||
)}
|
||||
>
|
||||
<MousePointer2
|
||||
key={phase}
|
||||
className={cn(
|
||||
'size-6 fill-foreground text-background drop-shadow-md transition-transform',
|
||||
'motion-safe:animate-tutorial-tap',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default VerifyTutorial;
|
||||
@@ -163,7 +163,7 @@ function SortedByTopGrid({ campaigns }: { campaigns: ParsedCampaign[] }) {
|
||||
queryKey: ['bitcoin-balance', 'campaign', esploraApis, address],
|
||||
queryFn: ({ signal }: { signal: AbortSignal }) =>
|
||||
fetchAddressData(address, esploraApis, signal),
|
||||
staleTime: 30_000,
|
||||
staleTime: 60_000,
|
||||
enabled: !!address,
|
||||
})),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { BadgeCheck } from 'lucide-react';
|
||||
|
||||
import { CampaignCard, CampaignCardSkeleton } from '@/components/CampaignCard';
|
||||
import { ProfileVerifierSection } from '@/components/profile/ProfileVerifierSection';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { useVerifiedCampaigns } from '@/hooks/useVerifiedCampaigns';
|
||||
|
||||
interface ProfileVerifiedTabProps {
|
||||
pubkey: string;
|
||||
displayName: string;
|
||||
isOwnProfile?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The profile's verification tab: the account's self-published
|
||||
* "How We Verify" statement (kind 14672) followed by the grid of
|
||||
* campaigns it has verified — resolved from the account's own
|
||||
* `agora.verified` (kind 1985) labels via {@link useVerifiedCampaigns}.
|
||||
* Surfaced as the default tab for verifier profiles so visitors
|
||||
* immediately see how the organization vets campaigns and what it
|
||||
* stands behind.
|
||||
*/
|
||||
export function ProfileVerifiedTab({ pubkey, displayName, isOwnProfile = false }: ProfileVerifiedTabProps) {
|
||||
const { t } = useTranslation();
|
||||
const { campaigns, isLoading } = useVerifiedCampaigns(pubkey);
|
||||
|
||||
if (isLoading && campaigns.length === 0) {
|
||||
return (
|
||||
<div className="px-4 sm:px-6 py-6 space-y-6">
|
||||
<ProfileVerifierSection pubkey={pubkey} isOwnProfile={isOwnProfile} />
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4 sm:gap-5">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<CampaignCardSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (campaigns.length === 0) {
|
||||
return (
|
||||
<div className="px-4 sm:px-6 py-6 space-y-6" data-pubkey={pubkey}>
|
||||
<ProfileVerifierSection pubkey={pubkey} isOwnProfile={isOwnProfile} />
|
||||
<Card className="border-dashed">
|
||||
<div className="py-12 px-8 text-center">
|
||||
<BadgeCheck className="size-10 mx-auto mb-3 text-muted-foreground" />
|
||||
<p className="text-muted-foreground max-w-sm mx-auto">
|
||||
{t('profile.verified.empty', { name: displayName })}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-4 sm:px-6 py-6 space-y-4">
|
||||
<ProfileVerifierSection pubkey={pubkey} isOwnProfile={isOwnProfile} className="mb-2" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('profile.verified.count', { count: campaigns.length })}
|
||||
</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4 sm:gap-5">
|
||||
{campaigns.map((c) => (
|
||||
<CampaignCard key={c.aTag} campaign={c} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Loader2, Trash2 } from 'lucide-react';
|
||||
|
||||
import { PolicyMarkdown } from '@/components/PolicyMarkdown';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { useSetVerifierStatement, useVerifierStatement } from '@/hooks/useVerifierStatement';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface ProfileVerifierSectionProps {
|
||||
pubkey: string;
|
||||
/**
|
||||
* Whether the viewer owns this profile. When true, a Withdraw control is
|
||||
* surfaced in the card's top-right corner (mirroring the "Edit Profile"
|
||||
* affordance), letting the verifier retract their statement.
|
||||
*/
|
||||
isOwnProfile?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a profile's kind 14672 verifier statement — a self-published
|
||||
* explanation of how the account verifies campaigns. Surfaced full-width
|
||||
* above the profile tabs so donors can read, in the account's own words,
|
||||
* how it vets campaigns.
|
||||
*
|
||||
* Note: this is a self-authored claim, not a platform endorsement — the
|
||||
* heading is deliberately neutral ("How We Verify") and carries no
|
||||
* trust-implying badge, since Agora makes no guarantees about the account.
|
||||
*
|
||||
* Renders nothing when the profile has no statement (or has withdrawn it).
|
||||
*/
|
||||
export function ProfileVerifierSection({ pubkey, isOwnProfile = false, className }: ProfileVerifierSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
const { statement, isLoading } = useVerifierStatement(pubkey);
|
||||
const { mutateAsync: setStatement, isPending } = useSetVerifierStatement();
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<section className={cn('space-y-3', className)}>
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-24 w-full rounded-xl" />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (!statement) return null;
|
||||
|
||||
const handleWithdraw = async () => {
|
||||
try {
|
||||
await setStatement('');
|
||||
setConfirmOpen(false);
|
||||
toast({ title: t('verifier.withdrawnToast') });
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t('verifier.errorToast'),
|
||||
description: error instanceof Error ? error.message : String(error),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className={className}>
|
||||
<div className="rounded-xl border border-primary/20 bg-primary/5 px-4 py-3 space-y-2">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wide text-primary">
|
||||
{t('verifier.howWeVerifyTitle')}
|
||||
</h2>
|
||||
{isOwnProfile && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setConfirmOpen(true)}
|
||||
disabled={isPending}
|
||||
className="-mt-1 -mr-2 h-7 shrink-0 px-2 text-xs text-destructive hover:text-destructive"
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="size-3.5" />
|
||||
)}
|
||||
<span className="ml-1.5">{t('verifier.withdraw')}</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<PolicyMarkdown source={statement} />
|
||||
</div>
|
||||
|
||||
<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t('verifier.withdrawConfirmTitle')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t('verifier.withdrawConfirmBody')}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isPending}>{t('common.cancel')}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleWithdraw();
|
||||
}}
|
||||
disabled={isPending}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{isPending && <Loader2 className="size-4 animate-spin mr-2" />}
|
||||
{t('verifier.withdraw')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -14,8 +14,10 @@ export function QRCodeCanvas({ value, size = 256, level = 'M', className }: QRCo
|
||||
useEffect(() => {
|
||||
if (!canvasRef.current) return;
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
|
||||
QRCode.toCanvas(
|
||||
canvasRef.current,
|
||||
canvas,
|
||||
value,
|
||||
{
|
||||
width: size,
|
||||
@@ -26,6 +28,13 @@ export function QRCodeCanvas({ value, size = 256, level = 'M', className }: QRCo
|
||||
if (error) console.error('QR Code generation error:', error);
|
||||
}
|
||||
);
|
||||
|
||||
// The qrcode library hard-codes inline `width`/`height` pixel styles on
|
||||
// the canvas, which override Tailwind sizing classes and cause the QR to
|
||||
// overflow its container on narrow viewports. Clear them so the caller's
|
||||
// className (e.g. `h-auto w-full`) controls the rendered size responsively.
|
||||
canvas.style.removeProperty('width');
|
||||
canvas.style.removeProperty('height');
|
||||
}, [value, size, level]);
|
||||
|
||||
return <canvas ref={canvasRef} className={className} />;
|
||||
|
||||
@@ -288,6 +288,15 @@ export interface AppConfig {
|
||||
* Default: false.
|
||||
*/
|
||||
lowBandwidthMode: boolean;
|
||||
/**
|
||||
* Route all app traffic through the Tor network (arti). **Android only** —
|
||||
* ignored on web and iOS. The actual proxy is installed natively at app
|
||||
* startup, so changes take effect on the next launch (see `src/lib/tor.ts`
|
||||
* and the native `TorController`).
|
||||
*
|
||||
* Default: false.
|
||||
*/
|
||||
torEnabled: boolean;
|
||||
/** Hex pubkey of the curator whose follow list defines the curated feed. */
|
||||
curatorPubkey?: string;
|
||||
/**
|
||||
@@ -363,6 +372,15 @@ export interface AppConfig {
|
||||
* announced. Override via `agora.json` for a self-hosted endpoint.
|
||||
*/
|
||||
bip352IndexerUrl: string;
|
||||
/**
|
||||
* How many per-block fetches the silent-payment scanner keeps in flight at
|
||||
* once. The BlindBit Oracle exposes only per-block endpoints, so scan speed
|
||||
* is dominated by HTTP latency, not compute — higher concurrency hides that
|
||||
* latency. The default (8) is a polite value for the shared public indexer;
|
||||
* a fast self-hosted endpoint can take much higher. Clamped at runtime to
|
||||
* [1, 32]. Optional — omit to use the default.
|
||||
*/
|
||||
bip352ScanConcurrency?: number;
|
||||
/**
|
||||
* Display preference for monetary amounts (zap totals, balances, send forms).
|
||||
* - "usd" (default): convert sats to USD using the live BTC price.
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
import { useRef, useState, useCallback, useEffect } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { AudioPlayerContext, type AudioTrack } from '@/contexts/audioPlayerContextDef';
|
||||
|
||||
const VOLUME_KEY = 'audio-player-volume';
|
||||
|
||||
function getStoredVolume(): number {
|
||||
try {
|
||||
const v = localStorage.getItem(VOLUME_KEY);
|
||||
if (v !== null) {
|
||||
const n = parseFloat(v);
|
||||
if (isFinite(n) && n >= 0 && n <= 1) return n;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return 0.8;
|
||||
}
|
||||
|
||||
export function AudioPlayerProvider({ children }: { children: ReactNode }) {
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
|
||||
const [currentTrack, setCurrentTrack] = useState<AudioTrack | null>(null);
|
||||
const [playlist, setPlaylist] = useState<AudioTrack[]>([]);
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [minimized, setMinimized] = useState(false);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [volume, setVolumeState] = useState(getStoredVolume);
|
||||
|
||||
// Sync volume to audio element
|
||||
useEffect(() => {
|
||||
if (audioRef.current) audioRef.current.volume = volume;
|
||||
}, [volume]);
|
||||
|
||||
// Audio event listeners
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
|
||||
const onPlay = () => setIsPlaying(true);
|
||||
const onPause = () => setIsPlaying(false);
|
||||
const onEnded = () => {
|
||||
setIsPlaying(false);
|
||||
// Auto-advance playlist
|
||||
if (playlist.length > 0 && currentIndex < playlist.length - 1) {
|
||||
const next = currentIndex + 1;
|
||||
setCurrentIndex(next);
|
||||
setCurrentTrack(playlist[next]);
|
||||
audio.src = playlist[next].url;
|
||||
audio.play().catch(() => {});
|
||||
}
|
||||
};
|
||||
const onTimeUpdate = () => setCurrentTime(audio.currentTime);
|
||||
const onDurationChange = () => {
|
||||
if (audio.duration && isFinite(audio.duration)) setDuration(audio.duration);
|
||||
};
|
||||
|
||||
audio.addEventListener('play', onPlay);
|
||||
audio.addEventListener('pause', onPause);
|
||||
audio.addEventListener('ended', onEnded);
|
||||
audio.addEventListener('timeupdate', onTimeUpdate);
|
||||
audio.addEventListener('durationchange', onDurationChange);
|
||||
audio.addEventListener('loadedmetadata', onDurationChange);
|
||||
|
||||
return () => {
|
||||
audio.removeEventListener('play', onPlay);
|
||||
audio.removeEventListener('pause', onPause);
|
||||
audio.removeEventListener('ended', onEnded);
|
||||
audio.removeEventListener('timeupdate', onTimeUpdate);
|
||||
audio.removeEventListener('durationchange', onDurationChange);
|
||||
audio.removeEventListener('loadedmetadata', onDurationChange);
|
||||
};
|
||||
}, [playlist, currentIndex]);
|
||||
|
||||
// Media Session API — populates Android/iOS notification panel with track info and controls
|
||||
useEffect(() => {
|
||||
if (!('mediaSession' in navigator)) return;
|
||||
if (!currentTrack) {
|
||||
navigator.mediaSession.metadata = null;
|
||||
return;
|
||||
}
|
||||
const artwork: MediaImage[] = currentTrack.artwork
|
||||
? [{ src: currentTrack.artwork, sizes: '512x512', type: 'image/jpeg' }]
|
||||
: [];
|
||||
navigator.mediaSession.metadata = new MediaMetadata({
|
||||
title: currentTrack.title,
|
||||
artist: currentTrack.artist,
|
||||
artwork,
|
||||
});
|
||||
}, [currentTrack]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!('mediaSession' in navigator)) return;
|
||||
navigator.mediaSession.playbackState = isPlaying ? 'playing' : 'paused';
|
||||
}, [isPlaying]);
|
||||
|
||||
// Keep OS scrubber position in sync
|
||||
useEffect(() => {
|
||||
if (!('mediaSession' in navigator)) return;
|
||||
if (!currentTrack || duration <= 0) return;
|
||||
try {
|
||||
navigator.mediaSession.setPositionState({
|
||||
duration,
|
||||
playbackRate: audioRef.current?.playbackRate ?? 1,
|
||||
position: Math.min(currentTime, duration),
|
||||
});
|
||||
} catch { /* setPositionState may throw on some browsers */ }
|
||||
}, [currentTime, duration, currentTrack]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!('mediaSession' in navigator)) return;
|
||||
const audio = audioRef.current;
|
||||
|
||||
navigator.mediaSession.setActionHandler('play', () => audio?.play().catch(() => {}));
|
||||
navigator.mediaSession.setActionHandler('pause', () => audio?.pause());
|
||||
navigator.mediaSession.setActionHandler('previoustrack', () => {
|
||||
if (audio && audio.currentTime > 3) { audio.currentTime = 0; return; }
|
||||
const prev = currentIndex - 1;
|
||||
if (prev < 0 || playlist.length === 0) return;
|
||||
setCurrentIndex(prev);
|
||||
setCurrentTrack(playlist[prev]);
|
||||
setCurrentTime(0);
|
||||
setDuration(playlist[prev].duration ?? 0);
|
||||
if (audio) { audio.src = playlist[prev].url; audio.play().catch(() => {}); }
|
||||
});
|
||||
navigator.mediaSession.setActionHandler('nexttrack', () => {
|
||||
const next = currentIndex + 1;
|
||||
if (next >= playlist.length) return;
|
||||
setCurrentIndex(next);
|
||||
setCurrentTrack(playlist[next]);
|
||||
setCurrentTime(0);
|
||||
setDuration(playlist[next].duration ?? 0);
|
||||
if (audio) { audio.src = playlist[next].url; audio.play().catch(() => {}); }
|
||||
});
|
||||
navigator.mediaSession.setActionHandler('seekto', (details) => {
|
||||
if (audio && details.seekTime != null) audio.currentTime = details.seekTime;
|
||||
});
|
||||
|
||||
return () => {
|
||||
navigator.mediaSession.setActionHandler('play', null);
|
||||
navigator.mediaSession.setActionHandler('pause', null);
|
||||
navigator.mediaSession.setActionHandler('previoustrack', null);
|
||||
navigator.mediaSession.setActionHandler('nexttrack', null);
|
||||
navigator.mediaSession.setActionHandler('seekto', null);
|
||||
};
|
||||
}, [currentIndex, playlist]);
|
||||
|
||||
// beforeunload warning when playing
|
||||
useEffect(() => {
|
||||
if (!currentTrack) return;
|
||||
const handler = (e: BeforeUnloadEvent) => {
|
||||
e.preventDefault();
|
||||
};
|
||||
window.addEventListener('beforeunload', handler);
|
||||
return () => window.removeEventListener('beforeunload', handler);
|
||||
}, [currentTrack]);
|
||||
|
||||
const playTrack = useCallback((track: AudioTrack) => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
setCurrentTrack(track);
|
||||
setPlaylist([]);
|
||||
setCurrentIndex(0);
|
||||
setMinimized(false);
|
||||
setCurrentTime(0);
|
||||
setDuration(track.duration ?? 0);
|
||||
audio.src = track.url;
|
||||
audio.play().catch(() => {});
|
||||
}, []);
|
||||
|
||||
const playPlaylist = useCallback((tracks: AudioTrack[], startIndex = 0) => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio || tracks.length === 0) return;
|
||||
const idx = Math.max(0, Math.min(startIndex, tracks.length - 1));
|
||||
setPlaylist(tracks);
|
||||
setCurrentIndex(idx);
|
||||
setCurrentTrack(tracks[idx]);
|
||||
setMinimized(false);
|
||||
setCurrentTime(0);
|
||||
setDuration(tracks[idx].duration ?? 0);
|
||||
audio.src = tracks[idx].url;
|
||||
audio.play().catch(() => {});
|
||||
}, []);
|
||||
|
||||
const pause = useCallback(() => {
|
||||
audioRef.current?.pause();
|
||||
}, []);
|
||||
|
||||
const resume = useCallback(() => {
|
||||
audioRef.current?.play().catch(() => {});
|
||||
}, []);
|
||||
|
||||
const seek = useCallback((time: number) => {
|
||||
const audio = audioRef.current;
|
||||
if (audio) audio.currentTime = time;
|
||||
}, []);
|
||||
|
||||
const setVolume = useCallback((v: number) => {
|
||||
const clamped = Math.max(0, Math.min(1, v));
|
||||
setVolumeState(clamped);
|
||||
try { localStorage.setItem(VOLUME_KEY, String(clamped)); } catch { /* ignore */ }
|
||||
}, []);
|
||||
|
||||
const nextTrack = useCallback(() => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio || playlist.length === 0) return;
|
||||
const next = currentIndex + 1;
|
||||
if (next >= playlist.length) return;
|
||||
setCurrentIndex(next);
|
||||
setCurrentTrack(playlist[next]);
|
||||
setCurrentTime(0);
|
||||
setDuration(playlist[next].duration ?? 0);
|
||||
audio.src = playlist[next].url;
|
||||
audio.play().catch(() => {});
|
||||
}, [playlist, currentIndex]);
|
||||
|
||||
const prevTrack = useCallback(() => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio || playlist.length === 0) return;
|
||||
// If more than 3 seconds in, restart current track
|
||||
if (audio.currentTime > 3) {
|
||||
audio.currentTime = 0;
|
||||
return;
|
||||
}
|
||||
const prev = currentIndex - 1;
|
||||
if (prev < 0) return;
|
||||
setCurrentIndex(prev);
|
||||
setCurrentTrack(playlist[prev]);
|
||||
setCurrentTime(0);
|
||||
setDuration(playlist[prev].duration ?? 0);
|
||||
audio.src = playlist[prev].url;
|
||||
audio.play().catch(() => {});
|
||||
}, [playlist, currentIndex]);
|
||||
|
||||
const minimize = useCallback(() => setMinimized(true), []);
|
||||
|
||||
const expand = useCallback(() => setMinimized(false), []);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
const audio = audioRef.current;
|
||||
if (audio) {
|
||||
audio.pause();
|
||||
audio.src = '';
|
||||
}
|
||||
setCurrentTrack(null);
|
||||
setPlaylist([]);
|
||||
setCurrentIndex(0);
|
||||
setMinimized(false);
|
||||
setIsPlaying(false);
|
||||
setCurrentTime(0);
|
||||
setDuration(0);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AudioPlayerContext.Provider
|
||||
value={{
|
||||
currentTrack, playlist, currentIndex, minimized, isPlaying, currentTime, duration, volume,
|
||||
playTrack, playPlaylist, pause, resume, seek, setVolume, nextTrack, prevTrack, minimize, expand, stop,
|
||||
}}
|
||||
>
|
||||
{/* Hidden global audio element */}
|
||||
<audio ref={audioRef} preload="metadata" className="hidden" />
|
||||
{children}
|
||||
</AudioPlayerContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { useHdWalletSpInternal } from '@/hooks/useHdWalletSp';
|
||||
import { HdWalletSpContext } from '@/hooks/useHdWalletSpContext';
|
||||
|
||||
/**
|
||||
* Provides a single shared silent-payment wallet orchestrator to the whole
|
||||
* app. Mounting this once at the root means the BIP-352 background scanner
|
||||
* runs continuously — resuming from the last persisted block and keeping up
|
||||
* with the chain tip — regardless of which page the user is on. The
|
||||
* `/wallet` page consumes the shared state via `useHdWalletSp()`.
|
||||
*
|
||||
* The internal hook is a no-op (returns a disabled result) when the user
|
||||
* isn't logged in with an nsec or no indexer is configured, so it's safe to
|
||||
* mount unconditionally at the root.
|
||||
*/
|
||||
export function HdWalletSpProvider({ children }: { children: ReactNode }) {
|
||||
const sp = useHdWalletSpInternal();
|
||||
return <HdWalletSpContext.Provider value={sp}>{children}</HdWalletSpContext.Provider>;
|
||||
}
|
||||