Three small extractions that consolidate hand-rolled copies in the
discovery surfaces. No behavior change.
- getPledgeCoord → src/lib/pledges.ts. Was defined three times
(PledgesDiscoverySection, ActionsPage, ActionShareMenu), each with
the same '36639:<pubkey>:<d>' template. Lifted into the existing
pledges lib and typed structurally on { pubkey, id } so the lib
layer doesn't take a hook dep on Action.
- parseSort + toQuerySort → exported from useDiscoveryFilters and
useAllCampaigns respectively. AllCampaignsPage was carrying its own
copy of both with an apologetic comment ('mirroring the one in
useDiscoveryFilters'); CampaignsDiscoverySection had its own
toQuerySort. One source of truth each now, with the pages and the
section importing from the same module as the hook that consumes
the result.
- PledgeCardSkeleton → exported from PledgeCard. Replaces two
byte-identical ActionSkeleton components in PledgesDiscoverySection
and ActionsPage. Naming matches the existing CampaignCardSkeleton /
CommunityMiniCardSkeleton convention of placing the skeleton next
to its card.
Two related gates on the unified discovery sections:
- PledgesDiscoverySection: the chronological useActions({ limit: 300 })
query only feeds the idle render branch (via idlePledges), but it
was firing in active-search mode too. Active mode renders searchHits
from useNip50Search, which never reads rawActions. On every keystroke
that activates search we were burning a 300-event relay round-trip
whose results went nowhere. Gate the query on !isSearching so the
fetch happens only when the idle branch can actually consume it.
- CampaignsDiscoverySection: align the featured-coords useCampaigns
query's enabled flag with the pledges section's pattern. useCampaigns
already short-circuits on an empty coordinates array, so this is
purely about not creating an empty cache entry when moderators have
curated nothing — but it removes a small asymmetry that would have
made the next reviewer second-guess which pattern is intentional.
The home page now shows the same Campaigns / Groups / Pledges sections
as their dedicated pages (/campaigns/all, /groups, /pledges), with the
same titles, taglines, and search/sort/country toolbars instead of
'Browse all' shortcut links. Each surface's discovery logic lived
in its own page and the home page was about to grow a fourth copy of
it, so the section bodies move into reusable components:
CampaignsDiscoverySection src/components/discovery/
GroupsDiscoverySection
PledgesDiscoverySection
Each owns the section header (title / tagline switch on active
search), the DiscoverySearchToolbar, the idle featured grid, the
active search/sort/country grid, and the per-section empty / no-match
cards. Filter state (search input, sort, country, debouncing) lives
in a new useDiscoveryFilters hook which has two modes:
filterPersistence='url' - flat ?q=&sort=&country= params. Used by
the dedicated pages so search results
are shareable and survive refresh.
filterPersistence='local' - local-only state. Used by / where three
sections coexist and can't all own ?q=.
Refreshing the home lands on the curated
idle view, which matches what we want.
The dedicated pages keep their hero, optional Your-X shelf, and the
moderator-only Hidden collapsible — those stay page-level because
each page wants its own copy. They drive the section's Show-hidden
toolbar switch via a hoisted prop so the page-level Hidden
collapsible can read the same flag.
Side effects:
- ActionShareMenu moves from inside ActionsPage to its own file
so PledgesDiscoverySection can render it on every card without
re-importing the page module.
- useDiscoverCommunities is unchanged but only the dedicated
/groups page calls it now (for the Hidden collapsible /
hidden-count badge). The home page never triggers it.
- browseAllGroups and browseAllPledges locale keys drop from all
16 locales since the launchpad layout that needed them no
longer exists.
The home page used to be the canonical browse view for campaigns: hero
plus featured row, then the full community grid, then moderator-only
Pending/Hidden sections, then a per-viewer 'Your campaigns' shelf.
With /campaigns/all, /groups, and /pledges all now hosting their own
dedicated browse views (featured + search + sort + country in one
unified section), the home page no longer needs to duplicate the
campaigns browse experience.
Rebuild / as a three-section launchpad:
Hero -> unchanged (HeroLightningMap, Bebas Neue tagline, brand CTAs).
Featured campaigns -> capped at 4, links to /campaigns/all.
Featured groups -> capped at 8, links to /groups.
Featured pledges -> capped at 8, links to /pledges.
Each section pulls its featured set from the same moderation labels
that drive the dedicated page, so what surfaces here matches what
surfaces there — just truncated. Sections with no featured items
collapse silently (no empty card) so the page degrades gracefully if
moderators only curate one or two surfaces.
Each section's skeleton respects the dependency chain that gates its
underlying query: campaigns wait on useCampaignModeration, groups on
useOrganizationModeration (because useFeaturedOrganizations is
internally gated on it), pledges on usePledgeModeration. While those
are still resolving the section renders skeleton cards rather than
flashing an empty state.
Drop the unmoderated community grid, the Pending/Hidden moderator
sections, the 'Your campaigns' shelf, and the campaign-search
toolbar from the home page. All of that now lives on /campaigns/all
where viewers actually expect to browse and filter.
Add browseAllGroups and browseAllPledges to campaigns.home in all
16 locales so each section can link out with locale-appropriate copy.
useFeaturedOrganizations is internally gated on moderationReady — while
the organization moderation labels are loading, the underlying query
is disabled and reports isLoading: false / data: undefined. The Groups
page was using only that isLoading flag to decide whether to show the
skeleton, so during the moderation-loading window it rendered the
empty state for a moment before the curated grid popped in.
Track moderation readiness alongside the featured query and treat any
of the three states — moderation not ready, featured query in flight,
featured data not yet defined — as loading.
The Groups page was firing a global kind-34550 query through
useDiscoverCommunities, rendering the full results, then filtering
them client-side for the 'agora' client tag. This produced a brief
flash of unrelated communities before the curated set settled.
Drop the client-side Agora-tag filter entirely and stop using the
all-communities fetch for the idle render path. The unified Groups
section now renders moderator-featured groups directly, gated on
useFeaturedOrganizations's own loading state, so the page goes
skeleton → curated grid with no intermediate render.
useDiscoverCommunities is still called for moderators only — it
feeds the Hidden collapsible section and the hidden-count badge on
the toolbar. Non-moderators no longer trigger the global fetch at
all.
Campaigns, Groups, and Pledges each previously stacked a Featured
shelf above an All-X section. Collapse them into a single section
titled simply 'Campaigns' / 'Groups' / 'Pledges' that:
- Idle (no query, no sort, no country) shows the moderator-featured
grid. If nothing is featured yet, falls back to the chronological
all-X grid so the page is never blank.
- Active (the user typed, picked Top/New, or chose a country) shows
the full result set, ranked or chronological per the toolbar.
The shared toolbar drops the 'default' sort option from its dropdown
(now only Top and New). Clicking an already-active sort returns the
page to the curated idle view, giving users a clear exit affordance
now that 'default' is no longer an explicit menu choice.
Personal shelves (My pledges / My groups / Your campaigns) stay
above the unified section as separate, user-scoped lists.
Anchor the recovery scan's default start height one block before the
earliest known affected transaction (the original $500 send 9fb78657…,
mined in block 951431) instead of a rolling 30-day lookback. No affected
output can predate that block, so this covers every stranded payment
while keeping the scan bounded. defaultFromHeight is now always defined,
so the page prefills the input on mount.
Adds a 'Double-tweak SP Fix' option that rescues silent payments stranded
on-chain by the historical double-tweak bug, where outputs landed at
Q = taproot_tweak(P_k) instead of P_k and were invisible to the normal
scanner.
- recovery.ts: scans indexer tweaks for taproot_tweak(P_k) candidates,
matches them against the block UTXO set, and builds/signs a sweep that
spends them with taprootTweakPrivKey(b_spend + t_k).
- useHdWalletDoubleTweakRecovery: in-memory range scan + match reporting,
no NIP-78 persistence (recovered coins are swept immediately).
- WalletDoubleTweakFixPage at /wallet/double-tweak-fix: scan controls,
recoverable total, and a one-tap sweep into a fresh BIP-86 address.
- Wired into the legacy recovery hub and AppRouter.
English strings only; other locales fall back to English at runtime.
When spending a previously-received silent-payment UTXO, its signing scalar
d_k contributes to the BIP-352 input sum A. The recipient's indexer rebuilds
A by lifting each input's on-chain x-only key to even-Y, so the sender must
contribute the even-Y-normalized scalar (-d_k when d_k·G is odd-Y). SP inputs
were passed with isTaproot:false, which skipped that negation, so for odd-Y
d_k the output landed at a key the recipient never derives and the payment
was invisible.
Pass SP inputs with isTaproot:true so deriveSilentPaymentOutputs applies the
even-Y normalization. Input signing is unaffected (BIP-340 handles parity in
signSpUtxoInput, which derives its own d_k). Add a sender→receiver round-trip
regression test covering an odd-Y SP input.
The BIP-352 sender derived the correct output key P_k but then passed it
through btc.p2tr(), which treats its argument as a Taproot internal key and
applies the BIP-341 TapTweak again. The on-chain output was therefore
taproot_tweak(P_k) instead of P_k, a key the recipient's scanner never
derives — so Agora-built silent payments were unspendable/undetectable by
the recipient.
Write the SP output script as the raw OP_1 push32 <P_k> program via
spP2trScriptPubKey, and fix encodeP2TR to encode the key verbatim (tr
output script) rather than re-tweaking it.
The headline ‘$X.XX pending’ badge reads Blockbook's account-level
`unconfirmedBalance`, which captures mempool credits to *any*
xpub-derived address — including a freshly-advertised receive address
with no prior confirmed history. The Transactions accordion, however,
only attributes a tx to the wallet if its inputs/outputs touch an
address in the `tokens=used` set Blockbook returns, and that set may
omit addresses whose only activity is mempool-only. The result: the
headline updated, but the row never appeared below.
Pre-derive the next 20 receive + 20 change addresses past
`firstUnusedIndex` on each chain and fold them into `ourAddresses`
inside `buildHdTransactions`. Cheap (one HMAC-SHA512 per address) and
adds no network traffic.
Also teach `TxRow` to render pending state explicitly: spinning
`RefreshCw` + orange ‘Pending’ label in place of the date, mirroring
the headline `PendingBadge`. Reuses the existing
`wallet.tx.pending` i18n key so no locale changes are needed.
Bookmark / Add to list / Add to sidebar don't map onto kind 33863
campaigns (addressable, with dedicated UI), so hide them when the
menu is opened from a campaign. Relabel "Mute Conversation" to
"Mute Campaign" in the same context.
resizeImage.ts and ImageCropDialog.getCroppedBlob were doing the same
five-step pipeline (decode -> optional crop -> downscale -> encode ->
File wrap) with mildly different defaults, so quality decisions had to
be made in two places. Adding the PNG-vs-JPEG comparison to crops, or
adjusting JPEG quality across the app, meant editing both.
Consolidates into a single encodeImage(source, options) in
@/lib/resizeImage:
- source can be File | Blob | string (URL); the helper fetches/decodes
- crop is an optional source-pixel rect (full image when omitted)
- maxOutputSize caps the long edge; 0/undefined means no cap
- compareFormats encodes both JPEG and PNG and returns the smaller
- passthroughIfWithinBounds short-circuits the re-encode for files
already within the cap and not being cropped
- returns { file, dimensions } with the correct mime/extension
resizeImage(file) is now a one-line wrapper preserved for existing
callers (ComposeBox, ImageUploadField).
ImageCropDialog.getCroppedBlob is gone; the dialog calls encodeImage
directly and now emits a File (JPEG or PNG, whichever is smaller)
instead of a hardcoded JPEG-only Blob. JPEG quality drops from 0.92 to
the lib default of 0.85 so cropped covers match the rest of the
upload pipeline.
The onCrop contract changes from (Blob) => void to (File) => void.
Updated both consumers (CoverImageField, ProfileSettings) — neither
needed the manual 'new File([blob], ...)' wrapping anymore.
CoverImageField unconditionally capped the crop canvas at 1600px on the
long edge, which was right for the default 'compressed' setting but
silently overrode the user's choice when they had opted into 'original'
via Network Settings. Other upload paths (ComposeBox, ImageUploadField)
already respect this preference; campaign/action banners were the only
holdout.
Now: still crop to the configured aspect (that's a framing decision, not
a quality knob), but only pass maxOutputSize through when imageQuality
is 'compressed'. Users on 'original' get a full-resolution JPEG at q=0.92
from the cropped region, capped only by the natural source dimensions.
CoverImageField now routes every file-input pick and drag-drop through
ImageCropDialog instead of uploading the raw source. The crop is locked
to 3:1 by default (banner aspect, matching the dropzone preview) and
capped at 1600px on the long edge, so a multi-megapixel phone photo
ends up well under a megabyte instead of a multi-megabyte JPEG.
ImageCropDialog gained an optional maxOutputSize prop that downscales
the canvas via the 9-arg drawImage form with high-quality smoothing.
The default is no cap — preserves ProfileSettings behavior for callers
that haven't opted in.
Template clicks and direct URL paste still skip the crop dialog; those
are already-finalized URLs we don't own.
useLoggedInAccounts runs its own kind-0 query with a hard 1.5s relay
timeout. When that comes back empty (slow relay, cold pool), every login
gets metadata: {}, the AccountSwitcher avatar drops through to the
AvatarFallback, and genUserName() returns the literal 'Anonymous' — so a
logged-in user sees an 'A' placeholder instead of their picture, even
though the rest of the app (which uses useAuthor) shows the right
profile.
Layer useAuthor on top of the existing currentUser. useAuthor is seeded
from IndexedDB and shared with every other consumer of the user's
kind-0, so the avatar now picks up cached metadata immediately and stops
showing the 'A' fallback on logged-in sessions.
When a scanned QR or pasted BIP-21 URI carries both an on-chain address
and an sp= silent-payment parameter, the recipient input now surfaces
both as separate rows in a Popover dropdown so the donor explicitly
picks privacy (sp1) vs. compatibility (bc1) — matching how Ditto's send
dialog handles the same ambiguity. Refocusing or clicking the input
while it still contains a URI reopens the dropdown so the choice can be
changed without retyping.
Picking a row swaps the input out for a chip showing the chosen kind,
a truncated address, and an X to return to the input view. Bare bc1
or sp1 input still resolves directly, and single-option scans (URI with
only one valid candidate, bare address, bare sp1) bypass the dropdown
and go straight to the chip.
QR scanning moves into the picker, so the dialog no longer needs its
own scanner dialog or BIP-21 routing logic. The picker only supports
bc1 and sp1 destinations — pasted npub/nprofile is silently ignored
(no account search), matching Agora's narrower scope vs. Ditto.
The campaign donate flow used to pass two props (bc1 + sp1) and the
dialog rendered a swap toggle under the input. With the dropdown now
handling that choice natively, the toggle is gone and the campaign
page just builds a combined bitcoin:bc1?sp=sp1 URI as the prefill.
The NIP-73 external content page recognized bitcoin:tx:<txid> and
bitcoin:address:<addr> identifiers (parsed and titled correctly), but
ExternalContentPage never rendered a body for them — visitors arriving
from a wallet transaction row just saw the 'Bitcoin Transaction'
header with nothing beneath it.
Port BitcoinTxHeader and BitcoinAddressHeader from Ditto: confirmed/
unconfirmed status, block/size/fee/amount stats, mempool.space-style
inputs-to-outputs flow, address balance hero with sats + USD,
recent-transaction list, and a footer link out to mempool.space. The
backing useBitcoinTx and useBitcoinAddress hooks compose Agora's
existing fetchTxDetail / fetchAddressData / fetchAddressTxs helpers
against the configured esploraApis from AppContext, and share the
spot price with useBtcPrice so the page doesn't double-fetch.
The Send dialog used to print 'Available: $X.XX (Y sats)' below the
recipient field once the amount exceeded the balance. That left the
Send button reading 'Send Bitcoin' (disabled) with a separate footnote
the user had to notice and connect to the disabled state.
Move the signal onto the button: it now reads 'Not enough Bitcoin'
when the amount + estimated fee exceeds the available balance, and the
standalone availability line is gone.
The qr-scanner library spins up its ZXing decoder inside a Web Worker
created from a blob URL. Our CSP allowed scripts and connections but
not workers, so the browser silently blocked worker creation — the
camera opened fine (media-src is permissive) but no frame was ever
decoded, leaving the user pointed at a QR code that never registered.
Add 'worker-src self blob:' and 'child-src self blob:' (the latter
covers older browsers that fall back to child-src for worker policy)
to match the directives Ditto already ships.
Regression-of: bae49e61
The receive address advances automatically when funds are detected, so
exposing a manual "next address" affordance is redundant and lets users
needlessly skip ahead in the derivation chain. Drop the RefreshCw button
to the left of the BIP-21 copy row and the now-unused
wallet.receiveDialog.newAddress key across all locales.
The recipient input on /wallet's Send dialog no longer:
- Shows a "Recipient" label above the field.
- Lists "npub…" in its placeholder (now just "bc1…, sp1…").
- Searches Nostr profiles by name as the user types, or renders a
dropdown of matching accounts.
- Shows a search icon inside the input.
Pasted/scanned NIP-19 identifiers (npub1…, nprofile1…) still resolve
to a Bitcoin address via the existing `resolveRecipient` path, and
the resolved profile chip still renders below the input so the
sender can confirm the destination — only the autocomplete UI is
gone.
The walletSend.recipient.label i18n key is removed from every locale.
The useSearchProfiles dependency on this component is dropped; the
hook stays for other callers (mention autocomplete, search page,
etc.).
- Drop the "≈ N sats" line that sat under the dollar amount. The
USD figure is the source of truth in this dialog; the sats
conversion was visual noise.
- Drop the "Network fee" label and move the fee-tier popover under
the Send button, centered. With only one popover in the dialog,
the label was redundant and the row above the Send button was
competing with the recipient input for attention.
- Remove the now-unused walletSend.approxSats and walletSend.networkFee
i18n keys from every locale.
The "Sending to a raw Bitcoin address." / "Sending to a Nostr
user's on-chain address." / "Sending via a silent payment…"
muted-text line below the recipient input is gone. The recipient
chip already shows who's being paid, and the soft amber privacy
disclaimer covers the raw-address case, so the extra status line
was just noise. The three now-unused i18n keys are removed from
every locale.
The disclaimer shown when sending to a bare bc1… address now uses
the existing `soft` amber tone instead of the destructive red one,
and no longer requires ticking an acknowledgement checkbox. The
checkbox-gating made the Send Bitcoin button appear permanently
disabled to users who hadn't noticed (or hadn't scrolled to) the
checkbox.
The `bitcoinPublic` disclaimer component already supported both
tones — only the Send dialog's wiring changes here. The unused
`walletSend.errors.acknowledgePrivacy` string is removed from
every locale.
The recipient input on /wallet's Send dialog now has a camera button
that opens a QR scanner. Bitcoin BIP-21 URIs are parsed and the
silent-payment fallback (?sp=) is preferred when present, falling
back to the on-chain address otherwise. Plain addresses, sp1… codes,
npub, and nprofile values are dropped into the input verbatim and
resolved by the existing recipient logic.
QrScannerDialog is a standalone component (ported from Ditto) that
owns the camera lifecycle via getUserMedia and the qr-scanner npm
package. It surfaces failure modes (insecure context, denied
permission, no camera, busy camera, overconstrained, ready timeout)
instead of a silent black screen, and offers a flash toggle when the
device supports it.
Android needed an explicit CAMERA permission in the manifest; iOS's
existing NSCameraUsageDescription string was extended to mention QR
scanning. No Capacitor camera plugin is required — the standard web
APIs work inside WKWebView and Android's WebView.
The campaign banner previously stretched edge-to-edge with
`object-cover` and a fixed aspect ratio (4:3 mobile, 21:9 sm, 3:1 lg),
which cropped landscape banners hard left/right on phones and clipped
any baked-in text near the image edges.
Replace it with a full-bleed banner that respects the image:
- Sharp foreground uses `object-contain` capped to the same
`max-w-6xl` reading column and `max-h-[70vh]`. The banner's
height is dictated by the image, never by an arbitrary aspect
ratio. Source pixels are never cropped.
- The bleed gutters around the contained image are filled by a
blurred, scaled-up copy of the same image (`object-cover scale-110
blur-2xl brightness-75`) on a `bg-black` base, with a soft
horizontal vignette (`from-black/55 via-transparent to-black/55`)
so the bleed recedes and the centered image reads as the subject.
- A subtle `shadow-lg shadow-black/25` lifts the banner off the
page below.
- Clicking the banner opens the cover in the shared
`Lightbox` from `@/components/ImageGallery` — same fullscreen
component already used by NoteContent, NoteCard, PostDetailPage,
etc. (portal, body-scroll lock, keyboard nav, swipe, pinch zoom,
Capacitor-safe download).
Move the back / edit / delete chrome to behave differently per
breakpoint. On mobile (where the banner image fills nearly the full
viewport width and the bleed gutters are too narrow to safely host
overlays) the controls live in a black band above the banner. On
`sm:` and up they overlay the banner inside the same `max-w-6xl`
column as glass chips. The black band wrapper collapses to zero
height on desktop so the banner sits flush with the page top.
Add a new `campaignsDetail.openCover` translation key (aria-label
for the cover button) to en.json and translate it into all fifteen
non-English locales.
The campaign detail hero overlaid the title, summary, byline, country/
deadline meta, and PostActionBar on top of the banner image, behind an
80%-tall bottom scrim. Two failure modes on every breakpoint:
- Banner images often carry baked-in text or branding. The scrim
covered the bottom 80% of the image, and `object-cover` cropped
the rest hard on phone-portrait containers (mobile used
`min-h-[92svh]`, roughly 1:1.6 portrait), so banners with side-
aligned text were sliced off.
- Long titles and tl;dr summaries overflowed the fixed-height hero.
With `overflow-hidden` on the header the top of the title got
clipped behind the upper edge of the banner.
Split the hero into two components. `CampaignHero` keeps only the
banner image and the floating back/admin chip buttons; aspect ratios
stay landscape at every breakpoint (4:3 mobile, 21:9 sm, 3:1 lg) so
`object-cover` only trims a thin strip top/bottom. The top scrim is
gone — the chip buttons already carry `bg-black/30 backdrop-blur-md`
backdrops that read on any image without darkening the banner.
`CampaignHeading` renders the title, summary, byline, meta, divider,
and `PostActionBar` in the normal page flow inside the existing
`max-w-6xl` column on `bg-background`. Title and summary no longer
need a text-shadow or line-clamp; the action bar drops the glass-chip
overrides and inherits its default styling against the page surface.
The Trans component's values={{ count: formatNumber(N) }} was spreading
after the numeric count prop, overriding it with a string. i18next v26
requires a numeric count for plural resolution — a string causes it to
return the raw key path (e.g. 'campaignsDetail.repost') instead of the
resolved plural form.
Rename the interpolation variable from count to formattedCount so it no
longer collides with the count prop that Trans uses for plural selection.
Update all 16 locale files to use {{formattedCount}} in the repost, quote,
and like translation strings.
Regression-of: 86a084f3
The Trans component's values={{ count: formatNumber(N) }} was spreading
after the numeric count prop, overriding it with a string. i18next v26
requires a numeric count for plural resolution — a string causes it to
return the raw key path (e.g. 'campaignsDetail.repost') instead of the
resolved plural form.
Rename the interpolation variable from count to formattedCount so it no
longer collides with the count prop that Trans uses for plural selection.
Update all 16 locale files to use {{formattedCount}} in the repost, quote,
and like translation strings.
Regression-of: 86a084f3
When FollowPage was removed in the unreachable-page cleanup, the
QR code on the profile kept encoding `${origin}/follow/<npub-or-nip05>`,
which falls through to NotFound because there is no `/follow/...`
route. Encode the bare identifier instead — Agora's universal
NIP-19 dispatcher at /:nip19 already resolves both npub and
`user@domain.com` to ProfilePage.
Regression-of: b975e557
The Create Campaign page initialised `walletSource` to `'custom'` and
relied on a post-mount effect to flip it to `'mine'` once
`hdWalletAvailable` became true. With Radix Select's controlled value
that left users staring at the "Choose a wallet" placeholder on the
initial paint and forced them to open the dropdown to see that their
Agora wallet was even an option.
Seed both `walletSource` and `mineAccept` lazily from the
synchronously-available HD-wallet availability so the trigger already
reads as the user's Agora wallet on first render. The existing
availability-change effects still cover the (rare) case where the hook
resolves a tick later or where the wallet disappears mid-session.
Opens the HD wallet's send dialog prefilled with the campaign's on-chain
address — same flow used at /wallet, so donors don't have to learn a
second send UI. Sits above 'Open external wallet' inside the donate
panel; the external button downgrades to the secondary variant when
both are present so only one orange CTA stacks.
When the campaign declares both an on-chain (bc1…) and a silent payment
(sp1…) endpoint, a swap link appears under the privacy disclaimer so
donors can flip to SP without leaving the modal. The toggle hides
itself if the donor manually edits the recipient field, so we never
trash their typed input.
Gated on useHdWalletAccess (nsec logins only) — extension and bunker
logins fall through to the external-wallet QR. The button is also
hidden for SP-only campaigns (no on-chain address to prefill) and for
the campaign owner themselves.
Add Kosovo (XK) and Western Sahara (EH) to the country list. Kosovo
has no Unicode emoji flag, so it follows the Tibet pattern with a
bundled SVG asset that CountryFlag swaps in.
Surface Tibet (CN-XZ) as a search-list entry so it can be picked from
country autocompletes and pickers. The on-wire identifier stays
iso3166:CN-XZ; only the picker pretends.
Route every remaining raw country.flag span through CountryFlag so
bundled SVGs render in autocomplete dropdowns, organizer selects, the
ComposeBox destination switcher, and the world stats dialog.
Picked the noun each locale uses in its existing last3h / last24h
preset translations so the standalone label stays consistent with
the dropdown options it sits underneath.
The Math.max(resume, target) clamp inside resolveWindowFromHeight
turned the Since dropdown into a no-op whenever the wallet's
scanHeight had caught up to the chosen window. Picking 'Last week'
one minute after a successful scan resolved to scanHeight + 1, not
to a week ago, so the user couldn't actually rescan history from
the primary control — they had to drop into Advanced -> From block.
Removed the clamp so Since presets honor the wall-clock window
literally. Re-scanning blocks we've already scanned is cheap (the
indexer is just iterating tweak data) and is exactly what the user
asked for. Also dropped the isPresetUpToDate predicate that, after
the original clamp landed, was the mechanism by which Start went
disabled after a scan completed. isManualUpToDate is kept so the
override path still flags 'block number past the tip'.
Regression-of: 38946cbc
Adds a Custom… entry to the Since select that, when picked, reveals
an hours number input directly under the select. The hours value is
parsed as a positive (fractional allowed) number, multiplied to
seconds, and fed through the same mempool.space resolver as the
fixed presets. The Start button stays disabled until a valid hours
value is entered so there's no ambiguous empty-equals-zero submit.
Power users who want sub-hour granularity (e.g. 0.5) get it without
having to drop into the Advanced block-height path.
Bitcoin block timestamps obey BIP-113's median-time-past rule — they
must exceed the median of the previous 11 timestamps but don't have
to be strictly monotonic. Empirically, 1-2-block inversions show up
in the live chain regularly; 3-block rewinds covered the common case
but could still miss a payment landed in a block whose timestamp ran
backwards near the user's selected cutoff. 11 is the principled
upper bound (no inversion can extend further under MTP) and costs
only ~8 extra block fetches per scan.
Regression-of: 46d9952a
mempool.space's timestamp-to-block endpoint is the sole source of
truth now. The Blockbook binary search fallback was sequential and
could stall the UI for ~20 round-trips with no progress feedback
when mempool.space was down. When the lookup fails, surface a toast
that points the user at the Advanced -> From block override and
auto-open the disclosure so the escape hatch is immediately visible.
Regression-of: d6e6d616
Two small fit-and-finish tweaks the previous restyle missed:
1. The tab strip's baseline border sat 1px above the rounded panel's top
edge, so the panel's rounded corners visually overlapped the active
tab's under-rule. TabsContent now carries an explicit mt-4 (16px) so
the panel floats cleanly under the tab strip with breathing room
above its rounded top corners.
2. Tab labels were spaced gap-1 (4px), too tight for labels that read
as section headers. Bumped to gap-8 (32px) so 'Comments & donations'
and 'Ledger' read as separate section titles.
CommentsSection's outer mt-4 is overridden with className=mt-0 at the
call site so the TabsContent gap controls the spacing — no more 32px
double-margin.
The shadcn default 'muted pill control' tab strip felt bolted onto the page
between the action bar and the comments panel. The tab labels also doubled
up with the 'Comments & donations' heading rendered inside the panel — two
section titles stacked.
Replaces the pill control with an underline-style tab strip that visually
serves as the section header for the panel below. The active tab label is
rendered at the same size and weight as the page's other h2 section
headings (text-lg font-semibold tracking-tight), inactive tabs are muted
siblings, and a 1px baseline border carries across the panel width while
the active tab paints a thicker primary under-rule that flows into the
content surface.
CommentsSection's title becomes optional and the campaign page omits it
now that the tab label owns the heading. The dead 'commentsAndDonations'
i18n key is removed from all 16 locales (tabComments carries the same
copy).
Flips the headline + subline on each ledger row: USD is now the prominent
top number and the BTC equivalent (full units, not sats) sits below it in
muted text. Falls back to BTC-as-headline when the BTC/USD price isn't
available yet. Renames the i18n leaf 'campaignsDetail.ledger.satsUnit' to
'btcUnit' across en.json and all 15 other locales — BTC is a universal
ticker, so the unit string itself is now 'BTC' everywhere.
Moves the existing comments + donations panel under a 'Comments & donations'
tab and introduces a sibling 'Ledger' tab that surfaces public on-chain
activity for the campaign's bc1 address — receives, sends, confirmation
status, block height, and the equivalent USD — sourced from the
already-configured Esplora endpoints with mempool.space deep links per row.
The Ledger tab is rendered only when the campaign declares an on-chain
endpoint ('bc1q…' / 'bc1p…'). Silent-payment-only campaigns intentionally
have no scannable address and degrade to the un-tabbed comments surface to
avoid showing a lone disabled tab.
A new fetchAddressTxs helper in src/lib/bitcoin.ts wraps Esplora's
/address/:addr/txs + /chain/:last_seen_txid pagination, and useAddressLedger
exposes it as an infinite query (50 confirmed tx page size, plus mempool on
the first page).
Sits in the Bitcoin Donations chapter right after 'Are donations on
{{appName}} public?', so the natural flow goes from how donations
work to where they show up to why some take a while to confirm.
Translated into all sixteen locales.
The campaign detail already verifies each donation receipt on-chain
and knows whether the underlying Bitcoin tx is confirmed, but the
flag was being dropped before it reached the UI. Mirror the wallet's
treatment so visitors can see funds that are inbound but still in
the mempool.
Three changes:
* Extract PendingBadge (orange + spinning RefreshCw) and use it on
both the wallet headline and the campaign donate column. The
wallet's inline JSX is replaced with the shared component.
* Widen useCampaignDonations to expose pendingSats (the mempool
delta from Esplora) and a confirmedByTxid lookup built from the
already-verified receipts.
* DonateColumn now renders the pending sats under the raised total
and DonorPreviewList swaps the relative timestamp for the badge
on any row whose underlying tx is still unconfirmed.
CreateActionPage, CreateCommunityPage, and CreateEventPage each
showed a dead-end 'Back to pledges' / 'Back to groups' / 'Back to
events' link as their only logged-out CTA. Mirror the campaign-gate
fix and render LoginArea instead so visitors can join or log in
without leaving the page, and drop the three now-unused i18n keys
from every locale.
Regression-of: 2870d7a6
The gate previously offered only a 'Go home' link, leaving visitors
to find the login flow themselves. Render the existing LoginArea CTA
in its place so the 'Join' / 'Log in' modal opens directly from the
gate, and drop the now-unused campaignsCreate.goHome string from all
sixteen locales.
Wraps the title/toolbar row with flex-col on mobile so the search input,
filter dropdown, and country picker no longer cram next to the section
title at narrow widths. The toolbar also lets the search input grow to
fill the row on mobile (flex-1) while keeping its compact 16rem width
from the sm breakpoint up.
Applies to /groups, /pledges, and /campaigns/all — every place the
DiscoverySearchToolbar sits on a section heading row.
The brand mark in the top nav was rendered in the default UI font
(Inter Bold at text-lg). Swap it for the same typographic recipe the
CampaignsPage hero uses — Bebas Neue (font-display), uppercase, tracked,
with a 0.022em currentColor text-stroke that fattens the weight-400
letterforms without the fuzz a synthetic-bold would produce. The size
is bumped to text-3xl and the bolt icon to size-9 so the wordmark reads
as a deliberate logo lockup instead of a piece of UI chrome.
Two deviations from the hero recipe, tuned for the nav-bar context:
- The hero uses Bebas Neue's native italic (~12° skew). Here we
render the roman face and apply a softer skewX(-6deg) transform.
Sharp italics at nav-bar size start to read as a glitch rather
than personality; a gentle oblique keeps the family identity
without sacrificing legibility next to the four right-leaning
nav links.
- A scaleX(1.1) widens the letterforms slightly. Bebas is naturally
tall+narrow, which gets visually crowded at this size next to the
chunky bolt mark — the horizontal scale rebalances the proportions
so the wordmark sits as a peer to the icon instead of receding.
transform-origin: 0 100% anchors the skew to the baseline so the icon-
to-text relationship stays stable, and -ml-0.5 tucks the wordmark
tight against the bolt to read as a single lockup.
While here, change the active page indicator in the same nav from
text-foreground (white in dark / black in light) to text-primary so
the current page reads in brand orange. The mobile drawer's active
state already had a bg-primary/10 wash but kept text-foreground on
top; switch its text to text-primary as well so the orange wash and
the orange label reinforce each other instead of fighting.
Every caller wraps the grid in a `max-w-7xl mx-auto px-4 sm:px-6`
page container that already supplies the page-edge gutter — matching
the Campaigns and Pledges discovery pages. CommunityGrid then added a
second `px-4 sm:px-6` of its own, which every caller defensively
overrode with `className="px-0"`. The override only neutralized the
base utility: tailwind-merge happily kept the responsive variant, so
the `sm:px-6` survived. The result was group cards inset 24px further
than campaign and pledge cards at every `sm` breakpoint and up.
Drop the internal padding from CommunityGrid and remove the redundant
`px-0` overrides on the eleven CommunitiesPage callers. The page
container is now the single source of truth for horizontal padding on
all three discovery surfaces.
A community is its own scope — narrowing groups by country isn't a
useful axis the way it is for campaigns and pledges (which are about
local action). Drop the country prop from CommunitiesPage's
DiscoverySearchToolbar call so the Globe button doesn't render at
all there. Campaigns and Pledges keep theirs.
The pledges page already had a Globe-icon country popover sitting next
to a sort dropdown and the relay-driven search toolbar. The three
side-by-side filter affordances duplicated each other and pulled focus
away from the section heading. Collapse the cluster into the shared
DiscoverySearchToolbar:
- Drop the page-local Recent / Bounty / Deadline sort dropdown on
pledges. The toolbar's Default / Top / New sort, backed by NIP-50,
already covers the same ground and applies to every discovery
surface; pledges fall through to a newest-first chronological view
inside each lifecycle section.
- Add the country picker as a first-class prop on
DiscoverySearchToolbar (`country` + `onCountryChange`) so all
three discovery pages share the same Globe button without a generic
trailing-slot escape hatch. Brand-orange icon in the neutral state,
country flag emoji when selected.
- Extend useNip50Search with an optional `iTags` array, forwarded as
a standard `#i` filter alongside the `search` field. A non-empty
iTags array also activates the hook so picking a country with no
typed query produces a country-scoped grid, narrowing kind 34550 /
36639 by NIP-73 external identifier.
- Extend useAllCampaigns with a `countryCode` parameter that adds
the same `#i` filter to the relay query. AllCampaignsPage tracks
the country on the URL alongside the existing `sort` and `q`
params so country-scoped views are linkable.
- Brand-orange the ListFilter icon on the toolbar to match the
pledges-page aesthetic the user wanted to spread to the other
discovery pages.
The DiscoverySearchToolbar (search input + sort + show-hidden) was
duplicating affordances the dedicated /campaigns/all page already
provides. The home page is now a curated landing: hero, featured row,
community grid, and moderator/creator sections.
The three discovery pages (Campaigns, Pledges, Communities) already
pass sortMode and getKeywordHaystack to useNip50Search, but the hook
only supported 'top' / 'new' and did no client-side keyword filter —
so 'default' fell back to 'new' (a chronological feed of the entire
kind on first paint, not the curated layout the page actually wants)
and queries for kinds whose human-readable title lives in tags
(33863 campaigns, 34550 organizations, 36639 pledges) silently
returned nothing whenever the relay's NIP-50 index only indexed
'content'.
Extend Nip50Sort with a 'default' variant: empty-query + 'default'
deactivates the hook so the page renders its featured/curated layout,
while a typed query still issues a chronological search. 'top' and
'new' keep their existing semantics; 'new' now also drives an empty-
query chronological feed (previously only 'top' could). Plumb the
new option through DiscoverySearchToolbar as a LayoutGrid-iconed
segmented button and adjust the active-filter detector.
Add an optional getKeywordHaystack callback that lets callers supply
per-event strings (title / name / summary tag values + content) for
a case-insensitive substring re-filter over the relay response. This
costs a small amount of recall (we still rely on the relay to surface
the candidate) but fixes the 'search returns nothing' failure mode
for tag-titled kinds without changing the relay protocol or pinning
to a different index.
Regression-of: c61e9a06
The moderation menu / overlay / review-queue refactor wired pledges
into the same shared moderation components as campaigns and groups,
and three call sites (ActionsPage, ModerationMenu, ModerationOverlay)
imported usePledgeModeration from @/hooks/usePledgeModeration. The
hook file itself was never staged, so the tree didn't build and any
fresh clone would have failed tsc at those imports.
Add the hook (two-axis model — hide + featured, no approval gate, same
agora.moderation namespace and Team Soapbox moderator pack as
campaigns/organizations) and document the kind 36639 surface in
NIP.md alongside the existing 33863 / 34550 entries so the spec
matches the implementation.
Regression-of: c61e9a06
The /pledges page introduces large bold section headers above its grid
("text-2xl sm:text-3xl font-bold tracking-tight" + muted tagline +
right-side controls). /campaigns/all had no section heading at all, and
/groups was using the smaller SectionHeader pattern ("text-base
font-semibold") for its 'My groups' and 'Featured groups' shelves —
even though its own search-mode branch already used the large-bold
pattern. Three pages, three different section-header treatments.
Aligns both pages with the /pledges style:
- /campaigns/all: add a heading above the grid that switches between
Search / Top / New labels based on toolbar state, with a constant
tagline ("Browse every cause on the network").
- /groups: replace the SectionHeader usages on the My groups / Featured
groups shelves with the same large-bold heading + tagline pattern;
drop the now-unused SectionHeader import. Inner shelves
(CommunityGrid, EmptyShelf, show-more) get their internal
px-4 sm:px-6 stripped since the outer container now provides it.
- Moderator review sections use the same px-0 grid + collapsed trigger
padding inside the new wrapper.
/ (Index → Feed) keeps tabs as its visual navigation, so it doesn't
need a heading — that exception is the rule that proves the pattern.
ActionCard rendered two kebabs side-by-side: ActionShareMenu (Copy link +
owner Delete) and the shared ModerationOverlay's mod-only kebab. The two
buttons were also styled differently — the share menu had no banner
backdrop, so on photo-heavy cards it floated against the image while
the moderator kebab next to it sat in a translucent pill.
Extract ModerationMenuItems from ModerationMenu — the dropdown rows
themselves (label + items) without the trigger/content wrapper. Standalone
ModerationMenu and ModerationOverlay still work the same; cards that need
to embed moderator actions inside an existing dropdown now compose them
directly:
<DropdownMenuContent>
<DropdownMenuItem onClick={copy}>Copy link</DropdownMenuItem>
{isOwner && <DropdownMenuItem onClick={del}>Delete</DropdownMenuItem>}
{isMod && <DropdownMenuSeparator />}
<ModerationMenuItems coord={…} surface="pledge" axes={…} entityTitle={…} />
</DropdownMenuContent>
ModerationOverlay grows a showMenu prop (default true) so a card can still
get the mod-gated 'Hidden' badge in its banner corner without the
redundant second kebab when the moderation items have been embedded in
another dropdown.
Then on ActionCard:
- ActionShareMenu absorbs ModerationMenuItems under a separator, so the
card carries exactly one kebab.
- Its trigger now uses the same 'h-8 w-8 bg-background/80 backdrop-blur
text-muted-foreground hover:text-foreground' classes as the campaign
and group card kebabs so the pill is consistent across surfaces.
- The mod-only 'Hidden' badge still renders, via ModerationOverlay with
showMenu={false}.
- Adds a generic pledges.card.actionsAriaLabel ('Pledge actions')
aria-label since the kebab is no longer just for sharing — translated
into all 15 non-English locales.
Campaigns, pledges, and groups each had their own moderation kebab,
'Hidden' badge, and collapsible review sections. The three implementations
had drifted: the campaign card inlined the badge + menu while the pledge
and group cards used per-surface overlay wrappers, the pledge cards
imported the kebab without its 'Hidden' badge (so hidden pledges showed
the kebab but no visible status), pledges had no page-level Pending /
Hidden moderator queues at all, and each page reimplemented the
collapsible review section as a local component.
Consolidate everything into src/components/moderation/:
HiddenBadge — the shared destructive chip with EyeOff + 'Hidden'.
ModerationMenu — the shared kebab with an `axes` config prop, so
campaigns can request ['approval', 'hide', 'featured'] and pledges /
groups can request ['hide', 'featured']. Per-surface inner components
each mount exactly one moderation hook so a pledge card never
subscribes to the campaign label query (and vice versa).
ModerationOverlay — the absolutely-positioned wrapper that bundles
HiddenBadge + ModerationMenu in a card corner. Same gating contract:
returns null for non-moderators.
ModeratorCollapsibleSection — the shared 'Pending / Hidden' collapsible
used by CampaignsPage, CommunitiesPage, and now ActionsPage.
Migrate the three card surfaces and three index pages onto these shared
pieces, fixing two concrete bugs along the way:
ActionCard now renders a 'Hidden' badge on hidden pledges. Previously
it imported PledgeModerationMenu directly (kebab only) instead of
PledgeModerationOverlay (badge + kebab), so moderators saw the
kebab but had no inline cue that a card was hidden.
ActionsPage now has page-level Pending and Hidden moderator review
queues at the bottom, mirroring the queues already present on
CampaignsPage and CommunitiesPage. Pledges share the two-axis model
with groups, so 'Pending' here means 'no curation decision yet' —
pledges not yet featured or hidden.
useActions grows an optional `enabled` flag so the page can skip the
moderator-only review-queue query entirely for non-moderators rather
than firing it with limit:0.
Locale work: add the new moderation namespace and the pledges.list
needsReview / hidden / *Desc / *Empty keys to en.json and all 15
non-English locales (ar, es, fa, fr, hi, id, km, ps, pt, ru, sn, sw,
tr, zh, zh-Hant).
Build a single search affordance shared by Campaigns home, All Campaigns,
Communities, and Pledges so the four discovery surfaces stop having
four different toolbars (or none at all).
New shared pieces
-----------------
DebouncedSearchInput renders a controlled shadcn Input with the lucide
Search icon on the left and a clear (X) button on the right that
appears once the user has typed something. Rounded-square corners
(rounded-lg, matching the global SearchPage). Stateless — the caller
pairs it with useDebounce and feeds the debounced value into a query
hook so the same component works for URL-synced searches, in-memory
searches, or anywhere else.
DiscoverySearchToolbar wraps DebouncedSearchInput with a compact filter
button on the right that opens a popover containing:
- Top / New sort pills wired to NIP-50 'sort:top' (Top) and no sort
token (New).
- An optional Show-hidden switch — only rendered when the caller
passes a showHidden prop, so surfaces without a hidden axis
(Pledges) just hide the row.
The toolbar matches the visual idiom of the global SearchPage filter
button (40-ish-px rounded-lg square, primary-tinted border when any
modifier is active). Inline against the page background — no card
framing — so the hero anchors visual hierarchy. Fully controlled:
parent owns search / sort / show-hidden state, the toolbar is purely
presentational.
useNip50Search<T> issues a kind-scoped NIP-50 search against
nostr.group(DITTO_RELAYS). Routing through the Ditto group rather
than the default pool keeps results predictable: most non-NIP-50
relays either ignore the 'search' field (returning everything matching
the other filters) or return nothing — both modes drown the result
set in noise. Hook is generic over an event parser so each surface
parses its own kind (parseCampaign / parseCommunityEvent / parseAction).
Sort modes
----------
- new (default): no sort token in the search payload; relay returns
chronological results.
- top: appends ' sort:top' to the search payload; relay scores by
engagement. Also works with an empty keyword (sends just
'sort:top') so 'Top' alone is a valid 'most-engaged' feed.
Empty keyword + New sort is the hook's only 'inactive' state, in
which case the page falls through to its existing curated layout
(featured rows, my-groups shelf, active-pledges sections, etc.).
Per-page wiring
---------------
Campaigns home (/campaigns) — toolbar with show-hidden; results
filtered against useCampaignModeration.hiddenCoords. Empty + New
falls through to the existing Featured / Community / moderator
sections.
Communities (/communities) — toolbar with show-hidden; results
filtered against useOrganizationModeration.hiddenCoords. The hook
is lifted to the page level so search results can drop hidden
groups (or include them via the switch). The ModeratorReviewSections
subtree still calls its own copy — query results are cached so the
second call is free.
Pledges (/pledges) — toolbar without show-hidden. Pledges lack a
moderator-driven hidden axis today, so the switch wouldn't have
anything to toggle. Adding it is tracked as a follow-up.
All Campaigns (/campaigns/all) — gets a new HeroBanner +
HeroAtmosphere hero matching the Pledges / Communities shape
(kicker, two-line heading, body, glass stat pill, glass CTA, warm
hope palette), so the route looks like a real discovery surface
instead of a bare grid under a plain H1. The page also migrates
from its bespoke inline toolbar to the shared one, with a small
adapter mapping the toolbar's 'top'/'new' vocabulary to the legacy
'top'/'none' URL param so existing share links keep working.
i18n
----
New common.* keys (sortAriaLabel, sortTop, sortNew, showHidden,
filtersAriaLabel) plus campaigns.all.* hero keys (heroKicker,
heroHeading, heroHeadingLine2, heroBody, campaignsCount_one,
campaignsCount_other). Translated across all sixteen locales.
The Communities page rendered its content column at max-w-5xl while
the Campaigns and Pledges bodies use max-w-7xl, making Communities
look squeezed by comparison on wide displays. Bump Communities to
max-w-7xl so the three discovery pages share a content width.
Pin / Unpin previously rendered as a full-width header bar above every
comment, adding vertical noise to every row even when no comment was
pinned. Replace the bar with a single absolute-positioned slot in the
note's top-right corner driven by three states:
- Not pinned, can manage → 'Pin' button. Hidden until hover on
hover-capable pointers; always visible on coarse pointers (touch)
so mobile moderators can find it.
- Pinned, can manage → 'Unpin' button, always visible.
- Pinned, cannot manage → 'Pinned' badge, always visible.
- Not pinned, cannot manage → nothing rendered.
ThreadedReplyList wraps each note in 'relative group/note' so the
corner overlay positions against the note rather than the surrounding
thread, and the hover variant matches the right note. Visibility on
non-hover devices uses the [@media(hover:hover)] arbitrary variant so
the affordance doesn't depend on a hover event that touch can't fire.
The campaign detail page presented comments inside a muted, primary-
tinted card with rounded corners and retinted per-note dividers, while
the community and pledge detail pages rendered the composer and
threaded reply list bare on the page background. The result was that
the same conceptual area read as three different surfaces.
Extract the campaign-detail treatment into CommentsSection: a thin
wrapper that owns the heading + optional count chip and the muted
panel. Each detail page passes in its own composer, reply list,
skeletons, and empty state, so per-page behavior (pin headers,
campaigner badges, pledge-specific placeholder) stays where it
belongs.
Apply CommentsSection to Campaigns, Communities, and Pledges. Also
swap the campaign-detail hero's bespoke avatar/Link block for
AuthorByline variant='hero', removing the now-dead Avatar /
useProfileUrl / useAuthor / genUserName imports and the
creatorName / creatorPicture / creatorProfileUrl plumbing through
CampaignHeroProps.
Groups, Pledges, and Campaigns each shipped a different author
treatment in their card footers: groups used a raw <img> with no
fallback, campaigns had no avatar at all, and pledges localized the
'by X' string while the others left it as a hardcoded English literal.
Extract AuthorByline as the canonical author element. It uses the
shadcn Avatar primitive (initials fallback), resolves the display name
through the centralized getDisplayName helper, and links to the
author's profile via useProfileUrl. The 'by Name' label is sourced
from the shared common.byAuthor i18n key so every surface ships the
same translated string in every locale.
Inside a card, the byline renders as a <button> that navigates and
stops propagation so the outer card <Link> keeps wrapping the whole
card without nesting <a> inside <a>.
CampaignCard also picks up a localized donor count via the new
common.donors plural key, replacing the inline English 'donor' /
'donors' ternary.
Two changes to ActionsPage that landed together:
- Replace the bare Plus icon on the 'Create pledge' CTA (both the
hero button and the in-feed Section CTA) with PlusCircle so the
Pledges page matches the Communities and Campaigns CTAs.
- Replace the bespoke 'by {name}' footer on ActionCard with the new
shared AuthorByline component, dropping the now-dead useAuthor /
getDisplayName / Trans imports. The byline carries the same avatar
+ i18n'd 'by Name' label + profile-navigation behavior as every
other discovery-page card.
The Pledges and Communities heroes layer their stat pills on top of
black scrims and protest photography. The pills used bg-background/55
plus text-muted-foreground / text-primary, which in light mode rendered
as low-contrast gray text on a dark photo — the "4 pledges open right
now" counter was effectively unreadable.
Switch both pills to a translucent dark surface (bg-black/30) with
explicit white text, white/85 secondary text, theme-tinted icons
(amber-200 for Pledges, cyan-200 for Communities), and drop-shadows
matching the hero headline above. The pill now reads the same in light
and dark mode.
The kind 33863 Querying section previously told clients to aggregate
verified kind 8333 receipts for the campaign total. That undercounts
the campaign whenever a donor pays the BIP-21 QR with a native wallet
(no Nostr receipt published) — which the spec itself documents as a
supported donation path.
Document the actual implementation: the headline 'raised' amount is
cumulative chain_stats.funded_txo_sum on the on-chain `w` address,
fetched from an Esplora endpoint (default mempool.space). Kind 8333
receipts remain the source of the recent-donation list (donor pubkey,
comment, timestamp) but no longer drive the headline total.
Update the Wallet Modes UI matrix to match.
The full equirectangular viewBox (-90..+90 lat) leaves a tall empty
ocean band at the bottom of the hero on wide viewports. Antarctica's
coastline only reaches ~lat -75 around its perimeter, so the bottom
~5° of the viewBox is always empty. Trim to -85..+85 so 'slice' fills
the hero with land texture instead of empty space. No distortion —
preserveAspectRatio still scales uniformly.
Cards in a multi-column grid now have a deterministic, predictable
silhouette. The body region is a fixed stack — title (1 line, truncates)
+ summary (1 line, truncates with a non-breaking-space placeholder when
absent) + progress (invisible-bar placeholder absorbs no-goal and
silent-payment cases) + creator footer — so no card has dead space or
ragged bottoms regardless of which optional fields are populated.
Country, deadline, hidden badge, and moderation menu all moved onto the
banner image as glass chips overlaid on a bottom gradient (chips
appear only when there's data to show, so a chip-free banner stays
clean). The donor-count chip became an inline aside on the creator
line. The variable meta row is gone entirely from the body.
CampaignPrivateNotice was rebuilt to mirror CampaignProgress's
vertical footprint (invisible bar + one text row) so silent-payment
cards line up with public-progress cards beside them.
Progress bars on cards and the detail-page DonateColumn use
`bg-foreground/15` for the track instead of the primitive's default
`bg-secondary`, which in dark mode shares its color with the card
surface and made the empty portion of the bar invisible. The new
foreground-tinted track reads cleanly on the card in both themes.
- Wrap composer + comments / composer + feed in a rounded muted panel
with continuous border-primary rails.
- Composer and campaign sidebar use a near-white surface with a
brand-orange border; dark mode swaps to a near-black warm surface.
- Per-article borders and backgrounds retinted via scoped selectors
so NoteCard stays untouched globally.
- Add hideCommentContext, authorBadge to NoteCard and
renderAuthorBadge, leafCardClassName to ThreadedReplyList; move the
Campaigner badge into the author row.
- Light-mode tokens: --card to 0 0% 98%, --muted to 30 12% 94% so
cards and muted regions read as real surface steps.
- Drop the unused campaignsDetail.commentCount locale key everywhere.
The wallet derives a 24-word BIP-39 seed phrase importable into any
standard Bitcoin wallet (Sparrow, Electrum, BlueWallet, Phoenix, Trezor,
Ledger) at BIP-86 + BIP-352 paths, but the FAQ never said so. Adds a
new payments-category entry covering the export flow, the nsec-only
restriction (extension/bunker logins can't derive the seed), and the
fact that the imported wallet is the same wallet — not a transfer.
Rewrites connect-wallet to lead with self-custodial and link to the
new entry. Rewrites the activist guide's movePromptly steps so the
sweep step describes importing the seed into a desktop/hardware wallet
(rather than implying the Agora wallet isn't one you control), and the
dontSit step reframes from 'campaign address as mailbox' to 'browser
session as warm storage; seed offline = cold storage'.
Updates all 15 non-English locales in lockstep.
Updates connect-wallet to mention the wallet is self-custodial and points
at the new export-wallet FAQ. Adds export-wallet covering BIP-39 seed
backup, import into Sparrow/Electrum/BlueWallet/Phoenix/Trezor/Ledger
via BIP-86 + BIP-352 paths, and the nsec-only restriction. Rewrites the
movePromptly steps in the activist guide to describe seed export and
offline backup.
The Swahili locale (9a3f1cac) was generated against an en.json snapshot
that still contained six receiveDialog keys (onChain, silentPayment,
onChainIntro, silentIntro, noIndexer, silentBalance). Upstream
a83df0f5 had already removed those keys when it combined SP and
on-chain into a single BIP-21 QR. The rebase silently carried the
extra keys through, which the locales test caught.
Regression-of: 9a3f1cac
The selected-language indicator collapsed every BCP-47 tag down to its
base subtag before comparing against the supported list, so picking
'zh-Hant' set i18n.language to 'zh-Hant' (and the UI strings switched
to Traditional Chinese as expected) but the checkmark moved to the
'zh' (Simplified) row because the comparison only saw 'zh' on both
sides.
Match in three passes instead:
1. Exact case-insensitive match against SUPPORTED_LANGUAGES — keeps
'zh-Hant' checked when active.
2. Alias map for zh-TW / zh-HK (registered as resource aliases in
i18n.ts) so device locales from Taiwan and Hong Kong land on the
zh-Hant row in the switcher.
3. Base-subtag fallback for en-US -> en, pt-BR -> pt, etc., preserved
from the original behavior.
The scan now always runs to the indexer tip. Users can hit Cancel to
stop mid-scan, so a configurable upper bound just complicates the UI
for a case nobody hits in practice.
Standard / Kiswahili sanifu (East African Standard) — the variety
used in BBC Swahili / VOA Swahili / DW Kiswahili news coverage,
intelligible across Tanzania, Kenya, Uganda, Rwanda, and eastern DRC.
Register: formal-conversational, not bureaucratic, not academic.
Reaches the WLC governance roster's East African leadership (Rwanda,
DRC, Burundi, and the broader ~230M-speaker activist audience).
Key choices:
- pochi for crypto wallet (modern Swahili tech vocab)
- mfadhili / wafadhili for donor(s), mwanaharakati / wanaharakati for
activist(s), mchango / michango for donation(s), kampeni for campaign
- relei (transliteration) for Nostr relay
- malipo ya kimya for silent payments, iliyogawanywa for decentralized,
inastahimili udhibiti for censorship-resistant
- Polite 2nd person wewe; m-/wa- and n-/n- noun classes used
consistently for actor/wallet concord
- heroTagline avoids the orphan-period bug: trailing period sits
inside <0>...</0> as in the English source
Sub-agent flagged several judgment calls (above-ground activism,
bleeding-edge, push notifications, honeypot metaphor) where the
English idiom doesn't carry across cleanly — these are v1 choices
that East African native speakers may want to refine. Treat this
locale as 'shippable v1, expect refinement from user feedback', same
quality bar as the other African / South-Asian locales we've shipped.
Key parity verified: 1677 leaves, same as en.json, zero missing or
extra, all {{placeholders}} and <N>markup</N> tokens preserved.
DialogContent is a CSS grid container, and grid items default to
`min-width: auto` — i.e. the intrinsic content width. Long
unbreakable strings (e.g. a combined BIP-21 `bitcoin:<bc1>?sp=<sp1>`
URI in the wallet receive dialog) refuse to shrink, blow past
`max-w-lg` / `w-[calc(100%-2rem)]`, and the `flex-1 min-w-0 truncate`
pattern on the inner span never engages because the parent already
accepted the full intrinsic width.
Adding `[&>*]:min-w-0` to the grid container fixes truncation for
every dialog without touching individual call sites.
Mirrors CampaignWalletDonatePanel's payload format
(`bitcoin:<bc1>?sp=<sp1>`) so BIP-352-aware wallets pick the
`sp=` parameter and legacy wallets fall back to the on-chain
address. The dialog now shows one QR and one copyable row with the
full URI, the tabs and per-tab intro copy are gone, and the
"Scan for payments" action lives in the wallet's overflow menu
instead of inside the receive dialog.
Strip the redundant in-dialog header, the BIP-86/Taproot explainer, and
both the Reveal and Copy buttons from the seed-phrase backup UI. The
seed-phrase box itself is now the reveal affordance — tap to expose the
words, tap again to hide them. Copy-to-clipboard is gone entirely (users
write the words down rather than paste them).
- WalletBackupMnemonic: drop the inline "Wallet seed phrase" h2 + KeyRound
icon (the DialogTitle already renders the same heading), drop the
Nostr-key-derivation explainer paragraph, drop the two-button row at the
bottom, drop the copied-state and useToast wiring. Wrap the seed-phrase
box in a <button> with aria-pressed and aria-label so screen readers know
it's a toggle.
- en.json:
- Rewrite walletBackup.dialogDescription to the new short copy: "You can
access your Agora wallet in any Bitcoin wallet by importing this 24-word
backup."
- Rewrite walletBackup.hidden to "Tap to display your 24-word seed
phrase." (dropping the "Tap Reveal" wording, since there is no Reveal
button anymore).
- Add walletBackup.revealAria / hideAria for the toggle button's
aria-label.
- Remove walletBackup.explainer, .reveal, .hide, .copy, .copied,
.copyFailedTitle, .copyFailedDescription.
- Mirror the same restructuring across all 14 other locales. Four locales
(hi, id, tr, zh-Hant) didn't previously have a walletBackup block at all
and were falling back to English — they now ship full translations.
The Back up wallet menu item on /wallet now opens the existing
WalletBackupMnemonicDialog inline instead of navigating to a dedicated
page. The dialog already existed in src/components/WalletBackupMnemonic.tsx
but wasn't wired up anywhere — this finally uses it.
- WalletPage: import WalletBackupMnemonicDialog, hold an open/closed state,
flip the menu item from <Link to="/wallet/backup"> to a regular
DropdownMenuItem that calls setBackupOpen(true) onSelect. Render the
dialog alongside the existing Send / Receive / SP-scan dialogs. The
dialog's internal gating still hides for extension/bunker signers, but
the menu only renders in the 'available' branch of WalletPage so that
fallback isn't user-visible in practice.
- AppRouter: drop the lazy import and route for WalletBackupPage, replace
/wallet/backup with a redirect to /wallet so old bookmarks land somewhere
sensible. Point /wallet/settings/backup at /wallet for the same reason.
- Delete WalletBackupPage.tsx \u2014 no longer reachable.
- Locales: remove the now-unused walletBackupPage block (six keys:
seoTitle, seoDescription, title, subtitle, loggedOut, unsupported) from
en + 14 other locales. The dialog reuses walletBackup.* and
walletSettings.backup.label, which are unchanged.
The cog in the top-right of /wallet now opens a 3-dots (MoreVertical)
dropdown with two items — Back up wallet and Legacy wallet recovery —
each linking straight to the existing /wallet/backup and /wallet/legacy
pages. /wallet/settings (the intermediate Apple-style settings hub) is
gone; the route now redirects to /wallet for any old bookmarks.
- WalletPage: swap the Settings <Link> for a DropdownMenu with two
Link-backed items. Reuse the existing walletSettings.{backup,legacy}.label
strings as menu labels.
- AppRouter: drop the lazy import + route for BitcoinWalletSettingsPage,
replace /wallet/settings with a redirect to /wallet, keep the existing
/wallet/settings/{backup,legacy} redirects.
- WalletBackupPage / LegacyWalletRecoveryPage: backTo now points at
/wallet directly instead of the removed hub.
- Delete BitcoinWalletSettingsPage.tsx — no longer reachable.
- Locales: rename wallet.openSettings -> wallet.openMenu and prune the
walletSettings hub strings (seoTitle, seoDescription, title, subtitle,
per-row descriptions) across en + 14 other locales, keeping only the
two label values that the menu items now read.
The Suspense fallback for code-split routes was a max-w-6xl skeleton
shaped like a generic landing-style page (header bar + two text rows +
a 288px hero block). It ended up wrong-shaped for most routes — narrow
settings pages, the max-w-sm wallet screen, etc. — and on a fast chunk
load it would flash a wide block of placeholder geometry that bore no
resemblance to the page that actually rendered a moment later.
Replace it with a neutral centered Loader2 spinner. A spinner reads as
"loading" without committing to any particular page shape, so it works
equally well as the fallback for every lazy route in the app.
Also revert the balance skeleton on /wallet — that one was tuned to the
final balance shape (h-10 w-40 + h-4 w-24) and reads fine; it was the
route-level fallback that needed to change.
Regression-of: 9c16b300
The Wallet Settings hub stretched its header across the full layout width
while the menu list was capped at max-w-md, so the title floated centered
above a much narrower card. Constrain the page (header included) to the
same max-w-md container so the back arrow, title, and list line up. Apply
the same fix to /wallet/backup and /wallet/legacy.
Also tighten /wallet itself: the settings cog used to sit in a full-width
row while the balance + send/receive controls were max-w-sm, leaving the
cog floating off to the right. Pull the cog into the same max-w-sm
container so it sits flush with the rest of the UI.
The balance-loading state used two stacked skeletons (h-10 w-40, h-4 w-24)
that didn't match the final shape of the rendered balance — replace them
with a centered RefreshCw spinner. Drop the now-unused Skeleton import.
Flatten the sub-routes from /wallet/settings/backup -> /wallet/backup and
/wallet/settings/legacy -> /wallet/legacy. The deeper paths were redundant
since these are leaf pages reached only via the settings hub. Add Navigate
redirects from the old paths so any existing links / muscle memory still
resolve.
The wallet home (/wallet) now ships an Apple-style cog in the top-right
that leads to a new /wallet/settings hub with two rows:
- Back up wallet -> /wallet/settings/backup
- Legacy wallet recovery -> /wallet/settings/legacy
The inline 'Back up wallet' text link is removed from /wallet — the
seed-phrase reveal lives behind the cog now. The 'Move funds to your new
wallet' migration banner is removed too, along with the
useHdWalletV1Migration call that powered it. The same detection still
runs inside the legacy hub, but only when the user actually opens that
page; visiting /wallet no longer issues any Blockbook xpub scan or
NIP-78 query for legacy funds.
The Legacy Wallet Recovery hub surfaces the two previous Agora wallet
generations as separate options:
- V2 Prelaunch Beta Wallet — sweep via the existing /wallet/migrate-v1
flow (BIP-86 nsec-as-seed, plus its BIP-352 silent-payment UTXOs).
- V1 Breeze Wallet — sweep via /wallet/recovery, the previously
orphaned route for the Pathos-era Lightning custody. Reachable from
the UI for the first time.
Both options are documented as one-way sweeps into the user's current
wallet; the legacy wallet itself is not restored.
i18n: drop the now-unused wallet.backupAction and wallet.migration.*
keys. New namespaces walletSettings, walletBackupPage, and walletLegacy
added to en.json and the 14 other locales (ar, es, fa, fr, hi, id, km,
ps, pt, ru, sn, tr, zh, zh-Hant).
Derived from the existing Simplified Chinese (zh) locale via OpenCC-style
s2twp conversion (Simplified -> Traditional with Taiwan phrase
preferences), then hand-corrected for the cases where automated
conversion picks the wrong vocabulary swap:
- 支援 only for technical 'X supports Y feature' contexts; 支持 retained
for 'support a cause/person/activist' which is the more common sense
for an activism platform.
- 連結 (link/connect) rather than 連線 (online) in the hero tagline
'connecting activists to unstoppable funds'.
- 代碼 rather than 程式碼 for silent-payment / donation 'code'
identifiers (the source is a payment-address token, not source code).
- 實例 rather than 例項 for 'instance' (self-hosted weserv).
- 影片 rather than 視頻 for 'video' (Taiwan vocab).
- Fixed an automated-conversion artifact 聚整合員 -> 聚集成員.
The Traditional resource is also registered under zh-TW and zh-HK so
that browsers reporting those device locales route directly to the
Traditional file instead of falling back to Simplified zh.
Mainland zh-CN continues to resolve to zh (Simplified) via i18next's
nonExplicitSupportedLngs. The language switcher dropdown shows both
'简体中文' and '繁體中文' as distinct choices.
Key parity verified: 1677 leaves, same as en.json and zh.json, zero
missing or extra keys, all {{placeholders}} and <N>markup</N> tokens
preserved verbatim from the source.
Bebas Neue (the .font-display family) ships only Latin glyphs, so
Chinese hero headlines fall back to system fonts and lose their
industrial display character. Add @fontsource/noto-sans-tc weight 900
and a :lang() rule that swaps it in for any .font-display element
while the page language is a Chinese variant (zh, zh-Hant, zh-TW,
zh-HK).
The fontsource CSS uses unicode-range descriptors, so non-Chinese
users do not download the Han glyph slices (effectively zero cost
for Latin-only locales).
The rule reverses Tailwind's italic, uppercase, tracking, and the
hero's 0.022em -webkit-text-stroke fatten trick — none of those are
meaningful for CJK text and the stroke trick muddies strokes at
weight 900.
The HD wallet seed is now BIP-39-compatible. Pipeline:
entropy = HKDF-SHA256(nsec, info="agora/v1", length=32)
mnemonic = BIP-39 encoding of (entropy || checksum) // 24 words
seed = PBKDF2-HMAC-SHA512(mnemonic, salt="mnemonic", iters=2048)
The 24 words import cleanly into Sparrow, Electrum, Trezor, Ledger,
BlueWallet, Phoenix, etc., at the BIP-86 / BIP-352 paths. HKDF domain
separation means a leaked mnemonic compromises only the wallet, not
the Nostr identity (unlike the raw nsec).
v1 derivation (nsec used directly as BIP-32 master seed) is retained
as migration-only code. A new /wallet/migrate-v1 page detects funds
at the legacy addresses and builds a single sweep PSBT to consolidate
them into the v2 wallet. A persistent banner on /wallet surfaces the
flow when v1 funds exist.
The mnemonic shows up in two places: a "Back up wallet" dialog on
/wallet, and a section in Profile -> Advanced next to the nsec
backup. nsec backup copy updated to explain the relationship.
Locked test vectors pin the entire derivation pipeline (nsec -> 24
words -> first BIP-86 address -> sp1q...) so any future drift fails
loudly. Regenerate via scripts/derive_vectors.mjs.
Other changes:
- Re-key SP storage NIP-78 d-tag to /v2 so v1 and v2 UTXOs do not mix
- Re-key the persisted receive-address cursor to :v2: namespace
- Relax SP spend-key helper to 16-64 byte seeds (BIP-32 range) so the
migration sweep can sign with the legacy 32-byte v1 seed too
- Remove stale NIP-SP references from derivation comments (the draft
was not relevant to our use case)
- Document the wallet derivation scheme in NIP.md
- Translate every new string to all 10 non-English locales
Adds a complete Türkçe translation. Turkish is spoken by ~90M people
and has an unusually strong mission fit for Agora: Turkey has
documented Bitcoin demand from currency-crisis users, journalist
crackdowns, and an active diaspora that often funds back home.
Register is modern İstanbul Turkish (BBC Türkçe / WhatsApp Türkçe
style) — polite-but-direct, formal siz throughout. English loanwords
kept where they're standard in everyday Turkish (Bitcoin, Lightning,
Nostr, röle, zap); Turkish equivalents used where they read naturally
(cüzdan, gönderi, anlık bildirim).
{{appName}} suffixing uses the standard Turkish apostrophe convention
for proper nouns ({{appName}}'da, {{appName}}'nın, etc.). Suffixes
are pinned to back-vowel harmony because "Agora" ends in 'a' — this
will break if AppConfig.appName is ever changed to a front-vowel word
like "Eylem" or "Yardım", but that's a deliberate trade-off for
not having to fork the JSON across deployments.
heroTagline mirrors the English design rhythm — line break before the
highlight, with the inline-block orange box on its own line as the
visual centerpiece:
"Aktivistleri<1></1><0>durdurulamaz</0> finansmana bağlıyoruz."
The trailing period is glued to a multi-word phrase rather than
sitting alone after the inline-block, avoiding the wrap bug we fixed
for Portuguese.
Turkish casing note: the hero h1 has text-transform: uppercase. Turkish
has dotted/dotless i (İ/i, I/ı). CSS uppercase honors the lang="tr"
attribute that applyDocumentDirection sets on <html>, so dotted
lowercase "i" uppercases to "İ" correctly without manual handling.
i18n auto-detection picks up the new locale on devices set to Turkish
(navigator.language returns tr/tr-TR).
Validation: en/tr leaf counts both 1677, no extra/missing keys,
locales.test.ts passes (120 tests, +2 for the new locale).
Adds a complete Bahasa Indonesia translation. Indonesian is the
eleventh-most-spoken language worldwide (~200M speakers) and brings
Agora to Indonesia's growing Bitcoin-aware activist community plus the
broader maritime Southeast Asia.
Register is standard Bahasa Indonesia (formal-but-conversational,
the style used by Kompas, Tempo, Tirto). Avoids Malaysian-Malay-only
vocabulary so Malaysian users (handled via nonExplicitSupportedLngs
folding ms-* → id is NOT happening here; ms users will fall back to
English until we add ms separately, which is acceptable given the
~85% mutual intelligibility). Uses Anda for the second person, the
established UI convention. Standard Indonesian tech vocabulary where
present (dompet, kampanye, donasi, donatur, ikrar, penggalangan dana,
pengaturan); English loanwords kept where they're normal in modern
Indonesian software (Bitcoin, Lightning, Nostr, feed, post, relay,
zap, NIP, BIP).
heroTagline uses the period-inside-highlight pattern:
"Menghubungkan aktivis dengan<1></1>pendanaan <0>tak terbendung.</0>"
The trailing period lives inside the orange highlight span, so it
can't orphan to its own line at the hero's text-8xl size — the same
fix we applied for Portuguese.
i18n auto-detection picks up the new locale on devices set to
Indonesian (navigator.language returns id/id-ID).
Validation: en/id leaf counts both 1677, no extra/missing keys,
locales.test.ts passes (118 tests, +2 for the new locale).
Adds a complete Hindi translation alongside the existing 11 locales.
Hindi (हिन्दी) is the fourth-most-spoken language in the world (~610M
speakers) and brings Agora to a large activist population currently
served only by the English fallback.
Translation register is Hindustani / news-style (BBC Hindi-flavored),
not Sanskrit-leaning शुद्ध हिन्दी — chosen for accessibility to the
broadest Hindi-speaking audience, including users who'd otherwise
struggle with formal government Hindi. Uses आप throughout for the
second person. Standard tech loanwords kept transliterated where they
read naturally (वॉलेट, पोस्ट, कैंपेन, फ़ीड); brand names and
protocol tokens (Bitcoin, Nostr, BIP-352, nsec, sp1…) kept in Latin
script.
heroTagline uses the safe structure — short highlight on the adjective
"अजेय" with a trailing phrase ending in danda (।), avoiding the
inline-block-followed-by-lone-punctuation wrap bug that previously hit
the Portuguese locale.
i18n auto-detection picks up the new locale on devices set to Hindi
(navigator.language returns hi/hi-IN), with nonExplicitSupportedLngs
folding regional variants into the hi bucket. The runtime fallback to
English still works for any keys we might miss in future edits.
Validation: en/hi leaf counts both 1677, no extra/missing keys,
locales.test.ts passes (116 tests, +2 for the new locale).
The Portuguese heroTagline read
"Conectando ativistas a<1></1><0>financiamento incontrolável</0>."
where the period sits outside the <0>...</0> orange highlighter span.
That span is rendered as inline-block w-fit (CampaignsPage.tsx:203-207),
and the boundary between an inline-block and the following bare text
node is a soft-wrap opportunity. When the highlighted phrase
"financiamento incontrolável" filled the line — which it does at the
hero's text-7xl/8xl sizes inside max-w-2xl — the trailing period
wrapped to its own line.
Restructure the markup so only the adjective is highlighted and the
period lives inside the orange box, mirroring the design intent of the
English original (where the highlight is on the adjective and the noun
trails it):
"Conectando ativistas a<1></1>financiamento <0>incontrolável.</0>"
"financiamento " is now plain text before the box, and the period is
part of the box's content, so there is no wrap opportunity between the
last visible character and the period.
Other locales (fr, ru) have the same structural shape but short enough
highlighted words that the issue doesn't manifest in practice; leaving
them alone.
Adds an Internationalization section to AGENTS.md spelling out that
edits to user-facing strings must propagate across every locale in the
same change, not just en.json. Lists the ten translated locales (ar,
es, fa, fr, km, ps, pt, ru, sn, zh), gives rules for edits / new keys /
removals, notes that locales.test.ts only catches the structural
direction (extra keys in a locale) — missing keys silently fall back
to English — and recommends parallelizing the per-language work with
subagents for one-string-across-ten-locales edits.
The donation address derivation FAQ said "derived from your Nostr public
key," but the HD wallet (src/lib/hdwallet/derivation.ts) actually uses
the user's nsec (secret key) as the BIP-32 master seed for both the
BIP-86 Taproot wallet and the BIP-352 silent-payments wallet. Updated
the wording across why-not-rotating-addresses, what-is-nostr, the donor
guide's arrivesDirectly step, and the activist guide's howReceiving
intro.
The 'Does Agora support silent payments?' answer only mentioned the
activist receive side. Agora's own wallet supports sending silent
payments too (HDSendBitcoinDialog accepts sp1… addresses via the
BIP-352 sender in src/lib/hdwallet/sp/sender.ts), so the answer now
leads with that on the send side and follows with the existing
activist-receive paragraphs.
Propagated to all 10 non-English locales (ar, es, fa, fr, km, ps, pt,
ru, sn, zh).
Catches the ru/fr/pt locales up with the keys added upstream while the
initial translations were in flight: translate, forms additions,
organizationContext, groups.detail, calendarEvents, follow,
campaignsDetail.openInWallet, the assorted countryPlaceholder /
showLess / readMore entries, and a handful of scattered new keys.
Adds src/content/privacy/{ru,fr,pt}.md and src/content/csae/{ru,fr,pt}.md,
and registers them in usePolicyMarkdown.ts so the policy pages serve the
matching translation when the user's language is set to one of the three
new locales (falling back to en otherwise).
The 16x16 favicon frame and favicon-16.png were saved as 8bpp indexed
images with binary alpha. Antialiased edges of the orange logo got
quantized to opaque palette entries, baking in a dark halo that browser
tab strips revealed as an ugly outline around the glyph.
Re-render from public/logo.svg, preserving its native 720:880 aspect on
a transparent square canvas (the previous fix squished the logo into a
square), downsampling with Lanczos to each target size, forcing PNG
color-type 6 (RGBA) and a multi-frame ICO where every size is 32bpp.
Add scripts/generate-favicons.sh so the next logo change can't silently
reintroduce palette quantization or aspect-ratio stretching.
Bootstraps src/locales/{ru,fr,pt}.json from en.json as a starting
point and wires all three into i18n.ts. Strings are still English
pending the per-section translation passes that follow.
Fills the last two gaps in CSAE policy coverage. Khmer and Shona
markdown files land beside the existing six (en, es, zh, ar, fa, ps),
and both entries are registered in the usePolicyMarkdown loader.
Every long-form policy / guide surface is now translated in all eight
shipped locales.
Splits the guide content same way the FAQ was split:
* Structure (block order, kinds, audience, callout variant, option
grid chips/hrefs) stays in helpContent.ts as a typed array of
GuideBlockStructure descriptors with stable IDs.
* Every user-visible string moves to en.json under guides.donor.*,
guides.activist.*, and guides.shared.* (badge labels, tldr eyebrow,
payment comparison table headers + rows).
PaymentComparisonTable, InlinePaymentBadge, and GuideTLDR call
useTranslation() so a language switch triggers re-render. DonorGuidePage
and ActivistGuidePage already do, so getDonorGuideBlocks /
getActivistGuideBlocks re-run on every render and pick up fresh i18n
values automatically.
This commit lands the en strings only; non-en locales follow in the
next commit.
Ships the `faq.*` namespace in every non-English locale. Each locale
gets all 4 category labels and all 23 items (15 visible across About
Agora / Bitcoin Donations / About Nostr + 8 hidden legacy items used by
`HelpTip` call sites in settings pages).
Missing-key fallback to English still works via i18next's default
fallback chain — if a future FAQ item lands in en.json before the other
locales catch up, those locales will fall back to English at that key
without breaking the renderer.
Splits the FAQ definition in `helpContent.ts` into two layers:
* a structural template (category order, item IDs, hidden flag) that
stays in TS, and
* a flat `faq.*` namespace in `locales/*.json` that holds every
user-visible string (category labels, questions, answer paragraph
arrays).
`getFAQCategories` / `getFAQItems` / `getFAQItem` keep the same
`(appName)` signature and `FAQCategory` / `FAQItem` shape — internally
they resolve strings through `i18n.t()`, with `{appName}` literals
rewritten to `{{appName}}` for i18next interpolation. Answer arrays use
`returnObjects: true` so a missing locale entry falls back to English
without breaking the renderer.
`HelpFAQSection` reads `i18n.language` to re-resolve on language switch;
`HelpTip` calls `useTranslation()` for the same reason. The Donor /
Activist guide blocks below are still keyed off the original
single-brace `{appName}` literal — that's a separate i18n pass.
Aggressive cleanup of 359 exports across 153 files identified as
having zero importers outside their declaring module:
- 105 symbols deleted entirely (no internal uses either)
- 254 symbols un-exported (still referenced file-locally; dropped the
`export` keyword to shrink the public surface)
- ~70 cascade cleanups of locals that became dead once their sole
consumer was removed
Notable shrinkage:
- src/hooks/useShakespeare.ts: 626 \u2192 22 lines (unwired AI chat surface;
only the ChatMessage type is consumed)
- src/hooks/useTrending.ts: only useEventStats survives; trending feed
hooks were never wired up
- src/hooks/useTrustedCountryStats.ts: dead type re-exports removed
- src/lib/bitcoin.ts: PSBT helpers \u2014 unused wallet feature scaffolding
- src/lib/communityUtils.ts: unused NIP-72 moderation helpers
- src/lib/extraKinds.ts, src/lib/colorUtils.ts: unused helpers
- src/lib/logger.ts: bare debug/info/warn/error exports dropped;
consumers use the `logger` object
- src/lib/aiChatSystemPrompt.ts: trimmed to the
DEFAULT_SYSTEM_PROMPT_TEMPLATE constant
- src/components/music/MusicTrackRow.tsx: dead row component removed;
only the skeleton is consumed
src/hooks/useNostr.ts (intentional decoy) and src/i18n.ts
(side-effect import) were preserved per their respective contracts.
Extracts the ~285-line JSX prose body into per-language markdown files
served through the existing `usePolicyMarkdown` hook + `PolicyMarkdown`
component shipped with the privacy page.
Ships en/es/zh/ar/fa/ps. Khmer and Shona are left unregistered for now
and fall back to English at runtime via the loader's en fallback path —
the policy text is dense and translators will want a careful pass.
No new infrastructure; reuses the loader + renderer end-to-end.
Extracts the ~110-line JSX prose body into per-language markdown files
and renders it through a new `usePolicyMarkdown` hook + `PolicyMarkdown`
component. Eight locales ship beside `en.md`; missing locales fall back
to English at runtime.
The loader uses dynamic `import()` of `*.md?raw` (Vite + Bun friendly,
no `import.meta.glob`) keyed on a static `{ slug: { lng: loader } }`
table so bundlers can code-split per locale and we keep one shared chunk
for the markdown ecosystem. `{{appName}}` placeholders are interpolated
at render time with backslash-escape of markdown specials, and the
rendered output goes through rehype-sanitize.
This is the reusable loader infrastructure for the long-form policy
pages — CSAEPolicyPage is the next consumer.
Four files with zero importers across the repo:
- src/components/TeamSoapboxCard.tsx
- src/components/discovery/ProfileCard.tsx (superseded by
src/components/ProfileCard.tsx)
- src/components/letter/StickerPicker.tsx (superseded by
src/components/StickerPicker.tsx)
- src/hooks/useDominantColor.ts
src/hooks/useNostr.ts is also orphan but intentional \u2014 it exists as
a re-export decoy and is kept.
Drop 13 packages that have zero imports across src/, configs, and
native projects:
- @radix-ui/react-{menubar,navigation-menu,aspect-ratio,context-menu}
(no shadcn primitive consumes them)
- react-leaflet, leaflet, @types/leaflet (no map usage)
- smol-toml, fflate, html-to-image, input-otp
- react-resizable-panels, react-day-picker
Also drops the orphaned .leaflet-* CSS overrides in src/index.css.
The 11 cover images in /public/challenge-covers/ were superseded by
Blossom-hosted URLs in DEFAULT_ACTION_COVERS. No code referenced the
local files; the comment claiming otherwise was stale.
Reclaims ~4.4 MB from the bundle.
Adds the about.* namespace covering the entire /about landing page:
- hero — eyebrow ('About {appName}'), the 3-part headline with the
highlighter span (split into headlinePart1 / appName / headlinePart2
so each language can put the verb where it naturally belongs), body
paragraph, the three trust chips (Decentralized, Open source,
Censorship resistant), the two CTA buttons, and every string in the
tilted sample-campaign card (Venezuelan vigil alt text, org name,
campaign title, two-line description, 'raised / of $10,000',
'N donors · N countries' line, Donate Bitcoin button).
- howItWorks — section header + lede, plus three step cards (image alt
text, title, body) covering signup / send / spend.
- twoWays — section header, the two RailCards (kicker, tagline, title,
description, three bullets, tradeoff title + intro + bullets each),
the trade-off intro for Public Payments goes through <Trans /> with
inline links to the Donor and Activist guides, and the No-custody
banner including title, body, and the three GoFundMe / GiveSendGo /
'other Bitcoin' comparison items.
- faq — section eyebrow + title, and the three FAQ chapter labels +
descriptions (Getting started / Bitcoin donations / About Nostr).
FAQ_CHAPTERS stays a module-level constant; its label/description
fields just became labelKey/descriptionKey suffixes that AboutPage
hands to t() at render time.
- guides — section header + lede, both Donor and Activist guide cards
(image alt, role chip, title, description, three bullets each, cta),
and the closing 'Still stuck? Follow Team Soapbox' line.
The FAQ accordion bodies themselves come from helpContent.ts via
HelpFAQSection — that file (701 lines of prose) is the Stage E2
candidate and stays English here.
Added rtl:rotate-180 to the two hero CTA arrow icons and the guide-
card arrow icons so they flip in RTL.
ar / es / zh / fa / ps / km / sn translations drafted inline; km, sn,
ps, fa still pending native-speaker review.
Adds the policyPages.* namespace with five sub-namespaces, one per
page, all just translating the PageHeader title, SEO title/description,
and any chrome strings the page wraps around its (untranslated) body:
- privacy.* — PrivacyPolicyPage header. Body prose stays English for
E2 markdown extraction.
- csae.* — CSAEPolicyPage header. Same — body is policy prose.
- donorGuide.* — DonorGuidePage title + subtitle for the GuideHero.
Prose blocks come from helpContent.ts (still English; that file is
the E2 candidate).
- activistGuide.* — ActivistGuidePage hero same shape as donor.
- changelog.* — ChangelogPage chrome: title, error/empty states, Past
releases divider, Show less / Read more, pre-release banner ('Pre-
release build', body line, View unreleased changes link), and a
category-tooltip map (Added / Changed / Deprecated / Removed / Fixed
/ Security). The markdown body itself stays raw English — Read more
truncates whatever the parser produced.
ar / es / zh / fa / ps / km / sn translations drafted inline; km, sn,
ps, fa still pending native-speaker review.
Adds two large namespaces:
noteCard.* — the action labels surfaced under reaction / repost / poll
vote actor rows (reacted / reposted / voted), the Read more / Show less
truncation toggle, the live-stream status badges (LIVE / ENDED /
PLANNED / UNKNOWN), the Bot account avatar title, the donation prefix
on synthetic zap cards (Donated X to / Donated to), and the entire
KIND_HEADER_MAP — every 'X did a Y' header for the 24 kinds Agora
renders an action header for (photos, encrypted messages, letters,
treasures + finds, decks, emoji packs, groups, campaigns, badges,
streams, Zapstore app/release/asset, generic apps, git repos / patches
/ PRs, NIPs, nsites, zaps, pledges, follow packs, follow sets).
The KIND_HEADER_MAP refactor pulls the same trick as profileSettings
presets: each entry stores i18n keys instead of English strings, and
EventActionHeader runs them through t() at render time. publishedAtKey
is just publishedAtAction returning keys instead of phrases. The map
itself stays a module-level constant.
noteMoreMenu.* — the entire 'more' overflow menu hanging off every
NoteCard: every menu item (View post details, View Event JSON,
Bookmark, Add to list, Add/Remove from sidebar, Pin/Unpin to profile,
Pin/Unpin to country feed, Mute Conversation / mute @user / Report /
Remove from group / Delete post), the Encrypted content fallback in
the post preview, every toast (success + failure for each mutation),
the Delete confirmation alert dialog, and the Event JSON dialog (title,
Event ID label, Raw JSON label, Broadcast Event button + Broadcasting
state, plus the {{label}} copied toast).
ar / es / zh / fa / ps / km / sn translations drafted inline; km, sn,
ps, fa still pending native-speaker review.
Adds the compose.* namespace covering ComposeBox: placeholders
(default, poll, country-scoped, content warning), preview / edit toggle,
poll mode (Add option, Option N, Single/Multiple choice, Back to post,
Publish poll, success/failure toasts), voice recording (Cancel, Send,
Sending, mic-denied toast, voice-message tooltip), toolbar
(Attach file, Emoji/GIF, More, Poll, Spoiler, Emoji, Stickers tabs),
submit (Posting / Posted toasts, upload-failed / publish-failed),
and destination dropdown (Post to label, Global / community
explainer popover, Choose another country picker, search placeholder,
empty state, Set as default, default-updated toasts).
Also adds replyModal.* for ReplyComposeModal — the six title fallbacks
(New poll, New comment, Comment on profile, Reply to post, Quote post,
New post), three placeholder fallbacks, and the Bluesky disclaimer.
ComposeBox's hardcoded default placeholder ('What's on your mind?') and
submitLabel ('Post!') props moved from prop defaults to t() fallbacks
inside the component so existing call sites can keep passing translated
strings unchanged. Renamed an inner .map((t) => …) variable in the poll
type/duration row to .map((pt) => …) since t shadowed the i18next t.
Added rtl:rotate-180 to the 'Back to post' chevron since it visually
points left/back.
ar / es / zh / fa / ps / km / sn translations drafted inline; km, sn,
ps, fa still pending native-speaker review.
Two issues left over from the previous restore:
- `ps-1` adds 0.25rem of padding-inline-start. In LTR that pushes
the first letter rightward off the box's left edge, creating a
visible gap. Drop to `ps-0` so the letter sits flush with the
start edge.
- Bebas Neue's italic skew shifts the visible left edge of "U"
rightward of its geometric box, leaving an apparent gap even with
zero padding. Apply `text-indent: -0.06em` to pull the letter back
into the box. The shift is small enough that other scripts
(Arabic, Khmer, Chinese) tolerate it.
Extends the feed.* namespace with five new sub-namespaces:
compose.placeholder, tabs (Follows / Following / Global), empty (the
six per-tab + per-mode empty-state messages plus the three CTA labels
that show under them), modeSwitcher (the home feed's mode-picker dropdown
with Agora / All Nostr / Following options, the dropdown trigger's aria
label, and the disabled-Following tooltip), replyContext (Replying to,
the and-joiner, plus a pluralized andOthers_one/andOthers_other), and
actions (Reply / Repost / Undo repost / React / Zap / Share / More + the
Link-copied toast).
PostActionBar's replyLabel prop default went from 'Reply' to undefined;
the component falls back to t('feed.actions.reply') at render time so
existing call sites that pass an already-translated string keep working
unchanged. FeedModeSwitcher's OPTIONS array swapped its hard-coded
label string for an i18nKey suffix, materialized through t() inside the
component.
ar / es / zh / fa / ps / km / sn translations drafted inline; km, sn,
ps, fa still pending native-speaker review.
Commit ab8457a8 migrated the home-page hero tagline to <Trans /> and
passed the wrapper elements via the OBJECT form of the `components`
prop:
<Trans components={{ 0: <span className="bg-primary ..." /> }} />
In our react-i18next version (17.0.4), the object form silently drops
the indexed tags — the translated text renders, but stripped of the
wrapping element, so the orange highlighter span never makes it into
the DOM.
Switching to the ARRAY form makes i18next pick up the indexed tags:
<Trans components={[ <span className="bg-primary ..." />, <br /> ]} />
While here:
- Add an index-1 <br /> so English keeps its original two-line layout
(Connecting activists to / unstoppable funding.). Translations that
prefer inline flow simply omit <1></1> from their string.
- Switch the highlighter's padding from `pl-1 pr-3` to logical
`ps-1 pe-3` so the asymmetric flourish extending past the word's
trailing edge flips correctly for RTL languages (ar, fa, ps).
Regression-of: ab8457a8
Adds the profileSettings.* namespace covering ProfileSettings:
PageHeader (title, subtitle, Save button), intro section, profile
fields (Website, Lightning, label/value/ticker/address placeholders),
the seven field-preset pills (Music, Photo, Video, Email, Wallet, Link,
Weather, Custom) with labels, descriptions, and value placeholders,
media mismatch warnings (6 keys covering audio / image / video × wrong
type / unknown extension), the upload tooltip, crop dialog titles,
mobile fields preview button, Advanced section + Bot Account row, save
toasts, and the BackupKeySection (Your Key heading, extension / bunker
body, secret-key explainer + warning, copy / reveal / hide aria labels,
Back Up Key button, all backup-related toasts).
Module-level structures pulled the same way as elsewhere this session:
FIELD_PRESETS / CUSTOM_PRESET became key-only skeletons
(FIELD_PRESET_SKELETONS / CUSTOM_PRESET_SKELETON), and a useFieldPresets
hook materializes them inside the component over t(). The locale-
independent fields (icon, type, accept, formatHint, defaultLabel
emojis) stay at module scope. getMediaMismatchWarning was renamed to
getMediaMismatchWarningKey and now returns a translation key suffix
instead of an English string — the SortableFieldRow translates the key
at render time.
ar / es / zh / fa / ps / km / sn translations drafted inline; km, sn,
ps, fa still pending native-speaker review.
Adds the profile.* namespace covering ProfilePage (the more menu, follow
toast, image lightbox, follow/follower modals, Bitcoin QR modal, inline
profile fields, NIP-05 not-found states) and every sub-component the page
mounts: ProfileIdentityRail (action bar, stats, campaigns/pledges/groups
sections, RailPledgeCard, RailOrgCell), ProfileActivityTab,
ProfileCampaignsTab, ProfilePledgesTab, and OrganizationsAllDialog.
The desktop and mobile tab arrays (DESKTOP_TAB_LABELS / MOBILE_TAB_LABELS)
moved from English label strings to label keys so the tab maps are
translated through t('profile.tabs.*') at render time. CORE_TAB_IDS now
keys off the same identifier slugs to keep the click-through routing
intact.
Inner sub-components (RailCampaignsSection, RailPledgeCard, RailOrgCell,
StatList, ActionBar, ProfilePledgeCard, ProfileFieldInline,
BitcoinQRModal, FollowingListModal, FollowersListModal,
ProfileImageLightbox, ProfileMoreMenu) each pick up their own
useTranslation() per the established pattern — no t prop drilling.
ar / es / zh / fa / ps / km / sn translations drafted inline; km, sn, ps,
fa still pending native-speaker review.
Adds a top-level search.* namespace covering the global search page:
- SEO + PageHeader title, three tab labels (Agora / Nostr / Accounts).
- The whole filters popover: title + Reset button, 'From' author-scope
segmented control (Anyone / Follows / People), 'Sort' segmented
control (Recent / Hot / Trending), the four selects (Media,
Protocol, Language, Kind), the custom-kind input placeholder, and
the 'Include replies' switch label.
- The active-filter chip strip (built in a useMemo) — 'No replies',
'Images', 'Videos', 'Shorts & Divines', 'No media', 'Mastodon',
'Bluesky', 'Hot', 'Trending', 'All kinds', 'Kind: {{kind}}',
'Kind {{kind}}', kindsCount_one / _other, 'My follows', and
authorsCount_one / _other.
- The Clear shortcut button, the 'search:' debug-string label, and
the 'New posts' pill with newPosts_one / _other plural.
- Five empty-state messages (Posts results / Posts prompt / Accounts
results / Follows prompt / Agora results / Agora prompt), plus the
EmptyState 'Active filters:' header and 'Clear all filters' button.
- The 'Following' badge title and 🤖 'Bot account' tooltip on
AccountItem + FollowItem (each needs its own useTranslation()).
- The SearchInput placeholder.
All 7 non-English locales drafted inline. Native-speaker review
still pending for km, sn, ps, fa.
Adds a top-level notifSettings.* namespace covering the
/settings/notifications page: SEO meta, page header (title +
subtitle), Push Notifications section (heading, enable-push row,
unsupported/denied banner copy), Android-only Delivery Method
section (Push / Persistent radio with descriptions), 'Notify Me
About' section with the Filter / Types sub-headings, 'Only from
people I follow' row, and the eight notification type rows
(reactions, reposts, zaps, mentions, comments, badges, letters,
highlights) — each with its own label + description under
notifSettings.types.{key}.
NOTIFICATION_TYPES rows now carry labelKey / descriptionKey
strings instead of literal English, looked up via t() inside the
component render. The enable-failure toast (title + description)
also runs through t().
All 7 non-English locales drafted inline. Native-speaker review
still pending for km, sn, ps, fa.
Adds a top-level notifications.* namespace covering the entire
notifications feed. Highlights:
- The 48-entry NOTIFICATION_KIND_NOUNS map (kind → bare noun used
in 'reacted to your <noun>' / 'reposted your <noun>' etc.) was
moved into notifications.kinds.* in the locale files and looked
up via a new useNotificationKindNoun() hook. The component-side
table now only maps kind → i18n key suffix so it stays small and
diffable when new kinds are added.
- Action-verb strings for every notification type are interpolated:
reactedToYour / repostedYour use {{noun}}; zappedYou has a
zappedYouWithAmount variant taking {{sats}}; commentedOnYour
takes {{noun}}; highlightedYour takes {{noun}}; mentionedYou,
repliedToYourNote, repliedToYourComment, sentYouLetter,
awardedBadge, and awardedBadges are flat.
- The condensed group subject uses <Trans i18nKey='subject.twoActors'
components={{0: ActorLink, 1: ActorLink}}> so component order can
flip in RTL languages; the 'and N others' branch uses
andOthers_one / _other and {{count}}.
- '+N more' actor-overflow counter uses interpolation.
- Letter notification's 'View all letters' / 'Reply' buttons,
SEO meta, tab labels (All / Mentions), the empty state and
logged-out CTA all routed through t().
All 7 non-English locales drafted inline. Native-speaker review
still pending for km, sn, ps, fa.
Adds a top-level spScan.* namespace covering the BIP-352 silent-
payment scan dialog: header (title + description), the from/to block
inputs (label + 'tip' placeholder), the indexer-tip / last-scanned
footer line (with 'never' fallback), the include-already-spent
toggle + its long explanation, the in-progress block-counter and
matches_one/_other plural, the post-scan summary
(scannedRange + foundOutputs_one/_other / noNewPayments), and the
'Reconcile spent UTXOs' subsection (title, long description,
checking interpolation, checked_one/_other plural with prune count,
Reconciling… / Reconcile now button). Action buttons reuse
common.cancel + common.close.
All 7 non-English locales drafted inline. Native-speaker review
still pending for km, sn, ps, fa.
Two top-level namespaces:
walletSend.* — the Send Bitcoin dialog at /wallet: dialog title,
amount approx-sats interpolation, recipient label/placeholder, the
three recipient descriptions (silent payment / Nostr / raw address),
fee speed labels (10 min / 30 min / 1 hour / 1 day) and the
{{rate}} sat/vB display, network fee row, available-balance footer,
the four progress strings (building / signing / broadcasting /
sending), the 10-error error catalogue thrown into setError() +
the mutation onError toast title, two-tap arming ('Tap again to
confirm'), and the SuccessScreen (title, sats fallback, view
transaction, done).
bitcoinPublic.* — the BitcoinPublicDisclaimer shared component
used by both the wallet's Send dialog and the campaign DonateDialog.
Lead sentence, 'Learn more' link, the two long body variants
(with/without cash-out advice), and the 'I understand this
transaction is public.' acknowledgement label. The leadText prop
override is preserved; only the default is now translated.
The FEE_SPEED_LABELS module constant was replaced by an in-component
useMemo so the labels stay reactive to language switches.
All 7 non-English locales drafted inline. Native-speaker review
still pending for km, sn, ps, fa.
Adds a top-level walletConnect.* namespace covering the NWC settings
component used by the Advanced settings page: status section (WebLN
+ NWC cards with Ready / Not Found / None badges), the Add button,
empty state, per-connection rows (active badge, defaultWalletName
fallback, set-active and remove icon-button titles), the help text
shown when no signing method is available, and the Connect-NWC
dialog (title, description, alias placeholder, Connect /
Connecting... button).
Toast titles+descriptions for URI-required validation and active-
wallet-changed feedback also go through t(). The connectedCount_one
/ _other plural lets each locale handle pluralisation natively (zh
collapses to one form, ar has separate one/other, etc.).
All 7 non-English locales drafted inline. Native-speaker review
still pending for km, sn, ps, fa.
Adds a top-level walletRecovery.* namespace covering the legacy
Breez/Spark Lightning sweep flow: SEO meta, page chrome, logged-out
empty state, destination address card, recovery-phrase input
(including the NIP-78 backup detection alert, the amber 'only paste
phrases you trust' warning, and the sweep button), the in-progress
loader with all five progress strings (loading SDK, connecting,
checking balance, preparing transfer with sats interpolation,
broadcasting), success card (sats interpolation + view-transaction),
error card, toast on successful relay-backup decrypt, and the six
walletRecovery.errors.* strings thrown into setError().
Back-to-wallet arrow flipped with rtl:rotate-180. All 7 non-English
locales drafted inline. Native-speaker review still pending for km,
sn, ps, fa.
Adds a top-level wallet.* namespace covering the Bitcoin wallet
page: SEO meta, logged-out / unsupported-signer empty states, balance
display (refresh + pending), Send / Receive buttons, the Receive
dialog (on-chain + silent-payment tabs incl. wsrv-style <0>...</0>
Trans interpolations for the bip352IndexerUrl mono span and silent
balance highlight), and the transactions list (relative date helper
now takes t + i18n.language).
formatTxDate was lifted from a closure constant to a parameterised
helper so 'Today' / 'Yesterday' / '{count}d ago' / 'Pending' route
through t() and toLocaleDateString uses the active i18n language
(with an en-US fallback if the locale is unknown to Intl).
All 7 non-English locales drafted inline. Native-speaker review still
pending for km, sn, ps, fa.
Conflicts in NetworkSettingsPage.tsx and SettingsPage.tsx, both
introduced by upstream's Low-Bandwidth Mode + image proxy feature
landing on top of my i18n refactor of the same settings pages.
Resolution:
- Translated the new Low-Bandwidth Mode and Image Proxy block under
settings.network.{lowBandwidthHeading,reduceDataUsage,
reduceDataUsageDesc,useImageProxy,useImageProxyDesc,proxyUrl,
proxyApiDesc,reset}, using <Trans> for the wsrv.nl link in
proxyApiDesc (<0>...</0> component slot).
- Updated settings.network.subtitle and settings.sections.networkDesc
in en.json to match the new upstream English ("Manage data usage,
relays, and file upload servers." / "Data usage, relays, and file
upload servers.").
- Mirrored the new keys + updated values into all 7 non-English
locales. Native-speaker review still pending for km, sn, ps, fa.
Extract user-facing strings on the campaign detail page into the
campaignsDetail.* namespace. Covers the hero (back/edit/delete chips,
author attribution, deadline pill, comment action label), the
engagement counter row above the comments (repost/quote/like counts
with pluralized labels and a bold count wrapper), the comments +
donations section header and empty state, the delete-confirm
AlertDialog, the donate sidebar (raised/of-goal labels, donation
count, recent-donations list, share button, ended state), the story
component, and pin/unpin and deletion toasts.
Chevron + arrow icons flip with rtl:rotate-180. Uses i18next plural
suffixes for the four count-driven labels (reposts/quotes/likes,
comments, donations, days-left).
This completes Priority 1 of the i18n rollout (Pledges +
Communities/Groups + Campaigns verticals — list pages, create forms,
detail pages).
Native-speaker review still pending for: km, sn, ps, fa.
Extract user-facing strings on the create-campaign form (and its edit
variant) into the campaignsCreate.* namespace. Covers the four gate
states (login, invalid edit link, loading, not-author), the wallet
picker (source dropdown, accept-types dropdown, custom address +
silent-payment inputs with inline validation), the field labels and
placeholders, the country selector, success/error toasts, and the
mutation's twenty user-facing error messages.
Back-arrow flips with rtl:rotate-180.
Native-speaker review still pending for: km, sn, ps, fa.
Extract user-facing strings on the create-group form (and its edit-mode
variant) into the groups.create.* namespace. Covers all four
non-success states (login gate, invalid edit link, loading-group
spinner, not-the-founder gate), the field labels and placeholders, the
URL preview footer with mono-spaced slug, moderator chip rows and
remove-button aria, the cover-image and country selectors, the submit
button's create/edit/uploading variants, and the mutation's eight
user-facing error messages.
Back-arrow flips with rtl:rotate-180.
Native-speaker review still pending for: km, sn, ps, fa.
Previously the tap-to-load placeholder only kicked in when the proxy was
also disabled. The proxy still saves bandwidth, but users who flipped on
low-bandwidth mode generally want the explicit consent step on every
image — proxied or not. Make the two settings independent:
- ImageGallery / ProxiedImage: gate on lowBandwidthMode alone.
- LinkPreview: suppress thumbnail whenever lowBandwidthMode is on.
- Settings copy + AppConfig JSDoc updated to match.
Extract user-facing strings on the groups index into the groups.list.*
namespace: hero copy, the moderator review rails (Needs review / Hidden),
the My-groups and Featured-groups shelves, the empty states for both
logged-out viewers and logged-in users with no groups, the ticker stat
labels, and the show-more/show-less collapsible toggle.
Uses i18next plural suffixes for the three ticker stats (campaigns
raised, featured groups, countries posting today).
Native-speaker review still pending for: km, sn, ps, fa.
Translate the pledge detail page chrome into the pledges.detail.*
namespace, including the hero (back button, deadline pill, author
attribution, share/submit actions), the funding sidebar (funded/of
amount/trust note/share button), the submissions section
(skeleton/empty state/composer placeholder), pin/unpin toasts, and
the loading variant rendered by NIP19Page while the addressable
coordinate decodes.
Uses i18next plural suffixes for submission counts and days-left
labels. Chevron + arrow icons flip with rtl:rotate-180.
Native-speaker review still pending for: km, sn, ps, fa.
Extract user-facing strings on the create-pledge form into the
pledges.create.* namespace and add per-form-section labels via a new
forms.* namespace shared with FormSection (Required/Recommended/
Optional badge).
Translates the login gate, field labels, placeholders, the
mutation's user-facing error messages, success / failure toasts, the
country search box, and the country-hint footnote (with a <0> wrapper
around the iso3166 monospace span). The back-arrow flips with
rtl:rotate-180 for Arabic/Farsi/Pashto.
Native-speaker review still pending for: km, sn, ps, fa.
Two new AppConfig fields cover the data-saving story:
- `imageProxy` (default `https://wsrv.nl`) — wsrv.nl/weserv-compatible
image-resizing proxy. Empty string disables it. `proxyImageUrl(src, width)`
and `useImageProxy()` rewrite URLs to WebP at quality 75; the `default=`
param redirects to the origin if the proxy can't fetch upstream. The
proxy base URL is parsed through `URL` and rejected unless it's
`https:`, so user input from the settings field can't smuggle non-https
schemes into <img src>.
- `lowBandwidthMode` (default `false`) — forces autoplay off everywhere
(VideoPlayer, LiveStreamPlayer), skips `useVideoThumbnail`'s background
frame-grab, and (when the proxy is also disabled) gates feed images and
link-preview thumbnails behind a tap-to-load placeholder.
The two settings are independent. A privacy-conscious user can run with
the proxy off without being forced into tap-to-load, and a metered-data
user can keep the proxy on without ever seeing a placeholder. Tap-to-load
only kicks in when both "I'm low-bandwidth" and "the proxy is off"
are true (or the proxy errors in a gated context).
Three new shared pieces:
- `src/lib/proxyImageUrl.ts` — pure URL rewriter
- `src/hooks/useImageProxy.ts` — memoized `(src, width) => string`
- `src/components/MediaPlaceholder.tsx` — tap-to-load pill with
optional blurhash background; rendered as `<div role="button">`
so it nests cleanly inside InlineImage's outer lightbox button
- `src/components/ProxiedImage.tsx` — `<img>` with proxy + onError
fallback + optional placeholder gating
Wired call sites with per-context widths:
Inline post images (InlineImage, w=600, gated)
Image gallery tiles (GridImage, w=600, gated)
Lightbox (LightboxImage, w=1200)
Profile banner (ProfileBannerImage, w=1200)
Profile hover banner (ProfileHoverCard, w=400)
Link preview thumb (LinkPreview, w=400, suppressed when
low-bandwidth + no proxy)
Sidebar media tile (ProfileRightSidebar, w=300)
Avatars (via shadcn) (AvatarImage, w=96 default,
w=128 hover card,
w=256 profile header)
Custom emojis (CustomEmojiImg, w=48)
Badge thumbnails (BadgeThumbnail, w=max(size*2, 128))
Badge hero + glare mask (BadgeContent, w=256;
BadgeDetailContent, w=320;
EmbeddedNaddr, w=192)
Music / podcast art (AudioKindContent, w=600;
MusicTrackRow, w=96;
MusicDetailContent, w=320 hero / 96 row;
PodcastDetailContent, w=320)
Settings UI lives in NetworkSettingsPage with the Low-Bandwidth Mode
toggle at the top (above relay / Blossom plumbing) and the Image Proxy
section between Blossom Servers and Image Uploads. The low-bandwidth
description text adapts to whether the proxy is configured, so the
proxy↔tap-to-load coupling is visible at the toggle.
Video Tier 1:
- VideoPlayer overrides `autoPlay` to false in low-bandwidth
- LiveStreamPlayer no longer hardcodes `autoPlay`, reads config
- useVideoThumbnail skips its background frame-grab entirely
`lowBandwidthMode` is added to EncryptedSettingsSchema so it crosses
devices alongside `autoplayVideos`. `imageProxy` is local-only (privacy
/ network choice, not a UX preference).
Extract all user-facing strings on the Pledges index page into the
pledges.* namespace and supply translations for the 7 non-English
locales. Includes the hero copy, the card chrome (Pledged label,
ended badge, share menu, attribution), the sort/filter controls, the
section dividers, the empty state, and the SEO title/description.
Uses i18next plural suffixes (_one/_other) for the open-count pill and
the show-more button uses {{count}} interpolation.
Native-speaker review still pending for: km, sn, ps, fa.
Add campaigns.home.* namespace covering the hero (tagline, body,
three CTAs), the Featured and Community Campaigns section headings and
descriptions, the Browse-all link, the moderator-only Pending and
Hidden section labels (title/description/emptyText), the non-mod
"Your campaigns" section, and the empty-state card.
The hero tagline uses <Trans i18nKey="campaigns.home.heroTagline"
components={{0: <span/>}}> so each locale controls where the orange
highlighter falls. The English-only inner-span optical alignment
(negative margin to counter Bebas Neue italic skew on a leading 'u')
is dropped — it was overfit to one word and would have produced ugly
results in any non-English layout.
The ArrowRight icon in the "How it works" CTA picks up rtl:rotate-180
so it points the right way in RTL languages.
Add feed.indexTagline, notFound.*, and campaigns.all.* namespaces. Wire
useTranslation into:
- Index.tsx: SEO description tagline ("Your content. Your vibe. Your
rules.")
- NotFound.tsx: 404 heading + "Go home" button + SEO meta
- AllCampaignsPage.tsx: page title, SEO meta, search aria/placeholder,
clear-search aria, sort radio labels (Top/New) + sort group aria,
show-hidden switch, start-campaign CTA, all three empty states (no
match, all hidden, empty) + their hints
CampaignsPage landing page still needs translation; deferred to next
commit because it has hero sections and featured-slot copy that's worth
a dedicated pass.
Add settings.appearance.*, settings.network.*, settings.advanced.*, and
settings.wallet.* namespaces. Wire useTranslation into the four small
settings sub-pages so the page header, intro text, theme picker labels,
relay/Blossom section headings, image upload quality toggle, and
collapsible Wallet trigger all switch language live.
Leaves the Notification, Profile, and Organizers settings sub-pages
plus the embedded WalletSettings / RelayListManager / BlossomSettings /
AdvancedSettings component bodies for a follow-up commit.
Wire useTranslation into TopNav (nav items, mobile drawer, profile menu,
search button aria-label, brand home aria-label, open/close menu labels,
mobile footer links) and SiteFooter (tagline + footer nav links).
Add nav.* namespace to en.json + all 7 locale files. The mobile drawer
and desktop nav now switch language live the moment a user picks a new
language in /settings/language.
Phase 1 multi-language foundation:
- Register 8 locales in i18next (en, es, ar, fa, ps, km, sn, zh) with
static-bundled JSON. Wire languageChanged listener to set
document.documentElement.lang and dir for RTL support.
- Expose SUPPORTED_LANGUAGES (code + nativeName) so the switcher and
validator pick up new entries automatically.
- Restructure en.json: drop ~280 dead wallet-port keys, introduce
settings.sections.*, language.*, and a tightened common.* namespace.
Keep organizers.* and the keys actually consumed by t() calls today.
- Translate common, settings, language, and organizers namespaces in
all 7 non-English locales (machine drafts; km/sn/ps/fa flagged for
native-speaker QA before announcement).
- Add LanguageSettingsPage at /settings/language with a radio-style
picker rendering each language's native name in its own script/dir.
- Add Language entry to settingsSections, between Appearance and Network.
- Translate SettingsPage strings (page title, section labels, delete
button) using the new keys. Flip the chevron with rtl:rotate-180.
- Add src/test/locales.test.ts validator that fails CI on any locale
introducing keys absent from en.json (typo / stale-translation guard).
No visible UX change in English. Switching language now translates the
Settings hub, the Language sub-page, and the existing Organizers admin
end to end.
The shared HorizontalScroll component was removed from main during
orphaned component cleanup. Instead of restoring the shared file,
inline the small helper directly in MyDashboardPage since it is the
only consumer.
Agora's colors and fonts are now hardcoded in the bundle. There is no
runtime CSS-variable injection, no remote-loaded theme, no font-family
override from event data, no background image, no recolored favicon.
Switching themes only toggles the .dark class on <html>.
Removed:
- src/themes.ts: ThemeConfig, ThemesConfig, CoreThemeColors, ThemeFont,
ThemeBackground, themePresets (22 presets), buildThemeCssFromCore,
deriveTokensFromCore, the 'custom' Theme variant. Now exports only
resolveTheme(theme) -> 'light' | 'dark'.
- src/lib/fontLoader.ts: deleted. Mounted arbitrary @font-face rules with
event-sourced URLs and font-family overrides — the primary CSS-injection
vector. sanitizeCssString moved to src/lib/cssSanitize.ts (still used by
the Letter feature).
- AppProvider hooks useApplyFonts / useApplyBackground / useApplyFavicon.
- useTheme.applyCustomTheme — the entrypoint that wrote external palettes
to global theme state.
- ColorMomentEyeButton + its callers in NoteCard and PostDetailPage. Color
Moments (kind 3367) still render as palette art, but the 'Set as theme'
button is gone.
- LetterAttachment in LetterDetailSheet — the gift-box UI that applied an
attached color moment as the recipient's theme.
- paletteToTheme() from colorMomentUtils — only getColors() remains.
- customTheme / themes fields from AppConfig, AppConfigSchema,
EncryptedSettings, EncryptedSettingsSchema. Existing customTheme values
in localStorage and encrypted NIP-78 settings are now ignored.
- 14 unused @fontsource packages. Only the 10 letter fonts and the 2 base
UI fonts (Inter Variable, Bebas Neue) remain. noto-sans-nushu is kept
for the encrypted-letter obfuscation indicator.
Added:
- Static :root {} and .dark {} blocks in src/index.css with the 19 shadcn
tokens hardcoded. These were previously injected at runtime by
AppProvider; without them the app would lose all colors when the runtime
injector was removed.
- src/lib/cssSanitize.ts holding the sanitizeCssString helper for the
Letter feature's font-family interpolation.
Simplified:
- public/theme.js: no longer reads customTheme or themes from localStorage,
just resolves system/light/dark with hardcoded built-ins. Must stay in
sync with src/index.css colors.
- src/lib/fonts.ts: trimmed to only the 10 letter fonts. findBundledFont
and resolveCssFamily removed (they were only used by fontLoader).
- Rename MySquarePage -> MyDashboardPage (file, component, default export)
- Change route from /my-square to /my-dashboard
- Update user-facing copy: SEO title, logged-out heading, JSDoc
- Add 'My Dashboard' link to AccountSwitcher dropdown
- Add 'My Dashboard' entry to TopNav mobile/profile menu
- Use LayoutDashboard icon to distinguish from the existing Dashboard
- Existing /dashboard route and EventDashboardPage are untouched
The About page (and `HelpFAQSection`) shipped extensive `dark:` overrides
in 76597ae7 and b05ded03, but they never fired in the in-app theme
toggle because tailwind.config.ts left `darkMode` unset. Tailwind
defaults to `media`, so `dark:bg-...` and `dark:text-...` utilities
only respect the OS `prefers-color-scheme` — not the `.dark` class
that `useTheme` writes to <html> when the user picks a dark theme
inside Agora.
Setting `darkMode: 'class'` wires every `dark:` utility in the
codebase (About page, HelpFAQSection, alert variants, chart, et al.)
to the in-app theme toggle. The About page now actually goes dark in
dark mode.
Regression-of: 76597ae7
Replaces the previous near-uniform dark surfaces (#0e1218 / #11151c)
with genuinely dark, alternating tones plus a subtle world-map
texture on two sections, so the page reads as an editorial document
in dark mode the same way it does in light mode.
Section background mapping (dark mode)
Section Was Now
----------------------- ---------- ------------
Hero #0a0c14 #0a0c14 (unchanged)
Three steps #11151c #0a0c14 + texture
Two ways to get paid #0e1218 #13181f + texture
Frequently asked #11151c #0a0c14
Pick the side #0e1218 #13181f
The two alternating tones (#0a0c14 / #13181f) preserve the
cream/white rhythm from light mode. Sections 2 and 3 gain a
dark-only world-map background image at 5-6% opacity (the same
texture used in the hero) so they don't read as flat slabs. The
texture is gated behind 'hidden dark:block' so it has zero impact
on the light-mode rendering.
Card surface lifted
bg-[#1a1f29] → bg-[#1c2230] across StepCard, RailCard, GuideCard,
the FAQ accordion card-row (reference mode), the FAQ card variant,
and the FAQ tab pill. Hover-state for the tab pill follows from
#222937 to #252b3a. Net effect: cards now sit ~12 lightness above
the deepest section background and ~6 above the lifted one, keeping
clear elevation against both tones.
Every section of the About page now renders correctly in dark mode.
The hero stays dark in both modes (its identity is anchored on the
dark navy backdrop), and the four light sections (Three steps, Two
ways to get paid, Frequently asked, Pick the side) each gain a
dark-mode counterpart so the page reads consistently inside the
app's theme.
Section background mapping
Section Light bg Dark bg
----------------------- ----------- -----------
Hero #0a0c14 #0a0c14 (unchanged)
Three steps #faf8f4 #11151c
Two ways to get paid white #0e1218
Frequently asked #f5f1eb #11151c
Pick the side white #0e1218
Card surfaces (StepCard, RailCard, GuideCard, FAQ accordion item)
map bg-white -> dark:bg-[#1a1f29] and border-gray-200 ->
dark:border-white/10. Hover shadows pick up a darker variant
(dark:hover:shadow-[0_8px_24px_rgba(0,0,0,0.4)]) on cards that have
a hover-lift effect.
Text-color mapping (applied throughout)
text-gray-900 -> dark:text-white (headings)
text-gray-800 -> dark:text-gray-100 (inline strongs)
text-gray-700 -> dark:text-gray-300 (bullet body)
text-gray-600 -> dark:text-gray-300 (body prose)
text-amber-600 / 700 -> dark:text-amber-400 (trade-off label
+ icon)
border-gray-100 -> dark:border-white/10 (separators)
No-Custody comparison banner
The light gradient card (white -> primary/5) converts to a glass
tile in dark mode (white/0.04 -> primary/0.08) with primary/30
border. The inner Comparison grid's separator border picks up
dark:border-white/10. ComparisonItem's light theme path now also
sets dark:text-gray-300 on its body copy so it reads on the dark
glass tile.
HelpFAQSection
Both the 'reference' card-row accordion (used by the About page)
and the 'cards' masonry variant gain dark-mode card surfaces and
text-color mapping. The category tab pills get a dark-mode
inactive state. The active pill (bg-primary) is already correct in
both modes.
These three embed components rendered third-party content in
iframes (Twitter's platform.twitter.com widget, Spotify's
embed-iframe, Reddit's embed.reddit.com widget). Removed wholesale:
- SpotifyEmbed, TweetEmbed, RedditEmbed components.
- extractTweetId, extractSpotifyEmbed, extractRedditPost, and the
SpotifyEmbedInfo type from lib/linkEmbed.ts.
- The Twitter/Spotify/Reddit branches in LinkEmbed and
isEmbeddableUrl/embedLabel.
Tweet, Spotify, and Reddit URLs now fall through to the generic
LinkPreview card. useLinkPreview keeps its native Spotify/Reddit
oEmbed shortcuts so those previews still render with rich title +
thumbnail metadata instead of going through the link-preview proxy.
The iframe.diy-based sandbox infrastructure powered two features:
the nsite preview dialog (Run button on NsiteCard and AppHandlerContent)
and the webxdc embed (cartridge launcher + sandboxed iframe runtime
with kind 4932/20932 sync events).
Both are removed wholesale:
- iframe sandbox plumbing: SandboxFrame, src/lib/sandbox/*,
iframeSubdomain, previewInjectedScript.
- nsite preview: NsitePreviewDialog, NsitePermissionManager,
NsitePermissionPrompt, useNsiteSignerRpc, nsitePermissions,
nsiteNostrProvider, NsitePlayerContext.
- webxdc runtime: WebxdcEmbed, Webxdc, GameControls, useWebxdc,
webxdcMeta, public/cartridge.png, NOSTR_WEBXDC.md,
@webxdc/types dependency, kindLabels/signerWithNudge entries
for kinds 4932/20932.
- AppConfig: drop sandboxDomain, showWebxdc, feedIncludeWebxdc.
- extraKinds: drop kind 1063 webxdc entry; sidebar drops 'webxdc'.
- ComposeBox: .xdc uploads now flow through the generic file path
(no UUID injection, no manifest extraction, .xdc removed from
the file picker accept list).
- NoteContent / FileMetadataContent: webxdc branches removed; .xdc
attachments fall through to the generic file card.
- LayoutContext / CenterColumnContext: only consumed by the removed
fullscreen preview panels — deleted along with its provider in
AppRouter.
NsiteCard keeps its rich link-preview card but loses the Run button
and preview dialog. AppHandlerContent keeps a kind 35128 `a`-tag
reference but replaces 'Run' with an external 'Open Site' link to
`<pubkeyB36><dTag>.nsite.lol`. The standard HTML iframe `sandbox`
attribute used by SpotifyEmbed/TweetEmbed/RedditEmbed is unrelated
to iframe.diy and stays.
Squashed re-application of 15 local commits onto the new route-level
layout system (refactor: replace useLayoutOptions store with route-level
layout choice). Drops the obsolete useLayoutOptions({ fullBleed: true })
calls; the About page and the two guide pages instead live under the
wide FundraiserLayout route group in AppRouter.
Routing
- /about, /about/donors, /about/activists are now the canonical paths,
in the wide layout (no max-width cap so sections can span the
viewport with their own backgrounds).
- /help, /help/donors, /help/activists become <Navigate> redirects so
existing bookmarks and links keep working.
About page (new src/pages/AboutPage.tsx)
A landing-style document modeled on https://soapbox.pub/agora,
brought in-app to explain how the platform works. Five sections:
1. Hero (dark navy + world-map texture + orange halos), Bebas Neue
italic headline with an inline orange highlighter behind the
brand name, three trust chips, and Donor / Activist Guide CTAs.
Tilted Venezuelan sample-campaign card on lg+, hidden on mobile.
On mobile the H1 fits on one line via text-4xl + a conditional
<br className="hidden sm:inline" />.
2. Three steps. No middleman. (cream) 3-up white cards with 4:3
step images and corner 01 / 02 / 03 numerals.
3. Two ways to get paid. (white) Bitcoin Public Payments vs.
Bitcoin Silent Payments compare cards with gradient header
strips. Public-Payments trade-off carries the above-ground-
activism warning; Silent-Payments trade-off is five bold-headline
bullets (few wallets, slow, no push notifications, buggy,
no public counts). Below: a primary-tinted No-Custody banner
plus a 3-column comparison grid (Unlike GoFundMe / Unlike
GiveSendGo / Unlike other 'Bitcoin' platforms).
4. Frequently asked. (cream) Three integrated FAQ chapters in
page flow (Getting started / Bitcoin donations / About Nostr),
each with a Bebas Neue numeral + Inter Bold heading + card-row
accordion items with a left orange accent on hover/open.
5. Pick the side you're on. (white) Two large image-led guide
cards (Donor / Activist) using the soapbox.pub photography.
Closes with a quiet 'Still stuck? Follow Team Soapbox' line
linking in-app to the pack via the /:nip19 route.
Typography is Bebas Neue (font-display) italic font-normal with
WebkitTextStroke for the hero H1 and the step numerals only; every
other heading uses Inter Bold (font-sans font-bold tracking-tight).
Bebas Neue is never font-bold (synthetic bold renders as smear at
display sizes).
Em dashes have been removed throughout the page and the
HelpFAQSection component. Box-drawing chars (U+2500) in section
banner comments are not em dashes and stay.
Section backgrounds alternate dark → cream → white → cream → white.
The dark and cream sections keep their literal palette in both light
and dark mode (an editorial choice that gives the page its
landing-page identity rather than being just another themed surface).
Donor + Activist Guides (new block-based design)
Both pages now compose from a typed sequence of GuideBlock variants
defined in helpContent.ts. Each block kind is rendered by a dedicated
component under src/components/guide/:
- GuideTLDR top-of-page summary card with lede + checklist
- GuideSteps numbered vertical flow of short steps
- PaymentComparisonTable Public vs. Silent side-by-side. Three-column
grid on desktop, two stacked tinted cards on
mobile. Row content driven by audience flag.
- CalloutCard tinted info / warning / danger / success blocks
- OptionGrid two-column tile grid for privacy and cash-out
options
- GuideProse plain prose escape hatch
- InlinePaymentBadge small pill that distinguishes the two payment
options
- index.ts barrel
Content is rewritten throughout to reflect current reality: campaigns
can accept Public only, Silent only, or both; the QR code carries
both endpoints when both are accepted; wallets without silent-
payments support fall back to a regular Bitcoin transaction; silent
payments are slow, scan-based, lack push notifications, are
bleeding-edge, and produce no public donation counts.
Activist Guide structure:
TLDR
How receiving works
What everyone can see (intentionally before the table)
Public vs. Silent comparison
A note on silent payments today (calm prose, not an alarm callout)
Move donations promptly
Cashing out privately (silent-payments hop, Lightning
swap, coinjoin, P2P with brokers,
spend it directly)
Avoid centralized tumblers
Donor Guide structure:
TLDR
How a donation flows
Public vs. Silent comparison
Public donations are visible on-chain forever callout
Donating privately option grid
Consumer apps can't make you anonymous callout
A note on silent payments today
Other touchpoints
- Sidebar (sidebarItems.tsx): Help label → About, icon LifeBuoy → Info,
path /help → /about.
- Top nav profile menu (TopNav.tsx): Help → About.
- Site footer (AppRouter.tsx inline): Help → About.
- AccountSwitcher dropdown: Help → About.
- LandingHero FAQ button → /about#faq.
- HelpTip popover footer link → /about#faq.
- GuideHero back link → /about, label 'Back to About', wider
max-w-5xl on lg+ container so it sits well on the now-full-bleed
hero. Inner overlay min-height bumped to 320px on lg+.
- CampaignsPage 'How it works' button → /about.
New assets in /public/about/ pulled from soapbox.pub:
- world-map-bg.png (hero + textures)
- venezuela-libertad-presos-politicos.png (hero sample-card image)
- donor-guide-freedom-libertad.jpeg
- activist-guide-unity.png
Step photos in /public/help/ (step-1-account.jpg, step-2-send.jpg,
step-3-spend.jpg) for the Three Steps section.
HelpFAQSection gains:
- variant: 'list' | 'cards' (default 'list')
- tabs: boolean (only meaningful with variant='cards')
- listTone: 'default' | 'reference' (quieter category labels and more
breathable accordion items for the About page; existing inline
callers keep the default pill style)
In 'reference' mode each accordion item gets a card-row treatment
(rounded white card, subtle border, hover lifts to primary/40 border,
left orange accent rule driven off data-state=open).
helpContent.ts FAQ content (FAQItem / FAQCategory and templates) is
left untouched. Only the donor/activist guide section was rewritten
into GuideBlock[] arrays.
The Ditto-era LayoutStore let pages push layout config (FAB, sidebars,
arc styling, max-width) up to MainLayout via an external store. After
the fundraiser refocus the only knob left is noMaxWidth, and the
`useLayoutEffect + deferred-rAF-reset` dance had a real race condition
where the previous page's options could bleed into the next page
during Suspense transitions — visible as max-w-3xl appearing on pages
that should be wide.
Switch to idiomatic React: two layout variants picked by the router.
- Narrow group (`max-w-3xl`): /feed, /notifications, /search, /profile,
/t/:tag, /g/:geohash, /settings/*, /wallet*, /bitcoin, /help/*,
/privacy, /safety, /changelog, /organizers, /remoteloginsuccess
- Wide group (no max-width): /, /campaigns/*, /groups/*, /pledges/*,
/dashboard, /i/*, /:nip19, catch-all NotFound
`FundraiserLayout` now takes a `narrow` prop and is mounted twice in
the route tree, once per group. Pages no longer reach upward — the
width is a property of the route they live in.
Delete useLayoutOptions and its 13 call sites, useLayoutSnapshot,
LayoutOptions, LayoutStore, and LayoutStoreContext. `LayoutContext.ts`
shrinks to just CenterColumnContext + useCenterColumn (still used by
WebxdcEmbed and NsitePreviewDialog for portal targets).
After removing the sidebar / mobile drawer chrome the registry's
remaining consumers are useFeedSettings, ProfileSearchDropdown, and
CONTENT_KIND_ICONS. Drop the helpers no one calls anymore:
- isSidebarDivider, nostrUriToNip19, nsiteUriToSubdomain
- getSidebarItem, sidebarItemIcon, itemLabel, itemPath, isItemActive
- The internal OPTIONAL_SIDEBAR_ITEMS / ALL_SIDEBAR_ITEMS /
SIDEBAR_ITEM_MAP machinery that only served those helpers
Fold the previously-optional 'dashboard' entry into the main
SIDEBAR_ITEMS list so SIDEBAR_ITEM_IDS still recognizes it.
The inlined FundraiserLayout only reads noMaxWidth and wrapperClassName
from the layout snapshot, so all other LayoutOptions fields (showFAB,
fabKind, fabHref, onFabClick, fabIcon, fabMenu, hasSubHeader,
rightSidebar, scrollContainer, noOverscroll, noArcs, hideTopBar,
hideBottomNav, fullBleed) are dead.
- Strip those fields from every useLayoutOptions call site (~25 pages)
- Remove the now-empty calls entirely where the result is { }
- Slim LayoutOptions to just noMaxWidth + wrapperClassName
- Remove the fullBleed preset expansion in useLayoutOptions
- Drop DrawerContext, useOpenDrawer, NavHiddenContext, useNavHidden
(no useful consumers without mobile bottom nav)
- Inline navHidden=false at the two SubHeaderBar / ProfileTabs sites
and drop the dead conditional classes
- Keep CenterColumnContext + useCenterColumn (still used by
WebxdcEmbed and NsitePreviewDialog for portal targets)
- Drop LiveStreamPage's chatSidebar (it relied on rightSidebar which
no longer exists)
- Drop ProfileSettings's useLayoutOptions sidebar injection
(ProfileRightSidebar is still rendered inline in the page)
The router-level layout introduced in 73bb2a17 hardcoded max-w-3xl on
every page, ignoring useLayoutOptions({ noMaxWidth: true }) and
wrapperClassName. Split FundraiserLayout into an outer provider and
an inner consumer so the inner component can read the snapshot, and
restore the conditional class composition the original
FundraiserLayout used. CampaignDetailPage and ActionDetailPage (and
any page using the fullBleed preset) render edge-to-edge again.
Move the protocol-level Nostr questions (what is Nostr, why is the
sign-in so long, what happens if I lose my key, password-manager use)
out of 'About Agora' and into a new 'About Nostr' category positioned
after the Bitcoin Donations section. Newcomers see what Agora is and
how Bitcoin works first, and only dig into Nostr's identity model
once they care.
Also reorder the silent-payments FAQ to sit above the Lightning one,
since silent payments are part of the answer to 'how do payments
work' while Lightning is the explanation of an absence.
Donations settle to the activist's wallet, not to abstract
'beneficiaries' — the wallet framing matches the rest of the app.
Adds a secondary outline button next to 'Start a campaign' that links
to /help so newcomers have an obvious next step before committing to
creating a campaign.
Silent payments still settle on-chain; the meaningful distinction is
public vs. private (a silent-payment transaction's output can't be
linked back to the recipient's reusable code). Reword help and guide
copy so 'on-chain' only appears where it correctly contrasts with
Lightning or describes chain mechanics that apply to both kinds of
payment.
Activists now pick public, private, or both when creating a campaign;
both generates a combined BIP-21 QR so SP-aware wallets pay privately
and others fall back to on-chain. Update the FAQ, Donor Guide, and
Activist Guide accordingly:
- Recommend Ditto Wallet and Dana for donors who want privacy.
- Lead the activist cashout path with moving funds into a silent-
payments wallet first, then spending onward with the trail broken.
- Flip the 'why no silent payments' FAQ into 'yes, we support them'.
- Note that silent-payment donations are excluded from public donor
lists and totals by design.
The BlindBit Oracle exposes only per-block endpoints, so a 144-block
'Scan recent' was up to ~288 sequential HTTP round trips against the
public mainnet indexer (~700ms each = ~200s wall clock before any ECDH
math). Profiling against the configured indexer showed latency, not
ECDH or bandwidth, was the dominant cost — and that the server happily
handled 10 concurrent requests with ~6x speedup.
Replace the sequential for-loop in scanRange with a sliding-window
pipeline: keep up to SCAN_FETCH_CONCURRENCY (8) fetchBlockEntries
calls in flight, but process completed blocks strictly in height order
so optimisticRef, matchesFound, scan-progress, and the contiguous
scanHeight advancement stay single-writer and monotonic.
Cancel and error semantics are preserved:
- cancelScan still aborts every in-flight fetch via the controller.
- First in-order fetch failure aborts the rest of the scan, same as
before. The finally block drains still-pending fetches so their
rejections don't surface as unhandled promise rejections.
- Card grid: remove the corner "Private" badge on silent-payment cards.
- Detail hero: remove the "Private campaign" badge above the title.
- Detail donate aside: drop the "Private campaign — totals not public"
line; silent-payment campaigns now show only the goal target (if any)
in place of the raised/progress block.
The donate panel already makes it clear which wallet endpoints are
available, so the redundant privacy chrome was just noise.
For dual-wallet campaigns the QR already encodes a single BIP-21 URI
(`bitcoin:<addr>?sp=<sp>`). Mirror that in the copyable row instead of
showing one row per endpoint — modern wallets parse the URI in their
recipient field and BIP-352-aware ones pick up the `sp=` parameter
automatically. Single-endpoint campaigns still show the raw value with
a label-appropriate toast.
Also remove the on-chain traceability disclaimer and the silent-payment
unlinkability notice — both are noise on the donation panel.
The chip + 'Add another address' UX was too clever. Replace it with:
- A 'wallet source' dropdown ('My wallet' / 'Custom'), defaulting to
'My wallet' for nsec users and matching the pre-dual-wallet form.
- An 'accept' dropdown beneath it (only shown when source is 'My
wallet') that picks which donation types to accept: all (default),
public only, or private only. The 'all' and 'private' options are
disabled when the active login can't derive a silent-payment code.
When source is 'Custom' we still surface separate bc1 and sp1 inputs
so the user can publish a dual-endpoint campaign with addresses they
hold elsewhere. At least one of them must parse.
Without nsec access the dropdowns are skipped entirely and the two
custom inputs are shown directly \u2014 same as before.
Edit mode still starts in 'Custom' with the existing values pre-filled
so a no-op edit doesn't surprise the user by re-deriving HD
endpoints (or burning a receive index).
The on-chain traceability and silent-payment privacy notices stay
where they matter — on the campaign detail page's donate panel, where
donors actually see them. The form page no longer needs them
duplicated under the picker.
A campaign may now declare up to two `w` tags — at most one mainnet
on-chain address (bc1q…/bc1p…) and at most one silent-payment code
(sp1…) — and the QR/payment panel combines them into a single BIP-21
URI (`bitcoin:<bc1>?sp=<sp1>`) when both are present. BIP-352-aware
wallets pick the SP parameter automatically; legacy wallets fall back
to the on-chain address.
The campaign form is reorganized around the dual-endpoint model. Users
with nsec access see two avatar chips — "My wallet" and "My private
wallet" — both selected by default and an "Add another address"
disclosure that reveals separate bc1 and sp1 inputs. A typed value
wins over the corresponding chip's HD-derived value, so a cold-storage
address can be substituted without giving up the SP code. Users
without nsec access (extension / bunker logins) see the two custom
inputs unconditionally. At least one of the four sources must resolve.
The on-chain receive-index cursor is still advanced only at publish
time, and now only when "My wallet" is selected AND no custom
on-chain value was provided — so the cursor never burns on a no-op
edit or on a publish where the user overrode the chip with their own
address.
`ParsedCampaign.wallet` is replaced by `ParsedCampaign.wallets`, a
`{ onchain?, sp? }` struct. Consumers (`useCampaignDonations`,
`useDonateCampaign`, `useProfileCampaignStats`, `useOnchainZaps`,
`CampaignCard`, `CampaignDetailPage`, profile rails) keep their
existing on-chain semantics by reading `wallets.onchain`. The
"Private campaign" badge and hidden-aggregates UI now trigger on
SP-only campaigns (no on-chain endpoint), matching the spec.
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).
- Change non-nsec wallet fallback from 'View wallet' to 'Wallet details'
- Replace exact Blockbook/Esplora call counts in module JSDoc with
behavior-focused wording that won't drift when hook internals change
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.
- Replace removed useBitcoinWallet with useHdWallet + useBtcPrice
- Show graceful 'View wallet' fallback for non-nsec logins
- Drop includeArchived and recipientPubkeys (removed from useCampaigns)
- Remove beneficiary campaign query and 'Started for you' shelf
(campaigns are now self-authored; author = beneficiary)
- Simplify hero card from 4 stat tiles to 3
- Update stale JSDoc referencing Esplora and old query costs
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.
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.
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.
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.
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.
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.
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.
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.
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).
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.
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.
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.
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.
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'].
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.
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.
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.
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
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
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
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.
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.
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
- 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.
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.
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
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
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.
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.
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.
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.
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.
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.
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.
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.
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.
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
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
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.
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.
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
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.
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).
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).
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.
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.
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.
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.
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.
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.
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).
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).
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.
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.
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.
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+.
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.
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.
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.
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.
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).
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.
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.
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.
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.
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.
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).
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
/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.
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.
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).
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.
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).
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.
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.
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.
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.
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.
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.
The useMyCommunities hook was removed in a68cad44. Replace with
useUserOrganizations which returns the same community data (founded,
moderated, and followed organizations) under the UserOrganization type.
SectionHeader has built-in px-4, which stacked with the page container's
px-4 to produce 32px inset on mobile. Override with px-0 so the page
container provides the sole inset, matching the hero card and scroll
items.
New page at /my-square with personal hero, wallet summary, notification
previews, and grouped campaign sections (mine, country, community).
Includes useNotificationPreview hook for lightweight notification
display and a compact layout tweak to CampaignCard.
2026-05-20 19:44:21 -03:00
762 changed files with 77096 additions and 71085 deletions
@@ -260,6 +260,21 @@ Routes live in `AppRouter.tsx`. To add one:
The router provides automatic scroll-to-top on navigation and a 404 `NotFound` page.
## Internationalization
All user-facing strings live in `src/locales/<lang>.json`. `en.json` is the source of truth; ten other locales ship alongside it: `ar`, `es`, `fa`, `fr`, `km`, `ps`, `pt`, `ru`, `sn`, `zh`.
**When you edit, add, or remove a translated string, update every locale in the same change — not just `en.json`.** Leaving the other locales stale ships an inconsistent app: users in other languages either see outdated copy or get an English fallback in the middle of a localized screen. This applies to FAQ entries, guide bodies, button labels, error messages — every value reachable through `t()`.
Concrete rules:
- **Edits to an existing key** — change the value in `en.json` first, then update the corresponding key in all ten other locales. Translate the new content into each language; don't paste English. Preserve `{{interpolation}}` placeholders, markdown links, and technical tokens (`sp1…`, `BIP-352`, kind numbers, etc.) verbatim.
- **New keys** — add to `en.json` first, then add the same key with a translated value in every other locale. `src/test/locales.test.ts` fails the build if any locale ships a key that doesn't exist in `en.json`, but the inverse (a key missing from a non-English locale) is allowed and falls back to English at runtime — which is exactly the user-visible mess you're trying to avoid.
- **Removed keys** — delete from `en.json` and every other locale together. Leftover keys are dead translations and clutter future diffs.
- **Parallelize the translation work** — when updating one English string across all ten locales, dispatch the per-language edits to subagents in parallel rather than translating ten files sequentially. Provide each subagent the new English source, the existing translation snippet (so it matches established voice), and explicit instructions to preserve placeholders and technical tokens.
Always run `npm run test` after locale changes — `locales.test.ts` catches structural drift, and the wider suite catches any `t()` calls that referenced a key you renamed.
| Community Chat | 34550, 1311 | Realtime member chat scoped to a NIP-72 community |
| Campaign Moderation | 30223, 1985, 39089 | Homepage curation (approved / hidden / featured axes) via moderator-signed labels in the `agora.moderation` namespace, gated by a follow-pack moderator roster |
| Campaign Moderation | 33863, 34550, 36639, 1985, 39089 | Discovery curation (approved / hidden / featured axes) via moderator-signed labels in the `agora.moderation` namespace, gated by a follow-pack moderator roster. Covers campaigns (all three axes), organizations (hidden + featured), and pledges (hidden + featured). |
| HD Wallet Derivation | — | BIP-39 mnemonic deterministically derived from the user's nsec via HKDF; seeds a BIP-86 Taproot + BIP-352 silent-payment wallet importable into any BIP-39-compatible wallet (see [Agora HD Wallet](#agora-hd-wallet-derivation) below). |
### Agora Content Marker
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).
| 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:
The first two filters surface only Agora-created content. The third surfaces all country/geo-rooted comments and polls regardless of origin — the world layer is intentionally cross-client. The fourth captures any kind 1 note carrying `#agora` (including hashtags users type themselves), which preserves viral / opt-in discovery.
Clients filter both case variants (`agora` and `Agora`) because Nostr `t` tags are conventionally lowercase but some clients normalize hashtags to title case.
#### Backward compatibility
Events published before this marker was adopted do not carry `t:agora` and therefore do not appear in the Agora activity feed. They remain reachable by direct link and via kind-specific directories (e.g. the moderator-curated `/campaigns/all`). Authors who wish to surface a legacy event in the feed can republish it (any edit through the Agora UI will add the marker automatically).
### Community Chat
@@ -89,26 +137,45 @@ Single-recipient zap (the common case — tipping a post or profile):
["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).
@@ -165,13 +232,25 @@ For addressable events, use `"#a": ["<kind>:<pubkey>:<d-tag>"]` instead. For pro
**Verification (REQUIRED before trusting amounts):**
Clients MUST verify a kind 8333 event on-chain before counting it toward a zap total or displaying its amount. The `amount` tag is self-reported by the sender and would otherwise be trivially spoofable. To verify:
Clients MUST verify a kind 8333 event on-chain before counting it toward a zap total or displaying its amount. The `amount` tag is self-reported by the sender and would otherwise be trivially spoofable. Verification has two modes depending on the event shape:
*Identity-recipient mode* (the event has `p` tags — profile zaps, event zaps, community splits):
1. Extract the txid from the `i` tag.
2. Fetch the transaction from a Bitcoin data source (e.g. a mempool.space-compatible Esplora API).
3. For each `p` tag, derive the recipient's expected Taproot address.
4. Sum the values of all outputs in the transaction that pay any of the derived recipient addresses. This is the **verified amount**. Change outputs paying back to the **sender's** derived Taproot address MUST NOT be counted toward the verified amount — only outputs to listed recipients.
5. If the verified amount is 0 (no listed recipient received anything in the tx), the event SHOULD be discarded.
*Campaign-wallet mode* (the event has an `a` tag pointing at a kind 33863 campaign and no `p` tags):
1. Extract the txid from the `i` tag and the campaign coordinate from the `a` tag.
2. Fetch the campaign event and read its `w` tag to get the campaign's declared bech32(m) wallet address. Reject the receipt if `w` is missing, malformed, or starts with `sp1` (silent-payment campaigns do not publish receipts; see Kind 33863).
3. Fetch the transaction from a Bitcoin data source.
4. Sum the values of all outputs in the transaction that pay the campaign's `w` address. This is the **verified amount**.
In both modes:
5. If the verified amount is 0, the event SHOULD be discarded.
6. If the sender's `amount` tag exceeds the verified amount, clients MAY discard the event or MAY display the smaller verified amount (capping). Clients MUST NOT display or count the claimed amount when it exceeds the verified amount.
7. Unconfirmed transactions MAY be displayed as pending; clients MAY require confirmation before counting them toward public totals. Because unconfirmed transactions can be evicted (RBF, double-spend), clients SHOULD either exclude them from aggregate zap totals or clearly label them as pending.
@@ -199,139 +278,235 @@ The two zap kinds are complementary. Clients SHOULD sum verified amounts from bo
---
## Kind 30223: Campaign
## Kind 33863: Campaign
### Summary
Addressable event representing a **fundraising campaign**. A campaign carries the marketing-style metadata you would expect on GoFundMe, Kickstarter, or GiveSendGo (title, summary, cover image, story, category, goal, optional deadline, and recommended country), and — most importantly — a list of recipient pubkeys (`p` tags) that share the proceeds of any donation.
Addressable event representing a **self-authored fundraising campaign**. A campaign carries marketing-style metadata (title, summary, banner image, markdown story, optional goal, optional deadline, optional country) and one or two Bitcoin wallet endpoints declared in `w` tags. Each wallet endpoint is either a public on-chain bech32(m) address (`bc1q…`, `bc1p…`) or a silent-payment code (`sp1…`, per BIP-352). The mode of each endpoint is inferred from the prefix — the client renders a QR code that combines the present endpoints and adjusts the donation-progress UI accordingly. A campaign MAY declare **at most one** endpoint per mode (at most one on-chain address and at most one silent-payment code).
Donations are sent as a **single Bitcoin on-chain transaction** with one output per recipient. The donor's wallet derives each recipient's Taproot address from their pubkey via BIP-340/BIP-341 (the same scheme used by kind 8333 onchain zaps), so the campaign event itself does not need to carry Bitcoin addresses. After broadcasting the funding tx, the donor's client publishes one kind 8333 event referencing the `txid`, listing every campaign recipient under its own `p`tag, and tagging the campaign via `a` / `K`. The donation then shows up in the campaign's totals and in each recipient's profile zap history (the`#p`filter matches every listed recipient).
The author of the event is also the beneficiary. Campaigns are never authored on behalf of someone else; the event creator owns the wallet declared in `w`and receives the donations. To stop accepting donations, the creator publishes a NIP-09 kind 5 deletion request referencing the campaign's`a`coordinate.
The kind is addressable so the creator can edit the story, image, goal, deadline, and recipient list over the life of the campaign without minting new identifiers. The `d` tag is the campaign's slug.
The kind is addressable so the creator can edit the story, banner, goal, deadline, and wallet over the life of the campaign without minting new identifiers. The `d` tag is the campaign's slug.
### Event Structure
```json
{
"kind":30223,
"kind":33863,
"pubkey":"<creator-pubkey>",
"content":"<markdown story>",
"tags":[
["d","save-the-bookstore"],
["d","save-the-last-bookstore"],
["title","Save the Last Bookstore"],
["summary","Help our 40-year-old neighborhood bookstore make rent through winter."],
["image","https://example.com/cover.jpg"],
["t","human-rights"],
["t","legal-defense"],
["goal","10000000"],
["banner","https://blossom.example/abc123.jpg"],
["imeta",
"url https://blossom.example/abc123.jpg",
"m image/jpeg",
"x abc123def456...",
"dim 1600x900",
"blurhash LKO2?U%2Tw=w]~RBVZRi};RPxuwH",
"alt Storefront of the Last Bookstore at dusk"
],
["alt","Fundraising campaign: Save the Last Bookstore"],
["alt","Fundraising campaign: Save the Last Bookstore"]
["t","legal-defense"],
["t","mutual-aid"]
]
}
```
A silent-payment-only campaign omits the `bc1…``w` tag and carries only the `sp1…`:
```json
["w","sp1qq...verylongsilentpaymentcode..."]
```
An on-chain-only campaign omits the `sp1…``w` tag and carries only the `bc1…`:
```json
["w","bc1p7w2k3xq9...xyz"]
```
### 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).
| `d`| Yes | Campaign slug, unique per author. Forms the addressable coordinate `30223:<pubkey>:<d>`. |
| `title`| Yes | Display title of the campaign (plain text, max ~200 chars). |
| `summary` | Recommended | Short one-paragraph tagline shown in feed cards and previews. |
| `image` | Recommended | HTTPS URL of the cover image (jpg/png/webp). Clients MUST sanitize and verify the URL before rendering. |
| `t` | Recommended | Topic tag for discovery and filtering (e.g. `human-rights`, `legal-defense`, `independent-media`). Multiple `t` tags MAY be used. Clients SHOULD normalize user-entered tag labels by removing a leading `#`, lowercasing, and replacing whitespace with hyphens. |
| `goal` | Recommended | Fundraising goal in **satoshis** (decimal integer). Omit if the campaign has no fixed goal. |
| `deadline` | Optional | Unix timestamp (seconds) at which the campaign closes. After the deadline, clients SHOULD show the campaign as ended but MAY still accept donations. |
| `i` | Recommended | NIP-73 country identifier for sorting and discovery. SHOULD be `iso3166:<code>` with an uppercase ISO 3166-1 alpha-2 country code (e.g. `iso3166:VE`). |
| `k` | Recommended if `i` is present | NIP-73 external content kind. For country identifiers this SHOULD be `iso3166`. |
| `location` | Legacy | Human-readable location string used by older campaign events. New events SHOULD prefer `i` + `k` country tags. Clients MAY display this as a fallback only. |
| `status` | Optional | Lifecycle status. The only defined value is `archived`, which marks the campaign closed without deleting it. Other values SHOULD be ignored. See *Closing & archiving* below. |
| `p`| Yes (≥1) | Recipient pubkey. The 2nd element is the hex pubkey; the 3rd (optional) is a relay hint; the 4th (optional) is a positive decimal **weight** for split allocation. |
| `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. A campaign MUST carry at least one `w` tag and MAY carry up to two — at most one per mode (on-chain and silent payment). |
| `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:<code>` with an uppercase ISO 3166-1 alpha-2 country code (e.g. `iso3166:VE`). |
| `k` | Recommended if `i` is present | NIP-73 external content kind. For country identifiers this SHOULD be `iso3166`. |
| `t` | Optional | User-entered discovery/category tags. Agora also adds `t:agora` as the app marker; other `t` values are freeform topics such as `legal-defense` or `mutual-aid`. |
When a donor sends an amount `T` in satoshis to a campaign:
The prefix of each `w` value selects one of two donation modes. Clients MUST detect the mode from the prefix; the event carries no other mode discriminator. When a campaign carries both an on-chain and a silent-payment endpoint, the client SHOULD present a single combined QR (see "Combined QR" below) so a scan offers the donor's wallet whichever endpoint it supports, while still rendering on-chain aggregate UI from the on-chain endpoint and the silent-payment privacy notice from the silent-payment endpoint.
1. Read all `p` tags from the campaign event.
2. Parse the weight of each `p` tag from the 4th element. If absent, malformed, or non-positive, the weight defaults to **1**.
3. Compute each recipient's share as `floor(T * weight_i / sum_of_weights)` satoshis.
4. Any remainder from rounding (at most N−1 sats) MAY be appended to the largest share or kept by the donor as change — clients SHOULD prefer appending the remainder to the largest share so the full amount reaches the campaign.
5. If any computed share is below the Bitcoin dust limit (546 sats for P2TR), the donor's client MUST refuse the donation and surface a minimum-amount error.
| `bc1q…` / `bc1p…` | On-chain | Public mainnet bech32(m) address. Donations are traceable; clients show a progress bar, total raised, and donation list. |
| `sp1…` | Silent payment | BIP-352 silent-payment code. Donations are **unlinkable by design**. Clients MUST hide all aggregate totals and progress UI (see below). |
Equal splits are the default: omit the weight on every`p` tag, and all recipients receive `floor(T / N)` sats each.
Other prefixes (`tb1…`, `bcrt1…`, `tsp1…`, lightning invoices, etc.) MUST be rejected at parse time; the campaign does not render. A campaign carrying two`w` tags of the same mode (e.g., two `bc1…` addresses) is invalid and MUST NOT render — only one endpoint per mode is permitted.
### Donation Flow
Clients SHOULD validate the bech32(m) checksum of each `w` value, not just its prefix.
1. Donor opens the campaign and chooses an amount in sats (preset or custom).
2. Donor's client computes per-recipient amounts using the split rules above.
3. Donor's client builds a **single PSBT** with one output per recipient (paying each recipient's derived Taproot address) plus a change output back to the donor.
4. Donor signs the PSBT with their Nostr key (Taproot key-path spend) and broadcasts the resulting transaction.
5. Donor's client publishes **one kind 8333 event for the whole transaction**, listing every recipient under its own `p` tag. The event MUST include:
### Combined QR
When a campaign declares both endpoints, clients SHOULD render a single BIP-21 URI that combines them:
```
bitcoin:<bc1-address>?sp=<sp1-code>
```
BIP-352-aware wallets pick the `sp=` parameter and use the silent-payment flow; legacy wallets fall back to the on-chain address. Clients MAY also surface each endpoint's raw string as a copyable affordance so donors who prefer one over the other can choose explicitly. A single-endpoint campaign uses the standard form: `bitcoin:<bc1-address>` (on-chain only) or `bitcoin:?sp=<sp1-code>` (silent payment only).
### Client Behavior by Mode
Each endpoint type drives its own UI elements independently. A dual-endpoint campaign shows the on-chain aggregate UI (computed from the on-chain endpoint) **and** the silent-payment privacy notice (because at least some donations may flow through the SP endpoint and not be visible in any aggregate).
| QR code | bech32(m) address in BIP-21 `bitcoin:` URI | SP code in BIP-21 `?sp=` extension (combined with on-chain address when both are present) |
| "Raised X" / progress bar | Shown, computed from cumulative `chain_stats.funded_txo_sum` on the on-chain `w` address via an Esplora endpoint (default mempool.space). Kind 8333 receipts are **not** the source of this total — donors paying the BIP-21 QR with a native wallet would otherwise be missed. | **Not contributed.** When the on-chain endpoint is absent, aggregate UI is hidden entirely. |
| Donor / recent-donation list| Shown, populated from verified kind 8333 receipts against the on-chain address (attribution only — these do not feed the headline total). | **Not contributed.** |
| Goal display | Shown as USD target with optional sat-equivalent estimate | Shown as USD target; no progress computation when on-chain endpoint is absent |
| Donation receipt published | Donor's client publishes a kind 8333 receipt against the on-chain endpoint (see below) | **No receipt published.** Publishing one would defeat SP unlinkability and is forbidden. |
For campaigns with **only** a silent-payment endpoint (no on-chain endpoint), clients MUST NOT attempt to scan the chain, MUST NOT publish receipts, and MUST NOT display any aggregate that could leak donation activity. For dual-endpoint campaigns, the on-chain aggregate UI is permitted but clients SHOULD render a privacy notice indicating that silent-payment donations are not reflected in the totals.
### 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:
["alt", "Donation to <campaign-title>: <total-amount> sats"]
]
```
The `amount` tag is the sum of the outputs paying the listed recipients (i.e. the full donation, excluding the donor's change). Per-recipient amounts are not encoded in the event; clients that need them recompute them from the on-chain transaction by matching each recipient's derived Taproot address against the tx outputs.
The receipt MUST NOT carry `p` tags — campaigns are not Nostr-identity recipients. The `amount` tag is the sum of tx outputs paying the campaign's `w` address (excluding the donor's change output).
This mirrors the community batch-zap pattern documented in the kind 8333 section above, with the campaign's addressable coordinate replacing the community coordinate.
4. The receipt is published **after** the tx is broadcast; the txid is already final at that point. A receipt-publish failure does not roll back the donation — the on-chain transaction stands.
### Donation Flow — Silent Payment (`sp1`)
1. Donor opens the campaign and chooses an amount.
2. Donor's client uses the campaign's SP code to derive a fresh, one-time Taproot output script per BIP-352.
3. Donor broadcasts a Bitcoin transaction paying that derived output.
4. **No Nostr event is published.** The campaign owner discovers the donation by scanning the chain locally with their SP private key.
Silent-payment unlinkability is the entire point of this mode. Clients MUST NOT publish receipts, MUST NOT advertise the donation in any other Nostr event (replies, mentions, etc.) on the donor's behalf, and MUST NOT correlate the donor's pubkey with the campaign in any persisted client telemetry.
The headline "raised" amount for an on-chain campaign MUST be sourced **directly from the campaign's on-chain `w` address**, not by aggregating kind 8333 receipts. Specifically, clients SHOULD query a mempool.space-compatible Esplora endpoint for the address and use the cumulative `chain_stats.funded_txo_sum` (plus `mempool_stats.funded_txo_sum` if surfacing pending donations) as the total raised.
This is the source of truth for three reasons:
1. **Completeness.** Donors who pay the BIP-21 QR with a native wallet do not publish a kind 8333 receipt. Aggregating receipts would undercount the campaign.
2. **Forgery resistance.** A single Esplora `GET /address/<addr>` call cannot be spoofed by Nostr publishers, whereas verifying 500+ receipts against the chain is slower and more brittle (relay availability, pagination, replay attempts).
3. **Stability.** `funded_txo_sum` is cumulative — it does not regress when the beneficiary spends from the address.
**List individual donations (for the recent-donations sidebar):**
Clients MUST verify each kind 8333 event on-chain before counting it toward the campaign total, per the verification rules in the kind 8333 section.
Kind 8333 receipts are used **only** to attribute individual donations to Nostr identities (donor pubkey, comment, timestamp) — not to compute the campaign total. Clients MUST still verify each receipt on-chain per the *Campaign-wallet mode* verification rules in the kind 8333 section before displaying it.
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`).
The `pinnedEvents` array is ordered newest pin first. Pinning an already-pinned event removes it. Clients SHOULD ignore pin lists not authored by the root event owner.
### Client Behavior
- **Recipient validity:** clients SHOULD reject `p` tag entries whose pubkey is not 64 hex characters and SHOULD ignore weights that are not positive finite decimals.
- **Dust protection:** when a donor enters an amount that would assign any recipient less than the dust limit, the client MUST block the donation and either suggest the minimum viable total or prompt the donor to remove recipients.
- **Editability:** the creator MAY republish the same `(kind, pubkey, d)` triple to update the campaign. Clients SHOULD keep `published_at` from the first publish on subsequent edits (NIP-23 convention).
- **Closing & archiving:** the creator MAY soft-close a campaign by republishing it with a `["status", "archived"]` tag. Clients SHOULD hide archived campaigns from discovery feeds and disable the donate flow, but MUST keep them reachable by direct link so existing donors can still find them and donation history is preserved. The creator can reopen the campaign by republishing without the status tag (or with any other status value). For a hard delete, the creator MAY publish a NIP-09 kind 5 deletion request referencing the campaign's `a` coordinate; clients SHOULD continue to render past donations against the campaign even after deletion.
- **Wallet validity:** clients MUST reject events that carry no `w` tag, that carry more than one `w` tag of the same mode (e.g., two `bc1…` addresses), or whose `w` values fail 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.
- **Categories:** clients MAY use user-entered `t` tags for topic filtering and discovery. Agora reserves `t:agora` as its app marker but does not reserve any other topic namespace.
- **Migration:** kind 33863 has no relationship to any earlier campaign kind. Clients MUST NOT read, merge, or migrate events of any other kind into the kind 33863 namespace.
### Campaign Moderation Labels
### Agora Moderation Labels
Agora curates which kind 30223 campaigns appear on the homepage (`/`) and on Discover (`/discover`) via moderator-signed NIP-32 label events (kind 1985) in a dedicated label namespace. The campaign event itself is never modified — surfacing is purely a client-side rollup of label events.
Agora curates which kind 33863 campaigns appear on the homepage (`/`) and on the Support directory (`/campaigns/all`), which kind 34550 organizations appear in the Featured shelf on `/communities`, and which kind 36639 pledges appear in the discovery surfaces on `/pledges`, via moderator-signed NIP-32 label events (kind 1985) in a dedicated label namespace. The labeled event itself is never modified — surfacing is purely a client-side rollup of label events.
Campaigns, organizations, and pledges share a single label namespace and a single moderator pack (Team Soapbox); the only thing distinguishing the three streams is the kind prefix on the `a` tag of each label:
- `33863:<author-pubkey>:<d>` — campaign (kind 33863, see "Open Campaigns" above).
- `34550:<author-pubkey>:<d>` — organization (kind 34550, NIP-72 community definition).
- `36639:<author-pubkey>:<d>` — pledge (kind 36639, see "Pledge" below).
A client surfacing campaigns MUST filter folded labels to those whose `a` tag starts with `33863:`. A client surfacing organizations MUST filter to `34550:`. A client surfacing pledges MUST filter to `36639:`. Mixing the streams would let a moderator's `featured` label on a campaign appear to feature an unrelated pledge with the same `d` tag, or any other cross-surface bleed.
#### Namespace
@@ -346,22 +521,39 @@ Each label event carries the namespace twice, per NIP-32:
#### Label values
Three independent axes; the newest moderator-signed label per axis per campaign wins.
Three independent axes are defined; the newest moderator-signed label per axis per coordinate wins. **Campaigns** use all three axes (`approval`, `hide`, `featured`). **Organizations** and **pledges** use only two — `hide` and `featured` — because every Agora-tagged organization or pledge is publicly visible by default; there is no approval gate. Moderators MUST NOT publish `approved` or `unapproved` labels against kind 34550 or kind 36639 coordinates, and clients MUST ignore any such labels they receive.
| approval | `approved`, `unapproved` | campaigns only | `approved` allows the campaign on its discovery surfaces. `unapproved` retracts a previous approval. |
| hide | `hidden`, `unhidden` | campaigns, organizations, pledges | `hidden` suppresses the target everywhere it would otherwise appear. `unhidden` retracts a previous hide. |
| featured | `featured`, `unfeatured` | campaigns, organizations, pledges | `featured` places the target in a hand-picked Featured row. `unfeatured` retracts. |
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.
**Pledges**
- **Discovery surfaces on `/pledges`** — non-moderators MUST NOT see `hidden` pledges in the active / upcoming / past sections, the search results grid, or any future browse surface. Moderators MAY opt-in to seeing hidden pledges via a Show-hidden toggle so they can unhide.
- **Author-own surfaces** — a pledge author's own pledges in their profile always render regardless of moderation state. Moderation governs public discovery, not authorship.
- **Direct-URL access** — a pledge's detail page (`/<naddr>`) renders regardless of moderation state. Hidden pledges remain reachable by anyone who has the link; moderation only governs which surfaces enumerate them.
- **Featured** — reserved for a future curated pledge shelf. The `featured` axis is defined for symmetry with campaigns/organizations, and clients MAY use it when implementing such a shelf.
A pledge label has the same shape with a kind 36639 `a` tag:
```json
{
"kind": 1985,
"content": "",
"tags": [
["L", "agora.moderation"],
["l", "hidden", "agora.moderation"],
["a", "36639:<author-pubkey>:<pledge-d-tag>"],
["alt", "Pledge moderation: hidden"]
]
}
```
Required tags:
- `L` set to `agora.moderation`.
- `l` with the label value as the 2nd element and `agora.moderation` as the 3rd.
- `a` referencing the campaign coordinate `30223:<pubkey>:<d>`.
- `alt` (NIP-31) — clients without label support will display this string.
- `a` referencing the target coordinate (`33863:<pubkey>:<d>` for a campaign, `34550:<pubkey>:<d>` for an organization, `36639:<pubkey>:<d>` for a pledge).
- `alt` (NIP-31) — clients without label support will display this string. The `alt` value SHOULD identify the surface (e.g. `Campaign moderation: featured`, `Organization moderation: featured`, or `Pledge moderation: hidden`) so non-Agora clients can read it.
The pack `p` tags are the authoritative moderator list. Anyone may publish a kind 1985 event in the `agora.moderation` namespace, but events from non-pack authors are silently ignored at the relay-filter layer (`authors:` is pinned to the pack `p` tags). This means:
The pack `p` tags are the authoritative moderator list. Clients MUST pin `authors:` on their label REQ to the pack `p` tags; events from non-pack authors MUST be ignored. This means:
- Self-approval is impossible unless the pack author has added you.
- A moderator removed from the pack immediately loses moderation authority — campaigns kept alive only by their labels return to "pending" until another moderator approves them.
- A moderator removed from the pack immediately loses moderation authority — campaigns/organizations kept alive only by their labels return to "pending" until another moderator approves them.
- The pack author (single signer) can reset the entire moderator roster by republishing the pack.
The same moderator set governs both campaign and organization labels. Carving out per-surface moderator subsets is out of scope; clients that need that distinction would have to introduce a second follow pack and a second label namespace.
#### Querying
Step 1 — fetch the pack:
@@ -426,13 +648,14 @@ Step 2 — fetch label events from pack members in the namespace:
}
```
Step 3 — fold by `(campaign-coord, axis)`, latest-`created_at`-wins. Then fetch only the approved-and-not-hidden campaign coordinates with one filter per author (bundled in a single REQ).
Step 3 — fold by `(coord, axis)`, latest-`created_at`-wins, filtering to the relevant kind prefix (`33863:` for campaigns or `34550:` for organizations). Then fetch the targeted events themselves — one filter per author (bundled in a single REQ) keyed by their d-tags.
#### Client Behavior
- Clients SHOULD render approve/hide controls only for users whose pubkey appears in the pack.
- Clients MAY display "Hidden" badges on hidden campaigns when viewed by a moderator, and SHOULD NOT render them at all to non-moderators.
- Clients SHOULD render approve/hide/feature controls only for users whose pubkey appears in the pack.
- Clients MAY display "Hidden" badges on hidden campaigns/organizations when viewed by a moderator, and SHOULD NOT render them at all to non-moderators.
- Non-moderator authors viewing the homepage SHOULD see their own pending campaigns in a separate explained section so they understand why their campaign isn't yet on the homepage. The campaign URL remains live and donatable regardless of moderation state.
- Organization authors are not shown an equivalent "pending" surface today — organizations are visible at their NIP-19 route regardless of moderation, and the only moderation surface is the Featured shelf.
---
@@ -805,6 +1028,9 @@ A kind `34550` event defines the community, extending [NIP-72](https://github.co
| `image` | No | Image URL. |
| `a` | Yes (1) | Member badge definition reference with role marker `"member"`. |
| `p` | No | Moderator pubkeys. The 4th element SHOULD be `"moderator"`. |
| `i` | No | Agora extension: NIP-73 country identifier (`iso3166:XX`) for country-scoped group discovery. This is not part of NIP-72. |
| `k` | Recommended if `i` is present | Agora extension: external content kind hint. Use `iso3166` for country identifiers. |
| `t` | No | Agora extension: user-entered discovery/category tags. Agora also adds `t:agora` as the app marker. |
| `relay` | No | Recommended relay URL for community content (per [NIP-72](https://github.com/nostr-protocol/nips/blob/master/72.md)). |
| `alt` | No | [NIP-31](https://github.com/nostr-protocol/nips/blob/master/31.md) description. |
@@ -830,6 +1056,10 @@ The fourth element is a strict protocol marker, not a display label. Communities
@@ -1317,3 +1547,85 @@ 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
---
## Agora HD Wallet Derivation
### Summary
Agora's Bitcoin wallet is hierarchical-deterministic and derived from the user's Nostr secret key (`nsec`). The user backs up either the nsec or the 24-word BIP-39 mnemonic — the mnemonic is a deterministic, one-way function of the nsec, so anyone with the nsec can regenerate the mnemonic at will.
This specification covers two derivation generations:
- **v2 (current)** — nsec → HKDF → BIP-39 24-word mnemonic → PBKDF2 → BIP-32 master seed. The resulting mnemonic imports into any BIP-39-compatible wallet (Sparrow, Electrum, Trezor, Ledger, BlueWallet, Phoenix, …) at the standard BIP-86 / BIP-352 paths.
- **v1 (legacy, migration-only)** — nsec used directly as the BIP-32 master seed (`HDKey.fromMasterSeed(nsec_bytes)`). v1 and v2 produce different addresses for the same nsec.
### v2 Derivation
The v2 pipeline turns a 32-byte nsec into a 64-byte BIP-32 master seed in three steps:
```
entropy = HKDF-SHA256(ikm = nsec_bytes,
salt = "" (default per RFC 5869),
info = "agora/v1",
length = 32 bytes)
mnemonic = BIP-39 encoding of (entropy || SHA256(entropy)[0]) // 24 words
The `"agora/v1"` HKDF info string is a versioning hook: changing it would derive a completely independent wallet from the same nsec. The `"mnemonic"` PBKDF2 salt is the literal BIP-39 default (no user passphrase).
#### Properties
- **Deterministic** — the same nsec always produces the same mnemonic, seed, and BIP-32 master.
- **One-way** — the mnemonic is a hash of the nsec; an attacker who learns the mnemonic learns only the wallet, not the Nostr identity.
- **Interoperable** — the resulting 24-word phrase is a standard BIP-39 mnemonic. Any BIP-39-compatible wallet can import it at the BIP-86 / BIP-352 paths and recover the same on-chain addresses.
### Address Derivation
Once the BIP-32 master is in hand, addresses derive at the standard paths:
#### BIP-86 (Taproot single-key, key-path-only)
```
m/86'/0'/0'/<chain>/<index>
```
- `chain ∈ {0, 1}` — `0` = receive, `1` = change.
- `index` — advanced per receive (no address reuse).
Output script is P2TR with the derived x-only pubkey as `internalPubkey` (no tapscript tree).
#### BIP-352 (Silent Payments)
```
m/352'/0'/0'/0'/0 // spend keypair
m/352'/0'/0'/1'/0 // scan keypair
```
The silent-payment address (`sp1q…`) is the bech32m encoding of `(scan_pubkey || spend_pubkey)` with version `0` and HRP `sp`. The address is **static** — a user publishes one `sp1q…` and reuses it; each sender derives a fresh, unlinkable Taproot output per payment.
### v1 → v2 Migration
The v1 derivation (`HDKey.fromMasterSeed(nsec_bytes)`) produces a different BIP-32 master than v2 for the same nsec, so a user upgrading from v1 to v2 has funds at addresses that the v2 wallet never scans. Agora ships a one-shot migration page (`/wallet/migrate-v1`) that:
1. Detects v1 funds by scanning the v1 xpub against the configured Blockbook indexer and reading the v1 silent-payment UTXO doc from the user's relays (NIP-78 d-tag `${appId}/hdwallet/sp-utxos`).
2. If any v1 funds exist, builds a single sweep PSBT consuming every v1 BIP-86 UTXO + every v1 SP UTXO, with one output (`total − fee`) at the v2 wallet's first BIP-86 receive address.
3. Signs every input using v1-derived keys (`HDKey.fromMasterSeed(nsec_bytes)`) and broadcasts via Blockbook.
The v1 derivation code is retained indefinitely so users can migrate at any time. New scans, sends, and receives always run against v2.
### NIP-78 Storage
Agora stores per-wallet auxiliary state as a NIP-78 encrypted addressable event (kind 30078, NIP-44 to the user's own pubkey). The v2 d-tag suffix is `hdwallet/sp-utxos/v2`; the legacy v1 d-tag is `hdwallet/sp-utxos`. The two are independent: v2 never writes to the v1 tag, and the v1 tag is read only by the migration sweep.
### Security Notes
- The nsec is both the Nostr identity secret and the wallet seed source. Anyone with the nsec controls both. The 24-word mnemonic is the wallet half of that secret and is safer to share with Bitcoin-side tools (it can't impersonate the user on Nostr).
- The wallet is gated to nsec logins. Browser-extension (NIP-07) and remote-signer (NIP-46) logins do not expose the raw secret key, so the wallet cannot derive child keys and surfaces an "unsupported" state.
- Spend signing happens locally in the browser using the derived BIP-32 leaves. The nsec never leaves the device.
This NIP defines how to share and run [webxdc](https://webxdc.org/) apps over Nostr. Webxdc apps are `.xdc` (ZIP) files containing sandboxed HTML5 applications. They are attached to regular Nostr events using `imeta` tags (NIP-92), and state is coordinated through a unique identifier.
This spec covers public webxdc communication only. Private communication may be addressed in a future update.
## Attachment
A webxdc app is attached to any event by including the `.xdc` file URL in the content and an `imeta` tag with MIME type `application/x-webxdc`.
The `imeta` tag SHOULD include a `webxdc` property with a randomly generated unique string. This serves as the coordination identifier for state updates and realtime channels. If omitted, the app can still run but state won't work.
```json
{
"kind":1,
"content":"Let's play chess! https://blossom.example.com/abc123.xdc",
"tags":[
["imeta",
"url https://blossom.example.com/abc123.xdc",
"m application/x-webxdc",
"x a1b2c3d4e5f6...",
"webxdc 9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d"
]
]
}
```
A webxdc MAY also be published as a kind `1063` (NIP-94) file metadata event:
```json
{
"kind":1063,
"content":"A collaborative chess game. Play with friends over Nostr!",
"tags":[
["url","https://blossom.example.com/abc123.xdc"],
["m","application/x-webxdc"],
["x","a1b2c3d4e5f6..."],
["alt","Webxdc app: Chess"],
["webxdc","9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d"]
]
}
```
## Kind `4932`: State Update
A regular event carrying a state update, mapping to the webxdc [`sendUpdate()`](https://webxdc.org/docs/spec/sendUpdate.html) API. Updates are ordered by `created_at` and assigned serial numbers by the client.
### Tags
-`i`: The `webxdc` identifier from the originating event (required)
An ephemeral event carrying realtime data, mapping to the webxdc [`joinRealtimeChannel`](https://webxdc.org/docs/spec/joinRealtimeChannel.html) API. Relays forward these to active subscribers but do not store them.
### Tags
-`i`: The `webxdc` identifier from the originating event (required)
1. A user uploads a `.xdc` file (e.g. to Blossom) and publishes an event with the URL in content and an `imeta` tag. The `imeta` SHOULD include a `webxdc` property.
2. A client detects the `imeta` tag, downloads the `.xdc`, extracts it, and runs `index.html` in a sandboxed iframe or webview.
3.`sendUpdate()` publishes a kind `4932` event with the `webxdc` identifier in an `i` tag.
4. The client subscribes to kind `4932` events with `#i` matching the identifier and delivers them via `setUpdateListener()`.
5.`joinRealtimeChannel()` subscribes to kind `20932` events with `#i` matching the identifier. `send()` publishes ephemeral kind `20932` events. `leave()` closes the subscription.
6.`selfAddr` and `selfName` MAY map to the user's npub and display name, or any other values.
## Security Considerations
- Webxdc apps MUST be sandboxed with no network access, per the [webxdc spec](https://webxdc.org/docs/spec/messenger.html).
- Clients SHOULD verify the `.xdc` file hash (`x` tag) before running it.
- All communication in this spec is public. Webxdc apps designed for private chats or small groups may not work as expected.
- Webxdc apps have no access to Nostr signatures or identity verification. Any participant can claim to be anyone within the app. Apps should not rely on `selfAddr` or `selfName` for trust decisions.
description: customPublish?.successDescription??(replyTo?'Your reply has been published.':quotedEvent?'Your quote has been published.':'Your note has been published.'),
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.