The 'Discover' nav link used to drop visitors on /feed — a plain
kind-1 timeline that didn't connect any of the three things Agora is
actually about. This wires a new /discover page that weaves them
together while the old plain feed stays put at /feed.
Page composition:
- DiscoverHero — reuses the hand-drawn HeroGlobe but reframes it
around the world itself, not any one campaign. Three marker
layers (campaign hearts, community rings, country-pulse dots)
sit on the same sphere, the HOPE_PALETTE slowly drifts every 9s,
and a rotating ticker pill surfaces immutable network-wide
stats: total sats raised on-chain, communities online, countries
posting today.
- CountryPulseStrip — horizontal strip of country flag chips
ordered by trailing-window activity from the trusted kind 30385
snapshots. Click opens /i/iso3166:XX.
- 'Help raise hope' — horizontal CampaignCard shelf.
- 'Find your people' — horizontal CommunityMiniCard shelf.
- 'Voices from everywhere' — useDiscoverFeed infinite timeline
mixing new campaigns, country posts, community comments, and
Agora actions, rendered with the kind-appropriate card.
HeroGlobe gains an optional GlobeMarkerKind on each marker so the
campaigns page keeps its hearts-only behaviour while Discover layers
in rings and warm dots.
New hooks:
- useDiscoverCommunities — global kind 34550 discovery
- useDiscoverFeed — paginated mixed feed (30223 + 1111 + 36639)
- useGlobalDonations — network-wide kind 8333 aggregate for the
hero ticker
The PageHeader served no purpose beyond labeling the page: the wallet
UI sits right under it with the balance front and center, the mobile
bolt now opens this route directly, and the page title still wires
through useSeoMeta for the browser tab. Removing it tightens the
mobile layout and saves vertical space above the balance.
The apex bolt button in the mobile bottom nav previously routed to the
user's configured home page (defaulting to /), duplicating the Feed
sidebar entry. With /bitcoin folded into /wallet there's no longer a
prominent path to the wallet from mobile, so wire the bolt directly to
/wallet. Tapping it while already on /wallet scrolls to top; the old
feed-cache invalidation no longer applies.
nostrPubkeyToBitcoinAddress and the PSBT build helpers call
bitcoin.payments.p2tr / Psbt.sign, which require an ECC library to be
registered via bitcoin.initEccLib() first. The lazy init lived inside
getECPair(), which is only reached on the signing path — so render-time
callers (WalletPage, SendBitcoinDialog) blew up on first paint with
'No ECC Library provided'.
Ditto initializes ECC eagerly in main.tsx; agora's bitcoin.ts came from
that port but the main.tsx side never did. Add it.
Regression-of: 9190f62b
Agora previously shipped two parallel wallets: a heavy 6,400-line Breez
SDK Lightning wallet at /wallet and a lightweight on-chain Taproot view
at /bitcoin derived from the user's Nostr pubkey. Maintaining two key
custody models, two send flows, two zap paths (Lightning via Spark,
on-chain via PSBT), and the Spark-specific UI (CreateWallet, mnemonic
backup/restore, lock screen, payment history, etc.) didn't pay for itself
once on-chain Bitcoin signing via NIP-07/NIP-46 became viable.
This consolidation aligns Agora with Ditto's wallet model:
- The on-chain Taproot view from /bitcoin becomes the only /wallet UI.
- /bitcoin redirects to /wallet for back-compat; sidebar and TopNav
drop the duplicate Bitcoin entry.
- The Breez/Spark wallet stack is removed: SparkWalletProvider,
SparkWalletContext, all of src/components/SparkWallet/*, useSparkWallet,
useCommunityBatchZaps, usePaymentContext, WalletSettingsContent, and
LightningEffect are deleted (~6,400 lines).
- Ditto's mature bitcoin/zap stack is ported: useOnchainZap (single-event
on-chain zaps + kind 8333 receipts), OnchainZapContent, ZapDialog with
Bitcoin/Lightning tabs, ZapSuccessScreen, BitcoinContentHeader, and the
larger SendBitcoinDialog. useZaps loses its breezService branch and
falls back to NWC → WebLN → manual QR.
- bitcoin.ts now threads esploraBaseUrl through every call, matching
AppConfig and allowing future relay/Esplora customization.
- CommunityZapDialog is bitcoin-only; CommunityDetailPage drops the
sibling Lightning trigger.
Lightning recovery remains intentional. A small "Looking for your old
wallet?" link on /wallet routes to /wallet/recovery, which lazy-loads
@breeztech/breez-sdk-spark (now in its own 67 KB chunk plus the WASM)
only when a user needs to evacuate funds. The recovery page:
- Auto-detects the NIP-78 kind-30078 d="spark-wallet-backup" relay
backup and offers one-click NIP-44 decrypt via the user's signer.
- Accepts a manual 12-word mnemonic as fallback.
- Connects Breez in-memory, sweeps the entire on-chain balance to the
user's Nostr-derived Taproot address, then disconnects. Nothing is
persisted; the old wallet is never "restored" — only evacuated.
Other small carry-overs from Ditto needed by the ported code:
useFormatMoney + AppConfig.currencyDisplay ("usd" | "sats"), and the
nostrId helper (HexId branded type + isNostrId validator).
48 files changed, 2,464 insertions(+), 9,743 deletions(-).
The rotation rAF loop was being keyed by `useEffect([markers,
ringSizes, selectedKey])`. Each time `HeroCampaignSpotlight` cycled
to the next campaign, `selectedKey` changed, the effect tore down,
the new effect re-initialized `start = null`, and the elapsed-time
calculation snapped rotation back to 0°.
Hold `markers` and `selectedKey` in refs that the rAF loop reads
on each frame, and drop them from the effect's dep list so the loop
runs uninterrupted for the lifetime of the component.
Also dresses the hero's primary CTA up as an Apple-style liquid glass
pill: faint warm-tinted translucent body (white→amber→rose at low
opacity), heavy backdrop blur, hair-thin inner edge, soft warm-tinted
drop shadow. Hover lifts the tint and shadow a hair without changing
the pill's character — no specular streaks, no halo, no shadow
bloom. Slightly taller than the default `size=lg` (h-12, px-7,
text-base) so it reads as primary without feeling chunky.
- Anchor the globe's center to the right edge of the `max-w-7xl` content
container (matching the TopNav account switcher), nudged inward via a
percentage translate so a substantial slice of the sphere always reads
inside the hero regardless of viewport width.
- Drop the per-breakpoint width classes in favor of a fluid
`clamp(360px, 46dvw, 820px)` so the globe scales smoothly with the
viewport instead of in three discrete jumps. HeroGlobe accepts a
`style` prop so the page can pass the clamp() inline.
Make the globe feel like a beacon of hope:
- New outer halo div behind the SVG with a wide hue-tinted radial glow,
heavy blur, and a slow opacity-only breathing animation
(`hero-globe-halo-breath`, 6.5s) so the layout never shifts.
- Sphere base gradient warmed from cream/cool-earth to dawn-gold/honey
— the disc reads as 'lit from within' instead of dirt-colored.
- Outer dark rim swapped for a soft back-lit limb light tinted with the
active hope hue. Narrow band, low opacity — suggests atmosphere
rather than a neon ring.
Tie the globe to the surrounding atmosphere:
- New `src/lib/hopePalette.ts` exports a curated set of warm sunrise /
dawn hues (`scrim`, `glow`, `rim` per entry) plus
`hopeHueFor(seed)` that deterministically hashes a string (e.g. the
campaign aTag) to a stable palette entry.
- New `HeroAtmosphere` mounts a fresh layer of tinted gradients each
time the active seed changes and crossfades over 1.5s to match
`CampaignHeroBackground`. Uses `mix-blend-mode: screen` so it warms
the photo instead of flattening it.
- `HeroGlobe` takes the active `HopeHue` so the halo and limb tint
agree with the rest of the hero.
Layer order is now: photo BG → atmosphere → globe → readability scrim →
content. The scrim sits *above* the globe so it can darken whatever
slice of the sphere ends up behind the headline, and is hidden at lg+
where the globe is already pushed outside the headline column.
Per design feedback, the npub card on /claim's empty state no longer
offers a templated reply message — just a single 'Copy my npub'
action backed by the same click-to-copy npub block above it.
Freshly-signed-up invitees who reach /claim with no campaigns yet now
see a primary card with their own npub formatted for copy. Two copy
actions:
- 'Copy reply message' (templated: "I finished setting up my Agora
account! My npub is: npub1… — you can add me as a beneficiary now.")
- 'Copy npub only' for pasting into an existing thread.
The old 'No campaigns found yet' card stays as a secondary, demoted
'Expecting a campaign already?' message below the npub card.
Pure UX — no URL params, no inviter awareness, no analytics ping back.
The invitee still sends the message manually through whatever channel
the invite originally came from.
The hero is now layered like Treasures' HeroGallery:
- CampaignHeroBackground (new) — full-bleed banner image from the
currently-spotlit campaign, crossfading over ~1.5 s and panning left.
Warm tint + film grain overlay so foreground text stays legible.
- HeroGlobe — pushed to the right edge with a larger radius, slightly
translucent so the photo bleeds through. Hearts replace the old dots
for marker symbols; clicking one selects that campaign.
- HeroCampaignSpotlight (new) — minimal text overlay anchored to the
bottom-left of the hero container (title, summary, avatar + author,
location, progress bar with goal, 'View' link). No card chrome.
Land polygons are now the full Natural Earth 110m fidelity (~10.5k
vertices) instead of being heavily Douglas-Peucker'd, so coastlines
look organic rather than chunky. Back-hemisphere rings are now
properly hidden by walking each edge and either dropping back-side
vertices outright or interpolating to the sphere limb where a ring
crosses it — fixes the 'phantom continents through the front' bug.
Rings additionally fade in/out over a narrow z-band near the limb
instead of popping at z = 0.
Markers also have proper z-fade and pull off-canvas when on the back
so they can't intercept clicks they aren't visible for. Selected
markers scale 1.35x with a stronger glow so the user can tell which
campaign the spotlight refers to.
Other cleanup:
- formatCampaignAmount + formatSatsShort move out of CampaignCard.tsx
into src/lib/formatCampaignAmount.ts so CampaignCard stops failing
the react-refresh/only-export-components lint.
- Hero CTAs drop the 'Unstoppable fundraising on Nostr' pill and the
em dash from the supporting copy.
- New keyframes (heroPanLeft / heroPanRight) for the slow Ken-Burns
pan on the background photos, with prefers-reduced-motion respected.
Drops the 'Unstoppable fundraising on Nostr' pill and the em dash from the
hero copy, and adds an ambient SVG globe sitting behind the headline.
The globe is a pure-SVG orthographic projection (no WebGL, no canvas). It
renders Natural Earth 110m country boundaries pre-simplified down to ~1.5k
vertices (17 KB inline). Coloring is intentionally warm — cream sphere with
sandy-amber land — to avoid the satellite/HUD aesthetic. Campaigns whose
location string resolves to an ISO 3166-1 country appear as small glowing
markers, deduped by country.
Rotation is driven by requestAnimationFrame and applied imperatively via
refs (no React re-renders during animation), and respects
prefers-reduced-motion by holding at a static angle.
When creating a campaign, organizers can now invite or notify
beneficiaries directly from the recipient picker:
- 'Recipient not here yet? Invite them' button below the search box
copies a templated message that links to /receive — a signup-focused
landing page that pitches Agora to people who don't have a Nostr
account yet, then redirects them to /claim after onboarding.
- Each selected recipient row gains a 'Send {name} a message about
this campaign' button that copies a templated message linking to
/claim — a sign-in landing page that shows the user every campaign
whose 'p' tag includes their pubkey (using a new recipientPubkeys
option on useCampaigns that adds an #p filter).
Both landing pages live outside FundraiserLayout so they read as
standalone marketing/landing screens, matching the FollowPage pattern.
Copy templates match the spec wording from the request.
The previous behavior sent logged-out single-recipient donors straight
into the external-pay (BIP-21 QR) view, which is the lossy path —
externally-paid donations never publish a kind 8333 receipt, so they
don't count toward the campaign goal or show up in the donor list.
Now the dialog opens on a LoggedOutChooserView that presents the
ideal path first:
1. **Log in & donate** (recommended, highlighted card). Opens the
standard LoginDialog inline; on success the outer DonateDialog
re-renders into the normal donate form.
2. **Donate to {Recipient} directly** — secondary option, only shown
for single-recipient campaigns. Copy makes the tradeoff explicit:
the recipient still receives the funds, but the donation won't
count toward the campaign goal.
Multi-recipient campaigns hide the secondary option (the split
fundamentally needs the donor's signed PSBT) and explain why.
ExternalPayView gains an optional onBack so users can return to the
chooser without closing the dialog.
The split-PSBT flow legitimately needs a Nostr signature, but a campaign
with one recipient is just a regular Bitcoin payment — no reason to gate
that on a Nostr login. For single-recipient campaigns, the DonateDialog
now opens an ExternalPayView for logged-out users (and for logged-in
users whose signer can't build PSBTs) with:
- the recipient's Taproot address (Nostr-pubkey-derived) with copy
- a QR code embedding a BIP-21 `bitcoin:<addr>?amount=<btc>` URI
- optional USD amount input that drops into the URI as BTC
- copy URI / open-in-wallet actions
- a heads-up that externally-paid donations won't appear in Agora's
donor list or progress bar, since no kind 8333 receipt is published
Multi-recipient campaigns still require login (the split needs the
donor's signed PSBT to construct one tx with N outputs).
The mobile nav drawer in TopNav has its own X button inside the panel
header, but SheetContent was also rendering the shadcn primitive close
button just outside the panel — two X buttons for the same sheet.
Add an opt-in `hideClose` prop to SheetContent and set it on the
TopNav drawer. Other Sheet consumers (MobileDrawer, etc.) keep the
default built-in close.
Authors can soft-close a campaign by republishing it with a
`["status", "archived"]` tag. Archived campaigns are hidden from the
main fundraisers feed and the donate button is disabled, but the detail
page still loads by direct link so existing donors can find it and
past donations remain attached. The author sees Archive / Reopen
buttons on the detail page and an Archived badge on cards.
useCampaigns gains an `includeArchived` option (default false) so a
future profile view can opt in. NIP.md documents the new status tag.
Cents are visual noise on zap goal progress displays. Add satsToUSDWhole
helper and use it in CampaignCard, CampaignDetailPage, and the goal
preview in CreateCampaignPage. Wallet and send-bitcoin flows continue to
use satsToUSD with cents.
The zap card was an ActivityCard with a chunky amber circle in
the avatar slot and a compact ActorRow up top. That visual
language reads as 'an activity log entry' which clashes with the
NIP-22 comments alongside it on the campaign page. Rebuild it
to mirror NoteCard's normal layout exactly:
- Donor avatar takes the standard size-11 (size-10 in threaded
mode) slot, with a small amber zap badge anchored bottom-right
to keep the kind signal.
- Author block uses the same font-bold name + nip05 + timeAgo
stack as a regular note, with the verb ('donated' / 'sent')
and amount inlined on the name row so the card reads as one
sentence.
- For campaign targets the amount is followed by 'to <Campaign
Title>' where the title is a clickable Link to the naddr —
same routing the regular CommentContext header would use.
Resolved via a single useAddrEvent call gated on the receipt's
a-tag so non-campaign zaps incur no fetch.
- The donor comment renders below the author row as muted italic
text — same spot a normal note's body sits — instead of being
tucked under the actor row.
- Action bar uses the shared {actionButtons} JSX with the exact
same spacing as a comment, so reply / repost / react / zap /
share / more line up vertically across cards in the thread.
The amber Zap import and ProfileHoverCard / Avatar imports were
already in scope, so no new imports beyond useAddrEvent.
Four related changes:
1. Campaign story now clips to three lines (~4.5rem) behind a
soft fade overlay, with a Read more / Show less toggle.
When there's no story yet the empty placeholder renders
unclipped as before.
2. Zap cards in NoteCard gained the same reply/repost/react/
share action bar as a regular note. Each kind 9735 / 8333
event is a valid Nostr event in its own right, so NIP-22
replies target it directly via its event id and reactions
bind to it the same way.
3. The 'zapped' label is now 'donated' when the receipt's a-tag
points at a kind 30223 campaign, and 'sent' otherwise. The
target kind is read from the addressable coordinate; pure
e-tag Lightning zaps fall back to 'sent' without a fetch.
4. Zap amounts render in USD when a BTC→USD price is cached,
with the raw sats string moved to the title tooltip. Falls
back to sats when the price hasn't loaded. A new useBtcPrice
hook shares the existing 'btc-price' cache key so all
call sites (NoteCard, CampaignCard, useBitcoinWallet) dedupe
to one in-flight request.
- Drop the extra divider above the action bar; PostActionBar
already carries its own border-t/b.
- Remove hex pubkey lines under organizer and beneficiary names;
show the NIP-05 instead when available, otherwise just the name.
- Drop the Donors section entirely. Donations now appear inline
in the comments thread, so a separate list is redundant.
- Relocate the organizer attribution from a dedicated card section
to a small 'by {name}' link next to the title in the hero
overlay. Same subtle styling, just a less intrusive spot.
Three changes work together so campaign pages reflect how
campaigns actually receive support — via on-chain donations,
not Lightning zaps:
1. NoteCard's zap-receipt layout now renders kind 8333 in
addition to kind 9735. The helpers (getZapAmountSats,
getZapSenderPubkey) already branched correctly; only the
isZap gate and the amount/message extraction were 9735-only.
2. PostActionBar gained a hideZap prop. Campaigns set it so the
action bar shows only reply / repost / react / share — a
generic Lightning zap is the wrong CTA when the campaign
has its own donation flow.
3. CampaignDetailPage interleaves kind 8333 donation receipts
into the comments thread, sorted by created_at alongside
kind 1111 comments. Each donation produces one receipt per
beneficiary, so we dedupe by (txid, donor) and rewrite the
canonical receipt's amount tag to the summed total so the
card shows the full donation rather than one share.
Campaigns (kind 30223) now expose the standard PostActionBar
(reply/repost/react/zap/share/more) plus a NIP-22 threaded
comments list, mirroring how PostDetailPage handles other
addressable kinds. A stats row above the action bar opens the
existing InteractionsModal.
The 'organized by' link on the campaign detail page was using a raw
hex pubkey URL (/<hex>). Switch to useProfileUrl so the link prefers
the creator's verified NIP-05 identifier when one is available and
falls back to the npub otherwise — same pattern the rest of the app
uses for profile navigation.
The mobile drawer's Profile link was hardcoded to /profile, which has
no route and fell through to the catch-all NIP19Page. nip19.decode
threw on 'profile' and the profile page rendered 'Please log in to
view your profile' even when the user was logged in.
Encode the current user's pubkey as an npub for the link target,
matching how every other profile link in the app is built.
Regression-of: 704cb42e
NIP19Page renders <ProfilePage /> for raw 64-char hex identifiers that
relays resolve to a kind-0 author, but ProfilePage only knew how to
decode NIP-19 and NIP-05. The hex param fell through nip19.decode (which
throws), returned undefined, and the page rendered 'Please log in to
view your profile' — even for logged-in users visiting somebody else's
hex URL.
Accept raw hex pubkeys in the pubkey resolver, and replace the
misleading log-in copy with 'User not found' (the no-param case can't
reach this branch from the router anyway).
Regression-of: d58f4bb6
The iOS Associated Domains entitlement, Android intent filters, AASA
file, and assetlinks.json already reference agora.spot. Three call
sites still hard-coded ditto.pub:
- CREDENTIAL_DOMAIN in src/lib/credentialManager.ts, which keys iCloud
Keychain Shared Web Credentials by domain. Saved nsecs were being
filed under ditto.pub and so could never be matched against the
agora.spot AASA file.
- MainActivity.handleNotificationIntent host check, which only routed
the WebView when the tapped notification's URI host equaled
ditto.pub.
- NostrPoller.showNotification, which built notification PendingIntents
pointing at https://ditto.pub/notifications.