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/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/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 3a95ca9b..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: [ diff --git a/src/AppRouter.tsx b/src/AppRouter.tsx index 02aa16df..f07d0959 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 WikipediaPage = lazy(() => import("./pages/WikipediaPage").then(m => ({ de const WorldPage = lazy(() => import("./pages/WorldPage").then(m => ({ default: m.WorldPage }))); const FollowPage = lazy(() => import("./pages/FollowPage").then(m => ({ default: m.FollowPage }))); 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,12 +163,10 @@ export function AppRouter() { {/* Auto-follow deep link: fullscreen immersive (no sidebars/nav) */} } /> } /> - } /> {/* All routes share the persistent FundraiserLayout (top nav + footer) */} }> } /> - } /> } /> } /> } /> @@ -189,7 +183,6 @@ export function AppRouter() { } /> } /> } /> - } /> } /> } /> - } /> } /> } /> } /> 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()} - - -
-
{displayName}
-
- - - {/* QR code */} -
-
- -
-
- - {/* Copyable address */} -
- - -
- - {/* 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. */} - -
- ); -} - -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/BitcoinPublicDisclaimer.tsx b/src/components/BitcoinPublicDisclaimer.tsx index b354072b..cfe9bab1 100644 --- a/src/components/BitcoinPublicDisclaimer.tsx +++ b/src/components/BitcoinPublicDisclaimer.tsx @@ -67,7 +67,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', )} > 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 ( +
+
{icon}
+
{children}
+
+ ); +} + // --- 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 ── */} -
- -

Event Details

- {canEdit && ( - + const eventDetailsCard = ( + + +
+
Hosted by
+ +
+ + {(event.content || summary) && ( +
+
Description
+ {event.content ? ( + + ) : ( +

{summary}

+ )} +
)} -
- {/* ── Cover image ── */} - {image ? ( -
- {title} -
- ) : ( -
- -
- )} - - {/* ── 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} +
+
+ + + +
)} - {/* 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) => ( + + ))} +
+
+ )} + + + ); + + const participantsCard = participantsByRole.length > 0 ? ( + + +
Participants
+
+ {participantsByRole.map(([role, pubkeys]) => + pubkeys.map((pk) => ), + )} +
+
+
+ ) : null; + + return ( +
+
+
+ {image ? ( + + ) : ( +
+ +
+ )} +
+ +
+ + {canEdit && ( + + )} +
+ +
+
+

+ {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 && ( -
- {links.map((url) => ( - - - {url.replace(/^https?:\/\//, '')} - - - ))} +
+
+

Comments

+ {replyTree.length > 0 ? ( + + {replyTree.length.toLocaleString()} {replyTree.length === 1 ? 'comment' : 'comments'} + + ) : null} +
+ + + + {commentsLoading ? ( +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+
+ +
+ + + +
+
+
+ ))} +
+ ) : replyTree.length > 0 ? ( +
+ ( + handleTogglePin(event)} + /> + )} + /> +
+ ) : ( + + )} +
- )} - {/* 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 -

-
- - - -
-
- - )} - - 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/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.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. */} + +
+ ); +} + +/** + * 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..372fecfb 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,17 +27,27 @@ 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'; @@ -46,9 +58,11 @@ import { useBitcoinWallet } from '@/hooks/useBitcoinWallet'; 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}
@@ -243,11 +257,8 @@ function PledgeShelfCard({ pledge }: { pledge: Action }) { return ( -
- } label="Pledge" /> -
-
- by {displayName} +
+
+ by {displayName} +
+ } label="Pledge" />
@@ -336,11 +350,8 @@ function CalendarEventShelfCard({ event }: { event: NostrEvent }) { return ( -
- } label="Event" /> -
{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 && ( - - )} +
- ) : null} - {engagementStats?.quotes ? ( - - ) : null} - {engagementStats?.reactions ? ( - - ) : 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)} + /> + )} + />
) : (
); + + 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()}> + + + 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 ade7b627..e0c34cbf 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,7 +43,8 @@ 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'; @@ -50,6 +65,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,7 +172,15 @@ 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. */ + /** + * 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; @@ -218,7 +242,7 @@ export function ComposeBox({ customPublish, hidePoll = false, submitLabel = 'Post!', - defaultTags = [], + defaultTags, defaultExpanded = false, }: ComposeBoxProps) { const { user, metadata, isLoading: isProfileLoading } = useCurrentUser(); @@ -269,23 +293,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: '' }, @@ -323,10 +363,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, defaultExpanded]); + }, [initialMode, draftKey, defaultExpanded, defaultPostCountry]); // Use controlled preview mode if provided, otherwise use internal state const previewMode = controlledPreviewMode !== undefined ? controlledPreviewMode : internalPreviewMode; @@ -1114,10 +1154,14 @@ 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: [...defaultTags, ...tags], + tags: [...effectiveDefaultTags, ...tags], created_at: Math.floor(Date.now() / 1000), }); } @@ -1537,14 +1581,14 @@ export function ComposeBox({ })()} - + {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.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.name} + {country.code} + {destination === country.code && ( + + )} + + ))} + + +
)} @@ -1775,7 +1936,7 @@ export function ComposeBox({ + + ); @@ -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 { esploraApis } = 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', esploraApis, senderAddress], - queryFn: ({ signal }) => fetchUTXOs(senderAddress, esploraApis, signal), - enabled: !!senderAddress && validAmount, - staleTime: 30_000, - }); - - const { data: feeRates } = useQuery({ - queryKey: ['bitcoin-fee-rates', esploraApis], - queryFn: ({ signal }) => getFeeRates(esploraApis, signal), - 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 */} -
- -
- {PRESET_AMOUNTS.map(({ amountUsd: amount, icon: Icon, label }) => { - const selected = !customUsd && amount === presetAmountUsd; +
+ {/* Preset amounts */} +
+ +
+ {PRESET_AMOUNTS.map(({ amountUsd: usd, icon: Icon, label }) => { + const selected = !usingCustom && amountUsd === usd; return ( ); })}
- {/* Custom override */} -
- - 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 */} +
+ +
+ + $ + onCustomChange(e.target.value)} + className="pl-7" + />
+ {effectiveAmount > 0 && ( +
+ ≈ {formatSats(effectiveAmount)} sats + {btcPrice && effectiveUsd > 0 && ( + <> · ${effectiveUsd.toLocaleString()} at current price + )} +
+ )}
- {/* Optional comment */} -
- + {/* Comment */} +
+