Commit Graph

4313 Commits

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

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

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

Drops HeroGlobe, HeroCampaignSpotlight, and CampaignHeroBackground
along with the spotlight cycling state, the campaign-banner pipeline,
and hopeHueFor() coupling on this page. HeroAtmosphere / HeroBanner /
HOPE_PALETTE remain in use on Communities, Actions, and Guide pages.
2026-05-22 00:42:39 -05:00
Alex Gleason 3a703a261e Replace single-address /wallet with the HD wallet
Delete the old Taproot single-address wallet (WalletPage, SendBitcoinDialog,
useBitcoinWallet) and rename HDWalletPage to WalletPage so the HD wallet now
lives at /wallet. The /hdwallet route is gone.

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

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

The 'wallet unavailable' branch no longer points users at a non-existent
fallback wallet — it now tells extension/bunker users to sign in with their
nsec instead.
2026-05-22 00:26:17 -05:00
Alex Gleason 93c22dec2e Merge remote-tracking branch 'origin/main' into hdwallet
# Conflicts:
#	src/components/DonateDialog.tsx
#	src/hooks/useDonateCampaign.ts
#	src/hooks/useOnchainZaps.ts
2026-05-22 00:16:29 -05:00
Alex Gleason 58fd4c41c2 Explain silent-payment-only balance in HD Send dialog
When the user's only spendable balance is in silent-payment outputs,
the Send button stays disabled with no feedback because:

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

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

Surface that explicitly: when `totalBalance === 0` (no BIP-86
funds) but `silentPaymentBalance > 0`, render a one-line alert
above the Send button telling the user spending SP outputs isn't
supported yet and they need to receive on-chain to spend. Doesn't
remove the disabled state — there's nothing to spend regardless —
but at least the user now knows why.
2026-05-22 00:08:42 -05:00
Alex Gleason 6812b3dd74 Convert Blockbook feePerUnit from sat/kB to sat/vB
Blockbook's WebSocket estimateFee returns feePerUnit in sat/**kB**,
not sat/byte. The TypeScript declaration in blockbook-api.ts
describes it as "sat/byte, Wei/gas, etc.", which was misleading
enough that I trusted the declaration and added a code comment to
match. The Go source is explicit in api/worker.go:

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

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

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

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

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

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

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

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

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

Regression-of: 522c2650
2026-05-21 23:55:43 -05:00
Alex Gleason 63e2a7d1a8 Stamp SP UTXOs with real block timestamps from Blockbook
The HD wallet's silent-payment receives synthesised their timestamp from
block height using a 600-seconds-per-block constant anchored at block
800,000. Real average block time is shorter than 600s, so cumulative
drift on recent heights pushes the estimate days into the future and the
tx list rendered '-11d ago' for fresh receives.

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

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

Regression-of: 059f75db
2026-05-21 23:47:54 -05:00
Chad Curtis 671e3f14fe Rename Search tabs to Agora / Nostr / Accounts and pin Agora to client:Agora
The first Search tab is now 'Agora' (kindsOverride [33863 Campaigns,
36639 Pledges, 34550 Groups]) and additionally narrows results to
events whose NIP-89 'client' tag matches the running app name. Ditto's
relay (Agora's primary relay) indexes the multi-letter 'client' tag
server-side, so the constraint is fast and free; relays that don't
index it ignore the filter and return unfiltered results, which is a
graceful degradation rather than a correctness problem.

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

Plumbing:

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

Empty-state copy and tab/comments are updated.
2026-05-21 23:12:19 -05:00
Chad Curtis 0622efc781 Replace Search Communities tab with Activity (campaigns + pledges)
The old Communities tab pinned kindsOverride to [34550] — communities
only. The new Activity tab pins it to [33863 Campaigns, 36639 Pledges]
so /search opens onto Agora's first-class non-kind-1 content. Kind 1
text posts (and their reposts) continue to live on the Posts tab; the
Accounts tab is unchanged.

URL contract:

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

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

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

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

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

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

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

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

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

- Group the curated Agora set — Campaigns, Pledges, Communities, Posts,
  Articles, Events, Polls, Photos, Videos — under an 'Agora content'
  section at the top of the KindPicker dropdown, with the long-tail list
  of every other supported kind under an 'All kinds' section below. When
  the user types in the search box the partition collapses to a flat
  result list.
2026-05-21 23:02:56 -05:00
mkfain 4e9c6b37d3 Rename Support nav label to Campaigns, Organize to Groups
Touches user-facing labels only:

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

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

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

Concretely:

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

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

Two perf fixes that should make Featured paint noticeably faster:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Fix:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Implementation:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Registered purposes:

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

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

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

Also drops the now-orphaned DiscoverHero component and useDiscoverFeed
hook, and updates the NIP.md campaign moderation note that referenced
the deleted /discover route.
2026-05-21 17:39:15 -05:00
Chad Curtis 2b3a2e7daf Remove NIP-05 username from note card header 2026-05-21 17:38:44 -05:00
Chad Curtis 5c4cf3011e Merge remote-tracking branch 'origin/main' into fundraiser-detail-redesign
# Conflicts:
#	src/pages/CampaignDetailPage.tsx
2026-05-21 17:34:33 -05:00
Chad Curtis 81f1fd5d1f Use foreground token in soft Bitcoin disclaimer for light-mode contrast 2026-05-21 16:39:14 -05:00