diff --git a/NIP.md b/NIP.md
index 300653f1..eac85ae7 100644
--- a/NIP.md
+++ b/NIP.md
@@ -12,7 +12,7 @@
| Kind | Name | Description |
|-------|----------------------------|----------------------------------------------------------------|
-| 30223 | Campaign | Fundraising campaign with a list of on-chain Bitcoin recipients |
+| 33863 | Campaign | Self-authored fundraising campaign with a single Bitcoin wallet endpoint (`bc1...` or `sp1...`) |
| 30385 | Community Stats Snapshot | Pre-computed per-country / global community leaderboards |
| 36639 | Pledge | Donor pledge for concrete submissions, stored as sats |
@@ -22,7 +22,54 @@
|--------------------------|-----------------------------------------|-----------------------------------------------------------------|
| 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 | 30223, 1985, 39089 | Homepage curation (approved / hidden / featured axes) via moderator-signed labels in the `agora.moderation` namespace, gated by a follow-pack moderator roster |
+| Campaign Moderation | 33863, 1985, 39089 | Homepage curation (approved / hidden / featured axes) via moderator-signed labels in the `agora.moderation` namespace, gated by a follow-pack moderator roster |
+
+### Agora Content Marker
+
+Every event Agora publishes that represents a first-class Agora object carries the single-letter tag `["t", "agora"]`. This marker enables the Agora activity feed to filter strictly server-side via the relay-indexed `#t` filter (multi-letter tags like the NIP-89 `client` tag are not indexed by relays and are therefore unsuitable for this purpose).
+
+#### Tagged kinds
+
+| Kind | Object | Where tagged |
+|-------|---------------------|---------------------------------------------------------------|
+| 1 | Note (top-level, reply, quote) | `ComposeBox` default for top-level kind 1 publishes |
+| 1111 | NIP-22 comment | `usePostComment` (all comments authored in Agora) |
+| 8333 | Onchain zap | `useOnchainZap`, `useDonateCampaign`, `SendBitcoinDialog` |
+| 9041 | Zap goal | `CreateGoalDialog` |
+| 33863 | Campaign | `CreateCampaignPage` |
+| 31922 | Date calendar event | `CreateEventPage`, `CreateCommunityEventDialog` |
+| 31923 | Time calendar event | `CreateEventPage`, `CreateCommunityEventDialog` |
+| 34550 | Community | `CreateCommunityPage` |
+| 36639 | Pledge | `CreateActionPage` |
+
+The tag is added at publish time via the `withAgoraTag` helper in `src/lib/agoraNoteTags.ts`, which dedupes against any user-supplied `t:agora` tag.
+
+#### Untagged kinds (intentional)
+
+Reactions, reposts, follow lists, profile metadata, lists, settings, badges, vanish requests, encrypted DMs, and live chat are user-state or response events rather than first-class Agora content. Tagging them would pollute `#agora` hashtag surfaces without adding value to the activity feed.
+
+Untagged on purpose: 0, 3, 6, 7, 8, 16, 62, 1311, 30009, 10000-series, 30078, and any NIP-04 / NIP-44 encrypted kind.
+
+#### Querying
+
+The Agora activity feed combines a `t:agora`-strict layer with an intentionally cross-client world layer:
+
+```json
+[
+ { "kinds": [33863, 36639, 34550, 8333], "#t": ["agora", "Agora"] },
+ { "kinds": [1111], "#t": ["agora", "Agora"], "#K": ["33863", "36639", "34550"] },
+ { "kinds": [1111, 1068], "#k": ["iso3166", "geo"] },
+ { "kinds": [1], "#t": ["agora", "Agora"] }
+]
+```
+
+The first two filters surface only Agora-created content. The third surfaces all country/geo-rooted comments and polls regardless of origin — the world layer is intentionally cross-client. The fourth captures any kind 1 note carrying `#agora` (including hashtags users type themselves), which preserves viral / opt-in discovery.
+
+Clients filter both case variants (`agora` and `Agora`) because Nostr `t` tags are conventionally lowercase but some clients normalize hashtags to title case.
+
+#### 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).
### Community Chat
@@ -89,26 +136,45 @@ Single-recipient zap (the common case — tipping a post or profile):
}
```
-Multi-recipient zap (one transaction paying multiple recipients — campaign donations, community splits):
+Multi-recipient zap (one transaction paying multiple recipients — community splits):
```json
{
"kind": 8333,
"pubkey": "",
- "content": "Great campaign!",
+ "content": "Great community!",
"tags": [
["i", "bitcoin:tx:"],
["p", ""],
["p", ""],
["p", ""],
["amount", ""],
- ["a", "30223::"],
- ["K", "30223"],
+ ["a", "34550::"],
+ ["K", "34550"],
["alt", "Donation: 75000 sats across 3 recipients"]
]
}
```
+Campaign donation (one transaction paying a single campaign wallet — see Kind 33863 below):
+
+```json
+{
+ "kind": 8333,
+ "pubkey": "",
+ "content": "Keep up the good work.",
+ "tags": [
+ ["i", "bitcoin:tx:"],
+ ["amount", ""],
+ ["a", "33863::"],
+ ["K", "33863"],
+ ["alt", "Donation to Save the Last Bookstore: 25000 sats"]
+ ]
+}
+```
+
+Campaign donation receipts MUST NOT include `p` tags — campaigns no longer have Nostr-identity recipients, only a `w` wallet endpoint. Verification matches tx outputs against the campaign's declared `w` address rather than derived Taproot addresses (see *Verification* and Kind 33863 below).
+
### Content
The `content` field is a human-readable comment from the sender (may be empty). It is NOT a zap request JSON (unlike NIP-57 kind 9735).
@@ -165,13 +231,25 @@ For addressable events, use `"#a": ["::"]` instead. For pro
**Verification (REQUIRED before trusting amounts):**
-Clients MUST verify a kind 8333 event on-chain before counting it toward a zap total or displaying its amount. The `amount` tag is self-reported by the sender and would otherwise be trivially spoofable. To verify:
+Clients MUST verify a kind 8333 event on-chain before counting it toward a zap total or displaying its amount. The `amount` tag is self-reported by the sender and would otherwise be trivially spoofable. Verification has two modes depending on the event shape:
+
+*Identity-recipient mode* (the event has `p` tags — profile zaps, event zaps, community splits):
1. Extract the txid from the `i` tag.
2. Fetch the transaction from a Bitcoin data source (e.g. a mempool.space-compatible Esplora API).
3. For each `p` tag, derive the recipient's expected Taproot address.
4. Sum the values of all outputs in the transaction that pay any of the derived recipient addresses. This is the **verified amount**. Change outputs paying back to the **sender's** derived Taproot address MUST NOT be counted toward the verified amount — only outputs to listed recipients.
-5. If the verified amount is 0 (no listed recipient received anything in the tx), the event SHOULD be discarded.
+
+*Campaign-wallet mode* (the event has an `a` tag pointing at a kind 33863 campaign and no `p` tags):
+
+1. Extract the txid from the `i` tag and the campaign coordinate from the `a` tag.
+2. Fetch the campaign event and read its `w` tag to get the campaign's declared bech32(m) wallet address. Reject the receipt if `w` is missing, malformed, or starts with `sp1` (silent-payment campaigns do not publish receipts; see Kind 33863).
+3. Fetch the transaction from a Bitcoin data source.
+4. Sum the values of all outputs in the transaction that pay the campaign's `w` address. This is the **verified amount**.
+
+In both modes:
+
+5. If the verified amount is 0, the event SHOULD be discarded.
6. If the sender's `amount` tag exceeds the verified amount, clients MAY discard the event or MAY display the smaller verified amount (capping). Clients MUST NOT display or count the claimed amount when it exceeds the verified amount.
7. Unconfirmed transactions MAY be displayed as pending; clients MAY require confirmation before counting them toward public totals. Because unconfirmed transactions can be evicted (RBF, double-spend), clients SHOULD either exclude them from aggregate zap totals or clearly label them as pending.
@@ -199,139 +277,202 @@ The two zap kinds are complementary. Clients SHOULD sum verified amounts from bo
---
-## Kind 30223: Campaign
+## Kind 33863: Campaign
### Summary
-Addressable event representing a **fundraising campaign**. A campaign carries the marketing-style metadata you would expect on GoFundMe, Kickstarter, or GiveSendGo (title, summary, cover image, story, category, goal, optional deadline, and recommended country), and — most importantly — a list of recipient pubkeys (`p` tags) that share the proceeds of any donation.
+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 exactly one Bitcoin wallet endpoint declared in a `w` tag. The wallet endpoint is either a public on-chain bech32(m) address (`bc1q…`, `bc1p…`) or a silent-payment code (`sp1…`, per BIP-352). The mode is inferred from the prefix — the client renders the corresponding QR code and adjusts the donation-progress UI accordingly.
-Donations are sent as a **single Bitcoin on-chain transaction** with one output per recipient. The donor's wallet derives each recipient's Taproot address from their pubkey via BIP-340/BIP-341 (the same scheme used by kind 8333 onchain zaps), so the campaign event itself does not need to carry Bitcoin addresses. After broadcasting the funding tx, the donor's client publishes one kind 8333 event referencing the `txid`, listing every campaign recipient under its own `p` tag, and tagging the campaign via `a` / `K`. The donation then shows up in the campaign's totals and in each recipient's profile zap history (the `#p` filter matches every listed recipient).
+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, image, goal, deadline, and recipient list 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, deadline, and wallet over the life of the campaign without minting new identifiers. The `d` tag is the campaign's slug.
### Event Structure
```json
{
- "kind": 30223,
+ "kind": 33863,
"pubkey": "",
"content": "",
"tags": [
- ["d", "save-the-bookstore"],
+ ["d", "save-the-last-bookstore"],
+
["title", "Save the Last Bookstore"],
["summary", "Help our 40-year-old neighborhood bookstore make rent through winter."],
- ["image", "https://example.com/cover.jpg"],
- ["t", "human-rights"],
- ["t", "legal-defense"],
- ["goal", "10000000"],
+ ["banner", "https://blossom.example/abc123.jpg"],
+ ["imeta",
+ "url https://blossom.example/abc123.jpg",
+ "m image/jpeg",
+ "x abc123def456...",
+ "dim 1600x900",
+ "blurhash LKO2?U%2Tw=w]~RBVZRi};RPxuwH",
+ "alt Storefront of the Last Bookstore at dusk"
+ ],
+ ["alt", "Fundraising campaign: Save the Last Bookstore"],
+
+ ["w", "bc1p7w2k3xq9...xyz"],
+
+ ["goal", "25000"],
["deadline", "1735689600"],
- ["i", "iso3166:VE"],
- ["k", "iso3166"],
- ["p", "", "wss://relay.example", "2"],
- ["p", "", "wss://relay.example", "1"],
- ["p", ""],
- ["alt", "Fundraising campaign: Save the Last Bookstore"]
+
+ ["i", "iso3166-1:US"],
+ ["k", "iso3166-1"]
]
}
```
+A silent-payment campaign is identical except the `w` tag carries an `sp1…` code:
+
+```json
+["w", "sp1qq...verylongsilentpaymentcode..."]
+```
+
### Content
The `content` field is the **campaign story**, formatted as Markdown. Clients SHOULD render it with the same Markdown renderer they use for NIP-23 long-form content. Empty content is permitted (e.g. for a campaign that lives entirely in its summary).
### Tags
-| Tag | Required | Description |
-|------------|----------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------|
-| `d` | Yes | Campaign slug, unique per author. Forms the addressable coordinate `30223::`. |
-| `title` | Yes | Display title of the campaign (plain text, max ~200 chars). |
-| `summary` | Recommended | Short one-paragraph tagline shown in feed cards and previews. |
-| `image` | Recommended | HTTPS URL of the cover image (jpg/png/webp). Clients MUST sanitize and verify the URL before rendering. |
-| `t` | Recommended | Topic tag for discovery and filtering (e.g. `human-rights`, `legal-defense`, `independent-media`). Multiple `t` tags MAY be used. Clients SHOULD normalize user-entered tag labels by removing a leading `#`, lowercasing, and replacing whitespace with hyphens. |
-| `goal` | Recommended | Fundraising goal in **satoshis** (decimal integer). Omit if the campaign has no fixed goal. |
-| `deadline` | Optional | Unix timestamp (seconds) at which the campaign closes. After the deadline, clients SHOULD show the campaign as ended but MAY still accept donations. |
-| `i` | Recommended | NIP-73 country identifier for sorting and discovery. SHOULD be `iso3166:` 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`. |
-| `location` | Legacy | Human-readable location string used by older campaign events. New events SHOULD prefer `i` + `k` country tags. Clients MAY display this as a fallback only. |
-| `status` | Optional | Lifecycle status. The only defined value is `archived`, which marks the campaign closed without deleting it. Other values SHOULD be ignored. See *Closing & archiving* below. |
-| `p` | Yes (≥1) | Recipient pubkey. The 2nd element is the hex pubkey; the 3rd (optional) is a relay hint; the 4th (optional) is a positive decimal **weight** for split allocation. |
-| `alt` | Recommended | NIP-31 human-readable fallback. |
+| Tag | Required | Description |
+|-----------|----------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| `d` | Yes | Campaign slug, unique per author. Forms the addressable coordinate `33863::`. |
+| `title` | Yes | Display title of the campaign (plain text, max ~200 chars). |
+| `w` | Yes | Bitcoin wallet endpoint. The 2nd element is a single bech32(m) string: a mainnet on-chain address starting with `bc1q` (P2WPKH/P2WSH) or `bc1p` (P2TR), **or** a silent-payment code starting with `sp1` per BIP-352. Exactly one `w` tag per campaign. |
+| `summary` | Recommended | Short one-paragraph tagline shown in feed cards and previews. |
+| `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 ` 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-1:` with an uppercase ISO 3166-1 alpha-2 country code (e.g. `iso3166-1:VE`). |
+| `k` | Recommended if `i` is present | NIP-73 external content kind. For country identifiers this SHOULD be `iso3166-1`. |
+| `alt` | Recommended | NIP-31 human-readable fallback. |
-### Recipient Split Rules
+### Wallet Modes
-When a donor sends an amount `T` in satoshis to a campaign:
+The prefix of the `w` value selects one of two donation modes. Clients MUST detect the mode from the prefix; the event carries no other mode discriminator.
-1. Read all `p` tags from the campaign event.
-2. Parse the weight of each `p` tag from the 4th element. If absent, malformed, or non-positive, the weight defaults to **1**.
-3. Compute each recipient's share as `floor(T * weight_i / sum_of_weights)` satoshis.
-4. Any remainder from rounding (at most N−1 sats) MAY be appended to the largest share or kept by the donor as change — clients SHOULD prefer appending the remainder to the largest share so the full amount reaches the campaign.
-5. If any computed share is below the Bitcoin dust limit (546 sats for P2TR), the donor's client MUST refuse the donation and surface a minimum-amount error.
+| Prefix | Mode | Description |
+|---------------------|-----------|------------------------------------------------------------------------------------------------------------------------------------------|
+| `bc1q…` / `bc1p…` | On-chain | Public mainnet bech32(m) address. Donations are traceable; clients show a progress bar, total raised, and donation list. |
+| `sp1…` | Silent payment | BIP-352 silent-payment code. Donations are **unlinkable by design**. Clients MUST hide all aggregate totals and progress UI (see below). |
-Equal splits are the default: omit the weight on every `p` tag, and all recipients receive `floor(T / N)` sats each.
+Other prefixes (`tb1…`, `bcrt1…`, `tsp1…`, lightning invoices, etc.) MUST be rejected at parse time; the campaign does not render.
-### Donation Flow
+Clients SHOULD validate the bech32(m) checksum of the `w` value, not just its prefix.
-1. Donor opens the campaign and chooses an amount in sats (preset or custom).
-2. Donor's client computes per-recipient amounts using the split rules above.
-3. Donor's client builds a **single PSBT** with one output per recipient (paying each recipient's derived Taproot address) plus a change output back to the donor.
-4. Donor signs the PSBT with their Nostr key (Taproot key-path spend) and broadcasts the resulting transaction.
-5. Donor's client publishes **one kind 8333 event for the whole transaction**, listing every recipient under its own `p` tag. The event MUST include:
+### Client Behavior by Mode
+
+| UI element | On-chain (`bc1`) | Silent payment (`sp1`) |
+|-----------------------------|-----------------------------------------------------------------|-------------------------------------------------------|
+| QR code | bech32(m) address QR (or BIP-21 `bitcoin:` URI) | SP code QR (BIP-352 / BIP-21 SP extension) |
+| "Raised X" / progress bar | Shown, computed from verified kind 8333 receipts | **Hidden.** Replaced with a "Private campaign — totals are not public" notice. |
+| Donor / recent-donation list| Shown | **Hidden.** |
+| Goal display | Shown as USD target with optional sat-equivalent estimate | Shown as USD target; no progress computation |
+| Donation receipt published | Donor's client publishes a kind 8333 receipt (see below) | **No receipt published.** Publishing one would defeat SP unlinkability and is forbidden. |
+
+For silent-payment campaigns, clients MUST NOT attempt to scan the chain, MUST NOT publish receipts, and MUST NOT display any aggregate that could leak donation activity. The only signal the public sees is the campaign event itself.
+
+### Donation Flow — On-chain (`bc1`)
+
+1. Donor opens the campaign and chooses an amount.
+2. Donor's client constructs and broadcasts a Bitcoin transaction paying the campaign's `w` address.
+3. After broadcast, the donor's client publishes a single kind 8333 receipt:
```json
[
["i", "bitcoin:tx:"],
- ["p", ""],
- ["p", ""],
- ["p", ""],
- ["amount", ""],
- ["a", "30223::"],
- ["K", "30223"],
+ ["amount", ""],
+ ["a", "33863::"],
+ ["K", "33863"],
["alt", "Donation to : sats"]
]
```
- The `amount` tag is the sum of the outputs paying the listed recipients (i.e. the full donation, excluding the donor's change). Per-recipient amounts are not encoded in the event; clients that need them recompute them from the on-chain transaction by matching each recipient's derived Taproot address against the tx outputs.
+ The receipt MUST NOT carry `p` tags — campaigns are not Nostr-identity recipients. The `amount` tag is the sum of tx outputs paying the campaign's `w` address (excluding the donor's change output).
-This mirrors the community batch-zap pattern documented in the kind 8333 section above, with the campaign's addressable coordinate replacing the community coordinate.
+4. The receipt is published **after** the tx is broadcast; the txid is already final at that point. A receipt-publish failure does not roll back the donation — the on-chain transaction stands.
+
+### Donation Flow — Silent Payment (`sp1`)
+
+1. Donor opens the campaign and chooses an amount.
+2. Donor's client uses the campaign's SP code to derive a fresh, one-time Taproot output script per BIP-352.
+3. Donor broadcasts a Bitcoin transaction paying that derived output.
+4. **No Nostr event is published.** The campaign owner discovers the donation by scanning the chain locally with their SP private key.
+
+Silent-payment unlinkability is the entire point of this mode. Clients MUST NOT publish receipts, MUST NOT advertise the donation in any other Nostr event (replies, mentions, etc.) on the donor's behalf, and MUST NOT correlate the donor's pubkey with the campaign in any persisted client telemetry.
### Querying
**List campaigns (newest first):**
```json
-{ "kinds": [30223], "limit": 50 }
-```
-
-**Filter by category:**
-
-```json
-{ "kinds": [30223], "#t": ["medical"], "limit": 50 }
+{ "kinds": [33863], "limit": 50 }
```
**Fetch a specific campaign:**
```json
-{ "kinds": [30223], "authors": [""], "#d": [""], "limit": 1 }
+{ "kinds": [33863], "authors": [""], "#d": [""], "limit": 1 }
```
-**Aggregate donations for a campaign:**
+**Aggregate donations for an on-chain campaign:**
```json
-{ "kinds": [8333], "#a": ["30223::"], "limit": 500 }
+{ "kinds": [8333], "#a": ["33863::"], "limit": 500 }
```
-Clients MUST verify each kind 8333 event on-chain before counting it toward the campaign total, per the verification rules in the kind 8333 section.
+Clients MUST verify each kind 8333 event on-chain before counting it toward the campaign total, per the *Campaign-wallet mode* verification rules in the kind 8333 section.
+
+**Filter by country:**
+
+```json
+{ "kinds": [33863], "#i": ["iso3166-1:VE"], "limit": 50 }
+```
+
+**Fetch pinned event comments:**
+
+Event owners MAY pin important comments or activity feed events with a NIP-78 app-specific data event (`kind: 30078`) authored by the root event owner. The `d` tag is scoped to the root event coordinate. Agora uses this for campaigns (`33863`), pledges (`36639`), organizations (`34550`), and calendar events (`31922` / `31923`).
+
+```json
+{
+ "kind": 30078,
+ "pubkey": "",
+ "content": "{\"pinnedEvents\":[\"\",\"\"]}",
+ "tags": [
+ ["d", "agora-pinned-comments:::"],
+ ["a", "::"],
+ ["k", ""],
+ ["alt", "Pinned event comments"]
+ ]
+}
+```
+
+Clients SHOULD query the pin list with:
+
+```json
+{ "kinds": [30078], "authors": [""], "#d": ["agora-pinned-comments:::"], "limit": 1 }
+```
+
+The `pinnedEvents` array is ordered newest pin first. Pinning an already-pinned event removes it. Clients SHOULD ignore pin lists not authored by the root event owner.
### Client Behavior
-- **Recipient validity:** clients SHOULD reject `p` tag entries whose pubkey is not 64 hex characters and SHOULD ignore weights that are not positive finite decimals.
-- **Dust protection:** when a donor enters an amount that would assign any recipient less than the dust limit, the client MUST block the donation and either suggest the minimum viable total or prompt the donor to remove recipients.
-- **Editability:** the creator MAY republish the same `(kind, pubkey, d)` triple to update the campaign. Clients SHOULD keep `published_at` from the first publish on subsequent edits (NIP-23 convention).
-- **Closing & archiving:** the creator MAY soft-close a campaign by republishing it with a `["status", "archived"]` tag. Clients SHOULD hide archived campaigns from discovery feeds and disable the donate flow, but MUST keep them reachable by direct link so existing donors can still find them and donation history is preserved. The creator can reopen the campaign by republishing without the status tag (or with any other status value). For a hard delete, the creator MAY publish a NIP-09 kind 5 deletion request referencing the campaign's `a` coordinate; clients SHOULD continue to render past donations against the campaign even after deletion.
+- **Wallet validity:** clients MUST reject events whose `w` tag is missing, present more than once, or whose value does not pass bech32(m) checksum validation for one of the supported prefixes. Invalid campaigns do not render.
+- **Editability:** the creator MAY republish the same `(33863, pubkey, d)` triple to update any field, including the `w` wallet endpoint. Clients SHOULD keep `published_at` from the first publish on subsequent edits (NIP-23 convention).
+- **Closing a campaign:** there is no `status` tag. To stop accepting donations, the creator publishes a NIP-09 kind 5 deletion request referencing the campaign's `a` coordinate. Clients SHOULD honor the deletion by removing the campaign from discovery feeds. Historical kind 8333 receipts MAY still be rendered against the (now-deleted) campaign coordinate so donors can find their past donations.
+- **No category, no topics:** kind 33863 events MUST NOT carry `t` tags or NIP-32 category labels in any `agora.*` namespace. Campaigns are individual stories; discovery happens via search (NIP-50 against title/summary/content), country (`#i`), and moderator curation (below).
+- **Migration:** kind 33863 has no relationship to any earlier campaign kind. Clients MUST NOT read, merge, or migrate events of any other kind into the kind 33863 namespace.
-### Campaign Moderation Labels
+### Agora Moderation Labels
-Agora curates which kind 30223 campaigns appear on the homepage (`/`) and on Discover (`/discover`) via moderator-signed NIP-32 label events (kind 1985) in a dedicated label namespace. The campaign 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/all`), and which kind 34550 organizations appear in the Featured shelf on `/communities`, 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 and organizations share a single label namespace and a single moderator pack (Team Soapbox); the only thing distinguishing the two streams is the kind prefix on the `a` tag of each label:
+
+- `33863::` — campaign (kind 33863, see "Open Campaigns" above).
+- `34550::` — organization (kind 34550, NIP-72 community definition).
+
+A client surfacing campaigns MUST filter folded labels to those whose `a` tag starts with `33863:`. A client surfacing organizations MUST filter to `34550:`. Mixing the two streams would let a moderator's `featured` label on a campaign appear to feature an unrelated organization with the same `d` tag, or vice versa.
#### Namespace
@@ -346,22 +487,32 @@ Each label event carries the namespace twice, per NIP-32:
#### Label values
-Three independent axes; the newest moderator-signed label per axis per campaign wins.
+Three independent axes are defined; the newest moderator-signed label per axis per coordinate wins. **Campaigns** use all three axes (`approval`, `hide`, `featured`). **Organizations** use only two — `hide` and `featured` — because every Agora-tagged organization is publicly visible by default; there is no approval gate for orgs. Moderators MUST NOT publish `approved` or `unapproved` labels against kind 34550 coordinates, and clients MUST ignore any such labels they receive.
-| Axis | Values | Meaning |
-|----------|---------------------------|-------------------------------------------------------------------------|
-| approval | `approved`, `unapproved` | `approved` allows the campaign on `/` and Discover. `unapproved` retracts a previous approval. |
-| hide | `hidden`, `unhidden` | `hidden` suppresses the campaign everywhere it would otherwise appear. `unhidden` retracts a previous hide. |
-| featured | `featured`, `unfeatured` | `featured` places the campaign in the hand-picked Featured row on `/`. `unfeatured` retracts. |
+| 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` | both | `hidden` suppresses the campaign/organization everywhere it would otherwise appear. `unhidden` retracts a previous hide. |
+| featured | `featured`, `unfeatured` | both | `featured` places the campaign in the hand-picked Featured row on `/`, or the organization in the Featured shelf on `/communities`. `unfeatured` retracts. |
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.
+**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.
+- **"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.
+- **Hide enforcement on other organization discovery surfaces** — clients SHOULD suppress `hidden` organizations from any future "All organizations" / browse surface for non-moderators. Moderators MAY see hidden organizations with a "Hidden" treatment so they can unhide.
+
#### Event Structure
```json
@@ -371,20 +522,33 @@ Surfacing rules (hide always wins):
"tags": [
["L", "agora.moderation"],
["l", "approved", "agora.moderation"],
- ["a", "30223::"],
+ ["a", "33863::"],
["alt", "Campaign moderation: approved"]
]
}
```
-A `featured` label has the same shape with `["l", "featured", "agora.moderation"]` and `["alt", "Campaign moderation: featured"]`.
+An organization label has the same shape with a kind 34550 `a` tag:
+
+```json
+{
+ "kind": 1985,
+ "content": "",
+ "tags": [
+ ["L", "agora.moderation"],
+ ["l", "featured", "agora.moderation"],
+ ["a", "34550::"],
+ ["alt", "Organization moderation: featured"]
+ ]
+}
+```
Required tags:
- `L` set to `agora.moderation`.
- `l` with the label value as the 2nd element and `agora.moderation` as the 3rd.
-- `a` referencing the campaign coordinate `30223::`.
-- `alt` (NIP-31) — clients without label support will display this string.
+- `a` referencing the target coordinate (`33863::` for a campaign, `34550::` for an organization).
+- `alt` (NIP-31) — clients without label support will display this string. The `alt` value SHOULD identify the surface (e.g. `Campaign moderation: featured` or `Organization moderation: featured`) so non-Agora clients can read it.
#### Trust Model
@@ -396,12 +560,14 @@ pubkey: 932614571afcbad4d17a191ee281e39eebbb41b93fac8fd87829622aeb112f4d
d-tag: k4p5w0n22suf
```
-The pack `p` tags are the authoritative moderator list. Anyone may publish a kind 1985 event in the `agora.moderation` namespace, but events from non-pack authors are silently ignored at the relay-filter layer (`authors:` is pinned to the pack `p` tags). This means:
+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 kept alive only by their labels return to "pending" until another moderator approves them.
+- 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.
- 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.
+
#### Querying
Step 1 — fetch the pack:
@@ -426,13 +592,14 @@ Step 2 — fetch label events from pack members in the namespace:
}
```
-Step 3 — fold by `(campaign-coord, axis)`, latest-`created_at`-wins. Then fetch only the approved-and-not-hidden campaign coordinates with one filter per author (bundled in a single REQ).
+Step 3 — fold by `(coord, axis)`, latest-`created_at`-wins, filtering to the relevant kind prefix (`33863:` for campaigns or `34550:` for organizations). Then fetch the targeted events themselves — one filter per author (bundled in a single REQ) keyed by their d-tags.
#### Client Behavior
-- Clients SHOULD render approve/hide controls only for users whose pubkey appears in the pack.
-- Clients MAY display "Hidden" badges on hidden campaigns when viewed by a moderator, and SHOULD NOT render them at all to non-moderators.
+- 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.
---
diff --git a/index.html b/index.html
index 33e4eb03..e6b45bec 100644
--- a/index.html
+++ b/index.html
@@ -9,7 +9,7 @@
-
+
@@ -21,7 +21,7 @@
-
+
diff --git a/package-lock.json b/package-lock.json
index 84acbef3..a2801d22 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,7 +8,6 @@
"name": "agora",
"version": "2.8.0",
"dependencies": {
- "@bitcoinerlab/secp256k1": "^1.2.0",
"@breeztech/breez-sdk-spark": "^0.10.0",
"@capacitor/app": "^8.0.0",
"@capacitor/barcode-scanner": "^3.0.2",
@@ -35,6 +34,7 @@
"@fontsource-variable/nunito": "^5.2.7",
"@fontsource-variable/outfit": "^5.2.8",
"@fontsource-variable/playfair-display": "^5.2.8",
+ "@fontsource/bebas-neue": "^5.2.7",
"@fontsource/bungee-shade": "^5.2.7",
"@fontsource/caveat": "^5.2.8",
"@fontsource/cherry-bomb-one": "^5.2.7",
@@ -96,15 +96,16 @@
"@radix-ui/react-toggle": "^1.1.0",
"@radix-ui/react-toggle-group": "^1.1.0",
"@radix-ui/react-tooltip": "^1.2.8",
+ "@scure/base": "^1.1.1",
+ "@scure/bip32": "^2.2.0",
"@scure/bip39": "^1.6.0",
+ "@scure/btc-signer": "^2.2.0",
"@sentry/react": "^10.42.0",
"@tanstack/react-query": "^5.56.2",
"@types/d3-geo": "^3.1.0",
"@unhead/addons": "^2.1.13",
"@unhead/react": "^2.1.13",
- "bitcoinjs-lib": "^7.0.1",
"blurhash": "^2.0.5",
- "buffer": "^6.0.3",
"capacitor-secure-storage-plugin": "^0.13.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -114,7 +115,6 @@
"d3-scale": "^4.0.2",
"date-fns": "^3.6.0",
"dompurify": "^3.3.3",
- "ecpair": "^3.0.1",
"embla-carousel-react": "^8.3.0",
"emoji-mart": "^5.6.0",
"fflate": "^0.8.2",
@@ -360,30 +360,6 @@
"node": ">=6.9.0"
}
},
- "node_modules/@bitcoinerlab/secp256k1": {
- "version": "1.2.0",
- "resolved": "https://registry.npmjs.org/@bitcoinerlab/secp256k1/-/secp256k1-1.2.0.tgz",
- "integrity": "sha512-jeujZSzb3JOZfmJYI0ph1PVpCRV5oaexCgy+RvCXV8XlY+XFB/2n3WOcvBsKLsOw78KYgnQrQWb2HrKE4be88Q==",
- "license": "MIT",
- "dependencies": {
- "@noble/curves": "^1.7.0"
- }
- },
- "node_modules/@bitcoinerlab/secp256k1/node_modules/@noble/curves": {
- "version": "1.9.7",
- "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz",
- "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==",
- "license": "MIT",
- "dependencies": {
- "@noble/hashes": "1.8.0"
- },
- "engines": {
- "node": "^14.21.3 || >=16"
- },
- "funding": {
- "url": "https://paulmillr.com/funding/"
- }
- },
"node_modules/@breeztech/breez-sdk-spark": {
"version": "0.10.0",
"resolved": "https://registry.npmjs.org/@breeztech/breez-sdk-spark/-/breez-sdk-spark-0.10.0.tgz",
@@ -1486,6 +1462,15 @@
"url": "https://github.com/sponsors/ayuhito"
}
},
+ "node_modules/@fontsource/bebas-neue": {
+ "version": "5.2.7",
+ "resolved": "https://registry.npmjs.org/@fontsource/bebas-neue/-/bebas-neue-5.2.7.tgz",
+ "integrity": "sha512-DsmBrmq55d9BCU0mt4DT4RZDdH8vhWRKEUOfbuNB1EEjMuwbtFvM8N+3gIlkYSFbsb10P8Q19BV5OdpMu2h0fA==",
+ "license": "OFL-1.1",
+ "funding": {
+ "url": "https://github.com/sponsors/ayuhito"
+ }
+ },
"node_modules/@fontsource/bungee-shade": {
"version": "5.2.7",
"resolved": "https://registry.npmjs.org/@fontsource/bungee-shade/-/bungee-shade-5.2.7.tgz",
@@ -6213,55 +6198,40 @@
"license": "MIT"
},
"node_modules/@scure/bip32": {
- "version": "1.3.1",
- "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.3.1.tgz",
- "integrity": "sha512-osvveYtyzdEVbt3OfwwXFr4P2iVBL5u1Q3q4ONBfDY/UpOuXmOlbgwc1xECEboY8wIays8Yt6onaWMUdUbfl0A==",
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-2.2.0.tgz",
+ "integrity": "sha512-zFr7t2F+a9+5tB7QbarF2HQNYrgjCNaoLAupZdKkrFMYMozJf5zqH2WJCQibMzm1qQ0QogrxVGO3qXfQDYMaQg==",
"license": "MIT",
"dependencies": {
- "@noble/curves": "~1.1.0",
- "@noble/hashes": "~1.3.1",
- "@scure/base": "~1.1.0"
- },
- "funding": {
- "url": "https://paulmillr.com/funding/"
- }
- },
- "node_modules/@scure/bip32/node_modules/@noble/curves": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.1.0.tgz",
- "integrity": "sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA==",
- "license": "MIT",
- "dependencies": {
- "@noble/hashes": "1.3.1"
- },
- "funding": {
- "url": "https://paulmillr.com/funding/"
- }
- },
- "node_modules/@scure/bip32/node_modules/@noble/curves/node_modules/@noble/hashes": {
- "version": "1.3.1",
- "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.1.tgz",
- "integrity": "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA==",
- "license": "MIT",
- "engines": {
- "node": ">= 16"
+ "@noble/curves": "2.2.0",
+ "@noble/hashes": "2.2.0",
+ "@scure/base": "2.2.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/bip32/node_modules/@noble/hashes": {
- "version": "1.3.3",
- "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.3.tgz",
- "integrity": "sha512-V7/fPHgl+jsVPXqqeOzT8egNj2iBIVt+ECeMMG8TdcnTikP3oaBtUVqpT/gYCR68aEBJSF+XbYUxStjbFMqIIA==",
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
+ "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==",
"license": "MIT",
"engines": {
- "node": ">= 16"
+ "node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
+ "node_modules/@scure/bip32/node_modules/@scure/base": {
+ "version": "2.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/@scure/bip39": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.6.0.tgz",
@@ -6284,6 +6254,42 @@
"url": "https://paulmillr.com/funding/"
}
},
+ "node_modules/@scure/btc-signer": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@scure/btc-signer/-/btc-signer-2.2.0.tgz",
+ "integrity": "sha512-ZXZ08sZqSZKEcOuEQnxTF66ouHtl6+UA6U/QfQM06K9WiOlEkXF4LviZCaSgkdiFh9cyMt9+xdup7JtEv3p0fw==",
+ "license": "MIT",
+ "dependencies": {
+ "@noble/curves": "~2.2.0",
+ "@noble/hashes": "~2.2.0",
+ "@scure/base": "~2.2.0",
+ "micro-packed": "~0.9.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/@scure/btc-signer/node_modules/@noble/hashes": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
+ "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 20.19.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/@scure/btc-signer/node_modules/@scure/base": {
+ "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/@sentry-internal/browser-utils": {
"version": "10.42.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-10.42.0.tgz",
@@ -7741,16 +7747,11 @@
"dev": true,
"license": "MIT"
},
- "node_modules/base-x": {
- "version": "5.0.1",
- "resolved": "https://registry.npmjs.org/base-x/-/base-x-5.0.1.tgz",
- "integrity": "sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg==",
- "license": "MIT"
- },
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
+ "devOptional": true,
"funding": [
{
"type": "github",
@@ -7767,12 +7768,6 @@
],
"license": "MIT"
},
- "node_modules/bech32": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/bech32/-/bech32-2.0.0.tgz",
- "integrity": "sha512-LcknSilhIGatDAsY1ak2I8VtGaHNhgMSYVxFrGLXv+xLHytaKZKcaUJJUE7qmBr7h33o5YQwP55pMI0xmkpJwg==",
- "license": "MIT"
- },
"node_modules/better-sqlite3": {
"version": "12.9.0",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.9.0.tgz",
@@ -7821,37 +7816,6 @@
"file-uri-to-path": "1.0.0"
}
},
- "node_modules/bip174": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/bip174/-/bip174-3.0.0.tgz",
- "integrity": "sha512-N3vz3rqikLEu0d6yQL8GTrSkpYb35NQKWMR7Hlza0lOj6ZOlvQ3Xr7N9Y+JPebaCVoEUHdBeBSuLxcHr71r+Lw==",
- "license": "MIT",
- "dependencies": {
- "uint8array-tools": "^0.0.9",
- "varuint-bitcoin": "^2.0.0"
- },
- "engines": {
- "node": ">=18.0.0"
- }
- },
- "node_modules/bitcoinjs-lib": {
- "version": "7.0.1",
- "resolved": "https://registry.npmjs.org/bitcoinjs-lib/-/bitcoinjs-lib-7.0.1.tgz",
- "integrity": "sha512-vwEmpL5Tpj0I0RBdNkcDMXePoaYSTeKY6mL6/l5esbnTs+jGdPDuLp4NY1hSh6Zk5wSgePygZ4Wx5JJao30Pww==",
- "license": "MIT",
- "dependencies": {
- "@noble/hashes": "^1.2.0",
- "bech32": "^2.0.0",
- "bip174": "^3.0.0",
- "bs58check": "^4.0.0",
- "uint8array-tools": "^0.0.9",
- "valibot": "^1.2.0",
- "varuint-bitcoin": "^2.0.0"
- },
- "engines": {
- "node": ">=18.0.0"
- }
- },
"node_modules/bl": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
@@ -7965,49 +7929,6 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
- "node_modules/bs58": {
- "version": "6.0.0",
- "resolved": "https://registry.npmjs.org/bs58/-/bs58-6.0.0.tgz",
- "integrity": "sha512-PD0wEnEYg6ijszw/u8s+iI3H17cTymlrwkKhDhPZq+Sokl3AU4htyBFTjAeNAlCCmg0f53g6ih3jATyCKftTfw==",
- "license": "MIT",
- "dependencies": {
- "base-x": "^5.0.0"
- }
- },
- "node_modules/bs58check": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/bs58check/-/bs58check-4.0.0.tgz",
- "integrity": "sha512-FsGDOnFg9aVI9erdriULkd/JjEWONV/lQE5aYziB5PoBsXRind56lh8doIZIc9X4HoxT5x4bLjMWN1/NB8Zp5g==",
- "license": "MIT",
- "dependencies": {
- "@noble/hashes": "^1.2.0",
- "bs58": "^6.0.0"
- }
- },
- "node_modules/buffer": {
- "version": "6.0.3",
- "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
- "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/feross"
- },
- {
- "type": "patreon",
- "url": "https://www.patreon.com/feross"
- },
- {
- "type": "consulting",
- "url": "https://feross.org/support"
- }
- ],
- "license": "MIT",
- "dependencies": {
- "base64-js": "^1.3.1",
- "ieee754": "^1.2.1"
- }
- },
"node_modules/buffer-crc32": {
"version": "0.2.13",
"resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
@@ -8831,29 +8752,6 @@
"@types/trusted-types": "^2.0.7"
}
},
- "node_modules/ecpair": {
- "version": "3.0.1",
- "resolved": "https://registry.npmjs.org/ecpair/-/ecpair-3.0.1.tgz",
- "integrity": "sha512-uz8wMFvtdr58TLrXnAesBsoMEyY8UudLOfApcyg40XfZjP+gt1xO4cuZSIkZ8hTMTQ8+ETgt7xSIV4eM7M6VNw==",
- "license": "MIT",
- "dependencies": {
- "uint8array-tools": "^0.0.8",
- "valibot": "^1.2.0",
- "wif": "^5.0.0"
- },
- "engines": {
- "node": ">=20.0.0"
- }
- },
- "node_modules/ecpair/node_modules/uint8array-tools": {
- "version": "0.0.8",
- "resolved": "https://registry.npmjs.org/uint8array-tools/-/uint8array-tools-0.0.8.tgz",
- "integrity": "sha512-xS6+s8e0Xbx++5/0L+yyexukU7pz//Yg6IHg3BKhXotg1JcYtgxVcUctQ0HxLByiJzpAkNFawz1Nz5Xadzo82g==",
- "license": "MIT",
- "engines": {
- "node": ">=14.0.0"
- }
- },
"node_modules/electron-to-chromium": {
"version": "1.5.149",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.149.tgz",
@@ -9766,7 +9664,8 @@
"url": "https://feross.org/support"
}
],
- "license": "BSD-3-Clause"
+ "license": "BSD-3-Clause",
+ "optional": true
},
"node_modules/ignore": {
"version": "5.3.2",
@@ -11009,6 +10908,30 @@
"node": ">= 8"
}
},
+ "node_modules/micro-packed": {
+ "version": "0.9.0",
+ "resolved": "https://registry.npmjs.org/micro-packed/-/micro-packed-0.9.0.tgz",
+ "integrity": "sha512-gFdaWTxEXOwtSOcpxulO4AuXVtp3HWIRmB8eq8+3m1Zku0ubgva0UGpi03YhcvsTJasHngG9gTIUK5kHNKdesg==",
+ "license": "MIT",
+ "dependencies": {
+ "@scure/base": "~2.2.0"
+ },
+ "engines": {
+ "node": ">= 20.19.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/micro-packed/node_modules/@scure/base": {
+ "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/micromark": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz",
@@ -11874,6 +11797,32 @@
"url": "https://paulmillr.com/funding/"
}
},
+ "node_modules/nostr-tools/node_modules/@scure/bip32": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.3.1.tgz",
+ "integrity": "sha512-osvveYtyzdEVbt3OfwwXFr4P2iVBL5u1Q3q4ONBfDY/UpOuXmOlbgwc1xECEboY8wIays8Yt6onaWMUdUbfl0A==",
+ "license": "MIT",
+ "dependencies": {
+ "@noble/curves": "~1.1.0",
+ "@noble/hashes": "~1.3.1",
+ "@scure/base": "~1.1.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/nostr-tools/node_modules/@scure/bip32/node_modules/@noble/curves": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.1.0.tgz",
+ "integrity": "sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA==",
+ "license": "MIT",
+ "dependencies": {
+ "@noble/hashes": "1.3.1"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
"node_modules/nostr-tools/node_modules/@scure/bip39": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.2.1.tgz",
@@ -14698,15 +14647,6 @@
"integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==",
"license": "MIT"
},
- "node_modules/uint8array-tools": {
- "version": "0.0.9",
- "resolved": "https://registry.npmjs.org/uint8array-tools/-/uint8array-tools-0.0.9.tgz",
- "integrity": "sha512-9vqDWmoSXOoi+K14zNaf6LBV51Q8MayF0/IiQs3GlygIKUYtog603e6virExkjjFosfJUBI4LhbQK1iq8IG11A==",
- "license": "MIT",
- "engines": {
- "node": ">=14.0.0"
- }
- },
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
@@ -15045,38 +14985,6 @@
"devOptional": true,
"license": "MIT"
},
- "node_modules/valibot": {
- "version": "1.4.0",
- "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.4.0.tgz",
- "integrity": "sha512-iC/x7fVcSyOwlm/VSt7RlHnzNGLGvR9GnxdifUeWoCJo0q4ZZvrVkIHC6faTlkxG47I2Y4UrFquPuVHCrOnrLg==",
- "license": "MIT",
- "peerDependencies": {
- "typescript": ">=5"
- },
- "peerDependenciesMeta": {
- "typescript": {
- "optional": true
- }
- }
- },
- "node_modules/varuint-bitcoin": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/varuint-bitcoin/-/varuint-bitcoin-2.0.0.tgz",
- "integrity": "sha512-6QZbU/rHO2ZQYpWFDALCDSRsXbAs1VOEmXAxtbtjLtKuMJ/FQ8YbhfxlaiKv5nklci0M6lZtlZyxo9Q+qNnyog==",
- "license": "MIT",
- "dependencies": {
- "uint8array-tools": "^0.0.8"
- }
- },
- "node_modules/varuint-bitcoin/node_modules/uint8array-tools": {
- "version": "0.0.8",
- "resolved": "https://registry.npmjs.org/uint8array-tools/-/uint8array-tools-0.0.8.tgz",
- "integrity": "sha512-xS6+s8e0Xbx++5/0L+yyexukU7pz//Yg6IHg3BKhXotg1JcYtgxVcUctQ0HxLByiJzpAkNFawz1Nz5Xadzo82g==",
- "license": "MIT",
- "engines": {
- "node": ">=14.0.0"
- }
- },
"node_modules/vaul": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vaul/-/vaul-1.1.2.tgz",
@@ -16679,15 +16587,6 @@
"node": ">=8"
}
},
- "node_modules/wif": {
- "version": "5.0.0",
- "resolved": "https://registry.npmjs.org/wif/-/wif-5.0.0.tgz",
- "integrity": "sha512-iFzrC/9ne740qFbNjTZ2FciSRJlHIXoxqk/Y5EnE08QOXu1WjJyCCswwDTYbohAOEnlCtLaAAQBhyaLRFh2hMA==",
- "license": "MIT",
- "dependencies": {
- "bs58check": "^4.0.0"
- }
- },
"node_modules/word-wrap": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
diff --git a/package.json b/package.json
index 7bdd692f..be9929d2 100644
--- a/package.json
+++ b/package.json
@@ -15,7 +15,6 @@
"node": ">=22"
},
"dependencies": {
- "@bitcoinerlab/secp256k1": "^1.2.0",
"@breeztech/breez-sdk-spark": "^0.10.0",
"@capacitor/app": "^8.0.0",
"@capacitor/barcode-scanner": "^3.0.2",
@@ -42,6 +41,7 @@
"@fontsource-variable/nunito": "^5.2.7",
"@fontsource-variable/outfit": "^5.2.8",
"@fontsource-variable/playfair-display": "^5.2.8",
+ "@fontsource/bebas-neue": "^5.2.7",
"@fontsource/bungee-shade": "^5.2.7",
"@fontsource/caveat": "^5.2.8",
"@fontsource/cherry-bomb-one": "^5.2.7",
@@ -103,15 +103,16 @@
"@radix-ui/react-toggle": "^1.1.0",
"@radix-ui/react-toggle-group": "^1.1.0",
"@radix-ui/react-tooltip": "^1.2.8",
+ "@scure/base": "^1.1.1",
+ "@scure/bip32": "^2.2.0",
"@scure/bip39": "^1.6.0",
+ "@scure/btc-signer": "^2.2.0",
"@sentry/react": "^10.42.0",
"@tanstack/react-query": "^5.56.2",
"@types/d3-geo": "^3.1.0",
"@unhead/addons": "^2.1.13",
"@unhead/react": "^2.1.13",
- "bitcoinjs-lib": "^7.0.1",
"blurhash": "^2.0.5",
- "buffer": "^6.0.3",
"capacitor-secure-storage-plugin": "^0.13.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -121,7 +122,6 @@
"d3-scale": "^4.0.2",
"date-fns": "^3.6.0",
"dompurify": "^3.3.3",
- "ecpair": "^3.0.1",
"embla-carousel-react": "^8.3.0",
"emoji-mart": "^5.6.0",
"fflate": "^0.8.2",
diff --git a/public/advanced-intro.png b/public/advanced-intro.png
deleted file mode 100644
index e4aeb62a..00000000
Binary files a/public/advanced-intro.png and /dev/null differ
diff --git a/public/community-intro.png b/public/community-intro.png
deleted file mode 100644
index 2d83ff7d..00000000
Binary files a/public/community-intro.png and /dev/null differ
diff --git a/public/feed-intro.png b/public/feed-intro.png
deleted file mode 100644
index 474df32c..00000000
Binary files a/public/feed-intro.png and /dev/null differ
diff --git a/public/lists-intro.png b/public/lists-intro.png
deleted file mode 100644
index 77bab596..00000000
Binary files a/public/lists-intro.png and /dev/null differ
diff --git a/public/magic-intro.png b/public/magic-intro.png
deleted file mode 100644
index 72ef5931..00000000
Binary files a/public/magic-intro.png and /dev/null differ
diff --git a/public/mute-intro.png b/public/mute-intro.png
deleted file mode 100644
index 5fb77711..00000000
Binary files a/public/mute-intro.png and /dev/null differ
diff --git a/public/notification-intro.png b/public/notification-intro.png
deleted file mode 100644
index dc3d5eb9..00000000
Binary files a/public/notification-intro.png and /dev/null differ
diff --git a/public/og-image.jpg b/public/og-image.jpg
index 5f435436..0e8a18a4 100644
Binary files a/public/og-image.jpg and b/public/og-image.jpg differ
diff --git a/public/profile-intro.png b/public/profile-intro.png
deleted file mode 100644
index c9e05539..00000000
Binary files a/public/profile-intro.png and /dev/null differ
diff --git a/public/relay-intro.png b/public/relay-intro.png
deleted file mode 100644
index 554dcc99..00000000
Binary files a/public/relay-intro.png and /dev/null differ
diff --git a/public/theme-intro.png b/public/theme-intro.png
deleted file mode 100644
index 5c76c0ef..00000000
Binary files a/public/theme-intro.png and /dev/null differ
diff --git a/src/App.tsx b/src/App.tsx
index 1e1df42c..cb0ec2cb 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -43,7 +43,6 @@ const hardcodedConfig: AppConfig = {
shareOrigin: import.meta.env.VITE_SHARE_ORIGIN || undefined,
homePage: "campaigns",
client: "naddr1qvzqqqru7cpzq7q6z5ns2hm5c8msyv83qwzxpxe52j8c4d4q5m92wsp9sflelkh9qqzkg6t5w3hswjl4yp",
- magicMouse: false,
theme: "system",
useAppRelays: true,
useUserRelays: false,
@@ -57,48 +56,48 @@ const hardcodedConfig: AppConfig = {
feedIncludeReposts: true,
feedIncludeGenericReposts: true,
feedIncludeReactions: false,
- feedIncludeZaps: false,
+ feedIncludeZaps: true,
feedIncludeArticles: true,
showArticles: true,
showHighlights: true,
- feedIncludeHighlights: false,
+ feedIncludeHighlights: true,
showEvents: true,
feedIncludeEvents: true,
- showVines: true,
+ showVines: false,
showPolls: true,
- showTreasures: true,
- showTreasureGeocaches: true,
- showTreasureFoundLogs: true,
- showColors: true,
+ showTreasures: false,
+ showTreasureGeocaches: false,
+ showTreasureFoundLogs: false,
+ showColors: false,
showPeopleLists: true,
- feedIncludeVines: true,
+ feedIncludeVines: false,
feedIncludePolls: true,
- feedIncludeTreasureGeocaches: true,
- feedIncludeTreasureFoundLogs: true,
- feedIncludeColors: true,
+ feedIncludeTreasureGeocaches: false,
+ feedIncludeTreasureFoundLogs: false,
+ feedIncludeColors: false,
feedIncludePeopleLists: true,
- showDecks: true,
- feedIncludeDecks: true,
- showWebxdc: true,
- feedIncludeWebxdc: true,
+ showDecks: false,
+ feedIncludeDecks: false,
+ showWebxdc: false,
+ feedIncludeWebxdc: false,
showPhotos: true,
feedIncludePhotos: true,
showVideos: true,
feedIncludeNormalVideos: true,
feedIncludeShortVideos: true,
feedIncludeVoiceMessages: true,
- showEmojiPacks: true,
- feedIncludeEmojiPacks: true,
- showCustomEmojis: true,
- showUserStatuses: true,
- showMusic: true,
- feedIncludeMusicTracks: true,
- feedIncludeMusicPlaylists: true,
- showPodcasts: true,
- feedIncludePodcastEpisodes: true,
- feedIncludePodcastTrailers: true,
- showDevelopment: true,
- feedIncludeDevelopment: true,
+ showEmojiPacks: false,
+ feedIncludeEmojiPacks: false,
+ showCustomEmojis: false,
+ showUserStatuses: false,
+ showMusic: false,
+ feedIncludeMusicTracks: false,
+ feedIncludeMusicPlaylists: false,
+ showPodcasts: false,
+ feedIncludePodcastEpisodes: false,
+ feedIncludePodcastTrailers: false,
+ showDevelopment: false,
+ feedIncludeDevelopment: false,
showCommunities: true,
feedIncludeCommunities: true,
showBadges: true,
@@ -109,10 +108,10 @@ const hardcodedConfig: AppConfig = {
feedIncludeProfileBadges: true,
feedIncludeBadgeAwards: true,
feedIncludeVanish: true,
- showBirdstar: true,
- feedIncludeBirdDetections: true,
- feedIncludeBirdex: true,
- feedIncludeConstellations: true,
+ showBirdstar: false,
+ feedIncludeBirdDetections: false,
+ feedIncludeBirdex: false,
+ feedIncludeConstellations: false,
followsFeedShowReplies: true,
},
sidebarOrder: [
@@ -147,7 +146,13 @@ const hardcodedConfig: AppConfig = {
imageQuality: 'compressed',
curatorPubkey: '932614571afcbad4d17a191ee281e39eebbb41b93fac8fd87829622aeb112f4d',
sandboxDomain: 'iframe.diy',
- esploraBaseUrl: 'https://mempool.space/api',
+ esploraApis: [
+ 'https://mempool.space/api',
+ 'https://mempool.emzy.de/api',
+ 'https://blockstream.info/api',
+ ],
+ blockbookBaseUrl: 'https://btc.trezor.io',
+ bip352IndexerUrl: 'https://silentpayments.dev/blindbit/mainnet',
sidebarWidgets: [
{ id: 'trends' },
{ id: 'hot-posts' },
diff --git a/src/AppRouter.tsx b/src/AppRouter.tsx
index 9655b07a..39d0c2e5 100644
--- a/src/AppRouter.tsx
+++ b/src/AppRouter.tsx
@@ -25,7 +25,7 @@ const ReplyComposeModal = lazy(() => import("@/components/ReplyComposeModal").th
const EmojiPackDialog = lazy(() => import("@/components/EmojiPackDialog").then(m => ({ default: m.EmojiPackDialog })));
// Campaigns: home + create. (Campaign detail is dispatched from NIP19Page
-// when an naddr resolves to kind 30223.) The campaigns list IS the homepage;
+// when an naddr resolves to kind 33863.) The campaigns list IS the homepage;
// the configurable HomePage delegation from the Twitter-era app is gone.
const CampaignsPage = lazy(() => import("./pages/CampaignsPage").then(m => ({ default: m.CampaignsPage })));
const CreateCampaignPage = lazy(() => import("./pages/CreateCampaignPage").then(m => ({ default: m.CreateCampaignPage })));
@@ -47,10 +47,8 @@ const ChangelogPage = lazy(() => import("./pages/ChangelogPage").then(m => ({ de
const CommunitiesPage = lazy(() => import("./pages/CommunitiesPage").then(m => ({ default: m.CommunitiesPage })));
const CreateCommunityPage = lazy(() => import("./pages/CreateCommunityPage").then(m => ({ default: m.CreateCommunityPage })));
const CreateEventPage = lazy(() => import("./pages/CreateEventPage").then(m => ({ default: m.CreateEventPage })));
-const ContentPage = lazy(() => import("./pages/ContentPage").then(m => ({ default: m.ContentPage })));
const ContentSettingsPage = lazy(() => import("./pages/ContentSettingsPage").then(m => ({ default: m.ContentSettingsPage })));
const CSAEPolicyPage = lazy(() => import("./pages/CSAEPolicyPage").then(m => ({ default: m.CSAEPolicyPage })));
-const DiscoverPage = lazy(() => import("./pages/DiscoverPage").then(m => ({ default: m.DiscoverPage })));
const DomainFeedPage = lazy(() => import("./pages/DomainFeedPage").then(m => ({ default: m.DomainFeedPage })));
const EventsFeedPage = lazy(() => import("./pages/EventsFeedPage").then(m => ({ default: m.EventsFeedPage })));
const ExternalContentPage = lazy(() => import("./pages/ExternalContentPage").then(m => ({ default: m.ExternalContentPage })));
@@ -63,7 +61,6 @@ const KindFeedPage = lazy(() => import("./pages/KindFeedPage").then(m => ({ defa
const LetterComposePage = lazy(() => import("./pages/LetterComposePage").then(m => ({ default: m.LetterComposePage })));
const LetterPreferencesPage = lazy(() => import("./pages/LetterPreferencesPage").then(m => ({ default: m.LetterPreferencesPage })));
const LettersPage = lazy(() => import("./pages/LettersPage").then(m => ({ default: m.LettersPage })));
-const MagicSettingsPage = lazy(() => import("./pages/MagicSettingsPage").then(m => ({ default: m.MagicSettingsPage })));
const MusicPage = lazy(() => import("./pages/MusicPage").then(m => ({ default: m.MusicPage })));
const NetworkSettingsPage = lazy(() => import("./pages/NetworkSettingsPage").then(m => ({ default: m.NetworkSettingsPage })));
const NIP19Page = lazy(() => import("./pages/NIP19Page").then(m => ({ default: m.NIP19Page })));
@@ -93,7 +90,6 @@ const WorldPage = lazy(() => import("./pages/WorldPage").then(m => ({ default: m
const FollowPage = lazy(() => import("./pages/FollowPage").then(m => ({ default: m.FollowPage })));
const PlanetoraPage = lazy(() => import("./pages/PlanetoraPage").then(m => ({ default: m.PlanetoraPage })));
const ReceivePage = lazy(() => import("./pages/ReceivePage").then(m => ({ default: m.ReceivePage })));
-const ClaimPage = lazy(() => import("./pages/ClaimPage").then(m => ({ default: m.ClaimPage })));
const RemoteLoginSuccessPage = lazy(() => import("./pages/RemoteLoginSuccessPage").then(m => ({ default: m.RemoteLoginSuccessPage })));
const pollsDef = getExtraKindDef("polls")!;
@@ -167,13 +163,11 @@ export function AppRouter() {
{/* Auto-follow deep link: fullscreen immersive (no sidebars/nav) */}
} />
} />
- } />
{/* All routes share the persistent FundraiserLayout (top nav + footer) */}
} />
}>
} />
- } />
} />
} />
} />
@@ -190,7 +184,6 @@ export function AppRouter() {
} />
} />
} />
- } />
} />
}
/>
- } />
} />
} />
} />
diff --git a/src/components/ArcBackground.tsx b/src/components/ArcBackground.tsx
index bccb7f70..9746382b 100644
--- a/src/components/ArcBackground.tsx
+++ b/src/components/ArcBackground.tsx
@@ -33,6 +33,8 @@ interface ArcBackgroundProps {
variant: 'down' | 'up' | 'rect';
/** Extra classes on the element. */
className?: string;
+ /** Extra classes on the filled background path. */
+ fillClassName?: string;
}
/**
@@ -40,7 +42,7 @@ interface ArcBackgroundProps {
* MobileBottomNav. Draws a semi-transparent filled shape (rectangle + optional
* curved arc) as a single path so there are no sub-pixel seams between layers.
*/
-export function ArcBackground({ variant, className }: ArcBackgroundProps) {
+export function ArcBackground({ variant, className, fillClassName }: ArcBackgroundProps) {
const path = variant === 'down' ? ARC_DOWN_PATH : variant === 'up' ? ARC_UP_PATH : RECT_PATH;
const hasArc = variant !== 'rect';
@@ -57,7 +59,7 @@ export function ArcBackground({ variant, className }: ArcBackgroundProps) {
preserveAspectRatio="none"
style={hasArc ? (variant === 'up' ? arcUpHeightStyle : arcDownHeightStyle) : fullHeightStyle}
>
-
+
{variant === 'down' && }
{variant === 'up' && }
diff --git a/src/components/BanConfirmDialog.tsx b/src/components/BanConfirmDialog.tsx
index aa01b5fc..f855b1a1 100644
--- a/src/components/BanConfirmDialog.tsx
+++ b/src/components/BanConfirmDialog.tsx
@@ -64,8 +64,25 @@ export function BanConfirmDialog({
});
// Invalidate community queries so the moderation overlay updates
- // immediately (removes banned content without a page refresh).
- await queryClient.invalidateQueries({ queryKey: ['community-members', communityATag] });
+ // immediately (removes banned content without a page refresh). The
+ // activity feed's key is `['community-activity-feed', ]`
+ // where aTagsKey is a comma-joined list of the viewer's subscribed A
+ // tags. Predicate-match any feed whose aTagsKey contains this
+ // communityATag so the banned post disappears immediately.
+ await Promise.all([
+ queryClient.invalidateQueries({ queryKey: ['community-members', communityATag] }),
+ queryClient.invalidateQueries({
+ predicate: (q) => {
+ const [root, aTagsKey] = q.queryKey;
+ return root === 'community-activity-feed'
+ && typeof aTagsKey === 'string'
+ && aTagsKey.split(',').includes(communityATag);
+ },
+ }),
+ // Also refresh the organization-activity feed shown on the org
+ // detail page (used by the pledge/campaign shelves).
+ queryClient.invalidateQueries({ queryKey: ['organization-activity', communityATag] }),
+ ]);
toast({ title: 'Post removed from organization' });
setReason('');
diff --git a/src/components/BeneficiaryDonateDialog.tsx b/src/components/BeneficiaryDonateDialog.tsx
deleted file mode 100644
index 77dcc680..00000000
--- a/src/components/BeneficiaryDonateDialog.tsx
+++ /dev/null
@@ -1,190 +0,0 @@
-import { useMemo, useState } from 'react';
-import { Link } from 'react-router-dom';
-import { AlertTriangle, Check, Copy, ExternalLink } from 'lucide-react';
-
-import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
-import { BitcoinPublicDisclaimer } from '@/components/BitcoinPublicDisclaimer';
-import { Button } from '@/components/ui/button';
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogHeader,
- DialogTitle,
-} from '@/components/ui/dialog';
-import { Label } from '@/components/ui/label';
-import { QRCodeCanvas } from '@/components/ui/qrcode';
-import { useAuthor } from '@/hooks/useAuthor';
-import { useProfileUrl } from '@/hooks/useProfileUrl';
-import { useToast } from '@/hooks/useToast';
-import { nostrPubkeyToBitcoinAddress } from '@/lib/bitcoin';
-import { genUserName } from '@/lib/genUserName';
-import { sanitizeUrl } from '@/lib/sanitizeUrl';
-
-interface BeneficiaryDonatePanelProps {
- /** Hex pubkey of the beneficiary. */
- pubkey: string;
-}
-
-/**
- * Inline panel rendering a beneficiary's Taproot address as a scannable
- * BIP-21 QR code, a copyable string, and an "Open in wallet" button.
- *
- * Used both by `BeneficiaryDonateDialog` (modal context) and embedded
- * directly into the campaign page when there's a single beneficiary.
- *
- * Always shows the beneficiary's profile preview (avatar + name) as a
- * link to their Nostr profile — even when the surrounding page also
- * identifies a campaign organizer, the beneficiary is a distinct party
- * (the organizer may be running the campaign on someone else's behalf).
- *
- * Intentionally minimal: no amount input, no PSBT/in-app wallet flow —
- * that's `DonateDialog`'s job.
- */
-export function BeneficiaryDonatePanel({
- pubkey,
-}: BeneficiaryDonatePanelProps) {
- const { toast } = useToast();
- const [copied, setCopied] = useState(false);
-
- const author = useAuthor(pubkey);
- const metadata = author.data?.metadata;
- const displayName =
- metadata?.display_name || metadata?.name || genUserName(pubkey);
- const picture = sanitizeUrl(metadata?.picture);
- const profileUrl = useProfileUrl(pubkey, metadata);
-
- const address = useMemo(
- () => nostrPubkeyToBitcoinAddress(pubkey),
- [pubkey],
- );
- // BIP-21 URI: most wallets recognize the `bitcoin:` scheme when scanning.
- // No amount field — donor picks one in their wallet.
- const bip21 = address ? `bitcoin:${address}` : '';
-
- const copyAddress = async () => {
- if (!address) return;
- try {
- await navigator.clipboard.writeText(address);
- setCopied(true);
- setTimeout(() => setCopied(false), 1500);
- toast({ title: 'Address copied' });
- } catch {
- toast({
- title: 'Copy failed',
- description: 'Select and copy the address manually.',
- variant: 'destructive',
- });
- }
- };
-
- if (!address) {
- return (
-
-
-
We couldn't derive a Bitcoin address for this beneficiary.
-
- );
- }
-
- return (
-
-
-
- {picture && }
-
- {displayName.slice(0, 2).toUpperCase()}
-
-
-
-
-
- {/* QR code */}
-
-
- {/* Copyable address */}
-
-
- Bitcoin address
-
-
- {address}
- {copied ? (
-
- ) : (
-
- )}
-
-
-
- {/* Privacy notice — informational only. Bitcoin is a public
- ledger, so the donation can be traced back to the donor's
- wallet. */}
-
-
- {/* Open in wallet — relies on the `bitcoin:` URI handler. */}
-
-
-
- Open in wallet
-
-
-
- );
-}
-
-interface BeneficiaryDonateDialogProps {
- /** Hex pubkey of the beneficiary. */
- pubkey: string;
- open: boolean;
- onOpenChange: (open: boolean) => void;
-}
-
-/**
- * Modal wrapper around `BeneficiaryDonatePanel` for places that still want
- * the dialog UX (e.g. multi-beneficiary campaigns, where each row's
- * "Donate" button opens this dialog).
- */
-export function BeneficiaryDonateDialog({
- pubkey,
- open,
- onOpenChange,
-}: BeneficiaryDonateDialogProps) {
- const author = useAuthor(pubkey);
- const metadata = author.data?.metadata;
- const displayName =
- metadata?.display_name || metadata?.name || genUserName(pubkey);
-
- return (
-
-
-
- Donate to {displayName}
-
- Scan the QR code or copy the Bitcoin address below to donate.
-
-
-
-
-
-
- );
-}
diff --git a/src/components/BitcoinPrivateDisclaimer.tsx b/src/components/BitcoinPrivateDisclaimer.tsx
new file mode 100644
index 00000000..38388848
--- /dev/null
+++ b/src/components/BitcoinPrivateDisclaimer.tsx
@@ -0,0 +1,51 @@
+import { Alert, AlertDescription } from '@/components/ui/alert';
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from '@/components/ui/popover';
+
+/**
+ * Informational notice for BIP-352 silent-payment receive endpoints
+ * (sp1…). Surfaces the "private but experimental" trade-off the user
+ * accepts when they choose silent payments instead of a regular
+ * on-chain address.
+ *
+ * Visual treatment mirrors `BitcoinPublicDisclaimer` with `tone="soft"`:
+ * `role="note"`, amber tint, no icon, no checkbox. The lead sentence
+ * carries the headline, and "Learn more" opens a popover with the full
+ * explanation.
+ */
+export function BitcoinPrivateDisclaimer() {
+ return (
+
+ {/* No icon — the shadcn Alert reserves left padding for an icon via
+ `[&>svg~*]:pl-7`, so omitting it reclaims the indent. */}
+
+
+ Experimental. Donations are private, but bugs may occur.{' '}
+
+
+
+ Learn more
+
+
+
+ Your private wallet hides the real address of your wallet
+ and your donors on the Bitcoin network. Funds are always
+ fully recoverable, but bugs in the wallet may cause it to
+ show an incorrect balance, and it may require long wait
+ times to synchronize.
+
+
+
+
+
+ );
+}
diff --git a/src/components/BitcoinPublicDisclaimer.tsx b/src/components/BitcoinPublicDisclaimer.tsx
index b354072b..d60c1275 100644
--- a/src/components/BitcoinPublicDisclaimer.tsx
+++ b/src/components/BitcoinPublicDisclaimer.tsx
@@ -1,3 +1,4 @@
+import type { ReactNode } from 'react';
import { AlertTriangle } from 'lucide-react';
import { Alert, AlertDescription } from '@/components/ui/alert';
@@ -41,6 +42,14 @@ interface BitcoinPublicDisclaimerProps {
* Defaults to `true` for backwards compatibility.
*/
includeCashOutAdvice?: boolean;
+ /**
+ * Override the popover body. When set, replaces the entire "Bitcoin
+ * is a public ledger…" paragraph (including the cash-out advice). Use
+ * when the calling surface has a meaningfully different audience —
+ * e.g. a campaign *creator* configuring a receive address, vs. the
+ * sender flow this component was originally written for.
+ */
+ popoverText?: ReactNode;
}
/**
@@ -56,6 +65,7 @@ export function BitcoinPublicDisclaimer({
leadText = 'Money you send is public and can be traced back to you.',
tone = 'destructive',
includeCashOutAdvice = true,
+ popoverText,
}: BitcoinPublicDisclaimerProps) {
const showCheckbox = onAcknowledgedChange !== undefined;
const isSoft = tone === 'soft';
@@ -67,7 +77,12 @@ export function BitcoinPublicDisclaimer({
role={isSoft ? 'note' : 'alert'}
className={cn(
isSoft
- ? 'border-amber-300/60 bg-amber-50 text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-100'
+ // Use the project's foreground token (not raw amber-900) so
+ // the text always contrasts against the page in both light
+ // and dark themes. The faint amber tint keeps the
+ // "informational notice" cue without leaning on hard-coded
+ // amber text that disappears on the wrong backdrop.
+ ? 'border-amber-500/30 bg-amber-500/10 text-foreground'
: 'border-destructive/50 bg-destructive/5 text-destructive dark:border-destructive',
)}
>
@@ -88,11 +103,15 @@ export function BitcoinPublicDisclaimer({
- Bitcoin is a public ledger. Transactions you send can
- be traced back to you forever, even after being
- exchanged by multiple people. Send it only to those
- you wish to support publicly
- {includeCashOutAdvice ? ', or cash out at an exchange.' : '.'}
+ {popoverText ?? (
+ <>
+ Bitcoin is a public ledger. Transactions you send can
+ be traced back to you forever, even after being
+ exchanged by multiple people. Send it only to those
+ you wish to support publicly
+ {includeCashOutAdvice ? ', or cash out at an exchange.' : '.'}
+ >
+ )}
diff --git a/src/components/CalendarEventDetailPage.tsx b/src/components/CalendarEventDetailPage.tsx
index 9512a197..370b54b0 100644
--- a/src/components/CalendarEventDetailPage.tsx
+++ b/src/components/CalendarEventDetailPage.tsx
@@ -1,8 +1,8 @@
import { useMemo, useCallback, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import {
- ArrowLeft,
CalendarDays,
+ ChevronLeft,
MapPin,
Clock,
Users,
@@ -18,10 +18,12 @@ import type { NostrEvent, NostrMetadata } from '@nostrify/nostrify';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
-import { Separator } from '@/components/ui/separator';
+import { Card, CardContent } from '@/components/ui/card';
+import { DetailCommentComposer } from '@/components/DetailCommentComposer';
import { NoteContent } from '@/components/NoteContent';
import { NoteMoreMenu } from '@/components/NoteMoreMenu';
import { PostActionBar } from '@/components/PostActionBar';
+import { PinnedCommentHeader } from '@/components/PinnedCommentHeader';
import { ReplyComposeModal } from '@/components/ReplyComposeModal';
import { CreateCommunityEventDialog } from '@/components/CreateCommunityEventDialog';
import { RSVPAvatars } from '@/components/RSVPAvatars';
@@ -33,9 +35,11 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useEventRSVPs } from '@/hooks/useEventRSVPs';
import { useMyRSVP } from '@/hooks/useMyRSVP';
import { usePublishRSVP } from '@/hooks/usePublishRSVP';
+import { usePinnedEventComments } from '@/hooks/usePinnedEventComments';
import { useProfileUrl } from '@/hooks/useProfileUrl';
import { useToast } from '@/hooks/useToast';
import { genUserName } from '@/lib/genUserName';
+import { openUrl } from '@/lib/downloadFile';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import { cn } from '@/lib/utils';
@@ -126,6 +130,26 @@ function formatDetailDate(event: NostrEvent): string {
return startStr;
}
+function formatCalendarHeroDate(event: NostrEvent): string | null {
+ const startRaw = getTag(event.tags, 'start');
+ if (!startRaw) return null;
+
+ if (event.kind === 31922) {
+ const [year, month, day] = startRaw.split('-').map(Number);
+ const date = new Date(year, month - 1, day);
+ if (isNaN(date.getTime())) return startRaw;
+ return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
+ }
+
+ const timestamp = Number(startRaw);
+ if (!Number.isFinite(timestamp)) return startRaw;
+ const startTzid = getTag(event.tags, 'start_tzid');
+ return new Date(timestamp * 1000).toLocaleDateString('en-US', {
+ month: 'short', day: 'numeric', year: 'numeric',
+ ...(startTzid ? { timeZone: startTzid } : {}),
+ });
+}
+
const ROLE_ORDER = ['host', 'speaker', 'moderator', 'participant'];
function roleSort(a: string, b: string): number {
const ai = ROLE_ORDER.indexOf(a.toLowerCase());
@@ -162,6 +186,15 @@ function PersonRow({ pubkey, label, size = 'md' }: { pubkey: string; label?: str
);
}
+function EventDetailRow({ icon, children }: { icon: React.ReactNode; children: React.ReactNode }) {
+ return (
+
+ );
+}
+
// --- Main Component ---
export function CalendarEventDetailPage({ event }: { event: NostrEvent }) {
@@ -170,7 +203,7 @@ export function CalendarEventDetailPage({ event }: { event: NostrEvent }) {
const { toast } = useToast();
const title = getTag(event.tags, 'title') ?? 'Untitled Event';
- const image = getTag(event.tags, 'image');
+ const image = sanitizeUrl(getTag(event.tags, 'image'));
const locationRaw = getTag(event.tags, 'location');
const location = locationRaw ? parseLocation(locationRaw) : undefined;
const summary = getTag(event.tags, 'summary');
@@ -179,6 +212,7 @@ export function CalendarEventDetailPage({ event }: { event: NostrEvent }) {
const eventCoord = useMemo(() => getEventCoord(event), [event]);
const dateStr = useMemo(() => formatDetailDate(event), [event]);
+ const heroDate = useMemo(() => formatCalendarHeroDate(event), [event]);
// Participants grouped by role
const participantsByRole = useMemo(() => {
@@ -200,6 +234,12 @@ export function CalendarEventDetailPage({ event }: { event: NostrEvent }) {
const myRsvp = useMyRSVP(eventCoord);
const publishRSVP = usePublishRSVP();
const { data: commentsData, isLoading: commentsLoading } = useComments(event, 500);
+ const {
+ pinnedEvents,
+ isPinned,
+ canManagePins,
+ togglePin,
+ } = usePinnedEventComments(eventCoord, event.pubkey);
const [replyOpen, setReplyOpen] = useState(false);
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
const [editOpen, setEditOpen] = useState(false);
@@ -225,6 +265,11 @@ export function CalendarEventDetailPage({ event }: { event: NostrEvent }) {
.map((comment) => buildNode(comment));
}, [commentsData]);
+ const pinnedNodes = useMemo(
+ () => pinnedEvents.map((event): ReplyNode => ({ event, children: [] })),
+ [pinnedEvents],
+ );
+
const handleRSVP = useCallback(async (status: 'accepted' | 'declined' | 'tentative') => {
if (status === myRsvp.status) return;
try {
@@ -240,202 +285,339 @@ export function CalendarEventDetailPage({ event }: { event: NostrEvent }) {
}, [eventCoord, event.pubkey, myRsvp.status, publishRSVP, toast]);
const showRSVP = !!user;
+ const attendingCount = rsvps.accepted.length;
+ const interestedCount = rsvps.tentative.length;
+ const rsvpStatusLabel = myRsvp.status === 'accepted'
+ ? 'You are going'
+ : myRsvp.status === 'tentative'
+ ? 'You are interested'
+ : myRsvp.status === 'declined'
+ ? "You can't go"
+ : 'Choose your RSVP';
- return (
-
- {/* ── Standard top bar ── */}
-
-
window.history.length > 1 ? navigate(-1) : navigate('/')}
- className="p-1.5 -ml-1.5 rounded-full hover:bg-secondary/60 transition-colors"
- aria-label="Go back"
- >
-
-
-
Event Details
- {canEdit && (
-
setEditOpen(true)}
- aria-label="Edit event"
- >
-
-
+ const eventDetailsCard = (
+
+
+
+
+ {(event.content || summary) && (
+
+
Description
+ {event.content ? (
+
+ ) : (
+
{summary}
+ )}
+
)}
-
- {/* ── Cover image ── */}
- {image ? (
-
-
-
- ) : (
-
-
-
- )}
-
- {/* ── Content ── */}
-
- {/* Title */}
-
{title}
- {/* Organizer row */}
-
-
- {/* Date & Location — sidebar-style pills */}
-
-
-
- {dateStr}
-
+
+
}>
+ {dateStr}
+
{location && (
-
-
- {location}
-
+
}>
+ {location}
+
)}
- {/* Hashtags */}
- {hashtags.length > 0 && (
-
- {hashtags.map((tag) => (
-
-
- #{tag}
-
-
- ))}
+ {showRSVP && (
+
+
+
RSVP
+
{rsvpStatusLabel}
+
+
+ handleRSVP('accepted')}
+ >
+
+ Going
+
+ handleRSVP('tentative')}
+ >
+
+ Interested
+
+ handleRSVP('declined')}
+ >
+
+ Can't Go
+
+
)}
- {/* Description */}
- {(event.content || summary) && (
- <>
-
-
- About
- {event.content ? (
-
- ) : (
- {summary}
+ {rsvps.total > 0 && (
+
+
Attendees
+
+ {([
+ ['Going', rsvps.accepted, 'border-green-500/50 bg-green-500/5 text-green-600'],
+ ['Interested', rsvps.tentative, 'border-amber-500/50 bg-amber-500/5 text-amber-600'],
+ ["Can't Go", rsvps.declined, 'border-muted-foreground/30 bg-muted/30 text-muted-foreground'],
+ ] as const).map(([label, pks, cls]) => pks.length > 0 && (
+
+ {label} ({pks.length})
+
+
+ ))}
+
+
+ )}
+
+ {links.length > 0 && (
+
+
Links
+
+ {links.map((url) => (
+ void openUrl(url)}
+ className="flex w-full items-center gap-2 rounded-lg px-2 py-2 text-left text-sm hover:bg-muted/50 transition-colors"
+ >
+
+ {url.replace(/^https?:\/\//, '')}
+
+
+ ))}
+
+
+ )}
+
+
+ );
+
+ const participantsCard = participantsByRole.length > 0 ? (
+
+
+ Participants
+
+ {participantsByRole.map(([role, pubkeys]) =>
+ pubkeys.map((pk) =>
),
+ )}
+
+
+
+ ) : null;
+
+ return (
+
+
+
+ {image ? (
+
+ ) : (
+
+
+
+ )}
+
+
+
+
window.history.length > 1 ? navigate(-1) : navigate('/')}
+ className="p-2.5 -ml-2 rounded-full text-white/90 hover:bg-white/15 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/80 motion-safe:transition-colors"
+ aria-label="Go back"
+ >
+
+
+ {canEdit && (
+
setEditOpen(true)}
+ className="rounded-full bg-transparent text-white/90 shadow-none hover:bg-white/15 hover:text-white focus-visible:ring-white/80"
+ >
+
+ Edit
+
+ )}
+
+
+
+
+
+ {title}
+
+
+
+ {heroDate && (
+
+
+ {heroDate}
+
+ )}
+ {location && (
+
+
+ {location}
+
+ )}
+ {attendingCount > 0 && (
+
+
+ {attendingCount} attending
+
+ )}
+ {interestedCount > 0 && (
+
+
+ {interestedCount} interested
+
+ )}
+
+ {summary && (
+
+ {summary}
+
+ )}
+
+
+
+
+
+
+
setReplyOpen(true)}
+ onMore={() => setMoreMenuOpen(true)}
+ />
+
+
+
+ {pinnedNodes.length > 0 && (
+
+
+ (
+ handleTogglePin(event)}
+ />
+ )}
+ />
+
+
+ )}
+
+
+
+ {eventDetailsCard}
+ {participantsCard}
+
+
+
+
+
+ {hashtags.length > 0 && (
+
+ {hashtags.map((tag) => (
+
+
+ #{tag}
+
+
+ ))}
+
+ )}
+
+ {(event.content || summary) && (
+
+ {event.content ? (
+
+ ) : (
+ {summary}
+ )}
+
)}
- >
- )}
- {/* External links */}
- {links.length > 0 && (
-
- )}
- {/* Participants */}
- {participantsByRole.length > 0 && (
- <>
-
-
-
- Participants
-
-
- {participantsByRole.map(([role, pubkeys]) =>
- pubkeys.map((pk) =>
),
- )}
-
-
- >
- )}
-
- {/* Attendees */}
- {rsvps.total > 0 && (
- <>
-
-
-
- Attendees
-
-
- {([
- ['Going', rsvps.accepted, 'border-green-500/50 bg-green-500/5 text-green-600'],
- ['Interested', rsvps.tentative, 'border-amber-500/50 bg-amber-500/5 text-amber-600'],
- ["Can't Go", rsvps.declined, 'border-muted-foreground/30 bg-muted/30 text-muted-foreground'],
- ] as const).map(([label, pks, cls]) => pks.length > 0 && (
-
- {label} ({pks.length})
-
-
- ))}
-
-
- >
- )}
-
- {/* RSVP section */}
- {showRSVP && (
- <>
-
-
-
- RSVP
-
-
- handleRSVP('accepted')}
- >
- Going
-
- handleRSVP('tentative')}
- >
- Interested
-
- handleRSVP('declined')}
- >
- Can't Go
-
-
-
- >
- )}
-
-
setReplyOpen(true)}
- onMore={() => setMoreMenuOpen(true)}
- className="-mx-5 px-5"
- />
+
+
@@ -446,32 +628,40 @@ export function CalendarEventDetailPage({ event }: { event: NostrEvent }) {
event={event}
/>
)}
-
-
- {commentsLoading ? (
-
- {Array.from({ length: 3 }).map((_, i) => (
-
- ))}
-
- ) : replyTree.length > 0 ? (
-
-
-
- ) : (
-
- No comments yet. Be the first to comment!
-
- )}
-
-
+
+ );
+
+ function handleTogglePin(event: NostrEvent) {
+ const wasPinned = isPinned(event.id);
+ togglePin.mutate(event.id, {
+ onSuccess: () => {
+ toast({ title: wasPinned ? 'Unpinned from event' : 'Pinned to event' });
+ },
+ onError: () => {
+ toast({ title: 'Failed to update event pins', variant: 'destructive' });
+ },
+ });
+ }
+}
+
+function EventPinHeader({
+ isPinned,
+ canManagePins,
+ pinPending,
+ onTogglePin,
+}: {
+ isPinned: boolean;
+ canManagePins: boolean;
+ pinPending: boolean;
+ onTogglePin: () => void;
+}) {
+ return (
+
);
}
diff --git a/src/components/CampaignCard.tsx b/src/components/CampaignCard.tsx
index e3feb639..ae6ece7f 100644
--- a/src/components/CampaignCard.tsx
+++ b/src/components/CampaignCard.tsx
@@ -1,6 +1,7 @@
import { useMemo } from 'react';
+import type { ReactNode } from 'react';
import { Link } from 'react-router-dom';
-import { CalendarClock, HandHeart, MapPin, Target, Users, Archive } from 'lucide-react';
+import { CalendarClock, EyeOff, HandHeart, MapPin, ShieldCheck, Target } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Card } from '@/components/ui/card';
@@ -15,9 +16,8 @@ import {
type ParsedCampaign,
encodeCampaignNaddr,
getCampaignCountryLabel,
- getCampaignPrimaryTagLabel,
} from '@/lib/campaign';
-import { formatCampaignAmount } from '@/lib/formatCampaignAmount';
+import { formatCampaignAmount, formatUsdGoal, satsToUsd } from '@/lib/formatCampaignAmount';
import { genUserName } from '@/lib/genUserName';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import { cn } from '@/lib/utils';
@@ -33,20 +33,32 @@ function formatDeadline(unixSeconds: number): { label: string; isPast: boolean }
return { label: `${months} mo left`, isPast: false };
}
-/** Short helper rendered both inline (cards) and in the detail page. */
+/**
+ * Short helper rendered both inline (cards) and in the detail page.
+ *
+ * Per NIP.md Kind 33863, the campaign **goal** is integer USD and the
+ * **raised** total is the sum of verified sats. We render both in the
+ * goal's unit (USD) for consistency, converting the sats total at view
+ * time using the live BTC price. While the price is loading the raised
+ * amount falls back to sats.
+ */
export function CampaignProgress({
raisedSats,
- goalSats,
+ goalUsd,
btcPrice,
className,
}: {
raisedSats: number;
- goalSats?: number;
+ goalUsd?: number;
btcPrice?: number;
className?: string;
}) {
- const hasGoal = !!goalSats && goalSats > 0;
- const pct = hasGoal ? Math.min(100, Math.round((raisedSats / goalSats!) * 100)) : 0;
+ const hasGoal = !!goalUsd && goalUsd > 0;
+ const raisedUsd = satsToUsd(raisedSats, btcPrice);
+ const pct = hasGoal && raisedUsd !== undefined
+ ? Math.min(100, Math.round((raisedUsd / goalUsd!) * 100))
+ : 0;
+
return (
{hasGoal &&
}
@@ -56,32 +68,59 @@ export function CampaignProgress({
{!hasGoal &&
raised }
{hasGoal && (
-
of {formatCampaignAmount(goalSats!, btcPrice)} goal
+
of {formatUsdGoal(goalUsd!)} goal
)}
);
}
+/**
+ * Replaces {@link CampaignProgress} for silent-payment campaigns, where
+ * on-chain totals are unobservable by design. Shows the goal as a target
+ * (if set) but no progress bar or raised amount.
+ */
+export function CampaignPrivateNotice({
+ goalUsd,
+ className,
+}: {
+ goalUsd?: number;
+ className?: string;
+}) {
+ return (
+
+
+
+ Private campaign — totals are not public
+
+ {goalUsd && goalUsd > 0 && (
+
Target: {formatUsdGoal(goalUsd)}
+ )}
+
+ );
+}
+
interface CampaignCardProps {
campaign: ParsedCampaign;
/** Visual variant: `compact` for grid items, `featured` for hero placement. */
variant?: 'compact' | 'featured';
className?: string;
+ /** Optional footer affordance rendered opposite the author line. */
+ footerBadge?: ReactNode;
}
/**
* Renders a single campaign as a clickable card. The whole card is a
* `
` to the campaign's naddr-based detail route.
*/
-export function CampaignCard({ campaign, variant = 'compact', className }: CampaignCardProps) {
+export function CampaignCard({ campaign, variant = 'compact', className, footerBadge }: CampaignCardProps) {
const author = useAuthor(campaign.pubkey);
- const { data: stats } = useCampaignDonations(campaign.aTag);
+ const { data: stats } = useCampaignDonations(campaign);
const { data: btcPrice } = useBtcPrice();
const { data: moderation } = useCampaignModeration();
const naddr = useMemo(() => encodeCampaignNaddr(campaign), [campaign]);
- const cover = sanitizeUrl(campaign.image);
+ const cover = sanitizeUrl(campaign.banner);
const creatorName =
author.data?.metadata?.display_name ||
author.data?.metadata?.name ||
@@ -89,7 +128,7 @@ export function CampaignCard({ campaign, variant = 'compact', className }: Campa
const deadline = campaign.deadline ? formatDeadline(campaign.deadline) : null;
const raisedSats = stats?.totalSats ?? 0;
const countryLabel = getCampaignCountryLabel(campaign);
- const tagLabel = getCampaignPrimaryTagLabel(campaign);
+ const isSilentPayment = campaign.wallet.mode === 'sp';
const isFeaturedVariant = variant === 'featured';
const isApproved = moderation.approvedCoords.has(campaign.aTag);
@@ -129,29 +168,22 @@ export function CampaignCard({ campaign, variant = 'compact', className }: Campa
)}
- {tagLabel && (
+ {isSilentPayment && (
- {tagLabel}
+
+ Private
)}
- {campaign.archived && (
-
-
- Archived
-
- )}
{isHidden && (
+
Hidden
)}
@@ -190,16 +222,15 @@ export function CampaignCard({ campaign, variant = 'compact', className }: Campa
-
+ {isSilentPayment ? (
+
+ ) : (
+
+ )}
{/* Meta row */}
-
-
- {campaign.recipients.length}{' '}
- {campaign.recipients.length === 1 ? 'recipient' : 'recipients'}
-
- {stats && stats.donorCount > 0 && (
+ {!isSilentPayment && stats && stats.donorCount > 0 && (
{stats.donorCount} {stats.donorCount === 1 ? 'donor' : 'donors'}
@@ -224,8 +255,11 @@ export function CampaignCard({ campaign, variant = 'compact', className }: Campa
)}
-
- by
{creatorName}
+
+
+ by {creatorName}
+
+ {footerBadge &&
{footerBadge}
}
diff --git a/src/components/CampaignHeroBackground.tsx b/src/components/CampaignHeroBackground.tsx
deleted file mode 100644
index e72d98b5..00000000
--- a/src/components/CampaignHeroBackground.tsx
+++ /dev/null
@@ -1,108 +0,0 @@
-import { useEffect, useRef, useState } from 'react';
-
-import { sanitizeUrl } from '@/lib/sanitizeUrl';
-import { cn } from '@/lib/utils';
-interface CampaignHeroBackgroundProps {
- /**
- * Image URL for the active campaign. Each new URL crossfades over the
- * previous one — we keep up to two layers mounted at a time so the
- * transition is smooth even when the source changes mid-fade.
- */
- imageUrl: string | undefined;
- /** Optional className for the outer wrapper. */
- className?: string;
-}
-
-interface Layer {
- /** Stable key so React doesn't tear down the layer mid-transition. */
- id: number;
- /** Sanitized URL (or `null` for the gradient-only fallback). */
- url: string | null;
-}
-
-const FADE_MS = 1500;
-
-/**
- * Full-bleed crossfading background built from the active campaign's banner
- * image. Modelled after Treasures' HeroGallery: each image gets its own
- * stacked layer and we toggle opacity to crossfade. The previous layer
- * unmounts after the fade completes, so we never accumulate more than a
- * couple of layers in the DOM.
- *
- * A warm tint + subtle film-grain SVG sit on top so headlines stay readable
- * over any photo.
- */
-export function CampaignHeroBackground({ imageUrl, className }: CampaignHeroBackgroundProps) {
- const idRef = useRef(0);
- const [layers, setLayers] = useState
([]);
- const lastUrlRef = useRef(null);
-
- useEffect(() => {
- const safe = sanitizeUrl(imageUrl) ?? null;
- if (safe === lastUrlRef.current) return;
- lastUrlRef.current = safe;
-
- const id = ++idRef.current;
- // Add the new layer; existing layers stay mounted so the crossfade has
- // something to fade from.
- setLayers((prev) => [...prev, { id, url: safe }]);
-
- // After the fade completes, drop everything except the most recent
- // layer to keep the DOM tidy.
- const timeout = window.setTimeout(() => {
- setLayers((prev) => prev.filter((l) => l.id === id));
- }, FADE_MS + 50);
- return () => window.clearTimeout(timeout);
- }, [imageUrl]);
-
- return (
-
- {layers.map((layer, i) => {
- const isTop = i === layers.length - 1;
- return (
-
- {layer.url ? (
-
- ) : (
-
- )}
-
- );
- })}
-
- {/* Warm tint + dark gradient — keeps foreground text legible without
- completely washing the photo out. */}
-
-
-
- {/* Film grain — same trick as Treasures' HeroGallery. Helps the
- composited globe + photo feel like one image. */}
-
-
-
-
-
-
-
-
- );
-}
diff --git a/src/components/CampaignNoteCardContent.tsx b/src/components/CampaignNoteCardContent.tsx
new file mode 100644
index 00000000..c120af78
--- /dev/null
+++ b/src/components/CampaignNoteCardContent.tsx
@@ -0,0 +1,22 @@
+import type { NostrEvent } from '@nostrify/nostrify';
+
+import { CampaignCard } from '@/components/CampaignCard';
+import { parseCampaign } from '@/lib/campaign';
+
+/**
+ * Renders a kind 33863 Campaign event inside the activity feed using the
+ * same polished {@link CampaignCard} component that powers the campaign
+ * directory. The whole card is a ` ` to the campaign's naddr-based
+ * detail route, so taps from the feed land directly on the campaign page.
+ *
+ * Malformed events (missing required fields, invalid wallet endpoint,
+ * etc.) silently drop — `parseCampaign` returns `null` and we return
+ * `null` from the component. A future enhancement could render a
+ * "Malformed campaign" fallback, but for now keeping the feed clean
+ * wins over surfacing parse errors to viewers.
+ */
+export function CampaignNoteCardContent({ event }: { event: NostrEvent }) {
+ const campaign = parseCampaign(event);
+ if (!campaign) return null;
+ return ;
+}
diff --git a/src/components/CampaignWalletDonatePanel.tsx b/src/components/CampaignWalletDonatePanel.tsx
new file mode 100644
index 00000000..f84adc0b
--- /dev/null
+++ b/src/components/CampaignWalletDonatePanel.tsx
@@ -0,0 +1,147 @@
+import { useState } from 'react';
+import { AlertTriangle, Check, Copy, ExternalLink, ShieldCheck } from 'lucide-react';
+
+import { BitcoinPublicDisclaimer } from '@/components/BitcoinPublicDisclaimer';
+import { Button } from '@/components/ui/button';
+import { QRCodeCanvas } from '@/components/ui/qrcode';
+import { useToast } from '@/hooks/useToast';
+import type { CampaignWallet } from '@/lib/campaign';
+
+interface CampaignWalletDonatePanelProps {
+ /** Parsed wallet endpoint declared by the campaign's `w` tag. */
+ wallet: CampaignWallet;
+}
+
+/**
+ * Inline panel rendering the campaign's wallet endpoint as a scannable
+ * QR code, a copyable string, and an "Open in wallet" button.
+ *
+ * Behavior forks on the wallet's mode:
+ *
+ * - **on-chain** (`bc1q…` / `bc1p…`) — BIP-21 QR with the address; a
+ * public-ledger disclaimer reminds donors that the donation is
+ * traceable.
+ * - **sp** (`sp1…`) — raw silent-payment code QR; an "unlinkable by
+ * design" notice replaces the traceability disclaimer.
+ *
+ * Intentionally minimal: no amount input, no PSBT/in-app wallet flow —
+ * that's `DonateDialog`'s job. This panel is the always-available
+ * "scan and pay from any wallet" affordance.
+ */
+export function CampaignWalletDonatePanel({
+ wallet,
+}: CampaignWalletDonatePanelProps) {
+ const { toast } = useToast();
+ const [copied, setCopied] = useState(false);
+
+ // Build the QR payload. For on-chain we use BIP-21 so any wallet that
+ // recognizes the `bitcoin:` scheme can pre-fill the address; for SP we
+ // use the BIP-21 `bitcoin:?sp=` extension. Donors pick the amount in
+ // their wallet either way.
+ const qrPayload = wallet.mode === 'onchain'
+ ? `bitcoin:${wallet.value}`
+ : `bitcoin:?sp=${wallet.value}`;
+
+ const copyValue = async () => {
+ try {
+ await navigator.clipboard.writeText(wallet.value);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 1500);
+ toast({ title: wallet.mode === 'sp' ? 'Silent-payment code copied' : 'Address copied' });
+ } catch {
+ toast({
+ title: 'Copy failed',
+ description: 'Select and copy the value manually.',
+ variant: 'destructive',
+ });
+ }
+ };
+
+ return (
+
+ {/* QR — large, centered on a clean white tile with the Agora logo
+ embedded in an orange circular badge in the center.
+ Error-correction level H tolerates the centered occlusion
+ (~30% of modules can be missing and the code still scans). */}
+
+
+
+
+
+
+
+
+
+
+
+ {/* Copyable value — single line, tap to copy. No wrapping
+ container; sits flush with the rest of the column. */}
+
+
+ {wallet.value}
+
+ {copied ? (
+
+ ) : (
+
+ )}
+
+
+ {wallet.mode === 'onchain' ? (
+
+ ) : (
+
+
+
+ Silent-payment campaigns are unlinkable by design. Your donation
+ cannot be tied to the campaign by anyone other than the organizer.
+
+
+ )}
+
+ {/* Open in wallet — relies on the `bitcoin:` URI handler. SP codes
+ inside `bitcoin:?sp=` are still understood by BIP-352-aware
+ wallets. Older wallets that don't know about SP will ignore
+ the parameter and either refuse the link or show an error — at
+ which point the donor falls back to copy/paste anyway. */}
+
+
+
+ Open in wallet
+
+
+
+ );
+}
+
+/**
+ * Fallback rendered when the wallet failed to parse. The detail page
+ * should normally never reach this — `parseCampaign` rejects events
+ * without a valid `w` tag — but a defensive surface is cheap and helps
+ * debugging.
+ */
+export function CampaignWalletMissing() {
+ return (
+
+
+
This campaign is missing a valid wallet endpoint.
+
+ );
+}
diff --git a/src/components/CommentContext.tsx b/src/components/CommentContext.tsx
index 9e83463d..ab107db9 100644
--- a/src/components/CommentContext.tsx
+++ b/src/components/CommentContext.tsx
@@ -1,10 +1,10 @@
import type React from 'react';
-import { type ReactNode, useMemo, useState } from 'react';
+import { type ReactNode, useMemo } from 'react';
import { Link } from 'react-router-dom';
import { nip19 } from 'nostr-tools';
import {
Award, BarChart3, Bird, BookOpen, Camera, Clapperboard, FileText, Film,
- GitBranch, GitPullRequest, Highlighter, Mail, MapPin, Megaphone, MessageSquare, Mic, Music,
+ GitBranch, GitPullRequest, HandHeart, Highlighter, Mail, MapPin, Megaphone, MessageSquare, Mic, Music,
Package, Palette, PartyPopper, Podcast, Radio, Rocket, SmilePlus,
Stars, Target, Users, UserCheck, Vote, Zap,
} from 'lucide-react';
@@ -29,13 +29,11 @@ import { useLinkPreview } from '@/hooks/useLinkPreview';
import { useScryfallCard } from '@/hooks/useScryfallCard';
import { getDisplayName } from '@/lib/getDisplayName';
import { genUserName } from '@/lib/genUserName';
-import { getCountryInfo, getWikipediaTitle } from '@/lib/countries';
+import { getCountryInfo } from '@/lib/countries';
import { CountryFlag } from '@/components/CountryFlag';
-import { customFlagAsset, hasCustomFlag } from '@/lib/customFlags';
+import { hasCustomFlag } from '@/lib/customFlags';
import { useCountryFeed } from '@/contexts/CountryFeedContext';
import { cn } from '@/lib/utils';
-import { useFlagPalette } from '@/lib/flagPalette';
-import { useWikipediaSummary } from '@/hooks/useWikipediaSummary';
import { extractGathererCard, type GathererCard } from '@/lib/linkEmbed';
import { cardPrimaryImage } from '@/lib/scryfall';
@@ -148,6 +146,7 @@ const KIND_LABELS: Record = {
34236: 'a divine',
34550: 'an organization',
9041: 'a goal',
+ 33863: 'a campaign',
35128: 'an nsite',
36639: 'a pledge',
36787: 'a track',
@@ -205,6 +204,7 @@ const KIND_ICONS: Partial> = {
37516: 'treasure',
30621: 'constellation',
34550: 'organization',
+ 33863: 'campaign',
30054: 'episode',
30055: 'trailer',
34139: 'playlist',
@@ -912,10 +913,9 @@ function useCountryRootContext(event: NostrEvent): { iTag: string; code: string
}
/**
- * Whether the given event is rendering with country chrome (pill + flag
- * backdrop) in the current context. Useful for sibling components that want
- * to coordinate styling — e.g. NoteCard switching its text to white when a
- * flag is showing through behind the author row.
+ * Whether the given event is rendering with country chrome (the corner
+ * flag pill) in the current context. Useful for sibling components that
+ * want to coordinate styling.
*/
// eslint-disable-next-line react-refresh/only-export-components
export function useIsCountryRooted(event: NostrEvent): boolean {
@@ -939,100 +939,11 @@ export function CountryCommentPill({ event, className }: { event: NostrEvent; cl
}
/**
- * Decorative flag backdrop for country-rooted kind-1111 posts. Renders the
- * country's Wikipedia lead image (the flag, for country articles) faded
- * behind the post, echoing the country detail page's hero
- * (`CountryContentHeader` in `ExternalContentHeader.tsx`) but scaled down
- * to a card. Pairs with `CountryCommentPill`.
- *
- * Designed to be rendered as the first child of a `relative overflow-hidden`
- * parent. The wrapper is absolutely positioned at `z-0`; its foreground
- * siblings must declare `relative` (any positioned value works) so they
- * paint above the backdrop. Pointer events are disabled so the post body
- * stays fully interactive.
- *
- * The Wikipedia summary fetch is cached for 24 h across all cards
- * referencing the same country code, so a feed of N Venezuelan posts only
- * pays the network cost once.
- *
- * Visibility rules: see `useCountryRootContext` (identical to the pill).
+ * Decorative flag backdrop for country-rooted kind-1111 posts has been
+ * removed in favor of a cleaner card surface. The `CountryCommentPill`
+ * in the upper-right of the header is now the sole country chrome for
+ * world posts.
*/
-export function CountryFlagBackdrop({ event }: { event: NostrEvent }) {
- const ctx = useCountryRootContext(event);
- const info = ctx ? getCountryInfo(ctx.code) : null;
- const wikiTitle = ctx ? getWikipediaTitle(ctx.code) : null;
- const { data: wiki } = useWikipediaSummary(wikiTitle);
- // Sample dominant colors from the flag emoji at render time. Used as the
- // fallback gradient while Wikipedia is still resolving and after image
- // load failures, so the backdrop never reverts to a giant blurred emoji.
- const palette = useFlagPalette(info?.flag);
- // Track image load failures so we cleanly fall back to the flag-color
- // gradient. Wikipedia hosts these PNGs from upload.wikimedia.org which is
- // generally CORS-friendly, but hotlink-protection or transient 4xx
- // responses can still happen.
- const [imageFailed, setImageFailed] = useState(false);
-
- if (!ctx) return null;
-
- // Bundled-asset override: for codes with a curated flag SVG (currently
- // CN-XZ / Tibet) we skip Wikipedia entirely — its lead image is often
- // a parent-country map or an administrative-region inset, neither of
- // which reads as a flag behind a note. The Snow Lion SVG is what the
- // post is editorially "about", so it earns the backdrop slot.
- const bundledAsset = customFlagAsset(ctx.code);
- // For country articles Wikipedia returns the flag as the page's lead image
- // — the same source used by `CountryContentHeader`. Prefer the original
- // (full-resolution) over the 330px thumbnail; the thumbnail gets upscaled
- // and looks fuzzy when stretched across a full-width feed card.
- const flagImage = !imageFailed
- ? (bundledAsset ?? wiki?.originalImage?.source ?? wiki?.thumbnail?.source ?? null)
- : null;
-
- // Pre-built gradient using the palette (sampled from the flag emoji at
- // mount). Used as the fallback when Wikipedia hasn't returned an image or
- // its image failed to load. Single-color palettes get duplicated so
- // linear-gradient still has two stops.
- const paletteGradient =
- palette && palette.length > 0
- ? `linear-gradient(135deg, ${palette.length === 1 ? `${palette[0]}, ${palette[0]}` : palette.join(', ')})`
- : null;
-
- return (
-
-
- {flagImage ? (
- // Full-width flag banner across the top of the card. A mask-image
- // gradient fades the image to nothing at its bottom edge, so the
- // flag dissolves into the card with no hard seam.
-
setImageFailed(true)}
- className="w-full h-full object-cover opacity-20 select-none"
- style={{
- maskImage: 'linear-gradient(to bottom, black 0%, black 35%, transparent 100%)',
- WebkitMaskImage: 'linear-gradient(to bottom, black 0%, black 35%, transparent 100%)',
- }}
- />
- ) : paletteGradient ? (
- // Wikipedia not yet resolved (or its image failed) — paint the
- // flag-color gradient as a placeholder/fallback. Same opacity and
- // mask shape as the image so the visual swap is seamless when the
- // image arrives.
-
- ) : null}
-
-
- );
-}
/**
* Body-level comment context for ISO 3166 roots — intentionally renders
diff --git a/src/components/CommunityDetailPage.tsx b/src/components/CommunityDetailPage.tsx
index b02698a4..7913b4be 100644
--- a/src/components/CommunityDetailPage.tsx
+++ b/src/components/CommunityDetailPage.tsx
@@ -1,5 +1,6 @@
import { useMemo, useCallback, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
+import { useQueryClient } from '@tanstack/react-query';
import { nip19 } from 'nostr-tools';
import {
CalendarClock,
@@ -15,6 +16,7 @@ import {
Pencil,
Shield,
Share2,
+ Trash2,
UserCheck,
UserMinus,
UserPlus,
@@ -25,30 +27,42 @@ import type { NostrEvent, NostrMetadata } from '@nostrify/nostrify';
import { CampaignCard } from '@/components/CampaignCard';
import { PeopleAvatarStack } from '@/components/PeopleAvatarStack';
import { PostActionBar } from '@/components/PostActionBar';
+import { DetailCommentComposer } from '@/components/DetailCommentComposer';
+import { PinnedCommentHeader } from '@/components/PinnedCommentHeader';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from '@/components/ui/alert-dialog';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
import { Skeleton } from '@/components/ui/skeleton';
-import { DonateDialog } from '@/components/DonateDialog';
import { NoteContent } from '@/components/NoteContent';
import { FollowToggleButton } from '@/components/FollowButton';
-import { InteractionsModal, type InteractionTab } from '@/components/InteractionsModal';
import { NoteMoreMenu } from '@/components/NoteMoreMenu';
import { ReplyComposeModal } from '@/components/ReplyComposeModal';
import { ThreadedReplyList, type ReplyNode } from '@/components/ThreadedReplyList';
import { useAuthor } from '@/hooks/useAuthor';
import { useAuthors } from '@/hooks/useAuthors';
import { useComments } from '@/hooks/useComments';
-import { useBitcoinWallet } from '@/hooks/useBitcoinWallet';
+import { useBtcPrice } from '@/hooks/useBtcPrice';
import { useCommunityBookmarks } from '@/hooks/useCommunityBookmarks';
import { useCommunityMembers } from '@/hooks/useCommunityMembers';
import { useCurrentUser } from '@/hooks/useCurrentUser';
+import { useDeleteEvent } from '@/hooks/useDeleteEvent';
import { useEventStats } from '@/hooks/useTrending';
import { useNow } from '@/hooks/useNow';
import { useOrganizationActivity } from '@/hooks/useOrganizationActivity';
+import { usePinnedEventComments } from '@/hooks/usePinnedEventComments';
import { useProfileUrl } from '@/hooks/useProfileUrl';
import { useToast } from '@/hooks/useToast';
import { useEventRSVPs } from '@/hooks/useEventRSVPs';
@@ -217,7 +231,7 @@ function parseShelfLocation(raw: string): string {
function ActivityTypePill({ icon, label }: { icon: React.ReactNode; label: string }) {
return (
-
+
{icon}
{label}
@@ -225,7 +239,7 @@ function ActivityTypePill({ icon, label }: { icon: React.ReactNode; label: strin
}
function PledgeShelfCard({ pledge }: { pledge: Action }) {
- const { btcPrice } = useBitcoinWallet();
+ const { data: btcPrice } = useBtcPrice();
const author = useAuthor(pledge.pubkey);
const metadata = author.data?.metadata;
const displayName = getDisplayName(metadata, pledge.pubkey);
@@ -243,11 +257,8 @@ function PledgeShelfCard({ pledge }: { pledge: Action }) {
return (
-
-
- by
{displayName}
+
+
+ by {displayName}
+
+
} label="Pledge" />
@@ -336,11 +350,8 @@ function CalendarEventShelfCard({ event }: { event: NostrEvent }) {
return (
-
{coverImage ? (
@@ -405,8 +416,11 @@ function CalendarEventShelfCard({ event }: { event: NostrEvent }) {
) : null}
-
- by
{displayName}
+
+
+ by {displayName}
+
+
} label="Event" />
@@ -438,7 +452,7 @@ function OfficialShelf({ title, count, isLoading, isEmpty, children }: OfficialS
)}
-
+
{children}
@@ -462,11 +476,9 @@ function OfficialActivityShelves({
eventsLoading: boolean;
now: number;
}) {
- // Drop archived campaigns; mixed activity is sorted newest publish first.
- const liveCampaigns = useMemo(
- () => campaigns.filter((c) => !c.archived),
- [campaigns],
- );
+ // All loaded campaigns. Closure is via NIP-09 deletion (relay-level),
+ // so anything that reached us is current.
+ const liveCampaigns = campaigns;
// Drop expired pledges; mixed activity is sorted newest publish first.
const livePledges = useMemo(() => {
@@ -529,11 +541,12 @@ function OfficialActivityShelves({
{mixedActivity.map((item) => {
if (item.type === 'campaign') {
return (
-
-
-
} label="Campaign" />
-
-
+
+ } label="Campaign" />}
+ />
);
}
@@ -609,17 +622,15 @@ function CommunityCreateActions({
export function CommunityDetailPage({ event }: { event: NostrEvent }) {
const navigate = useNavigate();
+ const queryClient = useQueryClient();
const { toast } = useToast();
const { user } = useCurrentUser();
- const { btcPrice } = useBitcoinWallet();
-
const [membersDialogOpen, setMembersDialogOpen] = useState(false);
const [descriptionDialogOpen, setDescriptionDialogOpen] = useState(false);
- const [donateOpen, setDonateOpen] = useState(false);
const [replyOpen, setReplyOpen] = useState(false);
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
- const [interactionsOpen, setInteractionsOpen] = useState(false);
- const [interactionsTab, setInteractionsTab] = useState
('reposts');
+ const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
+ const deleteMutation = useDeleteEvent();
// Parse community definition
const community = useMemo(() => parseCommunityEvent(event), [event]);
@@ -669,29 +680,6 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
// (content bans, reports) used by the comment thread below.
const { moderation, rankMap, isLoading: membersLoading } = useCommunityMembers(community);
- const communityDonationTarget = useMemo(() => {
- if (!community) return null;
- const recipients = [
- { pubkey: community.founderPubkey, weight: 1 },
- ...community.moderatorPubkeys.map((pubkey) => ({ pubkey, weight: 1 })),
- ];
- return {
- event,
- pubkey: event.pubkey,
- identifier: community.dTag,
- aTag: community.aTag,
- title: community.name,
- summary: community.description,
- story: community.description,
- image: community.image,
- category: 'community',
- tags: ['community'],
- recipients,
- createdAt: event.created_at,
- archived: false,
- };
- }, [community, event]);
-
// Only the founder can edit organization metadata. Moderators can
// moderate content via the community context but don't get the
// "Edit community" action.
@@ -742,6 +730,12 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
// ── Comments (NIP-22 on the community event) ───────────────────────────────
const { data: commentsData, isLoading: commentsLoading } = useComments(event, 500);
+ const {
+ pinnedEvents,
+ isPinned,
+ canManagePins,
+ togglePin,
+ } = usePinnedEventComments(communityATag || undefined, event.pubkey);
// ── Official activity shelves ─────────────────────────────────────────────
// Author-filtered to founder + moderators (see useOrganizationActivity).
@@ -765,21 +759,8 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
}, [community, event.kind, event.pubkey]);
// ── Engagement stats for the community event itself ──────────────────────
- // Pulled from NIP-85. Used both for the inline counters above the action
- // bar and for the threaded-comments header. Matches the rhythm of the
- // campaign and pledge detail pages.
+ // Pulled from NIP-85 for the threaded-comments header.
const { data: engagementStats, isLoading: statsLoading } = useEventStats(event.id, event);
- const hasStats =
- !!engagementStats?.replies ||
- !!engagementStats?.reposts ||
- !!engagementStats?.quotes ||
- !!engagementStats?.reactions;
-
- const openInteractions = useCallback((tab: InteractionTab) => {
- setInteractionsTab(tab);
- setInteractionsOpen(true);
- }, []);
-
const replyTree = useMemo((): ReplyNode[] => {
if (!commentsData) return [];
const topLevel = commentsData.topLevelComments ?? [];
@@ -811,6 +792,11 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
.map((r) => buildNode(r));
}, [commentsData, moderation]);
+ const pinnedNodes = useMemo(
+ () => pinnedEvents.map((event): ReplyNode => ({ event, children: [] })),
+ [pinnedEvents],
+ );
+
// ── Share handler ───────────────────────────────────────────────────────────
const handleShare = useCallback(async () => {
const d = event.tags.find(([n]) => n === 'd')?.[1] ?? '';
@@ -828,6 +814,51 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
}
}, [event, toast]);
+ // ── Delete handler ─────────────────────────────────────────────────────────
+ // Founder-only. Publishes a NIP-09 kind 5 deletion request referencing the
+ // community definition (kind 34550) by both `e` and `a` tags so relays can
+ // drop it from both id-based and addressable lookups. After the request
+ // ships we invalidate every org-related cache so the page the user lands
+ // on (`/communities`) shows the deletion immediately, even if some relays
+ // haven't propagated yet.
+ const handleDeleteOrganization = useCallback(() => {
+ if (!community) return;
+ deleteMutation.mutate(
+ {
+ eventId: event.id,
+ eventKind: event.kind,
+ eventPubkey: event.pubkey,
+ eventDTag: community.dTag,
+ },
+ {
+ onSuccess: () => {
+ toast({
+ title: 'Organization deleted',
+ description:
+ 'A deletion request was published. Well-behaved relays will drop the organization from feeds.',
+ });
+ setDeleteConfirmOpen(false);
+ void queryClient.invalidateQueries({
+ queryKey: ['addr-event', event.kind, event.pubkey, community.dTag],
+ });
+ void queryClient.invalidateQueries({ queryKey: ['community-definition', community.aTag] });
+ void queryClient.invalidateQueries({ queryKey: ['manageable-organizations'] });
+ void queryClient.invalidateQueries({ queryKey: ['featured-organizations'] });
+ void queryClient.invalidateQueries({ queryKey: ['followed-organizations'] });
+ navigate('/communities');
+ },
+ onError: (error: unknown) => {
+ const msg = error instanceof Error ? error.message : String(error);
+ toast({
+ title: 'Could not delete organization',
+ description: msg,
+ variant: 'destructive',
+ });
+ },
+ },
+ );
+ }, [community, deleteMutation, event, navigate, queryClient, toast]);
+
useLayoutOptions({
noMaxWidth: true,
rightSidebar: null,
@@ -848,7 +879,7 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
{/* ── Hero ─────────────────────────────────────────────────────── */}
-
+
{cover ? (
) : (
@@ -970,6 +1001,18 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
Edit organization
)}
+ {isFounder && community && (
+
{
+ e.preventDefault();
+ setDeleteConfirmOpen(true);
+ }}
+ className="text-destructive focus:text-destructive focus:bg-destructive/10"
+ >
+
+ Delete organization
+
+ )}
@@ -977,21 +1020,39 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
+
+
setReplyOpen(true)}
+ onMore={() => setMoreMenuOpen(true)}
+ />
+
+
+ {pinnedNodes.length > 0 && (
+
+
+ (
+ handleTogglePin(event)}
+ />
+ )}
+ />
+
+
+ )}
+
{/* ── Body — single column, pledge-detail-style ─────────────────── */}
{/* Donate (when there's a member set) and Share buttons. Sits
just below the hero like the pledge page's action row. */}
-
- {communityDonationTarget && (
-
setDonateOpen(true)}
- >
-
- Donate
-
- )}
+
- {/* Engagement card — stats counters + post action bar. Matches
- the pledge / campaign detail layout. No funding progress bar
- here; an organization isn't a fundraising target itself. */}
+ {/* Comments — NIP-22 thread on the community event itself. */}
-
- {hasStats && (
-
- {engagementStats?.reposts ? (
- openInteractions('reposts')} className="hover:underline transition-colors">
- {formatNumber(engagementStats.reposts)} {' '}
- Repost{engagementStats.reposts !== 1 ? 's' : ''}
-
- ) : null}
- {engagementStats?.quotes ? (
- openInteractions('quotes')} className="hover:underline transition-colors">
- {formatNumber(engagementStats.quotes)} {' '}
- Quote{engagementStats.quotes !== 1 ? 's' : ''}
-
- ) : null}
- {engagementStats?.reactions ? (
- openInteractions('reactions')} className="hover:underline transition-colors">
- {formatNumber(engagementStats.reactions)} {' '}
- Like{engagementStats.reactions !== 1 ? 's' : ''}
-
- ) : null}
-
- )}
-
-
setReplyOpen(true)}
- onMore={() => setMoreMenuOpen(true)}
- className={hasStats ? 'pt-3 border-t border-border/60' : undefined}
- />
-
-
- {/* Comments — NIP-22 thread on the community event itself.
- Member-filter aware (see replyTree) and routed through
- CommunityModerationContext so per-reply ban actions work. */}
-
+
Comments
{engagementStats?.replies ? (
@@ -1074,6 +1096,8 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
) : null}
+
+
{commentsLoading && statsLoading && replyTree.length === 0 ? (
{Array.from({ length: 3 }).map((_, i) => (
@@ -1081,8 +1105,18 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
))}
) : replyTree.length > 0 ? (
-
-
+
+ (
+ handleTogglePin(event)}
+ />
+ )}
+ />
) : (
- {communityDonationTarget && (
-
- )}
-
{/* Description dialog — opened by clicking the truncated description in
the banner. Renders the full raw description plus a clickable
website link when the description ends with a URL. */}
@@ -1195,16 +1220,74 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
onOpenChange={setMoreMenuOpen}
/>
- {/* Tapping a repost / quote / like counter on the stats row opens
- a modal listing the people who took that action. */}
-
+ {/* Founder-only delete confirmation. NIP-09 is advisory — relays decide
+ whether to honor the request — so the copy makes the limitation
+ explicit and steers founders toward "Edit organization" if they
+ just want to change something. */}
+
+
+
+ Delete this organization?
+
+ This publishes a NIP-09 deletion request for{' '}
+ {name} .
+ Well-behaved relays will drop the organization from feeds and
+ direct links. Campaigns, pledges, and posts published under
+ the organization stay on-chain regardless. This action cannot
+ be undone — to change the name, banner, or moderators, edit
+ the organization instead.
+
+
+
+ Cancel
+ {
+ e.preventDefault();
+ handleDeleteOrganization();
+ }}
+ disabled={deleteMutation.isPending}
+ className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
+ >
+ {deleteMutation.isPending ? 'Deleting…' : 'Delete'}
+
+
+
+
);
+
+ function handleTogglePin(event: NostrEvent) {
+ const wasPinned = isPinned(event.id);
+ togglePin.mutate(event.id, {
+ onSuccess: () => {
+ toast({ title: wasPinned ? 'Unpinned from organization' : 'Pinned to organization' });
+ },
+ onError: () => {
+ toast({ title: 'Failed to update organization pins', variant: 'destructive' });
+ },
+ });
+ }
+}
+
+function OrganizationPinHeader({
+ isPinned,
+ canManagePins,
+ pinPending,
+ onTogglePin,
+}: {
+ isPinned: boolean;
+ canManagePins: boolean;
+ pinPending: boolean;
+ onTogglePin: () => void;
+}) {
+ return (
+
+ );
}
diff --git a/src/components/CommunityModerationMenu.tsx b/src/components/CommunityModerationMenu.tsx
new file mode 100644
index 00000000..1bc1cc4c
--- /dev/null
+++ b/src/components/CommunityModerationMenu.tsx
@@ -0,0 +1,209 @@
+import { useState } from 'react';
+import { Check, EyeOff, Eye, Loader2, MoreHorizontal, Sparkles, SparklesIcon } from 'lucide-react';
+
+import { Badge } from '@/components/ui/badge';
+import { Button } from '@/components/ui/button';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu';
+import { useCampaignModerators } from '@/hooks/useCampaignModerators';
+import { useCurrentUser } from '@/hooks/useCurrentUser';
+import { useOrganizationModeration } from '@/hooks/useOrganizationModeration';
+import { useToast } from '@/hooks/useToast';
+import type { ModerationLabel } from '@/lib/agoraModeration';
+
+interface CommunityModerationMenuProps {
+ /** The organization's `34550:
:` coordinate. */
+ coord: string;
+ /** Visible name for the organization (for toast feedback). */
+ organizationName: string;
+ className?: string;
+}
+
+/**
+ * Per-card kebab menu exposing the moderator actions for an organization:
+ *
+ * Hide / Unhide (axis = hide)
+ * Feature / Unfeature (axis = featured)
+ *
+ * Organizations intentionally do **not** have an `approved` axis — unlike
+ * campaigns, which gate homepage placement on moderator approval, every
+ * Agora-tagged organization is publicly visible by default. Moderators
+ * curate via two narrower controls: lifting an org into the Featured
+ * shelf, or suppressing it with a Hidden label.
+ *
+ * Renders `null` for users who are not Team Soapbox pack members. Sits
+ * inside the clickable `CommunityMiniCard` ` `, so the trigger
+ * swallows its own click and the dropdown content stops propagation —
+ * otherwise every menu interaction would navigate to the organization
+ * detail page.
+ *
+ * The moderation rollup is read inside this component (after the
+ * moderator gate) instead of at the parent so non-moderator viewers
+ * never subscribe to the heavy `useOrganizationModeration` query — every
+ * `CommunityMiniCard` in a grid would otherwise wake the same cache
+ * subscription up to 18+ times per page.
+ */
+export function CommunityModerationMenu({
+ coord,
+ organizationName,
+ className,
+}: CommunityModerationMenuProps) {
+ const { user } = useCurrentUser();
+ const { data: moderators } = useCampaignModerators();
+ const isMod = !!user && !!moderators && moderators.includes(user.pubkey);
+
+ // Bail before the heavy moderation query subscribes. Non-moderators
+ // (the overwhelming majority) never pay the network or render cost.
+ if (!isMod) return null;
+
+ return ;
+}
+
+function CommunityModerationMenuInner({
+ coord,
+ organizationName,
+ className,
+}: CommunityModerationMenuProps) {
+ const { data: moderation, moderate } = useOrganizationModeration();
+ const { toast } = useToast();
+ const [busy, setBusy] = useState(null);
+
+ const isHidden = moderation.hiddenCoords.has(coord);
+ const isFeatured = moderation.featuredCoords.has(coord);
+
+ const runAction = async (action: ModerationLabel, verbPast: string) => {
+ if (busy) return;
+ setBusy(action);
+ try {
+ await moderate.mutateAsync({ coord, action });
+ toast({ title: verbPast, description: organizationName });
+ } catch (error) {
+ const message = error instanceof Error ? error.message : 'Unknown error';
+ toast({
+ title: `Failed to ${action}`,
+ description: message,
+ variant: 'destructive',
+ });
+ } finally {
+ setBusy(null);
+ }
+ };
+
+ return (
+
+ e.preventDefault()}>
+
+ {busy ? : }
+
+
+ e.stopPropagation()}>
+
+ Moderator actions
+
+
+ {isFeatured ? (
+ runAction('unfeatured', 'Removed from featured')}>
+
+ Unfeature
+
+ Featured
+
+
+ ) : (
+ runAction('featured', 'Featured organization')}>
+
+ Feature
+
+ )}
+
+ {isHidden ? (
+ runAction('unhidden', 'Unhidden')}>
+
+ Unhide
+
+ Hidden
+
+
+ ) : (
+ runAction('hidden', 'Hidden')}
+ className="text-destructive focus:text-destructive"
+ >
+
+ Hide
+
+ )}
+
+
+ );
+}
+
+/**
+ * Banner-overlay wrapper for `CommunityMiniCard` cards. Renders the
+ * moderator kebab plus a "Hidden" badge when applicable, both
+ * absolutely-positioned at the card's top-right. Returns `null` for
+ * non-moderators so non-mod grids never subscribe to the moderation
+ * query at all.
+ *
+ * Pulling the overlay (and its `useOrganizationModeration` subscription)
+ * out of `CommunityMiniCard` into a single moderator-gated component is
+ * the perf win that lets `/communities` paint Featured/My orgs
+ * immediately without waiting for the moderator pack or the label query
+ * for every card on the page.
+ */
+export function CommunityModerationOverlay({
+ coord,
+ organizationName,
+}: {
+ coord: string;
+ organizationName: string;
+}) {
+ const { user } = useCurrentUser();
+ const { data: moderators } = useCampaignModerators();
+ const isMod = !!user && !!moderators && moderators.includes(user.pubkey);
+
+ if (!isMod) return null;
+
+ return (
+
+ );
+}
+
+function CommunityModerationOverlayInner({
+ coord,
+ organizationName,
+}: {
+ coord: string;
+ organizationName: string;
+}) {
+ const { data: moderation } = useOrganizationModeration();
+ const isHidden = moderation.hiddenCoords.has(coord);
+
+ return (
+
+ {isHidden && (
+
+
+ Hidden
+
+ )}
+ {/* The kebab inner uses the same moderation cache subscription, so
+ no extra round-trip is incurred. */}
+
+
+ );
+}
diff --git a/src/components/ComposeBox.tsx b/src/components/ComposeBox.tsx
index 1591a14b..ec0f042e 100644
--- a/src/components/ComposeBox.tsx
+++ b/src/components/ComposeBox.tsx
@@ -1,6 +1,6 @@
import { lazy, Suspense, useState, useRef, useCallback, useMemo, useEffect } from 'react';
import { Link } from 'react-router-dom';
-import { Paperclip, Smile, AlertTriangle, X, Loader2, Mic, Square, Sticker, BarChart3, Plus, ChevronLeft, HelpCircle } from 'lucide-react';
+import { Paperclip, Smile, AlertTriangle, X, Loader2, Mic, Square, Sticker, BarChart3, Plus, ChevronLeft, Check, Globe, HelpCircle } from 'lucide-react';
import { nip19 } from 'nostr-tools';
import { encode as blurhashEncode } from 'blurhash';
import type { NostrEvent } from '@nostrify/nostrify';
@@ -12,7 +12,21 @@ import { Input } from '@/components/ui/input';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
-import { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu';
+import {
+ CommandDialog,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+} from '@/components/ui/command';
import { useCustomEmojis } from '@/hooks/useCustomEmojis';
import { useFeedSettings } from '@/hooks/useFeedSettings';
import { GifPicker } from '@/components/GifPicker';
@@ -29,12 +43,14 @@ import { useNostrPublish } from '@/hooks/useNostrPublish';
import { usePostComment } from '@/hooks/usePostComment';
import { useUploadFile } from '@/hooks/useUploadFile';
import { useCountryFollows } from '@/hooks/useCountryFollows';
-import { getCountryInfo } from '@/lib/countries';
+import { useDefaultPostCountry } from '@/hooks/useDefaultPostCountry';
+import { COUNTRY_LIST, getCountryInfo } from '@/lib/countries';
import { createCountryIdentifier } from '@/lib/countryIdentifiers';
import { useQueryClient } from '@tanstack/react-query';
import { useToast } from '@/hooks/useToast';
import { useAppContext } from '@/hooks/useAppContext';
-import type { EventStats } from '@/hooks/useTrending';
+import type { Nip85EventStats } from '@/hooks/useNip85Stats';
+import { invalidateEventStats } from '@/lib/invalidateEventStats';
import { cn } from '@/lib/utils';
import { notificationSuccess } from '@/lib/haptics';
import { extractVideoUrls, extractAudioUrls, IMETA_MEDIA_URL_REGEX, IMETA_MEDIA_URL_TEST_REGEX, mimeFromExt } from '@/lib/mediaUrls';
@@ -50,6 +66,7 @@ import { DITTO_RELAY } from '@/lib/appRelays';
import { resizeImage } from '@/lib/resizeImage';
import { extractHashtags } from '@/lib/hashtag';
import { useIsMobile } from '@/hooks/useIsMobile';
+import { AGORA_DEFAULT_NOTE_TAGS } from '@/lib/agoraNoteTags';
const MAX_CHARS = 5000;
@@ -156,6 +173,18 @@ interface ComposeBoxProps {
hidePoll?: boolean;
/** Label for the primary submit button. */
submitLabel?: string;
+ /**
+ * Tags added to new top-level kind 1 notes without putting them in content.
+ *
+ * Defaults to {@link AGORA_DEFAULT_NOTE_TAGS} (the silent `t:agora` tag) when
+ * the composer is producing a top-level kind 1 note (no replyTo, not a quote,
+ * not poll mode, no custom publish, no country-scoped destination). Replies,
+ * quotes, polls, comments, and custom-kind publishes do not receive these
+ * tags regardless of this prop. Pass `[]` to opt out explicitly.
+ */
+ defaultTags?: string[][];
+ /** If true, the composer starts expanded without taking modal/flex behavior. */
+ defaultExpanded?: boolean;
}
/** Circular progress ring for character count. */
@@ -214,6 +243,8 @@ export function ComposeBox({
customPublish,
hidePoll = false,
submitLabel = 'Post!',
+ defaultTags,
+ defaultExpanded = false,
}: ComposeBoxProps) {
const { user, metadata, isLoading: isProfileLoading } = useCurrentUser();
const userProfileUrl = useProfileUrl(user?.pubkey ?? '', metadata);
@@ -228,6 +259,7 @@ export function ComposeBox({
const { toast } = useToast();
const { config } = useAppContext();
const imageQuality = config.imageQuality;
+ const statsPubkey = config.nip85StatsPubkey;
const isMobile = useIsMobile();
// Build a stable localStorage key based on compose context.
@@ -248,7 +280,7 @@ export function ComposeBox({
return '';
}
});
- const [expanded, setExpanded] = useState(false);
+ const [expanded, setExpanded] = useState(defaultExpanded);
const [cwEnabled, setCwEnabled] = useState(false);
const [cwText, setCwText] = useState('');
const [pickerOpen, setPickerOpen] = useState(false);
@@ -263,23 +295,39 @@ export function ComposeBox({
// from the home feed (no replyTo, not a custom-kind publish). When a
// country code is selected, the post is published as a NIP-22 kind
// 1111 comment rooted on that country instead of a plain kind 1 note.
- // Dropdown lists only the countries the user follows, with "Global"
- // always at the top.
+ //
+ // The dropdown shows: Global + the countries the user follows (quick
+ // picks) + a "Choose another country…" item that opens a searchable
+ // dialog over the full country list. So a user can post about any
+ // country, even one they don't follow.
const { followedCountries } = useCountryFollows();
const canChooseDestination =
- !replyTo && !customPublish && mode === 'post' && !!user && followedCountries.length > 0;
+ !replyTo && !customPublish && mode === 'post' && !!user;
+ /**
+ * User's saved default destination (persisted to localStorage). Used as
+ * the initial value of `destination` on every fresh compose, and updated
+ * when the user clicks "Set as default" in the destination menu.
+ */
+ const [defaultPostCountry, setDefaultPostCountry] = useDefaultPostCountry();
/** `'world'` for a regular kind-1 note, or an ISO 3166 country code for a kind-1111 community post. */
- const [destination, setDestination] = useState<'world' | string>('world');
+ const [destination, setDestination] = useState<'world' | string>(defaultPostCountry);
+ /** Open state for the "Choose another country" searchable picker dialog. */
+ const [countryPickerOpen, setCountryPickerOpen] = useState(false);
const selectedCountryCode = destination !== 'world' ? destination : null;
const selectedCountryInfo = selectedCountryCode ? getCountryInfo(selectedCountryCode) : null;
- // If the user unfollows the currently-selected country mid-session,
- // snap back to world so we don't try to publish a kind 1111 with
- // a root the user no longer cares about.
+ // Snap back to world if the currently selected destination is an
+ // invalid ISO code (e.g. a previously-followed country that was later
+ // removed from the country directory). Picking a non-followed but
+ // valid country is allowed — users can post about any country via the
+ // "Choose another country" picker, so following is not a prerequisite.
useEffect(() => {
- if (selectedCountryCode && !followedCountries.includes(selectedCountryCode)) {
+ if (selectedCountryCode && !getCountryInfo(selectedCountryCode)) {
setDestination('world');
+ if (defaultPostCountry === selectedCountryCode) {
+ setDefaultPostCountry('world');
+ }
}
- }, [selectedCountryCode, followedCountries]);
+ }, [selectedCountryCode, defaultPostCountry, setDefaultPostCountry]);
const [pollOptions, setPollOptions] = useState([
{ id: pollOptionId(), label: '' },
{ id: pollOptionId(), label: '' },
@@ -305,7 +353,7 @@ export function ComposeBox({
setContent('');
setCwEnabled(false);
setCwText('');
- setExpanded(false);
+ setExpanded(defaultExpanded);
setPickerOpen(false);
setTrayOpen(false);
setInternalPreviewMode(false);
@@ -317,10 +365,10 @@ export function ComposeBox({
setUploadedFileGroups(new Map());
setWebxdcUuids(new Map());
setWebxdcMetas(new Map());
- setDestination('world');
+ setDestination(defaultPostCountry);
// Clear the auto-saved draft
try { localStorage.removeItem(draftKey); } catch { /* ignore */ }
- }, [initialMode, draftKey]);
+ }, [initialMode, draftKey, defaultExpanded, defaultPostCountry]);
// Use controlled preview mode if provided, otherwise use internal state
const previewMode = controlledPreviewMode !== undefined ? controlledPreviewMode : internalPreviewMode;
@@ -862,6 +910,11 @@ export function ComposeBox({
// Reset state
queryClient.invalidateQueries({ queryKey: ['feed'] });
+ // Voice messages can surface in the home Agora activity feed (via
+ // the `t:Agora` marker on root messages and through the comment
+ // path on replies). Refresh both home feed queries.
+ queryClient.invalidateQueries({ queryKey: ['agora-feed'] });
+ queryClient.invalidateQueries({ queryKey: ['mixed-feed'] });
if (replyTo) {
if (isExternalRoot(replyTo)) {
queryClient.invalidateQueries({ queryKey: ['nostr', 'comments'] });
@@ -870,7 +923,13 @@ export function ComposeBox({
if (replyTo.kind !== 1) {
queryClient.invalidateQueries({ queryKey: ['nostr', 'comments'] });
}
+ // Bump comment count on the parent event so the UI updates.
+ invalidateEventStats(queryClient, replyTo, statsPubkey);
}
+ } else if (canChooseDestination && selectedCountryCode) {
+ // Root voice message published to a country community feed.
+ queryClient.invalidateQueries({ queryKey: ['agora-feed-paginated', selectedCountryCode] });
+ queryClient.invalidateQueries({ queryKey: ['agora-feed-new-posts', selectedCountryCode] });
}
notificationSuccess();
toast({ title: 'Voice message sent!', description: 'Your voice message has been published.' });
@@ -880,7 +939,7 @@ export function ComposeBox({
} finally {
setIsPublishingVoice(false);
}
- }, [user, voiceRecorder, uploadFile, buildContentWarningTags, customPublish, createEvent, onPublished, replyTo, queryClient, toast, onSuccess]);
+ }, [user, voiceRecorder, uploadFile, buildContentWarningTags, customPublish, createEvent, onPublished, replyTo, queryClient, toast, onSuccess, canChooseDestination, selectedCountryCode, statsPubkey]);
const handleSubmit = async () => {
if (!content.trim() || !user || charCount > MAX_CHARS) return;
@@ -1108,22 +1167,49 @@ export function ComposeBox({
const countryRoot = new URL(createCountryIdentifier(selectedCountryCode));
await postComment({ root: countryRoot, reply: undefined, content: finalContent, tags });
} else {
+ // Top-level kind 1 note. If the caller hasn't supplied `defaultTags`,
+ // auto-attach the silent Agora tag so the post surfaces in the Agora
+ // activity feed. Callers can opt out by passing `defaultTags={[]}`.
+ const effectiveDefaultTags = defaultTags ?? AGORA_DEFAULT_NOTE_TAGS;
await createEvent({
kind: 1,
content: finalContent,
- tags,
+ tags: [...effectiveDefaultTags, ...tags],
created_at: Math.floor(Date.now() / 1000),
});
}
resetComposeState();
- // Optimistically bump the reply count on the parent event
+ // Optimistically bump the comment count on the parent event
if (replyTo && !isExternalRoot(replyTo)) {
- queryClient.setQueryData(['event-stats', replyTo.id], (prev) =>
- prev ? { ...prev, replies: prev.replies + 1 } : prev,
+ queryClient.setQueryData(
+ ['nip85-event-stats', replyTo.id, statsPubkey],
+ (prev) => prev ? { ...prev, commentCount: prev.commentCount + 1 } : prev,
);
}
queryClient.invalidateQueries({ queryKey: ['feed'] });
+ // Top-level kind 1 posts with the silent Agora tag (the default for
+ // user-authored notes) surface in the home Agora activity feed
+ // (useAgoraFeed / mixed-feed). Invalidate both so the post appears
+ // there without a refresh — over-invalidation is cheap here.
+ queryClient.invalidateQueries({ queryKey: ['agora-feed'] });
+ queryClient.invalidateQueries({ queryKey: ['mixed-feed'] });
+ // Top-level kind 1 posts surface on country pages too. Country posts
+ // route through `usePostComment` (which handles its own invalidation),
+ // but the top-level branch above publishes via `createEvent`, so we
+ // need to invalidate the country feed keys here. `selectedCountryCode`
+ // is null for global posts, in which case nothing extra needs to
+ // refresh (the global Agora feed is served by relays, not a per-country
+ // query). For drafts attached to a specific country via customPublish
+ // we conservatively invalidate the broader prefix.
+ if (canChooseDestination && selectedCountryCode && !replyTo) {
+ queryClient.invalidateQueries({ queryKey: ['agora-feed-paginated', selectedCountryCode] });
+ queryClient.invalidateQueries({ queryKey: ['agora-feed-new-posts', selectedCountryCode] });
+ setTimeout(() => {
+ queryClient.invalidateQueries({ queryKey: ['agora-feed-paginated', selectedCountryCode] });
+ queryClient.invalidateQueries({ queryKey: ['agora-feed-new-posts', selectedCountryCode] });
+ }, 3000);
+ }
if (replyTo) {
if (isExternalRoot(replyTo)) {
queryClient.invalidateQueries({ queryKey: ['nostr', 'comments'] });
@@ -1138,7 +1224,7 @@ export function ComposeBox({
}
}
if (quotedEvent) {
- queryClient.invalidateQueries({ queryKey: ['event-stats', quotedEvent.id] });
+ invalidateEventStats(queryClient, quotedEvent, statsPubkey);
queryClient.invalidateQueries({ queryKey: ['event-interactions', quotedEvent.id] });
}
notificationSuccess();
@@ -1200,6 +1286,19 @@ export function ComposeBox({
await createEvent({ kind: 1068, content: finalContent, tags });
resetComposeState();
queryClient.invalidateQueries({ queryKey: ['feed'] });
+ // World-layer polls (iso3166 root) and Agora-marked polls surface
+ // in the home Agora activity feed.
+ queryClient.invalidateQueries({ queryKey: ['agora-feed'] });
+ queryClient.invalidateQueries({ queryKey: ['mixed-feed'] });
+ // Polls published with an iso3166 root surface on the country feed.
+ if (replyTo instanceof URL && replyTo.protocol === 'iso3166:') {
+ const countryCode = replyTo.pathname.toUpperCase();
+ queryClient.invalidateQueries({ queryKey: ['agora-feed-paginated', countryCode] });
+ queryClient.invalidateQueries({ queryKey: ['agora-feed-new-posts', countryCode] });
+ } else if (canChooseDestination && selectedCountryCode) {
+ queryClient.invalidateQueries({ queryKey: ['agora-feed-paginated', selectedCountryCode] });
+ queryClient.invalidateQueries({ queryKey: ['agora-feed-new-posts', selectedCountryCode] });
+ }
notificationSuccess();
toast({ title: 'Poll published!' });
onSuccess?.();
@@ -1531,14 +1630,14 @@ export function ComposeBox({
})()}
-
-
+
{/* Show just the flag in the trigger to keep the row
@@ -1547,28 +1646,145 @@ export function ComposeBox({
{selectedCountryInfo?.flag ?? '🌍'}
-
-
-
-
+
+
+ setDestination('world')}
+ className="cursor-pointer"
+ >
+
🌍
Global
-
- {followedCountries.map((code) => {
- const info = getCountryInfo(code);
- if (!info) return null;
- return (
-
-
- {info.flag}
- {info.name}
-
-
- );
- })}
-
-
+ {destination === 'world' && (
+
+ )}
+
+ {/* Build the quick-pick list. Followed countries appear first;
+ if the user has selected an ad-hoc country via the
+ searchable picker that they don't follow, show it too so
+ they have a one-tap way back to it. De-duplicates by code. */}
+ {(() => {
+ const codes = new Set();
+ const quickPicks: string[] = [];
+ for (const code of followedCountries) {
+ if (!codes.has(code) && getCountryInfo(code)) {
+ codes.add(code);
+ quickPicks.push(code);
+ }
+ }
+ if (selectedCountryCode && !codes.has(selectedCountryCode) && getCountryInfo(selectedCountryCode)) {
+ quickPicks.push(selectedCountryCode);
+ }
+ return quickPicks.map((code) => {
+ const info = getCountryInfo(code);
+ if (!info) return null;
+ return (
+ setDestination(code)}
+ className="cursor-pointer"
+ >
+
+ {info.flag}
+ {info.name}
+
+ {destination === code && (
+
+ )}
+
+ );
+ });
+ })()}
+
+ {
+ e.preventDefault();
+ setCountryPickerOpen(true);
+ }}
+ className="cursor-pointer text-sm"
+ >
+
+ Choose another country…
+
+
+ {destination === defaultPostCountry ? (
+
+ {(() => {
+ if (defaultPostCountry === 'world') return 'Global is your default';
+ const info = getCountryInfo(defaultPostCountry);
+ return info ? `${info.name} is your default` : 'This is your default';
+ })()}
+
+ ) : (
+ {
+ setDefaultPostCountry(destination);
+ const info = destination === 'world'
+ ? null
+ : getCountryInfo(destination);
+ toast({
+ title: 'Default updated',
+ description: info
+ ? `New posts will go to ${info.name} by default.`
+ : 'New posts will be global by default.',
+ });
+ }}
+ className="cursor-pointer text-sm"
+ >
+ Set as default
+
+ )}
+
+
+
+ {/* Searchable picker over the full country list. Opened from the
+ "Choose another country…" item in the destination dropdown,
+ so users can post to any country without having to follow it
+ first. */}
+
+
+
+ No countries found.
+
+ {
+ setDestination('world');
+ setCountryPickerOpen(false);
+ }}
+ >
+ 🌍
+ Global
+ {destination === 'world' && (
+
+ )}
+
+ {COUNTRY_LIST.map((country) => (
+ {
+ setDestination(country.code);
+ setCountryPickerOpen(false);
+ }}
+ >
+ {country.flag}
+ {country.name}
+ {country.code}
+ {destination === country.code && (
+
+ )}
+
+ ))}
+
+
+
)}
@@ -1769,7 +1985,7 @@ export function ComposeBox({
{isPollPending ? 'Publishing...' : 'Publish poll'}
@@ -1778,7 +1994,7 @@ export function ComposeBox({
MAX_CHARS}
- className="rounded-full px-5 font-bold"
+ className="rounded-full px-5 font-bold text-white"
size="sm"
>
{isPending || isCommentPending ? 'Posting...' : submitLabel}
diff --git a/src/components/ContentSettings.tsx b/src/components/ContentSettings.tsx
index 899a4d33..6d3cf100 100644
--- a/src/components/ContentSettings.tsx
+++ b/src/components/ContentSettings.tsx
@@ -1,8 +1,7 @@
-import { useState } from 'react';
-import { IntroImage } from '@/components/IntroImage';
+import { useState, type ReactNode } from 'react';
import {
Users, Download, Loader2, X, Pencil, Home, Globe, MapPin,
- Palette, Trash2, Plus, UserX, Hash, MessageSquareOff, ExternalLink, ShieldAlert,
+ Trash2, Plus, UserX, Hash, MessageSquareOff, ExternalLink,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -26,220 +25,108 @@ import { useAuthor } from '@/hooks/useAuthor';
import { FeedEditModal } from '@/components/FeedEditModal';
import { buildKindOptions } from '@/lib/feedFilterUtils';
import { genUserName } from '@/lib/genUserName';
-import { EXTRA_KINDS, FEED_KINDS, SECTION_ORDER, SECTION_LABELS } from '@/lib/extraKinds';
-import { CONTENT_KIND_ICONS, SIDEBAR_ITEMS } from '@/lib/sidebarItems';
-import type { SavedFeed, TabFilter, ContentWarningPolicy } from '@/contexts/AppContext';
-import type { ExtraKindDef, SubKindDef } from '@/lib/extraKinds';
+import { EXTRA_KINDS } from '@/lib/extraKinds';
+import { SIDEBAR_ITEMS } from '@/lib/sidebarItems';
+import type { FeedSettings, SavedFeed, TabFilter, ContentWarningPolicy } from '@/contexts/AppContext';
+import type { ExtraKindDef } from '@/lib/extraKinds';
export function ContentSettings() {
return (
-
- {/* Intro */}
-
-
What You See
-
- Customize your feed, choose what content appears, and control what you want to hide.
-
-
-
- {/* Homepage Section */}
+
- {/* Feed Tabs Section */}
-
+
- {/* Notes Section */}
-
-
-
Basic Home Feed Options
-
-
-
-
-
- Core content types that appear in your feed.
-
-
+
- {/* Column headers */}
-
- Feed
-
-
-
-
-
-
- {/* Other Stuff Section */}
-
-
-
Show More Content Types in Home Feed
-
-
-
- {/* Intro section for Other Stuff */}
-
-
-
-
Other Stuff
-
- Nostr isn't just text posts — people publish all kinds of things. Pick what shows up in your sidebar and feed.
-
-
-
-
- {/* Column headers */}
-
- Feed
-
-
- {/* Content type rows - reuse the internals from FeedSettingsForm */}
-
-
-
+
+
);
}
-
-
-function KindBadge({ kind }: { kind: number }) {
+function Section({ title, children }: { title: string; children: ReactNode }) {
return (
-
- [{kind}]
-
+
);
}
-function SubKindRow({ sub }: { sub: SubKindDef }) {
- const { feedSettings, updateFeedSettings } = useFeedSettings();
- const { updateSettings } = useEncryptedSettings();
- const { user } = useCurrentUser();
-
- const handleToggle = async (key: string, value: boolean) => {
- updateFeedSettings({ [key]: value });
- if (user) {
- await updateSettings.mutateAsync({ feedSettings: { ...feedSettings, [key]: value } });
- }
- };
+function FlatContentList() {
+ // Flat, ordered list of curated kinds. No section grouping, no sub-rows, no kind badges.
+ const orderedIds = [
+ 'posts', 'replies', 'reposts', 'articles', 'highlights',
+ 'photos', 'videos', 'voice',
+ 'events', 'polls', 'communities', 'badges',
+ 'reactions', 'zaps',
+ ];
+ const byId = new Map(EXTRA_KINDS.map((def) => [def.id, def]));
+ // Replies is id 'comments' in the registry; alias here for readability.
+ byId.set('replies', byId.get('comments')!);
+ const rows = orderedIds.map((id) => byId.get(id)).filter((d): d is ExtraKindDef => !!d && !!d.agora);
return (
-
-
-
{sub.label}
-
- {' '}{sub.description}
-
-
-
-
- handleToggle(sub.feedKey, checked)}
- className="scale-90"
- />
-
-
-
+
+ {rows.map((def) => (
+
+
+
+ ))}
+
);
}
+
+
function ContentTypeRow({ def }: { def: ExtraKindDef }) {
const { feedSettings, updateFeedSettings } = useFeedSettings();
const { updateSettings } = useEncryptedSettings();
const { user } = useCurrentUser();
- const IconComponent = CONTENT_KIND_ICONS[def.id] ?? Palette;
- const icon =
;
- const hasSubKinds = !!def.subKinds;
- const handleToggle = async (key: string, value: boolean) => {
- updateFeedSettings({ [key]: value });
+ // Toggle key: prefer the feed inclusion key; fall back to the sidebar visibility key
+ // for kinds that have no direct feed key of their own (e.g. parent kinds with sub-kinds).
+ const toggleKey: keyof FeedSettings | undefined = def.feedKey ?? def.showKey;
+ if (!toggleKey) return null;
+
+ const checked = feedSettings[toggleKey] !== false;
+
+ const handleToggle = async (value: boolean) => {
+ const next: Partial
= { [toggleKey]: value };
+ // Parent kinds with sub-kinds: toggle all sub-kind feed keys together so the
+ // single parent switch governs everything below it.
+ if (def.subKinds) {
+ for (const sub of def.subKinds) {
+ next[sub.feedKey] = value;
+ }
+ }
+ updateFeedSettings(next);
if (user) {
- await updateSettings.mutateAsync({ feedSettings: { ...feedSettings, [key]: value } });
+ await updateSettings.mutateAsync({ feedSettings: { ...feedSettings, ...next } });
}
};
return (
-
-
-
-
{icon}
-
-
{def.label}
-
- {' '}{def.description}
-
-
-
-
-
- {!hasSubKinds && def.feedKey ? (
- handleToggle(def.feedKey!, checked)}
- />
- ) : !hasSubKinds && def.feedOnly && def.showKey ? (
- handleToggle(def.showKey!, checked)}
- />
- ) : null}
-
-
+
+
+
{def.label}
+
{def.description}
-
- {hasSubKinds && def.subKinds && def.subKinds.map((sub) => (
-
- ))}
+
);
}
-function NotesFeedSettings() {
- return (
- <>
- {FEED_KINDS.map((def) => (
-
- ))}
- >
- );
-}
-
-function FeedSettingsFormInternals() {
- return (
- <>
- {SECTION_ORDER.map((section) => {
- const sectionKinds = EXTRA_KINDS.filter((def) => def.section === section);
- if (sectionKinds.length === 0) return null;
- return (
-
-
-
- {SECTION_LABELS[section]}
-
-
- {sectionKinds.map((def) => (
-
- ))}
-
- );
- })}
- >
- );
-}
-
// Feed Tabs Section Component
function FeedTabsSection() {
const { toast } = useToast();
@@ -407,14 +294,11 @@ function FeedTabsSection() {
return (
{/* Intro section for Feed Tabs */}
-
-
-
-
Feed Navigation
-
- Manage which feed tabs appear in your navigation and follow communities by domain.
-
-
+
+
Feed Navigation
+
+ Manage which feed tabs appear in your navigation and follow communities by domain.
+
{/* Feed Tab Toggles */}
@@ -922,16 +806,10 @@ export function SensitiveContentSection() {
return (
{/* Intro */}
-
-
-
-
-
-
Content Warnings
-
- Some posts are tagged with content warnings (NIP-36) by their authors. This can include NSFW material, spoilers, or other sensitive content.
-
-
+
+
+ Some posts are tagged by their authors as sensitive — NSFW, graphic, or otherwise needing a content warning. Choose how to handle them.
+
{/* Policy options — consistent row style with other settings */}
diff --git a/src/components/CoverImageField.tsx b/src/components/CoverImageField.tsx
index 7e968bf5..4f387f54 100644
--- a/src/components/CoverImageField.tsx
+++ b/src/components/CoverImageField.tsx
@@ -25,6 +25,14 @@ interface CoverImageFieldProps {
onChange: (url: string) => void;
/** Notifies parent forms so they can block submit while Blossom upload runs. */
onUploadingChange?: (uploading: boolean) => void;
+ /**
+ * Fires after a successful Blossom upload with the NIP-94-style tag
+ * array returned by `useUploadFile`:
+ * `[["url", "
"], ["x", ""], ["ox", ""], ["size", ""], ["m", "image/jpeg"]]`.
+ * Parents that want to publish a paired NIP-92 `imeta` tag in their
+ * Nostr event should convert this array — see Kind 33863 publishing.
+ */
+ onUploadComplete?: (nip94Tags: string[][]) => void;
/** Optional template gallery shown between the dropzone and the URL input. */
templates?: readonly CoverImageTemplate[];
}
@@ -45,7 +53,7 @@ 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, templates }: CoverImageFieldProps) {
+export function CoverImageField({ value, onChange, onUploadingChange, onUploadComplete, templates }: CoverImageFieldProps) {
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
const { toast } = useToast();
const [isDragging, setIsDragging] = useState(false);
@@ -71,8 +79,21 @@ export function CoverImageField({ value, onChange, onUploadingChange, templates
return;
}
try {
- const [[, url]] = await uploadFile(file);
+ const tags = await uploadFile(file);
+ const [[, url]] = tags;
onChange(url);
+ // Forward the raw NIP-94 tag array to the parent so it can build a
+ // paired NIP-92 imeta tag. The URL inside the tags is what Blossom
+ // returned; the parent's `value` may pick up an appended extension
+ // via the useUploadFile post-processing, but the sha256 ("x") still
+ // identifies the same byte stream.
+ if (onUploadComplete) {
+ // Replace the URL in the first tag with the extension-corrected
+ // value the parent now holds (matches the rendered banner src).
+ const adjusted = tags.map((t) => [...t]);
+ if (adjusted[0]?.[0] === 'url') adjusted[0][1] = url;
+ onUploadComplete(adjusted);
+ }
} catch (error) {
toast({
title: 'Upload failed',
diff --git a/src/components/CreateActionDialog.tsx b/src/components/CreateActionDialog.tsx
index 74df7785..17d9672b 100644
--- a/src/components/CreateActionDialog.tsx
+++ b/src/components/CreateActionDialog.tsx
@@ -298,6 +298,7 @@ export function CreateActionDialog({ countryCode, communityATag, open, onOpenCha
if (communityATag) {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['community-actions', communityATag] }),
+ queryClient.invalidateQueries({ queryKey: ['organization-activity', communityATag] }),
queryClient.invalidateQueries({
predicate: (q) => {
const [root, aTagsKey] = q.queryKey;
@@ -308,6 +309,9 @@ export function CreateActionDialog({ countryCode, communityATag, open, onOpenCha
}),
]);
}
+ // Pledges (kind 36639) surface in the home Agora activity feed.
+ await queryClient.invalidateQueries({ queryKey: ['agora-feed'] });
+ await queryClient.invalidateQueries({ queryKey: ['mixed-feed'] });
setFormData({
title: '', description: '', tagInput: '', pledgeUsd: '',
diff --git a/src/components/CreateCommunityEventDialog.tsx b/src/components/CreateCommunityEventDialog.tsx
index dd134e1e..f90c03dd 100644
--- a/src/components/CreateCommunityEventDialog.tsx
+++ b/src/components/CreateCommunityEventDialog.tsx
@@ -25,6 +25,7 @@ import { useToast } from '@/hooks/useToast';
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
import { createOrganizationAssociationTags } from '@/lib/organizationContext';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
+import { withAgoraTag } from '@/lib/agoraNoteTags';
interface CreateCommunityEventDialogProps {
communityATag?: string;
@@ -307,7 +308,7 @@ export function CreateCommunityEventDialog({ communityATag, open, onOpenChange,
const publishedEvent = await publishEvent({
kind,
content: description.trim(),
- tags,
+ tags: withAgoraTag(tags),
prev: prev ?? undefined,
});
diff --git a/src/components/CreateGoalDialog.tsx b/src/components/CreateGoalDialog.tsx
index f1078ac9..b818f319 100644
--- a/src/components/CreateGoalDialog.tsx
+++ b/src/components/CreateGoalDialog.tsx
@@ -20,6 +20,7 @@ import { getEffectiveRelays } from '@/lib/appRelays';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import { useQueryClient } from '@tanstack/react-query';
import { ZAP_GOAL_KIND } from '@/lib/goalUtils';
+import { withAgoraTag } from '@/lib/agoraNoteTags';
interface CreateGoalDialogProps {
/** The community `a` tag coordinate (e.g. `34550::`). */
@@ -116,7 +117,7 @@ export function CreateGoalDialog({ communityATag, children, open: controlledOpen
await publishEvent({
kind: ZAP_GOAL_KIND,
content: title.trim(),
- tags,
+ tags: withAgoraTag(tags),
});
// Refresh the goals tab and the community activity feed
diff --git a/src/components/CursorFireEffect.tsx b/src/components/CursorFireEffect.tsx
deleted file mode 100644
index 2c4b6819..00000000
--- a/src/components/CursorFireEffect.tsx
+++ /dev/null
@@ -1,257 +0,0 @@
-import { useEffect, useRef } from 'react';
-
-interface Particle {
- x: number;
- y: number;
- vx: number;
- vy: number;
- life: number;
- size: number;
-}
-
-interface Ring {
- x: number;
- y: number;
- radius: number;
- maxRadius: number;
- life: number; // 1 → 0
-}
-
-function parseHslString(hsl: string): { h: number; s: number; l: number } {
- const parts = hsl.trim().split(/\s+/);
- return {
- h: parseFloat(parts[0] ?? '30'),
- s: parseFloat(parts[1] ?? '100'),
- l: parseFloat(parts[2] ?? '55'),
- };
-}
-
-export function CursorFireEffect() {
- const canvasRef = useRef(null);
- const particles = useRef([]);
- const rings = useRef([]);
- const cursor = useRef<{ x: number; y: number } | null>(null);
- const active = useRef(false);
- const raf = useRef(0);
- const pulse = useRef(0);
- const frame = useRef(0);
-
- useEffect(() => {
- const canvas = canvasRef.current;
- if (!canvas) return;
- const ctx = canvas.getContext('2d');
- if (!ctx) return;
-
- function resize() {
- if (!canvas) return;
- canvas.width = window.innerWidth;
- canvas.height = window.innerHeight;
- }
- resize();
- window.addEventListener('resize', resize);
-
- function onMouseMove(e: MouseEvent) {
- cursor.current = { x: e.clientX, y: e.clientY };
- active.current = true;
- }
- function onTouchMove(e: TouchEvent) {
- const t = e.touches[0];
- if (t) { cursor.current = { x: t.clientX, y: t.clientY }; active.current = true; }
- }
- function onLeave() { active.current = false; }
-
- function onClick(e: MouseEvent) {
- spawnClickBurst(e.clientX, e.clientY);
- }
- function onTouchStart(e: TouchEvent) {
- const t = e.touches[0];
- if (t) spawnClickBurst(t.clientX, t.clientY);
- }
-
- window.addEventListener('mousemove', onMouseMove);
- window.addEventListener('touchmove', onTouchMove, { passive: true });
- window.addEventListener('mouseleave', onLeave);
- window.addEventListener('touchend', onLeave);
- window.addEventListener('click', onClick);
- window.addEventListener('touchstart', onTouchStart, { passive: true });
-
- function getPrimary() {
- const raw = getComputedStyle(document.documentElement).getPropertyValue('--primary').trim();
- return raw ? parseHslString(raw) : { h: 270, s: 80, l: 60 };
- }
-
- function spawnWispParticles(x: number, y: number) {
- const count = Math.floor(Math.random() * 2) + 2;
- for (let i = 0; i < count; i++) {
- const angle = -Math.PI / 2 + (Math.random() - 0.5) * 0.3;
- const speed = Math.random() * 0.6 + 0.3;
- particles.current.push({
- x: x + (Math.random() - 0.5) * 6,
- y,
- vx: Math.cos(angle) * speed * 0.2,
- vy: Math.sin(angle) * speed,
- life: 1,
- size: Math.random() * 28 + 20,
- });
- }
- }
-
- function spawnClickBurst(x: number, y: number) {
- // Expanding shockwave ring
- rings.current.push({ x, y, radius: 0, maxRadius: 120, life: 1 });
-
- // Secondary smaller ring
- rings.current.push({ x, y, radius: 0, maxRadius: 60, life: 1 });
-
- // Radial burst of particles in all directions
- const count = 18;
- for (let i = 0; i < count; i++) {
- const angle = (i / count) * Math.PI * 2;
- const speed = Math.random() * 3.5 + 1.5;
- particles.current.push({
- x,
- y,
- vx: Math.cos(angle) * speed,
- vy: Math.sin(angle) * speed,
- life: 1,
- size: Math.random() * 20 + 12,
- });
- }
-
- // Extra upward plume
- for (let i = 0; i < 8; i++) {
- const angle = -Math.PI / 2 + (Math.random() - 0.5) * 0.8;
- const speed = Math.random() * 4 + 2;
- particles.current.push({
- x: x + (Math.random() - 0.5) * 10,
- y,
- vx: Math.cos(angle) * speed * 0.3,
- vy: Math.sin(angle) * speed,
- life: 1,
- size: Math.random() * 30 + 18,
- });
- }
- }
-
- function draw() {
- if (!canvas || !ctx) return;
- ctx.clearRect(0, 0, canvas.width, canvas.height);
-
- const { h, s, l } = getPrimary();
-
- // Spawn wisp particles every 4th frame
- frame.current++;
- if (active.current && cursor.current && frame.current % 4 === 0) {
- spawnWispParticles(cursor.current.x, cursor.current.y);
- }
-
- ctx.globalCompositeOperation = 'screen';
-
- // Draw expanding rings
- const aliveRings: Ring[] = [];
- for (const r of rings.current) {
- r.life -= 0.022;
- if (r.life <= 0) continue;
- r.radius += (r.maxRadius - r.radius) * 0.08;
-
- const t = r.life;
- const lineAlpha = Math.pow(t, 1.5) * 0.8;
- const glowAlpha = Math.pow(t, 2) * 0.4;
- const lineWidth = t * 3;
-
- // Outer glow halo
- ctx.beginPath();
- ctx.arc(r.x, r.y, r.radius, 0, Math.PI * 2);
- ctx.strokeStyle = `hsla(${h}, ${s}%, ${Math.min(l + 20, 85)}%, ${glowAlpha})`;
- ctx.lineWidth = lineWidth + 8;
- ctx.stroke();
-
- // Sharp ring
- ctx.beginPath();
- ctx.arc(r.x, r.y, r.radius, 0, Math.PI * 2);
- ctx.strokeStyle = `hsla(${h - 10}, ${s}%, 90%, ${lineAlpha})`;
- ctx.lineWidth = lineWidth;
- ctx.stroke();
-
- aliveRings.push(r);
- }
- rings.current = aliveRings;
-
- // Draw flame particles
- const alive: Particle[] = [];
- for (const p of particles.current) {
- p.life -= 0.005 + Math.random() * 0.002;
- if (p.life <= 0) continue;
-
- p.x += p.vx;
- p.y += p.vy;
- p.vy -= 0.018;
- p.vx *= 0.98;
- p.size *= 0.985;
-
- const t = p.life;
- const ph = h + (1 - t) * 25;
- const pl = Math.min(l + t * 40, 90);
- const alpha = Math.pow(t, 1.5) * 0.18;
- const radius = p.size * (0.4 + t * 0.6);
-
- const g = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, radius);
- g.addColorStop(0, `hsla(${ph - 5}, ${s}%, ${pl}%, ${alpha})`);
- g.addColorStop(0.35, `hsla(${ph}, ${s}%, ${Math.max(l, 40)}%, ${alpha * 0.6})`);
- g.addColorStop(0.7, `hsla(${ph + 15}, ${s}%, ${Math.max(l - 15, 20)}%, ${alpha * 0.2})`);
- g.addColorStop(1, `hsla(${ph + 25}, ${s}%, ${Math.max(l - 25, 5)}%, 0)`);
-
- ctx.beginPath();
- ctx.arc(p.x, p.y, radius, 0, Math.PI * 2);
- ctx.fillStyle = g;
- ctx.fill();
-
- alive.push(p);
- }
- particles.current = alive;
-
- // Orb: slow pulsing core glow at cursor
- if (active.current && cursor.current) {
- const { x, y } = cursor.current;
- pulse.current += 0.025;
- const pv = (Math.sin(pulse.current) + 1) / 2;
- const r = 20 + pv * 12;
- const a = 0.5 + pv * 0.3;
-
- const orb = ctx.createRadialGradient(x, y, 0, x, y, r);
- orb.addColorStop(0, `hsla(${h - 10}, ${Math.max(s - 10, 0)}%, 95%, ${a})`);
- orb.addColorStop(0.4, `hsla(${h}, ${s}%, ${Math.min(l + 15, 85)}%, ${a * 0.5})`);
- orb.addColorStop(1, `hsla(${h + 15}, ${s}%, ${l}%, 0)`);
-
- ctx.beginPath();
- ctx.arc(x, y, r, 0, Math.PI * 2);
- ctx.fillStyle = orb;
- ctx.fill();
- }
-
- ctx.globalCompositeOperation = 'source-over';
- raf.current = requestAnimationFrame(draw);
- }
-
- raf.current = requestAnimationFrame(draw);
-
- return () => {
- cancelAnimationFrame(raf.current);
- window.removeEventListener('resize', resize);
- window.removeEventListener('mousemove', onMouseMove);
- window.removeEventListener('touchmove', onTouchMove);
- window.removeEventListener('mouseleave', onLeave);
- window.removeEventListener('touchend', onLeave);
- window.removeEventListener('click', onClick);
- window.removeEventListener('touchstart', onTouchStart);
- };
- }, []);
-
- return (
-
- );
-}
diff --git a/src/components/DetailCommentComposer.tsx b/src/components/DetailCommentComposer.tsx
new file mode 100644
index 00000000..45654081
--- /dev/null
+++ b/src/components/DetailCommentComposer.tsx
@@ -0,0 +1,31 @@
+import type { NostrEvent } from '@nostrify/nostrify';
+
+import { ComposeBox } from '@/components/ComposeBox';
+
+interface DetailCommentComposerProps {
+ event: NostrEvent;
+ placeholder?: string;
+ onSuccess?: () => void;
+ className?: string;
+}
+
+export function DetailCommentComposer({
+ event,
+ placeholder = "What's on your mind?",
+ onSuccess,
+ className,
+}: DetailCommentComposerProps) {
+ return (
+
+
+
+ );
+}
diff --git a/src/components/DonateDialog.tsx b/src/components/DonateDialog.tsx
index 62b44c38..87d8d44f 100644
--- a/src/components/DonateDialog.tsx
+++ b/src/components/DonateDialog.tsx
@@ -1,13 +1,10 @@
import { useEffect, useMemo, useState } from 'react';
-import { Link } from 'react-router-dom';
import { useMutation, useQuery } from '@tanstack/react-query';
import {
AlertTriangle,
ArrowUpRight,
Check,
ChevronLeft,
- Copy,
- ExternalLink,
HandHeart,
Heart,
Loader2,
@@ -15,12 +12,12 @@ import {
Sparkle,
Sparkles,
Star,
- Wallet,
} from 'lucide-react';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { BitcoinPublicDisclaimer } from '@/components/BitcoinPublicDisclaimer';
import { Button } from '@/components/ui/button';
+import { CampaignWalletDonatePanel } from '@/components/CampaignWalletDonatePanel';
import {
Dialog,
DialogContent,
@@ -30,7 +27,6 @@ import {
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
-import { QRCodeCanvas } from '@/components/ui/qrcode';
import {
Select,
SelectContent,
@@ -41,7 +37,6 @@ import {
import { Skeleton } from '@/components/ui/skeleton';
import { Textarea } from '@/components/ui/textarea';
import AuthDialog from '@/components/auth/AuthDialog';
-import { useAuthor } from '@/hooks/useAuthor';
import { useAppContext } from '@/hooks/useAppContext';
import { useBitcoinSigner } from '@/hooks/useBitcoinSigner';
import { useCurrentUser } from '@/hooks/useCurrentUser';
@@ -59,11 +54,8 @@ import {
type FeeRates,
} from '@/lib/bitcoin';
import {
- minDonationForSplit,
type ParsedCampaign,
- splitDonation,
} from '@/lib/campaign';
-import { genUserName } from '@/lib/genUserName';
import { cn } from '@/lib/utils';
/**
@@ -79,16 +71,12 @@ const PRESET_AMOUNTS: readonly { amountUsd: number; icon: React.ComponentType<{
];
function parseUsdInput(input: string): number {
- return Number(input.replace(/[, $]/g, ''));
+ const cleaned = input.replace(/[, $]/g, '').trim();
+ if (!cleaned) return 0;
+ const n = Number(cleaned);
+ return Number.isFinite(n) && n > 0 ? n : 0;
}
-const FEE_SPEED_LABELS: Record = {
- fastest: 'Fastest (~10 min)',
- halfHour: 'Half hour',
- hour: 'One hour',
- economy: 'Economy (~1 day)',
-};
-
function feeRateForSpeed(rates: FeeRates, speed: DonationFeeSpeed): number {
return {
fastest: rates.fastestFee,
@@ -99,22 +87,14 @@ function feeRateForSpeed(rates: FeeRates, speed: DonationFeeSpeed): number {
}
function estimateDonationFee({
- inputCount,
- outputCount,
- totalBalance,
- amountSats,
feeRate,
+ utxoCount,
}: {
- inputCount: number;
- outputCount: number;
- totalBalance: number;
- amountSats: number;
feeRate: number;
+ utxoCount: number;
}): number {
- const feeWithChange = estimateFee(inputCount, outputCount + 1, feeRate);
- const changeWithChange = totalBalance - amountSats - feeWithChange;
- const hasChange = changeWithChange >= BITCOIN_DUST_LIMIT;
- return estimateFee(inputCount, outputCount + (hasChange ? 1 : 0), feeRate);
+ // Single recipient + change output.
+ return estimateFee(utxoCount, 2, feeRate);
}
interface DonateDialogProps {
@@ -127,6 +107,17 @@ interface DonateDialogProps {
type Step = 'form' | 'confirm' | 'success';
+/**
+ * Donate dialog for **on-chain** (`bc1q…` / `bc1p…`) campaigns. The
+ * campaign's `w` wallet endpoint is the single output destination —
+ * there are no recipient splits, no per-recipient previews, and no
+ * dust math beyond the one-output PSBT.
+ *
+ * Silent-payment campaigns (`sp1…`) never open this dialog; their
+ * detail-page donate column points directly at the SP code via the
+ * `CampaignWalletDonatePanel` so donors can scan/copy and pay from a
+ * BIP-352-aware external wallet.
+ */
export function DonateDialog({ campaign, open, onOpenChange, btcPrice }: DonateDialogProps) {
const { user } = useCurrentUser();
const { canSignPsbt } = useBitcoinSigner();
@@ -148,26 +139,12 @@ export function DonateDialog({ campaign, open, onOpenChange, btcPrice }: DonateD
}
}, [open]);
- const minDonation = useMemo(() => {
- return minDonationForSplit(campaign.recipients, user?.pubkey, BITCOIN_DUST_LIMIT);
- }, [campaign.recipients, user?.pubkey]);
-
const effectiveUsd = customUsd.trim()
? parseUsdInput(customUsd)
: amountUsd;
const effectiveAmount = usdToSats(effectiveUsd, btcPrice);
- const splitPreview = useMemo(() => {
- if (!Number.isFinite(effectiveAmount) || effectiveAmount <= 0) return null;
- try {
- return splitDonation(campaign.recipients, effectiveAmount, user?.pubkey);
- } catch {
- return null;
- }
- }, [campaign.recipients, effectiveAmount, user?.pubkey]);
-
- const belowMin = Number.isFinite(effectiveAmount) && effectiveAmount < minDonation;
- const tooSmallSplit = splitPreview?.some((s) => s.amountSats < BITCOIN_DUST_LIMIT) ?? false;
+ const belowDust = Number.isFinite(effectiveAmount) && effectiveAmount > 0 && effectiveAmount < BITCOIN_DUST_LIMIT;
const donateMutation = useMutation({
mutationFn: async () =>
@@ -209,21 +186,12 @@ export function DonateDialog({ campaign, open, onOpenChange, btcPrice }: DonateD
};
// ── Logged-out flow ──
- //
- // The ideal path is always to log in and donate through the campaign, so the
- // donation publishes a kind 8333 receipt and counts toward the goal. As a
- // secondary path, single-recipient campaigns can be paid externally from any
- // Bitcoin wallet — the funds reach the recipient but no Nostr receipt is
- // published, so the donation won't appear in Agora's totals. Multi-recipient
- // campaigns only support the log-in path (the split needs the donor's
- // signature on a single PSBT with N outputs).
if (open && !user) {
return (
@@ -231,37 +199,15 @@ export function DonateDialog({ campaign, open, onOpenChange, btcPrice }: DonateD
);
}
+ // Logged-in but the signer can't build a PSBT (e.g. NIP-07 extension
+ // without signPsbt). Direct the donor at the external-wallet panel on
+ // the page — the in-app flow simply isn't possible without a PSBT
+ // signer.
if (open && !canSignPsbt) {
- // Logged-in but the signer can't build a PSBT (e.g. NIP-07 extension
- // without signPsbt). Single-recipient campaigns can still be paid from
- // any external wallet; multi-recipient ones cannot.
- if (campaign.recipients.length === 1) {
- return (
-
-
-
-
-
- );
- }
-
return (
-
-
-
-
- Donating not available
-
-
- Your current login can't sign Bitcoin transactions for the multi-recipient split. Log in with your secret key (nsec) or a signer that supports signPsbt to donate.
-
-
- Close
+
+
);
@@ -269,42 +215,51 @@ export function DonateDialog({ campaign, open, onOpenChange, btcPrice }: DonateD
return (
-
- {step === 'success' && result ? (
-
- ) : step === 'confirm' ? (
+
+ {step === 'form' && (
+ {
+ setAmountUsd(usd);
+ setCustomUsd('');
+ }}
+ onCustomChange={setCustomUsd}
+ onCommentChange={setComment}
+ onFeeSpeedChange={setFeeSpeed}
+ onContinue={() => setStep('confirm')}
+ onClose={handleClose}
+ />
+ )}
+
+ {step === 'confirm' && (
setStep('form')}
- onConfirm={() => donateMutation.mutate()}
+ onSubmit={() => donateMutation.mutate()}
/>
- ) : (
- {
- setAmountUsd(amt);
- setCustomUsd('');
- }}
- onCustomChange={(v) => setCustomUsd(v)}
- onCommentChange={setComment}
- onFeeSpeedChange={setFeeSpeed}
- onNext={() => setStep('confirm')}
- onCancel={handleClose}
+ onClose={handleClose}
/>
)}
@@ -312,455 +267,325 @@ export function DonateDialog({ campaign, open, onOpenChange, btcPrice }: DonateD
);
}
-// ─── Form view ───────────────────────────────────────────────────────────────
+// ─────────────────────────────────────────────────────────────────────
+// Form step
+// ─────────────────────────────────────────────────────────────────────
+
+interface FormViewProps {
+ campaign: ParsedCampaign;
+ amountUsd: number;
+ customUsd: string;
+ comment: string;
+ feeSpeed: DonationFeeSpeed;
+ effectiveAmount: number;
+ effectiveUsd: number;
+ belowDust: boolean;
+ btcPrice: number | undefined;
+ isPending: boolean;
+ onAmountChange: (usd: number) => void;
+ onCustomChange: (value: string) => void;
+ onCommentChange: (value: string) => void;
+ onFeeSpeedChange: (speed: DonationFeeSpeed) => void;
+ onContinue: () => void;
+ onClose: () => void;
+}
function FormView({
campaign,
- presetAmountUsd,
+ amountUsd,
customUsd,
- effectiveUsd,
- effectiveAmount,
- minDonation,
- belowMin,
- tooSmallSplit,
comment,
feeSpeed,
+ effectiveAmount,
+ effectiveUsd,
+ belowDust,
btcPrice,
- onPresetClick,
+ isPending,
+ onAmountChange,
onCustomChange,
onCommentChange,
onFeeSpeedChange,
- onNext,
- onCancel,
-}: {
- campaign: ParsedCampaign;
- presetAmountUsd: number;
- customUsd: string;
- effectiveUsd: number;
- effectiveAmount: number;
- minDonation: number;
- belowMin: boolean;
- tooSmallSplit: boolean;
- comment: string;
- feeSpeed: DonationFeeSpeed;
- btcPrice?: number;
- onPresetClick: (amt: number) => void;
- onCustomChange: (v: string) => void;
- onCommentChange: (v: string) => void;
- onFeeSpeedChange: (v: DonationFeeSpeed) => void;
- onNext: () => void;
- onCancel: () => void;
-}) {
- const recipientCount = campaign.recipients.length;
- const { user } = useCurrentUser();
- const { config } = useAppContext();
- const { esploraBaseUrl } = config;
- const senderAddress = user ? nostrPubkeyToBitcoinAddress(user.pubkey) : '';
- const hasPrice = !!btcPrice && Number.isFinite(btcPrice) && btcPrice > 0;
- const validAmount = hasPrice && Number.isFinite(effectiveUsd) && effectiveUsd > 0 && effectiveAmount > 0;
- const canContinue = validAmount && !belowMin && !tooSmallSplit;
-
- const { data: utxos } = useQuery({
- queryKey: ['bitcoin-utxos', esploraBaseUrl, senderAddress],
- queryFn: () => fetchUTXOs(senderAddress, esploraBaseUrl),
- enabled: !!senderAddress && validAmount,
- staleTime: 30_000,
- });
-
- const { data: feeRates } = useQuery({
- queryKey: ['bitcoin-fee-rates', esploraBaseUrl],
- queryFn: () => getFeeRates(esploraBaseUrl),
- enabled: validAmount,
- staleTime: 30_000,
- });
-
- const totalBalance = useMemo(() => utxos?.reduce((sum, utxo) => sum + utxo.value, 0) ?? 0, [utxos]);
- const feeEstimates = useMemo(() => {
- if (!utxos?.length || !feeRates || !validAmount) return {};
-
- let outputCount = recipientCount;
- try {
- outputCount = splitDonation(campaign.recipients, effectiveAmount, user?.pubkey).length;
- } catch {
- return {};
- }
-
- return (Object.keys(FEE_SPEED_LABELS) as DonationFeeSpeed[]).reduce>>((acc, speed) => {
- acc[speed] = estimateDonationFee({
- inputCount: utxos.length,
- outputCount,
- totalBalance,
- amountSats: effectiveAmount,
- feeRate: feeRateForSpeed(feeRates, speed),
- });
- return acc;
- }, {});
- }, [utxos, feeRates, validAmount, recipientCount, campaign.recipients, effectiveAmount, user?.pubkey, totalBalance]);
+ onContinue,
+}: FormViewProps) {
+ const usingCustom = customUsd.trim().length > 0;
+ const canContinue = effectiveAmount > 0 && !belowDust;
return (
<>
-
-
- Donate to {campaign.title}
-
+ Donate to {campaign.title}
- Your donation is sent as one Bitcoin transaction split across {recipientCount}{' '}
- {recipientCount === 1 ? 'recipient' : 'recipients'}.
+ Send Bitcoin to the campaign's wallet from your in-app balance.
-
- {/* Preset grid */}
-
-
Amount (USD)
-
- {PRESET_AMOUNTS.map(({ amountUsd: amount, icon: Icon, label }) => {
- const selected = !customUsd && amount === presetAmountUsd;
+
+ {/* Preset amounts */}
+
+
+ Amount
+
+
+ {PRESET_AMOUNTS.map(({ amountUsd: usd, icon: Icon, label }) => {
+ const selected = !usingCustom && amountUsd === usd;
return (
onPresetClick(amount)}
+ onClick={() => onAmountChange(usd)}
className={cn(
- 'flex flex-col items-center justify-center gap-1 rounded-lg border p-2 text-xs font-medium motion-safe:transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary',
+ 'flex flex-col items-center gap-1 rounded-lg border px-2 py-2.5 text-xs font-semibold motion-safe:transition-colors',
selected
- ? 'border-primary bg-primary/10 text-foreground'
- : 'border-border hover:bg-secondary text-muted-foreground hover:text-foreground',
+ ? 'border-primary bg-primary/10 text-primary'
+ : 'border-border bg-card hover:bg-muted/60',
)}
- aria-pressed={selected}
>
- {label}
+ {label}
);
})}
- {/* Custom override */}
-
-
Or enter a custom amount (USD)
-
onCustomChange(e.target.value)}
- />
-
-
- {validAmount
- ? `${satsToUSD(effectiveAmount, btcPrice)} · ${formatSats(effectiveAmount)} sats`
- : btcPrice
- ? `Minimum: ${satsToUSD(minDonation, btcPrice)} (${minDonation.toLocaleString()} sats)`
- : 'Waiting for BTC/USD price'}
-
-
- {recipientCount > 1 && validAmount
- ? `≈ ${formatSats(Math.floor(effectiveAmount / recipientCount))} sats per recipient`
- : ''}
+ {/* Custom amount */}
+
+
+ Or custom (USD)
+
+
+
+ $
+ onCustomChange(e.target.value)}
+ className="pl-7"
+ />
+ {effectiveAmount > 0 && (
+
+ ≈ {formatSats(effectiveAmount)} sats
+ {btcPrice && effectiveUsd > 0 && (
+ <> · ${effectiveUsd.toLocaleString()} at current price>
+ )}
+
+ )}
- {/* Optional comment */}
-
-
Comment (optional)
+ {/* Comment */}
+
+
+ Public comment (optional)
+
{/* Fee speed */}
-
-
Arrival
+
+
+ Confirmation speed
+
onFeeSpeedChange(v as DonationFeeSpeed)}>
- {(Object.keys(FEE_SPEED_LABELS) as DonationFeeSpeed[]).map((speed) => (
-
- {FEE_SPEED_LABELS[speed]}
- {btcPrice && feeEstimates[speed] !== undefined
- ? ` · ${satsToUSD(feeEstimates[speed], btcPrice)} fee`
- : ''}
-
- ))}
+ Fastest (~10 min)
+ Half hour
+ Hour
+ Economy (cheapest)
- {(belowMin || tooSmallSplit) && validAmount && (
+ {belowDust && (
- That's too small to split across {recipientCount} recipients (each output must be at
- least {BITCOIN_DUST_LIMIT.toLocaleString()} sats). Minimum donation:{' '}
- {minDonation.toLocaleString()} sats.
+ Amount is below the Bitcoin dust limit ({BITCOIN_DUST_LIMIT.toLocaleString()} sats).
+ Choose a larger amount.
)}
- {!hasPrice && (
-
-
-
- Waiting for the BTC/USD price before converting your USD donation to sats.
-
-
- )}
-
-
-
-
- Bitcoin transactions are irreversible and can take time to confirm.
- Your on-chain wallet pays the network fee on top of your donation
- amount.
-
-
-
-
- Cancel
-
-
-
- Review
-
-
+
+
+
+ Review donation
+
+
>
);
}
-// ─── Confirm view ────────────────────────────────────────────────────────────
+// ─────────────────────────────────────────────────────────────────────
+// Confirm step
+// ─────────────────────────────────────────────────────────────────────
-function BeneficiarySplitRow({ pubkey, amountSats }: { pubkey: string; amountSats: number }) {
- const author = useAuthor(pubkey);
- const metadata = author.data?.metadata;
- const name = metadata?.display_name || metadata?.name || genUserName(pubkey);
-
- return (
-
-
-
- {name}
-
- {pubkey.slice(0, 8)}…{pubkey.slice(-4)}
-
-
-
-
- {amountSats.toLocaleString()} sats
-
-
- );
+interface ConfirmViewProps {
+ campaign: ParsedCampaign;
+ amountSats: number;
+ effectiveUsd: number;
+ comment: string;
+ feeSpeed: DonationFeeSpeed;
+ btcPrice: number | undefined;
+ isPending: boolean;
+ onBack: () => void;
+ onSubmit: () => void;
}
function ConfirmView({
campaign,
amountSats,
- feeSpeed,
+ effectiveUsd,
comment,
+ feeSpeed,
btcPrice,
isPending,
onBack,
- onConfirm,
-}: {
- campaign: ParsedCampaign;
- amountSats: number;
- feeSpeed: DonationFeeSpeed;
- comment: string;
- btcPrice?: number;
- isPending: boolean;
- onBack: () => void;
- onConfirm: () => void;
-}) {
+ onSubmit,
+}: ConfirmViewProps) {
const { user } = useCurrentUser();
const { config } = useAppContext();
- const { esploraBaseUrl } = config;
- const senderAddress = user ? nostrPubkeyToBitcoinAddress(user.pubkey) : '';
- const splits = useMemo(() => {
- try {
- return splitDonation(campaign.recipients, amountSats, user?.pubkey);
- } catch {
- return [];
- }
- }, [campaign.recipients, amountSats, user?.pubkey]);
+ const { esploraApis } = config;
- const { data: utxos, isLoading: utxosLoading, isError: utxosError } = useQuery({
- queryKey: ['bitcoin-utxos', esploraBaseUrl, senderAddress],
- queryFn: () => fetchUTXOs(senderAddress, esploraBaseUrl),
- enabled: !!senderAddress && amountSats > 0,
+ const senderAddress = user ? nostrPubkeyToBitcoinAddress(user.pubkey) : null;
+
+ // Pre-fetch UTXOs + fee rates so the confirm screen can show an
+ // accurate fee estimate before the donor commits.
+ const utxosQuery = useQuery({
+ queryKey: ['bitcoin-utxos', senderAddress, esploraApis],
+ queryFn: ({ signal }) => fetchUTXOs(senderAddress!, esploraApis, signal),
+ enabled: !!senderAddress,
+ staleTime: 30_000,
+ });
+ const feeRatesQuery = useQuery({
+ queryKey: ['bitcoin-fee-rates', esploraApis],
+ queryFn: ({ signal }) => getFeeRates(esploraApis, signal),
staleTime: 30_000,
});
- const { data: feeRates, isLoading: feeRatesLoading, isError: feeRatesError } = useQuery({
- queryKey: ['bitcoin-fee-rates', esploraBaseUrl],
- queryFn: () => getFeeRates(esploraBaseUrl),
- enabled: amountSats > 0,
- staleTime: 30_000,
- });
-
- const totalBalance = useMemo(() => utxos?.reduce((sum, utxo) => sum + utxo.value, 0) ?? 0, [utxos]);
- const estimatedFee = useMemo(() => {
- if (!utxos?.length || !feeRates || splits.length === 0) return undefined;
-
+ const feeEstimate = useMemo(() => {
+ const utxos = utxosQuery.data;
+ const rates = feeRatesQuery.data;
+ if (!utxos || !rates) return null;
return estimateDonationFee({
- inputCount: utxos.length,
- outputCount: splits.length,
- totalBalance,
- amountSats,
- feeRate: feeRateForSpeed(feeRates, feeSpeed),
+ feeRate: feeRateForSpeed(rates, feeSpeed),
+ utxoCount: utxos.length,
});
- }, [utxos, feeRates, splits.length, feeSpeed, totalBalance, amountSats]);
- const feeLoading = utxosLoading || feeRatesLoading;
- const feeError = utxosError || feeRatesError;
- const noSpendableFunds = !!utxos && utxos.length === 0;
- const insufficientFunds = estimatedFee !== undefined && totalBalance < amountSats + estimatedFee;
- const canConfirm = !isPending && estimatedFee !== undefined && !feeError && !noSpendableFunds && !insufficientFunds;
- const formatReviewAmount = (sats: number) => (
- btcPrice ? (
- <>
- {satsToUSD(sats, btcPrice)}
-
({formatSats(sats)} sats)
- >
- ) : `${formatSats(sats)} sats`
- );
+ }, [utxosQuery.data, feeRatesQuery.data, feeSpeed]);
return (
<>
-
-
- Confirm Donation
-
- Review the split before broadcasting.
+
+
+ Back
+
+ Confirm donation
+
+ Review the details before signing the transaction.
+
-
-
-
Campaign
-
{campaign.title}
-
-
-
-
-
Donation amount
-
- {formatReviewAmount(amountSats)}
+
+
+
+ {formatSats(amountSats)} sats
+ {btcPrice && effectiveUsd > 0 && (
+ ≈ ${effectiveUsd.toLocaleString()}
+ )}
-
-
- Network fee
-
- {estimatedFee !== undefined
- ? formatReviewAmount(estimatedFee)
- : feeLoading
- ? 'Calculating…'
- : noSpendableFunds
- ? 'No spendable funds'
- : 'Unavailable'}
-
-
- {estimatedFee !== undefined && (
-
- Total
-
- {formatReviewAmount(amountSats + estimatedFee)}
-
-
- )}
-
- Arrival
- {FEE_SPEED_LABELS[feeSpeed]}
-
-
-
- {feeError && (
-
-
-
- Could not estimate the network fee. Check your connection and try again.
-
-
- )}
-
- {noSpendableFunds && (
-
-
-
- Your Bitcoin wallet has no spendable funds.
-
-
- )}
-
- {insufficientFunds && estimatedFee !== undefined && (
-
-
-
- Insufficient funds. This donation needs {(amountSats + estimatedFee).toLocaleString()} sats including the network fee.
-
-
- )}
-
-
-
-
- {splits.map((s) => (
-
- ))}
-
-
-
-
- {comment && (
-
-
Your comment
-
{comment}
-
- )}
-
-
- The fee is locked into the transaction when you sign. Confirmation time can still vary with mempool conditions.
-
-
-
-
-
- Back
-
-
- {isPending ? (
- <>
-
- Sending…
- >
+ }
+ />
+ {campaign.wallet.value}
+ }
+ />
+
) : (
- <>
-
- Donate {btcPrice ? satsToUSD(amountSats, btcPrice) : `${formatSats(amountSats)} sats`}
- >
- )}
-
-
+
+ {formatSats(feeEstimate)} sats
+ {btcPrice && (
+
+ ≈ ${satsToUSD(feeEstimate, btcPrice)}
+
+ )}
+
+ )
+ }
+ />
+ {comment.trim() && (
+
"{comment}"} />
+ )}
+
+
+ {isPending ? (
+ <>
+
+ Sending donation…
+ >
+ ) : (
+ <>
+
+ Send donation
+ >
+ )}
+
>
);
}
-// ─── Success view ────────────────────────────────────────────────────────────
+function Row({ label, value }: { label: string; value: React.ReactNode }) {
+ return (
+
+ {label}
+ {value}
+
+ );
+}
+
+// ─────────────────────────────────────────────────────────────────────
+// Success step
+// ─────────────────────────────────────────────────────────────────────
function SuccessView({
campaign,
@@ -770,446 +595,142 @@ function SuccessView({
}: {
campaign: ParsedCampaign;
result: DonateCampaignResult;
- btcPrice?: number;
+ btcPrice: number | undefined;
onClose: () => void;
}) {
- const txPath = `/i/bitcoin:tx:${result.txid}`;
- const formatSuccessAmount = (sats: number) => (
- btcPrice ? (
- <>
- {satsToUSD(sats, btcPrice)}
-
({formatSats(sats)} sats)
- >
- ) : `${formatSats(sats)} sats`
- );
-
return (
<>
-
-
- Donation Sent
-
-
- Thanks for supporting {campaign.title} .
+
+
+
+ Thank you!
+
+ Your donation to {campaign.title} is on its way.
-
-
- Transaction ID
-
- {result.txid}
-
-
-
-
-
-
Amount sent
-
- {btcPrice ? satsToUSD(result.totalSats, btcPrice) : `${formatSats(result.totalSats)} sats`}
+
+
+ {formatSats(result.totalSats)} sats
+ {btcPrice && (
+
+ ≈ ${satsToUSD(result.totalSats, btcPrice)}
+
+ )}
-
-
- Network fee
- {formatSuccessAmount(result.fee)}
-
-
- Recipients paid
- {result.recipientCount}
-
-
-
- {!result.receiptPublished && (
-
-
-
- The Bitcoin tx is final, but the kind 8333 receipt didn't publish. Your donation still
- counts.
-
-
- )}
-
-
-
-
- View Transaction
-
-
-
- Done
-
-
+ }
+ />
+
{formatSats(result.fee)} sats}
+ />
+
+ {result.txid.slice(0, 16)}…
+
+ }
+ />
+
+
+ Done
+
>
);
}
-/** Skeleton placeholder for the donate button while resolving auth. */
-export function DonateButtonSkeleton() {
- return
;
-}
+// ─────────────────────────────────────────────────────────────────────
+// Logged-out chooser
+// ─────────────────────────────────────────────────────────────────────
-// ─── Logged-out chooser view ────────────────────────────────────────────────
-
-/**
- * Shown when a logged-out user clicks Donate. Frames the choice clearly:
- *
- * 1. **Recommended**: log in and donate through Agora so the donation
- * publishes a kind 8333 receipt and counts toward the campaign goal.
- * 2. **Secondary** (single-recipient campaigns only): pay the recipient
- * directly with any Bitcoin wallet. Funds reach the recipient but the
- * donation won't appear in Agora's totals or donor list.
- *
- * Multi-recipient campaigns hide the secondary option because the split
- * fundamentally requires a single PSBT signed by the donor.
- */
function LoggedOutChooserView({
campaign,
- btcPrice,
onClose,
}: {
campaign: ParsedCampaign;
- btcPrice?: number;
onClose: () => void;
}) {
- const [view, setView] = useState<'choose' | 'external'>('choose');
- const [loginOpen, setLoginOpen] = useState(false);
-
- const singleRecipient = campaign.recipients.length === 1;
- const firstRecipient = campaign.recipients[0];
- const recipientAuthor = useAuthor(firstRecipient.pubkey);
- const recipientName = singleRecipient
- ? recipientAuthor.data?.metadata?.display_name ||
- recipientAuthor.data?.metadata?.name ||
- genUserName(firstRecipient.pubkey)
- : '';
-
- if (view === 'external') {
- return (
-
setView('choose')}
- onClose={onClose}
- />
- );
- }
+ const [authOpen, setAuthOpen] = useState(false);
return (
<>
-
-
- Log in to donate to this campaign
-
+ Donate to {campaign.title}
- Donations made through Agora publish a Nostr receipt that counts
- toward {campaign.title} 's goal and
- appears in the donor list.
+ Log in to donate from your in-app wallet, or scan the QR on the
+ campaign page to pay from any external Bitcoin wallet.
-
- {/* Primary path — log in */}
-
setLoginOpen(true)}
- className="w-full text-left rounded-xl border-2 border-primary bg-primary/5 p-4 hover:bg-primary/10 motion-safe:transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
+
+
setAuthOpen(true)}
>
-
-
-
-
-
-
- Log in & donate
-
- Recommended
-
-
-
- Your donation counts toward the campaign goal and shows up in the donor list.
- {!singleRecipient &&
- ` Required for this campaign because it splits across ${campaign.recipients.length} recipients.`}
-
-
-
-
-
-
- {/* Secondary path — external pay, single recipient only */}
- {singleRecipient && (
-
setView('external')}
- className="w-full text-left rounded-xl border border-border bg-background p-4 hover:bg-muted/50 motion-safe:transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
- >
-
-
-
-
-
-
- Donate to {recipientName} directly
-
-
- Pay {recipientName} from any Bitcoin wallet. It{' '}
- won't count toward the
- campaign goal, but {recipientName} will still receive it.
-
-
-
-
-
- )}
-
- {!singleRecipient && (
-
-
-
- This campaign splits each donation across {campaign.recipients.length} recipients in
- one transaction, which requires your signature. Direct payments aren't available for
- multi-recipient campaigns.
-
-
- )}
-
-
- Cancel
+
+ Log in to donate
+
+
+ Pay from external wallet instead
- {
- // The outer DonateDialog re-renders once `user` becomes truthy and
- // automatically swaps to the FormView for the now-logged-in donor.
- setLoginOpen(false);
- }}
- />
+ setAuthOpen(false)} />
>
);
}
-// ─── External-pay view (logged-out, single recipient) ────────────────────────
+// ─────────────────────────────────────────────────────────────────────
+// Signer-unsupported fallback
+// ─────────────────────────────────────────────────────────────────────
-/**
- * Renders address + QR + BIP-21 deep link for a single-recipient campaign so
- * donors can pay from any Bitcoin wallet without logging into Agora.
- *
- * Caveat surfaced to the donor: because no kind 8333 receipt is published,
- * the donation will not appear in Agora's donation totals or donor list. The
- * funds still reach the recipient on-chain; only the social-layer attestation
- * is missing.
- */
-function ExternalPayView({
+function SignerUnsupportedView({
campaign,
- btcPrice,
- onBack,
onClose,
}: {
campaign: ParsedCampaign;
- btcPrice?: number;
- /** When provided, renders a back affordance returning to the chooser. */
- onBack?: () => void;
onClose: () => void;
}) {
- const recipient = campaign.recipients[0];
- const author = useAuthor(recipient.pubkey);
- const { toast } = useToast();
-
- const [usd, setUsd] = useState('');
- const [copiedAddress, setCopiedAddress] = useState(false);
- const [copiedUri, setCopiedUri] = useState(false);
-
- const address = useMemo(
- () => nostrPubkeyToBitcoinAddress(recipient.pubkey),
- [recipient.pubkey],
- );
-
- const metadata = author.data?.metadata;
- const recipientName =
- metadata?.display_name || metadata?.name || genUserName(recipient.pubkey);
-
- const parsedUsd = usd.trim() ? parseUsdInput(usd) : NaN;
- const hasPrice = !!btcPrice && Number.isFinite(btcPrice) && btcPrice > 0;
- const amountSats =
- Number.isFinite(parsedUsd) && parsedUsd > 0 && hasPrice
- ? usdToSats(parsedUsd, btcPrice)
- : 0;
- const amountBtc = amountSats > 0 ? amountSats / 100_000_000 : 0;
-
- // BIP-21 URI. `amount` is in BTC with up to 8 decimals, no trailing zeros.
- // We only include it when the donor entered a positive USD amount AND we
- // have a price to convert with — otherwise we omit it so the wallet can
- // prompt for any amount.
- const bip21 = useMemo(() => {
- if (!address) return '';
- if (amountBtc > 0) {
- // Trim trailing zeros after the decimal, but keep up to 8 places.
- const fixed = amountBtc.toFixed(8).replace(/\.?0+$/, '');
- return `bitcoin:${address}?amount=${fixed}`;
- }
- return `bitcoin:${address}`;
- }, [address, amountBtc]);
-
- const copy = async (value: string, marker: 'address' | 'uri') => {
- if (!value) return;
- try {
- await navigator.clipboard.writeText(value);
- if (marker === 'address') {
- setCopiedAddress(true);
- setTimeout(() => setCopiedAddress(false), 1500);
- } else {
- setCopiedUri(true);
- setTimeout(() => setCopiedUri(false), 1500);
- }
- toast({ title: marker === 'address' ? 'Address copied' : 'Payment URI copied' });
- } catch {
- toast({
- title: 'Copy failed',
- description: 'Select and copy the address manually.',
- variant: 'destructive',
- });
- }
- };
-
- if (!address) {
- return (
- <>
-
-
-
- Address unavailable
-
-
- We couldn't derive a Bitcoin address for this recipient. Try again later or contact the
- campaign creator.
-
-
- Close
- >
- );
- }
-
return (
<>
- {onBack && (
-
-
- Back
-
- )}
-
-
- Pay with any Bitcoin wallet
-
+ Donate to {campaign.title}
- Scan the QR code or copy the address below to donate to{' '}
- {recipientName} from your existing wallet.
+ Scan the QR code with your phone's Bitcoin wallet, or tap "Open in
+ wallet" to send your donation. You choose the amount in your wallet.
-
- {/* Optional amount input — informs the BIP-21 URI / QR */}
-
-
Amount (USD, optional)
-
setUsd(e.target.value)}
- disabled={!hasPrice}
- />
-
- {amountSats > 0
- ? `${formatSats(amountSats)} sats · embedded in the QR code below`
- : 'Without an amount, your wallet will prompt you to enter one when it scans the QR code.'}
-
-
+
- {/* QR code */}
-
-
- {/* Address (copyable) */}
-
-
- Bitcoin address
-
- copy(address, 'address')}
- className="w-full flex items-center justify-between gap-2 rounded-lg border bg-muted/40 px-3 py-2.5 font-mono text-xs break-all text-left hover:bg-muted/60 motion-safe:transition-colors"
- >
- {address}
- {copiedAddress ? (
-
- ) : (
-
- )}
-
-
-
- {/* Public-ledger notice — informational. Bitcoin is a public
- ledger, so the donation can be traced back to the donor's
- wallet. */}
-
-
- {/* Action buttons */}
-
-
copy(bip21, 'uri')}>
- {copiedUri ? (
-
- ) : (
-
- )}
- Copy payment URI
-
-
- {/* `bitcoin:` is a registered URI scheme. Most desktop / mobile
- wallets will intercept it; if none does, the click is a no-op
- from the user's perspective. */}
-
-
- Open in wallet
-
-
-
-
- {/* Heads-up: donation won't appear in Agora's totals */}
-
-
-
- Payments made this way go straight to the recipient on Bitcoin but won't show up in
- this campaign's donor list or progress bar. To have your donation counted on Agora,{' '}
- log in and donate through the app.
-
-
-
-
- Done
-
-
+
+ Close
+
>
);
}
+
+// ─────────────────────────────────────────────────────────────────────
+// Loading skeleton (for callers that need a placeholder button)
+// ─────────────────────────────────────────────────────────────────────
+
+export function DonateButtonSkeleton() {
+ return ;
+}
diff --git a/src/components/EditProfileForm.tsx b/src/components/EditProfileForm.tsx
index 334f23c0..33495915 100644
--- a/src/components/EditProfileForm.tsx
+++ b/src/components/EditProfileForm.tsx
@@ -29,7 +29,6 @@ import {
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import { z } from 'zod';
-import { IntroImage } from '@/components/IntroImage';
import { ImageCropDialog } from '@/components/ImageCropDialog';
// Extended form schema that includes custom fields
@@ -203,7 +202,7 @@ export const EditProfileForm: React.FC = ({ onValuesChange
// Combine existing metadata with new values
const data: Record = { ...metadata, ...standardMetadata };
- // Strip any legacy avatar shape data from old Ditto-style profiles
+ // Strip any legacy avatar-shape field carried over from older clients.
delete data.shape;
// Clean up empty values in standard metadata
@@ -248,14 +247,11 @@ export const EditProfileForm: React.FC = ({ onValuesChange
return (
{/* Intro */}
-
-
-
-
Your Identity
-
- Customize your profile with a name, bio, images, and verification. This is how others will see you on Nostr.
-
-
+
+
Your Identity
+
+ Customize your profile with a name, bio, images, and verification. This is how others will see you on Nostr.
+
{/* Crop dialog */}
diff --git a/src/components/ExternalContentHeader.tsx b/src/components/ExternalContentHeader.tsx
index 056f5b36..ee409c86 100644
--- a/src/components/ExternalContentHeader.tsx
+++ b/src/components/ExternalContentHeader.tsx
@@ -3,8 +3,7 @@ import { Link } from 'react-router-dom';
import { ArrowLeft, BookOpen, Coins, ExternalLink, FileText, Globe, Landmark, Languages, MapPin, Megaphone, MessageCircle, Package, Pause, Play, Repeat2, Share2, User, UserCheck, UserMinus, UserPlus, Users } from 'lucide-react';
import { nip19 } from 'nostr-tools';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
-import { Skeleton } from '@/components/ui/skeleton';
-import { ExternalFavicon } from '@/components/ExternalFavicon';
+import { Skeleton } from '@/components/ui/skeleton';import { ExternalFavicon } from '@/components/ExternalFavicon';
import { ExternalReactionButton } from '@/components/ExternalReactionButton';
import { FollowToggleButton } from '@/components/FollowButton';
import { LinkEmbed } from '@/components/LinkEmbed';
@@ -22,7 +21,6 @@ import { useAuthor } from '@/hooks/useAuthor';
import { useCountryFollows } from '@/hooks/useCountryFollows';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useProfileUrl } from '@/hooks/useProfileUrl';
-import { useWeather } from '@/hooks/useWeather';
import { useToast } from '@/hooks/useToast';
import { genUserName } from '@/lib/genUserName';
import { getCountryInfo, getWikipediaTitle } from '@/lib/countries';
@@ -638,9 +636,11 @@ function WikipediaExtract({ extract, articleUrl }: { extract: string; articleUrl
* above the Wikipedia extract doesn't draw against a phantom row.
*/
function WeatherVitalsRow({ code, facts }: { code: string; facts: CountryFacts | undefined }) {
- const { data: weather, isLoading } = useWeather(code);
- const capital = facts?.capital ?? null;
-
+ // Weather has been removed; this row now renders only the country vitals
+ // (population / languages / currency). The legacy name is preserved so
+ // the mount call sites don't churn — the row still vanishes when there
+ // are no vitals to show, matching the original behavior.
+ void code;
const vitals: { key: string; icon: React.ReactNode; label: string; value: string }[] = [];
if (facts) {
if (facts.population !== null) {
@@ -670,40 +670,17 @@ function WeatherVitalsRow({ code, facts }: { code: string; facts: CountryFacts |
}
}
- if (isLoading && vitals.length === 0) {
- return (
-
-
-
-
- );
- }
+ if (vitals.length === 0) return null;
- const hasWeatherSide = !!weather || !!capital;
- const hasVitalsSide = vitals.length > 0;
- if (!hasWeatherSide && !hasVitalsSide) return null;
+ const capital = facts?.capital ?? null;
+ const hasCapitalSide = !!capital;
return (
- {/* Left group — weather + capital. */}
- {hasWeatherSide && (
+ {/* Left group — capital. */}
+ {hasCapitalSide && (
- {weather && (
- <>
-
-
- {weather.icon}
-
- {weather.temperature}°
-
- {weather.description}
- >
- )}
{capital && (
- // The country's capital is the stable national place anchor for
- // the header. The weather-station city is intentionally omitted
- // — it's often a smaller, less-recognised town nearby and
- // duplicates a less-meaningful place name on the same line.
{capital}
@@ -712,28 +689,21 @@ function WeatherVitalsRow({ code, facts }: { code: string; facts: CountryFacts |
)}
- {/* Right group — vitals (population, language, currency). On narrow
- viewports this wraps onto its own line under the weather group
- rather than getting crushed beside it. Styled to match the
- capital chip on the left (text-xs muted-foreground/80 with a
- size-3 icon) so the row reads as a single uniform metadata
- strip rather than two competing weights. */}
- {hasVitalsSide && (
-
- {vitals.map((item) => (
-
- {item.icon}
-
- {item.value}
-
-
- ))}
-
- )}
+ {/* Right group — vitals (population, language, currency). */}
+
+ {vitals.map((item) => (
+
+ {item.icon}
+
+ {item.value}
+
+
+ ))}
+
);
}
@@ -877,7 +847,6 @@ export function CountryContentHeader({ code }: { code: string }) {
// Country facts are only fetched for sovereign countries (alpha-2 codes);
// the hook's internal guard returns `null` for subdivisions like `US-CA`.
const { data: facts } = useCountryFacts(info?.subdivision ? null : code);
- const { data: weather } = useWeather(code);
const { user } = useCurrentUser();
const { isFollowingCountry, toggleCountryFollow, isPending } = useCountryFollows();
const { toast } = useToast();
@@ -916,7 +885,10 @@ export function CountryContentHeader({ code }: { code: string }) {
// map or administrative photo, which contradicts the editorial choice
// to surface Tibet as a country in its own right.
const heroImage = customFlagAsset(code) ?? wiki?.originalImage?.source ?? wiki?.thumbnail?.source ?? null;
- const isDay = weather?.isDay ?? true;
+ // Always render the daytime sky overlay. Previously we keyed this off the
+ // live `weather.isDay` flag to flip into a night palette; weather has been
+ // removed so we default to the warm amber/rose daytime tint.
+ const isDay = true;
// Sky-tint gradient layered above the hero photo. Warm amber/rose during
// local daytime, deep indigo/violet at night. Same gradient shape, only
// the colour palette flips — preserves the cinematic curve while the mood
diff --git a/src/components/Feed.tsx b/src/components/Feed.tsx
index 668b09af..5832921c 100644
--- a/src/components/Feed.tsx
+++ b/src/components/Feed.tsx
@@ -1,5 +1,4 @@
-import { useState, useEffect, useMemo, useCallback } from 'react';
-import { useNavigate } from 'react-router-dom';
+import { useState, useEffect, useMemo } from 'react';
import { useInView } from 'react-intersection-observer';
import { usePageRefresh } from '@/hooks/usePageRefresh';
import { ComposeBox } from '@/components/ComposeBox';
@@ -7,29 +6,25 @@ import { LandingHero } from '@/components/LandingHero';
import { NoteCard } from '@/components/NoteCard';
import { PullToRefresh } from '@/components/PullToRefresh';
import { FeedEmptyState } from '@/components/FeedEmptyState';
+import { FeedModeSwitcher } from '@/components/FeedModeSwitcher';
import { Skeleton } from '@/components/ui/skeleton';
-import { Globe2, Loader2, Users } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import { Loader2 } from 'lucide-react';
import AuthDialog from '@/components/auth/AuthDialog';
import { useFeed } from '@/hooks/useFeed';
import { useFollowingFeed } from '@/hooks/useFollowingFeed';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useFeedTab } from '@/hooks/useFeedTab';
import { useMuteList } from '@/hooks/useMuteList';
-import { useTabFeed } from '@/hooks/useProfileFeed';
-import { useSavedFeeds } from '@/hooks/useSavedFeeds';
-import { useResolveTabFilter } from '@/hooks/useResolveTabFilter';
-import { useWorldFeed } from '@/hooks/useWorldFeed';
+import { useMixedFeed, type FeedMode } from '@/hooks/useMixedFeed';
import { shouldHideFeedEvent } from '@/lib/feedUtils';
import { isEventMuted } from '@/lib/muteHelpers';
import { SubHeaderBar } from '@/components/SubHeaderBar';
import { TabButton } from '@/components/TabButton';
-import { Button } from '@/components/ui/button';
-import { useNavHidden } from '@/contexts/LayoutContext';
import { cn } from '@/lib/utils';
import type { FeedItem } from '@/lib/feedUtils';
-import type { SavedFeed } from '@/contexts/AppContext';
-type CoreFeedTab = 'follows' | 'network' | 'global' | 'communities' | 'world';
+type CoreFeedTab = 'follows' | 'network' | 'global' | 'communities' | 'world' | 'agora';
type FeedTab = CoreFeedTab | string; // string = saved feed id
interface FeedProps {
@@ -43,72 +38,42 @@ interface FeedProps {
hideCompose?: boolean;
/** Message shown when the feed is empty. */
emptyMessage?: string;
- /** Unique identifier for this feed page, used to persist the active tab in sessionStorage. Defaults to 'home'. */
+ /** Unique identifier for this feed page, used to persist the active tab/mode in localStorage. Defaults to 'home'. */
feedId?: string;
}
+const FEED_MODES: readonly FeedMode[] = ['agora', 'all-nostr', 'following'] as const;
+
+function isFeedMode(value: string): value is FeedMode {
+ return (FEED_MODES as readonly string[]).includes(value);
+}
+
export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, feedId = 'home' }: FeedProps = {}) {
const { user } = useCurrentUser();
- const navigate = useNavigate();
const { muteItems } = useMuteList();
- const { savedFeeds } = useSavedFeeds();
- const navHidden = useNavHidden();
-
- // Tab settings from localStorage
- const showGlobalFeed = (() => {
- const stored = localStorage.getItem('ditto:showGlobalFeed');
- return stored !== null ? stored === 'true' : false;
- })();
-
- const showWorldFeed = (() => {
- const stored = localStorage.getItem('agora:showWorldFeed');
- return stored !== null ? stored === 'true' : true;
- })();
-
- const showCommunityFeed = (() => {
- const stored = localStorage.getItem('ditto:showCommunityFeed');
- return stored !== null ? stored === 'true' : false;
- })();
-
- const communityLabel = (() => {
- try {
- const stored = localStorage.getItem('ditto:community');
- if (stored) {
- const community = JSON.parse(stored);
- return community.label || 'Community';
- }
- } catch {
- // Fall through
- }
- return 'Community';
- })();
const [rawActiveTab, handleSetActiveTab] = useFeedTab
(feedId);
const [authDialogOpen, setAuthDialogOpen] = useState(false);
+ const isHomeAgoraFeed = !kinds && !tagFilters;
- // Kind-specific pages only support Follows + Global. Clamp any other
- // persisted tab (e.g. 'world', 'communities') back to the appropriate default.
- // Logged-out users on the home feed land on 'world' to see global content.
+ // For the home /feed page we use a three-mode picker instead of the
+ // Follows/Global tab pair. Mode persists via the same useFeedTab storage,
+ // keyed under the same feedId.
+ const homeFeedMode: FeedMode = (() => {
+ if (!isHomeAgoraFeed) return 'agora';
+ if (isFeedMode(rawActiveTab)) return rawActiveTab;
+ // Legacy values get coerced to the Agora default.
+ return 'agora';
+ })();
+
+ // Specialized feed pages keep the original Follows + Global tabs.
const activeTab: FeedTab = (() => {
- if (!kinds) {
- // Migrate legacy 'ditto' tab to 'world'
- if (rawActiveTab === 'ditto') return 'world';
- // Legacy hashtag:/geotag: tabs are now part of the combined Following
- // feed; surface them there instead of rendering a missing sub-feed.
- if (rawActiveTab.startsWith('hashtag:') || rawActiveTab.startsWith('geotag:')) return 'follows';
- return rawActiveTab;
- }
+ if (isHomeAgoraFeed) return homeFeedMode;
if (rawActiveTab === 'global') return 'global';
if (rawActiveTab === 'follows' && user) return 'follows';
return user ? 'follows' : 'global';
})();
- // Is the active tab a saved feed?
- const activeSavedFeed = useMemo(
- () => savedFeeds.find((f) => f.id === activeTab) ?? null,
- [savedFeeds, activeTab],
- );
-
// Migrate legacy hashtag:/geotag: tabs (which used to render their own
// sub-feeds) back to the home Following feed. Followed hashtags/geotags
// now contribute to the combined Following feed instead of getting
@@ -119,60 +84,51 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
}
}, [rawActiveTab, handleSetActiveTab]);
+ const handleModeChange = (mode: FeedMode) => {
+ handleSetActiveTab(mode);
+ };
+
// Kind-specific pages (e.g. Development, WebXDC) only show Follows + Global tabs.
- // Extra tabs (World, Community, saved feeds) are only for the home feed.
const isKindSpecificPage = !!kinds;
- // When logged out (and not on a kind-specific page), show the World feed.
- const useWorldForLoggedOut = !user && !kinds;
+ // -------------------------------------------------------------------------
+ // Home feed (mixed-mode): drives off useMixedFeed.
+ // -------------------------------------------------------------------------
+ const mixedFeed = useMixedFeed(homeFeedMode, isHomeAgoraFeed);
- // When the World tab is active (logged in), show the world feed.
- // Disabled on kind-specific pages — the World tab is not shown there.
- const useWorldTab = activeTab === 'world' && !kinds;
-
- // Is the world feed active?
- const isWorldActive = useWorldForLoggedOut || !!useWorldTab;
-
- // Standard feed query (used when logged in, or on kind-specific pages, or core tabs)
- const isHomeFollowingActive = activeTab === 'follows' && !isKindSpecificPage && !tagFilters;
- const isCoreFeedTab = activeTab === 'follows' || activeTab === 'network' || activeTab === 'global' || activeTab === 'communities' || activeTab === 'world';
+ // -------------------------------------------------------------------------
+ // Specialized feed pages: original Follows/Global behavior.
+ // -------------------------------------------------------------------------
+ const isHomeFollowingActive = activeTab === 'follows' && !isKindSpecificPage && !tagFilters && !isHomeAgoraFeed;
+ const isCoreFeedTab = activeTab === 'follows' || activeTab === 'network' || activeTab === 'global' || activeTab === 'communities' || activeTab === 'world' || activeTab === 'agora';
type UseFeedTab = 'follows' | 'network' | 'global' | 'communities';
const feedTabForQuery: UseFeedTab =
activeTab === 'follows'
- ? (isHomeFollowingActive ? 'network' : 'network')
+ ? 'network'
: activeTab === 'network' || activeTab === 'global' || activeTab === 'communities'
? (activeTab as UseFeedTab)
: 'global';
const standardFeedOptions = (kinds || tagFilters)
- ? { kinds, tagFilters, enabled: !isHomeFollowingActive }
- : { enabled: !isHomeFollowingActive };
+ ? { kinds, tagFilters, enabled: !isHomeFollowingActive && !isHomeAgoraFeed }
+ : { enabled: !isHomeFollowingActive && !isHomeAgoraFeed };
const feedQuery = useFeed(
- isCoreFeedTab && !isWorldActive ? feedTabForQuery : 'global',
+ isCoreFeedTab && !isHomeAgoraFeed ? feedTabForQuery : 'global',
standardFeedOptions,
);
const followingFeed = useFollowingFeed(isHomeFollowingActive);
- // World feed: all country-tagged events with diversity cap + live streaming.
- const worldFeed = useWorldFeed(isWorldActive);
- const { flushStreamBuffer } = worldFeed;
-
// For non-world tabs, use the standard feed query
const queryKey = useMemo(
- () => isWorldActive
- ? ['world-feed']
- : isHomeFollowingActive
- ? [['feed', 'network'], ['community-activity-feed'], ['following-country-feed']]
- : ['feed', activeTab],
- [isWorldActive, isHomeFollowingActive, activeTab],
+ () => isHomeAgoraFeed
+ ? ['mixed-feed', homeFeedMode]
+ : isHomeFollowingActive
+ ? [['feed', 'network'], ['community-activity-feed'], ['following-country-feed']]
+ : ['feed', activeTab],
+ [isHomeAgoraFeed, homeFeedMode, isHomeFollowingActive, activeTab],
);
const handleRefresh = usePageRefresh(queryKey);
- const handleWorldRefresh = useCallback(async () => {
- flushStreamBuffer();
- await handleRefresh();
- window.scrollTo({ top: 0, behavior: 'smooth' });
- }, [flushStreamBuffer, handleRefresh]);
const {
data: rawData,
@@ -184,16 +140,16 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
} = isHomeFollowingActive ? followingFeed : feedQuery;
// Unify pagination interface
- const fetchNextPage = isWorldActive ? worldFeed.fetchNextPage : fetchNextPageStandard;
- const hasNextPage = isWorldActive ? worldFeed.hasNextPage : hasNextPageStandard;
- const isFetchingNextPage = isWorldActive ? worldFeed.isFetchingNextPage : isFetchingNextPageStandard;
+ const fetchNextPage = isHomeAgoraFeed ? mixedFeed.fetchNextPage : fetchNextPageStandard;
+ const hasNextPage = isHomeAgoraFeed ? mixedFeed.hasNextPage : hasNextPageStandard;
+ const isFetchingNextPage = isHomeAgoraFeed ? mixedFeed.isFetchingNextPage : isFetchingNextPageStandard;
// Auto-fetch page 2 as soon as page 1 arrives for smoother scrolling
useEffect(() => {
- if (!isHomeFollowingActive && !isWorldActive && hasNextPage && !isFetchingNextPage && rawData?.pages?.length === 1) {
+ if (!isHomeFollowingActive && !isHomeAgoraFeed && hasNextPage && !isFetchingNextPage && rawData?.pages?.length === 1) {
fetchNextPage();
}
- }, [isHomeFollowingActive, isWorldActive, hasNextPage, isFetchingNextPage, rawData?.pages?.length, fetchNextPage]);
+ }, [isHomeFollowingActive, isHomeAgoraFeed, hasNextPage, isFetchingNextPage, rawData?.pages?.length, fetchNextPage]);
// Intersection observer for infinite scroll
const { ref: scrollRef, inView } = useInView({
@@ -209,9 +165,8 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
// Flatten, deduplicate, and filter muted content.
const feedItems = useMemo(() => {
- if (isWorldActive) {
- // World feed: events are already filtered/deduped by useWorldFeed
- return worldFeed.events.map((event): FeedItem => ({ event, sortTimestamp: event.created_at }));
+ if (isHomeAgoraFeed) {
+ return mixedFeed.items;
}
if (!rawData?.pages) return [];
@@ -227,79 +182,67 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
if (muteItems.length > 0 && isEventMuted(item.event, muteItems)) return false;
return true;
});
- }, [isWorldActive, worldFeed.events, rawData?.pages, muteItems]);
+ }, [isHomeAgoraFeed, mixedFeed.items, rawData?.pages, muteItems]);
// Show skeletons while loading.
- const showSkeleton = isWorldActive
- ? worldFeed.isLoading
- : (isPending || (isLoading && !rawData));
+ const showSkeleton = isHomeAgoraFeed
+ ? mixedFeed.isLoading && feedItems.length === 0
+ : (isPending || (isLoading && !rawData));
- const showSavedFeedTabs = user && !isKindSpecificPage && !tagFilters;
+ // Per-mode empty-state copy for the home feed.
+ const homeEmptyMessage = (() => {
+ if (homeFeedMode === 'agora') {
+ return "Quiet moment on Agora. New campaigns, pledges, donations, and posts will appear here as they happen.";
+ }
+ if (homeFeedMode === 'following') {
+ return user
+ ? "Your follow feed is empty. Follow some people to see what they're up to, or switch to Agora or All Nostr."
+ : "Log in to see posts from people you follow.";
+ }
+ return 'Nothing to show. Check your relay connections or try again in a moment.';
+ })();
return (
-
- {header}
+
+
+ {header}
- {/* CTA (logged out, main feed only) */}
- {!user && !kinds && (
-
setAuthDialogOpen(true)} />
- )}
+ {/* CTA (logged out, main feed only) */}
+ {!user && !kinds && (
+ setAuthDialogOpen(true)} />
+ )}
- {!hideCompose && }
-
- {/* Tabs (logged in) */}
- {user && (
-
- handleSetActiveTab('follows')} />
- {!isKindSpecificPage && !tagFilters && (
- handleSetActiveTab('network')} />
- )}
- {!isKindSpecificPage && showWorldFeed && (
- handleSetActiveTab('world')} />
- )}
- {!isKindSpecificPage && showCommunityFeed && (
- handleSetActiveTab('communities')} />
- )}
- {(isKindSpecificPage || showGlobalFeed) && (
- handleSetActiveTab('global')} />
- )}
- {showSavedFeedTabs && savedFeeds.map((feed) => (
- handleSetActiveTab(feed.id)}
+ {/* Home-feed mode switcher: top-left, anchors the page visually */}
+ {isHomeAgoraFeed && (
+
+ setAuthDialogOpen(true)}
/>
- ))}
-
- )}
+
+ )}
- {/* Feed content — saved feed tab gets its own stream */}
- {activeSavedFeed ? (
-
- ) : (
-
- {/* "X new posts" pill for World tab */}
- {isWorldActive && worldFeed.newPostCount > 0 && (
-
- {
- worldFeed.flushStreamBuffer();
- window.scrollTo({ top: 0, behavior: 'smooth' });
- }}
- className="pointer-events-auto px-4 py-1.5 rounded-full bg-primary text-primary-foreground text-sm font-medium shadow-lg hover:bg-primary/90 transition-colors animate-in fade-in slide-in-from-top-2 duration-300"
- >
- {worldFeed.newPostCount} new post{worldFeed.newPostCount !== 1 ? 's' : ''}
-
-
- )}
+ {!hideCompose && (
+
+ )}
+
+ {/* Tabs are only kept for specialized feed pages. The home feed uses
+ the FeedModeSwitcher above. */}
+ {user && (isKindSpecificPage || tagFilters) && (
+
+ handleSetActiveTab('follows')} />
+ handleSetActiveTab('global')} />
+
+ )}
+
+
{showSkeleton ? (
{Array.from({ length: 5 }).map((_, i) => (
@@ -313,7 +256,6 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
key={item.repostedBy ? `repost-${item.repostedBy}-${item.event.id}` : item.event.id}
event={item.event}
repostedBy={item.repostedBy}
- highlight={isWorldActive && worldFeed.flushedIds.has(item.event.id)}
/>
))}
{hasNextPage && (
@@ -326,157 +268,78 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
)}
- ) : isHomeFollowingActive && !emptyMessage ? (
- navigate('/world')} />
- ) : activeTab === 'network' && !emptyMessage ? (
- navigate('/packs')} />
+ ) : isHomeAgoraFeed ? (
+ handleModeChange('agora') : undefined}
+ onLoginClick={!user && homeFeedMode === 'following' ? () => setAuthDialogOpen(true) : undefined}
+ />
) : (
handleSetActiveTab('global')
: undefined
}
/>
)}
- )}
- {/* Auth dialog (only needed on main feed) */}
- {!kinds && (
- setAuthDialogOpen(false)}
- />
- )}
+ {/* Auth dialog (only needed on main feed) */}
+ {!kinds && (
+ setAuthDialogOpen(false)}
+ />
+ )}
+
);
}
-/** Renders a saved search feed using useTabFeed (TanStack Query cached, infinite scroll). */
-function SavedFeedContent({ feed }: { feed: SavedFeed }) {
- const { ref: scrollRef, inView } = useInView({ threshold: 0, rootMargin: '400px' });
- const { user } = useCurrentUser();
- const { muteItems } = useMuteList();
-
- // Resolve variable placeholders ($follows etc.) the same way profile tabs do
- const { filter: resolvedFilter, isLoading: isResolving } = useResolveTabFilter(
- feed.filter,
- feed.vars ?? [],
- user?.pubkey ?? '',
- );
-
- // Augment the resolved filter with protocol:nostr (NIP-50 Ditto extension)
- // to match the behavior of the core feeds and ensure latest native Nostr
- // posts are returned.
- const augmentedFilter = useMemo(() => {
- if (!resolvedFilter) return null;
- const existing = resolvedFilter.search ?? '';
- const search = existing.includes('protocol:nostr')
- ? existing
- : existing
- ? `${existing} protocol:nostr`
- : 'protocol:nostr';
- return { ...resolvedFilter, search };
- }, [resolvedFilter]);
-
- const {
- data: rawData,
- isLoading: isFeedLoading,
- fetchNextPage,
- hasNextPage,
- isFetchingNextPage,
- } = useTabFeed(augmentedFilter, `saved-${feed.id}`, !isResolving);
-
- const isLoading = isResolving || isFeedLoading;
-
- // Prefix key -- usePageRefresh does prefix matching, so this invalidates
- // the full ['tab-feed', tabKey, kindsKey, authorsKey, searchKey] used by useTabFeed.
- const queryKey = useMemo(
- () => ['tab-feed', `saved-${feed.id}`],
- [feed.id],
- );
- const handleRefresh = usePageRefresh(queryKey);
-
- // Infinite scroll: fetch next page when sentinel is in view
- useEffect(() => {
- if (inView && hasNextPage && !isFetchingNextPage) {
- fetchNextPage();
- }
- }, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]);
-
- // Flatten pages, deduplicate, and filter muted content
- const feedItems = useMemo(() => {
- if (!rawData?.pages) return [];
- const seen = new Set
();
- return rawData.pages
- .flatMap((page) => page.items)
- .filter((item) => {
- const key = item.repostedBy ? `repost-${item.repostedBy}-${item.event.id}` : item.event.id;
- if (!key || seen.has(key)) return false;
- seen.add(key);
- if (shouldHideFeedEvent(item.event)) return false;
- if (muteItems.length > 0 && isEventMuted(item.event, muteItems)) return false;
- return true;
- });
- }, [rawData?.pages, muteItems]);
-
- if (isLoading && feedItems.length === 0) {
- return (
-
- {Array.from({ length: 5 }).map((_, i) => (
-
- ))}
-
- );
- }
-
- if (feedItems.length === 0) {
- return (
-
-
-
- );
- }
+interface HomeFeedEmptyStateProps {
+ mode: FeedMode;
+ message: string;
+ onSwitchToAgora?: () => void;
+ onLoginClick?: () => void;
+}
+function HomeFeedEmptyState({ mode, message, onSwitchToAgora, onLoginClick }: HomeFeedEmptyStateProps) {
return (
-
-
- {feedItems.map((item) => (
-
- ))}
- {hasNextPage && (
-
- {isFetchingNextPage && (
-
-
-
- )}
-
+
+
{message}
+
+ {onLoginClick && (
+
+ Log in
+
+ )}
+ {onSwitchToAgora && (
+
+ Browse the Agora feed
+
)}
- {!hasNextPage &&
}
-
+
);
}
-function NoteCardSkeleton() {
+function NoteCardSkeleton({ className }: { className?: string }) {
return (
-
+
@@ -497,47 +360,3 @@ function NoteCardSkeleton() {
);
}
-
-function FollowingEmptyState({ onExploreWorld }: { onExploreWorld: () => void }) {
- return (
-
-
-
-
-
-
No activity yet
-
- Your Following feed is quiet right now. Visit World to discover more global activity.
-
-
-
-
-
- Visit World
-
-
-
- );
-}
-
-function NetworkEmptyState({ onDiscoverPeople }: { onDiscoverPeople: () => void }) {
- return (
-
-
-
-
-
-
No network activity yet
-
- Follow more people to fill your Network feed.
-
-
-
-
-
- Discover people
-
-
-
- );
-}
diff --git a/src/components/FeedModeSwitcher.tsx b/src/components/FeedModeSwitcher.tsx
new file mode 100644
index 00000000..f79aa6e4
--- /dev/null
+++ b/src/components/FeedModeSwitcher.tsx
@@ -0,0 +1,125 @@
+import { Check, ChevronDown, Globe, Sparkles, Users } from 'lucide-react';
+import type { ComponentType } from 'react';
+
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu';
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from '@/components/ui/tooltip';
+import type { FeedMode } from '@/hooks/useMixedFeed';
+import { cn } from '@/lib/utils';
+
+interface FeedModeOption {
+ mode: FeedMode;
+ label: string;
+ icon: ComponentType<{ className?: string }>;
+}
+
+const OPTIONS: FeedModeOption[] = [
+ { mode: 'agora', label: 'Agora', icon: Sparkles },
+ { mode: 'all-nostr', label: 'All Nostr', icon: Globe },
+ { mode: 'following', label: 'Following', icon: Users },
+];
+
+interface FeedModeSwitcherProps {
+ value: FeedMode;
+ onChange: (mode: FeedMode) => void;
+ /** When false, Following mode is disabled (requires login). */
+ followingAvailable: boolean;
+ /** Click handler for the disabled Following item (typically opens the auth dialog). */
+ onLoginRequested?: () => void;
+ className?: string;
+}
+
+/**
+ * The primary feed-mode picker rendered at the top-left of the home feed page.
+ *
+ * Visually anchored as the page heading — the active mode label is the largest
+ * text on the page. Clicking opens a compact dropdown menu offering the three
+ * modes; the active one is marked with a check.
+ *
+ * Logged-out users see "Following" greyed out; clicking it invokes
+ * {@link FeedModeSwitcherProps.onLoginRequested} to surface the auth dialog.
+ */
+export function FeedModeSwitcher({
+ value,
+ onChange,
+ followingAvailable,
+ onLoginRequested,
+ className,
+}: FeedModeSwitcherProps) {
+ const active = OPTIONS.find((opt) => opt.mode === value) ?? OPTIONS[0];
+
+ return (
+
+
+
+ {active.label}
+
+
+
+
+ {OPTIONS.map((opt) => {
+ const Icon = opt.icon;
+ const isActive = opt.mode === value;
+ const isFollowing = opt.mode === 'following';
+ const disabled = isFollowing && !followingAvailable;
+
+ const handleSelect = (event: Event) => {
+ if (disabled) {
+ event.preventDefault();
+ onLoginRequested?.();
+ return;
+ }
+ onChange(opt.mode);
+ };
+
+ const itemContent = (
+
+
+ {opt.label}
+ {isActive && }
+
+ );
+
+ if (disabled) {
+ return (
+
+ {itemContent}
+
+ Log in to see posts from people you follow
+
+
+ );
+ }
+ return itemContent;
+ })}
+
+
+ );
+}
diff --git a/src/components/FloatingComposeButton.tsx b/src/components/FloatingComposeButton.tsx
index f15f4c56..bd85f227 100644
--- a/src/components/FloatingComposeButton.tsx
+++ b/src/components/FloatingComposeButton.tsx
@@ -1,6 +1,7 @@
import { lazy, Suspense, useState } from 'react';
-import { Plus, Construction } from 'lucide-react';
+import { Construction } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
+import { LogoIcon } from '@/components/icons/LogoIcon';
import { Button } from '@/components/ui/button';
import {
Dialog,
@@ -41,26 +42,43 @@ export function FloatingComposeButton({ kind = 1, href, onFabClick, icon, menu }
return null;
}
- const renderedIcon = icon ??
;
+ const hasCustomIcon = icon !== undefined;
+ const renderedIcon = icon;
+ const logoButtonClassName = "relative size-20 text-primary transition-transform hover:scale-105 active:scale-95 disabled:opacity-40 disabled:pointer-events-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-sm";
+ const logoButtonStyle = { filter: 'drop-shadow(0 3px 10px hsl(var(--primary) / 0.28))' };
// ── Menu mode — anchor a Popover to the FAB itself ────────────────────────
if (menu && menu.length > 0) {
return (
-
-
-
- {renderedIcon}
-
-
+ {hasCustomIcon ? (
+
+
+
+ {renderedIcon}
+
+
+ ) : (
+
+
+
+
+ )}
-
+ {hasCustomIcon ? (
+
+ ) : (
+
+
+
+
+ )}
{/* Kind 1: Compose modal (lazy-loaded) */}
{kind === 1 && composeOpen && (
diff --git a/src/components/FollowPackDetailContent.tsx b/src/components/FollowPackDetailContent.tsx
index 397c3d98..c7f18b19 100644
--- a/src/components/FollowPackDetailContent.tsx
+++ b/src/components/FollowPackDetailContent.tsx
@@ -15,12 +15,9 @@ import { useAuthor } from '@/hooks/useAuthor';
import { useAuthors } from '@/hooks/useAuthors';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useFollowList, useFollowActions } from '@/hooks/useFollowActions';
-import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useStreamPosts } from '@/hooks/useStreamPosts';
import { useMuteList } from '@/hooks/useMuteList';
import { isEventMuted } from '@/lib/muteHelpers';
-import { useNostr } from '@nostrify/react';
-import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
import { genUserName } from '@/lib/genUserName';
import { parsePackEvent } from '@/lib/packUtils';
import { VerifiedNip05Text } from '@/components/Nip05Badge';
@@ -139,10 +136,9 @@ export function PackMembersTab({
*/
export function FollowPackDetailContent({ event }: { event: NostrEvent }) {
const { toast } = useToast();
- const { nostr } = useNostr();
const { user } = useCurrentUser();
const { data: followList } = useFollowList();
- const { mutateAsync: publishEvent } = useNostrPublish();
+ const { followMany } = useFollowActions();
const author = useAuthor(event.pubkey);
const metadata = author.data?.metadata;
@@ -175,27 +171,7 @@ export function FollowPackDetailContent({ event }: { event: NostrEvent }) {
setIsFollowingAll(true);
try {
- // 1. Fetch freshest kind 3 from relays (not cache)
- const prev = await fetchFreshEvent(nostr, { kinds: [3], authors: [user.pubkey] });
-
- // 2. Separate p-tags from non-p-tags to preserve relay hints, petnames, etc.
- const existingPTags = prev?.tags.filter(([n]) => n === 'p') ?? [];
- const nonPTags = prev?.tags.filter(([n]) => n !== 'p') ?? [];
- const existingPubkeys = new Set(existingPTags.map(([, pk]) => pk));
-
- // 3. Merge: add new pubkeys that aren't already followed
- const newPTags = pubkeys
- .filter((pk) => !existingPubkeys.has(pk))
- .map((pk) => ['p', pk]);
- const added = newPTags.length;
-
- // 4. Publish with prev for published_at preservation
- await publishEvent({
- kind: 3,
- content: prev?.content ?? '',
- tags: [...nonPTags, ...existingPTags, ...newPTags],
- prev: prev ?? undefined,
- });
+ const added = await followMany(pubkeys);
toast({
title: 'Following all!',
@@ -213,7 +189,7 @@ export function FollowPackDetailContent({ event }: { event: NostrEvent }) {
} finally {
setIsFollowingAll(false);
}
- }, [user, pubkeys, nostr, publishEvent, toast]);
+ }, [user, pubkeys, followMany, toast]);
const handleCopyLink = useCallback(() => {
const dTag = event.tags.find(([n]) => n === 'd')?.[1] ?? '';
diff --git a/src/components/FundraiserLayout.tsx b/src/components/FundraiserLayout.tsx
index 8e7304ba..c1626910 100644
--- a/src/components/FundraiserLayout.tsx
+++ b/src/components/FundraiserLayout.tsx
@@ -1,6 +1,7 @@
import { Suspense, useCallback, useMemo, useRef, useState } from 'react';
import { Link, Outlet } from 'react-router-dom';
+import { FloatingComposeButton } from '@/components/FloatingComposeButton';
import { TopNav } from '@/components/TopNav';
import { Skeleton } from '@/components/ui/skeleton';
import {
@@ -48,7 +49,7 @@ function PageSkeleton() {
function FundraiserLayoutInner() {
const centerColumnRef = useRef(null);
const [centerColumnEl, setCenterColumnEl] = useState(null);
- const { noMaxWidth, wrapperClassName } = useLayoutSnapshot();
+ const { noMaxWidth, wrapperClassName, showFAB = false, fabKind = 1, fabHref, onFabClick, fabIcon, fabMenu } = useLayoutSnapshot();
// Mobile drawer is owned by TopNav now, so consumers of `useOpenDrawer`
// become no-ops. Keeping the context shape avoids touching every page that
@@ -83,6 +84,14 @@ function FundraiserLayoutInner() {
+ {showFAB && (
+
+ )}
+
@@ -93,7 +102,7 @@ function FundraiserLayoutInner() {
function SiteFooter() {
return (
-
+
© {new Date().getFullYear()} Agora. Fundraisers on Nostr.
diff --git a/src/components/HDSendBitcoinDialog.tsx b/src/components/HDSendBitcoinDialog.tsx
new file mode 100644
index 00000000..c10efb3a
--- /dev/null
+++ b/src/components/HDSendBitcoinDialog.tsx
@@ -0,0 +1,741 @@
+import { useState, useCallback, useMemo, useRef, useEffect } from 'react';
+import { Link } from 'react-router-dom';
+import { nip19 } from 'nostr-tools';
+import {
+ AlertTriangle,
+ Check,
+ ExternalLink,
+ Loader2,
+ X,
+} from 'lucide-react';
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import {
+ Dialog,
+ DialogContent,
+ DialogTitle,
+} from '@/components/ui/dialog';
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from '@/components/ui/popover';
+import { Alert, AlertDescription } from '@/components/ui/alert';
+import { BitcoinPublicDisclaimer } from '@/components/BitcoinPublicDisclaimer';
+import { cn } from '@/lib/utils';
+
+import { useToast } from '@/hooks/useToast';
+import { useAppContext } from '@/hooks/useAppContext';
+import { useHdWalletAccess } from '@/hooks/useHdWalletAccess';
+import { useHdWallet } from '@/hooks/useHdWallet';
+import { notificationSuccess } from '@/lib/haptics';
+import {
+ isLargeAmount,
+ nostrPubkeyToBitcoinAddress,
+ satsToUSD,
+} from '@/lib/bitcoin';
+import {
+ broadcastBlockbookTx,
+ type BlockbookFeeRates,
+ fetchFeeRates,
+} from '@/lib/hdwallet/blockbook';
+import {
+ buildHdSpendPsbt,
+ finalizeHdPsbt,
+ type HdInput,
+ type HdSpendableSpUtxo,
+ type HdSpendableUtxo,
+ parseHdRecipient,
+ previewHdFee,
+ signHdPsbt,
+} from '@/lib/hdwallet/transaction';
+import { useQuery } from '@tanstack/react-query';
+
+// ---------------------------------------------------------------------------
+// Constants
+// ---------------------------------------------------------------------------
+
+const USD_PRESETS = [1, 5, 10, 25, 100];
+
+type FeeSpeed = 'fastest' | 'halfHour' | 'hour' | 'economy';
+
+const FEE_SPEED_LABELS: Record = {
+ fastest: '~10 min',
+ halfHour: '~30 min',
+ hour: '~1 hour',
+ economy: '~1 day',
+};
+
+const FEE_SPEED_ORDER: FeeSpeed[] = ['fastest', 'halfHour', 'hour', 'economy'];
+
+function getRateForSpeed(rates: BlockbookFeeRates, speed: FeeSpeed): number {
+ switch (speed) {
+ case 'fastest': return rates.fastestFee;
+ case 'halfHour': return rates.halfHourFee;
+ case 'hour': return rates.hourFee;
+ case 'economy': return rates.economyFee;
+ }
+}
+
+function getUniqueFeeSpeeds(rates: BlockbookFeeRates | undefined): FeeSpeed[] {
+ if (!rates) return FEE_SPEED_ORDER;
+ const seen = new Set();
+ const result: FeeSpeed[] = [];
+ for (const speed of FEE_SPEED_ORDER) {
+ const rate = getRateForSpeed(rates, speed);
+ if (!seen.has(rate)) { seen.add(rate); result.push(speed); }
+ }
+ return result;
+}
+
+// ---------------------------------------------------------------------------
+// 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
+// ---------------------------------------------------------------------------
+
+interface HDSendBitcoinDialogProps {
+ isOpen: boolean;
+ onClose: () => void;
+ /** BTC/USD price — passed in to avoid duplicate fetches. */
+ btcPrice?: number;
+}
+
+interface SendResult {
+ txid: string;
+ amountSats: number;
+ fee: number;
+ /**
+ * Silent-payment UTXOs (`(txid, vout)`) consumed by the broadcast tx.
+ * Pruned from local SP storage in `onSuccess` — otherwise the wallet
+ * would keep treating them as spendable and the displayed balance would
+ * jump *up* after the spend (because the BIP-86 change credits to
+ * Blockbook's xpub balance while the SP entries remain locally).
+ */
+ consumedSpUtxos: Array<{ txid: string; vout: number }>;
+}
+
+/**
+ * "Send Bitcoin" dialog for the HD wallet at `/wallet`.
+ *
+ * Provides a large editable USD amount, preset chips, fee speed picker, two-tap
+ * arming for large amounts, and a privacy disclaimer for raw addresses. Uses
+ * 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 }: HDSendBitcoinDialogProps) {
+ const availability = useHdWalletAccess();
+ const {
+ scan,
+ silentPaymentBalance,
+ silentPaymentStorage,
+ refetch: refetchWallet,
+ pruneSpentSilentPaymentUtxos,
+ } = useHdWallet();
+ const { config } = useAppContext();
+ const { blockbookBaseUrl } = config;
+ const { toast } = useToast();
+ const queryClient = useQueryClient();
+
+ const isReady = availability.status === 'available';
+
+ // ── Form state ───────────────────────────────────────────────
+ const [recipientInput, setRecipientInput] = useState('');
+ const [usdAmount, setUsdAmount] = useState(5);
+ const [feeSpeed, setFeeSpeed] = useState('halfHour');
+ const [error, setError] = useState('');
+ const [editingAmount, setEditingAmount] = useState(false);
+ const [feePopoverOpen, setFeePopoverOpen] = useState(false);
+ const [success, setSuccess] = useState(null);
+
+ const amountInputRef = useRef(null);
+ const feeSpeedUserChanged = useRef(false);
+
+ const recipient = useMemo(() => resolveRecipient(recipientInput), [recipientInput]);
+
+ // ── Fee rates ────────────────────────────────────────────────
+ const { data: feeRates } = 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 getRateForSpeed(feeRates, feeSpeed);
+ }, [feeRates, feeSpeed]);
+
+ // ── Owned UTXO set ───────────────────────────────────────────
+ //
+ // Combines BIP-86 UTXOs scanned from Blockbook with silent-payment UTXOs
+ // discovered by the BIP-352 scanner and persisted via NIP-78. Both can
+ // fund a send; the PSBT builder dispatches per-input.
+ const bip86Utxos: HdSpendableUtxo[] = useMemo(() => scan?.utxos ?? [], [scan]);
+ const spUtxos: HdSpendableSpUtxo[] = useMemo(
+ () =>
+ (silentPaymentStorage?.utxos ?? []).map((u) => ({
+ txid: u.txid,
+ vout: u.vout,
+ value: u.value,
+ tweakHex: u.tweak,
+ k: u.k,
+ height: u.height,
+ })),
+ [silentPaymentStorage],
+ );
+ const ownedInputs: HdInput[] = useMemo(
+ () => [
+ ...bip86Utxos.map((utxo) => ({ kind: 'bip86', utxo })),
+ ...spUtxos.map((utxo) => ({ kind: 'sp', utxo })),
+ ],
+ [bip86Utxos, spUtxos],
+ );
+ const totalBalance = useMemo(
+ () => bip86Utxos.reduce((s, u) => s + u.value, 0) + silentPaymentBalance,
+ [bip86Utxos, silentPaymentBalance],
+ );
+
+ // ── USD → sats ───────────────────────────────────────────────
+ const amountSats = useMemo(() => {
+ if (!btcPrice) return 0;
+ const usd = typeof usdAmount === 'string' ? parseFloat(usdAmount) : usdAmount;
+ if (!Number.isFinite(usd) || usd <= 0) return 0;
+ return Math.round((usd / btcPrice) * 100_000_000);
+ }, [usdAmount, btcPrice]);
+
+ // ── Fee estimate (matches the actual coin selection) ────────
+ //
+ // Crucially we do NOT use `ownedInputs.length` as the input count: an HD
+ // wallet typically has many UTXOs across many addresses, but a real send
+ // only consumes the minimal set the coin selector picks. Using the full
+ // count would over-estimate fees by 10x or more on an active wallet, and
+ // would also make the UI think we're insufficient when we're not.
+ const estimatedFeeSats = useMemo(() => {
+ if (!ownedInputs.length || !currentFeeRate || !amountSats) return 0;
+ return previewHdFee(ownedInputs, amountSats, currentFeeRate);
+ }, [ownedInputs, currentFeeRate, amountSats]);
+
+ const totalSats = amountSats + estimatedFeeSats;
+ // `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 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 (!ownedInputs.length || !feeRates || amountSats <= 0) return;
+
+ const uniqueSpeeds = getUniqueFeeSpeeds(feeRates);
+ const threshold = amountSats * 0.4;
+
+ let target: FeeSpeed = uniqueSpeeds[uniqueSpeeds.length - 1];
+ for (const speed of uniqueSpeeds) {
+ const rate = getRateForSpeed(feeRates, speed);
+ const fee = previewHdFee(ownedInputs, amountSats, rate);
+ if (fee > 0 && fee <= threshold) { target = speed; break; }
+ }
+ setFeeSpeed((prev) => (prev === target ? prev : target));
+ }, [amountSats, feeRates, ownedInputs, totalBalance]);
+
+ const handleFeeSpeedChange = useCallback((speed: FeeSpeed) => {
+ feeSpeedUserChanged.current = true;
+ setFeeSpeed(speed);
+ setFeePopoverOpen(false);
+ }, []);
+
+ // ── Two-tap arm + raw-address disclaimer ─────────────────────
+ const isLarge = isLargeAmount(totalSats, btcPrice);
+ // 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;
+ const [confirmArmed, setConfirmArmed] = useState(false);
+ const [acknowledgedPublic, setAcknowledgedPublic] = useState(false);
+
+ useEffect(() => {
+ setConfirmArmed(false);
+ }, [amountSats, 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]);
+
+ const requiresArm = isLarge || isRawAddress;
+
+ // ── Amount focus management ──────────────────────────────────
+ useEffect(() => {
+ if (editingAmount) {
+ amountInputRef.current?.focus();
+ amountInputRef.current?.select();
+ }
+ }, [editingAmount]);
+
+ const commitAmountEdit = useCallback(() => {
+ setEditingAmount(false);
+ if (typeof usdAmount === 'string' && usdAmount.trim() === '') setUsdAmount(0);
+ }, [usdAmount]);
+
+ // ── Send mutation ────────────────────────────────────────────
+ const [progress, setProgress] = useState<'idle' | 'building' | 'signing' | 'broadcasting'>('idle');
+
+ const sendMutation = useMutation({
+ mutationFn: async () => {
+ if (availability.status !== 'available') {
+ throw new Error('HD wallet is not available for this login type.');
+ }
+ if (!recipient) throw new Error('Enter a Bitcoin address, sp1… address, or npub.');
+ if (!ownedInputs.length) throw new Error('No spendable Bitcoin in this wallet.');
+ if (!feeRates) throw new Error('Fee rates not loaded.');
+ if (recipient.pubkey === availability.pubkey) throw new Error("You can't send to yourself.");
+ if (amountSats <= 0) throw new Error('Enter an amount.');
+ if (insufficient) throw new Error('Not enough Bitcoin for this amount + network fee.');
+
+ const rate = getRateForSpeed(feeRates, feeSpeed);
+ const nextChangeIndex = scan?.change.firstUnusedIndex ?? 0;
+
+ 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,
+ nsecBytes: availability.nsecBytes,
+ });
+
+ setProgress('signing');
+ const signedHex = signHdPsbt(
+ built.psbtHex,
+ built.inputDescriptors,
+ availability.account,
+ availability.nsecBytes,
+ );
+ const txHex = finalizeHdPsbt(signedHex);
+
+ setProgress('broadcasting');
+ const txid = await broadcastBlockbookTx(blockbookBaseUrl, txHex);
+
+ return { txid, amountSats, fee: built.fee, consumedSpUtxos: built.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:
+ // the balance would still count them, the coin selector would try
+ // to spend them again (resulting in "missing/spent input" broadcast
+ // errors), and the wallet would appear to *gain* money on each SP
+ // spend (BIP-86 change is observed by Blockbook, but the consumed
+ // SP value is not subtracted locally).
+ if (result.consumedSpUtxos.length > 0) {
+ pruneSpentSilentPaymentUtxos(result.consumedSpUtxos);
+ }
+ void refetchWallet();
+ },
+ onError: (err) => {
+ toast({ title: 'Transaction failed', description: err.message, variant: 'destructive' });
+ },
+ onSettled: () => setProgress('idle'),
+ });
+
+ const handleSend = useCallback(() => {
+ setError('');
+ if (availability.status !== 'available') {
+ setError('HD wallet is not available for this login type.'); return;
+ }
+ if (!recipient) { setError('Enter a Bitcoin address, sp1… address, or npub.'); return; }
+ if (recipient.pubkey === availability.pubkey) { setError("You can't send to yourself."); return; }
+ if (!btcPrice) { setError('Waiting for BTC price…'); return; }
+ if (amountSats <= 0) { setError('Enter an amount.'); return; }
+ if (!ownedInputs.length) { setError("You don't have any Bitcoin yet."); return; }
+ if (insufficient) { setError('Not enough Bitcoin for this amount + network fee.'); return; }
+ if (isRawAddress && !acknowledgedPublic) {
+ setError('Acknowledge the privacy warning before sending.'); return;
+ }
+ if (requiresArm && !confirmArmed) { setConfirmArmed(true); return; }
+ sendMutation.mutate();
+ }, [
+ availability,
+ recipient,
+ btcPrice,
+ amountSats,
+ ownedInputs.length,
+ insufficient,
+ isRawAddress,
+ acknowledgedPublic,
+ requiresArm,
+ confirmArmed,
+ sendMutation,
+ ]);
+
+ // ── Reset on close ───────────────────────────────────────────
+ const handleClose = useCallback(() => {
+ if (sendMutation.isPending) return;
+ onClose();
+ // defer to allow exit animation
+ setTimeout(() => {
+ setRecipientInput('');
+ setUsdAmount(5);
+ setError('');
+ setConfirmArmed(false);
+ setAcknowledgedPublic(false);
+ setSuccess(null);
+ feeSpeedUserChanged.current = false;
+ }, 200);
+ }, [onClose, sendMutation.isPending]);
+
+ // ── Render helpers ───────────────────────────────────────────
+ const sendButtonLabel = (() => {
+ if (sendMutation.isPending) {
+ switch (progress) {
+ case 'building': return 'Building transaction…';
+ case 'signing': return 'Signing…';
+ case 'broadcasting': return 'Broadcasting…';
+ default: return 'Sending…';
+ }
+ }
+ if (confirmArmed) return 'Tap again to confirm';
+ return 'Send Bitcoin';
+ })();
+
+ const sendDisabled =
+ sendMutation.isPending ||
+ !recipient ||
+ !btcPrice ||
+ amountSats <= 0 ||
+ insufficient ||
+ !ownedInputs.length ||
+ (isRawAddress && !acknowledgedPublic);
+
+ // ── Render ───────────────────────────────────────────────────
+ return (
+ { if (!v) handleClose(); }}>
+
+ Send Bitcoin
+
+ {success ? (
+
+ ) : (
+
+ {/* Header */}
+
+
Send Bitcoin
+
+
+
+
+
+ {/* Amount */}
+
+ {editingAmount ? (
+
+ $
+ setUsdAmount(e.target.value)}
+ onBlur={commitAmountEdit}
+ onKeyDown={(e) => { if (e.key === 'Enter') commitAmountEdit(); }}
+ className="bg-transparent border-none focus-visible:ring-0 text-4xl font-bold tracking-tight w-32 text-center px-0 h-auto"
+ />
+
+ ) : (
+
setEditingAmount(true)}
+ className="text-4xl font-bold tracking-tight hover:text-primary transition-colors cursor-text"
+ >
+ ${typeof usdAmount === 'number' ? usdAmount : (parseFloat(usdAmount) || 0)}
+
+ )}
+ {amountSats > 0 && btcPrice && (
+
+ ≈ {amountSats.toLocaleString()} sats
+
+ )}
+
+
+ {/* USD presets */}
+
+ {USD_PRESETS.map((preset) => (
+ { setUsdAmount(preset); setEditingAmount(false); }}
+ className={cn(
+ 'px-3 py-1 rounded-full text-xs border transition-colors',
+ Number(usdAmount) === preset
+ ? 'bg-primary text-primary-foreground border-primary'
+ : 'border-border hover:bg-muted/50',
+ )}
+ >
+ ${preset}
+
+ ))}
+
+
+ {/* Recipient */}
+
+
+ Recipient
+
+
setRecipientInput(e.target.value)}
+ placeholder="bc1…, sp1…, or npub…"
+ autoComplete="off"
+ spellCheck={false}
+ className="font-mono text-sm"
+ />
+ {recipient && (
+
+ {recipient.kind === 'sp' ? (
+ <>Sending via a silent payment — the recipient gets a fresh, unlinkable on-chain address.>
+ ) : recipient.pubkey ? (
+ <>Sending to a Nostr user's on-chain address.>
+ ) : (
+ <>Sending to a raw Bitcoin address.>
+ )}
+
+ )}
+
+
+ {/* Privacy disclaimer for raw addresses */}
+ {isRawAddress && (
+
+ )}
+
+ {/* Fee speed */}
+
+
Network fee
+
+
+
+ {estimatedFeeSats > 0 && btcPrice ? (
+ <>≈ {satsToUSD(estimatedFeeSats, btcPrice)}>
+ ) : currentFeeRate ? (
+ <>{currentFeeRate} sat/vB>
+ ) : (
+ <>—>
+ )}
+ ·
+ {FEE_SPEED_LABELS[feeSpeed]}
+
+
+
+
+ {getUniqueFeeSpeeds(feeRates).map((speed) => (
+ 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',
+ )}
+ >
+ {FEE_SPEED_LABELS[speed]}
+ {feeRates && (
+
+ {getRateForSpeed(feeRates, speed)} sat/vB
+
+ )}
+
+ ))}
+
+
+
+
+
+ {showBalance && totalBalance > 0 && btcPrice && (
+
+ Available: {satsToUSD(totalBalance, btcPrice)} ({totalBalance.toLocaleString()} sats)
+
+ )}
+
+ {/* Error */}
+ {error && (
+
+
+ {error}
+
+ )}
+
+ {/* Send button */}
+
+ {sendMutation.isPending && }
+ {sendButtonLabel}
+
+
+ )}
+
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Success screen
+// ---------------------------------------------------------------------------
+
+interface SuccessScreenProps {
+ txid: string;
+ amountSats: number;
+ btcPrice: number | undefined;
+ onClose: () => void;
+}
+
+function SuccessScreen({ txid, amountSats, btcPrice, onClose }: SuccessScreenProps) {
+ const usdDisplay = btcPrice ? satsToUSD(amountSats, btcPrice) : '';
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
Bitcoin sent
+
+ {usdDisplay || `${amountSats.toLocaleString()} sats`}
+
+
+
+
+
+
+
+ View transaction
+
+
+ Done
+
+
+ );
+}
diff --git a/src/components/HDSilentPaymentScanDialog.tsx b/src/components/HDSilentPaymentScanDialog.tsx
new file mode 100644
index 00000000..348eaf7c
--- /dev/null
+++ b/src/components/HDSilentPaymentScanDialog.tsx
@@ -0,0 +1,292 @@
+import { useEffect, useState } from 'react';
+import { Loader2, AlertCircle, CheckCircle2 } from 'lucide-react';
+
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { Checkbox } from '@/components/ui/checkbox';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { Progress } from '@/components/ui/progress';
+import { useHdWalletSp } from '@/hooks/useHdWalletSp';
+
+// ---------------------------------------------------------------------------
+// HD wallet — silent-payment "Scan history" dialog
+// ---------------------------------------------------------------------------
+//
+// Walks the user through running a BIP-352 chain scan over a configurable
+// block range. Defaults to "from last scanned height → tip", which is the
+// common forward-catch-up case; advanced users can edit the bounds for a
+// targeted backfill.
+// ---------------------------------------------------------------------------
+
+export interface HDSilentPaymentScanDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+}
+
+export function HDSilentPaymentScanDialog({ open, onOpenChange }: HDSilentPaymentScanDialogProps) {
+ const sp = useHdWalletSp();
+ const [from, setFrom] = useState('');
+ const [to, setTo] = useState('');
+ const [touched, setTouched] = useState(false);
+ const [includeSpent, setIncludeSpent] = useState(false);
+
+ // Seed defaults whenever the dialog opens or upstream data changes.
+ useEffect(() => {
+ if (!open) {
+ setTouched(false);
+ setIncludeSpent(false);
+ return;
+ }
+ if (touched) return;
+ const tip = sp.tipHeight;
+ const lastScanned = sp.storage?.scanHeight ?? 0;
+ const defaultFrom = lastScanned > 0 ? lastScanned + 1 : tip ? Math.max(0, tip - 144) : 0;
+ setFrom(String(defaultFrom));
+ setTo(tip ? String(tip) : '');
+ }, [open, sp.tipHeight, sp.storage?.scanHeight, touched]);
+
+ const fromNum = Number(from);
+ const toNum = Number(to);
+ const fromValid = Number.isInteger(fromNum) && fromNum >= 0;
+ const toValid = to === '' || (Number.isInteger(toNum) && toNum >= fromNum);
+ const inputsValid = fromValid && toValid;
+
+ const handleScan = async () => {
+ if (!inputsValid) return;
+ await sp.scanRange({
+ fromHeight: fromNum,
+ toHeight: to === '' ? undefined : toNum,
+ includeSpent,
+ });
+ };
+
+ const progressPercent = sp.scanProgress
+ ? Math.min(
+ 100,
+ Math.round(
+ ((sp.scanProgress.currentHeight - sp.scanProgress.fromHeight + 1) /
+ Math.max(1, sp.scanProgress.toHeight - sp.scanProgress.fromHeight + 1)) *
+ 100,
+ ),
+ )
+ : 0;
+
+ return (
+
+
+
+ Scan for silent payments
+
+ Walks the configured BIP-352 indexer block-by-block to detect incoming silent payments.
+
+
+
+
+
+
+ {sp.tipHeight !== undefined && (
+
+ Indexer tip: {sp.tipHeight.toLocaleString()}
+ {sp.storage && (
+ <>
+ {' · '}
+ Last fully scanned:{' '}
+
+ {sp.storage.scanHeight > 0 ? sp.storage.scanHeight.toLocaleString() : 'never'}
+
+ >
+ )}
+
+ )}
+
+ {/*
+ * "Include already-spent" deep-rescan toggle. Off by default
+ * because the normal scan path doesn't want already-spent
+ * outputs cluttering the active UTXO set. Turn on to recover
+ * historical receive rows whose UTXOs were later spent and
+ * subsequently pruned from local storage — matches against
+ * spent outputs are routed straight into the `spent` archive,
+ * which powers both the receive-history rows and the
+ * send-vs-receive classifier in the tx list.
+ */}
+
+
setIncludeSpent(v === true)}
+ disabled={sp.isScanning}
+ className="mt-0.5"
+ />
+
+
+ Include already-spent
+
+
+ Also detect silent payments that have since been spent. Use when
+ rebuilding receive history after a missed scan or a reset.
+
+
+
+
+ {sp.isScanning && sp.scanProgress && (
+
+
+
+
+
+ Block {sp.scanProgress.currentHeight.toLocaleString()} /{' '}
+ {sp.scanProgress.toHeight.toLocaleString()}
+
+
+ {sp.scanProgress.matchesFound} match
+ {sp.scanProgress.matchesFound === 1 ? '' : 'es'}
+
+
+
+ )}
+
+ {!sp.isScanning && sp.scanError && (
+
+
+
{sp.scanError.message}
+
+ )}
+
+ {!sp.isScanning && !sp.scanError && sp.scanProgress && (
+
+
+
+ Scanned blocks {sp.scanProgress.fromHeight.toLocaleString()} →{' '}
+ {sp.scanProgress.currentHeight.toLocaleString()}.{' '}
+ {sp.scanProgress.matchesFound > 0
+ ? `Found ${sp.scanProgress.matchesFound} new ${
+ sp.scanProgress.matchesFound === 1 ? 'output' : 'outputs'
+ }.`
+ : 'No new payments.'}
+
+
+ )}
+
+ {/* ── Reconcile spent UTXOs ──────────────────────────── */}
+ {/*
+ * Manual fix-up path for SP UTXOs that were spent outside the
+ * local send flow — different device, or a build that predates
+ * the send-time prune logic. Walks the stored set, asks
+ * Blockbook for each output's spent status, and drops the spent
+ * ones. Capped at 50 UTXOs per click; subsequent clicks pick up
+ * any remainder.
+ */}
+ {sp.storage && sp.storage.utxos.length > 0 && (
+
+
+
Reconcile spent UTXOs
+
+ Checks each stored silent-payment UTXO against Blockbook and removes any
+ that have been spent. Use this if the balance is higher than it should
+ be after a send.
+
+
+
+ {sp.reconcileProgress && !sp.reconcileError && (
+
+ {sp.isReconciling
+ ? `Checking ${sp.reconcileProgress.checked} / ${sp.reconcileProgress.total}…`
+ : `Checked ${sp.reconcileProgress.checked} UTXO${
+ sp.reconcileProgress.checked === 1 ? '' : 's'
+ } · pruned ${sp.reconcileProgress.prunedSoFar}.`}
+
+ )}
+
+ {sp.reconcileError && (
+
+
+
{sp.reconcileError.message}
+
+ )}
+
+
{
+ void sp.reconcileSpentUtxos();
+ }}
+ disabled={sp.isReconciling || sp.isScanning}
+ >
+ {sp.isReconciling ? (
+ <>
+
+ Reconciling…
+ >
+ ) : (
+ 'Reconcile now'
+ )}
+
+
+ )}
+
+
+ {sp.isScanning ? (
+ sp.cancelScan()}>
+ Cancel
+
+ ) : (
+ <>
+ onOpenChange(false)}>
+ Close
+
+
+ Start scan
+
+ >
+ )}
+
+
+
+
+ );
+}
diff --git a/src/components/HeroAtmosphere.tsx b/src/components/HeroAtmosphere.tsx
index 32bdff1f..ed6404e4 100644
--- a/src/components/HeroAtmosphere.tsx
+++ b/src/components/HeroAtmosphere.tsx
@@ -22,6 +22,8 @@ interface HeroAtmosphereProps {
* warm palette is the wrong vibe.
*/
hue?: HopeHue;
+ /** Crossfade duration in milliseconds. Defaults to the campaign hero timing. */
+ fadeMs?: number;
/** Extra classes for the outer wrapper. */
className?: string;
}
@@ -51,7 +53,7 @@ const FADE_MS = 1500;
* the old one, matching the timing of the photo crossfade so the whole
* hero blooms together.
*/
-export function HeroAtmosphere({ seed, hue: hueOverride, className }: HeroAtmosphereProps) {
+export function HeroAtmosphere({ seed, hue: hueOverride, fadeMs = FADE_MS, className }: HeroAtmosphereProps) {
const idRef = useRef(0);
const [layers, setLayers] = useState([]);
const lastHueRef = useRef(null);
@@ -68,9 +70,9 @@ export function HeroAtmosphere({ seed, hue: hueOverride, className }: HeroAtmosp
// safely past, so the DOM never accumulates stale gradients.
const timeout = window.setTimeout(() => {
setLayers((prev) => prev.filter((l) => l.id === id));
- }, FADE_MS + 50);
+ }, fadeMs + 50);
return () => window.clearTimeout(timeout);
- }, [seed, hueOverride]);
+ }, [seed, hueOverride, fadeMs]);
return (
@@ -82,7 +84,7 @@ export function HeroAtmosphere({ seed, hue: hueOverride, className }: HeroAtmosp
className="absolute inset-0"
style={{
opacity: isTop ? 1 : 0,
- transition: `opacity ${FADE_MS}ms ease-in-out`,
+ transition: `opacity ${fadeMs}ms ease-in-out`,
}}
>
{/* Warm directional scrim — pulls the photo toward the active
diff --git a/src/components/HeroCampaignSpotlight.tsx b/src/components/HeroCampaignSpotlight.tsx
deleted file mode 100644
index be32ce0c..00000000
--- a/src/components/HeroCampaignSpotlight.tsx
+++ /dev/null
@@ -1,144 +0,0 @@
-import { Link } from 'react-router-dom';
-import { ArrowRight, MapPin } from 'lucide-react';
-
-import { Skeleton } from '@/components/ui/skeleton';
-import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
-import { cn } from '@/lib/utils';
-import { encodeCampaignNaddr, getCampaignCountryLabel, type ParsedCampaign } from '@/lib/campaign';
-import { sanitizeUrl } from '@/lib/sanitizeUrl';
-import { useAuthor } from '@/hooks/useAuthor';
-import { genUserName } from '@/lib/genUserName';
-import { useCampaignDonations } from '@/hooks/useCampaignDonations';
-import { useBtcPrice } from '@/hooks/useBtcPrice';
-import { formatCampaignAmount } from '@/lib/formatCampaignAmount';
-
-interface HeroCampaignSpotlightProps {
- /** Campaign to feature. `null` renders the empty placeholder. */
- campaign: ParsedCampaign | null;
- /** Show a skeleton while the parent is still loading featured campaigns. */
- isLoading?: boolean;
- /** Extra classes for the outer wrapper. */
- className?: string;
-}
-
-/**
- * Banner-overlay spotlight for the active campaign — title, summary,
- * author, location, and a "View campaign" CTA — rendered directly on the
- * hero photo (no card chrome). The hero photo IS the background, so this
- * component is purely a text overlay.
- *
- * Parent (`CampaignsPage`) drives the `campaign` prop, cycling on a timer
- * or pinning to whichever marker the user clicked on the globe.
- */
-export function HeroCampaignSpotlight({
- campaign,
- isLoading = false,
- className,
-}: HeroCampaignSpotlightProps) {
- // useAuthor must be called unconditionally to keep hook order stable —
- // when there's no campaign yet we pass an empty pubkey and ignore the
- // (no-op) result below. Same for donations + BTC price.
- const author = useAuthor(campaign?.pubkey ?? '');
- const { data: stats } = useCampaignDonations(campaign?.aTag);
- const { data: btcPrice } = useBtcPrice();
-
- if (isLoading && !campaign) {
- return (
-
-
-
-
-
- );
- }
-
- if (!campaign) return null;
-
- const naddr = encodeCampaignNaddr(campaign);
- const meta = author.data?.metadata;
- const authorName = meta?.display_name || meta?.name || genUserName(campaign.pubkey);
- const authorPicture = sanitizeUrl(meta?.picture);
- const countryLabel = getCampaignCountryLabel(campaign);
-
- return (
-
-
- {campaign.title}
-
-
- {campaign.summary && (
-
- {campaign.summary}
-
- )}
-
- {/* Progress / goal. Hand-rolled instead of using
- so we can tune the bar for legibility on top of a photo: dark
- translucent track, glowing primary fill. When the campaign has no
- goal tag, the bar is omitted entirely and we only show the raised
- total. */}
- {(() => {
- const raised = stats?.totalSats ?? 0;
- const goal = campaign.goalSats;
- const hasGoal = !!goal && goal > 0;
- const pct = hasGoal ? Math.min(100, Math.round((raised / goal!) * 100)) : 0;
- return (
-
- {hasGoal && (
-
- )}
-
-
- {formatCampaignAmount(raised, btcPrice)}
- {!hasGoal && raised }
-
- {hasGoal && (
-
- of {formatCampaignAmount(goal!, btcPrice)} goal
-
- )}
-
-
- );
- })()}
-
-
-
-
- {authorPicture && }
-
- {authorName.slice(0, 2).toUpperCase()}
-
-
- {authorName}
-
- {countryLabel && (
-
-
- {countryLabel}
-
- )}
-
- View
-
-
-
-
- );
-}
diff --git a/src/components/HeroGlobe.tsx b/src/components/HeroGlobe.tsx
deleted file mode 100644
index 7675aec3..00000000
--- a/src/components/HeroGlobe.tsx
+++ /dev/null
@@ -1,470 +0,0 @@
-import type { CSSProperties } from 'react';
-import { useEffect, useMemo, useRef } from 'react';
-
-import { LAND_RINGS } from '@/lib/landPolygons';
-import { HOPE_PALETTE, type HopeHue } from '@/lib/hopePalette';
-
-/** Geographic point used by the globe projection. */
-interface GeoPoint {
- /** Latitude in degrees, [-90, 90]. */
- lat: number;
- /** Longitude in degrees, [-180, 180]. */
- lng: number;
-}
-
-/**
- * Visual variant for a globe marker. Each kind gets its own glyph + halo
- * so the three "threads" of Discover — campaigns, communities, and
- * country activity — read distinctly without needing legend chrome.
- */
-export type GlobeMarkerKind = 'campaign' | 'community' | 'country-pulse';
-
-interface CampaignMarker extends GeoPoint {
- /** Stable key for the marker (e.g. the campaign aTag). */
- key: string;
- /** Tooltip / accessible label shown on hover. */
- label?: string;
- /**
- * Visual style of this marker. Defaults to `'campaign'` so existing
- * callers (the campaigns hero) keep their heart markers unchanged.
- */
- kind?: GlobeMarkerKind;
-}
-
-interface HeroGlobeProps {
- /** Markers to plot on top of the globe — one per geo-located campaign. */
- markers?: CampaignMarker[];
- /**
- * Marker the user has selected. The selected marker gets a stronger glow
- * and a slightly larger heart so it reads as the "live" one.
- */
- selectedKey?: string | null;
- /** Fires when the user clicks a marker. */
- onMarkerClick?: (key: string) => void;
- /**
- * Active hopeful hue. Drives the outer halo color and the back-lit
- * limb tint so the globe agrees with the surrounding {@link HeroAtmosphere}.
- */
- hue?: HopeHue;
- /** Optional className applied to the outer container. */
- className?: string;
- /** Optional inline style applied to the outer container (e.g. fluid width via `clamp()`). */
- style?: CSSProperties;
-}
-
-/** Pre-parsed land rings as arrays of {lat, lng} points. */
-const LANDMASSES: readonly GeoPoint[][] = LAND_RINGS.map((flat) => {
- const out: GeoPoint[] = [];
- for (let i = 0; i < flat.length; i += 2) {
- out.push({ lng: flat[i], lat: flat[i + 1] });
- }
- return out;
-});
-
-const RADIUS = 285;
-const CENTER = 300;
-/** Seconds per full revolution. Slow on purpose so the motion is ambient. */
-const ROTATION_PERIOD_SECONDS = 140;
-
-/**
- * Orthographic projection: turns a (lat, lng) pair into 2D screen
- * coordinates plus a `z` depth value. Points with `z <= 0` are on the
- * back hemisphere and should be hidden (or drawn with low opacity).
- */
-function project(lat: number, lng: number, rotationDeg: number) {
- const phi = (lat * Math.PI) / 180;
- // Subtract rotation so the globe appears to spin west-to-east.
- const lambda = ((lng - rotationDeg) * Math.PI) / 180;
- const cosPhi = Math.cos(phi);
- const x = cosPhi * Math.sin(lambda);
- const y = Math.sin(phi);
- const z = cosPhi * Math.cos(lambda);
- return {
- x: CENTER + x * RADIUS,
- // Negate so positive latitudes render upward in SVG.
- y: CENTER - y * RADIUS,
- z,
- };
-}
-
-/**
- * Slowly-rotating SVG globe rendered with pure SVG (no WebGL, no canvas).
- *
- * Visuals are intentionally warm and hand-drawn rather than satellite/HUD:
- * - a soft cream sphere lit from the upper-left,
- * - sandy-amber landmasses (real Natural Earth continent shapes,
- * pre-simplified to ~1.5k vertices), and
- * - small glowing marker dots for active campaigns.
- *
- * Rotation is driven by `requestAnimationFrame` and applied imperatively via
- * refs so the component never re-renders during animation. Respects
- * `prefers-reduced-motion` by holding at a static angle.
- */
-export function HeroGlobe({
- markers = [],
- selectedKey = null,
- onMarkerClick,
- hue = HOPE_PALETTE[0],
- className,
- style,
-}: HeroGlobeProps) {
- const landRef = useRef
(null);
- const markersRef = useRef(null);
-
- // Stable per-ring point counts so the animation loop knows how many polygon
- // elements to update without re-reading the DOM each frame.
- const ringSizes = useMemo(() => LANDMASSES.map((r) => r.length), []);
-
- // Live refs so the rAF loop can read the latest markers / selection
- // without retriggering the effect — otherwise every spotlight tick
- // would tear down the loop and snap rotation back to 0°.
- const markersRefValue = useRef(markers);
- const selectedKeyRef = useRef(selectedKey);
- useEffect(() => {
- markersRefValue.current = markers;
- selectedKeyRef.current = selectedKey;
- }, [markers, selectedKey]);
-
- useEffect(() => {
- const prefersReducedMotion =
- typeof window !== 'undefined' &&
- window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
-
- let rafId = 0;
- let start: number | null = null;
-
- const tick = (timestamp: number) => {
- if (start === null) start = timestamp;
- const elapsedSeconds = (timestamp - start) / 1000;
- const rotation = prefersReducedMotion
- ? 25 // Hold at a flattering static angle.
- : (elapsedSeconds / ROTATION_PERIOD_SECONDS) * 360;
-
- // --- Landmass polygons ---
- //
- // For each ring we walk vertex-by-vertex projecting through the
- // orthographic camera. Vertices on the *front* of the sphere
- // (z > 0) are kept as-is. Vertices on the *back* (z < 0) would
- // otherwise project on top of front-side land — orthographic
- // projection collapses depth — so we drop them.
- //
- // Where a ring crosses the visible limb (front ↔ back) we emit an
- // interpolated point on the limb itself, so polygons that wrap
- // around the side of the globe close cleanly along the sphere's
- // outline instead of cutting across the disc interior.
- //
- // We also fade rings out over a narrow band near the limb so they
- // don't pop on/off when crossing z = 0. Anything with maxZ below
- // FADE_OUT is considered fully hidden; rings between FADE_OUT and
- // FADE_IN ease in/out.
- const FADE_OUT = 0.0;
- const FADE_IN = 0.08;
- const landEl = landRef.current;
- if (landEl) {
- const polygons = landEl.children;
- for (let i = 0; i < LANDMASSES.length; i++) {
- const ring = LANDMASSES[i];
- const polygon = polygons[i] as SVGPolygonElement | undefined;
- if (!polygon) continue;
-
- // First pass: project every vertex, remembering z so we can
- // detect front/back transitions cheaply.
- const n = ring.length;
- const xs = new Array(n);
- const ys = new Array(n);
- const zs = new Array(n);
- let maxZ = -1;
- for (let j = 0; j < n; j++) {
- const p = project(ring[j].lat, ring[j].lng, rotation);
- xs[j] = p.x;
- ys[j] = p.y;
- zs[j] = p.z;
- if (p.z > maxZ) maxZ = p.z;
- }
- if (maxZ <= FADE_OUT) {
- polygon.setAttribute('opacity', '0');
- continue;
- }
-
- // Second pass: emit only the visible portion. For each edge we
- // include the endpoint when it's in front, and any limb-crossing
- // we step over gets an interpolated point on the sphere edge.
- const parts: string[] = [];
- for (let j = 0; j < n; j++) {
- const k = (j + 1) % n;
- const zj = zs[j];
- const zk = zs[k];
- if (zj > 0) parts.push(`${xs[j].toFixed(1)},${ys[j].toFixed(1)}`);
- if ((zj > 0) !== (zk > 0)) {
- // Find the parameter t in [0,1] along this edge where z=0.
- const t = zj / (zj - zk);
- const ex = xs[j] + (xs[k] - xs[j]) * t;
- const ey = ys[j] + (ys[k] - ys[j]) * t;
- // Project the limb point onto the actual sphere edge so it
- // never lands inside the disc.
- const dx = ex - CENTER;
- const dy = ey - CENTER;
- const d = Math.hypot(dx, dy) || 1;
- const lx = CENTER + (dx / d) * RADIUS;
- const ly = CENTER + (dy / d) * RADIUS;
- parts.push(`${lx.toFixed(1)},${ly.toFixed(1)}`);
- }
- }
- if (parts.length < 3) {
- polygon.setAttribute('opacity', '0');
- continue;
- }
- polygon.setAttribute('points', parts.join(' '));
- // Smooth fade as rings come around the limb. `fade` clamps to
- // [0,1] over the narrow FADE_OUT→FADE_IN band, then we keep
- // adding the small depth-based dimming used before.
- const fade = Math.min(1, Math.max(0, (maxZ - FADE_OUT) / (FADE_IN - FADE_OUT)));
- polygon.setAttribute('opacity', (fade * Math.min(1, 0.55 + maxZ * 0.55)).toFixed(2));
- }
- }
-
- // --- Campaign markers ---
- const markersEl = markersRef.current;
- const liveMarkers = markersRefValue.current;
- const liveSelectedKey = selectedKeyRef.current;
- if (markersEl) {
- const groups = markersEl.children;
- for (let i = 0; i < liveMarkers.length; i++) {
- const m = liveMarkers[i];
- const group = groups[i] as SVGGElement | undefined;
- if (!group) continue;
- const p = project(m.lat, m.lng, rotation);
- if (p.z <= 0) {
- group.setAttribute('opacity', '0');
- // Pull off-canvas so backside markers don't intercept clicks.
- group.setAttribute('transform', 'translate(-1000 -1000)');
- continue;
- }
- // Selected marker scales up subtly to read as "you are here".
- const scale = m.key === liveSelectedKey ? 1.35 : 1;
- group.setAttribute(
- 'transform',
- `translate(${p.x.toFixed(2)} ${p.y.toFixed(2)}) scale(${scale})`,
- );
- group.setAttribute('opacity', (0.55 + p.z * 0.45).toFixed(2));
- }
- }
-
- if (!prefersReducedMotion) {
- rafId = requestAnimationFrame(tick);
- }
- };
-
- rafId = requestAnimationFrame(tick);
- return () => cancelAnimationFrame(rafId);
- // `markers` and `selectedKey` are read inside `tick` via refs above,
- // so we deliberately omit them from this dep list to keep the
- // rotation loop alive across spotlight cycles.
- }, [ringSizes]);
-
- return (
-
- {/* Wrapper so the outer halo can sit behind the SVG. The halo is a
- plain div (not part of the SVG) so its blur extends past the
- sphere without needing a giant viewBox, and so we can drive it
- with a CSS keyframe animation independent of the rotation. */}
-
- {/* Outer atmospheric halo. Scaled larger than the wrapper so light
- spills out into the photo, blurred for softness, and tinted
- with the active campaign's hopeful hue. Breathes slowly via
- the .hero-globe-halo-breath class defined in index.css. */}
-
-
-
-
- {/* Sphere base: warm dawn gold lit from the upper-left, fading
- into a deeper honey shadow on the lower-right. The whole
- sphere is meant to read as "lit from within" — like the
- moment before sunrise — not as a slab of dirt. */}
-
-
-
-
-
- {/* Back-lit limb light. Reads as light pooling on the inside of
- the sphere edge — Earthrise rather than satellite. Tinted
- with the active hopeful hue, kept narrow + low-opacity so it
- feels like atmosphere, not a neon ring. */}
-
-
-
-
-
- {/* Soft highlight in the upper-left to sell the sphere shape. */}
-
-
-
-
- {/* Marker glow halo. Soft, warm, no pulsing. */}
-
-
-
-
-
- {/* Stronger halo used for the selected marker so it visibly leads
- the eye to whatever the spotlight card is currently showing. */}
-
-
-
-
-
- {/* Clip everything to the sphere so polygons straddling the
- terminator don't leak outside the circle. */}
-
-
-
-
-
- {/* Base sphere with light shading. */}
-
-
- {/* Landmasses, clipped to the sphere. */}
-
-
- {LANDMASSES.map((_, i) => (
-
- ))}
-
-
-
- {/* Warm highlight + rim shading sit above the land so the sphere
- still reads as a lit ball, not a flat map. */}
-
-
-
- {/* Campaign markers — a small heart glyph with a warm glow halo.
- Each marker is a button: clicking selects the campaign, which
- the parent uses to populate the spotlight card.
-
- On the Discover page the same `` slots are reused for
- community and country-pulse markers, distinguished by `m.kind`
- and rendered with a softer glyph + halo so campaigns stay the
- visual lead. */}
-
- {markers.map((m) => {
- const isSelected = m.key === selectedKey;
- const kind: GlobeMarkerKind = m.kind ?? 'campaign';
- return (
- onMarkerClick(m.key) : undefined}
- onKeyDown={
- onMarkerClick
- ? (e) => {
- if (e.key === 'Enter' || e.key === ' ') {
- e.preventDefault();
- onMarkerClick(m.key);
- }
- }
- : undefined
- }
- style={{
- cursor: onMarkerClick ? 'pointer' : undefined,
- outline: 'none',
- }}
- >
- {kind === 'campaign' ? (
- <>
- {/* Glow halo (stronger for the active marker). */}
-
- {/* Heart glyph. Path is centered at the origin (~14×12 units)
- so the parent 's translate+scale lands it on the globe. */}
-
- {/* Tiny inner highlight to make the heart pop on the warm
- landmass without needing a heavy outline. */}
-
- >
- ) : kind === 'community' ? (
- <>
- {/* Community: a softly-glowing ring. Reads as a circle of
- people, gathered. Smaller than the heart so campaigns
- stay the dominant signal. */}
-
-
-
- >
- ) : (
- <>
- {/* Country pulse: tiny warm sun-dot, no halo button feel.
- These are decorative — they trace where the world is
- currently posting without inviting interaction. */}
-
-
- >
- )}
- {/* Transparent hit target — much easier to click/tap than the
- tiny visible glyph, especially on touch. */}
-
-
- );
- })}
-
-
-
-
- );
-}
diff --git a/src/components/HeroLightningMap.tsx b/src/components/HeroLightningMap.tsx
new file mode 100644
index 00000000..490a8ab3
--- /dev/null
+++ b/src/components/HeroLightningMap.tsx
@@ -0,0 +1,278 @@
+import { memo, useId, useMemo } from 'react';
+
+import { LAND_RINGS } from '@/lib/landPolygons';
+import { cn } from '@/lib/utils';
+
+/**
+ * Decorative dark world map with glowing brand-orange Lightning arcs and
+ * pulsing city nodes. Designed as a hero backdrop on near-black surfaces:
+ * type sits comfortably over it without any text shadow.
+ *
+ * Composition (back to front):
+ * 1. Equirectangular world map drawn from {@link LAND_RINGS} — barely
+ * lit so it reads as texture, not focus.
+ * 2. Central radial glow tinted in brand orange behind the visual
+ * center of gravity.
+ * 3. Curated set of arcs between major cities, drawn as quadratic
+ * Bézier paths with a flowing dash animation (the "lightning" hops).
+ * 4. Pulsing dot at every endpoint, with a soft halo.
+ *
+ * The data is intentionally curated — no campaign coupling. The map is a
+ * brand visual, not a state visualization. Arc list lives at the bottom
+ * of this file and can be swapped freely without touching layout.
+ *
+ * Pure SVG, no WebGL, no canvas. ~12 arcs + ~150 polygons — render cost
+ * is negligible. Animations honor `prefers-reduced-motion`.
+ */
+function HeroLightningMapImpl({ className }: { className?: string }) {
+ const uid = useId();
+ const arcId = (key: string) => `${uid}-${key}`;
+
+ // viewBox is the equirectangular world: 360 wide × 180 tall, recentered
+ // so (0,0) is the geographic origin. We project [lng, lat] -> [lng, -lat]
+ // (SVG y grows downward).
+ const W = 360;
+ const H = 180;
+
+ const landPaths = useMemo(() => {
+ return LAND_RINGS.map((ring, idx) => {
+ // Rings are flat [lng, lat, lng, lat, ...]. Convert to an SVG path.
+ //
+ // Antimeridian handling: a few rings (notably Russia and Antarctica
+ // in the Natural Earth source) cross the ±180° seam. The data stores
+ // those rings as a single polygon whose longitude jumps from +180 to
+ // -180 (or vice versa) in one step. Drawn naively with a continuous
+ // `L` command, that jump renders as a long horizontal slash spanning
+ // the whole equirectangular viewBox — the "two lines" sitting at
+ // ~lat 41 and ~lat 77 across the map are exactly Russia's bounding
+ // edges drawn by such a connection.
+ //
+ // Detect any longitude step > 180° and close + restart the subpath
+ // with `M` instead, so the two halves of the country render in their
+ // actual hemispheres without a connecting line through the middle.
+ let d = '';
+ let prevLng: number | null = null;
+ for (let i = 0; i < ring.length; i += 2) {
+ const lng = ring[i];
+ const lat = ring[i + 1];
+ const isFirst = i === 0;
+ const wraps = prevLng !== null && Math.abs(lng - prevLng) > 180;
+ const cmd = isFirst || wraps ? 'M' : 'L';
+ d += `${cmd}${lng.toFixed(2)} ${(-lat).toFixed(2)}`;
+ prevLng = lng;
+ }
+ d += 'Z';
+ return ;
+ });
+ }, []);
+
+ // All endpoints across the curated arc set, deduplicated, so we render
+ // one pulsing node per city even if multiple arcs share it.
+ const nodes = useMemo(() => {
+ const seen = new Map();
+ for (const arc of CURATED_ARCS) {
+ const a = `${arc.from[0]},${arc.from[1]}`;
+ const b = `${arc.to[0]},${arc.to[1]}`;
+ if (!seen.has(a)) seen.set(a, { lng: arc.from[0], lat: arc.from[1] });
+ if (!seen.has(b)) seen.set(b, { lng: arc.to[0], lat: arc.to[1] });
+ }
+ return Array.from(seen.values());
+ }, []);
+
+ return (
+
+ {/* Central radial brand-orange glow. Sits behind the map texture so
+ the map reads as illuminated by it, not pasted over it. Position
+ biased slightly right so the headline column on the left stays
+ on the cooler side of the glow. */}
+
+
+
+
+ {/* Land fill — brand-orange wash, fully opaque. The transparency
+ lives on the wrapping below so that overlapping
+ country polygons don't stack their alpha at shared borders
+ (which is what painted the visible "latitude line" along the
+ equator and other country seams). */}
+
+
+
+
+
+ {/* Arc gradient — bright at midpoint, fading at endpoints, so
+ the line reads as energy traveling rather than a solid wire. */}
+
+
+
+
+
+
+
+ {/* Glow filter for arcs and nodes — wider and softer than a CSS
+ shadow, and crucially, applied inside the SVG so it scales
+ cleanly with the viewBox. */}
+
+
+
+
+
+
+
+
+ {/* Stronger glow for the nodes themselves so they punch through
+ the arcs at intersections. */}
+
+
+
+
+
+
+
+ {/* Land. Each country is its own ring; rendered as separate paths
+ with semi-transparent fill, every shared country border doubles
+ up where polygons overlap. The most jarring of those overlaps
+ falls along the equator (Kenya/Tanzania, DRC/Angola, Indonesian
+ islands) and reads as a horizontal "latitude line."
+
+ Fix: paint each country with a fully-opaque fill, then put the
+ transparency on the wrapping . SVG group opacity
+ renders the children into an offscreen buffer first and then
+ composites the buffer at the group's alpha, so internal overlaps
+ don't stack. No stroke for the same reason. */}
+
+ {landPaths}
+
+
+ {/* Arcs. Each arc is a quadratic Bézier with the control point
+ lifted above the great-circle path, giving the curved silhouette
+ from the reference. Stroke-dasharray + animated stroke-dashoffset
+ produces the flowing-energy effect.
+
+ `vector-effect="non-scaling-stroke"` keeps the stroke at a fixed
+ pixel width regardless of viewBox-to-screen scale, which is what
+ kills the line jitter — without it, sub-pixel stroke widths in
+ user-space combine with the SVG glow filter to shimmer at any
+ responsive size. */}
+
+ {CURATED_ARCS.map((arc, i) => {
+ const [x1, y1] = [arc.from[0], -arc.from[1]];
+ const [x2, y2] = [arc.to[0], -arc.to[1]];
+ // Lift the control point above the chord, scaled with chord
+ // length so short arcs stay tight and trans-oceanic arcs
+ // sweep dramatically.
+ const len = Math.hypot(x2 - x1, y2 - y1);
+ const lift = Math.min(60, len * 0.42);
+ const mx = (x1 + x2) / 2;
+ const my = (y1 + y2) / 2;
+ // Push the lift toward whichever hemisphere the chord midpoint
+ // already favors, so equator-crossing arcs sweep clearly into
+ // their dominant hemisphere instead of all stacking through
+ // y=0. Pure equatorial midpoints (my≈0) default to lifting
+ // north (negative y in SVG space).
+ const direction = my > 0 ? 1 : -1;
+ const cx = mx;
+ const cy = my + lift * direction;
+ return (
+
+ );
+ })}
+
+
+ {/* City nodes. Two layers per node: a soft halo behind, a hot dot
+ in front. Halo is what reads at distance; dot is what reads up
+ close. */}
+
+ {nodes.map((n, i) => {
+ const x = n.lng;
+ const y = -n.lat;
+ return (
+
+
+
+
+ );
+ })}
+
+
+
+ );
+}
+
+export const HeroLightningMap = memo(HeroLightningMapImpl);
+
+/**
+ * Hand-picked arcs between major activist hubs across continents. Order
+ * matters only for the staggered animation start times — pairs are
+ * otherwise independent. Coordinates are `[lng, lat]` in degrees.
+ *
+ * Edit freely. Twelve arcs is roughly the sweet spot — fewer feels
+ * sparse, more turns into a tangle that competes with the headline.
+ */
+const CURATED_ARCS: ReadonlyArray<{
+ from: readonly [number, number];
+ to: readonly [number, number];
+}> = [
+ // Trans-Atlantic — North America ↔ Europe / Africa
+ { from: [-74.0, 40.7], to: [-0.1, 51.5] }, // New York → London
+ { from: [-122.4, 37.8], to: [13.4, 52.5] }, // San Francisco → Berlin
+ { from: [-79.4, 43.7], to: [2.35, 48.9] }, // Toronto → Paris
+ { from: [-0.1, 51.5], to: [3.4, 6.5] }, // London → Lagos
+ // Trans-Pacific — Americas ↔ Asia / Oceania
+ { from: [-118.2, 34.0], to: [139.7, 35.7] }, // Los Angeles → Tokyo
+ { from: [-99.1, 19.4], to: [121.5, 25.0] }, // Mexico City → Taipei
+ { from: [151.2, -33.9], to: [-122.4, 37.8] }, // Sydney → San Francisco
+ // South America bridges
+ { from: [-58.4, -34.6], to: [-43.2, -22.9] }, // Buenos Aires → Rio
+ { from: [-43.2, -22.9], to: [3.4, 6.5] }, // Rio → Lagos
+ // Asia / Africa lattice
+ { from: [77.2, 28.6], to: [55.3, 25.3] }, // Delhi → Dubai
+ { from: [55.3, 25.3], to: [31.2, 30.0] }, // Dubai → Cairo
+ { from: [31.2, 30.0], to: [13.4, 52.5] }, // Cairo → Berlin
+ { from: [103.8, 1.35], to: [121.5, 25.0] }, // Singapore → Taipei
+ { from: [18.4, -33.9], to: [3.4, 6.5] }, // Cape Town → Lagos
+];
diff --git a/src/components/IntroImage.tsx b/src/components/IntroImage.tsx
deleted file mode 100644
index 977c6e32..00000000
--- a/src/components/IntroImage.tsx
+++ /dev/null
@@ -1,25 +0,0 @@
-interface IntroImageProps {
- src: string;
- /** Tailwind size class, e.g. "w-40" (default) or "w-10" */
- size?: string;
- className?: string;
-}
-
-export function IntroImage({ src, size = 'w-40', className }: IntroImageProps) {
- return (
-
- );
-}
diff --git a/src/components/MainLayout.tsx b/src/components/MainLayout.tsx
index cc1789b6..e98ee6ee 100644
--- a/src/components/MainLayout.tsx
+++ b/src/components/MainLayout.tsx
@@ -4,10 +4,8 @@ import { LeftSidebar } from '@/components/LeftSidebar';
import { MobileTopBar } from '@/components/MobileTopBar';
import { MobileDrawer } from '@/components/MobileDrawer';
import { FloatingComposeButton } from '@/components/FloatingComposeButton';
-import { CursorFireEffect } from '@/components/CursorFireEffect';
import { Skeleton } from '@/components/ui/skeleton';
import { CenterColumnContext, DrawerContext, LayoutStore, LayoutStoreContext, NavHiddenContext, useLayoutSnapshot } from '@/contexts/LayoutContext';
-import { useAppContext } from '@/hooks/useAppContext';
import { useScrollDirection } from '@/hooks/useScrollDirection';
import { cn } from '@/lib/utils';
@@ -55,15 +53,11 @@ function MainLayoutInner() {
const openDrawer = useCallback(() => setDrawerOpen(true), []);
const centerColumnRef = useRef(null);
const [centerColumnEl, setCenterColumnEl] = useState(null);
- const { config } = useAppContext();
const { hidden: navHidden } = useScrollDirection(scrollContainer);
return (
- {/* Magic Mouse fire particle overlay */}
- {config.magicMouse && }
-
{/* Mobile top bar - only on small screens, hidden when page requests immersive mode */}
{!hideTopBar && setDrawerOpen(true)} hasSubHeader={hasSubHeader} />}
diff --git a/src/components/MobileBottomNav.tsx b/src/components/MobileBottomNav.tsx
index ea8c6b2e..1a22de64 100644
--- a/src/components/MobileBottomNav.tsx
+++ b/src/components/MobileBottomNav.tsx
@@ -110,7 +110,7 @@ export function MobileBottomNav() {
{/* Organizations */}
{ selectionChanged(); setSearchOpen(false); }}
diff --git a/src/components/MobileDrawer.tsx b/src/components/MobileDrawer.tsx
index 24fec2fd..587e1216 100644
--- a/src/components/MobileDrawer.tsx
+++ b/src/components/MobileDrawer.tsx
@@ -83,6 +83,12 @@ export function MobileDrawer({ open, onOpenChange }: MobileDrawerProps) {
if (def.requiresAdmin && !isAdmin(user?.pubkey)) return false;
return true;
});
+ // Phase 1b: when logged out, ensure Help is visible in the main menu.
+ // Logged-in users access Help via the account menu (AccountSwitcher),
+ // but logged-out users have no equivalent affordance — surface it here.
+ if (!user && !filtered.includes('help')) {
+ filtered.push('help');
+ }
// Phase 2: remove leading, trailing, and consecutive dividers.
return filtered.filter((id, i, arr) => {
if (id !== 'divider') return true;
@@ -98,6 +104,9 @@ export function MobileDrawer({ open, onOpenChange }: MobileDrawerProps) {
const def = getSidebarItem(item.id);
if (!def) return true;
if (def.requiresAdmin && !isAdmin(user?.pubkey)) return false;
+ // When logged out, Help is forced into the main list, so hide it
+ // from the "More…" menu to avoid duplication.
+ if (!user && item.id === 'help') return false;
return true;
});
}, [hiddenItems, user]);
diff --git a/src/components/NoteCard.tsx b/src/components/NoteCard.tsx
index 08da9d8f..ebd41365 100644
--- a/src/components/NoteCard.tsx
+++ b/src/components/NoteCard.tsx
@@ -6,6 +6,7 @@ import {
FileText,
GitBranch,
GitPullRequest,
+ HandHeart,
Mail,
Megaphone,
MessageCircle,
@@ -33,13 +34,14 @@ import {
} from "@/components/AudioKindContent";
import { ActionContent } from "@/components/ActionContent";
import { BadgeContent } from "@/components/BadgeContent";
+import { CampaignNoteCardContent } from "@/components/CampaignNoteCardContent";
import { CommunityContent } from "@/components/CommunityContent";
import { CalendarEventContent } from "@/components/CalendarEventContent";
import {
ColorMomentContent,
ColorMomentEyeButton,
} from "@/components/ColorMomentContent";
-import { CommentContext, CountryCommentPill, CountryFlagBackdrop } from "@/components/CommentContext";
+import { CommentContext, CountryCommentPill } from "@/components/CommentContext";
import { CommunityContentWarning } from "@/components/CommunityContentWarning";
import { ContentWarningGuard } from "@/components/ContentWarningGuard";
import { EmojifiedText, ReactionEmoji } from "@/components/CustomEmoji";
@@ -58,7 +60,6 @@ import { ChestIcon } from "@/components/icons/ChestIcon";
import { RepostIcon } from "@/components/icons/RepostIcon";
import { LiveStreamPlayer } from "@/components/LiveStreamPlayer";
import { MagicDeckContent } from "@/components/MagicDeckContent";
-import { Nip05Badge } from "@/components/Nip05Badge";
import { NoteContent } from "@/components/NoteContent";
import { NoteMoreMenu } from "@/components/NoteMoreMenu";
import { PatchCard } from "@/components/PatchCard";
@@ -86,7 +87,6 @@ import { ZapDialog } from "@/components/ZapDialog";
import { useAppContext } from "@/hooks/useAppContext";
import { useAuthor } from "@/hooks/useAuthor";
import { useCurrentUser } from "@/hooks/useCurrentUser";
-import { useNip05Verify } from "@/hooks/useNip05Verify";
import { useOpenPost } from "@/hooks/useOpenPost";
import { useProfileUrl } from "@/hooks/useProfileUrl";
import { toast } from "@/hooks/useToast";
@@ -398,11 +398,6 @@ export const NoteCard = memo(function NoteCard({
const metadata = author.data?.metadata;
const actionMetadata = actionEvent ? actionAuthor.data?.metadata : metadata;
const displayName = getDisplayName(metadata, event.pubkey);
- const nip05 = metadata?.nip05;
- const { data: nip05Verified, isPending: nip05Pending } = useNip05Verify(
- nip05,
- event.pubkey,
- );
const profileUrl = useProfileUrl(event.pubkey, metadata);
const encodedId = useMemo(() => encodeEventId(actionTarget), [actionTarget]);
const { data: stats } = useEventStats(actionTarget.id, actionTarget);
@@ -474,6 +469,7 @@ export const NoteCard = memo(function NoteCard({
const isCommunity = event.kind === 34550;
const isZapGoal = event.kind === 9041;
const isAction = event.kind === 36639;
+ const isCampaign = event.kind === 33863;
const isReaction = event.kind === 7;
const isPollVote = event.kind === 1018;
const isRepost = event.kind === 6 || event.kind === 16;
@@ -520,6 +516,7 @@ export const NoteCard = memo(function NoteCard({
!isCommunity &&
!isZapGoal &&
!isAction &&
+ !isCampaign &&
!isReaction &&
!isPollVote &&
!isRepost &&
@@ -683,6 +680,9 @@ export const NoteCard = memo(function NoteCard({
) : isAction ? (
+ ) : isCampaign ? (
+
+
) : isVoiceMessage ? (
) : isCalendarEvent ? (
@@ -776,12 +776,6 @@ export const NoteCard = memo(function NoteCard({
)}
- {nip05 && nip05Pending &&
}
- {nip05 && nip05Pending &&
· }
- {nip05 && nip05Verified && (
-
- )}
- {nip05 && nip05Verified &&
· }
{timeAgo(event.created_at)}
@@ -1111,9 +1105,6 @@ export const NoteCard = memo(function NoteCard({
onClick={handleCardClick}
onAuxClick={handleAuxClick}
>
-
- {/* Foreground wrapper — `relative` lifts the entire post above the
- absolute backdrop layer rendered by CountryFlagBackdrop. */}
{threadedKindHeader && (
@@ -1164,9 +1155,6 @@ export const NoteCard = memo(function NoteCard({
onClick={handleCardClick}
onAuxClick={handleAuxClick}
>
-
- {/* Foreground wrapper — `relative` lifts the entire post above the
- absolute backdrop layer rendered by CountryFlagBackdrop. */}
{/* Action header — repost takes priority, otherwise derived from event kind */}
@@ -1206,17 +1194,13 @@ export const NoteCard = memo(function NoteCard({
})()
)}
- {/* Header: avatar + name/handle stacked. The country pill is
- appended outside this flag-mode wrapper as a flex sibling, so
- it keeps its own surface treatment. */}
+ {/* Header: avatar + name/handle with the country pill anchored
+ right. The pill is a flex sibling of the author row so it
+ keeps its own surface treatment regardless of context. */}
{avatarElement}
{authorInfo}
{isColor &&
}
- {/* Country pill — rendered outside the flag-mode color flip via
- `[&]:` to escape the parent's color rules. It's wrapped in
- its own flex slot so the row layout matches the non-flag
- case (pill anchored right). */}
= {
noun: "organization",
nounRoute: "/communities",
},
+ 33863: {
+ icon: HandHeart,
+ action: (event) => publishedAtAction(event, { created: "launched a", updated: "updated a", fallback: "shared a" }),
+ noun: "campaign",
+ nounRoute: "/campaigns/all",
+ },
30009: {
icon: Award,
action: (event) => publishedAtAction(event, { created: "created a", updated: "updated a", fallback: "created a" }),
diff --git a/src/components/OnchainZapContent.tsx b/src/components/OnchainZapContent.tsx
index 880497d9..7afb2a5c 100644
--- a/src/components/OnchainZapContent.tsx
+++ b/src/components/OnchainZapContent.tsx
@@ -96,7 +96,7 @@ export function OnchainZapContent({ target, onSuccess, onClose }: OnchainZapCont
const { capability } = useBitcoinSigner();
const { logins } = useNostrLogin();
const { config } = useAppContext();
- const { esploraBaseUrl } = config;
+ const { esploraApis } = config;
const loginType = logins[0]?.type;
const [usdAmount, setUsdAmount] = useState(5);
@@ -117,21 +117,21 @@ export function OnchainZapContent({ target, onSuccess, onClose }: OnchainZapCont
: '';
const { data: btcPrice } = useQuery({
- queryKey: ['btc-price', esploraBaseUrl],
- queryFn: () => fetchBtcPrice(esploraBaseUrl),
+ queryKey: ['btc-price', esploraApis],
+ queryFn: ({ signal }) => fetchBtcPrice(esploraApis, signal),
staleTime: 30_000,
});
const { data: utxos } = useQuery({
- queryKey: ['bitcoin-utxos', esploraBaseUrl, senderAddress],
- queryFn: () => fetchUTXOs(senderAddress, esploraBaseUrl),
+ queryKey: ['bitcoin-utxos', esploraApis, senderAddress],
+ queryFn: ({ signal }) => fetchUTXOs(senderAddress, esploraApis, signal),
enabled: !!senderAddress && capability !== 'unsupported',
staleTime: 30_000,
});
const { data: feeRates } = useQuery({
- queryKey: ['bitcoin-fee-rates', esploraBaseUrl],
- queryFn: () => getFeeRates(esploraBaseUrl),
+ queryKey: ['bitcoin-fee-rates', esploraApis],
+ queryFn: ({ signal }) => getFeeRates(esploraApis, signal),
enabled: capability !== 'unsupported',
staleTime: 30_000,
});
diff --git a/src/components/PinnedCommentHeader.tsx b/src/components/PinnedCommentHeader.tsx
new file mode 100644
index 00000000..436d7968
--- /dev/null
+++ b/src/components/PinnedCommentHeader.tsx
@@ -0,0 +1,53 @@
+import { Pin } from 'lucide-react';
+import type { ReactNode } from 'react';
+
+import { cn } from '@/lib/utils';
+
+interface PinnedCommentHeaderProps {
+ isPinned: boolean;
+ canManagePins: boolean;
+ pinPending: boolean;
+ onTogglePin: () => void;
+ children?: ReactNode;
+}
+
+export function PinnedCommentHeader({
+ isPinned,
+ canManagePins,
+ pinPending,
+ onTogglePin,
+ children,
+}: PinnedCommentHeaderProps) {
+ if (!isPinned && !canManagePins && !children) return null;
+
+ return (
+
+
+ {isPinned && (
+
+
+ Pinned
+
+ )}
+ {children}
+
+ {canManagePins && (
+
{
+ e.stopPropagation();
+ onTogglePin();
+ }}
+ disabled={pinPending}
+ className={cn(
+ 'inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 font-medium transition-colors hover:bg-primary/10 hover:text-primary disabled:cursor-not-allowed disabled:opacity-60',
+ isPinned && 'text-primary',
+ )}
+ >
+
+ {isPinned ? 'Unpin' : 'Pin'}
+
+ )}
+
+ );
+}
diff --git a/src/components/PrecipitationEffect.tsx b/src/components/PrecipitationEffect.tsx
deleted file mode 100644
index d3ee80ac..00000000
--- a/src/components/PrecipitationEffect.tsx
+++ /dev/null
@@ -1,226 +0,0 @@
-import { useEffect, useRef, memo } from 'react';
-
-import type { PrecipitationIntensity, PrecipitationType } from '@/hooks/useWeather';
-
-interface PrecipitationEffectProps {
- type: PrecipitationType;
- intensity: PrecipitationIntensity;
-}
-
-// ---------------------------------------------------------------------------
-// Particle pool sizes by intensity
-// ---------------------------------------------------------------------------
-
-const RAIN_COUNT: Record = {
- light: 80,
- moderate: 160,
- heavy: 280,
-};
-
-const SNOW_COUNT: Record = {
- light: 50,
- moderate: 100,
- heavy: 180,
-};
-
-// ---------------------------------------------------------------------------
-// Raindrop particle
-// ---------------------------------------------------------------------------
-
-interface RainDrop {
- x: number;
- y: number;
- speed: number;
- length: number;
- opacity: number;
- drift: number;
-}
-
-function createRainDrop(w: number, h: number, intensity: PrecipitationIntensity): RainDrop {
- const speedBase = intensity === 'heavy' ? 14 : intensity === 'moderate' ? 10 : 7;
- const speedRange = intensity === 'heavy' ? 8 : intensity === 'moderate' ? 5 : 3;
- return {
- x: Math.random() * (w + 100) - 50,
- y: Math.random() * h * -1 - 20,
- speed: speedBase + Math.random() * speedRange,
- length: intensity === 'heavy' ? 18 + Math.random() * 12 : 10 + Math.random() * 10,
- opacity: 0.15 + Math.random() * 0.2,
- drift: intensity === 'heavy' ? 1.5 + Math.random() : 0.5 + Math.random() * 0.8,
- };
-}
-
-// ---------------------------------------------------------------------------
-// Snowflake particle
-// ---------------------------------------------------------------------------
-
-interface SnowFlake {
- x: number;
- y: number;
- speed: number;
- radius: number;
- opacity: number;
- wobbleAmp: number;
- wobbleFreq: number;
- wobblePhase: number;
-}
-
-function createSnowFlake(w: number, h: number, intensity: PrecipitationIntensity): SnowFlake {
- const sizeBase = intensity === 'heavy' ? 2.5 : intensity === 'moderate' ? 2 : 1.5;
- const sizeRange = intensity === 'heavy' ? 3 : intensity === 'moderate' ? 2.5 : 2;
- return {
- x: Math.random() * (w + 60) - 30,
- y: Math.random() * h * -1 - 10,
- speed: 0.5 + Math.random() * (intensity === 'heavy' ? 1.8 : intensity === 'moderate' ? 1.2 : 0.8),
- radius: sizeBase + Math.random() * sizeRange,
- opacity: 0.4 + Math.random() * 0.4,
- wobbleAmp: 0.3 + Math.random() * 0.8,
- wobbleFreq: 0.01 + Math.random() * 0.02,
- wobblePhase: Math.random() * Math.PI * 2,
- };
-}
-
-// ---------------------------------------------------------------------------
-// Canvas precipitation renderer
-// ---------------------------------------------------------------------------
-
-export const PrecipitationEffect = memo(function PrecipitationEffect({
- type,
- intensity,
-}: PrecipitationEffectProps) {
- const canvasRef = useRef(null);
- const rafRef = useRef(0);
- const rainDrops = useRef([]);
- const snowFlakes = useRef([]);
- const frameRef = useRef(0);
-
- useEffect(() => {
- const canvas = canvasRef.current;
- if (!canvas || !type) return;
-
- const ctx = canvas.getContext('2d');
- if (!ctx) return;
-
- function resize() {
- if (!canvas) return;
- canvas.width = window.innerWidth;
- canvas.height = window.innerHeight;
- }
- resize();
- window.addEventListener('resize', resize);
-
- // Initialize particle pools
- const w = canvas.width;
- const h = canvas.height;
-
- if (type === 'rain') {
- const count = RAIN_COUNT[intensity];
- rainDrops.current = [];
- for (let i = 0; i < count; i++) {
- const drop = createRainDrop(w, h, intensity);
- // Scatter initial y positions across the screen for instant coverage
- drop.y = Math.random() * h;
- rainDrops.current.push(drop);
- }
- } else {
- const count = SNOW_COUNT[intensity];
- snowFlakes.current = [];
- for (let i = 0; i < count; i++) {
- const flake = createSnowFlake(w, h, intensity);
- flake.y = Math.random() * h;
- snowFlakes.current.push(flake);
- }
- }
-
- function drawRain() {
- if (!canvas || !ctx) return;
- ctx.clearRect(0, 0, canvas.width, canvas.height);
-
- for (const drop of rainDrops.current) {
- drop.y += drop.speed;
- drop.x += drop.drift;
-
- // Reset when off screen
- if (drop.y > canvas.height + 20) {
- drop.y = -drop.length - Math.random() * 40;
- drop.x = Math.random() * (canvas.width + 100) - 50;
- }
- if (drop.x > canvas.width + 50) {
- drop.x = -50;
- }
-
- // Draw the raindrop as a thin line with a subtle glow
- ctx.beginPath();
- ctx.moveTo(drop.x, drop.y);
- ctx.lineTo(drop.x + drop.drift * 0.5, drop.y + drop.length);
- ctx.strokeStyle = `rgba(174, 194, 224, ${drop.opacity})`;
- ctx.lineWidth = 1.2;
- ctx.lineCap = 'round';
- ctx.stroke();
- }
-
- rafRef.current = requestAnimationFrame(drawRain);
- }
-
- function drawSnow() {
- if (!canvas || !ctx) return;
- ctx.clearRect(0, 0, canvas.width, canvas.height);
- frameRef.current++;
-
- for (const flake of snowFlakes.current) {
- flake.y += flake.speed;
- flake.x += Math.sin(frameRef.current * flake.wobbleFreq + flake.wobblePhase) * flake.wobbleAmp;
-
- // Reset when off screen
- if (flake.y > canvas.height + 10) {
- flake.y = -flake.radius * 2 - Math.random() * 30;
- flake.x = Math.random() * (canvas.width + 60) - 30;
- }
- if (flake.x > canvas.width + 30) {
- flake.x = -30;
- } else if (flake.x < -30) {
- flake.x = canvas.width + 30;
- }
-
- // Draw the snowflake as a soft glowing circle
- ctx.beginPath();
- ctx.arc(flake.x, flake.y, flake.radius, 0, Math.PI * 2);
-
- const gradient = ctx.createRadialGradient(
- flake.x, flake.y, 0,
- flake.x, flake.y, flake.radius,
- );
- gradient.addColorStop(0, `rgba(255, 255, 255, ${flake.opacity})`);
- gradient.addColorStop(0.5, `rgba(230, 238, 255, ${flake.opacity * 0.6})`);
- gradient.addColorStop(1, `rgba(210, 225, 250, 0)`);
-
- ctx.fillStyle = gradient;
- ctx.fill();
- }
-
- rafRef.current = requestAnimationFrame(drawSnow);
- }
-
- if (type === 'rain') {
- rafRef.current = requestAnimationFrame(drawRain);
- } else {
- rafRef.current = requestAnimationFrame(drawSnow);
- }
-
- return () => {
- cancelAnimationFrame(rafRef.current);
- window.removeEventListener('resize', resize);
- rainDrops.current = [];
- snowFlakes.current = [];
- };
- }, [type, intensity]);
-
- if (!type) return null;
-
- return (
-
- );
-});
diff --git a/src/components/ProfileCard.tsx b/src/components/ProfileCard.tsx
index fc1763de..db43f58f 100644
--- a/src/components/ProfileCard.tsx
+++ b/src/components/ProfileCard.tsx
@@ -95,6 +95,8 @@ export interface ProfileCardProps {
onRemoveAvatar?: () => void;
/** Show NIP-05 row (default true) */
showNip05?: boolean;
+ /** Show NIP-58 badge showcase row (default true). */
+ showBadges?: boolean;
/** When provided, render an editable profile fields section below bio */
extraFields?: ProfileField[];
onExtraFieldsChange?: (fields: ProfileField[]) => void;
@@ -107,6 +109,7 @@ export function ProfileCard({
onPickImage,
onRemoveAvatar,
showNip05 = true,
+ showBadges = true,
extraFields,
onExtraFieldsChange,
}: ProfileCardProps) {
@@ -332,7 +335,7 @@ export function ProfileCard({
{/* Badge showcase */}
- {(badgeRefs.length > 0 || badgesLoading) && (
+ {showBadges && (badgeRefs.length > 0 || badgesLoading) && (
({
diff --git a/src/components/ProfileReactionButton.tsx b/src/components/ProfileReactionButton.tsx
index 283b2d2b..5490b7d5 100644
--- a/src/components/ProfileReactionButton.tsx
+++ b/src/components/ProfileReactionButton.tsx
@@ -1,14 +1,17 @@
import { useState, useRef, useCallback } from 'react';
import { SmilePlus } from 'lucide-react';
+import { useQueryClient } from '@tanstack/react-query';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { QuickReactMenu } from '@/components/QuickReactMenu';
import { Button } from '@/components/ui/button';
+import { useAppContext } from '@/hooks/useAppContext';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useEmojiUsage } from '@/hooks/useEmojiUsage';
import { useToast } from '@/hooks/useToast';
import { impactLight } from '@/lib/haptics';
+import { invalidateEventStats } from '@/lib/invalidateEventStats';
import type { NostrEvent } from '@nostrify/nostrify';
interface ProfileReactionButtonProps {
@@ -28,6 +31,9 @@ export function ProfileReactionButton({ profileEvent, className }: ProfileReacti
const { mutate: publishEvent } = useNostrPublish();
const { trackEmojiUsage } = useEmojiUsage();
const { toast } = useToast();
+ const queryClient = useQueryClient();
+ const { config } = useAppContext();
+ const statsPubkey = config.nip85StatsPubkey;
const [menuOpen, setMenuOpen] = useState(false);
const pickerExpandedRef = useRef(false);
const justClosedRef = useRef(false);
@@ -56,10 +62,23 @@ export function ProfileReactionButton({ profileEvent, className }: ProfileReacti
{
onSuccess: () => {
toast({ title: 'Reaction sent!' });
+ // Bump reaction count on the profile. Profile reactions target the
+ // kind 0 event by id, so useNip85EventStats refresh covers it. Also
+ // refresh the addressable `0::` key in case any consumer
+ // reads profile stats via useNip85AddrStats.
+ setTimeout(() => {
+ invalidateEventStats(queryClient, profileEvent, statsPubkey);
+ queryClient.invalidateQueries({
+ queryKey: ['nip85-addr-stats', `0:${profileEvent.pubkey}:`, statsPubkey],
+ });
+ queryClient.invalidateQueries({
+ queryKey: ['nip85-addr-stats', `0:${profileEvent.pubkey}:`],
+ });
+ }, 3000);
},
},
);
- }, [user, profileEvent, publishEvent, trackEmojiUsage, toast]);
+ }, [user, profileEvent, publishEvent, trackEmojiUsage, toast, queryClient, statsPubkey]);
if (!user) return null;
diff --git a/src/components/ProfileRightSidebar.tsx b/src/components/ProfileRightSidebar.tsx
index f14fcb24..6cf5178e 100644
--- a/src/components/ProfileRightSidebar.tsx
+++ b/src/components/ProfileRightSidebar.tsx
@@ -82,6 +82,16 @@ interface ProfileRightSidebarProps {
onMediaClick?: (url: string) => void;
/** Override the root element's className (e.g. to show on mobile). */
className?: string;
+ /**
+ * Layout variant.
+ *
+ * - `'rail'` (default) — legacy 1/4-width fixed sidebar with full-height
+ * sticky scrolling. Designed for the old MainLayout shell.
+ * - `'inline'` — fills its parent's width with no positioning, so it can
+ * be slotted into a grid cell or another container that manages
+ * layout (e.g. the ProfilePage two-column body).
+ */
+ variant?: 'rail' | 'inline';
}
interface MediaItem {
@@ -495,7 +505,7 @@ function sidebarJustifiedLayout(items: MediaItem[]): { items: MediaItem[]; heigh
return rows;
}
-export function ProfileRightSidebar({ fields, pubkey, onMediaClick, className }: ProfileRightSidebarProps) {
+export function ProfileRightSidebar({ fields, pubkey, onMediaClick, className, variant = 'rail' }: ProfileRightSidebarProps) {
const { config } = useAppContext();
const { nostr } = useNostr();
@@ -534,8 +544,12 @@ export function ProfileRightSidebar({ fields, pubkey, onMediaClick, className }:
const sidebarRows = useMemo(() => sidebarJustifiedLayout(media), [media]);
+ const rootClass = variant === 'inline'
+ ? 'flex flex-col w-full'
+ : 'w-1/4 max-w-[300px] shrink-0 hidden lg:flex flex-col sticky top-0 h-screen overflow-y-auto pt-2 pb-3 px-3';
+
return (
-
+
{/* Media Section — only shown when pubkey prop is provided */}
{pubkey !== undefined &&
Media
diff --git a/src/components/QuickReactMenu.tsx b/src/components/QuickReactMenu.tsx
index 21ddd947..4d3fcb26 100644
--- a/src/components/QuickReactMenu.tsx
+++ b/src/components/QuickReactMenu.tsx
@@ -5,6 +5,7 @@ import { useQueryClient } from '@tanstack/react-query';
import { CustomEmojiImg } from '@/components/CustomEmoji';
import { EmojiPicker, type EmojiSelection } from '@/components/EmojiPicker';
import { isCustomEmoji } from '@/lib/customEmoji';
+import { useAppContext } from '@/hooks/useAppContext';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useEmojiUsage } from '@/hooks/useEmojiUsage';
@@ -12,7 +13,8 @@ import { useCustomEmojis } from '@/hooks/useCustomEmojis';
import { useFeedSettings } from '@/hooks/useFeedSettings';
import { cn } from '@/lib/utils';
import { impactLight } from '@/lib/haptics';
-import type { EventStats } from '@/hooks/useTrending';
+import { invalidateEventStats } from '@/lib/invalidateEventStats';
+import type { Nip85EventStats } from '@/hooks/useNip85Stats';
import type { ResolvedEmoji } from '@/lib/customEmoji';
interface QuickReactMenuProps {
@@ -48,6 +50,12 @@ export function QuickReactMenu({
const { user } = useCurrentUser();
const { mutate: publishEvent } = useNostrPublish();
const queryClient = useQueryClient();
+ const { config } = useAppContext();
+ const statsPubkey = config.nip85StatsPubkey;
+ const statsKey = useMemo(
+ () => ['nip85-event-stats', eventId, statsPubkey] as const,
+ [eventId, statsPubkey],
+ );
const { trackEmojiUsage, getTopEmojis } = useEmojiUsage();
const { feedSettings } = useFeedSettings();
const { emojis: allCustomEmojis } = useCustomEmojis();
@@ -104,14 +112,11 @@ export function QuickReactMenu({
const resolvedEmoji: ResolvedEmoji = emojiTag
? { content: displayEmoji, url: emojiTag[2], name: emojiTag[1] }
: { content: displayEmoji };
- const prevStats = queryClient.getQueryData(['event-stats', eventId]);
+ const prevStats = queryClient.getQueryData(statsKey);
if (prevStats) {
- queryClient.setQueryData(['event-stats', eventId], {
+ queryClient.setQueryData(statsKey, {
...prevStats,
- reactions: prevStats.reactions + 1,
- reactionEmojis: prevStats.reactionEmojis.some((e) => e.content === displayEmoji)
- ? prevStats.reactionEmojis
- : [...prevStats.reactionEmojis, resolvedEmoji],
+ reactionCount: prevStats.reactionCount + 1,
});
}
@@ -136,20 +141,20 @@ export function QuickReactMenu({
{
onSuccess: () => {
setTimeout(() => {
- queryClient.invalidateQueries({ queryKey: ['event-stats', eventId] });
+ invalidateEventStats(queryClient, eventId, statsPubkey);
queryClient.invalidateQueries({ queryKey: ['event-interactions', eventId] });
}, 3000);
},
onError: () => {
setSelectedEmoji(null);
if (prevStats) {
- queryClient.setQueryData(['event-stats', eventId], prevStats);
+ queryClient.setQueryData(statsKey, prevStats);
}
queryClient.removeQueries({ queryKey: ['user-reaction', eventId] });
},
},
);
- }, [user, eventId, eventPubkey, eventKind, onReact, publishEvent, queryClient, trackEmojiUsage, onClose]);
+ }, [user, eventId, eventPubkey, eventKind, onReact, publishEvent, queryClient, trackEmojiUsage, onClose, statsPubkey, statsKey]);
/** Handle selection from the quick buttons (native or custom emoji). */
const handleQuickSelect = useCallback((emoji: string) => {
diff --git a/src/components/ReactionButton.tsx b/src/components/ReactionButton.tsx
index b8e490de..db3871e6 100644
--- a/src/components/ReactionButton.tsx
+++ b/src/components/ReactionButton.tsx
@@ -1,4 +1,4 @@
-import { useState, useRef, useCallback } from 'react';
+import { useState, useRef, useCallback, useMemo } from 'react';
import { Heart } from 'lucide-react';
import { useQueryClient } from '@tanstack/react-query';
import { useNostr } from '@nostrify/react';
@@ -6,13 +6,15 @@ import { useNostr } from '@nostrify/react';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { QuickReactMenu } from '@/components/QuickReactMenu';
import { RenderResolvedEmoji } from '@/components/CustomEmoji';
+import { useAppContext } from '@/hooks/useAppContext';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useUserReaction } from '@/hooks/useUserReaction';
import { useNostrPublish } from '@/hooks/useNostrPublish';
+import type { Nip85EventStats } from '@/hooks/useNip85Stats';
import { formatNumber } from '@/lib/formatNumber';
import { impactLight } from '@/lib/haptics';
+import { invalidateEventStats } from '@/lib/invalidateEventStats';
import { cn } from '@/lib/utils';
-import type { EventStats } from '@/hooks/useTrending';
interface ReactionButtonProps {
/** The event ID being reacted to. */
@@ -50,6 +52,12 @@ export function ReactionButton({
const { nostr } = useNostr();
const { mutate: publishEvent } = useNostrPublish();
const queryClient = useQueryClient();
+ const { config } = useAppContext();
+ const statsPubkey = config.nip85StatsPubkey;
+ const statsKey = useMemo(
+ () => ['nip85-event-stats', eventId, statsPubkey] as const,
+ [eventId, statsPubkey],
+ );
const [menuOpen, setMenuOpen] = useState(false);
const closeTimeoutRef = useRef(null);
const justClosedRef = useRef(false);
@@ -76,14 +84,14 @@ export function ReactionButton({
// Snapshot for rollback
const prevReaction = queryClient.getQueryData(['user-reaction', eventId]);
- const prevStats = queryClient.getQueryData(['event-stats', eventId]);
+ const prevStats = queryClient.getQueryData(statsKey);
// Optimistic update: clear reaction and decrement count
queryClient.setQueryData(['user-reaction', eventId], null);
if (prevStats) {
- queryClient.setQueryData(['event-stats', eventId], {
+ queryClient.setQueryData(statsKey, {
...prevStats,
- reactions: Math.max(0, prevStats.reactions - 1),
+ reactionCount: Math.max(0, prevStats.reactionCount - 1),
});
}
@@ -92,7 +100,7 @@ export function ReactionButton({
{
onSuccess: () => {
setTimeout(() => {
- queryClient.invalidateQueries({ queryKey: ['event-stats', eventId] });
+ invalidateEventStats(queryClient, eventId, statsPubkey);
queryClient.invalidateQueries({ queryKey: ['event-interactions', eventId] });
queryClient.invalidateQueries({ queryKey: ['user-reaction', eventId] });
}, 3000);
@@ -101,12 +109,12 @@ export function ReactionButton({
// Rollback
queryClient.setQueryData(['user-reaction', eventId], prevReaction);
if (prevStats) {
- queryClient.setQueryData(['event-stats', eventId], prevStats);
+ queryClient.setQueryData(statsKey, prevStats);
}
},
},
);
- }, [user, nostr, eventId, publishEvent, queryClient]);
+ }, [user, nostr, eventId, publishEvent, queryClient, statsPubkey, statsKey]);
const handleMouseEnter = useCallback(() => {
if (!user) return;
@@ -163,12 +171,12 @@ export function ReactionButton({
if (hasReacted) return;
impactLight();
setMenuOpen(false);
- const prevStats = queryClient.getQueryData(['event-stats', eventId]);
+ const prevStats = queryClient.getQueryData(statsKey);
queryClient.setQueryData(['user-reaction', eventId], { content: '❤️' });
if (prevStats) {
- queryClient.setQueryData(['event-stats', eventId], {
+ queryClient.setQueryData(statsKey, {
...prevStats,
- reactions: prevStats.reactions + 1,
+ reactionCount: prevStats.reactionCount + 1,
});
}
publishEvent(
@@ -180,7 +188,7 @@ export function ReactionButton({
{
onSuccess: () => {
setTimeout(() => {
- queryClient.invalidateQueries({ queryKey: ['event-stats', eventId] });
+ invalidateEventStats(queryClient, eventId, statsPubkey);
queryClient.invalidateQueries({ queryKey: ['event-interactions', eventId] });
queryClient.invalidateQueries({ queryKey: ['user-reaction', eventId] });
}, 3000);
@@ -188,7 +196,7 @@ export function ReactionButton({
onError: () => {
queryClient.setQueryData(['user-reaction', eventId], null);
if (prevStats) {
- queryClient.setQueryData(['event-stats', eventId], prevStats);
+ queryClient.setQueryData(statsKey, prevStats);
}
},
},
@@ -214,8 +222,6 @@ export function ReactionButton({
{formatNumber(reactionCount)}
- ) : variant === 'chip' ? (
- React
) : null}
diff --git a/src/components/RepostMenu.tsx b/src/components/RepostMenu.tsx
index 53e75bdb..21fa599a 100644
--- a/src/components/RepostMenu.tsx
+++ b/src/components/RepostMenu.tsx
@@ -1,4 +1,4 @@
-import { Quote, Rocket, Undo2 } from 'lucide-react';
+import { Megaphone, Quote, Undo2 } from 'lucide-react';
import { RepostIcon } from '@/components/icons/RepostIcon';
import { useState } from 'react';
import type { NostrEvent } from '@nostrify/nostrify';
@@ -7,6 +7,7 @@ import { impactLight } from '@/lib/haptics';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { ReplyComposeModal } from '@/components/ReplyComposeModal';
import { useNostrPublish } from '@/hooks/useNostrPublish';
+import { useAppContext } from '@/hooks/useAppContext';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useDeleteEvent } from '@/hooks/useDeleteEvent';
import { useRepostStatus } from '@/hooks/useRepostStatus';
@@ -15,7 +16,8 @@ import { useToast } from '@/hooks/useToast';
import { getRepostKind } from '@/lib/feedUtils';
import { DITTO_RELAY } from '@/lib/appRelays';
import { encodeEventAddress } from '@/lib/encodeEvent';
-import type { EventStats } from '@/hooks/useTrending';
+import type { Nip85EventStats } from '@/hooks/useNip85Stats';
+import { invalidateEventStats } from '@/lib/invalidateEventStats';
interface RepostMenuProps {
event: NostrEvent;
@@ -32,6 +34,9 @@ export function RepostMenu({ event, children }: RepostMenuProps) {
const repostEventId = useRepostStatus(event.id);
const queryClient = useQueryClient();
const { toast } = useToast();
+ const { config } = useAppContext();
+ const statsPubkey = config.nip85StatsPubkey;
+ const statsKey = ['nip85-event-stats', event.id, statsPubkey] as const;
const isReposted = !!repostEventId;
@@ -43,11 +48,11 @@ export function RepostMenu({ event, children }: RepostMenuProps) {
impactLight();
// Optimistically update stats cache immediately
- const prevStats = queryClient.getQueryData(['event-stats', event.id]);
+ const prevStats = queryClient.getQueryData(statsKey);
if (prevStats) {
- queryClient.setQueryData(['event-stats', event.id], {
+ queryClient.setQueryData(statsKey, {
...prevStats,
- reposts: prevStats.reposts + 1,
+ repostCount: prevStats.repostCount + 1,
});
}
@@ -83,7 +88,7 @@ export function RepostMenu({ event, children }: RepostMenuProps) {
setOpen(false);
// Delay invalidation so the relay has time to index the new event.
setTimeout(() => {
- queryClient.invalidateQueries({ queryKey: ['event-stats', event.id] });
+ invalidateEventStats(queryClient, event, statsPubkey);
queryClient.invalidateQueries({ queryKey: ['event-interactions', event.id] });
queryClient.invalidateQueries({ queryKey: ['user-repost', event.id] });
}, 3000);
@@ -92,7 +97,7 @@ export function RepostMenu({ event, children }: RepostMenuProps) {
toast({ title: 'Failed to repost', variant: 'destructive' });
// Revert optimistic updates
if (prevStats) {
- queryClient.setQueryData(['event-stats', event.id], prevStats);
+ queryClient.setQueryData(statsKey, prevStats);
}
queryClient.setQueryData(['user-repost', event.id], null);
},
@@ -105,11 +110,11 @@ export function RepostMenu({ event, children }: RepostMenuProps) {
impactLight();
// Optimistically update stats cache
- const prevStats = queryClient.getQueryData(['event-stats', event.id]);
+ const prevStats = queryClient.getQueryData(statsKey);
if (prevStats) {
- queryClient.setQueryData(['event-stats', event.id], {
+ queryClient.setQueryData(statsKey, {
...prevStats,
- reposts: Math.max(0, prevStats.reposts - 1),
+ repostCount: Math.max(0, prevStats.repostCount - 1),
});
}
@@ -124,7 +129,7 @@ export function RepostMenu({ event, children }: RepostMenuProps) {
toast({ title: 'Repost removed' });
setOpen(false);
setTimeout(() => {
- queryClient.invalidateQueries({ queryKey: ['event-stats', event.id] });
+ invalidateEventStats(queryClient, event, statsPubkey);
queryClient.invalidateQueries({ queryKey: ['event-interactions', event.id] });
queryClient.invalidateQueries({ queryKey: ['user-repost', event.id] });
}, 3000);
@@ -133,7 +138,7 @@ export function RepostMenu({ event, children }: RepostMenuProps) {
toast({ title: 'Failed to remove repost', variant: 'destructive' });
// Revert optimistic updates
if (prevStats) {
- queryClient.setQueryData(['event-stats', event.id], prevStats);
+ queryClient.setQueryData(statsKey, prevStats);
}
queryClient.setQueryData(['user-repost', event.id], prevRepostStatus);
},
@@ -195,7 +200,7 @@ export function RepostMenu({ event, children }: RepostMenuProps) {
}}
className="flex items-center gap-3 w-full px-4 py-3 text-[15px] text-foreground hover:bg-secondary/60 transition-colors"
>
-
+
Boost
diff --git a/src/components/SavedFeedFiltersEditor.tsx b/src/components/SavedFeedFiltersEditor.tsx
index 87e3b256..820add78 100644
--- a/src/components/SavedFeedFiltersEditor.tsx
+++ b/src/components/SavedFeedFiltersEditor.tsx
@@ -44,7 +44,7 @@ type KindOption = {
// ─── Kind options (built once) ───────────────────────────────────────────────
-import { buildKindOptions } from '@/lib/feedFilterUtils';
+import { buildKindOptions, AGORA_PRESET_KIND_VALUES } from '@/lib/feedFilterUtils';
// ─── useScrollCarets ─────────────────────────────────────────────────────────
@@ -152,7 +152,26 @@ export function KindPicker({ value, options, onChange }: {
);
}, [options, search]);
- const selected = value === 'all' || value === 'custom' ? null : options.find((o) => o.value === value);
+ // Partition into Agora preset (top of picker) and the rest.
+ // When the user is searching, we skip the partition and show a flat list.
+ const presetSet = useMemo(() => new Set(AGORA_PRESET_KIND_VALUES), []);
+ const { presetOptions, otherOptions } = useMemo(() => {
+ if (search) return { presetOptions: [], otherOptions: filtered };
+ const preset: KindOption[] = [];
+ const other: KindOption[] = [];
+ // Preserve AGORA_PRESET_KIND_VALUES order for the preset section.
+ const byValue = new Map(filtered.map((o) => [o.value, o]));
+ for (const v of AGORA_PRESET_KIND_VALUES) {
+ const opt = byValue.get(v);
+ if (opt) preset.push(opt);
+ }
+ for (const o of filtered) {
+ if (!presetSet.has(o.value)) other.push(o);
+ }
+ return { presetOptions: preset, otherOptions: other };
+ }, [filtered, presetSet, search]);
+
+ const selected = value === 'all' || value === 'agora' || value === 'custom' ? null : options.find((o) => o.value === value);
const SelectedIcon = selected?.icon;
const handleSelect = (v: string) => { onChange(v); setOpen(false); setSearch(''); };
@@ -170,7 +189,13 @@ export function KindPicker({ value, options, onChange }: {
?
:
}
- {value === 'all' ? 'All' : value === 'custom' ? 'Custom...' : (selected?.label ?? value)}
+ {value === 'all'
+ ? 'All kinds'
+ : value === 'agora'
+ ? 'Agora content'
+ : value === 'custom'
+ ? 'Custom...'
+ : (selected?.label ?? value)}
@@ -198,8 +223,28 @@ export function KindPicker({ value, options, onChange }: {
{canScrollUp &&
startScroll('up')} onMouseLeave={stopScroll} />}
- {!search &&
handleSelect('all')} />}
- {filtered.map((opt) => (
+ {!search && (
+ <>
+ handleSelect('agora')} />
+ handleSelect('all')} />
+ >
+ )}
+ {!search && presetOptions.length > 0 && (
+ <>
+
+ Agora content
+
+ {presetOptions.map((opt) => (
+ handleSelect(opt.value)} />
+ ))}
+ {otherOptions.length > 0 && (
+
+ All kinds
+
+ )}
+ >
+ )}
+ {otherOptions.map((opt) => (
handleSelect(opt.value)} />
))}
{(!search || 'custom'.includes(search.toLowerCase())) && (
diff --git a/src/components/SendBitcoinDialog.tsx b/src/components/SendBitcoinDialog.tsx
deleted file mode 100644
index 2155a498..00000000
--- a/src/components/SendBitcoinDialog.tsx
+++ /dev/null
@@ -1,1228 +0,0 @@
-import { useState, useCallback, useMemo, useRef, useEffect } from 'react';
-import { Link } from 'react-router-dom';
-import { nip19 } from 'nostr-tools';
-import {
- AlertTriangle,
- Bitcoin,
- Check,
- ExternalLink,
- Loader2,
- UserRoundCheck,
- X,
-} from 'lucide-react';
-import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
-
-import { Button } from '@/components/ui/button';
-import { Input } from '@/components/ui/input';
-import {
- Dialog,
- DialogContent,
- DialogTitle,
-} from '@/components/ui/dialog';
-import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
-import {
- Popover,
- PopoverContent,
- PopoverTrigger,
-} from '@/components/ui/popover';
-import { Alert, AlertDescription } from '@/components/ui/alert';
-import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
-import { BitcoinPublicDisclaimer } from '@/components/BitcoinPublicDisclaimer';
-import { ZapSuccessScreen } from '@/components/ZapSuccessScreen';
-import { EmojifiedText } from '@/components/CustomEmoji';
-import { getAvatarShape } from '@/lib/avatarShape';
-import { genUserName } from '@/lib/genUserName';
-import { cn } from '@/lib/utils';
-
-import { useCurrentUser } from '@/hooks/useCurrentUser';
-import { useBitcoinSigner } from '@/hooks/useBitcoinSigner';
-import { useNostrPublish } from '@/hooks/useNostrPublish';
-import { useToast } from '@/hooks/useToast';
-import { useAppContext } from '@/hooks/useAppContext';
-import { useSearchProfiles, type SearchProfile } from '@/hooks/useSearchProfiles';
-import { useAuthor } from '@/hooks/useAuthor';
-import { useNip05Resolve } from '@/hooks/useNip05Resolve';
-import { detectIdentifier, type IdentifierMatch } from '@/lib/nostrIdentifier';
-import { isNostrId } from '@/lib/nostrId';
-import { notificationSuccess } from '@/lib/haptics';
-import {
- nostrPubkeyToBitcoinAddress,
- validateBitcoinAddress,
- fetchUTXOs,
- getFeeRates,
- buildUnsignedPsbt,
- finalizePsbt,
- broadcastTransaction,
- estimateFee,
- satsToUSD,
- isLargeAmount,
- type FeeRates,
-} from '@/lib/bitcoin';
-
-// ---------------------------------------------------------------------------
-// Constants
-// ---------------------------------------------------------------------------
-
-const USD_PRESETS = [1, 5, 10, 25, 100];
-
-type FeeSpeed = 'fastest' | 'halfHour' | 'hour' | 'economy';
-
-const FEE_SPEED_LABELS: Record = {
- fastest: '~10 min',
- halfHour: '~30 min',
- hour: '~1 hour',
- economy: '~1 day',
-};
-
-const FEE_SPEED_ORDER: FeeSpeed[] = ['fastest', 'halfHour', 'hour', 'economy'];
-
-function getRateForSpeed(rates: FeeRates, speed: FeeSpeed): number {
- switch (speed) {
- case 'fastest': return rates.fastestFee;
- case 'halfHour': return rates.halfHourFee;
- case 'hour': return rates.hourFee;
- case 'economy': return rates.economyFee;
- }
-}
-
-/** Deduplicate fee tiers that share the same sat/vB rate. */
-function getUniqueFeeSpeeds(rates: FeeRates | undefined): FeeSpeed[] {
- if (!rates) return FEE_SPEED_ORDER;
- const seen = new Set();
- const result: FeeSpeed[] = [];
- for (const speed of FEE_SPEED_ORDER) {
- const rate = getRateForSpeed(rates, speed);
- if (!seen.has(rate)) {
- seen.add(rate);
- result.push(speed);
- }
- }
- return result;
-}
-
-// ---------------------------------------------------------------------------
-// Types
-// ---------------------------------------------------------------------------
-
-/**
- * A recipient resolved to send Bitcoin to. Always carries a Bitcoin address.
- * When `pubkey` is set, the recipient is also a Nostr identity — meaning we
- * can publish a kind 8333 profile-zap attesting the send.
- */
-interface ResolvedRecipient {
- /** P2TR Bitcoin address to send to. */
- address: string;
- /** Hex Nostr pubkey, when the recipient is a Nostr user. */
- pubkey?: string;
- /** Optional profile metadata for display. */
- profile?: SearchProfile;
- /** Raw text the user originally typed (for re-display on backspace). */
- raw: string;
-}
-
-interface SendBitcoinDialogProps {
- isOpen: boolean;
- onClose: () => void;
- /** BTC/USD price — passed from the parent to avoid a duplicate fetch. */
- btcPrice?: number;
-}
-
-interface SendResult {
- txid: string;
- amountSats: number;
- /** Set when the recipient was a Nostr user (kind 8333 published). */
- recipientPubkey?: string;
- /** Bitcoin network fee in satoshis. */
- fee: number;
-}
-
-// ---------------------------------------------------------------------------
-// Component
-// ---------------------------------------------------------------------------
-
-/**
- * Wallet "Send Bitcoin" dialog. Mirrors the `OnchainZapContent` UX for the
- * standalone wallet flow:
- *
- * - Single screen, no review step. Big editable USD amount on top, USD preset
- * chips below.
- * - Recipient picker with profile autocomplete (same lookups as the global
- * search bar — profiles only, plus pasted npub/nprofile/nip05/hex), and
- * fallback acceptance of a raw Bitcoin address (bc1…).
- * - Fee speed shown as a small line below the send button; popover for picking.
- * - When the recipient is a Nostr identity, publishes a kind 8333 onchain-zap
- * event after broadcast (no `e`/`a` tag → profile-level zap, per NIP.md).
- */
-export function SendBitcoinDialog({ isOpen, onClose, btcPrice }: SendBitcoinDialogProps) {
- const { user } = useCurrentUser();
- const { canSignPsbt, signPsbt } = useBitcoinSigner();
- const { mutateAsync: publishEvent } = useNostrPublish();
- const { toast } = useToast();
- const { config } = useAppContext();
- const { esploraBaseUrl } = config;
- const queryClient = useQueryClient();
-
- // ── Form state ───────────────────────────────────────────────
- const [recipient, setRecipient] = useState(null);
- const [usdAmount, setUsdAmount] = useState(5);
- const [feeSpeed, setFeeSpeed] = useState('halfHour');
- const [error, setError] = useState('');
- const [editingAmount, setEditingAmount] = useState(false);
- const [feePopoverOpen, setFeePopoverOpen] = useState(false);
- const [success, setSuccess] = useState(null);
-
- const amountInputRef = useRef(null);
- const feeSpeedUserChanged = useRef(false);
-
- const senderAddress = user ? nostrPubkeyToBitcoinAddress(user.pubkey) : '';
-
- // ── Data fetching ────────────────────────────────────────────
-
- const { data: utxos } = useQuery({
- queryKey: ['bitcoin-utxos', esploraBaseUrl, senderAddress],
- queryFn: () => fetchUTXOs(senderAddress, esploraBaseUrl),
- enabled: !!senderAddress && isOpen && canSignPsbt,
- staleTime: 30_000,
- });
-
- const { data: feeRates } = useQuery({
- queryKey: ['bitcoin-fee-rates', esploraBaseUrl],
- queryFn: () => getFeeRates(esploraBaseUrl),
- enabled: isOpen && canSignPsbt,
- staleTime: 30_000,
- });
-
- const totalBalance = useMemo(() => utxos?.reduce((s, u) => s + u.value, 0) ?? 0, [utxos]);
-
- const currentFeeRate = useMemo(() => {
- if (!feeRates) return 0;
- return getRateForSpeed(feeRates, feeSpeed);
- }, [feeRates, feeSpeed]);
-
- // ── USD → sats conversion ────────────────────────────────────
-
- const amountSats = useMemo(() => {
- if (!btcPrice) return 0;
- const usd = typeof usdAmount === 'string' ? parseFloat(usdAmount) : usdAmount;
- if (!Number.isFinite(usd) || usd <= 0) return 0;
- const btc = usd / btcPrice;
- return Math.round(btc * 100_000_000);
- }, [usdAmount, btcPrice]);
-
- const estimatedFeeSats = useMemo(() => {
- if (!utxos?.length || !currentFeeRate || !amountSats) return 0;
- // Estimate with 2 outputs first, then check whether change would be dust
- const fee2 = estimateFee(utxos.length, 2, currentFeeRate);
- const change = totalBalance - amountSats - fee2;
- const numOutputs = change > 546 ? 2 : 1;
- return estimateFee(utxos.length, numOutputs, currentFeeRate);
- }, [utxos, currentFeeRate, amountSats, totalBalance]);
-
- const totalSats = amountSats + estimatedFeeSats;
- const insufficient = totalBalance > 0 && totalSats > totalBalance;
- const showBalance = insufficient || (amountSats > 0 && totalBalance === 0);
-
- // Auto-tune the fee speed so the network fee stays under 40% of the send
- // amount, unless the user has manually picked a speed. Mirrors OnchainZapContent.
- useEffect(() => {
- if (feeSpeedUserChanged.current) return;
- if (!utxos?.length || !feeRates || amountSats <= 0) return;
-
- const uniqueSpeeds = getUniqueFeeSpeeds(feeRates);
- const threshold = amountSats * 0.4;
-
- let target: FeeSpeed = uniqueSpeeds[uniqueSpeeds.length - 1];
- for (const speed of uniqueSpeeds) {
- const rate = getRateForSpeed(feeRates, speed);
- const fee2 = estimateFee(utxos.length, 2, rate);
- const change = totalBalance - amountSats - fee2;
- const outputs = change > 546 ? 2 : 1;
- const fee = estimateFee(utxos.length, outputs, rate);
- if (fee <= threshold) {
- target = speed;
- break;
- }
- }
-
- setFeeSpeed((prev) => (prev === target ? prev : target));
- }, [amountSats, feeRates, utxos, totalBalance]);
-
- const handleFeeSpeedChange = useCallback((speed: FeeSpeed) => {
- feeSpeedUserChanged.current = true;
- setFeeSpeed(speed);
- setFeePopoverOpen(false);
- }, []);
-
- // ── Two-tap "arm" for large amounts ──────────────────────────
-
- const isLarge = isLargeAmount(totalSats, btcPrice);
- const [confirmArmed, setConfirmArmed] = useState(false);
-
- // ── Raw Bitcoin address privacy acknowledgement ─────────────
- //
- // When the recipient is a raw on-chain address (no Nostr pubkey attached),
- // Bitcoin's public ledger means the send can be linked back to the sender's
- // wallet forever. Require an explicit checkbox before unlocking the Send
- // button, and force the two-tap arm regardless of amount.
-
- const isRawAddress = !!recipient && !recipient.pubkey;
- const [acknowledgedPublic, setAcknowledgedPublic] = useState(false);
-
- useEffect(() => {
- setConfirmArmed(false);
- setAcknowledgedPublic(false);
- }, [amountSats, currentFeeRate, btcPrice, recipient?.address]);
-
- // For raw addresses we always require the two-tap arm, not just for large sends.
- const requiresArm = isLarge || isRawAddress;
-
- // ── Big amount focus management ──────────────────────────────
-
- useEffect(() => {
- if (editingAmount) {
- amountInputRef.current?.focus();
- amountInputRef.current?.select();
- }
- }, [editingAmount]);
-
- const commitAmountEdit = useCallback(() => {
- setEditingAmount(false);
- if (typeof usdAmount === 'string' && usdAmount.trim() === '') {
- setUsdAmount(0);
- }
- }, [usdAmount]);
-
- // ── Send mutation ────────────────────────────────────────────
-
- const [progress, setProgress] = useState<'idle' | 'building' | 'signing' | 'broadcasting' | 'publishing'>('idle');
-
- const sendMutation = useMutation({
- mutationFn: async () => {
- if (!user) throw new Error('You must be logged in.');
- if (!recipient) throw new Error('Choose a recipient.');
- if (!canSignPsbt || !signPsbt) throw new Error("Your login doesn't support sending Bitcoin.");
- if (!utxos?.length) throw new Error('No spendable Bitcoin available.');
- if (!feeRates) throw new Error('Fee rates not loaded.');
- if (recipient.pubkey === user.pubkey) throw new Error("You can't send to yourself.");
- if (amountSats <= 0) throw new Error('Enter an amount.');
- if (insufficient) throw new Error('Not enough Bitcoin for this amount + network fee.');
-
- setProgress('building');
- const rate = getRateForSpeed(feeRates, feeSpeed);
- const { psbtHex, fee } = buildUnsignedPsbt(
- user.pubkey,
- recipient.address,
- amountSats,
- utxos,
- rate,
- );
-
- setProgress('signing');
- const signedHex = await signPsbt(psbtHex);
- const txHex = finalizePsbt(signedHex);
-
- setProgress('broadcasting');
- const txid = await broadcastTransaction(txHex, esploraBaseUrl);
-
- // When the recipient is a Nostr identity, publish a kind 8333 profile zap
- // attesting the send. Per NIP.md, omitting `e`/`a` targets the recipient's
- // profile (a tip to the pubkey, not a specific event).
- if (recipient.pubkey) {
- setProgress('publishing');
- try {
- await publishEvent({
- kind: 8333,
- content: '',
- tags: [
- ['i', `bitcoin:tx:${txid}`],
- ['p', recipient.pubkey],
- ['amount', String(amountSats)],
- ['alt', `On-chain zap: ${amountSats.toLocaleString()} sats`],
- ],
- });
- } catch (err) {
- // The Bitcoin transaction already broadcast — the kind 8333 is a
- // best-effort attestation. Surface the failure but don't blow up
- // the success screen.
- console.warn('Failed to publish kind 8333 zap event:', err);
- }
- }
-
- return {
- txid,
- amountSats,
- recipientPubkey: recipient.pubkey,
- fee,
- }; },
- onSuccess: (result) => {
- notificationSuccess();
- setSuccess(result);
- // Invalidate caches that track balances / zaps
- queryClient.invalidateQueries({ queryKey: ['bitcoin-wallet'] });
- queryClient.invalidateQueries({ queryKey: ['bitcoin-utxos'] });
- queryClient.invalidateQueries({ queryKey: ['bitcoin-balance'] });
- queryClient.invalidateQueries({ queryKey: ['bitcoin-txs'] });
- if (result.recipientPubkey) {
- queryClient.invalidateQueries({ queryKey: ['onchain-zaps'] });
- }
- },
- onError: (err) => {
- toast({ title: 'Transaction failed', description: err.message, variant: 'destructive' });
- },
- onSettled: () => {
- setProgress('idle');
- },
- });
-
- // ── Send handler ─────────────────────────────────────────────
-
- const handleSend = useCallback(async () => {
- setError('');
- if (!user) { setError('You must be logged in.'); return; }
- if (!recipient) { setError('Choose a recipient.'); return; }
- if (recipient.pubkey === user.pubkey) { setError("You can't send to yourself."); return; }
- if (!btcPrice) { setError('Waiting for BTC price…'); return; }
- if (amountSats <= 0) { setError('Enter an amount.'); return; }
- if (!utxos?.length) { setError("You don't have any Bitcoin yet."); return; }
- if (insufficient) { setError('Not enough Bitcoin for this amount + network fee.'); return; }
-
- if (isRawAddress && !acknowledgedPublic) {
- setError('Acknowledge the privacy warning before sending.');
- return;
- }
-
- if (requiresArm && !confirmArmed) {
- setConfirmArmed(true);
- return;
- }
-
- try {
- await sendMutation.mutateAsync();
- } catch {
- // Toast handled in onError; nothing further to do here.
- }
- }, [user, recipient, btcPrice, amountSats, utxos, insufficient, isRawAddress, acknowledgedPublic, requiresArm, confirmArmed, sendMutation]);
-
- // ── Reset on close ───────────────────────────────────────────
-
- const handleClose = useCallback(() => {
- setRecipient(null);
- setUsdAmount(5);
- setError('');
- setFeeSpeed('halfHour');
- setSuccess(null);
- setConfirmArmed(false);
- setAcknowledgedPublic(false);
- setEditingAmount(false);
- feeSpeedUserChanged.current = false;
- onClose();
- }, [onClose]);
-
- // ── Render ───────────────────────────────────────────────────
-
- const currentUsd = typeof usdAmount === 'string' ? parseFloat(usdAmount) : usdAmount;
- const hasValidAmount = Number.isFinite(currentUsd) && currentUsd > 0;
- const totalUsdString = btcPrice ? satsToUSD(totalSats, btcPrice) : '';
- const uniqueFeeSpeeds = useMemo(() => getUniqueFeeSpeeds(feeRates), [feeRates]);
- const isPending = sendMutation.isPending;
-
- // ── Unsupported signer ───────────────────────────────────────
-
- if (isOpen && !canSignPsbt) {
- return (
-
-
-
-
- Sending Not Available
-
-
-
-
- Your login doesn't support sending Bitcoin. Log in with your secret key to send.
-
-
- Close
-
-
- );
- }
-
- return (
-
-
-
-
- {success ? 'Success' : 'Send Bitcoin'}
-
-
-
-
-
-
-
- {success ? (
- success.recipientPubkey ? (
-
- ) : (
-
- )
- ) : (
-
- {/* Recipient picker */}
-
{ setRecipient(v); setError(''); }}
- />
-
- {/* Big editable USD amount */}
-
- {editingAmount ? (
-
- $
- { setUsdAmount(e.target.value); setError(''); }}
- onBlur={commitAmountEdit}
- onKeyDown={(e) => {
- if (e.key === 'Enter') { e.preventDefault(); commitAmountEdit(); }
- }}
- aria-label="Amount in USD"
- className={`bg-transparent border-0 outline-none text-4xl font-semibold text-center [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none ${insufficient ? 'text-destructive' : ''}`}
- style={{ width: `${Math.max(2, String(usdAmount).length + 1)}ch` }}
- />
-
- ) : (
-
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"
- >
- $
-
- {hasValidAmount ? currentUsd : 0}
-
-
- )}
-
-
- {/* Preset chips */}
- { if (v) { setUsdAmount(Number(v)); setError(''); setEditingAmount(false); } }}
- className="grid grid-cols-5 gap-1 w-full"
- >
- {USD_PRESETS.map((v) => (
-
- ${v}
-
- ))}
-
-
- {/* Error */}
- {error && (
- {error}
- )}
-
- {/* Privacy warning for raw Bitcoin addresses */}
- {isRawAddress && (
- {
- setAcknowledgedPublic(checked);
- setError('');
- // Re-arming required after toggling the acknowledgement.
- setConfirmArmed(false);
- }}
- />
- )}
-
- {/* Send button */}
-
- {isPending ? (
- <>
-
- {progressLabel(progress)}
- >
- ) : insufficient ? (
- <>Not enough Bitcoin>
- ) : requiresArm && confirmArmed ? (
- <>Tap again to send {totalUsdString}>
- ) : (
- <>Send {totalUsdString || (hasValidAmount ? `$${currentUsd}` : '')}>
- )}
-
-
- {/* Fee line / picker */}
- {amountSats > 0 && (
-
-
-
-
-
- Fee{' '}
- {estimatedFeeSats > 0 && btcPrice
- ? `≈ ${satsToUSD(estimatedFeeSats, btcPrice)}`
- : '…'}
- · {FEE_SPEED_LABELS[feeSpeed]}
-
-
-
-
-
- {uniqueFeeSpeeds.map((speed) => {
- const rate = feeRates ? getRateForSpeed(feeRates, speed) : 0;
- const selected = speed === feeSpeed;
- return (
- handleFeeSpeedChange(speed)}
- className={cn(
- 'flex items-center justify-between px-2 py-1.5 rounded-sm text-xs text-left hover:bg-muted transition-colors',
- selected && 'bg-muted font-medium',
- )}
- >
- {FEE_SPEED_LABELS[speed]}
- {rate} sat/vB
-
- );
- })}
-
-
-
-
- {showBalance && !insufficient && btcPrice && (
-
- Balance: {satsToUSD(totalBalance, btcPrice)}
-
- )}
-
- )}
-
- )}
-
-
-
- );
-}
-
-function progressLabel(progress: 'idle' | 'building' | 'signing' | 'broadcasting' | 'publishing'): string {
- switch (progress) {
- case 'building': return 'Building…';
- case 'signing': return 'Signing…';
- case 'broadcasting': return 'Broadcasting…';
- case 'publishing': return 'Publishing…';
- default: return 'Sending…';
- }
-}
-
-// ═══════════════════════════════════════════════════════════════════
-// Recipient picker
-// ═══════════════════════════════════════════════════════════════════
-
-interface RecipientPickerProps {
- value: ResolvedRecipient | null;
- onChange: (value: ResolvedRecipient | null) => void;
-}
-
-/**
- * Combobox that lets the user pick a Nostr profile from autocomplete, paste a
- * Nostr identifier (npub/nprofile/nip05/hex), or type a raw Bitcoin address.
- *
- * The dropdown intentionally only surfaces profile-shaped suggestions — no
- * Wikipedia / Internet Archive / country / nav-item rows. That keeps the
- * picker focused: every selection is something the user can actually be paid.
- */
-function RecipientPicker({ value, onChange }: RecipientPickerProps) {
- const [query, setQuery] = useState('');
- const [open, setOpen] = useState(false);
- const [selectedIndex, setSelectedIndex] = useState(-1);
- const containerRef = useRef(null);
- const inputRef = useRef(null);
-
- const { data: profiles, isFetching, followedPubkeys } = useSearchProfiles(query);
-
- const identifierMatch = useMemo(() => {
- const m = detectIdentifier(query);
- if (!m) return null;
- // Only pubkey-resolvable identifiers belong in this picker.
- switch (m.type) {
- case 'npub':
- case 'nprofile':
- case 'nip05':
- case 'hex':
- return m;
- default:
- return null;
- }
- }, [query]);
-
- // Raw bitcoin address fallback — only when nothing else matches.
- const trimmed = query.trim();
- const looksLikeBtcAddress = !identifierMatch
- && trimmed.length > 0
- && !profiles?.length
- && validateBitcoinAddress(trimmed);
-
- // Deduplicate: if the identifier resolves to a pubkey that's also in the
- // profile results, drop it from the profile list.
- const identifierPubkey = useMemo(() => {
- if (!identifierMatch) return undefined;
- if (identifierMatch.type === 'npub' || identifierMatch.type === 'nprofile') return identifierMatch.pubkey;
- if (identifierMatch.type === 'hex') return identifierMatch.hex;
- return undefined; // nip05 resolves async; handled by IdentifierRow
- }, [identifierMatch]);
-
- const filteredProfiles = useMemo(() => {
- if (!profiles || !identifierPubkey) return profiles ?? [];
- return profiles.filter((p) => p.pubkey !== identifierPubkey);
- }, [profiles, identifierPubkey]);
-
- const hasIdentifier = !!identifierMatch;
- const hasBtcAddress = !!looksLikeBtcAddress;
- const profileCount = filteredProfiles.length;
- const totalItems = (hasIdentifier ? 1 : 0) + profileCount + (hasBtcAddress ? 1 : 0);
-
- // Open the dropdown whenever we have any suggestion or a non-empty query.
- useEffect(() => {
- if (trimmed.length === 0) {
- setOpen(false);
- return;
- }
- if (hasIdentifier || hasBtcAddress || profileCount > 0 || isFetching) {
- setOpen(true);
- }
- }, [trimmed, hasIdentifier, hasBtcAddress, profileCount, isFetching]);
-
- useEffect(() => {
- setSelectedIndex(-1);
- }, [profiles, identifierMatch, looksLikeBtcAddress]);
-
- // Close on outside click
- useEffect(() => {
- function handler(e: MouseEvent) {
- if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
- setOpen(false);
- }
- }
- document.addEventListener('mousedown', handler);
- return () => document.removeEventListener('mousedown', handler);
- }, []);
-
- const selectProfile = useCallback((profile: SearchProfile) => {
- const address = nostrPubkeyToBitcoinAddress(profile.pubkey);
- if (!address) return;
- onChange({
- address,
- pubkey: profile.pubkey,
- profile,
- raw: query,
- });
- setQuery('');
- setOpen(false);
- inputRef.current?.blur();
- }, [onChange, query]);
-
- const selectPubkey = useCallback((pubkey: string, raw: string) => {
- if (!isNostrId(pubkey)) return;
- const address = nostrPubkeyToBitcoinAddress(pubkey);
- if (!address) return;
- onChange({ address, pubkey, raw });
- setQuery('');
- setOpen(false);
- inputRef.current?.blur();
- }, [onChange]);
-
- const selectBtcAddress = useCallback((address: string) => {
- onChange({ address, raw: address });
- setQuery('');
- setOpen(false);
- inputRef.current?.blur();
- }, [onChange]);
-
- const handleKeyDown = (e: React.KeyboardEvent) => {
- if (e.key === 'Escape') {
- e.preventDefault();
- setOpen(false);
- inputRef.current?.blur();
- return;
- }
-
- if (e.key === 'Enter') {
- e.preventDefault();
- if (open && selectedIndex >= 0 && selectedIndex < totalItems) {
- // Order: [identifier?, ...profiles, btcAddress?]
- let idx = selectedIndex;
- if (hasIdentifier) {
- if (idx === 0) {
- // IdentifierRow handles its own selection via DOM click — the
- // selectedPubkey may need NIP-05 resolution.
- const items = containerRef.current?.querySelectorAll('[data-recipient-item]');
- (items?.[selectedIndex] as HTMLElement | undefined)?.click();
- return;
- }
- idx -= 1;
- }
- if (idx < profileCount) {
- selectProfile(filteredProfiles[idx]);
- return;
- }
- idx -= profileCount;
- if (hasBtcAddress && idx === 0) {
- selectBtcAddress(trimmed);
- return;
- }
- }
- return;
- }
-
- if (!open || totalItems === 0) return;
-
- if (e.key === 'ArrowDown') {
- e.preventDefault();
- setSelectedIndex((prev) => (prev < totalItems - 1 ? prev + 1 : 0));
- } else if (e.key === 'ArrowUp') {
- e.preventDefault();
- setSelectedIndex((prev) => (prev > 0 ? prev - 1 : totalItems - 1));
- }
- };
-
- // ── Selected-chip view ─────────────────────────────────────
-
- if (value) {
- return (
- onChange(null)} />
- );
- }
-
- // ── Input + dropdown ───────────────────────────────────────
-
- return (
-
-
setQuery(e.target.value)}
- onFocus={() => { if (trimmed.length > 0) setOpen(true); }}
- onKeyDown={handleKeyDown}
- placeholder="Search people, paste npub, or enter a Bitcoin address"
- autoComplete="off"
- role="combobox"
- aria-expanded={open}
- aria-haspopup="listbox"
- aria-autocomplete="list"
- className="rounded-full"
- />
-
- {open && totalItems > 0 && (
-
-
- {hasIdentifier && (
-
- )}
- {filteredProfiles.map((profile, i) => (
-
- ))}
- {hasBtcAddress && (
-
- )}
-
-
- )}
-
- {/* Empty state */}
- {open && trimmed.length > 0 && !isFetching && totalItems === 0 && (
-
-
- No matches. Paste an npub or a Bitcoin address.
-
-
- )}
-
- );
-}
-
-// ── Selected recipient chip ───────────────────────────────────
-
-function SelectedRecipientChip({
- value,
- onClear,
-}: {
- value: ResolvedRecipient;
- onClear: () => void;
-}) {
- const { pubkey, profile, address } = value;
- // Author lookup only when we have a pubkey but no inline profile.
- const author = useAuthor(profile ? undefined : pubkey);
- const metadata = profile?.metadata ?? author.data?.metadata;
- const tags = profile?.event.tags ?? author.data?.event?.tags ?? [];
-
- const displayName = pubkey
- ? metadata?.name || metadata?.display_name || genUserName(pubkey)
- : 'Bitcoin address';
-
- const subtitle = pubkey
- ? metadata?.nip05 ?? nip19.npubEncode(pubkey)
- : `${address.slice(0, 12)}…${address.slice(-8)}`;
-
- return (
-
- {pubkey ? (
-
-
-
- {displayName[0]?.toUpperCase() || '?'}
-
-
- ) : (
-
-
-
- )}
-
-
To
-
- {pubkey ? {displayName} : displayName}
-
-
- {subtitle}
-
-
-
-
-
-
- );
-}
-
-// ── Profile dropdown row ──────────────────────────────────────
-
-function ProfileRow({
- profile,
- isFollowed,
- isSelected,
- onClick,
-}: {
- profile: SearchProfile;
- isFollowed: boolean;
- isSelected: boolean;
- onClick: (profile: SearchProfile) => void;
-}) {
- const { metadata, pubkey } = profile;
- const displayName = metadata.name || metadata.display_name || genUserName(pubkey);
- const subtitle = metadata.nip05 ?? nip19.npubEncode(pubkey);
-
- return (
- onClick(profile)}
- onMouseDown={(e) => e.preventDefault()}
- className={cn(
- 'w-full flex items-center gap-3 px-3 py-2 text-left transition-colors cursor-pointer',
- isSelected ? 'bg-accent text-accent-foreground' : 'hover:bg-secondary/60',
- )}
- >
-
-
-
-
- {displayName[0]?.toUpperCase() || '?'}
-
-
- {isFollowed && (
-
-
-
- )}
-
-
-
- {displayName}
-
-
- {subtitle}
-
-
-
- );
-}
-
-// ── Identifier (npub / nprofile / nip05 / hex) dropdown row ───
-
-function IdentifierRow({
- match,
- isSelected,
- onSelectPubkey,
-}: {
- match: IdentifierMatch;
- isSelected: boolean;
- onSelectPubkey: (pubkey: string, raw: string) => void;
-}) {
- // Resolve nip05 → pubkey asynchronously. For other types we already have
- // the pubkey inline.
- const nip05Id = match.type === 'nip05' ? match.identifier : undefined;
- const { data: nip05Pubkey, isLoading: isResolvingNip05 } = useNip05Resolve(nip05Id);
-
- const pubkey = match.type === 'npub' || match.type === 'nprofile'
- ? match.pubkey
- : match.type === 'hex'
- ? match.hex
- : nip05Pubkey ?? undefined;
-
- const author = useAuthor(pubkey);
- const metadata = author.data?.metadata;
- const displayName = pubkey
- ? metadata?.name || metadata?.display_name || genUserName(pubkey)
- : match.type === 'nip05' ? match.identifier : '';
-
- const subtitle = match.type === 'nip05'
- ? match.identifier
- : pubkey
- ? metadata?.nip05 ?? `${pubkey.slice(0, 8)}…${pubkey.slice(-4)}`
- : '';
-
- const handleClick = useCallback(() => {
- if (!pubkey) return;
- const raw = match.type === 'nip05' ? match.identifier
- : match.type === 'hex' ? match.hex
- : match.raw;
- onSelectPubkey(pubkey, raw);
- }, [pubkey, match, onSelectPubkey]);
-
- if (isResolvingNip05) {
- return (
-
- );
- }
-
- if (!pubkey) {
- // nip05 didn't resolve — drop the row entirely
- return null;
- }
-
- return (
- e.preventDefault()}
- className={cn(
- 'w-full flex items-center gap-3 px-3 py-2 text-left transition-colors cursor-pointer',
- isSelected ? 'bg-accent text-accent-foreground' : 'hover:bg-secondary/60',
- )}
- >
-
-
-
- {displayName[0]?.toUpperCase() || '?'}
-
-
-
-
- {author.isLoading ? (
- Loading profile…
- ) : (
- {displayName}
- )}
-
-
- {subtitle}
-
-
-
- );
-}
-
-// ── Raw bitcoin address dropdown row ──────────────────────────
-
-function BtcAddressRow({
- address,
- isSelected,
- onClick,
-}: {
- address: string;
- isSelected: boolean;
- onClick: (address: string) => void;
-}) {
- return (
- onClick(address)}
- onMouseDown={(e) => e.preventDefault()}
- className={cn(
- 'w-full flex items-center gap-3 px-3 py-2 text-left transition-colors cursor-pointer',
- isSelected ? 'bg-accent text-accent-foreground' : 'hover:bg-secondary/60',
- )}
- >
-
-
-
-
-
Send to Bitcoin address
-
- {address.length > 28 ? `${address.slice(0, 14)}…${address.slice(-10)}` : address}
-
-
-
- );
-}
-
-// ═══════════════════════════════════════════════════════════════════
-// Success view for raw-address sends (no Nostr identity)
-// ═══════════════════════════════════════════════════════════════════
-
-interface RawAddressSuccessProps {
- txid: string;
- amountSats: number;
- btcPrice: number | undefined;
- onClose: () => void;
-}
-
-/**
- * Lighter-weight success screen for raw-address sends. No avatar / recipient
- * card because the user typed a bare Bitcoin address — we have no Nostr
- * identity to attribute the send to.
- */
-function RawAddressSuccess({ txid, amountSats, btcPrice, onClose }: RawAddressSuccessProps) {
- const usdDisplay = btcPrice ? satsToUSD(amountSats, btcPrice) : '';
-
- return (
-
-
-
-
-
-
-
-
-
-
-
Bitcoin sent
-
- {usdDisplay || `${amountSats.toLocaleString()} sats`}
-
-
-
-
-
-
-
- View transaction
-
-
-
- Done
-
-
-
- );
-}
diff --git a/src/components/SubHeaderBar.tsx b/src/components/SubHeaderBar.tsx
index 963c7bee..57444602 100644
--- a/src/components/SubHeaderBar.tsx
+++ b/src/components/SubHeaderBar.tsx
@@ -16,6 +16,8 @@ interface SubHeaderBarProps {
className?: string;
/** Extra classes on the inner flex container holding the tabs. */
innerClassName?: string;
+ /** Extra classes on the SVG background fill. */
+ backgroundFillClassName?: string;
/** Replace the decorative arc with a plain rectangle. */
noArc?: boolean;
/** Keep the bar visible when the mobile top bar hides (slides to top-0 instead of off-screen). */
@@ -30,7 +32,7 @@ interface SubHeaderBarProps {
* Used by all tab bars (Feed, Search, Notifications, etc.) and the MobileTopBar
* fallback arc.
*/
-export function SubHeaderBar({ children, className, innerClassName, noArc: _noArc, pinned }: SubHeaderBarProps) {
+export function SubHeaderBar({ children, className, innerClassName, backgroundFillClassName, noArc: _noArc, pinned }: SubHeaderBarProps) {
const [hover, setHover] = useState(null);
const [active, setActive] = useState(null);
const navHidden = useNavHidden();
@@ -127,7 +129,7 @@ export function SubHeaderBar({ children, className, innerClassName, noArc: _noAr
)}
{/* Inner wrapper holds the ArcBackground and tab content. */}
-
+
{/* Per-tab hover highlight: a flat-bottomed slab clipped to the hovered tab's x-slice */}
{hover && (
(null);
@@ -87,27 +85,7 @@ export function TeamSoapboxCard({ className }: { className?: string }) {
setIsFollowingAll(true);
try {
- // 1. Fetch freshest kind 3 from relays (not cache)
- const prev = await fetchFreshEvent(nostr, { kinds: [3], authors: [user.pubkey] });
-
- // 2. Separate p-tags from non-p-tags to preserve relay hints, petnames, etc.
- const existingPTags = prev?.tags.filter(([n]) => n === 'p') ?? [];
- const nonPTags = prev?.tags.filter(([n]) => n !== 'p') ?? [];
- const existingPubkeys = new Set(existingPTags.map(([, pk]) => pk));
-
- // 3. Merge: add new pubkeys that aren't already followed
- const newPTags = pubkeys
- .filter((pk) => !existingPubkeys.has(pk))
- .map((pk) => ['p', pk]);
- const added = newPTags.length;
-
- // 4. Publish with prev for published_at preservation
- await publishEvent({
- kind: 3,
- content: prev?.content ?? '',
- tags: [...nonPTags, ...existingPTags, ...newPTags],
- prev: prev ?? undefined,
- });
+ const added = await followMany(pubkeys);
toast({
title: 'Following Team Soapbox!',
@@ -125,7 +103,7 @@ export function TeamSoapboxCard({ className }: { className?: string }) {
} finally {
setIsFollowingAll(false);
}
- }, [user, event, pubkeys, nostr, publishEvent, toast]);
+ }, [user, event, pubkeys, followMany, toast]);
if (loading) {
return ;
diff --git a/src/components/ThreadedReplyList.tsx b/src/components/ThreadedReplyList.tsx
index 9a1beb01..24818586 100644
--- a/src/components/ThreadedReplyList.tsx
+++ b/src/components/ThreadedReplyList.tsx
@@ -1,4 +1,5 @@
import type { NostrEvent } from '@nostrify/nostrify';
+import type { ReactNode } from 'react';
import { useState } from 'react';
import { NoteCard } from '@/components/NoteCard';
import { cn } from '@/lib/utils';
@@ -14,17 +15,23 @@ export interface ReplyNode {
}
/** Renders a fully threaded reply tree with collapsible deep branches. */
-export function ThreadedReplyList({ roots }: { roots: ReplyNode[] }) {
+export function ThreadedReplyList({ roots, renderItemHeader }: { roots: ReplyNode[]; renderItemHeader?: (event: NostrEvent) => ReactNode }) {
return (
-
+ // Drop the trailing border on the last comment in the list — when
+ // the surrounding page doesn't wrap us in a card, that border
+ // floats orphaned below the final note. Two selectors are needed
+ // because the last root may be either a bare
(no
+ // children) or a wrapping an
chain. `!important`
+ // overrides NoteCard's own `border-b border-border` utility.
+
{roots.map((node) => (
-
+
))}
);
}
-function ReplyThread({ node, depth, depthless }: { node: ReplyNode; depth: number; depthless?: boolean }) {
+function ReplyThread({ node, depth, depthless, renderItemHeader }: { node: ReplyNode; depth: number; depthless?: boolean; renderItemHeader?: (event: NostrEvent) => ReactNode }) {
const [expanded, setExpanded] = useState(false);
const [showHidden, setShowHidden] = useState(false);
const hasChildren = node.children.length > 0;
@@ -34,6 +41,7 @@ function ReplyThread({ node, depth, depthless }: { node: ReplyNode; depth: numbe
if (shouldCollapse) {
return (
+ {renderItemHeader?.(node.event)}
setExpanded(true)} isLast />
@@ -41,7 +49,12 @@ function ReplyThread({ node, depth, depthless }: { node: ReplyNode; depth: numbe
}
if (!hasChildren) {
- return ;
+ return (
+
+ {renderItemHeader?.(node.event)}
+
+
+ );
}
// Once expanded past the depth cap, skip further caps for this subtree
@@ -49,6 +62,7 @@ function ReplyThread({ node, depth, depthless }: { node: ReplyNode; depth: numbe
return (
+ {renderItemHeader?.(node.event)}
{/* Show hidden sibling count between parent and first child */}
{hiddenCount > 0 && !showHidden && (
@@ -56,10 +70,13 @@ function ReplyThread({ node, depth, depthless }: { node: ReplyNode; depth: numbe
)}
{/* Revealed hidden siblings render as threaded items before the inline child */}
{showHidden && node.hiddenChildren!.map((child) => (
-
+
+ {renderItemHeader?.(child.event)}
+
+
))}
{node.children.map((child) => (
-
+
))}
);
diff --git a/src/components/TopNav.tsx b/src/components/TopNav.tsx
index 9ffee0e6..0d0531ec 100644
--- a/src/components/TopNav.tsx
+++ b/src/components/TopNav.tsx
@@ -1,5 +1,5 @@
import { useState, type ComponentType } from 'react';
-import { Link, NavLink } from 'react-router-dom';
+import { Link, NavLink, useNavigate } from 'react-router-dom';
import {
Activity,
Bell,
@@ -33,8 +33,9 @@ interface NavItem {
}
const NAV_ITEMS: NavItem[] = [
- { label: 'Discover', to: '/discover', icon: HandHeart },
- { label: 'Organize', to: '/communities', icon: Users },
+ { label: 'Activity', to: '/feed', icon: Activity },
+ { label: 'Campaigns', to: '/campaigns/all', icon: HandHeart },
+ { label: 'Groups', to: '/communities', icon: Users },
{ label: 'Pledge', to: '/pledges', icon: Megaphone },
];
@@ -53,6 +54,9 @@ export function TopNav() {
const { user } = useCurrentUser();
const { orderedItems } = useFeedSettings();
const [mobileOpen, setMobileOpen] = useState(false);
+ const navigate = useNavigate();
+
+ const goToSearch = () => navigate('/search');
return (
@@ -88,6 +92,18 @@ export function TopNav() {
{/* Right cluster */}
+ {/* Search — navigates to the /search page. Visible on all
+ breakpoints so users can always reach search from the chrome. */}
+
+
+
+
{/* LoginArea handles both logged-in (account avatar dropdown) and
logged-out (Log in / Sign up) states. We render it inline-flex
and let it style its own children. */}
diff --git a/src/components/ZapDialog.tsx b/src/components/ZapDialog.tsx
index cd49d0c7..e9fb3e87 100644
--- a/src/components/ZapDialog.tsx
+++ b/src/components/ZapDialog.tsx
@@ -289,7 +289,7 @@ export function ZapDialog({ target, children, className }: ZapDialogProps) {
const { toast } = useToast();
const { webln, activeNWC } = useWallet();
const { config } = useAppContext();
- const { esploraBaseUrl } = config;
+ const { esploraApis } = config;
// Success state: populated by either zap rail's onSuccess callback.
// When set, we replace the tab UI with .
@@ -323,8 +323,8 @@ export function ZapDialog({ target, children, className }: ZapDialogProps) {
const amountInputRef = useRef(null);
const { data: btcPrice } = useQuery({
- queryKey: ['btc-price', esploraBaseUrl],
- queryFn: () => fetchBtcPrice(esploraBaseUrl),
+ queryKey: ['btc-price', esploraApis],
+ queryFn: ({ signal }) => fetchBtcPrice(esploraApis, signal),
staleTime: 30_000,
});
diff --git a/src/components/auth/AuthDialog.tsx b/src/components/auth/AuthDialog.tsx
index 87230a27..fab877a6 100644
--- a/src/components/auth/AuthDialog.tsx
+++ b/src/components/auth/AuthDialog.tsx
@@ -837,6 +837,7 @@ const NsecLoginForm: React.FC = ({
onSubmit();
}}
className="space-y-3"
+ data-nsec-allowed
>
+ {children}
+
+ );
+}
diff --git a/src/components/discovery/CommunityMiniCard.tsx b/src/components/discovery/CommunityMiniCard.tsx
index 31b8f549..c99ac3d1 100644
--- a/src/components/discovery/CommunityMiniCard.tsx
+++ b/src/components/discovery/CommunityMiniCard.tsx
@@ -2,6 +2,7 @@ import { Link } from 'react-router-dom';
import { Users } from 'lucide-react';
import { nip19 } from 'nostr-tools';
+import { CommunityModerationOverlay } from '@/components/CommunityModerationMenu';
import { Card } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { useAuthor } from '@/hooks/useAuthor';
@@ -29,6 +30,13 @@ interface CommunityMiniCardProps {
* - founder avatar + display name in a muted row.
*
* Kept narrow enough to fit ~4 cards across a desktop content column.
+ *
+ * Moderators (Team Soapbox pack members) see a kebab menu overlaid on the
+ * banner exposing the Feature / Hide actions plus a Hidden badge when the
+ * org is currently hidden. Non-moderators see no overlay — the whole
+ * moderation pipeline (including the heavy `useOrganizationModeration`
+ * query) is bypassed for them so grids of dozens of cards don't fan out
+ * a per-card cache subscription on every viewer.
*/
export function CommunityMiniCard({ community, className }: CommunityMiniCardProps) {
const founder = useAuthor(community.founderPubkey);
@@ -67,6 +75,11 @@ export function CommunityMiniCard({ community, className }: CommunityMiniCardPro
)}
+ {/* Moderator overlay (Hidden badge + kebab). Renders `null` for
+ non-moderators, which is why this component owns the
+ `useOrganizationModeration` subscription rather than the
+ card — keeps non-mod grids free of the heavy label query. */}
+
diff --git a/src/components/discovery/DiscoverHero.tsx b/src/components/discovery/DiscoverHero.tsx
deleted file mode 100644
index d00219a7..00000000
--- a/src/components/discovery/DiscoverHero.tsx
+++ /dev/null
@@ -1,355 +0,0 @@
-import { useEffect, useMemo, useState } from 'react';
-import { Link } from 'react-router-dom';
-import { Globe2, HandHeart, PlusCircle, Users } from 'lucide-react';
-
-import { HeroGlobe, type GlobeMarkerKind } from '@/components/HeroGlobe';
-import { HeroAtmosphere } from '@/components/HeroAtmosphere';
-import { Button } from '@/components/ui/button';
-import { Skeleton } from '@/components/ui/skeleton';
-import { useCampaigns } from '@/hooks/useCampaigns';
-import { useGlobalActivity } from '@/hooks/useGlobalActivity';
-import { useGlobalDonations } from '@/hooks/useGlobalDonations';
-import { useDiscoverCommunities } from '@/hooks/useDiscoverCommunities';
-import { HOPE_PALETTE } from '@/lib/hopePalette';
-import { searchCountry } from '@/lib/countries';
-import { getCoordinates } from '@/lib/coordinates';
-import { formatSatsShort } from '@/lib/formatCampaignAmount';
-import { cn } from '@/lib/utils';
-
-interface DiscoverHeroProps {
- className?: string;
-}
-
-interface GlobeMarker {
- key: string;
- lat: number;
- lng: number;
- label: string;
- kind: GlobeMarkerKind;
-}
-
-interface TickerStat {
- /** Stable React key. */
- id: string;
- /** Big number / value text. */
- value: string;
- /** Trailing label that describes what the number is. */
- label: string;
- /** Decorative leading icon. */
- icon: React.ReactNode;
-}
-
-/** Country code → lat/lng. Used to seed country-pulse markers. */
-function lookupCountryCoords(code: string) {
- const coords = getCoordinates(code);
- return coords ? { lat: coords.latitude, lng: coords.longitude } : null;
-}
-
-/**
- * Discover-page hero. The same hand-drawn `HeroGlobe` that anchors the
- * fundraising home page (`/`), but reframed: the globe is the
- * protagonist, three marker types sit on it at once — campaigns
- * (hearts), communities (rings), and country-pulse (warm dots) — and a
- * rotating stat ticker headlines what the network has done.
- *
- * Visual chrome:
- * - Slow hue drift through `HOPE_PALETTE` every ~8s (the page literally
- * pulses with hope).
- * - `HeroAtmosphere` carries the warm scrim + radial glow + sunrise rim,
- * same component the campaigns hero uses for crossfade.
- * - No background photo — Discover isn't selling any one campaign, so
- * the sphere reads against a soft secondary wash instead.
- */
-export function DiscoverHero({ className }: DiscoverHeroProps) {
- // ─── Data ──────────────────────────────────────────────────────────────
- const { data: campaigns } = useCampaigns({ limit: 60 });
- const { data: communities } = useDiscoverCommunities({ limit: 60 });
- const { data: activityByCountry } = useGlobalActivity();
- const { data: donations, isLoading: donationsLoading } = useGlobalDonations();
-
- // ─── Globe markers ─────────────────────────────────────────────────────
- // Layer three pin types. We dedupe primarily by country so the globe
- // never piles dozens of markers on top of each other — the goal is a
- // sparse, hopeful constellation, not a heatmap. Hearts win over rings
- // win over dots when the same country shows up in multiple sources.
- const markers = useMemo(() => {
- const out: GlobeMarker[] = [];
- const claimedCountries = new Set();
-
- // 1. Campaigns → hearts. Newest first; cap at 18 so they don't crowd.
- let heartCount = 0;
- for (const c of campaigns ?? []) {
- if (heartCount >= 18) break;
- if (!c.location) continue;
- const match = searchCountry(c.location);
- if (!match) continue;
- if (claimedCountries.has(match.country.code)) continue;
- const coords = getCoordinates(match.country.code);
- if (!coords) continue;
- claimedCountries.add(match.country.code);
- out.push({
- key: `campaign:${c.aTag}`,
- lat: coords.latitude,
- lng: coords.longitude,
- label: c.title,
- kind: 'campaign',
- });
- heartCount++;
- }
-
- // 2. Country-pulse dots — the trusted-stats country activity, sized
- // implicitly by the marker glyph. Cap at 28 so the back of the globe
- // doesn't bristle when it rotates into view.
- let pulseCount = 0;
- if (activityByCountry) {
- const sortedCodes = [...activityByCountry.entries()]
- .sort((a, b) => b[1] - a[1])
- .map(([code]) => code);
- for (const code of sortedCodes) {
- if (pulseCount >= 28) break;
- if (claimedCountries.has(code)) continue;
- const coords = lookupCountryCoords(code);
- if (!coords) continue;
- claimedCountries.add(code);
- out.push({
- key: `pulse:${code}`,
- lat: coords.lat,
- lng: coords.lng,
- label: `Active in ${code}`,
- kind: 'country-pulse',
- });
- pulseCount++;
- }
- }
-
- // 3. Community rings — only when we can geolocate one of the
- // moderators. Communities don't carry a location tag of their own,
- // so we use a small heuristic: spread the first N communities across
- // continents by scattering them on a stable hash. Keeps the layer
- // present without inventing coordinates we can't justify.
- //
- // To keep ourselves honest we cap this at 6 rings and never overwrite
- // a country that already has a campaign heart or pulse dot. If we
- // genuinely can't place any, we skip the layer.
- const scatter: Array<{ lat: number; lng: number }> = [
- { lat: 40.7, lng: -74.0 }, // Americas
- { lat: -23.5, lng: -46.6 }, // S. America
- { lat: 51.5, lng: -0.1 }, // Europe
- { lat: -1.3, lng: 36.8 }, // Africa
- { lat: 35.7, lng: 139.7 }, // E. Asia
- { lat: -33.9, lng: 151.2 }, // Oceania
- ];
- let ringCount = 0;
- for (const community of communities ?? []) {
- if (ringCount >= scatter.length) break;
- const slot = scatter[ringCount];
- out.push({
- key: `community:${community.aTag}`,
- lat: slot.lat,
- lng: slot.lng,
- label: community.name,
- kind: 'community',
- });
- ringCount++;
- }
-
- return out;
- }, [campaigns, communities, activityByCountry]);
-
- // ─── Hue drift ─────────────────────────────────────────────────────────
- // Cycle through the hopeful palette on a slow ~9s interval. We seed
- // HeroAtmosphere with a stable string per cycle so its crossfade logic
- // kicks in correctly between hues.
- const [hueIndex, setHueIndex] = useState(0);
- useEffect(() => {
- const id = window.setInterval(() => {
- setHueIndex((i) => (i + 1) % HOPE_PALETTE.length);
- }, 9_000);
- return () => window.clearInterval(id);
- }, []);
- const activeHue = HOPE_PALETTE[hueIndex];
- const atmosphereSeed = `discover-hue-${activeHue.name}`;
-
- // ─── Stat ticker ───────────────────────────────────────────────────────
- // Three rotating, immutable network-wide stats. We compute them
- // defensively — when the underlying query is still loading we surface
- // a small skeleton inside the ticker row instead of "0" so the page
- // doesn't lie about the network's scale.
- const stats = useMemo(() => {
- const items: TickerStat[] = [];
-
- if (donations && donations.totalSats > 0) {
- items.push({
- id: 'sats',
- value: formatSatsShort(donations.totalSats),
- label: `raised on-chain across ${donations.campaignCount.toLocaleString()} ${
- donations.campaignCount === 1 ? 'campaign' : 'campaigns'
- }`,
- icon: ,
- });
- }
- if (communities && communities.length > 0) {
- items.push({
- id: 'communities',
- value: communities.length.toLocaleString(),
- label: `${communities.length === 1 ? 'organization' : 'organizations'} gathering on Nostr`,
- icon: ,
- });
- }
- if (activityByCountry && activityByCountry.size > 0) {
- items.push({
- id: 'countries',
- value: activityByCountry.size.toLocaleString(),
- label: `${activityByCountry.size === 1 ? 'country' : 'countries'} posting today`,
- icon: ,
- });
- }
- return items;
- }, [donations, communities, activityByCountry]);
-
- // Auto-advance the ticker. Holds at the first slot until at least one
- // stat is known so the visitor doesn't see an empty pill.
- const [tickerIndex, setTickerIndex] = useState(0);
- useEffect(() => {
- if (stats.length <= 1) return;
- const id = window.setInterval(() => {
- setTickerIndex((i) => (i + 1) % stats.length);
- }, 4_000);
- return () => window.clearInterval(id);
- }, [stats.length]);
-
- const currentStat = stats[tickerIndex % Math.max(stats.length, 1)];
-
- return (
-
- {/* Atmosphere — same scrim + radial glow + sunrise rim used on
- `/`. Seeded by the active hue so the whole hero blooms together
- when the palette advances. */}
-
-
- {/* Globe — centered, dominant. Slight upward bias so the headline
- beneath has breathing room. */}
-
-
- {/* Readability scrim. Sits above the globe + atmosphere but below
- the foreground content so the headline / paragraph stay legible
- regardless of which hue the palette is currently cycling
- through. Top-down so the eye-line lands on the darkest pixels;
- we taper to transparent before the ticker pill so the CTAs and
- stat row underneath keep their warm wash. */}
-
-
- {/* Foreground content — headline above the sphere, ticker + CTAs
- below it. Uses the same `max-w-5xl` container as the rest of
- the Discover page so the hero never sprawls wider than the
- shelves beneath it. */}
-
-
-
- Discover
-
-
- The world,
- gathering.
-
-
- Campaigns, communities, and conversations from every corner of the
- globe. Backed by Bitcoin, broadcast on Nostr, owned by no one.
-
-
-
- {/* Spacer so the next block lands beneath the sphere. */}
-
-
- {/* Rotating stat ticker. The fixed min-height stops the layout
- from jumping as labels swap; the keyed inner span re-mounts on
- every change to trigger the fade-in transition. */}
-
- {currentStat ? (
-
- {currentStat.icon}
-
- {currentStat.value}
-
-
- {currentStat.label}
-
-
- ) : (
-
- {donationsLoading ? (
- <>
-
-
-
- >
- ) : (
-
- Connecting to relays…
-
- )}
-
- )}
-
-
- {/* CTAs — clean glass pills, same vocabulary as `/`. Two clear
- actions: start something (campaign creation), or browse the
- world map for inspiration. */}
-
-
-
-
- Start a campaign
-
-
-
-
-
- Browse the world
-
-
-
-
-
- );
-}
diff --git a/src/components/profile/OrganizationsAllDialog.tsx b/src/components/profile/OrganizationsAllDialog.tsx
new file mode 100644
index 00000000..41e46eb2
--- /dev/null
+++ b/src/components/profile/OrganizationsAllDialog.tsx
@@ -0,0 +1,73 @@
+import { useState, type ReactNode } from 'react';
+import { Users } from 'lucide-react';
+
+import { Badge } from '@/components/ui/badge';
+import { Button } from '@/components/ui/button';
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from '@/components/ui/dialog';
+import { ScrollArea } from '@/components/ui/scroll-area';
+import { CommunityMiniCard } from '@/components/discovery/CommunityMiniCard';
+import type { ProfileOrganization } from '@/hooks/useProfileOrganizations';
+import { cn } from '@/lib/utils';
+
+interface OrganizationsAllDialogProps {
+ /** Full list of organizations to surface in the overflow dialog. */
+ orgs: ProfileOrganization[];
+ /** Trigger element — typically a "See all N →" button. */
+ children: ReactNode;
+}
+
+/**
+ * "See all organizations" dialog used by the profile identity rail.
+ *
+ * Renders every org the profile is associated with in a scrollable
+ * 2-column grid of CommunityMiniCards with role badges. Lifted out of
+ * the (now-deleted) ProfileOrganizationsStrip so the rail can use it
+ * directly without pulling in the strip's full file.
+ */
+export function OrganizationsAllDialog({ orgs, children }: OrganizationsAllDialogProps) {
+ const [open, setOpen] = useState(false);
+
+ return (
+
+ {children}
+
+
+
+
+ All organizations
+ ({orgs.length})
+
+
+
+
+ {orgs.map((entry) => (
+
+
+
+ {entry.isFounder ? 'Founder' : 'Moderator'}
+
+
+ ))}
+
+
+
+ setOpen(false)}>
+ Close
+
+
+
+
+ );
+}
diff --git a/src/components/profile/ProfileActivityTab.tsx b/src/components/profile/ProfileActivityTab.tsx
new file mode 100644
index 00000000..0baa0807
--- /dev/null
+++ b/src/components/profile/ProfileActivityTab.tsx
@@ -0,0 +1,99 @@
+import { useEffect } from 'react';
+import { useInView } from 'react-intersection-observer';
+import { Loader2, Sparkles } from 'lucide-react';
+
+import { Card } from '@/components/ui/card';
+import { Skeleton } from '@/components/ui/skeleton';
+import { NoteCard } from '@/components/NoteCard';
+import { useAgoraFeed } from '@/hooks/useAgoraFeed';
+
+interface ProfileActivityTabProps {
+ pubkey: string;
+ displayName: string;
+}
+
+/**
+ * Unified profile feed scoped to one author.
+ *
+ * Pipes {@link useAgoraFeed} through with `authors=[pubkey]` AND
+ * `includeAuthorNotes: true`, so the relay-side filter pulls in:
+ *
+ * - Agora-marked content (campaigns, pledges, communities, marked notes,
+ * Agora-rooted comments, donation receipts), and
+ * - every kind 1 / 6 note this author has published, regardless of the
+ * `t:agora` marker.
+ *
+ * The two sources merge into a single chronological timeline so the
+ * profile shows "everything this person has done on the network." Replaces
+ * the previous separate Activity + Posts tabs.
+ *
+ * Single-column inside the tab area because the timeline is mixed-kind
+ * and benefits from full-width cards.
+ */
+export function ProfileActivityTab({ pubkey, displayName }: ProfileActivityTabProps) {
+ const {
+ events,
+ isLoading,
+ isFetchingNextPage,
+ hasNextPage,
+ fetchNextPage,
+ } = useAgoraFeed(true, { authors: [pubkey], includeAuthorNotes: true });
+
+ const { ref: scrollRef, inView } = useInView({ threshold: 0 });
+
+ useEffect(() => {
+ if (inView && hasNextPage && !isFetchingNextPage) {
+ fetchNextPage();
+ }
+ }, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]);
+
+ if (isLoading && events.length === 0) {
+ return (
+
+ {Array.from({ length: 4 }).map((_, i) => (
+
+
+
+
+
+
+
+
+ ))}
+
+ );
+ }
+
+ if (events.length === 0) {
+ return (
+
+
+
+
+
+ No activity from {displayName} yet. Posts, campaigns, pledges,
+ and donations all show up here.
+
+
+
+
+ );
+ }
+
+ return (
+
+
+ {events.map((event) => (
+
+ ))}
+
+ {hasNextPage && (
+
+ {isFetchingNextPage && (
+
+ )}
+
+ )}
+
+ );
+}
diff --git a/src/components/profile/ProfileCampaignsTab.tsx b/src/components/profile/ProfileCampaignsTab.tsx
new file mode 100644
index 00000000..cc27f1fe
--- /dev/null
+++ b/src/components/profile/ProfileCampaignsTab.tsx
@@ -0,0 +1,184 @@
+import { useMemo, useState } from 'react';
+import { Link } from 'react-router-dom';
+import { Megaphone } from 'lucide-react';
+import { useQueries } from '@tanstack/react-query';
+
+import { CampaignCard, CampaignCardSkeleton } from '@/components/CampaignCard';
+import { Button } from '@/components/ui/button';
+import { Card } from '@/components/ui/card';
+import { useCampaignModeration } from '@/hooks/useCampaignModeration';
+import { useCampaignModerators } from '@/hooks/useCampaignModerators';
+import { useCurrentUser } from '@/hooks/useCurrentUser';
+import { useAppContext } from '@/hooks/useAppContext';
+import { fetchAddressData } from '@/lib/bitcoin';
+import type { ParsedCampaign } from '@/lib/campaign';
+
+interface ProfileCampaignsTabProps {
+ pubkey: string;
+ displayName: string;
+ isOwnProfile: boolean;
+ campaigns: ParsedCampaign[];
+ isLoading: boolean;
+}
+
+type SortMode = 'top' | 'new';
+
+/**
+ * Full grid of every campaign authored by this profile.
+ *
+ * Owner / moderator can toggle "Show hidden" to see campaigns the
+ * moderation pack has hidden from the home page — visitors only see
+ * non-hidden campaigns by default. Sort modes mirror
+ * {@link AllCampaignsPage}: New (newest created_at first, the default
+ * incoming order) and Top (most sats raised, requires the verified
+ * donation totals).
+ */
+export function ProfileCampaignsTab({
+ pubkey,
+ displayName,
+ isOwnProfile,
+ campaigns,
+ isLoading,
+}: ProfileCampaignsTabProps) {
+ const { user } = useCurrentUser();
+ const { data: moderation } = useCampaignModeration();
+ const { data: moderators } = useCampaignModerators();
+ const isModerator = !!user && (moderators ?? []).includes(user.pubkey);
+
+ const [sortMode, setSortMode] = useState('new');
+ const [showHidden, setShowHidden] = useState(false);
+
+ const canShowHidden = isOwnProfile || isModerator;
+
+ const filtered = useMemo(() => {
+ if (canShowHidden && showHidden) return campaigns;
+ return campaigns.filter((c) => !moderation.hiddenCoords.has(c.aTag));
+ }, [campaigns, canShowHidden, showHidden, moderation.hiddenCoords]);
+
+ if (isLoading && filtered.length === 0) {
+ return (
+
+
+ {Array.from({ length: 3 }).map((_, i) => (
+
+ ))}
+
+
+ );
+ }
+
+ if (filtered.length === 0) {
+ return (
+
+
+
+
+
+ {isOwnProfile
+ ? "You haven't launched a campaign yet."
+ : `${displayName} hasn't launched a campaign yet.`}
+
+ {isOwnProfile && (
+
+ Start a campaign →
+
+ )}
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ {filtered.length} {filtered.length === 1 ? 'campaign' : 'campaigns'}
+
+
+ setSortMode('new')}
+ >
+ New
+
+ setSortMode('top')}
+ >
+ Top
+
+ {canShowHidden && (
+ setShowHidden((v) => !v)}
+ >
+ {showHidden ? 'Hide hidden' : 'Show hidden'}
+
+ )}
+
+
+
+ {sortMode === 'top' ? (
+
+ ) : (
+
+ {filtered.map((c) => (
+
+ ))}
+
+ )}
+
+ );
+}
+
+/**
+ * Sorts the visible campaigns by sats raised (descending) by fanning
+ * out one address-balance query per on-chain campaign. Uses `useQueries`,
+ * so the hook call count is deterministic per render (one queries-tuple,
+ * not one hook per campaign) and the rules of hooks hold.
+ *
+ * Caches share keys with `useCampaignDonations` so the balance results
+ * are reused across the profile and any other view of the same campaign.
+ */
+function SortedByTopGrid({ campaigns }: { campaigns: ParsedCampaign[] }) {
+ const { config } = useAppContext();
+ const { esploraApis } = config;
+
+ // Only on-chain campaigns can have observable totals. SP campaigns sort to 0.
+ const onchain = campaigns.filter((c) => c.wallet?.mode === 'onchain');
+
+ const balanceQueries = useQueries({
+ queries: onchain.map((campaign) => ({
+ queryKey: ['bitcoin-balance', 'campaign', esploraApis, campaign.wallet?.value ?? ''],
+ queryFn: ({ signal }: { signal: AbortSignal }) =>
+ fetchAddressData(campaign.wallet!.value, esploraApis, signal),
+ staleTime: 30_000,
+ enabled: !!campaign.wallet?.value,
+ })),
+ });
+
+ const totalsByCoord = new Map();
+ for (let i = 0; i < onchain.length; i++) {
+ const sats = balanceQueries[i]?.data?.totalReceived ?? 0;
+ totalsByCoord.set(onchain[i].aTag, sats);
+ }
+
+ const sorted = [...campaigns].sort(
+ (a, b) => (totalsByCoord.get(b.aTag) ?? 0) - (totalsByCoord.get(a.aTag) ?? 0),
+ );
+
+ return (
+
+ {sorted.map((campaign) => (
+
+ ))}
+
+ );
+}
diff --git a/src/components/profile/ProfileIdentityRail.tsx b/src/components/profile/ProfileIdentityRail.tsx
new file mode 100644
index 00000000..c359b7a9
--- /dev/null
+++ b/src/components/profile/ProfileIdentityRail.tsx
@@ -0,0 +1,991 @@
+import type { ReactNode } from 'react';
+import { Link, Link as RouterLink } from 'react-router-dom';
+import {
+ Bitcoin,
+ CalendarClock,
+ Globe,
+ HandHeart,
+ Megaphone,
+ MoreHorizontal,
+ QrCode,
+ Users,
+} from 'lucide-react';
+import { nip19 } from 'nostr-tools';
+import type { NostrEvent, NostrMetadata } from '@nostrify/nostrify';
+
+import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
+import { Badge } from '@/components/ui/badge';
+import { Button } from '@/components/ui/button';
+import { Card } from '@/components/ui/card';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu';
+import { Skeleton } from '@/components/ui/skeleton';
+import { BioContent } from '@/components/BioContent';
+import { CampaignCard, CampaignCardSkeleton } from '@/components/CampaignCard';
+import { CommunityMiniCard, CommunityMiniCardSkeleton } from '@/components/discovery/CommunityMiniCard';
+import { EmojifiedText } from '@/components/CustomEmoji';
+import { FollowToggleButton } from '@/components/FollowButton';
+import { Nip05Badge } from '@/components/Nip05Badge';
+import { ProfileReactionButton } from '@/components/ProfileReactionButton';
+import { OrganizationsAllDialog } from '@/components/profile/OrganizationsAllDialog';
+import { useCampaignModeration } from '@/hooks/useCampaignModeration';
+import { useProfileOrganizations, type ProfileOrganization } from '@/hooks/useProfileOrganizations';
+import type { ProfileCampaignStats } from '@/hooks/useProfileCampaignStats';
+import type { ParsedCampaign } from '@/lib/campaign';
+import { formatCampaignAmount } from '@/lib/formatCampaignAmount';
+import { formatNumber } from '@/lib/formatNumber';
+import { sanitizeUrl } from '@/lib/sanitizeUrl';
+import { cn } from '@/lib/utils';
+import type { Action } from '@/hooks/useActions';
+import { DEFAULT_COVER_IMAGE } from '@/lib/defaultActionCovers';
+import { formatCompactPledgeDeadline, formatPledgeAmount } from '@/lib/pledges';
+import { getGeoDisplayName } from '@/lib/countries';
+
+export interface ProfileIdentityRailProps {
+ pubkey: string;
+ /** Whether the logged-in user is viewing their own profile. */
+ isOwnProfile: boolean;
+ /** Resolved kind 0 metadata, if any. */
+ metadata: NostrMetadata | undefined;
+ /** Raw kind 0 event — needed for emoji tag rendering on display name. */
+ metadataEvent: NostrEvent | undefined;
+ /** Pre-resolved display name (with `genUserName` fallback applied upstream). */
+ displayName: string;
+ /** True while the kind-0 author query is still in flight; renders skeletons. */
+ isAuthorLoading: boolean;
+
+ /** Banner image URL — used to wire the avatar lightbox to the same url. */
+ bannerUrl: string | undefined;
+ /** Optional NIP-38 status (renders as a thought bubble next to the avatar). */
+ status?: { text: string | undefined; url: string | undefined };
+
+ /** Custom kind-0 profile fields, already parsed. */
+ fields: { label: string; value: string }[];
+ /** Pre-rendered list of nodes — keeps that helper inside ProfilePage. */
+ fieldsContent: ReactNode;
+
+ /** Campaigns authored by this profile (newest-first). */
+ campaigns: ParsedCampaign[];
+ /** Aggregated campaign + raised stats for the stat block. */
+ campaignStats: ProfileCampaignStats;
+ /**
+ * The profile's pledges (kind 36639) — used to surface the latest one
+ * in the rail when the profile has no campaigns. The rail picks the
+ * newest by `createdAt` itself, so callers can pass the unsorted list.
+ */
+ pledges: Action[];
+ /** Spot BTC price for the Raised stat row. */
+ btcPrice: number | undefined;
+
+ followersCount: number;
+ followingCount: number;
+ isFollowing: boolean;
+ followPending: boolean;
+
+ onLightbox: (url: string) => void;
+ onFollowersOpen: () => void;
+ onFollowingOpen: () => void;
+ onMoreMenuOpen: () => void;
+ onFollowQROpen: () => void;
+ onToggleFollow: () => void;
+ onTabChange: (tabId: string) => void;
+ onDonate: (campaign: ParsedCampaign) => void;
+ /** Whether the viewer can take any action (logged in). Disables follow when null. */
+ canFollow: boolean;
+ /** Latest kind-0 event used by ProfileReactionButton; falls back to metadataEvent. */
+ authorEvent: NostrEvent | undefined;
+}
+
+const RAIL_CAMPAIGN_LIMIT = 2;
+const RAIL_ORG_LIMIT = 4;
+
+/**
+ * ProfileIdentityRail — the left rail of the two-column profile.
+ *
+ * Holds everything that's a *standing fact* about the profile: who they
+ * are (avatar, name, bio), what they're raising for (active campaigns),
+ * who they organize with (orgs), key counts (followers / following /
+ * campaigns / pledges / raised), and the freeform profile fields.
+ *
+ * Sticky on `lg+` so it stays visible while the right tab column scrolls.
+ * Below `lg` the rail just stacks above the content — its avatar still
+ * overlaps the banner via `-mt-16` because the rail is the first child
+ * below the banner element.
+ *
+ * The rail does NOT own the tab bar or the tab content — those live in
+ * the right column. Click handlers like `onTabChange` exist so rail rows
+ * can switch tabs (e.g. "See all campaigns →" jumps to the Campaigns tab).
+ */
+export function ProfileIdentityRail({
+ pubkey,
+ isOwnProfile,
+ metadata,
+ metadataEvent,
+ displayName,
+ isAuthorLoading,
+ bannerUrl: _bannerUrl,
+ status,
+ fields,
+ fieldsContent,
+ campaigns,
+ campaignStats,
+ pledges,
+ btcPrice,
+ followersCount,
+ followingCount,
+ isFollowing,
+ followPending,
+ onLightbox,
+ onFollowersOpen,
+ onFollowingOpen,
+ onMoreMenuOpen,
+ onFollowQROpen,
+ onToggleFollow,
+ onTabChange,
+ onDonate,
+ canFollow,
+ authorEvent,
+}: ProfileIdentityRailProps) {
+ if (isAuthorLoading) {
+ return (
+
+ );
+ }
+
+ const websiteHref = (() => {
+ if (!metadata?.website) return undefined;
+ const candidate = metadata.website.startsWith('http')
+ ? metadata.website
+ : `https://${metadata.website}`;
+ return sanitizeUrl(candidate);
+ })();
+
+ const onchainCampaigns = campaigns.filter((c) => c.wallet?.mode === 'onchain');
+
+ return (
+ // Two-layer structure so the rail can scroll independently on lg+
+ // without clipping the avatar that pokes above the rail's top edge:
+ // - Outer flex column owns the avatar (which uses -mt-16 to overlap
+ // the banner). It must NOT clip overflow.
+ // - Inner div carries the rest of the rail and is the scroll
+ // container: `lg:flex-1 lg:min-h-0 lg:overflow-y-auto` makes it
+ // fill the remaining height of the sticky aside and scroll
+ // internally so the page's main scroll only drives the feed.
+
+ {/* Avatar — overlaps the banner from inside the rail. Sits OUTSIDE
+ the scroll container so its negative-margin overhang is never
+ clipped by `overflow-y-auto`. */}
+
+
+
+
+ );
+}
+
+// ─── Identity header (name / bio / actions / stats) ─────────────────────────
+
+export interface ProfileIdentityHeaderProps {
+ pubkey: string;
+ isOwnProfile: boolean;
+ metadata: NostrMetadata | undefined;
+ metadataEvent: NostrEvent | undefined;
+ displayName: string;
+ /** Pre-sanitized website URL (`undefined` if none / unsafe). */
+ websiteHref: string | undefined;
+ isFollowing: boolean;
+ followPending: boolean;
+ canFollow: boolean;
+ followersCount: number;
+ followingCount: number;
+ totalRaisedSats: number;
+ btcPrice: number | undefined;
+ onchainCampaigns: ParsedCampaign[];
+ onToggleFollow: () => void;
+ onMoreMenuOpen: () => void;
+ onFollowQROpen: () => void;
+ onDonate: (campaign: ParsedCampaign) => void;
+ onFollowersOpen: () => void;
+ onFollowingOpen: () => void;
+ onTabChange: (tabId: string) => void;
+ authorEvent: NostrEvent | undefined;
+ className?: string;
+}
+
+/**
+ * The fixed identity block: name, NIP-05, website, bio, action bar, and
+ * top-level stat row (Followers / Following / Raised).
+ *
+ * Rendered inside `ProfileIdentityRail` on desktop and directly above the
+ * tab bar on mobile. Does NOT include the avatar — that lives outside any
+ * scroll container so its `-mt-16` overhang into the banner isn't clipped.
+ */
+export function ProfileIdentityHeader({
+ pubkey,
+ isOwnProfile,
+ metadata,
+ metadataEvent,
+ displayName,
+ websiteHref,
+ isFollowing,
+ followPending,
+ canFollow,
+ followersCount,
+ followingCount,
+ totalRaisedSats,
+ btcPrice,
+ onchainCampaigns,
+ onToggleFollow,
+ onMoreMenuOpen,
+ onFollowQROpen,
+ onDonate,
+ onFollowersOpen,
+ onFollowingOpen,
+ onTabChange,
+ authorEvent,
+ className,
+}: ProfileIdentityHeaderProps) {
+ return (
+
+ {/* Identity: name + NIP-05 + website + bio */}
+
+
+ {metadataEvent ? (
+ {displayName}
+ ) : displayName}
+
+ {metadata?.nip05 && (
+
+ )}
+ {websiteHref && (
+
+ )}
+ {metadata?.about && (
+
+ {metadata.about}
+
+ )}
+
+
+ {/* Action bar — wraps onto multiple rows in a 340px-wide rail. */}
+
+
+ {/* Stats: Followers + Following inline; Raised below if applicable. */}
+
+
+ );
+}
+
+// ─── Overview sections (campaigns / latest pledge / orgs / fields) ──────────
+
+export interface ProfileOverviewSectionsProps {
+ pubkey: string;
+ isOwnProfile: boolean;
+ campaigns: ParsedCampaign[];
+ campaignStats: ProfileCampaignStats;
+ pledges: Action[];
+ btcPrice: number | undefined;
+ fields: { label: string; value: string }[];
+ fieldsContent: ReactNode;
+ onTabChange: (tabId: string) => void;
+ /** Render the Organizations grid inline (default true). Set false on
+ * mobile when "Community" is a dedicated tab and orgs should not also
+ * appear inside Overview. */
+ showOrganizations?: boolean;
+ className?: string;
+}
+
+/**
+ * The collection of secondary rail sections: active campaigns, a fallback
+ * "latest pledge" card when there are no campaigns, organizations the
+ * profile founded/moderates, and freeform kind-0 profile fields.
+ *
+ * On desktop these stack inside the identity rail. On mobile they become
+ * the content of the "Overview" tab (with `showOrganizations={false}` so
+ * the organizations list moves into the dedicated "Community" tab).
+ */
+export function ProfileOverviewSections({
+ pubkey,
+ isOwnProfile,
+ campaigns,
+ campaignStats,
+ pledges,
+ btcPrice,
+ fields,
+ fieldsContent,
+ onTabChange,
+ showOrganizations = true,
+ className,
+}: ProfileOverviewSectionsProps) {
+ return (
+
+ {/* Profile fields (rendered upstream) — placed first so the
+ profile's own freeform metadata (links, addresses, etc.) is
+ the first thing visitors read, ahead of campaigns/orgs. */}
+ {fields.length > 0 && (
+
+ )}
+
+ {/* Active campaigns */}
+
onTabChange('campaigns')}
+ />
+
+ {/* Latest pledge — surfaced as a fallback when this profile has
+ nothing in the Campaigns slot, so the rail still has a piece of
+ first-class Agora content in the campaigns slot. */}
+ {campaigns.length === 0 && pledges.length > 0 && (
+ 1}
+ onSeeAll={() => onTabChange('pledges')}
+ />
+ )}
+
+ {/* Organizations */}
+ {showOrganizations && }
+
+ );
+}
+
+/**
+ * Standalone organizations section — same `RailOrganizationsSection`
+ * content used inside the rail's overview, but exposed as a top-level
+ * export so the mobile "Community" tab can render it directly.
+ *
+ * The rendering is identical to the rail's version (same grid, same
+ * "See all" overflow dialog). Wrapping it in its own export keeps the
+ * tab content honest about where the data is coming from and lets us
+ * swap in a richer layout later without touching ProfilePage.
+ */
+export function ProfileOrganizationsSection({ pubkey, className }: { pubkey: string; className?: string }) {
+ return (
+
+
+
+ );
+}
+
+// ─── Avatar block ────────────────────────────────────────────────────────────
+
+export interface ProfileAvatarBlockProps {
+ metadata: NostrMetadata | undefined;
+ displayName: string;
+ status: { text: string | undefined; url: string | undefined } | undefined;
+ onLightbox: (url: string) => void;
+}
+
+/**
+ * Avatar + NIP-38 status bubble. Always rendered as the first thing below
+ * the banner; the avatar uses `-mt-16 md:-mt-20` to overlap into the banner
+ * area. Must NOT be wrapped in any element with `overflow: hidden` /
+ * `overflow-y: auto` or the overhang will be clipped.
+ */
+export function ProfileAvatarBlock({
+ metadata,
+ displayName,
+ status,
+ onLightbox,
+}: ProfileAvatarBlockProps) {
+ const picture = metadata?.picture;
+ return (
+
+
picture && onLightbox(picture)}
+ disabled={!picture}
+ >
+
+
+
+ {displayName[0]?.toUpperCase() ?? '?'}
+
+
+
+
+ {/* NIP-38 thought bubble — floats to the right of the avatar over the banner area. */}
+ {status?.text && (
+
+
+
+ {status.url ? (
+
+ {status.text}
+
+ ) : (
+ status.text
+ )}
+
+ {/* Speech bubble triangle tail */}
+
+
+
+
+ )}
+
+ );
+}
+
+// ─── Action bar ──────────────────────────────────────────────────────────────
+
+function ActionBar({
+ isOwnProfile,
+ isFollowing,
+ followPending,
+ canFollow,
+ onToggleFollow,
+ onMoreMenuOpen,
+ onFollowQROpen,
+ authorEvent,
+ onchainCampaigns,
+ onDonate,
+}: {
+ isOwnProfile: boolean;
+ isFollowing: boolean;
+ followPending: boolean;
+ canFollow: boolean;
+ onToggleFollow: () => void;
+ onMoreMenuOpen: () => void;
+ onFollowQROpen: () => void;
+ authorEvent: NostrEvent | undefined;
+ onchainCampaigns: ParsedCampaign[];
+ onDonate: (campaign: ParsedCampaign) => void;
+}) {
+ return (
+
+ {isOwnProfile ? (
+ <>
+
+
+ Edit profile
+
+
+
+
+
+
+
+
+ >
+ ) : (
+ <>
+
+ {onchainCampaigns.length === 1 ? (
+
onDonate(onchainCampaigns[0])}
+ className="rounded-full font-bold gap-1.5"
+ >
+
+ Donate
+
+ ) : onchainCampaigns.length > 1 ? (
+
+
+
+
+ Donate
+
+
+
+ {onchainCampaigns.map((c) => (
+ onDonate(c)}
+ className="flex flex-col items-start gap-0.5"
+ >
+ {c.title}
+ {c.goalUsd ? (
+
+ Goal ${c.goalUsd.toLocaleString()}
+
+ ) : null}
+
+ ))}
+
+
+ ) : null}
+ {authorEvent &&
}
+
+
+
+ >
+ )}
+
+ );
+}
+
+// ─── Stat list ──────────────────────────────────────────────────────────────
+
+function StatList({
+ followersCount,
+ followingCount,
+ totalRaisedSats,
+ btcPrice,
+ onFollowersOpen,
+ onFollowingOpen,
+ onTabChange,
+}: {
+ followersCount: number;
+ followingCount: number;
+ totalRaisedSats: number;
+ btcPrice: number | undefined;
+ onFollowersOpen: () => void;
+ onFollowingOpen: () => void;
+ onTabChange: (id: string) => void;
+}) {
+ // Secondary stat rows (one per row). Followers / Following live inline
+ // at the top. Campaigns and Pledges are intentionally not surfaced as
+ // counts here — the rail's Campaigns and (when relevant) Pledges
+ // sections below already show the underlying content directly.
+ const rows: Array<{
+ icon?: ReactNode;
+ label: string;
+ value: string;
+ onClick?: () => void;
+ show: boolean;
+ }> = [
+ {
+ icon: ,
+ label: 'Raised',
+ value: formatCampaignAmount(totalRaisedSats, btcPrice),
+ onClick: () => onTabChange('campaigns'),
+ show: totalRaisedSats > 0,
+ },
+ ].filter((r) => r.show);
+
+ const hasFollowRow = followersCount > 0 || followingCount > 0;
+ if (!hasFollowRow && rows.length === 0) return null;
+
+ return (
+
+ {/* Followers + Following on a single horizontal row. */}
+ {hasFollowRow && (
+
+ {followersCount > 0 && (
+
+ {formatNumber(followersCount)}
+ Followers
+
+ )}
+ {followingCount > 0 && (
+
+ {formatNumber(followingCount)}
+ Following
+
+ )}
+
+ )}
+
+ {/* Secondary stat rows. */}
+ {rows.length > 0 && (
+
+ {rows.map((row) => (
+
+
+ {row.icon}
+ {row.label}
+
+ {row.value}
+
+ ))}
+
+ )}
+
+ );
+}
+
+// ─── Rail Campaigns Section ─────────────────────────────────────────────────
+
+function RailCampaignsSection({
+ campaigns,
+ isOwnProfile,
+ isLoading,
+ onSeeAll,
+}: {
+ campaigns: ParsedCampaign[];
+ isOwnProfile: boolean;
+ isLoading: boolean;
+ onSeeAll: () => void;
+}) {
+ const { data: moderation } = useCampaignModeration();
+ const visible = isOwnProfile
+ ? campaigns
+ : campaigns.filter((c) => !moderation.hiddenCoords.has(c.aTag));
+
+ if (isLoading && visible.length === 0) {
+ return (
+
+ } title="Campaigns" />
+
+
+ );
+ }
+
+ if (visible.length === 0) return null;
+
+ const shown = visible.slice(0, RAIL_CAMPAIGN_LIMIT);
+ const more = visible.length - shown.length;
+
+ return (
+
+ }
+ title="Campaigns"
+ count={visible.length}
+ />
+
+ {shown.map((c) => (
+
+ ))}
+
+ {(more > 0 || visible.length > 1) && (
+
+ {more > 0 ? `See all ${visible.length} campaigns →` : 'View campaigns tab →'}
+
+ )}
+
+ );
+}
+
+// ─── Rail Latest Pledge Section ─────────────────────────────────────────────
+
+/**
+ * Compact "latest pledge" card shown in the rail when the profile has
+ * no campaigns. Picks the newest pledge from the supplied list (sorted
+ * by `createdAt` descending) and renders it as a single small card with
+ * cover, title, pledged amount, country, and deadline.
+ */
+function RailLatestPledgeSection({
+ pledges,
+ btcPrice,
+ showSeeAll,
+ onSeeAll,
+}: {
+ pledges: Action[];
+ btcPrice: number | undefined;
+ showSeeAll: boolean;
+ onSeeAll: () => void;
+}) {
+ // Pick the newest pledge by created_at. The page query is roughly
+ // newest-first already, but sorting here keeps the rail correct
+ // regardless of upstream order.
+ const latest = [...pledges].sort((a, b) => b.createdAt - a.createdAt)[0];
+ if (!latest) return null;
+
+ return (
+
+ }
+ title="Latest pledge"
+ />
+
+ {showSeeAll && (
+
+ See all {pledges.length} pledges →
+
+ )}
+
+ );
+}
+
+/**
+ * Compact pledge card sized for the rail's narrow column. Smaller cover
+ * aspect, tighter padding, and a single-line pledged amount that doesn't
+ * dominate the rail.
+ */
+function RailPledgeCard({
+ action,
+ btcPrice,
+}: {
+ action: Action;
+ btcPrice: number | undefined;
+}) {
+ const naddr = nip19.naddrEncode({
+ kind: 36639,
+ pubkey: action.pubkey,
+ identifier: action.id,
+ });
+ const cover = sanitizeUrl(action.image) ?? DEFAULT_COVER_IMAGE;
+ const deadline = action.deadline ? formatCompactPledgeDeadline(action.deadline) : null;
+ const isExpired = !!deadline?.isPast;
+ const countryLabel = action.countryCode ? getGeoDisplayName(action.countryCode) : undefined;
+
+ return (
+
+
+
+
+ {isExpired && (
+
+ Ended
+
+ )}
+
+
+
{action.title}
+
+ Pledged
+
+ {formatPledgeAmount(action.bounty, btcPrice)}
+
+
+ {(countryLabel || deadline) && (
+
+ {deadline && (
+
+
+ {deadline.label}
+
+ )}
+ {countryLabel && {countryLabel} }
+
+ )}
+
+
+
+ );
+}
+
+// ─── Rail Organizations Section ─────────────────────────────────────────────
+
+function RailOrganizationsSection({ pubkey }: { pubkey: string }) {
+ const { data: orgs, isLoading } = useProfileOrganizations(pubkey);
+
+ if (isLoading && orgs.length === 0) {
+ return (
+
+ } title="Organizations" />
+
+ {Array.from({ length: 2 }).map((_, i) => (
+
+ ))}
+
+
+ );
+ }
+
+ if (orgs.length === 0) return null;
+
+ const shown = orgs.slice(0, RAIL_ORG_LIMIT);
+ const overflow = Math.max(0, orgs.length - shown.length);
+
+ return (
+
+ }
+ title="Organizations"
+ count={orgs.length}
+ />
+
+ {shown.map((entry) => (
+
+ ))}
+
+ {overflow > 0 && (
+
+
+ See all {orgs.length} →
+
+
+ )}
+
+ );
+}
+
+function RailOrgCell({ entry }: { entry: ProfileOrganization }) {
+ return (
+
+
+
+ {entry.isFounder ? 'Founder' : 'Mod'}
+
+
+ );
+}
+
+// ─── Section header & skeleton ──────────────────────────────────────────────
+
+function RailSectionHeader({
+ icon,
+ title,
+ count,
+}: {
+ icon: ReactNode;
+ title: string;
+ count?: number;
+}) {
+ return (
+
+ {icon}
+ {title}
+ {count !== undefined && count > 0 && (
+ ({count})
+ )}
+
+ );
+}
+
+function RailSkeleton() {
+ return (
+
+ );
+}
diff --git a/src/components/profile/ProfilePledgesTab.tsx b/src/components/profile/ProfilePledgesTab.tsx
new file mode 100644
index 00000000..c5459c3d
--- /dev/null
+++ b/src/components/profile/ProfilePledgesTab.tsx
@@ -0,0 +1,220 @@
+import { useState } from 'react';
+import { Link as RouterLink } from 'react-router-dom';
+import { CalendarClock, HandHeart, MapPin } from 'lucide-react';
+import { nip19 } from 'nostr-tools';
+
+import { Badge } from '@/components/ui/badge';
+import { Card } from '@/components/ui/card';
+import { Skeleton } from '@/components/ui/skeleton';
+import { DEFAULT_COVER_IMAGE } from '@/lib/defaultActionCovers';
+import { formatCompactPledgeDeadline, formatPledgeAmount } from '@/lib/pledges';
+import { getGeoDisplayName } from '@/lib/countries';
+import { cn } from '@/lib/utils';
+import type { Action } from '@/hooks/useActions';
+
+interface ProfilePledgesTabProps {
+ pubkey: string;
+ displayName: string;
+ isOwnProfile: boolean;
+ /** Pledges authored by this pubkey. Already filtered upstream. */
+ pledges: Action[];
+ /** BTC price for sats↔USD conversion in pledge amount labels. */
+ btcPrice: number | undefined;
+ /** True while the underlying useActions() query is still in flight. */
+ isLoading: boolean;
+}
+
+/**
+ * Pledges authored by this profile, rendered as a responsive grid that
+ * mirrors the `/pledges` (`ActionsPage`) directory styling.
+ *
+ * v1 scope per the design plan: pledges *created* by the user.
+ * "Pledges backed" (zapped submissions on others' pledges) is deferred to v2.
+ */
+export function ProfilePledgesTab({
+ pubkey,
+ displayName,
+ isOwnProfile,
+ pledges,
+ btcPrice,
+ isLoading,
+}: ProfilePledgesTabProps) {
+ const now = Math.floor(Date.now() / 1000);
+
+ // Loading skeleton until the first list resolves.
+ if (isLoading && pledges.length === 0) {
+ return (
+
+ );
+ }
+
+ if (pledges.length === 0) {
+ return (
+
+
+
+
+
+ {isOwnProfile
+ ? "You haven't created a pledge yet."
+ : `${displayName} hasn't created a pledge yet.`}
+
+ {isOwnProfile && (
+
+ Create a pledge →
+
+ )}
+
+
+
+ );
+ }
+
+ // Split into active vs ended so the active pledges lead the grid.
+ const active: Action[] = [];
+ const ended: Action[] = [];
+ for (const p of pledges) {
+ if (p.deadline && p.deadline <= now) ended.push(p);
+ else active.push(p);
+ }
+
+ return (
+
+ {active.length > 0 && (
+
+ {ended.length > 0 && (
+
+ Active
+
+ )}
+
+ {active.map((pledge) => (
+
+ ))}
+
+
+ )}
+
+ {ended.length > 0 && (
+
+
+ Ended
+
+
+ {ended.map((pledge) => (
+
+ ))}
+
+
+ )}
+
+ );
+}
+
+function ProfilePledgeCard({
+ action,
+ isExpired,
+ btcPrice,
+}: {
+ action: Action;
+ isExpired?: boolean;
+ btcPrice: number | undefined;
+}) {
+ const [imageLoadFailed, setImageLoadFailed] = useState(false);
+
+ const naddr = nip19.naddrEncode({
+ kind: 36639,
+ pubkey: action.pubkey,
+ identifier: action.id,
+ });
+
+ const coverImage = (action.image && !imageLoadFailed) ? action.image : DEFAULT_COVER_IMAGE;
+ const deadline = action.deadline ? formatCompactPledgeDeadline(action.deadline) : null;
+ const countryLabel = action.countryCode ? getGeoDisplayName(action.countryCode) : undefined;
+
+ return (
+
+
+
+
setImageLoadFailed(true)}
+ loading="lazy"
+ />
+ {isExpired && (
+
+ Ended
+
+ )}
+
+
+
+
+ {action.title}
+
+ {action.description.trim() && (
+
{action.description}
+ )}
+
+
+
+
+
Pledged
+
+ {formatPledgeAmount(action.bounty, btcPrice)}
+
+
+
+
+ {countryLabel && (
+
+
+ {countryLabel}
+
+ )}
+ {deadline && (
+
+
+ {deadline.label}
+
+ )}
+
+
+
+
+ );
+}
+
+function PledgesGridSkeleton() {
+ return (
+
+ {Array.from({ length: 3 }).map((_, i) => (
+
+
+
+
+
+
+
+
+
+ ))}
+
+ );
+}
diff --git a/src/components/profile/ProfileTabs.tsx b/src/components/profile/ProfileTabs.tsx
new file mode 100644
index 00000000..a6425ace
--- /dev/null
+++ b/src/components/profile/ProfileTabs.tsx
@@ -0,0 +1,148 @@
+import { useEffect, useLayoutEffect, useRef, useState } from 'react';
+import { cn } from '@/lib/utils';
+import { useNavHidden } from '@/contexts/LayoutContext';
+
+export interface ProfileTabsProps {
+ tabs: Array<{ id: string; label: string }>;
+ activeTab: string;
+ onChange: (id: string) => void;
+}
+
+/**
+ * Profile-local tab bar.
+ *
+ * A focused alternative to the global `SubHeaderBar` — no arc decoration,
+ * no hover slice tracking, no FAB-aware spacing. Just a clean horizontal
+ * row with an animated underline marking the active tab. Used in two
+ * shapes on the profile page: a 3-tab content set on desktop (Activity /
+ * Campaigns / Pledges, alongside the sticky identity rail) and a 5-tab
+ * set on mobile (Overview / Activity / Campaigns / Community / Pledges,
+ * since the rail collapses into the Overview / Community tabs).
+ *
+ * Behavior:
+ * - Sticks to the top of its containing scroll context. The parent column
+ * can place it inside any scroll region; the bar uses `position: sticky`.
+ * - Underline animates between active tabs via a single absolute-positioned
+ * indicator measured from the active tab's offset/width.
+ * - Horizontally scrolls when overflowing; auto-scrolls the active tab into
+ * view on selection (matches the previous TabButton behavior).
+ */
+export function ProfileTabs({ tabs, activeTab, onChange }: ProfileTabsProps) {
+ const trackRef = useRef(null);
+ const tabRefs = useRef>(new Map());
+ const [indicator, setIndicator] = useState<{ left: number; width: number } | null>(null);
+ const navHidden = useNavHidden();
+
+ // Measure the active tab and update the underline indicator.
+ useLayoutEffect(() => {
+ const btn = tabRefs.current.get(activeTab);
+ if (!btn) {
+ setIndicator(null);
+ return;
+ }
+ setIndicator({ left: btn.offsetLeft, width: btn.offsetWidth });
+ }, [activeTab, tabs]);
+
+ // Recompute on resize (label-width changes between breakpoints).
+ useEffect(() => {
+ const onResize = () => {
+ const btn = tabRefs.current.get(activeTab);
+ if (!btn) return;
+ setIndicator({ left: btn.offsetLeft, width: btn.offsetWidth });
+ };
+ window.addEventListener('resize', onResize);
+ return () => window.removeEventListener('resize', onResize);
+ }, [activeTab]);
+
+ // Scroll the active tab into view when activated (overflow scroll case).
+ useLayoutEffect(() => {
+ const btn = tabRefs.current.get(activeTab);
+ const track = trackRef.current;
+ if (!btn || !track) return;
+ const left = btn.offsetLeft;
+ const right = left + btn.offsetWidth;
+ const viewLeft = track.scrollLeft;
+ const viewRight = viewLeft + track.clientWidth;
+ if (left < viewLeft) {
+ track.scrollTo({ left: left - 8, behavior: 'smooth' });
+ } else if (right > viewRight) {
+ track.scrollTo({ left: right - track.clientWidth + 8, behavior: 'smooth' });
+ }
+ }, [activeTab]);
+
+ return (
+
+
+ {tabs.map((tab) => {
+ const active = tab.id === activeTab;
+ return (
+ {
+ if (el) tabRefs.current.set(tab.id, el);
+ else tabRefs.current.delete(tab.id);
+ }}
+ onClick={() => {
+ if (active) {
+ window.scrollTo({ top: 0, behavior: 'smooth' });
+ } else {
+ window.scrollTo({ top: 0 });
+ onChange(tab.id);
+ }
+ }}
+ className={cn(
+ 'relative shrink-0 px-4 py-3.5 text-sm font-medium whitespace-nowrap',
+ 'transition-colors duration-150',
+ 'focus:outline-none focus-visible:bg-secondary/40 rounded-sm',
+ active
+ ? 'text-foreground'
+ : 'text-muted-foreground hover:text-foreground',
+ )}
+ aria-selected={active}
+ role="tab"
+ >
+ {tab.label}
+
+ );
+ })}
+
+ {/* Active-tab underline indicator. Animates between tab positions. */}
+ {indicator && (
+
+ )}
+
+
+ );
+}
diff --git a/src/contexts/AppContext.ts b/src/contexts/AppContext.ts
index b0210cac..423c8a42 100644
--- a/src/contexts/AppContext.ts
+++ b/src/contexts/AppContext.ts
@@ -208,8 +208,6 @@ export interface AppConfig {
clientName?: string;
/** NIP-19 `naddr1…` identifying this client's kind 31990 handler event. Decoded at publish time to produce the `31990::` addr and relay hint for the "client" tag per NIP-89. */
client?: string;
- /** Enable Magic Mouse mode: cursor/finger emanates magical fire in the primary color */
- magicMouse: boolean;
/** Current theme */
theme: Theme;
/** Custom theme config (colors, fonts, background). Only used when theme === "custom". */
@@ -271,12 +269,78 @@ export interface AppConfig {
/** Wildcard domain used for iframe sandboxing (e.g. "iframe.diy"). Default: "iframe.diy". */
sandboxDomain: string;
/**
- * Base URL for the Esplora-compatible Bitcoin REST API. Used by the wallet,
- * on-chain zap flows, and NIP-73 Bitcoin tx/address pages. The standard
- * Esplora REST root (no version segment). The mempool.space `/v1/prices`
- * extension is appended by the price call. Default: "https://mempool.space/api".
+ * Ordered list of base URLs for Esplora-compatible Bitcoin REST APIs. Used
+ * by the wallet, on-chain zap flows, and NIP-73 Bitcoin tx/address pages.
+ * Each URL is the standard Esplora REST root (no version segment, no
+ * trailing slash). The list is tried in order with exponential-backoff
+ * failover on `429` / `5xx` responses — see `src/lib/esplora.ts`.
+ *
+ * The first entry is treated as the primary. The mempool.space `/v1/prices`
+ * extension is appended by the price call; endpoints that don't speak it
+ * (e.g. Blockstream's Esplora) are silently skipped via a `404` soft-failover.
+ *
+ * Default:
+ * ```
+ * [
+ * 'https://mempool.space/api',
+ * 'https://mempool.emzy.de/api',
+ * 'https://blockstream.info/api',
+ * ]
+ * ```
*/
- esploraBaseUrl: string;
+ esploraApis: string[];
+ /**
+ * Base URL for Trezor's Blockbook API, used exclusively by the HD wallet at
+ * `/wallet`. Blockbook's xpub endpoint (`/api/v2/xpub/`) lets
+ * the HD wallet scan, balance, and pull tx history for the entire account
+ * in a single HTTP call, where the equivalent Esplora workflow would be
+ * dozens of per-address calls.
+ *
+ * No version segment, no trailing slash. The endpoint must be a real
+ * Blockbook instance — Esplora-compatible servers do NOT speak the
+ * `/api/v2/xpub/` path. There is no failover list and no automatic
+ * fallback; HD wallet errors are surfaced to the user.
+ *
+ * **Privacy note**: the full account xpub is sent to this server on every
+ * request. Whoever operates the configured Blockbook instance can link
+ * every wallet address and observe balance over time. Default is Trezor's
+ * public mirror; users who care can self-host.
+ *
+ * Default: `"https://btc.trezor.io"` — the canonical endpoint Trezor
+ * Suite itself uses.
+ */
+ blockbookBaseUrl: string;
+ /**
+ * Base URL of a BIP-352 tweak-data indexer (BlindBit Oracle v2-compatible),
+ * used by the HD wallet at `/wallet` to detect incoming silent payments.
+ *
+ * The wallet derives the scan private key `bscan` locally from the user's
+ * nsec and finishes the BIP-352 ECDH step itself; only public per-tx tweak
+ * data and Taproot outputs come over the wire. `bscan` MUST NEVER leave the
+ * device.
+ *
+ * Endpoints consumed (all public, no auth):
+ * - `GET /info` → tip height
+ * - `GET /tweaks/:height` → 33-byte compressed tweaks
+ * - `GET /utxos/:height` → P2TR outputs in the block
+ *
+ * No version segment, no trailing slash. An empty string disables silent
+ * payment scanning entirely (the wallet still displays the static `sp1q…`
+ * receive address, but never resolves balances or history).
+ *
+ * **Privacy note**: the indexer never sees `bscan`, but it does observe the
+ * sequence of block heights you ask about, paired with your IP. For a
+ * backfill scan over a contiguous range that signal is uninformative; for
+ * live ongoing scans the operator can correlate your IP with the wallet's
+ * last-known tip. Self-hosting (or pointing this at a trusted endpoint) is
+ * the strongest mitigation.
+ *
+ * Default: `"https://silentpayments.dev/blindbit/mainnet"` — the same
+ * public BlindBit Oracle the Dana wallet (cygnet3/dana) uses. Operated by
+ * the silentpayments.dev project, no authentication, no rate-limiting
+ * announced. Override via `agora.json` for a self-hosted endpoint.
+ */
+ bip352IndexerUrl: string;
/**
* Display preference for monetary amounts (zap totals, balances, send forms).
* - "usd" (default): convert sats to USD using the live BTC price.
diff --git a/src/hooks/useAgoraFeed.ts b/src/hooks/useAgoraFeed.ts
new file mode 100644
index 00000000..efe9edd1
--- /dev/null
+++ b/src/hooks/useAgoraFeed.ts
@@ -0,0 +1,298 @@
+import { useNostr } from '@nostrify/react';
+import { useInfiniteQuery } from '@tanstack/react-query';
+import type { NostrEvent, NostrFilter } from '@nostrify/nostrify';
+
+import { useContentFilters } from '@/hooks/useContentFilters';
+import { useFeedSettings } from '@/hooks/useFeedSettings';
+import { useMuteList } from '@/hooks/useMuteList';
+import { CAMPAIGN_KIND } from '@/lib/campaign';
+import { getEnabledFeedKinds } from '@/lib/extraKinds';
+import { getPaginationCursor, shouldHideFeedEvent } from '@/lib/feedUtils';
+import { isEventMuted } from '@/lib/muteHelpers';
+
+const AGORA_PAGE_SIZE = 25;
+const PLEDGE_KIND = 36639;
+const COMMUNITY_KIND = 34550;
+const POLL_KIND = 1068;
+const COMMENT_KIND = 1111;
+const NOTE_KIND = 1;
+const ONCHAIN_ZAP_KIND = 8333;
+const LIGHTNING_ZAP_KIND = 9735;
+
+const AGORA_ENTITY_KINDS = [CAMPAIGN_KIND, PLEDGE_KIND, COMMUNITY_KIND, ONCHAIN_ZAP_KIND];
+const COMMENT_ROOT_KINDS = [String(CAMPAIGN_KIND), String(PLEDGE_KIND), String(COMMUNITY_KIND)];
+const WORLD_K_TAGS = ['iso3166', 'geo'];
+const AGORA_T_TAGS = ['agora', 'Agora'];
+const IGNORED_AGORA_NOTE_AUTHORS = new Set([
+ '4fe14ef28934b4093d71d43a8c9e9ec42ab4243febfff38470bfef05f51992ec',
+]);
+
+interface AgoraFeedPage {
+ events: NostrEvent[];
+ oldestTimestamp: number | null;
+ totalFetched: number;
+}
+
+function tagValues(event: NostrEvent, name: string): string[] {
+ return event.tags.filter(([tagName]) => tagName === name).map(([, value]) => value).filter(Boolean);
+}
+
+function hasTagValue(event: NostrEvent, name: string, values: readonly string[]): boolean {
+ const accepted = new Set(values.map((value) => value.toLowerCase()));
+ return tagValues(event, name).some((value) => accepted.has(value.toLowerCase()));
+}
+
+function hasAgoraTag(event: NostrEvent): boolean {
+ return hasTagValue(event, 't', AGORA_T_TAGS);
+}
+
+function isWorldComment(event: NostrEvent): boolean {
+ return event.kind === COMMENT_KIND && hasTagValue(event, 'k', WORLD_K_TAGS);
+}
+
+function isWorldPoll(event: NostrEvent): boolean {
+ return event.kind === POLL_KIND && hasTagValue(event, 'k', WORLD_K_TAGS);
+}
+
+/**
+ * Strict Agora filter — accepts an event only if it is genuinely Agora-created
+ * content (carries the `t:agora` marker) OR is a world-layer event (country-
+ * rooted comment / poll), which is intentionally surfaced cross-client.
+ *
+ * See `src/lib/agoraNoteTags.ts` and `NIP.md` (§ Agora Content Marker).
+ */
+function isRelevantAgoraEvent(event: NostrEvent): boolean {
+ if (shouldHideFeedEvent(event)) return false;
+
+ // World-layer posts are kept regardless of the Agora marker.
+ if (isWorldComment(event) || isWorldPoll(event)) return true;
+
+ // Everything else must carry the Agora content marker.
+ if (!hasAgoraTag(event)) return false;
+
+ if (event.kind === CAMPAIGN_KIND) return true;
+ if (event.kind === COMMUNITY_KIND) return true;
+ if (event.kind === PLEDGE_KIND) return true;
+
+ if (event.kind === COMMENT_KIND) {
+ // Comment must reference an Agora entity root (campaign / pledge / community).
+ return hasTagValue(event, 'K', COMMENT_ROOT_KINDS)
+ || tagValues(event, 'A').some((value) => value.startsWith(`${COMMUNITY_KIND}:`));
+ }
+
+ if (event.kind === NOTE_KIND) {
+ if (IGNORED_AGORA_NOTE_AUTHORS.has(event.pubkey)) return false;
+ return true; // already verified `t:agora` above
+ }
+
+ if (event.kind === ONCHAIN_ZAP_KIND || event.kind === LIGHTNING_ZAP_KIND) {
+ return hasTagValue(event, 'K', COMMENT_ROOT_KINDS) || tagValues(event, 'a').some(isAgoraAddress);
+ }
+
+ return false;
+}
+
+function isAgoraAddress(value: string): boolean {
+ const kind = value.split(':')[0];
+ return kind === String(CAMPAIGN_KIND) || kind === String(PLEDGE_KIND) || kind === String(COMMUNITY_KIND);
+}
+
+function getAddressableCoordinate(event: NostrEvent): string | undefined {
+ if (event.kind < 30000 || event.kind >= 40000) return undefined;
+ const d = event.tags.find(([name]) => name === 'd')?.[1];
+ if (!d) return undefined;
+ return `${event.kind}:${event.pubkey}:${d}`;
+}
+
+function extractDonationTargets(events: NostrEvent[]): { coordinates: string[]; eventIds: string[] } {
+ const coordinates = new Set();
+ const eventIds = new Set();
+
+ for (const event of events) {
+ const coord = getAddressableCoordinate(event);
+ if (coord && (event.kind === CAMPAIGN_KIND || event.kind === PLEDGE_KIND)) {
+ coordinates.add(coord);
+ }
+
+ if (event.kind === COMMENT_KIND && hasTagValue(event, 'K', [String(PLEDGE_KIND)])) {
+ eventIds.add(event.id);
+ }
+ }
+
+ return {
+ coordinates: Array.from(coordinates).slice(0, 40),
+ eventIds: Array.from(eventIds).slice(0, 40),
+ };
+}
+
+export interface UseAgoraFeedOptions {
+ /**
+ * Restrict the feed to events authored by these pubkeys. Applied as an
+ * `authors:` filter on every relay query (server-side filtering). Empty
+ * array disables the query — used for "Following" mode when the user
+ * follows nobody.
+ */
+ authors?: string[];
+ /**
+ * When true, also include events authored by `authors` in any of the
+ * user's enabled "feed kinds" (notes, reposts, articles, photos,
+ * videos, polls, etc. — see {@link getEnabledFeedKinds}) regardless
+ * of the `t:agora` marker. Produces a unified "everything this person
+ * has done on the network" feed.
+ *
+ * Only meaningful in combination with `authors`; setting it without
+ * `authors` would flood the feed with all kind-1 notes on every relay
+ * and is silently ignored.
+ *
+ * Used by the profile page to merge the legacy Posts tab into the
+ * Activity tab. Off by default so the strict Agora home feed isn't
+ * affected.
+ */
+ includeAuthorNotes?: boolean;
+}
+
+/** Strict Agora activity feed: campaigns, pledges, communities, world posts, #Agora notes, and donations. */
+export function useAgoraFeed(enabled: boolean, options?: UseAgoraFeedOptions) {
+ const { nostr } = useNostr();
+ const { muteItems } = useMuteList();
+ const { shouldFilterEvent } = useContentFilters();
+ const { feedSettings } = useFeedSettings();
+
+ const authors = options?.authors;
+ const authorsKey = authors ? [...authors].sort().join(',') : '';
+ // If `authors` is provided but empty, the feed is intentionally empty
+ // (e.g. the user follows nobody) — skip the query entirely.
+ const authorsEmpty = authors !== undefined && authors.length === 0;
+ const queryEnabled = enabled && !authorsEmpty;
+ // Author-scoped notes inclusion only makes sense when at least one
+ // author is set; ignore the option otherwise (see option doc).
+ const includeAuthorNotes = !!options?.includeAuthorNotes && !!authors && authors.length > 0;
+ // Pull the user's enabled "feed kinds" — same set the legacy Posts tab
+ // used. Includes notes (1), reposts (6), articles (30023), photos (20),
+ // videos (21/22), polls, etc. — every kind the user opted to see in
+ // mixed feeds. Memoize via stable cache-key so changing settings refetch.
+ const authorNoteKinds = includeAuthorNotes ? getEnabledFeedKinds(feedSettings) : [];
+ // Always include kind 1 / 6 even if the user disabled them in feed
+ // settings — a profile feed without notes is broken.
+ if (includeAuthorNotes && !authorNoteKinds.includes(1)) authorNoteKinds.push(1);
+ if (includeAuthorNotes && !authorNoteKinds.includes(6)) authorNoteKinds.push(6);
+ const authorNoteKindsKey = [...authorNoteKinds].sort((a, b) => a - b).join(',');
+
+ const query = useInfiniteQuery({
+ queryKey: ['agora-feed', authorsKey, includeAuthorNotes, authorNoteKindsKey],
+ queryFn: async ({ pageParam, signal: querySignal }) => {
+ const signal = AbortSignal.any([querySignal, AbortSignal.timeout(8_000)]);
+ const until = pageParam as number | undefined;
+ const authorsFilter = authors && authors.length > 0 ? { authors } : {};
+
+ const filters: NostrFilter[] = [
+ // Agora entity kinds — strict `t:agora` required.
+ { kinds: AGORA_ENTITY_KINDS, '#t': AGORA_T_TAGS, limit: AGORA_PAGE_SIZE, ...authorsFilter, ...(until && { until }) },
+ // Comments on Agora entities — strict `t:agora` required.
+ { kinds: [COMMENT_KIND], '#t': AGORA_T_TAGS, '#K': COMMENT_ROOT_KINDS, limit: AGORA_PAGE_SIZE, ...authorsFilter, ...(until && { until }) },
+ // World layer — country/geo-rooted comments and polls. Intentionally
+ // cross-client; the `#k=iso3166|geo` filter is the entire gate.
+ { kinds: [COMMENT_KIND, POLL_KIND], '#k': WORLD_K_TAGS, limit: AGORA_PAGE_SIZE, ...authorsFilter, ...(until && { until }) },
+ // `#Agora`-tagged kind 1 notes — accepts any author opting in via the tag.
+ { kinds: [NOTE_KIND], '#t': AGORA_T_TAGS, limit: Math.ceil(AGORA_PAGE_SIZE / 2), ...authorsFilter, ...(until && { until }) },
+ ];
+
+ // Author-scoped notes — every enabled feed kind from this author,
+ // no `t:agora` requirement. Powers the unified profile feed where
+ // the legacy Posts tab has been folded into Activity. The kind set
+ // mirrors the user's feed settings (notes, reposts, articles,
+ // photos, videos, polls, etc.) so a profile shows everything the
+ // person has done across the network.
+ if (includeAuthorNotes && authorNoteKinds.length > 0) {
+ filters.push({
+ kinds: authorNoteKinds,
+ ...authorsFilter,
+ limit: AGORA_PAGE_SIZE,
+ ...(until && { until }),
+ });
+ }
+
+ const raw = await nostr.query(filters, { signal });
+ // When author-notes are included, accept any event of an enabled
+ // feed kind authored by one of the requested authors regardless of
+ // the strict Agora gate. The strong author scope is the trust
+ // anchor.
+ const authorSet = new Set(authors ?? []);
+ const authorKindSet = new Set(authorNoteKinds);
+ const filtered = raw.filter((event) => {
+ if (isRelevantAgoraEvent(event)) return true;
+ if (!includeAuthorNotes) return false;
+ if (!authorKindSet.has(event.kind)) return false;
+ if (shouldHideFeedEvent(event)) return false;
+ return authorSet.has(event.pubkey);
+ });
+ const { coordinates, eventIds } = extractDonationTargets(filtered);
+
+ // Donation enrichment: pull lightning + onchain zaps that reference
+ // the Agora entities visible on this page. Donation events must also
+ // carry the Agora marker to be included (per `isRelevantAgoraEvent`).
+ const donationFilters: NostrFilter[] = [];
+ if (coordinates.length > 0) {
+ donationFilters.push({ kinds: [LIGHTNING_ZAP_KIND, ONCHAIN_ZAP_KIND], '#t': AGORA_T_TAGS, '#a': coordinates, limit: coordinates.length * 10 });
+ }
+ if (eventIds.length > 0) {
+ donationFilters.push({ kinds: [LIGHTNING_ZAP_KIND, ONCHAIN_ZAP_KIND], '#t': AGORA_T_TAGS, '#e': eventIds, limit: eventIds.length * 10 });
+ }
+
+ const donationEvents = donationFilters.length > 0
+ ? await nostr.query(donationFilters, { signal })
+ : [];
+
+ const seen = new Set();
+ const combined = [
+ ...filtered,
+ // Donation enrichment is already scoped by exact #a/#e targets from this page.
+ ...donationEvents.filter((event) => !shouldHideFeedEvent(event) && hasAgoraTag(event)),
+ ]
+ .filter((event) => {
+ if (seen.has(event.id)) return false;
+ seen.add(event.id);
+ if (muteItems.length > 0 && isEventMuted(event, muteItems)) return false;
+ if (shouldFilterEvent(event)) return false;
+ return true;
+ })
+ .sort((a, b) => b.created_at - a.created_at);
+
+ const page = combined.slice(0, AGORA_PAGE_SIZE);
+ const oldestTimestamp = page.length > 0 ? getPaginationCursor(page) : null;
+
+ return {
+ events: page,
+ oldestTimestamp,
+ totalFetched: combined.length,
+ };
+ },
+ initialPageParam: undefined as number | undefined,
+ getNextPageParam: (lastPage) => {
+ if (lastPage.totalFetched < AGORA_PAGE_SIZE || !lastPage.oldestTimestamp) return undefined;
+ return lastPage.oldestTimestamp - 1;
+ },
+ enabled: queryEnabled,
+ staleTime: 30_000,
+ placeholderData: (prev) => prev,
+ });
+
+ const seen = new Set();
+ const events: NostrEvent[] = [];
+ for (const page of query.data?.pages ?? []) {
+ for (const event of page.events) {
+ if (seen.has(event.id)) continue;
+ seen.add(event.id);
+ events.push(event);
+ }
+ }
+
+ return {
+ events,
+ isLoading: queryEnabled ? query.isPending : false,
+ isFetchingNextPage: query.isFetchingNextPage,
+ hasNextPage: !authorsEmpty && query.hasNextPage,
+ fetchNextPage: query.fetchNextPage,
+ pageCount: query.data?.pages.length,
+ };
+}
diff --git a/src/hooks/useAllCampaigns.ts b/src/hooks/useAllCampaigns.ts
index 5f5e6d72..8ce073a2 100644
--- a/src/hooks/useAllCampaigns.ts
+++ b/src/hooks/useAllCampaigns.ts
@@ -30,7 +30,7 @@ interface CampaignScore {
const EMPTY_SCORE: CampaignScore = { totalSats: 0, donorCount: 0 };
/**
- * Loads kind 30223 campaigns with optional Top ranking (most-zapped first)
+ * Loads kind 33863 campaigns with optional Top ranking (most-zapped first)
* and free-text search applied client-side.
*
* **Why client-side rather than NIP-50?** Ditto's `sort:top` / `sort:hot`
@@ -46,7 +46,7 @@ const EMPTY_SCORE: CampaignScore = { totalSats: 0, donorCount: 0 };
* - **Full relay coverage** — we fetch from the user's default pool, not
* just Ditto, so campaigns published anywhere are discoverable.
* - **Search that actually matches** — substring across title, summary,
- * story, location, and category tags.
+ * and story.
*
* Tradeoff: we fetch up to `limit` (default 200) campaigns regardless of
* search, then filter in JavaScript. At current campaign volume this is
@@ -70,7 +70,7 @@ export function useAllCampaigns({
[{ kinds: [CAMPAIGN_KIND], limit }],
{ signal: AbortSignal.any([c.signal, AbortSignal.timeout(10_000)]) },
);
- return parseCampaignEvents(events, { includeArchived: false, sortByCreatedAt: true });
+ return parseCampaignEvents(events, { sortByCreatedAt: true });
},
staleTime: 30_000,
});
@@ -185,9 +185,5 @@ function matchesQuery(campaign: ParsedCampaign, lowerQuery: string): boolean {
if (campaign.title.toLowerCase().includes(lowerQuery)) return true;
if (campaign.summary.toLowerCase().includes(lowerQuery)) return true;
if (campaign.story.toLowerCase().includes(lowerQuery)) return true;
- // Location and `t` tags are short but worth matching so users can type
- // "kenya" or "mutual aid" and get useful results.
- if (campaign.location?.toLowerCase().includes(lowerQuery)) return true;
- if (campaign.tags.some((t) => t.toLowerCase().includes(lowerQuery))) return true;
return false;
}
diff --git a/src/hooks/useArchiveCampaign.ts b/src/hooks/useArchiveCampaign.ts
deleted file mode 100644
index d383c0ba..00000000
--- a/src/hooks/useArchiveCampaign.ts
+++ /dev/null
@@ -1,82 +0,0 @@
-import { useNostr } from '@nostrify/react';
-import { useMutation, useQueryClient } from '@tanstack/react-query';
-
-import { useCurrentUser } from '@/hooks/useCurrentUser';
-import { useNostrPublish } from '@/hooks/useNostrPublish';
-import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
-import {
- CAMPAIGN_KIND,
- parseCampaign,
- type ParsedCampaign,
-} from '@/lib/campaign';
-
-interface ArchiveCampaignArgs {
- campaign: ParsedCampaign;
- /** `true` to mark archived, `false` to reopen by removing the status tag. */
- archived: boolean;
-}
-
-/**
- * Archive (or reopen) a fundraising campaign without deleting it.
- *
- * Archiving republishes the campaign with `["status", "archived"]` so the
- * UI can hide it from the main fundraisers feed while still loading it by
- * direct link. Past donations remain intact because the addressable
- * coordinate (kind, pubkey, d) is unchanged.
- *
- * Unarchive removes the status tag (or any other status value), bringing
- * the campaign back into the main list.
- *
- * Only the campaign author can archive — the relay would reject anyone
- * else's republish under the same coordinate, but we also guard at the
- * UI layer.
- */
-export function useArchiveCampaign() {
- const { nostr } = useNostr();
- const { user } = useCurrentUser();
- const { mutateAsync: publishEvent } = useNostrPublish();
- const queryClient = useQueryClient();
-
- return useMutation({
- mutationFn: async ({ campaign, archived }: ArchiveCampaignArgs) => {
- if (!user) throw new Error('You must be logged in.');
- if (user.pubkey !== campaign.pubkey) {
- throw new Error('Only the campaign author can change its status.');
- }
-
- // Read-modify-write: never trust the local cache for addressable mutations.
- const prev = await fetchFreshEvent(nostr, {
- kinds: [CAMPAIGN_KIND],
- authors: [user.pubkey],
- '#d': [campaign.identifier],
- });
- if (!prev || !parseCampaign(prev)) {
- throw new Error('Could not load the latest version of this campaign.');
- }
-
- // Carry over every tag except any existing `status` tag, which we own here.
- const nextTags = prev.tags.filter(([name]) => name !== 'status');
- if (archived) nextTags.push(['status', 'archived']);
-
- const published = await publishEvent({
- kind: CAMPAIGN_KIND,
- content: prev.content,
- tags: nextTags,
- prev,
- });
-
- const parsed = parseCampaign(published);
- if (!parsed) {
- throw new Error('Updated campaign failed validation.');
- }
- return parsed;
- },
- onSuccess: (campaign) => {
- void queryClient.invalidateQueries({
- queryKey: ['campaign', campaign.pubkey, campaign.identifier],
- });
- void queryClient.invalidateQueries({ queryKey: ['campaigns'] });
- void queryClient.invalidateQueries({ queryKey: ['campaign-featured'] });
- },
- });
-}
diff --git a/src/hooks/useBitcoinAddress.ts b/src/hooks/useBitcoinAddress.ts
index 754f0b25..93b0bb91 100644
--- a/src/hooks/useBitcoinAddress.ts
+++ b/src/hooks/useBitcoinAddress.ts
@@ -10,18 +10,18 @@ import { useAppContext } from '@/hooks/useAppContext';
*/
export function useBitcoinAddress(address: string) {
const { config } = useAppContext();
- const { esploraBaseUrl } = config;
+ const { esploraApis } = config;
const { data: addressDetail, isLoading, error, refetch } = useQuery({
- queryKey: ['bitcoin-address-detail', esploraBaseUrl, address],
- queryFn: () => fetchAddressDetail(address, esploraBaseUrl),
+ queryKey: ['bitcoin-address-detail', esploraApis, address],
+ queryFn: ({ signal }) => fetchAddressDetail(address, esploraApis, signal),
enabled: !!address,
refetchInterval: 30_000,
});
const { data: btcPrice } = useQuery({
- queryKey: ['btc-price', esploraBaseUrl],
- queryFn: () => fetchBtcPrice(esploraBaseUrl),
+ queryKey: ['btc-price', esploraApis],
+ queryFn: ({ signal }) => fetchBtcPrice(esploraApis, signal),
refetchInterval: 60_000,
staleTime: 30_000,
});
diff --git a/src/hooks/useBitcoinTx.ts b/src/hooks/useBitcoinTx.ts
index 7370faaf..10bcced1 100644
--- a/src/hooks/useBitcoinTx.ts
+++ b/src/hooks/useBitcoinTx.ts
@@ -10,18 +10,18 @@ import { useAppContext } from '@/hooks/useAppContext';
*/
export function useBitcoinTx(txid: string) {
const { config } = useAppContext();
- const { esploraBaseUrl } = config;
+ const { esploraApis } = config;
const { data: tx, isLoading, error } = useQuery({
- queryKey: ['bitcoin-tx-detail', esploraBaseUrl, txid],
- queryFn: () => fetchTxDetail(txid, esploraBaseUrl),
+ queryKey: ['bitcoin-tx-detail', esploraApis, txid],
+ queryFn: ({ signal }) => fetchTxDetail(txid, esploraApis, signal),
enabled: !!txid,
staleTime: 60_000,
});
const { data: btcPrice } = useQuery({
- queryKey: ['btc-price', esploraBaseUrl],
- queryFn: () => fetchBtcPrice(esploraBaseUrl),
+ queryKey: ['btc-price', esploraApis],
+ queryFn: ({ signal }) => fetchBtcPrice(esploraApis, signal),
refetchInterval: 60_000,
staleTime: 30_000,
});
diff --git a/src/hooks/useBitcoinWallet.ts b/src/hooks/useBitcoinWallet.ts
deleted file mode 100644
index 8342a109..00000000
--- a/src/hooks/useBitcoinWallet.ts
+++ /dev/null
@@ -1,75 +0,0 @@
-import { useMemo } from 'react';
-import { useQuery } from '@tanstack/react-query';
-
-import { useCurrentUser } from '@/hooks/useCurrentUser';
-import { useAppContext } from '@/hooks/useAppContext';
-import { nostrPubkeyToBitcoinAddress, fetchAddressData, fetchBtcPrice, fetchTransactions } from '@/lib/bitcoin';
-
-/**
- * Hook that derives a Bitcoin Taproot address from the current user's Nostr
- * pubkey and fetches the on-chain balance from the configured Esplora-compatible
- * API (default: mempool.space).
- *
- * Balance auto-refreshes every 30 seconds while the component is mounted.
- * BTC/USD price refreshes every 60 seconds.
- */
-export function useBitcoinWallet() {
- const { user } = useCurrentUser();
- const { config } = useAppContext();
- const { esploraBaseUrl } = config;
-
- const bitcoinAddress = useMemo(() => {
- if (!user) return '';
- return nostrPubkeyToBitcoinAddress(user.pubkey);
- }, [user]);
-
- const {
- data: addressData,
- isLoading,
- error,
- refetch,
- } = useQuery({
- queryKey: ['bitcoin-balance', esploraBaseUrl, bitcoinAddress],
- queryFn: () => fetchAddressData(bitcoinAddress, esploraBaseUrl),
- enabled: !!bitcoinAddress,
- refetchInterval: 30_000,
- });
-
- const { data: btcPrice } = useQuery({
- queryKey: ['btc-price', esploraBaseUrl],
- queryFn: () => fetchBtcPrice(esploraBaseUrl),
- refetchInterval: 60_000,
- staleTime: 30_000,
- });
-
- const {
- data: transactions,
- isLoading: isLoadingTxs,
- } = useQuery({
- queryKey: ['bitcoin-txs', esploraBaseUrl, bitcoinAddress],
- queryFn: () => fetchTransactions(bitcoinAddress, esploraBaseUrl),
- enabled: !!bitcoinAddress,
- refetchInterval: 30_000,
- });
-
- return {
- /** The derived bc1p... Taproot address. */
- bitcoinAddress,
- /** Balance and transaction data (undefined while loading). */
- addressData,
- /** Current BTC price in USD. */
- btcPrice,
- /** Transaction history for the address. */
- transactions,
- /** Whether the initial balance fetch is in progress. */
- isLoading,
- /** Whether transactions are still loading. */
- isLoadingTxs,
- /** Error from the balance query, if any. */
- error,
- /** Manually trigger a balance refresh. */
- refetch,
- /** The current user's hex pubkey (convenience). */
- pubkey: user?.pubkey ?? '',
- };
-}
diff --git a/src/hooks/useBtcPrice.ts b/src/hooks/useBtcPrice.ts
index 20e8e611..1d5523bf 100644
--- a/src/hooks/useBtcPrice.ts
+++ b/src/hooks/useBtcPrice.ts
@@ -16,10 +16,10 @@ import { useAppContext } from '@/hooks/useAppContext';
*/
export function useBtcPrice() {
const { config } = useAppContext();
- const { esploraBaseUrl } = config;
+ const { esploraApis } = config;
return useQuery({
- queryKey: ['btc-price', esploraBaseUrl],
- queryFn: () => fetchBtcPrice(esploraBaseUrl),
+ queryKey: ['btc-price', esploraApis],
+ queryFn: ({ signal }) => fetchBtcPrice(esploraApis, signal),
refetchInterval: 60_000,
staleTime: 30_000,
});
diff --git a/src/hooks/useCampaignDonations.ts b/src/hooks/useCampaignDonations.ts
index 14388bab..59b1db77 100644
--- a/src/hooks/useCampaignDonations.ts
+++ b/src/hooks/useCampaignDonations.ts
@@ -1,76 +1,162 @@
import { useNostr } from '@nostrify/react';
-import { useQuery } from '@tanstack/react-query';
+import { useQueries, useQuery } from '@tanstack/react-query';
import type { NostrEvent } from '@nostrify/nostrify';
+import { useAppContext } from '@/hooks/useAppContext';
+import { verifyOnchainZap, extractOnchainZapTxid, type OnchainZapEntry } from '@/hooks/useOnchainZaps';
+import { fetchAddressData } from '@/lib/bitcoin';
+import type { ParsedCampaign } from '@/lib/campaign';
+
export interface CampaignDonationStats {
- /** Total satoshis pledged across all kind 8333 receipts (self-reported sum). */
+ /**
+ * Total satoshis raised, sourced from the cumulative on-chain amount
+ * ever received by the campaign's `w` address (`chain_stats.funded_txo_sum`
+ * from Esplora). This is independent of Nostr donation receipts —
+ * any payment to the address counts, and beneficiary payouts do not
+ * reduce the number.
+ */
totalSats: number;
- /** Number of unique on-chain transactions counted. */
+ /** Number of unique on-chain transactions counted (from verified receipts). */
txCount: number;
- /** Number of unique donor pubkeys. */
+ /** Number of unique donor pubkeys (from verified receipts). */
donorCount: number;
- /** All kind 8333 receipts for the campaign, newest first. */
+ /** All raw kind 8333 receipts for the campaign, newest first. */
receipts: NostrEvent[];
+ /** Verified entries (one per unique txid). */
+ verified: OnchainZapEntry[];
+ /**
+ * True while underlying queries (address balance + receipt verification)
+ * are still in flight. Callers may use this to defer rendering
+ * "0 sats raised" until the data has had a chance to load.
+ */
+ isVerifying: boolean;
}
+const EMPTY_RECEIPTS: NostrEvent[] = [];
+
/**
- * Aggregates donation receipts (kind 8333 events) for a campaign by its
- * addressable coordinate.
+ * Aggregates donation statistics for a campaign.
*
- * Each kind 8333 event's `amount` tag is the total sats paid to the
- * recipients listed in that event (see `NIP.md`). New donations publish a
- * single event per tx covering every recipient; legacy donations published
- * one event per recipient. In either case, summing `amount` across all
- * events that tag the campaign yields the campaign's total — the legacy
- * per-recipient amounts sum to the full donation, and the new per-tx
- * amount IS the full donation.
+ * The headline number — `totalSats` — comes from a direct balance lookup
+ * on the campaign's `w` Bitcoin address via the configured Esplora endpoint
+ * (default: mempool.space). Specifically, it's `chain_stats.funded_txo_sum`,
+ * the cumulative amount ever sent to the address. This means:
*
- * The returned `totalSats` is **self-reported**. Per the NIP.md spec a
- * strict client would verify each receipt against the on-chain transaction
- * before counting it; that's left to a future iteration (see TODO inline).
+ * - Donations are counted whether or not the donor publishes a Nostr
+ * receipt (kind 8333).
+ * - The progress bar does not regress when the beneficiary spends from
+ * the address.
+ * - Anyone who sends sats to the address contributes to "raised" —
+ * address reuse trades off security here. Fresh-per-campaign addresses
+ * (the default "public" wallet source) avoid this entirely.
+ *
+ * Donation receipts (kind 8333) are still fetched and verified on-chain
+ * to populate the donor list, donor count, and per-tx breakdown shown in
+ * the UI. They no longer contribute to `totalSats`.
+ *
+ * Silent-payment campaigns (`w` starts with `sp1…`) short-circuit to
+ * zeros — donations are unlinkable by design, so address balance is
+ * undefined.
*/
-export function useCampaignDonations(aTag: string | undefined) {
+export function useCampaignDonations(campaign: ParsedCampaign | undefined): {
+ data: CampaignDonationStats;
+ isLoading: boolean;
+} {
const { nostr } = useNostr();
+ const { config } = useAppContext();
+ const { esploraApis } = config;
- return useQuery({
- queryKey: ['campaign-donations', aTag ?? ''],
- queryFn: async (c): Promise => {
- if (!aTag) {
- return { totalSats: 0, txCount: 0, donorCount: 0, receipts: [] };
- }
+ const aTag = campaign?.aTag;
+ const wallet = campaign?.wallet;
+ const isSilentPayment = wallet?.mode === 'sp';
+ const isOnchain = wallet?.mode === 'onchain';
+ const walletValue = wallet?.value;
+
+ // Headline number: query the address balance directly from Esplora.
+ // `totalReceived` is `chain_stats.funded_txo_sum` — sats ever sent to
+ // the address. Does not regress when the beneficiary spends.
+ const addressQuery = useQuery({
+ queryKey: ['bitcoin-balance', 'campaign', esploraApis, walletValue ?? ''],
+ queryFn: ({ signal }) => fetchAddressData(walletValue!, esploraApis, signal),
+ enabled: !!walletValue && isOnchain,
+ staleTime: 30_000,
+ refetchInterval: 30_000,
+ });
+
+ // Donor list / breakdown: fetch kind 8333 receipts. Disabled for SP
+ // campaigns (no receipts are published by design).
+ const receiptsQuery = useQuery({
+ queryKey: ['campaign-donations', 'events', aTag ?? ''],
+ queryFn: async ({ signal }): Promise => {
+ if (!aTag) return EMPTY_RECEIPTS;
const events = await nostr.query(
[{ kinds: [8333], '#a': [aTag], limit: 500 }],
- { signal: c.signal },
+ { signal },
);
-
- let totalSats = 0;
- const txids = new Set();
- const donors = new Set();
- for (const event of events) {
- const txid = event.tags.find(([n]) => n === 'i')?.[1]?.replace(/^bitcoin:tx:/, '');
- const amountTag = event.tags.find(([n]) => n === 'amount')?.[1];
- const amount = amountTag ? Number(amountTag) : NaN;
- if (!txid || !Number.isFinite(amount) || amount <= 0) continue;
-
- totalSats += amount;
- txids.add(txid);
- donors.add(event.pubkey);
- }
-
- // TODO: verify each txid against mempool.space and sum only the outputs
- // that pay listed recipients' derived Taproot addresses. Until then the
- // total is best-effort and trivially spoofable.
-
- const receipts = [...events].sort((a, b) => b.created_at - a.created_at);
-
- return {
- totalSats,
- txCount: txids.size,
- donorCount: donors.size,
- receipts,
- };
+ return events;
},
- enabled: !!aTag,
+ enabled: !!aTag && !isSilentPayment,
staleTime: 15_000,
});
+
+ // Dedupe by txid; prefer the earliest receipt per tx (first to claim).
+ const receipts = receiptsQuery.data ?? EMPTY_RECEIPTS;
+ const dedupedByTxid = (() => {
+ const byTxid = new Map();
+ for (const event of receipts) {
+ const txid = extractOnchainZapTxid(event);
+ if (!txid) continue;
+ const existing = byTxid.get(txid);
+ if (!existing || event.created_at < existing.created_at) {
+ byTxid.set(txid, event);
+ }
+ }
+ return Array.from(byTxid.values());
+ })();
+
+ // Verify each unique-txid receipt against the campaign's `w` wallet
+ // address. The verified entries drive the donor list / breakdown UI,
+ // not the headline raised total.
+ const verifications = useQueries({
+ queries: dedupedByTxid.map((event) => ({
+ queryKey: ['onchain-zaps', 'verify', esploraApis, event.id, walletValue ?? ''],
+ queryFn: ({ signal }: { signal: AbortSignal }) =>
+ verifyOnchainZap(event, esploraApis, walletValue, signal),
+ staleTime: 60_000,
+ enabled: !!walletValue && !isSilentPayment,
+ })),
+ });
+
+ const verified: OnchainZapEntry[] = verifications
+ .map((v) => v.data)
+ .filter((v): v is OnchainZapEntry => !!v);
+
+ const totalSats = isOnchain ? (addressQuery.data?.totalReceived ?? 0) : 0;
+
+ const txids = new Set();
+ const donors = new Set();
+ for (const v of verified) {
+ txids.add(v.txid);
+ donors.add(v.senderPubkey);
+ }
+
+ const sortedReceipts = [...receipts].sort((a, b) => b.created_at - a.created_at);
+
+ const isVerifying =
+ !isSilentPayment &&
+ (addressQuery.isLoading ||
+ receiptsQuery.isLoading ||
+ verifications.some((v) => v.isLoading));
+
+ return {
+ data: {
+ totalSats,
+ txCount: txids.size,
+ donorCount: donors.size,
+ receipts: sortedReceipts,
+ verified,
+ isVerifying,
+ },
+ isLoading: isVerifying,
+ };
}
diff --git a/src/hooks/useCampaignModeration.ts b/src/hooks/useCampaignModeration.ts
index 06f3c9a2..412dc09a 100644
--- a/src/hooks/useCampaignModeration.ts
+++ b/src/hooks/useCampaignModeration.ts
@@ -1,130 +1,31 @@
import { useNostr } from '@nostrify/react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
-import type { NostrEvent } from '@nostrify/nostrify';
import { useNostrPublish } from './useNostrPublish';
import { useCampaignModerators } from './useCampaignModerators';
import { CAMPAIGN_KIND } from '@/lib/campaign';
+import {
+ AGORA_MODERATION_NAMESPACE,
+ EMPTY_MODERATION_DATA,
+ LABEL_KIND,
+ type ModerationData,
+ type ModerationLabel,
+ type ModerationState,
+ foldModerationLabels,
+} from '@/lib/agoraModeration';
-/** NIP-32 label kind. */
-const LABEL_KIND = 1985;
-/** Label namespace for Agora's moderation labels. */
-export const AGORA_MODERATION_NAMESPACE = 'agora.moderation';
-
-/** The six possible label values in the moderation namespace. */
-export type ModerationLabel =
- | 'approved'
- | 'unapproved'
- | 'hidden'
- | 'unhidden'
- | 'featured'
- | 'unfeatured';
-
-/** A single label event narrowed to its decision axis. */
-interface AxisDecision {
- /** Latest label observed on this axis. */
- label: ModerationLabel;
- /** Author of the latest label. */
- pubkey: string;
- /** Created-at of the latest label. */
- createdAt: number;
-}
+// Re-exports for existing import sites. The namespace constant and the
+// `ModerationLabel` type are imported from this module by the campaign
+// moderation menu and other surfaces; keep those exports stable so the
+// shared-module refactor stays a no-op for callers.
+export { AGORA_MODERATION_NAMESPACE };
+export type { ModerationLabel };
/** Per-campaign rollup of approval + hide + featured state. */
-export interface CampaignModerationState {
- approval?: AxisDecision; // `approved` or `unapproved`
- hide?: AxisDecision; // `hidden` or `unhidden`
- featured?: AxisDecision; // `featured` or `unfeatured`
-}
+export type CampaignModerationState = ModerationState;
-export interface CampaignModerationData {
- /** Map of `30223::` -> rollup. */
- byCoord: Map;
- /** Coordinates where the latest approval label is `approved`. */
- approvedCoords: Set;
- /** Coordinates where the latest hide label is `hidden`. */
- hiddenCoords: Set;
- /** Coordinates where the latest featured label is `featured`. */
- featuredCoords: Set;
- /**
- * Map of `coord` -> `created_at` of the latest `featured` label.
- * Used to sort the home-page featured row newest-first.
- */
- featuredOrder: Map;
- /** Pubkeys that were considered moderators when the query ran. */
- moderators: string[];
-}
-
-const EMPTY_DATA: CampaignModerationData = {
- byCoord: new Map(),
- approvedCoords: new Set(),
- hiddenCoords: new Set(),
- featuredCoords: new Set(),
- featuredOrder: new Map(),
- moderators: [],
-};
-
-/** True if a label value belongs to the approval axis. */
-function isApprovalLabel(value: string): value is 'approved' | 'unapproved' {
- return value === 'approved' || value === 'unapproved';
-}
-
-/** True if a label value belongs to the hide axis. */
-function isHideLabel(value: string): value is 'hidden' | 'unhidden' {
- return value === 'hidden' || value === 'unhidden';
-}
-
-/** True if a label value belongs to the featured axis. */
-function isFeaturedLabel(value: string): value is 'featured' | 'unfeatured' {
- return value === 'featured' || value === 'unfeatured';
-}
-
-/**
- * Fold a flat list of label events into per-coordinate rollups by axis.
- * The newest event per `(coord, axis)` wins. Events not addressing a
- * campaign coordinate or carrying a value outside the namespace are dropped.
- */
-function foldLabelEvents(events: NostrEvent[], moderators: string[]): CampaignModerationData {
- const byCoord = new Map();
-
- for (const event of events) {
- const value = event.tags.find(([n, , ns]) => n === 'l' && ns === AGORA_MODERATION_NAMESPACE)?.[1];
- if (!value) continue;
- const aTag = event.tags.find(([n, v]) => n === 'a' && typeof v === 'string' && v.startsWith(`${CAMPAIGN_KIND}:`))?.[1];
- if (!aTag) continue;
-
- const state = byCoord.get(aTag) ?? {};
- if (isApprovalLabel(value)) {
- if (!state.approval || event.created_at > state.approval.createdAt) {
- state.approval = { label: value, pubkey: event.pubkey, createdAt: event.created_at };
- }
- } else if (isHideLabel(value)) {
- if (!state.hide || event.created_at > state.hide.createdAt) {
- state.hide = { label: value, pubkey: event.pubkey, createdAt: event.created_at };
- }
- } else if (isFeaturedLabel(value)) {
- if (!state.featured || event.created_at > state.featured.createdAt) {
- state.featured = { label: value, pubkey: event.pubkey, createdAt: event.created_at };
- }
- }
- byCoord.set(aTag, state);
- }
-
- const approvedCoords = new Set();
- const hiddenCoords = new Set();
- const featuredCoords = new Set();
- const featuredOrder = new Map();
- for (const [coord, state] of byCoord) {
- if (state.approval?.label === 'approved') approvedCoords.add(coord);
- if (state.hide?.label === 'hidden') hiddenCoords.add(coord);
- if (state.featured?.label === 'featured') {
- featuredCoords.add(coord);
- featuredOrder.set(coord, state.featured.createdAt);
- }
- }
-
- return { byCoord, approvedCoords, hiddenCoords, featuredCoords, featuredOrder, moderators };
-}
+/** Surface-scoped alias so existing callers keep working. */
+export type CampaignModerationData = ModerationData;
/**
* Fetches and folds campaign-moderation label events authored by Team
@@ -154,7 +55,8 @@ export function useCampaignModeration() {
// an empty `authors:` filter (which would return everything matching the
// namespace from any author and break our trust model — see AGENTS.md).
// Once moderators arrives empty, the query runs and immediately resolves
- // to EMPTY_DATA — no rendering can promote a campaign without a moderator.
+ // to EMPTY_MODERATION_DATA — no rendering can promote a campaign without
+ // a moderator.
const moderatorsKey = moderators ? [...moderators].sort().join(',') : '';
const moderationQuery = useQuery({
@@ -162,7 +64,7 @@ export function useCampaignModeration() {
enabled: moderators !== undefined,
queryFn: async ({ signal }): Promise => {
if (!moderators || moderators.length === 0) {
- return { ...EMPTY_DATA, moderators: [] };
+ return { ...EMPTY_MODERATION_DATA, moderators: [] };
}
const events = await nostr.query(
[
@@ -179,7 +81,7 @@ export function useCampaignModeration() {
],
{ signal: AbortSignal.any([signal, AbortSignal.timeout(8000)]) },
);
- return foldLabelEvents(events, moderators);
+ return foldModerationLabels(events, moderators, CAMPAIGN_KIND);
},
staleTime: 30_000,
});
@@ -203,11 +105,18 @@ export function useCampaignModeration() {
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['campaign-moderation'] });
+ // Moderation decisions (approve / hide / feature) gate which campaigns
+ // surface on the home page, discover shelf, and community grids — so
+ // the list queries need to refetch too, otherwise the moderator's UI
+ // still shows the old approval state until refresh.
+ queryClient.invalidateQueries({ queryKey: ['campaigns'] });
+ queryClient.invalidateQueries({ queryKey: ['campaigns-all'] });
+ queryClient.invalidateQueries({ queryKey: ['campaigns-all-scores'] });
},
});
return {
- data: moderationQuery.data ?? EMPTY_DATA,
+ data: moderationQuery.data ?? EMPTY_MODERATION_DATA,
isPending: moderationQuery.isPending,
isLoading: moderationQuery.isLoading,
isReady: moderationQuery.isSuccess,
diff --git a/src/hooks/useCampaigns.ts b/src/hooks/useCampaigns.ts
index f09fd5ee..f5855cd6 100644
--- a/src/hooks/useCampaigns.ts
+++ b/src/hooks/useCampaigns.ts
@@ -2,16 +2,10 @@ import { useNostr } from '@nostrify/react';
import { useQuery } from '@tanstack/react-query';
import type { NostrEvent, NostrFilter } from '@nostrify/nostrify';
-import { CAMPAIGN_KIND, type CampaignCategory, parseCampaign, type ParsedCampaign } from '@/lib/campaign';
+import { CAMPAIGN_KIND, parseCampaign, type ParsedCampaign } from '@/lib/campaign';
import { createCountryIdentifier } from '@/lib/countryIdentifiers';
interface ParseCampaignEventsOptions {
- /**
- * Include campaigns whose latest revision carries `["status", "archived"]`.
- * Defaults to `false` so archived campaigns never appear in the main
- * fundraisers listing.
- */
- includeArchived?: boolean;
/**
* When `true`, sort the parsed campaigns newest-`created_at`-first.
* When `false`, preserve the order in which events were returned —
@@ -23,7 +17,7 @@ interface ParseCampaignEventsOptions {
}
/**
- * Deduplicate, parse, and (optionally) reorder a flat list of kind 30223
+ * Deduplicate, parse, and (optionally) reorder a flat list of kind 33863
* events into `ParsedCampaign` objects.
*
* For each `(pubkey, d)` pair we keep only the latest event — relays may
@@ -35,7 +29,7 @@ interface ParseCampaignEventsOptions {
*/
export function parseCampaignEvents(
events: NostrEvent[],
- { includeArchived = false, sortByCreatedAt = true }: ParseCampaignEventsOptions = {},
+ { sortByCreatedAt = true }: ParseCampaignEventsOptions = {},
): ParsedCampaign[] {
// Track insertion order keyed by coord so we can preserve relay-scored
// order when we don't want to re-sort. `Map` iteration order is insertion
@@ -62,7 +56,6 @@ export function parseCampaignEvents(
if (!event) continue;
const campaign = parseCampaign(event);
if (!campaign) continue;
- if (!includeArchived && campaign.archived) continue;
parsed.push(campaign);
}
@@ -74,8 +67,6 @@ export function parseCampaignEvents(
}
interface UseCampaignsOptions {
- /** Optional category filter (`t` tag). */
- category?: CampaignCategory;
/** Optional ISO 3166-1 alpha-2 country filter (`i` tag). */
countryCode?: string;
/** Maximum number of events to fetch from relays. Default: 60. */
@@ -83,12 +74,7 @@ interface UseCampaignsOptions {
/** Authors to fetch from, e.g. for a profile's campaigns. */
authors?: string[];
/**
- * Restrict to campaigns whose recipient `p` tags include any of these
- * pubkeys. Used by the /claim page to find campaigns set up *for* a user.
- */
- recipientPubkeys?: string[];
- /**
- * Restrict to a specific set of `30223::` coordinates.
+ * Restrict to a specific set of `33863::` coordinates.
*
* Used by moderator-curated surfaces (the home page, Discover) that only
* want to render campaigns labeled `approved` by a Team Soapbox moderator
@@ -102,24 +88,19 @@ interface UseCampaignsOptions {
* the unfiltered behavior while their moderator query loads.
*/
coordinates?: string[];
- /**
- * Include campaigns that have been archived by their creator
- * (`["status", "archived"]`). Defaults to `false` so archived
- * campaigns never appear in the main fundraisers listing.
- */
- includeArchived?: boolean;
}
/**
- * Loads kind 30223 campaign events and returns them as fully-parsed
+ * Loads kind 33863 campaign events and returns them as fully-parsed
* {@link ParsedCampaign} objects, newest first.
*
- * Campaigns that fail validation (missing title, no recipients, etc.) are
+ * Campaigns that fail validation (missing title, no `w` wallet, etc.) are
* dropped so the UI never has to defensively check for missing fields.
*
- * Archived campaigns (`status=archived`) are excluded by default. Pass
- * `includeArchived: true` to load them — used by the author's own profile
- * view so they can see and reopen their own archives.
+ * To stop a campaign from appearing the creator publishes a NIP-09 kind 5
+ * deletion request referencing the campaign's `a` coordinate; well-behaved
+ * relays honor the deletion and the campaign drops out of result sets
+ * automatically.
*
* For each `(pubkey, d)` pair we keep only the latest event — relays may
* return older revisions of an addressable event alongside the current one.
@@ -127,13 +108,10 @@ interface UseCampaignsOptions {
export function useCampaigns(options: UseCampaignsOptions = {}) {
const { nostr } = useNostr();
const {
- category,
countryCode,
limit = 60,
authors,
- recipientPubkeys,
coordinates,
- includeArchived = false,
} = options;
// Stable cache key for the coordinates option; sort so order doesn't
@@ -143,7 +121,7 @@ export function useCampaigns(options: UseCampaignsOptions = {}) {
return useQuery({
queryKey: [
'campaigns',
- { category, countryCode, limit, authors, recipientPubkeys, coordinatesKey, includeArchived },
+ { countryCode, limit, authors, coordinatesKey },
],
queryFn: async (c) => {
// Sentinel: empty allowlist = empty result. Skip the relay entirely.
@@ -156,7 +134,7 @@ export function useCampaigns(options: UseCampaignsOptions = {}) {
if (coordinates && coordinates.length > 0) {
const byAuthor = new Map();
for (const coord of coordinates) {
- // Expected: `30223::`
+ // Expected: `33863::`
const parts = coord.split(':');
if (parts.length < 3) continue;
const kindPart = Number(parts[0]);
@@ -171,23 +149,18 @@ export function useCampaigns(options: UseCampaignsOptions = {}) {
if (byAuthor.size === 0) return [] as ParsedCampaign[];
filters = Array.from(byAuthor, ([author, dTags]) => {
const f: NostrFilter = { kinds: [CAMPAIGN_KIND], authors: [author], '#d': dTags };
- if (category) f['#t'] = [category];
if (countryCode) f['#i'] = [createCountryIdentifier(countryCode)];
return f;
});
} else {
const filter: NostrFilter = { kinds: [CAMPAIGN_KIND], limit };
- if (category) filter['#t'] = [category];
if (countryCode) filter['#i'] = [createCountryIdentifier(countryCode)];
if (authors && authors.length > 0) filter.authors = authors;
- if (recipientPubkeys && recipientPubkeys.length > 0) {
- filter['#p'] = recipientPubkeys;
- }
filters = [filter];
}
const events = await nostr.query(filters, { signal: c.signal });
- return parseCampaignEvents(events, { includeArchived, sortByCreatedAt: true });
+ return parseCampaignEvents(events, { sortByCreatedAt: true });
},
staleTime: 30_000,
});
diff --git a/src/hooks/useDefaultPostCountry.ts b/src/hooks/useDefaultPostCountry.ts
new file mode 100644
index 00000000..d275dac6
--- /dev/null
+++ b/src/hooks/useDefaultPostCountry.ts
@@ -0,0 +1,68 @@
+import { useCallback, useState } from 'react';
+import { useAppContext } from '@/hooks/useAppContext';
+import { getCountryInfo } from '@/lib/countries';
+import { getStorageKey } from '@/lib/storageKey';
+
+/**
+ * Sentinel value for "post to the global / world feed" (a plain kind 1 note
+ * with no country root). Matches the value used by ComposeBox's `destination`
+ * state.
+ */
+export type PostCountryDestination = 'world' | string;
+
+const WORLD = 'world' as const;
+const STORAGE_SUFFIX = 'compose-default-country';
+
+function readStored(key: string): PostCountryDestination {
+ try {
+ const raw = localStorage.getItem(key);
+ if (!raw || raw === WORLD) return WORLD;
+ // Validate against the country directory so a stale code (or an
+ // invalid string from a different version) doesn't pin the composer
+ // to a country that no longer parses.
+ return getCountryInfo(raw) ? raw : WORLD;
+ } catch {
+ return WORLD;
+ }
+}
+
+/**
+ * The user's preferred default post destination — either `'world'` (plain
+ * kind 1) or an ISO 3166 country code (NIP-22 country-rooted kind 1111).
+ *
+ * Persisted to localStorage so the choice survives reloads. Hydrates
+ * synchronously from storage on first render, so the composer never flashes
+ * the wrong default.
+ *
+ * The act of selecting a destination in ComposeBox does NOT auto-save the
+ * default — there is an explicit "Set as default" affordance for that. This
+ * means a user posting once to a country they don't normally post to does
+ * not unintentionally change their default.
+ */
+export function useDefaultPostCountry(): [
+ PostCountryDestination,
+ (value: PostCountryDestination) => void,
+] {
+ const { config } = useAppContext();
+ const key = getStorageKey(config.appId, STORAGE_SUFFIX);
+
+ const [value, setValue] = useState(() => readStored(key));
+
+ const setDefault = useCallback(
+ (next: PostCountryDestination) => {
+ setValue(next);
+ try {
+ if (next === WORLD) {
+ localStorage.setItem(key, WORLD);
+ } else if (getCountryInfo(next)) {
+ localStorage.setItem(key, next);
+ }
+ } catch {
+ // localStorage unavailable — non-critical.
+ }
+ },
+ [key],
+ );
+
+ return [value, setDefault];
+}
diff --git a/src/hooks/useDeleteEvent.ts b/src/hooks/useDeleteEvent.ts
index 56689c73..b9576f70 100644
--- a/src/hooks/useDeleteEvent.ts
+++ b/src/hooks/useDeleteEvent.ts
@@ -13,6 +13,71 @@ interface DeleteEventParams {
eventDTag?: string;
}
+/**
+ * Prefixes of query keys that can contain a deleted event and therefore
+ * need a refetch after a NIP-09 deletion. This is wider than the average
+ * mutation invalidation — a deletion is inherently cross-cutting (a single
+ * post can sit in the main feed, the author's profile feed, a country
+ * feed, the community activity feed, a comment thread, and more), and
+ * over-invalidating is cheap relative to leaving stale items on screen.
+ */
+const FEED_INVALIDATION_PREFIXES: ReadonlySet = new Set([
+ // Generic / legacy feed
+ 'feed',
+ // Agora country feeds
+ 'agora-feed',
+ 'agora-feed-paginated',
+ 'agora-feed-new-posts',
+ // Home mixed-mode feed (composes useAgoraFeed + useNostrLayer)
+ 'mixed-feed',
+ 'nostr-layer',
+ // Profile + likes
+ 'profile-feed',
+ 'profile-likes-infinite',
+ 'profile-media',
+ 'profile-pinned-events',
+ // Replies + comments
+ 'replies',
+ 'nostr', // useComments (NIP-22) uses ['nostr', 'comments', ...]
+ 'event-comments',
+ 'wall-comments',
+ 'pinned-event-comments',
+ 'pinned-event-comments-list',
+ // Notifications
+ 'notifications',
+ 'notifications-unread',
+ // Campaigns & pledges
+ 'campaign',
+ 'campaigns',
+ 'campaigns-all',
+ 'campaigns-all-scores',
+ 'agora-action',
+ 'agora-actions',
+ 'community-actions',
+ // Community / org activity surfaces
+ 'community-activity-feed',
+ 'organization-activity',
+ 'organization-home-activity-feed',
+ // Trending & curated
+ 'trending',
+ 'trending-posts',
+ 'sorted-posts',
+ 'infinite-sorted-posts',
+ 'infinite-hot-feed',
+ 'ditto-curated-feed',
+ 'world-feed',
+ 'following-feed',
+ 'following-country-feed',
+ 'following-hashtag-feed',
+ 'my-feed',
+ 'tab-feed',
+ 'relay-feed',
+ 'domain-feed',
+ // Misc per-event caches
+ 'event',
+ 'addr-event',
+]);
+
/**
* Hook to publish a kind 5 deletion request event (NIP-09).
*
@@ -20,8 +85,11 @@ interface DeleteEventParams {
* an `e` tag and an `a` tag so it works on relays that only support
* e-tag deletion as well as relays that support a-tag deletion.
*
- * After publishing, invalidates feed caches so relays are re-queried
- * and the deleted event is no longer returned.
+ * After publishing, invalidates every feed-shaped cache so relays are
+ * re-queried and the deleted event is no longer returned. The set is
+ * deliberately broad — deletions are user-initiated and rare, and the
+ * cost of an extra refetch is much smaller than the cost of leaving a
+ * deleted post visible across the app.
*/
export function useDeleteEvent() {
const { user } = useCurrentUser();
@@ -52,13 +120,14 @@ export function useDeleteEvent() {
return eventId;
},
onSuccess: () => {
- // Invalidate feed queries so relays are re-queried.
- // The relay should no longer return the deleted event.
- queryClient.invalidateQueries({ queryKey: ['feed'] });
- queryClient.invalidateQueries({ queryKey: ['profile-feed'] });
- queryClient.invalidateQueries({ queryKey: ['profile-likes-infinite'] });
- queryClient.invalidateQueries({ queryKey: ['replies'] });
- queryClient.invalidateQueries({ queryKey: ['notifications'] });
+ // Invalidate every feed-shaped query so relays are re-queried and
+ // the deleted event drops out of every surface it appeared on.
+ queryClient.invalidateQueries({
+ predicate: (q) => {
+ const root = q.queryKey[0];
+ return typeof root === 'string' && FEED_INVALIDATION_PREFIXES.has(root);
+ },
+ });
},
});
}
diff --git a/src/hooks/useDiscoverFeed.ts b/src/hooks/useDiscoverFeed.ts
deleted file mode 100644
index f3abbb32..00000000
--- a/src/hooks/useDiscoverFeed.ts
+++ /dev/null
@@ -1,148 +0,0 @@
-import { useNostr } from '@nostrify/react';
-import { useInfiniteQuery } from '@tanstack/react-query';
-import type { NostrEvent, NostrFilter } from '@nostrify/nostrify';
-
-import { CAMPAIGN_KIND } from '@/lib/campaign';
-import { shouldHideFeedEvent } from '@/lib/feedUtils';
-
-const DISCOVER_PAGE_SIZE = 30;
-
-/**
- * Kinds surfaced in the mixed Discover feed:
- *
- * - **30223** — new campaign creations (addressable). When a campaign is
- * minted or revised it bubbles back to the top, the same way a new
- * Substack post would.
- * - **1111** — NIP-22 comments. We pull two slices: comments scoped to
- * countries (`#K = iso3166`) and comments scoped to communities
- * (`#K = 34550`). Together these are "posts from the world" + "voices
- * inside the communities".
- * - **36639** — Agora pledges (challenges / civic calls). Always
- * included because they're the most action-oriented funding signal.
- *
- * We deliberately *exclude* free-form kind 1 notes here — the Discover
- * page is the place to see content that's tagged to a real-world thread
- * (country, community, campaign), not the global text-note firehose. The
- * old plain feed still lives at `/feed`.
- */
-
-/** Tag scopes we accept on kind 1111 comments. */
-const COMMENT_K_SCOPES = ['iso3166', 'geo', '34550'];
-
-/** Aliases we accept on kind 36639 pledge `t` tags. */
-const ACTION_T_ALIASES = ['agora-action', 'pathos-challenge', 'agora-challenge'];
-
-/**
- * Apply Discover-specific filtering after relay fetch. Drops events that
- * `shouldHideFeedEvent` flags (mutes, content filters happen later) and
- * any 1111 comment that lacks a recognised scope tag, since relays may
- * over-return when we union filters.
- */
-function filterDiscoverEvents(events: NostrEvent[]): NostrEvent[] {
- return events
- .filter((event) => {
- if (shouldHideFeedEvent(event)) return false;
- if (event.kind === 1111) {
- const kTags = event.tags
- .filter(([n]) => n === 'k' || n === 'K')
- .map(([, v]) => v);
- return kTags.some((v) => COMMENT_K_SCOPES.includes(v));
- }
- return true;
- })
- .sort((a, b) => b.created_at - a.created_at);
-}
-
-/**
- * Public infinite feed for the Discover page. Streams together new
- * campaigns, world-tagged comments, community comments, and Agora
- * pledges, paginated by `created_at` cursor.
- *
- * Each page issues exactly one relay request (the union of all relevant
- * filters) to stay inside per-page rate budgets — the same pattern
- * `useWorldFeed` uses.
- *
- * Returns the standard `useInfiniteQuery` surface plus a flattened
- * `events` list for convenient consumption.
- */
-export function useDiscoverFeed(enabled = true) {
- const { nostr } = useNostr();
-
- const query = useInfiniteQuery({
- queryKey: ['discover-feed'],
- queryFn: async ({ pageParam, signal: querySignal }) => {
- const signal = AbortSignal.any([querySignal, AbortSignal.timeout(8_000)]);
- const until = pageParam as number | undefined;
-
- const filters: NostrFilter[] = [
- // New / revised campaigns — addressable, so we lean on a small
- // limit and let the relay's natural newest-first ordering surface
- // recent edits. No `#k` scoping needed.
- {
- kinds: [CAMPAIGN_KIND],
- limit: Math.floor(DISCOVER_PAGE_SIZE / 3),
- ...(until && { until }),
- },
- // Community + country-scoped comments.
- {
- kinds: [1111],
- '#K': COMMENT_K_SCOPES,
- limit: DISCOVER_PAGE_SIZE,
- ...(until && { until }),
- },
- // Agora pledges.
- {
- kinds: [36639],
- '#t': ACTION_T_ALIASES,
- limit: Math.floor(DISCOVER_PAGE_SIZE / 3),
- ...(until && { until }),
- },
- ];
-
- const raw = await nostr.query(filters, { signal });
- const filtered = filterDiscoverEvents(raw);
- const page = filtered.slice(0, DISCOVER_PAGE_SIZE);
-
- const oldestTimestamp = page.length > 0
- ? page[page.length - 1].created_at
- : null;
-
- return {
- events: page,
- oldestTimestamp,
- totalFetched: filtered.length,
- };
- },
- initialPageParam: undefined as number | undefined,
- getNextPageParam: (lastPage) => {
- if (lastPage.totalFetched < DISCOVER_PAGE_SIZE || !lastPage.oldestTimestamp) {
- return undefined;
- }
- return lastPage.oldestTimestamp - 1;
- },
- enabled,
- staleTime: 30_000,
- placeholderData: (prev) => prev,
- });
-
- // Flatten + dedupe. Each addressable event may legitimately appear
- // across pages if a newer revision lands; we keep the newest version.
- const seen = new Set();
- const events: NostrEvent[] = [];
- for (const page of query.data?.pages ?? []) {
- for (const event of page.events) {
- if (seen.has(event.id)) continue;
- seen.add(event.id);
- events.push(event);
- }
- }
-
- return {
- events,
- isLoading: query.isPending,
- isFetchingNextPage: query.isFetchingNextPage,
- hasNextPage: query.hasNextPage,
- fetchNextPage: query.fetchNextPage,
- pageCount: query.data?.pages.length,
- };
-}
diff --git a/src/hooks/useDonateCampaign.ts b/src/hooks/useDonateCampaign.ts
index 3b11d8d6..e3467dc9 100644
--- a/src/hooks/useDonateCampaign.ts
+++ b/src/hooks/useDonateCampaign.ts
@@ -5,25 +5,25 @@ import { isSignerCapabilityError, reportSignerUnsupported, useBitcoinSigner } fr
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import {
- BITCOIN_DUST_LIMIT,
broadcastTransaction,
- buildUnsignedMultiOutputPsbt,
+ buildUnsignedPsbt,
fetchUTXOs,
finalizePsbt,
getFeeRates,
nostrPubkeyToBitcoinAddress,
} from '@/lib/bitcoin';
import type { FeeRates } from '@/lib/bitcoin';
-import { minDonationForSplit, type ParsedCampaign, splitDonation } from '@/lib/campaign';
+import { CAMPAIGN_KIND, type ParsedCampaign } from '@/lib/campaign';
+import { withAgoraTag } from '@/lib/agoraNoteTags';
/** Supported on-chain fee speeds (mirrors {@link SendBitcoinDialog}). */
export type DonationFeeSpeed = 'fastest' | 'halfHour' | 'hour' | 'economy';
export interface DonateCampaignArgs {
campaign: ParsedCampaign;
- /** Total donation amount in satoshis. Split across recipients per the campaign weights. */
+ /** Donation amount in satoshis. */
amountSats: number;
- /** Optional public comment included in each kind 8333 receipt. */
+ /** Optional public comment included in the kind 8333 receipt. */
comment?: string;
/** Fee speed for the on-chain tx. Default: `halfHour`. */
feeSpeed?: DonationFeeSpeed;
@@ -34,9 +34,7 @@ export interface DonateCampaignResult {
txid: string;
/** On-chain fee paid in satoshis. */
fee: number;
- /** Number of recipients that received funds in the tx. */
- recipientCount: number;
- /** Total sent to recipients (donation amount; excludes fee). */
+ /** Sats paid to the campaign wallet (excludes fee and donor change). */
totalSats: number;
/** Whether the kind 8333 donation receipt published successfully. */
receiptPublished: boolean;
@@ -58,17 +56,29 @@ function errorMessage(error: unknown): string {
}
/**
- * Mutation hook that sends a single multi-output Bitcoin transaction to all
- * of a campaign's recipients (split per their weights), broadcasts it via
- * mempool.space, and then publishes a single kind 8333 onchain-zap receipt
- * for the transaction referencing the campaign's addressable coordinate and
- * listing every recipient under its own `p` tag.
+ * Mutation hook that donates to a campaign by paying its declared wallet
+ * endpoint with a single Bitcoin transaction, then publishing a kind 8333
+ * donation receipt referencing the campaign's addressable coordinate.
*
- * Returns an async function that throws on any pre-broadcast failure
- * (insufficient funds, signer not available, dust, etc.). Once the tx is
- * broadcast, the function always resolves: a kind 8333 publish failure is
- * reported in {@link DonateCampaignResult.receiptPublished} rather than
- * thrown, because the donation itself is already final on-chain.
+ * The campaign's `w` tag drives the destination:
+ *
+ * - **on-chain** (`bc1q…` / `bc1p…`) — the donor's client builds a
+ * single-output PSBT paying the campaign address, broadcasts it, then
+ * publishes a kind 8333 receipt with no `p` tags (campaigns are not
+ * Nostr-identity recipients; verification matches tx outputs against
+ * the campaign's `w` address).
+ * - **silent payment** (`sp1…`) — this hook refuses the request.
+ * Donating to a silent-payment campaign requires a BIP-352-aware
+ * wallet that derives a fresh one-time output from the SP code; the
+ * in-app Taproot signer does not support that. Donors are directed to
+ * an external wallet via a copy/QR affordance instead, and no Nostr
+ * event is ever published.
+ *
+ * Throws on any pre-broadcast failure (insufficient funds, signer not
+ * available, SP mode, etc.). Once the tx is broadcast, the function
+ * always resolves: a kind 8333 publish failure is reported in
+ * {@link DonateCampaignResult.receiptPublished} rather than thrown,
+ * because the donation itself is already final on-chain.
*/
export function useDonateCampaign() {
const { user } = useCurrentUser();
@@ -76,7 +86,7 @@ export function useDonateCampaign() {
const { mutateAsync: publishEvent } = useNostrPublish();
const queryClient = useQueryClient();
const { config } = useAppContext();
- const { esploraBaseUrl } = config;
+ const { esploraApis } = config;
async function donateToCampaign({
campaign,
@@ -92,31 +102,22 @@ export function useDonateCampaign() {
throw new Error('Enter a valid donation amount in satoshis.');
}
- // Split the donation across the campaign's payable recipients.
- const splits = splitDonation(campaign.recipients, amountSats, user.pubkey);
-
- // Dust guard: every output must clear the BIP-141 dust limit for P2TR.
- const tooSmall = splits.find((s) => s.amountSats < BITCOIN_DUST_LIMIT);
- if (tooSmall) {
- const min = minDonationForSplit(campaign.recipients, user.pubkey, BITCOIN_DUST_LIMIT);
+ if (campaign.wallet.mode === 'sp') {
throw new Error(
- `Donation is too small to split: each recipient would get less than the dust limit (${BITCOIN_DUST_LIMIT} sats). Minimum: ${min.toLocaleString()} sats.`,
+ 'This campaign uses silent payments. Donate from an external BIP-352-capable wallet using the QR code.',
);
}
- // Build the multi-output PSBT.
+ // Donor cannot donate to their own campaign (the tx output would just
+ // pay the donor's own wallet — an obvious foot-gun).
+ if (campaign.pubkey === user.pubkey) {
+ throw new Error('You cannot donate to your own campaign.');
+ }
+
const senderAddress = nostrPubkeyToBitcoinAddress(user.pubkey);
if (!senderAddress) throw new Error('Failed to derive your Bitcoin address.');
- const outputs = splits.map((s) => {
- const address = nostrPubkeyToBitcoinAddress(s.pubkey);
- if (!address) {
- throw new Error(`Failed to derive Bitcoin address for ${s.pubkey.slice(0, 8)}…`);
- }
- return { address, amountSats: s.amountSats };
- });
-
- const [utxos, rates] = await Promise.all([fetchUTXOs(senderAddress, esploraBaseUrl), getFeeRates(esploraBaseUrl)]);
+ const [utxos, rates] = await Promise.all([fetchUTXOs(senderAddress, esploraApis), getFeeRates(esploraApis)]);
if (utxos.length === 0) {
throw new Error('Your Bitcoin wallet has no spendable funds.');
}
@@ -124,9 +125,10 @@ export function useDonateCampaign() {
let signedHex: string;
let fee: number;
try {
- const unsigned = buildUnsignedMultiOutputPsbt(
+ const unsigned = buildUnsignedPsbt(
user.pubkey,
- outputs,
+ campaign.wallet.value,
+ amountSats,
utxos,
feeRateForSpeed(rates, feeSpeed),
);
@@ -140,38 +142,25 @@ export function useDonateCampaign() {
}
const txHex = finalizePsbt(signedHex);
- const txid = await broadcastTransaction(txHex, esploraBaseUrl);
+ const txid = await broadcastTransaction(txHex, esploraApis);
- // Publish a single kind 8333 receipt covering the whole transaction. The
- // event lists every recipient under its own `p` tag; the `amount` tag is
- // the combined total paid to all recipients (i.e. the full donation,
- // excluding the donor's change). Per-recipient amounts are recomputed
- // from the on-chain tx at display time by matching each recipient's
- // derived Taproot address against the tx outputs.
- //
- // The on-chain tx is already final at this point; we record a publish
- // failure rather than throwing so the donor sees a successful result
- // even if the relay hiccups.
- const totalSats = splits.reduce((sum, s) => sum + s.amountSats, 0);
+ // Publish the kind 8333 receipt. Per NIP.md §Kind 33863 §Donation flow,
+ // campaign donation receipts MUST NOT carry `p` tags — the recipient is
+ // the campaign's `w` wallet, not a Nostr identity. Viewers verify by
+ // matching tx outputs against the campaign's `w` address.
let receiptPublished = false;
let receiptPublishError: string | undefined;
try {
await publishEvent({
kind: 8333,
content: comment,
- tags: [
+ tags: withAgoraTag([
['i', `bitcoin:tx:${txid}`],
- ...splits.map((s) => ['p', s.pubkey]),
- ['amount', String(totalSats)],
+ ['amount', String(amountSats)],
['a', campaign.aTag],
- ['K', String(campaign.event.kind)],
- [
- 'alt',
- splits.length === 1
- ? `Donation to ${campaign.title}: ${totalSats.toLocaleString()} sats`
- : `Donation to ${campaign.title}: ${totalSats.toLocaleString()} sats across ${splits.length} recipients`,
- ],
- ],
+ ['K', String(CAMPAIGN_KIND)],
+ ['alt', `Donation to ${campaign.title}: ${amountSats.toLocaleString()} sats`],
+ ]),
});
receiptPublished = true;
} catch (error) {
@@ -183,13 +172,33 @@ export function useDonateCampaign() {
queryClient.invalidateQueries({ queryKey: ['bitcoin-balance'] });
queryClient.invalidateQueries({ queryKey: ['bitcoin-txs'] });
queryClient.invalidateQueries({ queryKey: ['onchain-zaps'] });
- queryClient.invalidateQueries({ queryKey: ['campaign-donations', campaign.aTag] });
+ // The receipts query in useCampaignDonations is keyed
+ // ['campaign-donations', 'events', aTag]; also invalidate the looser
+ // ['campaign-donations'] prefix so any related downstream queries refresh.
+ queryClient.invalidateQueries({ queryKey: ['campaign-donations', 'events', campaign.aTag] });
+ queryClient.invalidateQueries({ queryKey: ['campaign-donations'] });
+ // The campaign may be attached to an organization via an `A` tag; refresh
+ // the org's activity feed so the donation shows up there too.
+ const orgATag = campaign.event.tags.find(([n]) => n === 'A')?.[1];
+ if (orgATag) {
+ queryClient.invalidateQueries({ queryKey: ['organization-activity', orgATag] });
+ }
+ // Campaign list views (and per-campaign progress bars) read totals via
+ // useCampaignDonations, which keys its address-balance lookup under
+ // ['bitcoin-balance', 'campaign', …]. The broader ['bitcoin-balance']
+ // invalidation above already covers it.
+ queryClient.invalidateQueries({ queryKey: ['campaigns'] });
+ queryClient.invalidateQueries({ queryKey: ['campaigns-all'] });
+ // Donations (kind 8333 receipts) surface in the home Agora activity
+ // feed (useAgoraFeed). Without invalidating it the new donation only
+ // appears after a manual refresh.
+ queryClient.invalidateQueries({ queryKey: ['agora-feed'] });
+ queryClient.invalidateQueries({ queryKey: ['mixed-feed'] });
return {
txid,
fee,
- recipientCount: splits.length,
- totalSats,
+ totalSats: amountSats,
receiptPublished,
receiptPublishError,
};
diff --git a/src/hooks/useFeaturedOrganizations.ts b/src/hooks/useFeaturedOrganizations.ts
index c9fc1735..c11a8cbe 100644
--- a/src/hooks/useFeaturedOrganizations.ts
+++ b/src/hooks/useFeaturedOrganizations.ts
@@ -1,7 +1,8 @@
import { useNostr } from '@nostrify/react';
import { useQuery } from '@tanstack/react-query';
-import type { NostrEvent } from '@nostrify/nostrify';
+import type { NostrEvent, NostrFilter } from '@nostrify/nostrify';
+import { useOrganizationModeration } from '@/hooks/useOrganizationModeration';
import {
COMMUNITY_DEFINITION_KIND,
parseCommunityEvent,
@@ -9,56 +10,126 @@ import {
} from '@/lib/communityUtils';
import { dedupeAddressableLatest } from '@/lib/addressableEvents';
-/**
- * Hand-curated list of featured organization authors.
- *
- * We query all organizations authored by these accounts in one relay request,
- * then latest-wins dedupe addressable revisions client-side.
- */
-export const FEATURED_ORGANIZATION_AUTHORS = [
- '932614571afcbad4d17a191ee281e39eebbb41b93fac8fd87829622aeb112f4d',
-] as const;
-
export interface FeaturedOrganization {
community: ParsedCommunity;
event: NostrEvent;
}
/**
- * Fetch featured organizations by author.
+ * Parse a kind 34550 coordinate string (`34550::`) into its
+ * pubkey and d-tag components. Returns `null` for malformed coords so the
+ * caller can skip them without crashing the query.
+ */
+function parseCoord(coord: string): { pubkey: string; dTag: string } | null {
+ const colon1 = coord.indexOf(':');
+ if (colon1 < 0) return null;
+ const colon2 = coord.indexOf(':', colon1 + 1);
+ if (colon2 < 0) return null;
+ const pubkey = coord.slice(colon1 + 1, colon2);
+ const dTag = coord.slice(colon2 + 1);
+ if (!pubkey || !dTag) return null;
+ return { pubkey, dTag };
+}
+
+/**
+ * Fetch the featured organizations selected by Agora moderators.
*
- * One author-filtered query is more reliable than pinning individual event IDs
- * because kind 34550 definitions are addressable and can be revised.
+ * Featured selection rides the shared `agora.moderation` namespace (kind
+ * 1985 NIP-32 labels): a moderator publishes a `featured` label tagging
+ * the organization's `34550::` coordinate, and the home/communities
+ * page surfaces it here. A `hidden` label on the same coord always wins —
+ * even if `featured` is set, a hidden org never reaches this list.
+ *
+ * Coords are grouped by author into one filter per unique author, then OR'd
+ * into a single `nostr.query` call. For the typical case (a small handful
+ * of featured orgs across a few authors) this stays one relay round-trip.
+ * Results are sorted by the `created_at` of the latest `featured` label so
+ * moderators control ordering by simply re-publishing the label to bump an
+ * org to the top.
*/
export function useFeaturedOrganizations() {
const { nostr } = useNostr();
+ const { data: moderation, isReady: moderationReady } = useOrganizationModeration();
+
+ // Derive the curated coord set: featured minus hidden, sorted by the
+ // recency of the `featured` label. No cap — the moderator pack controls
+ // how many orgs surface.
+ const featuredCoords = moderationReady
+ ? Array.from(moderation.featuredCoords)
+ .filter((coord) => !moderation.hiddenCoords.has(coord))
+ .sort(
+ (a, b) =>
+ (moderation.featuredOrder.get(b) ?? 0) -
+ (moderation.featuredOrder.get(a) ?? 0),
+ )
+ : [];
+
+ // Include the coord set in the query key so the cache busts whenever the
+ // curation changes (mutations invalidate `featured-organizations` too, so
+ // there's no one-tick gap).
+ const featuredCoordsKey = featuredCoords.join('|');
return useQuery({
- queryKey: ['featured-organizations', FEATURED_ORGANIZATION_AUTHORS.join(',')],
+ queryKey: ['featured-organizations', featuredCoordsKey],
+ enabled: moderationReady,
queryFn: async ({ signal }) => {
- const combinedSignal = AbortSignal.any([signal, AbortSignal.timeout(8000)]);
+ if (featuredCoords.length === 0) return [];
- const events = await nostr.query(
- [{
+ // Group coord d-tags by author so we can issue one filter per author
+ // instead of one per coord. Most featured orgs cluster around a few
+ // founders, so this typically collapses to a single-digit number of
+ // filters in one round-trip.
+ const dTagsByAuthor = new Map();
+ for (const coord of featuredCoords) {
+ const parsed = parseCoord(coord);
+ if (!parsed) continue;
+ const bucket = dTagsByAuthor.get(parsed.pubkey);
+ if (bucket) {
+ bucket.push(parsed.dTag);
+ } else {
+ dTagsByAuthor.set(parsed.pubkey, [parsed.dTag]);
+ }
+ }
+ if (dTagsByAuthor.size === 0) return [];
+
+ const filters: NostrFilter[] = Array.from(dTagsByAuthor.entries()).map(
+ ([pubkey, dTags]) => ({
kinds: [COMMUNITY_DEFINITION_KIND],
- authors: [...FEATURED_ORGANIZATION_AUTHORS],
- limit: 60,
- }],
- { signal: combinedSignal },
+ authors: [pubkey],
+ '#d': dTags,
+ }),
);
- const entries: FeaturedOrganization[] = [];
+ const combinedSignal = AbortSignal.any([signal, AbortSignal.timeout(8000)]);
+ const events = await nostr.query(filters, { signal: combinedSignal });
+
+ // Latest-wins dedupe of addressable revisions, then index by coord so
+ // we can return them in the moderator-controlled `featuredOrder`.
+ const byCoord = new Map();
for (const event of dedupeAddressableLatest(events)) {
const community = parseCommunityEvent(event);
if (!community) continue;
- entries.push({ community, event });
+ byCoord.set(community.aTag, { community, event });
}
- entries.sort((a, b) => b.event.created_at - a.event.created_at);
-
- return entries;
+ // Preserve the moderator's chosen ordering by walking `featuredCoords`
+ // (already sorted newest-label-first) and emitting entries in that
+ // order. Drops coords whose underlying 34550 event we couldn't fetch
+ // (e.g. it was deleted or never reached the queried relays).
+ const ordered: FeaturedOrganization[] = [];
+ for (const coord of featuredCoords) {
+ const entry = byCoord.get(coord);
+ if (entry) ordered.push(entry);
+ }
+ return ordered;
},
- // 5 minutes — featured list is hand-curated, doesn't churn.
+ // Featured org definitions don't change often — orgs publish a
+ // new revision when their banner or description changes, not minute
+ // to minute — so a generous staleTime makes back-navigation to
+ // /communities feel instant. The moderation hook explicitly
+ // invalidates this key on mutation, so moderator-driven churn is
+ // still visible immediately.
staleTime: 5 * 60_000,
+ gcTime: 60 * 60_000,
});
}
diff --git a/src/hooks/useFeedTab.ts b/src/hooks/useFeedTab.ts
index e7057740..65f351c9 100644
--- a/src/hooks/useFeedTab.ts
+++ b/src/hooks/useFeedTab.ts
@@ -5,8 +5,7 @@ import { getStorageKey } from '@/lib/storageKey';
/**
* Manages the active feed tab for a specific feed page, persisting
- * the selection in sessionStorage so it survives navigation within
- * the same browser session.
+ * the selection in localStorage so it survives reloads and new sessions.
*
* Each feed page should pass a unique `feedId` (e.g. 'home', 'vines', 'videos').
*
@@ -24,17 +23,17 @@ export function useFeedTab(
const [activeTab, setActiveTab] = useState(() => {
const defaultTab = (user ? 'follows' : 'world') as T;
try {
- const stored = sessionStorage.getItem(key);
+ const stored = localStorage.getItem(key);
if (stored) {
if (feedId === 'home' && stored === 'network') {
- sessionStorage.setItem(key, defaultTab);
+ localStorage.setItem(key, defaultTab);
return defaultTab;
}
if (!validTabs || validTabs.includes(stored as T)) {
return stored as T;
}
}
- } catch { /* sessionStorage unavailable */ }
+ } catch { /* localStorage unavailable */ }
// Validate the default tab against validTabs. If it's not in the list,
// fall back to the last valid tab (typically 'global').
if (validTabs && !validTabs.includes(defaultTab)) {
@@ -45,7 +44,7 @@ export function useFeedTab(
const setTab = useCallback((tab: T) => {
setActiveTab(tab);
- try { sessionStorage.setItem(key, tab); } catch { /* ignore */ }
+ try { localStorage.setItem(key, tab); } catch { /* ignore */ }
}, [key]);
return [activeTab, setTab];
diff --git a/src/hooks/useFormatMoney.ts b/src/hooks/useFormatMoney.ts
index 775e78cf..75e8aa1f 100644
--- a/src/hooks/useFormatMoney.ts
+++ b/src/hooks/useFormatMoney.ts
@@ -34,7 +34,7 @@ export interface FormatMoneyResult {
* failed, the function falls back to the sats representation so we never block
* the UI on a network round-trip.
*
- * The BTC price is fetched via TanStack Query with a `['btc-price', esploraBaseUrl]`
+ * The BTC price is fetched via TanStack Query with a `['btc-price', esploraApis]`
* key — the same key used by the wallet, zap dialogs, and on-chain zap flows — so
* a single request is deduped across the whole app.
*/
@@ -44,8 +44,8 @@ export function useFormatMoney(): FormatMoneyResult {
// Reuse the shared price query so all callers share one cached fetch.
const { data: btcPrice } = useQuery({
- queryKey: ['btc-price', config.esploraBaseUrl],
- queryFn: () => fetchBtcPrice(config.esploraBaseUrl),
+ queryKey: ['btc-price', config.esploraApis],
+ queryFn: ({ signal }) => fetchBtcPrice(config.esploraApis, signal),
// Prices move; 60 s is fine for display formatting.
staleTime: 60_000,
// Don't pop a UI error if the price endpoint is down; we just fall back to sats.
diff --git a/src/hooks/useHdBtcPrice.ts b/src/hooks/useHdBtcPrice.ts
new file mode 100644
index 00000000..48cd553f
--- /dev/null
+++ b/src/hooks/useHdBtcPrice.ts
@@ -0,0 +1,31 @@
+import { useQuery } from '@tanstack/react-query';
+
+import { fetchBlockbookBtcPrice } from '@/lib/hdwallet/blockbook';
+import { useAppContext } from '@/hooks/useAppContext';
+
+/**
+ * Spot BTC→USD price for the HD wallet, sourced from Trezor Blockbook's
+ * WebSocket `getCurrentFiatRates` method.
+ *
+ * Why a dedicated hook instead of the app-wide {@link useBtcPrice}?
+ *
+ * `/wallet` deliberately isolates its network surface to the single
+ * Blockbook endpoint the user has configured — no Esplora, no
+ * mempool.space `/v1/prices`. This hook keeps that contract: if Blockbook
+ * is reachable, the HD wallet has everything it needs; if it isn't,
+ * errors surface in one place instead of being split across two
+ * unrelated APIs.
+ *
+ * Cache key is keyed on the Blockbook base URL so changes in app settings
+ * trigger a fresh fetch without manual invalidation.
+ */
+export function useHdBtcPrice() {
+ const { config } = useAppContext();
+ const { blockbookBaseUrl } = config;
+ return useQuery({
+ queryKey: ['hd-btc-price', blockbookBaseUrl],
+ queryFn: ({ signal }) => fetchBlockbookBtcPrice(blockbookBaseUrl, 'usd', signal),
+ refetchInterval: 60_000,
+ staleTime: 30_000,
+ });
+}
diff --git a/src/hooks/useHdWallet.ts b/src/hooks/useHdWallet.ts
new file mode 100644
index 00000000..16955918
--- /dev/null
+++ b/src/hooks/useHdWallet.ts
@@ -0,0 +1,295 @@
+import { useCallback, useMemo } from 'react';
+import { useQuery, useQueryClient } from '@tanstack/react-query';
+
+import { useAppContext } from '@/hooks/useAppContext';
+import { useSecureLocalStorage } from '@/hooks/useSecureLocalStorage';
+import { useHdWalletAccess, type HdWalletAvailability } from '@/hooks/useHdWalletAccess';
+import { useHdWalletSp } from '@/hooks/useHdWalletSp';
+import {
+ deriveReceiveAddress,
+ deriveSilentPaymentAddress,
+ type DerivedAddress,
+ type SilentPaymentAddress,
+} from '@/lib/hdwallet/derivation';
+import {
+ type AccountScanResult,
+ buildHdTransactions,
+ type HdTransaction,
+ scanAccount,
+} from '@/lib/hdwallet/scan';
+import type { SPStorageDocument } from '@/lib/hdwallet/sp/storage';
+
+// ---------------------------------------------------------------------------
+// Persisted UI cursor (per user)
+// ---------------------------------------------------------------------------
+//
+// We persist a single integer per user: the "preferred receive index" — the
+// index of the address we are currently advertising on the wallet page.
+// The chain-scan source of truth is `firstUnusedIndex` (from Blockbook), but
+// if the user explicitly bumps to a fresh address we honour that until the
+// chain catches up.
+
+const CURSOR_KEY = (pubkey: string) => `hdwallet:cursor:${pubkey}`;
+
+interface PersistedCursor {
+ /** Currently-displayed receive index. */
+ receiveIndex: number;
+}
+
+const DEFAULT_CURSOR: PersistedCursor = { receiveIndex: 0 };
+
+// ---------------------------------------------------------------------------
+// Query refresh cadence
+// ---------------------------------------------------------------------------
+
+/**
+ * Re-scan every 60 seconds. With Blockbook, a refresh is exactly 2 HTTP
+ * calls (`/xpub` + `/utxo`) regardless of wallet size, so a faster refresh
+ * is cheap. We pick 60s as a UX compromise between immediacy and politeness
+ * to the public Blockbook host.
+ */
+const REFRESH_INTERVAL_MS = 60_000;
+
+// ---------------------------------------------------------------------------
+// Return shape
+// ---------------------------------------------------------------------------
+
+export interface UseHdWalletResult {
+ /** Availability status — mirrors `useHdWalletAccess`. */
+ availability: HdWalletAvailability;
+ /** Currently-advertised receive address (the one the UI shows). */
+ currentReceiveAddress?: DerivedAddress;
+ /**
+ * BIP-352 silent payment address (`sp1q…`) for this wallet. Static — a
+ * single identifier the user can publish and reuse forever. Undefined
+ * unless `availability.status === 'available'`.
+ */
+ silentPaymentAddress?: SilentPaymentAddress;
+ /** Full scan result — UTXOs, used addresses, etc. */
+ scan?: AccountScanResult;
+ /** Aggregated wallet-level transaction history (newest first). */
+ transactions?: HdTransaction[];
+ /** Confirmed + pending balance in sats. */
+ totalBalance: number;
+ /** Pending (mempool) balance in sats. */
+ pendingBalance: number;
+ /**
+ * Confirmed balance of silent-payment UTXOs only, in sats. Already included
+ * in `totalBalance` — this field is exposed for the UI breakdown.
+ */
+ silentPaymentBalance: number;
+ /** The persisted SP UTXO document, if loaded. */
+ silentPaymentStorage?: SPStorageDocument;
+ /** Initial scan in progress. */
+ isLoading: boolean;
+ /** Scan currently fetching (initial or background refresh). */
+ isFetching: boolean;
+ /** Scan error, if any. */
+ error: unknown;
+ /** Trigger a manual scan refresh. */
+ refetch: () => Promise;
+ /** Advance the receive cursor to the next unused address. Persisted. */
+ nextReceiveAddress: () => DerivedAddress | undefined;
+ /**
+ * Drop the given SP UTXOs from local storage and republish so other
+ * devices stay in sync. Call after a successful spend that consumed
+ * silent-payment UTXOs — see `useHdWalletSp.pruneSpentUtxos`.
+ */
+ pruneSpentSilentPaymentUtxos: (spent: ReadonlyArray<{ txid: string; vout: number }>) => void;
+}
+
+// ---------------------------------------------------------------------------
+// Hook
+// ---------------------------------------------------------------------------
+
+/**
+ * Top-level HD wallet hook backed by Trezor's Blockbook indexer.
+ *
+ * The entire scan (balance, used addresses, tx history, UTXOs) comes from
+ * two HTTP calls to the configured Blockbook server:
+ *
+ * - `GET /api/v2/xpub/?details=txs&tokens=used`
+ * - `GET /api/v2/utxo/ `
+ *
+ * No fallback to other indexers, no client-side gap-limit walking. If
+ * Blockbook is unreachable the wallet surfaces the error.
+ *
+ * The hook is safe to call regardless of login state — non-nsec logins
+ * return `availability.status !== 'available'` without doing any derivation
+ * or network work.
+ */
+export function useHdWallet(): UseHdWalletResult {
+ const { config } = useAppContext();
+ const { blockbookBaseUrl } = config;
+ const availability = useHdWalletAccess();
+ const queryClient = useQueryClient();
+ const sp = useHdWalletSp();
+
+ const pubkey = availability.status === 'available' ? availability.pubkey : '';
+ const account = availability.status === 'available' ? availability.account : undefined;
+ const nsecBytes = availability.status === 'available' ? availability.nsecBytes : undefined;
+
+ // ── Persisted receive cursor ─────────────────────────────────
+ const [cursor, setCursor] = useSecureLocalStorage(
+ pubkey ? CURSOR_KEY(pubkey) : 'hdwallet:cursor:none',
+ DEFAULT_CURSOR,
+ );
+
+ // ── Scan query ───────────────────────────────────────────────
+ const scanKey = ['hdwallet-scan', blockbookBaseUrl, pubkey];
+ const {
+ data: scan,
+ isLoading: scanLoading,
+ isFetching: scanFetching,
+ error: scanError,
+ refetch: refetchScan,
+ } = useQuery({
+ queryKey: scanKey,
+ queryFn: async ({ signal }) => {
+ if (!account || !pubkey) throw new Error('HD wallet account unavailable');
+ return scanAccount(account, blockbookBaseUrl, signal);
+ },
+ enabled: !!account && pubkey !== '',
+ refetchInterval: REFRESH_INTERVAL_MS,
+ staleTime: REFRESH_INTERVAL_MS / 2,
+ refetchOnWindowFocus: false,
+ });
+
+ // ── Transaction history (derived; zero extra fetches) ────────
+ //
+ // Combines BIP-86 transactions (scanned from Blockbook) with silent-payment
+ // receives (discovered by the BIP-352 scanner). SP UTXOs carry a real
+ // block timestamp when one is available (sourced from Blockbook by the
+ // SP orchestrator at scan time, or backfilled on subsequent loads). When
+ // a UTXO is missing `time` — older docs written before this field
+ // existed, or scans that ran while Blockbook was unreachable — we fall
+ // back to a synthetic estimate from height using a fixed anchor
+ // (block 800,000 ≈ 2023-07-23T00:00:00Z, average 10-minute spacing).
+ // The synthetic estimate is clamped to "now" so it never reports a future
+ // timestamp (real average block time is shorter than 600s, so the naive
+ // estimate drifts noticeably ahead of wall-clock as cumulative blocks
+ // accumulate).
+ const transactions = useMemo(() => {
+ if (!scan && !sp.storage) return undefined;
+
+ // Build the SP outpoint → value map (active + archived) so the BIP-86
+ // tx classifier can detect transactions that spent our SP UTXOs and
+ // mark them as sends instead of mis-attributing the BIP-86 change
+ // output as an unsolicited receive.
+ const spOutpoints = new Map();
+ const archivedSpUtxos = sp.storage?.spent ?? [];
+ for (const u of sp.storage?.utxos ?? []) {
+ spOutpoints.set(`${u.txid}:${u.vout}`, u.value);
+ }
+ for (const u of archivedSpUtxos) {
+ // Don't clobber a value already recorded from the active set (in the
+ // unlikely event of overlap).
+ if (!spOutpoints.has(`${u.txid}:${u.vout}`)) {
+ spOutpoints.set(`${u.txid}:${u.vout}`, u.value);
+ }
+ }
+
+ const bip86 = scan ? buildHdTransactions(scan, spOutpoints) : [];
+
+ // Group SP UTXOs by txid and sum to keep the row shape consistent with
+ // the rest of the wallet (one row per tx, not per output). Include
+ // archived (spent) UTXOs so the receive history doesn't disappear when
+ // a UTXO is later spent — the original receive is still real wallet
+ // activity worth showing.
+ const spByTxid = new Map<
+ string,
+ { amount: number; height: number; time?: number }
+ >();
+ const allSpUtxos = [...(sp.storage?.utxos ?? []), ...archivedSpUtxos];
+ for (const u of allSpUtxos) {
+ const existing = spByTxid.get(u.txid);
+ if (existing) {
+ existing.amount += u.value;
+ // Same tx → same block; prefer any concrete time we find.
+ if (existing.time === undefined && u.time !== undefined) {
+ existing.time = u.time;
+ }
+ } else {
+ spByTxid.set(u.txid, { amount: u.value, height: u.height, time: u.time });
+ }
+ }
+
+ const HEIGHT_ANCHOR = 800_000;
+ const TIMESTAMP_ANCHOR = 1_690_070_400; // 2023-07-23T00:00:00Z (block 800,000)
+ const SECONDS_PER_BLOCK = 600;
+ const nowSeconds = Math.floor(Date.now() / 1000);
+
+ const spRows: HdTransaction[] = Array.from(spByTxid.entries()).map(([txid, info]) => {
+ const synthetic = TIMESTAMP_ANCHOR + (info.height - HEIGHT_ANCHOR) * SECONDS_PER_BLOCK;
+ const timestamp = info.time ?? Math.min(synthetic, nowSeconds);
+ return {
+ txid,
+ amount: info.amount,
+ type: 'receive',
+ // SP UTXOs come from confirmed P2TR outputs in mined blocks — mempool
+ // SP detection isn't supported by BlindBit (you need a confirmed block
+ // to derive `input_hash`), so any UTXO we've persisted is confirmed.
+ confirmed: true,
+ timestamp,
+ source: 'silent-payment',
+ };
+ });
+
+ const merged = [...bip86, ...spRows];
+ merged.sort((a, b) => {
+ if (!a.timestamp && !b.timestamp) return 0;
+ if (!a.timestamp) return -1;
+ if (!b.timestamp) return 1;
+ return b.timestamp - a.timestamp;
+ });
+ return merged;
+ }, [scan, sp.storage]);
+
+ // ── Current receive address ──────────────────────────────────
+ const currentReceiveAddress = useMemo(() => {
+ if (!account) return undefined;
+ const chainNextUnused = scan?.receive.firstUnusedIndex ?? 0;
+ const resolved = Math.max(chainNextUnused, cursor.receiveIndex);
+ return deriveReceiveAddress(account, resolved);
+ }, [account, scan, cursor.receiveIndex]);
+
+ // ── Silent payment address (static; depends only on nsec) ────
+ const silentPaymentAddress = useMemo(() => {
+ if (!nsecBytes) return undefined;
+ return deriveSilentPaymentAddress(nsecBytes);
+ }, [nsecBytes]);
+
+ // ── Advance to next receive address ──────────────────────────
+ const nextReceiveAddress = useCallback((): DerivedAddress | undefined => {
+ if (!account) return undefined;
+ const chainNextUnused = scan?.receive.firstUnusedIndex ?? 0;
+ const current = Math.max(chainNextUnused, cursor.receiveIndex);
+ const next = current + 1;
+ setCursor({ receiveIndex: next });
+ return deriveReceiveAddress(account, next);
+ }, [account, scan, cursor.receiveIndex, setCursor]);
+
+ // ── Unified refetch ──────────────────────────────────────────
+ const refetch = useCallback(async () => {
+ await queryClient.invalidateQueries({ queryKey: ['hdwallet-scan'] });
+ return refetchScan();
+ }, [queryClient, refetchScan]);
+
+ return {
+ availability,
+ currentReceiveAddress,
+ silentPaymentAddress,
+ scan,
+ transactions,
+ totalBalance: (scan?.totalBalance ?? 0) + sp.balance,
+ pendingBalance: scan?.pendingBalance ?? 0,
+ silentPaymentBalance: sp.balance,
+ silentPaymentStorage: sp.storage,
+ isLoading: scanLoading,
+ isFetching: scanFetching,
+ error: scanError,
+ refetch,
+ nextReceiveAddress,
+ pruneSpentSilentPaymentUtxos: sp.pruneSpentUtxos,
+ };
+}
diff --git a/src/hooks/useHdWalletAccess.ts b/src/hooks/useHdWalletAccess.ts
new file mode 100644
index 00000000..0586571a
--- /dev/null
+++ b/src/hooks/useHdWalletAccess.ts
@@ -0,0 +1,67 @@
+import { useMemo } from 'react';
+import { useNostrLogin } from '@nostrify/react/login';
+import { nip19 } from 'nostr-tools';
+
+import { useCurrentUser } from '@/hooks/useCurrentUser';
+import { deriveAccountFromNsec, type HdAccount } from '@/lib/hdwallet/derivation';
+
+/**
+ * Aggregate availability of the HD wallet for the active login.
+ *
+ * The HD wallet derives all of its keys from the user's raw Nostr secret key.
+ * Only the `nsec` login type stores that key in a form we can read; both the
+ * NIP-07 browser extension and NIP-46 remote bunker keep the key elsewhere
+ * (the extension never exposes it; the bunker never sends it). Without the
+ * raw secret we cannot derive child keys, so the HD wallet is gated to nsec
+ * logins.
+ */
+export type HdWalletAvailability =
+ /** User logged in with nsec — full HD wallet access. */
+ | { status: 'available'; account: HdAccount; nsecBytes: Uint8Array; pubkey: string }
+ /** Not logged in at all. */
+ | { status: 'logged-out' }
+ /** Logged in, but the login type doesn't expose the secret key. */
+ | { status: 'unsupported'; loginType: 'extension' | 'bunker' | 'other' };
+
+/**
+ * Hook that returns whether the HD wallet is usable for the active login,
+ * and (when usable) the derived BIP86 account.
+ *
+ * **Security note**: the returned `account` holds private extended keys in
+ * memory for as long as the consumer holds the reference. This is unavoidable
+ * for a wallet that signs locally — the nsec is already in plaintext
+ * localStorage in the same threat model.
+ *
+ * The hook intentionally re-derives on every login change rather than caching
+ * across logouts, so a fresh login starts from a clean derivation.
+ */
+export function useHdWalletAccess(): HdWalletAvailability {
+ const { user } = useCurrentUser();
+ const { logins } = useNostrLogin();
+ const activeLogin = logins[0];
+
+ return useMemo(() => {
+ if (!user || !activeLogin) return { status: 'logged-out' };
+
+ if (activeLogin.type !== 'nsec') {
+ const loginType =
+ activeLogin.type === 'extension'
+ ? 'extension'
+ : activeLogin.type === 'bunker'
+ ? 'bunker'
+ : 'other';
+ return { status: 'unsupported', loginType };
+ }
+
+ // Decode the nsec → 32-byte secret key, then derive the BIP86 account.
+ const decoded = nip19.decode(activeLogin.data.nsec);
+ if (decoded.type !== 'nsec') {
+ // Defensive — should be impossible given the discriminated union.
+ return { status: 'unsupported', loginType: 'other' };
+ }
+ const nsecBytes = decoded.data;
+ const account = deriveAccountFromNsec(nsecBytes);
+
+ return { status: 'available', account, nsecBytes, pubkey: user.pubkey };
+ }, [user, activeLogin]);
+}
diff --git a/src/hooks/useHdWalletSp.ts b/src/hooks/useHdWalletSp.ts
new file mode 100644
index 00000000..bbd10bf1
--- /dev/null
+++ b/src/hooks/useHdWalletSp.ts
@@ -0,0 +1,826 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useNostr } from '@nostrify/react';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+
+import { useAppContext } from '@/hooks/useAppContext';
+import { useCurrentUser } from '@/hooks/useCurrentUser';
+import { useHdWalletAccess } from '@/hooks/useHdWalletAccess';
+import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
+import {
+ deriveSilentPaymentKeys,
+ type SilentPaymentKeys,
+} from '@/lib/hdwallet/derivation';
+import { fetchBlockTime, fetchUtxoSpentStatus } from '@/lib/hdwallet/blockbook';
+import { fetchBlockEntries, fetchTipHeight } from '@/lib/hdwallet/sp/indexer';
+import { scanBatch, type SPMatchedUtxo } from '@/lib/hdwallet/sp/scanner';
+import {
+ EMPTY_SP_STORAGE,
+ archiveSpentUtxos,
+ matchedUtxoToStored,
+ mergeUtxos,
+ parseSPStorage,
+ pruneSpUtxos,
+ serializeSPStorage,
+ type SPStorageDocument,
+ type SPStoredUtxo,
+ spStorageBalance,
+ spStorageDTag,
+ SP_STORAGE_VERSION,
+} from '@/lib/hdwallet/sp/storage';
+
+// ---------------------------------------------------------------------------
+// HD wallet — silent-payments orchestrator
+// ---------------------------------------------------------------------------
+//
+// Pulls everything below together so the HD wallet UI can:
+//
+// 1. Read the persisted SP UTXO state (NIP-78 / kind 30078, NIP-44 encrypted).
+// 2. Run a chain scan against a BlindBit Oracle v2 indexer in user-driven
+// ranges (`scanRange({ fromHeight, toHeight? })`).
+// 3. Persist freshly discovered UTXOs back to the encrypted NIP-78 event
+// as they're found.
+//
+// Spending and sending are deliberately not in scope — see
+// `src/lib/hdwallet/sp/crypto.ts` for the rationale.
+// ---------------------------------------------------------------------------
+
+/** Default scan window when the user clicks "Scan recent" with no explicit bounds. */
+const DEFAULT_RECENT_SCAN_BLOCKS = 144; // ~24 hours of mainnet blocks.
+
+/**
+ * Maximum distinct txids to check per manual reconcile click. Bounds the
+ * Blockbook WS fan-out on wallets with many stored SP UTXOs — remaining
+ * entries are picked up on subsequent clicks.
+ */
+const MAX_RECONCILE_UTXOS = 50;
+
+export interface UseHdWalletSpResult {
+ /** Whether the feature is usable. False when not logged in with nsec, or no indexer configured. */
+ enabled: boolean;
+ /** Concrete reason `enabled` is false, when applicable. */
+ unavailableReason?: 'logged-out' | 'unsupported-signer' | 'no-indexer';
+
+ /** The wallet's SP key material. `undefined` until the hook is enabled. */
+ keys?: SilentPaymentKeys;
+
+ /** The decrypted persisted UTXO document. `undefined` while loading. */
+ storage?: SPStorageDocument;
+ /** Sum of all stored SP UTXO values, in satoshis. */
+ balance: number;
+ /** True until the first storage load resolves. */
+ isLoading: boolean;
+
+ /** Active scan progress, if any. */
+ scanProgress?: {
+ fromHeight: number;
+ toHeight: number;
+ currentHeight: number;
+ matchesFound: number;
+ };
+ /** True while `scanRange` (or a derived helper) is running. */
+ isScanning: boolean;
+ /** Error from the most recent scan, if it failed. Cleared on next scan start. */
+ scanError?: Error;
+
+ /** Tip height as reported by the indexer (cached, lightly refreshed). */
+ tipHeight?: number;
+
+ /**
+ * Scan a contiguous block range. `toHeight` defaults to current tip.
+ *
+ * `includeSpent` opts into a deeper rescan that also considers UTXOs
+ * already spent on-chain. Matches against spent outputs land in the
+ * `spent` archive rather than the active set — useful for recovering
+ * historical receive rows when the wallet's local doc was pruned
+ * without archiving (e.g. by a build that predates the archive logic).
+ */
+ scanRange: (args: {
+ fromHeight: number;
+ toHeight?: number;
+ includeSpent?: boolean;
+ }) => Promise;
+ /** Scan the most recent `DEFAULT_RECENT_SCAN_BLOCKS` blocks (or fewer if newer). */
+ scanRecent: () => Promise;
+ /** Abort an in-flight scan. */
+ cancelScan: () => void;
+
+ /**
+ * Drop the given SP UTXOs from local storage and republish the NIP-78
+ * document so other devices stay in sync.
+ *
+ * Called by the send flow after a successful broadcast — Blockbook's
+ * xpub-scoped scan can't observe silent-payment outputs, so without
+ * this the wallet has no way to learn that an SP UTXO it just spent is
+ * gone. Failure to call it (or to publish) results in stale balance and
+ * subsequent double-spend attempts.
+ */
+ pruneSpentUtxos: (spent: ReadonlyArray<{ txid: string; vout: number }>) => void;
+
+ /** Progress for an in-flight reconcile (or the last completed one). */
+ reconcileProgress?: {
+ /** Number of UTXOs queued for checking this run. */
+ total: number;
+ /** UTXOs whose Blockbook lookup has completed. */
+ checked: number;
+ /** UTXOs flagged as spent and pruned. */
+ prunedSoFar: number;
+ };
+ /** True while a reconcile pass is in flight. */
+ isReconciling: boolean;
+ /** Error from the most recent reconcile, cleared on next start. */
+ reconcileError?: Error;
+
+ /**
+ * Walk the stored SP UTXO set, ask Blockbook whether each one is still
+ * unspent, and prune any that are spent. Capped at 50 distinct txids per
+ * call to bound network fan-out — remaining entries are reconciled on
+ * the next click.
+ *
+ * Exists because Blockbook's xpub scan can't observe SP outputs, so a
+ * UTXO spent outside the local send flow (different device, pre-fix
+ * build) would otherwise linger in the encrypted NIP-78 doc forever.
+ *
+ * Resolves with the number of UTXOs pruned this pass.
+ */
+ reconcileSpentUtxos: () => Promise;
+}
+
+const EMPTY_RESULT: UseHdWalletSpResult = {
+ enabled: false,
+ balance: 0,
+ isLoading: false,
+ isScanning: false,
+ scanRange: async () => {},
+ scanRecent: async () => {},
+ cancelScan: () => {},
+ pruneSpentUtxos: () => {},
+ isReconciling: false,
+ reconcileSpentUtxos: async () => 0,
+};
+
+export function useHdWalletSp(): UseHdWalletSpResult {
+ const { config } = useAppContext();
+ const { user } = useCurrentUser();
+ const { nostr } = useNostr();
+ const access = useHdWalletAccess();
+ const queryClient = useQueryClient();
+
+ const indexerUrl = (config.bip352IndexerUrl ?? '').trim();
+ const blockbookUrl = (config.blockbookBaseUrl ?? '').trim();
+ const pubkey = access.status === 'available' ? access.pubkey : '';
+ const nsecBytes = access.status === 'available' ? access.nsecBytes : undefined;
+
+ // ── SP key derivation (memoised) ─────────────────────────────
+ const keys = useMemo(() => {
+ if (!nsecBytes) return undefined;
+ return deriveSilentPaymentKeys(nsecBytes);
+ }, [nsecBytes]);
+
+ // ── Availability gating ──────────────────────────────────────
+ // Compute the early-return shape *before* hooks branch so React's
+ // hook-order rule stays happy.
+ const unavailableReason: UseHdWalletSpResult['unavailableReason'] =
+ access.status === 'logged-out'
+ ? 'logged-out'
+ : access.status === 'unsupported'
+ ? 'unsupported-signer'
+ : indexerUrl === ''
+ ? 'no-indexer'
+ : undefined;
+ const enabled = unavailableReason === undefined;
+
+ // ── Stable d-tag for the persisted UTXO event ────────────────
+ const dTag = spStorageDTag(config.appId);
+
+ // ── Tip-height query (cheap, refreshed every 60s when enabled) ──
+ const { data: tipHeight } = useQuery({
+ queryKey: ['hdwallet-sp-tip', indexerUrl],
+ queryFn: ({ signal }) => fetchTipHeight(indexerUrl, signal),
+ enabled,
+ refetchInterval: 60_000,
+ staleTime: 30_000,
+ refetchOnWindowFocus: false,
+ });
+
+ // ── Persisted storage event ──────────────────────────────────
+ //
+ // Two-stage query like `useEncryptedSettings`: stage 1 fetches the raw
+ // event from relays, stage 2 NIP-44-decrypts it. We key the parse stage
+ // on the event id so a stale parse doesn't survive an event update.
+ const storageEventQuery = useQuery({
+ queryKey: ['hdwallet-sp-event', pubkey, dTag],
+ queryFn: async () => {
+ if (!user) return null;
+ const events = await nostr.query([
+ {
+ kinds: [30078],
+ authors: [user.pubkey],
+ '#d': [dTag],
+ limit: 1,
+ },
+ ]);
+ if (events.length === 0) return null;
+ // Pick the most recent if multiple relays returned different versions.
+ return events.reduce((latest, current) =>
+ current.created_at > latest.created_at ? current : latest,
+ );
+ },
+ enabled: enabled && !!user,
+ staleTime: 5 * 60 * 1000,
+ refetchOnWindowFocus: true,
+ refetchOnMount: false,
+ refetchOnReconnect: false,
+ });
+
+ const storageDocQuery = useQuery({
+ queryKey: ['hdwallet-sp-doc', storageEventQuery.data?.id ?? '(empty)'],
+ queryFn: async () => {
+ const event = storageEventQuery.data;
+ if (!event) return { ...EMPTY_SP_STORAGE };
+ if (!user?.signer.nip44) return { ...EMPTY_SP_STORAGE };
+ if (!event.content) return { ...EMPTY_SP_STORAGE };
+ try {
+ const plaintext = await user.signer.nip44.decrypt(user.pubkey, event.content);
+ return parseSPStorage(plaintext);
+ } catch (err) {
+ console.warn('Failed to decrypt SP storage event; treating as empty:', err);
+ return { ...EMPTY_SP_STORAGE };
+ }
+ },
+ enabled: enabled && !!user,
+ staleTime: 0,
+ refetchOnMount: false,
+ refetchOnWindowFocus: false,
+ refetchOnReconnect: false,
+ });
+
+ // ── Optimistic in-memory copy ────────────────────────────────
+ //
+ // The relay round-trip on each scan-progress tick would be unacceptable, so
+ // we maintain an in-memory document that the scanner updates synchronously
+ // and the relay republish coalesces every few seconds. The `?? loaded`
+ // pattern below means we drop the optimistic copy as soon as a newer
+ // event lands.
+ const optimisticRef = useRef(null);
+ const [optimisticVersion, setOptimisticVersion] = useState(0);
+ void optimisticVersion; // touched so React knows to re-render on bump
+
+ const storage = useMemo(() => {
+ if (!enabled) return undefined;
+ if (!storageDocQuery.data) return undefined;
+ // Prefer the optimistic copy when it's at least as fresh as relays.
+ const loaded = storageDocQuery.data;
+ const opt = optimisticRef.current;
+ if (!opt) return loaded;
+ // Heuristic: optimistic wins when it's caught up scan-wise AND it
+ // accounts for at least as many entries (active + archived) as the
+ // loaded copy. The combined-count check matters because prunes shrink
+ // `utxos` while growing `spent`, and deep rescans grow `spent` without
+ // touching `utxos`.
+ const optTotal = opt.utxos.length + (opt.spent?.length ?? 0);
+ const loadedTotal = loaded.utxos.length + (loaded.spent?.length ?? 0);
+ if (opt.scanHeight >= loaded.scanHeight && optTotal >= loadedTotal) {
+ return opt;
+ }
+ return loaded;
+ }, [enabled, storageDocQuery.data]);
+
+ // ── Mutation: persist a new document to relays ───────────────
+ //
+ // Optionally accepts a list of `(txid, vout)` entries that were spent
+ // locally; these are stripped from the remote-merged document too, so
+ // the canonical published copy actually loses the spent UTXOs instead
+ // of having them merged back in by `mergeUtxos` (which is insert-only).
+ const publishStorage = useMutation({
+ mutationFn: async (args: {
+ next: SPStorageDocument;
+ spent?: ReadonlyArray<{ txid: string; vout: number }>;
+ }) => {
+ const { next, spent } = args;
+ if (!user) throw new Error('not logged in');
+ if (!user.signer.nip44) throw new Error('signer does not support NIP-44');
+ // Always read-modify-write off the freshest event so a concurrent device
+ // doesn't lose its progress.
+ const prev = await fetchFreshEvent(nostr, {
+ kinds: [30078],
+ authors: [user.pubkey],
+ '#d': [dTag],
+ });
+ let merged: SPStorageDocument = next;
+ if (prev?.content) {
+ try {
+ const decrypted = await user.signer.nip44.decrypt(user.pubkey, prev.content);
+ const remote = parseSPStorage(decrypted);
+ // Prune any spent UTXOs from the remote *before* the merge —
+ // otherwise insert-only `mergeUtxos` would re-add them.
+ const remoteUtxos = spent && spent.length > 0
+ ? pruneSpUtxos(remote.utxos, spent)
+ : remote.utxos;
+ // Merge the spent archive: union both sides' archives, plus the
+ // entries we just pruned out of `remote.utxos`. Without this a
+ // racing relay copy could resurrect a row in `utxos` that the
+ // local prune already classified as spent, or drop archive
+ // entries the local copy intentionally retained for history.
+ const localArchive = next.spent ?? [];
+ const remoteArchive = remote.spent ?? [];
+ const archiveByKey = new Map();
+ for (const u of remoteArchive) archiveByKey.set(`${u.txid}:${u.vout}`, u);
+ for (const u of localArchive) {
+ if (!archiveByKey.has(`${u.txid}:${u.vout}`)) {
+ archiveByKey.set(`${u.txid}:${u.vout}`, u);
+ }
+ }
+ // Pull pruned-from-remote rows into the archive too — they're
+ // outpoints we know are spent but the remote didn't realise.
+ if (spent && spent.length > 0) {
+ const spentKeys = new Set(spent.map((s) => `${s.txid}:${s.vout}`));
+ for (const u of remote.utxos) {
+ const k = `${u.txid}:${u.vout}`;
+ if (spentKeys.has(k) && !archiveByKey.has(k)) {
+ archiveByKey.set(k, u);
+ }
+ }
+ }
+ merged = {
+ version: SP_STORAGE_VERSION,
+ scanHeight: Math.max(remote.scanHeight, next.scanHeight),
+ utxos: mergeUtxos(remoteUtxos, next.utxos),
+ spent: Array.from(archiveByKey.values()),
+ };
+ } catch {
+ // Treat undecryptable remote as empty rather than blocking the write.
+ }
+ }
+ const ciphertext = await user.signer.nip44.encrypt(user.pubkey, serializeSPStorage(merged));
+ const unsigned = {
+ kind: 30078,
+ content: ciphertext,
+ tags: [
+ ['d', dTag],
+ ['title', `${config.appName} HD Wallet — Silent Payment UTXOs`],
+ ['client', config.appName, ...(config.client ? [config.client] : [])],
+ ['alt', 'Encrypted silent-payment UTXO set for the HD wallet'],
+ ],
+ created_at: Math.floor(Date.now() / 1000),
+ };
+ const signed = await user.signer.signEvent(unsigned);
+ // Best-effort publish — the local optimistic copy is still authoritative.
+ nostr.event(signed, { signal: AbortSignal.timeout(5000) }).catch((e) => {
+ console.warn('Failed to publish SP storage event:', e);
+ });
+ return { merged, signed };
+ },
+ onSuccess: ({ merged, signed }) => {
+ // Update query caches in-place to avoid an immediate refetch round-trip.
+ queryClient.setQueryData(['hdwallet-sp-event', pubkey, dTag], signed);
+ queryClient.setQueryData(['hdwallet-sp-doc', signed.id], merged);
+ },
+ });
+
+ // ── Scan state ───────────────────────────────────────────────
+ const [scanProgress, setScanProgress] = useState();
+ const [scanError, setScanError] = useState();
+ const [isScanning, setIsScanning] = useState(false);
+ const scanAbortRef = useRef(null);
+ // Throttle timer for republishing storage during a long scan. Armed once
+ // when there's unpublished progress; subsequent `scheduleRepublish` calls
+ // while the timer is armed are no-ops. This guarantees a publish at least
+ // every `REPUBLISH_THROTTLE_MS` during a continuous scan — unlike a
+ // trailing debounce, which keeps resetting and may never fire.
+ const republishTimerRef = useRef | null>(null);
+ // True when `optimisticRef.current` contains a *match* that hasn't been
+ // republished yet. Scan-height advancement alone does NOT set this — we
+ // don't want to fire a relay event every 5s during a 10k-block walk over
+ // empty blocks. The final flush in `scanRange`'s `finally` publishes
+ // unconditionally so the advanced `scanHeight` still gets checkpointed.
+ const republishDirtyRef = useRef(false);
+
+ const cancelScan = useCallback(() => {
+ scanAbortRef.current?.abort();
+ }, []);
+
+ const flushRepublish = useCallback(() => {
+ if (republishTimerRef.current) {
+ clearTimeout(republishTimerRef.current);
+ republishTimerRef.current = null;
+ }
+ const doc = optimisticRef.current;
+ if (!doc) return;
+ republishDirtyRef.current = false;
+ publishStorage.mutate({ next: doc });
+ }, [publishStorage]);
+
+ const REPUBLISH_THROTTLE_MS = 5000;
+ const scheduleRepublish = useCallback(() => {
+ // Already armed — let the existing timer fire. This is the difference
+ // from a debounce: we don't reset on every call, so a tight scan loop
+ // still publishes on the original schedule.
+ if (republishTimerRef.current) return;
+ // Nothing worth publishing — don't arm.
+ if (!republishDirtyRef.current) return;
+ republishTimerRef.current = setTimeout(() => {
+ republishTimerRef.current = null;
+ const doc = optimisticRef.current;
+ if (!doc) return;
+ if (!republishDirtyRef.current) return;
+ republishDirtyRef.current = false;
+ publishStorage.mutate({ next: doc });
+ }, REPUBLISH_THROTTLE_MS);
+ }, [publishStorage]);
+
+ // ── The core scan loop ───────────────────────────────────────
+ const scanRange = useCallback(
+ async ({ fromHeight, toHeight, includeSpent = false }) => {
+ if (!enabled || !keys) return;
+ if (!storage) return; // Wait for the first load — caller can retry.
+ if (!Number.isInteger(fromHeight) || fromHeight < 0) {
+ throw new Error(`Invalid fromHeight: ${fromHeight}`);
+ }
+
+ // Resolve the upper bound — default to current tip.
+ const resolvedTo = toHeight ?? tipHeight ?? (await fetchTipHeight(indexerUrl));
+ if (!Number.isInteger(resolvedTo) || resolvedTo < fromHeight) {
+ throw new Error(`Invalid toHeight: ${resolvedTo}`);
+ }
+
+ // Abort any prior in-flight scan.
+ scanAbortRef.current?.abort();
+ const controller = new AbortController();
+ scanAbortRef.current = controller;
+
+ setScanError(undefined);
+ setIsScanning(true);
+ setScanProgress({
+ fromHeight,
+ toHeight: resolvedTo,
+ currentHeight: fromHeight,
+ matchesFound: 0,
+ });
+
+ // Seed the optimistic doc from the current snapshot so we don't lose
+ // existing UTXOs (or archive entries) while scanning a sparse range.
+ optimisticRef.current = {
+ version: SP_STORAGE_VERSION,
+ scanHeight: storage.scanHeight,
+ utxos: storage.utxos.slice(),
+ spent: (storage.spent ?? []).slice(),
+ };
+
+ let matchesFound = 0;
+ let highestContiguousScanned = fromHeight - 1;
+
+ try {
+ for (let h = fromHeight; h <= resolvedTo; h++) {
+ if (controller.signal.aborted) break;
+
+ const entries = await fetchBlockEntries(
+ indexerUrl,
+ h,
+ controller.signal,
+ includeSpent,
+ );
+ let blockMatches: SPMatchedUtxo[] = [];
+ if (entries.length > 0) {
+ blockMatches = await scanBatch(entries, keys.bscan, keys.Bspend, {
+ signal: controller.signal,
+ });
+ }
+
+ // Merge matches into the optimistic doc.
+ if (blockMatches.length > 0) {
+ // Fetch the real block timestamp from Blockbook so we can stamp
+ // every fresh UTXO with `time`. The HD wallet's UI falls back to
+ // a synthetic `block-height × 600s` estimate when this is
+ // missing, but that estimate drifts noticeably (often days) on
+ // recent blocks because real average block time is shorter than
+ // 600s, leading to "X days ago" labels that flip into the
+ // future. A single Blockbook lookup per matched block is cheap
+ // and fixes it.
+ let blockTime: number | undefined;
+ if (blockbookUrl) {
+ try {
+ blockTime = await fetchBlockTime(blockbookUrl, h, controller.signal);
+ } catch (err) {
+ // Best-effort: don't fail the whole scan because Blockbook
+ // is unreachable. The synthetic fallback still renders.
+ console.warn(`Failed to fetch block time for height ${h}:`, err);
+ }
+ }
+
+ // Partition matches into "still unspent" (active set) and
+ // "already spent at scan time" (archive). The archive entries
+ // are essential for the tx-history classifier to attribute the
+ // spending tx as a wallet send — without them a deep rescan is
+ // useless for history recovery.
+ const freshActive: SPStoredUtxo[] = [];
+ const freshArchive: SPStoredUtxo[] = [];
+ for (const m of blockMatches) {
+ const stored = matchedUtxoToStored(m);
+ const stamped =
+ blockTime !== undefined ? { ...stored, time: blockTime } : stored;
+ if (m.spent) freshArchive.push(stamped);
+ else freshActive.push(stamped);
+ }
+
+ const opt = optimisticRef.current!;
+ optimisticRef.current = {
+ version: SP_STORAGE_VERSION,
+ scanHeight: opt.scanHeight,
+ utxos: mergeUtxos(opt.utxos, freshActive),
+ spent: mergeUtxos(opt.spent ?? [], freshArchive),
+ };
+ matchesFound += blockMatches.length;
+ // New matches landed — arm the throttle so they reach relays
+ // within `REPUBLISH_THROTTLE_MS` even if the user closes the
+ // tab before the scan finishes.
+ republishDirtyRef.current = true;
+ }
+
+ // Forward the scan cursor as long as we advance contiguously from
+ // the start of this range.
+ if (h === highestContiguousScanned + 1) {
+ highestContiguousScanned = h;
+ const opt = optimisticRef.current!;
+ optimisticRef.current = {
+ ...opt,
+ scanHeight: Math.max(opt.scanHeight, highestContiguousScanned),
+ };
+ }
+
+ setScanProgress({
+ fromHeight,
+ toHeight: resolvedTo,
+ currentHeight: h,
+ matchesFound,
+ });
+ // Bump the optimistic-version state so `storage` recomputes.
+ setOptimisticVersion((v) => v + 1);
+
+ // Throttled relay republish — fires at most once per
+ // `REPUBLISH_THROTTLE_MS`, and only when new matches have landed
+ // since the last publish. Guarantees the user loses at most one
+ // throttle window of progress if they close the tab mid-scan,
+ // without flooding their signer on empty-block walks.
+ scheduleRepublish();
+ }
+ } catch (err) {
+ if (controller.signal.aborted) {
+ // Caller asked to cancel — not an error to surface.
+ } else {
+ setScanError(err instanceof Error ? err : new Error(String(err)));
+ }
+ } finally {
+ setIsScanning(false);
+ // Final flush — make sure the last scan progress reaches relays.
+ flushRepublish();
+ if (scanAbortRef.current === controller) {
+ scanAbortRef.current = null;
+ }
+ }
+ },
+ [enabled, keys, storage, tipHeight, indexerUrl, blockbookUrl, scheduleRepublish, flushRepublish],
+ );
+
+ const scanRecent = useCallback(async () => {
+ if (!enabled) return;
+ const tip = tipHeight ?? (await fetchTipHeight(indexerUrl));
+ const from = Math.max(0, tip - DEFAULT_RECENT_SCAN_BLOCKS + 1);
+ await scanRange({ fromHeight: from, toHeight: tip });
+ }, [enabled, indexerUrl, tipHeight, scanRange]);
+
+ const balance = useMemo(() => (storage ? spStorageBalance(storage) : 0), [storage]);
+
+ // Keep a stable ref to the latest storage so callbacks called from outside
+ // the React render cycle (e.g. the send dialog's mutation success handler)
+ // see the freshest UTXO set without forcing the callback to re-create.
+ const storageRef = useRef(storage);
+ storageRef.current = storage;
+
+ // ── Prune spent SP UTXOs after a successful broadcast ────────
+ //
+ // The send flow consumes one or more SP UTXOs but Blockbook's xpub scan
+ // can't observe them — they sit in the NIP-78 doc forever unless we
+ // remove them explicitly. Without this, `balance` would keep counting
+ // the spent UTXOs and the coin selector would offer them again on the
+ // next send (producing a "missing/spent input" broadcast failure), all
+ // while Blockbook's view of the BIP-86 change credits to total balance,
+ // so the wallet appears to *gain* money after a spend.
+ const pruneSpentUtxos = useCallback(
+ (spent) => {
+ if (!spent.length) return;
+ // Cancel any pending throttled republish — its document snapshot
+ // doesn't know about the prune. We're about to publish a strictly
+ // newer doc below, so the throttle's pending payload would be stale.
+ if (republishTimerRef.current) {
+ clearTimeout(republishTimerRef.current);
+ republishTimerRef.current = null;
+ }
+ republishDirtyRef.current = false;
+ const base = storageRef.current ?? optimisticRef.current;
+ if (!base) return;
+ // Archive (don't delete) the pruned entries so the transaction-history
+ // UI can still show their original receive row, and the send-vs-
+ // receive classifier in `buildHdTransactions` can attribute any
+ // future Blockbook tx that referenced one of these outpoints as a
+ // wallet send.
+ const next: SPStorageDocument = archiveSpentUtxos(base, spent);
+ optimisticRef.current = next;
+ setOptimisticVersion((v) => v + 1);
+ // Also write the pruned doc directly into the doc-query cache so
+ // the `storage` memo doesn't briefly fall back to the unpruned
+ // relay copy while the publish round-trip is in flight (the
+ // optimistic-preference heuristic uses `utxos.length` to decide
+ // freshness, and a prune *shrinks* the list).
+ const eventId = queryClient.getQueryData<{ id?: string } | null>([
+ 'hdwallet-sp-event',
+ pubkey,
+ dTag,
+ ])?.id;
+ if (eventId) {
+ queryClient.setQueryData(['hdwallet-sp-doc', eventId], next);
+ }
+ publishStorage.mutate({ next, spent });
+ },
+ [publishStorage, queryClient, pubkey, dTag],
+ );
+
+ // ── Manual reconcile of spent SP UTXOs against Blockbook ─────
+ //
+ // The send flow's prune (above) only catches UTXOs the *current* session
+ // spends. Anything spent before this code shipped — or spent on another
+ // device — sits in the encrypted NIP-78 doc forever, inflating the
+ // displayed balance and offering already-spent inputs to the next send.
+ //
+ // This action lets the user manually walk the stored set, ask Blockbook
+ // for each output's spent status, and drop the spent ones. Manual rather
+ // than on-load because we don't want to fire ≤50 WS calls on every wallet
+ // page mount; the scan dialog already exists as a "fix-up" UI surface.
+ const [isReconciling, setIsReconciling] = useState(false);
+ const [reconcileProgress, setReconcileProgress] = useState<
+ UseHdWalletSpResult['reconcileProgress']
+ >();
+ const [reconcileError, setReconcileError] = useState();
+ const reconcileAbortRef = useRef(null);
+
+ const reconcileSpentUtxos = useCallback<
+ UseHdWalletSpResult['reconcileSpentUtxos']
+ >(async () => {
+ if (!enabled || !blockbookUrl) return 0;
+ const current = storageRef.current;
+ if (!current || current.utxos.length === 0) return 0;
+
+ // Cap fan-out to MAX_RECONCILE_UTXOS distinct txids. We iterate the
+ // stored UTXO list (insertion order) and keep candidates until we hit
+ // the cap; remaining UTXOs are reconciled on the next click.
+ const distinctTxids = new Set();
+ const candidates: Array<{ txid: string; vout: number }> = [];
+ for (const u of current.utxos) {
+ if (!distinctTxids.has(u.txid) && distinctTxids.size >= MAX_RECONCILE_UTXOS) {
+ continue;
+ }
+ distinctTxids.add(u.txid);
+ candidates.push({ txid: u.txid, vout: u.vout });
+ }
+ if (candidates.length === 0) return 0;
+
+ // Abort any prior in-flight reconcile (e.g. user double-clicked).
+ reconcileAbortRef.current?.abort();
+ const controller = new AbortController();
+ reconcileAbortRef.current = controller;
+
+ setReconcileError(undefined);
+ setIsReconciling(true);
+ setReconcileProgress({ total: candidates.length, checked: 0, prunedSoFar: 0 });
+
+ try {
+ const spentMap = await fetchUtxoSpentStatus(
+ blockbookUrl,
+ candidates,
+ controller.signal,
+ );
+ if (controller.signal.aborted) return 0;
+
+ const spent: Array<{ txid: string; vout: number }> = [];
+ for (const c of candidates) {
+ if (spentMap.get(`${c.txid}:${c.vout}`) === true) spent.push(c);
+ }
+ setReconcileProgress({
+ total: candidates.length,
+ checked: candidates.length,
+ prunedSoFar: spent.length,
+ });
+
+ if (spent.length > 0) {
+ pruneSpentUtxos(spent);
+ }
+ return spent.length;
+ } catch (err) {
+ if (controller.signal.aborted) return 0;
+ const e = err instanceof Error ? err : new Error(String(err));
+ setReconcileError(e);
+ throw e;
+ } finally {
+ setIsReconciling(false);
+ if (reconcileAbortRef.current === controller) {
+ reconcileAbortRef.current = null;
+ }
+ }
+ }, [enabled, blockbookUrl, pruneSpentUtxos]);
+
+ // ── Backfill missing block timestamps ────────────────────────
+ //
+ // Older docs (written before SP UTXOs carried `time`) and any UTXOs that
+ // were stamped while Blockbook was unreachable arrive here without a
+ // timestamp, so the UI is forced to use the synthetic
+ // `block-height × 600s` estimate. That estimate drifts ~12 days into the
+ // future at current heights and renders as e.g. "-11d ago".
+ //
+ // Fix it once per session: on the first storage load that contains any
+ // un-stamped UTXOs, fetch their block timestamps from Blockbook,
+ // de-duplicated by height, and re-publish the document.
+ //
+ // Bounded to avoid hammering Blockbook on a wallet with hundreds of
+ // historical UTXOs — remaining entries get backfilled on subsequent
+ // sessions.
+ const backfillRanRef = useRef(false);
+ useEffect(() => {
+ if (!enabled) return;
+ if (!blockbookUrl) return;
+ if (!storage) return;
+ if (backfillRanRef.current) return;
+ if (isScanning) return; // Don't race with an in-flight scan.
+
+ const missing = storage.utxos.filter((u) => u.time === undefined);
+ if (missing.length === 0) {
+ backfillRanRef.current = true;
+ return;
+ }
+
+ backfillRanRef.current = true;
+ const controller = new AbortController();
+ const MAX_HEIGHTS = 50;
+
+ (async () => {
+ const heights = Array.from(new Set(missing.map((u) => u.height))).slice(0, MAX_HEIGHTS);
+ const heightTimes = new Map();
+ for (const h of heights) {
+ if (controller.signal.aborted) return;
+ try {
+ const t = await fetchBlockTime(blockbookUrl, h, controller.signal);
+ heightTimes.set(h, t);
+ } catch (err) {
+ // Skip this height; it will retry on a future session.
+ console.warn(`Failed to backfill block time for height ${h}:`, err);
+ }
+ }
+ if (heightTimes.size === 0) return;
+ if (controller.signal.aborted) return;
+
+ const next: SPStorageDocument = {
+ version: SP_STORAGE_VERSION,
+ scanHeight: storage.scanHeight,
+ utxos: storage.utxos.map((u) => {
+ if (u.time !== undefined) return u;
+ const t = heightTimes.get(u.height);
+ return t !== undefined ? { ...u, time: t } : u;
+ }),
+ };
+
+ // Mirror the scan-loop pattern: update the optimistic copy and
+ // republish so other devices pick up the backfilled timestamps.
+ optimisticRef.current = next;
+ setOptimisticVersion((v) => v + 1);
+ publishStorage.mutate({ next });
+ })();
+
+ return () => controller.abort();
+ // We deliberately depend only on `storage` and the static URLs — running
+ // once per fresh load is the goal.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [enabled, blockbookUrl, storage]);
+
+ // ── Assemble the public shape ───────────────────────────────
+ if (!enabled) {
+ return { ...EMPTY_RESULT, unavailableReason, keys };
+ }
+
+ return {
+ enabled,
+ keys,
+ storage,
+ balance,
+ isLoading: storageEventQuery.isLoading || storageDocQuery.isLoading,
+ scanProgress,
+ isScanning,
+ scanError,
+ tipHeight,
+ scanRange,
+ scanRecent,
+ cancelScan,
+ pruneSpentUtxos,
+ isReconciling,
+ reconcileProgress,
+ reconcileError,
+ reconcileSpentUtxos,
+ };
+}
diff --git a/src/hooks/useMixedFeed.ts b/src/hooks/useMixedFeed.ts
new file mode 100644
index 00000000..14e99339
--- /dev/null
+++ b/src/hooks/useMixedFeed.ts
@@ -0,0 +1,254 @@
+import { useCallback, useMemo } from 'react';
+import { useNostr } from '@nostrify/react';
+import { useInfiniteQuery } from '@tanstack/react-query';
+import type { NostrEvent, NostrFilter } from '@nostrify/nostrify';
+
+import { useAgoraFeed } from '@/hooks/useAgoraFeed';
+import { type FeedItem } from '@/hooks/useFeed';
+import { useFollowList } from '@/hooks/useFollowActions';
+import { useCurrentUser } from '@/hooks/useCurrentUser';
+import { useMuteList } from '@/hooks/useMuteList';
+import { getPaginationCursor, shouldHideFeedEvent, isRepostKind } from '@/lib/feedUtils';
+import { isEventMuted } from '@/lib/muteHelpers';
+import { parseRepostContent } from '@/lib/feedUtils';
+
+/**
+ * The three feed modes available on the home `/feed` page.
+ *
+ * - `agora` — Agora content only (campaigns, pledges, donations, comments
+ * on Agora entities, communities, `#Agora`-tagged notes).
+ * - `all-nostr` — global kind 1 stream from across the network plus the
+ * full Agora content mix, interleaved chronologically.
+ * - `following` — same as `all-nostr` but every layer is filtered to authors
+ * you follow.
+ */
+export type FeedMode = 'agora' | 'all-nostr' | 'following';
+
+const NOSTR_PAGE_SIZE = 30;
+/** Kinds included in the "all Nostr" / "following" kind 1 layer. */
+const NOSTR_LAYER_KINDS = [1, 6, 16];
+
+interface NostrLayerPage {
+ items: FeedItem[];
+ oldestTimestamp: number | null;
+ rawCount: number;
+}
+
+/**
+ * A deliberately broad kind 1 + reposts query for the home feed's
+ * non-Agora layers. Unlike `useFeed('global')`, which uses Ditto's
+ * curated `sort:hot` NIP-50 extension and is filtered by user feed
+ * settings, this query is a straight chronological pull of recent
+ * kind 1 notes (plus reposts) so "All Nostr" actually lives up to
+ * its name.
+ */
+function useNostrLayer({
+ enabled,
+ authors,
+}: {
+ enabled: boolean;
+ /** When provided, restrict to these authors (Following mode). */
+ authors?: string[];
+}) {
+ const { nostr } = useNostr();
+ const authorsKey = authors ? [...authors].sort().join(',') : '';
+ // If `authors` is provided but empty, the query is intentionally empty
+ // (e.g. the user follows nobody).
+ const authorsEmpty = authors !== undefined && authors.length === 0;
+ const queryEnabled = enabled && !authorsEmpty;
+
+ return useInfiniteQuery({
+ queryKey: ['nostr-layer', authorsKey],
+ queryFn: async ({ pageParam, signal: querySignal }) => {
+ const signal = AbortSignal.any([querySignal, AbortSignal.timeout(8_000)]);
+ const until = pageParam as number | undefined;
+ const now = Math.floor(Date.now() / 1000);
+
+ const filter: NostrFilter = {
+ kinds: NOSTR_LAYER_KINDS,
+ limit: NOSTR_PAGE_SIZE,
+ ...(authors && authors.length > 0 ? { authors } : {}),
+ ...(until ? { until } : {}),
+ };
+
+ const raw = await nostr.query([filter], { signal });
+ const valid = raw.filter((ev) => ev.created_at <= now);
+
+ const items: FeedItem[] = [];
+ for (const ev of valid) {
+ if (isRepostKind(ev.kind)) {
+ const embedded = parseRepostContent(ev);
+ if (embedded && embedded.created_at <= now) {
+ items.push({ event: embedded, repostedBy: ev.pubkey, sortTimestamp: ev.created_at });
+ }
+ } else {
+ items.push({ event: ev, sortTimestamp: ev.created_at });
+ }
+ }
+
+ const oldestTimestamp = valid.length > 0 ? getPaginationCursor(valid) : null;
+ return { items, oldestTimestamp, rawCount: valid.length };
+ },
+ initialPageParam: undefined as number | undefined,
+ getNextPageParam: (lastPage) => {
+ if (lastPage.rawCount === 0 || lastPage.oldestTimestamp === null) return undefined;
+ return lastPage.oldestTimestamp - 1;
+ },
+ enabled: queryEnabled,
+ staleTime: 30_000,
+ refetchOnWindowFocus: false,
+ placeholderData: (prev) => prev,
+ });
+}
+
+/**
+ * Orchestrates the three-mode home feed. Internally wires the appropriate
+ * combination of {@link useNostrLayer} (a broad chronological kind 1 +
+ * reposts query) and {@link useAgoraFeed} (Agora activity mix),
+ * interleaving their results chronologically and exposing a single
+ * pagination interface.
+ */
+export function useMixedFeed(mode: FeedMode, enabled: boolean) {
+ const { user } = useCurrentUser();
+ const { data: followData } = useFollowList();
+ const { muteItems } = useMuteList();
+
+ // In Following mode, both layers are filtered to authors the user
+ // follows (plus themselves, matching the network feed convention).
+ // Passing `authors: []` intentionally disables queries until the
+ // follow list has loaded — without this guard a logged-in user
+ // would briefly see the global mix before their follows arrive.
+ const followAuthors = useMemo(() => {
+ if (mode !== 'following') return undefined;
+ if (!user) return [];
+ const follows = followData?.pubkeys;
+ if (follows === undefined) return undefined; // still loading
+ return follows.length > 0 ? [...follows, user.pubkey] : [user.pubkey];
+ }, [mode, user, followData?.pubkeys]);
+
+ // Following mode is gated on the follow list being loaded.
+ const layersReady = enabled && (mode !== 'following' || followAuthors !== undefined);
+ const agoraOptions = useMemo(
+ () => (followAuthors !== undefined ? { authors: followAuthors } : undefined),
+ [followAuthors],
+ );
+ const agoraFeed = useAgoraFeed(layersReady, agoraOptions);
+
+ // Kind 1 layer: only used by 'all-nostr' and 'following'. 'agora' mode skips it.
+ const useNostr = mode === 'all-nostr' || mode === 'following';
+ const nostrLayer = useNostrLayer({
+ enabled: layersReady && useNostr,
+ authors: followAuthors,
+ });
+
+ // Flatten kind 1 layer pages → FeedItem[].
+ const nostrItems = useMemo(() => {
+ if (!useNostr) return [];
+ return nostrLayer.data?.pages.flatMap((page) => page.items) ?? [];
+ }, [useNostr, nostrLayer.data?.pages]);
+
+ // Flatten Agora layer events → FeedItem[].
+ const agoraItems = useMemo(
+ () => agoraFeed.events.map((event: NostrEvent) => ({ event, sortTimestamp: event.created_at })),
+ [agoraFeed.events],
+ );
+
+ // When the Nostr layer is active, clip the Agora layer to the recency
+ // window the Nostr layer has already loaded. Without this, the Nostr
+ // firehose advances its `until` cursor by minutes per page while the
+ // (sparser) Agora layer covers weeks per page — so at the bottom of
+ // the merged feed only Agora items remain, giving the impression that
+ // the "All Nostr" feed has degenerated back into the Agora-only feed.
+ //
+ // The clip floor is the oldest Nostr item we've loaded. Agora items
+ // older than that are held back until the Nostr layer paginates far
+ // enough to keep them company.
+ const nostrFloor = useMemo(() => {
+ if (!useNostr || nostrItems.length === 0) return null;
+ let oldest = nostrItems[0].sortTimestamp;
+ for (const item of nostrItems) {
+ if (item.sortTimestamp < oldest) oldest = item.sortTimestamp;
+ }
+ return oldest;
+ }, [useNostr, nostrItems]);
+
+ const visibleAgoraItems = useMemo(() => {
+ if (!useNostr) return agoraItems;
+ if (nostrFloor === null) return [];
+ return agoraItems.filter((item) => item.sortTimestamp >= nostrFloor);
+ }, [useNostr, agoraItems, nostrFloor]);
+
+ // Merge both layers, dedupe by event id, apply mute filter, sort newest-first.
+ const items = useMemo(() => {
+ const seen = new Map();
+ const consider = (item: FeedItem) => {
+ const key = item.repostedBy ? `repost-${item.repostedBy}-${item.event.id}` : item.event.id;
+ if (!key) return;
+ if (shouldHideFeedEvent(item.event)) return;
+ if (muteItems.length > 0 && isEventMuted(item.event, muteItems)) return;
+ const existing = seen.get(key);
+ if (!existing || item.sortTimestamp > existing.sortTimestamp) {
+ seen.set(key, item);
+ }
+ };
+ for (const item of visibleAgoraItems) consider(item);
+ for (const item of nostrItems) consider(item);
+ return Array.from(seen.values()).sort((a, b) => b.sortTimestamp - a.sortTimestamp);
+ }, [visibleAgoraItems, nostrItems, muteItems]);
+
+ // Unified pagination. The Agora layer is sparser than Nostr, so it
+ // exhausts its pages first. Once that happens, only Nostr keeps
+ // advancing — which is fine because we already clip Agora to the
+ // Nostr recency window. We only fetch Agora when the Nostr floor is
+ // about to dip below the Agora floor (i.e. we're about to scroll into
+ // a region where the Agora buffer is empty).
+ const agoraHasNext = agoraFeed.hasNextPage;
+ const agoraFetchNext = agoraFeed.fetchNextPage;
+ const nostrHasNext = nostrLayer.hasNextPage;
+ const nostrFetchNext = nostrLayer.fetchNextPage;
+
+ // Oldest Agora item currently loaded — used to decide whether we need
+ // to fetch more Agora when the Nostr cursor advances past it.
+ const agoraFloor = useMemo(() => {
+ if (agoraItems.length === 0) return null;
+ let oldest = agoraItems[0].sortTimestamp;
+ for (const item of agoraItems) {
+ if (item.sortTimestamp < oldest) oldest = item.sortTimestamp;
+ }
+ return oldest;
+ }, [agoraItems]);
+
+ const fetchNextPage = useCallback(async () => {
+ if (!useNostr) {
+ // Pure Agora mode — just advance Agora.
+ if (agoraHasNext) await agoraFetchNext();
+ return;
+ }
+ // Mixed mode: always advance Nostr (it's the dense layer driving the
+ // scroll). Only advance Agora if its floor is at or above the Nostr
+ // floor — i.e. the Agora buffer is on the verge of being uncovered
+ // by further Nostr pagination. Otherwise we'd fetch Agora pages we
+ // can't display yet because they fall below the visible window.
+ const advanceAgora =
+ agoraHasNext && agoraFloor !== null && nostrFloor !== null && agoraFloor >= nostrFloor;
+ await Promise.all([
+ nostrHasNext ? nostrFetchNext() : Promise.resolve(),
+ advanceAgora ? agoraFetchNext() : Promise.resolve(),
+ ]);
+ }, [useNostr, agoraHasNext, agoraFetchNext, agoraFloor, nostrHasNext, nostrFetchNext, nostrFloor]);
+
+ const hasNextPage = useNostr
+ ? !!nostrHasNext || !!agoraHasNext
+ : !!agoraHasNext;
+ const isFetchingNextPage = agoraFeed.isFetchingNextPage
+ || (useNostr && nostrLayer.isFetchingNextPage);
+ const isLoading = agoraFeed.isLoading || (useNostr && nostrLayer.isPending);
+
+ return {
+ items,
+ isLoading,
+ hasNextPage,
+ isFetchingNextPage,
+ fetchNextPage,
+ };
+}
diff --git a/src/hooks/useOnchainZap.ts b/src/hooks/useOnchainZap.ts
index 35e17e9f..b7223b27 100644
--- a/src/hooks/useOnchainZap.ts
+++ b/src/hooks/useOnchainZap.ts
@@ -8,6 +8,7 @@ import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useToast } from '@/hooks/useToast';
import { useAppContext } from '@/hooks/useAppContext';
import { notificationSuccess } from '@/lib/haptics';
+import { withAgoraTag } from '@/lib/agoraNoteTags';
import {
nostrPubkeyToBitcoinAddress,
fetchUTXOs,
@@ -74,7 +75,7 @@ export function useOnchainZap(
const { mutateAsync: publishEvent } = useNostrPublish();
const { toast } = useToast();
const { config } = useAppContext();
- const { esploraBaseUrl } = config;
+ const { esploraApis } = config;
const queryClient = useQueryClient();
const [isZapping, setIsZapping] = useState(false);
@@ -104,8 +105,8 @@ export function useOnchainZap(
// Fetch UTXOs and fee rates
const [utxos, rates] = await Promise.all([
- fetchUTXOs(senderAddress, esploraBaseUrl),
- getFeeRates(esploraBaseUrl),
+ fetchUTXOs(senderAddress, esploraApis),
+ getFeeRates(esploraApis),
]);
if (utxos.length === 0) {
@@ -137,7 +138,7 @@ export function useOnchainZap(
// Broadcast
setProgress('broadcasting');
- const txid = await broadcastTransaction(txHex, esploraBaseUrl);
+ const txid = await broadcastTransaction(txHex, esploraApis);
// Publish kind 8333 event
setProgress('publishing');
@@ -162,7 +163,7 @@ export function useOnchainZap(
const event = await publishEvent({
kind: 8333,
content: comment,
- tags,
+ tags: withAgoraTag(tags),
});
return { txid, amountSats, fee, event };
diff --git a/src/hooks/useOnchainZaps.ts b/src/hooks/useOnchainZaps.ts
index b9acbee0..40945847 100644
--- a/src/hooks/useOnchainZaps.ts
+++ b/src/hooks/useOnchainZaps.ts
@@ -4,6 +4,7 @@ import type { NostrEvent } from '@nostrify/nostrify';
import { fetchTxDetail, nostrPubkeyToBitcoinAddress } from '@/lib/bitcoin';
import { useAppContext } from '@/hooks/useAppContext';
+import { CAMPAIGN_KIND, parseCampaign } from '@/lib/campaign';
/** A single verified on-chain zap, with the amount that actually paid the recipient(s) on-chain. */
export interface OnchainZapEntry {
/** The kind 8333 event. */
@@ -13,15 +14,18 @@ export interface OnchainZapEntry {
/** Pubkey of the sender (the 8333 event author). */
senderPubkey: string;
/**
- * Pubkeys of the recipients (one per `p` tag). For legacy single-recipient
- * events this has length 1; for multi-output events (campaign donations,
- * community batch zaps) it has one entry per recipient.
+ * Pubkeys of the recipients (one per `p` tag). For identity zaps this has
+ * length 1 (or more for batch community zaps). For campaign donations
+ * (kind 33863 targets) this is always empty — campaigns are not
+ * Nostr-identity recipients and the receipt carries no `p` tags.
*/
recipientPubkeys: string[];
/**
- * Verified total in sats — sum of tx outputs that pay any of the listed
- * recipients' derived Taproot addresses. Excludes the sender's change
- * output even if some helpful soul tagged the sender as a recipient.
+ * Verified total in sats — sum of tx outputs paying any expected
+ * destination. In identity-recipient mode this is "any of the listed
+ * recipients' derived Taproot addresses." In campaign-wallet mode
+ * (target is a kind 33863 campaign) it is "outputs paying the campaign's
+ * `w` address." Excludes the sender's change output in either case.
*/
amountSats: number;
/** Sender's self-reported amount tag (may differ from verified). */
@@ -54,9 +58,9 @@ export function extractOnchainZapClaimedAmount(event: NostrEvent): number {
/**
* Parse the recipient pubkey(s) from a kind 8333 event.
*
- * Legacy single-recipient events have exactly one `p` tag; multi-output
- * events (campaigns, community batch zaps) list every recipient under its
- * own `p` tag. Returns the pubkeys in `p`-tag order, deduplicated.
+ * Identity-recipient events carry one or more `p` tags; campaign-donation
+ * events carry none (a campaign is not a Nostr identity). Returns the
+ * pubkeys in `p`-tag order, deduplicated.
*/
export function extractOnchainZapRecipients(event: NostrEvent): string[] {
const seen = new Set();
@@ -82,51 +86,94 @@ export function extractOnchainZapRecipient(event: NostrEvent): string {
return extractOnchainZapRecipients(event)[0] ?? '';
}
+/**
+ * Returns the addressable coordinate from the receipt's `a` tag if it
+ * points at a kind 33863 campaign, or null otherwise.
+ */
+function extractCampaignTarget(event: NostrEvent): string | null {
+ const aTag = event.tags.find(([n]) => n === 'a')?.[1];
+ if (typeof aTag !== 'string') return null;
+ if (!aTag.startsWith(`${CAMPAIGN_KIND}:`)) return null;
+ return aTag;
+}
+
/**
* Verify a kind 8333 on-chain zap event against the Bitcoin blockchain.
*
- * Returns the verified amount (sum of tx outputs paying any listed
- * recipient's derived Taproot address) and confirmation status. Returns
- * `null` if the event is malformed or the transaction cannot be verified.
+ * Returns the verified amount (sum of tx outputs paying the expected
+ * destination) and confirmation status. Returns `null` if the event is
+ * malformed or the transaction cannot be verified.
*
- * A verified amount of 0 means the transaction exists but does not pay
- * any listed recipient — callers should discard such events.
+ * Verification has two modes depending on the event shape:
*
- * @param event The kind 8333 event to verify.
- * @param esploraBaseUrl Esplora REST root used to fetch the tx detail.
+ * - **Identity-recipient mode** — event has one or more `p` tags and no
+ * campaign `a` tag. The expected destinations are the recipients'
+ * derived Taproot addresses (`nostrPubkeyToBitcoinAddress`).
+ * - **Campaign-wallet mode** — event has an `a` tag pointing at a kind
+ * 33863 campaign and no `p` tags. The expected destination is the
+ * campaign's declared `w` bech32(m) address. Silent-payment campaigns
+ * (`w` starts with `sp1…`) cannot be verified on-chain by definition
+ * and are rejected.
+ *
+ * @param event The kind 8333 event to verify.
+ * @param esploraApis Ordered list of Esplora REST roots tried with failover.
+ * @param campaignWallet When the receipt targets a kind 33863 campaign,
+ * the campaign's `w` value. Required for campaign-wallet
+ * mode; ignored otherwise.
+ * @param signal Optional abort signal (e.g. from TanStack Query).
*/
export async function verifyOnchainZap(
event: NostrEvent,
- esploraBaseUrl: string,
+ esploraApis: string[],
+ campaignWallet?: string,
+ signal?: AbortSignal,
): Promise {
const txid = extractOnchainZapTxid(event);
- const recipientPubkeys = extractOnchainZapRecipients(event);
- if (!txid || recipientPubkeys.length === 0) return null;
+ if (!txid) return null;
- // Reject self-zaps: the sender already controls each derived destination
- // address, so any output paying the sender is change. We strip the sender
- // from the recipient set rather than discarding the whole event so a tx
- // that pays the sender plus legitimate recipients still verifies for the
- // others.
- const externalRecipients = recipientPubkeys.filter((p) => p !== event.pubkey);
- if (externalRecipients.length === 0) return null;
+ const campaignTarget = extractCampaignTarget(event);
- const recipientAddresses = new Set();
- for (const pubkey of externalRecipients) {
- const address = nostrPubkeyToBitcoinAddress(pubkey);
- if (address) recipientAddresses.add(address);
+ // Determine the set of expected destination addresses for verification.
+ const expectedAddresses = new Set();
+ let recipientPubkeys: string[] = [];
+
+ if (campaignTarget) {
+ // Campaign-wallet mode: match outputs against the campaign's declared
+ // `w` address. Silent-payment campaigns publish no receipts, so a
+ // receipt referencing an `sp1…` campaign is malformed.
+ if (!campaignWallet) return null;
+ if (campaignWallet.startsWith('sp1')) return null;
+ expectedAddresses.add(campaignWallet);
+ // No identity recipients in this mode.
+ } else {
+ // Identity-recipient mode.
+ const recipients = extractOnchainZapRecipients(event);
+ if (recipients.length === 0) return null;
+
+ // Reject self-zaps: the sender already controls each derived destination
+ // address, so any output paying the sender is change. Strip the sender
+ // from the recipient set rather than discarding the whole event so a tx
+ // that pays the sender plus legitimate recipients still verifies for
+ // the others.
+ recipientPubkeys = recipients.filter((p) => p !== event.pubkey);
+ if (recipientPubkeys.length === 0) return null;
+
+ for (const pubkey of recipientPubkeys) {
+ const address = nostrPubkeyToBitcoinAddress(pubkey);
+ if (address) expectedAddresses.add(address);
+ }
+ if (expectedAddresses.size === 0) return null;
}
- if (recipientAddresses.size === 0) return null;
let detail;
try {
- detail = await fetchTxDetail(txid, esploraBaseUrl);
+ detail = await fetchTxDetail(txid, esploraApis, signal);
} catch {
return null;
}
const amountSats = detail.outputs
- .filter((o) => o.address && recipientAddresses.has(o.address))
+ .filter((o) => o.address && expectedAddresses.has(o.address))
.reduce((sum, o) => sum + o.value, 0);
if (amountSats === 0) return null;
@@ -139,7 +186,7 @@ export async function verifyOnchainZap(
event,
txid,
senderPubkey: event.pubkey,
- recipientPubkeys: externalRecipients,
+ recipientPubkeys,
amountSats: effectiveClaim,
claimedAmountSats: claimed,
comment: event.content,
@@ -151,17 +198,30 @@ export async function verifyOnchainZap(
/**
* Query all kind 8333 on-chain zaps targeting a specific event, then verify
* each one on-chain. Returns only verified entries (deduped by txid).
+ *
+ * When the target is a kind 33863 campaign, verification matches against
+ * the campaign's `w` wallet address rather than derived recipient
+ * addresses. Silent-payment campaigns (`w` starts with `sp1…`) return an
+ * empty list — donations to those campaigns are unlinkable by design.
*/
export function useOnchainZaps(target: NostrEvent | undefined) {
const { nostr } = useNostr();
const { config } = useAppContext();
- const { esploraBaseUrl } = config;
+ const { esploraApis } = config;
const isAddressable = target && target.kind >= 30000 && target.kind < 40000;
const dTag = isAddressable
? target.tags.find(([n]) => n === 'd')?.[1] ?? ''
: '';
const aCoord = isAddressable && target ? `${target.kind}:${target.pubkey}:${dTag}` : '';
+ // If the target is a campaign, parse its `w` wallet for campaign-wallet
+ // mode verification. Silent-payment campaigns short-circuit to "no
+ // verifiable donations" — we don't issue any verifier queries.
+ const campaignWallet = target && target.kind === CAMPAIGN_KIND
+ ? parseCampaign(target)?.wallet
+ : undefined;
+ const isSilentPayment = campaignWallet?.mode === 'sp';
+
// Step 1: fetch the raw kind 8333 events for this target
const eventsQuery = useQuery({
queryKey: ['onchain-zaps', 'events', target?.id ?? '', aCoord],
@@ -196,16 +256,18 @@ export function useOnchainZaps(target: NostrEvent | undefined) {
return Array.from(byTxid.values());
},
- enabled: !!target,
+ enabled: !!target && !isSilentPayment,
staleTime: 30_000,
});
// Step 2: verify each event on-chain (parallel, cached per event)
const events = eventsQuery.data ?? [];
+ const walletValue = campaignWallet?.value;
const verifications = useQueries({
queries: events.map((event) => ({
- queryKey: ['onchain-zaps', 'verify', esploraBaseUrl, event.id],
- queryFn: () => verifyOnchainZap(event, esploraBaseUrl),
+ queryKey: ['onchain-zaps', 'verify', esploraApis, event.id, walletValue ?? ''],
+ queryFn: ({ signal }: { signal: AbortSignal }) =>
+ verifyOnchainZap(event, esploraApis, walletValue, signal),
staleTime: 60_000,
})),
});
@@ -218,7 +280,7 @@ export function useOnchainZaps(target: NostrEvent | undefined) {
verified.sort((a, b) => b.amountSats - a.amountSats);
const totalSats = verified.reduce((s, v) => s + v.amountSats, 0);
- const isLoading = eventsQuery.isLoading || verifications.some((v) => v.isLoading);
+ const isLoading = !isSilentPayment && (eventsQuery.isLoading || verifications.some((v) => v.isLoading));
return {
zaps: verified,
@@ -236,17 +298,35 @@ export function useOnchainZaps(target: NostrEvent | undefined) {
*
* Returns `undefined` while loading, `null` if the event fails verification
* (invalid tx, wrong recipient, self-zap, etc.), or the entry.
+ *
+ * If the receipt targets a kind 33863 campaign, pass the campaign's `w`
+ * value as `campaignWallet` so the verifier can match outputs against the
+ * campaign address. Without it, campaign-targeted receipts always fail
+ * verification (no `p` tags, no fallback).
*/
-export function useVerifiedOnchainZap(event: NostrEvent | undefined): OnchainZapEntry | null | undefined {
+export function useVerifiedOnchainZap(
+ event: NostrEvent | undefined,
+ campaignWallet?: string,
+): OnchainZapEntry | null | undefined {
const { config } = useAppContext();
- const { esploraBaseUrl } = config;
+ const { esploraApis } = config;
const txid = event ? extractOnchainZapTxid(event) : null;
- const hasRecipient = event ? extractOnchainZapRecipients(event).length > 0 : false;
+ const targetsCampaign = event ? !!extractCampaignTarget(event) : false;
+ const hasIdentityRecipient = event ? extractOnchainZapRecipients(event).length > 0 : false;
+
+ // Enable verification when:
+ // - we have a txid AND
+ // - either the receipt targets a campaign AND we know the wallet, OR
+ // - the receipt has an identity recipient (`p` tag).
+ const enabled = !!event && !!txid && (
+ (targetsCampaign && !!campaignWallet)
+ || (!targetsCampaign && hasIdentityRecipient)
+ );
const { data } = useQuery({
- queryKey: ['onchain-zaps', 'verify', esploraBaseUrl, event?.id ?? ''],
- queryFn: () => verifyOnchainZap(event!, esploraBaseUrl),
- enabled: !!event && !!txid && hasRecipient,
+ queryKey: ['onchain-zaps', 'verify', esploraApis, event?.id ?? '', campaignWallet ?? ''],
+ queryFn: ({ signal }) => verifyOnchainZap(event!, esploraApis, campaignWallet, signal),
+ enabled,
staleTime: 60_000,
});
diff --git a/src/hooks/useOrganizationHomeActivityFeed.ts b/src/hooks/useOrganizationHomeActivityFeed.ts
deleted file mode 100644
index 012ce454..00000000
--- a/src/hooks/useOrganizationHomeActivityFeed.ts
+++ /dev/null
@@ -1,203 +0,0 @@
-import { useMemo } from 'react';
-import type { NostrEvent, NostrFilter } from '@nostrify/nostrify';
-import { useNostr } from '@nostrify/react';
-import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query';
-
-import type { UserOrganization } from '@/hooks/useUserOrganizations';
-import { CAMPAIGN_KIND } from '@/lib/campaign';
-import { getPaginationCursor } from '@/lib/feedUtils';
-
-const PLEDGE_KIND = 36639;
-const CALENDAR_EVENT_KINDS = [31922, 31923];
-const OFFICIAL_ACTIVITY_KINDS = [CAMPAIGN_KIND, PLEDGE_KIND, ...CALENDAR_EVENT_KINDS];
-const OFFICIAL_ACTIVITY_KIND_SET = new Set(OFFICIAL_ACTIVITY_KINDS);
-const COMMENTS_PAGE_SIZE = 60;
-const OFFICIAL_PAGE_SIZE = 40;
-
-interface OrganizationFeedPage {
- events: NostrEvent[];
- commentsRawCount: number;
- officialRawCount: number;
- commentsOldestTimestamp: number | null;
- officialOldestTimestamp: number | null;
-}
-
-interface OrganizationFeedPageParam {
- commentsUntil?: number;
- officialUntil?: number;
- commentsDone?: boolean;
- officialDone?: boolean;
-}
-
-function getEventOrganizationATag(event: NostrEvent): string | undefined {
- return event.tags.find(([name]) => name === 'A')?.[1];
-}
-
-function buildLeadershipByATag(organizations: UserOrganization[]) {
- const leadershipByATag = new Map>();
- for (const entry of organizations) {
- leadershipByATag.set(
- entry.community.aTag,
- new Set([entry.community.founderPubkey, ...entry.community.moderatorPubkeys]),
- );
- }
- return leadershipByATag;
-}
-
-/** Feed of comments and official activity from the user's founded/moderated/followed organizations. */
-export function useOrganizationHomeActivityFeed(
- organizations: UserOrganization[] | undefined,
- membersOnly: boolean,
- enabled = true,
-) {
- const { nostr } = useNostr();
- const queryClient = useQueryClient();
-
- const aTags = useMemo(
- () => organizations?.map((entry) => entry.community.aTag) ?? [],
- [organizations],
- );
- const aTagsKey = aTags.join(',');
- const leadershipByATag = useMemo(
- () => buildLeadershipByATag(organizations ?? []),
- [organizations],
- );
- const officialAuthors = useMemo(() => {
- const authors = new Set();
- for (const leaders of leadershipByATag.values()) {
- for (const pubkey of leaders) authors.add(pubkey);
- }
- return [...authors];
- }, [leadershipByATag]);
-
- const query = useInfiniteQuery({
- queryKey: ['organization-home-activity-feed', aTagsKey, membersOnly],
- queryFn: async ({ pageParam, signal }) => {
- if (aTags.length === 0) {
- return {
- events: [],
- commentsRawCount: 0,
- officialRawCount: 0,
- commentsOldestTimestamp: null,
- officialOldestTimestamp: null,
- };
- }
-
- const cursor = pageParam as OrganizationFeedPageParam | undefined;
- const filters: NostrFilter[] = [];
-
- if (!cursor?.commentsDone) {
- filters.push({
- kinds: [1111],
- '#A': aTags,
- ...(membersOnly && officialAuthors.length > 0 ? { authors: officialAuthors } : {}),
- limit: COMMENTS_PAGE_SIZE,
- ...(cursor?.commentsUntil ? { until: cursor.commentsUntil } : {}),
- });
- }
-
- if (!cursor?.officialDone && officialAuthors.length > 0) {
- filters.push({
- kinds: OFFICIAL_ACTIVITY_KINDS,
- authors: officialAuthors,
- '#A': aTags,
- limit: OFFICIAL_PAGE_SIZE,
- ...(cursor?.officialUntil ? { until: cursor.officialUntil } : {}),
- });
- }
-
- if (filters.length === 0) {
- return {
- events: [],
- commentsRawCount: 0,
- officialRawCount: 0,
- commentsOldestTimestamp: null,
- officialOldestTimestamp: null,
- };
- }
-
- const combinedSignal = AbortSignal.any([signal, AbortSignal.timeout(8000)]);
- const raw = await nostr.query(filters, { signal: combinedSignal });
- const rawComments = raw.filter((event) => event.kind === 1111);
- const rawOfficial = raw.filter((event) => OFFICIAL_ACTIVITY_KIND_SET.has(event.kind));
- const seen = new Set();
- const events: NostrEvent[] = [];
-
- for (const event of raw.sort((a, b) => b.created_at - a.created_at)) {
- if (seen.has(event.id)) continue;
- seen.add(event.id);
-
- const aTag = getEventOrganizationATag(event);
- if (!aTag) continue;
- const leaders = leadershipByATag.get(aTag);
- if (!leaders) continue;
-
- if (event.kind === 1111) {
- if (membersOnly && !leaders.has(event.pubkey)) continue;
- events.push(event);
- continue;
- }
-
- if (!leaders.has(event.pubkey)) continue;
- events.push(event);
- }
-
- for (const event of events) {
- if (!queryClient.getQueryData(['event', event.id])) {
- queryClient.setQueryData(['event', event.id], event);
- }
- }
-
- return {
- events,
- commentsRawCount: rawComments.length,
- officialRawCount: rawOfficial.length,
- commentsOldestTimestamp: rawComments.length > 0 ? getPaginationCursor(rawComments) : null,
- officialOldestTimestamp: rawOfficial.length > 0 ? getPaginationCursor(rawOfficial) : null,
- };
- },
- getNextPageParam: (lastPage) => {
- const commentsCursor = lastPage.commentsOldestTimestamp;
- const officialCursor = lastPage.officialOldestTimestamp;
- const commentsDone = lastPage.commentsRawCount < COMMENTS_PAGE_SIZE || commentsCursor === null;
- const officialDone = lastPage.officialRawCount < OFFICIAL_PAGE_SIZE || officialCursor === null;
-
- if (commentsDone && officialDone) return undefined;
-
- return {
- commentsDone,
- officialDone,
- commentsUntil: commentsDone ? undefined : commentsCursor - 1,
- officialUntil: officialDone ? undefined : officialCursor - 1,
- } satisfies OrganizationFeedPageParam;
- },
- initialPageParam: undefined as OrganizationFeedPageParam | undefined,
- enabled: enabled && aTags.length > 0,
- staleTime: 60_000,
- placeholderData: (prev) => prev,
- refetchOnWindowFocus: false,
- });
-
- return useMemo(() => {
- const seen = new Set();
- const events = (query.data?.pages ?? [])
- .flatMap((page) => page.events)
- .filter((event) => {
- if (seen.has(event.id)) return false;
- seen.add(event.id);
- return true;
- })
- .sort((a, b) => b.created_at - a.created_at);
-
- return {
- events,
- isLoading: query.isLoading,
- isError: query.isError,
- error: query.error,
- hasNextPage: query.hasNextPage,
- isFetchingNextPage: query.isFetchingNextPage,
- fetchNextPage: query.fetchNextPage,
- pageCount: query.data?.pages.length,
- };
- }, [query.data, query.error, query.fetchNextPage, query.hasNextPage, query.isError, query.isFetchingNextPage, query.isLoading]);
-}
diff --git a/src/hooks/useOrganizationMembersOnlyFilter.ts b/src/hooks/useOrganizationMembersOnlyFilter.ts
deleted file mode 100644
index de661959..00000000
--- a/src/hooks/useOrganizationMembersOnlyFilter.ts
+++ /dev/null
@@ -1,58 +0,0 @@
-import { useCallback, useSyncExternalStore } from 'react';
-
-const STORAGE_KEY = 'organization-feed:members-only';
-
-function readFromStorage(): boolean {
- try {
- const raw = localStorage.getItem(STORAGE_KEY);
- if (raw === null) return false;
- return JSON.parse(raw) === true;
- } catch {
- return false;
- }
-}
-
-let cached = typeof localStorage !== 'undefined' ? readFromStorage() : false;
-const subscribers = new Set<() => void>();
-
-function notify() {
- for (const cb of subscribers) cb();
-}
-
-function setMembersOnly(next: boolean) {
- if (cached === next) return;
- cached = next;
- try {
- localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
- } catch {
- // Keep the in-memory preference even when storage is unavailable.
- }
- notify();
-}
-
-if (typeof window !== 'undefined') {
- window.addEventListener('storage', (event) => {
- if (event.key !== STORAGE_KEY) return;
- const next = readFromStorage();
- if (next === cached) return;
- cached = next;
- notify();
- });
-}
-
-function subscribe(cb: () => void) {
- subscribers.add(cb);
- return () => subscribers.delete(cb);
-}
-
-function getSnapshot() {
- return cached;
-}
-
-/** Global preference for showing only founder/moderator comments in org feeds. */
-export function useOrganizationMembersOnlyFilter() {
- const membersOnly = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
- const toggle = useCallback(() => setMembersOnly(!cached), []);
-
- return { membersOnly, setMembersOnly, toggle };
-}
diff --git a/src/hooks/useOrganizationModeration.ts b/src/hooks/useOrganizationModeration.ts
new file mode 100644
index 00000000..5f6b64f9
--- /dev/null
+++ b/src/hooks/useOrganizationModeration.ts
@@ -0,0 +1,133 @@
+import { useNostr } from '@nostrify/react';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+
+import { useNostrPublish } from './useNostrPublish';
+import { useCampaignModerators } from './useCampaignModerators';
+import { COMMUNITY_DEFINITION_KIND } from '@/lib/communityUtils';
+import {
+ AGORA_MODERATION_NAMESPACE,
+ EMPTY_MODERATION_DATA,
+ LABEL_KIND,
+ type ModerationData,
+ type ModerationLabel,
+ foldModerationLabels,
+} from '@/lib/agoraModeration';
+
+/** Surface-scoped alias so call sites read naturally. */
+export type OrganizationModerationData = ModerationData;
+
+/**
+ * Fetches and folds organization-moderation label events authored by Team
+ * Soapbox members. Returns hide / featured rollups per community
+ * coordinate (`34550::`).
+ *
+ * Organizations ride the same `agora.moderation` namespace and the same
+ * moderator pack as campaigns; we just narrow the fold to labels whose `a`
+ * tag points at a kind 34550 coordinate. The relay-side query is identical
+ * to the campaign side (we fetch every namespace-tagged label authored by
+ * moderators) — the surface separation is purely client-side.
+ *
+ * **Two-axis model.** Unlike campaigns, organizations don't have an
+ * `approved` axis. Every Agora-tagged organization is publicly visible
+ * by default; moderation reduces to `featured` (lift into the curated
+ * shelf) and `hidden` (suppress from public discovery). The shared
+ * fold helper still tracks `approvedCoords` for type symmetry with the
+ * campaign hook, but the org UI never emits or reads it — moderators
+ * SHOULD NOT publish `approved` / `unapproved` labels against kind
+ * 34550 coordinates.
+ *
+ * **Display rule** consumers should follow:
+ * - Featured shelf on `/communities` iff
+ * `featuredCoords.has(coord) && !hiddenCoords.has(coord)`.
+ * - Future "All organizations" / discovery surfaces iff
+ * `!hiddenCoords.has(coord)` for non-moderators; moderators may see hidden
+ * orgs with a dimmed treatment.
+ * - "My organizations" intentionally ignores moderation — a user's own
+ * founded / moderated / followed orgs always render regardless of label.
+ * - Hide always wins over featured.
+ *
+ * The mutation `moderate({ coord, action })` publishes a single kind 1985
+ * event labeling one organization in the `agora.moderation` namespace.
+ * Callers MUST be in the moderator set or the relay-side `authors:` filter
+ * on read will silently ignore the new event.
+ */
+export function useOrganizationModeration() {
+ const { nostr } = useNostr();
+ const queryClient = useQueryClient();
+ const { mutateAsync: publishEvent } = useNostrPublish();
+ const { data: moderators } = useCampaignModerators();
+
+ // Same gating as the campaign hook: never fire with an empty `authors:`
+ // filter, since that would return labels from any author and break the
+ // trust model.
+ const moderatorsKey = moderators ? [...moderators].sort().join(',') : '';
+
+ const moderationQuery = useQuery({
+ queryKey: ['organization-moderation', moderatorsKey],
+ enabled: moderators !== undefined,
+ queryFn: async ({ signal }): Promise => {
+ if (!moderators || moderators.length === 0) {
+ return { ...EMPTY_MODERATION_DATA, moderators: [] };
+ }
+ const events = await nostr.query(
+ [
+ {
+ kinds: [LABEL_KIND],
+ authors: moderators,
+ '#L': [AGORA_MODERATION_NAMESPACE],
+ limit: 2000,
+ },
+ ],
+ { signal: AbortSignal.any([signal, AbortSignal.timeout(8000)]) },
+ );
+ return foldModerationLabels(events, moderators, COMMUNITY_DEFINITION_KIND);
+ },
+ // Moderation labels change slowly — moderators feature or hide on
+ // human timescales, not seconds. A generous staleTime keeps repeat
+ // visits to /communities instant (no relay round-trip on remount),
+ // and explicit invalidation in the `moderate` mutation below catches
+ // local changes immediately. The hour-long gcTime survives tab
+ // switches and back-button navigation without refetching.
+ staleTime: 5 * 60_000,
+ gcTime: 60 * 60_000,
+ });
+
+ const moderate = useMutation({
+ mutationFn: async ({ coord, action }: { coord: string; action: ModerationLabel }) => {
+ if (!coord.startsWith(`${COMMUNITY_DEFINITION_KIND}:`)) {
+ throw new Error(`Coordinate must start with ${COMMUNITY_DEFINITION_KIND}:`);
+ }
+ // Organizations use a two-axis model — only `featured` / `unfeatured`
+ // / `hidden` / `unhidden` are valid here. Reject `approved` /
+ // `unapproved` defensively so a stray UI bug can't poison the
+ // label stream with axis-mixed events.
+ if (action === 'approved' || action === 'unapproved') {
+ throw new Error(`Organizations do not support the ${action} label`);
+ }
+ return publishEvent({
+ kind: LABEL_KIND,
+ content: '',
+ tags: [
+ ['L', AGORA_MODERATION_NAMESPACE],
+ ['l', action, AGORA_MODERATION_NAMESPACE],
+ ['a', coord],
+ ['alt', `Organization moderation: ${action}`],
+ ],
+ });
+ },
+ onSuccess: () => {
+ // Invalidate both the moderation rollup and the derived featured
+ // query so the grid reflects the new state immediately.
+ queryClient.invalidateQueries({ queryKey: ['organization-moderation'] });
+ queryClient.invalidateQueries({ queryKey: ['featured-organizations'] });
+ },
+ });
+
+ return {
+ data: moderationQuery.data ?? EMPTY_MODERATION_DATA,
+ isPending: moderationQuery.isPending,
+ isLoading: moderationQuery.isLoading,
+ isReady: moderationQuery.isSuccess,
+ moderate,
+ };
+}
diff --git a/src/hooks/usePinnedEventComments.ts b/src/hooks/usePinnedEventComments.ts
new file mode 100644
index 00000000..50a29985
--- /dev/null
+++ b/src/hooks/usePinnedEventComments.ts
@@ -0,0 +1,109 @@
+import { useNostr } from '@nostrify/react';
+import type { NostrEvent } from '@nostrify/nostrify';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+
+import { useCurrentUser } from '@/hooks/useCurrentUser';
+import { useNostrPublish } from '@/hooks/useNostrPublish';
+import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
+
+const PIN_LIST_KIND = 30078;
+const PIN_D_TAG_PREFIX = 'agora-pinned-comments:';
+
+function pinDTag(rootATag: string): string {
+ return `${PIN_D_TAG_PREFIX}${rootATag}`;
+}
+
+function parsePinnedIds(event: NostrEvent | null | undefined): string[] {
+ if (!event) return [];
+
+ try {
+ const parsed = JSON.parse(event.content) as { pinnedEvents?: unknown };
+ if (!Array.isArray(parsed.pinnedEvents)) return [];
+ return parsed.pinnedEvents.filter((id): id is string => typeof id === 'string');
+ } catch {
+ return [];
+ }
+}
+
+export function usePinnedEventComments(rootATag: string | undefined, ownerPubkey: string | undefined) {
+ const { nostr } = useNostr();
+ const { user } = useCurrentUser();
+ const queryClient = useQueryClient();
+ const { mutateAsync: publishEvent } = useNostrPublish();
+ const dTag = rootATag ? pinDTag(rootATag) : undefined;
+ const canManagePins = !!user && !!ownerPubkey && user.pubkey === ownerPubkey;
+
+ const pinnedListQuery = useQuery({
+ queryKey: ['pinned-event-comments-list', rootATag, ownerPubkey],
+ queryFn: async ({ signal }) => {
+ if (!rootATag || !ownerPubkey || !dTag) return null;
+ const events = await nostr.query(
+ [{ kinds: [PIN_LIST_KIND], authors: [ownerPubkey], '#d': [dTag], limit: 1 }],
+ { signal: AbortSignal.any([signal, AbortSignal.timeout(5000)]) },
+ );
+ return events[0] ?? null;
+ },
+ enabled: !!rootATag && !!ownerPubkey && !!dTag,
+ staleTime: 30_000,
+ });
+
+ const pinnedIds = parsePinnedIds(pinnedListQuery.data);
+
+ const pinnedEventsQuery = useQuery({
+ queryKey: ['pinned-event-comments', rootATag, pinnedIds],
+ queryFn: async ({ signal }) => {
+ if (pinnedIds.length === 0) return [];
+ const events = await nostr.query(
+ [{ ids: pinnedIds, limit: pinnedIds.length }],
+ { signal: AbortSignal.any([signal, AbortSignal.timeout(5000)]) },
+ );
+ return events.sort((a, b) => pinnedIds.indexOf(a.id) - pinnedIds.indexOf(b.id));
+ },
+ enabled: !!rootATag && pinnedIds.length > 0,
+ staleTime: 30_000,
+ });
+
+ const togglePin = useMutation({
+ mutationFn: async (eventId: string) => {
+ if (!user) throw new Error('User is not logged in');
+ if (!rootATag || !ownerPubkey || !dTag) throw new Error('Missing pin context.');
+ if (user.pubkey !== ownerPubkey) throw new Error('Only the event owner can pin comments.');
+
+ const prev = await fetchFreshEvent(nostr, {
+ kinds: [PIN_LIST_KIND],
+ authors: [ownerPubkey],
+ '#d': [dTag],
+ });
+
+ const current = parsePinnedIds(prev);
+ const next = current.includes(eventId)
+ ? current.filter((id) => id !== eventId)
+ : [eventId, ...current.filter((id) => id !== eventId)];
+
+ await publishEvent({
+ kind: PIN_LIST_KIND,
+ content: JSON.stringify({ pinnedEvents: next }),
+ tags: [
+ ['d', dTag],
+ ['a', rootATag],
+ ['k', rootATag.split(':')[0] ?? ''],
+ ['alt', 'Pinned event comments'],
+ ],
+ prev: prev ?? undefined,
+ });
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['pinned-event-comments-list', rootATag, ownerPubkey] });
+ queryClient.invalidateQueries({ queryKey: ['pinned-event-comments', rootATag] });
+ },
+ });
+
+ return {
+ pinnedIds,
+ pinnedEvents: pinnedEventsQuery.data ?? [],
+ isLoading: pinnedListQuery.isLoading || pinnedEventsQuery.isLoading,
+ isPinned: (eventId: string) => pinnedIds.includes(eventId),
+ canManagePins,
+ togglePin,
+ };
+}
diff --git a/src/hooks/usePostComment.ts b/src/hooks/usePostComment.ts
index 0e0b2dae..ae09b4a0 100644
--- a/src/hooks/usePostComment.ts
+++ b/src/hooks/usePostComment.ts
@@ -1,5 +1,6 @@
import { useMutation, useQueryClient, type InfiniteData } from '@tanstack/react-query';
import { useNostrPublish } from '@/hooks/useNostrPublish';
+import { withAgoraTag } from '@/lib/agoraNoteTags';
import { NKinds, type NostrEvent } from '@nostrify/nostrify';
interface PaginatedFeedPage {
@@ -45,7 +46,7 @@ export function usePostComment() {
const event = await publishEvent({
kind: 1111,
content,
- tags,
+ tags: withAgoraTag(tags),
});
return event;
@@ -59,6 +60,46 @@ export function usePostComment() {
queryKey: ['nostr', 'comments', rootKey]
});
+ // The home Agora activity feed (useAgoraFeed) surfaces NIP-22 comments
+ // whose root is an Agora entity (campaign / pledge / community) or a
+ // world-layer iso3166 / geo root. Invalidate it on every comment so
+ // the freshly-posted comment shows up there without a page refresh.
+ // The key shape is ['agora-feed', authorsKey, ...] — prefix sweep
+ // covers every authors variant the user may have mounted.
+ queryClient.invalidateQueries({ queryKey: ['agora-feed'] });
+ // The mixed home feed (Feed.tsx homeFeedMode === 'agora') flattens
+ // useAgoraFeed events; invalidating the underlying source query is
+ // enough because the mixed selector recomputes from it.
+ queryClient.invalidateQueries({ queryKey: ['mixed-feed'] });
+
+ // Comments attached to a campaign / pledge / community (or to any
+ // event that itself carries an organization `A` tag) need to refresh
+ // the organization-activity shelf so they show up on the org page.
+ if (isEvent(root)) {
+ const orgATag = root.tags.find(([n]) => n === 'A')?.[1];
+ if (orgATag) {
+ queryClient.invalidateQueries({ queryKey: ['organization-activity', orgATag] });
+ }
+ // Predicate-match the community activity feed too — it's keyed
+ // ['community-activity-feed', aTagsKey] where aTagsKey is a
+ // comma-joined list of subscribed A tags.
+ if (orgATag) {
+ queryClient.invalidateQueries({
+ predicate: (q) => {
+ const [key, aTagsKey] = q.queryKey;
+ return key === 'community-activity-feed'
+ && typeof aTagsKey === 'string'
+ && aTagsKey.split(',').includes(orgATag);
+ },
+ });
+ }
+ // Comments on a campaign also need the campaign's per-page comment
+ // cache to refresh (it uses ['event-comments', aTag]).
+ const rootDTag = root.tags.find(([n]) => n === 'd')?.[1] ?? '';
+ const rootATag = `${root.kind}:${root.pubkey}:${rootDTag}`;
+ queryClient.invalidateQueries({ queryKey: ['event-comments', rootATag] });
+ }
+
if (countryCode) {
queryClient.setQueriesData>(
{ queryKey: ['agora-feed-paginated', countryCode] },
diff --git a/src/hooks/useProfileCampaignStats.ts b/src/hooks/useProfileCampaignStats.ts
new file mode 100644
index 00000000..b00e9172
--- /dev/null
+++ b/src/hooks/useProfileCampaignStats.ts
@@ -0,0 +1,74 @@
+import { useQueries } from '@tanstack/react-query';
+
+import { useCampaigns } from '@/hooks/useCampaigns';
+import { useAppContext } from '@/hooks/useAppContext';
+import { fetchAddressData } from '@/lib/bitcoin';
+import type { ParsedCampaign } from '@/lib/campaign';
+
+export interface ProfileCampaignStats {
+ /** Total number of non-deleted campaigns authored by this pubkey. */
+ campaignCount: number;
+ /**
+ * Sum of cumulative on-chain receipts (`chain_stats.funded_txo_sum`)
+ * across all of this user's on-chain campaigns, in sats. Silent-payment
+ * campaigns contribute 0 by design (donations are unlinkable).
+ */
+ totalRaisedSats: number;
+ /** True while underlying address-balance queries are still resolving. */
+ isVerifying: boolean;
+ /** The raw campaigns list, for reuse by the chip click handler. */
+ campaigns: ParsedCampaign[];
+}
+
+/**
+ * Aggregate campaign and donation stats for a single profile.
+ *
+ * Mirrors {@link useCampaignDonations} per campaign — fans out a balance
+ * lookup against each on-chain campaign's `w` address via the configured
+ * Esplora endpoint (default: mempool.space) and sums `totalReceived`
+ * across them. Silent-payment campaigns are excluded (their donations
+ * are intentionally unlinkable).
+ *
+ * Lazy: returns 0 / empty until the campaigns list resolves, then fans
+ * out balance fetches in parallel. Suitable for header stat chips where
+ * an in-flight number is fine.
+ */
+export function useProfileCampaignStats(pubkey: string | undefined): ProfileCampaignStats {
+ const { config } = useAppContext();
+ const { esploraApis } = config;
+
+ const campaignsQuery = useCampaigns(
+ pubkey ? { authors: [pubkey], limit: 100 } : { authors: [], limit: 0 },
+ );
+ const campaigns = pubkey ? (campaignsQuery.data ?? []) : [];
+
+ // Fan out: one balance lookup per on-chain campaign address.
+ const onchainCampaigns = campaigns.filter((c) => c.wallet?.mode === 'onchain');
+ const balanceQueries = useQueries({
+ queries: onchainCampaigns.map((campaign) => ({
+ // Share the cache key with useCampaignDonations so both surfaces
+ // refresh together when useDonateCampaign invalidates
+ // ['bitcoin-balance'].
+ queryKey: ['bitcoin-balance', 'campaign', esploraApis, campaign.wallet?.value ?? ''],
+ queryFn: ({ signal }: { signal: AbortSignal }) =>
+ fetchAddressData(campaign.wallet!.value, esploraApis, signal),
+ staleTime: 30_000,
+ enabled: !!campaign.wallet?.value,
+ })),
+ });
+
+ const totalRaisedSats = balanceQueries.reduce(
+ (sum, q) => sum + (q.data?.totalReceived ?? 0),
+ 0,
+ );
+
+ const isVerifying =
+ campaignsQuery.isLoading || balanceQueries.some((q) => q.isLoading);
+
+ return {
+ campaignCount: campaigns.length,
+ totalRaisedSats,
+ isVerifying,
+ campaigns,
+ };
+}
diff --git a/src/hooks/useProfileOrganizations.ts b/src/hooks/useProfileOrganizations.ts
new file mode 100644
index 00000000..939c311c
--- /dev/null
+++ b/src/hooks/useProfileOrganizations.ts
@@ -0,0 +1,92 @@
+import { useMemo } from 'react';
+import type { NostrEvent } from '@nostrify/nostrify';
+import { useNostr } from '@nostrify/react';
+import { useQuery } from '@tanstack/react-query';
+
+import {
+ COMMUNITY_DEFINITION_KIND,
+ parseCommunityEvent,
+ type ParsedCommunity,
+} from '@/lib/communityUtils';
+import { dedupeAddressableLatest } from '@/lib/addressableEvents';
+
+export interface ProfileOrganization {
+ community: ParsedCommunity;
+ event: NostrEvent;
+ /** This pubkey is the founder (author of the kind 34550 event). */
+ isFounder: boolean;
+ /** This pubkey is listed in a `p` tag with role `moderator`. */
+ isModerator: boolean;
+}
+
+function organizationRank(entry: ProfileOrganization): number {
+ if (entry.isFounder) return 0;
+ return 1;
+}
+
+/**
+ * Organizations any given pubkey is publicly associated with — orgs they
+ * founded and orgs they moderate.
+ *
+ * Distinct from {@link useUserOrganizations}, which augments the logged-in
+ * user's view with their private NIP-51 community bookmarks (kind 10004).
+ * Bookmarks are personal state that a third party can't see without the
+ * owner's keys, so for someone else's profile we surface only the public
+ * founder + moderator signals.
+ */
+export function useProfileOrganizations(pubkey: string | undefined) {
+ const { nostr } = useNostr();
+
+ const query = useQuery({
+ queryKey: ['profile-organizations', pubkey ?? ''],
+ queryFn: async ({ signal }) => {
+ if (!pubkey) return [];
+
+ const combinedSignal = AbortSignal.any([signal, AbortSignal.timeout(8000)]);
+
+ const [foundedEvents, pTaggedEvents] = await Promise.all([
+ nostr.query(
+ [{ kinds: [COMMUNITY_DEFINITION_KIND], authors: [pubkey], limit: 50 }],
+ { signal: combinedSignal },
+ ),
+ nostr.query(
+ [{ kinds: [COMMUNITY_DEFINITION_KIND], '#p': [pubkey], limit: 100 }],
+ { signal: combinedSignal },
+ ),
+ ]);
+
+ const entries: ProfileOrganization[] = [];
+ for (const event of dedupeAddressableLatest([...foundedEvents, ...pTaggedEvents])) {
+ const community = parseCommunityEvent(event);
+ if (!community) continue;
+
+ const isFounder = community.founderPubkey === pubkey;
+ const isModerator = community.moderatorPubkeys.includes(pubkey);
+
+ if (!isFounder && !isModerator) continue;
+
+ entries.push({ community, event, isFounder, isModerator });
+ }
+
+ entries.sort((a, b) => {
+ const rankDiff = organizationRank(a) - organizationRank(b);
+ if (rankDiff !== 0) return rankDiff;
+ return b.event.created_at - a.event.created_at;
+ });
+
+ return entries;
+ },
+ enabled: !!pubkey,
+ staleTime: 60_000,
+ });
+
+ return useMemo(
+ () => ({
+ data: query.data ?? [],
+ isLoading: query.isLoading,
+ isError: query.isError,
+ error: query.error,
+ }),
+ [query.data, query.isLoading, query.isError, query.error],
+ );
+}
diff --git a/src/hooks/useStreamPosts.ts b/src/hooks/useStreamPosts.ts
index 1883469c..938e4c37 100644
--- a/src/hooks/useStreamPosts.ts
+++ b/src/hooks/useStreamPosts.ts
@@ -27,6 +27,13 @@ interface StreamPostsOptions {
* Each entry accepts raw hex or npub-encoded pubkeys.
*/
authorPubkeys?: string[];
+ /**
+ * When set, narrows results to events whose NIP-89 `client` tag's first
+ * value matches this string. Relays that index the multi-letter `client`
+ * tag (e.g. Ditto's relay) honor this server-side; on relays that don't,
+ * the filter is silently dropped on their end and results are unfiltered.
+ */
+ clientName?: string;
/** NIP-50 sort preference. 'recent' = default (no sort: term). */
sort?: 'recent' | 'hot' | 'trending';
}
@@ -295,9 +302,16 @@ export function useStreamPosts(query: string, options: StreamPostsOptions) {
streamFilter.authors = resolvedAuthorPubkeys;
}
+ // NIP-89 client tag filter (Ditto relay indexes the multi-letter `client`
+ // tag; other relays silently ignore the constraint).
+ if (options.clientName) {
+ searchFilter['#client'] = [options.clientName];
+ streamFilter['#client'] = [options.clientName];
+ }
+
return { searchFilter, streamFilter };
// eslint-disable-next-line react-hooks/exhaustive-deps -- enabledKinds is stabilized via kindsKey; options.protocols via protocolsKey; kindsOverride via kindsOverrideKey; authorPubkeys via authorPubkeysKey
- }, [query, isDedicatedKindQuery, kindsKey, options.language, options.mediaType, protocolsKey, kindsOverrideKey, authorPubkeysKey, options.sort]);
+ }, [query, isDedicatedKindQuery, kindsKey, options.language, options.mediaType, protocolsKey, kindsOverrideKey, authorPubkeysKey, options.sort, options.clientName]);
// Shared ref for the event map and known IDs — persists across initial fetch + pagination
const eventMapRef = useRef(new Map());
diff --git a/src/hooks/useWeather.ts b/src/hooks/useWeather.ts
deleted file mode 100644
index 5d5173a0..00000000
--- a/src/hooks/useWeather.ts
+++ /dev/null
@@ -1,140 +0,0 @@
-import { useQuery } from '@tanstack/react-query';
-
-import { getCoordinates } from '@/lib/coordinates';
-
-/** WMO weather interpretation codes → descriptions and icons. */
-const WMO_CODES: Record = {
- 0: { description: 'Clear sky', icon: '☀️' },
- 1: { description: 'Mainly clear', icon: '🌤️' },
- 2: { description: 'Partly cloudy', icon: '⛅' },
- 3: { description: 'Overcast', icon: '☁️' },
- 45: { description: 'Foggy', icon: '🌫️' },
- 48: { description: 'Depositing rime fog', icon: '🌫️' },
- 51: { description: 'Light drizzle', icon: '🌦️' },
- 53: { description: 'Moderate drizzle', icon: '🌦️' },
- 55: { description: 'Dense drizzle', icon: '🌧️' },
- 56: { description: 'Light freezing drizzle', icon: '🌧️' },
- 57: { description: 'Dense freezing drizzle', icon: '🌧️' },
- 61: { description: 'Slight rain', icon: '🌦️' },
- 63: { description: 'Moderate rain', icon: '🌧️' },
- 65: { description: 'Heavy rain', icon: '🌧️' },
- 66: { description: 'Light freezing rain', icon: '🌧️' },
- 67: { description: 'Heavy freezing rain', icon: '🌧️' },
- 71: { description: 'Slight snowfall', icon: '🌨️' },
- 73: { description: 'Moderate snowfall', icon: '🌨️' },
- 75: { description: 'Heavy snowfall', icon: '❄️' },
- 77: { description: 'Snow grains', icon: '❄️' },
- 80: { description: 'Slight rain showers', icon: '🌦️' },
- 81: { description: 'Moderate rain showers', icon: '🌧️' },
- 82: { description: 'Violent rain showers', icon: '🌧️' },
- 85: { description: 'Slight snow showers', icon: '🌨️' },
- 86: { description: 'Heavy snow showers', icon: '❄️' },
- 95: { description: 'Thunderstorm', icon: '⛈️' },
- 96: { description: 'Thunderstorm with slight hail', icon: '⛈️' },
- 99: { description: 'Thunderstorm with heavy hail', icon: '⛈️' },
-};
-
-export type PrecipitationType = 'rain' | 'snow' | null;
-
-/** Intensity: 'light' | 'moderate' | 'heavy'. */
-export type PrecipitationIntensity = 'light' | 'moderate' | 'heavy';
-
-/** Determine the precipitation type and intensity from a WMO code. */
-export function getPrecipitation(code: number): { type: PrecipitationType; intensity: PrecipitationIntensity } {
- // Snow codes
- if ([71, 85].includes(code)) return { type: 'snow', intensity: 'light' };
- if ([73].includes(code)) return { type: 'snow', intensity: 'moderate' };
- if ([75, 77, 86].includes(code)) return { type: 'snow', intensity: 'heavy' };
-
- // Rain codes (including drizzle, freezing rain, showers, thunderstorms)
- if ([51, 56, 61, 80].includes(code)) return { type: 'rain', intensity: 'light' };
- if ([53, 63, 66, 81, 95].includes(code)) return { type: 'rain', intensity: 'moderate' };
- if ([55, 57, 65, 67, 82, 96, 99].includes(code)) return { type: 'rain', intensity: 'heavy' };
-
- return { type: null, intensity: 'light' };
-}
-
-export interface WeatherData {
- /** Current temperature in °C. */
- temperature: number;
- /** Apparent (feels like) temperature in °C. */
- apparentTemperature: number;
- /** Relative humidity percentage. */
- humidity: number;
- /** Wind speed in km/h. */
- windSpeed: number;
- /** WMO weather code. */
- weatherCode: number;
- /** Human-readable weather description. */
- description: string;
- /** Weather emoji icon. */
- icon: string;
- /** Whether it's currently daytime. */
- isDay: boolean;
- /** City name for display. */
- city: string;
-}
-
-async function fetchWeather(
- latitude: number,
- longitude: number,
- signal?: AbortSignal,
-): Promise | null> {
- try {
- const params = new URLSearchParams({
- latitude: latitude.toString(),
- longitude: longitude.toString(),
- current: 'temperature_2m,relative_humidity_2m,apparent_temperature,weather_code,wind_speed_10m,is_day',
- timezone: 'auto',
- });
-
- const response = await fetch(
- `https://api.open-meteo.com/v1/forecast?${params}`,
- { signal, headers: { Accept: 'application/json' } },
- );
-
- if (!response.ok) return null;
-
- const data = await response.json();
- const current = data.current;
- if (!current) return null;
-
- const code = current.weather_code ?? 0;
- const wmo = WMO_CODES[code] ?? { description: 'Unknown', icon: '🌡️' };
-
- return {
- temperature: Math.round(current.temperature_2m),
- apparentTemperature: Math.round(current.apparent_temperature),
- humidity: Math.round(current.relative_humidity_2m),
- windSpeed: Math.round(current.wind_speed_10m),
- weatherCode: code,
- description: wmo.description,
- icon: wmo.icon,
- isDay: current.is_day === 1,
- };
- } catch {
- return null;
- }
-}
-
-/**
- * Fetch current weather for an ISO 3166 code (country or subdivision).
- * Uses the free Open-Meteo API (no API key required).
- */
-export function useWeather(code: string | null) {
- const coords = code ? getCoordinates(code) : null;
-
- return useQuery({
- queryKey: ['weather', code],
- queryFn: async ({ signal }): Promise => {
- if (!coords) return null;
- const weather = await fetchWeather(coords.latitude, coords.longitude, signal);
- if (!weather) return null;
- return { ...weather, city: coords.city };
- },
- enabled: !!coords,
- staleTime: 1000 * 60 * 15, // 15 minutes
- gcTime: 1000 * 60 * 60, // 1 hour
- retry: 1,
- });
-}
diff --git a/src/index.css b/src/index.css
index 07021d7c..6c1180e4 100644
--- a/src/index.css
+++ b/src/index.css
@@ -60,9 +60,9 @@
overflow-wrap: anywhere;
}
- /* FAB bottom offset: clears bottom nav + safe area inset on mobile */
+ /* FAB bottom offset: no bottom nav, only clear safe area on native shells. */
.bottom-fab {
- bottom: calc(1.5rem + var(--bottom-nav-height) + var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px)));
+ bottom: calc(2rem + var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px)));
}
/* Position above mobile bottom nav + safe area + arc overhang (28px) */
@@ -242,26 +242,6 @@
animation: ping-3 4s ease-out forwards;
}
-/* Slow continuous pulse for nagging buttons */
-@keyframes pulse-slow {
- 0%, 100% { transform: scale(1); opacity: 0.5; }
- 50% { transform: scale(1.8); opacity: 0; }
-}
-
-.animate-pulse-slow {
- animation: pulse-slow 2.5s ease-in-out infinite;
-}
-
-/* Eerie sigil glow pulse */
-@keyframes sigil-glow {
- 0%, 100% { opacity: 0.12; filter: drop-shadow(0 0 4px hsl(var(--primary) / 0.3)); }
- 50% { opacity: 0.28; filter: drop-shadow(0 0 12px hsl(var(--primary) / 0.6)) drop-shadow(0 0 24px hsl(var(--primary) / 0.25)); }
-}
-
-.animate-sigil-glow {
- animation: sigil-glow 4s ease-in-out infinite;
-}
-
/* Eyeball blink animation - eyelid sweeps down then back up */
@keyframes eyelid-blink {
0% { transform: translateY(-100%); }
@@ -724,7 +704,8 @@
}
/* Slow Ken-Burns-style pan for full-bleed hero images. The image is
- oversized (scale 1.12) so the translation never reveals the edge. */
+ oversized (scale 1.12) so the translation never reveals the edge.
+ Used by HeroBanner on Communities/Actions/Guide pages. */
@keyframes heroPanRight {
0% { transform: scale(1.12) translateX(-1.5%); }
50% { transform: scale(1.12) translateX(1.5%); }
@@ -738,18 +719,37 @@
.hero-pan-right { animation: heroPanRight 28s ease-in-out infinite; }
.hero-pan-left { animation: heroPanLeft 28s ease-in-out infinite; }
-/* Slow breathing glow used on the hero globe's outer halo so the beacon
- feels alive rather than statically pasted. The radius itself doesn't
- change — only opacity — so the layout stays rock-stable. Range is
- intentionally narrow + low so the halo is felt, not seen. */
-@keyframes heroGlobeHaloBreath {
- 0% { opacity: 0.32; }
- 50% { opacity: 0.55; }
- 100% { opacity: 0.32; }
+/* Lightning-arc flow on the home hero. Each path is dashed and the
+ dash offset animates by exactly one full dash period, so the loop
+ point is visually identical to the start point — no snap, no
+ visible restart. Dash sizes are in screen pixels because the arcs
+ use `vector-effect: non-scaling-stroke`. */
+@keyframes heroArcFlow {
+ 0% { stroke-dashoffset: 0; }
+ 100% { stroke-dashoffset: -16; }
+}
+.hero-arc-flow {
+ stroke-dasharray: 6 10;
+ animation: heroArcFlow 2.4s linear infinite;
+}
+
+/* City-node pulse used on the home hero. The dot itself doesn't move;
+ we just breathe the halo's opacity so the location reads as a live
+ beacon. Offsets are staggered per-node from the JSX so the field
+ pulses organically rather than in lockstep. */
+@keyframes heroNodePulse {
+ 0% { opacity: 0.35; transform: scale(0.85); }
+ 50% { opacity: 0.95; transform: scale(1.15); }
+ 100% { opacity: 0.35; transform: scale(0.85); }
+}
+.hero-node-pulse {
+ transform-origin: center;
+ transform-box: fill-box;
+ animation: heroNodePulse 3.2s ease-in-out infinite;
}
-.hero-globe-halo-breath { animation: heroGlobeHaloBreath 6.5s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) {
.hero-pan-right, .hero-pan-left { animation: none; }
- .hero-globe-halo-breath { animation: none; opacity: 0.42; }
+ .hero-arc-flow { animation: none; stroke-dasharray: none; }
+ .hero-node-pulse { animation: none; opacity: 0.7; }
}
diff --git a/src/lib/agoraModeration.ts b/src/lib/agoraModeration.ts
new file mode 100644
index 00000000..53de7851
--- /dev/null
+++ b/src/lib/agoraModeration.ts
@@ -0,0 +1,152 @@
+import type { NostrEvent } from '@nostrify/nostrify';
+
+/**
+ * Shared building blocks for Agora's moderation labels (NIP-32 kind 1985 in
+ * the `agora.moderation` namespace). Both campaigns (kind 33863) and
+ * organizations (kind 34550) ride the same label stream and the same
+ * moderator pack (Team Soapbox); the only thing that varies between them is
+ * the kind prefix on the `a` tag.
+ *
+ * Centralizing the constants, types, and folding logic here keeps the two
+ * per-surface hooks (`useCampaignModeration`, `useOrganizationModeration`)
+ * from drifting apart on namespace strings, axis semantics, or the
+ * surfacing-rule contract documented in NIP.md.
+ */
+
+/** NIP-32 label kind. */
+export const LABEL_KIND = 1985;
+
+/** Label namespace for Agora's moderation labels. */
+export const AGORA_MODERATION_NAMESPACE = 'agora.moderation';
+
+/** The six possible label values in the moderation namespace. */
+export type ModerationLabel =
+ | 'approved'
+ | 'unapproved'
+ | 'hidden'
+ | 'unhidden'
+ | 'featured'
+ | 'unfeatured';
+
+/** A single label event narrowed to its decision axis. */
+export interface AxisDecision {
+ /** Latest label observed on this axis. */
+ label: ModerationLabel;
+ /** Author of the latest label. */
+ pubkey: string;
+ /** Created-at of the latest label. */
+ createdAt: number;
+}
+
+/** Per-coordinate rollup of approval + hide + featured state. */
+export interface ModerationState {
+ approval?: AxisDecision; // `approved` or `unapproved`
+ hide?: AxisDecision; // `hidden` or `unhidden`
+ featured?: AxisDecision; // `featured` or `unfeatured`
+}
+
+/**
+ * Per-surface rollup. Keys are the addressable coordinates we filtered for
+ * (e.g. `33863::` for campaigns or `34550::` for
+ * organizations).
+ */
+export interface ModerationData {
+ /** Map of `::` -> rollup. */
+ byCoord: Map;
+ /** Coordinates where the latest approval label is `approved`. */
+ approvedCoords: Set;
+ /** Coordinates where the latest hide label is `hidden`. */
+ hiddenCoords: Set;
+ /** Coordinates where the latest featured label is `featured`. */
+ featuredCoords: Set;
+ /**
+ * Map of `coord` -> `created_at` of the latest `featured` label. Used to
+ * sort featured rows newest-first.
+ */
+ featuredOrder: Map;
+ /** Pubkeys that were considered moderators when the query ran. */
+ moderators: string[];
+}
+
+export const EMPTY_MODERATION_DATA: ModerationData = {
+ byCoord: new Map(),
+ approvedCoords: new Set(),
+ hiddenCoords: new Set(),
+ featuredCoords: new Set(),
+ featuredOrder: new Map(),
+ moderators: [],
+};
+
+function isApprovalLabel(value: string): value is 'approved' | 'unapproved' {
+ return value === 'approved' || value === 'unapproved';
+}
+
+function isHideLabel(value: string): value is 'hidden' | 'unhidden' {
+ return value === 'hidden' || value === 'unhidden';
+}
+
+function isFeaturedLabel(value: string): value is 'featured' | 'unfeatured' {
+ return value === 'featured' || value === 'unfeatured';
+}
+
+/**
+ * Fold a flat list of label events into per-coordinate rollups by axis.
+ * The newest event per `(coord, axis)` wins.
+ *
+ * Events are filtered to only those carrying an `a` tag that starts with
+ * `:` so the campaign and organization label streams never bleed
+ * into each other even though they share a namespace and signer set.
+ *
+ * Events with a value outside the moderation namespace, or with no `l` tag
+ * in that namespace, are dropped.
+ */
+export function foldModerationLabels(
+ events: NostrEvent[],
+ moderators: string[],
+ coordKind: number,
+): ModerationData {
+ const coordPrefix = `${coordKind}:`;
+ const byCoord = new Map();
+
+ for (const event of events) {
+ const value = event.tags.find(
+ ([n, , ns]) => n === 'l' && ns === AGORA_MODERATION_NAMESPACE,
+ )?.[1];
+ if (!value) continue;
+ const aTag = event.tags.find(
+ ([n, v]) => n === 'a' && typeof v === 'string' && v.startsWith(coordPrefix),
+ )?.[1];
+ if (!aTag) continue;
+
+ const state = byCoord.get(aTag) ?? {};
+ if (isApprovalLabel(value)) {
+ if (!state.approval || event.created_at > state.approval.createdAt) {
+ state.approval = { label: value, pubkey: event.pubkey, createdAt: event.created_at };
+ }
+ } else if (isHideLabel(value)) {
+ if (!state.hide || event.created_at > state.hide.createdAt) {
+ state.hide = { label: value, pubkey: event.pubkey, createdAt: event.created_at };
+ }
+ } else if (isFeaturedLabel(value)) {
+ if (!state.featured || event.created_at > state.featured.createdAt) {
+ state.featured = { label: value, pubkey: event.pubkey, createdAt: event.created_at };
+ }
+ }
+ byCoord.set(aTag, state);
+ }
+
+ const approvedCoords = new Set();
+ const hiddenCoords = new Set();
+ const featuredCoords = new Set();
+ const featuredOrder = new Map();
+ for (const [coord, state] of byCoord) {
+ if (state.approval?.label === 'approved') approvedCoords.add(coord);
+ if (state.hide?.label === 'hidden') hiddenCoords.add(coord);
+ if (state.featured?.label === 'featured') {
+ featuredCoords.add(coord);
+ featuredOrder.set(coord, state.featured.createdAt);
+ }
+ }
+
+ return { byCoord, approvedCoords, hiddenCoords, featuredCoords, featuredOrder, moderators };
+}
diff --git a/src/lib/agoraNoteTags.ts b/src/lib/agoraNoteTags.ts
new file mode 100644
index 00000000..c34c383b
--- /dev/null
+++ b/src/lib/agoraNoteTags.ts
@@ -0,0 +1,53 @@
+/**
+ * The single-letter `t` tag that marks an event as first-class Agora content.
+ *
+ * Every event Agora creates carries `["t", "agora"]` so the Agora activity
+ * feed can filter strictly server-side via the relay-indexed `#t` filter
+ * (multi-letter tags like `client` are not indexed by relays, so NIP-89
+ * cannot serve this purpose).
+ *
+ * Tagged kinds: 1 (notes, replies, quotes), 1111 (comments via usePostComment),
+ * 8333 (onchain zaps), 9041 (zap goals), 30223 (campaigns), 31922 / 31923
+ * (calendar events), 34550 (communities), 36639 (pledges).
+ *
+ * Country-rooted world posts (kind 1111 / 1068 with `#k=iso3166` or `geo`)
+ * are surfaced regardless of this tag — the world layer is intentionally
+ * cross-client.
+ *
+ * Untagged kinds (intentional): 0, 3, 6, 7, 8, 16, 62, 1311, 30009,
+ * 10000-series, 30078, encrypted DMs. Reactions, reposts, follows, profile
+ * metadata, lists, settings, badges, and chat are user-state or response
+ * events — not first-class Agora content.
+ *
+ * See `NIP.md` (§ Agora Content Marker) for the canonical protocol doc.
+ */
+export const AGORA_DEFAULT_NOTE_TAGS: string[][] = [['t', 'agora']];
+
+/**
+ * Append `["t", "agora"]` to a tag array if it is not already present.
+ *
+ * Use at every Agora publish site so the feed can strictly filter by
+ * `#t=agora`. Dedupes user-supplied `t` tags (e.g. campaigns let users
+ * enter free-text hashtags — typing "agora" must not yield two tags).
+ */
+export function withAgoraTag(tags: string[][]): string[][] {
+ const hasAgora = tags.some(
+ ([name, value]) => name === 't' && value?.toLowerCase() === 'agora',
+ );
+ return hasAgora ? tags : [...tags, ['t', 'agora']];
+}
+
+/**
+ * Returns `true` when an event carries Agora's `["t", "agora"]` marker
+ * (case-insensitive on the value).
+ *
+ * Use to client-side filter discovery surfaces — e.g. the moderator
+ * "Pending review" list on `/communities` only surfaces orgs minted
+ * through Agora's create flow, so reviewers aren't drowning in every
+ * kind 34550 community on the network.
+ */
+export function hasAgoraTag(tags: string[][]): boolean {
+ return tags.some(
+ ([name, value]) => name === 't' && value?.toLowerCase() === 'agora',
+ );
+}
diff --git a/src/lib/bitcoin-signers.ts b/src/lib/bitcoin-signers.ts
index 0e8b7321..5336ef7e 100644
--- a/src/lib/bitcoin-signers.ts
+++ b/src/lib/bitcoin-signers.ts
@@ -1,6 +1,7 @@
import type { NostrSigner } from '@nostrify/types';
import { NSecSigner, NBrowserSigner, NConnectSigner } from '@nostrify/nostrify';
import type { NConnectSignerOpts } from '@nostrify/nostrify';
+import { hex } from '@scure/base';
import { signPsbtLocal } from '@/lib/bitcoin';
@@ -46,7 +47,7 @@ export class NSecSignerBtc extends NSecSigner implements BtcSigner {
}
async signPsbt(psbtHex: string): Promise {
- const privateKeyHex = Buffer.from(this.#secretKeyBytes).toString('hex');
+ const privateKeyHex = hex.encode(this.#secretKeyBytes);
return signPsbtLocal(psbtHex, privateKeyHex);
}
}
diff --git a/src/lib/bitcoin.test.ts b/src/lib/bitcoin.test.ts
index 3a965564..5b9de915 100644
--- a/src/lib/bitcoin.test.ts
+++ b/src/lib/bitcoin.test.ts
@@ -1,31 +1,29 @@
-import { beforeAll, describe, expect, it } from 'vitest';
+import { describe, expect, it } from 'vitest';
import { nip19 } from 'nostr-tools';
-import * as bitcoin from 'bitcoinjs-lib';
-import * as ecc from '@bitcoinerlab/secp256k1';
import '@/lib/polyfills';
import {
+ buildUnsignedPsbt,
+ createBitcoinTransaction,
+ finalizePsbt,
isLargeAmount,
LARGE_AMOUNT_USD_THRESHOLD,
nostrPubkeyToBitcoinAddress,
npubToBitcoinAddress,
+ signPsbtLocal,
validateBitcoinAddress,
} from '@/lib/bitcoin';
-// Initialise ECC once for this test file. In the running app, `main.tsx`
-// does this at startup; in a test process `main.tsx` is never imported.
-beforeAll(() => {
- bitcoin.initEccLib(ecc);
-});
-
/**
* Regression test vectors for key-path-only P2TR address derivation using the
* Nostr pubkey directly as the internal key (no script tree).
*
- * Each vector was produced by the live `bitcoinjs-lib` + `@bitcoinerlab/secp256k1`
- * toolchain and independently validated against the address's bech32m
- * checksum. They serve as regression fixtures: if the derivation ever changes
- * (library upgrade, ECC backend switch, etc.) these tests will fail loudly.
+ * Each vector was produced by the original `bitcoinjs-lib` +
+ * `@bitcoinerlab/secp256k1` toolchain and independently validated against
+ * the address's bech32m checksum. They are preserved unchanged after the
+ * migration to `@scure/btc-signer` to prove byte-for-byte derivation
+ * equivalence — if the derivation ever drifts (library upgrade, ECC backend
+ * switch, etc.) these tests will fail loudly.
*
* Note: these are NOT the addresses in the BIP-341 wallet test vectors,
* because those vectors use a non-empty script tree (merkle root); our
@@ -85,16 +83,8 @@ describe('nostrPubkeyToBitcoinAddress', () => {
});
it('returns empty string for hex that is not a valid secp256k1 x-only point', () => {
- // Suppress the catch-block console.error for this test so it doesn't
- // pollute the test output. The function is expected to log and return ''.
- const origError = console.error;
- console.error = () => {};
- try {
- // Valid 64-char hex, but not a valid on-curve secp256k1 x-only point.
- expect(nostrPubkeyToBitcoinAddress('e7a2e3b5f1c8d4a6b9c0e1f2d3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2')).toBe('');
- } finally {
- console.error = origError;
- }
+ // Valid 64-char hex, but not a valid on-curve secp256k1 x-only point.
+ expect(nostrPubkeyToBitcoinAddress('e7a2e3b5f1c8d4a6b9c0e1f2d3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2')).toBe('');
});
it('accepts both upper- and lower-case hex', () => {
@@ -141,6 +131,169 @@ describe('validateBitcoinAddress', () => {
});
});
+/**
+ * PSBT construction & signing regression — proves the byte-for-byte
+ * equivalence of the `@scure/btc-signer` rewrite against the previous
+ * `bitcoinjs-lib` + `ecpair` + `@bitcoinerlab/secp256k1` implementation.
+ *
+ * The unsigned-PSBT hex strings below were captured from the bitcoinjs-lib
+ * pipeline before the migration. They lock in:
+ * - input layout (txid, vout, witnessUtxo script & amount, tapInternalKey)
+ * - output ordering (recipient first, then change)
+ * - the fee-vs-change decision (drop change when it would be sub-dust)
+ * - the PSBT v0 serialisation envelope (version, lock-time, magic, key types)
+ *
+ * Signing uses BIP-340 Schnorr with random aux randomness by default, so the
+ * post-sign witness bytes differ run-to-run. Instead we round-trip:
+ * sign → finalize → broadcast hex → verify witness count + scripts +
+ * txid stability is implicit via the unsigned-PSBT fixture above
+ * and additionally cross-check that the standalone signer (`signPsbtLocal`)
+ * agrees with the convenience wrapper (`createBitcoinTransaction`).
+ */
+describe('PSBT round-trip (bitcoinjs-lib regression)', () => {
+ // Privkey 0x03, derived xonly pubkey — sender's Taproot wallet.
+ // bc1pgxxyvcmdncdxs06cudd5yvmwwahaesaj6n3eu7st7x4sw9hrchaqjy33gs
+ const SINGLE_INPUT = {
+ privkey: '0000000000000000000000000000000000000000000000000000000000000003',
+ senderXOnly: 'f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9',
+ utxos: [
+ {
+ txid: '0000000000000000000000000000000000000000000000000000000000000001',
+ vout: 0,
+ value: 10000,
+ status: { confirmed: true },
+ },
+ ],
+ toAddress: 'bc1p2wsldez5mud2yam29q22wgfh9439spgduvct83k3pm50fcxa5dps59h4z5',
+ amountSats: 9000,
+ feeRate: 1,
+ expectedFee: 154,
+ // Captured from bitcoinjs-lib before the migration.
+ expectedUnsignedPsbtHex:
+ '70736274ff010089020000000101000000000000000000000000000000000000000000000000000000000000000000000000ffffffff02282300000000000022512053a1f6e454df1aa2776a2814a721372d6258050de330b3c6d10ee8f4e0dda3434e03000000000000225120418c46636d9e1a683f58e35b42336e776fdcc3b2d4e39e7a0bf1ab0716e3c5fa000000000001012b1027000000000000225120418c46636d9e1a683f58e35b42336e776fdcc3b2d4e39e7a0bf1ab0716e3c5fa011720f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9000000',
+ };
+
+ // Two-input, two-output (with change) — exercises the multi-input signing
+ // path and the change-output branch.
+ const TWO_INPUTS = {
+ privkey: '0000000000000000000000000000000000000000000000000000000000000005',
+ senderXOnly: '2f8bde4d1a07209355b4a7250a5c5128e88b84bddc619ab7cba8d569b240efe4',
+ utxos: [
+ {
+ txid: '0000000000000000000000000000000000000000000000000000000000000001',
+ vout: 0,
+ value: 30000,
+ status: { confirmed: true },
+ },
+ {
+ txid: '0000000000000000000000000000000000000000000000000000000000000002',
+ vout: 1,
+ value: 50000,
+ status: { confirmed: true },
+ },
+ ],
+ toAddress: 'bc1p2wsldez5mud2yam29q22wgfh9439spgduvct83k3pm50fcxa5dps59h4z5',
+ amountSats: 60000,
+ feeRate: 2,
+ expectedFee: 423,
+ expectedUnsignedPsbtHex:
+ '70736274ff0100b2020000000201000000000000000000000000000000000000000000000000000000000000000000000000ffffffff02000000000000000000000000000000000000000000000000000000000000000100000000ffffffff0260ea00000000000022512053a1f6e454df1aa2776a2814a721372d6258050de330b3c6d10ee8f4e0dda343794c000000000000225120ee713c671c569fbb39901ea3f75195854ba615099ab33a6aecaa5ed539522f93000000000001012b3075000000000000225120ee713c671c569fbb39901ea3f75195854ba615099ab33a6aecaa5ed539522f930117202f8bde4d1a07209355b4a7250a5c5128e88b84bddc619ab7cba8d569b240efe40001012b50c3000000000000225120ee713c671c569fbb39901ea3f75195854ba615099ab33a6aecaa5ed539522f930117202f8bde4d1a07209355b4a7250a5c5128e88b84bddc619ab7cba8d569b240efe4000000',
+ };
+
+ it('builds the same unsigned PSBT as bitcoinjs-lib (single input, no change)', () => {
+ const built = buildUnsignedPsbt(
+ SINGLE_INPUT.senderXOnly,
+ SINGLE_INPUT.toAddress,
+ SINGLE_INPUT.amountSats,
+ SINGLE_INPUT.utxos,
+ SINGLE_INPUT.feeRate,
+ );
+ expect(built.fee).toBe(SINGLE_INPUT.expectedFee);
+ expect(built.psbtHex).toBe(SINGLE_INPUT.expectedUnsignedPsbtHex);
+ });
+
+ it('builds the same unsigned PSBT as bitcoinjs-lib (two inputs, with change)', () => {
+ const built = buildUnsignedPsbt(
+ TWO_INPUTS.senderXOnly,
+ TWO_INPUTS.toAddress,
+ TWO_INPUTS.amountSats,
+ TWO_INPUTS.utxos,
+ TWO_INPUTS.feeRate,
+ );
+ expect(built.fee).toBe(TWO_INPUTS.expectedFee);
+ expect(built.psbtHex).toBe(TWO_INPUTS.expectedUnsignedPsbtHex);
+ });
+
+ it('signs and finalizes a PSBT into a broadcastable transaction (single input)', () => {
+ const { psbtHex } = buildUnsignedPsbt(
+ SINGLE_INPUT.senderXOnly,
+ SINGLE_INPUT.toAddress,
+ SINGLE_INPUT.amountSats,
+ SINGLE_INPUT.utxos,
+ SINGLE_INPUT.feeRate,
+ );
+ const signed = signPsbtLocal(psbtHex, SINGLE_INPUT.privkey);
+ const txHex = finalizePsbt(signed);
+
+ // Witness format for P2TR key-path: a single 64-byte Schnorr signature
+ // (no sighash byte appended → DEFAULT/0x00). The raw tx hex therefore
+ // contains a 0x01-element witness stack + 0x40 length prefix near the end:
+ // ... 01 40 <64-byte sig> 00 00 00 00 (locktime)
+ expect(txHex).toMatch(/0140[0-9a-f]{128}00000000$/);
+ // version (2) + flag/marker (0001) for segwit
+ expect(txHex.startsWith('02000000')).toBe(true);
+ });
+
+ it('signs and finalizes a multi-input PSBT (every input gets a witness)', () => {
+ const { psbtHex } = buildUnsignedPsbt(
+ TWO_INPUTS.senderXOnly,
+ TWO_INPUTS.toAddress,
+ TWO_INPUTS.amountSats,
+ TWO_INPUTS.utxos,
+ TWO_INPUTS.feeRate,
+ );
+ const signed = signPsbtLocal(psbtHex, TWO_INPUTS.privkey);
+ const txHex = finalizePsbt(signed);
+
+ // Two inputs → two witness stacks → two `01 40` sig markers.
+ const sigMatches = txHex.match(/0140[0-9a-f]{128}/g);
+ expect(sigMatches).not.toBeNull();
+ expect(sigMatches?.length).toBe(2);
+ });
+
+ it('createBitcoinTransaction matches the buildUnsignedPsbt + signPsbtLocal + finalizePsbt pipeline', () => {
+ // We can't compare witness bytes directly (random aux), but the unsigned
+ // tx body, fee, input/output topology, and witness *shape* must match.
+ // Easiest check: extract the non-witness bytes via vsize / weight by
+ // round-tripping both outputs back through `Transaction.fromRaw`.
+ const direct = createBitcoinTransaction(
+ SINGLE_INPUT.privkey,
+ SINGLE_INPUT.toAddress,
+ SINGLE_INPUT.amountSats,
+ SINGLE_INPUT.utxos,
+ SINGLE_INPUT.feeRate,
+ );
+ expect(direct.fee).toBe(SINGLE_INPUT.expectedFee);
+ // Same output shape as the manual pipeline.
+ expect(direct.txHex).toMatch(/0140[0-9a-f]{128}00000000$/);
+ expect(direct.txHex.startsWith('02000000')).toBe(true);
+ });
+
+ it('signPsbtLocal throws when no input belongs to the signer', () => {
+ const { psbtHex } = buildUnsignedPsbt(
+ SINGLE_INPUT.senderXOnly,
+ SINGLE_INPUT.toAddress,
+ SINGLE_INPUT.amountSats,
+ SINGLE_INPUT.utxos,
+ SINGLE_INPUT.feeRate,
+ );
+ // Try to sign with a different key whose xonly pubkey does not match the
+ // PSBT's tapInternalKey — every input must be skipped.
+ const wrongKey = '0000000000000000000000000000000000000000000000000000000000000007';
+ expect(() => signPsbtLocal(psbtHex, wrongKey)).toThrow(/no inputs/i);
+ });
+});
+
describe('isLargeAmount', () => {
// Assume a BTC price of $100_000 for easy arithmetic. 1 BTC = $100k, so
// 1 sat = $0.001 and the $100 threshold corresponds to 100_000 sats.
diff --git a/src/lib/bitcoin.ts b/src/lib/bitcoin.ts
index e107efaf..926d773e 100644
--- a/src/lib/bitcoin.ts
+++ b/src/lib/bitcoin.ts
@@ -1,8 +1,9 @@
-import * as bitcoin from 'bitcoinjs-lib';
-import { toXOnly } from 'bitcoinjs-lib';
+import { hex } from '@scure/base';
+import { schnorr } from '@noble/curves/secp256k1.js';
+import * as btc from '@scure/btc-signer';
import { nip19 } from 'nostr-tools';
-import * as ecc from '@bitcoinerlab/secp256k1';
-import { ECPairFactory, type ECPairAPI } from 'ecpair';
+
+import { esploraFetch } from './esplora';
// ---------------------------------------------------------------------------
// Constants
@@ -28,26 +29,35 @@ const VBYTES_PER_OUTPUT = 43;
/** Estimated vBytes for transaction overhead (version, locktime, etc.). */
const VBYTES_OVERHEAD = 10.5;
-// ---------------------------------------------------------------------------
-// ECC initialisation (lazy)
-// ---------------------------------------------------------------------------
+/** Mainnet network constant for @scure/btc-signer. */
+const NETWORK = btc.NETWORK;
-let _ECPair: ECPairAPI | null = null;
-
-function getECPair(): ECPairAPI {
- if (!_ECPair) {
- bitcoin.initEccLib(ecc);
- _ECPair = ECPairFactory(ecc);
- }
- return _ECPair;
-}
+// ---------------------------------------------------------------------------
+// Pubkey validation
+// ---------------------------------------------------------------------------
/**
* Strict 32-byte hex validator. Rejects anything that isn't exactly 64
* lowercase-or-uppercase hex characters.
*/
-function isValidPubkeyHex(hex: string): boolean {
- return typeof hex === 'string' && /^[0-9a-fA-F]{64}$/.test(hex);
+function isValidPubkeyHex(hexStr: string): boolean {
+ return typeof hexStr === 'string' && /^[0-9a-fA-F]{64}$/.test(hexStr);
+}
+
+/**
+ * Check that a 32-byte x-only key is actually a valid secp256k1 point.
+ *
+ * BIP-340 specifies that an x-only pubkey is valid iff `lift_x(P) ≠ ∞`. Noble's
+ * `schnorr.utils.lift_x` throws when the x-coordinate is not on the curve, so
+ * we use it as a curve-membership check.
+ */
+function isOnCurve(xonly: Uint8Array): boolean {
+ try {
+ schnorr.utils.lift_x(BigInt('0x' + hex.encode(xonly)));
+ return true;
+ } catch {
+ return false;
+ }
}
/**
@@ -64,13 +74,10 @@ export function nostrPubkeyToBitcoinAddress(pubkeyHex: string): string {
if (!isValidPubkeyHex(pubkeyHex)) return '';
try {
- const pubkeyBuffer = Buffer.from(pubkeyHex, 'hex');
-
- const { address } = bitcoin.payments.p2tr({
- internalPubkey: pubkeyBuffer,
- network: bitcoin.networks.bitcoin,
- });
+ const internalPubkey = hex.decode(pubkeyHex.toLowerCase());
+ if (!isOnCurve(internalPubkey)) return '';
+ const { address } = btc.p2tr(internalPubkey, undefined, NETWORK);
return address || '';
} catch (error) {
console.error('Error generating Bitcoin address:', error);
@@ -117,10 +124,15 @@ export interface AddressData {
* Esplora-compatible REST API (e.g. mempool.space, Blockstream).
*
* @param address The Bitcoin address to look up.
- * @param baseUrl Esplora REST root, no trailing slash (e.g. `https://mempool.space/api`).
+ * @param baseUrls Ordered list of Esplora REST roots tried with failover.
+ * @param signal Optional abort signal (e.g. from TanStack Query).
*/
-export async function fetchAddressData(address: string, baseUrl: string): Promise {
- const response = await fetch(`${baseUrl}/address/${address}`);
+export async function fetchAddressData(
+ address: string,
+ baseUrls: string[],
+ signal?: AbortSignal,
+): Promise {
+ const response = await esploraFetch(baseUrls, `/address/${address}`, { signal });
if (!response.ok) {
throw new Error('Failed to fetch balance');
@@ -169,12 +181,20 @@ export function formatSats(sats: number): string {
*
* Note: the `/v1/prices` endpoint is a mempool.space extension to the
* standard Esplora REST surface. Backends like Blockstream's Esplora do
- * not expose it.
+ * not expose it — those endpoints return `404` and the failover client
+ * silently advances to the next URL (without penalising the endpoint).
*
- * @param baseUrl Esplora REST root, no trailing slash (e.g. `https://mempool.space/api`).
+ * @param baseUrls Ordered list of Esplora REST roots tried with failover.
+ * @param signal Optional abort signal (e.g. from TanStack Query).
*/
-export async function fetchBtcPrice(baseUrl: string): Promise {
- const response = await fetch(`${baseUrl}/v1/prices`);
+export async function fetchBtcPrice(baseUrls: string[], signal?: AbortSignal): Promise {
+ const response = await esploraFetch(baseUrls, `/v1/prices`, {
+ // /v1/prices is a mempool.space extension — 404 means "endpoint doesn't
+ // speak this path", not "the endpoint is dead". Soft-failover to the
+ // next URL without putting this one in cool-down.
+ skipStatuses: [404],
+ signal,
+ });
if (!response.ok) {
throw new Error('Failed to fetch BTC price');
@@ -260,10 +280,15 @@ export interface Transaction {
* Returns simplified transactions with net amount relative to the address.
*
* @param address The Bitcoin address to look up.
- * @param baseUrl Esplora REST root, no trailing slash.
+ * @param baseUrls Ordered list of Esplora REST roots tried with failover.
+ * @param signal Optional abort signal (e.g. from TanStack Query).
*/
-export async function fetchTransactions(address: string, baseUrl: string): Promise {
- const response = await fetch(`${baseUrl}/address/${address}/txs`);
+export async function fetchTransactions(
+ address: string,
+ baseUrls: string[],
+ signal?: AbortSignal,
+): Promise {
+ const response = await esploraFetch(baseUrls, `/address/${address}/txs`, { signal });
if (!response.ok) {
throw new Error('Failed to fetch transactions');
@@ -350,10 +375,15 @@ export interface TxDetail {
* Fetch full transaction details from an Esplora-compatible API.
*
* @param txid The transaction ID (hex).
- * @param baseUrl Esplora REST root, no trailing slash.
+ * @param baseUrls Ordered list of Esplora REST roots tried with failover.
+ * @param signal Optional abort signal (e.g. from TanStack Query).
*/
-export async function fetchTxDetail(txid: string, baseUrl: string): Promise {
- const response = await fetch(`${baseUrl}/tx/${txid}`);
+export async function fetchTxDetail(
+ txid: string,
+ baseUrls: string[],
+ signal?: AbortSignal,
+): Promise {
+ const response = await esploraFetch(baseUrls, `/tx/${txid}`, { signal });
if (!response.ok) throw new Error('Failed to fetch transaction');
const tx = await response.json();
@@ -429,12 +459,17 @@ export interface AddressDetail {
* Fetch full address details (balance + recent txs) from an Esplora-compatible API.
*
* @param address The Bitcoin address to look up.
- * @param baseUrl Esplora REST root, no trailing slash.
+ * @param baseUrls Ordered list of Esplora REST roots tried with failover.
+ * @param signal Optional abort signal (e.g. from TanStack Query).
*/
-export async function fetchAddressDetail(address: string, baseUrl: string): Promise {
+export async function fetchAddressDetail(
+ address: string,
+ baseUrls: string[],
+ signal?: AbortSignal,
+): Promise {
const [addrData, txs] = await Promise.all([
- fetchAddressData(address, baseUrl),
- fetchTransactions(address, baseUrl),
+ fetchAddressData(address, baseUrls, signal),
+ fetchTransactions(address, baseUrls, signal),
]);
return {
@@ -466,10 +501,15 @@ export interface UTXO {
* Fetch UTXOs for a Bitcoin address from an Esplora-compatible API.
*
* @param address The Bitcoin address to look up.
- * @param baseUrl Esplora REST root, no trailing slash.
+ * @param baseUrls Ordered list of Esplora REST roots tried with failover.
+ * @param signal Optional abort signal (e.g. from TanStack Query).
*/
-export async function fetchUTXOs(address: string, baseUrl: string): Promise {
- const response = await fetch(`${baseUrl}/address/${address}/utxo`);
+export async function fetchUTXOs(
+ address: string,
+ baseUrls: string[],
+ signal?: AbortSignal,
+): Promise {
+ const response = await esploraFetch(baseUrls, `/address/${address}/utxo`, { signal });
if (!response.ok) throw new Error('Failed to fetch UTXOs');
return response.json();
}
@@ -491,10 +531,11 @@ export interface FeeRates {
/**
* Fetch recommended fee rates (sat/vB) from an Esplora-compatible API.
*
- * @param baseUrl Esplora REST root, no trailing slash.
+ * @param baseUrls Ordered list of Esplora REST roots tried with failover.
+ * @param signal Optional abort signal (e.g. from TanStack Query).
*/
-export async function getFeeRates(baseUrl: string): Promise