- 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.
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.
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.
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
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
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.
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.
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.