Compare commits

...

455 Commits

Author SHA1 Message Date
sam 53828741e1 Merge branch 'main' into cooking/planetora 2026-05-23 11:21:31 +07:00
mkfain b5f4e6febb Update OG image to Agora PR cover
Replace og-image.jpg with the 1200x630 version of the Agora PR cover and
update index.html to reference the .jpg URL (the meta tags previously
pointed to a non-existent og-image.png).
2026-05-22 22:57:03 -05:00
mkfain 5a04b071f1 Mobile drawer: show Help in main menu when logged out
Logged-in users reach Help through the account menu (AccountSwitcher
dropdown), but logged-out users have no equivalent affordance — the
default sidebar order doesn't include Help, so it was buried in the
"More…" menu. Force it into the main list when there's no user, and
suppress the duplicate in the hidden-items menu.
2026-05-22 19:43:25 -05:00
Alex Gleason 7cdeead7b2 Campaign progress: source raised total from on-chain address balance
Per-campaign 'raised' was the sum of verified kind 8333 donation receipts:
each receipt's tx was re-fetched and its outputs paying the campaign's `w`
address were summed. That counted only donations whose donor published a
receipt — direct on-chain payments were ignored — and required N `/tx/`
lookups per campaign view.

Source `totalSats` from a single `/address/{w}` lookup against the
configured Esplora endpoint (default: mempool.space) and use
`chain_stats.funded_txo_sum` (lifetime received). Any payment to the
address now counts, and the progress bar does not regress when the
beneficiary spends.

Kind 8333 receipts are still fetched and verified to power the donor list,
donor count, and per-tx breakdown — they just no longer drive the headline
number.

Silent-payment campaigns are unchanged (no observable balance).
`useProfileCampaignStats` and the `SortedByTopGrid` on the profile
campaigns tab switch to the same address-balance source.
2026-05-22 18:55:07 -05:00
Alex Gleason 93108bc00e AuthDialog: mark nsec login form with data-nsec-allowed
The nsec paste guard (useNsecPasteGuard) bails out when the paste
target has id="nsec" or sits inside [data-nsec-allowed], but the
login form had neither marker, so pasting an nsec into the login
field triggered the "Secret key detected" toast and was blocked.
2026-05-22 16:45:38 -05:00
Alex Gleason 35b84c76dc Campaign detail: drop in-app on-chain Donate button
Removes the 'Donate' button that opened the PSBT-signing
DonateDialog above the wallet QR/address panel. Agora no longer
runs the in-app on-chain donate flow — donors pay from an
external wallet via the QR code, the same path silent-payment
campaigns already used.

The DonateDialog component and useDonateCampaign hook stay in
the tree for now; they're still wired into the profile rail's
campaign-donate dropdown.
2026-05-22 16:35:14 -05:00
Alex Gleason 6671908e2e Use white text on saturated brand-primary buttons
The contrastForeground() helper relied on isDarkTheme(), which only
treats a color as dark when its luminance < 0.2. The default orange
primary (24 100% 50%) has luminance ~0.31, so it was classified as a
light background and got dark text — black letters on orange buttons
throughout the site. Same problem hit most saturated mid-lightness
brand colors (red, blue, purple, green).

Raise the threshold to 0.55 so saturated mid-lightness colors get
white text while genuinely light pastels (pink theme, sunset, light
mode background) still get dark text.
2026-05-22 16:30:45 -05:00
Alex Gleason 0c2c42d039 Homepage hero CTA: force white text on Start a campaign button
The button used `text-primary-foreground`, which the theme derives via
auto-contrast against the orange primary. With the current orange (HSL
24 100% 50%) the contrast helper picks black, which clashes with the
hero's dark background and reads as low-effort. Force white explicitly
so the brand-orange pill keeps a consistent look regardless of how the
primary-foreground token shifts.
2026-05-22 16:21:03 -05:00
Alex Gleason 4f32fee37a Merge branch 'fix-query-invalidation' into 'main'
Fix query invalidation gaps so UI updates without page refresh

See merge request soapbox-pub/agora!34
2026-05-22 21:18:37 +00:00
Alex Gleason e5dc8fd50b Campaign new: drop empty-custom format hint
When 'Custom' is selected with an empty field, the long bc1q/bc1p/sp1
explanation read as noise. The input's placeholder ("bc1p…  or  sp1…")
already conveys the expected format; the invalid-input error still
fires when something unparseable is typed.
2026-05-22 16:18:07 -05:00
Alex Gleason ca55030c68 Campaign new: trim wallet dropdown items and align Custom
Remove the secondary captions ("A new on-chain address per campaign",
"Static silent-payment code", "Paste any mainnet bc1… or sp1… address")
from the three wallet options — the primary label already says what
the option is.

Give the Custom item a matching size-7 circle on the left, with a
Wallet icon centered in it, so the three items (and the closed-state
trigger) line up vertically. Without it, Custom sits flush against
the left padding while the other two are pushed in by an avatar.
2026-05-22 16:15:09 -05:00
mkfain 69a688706e Invalidate home Agora activity feed when posting / pledging / donating
The home page's Agora activity tab is driven by useAgoraFeed
(['agora-feed', ...]) and the mixed-mode composer (['mixed-feed', ...]).
None of the publishing paths invalidated those keys, so a freshly posted
comment / pledge / donation / campaign / kind 1 note didn't appear in
the home activity feed until the user refreshed the page.

* usePostComment now invalidates ['agora-feed'] and ['mixed-feed'] on
  every comment publish, and additionally cascades to the parent
  event's ['organization-activity', A], the predicate-matched
  ['community-activity-feed', aTagsKey], and the campaign-page
  ['event-comments', aTag] cache when the root carries an org A tag
  or addressable root coords.

* useDonateCampaign, CreateActionPage, CreateActionDialog,
  CreateCampaignPage, and ComposeBox (top-level kind 1, voice, poll)
  each gain ['agora-feed'] / ['mixed-feed'] invalidations so their new
  content lands in the activity feed without a refresh.

* useDeleteEvent's predicate sweep is extended to ['mixed-feed'] and
  ['nostr-layer'] so deletions also drop from the home activity feed
  composition layers, not just the source useAgoraFeed query.
2026-05-22 16:09:52 -05:00
Alex Gleason 2f8c8762e3 Theme tokens: separate accent from muted/secondary
The shadcn "accent" token is the *interactive* surface used by
dropdown items, ghost buttons, calendar cells, command palettes,
etc. Previously `accent` was aliased to `primary` (the brand
color), which painted every menu hover state with the loud brand
color — most visibly on the new /campaigns/new wallet dropdown.

Repoint accent to a derived surface that sits one perceptible step
beyond `secondary`/`muted`:

  - dark themes:  lighten(background, 14)  vs  +8 for muted
  - light themes: darken(background,  8)   vs  -4 for muted

The extra ~6 lightness points are deliberate. Without the gap,
`accent === muted` would mean a hovered menu row containing an
avatar (which uses `bg-muted` for its fallback) makes the avatar
disappear into the hover surface. The same applies to badges, code
chips, and inline pill tags that share the muted background.

`accent-foreground` becomes the page foreground (neutral text on
neutral surface) instead of the primary-foreground (light text
designed for the brand background).
2026-05-22 16:00:23 -05:00
mkfain 7f7db43910 Broaden deletion + moderation invalidation to refresh every feed surface
Phase 3 of the invalidation cleanup.

* useDeleteEvent previously invalidated only ['feed'], ['profile-feed'],
  ['profile-likes-infinite'], ['replies'], and ['notifications']. A
  deleted event can sit in many other surfaces — country feeds
  (agora-feed-paginated / agora-feed-new-posts), comment threads
  (['nostr', 'comments', ...], ['event-comments', ...], wall-comments),
  campaign and pledge lists, community / organization activity feeds,
  trending, and per-event caches. Switch to a predicate that sweeps a
  curated allow-list of feed-shaped query-key prefixes so the deleted
  post drops off every visible surface in a single refetch wave.

* useCampaignModeration only invalidated its own ['campaign-moderation']
  cache. Moderation labels (approve / hide / feature) gate which
  campaigns surface on the home page, discover shelf, and community
  grids, so the list queries need refetching too. Cascade to
  ['campaigns'], ['campaigns-all'], and ['campaigns-all-scores'].

ReportDialog (kind 1984) and useRequestToVanish (kind 62) were reviewed
and intentionally left alone: ReportDialog has no UI consequence inside
Agora (reports only show up to external moderators), and Request to
Vanish logs the user out, after which any cached state is cleared
anyway.
2026-05-22 15:53:15 -05:00
Alex Gleason 4bb44ff210 Campaign new: drop "back to you" from public-wallet disclaimer
Traceability cuts both ways on a campaign — donors are also exposed,
not just the organizer — so the lead reads "Donations are public and
can be traced." without scoping the trace to the campaign owner.
2026-05-22 15:51:32 -05:00
mkfain 6ccdeefdee Fill in partial query invalidation for pledges, campaigns, and moderation
Phase 2 of the invalidation cleanup. Each mutation below already invalidated
some of its consumer queries but missed sibling surfaces that display the
same data, so users had to refresh to see their action reflected everywhere.

* ProfileReactionButton (kind 7 on a kind 0 profile) didn't refresh any
  stats. Bump the profile's nip85-event-stats and nip85-addr-stats keys
  via invalidateEventStats + an explicit '0:<pubkey>:' addr sweep.

* BanConfirmDialog only invalidated ['community-members', aTag], so a
  removed post remained visible in the org's activity feed until refresh.
  Mirror CommunityReportDialog's predicate-match on
  ['community-activity-feed', aTagsKey] and also refresh
  ['organization-activity', aTag].

* CreateActionDialog (the quick-create pledge dialog inside an org)
  refreshed community-actions and the activity feed but skipped
  ['organization-activity', communityATag], so the new pledge didn't
  appear on the same org-detail page that launched the dialog.

* ActionsPage delete handler only invalidated ['agora-actions'] and
  ['agora-action']. Extract any organization 'A' tag from the pledge
  event and cascade to organization-activity, community-actions, and
  the community-activity-feed predicate.

* CampaignDetailPage delete handler skipped campaigns-all (the discover
  list), the campaign's organization shelf, and the country feed if the
  campaign carried a country code. Add all three.

* CreateCampaignPage onSuccess refreshed only the single-campaign key
  and one org-activity key. Add the campaigns/campaigns-all list keys
  and the country-feed keys so newly launched or edited campaigns show
  up everywhere they're displayed.
2026-05-22 15:49:40 -05:00
Alex Gleason fed462dad5 Campaign new: wallet dropdown for HD wallet / private / custom
Replaces the freeform 'Bitcoin wallet' input on /campaigns/new with a
three-option Select:

  1. "<Name>'s wallet" — derives a fresh bc1p… from the user's HD
     wallet at submit time. Advances the persistent receive-index
     cursor by 1, but only after every other field has validated, so
     a failed publish doesn't burn an index.
  2. "<Name>'s private wallet" — uses the static BIP-352 silent-
     payment code (sp1…).
  3. "Custom" — keeps the existing freeform input for any mainnet
     bech32(m) address.

The two HD-wallet options are disabled for extension/bunker logins,
which can't expose the raw nsec the derivation needs. Edit mode always
starts in 'custom' with the existing w tag pre-filled — switching
wallets on a live campaign is an explicit user choice.

Adds two soft, informational disclaimers below the field that swap
based on the effective wallet mode:

  - on-chain → BitcoinPublicDisclaimer (tone="soft") with new
    popoverText override for the campaign-creator audience: "Bitcoin
    is a public ledger. Transactions sent to this wallet will be
    visible to everyone…"
  - silent-payment → new BitcoinPrivateDisclaimer with the
    "Experimental. Donations are private, but bugs may occur."
    headline + popover explaining the recoverability/sync trade-offs.
2026-05-22 15:48:49 -05:00
Alex Gleason 31e1b58012 Merge branch 'main' of gitlab.com:soapbox-pub/agora 2026-05-22 15:04:16 -05:00
mkfain df5c08ef27 Fix query invalidation gaps so UI updates without page refresh
Several mutations published Nostr events but invalidated cache keys that
no live query subscribed to, leaving the UI showing stale counts and
missing entries until the user manually refreshed.

* Campaign donations: useDonateCampaign and CampaignDetailPage invalidated
  ['campaign-donations', aTag], but useCampaignDonations subscribes to
  ['campaign-donations', 'events', aTag]. Use the correct key, cascade to
  organization-activity / campaigns lists, and broaden via prefix sweep.

* Reactions / reposts / quotes: ReactionButton, RepostMenu, QuickReactMenu,
  ComposeBox quote path, VinesFeedPage and ListDetailPage all wrote to
  ['event-stats', id], which no query reads. Counts are served by
  useNip85EventStats / useNip85AddrStats at ['nip85-event-stats', id,
  statsPubkey] and ['nip85-addr-stats', addr, statsPubkey]. Route
  optimistic writes and invalidations through a new invalidateEventStats
  helper that handles both the regular and addressable variants.

* Top-level posts on country pages: ComposeBox's createEvent path
  invalidated ['feed'], but country pages subscribe to
  ['agora-feed-paginated', countryCode, ...] and
  ['agora-feed-new-posts', countryCode, ...]. Add the country-feed
  invalidations to the kind 1, voice, and poll handlers — matching the
  pattern usePostComment already uses for kind 1111 comments.

* Follow All inline reimplementations: FollowPage's FollowPackView,
  TeamSoapboxCard, and FollowPackDetailContent published kind 3 events
  inline with no invalidation, so follow buttons and the user's feed
  stayed unchanged. Replace each with useFollowActions.followMany, which
  already invalidates ['follow-list'], ['feed'], and ['following-feed'].
2026-05-22 15:03:30 -05:00
mkfain 747b95c125 Profile rail/overview: move profile fields above campaigns
The freeform kind-0 profile fields (links, addresses, etc.) used to
sit at the very bottom of the rail/overview, after campaigns,
latest pledge, and organizations. Move them to the top so the
profile's own metadata is the first thing visitors read.
2026-05-22 14:33:35 -05:00
Alex Gleason bbda106f7b Throttle SP scan republishes instead of debouncing
A long silent-payment scan over mostly-empty blocks would keep
resetting the 5s debounce timer and never actually fire an
intermediate republish — so closing the tab mid-scan could discard
many minutes of work. Switch to a leading-arm throttle: the timer is
armed once when a match lands, fires after at most 5s, and ignores
subsequent matches until it fires. Empty blocks never arm the timer,
so the user's signer isn't spammed during a 10k-block backfill.

The final flush in scanRange's finally still publishes
unconditionally so the advanced scanHeight is checkpointed even on
match-free ranges.
2026-05-22 14:19:17 -05:00
mkfain 7d8e2d1192 Mobile profile: surface overview as the default tab
On mobile the profile page used to stack the full identity rail
(avatar / bio / actions / stats / campaigns / latest pledge / orgs
/ fields) above the tab bar. Users had to scroll past the entire
rail before they reached the tabs, and once the tabs did pin they
clashed with the main app top nav.

Reshape the mobile layout so the rail's content becomes a tab. The
avatar, name, bio, action bar, and Followers/Following/Raised stats
stay above the tab bar as a persistent identity header; everything
else moves into two new mobile-only tabs:

  Mobile:  Overview | Activity | Campaigns | Community | Pledges
  Desktop: Activity | Campaigns | Pledges   (unchanged)

Overview shows the campaigns preview, the fallback latest-pledge
card, and the freeform kind-0 profile fields. Community shows the
organizations grid. Desktop is byte-identical — the two-column
grid with the sticky 340px rail still renders the original three
content tabs.

Implementation:
 - Split ProfileIdentityRail.tsx into reusable exports:
   ProfileAvatarBlock, ProfileIdentityHeader, ProfileOverviewSections
   (with an opt-out showOrganizations flag), and a standalone
   ProfileOrganizationsSection for the Community tab. The original
   ProfileIdentityRail wrapper still composes them in the same
   two-layer structure used by the desktop sticky aside.
 - ProfilePage.tsx now renders two parallel layouts toggled with
   hidden / lg:hidden (no useIsMobile, so no first-render flicker).
   A new ProfileTabContent helper routes the active tab id to its
   body for both layouts.
 - Initial activeTab is picked from matchMedia('(min-width: 1024px)')
   so mobile defaults to 'overview' and desktop to 'activity'
   without a wrong-tab flash. Resizing from mobile to desktop while
   on overview / community redirects to activity.
2026-05-22 14:17:01 -05:00
mkfain e2a9277489 Remove pledges count from profile identity rail stats 2026-05-22 14:03:14 -05:00
Alex Gleason 357e18e063 Keep SP receives in the tx history after the UTXO is spent
The transaction list derived SP receive rows from the active UTXO set,
so a UTXO that got pruned (either by a send or by the manual reconcile
pass) silently vanished from history. The spending transaction itself
also mis-classified: Blockbook's xpub scan sees only the BIP-86 change
output, so a self-send appeared as a small unsolicited receive.

Archive SP UTXOs instead of deleting them:
- `SPStorageDocument` gains an optional `spent: SPStoredUtxo[]`
  list; the parser, serialiser, and the publish-time merge handle it
  alongside `utxos`.
- `pruneSpentUtxos` moves entries from `utxos` to `spent` via the
  new `archiveSpentUtxos` helper rather than dropping them.
- The optimistic-vs-loaded heuristic compares combined (`utxos` +
  `spent`) counts so a prune that shrinks `utxos` while growing
  `spent` doesn't accidentally fall back to the stale relay copy.

Use the archive to fix the tx-history UI:
- The receive-history builder in `useHdWallet` merges active +
  archived SP UTXOs, so historical receives stay visible.
- `buildHdTransactions` is reworked to do per-Blockbook-tx accounting
  using raw `vin`/`vout` data (now plumbed through
  `AccountScanResult.rawTransactions`). It accepts a map of SP
  outpoints we own (active + archived) and subtracts `outflowsSp`
  from the net delta — a tx whose vin matches one of our SP UTXOs
  flips from 'receive of change' to 'send' with the correct amount.

Add a deep-rescan recovery path for state that was pruned before the
archive logic shipped. The BIP-352 indexer fetchers gain an
`includeSpent` flag; spent-flagged matches surface in
`SPMatchedUtxo.spent` and the orchestrator routes them straight into
the archive. Exposed as an 'Include already-spent' checkbox in the
existing scan dialog.

Regression-of: 3adfe5d8
2026-05-22 13:55:28 -05:00
mkfain 0b193b823f Match notification feed format to activity feed (no outer card wrapper) 2026-05-22 13:47:13 -05:00
Alex Gleason 6d15204b47 Add Reconcile UTXOs button to the SP scan dialog
The send-time prune from c983d406 only catches SP UTXOs the current
session spends. Any UTXO spent before that fix shipped — or spent on
another device — stays in the encrypted NIP-78 doc indefinitely,
inflating the displayed balance and offering already-spent inputs to
the next coin-selection pass. Blockbook's xpub scan can't observe SP
outputs (they aren't on the BIP-86 hierarchy), so chain refresh can't
fix it either.

Wrap Blockbook's WS `getTransaction` to read per-vout `spent` flags
and expose `reconcileSpentUtxos` from `useHdWalletSp`: it walks up
to 50 distinct stored txids per click, asks Blockbook which outputs
are spent, and feeds the spent set through the existing
`pruneSpentUtxos` helper. Surface as a 'Reconcile now' button in the
existing SP scan dialog — same place users already go to fix up SP
state.

Manual rather than automatic on-load because firing ≤50 WS calls on
every wallet page mount would be wasteful when the steady-state case
(after this and the send-time prune both land) is that nothing needs
fixing. The cap is mirrored from the existing block-timestamp backfill.

Regression-of: 3adfe5d8
2026-05-22 12:08:57 -05:00
Alex Gleason c983d406c9 Prune spent silent-payment UTXOs from HD wallet storage after a send
Blockbook's xpub scan can't observe silent-payment outputs, so when the
send flow consumes one, nothing on the chain-scan side removes it from
the wallet's local NIP-78 UTXO doc. The previous `onSuccess` only
invalidated the doc query, but the relay copy still contained the spent
UTXO and `mergeUtxos` is insert-only — so the entry never went away.

The visible symptom: spending SP UTXOs made the wallet balance go *up*.
The send tx routed change to a fresh BIP-86 address, which credited to
Blockbook's xpub balance, while `silentPaymentBalance` kept counting
the consumed SP UTXOs as still spendable.

Surface the actually-consumed SP `(txid, vout)` set from
`buildHdSpendPsbt`, thread it through the send mutation, and have
`useHdWalletSp` apply a prune+republish that also strips the same
entries from the remote doc before merging (otherwise insert-only
`mergeUtxos` would re-add them on the next read-modify-write).

Regression-of: 3adfe5d8
2026-05-22 11:36:26 -05:00
Alex Gleason 553edf761e Merge branch 'main' of gitlab.com:soapbox-pub/agora 2026-05-22 01:21:57 -05:00
Alex Gleason 3adfe5d89a Send to and from BIP-352 silent payment addresses
The HD wallet can already derive its own sp1q… receive address and detect
incoming silent payments via the BlindBit indexer, but the Send dialog
only handled bare Bitcoin addresses (or npubs / nprofiles, which routed
through nostrPubkeyToBitcoinAddress). Silent-payment funds were stuck:
they showed in the balance but the dialog gated them with a "spending
isn't supported yet" notice.

Now the dialog handles both ends:

  - Recipient resolution in parseHdRecipient accepts sp1… (mainnet, v0)
    alongside bc1…, npub1…, and nprofile1…. The send mutation decodes the
    address, derives the per-transaction P_k locally from the selected
    inputs' BIP-341-tweaked private keys, and writes it as a regular P2TR
    output. The on-chain transaction looks like any other Taproot spend;
    the BIP-352 ECDH happens entirely off-chain.

  - The coin selector now mixes BIP-86 UTXOs with SP UTXOs from the
    NIP-78 storage doc. SP inputs are signed by computing
    d_k = b_spend + t_k and writing tapKeySig directly, bypassing
    @scure/btc-signer's automatic TapTweak (which would re-tweak the
    already-on-chain P_k and produce an invalid signature).

  - The "silent-payment-only balance" warning is gone — those funds are
    now spendable. The privacy disclaimer still appears for bare bc1…
    addresses but is suppressed for sp1… recipients, since the whole
    point of silent payments is that the on-chain output is fresh and
    unlinkable.

src/lib/hdwallet/sp/sender.ts contains the BIP-352 sender math (address
bech32m decode, outpoint serialisation, ECDH, P_k derivation) ported from
Ditto and adapted to noble-curves v2. src/lib/hdwallet/sp/spend.ts holds
the spend-side helpers (b_spend derivation, d_k = b_spend + t_k, manual
Schnorr signing for SP inputs). Both are covered by the BIP-352 canonical
taproot-only test vectors plus a sender↔receiver round-trip check that
the same (b_spend, t_k) the scanner persists really does produce the
P_k the spender signs against.
2026-05-22 01:19:08 -05:00
Alex Gleason 05332e31c9 Swap bitcoinjs-lib for @scure/btc-signer
Replace the bitcoinjs-lib + ecpair + @bitcoinerlab/secp256k1 + Buffer-polyfill
stack with @scure/btc-signer (plus @noble/curves for BIP-352 point math)
across every consumer:

  - src/lib/bitcoin.ts: P2TR payment + PSBT build/sign/finalize via btc.p2tr
    and btc.Transaction. signPsbtLocal hands the raw 32-byte private key to
    signIdx, which detects tapInternalKey and applies the BIP-341 TapTweak
    internally — the ECPair + manual taggedHash('TapTweak', ...) song-and-dance
    is gone. The empty-string-on-invalid-pubkey contract is preserved via an
    explicit on-curve check using schnorr.utils.lift_x.
  - src/lib/hdwallet/derivation.ts: deriveLeafTaprootSigner is removed in
    favour of deriveLeafPrivateKey, which is now sufficient because
    signIdx tweaks internally. The lazy ensureEcc / ECPairFactory plumbing
    is gone.
  - src/lib/hdwallet/transaction.ts: PSBT pipeline ported to btc.Transaction;
    signHdPsbt now wipes the materialised leaf privkey after signIdx().
  - src/lib/hdwallet/sp/{crypto,scanner}.ts: replace ecc.pointFromScalar /
    pointAdd / pointMultiply with secp256k1.Point methods from @noble/curves.
    pointMultiplyCompressed is exported for the scanner. Noble multiply is
    strict (throws on scalar 0 or >= n) so the wrappers preserve the previous
    "Failed to compute …" semantics.
  - src/lib/campaign.ts: parseCampaignWallet uses the shared
    validateBitcoinAddress helper instead of bitcoin.address.toOutputScript.
  - src/lib/bitcoin-signers.ts: NSecSignerBtc no longer touches Buffer.
  - src/lib/polyfills.ts + src/main.tsx: drop the global Buffer polyfill and
    the bitcoin.initEccLib(ecc) bootstrap — neither is needed anymore.

package.json: removes bitcoinjs-lib, ecpair, @bitcoinerlab/secp256k1, and
the buffer polyfill package; adds @scure/btc-signer ^2.2.0. @noble/curves
and @scure/{base,bip32,bip39} were already in tree.

bitcoin.test.ts gains a PSBT round-trip regression block. The unsigned-PSBT
hex fixtures in those tests were captured from the bitcoinjs-lib pipeline
before the migration, so the new build path is asserted to produce
byte-for-byte identical PSBT envelopes (input layout, output ordering,
fee-vs-change decision, PSBT v0 serialisation). Signing uses random aux so
witness bytes differ run-to-run; the tests verify the resulting raw tx hex
has the right Schnorr-key-path witness shape (0x01 stack + 0x40-byte sig)
for every input, plus that signPsbtLocal still throws when no input belongs
to the signer.

All 25 bitcoin.test.ts tests pass; full \`npm run test\` (72 tests + tsc +
eslint + vite build) is green.
2026-05-22 00:58:46 -05:00
mkfain 30f6058228 Merge branch 'main' of gitlab.com:soapbox-pub/agora 2026-05-22 00:49:04 -05:00
mkfain 03003e4541 Update profile campaign verifications for esploraApis rename
The on-chain wallet rewrite (3a703a26) replaced the single
`esploraBaseUrl` config field with an ordered `esploraApis` array
that `verifyOnchainZap` failovers across, but `ProfileCampaignsTab`
and `useProfileCampaignStats` still pulled the old name off
`AppConfig` and passed a string where the array is now required —
the two surviving call sites from the original Esplora API.

Switch both to destructure `esploraApis` and pass it through to
`verifyOnchainZap` to match the new signature.

Regression-of: 3a703a26
2026-05-22 00:48:58 -05:00
Chad Curtis 91eb2fcee2 Trim orange highlight box's top and bottom padding 2026-05-22 00:47:36 -05:00
mkfain e3f2941294 Merge branch 'main' of gitlab.com:soapbox-pub/agora 2026-05-22 00:46:57 -05:00
Chad Curtis 53a7c01a9e Polish home hero: typeface, highlight box, line spacing, map artifacts
- Switch the hook headline to Bebas Neue (font-display family) at heavier
  size with a synthetic webkit-text-stroke fatten, italic, uppercase, and
  tight leading so 'Connecting activists to / unstoppable funding.' reads
  as a single editorial statement.
- Force the orange highlight onto its own line via <br>; tune left/right
  padding and inner text offset so the U sits flush with 'Connecting'
  above while the box extends past the word as a flourish.
- Fix two horizontal slashes across the world map caused by
  antimeridian-crossing rings (Russia, Antarctica): detect any longitude
  step > 180° and close+restart the SVG subpath instead of drawing the
  connecting line.
- Drop the 2008-era left-edge darkening gradient and the bottom
  vignette behind the map.
- Dim the central radial brand-orange glow (~half alpha).
- Tighten the arc-flow dash period and pixel size.
2026-05-22 00:42:39 -05:00
Chad Curtis 8620bb2bc7 Redesign home hero as a dark Lightning-map composition
The previous hero was a full-bleed user-uploaded campaign banner with a
3D spinning globe, an 8-hue palette that cycled every 6s, and a heavy
text-shadow on the headline to keep it legible against the photo. Three
structural problems: the hero's quality floor was whatever the worst
featured campaign uploaded, the brand orange was just one of eight
rotating hues, and the headline depended on a drop shadow to read.

New hero is brand-driven, type-led, and self-contained:

- HeroLightningMap renders a dark equirectangular world map (reusing
  LAND_RINGS) with a curated set of glowing orange arcs between major
  cities and pulsing city nodes. Pure SVG, no campaign coupling, looks
  the same on every visit.
- Near-black backdrop (hsl(220 25% 6%)) gives the brand orange the
  spotlight without competing with it. The CTA is a solid brand-orange
  pill instead of the previous translucent glass treatment.
- A left-edge gradient inside HeroLightningMap creates a structural
  quiet zone behind the headline column, so the H1 is fully legible
  with no text-shadow at all. hero-text-shadow / hero-text-shadow-soft
  are gone.
- Animations honor prefers-reduced-motion.

Drops HeroGlobe, HeroCampaignSpotlight, and CampaignHeroBackground
along with the spotlight cycling state, the campaign-banner pipeline,
and hopeHueFor() coupling on this page. HeroAtmosphere / HeroBanner /
HOPE_PALETTE remain in use on Communities, Actions, and Guide pages.
2026-05-22 00:42:39 -05:00
mkfain 7cdcea1586 Fade profile tabs as they slide off-screen on scroll
The previous fix translated the tabs up by their own height plus the
top-bar zone, but because the tab bar is notably taller than other
sub-headers and the top bar (z-20) paints over the tabs (z-10), the
top half of the bar was visibly clipped by the top bar mid-transition
— it looked like the tabs slid halfway up and then stopped.

Pair the slide with an opacity fade so the bar disappears as it
transits the top-bar zone, never visibly intersecting the top bar.

Regression-of: e1c66f3b
2026-05-22 00:41:46 -05:00
mkfain e1c66f3bba Slide profile tabs away with the mobile top bar on scroll
The MobileTopBar slides off-screen on scroll-down, but ProfileTabs
stayed pinned at `top-mobile-bar`, leaving a translucent gap above
the tabs where the top bar used to be — and on scroll-up, the
returning top bar (z-20) visibly crossed over the top of the tab bar
(z-10) until it docked flush above them.

Mirror the global `SubHeaderBar`'s default behavior: track
`useNavHidden()` and apply `nav-hidden-slide` with a transform
transition so the tabs ride up off-screen together with the top bar.

Regression-of: 121991f3
2026-05-22 00:39:07 -05:00
Alex Gleason 3a703a261e Replace single-address /wallet with the HD wallet
Delete the old Taproot single-address wallet (WalletPage, SendBitcoinDialog,
useBitcoinWallet) and rename HDWalletPage to WalletPage so the HD wallet now
lives at /wallet. The /hdwallet route is gone.

Five non-wallet callers (CreateActionPage, ActionsPage, ActionDetailPage,
CommunityDetailPage, CampaignDetailPage) imported useBitcoinWallet only for
its btcPrice field; they now use the standalone useBtcPrice hook.

WalletRecoveryPage (legacy Breez/Spark sweep) is preserved at /wallet/recovery
since it is a one-shot tool independent of the live wallet page.

The 'wallet unavailable' branch no longer points users at a non-existent
fallback wallet — it now tells extension/bunker users to sign in with their
nsec instead.
2026-05-22 00:26:17 -05:00
mkfain 4fb67e3b1c Stop graying out ended pledge cards
The 'opacity-75 + grayscale cover' treatment on ended pledges read as
dull/sad rather than informative. The 'Ended' badge already conveys the
state cleanly. Drop the dimming on all three pledge card surfaces:

  - The compact RailPledgeCard in the profile rail.
  - ProfilePledgesTab cards on the profile.
  - ActionCard on the global /pledges directory.

The Ended badge stays — it's a clear, single-glance signal without
making the whole card look like inactive content.
2026-05-22 00:18:49 -05:00
mkfain 3d9f760156 Show latest pledge in rail when profile has no campaigns
Profiles with pledges but no campaigns had a noticeably empty rail —
the Campaigns section self-collapses, leaving just the stat block and
orgs (often empty too) above the Profile fields. Surface the user's
latest pledge as a fallback first-class Agora content slot.

Adds a 'pledges: Action[]' prop to ProfileIdentityRail (the page now
passes the same filtered list it gives ProfilePledgesTab) and a new
RailLatestPledgeSection that renders only when:

  - campaigns.length === 0, AND
  - the profile has at least one pledge.

The section picks the newest pledge by created_at and renders a compact
RailPledgeCard sized for the 340px rail (16:9 cover, single-line
pledged amount, optional country + deadline meta row). When there's
more than one pledge a 'See all N pledges →' link below jumps to the
Pledges tab.

If the profile has campaigns OR no pledges at all, the section returns
null and the rail's existing layout is unchanged.
2026-05-22 00:17:29 -05:00
Alex Gleason 93c22dec2e Merge remote-tracking branch 'origin/main' into hdwallet
# Conflicts:
#	src/components/DonateDialog.tsx
#	src/hooks/useDonateCampaign.ts
#	src/hooks/useOnchainZaps.ts
2026-05-22 00:16:29 -05:00
mkfain fd3446a2f5 Hide badges in edit profile, drop 'Add to sidebar' from profile menu
Edit profile (ProfileSettings) renders an interactive ProfileCard for
the avatar/banner/bio editor. The card was showing the user's NIP-58
badge showcase grid, which doesn't belong on the edit form. Add a
showBadges prop to ProfileCard (default true to preserve all other
consumers — NoteCard, PostDetailPage, MusicArtistsTab, MusicDiscoverTab)
and pass showBadges={false} from ProfileSettings.

Profile 3-dots menu: drop the 'Add to sidebar / Remove from sidebar'
row. Sidebar pinning is a feed-management feature that doesn't belong
on a profile-action menu — add-to-list already covers the
list-management use case. Cleans out the supporting machinery
(addToSidebar / removeFromSidebar / orderedItems / sidebarId /
isInSidebar / handleToggleSidebar) and the now-unused Trash2 and
PanelLeft lucide icons.
2026-05-22 00:11:36 -05:00
Alex Gleason 58fd4c41c2 Explain silent-payment-only balance in HD Send dialog
When the user's only spendable balance is in silent-payment outputs,
the Send button stays disabled with no feedback because:

  - The dialog's `ownedUtxos` is sourced from `scan.utxos` (BIP-86
    only, populated by Blockbook).
  - SP UTXOs live in a separate persisted store and aren't included.
  - The PSBT signer can't spend them anyway: SP outputs use a
    BIP-352 tweaked private key that isn't derivable from the
    BIP-86 (chain, index) pair `signHdPsbt` reconstructs, so even
    plumbing them into `ownedUtxos` would just move the failure
    from "button disabled" to "signing throws".

`src/lib/hdwallet/sp/crypto.ts` is explicit: the wallet
"scans-and-displays SP receives but cannot spend or send them".

Surface that explicitly: when `totalBalance === 0` (no BIP-86
funds) but `silentPaymentBalance > 0`, render a one-line alert
above the Send button telling the user spending SP outputs isn't
supported yet and they need to receive on-chain to spend. Doesn't
remove the disabled state — there's nothing to spend regardless —
but at least the user now knows why.
2026-05-22 00:08:42 -05:00
mkfain ea3a1ff5bd Drop LinkFooter from the profile rail
The rail rendered <LinkFooter /> at the bottom of its content. On
desktop that worked — the rail is sticky and scrolls independently, so
the footer landed at the bottom of the rail's scroll container.

On mobile the rail is just the first stacked element above the right
column (tabs + feed). That meant the footer sat in the middle of the
page, between the rail's profile-fields section and the tab bar — a
weird mid-page footer band.

The global SiteFooter rendered by FundraiserLayout already handles
About / Privacy / Safety / Source / Changelog at the page level, so
the rail's LinkFooter was redundant on every breakpoint anyway. Drop
it.
2026-05-22 00:07:51 -05:00
mkfain 93e9f7ca97 Make the profile rail scroll independently of the feed
Symptom: scrolling on top of the rail moved the page (and feed) instead
of the rail. Once the page reached the end of the feed, the rail would
finally scroll until pagination loaded more, then the feed took over
again. Frustrating for users on tall rails.

Cause: the rail aside was 'lg:sticky lg:top-4' with no height cap and no
internal scroll, so it scrolled with the page until its bottom reached
the viewport bottom — at which point sticky positioning kicked in. There
was nowhere for the rail to scroll independently because it had no
overflow container of its own.

Earlier I removed the rail's 'lg:overflow-y-auto' because it clipped
the avatar's '-mt-16' overhang above the rail's top edge. The fix is to
split the rail into two layers:

  - Outer flex column (the aside contents) — owns the avatar, has no
    overflow constraint, so the avatar's negative-margin overhang above
    the aside's top is never clipped.
  - Inner scroll container — wraps everything below the avatar with
    'lg:flex-1 lg:min-h-0 lg:overflow-y-auto'. On lg+ this fills the
    remaining height of a now-bounded sticky aside
    ('lg:h-[calc(100vh-2rem)]') and scrolls internally. Mouse wheel
    over the rail scrolls the rail; mouse wheel over the right column
    scrolls the page.

Below lg the inner container's lg-prefixed classes don't apply, so the
rail still flows naturally above the tab content as before.
2026-05-22 00:05:16 -05:00
mkfain 6b7bdb9322 Expand profile Activity feed to all enabled kinds, not just notes/reposts
The unified profile feed was only fetching kind 1/6 in the
'includeAuthorNotes' branch, which dropped articles (30023), photos (20),
videos (21/22), polls, and any other kind the user has enabled in their
feed settings. The legacy Posts tab pulled the full getEnabledFeedKinds
set, so the merged feed lost content compared to before.

Pipe getEnabledFeedKinds(feedSettings) through useAgoraFeed when
includeAuthorNotes is set. Always force kind 1 + 6 in if the user has
disabled them in settings — a profile feed without notes would be
useless. The post-filter now accepts any author-scoped event whose
kind is in that set, mirroring the relay request shape.
2026-05-22 00:02:41 -05:00
mkfain 4312a7c6f6 Merge profile Activity + Posts into a unified feed
Profile pages had two tabs that both showed 'this person's stuff' — one
strict-Agora (Activity) and one general kind-1/6 (Posts). For a profile,
that distinction is noise: visitors want to see everything someone has
done on the network in one timeline.

Add an 'includeAuthorNotes' option to useAgoraFeed. When set alongside
'authors', the relay query gains a fifth filter '{ kinds: [1, 6],
authors }' and the post-filter relaxes the strict t:agora gate for
events authored by the requested set. The strong author scope is the
trust anchor — we know it's by this person, so we surface it.

ProfileActivityTab consumes the new option, becoming the single feed
for the profile. Drops the Posts tab, the Posts & replies overflow tab,
useProfileFeed integration on ProfilePage, the feedItems / currentItems
machinery, the MIN_VISIBLE_ITEMS auto-load effect, the pinned-posts
surface (was tied to the Posts tab; togglePin still exists elsewhere),
the PinnedLabel helper, the profile-pinned-events query, and a pile of
now-unused imports (NoteCard, usePinnedNotes, isEventMuted,
useProfileFeed, filterByTab, FeedItem, useQuery, useNostr from the
outer scope, useMuteList outside ProfileMoreMenu, Pin lucide icon).

Profile tabs are now: Activity / Campaigns / Pledges. The home /
mixed feed is unaffected because includeAuthorNotes defaults off.

Net: -150 LoC in ProfilePage.tsx; +28 LoC in useAgoraFeed; -8 LoC in
ProfileActivityTab.
2026-05-21 23:59:34 -05:00
Alex Gleason 6812b3dd74 Convert Blockbook feePerUnit from sat/kB to sat/vB
Blockbook's WebSocket estimateFee returns feePerUnit in sat/**kB**,
not sat/byte. The TypeScript declaration in blockbook-api.ts
describes it as "sat/byte, Wei/gas, etc.", which was misleading
enough that I trusted the declaration and added a code comment to
match. The Go source is explicit in api/worker.go:

    // fee is in sats/kB
    fee, _ := w.cachedEstimateFee(i+1, true)

Confirmed against btc.trezor.io: at typical mempool conditions the
WS reports values around 3000–3500 (sat/kB), which the HD Send
dialog rendered verbatim as "3320 sat/vB" for the 10-minute tier.
Dividing by 1000 yields ~3 sat/vB, matching what /wallet shows.

Round up after the divide so we never underpay relative to the
backend's recommendation — under-paying by 0.5 sat/vB is the kind
of thing that gets a tx stuck for a day.

Regression-of: 2e5a2628
2026-05-21 23:57:40 -05:00
Alex Gleason ea825505cc Fix four bugs in /hdwallet Send dialog
1. Double close buttons. shadcn's DialogContent always renders an
   absolute-positioned X in the top-right corner; the dialog also drew
   its own X in a header row. Hide the default with `[&>button]:hidden`,
   matching SendBitcoinDialog.

2. Absurdly high fees / "can't send". The UI fee preview multiplied the
   fee rate by `ownedUtxos.length`, but an HD wallet typically holds
   many UTXOs across many addresses while a real send only consumes the
   minimal set the coin selector picks. On an active wallet this
   over-estimated by an order of magnitude, drove `totalSats` past
   `totalBalance`, and disabled the Send button via `insufficient`.

   Add `previewHdFee` in lib/hdwallet/transaction.ts that runs the same
   `selectUtxos` logic as the real PSBT builder and returns the resulting
   fee. Use it in both the live fee display and the auto-tune effect, so
   the preview matches what the transaction will actually pay.

   Also flag `selectionFailed` (positive amount but `previewHdFee`
   returns 0) as `insufficient` so the UI doesn't claim a 0-sat fee is
   spendable when coin selection failed.

3. Em dash on every fee tier. The popover trigger only had a value path
   for `estimatedFeeSats > 0 && btcPrice`. With no amount entered yet,
   or while fee rates are still loading, every tier displayed `—`. Fall
   back to `<rate> sat/vB` when we have a rate but no amount-derived
   USD fee.

4. Privacy checkbox unchecking on fee tier change. The reset effect
   listed `currentFeeRate` in its deps, so picking a different fee tier
   silently flipped `acknowledgedPublic` back to false. Split the resets:
   `confirmArmed` still re-arms on amount/fee/price/recipient changes,
   but `acknowledgedPublic` only resets when the recipient changes.

Regression-of: 522c2650
2026-05-21 23:55:43 -05:00
mkfain 0c5eae3ceb Fix profile tab bar bleeding past the column on mobile
ProfileTabs had '-mx-4 sm:-mx-6 lg:mx-0' to bleed the bar to the page
edges, paired with matching positive padding on the inner scroll track.
On lg+ that worked (the bleed was suppressed) but below lg the negative
margin pushed the tab bar wider than the rest of the profile content
column, making the page width look broken once the rail stacked above
the right column.

Drop the bleed entirely. The tab bar now sits exactly at the column's
width at every breakpoint. The translucent backdrop and bottom border
still read as a visual separator without escaping the content area.
2026-05-21 23:53:42 -05:00
mkfain 3ec8d1b9f9 Merge branch 'main' of gitlab.com:soapbox-pub/agora 2026-05-21 23:51:00 -05:00
mkfain 121991f3e5 Drop Media/Badges/Likes tabs, redesign profile tab bar
Tabs:
  - Activity, Campaigns, Pledges, Posts. That's it.
  - Media / Badges / Likes removed along with their renderers,
    ProfileBadgesTab function (152 LoC), useProfileMedia + useProfileLikes
    hooks, mediaEvents / likedItems / mediaFeedItems / likedFeedItems
    memos, the sidebarMediaUrl state and its callbacks, and the
    profile-media / profile-likes-infinite cache invalidations.
  - The 'isCoreProfileTab' fallthrough now only handles posts/replies.

Visuals:
  - New ProfileTabs component replaces the global SubHeaderBar +
    TabButton pair on this page. It's a clean column-local sticky bar
    with backdrop-blurred translucent background, hairline bottom
    border, and an animated underline that slides between active tabs.
    No arc decoration, no hover-slice tracking — just tabs.
  - Sticks to top-mobile-bar on small screens (clears mobile chrome)
    and top-0 from sidebar breakpoint up. Active tab auto-scrolls into
    view when it overflows the column.

Net: -289 LoC in ProfilePage.tsx; +118 LoC for the new ProfileTabs.
2026-05-21 23:50:35 -05:00
Alex Gleason 63e2a7d1a8 Stamp SP UTXOs with real block timestamps from Blockbook
The HD wallet's silent-payment receives synthesised their timestamp from
block height using a 600-seconds-per-block constant anchored at block
800,000. Real average block time is shorter than 600s, so cumulative
drift on recent heights pushes the estimate days into the future and the
tx list rendered '-11d ago' for fresh receives.

Fetch the actual block timestamp from Blockbook's getBlock at scan time
and persist it on each SPStoredUtxo. Existing docs without the field
are backfilled opportunistically on the first session that loads them
(bounded to 50 unique heights per session to avoid hammering Blockbook
on wallets with deep history).

The synthetic estimate is preserved as a fallback for the rare case
that Blockbook is unreachable and clamped to 'now' so it can never
report a future timestamp. The relative-time formatter in HDWalletPage
and WalletPage also clamps negative diffs to 'Today' as a final guard.

Regression-of: 059f75db
2026-05-21 23:47:54 -05:00
mkfain 6e7fcb8732 Trim profile rail and tabs to the essentials
Rail:
  - Drop the badge preview row.
  - Pull Followers + Following onto a single inline horizontal row at
    the top of the stat block. The campaigns-count row is gone (it was
    redundant with the Campaigns section that sits right below it).
  - Pledges and Raised remain as full rows in the secondary stat list.

Tabs:
  - Remove the Overview tab. Activity is now the default, leftmost tab.
  - Remove the Wall tab and all its supporting code: useWallComments
    hook, wallReplyTarget memo, wallComments / orderedWallReplies
    flatten, wallComposeOpen state, openWallCompose callback,
    profileFollowsMe gate, FAB wall-compose wiring, the wall-comments
    cache invalidation, and the Wall tab renderer.
  - Remove the overflow '⋯' dropdown that exposed non-default core
    tabs. Every core tab is now visible in the strip.

Fallouts:
  - DEFAULT_TAB_LABELS = CORE_TAB_LABELS (every core tab is shown), so
    CORE_TAB_LABELS is dropped.
  - Delete src/components/profile/ProfileOverviewTab.tsx.
  - Drop a pile of now-unused imports from ProfilePage.tsx
    (MoreHorizontal, MessageSquare, DropdownMenu*, FeedCard,
    ComposeBox, ReplyComposeModal, useWallComments,
    FlatThreadedReplyList, ProfileOverviewTab) and the badge-related
    imports from ProfileIdentityRail (BadgeThumbnail,
    useBadgeDefinitions, useProfileBadges, nip19).

Net: -418 LoC across the page and rail.
2026-05-21 23:38:13 -05:00
mkfain d5a54f6844 Restore avatar position, add z-index instead
The previous commit raised the avatar too far up the page by matching
the negative margin to the avatar's full height. The user's actual
complaint was z-stacking, not vertical position.

Restore -mt-16 md:-mt-20 so half the avatar overlaps the banner (the
classic profile look) and add 'relative z-10' to the avatar button so
it explicitly layers above the banner's stacking context regardless of
which parent creates a new context (e.g. the lg:sticky aside).
2026-05-21 23:29:11 -05:00
mkfain 69f7ec9176 Lift profile avatar fully above the banner
The avatar's negative margin was -mt-16/-mt-20 (-64/-80px) but the
avatar itself is 112/128px tall, so roughly half of it sat under the
rail boundary and got cut off behind the banner edge.

Match the negative margin to the avatar's full size — -mt-28 md:-mt-32 —
so the bottom edge of the avatar sits at the rail's top edge (which is
the banner's bottom edge). The whole avatar floats over the banner now.

Drop lg:overflow-y-auto + lg:max-h-[calc(100vh-2rem)] from the rail
<aside> so the avatar's overhang above the rail's top edge isn't
clipped. The rail still sticks via lg:sticky lg:top-4; when content is
taller than the viewport, sticky positioning naturally scrolls with
the page (matching GitHub's profile rail behavior).
2026-05-21 23:27:33 -05:00
mkfain d92ec350e4 Rebuild profile as a real two-column layout
Profile page was structurally broken: a stack of full-width sections
(banner, header, campaigns strip, orgs strip, tabs, content) with a
sidebar that only existed beside the tab content. The tab bar split
the page in two unrelated halves and the sidebar floated in the
middle.

Replace it with a GitHub-style two-column shell:

  - LEFT: ProfileIdentityRail (new), sticky on lg+. Owns avatar
    overlapping the banner via -mt-16, identity (name/NIP-05/website/
    bio), badge preview, action bar with auto-flow wrap (Follow /
    Donate / ⋯, or Edit profile / QR / ⋯ for own profile), a vertical
    stat list (followers / following / campaigns / pledges / raised),
    a compact campaigns section (cap 2 + 'See all → Campaigns tab'),
    an organizations grid (cap 4 + 'See all' dialog), the freeform
    profile fields, and LinkFooter.

  - RIGHT: tab navigation and the active tab's content. Tabs stick to
    the top of this column only, so they're clearly nav for that
    column, not a page-wide divider.

Below lg the grid collapses to a single column and the rail stacks
above the tabs — the avatar still overlaps the banner because the rail
is the first child below it. Reads top-down like a document.

Drops ProfileCampaignsStrip and ProfileOrganizationsStrip as page-wide
strips; their content folds into the rail. Extracts the orgs overflow
modal into a small standalone component (OrganizationsAllDialog) so
the rail can use it without pulling in the strip files.

Trims ProfileOverviewTab to just Recent activity + Recent posts; the
'Featured campaign' section is redundant now that the rail carries
campaigns as standing facts.

Net: ProfilePage.tsx -930 LoC. The shape of the page no longer fights
with the way users read web pages.
2026-05-21 23:23:59 -05:00
Chad Curtis 671e3f14fe Rename Search tabs to Agora / Nostr / Accounts and pin Agora to client:Agora
The first Search tab is now 'Agora' (kindsOverride [33863 Campaigns,
36639 Pledges, 34550 Groups]) and additionally narrows results to
events whose NIP-89 'client' tag matches the running app name. Ditto's
relay (Agora's primary relay) indexes the multi-letter 'client' tag
server-side, so the constraint is fast and free; relays that don't
index it ignore the filter and return unfiltered results, which is a
graceful degradation rather than a correctness problem.

The second tab is renamed 'Nostr' to make its role explicit: it's the
unconstrained, cross-client firehose. The third tab (Accounts) is
unchanged.

Plumbing:

- useStreamPosts gains a clientName option that adds '#client': [name]
  to both the search and stream subscriptions.
- AgoraSearchTab reads config.clientName ?? config.appName from
  AppContext and passes it through.
- ?tab=communities and ?tab=activity continue to alias to ?tab=agora
  for back-compat with linked URLs.

Empty-state copy and tab/comments are updated.
2026-05-21 23:12:19 -05:00
mkfain 4dbf8b00ec Strip Ditto custom-tab system, cap orgs, move sidebar right
Three corrections from the previous profile-refocus pass:

1. ProfileOrganizationsStrip is now capped at 4 cards (the design always
   intended a single hero row on lg+). Overflow surfaces in a 'See all
   N organizations' modal triggered from the section header so a
   power-affiliated profile no longer pushes the rest of the page down.
   The grid switches to 1/2/4 cols at sm/lg to match the 4-card cap.

2. The supplementary rail moves from the left of the tab content to the
   right. The two-column grid template flips from
   '[300px_minmax(0,1fr)]' to '[minmax(0,1fr)_320px]', and the JSX
   reorders <section> before <aside>. The page canvas widens from
   max-w-6xl to max-w-7xl (matching CampaignsPage / AllCampaignsPage),
   so on ultrawides the sidebar sits closer to the viewport's right
   edge instead of floating in the middle of the content area.

3. Kind 16769 (Profile Tabs) is fully removed from ProfilePage. The
   pencil edit-mode, drag-to-reorder via dnd-kit, '+' Add custom tab
   menu, ProfileTabEditModal render, ProfileSavedFeedContent renderer,
   CORE_TAB_FILTERS map, NoTabsEmptyState, and all backing state
   (tabEditMode, localTabs, tabModalOpen, editingTab, dndSensors,
   profileTabsQuery, profileTabsData, profileSavedTabs, profileVars,
   publishProfileTabs) are deleted. The tab list is now the fixed
   DEFAULT_TAB_LABELS shown identically to owner and visitor, with the
   four legacy social tabs (Posts & replies, Media, Badges, Likes)
   reachable through a single overflow dropdown. This drops six
   imports (useProfileTabs, usePublishProfileTabs, ProfileTabEditModal,
   useResolveTabFilter, useTabFeed, useActiveTabIndicator), three
   dnd-kit module imports, the lucide icons Pencil, GripVertical, Plus,
   and the DropdownMenuSeparator unused after the deletion. The shared
   kind 16769 infrastructure stays in the codebase because SearchPage
   and the people-list features still depend on it.

The 'hasTabs' gate that protected against the legacy empty-tab-list
case is now structurally true and is inlined into the hook calls
(useProfileMedia, useProfileLikesInfinite, useWallComments) and tab
conditional renderers. Net diff: 154 added, 512 removed in
ProfilePage.tsx.
2026-05-21 23:08:57 -05:00
Chad Curtis 0622efc781 Replace Search Communities tab with Activity (campaigns + pledges)
The old Communities tab pinned kindsOverride to [34550] — communities
only. The new Activity tab pins it to [33863 Campaigns, 36639 Pledges]
so /search opens onto Agora's first-class non-kind-1 content. Kind 1
text posts (and their reposts) continue to live on the Posts tab; the
Accounts tab is unchanged.

URL contract:

- The new default is ?tab= absent → Activity.
- ?tab=activity is the explicit form.
- ?tab=communities still resolves (parseTab aliases it to activity) so
  existing links don't 404, but they now show the campaigns/pledges
  stream rather than a communities-only stream.

Empty-state copy and stale 'communities tab' comments are updated.
2026-05-21 23:06:57 -05:00
Chad Curtis edf9f77060 Default the Search Kind filter to Agora content only
Search previously defaulted to 'all kinds' — every kind in the picker
(50+), which means a user typing 'bitcoin' into the search bar got a
firehose of music tracks, podcasts, bird detections, geocaches, and
every other supported NIP. The Posts tab is meant to surface Agora
content; the long-tail kinds belong behind an explicit opt-in.

Introduce 'agora' as a new sentinel value for the Kind filter that
expands to AGORA_PRESET_KIND_VALUES (Campaigns, Pledges, Communities,
Posts, Articles, Events, Polls, Photos, Videos). Make it the default
in DEFAULT_FILTERS, teach parseKindFilter to resolve it, and render
both 'Agora content' and 'All kinds' as top-level selectable rows in
the KindPicker (with the existing Agora-curated quick-list still
appearing as individual selectable items below).

The active-filter chip summary suppresses a chip when the default
'agora' is in effect, surfaces 'All kinds' as a chip when the user
explicitly broadens the search, and continues to show individual kind
labels for everything else.
2026-05-21 23:02:57 -05:00
Chad Curtis f762a8b0d7 Add breathing room above SiteFooter and drop its top border
Removes the border-t border-border line above the global footer and
adds pt-12 so the footer separates from page content via whitespace
instead of a hairline divider.
2026-05-21 23:02:57 -05:00
Chad Curtis ee79b789a7 Drop the FeedCard wrapper from PostDetailPage
PostDetailPage wrapped the focused post (with ancestor previews,
ancestor thread chain, and the focused event itself) in one rounded
FeedCard surface and the replies list in another, with an extra
max-w-6xl mx-auto px-4 sm:px-6 outer container on top of the layout's
own column. That made the post detail page look boxy and inset
compared to the Activity feed and the Search results, which render
edge-to-edge in bare divs.

Match the Activity feed pattern exactly: drop every FeedCard wrapper
(focused post + replies + skeleton), drop the redundant outer max-w
+ px container, and let NoteCard's self-applied px-4 py-3 + bottom
border handle the row layout. Replies / reply-skeleton lists become
bare divs (with divide-y on the skeleton, since skeleton rows don't
self-border).
2026-05-21 23:02:57 -05:00
Chad Curtis 6a55092f2c Drop the FeedCard wrapper from Search results
Search results were rendered inside FeedCard — a rounded, bordered,
margin-inset card surface — while the main Activity feed renders posts
edge-to-edge in a bare div. The mismatch made search results look
'boxed in' compared to every other feed in the app.

Replace every FeedCard wrapper on the Search page (Posts, Communities,
Accounts, Follows lists, and their skeletons) with the bare-div pattern
used by Feed.tsx. NoteCard self-applies a bottom border, so the result
lists drop divide-y while skeleton lists keep it (skeleton rows don't
self-border).
2026-05-21 23:02:57 -05:00
Chad Curtis 59f1b07a03 Remove 'Add to feed' button from the Search page
The bookmark-style 'Add to feed' button next to the search input has
been removed. Saving a search as a home-feed tab or profile tab is
rarely used and clutters the search bar's filter affordances. The
underlying useSavedFeeds / usePublishProfileTabs hooks remain available
for callers that need them.

Also drops the now-unused SaveDestinationRow helper, the savePopover
state, and a handful of imports (BookmarkPlus, Check, Loader2, User,
TabFilter type, useSavedFeeds, useProfileTabs, usePublishProfileTabs,
useCurrentUser) that only existed to support the removed UI.
2026-05-21 23:02:57 -05:00
Chad Curtis 1dbac90108 Expose search in TopNav and add Agora kind presets to the Kind picker
Add a Search icon button to the TopNav right cluster (visible on all
breakpoints) that links to /search, so users no longer have to dig into
the mobile drawer or profile menu to reach search.

Surface Agora's main content kinds in the Search filter Kind picker:

- Add Campaigns (33863) and Pledges (36639) to buildKindOptions(), since
  they are first-class Agora content but live outside EXTRA_KINDS (which
  drives the sidebar / feed-settings UI and shouldn't be polluted with
  kinds that don't have their own toggleable feed/sidebar items).

- Group the curated Agora set — Campaigns, Pledges, Communities, Posts,
  Articles, Events, Polls, Photos, Videos — under an 'Agora content'
  section at the top of the KindPicker dropdown, with the long-tail list
  of every other supported kind under an 'All kinds' section below. When
  the user types in the search box the partition collapses to a flat
  result list.
2026-05-21 23:02:56 -05:00
mkfain c774405dc3 Restructure profile tabs around Agora activity
Re-order CORE_TAB_LABELS so the Agora-native tabs lead and the legacy
social tabs fall back to the overflow menu:

  Overview · Campaigns · Pledges · Activity · Posts · Wall (default)
  + Posts & replies · Media · Badges · Likes (overflow)

Overview becomes the default landing tab and replaces 'Posts' as the
first visible card on a fresh profile. CORE_TAB_FILTERS gets matching
NIP-01 filter entries for each new tab so kind 16769 events remain
interpretable by non-Agora clients (Campaigns → kinds:[33863] + author,
Pledges → kinds:[36639] + author, Activity → the Agora-feed kind set
plus #t:agora, Overview → the same Posts shape as a safe default).

The four new tab renderers live in src/components/profile/:

  - ProfileOverviewTab — composite: featured campaign + recent Agora
    activity preview + 3 most recent posts, with section-level 'see all'
    links into the deeper tabs. Empty-state shows own-profile CTAs to
    start a campaign / create a pledge.
  - ProfileCampaignsTab — full grid of the user's campaigns with
    New / Top sort and a Show-hidden toggle gated to the owner + Team
    Soapbox moderators. 'Top' sort fans out one receipts query +
    per-receipt verification across the visible set via useQueries (no
    rules-of-hooks violation), keying into the same caches as
    useCampaignDonations so verifier results are shared.
  - ProfilePledgesTab — pledges created by the user, split into Active
    and Ended groups, with a card visual that mirrors /pledges. 'Pledges
    backed' (zapped submissions on others' pledges) is intentionally
    deferred to v2 per the design plan.
  - ProfileActivityTab — useAgoraFeed scoped to a single author with
    infinite scroll; renders the mixed-kind timeline through NoteCard.

The 'I Have No Mouth, and I Must Scream' empty state on profiles whose
kind 16769 publishes an empty tab list is replaced with a neutral
one-liner — the Harlan Ellison quote rotation was an upstream Easter
egg that clashes with Agora's framing.
2026-05-21 22:51:40 -05:00
mkfain c738b60c7b Add Campaigns + Organizations hero strips and two-column profile body
Insert two responsive hero rows between the profile header and the tab
strip:

  - ProfileCampaignsStrip — renders the profile owner's campaigns as a
    full CampaignCard grid (1/2/3/4 columns at sm/lg/xl), capped at 6
    with a 'View all' link into the (forthcoming) Campaigns tab.
    Filters hidden campaigns out for visitors; own-profile sees them so
    creators understand their moderation state.

  - ProfileOrganizationsStrip — renders orgs the profile founded or
    moderates as CommunityMiniCards with a Founder / Moderator badge
    overlay. Backed by a new useProfileOrganizations hook that takes any
    pubkey and only surfaces public signals — kind 34550 author and
    kind 34550 #p moderator entries. The 'follows' axis (kind 10004
    bookmarks) is intentionally not shown because bookmarks are private
    state that another viewer can't see.

Both strips self-collapse when empty, so a Nostr-native profile with no
Agora activity remains compact.

The body below the tab strip becomes a two-column grid at lg+: a
300 px sticky left rail (the existing ProfileRightSidebar, now
inlined into a grid cell via a new variant='inline' prop) and a
min-w-0 right column for the tab content. Below lg the rail is hidden
and tab content flows full-width — the rail's profile-fields content
is already shown inline in the mobile header, so nothing is lost.

The legacy 'xl:hidden' inline profile-fields breakpoint shifts to
'lg:hidden' so the rail and the inline fields don't double up at lg+.
2026-05-21 22:44:41 -05:00
mkfain 70e78b7e5f Refocus profile header on Agora: campaigns, pledges, raised, donate
Replace the kind-1 posting streak chip with Agora-native stat chips
(Campaigns / Pledges / Raised), opt out of FundraiserLayout's
max-w-3xl cap so the profile can use a max-w-6xl canvas like
CommunityDetailPage, and surface a Donate button (single-campaign
direct, multi-campaign dropdown) next to Follow when the profile has
at least one on-chain campaign.

The stat chips wrap on narrow viewports and click through to the
corresponding tab id ("campaigns" / "pledges") — those tabs land
in commit 3.

Adds useProfileCampaignStats, which fans receipt-verification queries
out across the profile's campaigns in parallel using the same kind
8333 -> Esplora verification path as useCampaignDonations.

Drops two leftover Ditto comments (relay.ditto.pub media note,
"Ditto-style profiles" shape cleanup). The dead useLayoutOptions
rightSidebar registration is removed — FundraiserLayout silently
ignored it.
2026-05-21 22:39:52 -05:00
mkfain 4e9c6b37d3 Rename Support nav label to Campaigns, Organize to Groups
Touches user-facing labels only:

- TopNav: Support -> Campaigns, Organize -> Groups
- Sidebar: Organize -> Groups
- MobileBottomNav: Organize -> Groups
- /communities hero kicker: Organize -> Groups

Routes, hooks, and the country-organizers admin feature
(`OrganizersPage` / `useOrganizers` — a separate concept covering
appointed pinners for country feeds) are left alone. Code comments
referring to the "Organize hero" are kept as-is so future readers can
still find their way around by structural name.
2026-05-21 22:06:36 -05:00
mkfain c09775473a Widen the Support page so desktop fits more campaigns per row
Drops MainLayout's default 600px column cap on /campaigns/all (the same
`noMaxWidth: true` trick the Pledge index uses) and lifts the grid
from `sm:grid-cols-2` to `sm:grid-cols-2 lg:grid-cols-3
xl:grid-cols-4`. Mobile and small tablets stay 1/2 columns so the
cards keep their tappable size; the inner `max-w-7xl` wrapper keeps
the page from sprawling on ultrawide monitors. No hero added — the
existing toolbar still sits directly under the page title.

Skeleton placeholder count bumped 4 → 8 so the loading state fills the
wider grid instead of leaving most of the row empty.
2026-05-21 22:02:14 -05:00
mkfain af483d9989 Cut Approved from organization moderation, fix Featured load latency
Organizations now use a two-axis moderation model — featured and hidden.
The approval axis is campaign-only. Every Agora-tagged organization is
publicly visible by default; moderators curate by lifting orgs into the
Featured shelf or suppressing them with a Hidden label, nothing in
between.

Concretely:

- CommunityModerationMenu drops the Approve / Unapprove items. The org
  side never publishes those labels; useOrganizationModeration's
  mutate() rejects them defensively so a stray UI bug can't poison the
  label stream with axis-mixed events. The shared ModerationData type
  still tracks approvedCoords for symmetry with the campaign side, but
  the org UI never reads or writes it.

- /communities loses the in-flight 'Approved organizations' grid that
  was never finished and was conceptually wrong here. The moderator-only
  rail formerly called 'Pending review' is renamed 'Needs review' and
  re-derived as t:agora AND not featured AND not hidden.

Two perf fixes that should make Featured paint noticeably faster:

- The 200-event discovery query and the 2000-event label fold that
  power the moderator rails now live INSIDE ModeratorReviewSections.
  Non-moderator viewers (the overwhelming majority on /communities)
  never trigger either query — they used to fire at the page level
  whether they were used or not.

- Every CommunityMiniCard used to subscribe to useOrganizationModeration
  individually so it could overlay the Hidden badge and kebab. With
  18+ cards per page, that meant 18 component subscriptions to a
  query no non-mod cared about. The badge + kebab now live inside a
  single CommunityModerationOverlay that's gated on isMod up front
  and returns null for everyone else; non-mods never subscribe.

- staleTime on useOrganizationModeration and useFeaturedOrganizations
  bumped from 30s to 5m, with a 1-hour gcTime. Moderators feature and
  hide on human timescales, not seconds — repeat visits to /communities
  shouldn't pay a fresh relay round-trip every half-minute. Local
  changes still invalidate the keys explicitly so moderator actions
  show up immediately.

Also reverts two pieces of unrelated scope creep I built by mistake:
the 'Approved campaigns' section on the org detail page and the
moderator kebab on the campaign detail page. Those weren't asked for —
they were chasing a misread of an earlier instruction.

NIP.md updated to reflect the campaign-vs-org axis split and the new
'Needs review' surface name.
2026-05-21 21:43:48 -05:00
mkfain 7ea0f0977d Scope org moderation surfaces and add Approved campaigns to org page
Three related changes:

1. Pending review on /communities now filters to orgs that carry the
   t:agora marker. Without this gate every kind 34550 community on the
   network ends up in the moderator queue — badge-gated NIP-72 spaces,
   music scenes, anything else — none of which Agora moderators are
   expected to triage. ParsedCommunity now carries its source event's
   tags so the check can run without re-fetching, and a hasAgoraTag()
   helper joins withAgoraTag() in src/lib/agoraNoteTags.ts.

2. Campaign detail page gets the same moderator Feature/Approve/Hide
   kebab the campaign cards already have. CampaignModerationMenu drops
   into the hero's top-right row next to the creator's Edit/Delete
   buttons; it self-gates on Team Soapbox membership and renders null
   for non-moderators, so creators who aren't moderators see no change.
   Moderation state is read from the same useCampaignModeration cache
   the rest of the page consumes.

3. Org detail page gets a dedicated 'Approved campaigns' section that
   mirrors the home page's surfacing rule
   (approvedCoords ∩ !hiddenCoords). The org's campaigns are split
   client-side: approved-and-not-hidden go into the new grid section
   above the mixed activity rail, everything else stays in the
   existing OfficialActivityShelves to avoid double-rendering. The
   section only renders when at least one campaign is approved, so
   unmoderated orgs don't show an empty rail.

Per the user's constraint, no org moderation UI is exposed on the org
detail page itself — moderation actions for organizations stay on
CommunityMiniCard (grid cards) only. The detail-page banner dropdown
remains founder-only (Edit, Delete, View leadership).
2026-05-21 21:32:57 -05:00
mkfain b10335efc1 Add Pending review and Hidden sections to /communities for moderators
Mirror the home page's moderator review rails on the organizations page.
Below 'Featured organizations,' moderators (Team Soapbox pack members)
now see two collapsible sections:

  - Pending review — kind 34550 orgs with no approve or hide label yet.
  - Hidden — orgs whose latest hide-axis label is 'hidden.'

Both sections fetch a 200-event slice via useDiscoverCommunities and
fold it against useOrganizationModeration's approved/hidden coords. The
existing CommunityMiniCard kebab menu lets moderators feature, approve,
hide, or unhide directly from each card.

Sections are collapsed by default when long (>6 orgs), expanded
otherwise — same heuristic as the campaign-side ModeratorSection.
Non-moderators see no change to the page.
2026-05-21 21:21:25 -05:00
mkfain 9fd585ebdd Moderator-curated featured organizations
Replace the hardcoded FEATURED_ORGANIZATION_AUTHORS allowlist with the
same NIP-32 label flow that curates featured campaigns: Team Soapbox
pack members publish kind 1985 labels in the agora.moderation namespace
tagging an organization's 34550:<pubkey>:<d> coordinate as featured,
hidden, or approved, and useFeaturedOrganizations folds those labels
into the /communities Featured shelf.

The campaign and organization label streams share a single namespace
and a single moderator pack — they're separated purely by which kind
prefix the 'a' tag carries. To keep that contract enforced in one
place, the constants, types, and folding logic are now in
src/lib/agoraModeration.ts; useCampaignModeration and the new
useOrganizationModeration both call foldModerationLabels with their
respective kind. The campaign hook's external surface
(AGORA_MODERATION_NAMESPACE, ModerationLabel, CampaignModerationData)
is preserved via re-exports so existing call sites don't move.

Moderators see a CommunityModerationMenu kebab overlaid on every
CommunityMiniCard exposing approve/unapprove, hide/unhide, and
feature/unfeature. Mounting reads moderation state once per page from
the shared TanStack cache, mirroring CampaignCard. Non-moderators see
no overlay (the menu returns null) and no card affordances change.

The 'My organizations' shelf intentionally ignores moderation — a
user's own founded, moderated, or followed organizations always render
regardless of label state. Only the Featured shelf consumes the
curation rollup.

The Featured grid is uncapped: moderators control how many orgs
surface by labeling, and ordering follows the recency of each
'featured' label so re-publishing bumps an org to the top.

NIP.md's 'Campaign Moderation Labels' section is renamed to 'Agora
Moderation Labels' and documents the kind-34550 coord form and the
'My organizations ignores moderation' rule.

Note: existing surfaced organizations will disappear from the shelf
until a moderator publishes featured labels for them.
2026-05-21 21:12:33 -05:00
mkfain c2fee23582 Let founders delete their organization
Add a 'Delete organization' item to the banner-overlay dropdown on the
community detail page, gated by the existing isFounder check. Clicking
it opens an AlertDialog that publishes a NIP-09 (kind 5) deletion
request referencing the community definition by both 'e' and 'a' tags
via the shared useDeleteEvent hook.

On success we invalidate every org-related query key the hook doesn't
touch — addr-event for this community, community-definition,
manageable-organizations, featured-organizations, and the
followed-organizations sub-queries — then navigate back to
/communities so the user lands on a list that already reflects the
deletion. Errors surface as a destructive toast.

The confirmation copy is explicit about NIP-09's advisory nature
(well-behaved relays will honor it; campaigns and pledges published
under the organization stay on-chain) and points users toward 'Edit
organization' as the non-destructive alternative.
2026-05-21 20:57:14 -05:00
mkfain 0a7388ac2f Replace organizations horizontal scroll with responsive grid
The /communities page previously rendered both 'My organizations' and
'Featured organizations' as horizontally-scrolling shelves of 256px
cards, which hid most content off-screen and made it tedious to browse.

Now both shelves use a new responsive CommunityGrid (1/2/3/4 columns by
breakpoint) inside the existing max-w-5xl content column. Card visuals
stay identical at the desktop breakpoint (~232-256px wide); the only
difference is that cards now wrap onto subsequent rows.

'My organizations' starts collapsed at the first 4 entries with a
'Show N more' / 'Show less' toggle, replacing the prior 18-card cap so
power users can see everything in one place when they want to.
Featured stays a full grid (the curated list is intentionally short)
and its loading skeleton count is bumped from 4 to 8 to fill two rows.
2026-05-21 20:52:12 -05:00
mkfain fed1bb9ce0 Render kind 33863 campaigns as rich cards in the activity feed
Campaign-launch events (kind 33863) previously fell through to the
generic text-note path in NoteCard, rendering as just the author row
plus the campaign's raw markdown story. No banner, no title, no
progress, no donor count, no goal, no deadline, no link to the
campaign page.

Wire campaigns through the same polished CampaignCard component the
campaign directory already uses:

- New CampaignNoteCardContent component — thin wrapper that parses the
  event and renders a CampaignCard. Malformed events silently drop.
- NoteCard: add isCampaign flag, exclude from isTextNote, add the
  dispatch branch, and import HandHeart for the action header.
- KIND_HEADER_MAP: 33863 entry gives 'Alice launched a campaign'
  header above the rich card (uses publishedAtAction so edits read
  'updated a' instead of 'launched a'). nounRoute points at
  /campaigns/all so the bold 'campaign' word is clickable.
- CommentContext: register 33863 in KIND_LABELS ('a campaign'),
  KIND_SUFFIXES ('campaign'), and KIND_ICONS (HandHeart) so comments
  on campaigns render with proper context labels instead of falling
  back to 'an unsupported event'.

The whole feed card now links to the campaign's naddr-based detail
route via CampaignCard's existing <Link> wrapper.

Embedded quote-preview rendering for kind 33863 and notification-stack
integration are deferred — out of scope for the activity-feed card.
2026-05-21 20:36:04 -05:00
mkfain b864a73573 Rename TopNav 'Feed' to 'Activity' 2026-05-21 20:26:01 -05:00
mkfain d52d9e25a5 Clip Agora layer to Nostr recency window in All Nostr / Following modes
After paginating the All Nostr feed past a certain depth the visible
items would degenerate back into Agora-only content. The root cause:

- The Nostr (kind 1) firehose is dense — one page of 30 events covers
  ~minutes of real time.
- The Agora layer is sparse — one page of 25 events covers ~days.
- With lockstep pagination both layers' `until` cursors advanced at
  roughly the same rate per page, so after several pages the Nostr
  cursor was at -30 minutes while Agora was at -30 days.
- When scrolling down through the chronologically-sorted merge, the
  user eventually reached timestamps below the Nostr cursor's reach,
  where the only remaining buffered items were old Agora ones.

Fix:

1. Compute the Nostr layer's "floor" (oldest loaded timestamp) and
   only render Agora items at or above that floor. Older Agora items
   are held back until Nostr catches up.

2. Smarter pagination — in mixed mode, always advance Nostr (it's the
   dense layer driving the scroll), but only advance Agora when its
   floor is at or above Nostr's. Otherwise we'd fetch Agora pages
   that can't be displayed because they sit below the visible window.

3. `hasNextPage` is now Nostr-driven in mixed modes — once Nostr is
   exhausted the feed is genuinely done, even if Agora still has more
   buffered older items (they were already shown earlier in scroll).

Pure Agora mode is unaffected (still paginates Agora directly).
2026-05-21 20:26:01 -05:00
mkfain 7506ed7dec Make All Nostr feed actually pull the firehose
The All Nostr layer was previously routing through `useFeed('global')`,
which has two problems for a 'show me everything' surface:

- It uses Ditto's `search: 'sort:hot protocol:nostr'` NIP-50 extension.
  Relays that don't honor this extension return nothing; relays that do
  return only curated 'hot' content, not the firehose.
- It filters by `getEnabledFeedKinds(feedSettings)`, so only kinds the
  user explicitly enabled in their feed-kind settings come through.

Replace the indirection with a dedicated `useNostrLayer` infinite query
inside `useMixedFeed` that pulls kinds [1, 6, 16] (notes + reposts) with
no `search:` filter and no kind-settings gate. The result is a straight
chronological pull of recent Nostr activity, which is what 'All Nostr'
should actually mean.

Following mode reuses the same layer with an `authors:` filter, replacing
the previous `useFeed('network')` path. The follow-list gate is preserved
so a logged-in user doesn't briefly see the global mix before their
follows arrive.
2026-05-21 20:26:01 -05:00
mkfain 4188e926a4 Post to any country, white Post button text
The post-to-country picker previously only listed countries the user
followed, so a user following VE couldn't post about Iran without
following Iran first. Two changes fix this:

- New "Choose another country…" item at the bottom of the destination
  dropdown opens a searchable CommandDialog over the full COUNTRY_LIST.
  Search matches both name and ISO code ("iran" and "IR" both work).
- The dropdown's quick-pick list now also includes any ad-hoc country
  the user has selected via the picker, even if not in their follow
  list, so they have a one-tap way back to it.
- canChooseDestination no longer requires followedCountries.length > 0;
  any logged-in user composing a top-level kind 1 can now pick a country.
- Snap-back guard now only fires when the selected code is invalid
  (deleted from the country directory), not just because it isn't in
  the follow list.

Also: the orange Post! / Publish poll button text is now forced white
via a className override. Previously it relied on the theme's
--primary-foreground token which was producing low-contrast text on
the orange background.
2026-05-21 20:26:01 -05:00
mkfain f4688137bc Persistent default country, drop weather + organize feed, simplify mode switcher
Five small UX improvements bundled together.

Default post-to-country with localStorage persistence
- New `useDefaultPostCountry` hook (localStorage-backed) hydrates the
  composer's destination from a saved preference on every fresh compose.
- ComposeBox's country-destination picker (formerly a shadcn Select)
  becomes a DropdownMenu so it can mix country options with an action
  item.
- New "Set as default" item appears at the bottom of the dropdown
  when the current selection is not already the saved default; clicking
  it persists the choice and shows a toast.
- A passive "Country X is your default" label replaces the action
  item when the current selection already is the default.
- resetComposeState now resets to the saved default instead of
  hardcoded 'world', so the next compose lands where the user expects.
- The existing snap-back-to-world guard now also clears the saved
  default if it points at a country the user has just unfollowed.

Remove weather from country feed pages
- Drop `WeatherVitalsRow`'s weather panel — the row keeps the
  population / languages / currency vitals but no longer renders
  temperature, sky description, or icon.
- Remove the live day/night sky-overlay flip on the country hero;
  default to the warm daytime gradient.
- Remove the `PrecipitationEffect` overlay (animated rain/snow) from
  country pages.
- Delete the now-orphan `useWeather` hook and
  `PrecipitationEffect` component.

Remove organization activity feed from /communities
- Drop the `OrganizationActivityFeed` section and its helpers.
- /communities is now a directory page: hero + My organizations shelf
  + Featured organizations shelf.
- Delete the now-orphan `useOrganizationHomeActivityFeed` and
  `useOrganizationMembersOnlyFilter` hooks.

Compact FeedModeSwitcher
- Strip the per-item descriptions ("Campaigns, pledges, donations,
  and Agora posts", etc.) from the home-feed mode dropdown.
- Each menu item is now a single line: icon + label + optional check.
- Shrink menu width from w-72 to w-56 to match the new content density.
- Keep the disabled-Following tooltip — that's a state explanation,
  not help text.
2026-05-21 20:26:01 -05:00
mkfain 7ee35644e3 Strict t:agora tagging for all Agora-created content
The Agora activity feed now filters strictly to Agora-created content via
the relay-indexed single-letter `t:agora` tag. Multi-letter tags like
NIP-89 `client` are not indexed by relays and cannot serve this purpose.

Every event Agora publishes that represents first-class Agora content
now carries `["t", "agora"]`, added via a new `withAgoraTag` helper
in `src/lib/agoraNoteTags.ts` that dedupes against any user-supplied
`t:agora` tag.

Tagged at publish time:
- Communities (kind 34550) — CreateCommunityPage
- Campaigns (kind 30223) — CreateCampaignPage, useArchiveCampaign
- Pledges (kind 36639) — CreateActionPage (alongside agora-action)
- Calendar events (kinds 31922 / 31923) — CreateEventPage and
  CreateCommunityEventDialog
- Onchain zaps (kind 8333) — useOnchainZap, useDonateCampaign,
  SendBitcoinDialog
- Zap goals (kind 9041) — CreateGoalDialog
- NIP-22 comments (kind 1111) — usePostComment, covering every comment
  authored from within the app regardless of root kind
- Kind 1 notes — already covered by ComposeBox default tags

Intentionally not tagged: reactions, reposts, follows, profile metadata,
lists, settings, badges, vanish requests, encrypted DMs, live chat.

useAgoraFeed tightened:
- Entity kinds and Agora-comment kinds now require `#t=agora` at the
  relay layer (server-side filter).
- World layer (kind 1111 / 1068 with `#k=iso3166|geo`) remains
  unfiltered — intentionally cross-client.
- `#Agora`-tagged kind 1 notes still surface from any author (preserves
  viral / opt-in discovery via user-typed hashtags).
- Donation enrichment now requires the Agora marker on zap receipts.
- `isRelevantAgoraEvent` rewritten as a strict checker that demands
  the marker for everything outside the world layer.

Legacy content without the marker disappears from the feed. It remains
reachable by direct link and via kind-specific directories (e.g.
`/campaigns/all`). Authors who edit a legacy event through the Agora UI
will automatically add the marker via the helper.

NIP.md updated with a new "Agora Content Marker" section under "Agora
Protocols" — documents the tagged-kind table, the untagged-kind list,
the canonical query shape, and the backward-compatibility behavior.
2026-05-21 20:25:01 -05:00
mkfain b83d35fc75 Remove flag backdrop behind world posts, keep corner flag pill
The CountryFlagBackdrop rendered a faded full-width Wikipedia flag image
across the top of every country-rooted (kind 1111, iso3166-rooted) post.
It cluttered the feed and competed with post content. Drop it.

The CountryCommentPill in the upper-right of the card header is retained
— it remains the sole country chrome for world posts.

Removed:
- CountryFlagBackdrop component from CommentContext.tsx
- Both NoteCard render sites (threaded + normal layouts)
- The CountryFlagBackdrop import in NoteCard
- Dead imports in CommentContext: useState, getWikipediaTitle,
  customFlagAsset, useFlagPalette, useWikipediaSummary

Updated jsdoc on useIsCountryRooted to reflect that country chrome
is now just the pill, not pill+backdrop.
2026-05-21 20:22:36 -05:00
mkfain f08e3d6226 Add three-mode home feed with Agora / All Nostr / Following
The home /feed page now offers a top-left dropdown to switch between three
chronological streams:

- Agora: campaigns, pledges, donations, communities, comments on Agora
  entities, and #Agora-tagged kind 1 notes (the existing useAgoraFeed mix,
  widened for NIP-72 communities).
- All Nostr: the global kind 1 stream interleaved with the full Agora mix.
- Following: same content scoped to authors in the logged-in user's
  follow list (gracefully gated when the follow list is loading or empty).

Implementation:

- useMixedFeed orchestrates the three modes, paginating the Agora and
  kind 1 layers in lockstep and merging chronologically.
- useAgoraFeed now accepts an optional authors filter (server-side) so
  Following mode doesn't fetch and discard the global Agora mix. It also
  includes new community definitions (34550) and community-scoped
  comments (1111 with A=34550:...).
- FeedModeSwitcher is the new top-left picker: large text2xl trigger,
  shadcn DropdownMenu with iconified options and active checkmark.
  Following is disabled (with tooltip) for logged-out users.
- AGORA_DEFAULT_NOTE_TAGS moved to src/lib/agoraNoteTags.ts; ComposeBox
  now auto-attaches t:agora to every top-level kind 1 from anywhere in
  the app (replies, quotes-as-replies, polls, and comments are unaffected).
- Feed mode persistence upgraded from sessionStorage to localStorage so
  preference sticks across sessions.
- Feed entry added to TopNav as the first item.

The globe backdrop, hue rotation, and translucent card treatment are
removed for a cleaner solid-background presentation. Specialized
feed pages (kind-specific, tag-filtered) keep the original Follows /
Global tab pair unchanged.
2026-05-21 20:22:36 -05:00
Alex Gleason 75337cc5bf Default bip352IndexerUrl to silentpayments.dev (Dana's default) 2026-05-21 19:31:30 -05:00
Chad Curtis d1c53df4d4 Style campaign donate CTAs with white text and white QR logo 2026-05-21 19:27:57 -05:00
Alex Gleason 059f75dbc5 Scan for silent payments in /hdwallet via BIP-352 indexer 2026-05-21 19:25:24 -05:00
Chad Curtis 6693f2c153 Render wallet QR in donate dialog when signer can't sign PSBTs 2026-05-21 19:16:50 -05:00
Chad Curtis 8436c7b787 Drop campaign title from SP donation disclaimer
The parenthetical '(<campaign title>)' after 'organizer' was awkward
and redundant -- the donor already knows which campaign they're on.
Strip it and remove the now-unused `campaignTitle` prop from
CampaignWalletDonatePanel.
2026-05-21 19:04:41 -05:00
Chad Curtis 710aa08818 Remove inaccurate URL preview from campaign form
The 'URL preview' line showed '/<slug>', but campaigns are routed via
NIP-19 naddr (`/:nip19`), not by slug. The actual URL is
'/naddr1...' which only exists after publish. Showing the slug
masquerading as a URL was confidently wrong, so drop it.
2026-05-21 19:04:41 -05:00
Chad Curtis 935c121bab Implement Campaign kind 33863
Replaces every kind 30223 surface with kind 33863 -- the self-authored
fundraising campaign with a single `w` Bitcoin wallet endpoint. Hard
cutoff: no migration, no dual-read, no legacy support.

Schema (src/lib/campaign.ts):
- `CAMPAIGN_KIND` constant bumped 30223 -> 33863.
- New `CampaignWallet` type with `onchain` (`bc1q`/`bc1p`) and `sp`
  (`sp1`) modes, prefix-disambiguated. Bitcoin-mainnet only;
  testnet/regtest/lightning prefixes are rejected at parse time.
- New `parseCampaignWallet()` validates bech32 via bitcoinjs-lib for
  on-chain addresses and shape-checks silent-payment codes.
- `ParsedCampaign` drops `recipients`, `category`, `tags`,
  `location`, `archived`, `image` (-> `banner`), `goalSats` (->
  `goalUsd`). Adds `wallet` and `bannerImeta` (parsed NIP-92).
- `CAMPAIGN_CATEGORIES`, `CampaignCategory`,
  `LEGACY_CAMPAIGN_CATEGORY_ALIASES`, `getCampaignPrimaryTagLabel`,
  `splitDonation`, `minDonationForSplit`, `DonationSplit`,
  `CampaignRecipient` removed.

Publishing (useDonateCampaign):
- Single-output PSBT paying `campaign.wallet.value`.
- Kind 8333 receipt has NO `p` tags -- campaigns are not Nostr-identity
  recipients. `i`, `amount`, `a`, `K`, `alt` only.
- SP campaigns are refused with a clear error directing donors to
  external BIP-352-capable wallets via the on-page QR/copy panel.

Verification (useOnchainZaps.verifyOnchainZap):
- Two modes: identity-recipient (existing `p`-tag derivation) and
  campaign-wallet (match outputs against `campaign.w`). The branch is
  selected by whether the receipt has an `a` tag pointing at a kind
  33863 campaign. SP-targeted receipts are rejected.

Querying (useCampaigns, useAllCampaigns, useCampaignDonations):
- Drop `category`, `recipientPubkeys`, `includeArchived` options.
- `useCampaignDonations` now takes a `ParsedCampaign` and verifies
  every receipt on-chain against the campaign's `w` address before
  counting it. SP campaigns short-circuit to zeros.
- Search drops `location` and `t`-tag branches; title/summary/story
  only.

UI:
- `CampaignCard`, `HeroCampaignSpotlight`, `CampaignsPage`: drop
  recipient counts, archived/category badges, `location`; use
  `banner`. Silent-payment campaigns render a "Private -- totals not
  public" notice instead of progress.
- `CampaignDetailPage`: archive flow replaced with NIP-09 kind 5
  deletion. Drops the multi-beneficiary recipient column. Donate column
  shows the in-app PSBT donate button (on-chain) plus the always-
  available external-wallet QR/copy panel. SP campaigns show the panel
  only -- no in-app donate.
- `CreateCampaignPage`: drops Beneficiaries section, tag input, and
  USD-to-sats conversion. Adds a Wallet field with mode-aware
  validation hint. Goal is integer USD. Banner upload captures NIP-94
  tags and converts to NIP-92 `imeta` at publish.
- `DonateDialog`: collapses ~1200 LOC of split logic into a single-
  output flow. Form -> Confirm -> Success. Logged-out and signer-
  unsupported users are pointed at the external-wallet panel.
- New `CampaignWalletDonatePanel` component (replaces the
  pubkey-derived `BeneficiaryDonateDialog`). QR + copy + open-in-wallet
  for any `bc1`/`sp1` endpoint, with mode-appropriate privacy notice.

Removed:
- `useArchiveCampaign` hook (closure via NIP-09 deletion only).
- `ClaimPage` and its `/claim` route (claim-for-someone-else flow no
  longer applies -- campaigns are self-authored).
- `BeneficiaryDonateDialog.tsx` (replaced by
  `CampaignWalletDonatePanel.tsx`).
- Community-donate synthesis hack in `CommunityDetailPage` (no more
  fabricating a `ParsedCampaign` from community moderators).

NIP.md was updated separately to specify Kind 33863.
2026-05-21 19:04:41 -05:00
Chad Curtis d66eaf6aa4 Redefine Campaign kind: 30223 -> 33863 with wallet endpoint
Replaces the kind 30223 Campaign spec with a clean kind 33863 design.
Hard cutoff: no migration, no dual-read, no legacy support.

Key changes:
- Kind number 33863 (FUND on T9 keypad).
- Single `w` tag carries one bech32(m) wallet endpoint. Prefix
  selects the mode: `bc1q`/`bc1p` for public on-chain, `sp1` for
  silent payments (BIP-352). Other prefixes are rejected.
- Recipient `p` tags removed. Campaigns are self-authored; the
  event author is the beneficiary and owns the wallet in `w`.
- No split weights, no dust calculations, no BIP-340/341 Taproot
  derivation from Nostr pubkeys.
- `t` topic tags and `agora.category` labels removed. Discovery is
  via search, country (`#i`), and moderator curation.
- `image` -> `banner`, with required NIP-92 `imeta` for dim,
  blurhash, MIME, and SHA-256.
- `goal` is a single integer USD value, no unit, no currency code.
- `status` tag removed. To close a campaign, publish a NIP-09 kind
  5 deletion referencing the campaign's `a` coordinate.
- `location` legacy tag removed.
- Kind 8333 receipts for campaigns carry no `p` tags; verification
  matches tx outputs against the campaign's `w` address. SP
  campaigns publish no receipts and hide all aggregate UI by design.
2026-05-21 19:03:55 -05:00
Alex Gleason 774305f799 Use nsec directly as BIP-32 seed (NIP-SP §2.2)
Drop the HKDF stretch from both wallets. The 32 bytes of nsec are now
fed straight to BIP-32's master step:

  master = HMAC-SHA512("Bitcoin seed", nsec)
  bip86  = master / 86'  / 0' / 0' / chain / index
  bip352 = master / 352' / 0' / 0' / {0',1'} / 0

For silent payments this matches NIP-SP §2.2 exactly — Agora's sp1q…
is now interoperable with every other NIP-SP-compliant client (Ditto,
reference implementations) for the same nsec.

BIP-86 also moves to direct nsec → BIP-32 for symmetry. BIP-32's
hardened derivation at the purpose level (86' vs 352') already
provides cryptographic isolation between the two branches; the HKDF
sub-tags were redundant with that guarantee.

Trade-off: the previous HKDF design domain-separated the Bitcoin
sub-system from the nsec's other uses (Schnorr signing, NIP-04 ECDH,
NIP-44 ECDH). That property is dropped in favor of spec compliance
and recoverability from nsec alone. In practice the operations on
nsec across these protocols are independent enough that no known
interaction leaks the scalar through any one of them.

Existing wallet addresses change (the seed bytes change); since this
feature has no users yet, no migration is needed.
2026-05-21 18:38:36 -05:00
Alex Gleason 95a1e966bc Use HKDF with "NostrWallet" salt and per-purpose info tags
Replace the previous "agora-hdwallet:bip86:v1" HKDF info string with a
two-step HKDF design suitable for proposal as a NIP:

  PRK          = HKDF-Extract(salt = "NostrWallet", IKM = nsec)
  seed_<purp>  = HKDF-Expand(PRK, info = "NostrWallet/<Purp>", L = 64)

Registered purposes:

  "NostrWallet/Bip32"          — generic BIP-32 master (BIP-44/49/84/86)
  "NostrWallet/SilentPayments" — BIP-352 master

The salt is a protocol-level constant (no app-specific string), so any
"NostrWallet"-compliant client recovers the same wallets from the same
nsec. Per-purpose info tags give the BIP-86 and BIP-352 wallets
cryptographically independent BIP-32 masters — neither's keys reveal
the other's.

This deliberately diverges from NIP-SP §2.2, which specifies the nsec
itself as the BIP-32 seed for silent payments. The HKDF step preserves
domain separation from every other use of nsec (Schnorr, NIP-04,
NIP-44) at the cost of incompatible sp1q… addresses with §2.2-only
clients. NIP-SP is a draft; this is the design we believe should land.
2026-05-21 18:10:58 -05:00
Alex Gleason b6f90a03c4 Merge branch 'main' of gitlab.com:soapbox-pub/agora 2026-05-21 17:46:47 -05:00
Chad Curtis 51d50e3b33 Remove "React" label from reaction button 2026-05-21 17:40:10 -05:00
Alex Gleason b53cb20d61 Replace Discover with Support nav link
Remove the Discover page and route entirely. In the top nav, swap the
Discover entry for a Support link pointing at /campaigns/all (the all
campaigns directory).

Also drops the now-orphaned DiscoverHero component and useDiscoverFeed
hook, and updates the NIP.md campaign moderation note that referenced
the deleted /discover route.
2026-05-21 17:39:15 -05:00
Chad Curtis 2b3a2e7daf Remove NIP-05 username from note card header 2026-05-21 17:38:44 -05:00
Chad Curtis 5c4cf3011e Merge remote-tracking branch 'origin/main' into fundraiser-detail-redesign
# Conflicts:
#	src/pages/CampaignDetailPage.tsx
2026-05-21 17:34:33 -05:00
Chad Curtis 81f1fd5d1f Use foreground token in soft Bitcoin disclaimer for light-mode contrast 2026-05-21 16:39:14 -05:00
Alex Gleason 5d872e9a95 Add silent payment (BIP-352) address to /hdwallet Receive dialog
Derive a static sp1q… identifier from the user's nsec via BIP-352's
spend/scan key paths (m/352'/0'/0'/0'/0 and m/352'/0'/0'/1'/0), then
bech32m-encode scan_pubkey || spend_pubkey with HRP "sp" and version 0.

The Receive dialog now has two tabs: the existing BIP86 fresh-address
flow and a Silent payment tab that shows the static address with QR
and copy. The SP tab is labelled receive-only — the wallet doesn't yet
scan for incoming silent payments, so funds sent there won't appear in
the balance until scan + spend support is wired in.
2026-05-21 16:23:54 -05:00
Lemon 8efd7c7128 Merge branch 'feat/pin-updates' into 'main'
Feat/pin updates

See merge request soapbox-pub/agora!33
2026-05-21 14:12:47 -07:00
lemon 48744aa13d Refine calendar event details 2026-05-21 14:08:11 -07:00
lemon b7d33577f1 Restore event RSVP detail card 2026-05-21 14:08:11 -07:00
lemon ffb9c93ee6 Add pins across detail comments 2026-05-21 14:08:11 -07:00
lemon 97ec528b50 Normalize organization activity cards 2026-05-21 14:08:11 -07:00
lemon 4dd913d3ca Redesign calendar event details 2026-05-21 14:08:11 -07:00
lemon e41e8396d7 Align detail comment widths 2026-05-21 14:08:11 -07:00
lemon cf10654ea6 Align campaign activity widths 2026-05-21 14:08:11 -07:00
lemon 0cf5614502 Move campaign pins below hero 2026-05-21 14:08:11 -07:00
lemon 62517cc062 Join detail hero action bars 2026-05-21 14:08:11 -07:00
lemon b32ae751a2 Use megaphone for boost action 2026-05-21 14:08:11 -07:00
lemon ad2e9a2ee9 Align event detail comments layout 2026-05-21 14:08:11 -07:00
lemon 0f85584294 Reuse detail comment composer 2026-05-21 14:08:11 -07:00
lemon 1a53f3047d Add campaign activity composer 2026-05-21 14:08:11 -07:00
lemon dc959f6360 Move campaign actions below hero 2026-05-21 14:08:11 -07:00
lemon c6ca9b8042 Add campaigner badge icon 2026-05-21 14:08:11 -07:00
lemon f0724f705f Refine campaigner activity badge 2026-05-21 14:08:11 -07:00
lemon 48794fa3b4 Pin campaign activity updates 2026-05-21 14:08:11 -07:00
Alex Gleason ce4a53b61e Remove /wallet link from /hdwallet 2026-05-21 15:56:22 -05:00
Alex Gleason 68ed98c7b5 Move /hdwallet QR to Receive dialog, refresh by clicking balance 2026-05-21 15:46:20 -05:00
Alex Gleason 24c4fe0dc7 Source /hdwallet BTC price from Trezor Blockbook
/hdwallet was using useBtcPrice, which calls mempool.space's /v1/prices
Esplora extension. That kept the HD wallet quietly dependent on a second
backend (mempool.space) even after the rest of the page moved entirely
to Blockbook.

Blockbook itself ships a getCurrentFiatRates WebSocket method that
returns { ts, rates: { usd: <number> } }. Adding a thin wrapper around
it and a dedicated useHdBtcPrice hook keeps /hdwallet's network surface
contained to the single Blockbook endpoint the user has configured;
errors surface in one place and there's no soft dependency on
mempool.space anymore.

The app-wide useBtcPrice continues to serve /wallet, zap UI, NoteCard,
CampaignCard, etc. — unchanged.
2026-05-21 15:37:06 -05:00
Chad Curtis fe1061f81b Redesign beneficiary donate panel; align content edges; full-bleed at lg 2026-05-21 15:31:04 -05:00
Alex Gleason 881ddf3c81 Default /hdwallet Blockbook endpoint to btc.trezor.io
The canonical endpoint Trezor Suite itself uses is btc.trezor.io
(unnumbered). The numbered mirrors (btc1..btc5) resolve to the same
Cloudflare-fronted backend pool but aren't enumerated in Suite's
defaults, so matching Suite's choice is the lowest-surprise option.

Existing users keep their persisted blockbookBaseUrl; only the
out-of-the-box default and docstrings change.
2026-05-21 15:25:36 -05:00
Alex Gleason 2e5a262864 Switch /hdwallet Blockbook client to WebSocket transport
The public Blockbook REST endpoints (btc1.trezor.io etc.) don't send
CORS headers, so browsers reject every response. Blockbook also exposes
a WebSocket API at wss://<host>/websocket — Trezor Suite's actual
production transport — which has no same-origin restriction and lets us
multiplex every request over one persistent connection.

The module keeps its existing public API (fetchXpubSnapshot,
fetchXpubUtxos, fetchFeeRates, broadcastBlockbookTx, fetchBlockbookStatus)
so scan.ts and HDSendBitcoinDialog don't change. Under the hood:

- BlockbookSocket: one persistent WS per base URL, lazy connect,
  id-keyed request/response correlation, per-call AbortSignal + timeout,
  idle auto-disconnect after 90s, fail-all on close.
- fetchFeeRates now uses a single estimateFee call with blocks=[1,3,6,144]
  instead of four parallel REST round-trips. The WS API returns sat/vB
  directly, removing the BTC/kB conversion.
- URL transform: https://host -> wss://host/websocket (idempotent).
2026-05-21 15:14:35 -05:00
Chad Curtis 421d4f366e Tighter campaign hero on desktop; align back button to content width; drop boxes 2026-05-21 15:09:48 -05:00
Chad Curtis 7777271df1 Taller campaign hero with deeper, taller bottom gradient 2026-05-21 15:02:04 -05:00
Chad Curtis 840769af21 Move campaign tag to bottom meta row; drop tacky Tag icon 2026-05-21 14:59:56 -05:00
Chad Curtis bfc8d1ab07 Full-bleed campaign hero with overlaid title, creator, summary 2026-05-21 14:57:38 -05:00
Alex Gleason f1000f1838 Rewrite /hdwallet on Trezor Blockbook xpub API
The HD wallet at /hdwallet now talks exclusively to a single Blockbook
endpoint (default: https://btc1.trezor.io, configurable via AppConfig's
new blockbookBaseUrl). One scan refresh is exactly two HTTP calls
regardless of wallet size:

  - GET /api/v2/xpub/<tr(xpub)>?details=txs&tokens=used
      Returns account-level balance, the list of used derived addresses
      with their BIP32 paths, and the full tx history -- everything we
      need to populate the UI -- in one response.

  - GET /api/v2/utxo/<tr(xpub)>
      Returns the UTXO set with paths attached, so the coin selector
      and signer can recover (chain, index) without redoing the
      derivation walk.

This replaces the previous Esplora architecture which made dozens of
per-address requests per refresh and routinely tripped mempool.space's
public rate limits.

What changed:

- New src/lib/hdwallet/blockbook.ts: HTTP client for Blockbook's xpub,
  utxo, estimatefee, and sendtx endpoints. No failover list, no
  fallback to other indexers; errors surface to the user. Per-request
  timeout still applies (20s) so a hung connection doesn't lock the UI.

- src/lib/hdwallet/derivation.ts gains accountToBip86Descriptor() which
  wraps account.accountNode.publicExtendedKey as `tr(<xpub>)`. The
  bare xpub prefix would default Blockbook to BIP44; the `tr(...)`
  wrapper selects BIP86 Taproot.

- src/lib/hdwallet/scan.ts is now a thin translator from the Blockbook
  response shape into the existing AccountScanResult shape consumed by
  the page and the send dialog. The previous gap-walk, snapshot
  derivation, cache hydration, and inter-batch pacing are all gone --
  Blockbook indexes the xpub server-side. Every server-returned
  address is re-derived locally and discarded if it doesn't match, so
  a compromised backend can't redirect funds to its own addresses.

- src/lib/hdwallet/snapshot.ts and cache.ts deleted (Esplora-era only).

- useHdWallet drops esploraApis dependency, reads blockbookBaseUrl,
  bumps refresh to 60s (was 120s; with only 2 calls per refresh we can
  afford it).

- HDSendBitcoinDialog reads fee rates via fetchFeeRates (Blockbook
  /estimatefee for blocks 1/3/6/144 in parallel) and broadcasts via
  broadcastBlockbookTx. UTXOs come from the shared scan result, no
  separate fetch.

- AppConfig: new blockbookBaseUrl: string field, defaulted to
  https://btc1.trezor.io in App.tsx, TestApp, and the Zod schema.

/wallet and the rest of the app continue to use Esplora for the
single-address wallet, on-chain zaps, NIP-73 tx/address pages, and
campaign donations. No shared backend abstraction yet; this is
deliberate -- Blockbook's xpub endpoint is unique to HD wallets.

Privacy note: the full account xpub now goes to the configured
Blockbook server on every request. Users who don't want that exposure
can self-host Blockbook and point blockbookBaseUrl at it. Default
remains Trezor's public mirror.
2026-05-21 14:50:48 -05:00
Alex Gleason 772a2de236 Fix HD wallet cold-scan-on-every-refresh + pace bursts
Two bugs working together caused the HD wallet to make ~60 /txs requests
on every page refresh (well over public Esplora rate limits, visible as
clusters of HTTP 429s in devtools).

**Bug 1: cache hydration race.** useHdWallet used a useEffect-driven
ref to mirror the persisted PersistedScan into livePrevRef. On the first
render, useCurrentUser/useNostrLogin hadn't resolved yet, so pubkey was
"" and useSecureLocalStorage returned the default for the unknown
"hdwallet:scan:none" key. The hydration effect ran, populated
livePrevRef with an empty stub, and the "already populated" guard
prevented it from re-hydrating once pubkey became real. Result: every
single page refresh ran a cold gap-limit scan even though localStorage
held a perfectly good cached skeleton.

Fix: drop the effect entirely. Inside queryFn, read the cache directly
via secureStorage.getItem(scanCacheKey(pubkey)). The query is gated on
`pubkey !== ''` so by the time queryFn runs, the key is real. After the
scan completes, both livePrevRef (in-memory) and the persisted copy
are updated. No effect, no race, no flicker.

**Bug 2: burst concurrency.** Even with the cache fixed, a true cold
scan (fresh install) was still firing two chains × Promise.all(5) = 10
in-flight requests at once, plus the warm path's refresh-known-used
and walk-forward-from-index ran in parallel = double again on warm
scans. mempool.space rate-limits at the burst level, so even moderate
concurrency tripped 429s.

Fixes in scan.ts:
- SCAN_BATCH_SIZE 5 -> 3.
- INTER_BATCH_DELAY_MS = 250 between consecutive batches inside one
  chain walk, with a sleep() helper that honours the abort signal.
- scanChain warm path: refreshKnownUsed then walkForwardFromIndex,
  serially (was Promise.all).
- scanAccount: receive chain then change chain, serially (was Promise.all).

Net effect on a steady-state wallet with the cache populated:
~3-6 requests per refresh (only known-used addresses + tail probe),
paced ~250ms apart, spread over ~1-2s. First-ever cold scan is
~40 requests but paced into 14 batches over ~3.5s, well under any
sensible rate limit.

Also removed the unused EMPTY_PERSISTED_SCAN export from cache.ts
(no longer needed now that useHdWallet reads storage directly).
2026-05-21 14:07:48 -05:00
Chad Curtis 5920523b57 Flatten Home Feed settings; merge mutes inline; drop jargon
Reduce cognitive load on the Content settings page by collapsing the
two-section toggle layout, group sub-headers, sub-kind rows, kind
badges, and column headers into a single flat list of 14 toggles
ordered by importance: Posts, Replies, Reposts, Articles, Highlights,
Photos, Videos, Voice Messages, Events, Polls, Organizations, Badges,
Reactions, Zaps.

Each row is now a plain label + one-line description + switch. No
content-kind icons, no [1234] kind-number badges, no Media / Social /
Whimsy sub-headers, no Normal/Short video or Badge Definitions /
Profile Badges / Badge Awards sub-rows (the parent toggle now governs
all sub-kinds together).

Combine kind 6 (Reposted Notes) and kind 16 (Reposted Other Content)
into a single "Reposts" toggle via extraFeedKinds: [16]. The old
feedIncludeGenericReposts flag stays in the schema for backwards
compat but no longer surfaces in UI.

Rename "Comments" -> "Replies" — Nostr's NIP-22 threading is most
naturally called replies.

Strip NIP / kind-number references from all curated descriptions
(NIP-22, NIP-52, NIP-58, NIP-68, NIP-71, NIP-72, NIP-84, NIP-A0,
"kind 30009", etc.). Plain English only.

Merge the standalone /settings/content page (mutes + sensitive
content) into /settings/feed as inline sections under the toggle
list, since both are about "what you see in the feed." Delete
ContentPage.tsx and its route; remove the Content entry from the
settings index. Drop the giant ShieldAlert icon from the sensitive
content intro.

Rename "Home Feed Tabs" -> "Saved Feeds" in the page section heading.
2026-05-21 13:48:19 -05:00
Alex Gleason ee8e4f0bcb Cut HD wallet Esplora request volume ~10x
Three layered optimizations to stay under public Esplora rate limits
(mempool.space's ~30 req/min) without giving up the 60s-class refresh feel:

1. Collapse three calls into one per used address.
   New fetchAddressSnapshot() (src/lib/hdwallet/snapshot.ts) calls
   /address/:addr/txs once and derives AddressData, the simplified
   Transaction[], and the UTXO set from the same response. Drops the
   separate /address/:addr and /address/:addr/utxo calls the scan
   was making per address. UTXOs are reconstructed by spent-output
   bookkeeping (output to us minus input from us, the standard
   Electrum-style trick). Esplora caps the response at 25 confirmed
   txs; we flag that case via `historyCapped` for callers that need
   uncapped totals -- our gap-limit scan does not.

2. Incremental warm-scan.
   scanAccount(account, esploraApis, signal, prev?) now accepts a
   previous result. When supplied, it refreshes only known-used
   addresses + a tail past prev.firstUnusedIndex (parallel) instead
   of re-walking the full BIP44 gap from zero. The cold path is
   unchanged; only the steady-state cost drops.

3. Persist the scan skeleton across reloads.
   New src/lib/hdwallet/cache.ts defines a minimal versioned
   PersistedScan (used-index lists + firstUnusedIndex per chain),
   stored via useSecureLocalStorage keyed by pubkey. useHdWallet
   hydrates this into a stub AccountScanResult on mount and feeds
   it as `prev` to the very first scanAccount call after a reload
   -- so the wallet does a warm scan, not a cold scan, on every
   page load after the first ever.

Supporting changes:

- The separate tx-history query is gone; tx aggregation moved into
  the pure buildHdTransactions(scan) helper that runs in memory from
  snapshot data. Previous implementation duplicated every used
  address's /txs fetch every refresh.

- Refresh interval bumped from 60s to 120s. With the incremental
  scan it's only ~5 requests/refresh on a steady wallet (down from
  ~50+).

- Disabled refetchOnWindowFocus on the scan query to avoid a
  request storm when the user tabs back in mid-interval.

- HDWalletPage gets an explicit Refresh button (with isFetching
  spinner) so users have a manual override now that the auto-refresh
  is less aggressive.
2026-05-21 13:43:27 -05:00
Chad Curtis 4aa358d685 Limit content-type toggles in settings to Agora-curated kinds
The settings UI iterates EXTRA_KINDS and renders a toggle row per kind,
which exposed every Nostr content type the app understands (vines,
treasures, colors, decks, webxdc, birdstar, emoji packs, music,
podcasts, development, etc.) regardless of whether they fit Agora's
activist-utopian framing. The result was a wall of toggles with no
meaningful default.

Add an `agora` boolean to ExtraKindDef and mark only the curated set:
posts, comments, reposts, generic-reposts, reactions, zaps, articles,
highlights, photos, videos (with sub-toggles), voice messages, events,
polls, organizations (NIP-72 communities), and badges. Filter the
"Basic Home Feed Options" and "Show More Content Types" sections to
`def.agora === true`. Move badges from the "Whimsy" section into
"Social" so the Whimsy and Development groups vanish entirely after
filtering.

Enable zaps in the home feed by default (they're core engagement,
not noise) and drop "Disabled by default" from the zaps description.

Other pages (KindFeedPage deep-links, ExternalContentHeader quoted
events, etc.) still see the full EXTRA_KINDS registry, so external
content from non-curated kinds still renders correctly when linked.
2026-05-21 13:36:20 -05:00
Chad Curtis f811245f90 Make settings utilitarian and disable whimsical content types by default
Remove the spellbook-themed settings index: drop the "Codex of
Configuration" heading, the gradient ornaments with ✦/◆ dividers, the
sigil that appeared after two minutes of inactivity, and the IntroImage
illustration tiles on every section row and sub-page intro block. The
index is now a flat divider-separated list of labels and one-line
descriptions, with breathing room on both sides.

Delete the Magic settings page, its CursorFireEffect overlay, the
animate-sigil-glow / animate-pulse-slow keyframes, the magicMouse
AppConfig flag (schema, default, test fixture), and the /settings/magic
route. Delete the now-unreferenced IntroImage component and the ten
*-intro.png assets it masked.

Disable content types that don't fit an activist tool by default: vines,
treasures (geocaches + found logs), colors, decks, webxdc, birdstar
(detections / birdex / constellations), emoji packs, custom emojis, user
statuses, music, podcasts, and development. They remain available in
settings — just off out of the box. Highlights is bumped on by default
to pair with Articles. Posts, comments, reposts, articles, highlights,
events, polls, communities, people lists, badges, photos, videos, and
voice messages stay on.
2026-05-21 13:22:57 -05:00
Alex Gleason b0561a5503 Esplora REST failover with abort signals and timeouts
Replace the single `esploraBaseUrl: string` with `esploraApis: string[]`
and route every Esplora REST call through a new `esploraFetch` helper
that handles ordered failover across multiple API endpoints.

The failover client:

- Tries URLs in order with a per-attempt 15s timeout. mempool.space has
  a shadowban-style rate-limit behaviour where requests are silently
  absorbed and never reply; the timeout converts that hang into a
  regular failover signal so the next URL is tried.
- On `429` / `5xx` / network error / timeout, parks the URL in a
  module-level cool-down with exponential backoff (30s, 60s, 120s,
  240s, 300s cap) and advances to the next.
- Resets a URL's failure count on the first 2xx response, so the
  primary comes back into rotation as soon as it recovers.
- Treats configurable `skipStatuses` (e.g. `404` on `/v1/prices`) as
  endpoint-capability mismatches: skip without penalising the endpoint.
  This lets non-mempool backends like Blockstream coexist in the list
  even though they don't expose the price extension.
- Composes a caller-supplied AbortSignal with the per-attempt timeout
  via AbortSignal.any. Caller aborts (e.g. TanStack Query queryFn
  unmounts) propagate immediately; timeouts mark the endpoint failed
  and try the next URL.
- Falls back to cooled-down endpoints when *every* URL is in cool-down,
  rather than failing outright.

Default list is mempool.space \u2192 mempool.emzy.de \u2192 blockstream.info.

Every helper in `src/lib/bitcoin.ts`, `src/lib/hdwallet/scan.ts`, and
`verifyOnchainZap` now takes `(input, esploraApis: string[], signal?: AbortSignal)`.
Every TanStack Query caller threads its `queryFn` signal through.
Mutations (broadcasts, send/donate/onchain-zap flows) still call
without an explicit signal but get the 15s per-attempt timeout.
2026-05-21 13:17:56 -05:00
Alex Gleason 522c265041 Add HD Bitcoin wallet at /hdwallet
A production-grade BIP86 Taproot HD wallet, separate from the single-address
wallet at /wallet. The seed is derived deterministically from the user's nsec
via HKDF-SHA-256 with an app-specific info string, so there is no new secret
for the user to back up \u2014 if they have their nsec they have the wallet.

Architecture:

  - src/lib/hdwallet/derivation.ts  \u2014 HKDF seed, BIP86 (m/86'/0'/0'),
    receive (0) and change (1) chains, per-leaf P2TR addresses, TapTweaked
    signing keys.

  - src/lib/hdwallet/scan.ts  \u2014 Standard gap-limit (20) scan across both
    chains via Esplora. Aggregated UTXO set, balance, and tx history
    (merged by txid so send-with-change shows as one row).

  - src/lib/hdwallet/transaction.ts  \u2014 Largest-first coin selection
    (confirmed first), multi-input P2TR PSBT build with per-input
    tapInternalKey from re-derived child keys, fresh change addresses on
    the internal chain (no address reuse).

  - useHdWalletAccess  \u2014 Gates on login type === 'nsec'. Extension and
    bunker logins keep the key isolated, so the page shows an explanatory
    card with a link back to /wallet.

  - useHdWallet  \u2014 Scan + tx history queries (60 s refresh), persisted
    receive-cursor in secure storage (Keychain on native, localStorage on
    web), auto-advance when chain catches up so old addresses are never
    re-shown.

  - HDWalletPage  \u2014 Mirrors /wallet's clean UX: big USD balance, send
    button, QR + truncated address, 'New address' rotator, collapsible tx
    history.

  - HDSendBitcoinDialog  \u2014 Mirrors SendBitcoinDialog (USD presets, fee
    speed picker, two-tap arm for large amounts, raw-address privacy
    disclaimer, success screen) but uses the HD UTXO set across many
    addresses and signs with per-input HD-derived keys.
2026-05-21 12:48:40 -05:00
Mary Kate 25ef304e42 Merge branch 'fix/wallet-recovery-warning-contrast' into 'main'
Fix wallet recovery warning contrast in light mode

Closes #24

See merge request soapbox-pub/agora!29
2026-05-21 14:48:41 +00:00
sam 4cd2aadba2 Lift the Planetora event panel off the background
The panel was bg-background/40 + backdrop-blur — a fogged version of
the same hue as the warm dawn-toned page background, which on mobile
made the bottom sheet visually merge with the globe area underneath.

Now:
- Surface fill bumped to bg-background/85 so the panel reads as a
  distinct surface instead of a tinted overlay (backdrop-blur is
  retained so a hint of the globe still glows through).
- Border tightened to border-border/70 plus a hairline foreground ring
  to crisp the edge against any backdrop colour.
- Mobile sheet gets an explicit two-direction shadow (top + bottom)
  rather than shadow-2xl, which only casts downward — for a sheet
  pinned to the bottom of the viewport that downward cast falls off
  screen and leaves no visible separation. The new top-edge shadow is
  what actually lifts the sheet off the globe.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-21 19:58:41 +07:00
sam e57a3029f5 Track event latitude when focusing the camera
The selected event eased toward lng=0 (centre of the visible
hemisphere) but latitude stayed pinned at the resting tilt
(VIEW_TILT_DEG = 20°). For events near the equator that pushed the
marker well below the disc's centre — and on mobile, where the bottom
sheet sits over the lower half of the screen, the focused location
ended up partly behind the panel (e.g. a Venezuelan post sitting at
~8°N landed roughly 50 px below the globe's centre, right in the
panel's overlap zone).

Now both rotLng and rotLat ease toward the selected event's coords so
the focal point lands at the centre of the disc, well clear of the
mobile panel. With nothing selected the camera settles back to
VIEW_TILT_DEG. The existing depth-based fading uses the same eased
latitude so rings and the selected dot continue to fade correctly as
the globe tumbles.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-21 19:56:20 +07:00
sam 7590af5a76 Drop the orphaned WebGL Planetora globe and its react-globe.gl dep
PlanetoraGlobe.tsx hadn't been rendered since the SVG renderer landed —
only its PlanetoraTheme type was still referenced. Move the type into
PlanetoraSvgGlobe.tsx (next to the only renderer that consumes it),
delete the legacy file, and trim WARM_THEME of the WebGL-only fields
(countrySideFill, atmosphereColor, background) along with their now
defunct comments.

react-globe.gl (which pulled in three.js, ~280 kB gzip) drops out
entirely — `npm uninstall react-globe.gl` cleans node_modules and the
shipped bundle. Stale doc-comment references to PlanetoraGlobe in the
playback / auto-pilot hooks are pointed at PlanetoraSvgGlobe.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-21 19:53:17 +07:00
sam 605e7f599f Shift the Planetora globe up (not down) when the mobile panel opens
When the bottom-sheet event panel was opened on mobile, the SVG canvas
was extended off the bottom of the viewport — which centres the globe
*lower* in screen space, sliding it down behind the panel. Flip the
extension to the top edge instead so the globe centres in the
panel-free band above the sheet, the way it does on desktop with the
right-side panel.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-21 19:36:51 +07:00
sam f5bc774aba Hide the Planetora bottom HUD on mobile while an event panel is open
The bottom-sheet event panel and the timeline / live-stat HUD both
anchor to the bottom of the viewport on mobile, so they fight for the
same pixels — the panel covers the HUD and the page reads as
cluttered. Add a hideBottomHud flag and toggle it on whenever the
mobile event panel is up; the HUD fades out so the panel is the
unambiguous focus, and fades back in when the panel closes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-21 19:16:58 +07:00
sam 3b125592d1 Layer the homepage's atmospheric backdrop behind Planetora
Tracking bg-background was correct for theme awareness but left the
page flat — solid cream in light mode, solid black in dark mode —
which read as nothing like the homepage's hero. Layer in the same
recipe: a warm/cool primary→secondary gradient wash, a directional
dawn-gold scrim from the upper-left, a big radial sunrise glow
brightened with mix-blend-screen, a thin top-edge sliver of
sunrise light, and the homepage's fractal-noise film grain.

Adds peach corners + a soft warm glow in light mode and a deep warm
atmosphere around the globe in dark mode, matching the hero
section's vibe without copying its content.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-21 15:38:02 +07:00
sam e318ca0550 Have Planetora's page background follow the app's light/dark theme
Pinning Planetora to a fixed dawn-sky gradient broke the visual
contract with the rest of the app — the homepage tracks bg-background
(cream in light mode, near-black in dark mode) and the warm globe
already pops in either, so there's no reason for Planetora to pick
its own background. The page now uses bg-background like everything
else; the warm sphere palette stays fixed.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-21 15:31:28 +07:00
sam af36a9c7d5 Lighten the Planetora page background to a dawn sky gradient
Even with the warm globe palette in place, sitting it on a near-black
slab still made the page feel heavy. Swap the solid dusk navy for a
pale dawn sky — powder blue overhead easing into peach-cream near the
horizon — so the cream globe sits in light instead of a void, and the
honey-shaded limb naturally blends into the warmer lower band.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-21 15:27:53 +07:00
sam d7729b705e Recolour Planetora globe with the homepage's warm dawn palette
A dark globe with bright orange splashes was reading as a
surveillance / situation-room dashboard rather than a friendly
community-activity visualiser. Planetora now uses the same warm
"dawn earth" tones as the homepage HeroGlobe — a cream→honey radial
sphere, sandy-amber inactive countries, terracotta active countries,
cream pulse rings, and a soft dusk-navy page background — regardless
of the surrounding app's light/dark mode.

Adds three sphere-shading fields (sphereCentre / sphereMid /
sphereEdge) to the shared PlanetoraTheme so the SVG renderer can
paint a real lit sphere instead of stacking opacities of the page
background. Drops the now-unused light/dark CSS-variable observer in
the page (the palette is fixed) along with its hsl() helper.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-21 15:23:41 +07:00
sam e04342668b Add a continuous ping animation to the selected event marker
The selected marker was just a static halo ring around a dot, which
made the focused event read as a flat target. Two phase-offset pulse
rings now expand outwards continuously while the marker is shown,
matching the visual language of the per-event rings so a focused event
still feels alive.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-21 15:16:08 +07:00
Lemon 590e592cf0 Merge branch 'ui/fix-home-page-contrast' into 'main'
ui/fix-home-page-contrast

See merge request soapbox-pub/agora!30
2026-05-21 01:08:03 -07:00
sam 80b56b3318 Render Planetora countries with d3-geo orthographic projection
Drops the hand-rolled projection / hemisphere clipper for d3-geo's
geoOrthographic + geoPath. The previous implementation cut a straight
chord across the back of the disc when a polygon (Russia, Greenland,
Antarctica…) crossed from front to back to front, leaving abrupt flat
edges along the limb. d3 walks the limb arc properly via clipAngle(90),
so the silhouette matches the sphere.

Also drops the camera "zoom" by lowering RADIUS from 285 to 250, giving
the globe ~17% more breathing room from the viewport edges.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-21 15:05:25 +07:00
sam 7dc1afc5a1 fix white text on light background 2026-05-21 01:03:13 -07:00
sam a42522dda2 mid light/dark coloured gradient/shadows that thus contrast on light and dark mode 2026-05-21 01:02:56 -07:00
sam 3ade1e8126 Cap Planetora event panel height to clear the bottom HUD
The panel anchored at top-36 with max-h calc(100vh-10rem) could extend
to within 16px of the bottom edge, overlapping the "now showing" summary
that lives inset-x-0 bottom-0. Reserving 18rem (≈9rem top + 9rem
bottom) instead keeps tall notes scrollable inside the panel body
while leaving the timeline / live HUD visible underneath.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-21 14:55:41 +07:00
sam 279c8b914c Drop the orange limb glow on the SVG globe
The rim gradient was layering a thick atmosphere-coloured band onto the
sphere's edge, which on the dark theme reads as a glowing orange halo
ring. Sphere shading is fine without it — the base radial fill plus the
highlight already give the disc enough depth.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-21 14:54:13 +07:00
lemon 9a5d3e56fe Replace Discover with Agora feed 2026-05-21 00:50:14 -07:00
lemon fe43906cf1 Remove mobile FAB bottom-nav offset 2026-05-21 00:47:38 -07:00
sam 9313e9b1d7 Shift Planetora globe off-centre when the event panel is open
Restores the layout the WebGL globe had: the SVG canvas now extends off
the left edge (desktop) or bottom edge (mobile) by the panel's width or
height, so the globe sits centred in the *panel-free* space and stops
visually colliding with the opened note.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-21 14:45:14 +07:00
lemon 0d1d782437 Refine feed composer and FAB placement 2026-05-21 00:43:25 -07:00
lemon 7f93dcb3af Make home feed Agora-only 2026-05-21 00:38:50 -07:00
sam 9ca70dfcc2 Swap WebGL globe for a pure-SVG renderer in Planetora
Drops the dependency on react-globe.gl / WebGL so the page works on
machines and browsers that fall back to software rendering. Country
polygons and pulse rings are projected each rAF tick and applied
imperatively to ref'd SVG nodes, so the React tree stays quiet during
animation.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-21 14:34:31 +07:00
lemon 3b9eef908f Tag default notes as Agora 2026-05-21 00:30:37 -07:00
lemon 948e6b70b6 Ignore spammy Agora hashtag author 2026-05-21 00:20:27 -07:00
lemon afe2bf1c28 Enlarge feed backdrop globe 2026-05-21 00:17:19 -07:00
lemon ae3daef072 Add Agora feed tab 2026-05-21 00:15:04 -07:00
lemon da6cab8784 Use Agora logo for compose FAB 2026-05-20 23:52:56 -07:00
sam 4067904e09 Merge branch 'main' into cooking/planetora 2026-05-21 13:51:53 +07:00
lemon 2722ee1dcd Restore feed compose FAB 2026-05-20 23:48:26 -07:00
lemon 475843cd27 Add feed content filter layer 2026-05-20 23:37:45 -07:00
lemon fd97b76fbb Make feed surfaces transparent 2026-05-20 23:36:14 -07:00
lemon 587d7eb5ba Extend feed wash across viewport 2026-05-20 23:32:49 -07:00
lemon 5c6b9b3baf Soften feed backdrop transitions 2026-05-20 23:28:17 -07:00
lemon f6947aca9b Refine feed globe backdrop 2026-05-20 23:21:36 -07:00
lemon 4df6197a9a Add globe backdrop to feed 2026-05-20 23:19:55 -07:00
filemon 559a52f46f Fix wallet recovery warning contrast in light mode
The amber warning alert used bg-amber-500/5 (5% opacity — nearly invisible)
and text-amber-900. The faint background combined with potential
tailwind-merge ambiguity between the Alert variant's text-foreground and
the override text-amber-900 resulted in poor readability in light mode.

Align with the established BitcoinPublicDisclaimer pattern:
- bg-amber-500/5  → bg-amber-50        (solid visible amber tint)
- text-amber-900  → text-amber-950      (darkest amber for max contrast)
- border-amber-500/50 → border-amber-300/60 + dark:border-amber-500/30
- dark:bg-amber-950/50 for a distinct dark-mode background
- Icon: !text-amber-500 → !text-amber-600 dark:!text-amber-400

No wallet logic, sweep behavior, or component structure changed.
2026-05-20 20:42:39 -03:00
mkfain c4778471bb Clarify that RoboSats is Lightning-only; needs Boltz swap first
RoboSats trades Lightning Bitcoin, not on-chain, so a user cashing
out from their Agora address can't drop straight into it. Update the
Donor Guide ('use non-KYC Bitcoin'), the Activist Guide
('Peer-to-peer exchange'), and the cash-out comparison row to spell
out the two-step path: Boltz to swap on-chain → Lightning, then
RoboSats to trade for fiat. Bisq and HodlHodl still trade on-chain
directly and are listed separately.
2026-05-20 16:41:32 -05:00
Chad Curtis a3964662fa Merge branch 'feat/organize+pledge' into 'main'
Communities -> Organizations & Actions -> Pledges

See merge request soapbox-pub/agora!27
2026-05-20 21:16:27 +00:00
mkfain 97cf2763a5 Reframe 'why no rotating addresses' around money-transmitter risk; move Monero last
The previous answer to 'Why doesn't Agora generate a new address for
every donation?' focused on the single-point-of-failure angle. Lead
with the bigger reason: rotating addresses would mean Agora has to
take custody of the Bitcoin before forwarding it on to activists,
which makes Agora a money-transmitting service. That brings
regulatory exposure and creates a real chokepoint someone could
shut down to stop every donation flowing through the platform.

Also reorder the Bitcoin Donations section so 'Why not Monero or
another cryptocurrency?' sits as the very last item, after the
silent-payments and rotating-addresses explanations.
2026-05-20 16:05:12 -05:00
Alex Gleason 0bb55ebb97 Merge branch 'main' of gitlab.com:soapbox-pub/agora 2026-05-20 15:36:57 -05:00
mkfain 5983583388 Drop guide hero eyebrow; promote 'Donor Guide' / 'Activist Guide' to headline
The eyebrow label in the top-right of the guide hero was redundant
with the headline beside it. Cut the eyebrow prop and use the page
name ('Donor Guide' / 'Activist Guide') as the actual headline. The
'Back to Help' chip stays on its own row at the top of the hero.
2026-05-20 15:33:37 -05:00
mkfain b1d4237bee Give Donor and Activist guides photo heros and expand payment Q&A
Replace the plain sticky 'Back to Help' bar on the two guide pages
with a hero section in the same recipe as the Organize and Actions
homepage heros: rotating photo banner (HeroBanner) + atmospheric tint
(HeroAtmosphere) + scrims + overlay copy. Sub-page sized — ~280px
instead of the 460px homepage heros — and embeds a glassy
'Back to Help' chip in the eyebrow row so navigation out stays
prominent without a separate sticky strip.

- Donor Guide reuses the World Liberty Congress photos in /public/hero
  with the cool palette: reads as community / supporters.
- Activist Guide reuses the protest cover gallery from
  DEFAULT_ACTION_COVERS with the warm hope palette: reads as people in
  motion.

Both pages drop into the new shared GuideHero component to keep the
two pages DRY.

FAQ updates in the Bitcoin Donations section:

- 'Why on-chain Bitcoin?' now spells out that on-chain donations
  require zero extra setup for activists who already have a Nostr
  account and zero extra setup for donors who already hold Bitcoin —
  the accessibility argument that makes Agora viable for normal
  people.
- 'Why doesn't Agora use Lightning?' now names Strike and Breez
  alongside Wallet of Satoshi as examples of the popular custodial
  Lightning wallets that can be shut down or pressured.
- New 'Why not Monero or another cryptocurrency?' item: Bitcoin's
  adoption is what makes it easiest for donors to send and activists
  to receive and spend; niche coins create a barrier neither side
  should have to clear.
2026-05-20 15:33:37 -05:00
mkfain a20a91de0d Broaden consumer-app examples in Donor Guide and add sticky guide nav
The Donor Guide and a few related FAQ items singled out Cash App as
the example of a custodial consumer Bitcoin app. Replace those with a
broader list — Cash App, Coinbase, Strike, Venmo, PayPal, Kraken,
Binance — so it doesn't read as picking on one product. The Bisq
Pros/Cons and the 'What consumer apps can't do' section heading
follow the same edit.

Also move the 'Back to Help' navigation on the Donor and Activist
guide pages from a bottom button into a sticky top bar, PWA-style. It
sits right under the TopNav (top-16, z-30) and stays visible while
scrolling so users can return to /help from anywhere on the page
without scrolling back up. Replaces the previous PageHeader, which
hid its back arrow on desktop and made navigating out of the guides
awkward.
2026-05-20 15:33:37 -05:00
mkfain be262fe0d6 Cut 'What is Agora for?' and the entire Network & Safety FAQ section
The 'What is Agora for?' item duplicated 'What is Agora?' from the
About Agora section. Drop it.

The Network & Safety section was carrying Nostr-protocol explainers
(feed, relays, Blossom, Mastodon/Bluesky comparison, profile fields,
reporting) that aren't core to Agora's donation flow. Drop the whole
section from the visible FAQ.

The six IDs in that section that other pages reference via HelpTip
(fyp, what-are-relays, what-are-blossom, report-content, vs-mastodon-
bluesky, profile-fields) move into the existing hidden 'Legacy'
category alongside the Lightning/zap stubs, so the call sites on
NetworkSettingsPage, ContentSettingsPage, ContentPage, SearchPage,
RelayListManager, and ProfileSettings continue to resolve.
2026-05-20 15:33:37 -05:00
mkfain 8f065379a0 Merge Bitcoin Donations FAQ section and drop Lightning/zap content
The 'Bitcoin Donations' and 'About Bitcoin Payments on Agora' FAQ
sections were saying overlapping things in two places. Combine them
into a single 'Bitcoin Donations on Agora' section, placed in the
higher slot (right after 'About Agora').

Agora's donation flow is on-chain only, so Lightning and zap items are
removed from the visible FAQ. The two IDs that other pages reference
via HelpTip (send-bitcoin-lightning in ZapDialog, what-are-zaps in
ProfileSettings) are kept in a new hidden 'Legacy' category so those
call sites don't break. HelpFAQSection skips hidden categories on the
default render but the per-ID lookup used by HelpTip still finds them.

Also adds a 'Back to Help' button at the bottom of both the Donor
Guide and Activist Guide pages so users don't have to scroll back up
to the header to navigate out.
2026-05-20 15:33:37 -05:00
mkfain 99e4fd0406 Trim Help FAQs to core Agora features and rename categories
The FAQ accordion was carrying a lot of generic Nostr-client content
(profile field formats, app-store availability, Mastodon/Bluesky
comparisons, marketing copy) that wasn't relevant to Agora's core: on-
chain Bitcoin donations to activists.

Restructure into four focused categories:

- 'About Agora' (formerly 'Getting Started') — what Agora is, what
  Nostr is, key management, cost.
- 'Bitcoin Donations' — how sending works, the wallet, zaps,
  censorship resistance.
- 'Network & Safety' — relays, Blossom, reporting, profile fields.
- 'About Bitcoin Payments on Agora' (formerly 'About Agora') — the
  design-rationale Q&A added in the previous commit (why not
  Lightning / silent payments / rotating addresses).

All FAQ item IDs referenced by HelpTip on other pages are preserved
(connect-wallet, fyp, profile-fields, what-are-zaps, vs-mastodon-
bluesky, send-bitcoin-onchain, send-bitcoin-lightning, what-is-nostr,
what-are-relays, what-are-blossom, report-content, censorship-
resistance) — their content has been rewritten to be relevant to
Agora's donation flow without breaking the callers.

Also moves the 'Need help? Meet Team Soapbox' follow-pack card from
the top of the page to the bottom, after the FAQ.
2026-05-20 15:33:37 -05:00
mkfain e7f7d9419d Add Donor and Activist guide pages with privacy-focused content
Help page now opens with an amber disclaimer that Agora is recommended
only for above-ground activism, followed by two large buttons routing to
new /help/donors and /help/activists guide pages.

The guides cover how on-chain donations work on Agora, why they're
publicly visible on the Bitcoin blockchain and Nostr, and the main paths
for protecting donor privacy or cashing out privately (non-KYC purchase,
coinjoin, Lightning swaps via Boltz, peer-to-peer exchanges like Bisq
and RoboSats). Each tradeoff section is rendered as Pros/Cons bullets.

Also adds an 'About Agora' FAQ category to the existing accordion
covering the design rationale for not using Lightning, silent payments,
or server-rotated addresses.

The inline-markup renderer used by FAQ answers is extracted to
src/lib/helpMarkup.tsx so it can be reused by the guide pages.
2026-05-20 15:33:37 -05:00
Alex Gleason 907fdc1b70 Soften the campaign donation public-ledger notices
Drop the destructive treatment from the disclaimer on campaign donation
surfaces — donating isn't itself dangerous, just publicly traceable, so
red + alert icon + a hard checkbox gate overstated the risk.

Add a 'soft' tone to BitcoinPublicDisclaimer (amber, no icon, no role=
alert) and an includeCashOutAdvice flag so the popover can omit the 'or
cash out at an exchange' line — relevant for the wallet (donor holds
sats) but not for a campaign donor sending money away.

The wallet's Send dialog keeps the destructive variant with the
acknowledgement checkbox unchanged.
2026-05-20 15:05:12 -05:00
lemon d0e8c5b64b Align organize feed with discover layout 2026-05-20 12:44:28 -07:00
lemon 904df8a776 Fix organization activity pagination 2026-05-20 12:32:57 -07:00
lemon c085c2017f Tighten organize page organization queries 2026-05-20 12:32:57 -07:00
lemon c322f2796a Limit featured organizations author list 2026-05-20 12:32:57 -07:00
lemon 2a66968198 Restore organization activity feed 2026-05-20 12:32:57 -07:00
lemon 5831013baf Show followed organizations on Organize 2026-05-20 12:32:57 -07:00
lemon 5d7547f70b Query featured organizations by author 2026-05-20 12:32:57 -07:00
lemon b9e82da61e Deduplicate organization publishing helpers 2026-05-20 12:32:57 -07:00
lemon 90ebc19e79 Sanitize organization avatar URLs 2026-05-20 12:32:57 -07:00
lemon 9f9271cd64 Keep organization activity caches fresh 2026-05-20 12:32:57 -07:00
lemon c494750efd Reduce organization activity relay load 2026-05-20 12:32:57 -07:00
lemon 1cf3646b2a Restrict organization create panel 2026-05-20 12:32:57 -07:00
lemon ce95c6c12c Refine organization create panel 2026-05-20 12:32:57 -07:00
lemon c35a5b942c Combine organization activity rail 2026-05-20 12:32:57 -07:00
lemon 5f3af5e206 Expand organization event cards 2026-05-20 12:32:57 -07:00
lemon 39949bb439 Match organization pledge card design 2026-05-20 12:32:57 -07:00
lemon 146d569b88 Clarify event organization context 2026-05-20 12:32:57 -07:00
lemon 68e62ae705 Add event creation page 2026-05-20 12:32:57 -07:00
lemon b3b9e73c9f Add organization create panel 2026-05-20 12:32:57 -07:00
lemon ac3cdf34b2 Rename user-facing 'Community' → 'Organization'
User-visible copy now matches the Organization rebrand. Internal
symbols, file names, query keys, routes, and storage keys are
intentionally left alone for this pass — they're still pinned to
"community" / "communities" until a dedicated rename commit.

Touched strings:

- `MobileBottomNav` and `sidebarItems` labels: "Communities" →
  "Organize", matching the existing TopNav copy.
- `CommunityDetailPage` hero fallback ("Unnamed Community" →
  "Unnamed Organization") and "About this organization" aria-label.
- `CommunityContent` thumbnail fallback name.
- `ExternalContentHeader.CommunityPreview` row label and fallback name.
- `NoteCard` kind-34550 noun "community" → "organization" (used in
  feed-card action lines like "created an organization") and the
  article switches from "a" to "an".
- `NoteMoreMenu` overflow-menu labels: "Report post to community" →
  "Report post to organization", "Remove from community" → "Remove
  from organization".
- `BanConfirmDialog` title, description, and success/failure toasts.
- `CommunityContentWarning` reporter pluralization and the
  fallback report-type label ("community guidelines" →
  "organization guidelines"); reporters are now scoped to founder /
  moderators per the commit 4 cleanup, so the wording reflects that.
- `CommunityReportDialog` description copy.
- `CreateGoalDialog` placeholder example.
- `CreateActionDialog` org-scoped description string.
- `CreateCommunityEventDialog` NIP-31 `alt` tag prefix.
- `CommentContext` kind-34550 entries in the action-noun and
  rendered-noun maps ("a community" / "community" → "an
  organization" / "organization").
- `extraKinds` kind-34550 entry: label, description, and blurb.
- `kindLabels` kinds 4550, 10004, 34550.
- `DiscoverHero` ticker stat copy.
- `GetFeedTool` error message drops "communities" since the
  Following feed no longer includes organization activity (removed
  in the badge-runtime commit).
2026-05-20 12:32:57 -07:00
lemon a68cad44c3 Drop badge-award member runtime
Removes NIP-58 badge-award membership validation from Agora's
organization model. Authorization collapses to two roles:

- Founder = author of the kind 34550 event (only the founder can edit
  organization metadata, enforced by replaceable-event semantics).
- Moderators = `p` tags with role "moderator" on that event (can hide
  content via kind 1984 content-bans; cannot edit the org).

There is no "member" tier any more. The kind 8 / kind 30009 chain
that previously gated discussion access, the members-only feed
filter, the avatar-stack member count, and member-ban moderation
actions are all gone.

What changes:

- `useCommunityMembers` returns `{ founderPubkey, moderatorPubkeys }`
  read directly from the parsed community event. The kind 8 badge
  query, `resolveMembership`, `isAuthorizedAward`, and the rank-1
  member tier are removed. The hook still queries kind 1984 events
  scoped to the org so content-bans and soft reports keep working.

- `applyCommunityModerationToEvents` keeps content-ban filtering for
  founder/mods. `resolveCommunityModeration` is simplified to a
  single pass — only founder/moderators can publish moderation
  actions, and the parsed report classification drops the
  `member-ban` action since wholesale member bans no longer exist.

- `BanConfirmDialog` drops `mode="member"`. Only event-level content
  bans remain. `NoteMoreMenu` drops the "Ban from community"
  affordance accordingly.

- `ParsedCommunity` loses `memberBadgeATag` and
  `memberBadgeRelayHint`. `parseCommunityEvent` no longer reads the
  `['a', '30009:…', '', 'member']` tag. `CreateCommunityPage` no
  longer mints a "Member of <org>" badge or attaches the member-badge
  tag, and on edit it strips any pre-existing member-badge tag so
  legacy wiring doesn't linger.

- The "Voices from everywhere" cross-organization activity feed is
  deleted from the Organize page. It duplicated per-org activity (now
  served by the official-activity shelves on each org detail page)
  and was the only consumer of `useCommunityActivityFeed`.
  `useFollowingFeed` drops its community-feed leg for the same
  reason. `useMyCommunities`, `useMembersOnlyFilter`, and
  `MembersOnlyToggle` go with it.

- `CommunityDetailPage` loses the MembersOnlyToggle in the hero, the
  "Add members" and "Edit badge" overflow-menu items, and the
  per-member ban affordance in the members dialog. The avatar stack
  now renders founder + moderators with a "Founder + N moderators"
  label and the dialog title becomes "Leadership".

- Deleted: `AddMemberDialog`, `CommunityBadgePanel` (which exported
  `CommunityBadgeEditorDialog`), `useCommunityActivityFeed`,
  `useMyCommunities`, `useMembersOnlyFilter`, `MembersOnlyToggle`,
  plus three already-unused legacy components (`CommunityChatPanel`,
  `CommunityPulsePanel`, `CreateCommunityDialog`) and
  `useCommunityChatMessages`. `PersonSearch` is extracted from the
  deleted `AddMemberDialog` into its own file so the create-campaign
  and create-community forms keep their member-picker UI.

The standalone NIP-58 badge tooling (`BadgesPage`, `GiveBadgeDialog`,
`useBadgeFeed`, etc.) is untouched — it's a separate feature surface
from organization membership and continues to work.

Out of scope: file/symbol renames (Community* → Organization*).
That's the next commit.
2026-05-20 12:32:57 -07:00
lemon 743390edf7 Rebrand Organize page: My organizations + Featured organizations
Updates the Organize page (/communities) for the organization-first
direction:

- All user-visible "Community" copy becomes "Organization" — page
  title, hero subtitle, section headers, empty states, logged-out
  prompts, members-only filter messaging, and the create-organization
  CTA. The route, file name, and internal symbols are unchanged for
  this commit; the codebase-wide rename is the next pass.

- "My organizations" replaces "My communities". Wired to
  `useManageableOrganizations` instead of `useMyCommunities`, so the
  shelf reflects the same trust model used for implicit org tagging in
  the create flows — organizations the user either founded (author of
  the kind 34550 event) or is listed as a moderator on (`p` tag with
  role "moderator"). The badge-award member-validation path is no
  longer surfaced here.

- "Featured organizations" replaces "Discover communities". Backed by
  a new `useFeaturedOrganizations` hook with a hardcoded list of
  curator-approved kind 34550 event IDs in
  `FEATURED_ORGANIZATION_EVENT_IDS`. Pinning by event ID locks the
  card to a specific revision; bumping a featured org means swapping
  in a newer ID. This is intentionally a stopgap until a configurable
  featuring mechanism (AppConfig field, NIP-51 list, etc.) lands.

- The hero stat ticker still rotates donations / orgs / countries, but
  the "orgs" entry now counts featured organizations instead of all
  discoverable communities.

`useMyCommunities` and `useDiscoverCommunities` stay in place for now
because `useCommunityActivityFeed` and `DiscoverPage` still depend on
them. The badge-award removal pass will follow.
2026-05-20 12:32:57 -07:00
lemon 558e5affea Align organization detail page layout with campaign and pledge pages
Restructures the org detail page body to match the rhythm already
established on /campaigns/:naddr and /pledges/:naddr:

- Container widens from `max-w-3xl` to `max-w-6xl` and the hero gets
  the same `sm:aspect-[21/9]` treatment so it doesn't feel cramped at
  the wider viewport width.
- The tab strip is removed entirely. Activity was the only visible tab
  and the Pulse / Chat triggers were already hidden. With one section
  left there's no reason to keep the `<Tabs>` wrapper or its plumbing
  (`activeTab`, `setActiveTab`, `fabAvailable`).
- Below the hero: the existing Donate / Share action row, then the
  official-activity shelves, then a new pledge-style engagement card
  (stats counters + `<PostActionBar>`), then a NIP-22 comments section.
- No funding progress bar — an organization isn't a fundraising target
  itself. Campaigns continue to provide that for fundraising.
- Comments render in the same `rounded-2xl bg-card border` frame the
  pledge page uses, with the same "No comments yet" dashed empty
  card. The legacy interleaved "initiatives + discussion" feed is
  gone — official campaigns / pledges / events now live in the
  shelves, and the comments section is purely NIP-22 replies.
- Adds `useEventStats`, `InteractionsModal`, `NoteMoreMenu`, and a
  separate `replyOpen` slot for the engagement bar's Reply button
  (the FAB's "New post" item keeps its own `composeOpen` slot so the
  two entry points don't interleave state).
- Removes the now-unused `<CommunityChatPanel>`, `<CommunityPulsePanel>`,
  `<ComposeBox>`, `<NoteCard>`, and `<FeedCard>` imports, along with
  the `useCommunityGoals` / `useCommunityActions` / `useCommunityEvents`
  hooks and the chain of derived state (`moderatedGoals`, `activeGoals`,
  `pastGoals`, `eventItems`, `actionEvents`, `activeInitiatives`,
  `pastInitiatives`, `activityItems`) that fed the old feed. Those
  components still exist in the codebase for now; commit 4 will prune
  the badge-award member runtime separately.

The FAB is now always available (single-column page) and routes
`New campaign` / `New pledge` to their dedicated create pages with
`?org=<naddr>`. `New event` still opens the in-page calendar dialog
since there's no dedicated create page for that yet.
2026-05-20 12:32:57 -07:00
lemon ec7d7f4326 Add official-activity shelves to organization detail page
The org detail page now surfaces three horizontally-scrolling shelves
above the activity feed:

- Campaigns (kind 30223)
- Pledges (kind 36639)
- Upcoming events (NIP-52 kinds 31922 / 31923)

All three shelves are powered by the useOrganization* hooks added in
5b5e8fe8, which author-filter to founder + moderators before querying
by the org's uppercase `A` root-scope tag. Anyone can publish an
event with an org's `A` tag, so the author filter is the actual
trust boundary that decides what counts as "official" activity for
that organization.

Each shelf is suppressed entirely when it has nothing to show, so an
org with no campaigns/pledges/events keeps the discussion feed at the
top of the viewport.

The activity-tab FAB now routes campaign and pledge creation to the
dedicated create pages with `?org=<naddr>` in the query string,
closing the loop with the implicit-tagging change from 8bef15a3. The
in-page `CreateGoalDialog` and `CreateActionDialog` are removed —
zap goals are being deprecated in favor of campaigns, and pledges now
go through the dedicated create page. The calendar-event dialog stays
in-page since there's no dedicated create page yet; it already emits
the uppercase `A` tag.

Pulse and Chat tab triggers are hidden from the tab strip while the
organization-first redesign is in progress. The corresponding
`<TabsContent>` panels are left intact so the code paths stay
verified — only the trigger affordances are suppressed.
2026-05-20 12:32:57 -07:00
lemon 3520c0ad6b Fix orphan spacing above pledge details action bar
The stats row had a `pb-2` and the PostActionBar received a `pt-3`
className when stats were present — leftover spacing from when a
horizontal divider sat between them. With the divider removed the
padding became an unjustified gap above the action bar.

Drop both so the action bar sits flush against the stats row with
even top/bottom spacing inside the engagement card, matching the
no-divider look the campaign details page already has when stats
are absent.
2026-05-20 12:32:57 -07:00
lemon c4f52b8aa7 Match pledge action bar to campaign details
Restore the `pt-3 border-t border-border/60` divider/padding
that separates engagement counts from the action bar, identical
to the campaign details page. The earlier `pt-3` without the
border left dead space; with the border back, the two pages
share the same visual rhythm.
2026-05-20 12:32:57 -07:00
lemon df44b1b2c6 Drop primary-tag badge from pledge card cover 2026-05-20 12:32:57 -07:00
lemon 8c6721a3fc Match pledge hero cover handling to the card
Pledge cards render their cover image with a built-in fallback:
when `action.image` is unset or fails to load, they fall back to
`DEFAULT_COVER_IMAGE`. The detail-page hero only handled the
unset case (with a gradient + icon) and never recovered from a
broken URL.

Mirror the card pattern in the hero: render an `<img>` with an
`onError` handler that swaps in `DEFAULT_COVER_IMAGE` on load
failure, so pledges without a cover (or with a dead one) still
look intentional instead of blank.
2026-05-20 12:32:57 -07:00
lemon fb7676b760 Simplify pledge detail hero and funding card
Remove visual clutter from the pledge detail page:

- Drop the "Pledge" badge and description preview from the hero
  overlay so the cover image and title carry the framing.
- Remove the divider between engagement counts and the action bar.
- Drop the "Remaining" and "Stored as sats" tiles from the
  funding card — donors only need funded vs. pledged totals.

The hero cover image is unchanged; it sources `action.image`
sanitized by `sanitizeUrl`, which falls back to the gradient
placeholder only when the event has no `image` tag.
2026-05-20 12:32:57 -07:00
lemon f97792b81e Drop deadline helper subtext from pledge form 2026-05-20 12:32:57 -07:00
lemon f3c9a74b0f Simplify pledge creation form
Pledges are now open-ended by default — drop the optional start
date input and stop publishing the legacy `start` tag. The kind
36639 `created_at` already marks when the pledge becomes active.

Hide the built-in cover image templates so the upload area is the
only path for cover art, encouraging pledgers to supply their own
visuals. The thumbnail strip remains available via the
`templates` prop on `CoverImageField` for other surfaces.

Rename the description heading from "Story" to "Description"
to avoid implying a narrative expectation, and label the optional
tag input as "Recommended" to nudge categorization.

Mirror the same changes in `CreateActionDialog` (the community
scoped variant) and mark `start` as legacy in `NIP.md`.
2026-05-20 12:32:57 -07:00
lemon 248b07f45c Drop org selector from create forms; derive org from ?org= query param
Reverts the user-controlled OrganizationSelector added in b3997ec5.
Selecting an organization from inside a create form was the wrong
affordance: if a user starts a pledge or campaign from outside any
organization, the publication is implicitly under their own identity,
and if they start it from inside an organization, the org tag is
already implied by where the create flow was launched.

CreateCampaignPage and CreateActionPage now read `?org=<naddr|aTag>`
from the URL instead of rendering a selector field. When the param is
present, useManageableOrganizations checks whether the current user is
the founder or a moderator of that org; if so, the form emits the
`A` / `K` / `P` tags on publish. If not, the param is silently
dropped (so a stale or copy-pasted link can't forge an org-tagged
event), and a small note explains the publication is going under the
user's account.

OrganizationContextChip is a tiny shared component that surfaces the
resolved org as a non-interactive chip under the form title. The
organization detail page CTAs (next commit) will populate `?org=`
when launching the create flow from inside an org.

OrganizationSelector.tsx is removed — no other call sites.
2026-05-20 12:32:57 -07:00
lemon a759e653de Let campaigns and pledges publish under an organization
Adds an optional Organization selector to the create-campaign and
create-pledge forms. Only NIP-72 communities where the current user
is the founder or a listed moderator are offered, so the resulting
event's uppercase `A` root-scope tag lines up with the trust filter
applied by useOrganizationCampaigns / useOrganizationPledges.

Technically anyone could publish a kind 30223 or 36639 with another
org's `A` tag outside the Agora client. Restricting the selector
inside the client is the first line of defense; the author-filter in
the org-detail queries (next commits) is the actual security boundary.

Changes:

- useManageableOrganizations queries kind 34550 events the user
  founded (`authors`) or is moderator-tagged on (`#p`), then keeps
  only the ones where they're founder OR moderator.
- OrganizationSelector renders those orgs in a popover combobox with
  Founder / Moderator badges, plus a 'No organization' option.
- CreateCampaignPage and CreateActionPage gain a new optional 'Publish
  under organization' field. On publish, the form emits
  `A: 34550:<pubkey>:<d>` plus the `K` and `P` companion tags.
- CreateCampaignPage hydrates the selector from an existing campaign's
  `A` tag when editing.

Existing campaigns and pledges without an `A` tag continue to work
unchanged.
2026-05-20 12:32:57 -07:00
lemon 14939ff534 Add organization role helpers and official-activity hooks
Introduces the Agora Organization model on top of NIP-72 communities:
only the founder (kind 34550 event author) can edit metadata, and the
founder plus listed moderators (p tags with role "moderator") can
moderate the feed. Membership/badge-award validation will be dropped
from the runtime path in follow-up commits.

src/lib/communityUtils.ts grows four small helpers:

  - isOrganizationFounder
  - isOrganizationModerator
  - canEditOrganization        (founder only)
  - canModerateOrganization    (founder OR moderator)
  - getOrganizationOfficialAuthors

src/hooks/useOrganizationActivity.ts adds three trust-filtered queries:

  - useOrganizationCampaigns
  - useOrganizationPledges
  - useOrganizationEvents

Each query passes the founder-plus-moderators list as the `authors`
filter so forged events tagged with the organization's uppercase `A`
root-scope tag from non-moderator pubkeys never surface as official
activity. This matches the nostr-security skill's guidance on
author-filtering trust-sensitive queries.

The hooks do not yet ship in the Organization page; that wiring is in
the next commit.
2026-05-20 12:32:57 -07:00
lemon c9b48aeaae Align pledges with campaign patterns 2026-05-20 12:32:57 -07:00
lemon 24aa3a32d9 Polish pledge navigation and amount input 2026-05-20 12:32:57 -07:00
lemon 3dd229edfb Reframe actions as pledges 2026-05-20 12:32:57 -07:00
Alex Gleason 0e13455c7a Gate campaign donations on the wallet's public-ledger disclaimer
Extract the Send dialog's raw-Bitcoin-address privacy warning into a
shared BitcoinPublicDisclaimer component and reuse it across every
on-chain payment surface on campaign pages:

- BeneficiaryDonatePanel (single-beneficiary BIP-21 "Open in wallet",
  also used inside BeneficiaryDonateDialog for multi-beneficiary rows).
- DonateDialog FormView: replaces the milder "public, irreversible,
  takes time" alert. The irreversible / network-fee note stays as a
  separate muted line below.
- DonateDialog ExternalPayView: the logged-out external-wallet
  fallback for single-recipient campaigns.

Each surface now gates its primary action (Open in wallet / Review /
Copy payment URI) on the donor checking "I understand this transaction
is public." The acknowledgement resets when the dialog reopens.
2026-05-20 14:24:15 -05:00
Chad Curtis 75a3453daa Merge branch 'dashboard-ui-polish' into 'main'
Polish dashboard page to match app design system

Closes #12

See merge request soapbox-pub/agora!24
2026-05-20 19:18:46 +00:00
Chad Curtis 7d0f565101 Merge branch 'ui-polish' into 'main'
Fix five small UI polish issues

Closes #13

See merge request soapbox-pub/agora!25
2026-05-20 19:15:13 +00:00
Chad Curtis 3d744518b2 Merge branch 'update-campaign-goal' into 'main'
Clarify that campaign goal is saved as sats, prevent edit drift

Closes #17

See merge request soapbox-pub/agora!26
2026-05-20 19:13:19 +00:00
Alex Gleason d4590a9340 Brighten the account switcher chevron on hover 2026-05-20 14:00:21 -05:00
Alex Gleason 3c330efaa9 Compact the top-nav account switcher trigger 2026-05-20 13:58:54 -05:00
Alex Gleason 89c392fa63 Remove Start Campaign button from top nav; brighten Join contrast 2026-05-20 13:49:36 -05:00
Alex Gleason a76a971321 Remove World link from the top nav 2026-05-20 13:22:42 -05:00
sam 523235e043 live events from user relays too 2026-05-20 22:43:38 +07:00
sam 14ca8999ad live mode 2026-05-20 22:16:10 +07:00
sam 9dcc183044 link in footer to planetora page 2026-05-20 20:35:17 +07:00
sam 7a519ba341 Merge branch 'main' into cooking/planetora 2026-05-20 20:18:22 +07:00
mkfain d2eca1811d Reword Community Campaigns subtitle
"Community-submitted fundraisers approved by moderators" was
procedural; replace with "Help fund the changes worth making" so the
section reads aspirationally rather than as a moderation explainer.
2026-05-19 18:39:36 -05:00
mkfain fdb849aa1d Default All Campaigns to Top; rename Newest pill to New
Top (most sats raised) is now the default sort; visiting /campaigns/all
shows the ranked list immediately. The chronological pill is renamed
from Newest to New for brevity.

URL state inverts accordingly: ?sort=none is the explicit value, Top
omits the param to keep the canonical URL clean.
2026-05-19 18:35:55 -05:00
mkfain d3e0d177a5 Rank All Campaigns by donation volume; drop NIP-50 sort/search
The previous NIP-50 sort:top/sort:hot path against Ditto was a dead end
for kind 30223: Ditto's engagement scoring is built for kind 1 notes
(likes / reposts / replies), none of which apply to fundraising
campaigns. All three sort modes returned indistinguishable output, and
the relay-side search: field returned nothing for campaign content.

Switch to client-side ranking and filtering using campaign-native
signals:

- Sort is now "Newest" (chronological, default) and "Top" (most sats
  raised across kind 8333 donation receipts).
- Newest is the default since it gives full relay coverage with no extra
  data dependency.
- Top batch-fetches every kind 8333 receipt tagging any visible campaign
  in a single round-trip, sums the amount tag, and ranks by total sats
  with donor count + created_at as tiebreakers. While scores load the
  list stays chronological so the page renders something useful
  immediately.
- Search filters client-side across title, summary, story, location,
  and t-tags. Substring, case-insensitive.

Drop the Hot mode entirely \u2014 7-day donation activity is too noisy with
current campaign volume to justify a separate UI affordance, and a
single Top vs. Newest choice is clearer.

Remove the page subtitle ("Every campaign published on Agora, including
ones awaiting moderation\u2026") at the user's request.

The hook no longer routes any queries to Ditto's relay group; the
default user-configured pool is used throughout, so campaigns published
only to non-Ditto relays are now discoverable too.
2026-05-19 18:31:59 -05:00
mkfain b7c88ecca8 Add sort + search to the All Campaigns page
Top/Hot/None sort and a free-text search bar, capped at 2 cards per row.
Top is the default.

Top and Hot use NIP-50 extensions (`sort:top`, `sort:hot`) that Ditto
implements; the page routes those queries (and any free-text search) to
the Ditto relay group via `nostr.group(DITTO_RELAYS)`. None with no
search uses the default user-configured pool so campaigns published only
to non-Ditto relays are still discoverable.

If Top/Hot returns nothing — cold cache, or Ditto doesn't yet weight
engagement for kind 30223 — we silently retry against Ditto without the
`sort:` field rather than show an empty page. Mirrors useMusicFeed.

The search input debounces at 300ms via the existing useDebounce hook.
Sort and search are independent: `search: "<query> sort:top"` works.

URL state (`?sort=top|hot|none&q=<query>`) makes results shareable.
Default values are omitted from the URL so the canonical path stays
clean.

Refactored useCampaigns to expose parseCampaignEvents — a pure helper
that handles the (pubkey, d) dedupe, archive filter, and parse step.
useAllCampaigns reuses it and passes `sortByCreatedAt: false` for
top/hot/search so we don't undo the relay's score order. No behavior
change for existing useCampaigns callers.
2026-05-19 18:16:22 -05:00
mkfain 09bd4096e2 Make Featured a moderation axis and add an All Campaigns page
Featured was a hardcoded array of naddrs in src/lib/featuredCampaigns.ts.
Promote it to a third moderation axis (`featured` / `unfeatured`)
alongside `approved` and `hidden`, managed by Team Soapbox pack members
through the same kebab menu on each campaign card.

The Featured row on the homepage now:
- Reads from `moderation.featuredCoords`, ordered newest-featured-label first.
- Caps at 4 campaigns.
- Adapts its grid to 1/2/3/4 desktop columns based on count (mobile stays
  one column), collapses when nothing is featured, and surfaces the hero
  `variant="featured"` card only when exactly one campaign is featured.
- Hide still wins: a featured-then-hidden campaign disappears from the row.

Rename the homepage's second section from "All campaigns" to "Community
Campaigns", which more accurately reflects that it's the approved-not-
hidden set with featured campaigns deduplicated out.

Add a new /campaigns/all page that lists every campaign found on relays
(approved, pending, and unmoderated alike), with a "Show hidden" toggle
that adds hidden campaigns back in. The Discover page's "All campaigns"
link now points here instead of /, since the homepage is no longer a
truly-all view post-moderation. Also surface a small "Browse all
campaigns" link beneath the Community Campaigns grid so the new page is
discoverable from home.

Update NIP.md to document the third axis and the home-page surfacing
rules (Featured row, Community Campaigns grid, Discover shelf).

Delete src/lib/featuredCampaigns.ts entirely — there's no longer a build-
time list to maintain.
2026-05-19 18:04:28 -05:00
Chad Curtis ed923bcde6 Drop the Team Soapbox attribution from the All campaigns subheader 2026-05-19 16:19:04 -05:00
Chad Curtis d065580e47 Curate the campaigns homepage with Team Soapbox labels
Move campaign curation from a hardcoded HIDDEN_CAMPAIGN_COORDS set to a
real moderation system. Team Soapbox (kind 39089 follow pack
k4p5w0n22suf) is the moderator roster; each member signs NIP-32 kind
1985 labels in the agora.moderation namespace to approve or hide a
campaign. The home page and Discore shelf render the approved-and-
not-hidden set; moderators additionally see Pending + Hidden sections
and a per-card kebab menu. Non-moderator authors get a Your Campaigns
section explaining their campaign is live on Nostr but awaiting a
homepage approval.
2026-05-19 16:14:45 -05:00
filemon cb32405e55 Clarify that campaign goal is saved as sats, prevent edit drift
The goal input accepts USD but the published event stores sats. The UI
gave no indication of this, so the displayed USD goal silently drifted
as BTC price changed — confusing creators.

Create mode: the preview now reads "Saved as X sats · about $Y today.
The USD estimate may change with BTC price."

Edit mode: a goalTouched flag tracks whether the user actually changed
the goal field. If untouched, the submit handler preserves the original
goalSats exactly instead of round-tripping through USD at the current
price. A helper note shows the current saved sats so the creator knows
the field is pre-filled from a reverse conversion.
2026-05-19 17:05:12 -03:00
filemon b9fee19510 Merge branch 'main' into ui-polish 2026-05-19 12:25:15 -03:00
filemon f6b209949a Fix post menu preview: keep NoteContent with working line-clamp
The previous attempt replaced NoteContent with plain text, losing rich
rendering of hashtags, mentions, custom emoji, and nostr identifiers.
Reverting to NoteContent while keeping the overflow fix needed two
changes to make line-clamp-3 work:

- Render NoteContent as a <span> (as="span") so it participates in
  the parent's -webkit-box line counting. The default <div> wrapper
  with overflow-hidden created a separate block formatting context
  that defeated line-clamp entirely.
- Add disableNoteEmbeds to prevent block-level EmbeddedNote/
  EmbeddedNaddr cards from appearing inside the compact preview.

The outer container keeps overflow-wrap-anywhere so long URLs break
safely within the clamped area.

Regression-of: fc950865
2026-05-19 12:17:27 -03:00
Alex Gleason 949bd5fde4 Harden cover image forms and self-zap feedback 2026-05-19 00:33:16 -07:00
lemon 27d65bc389 Reuse the Create Community page for editing, drop founder row
Two follow-ups to the new /communities/new page:

1. Stop rendering the founder as an 'anonymous' chip at the top of the
   Moderators section. The row was synthesized from just a pubkey
   (genUserName fallback, no avatar), which looked broken even though
   the founder is always implicitly the first moderator on the
   published kind 34550. Drop the row entirely; the founder remains
   pubkey #0 in the moderator list when we publish, just isn't
   visible as a chip.

2. Wire the page to handle ?edit=<naddr> the same way
   CreateCampaignPage handles its edit param:

   - Decode the naddr, reject anything that isn't a kind 34550 with
     an 'Invalid edit link' guard card.
   - Fetch the existing community (inline useQuery against
     ['community', pubkey, dTag] since there's no useCommunity hook
     yet). Show a 'Loading community…' card while it resolves.
   - Prefill name, description, image, and moderators when the data
     lands. Resolve each moderator's kind-0 profile via the same
     two-step cache-then-network pattern campaigns use for recipients,
     so chips render with proper avatars and names instead of fallback
     stubs.
   - Show a 'Community cannot be edited' guard if the viewer isn't
     the founder, mirroring the campaign edit author check.
   - On submit, fetchFreshEvent + publish kind 34550 with prev. Strip
     d/name/description/image/alt and any p-with-role-moderator tags
     from the previous tag set; rebuild them from form state, then
     re-append the preserved tags (badge a-tag, relay hints, …) and a
     fresh alt. The implicit member badge is left alone in edit mode
     (matches CreateCommunityDialog's edit branch).

CommunityDetailPage's 'Edit community' dropdown item now navigates to
/communities/new?edit=<naddr> instead of opening CreateCommunityDialog.
The dialog mount and its editCommunityOpen state are removed.

The dialog file is left in the tree even though nothing imports it
anymore — keeping it makes a revert cleaner if the user changes their
mind.
2026-05-19 00:19:38 -07:00
lemon f2805ed9d8 Add a dedicated Create Community page
CommunitiesPage used to open CreateCommunityDialog when the user hit
'Create community' in the hero, the My Communities shelf, or the empty
state. Replace those entry points with navigate('/communities/new'),
mirroring how CreateCampaignPage and CreateActionPage already work.

The new page covers the same three NIP-72 fields the dialog handled
(name + description + cover image) plus a Moderators section that
the dialog never exposed:

- Name, with a live URL preview of the derived slug for transparency.
- Description, in the same Textarea shape as the dialog.
- Cover image, via the shared <CoverImageField> so drag-and-drop +
  paste-URL behavior matches the campaign and action create pages.
- Moderators, via the same <PersonSearch> CreateCampaignPage uses for
  recipients. The founder is pinned at the top of the list as a
  non-removable row labeled 'Founder'; PersonSearch is told to exclude
  the founder so they can't be added a second time. Extra moderators
  go into the kind 34550 event as additional ['p', pk, '', 'moderator']
  tags alongside the founder's.

Submit logic is the kind 30009 badge mint + kind 34550 community
publish from CreateCommunityDialog's create branch, including the
d-tag and badge-d-tag collision checks. Cache keys touched on success
match the dialog: ['addr-event', 34550, pubkey, dTag] is seeded,
['my-communities'] and ['community-activity-feed'] are invalidated.

CreateCommunityDialog itself stays in the tree because
CommunityDetailPage still opens it in edit mode. A unified edit page
that folds in 'View members', 'Add members', and 'Edit badge' is
explicitly out of scope for this change.
2026-05-19 00:19:38 -07:00
lemon 0745d99e85 Share the cover-image picker between Action and Campaign forms
Both forms had their own CoverPicker / dropzone implementation, and the
two had diverged: the action page learned to accept drag-and-drop while
the campaign page was still click-only. Extract the entire affordance
(dashed dropzone + sanitized preview + remove button + template strip +
URL input) into <CoverImageField> in src/components/, used by both
pages.

The new component takes a controlled value/onChange pair and an optional
templates array, so the campaign page (no templates) and the action page
(six Blossom-hosted defaults) reuse the same dropzone and the same
drag-and-drop, MIME-checked upload path. Clicking a template fills both
the dropzone preview and the URL input from a single source of truth.

While here, dedup three more copy-pastes between the two pages:

- Lift FormSection (the titled section wrapper with the Required /
  Recommended / Optional badge) into src/components/FormSection.tsx.
  Both pages now import the same component instead of redeclaring it.
- Move getTodayDateInput() into src/lib/dateInput.ts. Both pages need
  the same YYYY-MM-DD-in-local-tz string for the deadline picker's min
  attribute; keeping it in one place means future timezone tweaks land
  in one file.
- Run the action page's coverImage through sanitizeUrl() at submit
  time the same way the campaign page does, so a paste-in cover URL
  that isn't well-formed https:// drops out of the published 'image'
  tag instead of getting written verbatim.

No user-visible behavior change on the action page; the campaign page
gains drag-and-drop and the 'Click or drag an image here' prompt copy.
Net diff: -318 / +13 in the page files.
2026-05-19 00:19:38 -07:00
lemon 4bba4159f1 Serve default action covers from Blossom
The template gallery used to point at relative paths under
/challenge-covers/, which meant any kind-36639 event whose author
picked a template published an 'image' tag like
'/challenge-covers/cover9.png'. That string only resolves on Agora's
own origin, so the cover broke as soon as another Nostr client
rendered the event.

Replace each DEFAULT_ACTION_COVERS entry with the public Blossom URL
of the same image so the tag we publish is portable across clients.
DEFAULT_COVER_IMAGE (the fallback for action cards whose author never
set one) now points at the Blossom-hosted Justice image too.

The original /public/challenge-covers/ files are kept in place because
the Actions hero banner still reads them as static assets through Vite.
2026-05-19 00:19:38 -07:00
lemon 23977a64ca Let the cover-image dropzone accept drag-and-drop
The dropzone label previously only opened the file picker on click. Wire
up onDragOver / onDragEnter / onDragLeave / onDrop so dragging an image
from the OS file manager (or another browser window) onto the box
uploads it through the same useUploadFile path the click flow uses.

- Highlight the dropzone (border-primary + light primary fill) while a
  file is being dragged over it so users get a clear hit target.
- Validate the dropped file's MIME type against the same image/png,
  image/jpeg, image/webp set the file input's accept attribute enforces,
  so a stray PDF dragged in doesn't get posted to Blossom.
- Update the placeholder copy to 'Click or drag an image here' so
  the affordance is discoverable.
- Reject the dropped file silently while an upload is already in
  flight (matches the pointer-events-none on the click path).
2026-05-19 00:19:38 -07:00
lemon c73c15de22 Polish the Create Action page form
- Reorder the fields to match how authors think about an action: Title,
  Type + Bounty on the same row, Description, Country, Cover image,
  Start date + Deadline on the same row, then the Timezone block.
- Default the Type select to "Action" so the most general bucket is
  picked unless the author opts into a more specific kind.
- Block past dates in the Deadline picker (min={today}) and reject them
  at submit time with a clear error, mirroring CreateCampaignPage.
- Promote Country and Cover image from Optional to Recommended now that
  the actions index leans heavily on both for filtering and visual scan.
- Replace the cover field with the campaign-style picker: a clickable
  dashed dropzone (image preview + remove button + ImagePlus prompt),
  a thumbnail strip in between, and a URL input below. Clicking a
  thumbnail just fills the URL input, no permanent default selection
  is forced on the user, and the URL stays editable.
- Trim the default cover gallery to seven curated images by dropping
  the four overlapping/redundant entries (Protest March, Unity,
  Resistance, Change, Demonstration).
2026-05-19 00:19:38 -07:00
lemon a5159e040b Convert Create Action modal into a dedicated /actions/new page
The actions index used to open CreateActionDialog from three places (hero
CTA, FAB, empty state). Now all three navigate to /actions/new, which
mirrors CreateCampaignPage's layout — back arrow + page title, a single
rounded form panel of FormSection blocks, and a full-width submit
button — while preserving every field, validation rule, and tag-emitting
behavior of the old modal.

CreateActionDialog itself stays in the tree because CommunityDetailPage
still opens it inline to attach an action to a NIP-72 community.
2026-05-19 00:19:34 -07:00
Alex Gleason c2bf0bd88e Restrict the campaigns hero rotation to featured campaigns
The rotating banner, spotlight card, and globe markers now all draw
from the hand-picked featured pool only. Previously the spotlight
loop also pulled in every campaign returned by useCampaigns, so the
banner image, spotlight card, and globe pins cycled through community
submissions alongside the two featured slots.

Featured campaigns still flow through the existing country/coords
gate, so anything without a resolvable country is dropped from the
globe — and from the banner — to keep the three in sync.
2026-05-19 00:37:56 -05:00
Alex Gleason 9fec863f18 Put the entire country page inside one FeedCard
User flagged that the /i/iso3166:VE page still looked disjointed:
the cinematic hero bled edge-to-edge, the ExternalActionBar sat as
a bare Twitter-style border row below it, the ComposeBox sat raw,
and only the Pinned + Recent feeds were wrapped in their own
FeedCards underneath. Five separate stripes stacked vertically with
no shared container.

Restructure the country branch so the whole surface lives inside
one rounded FeedCard:

- CountryContentHeader (cinematic hero) becomes the top of the card
  with its rounded corners clipped by the FeedCard's overflow-hidden.
- Hero gradient's bottom stop changes from hsl(var(--background)) to
  hsl(var(--card)) so the fade meets the card surface, not the page
  background, eliminating the dark-mode color seam.
- Drop the section's mb-2 since the action bar now sits flush.
- ExternalActionBar sits flush inside the card; its existing
  border-b reads as an internal section separator.
- ComposeBox renders with hideBorder + bg-transparent so it inherits
  the card surface. Added an optional className prop to ComposeBox
  so callers can override the default bg-background/85.
- Pinned section gets a border-t border-border heading band; its
  loading state inlines NoteCard-shaped skeletons rather than the
  FeedCard-wrapped CommentsSkeleton (which would have card chrome
  inside card chrome).
- Recent section's heading becomes a border-t band between Pinned
  and Recent when both are present; its loading state also inlines
  skeletons; FlatThreadedReplyList sits flush in the card.
- The infinite-scroll sentinel + empty state move outside the card.

URL / unknown content types keep the previous edge-to-edge action
bar treatment (their content headers already render their own
chrome and don't need the unified card).
2026-05-19 00:14:27 -05:00
Alex Gleason 6aeed26642 Wrap focused post, community feeds, and pinned posts in FeedCard
The previous sweep wrapped vertical NoteCard feeds in FeedCard but
missed three surfaces that share the same Twitter-style edge-to-edge
problem:

- PostDetailPage's focused post (`/nevent…`, `/note…`): the
  ancestor previews + AncestorThread + focused <article> all sat
  bare on the page background while the replies below were wrapped
  in a FeedCard. The main post read like background noise and the
  replies read like the actual content. Wrap the entire focused
  group (previews + ancestors + focused article — all seven kind
  variants funnel through this) in one FeedCard so the page reads
  "thread context → this post → replies" as two cohesive cards.
  Also wrap the ProfileBadgesDetailView's bare top NoteCard.

- CommunityDetailPage's Activity tab (`/naddr…` community kind
  34550): bare `divide-y divide-border` for the activity stream
  AND the past-initiatives stream. Replace with FeedCard. The
  community page already supplies its own `max-w-3xl px-4 sm:px-6`
  wrapper, so the FeedCard opts out of its own margins via `mx-0
  sm:mx-0` to avoid doubling up the side padding. The Past-Initiatives
  heading gains a `border-t border-border` so it reads as a section
  divider inside the card rather than a floating header.

- CommunityPulsePanel: same divide-y → FeedCard fix.

- ExternalContentPage's country pages (`/i/iso3166:VE`): Pinned
  posts list was bare while Recent posts below it sat in a FeedCard
  — same Pinned-bare / Recent-card mismatch on the same screen. Wrap
  Pinned in FeedCard. Add a "Recent" eyebrow heading above the
  recent posts feed when both sections are visible, so the two
  sections are clearly delineated. Move the Pinned heading's
  horizontal padding from `px-4` to `px-4 sm:px-6` so it lines
  up with the card margins on tablet+.
2026-05-19 00:02:58 -05:00
Alex Gleason 3530754518 Wrap NoteCard feeds across the app in a soft FeedCard surface
After fixing /discover's 'Voices from everywhere' feed, the same
Twitter-style edge-to-edge divided-list pattern was hiding in 24
other feed sections across the app: notifications, profile wall,
search results (posts + accounts + community posts + the empty-state
FollowsList), bookmarks, badges, trends, list members + their posts,
domain feeds, relay feeds, events feed, communities feed, the
Bluesky page, external-content comments + book reviews, post detail
replies (in three render paths), and the FollowPage members tab. On
the GoFundMe-shaped layout they all read as 'random separators
floating in space and posts with no sides.'

Introduce a shared FeedCard component that bakes in the standard
canvas — mx-4 sm:mx-6 rounded-2xl bg-card border border-border/60
shadow-sm overflow-hidden — and replace the bare divide-y wrappers
at all 25 feed-list sites with it. NoteCard rows already self-apply
px-4 py-3 border-b border-border, so live feeds don't need a
divide-y; pure skeleton lists (rows with no own borders) keep
divide-y on the FeedCard className.

Sites intentionally left alone:
- The 8 popover/autocomplete dropdown lists inside max-h-* scrollers
  on BlueskyPage, WikipediaPage, BooksPage, and ArchivePage (already
  inside bg-popover rounded-lg chrome — double-wrapping clashes).
- CampaignDetailPage's beneficiaries list (already inside a Card,
  the divide-y is a section separator).
- CampaignDetailPage's comments card (uses a custom -mx-2 sm:-mx-4
  inside the article column; FeedCard's mx-4 sm:mx-6 is wrong for
  that nesting).

Also fix TrendsPage's hashtag skeleton: it used a vertical divide-y
list shape but the loaded UI is a flex-wrap of pill badges. Replace
TrendSkeleton with a small h-7 w-24 rounded-full so the skeleton
matches the loaded shape and doesn't pop on transition.
2026-05-18 23:47:08 -05:00
Alex Gleason 5049116a6f Wrap the Voices feed in a soft card
DiscoverPage's 'Voices from everywhere' feed was the page's odd one
out: every section above (country pulse strip, Help raise hope
shelf, Find your people shelf) renders inside its own banded or
rounded canvas, but the Voices feed used the legacy Twitter timeline
treatment — edge-to-edge rows separated by floating divide-y
hairlines with no container chrome. On a GoFundMe-shaped page next
to rounded shelves above, the rows read as 'separators floating in
space and posts with no sides'.

Wrap the feed (loading skeleton, populated, and empty states) in
the same rounded-2xl bg-card border border-border/60 shadow-sm
overflow-hidden surface I introduced for the campaign-page comments
card. Drop the redundant divide-y on the populated state — NoteCard
already self-applies px-4 py-3 border-b border-border, so the
container only needed dividers in the skeleton state where rows have
no border of their own. Add the same border-b to the CampaignCard
branch of DiscoverFeedRow so campaign rows separate correctly
between NoteCard neighbours inside the card.
2026-05-18 23:14:46 -05:00
Alex Gleason 3b641a8d7c Sweep remaining Twitter-style action rows into PostActionBar / chip style
The first chip-row pass touched the campaign page (PostActionBar +
NoteCard). The post-detail page, book feed, and external/Bluesky
content rows still rendered the old spread-out pill toolbar — visible
when clicking any note from /discover, the book index, or a Bluesky
syndication. Bring them in line:

- PostDetailPage.tsx: replace the five remaining inline action rows
  (standard post, repost card, zap card, profile detail, vanish event)
  with <PostActionBar event={event} onReply onMore />. Drops ~270
  lines of duplicated reply/repost/react/share/more JSX and removes
  the now-dead handleShare, repostTotal, encodedEventId locals and
  the MessageCircle / MoreHorizontal / Share2 / ReactionButton /
  RepostMenu / toast / shareOrCopy imports that fed them. Update the
  loading skeleton to use chip-shaped placeholders.

- BookFeedItem.tsx: same migration. The component already had a
  bespoke action row that mirrored PostActionBar minus the share
  button — using PostActionBar restores parity and removes the
  hand-rolled zap branch, the canZapAuthor / isZapped / useUserZap
  glue, and the MessageCircle / RepostIcon / Zap / formatNumber
  imports.

- ExternalContentHeader.tsx + BlueskyPage.tsx: these can't switch to
  PostActionBar because the comment/repost handlers publish to
  Bluesky's external semantics, not Nostr. Hand-restyle the rows to
  the chip aesthetic instead (h-9 px-3 rounded-full, label fallback
  on sm+, share/more pushed right with a flex spacer).

- ExternalReactionButton.tsx: mirror the variant: 'pill' | 'chip'
  prop added to ReactionButton in the previous commit, so the
  external-content rows can opt into the chip look without affecting
  ExternalContentPage's sidebar toolbar which still uses the pill.

PodcastDetailContent, MusicDetailContent, and PhotoBottomBar still
use their own deliberate aesthetics (large circular play button with
matching side buttons; immersive lightbox bar). Left alone — they
don't read as Twitter rows.
2026-05-18 23:04:39 -05:00
Alex Gleason 3e31d26660 Replace Twitter-style action bar with GoFundMe-style chip row
The campaign page (and every post feed) inherited Ditto's spread-out
pill action bar — icons across the full width, framed by a heavy
top+bottom border band, with cascading dots on the 'Show more replies'
thread connector. It read as a Twitter toolbar, which is wrong for a
fundraising client.

Restyle the shared action row across PostActionBar and NoteCard:
- Chip-style buttons (h-9 px-3 rounded-full) with inline counts.
- Engagement actions cluster left, share/more pushed right with a
  flex spacer instead of justify-between.
- Drop the unconditional top+bottom border band; pages add their own
  separator via className when needed.
- Show 'React'/'Reply'/'Repost' word labels on sm+ when the count is
  zero, so the bar reads as labelled affordances rather than icon
  pills floating in space.
- Add a 'chip' variant to ReactionButton so it can switch between the
  legacy pill (still used by PhotoBottomBar, PodcastDetailContent,
  MusicDetailContent, BookFeedItem, PostDetailPage, etc.) and the new
  chip look.

ThreadedReplyList's 'Show N more replies' button drops the four
cascading dots in favour of a single soft connector that brightens on
hover.

On the campaign page itself, wrap the engagement stats + action bar
in a soft card, add a 'Comments & donations' section heading with a
count, and replace the bare 'No comments yet' line with a dashed
empty-state CTA that opens the reply composer.
2026-05-18 22:47:18 -05:00
Alex Gleason 65633bcac9 Fix donate column bottom getting cut off on tall campaigns
The desktop sticky donate column capped its height with
`lg:max-h-[calc(100vh-2rem)] lg:overflow-y-auto`, intending to keep
tall donate cards (QR + many beneficiaries) reachable on short
viewports. In practice this swapped one problem for another: instead
of overflowing the viewport the column rendered with an inner
scrollbar and visually clipped the beneficiary list at the bottom of
the card — exactly the report from the team-soapbox campaign (11
beneficiaries).

Drop the height cap and wrap the column contents in a sticky inner
`div`. While the column is shorter than the viewport it sticks 1rem
below the top as before. When it's taller, the sticky wrapper rides
along with the page scroll until the flex row ends, exposing the
column's bottom via the normal page scroll instead of trapping it
behind a nested scrollbar.

Regression-of: 2bce20ba03
2026-05-18 22:33:10 -05:00
Alex Gleason 3cf8b20e97 Link beneficiary profiles to NIP-19 routes on campaign pages
Beneficiary rows on multi-beneficiary campaigns linked to /${pubkey} —
the raw hex pubkey — which falls through the /:nip19 catch-all to the
404 page. Single-beneficiary campaigns hid the beneficiary's profile
preview entirely, on the assumption that the campaign organizer above
the panel was the same person; that's not true when an organizer runs
a campaign on someone else's behalf.

Switch all three call sites — RecipientRow, BeneficiaryDonatePanel's
profile row, and the hero "by {creatorName}" link — to the canonical
useProfileUrl helper, which picks a verified NIP-05 path when available
and falls back to the npub. Drop the hideProfile prop on
BeneficiaryDonatePanel so single-beneficiary campaigns always show the
beneficiary's avatar and name with a working profile link.
2026-05-18 21:09:25 -05:00
Alex Gleason aa5f5d7640 Brand the auth dialog with the Agora bolt mark
Replaces the generic key emoji / lucide Key icons on the welcome,
generate, and secure steps with AgoraBoltIcon (matching the rest of
the app's onboarding chrome). Personalizes the dialog title with the
app name and clarifies the welcome buttons to 'Create a new Nostr
account' / 'Log in to an existing account'.
2026-05-18 20:57:29 -05:00
Alex Gleason 0b1caeffa7 Unify auth into a single Join button using MKStack AuthDialog
Replaces the separate Log in / Sign up entry points with one Join
button that opens AuthDialog (welcome -> create-account or log-in).
Drops the full-screen SetupQuestionnaire signup flow, LoginDialog,
SignupDialog, and the useOnboarding context.

Removes InitialSyncGate so the app no longer blocks on initial
encrypted-settings / relay-list / mute-list sync. The same side
effects now run via InitialSyncRunner, mounted alongside NostrSync
at the top of the tree, so settings still get pulled and seeded
into the query cache on login \u2014 just in the background.
2026-05-18 20:53:15 -05:00
Alex Gleason dfeeb81ab8 Fix useLayoutOptions resetting store on Suspense / StrictMode unmount
The unmount cleanup deferred its store.reset() to a rAF so navigating
pages had a chance to overwrite the store first. The deferred reset
checked whether the store still held this hook's snapshot \u2014 if yes,
reset.

But Suspense (and React StrictMode dev double-invoke) trigger a
cleanup-then-resetup cycle on the same hook instance: the cleanup
fires, schedules the rAF, then the setup re-runs but doesn't write
a new snapshot (shallowEqualOptions sees identical options and
skips). When the rAF fires, the store still has the original
snapshot, so the check passes and the store is wrongly reset \u2014
silently dropping the page's layout options (noMaxWidth,
wrapperClassName, etc.) mid-life.

The symptom: campaign pages and other noMaxWidth pages narrowed to
the default max-w-3xl cap a few seconds after opening, whenever
something downstream triggered a Suspense boundary to re-resolve.

Fix: a per-instance 'unmounting' ref. Cleanup flips it to true,
setup flips it back to false. The rAF bails if the hook re-mounted
in between, so only genuine unmounts trigger the reset.

Regression-of: 2bce20ba
2026-05-18 20:25:37 -05:00
Alex Gleason 2bce20ba03 Restructure campaign page into 2-column GoFundMe-style layout
Splits CampaignDetailContent into a main article column (story +
comments + threaded donation receipts) and a sticky right donate
column that holds raised stats / progress / primary CTA / share /
beneficiaries / recent donors. On lg+ the column sticks beside the
article; on mobile it collapses inline below the hero so the donate
CTA stays above the fold.

Extracts CampaignHero, CampaignStory, DonateColumn,
SingleBeneficiaryActions, MultiBeneficiaryActions, DonorPreviewList
as named subcomponents so the two recipient variants stay in one
file but each has a self-contained code path.

The donor list ('Recent donations') shows up to 5 aggregated kind 8333
receipts (amount + relative time, no avatar) with a 'See all' button
that scrolls to the inline activity feed below where every receipt
is already rendered alongside comments.

Drops CampaignProgress in favor of a raw Progress bar so the column's
raised/of-goal text isn't duplicated by the helper's inline label.
2026-05-18 19:58:40 -05:00
Alex Gleason 208296f841 Move 'Open in wallet' button under the Bitcoin address in beneficiary panel
The button now lives inside BeneficiaryDonatePanel directly under the
copyable address, so it's always present wherever the panel renders
(inline on single-beneficiary campaign pages, and inside the dialog
for multi-beneficiary campaigns).

Drops the top primary donate button on single-beneficiary campaign
pages \u2014 redundant now that the panel has its own \u2014 and lets the
Share button take the full row.

Regression-of: 6488a0ed
2026-05-18 19:41:48 -05:00
Alex Gleason 6488a0ed63 Inline single-beneficiary QR panel into campaign page
For campaigns with exactly one recipient, the QR + Bitcoin address +
copyable string that BeneficiaryDonateDialog used to host in a modal
is now embedded directly in the 'Beneficiary' section of the campaign
page. The big primary button becomes 'Open in wallet' and links to
the same BIP-21 URI as the inline QR.

Extracts BeneficiaryDonatePanel as the reusable body; the dialog
keeps wrapping it for the multi-beneficiary case where each row's
Donate button still opens a modal.

Regression-of: 69929fc0
2026-05-18 19:34:50 -05:00
Alex Gleason 69929fc00d Route logged-out single-beneficiary donate button to per-beneficiary dialog 2026-05-18 19:26:52 -05:00
Alex Gleason 83b4290e62 Show recipient name and avatar in beneficiary donate dialog 2026-05-18 19:09:30 -05:00
Alex Gleason e8bf01b149 Replace animal-name fallback with "Anonymous" 2026-05-18 19:03:41 -05:00
filemon fc950865c4 Fix post menu URL overflow, campaign slug ellipsis, and zoom control jump
- NoteMoreMenu: use overflow-wrap-anywhere so long URLs break safely
  within the 3-line post preview instead of overflowing the dialog
- CreateCampaignPage: add min-w-0 to the slug truncate span and show
  trailing '...' when the slug was clipped to the 64-char limit
- index.css: move Leaflet top offset from margin-top on individual
  controls to top on the .leaflet-top container, preventing the
  visual jump that occurred when Leaflet re-rendered controls on zoom
2026-05-18 20:43:05 -03:00
Alex Gleason 043d70fbe0 Strip donate-beneficiary dialog to QR + address + open-in-wallet
The previous version was overbuilt. Drop the title, description,
recipient identity strip, and the heads-up alert. Keep the QR code,
the copyable address, and the Open-in-wallet button. Remove the
Bitcoin icon from the Donate trigger on the campaign page too.

The required-by-Radix DialogTitle / DialogDescription are kept but
moved to sr-only so screen-reader users still get context.
2026-05-18 18:40:31 -05:00
Alex Gleason 77db5965a9 Add per-beneficiary donate dialog on campaign page
Each beneficiary listed on a campaign page now has a Donate button
next to it. Clicking it opens a dialog that shows the beneficiary's
Bitcoin (Taproot) address — derived from their Nostr pubkey — as a
scannable BIP-21 QR code and a copyable address.

This is distinct from the existing campaign DonateDialog, which
splits across all recipients. The new dialog targets a single
beneficiary directly, so it intentionally skips amount entry and
the campaign-tally flow.
2026-05-18 18:38:17 -05:00
filemon 2f8569c302 Fix five small UI polish issues
- Push Leaflet zoom controls below the sticky header on /world so they
  remain clickable instead of sitting behind the 64px top bar.
- Replace plain <a> tags with React Router <Link> in the site footer so
  navigation to /help, /privacy, /safety, and /changelog is client-side
  instead of triggering a full page reload.
- Use plain-text preview with line-clamp-3 in the post more-menu instead
  of NoteContent + a conflicting max-h hard clip, so long content
  truncates with an ellipsis rather than cutting off abruptly.
- Switch the campaign detail Donate/Share row from a rigid 4-column grid
  to flex so the Share button gets its natural width instead of being
  cramped into 25% of the row on mobile.
- Make the campaign URL preview in /campaigns/new truncate with an
  ellipsis for long slugs instead of overflowing or clipping silently.
2026-05-18 20:09:36 -03:00
Alex Gleason 7465ad01d4 Hide top-nav Start Campaign button when logged out 2026-05-18 17:45:40 -05:00
Alex Gleason ab9e8bfcd6 Hide campaign progress bar when no goal is set 2026-05-18 16:55:45 -05:00
Alex Gleason d71d6de05f Publish a single kind 8333 receipt per donation tx
A single Bitcoin transaction with N outputs now produces a single kind
8333 onchain-zap event listing every recipient under its own `p` tag,
instead of one event per recipient. The `amount` tag carries the total
sats paid to the listed recipients (the full donation, excluding the
donor's change).

This is straight-forward forward-compatibility: legacy single-recipient
events are just the degenerate case (one `p` tag, amount equal to the
one recipient's slice). Aggregators (`useCampaignDonations`,
`useGlobalDonations`) simplify to summing the `amount` tag across every
matching event — under both schemas an event's `amount` is the total
paid to the recipients listed in that event, so the sum across all
events for a campaign is the campaign total either way.

The verifier (`verifyOnchainZap`) now sums tx outputs paying any listed
recipient's derived Taproot address and strips the sender from the
recipient set so a tx that includes the sender plus legitimate
recipients still verifies. The notifications surface uses a new
`getZapAmountSatsForRecipient` helper to attribute only the viewer's
estimated slice (amount / p_count) rather than crediting them with the
full multi-recipient donation. `CampaignDetailPage` keeps its
group-by-(txid, donor) reply rendering so legacy multi-event donations
still collapse to a single donation card.
2026-05-18 16:36:32 -05:00
filemon f9eec18adb Merge branch 'main' into dashboard-ui-polish 2026-05-18 16:10:50 -03:00
filemon ba08d749ac Remove sidebar add/remove button from dashboard header 2026-05-18 15:55:44 -03:00
filemon 6eccacc06a Update getStableCount comment to reflect current usage
The comment said it was not used in participants, but it now is.
2026-05-18 14:59:48 -03:00
Chad Curtis 8feaccf5dd Increase world map default zoom and recenter on Atlantic 2026-05-18 12:55:18 -05:00
Chad Curtis 1ac62aac06 Add photo banner heroes to organize and actions 2026-05-18 12:52:54 -05:00
lemon 041979de07 Tighten mobile nav spacing 2026-05-18 10:48:44 -07:00
lemon 59556406a8 Move mobile footer links below CTA 2026-05-18 10:47:04 -07:00
lemon 58bb3046e7 Reorganize sidebar navigation 2026-05-18 10:43:49 -07:00
filemon 45242292d6 Use stable COUNT floor for participants list counts
Apply getStableCount to the participants/full-state-list section so
per-state numbers are consistent with the leaderboard and distribution
chart. Previously the participants list used raw event-based counts
while the other sections used NIP-45 COUNT floors, causing visible
mismatches (e.g. Miranda showing 847 in the list but 859 in the donut).

In municipalities view this is a no-op — getStableCount returns the raw
feed.count unchanged since there are no per-municipality COUNT queries.

Live/activity indicators still derive from loaded events.
2026-05-18 14:32:50 -03:00
lemon ef9adb29e8 Update primary navigation 2026-05-18 10:30:15 -07:00
lemon 1d5f0541d7 Clone discover hero ticker 2026-05-18 10:26:04 -07:00
lemon 0671910e67 Match organize hero ticker style 2026-05-18 10:24:51 -07:00
filemon 7bb960b6b3 Align dashboard header with content column and add bottom spacing
Pass max-w-5xl mx-auto sm:px-6 to PageHeader via its className prop so
the title and action buttons sit inside the same centered column as the
dashboard body.  This is a local override — the shared PageHeader
component is not modified.

Add pb-8 to the content container for comfortable breathing room between
the last dashboard section and the footer.
2026-05-18 14:24:20 -03:00
lemon e71d95fcc6 Refine organize hero copy 2026-05-18 10:23:43 -07:00
lemon 84496d30a1 Align organize hero with discover 2026-05-18 10:17:35 -07:00
filemon a0ca42af26 Polish dashboard page to match app design system
Normalize the dashboard layout and card styling for visual consistency
with the rest of the app. No data fetching or behavioral changes.

Page container:
- Widen content from max-w-4xl to max-w-5xl (justifies noMaxWidth opt-out)
- Add responsive padding (px-4 sm:px-6)
- Replace non-standard pb-24 with pb-16 sidebar:pb-0
- Add min-h-screen to prevent short-page footer ride-up
- Error state now uses the same max-w-5xl container (no layout jump)

Header actions:
- Replace Trash2 icon with PanelLeftClose for sidebar toggle (less alarming)
- Convert sidebar toggle from outline button with text to ghost icon button
- Add aria-label attributes for accessibility
- Tighten gap from gap-2 to gap-1.5 for compact icon-button row
- Move statusBadge/headerActions above the error early-return so both
  code paths share the same header

Chart cards (ActivityChart, TopRegionsChart, DistributionDonut):
- Replace raw rounded-2xl border divs with shadcn Card/CardHeader/CardContent
- Picks up consistent rounded-lg, bg-card, shadow-sm, and standard padding

List cards (ParticipantsList, RecentActivityList):
- Normalize from rounded-2xl to rounded-lg with bg-card and shadow-sm
- Preserve overflow-hidden and custom internal grid layouts

Skeleton:
- Add tabs placeholder skeleton
- Use Card/CardHeader/CardContent for chart skeletons
- Normalize table skeleton wrapper to match new card styles

Tabs:
- Add bg-muted/50 to TabsList for subtle visual grounding
2026-05-18 14:14:32 -03:00
lemon 9905d39e19 Redesign communities landing page 2026-05-18 10:14:21 -07:00
lemon e91f4a2c63 Move help to account menu 2026-05-18 09:55:18 -07:00
lemon 98976c9ce9 Add search nav link 2026-05-18 09:53:50 -07:00
lemon b1e0bcda63 Add organize nav link 2026-05-18 09:50:53 -07:00
lemon 634e161085 Remove ephemeral geo chat 2026-05-18 09:49:31 -07:00
lemon ae41290b68 Hide blocked campaign 2026-05-18 09:45:30 -07:00
lemon 77b35995eb Align top nav controls 2026-05-18 09:41:59 -07:00
lemon 94bcf23b68 Improve account menu 2026-05-18 09:37:46 -07:00
lemon 847b2f2f00 Update Agora navigation 2026-05-18 09:33:44 -07:00
lemon 1eace996f5 Simplify community tab selector 2026-05-18 00:22:48 -07:00
lemon 9d4116b478 Polish donation success state 2026-05-18 00:20:38 -07:00
lemon c281764bd9 Reuse donation dialog for communities 2026-05-18 00:16:49 -07:00
lemon a3e3202f21 Use review step for community zaps 2026-05-18 00:10:43 -07:00
lemon 09dac639c9 Improve donation review details 2026-05-18 00:01:50 -07:00
lemon b3bdf69d61 Polish community and donation flows 2026-05-17 23:58:31 -07:00
lemon 59fd1b2d14 Make campaign submit full width 2026-05-17 23:12:13 -07:00
lemon de26235621 Remove campaign form subtext 2026-05-17 23:12:13 -07:00
lemon f4f07ce91f Remove empty beneficiary placeholder 2026-05-17 23:12:13 -07:00
lemon 93d00ea4c0 Polish campaign deadline input 2026-05-17 23:12:13 -07:00
lemon 2571f9d216 Polish campaign goal input 2026-05-17 23:12:13 -07:00
lemon f665ffa0c0 Flatten campaign form details 2026-05-17 23:12:13 -07:00
lemon 58ca29fb62 Inline beneficiary notify action 2026-05-17 23:12:13 -07:00
lemon a4e785e574 Use canonical invite links 2026-05-17 23:12:13 -07:00
lemon f3b277bc23 Share campaign recipient invites 2026-05-17 23:12:13 -07:00
lemon 42b901d769 Polish campaign country selector 2026-05-17 23:12:13 -07:00
lemon ba2c541c31 Add country tags to campaigns 2026-05-17 23:12:13 -07:00
Chad Curtis 735de6ece9 Treat Tibet as country-level across post + country chrome
Extends the prior Tibet/CountryFlag work so the Snow Lion flag wins
everywhere a CN-XZ post or page surfaces, not just in the Discover
country pulse strip.

  - Comment context (NoteCard + PostDetailPage)
      * Country pill on the card header swaps in the SVG via
        CountryFlag (was bare emoji span).
      * Pill hover card uses the subdivision's own name, drops the
        parent-country sub-line, and labels it as 'Country' rather
        than 'Region' for codes with a custom flag.
      * CountryFlagBackdrop (the faded full-bleed flag behind a
        country-rooted note) prefers the bundled SVG over the
        Wikipedia lead image, which for Tibet returns an
        administrative map.

  - PostDetailPage 'country above the post' chip
      * CountryPreview now routes through CountryFlag and prefers
        info.subdivisionName when a custom flag is registered, so
        the chip reads as 'Tibet' instead of 'China'.

  - Country page (/i/iso3166:CN-XZ)
      * Hero banner driven by customFlagAsset(code) when present,
        sharing the same <img>+skyOverlay pipeline as Wikipedia
        photos so the day/night tint and bottom fade still apply.
      * Subline beneath the title no longer falls into the
        'subdivision = show parent country' branch for custom-flag
        codes; it now reads the Wikipedia description / official
        name like other countries do.
      * Big flag slot uses CountryFlag too, bypassing the Wikipedia
        subdivision thumbnail.

  - Helpers split out of CountryFlag.tsx into src/lib/customFlags.ts
    (hasCustomFlag, customFlagAsset) so the component file only
    exports a component — fixes the react-refresh warning that came
    out of the first pass.

  - Action cards (feed + detail) now render their country chip
    through CountryFlag, picking up the SVG for any future Tibet-
    tagged action.
2026-05-17 23:39:23 -05:00
Chad Curtis e5f7ece942 Bring back Tibet as a country with Snow Lion flag SVG
The older Pathos/Agora codebase treated CN-XZ as country-level Tibet
with a bundled Wikimedia Snow Lion SVG (commits f03d2400, 351b3be4,
6e04b80d). That fell out somewhere in the port — restore it.

  - public/flag-tibet.svg recovered verbatim from f03d2400.
  - New CountryFlag component centralises the country-flag rendering
    decision: emoji for everyone Unicode covers, bundled SVG for the
    short list of recognised flags that don't have an emoji
    codepoint (Tibet today, room for more later).
  - CountryPulseStrip special-cases CN-XZ as country-level: renders
    'Tibet' (not 'Tibet Autonomous Region, China') and drops the
    XZ subdivision-token badge.

Also adds the subdivisionFlag() helper for RGI tag-sequence
subdivisions (England, Scotland, Wales) — Unicode actually does
ship those, and the strip now picks them up automatically.

Other Unicode-missing subdivisions (US states, Canadian provinces)
still render as parent country flag plus a typographic ISO 3166-2
badge. They have no emoji codepoint and bundling a flag pack for
every state is out of scope for this change.
2026-05-17 23:39:23 -05:00
Chad Curtis 5ebc988190 Narrow /discover content widths
Cap the Discover page at max-w-5xl (down from max-w-7xl) so the
hero, country pulse, and shelves stop sprawling on widescreen
displays. Tighten the mixed feed below to max-w-2xl so each row
reads at a comfortable line length, the same reading column width
as the rest of Agora's NoteCard feeds — while the horizontal
shelves keep their wider canvas above.
2026-05-17 23:39:23 -05:00
Chad Curtis 53cc92d9d0 Make /discover the public square: globe, country pulse, mixed feed
The 'Discover' nav link used to drop visitors on /feed — a plain
kind-1 timeline that didn't connect any of the three things Agora is
actually about. This wires a new /discover page that weaves them
together while the old plain feed stays put at /feed.

Page composition:

  - DiscoverHero — reuses the hand-drawn HeroGlobe but reframes it
    around the world itself, not any one campaign. Three marker
    layers (campaign hearts, community rings, country-pulse dots)
    sit on the same sphere, the HOPE_PALETTE slowly drifts every 9s,
    and a rotating ticker pill surfaces immutable network-wide
    stats: total sats raised on-chain, communities online, countries
    posting today.
  - CountryPulseStrip — horizontal strip of country flag chips
    ordered by trailing-window activity from the trusted kind 30385
    snapshots. Click opens /i/iso3166:XX.
  - 'Help raise hope' — horizontal CampaignCard shelf.
  - 'Find your people' — horizontal CommunityMiniCard shelf.
  - 'Voices from everywhere' — useDiscoverFeed infinite timeline
    mixing new campaigns, country posts, community comments, and
    Agora actions, rendered with the kind-appropriate card.

HeroGlobe gains an optional GlobeMarkerKind on each marker so the
campaigns page keeps its hearts-only behaviour while Discover layers
in rings and warm dots.

New hooks:
  - useDiscoverCommunities — global kind 34550 discovery
  - useDiscoverFeed — paginated mixed feed (30223 + 1111 + 36639)
  - useGlobalDonations — network-wide kind 8333 aggregate for the
    hero ticker
2026-05-17 23:39:23 -05:00
Alex Gleason 5c3dc851bc Merge branch 'main' of gitlab.com:soapbox-pub/agora 2026-05-17 23:05:55 -05:00
Alex Gleason 4e8cf62418 Drop redundant Wallet header from /wallet
The PageHeader served no purpose beyond labeling the page: the wallet
UI sits right under it with the balance front and center, the mobile
bolt now opens this route directly, and the page title still wires
through useSeoMeta for the browser tab. Removing it tightens the
mobile layout and saves vertical space above the balance.
2026-05-17 23:04:37 -05:00
Alex Gleason 883b5b5760 Point bottom-nav bolt at /wallet
The apex bolt button in the mobile bottom nav previously routed to the
user's configured home page (defaulting to /), duplicating the Feed
sidebar entry. With /bitcoin folded into /wallet there's no longer a
prominent path to the wallet from mobile, so wire the bolt directly to
/wallet. Tapping it while already on /wallet scrolls to top; the old
feed-cache invalidation no longer applies.
2026-05-17 23:02:13 -05:00
Alex Gleason ff671bda39 Init bitcoinjs-lib ECC eagerly in main.tsx
nostrPubkeyToBitcoinAddress and the PSBT build helpers call
bitcoin.payments.p2tr / Psbt.sign, which require an ECC library to be
registered via bitcoin.initEccLib() first. The lazy init lived inside
getECPair(), which is only reached on the signing path — so render-time
callers (WalletPage, SendBitcoinDialog) blew up on first paint with
'No ECC Library provided'.

Ditto initializes ECC eagerly in main.tsx; agora's bitcoin.ts came from
that port but the main.tsx side never did. Add it.

Regression-of: 9190f62b
2026-05-17 22:56:21 -05:00
Alex Gleason 9190f62b9e Consolidate /bitcoin into /wallet, drop Lightning custody
Agora previously shipped two parallel wallets: a heavy 6,400-line Breez
SDK Lightning wallet at /wallet and a lightweight on-chain Taproot view
at /bitcoin derived from the user's Nostr pubkey. Maintaining two key
custody models, two send flows, two zap paths (Lightning via Spark,
on-chain via PSBT), and the Spark-specific UI (CreateWallet, mnemonic
backup/restore, lock screen, payment history, etc.) didn't pay for itself
once on-chain Bitcoin signing via NIP-07/NIP-46 became viable.

This consolidation aligns Agora with Ditto's wallet model:

  - The on-chain Taproot view from /bitcoin becomes the only /wallet UI.
  - /bitcoin redirects to /wallet for back-compat; sidebar and TopNav
    drop the duplicate Bitcoin entry.
  - The Breez/Spark wallet stack is removed: SparkWalletProvider,
    SparkWalletContext, all of src/components/SparkWallet/*, useSparkWallet,
    useCommunityBatchZaps, usePaymentContext, WalletSettingsContent, and
    LightningEffect are deleted (~6,400 lines).
  - Ditto's mature bitcoin/zap stack is ported: useOnchainZap (single-event
    on-chain zaps + kind 8333 receipts), OnchainZapContent, ZapDialog with
    Bitcoin/Lightning tabs, ZapSuccessScreen, BitcoinContentHeader, and the
    larger SendBitcoinDialog. useZaps loses its breezService branch and
    falls back to NWC → WebLN → manual QR.
  - bitcoin.ts now threads esploraBaseUrl through every call, matching
    AppConfig and allowing future relay/Esplora customization.
  - CommunityZapDialog is bitcoin-only; CommunityDetailPage drops the
    sibling Lightning trigger.

Lightning recovery remains intentional. A small "Looking for your old
wallet?" link on /wallet routes to /wallet/recovery, which lazy-loads
@breeztech/breez-sdk-spark (now in its own 67 KB chunk plus the WASM)
only when a user needs to evacuate funds. The recovery page:

  - Auto-detects the NIP-78 kind-30078 d="spark-wallet-backup" relay
    backup and offers one-click NIP-44 decrypt via the user's signer.
  - Accepts a manual 12-word mnemonic as fallback.
  - Connects Breez in-memory, sweeps the entire on-chain balance to the
    user's Nostr-derived Taproot address, then disconnects. Nothing is
    persisted; the old wallet is never "restored" — only evacuated.

Other small carry-overs from Ditto needed by the ported code:
useFormatMoney + AppConfig.currencyDisplay ("usd" | "sats"), and the
nostrId helper (HexId branded type + isNostrId validator).

48 files changed, 2,464 insertions(+), 9,743 deletions(-).
2026-05-17 22:53:20 -05:00
Chad Curtis 707b24f41e Raise hero globe minimum size so it doesn't shrink as much 2026-05-17 22:46:50 -05:00
Chad Curtis 7e93dcba6c Stop hero globe from snapping back on spotlight change
The rotation rAF loop was being keyed by `useEffect([markers,
ringSizes, selectedKey])`. Each time `HeroCampaignSpotlight` cycled
to the next campaign, `selectedKey` changed, the effect tore down,
the new effect re-initialized `start = null`, and the elapsed-time
calculation snapped rotation back to 0°.

Hold `markers` and `selectedKey` in refs that the rAF loop reads
on each frame, and drop them from the effect's dep list so the loop
runs uninterrupted for the lifetime of the component.

Also dresses the hero's primary CTA up as an Apple-style liquid glass
pill: faint warm-tinted translucent body (white→amber→rose at low
opacity), heavy backdrop blur, hair-thin inner edge, soft warm-tinted
drop shadow. Hover lifts the tint and shadow a hair without changing
the pill's character — no specular streaks, no halo, no shadow
bloom. Slightly taller than the default `size=lg` (h-12, px-7,
text-base) so it reads as primary without feeling chunky.
2026-05-17 21:53:28 -05:00
Chad Curtis 42abac7527 Polish hero into hopeful beacon with per-campaign atmosphere
- Anchor the globe's center to the right edge of the `max-w-7xl` content
  container (matching the TopNav account switcher), nudged inward via a
  percentage translate so a substantial slice of the sphere always reads
  inside the hero regardless of viewport width.
- Drop the per-breakpoint width classes in favor of a fluid
  `clamp(360px, 46dvw, 820px)` so the globe scales smoothly with the
  viewport instead of in three discrete jumps. HeroGlobe accepts a
  `style` prop so the page can pass the clamp() inline.

Make the globe feel like a beacon of hope:

- New outer halo div behind the SVG with a wide hue-tinted radial glow,
  heavy blur, and a slow opacity-only breathing animation
  (`hero-globe-halo-breath`, 6.5s) so the layout never shifts.
- Sphere base gradient warmed from cream/cool-earth to dawn-gold/honey
  — the disc reads as 'lit from within' instead of dirt-colored.
- Outer dark rim swapped for a soft back-lit limb light tinted with the
  active hope hue. Narrow band, low opacity — suggests atmosphere
  rather than a neon ring.

Tie the globe to the surrounding atmosphere:

- New `src/lib/hopePalette.ts` exports a curated set of warm sunrise /
  dawn hues (`scrim`, `glow`, `rim` per entry) plus
  `hopeHueFor(seed)` that deterministically hashes a string (e.g. the
  campaign aTag) to a stable palette entry.
- New `HeroAtmosphere` mounts a fresh layer of tinted gradients each
  time the active seed changes and crossfades over 1.5s to match
  `CampaignHeroBackground`. Uses `mix-blend-mode: screen` so it warms
  the photo instead of flattening it.
- `HeroGlobe` takes the active `HopeHue` so the halo and limb tint
  agree with the rest of the hero.

Layer order is now: photo BG → atmosphere → globe → readability scrim →
content. The scrim sits *above* the globe so it can darken whatever
slice of the sphere ends up behind the headline, and is hidden at lg+
where the globe is already pushed outside the headline column.
2026-05-17 21:53:28 -05:00
mkfain 937da49cd7 Drop templated reply on /claim, copy npub only
Per design feedback, the npub card on /claim's empty state no longer
offers a templated reply message — just a single 'Copy my npub'
action backed by the same click-to-copy npub block above it.
2026-05-17 21:34:31 -05:00
mkfain a26269ebbe Show npub copy card on /claim empty state
Freshly-signed-up invitees who reach /claim with no campaigns yet now
see a primary card with their own npub formatted for copy. Two copy
actions:

- 'Copy reply message' (templated: "I finished setting up my Agora
  account! My npub is: npub1… — you can add me as a beneficiary now.")
- 'Copy npub only' for pasting into an existing thread.

The old 'No campaigns found yet' card stays as a secondary, demoted
'Expecting a campaign already?' message below the npub card.

Pure UX — no URL params, no inviter awareness, no analytics ping back.
The invitee still sends the message manually through whatever channel
the invite originally came from.
2026-05-17 21:32:57 -05:00
Chad Curtis 0fcce88409 Nudge the hero globe ~10% left at each breakpoint 2026-05-17 21:15:05 -05:00
Chad Curtis 0ea1e55ee4 Slow the hero globe to ~140s per revolution 2026-05-17 21:15:05 -05:00
Chad Curtis 2c8cd11153 Rebuild campaigns hero around photo BG + globe + spotlight
The hero is now layered like Treasures' HeroGallery:

- CampaignHeroBackground (new) — full-bleed banner image from the
  currently-spotlit campaign, crossfading over ~1.5 s and panning left.
  Warm tint + film grain overlay so foreground text stays legible.
- HeroGlobe — pushed to the right edge with a larger radius, slightly
  translucent so the photo bleeds through. Hearts replace the old dots
  for marker symbols; clicking one selects that campaign.
- HeroCampaignSpotlight (new) — minimal text overlay anchored to the
  bottom-left of the hero container (title, summary, avatar + author,
  location, progress bar with goal, 'View' link). No card chrome.

Land polygons are now the full Natural Earth 110m fidelity (~10.5k
vertices) instead of being heavily Douglas-Peucker'd, so coastlines
look organic rather than chunky. Back-hemisphere rings are now
properly hidden by walking each edge and either dropping back-side
vertices outright or interpolating to the sphere limb where a ring
crosses it — fixes the 'phantom continents through the front' bug.
Rings additionally fade in/out over a narrow z-band near the limb
instead of popping at z = 0.

Markers also have proper z-fade and pull off-canvas when on the back
so they can't intercept clicks they aren't visible for. Selected
markers scale 1.35x with a stronger glow so the user can tell which
campaign the spotlight refers to.

Other cleanup:

- formatCampaignAmount + formatSatsShort move out of CampaignCard.tsx
  into src/lib/formatCampaignAmount.ts so CampaignCard stops failing
  the react-refresh/only-export-components lint.
- Hero CTAs drop the 'Unstoppable fundraising on Nostr' pill and the
  em dash from the supporting copy.
- New keyframes (heroPanLeft / heroPanRight) for the slow Ken-Burns
  pan on the background photos, with prefers-reduced-motion respected.
2026-05-17 21:15:05 -05:00
Chad Curtis 2a69747744 Add slow-spinning globe behind campaigns hero
Drops the 'Unstoppable fundraising on Nostr' pill and the em dash from the
hero copy, and adds an ambient SVG globe sitting behind the headline.

The globe is a pure-SVG orthographic projection (no WebGL, no canvas). It
renders Natural Earth 110m country boundaries pre-simplified down to ~1.5k
vertices (17 KB inline). Coloring is intentionally warm — cream sphere with
sandy-amber land — to avoid the satellite/HUD aesthetic. Campaigns whose
location string resolves to an ISO 3166-1 country appear as small glowing
markers, deduped by country.

Rotation is driven by requestAnimationFrame and applied imperatively via
refs (no React re-renders during animation), and respects
prefers-reduced-motion by holding at a static angle.
2026-05-17 21:15:05 -05:00
mkfain 48881677b5 Add /receive and /claim landing pages + invite/notify shortcuts
When creating a campaign, organizers can now invite or notify
beneficiaries directly from the recipient picker:

- 'Recipient not here yet? Invite them' button below the search box
  copies a templated message that links to /receive — a signup-focused
  landing page that pitches Agora to people who don't have a Nostr
  account yet, then redirects them to /claim after onboarding.

- Each selected recipient row gains a 'Send {name} a message about
  this campaign' button that copies a templated message linking to
  /claim — a sign-in landing page that shows the user every campaign
  whose 'p' tag includes their pubkey (using a new recipientPubkeys
  option on useCampaigns that adds an #p filter).

Both landing pages live outside FundraiserLayout so they read as
standalone marketing/landing screens, matching the FollowPage pattern.
Copy templates match the spec wording from the request.
2026-05-17 21:13:08 -05:00
mkfain c9f3a304e6 Frame logged-out donate flow as a chooser, log-in path first
The previous behavior sent logged-out single-recipient donors straight
into the external-pay (BIP-21 QR) view, which is the lossy path —
externally-paid donations never publish a kind 8333 receipt, so they
don't count toward the campaign goal or show up in the donor list.

Now the dialog opens on a LoggedOutChooserView that presents the
ideal path first:

1. **Log in & donate** (recommended, highlighted card). Opens the
   standard LoginDialog inline; on success the outer DonateDialog
   re-renders into the normal donate form.
2. **Donate to {Recipient} directly** — secondary option, only shown
   for single-recipient campaigns. Copy makes the tradeoff explicit:
   the recipient still receives the funds, but the donation won't
   count toward the campaign goal.

Multi-recipient campaigns hide the secondary option (the split
fundamentally needs the donor's signed PSBT) and explain why.

ExternalPayView gains an optional onBack so users can return to the
chooser without closing the dialog.
2026-05-17 20:50:03 -05:00
mkfain ad364e4b19 Let logged-out donors pay single-recipient campaigns externally
The split-PSBT flow legitimately needs a Nostr signature, but a campaign
with one recipient is just a regular Bitcoin payment — no reason to gate
that on a Nostr login. For single-recipient campaigns, the DonateDialog
now opens an ExternalPayView for logged-out users (and for logged-in
users whose signer can't build PSBTs) with:

- the recipient's Taproot address (Nostr-pubkey-derived) with copy
- a QR code embedding a BIP-21 `bitcoin:<addr>?amount=<btc>` URI
- optional USD amount input that drops into the URI as BTC
- copy URI / open-in-wallet actions
- a heads-up that externally-paid donations won't appear in Agora's
  donor list or progress bar, since no kind 8333 receipt is published

Multi-recipient campaigns still require login (the split needs the
donor's signed PSBT to construct one tx with N outputs).
2026-05-17 20:37:23 -05:00
mkfain f413d29fa1 Remove duplicate close X in mobile hamburger menu
The mobile nav drawer in TopNav has its own X button inside the panel
header, but SheetContent was also rendering the shadcn primitive close
button just outside the panel — two X buttons for the same sheet.

Add an opt-in `hideClose` prop to SheetContent and set it on the
TopNav drawer. Other Sheet consumers (MobileDrawer, etc.) keep the
default built-in close.
2026-05-17 20:30:45 -05:00
mkfain 323c613222 Add archive flow for campaigns
Authors can soft-close a campaign by republishing it with a
`["status", "archived"]` tag. Archived campaigns are hidden from the
main fundraisers feed and the donate button is disabled, but the detail
page still loads by direct link so existing donors can find it and
past donations remain attached. The author sees Archive / Reopen
buttons on the detail page and an Archived badge on cards.

useCampaigns gains an `includeArchived` option (default false) so a
future profile view can opt in. NIP.md documents the new status tag.
2026-05-17 20:20:44 -05:00
mkfain babfbc5b10 Round campaign USD amounts to whole dollars
Cents are visual noise on zap goal progress displays. Add satsToUSDWhole
helper and use it in CampaignCard, CampaignDetailPage, and the goal
preview in CreateCampaignPage. Wallet and send-bitcoin flows continue to
use satsToUSD with cents.
2026-05-17 20:11:14 -05:00
lemon 640a8328cf Polish campaign editing form 2026-05-17 18:03:46 -07:00
lemon e56523b819 Add campaign editing flow 2026-05-17 18:03:46 -07:00
lemon 177caded5c Refine campaign creation form 2026-05-17 18:03:46 -07:00
Chad Curtis d9d99d6b0b Point Discover nav link to /feed 2026-05-17 19:55:26 -05:00
lemon 7dbfc31f04 Render zaps with comment card layout 2026-05-17 17:40:21 -07:00
lemon fb6f157c42 Align zap cards with comments 2026-05-17 17:40:21 -07:00
lemon 3504a24be5 Realign zap card with the regular comment layout
The zap card was an ActivityCard with a chunky amber circle in
the avatar slot and a compact ActorRow up top. That visual
language reads as 'an activity log entry' which clashes with the
NIP-22 comments alongside it on the campaign page. Rebuild it
to mirror NoteCard's normal layout exactly:

- Donor avatar takes the standard size-11 (size-10 in threaded
  mode) slot, with a small amber zap badge anchored bottom-right
  to keep the kind signal.
- Author block uses the same font-bold name + nip05 + timeAgo
  stack as a regular note, with the verb ('donated' / 'sent')
  and amount inlined on the name row so the card reads as one
  sentence.
- For campaign targets the amount is followed by 'to <Campaign
  Title>' where the title is a clickable Link to the naddr —
  same routing the regular CommentContext header would use.
  Resolved via a single useAddrEvent call gated on the receipt's
  a-tag so non-campaign zaps incur no fetch.
- The donor comment renders below the author row as muted italic
  text — same spot a normal note's body sits — instead of being
  tucked under the actor row.
- Action bar uses the shared {actionButtons} JSX with the exact
  same spacing as a comment, so reply / repost / react / zap /
  share / more line up vertically across cards in the thread.

The amber Zap import and ProfileHoverCard / Avatar imports were
already in scope, so no new imports beyond useAddrEvent.
2026-05-17 17:40:21 -07:00
lemon f49c20787e Polish zap cards and clamp the campaign story
Four related changes:

1. Campaign story now clips to three lines (~4.5rem) behind a
   soft fade overlay, with a Read more / Show less toggle.
   When there's no story yet the empty placeholder renders
   unclipped as before.

2. Zap cards in NoteCard gained the same reply/repost/react/
   share action bar as a regular note. Each kind 9735 / 8333
   event is a valid Nostr event in its own right, so NIP-22
   replies target it directly via its event id and reactions
   bind to it the same way.

3. The 'zapped' label is now 'donated' when the receipt's a-tag
   points at a kind 30223 campaign, and 'sent' otherwise. The
   target kind is read from the addressable coordinate; pure
   e-tag Lightning zaps fall back to 'sent' without a fetch.

4. Zap amounts render in USD when a BTC→USD price is cached,
   with the raw sats string moved to the title tooltip. Falls
   back to sats when the price hasn't loaded. A new useBtcPrice
   hook shares the existing 'btc-price' cache key so all
   call sites (NoteCard, CampaignCard, useBitcoinWallet) dedupe
   to one in-flight request.
2026-05-17 17:40:21 -07:00
lemon 39fed90296 Trim campaign donate card
- Drop the extra divider above the action bar; PostActionBar
  already carries its own border-t/b.
- Remove hex pubkey lines under organizer and beneficiary names;
  show the NIP-05 instead when available, otherwise just the name.
- Drop the Donors section entirely. Donations now appear inline
  in the comments thread, so a separate list is redundant.
- Relocate the organizer attribution from a dedicated card section
  to a small 'by {name}' link next to the title in the hero
  overlay. Same subtle styling, just a less intrusive spot.
2026-05-17 17:40:21 -07:00
lemon 760e11138d Show donation receipts in campaign comments
Three changes work together so campaign pages reflect how
campaigns actually receive support — via on-chain donations,
not Lightning zaps:

1. NoteCard's zap-receipt layout now renders kind 8333 in
   addition to kind 9735. The helpers (getZapAmountSats,
   getZapSenderPubkey) already branched correctly; only the
   isZap gate and the amount/message extraction were 9735-only.

2. PostActionBar gained a hideZap prop. Campaigns set it so the
   action bar shows only reply / repost / react / share — a
   generic Lightning zap is the wrong CTA when the campaign
   has its own donation flow.

3. CampaignDetailPage interleaves kind 8333 donation receipts
   into the comments thread, sorted by created_at alongside
   kind 1111 comments. Each donation produces one receipt per
   beneficiary, so we dedupe by (txid, donor) and rewrite the
   canonical receipt's amount tag to the summed total so the
   card shows the full donation rather than one share.
2026-05-17 17:39:39 -07:00
lemon 534b8f0102 Add comments and reactions to campaign pages
Campaigns (kind 30223) now expose the standard PostActionBar
(reply/repost/react/zap/share/more) plus a NIP-22 threaded
comments list, mirroring how PostDetailPage handles other
addressable kinds. A stats row above the action bar opens the
existing InteractionsModal.
2026-05-17 17:39:39 -07:00
lemon 5cb4c9f950 Align campaign support section 2026-05-17 17:39:18 -07:00
lemon e37552c8ce Refine campaign support card 2026-05-17 17:39:18 -07:00
lemon 3af32e167c Simplify campaign support card 2026-05-17 17:39:18 -07:00
lemon 3927a50633 Combine campaign support sections 2026-05-17 17:39:18 -07:00
lemon 0712034720 Clarify campaign organizer and recipients 2026-05-17 17:39:18 -07:00
lemon c0a23061ee Move beneficiaries above campaign story 2026-05-17 17:38:27 -07:00
lemon 44be9e6e35 Promote campaign donation panel 2026-05-17 17:38:27 -07:00
lemon fa813ed084 Restyle campaign category meta 2026-05-17 17:38:27 -07:00
lemon 2c58a7b0fd Tune campaign hero actions 2026-05-17 17:38:27 -07:00
lemon 532ff57c29 Overlay campaign hero details 2026-05-17 17:38:27 -07:00
lemon f99c1d0b17 Avoid duplicate campaign story image 2026-05-17 17:38:27 -07:00
lemon 703bb6d3ab Use people search for campaign beneficiaries 2026-05-17 17:38:27 -07:00
lemon 162d4eee43 Make campaigns USD-first 2026-05-17 17:38:27 -07:00
Alex Gleason 810cbfba00 Set default featured campaigns 2026-05-17 19:34:51 -05:00
Chad Curtis 3e5af1922d Build campaign creator link with useProfileUrl
The 'organized by' link on the campaign detail page was using a raw
hex pubkey URL (/<hex>). Switch to useProfileUrl so the link prefers
the creator's verified NIP-05 identifier when one is available and
falls back to the npub otherwise — same pattern the rest of the app
uses for profile navigation.
2026-05-17 19:00:25 -05:00
Chad Curtis 3a540ffaa1 Point TopNav mobile drawer Profile link at the user's npub
The mobile drawer's Profile link was hardcoded to /profile, which has
no route and fell through to the catch-all NIP19Page. nip19.decode
threw on 'profile' and the profile page rendered 'Please log in to
view your profile' even when the user was logged in.

Encode the current user's pubkey as an npub for the link target,
matching how every other profile link in the app is built.

Regression-of: 704cb42e
2026-05-17 19:00:20 -05:00
Chad Curtis 2b9ea24238 Resolve hex pubkey URLs on the profile page
NIP19Page renders <ProfilePage /> for raw 64-char hex identifiers that
relays resolve to a kind-0 author, but ProfilePage only knew how to
decode NIP-19 and NIP-05. The hex param fell through nip19.decode (which
throws), returned undefined, and the page rendered 'Please log in to
view your profile' — even for logged-in users visiting somebody else's
hex URL.

Accept raw hex pubkeys in the pubkey resolver, and replace the
misleading log-in copy with 'User not found' (the no-param case can't
reach this branch from the router anyway).

Regression-of: d58f4bb6
2026-05-17 19:00:14 -05:00
Chad Curtis 308f3098f3 Update VITE_SHARE_ORIGIN example to agora.spot
The example in .env.example still suggested ditto.pub as the canonical
share origin. Use agora.spot so contributors copying the example don't
end up generating share URLs that point at a different app.
2026-05-17 18:42:46 -05:00
Chad Curtis 77eee4f872 Switch credential domain and Android deep links from ditto.pub to agora.spot
The iOS Associated Domains entitlement, Android intent filters, AASA
file, and assetlinks.json already reference agora.spot. Three call
sites still hard-coded ditto.pub:

- CREDENTIAL_DOMAIN in src/lib/credentialManager.ts, which keys iCloud
  Keychain Shared Web Credentials by domain. Saved nsecs were being
  filed under ditto.pub and so could never be matched against the
  agora.spot AASA file.
- MainActivity.handleNotificationIntent host check, which only routed
  the WebView when the tapped notification's URI host equaled
  ditto.pub.
- NostrPoller.showNotification, which built notification PendingIntents
  pointing at https://ditto.pub/notifications.
2026-05-17 18:42:46 -05:00
Chad Curtis 1b4399df68 Rebrand app identifier and IPA name from Ditto to Agora
Renames the Capacitor app identifier from pub.agora.app to
spot.agora.app and cleans up Ditto-branded artifacts that don't refer
to upstream Ditto-the-project or Ditto-stack services.

App identifier (pub.agora.app -> spot.agora.app):
- capacitor.config.ts appId
- android applicationId, namespace, package_name string, custom_url_scheme
- iOS PRODUCT_BUNDLE_IDENTIFIER (Debug + Release)
- public/.well-known/assetlinks.json package_name
- public/.well-known/apple-app-site-association app id
- Info.plist BGTaskSchedulerPermittedIdentifiers and the matching
  Swift bgTaskIdentifier (previously mismatched: plist said
  pub.agora.app.notification-refresh, Swift said
  pub.ditto.app.notification-refresh, so background refresh would
  silently fail to register)
- src/lib/helpContent.ts Zapstore URLs
- .gitlab-ci.yml --package_name for fastlane supply

Android Java package (pub.ditto.app -> spot.agora.app):
- Move android/app/src/main/java/pub/ditto/app/ ->
  android/app/src/main/java/spot/agora/app/ (4 files: MainActivity,
  DittoNotificationPlugin, NostrPoller, NotificationRelayService)
- Update package declarations to match the new Android namespace
  (was a hard build failure with namespace = spot.agora.app)
- Update proguard -keep rule
- Update NotificationRelayService ACTION_FETCH intent string
  pub.ditto.app.ACTION_FETCH -> spot.agora.app.ACTION_FETCH

Fastlane (pub.ditto.app -> spot.agora.app):
- Appfile, Matchfile, Fastfile provisioning profile specifiers.
  Matchfile still points at Soapbox's certificates git repo; a new
  match repo with certs for spot.agora.app is required before iOS CI
  signing works.

IPA artifact name (Ditto.ipa -> Agora.ipa):
- Fastfile output_name and matching CI artifact paths
- .gitlab-ci.yml: artifacts/Ditto.ipa references and the GitLab
  Generic Packages path from /packages/generic/ditto/ ->
  /packages/generic/agora/ (matches how APK/AAB are already
  published). Existing release artifacts at the old path remain
  reachable; new releases land at the new path.

Release-notes script fallback (Ditto vX.Y.Z -> Agora vX.Y.Z):
- scripts/extract-release-notes.mjs fallback used as the App Store /
  Play Store 'What's New' blurb when a changelog section has no
  summary.

manifest.webmanifest:
- Update related_applications Play Store entry to spot.agora.app.
- Remove the iTunes related_applications entry that pointed at
  the existing Ditto App Store listing; not applicable to Agora
  until Agora has its own listing.

Capacitor sync incidentals:
- npm run cap:sync picked up @capacitor/barcode-scanner registration
  that had been missed in a prior plugin install
  (android/app/capacitor.build.gradle, capacitor.settings.gradle,
  ios/App/CapApp-SPM/Package.swift).

Intentionally NOT touched:
- ditto.json filename, DittoConfigSchema, DittoConfig, and JSDoc
  references to ditto.json. The config-system shape is shared with
  upstream Ditto by design.
- relay.ditto.pub, blossom.ditto.pub, ditto.pub/api/* and other
  Ditto-stack services Agora actively consumes.
- The DittoNotificationPlugin Android/iOS class name, the
  DittoNotification JS bridge name, ditto_notification_config
  SharedPreferences keys, ic_stat_ditto drawables, and the
  DittoBridgeViewController. Renaming requires a coordinated
  JS-side rename plus a SharedPreferences migration or existing
  users on the Ditto fork lose their notification config on upgrade.
- Ditto references in skill docs, NIP.md kind comments, README, and
  zapstore.yaml attribution \u2014 those correctly describe the upstream
  Ditto project that Agora forked from.

Follow-ups required before CI succeeds end-to-end (out of scope here):
- Stand up a new fastlane match git repo containing certs +
  provisioning profiles for spot.agora.app, or update Matchfile
  git_url to point at it.
- Register spot.agora.app in App Store Connect for team GZLTTH5DLM
  and create a new App Store listing.
- Create a new Google Play Console listing for spot.agora.app
  (package name is immutable per app on Play; the existing
  pub.agora.app listing cannot be reused).
- Re-publish to Zapstore under spot.agora.app so the URLs in
  helpContent.ts resolve.
2026-05-17 18:42:46 -05:00
Alex Gleason aacfb66e2c Restore MobileBottomNav across all screen sizes
The FundraiserLayout overhaul dropped the bottom nav along with the
rest of the Twitter-style chrome. Bring it back and unhide it above
the 900px sidebar breakpoint so the Search / Communities / Feed /
Notifications / World row is available on every viewport.

- Mount <MobileBottomNav /> in FundraiserLayoutInner, outside the
  flex column so its fixed positioning behaves normally.
- Drop the 'sidebar:hidden' class on the nav element.
- Pad the layout root by --bottom-nav-height + safe-area-inset-bottom
  so the SiteFooter still clears the fixed bar.

Regression-of: 704cb42e
2026-05-17 18:24:34 -05:00
Alex Gleason a1be35f1f2 Cap center column width app-wide in FundraiserLayout
The layout outlet had no max-width, so pages without their own `max-w-*`
wrapper (e.g. /help, the home feed) stretched edge-to-edge on widescreen
monitors. Add a default `max-w-3xl` cap on the center column and wire
the existing `noMaxWidth` and `wrapperClassName` LayoutOptions through,
so pages that need wider canvases keep working — CampaignsPage,
CampaignDetailPage, CreateCampaignPage, EventDashboardPage, and WorldPage
already opt out via `noMaxWidth: true` or the `fullBleed` preset.
2026-05-17 18:10:58 -05:00
Alex Gleason fe5a622998 Merge branch 'main' of gitlab.com:soapbox-pub/agora 2026-05-17 17:41:02 -05:00
Alex Gleason 0f1103a607 Reframe homepage hero around activist funding mission 2026-05-17 17:32:09 -05:00
Chad Curtis 8975d762ef Switch default AI model to google/gemma-4-26b 2026-05-17 17:31:13 -05:00
Alex Gleason 704cb42e99 Replace MainLayout with a top-nav-only FundraiserLayout
The previous overhaul left the campaigns content nested inside the
Twitter-style three-column MainLayout (LeftSidebar + 600-px center
column + WidgetSidebar + mobile FAB + mobile bottom nav). It looked
like a Nostr client that happened to render campaign cards instead of
a fundraising site.

This commit takes the chrome down to studs:

- New FundraiserLayout: a sticky GoFundMe-style TopNav with logo,
  Discover / Start a campaign / About links, the existing LoginArea
  on the right (so the avatar dropdown / Log in & Sign up buttons all
  keep working unchanged), and a primary "Start a campaign" pill.
  Mobile collapses to a hamburger drawer with the same items plus
  quick shortcuts to Wallet / Bitcoin / Notifications / Profile /
  Settings for logged-in users.
- One full-width content area below the nav and a slim site footer.
  No LeftSidebar, no WidgetSidebar, no FAB, no MobileTopBar/BottomNav.
- The old layout still provides LayoutStoreContext / DrawerContext /
  CenterColumnContext / NavHiddenContext so every page that calls
  useLayoutOptions(...) keeps mounting cleanly. FAB / sidebar /
  scroll-direction options are simply ignored.

Routing changes:

- / now renders CampaignsPage directly (instead of dispatching
  through a configurable HomePage). /campaigns redirects to /.
- The orphaned HomePage.tsx is removed.

Campaign pages were calibrated for the old 600-px center column.
Re-flowed them to take advantage of the full canvas:

- Hero copy is recentred under max-w-7xl with GoFundMe-style language
  ("Where successful fundraisers start.").
- Campaign grid grows to four columns on xl screens.
- CampaignDetailPage drops its local sticky sub-header (redundant
  under the global TopNav) and the donation rail re-anchors to the
  new nav height.
- CreateCampaignPage drops its sticky sub-header and reads as a
  proper landing form.

The legacy MainLayout / LeftSidebar / WidgetSidebar / MobileTopBar /
MobileBottomNav / MobileDrawer / FloatingComposeButton components
remain on disk but are no longer mounted; they tree-shake out of the
production bundle.
2026-05-17 16:58:58 -05:00
Alex Gleason 1db8b4d5b0 Add fundraising campaigns as the new home surface
Pivot the homepage from a Twitter-style social feed to a GoFundMe-style
fundraising hub. Introduces a new addressable kind 30223 "Campaign" that
carries the marketing-style metadata (title, summary, cover image, story,
category, goal, deadline, location) plus a list of recipient pubkeys with
optional split weights. Documented in NIP.md alongside the kind 8333
onchain-zap spec it builds on.

Donations are sent as a single multi-output Bitcoin transaction (one
output per recipient, derived Taproot addresses) using the existing
buildUnsignedMultiOutputPsbt + useBitcoinSigner infrastructure that
backs community on-chain zaps. After broadcast, the client publishes
one kind 8333 receipt per recipient with the campaign's `a` coordinate
so the donation aggregates into the campaign's totals.

UI surfaces:

- /campaigns is now the default homePage. Hero, two featured slots
  (placeholders in src/lib/featuredCampaigns.ts), then a grid of
  user-submitted campaigns.
- /campaigns/new is a full create form with cover upload, slug
  collision check, recipient builder with per-row weights, and
  preset/custom donation-amount UX.
- naddr1 identifiers for kind 30223 route to CampaignDetailPage via
  NIP19Page (full story rendered through the existing ArticleContent
  markdown component, plus a sticky donate rail with progress).
- DonateDialog presets are tuned for on-chain amounts (10K-1M sats)
  with a dust-aware minimum guard derived from the split math.
- Fundraisers sidebar item with a HandHeart icon.

Kept the existing social-feed pages addressable from the sidebar; the
overhaul is scoped to the home/landing experience rather than removing
the underlying Nostr features.
2026-05-17 16:48:06 -05:00
Alex Gleason b62da321f7 Restore Messages sidebar entry, add WhiteNoise logo to /messages page
- Re-add the Messages item to the left sidebar with its previous
  MessageSquareMore lucide icon. Drop requiresAuth so logged-out users
  also see the entry — the page is a static recommendation.
- Restore 'messages' to the default sidebarOrder in App.tsx.
- Add WhiteNoiseIcon (the logomark from whitenoise.chat, recolored to
  currentColor so it adapts to theme) and use it on the /messages
  install-CTA card in place of the generic Lock glyph.
2026-05-17 14:41:34 -05:00
Alex Gleason c5b929187a Remove Nostr direct messaging feature
The @samthomson/nostr-messaging library opens fresh NRelay1 sockets per
participant per relay outside the shared NPool, fanning out to every
conversation partner's NIP-65 + NIP-17 inbox relays plus all
discoveryRelays in hybrid mode. In practice this drives connection counts
to several hundred relays per session.

Rather than band-aid the fan-out, drop the feature entirely and point
users to White Noise for end-to-end encrypted Nostr chat.

- Replace /messages with a 'Install White Noise' CTA card (route kept)
- Delete MessagingSettingsPage, DMProviderWrapper, messaging-intro.png
- Remove DMProvider wrapper and PROTOCOL_MODE config from App.tsx
- Drop messaging config from AppConfig, AppConfigSchema,
  EncryptedSettingsSchema, EncryptedSettings, and the NostrSync /
  useInitialSync sync paths
- Remove messages sidebar entry, default sidebarOrder slot, and
  SettingsPage messaging card
- Uninstall @samthomson/nostr-messaging and drop its tailwind content
  glob and vitest deps.inline entry
- Update copy in PrivacyPolicy, AdvancedSettings delete-account warning,
  ProfileSettings nsec warning, RequestToVanishDialog deletion checklist,
  MainLayout comment, and NIP.md
- Leave kind 4 rendering (EncryptedMessageContent) intact so DM events
  authored elsewhere still display in feeds and quote embeds
2026-05-17 14:37:49 -05:00
Alex Gleason 119307d13b Auto-reload open tabs on SW activate so returning users see fresh build immediately
Regression-of: 5b8d2d5c

The previous SW eviction commit wiped caches and called clients.claim()
on activate, but that only changes which SW handles future fetches — it
does not re-render a tab that already finished loading the stale bundle.
In practice, returning users had to manually close and reopen the tab
before seeing the new build.

Fix: after clients.claim(), iterate self.clients.matchAll({ type: 'window' })
and call client.navigate(client.url) on each one. Since this SW has no
fetch handler, the navigation falls through to the network and the tab
re-renders against the fresh index.html + hashed bundle.

Caveats:
- Users mid-interaction (typing a post, scrolling) lose their unsaved
  state. Acceptable trade — the alternative is they stay on a broken
  cached bundle indefinitely.
- Fires exactly once per user (only on the install -> activate transition
  for a byte-different /sw.js). No reload loop.

Also corrected the misleading comment on the main.tsx registration: that
registration is forward-looking insurance for future cache busts, not the
mechanism that evicts the old SW. The browser's own SW update check is
what re-fetches /sw.js out-of-band; our in-page JS never runs on a tab
the old precache SW is controlling.
2026-05-17 14:05:20 -05:00
Alex Gleason 5b8d2d5c06 Evict stale precache service worker from old Agora deployment
A previous version of Agora deployed at agora.spot shipped a precaching
service worker that is still controlling returning browsers and serving
them stale HTML/JS — they never see new deploys.

The fix has three parts:

1. public/sw.js — on activate, delete every Cache Storage entry the old
   SW left behind. This SW has no fetch handler, so once it takes over
   nothing re-populates the cache.

2. src/main.tsx — register /sw.js unconditionally on every web page load.
   Previously only usePushNotifications registered it, which meant users
   who never visited NotificationSettings stayed pinned to the old SW
   forever. Native (Capacitor) skips this — there is no stale SW on the
   filesystem origin.

3. .gitlab-ci.yml — the deploy-web rsync was excluding sw.js from the
   first pass and never re-adding it to the second pass, so deploys
   silently never updated sw.js. Now it ships in the second pass
   alongside index.html (after hashed assets land).
2026-05-17 13:48:24 -05:00
Alex Gleason e0024567ff ci: remove redundant build-web job
test + deploy-web already cover what build-web was doing — the test
stage validates the build via 'npm run test' (which runs vite build),
and deploy-web builds and ships the dist/ to the live site. Keeping
build-web around just burned a runner slot to produce a dist/ artifact
nobody consumed.
2026-05-17 13:41:37 -05:00
Alex Gleason 0316331fd2 Add deploy-web job to push to agora.spot on every main push
Mirrors the venus/rrsync pattern documented in GITLAB_DEPLOY.md and the
deploy job from the old agora-v1 repo. Requires DEPLOY_SSH_KEY and
DEPLOY_TARGET protected CI/CD variables, which have been migrated over
from soapbox-pub/agora-v1.
2026-05-17 13:35:45 -05:00
Lemon 7807c994ff Merge branch 'feat/on-chain' into 'main'
Re-Design UI, Add On-Chain, Enhance Country Profiles

See merge request soapbox-pub/agora!23
2026-05-17 11:23:16 -07:00
lemon cd90cbce0e Retry profile banners as blob URLs 2026-05-17 10:59:31 -07:00
lemon 650ba868b3 Fix profile banner image fallback 2026-05-17 10:34:52 -07:00
lemon 256e22f0bd Default Bitcoin community zaps to 1000 sats 2026-05-17 01:20:08 -07:00
lemon 3926a1c886 Initialize Bitcoin ECC before address derivation 2026-05-17 01:16:56 -07:00
lemon b87b70fa72 Polish Bitcoin dust limit handling 2026-05-17 01:13:53 -07:00
lemon ac48231e82 Document batch on-chain zaps 2026-05-17 01:12:16 -07:00
lemon 5f891bbce4 Add community Bitcoin zap action 2026-05-17 01:11:18 -07:00
lemon e17dbdc9c2 Reuse community zap dialog for Bitcoin 2026-05-17 01:10:09 -07:00
lemon a6ed6cd4da Add community on-chain zap hook 2026-05-17 01:08:12 -07:00
lemon 84bd0c9e17 Support multi-output Bitcoin transactions 2026-05-17 01:07:03 -07:00
lemon 11999c0e8b Add Bitcoin wallet page 2026-05-17 01:05:51 -07:00
lemon 6c2cedf8ec Add Bitcoin signing support 2026-05-17 01:00:49 -07:00
lemon 0e117fa417 Add Bitcoin wallet primitives 2026-05-17 00:59:36 -07:00
lemon c1f942210a Tighten community zap amount layout 2026-05-17 00:28:14 -07:00
lemon 302b756c54 Simplify community zap amount controls 2026-05-17 00:26:11 -07:00
lemon d239948757 Use primary community zap progress 2026-05-17 00:23:13 -07:00
lemon c8a126ffd6 Refine community zap dialog progress 2026-05-17 00:21:23 -07:00
lemon ba19a1045e Match community zap action sizing 2026-05-17 00:18:20 -07:00
lemon d53988aa0d Refine community zap controls 2026-05-17 00:16:20 -07:00
lemon 0cbaffb77f Keep transaction refresh control stable 2026-05-17 00:01:09 -07:00
lemon 24a7ae014d Stabilize transaction refresh control 2026-05-16 23:57:21 -07:00
lemon 1e6ce6fb30 Stabilize wallet deposit section 2026-05-16 23:49:35 -07:00
lemon c4cfc0bd2c Use stable Breez Spark SDK 2026-05-16 23:38:37 -07:00
lemon d4595d55bf Reuse in-flight Spark wallet sync 2026-05-16 23:21:37 -07:00
lemon 15f10cd58a Make Spark diagnostics timeout tolerant 2026-05-16 23:18:23 -07:00
lemon d5bf21c853 Add Spark wallet diagnostics 2026-05-16 23:15:26 -07:00
lemon d7996c49db Isolate Spark wallet SDK storage 2026-05-16 23:12:53 -07:00
lemon 8ea55d2c53 Require hold to submit community zaps 2026-05-16 22:46:33 -07:00
lemon a38a80fdec Skip self in community zaps 2026-05-16 22:45:08 -07:00
lemon cd97f854c0 Add community batch zaps 2026-05-16 22:37:13 -07:00
lemon 3ae03c3d17 Polish community hero tabs 2026-05-16 18:27:42 -07:00
lemon 966b71f0d8 Split community member management actions 2026-05-16 16:50:39 -07:00
lemon 2d1b270e8a Match community tabs to feed styling 2026-05-16 16:36:41 -07:00
lemon 0ac1db2085 Use theme-aware community hero fade 2026-05-16 16:32:46 -07:00
lemon 39b2f79e38 Increase world event flag backdrop opacity 2026-05-16 11:20:41 -07:00
lemon a70caae2da Allow dashboard page sidebar add 2026-05-16 11:18:03 -07:00
lemon 5cfd9a049f Remove dashboard from sidebar 2026-05-16 11:15:46 -07:00
lemon b5f2d9bebb Simplify country flag card backdrop 2026-05-16 11:04:18 -07:00
lemon d3fadeca09 Make dashboard public and optional in sidebar 2026-05-16 11:01:29 -07:00
lemon 343085684e Adapt country flag cards for light mode 2026-05-16 10:58:15 -07:00
lemon 7b66c795fe Fix unused action icon imports 2026-05-16 09:04:55 -07:00
lemon 6d4d8ee9fb Document community actions 2026-05-16 09:03:45 -07:00
lemon b772be3139 Include actions in community activity feed 2026-05-16 09:02:25 -07:00
lemon fd6b6b41bc Add community-scoped actions 2026-05-16 09:01:38 -07:00
lemon bc0b8f83d4 Render actions as feed cards 2026-05-16 09:00:02 -07:00
lemon 9dad7c2488 Extract reusable action dialog 2026-05-16 08:57:31 -07:00
lemon baf91fef89 Allow user-created optional-country actions 2026-05-16 08:54:43 -07:00
lemon 77752f8b65 Rename action challenge internals 2026-05-16 08:53:03 -07:00
lemon 5084c99367 Reorder community tabs 2026-05-16 08:30:34 -07:00
lemon 4dbd52e914 Add dashboard admin 2026-05-16 07:58:48 -07:00
lemon 9d6f2cefec Align Network empty state 2026-05-16 06:59:25 -07:00
lemon f92e013347 Clarify Following empty state 2026-05-16 06:59:25 -07:00
lemon c833021eea Default home feed to Following 2026-05-16 06:59:25 -07:00
lemon cbffd73953 Refresh feeds after bulk follows 2026-05-16 06:59:25 -07:00
lemon 7701ce006d Fix onboarding country check layering 2026-05-16 06:59:25 -07:00
lemon 9a94d2b639 Simplify onboarding country picker 2026-05-16 06:59:25 -07:00
lemon 98077d4738 Polish onboarding country grid 2026-05-16 06:59:25 -07:00
lemon 9cedada01d Improve onboarding country picker 2026-05-16 06:59:25 -07:00
lemon b01e7e8fe5 Add country follows to onboarding 2026-05-16 06:59:25 -07:00
sam aeb73e941b transparency++ 2026-05-15 14:47:12 +07:00
sam 9fed3bc0b7 edge case to avoid stale data getting stuck 2026-05-15 12:57:36 +07:00
sam 6555253224 perf 2026-05-15 12:43:36 +07:00
sam 4ad6feac5d first draft - planetora; a visualisation/hub of agora activity 2026-05-15 12:23:07 +07:00
367 changed files with 39048 additions and 23836 deletions
+1 -1
View File
@@ -5,7 +5,7 @@ VITE_PLAUSIBLE_ENDPOINT="https://plausible.example.tld/api/event"
VITE_NOSTR_PUSH_PUBKEY=""
# Canonical origin used when generating shareable URLs (QR codes, copy-link, remote-login callbacks).
# Primarily useful for native (Capacitor) builds, where window.location.origin is capacitor://localhost.
# Example: VITE_SHARE_ORIGIN="https://ditto.pub"
# Example: VITE_SHARE_ORIGIN="https://agora.spot"
VITE_SHARE_ORIGIN=""
# Set to "*" to allow any host in the Vite dev server (eg. when proxying through a custom domain)
# ALLOWED_HOSTS="*"
+43 -27
View File
@@ -26,6 +26,38 @@ test:
script:
- npm run test
# Deploy the built web app to agora.spot on venus.vps via rsync over SSH.
# Uses the per-site jailed deploy key documented in GITLAB_DEPLOY.md.
# DEPLOY_SSH_KEY and DEPLOY_TARGET are protected CI/CD variables; they're
# only exposed to jobs on the protected default branch.
deploy-web:
stage: deploy
timeout: 10 minutes
rules:
- if: $CI_COMMIT_TAG
when: never
- if: $CI_COMMIT_REF_NAME == $CI_DEFAULT_BRANCH && $DEPLOY_SSH_KEY && $DEPLOY_TARGET
script:
# Build the web app
- npm ci
- npm run build
- cp dist/index.html dist/404.html
# Install rsync + ssh client and load the deploy key
- apt-get update -qq && apt-get install -y --no-install-recommends rsync openssh-client >/dev/null
- mkdir -p ~/.ssh && chmod 700 ~/.ssh
- echo "$DEPLOY_SSH_KEY" | tr -d '\r' > ~/.ssh/id_ed25519 && chmod 600 ~/.ssh/id_ed25519
- ssh-keyscan -H "${DEPLOY_TARGET##*@}" >> ~/.ssh/known_hosts 2>/dev/null
# Two-phase rsync: upload hashed assets first, then index.html and sw.js,
# so the site never serves an index.html that points at assets that
# haven't finished uploading. sw.js is in the second pass for the same
# reason — it's a stable filename that all browsers re-fetch to check
# for updates, so we want it to land last. The destination ":/" is the
# rrsync jail root on venus, which maps to /var/www/agora.spot/.
- rsync -av --exclude=/sw.js --exclude=/index.html -e "ssh -i ~/.ssh/id_ed25519" dist/ "${DEPLOY_TARGET}:/"
- rsync -av -e "ssh -i ~/.ssh/id_ed25519" dist/index.html dist/sw.js "${DEPLOY_TARGET}:/"
# Disabled: nsite deploy not needed right now; re-enable by restoring the
# rules below to run on default branch (and ensure NSITE_NBUNKSEC is set).
deploy-nsite:
@@ -61,22 +93,6 @@ deploy-nsite:
--use-fallback-relays
--use-fallback-servers
build-web:
stage: build
timeout: 10 minutes
needs: []
rules:
- if: $CI_COMMIT_TAG
when: never
- if: $CI_COMMIT_REF_NAME == $CI_DEFAULT_BRANCH
script:
- npm ci
- npm run build
- cp dist/index.html dist/404.html
artifacts:
paths:
- dist/
release-notes:
stage: build
timeout: 2 minutes
@@ -88,7 +104,7 @@ release-notes:
# release-notes.md is the full section (summary + bulleted lists), used as
# the GitLab Release description. release-notes-summary.txt is the leading
# plaintext paragraph only, used as the App Store / Play Store release
# blurb. Falls back to "Ditto vX.Y.Z" when the section has no summary.
# blurb. Falls back to "Agora vX.Y.Z" when the section has no summary.
- mkdir -p artifacts
- node scripts/extract-release-notes.mjs "$CI_COMMIT_TAG" > artifacts/release-notes.md
- node scripts/extract-release-notes.mjs "$CI_COMMIT_TAG" --summary > artifacts/release-notes-summary.txt
@@ -264,27 +280,27 @@ build-ipa:
ios/App/App.xcodeproj/project.pbxproj
# Run match (cert verify + decrypt) and build_app to produce the IPA.
# build_app writes ./artifacts/Ditto.ipa relative to the project root.
# build_app writes ./artifacts/Agora.ipa relative to the project root.
- cd ios
- fastlane build_ipa
- cd ..
# Move the IPA to a stable name in the artifact directory.
- ls -lh artifacts/
- test -f artifacts/Ditto.ipa
- test -f artifacts/Agora.ipa
# Upload to the Generic Packages registry for a stable public download URL,
# mirroring how build-apk publishes the APK and AAB.
- |
curl --fail --header "JOB-TOKEN: ${CI_JOB_TOKEN}" \
--upload-file "artifacts/Ditto.ipa" \
"${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/ditto/${CI_COMMIT_TAG}/Ditto-${CI_COMMIT_TAG}.ipa"
--upload-file "artifacts/Agora.ipa" \
"${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/agora/${CI_COMMIT_TAG}/Agora-${CI_COMMIT_TAG}.ipa"
after_script:
# Wipe the API key so nothing sensitive sticks around between jobs.
- rm -f "$HOME/.private_keys"/AuthKey_*.p8 || true
artifacts:
paths:
- artifacts/Ditto.ipa
- artifacts/Agora.ipa
expire_in: 90 days
release:
@@ -317,8 +333,8 @@ release:
- name: Agora-${CI_COMMIT_TAG}.aab
url: ${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/agora/${CI_COMMIT_TAG}/Agora-${CI_COMMIT_TAG}.aab
link_type: package
- name: Ditto-${CI_COMMIT_TAG}.ipa
url: ${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/ditto/${CI_COMMIT_TAG}/Ditto-${CI_COMMIT_TAG}.ipa
- name: Agora-${CI_COMMIT_TAG}.ipa
url: ${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/agora/${CI_COMMIT_TAG}/Agora-${CI_COMMIT_TAG}.ipa
link_type: package
publish-zapstore:
@@ -379,7 +395,7 @@ publish-google-play:
- >-
fastlane supply
--aab artifacts/Agora.aab
--package_name pub.agora.app
--package_name spot.agora.app
--track production
--json_key /tmp/play-service-account.json
--metadata_path android/fastlane/metadata/android
@@ -430,7 +446,7 @@ publish-app-store:
# a JSON descriptor; we pass the API key inline via the Fastfile.
- unset APP_STORE_CONNECT_API_KEY_PATH || true
script:
- test -f artifacts/Ditto.ipa
- test -f artifacts/Agora.ipa
- test -f artifacts/release-notes-summary.txt
# Use the release summary paragraph as the App Store "What's New" text.
@@ -442,7 +458,7 @@ publish-app-store:
- echo "-------------------------"
# Submit the prebuilt IPA from build-ipa to App Store Connect for review.
- export IPA_PATH="$CI_PROJECT_DIR/artifacts/Ditto.ipa"
- export IPA_PATH="$CI_PROJECT_DIR/artifacts/Agora.ipa"
- cd ios
- fastlane submit_release
after_script:
+487 -123
View File
@@ -12,10 +12,9 @@
| Kind | Name | Description |
|-------|----------------------------|----------------------------------------------------------------|
| 20000 | Ephemeral Geo Chat (public) | Geo-anchored ephemeral chat message (kind 20000, public) |
| 20001 | Ephemeral Geo Heartbeat | Geo-anchored ephemeral presence heartbeat (kind 20001) |
| 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 | Activist Action | Country-scoped activist challenge with a sats bounty |
| 36639 | Pledge | Donor pledge for concrete submissions, stored as sats |
### Agora Protocols
@@ -23,6 +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 | 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
@@ -72,6 +119,8 @@ Because every Nostr keypair deterministically maps to a Bitcoin Taproot (P2TR) a
### Event Structure
Single-recipient zap (the common case — tipping a post or profile):
```json
{
"kind": 8333,
@@ -87,6 +136,45 @@ Because every Nostr keypair deterministically maps to a Bitcoin Taproot (P2TR) a
}
```
Multi-recipient zap (one transaction paying multiple recipients — community splits):
```json
{
"kind": 8333,
"pubkey": "<sender-pubkey>",
"content": "Great community!",
"tags": [
["i", "bitcoin:tx:<txid>"],
["p", "<recipient-1-pubkey>"],
["p", "<recipient-2-pubkey>"],
["p", "<recipient-3-pubkey>"],
["amount", "<total-sats-paid-to-all-listed-recipients>"],
["a", "34550:<community-author>:<community-d-tag>"],
["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": "<donor-pubkey>",
"content": "Keep up the good work.",
"tags": [
["i", "bitcoin:tx:<txid>"],
["amount", "<sats-paid-to-campaign-wallet>"],
["a", "33863:<campaign-author>:<campaign-d-tag>"],
["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).
@@ -96,21 +184,41 @@ The `content` field is a human-readable comment from the sender (may be empty).
| Tag | Required | Description |
|----------|----------|----------------------------------------------------------------------------------------------|
| `i` | Yes | NIP-73 external content identifier. MUST be `bitcoin:tx:<txid>` where `<txid>` is a 64-char lowercase hex Bitcoin transaction ID. |
| `p` | Yes | 32-byte hex pubkey of the zap **recipient** (the author being paid). |
| `amount` | Yes | Amount paid to the recipient in **satoshis** (decimal integer). This is the sum of outputs in the tx that paid the recipient's derived Taproot address*not* the total tx value. |
| `p` | Yes (≥1) | 32-byte hex pubkey of a zap **recipient** (an author being paid). A single event MAY include multiple `p` tags when the transaction has one output per recipient — each `p` tag MUST correspond to at least one tx output paying that recipient's derived Taproot address. |
| `amount` | Yes | **Total** amount paid in **satoshis** (decimal integer). This is the sum of outputs in the tx that paid the derived Taproot addresses of **all** listed `p` recipients combined — *not* the total tx value (it excludes the sender's change output). For single-recipient events this is the amount paid to that one recipient. |
| `e` | If zapping an event | 32-byte hex ID of the event being zapped. Include a relay hint as the 3rd element where possible. |
| `a` | If zapping an addressable event | Addressable event coordinate `<kind>:<pubkey>:<d-tag>`. Used instead of (or alongside) `e` for kinds 3000039999. |
| `alt` | Yes | NIP-31 human-readable fallback. |
If neither `e` nor `a` is present, the zap targets the recipient's **profile** (i.e. a tip to the pubkey, not to a specific event).
If neither `e` nor `a` is present, the zap targets the recipients' **profiles** (i.e. a tip to the pubkey(s), not to a specific event).
### Publishing Flow
1. Sender builds a Bitcoin transaction paying the recipient's derived Taproot address (`nostrPubkeyToBitcoinAddress(recipientPubkey)`).
1. Sender builds a Bitcoin transaction with one output per intended recipient, each paying the recipient's derived Taproot address (`nostrPubkeyToBitcoinAddress(recipientPubkey)`).
2. Sender broadcasts the transaction to the Bitcoin network and obtains the `txid`.
3. Sender signs and publishes a kind 8333 event referencing that `txid` with the appropriate `e`/`a`/`p` tags.
3. Sender signs and publishes a **single** kind 8333 event referencing that `txid` with one `p` tag per recipient and an `amount` tag carrying the total paid across all of them.
4. The event is published **after** broadcast; the txid is already final at that point.
### Batch / Community Zaps
A single Bitcoin transaction MAY pay multiple recipients by including one output per recipient. Clients SHOULD publish **one kind 8333 event per transaction**, listing every recipient under its own `p` tag and putting the combined total in the single `amount` tag. Per-recipient amounts are not encoded in the event — clients that need them recompute them from the on-chain transaction during verification (each `p` tag's derived Taproot address is matched against tx outputs).
For community-level zaps, clients MAY include the community addressable coordinate in an `a` tag and the community kind in a `K` tag:
```json
[
["i", "bitcoin:tx:<txid>"],
["p", "<recipient-1-pubkey>"],
["p", "<recipient-2-pubkey>"],
["amount", "5000"],
["a", "34550:<community-author>:<community-d-tag>"],
["K", "34550"],
["alt", "Bitcoin zap: 5000 sats across 2 recipients"]
]
```
The `amount` tag MUST be the sum of outputs paying the listed recipients; it MUST NOT include the sender's change output.
### Client Behavior
**Querying onchain zaps for an event:**
@@ -119,23 +227,37 @@ If neither `e` nor `a` is present, the zap targets the recipient's **profile** (
{ "kinds": [8333], "#e": ["<target-event-id>"], "limit": 100 }
```
For addressable events, use `"#a": ["<kind>:<pubkey>:<d-tag>"]` instead. For profile-level zaps, use `"#p": ["<pubkey>"]`.
For addressable events, use `"#a": ["<kind>:<pubkey>:<d-tag>"]` instead. For profile-level zaps targeting a specific user, use `"#p": ["<pubkey>"]` — this matches both single-recipient events tagging that user and multi-recipient events where the user is one of several recipients.
**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. Derive the recipient's expected Taproot address from the `p` tag pubkey.
4. Sum the values of all outputs in the transaction that pay that address. 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 the recipient.
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.
*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.
**Sender/recipient identity:** Clients SHOULD reject events where the sender's pubkey (`event.pubkey`) equals the recipient pubkey from the `p` tag. Self-zaps are trivial to fabricate (the sender already controls the destination address) and contribute nothing meaningful to zap totals.
When a client needs to attribute a multi-recipient event's amount to one specific recipient (e.g. rendering a profile zap-history entry), it MAY sum only the tx outputs paying that one recipient's derived Taproot address. Per-recipient amounts are not stored in the event — they are recomputed from the transaction at display time.
**Deduplication:** Clients SHOULD deduplicate events that reference the same `txid` (an attacker could publish many events pointing at one real transaction). One kind 8333 event per (txid, target) pair is canonical — when multiple events reference the same `txid` for the same target, the earliest is preferred.
**Sender/recipient identity:** Clients SHOULD reject events where the sender's pubkey (`event.pubkey`) appears in any `p` tag. Self-zaps are trivial to fabricate (the sender already controls the destination address) and contribute nothing meaningful to zap totals. Outputs in the underlying transaction that pay the sender's own derived Taproot address are change outputs and MUST NOT be counted toward the verified amount regardless of the tag set.
**Deduplication:** Clients SHOULD deduplicate events that reference the same `txid` (an attacker could publish many events pointing at one real transaction). One kind 8333 event per `txid` is canonical — when multiple events reference the same `txid`, the earliest is preferred.
**Network scope:** This specification applies to Bitcoin **mainnet** only. Testnet, signet, and other networks are out of scope; addresses and txids on those networks MUST NOT be used in kind 8333 events.
@@ -155,42 +277,329 @@ The two zap kinds are complementary. Clients SHOULD sum verified amounts from bo
---
## Standard NIPs: Direct Messaging
## Kind 33863: Campaign
This application implements encrypted direct messaging using two standard Nostr protocols:
### Summary
### NIP-04 (Legacy Encrypted DMs)
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.
| Field | Value |
|-------|-------|
| Kind | 4 |
| Spec | [NIP-04](https://github.com/nostr-protocol/nips/blob/master/04.md) |
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.
Legacy encrypted direct messages. Content is encrypted with AES-256-CBC using a shared secret derived from the sender's private key and recipient's public key. The recipient is identified by a `p` tag.
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.
Used for backward compatibility with older Nostr clients that do not support NIP-17.
### Event Structure
### NIP-17 (Private Direct Messages)
```json
{
"kind": 33863,
"pubkey": "<creator-pubkey>",
"content": "<markdown story>",
"tags": [
["d", "save-the-last-bookstore"],
| Field | Value |
|-------|-------|
| Kinds | 1059 (Gift Wrap), 1060 (Seal) |
| Spec | [NIP-17](https://github.com/nostr-protocol/nips/blob/master/17.md) |
["title", "Save the Last Bookstore"],
["summary", "Help our 40-year-old neighborhood bookstore make rent through winter."],
["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"],
Modern private direct messages using the Gift Wrap protocol. Messages are triple-layered:
["w", "bc1p7w2k3xq9...xyz"],
1. **Rumor** (kind 14) — unsigned plaintext message
2. **Seal** (kind 13) — rumor encrypted to the recipient, signed by the sender
3. **Gift Wrap** (kind 1059) — seal encrypted to the recipient, signed by a random ephemeral key
["goal", "25000"],
["deadline", "1735689600"],
This provides metadata protection: relays and observers cannot determine the sender, recipient, or content. The application uses NIP-17 as the default send protocol, with optional NIP-04 compatibility for older clients.
["i", "iso3166-1:US"],
["k", "iso3166-1"]
]
}
```
### Protocol Configuration
A silent-payment campaign is identical except the `w` tag carries an `sp1…` code:
Users can configure their preferred send protocol via Settings > Messages:
```json
["w", "sp1qq...verylongsilentpaymentcode..."]
```
- **NIP-17 only** (default) — maximum privacy, only modern clients can read
- **NIP-04 + NIP-17** — sends via both protocols for compatibility with legacy clients
### 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 `33863:<pubkey>:<d>`. |
| `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 <value>` pair MUST match the `banner` URL; clients SHOULD ignore an `imeta` whose URL does not match. |
| `goal` | Optional | Fundraising goal in **integer US Dollars** (no unit suffix, no decimals). Clients MAY display an estimated sat-equivalent at view time using a live exchange rate. |
| `deadline`| Optional | Unix timestamp (seconds) at which the campaign closes for new donations. After the deadline, clients SHOULD show the campaign as ended but MAY still accept donations. |
| `i` | Recommended | NIP-73 country identifier. SHOULD be `iso3166-1:<code>` 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. |
### Wallet Modes
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.
| 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). |
Other prefixes (`tb1…`, `bcrt1…`, `tsp1…`, lightning invoices, etc.) MUST be rejected at parse time; the campaign does not render.
Clients SHOULD validate the bech32(m) checksum of the `w` value, not just its prefix.
### 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:<txid>"],
["amount", "<sats-paid-to-campaign-wallet>"],
["a", "33863:<campaign-author-pubkey>:<campaign-d-tag>"],
["K", "33863"],
["alt", "Donation to <campaign-title>: <total-amount> sats"]
]
```
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).
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": [33863], "limit": 50 }
```
**Fetch a specific campaign:**
```json
{ "kinds": [33863], "authors": ["<creator-pubkey>"], "#d": ["<slug>"], "limit": 1 }
```
**Aggregate donations for an on-chain campaign:**
```json
{ "kinds": [8333], "#a": ["33863:<creator-pubkey>:<slug>"], "limit": 500 }
```
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": "<root-event-author-pubkey>",
"content": "{\"pinnedEvents\":[\"<event-id-2>\",\"<event-id-1>\"]}",
"tags": [
["d", "agora-pinned-comments:<kind>:<root-event-author-pubkey>:<d-tag>"],
["a", "<kind>:<root-event-author-pubkey>:<d-tag>"],
["k", "<kind>"],
["alt", "Pinned event comments"]
]
}
```
Clients SHOULD query the pin list with:
```json
{ "kinds": [30078], "authors": ["<root-event-author-pubkey>"], "#d": ["agora-pinned-comments:<kind>:<root-event-author-pubkey>:<d-tag>"], "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
- **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.
### Agora Moderation Labels
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:<author-pubkey>:<d>` — campaign (kind 33863, see "Open Campaigns" above).
- `34550:<author-pubkey>:<d>` — 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
```
agora.moderation
```
Each label event carries the namespace twice, per NIP-32:
- A capital-`L` "namespace" tag (relay-indexed for queries).
- A lowercase `l` tag where the 2nd element is the label value and the 3rd is the namespace.
#### Label values
Three independent axes are defined; the newest moderator-signed label per axis per coordinate wins. **Campaigns** use all three axes (`approval`, `hide`, `featured`). **Organizations** 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 | 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
{
"kind": 1985,
"content": "",
"tags": [
["L", "agora.moderation"],
["l", "approved", "agora.moderation"],
["a", "33863:<author-pubkey>:<campaign-d-tag>"],
["alt", "Campaign moderation: approved"]
]
}
```
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:<author-pubkey>:<organization-d-tag>"],
["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 target coordinate (`33863:<pubkey>:<d>` for a campaign, `34550:<pubkey>:<d>` 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
Only label events authored by current members of the **Team Soapbox** follow pack are honored. The pack is a kind 39089 (NIP-51 follow pack) addressable event:
```
kind: 39089
pubkey: 932614571afcbad4d17a191ee281e39eebbb41b93fac8fd87829622aeb112f4d
d-tag: k4p5w0n22suf
```
The pack `p` tags are the authoritative moderator list. Clients MUST pin `authors:` on their label REQ to the pack `p` tags; events from non-pack authors MUST be ignored. This means:
- Self-approval is impossible unless the pack author has added you.
- A moderator removed from the pack immediately loses moderation authority — campaigns/organizations kept alive only by their labels return to "pending" until another moderator approves them.
- 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:
```json
{
"kinds": [39089],
"authors": ["932614571afcbad4d17a191ee281e39eebbb41b93fac8fd87829622aeb112f4d"],
"#d": ["k4p5w0n22suf"],
"limit": 1
}
```
Step 2 — fetch label events from pack members in the namespace:
```json
{
"kinds": [1985],
"authors": ["<pack p-tag 1>", "<pack p-tag 2>", "..."],
"#L": ["agora.moderation"],
"limit": 2000
}
```
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/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.
---
@@ -290,22 +699,19 @@ After resolution (assuming `$follows` = `["pk1", "pk2"]`):
---
## Kind 36639: Activist Action
## Kind 36639: Pledge
### Summary
Addressable event kind for publishing **activist actions** (called "challenges" internally for backwards compatibility). An action is a country-scoped task — take a photo, make art, gather information, or take direct action — with an optional sats bounty paid out via NIP-57 zaps to the best **submissions**.
Addressable event kind for publishing **pledges**. A pledge is donor intent to fund a concrete action, evidence request, or outcome — take a photo, make art, gather information, clean a beach, or take direct action — with an optional country scope, optional community scope, and a sats-denominated pledge amount paid out via zaps or donation receipts to the best **submissions**.
Submissions are **NIP-22 comments** (kind 1111) authored under the action's coordinate, ranked by zap totals. There is no separate submission kind; an earlier draft (kind 36640) was deprecated in favor of NIP-22 reuse.
Submissions are **NIP-22 comments** (kind 1111) authored under the pledge's coordinate, ranked by zap totals. There is no separate submission kind; an earlier draft (kind 36640) was deprecated in favor of NIP-22 reuse.
### Trust model
Anyone can publish a kind 36639 event, but clients SHOULD only display actions whose author is either:
Pledges are user-generated. Anyone can publish a kind 36639 event, and Agora displays valid pledges without platform-admin or country-organizer author filtering.
1. A platform-level admin (see `src/lib/admins.ts`), or
2. An organizer for the action's country (see kind 30078 `agora-organizers`).
This authorization model is identical to the per-country pin model — see Kind 30078 in this document for the storage shape.
Community-scoped pledges inherit the community's moderation context. Clients rendering a specific community SHOULD query by the community `A` tag and apply that community's moderation and membership filters.
### Event Structure
@@ -316,14 +722,17 @@ This authorization model is identical to the per-country pin model — see Kind
"tags": [
["d", "plant-a-tree-1729000000000"],
["title", "Plant a tree in your neighborhood"],
["challenge-type", "photo"],
["bounty", "10000"],
["i", "iso3166:US"],
["A", "34550:<community-pubkey>:<community-d-tag>"],
["K", "34550"],
["P", "<community-pubkey>"],
["t", "agora-action"],
["t", "tree-planting"],
["t", "local-action"],
["image", "https://example.com/cover.jpg"],
["start", "1729000000"],
["deadline", "1729604800"],
["alt", "Agora activist action: Plant a tree in your neighborhood"]
["alt", "Agora pledge: Plant a tree in your neighborhood"]
]
}
```
@@ -334,30 +743,36 @@ This authorization model is identical to the per-country pin model — see Kind
|------------------|----------|----------------------------------------------------------------------------------------------------------|
| `d` | Yes | Unique identifier (typically slug + timestamp). Forms the addressable coordinate `36639:<pubkey>:<d>`. |
| `title` | Yes | Short title shown on cards. |
| `challenge-type` | Yes | One of `photo`, `art`, `info`, `action`. Drives the display icon and submission expectations. |
| `bounty` | Yes | Bounty in **sats**, as an unsigned integer string. Paid out via zaps to the chosen submission(s). |
| `i` | Yes | NIP-73 country identifier: `iso3166:XX` (preferred). Legacy `geo:XX` (length 6, country code only) is accepted as a read alias. Optionally combined with a `location` tag fallback. |
| `t` | Yes | Discovery tag. Canonical write value is `agora-action`. Read aliases: `pathos-challenge`, `agora-challenge`. |
| `bounty` | Yes | Pledge amount in **sats**, as an unsigned integer string. Paid out via zaps or donation receipts to chosen submission(s). |
| `i` | No | NIP-73 country identifier: `iso3166:XX` (preferred). Legacy `geo:XX` (length 6, country code only) is accepted as a read alias. Optionally combined with a `location` tag fallback. |
| `A` | No | Community root coordinate for community-scoped pledges, e.g. `34550:<pubkey>:<d-tag>`. |
| `K` | No | Root kind hint for community-scoped pledges. Use `34550` when `A` points to a NIP-72 community. |
| `P` | No | Root author hint for community-scoped pledges. Use the community definition author pubkey. |
| `t` | Yes | Discovery and category tags. Canonical write value includes `agora-action`; additional `t` tags are optional hashtags/categories. Read aliases: `pathos-challenge`, `agora-challenge`. |
| `image` | No | Cover image URL. |
| `start` | No | Unix timestamp when the action becomes active. Defaults to `created_at`. |
| `deadline` | No | Unix timestamp when the action expires. Defaults to `start + 48h`. |
| `alt` | Yes | NIP-31 human-readable fallback. Convention: `"Agora activist action: <title>"`. |
| `start` | No | Legacy. Unix timestamp when the pledge becomes active. Defaults to `created_at`. New pledges omit it; the `created_at` is the start. |
| `deadline` | No | Optional Unix timestamp when the pledge expires. Omit for open-ended pledges. |
| `alt` | Yes | NIP-31 human-readable fallback. Convention: `"Agora pledge: <title>"`. |
### Content
Long-form description of the action. Plain text or light markdown. Clients render this as the action's body on the detail page.
Long-form description of the pledge. Plain text or light markdown. Clients render this as the pledge's body on the detail page.
### Categories
Clients SHOULD use optional `t` tags for filtering and discovery instead of the deprecated `challenge-type` tag. Suggested user-entered tags include values like `beach-cleanup`, `protest-documentation`, `internet-blackout`, `legal-defense`, or `mutual-aid`.
### Submissions
Submissions are kind 1111 NIP-22 comments addressed to the action's coordinate (`["A", "36639:<pubkey>:<d>"]` and `["P", "<pubkey>"]`). Clients SHOULD:
Submissions are kind 1111 NIP-22 comments addressed to the pledge's coordinate (`["A", "36639:<pubkey>:<d>"]` and `["P", "<pubkey>"]`). Clients SHOULD:
- Sort top-level submissions by **total zap amount** (sum of NIP-57 zap receipts on each submission), descending.
- Show the bounty as the prize pool that organizers can distribute to top submissions via zaps.
- Hide submissions with `created_at` after the action's `deadline` for "past" leaderboards (or surface them separately as "late submissions").
- Sort top-level submissions by **total funded amount** (sum of kind 9735 zap receipts and kind 8333 donation receipts on each submission), descending.
- Show the pledge amount, total funded, and remaining amount as a trust-based progress indicator. There is no escrow guarantee.
- Hide submissions with `created_at` after the pledge's `deadline` for "past" leaderboards (or surface them separately as "late submissions"). Open-ended pledges have no deadline cutoff.
### Discovery
Clients querying actions globally:
Clients querying pledges globally:
```json
{ "kinds": [36639], "#t": ["agora-action", "pathos-challenge", "agora-challenge"], "limit": 50 }
@@ -374,7 +789,17 @@ Per country:
}
```
After fetching, clients MUST filter the results down to events whose author is either an admin or an organizer for the event's country.
Per community:
```json
{
"kinds": [36639],
"#A": ["34550:<community-pubkey>:<community-d-tag>"],
"limit": 50
}
```
Country and community scopes are independent. A future action MAY include both `i` and `A`/`K`/`P` tags when both scopes are useful.
---
@@ -492,66 +917,6 @@ After fetching, take the event with the highest `created_at` and parse it. Cache
---
## Kinds 20000 / 20001: Ephemeral Geo Chat
### Summary
Ephemeral events used to power realtime location-anchored chat on the world map. Both kinds live in NIP-01's ephemeral range (`20000 ≤ kind < 30000`), so relays MUST NOT persist them — they are short-lived signals only.
- **Kind 20000** — public chat message. The `content` field carries the message text.
- **Kind 20001** — presence "heartbeat". Same tag schema, but `content` MAY be empty (the event simply broadcasts that someone is listening at the geohash).
This kind range is shared with the wider Bitchat / geo-chat ecosystem; Agora interoperates with Pathos and other clients producing the same shape.
### Tags
| Tag | Required | Purpose |
|-----|----------|-------------------------------------------------------------------------|
| `g` | Yes | Geohash anchoring the message. Any precision is allowed; the dialog filters by exact-match `g` value, while the map clusters by full geohash. |
| `n` | No | Display nickname (≤ 16 chars after client-side truncation). Anonymous senders pick a random "ghost" handle; logged-in senders may use their account display name. |
Events without a `g` tag MUST be ignored — they cannot be plotted.
### Identity
There are two valid signing paths:
1. **Real identity** — a logged-in user signs with their existing Nostr key (typically via NIP-07 / NIP-46). Other clients can correlate the chat message with the author's public profile.
2. **Ephemeral "ghost" identity** — the client generates a fresh in-memory keypair (never persisted) and signs locally. Only the chosen `n` nickname is persisted (in `localStorage`) so the user keeps a stable handle even though the pubkey rotates per session.
Clients SHOULD let logged-in users toggle between modes per-session and SHOULD default to the ghost mode when no account is available.
### Relay Routing
Because ephemeral events are not stored, latency dominates the experience. Clients SHOULD:
1. Always include a baseline of widely-reachable relays (`wss://nos.lol`, `wss://relay.damus.io`, `wss://relay.primal.net`).
2. Augment with geo-located relays drawn from the [permissionlesstech/georelays](https://github.com/permissionlesstech/georelays) CSV catalogue (`relayUrl,latitude,longitude` per line).
3. For a specific geohash conversation, prefer the relays nearest the decoded coordinates (Haversine distance, top-N).
4. For the global map heatmap, take a rotating window (e.g. 8 relays, rotated every 5 minutes) so coverage spreads without saturating any single relay.
### Time Window
Clients SHOULD only surface events from the last hour (`since = now - 3600`). Older ephemeral events are uninteresting for "what's happening right now" and most relays will have dropped them anyway.
### Example
```json
{
"kind": 20000,
"created_at": 1734567890,
"pubkey": "...",
"tags": [
["g", "u4pruydqqvj"],
["n", "stealthranger4242"]
],
"content": "anyone in berlin tonight?",
"sig": "..."
}
```
---
## Flat Communities
Flat communities on Nostr, composed from existing event kinds. Communities have one membership badge, explicit moderators, and no recursive badge-chain authority.
@@ -1119,4 +1484,3 @@ Albums are represented as kind 34139 playlist events with a `["t", "album"]` tag
- Albums display release date and label information when available
- Track ordering follows the order of `a` tags in the event
- The same detail view, playback, and commenting features apply to both albums and playlists
+2 -2
View File
@@ -7,10 +7,10 @@ if (keystorePropertiesFile.exists()) {
}
android {
namespace = "pub.agora.app"
namespace = "spot.agora.app"
compileSdk = rootProject.ext.compileSdkVersion
defaultConfig {
applicationId "pub.agora.app"
applicationId "spot.agora.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
+1
View File
@@ -10,6 +10,7 @@ android {
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {
implementation project(':capacitor-app')
implementation project(':capacitor-barcode-scanner')
implementation project(':capacitor-filesystem')
implementation project(':capacitor-haptics')
implementation project(':capacitor-keyboard')
+1 -1
View File
@@ -7,7 +7,7 @@
# Keep Capacitor classes (WebView JS bridge)
-keep class com.getcapacitor.** { *; }
-keep class pub.ditto.app.** { *; }
-keep class spot.agora.app.** { *; }
# Keep WebView JS interfaces
-keepclassmembers class * {
@@ -1,4 +1,4 @@
package pub.ditto.app;
package spot.agora.app;
import android.app.ForegroundServiceStartNotAllowedException;
import android.content.Context;
@@ -1,4 +1,4 @@
package pub.ditto.app;
package spot.agora.app;
import android.app.ForegroundServiceStartNotAllowedException;
import android.content.Context;
@@ -63,7 +63,7 @@ public class MainActivity extends BridgeActivity {
private void handleNotificationIntent(Intent intent) {
if (intent == null) return;
Uri data = intent.getData();
if (data != null && "ditto.pub".equals(data.getHost())) {
if (data != null && "agora.spot".equals(data.getHost())) {
String path = data.getPath();
if (path != null && !path.isEmpty()) {
// Wait for WebView to be ready, then navigate
@@ -1,4 +1,4 @@
package pub.ditto.app;
package spot.agora.app;
import android.app.NotificationChannel;
import android.app.NotificationManager;
@@ -337,7 +337,7 @@ public class NostrPoller {
if (manager == null) return;
Intent intent = new Intent(context, MainActivity.class);
intent.setData(Uri.parse("https://ditto.pub/notifications"));
intent.setData(Uri.parse("https://agora.spot/notifications"));
intent.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP);
PendingIntent pendingIntent = PendingIntent.getActivity(
context, id, intent,
@@ -1,4 +1,4 @@
package pub.ditto.app;
package spot.agora.app;
import android.app.AlarmManager;
import android.app.ForegroundServiceStartNotAllowedException;
@@ -83,7 +83,7 @@ public class NotificationRelayService extends Service {
// + REQ + up to 5 events + EOSE + metadata fetch + disconnect.
private static final long FETCH_WAKELOCK_TIMEOUT_MS = 30_000;
private static final String ACTION_FETCH = "pub.ditto.app.ACTION_FETCH";
private static final String ACTION_FETCH = "spot.agora.app.ACTION_FETCH";
// Backoff bounds for relay connect failures (separate from alarm interval).
private static final long INITIAL_BACKOFF_MS = 1_000;
+2 -2
View File
@@ -2,6 +2,6 @@
<resources>
<string name="app_name">Agora</string>
<string name="title_activity_main">Agora</string>
<string name="package_name">pub.agora.app</string>
<string name="custom_url_scheme">pub.agora.app</string>
<string name="package_name">spot.agora.app</string>
<string name="custom_url_scheme">spot.agora.app</string>
</resources>
+3
View File
@@ -5,6 +5,9 @@ project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/
include ':capacitor-app'
project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android')
include ':capacitor-barcode-scanner'
project(':capacitor-barcode-scanner').projectDir = new File('../node_modules/@capacitor/barcode-scanner/android')
include ':capacitor-filesystem'
project(':capacitor-filesystem').projectDir = new File('../node_modules/@capacitor/filesystem/android')
+1 -1
View File
@@ -1,7 +1,7 @@
import type { CapacitorConfig } from '@capacitor/cli';
const config: CapacitorConfig = {
appId: 'pub.agora.app',
appId: 'spot.agora.app',
appName: 'Agora',
webDir: 'dist',
server: {
+2 -2
View File
@@ -9,7 +9,7 @@
<!-- Open Graph -->
<meta property="og:title" content="Agora" />
<meta property="og:description" content="Power to the people." />
<meta property="og:image" content="https://agora.spot/og-image.png" />
<meta property="og:image" content="https://agora.spot/og-image.jpg" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:image:type" content="image/jpeg" />
@@ -21,7 +21,7 @@
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Agora" />
<meta name="twitter:description" content="Power to the people." />
<meta name="twitter:image" content="https://agora.spot/og-image.png" />
<meta name="twitter:image" content="https://agora.spot/og-image.jpg" />
<meta http-equiv="content-security-policy" content="default-src 'none'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; frame-src 'self' https:; font-src 'self' https:; base-uri 'self'; manifest-src 'self'; connect-src 'self' blob: https: wss:; img-src 'self' data: blob: https:; media-src 'self' blob: https:">
<link rel="icon" type="image/x-icon" href="/favicon.ico">
+2 -2
View File
@@ -325,7 +325,7 @@
);
MARKETING_VERSION = 2.14.4;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = pub.agora.app;
PRODUCT_BUNDLE_IDENTIFIER = spot.agora.app;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_VERSION = 5.0;
@@ -348,7 +348,7 @@
"@executable_path/Frameworks",
);
MARKETING_VERSION = 2.8.0;
PRODUCT_BUNDLE_IDENTIFIER = pub.agora.app;
PRODUCT_BUNDLE_IDENTIFIER = spot.agora.app;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
SWIFT_VERSION = 5.0;
+1 -1
View File
@@ -33,7 +33,7 @@ public class DittoNotificationPlugin: CAPPlugin, CAPBridgedPlugin {
// MARK: - Constants
static let bgTaskIdentifier = "pub.ditto.app.notification-refresh"
static let bgTaskIdentifier = "spot.agora.app.notification-refresh"
private static let prefsKey = "ditto_notification_config"
// MARK: - Plugin Methods
+1 -1
View File
@@ -61,7 +61,7 @@
</array>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>pub.agora.app.notification-refresh</string>
<string>spot.agora.app.notification-refresh</string>
</array>
</dict>
</plist>
+2
View File
@@ -13,6 +13,7 @@ let package = Package(
dependencies: [
.package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", exact: "8.2.0"),
.package(name: "CapacitorApp", path: "../../../node_modules/@capacitor/app"),
.package(name: "CapacitorBarcodeScanner", path: "../../../node_modules/@capacitor/barcode-scanner"),
.package(name: "CapacitorFilesystem", path: "../../../node_modules/@capacitor/filesystem"),
.package(name: "CapacitorHaptics", path: "../../../node_modules/@capacitor/haptics"),
.package(name: "CapacitorKeyboard", path: "../../../node_modules/@capacitor/keyboard"),
@@ -28,6 +29,7 @@ let package = Package(
.product(name: "Capacitor", package: "capacitor-swift-pm"),
.product(name: "Cordova", package: "capacitor-swift-pm"),
.product(name: "CapacitorApp", package: "CapacitorApp"),
.product(name: "CapacitorBarcodeScanner", package: "CapacitorBarcodeScanner"),
.product(name: "CapacitorFilesystem", package: "CapacitorFilesystem"),
.product(name: "CapacitorHaptics", package: "CapacitorHaptics"),
.product(name: "CapacitorKeyboard", package: "CapacitorKeyboard"),
+1 -1
View File
@@ -1,2 +1,2 @@
app_identifier("pub.ditto.app")
app_identifier("spot.agora.app")
team_id("GZLTTH5DLM")
+5 -5
View File
@@ -3,7 +3,7 @@ default_platform(:ios)
platform :ios do
# ─── Lanes ────────────────────────────────────────────────────────────
desc "Build and sign the App Store IPA. Output at ../artifacts/Ditto.ipa."
desc "Build and sign the App Store IPA. Output at ../artifacts/Agora.ipa."
lane :build_ipa do
setup_lane_signing!
build_release_ipa!
@@ -19,7 +19,7 @@ platform :ios do
submit_release_for_review!(ipa_path)
end
desc "Build, sign, and submit Ditto to the App Store for review (single-step convenience)."
desc "Build, sign, and submit Agora to the App Store for review (single-step convenience)."
lane :release do
setup_lane_signing!
build_release_ipa!
@@ -83,7 +83,7 @@ platform :ios do
configuration: "Release",
export_method: "app-store",
output_directory: "../artifacts",
output_name: "Ditto.ipa",
output_name: "Agora.ipa",
clean: true,
# Override the Xcode project's Automatic signing for this build only.
# Match has already installed the AppStore cert + profile into the
@@ -93,7 +93,7 @@ platform :ios do
xcargs: [
"CODE_SIGN_STYLE=Manual",
"CODE_SIGN_IDENTITY='Apple Distribution'",
"PROVISIONING_PROFILE_SPECIFIER='match AppStore pub.ditto.app'",
"PROVISIONING_PROFILE_SPECIFIER='match AppStore spot.agora.app'",
"DEVELOPMENT_TEAM=GZLTTH5DLM",
].join(" "),
export_options: {
@@ -101,7 +101,7 @@ platform :ios do
signingStyle: "manual",
teamID: "GZLTTH5DLM",
provisioningProfiles: {
"pub.ditto.app" => "match AppStore pub.ditto.app",
"spot.agora.app" => "match AppStore spot.agora.app",
},
},
)
+1 -1
View File
@@ -1,5 +1,5 @@
git_url("https://gitlab.com/soapbox-pub/certificates.git")
storage_mode("git")
type("appstore")
app_identifier(["pub.ditto.app"])
app_identifier(["spot.agora.app"])
team_id("GZLTTH5DLM")
+194 -274
View File
@@ -8,7 +8,7 @@
"name": "agora",
"version": "2.8.0",
"dependencies": {
"@breeztech/breez-sdk-spark": "^0.13.2-dev1",
"@breeztech/breez-sdk-spark": "^0.10.0",
"@capacitor/app": "^8.0.0",
"@capacitor/barcode-scanner": "^3.0.2",
"@capacitor/core": "^8.1.0",
@@ -34,6 +34,7 @@
"@fontsource-variable/nunito": "^5.2.7",
"@fontsource-variable/outfit": "^5.2.8",
"@fontsource-variable/playfair-display": "^5.2.8",
"@fontsource/bebas-neue": "^5.2.7",
"@fontsource/bungee-shade": "^5.2.7",
"@fontsource/caveat": "^5.2.8",
"@fontsource/cherry-bomb-one": "^5.2.7",
@@ -95,19 +96,22 @@
"@radix-ui/react-toggle": "^1.1.0",
"@radix-ui/react-toggle-group": "^1.1.0",
"@radix-ui/react-tooltip": "^1.2.8",
"@samthomson/nostr-messaging": "^0.17.1",
"@scure/base": "^1.1.1",
"@scure/bip32": "^2.2.0",
"@scure/bip39": "^1.6.0",
"@scure/btc-signer": "^2.2.0",
"@sentry/react": "^10.42.0",
"@tanstack/react-query": "^5.56.2",
"@types/d3-geo": "^3.1.0",
"@unhead/addons": "^2.1.13",
"@unhead/react": "^2.1.13",
"blurhash": "^2.0.5",
"buffer": "^6.0.3",
"capacitor-secure-storage-plugin": "^0.13.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
"d3-celestial": "^0.7.35",
"d3-geo": "^3.1.1",
"d3-scale": "^4.0.2",
"date-fns": "^3.6.0",
"dompurify": "^3.3.3",
@@ -123,7 +127,6 @@
"iso-3166": "^4.4.0",
"leaflet": "^1.9.4",
"lucide-react": "^1.8.0",
"ngeohash": "^0.6.3",
"nostr-tools": "^2.13.0",
"qrcode": "^1.5.4",
"react": "^19.2.4",
@@ -144,6 +147,7 @@
"smol-toml": "^1.6.0",
"tailwind-merge": "^2.5.2",
"tailwindcss-animate": "^1.0.7",
"topojson-client": "^3.1.0",
"uri-templates": "^0.2.0",
"vaul": "^1.1.2",
"zod": "^4.3.6"
@@ -166,6 +170,8 @@
"@types/qrcode": "^1.5.5",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/topojson-client": "^3.1.5",
"@types/topojson-specification": "^1.0.5",
"@vitejs/plugin-react": "^6.0.1",
"@webbtc/webln-types": "^3.0.0",
"@webxdc/types": "^2.1.2",
@@ -355,16 +361,15 @@
}
},
"node_modules/@breeztech/breez-sdk-spark": {
"version": "0.13.2-dev1",
"resolved": "https://registry.npmjs.org/@breeztech/breez-sdk-spark/-/breez-sdk-spark-0.13.2-dev1.tgz",
"integrity": "sha512-W7udRIz+ehjqzCFGCmzJ6fYhSPZ6AGsXyO/X3upOmbJdHXw2DtIVaRYz5sxHLlmIHre8MYAbNUFS3nRqMMVfVQ==",
"version": "0.10.0",
"resolved": "https://registry.npmjs.org/@breeztech/breez-sdk-spark/-/breez-sdk-spark-0.10.0.tgz",
"integrity": "sha512-eBsh0oX2B8uGuWfCMmtH3SNXmSkED5du/CiWQKh1Ei1r0LsO6jlVnUmh94j7R5W4siIi7M6CC7ywll3FQ47rYQ==",
"license": "MIT",
"engines": {
"node": ">=22"
},
"optionalDependencies": {
"better-sqlite3": "^12.2.0",
"pg": "^8.18.0"
"better-sqlite3": "^12.2.0"
}
},
"node_modules/@capacitor/android": {
@@ -1457,6 +1462,15 @@
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/bebas-neue": {
"version": "5.2.7",
"resolved": "https://registry.npmjs.org/@fontsource/bebas-neue/-/bebas-neue-5.2.7.tgz",
"integrity": "sha512-DsmBrmq55d9BCU0mt4DT4RZDdH8vhWRKEUOfbuNB1EEjMuwbtFvM8N+3gIlkYSFbsb10P8Q19BV5OdpMu2h0fA==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/bungee-shade": {
"version": "5.2.7",
"resolved": "https://registry.npmjs.org/@fontsource/bungee-shade/-/bungee-shade-5.2.7.tgz",
@@ -6171,39 +6185,6 @@
"win32"
]
},
"node_modules/@samthomson/nostr-messaging": {
"version": "0.17.1",
"resolved": "https://registry.npmjs.org/@samthomson/nostr-messaging/-/nostr-messaging-0.17.1.tgz",
"integrity": "sha512-TfgC3L/7sKnkLSqod1UyF9Bt/F36kH02nRffWjm5YEMfLvHLEYlT5ECgzyrnt9QVpYXG25rVAhEpXF9wxmPX0w==",
"license": "MIT",
"dependencies": {
"blurhash": "^2.0.5",
"buffer": "^6.0.3",
"fuse.js": "^7.1.0",
"idb": "^8.0.3",
"nostr-tools": "^2.13.0",
"react-blurhash": "^0.3.0"
},
"peerDependencies": {
"@nostrify/nostrify": ">=0.47.0",
"@nostrify/react": ">=0.2.0",
"@radix-ui/react-avatar": "^1.1.0",
"@radix-ui/react-dialog": "^1.1.2",
"@radix-ui/react-popover": "^1.1.0",
"@radix-ui/react-scroll-area": "^1.1.0",
"@radix-ui/react-select": "^2.1.1",
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-tabs": "^1.1.0",
"@radix-ui/react-tooltip": "^1.1.4",
"@tanstack/react-query": "^5.56.2",
"clsx": "^2.0.0",
"lucide-react": "^0.462.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.0.0",
"tailwind-merge": "^2.0.0"
}
},
"node_modules/@scure/base": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.1.tgz",
@@ -6217,55 +6198,40 @@
"license": "MIT"
},
"node_modules/@scure/bip32": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.3.1.tgz",
"integrity": "sha512-osvveYtyzdEVbt3OfwwXFr4P2iVBL5u1Q3q4ONBfDY/UpOuXmOlbgwc1xECEboY8wIays8Yt6onaWMUdUbfl0A==",
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-2.2.0.tgz",
"integrity": "sha512-zFr7t2F+a9+5tB7QbarF2HQNYrgjCNaoLAupZdKkrFMYMozJf5zqH2WJCQibMzm1qQ0QogrxVGO3qXfQDYMaQg==",
"license": "MIT",
"dependencies": {
"@noble/curves": "~1.1.0",
"@noble/hashes": "~1.3.1",
"@scure/base": "~1.1.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/bip32/node_modules/@noble/curves": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.1.0.tgz",
"integrity": "sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "1.3.1"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/bip32/node_modules/@noble/curves/node_modules/@noble/hashes": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.1.tgz",
"integrity": "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA==",
"license": "MIT",
"engines": {
"node": ">= 16"
"@noble/curves": "2.2.0",
"@noble/hashes": "2.2.0",
"@scure/base": "2.2.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/bip32/node_modules/@noble/hashes": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.3.tgz",
"integrity": "sha512-V7/fPHgl+jsVPXqqeOzT8egNj2iBIVt+ECeMMG8TdcnTikP3oaBtUVqpT/gYCR68aEBJSF+XbYUxStjbFMqIIA==",
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
"integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==",
"license": "MIT",
"engines": {
"node": ">= 16"
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/bip32/node_modules/@scure/base": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.2.0.tgz",
"integrity": "sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg==",
"license": "MIT",
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/bip39": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.6.0.tgz",
@@ -6288,6 +6254,42 @@
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/btc-signer": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@scure/btc-signer/-/btc-signer-2.2.0.tgz",
"integrity": "sha512-ZXZ08sZqSZKEcOuEQnxTF66ouHtl6+UA6U/QfQM06K9WiOlEkXF4LviZCaSgkdiFh9cyMt9+xdup7JtEv3p0fw==",
"license": "MIT",
"dependencies": {
"@noble/curves": "~2.2.0",
"@noble/hashes": "~2.2.0",
"@scure/base": "~2.2.0",
"micro-packed": "~0.9.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/btc-signer/node_modules/@noble/hashes": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
"integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/btc-signer/node_modules/@scure/base": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.2.0.tgz",
"integrity": "sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg==",
"license": "MIT",
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@sentry-internal/browser-utils": {
"version": "10.42.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-10.42.0.tgz",
@@ -6626,6 +6628,15 @@
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
"license": "MIT"
},
"node_modules/@types/d3-geo": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz",
"integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==",
"license": "MIT",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
@@ -6719,7 +6730,6 @@
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/hast": {
@@ -6837,6 +6847,27 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/topojson-client": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/@types/topojson-client/-/topojson-client-3.1.5.tgz",
"integrity": "sha512-C79rySTyPxnQNNguTZNI1Ct4D7IXgvyAs3p9HPecnl6mNrJ5+UhvGNYcZfpROYV2lMHI48kJPxwR+F9C6c7nmw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/geojson": "*",
"@types/topojson-specification": "*"
}
},
"node_modules/@types/topojson-specification": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/@types/topojson-specification/-/topojson-specification-1.0.5.tgz",
"integrity": "sha512-C7KvcQh+C2nr6Y2Ub4YfgvWvWCgP2nOQMtfhlnwsRL4pYmmwzBS7HclGiS87eQfDOU/DLQpX6GEscviaz4yLIQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
@@ -7720,6 +7751,7 @@
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"devOptional": true,
"funding": [
{
"type": "github",
@@ -7897,30 +7929,6 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/buffer": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.2.1"
}
},
"node_modules/buffer-crc32": {
"version": "0.2.13",
"resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
@@ -8401,6 +8409,18 @@
"node": ">=12"
}
},
"node_modules/d3-geo": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz",
"integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2.5.0 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
@@ -9334,19 +9354,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/fuse.js": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.3.0.tgz",
"integrity": "sha512-plz8RVjfcDedTGfVngWH1jmJvBvAwi1v2jecfDerbEnMcmOYUEEwKFTHbNoCiYyzaK2Ws8lABkTCcRSqCY1q4w==",
"license": "Apache-2.0",
"engines": {
"node": ">=10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/krisk"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
@@ -9657,7 +9664,8 @@
"url": "https://feross.org/support"
}
],
"license": "BSD-3-Clause"
"license": "BSD-3-Clause",
"optional": true
},
"node_modules/ignore": {
"version": "5.3.2",
@@ -10900,6 +10908,30 @@
"node": ">= 8"
}
},
"node_modules/micro-packed": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/micro-packed/-/micro-packed-0.9.0.tgz",
"integrity": "sha512-gFdaWTxEXOwtSOcpxulO4AuXVtp3HWIRmB8eq8+3m1Zku0ubgva0UGpi03YhcvsTJasHngG9gTIUK5kHNKdesg==",
"license": "MIT",
"dependencies": {
"@scure/base": "~2.2.0"
},
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/micro-packed/node_modules/@scure/base": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.2.0.tgz",
"integrity": "sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg==",
"license": "MIT",
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/micromark": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz",
@@ -11660,15 +11692,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/ngeohash": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/ngeohash/-/ngeohash-0.6.3.tgz",
"integrity": "sha512-kltF0cOxgx1AbmVzKxYZaoB0aj7mOxZeHaerEtQV0YaqnkXNq26WWqMmJ6lTqShYxVRWZ/mwvvTrNeOwdslWiw==",
"license": "MIT",
"engines": {
"node": ">=v0.2.0"
}
},
"node_modules/node-abi": {
"version": "3.89.0",
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.89.0.tgz",
@@ -11774,6 +11797,32 @@
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/nostr-tools/node_modules/@scure/bip32": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.3.1.tgz",
"integrity": "sha512-osvveYtyzdEVbt3OfwwXFr4P2iVBL5u1Q3q4ONBfDY/UpOuXmOlbgwc1xECEboY8wIays8Yt6onaWMUdUbfl0A==",
"license": "MIT",
"dependencies": {
"@noble/curves": "~1.1.0",
"@noble/hashes": "~1.3.1",
"@scure/base": "~1.1.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/nostr-tools/node_modules/@scure/bip32/node_modules/@noble/curves": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.1.0.tgz",
"integrity": "sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "1.3.1"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/nostr-tools/node_modules/@scure/bip39": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.2.1.tgz",
@@ -12019,102 +12068,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/pg": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz",
"integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==",
"license": "MIT",
"optional": true,
"dependencies": {
"pg-connection-string": "^2.12.0",
"pg-pool": "^3.13.0",
"pg-protocol": "^1.13.0",
"pg-types": "2.2.0",
"pgpass": "1.0.5"
},
"engines": {
"node": ">= 16.0.0"
},
"optionalDependencies": {
"pg-cloudflare": "^1.3.0"
},
"peerDependencies": {
"pg-native": ">=3.0.1"
},
"peerDependenciesMeta": {
"pg-native": {
"optional": true
}
}
},
"node_modules/pg-cloudflare": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz",
"integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==",
"license": "MIT",
"optional": true
},
"node_modules/pg-connection-string": {
"version": "2.12.0",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz",
"integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==",
"license": "MIT",
"optional": true
},
"node_modules/pg-int8": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
"license": "ISC",
"optional": true,
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/pg-pool": {
"version": "3.13.0",
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz",
"integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==",
"license": "MIT",
"optional": true,
"peerDependencies": {
"pg": ">=8.0"
}
},
"node_modules/pg-protocol": {
"version": "1.13.0",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz",
"integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==",
"license": "MIT",
"optional": true
},
"node_modules/pg-types": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
"license": "MIT",
"optional": true,
"dependencies": {
"pg-int8": "1.0.1",
"postgres-array": "~2.0.0",
"postgres-bytea": "~1.0.0",
"postgres-date": "~1.0.4",
"postgres-interval": "^1.1.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/pgpass": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
"license": "MIT",
"optional": true,
"dependencies": {
"split2": "^4.1.0"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -12352,49 +12305,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=4"
}
},
"node_modules/postgres-bytea": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-date": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-interval": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
"license": "MIT",
"optional": true,
"dependencies": {
"xtend": "^4.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/powershell-utils": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz",
@@ -14100,7 +14010,7 @@
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"devOptional": true,
"dev": true,
"license": "ISC",
"engines": {
"node": ">= 10.x"
@@ -14565,6 +14475,26 @@
"node": ">=8.0"
}
},
"node_modules/topojson-client": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/topojson-client/-/topojson-client-3.1.0.tgz",
"integrity": "sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw==",
"license": "ISC",
"dependencies": {
"commander": "2"
},
"bin": {
"topo2geo": "bin/topo2geo",
"topomerge": "bin/topomerge",
"topoquantize": "bin/topoquantize"
}
},
"node_modules/topojson-client/node_modules/commander": {
"version": "2.20.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
"license": "MIT"
},
"node_modules/tough-cookie": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz",
@@ -16797,16 +16727,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=0.4"
}
},
"node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
+10 -4
View File
@@ -15,7 +15,7 @@
"node": ">=22"
},
"dependencies": {
"@breeztech/breez-sdk-spark": "^0.13.2-dev1",
"@breeztech/breez-sdk-spark": "^0.10.0",
"@capacitor/app": "^8.0.0",
"@capacitor/barcode-scanner": "^3.0.2",
"@capacitor/core": "^8.1.0",
@@ -41,6 +41,7 @@
"@fontsource-variable/nunito": "^5.2.7",
"@fontsource-variable/outfit": "^5.2.8",
"@fontsource-variable/playfair-display": "^5.2.8",
"@fontsource/bebas-neue": "^5.2.7",
"@fontsource/bungee-shade": "^5.2.7",
"@fontsource/caveat": "^5.2.8",
"@fontsource/cherry-bomb-one": "^5.2.7",
@@ -102,19 +103,22 @@
"@radix-ui/react-toggle": "^1.1.0",
"@radix-ui/react-toggle-group": "^1.1.0",
"@radix-ui/react-tooltip": "^1.2.8",
"@samthomson/nostr-messaging": "^0.17.1",
"@scure/base": "^1.1.1",
"@scure/bip32": "^2.2.0",
"@scure/bip39": "^1.6.0",
"@scure/btc-signer": "^2.2.0",
"@sentry/react": "^10.42.0",
"@tanstack/react-query": "^5.56.2",
"@types/d3-geo": "^3.1.0",
"@unhead/addons": "^2.1.13",
"@unhead/react": "^2.1.13",
"blurhash": "^2.0.5",
"buffer": "^6.0.3",
"capacitor-secure-storage-plugin": "^0.13.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
"d3-celestial": "^0.7.35",
"d3-geo": "^3.1.1",
"d3-scale": "^4.0.2",
"date-fns": "^3.6.0",
"dompurify": "^3.3.3",
@@ -130,7 +134,6 @@
"iso-3166": "^4.4.0",
"leaflet": "^1.9.4",
"lucide-react": "^1.8.0",
"ngeohash": "^0.6.3",
"nostr-tools": "^2.13.0",
"qrcode": "^1.5.4",
"react": "^19.2.4",
@@ -151,6 +154,7 @@
"smol-toml": "^1.6.0",
"tailwind-merge": "^2.5.2",
"tailwindcss-animate": "^1.0.7",
"topojson-client": "^3.1.0",
"uri-templates": "^0.2.0",
"vaul": "^1.1.2",
"zod": "^4.3.6"
@@ -173,6 +177,8 @@
"@types/qrcode": "^1.5.5",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/topojson-client": "^3.1.5",
"@types/topojson-specification": "^1.0.5",
"@vitejs/plugin-react": "^6.0.1",
"@webbtc/webln-types": "^3.0.0",
"@webxdc/types": "^2.1.2",
@@ -1,7 +1,7 @@
{
"webcredentials": {
"apps": [
"GZLTTH5DLM.pub.agora.app"
"GZLTTH5DLM.spot.agora.app"
]
}
}
+1 -1
View File
@@ -5,7 +5,7 @@
],
"target": {
"namespace": "android_app",
"package_name": "pub.agora.app",
"package_name": "spot.agora.app",
"sha256_cert_fingerprints": [
"7C:05:A8:5A:07:0F:84:AE:43:DE:85:67:A4:5F:7F:FB:42:0A:05:05:27:CE:B6:8C:DA:AF:A5:E0:12:E0:9E:71",
"E5:B1:A9:13:C9:37:35:3C:A5:E7:27:89:C0:9D:3D:0D:A5:4F:F5:26:88:06:BD:24:46:21:AB:61:6B:CC:C5:E5"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

+236
View File
@@ -0,0 +1,236 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="1152" height="720">
<defs>
<linearGradient id="grad">
<stop stop-color="#84be86" offset="0"/>
<stop stop-color="#328c4e" offset="1"/>
</linearGradient>
</defs>
<path fill="#f4e109" d="m0,0,0,720,1152,0,0-720z"/>
<path fill="#da251c" d="m596.99,620,555.01,56.187,0-634.28-1110,0,0,634.28"/>
<g fill="#29166f">
<path d="m597,359.06,0-317.16-277.69,0z"/>
<path d="m597,359.06,555-317.16-278.03,0z"/>
<path d="m1152,200.47,0,158.59-1110,0,0,158.56z"/>
<path d="m1152,676.14,0-158.51-1110-317.16,0-158.56z"/>
</g>
<path fill="#f4e109" d="m392.87,329.97,102.05,17.726c-0.408,3.732-0.632,7.516-0.632,11.355,0,3.868,0.225,7.685,0.64,11.441l-102.04,17.813,102.92-11.563c1.158,6.703,2.963,13.182,5.344,19.374l-94.205,43.224,96.647-37.403c1.266,2.774,2.643,5.487,4.14,8.123l178.54,0.001c1.495-2.636,2.874-5.349,4.14-8.123l96.646,37.403-94.206-43.224c2.382-6.192,4.187-12.671,5.345-19.374l102.92,11.563-102.04-17.813c0.414-3.757,0.641-7.573,0.641-11.441,0-3.839-0.227-7.623-0.632-11.355l102.05-17.726-102.94,11.476c-1.148-6.719-2.944-13.211-5.321-19.419l94.22-43.177-96.657,37.357c-3.226-7.082-7.215-13.735-11.879-19.849l78.264-68.147-82.245,63.261c-10.31-11.931-23.281-21.47-38.01-27.65l33.979-98.248-39.859,96.01c-10.556-3.681-21.882-5.707-33.686-5.707-11.803,0-23.128,2.026-33.684,5.707l-39.86-96.01,33.98,98.248c-14.729,6.181-27.701,15.719-38.01,27.65l-82.246-63.261,78.266,68.147c-4.665,6.114-8.654,12.768-11.878,19.849l-96.659-37.357,94.22,43.177c-2.378,6.208-4.171,12.7-5.321,19.419z"/>
<path fill="#fff" d="M596.99,359.05,1152,676.19h-1110l554.99-317.14z"/>
<g fill="#da251c" stroke="#000" stroke-width="0.476">
<path fill="#f1c700" d="m629.96,593.89c0,18.271-14.811,33.082-33.081,33.082-18.268,0-33.082-14.812-33.082-33.082,0-18.269,14.814-33.079,33.082-33.079,18.27,0,33.081,14.81,33.081,33.079z"/>
<path d="m624.99,593.89c0,15.526-12.586,28.112-28.112,28.112s-28.112-12.586-28.112-28.112,12.586-28.112,28.112-28.112,28.112,12.586,28.112,28.112z"/>
<path fill="#e87817" d="m620.54,593.89c0,13.069-10.594,23.663-23.663,23.663s-23.663-10.594-23.663-23.663,10.594-23.663,23.663-23.663,23.663,10.594,23.663,23.663z"/>
<path fill="#29166f" d="m620.54,593.9c0,13.065-10.594,23.661-23.663,23.661s-23.702-10.596-23.702-23.661c0-6.541,5.301-11.858,11.848-11.858,6.552,0,11.836,5.317,11.836,11.858,0,6.542,5.296,11.849,11.85,11.849,6.542-0.001,11.831-5.307,11.831-11.849z"/>
<path fill="#e87817" d="m588.41,593.89c0,1.868-1.509,3.382-3.38,3.382s-3.382-1.514-3.382-3.382c0-1.864,1.511-3.384,3.382-3.384s3.38,1.519,3.38,3.384z"/>
<path fill="#29166f" d="m612.11,593.89c0,1.868-1.508,3.382-3.38,3.382-1.871,0-3.385-1.514-3.385-3.382,0-1.864,1.514-3.384,3.385-3.384,1.873,0,3.38,1.519,3.38,3.384z"/>
<path d="m596.87,556.27c-1.38,0-2.501-1.117-2.501-2.5,0-1.385,1.121-2.507,2.501-2.507,1.384,0,2.502,1.122,2.502,2.507,0,1.382-1.118,2.5-2.502,2.5z"/>
<path d="m596.81,558.2c0,1.385-1.117,2.506-2.502,2.506s-2.501-1.121-2.501-2.506c0-1.378,1.116-2.502,2.501-2.502s2.502,1.124,2.502,2.502z"/>
<path d="m601.94,558.2c0,1.385-1.116,2.506-2.5,2.506-1.386,0-2.506-1.121-2.506-2.506,0-1.378,1.12-2.502,2.506-2.502,1.384,0,2.5,1.124,2.5,2.502z"/>
<path d="m596.87,631.52c1.379,0,2.506,1.12,2.506,2.5,0,1.384-1.127,2.502-2.506,2.502-1.384,0-2.5-1.118-2.5-2.502,0-1.38,1.116-2.5,2.5-2.5z"/>
<path d="m596.94,629.58c0-1.38,1.118-2.5,2.502-2.5,1.382,0,2.504,1.12,2.504,2.5,0,1.386-1.122,2.506-2.504,2.506-1.384,0-2.502-1.12-2.502-2.506z"/>
<path d="m591.81,629.58c0-1.38,1.116-2.5,2.5-2.5s2.505,1.12,2.505,2.5c0,1.386-1.121,2.506-2.505,2.506s-2.5-1.12-2.5-2.506z"/>
<path d="m639.5,593.89c0,1.3813-1.1197,2.501-2.501,2.501s-2.501-1.1197-2.501-2.501,1.1197-2.501,2.501-2.501,2.501,1.1197,2.501,2.501z"/>
<path d="m635.07,596.46c0,1.3824-1.1206,2.503-2.503,2.503s-2.503-1.1206-2.503-2.503,1.1206-2.503,2.503-2.503,2.503,1.1206,2.503,2.503z"/>
<path d="m632.57,588.83c-1.381,0-2.502,1.115-2.502,2.5s1.121,2.504,2.502,2.504c1.382,0,2.502-1.119,2.502-2.504s-1.12-2.5-2.502-2.5z"/>
<path d="m559.25,593.89c0-1.378-1.115-2.504-2.5-2.504-1.384,0-2.501,1.126-2.501,2.504,0,1.383,1.117,2.5,2.501,2.5,1.385,0,2.5-1.118,2.5-2.5z"/>
<path d="m563.68,591.33c0,1.3824-1.1206,2.503-2.503,2.503s-2.503-1.1206-2.503-2.503,1.1206-2.503,2.503-2.503,2.503,1.1206,2.503,2.503z"/>
<path d="m563.68,596.45c0,1.3818-1.1202,2.502-2.502,2.502s-2.502-1.1202-2.502-2.502,1.1202-2.502,2.502-2.502,2.502,1.1202,2.502,2.502z"/>
</g>
<g stroke-width="0.476">
<g fill="#e0609b" stroke="#000">
<path fill="#e87817" d="m600.33,516.36c0,1.84-1.487,3.326-3.325,3.326-1.84,0-3.326-1.486-3.326-3.326,0-1.838,1.486-3.324,3.326-3.324,1.838,0,3.325,1.486,3.325,3.324z"/>
<path fill="#e12211" d="m622.04,486.76c-1.398,5.898-12.077,10.491-25.054,10.491-13.008,0-23.717-4.616-25.07-10.542-4.437,2.376-7.1,5.4-7.1,8.691,0,7.66,14.382,13.867,32.124,13.867,17.74,0,32.118-6.207,32.118-13.867,0-3.268-2.629-6.274-7.018-8.64z"/>
<path d="m597.44,497.25c-2.56,0-5.03-0.179-7.36-0.514-0.354,0.997-2.361,8.101,7.313,12.53,7.429-2.847,7.275-10.72,6.511-12.41-2.063,0.254-4.228,0.394-6.464,0.394z"/>
<path d="m571.8,485.93c-1.184,0.502-7.366,2.488-5.974,13.064,9.224,2.535,10.25-5.694,10.28-6.938-2.553-1.761-4.104-3.864-4.306-6.126z"/>
<path d="m588.87,496.54c0.079,1.021,0.6,9.042-9.55,10.257-7.443-6.215-2.961-13.113-1.307-13.851,2.84,1.615,6.58,2.866,10.857,3.594z"/>
<path d="m617.82,492.06c0.027,1.243,1.056,9.473,10.279,6.938,1.392-10.576-4.792-12.563-5.976-13.064-0.198,2.261-1.752,4.364-4.303,6.126z"/>
<path d="m615.91,492.95c1.654,0.737,6.138,7.636-1.308,13.851-10.149-1.215-9.625-9.235-9.55-10.257,4.278-0.728,8.019-1.979,10.858-3.594z"/>
<path fill="#f1c700" d="m627.78,491.52c0.831,1.236,1.284,2.536,1.284,3.881,0,7.66-14.378,13.867-32.118,13.867-17.741,0-32.124-6.207-32.124-13.867,0-1.237,0.381-2.438,1.089-3.585-3.936,2.409-6.231,5.296-6.231,8.405,0,8.402,16.749,15.207,37.411,15.207,20.669,0,37.421-6.805,37.421-15.207,0.001-3.238-2.495-6.233-6.732-8.701z"/>
</g>
<g fill="#fff">
<path d="m597.12,504.23c-1.458-2.815-0.844-5.285-0.803-6.996,0.16,0.004,0.317,0.005,0.478,0.005-0.06,0.894-0.052,3.593,0.325,6.991z"/>
<path d="m572.84,488.82c-0.661,0.648-2.937,3.078-4.184,6.476,2.1-2.619,4.031-5.024,4.688-5.757-0.181-0.236-0.351-0.477-0.504-0.719z"/>
<path d="m582.4,494.9c-0.296,0.59-1.664,3.014-1.664,6.553,1.033-3.603,2.047-5.626,2.423-6.3-0.261-0.084-0.511-0.164-0.759-0.253z"/>
<path d="m620.59,489.54c0.657,0.732,2.588,3.138,4.688,5.757-1.247-3.397-3.522-5.827-4.184-6.476-0.154,0.242-0.321,0.483-0.504,0.719z"/>
<path d="m610.77,495.16c0.373,0.674,1.387,2.697,2.419,6.3,0-3.539-1.367-5.963-1.664-6.553-0.247,0.089-0.496,0.169-0.755,0.253z"/>
</g>
<g stroke="#000">
<path fill="#e12211" stroke-linejoin="round" stroke-linecap="round" d="m576.26,461.85s-2.332-2.805-5.756-4.99c-3.421-2.194-5.785-0.894-9.277-1.612-3.316-0.688-10.426-2.966-8.386-15.539,2.053,11.032,7.61,7.926,8.077,6.467,0.581-1.814-0.708-4.537-2.603-5.753-2.393-1.538-6.708-5.063-6.708-10.131,0-1.56,0.377-3.218,0.901-4.756,0.566-1.675,3.003-3.86,2.557-6.782,1.879,4.552-2.285,4.675,2.022,11.125,1.16,1.736,2.86,0.363,3.148-0.958,0.442-2.052-0.822-4.79-1.779-6.984-0.962-2.188-1.82-5.789-0.962-8.896,1.235-4.453,3.868-5.447,4.794-8.768,0.561-2.009-2.656-6.852,2.054-10.679-2.584,5.996,0.959,7.939,1.779,9.723,0.822,1.78-0.417,5.209-0.547,6.435-0.171,1.612,2.178,2.282,3.695,1.265,1.955-1.3,1.678-5.234,1.819-7.422,0.136-2.197,1.985-3.532,4.311-4.627,2.329-1.094,6.149-5.379,3.219-11.946,7.264,5.734,5.718,11.09,5.029,13.282-0.683,2.191-2.052,6.301-1.771,9.035,0.27,2.742,2.184,2.602,2.868,1.508,0.688-1.095-0.373-4.674-0.138-6.573,0.4-3.227,5.537-3.862,5.804-6.267,0.222-2.001-4.978-6.153-4.694-9.609,0.445-5.436,4.978-3.62,3.275-9.52,3.697,3.697,1.232,6.499,0.959,7.867-0.273,1.371,0.055,3.479,1.78,3.291,1.233-0.141,2.381-1.729,3.149-4.795,2.085-8.319-4.873-6.527-4.146-12.84,2.566,4.148,5.99,1.523,9.867,7.603,1.874,2.938,0.988,5.373,0.579,7.425-0.411,2.058,1.371,4.114,2.603,2.195,2.794-4.354-1.274-5.243,1.916-10.888-1.149,8.436,3.851,5.978,3.147,14.858-0.291,3.675-4.79,6.982-5.206,10-0.347,2.572,0.449,5.575,1.406,6.533,1.584,1.586,3.188,1.326,3.699-0.549,0.407-1.503-0.413-3.114-0.962-4.759-0.621-1.868,0.601-3.651,2.979-4.243,1.821-0.453,3.977-3.488,3.56-6.16-0.426-2.792-4.774-4.581-0.408-11.643-2.45,7.937,4.134,6.937,4.962,12.396,0.353,2.322-0.43,3.844-0.648,5.063-0.282,1.556,4.211,3.806,2.811-2.258,4.718,3.862,5.063,8.629,4.517,10.407-0.551,1.78-2.056,3.968-1.098,5.886s3.249,2.671,4.52,0.822c1.059-1.541,1.366-3.745,0.582-6.299-0.844-2.749-4.469-3.581-0.513-9.793,0,6.212,4.919,8.503,5.714,11.504,0.393,1.481-0.442,3.219,0.242,4.176,0.685,0.962,4.938,0.58,1.645-6.843,5.86,4.789,2.737,10.437,0.82,11.948-1.914,1.506-5.339,5.441-5.339,8.867,0,3.422,3.704,6.521,4.929,3.559,1.353-3.271-1.383-6.007,1.507-10.547-0.765,4.582,0.693,9.567,2.872,9.859,1.819,0.244,2.356-2.655,2.982-4.622,1.839,5.22-2.16,7.878-2.982,9.109-0.822,1.229-1.197,4.484-2.738,5.342-1.29,0.713-4.38,1.406-6.296,2.769-1.916,1.368-4.174,4.275-0.683,4.799,3.212,0.479,5.377-0.508,7.391-5.48,2.98,9.223-5.41,12.905-9.586,13.423-3.398,0.415-9.174,0.544-10.818,1.776-1.643,1.231-1.643,3.214-1.643,3.214s-2.602,4.729-7.805,5.824c-5.201,1.097-19.718,1.917-23.962,0.273s-7.4-3.84-9.036-6.097zm66.648-45.118c-5.9,5.665-0.803,12.679,0.446,8.628,1.39-4.507-2.096-3.241-0.446-8.628zm-16.259,21.773c-1.02,1.02-0.98,2.195-0.445,2.808,0.445,0.512,1.472,0.037,2.156-0.475,0.687-0.517,1.664-1.645,1.715-3.082,0.09-2.557-2.178-2.633-2.433-5.856-1.2,2.4,2.089,3.523-0.993,6.605zm-5.342-23.107c-1.089,3.51,1.18,4.697,1.493,6.006,0.308,1.287-0.256,1.855,0.151,2.93,0.411,1.075,1.594,2.105,2.26,0.769,0.75-1.494,0.432-2.606-0.099-3.952-0.686-1.743-3.805-1.993-3.805-5.753zm-6.11-12.98c1.332,2.896,0.14,3.552,0.716,6.829,0.206,1.17,1.912,3.409,2.519,1.591,0.512-1.535,0.41-3.078-0.617-4.105-2.288-2.287-0.356-2.552-2.618-4.315zm-6.574,11.655c4.591,5.847-0.048,11.04-1.026,11.864-0.977,0.821-1.542,1.692-1.797,2.513-0.255,0.826-0.052,2.363,1.384,2.004,1.439-0.361,2.673-1.848,3.339-3.081,0.668-1.229,1.598-4.002,1.696-6.265,0.15-3.439-1.505-6.773-3.596-7.035zm-6.832-1.077c-1.868,2.38,0.052,3.88-0.203,5.494-0.149,0.946-0.924,1.438-0.768,2.206,0.15,0.773,1.229,1.593,1.949,0.976,0.716-0.614,1.334-2.668,0.82-3.539-0.511-0.875-2.979-2.11-1.798-5.137zm-10.214-6.161c-4.343,5.054-1.492,10.216-0.927,10.889,0.566,0.664,2.617,0.929,2.72-0.98,0.208-3.886-3.511-3.886-1.793-9.909zm-5.958,9.653c-2.987,8.139,0.14,8.804,0.716,8.42,0.772-0.511,1.334-1.54,0.979-2.258-0.361-0.72-2.432-0.107-1.695-6.162zm-6.676-3.597c0.979,4.318-1.437,4.527-2.058,9.865-0.236,2.036,0.67,4.361,1.079,5.182,0.409,0.826,1.596,1.42,2.314,0.46,0.77-1.025,0.973-2.206,0.408-3.337-0.463-0.928-2.241-1.436-2.211-3.542,0.033-2.143,2.449-6.185,0.468-8.628zm-10.94,0.363c-2.247,4.872,2.253,5.163,2.93,6.979,0.414,1.11,1.229,2.056,1.897,0.98,0.67-1.079,0.413-3.081-0.465-3.906-0.871-0.819-4.362-1.265-4.362-4.053zm-4.108,8.473c-1.598,2.482,1.319,3.732,0.512,6.723-0.445,1.646-0.615,2.771-0.308,3.393,0.308,0.615,1.695,0.924,2.313-0.258,0.615-1.178,0.702-2.16,0.511-3.135-0.357-1.824-3.028-3.174-3.028-6.723zm11.4-17.44c-0.453,0.623-0.874,2.411-0.566,3.54,0.311,1.131,1.802,0.835,2.107,0.257,0.463-0.871-0.183-1.63-0.053-2.613,0.253-1.906,2.894-1.781,2.208-5.139-0.553,3.096-3.03,3.029-3.696,3.955zm18.637-2.621c-0.215,2.016,0.618,5.6,1.899,6.776,1.285,1.182,2.946-0.305,3.028-1.744,0.104-1.831-1.932-2.553-2.253-4.616-0.617-3.965,4.399-5.675,0.409-10.788,1.866,5.655-2.465,4.573-3.083,10.372z"/>
<path fill="#f1c700" d="m615.28,477.31c1.009,1.263,1.566,2.65,1.566,4.11,0,5.835-8.932,10.569-19.953,10.569-11.018,0-19.952-4.734-19.952-10.569,0-1.4,0.521-2.734,1.455-3.958-4.11,2.104-6.614,4.901-6.614,7.977,0,6.521,11.282,11.808,25.208,11.808,13.924,0,25.213-5.286,25.213-11.808,0-3.154-2.634-6.012-6.923-8.129z"/>
<path fill="#e12211" d="m615.59,477.73c-0.229,0.014-0.333,0.168-0.563-0.064-0.336-0.343-1.774-5.841-2.258-7.72-3.385,1.708-9.229,2.841-15.875,2.841-6.458,0-12.236-1.075-15.655-2.727-0.51,1.98-1.901,7.273-2.233,7.605-0.296,0.305-0.373-0.063-0.834,0.111-0.788,1.137-1.228,2.361-1.228,3.644,0,5.835,8.935,10.569,19.952,10.569,11.021,0,19.953-4.734,19.953-10.569,0-1.299-0.451-2.542-1.259-3.69z"/>
<path fill="#e87817" d="m579.67,467.35c1.768,4.648-2.587,10.577-1.52,13.252,1.063,2.667,3.491,4.079,5.861,4.878,5.991,2.019,7.075-3.606,8.075-14.169-3.69,0-9.599-1.144-12.416-3.961z"/>
<path fill="#29166f" d="m614.32,467.35c-1.768,4.648,2.587,10.577,1.52,13.252-1.063,2.667-3.491,4.079-5.861,4.878-5.991,2.019-7.075-3.606-8.075-14.169,3.691,0,9.599-1.144,12.416-3.961z"/>
<path fill="#fff" d="m602.32,471.78c0.75,2.538,2.585,11.549,1.657,13.4-1.125,2.239-4.041,3.375-6.981,3.406s-5.856-1.167-6.981-3.406c-0.928-1.852,0.907-10.862,1.657-13.4h10.648z"/>
<path fill="#e87817" d="m579.97,432c-5.032-3.902-7.604-3.569-7.604-3.569s-2.019,1.635-2.66,7.97c-0.405,4.006-0.633,10.451,2.174,16.628,2.774,6.112,7.654,12.183,9.485,14.039,1.834,1.854,3.345,3.692,3.345,3.692l10.187-4.366s-0.293-2.36-0.374-4.969c-0.077-2.607-1.494-10.163-4.002-16.389-2.543-6.29-7.373-10.568-10.551-13.036z"/>
<path fill="#29166f" d="m613.86,432c5.032-3.902,7.604-3.569,7.604-3.569s2.02,1.635,2.66,7.97c0.406,4.006,0.633,10.451-2.173,16.628-2.774,6.112-7.654,12.183-9.485,14.039-1.834,1.854-3.345,3.692-3.345,3.692l-10.187-4.366s0.293-2.36,0.374-4.969c0.077-2.607,1.494-10.163,4.002-16.389,2.542-6.29,7.372-10.568,10.55-13.036z"/>
<path fill="#fff" d="m602.57,430.27c-3.088-5.569-5.582-6.276-5.582-6.276s-2.5,0.707-5.586,6.276c-1.951,3.521-4.7,9.355-4.556,16.139,0.142,6.711,2.234,14.213,3.185,16.641,0.956,2.428,1.62,4.713,1.62,4.713h11.083s0.66-2.285,1.614-4.713c0.956-2.428,2.632-9.93,2.779-16.641,0.143-6.783-2.609-12.617-4.557-16.139z"/>
<path fill="#f4e109" d="m596.59,470.93c-9.241,0-17.013-2.043-19.26-4.805-0.03,0.133-0.047,0.266-0.047,0.404,0,3.451,8.643,6.256,19.307,6.256,10.662,0,19.308-2.805,19.308-6.256,0-0.139-0.019-0.271-0.048-0.404-2.251,2.762-10.023,4.805-19.26,4.805z"/>
<path fill="#e87817" d="m596.59,468.84c-9.572,0-17.604-2.131-19.827-5.008-0.063,0.21-0.099,0.432-0.099,0.646,0,3.564,8.917,6.457,19.926,6.457,11.001,0,19.924-2.893,19.924-6.457,0-0.215-0.036-0.437-0.099-0.646-2.226,2.877-10.255,5.008-19.825,5.008z"/>
<path fill="#f4e109" d="m596.59,466.98c-9.833,0-18.088-2.182-20.402-5.132-0.021,0.121-0.037,0.248-0.037,0.369,0,3.654,9.148,6.62,20.439,6.62,11.283,0,20.435-2.966,20.435-6.62,0-0.121-0.013-0.248-0.033-0.369-2.314,2.95-10.573,5.132-20.402,5.132z"/>
</g>
<g fill="#000">
<path d="m577.27,430.13c0.364,0.851-0.835,2.477-2.762,3.303s-3.931,0.573-4.296-0.277l-0.109,0.557c0.364,0.851,2.463,1.506,4.75,0.524,2.288-0.98,3.261-2.951,2.896-3.803l-0.479-0.304z"/>
<path d="m581.32,433.08c0.569,1.327-1.313,4.192-4.564,5.585-3.25,1.395-6.623,0.784-7.192-0.545l-0.042,0.734c0.568,1.328,4.137,2.432,7.703,0.902,3.565-1.528,5.226-4.873,4.656-6.202l-0.561-0.474z"/>
<path d="m584.63,436.13c0.718,1.674-1.6,5.621-5.78,7.415-4.183,1.793-8.64,0.749-9.357-0.924l0.065,0.862c0.718,1.674,5.294,3.135,9.784,1.21,4.489-1.925,6.586-6.248,5.867-7.921l-0.579-0.642z"/>
<path d="M586.84,446.47c-1.25,1.38-3.02,2.7-5.18,3.62-5.01,2.15-10.19,1.29-11.04-0.68l0.16,0.68c0.85,1.98,6.07,3.49,11.38,1.22,1.98-0.85,3.6-2.09,4.78-3.37-0.03-0.5-0.09-0.98-0.1-1.47z"/>
<path d="m616.56,430.13c-0.364,0.851,0.834,2.477,2.762,3.303,1.927,0.826,3.931,0.573,4.295-0.277l0.11,0.557c-0.364,0.851-2.463,1.506-4.751,0.524-2.287-0.98-3.26-2.951-2.895-3.803l0.479-0.304z"/>
<path d="m612.52,433.08c-0.569,1.327,1.313,4.192,4.563,5.585,3.251,1.395,6.623,0.784,7.193-0.545l0.041,0.734c-0.568,1.328-4.137,2.432-7.702,0.902-3.565-1.528-5.226-4.873-4.656-6.202l0.561-0.474z"/>
<path d="m609.21,436.13c-0.718,1.674,1.6,5.621,5.78,7.415,4.182,1.793,8.64,0.749,9.357-0.924l-0.066,0.862c-0.717,1.674-5.293,3.135-9.783,1.21-4.489-1.925-6.586-6.248-5.867-7.921l0.579-0.642z"/>
<path d="M607.12,446.59c-0.01,0.5-0.06,1-0.09,1.5,1.17,1.24,2.71,2.4,4.63,3.22,5.3,2.27,10.56,0.76,11.4-1.22l0.16-0.68c-0.85,1.97-6.02,2.83-11.03,0.68-2.09-0.89-3.82-2.17-5.07-3.5z"/>
<path d="m600.83,427.49c0,0.926-1.742,1.948-3.839,1.948s-3.839-1.022-3.839-1.948l-0.32,0.469c0,0.926,1.67,2.354,4.159,2.354s4.159-1.429,4.159-2.354l-0.32-0.469z"/>
<path d="m603.39,431.79c0,1.445-2.858,3.336-6.396,3.336s-6.396-1.891-6.396-3.336l-0.328,0.658c0,1.445,2.844,3.865,6.724,3.865s6.724-2.42,6.724-3.865l-0.328-0.658z"/>
<path d="m605.23,435.9c0,1.821-3.686,4.536-8.235,4.536s-8.235-2.715-8.235-4.536l-0.279,0.818c0,1.821,3.63,4.968,8.515,4.968s8.515-3.146,8.515-4.968l-0.29-0.82z"/>
<path d="m607,443.28c0,2.152-4.238,5.593-10.007,5.593s-10.007-3.44-10.007-5.593l0.143-0.714c0,2.152,4.412,4.994,9.864,4.994s9.864-2.842,9.864-4.994l0.16,0.71z"/>
</g>
</g>
<g id="lion" stroke="#000" stroke-width="0.476" stroke-linejoin="round" stroke-linecap="round">
<path fill="#f4e109" d="m578.1,509.26c6.627,6.627-0.549,11.553-4.858,8.988,0.618-1.141,1.062-3.734,1.062-3.734s4.872,0.986,3.796-5.254z"/>
<g fill="#fff" stroke-width="1.429">
<path d="m445.45,607.5s-4.234,8.459-7.507,10.099c-0.995,0.498-1.821,0.36-1.821,0.36s4.128,3.283,6.071,4.015c1.943,0.726,5.344,2.427,6.436,2.061,1.095-0.362,4.86-2.188,7.652-3.037,2.795-0.849,4.738-0.728,5.952,0.122,1.216,0.854,3.28,3.768,2.552,4.982-0.729,1.213-2.309,2.064-2.309,2.064s0.242,1.094-0.608,2.063c-0.849,0.972-2.914,1.334-2.914,1.334l-0.972,2.554c-0.484,1.335-2.064,1.455-2.064,1.455s-0.122,3.16-1.215,4.735c-1.093,1.583-2.793,1.945-4.857,1.703-2.065-0.242-5.345-1.818-6.316-1.942-0.971-0.121-2.672-1.094-3.157-2.309-0.488-1.214-2.065-2.915-3.766-4.009-1.701-1.092-11.173-5.1-14.453-5.584-3.279-0.489-13.117-3.158-13.117-3.158s8.381-6.803,9.109-12.756c0.729-5.948,2.309-9.105,9.717-7.404,7.406,1.7,17.587,2.652,17.587,2.652z"/>
<path d="m513.73,538.17s2.722-2.971,6.152-4.2c6.747-2.421,9.257-0.081,9.257-0.081s0.136-2.521,5.48-4.223c5.344-1.703,8.259-0.727,8.259-0.727s0.972-3.403,3.888-4.617c2.914-1.215,5.829-1.945,6.315-3.885,0.484-1.943-0.73-5.83,0.241-8.018,0.973-2.188,3.771-4.163,6.406-2.825,2.277,1.155,2.127-0.121,3.826,0.362,1.699,0.49,2.458,1.367,2.458,1.367s1.398-1.182,3.828-0.451c2.427,0.725,3.399,1.547,3.399,1.547s2.429,1.457,2.188,3.156c-0.246,1.704-0.974,4.132-3.887,4.617-2.915,0.485-5.584-0.243-7.53,0.727-1.942,0.976-4.372,4.616-5.102,6.56-0.728,1.945-5.828,15.304-13.115,20.647-7.288,5.342-18.219,16.518-21.133,17.245-2.916,0.731-7.287,1.7-7.287,1.7s1.214-16.518-0.729-21.375c-1.944-4.855-2.914-7.526-2.914-7.526z"/>
</g>
<g fill="url(#grad)" stroke-width="0.667">
<path d="m375.46,542.44s6.964-6.071,13.295,1.129c6.426,7.311,2.914,15.123-0.366,18.218-3.278,3.098-7.604,6.334-13.298,5.285,1.856-2.311,1.032-3.699,0.785-4.513-0.547-1.786,1.408-1.575,3.362-4.122,1.434-1.861,2.518-5.51,1.625-8.188-1.856-5.57-5.403-7.809-5.403-7.809z"/>
<path d="m302.22,567.26c5.333-4.255,8.208,3.433,15.121,0.731,3.417-1.335,6.922-8.2,11.295-8.932,4.373-0.727,9.525,2.734,12.752,0.551,5.647-3.826,0.911-13.302,13.117-18.765,8.628-3.862,18.4-3.101,22.956,1.639,4.554,4.733,5.647,11.476,4.19,14.211-1.458,2.731-5.466,6.011-5.466,6.011l-6.377,1.092s-2.186-4.738-6.739-5.465c-4.554-0.729-6.922,2.369-9.109,5.83-2.62,4.148-9.11,9.475-19.312,7.469-3.77-0.739-7.834,4.918-17.672,2.187-4.889-1.357-7.986-6.626-14.756-6.559z"/>
<path d="m307.31,581.61c4.309-3.92,8.75,0.238,10.765,0.221,3.659-0.029,6.921-2.55,8.015-5.827,1.092-3.281,4.19-6.194,8.015-6.558,3.826-0.367,10.02,0.543,12.752-3.101,2.734-3.642,7.652-14.209,14.028-15.485,6.377-1.272,9.11,0.729,9.838,3.826,0.729,3.1-0.912,9.84-0.912,9.84l-2.549,5.463-8.564,2.918s-0.18,4.371-4.007,5.827c-3.825,1.46-12.752-0.543-17.854,0.729-5.1,1.275-4.653,3.521-11.295,4.737-11.548,2.116-12.173-5.009-18.232-2.59z"/>
<path d="m303.13,600.59c5.109-6.156,10.974,2.27,17.296-1.594,3.454-2.11,4.564-10.246,8.754-11.705,4.191-1.457,9.473,1.277,12.388-2.55,2.915-3.826,0.547-9.841,6.558-12.572,6.014-2.729,10.932-0.728,10.932-0.728s5.283-1.821,8.199-1.459c2.914,0.365,9.656,4.012,9.656,4.012s1.456,8.377-4.01,13.297c-5.465,4.921-10.565,7.471-15.303,5.83-3.05-1.055-4.917-1.641-7.285,0-2.37,1.641-2.37,4.373-6.924,4.373s-8.198-2.914-10.93-1.094c-2.733,1.819-4.158,5.658-8.928,6.377-12.231,1.844-13.419-5.218-20.403-2.187z"/>
<path d="m411.96,622.09s-3.158,2.917-5.102,3.767c-1.944,0.851-6.804,2.913-6.804,2.913s4.374,0.489,4.982,1.823c0.606,1.338-1.104,4.906-5.83,2.308,1.976,4.849,9.664,2.724,11.779,1.823,1.767-0.752,4.858-1.336,5.588-0.364,0.728,0.971-0.266,4.265-5.344,3.033,8.016,4.294,11.66-0.729,14.089-2.185,2.43-1.459,4.615-2.551,5.345-1.459,0.729,1.094,1.207,4.313-4.617,4.373,4.886,1.564,8.016-0.485,9.596-2.064,1.577-1.577,2.269-2.009,3.728-2.496,1.457-0.486,6.594-2.361,7.444-5.032,0.852-2.673,0.729-4.616-0.485-6.194-1.215-1.58-2.672-1.945-3.644-1.824-0.972,0.123-1.578,0.73-1.578,1.462,0,0.726,0.848,3.034-1.579,3.884-2.429,0.851-4.98,1.7-7.41,0.973-2.429-0.729-5.586-1.46-7.894-1.336-2.307,0.12-3.886,1.821-5.83,0.849-1.941-0.975-6.434-4.254-6.434-4.254z"/>
<path d="m547.98,521.16c1.943-1.212,4.13-2.668,7.044-0.483,2.916,2.187,5.155,6.253,8.258,8.987,7.825,6.896,5.394,11.146,1.7,16.516,2.193-9.182-4.856-11.494-4.126-7.041,0.563,3.434,3.069,5.672-1.946,15.06,0.391-3.2,0.244-5.831-0.484-7.531-0.729-1.698-2.672-1.942-2.672,0.245,0,2.182,3.234,10.336-6.073,17.971,7.12-10.635-0.428-14.188-1.944-9.957-2.623,7.322-0.748,14.635-11.173,16.03,8.362-6.896,4.658-12.913,1.215-9.47-4.158,4.158-1.54,11.074-9.961,16.03,4.636-7.661-0.893-7.363-3.155-6.073-1.702,0.971-8.504,5.341-8.504,5.341s0.972-4.37,1.217-8.256c0.24-3.886,1.456-4.614,1.456-4.614s6.315-0.728,8.258-4.617c1.944-3.887,3.158-9.231,7.531-11.172,4.372-1.945,12.631-3.645,14.817-7.771,2.186-4.13,1.701-6.076-0.242-8.988-1.944-2.919-4.131-4.854-3.888-6.559,0.243-1.703,1.186-2.718,2.672-3.648z"/>
<path d="m543.61,529.36c-2.599-2.209-8.987,0.305-8.987,0.305s4.978-1.125,6.618,1.246c0.909,1.307,0.092,2.912-0.273,2.912,0,0-0.001,0.004-0.003,0.004-0.602-0.506-1.366-0.822-2.213-0.822-1.911,0-3.46,1.55-3.46,3.461,0,1.864,1.467,3.346,3.295,3.593,2.319,0.313,4.523-0.357,5.842-1.771,2.023-2.171,2.825-5.83-0.819-8.928z"/>
<path d="m527.3,533.01c-2.945-0.79-8.198,1.273-8.198,1.273s5.968-0.32,7.378,1.457c1.167,1.472,0.584,2.716,0.376,3.006-0.519-0.248-1.098-0.393-1.712-0.393-2.181,0-3.948,1.766-3.948,3.949,0,2.179,1.768,3.947,3.948,3.947,0.183,0,1.249-0.166,1.609-0.344,0.58-0.287,1.353-0.709,1.829-1.141,1.935-1.756,2.5-3.363,2.726-6.291,0.14-1.814-1.063-4.67-4.008-5.463z"/>
<path d="m442.89,610.35s-0.639,3.826-5.102,5.559c-2.21,0.857-4.357,0.055-5.591-0.609,0.699-0.781,1.127-1.812,1.127-2.945,0-2.449-1.982-4.434-4.432-4.434-2.239,0-4.089,1.661-4.389,3.817-0.093,0.667-0.15,1.32,0.017,2.058,0.495,2.16,2.297,4.455,5.524,5.301,3.826,1,8.157-0.08,10.475-2.825,2.46-2.917,2.371-5.922,2.371-5.922z"/>
<path d="m502.07,460.92c19.043-1.048,17.543,12.202,9.716,15.062,0,0-3.765-0.242-8.016,0.123-4.801,0.409-5.969-5.17-0.094-5.92,5.737-0.733,9.562-5.813-1.606-9.265z"/>
<path d="m437.46,479.14c7.594-9.643,15.844,0.732,22.349-3.158,3.758-2.248,7.529-7.775,13.36-7.047,5.83,0.729,9.23,1.943,12.145,0.242,2.914-1.697,7.775-6.557,14.333-4.613,3.462,1.029,4.989,3.785,5.28,5.225,0.417,2.055,0.306,6.435,0.306,6.435l-10.201,1.7c-3.888,1.701-7.774,4.375-7.774,4.375s-4.129-5.831-7.773-6.075c-3.644-0.241-9.472,0.244-12.146,2.429-2.672,2.188-6.779,6.01-11.174,4.616-4.798-1.522-8.798-8.334-18.705-4.129z"/>
<path d="m428.23,491.77c11.636-9.71,11.386,3.228,21.761-6.71,4.08-3.907,8.063-1.188,13.46-4.463,5.13-3.113,9.715-9.23,16.031-7.287,6.316,1.939,9.961,7.529,9.961,7.529s-8.017,5.586-9.717,8.984c-1.701,3.402-10.033-1.645-14.575-1.214-5.101,0.489-7.685,3.819-12.389,6.075-14.333,6.877-13.583-5.686-24.532-2.914z"/>
<path d="m415.36,520.92c9.572,5.393,15.879,2.891,17.973,0,2.939-4.056,3.144-9.014,7.287-10.928,4.055-1.873,8.56,0.189,10.689-2.432,3.157-3.885-0.703-16.087,6.072-20.648,12.631-8.5,24.29,0.488,24.29,0.488s-5.708,5.828-6.558,13.848c-0.718,6.766,2.187,10.442,2.187,10.442s-3.479,6.896-13.118,5.59c-11.751-1.593-13.053-0.324-18.751,4.907-6.874,6.313-20.595,9.499-30.071-1.267z"/>
<path d="m405.88,539.38c8.234-5.445,10.359,1.742,19.433,1.214,4.143-0.241,7.289-2.185,9.959-6.798,2.672-4.617,4.616-7.047,9.959-7.286,5.344-0.245,9.716-0.977,11.902-4.378,2.187-3.398-1.699-12.875,3.887-19.187,5.588-6.317,14.333-4.376,14.333-4.376s-1.194,5.555,0.122,9.594c1.7,5.223,4.493,5.469,4.493,5.469s-5.068,2.232-6.314,8.621c-0.96,4.917,0.97,9.355,0.97,9.355s-6.316,6.559-16.76,5.102c-10.445-1.458-11.417-1.945-15.061,0.728-3.644,2.671-6.719,6.966-15.059,7.286-12.005,0.461-11.505-7.977-21.864-5.344z"/>
</g>
<g stroke-width="1.429" fill="#fff">
<path d="m387.91,572.9c-9.666,7.408-12.791,1.471-20.041-1.459-1.994-0.806-5.344-0.242-5.344-0.242l-1.699-3.888s-0.85-2.671,0.606-3.886c1.458-1.217,5.952-0.85,6.802,0.363,0.851,1.217,3.399,5.104,4.857,6.014,1.458,0.906,4.858,1.883,7.409,1.032,2.55-0.853,8.017-5.222,13.846-9.351,5.83-4.131,10.808-6.317,16.031-6.196,5.223,0.12,9.961,2.184,13.967,2.429,4.01,0.246,8.988-1.822,12.997-4.127,4.007-2.311,15.008-4.386,24.776-12.025,3.811-2.98,12.631-12.995,14.938-15.787,2.309-2.798,12.631,1.943,14.576,3.4,1.944,1.458,10.444,0.484,10.444,0.484s4.979-2.063,6.679-2.309c1.701-0.242,2.186,0.729,2.186,0.729s6.195,8.627,8.138,15.428c1.946,6.797,1.946,21.252,1.095,26.473-0.851,5.227-1.823,14.456-8.624,18.828-6.803,4.371-28.056,14.087-38.986,16.517-10.932,2.429-17.611,6.317-22.592,6.071-4.98-0.24-16.762-3.396-21.01-3.884-4.25-0.487-8.501-0.606-8.501-0.606s-0.487,6.68-2.309,10.808c-1.822,4.127-8.859,10.615-14.204,12.071-5.342,1.459-13.245,2.016-13.729,2.621-0.488,0.61-0.73,2.432,0,3.766,0.727,1.34,4.613,8.383,5.951,9.355,1.552,1.129,5.886,2.124,7.771,1.821,1.815-0.292,4.25,0,5.102,1.335,0.85,1.336,0.851,4.372,0.364,4.979-0.486,0.61-1.578,1.096-1.578,1.096s-0.244,2.188-1.215,2.796c-0.972,0.604-2.673,1.092-2.673,1.092s-0.122,0.847-1.215,1.696c-1.093,0.853-1.785,0.725-1.785,0.725s-1.369,0.237-2.464,0.978c-1.805,1.221-7.742-0.242-10.082-2.43-1.821-1.701-2.188-5.342-2.671-6.559-0.486-1.212-2.066-2.79-3.402-3.765-1.336-0.973-21.618-25.263-21.618-27.811,0-2.552,2.31-9.476,5.346-10.809,3.034-1.338,8.621-3.159,9.351-5.103,0.729-1.942-1.848-11.221,0.242-17.734,2.977-9.277,4.29-9.465,12.268-14.936z"/>
<path d="m363.62,573.09c4.938,1.414,1.375,8.039-3.461,5.828-2.339-1.069-4.281-4.279-3.279-6.833,1.001-2.546,2.825-3.277,4.098-3.005,1.275,0.271,3.644,1.824,3.644,1.824l-3.735,1.91c-0.762,1.458-0.364,2.732,1.094,3.464,1.305,0.651,3.077-0.711,1.639-3.188z"/>
<path d="m474.02,563.19s13.489,1.655,17.775,5.622c4.885,4.523,3.845,8.465,3.845,8.465s7.59-0.119,10.687,3.039c2.536,2.587,4.07,4.248,4.07,4.248l9.534,2.672c3.28,2.673,4.676,5.223,4.676,5.223s2.246-0.119,6.132,1.336c3.887,1.459,6.559,2.916,8.744,2.55,2.187-0.362,6.802-1.577,7.774-3.767,0.973-2.186-0.607-7.526,1.82-9.957,2.43-2.428,3.887-3.034,5.587-2.549,1.703,0.484,3.279,4.492,3.279,4.492l4.253,2.917c0.713,1.906,0,4.005,0,4.005s0.363,0.731,0.971,2.794c0.607,2.063-0.304,2.978-0.304,2.978s0.061,2.488-1.274,3.461c-1.338,0.973-3.158,1.216-3.158,1.216s0.561,2.091-0.168,3.425c-0.729,1.333-1.429,1.785-2.5,2.262-0.831,0.368-2.434,0.141-3.405,1.116-0.973,0.967-1.94,3.396-5.101,2.913-3.159-0.486-4.131-2.428-8.26-3.279-4.128-0.849-18.581-2.065-27.083-3.648-8.502-1.574-15.302-1.574-23.563-1.695-8.26-0.121-20.282-1.459-20.769-5.224-0.486-3.764,2.552-7.53,2.186-9.472-0.364-1.944-2.307-8.016-2.307-10.203s-1.568-10.701,0.849-13.848c1.216-1.579,4.008-1.579,5.71-1.092z"/>
<path d="m364.89,565.07c-0.399,3.365-3.587,1.303-2.824-0.64,0.581-1.479,3.279-1.641,4.646-1.003,1.366,0.639,3.644,3.464,3.825,4.372,0.183,0.911,0.51-3.675-1.183-5.555-1.641-1.825-5.193-2.825-8.018-1.367-2.445,1.261-3.279,3.188-2.915,5.919,0.313,2.352,2.565,3.931,4.646,4.192,3.548,0.448,6.298-3.177,1.823-5.918z"/>
<path d="m375.09,567.07c5.21-2.135,0.96-7.26-1.458-6.922-2.263,0.316-3.593,2.115-3.828,3.643-0.24,1.564,0.296,3.826,1.185,4.826,0.684,0.771,2.445,1.321,2.445,1.321s-1.904-2.741-0.714-4.597c1.142-1.78,3.58-2.093,2.37,1.729z"/>
</g>
<path fill="#fff" stroke="none" d="m463.89,559c8.617,3.146,9.46,3.168,15.442,4.337,3.271,0.64,2.942,4.616,0.585,7.446s-0.472,10.842-2.357,13.199-6.579,5.318-6.579,5.318c-4.263-3.434-8.504-4.848-8.976-11.918-0.47-7.08,1.89-18.39,1.89-18.39z"/>
<g fill="#f4e109">
<path d="m399.81,658.08s-2.623-1.095-3.349,0.82c-0.728,1.916,0.488,2.89,0.488,2.89s4.104,0.651,1.55,5.729c6.804-3.952,1.311-9.439,1.311-9.439z"/>
<path d="m403.26,655.94s-2.242-0.728-2.632,1.055c-0.389,1.783,0.772,3.008,0.772,3.008s2.961,0.75,1.796,4.503c5.353-4.504,0.064-8.566,0.064-8.566z"/>
<path d="m409.39,649.88s-0.831-0.381-1.438,0.833c-0.606,1.218-0.005,2.833-0.005,2.833s3.294,0.391,3.339,4.549c4.642-6.596-1.896-8.215-1.896-8.215z"/>
<path d="m454.1,634.6s-2.087-0.613-2.574,2.422c-0.403,2.519,1.482,2.802,1.482,2.802s3.485-1.263,3.643,2.913c5.405-4.739-2.551-8.137-2.551-8.137z"/>
<path d="m457.37,630.71s-1.765-0.27-1.817,2.491c-0.043,2.289,1.474,2.295,1.474,2.295s3.4-1.688,4.208,2.357c4.254-4.857-3.865-7.143-3.865-7.143z"/>

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 322 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 272 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

+2 -7
View File
@@ -91,13 +91,8 @@
"related_applications": [
{
"platform": "play",
"url": "https://play.google.com/store/apps/details?id=pub.ditto.app",
"id": "pub.ditto.app"
},
{
"platform": "itunes",
"url": "https://apps.apple.com/us/app/ditto-fun-social-media/id6761851821",
"id": "6761851821"
"url": "https://play.google.com/store/apps/details?id=spot.agora.app",
"id": "spot.agora.app"
}
],
"prefer_related_applications": false
Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 124 KiB

File diff suppressed because one or more lines are too long
Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

+35 -1
View File
@@ -56,8 +56,42 @@ self.addEventListener('notificationclick', (event) => {
});
// --- Activate immediately ---
//
// On activate:
// 1. Wipe every Cache Storage entry. A previous version of Agora deployed
// a precaching service worker (Workbox-style) that's still serving stale
// HTML/JS to returning users on this origin. Clearing caches means future
// requests bypass anything the old SW left behind.
// 2. Take control of all open clients via clients.claim().
// 3. Force each controlled tab to navigate to its own URL. clients.claim()
// only changes which SW handles future fetches — it does not re-render
// pages that already finished loading. Without the explicit navigate,
// the user is stuck on the old rendered bundle until they manually
// close and reopen the tab. Since this SW has no fetch handler, the
// navigation falls through to the network and gets the new build.
//
// This SW has no 'fetch' handler, so it never repopulates a cache — push
// notifications are the only thing it intercepts.
self.addEventListener('install', () => self.skipWaiting());
self.addEventListener('activate', (event) => {
event.waitUntil(self.clients.claim());
event.waitUntil(
(async () => {
const keys = await caches.keys();
await Promise.all(keys.map((key) => caches.delete(key)));
await self.clients.claim();
// Soft-reload every open same-origin tab so it picks up the fresh
// index.html + hashed bundle from the network. WindowClient.navigate()
// is same-origin-only by spec, which is exactly what we want.
const windowClients = await self.clients.matchAll({ type: 'window' });
await Promise.all(
windowClients.map((client) =>
'navigate' in client
? client.navigate(client.url).catch(() => {})
: Promise.resolve(),
),
);
})(),
);
});
Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

+120
View File
@@ -0,0 +1,120 @@
// Build a heavily-simplified land-polygon dataset for the hero globe.
//
// Input: Natural Earth 110m countries TopoJSON
// (https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json)
//
// Output: src/lib/landPolygons.ts — an array of rings (each ring is a flat
// array [lng0, lat0, lng1, lat1, ...]) representing landmasses.
//
// Run with: node scripts/build-land-polygons.mjs
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = path.resolve(__dirname, '..');
const INPUT = process.argv[2] ?? '/tmp/opencode/countries-110m.json';
const OUTPUT = path.join(REPO_ROOT, 'src/lib/landPolygons.ts');
const topo = JSON.parse(fs.readFileSync(INPUT, 'utf8'));
const layer = topo.objects.countries;
const transform = topo.transform;
/** Decode a topojson arc into absolute [lng, lat] pairs. */
function decodeArc(arc) {
const out = [];
let x = 0;
let y = 0;
for (const [dx, dy] of arc) {
x += dx;
y += dy;
out.push([
x * transform.scale[0] + transform.translate[0],
y * transform.scale[1] + transform.translate[1],
]);
}
return out;
}
const arcs = topo.arcs.map(decodeArc);
/** Resolve a topojson arc index (negative means reversed) into points. */
function resolveArc(i) {
if (i < 0) {
const arc = arcs[~i];
return arc.slice().reverse();
}
return arcs[i];
}
/** Build a ring from an array of arc indices. */
function buildRing(arcIndices) {
const ring = [];
for (let i = 0; i < arcIndices.length; i++) {
const seg = resolveArc(arcIndices[i]);
// Skip the duplicated joining point between consecutive arcs.
if (i === 0) ring.push(...seg);
else ring.push(...seg.slice(1));
}
return ring;
}
const rings = [];
for (const feature of layer.geometries) {
if (feature.type === 'Polygon') {
for (const arcIndices of feature.arcs) {
rings.push(buildRing(arcIndices));
}
} else if (feature.type === 'MultiPolygon') {
for (const polygon of feature.arcs) {
for (const arcIndices of polygon) {
rings.push(buildRing(arcIndices));
}
}
}
}
// Use the full Natural Earth 110m resolution. We skip Douglas-Peucker
// entirely so coastlines look organic at hero scale rather than blocky.
// We still quantize to 0.1° (well below the rendered pixel size on a
// ~600 px globe) which is a free ~30 % byte saving with no visible loss.
const MIN_VERTS = 3;
const simplifiedRings = [];
for (const ring of rings) {
if (ring.length < MIN_VERTS) continue;
const flat = [];
for (const [lng, lat] of ring) {
flat.push(Math.round(lng * 10) / 10, Math.round(lat * 10) / 10);
}
simplifiedRings.push(flat);
}
const totalCoords = simplifiedRings.reduce((sum, r) => sum + r.length / 2, 0);
const banner = `/**
* Simplified land polygons for the hero globe.
*
* Generated from Natural Earth 110m country boundaries via
* \`scripts/build-land-polygons.mjs\`. Each entry is a flat \`[lng, lat, lng,
* lat, ...]\` ring. We keep the data inline (rather than fetching a TopoJSON
* blob at runtime) so the hero renders instantly, with no network jitter and
* no extra runtime dependency.
*
* Do not edit by hand — re-run the script to regenerate.
*/
`;
const body = `export const LAND_RINGS: readonly (readonly number[])[] = [\n${
simplifiedRings.map((r) => ` [${r.join(',')}],`).join('\n')
}\n];\n`;
fs.writeFileSync(OUTPUT, banner + body);
console.log(
`Wrote ${OUTPUT}`,
`\n rings: ${simplifiedRings.length}`,
`\n vertices: ${totalCoords}`,
`\n bytes: ${fs.statSync(OUTPUT).size}`,
);
+3 -3
View File
@@ -25,7 +25,7 @@
* node scripts/extract-release-notes.mjs <version> [--summary] [--changelog <path>]
*
* --summary Print only the summary paragraph (no headings, no bullets).
* Falls back to "Ditto vX.Y.Z" if the section has no summary.
* Falls back to "Agora vX.Y.Z" if the section has no summary.
* --changelog Path to the changelog file. Defaults to CHANGELOG.md.
*
* Exits 0 with the extracted text on stdout. Exits non-zero if the version is
@@ -128,7 +128,7 @@ if (!section) {
if (summary) {
const text = extractSummary(section);
stdout.write(text ?? `Ditto v${version}`);
stdout.write(text ?? `Agora v${version}`);
stdout.write('\n');
} else {
const body = trimBlankEdges(section).join('\n');
@@ -136,6 +136,6 @@ if (summary) {
stdout.write(body);
stdout.write('\n');
} else {
stdout.write(`Ditto v${version}\n`);
stdout.write(`Agora v${version}\n`);
}
}
+43 -54
View File
@@ -6,8 +6,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { InferSeoMetaPlugin } from "@unhead/addons";
import { createHead, UnheadProvider } from "@unhead/react/client";
import { AppProvider } from "@/components/AppProvider";
import { DMProviderWrapper } from "@/components/DMProviderWrapper";
import { InitialSyncGate } from "@/components/InitialSyncGate";
import { InitialSyncRunner } from "@/components/InitialSyncRunner";
import { NativeNotifications } from "@/components/NativeNotifications";
import NostrProvider from "@/components/NostrProvider";
import { NostrSync } from "@/components/NostrSync";
@@ -19,10 +18,8 @@ import { TooltipProvider } from "@/components/ui/tooltip";
import { useNsecPasteGuard } from "@/hooks/useNsecPasteGuard";
import type { AppConfig } from "@/contexts/AppContext";
import { NWCProvider } from "@/contexts/NWCContext";
import { SparkWalletProvider } from "@/contexts/SparkWalletContext";
import { BuildConfigSchema, type BuildConfig } from "@/lib/schemas";
import { secureStorage } from "@/lib/secureStorage";
import { PROTOCOL_MODE } from "@samthomson/nostr-messaging/core";
import AppRouter from "./AppRouter";
const head = createHead({
@@ -44,9 +41,8 @@ const hardcodedConfig: AppConfig = {
appName: "Agora",
appId: "agora",
shareOrigin: import.meta.env.VITE_SHARE_ORIGIN || undefined,
homePage: "feed",
homePage: "campaigns",
client: "naddr1qvzqqqru7cpzq7q6z5ns2hm5c8msyv83qwzxpxe52j8c4d4q5m92wsp9sflelkh9qqzkg6t5w3hswjl4yp",
magicMouse: false,
theme: "system",
useAppRelays: true,
useUserRelays: false,
@@ -60,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,
@@ -112,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: [
@@ -150,23 +146,21 @@ const hardcodedConfig: AppConfig = {
imageQuality: 'compressed',
curatorPubkey: '932614571afcbad4d17a191ee281e39eebbb41b93fac8fd87829622aeb112f4d',
sandboxDomain: 'iframe.diy',
esploraBaseUrl: 'https://mempool.space/api',
esploraApis: [
'https://mempool.space/api',
'https://mempool.emzy.de/api',
'https://blockstream.info/api',
],
blockbookBaseUrl: 'https://btc.trezor.io',
bip352IndexerUrl: 'https://silentpayments.dev/blindbit/mainnet',
sidebarWidgets: [
{ id: 'trends' },
{ id: 'hot-posts' },
{ id: 'ai-chat' },
],
messaging: {
enabled: true,
relayMode: 'hybrid',
protocolMode: PROTOCOL_MODE.NIP17_ONLY,
renderInlineMedia: true,
soundEnabled: false,
devMode: false,
},
aiBaseURL: 'https://ai.shakespeare.diy/v1',
aiApiKey: '',
aiModel: 'grok-4.1-fast',
aiModel: 'google/gemma-4-26b',
aiSystemPrompt: '',
};
@@ -210,18 +204,13 @@ export function App() {
<NostrLoginProvider storageKey="nostr:login" storage={secureStorage}>
<NostrProvider>
<NostrSync />
<InitialSyncRunner />
<NativeNotifications />
<NWCProvider>
<SparkWalletProvider>
<DMProviderWrapper>
<TooltipProvider>
<InitialSyncGate>
<AppRouter />
</InitialSyncGate>
<AppRouter />
</TooltipProvider>
</DMProviderWrapper>
</SparkWalletProvider>
</NWCProvider>
</NostrProvider>
</NostrLoginProvider>
+32 -12
View File
@@ -6,7 +6,7 @@ import { MinimizedAudioBar } from "@/components/MinimizedAudioBar";
import { AudioPlayerProvider } from "@/contexts/AudioPlayerContext";
import { sidebarItemIcon } from "@/lib/sidebarItems";
import { Toaster } from "./components/ui/toaster";
import { MainLayout } from "./components/MainLayout";
import { FundraiserLayout } from "./components/FundraiserLayout";
import { ScrollToTop } from "./components/ScrollToTop";
import { VersionCheck } from "./components/VersionCheck";
import { useCurrentUser } from "./hooks/useCurrentUser";
@@ -24,11 +24,16 @@ const ReplyComposeModal = lazy(() => import("@/components/ReplyComposeModal").th
// Lazy-loaded emoji pack dialog
const EmojiPackDialog = lazy(() => import("@/components/EmojiPackDialog").then(m => ({ default: m.EmojiPackDialog })));
// HomePage eagerly imported all page components; now lazy-loaded
const HomePage = lazy(() => import("./pages/HomePage").then(m => ({ default: m.HomePage })));
// Campaigns: home + create. (Campaign detail is dispatched from NIP19Page
// 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 })));
const AllCampaignsPage = lazy(() => import("./pages/AllCampaignsPage").then(m => ({ default: m.AllCampaignsPage })));
// All other pages: code-split via React.lazy
const ActionsPage = lazy(() => import("./pages/ActionsPage"));
const CreateActionPage = lazy(() => import("./pages/CreateActionPage").then(m => ({ default: m.CreateActionPage })));
const AdvancedSettingsPage = lazy(() => import("./pages/AdvancedSettingsPage").then(m => ({ default: m.AdvancedSettingsPage })));
const AIChatPage = lazy(() => import("./pages/AIChatPage").then(m => ({ default: m.AIChatPage })));
const AppearanceSettingsPage = lazy(() => import("./pages/AppearanceSettingsPage").then(m => ({ default: m.AppearanceSettingsPage })));
@@ -40,7 +45,8 @@ const BookmarksPage = lazy(() => import("./pages/BookmarksPage").then(m => ({ de
const BooksPage = lazy(() => import("./pages/BooksPage").then(m => ({ default: m.BooksPage })));
const ChangelogPage = lazy(() => import("./pages/ChangelogPage").then(m => ({ default: m.ChangelogPage })));
const CommunitiesPage = lazy(() => import("./pages/CommunitiesPage").then(m => ({ default: m.CommunitiesPage })));
const ContentPage = lazy(() => import("./pages/ContentPage").then(m => ({ default: m.ContentPage })));
const CreateCommunityPage = lazy(() => import("./pages/CreateCommunityPage").then(m => ({ default: m.CreateCommunityPage })));
const CreateEventPage = lazy(() => import("./pages/CreateEventPage").then(m => ({ default: m.CreateEventPage })));
const ContentSettingsPage = lazy(() => import("./pages/ContentSettingsPage").then(m => ({ default: m.ContentSettingsPage })));
const CSAEPolicyPage = lazy(() => import("./pages/CSAEPolicyPage").then(m => ({ default: m.CSAEPolicyPage })));
const DomainFeedPage = lazy(() => import("./pages/DomainFeedPage").then(m => ({ default: m.DomainFeedPage })));
@@ -49,12 +55,12 @@ const ExternalContentPage = lazy(() => import("./pages/ExternalContentPage").the
const GeotagPage = lazy(() => import("./pages/GeotagPage").then(m => ({ default: m.GeotagPage })));
const HashtagPage = lazy(() => import("./pages/HashtagPage").then(m => ({ default: m.HashtagPage })));
const HelpPage = lazy(() => import("./pages/HelpPage").then(m => ({ default: m.HelpPage })));
const DonorGuidePage = lazy(() => import("./pages/DonorGuidePage").then(m => ({ default: m.DonorGuidePage })));
const ActivistGuidePage = lazy(() => import("./pages/ActivistGuidePage").then(m => ({ default: m.ActivistGuidePage })));
const KindFeedPage = lazy(() => import("./pages/KindFeedPage").then(m => ({ default: m.KindFeedPage })));
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 MessagingSettingsPage = lazy(() => import("./pages/MessagingSettingsPage").then(m => ({ default: m.MessagingSettingsPage })));
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 })));
@@ -76,11 +82,14 @@ const VerifiedPage = lazy(() => import("./pages/VerifiedPage").then(m => ({ defa
const VideosFeedPage = lazy(() => import("./pages/VideosFeedPage").then(m => ({ default: m.VideosFeedPage })));
const VinesFeedPage = lazy(() => import("./pages/VinesFeedPage").then(m => ({ default: m.VinesFeedPage })));
const WalletPage = lazy(() => import("./pages/WalletPage").then(m => ({ default: m.WalletPage })));
const WalletRecoveryPage = lazy(() => import("./pages/WalletRecoveryPage").then(m => ({ default: m.WalletRecoveryPage })));
const WalletSettingsPage = lazy(() => import("./pages/WalletSettingsPage").then(m => ({ default: m.WalletSettingsPage })));
const WebxdcFeedPage = lazy(() => import("./pages/WebxdcFeedPage").then(m => ({ default: m.WebxdcFeedPage })));
const WikipediaPage = lazy(() => import("./pages/WikipediaPage").then(m => ({ default: m.WikipediaPage })));
const WorldPage = lazy(() => import("./pages/WorldPage").then(m => ({ default: m.WorldPage })));
const FollowPage = lazy(() => import("./pages/FollowPage").then(m => ({ default: m.FollowPage })));
const PlanetoraPage = lazy(() => import("./pages/PlanetoraPage").then(m => ({ default: m.PlanetoraPage })));
const ReceivePage = lazy(() => import("./pages/ReceivePage").then(m => ({ default: m.ReceivePage })));
const RemoteLoginSuccessPage = lazy(() => import("./pages/RemoteLoginSuccessPage").then(m => ({ default: m.RemoteLoginSuccessPage })));
const pollsDef = getExtraKindDef("polls")!;
@@ -153,11 +162,16 @@ export function AppRouter() {
<Routes>
{/* Auto-follow deep link: fullscreen immersive (no sidebars/nav) */}
<Route path="/follow/:npub" element={<FollowPage />} />
<Route path="/receive" element={<ReceivePage />} />
{/* All routes share the persistent MainLayout (sidebar + nav) */}
<Route element={<MainLayout />}>
<Route path="/" element={<HomePage />} />
{/* All routes share the persistent FundraiserLayout (top nav + footer) */}
<Route path="/planetora" element={<PlanetoraPage />} />
<Route element={<FundraiserLayout />}>
<Route path="/" element={<CampaignsPage />} />
<Route path="/feed" element={<Index />} />
<Route path="/campaigns" element={<Navigate to="/" replace />} />
<Route path="/campaigns/new" element={<CreateCampaignPage />} />
<Route path="/campaigns/all" element={<AllCampaignsPage />} />
<Route path="/notifications" element={<NotificationsPage />} />
<Route path="/messages" element={<MessagesPage />} />
<Route path="/search" element={<SearchPage />} />
@@ -170,21 +184,19 @@ export function AppRouter() {
<Route path="/settings/appearance" element={<AppearanceSettingsPage />} />
<Route path="/settings/profile" element={<ProfileSettings />} />
<Route path="/settings/feed" element={<ContentSettingsPage />} />
<Route path="/settings/content" element={<ContentPage />} />
<Route path="/settings/wallet" element={<WalletSettingsPage />} />
<Route
path="/settings/notifications"
element={<NotificationSettings />}
/>
<Route path="/settings/messaging" element={<MessagingSettingsPage />} />
<Route
path="/settings/advanced"
element={<AdvancedSettingsPage />}
/>
<Route path="/settings/magic" element={<MagicSettingsPage />} />
<Route path="/settings/network" element={<NetworkSettingsPage />} />
<Route path="/lists" element={<UserListsPage />} />
<Route path="/events" element={<EventsFeedPage />} />
<Route path="/events/new" element={<CreateEventPage />} />
<Route path="/photos" element={<PhotosFeedPage />} />
<Route path="/videos" element={<VideosFeedPage />} />
{/* /streams redirects to /videos for backward compatibility */}
@@ -268,6 +280,8 @@ export function AppRouter() {
}
/>
<Route path="/wallet" element={<WalletPage />} />
<Route path="/wallet/recovery" element={<WalletRecoveryPage />} />
<Route path="/bitcoin" element={<Navigate to="/wallet" replace />} />
<Route path="/bookmarks" element={<BookmarksPage />} />
<Route path="/ai-chat" element={<AIChatPage />} />
<Route path="/verified" element={<VerifiedPage />} />
@@ -278,10 +292,13 @@ export function AppRouter() {
<Route path="/bluesky" element={<BlueskyPage />} />
<Route path="/wikipedia" element={<WikipediaPage />} />
<Route path="/communities" element={<CommunitiesPage />} />
<Route path="/communities/new" element={<CreateCommunityPage />} />
<Route path="/letters" element={<LettersPage />} />
<Route path="/letters/compose" element={<LetterComposePage />} />
<Route path="/settings/letters" element={<LetterPreferencesPage />} />
<Route path="/help" element={<HelpPage />} />
<Route path="/help/donors" element={<DonorGuidePage />} />
<Route path="/help/activists" element={<ActivistGuidePage />} />
<Route path="/privacy" element={<PrivacyPolicyPage />} />
<Route path="/safety" element={<CSAEPolicyPage />} />
<Route path="/changelog" element={<ChangelogPage />} />
@@ -292,6 +309,9 @@ export function AppRouter() {
/>
<Route path="/i/*" element={<ExternalContentPage />} />
<Route path="/actions" element={<ActionsPage />} />
<Route path="/actions/new" element={<CreateActionPage />} />
<Route path="/pledges" element={<ActionsPage />} />
<Route path="/pledges/new" element={<CreateActionPage />} />
<Route path="/agent" element={<AIChatPage />} />
<Route path="/organizers" element={<OrganizersPage />} />
<Route path="/dashboard" element={<EventDashboardPage />} />
+114
View File
@@ -0,0 +1,114 @@
import { Link } from 'react-router-dom';
import { format } from 'date-fns';
import { nip19 } from 'nostr-tools';
import type { NostrEvent } from '@nostrify/nostrify';
import { Camera, Clock, DollarSign, Info, Megaphone, Palette } from 'lucide-react';
import { parseAction, type Action } from '@/hooks/useActions';
import { useBtcPrice } from '@/hooks/useBtcPrice';
import { countryCodeToFlag, getGeoDisplayName } from '@/lib/countries';
import { CountryFlag } from '@/components/CountryFlag';
import { DEFAULT_COVER_IMAGE } from '@/lib/defaultActionCovers';
import { formatSats, satsToUSDWhole } from '@/lib/bitcoin';
import { cn } from '@/lib/utils';
const ACTION_ICONS = {
photo: Camera,
art: Palette,
info: Info,
action: Megaphone,
} as const;
function actionNaddr(action: Action): string {
return nip19.naddrEncode({
kind: 36639,
pubkey: action.pubkey,
identifier: action.id,
});
}
export function ActionContent({ event, compact = true }: { event: NostrEvent; compact?: boolean }) {
const { data: btcPrice } = useBtcPrice();
const action = parseAction(event);
if (!action) return null;
const Icon = ACTION_ICONS[action.type];
const now = Date.now() / 1000;
const startTime = action.startTime ?? action.createdAt;
const isUpcoming = startTime > now;
const isExpired = !!action.deadline && action.deadline <= now;
const coverImage = action.image ?? DEFAULT_COVER_IMAGE;
const href = `/${actionNaddr(action)}`;
return (
<Link
to={href}
className={cn(
'mt-2 block overflow-hidden rounded-xl border border-border bg-card transition-colors hover:bg-muted/30',
isExpired && 'opacity-75',
)}
onClick={(e) => e.stopPropagation()}
>
<div className={cn('relative overflow-hidden bg-muted', compact ? 'h-36' : 'h-56')}>
<img
src={coverImage}
alt={action.title}
className={cn('h-full w-full object-cover transition-transform duration-300 hover:scale-[1.02]', isExpired && 'grayscale')}
loading="lazy"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-black/10 to-transparent" />
{action.countryCode && (
<CountryFlag
code={action.countryCode}
emoji={countryCodeToFlag(action.countryCode)}
label={getGeoDisplayName(action.countryCode)}
className="absolute left-3 top-3 text-2xl drop-shadow-md"
/>
)}
<div className="absolute bottom-3 left-3 right-3 flex items-center justify-between gap-2 text-white">
<span className="inline-flex items-center gap-1.5 rounded-full bg-black/45 px-2.5 py-1 text-xs font-semibold backdrop-blur-sm">
<Icon className="size-3.5" />
Pledge
</span>
{isExpired ? (
<span className="inline-flex items-center gap-1 rounded-full bg-black/45 px-2.5 py-1 text-xs font-medium backdrop-blur-sm">
<Clock className="size-3" /> Expired
</span>
) : isUpcoming ? (
<span className="inline-flex items-center gap-1 rounded-full bg-black/45 px-2.5 py-1 text-xs font-medium backdrop-blur-sm">
<Clock className="size-3" /> Starts {format(startTime * 1000, 'MMM d')}
</span>
) : action.deadline ? (
<span className="inline-flex items-center gap-1 rounded-full bg-black/45 px-2.5 py-1 text-xs font-medium backdrop-blur-sm">
<Clock className="size-3" /> Due {format(action.deadline * 1000, 'MMM d')}
</span>
) : null}
</div>
</div>
<div className="space-y-2 p-3">
<div className="flex items-start gap-2">
<Megaphone className="mt-0.5 size-5 shrink-0 text-primary" />
<h3 className="line-clamp-2 text-base font-bold leading-tight">{action.title}</h3>
</div>
{action.description.trim() && (
<p className="line-clamp-3 text-sm leading-relaxed text-muted-foreground">
{action.description}
</p>
)}
<div className="flex items-center gap-2 text-sm">
<DollarSign className="size-4 shrink-0 text-primary" />
<span className="font-semibold">
{btcPrice ? satsToUSDWhole(action.bounty, btcPrice) : `${formatSats(action.bounty)} sats`}
</span>
{btcPrice && <span className="text-xs text-muted-foreground">~{formatSats(action.bounty)} sats</span>}
{action.countryCode && (
<>
<span className="text-muted-foreground/50">·</span>
<span className="truncate text-xs text-muted-foreground">{getGeoDisplayName(action.countryCode)}</span>
</>
)}
</div>
</div>
</Link>
);
}
-841
View File
@@ -1,841 +0,0 @@
import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
import { UserPlus, Loader2, X, Search, Crown, Users, PartyPopper } from 'lucide-react';
import { useNostr } from '@nostrify/react';
import { useQueryClient } from '@tanstack/react-query';
import type { NostrEvent } from '@nostrify/nostrify';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import { BadgeThumbnail } from '@/components/BadgeThumbnail';
import { ImageUploadField } from '@/components/ImageUploadField';
import { EmojifiedText } from '@/components/CustomEmoji';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useToast } from '@/hooks/useToast';
import { useBadgeDefinitions } from '@/hooks/useBadgeDefinitions';
import { useSearchProfiles, type SearchProfile } from '@/hooks/useSearchProfiles';
import { useSearchPeopleLists, type PeopleListSearchResult } from '@/hooks/useSearchPeopleLists';
import { PortalContainerProvider } from '@/hooks/usePortalContainer';
import { parseAuthorEvent } from '@/hooks/useAuthor';
import { genUserName } from '@/lib/genUserName';
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import {
COMMUNITY_DEFINITION_KIND,
BADGE_DEFINITION_KIND,
BADGE_AWARD_KIND,
EMPTY_MODERATION,
type CommunityMember,
type CommunityMembership,
type CommunityModeration,
type ParsedCommunity,
} from '@/lib/communityUtils';
// ── Types ─────────────────────────────────────────────────────────────────────
type MemberRole = 'moderator' | 'member';
interface PendingMember {
profile: SearchProfile;
role: MemberRole;
}
interface BadgeRef {
pubkey: string;
identifier: string;
}
interface CommunityMembersCacheValue {
membership: CommunityMembership;
moderation: CommunityModeration;
rankMap: Map<string, CommunityMember>;
}
interface AddMemberDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** The raw community definition event. */
communityEvent: NostrEvent;
/** Parsed community data. */
community: ParsedCommunity;
/** Whether the current user is the founder (can add moderators). */
isFounder: boolean;
/** Existing active members and moderators, excluded from duplicate adds. */
existingMemberPubkeys: string[];
}
interface AddMemberPanelProps {
/** The raw community definition event. */
communityEvent: NostrEvent;
/** Parsed community data. */
community: ParsedCommunity;
/** Whether the current user is the founder (can add moderators). */
isFounder: boolean;
/** Existing active members and moderators, excluded from duplicate adds. */
existingMemberPubkeys: string[];
/** Called after a successful publish so the host (dialog/page) can close or refresh. */
onComplete?: () => void;
}
function parseBadgeATag(aTag: string | undefined): BadgeRef | undefined {
if (!aTag) return undefined;
const [kind, pubkey, ...identifierParts] = aTag.split(':');
const identifier = identifierParts.join(':');
if (kind !== String(BADGE_DEFINITION_KIND) || !pubkey || !identifier) return undefined;
return { pubkey, identifier };
}
function isHexPubkey(value: string): boolean {
return /^[0-9a-f]{64}$/i.test(value);
}
function makeFallbackProfile(pubkey: string): SearchProfile {
return {
pubkey,
metadata: {},
event: {
id: '',
pubkey,
created_at: 0,
kind: 0,
tags: [],
content: '{}',
sig: '',
},
};
}
function profileFromEvent(event: NostrEvent): SearchProfile {
const parsed = parseAuthorEvent(event);
return { pubkey: event.pubkey, metadata: parsed.metadata ?? {}, event };
}
// ── Component ─────────────────────────────────────────────────────────────────
export function AddMemberDialog({
open,
onOpenChange,
communityEvent,
community,
isFounder,
existingMemberPubkeys,
}: AddMemberDialogProps) {
const { user } = useCurrentUser();
const [portalContainer, setPortalContainer] = useState<HTMLElement | undefined>(undefined);
const dialogContentRef = useCallback((node: HTMLElement | null) => {
setPortalContainer(node ?? undefined);
}, []);
if (!user) return null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent ref={dialogContentRef} className="sm:max-w-md gap-0 p-0 overflow-visible">
<PortalContainerProvider value={portalContainer}>
<DialogHeader className="px-5 pt-5 pb-3">
<DialogTitle className="flex items-center gap-2">
<UserPlus className="size-5 text-primary" />
Add Members
</DialogTitle>
<DialogDescription>Add to community</DialogDescription>
</DialogHeader>
<ScrollArea className="max-h-[60vh]">
<div className="px-5 pb-5">
<AddMemberPanel
communityEvent={communityEvent}
community={community}
isFounder={isFounder}
existingMemberPubkeys={existingMemberPubkeys}
onComplete={() => onOpenChange(false)}
/>
</div>
</ScrollArea>
</PortalContainerProvider>
</DialogContent>
</Dialog>
);
}
/**
* Inline form that searches for people and adds them as community members or
* moderators. Pulled out of `AddMemberDialog` so the same flow can be
* embedded inside other surfaces — e.g. the members dialog on
* `CommunityDetailPage` — without nesting a second `Dialog`.
*/
export function AddMemberPanel({
communityEvent,
community,
isFounder,
existingMemberPubkeys,
onComplete,
}: AddMemberPanelProps) {
const { user } = useCurrentUser();
const { nostr } = useNostr();
const { toast } = useToast();
const queryClient = useQueryClient();
// Form state
const [pendingMembers, setPendingMembers] = useState<PendingMember[]>([]);
const [badgeImageUrl, setBadgeImageUrl] = useState('');
const [isBadgeImageUploading, setIsBadgeImageUploading] = useState(false);
const [isPublishing, setIsPublishing] = useState(false);
// Mutations
const { mutateAsync: publishEvent } = useNostrPublish();
// Does this community already have a member badge definition?
const existingBadgeATag = community.memberBadgeATag;
const hasBadge = !!existingBadgeATag;
const existingBadgeRef = useMemo(() => parseBadgeATag(existingBadgeATag), [existingBadgeATag]);
const existingBadgeRefs = useMemo(() => existingBadgeRef ? [existingBadgeRef] : [], [existingBadgeRef]);
const { badgeMap, isLoading: isBadgeLoading, isError: isBadgeError } = useBadgeDefinitions(existingBadgeRefs);
const existingBadge = existingBadgeATag ? badgeMap.get(existingBadgeATag) : undefined;
// Are there any pending members with the "member" role?
const hasPendingMembers = pendingMembers.some((m) => m.role === 'member');
// Will we need to create a badge? (members added + no badge exists yet)
const needsBadgeCreation = hasPendingMembers && !hasBadge;
const resetForm = useCallback(() => {
setPendingMembers([]);
setBadgeImageUrl('');
setIsBadgeImageUploading(false);
setIsPublishing(false);
}, []);
// ── People management ─────────────────────────────────────────────────────
const addPerson = useCallback((profile: SearchProfile) => {
if (!user) return;
if (profile.pubkey === community.founderPubkey) {
toast({ title: 'Already the founder' });
return;
}
if (existingMemberPubkeys.includes(profile.pubkey)) {
toast({ title: 'Already in the community' });
return;
}
if (pendingMembers.some((m) => m.profile.pubkey === profile.pubkey)) {
toast({ title: 'Already added' });
return;
}
setPendingMembers((prev) => [...prev, { profile, role: 'member' }]);
}, [user, community.founderPubkey, existingMemberPubkeys, pendingMembers, toast]);
const addPeople = useCallback((profiles: SearchProfile[], sourceTitle?: string) => {
if (!user) return;
const excluded = new Set([
community.founderPubkey,
...existingMemberPubkeys,
...pendingMembers.map((m) => m.profile.pubkey),
]);
const nextProfiles: SearchProfile[] = [];
for (const profile of profiles) {
if (excluded.has(profile.pubkey)) continue;
excluded.add(profile.pubkey);
nextProfiles.push(profile);
}
if (nextProfiles.length === 0) {
toast({ title: 'No new people to add', description: 'Everyone in that follow pack is already included.' });
return;
}
setPendingMembers((prev) => [...prev, ...nextProfiles.map((profile) => ({ profile, role: 'member' as const }))]);
if (sourceTitle) {
toast({ title: `Added ${nextProfiles.length} from ${sourceTitle}` });
}
}, [user, community.founderPubkey, existingMemberPubkeys, pendingMembers, toast]);
const removePerson = useCallback((pubkey: string) => {
setPendingMembers((prev) => prev.filter((m) => m.profile.pubkey !== pubkey));
}, []);
const setRole = useCallback((pubkey: string, role: MemberRole) => {
if (!isFounder) return; // Only founder can appoint moderators
setPendingMembers((prev) => prev.map((m) =>
m.profile.pubkey === pubkey
? { ...m, role }
: m,
));
}, [isFounder]);
const applyOptimisticMembership = useCallback((members: PendingMember[], awardEvents: Map<string, NostrEvent>) => {
queryClient.setQueryData<CommunityMembersCacheValue>(['community-members', community.aTag], (prev) => {
const moderation = prev?.moderation ?? EMPTY_MODERATION;
const rankMap = new Map(prev?.rankMap ?? []);
const membershipByPubkey = new Map(
(prev?.membership.members ?? []).map((member) => [member.pubkey, member] as const),
);
const seedRankZero = (pubkey: string) => {
if (moderation.bannedPubkeys.has(pubkey)) return;
const member: CommunityMember = { pubkey, rank: 0 };
if (!membershipByPubkey.has(pubkey)) membershipByPubkey.set(pubkey, member);
if (!rankMap.has(pubkey)) rankMap.set(pubkey, member);
};
seedRankZero(community.founderPubkey);
community.moderatorPubkeys.forEach(seedRankZero);
for (const pending of members) {
if (moderation.bannedPubkeys.has(pending.profile.pubkey)) continue;
const nextMember: CommunityMember = pending.role === 'moderator'
? { pubkey: pending.profile.pubkey, rank: 0 }
: {
pubkey: pending.profile.pubkey,
rank: 1,
awardEvent: awardEvents.get(pending.profile.pubkey),
awardedBy: user?.pubkey,
};
const current = membershipByPubkey.get(nextMember.pubkey);
if (!current || nextMember.rank < current.rank) {
membershipByPubkey.set(nextMember.pubkey, nextMember);
}
const currentRank = rankMap.get(nextMember.pubkey);
if (!currentRank || nextMember.rank < currentRank.rank) {
rankMap.set(nextMember.pubkey, nextMember);
}
}
const membership: CommunityMembership = {
members: Array.from(membershipByPubkey.values()).sort((a, b) => a.rank - b.rank),
};
return { membership, moderation, rankMap };
});
}, [community.aTag, community.founderPubkey, community.moderatorPubkeys, queryClient, user?.pubkey]);
// ── Publish ───────────────────────────────────────────────────────────────
const handleSubmit = useCallback(async () => {
if (!user || pendingMembers.length === 0) return;
if (isBadgeImageUploading) {
toast({ title: 'Image is still uploading', description: 'Please wait for the upload to finish.' });
return;
}
if (badgeImageUrl.trim() && !sanitizeUrl(badgeImageUrl.trim())) {
toast({ title: 'Image URL must be a valid https URL', variant: 'destructive' });
return;
}
if (needsBadgeCreation && !isFounder) {
toast({ title: 'Member badge is missing', description: 'Only the founder can initialize community membership.', variant: 'destructive' });
return;
}
setIsPublishing(true);
try {
const newModerators = pendingMembers.filter((m) => m.role === 'moderator');
const newMembers = pendingMembers.filter((m) => m.role === 'member');
let badgeATag = existingBadgeATag;
// Step 1: Create badge definition if needed
if (newMembers.length > 0 && !hasBadge) {
const badgeDTag = `${community.dTag}-member`;
const existingBadge = await nostr.query([{
kinds: [BADGE_DEFINITION_KIND],
authors: [user.pubkey],
'#d': [badgeDTag],
limit: 1,
}]);
if (existingBadge.length > 0) {
toast({
title: 'Member badge ID already in use',
description: 'This community needs a member badge, but that badge identifier already exists on your account.',
variant: 'destructive',
});
setIsPublishing(false);
return;
}
const badgeTags: string[][] = [
['d', badgeDTag],
['name', 'Member'],
['description', `Member of ${community.name}`],
];
const sanitizedBadgeImage = sanitizeUrl(badgeImageUrl.trim());
if (sanitizedBadgeImage) {
badgeTags.push(['image', sanitizedBadgeImage, '1024x1024']);
}
badgeTags.push(['alt', `Badge definition: Member of ${community.name}`]);
const badgeEvent = await publishEvent({
kind: BADGE_DEFINITION_KIND,
content: '',
tags: badgeTags,
} as Omit<NostrEvent, 'id' | 'pubkey' | 'sig'>);
badgeATag = `${BADGE_DEFINITION_KIND}:${badgeEvent.pubkey}:${badgeDTag}`;
}
// Step 2: Republish community definition if needed
// Needed when: adding moderators (new p tags) OR badge was just created (new a tag)
const needsCommunityUpdate = newModerators.length > 0 || (newMembers.length > 0 && !hasBadge);
if (needsCommunityUpdate) {
// Fetch fresh community event to avoid stale overwrites
const prev = await fetchFreshEvent(nostr, {
kinds: [COMMUNITY_DEFINITION_KIND],
authors: [communityEvent.pubkey],
'#d': [community.dTag],
});
const baseTags = prev?.tags ?? communityEvent.tags;
const updatedTags = [...baseTags];
// Add new moderator p tags
for (const mod of newModerators) {
// Don't add if already exists
const exists = updatedTags.some(
([n, v, , role]) => n === 'p' && v === mod.profile.pubkey && role === 'moderator',
);
if (!exists) {
updatedTags.push(['p', mod.profile.pubkey, '', 'moderator']);
}
}
// Add badge a tag if badge was just created
if (badgeATag && !hasBadge) {
updatedTags.push(['a', badgeATag, '', 'member']);
}
const updatedEvent = await publishEvent({
kind: COMMUNITY_DEFINITION_KIND,
content: prev?.content ?? '',
tags: updatedTags,
prev: prev ?? undefined,
} as Omit<NostrEvent, 'id' | 'pubkey' | 'sig'> & { prev?: NostrEvent });
queryClient.setQueryData(
['addr-event', COMMUNITY_DEFINITION_KIND, communityEvent.pubkey, community.dTag],
updatedEvent,
);
queryClient.setQueryData(['event', updatedEvent.id], updatedEvent);
}
// Step 3: Publish badge awards for each member
const memberAwardEvents = new Map<string, NostrEvent>();
if (newMembers.length > 0 && badgeATag) {
for (const member of newMembers) {
const awardEvent = await publishEvent({
kind: BADGE_AWARD_KIND,
content: '',
tags: [
['a', badgeATag],
['p', member.profile.pubkey],
['alt', `Badge award: Member in ${community.name}`],
],
} as Omit<NostrEvent, 'id' | 'pubkey' | 'sig'>);
memberAwardEvents.set(member.profile.pubkey, awardEvent);
}
}
applyOptimisticMembership(pendingMembers, memberAwardEvents);
queryClient.invalidateQueries({ queryKey: ['community-members', community.aTag], refetchType: 'inactive' });
queryClient.invalidateQueries({ queryKey: ['community-activity-feed'], exact: false });
queryClient.invalidateQueries({ queryKey: ['my-communities'], exact: false });
if (!hasBadge && newMembers.length > 0) {
queryClient.invalidateQueries({ queryKey: ['badge-feed'] });
}
const addedCount = pendingMembers.length;
toast({ title: `Added ${addedCount} ${addedCount === 1 ? 'person' : 'people'} to the community` });
resetForm();
onComplete?.();
} catch (err) {
toast({
title: 'Failed to add members',
description: err instanceof Error ? err.message : 'Please try again.',
variant: 'destructive',
});
} finally {
setIsPublishing(false);
}
}, [
user, pendingMembers, existingBadgeATag, hasBadge, needsBadgeCreation, isFounder, community, communityEvent,
badgeImageUrl, nostr, publishEvent, queryClient, toast, resetForm, onComplete, applyOptimisticMembership, isBadgeImageUploading,
]);
if (!user) return null;
return (
<div className="space-y-4">
{/* People search */}
<div className="space-y-1.5">
<Label>Search</Label>
<PersonSearch
onAdd={addPerson}
onAddMany={addPeople}
excludePubkeys={[
community.founderPubkey,
...existingMemberPubkeys,
...pendingMembers.map((m) => m.profile.pubkey),
]}
/>
</div>
{/* Pending members list */}
{pendingMembers.length > 0 && (
<div className="space-y-1.5">
<Label>
People to add
<span className="text-muted-foreground font-normal ml-1">({pendingMembers.length})</span>
</Label>
<div className="space-y-1">
{pendingMembers.map((pm) => (
<PendingMemberChip
key={pm.profile.pubkey}
pending={pm}
onRemove={removePerson}
onRoleChange={isFounder ? setRole : undefined}
/>
))}
</div>
</div>
)}
{hasPendingMembers && (
<div className="space-y-2">
<Label>Member badge</Label>
{hasBadge ? (
<div className="rounded-xl border border-border/60 bg-secondary/30 p-3">
{isBadgeError ? (
<p className="text-sm text-destructive">Failed to load the current member badge.</p>
) : isBadgeLoading ? (
<div className="flex items-center gap-3">
<div className="size-12 animate-pulse rounded-lg bg-muted" />
<div className="space-y-2">
<div className="h-4 w-28 animate-pulse rounded bg-muted" />
<div className="h-3 w-44 animate-pulse rounded bg-muted" />
</div>
</div>
) : existingBadge ? (
<div className="flex items-center gap-3">
<BadgeThumbnail badge={existingBadge} size={48} className="shrink-0" />
<div className="min-w-0">
<p className="text-sm font-medium truncate">{existingBadge.name}</p>
<p className="text-xs text-muted-foreground line-clamp-2">
{existingBadge.description || 'Selected members will receive this badge.'}
</p>
</div>
</div>
) : (
<p className="text-sm text-destructive">The configured member badge could not be found.</p>
)}
</div>
) : (
<ImageUploadField
id="member-badge-image"
label={<>Create Member Badge Image <span className="text-muted-foreground font-normal">(optional)</span></>}
value={badgeImageUrl}
onChange={setBadgeImageUrl}
onUploadingChange={setIsBadgeImageUploading}
uploadToastTitle="Badge image uploaded"
previewAlt="Badge preview"
objectFit="contain"
dropAreaClassName="min-h-24"
/>
)}
</div>
)}
{/* Submit button */}
<Button
onClick={handleSubmit}
disabled={pendingMembers.length === 0 || isPublishing || isBadgeImageUploading}
className="w-full gap-2"
>
{isPublishing ? (
<><Loader2 className="size-4 animate-spin" /> Adding...</>
) : (
<><UserPlus className="size-4" /> Add {pendingMembers.length || ''} {pendingMembers.length === 1 ? 'Person' : pendingMembers.length > 1 ? 'People' : 'Members'}</>
)}
</Button>
</div>
);
}
// ── Sub-Components ────────────────────────────────────────────────────────────
/** Inline type-ahead person search. */
function PersonSearch({
onAdd,
onAddMany,
excludePubkeys,
}: {
onAdd: (profile: SearchProfile) => void;
onAddMany: (profiles: SearchProfile[], sourceTitle?: string) => void;
excludePubkeys: string[];
}) {
const { nostr } = useNostr();
const { toast } = useToast();
const [query, setQuery] = useState('');
const [dropdownOpen, setDropdownOpen] = useState(false);
const [isAddingPack, setIsAddingPack] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const { data: profiles, isFetching } = useSearchProfiles(query);
const { data: peopleLists, isFetching: isFetchingPeopleLists } = useSearchPeopleLists(query);
const excludeSet = useMemo(() => new Set(excludePubkeys), [excludePubkeys]);
const filteredProfiles = useMemo(
() => (profiles ?? []).filter((p) => !excludeSet.has(p.pubkey)),
[profiles, excludeSet],
);
const filteredPeopleLists = useMemo(
() => (peopleLists ?? []).filter((pack) => pack.pubkeys.some((pubkey) => isHexPubkey(pubkey) && !excludeSet.has(pubkey.toLowerCase()))),
[peopleLists, excludeSet],
);
const hasResults = filteredProfiles.length > 0 || filteredPeopleLists.length > 0;
const isSearching = isFetching || isFetchingPeopleLists || isAddingPack;
useEffect(() => {
if (query.trim().length > 0 && hasResults) {
setDropdownOpen(true);
} else if (query.trim().length === 0) {
setDropdownOpen(false);
}
}, [hasResults, query]);
const handleSelect = useCallback((profile: SearchProfile) => {
onAdd(profile);
setQuery('');
setDropdownOpen(false);
inputRef.current?.focus();
}, [onAdd]);
const handleSelectPeopleList = useCallback(async (pack: PeopleListSearchResult) => {
const eligiblePubkeys = Array.from(new Set(
pack.pubkeys
.map((pubkey) => pubkey.toLowerCase())
.filter((pubkey) => isHexPubkey(pubkey) && !excludeSet.has(pubkey)),
));
if (eligiblePubkeys.length === 0) {
toast({ title: 'No new people to add', description: 'Everyone in that follow pack is already included.' });
return;
}
if (eligiblePubkeys.length > 20 && !window.confirm(`Add ${eligiblePubkeys.length} people from ${pack.title}?`)) {
return;
}
setIsAddingPack(true);
try {
const events = await nostr.query(
[{ kinds: [0], authors: eligiblePubkeys, limit: eligiblePubkeys.length }],
{ signal: AbortSignal.timeout(8000) },
);
const latestByPubkey = new Map<string, NostrEvent>();
for (const event of events) {
const existing = latestByPubkey.get(event.pubkey);
if (!existing || event.created_at > existing.created_at) latestByPubkey.set(event.pubkey, event);
}
const profilesToAdd = eligiblePubkeys.map((pubkey) => {
const event = latestByPubkey.get(pubkey);
return event ? profileFromEvent(event) : makeFallbackProfile(pubkey);
});
onAddMany(profilesToAdd, pack.title);
setQuery('');
setDropdownOpen(false);
inputRef.current?.focus();
} catch (error) {
toast({
title: 'Failed to load follow pack members',
description: error instanceof Error ? error.message : 'Please try again.',
variant: 'destructive',
});
} finally {
setIsAddingPack(false);
}
}, [excludeSet, nostr, onAddMany, toast]);
return (
<Popover open={dropdownOpen} onOpenChange={setDropdownOpen}>
<PopoverTrigger asChild>
<div className="relative flex items-center">
<Search className="absolute left-3 size-4 text-muted-foreground pointer-events-none" />
{isSearching && query.trim() && (
<Loader2 className="absolute right-3 size-4 text-muted-foreground animate-spin" />
)}
<Input
ref={inputRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
onFocus={() => {
if (query.trim().length > 0 && hasResults) {
setDropdownOpen(true);
}
}}
placeholder="Search people..."
className="pl-10 pr-10 rounded-full bg-secondary border-0 focus-visible:ring-0 focus-visible:ring-offset-0 h-9 text-sm"
autoComplete="off"
/>
</div>
</PopoverTrigger>
<PopoverContent
align="start"
side="bottom"
sideOffset={6}
onOpenAutoFocus={(e) => e.preventDefault()}
className="z-[270] w-[var(--radix-popover-trigger-width)] rounded-xl border-border p-0 shadow-lg overflow-hidden"
>
{hasResults ? (
<div className="max-h-[200px] overflow-y-auto py-1">
{filteredProfiles.map((profile) => (
<SearchResultItem key={profile.pubkey} profile={profile} onClick={handleSelect} />
))}
{filteredPeopleLists.map((pack) => (
<PeopleListSearchResultItem key={`${pack.event.kind}:${pack.event.pubkey}:${pack.event.tags.find(([name]) => name === 'd')?.[1] ?? pack.event.id}`} pack={pack} onClick={handleSelectPeopleList} />
))}
</div>
) : query.trim().length >= 2 && !isSearching ? (
<div className="py-4 text-center text-sm text-muted-foreground">
No people or follow packs found
</div>
) : null}
</PopoverContent>
</Popover>
);
}
/** A follow pack / follow set search result row. */
function PeopleListSearchResultItem({ pack, onClick }: { pack: PeopleListSearchResult; onClick: (pack: PeopleListSearchResult) => void }) {
return (
<button
className="w-full flex items-center gap-3 px-3 py-2 text-left transition-colors cursor-pointer hover:bg-secondary/60"
onClick={() => onClick(pack)}
onMouseDown={(e) => e.preventDefault()}
>
<div className="size-8 shrink-0 rounded-full bg-primary/10 text-primary flex items-center justify-center">
<PartyPopper className="size-4" />
</div>
<div className="flex-1 min-w-0">
<span className="text-sm font-medium truncate block">{pack.title}</span>
<span className="text-xs text-muted-foreground truncate block">
Follow pack · {pack.pubkeys.length} people
</span>
</div>
</button>
);
}
/** A pending member chip with role toggle and remove button. */
function PendingMemberChip({
pending,
onRemove,
onRoleChange,
}: {
pending: PendingMember;
onRemove: (pubkey: string) => void;
onRoleChange?: (pubkey: string, role: MemberRole) => void;
}) {
const { profile, role } = pending;
const { metadata, pubkey } = profile;
const displayName = metadata.display_name || metadata.name || genUserName(pubkey);
return (
<div className="flex items-center gap-2 p-2 rounded-lg bg-secondary/30 border border-border/50">
<Avatar className="size-7 shrink-0">
<AvatarImage src={metadata.picture} alt={displayName} />
<AvatarFallback className="bg-primary/20 text-primary text-[10px]">
{displayName[0]?.toUpperCase() || '?'}
</AvatarFallback>
</Avatar>
<span className="flex-1 text-sm truncate">
<EmojifiedText tags={profile.event.tags}>{displayName}</EmojifiedText>
</span>
{onRoleChange ? (
<ToggleGroup
type="single"
value={role}
onValueChange={(value) => {
if (value === 'member' || value === 'moderator') onRoleChange(pubkey, value);
}}
className="shrink-0 rounded-full bg-muted p-0.5"
aria-label={`Role for ${displayName}`}
>
<ToggleGroupItem value="member" size="sm" className="h-7 rounded-full px-2 text-xs data-[state=on]:bg-primary data-[state=on]:text-primary-foreground" aria-label="Member">
<Users className="size-3 sm:mr-1" />
<span className="hidden sm:inline">Member</span>
</ToggleGroupItem>
<ToggleGroupItem value="moderator" size="sm" className="h-7 rounded-full px-2 text-xs data-[state=on]:bg-amber-500 data-[state=on]:text-white" aria-label="Moderator">
<Crown className="size-3 sm:mr-1" />
<span className="hidden sm:inline">Moderator</span>
</ToggleGroupItem>
</ToggleGroup>
) : (
<span className="flex shrink-0 items-center gap-1 rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
<Users className="size-3" />
Member
</span>
)}
<button
type="button"
onClick={() => onRemove(pubkey)}
className="shrink-0 size-6 rounded-full hover:bg-destructive/10 flex items-center justify-center transition-colors"
title="Remove"
>
<X className="size-3.5 text-muted-foreground hover:text-destructive" />
</button>
</div>
);
}
/** A profile search result row. */
function SearchResultItem({ profile, onClick }: { profile: SearchProfile; onClick: (profile: SearchProfile) => void }) {
const { metadata, pubkey } = profile;
const displayName = metadata.display_name || metadata.name || genUserName(pubkey);
return (
<button
className="w-full flex items-center gap-3 px-3 py-2 text-left transition-colors cursor-pointer hover:bg-secondary/60"
onClick={() => onClick(profile)}
onMouseDown={(e) => e.preventDefault()}
>
<Avatar className="size-8 shrink-0">
<AvatarImage src={metadata.picture} alt={displayName} />
<AvatarFallback className="bg-primary/20 text-primary text-xs">
{displayName[0]?.toUpperCase() || '?'}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<span className="text-sm font-medium truncate block">
<EmojifiedText tags={profile.event.tags}>{displayName}</EmojifiedText>
</span>
{metadata.nip05 && (
<span className="text-xs text-muted-foreground truncate block">
{metadata.nip05.startsWith('_@') ? metadata.nip05.slice(2) : metadata.nip05}
</span>
)}
</div>
</button>
);
}
+3 -3
View File
@@ -15,7 +15,7 @@ import { DEFAULT_SYSTEM_PROMPT_TEMPLATE } from '@/lib/aiChatSystemPrompt';
/** Hardcoded default values for Agent provider fields. Used for reset buttons. */
const DEFAULT_AI_BASE_URL = 'https://ai.shakespeare.diy/v1';
const DEFAULT_AI_MODEL = 'grok-4.1-fast';
const DEFAULT_AI_MODEL = 'google/gemma-4-26b';
/** The build-time default DSN from the environment variable. */
const DEFAULT_SENTRY_DSN = import.meta.env.VITE_SENTRY_DSN || '';
@@ -195,7 +195,7 @@ export function AdvancedSettings() {
Model
</Label>
<p className="text-xs text-muted-foreground mt-1 mb-2">
Model ID sent to the provider (e.g. <code className="bg-muted px-1 rounded">grok-4.1-fast</code>, <code className="bg-muted px-1 rounded">claude-opus-4.6</code>, <code className="bg-muted px-1 rounded">gpt-4o</code>).
Model ID sent to the provider (e.g. <code className="bg-muted px-1 rounded">google/gemma-4-26b</code>, <code className="bg-muted px-1 rounded">claude-opus-4.6</code>, <code className="bg-muted px-1 rounded">gpt-4o</code>).
</p>
<Input
id="ai-model"
@@ -521,7 +521,7 @@ export function AdvancedSettings() {
<h3 className="text-sm font-medium">Delete Account</h3>
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
Permanently delete your data from the network, including your profile,
posts, reactions, and direct messages. This action is irreversible.
posts, and reactions. This action is irreversible.
</p>
</div>
<Button
+4 -2
View File
@@ -33,6 +33,8 @@ interface ArcBackgroundProps {
variant: 'down' | 'up' | 'rect';
/** Extra classes on the <svg> element. */
className?: string;
/** Extra classes on the filled background path. */
fillClassName?: string;
}
/**
@@ -40,7 +42,7 @@ interface ArcBackgroundProps {
* MobileBottomNav. Draws a semi-transparent filled shape (rectangle + optional
* curved arc) as a single path so there are no sub-pixel seams between layers.
*/
export function ArcBackground({ variant, className }: ArcBackgroundProps) {
export function ArcBackground({ variant, className, fillClassName }: ArcBackgroundProps) {
const path = variant === 'down' ? ARC_DOWN_PATH : variant === 'up' ? ARC_UP_PATH : RECT_PATH;
const hasArc = variant !== 'rect';
@@ -57,7 +59,7 @@ export function ArcBackground({ variant, className }: ArcBackgroundProps) {
preserveAspectRatio="none"
style={hasArc ? (variant === 'up' ? arcUpHeightStyle : arcDownHeightStyle) : fullHeightStyle}
>
<path d={path} className="fill-background/85" />
<path d={path} className={cn('fill-background/85', fillClassName)} />
{variant === 'down' && <path d="M0,34 L50,46 L100,34" fill="none" className="stroke-border" strokeWidth="1" vectorEffect="non-scaling-stroke" strokeLinejoin="round" />}
{variant === 'up' && <path d="M0,40 L50,16 L100,40" fill="none" className="stroke-border" strokeWidth="1" vectorEffect="non-scaling-stroke" strokeLinejoin="round" />}
</svg>
+27 -46
View File
@@ -18,42 +18,27 @@ import {
} from '@/lib/communityUtils';
// ── Props ─────────────────────────────────────────────────────────────────────
//
// Only content-level bans remain. Agora's organization trust model has no
// "member" tier any more, so banning a user wholesale is no longer
// modeled — hide each unwanted post individually instead.
interface BanContentProps {
/** Ban a specific post. */
mode: 'content';
interface BanConfirmDialogProps {
/** The event ID to ban. */
eventId: string;
/** The event author's pubkey. */
targetPubkey: string;
/** Display name for the dialog description. */
displayName?: string;
}
interface BanMemberProps {
/** Ban a member. */
mode: 'member';
eventId?: never;
/** The pubkey of the member to ban. */
targetPubkey: string;
/** Display name for the dialog description. */
displayName?: string;
}
type BanMode = BanContentProps | BanMemberProps;
type BanConfirmDialogProps = BanMode & {
/** The community `A` tag coordinate. */
communityATag: string;
/** Display name for the dialog description. */
displayName?: string;
open: boolean;
onOpenChange: (open: boolean) => void;
};
}
export function BanConfirmDialog({
mode,
eventId,
targetPubkey,
displayName,
communityATag,
open,
onOpenChange,
@@ -62,23 +47,15 @@ export function BanConfirmDialog({
const { mutateAsync: publishEvent, isPending } = useNostrPublish();
const [reason, setReason] = useState('');
const title = mode === 'content' ? 'Remove from community' : `Ban ${displayName ? `@${displayName}` : 'member'} from community`;
const description = mode === 'content'
? 'This will hide the post from canonical community views.'
: `This will ban ${displayName ? `@${displayName}` : 'this member'} from the community. Their recruits remain unaffected.`;
const handleSubmit = async () => {
try {
const tags: string[][] = [];
if (mode === 'content' && eventId) {
tags.push(['e', eventId, 'other']);
}
tags.push(['p', targetPubkey, 'other']);
tags.push(['A', communityATag]);
tags.push(['L', MODERATION_LABEL_NAMESPACE]);
tags.push(['l', MODERATION_BAN_LABEL, MODERATION_LABEL_NAMESPACE]);
const tags: string[][] = [
['e', eventId, 'other'],
['p', targetPubkey, 'other'],
['A', communityATag],
['L', MODERATION_LABEL_NAMESPACE],
['l', MODERATION_BAN_LABEL, MODERATION_LABEL_NAMESPACE],
];
await publishEvent({
kind: REPORT_KIND,
@@ -87,10 +64,11 @@ export function BanConfirmDialog({
});
// Invalidate community queries so the moderation overlay updates
// immediately (removes banned content/members without a page refresh).
// The activity feed's key is `['community-activity-feed', <aTagsKey>]`
// immediately (removes banned content without a page refresh). The
// activity feed's key is `['community-activity-feed', <aTagsKey>]`
// where aTagsKey is a comma-joined list of the viewer's subscribed A
// tags. We match any feed whose aTagsKey contains this communityATag.
// tags. Predicate-match any feed whose aTagsKey contains this
// communityATag so the banned post disappears immediately.
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['community-members', communityATag] }),
queryClient.invalidateQueries({
@@ -101,22 +79,25 @@ export function BanConfirmDialog({
&& aTagsKey.split(',').includes(communityATag);
},
}),
// Also refresh the organization-activity feed shown on the org
// detail page (used by the pledge/campaign shelves).
queryClient.invalidateQueries({ queryKey: ['organization-activity', communityATag] }),
]);
toast({ title: mode === 'content' ? 'Post removed from community' : 'Member banned from community' });
toast({ title: 'Post removed from organization' });
setReason('');
onOpenChange(false);
} catch {
toast({ title: mode === 'content' ? 'Failed to remove post from community' : 'Failed to ban member from community', variant: 'destructive' });
toast({ title: 'Failed to remove post from organization', variant: 'destructive' });
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md rounded-2xl flex flex-col overflow-hidden">
<DialogTitle>{title}</DialogTitle>
<DialogTitle>Remove from organization</DialogTitle>
<DialogDescription className="text-sm text-muted-foreground">
{description}
This will hide the post from canonical organization views.
</DialogDescription>
<div className="space-y-2">
@@ -146,7 +127,7 @@ export function BanConfirmDialog({
disabled={isPending}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isPending ? 'Submitting...' : (mode === 'content' ? 'Remove' : 'Ban')}
{isPending ? 'Submitting...' : 'Remove'}
</Button>
</div>
</DialogContent>
+631
View File
@@ -0,0 +1,631 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import {
ArrowDownLeft,
ArrowRight,
ArrowUpRight,
Bitcoin,
Check,
Clock,
Copy,
ExternalLink,
Hash,
Layers,
RefreshCw,
Weight,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { useBitcoinTx } from '@/hooks/useBitcoinTx';
import { useBitcoinAddress } from '@/hooks/useBitcoinAddress';
import { satsToBTC, satsToUSD, formatSats, formatBTC } from '@/lib/bitcoin';
import type { TxDetail, TxInput, TxOutput } from '@/lib/bitcoin';
// ---------------------------------------------------------------------------
// Shared helpers
// ---------------------------------------------------------------------------
function truncateMiddle(str: string, startLen = 8, endLen = 8): string {
if (str.length <= startLen + endLen + 3) return str;
return `${str.slice(0, startLen)}...${str.slice(-endLen)}`;
}
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// clipboard not available
}
};
return (
<button
onClick={handleCopy}
className="p-1 rounded hover:bg-muted/50 transition-colors text-muted-foreground hover:text-foreground cursor-pointer"
title="Copy"
>
{copied ? <Check className="size-3.5 text-green-500" /> : <Copy className="size-3.5" />}
</button>
);
}
/** Format a unix timestamp as a readable date string. */
function formatBlockTime(timestamp: number): string {
const date = new Date(timestamp * 1000);
return date.toLocaleString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: true,
});
}
/** Format a large number with locale separators. */
function formatNumber(n: number): string {
return n.toLocaleString();
}
// ---------------------------------------------------------------------------
// Bitcoin Transaction Header
// ---------------------------------------------------------------------------
export function BitcoinTxHeader({ txid }: { txid: string }) {
const { tx, btcPrice, isLoading, error } = useBitcoinTx(txid);
if (isLoading) return <TxSkeleton />;
if (error || !tx) {
return (
<div className="rounded-2xl border border-border p-6 text-center space-y-3">
<Bitcoin className="size-10 mx-auto text-muted-foreground/40" />
<p className="text-sm text-destructive">Failed to load transaction</p>
<p className="text-xs text-muted-foreground font-mono break-all">{txid}</p>
</div>
);
}
return (
<div className="rounded-2xl border border-border overflow-hidden">
{/* Header */}
<div className="p-5 space-y-4">
<div className="flex items-center gap-3">
<div className={`flex items-center justify-center size-10 rounded-full ${
tx.confirmed
? 'bg-green-500/10 text-green-600 dark:text-green-400'
: 'bg-orange-500/10 text-orange-600 dark:text-orange-400'
}`}>
{tx.confirmed ? <Check className="size-5" /> : <Clock className="size-5" />}
</div>
<div>
<h2 className="text-lg font-bold">
{tx.confirmed ? 'Confirmed' : 'Unconfirmed'}
</h2>
{tx.blockTime && (
<p className="text-sm text-muted-foreground">{formatBlockTime(tx.blockTime)}</p>
)}
</div>
</div>
{/* Transaction ID */}
<div className="space-y-1.5">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Transaction ID</p>
<div className="flex items-center gap-2">
<p className="text-sm font-mono text-foreground break-all">{tx.txid}</p>
<CopyButton text={tx.txid} />
</div>
</div>
{/* Stats grid */}
<div className="grid grid-cols-2 gap-3">
{tx.confirmed && tx.blockHeight !== undefined && (
<StatCard icon={<Layers className="size-3.5" />} label="Block" value={formatNumber(tx.blockHeight)} />
)}
<StatCard icon={<Weight className="size-3.5" />} label="Size" value={`${formatNumber(tx.weight / 4)} vB`} />
<StatCard
icon={<Bitcoin className="size-3.5" />}
label="Fee"
value={`${formatSats(tx.fee)} sat`}
subtitle={`${(tx.fee / (tx.weight / 4)).toFixed(1)} sat/vB`}
/>
<StatCard
icon={<Hash className="size-3.5" />}
label="Amount"
value={`${formatBTC(tx.totalOutput)} BTC`}
subtitle={btcPrice ? satsToUSD(tx.totalOutput, btcPrice) : undefined}
/>
</div>
</div>
{/* Inputs → Outputs flow */}
<div className="border-t border-border">
<TxFlow tx={tx} btcPrice={btcPrice} />
</div>
{/* Footer: link to mempool.space */}
<div className="border-t border-border px-5 py-2.5">
<a
href={`https://mempool.space/tx/${txid}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<Bitcoin className="size-3.5" />
<span>View on mempool.space</span>
<ExternalLink className="size-3" />
</a>
</div>
</div>
);
}
function StatCard({ icon, label, value, subtitle }: { icon: React.ReactNode; label: string; value: string; subtitle?: string }) {
return (
<div className="rounded-xl bg-secondary/40 px-3.5 py-2.5 space-y-0.5">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
{icon}
<span>{label}</span>
</div>
<p className="text-sm font-semibold">{value}</p>
{subtitle && <p className="text-xs text-muted-foreground">{subtitle}</p>}
</div>
);
}
/** Inputs → Outputs visualization, mempool.space-style. */
function TxFlow({ tx, btcPrice }: { tx: TxDetail; btcPrice?: number }) {
return (
<div className="p-4 space-y-3">
<div className="flex items-center gap-2 text-xs font-medium text-muted-foreground uppercase tracking-wider px-1">
<span>{tx.inputs.length} Input{tx.inputs.length !== 1 ? 's' : ''}</span>
<ArrowRight className="size-3" />
<span>{tx.outputs.length} Output{tx.outputs.length !== 1 ? 's' : ''}</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{/* Inputs */}
<div className="space-y-1.5">
{tx.inputs.slice(0, 10).map((input, i) => (
<TxInputRow key={`${input.txid}-${input.vout}-${i}`} input={input} btcPrice={btcPrice} />
))}
{tx.inputs.length > 10 && (
<p className="text-xs text-muted-foreground text-center py-1">
+{tx.inputs.length - 10} more input{tx.inputs.length - 10 !== 1 ? 's' : ''}
</p>
)}
</div>
{/* Outputs */}
<div className="space-y-1.5">
{tx.outputs.slice(0, 10).map((output, i) => (
<TxOutputRow key={`${output.address ?? 'op_return'}-${i}`} output={output} btcPrice={btcPrice} />
))}
{tx.outputs.length > 10 && (
<p className="text-xs text-muted-foreground text-center py-1">
+{tx.outputs.length - 10} more output{tx.outputs.length - 10 !== 1 ? 's' : ''}
</p>
)}
</div>
</div>
</div>
);
}
function TxInputRow({ input, btcPrice }: { input: TxInput; btcPrice?: number }) {
if (input.isCoinbase) {
return (
<div className="rounded-lg bg-amber-500/5 border border-amber-500/20 px-3 py-2">
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-medium text-amber-600 dark:text-amber-400">Coinbase</span>
<span className="text-xs font-mono">{formatBTC(input.value)} BTC</span>
</div>
</div>
);
}
return (
<div className="rounded-lg bg-red-500/5 border border-red-500/10 px-3 py-2 space-y-0.5">
<div className="flex items-center justify-between gap-2">
{input.address ? (
<Link
to={`/i/bitcoin:address:${input.address}`}
className="text-xs font-mono text-red-600 dark:text-red-400 hover:underline truncate"
>
{truncateMiddle(input.address, 10, 6)}
</Link>
) : (
<span className="text-xs text-muted-foreground">Unknown</span>
)}
<span className="text-xs font-mono shrink-0">{formatBTC(input.value)} BTC</span>
</div>
{btcPrice !== undefined && (
<p className="text-[10px] text-muted-foreground text-right">{satsToUSD(input.value, btcPrice)}</p>
)}
</div>
);
}
function TxOutputRow({ output, btcPrice }: { output: TxOutput; btcPrice?: number }) {
const isOpReturn = output.scriptpubkeyType === 'op_return';
if (isOpReturn) {
return (
<div className="rounded-lg bg-secondary/60 border border-border/50 px-3 py-2">
<span className="text-xs text-muted-foreground">OP_RETURN</span>
</div>
);
}
return (
<div className="rounded-lg bg-green-500/5 border border-green-500/10 px-3 py-2 space-y-0.5">
<div className="flex items-center justify-between gap-2">
{output.address ? (
<Link
to={`/i/bitcoin:address:${output.address}`}
className="text-xs font-mono text-green-600 dark:text-green-400 hover:underline truncate"
>
{truncateMiddle(output.address, 10, 6)}
</Link>
) : (
<span className="text-xs text-muted-foreground">Unknown</span>
)}
<span className="text-xs font-mono shrink-0">{formatBTC(output.value)} BTC</span>
</div>
{btcPrice !== undefined && (
<p className="text-[10px] text-muted-foreground text-right">{satsToUSD(output.value, btcPrice)}</p>
)}
</div>
);
}
function TxSkeleton() {
return (
<div className="rounded-2xl border border-border overflow-hidden">
<div className="p-5 space-y-4">
<div className="flex items-center gap-3">
<Skeleton className="size-10 rounded-full" />
<div className="space-y-1.5">
<Skeleton className="h-5 w-28" />
<Skeleton className="h-3.5 w-40" />
</div>
</div>
<div className="space-y-1.5">
<Skeleton className="h-3 w-24" />
<Skeleton className="h-4 w-full" />
</div>
<div className="grid grid-cols-2 gap-3">
<Skeleton className="h-16 rounded-xl" />
<Skeleton className="h-16 rounded-xl" />
<Skeleton className="h-16 rounded-xl" />
<Skeleton className="h-16 rounded-xl" />
</div>
</div>
<div className="border-t border-border p-4 space-y-3">
<Skeleton className="h-3 w-32" />
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Skeleton className="h-12 rounded-lg" />
<Skeleton className="h-12 rounded-lg" />
</div>
<div className="space-y-1.5">
<Skeleton className="h-12 rounded-lg" />
<Skeleton className="h-12 rounded-lg" />
</div>
</div>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Bitcoin Address Header
// ---------------------------------------------------------------------------
export function BitcoinAddressHeader({ address }: { address: string }) {
const { addressDetail, btcPrice, isLoading, error, refetch } = useBitcoinAddress(address);
if (isLoading) return <AddressSkeleton />;
if (error || !addressDetail) {
return (
<div className="rounded-2xl border border-border p-6 text-center space-y-3">
<Bitcoin className="size-10 mx-auto text-muted-foreground/40" />
<p className="text-sm text-destructive">Failed to load address</p>
<p className="text-xs text-muted-foreground font-mono break-all">{address}</p>
<Button variant="outline" size="sm" onClick={() => refetch()}>
<RefreshCw className="size-3.5 mr-1.5" />
Retry
</Button>
</div>
);
}
return (
<div className="rounded-2xl border border-border overflow-hidden">
{/* Header */}
<div className="p-5 space-y-4">
<div className="flex items-center gap-3">
<div className="flex items-center justify-center size-10 rounded-full bg-primary/10 text-primary">
<Bitcoin className="size-5" />
</div>
<div>
<h2 className="text-lg font-bold">Bitcoin Address</h2>
<p className="text-xs text-muted-foreground">
{addressDetail.txCount + addressDetail.pendingTxCount} transaction{(addressDetail.txCount + addressDetail.pendingTxCount) !== 1 ? 's' : ''}
</p>
</div>
</div>
{/* Address */}
<div className="space-y-1.5">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Address</p>
<div className="flex items-center gap-2">
<p className="text-sm font-mono text-foreground break-all">{address}</p>
<CopyButton text={address} />
</div>
</div>
{/* Balance hero */}
<div className="rounded-xl bg-secondary/40 p-4 text-center space-y-1">
<p className="text-xs text-muted-foreground uppercase tracking-wider">Balance</p>
<p className="text-3xl font-bold tracking-tight">
{btcPrice ? satsToUSD(addressDetail.totalBalance, btcPrice) : `${formatBTC(addressDetail.totalBalance)} BTC`}
</p>
<p className="text-sm text-muted-foreground">
{formatBTC(addressDetail.totalBalance)} BTC
</p>
{addressDetail.pendingBalance !== 0 && (
<p className="flex items-center justify-center gap-1 text-xs text-orange-500 dark:text-orange-400 pt-1">
<RefreshCw className="size-3 animate-spin" />
{btcPrice
? `${satsToUSD(addressDetail.pendingBalance, btcPrice)} pending`
: `${formatBTC(addressDetail.pendingBalance)} BTC pending`}
</p>
)}
</div>
{/* Stats grid */}
<div className="grid grid-cols-2 gap-3">
<StatCard
icon={<ArrowDownLeft className="size-3.5" />}
label="Total Received"
value={`${formatBTC(addressDetail.totalReceived)} BTC`}
subtitle={btcPrice ? satsToUSD(addressDetail.totalReceived, btcPrice) : undefined}
/>
<StatCard
icon={<ArrowUpRight className="size-3.5" />}
label="Total Sent"
value={`${formatBTC(addressDetail.totalSent)} BTC`}
subtitle={btcPrice ? satsToUSD(addressDetail.totalSent, btcPrice) : undefined}
/>
</div>
</div>
{/* Recent Transactions */}
{addressDetail.recentTxs.length > 0 && (
<div className="border-t border-border">
<div className="px-5 py-3">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Recent Transactions
</p>
</div>
<div className="divide-y divide-border">
{addressDetail.recentTxs.slice(0, 10).map((tx) => (
<AddressTxRow key={tx.txid} tx={tx} btcPrice={btcPrice} />
))}
</div>
{addressDetail.recentTxs.length > 10 && (
<div className="px-5 py-3 text-center">
<p className="text-xs text-muted-foreground">
{addressDetail.txCount - 10} more transaction{addressDetail.txCount - 10 !== 1 ? 's' : ''}
</p>
</div>
)}
</div>
)}
{/* Footer: link to mempool.space */}
<div className="border-t border-border px-5 py-2.5">
<a
href={`https://mempool.space/address/${address}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<Bitcoin className="size-3.5" />
<span>View on mempool.space</span>
<ExternalLink className="size-3" />
</a>
</div>
</div>
);
}
function AddressTxRow({ tx, btcPrice }: { tx: { txid: string; amount: number; type: 'receive' | 'send'; confirmed: boolean; timestamp?: number }; btcPrice?: number }) {
const isReceive = tx.type === 'receive';
return (
<Link
to={`/i/bitcoin:tx:${tx.txid}`}
className="flex items-center justify-between py-3 px-5 hover:bg-muted/50 transition-colors"
>
<div className="flex items-center gap-3">
<div className={`flex items-center justify-center size-8 rounded-full ${
isReceive
? 'bg-green-500/10 text-green-600 dark:text-green-400'
: 'bg-red-500/10 text-red-600 dark:text-red-400'
}`}>
{isReceive ? <ArrowDownLeft className="size-4" /> : <ArrowUpRight className="size-4" />}
</div>
<div>
<p className="text-sm font-medium">{isReceive ? 'Received' : 'Sent'}</p>
<p className="text-xs text-muted-foreground font-mono">{truncateMiddle(tx.txid, 8, 8)}</p>
</div>
</div>
<div className="text-right">
<p className={`text-sm font-medium ${
isReceive ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'
}`}>
{isReceive ? '+' : '-'}{formatBTC(tx.amount)} BTC
</p>
{btcPrice && (
<p className="text-xs text-muted-foreground">
{satsToUSD(tx.amount, btcPrice)}
</p>
)}
</div>
</Link>
);
}
function AddressSkeleton() {
return (
<div className="rounded-2xl border border-border overflow-hidden">
<div className="p-5 space-y-4">
<div className="flex items-center gap-3">
<Skeleton className="size-10 rounded-full" />
<div className="space-y-1.5">
<Skeleton className="h-5 w-32" />
<Skeleton className="h-3.5 w-24" />
</div>
</div>
<div className="space-y-1.5">
<Skeleton className="h-3 w-16" />
<Skeleton className="h-4 w-full" />
</div>
<div className="rounded-xl bg-secondary/40 p-4 space-y-2 flex flex-col items-center">
<Skeleton className="h-3 w-12" />
<Skeleton className="h-9 w-40" />
<Skeleton className="h-4 w-28" />
</div>
<div className="grid grid-cols-2 gap-3">
<Skeleton className="h-16 rounded-xl" />
<Skeleton className="h-16 rounded-xl" />
</div>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Compact previews (used in NoteCard embeds, hover cards, etc.)
// ---------------------------------------------------------------------------
/** Compact preview for a Bitcoin transaction — fetches real data. */
export function BitcoinTxPreview({ txid, link }: { txid: string; link: string }) {
const { tx, btcPrice, isLoading } = useBitcoinTx(txid);
if (isLoading) {
return (
<div className="px-4 py-3 border-b border-border">
<div className="flex items-center gap-3">
<Skeleton className="size-12 rounded-lg shrink-0" />
<div className="flex-1 min-w-0 space-y-1.5">
<Skeleton className="h-3 w-32" />
<Skeleton className="h-4 w-3/4" />
</div>
</div>
</div>
);
}
const amount = tx ? tx.totalOutput : 0;
const fee = tx?.fee ?? 0;
return (
<Link
to={link}
className="flex items-center gap-3 px-4 py-3 border-b border-border hover:bg-secondary/30 transition-colors"
>
<div className="size-12 rounded-lg bg-orange-500/10 flex items-center justify-center shrink-0">
<Bitcoin className="size-5 text-orange-600 dark:text-orange-400" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Bitcoin className="size-3 shrink-0" />
<span>Bitcoin Transaction</span>
{tx && (
<span className={tx.confirmed
? 'text-green-600 dark:text-green-400'
: 'text-yellow-600 dark:text-yellow-400'
}>
{tx.confirmed ? 'Confirmed' : 'Unconfirmed'}
</span>
)}
</div>
<p className="text-sm font-medium truncate mt-0.5">
{tx ? `${satsToBTC(amount)} BTC` : truncateMiddle(txid, 12, 8)}
{tx && btcPrice ? (
<span className="text-muted-foreground font-normal"> ({satsToUSD(amount, btcPrice)})</span>
) : null}
</p>
{tx && (
<p className="text-xs text-muted-foreground truncate">
Fee {formatSats(fee)} sats
{tx.blockHeight ? ` · Block ${tx.blockHeight.toLocaleString()}` : ''}
</p>
)}
</div>
<ExternalLink className="size-4 text-muted-foreground shrink-0" />
</Link>
);
}
/** Compact preview for a Bitcoin address — fetches real data. */
export function BitcoinAddressPreview({ address, link }: { address: string; link: string }) {
const { addressDetail, btcPrice, isLoading } = useBitcoinAddress(address);
if (isLoading) {
return (
<div className="px-4 py-3 border-b border-border">
<div className="flex items-center gap-3">
<Skeleton className="size-12 rounded-lg shrink-0" />
<div className="flex-1 min-w-0 space-y-1.5">
<Skeleton className="h-3 w-28" />
<Skeleton className="h-4 w-3/4" />
</div>
</div>
</div>
);
}
const balance = addressDetail?.totalBalance ?? 0;
const txCount = addressDetail ? addressDetail.txCount + addressDetail.pendingTxCount : 0;
return (
<Link
to={link}
className="flex items-center gap-3 px-4 py-3 border-b border-border hover:bg-secondary/30 transition-colors"
>
<div className="size-12 rounded-lg bg-orange-500/10 flex items-center justify-center shrink-0">
<Bitcoin className="size-5 text-orange-600 dark:text-orange-400" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Bitcoin className="size-3 shrink-0" />
<span>Bitcoin Address</span>
</div>
<p className="text-sm font-medium truncate mt-0.5">
{addressDetail ? `${satsToBTC(balance)} BTC` : truncateMiddle(address, 12, 8)}
{addressDetail && btcPrice ? (
<span className="text-muted-foreground font-normal"> ({satsToUSD(balance, btcPrice)})</span>
) : null}
</p>
{addressDetail && (
<p className="text-xs text-muted-foreground truncate">
{txCount.toLocaleString()} transaction{txCount !== 1 ? 's' : ''}
{' · '}
<span className="font-mono">{truncateMiddle(address, 8, 6)}</span>
</p>
)}
</div>
<ExternalLink className="size-4 text-muted-foreground shrink-0" />
</Link>
);
}
@@ -0,0 +1,51 @@
import { Alert, AlertDescription } from '@/components/ui/alert';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
/**
* Informational notice for BIP-352 silent-payment receive endpoints
* (sp1…). Surfaces the "private but experimental" trade-off the user
* accepts when they choose silent payments instead of a regular
* on-chain address.
*
* Visual treatment mirrors `BitcoinPublicDisclaimer` with `tone="soft"`:
* `role="note"`, amber tint, no icon, no checkbox. The lead sentence
* carries the headline, and "Learn more" opens a popover with the full
* explanation.
*/
export function BitcoinPrivateDisclaimer() {
return (
<Alert
role="note"
className="border-amber-500/30 bg-amber-500/10 text-foreground"
>
{/* No icon — the shadcn Alert reserves left padding for an icon via
`[&>svg~*]:pl-7`, so omitting it reclaims the indent. */}
<AlertDescription className="text-xs">
<p>
Experimental. Donations are private, but bugs may occur.{' '}
<Popover>
<PopoverTrigger asChild>
<button
type="button"
className="underline underline-offset-2 font-medium hover:opacity-80 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-sm"
>
Learn more
</button>
</PopoverTrigger>
<PopoverContent side="top" align="start" className="w-72 text-xs leading-relaxed">
Your private wallet hides the real address of your wallet
and your donors on the Bitcoin network. Funds are always
fully recoverable, but bugs in the wallet may cause it to
show an incorrect balance, and it may require long wait
times to synchronize.
</PopoverContent>
</Popover>
</p>
</AlertDescription>
</Alert>
);
}
+137
View File
@@ -0,0 +1,137 @@
import type { ReactNode } from 'react';
import { AlertTriangle } from 'lucide-react';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Checkbox } from '@/components/ui/checkbox';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { cn } from '@/lib/utils';
/**
* - `destructive`: red, with a warning icon. Used in high-stakes contexts
* like the wallet's Send dialog where the disclaimer also gates an
* acknowledgement checkbox.
* - `soft`: amber, no icon. Used as an informational notice in lower-stakes
* contexts (e.g. campaign donation surfaces) where we don't want to
* imply the donor is about to do something dangerous.
*/
type Tone = 'destructive' | 'soft';
interface BitcoinPublicDisclaimerProps {
/**
* When provided, render an "I understand this transaction is public"
* acknowledgement checkbox below the warning. Callers should typically
* gate the primary action (Send / Donate / Review / Open in wallet) on
* `acknowledged === true`. When omitted, the disclaimer renders as an
* informational notice with no interactive control.
*/
acknowledged?: boolean;
onAcknowledgedChange?: (acknowledged: boolean) => void;
/** Optional override for the lead sentence (e.g. "Donations" instead of "Money"). */
leadText?: string;
/** Visual treatment. Defaults to `destructive` for backwards compatibility with the wallet's Send dialog. */
tone?: Tone;
/**
* Whether the "Learn more" popover should include the
* "or cash out at an exchange" advice. Relevant in the wallet (the
* user holds Bitcoin and could cash out) but not on a campaign page
* (the donor is sending money away, not deciding what to do with it).
* Defaults to `true` for backwards compatibility.
*/
includeCashOutAdvice?: boolean;
/**
* Override the popover body. When set, replaces the entire "Bitcoin
* is a public ledger…" paragraph (including the cash-out advice). Use
* when the calling surface has a meaningfully different audience —
* e.g. a campaign *creator* configuring a receive address, vs. the
* sender flow this component was originally written for.
*/
popoverText?: ReactNode;
}
/**
* Privacy disclaimer for on-chain Bitcoin payments. Bitcoin is a public
* ledger and the transaction can be traced back to the sender forever.
* Used wherever the user initiates an on-chain payment — wallet sends to
* raw addresses, campaign donations (BIP-21 panels, in-app PSBT
* donations, external-wallet fallbacks).
*/
export function BitcoinPublicDisclaimer({
acknowledged,
onAcknowledgedChange,
leadText = 'Money you send is public and can be traced back to you.',
tone = 'destructive',
includeCashOutAdvice = true,
popoverText,
}: BitcoinPublicDisclaimerProps) {
const showCheckbox = onAcknowledgedChange !== undefined;
const isSoft = tone === 'soft';
return (
<Alert
// For `soft` we drop the role="alert" semantics — it's informational,
// not an active warning the user must respond to.
role={isSoft ? 'note' : 'alert'}
className={cn(
isSoft
// 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',
)}
>
{/* Icon only on the destructive variant. The shadcn Alert reserves
left padding for an icon via `[&>svg~*]:pl-7`, so omitting the
icon also reclaims the indent. */}
{!isSoft && <AlertTriangle className="size-4 text-destructive" />}
<AlertDescription className="text-xs">
<p>
{leadText}{' '}
<Popover>
<PopoverTrigger asChild>
<button
type="button"
className="underline underline-offset-2 font-medium hover:opacity-80 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-sm"
>
Learn more
</button>
</PopoverTrigger>
<PopoverContent side="top" align="start" className="w-72 text-xs leading-relaxed">
{popoverText ?? (
<>
Bitcoin is a public ledger. Transactions you send can
be traced back to you forever, even after being
exchanged by multiple people. Send it only to those
you wish to support publicly
{includeCashOutAdvice ? ', or cash out at an exchange.' : '.'}
</>
)}
</PopoverContent>
</Popover>
</p>
{showCheckbox && (
<label className="mt-2 flex items-start gap-2 cursor-pointer select-none">
<Checkbox
checked={acknowledged ?? false}
onCheckedChange={(checked) => onAcknowledgedChange(checked === true)}
className={cn(
'mt-0.5',
isSoft
? 'border-amber-600 data-[state=checked]:bg-amber-600 data-[state=checked]:text-white dark:border-amber-400 dark:data-[state=checked]:bg-amber-500'
: 'border-destructive data-[state=checked]:bg-destructive data-[state=checked]:text-destructive-foreground',
)}
aria-label="I understand this transaction is public"
/>
<span>I understand this transaction is public.</span>
</label>
)}
</AlertDescription>
</Alert>
);
}
+8 -68
View File
@@ -1,30 +1,23 @@
import { useMemo, useState, useRef, useEffect, useCallback } from 'react';
import { Link } from 'react-router-dom';
import { BookOpen, MessageCircle, MessageSquare, MoreHorizontal, Star, Zap, AlertTriangle } from 'lucide-react';
import { BookOpen, MessageSquare, Star, AlertTriangle } from 'lucide-react';
import { nip19 } from 'nostr-tools';
import { RepostIcon } from '@/components/icons/RepostIcon';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { NoteContent } from '@/components/NoteContent';
import { ReactionButton } from '@/components/ReactionButton';
import { RepostMenu } from '@/components/RepostMenu';
import { PostActionBar } from '@/components/PostActionBar';
import { ProfileHoverCard } from '@/components/ProfileHoverCard';
import { EmojifiedText } from '@/components/CustomEmoji';
import { NoteMoreMenu } from '@/components/NoteMoreMenu';
import { ReplyComposeModal } from '@/components/ReplyComposeModal';
import { ZapDialog } from '@/components/ZapDialog';
import { useAuthor } from '@/hooks/useAuthor';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useEventStats } from '@/hooks/useTrending';
import { useUserZap } from '@/hooks/useUserZap';
import { useProfileUrl } from '@/hooks/useProfileUrl';
import { useOpenPost } from '@/hooks/useOpenPost';
import { useBookSummary } from '@/hooks/useBookSummary';
import { getDisplayName } from '@/lib/getDisplayName';
import { timeAgo } from '@/lib/timeAgo';
import { formatNumber } from '@/lib/formatNumber';
import { cn } from '@/lib/utils';
import { BOOKSTR_KINDS, extractISBNFromEvent, parseBookReview, ratingToStars } from '@/lib/bookstr';
import type { NostrEvent } from '@nostrify/nostrify';
@@ -49,18 +42,13 @@ function encodeEventId(event: NostrEvent): string {
}
export function BookFeedItem({ event, className }: BookFeedItemProps) {
const { user } = useCurrentUser();
const author = useAuthor(event.pubkey);
const metadata = author.data?.metadata;
const displayName = getDisplayName(metadata, event.pubkey);
const profileUrl = useProfileUrl(event.pubkey, metadata);
const { data: stats } = useEventStats(event.id, event);
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
const [replyOpen, setReplyOpen] = useState(false);
const canZapAuthor = !!user && user.pubkey !== event.pubkey;
const isZapped = useUserZap(canZapAuthor ? event.id : undefined) === true;
const isbn = useMemo(() => extractISBNFromEvent(event), [event]);
const isReview = event.kind === BOOKSTR_KINDS.BOOK_REVIEW;
const isComment = event.kind === 1111;
@@ -220,60 +208,12 @@ export function BookFeedItem({ event, className }: BookFeedItemProps) {
{isbn && <InlineBookCard isbn={isbn} />}
{/* Action buttons */}
<div className="flex items-center gap-5 mt-3 -ml-2">
<button
className="flex items-center gap-1.5 p-2 rounded-full text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors"
title="Reply"
onClick={(e) => { e.stopPropagation(); setReplyOpen(true); }}
>
<MessageCircle className="size-5" />
{stats?.replies ? <span className="text-sm tabular-nums">{formatNumber(stats.replies)}</span> : null}
</button>
<RepostMenu event={event}>
{(isReposted: boolean) => (
<button
className={`flex items-center gap-1.5 p-2 rounded-full transition-colors ${isReposted ? 'text-accent hover:text-accent/80 hover:bg-accent/10' : 'text-muted-foreground hover:text-accent hover:bg-accent/10'}`}
title={isReposted ? 'Undo repost' : 'Repost'}
>
<RepostIcon className="size-5" />
{(stats?.reposts || stats?.quotes) ? <span className="text-sm tabular-nums">{formatNumber((stats?.reposts ?? 0) + (stats?.quotes ?? 0))}</span> : null}
</button>
)}
</RepostMenu>
<ReactionButton
eventId={event.id}
eventPubkey={event.pubkey}
eventKind={event.kind}
reactionCount={stats?.reactions}
/>
{canZapAuthor && (
<ZapDialog target={event}>
<button
className={cn(
'flex items-center gap-1.5 p-2 rounded-full transition-colors',
isZapped
? 'text-amber-500 hover:text-amber-500/80 hover:bg-amber-500/10'
: 'text-muted-foreground hover:text-amber-500 hover:bg-amber-500/10',
)}
title={isZapped ? 'Zapped' : 'Zap'}
>
<Zap className="size-5" fill={isZapped ? 'currentColor' : 'none'} />
{stats?.zapAmount ? <span className="text-sm tabular-nums">{formatNumber(stats.zapAmount)}</span> : null}
</button>
</ZapDialog>
)}
<button
className="p-2 rounded-full text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors"
title="More"
onClick={(e) => { e.stopPropagation(); setMoreMenuOpen(true); }}
>
<MoreHorizontal className="size-5" />
</button>
</div>
<PostActionBar
event={event}
onReply={() => setReplyOpen(true)}
onMore={() => setMoreMenuOpen(true)}
className="mt-3"
/>
</div>
</div>
+400 -210
View File
@@ -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 (
<div className="flex items-start gap-3 rounded-xl bg-muted/40 px-3 py-3">
<div className="mt-0.5 text-primary shrink-0">{icon}</div>
<div className="min-w-0 text-sm leading-relaxed">{children}</div>
</div>
);
}
// --- 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 (
<div className="max-w-2xl mx-auto pb-16">
{/* ── Standard top bar ── */}
<div className="flex items-center gap-4 px-4 pt-4 pb-5">
<button
onClick={() => window.history.length > 1 ? navigate(-1) : navigate('/')}
className="p-1.5 -ml-1.5 rounded-full hover:bg-secondary/60 transition-colors"
aria-label="Go back"
>
<ArrowLeft className="size-5" />
</button>
<h1 className="text-xl font-bold flex-1">Event Details</h1>
{canEdit && (
<button
className="p-2 rounded-full hover:bg-secondary/60 transition-colors"
onClick={() => setEditOpen(true)}
aria-label="Edit event"
>
<Pencil className="size-5" />
</button>
const eventDetailsCard = (
<Card className="overflow-hidden">
<CardContent className="p-5 space-y-5">
<div className="space-y-3">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Hosted by</div>
<PersonRow pubkey={event.pubkey} size="sm" />
</div>
{(event.content || summary) && (
<div className="space-y-2 border-t border-border/60 pt-4">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Description</div>
{event.content ? (
<NoteContent event={event} className="text-sm leading-relaxed text-foreground" hideEmbedImages={!!image} disableEmbeds disableNoteEmbeds />
) : (
<p className="text-sm leading-relaxed text-muted-foreground">{summary}</p>
)}
</div>
)}
</div>
{/* ── Cover image ── */}
{image ? (
<div className="aspect-[2/1] w-full overflow-hidden">
<img src={image} alt={title} className="w-full h-full object-cover" />
</div>
) : (
<div className="aspect-[3/1] w-full bg-gradient-to-br from-primary/15 via-primary/5 to-transparent flex items-center justify-center">
<CalendarDays className="size-20 text-primary/20" />
</div>
)}
{/* ── Content ── */}
<div className="px-5 mt-5 space-y-5">
{/* Title */}
<h2 className="text-2xl font-bold leading-tight tracking-tight">{title}</h2>
{/* Organizer row */}
<div className="flex items-center gap-3">
<div className="flex-1 min-w-0">
<PersonRow pubkey={event.pubkey} />
</div>
</div>
{/* Date & Location — sidebar-style pills */}
<div className="flex flex-col gap-0.5">
<div className="flex items-center gap-4 px-3 py-3 rounded-full bg-background/85">
<Clock className="size-5 text-primary shrink-0" />
<span className="text-sm">{dateStr}</span>
</div>
<div className="space-y-3">
<EventDetailRow icon={<Clock className="size-5" />}>
{dateStr}
</EventDetailRow>
{location && (
<div className="flex items-center gap-4 px-3 py-3 rounded-full bg-background/85">
<MapPin className="size-5 text-primary shrink-0" />
<span className="text-sm">{location}</span>
</div>
<EventDetailRow icon={<MapPin className="size-5" />}>
{location}
</EventDetailRow>
)}
</div>
{/* Hashtags */}
{hashtags.length > 0 && (
<div className="flex flex-wrap gap-2">
{hashtags.map((tag) => (
<Link key={tag} to={`/t/${tag}`}>
<Badge variant="secondary" className="cursor-pointer hover:bg-secondary/80 text-xs px-2.5 py-0.5">
#{tag}
</Badge>
</Link>
))}
{showRSVP && (
<div className="space-y-3 border-t border-border/60 pt-4">
<div className="flex items-center justify-between gap-3">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">RSVP</div>
<span className="text-xs font-medium text-muted-foreground">{rsvpStatusLabel}</span>
</div>
<div className="grid grid-cols-3 gap-2">
<Button
size="sm"
variant={myRsvp.status === 'accepted' ? 'default' : 'outline'}
disabled={publishRSVP.isPending}
className={cn('rounded-full px-2', myRsvp.status === 'accepted' && 'bg-green-600 hover:bg-green-700 text-white')}
onClick={() => handleRSVP('accepted')}
>
<Check className="size-3.5 mr-1" />
Going
</Button>
<Button
size="sm"
variant={myRsvp.status === 'tentative' ? 'default' : 'outline'}
disabled={publishRSVP.isPending}
className={cn('rounded-full px-2', myRsvp.status === 'tentative' && 'bg-amber-500 hover:bg-amber-600 text-white')}
onClick={() => handleRSVP('tentative')}
>
<Star className="size-3.5 mr-1" />
Interested
</Button>
<Button
size="sm"
variant={myRsvp.status === 'declined' ? 'default' : 'outline'}
disabled={publishRSVP.isPending}
className={cn('rounded-full px-2', myRsvp.status === 'declined' && 'bg-destructive hover:bg-destructive/90 text-destructive-foreground')}
onClick={() => handleRSVP('declined')}
>
<XIcon className="size-3.5 mr-1" />
Can't Go
</Button>
</div>
</div>
)}
{/* Description */}
{(event.content || summary) && (
<>
<Separator />
<section className="space-y-2">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">About</h2>
{event.content ? (
<NoteContent event={event} className="text-sm leading-relaxed text-foreground" hideEmbedImages={!!image} />
) : (
<p className="text-sm leading-relaxed text-muted-foreground">{summary}</p>
{rsvps.total > 0 && (
<div className="space-y-3 border-t border-border/60 pt-4">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Attendees</div>
<div className="space-y-3">
{([
['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 && (
<div key={label} className="space-y-2">
<Badge variant="outline" className={cn(cls, 'shrink-0 text-xs')}>{label} ({pks.length})</Badge>
<RSVPAvatars pubkeys={pks} maxVisible={8} size="sm" />
</div>
))}
</div>
</div>
)}
{links.length > 0 && (
<div className="space-y-2 border-t border-border/60 pt-4">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Links</div>
<div className="space-y-1">
{links.map((url) => (
<button
key={url}
type="button"
onClick={() => void openUrl(url)}
className="flex w-full items-center gap-2 rounded-lg px-2 py-2 text-left text-sm hover:bg-muted/50 transition-colors"
>
<LinkIcon className="size-4 text-primary shrink-0" />
<span className="truncate flex-1">{url.replace(/^https?:\/\//, '')}</span>
<ExternalLink className="size-3.5 text-muted-foreground shrink-0" />
</button>
))}
</div>
</div>
)}
</CardContent>
</Card>
);
const participantsCard = participantsByRole.length > 0 ? (
<Card>
<CardContent className="p-5 space-y-3">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Participants</div>
<div className="space-y-2">
{participantsByRole.map(([role, pubkeys]) =>
pubkeys.map((pk) => <PersonRow key={`${role}-${pk}`} pubkey={pk} label={role} size="sm" />),
)}
</div>
</CardContent>
</Card>
) : null;
return (
<main className="min-h-screen pb-16">
<div className="max-w-6xl mx-auto px-4 sm:px-6 pt-4">
<div className="relative aspect-[16/9] sm:aspect-[21/9] rounded-t-xl rounded-b-none overflow-hidden bg-gradient-to-br from-primary/30 via-primary/15 to-secondary">
{image ? (
<img src={image} alt="" className="absolute inset-0 size-full object-cover" />
) : (
<div className="absolute inset-0 flex items-center justify-center">
<CalendarDays className="size-16 sm:size-20 text-primary/40" />
</div>
)}
<div className="absolute inset-0 bg-gradient-to-t from-black/85 via-black/25 to-black/45" />
<div className="absolute left-0 right-0 top-0 z-10 flex items-center justify-between gap-3 px-4 pt-4">
<button
onClick={() => window.history.length > 1 ? navigate(-1) : navigate('/')}
className="p-2.5 -ml-2 rounded-full text-white/90 hover:bg-white/15 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/80 motion-safe:transition-colors"
aria-label="Go back"
>
<ChevronLeft className="size-6 drop-shadow-[0_1px_2px_rgba(0,0,0,0.85)]" />
</button>
{canEdit && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setEditOpen(true)}
className="rounded-full bg-transparent text-white/90 shadow-none hover:bg-white/15 hover:text-white focus-visible:ring-white/80"
>
<Pencil className="size-4 mr-2" />
Edit
</Button>
)}
</div>
<div className="absolute inset-x-0 bottom-0 z-10 space-y-2 p-5 sm:p-6 [text-shadow:0_1px_4px_rgba(0,0,0,0.75),0_2px_10px_rgba(0,0,0,0.45)]">
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<h1 className="text-3xl sm:text-4xl font-bold leading-tight tracking-tight text-white">
{title}
</h1>
</div>
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5 text-xs sm:text-sm font-medium text-white/85">
{heroDate && (
<span className="inline-flex items-center gap-1.5">
<CalendarDays className="size-3.5 sm:size-4" />
{heroDate}
</span>
)}
{location && (
<span className="inline-flex items-center gap-1.5">
<MapPin className="size-3.5 sm:size-4" />
{location}
</span>
)}
{attendingCount > 0 && (
<span className="inline-flex items-center gap-1.5">
<Users className="size-3.5 sm:size-4" />
{attendingCount} attending
</span>
)}
{interestedCount > 0 && (
<span className="inline-flex items-center gap-1.5">
<Users className="size-3.5 sm:size-4" />
{interestedCount} interested
</span>
)}
</div>
{summary && (
<p className="max-w-2xl text-base sm:text-lg text-white/90 line-clamp-3">
{summary}
</p>
)}
</div>
</div>
</div>
<div className="max-w-6xl mx-auto px-4 sm:px-6">
<div className="rounded-b-xl rounded-t-none bg-card border border-t-0 border-border/60 shadow-sm px-4 sm:px-5 py-3">
<PostActionBar
event={event}
replyLabel="Comment"
onReply={() => setReplyOpen(true)}
onMore={() => setMoreMenuOpen(true)}
/>
</div>
</div>
{pinnedNodes.length > 0 && (
<div className="max-w-6xl mx-auto px-4 sm:px-6 pt-6">
<div className="rounded-2xl bg-card border border-border/60 overflow-hidden">
<ThreadedReplyList
roots={pinnedNodes}
renderItemHeader={(event) => (
<EventPinHeader
isPinned={isPinned(event.id)}
canManagePins={canManagePins}
pinPending={togglePin.isPending}
onTogglePin={() => handleTogglePin(event)}
/>
)}
/>
</div>
</div>
)}
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-6 lg:py-10">
<div className="lg:hidden mb-6 space-y-4">
{eventDetailsCard}
{participantsCard}
</div>
<div className="lg:flex lg:gap-8 lg:items-start">
<div className="flex-1 min-w-0 space-y-8">
<section className="space-y-5">
{hashtags.length > 0 && (
<div className="flex flex-wrap gap-2">
{hashtags.map((tag) => (
<Link key={tag} to={`/t/${tag}`}>
<Badge variant="secondary" className="cursor-pointer hover:bg-secondary/80 text-xs px-2.5 py-0.5">
#{tag}
</Badge>
</Link>
))}
</div>
)}
{(event.content || summary) && (
<article className="prose prose-neutral dark:prose-invert max-w-none">
{event.content ? (
<NoteContent event={event} hideEmbedImages={!!image} />
) : (
<p className="text-muted-foreground">{summary}</p>
)}
</article>
)}
</section>
</>
)}
{/* External links */}
{links.length > 0 && (
<div className="flex flex-col gap-0.5">
{links.map((url) => (
<a
key={url}
href={url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-4 px-3 py-3 rounded-full bg-background/85 hover:bg-secondary/60 transition-colors"
>
<LinkIcon className="size-5 text-primary shrink-0" />
<span className="text-sm truncate flex-1">{url.replace(/^https?:\/\//, '')}</span>
<ExternalLink className="size-4 text-muted-foreground shrink-0" />
</a>
))}
<section id="event-comments" className="scroll-mt-20">
<div className="flex items-baseline justify-between gap-3 mb-3 px-1">
<h2 className="text-lg font-semibold tracking-tight">Comments</h2>
{replyTree.length > 0 ? (
<span className="text-sm text-muted-foreground tabular-nums">
{replyTree.length.toLocaleString()} {replyTree.length === 1 ? 'comment' : 'comments'}
</span>
) : null}
</div>
<DetailCommentComposer event={event} className="mb-3" />
{commentsLoading ? (
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="rounded-2xl bg-card border border-border/60 px-4 py-3">
<div className="flex gap-3">
<Skeleton className="size-10 rounded-full shrink-0" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-2/3" />
</div>
</div>
</div>
))}
</div>
) : replyTree.length > 0 ? (
<div className="rounded-2xl bg-card border border-border/60 overflow-hidden">
<ThreadedReplyList
roots={replyTree}
renderItemHeader={(event) => (
<EventPinHeader
isPinned={isPinned(event.id)}
canManagePins={canManagePins}
pinPending={togglePin.isPending}
onTogglePin={() => handleTogglePin(event)}
/>
)}
/>
</div>
) : (
<button
type="button"
onClick={() => setReplyOpen(true)}
className="block w-full rounded-2xl border border-dashed border-border/80 bg-card/50 px-6 py-10 text-center hover:bg-card hover:border-primary/40 transition-colors"
>
<p className="text-base font-medium text-foreground">No comments yet</p>
<p className="mt-1 text-sm text-muted-foreground">Be the first to comment.</p>
</button>
)}
</section>
</div>
)}
{/* Participants */}
{participantsByRole.length > 0 && (
<>
<Separator />
<section className="space-y-3">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-2">
<Users className="size-4" /> Participants
</h2>
<div className="space-y-2">
{participantsByRole.map(([role, pubkeys]) =>
pubkeys.map((pk) => <PersonRow key={pk} pubkey={pk} label={role} size="sm" />),
)}
</div>
</section>
</>
)}
{/* Attendees */}
{rsvps.total > 0 && (
<>
<Separator />
<section className="space-y-3">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-2">
<Users className="size-4" /> Attendees
</h2>
<div className="space-y-2.5">
{([
['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 && (
<div key={label} className="flex items-center gap-3">
<Badge variant="outline" className={cn(cls, 'shrink-0 text-xs')}>{label} ({pks.length})</Badge>
<RSVPAvatars pubkeys={pks} maxVisible={8} size="sm" />
</div>
))}
</div>
</section>
</>
)}
{/* RSVP section */}
{showRSVP && (
<>
<Separator />
<section className="space-y-3">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-2">
<Check className="size-4" /> RSVP
</h2>
<div className="flex gap-2">
<Button
size="sm"
variant={myRsvp.status === 'accepted' ? 'default' : 'outline'}
disabled={publishRSVP.isPending}
className={cn('flex-1 rounded-full', myRsvp.status === 'accepted' && 'bg-green-600 hover:bg-green-700 text-white')}
onClick={() => handleRSVP('accepted')}
>
<Check className="size-3.5 mr-1.5" /> Going
</Button>
<Button
size="sm"
variant={myRsvp.status === 'tentative' ? 'default' : 'outline'}
disabled={publishRSVP.isPending}
className={cn('flex-1 rounded-full', myRsvp.status === 'tentative' && 'bg-amber-500 hover:bg-amber-600 text-white')}
onClick={() => handleRSVP('tentative')}
>
<Star className="size-3.5 mr-1.5" /> Interested
</Button>
<Button
size="sm"
variant={myRsvp.status === 'declined' ? 'default' : 'outline'}
disabled={publishRSVP.isPending}
className={cn('flex-1 rounded-full', myRsvp.status === 'declined' && 'bg-destructive hover:bg-destructive/90 text-destructive-foreground')}
onClick={() => handleRSVP('declined')}
>
<XIcon className="size-3.5 mr-1.5" /> Can't Go
</Button>
</div>
</section>
</>
)}
<PostActionBar
event={event}
replyLabel="Comments"
onReply={() => setReplyOpen(true)}
onMore={() => setMoreMenuOpen(true)}
className="-mx-5 px-5"
/>
<aside className="hidden lg:block lg:w-[360px] lg:shrink-0 lg:self-start">
<div className="lg:sticky lg:top-4 space-y-4">
{eventDetailsCard}
{participantsCard}
</div>
</aside>
</div>
<NoteMoreMenu event={event} open={moreMenuOpen} onOpenChange={setMoreMenuOpen} />
<ReplyComposeModal event={event} open={replyOpen} onOpenChange={setReplyOpen} />
@@ -446,32 +628,40 @@ export function CalendarEventDetailPage({ event }: { event: NostrEvent }) {
event={event}
/>
)}
<section>
{commentsLoading ? (
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="flex gap-3">
<Skeleton className="size-10 rounded-full shrink-0" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-2/3" />
</div>
</div>
))}
</div>
) : replyTree.length > 0 ? (
<div className="-mx-5">
<ThreadedReplyList roots={replyTree} />
</div>
) : (
<div className="py-8 text-center text-muted-foreground text-sm">
No comments yet. Be the first to comment!
</div>
)}
</section>
</div>
</div>
</main>
);
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 (
<PinnedCommentHeader
isPinned={isPinned}
canManagePins={canManagePins}
pinPending={pinPending}
onTogglePin={onTogglePin}
/>
);
}
+305
View File
@@ -0,0 +1,305 @@
import { useMemo } from 'react';
import type { ReactNode } from 'react';
import { Link } from 'react-router-dom';
import { CalendarClock, EyeOff, HandHeart, MapPin, ShieldCheck, Target } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Card } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
import { Skeleton } from '@/components/ui/skeleton';
import { CampaignModerationMenu } from '@/components/CampaignModerationMenu';
import { useAuthor } from '@/hooks/useAuthor';
import { useBtcPrice } from '@/hooks/useBtcPrice';
import { useCampaignDonations } from '@/hooks/useCampaignDonations';
import { useCampaignModeration } from '@/hooks/useCampaignModeration';
import {
type ParsedCampaign,
encodeCampaignNaddr,
getCampaignCountryLabel,
} from '@/lib/campaign';
import { formatCampaignAmount, formatUsdGoal, satsToUsd } from '@/lib/formatCampaignAmount';
import { genUserName } from '@/lib/genUserName';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import { cn } from '@/lib/utils';
function formatDeadline(unixSeconds: number): { label: string; isPast: boolean } {
const now = Math.floor(Date.now() / 1000);
const diff = unixSeconds - now;
if (diff <= 0) return { label: 'Ended', isPast: true };
const days = Math.ceil(diff / 86_400);
if (days <= 1) return { label: 'Ends today', isPast: false };
if (days < 30) return { label: `${days} days left`, isPast: false };
const months = Math.round(days / 30);
return { label: `${months} mo left`, isPast: false };
}
/**
* Short helper rendered both inline (cards) and in the detail page.
*
* 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,
goalUsd,
btcPrice,
className,
}: {
raisedSats: number;
goalUsd?: number;
btcPrice?: number;
className?: string;
}) {
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 (
<div className={cn('space-y-1.5', className)}>
{hasGoal && <Progress value={pct} className="h-2" />}
<div className="flex items-baseline justify-between gap-2 text-sm">
<span className="font-semibold">
{formatCampaignAmount(raisedSats, btcPrice)}
{!hasGoal && <span className="ml-1 font-normal text-muted-foreground">raised</span>}
</span>
{hasGoal && (
<span className="text-muted-foreground">of {formatUsdGoal(goalUsd!)} goal</span>
)}
</div>
</div>
);
}
/**
* 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 (
<div className={cn('space-y-1.5 text-sm', className)}>
<div className="flex items-center gap-1.5 text-muted-foreground">
<ShieldCheck className="size-3.5" />
<span>Private campaign totals are not public</span>
</div>
{goalUsd && goalUsd > 0 && (
<div className="text-xs text-muted-foreground">Target: {formatUsdGoal(goalUsd)}</div>
)}
</div>
);
}
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
* `<Link>` to the campaign's naddr-based detail route.
*/
export function CampaignCard({ campaign, variant = 'compact', className, footerBadge }: CampaignCardProps) {
const author = useAuthor(campaign.pubkey);
const { data: stats } = useCampaignDonations(campaign);
const { data: btcPrice } = useBtcPrice();
const { data: moderation } = useCampaignModeration();
const naddr = useMemo(() => encodeCampaignNaddr(campaign), [campaign]);
const cover = sanitizeUrl(campaign.banner);
const creatorName =
author.data?.metadata?.display_name ||
author.data?.metadata?.name ||
genUserName(campaign.pubkey);
const deadline = campaign.deadline ? formatDeadline(campaign.deadline) : null;
const raisedSats = stats?.totalSats ?? 0;
const countryLabel = getCampaignCountryLabel(campaign);
const isSilentPayment = campaign.wallet.mode === 'sp';
const isFeaturedVariant = variant === 'featured';
const isApproved = moderation.approvedCoords.has(campaign.aTag);
const isHidden = moderation.hiddenCoords.has(campaign.aTag);
const isFeatured = moderation.featuredCoords.has(campaign.aTag);
return (
<Link
to={`/${naddr}`}
className={cn(
'group block rounded-xl overflow-hidden focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background motion-safe:transition-transform motion-safe:duration-200 motion-safe:hover:-translate-y-0.5',
className,
)}
>
<Card
className={cn(
'overflow-hidden border-border/70 shadow-sm motion-safe:transition-shadow motion-safe:duration-200 group-hover:shadow-lg h-full flex flex-col',
isFeaturedVariant && 'sm:flex-row sm:items-stretch',
)}
>
{/* Cover image */}
<div
className={cn(
'relative w-full bg-gradient-to-br from-primary/15 via-primary/5 to-secondary',
isFeaturedVariant ? 'aspect-[16/10] sm:aspect-auto sm:w-1/2 sm:min-h-[280px]' : 'aspect-[16/9]',
)}
>
{cover ? (
<img
src={cover}
alt=""
loading="lazy"
className="absolute inset-0 size-full object-cover"
/>
) : (
<div className="absolute inset-0 flex items-center justify-center">
<HandHeart className="size-12 text-primary/40" />
</div>
)}
{isSilentPayment && (
<Badge
variant="secondary"
className="absolute top-3 left-3 backdrop-blur bg-background/80 border-border/40"
>
<ShieldCheck className="size-3.5 mr-1" />
Private
</Badge>
)}
<div className="absolute top-3 right-3 flex items-center gap-2">
{isHidden && (
<Badge
variant="secondary"
className="backdrop-blur bg-destructive/15 text-destructive border-destructive/30"
>
<EyeOff className="size-3.5 mr-1" />
Hidden
</Badge>
)}
<CampaignModerationMenu
coord={campaign.aTag}
campaignTitle={campaign.title}
isApproved={isApproved}
isHidden={isHidden}
isFeatured={isFeatured}
/>
</div>
</div>
{/* Body */}
<div className={cn('flex flex-col gap-3 p-5', isFeaturedVariant && 'sm:w-1/2 sm:p-6')}>
<div className="space-y-2">
<h3
className={cn(
'font-bold leading-tight tracking-tight',
isFeaturedVariant ? 'text-2xl sm:text-3xl' : 'text-lg',
)}
>
{campaign.title}
</h3>
{campaign.summary && (
<p
className={cn(
'text-muted-foreground',
isFeaturedVariant ? 'text-base line-clamp-3' : 'text-sm line-clamp-2',
)}
>
{campaign.summary}
</p>
)}
</div>
<div className="flex-1" />
{isSilentPayment ? (
<CampaignPrivateNotice goalUsd={campaign.goalUsd} />
) : (
<CampaignProgress raisedSats={raisedSats} goalUsd={campaign.goalUsd} btcPrice={btcPrice} />
)}
{/* Meta row */}
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5 text-xs text-muted-foreground pt-1">
{!isSilentPayment && stats && stats.donorCount > 0 && (
<span className="inline-flex items-center gap-1.5">
<Target className="size-3.5" />
{stats.donorCount} {stats.donorCount === 1 ? 'donor' : 'donors'}
</span>
)}
{countryLabel && (
<span className="inline-flex items-center gap-1.5">
<MapPin className="size-3.5" />
{countryLabel}
</span>
)}
{deadline && (
<span
className={cn(
'inline-flex items-center gap-1.5',
deadline.isPast && 'text-destructive',
)}
>
<CalendarClock className="size-3.5" />
{deadline.label}
</span>
)}
</div>
<div className="flex items-center justify-between gap-3 border-t border-border/60 pt-3 text-xs text-muted-foreground">
<div className="truncate">
by <span className="font-medium text-foreground">{creatorName}</span>
</div>
{footerBadge && <div className="shrink-0">{footerBadge}</div>}
</div>
</div>
</Card>
</Link>
);
}
/** Loading placeholder mirroring {@link CampaignCard} dimensions. */
export function CampaignCardSkeleton({
variant = 'compact',
className,
}: {
variant?: 'compact' | 'featured';
className?: string;
}) {
const isFeatured = variant === 'featured';
return (
<Card
className={cn(
'overflow-hidden border-border/70 shadow-sm h-full flex flex-col',
isFeatured && 'sm:flex-row sm:items-stretch',
className,
)}
>
<Skeleton
className={cn(
'w-full rounded-none',
isFeatured ? 'aspect-[16/10] sm:aspect-auto sm:w-1/2 sm:min-h-[280px]' : 'aspect-[16/9]',
)}
/>
<div className={cn('flex-1 p-5 space-y-3', isFeatured && 'sm:w-1/2 sm:p-6')}>
<Skeleton className={cn('w-3/4', isFeatured ? 'h-7' : 'h-5')} />
<div className="space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-4/5" />
</div>
<div className="flex-1" />
<Skeleton className="h-2 w-full" />
<Skeleton className="h-3 w-32" />
</div>
</Card>
);
}
+144
View File
@@ -0,0 +1,144 @@
import { useState } from 'react';
import { Check, EyeOff, Eye, Loader2, MoreHorizontal, ShieldCheck, ShieldOff, Sparkles, SparklesIcon } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useCampaignModeration, type ModerationLabel } from '@/hooks/useCampaignModeration';
import { useCampaignModerators } from '@/hooks/useCampaignModerators';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useToast } from '@/hooks/useToast';
interface CampaignModerationMenuProps {
/** The campaign's `30223:<pubkey>:<d>` coordinate. */
coord: string;
/** Visible label for the campaign (for toast feedback). */
campaignTitle: string;
/** Whether the campaign is currently approved. */
isApproved: boolean;
/** Whether the campaign is currently hidden. */
isHidden: boolean;
/** Whether the campaign is currently featured. */
isFeatured: boolean;
className?: string;
}
/**
* Per-card kebab menu exposing the six moderation actions:
* Approve / Unapprove (axis = approval)
* Hide / Unhide (axis = hide)
* Feature / Unfeature (axis = featured)
*
* Renders `null` for users who are not Team Soapbox pack members. Sits
* inside the clickable `CampaignCard` `<Link>`, so the trigger swallows
* its own click + the dropdown content stops propagation, otherwise every
* menu interaction would navigate to the campaign detail page.
*/
export function CampaignModerationMenu({
coord,
campaignTitle,
isApproved,
isHidden,
isFeatured,
className,
}: CampaignModerationMenuProps) {
const { user } = useCurrentUser();
const { data: moderators } = useCampaignModerators();
const { moderate } = useCampaignModeration();
const { toast } = useToast();
const [busy, setBusy] = useState<ModerationLabel | null>(null);
const isMod = !!user && !!moderators && moderators.includes(user.pubkey);
if (!isMod) return null;
const runAction = async (action: ModerationLabel, verbPast: string) => {
if (busy) return;
setBusy(action);
try {
await moderate.mutateAsync({ coord, action });
toast({ title: `${verbPast}`, description: campaignTitle });
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
toast({
title: `Failed to ${action}`,
description: message,
variant: 'destructive',
});
} finally {
setBusy(null);
}
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild onClick={(e) => e.preventDefault()}>
<Button
variant="ghost"
size="icon"
aria-label="Moderate campaign"
className={className ?? 'h-8 w-8 bg-background/80 backdrop-blur text-muted-foreground hover:text-foreground'}
>
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <MoreHorizontal className="h-4 w-4" />}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" onClick={(e) => e.stopPropagation()}>
<DropdownMenuLabel className="text-xs text-muted-foreground">
Moderator actions
</DropdownMenuLabel>
<DropdownMenuSeparator />
{isApproved ? (
<DropdownMenuItem onClick={() => runAction('unapproved', 'Removed from homepage')}>
<ShieldOff className="h-4 w-4 mr-2" />
Unapprove
<span className="ml-auto text-xs text-muted-foreground inline-flex items-center gap-1">
<Check className="h-3 w-3" /> Approved
</span>
</DropdownMenuItem>
) : (
<DropdownMenuItem onClick={() => runAction('approved', 'Approved for homepage')}>
<ShieldCheck className="h-4 w-4 mr-2" />
Approve
</DropdownMenuItem>
)}
{isHidden ? (
<DropdownMenuItem onClick={() => runAction('unhidden', 'Unhidden')}>
<Eye className="h-4 w-4 mr-2" />
Unhide
<span className="ml-auto text-xs text-muted-foreground inline-flex items-center gap-1">
<Check className="h-3 w-3" /> Hidden
</span>
</DropdownMenuItem>
) : (
<DropdownMenuItem
onClick={() => runAction('hidden', 'Hidden')}
className="text-destructive focus:text-destructive"
>
<EyeOff className="h-4 w-4 mr-2" />
Hide
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
{isFeatured ? (
<DropdownMenuItem onClick={() => runAction('unfeatured', 'Removed from featured')}>
<SparklesIcon className="h-4 w-4 mr-2" />
Unfeature
<span className="ml-auto text-xs text-muted-foreground inline-flex items-center gap-1">
<Check className="h-3 w-3" /> Featured
</span>
</DropdownMenuItem>
) : (
<DropdownMenuItem onClick={() => runAction('featured', 'Featured on homepage')}>
<Sparkles className="h-4 w-4 mr-2" />
Feature
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -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 `<Link>` 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 <CampaignCard campaign={campaign} className="mt-2" />;
}
@@ -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 (
<div className="space-y-5">
{/* 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). */}
<div className="flex justify-center">
<div className="relative rounded-2xl bg-white p-4 shadow-sm">
<QRCodeCanvas value={qrPayload} size={280} level="H" />
<div
aria-hidden
className="absolute inset-0 flex items-center justify-center pointer-events-none"
>
<div className="rounded-full bg-primary p-2 ring-[6px] ring-white">
<img
src="/logo.svg"
alt=""
className="size-16 object-contain brightness-0 invert"
draggable={false}
/>
</div>
</div>
</div>
</div>
{/* Copyable value — single line, tap to copy. No wrapping
container; sits flush with the rest of the column. */}
<button
type="button"
onClick={copyValue}
className="w-full flex items-center gap-2 rounded-lg border bg-muted/40 px-3 py-2.5 font-mono text-xs text-left hover:bg-muted/60 motion-safe:transition-colors"
aria-label={wallet.mode === 'sp' ? 'Copy silent-payment code' : 'Copy Bitcoin address'}
>
<span className="flex-1 min-w-0 truncate" title={wallet.value}>
{wallet.value}
</span>
{copied ? (
<Check className="size-4 text-green-500 shrink-0" />
) : (
<Copy className="size-4 text-muted-foreground shrink-0" />
)}
</button>
{wallet.mode === 'onchain' ? (
<BitcoinPublicDisclaimer
tone="soft"
includeCashOutAdvice={false}
leadText="Donations are public and can be traced back to you."
/>
) : (
<div className="flex items-start gap-2 rounded-lg bg-muted/40 px-3 py-2.5 text-xs text-muted-foreground">
<ShieldCheck className="size-4 shrink-0 mt-0.5 text-primary" />
<span>
Silent-payment campaigns are unlinkable by design. Your donation
cannot be tied to the campaign by anyone other than the organizer.
</span>
</div>
)}
{/* 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. */}
<Button asChild className="w-full text-white">
<a href={qrPayload}>
<ExternalLink className="size-4 mr-1.5" />
Open in wallet
</a>
</Button>
</div>
);
}
/**
* 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 (
<div className="flex items-center gap-2 text-sm">
<AlertTriangle className="size-5 text-orange-500 shrink-0" />
<span>This campaign is missing a valid wallet endpoint.</span>
</div>
);
}
+83 -159
View File
@@ -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, 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,11 +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 { 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';
@@ -144,10 +144,11 @@ const KIND_LABELS: Record<number, string> = {
32267: 'a Zapstore app',
34139: 'a playlist',
34236: 'a divine',
34550: 'a community',
34550: 'an organization',
9041: 'a goal',
33863: 'a campaign',
35128: 'an nsite',
36639: 'an action',
36639: 'a pledge',
36787: 'a track',
37381: 'a Magic deck',
37516: 'a treasure',
@@ -178,7 +179,7 @@ const KIND_ICONS: Partial<Record<number, React.ComponentType<{ className?: strin
1618: GitPullRequest,
15128: Rocket,
35128: Rocket,
36639: Zap,
36639: Megaphone,
10008: Award,
30008: Award,
30009: Award,
@@ -203,6 +204,7 @@ const KIND_ICONS: Partial<Record<number, React.ComponentType<{ className?: strin
39089: PartyPopper,
3367: Palette,
9041: Target,
33863: HandHeart,
9735: Zap,
9802: Highlighter,
2473: Bird,
@@ -244,7 +246,8 @@ const KIND_SUFFIXES: Partial<Record<number, string>> = {
37381: 'deck',
37516: 'treasure',
30621: 'constellation',
34550: 'community',
34550: 'organization',
33863: 'campaign',
30054: 'episode',
30055: 'trailer',
34139: 'playlist',
@@ -383,6 +386,7 @@ function EventHoverLink({ display, link, hoverContent }: EventHoverLinkProps) {
interface CommentContextProps {
event: NostrEvent;
className?: string;
prefix?: string;
}
/**
@@ -390,7 +394,7 @@ interface CommentContextProps {
* When the parent item (lowercase k tag) is another kind 1111 comment, shows "Replying to @user"
* using the lowercase p tag (parent author). Otherwise shows "Commenting on [root]".
*/
export function CommentContext({ event, className }: CommentContextProps) {
export function CommentContext({ event, className, prefix = 'Commenting on' }: CommentContextProps) {
// If the direct parent is another comment (k="1111"), show "Replying to @user"
const parentKind = event.tags.find(([name]) => name === 'k')?.[1];
const parentAuthorPubkey = event.tags.findLast(([name]) => name === 'p')?.[1];
@@ -405,11 +409,11 @@ export function CommentContext({ event, className }: CommentContextProps) {
switch (root.type) {
case 'addr':
return <AddrCommentContext root={root} className={className} />;
return <AddrCommentContext root={root} className={className} prefix={prefix} />;
case 'event':
return <EventCommentContext root={root} className={className} />;
return <EventCommentContext root={root} className={className} prefix={prefix} />;
case 'external':
return <ExternalCommentContext root={root} className={className} />;
return <ExternalCommentContext root={root} className={className} prefix={prefix} />;
default:
return null;
}
@@ -444,27 +448,27 @@ function ReplyToCommentContext({ pubkey, eventId, className }: { pubkey: string;
}
/** Comment context for addressable event roots (A tag). */
function AddrCommentContext({ root, className }: { root: CommentRoot; className?: string }) {
function AddrCommentContext({ root, className, prefix }: { root: CommentRoot; className?: string; prefix: string }) {
// Kind 0 (profile) roots get special treatment — show "@DisplayName" with a profile link
if (root.addr?.kind === 0) {
return <ProfileCommentContext pubkey={root.addr.pubkey} className={className} />;
return <ProfileCommentContext pubkey={root.addr.pubkey} className={className} prefix={prefix} />;
}
// Kind 10008 or 30008 (profile badges) roots — show "@User's profile badges"
if (root.addr?.kind === 10008 || root.addr?.kind === 30008) {
return <ProfileBadgesCommentContext root={root} className={className} />;
return <ProfileBadgesCommentContext root={root} className={className} prefix={prefix} />;
}
// Kind 3 follow lists have no title of their own — synthesize one from the author's name
if (root.addr?.kind === 3) {
return <FollowListCommentContext pubkey={root.addr.pubkey} className={className} />;
return <FollowListCommentContext pubkey={root.addr.pubkey} className={className} prefix={prefix} />;
}
return <GenericAddrCommentContext root={root} className={className} />;
return <GenericAddrCommentContext root={root} className={className} prefix={prefix} />;
}
/** Comment context for kind 3 (follow list) roots — shows "Commenting on @Name's follow list". */
function FollowListCommentContext({ pubkey, className }: { pubkey: string; className?: string }) {
function FollowListCommentContext({ pubkey, className, prefix }: { pubkey: string; className?: string; prefix: string }) {
const author = useAuthor(pubkey);
const metadata = author.data?.metadata;
const displayName = metadata?.name ?? metadata?.display_name ?? genUserName(pubkey);
@@ -475,7 +479,7 @@ function FollowListCommentContext({ pubkey, className }: { pubkey: string; class
);
return (
<CommentContextRow prefix="Commenting on" className={className} loading={author.isLoading}>
<CommentContextRow prefix={prefix} className={className} loading={author.isLoading}>
<ProfileHoverCard pubkey={pubkey} asChild>
<Link
to={`/${npubEncoded}`}
@@ -498,14 +502,14 @@ function FollowListCommentContext({ pubkey, className }: { pubkey: string; class
}
/** Comment context for kind 0 (profile) roots — shows "Commenting on @Name". */
function ProfileCommentContext({ pubkey, className }: { pubkey: string; className?: string }) {
function ProfileCommentContext({ pubkey, className, prefix }: { pubkey: string; className?: string; prefix: string }) {
const author = useAuthor(pubkey);
const metadata = author.data?.metadata;
const displayName = metadata?.name ?? metadata?.display_name ?? genUserName(pubkey);
const npubEncoded = useMemo(() => nip19.npubEncode(pubkey), [pubkey]);
return (
<CommentContextRow prefix="Commenting on" className={className} loading={author.isLoading}>
<CommentContextRow prefix={prefix} className={className} loading={author.isLoading}>
<ProfileHoverCard pubkey={pubkey} asChild>
<Link
to={`/${npubEncoded}`}
@@ -520,7 +524,7 @@ function ProfileCommentContext({ pubkey, className }: { pubkey: string; classNam
}
/** Comment context for kind 10008/30008 (profile badges) roots — shows "Commenting on profile badges by @User". */
function ProfileBadgesCommentContext({ root, className }: { root: CommentRoot; className?: string }) {
function ProfileBadgesCommentContext({ root, className, prefix }: { root: CommentRoot; className?: string; prefix: string }) {
const pubkey = root.addr?.pubkey ?? '';
const author = useAuthor(pubkey);
const metadata = author.data?.metadata;
@@ -542,7 +546,7 @@ function ProfileBadgesCommentContext({ root, className }: { root: CommentRoot; c
) : undefined;
return (
<CommentContextRow prefix="Commenting on" className={className} loading={author.isLoading}>
<CommentContextRow prefix={prefix} className={className} loading={author.isLoading}>
{link && hoverContent ? (
<EventHoverLink
display={{ text: 'profile badges', icon: Award }}
@@ -570,11 +574,11 @@ function ProfileBadgesCommentContext({ root, className }: { root: CommentRoot; c
}
/** Comment context for non-profile addressable event roots (A tag). */
function GenericAddrCommentContext({ root, className }: { root: CommentRoot; className?: string }) {
function GenericAddrCommentContext({ root, className, prefix }: { root: CommentRoot; className?: string; prefix: string }) {
const { data: event, isLoading } = useAddrEvent(root.addr, root.relayHint ? [root.relayHint] : undefined);
const isCommunity = root.rootKind === '34550' || root.addr?.kind === 34550;
const prefix = isCommunity ? 'Posted in' : 'Commenting on';
const rowPrefix = isCommunity && prefix === 'Commenting on' ? 'Posted in' : prefix;
const display = event ? getEventDisplayName(event) : { text: getRootKindLabel(root.rootKind) };
const link = event ? getRootLink(event) : undefined;
@@ -588,7 +592,7 @@ function GenericAddrCommentContext({ root, className }: { root: CommentRoot; cla
) : undefined;
return (
<CommentContextRow prefix={prefix} className={className} loading={isLoading}>
<CommentContextRow prefix={rowPrefix} className={className} loading={isLoading}>
{link && hoverContent ? (
<EventHoverLink display={display} link={link} hoverContent={hoverContent} />
) : link ? (
@@ -608,7 +612,7 @@ function GenericAddrCommentContext({ root, className }: { root: CommentRoot; cla
}
/** Comment context for regular event roots (E tag). */
function EventCommentContext({ root, className }: { root: CommentRoot; className?: string }) {
function EventCommentContext({ root, className, prefix }: { root: CommentRoot; className?: string; prefix: string }) {
const { data: event, isLoading } = useEvent(
root.eventId,
root.relayHint ? [root.relayHint] : undefined,
@@ -617,12 +621,12 @@ function EventCommentContext({ root, className }: { root: CommentRoot; className
// Kind 7 reactions get special treatment
if (event?.kind === 7) {
return <ReactionCommentContext event={event} className={className} />;
return <ReactionCommentContext event={event} className={className} prefix={prefix} />;
}
// Kind 1018 poll votes get special treatment
if (event?.kind === 1018) {
return <PollVoteCommentContext event={event} className={className} />;
return <PollVoteCommentContext event={event} className={className} prefix={prefix} />;
}
const display = event ? getEventDisplayName(event) : { text: getRootKindLabel(root.rootKind) };
@@ -639,7 +643,7 @@ function EventCommentContext({ root, className }: { root: CommentRoot; className
) : undefined;
return (
<CommentContextRow prefix="Commenting on" className={className} loading={isLoading}>
<CommentContextRow prefix={prefix} className={className} loading={isLoading}>
{link && hoverContent ? (
<EventHoverLink display={display} link={link} hoverContent={hoverContent} />
) : (
@@ -650,7 +654,7 @@ function EventCommentContext({ root, className }: { root: CommentRoot; className
}
/** Comment context for kind 7 reaction roots — shows "Commenting on {emoji} by @{name}". */
function ReactionCommentContext({ event, className }: { event: NostrEvent; className?: string }) {
function ReactionCommentContext({ event, className, prefix }: { event: NostrEvent; className?: string; prefix: string }) {
const author = useAuthor(event.pubkey);
const metadata = author.data?.metadata;
const displayName = getDisplayName(metadata, event.pubkey);
@@ -658,7 +662,7 @@ function ReactionCommentContext({ event, className }: { event: NostrEvent; class
const profileLink = `/${nip19.npubEncode(event.pubkey)}`;
return (
<CommentContextRow prefix="Commenting on" className={className}>
<CommentContextRow prefix={prefix} className={className}>
<Link
to={reactionLink}
className="text-primary hover:underline shrink-0 cursor-pointer"
@@ -685,7 +689,7 @@ function ReactionCommentContext({ event, className }: { event: NostrEvent; class
}
/** Comment context for kind 1018 poll vote roots — shows "Commenting on @{name}'s vote for {option}". */
function PollVoteCommentContext({ event, className }: { event: NostrEvent; className?: string }) {
function PollVoteCommentContext({ event, className, prefix }: { event: NostrEvent; className?: string; prefix: string }) {
const author = useAuthor(event.pubkey);
const metadata = author.data?.metadata;
const displayName = getDisplayName(metadata, event.pubkey);
@@ -695,7 +699,7 @@ function PollVoteCommentContext({ event, className }: { event: NostrEvent; class
const voteLabel = usePollVoteLabel(event);
return (
<CommentContextRow prefix="Commenting on" className={className}>
<CommentContextRow prefix={prefix} className={className}>
{author.isLoading ? (
<Skeleton className="h-3.5 w-16 inline-block" />
) : (
@@ -722,12 +726,12 @@ function PollVoteCommentContext({ event, className }: { event: NostrEvent; class
}
/** Comment context for external content roots (I tag). */
function ExternalCommentContext({ root, className }: { root: CommentRoot; className?: string }) {
function ExternalCommentContext({ root, className, prefix }: { root: CommentRoot; className?: string; prefix: string }) {
const identifier = root.identifier ?? '';
// ISBN identifiers get special treatment — show book title instead of raw ISBN
if (identifier.startsWith('isbn:')) {
return <IsbnCommentContext identifier={identifier} className={className} />;
return <IsbnCommentContext identifier={identifier} className={className} prefix={prefix} />;
}
// URL identifiers get special treatment — show page title with favicon.
@@ -736,21 +740,21 @@ function ExternalCommentContext({ root, className }: { root: CommentRoot; classN
if (identifier.startsWith('http://') || identifier.startsWith('https://')) {
const gathererCard = extractGathererCard(identifier);
if (gathererCard) {
return <GathererCardCommentContext card={gathererCard} url={identifier} className={className} />;
return <GathererCardCommentContext card={gathererCard} url={identifier} className={className} prefix={prefix} />;
}
return <UrlCommentContext url={identifier} className={className} />;
return <UrlCommentContext url={identifier} className={className} prefix={prefix} />;
}
// ISO 3166 country/subdivision identifiers get special treatment
if (identifier.startsWith('iso3166:')) {
return <CountryCommentContext identifier={identifier} className={className} />;
return <CountryCommentContext identifier={identifier} className={className} prefix={prefix} />;
}
// Generic fallback for other external identifiers
const link = `/i/${encodeURIComponent(identifier)}`;
return (
<CommentContextRow prefix="Commenting on" className={className}>
<CommentContextRow prefix={prefix} className={className}>
<Link
to={link}
className="text-primary hover:underline truncate"
@@ -763,7 +767,7 @@ function ExternalCommentContext({ root, className }: { root: CommentRoot; classN
}
/** Comment context for URL identifiers — fetches and displays the page title with favicon. */
function UrlCommentContext({ url, className }: { url: string; className?: string }) {
function UrlCommentContext({ url, className, prefix }: { url: string; className?: string; prefix: string }) {
const { data: preview, isLoading } = useLinkPreview(url);
const link = `/i/${encodeURIComponent(url)}`;
@@ -777,7 +781,7 @@ function UrlCommentContext({ url, className }: { url: string; className?: string
const title = preview?.title;
return (
<CommentContextRow prefix="Commenting on" className={className} loading={isLoading}>
<CommentContextRow prefix={prefix} className={className} loading={isLoading}>
<ExternalFavicon url={url} size={14} className="shrink-0" />
<HoverCard openDelay={300} closeDelay={150}>
<HoverCardTrigger asChild>
@@ -810,6 +814,16 @@ function CountryPillBadge({ identifier, className }: { identifier: string; class
const link = `/i/${encodeURIComponent(identifier)}`;
const flag = info?.flag ?? '🌍';
// Treat ISO codes with a curated custom flag (Tibet) as country-level
// throughout the pill chrome — display its own name, drop the parent
// country sub-line, and label it as a country rather than a region.
const treatAsCountry = hasCustomFlag(code);
const displayLabel = treatAsCountry
? info?.subdivisionName ?? info?.name ?? code
: info?.subdivisionName ?? info?.name ?? code;
const subLabel = !treatAsCountry && info?.subdivisionName && info.name ? info.name : null;
const tierLabel = info?.subdivisionName && !treatAsCountry ? 'Region' : 'Country';
const ariaLabel = info ? `Flag of ${displayLabel}` : code;
return (
<HoverCard openDelay={300} closeDelay={150}>
@@ -827,9 +841,9 @@ function CountryPillBadge({ identifier, className }: { identifier: string; class
className,
)}
>
<span role="img" aria-label={info ? `Flag of ${info.name}` : code}>
{flag}
</span>
{/* CountryFlag swaps in a bundled SVG (Tibet's Snow Lion etc.)
when the ISO code has no Unicode flag — emoji otherwise. */}
<CountryFlag code={code} emoji={flag} label={ariaLabel} />
</Link>
</HoverCardTrigger>
<HoverCardContent
@@ -840,20 +854,23 @@ function CountryPillBadge({ identifier, className }: { identifier: string; class
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center gap-3 px-4 py-3">
<span className="text-2xl leading-none shrink-0" role="img" aria-label={info ? `Flag of ${info.name}` : code}>
{flag}
</span>
<CountryFlag
code={code}
emoji={flag}
label={ariaLabel}
className="text-2xl shrink-0"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<MapPin className="size-3 shrink-0" />
<span>{info?.subdivisionName ? 'Region' : 'Country'}</span>
<span>{tierLabel}</span>
</div>
<p className="text-sm font-medium truncate mt-0.5">
{info?.subdivisionName ?? info?.name ?? code}
{displayLabel}
</p>
{info?.subdivisionName && info.name && (
{subLabel && (
<p className="text-xs text-muted-foreground truncate">
{info.name}
{subLabel}
</p>
)}
</div>
@@ -896,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 {
@@ -923,105 +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;
// 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
? (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 (
<div aria-hidden className="pointer-events-none absolute inset-0 z-0 overflow-hidden">
<div className="absolute top-0 left-0 right-0 h-64 sm:h-72">
{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.
<img
src={flagImage}
alt=""
decoding="async"
onError={() => setImageFailed(true)}
className="w-full h-full object-cover opacity-60 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.
<div
className="absolute inset-0 opacity-60"
style={{
backgroundImage: paletteGradient,
maskImage: 'linear-gradient(to bottom, black 0%, black 35%, transparent 100%)',
WebkitMaskImage: 'linear-gradient(to bottom, black 0%, black 35%, transparent 100%)',
}}
/>
) : null}
{/* Black wash for foreground readability. Mirrors the mask shape
so the wash itself fades along with the flag — no hard edge. */}
<div
className="absolute inset-0"
style={{
backgroundImage:
'linear-gradient(to bottom, rgba(0,0,0,0.7) 0%, rgba(0,0,0,0.75) 50%, rgba(0,0,0,0) 100%)',
maskImage: 'linear-gradient(to bottom, black 0%, black 35%, transparent 100%)',
WebkitMaskImage: 'linear-gradient(to bottom, black 0%, black 35%, transparent 100%)',
}}
/>
</div>
</div>
);
}
/**
* Body-level comment context for ISO 3166 roots — intentionally renders
@@ -1029,12 +951,12 @@ export function CountryFlagBackdrop({ event }: { event: NostrEvent }) {
* `CountryCommentPill`, so we suppress the in-body version to avoid
* duplication.
*/
function CountryCommentContext(_props: { identifier: string; className?: string }) {
function CountryCommentContext(_props: { identifier: string; className?: string; prefix: string }) {
return null;
}
/** Comment context for ISBN identifiers — fetches and displays the book title with hover preview. */
function IsbnCommentContext({ identifier, className }: { identifier: string; className?: string }) {
function IsbnCommentContext({ identifier, className, prefix }: { identifier: string; className?: string; prefix: string }) {
const isbn = identifier.slice('isbn:'.length);
const { data: bookInfo, isLoading } = useBookInfo(isbn);
const link = `/i/${encodeURIComponent(identifier)}`;
@@ -1043,7 +965,7 @@ function IsbnCommentContext({ identifier, className }: { identifier: string; cla
const authors = bookInfo?.authors?.map((a) => a.name).join(', ');
return (
<CommentContextRow prefix="Commenting on" className={className} loading={isLoading}>
<CommentContextRow prefix={prefix} className={className} loading={isLoading}>
<HoverCard openDelay={300} closeDelay={150}>
<HoverCardTrigger asChild>
<Link
@@ -1105,10 +1027,12 @@ function GathererCardCommentContext({
card,
url,
className,
prefix,
}: {
card: GathererCard;
url: string;
className?: string;
prefix: string;
}) {
const lookup = useMemo(() => (
card.kind === 'multiverse'
@@ -1122,7 +1046,7 @@ function GathererCardCommentContext({
const coverUrl = scryCard ? cardPrimaryImage(scryCard, 'small') : undefined;
return (
<CommentContextRow prefix="Commenting on" className={className} loading={isLoading}>
<CommentContextRow prefix={prefix} className={className} loading={isLoading}>
<HoverCard openDelay={300} closeDelay={150}>
<HoverCardTrigger asChild>
<Link
-335
View File
@@ -1,335 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Award, Loader2 } from 'lucide-react';
import { useNostr } from '@nostrify/react';
import { useQueryClient } from '@tanstack/react-query';
import type { NostrEvent } from '@nostrify/nostrify';
import { BadgeThumbnail } from '@/components/BadgeThumbnail';
import { ImageUploadField } from '@/components/ImageUploadField';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { useBadgeDefinitions, type BadgeDefinition } from '@/hooks/useBadgeDefinitions';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useToast } from '@/hooks/useToast';
import { BADGE_DEFINITION_KIND, COMMUNITY_DEFINITION_KIND, type ParsedCommunity } from '@/lib/communityUtils';
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
interface BadgeRef {
pubkey: string;
identifier: string;
}
interface CommunityBadgePanelProps {
communityEvent: NostrEvent;
community: ParsedCommunity;
isFounder: boolean;
}
function parseBadgeATag(aTag: string | undefined): BadgeRef | undefined {
if (!aTag) return undefined;
const [kind, pubkey, ...identifierParts] = aTag.split(':');
const identifier = identifierParts.join(':');
if (kind !== String(BADGE_DEFINITION_KIND) || !pubkey || !identifier) return undefined;
return { pubkey, identifier };
}
function buildBadgeTags(baseTags: string[][], dTag: string, name: string, description: string, imageUrl: string): string[][] {
const tags = baseTags.filter(([tagName]) => !['d', 'name', 'description', 'image', 'thumb', 'alt'].includes(tagName));
const nextTags: string[][] = [
['d', dTag],
['name', name.trim()],
];
if (description.trim()) {
nextTags.push(['description', description.trim()]);
}
const image = sanitizeUrl(imageUrl.trim());
if (image) {
nextTags.push(['image', image, '1024x1024']);
}
nextTags.push(...tags);
nextTags.push(['alt', `Badge definition: ${name.trim()}`]);
return nextTags;
}
function buildCommunityBadgeTags(baseTags: string[][], badgeATag: string): string[][] {
return [
...baseTags.filter(([tagName, value, , role]) => !(tagName === 'a' && value?.startsWith(`${BADGE_DEFINITION_KIND}:`) && role === 'member')),
['a', badgeATag, '', 'member'],
];
}
export function CommunityBadgePanel({ communityEvent, community, isFounder }: CommunityBadgePanelProps) {
const [editOpen, setEditOpen] = useState(false);
const badgeRef = useMemo(() => parseBadgeATag(community.memberBadgeATag), [community.memberBadgeATag]);
const badgeRefs = useMemo(() => badgeRef ? [badgeRef] : [], [badgeRef]);
const { badgeMap, isLoading, isError } = useBadgeDefinitions(badgeRefs);
const badge = community.memberBadgeATag ? badgeMap.get(community.memberBadgeATag) : undefined;
const badgeButtonLabel = badge ? `Edit ${badge.name} badge` : 'Set member badge';
const badgeVisual = isLoading ? (
<div className="size-10 animate-pulse rounded-lg bg-muted" />
) : badge ? (
<BadgeThumbnail badge={badge} size={40} className="shrink-0" />
) : (
<div className="flex size-10 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
<Award className="size-4" />
</div>
);
return (
<div className="min-w-0 flex-1">
<p className="mb-1 text-[11px] font-medium text-muted-foreground uppercase tracking-wider">Member badge</p>
<div className="flex items-center gap-3 py-1">
{isFounder ? (
<button
type="button"
onClick={() => setEditOpen(true)}
className="shrink-0 rounded-lg transition-opacity hover:opacity-80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
aria-label={badgeButtonLabel}
title={badgeButtonLabel}
>
{badgeVisual}
</button>
) : badgeVisual}
<div className="min-w-0 flex-1">
{isError ? (
<p className="text-sm text-destructive">Failed to load badge</p>
) : isLoading ? (
<div className="space-y-1.5">
<div className="h-4 w-24 animate-pulse rounded bg-muted" />
<div className="h-3 w-16 animate-pulse rounded bg-muted" />
</div>
) : badge ? (
<>
<p className="truncate text-sm font-medium">{badge.name}</p>
<p className="truncate text-xs text-muted-foreground">Community member badge</p>
</>
) : (
<>
<p className="truncate text-sm font-medium">Member badge</p>
<p className="truncate text-xs text-muted-foreground">
{isFounder ? 'Click the badge image to set one' : 'No badge set yet'}
</p>
</>
)}
</div>
</div>
{isFounder && (
<CommunityBadgeDialog
open={editOpen}
onOpenChange={setEditOpen}
communityEvent={communityEvent}
community={community}
badge={badge}
/>
)}
</div>
);
}
function CommunityBadgeDialog({
open,
onOpenChange,
communityEvent,
community,
badge,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
communityEvent: NostrEvent;
community: ParsedCommunity;
badge?: BadgeDefinition;
}) {
const { user } = useCurrentUser();
const { nostr } = useNostr();
const { toast } = useToast();
const queryClient = useQueryClient();
const { mutateAsync: publishEvent } = useNostrPublish();
const [name, setName] = useState('Member');
const [description, setDescription] = useState('');
const [imageUrl, setImageUrl] = useState('');
const [isPublishing, setIsPublishing] = useState(false);
const [isImageUploading, setIsImageUploading] = useState(false);
const canEditExistingBadge = !!badge && !!user && badge.event.pubkey === user.pubkey;
const canSave = !badge || canEditExistingBadge;
const resetForm = useCallback(() => {
setName(badge?.name || 'Member');
setDescription(badge?.description || `Member of ${community.name}`);
setImageUrl(badge?.image || badge?.thumbs[0]?.url || '');
setIsPublishing(false);
setIsImageUploading(false);
}, [badge, community.name]);
useEffect(() => {
if (open) resetForm();
}, [open, resetForm]);
const handleOpenChange = useCallback((nextOpen: boolean) => {
if (!nextOpen) resetForm();
onOpenChange(nextOpen);
}, [onOpenChange, resetForm]);
const handleSave = useCallback(async () => {
if (!user || user.pubkey !== communityEvent.pubkey) return;
if (!name.trim()) {
toast({ title: 'Enter a badge name', variant: 'destructive' });
return;
}
if (isImageUploading) {
toast({ title: 'Image is still uploading', description: 'Please wait for the upload to finish.' });
return;
}
if (imageUrl.trim() && !sanitizeUrl(imageUrl.trim())) {
toast({ title: 'Badge image must be a valid https URL', variant: 'destructive' });
return;
}
if (badge && !canEditExistingBadge) {
toast({ title: 'Badge cannot be edited', description: 'Only the badge issuer can edit this member badge.', variant: 'destructive' });
return;
}
setIsPublishing(true);
try {
const targetDTag = badge?.identifier || `${community.dTag}-member`;
const prevBadge = await fetchFreshEvent(nostr, {
kinds: [BADGE_DEFINITION_KIND],
authors: [user.pubkey],
'#d': [targetDTag],
});
const baseBadge = prevBadge ?? badge?.event;
const badgeEvent = await publishEvent({
kind: BADGE_DEFINITION_KIND,
content: baseBadge?.content ?? '',
tags: buildBadgeTags(baseBadge?.tags ?? [['d', targetDTag]], targetDTag, name, description, imageUrl),
prev: prevBadge ?? undefined,
});
const badgeATag = `${BADGE_DEFINITION_KIND}:${badgeEvent.pubkey}:${targetDTag}`;
if (!community.memberBadgeATag) {
const prevCommunity = await fetchFreshEvent(nostr, {
kinds: [COMMUNITY_DEFINITION_KIND],
authors: [communityEvent.pubkey],
'#d': [community.dTag],
});
const baseCommunity = prevCommunity ?? communityEvent;
const updatedCommunity = await publishEvent({
kind: COMMUNITY_DEFINITION_KIND,
content: baseCommunity.content,
tags: buildCommunityBadgeTags(baseCommunity.tags, badgeATag),
prev: prevCommunity ?? undefined,
});
queryClient.setQueryData(['addr-event', COMMUNITY_DEFINITION_KIND, updatedCommunity.pubkey, community.dTag], updatedCommunity);
}
queryClient.setQueryData(['addr-event', BADGE_DEFINITION_KIND, badgeEvent.pubkey, targetDTag], badgeEvent);
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['badge-definitions-batch'], exact: false }),
queryClient.invalidateQueries({ queryKey: ['community-members', community.aTag], exact: false }),
queryClient.invalidateQueries({ queryKey: ['community-activity-feed'], exact: false }),
queryClient.invalidateQueries({ queryKey: ['my-communities'], exact: false }),
]);
toast({ title: badge ? 'Member badge updated' : 'Member badge added' });
handleOpenChange(false);
} catch (error) {
toast({
title: 'Failed to update member badge',
description: error instanceof Error ? error.message : 'Please try again.',
variant: 'destructive',
});
} finally {
setIsPublishing(false);
}
}, [
user, communityEvent, name, isImageUploading, imageUrl, badge, canEditExistingBadge, community, nostr,
publishEvent, description, queryClient, toast, handleOpenChange,
]);
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-md gap-0 p-0 overflow-hidden">
<DialogHeader className="px-5 pt-5 pb-3">
<DialogTitle className="flex items-center gap-2">
<Award className="size-5 text-primary" />
Member Badge
</DialogTitle>
<DialogDescription>
This badge is awarded to members of {community.name}.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 px-5 pb-5">
{badge && !canEditExistingBadge && (
<p className="rounded-lg border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
This badge was issued by another account, so it cannot be edited here.
</p>
)}
<div className="space-y-1.5">
<Label htmlFor="community-member-badge-name">Badge name *</Label>
<Input
id="community-member-badge-name"
value={name}
onChange={(e) => setName(e.target.value)}
disabled={!canSave || isPublishing}
maxLength={80}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="community-member-badge-description">Description</Label>
<Textarea
id="community-member-badge-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
disabled={!canSave || isPublishing}
rows={2}
/>
</div>
<ImageUploadField
id="community-member-badge-image"
label={<>Badge image <span className="text-muted-foreground font-normal">(recommended)</span></>}
value={imageUrl}
onChange={setImageUrl}
onUploadingChange={setIsImageUploading}
uploadToastTitle="Badge image uploaded"
previewAlt="Member badge preview"
objectFit="contain"
dropAreaClassName="min-h-28"
disabled={!canSave || isPublishing}
/>
<Button
onClick={handleSave}
disabled={!canSave || !name.trim() || isPublishing || isImageUploading}
className="w-full gap-2"
>
{isPublishing ? <><Loader2 className="size-4 animate-spin" /> Saving...</> : <><Award className="size-4" /> Save Badge</>}
</Button>
</div>
</DialogContent>
</Dialog>
);
}
-213
View File
@@ -1,213 +0,0 @@
import { useCallback, useMemo } from 'react';
import { Link } from 'react-router-dom';
import { MessageSquare } from 'lucide-react';
import { useQueryClient } from '@tanstack/react-query';
import type { NostrEvent, NostrMetadata } from '@nostrify/nostrify';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Skeleton } from '@/components/ui/skeleton';
import { ComposeBox } from '@/components/ComposeBox';
import { ContentWarningGuard } from '@/components/ContentWarningGuard';
import { NoteContent } from '@/components/NoteContent';
import { useAuthor } from '@/hooks/useAuthor';
import { useCommunityChatMessages, COMMUNITY_CHAT_KIND } from '@/hooks/useCommunityChatMessages';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useProfileUrl } from '@/hooks/useProfileUrl';
import { getDisplayName } from '@/lib/getDisplayName';
import type { CommunityMember, CommunityModeration } from '@/lib/communityUtils';
import { cn } from '@/lib/utils';
interface CommunityChatPanelProps {
communityATag: string;
moderation: CommunityModeration;
rankMap: ReadonlyMap<string, CommunityMember>;
isMembershipLoading: boolean;
}
function shortTimeAgo(timestamp: number): string {
const diff = Math.max(0, Math.floor(Date.now() / 1000) - timestamp);
if (diff < 60) return 'now';
if (diff < 3600) return `${Math.floor(diff / 60)}m`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h`;
return `${Math.floor(diff / 86400)}d`;
}
export function CommunityChatPanel({
communityATag,
moderation,
rankMap,
isMembershipLoading,
}: CommunityChatPanelProps) {
const queryClient = useQueryClient();
const { user } = useCurrentUser();
const { data: messages, isLoading, isError, error, queryKey } = useCommunityChatMessages(communityATag, moderation);
const isBanned = !!user && moderation.bannedPubkeys.has(user.pubkey);
const isMember = !!user && rankMap.has(user.pubkey) && !isBanned;
const disabledReason = !user
? 'Log in to chat with this community.'
: isMembershipLoading
? 'Loading membership...'
: isBanned
? 'You are banned from this community.'
: !isMember
? 'Only community members can chat.'
: undefined;
const canSend = !disabledReason;
const chatPublish = useMemo(() => ({
kind: COMMUNITY_CHAT_KIND,
tags: [['a', communityATag, '', 'root']],
suppressSuccessToast: true,
}), [communityATag]);
const handlePublished = useCallback((event: NostrEvent) => {
queryClient.setQueryData<NostrEvent[]>(queryKey, (old = []) => {
if (old.some((existing) => existing.id === event.id)) return old;
return [...old, event].sort((a, b) => b.created_at - a.created_at);
});
}, [queryClient, queryKey]);
return (
<div>
<div>
{disabledReason && (
<p className="px-4 pt-3 text-center text-xs text-muted-foreground">{disabledReason}</p>
)}
{canSend && (
<ComposeBox
compact
placeholder="What's up?"
customPublish={chatPublish}
hidePoll
submitLabel="Send"
onPublished={handlePublished}
/>
)}
</div>
<div>
{isLoading ? (
<CommunityChatSkeleton />
) : isError ? (
<div className="py-12 px-4 text-center text-sm text-destructive">
{error instanceof Error ? error.message : 'Failed to load community chat.'}
</div>
) : messages.length === 0 ? (
<div className="flex flex-col items-center justify-center px-4 py-12 text-center">
<div className="mb-3 rounded-full bg-primary/10 p-3">
<MessageSquare className="size-6 text-primary" />
</div>
<p className="text-sm font-medium">No messages yet</p>
<p className="mt-1 text-xs text-muted-foreground">Start the first live conversation here.</p>
</div>
) : (
<div>
{messages.map((event, index) => {
const previous = messages[index - 1];
const showAvatar = !previous
|| previous.pubkey !== event.pubkey
|| previous.created_at - event.created_at > 300;
return <CommunityChatMessage key={event.id} event={event} showAvatar={showAvatar} />;
})}
</div>
)}
</div>
</div>
);
}
function CommunityChatSkeleton() {
return (
<div className="space-y-4 px-2 py-3">
{Array.from({ length: 6 }).map((_, index) => (
<div key={index} className="flex items-start gap-3">
<Skeleton className="size-8 rounded-full" />
<div className="flex-1 space-y-2 pt-1">
<Skeleton className="h-3 w-24" />
<Skeleton className={cn('h-4', index % 2 === 0 ? 'w-4/5' : 'w-2/3')} />
</div>
</div>
))}
</div>
);
}
function CommunityChatMessage({ event, showAvatar }: { event: NostrEvent; showAvatar: boolean }) {
const { user } = useCurrentUser();
const author = useAuthor(event.pubkey);
const metadata: NostrMetadata | undefined = author.data?.metadata;
const displayName = getDisplayName(metadata, event.pubkey);
const profileUrl = useProfileUrl(event.pubkey, metadata);
const isOwnMessage = user?.pubkey === event.pubkey;
return (
<div
className={cn(
'group flex gap-3 px-4 py-3 transition-colors hover:bg-secondary/40',
!showAvatar && 'py-2',
isOwnMessage && 'justify-end',
)}
>
{!isOwnMessage && <ChatMessageAvatar showAvatar={showAvatar} profileUrl={profileUrl} metadata={metadata} displayName={displayName} createdAt={event.created_at} />}
<div className={cn('min-w-0 flex-1', isOwnMessage && 'flex flex-col items-end')}>
{showAvatar && (
<div className={cn('mb-0.5 flex items-baseline gap-2', isOwnMessage && 'justify-end')}>
<Link
to={profileUrl}
className={cn('truncate text-xs font-semibold text-primary hover:underline', isOwnMessage && 'order-2')}
onClick={(event) => event.stopPropagation()}
>
{displayName}
</Link>
<span className={cn('text-[10px] text-muted-foreground/60', isOwnMessage && 'order-1')}>{shortTimeAgo(event.created_at)}</span>
</div>
)}
<ContentWarningGuard event={event} className="w-full max-w-[64%] sm:max-w-xs">
<div
className={cn(
'inline-block w-fit max-w-[64%] break-words rounded-2xl px-3 py-2 text-sm leading-relaxed sm:max-w-xs',
isOwnMessage ? 'rounded-tr-md bg-primary text-primary-foreground text-right' : 'rounded-tl-md bg-secondary/60',
)}
>
<NoteContent event={event} disableNoteEmbeds />
</div>
</ContentWarningGuard>
</div>
{isOwnMessage && <ChatMessageAvatar showAvatar={showAvatar} profileUrl={profileUrl} metadata={metadata} displayName={displayName} createdAt={event.created_at} />}
</div>
);
}
function ChatMessageAvatar({
showAvatar,
profileUrl,
metadata,
displayName,
createdAt,
}: {
showAvatar: boolean;
profileUrl: string;
metadata: NostrMetadata | undefined;
displayName: string;
createdAt: number;
}) {
return (
<div className="w-8 shrink-0">
{showAvatar ? (
<Link to={profileUrl} onClick={(event) => event.stopPropagation()}>
<Avatar className="size-8">
<AvatarImage src={metadata?.picture} alt={displayName} />
<AvatarFallback className="bg-primary/15 text-[10px] text-primary">
{displayName.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
</Link>
) : (
<span className="hidden pt-0.5 text-[10px] text-muted-foreground/60 group-hover:block">
{shortTimeAgo(createdAt)}
</span>
)}
</div>
);
}
+1 -1
View File
@@ -11,7 +11,7 @@ function getTag(tags: string[][], name: string): string | undefined {
}
function parseCommunityEvent(event: NostrEvent) {
const name = getTag(event.tags, 'name') || getTag(event.tags, 'd') || 'Unnamed Community';
const name = getTag(event.tags, 'name') || getTag(event.tags, 'd') || 'Unnamed Organization';
const description = getTag(event.tags, 'description') || '';
const image = getTag(event.tags, 'image');
+3 -3
View File
@@ -14,7 +14,7 @@ const REPORT_TYPE_LABELS: Record<Nip56ReportType, string> = {
illegal: 'illegal content',
malware: 'malware',
impersonation: 'impersonation',
other: 'community guidelines',
other: 'organization guidelines',
};
interface CommunityContentWarningProps {
@@ -68,8 +68,8 @@ export function CommunityContentWarning({ event, children, className }: Communit
<p className="text-sm font-medium text-foreground">Reported Content</p>
<p className="text-xs text-muted-foreground leading-relaxed">
{reporterCount === 1
? `Reported by a community member for ${typeLabels}.`
: `Reported by ${reporterCount} community members for ${typeLabels}.`}
? `Reported by an organization moderator for ${typeLabels}.`
: `Reported by ${reporterCount} organization moderators for ${typeLabels}.`}
</p>
</div>
<Button
File diff suppressed because it is too large Load Diff
+209
View File
@@ -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:<pubkey>:<d>` 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` `<Link>`, 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 <CommunityModerationMenuInner coord={coord} organizationName={organizationName} className={className} />;
}
function CommunityModerationMenuInner({
coord,
organizationName,
className,
}: CommunityModerationMenuProps) {
const { data: moderation, moderate } = useOrganizationModeration();
const { toast } = useToast();
const [busy, setBusy] = useState<ModerationLabel | null>(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 (
<DropdownMenu>
<DropdownMenuTrigger asChild onClick={(e) => e.preventDefault()}>
<Button
variant="ghost"
size="icon"
aria-label="Moderate organization"
className={className ?? 'h-8 w-8 bg-background/80 backdrop-blur text-muted-foreground hover:text-foreground'}
>
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <MoreHorizontal className="h-4 w-4" />}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" onClick={(e) => e.stopPropagation()}>
<DropdownMenuLabel className="text-xs text-muted-foreground">
Moderator actions
</DropdownMenuLabel>
<DropdownMenuSeparator />
{isFeatured ? (
<DropdownMenuItem onClick={() => runAction('unfeatured', 'Removed from featured')}>
<SparklesIcon className="h-4 w-4 mr-2" />
Unfeature
<span className="ml-auto text-xs text-muted-foreground inline-flex items-center gap-1">
<Check className="h-3 w-3" /> Featured
</span>
</DropdownMenuItem>
) : (
<DropdownMenuItem onClick={() => runAction('featured', 'Featured organization')}>
<Sparkles className="h-4 w-4 mr-2" />
Feature
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
{isHidden ? (
<DropdownMenuItem onClick={() => runAction('unhidden', 'Unhidden')}>
<Eye className="h-4 w-4 mr-2" />
Unhide
<span className="ml-auto text-xs text-muted-foreground inline-flex items-center gap-1">
<Check className="h-3 w-3" /> Hidden
</span>
</DropdownMenuItem>
) : (
<DropdownMenuItem
onClick={() => runAction('hidden', 'Hidden')}
className="text-destructive focus:text-destructive"
>
<EyeOff className="h-4 w-4 mr-2" />
Hide
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}
/**
* 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 (
<CommunityModerationOverlayInner coord={coord} organizationName={organizationName} />
);
}
function CommunityModerationOverlayInner({
coord,
organizationName,
}: {
coord: string;
organizationName: string;
}) {
const { data: moderation } = useOrganizationModeration();
const isHidden = moderation.hiddenCoords.has(coord);
return (
<div className="absolute top-2 right-2 flex items-center gap-1.5">
{isHidden && (
<Badge
variant="secondary"
className="backdrop-blur bg-destructive/15 text-destructive border-destructive/30 h-6 px-1.5 text-[10px]"
>
<EyeOff className="size-3 mr-1" />
Hidden
</Badge>
)}
{/* The kebab inner uses the same moderation cache subscription, so
no extra round-trip is incurred. */}
<CommunityModerationMenuInner coord={coord} organizationName={organizationName} />
</div>
);
}
-173
View File
@@ -1,173 +0,0 @@
/**
* CommunityPulsePanel
*
* "Pulse" tab on the community detail page — an infinite-scrolling feed of
* posts published by community members *outside* this community. The intent
* is to surface what members are sharing in the wider Nostr ecosystem, as
* opposed to the in-community Activity tab.
*
* Implementation notes:
* - Authors come from the community `rankMap` (founders + moderators +
* members). Without authors the relay would return the entire global
* timeline.
* - Kinds come from `getEnabledFeedKinds(feedSettings)` so the feed
* respects the user's "Notes / Articles / Reposts / etc." preferences,
* exactly like the home feed.
* - Events tagged with this community's `a` reference are dropped — those
* belong on the Activity tab.
* - Replies (NIP-10 / NIP-22) are dropped so the Pulse reads like a
* timeline of top-level posts, not threaded responses.
* - Mute list, content-warning, and repost unwrap behavior come for free
* by reusing `useTabFeed` + the `feedUtils` helpers.
*/
import { useEffect, useMemo } from 'react';
import { useInView } from 'react-intersection-observer';
import { Loader2 } from 'lucide-react';
import type { NostrFilter } from '@nostrify/nostrify';
import { NoteCard } from '@/components/NoteCard';
import { Skeleton } from '@/components/ui/skeleton';
import { useTabFeed } from '@/hooks/useProfileFeed';
import { useMuteList } from '@/hooks/useMuteList';
import { isEventMuted } from '@/lib/muteHelpers';
import { shouldHideFeedEvent } from '@/lib/feedUtils';
import { isReplyEvent } from '@/lib/nostrEvents';
interface CommunityPulsePanelProps {
/** `34550:<pubkey>:<d>` — used both for the cache key and the in-community filter. */
communityATag: string;
/** Author allowlist — founders + moderators + members. */
memberPubkeys: string[];
/** True while membership is still resolving; suppresses an empty-state flash. */
isMembershipLoading: boolean;
}
export function CommunityPulsePanel({
communityATag,
memberPubkeys,
isMembershipLoading,
}: CommunityPulsePanelProps) {
const { muteItems } = useMuteList();
const { ref: sentinelRef, inView } = useInView({ threshold: 0, rootMargin: '400px' });
// Build the TabFeed filter — kinds default to the user's enabled feed kinds
// (handled inside useTabFeed when `kinds` is omitted from the filter).
const filter = useMemo<NostrFilter | null>(
() => (memberPubkeys.length > 0 ? { authors: memberPubkeys } : null),
[memberPubkeys],
);
const {
data,
isLoading,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useTabFeed(filter, `community-pulse-${communityATag}`, memberPubkeys.length > 0);
// Fetch next page when the sentinel scrolls into view.
useEffect(() => {
if (inView && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]);
/**
* Drop events that reference *this* community via an `a` tag — they belong
* to the Activity tab, not Pulse. We check both the original event and the
* embedded event of a repost.
*/
const referencesThisCommunity = (tags: string[][]): boolean => {
for (const tag of tags) {
if (tag[0] === 'a' && tag[1] === communityATag) return true;
}
return false;
};
// Flatten pages, dedupe, and apply mute / content-warning / reply /
// in-community filters.
const feedItems = useMemo(() => {
if (!data?.pages) return [];
const seen = new Set<string>();
return data.pages
.flatMap((page) => page.items)
.filter((item) => {
const key = item.repostedBy ? `repost-${item.repostedBy}-${item.event.id}` : item.event.id;
if (seen.has(key)) return false;
seen.add(key);
if (shouldHideFeedEvent(item.event)) return false;
if (muteItems.length > 0 && isEventMuted(item.event, muteItems)) return false;
// Hide replies on original (non-repost) text notes; a repost of a
// reply is still a legitimate top-level surface.
if (item.event.kind === 1 && !item.repostedBy && isReplyEvent(item.event)) {
return false;
}
// Drop anything authored against this community — that's Activity.
if (referencesThisCommunity(item.event.tags)) return false;
if (item.repostEvent && referencesThisCommunity(item.repostEvent.tags)) return false;
return true;
});
// `referencesThisCommunity` and `communityATag` referenced via closure —
// adding `communityATag` to deps is sufficient.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data?.pages, muteItems, communityATag]);
// ── States ────────────────────────────────────────────────────────────────
if (memberPubkeys.length === 0 && !isMembershipLoading) {
return (
<div className="py-12 text-center text-muted-foreground text-sm px-5">
No community members yet nothing to surface here.
</div>
);
}
if ((isLoading || isMembershipLoading) && feedItems.length === 0) {
return (
<div className="divide-y divide-border">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="px-4 py-3">
<div className="flex gap-3">
<Skeleton className="size-11 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-48" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
</div>
</div>
</div>
))}
</div>
);
}
if (feedItems.length === 0) {
return (
<div className="py-12 text-center text-muted-foreground text-sm px-5">
No posts from community members elsewhere yet.
</div>
);
}
return (
<div>
{feedItems.map((item) => (
<NoteCard
key={item.repostedBy ? `repost-${item.repostedBy}-${item.event.id}` : item.event.id}
event={item.event}
repostedBy={item.repostedBy}
/>
))}
{hasNextPage && (
<div ref={sentinelRef} className="flex justify-center py-6">
{isFetchingNextPage && <Loader2 className="size-5 animate-spin text-muted-foreground" />}
</div>
)}
</div>
);
}
+1 -1
View File
@@ -91,7 +91,7 @@ export function CommunityReportDialog({
<DialogContent className="max-w-md max-h-[85dvh] rounded-2xl flex flex-col overflow-hidden">
<DialogTitle>Report post</DialogTitle>
<DialogDescription className="text-sm text-muted-foreground">
Select a reason for reporting this post to the community.
Select a reason for reporting this post to the organization.
</DialogDescription>
<div className="flex-1 overflow-y-auto min-h-0 -mx-6 px-6">
+272 -50
View File
@@ -1,6 +1,6 @@
import { lazy, Suspense, useState, useRef, useCallback, useMemo, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { Paperclip, Smile, AlertTriangle, X, Loader2, Mic, Square, Sticker, BarChart3, Plus, ChevronLeft, HelpCircle } from 'lucide-react';
import { Paperclip, Smile, AlertTriangle, X, Loader2, Mic, Square, Sticker, BarChart3, Plus, ChevronLeft, Check, Globe, HelpCircle } from 'lucide-react';
import { nip19 } from 'nostr-tools';
import { encode as blurhashEncode } from 'blurhash';
import type { NostrEvent } from '@nostrify/nostrify';
@@ -12,7 +12,21 @@ import { Input } from '@/components/ui/input';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
import { useCustomEmojis } from '@/hooks/useCustomEmojis';
import { useFeedSettings } from '@/hooks/useFeedSettings';
import { GifPicker } from '@/components/GifPicker';
@@ -29,12 +43,14 @@ import { useNostrPublish } from '@/hooks/useNostrPublish';
import { usePostComment } from '@/hooks/usePostComment';
import { useUploadFile } from '@/hooks/useUploadFile';
import { useCountryFollows } from '@/hooks/useCountryFollows';
import { getCountryInfo } from '@/lib/countries';
import { useDefaultPostCountry } from '@/hooks/useDefaultPostCountry';
import { COUNTRY_LIST, getCountryInfo } from '@/lib/countries';
import { createCountryIdentifier } from '@/lib/countryIdentifiers';
import { useQueryClient } from '@tanstack/react-query';
import { useToast } from '@/hooks/useToast';
import { useAppContext } from '@/hooks/useAppContext';
import type { EventStats } from '@/hooks/useTrending';
import type { Nip85EventStats } from '@/hooks/useNip85Stats';
import { invalidateEventStats } from '@/lib/invalidateEventStats';
import { cn } from '@/lib/utils';
import { notificationSuccess } from '@/lib/haptics';
import { extractVideoUrls, extractAudioUrls, IMETA_MEDIA_URL_REGEX, IMETA_MEDIA_URL_TEST_REGEX, mimeFromExt } from '@/lib/mediaUrls';
@@ -50,6 +66,7 @@ import { DITTO_RELAY } from '@/lib/appRelays';
import { resizeImage } from '@/lib/resizeImage';
import { extractHashtags } from '@/lib/hashtag';
import { useIsMobile } from '@/hooks/useIsMobile';
import { AGORA_DEFAULT_NOTE_TAGS } from '@/lib/agoraNoteTags';
const MAX_CHARS = 5000;
@@ -132,6 +149,10 @@ interface ComposeBoxProps {
hideAvatar?: boolean;
/** If true, suppresses the bottom border. Use when the composer sits directly above a visually distinct section (e.g. tabs with an arc background) that already provides separation. */
hideBorder?: boolean;
/** Extra class names merged onto the outer wrapper. Useful for
* overriding the default `bg-background/85` when the composer is
* rendered inside a card surface. */
className?: string;
/** Controlled preview mode (for modal usage). */
previewMode?: boolean;
/** Callback to notify parent of previewable content changes. */
@@ -152,6 +173,18 @@ interface ComposeBoxProps {
hidePoll?: boolean;
/** Label for the primary submit button. */
submitLabel?: string;
/**
* Tags added to new top-level kind 1 notes without putting them in content.
*
* Defaults to {@link AGORA_DEFAULT_NOTE_TAGS} (the silent `t:agora` tag) when
* the composer is producing a top-level kind 1 note (no replyTo, not a quote,
* not poll mode, no custom publish, no country-scoped destination). Replies,
* quotes, polls, comments, and custom-kind publishes do not receive these
* tags regardless of this prop. Pass `[]` to opt out explicitly.
*/
defaultTags?: string[][];
/** If true, the composer starts expanded without taking modal/flex behavior. */
defaultExpanded?: boolean;
}
/** Circular progress ring for character count. */
@@ -202,6 +235,7 @@ export function ComposeBox({
forceExpanded = false,
hideAvatar = false,
hideBorder = false,
className,
previewMode: controlledPreviewMode,
onHasPreviewableContentChange,
initialContent = '',
@@ -209,6 +243,8 @@ export function ComposeBox({
customPublish,
hidePoll = false,
submitLabel = 'Post!',
defaultTags,
defaultExpanded = false,
}: ComposeBoxProps) {
const { user, metadata, isLoading: isProfileLoading } = useCurrentUser();
const userProfileUrl = useProfileUrl(user?.pubkey ?? '', metadata);
@@ -223,6 +259,7 @@ export function ComposeBox({
const { toast } = useToast();
const { config } = useAppContext();
const imageQuality = config.imageQuality;
const statsPubkey = config.nip85StatsPubkey;
const isMobile = useIsMobile();
// Build a stable localStorage key based on compose context.
@@ -243,7 +280,7 @@ export function ComposeBox({
return '';
}
});
const [expanded, setExpanded] = useState(false);
const [expanded, setExpanded] = useState(defaultExpanded);
const [cwEnabled, setCwEnabled] = useState(false);
const [cwText, setCwText] = useState('');
const [pickerOpen, setPickerOpen] = useState(false);
@@ -258,23 +295,39 @@ export function ComposeBox({
// from the home feed (no replyTo, not a custom-kind publish). When a
// country code is selected, the post is published as a NIP-22 kind
// 1111 comment rooted on that country instead of a plain kind 1 note.
// Dropdown lists only the countries the user follows, with "Global"
// always at the top.
//
// The dropdown shows: Global + the countries the user follows (quick
// picks) + a "Choose another country…" item that opens a searchable
// dialog over the full country list. So a user can post about any
// country, even one they don't follow.
const { followedCountries } = useCountryFollows();
const canChooseDestination =
!replyTo && !customPublish && mode === 'post' && !!user && followedCountries.length > 0;
!replyTo && !customPublish && mode === 'post' && !!user;
/**
* User's saved default destination (persisted to localStorage). Used as
* the initial value of `destination` on every fresh compose, and updated
* when the user clicks "Set as default" in the destination menu.
*/
const [defaultPostCountry, setDefaultPostCountry] = useDefaultPostCountry();
/** `'world'` for a regular kind-1 note, or an ISO 3166 country code for a kind-1111 community post. */
const [destination, setDestination] = useState<'world' | string>('world');
const [destination, setDestination] = useState<'world' | string>(defaultPostCountry);
/** Open state for the "Choose another country" searchable picker dialog. */
const [countryPickerOpen, setCountryPickerOpen] = useState(false);
const selectedCountryCode = destination !== 'world' ? destination : null;
const selectedCountryInfo = selectedCountryCode ? getCountryInfo(selectedCountryCode) : null;
// If the user unfollows the currently-selected country mid-session,
// snap back to world so we don't try to publish a kind 1111 with
// a root the user no longer cares about.
// Snap back to world if the currently selected destination is an
// invalid ISO code (e.g. a previously-followed country that was later
// removed from the country directory). Picking a non-followed but
// valid country is allowed — users can post about any country via the
// "Choose another country" picker, so following is not a prerequisite.
useEffect(() => {
if (selectedCountryCode && !followedCountries.includes(selectedCountryCode)) {
if (selectedCountryCode && !getCountryInfo(selectedCountryCode)) {
setDestination('world');
if (defaultPostCountry === selectedCountryCode) {
setDefaultPostCountry('world');
}
}
}, [selectedCountryCode, followedCountries]);
}, [selectedCountryCode, defaultPostCountry, setDefaultPostCountry]);
const [pollOptions, setPollOptions] = useState([
{ id: pollOptionId(), label: '' },
{ id: pollOptionId(), label: '' },
@@ -300,7 +353,7 @@ export function ComposeBox({
setContent('');
setCwEnabled(false);
setCwText('');
setExpanded(false);
setExpanded(defaultExpanded);
setPickerOpen(false);
setTrayOpen(false);
setInternalPreviewMode(false);
@@ -312,10 +365,10 @@ export function ComposeBox({
setUploadedFileGroups(new Map());
setWebxdcUuids(new Map());
setWebxdcMetas(new Map());
setDestination('world');
setDestination(defaultPostCountry);
// Clear the auto-saved draft
try { localStorage.removeItem(draftKey); } catch { /* ignore */ }
}, [initialMode, draftKey]);
}, [initialMode, draftKey, defaultExpanded, defaultPostCountry]);
// Use controlled preview mode if provided, otherwise use internal state
const previewMode = controlledPreviewMode !== undefined ? controlledPreviewMode : internalPreviewMode;
@@ -857,6 +910,11 @@ export function ComposeBox({
// Reset state
queryClient.invalidateQueries({ queryKey: ['feed'] });
// Voice messages can surface in the home Agora activity feed (via
// the `t:Agora` marker on root messages and through the comment
// path on replies). Refresh both home feed queries.
queryClient.invalidateQueries({ queryKey: ['agora-feed'] });
queryClient.invalidateQueries({ queryKey: ['mixed-feed'] });
if (replyTo) {
if (isExternalRoot(replyTo)) {
queryClient.invalidateQueries({ queryKey: ['nostr', 'comments'] });
@@ -865,7 +923,13 @@ export function ComposeBox({
if (replyTo.kind !== 1) {
queryClient.invalidateQueries({ queryKey: ['nostr', 'comments'] });
}
// Bump comment count on the parent event so the UI updates.
invalidateEventStats(queryClient, replyTo, statsPubkey);
}
} else if (canChooseDestination && selectedCountryCode) {
// Root voice message published to a country community feed.
queryClient.invalidateQueries({ queryKey: ['agora-feed-paginated', selectedCountryCode] });
queryClient.invalidateQueries({ queryKey: ['agora-feed-new-posts', selectedCountryCode] });
}
notificationSuccess();
toast({ title: 'Voice message sent!', description: 'Your voice message has been published.' });
@@ -875,7 +939,7 @@ export function ComposeBox({
} finally {
setIsPublishingVoice(false);
}
}, [user, voiceRecorder, uploadFile, buildContentWarningTags, customPublish, createEvent, onPublished, replyTo, queryClient, toast, onSuccess]);
}, [user, voiceRecorder, uploadFile, buildContentWarningTags, customPublish, createEvent, onPublished, replyTo, queryClient, toast, onSuccess, canChooseDestination, selectedCountryCode, statsPubkey]);
const handleSubmit = async () => {
if (!content.trim() || !user || charCount > MAX_CHARS) return;
@@ -1103,22 +1167,49 @@ export function ComposeBox({
const countryRoot = new URL(createCountryIdentifier(selectedCountryCode));
await postComment({ root: countryRoot, reply: undefined, content: finalContent, tags });
} else {
// Top-level kind 1 note. If the caller hasn't supplied `defaultTags`,
// auto-attach the silent Agora tag so the post surfaces in the Agora
// activity feed. Callers can opt out by passing `defaultTags={[]}`.
const effectiveDefaultTags = defaultTags ?? AGORA_DEFAULT_NOTE_TAGS;
await createEvent({
kind: 1,
content: finalContent,
tags,
tags: [...effectiveDefaultTags, ...tags],
created_at: Math.floor(Date.now() / 1000),
});
}
resetComposeState();
// Optimistically bump the reply count on the parent event
// Optimistically bump the comment count on the parent event
if (replyTo && !isExternalRoot(replyTo)) {
queryClient.setQueryData<EventStats>(['event-stats', replyTo.id], (prev) =>
prev ? { ...prev, replies: prev.replies + 1 } : prev,
queryClient.setQueryData<Nip85EventStats | null>(
['nip85-event-stats', replyTo.id, statsPubkey],
(prev) => prev ? { ...prev, commentCount: prev.commentCount + 1 } : prev,
);
}
queryClient.invalidateQueries({ queryKey: ['feed'] });
// Top-level kind 1 posts with the silent Agora tag (the default for
// user-authored notes) surface in the home Agora activity feed
// (useAgoraFeed / mixed-feed). Invalidate both so the post appears
// there without a refresh — over-invalidation is cheap here.
queryClient.invalidateQueries({ queryKey: ['agora-feed'] });
queryClient.invalidateQueries({ queryKey: ['mixed-feed'] });
// Top-level kind 1 posts surface on country pages too. Country posts
// route through `usePostComment` (which handles its own invalidation),
// but the top-level branch above publishes via `createEvent`, so we
// need to invalidate the country feed keys here. `selectedCountryCode`
// is null for global posts, in which case nothing extra needs to
// refresh (the global Agora feed is served by relays, not a per-country
// query). For drafts attached to a specific country via customPublish
// we conservatively invalidate the broader prefix.
if (canChooseDestination && selectedCountryCode && !replyTo) {
queryClient.invalidateQueries({ queryKey: ['agora-feed-paginated', selectedCountryCode] });
queryClient.invalidateQueries({ queryKey: ['agora-feed-new-posts', selectedCountryCode] });
setTimeout(() => {
queryClient.invalidateQueries({ queryKey: ['agora-feed-paginated', selectedCountryCode] });
queryClient.invalidateQueries({ queryKey: ['agora-feed-new-posts', selectedCountryCode] });
}, 3000);
}
if (replyTo) {
if (isExternalRoot(replyTo)) {
queryClient.invalidateQueries({ queryKey: ['nostr', 'comments'] });
@@ -1133,7 +1224,7 @@ export function ComposeBox({
}
}
if (quotedEvent) {
queryClient.invalidateQueries({ queryKey: ['event-stats', quotedEvent.id] });
invalidateEventStats(queryClient, quotedEvent, statsPubkey);
queryClient.invalidateQueries({ queryKey: ['event-interactions', quotedEvent.id] });
}
notificationSuccess();
@@ -1195,6 +1286,19 @@ export function ComposeBox({
await createEvent({ kind: 1068, content: finalContent, tags });
resetComposeState();
queryClient.invalidateQueries({ queryKey: ['feed'] });
// World-layer polls (iso3166 root) and Agora-marked polls surface
// in the home Agora activity feed.
queryClient.invalidateQueries({ queryKey: ['agora-feed'] });
queryClient.invalidateQueries({ queryKey: ['mixed-feed'] });
// Polls published with an iso3166 root surface on the country feed.
if (replyTo instanceof URL && replyTo.protocol === 'iso3166:') {
const countryCode = replyTo.pathname.toUpperCase();
queryClient.invalidateQueries({ queryKey: ['agora-feed-paginated', countryCode] });
queryClient.invalidateQueries({ queryKey: ['agora-feed-new-posts', countryCode] });
} else if (canChooseDestination && selectedCountryCode) {
queryClient.invalidateQueries({ queryKey: ['agora-feed-paginated', selectedCountryCode] });
queryClient.invalidateQueries({ queryKey: ['agora-feed-new-posts', selectedCountryCode] });
}
notificationSuccess();
toast({ title: 'Poll published!' });
onSuccess?.();
@@ -1217,6 +1321,7 @@ export function ComposeBox({
forceExpanded ? "flex-1 min-h-0 rounded-2xl" : "",
pickerOpen ? "pb-0" : "pb-3",
!forceExpanded && !hideBorder && "border-b border-border",
className,
)}>
{/* Preview toggle at top when not controlled and has previewable content */}
{hasPreviewableContent && controlledPreviewMode === undefined && (
@@ -1525,14 +1630,14 @@ export function ComposeBox({
})()}
</PopoverContent>
</Popover>
<Select value={destination} onValueChange={setDestination}>
<SelectTrigger
<DropdownMenu>
<DropdownMenuTrigger
aria-label="Post destination"
className={cn(
'h-8 w-auto gap-1.5 px-2.5 py-1 text-base leading-none',
'border-0 bg-muted/50 hover:bg-muted shadow-none',
'focus:ring-2 focus:ring-primary/50 focus:ring-offset-0',
'rounded-lg',
'inline-flex items-center justify-center h-8 w-auto gap-1.5 px-2.5 py-1 text-base leading-none',
'bg-muted/50 hover:bg-muted shadow-none',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 focus-visible:ring-offset-0',
'rounded-lg motion-safe:transition-colors',
)}
>
{/* Show just the flag in the trigger to keep the row
@@ -1541,28 +1646,145 @@ export function ComposeBox({
<span aria-hidden="true">
{selectedCountryInfo?.flag ?? '🌍'}
</span>
</SelectTrigger>
<SelectContent align="end" className="min-w-[180px]">
<SelectItem value="world">
<span className="inline-flex items-center gap-2">
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[240px]">
<DropdownMenuItem
onSelect={() => setDestination('world')}
className="cursor-pointer"
>
<span className="inline-flex items-center gap-2 flex-1">
<span aria-hidden="true">🌍</span>
<span>Global</span>
</span>
</SelectItem>
{followedCountries.map((code) => {
const info = getCountryInfo(code);
if (!info) return null;
return (
<SelectItem key={code} value={code}>
<span className="inline-flex items-center gap-2">
<span aria-hidden="true">{info.flag}</span>
<span>{info.name}</span>
</span>
</SelectItem>
);
})}
</SelectContent>
</Select>
{destination === 'world' && (
<Check className="size-4 text-primary" aria-hidden />
)}
</DropdownMenuItem>
{/* 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<string>();
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 (
<DropdownMenuItem
key={code}
onSelect={() => setDestination(code)}
className="cursor-pointer"
>
<span className="inline-flex items-center gap-2 flex-1">
<span aria-hidden="true">{info.flag}</span>
<span>{info.name}</span>
</span>
{destination === code && (
<Check className="size-4 text-primary" aria-hidden />
)}
</DropdownMenuItem>
);
});
})()}
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault();
setCountryPickerOpen(true);
}}
className="cursor-pointer text-sm"
>
<Globe className="size-4 mr-2 text-muted-foreground" aria-hidden />
Choose another country
</DropdownMenuItem>
<DropdownMenuSeparator />
{destination === defaultPostCountry ? (
<div className="px-2 py-1.5 text-xs text-muted-foreground">
{(() => {
if (defaultPostCountry === 'world') return 'Global is your default';
const info = getCountryInfo(defaultPostCountry);
return info ? `${info.name} is your default` : 'This is your default';
})()}
</div>
) : (
<DropdownMenuItem
onSelect={() => {
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
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
{/* 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. */}
<CommandDialog
open={countryPickerOpen}
onOpenChange={setCountryPickerOpen}
>
<CommandInput placeholder="Search countries..." />
<CommandList>
<CommandEmpty>No countries found.</CommandEmpty>
<CommandGroup>
<CommandItem
value="Global 🌍"
onSelect={() => {
setDestination('world');
setCountryPickerOpen(false);
}}
>
<span aria-hidden="true" className="mr-2">🌍</span>
<span>Global</span>
{destination === 'world' && (
<Check className="ml-auto size-4 text-primary" aria-hidden />
)}
</CommandItem>
{COUNTRY_LIST.map((country) => (
<CommandItem
key={country.code}
// Include code + name in the searchable value so users
// can type either "iran" or "IR".
value={`${country.name} ${country.code}`}
onSelect={() => {
setDestination(country.code);
setCountryPickerOpen(false);
}}
>
<span aria-hidden="true" className="mr-2">{country.flag}</span>
<span>{country.name}</span>
<span className="ml-2 text-xs text-muted-foreground">{country.code}</span>
{destination === country.code && (
<Check className="ml-auto size-4 text-primary" aria-hidden />
)}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</CommandDialog>
</div>
)}
@@ -1763,7 +1985,7 @@ export function ComposeBox({
<Button
onClick={handlePollSubmit}
disabled={!isPollValid || isPollPending || !user}
className="rounded-full px-5 font-bold"
className="rounded-full px-5 font-bold text-white"
size="sm"
>
{isPollPending ? 'Publishing...' : 'Publish poll'}
@@ -1772,7 +1994,7 @@ export function ComposeBox({
<Button
onClick={handleSubmit}
disabled={!content.trim() || isPending || isCommentPending || !user || charCount > MAX_CHARS}
className="rounded-full px-5 font-bold"
className="rounded-full px-5 font-bold text-white"
size="sm"
>
{isPending || isCommentPending ? 'Posting...' : submitLabel}
+77 -199
View File
@@ -1,8 +1,7 @@
import { useState } from 'react';
import { IntroImage } from '@/components/IntroImage';
import { useState, type ReactNode } from 'react';
import {
Users, Download, Loader2, X, Pencil, Home, Globe, MapPin,
Palette, Trash2, Plus, UserX, Hash, MessageSquareOff, ExternalLink, ShieldAlert,
Trash2, Plus, UserX, Hash, MessageSquareOff, ExternalLink,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -26,220 +25,108 @@ import { useAuthor } from '@/hooks/useAuthor';
import { FeedEditModal } from '@/components/FeedEditModal';
import { buildKindOptions } from '@/lib/feedFilterUtils';
import { genUserName } from '@/lib/genUserName';
import { EXTRA_KINDS, FEED_KINDS, SECTION_ORDER, SECTION_LABELS } from '@/lib/extraKinds';
import { CONTENT_KIND_ICONS, SIDEBAR_ITEMS } from '@/lib/sidebarItems';
import type { SavedFeed, TabFilter, ContentWarningPolicy } from '@/contexts/AppContext';
import type { ExtraKindDef, SubKindDef } from '@/lib/extraKinds';
import { EXTRA_KINDS } from '@/lib/extraKinds';
import { SIDEBAR_ITEMS } from '@/lib/sidebarItems';
import type { FeedSettings, SavedFeed, TabFilter, ContentWarningPolicy } from '@/contexts/AppContext';
import type { ExtraKindDef } from '@/lib/extraKinds';
export function ContentSettings() {
return (
<div>
{/* Intro */}
<div className="px-3 pt-2 pb-4">
<h2 className="text-sm font-semibold">What You See</h2>
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
Customize your feed, choose what content appears, and control what you want to hide.
</p>
</div>
{/* Homepage Section */}
<div className="space-y-8">
<HomePageSetting />
{/* Feed Tabs Section */}
<div>
<div className="relative px-3 py-3.5">
<h2 className="text-base font-semibold">Home Feed Tabs</h2>
<div className="absolute bottom-0 left-0 right-0 h-1 bg-primary rounded-full" />
</div>
<div className="pb-4">
<FeedTabsSection />
</div>
</div>
<Section title="Saved Feeds">
<FeedTabsSection />
</Section>
{/* Notes Section */}
<div>
<div className="relative px-3 py-3.5">
<h2 className="text-base font-semibold">Basic Home Feed Options</h2>
<div className="absolute bottom-0 left-0 right-0 h-1 bg-primary rounded-full" />
</div>
<div className="pb-4">
<div className="px-3 pt-3 pb-4">
<p className="text-xs text-muted-foreground leading-relaxed">
Core content types that appear in your feed.
</p>
</div>
<Section title="Content in Home Feed">
<FlatContentList />
</Section>
{/* Column headers */}
<div className="flex items-center justify-end gap-2 px-3 pb-2 border-b border-border">
<span className="text-[11px] font-medium text-muted-foreground w-[52px] text-center">Feed</span>
</div>
<NotesFeedSettings />
</div>
</div>
{/* Other Stuff Section */}
<div>
<div className="relative px-3 py-3.5">
<h2 className="text-base font-semibold">Show More Content Types in Home Feed</h2>
<div className="absolute bottom-0 left-0 right-0 h-1 bg-primary rounded-full" />
</div>
<div className="pb-4">
{/* Intro section for Other Stuff */}
<div className="flex items-center gap-4 px-3 pt-3 pb-4">
<IntroImage src="/feed-intro.png" />
<div className="min-w-0">
<h3 className="text-sm font-semibold">Other Stuff</h3>
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
Nostr isn't just text posts — people publish all kinds of things. Pick what shows up in your sidebar and feed.
</p>
</div>
</div>
{/* Column headers */}
<div className="flex items-center justify-end gap-2 px-3 pb-2 border-b border-border">
<span className="text-[11px] font-medium text-muted-foreground w-[52px] text-center">Feed</span>
</div>
{/* Content type rows - reuse the internals from FeedSettingsForm */}
<FeedSettingsFormInternals />
</div>
</div>
<Section title="Muted">
<MuteSettingsInternals />
</Section>
<Section title="Sensitive Content">
<SensitiveContentSection />
</Section>
</div>
);
}
function KindBadge({ kind }: { kind: number }) {
function Section({ title, children }: { title: string; children: ReactNode }) {
return (
<span className="text-[10px] font-mono text-muted-foreground/60 shrink-0">
[{kind}]
</span>
<section>
<h2 className="text-base font-semibold px-3 pb-2 border-b border-border">{title}</h2>
<div className="pt-2">{children}</div>
</section>
);
}
function SubKindRow({ sub }: { sub: SubKindDef }) {
const { feedSettings, updateFeedSettings } = useFeedSettings();
const { updateSettings } = useEncryptedSettings();
const { user } = useCurrentUser();
const handleToggle = async (key: string, value: boolean) => {
updateFeedSettings({ [key]: value });
if (user) {
await updateSettings.mutateAsync({ feedSettings: { ...feedSettings, [key]: value } });
}
};
function FlatContentList() {
// Flat, ordered list of curated kinds. No section grouping, no sub-rows, no kind badges.
const orderedIds = [
'posts', 'replies', 'reposts', 'articles', 'highlights',
'photos', 'videos', 'voice',
'events', 'polls', 'communities', 'badges',
'reactions', 'zaps',
];
const byId = new Map(EXTRA_KINDS.map((def) => [def.id, def]));
// Replies is id 'comments' in the registry; alias here for readability.
byId.set('replies', byId.get('comments')!);
const rows = orderedIds.map((id) => byId.get(id)).filter((d): d is ExtraKindDef => !!d && !!d.agora);
return (
<div className="flex items-center justify-between py-2.5 pl-12 pr-3 transition-colors">
<div className="min-w-0">
<span className="text-sm">{sub.label}</span>
<p className="text-xs text-muted-foreground mt-0.5">
<KindBadge kind={sub.kind} />{' '}{sub.description}
</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<div className="w-[52px] flex justify-center">
<Switch
checked={feedSettings[sub.feedKey]}
onCheckedChange={(checked) => handleToggle(sub.feedKey, checked)}
className="scale-90"
/>
</div>
</div>
</div>
<ul className="divide-y divide-border">
{rows.map((def) => (
<li key={def.id}>
<ContentTypeRow def={def} />
</li>
))}
</ul>
);
}
function ContentTypeRow({ def }: { def: ExtraKindDef }) {
const { feedSettings, updateFeedSettings } = useFeedSettings();
const { updateSettings } = useEncryptedSettings();
const { user } = useCurrentUser();
const IconComponent = CONTENT_KIND_ICONS[def.id] ?? Palette;
const icon = <IconComponent className="size-5" />;
const hasSubKinds = !!def.subKinds;
const handleToggle = async (key: string, value: boolean) => {
updateFeedSettings({ [key]: value });
// Toggle key: prefer the feed inclusion key; fall back to the sidebar visibility key
// for kinds that have no direct feed key of their own (e.g. parent kinds with sub-kinds).
const toggleKey: keyof FeedSettings | undefined = def.feedKey ?? def.showKey;
if (!toggleKey) return null;
const checked = feedSettings[toggleKey] !== false;
const handleToggle = async (value: boolean) => {
const next: Partial<FeedSettings> = { [toggleKey]: value };
// Parent kinds with sub-kinds: toggle all sub-kind feed keys together so the
// single parent switch governs everything below it.
if (def.subKinds) {
for (const sub of def.subKinds) {
next[sub.feedKey] = value;
}
}
updateFeedSettings(next);
if (user) {
await updateSettings.mutateAsync({ feedSettings: { ...feedSettings, [key]: value } });
await updateSettings.mutateAsync({ feedSettings: { ...feedSettings, ...next } });
}
};
return (
<div className="border-b border-border last:border-b-0">
<div className="flex items-center justify-between py-3.5 px-3">
<div className="flex items-center gap-3 min-w-0">
<span className="text-muted-foreground shrink-0">{icon}</span>
<div className="min-w-0">
<span className="text-sm font-medium">{def.label}</span>
<p className="text-xs text-muted-foreground mt-0.5">
<KindBadge kind={def.kind} />{' '}{def.description}
</p>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<div className="w-[52px] flex justify-center">
{!hasSubKinds && def.feedKey ? (
<Switch
checked={feedSettings[def.feedKey]}
onCheckedChange={(checked) => handleToggle(def.feedKey!, checked)}
/>
) : !hasSubKinds && def.feedOnly && def.showKey ? (
<Switch
checked={feedSettings[def.showKey] !== false}
onCheckedChange={(checked) => handleToggle(def.showKey!, checked)}
/>
) : null}
</div>
</div>
<div className="flex items-center justify-between gap-4 py-3.5 px-3">
<div className="min-w-0">
<span className="text-sm font-medium">{def.label}</span>
<p className="text-xs text-muted-foreground mt-0.5">{def.description}</p>
</div>
{hasSubKinds && def.subKinds && def.subKinds.map((sub) => (
<SubKindRow
key={sub.feedKey}
sub={sub}
/>
))}
<Switch checked={checked} onCheckedChange={handleToggle} className="shrink-0" />
</div>
);
}
function NotesFeedSettings() {
return (
<>
{FEED_KINDS.map((def) => (
<ContentTypeRow key={def.id} def={def} />
))}
</>
);
}
function FeedSettingsFormInternals() {
return (
<>
{SECTION_ORDER.map((section) => {
const sectionKinds = EXTRA_KINDS.filter((def) => def.section === section);
if (sectionKinds.length === 0) return null;
return (
<div key={section}>
<div className="px-3 pt-4 pb-2">
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
{SECTION_LABELS[section]}
</span>
</div>
{sectionKinds.map((def) => (
<ContentTypeRow key={def.id} def={def} />
))}
</div>
);
})}
</>
);
}
// Feed Tabs Section Component
function FeedTabsSection() {
const { toast } = useToast();
@@ -407,14 +294,11 @@ function FeedTabsSection() {
return (
<div>
{/* Intro section for Feed Tabs */}
<div className="flex items-center gap-4 px-3 pt-3 pb-4">
<IntroImage src="/community-intro.png" />
<div className="min-w-0">
<h3 className="text-sm font-semibold">Feed Navigation</h3>
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
Manage which feed tabs appear in your navigation and follow communities by domain.
</p>
</div>
<div className="px-3 pt-3 pb-4">
<h3 className="text-sm font-semibold">Feed Navigation</h3>
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
Manage which feed tabs appear in your navigation and follow communities by domain.
</p>
</div>
{/* Feed Tab Toggles */}
@@ -922,16 +806,10 @@ export function SensitiveContentSection() {
return (
<div>
{/* Intro */}
<div className="flex items-center gap-4 px-3 pt-3 pb-4">
<div className="w-40 shrink-0 flex items-center justify-center">
<ShieldAlert className="size-16 text-muted-foreground/40" />
</div>
<div className="min-w-0">
<h3 className="text-sm font-semibold">Content Warnings</h3>
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
Some posts are tagged with content warnings (NIP-36) by their authors. This can include NSFW material, spoilers, or other sensitive content.
</p>
</div>
<div className="px-3 pt-3 pb-4">
<p className="text-xs text-muted-foreground leading-relaxed">
Some posts are tagged by their authors as sensitive — NSFW, graphic, or otherwise needing a content warning. Choose how to handle them.
</p>
</div>
{/* Policy options — consistent row style with other settings */}
+63
View File
@@ -0,0 +1,63 @@
import { cn } from '@/lib/utils';
import { customFlagAsset } from '@/lib/customFlags';
interface CountryFlagProps {
/**
* ISO 3166-1 alpha-2 country code (e.g. `US`, `BR`) or ISO 3166-2
* subdivision code (e.g. `CN-XZ`, `GB-SCT`). Case-insensitive.
*/
code: string;
/** The flag emoji to render when no custom asset is available. */
emoji: string;
/** Accessible label / `alt` for the flag. */
label: string;
/** Optional extra classes applied to the rendering element. */
className?: string;
}
/**
* Render a flag for a country or subdivision. For codes with a bundled
* SVG (currently Tibet) we emit an `<img>` that visually matches the
* surrounding emoji line-height; for everything else we drop the emoji
* straight into a `<span>` so it inherits font color and selection
* behaviour like the rest of the text run.
*
* Callers control sizing via Tailwind classes — pass `text-3xl` to size
* the emoji and the SVG will scale to match (`h-[1em] w-auto`).
*/
export function CountryFlag({ code, emoji, label, className }: CountryFlagProps) {
const customAsset = customFlagAsset(code);
if (customAsset) {
return (
// The wrapper span carries the font-size class so the inner image
// can size itself in `em` units and stay in lockstep with the
// emoji glyphs on neighbouring chips. A thin border + tiny radius
// keeps the SVG reading as a *flag* and not a colored rectangle
// when it's shrunk down inside a small chip.
<span
className={cn('inline-flex items-center leading-none', className)}
role="img"
aria-label={label}
>
<img
src={customAsset}
alt=""
aria-hidden="true"
className="inline-block h-[1em] w-auto rounded-[2px] align-middle ring-1 ring-black/10 dark:ring-white/15 shadow-sm"
loading="lazy"
/>
</span>
);
}
return (
<span
className={cn('leading-none select-none', className)}
role="img"
aria-label={label}
>
{emoji}
</span>
);
}
+233
View File
@@ -0,0 +1,233 @@
import { useEffect, useState } from 'react';
import { ImagePlus, Loader2, X } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { useToast } from '@/hooks/useToast';
import { useUploadFile } from '@/hooks/useUploadFile';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import { cn } from '@/lib/utils';
/**
* Template thumbnail row: each click sets the cover URL to that template's
* URL. The thumbnail strip is optional — pass `templates` to enable it.
*/
export interface CoverImageTemplate {
id: string;
/** Sanitized https URL the picker will publish if this template is chosen. */
url: string;
/** Display name for `title` / `aria-label`. */
name: string;
}
interface CoverImageFieldProps {
/** Current cover URL (controlled). Empty string means "no cover". */
value: string;
onChange: (url: string) => void;
/** Notifies parent forms so they can block submit while Blossom upload runs. */
onUploadingChange?: (uploading: boolean) => void;
/**
* Fires after a successful Blossom upload with the NIP-94-style tag
* array returned by `useUploadFile`:
* `[["url", "<url>"], ["x", "<sha256>"], ["ox", "<sha256>"], ["size", "<bytes>"], ["m", "image/jpeg"]]`.
* Parents that want to publish a paired NIP-92 `imeta` tag in their
* Nostr event should convert this array — see Kind 33863 publishing.
*/
onUploadComplete?: (nip94Tags: string[][]) => void;
/** Optional template gallery shown between the dropzone and the URL input. */
templates?: readonly CoverImageTemplate[];
}
/**
* Unified cover-image affordance shared by CreateActionPage and
* CreateCampaignPage. Includes:
*
* - A dashed dropzone (`<label>`) that accepts both click-to-open and
* native drag-and-drop. Both paths funnel through the same MIME check
* and `useUploadFile` upload.
* - An optional template gallery — clicking a thumbnail just sets the
* controlled `value`, so the URL input and dropzone preview both
* update from a single source of truth.
* - A plain URL `<Input>` so users can paste any https:// image.
*
* The dropzone preview goes through `sanitizeUrl()`, which rejects
* anything other than a well-formed https URL — that's deliberate, since
* the same value is what gets published in the Nostr event's `image` tag.
*/
export function CoverImageField({ value, onChange, onUploadingChange, onUploadComplete, templates }: CoverImageFieldProps) {
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
const { toast } = useToast();
const [isDragging, setIsDragging] = useState(false);
const sanitized = sanitizeUrl(value);
useEffect(() => {
onUploadingChange?.(isUploading);
}, [isUploading, onUploadingChange]);
/**
* Shared upload path used by both the file-input change handler and
* the drag-and-drop handler. Validates the MIME type up front so a
* stray dragged-in PDF or video doesn't end up posted to Blossom.
*/
const uploadCoverFile = async (file: File) => {
if (!/^image\/(png|jpeg|webp)$/.test(file.type)) {
toast({
title: 'Unsupported file type',
description: 'Cover image must be PNG, JPG, or WEBP.',
variant: 'destructive',
});
return;
}
try {
const tags = await uploadFile(file);
const [[, url]] = tags;
onChange(url);
// Forward the raw NIP-94 tag array to the parent so it can build a
// paired NIP-92 imeta tag. The URL inside the tags is what Blossom
// returned; the parent's `value` may pick up an appended extension
// via the useUploadFile post-processing, but the sha256 ("x") still
// identifies the same byte stream.
if (onUploadComplete) {
// Replace the URL in the first tag with the extension-corrected
// value the parent now holds (matches the rendered banner src).
const adjusted = tags.map((t) => [...t]);
if (adjusted[0]?.[0] === 'url') adjusted[0][1] = url;
onUploadComplete(adjusted);
}
} catch (error) {
toast({
title: 'Upload failed',
description: error instanceof Error ? error.message : String(error),
variant: 'destructive',
});
}
};
const handleDragOver = (e: React.DragEvent<HTMLLabelElement>) => {
// Without preventDefault, the browser navigates to the dropped file
// instead of letting our onDrop handler claim it.
e.preventDefault();
if (isUploading) return;
e.dataTransfer.dropEffect = 'copy';
if (!isDragging) setIsDragging(true);
};
const handleDragLeave = (e: React.DragEvent<HTMLLabelElement>) => {
// Only clear the highlight when the cursor actually leaves the label.
// Dragging over a child element fires dragleave on the parent in some
// browsers, so we re-check relatedTarget.
if (e.currentTarget.contains(e.relatedTarget as Node | null)) return;
setIsDragging(false);
};
const handleDrop = async (e: React.DragEvent<HTMLLabelElement>) => {
e.preventDefault();
setIsDragging(false);
if (isUploading) return;
const file = e.dataTransfer.files?.[0];
if (!file) return;
await uploadCoverFile(file);
};
return (
<>
<label
onDragOver={handleDragOver}
onDragEnter={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={cn(
'relative block h-40 w-full cursor-pointer overflow-hidden rounded-xl border-2 border-dashed border-border bg-gradient-to-br from-muted/40 via-background to-muted/20 motion-safe:transition-colors hover:border-primary sm:h-48',
isDragging && 'border-primary bg-primary/5',
isUploading && 'opacity-70 pointer-events-none',
)}
>
{sanitized ? (
<>
<img
src={sanitized}
alt=""
className="absolute inset-0 size-full object-cover"
/>
<button
type="button"
onClick={(e) => {
e.preventDefault();
onChange('');
}}
className="absolute top-3 right-3 rounded-full bg-background/85 backdrop-blur p-1.5 hover:bg-background motion-safe:transition-colors"
aria-label="Remove image"
>
<X className="size-4" />
</button>
</>
) : (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2 text-muted-foreground">
{isUploading ? (
<>
<Loader2 className="size-8 animate-spin" />
<span className="text-sm">Uploading</span>
</>
) : (
<>
<ImagePlus className="size-8" />
<span className="text-sm">Click or drag an image here</span>
<span className="text-xs">PNG, JPG, or WEBP</span>
</>
)}
</div>
)}
<input
type="file"
accept="image/png,image/jpeg,image/webp"
className="sr-only"
disabled={isUploading}
onChange={(e) => {
const file = e.target.files?.[0];
e.currentTarget.value = '';
if (file) void uploadCoverFile(file);
}}
/>
</label>
{templates && templates.length > 0 && (
<div className="relative w-full overflow-hidden">
<div className="flex gap-2 overflow-x-auto pb-2 -mx-1 px-1">
{templates.map((template) => {
const isActive = value === template.url;
return (
<button
key={template.id}
type="button"
onClick={() => onChange(template.url)}
className={cn(
'relative h-20 w-28 flex-shrink-0 rounded-md overflow-hidden border-2 transition-all',
isActive
? 'border-primary ring-2 ring-primary/50'
: 'border-border hover:border-primary/50',
)}
title={template.name}
aria-label={`Use ${template.name} cover`}
>
<img
src={template.url}
alt={template.name}
className="w-full h-full object-cover"
/>
</button>
);
})}
</div>
</div>
)}
<Input
type="url"
inputMode="url"
placeholder="Or paste an https:// image URL"
value={value}
onChange={(e) => onChange(e.target.value)}
/>
</>
);
}
+370
View File
@@ -0,0 +1,370 @@
import { useMemo, useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { Check, ChevronRight, Clock, Loader2, Megaphone, Plus, Upload } from 'lucide-react';
import { TimezoneSwitcher } from '@/components/TimezoneSwitcher';
import { Button } from '@/components/ui/button';
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Drawer, DrawerContent, DrawerDescription, DrawerHeader, DrawerTitle } from '@/components/ui/drawer';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Textarea } from '@/components/ui/textarea';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useBtcPrice } from '@/hooks/useBtcPrice';
import { useIsMobile } from '@/hooks/useIsMobile';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useToast } from '@/hooks/useToast';
import { useUploadFile } from '@/hooks/useUploadFile';
import { createCountryIdentifier } from '@/lib/countryIdentifiers';
import { countryCodeToFlag, getAllCountries, getGeoDisplayName } from '@/lib/countries';
import { DEFAULT_COVER_IMAGE } from '@/lib/defaultActionCovers';
import { createOrganizationAssociationTags } from '@/lib/organizationContext';
import { unixSecondsInTimezone } from '@/lib/timezone';
import { usdToSats } from '@/lib/bitcoin';
import { cn } from '@/lib/utils';
interface CreateActionDialogProps {
countryCode?: string;
communityATag?: string;
open: boolean;
onOpenChange: (open: boolean) => void;
}
interface CreateActionFormState {
title: string;
description: string;
tagInput: string;
pledgeUsd: string;
deadline: string;
time: string;
coverImage: string;
selectedCountry: string;
timezone: string;
}
function normalizePledgeTag(value: string): string {
return value.trim().replace(/^#+/, '').toLowerCase().replace(/\s+/g, '-');
}
function parsePledgeTagInput(value: string): string[] {
const seen = new Set<string>();
const tags: string[] = [];
for (const part of value.split(',')) {
const tag = normalizePledgeTag(part);
if (!tag || seen.has(tag)) continue;
seen.add(tag);
tags.push(tag);
}
return tags;
}
function CreateActionForm({
formData,
setFormData,
isSubmitting,
handleSubmit,
onCancel,
pageCountryCode,
}: {
formData: CreateActionFormState;
setFormData: (data: CreateActionFormState) => void;
isSubmitting: boolean;
handleSubmit: () => void;
onCancel: () => void;
pageCountryCode?: string;
}) {
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
const { data: btcPrice } = useBtcPrice();
const allCountries = useMemo(() => getAllCountries(), []);
const [countryPickerOpen, setCountryPickerOpen] = useState(false);
const countryOptions = useMemo(() => {
const options: Array<{ value: string; label: string; flag: string }> = [
{ value: 'none', label: 'No country', flag: '🌍' },
];
if (pageCountryCode) {
options.push({
value: pageCountryCode,
label: getGeoDisplayName(pageCountryCode),
flag: countryCodeToFlag(pageCountryCode),
});
}
allCountries.forEach((country) => {
if (country.code !== pageCountryCode) {
options.push({ value: country.code, label: country.name, flag: countryCodeToFlag(country.code) });
}
});
return options;
}, [pageCountryCode, allCountries]);
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
try {
const [[, url]] = await uploadFile(file);
setFormData({ ...formData, coverImage: url });
} catch (error) {
console.error('Failed to upload cover image:', error);
}
};
return (
<>
<div className="space-y-4 py-2 px-4 max-w-full overflow-hidden">
<div className="space-y-2">
<Label htmlFor="country">Country (optional)</Label>
<Popover open={countryPickerOpen} onOpenChange={setCountryPickerOpen}>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" aria-expanded={countryPickerOpen} className="w-full justify-between">
{formData.selectedCountry ? (
<span className="flex items-center gap-2">
<span>{countryCodeToFlag(formData.selectedCountry)}</span>
<span>{getGeoDisplayName(formData.selectedCountry)}</span>
</span>
) : (
<span>No country</span>
)}
<ChevronRight className="ml-2 h-4 w-4 shrink-0 opacity-50 rotate-90" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[280px] p-0" align="start" sideOffset={4}>
<Command>
<CommandInput placeholder="Search..." />
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup>
{countryOptions.map((option) => (
<CommandItem
key={option.value}
value={`${option.label} ${option.value}`}
onSelect={() => {
setFormData({ ...formData, selectedCountry: option.value === 'none' ? '' : option.value });
setCountryPickerOpen(false);
}}
className="gap-2"
>
<span>{option.flag}</span>
<span className="flex-1">{option.label}</span>
<Check className={cn('h-4 w-4', (formData.selectedCountry || 'none') === option.value ? 'opacity-100' : 'opacity-0')} />
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<div className="space-y-2">
<Label>Cover image</Label>
<div className="relative w-full h-32 rounded-lg overflow-hidden border border-border">
<img src={formData.coverImage || DEFAULT_COVER_IMAGE} alt="Cover preview" className="w-full h-full object-cover" />
</div>
<div className="flex items-center gap-2">
<Label htmlFor="cover-upload" className="flex-1 cursor-pointer flex items-center justify-center gap-2 px-4 py-2 border border-border rounded-lg hover:bg-primary/10 transition-colors">
{isUploading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
<span className="text-sm">Upload custom</span>
</Label>
<input id="cover-upload" type="file" accept="image/*" className="hidden" onChange={handleFileUpload} disabled={isUploading} />
</div>
</div>
<div className="space-y-2">
<Label htmlFor="title">Title</Label>
<Input id="title" placeholder="What needs to happen?" value={formData.title} onChange={(e) => setFormData({ ...formData, title: e.target.value })} />
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
placeholder="Explain the action, evidence, or outcome you want to inspire and what submissions should include..."
className="min-h-[80px]"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="pledge-tags">Tags</Label>
<Input id="pledge-tags" placeholder="beach-cleanup, legal-defense" value={formData.tagInput} onChange={(e) => setFormData({ ...formData, tagInput: e.target.value })} />
</div>
<div className="space-y-2">
<Label htmlFor="pledgeUsd">Pledge</Label>
<div className="relative">
<span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-sm text-muted-foreground">
$
</span>
<Input
id="pledgeUsd"
type="text"
inputMode="decimal"
placeholder="100"
value={formData.pledgeUsd}
onChange={(e) => setFormData({ ...formData, pledgeUsd: e.target.value })}
className="pl-7 pr-14"
/>
<span className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-xs font-medium text-muted-foreground">
USD
</span>
</div>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="deadline">Deadline (optional)</Label>
<Input id="deadline" type="date" className="w-full min-w-0" value={formData.deadline} onChange={(e) => setFormData({ ...formData, deadline: e.target.value })} />
{formData.deadline && <Input id="time" type="time" className="w-full min-w-0" value={formData.time} onChange={(e) => setFormData({ ...formData, time: e.target.value })} />}
</div>
{formData.deadline && (
<div className="space-y-2 bg-muted/30 p-3 rounded-lg border border-border/50 animate-in slide-in-from-top-2 duration-200">
<Label className="text-sm font-medium flex items-center gap-2"><Clock className="h-4 w-4" /> Timezone</Label>
<TimezoneSwitcher value={formData.timezone} onChange={(timezone) => setFormData({ ...formData, timezone })} />
<p className="text-xs text-muted-foreground">Deadline time will be interpreted in this timezone.</p>
</div>
)}
</div>
<div className="flex flex-col gap-2 p-4 pt-2">
<Button onClick={handleSubmit} disabled={!formData.title || !formData.description || !formData.pledgeUsd || usdToSats(Number(formData.pledgeUsd.replace(/[, $]/g, '')), btcPrice) <= 0 || isSubmitting} className="gap-2 w-full">
{isSubmitting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plus className="h-4 w-4" />}
Create pledge
</Button>
<Button variant="outline" onClick={onCancel} className="w-full">Cancel</Button>
</div>
</>
);
}
export function CreateActionDialog({ countryCode, communityATag, open, onOpenChange }: CreateActionDialogProps) {
const [isSubmitting, setIsSubmitting] = useState(false);
const { user } = useCurrentUser();
const { mutateAsync: createEvent } = useNostrPublish();
const { data: btcPrice } = useBtcPrice();
const queryClient = useQueryClient();
const isMobile = useIsMobile();
const { toast } = useToast();
const browserTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
const [formData, setFormData] = useState<CreateActionFormState>({
title: '',
description: '',
tagInput: '',
pledgeUsd: '',
deadline: '',
time: '',
coverImage: DEFAULT_COVER_IMAGE,
selectedCountry: countryCode || '',
timezone: browserTimezone,
});
const handleSubmit = async () => {
if (!user) return;
setIsSubmitting(true);
try {
const now = Date.now();
const slug = formData.title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
const dTag = `${slug || 'pledge'}-${now}`;
const pledgeSats = usdToSats(Number(formData.pledgeUsd.replace(/[, $]/g, '')), btcPrice);
if (pledgeSats <= 0) throw new Error('Waiting for BTC/USD price to calculate the pledge amount.');
const pledgeTags = parsePledgeTagInput(formData.tagInput);
const tags: string[][] = [
['d', dTag],
['title', formData.title],
['bounty', String(pledgeSats)],
['t', 'agora-action'],
['alt', `Agora pledge: ${formData.title}`],
];
for (const tag of pledgeTags) tags.push(['t', tag]);
if (formData.selectedCountry) tags.push(['i', createCountryIdentifier(formData.selectedCountry.toUpperCase())]);
if (communityATag) {
tags.push(...createOrganizationAssociationTags(communityATag));
}
if (formData.coverImage) tags.push(['image', formData.coverImage]);
if (formData.deadline) {
const [year, month, day] = formData.deadline.split('-').map(Number);
const [hours, minutes] = formData.time ? formData.time.split(':').map(Number) : [23, 59];
tags.push(['deadline', String(unixSecondsInTimezone(year, month, day, hours, minutes, formData.timezone))]);
}
await createEvent({ kind: 36639, content: formData.description, tags });
await queryClient.invalidateQueries({ queryKey: ['agora-actions'] });
await queryClient.refetchQueries({ queryKey: ['agora-actions'] });
if (communityATag) {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['community-actions', communityATag] }),
queryClient.invalidateQueries({ queryKey: ['organization-activity', communityATag] }),
queryClient.invalidateQueries({
predicate: (q) => {
const [root, aTagsKey] = q.queryKey;
return root === 'community-activity-feed'
&& typeof aTagsKey === 'string'
&& aTagsKey.split(',').includes(communityATag);
},
}),
]);
}
// Pledges (kind 36639) surface in the home Agora activity feed.
await queryClient.invalidateQueries({ queryKey: ['agora-feed'] });
await queryClient.invalidateQueries({ queryKey: ['mixed-feed'] });
setFormData({
title: '', description: '', tagInput: '', pledgeUsd: '',
deadline: '', time: '',
coverImage: DEFAULT_COVER_IMAGE,
selectedCountry: countryCode || '',
timezone: browserTimezone,
});
onOpenChange(false);
toast({ title: 'Pledge created' });
} catch (error) {
console.error('Failed to create pledge:', error);
toast({ title: 'Failed to create pledge', variant: 'destructive' });
} finally {
setIsSubmitting(false);
}
};
if (!user) return null;
const description = communityATag
? 'New organization pledge. You can optionally choose a country below.'
: countryCode
? `New pledge for ${getGeoDisplayName(countryCode)}.`
: 'New pledge. You can optionally choose a country below.';
if (isMobile) {
return (
<Drawer open={open} onOpenChange={onOpenChange}>
<DrawerContent className="h-[85dvh] max-h-[85dvh]">
<DrawerHeader className="text-left">
<DrawerTitle className="flex items-center gap-2"><Megaphone className="h-5 w-5 text-primary" /> Create pledge</DrawerTitle>
<DrawerDescription>{description}</DrawerDescription>
</DrawerHeader>
<div className="overflow-y-auto flex-1 pb-safe">
<CreateActionForm formData={formData} setFormData={setFormData} isSubmitting={isSubmitting} handleSubmit={handleSubmit} onCancel={() => onOpenChange(false)} pageCountryCode={countryCode} />
</div>
</DrawerContent>
</Drawer>
);
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md sm:max-w-lg md:max-w-2xl max-h-[85vh] w-[calc(100vw-2rem)] sm:w-full overflow-hidden flex flex-col p-0">
<DialogHeader className="px-6 pt-6 pb-4 flex-shrink-0">
<DialogTitle className="flex items-center gap-2"><Megaphone className="h-5 w-5 text-primary" /> Create pledge</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<div className="overflow-y-auto overflow-x-hidden flex-1 min-h-0">
<CreateActionForm formData={formData} setFormData={setFormData} isSubmitting={isSubmitting} handleSubmit={handleSubmit} onCancel={() => onOpenChange(false)} pageCountryCode={countryCode} />
</div>
</DialogContent>
</Dialog>
);
}
-331
View File
@@ -1,331 +0,0 @@
import { useState, useCallback, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { Users, Loader2 } from 'lucide-react';
import { useNostr } from '@nostrify/react';
import { useQueryClient } from '@tanstack/react-query';
import { nip19 } from 'nostr-tools';
import type { NostrEvent } from '@nostrify/nostrify';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { ScrollArea } from '@/components/ui/scroll-area';
import { ImageUploadField } from '@/components/ImageUploadField';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useToast } from '@/hooks/useToast';
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
import { BADGE_DEFINITION_KIND, COMMUNITY_DEFINITION_KIND, type ParsedCommunity } from '@/lib/communityUtils';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
// ── Helpers ───────────────────────────────────────────────────────────────────
/** Convert text into a URL-safe slug for the d-tag identifier. */
function slugify(text: string): string {
return text
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, '')
.replace(/[\s_]+/g, '-')
.replace(/^-+|-+$/g, '');
}
// ── Types ─────────────────────────────────────────────────────────────────────
interface CreateCommunityDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** Existing community event when editing. Omit to create a new community. */
communityEvent?: NostrEvent;
/** Parsed existing community data when editing. */
community?: ParsedCommunity;
}
// ── Component ─────────────────────────────────────────────────────────────────
export function CreateCommunityDialog({ open, onOpenChange, communityEvent, community }: CreateCommunityDialogProps) {
const { user } = useCurrentUser();
const { nostr } = useNostr();
const { toast } = useToast();
const navigate = useNavigate();
const queryClient = useQueryClient();
const isEditing = !!communityEvent && !!community;
// Form state
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [imageUrl, setImageUrl] = useState('');
const [isPublishing, setIsPublishing] = useState(false);
const [isImageUploading, setIsImageUploading] = useState(false);
// Mutations
const { mutateAsync: publishEvent } = useNostrPublish();
// Derived
const effectiveSlug = isEditing && community ? community.dTag : slugify(name);
const populateFromCommunity = useCallback(() => {
setName(community?.name ?? '');
setDescription(community?.description ?? '');
setImageUrl(community?.image ?? '');
setIsPublishing(false);
setIsImageUploading(false);
}, [community]);
const resetForm = useCallback(() => {
if (isEditing) {
populateFromCommunity();
} else {
setName('');
setDescription('');
setImageUrl('');
setIsPublishing(false);
setIsImageUploading(false);
}
}, [isEditing, populateFromCommunity]);
useEffect(() => {
if (open && isEditing) {
populateFromCommunity();
}
}, [open, isEditing, populateFromCommunity]);
const handleOpenChange = useCallback((nextOpen: boolean) => {
if (!nextOpen) resetForm();
onOpenChange(nextOpen);
}, [onOpenChange, resetForm]);
const buildUpdatedCommunityTags = useCallback((baseTags: string[][]): string[][] => {
const tags = baseTags.filter(([name]) => !['d', 'name', 'description', 'image', 'alt'].includes(name));
const nextTags: string[][] = [
['d', effectiveSlug],
['name', name.trim()],
];
if (description.trim()) {
nextTags.push(['description', description.trim()]);
}
const sanitizedImage = sanitizeUrl(imageUrl.trim());
if (sanitizedImage) {
nextTags.push(['image', sanitizedImage]);
}
nextTags.push(...tags);
nextTags.push(['alt', `Community: ${name.trim()}`]);
return nextTags;
}, [description, effectiveSlug, imageUrl, name]);
// ── Publish ───────────────────────────────────────────────────────────────
const handleCreate = useCallback(async () => {
if (!user || !name.trim() || !effectiveSlug) return;
if (isImageUploading) {
toast({ title: 'Image is still uploading', description: 'Please wait for the upload to finish.' });
return;
}
if (imageUrl.trim() && !sanitizeUrl(imageUrl.trim())) {
toast({ title: 'Image URL must be a valid https URL', variant: 'destructive' });
return;
}
setIsPublishing(true);
try {
if (isEditing && communityEvent && community) {
const prev = await fetchFreshEvent(nostr, {
kinds: [COMMUNITY_DEFINITION_KIND],
authors: [communityEvent.pubkey],
'#d': [community.dTag],
});
const updatedEvent = await publishEvent({
kind: COMMUNITY_DEFINITION_KIND,
content: prev?.content ?? communityEvent.content,
tags: buildUpdatedCommunityTags(prev?.tags ?? communityEvent.tags),
prev: prev ?? undefined,
} as Omit<NostrEvent, 'id' | 'pubkey' | 'sig'> & { prev?: NostrEvent });
queryClient.setQueryData(
['addr-event', COMMUNITY_DEFINITION_KIND, communityEvent.pubkey, community.dTag],
updatedEvent,
);
queryClient.invalidateQueries({ queryKey: ['community-activity-feed'], exact: false });
queryClient.invalidateQueries({ queryKey: ['my-communities'], exact: false });
toast({ title: 'Community updated!' });
handleOpenChange(false);
return;
}
// Check for d-tag collision (same author, same kind, same d-tag)
const existing = await nostr.query([{
kinds: [COMMUNITY_DEFINITION_KIND],
authors: [user.pubkey],
'#d': [effectiveSlug],
limit: 1,
}]);
if (existing.length > 0) {
toast({
title: 'Name already in use',
description: 'You already have a community with this name. Please choose a different name.',
variant: 'destructive',
});
setIsPublishing(false);
return;
}
const badgeDTag = `${effectiveSlug}-member`;
const existingBadge = await nostr.query([{
kinds: [BADGE_DEFINITION_KIND],
authors: [user.pubkey],
'#d': [badgeDTag],
limit: 1,
}]);
if (existingBadge.length > 0) {
toast({
title: 'Member badge ID already in use',
description: 'Choose a different community name so the member badge can be created safely.',
variant: 'destructive',
});
setIsPublishing(false);
return;
}
const badgeEvent = await publishEvent({
kind: BADGE_DEFINITION_KIND,
content: '',
tags: [
['d', badgeDTag],
['name', 'Member'],
['description', `Member of ${name.trim()}`],
['alt', `Badge definition: Member of ${name.trim()}`],
],
} as Omit<NostrEvent, 'id' | 'pubkey' | 'sig'>);
// Founder as moderator (p tag) plus one flat member badge reference.
const communityTags = buildUpdatedCommunityTags([
['a', `${BADGE_DEFINITION_KIND}:${badgeEvent.pubkey}:${badgeDTag}`, '', 'member'],
['p', user.pubkey, '', 'moderator'],
]);
// Publish community definition (kind 34550)
const createdEvent = await publishEvent({
kind: COMMUNITY_DEFINITION_KIND,
content: '',
tags: communityTags,
} as Omit<NostrEvent, 'id' | 'pubkey' | 'sig'>);
// Navigate to the new community
const naddr = nip19.naddrEncode({
kind: COMMUNITY_DEFINITION_KIND,
pubkey: createdEvent.pubkey,
identifier: effectiveSlug,
});
toast({ title: 'Community created!' });
handleOpenChange(false);
navigate(`/${naddr}`);
} catch (err) {
toast({
title: isEditing ? 'Failed to update community' : 'Failed to create community',
description: err instanceof Error ? err.message : 'Please try again.',
variant: 'destructive',
});
} finally {
setIsPublishing(false);
}
}, [
user, name, effectiveSlug, isEditing, communityEvent, community, nostr, isImageUploading, imageUrl,
publishEvent, buildUpdatedCommunityTags, queryClient, toast, handleOpenChange, navigate,
]);
if (!user) return null;
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-lg gap-0 p-0 overflow-hidden">
<DialogHeader className="px-5 pt-5 pb-3">
<DialogTitle className="flex items-center gap-2">
<Users className="size-5 text-primary" />
{isEditing ? 'Edit Community' : 'Create a Community'}
</DialogTitle>
<DialogDescription>
{isEditing
? 'Update the name, image, and description. Moderators are preserved.'
: "Start a new community on Nostr. You'll be the founder."}
</DialogDescription>
</DialogHeader>
<ScrollArea className="max-h-[calc(100vh-9rem)] sm:max-h-none">
<div className="px-5 pb-5 space-y-4">
{/* Community name */}
<div className="space-y-1.5">
<Label htmlFor="community-name">Community Name *</Label>
<Input
id="community-name"
placeholder="e.g. The Arbiter's Guard"
value={name}
onChange={(e) => setName(e.target.value)}
maxLength={100}
/>
{name.trim() && (
<p className="text-xs text-muted-foreground font-mono">
ID: {effectiveSlug || '...'}{isEditing ? ' (unchanged)' : ''}
</p>
)}
</div>
<ImageUploadField
id="community-image"
label={<>Community Image <span className="text-muted-foreground font-normal">(recommended)</span></>}
value={imageUrl}
onChange={setImageUrl}
onUploadingChange={setIsImageUploading}
previewAlt="Community image preview"
dropAreaClassName="min-h-32"
/>
{/* Description */}
<div className="space-y-1.5">
<Label htmlFor="community-description">
Description
<span className="text-muted-foreground font-normal ml-1">(recommended)</span>
</Label>
<Textarea
id="community-description"
placeholder="What is this community about?"
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
/>
</div>
{/* Submit button */}
<Button
onClick={handleCreate}
disabled={!name.trim() || !effectiveSlug || isPublishing || isImageUploading}
className="w-full gap-2"
>
{isPublishing ? (
<><Loader2 className="size-4 animate-spin" /> {isEditing ? 'Saving...' : 'Creating...'}</>
) : (
<><Users className="size-4" /> {isEditing ? 'Save Changes' : 'Create Community'}</>
)}
</Button>
</div>
</ScrollArea>
</DialogContent>
</Dialog>
);
}
+6 -12
View File
@@ -23,7 +23,9 @@ import { useNostrPublish } from '@/hooks/useNostrPublish';
import { usePublishRSVP } from '@/hooks/usePublishRSVP';
import { useToast } from '@/hooks/useToast';
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
import { createOrganizationAssociationTags } from '@/lib/organizationContext';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import { withAgoraTag } from '@/lib/agoraNoteTags';
interface CreateCommunityEventDialogProps {
communityATag?: string;
@@ -80,11 +82,6 @@ function toLocalTimestamp(date: string, time: string): number {
return Math.floor(new Date(`${date}T${time}:00`).getTime() / 1000);
}
function parseCommunityAuthor(communityATag: string): string | undefined {
const [, pubkey] = communityATag.split(':');
return pubkey || undefined;
}
export function CreateCommunityEventDialog({ communityATag, open, onOpenChange, event }: CreateCommunityEventDialogProps) {
const { user } = useCurrentUser();
const { nostr } = useNostr();
@@ -243,16 +240,12 @@ export function CreateCommunityEventDialog({ communityATag, open, onOpenChange,
const tags: string[][] = [
['d', dTag],
['title', trimmedTitle],
['alt', `${isCommunityEvent ? 'Community event' : 'Calendar event'}: ${trimmedTitle}`],
['alt', `${isCommunityEvent ? 'Organization event' : 'Calendar event'}: ${trimmedTitle}`],
...preservedTags,
];
if (effectiveCommunityATag) {
const communityAuthor = parseCommunityAuthor(effectiveCommunityATag);
tags.push(['A', effectiveCommunityATag], ['K', '34550']);
if (communityAuthor) {
tags.push(['P', communityAuthor]);
}
tags.push(...createOrganizationAssociationTags(effectiveCommunityATag));
}
if (description.trim()) {
@@ -315,7 +308,7 @@ export function CreateCommunityEventDialog({ communityATag, open, onOpenChange,
const publishedEvent = await publishEvent({
kind,
content: description.trim(),
tags,
tags: withAgoraTag(tags),
prev: prev ?? undefined,
});
@@ -339,6 +332,7 @@ export function CreateCommunityEventDialog({ communityATag, open, onOpenChange,
queryClient.invalidateQueries({ queryKey: ['addr-event', kind, publishedEvent.pubkey, dTag] }),
...(effectiveCommunityATag ? [
queryClient.invalidateQueries({ queryKey: ['community-events', effectiveCommunityATag] }),
queryClient.invalidateQueries({ queryKey: ['organization-activity', effectiveCommunityATag] }),
queryClient.invalidateQueries({
predicate: (q) => {
const [root, aTagsKey] = q.queryKey;
+3 -2
View File
@@ -20,6 +20,7 @@ import { getEffectiveRelays } from '@/lib/appRelays';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import { useQueryClient } from '@tanstack/react-query';
import { ZAP_GOAL_KIND } from '@/lib/goalUtils';
import { withAgoraTag } from '@/lib/agoraNoteTags';
interface CreateGoalDialogProps {
/** The community `a` tag coordinate (e.g. `34550:<pubkey>:<d-tag>`). */
@@ -116,7 +117,7 @@ export function CreateGoalDialog({ communityATag, children, open: controlledOpen
await publishEvent({
kind: ZAP_GOAL_KIND,
content: title.trim(),
tags,
tags: withAgoraTag(tags),
});
// Refresh the goals tab and the community activity feed
@@ -165,7 +166,7 @@ export function CreateGoalDialog({ communityATag, children, open: controlledOpen
<Label htmlFor="goal-title">Title</Label>
<Input
id="goal-title"
placeholder="e.g. Community meetup expenses"
placeholder="e.g. Organization meetup expenses"
value={title}
onChange={(e) => setTitle(e.target.value)}
required
-257
View File
@@ -1,257 +0,0 @@
import { useEffect, useRef } from 'react';
interface Particle {
x: number;
y: number;
vx: number;
vy: number;
life: number;
size: number;
}
interface Ring {
x: number;
y: number;
radius: number;
maxRadius: number;
life: number; // 1 → 0
}
function parseHslString(hsl: string): { h: number; s: number; l: number } {
const parts = hsl.trim().split(/\s+/);
return {
h: parseFloat(parts[0] ?? '30'),
s: parseFloat(parts[1] ?? '100'),
l: parseFloat(parts[2] ?? '55'),
};
}
export function CursorFireEffect() {
const canvasRef = useRef<HTMLCanvasElement>(null);
const particles = useRef<Particle[]>([]);
const rings = useRef<Ring[]>([]);
const cursor = useRef<{ x: number; y: number } | null>(null);
const active = useRef(false);
const raf = useRef(0);
const pulse = useRef(0);
const frame = useRef(0);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
function resize() {
if (!canvas) return;
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
}
resize();
window.addEventListener('resize', resize);
function onMouseMove(e: MouseEvent) {
cursor.current = { x: e.clientX, y: e.clientY };
active.current = true;
}
function onTouchMove(e: TouchEvent) {
const t = e.touches[0];
if (t) { cursor.current = { x: t.clientX, y: t.clientY }; active.current = true; }
}
function onLeave() { active.current = false; }
function onClick(e: MouseEvent) {
spawnClickBurst(e.clientX, e.clientY);
}
function onTouchStart(e: TouchEvent) {
const t = e.touches[0];
if (t) spawnClickBurst(t.clientX, t.clientY);
}
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('touchmove', onTouchMove, { passive: true });
window.addEventListener('mouseleave', onLeave);
window.addEventListener('touchend', onLeave);
window.addEventListener('click', onClick);
window.addEventListener('touchstart', onTouchStart, { passive: true });
function getPrimary() {
const raw = getComputedStyle(document.documentElement).getPropertyValue('--primary').trim();
return raw ? parseHslString(raw) : { h: 270, s: 80, l: 60 };
}
function spawnWispParticles(x: number, y: number) {
const count = Math.floor(Math.random() * 2) + 2;
for (let i = 0; i < count; i++) {
const angle = -Math.PI / 2 + (Math.random() - 0.5) * 0.3;
const speed = Math.random() * 0.6 + 0.3;
particles.current.push({
x: x + (Math.random() - 0.5) * 6,
y,
vx: Math.cos(angle) * speed * 0.2,
vy: Math.sin(angle) * speed,
life: 1,
size: Math.random() * 28 + 20,
});
}
}
function spawnClickBurst(x: number, y: number) {
// Expanding shockwave ring
rings.current.push({ x, y, radius: 0, maxRadius: 120, life: 1 });
// Secondary smaller ring
rings.current.push({ x, y, radius: 0, maxRadius: 60, life: 1 });
// Radial burst of particles in all directions
const count = 18;
for (let i = 0; i < count; i++) {
const angle = (i / count) * Math.PI * 2;
const speed = Math.random() * 3.5 + 1.5;
particles.current.push({
x,
y,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
life: 1,
size: Math.random() * 20 + 12,
});
}
// Extra upward plume
for (let i = 0; i < 8; i++) {
const angle = -Math.PI / 2 + (Math.random() - 0.5) * 0.8;
const speed = Math.random() * 4 + 2;
particles.current.push({
x: x + (Math.random() - 0.5) * 10,
y,
vx: Math.cos(angle) * speed * 0.3,
vy: Math.sin(angle) * speed,
life: 1,
size: Math.random() * 30 + 18,
});
}
}
function draw() {
if (!canvas || !ctx) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
const { h, s, l } = getPrimary();
// Spawn wisp particles every 4th frame
frame.current++;
if (active.current && cursor.current && frame.current % 4 === 0) {
spawnWispParticles(cursor.current.x, cursor.current.y);
}
ctx.globalCompositeOperation = 'screen';
// Draw expanding rings
const aliveRings: Ring[] = [];
for (const r of rings.current) {
r.life -= 0.022;
if (r.life <= 0) continue;
r.radius += (r.maxRadius - r.radius) * 0.08;
const t = r.life;
const lineAlpha = Math.pow(t, 1.5) * 0.8;
const glowAlpha = Math.pow(t, 2) * 0.4;
const lineWidth = t * 3;
// Outer glow halo
ctx.beginPath();
ctx.arc(r.x, r.y, r.radius, 0, Math.PI * 2);
ctx.strokeStyle = `hsla(${h}, ${s}%, ${Math.min(l + 20, 85)}%, ${glowAlpha})`;
ctx.lineWidth = lineWidth + 8;
ctx.stroke();
// Sharp ring
ctx.beginPath();
ctx.arc(r.x, r.y, r.radius, 0, Math.PI * 2);
ctx.strokeStyle = `hsla(${h - 10}, ${s}%, 90%, ${lineAlpha})`;
ctx.lineWidth = lineWidth;
ctx.stroke();
aliveRings.push(r);
}
rings.current = aliveRings;
// Draw flame particles
const alive: Particle[] = [];
for (const p of particles.current) {
p.life -= 0.005 + Math.random() * 0.002;
if (p.life <= 0) continue;
p.x += p.vx;
p.y += p.vy;
p.vy -= 0.018;
p.vx *= 0.98;
p.size *= 0.985;
const t = p.life;
const ph = h + (1 - t) * 25;
const pl = Math.min(l + t * 40, 90);
const alpha = Math.pow(t, 1.5) * 0.18;
const radius = p.size * (0.4 + t * 0.6);
const g = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, radius);
g.addColorStop(0, `hsla(${ph - 5}, ${s}%, ${pl}%, ${alpha})`);
g.addColorStop(0.35, `hsla(${ph}, ${s}%, ${Math.max(l, 40)}%, ${alpha * 0.6})`);
g.addColorStop(0.7, `hsla(${ph + 15}, ${s}%, ${Math.max(l - 15, 20)}%, ${alpha * 0.2})`);
g.addColorStop(1, `hsla(${ph + 25}, ${s}%, ${Math.max(l - 25, 5)}%, 0)`);
ctx.beginPath();
ctx.arc(p.x, p.y, radius, 0, Math.PI * 2);
ctx.fillStyle = g;
ctx.fill();
alive.push(p);
}
particles.current = alive;
// Orb: slow pulsing core glow at cursor
if (active.current && cursor.current) {
const { x, y } = cursor.current;
pulse.current += 0.025;
const pv = (Math.sin(pulse.current) + 1) / 2;
const r = 20 + pv * 12;
const a = 0.5 + pv * 0.3;
const orb = ctx.createRadialGradient(x, y, 0, x, y, r);
orb.addColorStop(0, `hsla(${h - 10}, ${Math.max(s - 10, 0)}%, 95%, ${a})`);
orb.addColorStop(0.4, `hsla(${h}, ${s}%, ${Math.min(l + 15, 85)}%, ${a * 0.5})`);
orb.addColorStop(1, `hsla(${h + 15}, ${s}%, ${l}%, 0)`);
ctx.beginPath();
ctx.arc(x, y, r, 0, Math.PI * 2);
ctx.fillStyle = orb;
ctx.fill();
}
ctx.globalCompositeOperation = 'source-over';
raf.current = requestAnimationFrame(draw);
}
raf.current = requestAnimationFrame(draw);
return () => {
cancelAnimationFrame(raf.current);
window.removeEventListener('resize', resize);
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('touchmove', onTouchMove);
window.removeEventListener('mouseleave', onLeave);
window.removeEventListener('touchend', onLeave);
window.removeEventListener('click', onClick);
window.removeEventListener('touchstart', onTouchStart);
};
}, []);
return (
<canvas
ref={canvasRef}
className="pointer-events-none fixed inset-0 z-[9999]"
aria-hidden="true"
/>
);
}
-123
View File
@@ -1,123 +0,0 @@
import { type ReactNode, useCallback, useMemo } from "react";
import { DMProvider } from "@samthomson/nostr-messaging/core";
import { DEFAULT_NEW_MESSAGE_SOUNDS } from "@samthomson/nostr-messaging/core";
import type { NostrEvent } from "@nostrify/nostrify";
import { useNostr } from "@nostrify/react";
import { toast } from "@/hooks/useToast";
import { useCurrentUser } from "@/hooks/useCurrentUser";
import { useAppContext } from "@/hooks/useAppContext";
import { useNostrPublish } from "@/hooks/useNostrPublish";
import { useUploadFile } from "@/hooks/useUploadFile";
import { useProfileSupplementary } from "@/hooks/useProfileData";
import { useIsMobile } from "@/hooks/useIsMobile";
import { getDisplayName } from "@/lib/getDisplayName";
import { getEffectiveRelays } from "@/lib/appRelays";
import { useAuthors } from "@/hooks/useAuthors";
interface DMProviderWrapperProps {
children: ReactNode;
}
export function DMProviderWrapper({ children }: DMProviderWrapperProps) {
const { nostr } = useNostr();
const { user } = useCurrentUser();
const { config } = useAppContext();
const { mutateAsync: publishEvent } = useNostrPublish();
const { mutateAsync: uploadFileMutation } = useUploadFile();
const isMobile = useIsMobile();
const { data: profileData } = useProfileSupplementary(user?.pubkey);
const follows = useMemo(() => profileData?.following ?? [], [profileData]);
const handlePublishEvent = useCallback(async (
event: Omit<NostrEvent, "id" | "pubkey" | "sig">,
): Promise<void> => {
await publishEvent(event);
}, [publishEvent]);
const handleUploadFile = useCallback(async (file: File): Promise<string> => {
const tags = await uploadFileMutation(file);
return tags[0][1] ?? "";
}, [uploadFileMutation]);
const handleGetDisplayName = useCallback((
pubkey: string,
metadata?: Parameters<typeof getDisplayName>[0],
) => {
return getDisplayName(metadata, pubkey);
}, []);
const handleNotify = useCallback((options: { title?: string; description?: string; variant?: "default" | "destructive" }) => {
toast(options);
}, []);
const messaging = useMemo(() => config.messaging ?? {}, [config.messaging]);
const discoveryRelays = useMemo(() => {
if (messaging.discoveryRelays?.length) {
return messaging.discoveryRelays;
}
return getEffectiveRelays(config.relayMetadata, config.useAppRelays, config.useUserRelays).relays
.filter((relay) => relay.read)
.map((relay) => relay.url);
}, [messaging.discoveryRelays, config.relayMetadata, config.useAppRelays, config.useUserRelays]);
const relayMode = messaging.relayMode ?? "hybrid";
const protocolMode = messaging.protocolMode;
const messagingEnabled = messaging.enabled ?? true;
const renderInlineMedia = messaging.renderInlineMedia ?? true;
const soundEnabled = messaging.soundEnabled ?? false;
const soundId = messaging.soundId ?? DEFAULT_NEW_MESSAGE_SOUNDS[0]?.id ?? "";
const devMode = messaging.devMode ?? false;
const messagingConfig = useMemo(() => ({
enabled: messagingEnabled,
discoveryRelays,
relayMode,
protocolMode,
renderInlineMedia,
devMode,
appName: config.appName,
appDescription: `Direct messages on ${config.appName}`,
soundPref: {
options: DEFAULT_NEW_MESSAGE_SOUNDS,
value: { enabled: soundEnabled, soundId },
onChange: () => {},
},
}), [
messagingEnabled,
discoveryRelays,
relayMode,
protocolMode,
renderInlineMedia,
devMode,
config.appName,
soundEnabled,
soundId,
]);
const uiConfig = useMemo(() => ({
showShorts: false,
showSearch: true,
showHeader: false,
isMobile,
}), [isMobile]);
return (
<DMProvider
nostr={nostr}
user={user ?? null}
messagingConfig={messagingConfig}
onNotify={handleNotify}
getDisplayName={handleGetDisplayName}
fetchAuthorsBatch={useAuthors}
publishEvent={handlePublishEvent}
uploadFile={handleUploadFile}
follows={follows}
ui={uiConfig}
>
{children}
</DMProvider>
);
}
+31
View File
@@ -0,0 +1,31 @@
import type { NostrEvent } from '@nostrify/nostrify';
import { ComposeBox } from '@/components/ComposeBox';
interface DetailCommentComposerProps {
event: NostrEvent;
placeholder?: string;
onSuccess?: () => void;
className?: string;
}
export function DetailCommentComposer({
event,
placeholder = "What's on your mind?",
onSuccess,
className,
}: DetailCommentComposerProps) {
return (
<div className={className}>
<ComposeBox
compact
defaultExpanded
hideBorder
replyTo={event}
placeholder={placeholder}
onSuccess={onSuccess}
className="bg-transparent"
/>
</div>
);
}
+736
View File
@@ -0,0 +1,736 @@
import { useEffect, useMemo, useState } from 'react';
import { useMutation, useQuery } from '@tanstack/react-query';
import {
AlertTriangle,
ArrowUpRight,
Check,
ChevronLeft,
HandHeart,
Heart,
Loader2,
LogIn,
Sparkle,
Sparkles,
Star,
} from 'lucide-react';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { BitcoinPublicDisclaimer } from '@/components/BitcoinPublicDisclaimer';
import { Button } from '@/components/ui/button';
import { CampaignWalletDonatePanel } from '@/components/CampaignWalletDonatePanel';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Skeleton } from '@/components/ui/skeleton';
import { Textarea } from '@/components/ui/textarea';
import AuthDialog from '@/components/auth/AuthDialog';
import { useAppContext } from '@/hooks/useAppContext';
import { useBitcoinSigner } from '@/hooks/useBitcoinSigner';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useDonateCampaign, type DonateCampaignResult, type DonationFeeSpeed } from '@/hooks/useDonateCampaign';
import { useToast } from '@/hooks/useToast';
import {
BITCOIN_DUST_LIMIT,
estimateFee,
fetchUTXOs,
formatSats,
getFeeRates,
nostrPubkeyToBitcoinAddress,
satsToUSD,
usdToSats,
type FeeRates,
} from '@/lib/bitcoin';
import {
type ParsedCampaign,
} from '@/lib/campaign';
import { cn } from '@/lib/utils';
/**
* Donation presets in USD. The signed event and Bitcoin transaction still use
* sats; USD is only the user-facing input currency.
*/
const PRESET_AMOUNTS: readonly { amountUsd: number; icon: React.ComponentType<{ className?: string }>; label: string }[] = [
{ amountUsd: 10, icon: Sparkle, label: '$10' },
{ amountUsd: 25, icon: Sparkles, label: '$25' },
{ amountUsd: 100, icon: Star, label: '$100' },
{ amountUsd: 500, icon: Heart, label: '$500' },
{ amountUsd: 1_000, icon: HandHeart, label: '$1K' },
];
function parseUsdInput(input: string): number {
const cleaned = input.replace(/[, $]/g, '').trim();
if (!cleaned) return 0;
const n = Number(cleaned);
return Number.isFinite(n) && n > 0 ? n : 0;
}
function feeRateForSpeed(rates: FeeRates, speed: DonationFeeSpeed): number {
return {
fastest: rates.fastestFee,
halfHour: rates.halfHourFee,
hour: rates.hourFee,
economy: rates.economyFee,
}[speed];
}
function estimateDonationFee({
feeRate,
utxoCount,
}: {
feeRate: number;
utxoCount: number;
}): number {
// Single recipient + change output.
return estimateFee(utxoCount, 2, feeRate);
}
interface DonateDialogProps {
campaign: ParsedCampaign;
open: boolean;
onOpenChange: (open: boolean) => void;
/** Spot price of BTC in USD, used for inline USD previews. Optional. */
btcPrice?: number;
}
type Step = 'form' | 'confirm' | 'success';
/**
* Donate dialog for **on-chain** (`bc1q…` / `bc1p…`) campaigns. The
* campaign's `w` wallet endpoint is the single output destination —
* there are no recipient splits, no per-recipient previews, and no
* dust math beyond the one-output PSBT.
*
* Silent-payment campaigns (`sp1…`) never open this dialog; their
* detail-page donate column points directly at the SP code via the
* `CampaignWalletDonatePanel` so donors can scan/copy and pay from a
* BIP-352-aware external wallet.
*/
export function DonateDialog({ campaign, open, onOpenChange, btcPrice }: DonateDialogProps) {
const { user } = useCurrentUser();
const { canSignPsbt } = useBitcoinSigner();
const { donateToCampaign } = useDonateCampaign();
const { toast } = useToast();
const [step, setStep] = useState<Step>('form');
const [amountUsd, setAmountUsd] = useState<number>(PRESET_AMOUNTS[1].amountUsd);
const [customUsd, setCustomUsd] = useState('');
const [comment, setComment] = useState('');
const [feeSpeed, setFeeSpeed] = useState<DonationFeeSpeed>('fastest');
const [result, setResult] = useState<DonateCampaignResult | null>(null);
// Reset when the dialog reopens for a fresh donation.
useEffect(() => {
if (open) {
setStep('form');
setResult(null);
}
}, [open]);
const effectiveUsd = customUsd.trim()
? parseUsdInput(customUsd)
: amountUsd;
const effectiveAmount = usdToSats(effectiveUsd, btcPrice);
const belowDust = Number.isFinite(effectiveAmount) && effectiveAmount > 0 && effectiveAmount < BITCOIN_DUST_LIMIT;
const donateMutation = useMutation({
mutationFn: async () =>
donateToCampaign({
campaign,
amountSats: effectiveAmount,
comment,
feeSpeed,
}),
onSuccess: (r) => {
setResult(r);
setStep('success');
if (!r.receiptPublished) {
toast({
title: 'Donation sent, but the receipt failed',
description: `On-chain tx ${r.txid.slice(0, 12)}… broadcast; the kind 8333 receipt didn't publish${r.receiptPublishError ? ` (${r.receiptPublishError})` : ''}.`,
variant: 'destructive',
});
} else {
toast({
title: 'Donation sent',
description: `Thanks for supporting ${campaign.title}.`,
});
}
},
onError: (error: unknown) => {
const msg = error instanceof Error ? error.message : String(error);
toast({
title: 'Donation failed',
description: msg,
variant: 'destructive',
});
},
});
const handleClose = () => {
if (donateMutation.isPending) return;
onOpenChange(false);
};
// ── Logged-out flow ──
if (open && !user) {
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-md max-h-[90vh] overflow-y-auto">
<LoggedOutChooserView
campaign={campaign}
onClose={handleClose}
/>
</DialogContent>
</Dialog>
);
}
// Logged-in but the signer can't build a PSBT (e.g. NIP-07 extension
// without signPsbt). Direct the donor at the external-wallet panel on
// the page — the in-app flow simply isn't possible without a PSBT
// signer.
if (open && !canSignPsbt) {
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-md max-h-[90vh] overflow-y-auto">
<SignerUnsupportedView campaign={campaign} onClose={handleClose} />
</DialogContent>
</Dialog>
);
}
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-md max-h-[90vh] overflow-y-auto">
{step === 'form' && (
<FormView
campaign={campaign}
amountUsd={amountUsd}
customUsd={customUsd}
comment={comment}
feeSpeed={feeSpeed}
effectiveAmount={effectiveAmount}
effectiveUsd={effectiveUsd}
belowDust={belowDust}
btcPrice={btcPrice}
isPending={donateMutation.isPending}
onAmountChange={(usd) => {
setAmountUsd(usd);
setCustomUsd('');
}}
onCustomChange={setCustomUsd}
onCommentChange={setComment}
onFeeSpeedChange={setFeeSpeed}
onContinue={() => setStep('confirm')}
onClose={handleClose}
/>
)}
{step === 'confirm' && (
<ConfirmView
campaign={campaign}
amountSats={effectiveAmount}
effectiveUsd={effectiveUsd}
comment={comment}
feeSpeed={feeSpeed}
btcPrice={btcPrice}
isPending={donateMutation.isPending}
onBack={() => setStep('form')}
onSubmit={() => donateMutation.mutate()}
/>
)}
{step === 'success' && result && (
<SuccessView
campaign={campaign}
result={result}
btcPrice={btcPrice}
onClose={handleClose}
/>
)}
</DialogContent>
</Dialog>
);
}
// ─────────────────────────────────────────────────────────────────────
// 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,
amountUsd,
customUsd,
comment,
feeSpeed,
effectiveAmount,
effectiveUsd,
belowDust,
btcPrice,
isPending,
onAmountChange,
onCustomChange,
onCommentChange,
onFeeSpeedChange,
onContinue,
}: FormViewProps) {
const usingCustom = customUsd.trim().length > 0;
const canContinue = effectiveAmount > 0 && !belowDust;
return (
<>
<DialogHeader>
<DialogTitle>Donate to {campaign.title}</DialogTitle>
<DialogDescription>
Send Bitcoin to the campaign's wallet from your in-app balance.
</DialogDescription>
</DialogHeader>
<div className="space-y-5 py-2">
{/* Preset amounts */}
<div>
<Label className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Amount
</Label>
<div className="mt-2 grid grid-cols-3 sm:grid-cols-5 gap-2">
{PRESET_AMOUNTS.map(({ amountUsd: usd, icon: Icon, label }) => {
const selected = !usingCustom && amountUsd === usd;
return (
<button
key={usd}
type="button"
onClick={() => onAmountChange(usd)}
className={cn(
'flex flex-col items-center gap-1 rounded-lg border px-2 py-2.5 text-xs font-semibold motion-safe:transition-colors',
selected
? 'border-primary bg-primary/10 text-primary'
: 'border-border bg-card hover:bg-muted/60',
)}
>
<Icon className="size-4" />
{label}
</button>
);
})}
</div>
</div>
{/* Custom amount */}
<div className="space-y-1.5">
<Label htmlFor="donate-custom" className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Or custom (USD)
</Label>
<div className="relative">
<span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-sm text-muted-foreground">
$
</span>
<Input
id="donate-custom"
type="text"
inputMode="decimal"
placeholder="50"
value={customUsd}
onChange={(e) => onCustomChange(e.target.value)}
className="pl-7"
/>
</div>
{effectiveAmount > 0 && (
<div className="text-xs text-muted-foreground">
≈ {formatSats(effectiveAmount)} sats
{btcPrice && effectiveUsd > 0 && (
<> · ${effectiveUsd.toLocaleString()} at current price</>
)}
</div>
)}
</div>
{/* Comment */}
<div className="space-y-1.5">
<Label htmlFor="donate-comment" className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Public comment (optional)
</Label>
<Textarea
id="donate-comment"
value={comment}
onChange={(e) => onCommentChange(e.target.value)}
placeholder="Stay strong."
rows={2}
maxLength={280}
/>
</div>
{/* Fee speed */}
<div className="space-y-1.5">
<Label className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Confirmation speed
</Label>
<Select value={feeSpeed} onValueChange={(v) => onFeeSpeedChange(v as DonationFeeSpeed)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="fastest">Fastest (~10 min)</SelectItem>
<SelectItem value="halfHour">Half hour</SelectItem>
<SelectItem value="hour">Hour</SelectItem>
<SelectItem value="economy">Economy (cheapest)</SelectItem>
</SelectContent>
</Select>
</div>
{belowDust && (
<Alert variant="destructive">
<AlertTriangle className="size-4" />
<AlertDescription>
Amount is below the Bitcoin dust limit ({BITCOIN_DUST_LIMIT.toLocaleString()} sats).
Choose a larger amount.
</AlertDescription>
</Alert>
)}
<BitcoinPublicDisclaimer tone="soft" />
</div>
<Button
size="lg"
className="w-full"
onClick={onContinue}
disabled={!canContinue || isPending}
>
Review donation
<ArrowUpRight className="size-4 ml-1.5" />
</Button>
</>
);
}
// ─────────────────────────────────────────────────────────────────────
// Confirm step
// ─────────────────────────────────────────────────────────────────────
interface ConfirmViewProps {
campaign: ParsedCampaign;
amountSats: number;
effectiveUsd: number;
comment: string;
feeSpeed: DonationFeeSpeed;
btcPrice: number | undefined;
isPending: boolean;
onBack: () => void;
onSubmit: () => void;
}
function ConfirmView({
campaign,
amountSats,
effectiveUsd,
comment,
feeSpeed,
btcPrice,
isPending,
onBack,
onSubmit,
}: ConfirmViewProps) {
const { user } = useCurrentUser();
const { config } = useAppContext();
const { esploraApis } = config;
const senderAddress = user ? nostrPubkeyToBitcoinAddress(user.pubkey) : null;
// Pre-fetch UTXOs + fee rates so the confirm screen can show an
// accurate fee estimate before the donor commits.
const utxosQuery = useQuery({
queryKey: ['bitcoin-utxos', senderAddress, esploraApis],
queryFn: ({ signal }) => fetchUTXOs(senderAddress!, esploraApis, signal),
enabled: !!senderAddress,
staleTime: 30_000,
});
const feeRatesQuery = useQuery({
queryKey: ['bitcoin-fee-rates', esploraApis],
queryFn: ({ signal }) => getFeeRates(esploraApis, signal),
staleTime: 30_000,
});
const feeEstimate = useMemo(() => {
const utxos = utxosQuery.data;
const rates = feeRatesQuery.data;
if (!utxos || !rates) return null;
return estimateDonationFee({
feeRate: feeRateForSpeed(rates, feeSpeed),
utxoCount: utxos.length,
});
}, [utxosQuery.data, feeRatesQuery.data, feeSpeed]);
return (
<>
<DialogHeader>
<button
type="button"
onClick={onBack}
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground motion-safe:transition-colors -ml-1"
disabled={isPending}
>
<ChevronLeft className="size-4" />
Back
</button>
<DialogTitle>Confirm donation</DialogTitle>
<DialogDescription>
Review the details before signing the transaction.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<Row label="Campaign" value={campaign.title} />
<Row
label="Amount"
value={
<span>
<span className="font-semibold">{formatSats(amountSats)} sats</span>
{btcPrice && effectiveUsd > 0 && (
<span className="ml-2 text-xs text-muted-foreground">≈ ${effectiveUsd.toLocaleString()}</span>
)}
</span>
}
/>
<Row
label="To wallet"
value={
<span className="font-mono text-xs break-all">{campaign.wallet.value}</span>
}
/>
<Row
label="Network fee"
value={
feeEstimate === null ? (
<Skeleton className="h-4 w-20" />
) : (
<span>
<span className="font-semibold">{formatSats(feeEstimate)} sats</span>
{btcPrice && (
<span className="ml-2 text-xs text-muted-foreground">
≈ ${satsToUSD(feeEstimate, btcPrice)}
</span>
)}
</span>
)
}
/>
{comment.trim() && (
<Row label="Comment" value={<span className="italic">"{comment}"</span>} />
)}
</div>
<Button
size="lg"
className="w-full"
onClick={onSubmit}
disabled={isPending || feeEstimate === null}
>
{isPending ? (
<>
<Loader2 className="size-4 mr-2 animate-spin" />
Sending donation…
</>
) : (
<>
<HandHeart className="size-5 mr-2" />
Send donation
</>
)}
</Button>
</>
);
}
function Row({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="flex items-start justify-between gap-3 text-sm">
<span className="shrink-0 text-muted-foreground">{label}</span>
<span className="text-right min-w-0">{value}</span>
</div>
);
}
// ─────────────────────────────────────────────────────────────────────
// Success step
// ─────────────────────────────────────────────────────────────────────
function SuccessView({
campaign,
result,
btcPrice,
onClose,
}: {
campaign: ParsedCampaign;
result: DonateCampaignResult;
btcPrice: number | undefined;
onClose: () => void;
}) {
return (
<>
<DialogHeader>
<div className="mx-auto rounded-full bg-primary/15 p-3 mb-2">
<Check className="size-8 text-primary" />
</div>
<DialogTitle className="text-center">Thank you!</DialogTitle>
<DialogDescription className="text-center">
Your donation to <span className="font-semibold text-foreground">{campaign.title}</span> is on its way.
</DialogDescription>
</DialogHeader>
<div className="space-y-3 py-2">
<Row
label="Amount"
value={
<span className="font-semibold">
{formatSats(result.totalSats)} sats
{btcPrice && (
<span className="ml-2 text-xs text-muted-foreground">
≈ ${satsToUSD(result.totalSats, btcPrice)}
</span>
)}
</span>
}
/>
<Row
label="Network fee"
value={<span>{formatSats(result.fee)} sats</span>}
/>
<Row
label="Transaction"
value={
<a
href={`https://mempool.space/tx/${result.txid}`}
target="_blank"
rel="noopener noreferrer"
className="font-mono text-xs text-primary hover:underline break-all"
>
{result.txid.slice(0, 16)}…
</a>
}
/>
</div>
<Button size="lg" className="w-full" onClick={onClose}>
Done
</Button>
</>
);
}
// ─────────────────────────────────────────────────────────────────────
// Logged-out chooser
// ─────────────────────────────────────────────────────────────────────
function LoggedOutChooserView({
campaign,
onClose,
}: {
campaign: ParsedCampaign;
onClose: () => void;
}) {
const [authOpen, setAuthOpen] = useState(false);
return (
<>
<DialogHeader>
<DialogTitle>Donate to {campaign.title}</DialogTitle>
<DialogDescription>
Log in to donate from your in-app wallet, or scan the QR on the
campaign page to pay from any external Bitcoin wallet.
</DialogDescription>
</DialogHeader>
<div className="space-y-3 py-2">
<Button
size="lg"
className="w-full"
onClick={() => setAuthOpen(true)}
>
<LogIn className="size-4 mr-2" />
Log in to donate
</Button>
<Button
variant="outline"
size="lg"
className="w-full"
onClick={onClose}
>
Pay from external wallet instead
</Button>
</div>
<AuthDialog isOpen={authOpen} onClose={() => setAuthOpen(false)} />
</>
);
}
// ─────────────────────────────────────────────────────────────────────
// Signer-unsupported fallback
// ─────────────────────────────────────────────────────────────────────
function SignerUnsupportedView({
campaign,
onClose,
}: {
campaign: ParsedCampaign;
onClose: () => void;
}) {
return (
<>
<DialogHeader>
<DialogTitle>Donate to {campaign.title}</DialogTitle>
<DialogDescription>
Scan the QR code with your phone's Bitcoin wallet, or tap "Open in
wallet" to send your donation. You choose the amount in your wallet.
</DialogDescription>
</DialogHeader>
<CampaignWalletDonatePanel wallet={campaign.wallet} />
<Button variant="outline" size="lg" className="w-full" onClick={onClose}>
Close
</Button>
</>
);
}
// ─────────────────────────────────────────────────────────────────────
// Loading skeleton (for callers that need a placeholder button)
// ─────────────────────────────────────────────────────────────────────
export function DonateButtonSkeleton() {
return <Skeleton className="h-11 w-full rounded-md" />;
}
+6 -10
View File
@@ -29,7 +29,6 @@ import {
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import { z } from 'zod';
import { IntroImage } from '@/components/IntroImage';
import { ImageCropDialog } from '@/components/ImageCropDialog';
// Extended form schema that includes custom fields
@@ -203,7 +202,7 @@ export const EditProfileForm: React.FC<EditProfileFormProps> = ({ onValuesChange
// Combine existing metadata with new values
const data: Record<string, unknown> = { ...metadata, ...standardMetadata };
// Strip any legacy avatar shape data from old Ditto-style profiles
// Strip any legacy avatar-shape field carried over from older clients.
delete data.shape;
// Clean up empty values in standard metadata
@@ -248,14 +247,11 @@ export const EditProfileForm: React.FC<EditProfileFormProps> = ({ onValuesChange
return (
<div>
{/* Intro */}
<div className="flex items-center gap-4 px-3 pt-2 pb-4">
<IntroImage src="/profile-intro.png" />
<div className="min-w-0">
<h2 className="text-sm font-semibold">Your Identity</h2>
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
Customize your profile with a name, bio, images, and verification. This is how others will see you on Nostr.
</p>
</div>
<div className="px-3 pt-2 pb-4">
<h2 className="text-sm font-semibold">Your Identity</h2>
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
Customize your profile with a name, bio, images, and verification. This is how others will see you on Nostr.
</p>
</div>
{/* Crop dialog */}
+5
View File
@@ -7,6 +7,7 @@ import { Award, Image, MessageSquareOff } from 'lucide-react';
import type { NostrEvent } from '@nostrify/nostrify';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { ActionContent } from '@/components/ActionContent';
import { Skeleton } from '@/components/ui/skeleton';
import { EmojifiedText } from '@/components/CustomEmoji';
import { EmbeddedCardShell } from '@/components/EmbeddedCardShell';
@@ -88,6 +89,10 @@ export function EmbeddedNaddr({ addr, className, disableHoverCards }: EmbeddedNa
return <EmbeddedProfileBadgesCard event={event} className={className} />;
}
if (event.kind === 36639) {
return <ActionContent event={event} />;
}
return <EmbeddedNaddrCard event={event} className={className} disableHoverCards={disableHoverCards} />;
}
+89 -89
View File
@@ -1,10 +1,9 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Link } from 'react-router-dom';
import { ArrowLeft, BookOpen, Coins, ExternalLink, FileText, Globe, Landmark, Languages, MapPin, MessageCircle, Package, Pause, Play, Repeat2, Share2, User, UserCheck, UserMinus, UserPlus, Users, Zap } from 'lucide-react';
import { ArrowLeft, BookOpen, Coins, ExternalLink, FileText, Globe, Landmark, Languages, MapPin, Megaphone, MessageCircle, Package, Pause, Play, Repeat2, Share2, User, UserCheck, UserMinus, UserPlus, Users } from 'lucide-react';
import { nip19 } from 'nostr-tools';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { Skeleton } from '@/components/ui/skeleton';
import { ExternalFavicon } from '@/components/ExternalFavicon';
import { Skeleton } from '@/components/ui/skeleton';import { ExternalFavicon } from '@/components/ExternalFavicon';
import { ExternalReactionButton } from '@/components/ExternalReactionButton';
import { FollowToggleButton } from '@/components/FollowButton';
import { LinkEmbed } from '@/components/LinkEmbed';
@@ -22,10 +21,11 @@ import { useAuthor } from '@/hooks/useAuthor';
import { useCountryFollows } from '@/hooks/useCountryFollows';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useProfileUrl } from '@/hooks/useProfileUrl';
import { useWeather } from '@/hooks/useWeather';
import { useToast } from '@/hooks/useToast';
import { genUserName } from '@/lib/genUserName';
import { getCountryInfo, getWikipediaTitle } from '@/lib/countries';
import { CountryFlag } from '@/components/CountryFlag';
import { customFlagAsset, hasCustomFlag } from '@/lib/customFlags';
import { useWikipediaSummary } from '@/hooks/useWikipediaSummary';
import { useCountryFacts, type CountryFacts } from '@/hooks/useCountryFacts';
import { useCommonsAudio } from '@/hooks/useCommonsAudio';
@@ -231,30 +231,39 @@ function BlueskyPostHeader({ author, rkey, url }: { author: string; rkey: string
)}
{/* Action buttons */}
<div className="flex items-center gap-5 mt-3 -ml-2">
<div className="flex flex-wrap items-center gap-1 sm:gap-2 mt-3">
<button
type="button"
onClick={handleComment}
className="inline-flex items-center gap-1.5 p-2 rounded-full text-muted-foreground hover:text-sky-500 hover:bg-sky-500/10 transition-colors"
className="inline-flex items-center gap-2 h-9 px-3 rounded-full text-sm font-medium text-muted-foreground hover:text-sky-500 hover:bg-sky-500/10 transition-colors"
title="Comment"
>
<MessageCircle className="size-[18px]" />
{post.replyCount > 0 && <span className="text-sm tabular-nums">{formatCount(post.replyCount)}</span>}
{post.replyCount > 0 ? (
<span className="tabular-nums">{formatCount(post.replyCount)}</span>
) : (
<span className="hidden sm:inline">Comment</span>
)}
</button>
<button
type="button"
onClick={handleRepost}
className="inline-flex items-center gap-1.5 p-2 rounded-full text-muted-foreground hover:text-green-500 hover:bg-green-500/10 transition-colors"
className="inline-flex items-center gap-2 h-9 px-3 rounded-full text-sm font-medium text-muted-foreground hover:text-green-500 hover:bg-green-500/10 transition-colors"
title="Share to feed"
>
<Repeat2 className="size-[18px]" />
{post.repostCount > 0 && <span className="text-sm tabular-nums">{formatCount(post.repostCount)}</span>}
{post.repostCount > 0 ? (
<span className="tabular-nums">{formatCount(post.repostCount)}</span>
) : (
<span className="hidden sm:inline">Repost</span>
)}
</button>
<ExternalReactionButton content={externalContent} iconSize="size-[18px]" count={post.likeCount} />
<ExternalReactionButton content={externalContent} count={post.likeCount} variant="chip" />
<div className="flex-1" />
<button
type="button"
onClick={handleShare}
className="inline-flex items-center p-2 rounded-full text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors"
className="inline-flex items-center justify-center h-9 w-9 rounded-full text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors"
title="Share link"
>
<Share2 className="size-[18px]" />
@@ -627,9 +636,11 @@ function WikipediaExtract({ extract, articleUrl }: { extract: string; articleUrl
* above the Wikipedia extract doesn't draw against a phantom row.
*/
function WeatherVitalsRow({ code, facts }: { code: string; facts: CountryFacts | undefined }) {
const { data: weather, isLoading } = useWeather(code);
const capital = facts?.capital ?? null;
// Weather has been removed; this row now renders only the country vitals
// (population / languages / currency). The legacy name is preserved so
// the mount call sites don't churn — the row still vanishes when there
// are no vitals to show, matching the original behavior.
void code;
const vitals: { key: string; icon: React.ReactNode; label: string; value: string }[] = [];
if (facts) {
if (facts.population !== null) {
@@ -659,40 +670,17 @@ function WeatherVitalsRow({ code, facts }: { code: string; facts: CountryFacts |
}
}
if (isLoading && vitals.length === 0) {
return (
<div className="px-4 py-2 flex items-center gap-3">
<Skeleton className="size-6 rounded-md" />
<Skeleton className="h-4 w-40" />
</div>
);
}
if (vitals.length === 0) return null;
const hasWeatherSide = !!weather || !!capital;
const hasVitalsSide = vitals.length > 0;
if (!hasWeatherSide && !hasVitalsSide) return null;
const capital = facts?.capital ?? null;
const hasCapitalSide = !!capital;
return (
<div className="px-4 py-2 flex flex-wrap items-center justify-between gap-x-4 gap-y-1.5 text-sm">
{/* Left group — weather + capital. */}
{hasWeatherSide && (
{/* Left group — capital. */}
{hasCapitalSide && (
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1 min-w-0">
{weather && (
<>
<span className="flex items-baseline gap-2 text-foreground">
<span className="text-xl leading-none" role="img" aria-label={weather.description}>
{weather.icon}
</span>
<span className="font-bold tabular-nums">{weather.temperature}°</span>
</span>
<span className="text-muted-foreground">{weather.description}</span>
</>
)}
{capital && (
// The country's capital is the stable national place anchor for
// the header. The weather-station city is intentionally omitted
// — it's often a smaller, less-recognised town nearby and
// duplicates a less-meaningful place name on the same line.
<span className="flex items-center gap-1 text-muted-foreground/80 text-xs">
<Landmark className="size-3 shrink-0" />
<span>{capital}</span>
@@ -701,28 +689,21 @@ function WeatherVitalsRow({ code, facts }: { code: string; facts: CountryFacts |
</div>
)}
{/* Right group — vitals (population, language, currency). On narrow
viewports this wraps onto its own line under the weather group
rather than getting crushed beside it. Styled to match the
capital chip on the left (text-xs muted-foreground/80 with a
size-3 icon) so the row reads as a single uniform metadata
strip rather than two competing weights. */}
{hasVitalsSide && (
<ul className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground/80 min-w-0">
{vitals.map((item) => (
<li
key={item.key}
className="flex items-center gap-1 min-w-0"
title={`${item.label}: ${item.value}`}
>
{item.icon}
<span className="truncate max-w-[14ch] sm:max-w-[18ch]">
{item.value}
</span>
</li>
))}
</ul>
)}
{/* Right group — vitals (population, language, currency). */}
<ul className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground/80 min-w-0">
{vitals.map((item) => (
<li
key={item.key}
className="flex items-center gap-1 min-w-0"
title={`${item.label}: ${item.value}`}
>
{item.icon}
<span className="truncate max-w-[14ch] sm:max-w-[18ch]">
{item.value}
</span>
</li>
))}
</ul>
</div>
);
}
@@ -866,7 +847,6 @@ export function CountryContentHeader({ code }: { code: string }) {
// Country facts are only fetched for sovereign countries (alpha-2 codes);
// the hook's internal guard returns `null` for subdivisions like `US-CA`.
const { data: facts } = useCountryFacts(info?.subdivision ? null : code);
const { data: weather } = useWeather(code);
const { user } = useCurrentUser();
const { isFollowingCountry, toggleCountryFollow, isPending } = useCountryFollows();
const { toast } = useToast();
@@ -899,15 +879,23 @@ export function CountryContentHeader({ code }: { code: string }) {
);
}
const heroImage = wiki?.originalImage?.source ?? wiki?.thumbnail?.source ?? null;
const isDay = weather?.isDay ?? true;
// For codes with a bundled flag asset (Tibet's Snow Lion), drive the
// hero banner from that SVG instead of Wikipedia's lead image. The
// Wikipedia article for `Tibet (autonomous region)` typically returns a
// map or administrative photo, which contradicts the editorial choice
// to surface Tibet as a country in its own right.
const heroImage = customFlagAsset(code) ?? wiki?.originalImage?.source ?? wiki?.thumbnail?.source ?? null;
// Always render the daytime sky overlay. Previously we keyed this off the
// live `weather.isDay` flag to flip into a night palette; weather has been
// removed so we default to the warm amber/rose daytime tint.
const isDay = true;
// Sky-tint gradient layered above the hero photo. Warm amber/rose during
// local daytime, deep indigo/violet at night. Same gradient shape, only
// the colour palette flips — preserves the cinematic curve while the mood
// follows the destination.
const skyOverlay = isDay
? 'bg-[linear-gradient(to_bottom,rgba(254,202,87,0.18)_0%,rgba(255,107,107,0.12)_30%,rgba(0,0,0,0.65)_70%,hsl(var(--background))_100%)]'
: 'bg-[linear-gradient(to_bottom,rgba(30,27,75,0.55)_0%,rgba(15,23,42,0.55)_30%,rgba(0,0,0,0.85)_70%,hsl(var(--background))_100%)]';
? 'bg-[linear-gradient(to_bottom,rgba(254,202,87,0.18)_0%,rgba(255,107,107,0.12)_30%,rgba(0,0,0,0.65)_70%,hsl(var(--card))_100%)]'
: 'bg-[linear-gradient(to_bottom,rgba(30,27,75,0.55)_0%,rgba(15,23,42,0.55)_30%,rgba(0,0,0,0.85)_70%,hsl(var(--card))_100%)]';
// Whether to show the coat of arms inside the hero. Subdivisions get a
// thumbnail in the flag slot already (from Wikipedia), so we skip the coat
@@ -920,7 +908,7 @@ export function CountryContentHeader({ code }: { code: string }) {
// hero replaces the page header (it carries its own back arrow + follow
// button overlaid on the photo), so no negative top margin is needed to
// tuck under a sibling header band.
<section className="relative isolate overflow-hidden mb-2">
<section className="relative isolate overflow-hidden">
{/* Hero — Wikipedia photo (or gradient fallback) with day/night sky
overlay that fades into the page background. Aspect ratio scales
from a compact 2:1 on phones to a cinematic 21:9 on tablets+. */}
@@ -992,23 +980,25 @@ export function CountryContentHeader({ code }: { code: string }) {
white text legible against any underlying photo. */}
<div className="absolute bottom-0 left-0 right-0 px-5 pb-4 pt-10 [text-shadow:0_1px_4px_rgba(0,0,0,0.7),0_2px_8px_rgba(0,0,0,0.4)]">
<div className="flex items-end gap-3">
{/* Flag + (optional) coat of arms. Subdivisions show a small
Wikipedia thumbnail in the same slot when available. */}
{/* Flag + (optional) coat of arms. Subdivisions normally
show a small Wikipedia thumbnail in the same slot when
available; entries with a bundled custom flag asset
(Tibet's Snow Lion) bypass that branch so our editorial
flag wins. */}
<div className="flex items-end gap-2 [text-shadow:none] shrink-0">
{info.subdivision && wiki?.thumbnail ? (
{info.subdivision && wiki?.thumbnail && !hasCustomFlag(code) ? (
<img
src={wiki.thumbnail.source}
alt={info.subdivisionName ?? info.subdivision}
className="size-14 sm:size-16 rounded-md object-cover shadow-lg border border-white/20"
/>
) : (
<span
className="text-5xl sm:text-6xl leading-none drop-shadow-[0_2px_8px_rgba(0,0,0,0.6)]"
role="img"
aria-label={`Flag of ${info.name}`}
>
{info.flag}
</span>
<CountryFlag
code={code}
emoji={info.flag}
label={`Flag of ${info.subdivisionName ?? info.name}`}
className="text-5xl sm:text-6xl drop-shadow-[0_2px_8px_rgba(0,0,0,0.6)]"
/>
)}
{showCoatOfArms && (
<img
@@ -1030,7 +1020,7 @@ export function CountryContentHeader({ code }: { code: string }) {
<AnthemButton filename={facts.anthemFilename} title={facts.anthemTitle} />
)}
</div>
{info.subdivision ? (
{info.subdivision && !hasCustomFlag(code) ? (
<p className="text-sm text-white/85 mt-0.5 truncate">
{info.name}{info.subdivisionName ? '' : ` · ${info.subdivision}`}
</p>
@@ -1236,14 +1226,24 @@ function BookPreview({ isbn, link }: { isbn: string; link: string }) {
function CountryPreview({ code, link }: { code: string; link: string }) {
const info = getCountryInfo(code);
// For ISO 3166-2 codes we treat editorially as countries (Tibet today),
// prefer the subdivision's own name and let `CountryFlag` swap in the
// bundled Snow Lion SVG instead of the parent-country emoji.
const displayName = hasCustomFlag(code)
? info?.subdivisionName ?? info?.name ?? code
: info?.name ?? code;
return (
<Link
to={link}
className="flex items-center gap-3 px-4 py-3 border-b border-border hover:bg-secondary/30 transition-colors"
>
<span className="text-2xl leading-none shrink-0" role="img" aria-label={info ? `Flag of ${info.name}` : code}>
{info?.flag ?? '🌍'}
</span>
<CountryFlag
code={code}
emoji={info?.flag ?? '🌍'}
label={`Flag of ${displayName}`}
className="text-2xl shrink-0"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
@@ -1251,7 +1251,7 @@ function CountryPreview({ code, link }: { code: string; link: string }) {
<span>Country</span>
</div>
<p className="text-sm font-medium truncate mt-0.5">
{info?.name ?? code}
{displayName}
</p>
</div>
@@ -1275,7 +1275,7 @@ export function CommunityPreview({ addr }: { addr: { kind: number; pubkey: strin
const communityName = event?.tags.find(([n]) => n === 'name')?.[1]
|| event?.tags.find(([n]) => n === 'd')?.[1]
|| 'Community';
|| 'Organization';
const communityImage = event?.tags.find(([n]) => n === 'image')?.[1];
const communityDescription = event?.tags.find(([n]) => n === 'description')?.[1];
const moderatorCount = event?.tags.filter(([n, , , role]) => n === 'p' && role === 'moderator').length ?? 0;
@@ -1319,7 +1319,7 @@ export function CommunityPreview({ addr }: { addr: { kind: number; pubkey: strin
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Users className="size-3 shrink-0" />
<span>Community</span>
<span>Organization</span>
{moderatorCount > 0 && (
<span className="text-muted-foreground/60">&middot; {moderatorCount} mod{moderatorCount !== 1 ? 's' : ''}</span>
)}
@@ -1433,7 +1433,7 @@ const WELL_KNOWN_KIND_LABELS: Record<number, string> = {
3063: 'Zapstore Asset',
15128: 'Nsite',
35128: 'Nsite',
36639: 'Action',
36639: 'Pledge',
};
export function AddressableEventPreview({ addr }: { addr: { kind: number; pubkey: string; identifier: string } }) {
@@ -1458,7 +1458,7 @@ export function AddressableEventPreview({ addr }: { addr: { kind: number; pubkey
// Fallback icons for well-known kinds not in EXTRA_KINDS
if (addr.kind === 31990 || addr.kind === 32267 || addr.kind === 30063 || addr.kind === 3063) return Package;
if (addr.kind === 15128 || addr.kind === 35128) return Globe;
if (addr.kind === 36639) return Zap;
if (addr.kind === 36639) return Megaphone;
return FileText;
}, [kindDef, addr.kind]);
@@ -7,6 +7,7 @@ import { useMemo } from 'react';
import { cn } from '@/lib/utils';
import { parseExternalUri, headerLabel } from '@/lib/externalContent';
import { getCountryInfo } from '@/lib/countries';
import { CountryFlag } from '@/components/CountryFlag';
import { ExternalFavicon } from '@/components/ExternalFavicon';
import { useBookInfo } from '@/hooks/useBookInfo';
@@ -31,7 +32,14 @@ function ExternalSidebarIcon({ id }: { id: string }) {
if (content.type === 'iso3166') {
const info = getCountryInfo(content.code);
if (info?.flag) {
return <span className="text-lg leading-none shrink-0">{info.flag}</span>;
return (
<CountryFlag
code={content.code}
emoji={info.flag}
label={info.subdivisionName ?? info.name}
className="text-lg shrink-0"
/>
);
}
}
+19 -5
View File
@@ -43,6 +43,13 @@ interface ExternalReactionButtonProps {
count?: number;
/** Extra class names on the trigger button. */
className?: string;
/**
* Visual variant.
* - `pill` (default): compact icon-pill matching the legacy action bar.
* - `chip`: rounded chip with a label fallback when there's no count,
* matching the GoFundMe-style PostActionBar / NoteCard action row.
*/
variant?: 'pill' | 'chip';
}
/**
@@ -51,7 +58,7 @@ interface ExternalReactionButtonProps {
* Includes hover-to-open emoji picker via `QuickReactMenu`, optimistic UI,
* and displays the user's existing reaction & total count.
*/
export function ExternalReactionButton({ content, iconSize = 'size-5', count, className }: ExternalReactionButtonProps) {
export function ExternalReactionButton({ content, iconSize = 'size-5', count, className, variant = 'pill' }: ExternalReactionButtonProps) {
const { user } = useCurrentUser();
const { mutate: publishEvent } = useNostrPublish();
const queryClient = useQueryClient();
@@ -132,7 +139,10 @@ export function ExternalReactionButton({ content, iconSize = 'size-5', count, cl
<PopoverTrigger asChild>
<button
className={cn(
'flex items-center gap-1.5 p-2 rounded-full transition-colors',
'transition-colors',
variant === 'chip'
? 'inline-flex items-center gap-2 h-9 px-3 rounded-full text-sm font-medium'
: 'flex items-center gap-1.5 p-2 rounded-full',
hasReacted
? 'text-pink-500'
: 'text-muted-foreground hover:text-pink-500 hover:bg-pink-500/10',
@@ -155,9 +165,13 @@ export function ExternalReactionButton({ content, iconSize = 'size-5', count, cl
) : (
<Heart className={iconSize} />
)}
{(count ?? reactionCount) > 0 && (
<span className="text-sm tabular-nums">{formatNumber(count ?? reactionCount)}</span>
)}
{(count ?? reactionCount) > 0 ? (
<span className={cn('tabular-nums', variant === 'chip' ? '' : 'text-sm')}>
{formatNumber(count ?? reactionCount)}
</span>
) : variant === 'chip' ? (
<span className="hidden sm:inline">React</span>
) : null}
</button>
</PopoverTrigger>
<PopoverContent
+155 -319
View File
@@ -1,5 +1,4 @@
import { useState, useEffect, useMemo, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { useState, useEffect, useMemo } from 'react';
import { useInView } from 'react-intersection-observer';
import { usePageRefresh } from '@/hooks/usePageRefresh';
import { ComposeBox } from '@/components/ComposeBox';
@@ -7,30 +6,25 @@ import { LandingHero } from '@/components/LandingHero';
import { NoteCard } from '@/components/NoteCard';
import { PullToRefresh } from '@/components/PullToRefresh';
import { FeedEmptyState } from '@/components/FeedEmptyState';
import { FeedModeSwitcher } from '@/components/FeedModeSwitcher';
import { Skeleton } from '@/components/ui/skeleton';
import { Globe2, Loader2 } from 'lucide-react';
import LoginDialog from '@/components/auth/LoginDialog';
import { useOnboarding } from '@/hooks/useOnboarding';
import { Button } from '@/components/ui/button';
import { Loader2 } from 'lucide-react';
import AuthDialog from '@/components/auth/AuthDialog';
import { useFeed } from '@/hooks/useFeed';
import { useFollowingFeed } from '@/hooks/useFollowingFeed';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useFeedTab } from '@/hooks/useFeedTab';
import { useMuteList } from '@/hooks/useMuteList';
import { useTabFeed } from '@/hooks/useProfileFeed';
import { useSavedFeeds } from '@/hooks/useSavedFeeds';
import { useResolveTabFilter } from '@/hooks/useResolveTabFilter';
import { useWorldFeed } from '@/hooks/useWorldFeed';
import { useMixedFeed, type FeedMode } from '@/hooks/useMixedFeed';
import { shouldHideFeedEvent } from '@/lib/feedUtils';
import { isEventMuted } from '@/lib/muteHelpers';
import { SubHeaderBar } from '@/components/SubHeaderBar';
import { TabButton } from '@/components/TabButton';
import { Button } from '@/components/ui/button';
import { useNavHidden } from '@/contexts/LayoutContext';
import { cn } from '@/lib/utils';
import type { FeedItem } from '@/lib/feedUtils';
import type { SavedFeed } from '@/contexts/AppContext';
type CoreFeedTab = 'follows' | 'network' | 'global' | 'communities' | 'world';
type CoreFeedTab = 'follows' | 'network' | 'global' | 'communities' | 'world' | 'agora';
type FeedTab = CoreFeedTab | string; // string = saved feed id
interface FeedProps {
@@ -44,73 +38,42 @@ interface FeedProps {
hideCompose?: boolean;
/** Message shown when the feed is empty. */
emptyMessage?: string;
/** Unique identifier for this feed page, used to persist the active tab in sessionStorage. Defaults to 'home'. */
/** Unique identifier for this feed page, used to persist the active tab/mode in localStorage. Defaults to 'home'. */
feedId?: string;
}
const FEED_MODES: readonly FeedMode[] = ['agora', 'all-nostr', 'following'] as const;
function isFeedMode(value: string): value is FeedMode {
return (FEED_MODES as readonly string[]).includes(value);
}
export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, feedId = 'home' }: FeedProps = {}) {
const { user } = useCurrentUser();
const navigate = useNavigate();
const { muteItems } = useMuteList();
const { savedFeeds } = useSavedFeeds();
const navHidden = useNavHidden();
// Tab settings from localStorage
const showGlobalFeed = (() => {
const stored = localStorage.getItem('ditto:showGlobalFeed');
return stored !== null ? stored === 'true' : false;
})();
const showWorldFeed = (() => {
const stored = localStorage.getItem('agora:showWorldFeed');
return stored !== null ? stored === 'true' : true;
})();
const showCommunityFeed = (() => {
const stored = localStorage.getItem('ditto:showCommunityFeed');
return stored !== null ? stored === 'true' : false;
})();
const communityLabel = (() => {
try {
const stored = localStorage.getItem('ditto:community');
if (stored) {
const community = JSON.parse(stored);
return community.label || 'Community';
}
} catch {
// Fall through
}
return 'Community';
})();
const [rawActiveTab, handleSetActiveTab] = useFeedTab<FeedTab>(feedId);
const [loginDialogOpen, setLoginDialogOpen] = useState(false);
const { startSignup } = useOnboarding();
const [authDialogOpen, setAuthDialogOpen] = useState(false);
const isHomeAgoraFeed = !kinds && !tagFilters;
// Kind-specific pages only support Follows + Global. Clamp any other
// persisted tab (e.g. 'world', 'communities') back to the appropriate default.
// Logged-out users on the home feed land on 'world' to see global content.
// For the home /feed page we use a three-mode picker instead of the
// Follows/Global tab pair. Mode persists via the same useFeedTab storage,
// keyed under the same feedId.
const homeFeedMode: FeedMode = (() => {
if (!isHomeAgoraFeed) return 'agora';
if (isFeedMode(rawActiveTab)) return rawActiveTab;
// Legacy values get coerced to the Agora default.
return 'agora';
})();
// Specialized feed pages keep the original Follows + Global tabs.
const activeTab: FeedTab = (() => {
if (!kinds) {
// Migrate legacy 'ditto' tab to 'world'
if (rawActiveTab === 'ditto') return 'world';
// Legacy hashtag:/geotag: tabs are now part of the combined Following
// feed; surface them there instead of rendering a missing sub-feed.
if (rawActiveTab.startsWith('hashtag:') || rawActiveTab.startsWith('geotag:')) return 'follows';
return rawActiveTab;
}
if (isHomeAgoraFeed) return homeFeedMode;
if (rawActiveTab === 'global') return 'global';
if (rawActiveTab === 'follows' && user) return 'follows';
return user ? 'follows' : 'global';
})();
// Is the active tab a saved feed?
const activeSavedFeed = useMemo(
() => savedFeeds.find((f) => f.id === activeTab) ?? null,
[savedFeeds, activeTab],
);
// Migrate legacy hashtag:/geotag: tabs (which used to render their own
// sub-feeds) back to the home Following feed. Followed hashtags/geotags
// now contribute to the combined Following feed instead of getting
@@ -121,60 +84,51 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
}
}, [rawActiveTab, handleSetActiveTab]);
const handleModeChange = (mode: FeedMode) => {
handleSetActiveTab(mode);
};
// Kind-specific pages (e.g. Development, WebXDC) only show Follows + Global tabs.
// Extra tabs (World, Community, saved feeds) are only for the home feed.
const isKindSpecificPage = !!kinds;
// When logged out (and not on a kind-specific page), show the World feed.
const useWorldForLoggedOut = !user && !kinds;
// -------------------------------------------------------------------------
// Home feed (mixed-mode): drives off useMixedFeed.
// -------------------------------------------------------------------------
const mixedFeed = useMixedFeed(homeFeedMode, isHomeAgoraFeed);
// When the World tab is active (logged in), show the world feed.
// Disabled on kind-specific pages — the World tab is not shown there.
const useWorldTab = activeTab === 'world' && !kinds;
// Is the world feed active?
const isWorldActive = useWorldForLoggedOut || !!useWorldTab;
// Standard feed query (used when logged in, or on kind-specific pages, or core tabs)
const isHomeFollowingActive = activeTab === 'follows' && !isKindSpecificPage && !tagFilters;
const isCoreFeedTab = activeTab === 'follows' || activeTab === 'network' || activeTab === 'global' || activeTab === 'communities' || activeTab === 'world';
// -------------------------------------------------------------------------
// Specialized feed pages: original Follows/Global behavior.
// -------------------------------------------------------------------------
const isHomeFollowingActive = activeTab === 'follows' && !isKindSpecificPage && !tagFilters && !isHomeAgoraFeed;
const isCoreFeedTab = activeTab === 'follows' || activeTab === 'network' || activeTab === 'global' || activeTab === 'communities' || activeTab === 'world' || activeTab === 'agora';
type UseFeedTab = 'follows' | 'network' | 'global' | 'communities';
const feedTabForQuery: UseFeedTab =
activeTab === 'follows'
? (isHomeFollowingActive ? 'network' : 'network')
? 'network'
: activeTab === 'network' || activeTab === 'global' || activeTab === 'communities'
? (activeTab as UseFeedTab)
: 'global';
const standardFeedOptions = (kinds || tagFilters)
? { kinds, tagFilters, enabled: !isHomeFollowingActive }
: { enabled: !isHomeFollowingActive };
? { kinds, tagFilters, enabled: !isHomeFollowingActive && !isHomeAgoraFeed }
: { enabled: !isHomeFollowingActive && !isHomeAgoraFeed };
const feedQuery = useFeed(
isCoreFeedTab && !isWorldActive ? feedTabForQuery : 'global',
isCoreFeedTab && !isHomeAgoraFeed ? feedTabForQuery : 'global',
standardFeedOptions,
);
const followingFeed = useFollowingFeed(isHomeFollowingActive);
// World feed: all country-tagged events with diversity cap + live streaming.
const worldFeed = useWorldFeed(isWorldActive);
const { flushStreamBuffer } = worldFeed;
// For non-world tabs, use the standard feed query
const queryKey = useMemo(
() => isWorldActive
? ['world-feed']
: isHomeFollowingActive
? [['feed', 'network'], ['community-activity-feed'], ['following-country-feed']]
: ['feed', activeTab],
[isWorldActive, isHomeFollowingActive, activeTab],
() => isHomeAgoraFeed
? ['mixed-feed', homeFeedMode]
: isHomeFollowingActive
? [['feed', 'network'], ['community-activity-feed'], ['following-country-feed']]
: ['feed', activeTab],
[isHomeAgoraFeed, homeFeedMode, isHomeFollowingActive, activeTab],
);
const handleRefresh = usePageRefresh(queryKey);
const handleWorldRefresh = useCallback(async () => {
flushStreamBuffer();
await handleRefresh();
window.scrollTo({ top: 0, behavior: 'smooth' });
}, [flushStreamBuffer, handleRefresh]);
const {
data: rawData,
@@ -186,16 +140,16 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
} = isHomeFollowingActive ? followingFeed : feedQuery;
// Unify pagination interface
const fetchNextPage = isWorldActive ? worldFeed.fetchNextPage : fetchNextPageStandard;
const hasNextPage = isWorldActive ? worldFeed.hasNextPage : hasNextPageStandard;
const isFetchingNextPage = isWorldActive ? worldFeed.isFetchingNextPage : isFetchingNextPageStandard;
const fetchNextPage = isHomeAgoraFeed ? mixedFeed.fetchNextPage : fetchNextPageStandard;
const hasNextPage = isHomeAgoraFeed ? mixedFeed.hasNextPage : hasNextPageStandard;
const isFetchingNextPage = isHomeAgoraFeed ? mixedFeed.isFetchingNextPage : isFetchingNextPageStandard;
// Auto-fetch page 2 as soon as page 1 arrives for smoother scrolling
useEffect(() => {
if (!isHomeFollowingActive && !isWorldActive && hasNextPage && !isFetchingNextPage && rawData?.pages?.length === 1) {
if (!isHomeFollowingActive && !isHomeAgoraFeed && hasNextPage && !isFetchingNextPage && rawData?.pages?.length === 1) {
fetchNextPage();
}
}, [isHomeFollowingActive, isWorldActive, hasNextPage, isFetchingNextPage, rawData?.pages?.length, fetchNextPage]);
}, [isHomeFollowingActive, isHomeAgoraFeed, hasNextPage, isFetchingNextPage, rawData?.pages?.length, fetchNextPage]);
// Intersection observer for infinite scroll
const { ref: scrollRef, inView } = useInView({
@@ -211,9 +165,8 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
// Flatten, deduplicate, and filter muted content.
const feedItems = useMemo(() => {
if (isWorldActive) {
// World feed: events are already filtered/deduped by useWorldFeed
return worldFeed.events.map((event): FeedItem => ({ event, sortTimestamp: event.created_at }));
if (isHomeAgoraFeed) {
return mixedFeed.items;
}
if (!rawData?.pages) return [];
@@ -229,82 +182,67 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
if (muteItems.length > 0 && isEventMuted(item.event, muteItems)) return false;
return true;
});
}, [isWorldActive, worldFeed.events, rawData?.pages, muteItems]);
}, [isHomeAgoraFeed, mixedFeed.items, rawData?.pages, muteItems]);
// Show skeletons while loading.
const showSkeleton = isWorldActive
? worldFeed.isLoading
: (isPending || (isLoading && !rawData));
const showSkeleton = isHomeAgoraFeed
? mixedFeed.isLoading && feedItems.length === 0
: (isPending || (isLoading && !rawData));
const showSavedFeedTabs = user && !isKindSpecificPage && !tagFilters;
// Per-mode empty-state copy for the home feed.
const homeEmptyMessage = (() => {
if (homeFeedMode === 'agora') {
return "Quiet moment on Agora. New campaigns, pledges, donations, and posts will appear here as they happen.";
}
if (homeFeedMode === 'following') {
return user
? "Your follow feed is empty. Follow some people to see what they're up to, or switch to Agora or All Nostr."
: "Log in to see posts from people you follow.";
}
return 'Nothing to show. Check your relay connections or try again in a moment.';
})();
return (
<main className="flex-1 min-w-0 min-h-dvh">
{header}
<main className="flex-1 min-w-0 min-h-dvh bg-background">
<div>
{header}
{/* CTA (logged out, main feed only) */}
{!user && !kinds && (
<LandingHero
onLoginClick={() => setLoginDialogOpen(true)}
onSignupClick={startSignup}
/>
)}
{/* CTA (logged out, main feed only) */}
{!user && !kinds && (
<LandingHero onJoinClick={() => setAuthDialogOpen(true)} />
)}
{!hideCompose && <ComposeBox compact hideBorder />}
{/* Tabs (logged in) */}
{user && (
<SubHeaderBar>
<TabButton label={isKindSpecificPage || tagFilters ? 'Follows' : 'Following'} active={activeTab === 'follows'} onClick={() => handleSetActiveTab('follows')} />
{!isKindSpecificPage && !tagFilters && (
<TabButton label="Network" active={activeTab === 'network'} onClick={() => handleSetActiveTab('network')} />
)}
{!isKindSpecificPage && showWorldFeed && (
<TabButton label="World" active={activeTab === 'world'} onClick={() => handleSetActiveTab('world')} />
)}
{!isKindSpecificPage && showCommunityFeed && (
<TabButton label={communityLabel} active={activeTab === 'communities'} onClick={() => handleSetActiveTab('communities')} />
)}
{(isKindSpecificPage || showGlobalFeed) && (
<TabButton label="Global" active={activeTab === 'global'} onClick={() => handleSetActiveTab('global')} />
)}
{showSavedFeedTabs && savedFeeds.map((feed) => (
<TabButton
key={feed.id}
label={feed.label}
active={activeTab === feed.id}
onClick={() => handleSetActiveTab(feed.id)}
{/* Home-feed mode switcher: top-left, anchors the page visually */}
{isHomeAgoraFeed && (
<div className="px-4 pt-5 pb-3 sm:pt-6">
<FeedModeSwitcher
value={homeFeedMode}
onChange={handleModeChange}
followingAvailable={!!user}
onLoginRequested={() => setAuthDialogOpen(true)}
/>
))}
</SubHeaderBar>
)}
</div>
)}
{/* Feed content — saved feed tab gets its own stream */}
{activeSavedFeed ? (
<SavedFeedContent feed={activeSavedFeed} />
) : (
<PullToRefresh onRefresh={isWorldActive ? handleWorldRefresh : handleRefresh}>
{/* "X new posts" pill for World tab */}
{isWorldActive && worldFeed.newPostCount > 0 && (
<div
className={cn(
'sticky new-posts-pill z-10 flex justify-center pointer-events-none',
'max-sidebar:transition-opacity max-sidebar:duration-300 max-sidebar:ease-in-out',
navHidden && 'max-sidebar:opacity-0 max-sidebar:pointer-events-none',
)}
style={{ marginBottom: '-3rem' }}
>
<button
onClick={() => {
worldFeed.flushStreamBuffer();
window.scrollTo({ top: 0, behavior: 'smooth' });
}}
className="pointer-events-auto px-4 py-1.5 rounded-full bg-primary text-primary-foreground text-sm font-medium shadow-lg hover:bg-primary/90 transition-colors animate-in fade-in slide-in-from-top-2 duration-300"
>
{worldFeed.newPostCount} new post{worldFeed.newPostCount !== 1 ? 's' : ''}
</button>
</div>
)}
{!hideCompose && (
<ComposeBox
compact
hideBorder={isHomeAgoraFeed}
defaultExpanded
placeholder="What's happening?"
/>
)}
{/* Tabs are only kept for specialized feed pages. The home feed uses
the FeedModeSwitcher above. */}
{user && (isKindSpecificPage || tagFilters) && (
<SubHeaderBar>
<TabButton label={isKindSpecificPage || tagFilters ? 'Follows' : 'Following'} active={activeTab === 'follows'} onClick={() => handleSetActiveTab('follows')} />
<TabButton label="Global" active={activeTab === 'global'} onClick={() => handleSetActiveTab('global')} />
</SubHeaderBar>
)}
<PullToRefresh onRefresh={handleRefresh}>
{showSkeleton ? (
<div className="divide-y divide-border">
{Array.from({ length: 5 }).map((_, i) => (
@@ -318,7 +256,6 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
key={item.repostedBy ? `repost-${item.repostedBy}-${item.event.id}` : item.event.id}
event={item.event}
repostedBy={item.repostedBy}
highlight={isWorldActive && worldFeed.flushedIds.has(item.event.id)}
/>
))}
{hasNextPage && (
@@ -331,157 +268,78 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
</div>
)}
</div>
) : isHomeFollowingActive && !emptyMessage ? (
<FollowingEmptyState onExploreWorld={() => navigate('/world')} />
) : isHomeAgoraFeed ? (
<HomeFeedEmptyState
mode={homeFeedMode}
message={homeEmptyMessage}
onSwitchToAgora={homeFeedMode !== 'agora' ? () => handleModeChange('agora') : undefined}
onLoginClick={!user && homeFeedMode === 'following' ? () => setAuthDialogOpen(true) : undefined}
/>
) : (
<FeedEmptyState
message={
emptyMessage ?? (
activeTab === 'follows' || activeTab === 'network'
activeTab === 'follows'
? 'Your feed is empty. Follow some people to see their posts here.'
: activeTab === 'world'
? 'No world posts yet. Check back soon for global activity.'
: 'No posts found. Check your relay connections or come back soon.'
: 'No posts found. Check your relay connections or come back soon.'
)
}
showDiscover={!emptyMessage && (activeTab === 'follows' || activeTab === 'network')}
showDiscover={!emptyMessage && activeTab === 'follows'}
onSwitchToGlobal={
(activeTab === 'follows' || activeTab === 'network') && showGlobalFeed
activeTab === 'follows'
? () => handleSetActiveTab('global')
: undefined
}
/>
)}
</PullToRefresh>
)}
{/* Login/Signup dialogs (only needed on main feed) */}
{!kinds && (
<LoginDialog
isOpen={loginDialogOpen}
onClose={() => setLoginDialogOpen(false)}
onLogin={() => setLoginDialogOpen(false)}
onSignupClick={startSignup}
/>
)}
{/* Auth dialog (only needed on main feed) */}
{!kinds && (
<AuthDialog
isOpen={authDialogOpen}
onClose={() => setAuthDialogOpen(false)}
/>
)}
</div>
</main>
);
}
/** Renders a saved search feed using useTabFeed (TanStack Query cached, infinite scroll). */
function SavedFeedContent({ feed }: { feed: SavedFeed }) {
const { ref: scrollRef, inView } = useInView({ threshold: 0, rootMargin: '400px' });
const { user } = useCurrentUser();
const { muteItems } = useMuteList();
// Resolve variable placeholders ($follows etc.) the same way profile tabs do
const { filter: resolvedFilter, isLoading: isResolving } = useResolveTabFilter(
feed.filter,
feed.vars ?? [],
user?.pubkey ?? '',
);
// Augment the resolved filter with protocol:nostr (NIP-50 Ditto extension)
// to match the behavior of the core feeds and ensure latest native Nostr
// posts are returned.
const augmentedFilter = useMemo(() => {
if (!resolvedFilter) return null;
const existing = resolvedFilter.search ?? '';
const search = existing.includes('protocol:nostr')
? existing
: existing
? `${existing} protocol:nostr`
: 'protocol:nostr';
return { ...resolvedFilter, search };
}, [resolvedFilter]);
const {
data: rawData,
isLoading: isFeedLoading,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useTabFeed(augmentedFilter, `saved-${feed.id}`, !isResolving);
const isLoading = isResolving || isFeedLoading;
// Prefix key -- usePageRefresh does prefix matching, so this invalidates
// the full ['tab-feed', tabKey, kindsKey, authorsKey, searchKey] used by useTabFeed.
const queryKey = useMemo(
() => ['tab-feed', `saved-${feed.id}`],
[feed.id],
);
const handleRefresh = usePageRefresh(queryKey);
// Infinite scroll: fetch next page when sentinel is in view
useEffect(() => {
if (inView && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]);
// Flatten pages, deduplicate, and filter muted content
const feedItems = useMemo(() => {
if (!rawData?.pages) return [];
const seen = new Set<string>();
return rawData.pages
.flatMap((page) => page.items)
.filter((item) => {
const key = item.repostedBy ? `repost-${item.repostedBy}-${item.event.id}` : item.event.id;
if (!key || seen.has(key)) return false;
seen.add(key);
if (shouldHideFeedEvent(item.event)) return false;
if (muteItems.length > 0 && isEventMuted(item.event, muteItems)) return false;
return true;
});
}, [rawData?.pages, muteItems]);
if (isLoading && feedItems.length === 0) {
return (
<div className="divide-y divide-border">
{Array.from({ length: 5 }).map((_, i) => (
<NoteCardSkeleton key={i} />
))}
</div>
);
}
if (feedItems.length === 0) {
return (
<PullToRefresh onRefresh={handleRefresh}>
<FeedEmptyState message={`No posts found for "${feed.label}". Try adjusting your relay connections or check back later.`} />
</PullToRefresh>
);
}
interface HomeFeedEmptyStateProps {
mode: FeedMode;
message: string;
onSwitchToAgora?: () => void;
onLoginClick?: () => void;
}
function HomeFeedEmptyState({ mode, message, onSwitchToAgora, onLoginClick }: HomeFeedEmptyStateProps) {
return (
<PullToRefresh onRefresh={handleRefresh}>
<div>
{feedItems.map((item) => (
<NoteCard
key={item.repostedBy ? `repost-${item.repostedBy}-${item.event.id}` : item.event.id}
event={item.event}
repostedBy={item.repostedBy}
/>
))}
{hasNextPage && (
<div ref={scrollRef} className="py-4">
{isFetchingNextPage && (
<div className="flex justify-center">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
)}
</div>
<div className="py-20 px-8 flex flex-col items-center text-center">
<p className="text-muted-foreground max-w-sm leading-relaxed">{message}</p>
<div className="flex flex-col gap-2 mt-6 w-full max-w-xs">
{onLoginClick && (
<Button className="rounded-full" onClick={onLoginClick}>
Log in
</Button>
)}
{onSwitchToAgora && (
<Button
variant={mode === 'following' ? 'default' : 'ghost'}
className="rounded-full"
onClick={onSwitchToAgora}
>
Browse the Agora feed
</Button>
)}
{!hasNextPage && <div ref={scrollRef} className="py-2" />}
</div>
</PullToRefresh>
</div>
);
}
function NoteCardSkeleton() {
function NoteCardSkeleton({ className }: { className?: string }) {
return (
<div className="px-4 py-3 border-b border-border">
<div className={cn('px-4 py-3 border-b border-border', className)}>
<div className="flex items-center gap-3">
<Skeleton className="size-11 rounded-full shrink-0" />
<div className="min-w-0 space-y-1.5">
@@ -502,25 +360,3 @@ function NoteCardSkeleton() {
</div>
);
}
function FollowingEmptyState({ onExploreWorld }: { onExploreWorld: () => void }) {
return (
<div className="py-20 px-8 flex flex-col items-center gap-6 text-center">
<div className="p-4 rounded-full bg-primary/10">
<Globe2 className="size-8 text-primary" />
</div>
<div className="space-y-2 max-w-xs">
<h2 className="text-xl font-bold">No countries yet</h2>
<p className="text-muted-foreground text-sm">
Explore the World page and follow countries to build your Following feed.
</p>
</div>
<div className="flex flex-col gap-2 w-full max-w-xs">
<Button className="rounded-full" onClick={onExploreWorld}>
<Globe2 className="size-4 mr-2" />
Explore World
</Button>
</div>
</div>
);
}
+39
View File
@@ -0,0 +1,39 @@
import { forwardRef } from 'react';
import { cn } from '@/lib/utils';
interface FeedCardProps extends React.HTMLAttributes<HTMLDivElement> {
/** Extra class names merged after the defaults. */
className?: string;
/** Children — typically a list of NoteCards, member rows, notification rows, etc. */
children?: React.ReactNode;
}
/**
* Soft rounded card surface used to wrap vertical feed lists (NoteCard
* feeds, author lists, notification rows, etc.) so they sit inside a
* GoFundMe-style canvas instead of running edge-to-edge like a Twitter
* timeline.
*
* Rows inside are expected to supply their own per-row separator
* (NoteCard self-applies `border-b border-border`). For pure skeleton
* lists where rows don't self-border, pass `divide` on the className.
*
* `overflow-hidden` ensures the last row's bottom border tucks under
* the card's rounded corner instead of poking out.
*/
export const FeedCard = forwardRef<HTMLDivElement, FeedCardProps>(
function FeedCard({ className, children, ...rest }, ref) {
return (
<div
ref={ref}
className={cn(
'mx-4 sm:mx-6 rounded-2xl bg-card border border-border/60 shadow-sm overflow-hidden',
className,
)}
{...rest}
>
{children}
</div>
);
},
);
+125
View File
@@ -0,0 +1,125 @@
import { Check, ChevronDown, Globe, Sparkles, Users } from 'lucide-react';
import type { ComponentType } from 'react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import type { FeedMode } from '@/hooks/useMixedFeed';
import { cn } from '@/lib/utils';
interface FeedModeOption {
mode: FeedMode;
label: string;
icon: ComponentType<{ className?: string }>;
}
const OPTIONS: FeedModeOption[] = [
{ mode: 'agora', label: 'Agora', icon: Sparkles },
{ mode: 'all-nostr', label: 'All Nostr', icon: Globe },
{ mode: 'following', label: 'Following', icon: Users },
];
interface FeedModeSwitcherProps {
value: FeedMode;
onChange: (mode: FeedMode) => void;
/** When false, Following mode is disabled (requires login). */
followingAvailable: boolean;
/** Click handler for the disabled Following item (typically opens the auth dialog). */
onLoginRequested?: () => void;
className?: string;
}
/**
* The primary feed-mode picker rendered at the top-left of the home feed page.
*
* Visually anchored as the page heading — the active mode label is the largest
* text on the page. Clicking opens a compact dropdown menu offering the three
* modes; the active one is marked with a check.
*
* Logged-out users see "Following" greyed out; clicking it invokes
* {@link FeedModeSwitcherProps.onLoginRequested} to surface the auth dialog.
*/
export function FeedModeSwitcher({
value,
onChange,
followingAvailable,
onLoginRequested,
className,
}: FeedModeSwitcherProps) {
const active = OPTIONS.find((opt) => opt.mode === value) ?? OPTIONS[0];
return (
<DropdownMenu>
<DropdownMenuTrigger
className={cn(
'group inline-flex items-center gap-2 rounded-lg -ml-1 px-1 py-1 outline-none',
'text-foreground hover:text-foreground motion-safe:transition-colors',
'focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background',
className,
)}
aria-label={`Feed mode: ${active.label}. Click to change.`}
>
<span className="text-2xl sm:text-3xl font-bold tracking-tight leading-none">
{active.label}
</span>
<ChevronDown
className="size-5 text-muted-foreground motion-safe:transition-transform group-data-[state=open]:rotate-180"
aria-hidden
/>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" sideOffset={8} className="w-56 p-1.5">
{OPTIONS.map((opt) => {
const Icon = opt.icon;
const isActive = opt.mode === value;
const isFollowing = opt.mode === 'following';
const disabled = isFollowing && !followingAvailable;
const handleSelect = (event: Event) => {
if (disabled) {
event.preventDefault();
onLoginRequested?.();
return;
}
onChange(opt.mode);
};
const itemContent = (
<DropdownMenuItem
key={opt.mode}
onSelect={handleSelect}
className={cn(
'flex items-center gap-3 rounded-md px-3 py-2 cursor-pointer',
disabled && 'opacity-60 data-[disabled]:opacity-60',
)}
data-disabled={disabled || undefined}
>
<Icon className="size-4 shrink-0 text-muted-foreground" aria-hidden />
<span className="flex-1 text-sm font-medium">{opt.label}</span>
{isActive && <Check className="size-4 shrink-0 text-primary" aria-hidden />}
</DropdownMenuItem>
);
if (disabled) {
return (
<Tooltip key={opt.mode}>
<TooltipTrigger asChild>{itemContent}</TooltipTrigger>
<TooltipContent side="right">
Log in to see posts from people you follow
</TooltipContent>
</Tooltip>
);
}
return itemContent;
})}
</DropdownMenuContent>
</DropdownMenu>
);
}
+50 -19
View File
@@ -1,6 +1,7 @@
import { lazy, Suspense, useState } from 'react';
import { Plus, Construction } from 'lucide-react';
import { Construction } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { LogoIcon } from '@/components/icons/LogoIcon';
import { Button } from '@/components/ui/button';
import {
Dialog,
@@ -41,26 +42,43 @@ export function FloatingComposeButton({ kind = 1, href, onFabClick, icon, menu }
return null;
}
const renderedIcon = icon ?? <Plus strokeWidth={4} size={16} />;
const hasCustomIcon = icon !== undefined;
const renderedIcon = icon;
const logoButtonClassName = "relative size-20 text-primary transition-transform hover:scale-105 active:scale-95 disabled:opacity-40 disabled:pointer-events-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-sm";
const logoButtonStyle = { filter: 'drop-shadow(0 3px 10px hsl(var(--primary) / 0.28))' };
// ── Menu mode — anchor a Popover to the FAB itself ────────────────────────
if (menu && menu.length > 0) {
return (
<Popover open={menuOpen} onOpenChange={setMenuOpen}>
<PopoverTrigger asChild>
<button
type="button"
aria-label="Add"
aria-expanded={menuOpen}
aria-haspopup="menu"
className="relative size-16 transition-transform hover:scale-105 active:scale-95 disabled:opacity-40 disabled:pointer-events-none"
style={{ filter: 'drop-shadow(0 2px 8px hsl(var(--primary) / 0.25))' }}
>
<div className="absolute inset-0 bg-primary rounded-full" />
<span className="absolute inset-0 flex items-center justify-center text-primary-foreground">
{renderedIcon}
</span>
</button>
{hasCustomIcon ? (
<button
type="button"
aria-label="Add"
aria-expanded={menuOpen}
aria-haspopup="menu"
className="relative size-16 transition-transform hover:scale-105 active:scale-95 disabled:opacity-40 disabled:pointer-events-none"
style={{ filter: 'drop-shadow(0 2px 8px hsl(var(--primary) / 0.25))' }}
>
<div className="absolute inset-0 bg-primary rounded-full" />
<span className="absolute inset-0 flex items-center justify-center text-primary-foreground">
{renderedIcon}
</span>
</button>
) : (
<button
type="button"
aria-label="Add"
aria-expanded={menuOpen}
aria-haspopup="menu"
className={logoButtonClassName}
style={logoButtonStyle}
>
<span className="absolute inset-[-8%] rounded-full bg-primary/15 blur-xl" aria-hidden />
<LogoIcon className="relative size-full" />
</button>
)}
</PopoverTrigger>
<PopoverContent
side="top"
@@ -108,10 +126,23 @@ export function FloatingComposeButton({ kind = 1, href, onFabClick, icon, menu }
return (
<>
<FabButton
onClick={handleClick}
icon={renderedIcon}
/>
{hasCustomIcon ? (
<FabButton
onClick={handleClick}
icon={renderedIcon}
/>
) : (
<button
type="button"
onClick={handleClick}
aria-label="Add"
className={logoButtonClassName}
style={logoButtonStyle}
>
<span className="absolute inset-[-8%] rounded-full bg-primary/15 blur-xl" aria-hidden />
<LogoIcon className="relative size-full" />
</button>
)}
{/* Kind 1: Compose modal (lazy-loaded) */}
{kind === 1 && composeOpen && (
+3 -27
View File
@@ -15,12 +15,9 @@ import { useAuthor } from '@/hooks/useAuthor';
import { useAuthors } from '@/hooks/useAuthors';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useFollowList, useFollowActions } from '@/hooks/useFollowActions';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useStreamPosts } from '@/hooks/useStreamPosts';
import { useMuteList } from '@/hooks/useMuteList';
import { isEventMuted } from '@/lib/muteHelpers';
import { useNostr } from '@nostrify/react';
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
import { genUserName } from '@/lib/genUserName';
import { parsePackEvent } from '@/lib/packUtils';
import { VerifiedNip05Text } from '@/components/Nip05Badge';
@@ -139,10 +136,9 @@ export function PackMembersTab({
*/
export function FollowPackDetailContent({ event }: { event: NostrEvent }) {
const { toast } = useToast();
const { nostr } = useNostr();
const { user } = useCurrentUser();
const { data: followList } = useFollowList();
const { mutateAsync: publishEvent } = useNostrPublish();
const { followMany } = useFollowActions();
const author = useAuthor(event.pubkey);
const metadata = author.data?.metadata;
@@ -175,27 +171,7 @@ export function FollowPackDetailContent({ event }: { event: NostrEvent }) {
setIsFollowingAll(true);
try {
// 1. Fetch freshest kind 3 from relays (not cache)
const prev = await fetchFreshEvent(nostr, { kinds: [3], authors: [user.pubkey] });
// 2. Separate p-tags from non-p-tags to preserve relay hints, petnames, etc.
const existingPTags = prev?.tags.filter(([n]) => n === 'p') ?? [];
const nonPTags = prev?.tags.filter(([n]) => n !== 'p') ?? [];
const existingPubkeys = new Set(existingPTags.map(([, pk]) => pk));
// 3. Merge: add new pubkeys that aren't already followed
const newPTags = pubkeys
.filter((pk) => !existingPubkeys.has(pk))
.map((pk) => ['p', pk]);
const added = newPTags.length;
// 4. Publish with prev for published_at preservation
await publishEvent({
kind: 3,
content: prev?.content ?? '',
tags: [...nonPTags, ...existingPTags, ...newPTags],
prev: prev ?? undefined,
});
const added = await followMany(pubkeys);
toast({
title: 'Following all!',
@@ -213,7 +189,7 @@ export function FollowPackDetailContent({ event }: { event: NostrEvent }) {
} finally {
setIsFollowingAll(false);
}
}, [user, pubkeys, nostr, publishEvent, toast]);
}, [user, pubkeys, followMany, toast]);
const handleCopyLink = useCallback(() => {
const dTag = event.tags.find(([n]) => n === 'd')?.[1] ?? '';
+29
View File
@@ -0,0 +1,29 @@
/**
* Section wrapper used by the long single-column form pages
* (`CreateActionPage`, `CreateCampaignPage`). Each section is a titled
* `<section>` with a small muted requirement badge so users can scan the
* form at a glance for "what do I have to fill in?".
*/
export function FormSection({
title,
requirement,
children,
}: {
title: string;
requirement: 'Required' | 'Recommended' | 'Optional';
children: React.ReactNode;
}) {
return (
<section className="space-y-2.5 rounded-xl p-3 sm:p-4">
<div className="space-y-0.5">
<h2 className="flex items-center gap-2 text-lg font-semibold">
{title}
<span className="text-xs font-medium text-muted-foreground">
{requirement}
</span>
</h2>
</div>
<div className="space-y-2.5">{children}</div>
</section>
);
}
+129
View File
@@ -0,0 +1,129 @@
import { Suspense, useCallback, useMemo, useRef, useState } from 'react';
import { Link, Outlet } from 'react-router-dom';
import { FloatingComposeButton } from '@/components/FloatingComposeButton';
import { TopNav } from '@/components/TopNav';
import { Skeleton } from '@/components/ui/skeleton';
import {
CenterColumnContext,
DrawerContext,
LayoutStore,
LayoutStoreContext,
NavHiddenContext,
useLayoutSnapshot,
} from '@/contexts/LayoutContext';
import { cn } from '@/lib/utils';
/**
* Persistent app shell for the fundraising-platform overhaul.
*
* Replaces the previous Twitter-style three-column `MainLayout` with a
* GoFundMe-style top-nav-only chrome. Routes render in a single full-width
* content area below the {@link TopNav}.
*
* Compatibility surface:
* - We still provide `LayoutStoreContext`, so pages that call
* `useLayoutOptions(...)` keep working. Most options (FAB, sidebars,
* mobile arc) are intentionally ignored here because the new shell has
* no FAB and no sidebars. The store drives two width-related escape
* hatches: `wrapperClassName` (extra classes on the center column) and
* `noMaxWidth` (drops the default `max-w-3xl` cap). The `fullBleed`
* preset expands to both, so edge-to-edge pages keep working.
* - `CenterColumnContext` exposes the content `<div>` so legacy components
* (e.g. nsite preview overlay) can still portal into it.
* - `DrawerContext` and `NavHiddenContext` are kept as no-op providers so
* pages that read them don't crash.
*/
function PageSkeleton() {
return (
<div className="mx-auto w-full max-w-6xl px-4 sm:px-6 py-8 space-y-4">
<Skeleton className="h-8 w-1/3" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-4/5" />
<Skeleton className="h-72 w-full rounded-xl" />
</div>
);
}
function FundraiserLayoutInner() {
const centerColumnRef = useRef<HTMLDivElement>(null);
const [centerColumnEl, setCenterColumnEl] = useState<HTMLElement | null>(null);
const { noMaxWidth, wrapperClassName, showFAB = false, fabKind = 1, fabHref, onFabClick, fabIcon, fabMenu } = useLayoutSnapshot();
// Mobile drawer is owned by TopNav now, so consumers of `useOpenDrawer`
// become no-ops. Keeping the context shape avoids touching every page that
// pulls the hook.
const openDrawer = useCallback(() => {}, []);
return (
<CenterColumnContext.Provider value={centerColumnEl}>
<DrawerContext.Provider value={openDrawer}>
<NavHiddenContext.Provider value={false}>
<div className="min-h-dvh flex flex-col bg-background">
<TopNav />
<Suspense fallback={<PageSkeleton />}>
<div
ref={(el) => {
centerColumnRef.current = el;
setCenterColumnEl(el);
}}
className={cn(
'flex-1 min-w-0 w-full mx-auto',
// App-wide cap on the center column so pages like /help
// don't stretch across widescreen monitors. Pages that
// need a wider canvas opt out via `noMaxWidth: true` (or
// the `fullBleed` preset), which expands to `!max-w-none`
// through `wrapperClassName`.
!noMaxWidth && 'max-w-3xl',
wrapperClassName,
)}
>
<Outlet />
</div>
</Suspense>
{showFAB && (
<div className="fixed bottom-fab right-6 z-30 pointer-events-none sidebar:right-[max(1.5rem,calc((100vw-48rem)/2-7rem))]">
<div className="pointer-events-auto">
<FloatingComposeButton kind={fabKind} href={fabHref} onFabClick={onFabClick} icon={fabIcon} menu={fabMenu} />
</div>
</div>
)}
<SiteFooter />
</div>
</NavHiddenContext.Provider>
</DrawerContext.Provider>
</CenterColumnContext.Provider>
);
}
function SiteFooter() {
return (
<footer className="bg-background mt-auto pt-12">
<div className="mx-auto max-w-7xl px-4 sm:px-6 py-8 flex flex-col sm:flex-row items-center justify-between gap-4 text-xs text-muted-foreground">
<span>&copy; {new Date().getFullYear()} Agora. Fundraisers on Nostr.</span>
<nav className="flex items-center gap-5">
<a href="/planetora" className="hover:text-foreground motion-safe:transition-colors">Planetora</a>
<a href="/help" className="hover:text-foreground motion-safe:transition-colors">Help</a>
<a href="/privacy" className="hover:text-foreground motion-safe:transition-colors">Privacy</a>
<a href="/safety" className="hover:text-foreground motion-safe:transition-colors">Safety</a>
<Link to="/changelog" className="hover:text-foreground motion-safe:transition-colors">Changelog</Link>
</nav>
</div>
</footer>
);
}
export function FundraiserLayout() {
const store = useMemo(() => new LayoutStore(), []);
return (
<LayoutStoreContext.Provider value={store}>
<FundraiserLayoutInner />
</LayoutStoreContext.Provider>
);
}
export default FundraiserLayout;
+95
View File
@@ -0,0 +1,95 @@
import { useEffect, useState } from 'react';
import { ArrowLeft } from 'lucide-react';
import { Link } from 'react-router-dom';
import { HeroAtmosphere } from '@/components/HeroAtmosphere';
import { HeroBanner } from '@/components/HeroBanner';
import { HOPE_PALETTE, type HopeHue } from '@/lib/hopePalette';
interface GuideHeroProps {
/** Large hero headline. */
title: string;
/** Short subtitle under the headline. */
subtitle: string;
/** Rotating banner images. Pass at least one. */
images: readonly string[];
/**
* Color palette to cycle through for the atmospheric tint. Defaults
* to {@link HOPE_PALETTE} (warm). Pass {@link COOL_PALETTE} for the
* blue/green Organize-style vibe.
*/
palette?: readonly HopeHue[];
}
/**
* Compact photo hero shared by the Donor Guide and Activist Guide pages.
*
* Same structural recipe as the Organize / Actions homepage heroes
* ({@link HeroBanner} + {@link HeroAtmosphere} + scrims + overlay copy),
* but tuned smaller because these are sub-pages, not primary destinations.
* Also embeds a "Back to Help" link in the top-left as the page's
* primary navigation out — so a separate sticky bar isn't needed.
*/
export function GuideHero({
title,
subtitle,
images,
palette = HOPE_PALETTE,
}: GuideHeroProps) {
// Cycle through the palette on a slow cadence so the photo never
// feels static even when a single banner image is on screen.
const [hueIndex, setHueIndex] = useState(0);
useEffect(() => {
if (palette.length <= 1) return;
const id = window.setInterval(() => {
setHueIndex((i) => (i + 1) % palette.length);
}, 9_000);
return () => window.clearInterval(id);
}, [palette]);
const activeHue = palette[hueIndex];
return (
<section className="relative overflow-hidden border-b border-border bg-secondary/30">
<HeroBanner images={images} />
<HeroAtmosphere hue={activeHue} />
{/* Top + bottom scrims so the overlay text stays legible across
every photo in the rotation. */}
<div
className="absolute inset-x-0 top-0 h-48 sm:h-56 pointer-events-none bg-gradient-to-b from-black/75 via-black/45 to-transparent"
aria-hidden="true"
/>
<div
className="absolute inset-x-0 bottom-0 h-32 sm:h-40 pointer-events-none bg-gradient-to-t from-black/60 via-black/25 to-transparent"
aria-hidden="true"
/>
<div className="relative max-w-3xl mx-auto px-4 sm:px-6 py-6 sm:py-8 min-h-[240px] sm:min-h-[280px] flex flex-col">
{/* Back-to-Help action sits on its own row at the top so it
doubles as both the navigation out and the breadcrumb. */}
<div>
<Link
to="/help"
className="inline-flex items-center gap-1.5 rounded-full bg-black/30 hover:bg-black/45 backdrop-blur-sm border border-white/20 px-3 py-1.5 text-xs sm:text-sm font-medium text-white drop-shadow focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/70 transition-colors"
>
<ArrowLeft className="size-3.5" />
Back to Help
</Link>
</div>
{/* Headline + subtitle anchored to the bottom of the hero so the
photo gets room to breathe up top. */}
<div className="flex-1 min-h-[40px]" aria-hidden="true" />
<div className="space-y-2 max-w-2xl">
<h1 className="text-3xl sm:text-4xl lg:text-5xl font-bold tracking-tight leading-[1.05] text-white drop-shadow-[0_2px_12px_rgb(0_0_0/0.55)]">
{title}
</h1>
<p className="text-sm sm:text-base text-white/85 drop-shadow-[0_1px_6px_rgb(0_0_0/0.5)]">
{subtitle}
</p>
</div>
</div>
</section>
);
}
+52
View File
@@ -0,0 +1,52 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { type GuideSection } from '@/lib/helpContent';
import { renderInlineMarkup } from '@/lib/helpMarkup';
/**
* Renders a single {@link GuideSection} as a Card. Used by the Donor Guide
* and Activist Guide pages.
*
* Paragraphs accept the same inline markup as FAQ answers (**bold** and
* [link](url)). Optional `pros` / `cons` arrays render as colored bullet
* lists beneath the paragraphs.
*/
export function GuideSectionCard({ section }: { section: GuideSection }) {
return (
<Card>
<CardHeader>
<CardTitle className="text-base">{section.heading}</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm leading-relaxed text-foreground/80">
{section.paragraphs.map((p, i) => (
<p key={i}>{renderInlineMarkup(p)}</p>
))}
{section.pros && section.pros.length > 0 && (
<div className="pt-1">
<p className="text-xs font-semibold uppercase tracking-wider text-emerald-600 dark:text-emerald-400 mb-1">
Pros
</p>
<ul className="list-disc pl-5 space-y-1">
{section.pros.map((p, i) => (
<li key={i}>{renderInlineMarkup(p)}</li>
))}
</ul>
</div>
)}
{section.cons && section.cons.length > 0 && (
<div className="pt-1">
<p className="text-xs font-semibold uppercase tracking-wider text-amber-600 dark:text-amber-400 mb-1">
Cons
</p>
<ul className="list-disc pl-5 space-y-1">
{section.cons.map((c, i) => (
<li key={i}>{renderInlineMarkup(c)}</li>
))}
</ul>
</div>
)}
</CardContent>
</Card>
);
}
+741
View File
@@ -0,0 +1,741 @@
import { useState, useCallback, useMemo, useRef, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { nip19 } from 'nostr-tools';
import {
AlertTriangle,
Check,
ExternalLink,
Loader2,
X,
} from 'lucide-react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Dialog,
DialogContent,
DialogTitle,
} from '@/components/ui/dialog';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { BitcoinPublicDisclaimer } from '@/components/BitcoinPublicDisclaimer';
import { cn } from '@/lib/utils';
import { useToast } from '@/hooks/useToast';
import { useAppContext } from '@/hooks/useAppContext';
import { useHdWalletAccess } from '@/hooks/useHdWalletAccess';
import { useHdWallet } from '@/hooks/useHdWallet';
import { notificationSuccess } from '@/lib/haptics';
import {
isLargeAmount,
nostrPubkeyToBitcoinAddress,
satsToUSD,
} from '@/lib/bitcoin';
import {
broadcastBlockbookTx,
type BlockbookFeeRates,
fetchFeeRates,
} from '@/lib/hdwallet/blockbook';
import {
buildHdSpendPsbt,
finalizeHdPsbt,
type HdInput,
type HdSpendableSpUtxo,
type HdSpendableUtxo,
parseHdRecipient,
previewHdFee,
signHdPsbt,
} from '@/lib/hdwallet/transaction';
import { useQuery } from '@tanstack/react-query';
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const USD_PRESETS = [1, 5, 10, 25, 100];
type FeeSpeed = 'fastest' | 'halfHour' | 'hour' | 'economy';
const FEE_SPEED_LABELS: Record<FeeSpeed, string> = {
fastest: '~10 min',
halfHour: '~30 min',
hour: '~1 hour',
economy: '~1 day',
};
const FEE_SPEED_ORDER: FeeSpeed[] = ['fastest', 'halfHour', 'hour', 'economy'];
function getRateForSpeed(rates: BlockbookFeeRates, speed: FeeSpeed): number {
switch (speed) {
case 'fastest': return rates.fastestFee;
case 'halfHour': return rates.halfHourFee;
case 'hour': return rates.hourFee;
case 'economy': return rates.economyFee;
}
}
function getUniqueFeeSpeeds(rates: BlockbookFeeRates | undefined): FeeSpeed[] {
if (!rates) return FEE_SPEED_ORDER;
const seen = new Set<number>();
const result: FeeSpeed[] = [];
for (const speed of FEE_SPEED_ORDER) {
const rate = getRateForSpeed(rates, speed);
if (!seen.has(rate)) { seen.add(rate); result.push(speed); }
}
return result;
}
// ---------------------------------------------------------------------------
// Recipient resolution
// ---------------------------------------------------------------------------
interface ResolvedRecipient {
/**
* Final P2TR/P2WPKH/etc. address used as the PSBT output.
*
* For silent-payment (`sp1…`) recipients this is the original `sp1…`
* string — the real on-chain `P_k` is derived at build time, after coin
* selection. The dialog never displays this value directly when
* `kind === 'sp'`; it's kept here so {@link buildHdSpendPsbt} can route
* by recipient kind.
*/
address: string;
/** Optional Nostr pubkey when the recipient was an npub/nprofile. */
pubkey?: string;
/** Raw text the user typed (for re-display). */
raw: string;
/**
* Recipient kind. `'address'` for bare Bitcoin addresses (including
* Nostr-derived ones); `'sp'` for BIP-352 silent-payment addresses.
*/
kind: 'address' | 'sp';
}
/**
* Parse the recipient input as one of:
* - bare Bitcoin address (mainnet, any standard type)
* - silent-payment address (`sp1…`, mainnet, v0)
* - npub1… → P2TR derived from the Nostr pubkey
* - nprofile1… → P2TR derived from the encoded pubkey
*
* Returns `null` for unparseable input. The caller should treat `null` as
* "input still in progress" rather than "error" until the user submits.
*/
function resolveRecipient(input: string): ResolvedRecipient | null {
const trimmed = input.trim();
if (!trimmed) return null;
// Try bare Bitcoin / silent-payment via the unified parser.
const parsed = parseHdRecipient(trimmed);
if (parsed) {
if (parsed.kind === 'address') {
return { address: parsed.address, raw: trimmed, kind: 'address' };
}
return { address: parsed.spAddress, raw: trimmed, kind: 'sp' };
}
// Try NIP-19 npub / nprofile.
if (trimmed.startsWith('npub1') || trimmed.startsWith('nprofile1')) {
try {
const decoded = nip19.decode(trimmed);
if (decoded.type === 'npub') {
const address = nostrPubkeyToBitcoinAddress(decoded.data);
if (address) return { address, pubkey: decoded.data, raw: trimmed, kind: 'address' };
} else if (decoded.type === 'nprofile') {
const address = nostrPubkeyToBitcoinAddress(decoded.data.pubkey);
if (address) return { address, pubkey: decoded.data.pubkey, raw: trimmed, kind: 'address' };
}
} catch {
// fall through
}
}
return null;
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
interface HDSendBitcoinDialogProps {
isOpen: boolean;
onClose: () => void;
/** BTC/USD price — passed in to avoid duplicate fetches. */
btcPrice?: number;
}
interface SendResult {
txid: string;
amountSats: number;
fee: number;
/**
* Silent-payment UTXOs (`(txid, vout)`) consumed by the broadcast tx.
* Pruned from local SP storage in `onSuccess` — otherwise the wallet
* would keep treating them as spendable and the displayed balance would
* jump *up* after the spend (because the BIP-86 change credits to
* Blockbook's xpub balance while the SP entries remain locally).
*/
consumedSpUtxos: Array<{ txid: string; vout: number }>;
}
/**
* "Send Bitcoin" dialog for the HD wallet at `/wallet`.
*
* Provides a large editable USD amount, preset chips, fee speed picker, two-tap
* arming for large amounts, and a privacy disclaimer for raw addresses. Uses
* the HD wallet's UTXO set across many addresses, signs with per-input HD-derived
* keys, and emits change to a fresh internal address.
*/
export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice }: HDSendBitcoinDialogProps) {
const availability = useHdWalletAccess();
const {
scan,
silentPaymentBalance,
silentPaymentStorage,
refetch: refetchWallet,
pruneSpentSilentPaymentUtxos,
} = useHdWallet();
const { config } = useAppContext();
const { blockbookBaseUrl } = config;
const { toast } = useToast();
const queryClient = useQueryClient();
const isReady = availability.status === 'available';
// ── Form state ───────────────────────────────────────────────
const [recipientInput, setRecipientInput] = useState('');
const [usdAmount, setUsdAmount] = useState<number | string>(5);
const [feeSpeed, setFeeSpeed] = useState<FeeSpeed>('halfHour');
const [error, setError] = useState('');
const [editingAmount, setEditingAmount] = useState(false);
const [feePopoverOpen, setFeePopoverOpen] = useState(false);
const [success, setSuccess] = useState<SendResult | null>(null);
const amountInputRef = useRef<HTMLInputElement>(null);
const feeSpeedUserChanged = useRef(false);
const recipient = useMemo(() => resolveRecipient(recipientInput), [recipientInput]);
// ── Fee rates ────────────────────────────────────────────────
const { data: feeRates } = useQuery({
queryKey: ['blockbook-fee-rates', blockbookBaseUrl],
queryFn: ({ signal }) => fetchFeeRates(blockbookBaseUrl, signal),
enabled: isOpen && isReady,
staleTime: 30_000,
});
const currentFeeRate = useMemo(() => {
if (!feeRates) return undefined;
return getRateForSpeed(feeRates, feeSpeed);
}, [feeRates, feeSpeed]);
// ── Owned UTXO set ───────────────────────────────────────────
//
// Combines BIP-86 UTXOs scanned from Blockbook with silent-payment UTXOs
// discovered by the BIP-352 scanner and persisted via NIP-78. Both can
// fund a send; the PSBT builder dispatches per-input.
const bip86Utxos: HdSpendableUtxo[] = useMemo(() => scan?.utxos ?? [], [scan]);
const spUtxos: HdSpendableSpUtxo[] = useMemo(
() =>
(silentPaymentStorage?.utxos ?? []).map((u) => ({
txid: u.txid,
vout: u.vout,
value: u.value,
tweakHex: u.tweak,
k: u.k,
height: u.height,
})),
[silentPaymentStorage],
);
const ownedInputs: HdInput[] = useMemo(
() => [
...bip86Utxos.map<HdInput>((utxo) => ({ kind: 'bip86', utxo })),
...spUtxos.map<HdInput>((utxo) => ({ kind: 'sp', utxo })),
],
[bip86Utxos, spUtxos],
);
const totalBalance = useMemo(
() => bip86Utxos.reduce((s, u) => s + u.value, 0) + silentPaymentBalance,
[bip86Utxos, silentPaymentBalance],
);
// ── USD → sats ───────────────────────────────────────────────
const amountSats = useMemo(() => {
if (!btcPrice) return 0;
const usd = typeof usdAmount === 'string' ? parseFloat(usdAmount) : usdAmount;
if (!Number.isFinite(usd) || usd <= 0) return 0;
return Math.round((usd / btcPrice) * 100_000_000);
}, [usdAmount, btcPrice]);
// ── Fee estimate (matches the actual coin selection) ────────
//
// Crucially we do NOT use `ownedInputs.length` as the input count: an HD
// wallet typically has many UTXOs across many addresses, but a real send
// only consumes the minimal set the coin selector picks. Using the full
// count would over-estimate fees by 10x or more on an active wallet, and
// would also make the UI think we're insufficient when we're not.
const estimatedFeeSats = useMemo(() => {
if (!ownedInputs.length || !currentFeeRate || !amountSats) return 0;
return previewHdFee(ownedInputs, amountSats, currentFeeRate);
}, [ownedInputs, currentFeeRate, amountSats]);
const totalSats = amountSats + estimatedFeeSats;
// `previewHdFee` returns 0 when the coin selector can't cover `amount + fee`.
// Treat that as insufficient so the UI doesn't claim a 0-sat fee is fine.
const selectionFailed =
amountSats > 0 && !!currentFeeRate && ownedInputs.length > 0 && estimatedFeeSats === 0;
const insufficient = selectionFailed || (totalBalance > 0 && totalSats > totalBalance);
const showBalance = insufficient || (amountSats > 0 && totalBalance === 0);
// Auto-tune fee speed to keep fees < 40% of the send amount, unless the
// user has manually overridden.
useEffect(() => {
if (feeSpeedUserChanged.current) return;
if (!ownedInputs.length || !feeRates || amountSats <= 0) return;
const uniqueSpeeds = getUniqueFeeSpeeds(feeRates);
const threshold = amountSats * 0.4;
let target: FeeSpeed = uniqueSpeeds[uniqueSpeeds.length - 1];
for (const speed of uniqueSpeeds) {
const rate = getRateForSpeed(feeRates, speed);
const fee = previewHdFee(ownedInputs, amountSats, rate);
if (fee > 0 && fee <= threshold) { target = speed; break; }
}
setFeeSpeed((prev) => (prev === target ? prev : target));
}, [amountSats, feeRates, ownedInputs, totalBalance]);
const handleFeeSpeedChange = useCallback((speed: FeeSpeed) => {
feeSpeedUserChanged.current = true;
setFeeSpeed(speed);
setFeePopoverOpen(false);
}, []);
// ── Two-tap arm + raw-address disclaimer ─────────────────────
const isLarge = isLargeAmount(totalSats, btcPrice);
// SP recipients (`sp1…`) produce a fresh, unlinkable Taproot output per
// payment — they do NOT have the privacy concern of a reused on-chain
// address. The public disclaimer is only needed for bare BTC addresses
// typed in directly (no Nostr identity attached, no SP).
const isRawAddress =
!!recipient && recipient.kind === 'address' && !recipient.pubkey;
const [confirmArmed, setConfirmArmed] = useState(false);
const [acknowledgedPublic, setAcknowledgedPublic] = useState(false);
useEffect(() => {
setConfirmArmed(false);
}, [amountSats, currentFeeRate, btcPrice, recipient?.address]);
// Reset the privacy acknowledgement only when the recipient changes —
// not when the user adjusts the amount or fee tier. Toggling between
// fee speeds should not silently uncheck the warning.
useEffect(() => {
setAcknowledgedPublic(false);
}, [recipient?.address]);
const requiresArm = isLarge || isRawAddress;
// ── Amount focus management ──────────────────────────────────
useEffect(() => {
if (editingAmount) {
amountInputRef.current?.focus();
amountInputRef.current?.select();
}
}, [editingAmount]);
const commitAmountEdit = useCallback(() => {
setEditingAmount(false);
if (typeof usdAmount === 'string' && usdAmount.trim() === '') setUsdAmount(0);
}, [usdAmount]);
// ── Send mutation ────────────────────────────────────────────
const [progress, setProgress] = useState<'idle' | 'building' | 'signing' | 'broadcasting'>('idle');
const sendMutation = useMutation<SendResult, Error, void>({
mutationFn: async () => {
if (availability.status !== 'available') {
throw new Error('HD wallet is not available for this login type.');
}
if (!recipient) throw new Error('Enter a Bitcoin address, sp1… address, or npub.');
if (!ownedInputs.length) throw new Error('No spendable Bitcoin in this wallet.');
if (!feeRates) throw new Error('Fee rates not loaded.');
if (recipient.pubkey === availability.pubkey) throw new Error("You can't send to yourself.");
if (amountSats <= 0) throw new Error('Enter an amount.');
if (insufficient) throw new Error('Not enough Bitcoin for this amount + network fee.');
const rate = getRateForSpeed(feeRates, feeSpeed);
const nextChangeIndex = scan?.change.firstUnusedIndex ?? 0;
setProgress('building');
const built = buildHdSpendPsbt({
account: availability.account,
inputs: ownedInputs,
recipient:
recipient.kind === 'sp'
? { kind: 'sp', spAddress: recipient.address }
: { kind: 'address', address: recipient.address },
amountSats,
feeRate: rate,
nextChangeIndex,
nsecBytes: availability.nsecBytes,
});
setProgress('signing');
const signedHex = signHdPsbt(
built.psbtHex,
built.inputDescriptors,
availability.account,
availability.nsecBytes,
);
const txHex = finalizeHdPsbt(signedHex);
setProgress('broadcasting');
const txid = await broadcastBlockbookTx(blockbookBaseUrl, txHex);
return { txid, amountSats, fee: built.fee, consumedSpUtxos: built.consumedSpUtxos };
},
onSuccess: (result) => {
notificationSuccess();
setSuccess(result);
queryClient.invalidateQueries({ queryKey: ['hdwallet-scan'] });
// Remove the SP UTXOs we just spent from local storage and
// republish the NIP-78 doc. Blockbook's xpub scan can't see SP
// outputs, so without this the spent UTXOs would linger forever:
// the balance would still count them, the coin selector would try
// to spend them again (resulting in "missing/spent input" broadcast
// errors), and the wallet would appear to *gain* money on each SP
// spend (BIP-86 change is observed by Blockbook, but the consumed
// SP value is not subtracted locally).
if (result.consumedSpUtxos.length > 0) {
pruneSpentSilentPaymentUtxos(result.consumedSpUtxos);
}
void refetchWallet();
},
onError: (err) => {
toast({ title: 'Transaction failed', description: err.message, variant: 'destructive' });
},
onSettled: () => setProgress('idle'),
});
const handleSend = useCallback(() => {
setError('');
if (availability.status !== 'available') {
setError('HD wallet is not available for this login type.'); return;
}
if (!recipient) { setError('Enter a Bitcoin address, sp1… address, or npub.'); return; }
if (recipient.pubkey === availability.pubkey) { setError("You can't send to yourself."); return; }
if (!btcPrice) { setError('Waiting for BTC price…'); return; }
if (amountSats <= 0) { setError('Enter an amount.'); return; }
if (!ownedInputs.length) { setError("You don't have any Bitcoin yet."); return; }
if (insufficient) { setError('Not enough Bitcoin for this amount + network fee.'); return; }
if (isRawAddress && !acknowledgedPublic) {
setError('Acknowledge the privacy warning before sending.'); return;
}
if (requiresArm && !confirmArmed) { setConfirmArmed(true); return; }
sendMutation.mutate();
}, [
availability,
recipient,
btcPrice,
amountSats,
ownedInputs.length,
insufficient,
isRawAddress,
acknowledgedPublic,
requiresArm,
confirmArmed,
sendMutation,
]);
// ── Reset on close ───────────────────────────────────────────
const handleClose = useCallback(() => {
if (sendMutation.isPending) return;
onClose();
// defer to allow exit animation
setTimeout(() => {
setRecipientInput('');
setUsdAmount(5);
setError('');
setConfirmArmed(false);
setAcknowledgedPublic(false);
setSuccess(null);
feeSpeedUserChanged.current = false;
}, 200);
}, [onClose, sendMutation.isPending]);
// ── Render helpers ───────────────────────────────────────────
const sendButtonLabel = (() => {
if (sendMutation.isPending) {
switch (progress) {
case 'building': return 'Building transaction…';
case 'signing': return 'Signing…';
case 'broadcasting': return 'Broadcasting…';
default: return 'Sending…';
}
}
if (confirmArmed) return 'Tap again to confirm';
return 'Send Bitcoin';
})();
const sendDisabled =
sendMutation.isPending ||
!recipient ||
!btcPrice ||
amountSats <= 0 ||
insufficient ||
!ownedInputs.length ||
(isRawAddress && !acknowledgedPublic);
// ── Render ───────────────────────────────────────────────────
return (
<Dialog open={isOpen} onOpenChange={(v) => { if (!v) handleClose(); }}>
<DialogContent className="sm:max-w-md p-0 gap-0 overflow-hidden [&>button]:hidden">
<DialogTitle className="sr-only">Send Bitcoin</DialogTitle>
{success ? (
<SuccessScreen
txid={success.txid}
amountSats={success.amountSats}
btcPrice={btcPrice}
onClose={handleClose}
/>
) : (
<div className="grid gap-5 px-6 py-6">
{/* Header */}
<div className="flex items-center justify-between">
<h2 className="text-base font-semibold">Send Bitcoin</h2>
<button
onClick={handleClose}
className="text-muted-foreground hover:text-foreground transition-colors"
aria-label="Close"
>
<X className="size-4" />
</button>
</div>
{/* Amount */}
<div className="flex flex-col items-center py-2">
{editingAmount ? (
<div className="flex items-center text-4xl font-bold tracking-tight">
<span className="text-muted-foreground">$</span>
<Input
ref={amountInputRef}
type="number"
inputMode="decimal"
value={usdAmount}
onChange={(e) => setUsdAmount(e.target.value)}
onBlur={commitAmountEdit}
onKeyDown={(e) => { if (e.key === 'Enter') commitAmountEdit(); }}
className="bg-transparent border-none focus-visible:ring-0 text-4xl font-bold tracking-tight w-32 text-center px-0 h-auto"
/>
</div>
) : (
<button
type="button"
onClick={() => setEditingAmount(true)}
className="text-4xl font-bold tracking-tight hover:text-primary transition-colors cursor-text"
>
${typeof usdAmount === 'number' ? usdAmount : (parseFloat(usdAmount) || 0)}
</button>
)}
{amountSats > 0 && btcPrice && (
<span className="text-xs text-muted-foreground mt-1 tabular-nums">
{amountSats.toLocaleString()} sats
</span>
)}
</div>
{/* USD presets */}
<div className="flex flex-wrap justify-center gap-1.5">
{USD_PRESETS.map((preset) => (
<button
key={preset}
type="button"
onClick={() => { setUsdAmount(preset); setEditingAmount(false); }}
className={cn(
'px-3 py-1 rounded-full text-xs border transition-colors',
Number(usdAmount) === preset
? 'bg-primary text-primary-foreground border-primary'
: 'border-border hover:bg-muted/50',
)}
>
${preset}
</button>
))}
</div>
{/* Recipient */}
<div className="grid gap-1">
<label className="text-xs text-muted-foreground" htmlFor="hd-recipient-input">
Recipient
</label>
<Input
id="hd-recipient-input"
value={recipientInput}
onChange={(e) => setRecipientInput(e.target.value)}
placeholder="bc1…, sp1…, or npub…"
autoComplete="off"
spellCheck={false}
className="font-mono text-sm"
/>
{recipient && (
<p className="text-xs text-muted-foreground">
{recipient.kind === 'sp' ? (
<>Sending via a silent payment the recipient gets a fresh, unlinkable on-chain address.</>
) : recipient.pubkey ? (
<>Sending to a Nostr user&apos;s on-chain address.</>
) : (
<>Sending to a raw Bitcoin address.</>
)}
</p>
)}
</div>
{/* Privacy disclaimer for raw addresses */}
{isRawAddress && (
<BitcoinPublicDisclaimer
acknowledged={acknowledgedPublic}
onAcknowledgedChange={setAcknowledgedPublic}
/>
)}
{/* Fee speed */}
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">Network fee</span>
<Popover open={feePopoverOpen} onOpenChange={setFeePopoverOpen}>
<PopoverTrigger asChild>
<button
type="button"
className="flex items-center gap-1.5 hover:text-foreground transition-colors text-muted-foreground tabular-nums"
>
{estimatedFeeSats > 0 && btcPrice ? (
<> {satsToUSD(estimatedFeeSats, btcPrice)}</>
) : currentFeeRate ? (
<>{currentFeeRate} sat/vB</>
) : (
<></>
)}
<span className="opacity-60">·</span>
{FEE_SPEED_LABELS[feeSpeed]}
</button>
</PopoverTrigger>
<PopoverContent className="w-44 p-1" align="end">
<div className="grid gap-0.5">
{getUniqueFeeSpeeds(feeRates).map((speed) => (
<button
key={speed}
type="button"
onClick={() => handleFeeSpeedChange(speed)}
className={cn(
'flex justify-between items-center px-3 py-1.5 rounded-md text-xs hover:bg-muted/50 transition-colors',
feeSpeed === speed && 'bg-muted',
)}
>
<span>{FEE_SPEED_LABELS[speed]}</span>
{feeRates && (
<span className="text-muted-foreground tabular-nums">
{getRateForSpeed(feeRates, speed)} sat/vB
</span>
)}
</button>
))}
</div>
</PopoverContent>
</Popover>
</div>
{showBalance && totalBalance > 0 && btcPrice && (
<p className="text-xs text-muted-foreground text-center">
Available: {satsToUSD(totalBalance, btcPrice)} ({totalBalance.toLocaleString()} sats)
</p>
)}
{/* Error */}
{error && (
<Alert variant="destructive" className="py-2">
<AlertTriangle className="size-3.5" />
<AlertDescription className="text-xs">{error}</AlertDescription>
</Alert>
)}
{/* Send button */}
<Button
type="button"
onClick={handleSend}
disabled={sendDisabled}
className={cn(
'w-full',
confirmArmed && !sendMutation.isPending && 'bg-amber-500 hover:bg-amber-600 text-white',
)}
>
{sendMutation.isPending && <Loader2 className="size-4 mr-2 animate-spin" />}
{sendButtonLabel}
</Button>
</div>
)}
</DialogContent>
</Dialog>
);
}
// ---------------------------------------------------------------------------
// Success screen
// ---------------------------------------------------------------------------
interface SuccessScreenProps {
txid: string;
amountSats: number;
btcPrice: number | undefined;
onClose: () => void;
}
function SuccessScreen({ txid, amountSats, btcPrice, onClose }: SuccessScreenProps) {
const usdDisplay = btcPrice ? satsToUSD(amountSats, btcPrice) : '';
return (
<div
role="status"
aria-live="polite"
className="relative grid gap-5 px-6 py-8 w-full overflow-hidden text-center motion-safe:animate-success-fade-up"
>
<div
aria-hidden
className="pointer-events-none absolute inset-0 -z-10 bg-[radial-gradient(circle_at_50%_35%,hsl(var(--primary)/0.18),transparent_65%)]"
/>
<div className="relative mx-auto flex size-28 items-center justify-center">
<span
aria-hidden
className="absolute inset-0 rounded-full bg-gradient-to-br from-amber-400/40 to-orange-500/30 motion-safe:animate-success-halo"
/>
<span
aria-hidden
className="absolute inset-0 rounded-full bg-gradient-to-br from-amber-400 to-orange-500 shadow-lg shadow-orange-500/30 motion-safe:animate-success-pop"
/>
<Check className="relative size-14 text-white drop-shadow-sm motion-safe:animate-success-pop" strokeWidth={3} aria-hidden />
</div>
<div className="grid gap-1">
<h2 className="text-lg font-semibold tracking-tight">Bitcoin sent</h2>
<div className="text-4xl font-bold tabular-nums bg-gradient-to-br from-amber-500 to-orange-600 bg-clip-text text-transparent">
{usdDisplay || `${amountSats.toLocaleString()} sats`}
</div>
</div>
<div className="grid gap-2">
<Button type="button" variant="outline" asChild className="w-full">
<Link to={`/i/bitcoin:tx:${txid}`} onClick={onClose}>
<ExternalLink className="size-4 mr-2" />
View transaction
</Link>
</Button>
<Button type="button" onClick={onClose} className="w-full">Done</Button>
</div>
</div>
);
}
@@ -0,0 +1,292 @@
import { useEffect, useState } from 'react';
import { Loader2, AlertCircle, CheckCircle2 } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Progress } from '@/components/ui/progress';
import { useHdWalletSp } from '@/hooks/useHdWalletSp';
// ---------------------------------------------------------------------------
// HD wallet — silent-payment "Scan history" dialog
// ---------------------------------------------------------------------------
//
// Walks the user through running a BIP-352 chain scan over a configurable
// block range. Defaults to "from last scanned height → tip", which is the
// common forward-catch-up case; advanced users can edit the bounds for a
// targeted backfill.
// ---------------------------------------------------------------------------
export interface HDSilentPaymentScanDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function HDSilentPaymentScanDialog({ open, onOpenChange }: HDSilentPaymentScanDialogProps) {
const sp = useHdWalletSp();
const [from, setFrom] = useState('');
const [to, setTo] = useState('');
const [touched, setTouched] = useState(false);
const [includeSpent, setIncludeSpent] = useState(false);
// Seed defaults whenever the dialog opens or upstream data changes.
useEffect(() => {
if (!open) {
setTouched(false);
setIncludeSpent(false);
return;
}
if (touched) return;
const tip = sp.tipHeight;
const lastScanned = sp.storage?.scanHeight ?? 0;
const defaultFrom = lastScanned > 0 ? lastScanned + 1 : tip ? Math.max(0, tip - 144) : 0;
setFrom(String(defaultFrom));
setTo(tip ? String(tip) : '');
}, [open, sp.tipHeight, sp.storage?.scanHeight, touched]);
const fromNum = Number(from);
const toNum = Number(to);
const fromValid = Number.isInteger(fromNum) && fromNum >= 0;
const toValid = to === '' || (Number.isInteger(toNum) && toNum >= fromNum);
const inputsValid = fromValid && toValid;
const handleScan = async () => {
if (!inputsValid) return;
await sp.scanRange({
fromHeight: fromNum,
toHeight: to === '' ? undefined : toNum,
includeSpent,
});
};
const progressPercent = sp.scanProgress
? Math.min(
100,
Math.round(
((sp.scanProgress.currentHeight - sp.scanProgress.fromHeight + 1) /
Math.max(1, sp.scanProgress.toHeight - sp.scanProgress.fromHeight + 1)) *
100,
),
)
: 0;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Scan for silent payments</DialogTitle>
<DialogDescription>
Walks the configured BIP-352 indexer block-by-block to detect incoming silent payments.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="sp-scan-from" className="text-xs">
From block
</Label>
<Input
id="sp-scan-from"
type="number"
inputMode="numeric"
min={0}
value={from}
onChange={(e) => {
setTouched(true);
setFrom(e.target.value);
}}
disabled={sp.isScanning}
aria-invalid={!fromValid}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="sp-scan-to" className="text-xs">
To block
</Label>
<Input
id="sp-scan-to"
type="number"
inputMode="numeric"
min={0}
placeholder="tip"
value={to}
onChange={(e) => {
setTouched(true);
setTo(e.target.value);
}}
disabled={sp.isScanning}
aria-invalid={!toValid}
/>
</div>
</div>
{sp.tipHeight !== undefined && (
<p className="text-xs text-muted-foreground">
Indexer tip: <span className="font-mono">{sp.tipHeight.toLocaleString()}</span>
{sp.storage && (
<>
{' · '}
Last fully scanned:{' '}
<span className="font-mono">
{sp.storage.scanHeight > 0 ? sp.storage.scanHeight.toLocaleString() : 'never'}
</span>
</>
)}
</p>
)}
{/*
* "Include already-spent" deep-rescan toggle. Off by default
* because the normal scan path doesn't want already-spent
* outputs cluttering the active UTXO set. Turn on to recover
* historical receive rows whose UTXOs were later spent and
* subsequently pruned from local storage — matches against
* spent outputs are routed straight into the `spent` archive,
* which powers both the receive-history rows and the
* send-vs-receive classifier in the tx list.
*/}
<div className="flex items-start gap-2">
<Checkbox
id="sp-include-spent"
checked={includeSpent}
onCheckedChange={(v) => setIncludeSpent(v === true)}
disabled={sp.isScanning}
className="mt-0.5"
/>
<div className="space-y-0.5">
<Label htmlFor="sp-include-spent" className="text-xs cursor-pointer">
Include already-spent
</Label>
<p className="text-xs text-muted-foreground">
Also detect silent payments that have since been spent. Use when
rebuilding receive history after a missed scan or a reset.
</p>
</div>
</div>
{sp.isScanning && sp.scanProgress && (
<div className="space-y-2">
<Progress value={progressPercent} />
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span className="flex items-center gap-1.5">
<Loader2 className="size-3 animate-spin" />
Block {sp.scanProgress.currentHeight.toLocaleString()} /{' '}
{sp.scanProgress.toHeight.toLocaleString()}
</span>
<span>
{sp.scanProgress.matchesFound} match
{sp.scanProgress.matchesFound === 1 ? '' : 'es'}
</span>
</div>
</div>
)}
{!sp.isScanning && sp.scanError && (
<div className="flex items-start gap-2 text-xs text-destructive">
<AlertCircle className="size-4 shrink-0 mt-0.5" />
<p>{sp.scanError.message}</p>
</div>
)}
{!sp.isScanning && !sp.scanError && sp.scanProgress && (
<div className="flex items-start gap-2 text-xs text-muted-foreground">
<CheckCircle2 className="size-4 shrink-0 mt-0.5 text-green-500" />
<p>
Scanned blocks {sp.scanProgress.fromHeight.toLocaleString()} {' '}
{sp.scanProgress.currentHeight.toLocaleString()}.{' '}
{sp.scanProgress.matchesFound > 0
? `Found ${sp.scanProgress.matchesFound} new ${
sp.scanProgress.matchesFound === 1 ? 'output' : 'outputs'
}.`
: 'No new payments.'}
</p>
</div>
)}
{/* ── Reconcile spent UTXOs ──────────────────────────── */}
{/*
* Manual fix-up path for SP UTXOs that were spent outside the
* local send flow — different device, or a build that predates
* the send-time prune logic. Walks the stored set, asks
* Blockbook for each output's spent status, and drops the spent
* ones. Capped at 50 UTXOs per click; subsequent clicks pick up
* any remainder.
*/}
{sp.storage && sp.storage.utxos.length > 0 && (
<div className="space-y-2 border-t pt-3">
<div className="space-y-1">
<Label className="text-xs">Reconcile spent UTXOs</Label>
<p className="text-xs text-muted-foreground">
Checks each stored silent-payment UTXO against Blockbook and removes any
that have been spent. Use this if the balance is higher than it should
be after a send.
</p>
</div>
{sp.reconcileProgress && !sp.reconcileError && (
<p className="text-xs text-muted-foreground">
{sp.isReconciling
? `Checking ${sp.reconcileProgress.checked} / ${sp.reconcileProgress.total}`
: `Checked ${sp.reconcileProgress.checked} UTXO${
sp.reconcileProgress.checked === 1 ? '' : 's'
} · pruned ${sp.reconcileProgress.prunedSoFar}.`}
</p>
)}
{sp.reconcileError && (
<div className="flex items-start gap-2 text-xs text-destructive">
<AlertCircle className="size-4 shrink-0 mt-0.5" />
<p>{sp.reconcileError.message}</p>
</div>
)}
<Button
variant="outline"
size="sm"
onClick={() => {
void sp.reconcileSpentUtxos();
}}
disabled={sp.isReconciling || sp.isScanning}
>
{sp.isReconciling ? (
<>
<Loader2 className="size-3 animate-spin mr-2" />
Reconciling
</>
) : (
'Reconcile now'
)}
</Button>
</div>
)}
<div className="flex justify-end gap-2 pt-2">
{sp.isScanning ? (
<Button variant="outline" onClick={() => sp.cancelScan()}>
Cancel
</Button>
) : (
<>
<Button variant="ghost" onClick={() => onOpenChange(false)}>
Close
</Button>
<Button onClick={handleScan} disabled={!inputsValid}>
Start scan
</Button>
</>
)}
</div>
</div>
</DialogContent>
</Dialog>
);
}
+8 -48
View File
@@ -8,54 +8,7 @@ import {
} from '@/components/ui/accordion';
import { useAppContext } from '@/hooks/useAppContext';
import { getFAQCategories, type FAQCategory, type FAQItem } from '@/lib/helpContent';
// ── Inline markup renderer ────────────────────────────────────────────────────
/**
* Very lightweight inline markup: **bold** and [text](url).
* Returns an array of React nodes.
*/
function renderInlineMarkup(text: string): React.ReactNode[] {
const nodes: React.ReactNode[] = [];
// Match **bold** or [text](url)
const regex = /\*\*(.+?)\*\*|\[([^\]]+)\]\(([^)]+)\)/g;
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = regex.exec(text)) !== null) {
// Push text before this match
if (match.index > lastIndex) {
nodes.push(text.slice(lastIndex, match.index));
}
if (match[1] !== undefined) {
// **bold**
nodes.push(<strong key={match.index} className="font-semibold text-foreground">{match[1]}</strong>);
} else if (match[2] !== undefined && match[3] !== undefined) {
// [text](url)
nodes.push(
<a
key={match.index}
href={match[3]}
target="_blank"
rel="noopener noreferrer"
className="text-primary underline underline-offset-2 hover:text-primary/80 transition-colors"
>
{match[2]}
</a>,
);
}
lastIndex = match.index + match[0].length;
}
// Trailing text
if (lastIndex < text.length) {
nodes.push(text.slice(lastIndex));
}
return nodes;
}
import { renderInlineMarkup } from '@/lib/helpMarkup';
// ── Component ─────────────────────────────────────────────────────────────────
@@ -93,6 +46,13 @@ export function HelpFAQSection({ categories, items, hideHeadings, className }: H
const filteredCategories = useMemo(() => {
let cats: FAQCategory[] = getFAQCategories(config.appName);
// Drop hidden categories from the default render. They still exist in
// the underlying template so `HelpTip` can look up individual items by
// ID, but they don't show up in the FAQ accordion.
if (!categories && !items) {
cats = cats.filter((c) => !c.hidden);
}
// Filter to specific categories
if (categories) {
cats = cats.filter((c) => categories.includes(c.id));

Some files were not shown because too many files have changed in this diff Show More