Compare commits
192 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 545e6cf4be | |||
| eb978d651c | |||
| 7a52631eb2 | |||
| d48094ff68 | |||
| 247fbefa9b | |||
| 4a3c5df519 | |||
| 74478ee8ac | |||
| da94609855 | |||
| 8b90ef90f7 | |||
| 49f0ec2765 | |||
| 72c2170139 | |||
| a0082cbbcd | |||
| a8561f46f9 | |||
| 2c248f8269 | |||
| b8749f7064 | |||
| f800d55451 | |||
| ee8414f694 | |||
| 23ac55af6b | |||
| 2ef0642f6d | |||
| 18aacad290 | |||
| e82f0146d2 | |||
| 973defcd28 | |||
| 5bbd86ea90 | |||
| 65481d1280 | |||
| 3d4b40188e | |||
| a7f28e3963 | |||
| d2b6785ca7 | |||
| 2bab7ebe6e | |||
| e6fc7931b6 | |||
| c845f7286b | |||
| f5cdbb6f3a | |||
| b0759402cf | |||
| ef9c2eff89 | |||
| 2cde8fe1f8 | |||
| 9e26bb8209 | |||
| 7c14115119 | |||
| 12bc721952 | |||
| 6c5205cc75 | |||
| 737b197aa8 | |||
| 2cf3db0a51 | |||
| c54008cd3d | |||
| 7f16678acc | |||
| e77876ed16 | |||
| 03b68c3a24 | |||
| 2ab45a27d5 | |||
| e40f32a54f | |||
| c53e476dee | |||
| 3a06dcd4cb | |||
| d7144200fb | |||
| b5cb884004 | |||
| 0800b854ae | |||
| 3a98e38f7b | |||
| e198e8d572 | |||
| cf6364a84b | |||
| 34cae4c9ad | |||
| 0c686a2091 | |||
| d07bc64032 | |||
| e8acf45656 | |||
| 3c28e2b789 | |||
| dc43f723fb | |||
| c7ed31305d | |||
| 441eea160f | |||
| 4f056dfac0 | |||
| f16d5ea334 | |||
| ef8e6f9564 | |||
| 40f3179a63 | |||
| 3b35b084fd | |||
| 0ade19c51e | |||
| 81f3c9e755 | |||
| 657c0e43e3 | |||
| 5a72cf1fd0 | |||
| b3163ea2c9 | |||
| 1f545e7361 | |||
| 1b21edef19 | |||
| 2ba19fc135 | |||
| 934495a7d3 | |||
| a2a4c8b2a7 | |||
| c560bd8acd | |||
| 21907014e0 | |||
| 0b77980fc7 | |||
| 3bab0ef3e0 | |||
| cb52920259 | |||
| 6e4eff602a | |||
| 337d18951a | |||
| 31154f382d | |||
| 236e6aa211 | |||
| 8c684aeef2 | |||
| ab59960233 | |||
| 3565ebf098 | |||
| 0dcc2f2b93 | |||
| 4cbc9f64c1 | |||
| 7ccff2fbad | |||
| 83554c726d | |||
| 3adaf9709f | |||
| eebb6bf424 | |||
| 51d3acd076 | |||
| 58bcd56787 | |||
| 2b00cf9d7b | |||
| 2bfe712e2c | |||
| 30258d8ad1 | |||
| f8b9fdf8b9 | |||
| 7761d01c79 | |||
| 28d0f1ab2c | |||
| eb836e0eea | |||
| 7927b9806b | |||
| 6607066961 | |||
| a1ee51c29a | |||
| 10f128fb34 | |||
| 239ec43fbd | |||
| f390a88f29 | |||
| 33f9975262 | |||
| e5ac01f8a0 | |||
| 820404bed3 | |||
| 43fa17a7f8 | |||
| 99b4b2a5c7 | |||
| d9c69fb961 | |||
| 6d69676394 | |||
| 822446b3a9 | |||
| 92608f1471 | |||
| ea6aeda368 | |||
| 50b408cf9e | |||
| 2262fccc8e | |||
| bab370ae87 | |||
| e0917733a7 | |||
| c10434b336 | |||
| beb0665a30 | |||
| 5e46806bb5 | |||
| 9ed0237da8 | |||
| bae49e6123 | |||
| 843fb29f26 | |||
| 687fc9cb7d | |||
| 56dca6e9a0 | |||
| 652980b448 | |||
| f55325042a | |||
| b2f6f372f3 | |||
| 9465eb2215 | |||
| 01d98fa7bb | |||
| dcbc2737be | |||
| 99822afb82 | |||
| 324cdda5b9 | |||
| b29635762c | |||
| 7a6ef7a58b | |||
| 7f91afdc66 | |||
| 20abad9ee5 | |||
| 655fe98a46 | |||
| 9cea09b407 | |||
| 7a5164d6fc | |||
| 861281cfe7 | |||
| 2745732731 | |||
| 4e1854a9d9 | |||
| c1de844922 |
@@ -37,12 +37,12 @@ deploy-web:
|
||||
- if: $CI_COMMIT_TAG
|
||||
when: never
|
||||
- if: $CI_COMMIT_REF_NAME == $CI_DEFAULT_BRANCH && $DEPLOY_SSH_KEY && $DEPLOY_TARGET
|
||||
variables:
|
||||
# Vite inlines VITE_* env vars at build time. Sourced from GitLab CI/CD
|
||||
# variables so values can be rotated without a code change.
|
||||
VITE_TRANSLATE_WORKER_URL: $VITE_TRANSLATE_WORKER_URL
|
||||
VITE_PLAUSIBLE_DOMAIN: $VITE_PLAUSIBLE_DOMAIN
|
||||
VITE_PLAUSIBLE_ENDPOINT: $VITE_PLAUSIBLE_ENDPOINT
|
||||
# Vite inlines VITE_* env vars at build time. These are sourced directly from
|
||||
# project-level CI/CD variables, which are already present in the job
|
||||
# environment — do NOT re-declare them here as `KEY: $KEY`. That self-reference
|
||||
# overwrites the real value with the literal string "$KEY" whenever the source
|
||||
# variable is out of scope (e.g. a Protected variable on an unprotected ref),
|
||||
# which is how "$VITE_TRANSLATE_WORKER_URL" leaked into the built app.
|
||||
script:
|
||||
# Build the web app
|
||||
- npm ci
|
||||
@@ -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,5 +1,61 @@
|
||||
# Changelog
|
||||
|
||||
## [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
|
||||
|
||||
@@ -22,7 +22,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 (approved / hidden / featured axes) via moderator-signed labels in the `agora.moderation` namespace, gated by a follow-pack moderator roster. Covers campaigns (all three axes), organizations (hidden + featured), and pledges (hidden + featured). |
|
||||
| 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. |
|
||||
| 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
|
||||
@@ -70,7 +70,7 @@ Clients filter both case variants (`agora` and `Agora`) because Nostr `t` tags a
|
||||
|
||||
#### Backward compatibility
|
||||
|
||||
Events published before this marker was adopted do not carry `t:agora` and therefore do not appear in the Agora activity feed. They remain reachable by direct link and via kind-specific directories (e.g. the moderator-curated `/campaigns/all`). Authors who wish to surface a legacy event in the feed can republish it (any edit through the Agora UI will add the marker automatically).
|
||||
Events published before this marker was adopted do not carry `t:agora` and therefore do not appear in the Agora activity feed. They remain reachable by direct link and via kind-specific directories (e.g. the moderator-curated `/campaigns`). Authors who wish to surface a legacy event in the feed can republish it (any edit through the Agora UI will add the marker automatically).
|
||||
|
||||
### Community Chat
|
||||
|
||||
@@ -282,11 +282,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 +315,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 +351,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`. |
|
||||
@@ -498,7 +496,7 @@ The `pinnedEvents` array is ordered newest pin first. Pinning an already-pinned
|
||||
|
||||
### Agora Moderation Labels
|
||||
|
||||
Agora curates which kind 33863 campaigns appear on the homepage (`/`) and on the Support directory (`/campaigns/all`), which kind 34550 organizations appear in the Featured shelf on `/communities`, and which kind 36639 pledges appear in the discovery surfaces on `/pledges`, via moderator-signed NIP-32 label events (kind 1985) in a dedicated label namespace. The labeled event itself is never modified — surfacing is purely a client-side rollup of label events.
|
||||
Agora curates which kind 33863 campaigns appear on the homepage (`/`) and on the Support directory (`/campaigns`), which kind 34550 organizations appear in the Featured shelf on `/communities`, and which kind 36639 pledges appear in the discovery surfaces on `/pledges`, via moderator-signed NIP-32 label events (kind 1985) in a dedicated label namespace. The labeled event itself is never modified — surfacing is purely a client-side rollup of label events.
|
||||
|
||||
Campaigns, organizations, and pledges share a single label namespace and a single moderator pack (Team Soapbox); the only thing distinguishing the three streams is the kind prefix on the `a` tag of each label:
|
||||
|
||||
@@ -521,27 +519,26 @@ Each label event carries the namespace twice, per NIP-32:
|
||||
|
||||
#### Label values
|
||||
|
||||
Three independent axes are defined; the newest moderator-signed label per axis per coordinate wins. **Campaigns** use all three axes (`approval`, `hide`, `featured`). **Organizations** and **pledges** use only two — `hide` and `featured` — because every Agora-tagged organization or pledge is publicly visible by default; there is no approval gate. Moderators MUST NOT publish `approved` or `unapproved` labels against kind 34550 or kind 36639 coordinates, and clients MUST ignore any such labels they receive.
|
||||
Two independent axes are defined; the newest moderator-signed label per axis per coordinate wins. All three surfaces (campaigns, organizations, pledges) use the same two axes — every Agora-tagged entity is publicly visible by default, and moderation reduces to suppressing unwanted entries (`hide`) and lifting curated ones into a featured row (`featured`).
|
||||
|
||||
| Axis | Values | Surfaces | Meaning |
|
||||
|----------|---------------------------|------------------------------------|-------------------------------------------------------------------------|
|
||||
| approval | `approved`, `unapproved` | campaigns only | `approved` allows the campaign on its discovery surfaces. `unapproved` retracts a previous approval. |
|
||||
| hide | `hidden`, `unhidden` | campaigns, organizations, pledges | `hidden` suppresses the target everywhere it would otherwise appear. `unhidden` retracts a previous hide. |
|
||||
| featured | `featured`, `unfeatured` | campaigns, organizations, pledges | `featured` places the target in a hand-picked Featured row. `unfeatured` retracts. |
|
||||
|
||||
> **Legacy `approved` / `unapproved` labels.** A previous revision of this spec defined a third axis ("approval") used only by campaigns to gate which campaigns appeared on the home page. The axis was retired once `featured` became the single positive-curation mechanism on the home page. Clients MUST ignore `approved` / `unapproved` labels and SHOULD NOT publish new ones. Existing labels in relay archives are dead data.
|
||||
|
||||
Surfacing rules (hide always wins):
|
||||
|
||||
**Campaigns**
|
||||
|
||||
- **Featured row on `/`** — iff the latest featured label is `featured` AND the latest hide label is not `hidden`. Ordered newest-`created_at`-of-`featured`-label first. Featured is independent of Approved at the protocol level; a campaign may be featured without being approved (the home page treats Featured and Approved as deduplicated bins, with Featured taking precedence).
|
||||
- **Community Campaigns grid on `/`** — iff approved, not hidden, and not featured (featured campaigns get their own row above).
|
||||
- **Discover shelf** — iff approved AND not hidden.
|
||||
- **Moderator-only "Pending"** — iff neither approved nor hidden.
|
||||
- **Moderator-only "Hidden"** — iff hidden.
|
||||
- **Featured row on `/`** — iff the latest featured label is `featured` AND the latest hide label is not `hidden`. Ordered by the featured label's effective rank (see Moderator-driven Ordering), descending.
|
||||
- **Discover shelf on `/campaigns`** — iff the latest hide label is not `hidden`. Every non-hidden campaign on the network is enumerable here; the home page's Featured row is a curated subset, not a gate.
|
||||
- **Moderator-only "Hidden"** — iff hidden. Surfaces the suppressed set so moderators can unhide.
|
||||
|
||||
**Organizations**
|
||||
|
||||
- **Featured shelf on `/communities`** — iff the latest featured label is `featured` AND the latest hide label is not `hidden`. Ordered newest-`created_at`-of-`featured`-label first.
|
||||
- **Featured shelf on `/communities`** — iff the latest featured label is `featured` AND the latest hide label is not `hidden`. Ordered by the featured label's effective rank, descending (see Moderator-driven Ordering).
|
||||
- **"My organizations" shelf on `/communities`** — intentionally ignores all moderation labels. A user's own founded, moderated, or followed organizations always render regardless of label state.
|
||||
- **Moderator-only "Needs review"** — iff `t:agora` AND not featured AND not hidden. Surfaces orgs minted through Agora's create flow that haven't been triaged into Featured or Hidden yet.
|
||||
- **Moderator-only "Hidden"** — iff hidden.
|
||||
@@ -554,6 +551,28 @@ Surfacing rules (hide always wins):
|
||||
- **Direct-URL access** — a pledge's detail page (`/<naddr>`) renders regardless of moderation state. Hidden pledges remain reachable by anyone who has the link; moderation only governs which surfaces enumerate them.
|
||||
- **Featured** — reserved for a future curated pledge shelf. The `featured` axis is defined for symmetry with campaigns/organizations, and clients MAY use it when implementing such a shelf.
|
||||
|
||||
#### Moderator-driven Ordering
|
||||
|
||||
The Featured row is sorted by the **effective rank** of the moderator's latest `featured` label per campaign coordinate, descending.
|
||||
|
||||
A label's effective rank is the numeric value of its `["rank", "<number>"]` tag if present, falling back to the label's `created_at` when no rank tag is set. Labels published before this feature existed — and any normal hide / feature actions that don't carry a rank — surface with their `created_at` as the effective rank, so newer feature actions naturally float to the top.
|
||||
|
||||
The fold rule per `(coord, axis)` is unchanged: the newest event by `created_at` wins. Encoding order in the `created_at` itself would conflict with that rule the moment a moderator tried to lower a campaign's position — the new label would have an older `created_at` than the existing one and lose the fold. The rank tag decouples sort key from event recency so reorder publishes always use `created_at = now` and the fold always picks them up.
|
||||
|
||||
A moderator MAY reorder the row by republishing the `featured` label for a campaign with a `rank` tag carrying a chosen integer. Three operations cover the common cases:
|
||||
|
||||
- **Move to top** — publish with `rank = max(freshRank, currentTopRank + 1)`, where `freshRank` is a strictly-monotonic integer the client SHOULD source from current wall-clock time at sub-second resolution (Agora uses `Date.now() * 1000`). The `max` guard handles a (rare) clock-skewed existing rank that's already above `freshRank`.
|
||||
- **Move up by one** — publish with `rank = neighborAbove.rank + 1`, where `neighborAbove` is the label sorted directly above the campaign being moved.
|
||||
- **Move down by one** — publish with `rank = neighborBelow.rank - 1`. Only the moved campaign's label is republished; the neighbor below is untouched.
|
||||
|
||||
A general "drop at index `j`" (e.g. drag-and-drop in a moderator UI) is implemented by computing the two new neighbors of the moved campaign in the rearranged list and choosing any integer rank strictly between their ranks. When the gap is too tight (`prev.rank - next.rank < 2`), clients SHOULD pick `next.rank + 1` and accept that the rendered list may briefly be off by one until the next reorder leaves a wider gap. Using a sub-second-resolution `freshRank` keeps inter-rank gaps wide enough for many midpoint inserts before any renumbering is needed.
|
||||
|
||||
The conflict model matches the rest of the moderation namespace: the newest label per `(coord, axis)` from any moderator wins. Concurrent reorders by two moderators resolve to whoever's publish lands later; clients SHOULD refetch labels after a reorder publish to surface the authoritative order.
|
||||
|
||||
Reorder labels remain valid moderation labels in every other respect. Clients that don't recognize the `rank` tag simply read the label's axis state and ignore the rank — the labels are not a separate kind, not a separate namespace, and not a new tag namespace. Non-Agora clients see exactly the same hide / feature state they always have.
|
||||
|
||||
The featured row is the only Agora surface that uses moderator-driven ordering today. The same mechanism MAY be applied to the organization or pledge featured shelves if those grow a curation UI; until then, those shelves sort by `created_at` (the legacy behavior, identical to using a missing rank tag).
|
||||
|
||||
#### Event Structure
|
||||
|
||||
```json
|
||||
@@ -562,9 +581,9 @@ Surfacing rules (hide always wins):
|
||||
"content": "",
|
||||
"tags": [
|
||||
["L", "agora.moderation"],
|
||||
["l", "approved", "agora.moderation"],
|
||||
["l", "featured", "agora.moderation"],
|
||||
["a", "33863:<author-pubkey>:<campaign-d-tag>"],
|
||||
["alt", "Campaign moderation: approved"]
|
||||
["alt", "Campaign moderation: featured"]
|
||||
]
|
||||
}
|
||||
```
|
||||
@@ -606,6 +625,26 @@ Required tags:
|
||||
- `a` referencing the target coordinate (`33863:<pubkey>:<d>` for a campaign, `34550:<pubkey>:<d>` for an organization, `36639:<pubkey>:<d>` for a pledge).
|
||||
- `alt` (NIP-31) — clients without label support will display this string. The `alt` value SHOULD identify the surface (e.g. `Campaign moderation: featured`, `Organization moderation: featured`, or `Pledge moderation: hidden`) so non-Agora clients can read it.
|
||||
|
||||
Optional tags:
|
||||
|
||||
- `rank` — single string element parsed as an integer. Used on `featured` labels to position the target within the moderator-curated Featured row; see Moderator-driven Ordering above. Labels without this tag sort by `created_at` (descending), which is the correct behavior for all non-reorder uses.
|
||||
|
||||
A label with a rank tag looks like:
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 1985,
|
||||
"content": "",
|
||||
"tags": [
|
||||
["L", "agora.moderation"],
|
||||
["l", "featured", "agora.moderation"],
|
||||
["a", "33863:<author-pubkey>:<campaign-d-tag>"],
|
||||
["rank", "1700000000123000"],
|
||||
["alt", "Campaign moderation: featured"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### Trust Model
|
||||
|
||||
Only label events authored by current members of the **Team Soapbox** follow pack are honored. The pack is a kind 39089 (NIP-51 follow pack) addressable event:
|
||||
@@ -618,8 +657,8 @@ d-tag: k4p5w0n22suf
|
||||
|
||||
The pack `p` tags are the authoritative moderator list. Clients MUST pin `authors:` on their label REQ to the pack `p` tags; events from non-pack authors MUST be ignored. This means:
|
||||
|
||||
- Self-approval is impossible unless the pack author has added you.
|
||||
- A moderator removed from the pack immediately loses moderation authority — campaigns/organizations kept alive only by their labels return to "pending" until another moderator approves them.
|
||||
- Self-promotion is impossible unless the pack author has added you.
|
||||
- A moderator removed from the pack immediately loses moderation authority — campaigns kept alive on the Featured row only by their labels fall off the row until another moderator features them.
|
||||
- The pack author (single signer) can reset the entire moderator roster by republishing the pack.
|
||||
|
||||
The same moderator set governs both campaign and organization labels. Carving out per-surface moderator subsets is out of scope; clients that need that distinction would have to introduce a second follow pack and a second label namespace.
|
||||
@@ -652,10 +691,9 @@ Step 3 — fold by `(coord, axis)`, latest-`created_at`-wins, filtering to the r
|
||||
|
||||
#### Client Behavior
|
||||
|
||||
- Clients SHOULD render approve/hide/feature controls only for users whose pubkey appears in the pack.
|
||||
- Clients MAY display "Hidden" badges on hidden campaigns/organizations when viewed by a moderator, and SHOULD NOT render them at all to non-moderators.
|
||||
- Non-moderator authors viewing the homepage SHOULD see their own pending campaigns in a separate explained section so they understand why their campaign isn't yet on the homepage. The campaign URL remains live and donatable regardless of moderation state.
|
||||
- Organization authors are not shown an equivalent "pending" surface today — organizations are visible at their NIP-19 route regardless of moderation, and the only moderation surface is the Featured shelf.
|
||||
- Clients SHOULD render hide/feature controls only for users whose pubkey appears in the pack.
|
||||
- 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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ android {
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 1
|
||||
versionName "2.14.4"
|
||||
versionName "2.8.7"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
@@ -51,6 +51,7 @@ 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')
|
||||
|
||||
@@ -19,6 +19,14 @@
|
||||
-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.** { *; }
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
#-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
@@ -60,4 +60,6 @@
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||
</manifest>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -47,6 +53,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
|
||||
|
||||
@@ -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: 5.3 KiB After Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 5.3 KiB After Width: | Height: | Size: 965 B |
|
Before Width: | Height: | Size: 5.3 KiB After Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 730 B |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 7.7 KiB After Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 7.7 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 7.7 KiB After Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 14 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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
<meta name="twitter:description" content="Power to the people." />
|
||||
<meta name="twitter:image" content="https://agora.spot/og-image.jpg" />
|
||||
|
||||
<meta http-equiv="content-security-policy" content="default-src 'none'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; frame-src 'self' https:; font-src 'self' https:; base-uri 'self'; manifest-src 'self'; connect-src 'self' blob: https: wss:; img-src 'self' data: blob: https:; media-src 'self' blob: https:">
|
||||
<meta http-equiv="content-security-policy" content="default-src 'none'; script-src 'self' 'wasm-unsafe-eval'; worker-src 'self' blob:; child-src 'self' blob:; style-src 'self' 'unsafe-inline'; frame-src 'self' https:; font-src 'self' https:; base-uri 'self'; manifest-src 'self'; connect-src 'self' blob: https: wss:; img-src 'self' data: blob: https:; media-src 'self' blob: https:">
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png">
|
||||
|
||||
@@ -323,7 +323,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.14.4;
|
||||
MARKETING_VERSION = 2.8.7;
|
||||
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.7;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = spot.agora.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||
|
||||
|
Before Width: | Height: | Size: 291 KiB After Width: | Height: | Size: 99 KiB |
@@ -50,7 +50,7 @@
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>Agora needs access to your photo library to upload images to your posts and profile.</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>Agora needs camera access to take photos and videos for your posts.</string>
|
||||
<string>Agora needs camera access to take photos and videos for your posts, and to scan QR codes when sending Bitcoin.</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>Agora needs access to your microphone to record voice messages.</string>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "agora",
|
||||
"version": "2.8.0",
|
||||
"version": "2.8.6",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "agora",
|
||||
"version": "2.8.0",
|
||||
"version": "2.8.6",
|
||||
"dependencies": {
|
||||
"@breeztech/breez-sdk-spark": "^0.10.0",
|
||||
"@capacitor/app": "^8.0.0",
|
||||
@@ -52,8 +52,8 @@
|
||||
"@milkdown/utils": "^7.20.0",
|
||||
"@noble/curves": "^2.2.0",
|
||||
"@noble/hashes": "^1.8.0",
|
||||
"@nostrify/nostrify": "^0.52.0",
|
||||
"@nostrify/react": "^0.6.0",
|
||||
"@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",
|
||||
@@ -102,9 +102,9 @@
|
||||
"i18next": "^26.0.5",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"idb": "^8.0.3",
|
||||
"iso-3166": "^4.4.0",
|
||||
"lucide-react": "^1.8.0",
|
||||
"nostr-tools": "^2.13.0",
|
||||
"qr-scanner": "^1.4.2",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.2.4",
|
||||
"react-blurhash": "^0.3.0",
|
||||
@@ -148,6 +148,7 @@
|
||||
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.9",
|
||||
"globals": "^15.9.0",
|
||||
"iso-3166": "^4.4.0",
|
||||
"jsdom": "^26.1.0",
|
||||
"postcss": "^8.4.47",
|
||||
"rollup-plugin-visualizer": "^7.0.1",
|
||||
@@ -195,6 +196,44 @@
|
||||
"lru-cache": "^10.4.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-crypto/crc32": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz",
|
||||
"integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-crypto/util": "^5.2.0",
|
||||
"@aws-sdk/types": "^3.222.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-crypto/util": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz",
|
||||
"integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/types": "^3.222.0",
|
||||
"@smithy/util-utf8": "^2.0.0",
|
||||
"tslib": "^2.6.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/types": {
|
||||
"version": "3.973.9",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.9.tgz",
|
||||
"integrity": "sha512-kuBfgQVdcz5Bmapc4A13YbpVw/pXkesfhetcFYwbntqas8sF41OHyd4o28+/TG2ZQdHBsv90Lsu5y6oitvYCdg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@smithy/types": "^4.14.2",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
||||
@@ -2467,9 +2506,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@nostrify/nostrify": {
|
||||
"version": "0.52.0",
|
||||
"resolved": "https://registry.npmjs.org/@nostrify/nostrify/-/nostrify-0.52.0.tgz",
|
||||
"integrity": "sha512-x+gc8rxJ4C+mnoFgd4Zzi0JnXUz0acQA69nKqR0fnWhpc/KiQosgIILfaNUTWkecTPJ92iazT4Es+TrUUSFcRg==",
|
||||
"version": "0.52.2",
|
||||
"resolved": "https://registry.npmjs.org/@nostrify/nostrify/-/nostrify-0.52.2.tgz",
|
||||
"integrity": "sha512-X4pteBW9p2sVhBX9Dxt7Wf+beJYI7ophfEopcNmaTipNdj/u1LeS5ufze2fKozTvje53s4MoK7+DkMpRtFSKDg==",
|
||||
"dependencies": {
|
||||
"@nostrify/types": "0.37.0",
|
||||
"@scure/base": "^2.0.0",
|
||||
@@ -2484,18 +2523,18 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@nostrify/nostrify/node_modules/@scure/base": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz",
|
||||
"integrity": "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==",
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.2.0.tgz",
|
||||
"integrity": "sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@nostrify/nostrify/node_modules/@types/node": {
|
||||
"version": "24.12.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz",
|
||||
"integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==",
|
||||
"version": "24.12.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.4.tgz",
|
||||
"integrity": "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.16.0"
|
||||
@@ -2508,11 +2547,11 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@nostrify/react": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@nostrify/react/-/react-0.6.0.tgz",
|
||||
"integrity": "sha512-6vjF5UagAW5QRpxAu/of9lyI7837wwoyX/NLGQbEs6fcMQXjTo/m7wUBPipoj0E460QvyNXff5O8Byn72enWbQ==",
|
||||
"version": "0.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@nostrify/react/-/react-0.6.2.tgz",
|
||||
"integrity": "sha512-D7SXjhEQ74Gd3aEjlG4FOzrDZ/uPMb3LgWwGmZg48F8noRWKAUjDBS9i7d3J6lShPBydw/BLg7Yhue2GValAhg==",
|
||||
"dependencies": {
|
||||
"@nostrify/nostrify": "0.52.0",
|
||||
"@nostrify/nostrify": "0.52.2",
|
||||
"@nostrify/types": "0.37.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
@@ -5831,10 +5870,36 @@
|
||||
"react": "^16.14.0 || 17.x || 18.x || 19.x"
|
||||
}
|
||||
},
|
||||
"node_modules/@smithy/core": {
|
||||
"version": "3.24.6",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.24.6.tgz",
|
||||
"integrity": "sha512-wBXDRup6UU97VKyaiRo8AssnfStPtG0oAAfpq/bC0a1YYau8pM86YB4kM6ccoVi1mS8l/UHbn9oDM+7uozr/ug==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-crypto/crc32": "5.2.0",
|
||||
"@smithy/types": "^4.14.3",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@smithy/is-array-buffer": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.2.2.tgz",
|
||||
"integrity": "sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow==",
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz",
|
||||
"integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@smithy/types": {
|
||||
"version": "4.14.3",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.3.tgz",
|
||||
"integrity": "sha512-YupL0ZWmFtJexUN2cHzkvvF/b9pKrtAIfT1o7/oY/Ppu8IYeZ+lDPM5vZdQJaSeA132dJCqojjGC9NhXeF71VQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "^2.6.2"
|
||||
@@ -5844,13 +5909,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@smithy/util-base64": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.3.2.tgz",
|
||||
"integrity": "sha512-XRH6b0H/5A3SgblmMa5ErXQ2XKhfbQB+Fm/oyLZ2O2kCUrwgg55bU0RekmzAhuwOjA9qdN5VU2BprOvGGUkOOQ==",
|
||||
"version": "4.4.6",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.4.6.tgz",
|
||||
"integrity": "sha512-V6ApAGvCQnb7Wy1Sy60AQc+7UOEaNQxvAXBLdMi5Zzm66cmX0srvfAxDmg7BGuJ+9H9ez0PPWS/AeFgWxwGavA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@smithy/util-buffer-from": "^4.2.2",
|
||||
"@smithy/util-utf8": "^4.2.2",
|
||||
"@smithy/core": "^3.24.6",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
@@ -5858,24 +5922,25 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@smithy/util-buffer-from": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.2.2.tgz",
|
||||
"integrity": "sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q==",
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz",
|
||||
"integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@smithy/is-array-buffer": "^4.2.2",
|
||||
"@smithy/is-array-buffer": "^2.2.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@smithy/util-hex-encoding": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.2.tgz",
|
||||
"integrity": "sha512-Qcz3W5vuHK4sLQdyT93k/rfrUwdJ8/HZ+nMUOyGdpeGA1Wxt65zYwi3oEl9kOM+RswvYq90fzkNDahPS8K0OIg==",
|
||||
"version": "4.3.6",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.3.6.tgz",
|
||||
"integrity": "sha512-ooo5MQdstAtIlgS0bchoMkVsQ3x1wLLPtFilpeIV8wVtpwZYY8PoSdlvR79+yw0aJU9hjd8stKsmzIxrmAQ6fw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@smithy/core": "^3.24.6",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
@@ -5883,16 +5948,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@smithy/util-utf8": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.2.2.tgz",
|
||||
"integrity": "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw==",
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz",
|
||||
"integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@smithy/util-buffer-from": "^4.2.2",
|
||||
"@smithy/util-buffer-from": "^2.2.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@standard-schema/utils": {
|
||||
@@ -6226,6 +6291,12 @@
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/offscreencanvas": {
|
||||
"version": "2019.7.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.7.3.tgz",
|
||||
"integrity": "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/qrcode": {
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.5.tgz",
|
||||
@@ -9326,6 +9397,7 @@
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmjs.org/iso-3166/-/iso-3166-4.4.0.tgz",
|
||||
"integrity": "sha512-I6ylkNQgxVh7cYADMUJpqBUdremGvyGZkDRSk9Cdic/ITBUemsllQnUeRpz7yDKyfgAXI9oPa5A9dia+7IXLqw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
@@ -11997,6 +12069,15 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/qr-scanner": {
|
||||
"version": "1.4.2",
|
||||
"resolved": "https://registry.npmjs.org/qr-scanner/-/qr-scanner-1.4.2.tgz",
|
||||
"integrity": "sha512-kV1yQUe2FENvn59tMZW6mOVfpq9mGxGf8l6+EGaXUOd4RBOLg7tRC83OrirM5AtDvZRpdjdlXURsHreAOSPOUw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/offscreencanvas": "^2019.6.4"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "agora",
|
||||
"private": true,
|
||||
"version": "2.8.0",
|
||||
"version": "2.8.7",
|
||||
"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.0",
|
||||
"@nostrify/react": "^0.6.0",
|
||||
"@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",
|
||||
@@ -109,9 +109,9 @@
|
||||
"i18next": "^26.0.5",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"idb": "^8.0.3",
|
||||
"iso-3166": "^4.4.0",
|
||||
"lucide-react": "^1.8.0",
|
||||
"nostr-tools": "^2.13.0",
|
||||
"qr-scanner": "^1.4.2",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.2.4",
|
||||
"react-blurhash": "^0.3.0",
|
||||
@@ -155,6 +155,7 @@
|
||||
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.9",
|
||||
"globals": "^15.9.0",
|
||||
"iso-3166": "^4.4.0",
|
||||
"jsdom": "^26.1.0",
|
||||
"postcss": "^8.4.47",
|
||||
"rollup-plugin-visualizer": "^7.0.1",
|
||||
|
||||
@@ -1,5 +1,61 @@
|
||||
# Changelog
|
||||
|
||||
## [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: 13 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 226 KiB After Width: | Height: | Size: 40 KiB |
@@ -0,0 +1,39 @@
|
||||
// Generate src/lib/subdivisionCodes.ts — the authoritative list of ISO 3166-2
|
||||
// subdivision codes, extracted from the `iso-3166` package.
|
||||
//
|
||||
// We ship only the code strings (~42 KB) instead of importing the full
|
||||
// `iso-3166` dataset (~244 KB of objects with names, parents, and tree
|
||||
// structure) into the critical-path bundle. The only thing the runtime needs
|
||||
// these for is validating that a `CC-XX` code is a real subdivision
|
||||
// (see src/lib/countries.ts `isValidSubdivisionCode`).
|
||||
//
|
||||
// Run with: node scripts/gen-subdivision-codes.mjs
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { iso31662 } from 'iso-3166';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = path.resolve(__dirname, '..');
|
||||
const OUTPUT = path.join(REPO_ROOT, 'src/lib/subdivisionCodes.ts');
|
||||
|
||||
const codes = [...new Set(iso31662.map((s) => s.code))].sort();
|
||||
|
||||
const header = `// AUTO-GENERATED — do not edit by hand.
|
||||
//
|
||||
// The authoritative list of ISO 3166-2 subdivision codes, extracted from the
|
||||
// \`iso-3166\` package at build time. We ship only the code strings (~42 KB)
|
||||
// instead of importing the full \`iso-3166\` dataset (~244 KB of objects with
|
||||
// names, parents, and tree structure) into the critical-path bundle, since
|
||||
// the only thing the runtime needs these for is validating that a \`CC-XX\`
|
||||
// code is a real subdivision.
|
||||
//
|
||||
// Regenerate with: node scripts/gen-subdivision-codes.mjs
|
||||
|
||||
`;
|
||||
|
||||
const body = `export const SUBDIVISION_CODES: readonly string[] = ${JSON.stringify(codes)};\n`;
|
||||
|
||||
fs.writeFileSync(OUTPUT, header + body);
|
||||
console.log(`Wrote ${path.relative(REPO_ROOT, OUTPUT)} (${codes.length} codes)`);
|
||||
@@ -42,14 +42,17 @@ 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..."
|
||||
|
||||
@@ -82,23 +85,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 +115,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 +141,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 +153,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 +167,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)"
|
||||
|
||||
@@ -18,6 +18,7 @@ import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { useNsecPasteGuard } from "@/hooks/useNsecPasteGuard";
|
||||
import type { AppConfig } from "@/contexts/AppContext";
|
||||
import { NWCProvider } from "@/contexts/NWCContext";
|
||||
import { OnboardingProvider } from "@/contexts/OnboardingProvider";
|
||||
import { BuildConfigSchema, type BuildConfig } from "@/lib/schemas";
|
||||
import { secureStorage } from "@/lib/secureStorage";
|
||||
import AppRouter from "./AppRouter";
|
||||
@@ -161,6 +162,7 @@ const hardcodedConfig: AppConfig = {
|
||||
aiApiKey: '',
|
||||
aiModel: 'google/gemma-4-26b',
|
||||
aiSystemPrompt: '',
|
||||
translateWorkerUrl: import.meta.env.VITE_TRANSLATE_WORKER_URL || '',
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -207,9 +209,11 @@ export function App() {
|
||||
<NativeNotifications />
|
||||
|
||||
<NWCProvider>
|
||||
<TooltipProvider>
|
||||
<AppRouter />
|
||||
</TooltipProvider>
|
||||
<OnboardingProvider>
|
||||
<TooltipProvider>
|
||||
<AppRouter />
|
||||
</TooltipProvider>
|
||||
</OnboardingProvider>
|
||||
</NWCProvider>
|
||||
</NostrProvider>
|
||||
</NostrLoginProvider>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { Toaster } from "./components/ui/toaster";
|
||||
import { TopNav } from "./components/TopNav";
|
||||
import { OnboardingGate } from "./components/OnboardingGate";
|
||||
import { ScrollToTop } from "./components/ScrollToTop";
|
||||
import { VersionCheck } from "./components/VersionCheck";
|
||||
import { useCurrentUser } from "./hooks/useCurrentUser";
|
||||
@@ -20,6 +21,7 @@ import NotFound from "./pages/NotFound";
|
||||
const CampaignsPage = lazy(() => import("./pages/CampaignsPage").then(m => ({ default: m.CampaignsPage })));
|
||||
const CreateCampaignPage = lazy(() => import("./pages/CreateCampaignPage").then(m => ({ default: m.CreateCampaignPage })));
|
||||
const AllCampaignsPage = lazy(() => import("./pages/AllCampaignsPage").then(m => ({ default: m.AllCampaignsPage })));
|
||||
const CampaignListDetailPage = lazy(() => import("./pages/CampaignListDetailPage").then(m => ({ default: m.CampaignListDetailPage })));
|
||||
|
||||
// All other pages: code-split via React.lazy
|
||||
const ActionsPage = lazy(() => import("./pages/ActionsPage"));
|
||||
@@ -51,6 +53,7 @@ const SearchPage = lazy(() => import("./pages/SearchPage").then(m => ({ default:
|
||||
const SettingsPage = lazy(() => import("./pages/SettingsPage").then(m => ({ default: m.SettingsPage })));
|
||||
const WalletPage = lazy(() => import("./pages/WalletPage").then(m => ({ default: m.WalletPage })));
|
||||
const WalletMigrateV1Page = lazy(() => import("./pages/WalletMigrateV1Page").then(m => ({ default: m.WalletMigrateV1Page })));
|
||||
const WalletDoubleTweakFixPage = lazy(() => import("./pages/WalletDoubleTweakFixPage").then(m => ({ default: m.WalletDoubleTweakFixPage })));
|
||||
const WalletRecoveryPage = lazy(() => import("./pages/WalletRecoveryPage").then(m => ({ default: m.WalletRecoveryPage })));
|
||||
const WalletSettingsPage = lazy(() => import("./pages/WalletSettingsPage").then(m => ({ default: m.WalletSettingsPage })));
|
||||
const LegacyWalletRecoveryPage = lazy(() => import("./pages/LegacyWalletRecoveryPage").then(m => ({ default: m.LegacyWalletRecoveryPage })));
|
||||
@@ -130,7 +133,8 @@ export function AppRouter() {
|
||||
<Toaster />
|
||||
<VersionCheck />
|
||||
<ScrollToTop />
|
||||
<Routes>
|
||||
<OnboardingGate>
|
||||
<Routes>
|
||||
{/* Narrow layout — `max-w-3xl` center column. The default for
|
||||
form/prose-style pages. */}
|
||||
<Route element={<FundraiserLayout narrow />}>
|
||||
@@ -163,6 +167,7 @@ export function AppRouter() {
|
||||
<Route path="/wallet/settings/legacy" element={<Navigate to="/wallet/legacy" replace />} />
|
||||
<Route path="/wallet/recovery" element={<WalletRecoveryPage />} />
|
||||
<Route path="/wallet/migrate-v1" element={<WalletMigrateV1Page />} />
|
||||
<Route path="/wallet/double-tweak-fix" element={<WalletDoubleTweakFixPage />} />
|
||||
<Route path="/bitcoin" element={<Navigate to="/wallet" replace />} />
|
||||
{/* Legacy /help routes redirect to /about so existing links keep
|
||||
working. The About page and the two guides themselves live
|
||||
@@ -183,9 +188,13 @@ export function AppRouter() {
|
||||
constraints. */}
|
||||
<Route element={<FundraiserLayout narrow={false} />}>
|
||||
<Route path="/" element={<CampaignsPage />} />
|
||||
<Route path="/campaigns" element={<Navigate to="/" replace />} />
|
||||
<Route path="/campaigns" element={<AllCampaignsPage />} />
|
||||
<Route path="/campaigns/new" element={<CreateCampaignPage />} />
|
||||
<Route path="/campaigns/all" element={<AllCampaignsPage />} />
|
||||
<Route path="/campaigns/lists/:slug" element={<CampaignListDetailPage />} />
|
||||
{/* Legacy URL: the all-campaigns directory lived at
|
||||
`/campaigns/all` for a while. Keep it as a redirect so
|
||||
external links and bookmarks still resolve. */}
|
||||
<Route path="/campaigns/all" element={<Navigate to="/campaigns" replace />} />
|
||||
<Route path="/groups" element={<CommunitiesPage />} />
|
||||
<Route path="/groups/new" element={<CreateCommunityPage />} />
|
||||
<Route path="/events/new" element={<CreateEventPage />} />
|
||||
@@ -207,7 +216,8 @@ export function AppRouter() {
|
||||
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</Routes>
|
||||
</OnboardingGate>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import {
|
||||
Check,
|
||||
Link as LinkIcon,
|
||||
Loader2,
|
||||
MoreHorizontal,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { ModerationMenuItems } from '@/components/moderation';
|
||||
import { useCampaignModerators } from '@/hooks/useCampaignModerators';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { useShareOrigin } from '@/hooks/useShareOrigin';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { getPledgeCoord } from '@/lib/pledges';
|
||||
import type { Action } from '@/hooks/useActions';
|
||||
|
||||
/**
|
||||
* Per-card kebab menu for pledges. Surfaces:
|
||||
* • Delete (owner only) — NIP-09 with both `e` and `a` tags so
|
||||
* relays that ignore a-tag-only deletions still drop the event.
|
||||
* • Copy link — naddr1 URL on the current share origin.
|
||||
* • Moderation actions (mods only) — hide / feature, under a
|
||||
* separator that only renders when the viewer is a moderator.
|
||||
*
|
||||
* Lives outside `ActionsPage` so both the page and the reusable
|
||||
* `PledgesDiscoverySection` can pin it to the card's `topRight` slot
|
||||
* without duplicating the logic.
|
||||
*/
|
||||
export function ActionShareMenu({
|
||||
action,
|
||||
displayTitle,
|
||||
}: {
|
||||
action: Action;
|
||||
displayTitle: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useCurrentUser();
|
||||
const { data: moderators } = useCampaignModerators();
|
||||
const { mutateAsync: createEvent } = useNostrPublish();
|
||||
const { toast } = useToast();
|
||||
const shareOrigin = useShareOrigin();
|
||||
const queryClient = useQueryClient();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
const isOwner = user?.pubkey === action.pubkey;
|
||||
// Moderator gate is identical to the one in `ModerationMenuItems`,
|
||||
// duplicated here so we can decide whether to render the trailing
|
||||
// separator that introduces the moderator section.
|
||||
// `ModerationMenuItems` returns `null` for non-mods, so without
|
||||
// this check we'd render an orphaned separator at the bottom of
|
||||
// the dropdown.
|
||||
const isMod = !!user && !!moderators && moderators.includes(user.pubkey);
|
||||
|
||||
const naddr = nip19.naddrEncode({
|
||||
kind: 36639,
|
||||
pubkey: action.pubkey,
|
||||
identifier: action.id,
|
||||
});
|
||||
|
||||
const actionUrl = `${shareOrigin}/${naddr}`;
|
||||
|
||||
const handleCopyLink = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(actionUrl);
|
||||
setCopied(true);
|
||||
toast({ title: t('pledges.card.linkCopied') });
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (error) {
|
||||
console.error('Failed to copy link:', error);
|
||||
toast({ title: t('pledges.card.linkCopyFailed'), variant: 'destructive' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!user || !isOwner) return;
|
||||
|
||||
const confirmed = window.confirm(t('pledges.card.confirmDelete'));
|
||||
if (!confirmed) return;
|
||||
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
// NIP-09 deletion. Include both 'e' and 'a' tags — some relays don't
|
||||
// honour a-tag-only deletions for addressable events.
|
||||
await createEvent({
|
||||
kind: 5,
|
||||
content: t('pledges.card.deletedContent'),
|
||||
tags: [
|
||||
['e', action.event.id],
|
||||
['a', getPledgeCoord(action)],
|
||||
],
|
||||
});
|
||||
// Extract any organization `A` tag the pledge was associated with so
|
||||
// the org's activity shelf and community feeds refresh too.
|
||||
const orgATag = action.event.tags.find(([n]) => n === 'A')?.[1];
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['agora-actions'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['agora-action'] }),
|
||||
...(orgATag
|
||||
? [
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['organization-activity', orgATag],
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['community-actions', orgATag],
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (q) => {
|
||||
const [root, aTagsKey] = q.queryKey;
|
||||
return (
|
||||
root === 'community-activity-feed' &&
|
||||
typeof aTagsKey === 'string' &&
|
||||
aTagsKey.split(',').includes(orgATag)
|
||||
);
|
||||
},
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
]);
|
||||
toast({ title: t('pledges.card.deleted') });
|
||||
} catch (error) {
|
||||
console.error('Failed to delete pledge:', error);
|
||||
toast({ title: t('pledges.card.deleteFailed'), variant: 'destructive' });
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild onClick={(e) => e.preventDefault()}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={t('pledges.card.actionsAriaLabel')}
|
||||
className="h-8 w-8 bg-background/80 backdrop-blur text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" onClick={(e) => e.stopPropagation()}>
|
||||
{isOwner && (
|
||||
<>
|
||||
<DropdownMenuItem onClick={handleDelete} disabled={isDeleting}>
|
||||
{isDeleting ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
{t('pledges.card.deletePledge')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuItem onClick={handleCopyLink}>
|
||||
{copied ? (
|
||||
<Check className="h-4 w-4 mr-2 text-primary" />
|
||||
) : (
|
||||
<LinkIcon className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
{t('pledges.card.copyLink')}
|
||||
</DropdownMenuItem>
|
||||
{/* Moderator actions appear under a separator when the viewer
|
||||
is a Team Soapbox moderator. `ModerationMenuItems` returns
|
||||
null for non-mods, so we gate the trailing separator on
|
||||
the same `isMod` check to avoid an orphan separator at
|
||||
the bottom of non-mod dropdowns. */}
|
||||
{isMod && <DropdownMenuSeparator />}
|
||||
<ModerationMenuItems
|
||||
coord={getPledgeCoord(action)}
|
||||
entityTitle={displayTitle}
|
||||
surface="pledge"
|
||||
axes={['hide', 'featured']}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -126,7 +126,7 @@ export function AddMembersDialog({ open, onOpenChange, listId, listPubkeys }: Ad
|
||||
<div ref={listRef} className="overflow-y-auto flex-1">
|
||||
{!query.trim() ? (
|
||||
<div className="py-12 text-center text-muted-foreground text-sm">
|
||||
<UserPlus className="size-8 mx-auto mb-2 opacity-50" />
|
||||
<UserPlus className="size-8 mx-auto mb-2" />
|
||||
Search for people to add to this list.
|
||||
</div>
|
||||
) : isLoading && !searchResults ? (
|
||||
|
||||
@@ -143,7 +143,7 @@ export function AddToListDialog({ pubkey, displayName, open, onOpenChange }: Add
|
||||
</div>
|
||||
) : !hasAny ? (
|
||||
<div className="py-8 px-4 text-center text-sm text-muted-foreground">
|
||||
<List className="size-8 mx-auto mb-2 text-muted-foreground/40" />
|
||||
<List className="size-8 mx-auto mb-2 text-muted-foreground" />
|
||||
No lists or packs yet. Create one below.
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -17,6 +17,9 @@ import { DEFAULT_SYSTEM_PROMPT_TEMPLATE } from '@/lib/aiChatSystemPrompt';
|
||||
const DEFAULT_AI_BASE_URL = 'https://ai.shakespeare.diy/v1';
|
||||
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 || '';
|
||||
|
||||
/** The build-time default DSN from the environment variable. */
|
||||
const DEFAULT_SENTRY_DSN = import.meta.env.VITE_SENTRY_DSN || '';
|
||||
|
||||
@@ -34,6 +37,7 @@ export function AdvancedSettings() {
|
||||
const [faviconUrl, setFaviconUrl] = useState(config.faviconUrl);
|
||||
const [linkPreviewUrl, setLinkPreviewUrl] = useState(config.linkPreviewUrl);
|
||||
const [corsProxy, setCorsProxy] = useState(config.corsProxy);
|
||||
const [translateWorkerUrl, setTranslateWorkerUrl] = useState(config.translateWorkerUrl);
|
||||
const [sentryDsn, setSentryDsn] = useState(config.sentryDsn);
|
||||
const [baseUrlDraft, setBaseUrlDraft] = useState(config.aiBaseURL);
|
||||
const [apiKeyDraft, setApiKeyDraft] = useState(config.aiApiKey);
|
||||
@@ -399,6 +403,40 @@ export function AdvancedSettings() {
|
||||
<span className="font-mono break-all">https://proxy.shakespeare.diy/?url={'{href}'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Translation Worker URL */}
|
||||
<div>
|
||||
<Label htmlFor="translate-worker-url" className="text-sm font-medium">
|
||||
Translation Worker URL
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground mt-1 mb-2">
|
||||
DeepL-backed worker endpoint used by the "Translate" button on notes. Receives a POST with the text and target language.
|
||||
</p>
|
||||
<Input
|
||||
id="translate-worker-url"
|
||||
type="url"
|
||||
value={translateWorkerUrl}
|
||||
onChange={(e) => setTranslateWorkerUrl(e.target.value)}
|
||||
onBlur={async () => {
|
||||
const trimmed = translateWorkerUrl.trim();
|
||||
if (trimmed && trimmed !== config.translateWorkerUrl) {
|
||||
updateConfig(() => ({ translateWorkerUrl: trimmed }));
|
||||
if (user) await updateSettings.mutateAsync({ translateWorkerUrl: trimmed });
|
||||
toast({ title: 'Translation worker URL updated' });
|
||||
}
|
||||
}}
|
||||
placeholder={DEFAULT_TRANSLATE_WORKER_URL || 'https://example.workers.dev'}
|
||||
className="font-mono text-base md:text-sm"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
{DEFAULT_TRANSLATE_WORKER_URL && (
|
||||
<div className="text-xs text-muted-foreground mt-2">
|
||||
<span className="font-medium">Default: </span>
|
||||
<span className="font-mono break-all">{DEFAULT_TRANSLATE_WORKER_URL}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
@@ -17,17 +17,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,
|
||||
@@ -76,7 +65,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;
|
||||
@@ -113,16 +101,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>
|
||||
)}
|
||||
|
||||
@@ -159,7 +159,7 @@ export function AppHandlerContent({ event, compact }: AppHandlerContentProps) {
|
||||
/>
|
||||
) : (
|
||||
<div className="size-14 rounded-xl bg-primary/10 flex items-center justify-center shrink-0 border-3 border-background shadow-sm">
|
||||
<Package className="size-6 text-primary/50" />
|
||||
<Package className="size-6 text-primary" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -264,7 +264,7 @@ export function AppHandlerContent({ event, compact }: AppHandlerContentProps) {
|
||||
/>
|
||||
) : (
|
||||
<div className="size-20 rounded-2xl bg-primary/10 flex items-center justify-center shrink-0 border-4 border-background shadow-sm">
|
||||
<Package className="size-8 text-primary/50" />
|
||||
<Package className="size-8 text-primary" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -333,4 +333,3 @@ export function AppHandlerContent({ event, compact }: AppHandlerContentProps) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -72,6 +72,12 @@ export function AppProvider(props: AppProviderProps) {
|
||||
const config = {
|
||||
...defaultConfig,
|
||||
...rawConfig,
|
||||
// An empty persisted translateWorkerUrl must not shadow the build-time
|
||||
// default — fall back to the default so the Translate button stays
|
||||
// available. (Earlier builds could persist "" by merely opening Settings.)
|
||||
translateWorkerUrl: rawConfig.translateWorkerUrl?.trim()
|
||||
? rawConfig.translateWorkerUrl
|
||||
: defaultConfig.translateWorkerUrl,
|
||||
// Deep-merge feedSettings so new keys added to the default are visible
|
||||
// even for existing users who have an older feedSettings in localStorage.
|
||||
feedSettings: { ...defaultConfig.feedSettings, ...rawConfig.feedSettings },
|
||||
|
||||
@@ -88,7 +88,7 @@ export function MusicTrackContent({ event }: { event: NostrEvent }) {
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative flex items-center justify-center bg-gradient-to-br from-primary/10 via-primary/5 to-transparent h-[140px] cursor-pointer" onClick={handlePlay}>
|
||||
<Music className="size-10 text-primary/20" />
|
||||
<Music className="size-10 text-primary" />
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<PlayButton isPlaying={player.isPlaying} isActive={isNowPlaying} onClick={handlePlay} size="lg" />
|
||||
</div>
|
||||
@@ -129,7 +129,7 @@ export function MusicPlaylistContent({ event }: { event: NostrEvent }) {
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center bg-gradient-to-br from-primary/10 via-primary/5 to-transparent h-[100px]">
|
||||
<ListMusic className="size-10 text-primary/20" />
|
||||
<ListMusic className="size-10 text-primary" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -193,7 +193,7 @@ export function PodcastEpisodeContent({ event }: { event: NostrEvent }) {
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative flex items-center justify-center bg-gradient-to-br from-primary/10 via-primary/5 to-transparent h-[140px] cursor-pointer" onClick={handlePlay}>
|
||||
<Podcast className="size-10 text-primary/20" />
|
||||
<Podcast className="size-10 text-primary" />
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<PlayButton isPlaying={player.isPlaying} isActive={isNowPlaying} onClick={handlePlay} size="lg" />
|
||||
</div>
|
||||
@@ -252,7 +252,7 @@ export function PodcastTrailerContent({ event }: { event: NostrEvent }) {
|
||||
>
|
||||
{/* Compact header — clicking anywhere here plays/pauses */}
|
||||
<div className="flex items-center justify-center bg-gradient-to-br from-primary/10 via-primary/5 to-transparent h-[100px] relative cursor-pointer" onClick={handlePlay}>
|
||||
<Podcast className="size-8 text-primary/20" />
|
||||
<Podcast className="size-8 text-primary" />
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<PlayButton isPlaying={player.isPlaying} isActive={isNowPlaying} onClick={handlePlay} size="lg" />
|
||||
</div>
|
||||
|
||||
@@ -128,7 +128,7 @@ export function AwardBadgeDialog({ open, onOpenChange, badgeATag, badgeName }: A
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="max-w-[100px] truncate">{name}</span>
|
||||
<X className="size-3 opacity-60" />
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
@@ -154,12 +154,12 @@ export function AwardBadgeDialog({ open, onOpenChange, badgeATag, badgeName }: A
|
||||
</div>
|
||||
) : query.trim().length > 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-10 text-muted-foreground text-sm">
|
||||
<Search className="size-8 mb-2 opacity-30" />
|
||||
<Search className="size-8 mb-2" />
|
||||
No users found
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-10 text-muted-foreground text-sm">
|
||||
<Search className="size-8 mb-2 opacity-30" />
|
||||
<Search className="size-8 mb-2" />
|
||||
Search to find users
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -152,7 +152,7 @@ function BadgeShowcaseFallback({
|
||||
const body = (
|
||||
<div className="mt-3 rounded-2xl border border-dashed border-border py-10 px-6 flex flex-col items-center gap-3">
|
||||
<div className="size-20 rounded-2xl bg-gradient-to-br from-primary/10 via-primary/5 to-transparent flex items-center justify-center">
|
||||
<Award className="size-8 text-primary/40" />
|
||||
<Award className="size-8 text-primary" />
|
||||
</div>
|
||||
{name ? (
|
||||
<p className="text-sm font-semibold text-center">{name}</p>
|
||||
|
||||
@@ -155,7 +155,7 @@ function BadgeImageTilt({ heroImage, badgeName }: { heroImage?: string; badgeNam
|
||||
/>
|
||||
) : (
|
||||
<div className="size-28 rounded-2xl bg-gradient-to-br from-primary/10 via-primary/5 to-transparent flex items-center justify-center">
|
||||
<Award className="size-12 text-primary/30" />
|
||||
<Award className="size-12 text-primary" />
|
||||
</div>
|
||||
)}
|
||||
{/* Specular glare overlay */}
|
||||
|
||||
@@ -131,7 +131,7 @@ export function BadgeDetailContent({ event }: { event: NostrEvent }) {
|
||||
<BadgeHero heroImage={heroImage} badgeName={badge.name} />
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-[180px]">
|
||||
<Award className="size-16 text-primary/20" />
|
||||
<Award className="size-16 text-primary" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -286,7 +286,7 @@ function AwardedToTab({ awardedPubkeys, previewPubkeys, membersMap, membersLoadi
|
||||
if (awardedPubkeys.length === 0) {
|
||||
return (
|
||||
<div className="py-16 flex flex-col items-center gap-3 text-center px-8">
|
||||
<Users className="size-8 text-muted-foreground/30" />
|
||||
<Users className="size-8 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">No one has been awarded this badge yet.</p>
|
||||
</div>
|
||||
);
|
||||
@@ -387,7 +387,7 @@ function HoldersFeedTab({ awardedPubkeys, awardsLoading }: {
|
||||
if (awardedPubkeys.length === 0) {
|
||||
return (
|
||||
<div className="py-16 flex flex-col items-center gap-3 text-center px-8">
|
||||
<Newspaper className="size-8 text-muted-foreground/30" />
|
||||
<Newspaper className="size-8 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">No one has been awarded this badge yet, so there's no feed to show.</p>
|
||||
</div>
|
||||
);
|
||||
@@ -396,7 +396,7 @@ function HoldersFeedTab({ awardedPubkeys, awardsLoading }: {
|
||||
if (feedEvents.length === 0) {
|
||||
return (
|
||||
<div className="py-16 flex flex-col items-center gap-3 text-center px-8">
|
||||
<Newspaper className="size-8 text-muted-foreground/30" />
|
||||
<Newspaper className="size-8 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">No posts from badge holders yet.</p>
|
||||
</div>
|
||||
);
|
||||
@@ -436,7 +436,7 @@ function CommentsTab({ event, orderedReplies, commentsLoading }: {
|
||||
<FlatThreadedReplyList replies={orderedReplies} />
|
||||
) : (
|
||||
<div className="py-16 flex flex-col items-center gap-3 text-center px-8">
|
||||
<MessageCircle className="size-8 text-muted-foreground/30" />
|
||||
<MessageCircle className="size-8 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">No comments yet. Be the first to comment on this badge.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -84,7 +84,7 @@ export function BadgeThumbnail({ badge, size = 48, className }: BadgeThumbnailPr
|
||||
)}
|
||||
style={{ width: size, height: size }}
|
||||
>
|
||||
<Award className="text-primary/30" style={{ width: size * 0.5, height: size * 0.5 }} />
|
||||
<Award className="text-primary" style={{ width: size * 0.5, height: size * 0.5 }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,641 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
ArrowDownLeft,
|
||||
ArrowRight,
|
||||
ArrowUpRight,
|
||||
Bitcoin,
|
||||
Check,
|
||||
Clock,
|
||||
Copy,
|
||||
ExternalLink,
|
||||
Hash,
|
||||
Layers,
|
||||
RefreshCw,
|
||||
Weight,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useBitcoinTx } from '@/hooks/useBitcoinTx';
|
||||
import { useBitcoinAddress } from '@/hooks/useBitcoinAddress';
|
||||
import { satsToBTC, satsToUSD, formatSats, formatBTC } from '@/lib/bitcoin';
|
||||
import type { TxDetail, TxInput, TxOutput } from '@/lib/bitcoin';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function truncateMiddle(str: string, startLen = 8, endLen = 8): string {
|
||||
if (str.length <= startLen + endLen + 3) return str;
|
||||
return `${str.slice(0, startLen)}...${str.slice(-endLen)}`;
|
||||
}
|
||||
|
||||
function CopyButton({ text }: { text: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
// clipboard not available
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="p-1 rounded hover:bg-muted/50 transition-colors text-muted-foreground hover:text-foreground cursor-pointer"
|
||||
title="Copy"
|
||||
>
|
||||
{copied ? <Check className="size-3.5 text-green-500" /> : <Copy className="size-3.5" />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** Format a unix timestamp as a readable date string. */
|
||||
function formatBlockTime(timestamp: number): string {
|
||||
const date = new Date(timestamp * 1000);
|
||||
return date.toLocaleString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: true,
|
||||
});
|
||||
}
|
||||
|
||||
/** Format a large number with locale separators. */
|
||||
function formatNumber(n: number): string {
|
||||
return n.toLocaleString();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bitcoin Transaction Header
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function BitcoinTxHeader({ txid }: { txid: string }) {
|
||||
const { tx, btcPrice, isLoading, error } = useBitcoinTx(txid);
|
||||
|
||||
if (isLoading) return <TxSkeleton />;
|
||||
|
||||
if (error || !tx) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-border p-6 text-center space-y-3">
|
||||
<Bitcoin className="size-10 mx-auto text-muted-foreground/40" />
|
||||
<p className="text-sm text-destructive">Failed to load transaction</p>
|
||||
<p className="text-xs text-muted-foreground font-mono break-all">{txid}</p>
|
||||
<a
|
||||
href={`https://mempool.space/tx/${txid}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<Bitcoin className="size-3.5" />
|
||||
<span>View on mempool.space</span>
|
||||
<ExternalLink className="size-3" />
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-border overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="p-5 space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`flex items-center justify-center size-10 rounded-full ${
|
||||
tx.confirmed
|
||||
? 'bg-green-500/10 text-green-600 dark:text-green-400'
|
||||
: 'bg-orange-500/10 text-orange-600 dark:text-orange-400'
|
||||
}`}>
|
||||
{tx.confirmed ? <Check className="size-5" /> : <Clock className="size-5" />}
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-bold">
|
||||
{tx.confirmed ? 'Confirmed' : 'Unconfirmed'}
|
||||
</h2>
|
||||
{tx.blockTime && (
|
||||
<p className="text-sm text-muted-foreground">{formatBlockTime(tx.blockTime)}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Transaction ID */}
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Transaction ID</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-mono text-foreground break-all">{tx.txid}</p>
|
||||
<CopyButton text={tx.txid} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats grid */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{tx.confirmed && tx.blockHeight !== undefined && (
|
||||
<StatCard icon={<Layers className="size-3.5" />} label="Block" value={formatNumber(tx.blockHeight)} />
|
||||
)}
|
||||
<StatCard icon={<Weight className="size-3.5" />} label="Size" value={`${formatNumber(tx.weight / 4)} vB`} />
|
||||
<StatCard
|
||||
icon={<Bitcoin className="size-3.5" />}
|
||||
label="Fee"
|
||||
value={`${formatSats(tx.fee)} sat`}
|
||||
subtitle={`${(tx.fee / (tx.weight / 4)).toFixed(1)} sat/vB`}
|
||||
/>
|
||||
<StatCard
|
||||
icon={<Hash className="size-3.5" />}
|
||||
label="Amount"
|
||||
value={`${formatBTC(tx.totalOutput)} BTC`}
|
||||
subtitle={btcPrice ? satsToUSD(tx.totalOutput, btcPrice) : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Inputs → Outputs flow */}
|
||||
<div className="border-t border-border">
|
||||
<TxFlow tx={tx} btcPrice={btcPrice} />
|
||||
</div>
|
||||
|
||||
{/* Footer: link to mempool.space */}
|
||||
<div className="border-t border-border px-5 py-2.5">
|
||||
<a
|
||||
href={`https://mempool.space/tx/${txid}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<Bitcoin className="size-3.5" />
|
||||
<span>View on mempool.space</span>
|
||||
<ExternalLink className="size-3" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({ icon, label, value, subtitle }: { icon: React.ReactNode; label: string; value: string; subtitle?: string }) {
|
||||
return (
|
||||
<div className="rounded-xl bg-secondary/40 px-3.5 py-2.5 space-y-0.5">
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
{icon}
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
<p className="text-sm font-semibold">{value}</p>
|
||||
{subtitle && <p className="text-xs text-muted-foreground">{subtitle}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Inputs → Outputs visualization, mempool.space-style. */
|
||||
function TxFlow({ tx, btcPrice }: { tx: TxDetail; btcPrice?: number }) {
|
||||
return (
|
||||
<div className="p-4 space-y-3">
|
||||
<div className="flex items-center gap-2 text-xs font-medium text-muted-foreground uppercase tracking-wider px-1">
|
||||
<span>{tx.inputs.length} Input{tx.inputs.length !== 1 ? 's' : ''}</span>
|
||||
<ArrowRight className="size-3" />
|
||||
<span>{tx.outputs.length} Output{tx.outputs.length !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{/* Inputs */}
|
||||
<div className="space-y-1.5">
|
||||
{tx.inputs.slice(0, 10).map((input, i) => (
|
||||
<TxInputRow key={`${input.txid}-${input.vout}-${i}`} input={input} btcPrice={btcPrice} />
|
||||
))}
|
||||
{tx.inputs.length > 10 && (
|
||||
<p className="text-xs text-muted-foreground text-center py-1">
|
||||
+{tx.inputs.length - 10} more input{tx.inputs.length - 10 !== 1 ? 's' : ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Outputs */}
|
||||
<div className="space-y-1.5">
|
||||
{tx.outputs.slice(0, 10).map((output, i) => (
|
||||
<TxOutputRow key={`${output.address ?? 'op_return'}-${i}`} output={output} btcPrice={btcPrice} />
|
||||
))}
|
||||
{tx.outputs.length > 10 && (
|
||||
<p className="text-xs text-muted-foreground text-center py-1">
|
||||
+{tx.outputs.length - 10} more output{tx.outputs.length - 10 !== 1 ? 's' : ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TxInputRow({ input, btcPrice }: { input: TxInput; btcPrice?: number }) {
|
||||
if (input.isCoinbase) {
|
||||
return (
|
||||
<div className="rounded-lg bg-amber-500/5 border border-amber-500/20 px-3 py-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-xs font-medium text-amber-600 dark:text-amber-400">Coinbase</span>
|
||||
<span className="text-xs font-mono">{formatBTC(input.value)} BTC</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg bg-red-500/5 border border-red-500/10 px-3 py-2 space-y-0.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
{input.address ? (
|
||||
<Link
|
||||
to={`/i/bitcoin:address:${input.address}`}
|
||||
className="text-xs font-mono text-red-600 dark:text-red-400 hover:underline truncate"
|
||||
>
|
||||
{truncateMiddle(input.address, 10, 6)}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">Unknown</span>
|
||||
)}
|
||||
<span className="text-xs font-mono shrink-0">{formatBTC(input.value)} BTC</span>
|
||||
</div>
|
||||
{btcPrice !== undefined && (
|
||||
<p className="text-[10px] text-muted-foreground text-right">{satsToUSD(input.value, btcPrice)}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TxOutputRow({ output, btcPrice }: { output: TxOutput; btcPrice?: number }) {
|
||||
const isOpReturn = output.scriptpubkeyType === 'op_return';
|
||||
|
||||
if (isOpReturn) {
|
||||
return (
|
||||
<div className="rounded-lg bg-secondary/60 border border-border/50 px-3 py-2">
|
||||
<span className="text-xs text-muted-foreground">OP_RETURN</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg bg-green-500/5 border border-green-500/10 px-3 py-2 space-y-0.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
{output.address ? (
|
||||
<Link
|
||||
to={`/i/bitcoin:address:${output.address}`}
|
||||
className="text-xs font-mono text-green-600 dark:text-green-400 hover:underline truncate"
|
||||
>
|
||||
{truncateMiddle(output.address, 10, 6)}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">Unknown</span>
|
||||
)}
|
||||
<span className="text-xs font-mono shrink-0">{formatBTC(output.value)} BTC</span>
|
||||
</div>
|
||||
{btcPrice !== undefined && (
|
||||
<p className="text-[10px] text-muted-foreground text-right">{satsToUSD(output.value, btcPrice)}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TxSkeleton() {
|
||||
return (
|
||||
<div className="rounded-2xl border border-border overflow-hidden">
|
||||
<div className="p-5 space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="size-10 rounded-full" />
|
||||
<div className="space-y-1.5">
|
||||
<Skeleton className="h-5 w-28" />
|
||||
<Skeleton className="h-3.5 w-40" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Skeleton className="h-3 w-24" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Skeleton className="h-16 rounded-xl" />
|
||||
<Skeleton className="h-16 rounded-xl" />
|
||||
<Skeleton className="h-16 rounded-xl" />
|
||||
<Skeleton className="h-16 rounded-xl" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-border p-4 space-y-3">
|
||||
<Skeleton className="h-3 w-32" />
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Skeleton className="h-12 rounded-lg" />
|
||||
<Skeleton className="h-12 rounded-lg" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Skeleton className="h-12 rounded-lg" />
|
||||
<Skeleton className="h-12 rounded-lg" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bitcoin Address Header
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function BitcoinAddressHeader({ address }: { address: string }) {
|
||||
const { addressDetail, btcPrice, isLoading, error, refetch } = useBitcoinAddress(address);
|
||||
|
||||
if (isLoading) return <AddressSkeleton />;
|
||||
|
||||
if (error || !addressDetail) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-border p-6 text-center space-y-3">
|
||||
<Bitcoin className="size-10 mx-auto text-muted-foreground/40" />
|
||||
<p className="text-sm text-destructive">Failed to load address</p>
|
||||
<p className="text-xs text-muted-foreground font-mono break-all">{address}</p>
|
||||
<Button variant="outline" size="sm" onClick={() => refetch()}>
|
||||
<RefreshCw className="size-3.5 mr-1.5" />
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-border overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="p-5 space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center justify-center size-10 rounded-full bg-primary/10 text-primary">
|
||||
<Bitcoin className="size-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-bold">Bitcoin Address</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{addressDetail.txCount + addressDetail.pendingTxCount} transaction{(addressDetail.txCount + addressDetail.pendingTxCount) !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Address */}
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Address</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-mono text-foreground break-all">{address}</p>
|
||||
<CopyButton text={address} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Balance hero */}
|
||||
<div className="rounded-xl bg-secondary/40 p-4 text-center space-y-1">
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wider">Balance</p>
|
||||
<p className="text-3xl font-bold tracking-tight">
|
||||
{btcPrice ? satsToUSD(addressDetail.totalBalance, btcPrice) : `${formatBTC(addressDetail.totalBalance)} BTC`}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatBTC(addressDetail.totalBalance)} BTC
|
||||
</p>
|
||||
{addressDetail.pendingBalance !== 0 && (
|
||||
<p className="flex items-center justify-center gap-1 text-xs text-orange-500 dark:text-orange-400 pt-1">
|
||||
<RefreshCw className="size-3 animate-spin" />
|
||||
{btcPrice
|
||||
? `${satsToUSD(addressDetail.pendingBalance, btcPrice)} pending`
|
||||
: `${formatBTC(addressDetail.pendingBalance)} BTC pending`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stats grid */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<StatCard
|
||||
icon={<ArrowDownLeft className="size-3.5" />}
|
||||
label="Total Received"
|
||||
value={`${formatBTC(addressDetail.totalReceived)} BTC`}
|
||||
subtitle={btcPrice ? satsToUSD(addressDetail.totalReceived, btcPrice) : undefined}
|
||||
/>
|
||||
<StatCard
|
||||
icon={<ArrowUpRight className="size-3.5" />}
|
||||
label="Total Sent"
|
||||
value={`${formatBTC(addressDetail.totalSent)} BTC`}
|
||||
subtitle={btcPrice ? satsToUSD(addressDetail.totalSent, btcPrice) : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent Transactions */}
|
||||
{addressDetail.recentTxs.length > 0 && (
|
||||
<div className="border-t border-border">
|
||||
<div className="px-5 py-3">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Recent Transactions
|
||||
</p>
|
||||
</div>
|
||||
<div className="divide-y divide-border">
|
||||
{addressDetail.recentTxs.slice(0, 10).map((tx) => (
|
||||
<AddressTxRow key={tx.txid} tx={tx} btcPrice={btcPrice} />
|
||||
))}
|
||||
</div>
|
||||
{addressDetail.recentTxs.length > 10 && (
|
||||
<div className="px-5 py-3 text-center">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{addressDetail.txCount - 10} more transaction{addressDetail.txCount - 10 !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer: link to mempool.space */}
|
||||
<div className="border-t border-border px-5 py-2.5">
|
||||
<a
|
||||
href={`https://mempool.space/address/${address}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<Bitcoin className="size-3.5" />
|
||||
<span>View on mempool.space</span>
|
||||
<ExternalLink className="size-3" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AddressTxRow({ tx, btcPrice }: { tx: { txid: string; amount: number; type: 'receive' | 'send'; confirmed: boolean; timestamp?: number }; btcPrice?: number }) {
|
||||
const isReceive = tx.type === 'receive';
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={`/i/bitcoin:tx:${tx.txid}`}
|
||||
className="flex items-center justify-between py-3 px-5 hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`flex items-center justify-center size-8 rounded-full ${
|
||||
isReceive
|
||||
? 'bg-green-500/10 text-green-600 dark:text-green-400'
|
||||
: 'bg-red-500/10 text-red-600 dark:text-red-400'
|
||||
}`}>
|
||||
{isReceive ? <ArrowDownLeft className="size-4" /> : <ArrowUpRight className="size-4" />}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">{isReceive ? 'Received' : 'Sent'}</p>
|
||||
<p className="text-xs text-muted-foreground font-mono">{truncateMiddle(tx.txid, 8, 8)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className={`text-sm font-medium ${
|
||||
isReceive ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'
|
||||
}`}>
|
||||
{isReceive ? '+' : '-'}{formatBTC(tx.amount)} BTC
|
||||
</p>
|
||||
{btcPrice && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{satsToUSD(tx.amount, btcPrice)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function AddressSkeleton() {
|
||||
return (
|
||||
<div className="rounded-2xl border border-border overflow-hidden">
|
||||
<div className="p-5 space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="size-10 rounded-full" />
|
||||
<div className="space-y-1.5">
|
||||
<Skeleton className="h-5 w-32" />
|
||||
<Skeleton className="h-3.5 w-24" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Skeleton className="h-3 w-16" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
</div>
|
||||
<div className="rounded-xl bg-secondary/40 p-4 space-y-2 flex flex-col items-center">
|
||||
<Skeleton className="h-3 w-12" />
|
||||
<Skeleton className="h-9 w-40" />
|
||||
<Skeleton className="h-4 w-28" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Skeleton className="h-16 rounded-xl" />
|
||||
<Skeleton className="h-16 rounded-xl" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Compact previews (used in NoteCard embeds, hover cards, etc.)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Compact preview for a Bitcoin transaction — fetches real data. */
|
||||
export function BitcoinTxPreview({ txid, link }: { txid: string; link: string }) {
|
||||
const { tx, btcPrice, isLoading } = useBitcoinTx(txid);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="px-4 py-3 border-b border-border">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="size-12 rounded-lg shrink-0" />
|
||||
<div className="flex-1 min-w-0 space-y-1.5">
|
||||
<Skeleton className="h-3 w-32" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const amount = tx ? tx.totalOutput : 0;
|
||||
const fee = tx?.fee ?? 0;
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={link}
|
||||
className="flex items-center gap-3 px-4 py-3 border-b border-border hover:bg-secondary/30 transition-colors"
|
||||
>
|
||||
<div className="size-12 rounded-lg bg-orange-500/10 flex items-center justify-center shrink-0">
|
||||
<Bitcoin className="size-5 text-orange-600 dark:text-orange-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Bitcoin className="size-3 shrink-0" />
|
||||
<span>Bitcoin Transaction</span>
|
||||
{tx && (
|
||||
<span className={tx.confirmed
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: 'text-yellow-600 dark:text-yellow-400'
|
||||
}>
|
||||
{tx.confirmed ? 'Confirmed' : 'Unconfirmed'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm font-medium truncate mt-0.5">
|
||||
{tx ? `${satsToBTC(amount)} BTC` : truncateMiddle(txid, 12, 8)}
|
||||
{tx && btcPrice ? (
|
||||
<span className="text-muted-foreground font-normal"> ({satsToUSD(amount, btcPrice)})</span>
|
||||
) : null}
|
||||
</p>
|
||||
{tx && (
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
Fee {formatSats(fee)} sats
|
||||
{tx.blockHeight ? ` · Block ${tx.blockHeight.toLocaleString()}` : ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<ExternalLink className="size-4 text-muted-foreground shrink-0" />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
/** Compact preview for a Bitcoin address — fetches real data. */
|
||||
export function BitcoinAddressPreview({ address, link }: { address: string; link: string }) {
|
||||
const { addressDetail, btcPrice, isLoading } = useBitcoinAddress(address);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="px-4 py-3 border-b border-border">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="size-12 rounded-lg shrink-0" />
|
||||
<div className="flex-1 min-w-0 space-y-1.5">
|
||||
<Skeleton className="h-3 w-28" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const balance = addressDetail?.totalBalance ?? 0;
|
||||
const txCount = addressDetail ? addressDetail.txCount + addressDetail.pendingTxCount : 0;
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={link}
|
||||
className="flex items-center gap-3 px-4 py-3 border-b border-border hover:bg-secondary/30 transition-colors"
|
||||
>
|
||||
<div className="size-12 rounded-lg bg-orange-500/10 flex items-center justify-center shrink-0">
|
||||
<Bitcoin className="size-5 text-orange-600 dark:text-orange-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Bitcoin className="size-3 shrink-0" />
|
||||
<span>Bitcoin Address</span>
|
||||
</div>
|
||||
<p className="text-sm font-medium truncate mt-0.5">
|
||||
{addressDetail ? `${satsToBTC(balance)} BTC` : truncateMiddle(address, 12, 8)}
|
||||
{addressDetail && btcPrice ? (
|
||||
<span className="text-muted-foreground font-normal"> ({satsToUSD(balance, btcPrice)})</span>
|
||||
) : null}
|
||||
</p>
|
||||
{addressDetail && (
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{txCount.toLocaleString()} transaction{txCount !== 1 ? 's' : ''}
|
||||
{' · '}
|
||||
<span className="font-mono">{truncateMiddle(address, 8, 6)}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<ExternalLink className="size-4 text-muted-foreground shrink-0" />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -1,166 +1,610 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Loader2, Search } from 'lucide-react';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { ClipboardEvent } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AlertTriangle, Bitcoin, EyeOff, QrCode, X } from 'lucide-react';
|
||||
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { EmojifiedText } from '@/components/CustomEmoji';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useSearchProfiles, type SearchProfile } from '@/hooks/useSearchProfiles';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
import {
|
||||
Popover,
|
||||
PopoverAnchor,
|
||||
PopoverContent,
|
||||
} from '@/components/ui/popover';
|
||||
import { QrScannerDialog } from '@/components/QrScannerDialog';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { parseBitcoinUri, validateBitcoinAddress } from '@/lib/bitcoin';
|
||||
import {
|
||||
isSilentPaymentAddress,
|
||||
validateSilentPaymentAddress,
|
||||
} from '@/lib/hdwallet/sp/sender';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The resolved recipient produced by {@link BitcoinRecipientInput}.
|
||||
*
|
||||
* Either a bare on-chain Bitcoin address (`kind === 'address'`) or a BIP-352
|
||||
* silent payment address (`kind === 'sp'`). The dialog consumes this shape
|
||||
* directly when building the PSBT.
|
||||
*/
|
||||
export interface ResolvedRecipient {
|
||||
/**
|
||||
* For `kind === 'address'`: a validated mainnet on-chain address.
|
||||
* For `kind === 'sp'`: the `sp1…` string (the real P2TR `P_k` is derived
|
||||
* at PSBT-build time, after coin selection).
|
||||
*/
|
||||
address: string;
|
||||
/** Recipient kind — determines how the PSBT builder routes the output. */
|
||||
kind: 'address' | 'sp';
|
||||
/**
|
||||
* Raw text the user typed / pasted / scanned. Kept so the picker can
|
||||
* round-trip a chip back into the input on clear if we ever need it
|
||||
* (currently unused; the chip just dismisses).
|
||||
*/
|
||||
raw: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Candidate extraction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Resolve a piece of recipient text into the valid on-chain and/or
|
||||
* silent-payment candidates it carries.
|
||||
*
|
||||
* Handles bare `bc1…` / `sp1…` addresses and `bitcoin:` BIP-21 URIs (which
|
||||
* may carry an on-chain path, an `sp=` parameter, or both). Returns empty
|
||||
* strings for whichever kind isn't present/valid. Shared by the live
|
||||
* input memo and the paste handler so both agree on what counts.
|
||||
*/
|
||||
function resolveCandidates(text: string): { btc: string; sp: string } {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return { btc: '', sp: '' };
|
||||
|
||||
const bip21 = parseBitcoinUri(trimmed);
|
||||
|
||||
// On-chain: the URI path (when present) or the raw input. SP addresses
|
||||
// live in the `sp` field; don't double-count them as on-chain.
|
||||
const btcRaw = bip21 ? bip21.address : trimmed;
|
||||
const btc =
|
||||
btcRaw && !isSilentPaymentAddress(btcRaw) && validateBitcoinAddress(btcRaw)
|
||||
? btcRaw
|
||||
: '';
|
||||
|
||||
// Silent payment: prefer the URI `sp=` parameter; otherwise the path may
|
||||
// itself be an sp1 address (rare but legal — `bitcoin:sp1…` is a URI
|
||||
// without an on-chain fallback), or the raw input is a bare sp1.
|
||||
const spRaw = bip21 ? (bip21.sp ?? bip21.address) : trimmed;
|
||||
const sp =
|
||||
spRaw && isSilentPaymentAddress(spRaw) && validateSilentPaymentAddress(spRaw)
|
||||
? spRaw
|
||||
: '';
|
||||
|
||||
return { btc, sp };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface BitcoinRecipientInputProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
/** Currently-selected recipient, or `null` when nothing has been picked. */
|
||||
value: ResolvedRecipient | null;
|
||||
/** Called when the user picks a recipient (from the dropdown / QR scan) or clears. */
|
||||
onChange: (value: ResolvedRecipient | null) => void;
|
||||
/** Input placeholder text. */
|
||||
placeholder: string;
|
||||
resolvedPubkey?: string;
|
||||
/**
|
||||
* Optional initial input value applied when the picker mounts with no
|
||||
* `value`. Used by callers (e.g. campaign donate flow) that want to
|
||||
* pre-fill a `bitcoin:…` URI or bare address so the donor only needs to
|
||||
* pick from the dropdown.
|
||||
*
|
||||
* Applied on mount only. Clearing a selected chip (value → null) returns
|
||||
* to an empty input rather than restoring the prefill.
|
||||
*/
|
||||
initialInput?: string;
|
||||
}
|
||||
|
||||
function shouldSkipProfileSearch(value: string): boolean {
|
||||
const lower = value.trim().toLowerCase();
|
||||
if (!lower) return true;
|
||||
/**
|
||||
* Recipient input for the Send Bitcoin dialog. Combines a text input, an
|
||||
* inline QR-scanner button, and a Radix Popover dropdown that surfaces the
|
||||
* recognised destination(s) extracted from the input.
|
||||
*
|
||||
* Recognised destinations:
|
||||
*
|
||||
* - Bare on-chain Bitcoin address (any standard mainnet type) → "Send to
|
||||
* Bitcoin address" row.
|
||||
* - Bare BIP-352 silent payment address (`sp1…`) → "Send to silent payment
|
||||
* address" row.
|
||||
* - `bitcoin:` BIP-21 URI with an on-chain path and/or an `sp=` parameter →
|
||||
* one row per valid candidate (so a URI carrying both shows two rows and
|
||||
* the donor picks privacy vs. compatibility).
|
||||
*
|
||||
* Clicking a row swaps the input out for a {@link SelectedRecipientChip} via
|
||||
* `onChange`. Clicking the chip's X button calls `onChange(null)`, which
|
||||
* returns to the input view.
|
||||
*
|
||||
* Anything else (npub, nprofile, free text) is silently ignored — there is
|
||||
* no account search here, by design. The dropdown stays open as long as the
|
||||
* input holds at least one valid candidate; it doesn't dismiss when the
|
||||
* input loses focus or the user taps elsewhere. It closes only on selection,
|
||||
* when the input is cleared, or on Escape.
|
||||
*/
|
||||
export function BitcoinRecipientInput({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
initialInput,
|
||||
}: BitcoinRecipientInputProps) {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
|
||||
// Keep Bitcoin and silent-payment recipients as plain text. npub/nprofile
|
||||
// intentionally still search so pasted Nostr IDs can resolve to a person row.
|
||||
return (
|
||||
lower.startsWith('bc1') ||
|
||||
lower.startsWith('sp1') ||
|
||||
lower.startsWith('bitcoin:') ||
|
||||
lower.startsWith('1') ||
|
||||
lower.startsWith('3')
|
||||
);
|
||||
}
|
||||
|
||||
export function BitcoinRecipientInput({ value, onChange, placeholder, resolvedPubkey }: BitcoinRecipientInputProps) {
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
// Local input state. Independent of `value` so the user can keep typing
|
||||
// after dismissing the dropdown without losing their query, and so the
|
||||
// chip-cleared view starts blank instead of repopulating the previous
|
||||
// selection. `initialInput` only seeds the field on first mount —
|
||||
// clearing the chip (value → null) returns to an empty input, not the
|
||||
// prefill.
|
||||
const [query, setQuery] = useState<string>(initialInput ?? '');
|
||||
const [open, setOpen] = useState(false);
|
||||
// Tracks whether the popover has been opened at least once for the
|
||||
// current query. The "choose a payment method" hint suppresses on the
|
||||
// very first render so callers prefilling the input don't see the hint
|
||||
// flash for one frame before the auto-open effect runs.
|
||||
const [hasOpenedForQuery, setHasOpenedForQuery] = useState(false);
|
||||
const [scannerOpen, setScannerOpen] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const searchQuery = shouldSkipProfileSearch(value) ? '' : value;
|
||||
const { data: profiles, isFetching } = useSearchProfiles(searchQuery);
|
||||
|
||||
const filteredProfiles = useMemo(
|
||||
() => (profiles ?? []).slice(0, 6),
|
||||
[profiles],
|
||||
// ── Candidate extraction ──────────────────────────────────────────────
|
||||
//
|
||||
// BIP-21 `bitcoin:` URI handling. If the input is a URI, we route the
|
||||
// same way the QR scanner does: surface every valid candidate as its own
|
||||
// row so the user explicitly picks privacy (sp) vs. compatibility
|
||||
// (on-chain). A raw bc1…/sp1… input falls through here unchanged: `bip21`
|
||||
// is null and the candidate is just the trimmed query.
|
||||
const trimmed = query.trim();
|
||||
const { btc: btcCandidate, sp: spCandidate } = useMemo(
|
||||
() => resolveCandidates(trimmed),
|
||||
[trimmed],
|
||||
);
|
||||
const hasResults = filteredProfiles.length > 0;
|
||||
const shouldShowSearch = searchQuery.trim().length > 0;
|
||||
|
||||
const hasBtc = !!btcCandidate;
|
||||
const hasSp = !!spCandidate;
|
||||
const totalItems = (hasSp ? 1 : 0) + (hasBtc ? 1 : 0);
|
||||
|
||||
// Auto-open the dropdown whenever a candidate is available, auto-close on
|
||||
// empty input.
|
||||
useEffect(() => {
|
||||
if (shouldShowSearch && hasResults) {
|
||||
setDropdownOpen(true);
|
||||
} else if (!shouldShowSearch) {
|
||||
setDropdownOpen(false);
|
||||
if (trimmed.length === 0) {
|
||||
setOpen(false);
|
||||
setHasOpenedForQuery(false);
|
||||
return;
|
||||
}
|
||||
}, [hasResults, shouldShowSearch]);
|
||||
if (hasSp || hasBtc) setOpen(true);
|
||||
}, [trimmed, hasSp, hasBtc]);
|
||||
|
||||
const selectProfile = (profile: SearchProfile) => {
|
||||
onChange(nip19.npubEncode(profile.pubkey));
|
||||
setDropdownOpen(false);
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
// Track the first time the popover opens for the current query, so the
|
||||
// "choose a payment method" hint only appears after the donor has had a
|
||||
// chance to see (and dismiss) the dropdown — not flash for one paint
|
||||
// frame between mount and the auto-open effect above.
|
||||
useEffect(() => {
|
||||
if (open) setHasOpenedForQuery(true);
|
||||
}, [open]);
|
||||
|
||||
// ── Selection callbacks ───────────────────────────────────────────────
|
||||
const selectBtc = useCallback(
|
||||
(address: string) => {
|
||||
onChange({ address, kind: 'address', raw: query });
|
||||
setQuery('');
|
||||
setOpen(false);
|
||||
inputRef.current?.blur();
|
||||
},
|
||||
[onChange, query],
|
||||
);
|
||||
|
||||
const selectSp = useCallback(
|
||||
(address: string) => {
|
||||
onChange({ address, kind: 'sp', raw: query });
|
||||
setQuery('');
|
||||
setOpen(false);
|
||||
inputRef.current?.blur();
|
||||
},
|
||||
[onChange, query],
|
||||
);
|
||||
|
||||
// ── Mount-time auto-select for single-endpoint prefills ────────────────
|
||||
//
|
||||
// When the picker mounts pre-filled (e.g. the campaign "Pay with Agora"
|
||||
// flow) and `initialInput` resolves to exactly one valid candidate, skip
|
||||
// the dropdown and select it directly so it lands as a chip. When the
|
||||
// prefill carries *both* an on-chain address and an sp1 code we leave it
|
||||
// in the input and let the dropdown surface both rows — that's a genuine
|
||||
// choice the donor must make (privacy vs. compatibility).
|
||||
//
|
||||
// Guarded by a ref so it fires once per mount and never overrides a
|
||||
// selection the user has already made or a `clear chip → restore prefill`
|
||||
// transition (the picker is keyed on each open in the dialog, so a fresh
|
||||
// mount is the right granularity).
|
||||
const autoSelectedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (autoSelectedRef.current) return;
|
||||
autoSelectedRef.current = true;
|
||||
if (value || !initialInput) return;
|
||||
if (totalItems !== 1) return;
|
||||
if (hasSp) {
|
||||
selectSp(spCandidate);
|
||||
} else if (hasBtc) {
|
||||
selectBtc(btcCandidate);
|
||||
}
|
||||
// Intentionally mount-only: candidates are derived from `initialInput`
|
||||
// (via the initial `query`), so reading them here reflects the prefill.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// ── Paste auto-select ──────────────────────────────────────────────────
|
||||
//
|
||||
// When the user pastes text that resolves to exactly one valid candidate
|
||||
// (a bare `bc1…` / `sp1…` address or a single-endpoint `bitcoin:` URI),
|
||||
// convert it straight into a chip instead of making them click the lone
|
||||
// dropdown row. A paste carrying *both* an on-chain address and an sp1
|
||||
// code falls through to the normal dropdown so the donor picks privacy
|
||||
// vs. compatibility.
|
||||
//
|
||||
// We resolve from the pasted text directly because `query` state hasn't
|
||||
// updated yet inside the paste event. Returning early on a single match
|
||||
// lets us `preventDefault()` so the input never flickers the raw text.
|
||||
const handlePaste = useCallback(
|
||||
(e: ClipboardEvent<HTMLInputElement>) => {
|
||||
const pasted = e.clipboardData.getData('text');
|
||||
if (!pasted) return;
|
||||
const { btc, sp } = resolveCandidates(pasted);
|
||||
const count = (btc ? 1 : 0) + (sp ? 1 : 0);
|
||||
if (count !== 1) return; // 0 → let it land as text; 2 → use the dropdown.
|
||||
e.preventDefault();
|
||||
if (btc) {
|
||||
onChange({ address: btc, kind: 'address', raw: pasted.trim() });
|
||||
} else {
|
||||
onChange({ address: sp, kind: 'sp', raw: pasted.trim() });
|
||||
}
|
||||
setQuery('');
|
||||
setOpen(false);
|
||||
inputRef.current?.blur();
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
// ── QR scan handling ──────────────────────────────────────────────────
|
||||
/**
|
||||
* Interpret a freshly-scanned QR code.
|
||||
*
|
||||
* - **BIP-21 URI with valid bc1 *and* sp1** → drop the URI into the input
|
||||
* and open the dropdown so the donor picks between them.
|
||||
* - **BIP-21 URI with only `sp=` valid** → select SP directly (creates
|
||||
* the chip, bypasses the dropdown).
|
||||
* - **Bare bitcoin address** → select on-chain directly.
|
||||
* - **Bare `sp1…` address** → select SP directly.
|
||||
* - **Anything else** → toast.
|
||||
*/
|
||||
const handleScan = useCallback(
|
||||
(scanned: string) => {
|
||||
setScannerOpen(false);
|
||||
const text = scanned.trim();
|
||||
const parsed = parseBitcoinUri(text);
|
||||
|
||||
const candidate = parsed ? parsed.address : text;
|
||||
const sp = parsed?.sp;
|
||||
|
||||
const hasValidBtc = !!candidate && validateBitcoinAddress(candidate);
|
||||
const hasValidSp =
|
||||
!!sp && isSilentPaymentAddress(sp) && validateSilentPaymentAddress(sp);
|
||||
|
||||
// Both options — show the dropdown.
|
||||
if (parsed && hasValidBtc && hasValidSp) {
|
||||
setQuery(text);
|
||||
setOpen(true);
|
||||
// Focus is best-effort; on mobile the scanner dialog dismissal will
|
||||
// already steal focus and the dropdown stays usable via tap.
|
||||
inputRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
// SP-only via `bitcoin:…?sp=sp1…`.
|
||||
if (hasValidSp && sp) {
|
||||
selectSp(sp);
|
||||
return;
|
||||
}
|
||||
|
||||
// Direct on-chain.
|
||||
if (hasValidBtc) {
|
||||
selectBtc(candidate);
|
||||
return;
|
||||
}
|
||||
|
||||
// Bare sp1 (no `bitcoin:` prefix).
|
||||
if (
|
||||
isSilentPaymentAddress(candidate)
|
||||
&& validateSilentPaymentAddress(candidate)
|
||||
) {
|
||||
selectSp(candidate);
|
||||
return;
|
||||
}
|
||||
|
||||
toast({
|
||||
title: t('walletSend.scanError.title'),
|
||||
description: t('walletSend.scanError.description'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
[selectBtc, selectSp, t, toast],
|
||||
);
|
||||
|
||||
// ── Chip view ─────────────────────────────────────────────────────────
|
||||
if (value) {
|
||||
return (
|
||||
<SelectedRecipientChip value={value} onClear={() => onChange(null)} />
|
||||
);
|
||||
}
|
||||
|
||||
// ── Input + dropdown ──────────────────────────────────────────────────
|
||||
//
|
||||
// `popoverOpen` derives from the manual `open` flag AND the presence of
|
||||
// actionable candidates. This prevents an empty/garbage input from
|
||||
// popping the dropdown.
|
||||
const popoverOpen = open && totalItems > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Popover open={dropdownOpen} onOpenChange={setDropdownOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Popover open={popoverOpen} onOpenChange={setOpen}>
|
||||
<PopoverAnchor asChild>
|
||||
<div className="relative flex items-center">
|
||||
<Search className="absolute left-3 size-4 text-muted-foreground pointer-events-none" />
|
||||
{isFetching && shouldShowSearch && (
|
||||
<Loader2 className="absolute right-3 size-4 text-muted-foreground animate-spin" />
|
||||
)}
|
||||
<Input
|
||||
ref={inputRef}
|
||||
id="hd-recipient-input"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onPaste={handlePaste}
|
||||
// Reopen on focus so a user can recover the dropdown after an
|
||||
// outside-click dismiss (the value is still in the field).
|
||||
onFocus={() => {
|
||||
if (shouldShowSearch && hasResults) setDropdownOpen(true);
|
||||
if (totalItems > 0) setOpen(true);
|
||||
}}
|
||||
// `onFocus` only fires on the first tap; subsequent taps while
|
||||
// the input is still focused need their own opener so the user
|
||||
// can reopen the choice list without un-focusing first.
|
||||
onClick={() => {
|
||||
if (totalItems > 0) setOpen(true);
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className="pl-10 pr-10 font-mono text-base md:text-sm"
|
||||
role="combobox"
|
||||
aria-expanded={popoverOpen}
|
||||
aria-haspopup="listbox"
|
||||
aria-autocomplete="list"
|
||||
className={cn('font-mono text-base md:text-sm pr-11')}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setScannerOpen(true)}
|
||||
aria-label={t('walletSend.recipient.scan')}
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 size-8 rounded-full text-muted-foreground hover:text-foreground hover:bg-secondary/60 flex items-center justify-center motion-safe:transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<QrCode className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
</PopoverAnchor>
|
||||
|
||||
<PopoverContent
|
||||
align="start"
|
||||
side="bottom"
|
||||
sideOffset={6}
|
||||
// Keep typing focus in the input on open/close — Radix's default
|
||||
// is to focus the popover content, which would steal focus from
|
||||
// the input and dismiss the mobile keyboard mid-type.
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
className="z-[270] w-[var(--radix-popover-trigger-width)] rounded-xl border-border p-0 shadow-lg overflow-hidden"
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
// The dropdown is a persistent choice list, not a transient
|
||||
// hover-popover: it should stay open even when the input loses
|
||||
// focus or the user taps elsewhere on the page, so blurring out
|
||||
// doesn't make the candidate rows vanish. We block Radix's
|
||||
// auto-dismiss-on-outside-interaction and instead close the
|
||||
// dropdown explicitly — on selection, on a cleared input
|
||||
// (the auto-open effect), or via Escape (still honored below).
|
||||
onFocusOutside={(e) => e.preventDefault()}
|
||||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
style={{ width: 'var(--radix-popover-trigger-width)' }}
|
||||
className="p-0 w-[--radix-popover-trigger-width] max-h-none rounded-xl border border-border bg-popover shadow-lg overflow-hidden"
|
||||
>
|
||||
{hasResults && (
|
||||
<div className="max-h-[200px] overflow-y-auto py-1">
|
||||
{filteredProfiles.map((profile) => (
|
||||
<RecipientSearchResult key={profile.pubkey} profile={profile} onClick={selectProfile} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div role="listbox" className="max-h-[280px] overflow-y-auto py-1">
|
||||
{/* BTC comes before SP — the on-chain address is the
|
||||
broadly-compatible default; the silent-payment option
|
||||
follows for donors who want privacy. */}
|
||||
{hasBtc && (
|
||||
<BtcAddressRow address={btcCandidate} onClick={selectBtc} />
|
||||
)}
|
||||
{hasSp && (
|
||||
<SpAddressRow address={spCandidate} onClick={selectSp} />
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{resolvedPubkey && <ResolvedRecipientPreview pubkey={resolvedPubkey} />}
|
||||
{/* Picker-closed reminder. When the input holds parseable candidates
|
||||
but the donor hasn't actually picked one yet — typically because
|
||||
they tapped an amount preset, which counts as an outside-click
|
||||
and dismisses the popover — the Send button is disabled with no
|
||||
visible reason. Surface an actionable hint that re-opens the
|
||||
dropdown so the donor doesn't have to guess that they're meant
|
||||
to tap the recipient input again.
|
||||
|
||||
Gated on `hasOpenedForQuery` so the hint doesn't flash for one
|
||||
paint frame between mount and the auto-open effect on prefilled
|
||||
inputs (campaign donate flow). */}
|
||||
{hasOpenedForQuery && !popoverOpen && totalItems > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => {
|
||||
setOpen(true);
|
||||
inputRef.current?.focus();
|
||||
}}
|
||||
className="flex items-center gap-1.5 text-xs text-amber-600 dark:text-amber-500 hover:text-amber-700 dark:hover:text-amber-400 motion-safe:transition-colors text-left"
|
||||
>
|
||||
<AlertTriangle className="size-3.5 shrink-0" />
|
||||
<span>{t('walletSend.recipient.choosePaymentMethod')}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<QrScannerDialog
|
||||
isOpen={scannerOpen}
|
||||
onClose={() => setScannerOpen(false)}
|
||||
onScan={handleScan}
|
||||
title={t('walletSend.recipient.scan')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ResolvedRecipientPreview({ pubkey }: { pubkey: string }) {
|
||||
const author = useAuthor(pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
const displayName = metadata?.display_name || metadata?.name || genUserName(pubkey);
|
||||
const avatarUrl = sanitizeUrl(metadata?.picture);
|
||||
const encoded = nip19.npubEncode(pubkey);
|
||||
const fallbackLabel = `${encoded.slice(0, 12)}…${encoded.slice(-8)}`;
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dropdown rows
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-lg border bg-muted/30 px-3 py-2">
|
||||
<Avatar className="size-8 shrink-0">
|
||||
<AvatarImage src={avatarUrl} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-xs">
|
||||
{displayName[0]?.toUpperCase() || '?'}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="text-sm font-medium truncate block">{displayName}</span>
|
||||
<span className="text-xs text-muted-foreground truncate block">
|
||||
{metadata?.nip05?.startsWith('_@') ? metadata.nip05.slice(2) : metadata?.nip05 || fallbackLabel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
/** Truncate long addresses with an ellipsis so they don't overflow the row. */
|
||||
function truncateAddress(address: string): string {
|
||||
return address.length > 28
|
||||
? `${address.slice(0, 14)}…${address.slice(-10)}`
|
||||
: address;
|
||||
}
|
||||
|
||||
function RecipientSearchResult({ profile, onClick }: { profile: SearchProfile; onClick: (profile: SearchProfile) => void }) {
|
||||
const { metadata, pubkey } = profile;
|
||||
const displayName = metadata.display_name || metadata.name || genUserName(pubkey);
|
||||
const avatarUrl = sanitizeUrl(metadata.picture);
|
||||
|
||||
function BtcAddressRow({
|
||||
address,
|
||||
onClick,
|
||||
}: {
|
||||
address: string;
|
||||
onClick: (address: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="w-full flex items-center gap-3 px-3 py-2 text-left transition-colors cursor-pointer hover:bg-secondary/60"
|
||||
onClick={() => onClick(profile)}
|
||||
role="option"
|
||||
aria-selected={false}
|
||||
onClick={() => onClick(address)}
|
||||
// Prevent the input from blurring on mousedown — otherwise the popover
|
||||
// closes before `onClick` fires and the row never resolves.
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
className="w-full flex items-center gap-3 px-3 py-2 text-left transition-colors cursor-pointer hover:bg-secondary/60"
|
||||
>
|
||||
<Avatar className="size-8 shrink-0">
|
||||
<AvatarImage src={avatarUrl} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-xs">
|
||||
{displayName[0]?.toUpperCase() || '?'}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="size-9 shrink-0 rounded-full bg-orange-500/10 flex items-center justify-center">
|
||||
<Bitcoin className="size-4 text-orange-500" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium truncate block">
|
||||
<EmojifiedText tags={profile.event.tags}>{displayName}</EmojifiedText>
|
||||
</span>
|
||||
{metadata.nip05 && (
|
||||
<span className="text-xs text-muted-foreground truncate block">
|
||||
{metadata.nip05.startsWith('_@') ? metadata.nip05.slice(2) : metadata.nip05}
|
||||
</span>
|
||||
)}
|
||||
<div className="font-semibold text-sm truncate">
|
||||
{t('walletSend.recipient.sendToOnchain')}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground truncate font-mono">
|
||||
{truncateAddress(address)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dropdown row for BIP-352 silent payment addresses. We give it a distinct
|
||||
* label and icon (privacy eye-off) so the user can tell at a glance that
|
||||
* this is a static, unlinkable address rather than a regular Bitcoin
|
||||
* scriptPubKey — the privacy story is materially different.
|
||||
*/
|
||||
function SpAddressRow({
|
||||
address,
|
||||
onClick,
|
||||
}: {
|
||||
address: string;
|
||||
onClick: (address: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={false}
|
||||
onClick={() => onClick(address)}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
className="w-full flex items-center gap-3 px-3 py-2 text-left transition-colors cursor-pointer hover:bg-secondary/60"
|
||||
>
|
||||
<div className="size-9 shrink-0 rounded-full bg-violet-500/10 flex items-center justify-center">
|
||||
<EyeOff className="size-4 text-violet-500" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-semibold text-sm truncate">
|
||||
{t('walletSend.recipient.sendToSilentPayment')}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground truncate font-mono">
|
||||
{truncateAddress(address)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Selected recipient chip
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Compact panel that replaces the input once a recipient has been picked.
|
||||
* Renders a coloured icon (orange Bitcoin / violet EyeOff for SP), the kind
|
||||
* label, a truncated monospace address, and an X button that clears the
|
||||
* selection and returns the user to the input view.
|
||||
*/
|
||||
function SelectedRecipientChip({
|
||||
value,
|
||||
onClear,
|
||||
}: {
|
||||
value: ResolvedRecipient;
|
||||
onClear: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { address, kind } = value;
|
||||
|
||||
const displayName =
|
||||
kind === 'sp'
|
||||
? t('walletSend.recipient.silentPayment')
|
||||
: t('walletSend.recipient.bitcoinAddress');
|
||||
const subtitle = truncateAddress(address);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-2xl border border-border bg-muted/40 pl-2 pr-2 py-1.5 w-full min-w-0 max-w-full">
|
||||
{kind === 'sp' ? (
|
||||
<div className="size-9 shrink-0 rounded-full bg-violet-500/10 flex items-center justify-center">
|
||||
<EyeOff className="size-4 text-violet-500" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="size-9 shrink-0 rounded-full bg-orange-500/10 flex items-center justify-center">
|
||||
<Bitcoin className="size-4 text-orange-500" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0 overflow-hidden">
|
||||
<div className="text-[11px] text-muted-foreground leading-tight">
|
||||
{t('walletSend.recipient.toLabel')}
|
||||
</div>
|
||||
<div className="text-sm font-medium truncate">{displayName}</div>
|
||||
<div className="text-xs text-muted-foreground truncate font-mono">
|
||||
{subtitle}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClear}
|
||||
aria-label={t('walletSend.recipient.clear')}
|
||||
className="p-1.5 rounded-full text-muted-foreground hover:text-foreground hover:bg-secondary/60 transition-colors shrink-0"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
getUniqueBitcoinFeeSpeeds,
|
||||
type BitcoinFeeRates,
|
||||
type BitcoinFeeSpeed,
|
||||
} from '@/lib/bitcoinFeeSpeed';
|
||||
import {
|
||||
isFeeRecoverable,
|
||||
type BroadcastErrorKind,
|
||||
} from '@/lib/bitcoinBroadcastError';
|
||||
|
||||
interface BroadcastErrorAlertProps {
|
||||
/** Classifier output from {@link classifyBroadcastError}. */
|
||||
error: BroadcastErrorKind;
|
||||
/** Currently-resolved sat/vB rate, used to decide whether bump can do anything. */
|
||||
currentFeeRate: number | undefined;
|
||||
/** Currently-selected fee tier. */
|
||||
feeSpeed: BitcoinFeeSpeed;
|
||||
/** Loaded fee rates, used to compute the de-duped preset tier list. */
|
||||
feeRates: BitcoinFeeRates | undefined;
|
||||
/** Whether the underlying mutation is in flight (disables actions). */
|
||||
isPending: boolean;
|
||||
/** Bump-fee recovery action. */
|
||||
onBumpFee: () => void;
|
||||
/** Plain retry recovery action (used for `network` failures). */
|
||||
onRetry: () => void;
|
||||
/**
|
||||
* When `true` the component knows there's no custom-rate input available
|
||||
* in the consumer (e.g. {@link DonateDialog}), so we hide the bump button
|
||||
* and surface a static "you're on the fastest tier" message once the
|
||||
* user is already on the top preset.
|
||||
*/
|
||||
presetTiersOnly?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline alert rendered above a Bitcoin transaction's Send button when a
|
||||
* broadcast attempt is rejected. The classifier in
|
||||
* {@link ../lib/bitcoinBroadcastError} maps the raw relay error onto a
|
||||
* small enum; each kind gets specific copy and, where recovery is
|
||||
* possible, an action button.
|
||||
*
|
||||
* Action button rules:
|
||||
*
|
||||
* - **Fee-recoverable kinds** (`feeTooLow`, `mempoolFull`,
|
||||
* `rbfReplacementFeeTooLow`) get **Use a higher fee**, which calls
|
||||
* `onBumpFee`. In `presetTiersOnly` consumers, the button is disabled
|
||||
* when the user is already on the top preset and a separate hint
|
||||
* suggests donating from an external wallet.
|
||||
* - **`network`** gets **Try again**, which re-fires the mutation as-is.
|
||||
* - **Everything else** gets no action button — the user has to adjust
|
||||
* amount or recipient (which the consumer's auto-dismiss effect uses
|
||||
* to clear the alert) before retrying.
|
||||
*
|
||||
* The toast surface is intentionally not used for classified failures.
|
||||
* Toasts auto-dismiss and are visually disconnected from the fee picker;
|
||||
* an inline alert directly above Send keeps the recovery in the donor's
|
||||
* line of sight.
|
||||
*/
|
||||
export function BroadcastErrorAlert({
|
||||
error,
|
||||
currentFeeRate,
|
||||
feeSpeed,
|
||||
feeRates,
|
||||
isPending,
|
||||
onBumpFee,
|
||||
onRetry,
|
||||
presetTiersOnly,
|
||||
}: BroadcastErrorAlertProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { title, body } = useMemo(() => {
|
||||
switch (error.kind) {
|
||||
case 'feeTooLow':
|
||||
return {
|
||||
title: t('walletSend.broadcastError.feeTooLowTitle'),
|
||||
body: error.minRelayFeeRate
|
||||
? t('walletSend.broadcastError.feeTooLowBodyWithMin', { min: error.minRelayFeeRate })
|
||||
: t('walletSend.broadcastError.feeTooLowBody'),
|
||||
};
|
||||
case 'rbfReplacementFeeTooLow':
|
||||
return {
|
||||
title: t('walletSend.broadcastError.rbfTitle'),
|
||||
body: t('walletSend.broadcastError.rbfBody'),
|
||||
};
|
||||
case 'mempoolFull':
|
||||
return {
|
||||
title: t('walletSend.broadcastError.mempoolFullTitle'),
|
||||
body: t('walletSend.broadcastError.mempoolFullBody'),
|
||||
};
|
||||
case 'network':
|
||||
return {
|
||||
title: t('walletSend.broadcastError.networkTitle'),
|
||||
body: t('walletSend.broadcastError.networkBody'),
|
||||
};
|
||||
case 'mempoolConflict':
|
||||
return {
|
||||
title: t('walletSend.broadcastError.mempoolConflictTitle'),
|
||||
body: t('walletSend.broadcastError.mempoolConflictBody'),
|
||||
};
|
||||
case 'tooLongChain':
|
||||
return {
|
||||
title: t('walletSend.broadcastError.tooLongChainTitle'),
|
||||
body: t('walletSend.broadcastError.tooLongChainBody'),
|
||||
};
|
||||
case 'badInputs':
|
||||
return {
|
||||
title: t('walletSend.broadcastError.badInputsTitle'),
|
||||
body: t('walletSend.broadcastError.badInputsBody'),
|
||||
};
|
||||
case 'absurdlyHighFee':
|
||||
return {
|
||||
title: t('walletSend.broadcastError.absurdlyHighFeeTitle'),
|
||||
body: t('walletSend.broadcastError.absurdlyHighFeeBody'),
|
||||
};
|
||||
case 'unknown':
|
||||
default:
|
||||
return {
|
||||
title: t('walletSend.broadcastError.unknownTitle'),
|
||||
// Fall back to the raw bitcoind / framing message so the donor
|
||||
// (or a support thread) has something concrete to act on. Empty
|
||||
// when the classifier had no message to preserve.
|
||||
body: 'raw' in error && error.raw ? error.raw : '',
|
||||
};
|
||||
}
|
||||
}, [error, t]);
|
||||
|
||||
// Decide whether the bump-fee CTA is actually useful here. For consumers
|
||||
// that ship a custom-rate input (the HD wallet flow), the bump is always
|
||||
// useful — we either jump to a faster preset or escalate to a custom
|
||||
// rate seeded from the error. For preset-only consumers (the donate
|
||||
// flow), the button only makes sense while a faster preset exists; once
|
||||
// the user is on the top preset they need to switch to an external
|
||||
// wallet.
|
||||
const uniquePresets = feeRates ? getUniqueBitcoinFeeSpeeds(feeRates) : [];
|
||||
const isCustom = feeSpeed === 'custom';
|
||||
const isOnTopPreset =
|
||||
!isCustom
|
||||
&& uniquePresets.length > 0
|
||||
// Cast through the preset union to avoid `.indexOf` narrowing
|
||||
// `feeSpeed` for the rest of the function body.
|
||||
&& uniquePresets.indexOf(feeSpeed as Exclude<BitcoinFeeSpeed, 'custom'>) === 0;
|
||||
const haveFeeHint =
|
||||
error.kind === 'feeTooLow'
|
||||
&& !!(error.minRelayFeeRate || error.actualFeeRate);
|
||||
|
||||
const showBumpFee = isFeeRecoverable(error.kind) && !(presetTiersOnly && isOnTopPreset);
|
||||
const showAtMaxHint = presetTiersOnly && isOnTopPreset && isFeeRecoverable(error.kind);
|
||||
const canBumpUsefully =
|
||||
!isOnTopPreset || haveFeeHint || isCustom || !!currentFeeRate;
|
||||
|
||||
const showRetry = error.kind === 'network';
|
||||
|
||||
return (
|
||||
<Alert variant="destructive" className="py-2.5">
|
||||
<AlertTriangle className="size-4" />
|
||||
<AlertTitle className="text-sm">{title}</AlertTitle>
|
||||
{body && <AlertDescription className="text-xs mt-1">{body}</AlertDescription>}
|
||||
{showAtMaxHint && (
|
||||
<AlertDescription className="text-xs mt-1 font-medium">
|
||||
{t('walletSend.broadcastError.atMaxFeeTier')}
|
||||
</AlertDescription>
|
||||
)}
|
||||
{(showBumpFee || showRetry) && (
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{showBumpFee && (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onBumpFee}
|
||||
disabled={isPending || !canBumpUsefully}
|
||||
>
|
||||
{t('walletSend.broadcastError.useHigherFee')}
|
||||
</Button>
|
||||
)}
|
||||
{showRetry && (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onRetry}
|
||||
disabled={isPending}
|
||||
>
|
||||
{t('walletSend.broadcastError.tryAgain')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -230,7 +230,7 @@ export function CalendarEventContent({ event, compact, className }: CalendarEven
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center bg-gradient-to-br from-primary/10 via-primary/5 to-transparent py-8">
|
||||
<CalendarDays className="h-10 w-10 text-primary/30" />
|
||||
<CalendarDays className="h-10 w-10 text-primary" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -418,7 +418,7 @@ export function CalendarEventDetailPage({ event }: { event: NostrEvent }) {
|
||||
<img src={image} alt="" className="absolute inset-0 size-full object-cover" />
|
||||
) : (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<CalendarDays className="size-20 text-primary/40" />
|
||||
<CalendarDays className="size-20 text-primary" />
|
||||
</div>
|
||||
)}
|
||||
<div aria-hidden className="absolute inset-x-0 bottom-0 top-[20%] bg-gradient-to-t from-black/95 via-black/80 to-transparent" />
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useMemo } 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 { Card } from '@/components/ui/card';
|
||||
@@ -23,17 +23,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 +36,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
|
||||
@@ -127,6 +136,9 @@ interface CampaignCardProps {
|
||||
*
|
||||
* - `compact` — default grid item.
|
||||
* - `featured` — hero placement (wider, side-by-side on `sm+`).
|
||||
* The token is purely visual — it names the layout, not a
|
||||
* curation state — and stayed after the moderator-level
|
||||
* "Featured" concept was retired in favor of curated lists.
|
||||
* - `shelf` — fixed-width card for horizontal scroll rails (e.g. group
|
||||
* official-activity). Caller no longer hand-rolls the size wrapper.
|
||||
*/
|
||||
@@ -148,7 +160,7 @@ export function CampaignCard({ campaign, variant = 'compact', className, footerB
|
||||
});
|
||||
const displayCampaign = parseCampaign(translatedEvent) ?? campaign;
|
||||
const author = useAuthor(campaign.pubkey);
|
||||
const { data: stats } = useCampaignDonations(campaign);
|
||||
const { data: stats, isLoading: donationsLoading } = useCampaignDonations(campaign);
|
||||
const { data: btcPrice } = useBtcPrice();
|
||||
|
||||
const naddr = useMemo(() => encodeCampaignNaddr(campaign), [campaign]);
|
||||
@@ -156,7 +168,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
|
||||
@@ -181,7 +192,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
|
||||
@@ -201,14 +212,14 @@ export function CampaignCard({ campaign, variant = 'compact', className, footerB
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<HandHeart className="size-12 text-primary/40" />
|
||||
<HandHeart className="size-12 text-primary" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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"
|
||||
@@ -220,26 +231,14 @@ 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) && (
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
@@ -247,7 +246,7 @@ export function CampaignCard({ campaign, variant = 'compact', className, footerB
|
||||
coord={campaign.aTag}
|
||||
entityTitle={campaign.title}
|
||||
surface="campaign"
|
||||
axes={['approval', 'hide', 'featured']}
|
||||
axes={['hide']}
|
||||
badgeSize="default"
|
||||
className="absolute top-3 right-3 z-10 flex items-center gap-2"
|
||||
/>
|
||||
@@ -284,7 +283,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">
|
||||
|
||||
@@ -51,14 +51,12 @@ function buildQrPayload(wallets: CampaignWallets): string {
|
||||
* Inline panel rendering the campaign's wallet endpoints as a scannable
|
||||
* QR code, a copyable string, and an "Open in wallet" button.
|
||||
*
|
||||
* Behavior:
|
||||
* Behavior — the QR and the copyable row always carry a `bitcoin:`
|
||||
* BIP-21 URI, regardless of which endpoints the campaign exposes:
|
||||
*
|
||||
* - **on-chain only** (`bc1q…` / `bc1p…`) — BIP-21 QR with the address
|
||||
* and a copyable row for the raw address.
|
||||
* - **silent payment only** (`sp1…`) — raw silent-payment code QR and a
|
||||
* copyable row for the raw SP code.
|
||||
* - **both** — combined BIP-21 URI in the QR and a single copyable row
|
||||
* containing the same `bitcoin:<addr>?sp=<sp>` URI; BIP-352-aware
|
||||
* - **on-chain only** (`bc1q…` / `bc1p…`) — `bitcoin:<bc1>`.
|
||||
* - **silent payment only** (`sp1…`) — `bitcoin:?sp=<sp1>`.
|
||||
* - **both** — combined `bitcoin:<bc1>?sp=<sp1>` URI; BIP-352-aware
|
||||
* wallets pick the SP path automatically, legacy wallets fall back to
|
||||
* the on-chain address.
|
||||
*
|
||||
@@ -72,17 +70,13 @@ export function CampaignWalletDonatePanel({
|
||||
}: CampaignWalletDonatePanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const qrPayload = buildQrPayload(wallets);
|
||||
const { onchain, sp } = wallets;
|
||||
|
||||
// When both endpoints are present, donors copy the same BIP-21 URI
|
||||
// that the QR encodes — modern wallets parse it in their recipient
|
||||
// field. When only one endpoint exists, the raw value is friendlier.
|
||||
const copyValue = onchain && sp ? qrPayload : (onchain?.value ?? sp?.value ?? '');
|
||||
const copyLabel = onchain && sp
|
||||
? 'Payment URI'
|
||||
: sp
|
||||
? 'Silent-payment code'
|
||||
: 'Bitcoin address';
|
||||
// Donors always copy the same BIP-21 URI that the QR encodes — modern
|
||||
// wallets parse it in their recipient field, and a `bitcoin:` URI
|
||||
// round-trips through any wallet whether the campaign exposes an
|
||||
// on-chain address, a silent-payment code, or both.
|
||||
const copyValue = qrPayload;
|
||||
const copyLabel = 'Payment URI';
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
@@ -91,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>
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { CAMPAIGN_CATEGORIES } from '@/lib/campaignCategories';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface CategoryPickerProps {
|
||||
/** Set of currently-selected category slugs. */
|
||||
selected: Set<string>;
|
||||
/** Called with the slug whenever a pill is tapped. */
|
||||
onToggle: (slug: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Multi-select pill row of curated content categories — shared by
|
||||
* Agora's campaign and group creation flows. Each chip renders a
|
||||
* Lucide icon + a localized label, and toggling it adds or removes
|
||||
* that category's slug from the parent's selection set.
|
||||
*
|
||||
* The picker has no protocol-level awareness — the parent serializes
|
||||
* each selected slug as an ordinary `['t', slug]` tag, which keeps
|
||||
* the published events fully readable by any Nostr client that
|
||||
* already understands content tags.
|
||||
*
|
||||
* Layout is a free-flowing `flex flex-wrap` row: each pill sizes to
|
||||
* its own text, and the row breaks whenever the next pill wouldn't
|
||||
* fit. The category list is intentionally shared between campaigns
|
||||
* and groups so the same Lucide vocabulary feels consistent across
|
||||
* the two flows.
|
||||
*/
|
||||
export function CategoryPicker({ selected, onToggle }: CategoryPickerProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
// Free-flowing pill row: each chip sizes to its own text, the row
|
||||
// wraps to a new line whenever the next chip wouldn't fit. Some
|
||||
// rows naturally land at three pills, others at four — driven by
|
||||
// the labels' intrinsic widths rather than a fixed column count.
|
||||
// Each pill is fully rounded with generous horizontal padding so
|
||||
// it reads as a tag, not a grid cell.
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{CAMPAIGN_CATEGORIES.map(({ slug, labelKey, Icon }) => {
|
||||
const isSelected = selected.has(slug);
|
||||
return (
|
||||
<button
|
||||
key={slug}
|
||||
type="button"
|
||||
onClick={() => onToggle(slug)}
|
||||
aria-pressed={isSelected}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-full border px-3.5 py-2 text-sm whitespace-nowrap transition-colors motion-safe:transition-shadow',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||
isSelected
|
||||
? 'border-primary bg-primary/10 text-foreground shadow-sm'
|
||||
: 'border-border bg-background hover:border-primary/40 hover:bg-primary/5 text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
className={cn(
|
||||
'size-4 shrink-0',
|
||||
isSelected ? 'text-primary' : 'text-muted-foreground',
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{t(labelKey)}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -989,7 +989,7 @@ function IsbnCommentContext({ identifier, className, prefix }: { identifier: str
|
||||
/>
|
||||
) : (
|
||||
<div className="w-9 h-12 rounded bg-secondary flex items-center justify-center shrink-0">
|
||||
<BookOpen className="size-4 text-muted-foreground/40" />
|
||||
<BookOpen className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
@@ -1070,7 +1070,7 @@ function GathererCardCommentContext({
|
||||
/>
|
||||
) : (
|
||||
<div className="w-9 h-12 rounded bg-secondary flex items-center justify-center shrink-0">
|
||||
<CardsIcon className="size-4 text-muted-foreground/40" />
|
||||
<CardsIcon className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
|
||||
@@ -31,6 +31,7 @@ import { PostActionBar } from '@/components/PostActionBar';
|
||||
import { CommentsSection } from '@/components/CommentsSection';
|
||||
import { DetailCommentComposer } from '@/components/DetailCommentComposer';
|
||||
import { PinnedCommentHeader } from '@/components/PinnedCommentHeader';
|
||||
import { StartCampaignLink } from '@/components/StartCampaignLink';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -284,7 +285,7 @@ function CalendarEventShelfCard({ event }: { event: NostrEvent }) {
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<CalendarDays className="size-12 text-primary/40" />
|
||||
<CalendarDays className="size-12 text-primary" />
|
||||
</div>
|
||||
)}
|
||||
{dateLabel && (
|
||||
@@ -515,7 +516,7 @@ function GroupActionColumn({
|
||||
</div>
|
||||
|
||||
<div className="relative grid grid-cols-2 gap-3">
|
||||
<Link
|
||||
<StartCampaignLink
|
||||
to={`/campaigns/new${createQuery}`}
|
||||
className="group col-span-2 overflow-hidden rounded-2xl border border-primary/20 bg-primary text-primary-foreground shadow-sm transition-all hover:-translate-y-0.5 hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background motion-reduce:hover:translate-y-0"
|
||||
>
|
||||
@@ -534,7 +535,7 @@ function GroupActionColumn({
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</StartCampaignLink>
|
||||
|
||||
<Link
|
||||
to={`/pledges/new${createQuery}`}
|
||||
@@ -833,7 +834,7 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
|
||||
<img src={cover} alt="" className="absolute inset-0 size-full object-cover" />
|
||||
) : (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-gradient-to-br from-primary/50 via-primary/25 to-secondary">
|
||||
<Users className="size-20 text-primary/40" />
|
||||
<Users className="size-20 text-primary" />
|
||||
</div>
|
||||
)}
|
||||
<div aria-hidden className="absolute inset-x-0 bottom-0 top-[18%] bg-gradient-to-t from-black/95 via-black/80 to-transparent" />
|
||||
|
||||
@@ -1,38 +1,76 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useId, useMemo, useState } from 'react';
|
||||
import { useTranslation, Trans } from 'react-i18next';
|
||||
import { MapPin, X } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { CountryFlag } from '@/components/CountryFlag';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { getCountryInfo, searchCountries, type CountryEntry } from '@/lib/countries';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface CountrySelectProps {
|
||||
id: string;
|
||||
export interface CountrySelectProps {
|
||||
/** Current free-text query in the input. */
|
||||
query: string;
|
||||
/** Currently-selected ISO 3166 code (e.g. "US"). Empty string when none. */
|
||||
selectedCode: string;
|
||||
onQueryChange: (value: string) => void;
|
||||
onSelect: (country: CountryEntry) => void;
|
||||
onClear: () => void;
|
||||
/**
|
||||
* Explicit DOM `id` for the input. Optional — a stable `useId()`
|
||||
* value is generated when not provided. Callers that already wire
|
||||
* their own form labels (e.g. a `<label htmlFor={…}>` outside the
|
||||
* picker) should pass a known id; the wizard flows leave it
|
||||
* auto-generated.
|
||||
*/
|
||||
id?: string;
|
||||
/** Override the localized "Search countries" placeholder. */
|
||||
placeholder?: string;
|
||||
/**
|
||||
* Hide the i18n hint that explains the `i: iso3166:<code>` tag
|
||||
* we publish. Default `false` — the creation flows show it; the
|
||||
* event-detail dialog hides it because the surrounding card already
|
||||
* documents the behavior.
|
||||
*/
|
||||
hideHint?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Combobox-style country picker used across Agora's creation flows
|
||||
* (campaigns, groups, calendar events, …). Shows a `MapPin` icon, a
|
||||
* clear button when a value is present, and a dropdown of
|
||||
* `searchCountries(query)` results with full keyboard support
|
||||
* (ArrowUp/Down/Enter/Escape).
|
||||
*
|
||||
* The selection produces a country code (`onSelect(country.code)`)
|
||||
* that the parent serializes as `['i', 'iso3166:<CC>']` + `['k',
|
||||
* 'iso3166']` on its event.
|
||||
*
|
||||
* All i18n strings live under the shared `forms.*` namespace so the
|
||||
* picker drops into any flow without per-page key duplication.
|
||||
*/
|
||||
export function CountrySelect({
|
||||
id,
|
||||
query,
|
||||
selectedCode,
|
||||
onQueryChange,
|
||||
onSelect,
|
||||
onClear,
|
||||
id,
|
||||
placeholder,
|
||||
hideHint = false,
|
||||
}: CountrySelectProps) {
|
||||
const { t } = useTranslation();
|
||||
// `useId` gives us a stable, unique pair of ids for the
|
||||
// combobox/listbox association without forcing the caller to pass
|
||||
// a name — important when the wizard mounts the picker multiple
|
||||
// times across step navigations.
|
||||
const generatedId = useId();
|
||||
const inputId = id ?? generatedId;
|
||||
const listboxId = `${inputId}-results`;
|
||||
const [open, setOpen] = useState(false);
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const selectedCountry = selectedCode ? getCountryInfo(selectedCode) : undefined;
|
||||
const results = useMemo(() => searchCountries(query), [query]);
|
||||
const showResults = open && results.length > 0;
|
||||
const resultsId = `${id}-results`;
|
||||
|
||||
const selectCountry = (country: CountryEntry) => {
|
||||
onSelect(country);
|
||||
@@ -45,7 +83,7 @@ export function CountrySelect({
|
||||
<div className="relative">
|
||||
<MapPin className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
id={id}
|
||||
id={inputId}
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
onQueryChange(e.target.value);
|
||||
@@ -74,14 +112,14 @@ export function CountrySelect({
|
||||
autoComplete="off"
|
||||
role="combobox"
|
||||
aria-expanded={showResults}
|
||||
aria-controls={resultsId}
|
||||
aria-controls={listboxId}
|
||||
/>
|
||||
{(query || selectedCode) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClear}
|
||||
className="absolute right-2 top-1/2 rounded-full p-1 -translate-y-1/2 text-muted-foreground hover:bg-muted hover:text-foreground motion-safe:transition-colors"
|
||||
aria-label="Clear country"
|
||||
aria-label={t('forms.countryClearAria')}
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
@@ -89,7 +127,7 @@ export function CountrySelect({
|
||||
|
||||
{showResults && (
|
||||
<div
|
||||
id={resultsId}
|
||||
id={listboxId}
|
||||
role="listbox"
|
||||
className="absolute z-20 mt-2 max-h-[200px] w-full overflow-y-auto rounded-xl border border-border bg-popover py-1 shadow-lg"
|
||||
>
|
||||
@@ -110,7 +148,7 @@ export function CountrySelect({
|
||||
<CountryFlag
|
||||
code={country.code}
|
||||
emoji={country.flag}
|
||||
label={`Flag of ${country.name}`}
|
||||
label={t('forms.flagOfAria', { name: country.name })}
|
||||
className="text-lg"
|
||||
/>
|
||||
</span>
|
||||
@@ -124,9 +162,13 @@ export function CountrySelect({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedCountry && (
|
||||
{selectedCountry && !hideHint && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Publishes <span className="font-mono text-foreground">i: iso3166:{selectedCode}</span> for country sorting.
|
||||
<Trans
|
||||
i18nKey="forms.countryHint"
|
||||
values={{ code: selectedCode }}
|
||||
components={{ 0: <span className="font-mono text-foreground" /> }}
|
||||
/>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,9 @@ import { useEffect, useState } from 'react';
|
||||
import { ImagePlus, Loader2, X } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { ImageCropDialog } from '@/components/ImageCropDialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { useUploadFile } from '@/hooks/useUploadFile';
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
@@ -36,6 +38,24 @@ interface CoverImageFieldProps {
|
||||
onUploadComplete?: (nip94Tags: string[][]) => void;
|
||||
/** Optional template gallery shown between the dropzone and the URL input. */
|
||||
templates?: readonly CoverImageTemplate[];
|
||||
/**
|
||||
* Aspect ratio (width / height) the crop dialog enforces. Defaults to
|
||||
* `3` (3:1 banner). Pass a different value for non-banner cover surfaces
|
||||
* if/when one appears — for now every consumer (campaigns, events,
|
||||
* communities, actions) renders the cover at roughly 3:1.
|
||||
*/
|
||||
cropAspect?: number;
|
||||
/**
|
||||
* Maximum long-edge size (px) of the cropped JPEG. Defaults to `1600`
|
||||
* — plenty for 2x retina at typical banner widths while keeping uploads
|
||||
* well under 1 MB at q=0.92. Pass `0` to disable the cap.
|
||||
*
|
||||
* Honored only when the user's `imageQuality` preference is
|
||||
* `'compressed'` (the default). Users who opt into `'original'` via
|
||||
* Network Settings get the full-resolution crop with no dimension cap,
|
||||
* matching the behavior of ComposeBox / ImageUploadField.
|
||||
*/
|
||||
cropMaxOutputSize?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -54,11 +74,16 @@ interface CoverImageFieldProps {
|
||||
* anything other than a well-formed https URL — that's deliberate, since
|
||||
* the same value is what gets published in the Nostr event's `image` tag.
|
||||
*/
|
||||
export function CoverImageField({ value, onChange, onUploadingChange, onUploadComplete, templates }: CoverImageFieldProps) {
|
||||
export function CoverImageField({ value, onChange, onUploadingChange, onUploadComplete, templates, cropAspect = 3, cropMaxOutputSize = 1600 }: CoverImageFieldProps) {
|
||||
const { t } = useTranslation();
|
||||
const { config } = useAppContext();
|
||||
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
|
||||
const { toast } = useToast();
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
// Crop state holds the object URL of the user's source file while the
|
||||
// dialog is open. We revoke it on every exit path (confirm, cancel,
|
||||
// unmount) so blob: URLs don't leak across multiple picks.
|
||||
const [cropImageSrc, setCropImageSrc] = useState<string | null>(null);
|
||||
|
||||
const sanitized = sanitizeUrl(value);
|
||||
|
||||
@@ -67,11 +92,13 @@ export function CoverImageField({ value, onChange, onUploadingChange, onUploadCo
|
||||
}, [isUploading, onUploadingChange]);
|
||||
|
||||
/**
|
||||
* Shared upload path used by both the file-input change handler and
|
||||
* the drag-and-drop handler. Validates the MIME type up front so a
|
||||
* stray dragged-in PDF or video doesn't end up posted to Blossom.
|
||||
* Shared entry point for both the file-input change handler and the
|
||||
* drag-and-drop handler. Validates the MIME type up front so a stray
|
||||
* dragged-in PDF or video doesn't open the cropper, then hands off to
|
||||
* `ImageCropDialog`. The actual Blossom upload happens after the user
|
||||
* confirms the crop in `handleCropConfirm`.
|
||||
*/
|
||||
const uploadCoverFile = async (file: File) => {
|
||||
const handleSourceFile = (file: File) => {
|
||||
if (!/^image\/(png|jpeg|webp)$/.test(file.type)) {
|
||||
toast({
|
||||
title: 'Unsupported file type',
|
||||
@@ -80,6 +107,18 @@ export function CoverImageField({ value, onChange, onUploadingChange, onUploadCo
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Discard any prior object URL before allocating a new one — picking
|
||||
// a second file without confirming the first would otherwise leak.
|
||||
if (cropImageSrc) URL.revokeObjectURL(cropImageSrc);
|
||||
setCropImageSrc(URL.createObjectURL(file));
|
||||
};
|
||||
|
||||
const handleCropConfirm = async (file: File) => {
|
||||
const src = cropImageSrc;
|
||||
setCropImageSrc(null);
|
||||
if (src) URL.revokeObjectURL(src);
|
||||
// The crop dialog hands back a fully-formed File (JPEG or PNG,
|
||||
// whichever encoded smaller — see encodeImage in @/lib/resizeImage).
|
||||
try {
|
||||
const tags = await uploadFile(file);
|
||||
const [[, url]] = tags;
|
||||
@@ -105,6 +144,20 @@ export function CoverImageField({ value, onChange, onUploadingChange, onUploadCo
|
||||
}
|
||||
};
|
||||
|
||||
const handleCropCancel = () => {
|
||||
const src = cropImageSrc;
|
||||
setCropImageSrc(null);
|
||||
if (src) URL.revokeObjectURL(src);
|
||||
};
|
||||
|
||||
// Revoke any lingering object URL on unmount so a navigation-away
|
||||
// while the dialog is open doesn't leak the blob.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (cropImageSrc) URL.revokeObjectURL(cropImageSrc);
|
||||
};
|
||||
}, [cropImageSrc]);
|
||||
|
||||
const handleDragOver = (e: React.DragEvent<HTMLLabelElement>) => {
|
||||
// Without preventDefault, the browser navigates to the dropped file
|
||||
// instead of letting our onDrop handler claim it.
|
||||
@@ -122,13 +175,13 @@ export function CoverImageField({ value, onChange, onUploadingChange, onUploadCo
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
const handleDrop = async (e: React.DragEvent<HTMLLabelElement>) => {
|
||||
const handleDrop = (e: React.DragEvent<HTMLLabelElement>) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
if (isUploading) return;
|
||||
const file = e.dataTransfer.files?.[0];
|
||||
if (!file) return;
|
||||
await uploadCoverFile(file);
|
||||
handleSourceFile(file);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -187,7 +240,7 @@ export function CoverImageField({ value, onChange, onUploadingChange, onUploadCo
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.currentTarget.value = '';
|
||||
if (file) void uploadCoverFile(file);
|
||||
if (file) handleSourceFile(file);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
@@ -230,6 +283,18 @@ export function CoverImageField({ value, onChange, onUploadingChange, onUploadCo
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
|
||||
{cropImageSrc && (
|
||||
<ImageCropDialog
|
||||
open
|
||||
imageSrc={cropImageSrc}
|
||||
aspect={cropAspect}
|
||||
maxOutputSize={config.imageQuality === 'compressed' ? (cropMaxOutputSize || undefined) : undefined}
|
||||
title="Crop cover image"
|
||||
onCancel={handleCropCancel}
|
||||
onCrop={handleCropConfirm}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -133,7 +133,14 @@ export function DiscoverySearchToolbar({
|
||||
key={value}
|
||||
checked={sort === value}
|
||||
onCheckedChange={(checked) => {
|
||||
// `checked === false` means the user clicked the
|
||||
// currently-active item — return to the curated
|
||||
// `default` view (featured-first) rather than leaving
|
||||
// them stuck on Top/New with no exit affordance now
|
||||
// that `default` is no longer an exposed option in the
|
||||
// dropdown.
|
||||
if (checked) onSortChange(value);
|
||||
else onSortChange('default');
|
||||
}}
|
||||
// The checkbox slot on the left is hidden in favour of an
|
||||
// explicit `Check` on the right (matches the
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import AuthDialog from '@/components/auth/AuthDialog';
|
||||
import { BroadcastErrorAlert } from '@/components/BroadcastErrorAlert';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { useBitcoinSigner } from '@/hooks/useBitcoinSigner';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
@@ -53,6 +54,10 @@ import {
|
||||
usdToSats,
|
||||
type FeeRates,
|
||||
} from '@/lib/bitcoin';
|
||||
import {
|
||||
classifyBroadcastError,
|
||||
type BroadcastErrorKind,
|
||||
} from '@/lib/bitcoinBroadcastError';
|
||||
import {
|
||||
type ParsedCampaign,
|
||||
} from '@/lib/campaign';
|
||||
@@ -130,15 +135,30 @@ export function DonateDialog({ campaign, open, onOpenChange, btcPrice }: DonateD
|
||||
const [comment, setComment] = useState('');
|
||||
const [feeSpeed, setFeeSpeed] = useState<DonationFeeSpeed>('fastest');
|
||||
const [result, setResult] = useState<DonateCampaignResult | null>(null);
|
||||
/**
|
||||
* Classified failure from the most recent broadcast attempt. Renders as
|
||||
* an inline {@link BroadcastErrorAlert} above the Send button in the
|
||||
* confirm step. Cleared when the donor adjusts the fee speed or returns
|
||||
* to the form step.
|
||||
*/
|
||||
const [broadcastError, setBroadcastError] = useState<BroadcastErrorKind | null>(null);
|
||||
|
||||
// Reset when the dialog reopens for a fresh donation.
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setStep('form');
|
||||
setResult(null);
|
||||
setBroadcastError(null);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Clear the broadcast-error alert whenever the donor adjusts the fee
|
||||
// speed — the explicit recovery action — so the alert disappears when
|
||||
// they engage with the picker.
|
||||
useEffect(() => {
|
||||
setBroadcastError(null);
|
||||
}, [feeSpeed]);
|
||||
|
||||
const effectiveUsd = customUsd.trim()
|
||||
? parseUsdInput(customUsd)
|
||||
: amountUsd;
|
||||
@@ -171,12 +191,21 @@ export function DonateDialog({ campaign, open, onOpenChange, btcPrice }: DonateD
|
||||
}
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
toast({
|
||||
title: 'Donation failed',
|
||||
description: msg,
|
||||
variant: 'destructive',
|
||||
});
|
||||
const classified = classifyBroadcastError(error);
|
||||
setBroadcastError(classified);
|
||||
// Inline `<BroadcastErrorAlert>` in the confirm step is the primary
|
||||
// recovery surface for classified failures; a destructive toast on
|
||||
// top would just be noise. Keep the toast as a fallback for the
|
||||
// catch-all `unknown` bucket so the donor always sees *something*
|
||||
// even when we can't recognise the reject reason.
|
||||
if (classified.kind === 'unknown') {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
toast({
|
||||
title: 'Donation failed',
|
||||
description: msg,
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -249,8 +278,21 @@ export function DonateDialog({ campaign, open, onOpenChange, btcPrice }: DonateD
|
||||
feeSpeed={feeSpeed}
|
||||
btcPrice={btcPrice}
|
||||
isPending={donateMutation.isPending}
|
||||
onBack={() => setStep('form')}
|
||||
broadcastError={broadcastError}
|
||||
onBack={() => {
|
||||
setBroadcastError(null);
|
||||
setStep('form');
|
||||
}}
|
||||
onSubmit={() => donateMutation.mutate()}
|
||||
onBumpFee={() => {
|
||||
// Step toward the fastest preset. BITCOIN_FEE_SPEED_ORDER is
|
||||
// declared fast → slow; index 0 is `fastest`, so "bump" means
|
||||
// moving toward index 0.
|
||||
const order: DonationFeeSpeed[] = ['fastest', 'halfHour', 'hour', 'economy'];
|
||||
const idx = order.indexOf(feeSpeed);
|
||||
if (idx > 0) setFeeSpeed(order[idx - 1]);
|
||||
// `useEffect([feeSpeed])` clears the broadcastError alert.
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -448,8 +490,12 @@ interface ConfirmViewProps {
|
||||
feeSpeed: DonationFeeSpeed;
|
||||
btcPrice: number | undefined;
|
||||
isPending: boolean;
|
||||
/** Classified failure from the most recent broadcast attempt, if any. */
|
||||
broadcastError: BroadcastErrorKind | null;
|
||||
onBack: () => void;
|
||||
onSubmit: () => void;
|
||||
/** Steps `feeSpeed` toward the fastest preset; no-op once at `fastest`. */
|
||||
onBumpFee: () => void;
|
||||
}
|
||||
|
||||
function ConfirmView({
|
||||
@@ -460,8 +506,10 @@ function ConfirmView({
|
||||
feeSpeed,
|
||||
btcPrice,
|
||||
isPending,
|
||||
broadcastError,
|
||||
onBack,
|
||||
onSubmit,
|
||||
onBumpFee,
|
||||
}: ConfirmViewProps) {
|
||||
const { user } = useCurrentUser();
|
||||
const { config } = useAppContext();
|
||||
@@ -552,6 +600,25 @@ function ConfirmView({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Classified broadcast failure with an actionable bump-fee recovery.
|
||||
Sits between the donation rows and the Send button so the donor
|
||||
sees the alert in the same visual region they're about to tap.
|
||||
`presetTiersOnly` hides the bump button once they're on the
|
||||
fastest preset — at that point the recommendation is to switch
|
||||
to an external wallet via the panel on the campaign detail page. */}
|
||||
{broadcastError && (
|
||||
<BroadcastErrorAlert
|
||||
error={broadcastError}
|
||||
currentFeeRate={feeRatesQuery.data ? feeRateForSpeed(feeRatesQuery.data, feeSpeed) : undefined}
|
||||
feeSpeed={feeSpeed}
|
||||
feeRates={feeRatesQuery.data}
|
||||
isPending={isPending}
|
||||
onBumpFee={onBumpFee}
|
||||
onRetry={onSubmit}
|
||||
presetTiersOnly
|
||||
/>
|
||||
)}
|
||||
|
||||
<Button
|
||||
size="lg"
|
||||
className="w-full"
|
||||
|
||||
@@ -183,7 +183,7 @@ function EmbeddedBadgeCard({ event, className }: { event: NostrEvent; className?
|
||||
/>
|
||||
) : (
|
||||
<div className="size-20 rounded-xl bg-gradient-to-br from-primary/10 via-primary/5 to-transparent flex items-center justify-center">
|
||||
<Award className="size-8 text-primary/30" />
|
||||
<Award className="size-8 text-primary" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -471,7 +471,7 @@ export function EmojiPackDialog({ open, onOpenChange, editEvent }: EmojiPackDial
|
||||
} ${emojis.length > 0 ? 'h-20' : 'h-28'} ${isSubmitting ? 'pointer-events-none opacity-50' : ''}`}
|
||||
>
|
||||
<div className="flex flex-col items-center gap-1.5 text-muted-foreground">
|
||||
<Upload className="size-4 opacity-50" />
|
||||
<Upload className="size-4" />
|
||||
<span className="text-xs text-center px-4">
|
||||
Drop images or a folder here, or click to browse
|
||||
</span>
|
||||
|
||||
@@ -486,7 +486,7 @@ export function BookContentHeader({ isbn }: { isbn: string }) {
|
||||
</div>
|
||||
) : (
|
||||
<div className="shrink-0 w-[120px] sm:w-[140px] h-[180px] sm:h-[210px] rounded-lg bg-secondary flex items-center justify-center">
|
||||
<BookOpen className="size-10 text-muted-foreground/40" />
|
||||
<BookOpen className="size-10 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -873,7 +873,7 @@ export function CountryContentHeader({ code }: { code: string }) {
|
||||
if (!info) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-border p-5 text-center mx-4">
|
||||
<MapPin className="size-8 mx-auto mb-2 text-muted-foreground/40" />
|
||||
<MapPin className="size-8 mx-auto mb-2 text-muted-foreground" />
|
||||
<p className="text-muted-foreground">Unknown country code: {code}</p>
|
||||
</div>
|
||||
);
|
||||
@@ -1199,7 +1199,7 @@ function BookPreview({ isbn, link }: { isbn: string; link: string }) {
|
||||
/>
|
||||
) : (
|
||||
<div className="w-9 h-12 rounded bg-secondary flex items-center justify-center shrink-0">
|
||||
<BookOpen className="size-4 text-muted-foreground/40" />
|
||||
<BookOpen className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1312,7 +1312,7 @@ export function CommunityPreview({ addr }: { addr: { kind: number; pubkey: strin
|
||||
/>
|
||||
) : (
|
||||
<div className="size-12 rounded-lg bg-primary/10 flex items-center justify-center shrink-0">
|
||||
<Users className="size-5 text-primary/50" />
|
||||
<Users className="size-5 text-primary" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1390,4 +1390,3 @@ export function ProfilePreview({ pubkey }: { pubkey: string }) {
|
||||
// ---------------------------------------------------------------------------
|
||||
// Addressable event preview (vines, music, articles, etc.)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import { isEventMuted } from '@/lib/muteHelpers';
|
||||
import { SubHeaderBar } from '@/components/SubHeaderBar';
|
||||
import { TabButton } from '@/components/TabButton';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { getEventRelaySource } from '@/lib/relayDebug';
|
||||
import type { FeedItem } from '@/lib/feedUtils';
|
||||
|
||||
type CoreFeedTab = 'follows' | 'network' | 'global' | 'communities' | 'world' | 'agora';
|
||||
@@ -186,6 +187,25 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
|
||||
});
|
||||
}, [isHomeAgoraFeed, mixedFeed.items, rawData?.pages, muteItems]);
|
||||
|
||||
useEffect(() => {
|
||||
if (feedItems.length === 0) return;
|
||||
|
||||
console.groupCollapsed('[nostr feed render]', {
|
||||
feedId,
|
||||
mode: isHomeAgoraFeed ? homeFeedMode : activeTab,
|
||||
count: feedItems.length,
|
||||
});
|
||||
console.table(feedItems.map((item, index) => ({
|
||||
index,
|
||||
relay: getEventRelaySource(item.event.id) ?? 'unknown',
|
||||
kind: item.event.kind,
|
||||
id: item.event.id,
|
||||
created_at: item.event.created_at,
|
||||
repostedBy: item.repostedBy ?? '',
|
||||
})));
|
||||
console.groupEnd();
|
||||
}, [feedId, isHomeAgoraFeed, homeFeedMode, activeTab, feedItems]);
|
||||
|
||||
// Show skeletons while loading.
|
||||
const showSkeleton = isHomeAgoraFeed
|
||||
? mixedFeed.isLoading && feedItems.length === 0
|
||||
|
||||
@@ -45,7 +45,7 @@ function PackFeedTab({ pubkeys }: { pubkeys: string[] }) {
|
||||
if (pubkeys.length === 0) {
|
||||
return (
|
||||
<div className="py-16 text-center text-muted-foreground">
|
||||
<Users className="size-8 mx-auto mb-2 opacity-50" />
|
||||
<Users className="size-8 mx-auto mb-2" />
|
||||
<p className="text-sm">No members in this pack yet.</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -41,7 +41,7 @@ function GifThumbnail({ gif, onClick }: { gif: GifResult; onClick: (gif: GifResu
|
||||
{/* Error state */}
|
||||
{error && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-muted rounded-lg">
|
||||
<ImageOff className="size-5 text-muted-foreground/40" />
|
||||
<ImageOff className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -173,7 +173,7 @@ export function GifPicker({ onSelect }: GifPickerProps) {
|
||||
</div>
|
||||
) : isError ? (
|
||||
<div className="flex flex-col items-center justify-center h-48 text-muted-foreground">
|
||||
<ImageOff className="size-8 mb-2 opacity-40" />
|
||||
<ImageOff className="size-8 mb-2" />
|
||||
<p className="text-sm">Failed to load GIFs</p>
|
||||
<p className="text-xs mt-1">Please try again</p>
|
||||
</div>
|
||||
@@ -189,4 +189,3 @@ export function GifPicker({ onSelect }: GifPickerProps) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -237,7 +237,7 @@ export function GiveBadgeDialog({ open, onOpenChange, recipientPubkey, recipient
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-10 text-muted-foreground text-sm">
|
||||
<Award className="size-8 mb-2 opacity-30" />
|
||||
<Award className="size-8 mb-2" />
|
||||
<p>You haven't created any badges yet.</p>
|
||||
<p className="text-xs mt-1">Create badges on the <a href="/badges" className="text-primary hover:underline">Badges page</a>.</p>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useState, useCallback, useMemo, useRef, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import {
|
||||
AlertTriangle,
|
||||
Check,
|
||||
@@ -23,9 +22,14 @@ import {
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { BitcoinAmountPicker } from '@/components/BitcoinAmountPicker';
|
||||
import { BitcoinPublicDisclaimer } from '@/components/BitcoinPublicDisclaimer';
|
||||
import { BitcoinRecipientInput } from '@/components/BitcoinRecipientInput';
|
||||
import {
|
||||
BitcoinRecipientInput,
|
||||
type ResolvedRecipient,
|
||||
} from '@/components/BitcoinRecipientInput';
|
||||
import { BroadcastErrorAlert } from '@/components/BroadcastErrorAlert';
|
||||
import { HelpTip } from '@/components/HelpTip';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@@ -37,24 +41,26 @@ import { notificationSuccess } from '@/lib/haptics';
|
||||
import {
|
||||
getBitcoinFeeRate,
|
||||
getUniqueBitcoinFeeSpeeds,
|
||||
resolveBitcoinFeeRate,
|
||||
type BitcoinFeeSpeed,
|
||||
} from '@/lib/bitcoinFeeSpeed';
|
||||
import {
|
||||
isLargeAmount,
|
||||
nostrPubkeyToBitcoinAddress,
|
||||
satsToUSD,
|
||||
} from '@/lib/bitcoin';
|
||||
classifyBroadcastError,
|
||||
type BroadcastErrorKind,
|
||||
} from '@/lib/bitcoinBroadcastError';
|
||||
import { formatSats, isLargeAmount, satsToUSD } from '@/lib/bitcoin';
|
||||
import {
|
||||
broadcastBlockbookTx,
|
||||
fetchFeeRates,
|
||||
} from '@/lib/hdwallet/blockbook';
|
||||
import {
|
||||
buildHdSpendPsbt,
|
||||
buildHdMaxSpendPsbt,
|
||||
finalizeHdPsbt,
|
||||
type HdInput,
|
||||
type HdSpendableSpUtxo,
|
||||
type HdSpendableUtxo,
|
||||
parseHdRecipient,
|
||||
previewHdMaxSpend,
|
||||
previewHdFee,
|
||||
signHdPsbt,
|
||||
} from '@/lib/hdwallet/transaction';
|
||||
@@ -64,78 +70,10 @@ 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;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Recipient resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ResolvedRecipient {
|
||||
/**
|
||||
* Final P2TR/P2WPKH/etc. address used as the PSBT output.
|
||||
*
|
||||
* For silent-payment (`sp1…`) recipients this is the original `sp1…`
|
||||
* string — the real on-chain `P_k` is derived at build time, after coin
|
||||
* selection. The dialog never displays this value directly when
|
||||
* `kind === 'sp'`; it's kept here so {@link buildHdSpendPsbt} can route
|
||||
* by recipient kind.
|
||||
*/
|
||||
address: string;
|
||||
/** Optional Nostr pubkey when the recipient was an npub/nprofile. */
|
||||
pubkey?: string;
|
||||
/** Raw text the user typed (for re-display). */
|
||||
raw: string;
|
||||
/**
|
||||
* Recipient kind. `'address'` for bare Bitcoin addresses (including
|
||||
* Nostr-derived ones); `'sp'` for BIP-352 silent-payment addresses.
|
||||
*/
|
||||
kind: 'address' | 'sp';
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the recipient input as one of:
|
||||
* - bare Bitcoin address (mainnet, any standard type)
|
||||
* - silent-payment address (`sp1…`, mainnet, v0)
|
||||
* - npub1… → P2TR derived from the Nostr pubkey
|
||||
* - nprofile1… → P2TR derived from the encoded pubkey
|
||||
*
|
||||
* Returns `null` for unparseable input. The caller should treat `null` as
|
||||
* "input still in progress" rather than "error" until the user submits.
|
||||
*/
|
||||
function resolveRecipient(input: string): ResolvedRecipient | null {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
// Try bare Bitcoin / silent-payment via the unified parser.
|
||||
const parsed = parseHdRecipient(trimmed);
|
||||
if (parsed) {
|
||||
if (parsed.kind === 'address') {
|
||||
return { address: parsed.address, raw: trimmed, kind: 'address' };
|
||||
}
|
||||
return { address: parsed.spAddress, raw: trimmed, kind: 'sp' };
|
||||
}
|
||||
|
||||
// Try NIP-19 npub / nprofile.
|
||||
if (trimmed.startsWith('npub1') || trimmed.startsWith('nprofile1')) {
|
||||
try {
|
||||
const decoded = nip19.decode(trimmed);
|
||||
if (decoded.type === 'npub') {
|
||||
const address = nostrPubkeyToBitcoinAddress(decoded.data);
|
||||
if (address) return { address, pubkey: decoded.data, raw: trimmed, kind: 'address' };
|
||||
} else if (decoded.type === 'nprofile') {
|
||||
const address = nostrPubkeyToBitcoinAddress(decoded.data.pubkey);
|
||||
if (address) return { address, pubkey: decoded.data.pubkey, raw: trimmed, kind: 'address' };
|
||||
}
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -146,30 +84,20 @@ interface HDSendBitcoinDialogProps {
|
||||
/** BTC/USD price — passed in to avoid duplicate fetches. */
|
||||
btcPrice?: number;
|
||||
/**
|
||||
* Optional recipient (bare address, `sp1…` code, npub, or `bitcoin:` URI)
|
||||
* to prefill the recipient field with when the dialog opens. Used by
|
||||
* callers like the campaign detail page that already know the
|
||||
* destination, so the donor only needs to enter an amount.
|
||||
* Optional initial recipient string to prefill the recipient field. May
|
||||
* be a bare on-chain address (`bc1…`), a silent payment address
|
||||
* (`sp1…`), or a `bitcoin:` BIP-21 URI. When the URI carries both an
|
||||
* on-chain path and an `sp=` parameter, the picker's dropdown surfaces
|
||||
* both as separate rows so the donor explicitly picks which payment
|
||||
* path to use (no separate "swap" toggle needed — picking happens in
|
||||
* the dropdown).
|
||||
*
|
||||
* The field stays editable — donors can clear and retype if they want
|
||||
* to send somewhere else. The prefill applies on each open transition
|
||||
* (false → true) so reopening after a successful send loads the same
|
||||
* destination again.
|
||||
* The prefill is re-applied on each open transition (false → true) so
|
||||
* reopening after a successful send loads the same destination again,
|
||||
* and is also re-applied when the user clears a previously-selected
|
||||
* chip so they don't have to retype.
|
||||
*/
|
||||
initialRecipient?: string;
|
||||
/**
|
||||
* Optional *alternate* recipient. When supplied alongside
|
||||
* {@link initialRecipient}, the dialog shows a small "Use silent
|
||||
* payment instead" / "Use on-chain address instead" toggle under the
|
||||
* recipient field. Clicking it swaps the input between the two values
|
||||
* so donors can pick whichever the campaign provides without leaving
|
||||
* the modal.
|
||||
*
|
||||
* Campaign detail page wires the on-chain `bc1…` address as
|
||||
* {@link initialRecipient} (default) and the silent-payment `sp1…`
|
||||
* code as `initialRecipientAlt`.
|
||||
*/
|
||||
initialRecipientAlt?: string;
|
||||
}
|
||||
|
||||
interface SendResult {
|
||||
@@ -194,7 +122,7 @@ 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, initialRecipientAlt }: HDSendBitcoinDialogProps) {
|
||||
export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipient }: HDSendBitcoinDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const availability = useHdWalletAccess();
|
||||
const {
|
||||
@@ -217,34 +145,53 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipien
|
||||
halfHour: t('walletSend.feeSpeed.halfHour'),
|
||||
hour: t('walletSend.feeSpeed.hour'),
|
||||
economy: t('walletSend.feeSpeed.economy'),
|
||||
custom: t('walletSend.feeSpeed.custom'),
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
|
||||
// ── Form state ───────────────────────────────────────────────
|
||||
const [recipientInput, setRecipientInput] = useState('');
|
||||
//
|
||||
// The picker owns its input text internally and emits a resolved
|
||||
// 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('');
|
||||
const [error, setError] = useState('');
|
||||
const [feePopoverOpen, setFeePopoverOpen] = useState(false);
|
||||
const [success, setSuccess] = useState<SendResult | null>(null);
|
||||
/**
|
||||
* Classified failure from the most recent broadcast attempt. Renders as an
|
||||
* inline {@link BroadcastErrorAlert} above the Send button with a recovery
|
||||
* action (typically "Use a higher fee"). Cleared automatically whenever
|
||||
* the user adjusts any field that could plausibly resolve the failure,
|
||||
* and on every successful submit.
|
||||
*/
|
||||
const [broadcastError, setBroadcastError] = useState<BroadcastErrorKind | null>(null);
|
||||
|
||||
const feeSpeedUserChanged = useRef(false);
|
||||
|
||||
const recipient = useMemo(() => resolveRecipient(recipientInput), [recipientInput]);
|
||||
|
||||
// ── Fee rates ────────────────────────────────────────────────
|
||||
const { data: feeRates } = useQuery({
|
||||
const {
|
||||
data: feeRates,
|
||||
isLoading: feeRatesLoading,
|
||||
isError: feeRatesError,
|
||||
refetch: refetchFeeRates,
|
||||
} = useQuery({
|
||||
queryKey: ['blockbook-fee-rates', blockbookBaseUrl],
|
||||
queryFn: ({ signal }) => fetchFeeRates(blockbookBaseUrl, signal),
|
||||
enabled: isOpen && isReady,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const currentFeeRate = useMemo(() => {
|
||||
if (!feeRates) return undefined;
|
||||
return getBitcoinFeeRate(feeRates, feeSpeed);
|
||||
}, [feeRates, feeSpeed]);
|
||||
const currentFeeRate = useMemo(
|
||||
() => resolveBitcoinFeeRate(feeSpeed, feeRates, customFeeRate),
|
||||
[feeSpeed, feeRates, customFeeRate],
|
||||
);
|
||||
|
||||
// ── Owned UTXO set ───────────────────────────────────────────
|
||||
//
|
||||
@@ -284,6 +231,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
|
||||
@@ -296,18 +248,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);
|
||||
const showBalance = insufficient || (amountSats > 0 && totalBalance === 0);
|
||||
|
||||
// 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);
|
||||
@@ -320,12 +275,14 @@ 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;
|
||||
setFeeSpeed(speed);
|
||||
setFeePopoverOpen(false);
|
||||
// Keep the popover open for 'custom' so the user can type a rate; close
|
||||
// it for preset tiers since the choice is complete.
|
||||
if (speed !== 'custom') setFeePopoverOpen(false);
|
||||
}, []);
|
||||
|
||||
// ── Two-tap arm + raw-address disclaimer ─────────────────────
|
||||
@@ -333,62 +290,26 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipien
|
||||
// SP recipients (`sp1…`) produce a fresh, unlinkable Taproot output per
|
||||
// payment — they do NOT have the privacy concern of a reused on-chain
|
||||
// address. The public disclaimer is only needed for bare BTC addresses
|
||||
// typed in directly (no Nostr identity attached, no SP).
|
||||
const isRawAddress =
|
||||
!!recipient && recipient.kind === 'address' && !recipient.pubkey;
|
||||
// picked from the dropdown (no SP).
|
||||
const isRawAddress = !!recipient && recipient.kind === 'address';
|
||||
const [confirmArmed, setConfirmArmed] = useState(false);
|
||||
const [acknowledgedPublic, setAcknowledgedPublic] = useState(false);
|
||||
|
||||
// Recipient swap target. When the caller supplied two alternate
|
||||
// destinations (e.g. campaign detail page passing both `bc1…` and
|
||||
// `sp1…` from the campaign's wallet endpoints) and the current input
|
||||
// still matches one of them, expose a one-tap toggle to swap. If the
|
||||
// donor manually edited the field to something else, the toggle hides
|
||||
// itself so we don't trash their typed input.
|
||||
//
|
||||
// Lives next to `isRawAddress` so the render block can place the
|
||||
// toggle adjacent to the privacy disclaimer (its natural home — both
|
||||
// are about whether to expose a reusable on-chain address).
|
||||
const recipientSwap = useMemo<{ swapTo: string; labelKey: string } | null>(() => {
|
||||
if (!initialRecipient || !initialRecipientAlt) return null;
|
||||
const trimmed = recipientInput.trim();
|
||||
let swapTo: string | null = null;
|
||||
if (trimmed === initialRecipient) swapTo = initialRecipientAlt;
|
||||
else if (trimmed === initialRecipientAlt) swapTo = initialRecipient;
|
||||
if (!swapTo) return null;
|
||||
// The label tells the user *what they're switching to* — detect by
|
||||
// prefix so an `sp1…` swap target advertises silent payment.
|
||||
const labelKey = swapTo.toLowerCase().startsWith('sp1')
|
||||
? 'walletSend.recipient.useSilentPayment'
|
||||
: 'walletSend.recipient.useOnchain';
|
||||
return { swapTo, labelKey };
|
||||
}, [initialRecipient, initialRecipientAlt, recipientInput]);
|
||||
|
||||
useEffect(() => {
|
||||
setConfirmArmed(false);
|
||||
}, [amountSats, currentFeeRate, btcPrice, recipient?.address]);
|
||||
}, [effectiveAmountSats, currentFeeRate, btcPrice, recipient?.address]);
|
||||
|
||||
// Reset the privacy acknowledgement only when the recipient changes —
|
||||
// not when the user adjusts the amount or fee tier. Toggling between
|
||||
// fee speeds should not silently uncheck the warning.
|
||||
useEffect(() => {
|
||||
setAcknowledgedPublic(false);
|
||||
}, [recipient?.address]);
|
||||
|
||||
// Prefill the recipient field on every dialog open transition (closed →
|
||||
// open). Callers like the campaign donate column already know the
|
||||
// destination, so the donor only needs to type an amount. The field
|
||||
// stays editable; reopening after a send re-applies the prefill instead
|
||||
// of remembering the cleared value. Tracked via a ref so reopens with a
|
||||
// stable string still re-apply (the value alone can't tell us whether
|
||||
// the dialog has reopened).
|
||||
// Track open transitions so we can re-key the picker on each
|
||||
// closed → open transition. Re-keying remounts the picker with a fresh
|
||||
// `initialInput`, restoring the prefilled recipient after a successful
|
||||
// send (which closes and reopens with the same prefill).
|
||||
const [openCount, setOpenCount] = useState(0);
|
||||
const wasOpenRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (isOpen && !wasOpenRef.current && initialRecipient) {
|
||||
setRecipientInput(initialRecipient);
|
||||
if (isOpen && !wasOpenRef.current) {
|
||||
setOpenCount((c) => c + 1);
|
||||
}
|
||||
wasOpenRef.current = isOpen;
|
||||
}, [isOpen, initialRecipient]);
|
||||
}, [isOpen]);
|
||||
|
||||
const requiresArm = isLarge || isRawAddress;
|
||||
|
||||
@@ -402,32 +323,57 @@ 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 (!feeRates) throw new Error(t('walletSend.errors.feesNotLoaded'));
|
||||
if (recipient.pubkey === availability.pubkey) throw new Error(t('walletSend.errors.cantSendToSelf'));
|
||||
if (amountSats <= 0) throw new Error(t('walletSend.errors.enterAmount'));
|
||||
if (feeSpeed !== 'custom' && !feeRates) throw new Error(t('walletSend.errors.feesNotLoaded'));
|
||||
if (effectiveAmountSats <= 0) throw new Error(t('walletSend.errors.enterAmount'));
|
||||
if (insufficient) throw new Error(t('walletSend.errors.insufficient'));
|
||||
|
||||
const rate = getBitcoinFeeRate(feeRates, feeSpeed);
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
);
|
||||
@@ -436,12 +382,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:
|
||||
@@ -453,28 +398,124 @@ 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) => {
|
||||
toast({ title: t('walletSend.toast.failedTitle'), description: err.message, variant: 'destructive' });
|
||||
const classified = classifyBroadcastError(err);
|
||||
setBroadcastError(classified);
|
||||
// Force a re-arm on every failure so the donor explicitly re-confirms
|
||||
// after seeing the error — without this, a second tap of an
|
||||
// already-armed Send would immediately re-broadcast with the same
|
||||
// (rejected) parameters.
|
||||
setConfirmArmed(false);
|
||||
// The inline alert is the primary surface for classified errors;
|
||||
// a toast on top would be noisy. Keep the toast only for the
|
||||
// catch-all `unknown` bucket so something always surfaces even when
|
||||
// we can't recognise the reject reason.
|
||||
if (classified.kind === 'unknown') {
|
||||
toast({
|
||||
title: t('walletSend.toast.failedTitle'),
|
||||
description: err.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
},
|
||||
onSettled: () => setProgress('idle'),
|
||||
});
|
||||
|
||||
// Clear the broadcast-error alert as soon as the donor adjusts anything
|
||||
// that could plausibly resolve the failure. Recipient / amount / fee rate
|
||||
// changes are the obvious cases; we don't clear on a btcPrice tick alone
|
||||
// because that's just a passive refresh.
|
||||
useEffect(() => {
|
||||
setBroadcastError(null);
|
||||
}, [recipient?.address, effectiveAmountSats, feeSpeed, customFeeRate]);
|
||||
|
||||
/**
|
||||
* Recovery action for fee-related broadcast failures.
|
||||
*
|
||||
* Strategy:
|
||||
* - If the user is on a preset and a faster preset exists in the
|
||||
* *deduped* tier list, jump to it.
|
||||
* - Otherwise (already on the fastest tier, or only one unique tier
|
||||
* loaded), switch to a custom rate seeded from the strongest hint
|
||||
* we have: the parsed minRelayFee from the error, the parsed actual
|
||||
* rate * 1.5, or the current fastest preset + 1. Open the fee popover
|
||||
* so the donor can see the new rate and tweak it further.
|
||||
*
|
||||
* Either way: refetch fee rates, mark the picker as user-touched (so the
|
||||
* auto-tune effect doesn't override the bump on the next render), clear
|
||||
* the broadcast-error alert, and reset `confirmArmed`.
|
||||
*/
|
||||
const bumpFeeForRetry = useCallback(() => {
|
||||
feeSpeedUserChanged.current = true;
|
||||
setConfirmArmed(false);
|
||||
setBroadcastError(null);
|
||||
void refetchFeeRates();
|
||||
|
||||
const uniqueSpeeds = feeRates ? getUniqueBitcoinFeeSpeeds(feeRates) : [];
|
||||
const presetIndex = uniqueSpeeds.indexOf(feeSpeed as Exclude<FeeSpeed, 'custom'>);
|
||||
|
||||
if (feeSpeed !== 'custom' && presetIndex > 0) {
|
||||
// A faster preset exists — jump to it.
|
||||
setFeeSpeed(uniqueSpeeds[presetIndex - 1]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Either at the fastest preset already, or on `custom`. Fall back to
|
||||
// a custom rate using the strongest available hint.
|
||||
const fastestPresetRate = feeRates?.fastestFee ?? 1;
|
||||
const fromError =
|
||||
broadcastError?.kind === 'feeTooLow'
|
||||
? (broadcastError.minRelayFeeRate ?? broadcastError.actualFeeRate)
|
||||
: undefined;
|
||||
const seed = (() => {
|
||||
if (broadcastError?.kind === 'feeTooLow' && broadcastError.minRelayFeeRate) {
|
||||
// +1 sat/vB over the network minimum so we clear it comfortably.
|
||||
return Math.max(broadcastError.minRelayFeeRate + 1, fastestPresetRate);
|
||||
}
|
||||
if (fromError) {
|
||||
// No minimum surfaced but we know the rejected rate — 1.5× as a
|
||||
// safe escalation step.
|
||||
return Math.max(Math.ceil(fromError * 1.5), fastestPresetRate + 1);
|
||||
}
|
||||
// No usable hint — nudge above the current fastest tier.
|
||||
const current = currentFeeRate ?? fastestPresetRate;
|
||||
return Math.max(current + 1, fastestPresetRate + 1);
|
||||
})();
|
||||
|
||||
setFeeSpeed('custom');
|
||||
setCustomFeeRate(String(Math.max(1, Math.ceil(seed))));
|
||||
setFeePopoverOpen(true);
|
||||
}, [
|
||||
broadcastError,
|
||||
currentFeeRate,
|
||||
feeRates,
|
||||
feeSpeed,
|
||||
refetchFeeRates,
|
||||
]);
|
||||
|
||||
const handleSend = useCallback(() => {
|
||||
setError('');
|
||||
if (availability.status !== 'available') {
|
||||
setError(t('walletSend.errors.unavailable')); return;
|
||||
}
|
||||
if (!recipient) { setError(t('walletSend.errors.enterRecipient')); return; }
|
||||
if (recipient.pubkey === availability.pubkey) { setError(t('walletSend.errors.cantSendToSelf')); 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 (insufficient) { setError(t('walletSend.errors.insufficient')); return; }
|
||||
if (isRawAddress && !acknowledgedPublic) {
|
||||
setError(t('walletSend.errors.acknowledgePrivacy')); return;
|
||||
if (!currentFeeRate || currentFeeRate < 1) {
|
||||
setError(
|
||||
feeSpeed === 'custom'
|
||||
? t('walletSend.errors.feeRateTooLow')
|
||||
: t('walletSend.errors.feesNotLoadedYet'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (insufficient) { setError(t('walletSend.errors.insufficient')); return; }
|
||||
if (requiresArm && !confirmArmed) { setConfirmArmed(true); return; }
|
||||
sendMutation.mutate();
|
||||
}, [
|
||||
@@ -482,11 +523,11 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipien
|
||||
availability,
|
||||
recipient,
|
||||
btcPrice,
|
||||
amountSats,
|
||||
effectiveAmountSats,
|
||||
ownedInputs.length,
|
||||
currentFeeRate,
|
||||
feeSpeed,
|
||||
insufficient,
|
||||
isRawAddress,
|
||||
acknowledgedPublic,
|
||||
requiresArm,
|
||||
confirmArmed,
|
||||
sendMutation,
|
||||
@@ -498,12 +539,15 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipien
|
||||
onClose();
|
||||
// defer to allow exit animation
|
||||
setTimeout(() => {
|
||||
setRecipientInput('');
|
||||
setRecipient(null);
|
||||
setUsdAmount(5);
|
||||
setSendMax(false);
|
||||
setError('');
|
||||
setFeeSpeed('halfHour');
|
||||
setCustomFeeRate('');
|
||||
setConfirmArmed(false);
|
||||
setAcknowledgedPublic(false);
|
||||
setSuccess(null);
|
||||
setBroadcastError(null);
|
||||
feeSpeedUserChanged.current = false;
|
||||
}, 200);
|
||||
}, [onClose, sendMutation.isPending]);
|
||||
@@ -519,6 +563,7 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipien
|
||||
}
|
||||
}
|
||||
if (confirmArmed) return t('walletSend.tapAgainToConfirm');
|
||||
if (insufficient) return t('walletSend.notEnoughBitcoin');
|
||||
return t('walletSend.send');
|
||||
})();
|
||||
|
||||
@@ -526,10 +571,15 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipien
|
||||
sendMutation.isPending ||
|
||||
!recipient ||
|
||||
!btcPrice ||
|
||||
amountSats <= 0 ||
|
||||
effectiveAmountSats <= 0 ||
|
||||
insufficient ||
|
||||
!ownedInputs.length ||
|
||||
(isRawAddress && !acknowledgedPublic);
|
||||
!currentFeeRate ||
|
||||
currentFeeRate < 1;
|
||||
|
||||
const maxAmountLabel = sendMax && effectiveAmountSats > 0 && btcPrice
|
||||
? `${satsToUSD(effectiveAmountSats, btcPrice)} · ${t('walletSend.success.satsAmount', { sats: formatSats(effectiveAmountSats) })}`
|
||||
: undefined;
|
||||
|
||||
// ── Render ───────────────────────────────────────────────────
|
||||
return (
|
||||
@@ -564,117 +614,46 @@ 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}
|
||||
satsLabel={amountSats > 0 && btcPrice
|
||||
? t('walletSend.approxSats', { sats: amountSats.toLocaleString() })
|
||||
: undefined}
|
||||
onAmountChangeStart={() => setError('')}
|
||||
satsLabel={maxAmountLabel}
|
||||
onAmountChangeStart={() => {
|
||||
setError('');
|
||||
setSendMax(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Recipient */}
|
||||
<div className="grid gap-1">
|
||||
<label className="text-xs text-muted-foreground" htmlFor="hd-recipient-input">
|
||||
{t('walletSend.recipient.label')}
|
||||
</label>
|
||||
<BitcoinRecipientInput
|
||||
value={recipientInput}
|
||||
onChange={setRecipientInput}
|
||||
placeholder={t('walletSend.recipient.placeholder')}
|
||||
resolvedPubkey={recipient?.pubkey}
|
||||
/>
|
||||
{recipient && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{recipient.kind === 'sp'
|
||||
? t('walletSend.recipient.sendingSp')
|
||||
: recipient.pubkey
|
||||
? t('walletSend.recipient.sendingNostr')
|
||||
: t('walletSend.recipient.sendingRaw')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{/* Recipient — text input + Popover dropdown surfacing the
|
||||
BIP-21 candidates, with an inline QR-scanner button. The
|
||||
picker swaps itself out for a chip once a destination is
|
||||
selected; clicking the chip's X returns to the input. */}
|
||||
<BitcoinRecipientInput
|
||||
key={openCount}
|
||||
value={recipient}
|
||||
onChange={(next) => {
|
||||
setRecipient(next);
|
||||
setError('');
|
||||
}}
|
||||
placeholder={t('walletSend.recipient.placeholder')}
|
||||
initialInput={initialRecipient}
|
||||
/>
|
||||
|
||||
{/* Privacy disclaimer for raw addresses + companion
|
||||
swap-to-silent-payment toggle. Both are about whether
|
||||
the donation will land on a reusable, publicly-tied
|
||||
address; grouping them lets a donor who reads the
|
||||
warning flip straight to SP without hunting for the
|
||||
control. When the disclaimer is absent (recipient is
|
||||
already SP or a Nostr identity), the toggle still
|
||||
renders so the donor can swap back to on-chain. */}
|
||||
{(isRawAddress || recipientSwap) && (
|
||||
<div className="grid gap-2">
|
||||
{isRawAddress && (
|
||||
<BitcoinPublicDisclaimer
|
||||
acknowledged={acknowledgedPublic}
|
||||
onAcknowledgedChange={setAcknowledgedPublic}
|
||||
/>
|
||||
)}
|
||||
{recipientSwap && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRecipientInput(recipientSwap.swapTo)}
|
||||
className="self-start text-xs text-primary hover:underline motion-safe:transition-colors"
|
||||
>
|
||||
{t(recipientSwap.labelKey)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fee speed */}
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-muted-foreground">{t('walletSend.networkFee')}</span>
|
||||
<Popover open={feePopoverOpen} onOpenChange={setFeePopoverOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
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)}</>
|
||||
) : currentFeeRate ? (
|
||||
<>{t('walletSend.satPerVB', { rate: currentFeeRate })}</>
|
||||
) : (
|
||||
<>—</>
|
||||
)}
|
||||
<span className="opacity-60">·</span>
|
||||
{feeSpeedLabels[feeSpeed]}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-44 p-1" align="end">
|
||||
<div className="grid gap-0.5">
|
||||
{getUniqueBitcoinFeeSpeeds(feeRates).map((speed) => (
|
||||
<button
|
||||
key={speed}
|
||||
type="button"
|
||||
onClick={() => handleFeeSpeedChange(speed)}
|
||||
className={cn(
|
||||
'flex justify-between items-center px-3 py-1.5 rounded-md text-xs hover:bg-muted/50 transition-colors',
|
||||
feeSpeed === speed && 'bg-muted',
|
||||
)}
|
||||
>
|
||||
<span>{feeSpeedLabels[speed]}</span>
|
||||
{feeRates && (
|
||||
<span className="text-muted-foreground tabular-nums">
|
||||
{t('walletSend.satPerVB', { rate: getBitcoinFeeRate(feeRates, speed) })}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{showBalance && totalBalance > 0 && btcPrice && (
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
{t('walletSend.available', {
|
||||
usd: satsToUSD(totalBalance, btcPrice),
|
||||
sats: totalBalance.toLocaleString(),
|
||||
})}
|
||||
</p>
|
||||
{/* Privacy disclaimer for raw on-chain addresses. SP
|
||||
recipients produce a fresh unlinkable output per payment
|
||||
and don't need the warning. */}
|
||||
{isRawAddress && (
|
||||
<BitcoinPublicDisclaimer tone="soft" />
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
@@ -685,6 +664,24 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipien
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Classified broadcast failure with an actionable recovery.
|
||||
Replaces the older raw-toast UX so the donor can see why
|
||||
the network rejected the tx (fee too low, mempool full,
|
||||
RBF replacement underpriced, etc.) AND act on it without
|
||||
guessing. Cleared automatically the moment they touch a
|
||||
field that could resolve the failure. */}
|
||||
{broadcastError && (
|
||||
<BroadcastErrorAlert
|
||||
error={broadcastError}
|
||||
currentFeeRate={currentFeeRate}
|
||||
feeSpeed={feeSpeed}
|
||||
feeRates={feeRates}
|
||||
isPending={sendMutation.isPending}
|
||||
onBumpFee={bumpFeeForRetry}
|
||||
onRetry={() => sendMutation.mutate()}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Send button */}
|
||||
<Button
|
||||
type="button"
|
||||
@@ -698,6 +695,103 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipien
|
||||
{sendMutation.isPending && <Loader2 className="size-4 mr-2 animate-spin" />}
|
||||
{sendButtonLabel}
|
||||
</Button>
|
||||
|
||||
{/* Fee speed — under the Send button, centered. The label is
|
||||
implicit: the only thing in the dialog you'd open a popover
|
||||
for here is the network-fee tier. */}
|
||||
<div className="flex justify-center text-xs">
|
||||
<Popover open={feePopoverOpen} onOpenChange={setFeePopoverOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1.5 hover:text-foreground transition-colors text-muted-foreground tabular-nums"
|
||||
>
|
||||
{effectiveFeeSats > 0 && btcPrice ? (
|
||||
<>≈ {satsToUSD(effectiveFeeSats, btcPrice)}</>
|
||||
) : currentFeeRate ? (
|
||||
<>{t('walletSend.satPerVB', { rate: currentFeeRate })}</>
|
||||
) : feeRatesLoading && feeSpeed !== 'custom' ? (
|
||||
<>{t('walletSend.fee.loading')}</>
|
||||
) : feeRatesError && feeSpeed !== 'custom' ? (
|
||||
<>{t('walletSend.fee.unavailable')}</>
|
||||
) : (
|
||||
<>—</>
|
||||
)}
|
||||
<span className="opacity-60">·</span>
|
||||
{feeSpeed === 'custom' && currentFeeRate
|
||||
? t('walletSend.satPerVB', { rate: currentFeeRate })
|
||||
: feeSpeedLabels[feeSpeed]}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-56 p-1" align="center">
|
||||
<div className="grid gap-0.5">
|
||||
{feeRatesError && (
|
||||
<div className="px-3 py-1.5 text-xs text-muted-foreground">
|
||||
<p className="text-destructive">{t('walletSend.fee.loadFailed')}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => refetchFeeRates()}
|
||||
className="mt-1 underline hover:text-foreground transition-colors"
|
||||
>
|
||||
{t('walletSend.fee.retry')}
|
||||
</button>
|
||||
<p className="mt-1">{t('walletSend.fee.orCustom')}</p>
|
||||
</div>
|
||||
)}
|
||||
{feeRatesLoading && !feeRatesError && (
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 text-xs text-muted-foreground">
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
{t('walletSend.fee.loadingTiers')}
|
||||
</div>
|
||||
)}
|
||||
{feeRates && getUniqueBitcoinFeeSpeeds(feeRates).map((speed) => (
|
||||
<button
|
||||
key={speed}
|
||||
type="button"
|
||||
onClick={() => handleFeeSpeedChange(speed)}
|
||||
className={cn(
|
||||
'flex justify-between items-center px-3 py-1.5 rounded-md text-xs hover:bg-muted/50 transition-colors',
|
||||
feeSpeed === speed && 'bg-muted',
|
||||
)}
|
||||
>
|
||||
<span>{feeSpeedLabels[speed]}</span>
|
||||
<span className="text-muted-foreground tabular-nums">
|
||||
{t('walletSend.satPerVB', { rate: getBitcoinFeeRate(feeRates, speed) })}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
{/* Custom fee rate */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleFeeSpeedChange('custom')}
|
||||
className={cn(
|
||||
'flex justify-between items-center px-3 py-1.5 rounded-md text-xs hover:bg-muted/50 transition-colors',
|
||||
feeSpeed === 'custom' && 'bg-muted',
|
||||
)}
|
||||
>
|
||||
<span>{feeSpeedLabels.custom}</span>
|
||||
</button>
|
||||
{feeSpeed === 'custom' && (
|
||||
<div className="flex items-center gap-1.5 px-3 py-1.5">
|
||||
<Input
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
min={1}
|
||||
step={1}
|
||||
autoFocus
|
||||
value={customFeeRate}
|
||||
onChange={(e) => setCustomFeeRate(e.target.value)}
|
||||
placeholder={t('walletSend.fee.customPlaceholder')}
|
||||
className="h-7 text-xs"
|
||||
aria-label={t('walletSend.fee.customAriaLabel')}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">sat/vB</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Loader2, Search } from 'lucide-react';
|
||||
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { getAllLucideIcons } from '@/lib/lucideIconRegistry';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface IconPickerProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** Currently-selected icon name, used to highlight the active cell. */
|
||||
value?: string;
|
||||
/** Called with the chosen icon's PascalCase name when the user picks one. */
|
||||
onSelect: (name: string) => void;
|
||||
}
|
||||
|
||||
type IconEntry = {
|
||||
name: string;
|
||||
Component: React.ComponentType<{ className?: string; 'aria-hidden'?: boolean }>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Searchable picker over every named Lucide icon.
|
||||
*
|
||||
* The icon set is loaded on demand via {@link getAllLucideIcons}, which
|
||||
* dynamically imports `lucide-react` once per session and emits the whole
|
||||
* library as a separate Vite chunk. Until the chunk resolves the dialog
|
||||
* shows a spinner; subsequent opens read from the cached promise and are
|
||||
* effectively instant.
|
||||
*
|
||||
* **Search semantics.** Case-insensitive substring match against the
|
||||
* icon's PascalCase name with the camel-case word boundaries flattened
|
||||
* into spaces — so `arrow up` matches `ArrowUp`. Empty query shows the
|
||||
* full registry.
|
||||
*
|
||||
* **Rendering.** A windowed grid via plain CSS — we render the filtered
|
||||
* results up to a soft cap (`MAX_VISIBLE`) so the DOM stays manageable
|
||||
* for unfiltered queries. With ~1500 icons total this caps the picker
|
||||
* at a few hundred initial cells; typing narrows the set quickly.
|
||||
*/
|
||||
const MAX_VISIBLE = 600;
|
||||
|
||||
export function IconPicker({ open, onOpenChange, value, onSelect }: IconPickerProps) {
|
||||
const { t } = useTranslation();
|
||||
const [icons, setIcons] = useState<IconEntry[] | null>(null);
|
||||
const [query, setQuery] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (icons) return;
|
||||
let cancelled = false;
|
||||
getAllLucideIcons()
|
||||
.then((all) => {
|
||||
if (cancelled) return;
|
||||
setIcons(all);
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
setIcons([]);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, icons]);
|
||||
|
||||
const filtered = useMemo<IconEntry[]>(() => {
|
||||
if (!icons) return [];
|
||||
const q = query.trim().toLowerCase().replace(/\s+/g, '');
|
||||
if (!q) return icons.slice(0, MAX_VISIBLE);
|
||||
const out: IconEntry[] = [];
|
||||
for (const entry of icons) {
|
||||
// Flatten name to lowercase for substring match.
|
||||
if (entry.name.toLowerCase().includes(q)) {
|
||||
out.push(entry);
|
||||
if (out.length >= MAX_VISIBLE) break;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}, [icons, query]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="max-w-2xl max-h-[80dvh] rounded-2xl flex flex-col overflow-hidden"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<DialogTitle>{t('campaigns.lists.iconPicker.title')}</DialogTitle>
|
||||
<DialogDescription className="text-sm text-muted-foreground">
|
||||
{t('campaigns.lists.iconPicker.description')}
|
||||
</DialogDescription>
|
||||
|
||||
<div className="relative">
|
||||
<Search
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground pointer-events-none"
|
||||
aria-hidden
|
||||
/>
|
||||
<Input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={t('campaigns.lists.iconPicker.search')}
|
||||
aria-label={t('campaigns.lists.iconPicker.search')}
|
||||
className="pl-9"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-y-auto -mx-6 px-6">
|
||||
{icons === null ? (
|
||||
<div className="flex items-center justify-center py-16 text-muted-foreground">
|
||||
<Loader2 className="size-5 animate-spin" />
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="py-16 text-center text-sm text-muted-foreground">
|
||||
{t('campaigns.lists.iconPicker.empty')}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="grid gap-1.5 py-2"
|
||||
style={{
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(80px, 1fr))',
|
||||
}}
|
||||
>
|
||||
{filtered.map(({ name, Component }) => {
|
||||
const isSelected = name === value;
|
||||
return (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onSelect(name);
|
||||
onOpenChange(false);
|
||||
}}
|
||||
title={name}
|
||||
aria-pressed={isSelected}
|
||||
className={cn(
|
||||
'flex flex-col items-center justify-center gap-1 rounded-lg border px-2 py-3 motion-safe:transition-colors',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||
isSelected
|
||||
? 'border-primary bg-primary/10 text-primary'
|
||||
: 'border-transparent hover:border-border hover:bg-accent text-foreground',
|
||||
)}
|
||||
>
|
||||
<Component className="size-5" aria-hidden />
|
||||
<span className="text-[10px] leading-tight text-muted-foreground truncate max-w-full">
|
||||
{name}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -5,43 +5,32 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import { ZoomIn, ZoomOut, RotateCcw } from 'lucide-react';
|
||||
import { encodeImage } from '@/lib/resizeImage';
|
||||
|
||||
interface ImageCropDialogProps {
|
||||
open: boolean;
|
||||
imageSrc: string;
|
||||
aspect: number;
|
||||
title?: string;
|
||||
/**
|
||||
* Cap on the output's long edge, in pixels. When the selected crop
|
||||
* region in source-pixel space exceeds this, the canvas downscales with
|
||||
* `drawImage`'s built-in bilinear filter. Omit (or pass `0`) to preserve
|
||||
* the source-pixel crop size 1:1 — historical behavior, kept as the
|
||||
* default so existing callers (avatar/banner in ProfileSettings) aren't
|
||||
* silently down-rezzed without opting in.
|
||||
*/
|
||||
maxOutputSize?: number;
|
||||
onCancel: () => void;
|
||||
onCrop: (croppedBlob: Blob) => void;
|
||||
/**
|
||||
* Receives the cropped result as a `File` (JPEG or PNG, whichever
|
||||
* encoded smaller — see `encodeImage` in `@/lib/resizeImage`). The
|
||||
* mime/extension on the file reflects the winning format.
|
||||
*/
|
||||
onCrop: (croppedFile: File) => void;
|
||||
}
|
||||
|
||||
async function getCroppedBlob(imageSrc: string, pixelCrop: Area): Promise<Blob> {
|
||||
const image = await createImageBitmap(await (await fetch(imageSrc)).blob());
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = pixelCrop.width;
|
||||
canvas.height = pixelCrop.height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) throw new Error('Canvas context unavailable');
|
||||
ctx.drawImage(
|
||||
image,
|
||||
pixelCrop.x,
|
||||
pixelCrop.y,
|
||||
pixelCrop.width,
|
||||
pixelCrop.height,
|
||||
0,
|
||||
0,
|
||||
pixelCrop.width,
|
||||
pixelCrop.height,
|
||||
);
|
||||
return new Promise<Blob>((resolve, reject) => {
|
||||
canvas.toBlob((blob) => {
|
||||
if (blob) resolve(blob);
|
||||
else reject(new Error('Failed to create blob'));
|
||||
}, 'image/jpeg', 0.92);
|
||||
});
|
||||
}
|
||||
|
||||
export function ImageCropDialog({ open, imageSrc, aspect, title = 'Crop Image', onCancel, onCrop }: ImageCropDialogProps) {
|
||||
export function ImageCropDialog({ open, imageSrc, aspect, title = 'Crop Image', maxOutputSize, onCancel, onCrop }: ImageCropDialogProps) {
|
||||
const [crop, setCrop] = useState<Point>({ x: 0, y: 0 });
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [croppedAreaPixels, setCroppedAreaPixels] = useState<Area | null>(null);
|
||||
@@ -60,8 +49,18 @@ export function ImageCropDialog({ open, imageSrc, aspect, title = 'Crop Image',
|
||||
if (!croppedAreaPixels) return;
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
const blob = await getCroppedBlob(imageSrc, croppedAreaPixels);
|
||||
onCrop(blob);
|
||||
// Delegate to the shared encoder so cover-image crops pick up the
|
||||
// same JPEG-vs-PNG comparison and quality defaults used by the
|
||||
// upload paths in ComposeBox / ImageUploadField. JPEG quality stays
|
||||
// at the lib default (0.85) — the previous 0.92 was set when this
|
||||
// dialog was JPEG-only and not coordinating with the rest of the
|
||||
// app's resize pipeline.
|
||||
const { file } = await encodeImage(imageSrc, {
|
||||
crop: croppedAreaPixels,
|
||||
maxOutputSize,
|
||||
filename: 'cropped',
|
||||
});
|
||||
onCrop(file);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
|
||||
@@ -147,7 +147,7 @@ export function ImageUploadField({
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-2 px-4 text-muted-foreground">
|
||||
<Upload className="size-5 opacity-50" />
|
||||
<Upload className="size-5" />
|
||||
<span className="text-xs">{uploadText}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -148,7 +148,7 @@ export function LiveStreamChat({ aTag, className }: LiveStreamChatProps) {
|
||||
</div>
|
||||
) : messages.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<MessageCircle className="size-8 text-muted-foreground/40 mb-2" />
|
||||
<MessageCircle className="size-8 text-muted-foreground mb-2" />
|
||||
<p className="text-sm text-muted-foreground">No messages yet</p>
|
||||
<p className="text-xs text-muted-foreground/60 mt-1">Be the first to say something!</p>
|
||||
</div>
|
||||
|
||||
@@ -195,7 +195,7 @@ export function LiveStreamPage({ event }: LiveStreamPageProps) {
|
||||
) : (
|
||||
<div className="aspect-video lg:rounded-2xl bg-muted flex items-center justify-center border-y lg:border border-border">
|
||||
<div className="text-center space-y-2">
|
||||
<Radio className="size-8 text-muted-foreground/40 mx-auto" />
|
||||
<Radio className="size-8 text-muted-foreground mx-auto" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{status === 'planned' ? 'Stream has not started yet' : 'No stream URL available'}
|
||||
</p>
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { List as ListFallback } from 'lucide-react';
|
||||
|
||||
import { getLucideIcon } from '@/lib/lucideIconRegistry';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface LucideIconProps {
|
||||
/** PascalCase Lucide icon name (e.g. `"Heart"`). */
|
||||
name: string;
|
||||
/** Optional className passed through to the rendered icon. */
|
||||
className?: string;
|
||||
/** Optional aria-label; defaults to hidden from assistive tech. */
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a Lucide icon resolved by name at runtime. The icon registry is
|
||||
* loaded via a single shared dynamic import (see `lucideIconRegistry.ts`),
|
||||
* so the whole icon set lives in a separate Vite chunk and only pays its
|
||||
* bundle cost once per session.
|
||||
*
|
||||
* **Fallback.** While the registry resolves, and for any name that fails
|
||||
* to resolve (event published with an icon we don't recognize), the
|
||||
* generic `List` icon is rendered — already statically imported by other
|
||||
* parts of the app, so the fallback never causes a layout shift waiting
|
||||
* for a network round-trip.
|
||||
*/
|
||||
export function LucideIcon({ name, className, ariaLabel }: LucideIconProps) {
|
||||
// `Component` starts at `null` so the first paint always uses the
|
||||
// fallback. Once the dynamic import resolves, the matching component
|
||||
// takes over. We deliberately don't suspend on the import — the
|
||||
// fallback is a perfectly serviceable icon and suspending would force
|
||||
// every list pill to wait for the same chunk before anything renders.
|
||||
const [Component, setComponent] = useState<React.ComponentType<{
|
||||
className?: string;
|
||||
'aria-hidden'?: boolean;
|
||||
'aria-label'?: string;
|
||||
}> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setComponent(null);
|
||||
getLucideIcon(name)
|
||||
.then((c) => {
|
||||
if (cancelled) return;
|
||||
setComponent(() => c);
|
||||
})
|
||||
.catch(() => {
|
||||
// Network error loading the chunk — keep fallback.
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [name]);
|
||||
|
||||
const Icon = Component ?? ListFallback;
|
||||
return (
|
||||
<Icon
|
||||
className={cn(className)}
|
||||
aria-hidden={!ariaLabel}
|
||||
aria-label={ariaLabel}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -114,7 +114,7 @@ function TrackDetail({ event }: { event: NostrEvent }) {
|
||||
<img src={proxy(parsed.artwork, 320)} alt={parsed.title} className="w-full h-full object-cover" onError={() => setImgError(true)} />
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center bg-primary/10">
|
||||
<Music className="size-12 text-primary/30" />
|
||||
<Music className="size-12 text-primary" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -399,7 +399,7 @@ function PlaylistDetail({ event }: { event: NostrEvent }) {
|
||||
<img src={proxy(coverArt, 320)} alt={parsed?.title ?? ''} className="w-full h-full object-cover" onError={() => setImgError(true)} />
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center bg-primary/10">
|
||||
<FallbackIcon className="size-12 text-primary/30" />
|
||||
<FallbackIcon className="size-12 text-primary" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -575,7 +575,7 @@ function PlaylistTrackRow({
|
||||
<img src={proxy(parsed.artwork, 96)} alt={parsed.title} className="size-full object-cover" loading="lazy" onError={() => setImgError(true)} />
|
||||
) : (
|
||||
<div className="size-full flex items-center justify-center bg-primary/10">
|
||||
<Music className="size-5 text-primary/30" />
|
||||
<Music className="size-5 text-primary" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { NostrSigner } from '@nostrify/types';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { getEffectiveRelays, DITTO_RELAYS, DIVINE_RELAY, ZAPSTORE_RELAY } from '@/lib/appRelays';
|
||||
import { NostrBatcher } from '@/lib/NostrBatcher';
|
||||
import { DebugRelay } from '@/lib/relayDebug';
|
||||
|
||||
interface NostrProviderProps {
|
||||
children: React.ReactNode;
|
||||
@@ -71,7 +72,7 @@ const NostrProvider: React.FC<NostrProviderProps> = (props) => {
|
||||
if (!pool.current) {
|
||||
pool.current = new NPool({
|
||||
open(url: string) {
|
||||
return new NRelay1(url, {
|
||||
const relay = new NRelay1(url, {
|
||||
// NIP-42: Respond to relay AUTH challenges by signing a kind
|
||||
// 22242 ephemeral event with the current user's signer.
|
||||
auth: async (challenge: string) => {
|
||||
@@ -90,6 +91,7 @@ const NostrProvider: React.FC<NostrProviderProps> = (props) => {
|
||||
});
|
||||
},
|
||||
});
|
||||
return new DebugRelay(url, relay);
|
||||
},
|
||||
reqRouter(filters: NostrFilter[]): Map<URL['href'], NostrFilter[]> {
|
||||
const routes = new Map<string, NostrFilter[]>();
|
||||
|
||||
@@ -367,6 +367,14 @@ export function NostrSync() {
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (
|
||||
encryptedSettings.translateWorkerUrl &&
|
||||
encryptedSettings.translateWorkerUrl !== current.translateWorkerUrl
|
||||
) {
|
||||
updates.translateWorkerUrl = encryptedSettings.translateWorkerUrl;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
// Return the same reference if nothing changed to prevent re-render
|
||||
return changed ? updates : current;
|
||||
});
|
||||
|
||||
@@ -107,6 +107,7 @@ import { timeAgo } from "@/lib/timeAgo";
|
||||
import { formatNumber } from "@/lib/formatNumber";
|
||||
import { publishedAtAction } from "@/lib/publishedAtAction";
|
||||
import { getEffectiveStreamStatus } from "@/lib/streamStatus";
|
||||
import { getEventRelaySource } from "@/lib/relayDebug";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { hasGoalZapSplits } from "@/lib/goalUtils";
|
||||
|
||||
@@ -424,6 +425,18 @@ export const NoteCard = memo(function NoteCard({
|
||||
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
|
||||
const [replyOpen, setReplyOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
console.debug('[nostr note render]', {
|
||||
relay: getEventRelaySource(event.id) ?? 'unknown',
|
||||
kind: event.kind,
|
||||
id: event.id,
|
||||
actionRelay: actionEvent ? getEventRelaySource(actionEvent.id) ?? 'unknown' : undefined,
|
||||
actionKind: actionEvent?.kind,
|
||||
actionId: actionEvent?.id,
|
||||
path: window.location.pathname,
|
||||
});
|
||||
}, [event.id, event.kind, actionEvent]);
|
||||
|
||||
// Check if the current user can zap this event's author
|
||||
// TODO: Enable zapping split-recipient NIP-75 goals once zap split payments are supported.
|
||||
const canZapAuthor = user && canZap(actionMetadata) && !hasGoalZapSplits(actionTarget);
|
||||
@@ -899,7 +912,7 @@ export const NoteCard = memo(function NoteCard({
|
||||
<div className="flex-1" />
|
||||
|
||||
<button
|
||||
className="inline-flex items-center justify-center h-9 w-9 rounded-full text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors sidebar:hidden"
|
||||
className="inline-flex items-center justify-center h-9 w-9 rounded-full text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors"
|
||||
title={t('feed.actions.share')}
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
@@ -1854,7 +1867,7 @@ const KIND_HEADER_MAP: Record<number, KindHeaderConfig> = {
|
||||
icon: HandHeart,
|
||||
action: (event) => publishedAtKey(event, { created: "noteCard.kindHeader.campaignLaunched", updated: "noteCard.kindHeader.campaignUpdated", fallback: "noteCard.kindHeader.campaignFallback" }),
|
||||
noun: "noteCard.kindHeader.campaignNoun",
|
||||
nounRoute: "/campaigns/all",
|
||||
nounRoute: "/campaigns",
|
||||
},
|
||||
8: {
|
||||
icon: Award,
|
||||
|
||||
@@ -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';
|
||||
@@ -61,6 +63,7 @@ import { type CommunityMenuContext, canBanTarget, getViewerAuthority } from '@/l
|
||||
// `useCommunityModerationContext()`. Parents install a
|
||||
// `CommunityModerationContext.Provider` to enable community-aware menu items.
|
||||
import { isAdmin } from '@/lib/admins';
|
||||
import { CAMPAIGN_KIND } from '@/lib/campaign';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { timeAgo } from '@/lib/timeAgo';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
@@ -210,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);
|
||||
|
||||
@@ -232,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(
|
||||
@@ -268,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);
|
||||
@@ -306,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}
|
||||
@@ -346,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();
|
||||
@@ -360,6 +388,16 @@ function NoteMoreMenuContent({ event, open, onOpenChange, communityContext, onRe
|
||||
const pinned = isPinned(event.id);
|
||||
const isOwnPost = user?.pubkey === event.pubkey;
|
||||
|
||||
// Bookmark / Add to list / Add to sidebar don't map cleanly to campaigns
|
||||
// (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
|
||||
@@ -532,23 +570,34 @@ function NoteMoreMenuContent({ event, open, onOpenChange, communityContext, onRe
|
||||
label={t('noteMoreMenu.viewEventJson')}
|
||||
onClick={onViewEventJson}
|
||||
/>
|
||||
<MenuItem
|
||||
icon={<Bookmark className={cn("size-5", bookmarked && "fill-current")} />}
|
||||
label={bookmarked ? t('noteMoreMenu.removeBookmark') : t('noteMoreMenu.bookmark')}
|
||||
onClick={handleBookmark}
|
||||
/>
|
||||
{user && (
|
||||
{!isCampaign && (
|
||||
<MenuItem
|
||||
icon={<Bookmark className={cn("size-5", bookmarked && "fill-current")} />}
|
||||
label={bookmarked ? t('noteMoreMenu.removeBookmark') : t('noteMoreMenu.bookmark')}
|
||||
onClick={handleBookmark}
|
||||
/>
|
||||
)}
|
||||
{user && !isCampaign && (
|
||||
<MenuItem
|
||||
icon={<ListPlus className="size-5" />}
|
||||
label={t('noteMoreMenu.addToList')}
|
||||
onClick={() => { onAddToList(); }}
|
||||
/>
|
||||
)}
|
||||
<MenuItem
|
||||
icon={isInSidebar ? <Trash2 className="size-5" /> : <PanelLeft className="size-5" />}
|
||||
label={isInSidebar ? t('noteMoreMenu.removeFromSidebar') : t('noteMoreMenu.addToSidebar')}
|
||||
onClick={handleToggleSidebar}
|
||||
/>
|
||||
{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" />}
|
||||
label={isInSidebar ? t('noteMoreMenu.removeFromSidebar') : t('noteMoreMenu.addToSidebar')}
|
||||
onClick={handleToggleSidebar}
|
||||
/>
|
||||
)}
|
||||
{isOwnPost && (
|
||||
<MenuItem
|
||||
icon={<Pin className={cn("size-5", pinned && "fill-current")} />}
|
||||
@@ -566,7 +615,7 @@ function NoteMoreMenuContent({ event, open, onOpenChange, communityContext, onRe
|
||||
{!isOwnPost && (
|
||||
<MenuItem
|
||||
icon={<BellOff className="size-5" />}
|
||||
label={t('noteMoreMenu.muteConversation')}
|
||||
label={isCampaign ? t('noteMoreMenu.muteCampaign') : t('noteMoreMenu.muteConversation')}
|
||||
onClick={handleMuteConversation}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,488 @@
|
||||
import {
|
||||
useCallback,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
Bitcoin,
|
||||
Download,
|
||||
Eye,
|
||||
EyeOff,
|
||||
HandCoins,
|
||||
Link2,
|
||||
Loader2,
|
||||
Megaphone,
|
||||
User,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { generateSecretKey, getPublicKey, nip19 } from 'nostr-tools';
|
||||
|
||||
import { AgoraBoltIcon } from '@/components/icons/AgoraBoltIcon';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useLoginActions } from '@/hooks/useLoginActions';
|
||||
import { useOnboarding, type OnboardingRole } from '@/contexts/onboardingContextDef';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { downloadTextFile } from '@/lib/downloadFile';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* Step state machine for the captive signup flow.
|
||||
*
|
||||
* Order:
|
||||
* 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.
|
||||
*
|
||||
* 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';
|
||||
|
||||
const SIGNUP_STEPS: Step[] = ['keygen', 'secure', 'role'];
|
||||
|
||||
/**
|
||||
* The captive onboarding gate. Render this as a sibling of `<AppRouter />`;
|
||||
* it renders nothing when inactive and a fullscreen `fixed inset-0 z-50`
|
||||
* overlay when `useOnboarding().active === true`.
|
||||
*
|
||||
* The flow guides a brand-new user through:
|
||||
* 1. Key generation
|
||||
* 2. Save the nsec (with inline wallet-coupling framing)
|
||||
* 3. Role pick — primary CTA navigates by intent: creator → /campaigns/new,
|
||||
* donor → / (campaign grid)
|
||||
*
|
||||
* The overlay sits above all app chrome and cannot be dismissed by clicking
|
||||
* outside; users must either complete the flow or use the explicit Close (X)
|
||||
* button in the top-right corner.
|
||||
*/
|
||||
export function OnboardingGate({ children }: { children: ReactNode }) {
|
||||
const { active } = useOnboarding();
|
||||
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
{active && <CaptiveOverlay />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Inner overlay component — only mounted while the flow is active so the
|
||||
* per-flow state resets cleanly between sessions. */
|
||||
function CaptiveOverlay() {
|
||||
const { t } = useTranslation();
|
||||
const { cancel, role: contextRole, setRole: setContextRole } = useOnboarding();
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
const login = useLoginActions();
|
||||
const { user } = useCurrentUser();
|
||||
|
||||
// Decide the entry step.
|
||||
// - Already-authenticated users normally land on `role` directly.
|
||||
const initialStep: Step = useMemo(() => {
|
||||
if (user) return 'role';
|
||||
return 'keygen';
|
||||
}, [user]);
|
||||
|
||||
const [step, setStep] = useState<Step>(initialStep);
|
||||
|
||||
// Signup state
|
||||
const [nsec, setNsec] = useState('');
|
||||
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);
|
||||
const progress = currentProgressIndex < 0
|
||||
? 0
|
||||
: ((currentProgressIndex + 1) / SIGNUP_STEPS.length) * 100;
|
||||
|
||||
// Navigation helpers ------------------------------------------------------
|
||||
const goTo = useCallback((target: Step) => {
|
||||
setStep(target);
|
||||
}, []);
|
||||
|
||||
const showBackButton = !(step === 'keygen' && isGenerating);
|
||||
const handleBack = useCallback(() => {
|
||||
if (step === 'keygen') {
|
||||
cancel();
|
||||
} else if (step === 'secure') {
|
||||
goTo('keygen');
|
||||
} else {
|
||||
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.
|
||||
const handleRolePick = useCallback(
|
||||
(next: 'creator' | 'donor') => {
|
||||
setContextRole(next);
|
||||
cancel();
|
||||
if (next === 'creator') {
|
||||
navigate('/campaigns/new');
|
||||
} else {
|
||||
navigate('/campaigns');
|
||||
}
|
||||
},
|
||||
[setContextRole, cancel, navigate],
|
||||
);
|
||||
|
||||
// Key generation ----------------------------------------------------------
|
||||
const handleGenerateKey = useCallback(() => {
|
||||
setIsGenerating(true);
|
||||
// Brief visible spinner — the generation itself is instantaneous, but
|
||||
// an instant transition feels too "did anything happen?" given the
|
||||
// weight of what just got created.
|
||||
setTimeout(() => {
|
||||
const sk = generateSecretKey();
|
||||
setNsec(nip19.nsecEncode(sk));
|
||||
setIsGenerating(false);
|
||||
goTo('secure');
|
||||
}, 700);
|
||||
}, [goTo]);
|
||||
|
||||
// Download + install nsec into the login store ---------------------------
|
||||
const handleDownloadAndContinue = useCallback(async () => {
|
||||
try {
|
||||
const decoded = nip19.decode(nsec);
|
||||
if (decoded.type !== 'nsec') throw new Error('Invalid nsec');
|
||||
const pubkey = getPublicKey(decoded.data);
|
||||
const npub = nip19.npubEncode(pubkey);
|
||||
const filename = `nostr-${location.hostname.replaceAll(/\./g, '-')}-${npub.slice(5, 9)}.nsec.txt`;
|
||||
await downloadTextFile(filename, nsec);
|
||||
login.nsec(nsec);
|
||||
goTo('role');
|
||||
} catch {
|
||||
toast({
|
||||
title: t('onboarding.secure.downloadFailedTitle'),
|
||||
description: t('onboarding.secure.downloadFailedDescription'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
}, [nsec, login, goTo, toast, t]);
|
||||
|
||||
// Step renderer -----------------------------------------------------------
|
||||
const stepBody = (() => {
|
||||
switch (step) {
|
||||
case 'keygen':
|
||||
// First step. Back closes the captive flow entirely — the user got
|
||||
// here from the AuthDialog and already chose "signup".
|
||||
return (
|
||||
<KeygenStep
|
||||
isGenerating={isGenerating}
|
||||
onGenerate={handleGenerateKey}
|
||||
/>
|
||||
);
|
||||
case 'secure':
|
||||
return (
|
||||
<SecureStep
|
||||
nsec={nsec}
|
||||
showKey={showKey}
|
||||
onToggleShow={() => setShowKey((v) => !v)}
|
||||
onContinue={handleDownloadAndContinue}
|
||||
/>
|
||||
);
|
||||
case 'role':
|
||||
// Final step. Picking a role navigates to the matching surface
|
||||
// (creator → /campaigns/new, donor → /); Back goes to secure if the
|
||||
// user signed up through the full flow, or cancels the overlay if
|
||||
// they were already-authenticated and landed here directly.
|
||||
return (
|
||||
<RoleStep
|
||||
role={contextRole}
|
||||
onPick={handleRolePick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
})();
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 bg-background overflow-y-auto flex flex-col"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t('onboarding.ariaLabel')}
|
||||
>
|
||||
{/* Progress bar — every step in the flow counts toward it. */}
|
||||
<div className="sticky top-0 z-10 h-1 bg-muted">
|
||||
<div
|
||||
className="h-full bg-primary transition-all duration-500 ease-out"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showBackButton && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleBack}
|
||||
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"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5 rtl:rotate-180" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Top-right close. Lets users escape if they truly don't want to
|
||||
continue — but it's deliberately unobtrusive vs. a backdrop click
|
||||
so casual taps don't drop them out of the flow. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={cancel}
|
||||
aria-label={t('onboarding.close')}
|
||||
className="absolute right-4 top-4 z-20 sm:right-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"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
<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"
|
||||
>
|
||||
{stepBody}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Step components
|
||||
// =============================================================================
|
||||
|
||||
interface RoleStepProps {
|
||||
role: OnboardingRole;
|
||||
onPick: (role: 'creator' | 'donor') => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Two-card role picker, modeled on the Treasures CreateCacheLanding pattern.
|
||||
* Both cards use primary-tinted icon chips (both roles are first-class) and
|
||||
* the three-line "title / what you do / what the other side sees"
|
||||
* structure that makes the choice feel like a role rather than a feature
|
||||
* menu.
|
||||
*/
|
||||
function RoleStep({ role, onPick }: RoleStepProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2 text-center">
|
||||
<h2 className="text-2xl font-bold tracking-tight">{t('onboarding.role.title')}</h2>
|
||||
<p className="text-sm text-muted-foreground">{t('onboarding.role.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<RoleCard
|
||||
icon={<Megaphone className="h-5 w-5 md:h-6 md:w-6 text-primary" />}
|
||||
title={t('onboarding.role.creator.title')}
|
||||
description={t('onboarding.role.creator.description')}
|
||||
finderNote={t('onboarding.role.creator.finderNote')}
|
||||
selected={role === 'creator'}
|
||||
onClick={() => onPick('creator')}
|
||||
/>
|
||||
<RoleCard
|
||||
icon={<HandCoins className="h-5 w-5 md:h-6 md:w-6 text-primary" />}
|
||||
title={t('onboarding.role.donor.title')}
|
||||
description={t('onboarding.role.donor.description')}
|
||||
finderNote={t('onboarding.role.donor.finderNote')}
|
||||
selected={role === 'donor'}
|
||||
onClick={() => onPick('donor')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface RoleCardProps {
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
finderNote: string;
|
||||
selected: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
/** A single role card. Three text lines, primary-tinted icon chip, hover and
|
||||
* selected states matching the Treasures pattern. */
|
||||
function RoleCard({ icon, title, description, finderNote, selected, onClick }: RoleCardProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'w-full text-left rounded-xl border bg-card p-5 md:p-6 transition-all',
|
||||
'hover:border-primary/50 hover:shadow-md',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2',
|
||||
selected && 'border-primary shadow-md ring-1 ring-primary',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0 w-10 h-10 md:w-12 md:h-12 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
{icon}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-sm md:text-base font-semibold text-foreground">{title}</h3>
|
||||
<p className="text-xs md:text-sm text-muted-foreground mt-0.5">{description}</p>
|
||||
<p className="text-xs md:text-sm font-medium text-primary mt-1.5">{finderNote}</p>
|
||||
</div>
|
||||
<ArrowRight className="h-4 w-4 md:h-5 md:w-5 text-muted-foreground flex-shrink-0 mt-1 rtl:rotate-180" />
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
interface KeygenStepProps {
|
||||
isGenerating: boolean;
|
||||
onGenerate: () => void;
|
||||
}
|
||||
|
||||
/** Key generation step — a single CTA that fires off `generateSecretKey()`
|
||||
* with a brief visible spinner for tactile feedback. */
|
||||
function KeygenStep({ isGenerating, onGenerate }: KeygenStepProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="space-y-6 text-center">
|
||||
<div className="relative w-24 h-24 mx-auto">
|
||||
{isGenerating ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<Loader2 className="w-14 h-14 text-primary animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<AgoraBoltIcon className="size-20 drop-shadow-md" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-2xl font-bold tracking-tight">
|
||||
{isGenerating ? t('onboarding.keygen.generatingTitle') : t('onboarding.keygen.title')}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{isGenerating ? t('onboarding.keygen.generatingDescription') : t('onboarding.keygen.description')}
|
||||
</p>
|
||||
</div>
|
||||
{!isGenerating && (
|
||||
<Button onClick={onGenerate} className="w-full h-12 text-base rounded-full">
|
||||
{t('onboarding.keygen.button')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SecureStepProps {
|
||||
nsec: string;
|
||||
showKey: boolean;
|
||||
onToggleShow: () => void;
|
||||
onContinue: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reveals + downloads the nsec, then installs it into the login store and
|
||||
* advances.
|
||||
*
|
||||
* Three stacked elements communicate the weight of saving the key:
|
||||
* 1. The key itself (revealable input + download button).
|
||||
* 2. A "your account and your wallet share this key" coupling callout —
|
||||
* large linked icons make the relationship the visual centerpiece.
|
||||
* 3. A no-recovery emphasis block — calm, informational tone (not a red
|
||||
* destructive alert) but typographically dominant so the user can't
|
||||
* breeze past the "there is no way to get this back" point.
|
||||
*
|
||||
* This is the only captive-flow surface that explains the coupling and the
|
||||
* permanence to brand-new users, so it has to carry weight without scaring
|
||||
* them.
|
||||
*/
|
||||
function SecureStep({ nsec, showKey, onToggleShow, onContinue }: SecureStepProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2 text-center">
|
||||
<h2 className="text-2xl font-bold tracking-tight">{t('onboarding.secure.title')}</h2>
|
||||
<p className="text-sm text-muted-foreground">{t('onboarding.secure.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showKey ? 'text' : 'password'}
|
||||
value={nsec}
|
||||
readOnly
|
||||
className="pr-10 font-mono text-sm"
|
||||
aria-label="Secret key"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-0 top-0 h-full px-3 hover:bg-transparent"
|
||||
onClick={onToggleShow}
|
||||
>
|
||||
{showKey ? (
|
||||
<EyeOff className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Single coupling+permanence card. The linked-icon visual at the
|
||||
top communicates "your account and your wallet share this key";
|
||||
the typographically heavier line below it makes the no-recovery
|
||||
point inescapable without resorting to red/warning iconography.
|
||||
Both messages live in one card so the user can't skim past
|
||||
either. */}
|
||||
<div className="rounded-xl bg-primary/10 border-2 border-primary/30 p-5 space-y-4">
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<div className="w-14 h-14 rounded-full bg-background ring-2 ring-primary/30 flex items-center justify-center shadow-sm">
|
||||
<User className="h-7 w-7 text-primary" />
|
||||
</div>
|
||||
<div className="w-7 h-7 rounded-full bg-primary text-primary-foreground flex items-center justify-center shadow">
|
||||
<Link2 className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="w-14 h-14 rounded-full bg-background ring-2 ring-primary/30 flex items-center justify-center shadow-sm">
|
||||
<Bitcoin className="h-7 w-7 text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-foreground text-center leading-relaxed">
|
||||
{t('onboarding.secure.couplingNote')}
|
||||
</p>
|
||||
<div className="border-t border-primary/20 pt-3 text-center">
|
||||
<p className="text-base font-bold tracking-tight text-foreground leading-snug">
|
||||
{t('onboarding.secure.permanenceHeadline')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1.5 leading-relaxed">
|
||||
{t('onboarding.secure.permanenceBody')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button onClick={onContinue} className="w-full h-12 text-base rounded-full">
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
{t('onboarding.secure.button')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { nip19 } from 'nostr-tools';
|
||||
import { AuthorByline } from '@/components/AuthorByline';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useEventTranslation } from '@/hooks/useEventTranslation';
|
||||
import { parseAction, type Action } from '@/hooks/useActions';
|
||||
import { getGeoDisplayName } from '@/lib/countries';
|
||||
@@ -172,3 +173,30 @@ export function PledgeCard({
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loading placeholder that matches `PledgeCard`'s grid-variant shape:
|
||||
* 16:9 cover, then title, two lines of body, a progress bar row, and
|
||||
* a footer line. Sized to slot into the same `<DiscoveryGrid>` / 4-col
|
||||
* grids as the real card so the skeleton row doesn't reflow when data
|
||||
* arrives.
|
||||
*
|
||||
* Lives next to `PledgeCard` for parity with `CampaignCardSkeleton`
|
||||
* and `CommunityMiniCardSkeleton`, which sit next to their cards too.
|
||||
* Was duplicated as `ActionSkeleton` in `PledgesDiscoverySection` and
|
||||
* `ActionsPage` before this consolidation.
|
||||
*/
|
||||
export function PledgeCardSkeleton() {
|
||||
return (
|
||||
<Card className="overflow-hidden border-border/70 shadow-sm h-full flex flex-col">
|
||||
<Skeleton className="aspect-[16/9] w-full rounded-none" />
|
||||
<div className="flex-1 p-5 space-y-3">
|
||||
<Skeleton className="h-5 w-3/4" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-4/5" />
|
||||
<Skeleton className="h-2 w-full" />
|
||||
<Skeleton className="h-4 w-2/3" />
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -112,7 +112,7 @@ function EpisodeDetail({ event }: { event: NostrEvent }) {
|
||||
<img src={proxy(parsed.artwork, 320)} alt={parsed.title} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center bg-primary/10">
|
||||
<Podcast className="size-12 text-primary/30" />
|
||||
<Podcast className="size-12 text-primary" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -316,7 +316,7 @@ function TrailerDetail({ event }: { event: NostrEvent }) {
|
||||
<div className="px-4 flex gap-5 items-start">
|
||||
<div className="shrink-0 w-32 sm:w-40 aspect-square rounded-2xl overflow-hidden bg-muted shadow-lg">
|
||||
<div className="w-full h-full flex items-center justify-center bg-primary/10">
|
||||
<Podcast className="size-12 text-primary/30" />
|
||||
<Podcast className="size-12 text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Camera, Loader2, X, ZapOff, Zap } from 'lucide-react';
|
||||
import QrScanner from 'qr-scanner';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface QrScannerDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
/** Called with the decoded QR text the first time a code is read. */
|
||||
onScan: (value: string) => void;
|
||||
/** Override the dialog title (defaults to the localized "Scan QR code"). */
|
||||
title?: string;
|
||||
}
|
||||
|
||||
/** How long to wait after `start()` resolves before declaring the camera dead. */
|
||||
const VIDEO_READY_TIMEOUT_MS = 6000;
|
||||
|
||||
/**
|
||||
* Camera-based QR scanner dialog. Works in browsers, Capacitor's WKWebView
|
||||
* (iOS), and Android's WebView, all via `getUserMedia` + the `qr-scanner`
|
||||
* library (ZXing / BarcodeDetector under the hood).
|
||||
*
|
||||
* The dialog owns the camera lifecycle: it spins up the scanner when opened
|
||||
* and tears it down on close, so callers only need to manage `isOpen` and
|
||||
* react to `onScan`.
|
||||
*
|
||||
* Failure modes we explicitly surface (instead of a silent black screen):
|
||||
* - Insecure context (HTTP) — getUserMedia is unavailable.
|
||||
* - Camera permission denied.
|
||||
* - No camera on the device.
|
||||
* - `facingMode: 'environment'` not satisfiable (some laptops, some
|
||||
* locked-down WebViews). We retry with the front camera.
|
||||
* - `start()` resolves but the video never emits `loadedmetadata` within
|
||||
* `VIDEO_READY_TIMEOUT_MS` — usually means the worker engine failed to
|
||||
* initialize or another app is holding the camera.
|
||||
*/
|
||||
export function QrScannerDialog({ isOpen, onClose, onScan, title }: QrScannerDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
// Callback ref so we know the moment the element is attached. Radix
|
||||
// Dialog mounts content lazily inside a Portal, so a plain `useRef` is
|
||||
// still null on the first effect tick after `isOpen` flips to true.
|
||||
// A state-backed ref re-runs the effect once the <video> actually
|
||||
// exists in the DOM.
|
||||
const [video, setVideo] = useState<HTMLVideoElement | null>(null);
|
||||
const scannerRef = useRef<QrScanner | null>(null);
|
||||
// Keep the latest onScan in a ref so the start effect doesn't tear down
|
||||
// the camera every time the parent passes a new callback identity.
|
||||
const onScanRef = useRef(onScan);
|
||||
onScanRef.current = onScan;
|
||||
|
||||
const [status, setStatus] = useState<'idle' | 'starting' | 'running' | 'error'>('idle');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [hasFlash, setHasFlash] = useState(false);
|
||||
const [flashOn, setFlashOn] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
if (!video) return;
|
||||
|
||||
// Secure-context guard — getUserMedia is only available on https / localhost.
|
||||
if (typeof window !== 'undefined' && window.isSecureContext === false) {
|
||||
setStatus('error');
|
||||
setError(t('qrScanner.errors.insecure'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!navigator.mediaDevices?.getUserMedia) {
|
||||
setStatus('error');
|
||||
setError(t('qrScanner.errors.unsupported'));
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
let readyTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
setStatus('starting');
|
||||
setError(null);
|
||||
|
||||
const handleDecode = (result: QrScanner.ScanResult) => {
|
||||
if (cancelled) return;
|
||||
cancelled = true;
|
||||
scanner.stop();
|
||||
onScanRef.current(result.data);
|
||||
};
|
||||
|
||||
let scanner: QrScanner;
|
||||
try {
|
||||
scanner = new QrScanner(
|
||||
video,
|
||||
handleDecode,
|
||||
{
|
||||
returnDetailedScanResult: true,
|
||||
highlightScanRegion: true,
|
||||
highlightCodeOutline: true,
|
||||
preferredCamera: 'environment',
|
||||
maxScansPerSecond: 5,
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
setStatus('error');
|
||||
setError(humanizeCameraError(err, t));
|
||||
return;
|
||||
}
|
||||
|
||||
scannerRef.current = scanner;
|
||||
|
||||
/**
|
||||
* Watch the video element. If it never reaches `HAVE_METADATA` within the
|
||||
* timeout, the scanner is silently broken. Surface that instead of a
|
||||
* black screen.
|
||||
*/
|
||||
const armReadyTimeout = () => {
|
||||
readyTimer = setTimeout(() => {
|
||||
if (cancelled) return;
|
||||
if (video.readyState < 1 /* HAVE_METADATA */) {
|
||||
cancelled = true;
|
||||
scanner.stop();
|
||||
setStatus('error');
|
||||
setError(t('qrScanner.errors.didntStart'));
|
||||
}
|
||||
}, VIDEO_READY_TIMEOUT_MS);
|
||||
};
|
||||
|
||||
const clearReadyTimeout = () => {
|
||||
if (readyTimer) {
|
||||
clearTimeout(readyTimer);
|
||||
readyTimer = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const onLoadedMetadata = () => clearReadyTimeout();
|
||||
video.addEventListener('loadedmetadata', onLoadedMetadata, { once: true });
|
||||
|
||||
/**
|
||||
* Some devices reject `facingMode: 'environment'` with OverconstrainedError
|
||||
* (laptops without a rear camera, some Android WebViews). Retry without a
|
||||
* camera preference so the browser picks any available device.
|
||||
*/
|
||||
const startWithFallback = async () => {
|
||||
try {
|
||||
await scanner.start();
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
if (isOverconstrainedError(err)) {
|
||||
try {
|
||||
await scanner.setCamera('user');
|
||||
return;
|
||||
} catch {
|
||||
// Fall through and report the original error.
|
||||
}
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
startWithFallback()
|
||||
.then(async () => {
|
||||
if (cancelled) return;
|
||||
clearReadyTimeout();
|
||||
setStatus('running');
|
||||
armReadyTimeout();
|
||||
try {
|
||||
const flashAvailable = await scanner.hasFlash();
|
||||
if (!cancelled) setHasFlash(flashAvailable);
|
||||
} catch {
|
||||
// Flash detection is best-effort; ignore.
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (cancelled) return;
|
||||
clearReadyTimeout();
|
||||
setStatus('error');
|
||||
setError(humanizeCameraError(err, t));
|
||||
});
|
||||
|
||||
// Arm an initial timeout in case `start()` neither resolves nor rejects
|
||||
// (e.g. the worker engine wedges on CSP-blocked blob creation, or the
|
||||
// OS permission dialog is dismissed without a callback firing).
|
||||
armReadyTimeout();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearReadyTimeout();
|
||||
video.removeEventListener('loadedmetadata', onLoadedMetadata);
|
||||
scanner.stop();
|
||||
scanner.destroy();
|
||||
scannerRef.current = null;
|
||||
setHasFlash(false);
|
||||
setFlashOn(false);
|
||||
};
|
||||
}, [isOpen, video, t]);
|
||||
|
||||
const toggleFlash = async () => {
|
||||
const scanner = scannerRef.current;
|
||||
if (!scanner) return;
|
||||
try {
|
||||
await scanner.toggleFlash();
|
||||
setFlashOn(scanner.isFlashOn());
|
||||
} catch {
|
||||
// Ignore — some devices report `hasFlash` true but error on toggle.
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => { if (!open) onClose(); }}>
|
||||
<DialogContent className="max-w-[min(380px,calc(100vw-2rem))] max-h-[calc(100svh-2rem)] rounded-2xl p-0 gap-0 border-border overflow-hidden flex flex-col [&>button]:hidden">
|
||||
<div className="flex items-center justify-between px-4 h-12 shrink-0">
|
||||
<DialogTitle className="text-base font-semibold flex items-center gap-1.5">
|
||||
{title ?? t('qrScanner.title')}
|
||||
</DialogTitle>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 -mr-1.5 rounded-full text-muted-foreground hover:text-foreground hover:bg-secondary/60 motion-safe:transition-colors"
|
||||
aria-label={t('qrScanner.close')}
|
||||
>
|
||||
<X className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="relative w-full aspect-square bg-black overflow-hidden shrink-0"
|
||||
style={{ maxHeight: 'min(380px, calc(100vw - 2rem))' }}
|
||||
>
|
||||
<video
|
||||
ref={setVideo}
|
||||
className={cn(
|
||||
'absolute inset-0 w-full h-full object-cover',
|
||||
status !== 'running' && 'opacity-0',
|
||||
)}
|
||||
playsInline
|
||||
muted
|
||||
/>
|
||||
|
||||
{status === 'starting' && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 text-white/80">
|
||||
<Loader2 className="size-8 animate-spin" />
|
||||
<p className="text-sm">{t('qrScanner.starting')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === 'error' && (
|
||||
<div className="absolute inset-0 flex items-center justify-center p-6">
|
||||
<Alert variant="destructive" className="bg-background">
|
||||
<Camera className="size-4" />
|
||||
<AlertDescription className="text-xs">
|
||||
{error || t('qrScanner.errors.generic')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === 'running' && hasFlash && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleFlash}
|
||||
aria-label={flashOn ? t('qrScanner.flashOff') : t('qrScanner.flashOn')}
|
||||
className="absolute bottom-3 right-3 size-10 rounded-full bg-black/50 backdrop-blur-sm text-white flex items-center justify-center hover:bg-black/70 motion-safe:transition-colors"
|
||||
>
|
||||
{flashOn ? <ZapOff className="size-5" /> : <Zap className="size-5" />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-3 shrink-0">
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
{t('qrScanner.aim')}
|
||||
</p>
|
||||
{status === 'error' && (
|
||||
<Button onClick={onClose} variant="outline" className="w-full mt-3">
|
||||
{t('qrScanner.close')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function isOverconstrainedError(err: unknown): boolean {
|
||||
if (!err || typeof err !== 'object') return false;
|
||||
const name = (err as { name?: unknown }).name;
|
||||
if (name === 'OverconstrainedError') return true;
|
||||
const msg = err instanceof Error ? err.message : '';
|
||||
return /overconstrained|constraint/i.test(msg);
|
||||
}
|
||||
|
||||
function humanizeCameraError(err: unknown, t: (key: string) => string): string {
|
||||
const name = (err && typeof err === 'object' ? (err as { name?: unknown }).name : undefined);
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
|
||||
if (name === 'NotFoundError' || /no camera/i.test(msg) || /not found/i.test(msg)) {
|
||||
return t('qrScanner.errors.notFound');
|
||||
}
|
||||
if (name === 'NotAllowedError' || /permission/i.test(msg) || /denied/i.test(msg)) {
|
||||
return t('qrScanner.errors.denied');
|
||||
}
|
||||
if (name === 'NotReadableError' || /in use|busy|readable/i.test(msg)) {
|
||||
return t('qrScanner.errors.busy');
|
||||
}
|
||||
if (name === 'SecurityError' || /secure context/i.test(msg) || /https/i.test(msg)) {
|
||||
return t('qrScanner.errors.insecure');
|
||||
}
|
||||
if (name === 'OverconstrainedError' || /overconstrained|constraint/i.test(msg)) {
|
||||
return t('qrScanner.errors.overconstrained');
|
||||
}
|
||||
return msg || t('qrScanner.errors.generic');
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { forwardRef, useState, type ButtonHTMLAttributes, type MouseEvent } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import AuthDialog from '@/components/auth/AuthDialog';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
|
||||
type StartCampaignLinkProps = ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
to?: string;
|
||||
};
|
||||
|
||||
export const StartCampaignLink = forwardRef<HTMLButtonElement, StartCampaignLinkProps>(function StartCampaignLink(
|
||||
{ onClick, to = '/campaigns/new', type = 'button', ...props },
|
||||
ref,
|
||||
) {
|
||||
const { user } = useCurrentUser();
|
||||
const navigate = useNavigate();
|
||||
const [authOpen, setAuthOpen] = useState(false);
|
||||
|
||||
const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
|
||||
onClick?.(event);
|
||||
if (event.defaultPrevented) return;
|
||||
|
||||
if (user) {
|
||||
navigate(to);
|
||||
} else {
|
||||
setAuthOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button ref={ref} type={type} onClick={handleClick} {...props} />
|
||||
<AuthDialog isOpen={authOpen} onClose={() => setAuthOpen(false)} />
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -34,7 +34,7 @@ export function StickerPicker({ customEmojis, onSelect, height = 350, autoFocus
|
||||
if (customEmojis.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center text-muted-foreground gap-2" style={{ height }}>
|
||||
<Sticker className="size-8 opacity-40" />
|
||||
<Sticker className="size-8" />
|
||||
<p className="text-sm">No sticker packs yet</p>
|
||||
<p className="text-xs">Add emoji packs to your profile to use stickers</p>
|
||||
</div>
|
||||
|
||||
@@ -121,7 +121,7 @@ export function SubHeaderBar({ children, className, innerClassName, backgroundFi
|
||||
onClick={() => scrollBy('left')}
|
||||
className="hidden sidebar:flex absolute left-0 top-0 bottom-0 z-10 items-center pl-0.5 pr-1 bg-gradient-to-r from-background via-background to-transparent cursor-pointer"
|
||||
>
|
||||
<ChevronLeft className="size-4 text-foreground/60 drop-shadow-md" strokeWidth={4} />
|
||||
<ChevronLeft className="size-4 text-foreground drop-shadow-md" strokeWidth={4} />
|
||||
</button>
|
||||
)}
|
||||
<div
|
||||
@@ -138,7 +138,7 @@ export function SubHeaderBar({ children, className, innerClassName, backgroundFi
|
||||
onClick={() => scrollBy('right')}
|
||||
className="hidden sidebar:flex absolute right-0 top-0 bottom-0 z-10 items-center pr-0.5 pl-1 bg-gradient-to-l from-background via-background to-transparent cursor-pointer"
|
||||
>
|
||||
<ChevronRight className="size-4 text-foreground/60 drop-shadow-md" strokeWidth={4} />
|
||||
<ChevronRight className="size-4 text-foreground drop-shadow-md" strokeWidth={4} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -136,7 +136,7 @@ export function TimezoneSwitcher({ value, onChange, className }: TimezoneSwitche
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronDown className="h-4 w-4 opacity-50 shrink-0" />
|
||||
<ChevronDown className="h-4 w-4 shrink-0" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[320px] p-0" align="start" sideOffset={4}>
|
||||
|
||||
@@ -7,12 +7,10 @@ import {
|
||||
HandHeart,
|
||||
Info,
|
||||
LayoutDashboard,
|
||||
Megaphone,
|
||||
Menu,
|
||||
Search,
|
||||
Settings,
|
||||
User,
|
||||
Users,
|
||||
Wallet,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
@@ -22,9 +20,9 @@ import { LoginArea } from '@/components/auth/LoginArea';
|
||||
import { LogoIcon } from '@/components/icons/LogoIcon';
|
||||
import { Sheet, SheetContent } from '@/components/ui/sheet';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { useBtcPrice } from '@/hooks/useBtcPrice';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useFeedSettings } from '@/hooks/useFeedSettings';
|
||||
import { useHdBtcPrice } from '@/hooks/useHdBtcPrice';
|
||||
import { useHdWallet } from '@/hooks/useHdWallet';
|
||||
import { satsToUSD } from '@/lib/bitcoin';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -39,10 +37,16 @@ interface NavItem {
|
||||
}
|
||||
|
||||
const NAV_ITEMS: NavItem[] = [
|
||||
{ labelKey: 'nav.campaigns', to: '/campaigns', icon: HandHeart },
|
||||
{ labelKey: 'nav.activity', to: '/feed', icon: Activity },
|
||||
{ labelKey: 'nav.campaigns', to: '/campaigns/all', icon: HandHeart },
|
||||
{ labelKey: 'nav.groups', to: '/groups', icon: Users },
|
||||
{ labelKey: 'nav.pledge', to: '/pledges', icon: Megaphone },
|
||||
// Groups and Pledges are intentionally hidden from the main nav for
|
||||
// launch — keep the routes and feature code intact so we can re-add
|
||||
// them later by uncommenting these two lines (and re-importing the
|
||||
// `Users` and `Megaphone` icons from `lucide-react` at the top of
|
||||
// this file). Both pages still work when visited directly and are
|
||||
// still linked from in-page CTAs and user-authored content.
|
||||
// { labelKey: 'nav.groups', to: '/groups', icon: Users },
|
||||
// { labelKey: 'nav.pledge', to: '/pledges', icon: Megaphone },
|
||||
];
|
||||
|
||||
interface MobileLinkItem extends NavItem {
|
||||
@@ -63,8 +67,8 @@ 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">
|
||||
<div className="mx-auto flex h-16 max-w-7xl items-center gap-4 px-4 sm:px-6">
|
||||
<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
|
||||
type="button"
|
||||
@@ -184,7 +188,7 @@ export function TopNav() {
|
||||
/**
|
||||
* Compact USD balance pill in the top-nav right cluster, replacing the
|
||||
* previous search icon. Reads the HD-wallet sats balance via {@link useHdWallet}
|
||||
* and converts to USD via {@link useBtcPrice}. Renders nothing when the wallet
|
||||
* and converts to USD via {@link useHdBtcPrice}. Renders nothing when the wallet
|
||||
* isn't available (logged out, extension/bunker login, still loading, or no
|
||||
* price yet) so the chrome stays quiet rather than flashing placeholder text.
|
||||
*/
|
||||
@@ -201,7 +205,7 @@ function DeferredWalletBalancePill() {
|
||||
function WalletBalancePill() {
|
||||
const { t } = useTranslation();
|
||||
const { availability, totalBalance, isLoading, error } = useHdWallet();
|
||||
const { data: btcPrice } = useBtcPrice();
|
||||
const { data: btcPrice } = useHdBtcPrice();
|
||||
|
||||
if (availability.status !== 'available') return null;
|
||||
if (isLoading || error || !btcPrice) return null;
|
||||
|
||||
@@ -3,11 +3,10 @@ import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useAppContext } from "@/hooks/useAppContext";
|
||||
import { prepareForTranslation, restoreTokens } from "@/lib/prepareTranslation";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const DEFAULT_TRANSLATE_WORKER_URL = "https://agora-translate.mk-cc1.workers.dev";
|
||||
|
||||
const LANG_MAP: Record<string, string> = {
|
||||
en: "EN-US",
|
||||
es: "ES",
|
||||
@@ -54,6 +53,7 @@ export function TranslateButton({
|
||||
className,
|
||||
}: TranslateButtonProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { config } = useAppContext();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
@@ -66,6 +66,9 @@ export function TranslateButton({
|
||||
|
||||
if (!text.trim()) return;
|
||||
|
||||
const translateUrl = config.translateWorkerUrl.trim();
|
||||
if (!translateUrl) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
|
||||
@@ -73,7 +76,6 @@ export function TranslateButton({
|
||||
const languagePrefix = i18n.language.split("-")[0].toLowerCase();
|
||||
const targetLang = LANG_MAP[languagePrefix] ?? "EN-US";
|
||||
const prepared = (texts && texts.length > 0 ? texts : [text]).map(prepareForTranslation);
|
||||
const translateUrl = import.meta.env.VITE_TRANSLATE_WORKER_URL ?? DEFAULT_TRANSLATE_WORKER_URL;
|
||||
|
||||
const response = await fetch(translateUrl, {
|
||||
method: "POST",
|
||||
@@ -100,6 +102,9 @@ export function TranslateButton({
|
||||
}
|
||||
};
|
||||
|
||||
// No translation worker configured — hide the button entirely.
|
||||
if (!config.translateWorkerUrl.trim()) return null;
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -140,7 +140,7 @@ export function WalletSettings() {
|
||||
{connections.length === 0 ? (
|
||||
<Card className="border-dashed">
|
||||
<CardContent className="py-10 text-center">
|
||||
<WalletMinimal className="size-8 mx-auto mb-3 text-muted-foreground/50" />
|
||||
<WalletMinimal className="size-8 mx-auto mb-3 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground mb-1">{t('walletConnect.empty.title')}</p>
|
||||
<p className="text-xs text-muted-foreground/70">{t('walletConnect.empty.description')}</p>
|
||||
</CardContent>
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
import { useState, type FormEvent, type ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ArrowLeft, X } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
export interface WizardStep {
|
||||
/** Centered heading at the top of the step. Concise — one short phrase. */
|
||||
title: string;
|
||||
/** Muted single-line subtitle beneath the heading. Optional. */
|
||||
subtitle?: string;
|
||||
/** The form fields for this step. */
|
||||
body: ReactNode;
|
||||
}
|
||||
|
||||
export interface WizardProps {
|
||||
/**
|
||||
* Accessibility label for the wizard's dialog role. Should describe
|
||||
* what the user is creating — e.g. "Create a campaign", "Create a
|
||||
* group". Used as the `aria-label` on the outer `role="dialog"`.
|
||||
*/
|
||||
headingAriaLabel: string;
|
||||
/** 1-indexed list of steps. Length determines the total. */
|
||||
steps: WizardStep[];
|
||||
/**
|
||||
* Optional lead content rendered above the first step's body. The
|
||||
* campaign wizard uses this for the "publishing under <org>" chip so
|
||||
* the publishing-context is the very first thing the user sees on
|
||||
* step 1. Hidden on every other step.
|
||||
*/
|
||||
step1Lead?: ReactNode;
|
||||
/** Error alert rendered beneath each step's body. Pass null when no error. */
|
||||
errorAlert?: ReactNode;
|
||||
/**
|
||||
* Content rendered inside the terminal step's submit button — typically
|
||||
* "Launch campaign" / "Create group" with a leading icon, and a spinner +
|
||||
* "Publishing…" copy while submitting.
|
||||
*/
|
||||
submitButtonContent: ReactNode;
|
||||
/** True while the parent mutation is in flight; disables all forward actions. */
|
||||
submitting: boolean;
|
||||
/**
|
||||
* Predicate gating forward progress from a given (1-indexed) step.
|
||||
* Return `false` to disable Next on that step. Steps not gated by
|
||||
* this fn are always allowed to advance.
|
||||
*/
|
||||
canAdvanceFromStep: (step: number) => boolean;
|
||||
/** Optional async guard called before moving from a non-terminal step. */
|
||||
onBeforeAdvance?: (step: number) => boolean | Promise<boolean>;
|
||||
/**
|
||||
* 1-indexed step from which the "Skip Next & Launch" shortcut may
|
||||
* appear. The shortcut is *only* rendered when `launchNowLabel` is
|
||||
* also provided — pass `Infinity` (or omit `launchNowLabel`) to
|
||||
* disable the shortcut entirely. Earlier steps render only the Next
|
||||
* button.
|
||||
*/
|
||||
launchAvailableFromStep?: number;
|
||||
/**
|
||||
* Label for the optional ghost shortcut that submits the form
|
||||
* mid-wizard without finishing the remaining (optional) steps. Pass
|
||||
* `undefined` to hide the shortcut.
|
||||
*/
|
||||
launchNowLabel?: string;
|
||||
onSubmit: (e: FormEvent) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Multi-step layout used by Agora's creation flows (campaigns,
|
||||
* groups, …).
|
||||
*
|
||||
* Rendered as a **fullscreen captive overlay** (`fixed inset-0 z-50`)
|
||||
* so it sits above the persistent TopNav — the same treatment Chad's
|
||||
* onboarding flow uses for signup. From the user's perspective each
|
||||
* creation flow is a focused, distraction-free task, not "another
|
||||
* page in the app."
|
||||
*
|
||||
* Visually: a sticky single-bar progress fill across the top, a
|
||||
* top-right X to escape, a top-left back arrow from step 2 onward, a
|
||||
* centered narrow column for each step, and a big rounded-full
|
||||
* primary CTA at the bottom.
|
||||
*
|
||||
* Earlier required steps are gated by {@link WizardProps.canAdvanceFromStep};
|
||||
* an optional "Skip Next & Launch" ghost shortcut appears from
|
||||
* {@link WizardProps.launchAvailableFromStep} onward when a
|
||||
* {@link WizardProps.launchNowLabel} is provided. The last step is
|
||||
* terminal — its only forward action is the primary submit button.
|
||||
*
|
||||
* The `<form>` lives inside this wrapper (not the parent) so the
|
||||
* submit button — wherever it ends up in the wizard — submits the
|
||||
* same form and reuses the parent's `onSubmit`.
|
||||
*/
|
||||
export function Wizard({
|
||||
headingAriaLabel,
|
||||
steps,
|
||||
step1Lead,
|
||||
errorAlert,
|
||||
submitButtonContent,
|
||||
submitting,
|
||||
canAdvanceFromStep,
|
||||
onBeforeAdvance,
|
||||
launchAvailableFromStep = Infinity,
|
||||
launchNowLabel,
|
||||
onSubmit,
|
||||
onClose,
|
||||
}: WizardProps) {
|
||||
const { t } = useTranslation();
|
||||
const [step, setStep] = useState(1);
|
||||
const [isAdvancing, setIsAdvancing] = useState(false);
|
||||
const totalSteps = steps.length;
|
||||
const current = steps[step - 1];
|
||||
const isTerminal = step === totalSteps;
|
||||
const progress = (step / totalSteps) * 100;
|
||||
|
||||
const launchVisible = !!launchNowLabel && step >= launchAvailableFromStep;
|
||||
const canAdvance = canAdvanceFromStep(step);
|
||||
// The terminal step's own submit honors only `submitting` — its
|
||||
// required fields have already been cleared by the gates on
|
||||
// previous steps. The mid-wizard shortcut, on the other hand,
|
||||
// sits *on* a potentially-gated step, so it must respect the
|
||||
// same `canAdvance` check the Next button does — otherwise a
|
||||
// user could click "Skip Next & Launch" with a still-empty
|
||||
// required field and trip a server-side validation error.
|
||||
const canSubmit = isTerminal
|
||||
? !submitting && !isAdvancing
|
||||
: launchVisible && canAdvance && !submitting && !isAdvancing;
|
||||
|
||||
const handleAdvance = async () => {
|
||||
if (submitting || isAdvancing || !canAdvance) return;
|
||||
|
||||
setIsAdvancing(true);
|
||||
try {
|
||||
const shouldAdvance = await onBeforeAdvance?.(step);
|
||||
if (shouldAdvance === false) return;
|
||||
setStep((s) => Math.min(s + 1, totalSteps));
|
||||
} catch {
|
||||
// The parent owns user-visible errors for async step validation.
|
||||
} finally {
|
||||
setIsAdvancing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 bg-background overflow-y-auto flex flex-col"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={headingAriaLabel}
|
||||
>
|
||||
{/* Sticky single-bar progress indicator, mirroring the captive
|
||||
onboarding flow. */}
|
||||
<div className="sticky top-0 z-10 h-1 bg-muted">
|
||||
<div
|
||||
className="h-full bg-primary transition-all duration-500 ease-out"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Top-right close. Lets users escape if they truly don't want to
|
||||
continue — deliberately unobtrusive so casual taps don't drop
|
||||
them out of the flow. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label={t('common.goBack')}
|
||||
className="absolute right-4 top-4 z-20 sm:right-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"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</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 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => 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"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5 rtl:rotate-180" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<form
|
||||
className="flex-1 flex items-start sm:items-center justify-center px-6 pt-16 pb-12"
|
||||
onSubmit={onSubmit}
|
||||
// Hitting Enter inside an <input> normally triggers the
|
||||
// form's default submit — and on a non-terminal wizard step
|
||||
// that would silently publish the entity. Intercept Enter on
|
||||
// non-terminal steps and treat it as "advance" instead, so
|
||||
// keyboard users get the same flow as clicking Next.
|
||||
//
|
||||
// Textarea Enter is left alone — that's a legitimate newline
|
||||
// character inside the field.
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== 'Enter') return;
|
||||
if (isTerminal) return;
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.tagName === 'TEXTAREA') return;
|
||||
// IME composition still in progress — don't hijack.
|
||||
if (e.nativeEvent.isComposing) return;
|
||||
e.preventDefault();
|
||||
if (submitting || isAdvancing || !canAdvance) return;
|
||||
void handleAdvance();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
key={step}
|
||||
className="w-full max-w-md mx-auto space-y-6 animate-in fade-in slide-in-from-bottom-2 duration-300"
|
||||
>
|
||||
{/* Centered title block — captive-onboarding cadence: large
|
||||
heading + muted subtitle, no progress eyebrow (the
|
||||
top-of-page bar carries that signal). */}
|
||||
<div className="space-y-2 text-center">
|
||||
<h2 className="text-2xl font-bold tracking-tight">{current.title}</h2>
|
||||
{current.subtitle && (
|
||||
<p className="text-sm text-muted-foreground">{current.subtitle}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Step body. Step 1's optional lead (e.g. the campaign
|
||||
wizard's org chip) rides along here so the
|
||||
"publishing-as" context is the first thing the user
|
||||
sees. No card chrome — onboarding keeps the content
|
||||
area visually quiet so the focus stays on the fields. */}
|
||||
<div className="space-y-3">
|
||||
{step === 1 && step1Lead}
|
||||
{current.body}
|
||||
</div>
|
||||
|
||||
{errorAlert}
|
||||
|
||||
{/* Footer.
|
||||
- Non-terminal steps: primary "Next" advances the wizard.
|
||||
When `launchNowLabel` is provided and the user has
|
||||
cleared `launchAvailableFromStep`, a ghost shortcut sits
|
||||
beneath Next so the remaining steps are opt-in.
|
||||
- Terminal step: the primary submit button is the only
|
||||
forward action.
|
||||
- Back navigation lives in the top-left header chrome,
|
||||
not here. */}
|
||||
<div className="space-y-3 pt-1">
|
||||
{isTerminal ? (
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!canSubmit}
|
||||
className="w-full h-12 text-base rounded-full"
|
||||
>
|
||||
{submitButtonContent}
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void handleAdvance()}
|
||||
disabled={submitting || isAdvancing || !canAdvance}
|
||||
className="w-full h-12 text-base rounded-full"
|
||||
>
|
||||
{t('common.next')}
|
||||
</Button>
|
||||
{launchVisible && (
|
||||
<Button
|
||||
type="submit"
|
||||
variant="ghost"
|
||||
disabled={!canSubmit}
|
||||
className="w-full"
|
||||
>
|
||||
{submitting ? submitButtonContent : launchNowLabel}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||