Rounds out the phoenix rebrand for assets the earlier logo.svg swap
missed:
- public/icon-192.png / icon-512.png (PWA install icons, referenced by
manifest.webmanifest) still had the old bolt-on-circle mark.
- Android's push-notification status-bar icon (ic_stat_ditto, all
densities) was carrying an even older mark — a ringed-planet icon
from 'Ditto', the project two forks back. Regenerated as a white
phoenix silhouette (status-bar icons must be flat white on
transparent; the OS tints them).
- android/.../splash_icon_vector.xml, the actual live Android 12+
splash icon (wired via styles.xml's windowSplashScreenAnimatedIcon),
had the old double-bolt path hardcoded. Replaced with the phoenix
path and recomputed the scale/center math for its 1446x1246 viewBox.
- ios/.../Splash.imageset (wired into LaunchScreen.storyboard) had an
even older blue crossed-arrow mark predating Agora. Regenerated as
the brand-orange phoenix on white, matching the existing small
centered-mark proportions.
Also deleted the legacy per-density drawable*/splash.png and
drawable/splash_icon.png rasters: confirmed unreferenced by any theme,
manifest, or code (the app fully migrated to the Android 12
SplashScreen API / splash_icon_vector.xml), so they were dead weight
carrying outdated branding no user could ever see.
generate-icons.sh now produces all of the above so future logo changes
regenerate everything in one pass instead of missing assets again.
Note: unlike the web assets, the Android/iOS changes here only take
effect on the next native app build + store release — there's no web
deploy step for them.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
og-image.jpg (the link-preview image for eranos.fund) still carried the
Agora bolt logo and wordmark, plus an in-app mockup labeled 'Agora'.
Recreated it in-place via a headless-Chromium render: same world-map/
phone-mockup background, Agora branding swapped for the Eranos phoenix
in both spots.
Also removed the 'Who made Eranos?' FAQ entry, which credited Soapbox/
Ditto (the upstream Agora fork) rather than describing Eranos itself.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The nonlogs.io link previously sat on 'Skip the public receipt', a
behavioral tip with no relation to exchanges — clicking through led
somewhere the surrounding copy didn't explain. Looked up NonLogs (a
privacy-first exchange, no KYC/logs/account required, listed by the
Grin community for GRIN/USDT trading) and gave it its own card next to
'Acquire Grin without KYC', where it actually belongs. 'Skip the
public receipt' is now a plain tip with no dangling external link.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- 'Acquire Grin without KYC' copy now names Bisq as one option, not the
only one.
- 'Skip the public receipt' now links to nonlogs.io (no-KYC exchange)
instead of a coinjoin wallet, which didn't match the card's point.
- 'Exchanges know who you are' overstated that all exchanges track
withdrawals; softened to 'some' and points to nonlogs.io as the
no-KYC alternative.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Point the Exchanges tile at nonlogs.io (no-KYC) and match its chip/copy
to the Peer-to-peer tile's 'no KYC' framing. Drop the Spend it directly
tile — no vetted resource to link yet.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
LandingHero (shown as the splash above the feed) hardcoded the
fork-original 'ÁGORA' brand name instead of config.appName like
TopNav does.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
step-2-send.jpg showed a Bitcoin-B coin; Eranos is Grin-only. Replaced
with the MW smiley-coin, matching the alt text already in en.json.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The header, sidebar, hero, onboarding, and auth screens draw the brand
mark from inline React SVG components, not /logo.svg — so the previous
logo swap didn't reach them. LogoIcon now carries the phoenix path
(currentColor); AgoraBoltIcon wraps it in fixed brand yellow.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replace the diamond-bolt mark with the phoenix (brand yellow #fcd414).
Regenerate favicons, web logo.png, and Android/iOS launcher icons from
the new public/logo.svg; update the generator scripts for the new
viewBox (1446x1246) and fill color.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Rebrand Agora to Eranos and strip the non-Grin rails. Add Grin donations:
a GoblinPay client + GrinPayDialog, on-chain payment-proof verification
(receiver-sig + kernel-on-chain + dedupe), and a proof-verified campaign
tally (kind 3414). Shift the brand from orange to gold. 118 tests green.
The popup's donate button and learn-more link used a React Router
<Link> inside the Radix dialog. The dialog's click handling swallowed
the SPA navigation, so the click only closed the popup and left the user
on the home page — looking like the popup cleared itself out and went
nowhere.
Point both at the absolute production URL (https://agora.spot/venezuela-relief)
via a plain <a href>, which does a full, reliable navigation regardless
of the dialog's event handling.
Replace the single baked-in terremoto-venezuela campaign with a live,
agnostic showcase: every Venezuela-located campaign tagged
`humanitarian-aid` or `emergency-relief` and created since the
earthquake is pulled in and rendered as a card carousel across the home
hero and the dedicated relief page.
- New useVenezuelaReliefCampaigns hook queries kind 33863 filtered by
`#i: [iso3166:VE]` + `#t: [humanitarian-aid, emergency-relief]` +
`since` (quake timestamp), and aggregates on-chain raised totals the
same way useProfileCampaignStats does (one /address lookup per
campaign, summed; no per-receipt /tx fan-out). The flagship
terremoto-venezuela campaign predates the geo-tag convention and
carries no `iso3166:VE` tag, so it's pinned by coordinate and merged
ahead of the filtered results, deduped by aTag.
- Extend useCampaigns with `categories` (`#t`) and `since` options.
- New VenezuelaReliefShowcase: an auto-panning marquee of CampaignCards
(seamless -50% loop, hover/focus pause, click-drag + momentum, edge
fades, prefers-reduced-motion fallback to a native scroll rail),
ported from the surveil deck shelf.
- Hero/page CTAs and the popup donate button now point at the relief
page (no more single-campaign naddr deep-link); the page CTA scrolls
to the showcase. Drop the embedded CampaignDetailPage, the
.relief-donate-flash CSS, and the now-unused scroll-to-campaign logic.
- VenezuelaReliefGoal now reads the aggregate hook and shows the
combined raised total + matching-campaign count only (no goal bar —
an aggregate goal across independent campaigns is meaningless).
- Tighten the hero height so the carousel sits near the fold.
- Locale updates across all 16 languages: drop `startCampaign`/`goalOf`,
add `showcaseTitle` + `campaignCount`.
Resolve the terremoto-venezuela campaign (naddr) directly in the relief
banner, popup, and dedicated page, turning each into an info + donation
hybrid:
- New useVenezuelaReliefCampaign hook + VenezuelaReliefGoal component
surface the campaign's live raised/goal progress in all three surfaces.
- Donate CTAs deep-link to the campaign naddr; on the dedicated page the
full campaign detail (story, donate panel, ledger, comments) is embedded
below the appeal hero, and "Donate to relief" smooth-scrolls to the
donate panel and flashes a highlight ring (mobile inline + desktop
sidebar). Scroll margin clears the sticky nav.
- Drop the "Raise funds for Venezuela" button; shrink the page hero.
- Add the goalOf locale key across all 16 locales.
Extend the home-page Venezuela earthquake relief appeal beyond the hero
banner so it can reach more visitors and be shared directly:
- Session popup (VenezuelaReliefPopup) mounted site-wide in AppRouter.
Shows once per browser session (sessionStorage) on a fresh load of any
route, carrying the same photo, headline, and donate CTA as the hero.
- Dedicated /venezuela-relief page (VenezuelaReliefPage) so the appeal
has its own shareable URL with SEO/OG metadata and a "how your donation
helps" non-custodial explainer. The hero and popup link to it via
"Learn more".
- Share buttons (native share sheet, clipboard fallback via shareOrCopy +
useShareOrigin) on the hero banner, popup, and page.
- Shared constants in lib/venezuelaRelief.ts (route, image gallery,
donate deep-link, popup sessionStorage key) keep all three surfaces in
sync.
- Fix the highlighted "you" word to match the home hero idiom
(leading-[0.95] so the orange block hugs the cap height).
The popup decides whether to open exactly once per page load via a
module-level guard, so React 19 StrictMode's double-mount can't write the
"seen" flag on the first mount and then suppress the popup on the remount
(which made it flash open and immediately dismiss).
New venezuelaRelief locale keys added to en.json and translated across
all fifteen other locales.
The donors_one/donors_other plural keys were the last consumer of the
donor count, which the previous commit dropped from the campaign card.
Delete them from en.json and all fifteen other locales.
The card footer showed "x donors", sourced from useCampaignDonations'
donorCount. Populating that count required fetching every kind 8333
receipt for the campaign and then verifying each one with a per-receipt
Esplora /tx call — a fan-out that, across a ~200-card grid, hammered
every configured Esplora backend.
Cards only ever render the raised total (the progress bar), which needs
just the single /address balance lookup. Add a receipts option to
useCampaignDonations that skips the receipt fetch and verification
fan-out, and have CampaignCard pass receipts: false. Remove the now-dead
donor-count UI and its unused t()/useTranslation import.
Pin a full-bleed emergency relief banner to the top of the home page
during the Venezuela earthquake response. It rotates through news
photographs from Caracas via the shared HeroBanner (slow crossfade +
Ken-Burns pan), with a humanitarian headline and two CTAs: Donate to
relief (deep-links to /campaigns?country=VE) and Raise funds for
Venezuela (auth-gated campaign creation).
Also fix a latent HeroBanner crossfade bug where new layers mounted at
their final opacity, making transitions snap instead of fade; layers now
mount hidden and flip visible on the next animation frame.
Copy lives under campaigns.home.venezuelaRelief.* and is translated
across all 16 locales.
Keep the fast pooled first render, but merge each read relay's DM backfill
into the visible inbox as soon as that relay finishes instead of waiting for
all relays. Track per-relay sent/received cursors so older pagination cannot
skip dense relay history because another relay returned older sparse events.
Timed-out or failed relays are kept retryable instead of being marked
exhausted, while successful relay cursors unlock deterministic pagination.
Backfilled events still dedupe by id and update latest previews/sort order.
Use the shared pooled query for the initial messages page so the inbox keeps
Ditto-style fast first paint with the provider's short eoseTimeout. Page 0 is
marked pending backfill, then a background effect queries each configured read
relay individually, merges those slower results into the same infinite-query
cache entry, and unlocks older-page pagination after backfill completes.
Also update conversation merging so a backfilled newer event updates the row's
latest preview and sort order.
The inbox query went through the shared pool, whose eoseTimeout resolves as
soon as the first relay sends EOSE (+300ms). DMs held only by slower relays
were dropped, so each hard refresh returned a different, incomplete set of
conversations. Fan the kind-4 query out to each configured read relay
individually (Promise.allSettled, bounded by the existing 8s cap) and merge
deduped by event id, so every relay contributes its full set.
The inbox query key now carries the read-relay list; update the send-message
optimistic cache writes to match ['direct-messages', pubkey] by prefix so the
append still lands.
handleMute awaited addMute's relay round-trip (fetch fresh kind 10000 +
publish) before hiding the conversation, leaving the muted thread visible
for a few seconds. Hide the peer optimistically the moment the user
confirms; the relay-backed mute list still reconciles on reload.
Adds a 'New message' button to the conversation list (and the empty-pane
prompt) that opens a fresh compose pane in the right column instead of a
modal. The pane has a 'To:' field that drives debounced profile search via
useSearchProfiles (name / NIP-05 / npub / nprofile), rendering suggestions
inline beneath the field — no popover. Arrow-key navigation + Enter select a
recipient; choosing one opens the normal thread (existing conversation or a
fresh blank thread). Adds the supporting locale strings across all locales.
bottom-full anchored the bubble above the avatar block's top edge, which
margin-collapses up into the banner, pushing the bubble off the visible
area. Position it with a top offset within the same coordinate space the
original used, lowered so its tail rests at the banner's bottom edge.
- Anchor the NIP-38 status bubble to the bottom edge of the banner so it
no longer overlaps the follow/donate action buttons.
- Drop the founder/verified footer badges from the profile Agora tab so its
campaign cards render identically to the campaigns page CampaignCard.
- Recolor the 'raised' stat from text-orange-500 to text-primary to match
bio/profile hyperlink coloring.
The back arrow used top-5 on a size-9 padded button, sitting lower than
the X close button (top-4, size-5 icon). Move it to left-2 top-2 so its
icon center lines up with the X on the opposite side.
Overhaul AuthDialog's login step:
- Add translation keys for every previously-hardcoded string (titles,
buttons, placeholders, status labels, validation/errors) across all
sixteen locales.
- The secret-key form is no longer collapsible — it's always open and is
the first option, followed by 'Log in with extension', then a
text-with-arrow link to the remote-signer step.
- Move the key-file upload icon onto the same row as the nsec input
(instead of beside the submit button); submit button is now full-width
below.
- Surface extension-login errors as a destructive toast rather than
writing them into the nsec input's inline error.
- Replace the centered 'Back' text buttons with a back arrow in the
top-left of the dialog header.
Also correct AGENTS.md's i18n section: the project ships fifteen
non-English locales (hi, id, sw, tr, zh-Hant were missing from the list).
Logged-in users change language via Settings, so the top-nav globe now
appears only when logged out (matching the search icon). On the homepage
hero, replace the 'Explore campaigns' button with 'Verify campaigns' and
order it before 'How it works'. Drop the now-unused exploreCampaigns
locale key across all locales.
Add /sponsors, a landing-style page for companies that want to partner
with the platform. Lays out the three ways to get involved: an
unrestricted gift to the Agora Seed Fund (BTC or USD), a matching pool
for an individual campaign, a featured campaign, or a curated list of
campaigns (e.g. political prisoners or women's sovereignty in Africa),
and promoting Agora donations to a customer base as a philanthropic
initiative.
Modeled on AboutPage's section recipe (dark hero, alternating section
backgrounds, hand-rolled cards) and routed under the wide
FundraiserLayout above the /:nip19 catch-all. Adds a Sponsors link to
the site footer. English copy only for now; other locales fall back to
English until the copy settles.
ProfileCard's namePlaceholder default was a hardcoded 'Your name'
literal used by the profile/org editor onboarding steps. Default it to
the new profile.namePlaceholder key (translated across all locales) and
resolve it via t() in the component body; callers that pass their own
placeholder (e.g. the campaign name step) are unaffected.
Keys added to en.json over time (notably the verifier create-account
onboarding flow, the walletDoubleTweak recovery namespace, and the
campaign-wizard tag step) were never propagated to the other locales,
so they silently fell back to English at runtime — which is why the
verify-campaign onboarding steps still rendered in English under e.g.
Chinese despite the components calling t().
Translate every key present in en.json but missing from each locale
(84-157 keys each, 1553 total) so all sixteen locales are now complete
with zero English fallbacks. Placeholders, technical tokens, and the
Agora name are preserved verbatim; plural categories follow each
language's grammar.
The nsec reveal input in the onboarding 'Save your key' step carried a
hardcoded English aria-label. Route it through
onboarding.secure.secretKeyAriaLabel and translate across all locales.
This was the last untranslated user-facing string in the verifier
create-account flow.
Drop the 'Welcome to Agora' header and the Nostr-flavored subtext from
the welcome step. The Agora bolt mark and wordmark are now the focal
point — laid out inline with the same tight spacing and Bebas display
recipe as the top-nav wordmark, just larger — and the wordmark doubles
as the dialog's accessible title.
Retitle the two buttons to 'Create new account' / 'Log in with
existing' (no Nostr phrasing) and route them through new auth.* keys,
translated across all sixteen locales.
The crop modal used in the verifier 'set up your organization'
onboarding step had hardcoded English controls (Reset, the
reposition/zoom hint, Cancel, Processing, Apply Crop, and the default
title). Route them through a new top-level imageCrop namespace and
add translations for all sixteen locales.
The VerifyTutorial demo card's 'of … goal' label was also hardcoded;
reuse the existing campaignsDetail.ofGoal key.
Swap the top-nav language trigger from the Languages glyph to Globe — the
more universal "change language" affordance.
Stop flipping individual RTL rows: the previous per-item dir="rtl" pushed
Arabic / Persian / Pashto names to the far edge while LTR rows hugged the
left, leaving a ragged, jarring column. Force every row LTR-aligned
(text-left) so the selected dot and the names share one clean left edge,
while each name keeps its own lang attribute so the browser still shapes
Arabic/Persian/Khmer/CJK glyphs correctly.
A visitor who lands in a language they can't read had no discoverable way
to change it: on desktop the only chrome was the search icon + login, and
the picker was buried at /settings/language (itself only reachable through
the mobile hamburger's "Settings" — a word they may not be able to read).
Add a globe/languages icon to the top-nav right cluster, visible to
everyone regardless of login state. It opens a dropdown listing every
supported language by its own native name (each row rendered in its own
lang/dir), so the right entry is recognizable no matter the current UI
language. Selecting one switches in place via changeAppLanguage without
navigating away, so scroll position and in-progress form state survive.
The /settings/language page remains the canonical deep-link.
New key nav.language added to en.json and all 15 other locales (reusing
each locale's existing language.title translation).
Wire the branch-new hardcoded UI strings through i18n:
- ProfileCard's shared image-edit menu ("Upload file", "Paste URL",
"Remove") and the website-slot placeholder now use t(); the component
gains useTranslation.
- The campaign profile step's name placeholder regressed from a
translated string to a literal "Campaign title" when it switched to the
shared identity card; restore it via a new
onboarding.profile.campaignNamePlaceholder key.
New keys added to en.json and translated into all 15 other locales:
profile.imageMenu.{upload,pasteUrl,remove}, profile.websitePlaceholder,
and onboarding.profile.campaignNamePlaceholder.
The plural keys flagged by a first pass (campaignsCount, seeAllCampaigns,
followersTitle, profile.verified.count, etc.) already resolve via
i18next's _one/_other suffixes and needed no change. Pre-existing
untranslated strings that predate this branch (e.g. ImageCropDialog) were
left untouched.
Regression-of: a7a9ed06
These onboarding/campaign surfaces accumulated several copies of the same
logic over the iterative work. Fold them into shared modules:
- Kind-0 publishing: the campaign-creator profile step reimplemented the same
read-modify-write publish that usePublishOrgProfile already had (fetch fresh
-> parse -> merge name/picture/banner/about -> publish with prev), plus its
own parseProfileMetadata. Route it through the shared hook.
- Draft type: the identical ProfileIdentityDraft and OrgProfileDraft
interfaces become one ProfileDraft in src/lib/profileDraft.ts, alongside
parseProfileMetadata/mergeProfileDraft helpers. The hook no longer imports
its draft type from a component module.
- Auto-grow textarea: the campaign story and verifier bio fields shared an
identical borderless textarea with copy-pasted height-resize handlers and
styling. Extract an AutoGrowTextarea component plus an autoGrowTextarea
helper (also reused by ProfileCard's editable fields).
The action now sits in a compact row beside the avatar, so the verbose
'Edit profile' label is unnecessary. Shorten profile.header.editProfile
to just 'Edit' across all locales.
On sm/md the Edit Profile / QR / more (or Follow / Donate) buttons sat in
a full-width row below the bio. Put them on the same row as the avatar,
bottom-right, Twitter/X-style: ProfileIdentityHeader gains a hideActionBar
prop, ActionBar is exported with an align prop, and the mobile layout
renders the avatar and a right-justified ActionBar (align="end") together.
With align="end" the Edit Profile button sizes to its label instead of
stretching full-width.
The profile avatar was size-28 below md and size-32 at md+, so it shrank
on small screens. Pin it to size-32 everywhere and drop the matching
responsive overhang (-mt-20), status-bubble offset, and skeleton
placeholders to the larger variant so the layout stays aligned.
On /verify the VerifyTutorial rendered without the stacked prop, so its
DemoStage stayed capped at max-w-md inside the much wider max-w-2xl
verifier panel, leaving the animated campaign card looking too small for
its parent. Pass stacked so the demo spans the full container width.
Regression-of: 96c7acfc
The recent profile banner/sticky-rail experiments (the 3:1 cover crop,
desktop banner isolation, square corners, and the various sticky/pinned
identity-rail attempts) were reverted off this branch; they added
complexity without landing cleanly.
Re-apply the two wanted tweaks on the clean baseline instead:
- Banner height is now h-48 at every breakpoint rather than shrinking to
h-36 below md, so it no longer gets needlessly shorter on small screens.
- The avatar crop dialog draws a dashed circle inscribed in the existing
(square) crop boundary, tracking the cropper's actual rendered crop size
via onCropSizeChange so it stays flush as the boundary resizes. Wired in
for the avatar (picture) crops in ProfileSettings and the onboarding
ProfileIdentityEditor; banner/cover crops are unaffected.
The centered (non-stacked) demo card was capped at max-w-sm, which looked
undersized inside the wide card on the /verify page. Bump it to max-w-md so
it better fills the parent. The onboarding flow uses the stacked/full-width
path and is unaffected.
Swap the FormSection-wrapped mono textarea on "Tell your story" for the
borderless, auto-growing muted textarea used by the organization bio
step, so the two long-form surfaces look the same.
Fold the standalone "Add a banner" step into the "Name your campaign"
step (title required, banner optional), removing one wizard step.
Adjust the launch-shortcut step index and block advancing while the
banner is still uploading.
Add showBanner and a bioField "none" option to ProfileCard (threaded
through ProfileIdentityEditor), and drop the banner and bio from the
campaign creator's "Put a face to your campaign" step so it asks only
for an avatar and name.
Extract the verifier identity step's ProfileCard + crop/upload/paste/
remove machinery into a shared ProfileIdentityEditor, parameterized by
bioField ('website' for organizations, 'about' for campaigners) and an
aboutPlaceholder. VerifierIdentityStep now wraps it for the org flow.
The campaign creator's "Put a face to your campaign" wizard step now
renders the same banner + avatar + name + bio card instead of the old
plain name/avatar/collapsible-about form, with "A little about you…" as
the bio placeholder. The wizard already supplies the back arrow and
progress bar, and the published kind-0 now carries the banner too.
Remove the inline Withdraw button (and its props) from
VerifierStatementEditor, and drop the success toast from the onboarding
"Publish your verifier statement" step. Withdrawing now lives in the
top-right of the "How We Verify" card on the user's own profile — gated
on isOwnProfile, mirroring the Edit Profile affordance — with an
AlertDialog confirmation before retracting the kind 14672 statement.
Deduplicate the avatar and banner edit menus into a single ImageEditMenu
in ProfileCard: "Upload file" (replacing "Change avatar"/"Change banner"),
optional "Paste URL", and a generic "Remove" (replacing "Remove avatar").
The banner gains the Remove action via a new onRemoveBanner prop.
Fix the non-working Remove in the organization setup step: the verifier
identity step never passed remove handlers, and its onChange ignored
picture/banner — so removing an image did nothing. Wire onRemoveAvatar /
onRemoveBanner to clear the draft fields directly. Pass onRemoveBanner in
ProfileSettings too.
The org bio textarea now uses the same muted, borderless fill as the
'Your name' field on the identity step, starts at min-h-200, and
auto-grows as the user types. The verifier statement (Milkdown) editor
gets the same muted wrapper and a scoped 200px min-height so the two
steps match. The bio, statement, and how-to steps drop from max-w-3xl
to max-w-xl so the boxes and the tutorial's last step aren't oversized.
A pasted SVG (or any small source) was rasterized by the proxy at its
tiny intrinsic size, so the cropper showed a speck in the preview box.
Request a target width (1500 banner / 1024 avatar) with fit=inside so
small and vector sources are enlarged to a workable crop resolution.
Pasted image URLs were handed to the cropper as a raw remote src, so
encodeImage's canvas fetch hit the origin directly and failed CORS
(e.g. nips.nostr.com SVGs, or any host when the image proxy is off).
Now the paste handler fetches the bytes through the image proxy into an
object URL and feeds them through the same crop -> Blossom-upload flow
as a local file, so the cropper only ever sees a same-origin blob:.
Drop the clickable step buttons that let users scrub/pause the demo.
The animation already auto-advances and wraps around, so the gesture
now replays on an endless loop and users learn purely by watching.
The step list becomes a non-interactive read-out synced to the
animation. Removes the goto/paused reducer state and the
resume-after-scrub timer.
The 'About your organization' sub-header duplicated the step title.
Drop the visible Label and keep it as an aria-label so the textarea
stays accessible. Input text size already matches the verifier
statement editor (1.125rem / text-lg).
The how-to demo card now reflects a real published campaign (the Agora
App Development Fund) instead of invented placeholder copy: real title,
organizer, banner image, and goal/progress. The verified badge is now a
faithful copy of the live overlay CampaignVerificationBadge (dark
translucent pill, ring-bordered avatar, sky-300 check, no count) for a
single verifier, so the preview matches exactly how a verification
surfaces on a real card.
Drops the now-unused demo i18n keys (campaignTitle, campaignOrganizer,
verifiedBadge) across all locales.
The bio step now uses the same wide (max-w-3xl) layout and a large
text surface (min-h-[400px], text-lg) so it visually matches the
markdown statement editor, minus the markdown formatting.
Remove the badge preview from the org bio step and surface it on the
'How to verify a campaign' step instead, where the demo card now shows
the org's own avatar + name in place of the generic 'Verified by you'
label. Shorten the tutorial step descriptions.
Add hideHeader, bare, and stacked props to VerifyTutorial. The how-to step
now renders it borderless with the header hidden (single step header) and
the demo card stacked full-width above the step list so it matches the
button width below. Remove the tutorial from /organizations, leaving just
the 'Start verifying' CTA card.
Drop the 'Become a verifier' publish button: the statement step's primary
Continue button now publishes the kind 14672 statement and advances, with
an inline Withdraw for returning verifiers. Collapse the duplicated prompt /
disclaimer copy into a single header + subtext and make the editor
borderless. Replace the /organizations functional editor with a CTA that
launches the verifier onboarding flow.
Replace the plain avatar+name+website header with a preview that mirrors the
inline verification badge (stacked avatar + check + name), so the user sees
how their logo and name will surface to donors. Drops the website from the
preview.
Add a bioField prop to ProfileCard so the editable slot below the name can
edit the website instead of the bio. The verifier identity step now edits
the website inline (no separate input) and only requires avatar + name;
banner and website are optional, with website still https-validated when
entered.
Replace the placeholder shell with the real how-to step, reusing the
animated VerifyTutorial and a terminal 'View campaigns' CTA that cancels the
overlay and navigates to /campaigns. Widen the captive content wrapper for
the statement and how-to steps to fit the editor and two-column tutorial.
Pull the kind 14672 publish/update/withdraw editor out of OrganizationsPage
into a reusable VerifierStatementEditor with an onPublishedChange callback.
OrganizationsPage now consumes it (logged-out gate and verify tutorial
unchanged), and the captive verifier sub-flow renders it as step 3 with a
Continue button that unlocks once a statement is published.
Add usePublishOrgProfile, which merges the collected org draft (name,
website, picture, banner, about) onto any existing kind-0 via
fetchFreshEvent + prev, guarded by an expectedPubkey check so a failed
signup auto-login can't overwrite another account. The bio step's continue
now publishes the profile before advancing; failure is non-fatal (toast +
proceed).
A required bio textarea (kind-0 about) with a small avatar + name preview
header carried over from the identity step for continuity. The bio is added
to the shared profile draft; publishing of the assembled kind-0 profile is
wired up in the next commit.
Build the organization-identity screen on the shared ProfileCard (circular
avatar, rectangular banner, inline name) plus ImageCropDialog for uploads,
with a dedicated required website field. All four fields are required and
the website must be a valid https URL before continuing. The draft is held
in the captive overlay and published later; nothing is written here.
Add four sub-flow step keys (orgIdentity, orgBio, orgStatement,
orgVerifyHowto) that the verifier role enters instead of navigating away.
Wire the progress bar to the extended verifier step list, branch back/next
navigation through the sub-flow, and render placeholder shells. The
per-step UI lands in following commits.
Extend OnboardingRole and StartSignupOptions to include 'verifier', add a
third RoleCard to the role picker, and wire the pick handler to branch on
the new role. For now the verifier pick routes to the public /organizations
onboarding tool; a later commit replaces this with a captive sub-flow.
Remove Profile, Search, About, and Get the app items from the account
switcher dropdown. Show the active profile at the top of the profile
list (linking to your profile instead of switching), and reorder the
remaining items to Wallet, Notifications, My Dashboard, Settings.
Address two follow-ups from the Tor (arti) MR review.
Supply-chain hardening for the arti-mobile AAR, a native artifact with
network-proxy privileges:
- Pin the gpmaven Maven source to an immutable commit SHA
(guardianproject/gpmaven@b3ee2a6) instead of the mutable `master`
branch, so a force-push or new commit can't silently change what we
resolve.
- Verify the resolved AAR's SHA-256 at build time
(verifyArtiChecksum, wired ahead of assemble/bundle). A mismatch fails
the build before any APK is produced. Scoped to the one privileged
artifact rather than enabling global dependency verification, which
would force-verify every transitive dep.
Reconcile stale "apply on relaunch" / "next app launch" doc comments in
AppContext.ts, tor.ts, useTor.ts, TorController.java, and TorPlugin.java
with the actual behavior: the Advanced Settings toggle activates Tor
live via start/stop (arti starts/stops immediately, relay layer
remounts); the persisted flag only governs cold-launch auto-start.
Adds an opt-in Tor mode that routes all app traffic through a local
SOCKS5 proxy backed by arti (Tor in Rust), bundled via the
org.torproject:arti-mobile:1.7.0.1 AAR.
- TorController starts/stops arti and installs a fail-closed WebView
proxy override (no direct fallback) so traffic can't leak while Tor is
connecting or down. Connectivity is verified against
check.torproject.org (IsTor) and re-checked continuously; the exit IP
is surfaced for verification, and the status isn't latched so a dropped
circuit downgrades honestly.
- TorPlugin bridges enable/disable/status to the Capacitor/JS layer.
Toggling applies live, in place, with no app restart.
- UI: a slim fail-closed status banner (replacing the old full-screen
gate), the Tor toggle in Advanced settings reachable while logged out,
and Settings/Search/About added to the logged-out menu.
- R8 keep rules for org.torproject.arti.** so the JNI native-method
classes aren't stripped/renamed; androidx.webkit on the compile
classpath for the WebView proxy APIs.
The meta description still framed Agora as a generic Nostr social
client. Update it to describe the actual product: peer-to-peer
crowdfunding with an integrated non-custodial on-chain Bitcoin wallet.
Once an organization publishes its verifier statement on /organizations,
show an interactive walkthrough demonstrating how to verify a campaign:
a looping three-step animation on a mock campaign card where a cursor opens
the three-dots menu and selects 'Verify this campaign'. Step list is
clickable to scrub; motion is gated behind prefers-reduced-motion.
The campaign card wraps its contents in a react-router <Link>. The
moderation menu's VerificationDialog and CampaignListMembershipDialog
portal their DOM out of the card, but React synthetic events still
bubble through the component tree to the Link — so clicking anywhere
in either dialog navigated to the campaign page. Wrap both dialogs in
a span that stops click/pointerdown propagation, mirroring the guards
already on the dropdown trigger and content.
The 'Verify this campaign' control was gated on the hardcoded moderator
pack. Extend eligibility to self-declared verifiers (accounts with a
kind 14672 verifier statement): useCampaignVerifications now exposes
canVerify (moderator OR verifier) and its verify mutation accepts
verifiers.
The campaign moderation kebab (ModerationMenu / ModerationOverlay) now
renders for verifiers too, but shows only the verify row — the
moderator-only items (Hide, Add to list) and the Hidden badge stay
gated on moderator status. Pledge and group surfaces are unchanged.
Verifier profiles (accounts with a kind 14672 statement) now get a
'Verified' tab listing the campaigns they have vouched for, resolved
from the account's own agora.verified (kind 1985) labels via the new
useVerifiedCampaigns hook. The tab is surfaced first and becomes the
default on initial load (without overriding a manual tab selection),
on both desktop and mobile. Non-verifiers never see the tab.
Adds ProfileVerifiedTab, the useVerifiedCampaigns hook, and profile.tabs
/ profile.verified i18n keys across all locales.
Restore ProfileVerifierSection to its original spot as the first item in
ProfileOverviewSections (left sidebar on desktop, overview tab on mobile),
reverting the full-width-above-tabs placement. Keeps the 'How We Verify'
heading inside the box and no shield icon.
Surface a verifier's kind 14672 statement as a full-width band above the
profile tabs (below the header) on both desktop and mobile, instead of
tucked into the left identity rail / overview tab.
Drop the shield-check icon and rename the heading to 'How We Verify' —
the statement is a self-authored claim, not a platform endorsement, so
the heading stays neutral with no trust-implying badge. Rename the
verifier.profileSectionTitle key to verifier.howWeVerifyTitle across all
locales.
Drop the benefit-cards section between the steps and the editor, along
with the now-unused BenefitCard component, Users/Eye/cn imports, and the
organizations.why.* i18n keys across all locales.
Restore the deleted Milkdown WYSIWYG editor (its deps and CSS were still
installed) as a reusable component under src/components/markdown/:
MilkdownEditor, a formatting toolbar (bold/italic/headings/lists/links/
quote/code/hr + markdown help popover), and an insert-link dialog. All
strings are now i18n-driven via a new mdEditor locale block, translated
across every locale.
Wire it into the /organizations verifier-statement editor, replacing the
raw textarea + separate live preview with rich-text editing that emits
markdown. Fix the editor placeholder so it only shows while empty by
toggling a has-content class on the ProseMirror DOM.
Rework the /organizations page to match the /about landing style so it
doubles as marketing and a functional tool: a dark world-map hero with a
scroll-to CTA, a three-step onboarding section, benefit cards, and a
'get started' section housing the verifier-statement editor. Logged out
shows the org-profile login gate; logged in shows the full publish /
update / withdraw editor.
Move the route to the wide FundraiserLayout so section backgrounds span
the viewport, and expand the organizations i18n block (hero, steps, why,
getStarted) across all locales.
Replace the /settings/verifier route with a public /organizations help
page that onboards organizations onto Agora. Logged-out visitors are
prompted to log in with — or create — their organization's Nostr
profile; once signed in they get the full verifier-statement editor
that previously lived under settings.
Remove the Verifier entry from the settings list, delete
VerifierSettingsPage, and link /organizations from the site footer
instead. The old /settings/verifier path now redirects to
/organizations.
The Verifier page was the last settings page still rendering an in-header
subtitle. Drop it from both the logged-in and login-gate headers (the
verifier.subtitle string is still used as the SEO meta description).
Move the header's horizontal padding onto the inner content row so the
constrained column matches the body's max-w column exactly (previously the
header column was 32px wider than the body content under the narrow
max-w-3xl layout). Reduce the back-arrow-to-title gap from gap-4 to gap-1.5.
Migrate EventDashboard's header layout classes from className to
contentClassName to match the new model.
The bot value stays in the form schema and is preserved on save; only the
toggle UI is removed. Drops the now-unused Switch and Form sub-component
imports.
Delete the Website, Lightning address, sortable custom-fields list, and
add-field preset pills (the entire Profile Fields block) from the profile
settings form. Existing values are still loaded into form state, so they
are preserved on save and continue to render in the profile card and the
live sidebar preview — only the editing controls are gone.
Also removes the now-dead helpers, handlers, presets, SortableFieldRow
component, and unused imports.
- Add an opt-in contentClassName to PageHeader so a page can constrain the
header row to the same centered column as its body. Settings sub-pages now
pass max-w-2xl/max-w-xl so the title and back arrow line up with the cards
instead of floating against the viewport edge.
- Drop the entire header from the Settings hub (no title/back arrow); the
grouped card list now reads the same on mobile and desktop.
- Remove redundant subtitles and intro blurbs from Appearance, Language,
Network, Advanced, Profile, and Notifications.
- Delete the now-orphaned i18n keys across all locales.
Rework the Settings hub and sub-pages into iOS-style grouped inset cards:
- Hub groups rows into Account / App / System sections, each row gets a
colored gradient icon tile, with a rounded bg-card container and hairline
dividers instead of the flat full-width list.
- Appearance and Language pickers use the same inset-card list rather than
bordered selectable buttons.
- Network and Advanced replace the heavy accent-bar section headers with
uppercase group labels above clean bg-card panels.
- Add settings.groups.{account,app,system} strings across all locales.
Anyone can become a verifier by publishing a kind 15063 replaceable
event whose Markdown content describes how they vet campaigns. The
statement is surfaced prominently in the profile overview so donors
can judge whether to trust the account's judgement.
- Document kind 15063 in NIP.md
- useVerifierStatement / useSetVerifierStatement hooks (read-modify-write)
- /settings/verifier form page with live preview, publish/withdraw
- ProfileVerifierSection rendered first in the profile overview
- Localize all strings across every locale
Agora is a P2P crowdfunding client using on-chain Bitcoin with a
non-custodial HD wallet (BIP-86 Taproot, BIP-352 silent payments),
not a Lightning project. Update the overview, document the on-chain
wallet and crowdfunding hooks, and demote Lightning to secondary
tipping. Drop the stale useShakespeare hook reference.
A forward-jumping scan range (e.g. "Scan recent" on a wallet whose
cursor was far behind) recorded the skipped gap as scanned: runScan set
scanHeight = max(scanHeight, highestContiguousScanned) where contiguity
was measured only from the range's own fromHeight. The auto-scanner then
resumed from the new cursor, so payments received in the gap were
silently never discovered.
The cursor now only advances when the scan range is contiguous with the
effective persisted cursor (relay scanHeight or local checkpoint).
Non-contiguous scans still merge any UTXOs they find — only the cursor
is held back, so a later auto-scan pass walks the gap. Never-scanned
wallets (cursor 0) keep the documented bootstrap jump to a recent
window.
The people receiving donations are not necessarily activists — they are
often simply people in need (e.g. in Venezuela and Gaza). Rename the
user-facing term from "activist" to "recipient" across all copy and the
underlying code.
- Rename ActivistGuidePage -> RecipientGuidePage and the /about/activists
route -> /about/recipients (legacy URL kept as a redirect).
- Rename guide structure/helpers, the paymentComparison audience literal,
and the related i18n key namespaces (activistGuide -> recipientGuide,
about.guides.activist -> .recipient, guides.activist -> guides.recipient,
activistRows/activistHeader -> recipientRows/recipientHeader,
ctaActivist -> ctaRecipient).
- Translate the term in all 16 locales, plus fix stale "activists" hero
taglines left over from 0233a75d in id/hi/sw/tr/zh-Hant.
The Private wallet tab only showed a spinner plus 'Scanning… block X / Y'
text while the silent-payment scanner ran, which gave no sense of how far
along the scan was. Render a Progress bar (matching the Scan options
dialog) above the block-count text so completeness is visible at a glance.
Profile and detail pages that render music tracks, podcast episodes, or
audio kinds call useAudioPlayer(), but the AudioPlayerProvider and its
companion floating-bar / navigation-guard components were deleted as
"orphaned" in 256560cf — they were never wired into App.tsx, so the hook
always threw "useAudioPlayer must be used within AudioPlayerProvider".
Restore the provider, MinimizedAudioBar, and AudioNavigationGuard, re-export
AudioPlayerContext from the context def, wrap AppRouter in the provider, and
render the two router-dependent components inside BrowserRouter.
Regression-of: 256560cf
Three changes target the scan pipeline's two bottlenecks (HTTP latency
and main-thread ECDH):
- Cache per-block tweak + UTXO data in IndexedDB (blockCache.ts) and
route fetches through it. Mined blocks below the indexer tip are
immutable, so a repeat or overlapping scan now costs zero round-trips
for blocks already seen. Keyed by (indexerUrl, height); degrades
gracefully when IndexedDB is unavailable.
- Move the secp256k1 ECDH + per-output Pk derivation into a dedicated
Web Worker (scan.worker.ts + scanWorkerClient.ts). The scan loop no
longer has to setTimeout(0) every 64 entries to keep the UI
responsive, and fetching now overlaps with ECDH. The client falls back
to a main-thread scan if the worker can't be constructed or errors on
a block. bscan stays on-device — a same-origin Worker is not the
remote scan-helper pattern BIP-352 contemplates.
- Make fetch concurrency tunable via AppConfig.bip352ScanConcurrency
(clamped to [1, 32], default 8) so a fast self-hosted indexer can run
hotter than the polite public-host default.
Moderators had no way to hide/verify/add-to-list a campaign from its
detail page — the hero toolbar only showed creator (edit/delete) controls.
Follow the same pattern as ActionDetailPage (pledge detail): place a
ModerationMenu in both the mobile and desktop toolbars of CampaignHero.
It returns null for non-moderators so no conditional wrapping is needed.
- Add coord and entityTitle props to CampaignHeroProps; thread them from
CampaignDetailContent (campaign.aTag, campaign.title)
- Mobile: ModerationMenu sits in the right-side controls group, before
the creator edit/delete buttons
- Desktop: ModerationMenu appears at the far right of the top overlay bar
(after creator controls when present), styled with the same
bg-black/30 backdrop-blur chip as ActionDetailPage
On /campaigns/lists/:slug, ListMemberCard rendered its own reorder/remove
kebab at top-3 right-3 z-20, directly on top of CampaignCard's ModerationOverlay
(also top-3 right-3 z-10). The ListMemberCard kebab always won stacking order,
blocking moderators from accessing moderation actions on list-page cards.
- Add showModerationMenu prop to CampaignCard (default true); when false,
ModerationOverlay renders only the Hidden badge (no menu trigger), keeping
hide-state visibility without adding a second kebab in the same corner.
- ListMemberCard now passes showModerationMenu={false} to CampaignCard and
embeds ModerationMenuItems directly into its own dropdown (below a separator
after the reorder/remove items), giving moderators a single combined menu
with both list-management and moderation actions.
- Dialog state (CampaignListMembershipDialog, VerificationDialog) is hoisted
to ListMemberCard so the dropdown-unmount lifecycle doesn't tear them down.
Scan progress was only persisted to the relay on a match or at end-of-scan,
so refreshing mid-scan re-scanned every block since the last completed scan.
Add a free, synchronous localStorage checkpoint of the advancing scanHeight
(throttled to 5s, plus a final write on finish) scoped per pubkey+indexer.
The auto-scanner now resumes from max(relay scanHeight, local checkpoint),
bounding mid-scan-refresh re-scan to ~5s of blocks without adding any
encrypt/sign/broadcast traffic. Relay publishing stays gated to matches and
end-of-scan for cross-device sync; manual scans still honor the exact
fromHeight the user picks.
Lift the silent-payment orchestrator into an HdWalletSpProvider mounted at
the app root so a single shared scan instance keeps running across page
navigation. Add a background auto-scanner that resumes from the last
persisted scanHeight and keeps up with the chain tip without any user
action, bounding the first pass on a never-scanned wallet to ~7 days of
blocks. Surface live scan state on the Private wallet tab and add an
auto-scan toggle to the scan dialog.
Add an inline variant of CampaignVerificationBadge that reads on a light
page surface (bordered chip with a 'Verified' label) and render it next to
the author byline in the campaign detail heading. The overlay variant (dark
pill) is unchanged for cards over banners.
New i18n key campaignVerification.verifiedLabel across all locales.
Clicking 'Verify this campaign' now opens a VerificationDialog instead of
publishing immediately. The dialog previews the moderator's own avatar
with the verification checkmark (as it appears on verified campaign cards)
and shows the attestation the moderator is making before confirming.
- New VerificationDialog component (avatar + checkmark preview + attestation
text + Cancel / Verify actions).
- ModerationMenu owns the dialog state and the verify mutation, hoisted out
of the dropdown content (which unmounts on select) like the existing
add-to-list dialog; passes onRequestVerify down to CampaignVerifyItem.
- Removing a verification stays inline (no confirmation).
- New i18n keys dialogTitle + attestation across all locales.
Verification is gated by the existing campaign moderator pack
(useCampaignModerators / CAMPAIGN_MODERATORS), not a separate allowlist.
- Remove the config.labelers field (AppContext interface, Zod schema,
App.tsx and TestApp defaults) and delete useCampaignLabelers.
- useCampaignVerifications now reads/writes agora.verified labels gated
on the moderator pack (isModerator), same authors filter as the other
label streams.
- Move the verify / remove-verification row INSIDE the moderation kebab's
'Moderator actions' section (leadingExtra slot of ModerationItemsShell),
no longer a top-level item above the section label.
- Revert the isMod || isLabeler widening in ModerationMenu/Overlay back to
plain isMod.
- Remove the trailing 'Verified' checkmark text from 'Remove my
verification'.
- Rename labeler->moderator in agoraVerification, the badge component, and
all locale strings; drop now-unused notVerified / verifiedState keys.
- Update NIP.md to document verification as a moderator action.
The verify / remove-verification row now renders above the moderator
rows. The moderator section's own 'Moderator actions' label provides the
visual separator, so no extra divider is needed between them.
The verify / remove-verification controls now live as rows in the
campaign 'Moderator actions' 3-dots menu instead of inside the
verification badge popover. The badge over the card is now display-only
(stacked verifier avatars + a popover listing them, linking to profiles).
Because labelers are a distinct allowlist from the moderator pack, the
campaign moderation menu/overlay now mounts for a user who is a mod OR a
labeler; the verify row itself is gated on labeler membership, while the
hide/add-to-list rows stay moderator-only.
Trusted labelers (AppConfig.labelers) can vouch for campaigns via NIP-32
kind 1985 labels in the new agora.verified namespace. Verifier avatars
render as a stacked badge on campaign cards; hovering/clicking opens a
popover listing verifiers (linking to their profiles). Logged-in labelers
get verify / remove-verification controls — verify publishes a label,
unverify issues a kind 5 deletion of their own label.
The read query filters by authors: labelers so verifications from outside
the allowlist are never honored. Kind 1985 label reqs are routed to the
search relays (relay.ditto.pub, relay.dreamith.to) in NostrProvider.
Expose the Esplora API endpoints, Blockbook URL, and BIP-352 silent
payment indexer (already wired through AppConfig) in a new Bitcoin
section on the Advanced settings page. Each field validates input,
normalizes trailing slashes, and offers a reset-to-default button;
clearing the indexer URL disables silent-payment scanning.
The home hero row sliced the Featured Campaigns list to the first 6
members (FEATURED_HERO_CAP). Drop the cap so the row renders every
campaign the curator has added — the existing hero layout (two
column-spanning cards + 4-up tail) already scales to any count. The
skeleton placeholder count stays bounded by FEATURED_SKELETON_CAP for
the loading state only.
Switch the home-page hero row from the World Liberty Congress curated
list (d=world-liberty-congress) to the Featured Campaigns list
(d=featured-campaigns), both curator-published kind-30003 NIP-51
bookmark sets.
Replace the WLC org-branded heading (avatar, name, npub profile link
pulled via useAuthor) with a plain 'Featured Campaigns' heading that
links to /campaigns/lists/featured-campaigns. Drop the now-unused
Avatar / useAuthor / genUserName imports and the WLC pubkey/npub
constants.
Rename the WLC_* constants to FEATURED_* and replace the wlcDesc
locale key with featuredTitle + featuredDesc across all 16 locales.
The campaigns grid renders up to 200 CampaignCards at once with no
virtualization, and each card eagerly ran useCampaignDonations: an
Esplora /address lookup per card (polling every 30s via refetchInterval)
plus a /tx verification call per kind-8333 donation receipt. On first
paint that's hundreds of /address calls and potentially thousands of
/tx calls in one burst, then 200 more /address calls every 30s forever.
Once mempool.space starts returning 429, the failover client retries
each call across every configured endpoint, cascading the rate-limit to
all of them.
Gate the donation lookup behind an IntersectionObserver so only on-screen
cards talk to Esplora, and drop the per-card 30s polling. The detail page
(a single instance, not a grid) opts back into the 30s live refresh via a
new refetchInterval option.
- Add useInView hook (once-only, rootMargin pre-arm, IO-less fallback).
- useCampaignDonations gains enabled + refetchInterval options; default
no polling, longer staleTime; treat 'not enabled yet' as loading so
off-screen cards show a skeleton instead of flashing '0 raised'.
- Gate CampaignCard and CampaignInlinePreview on visibility.
- CampaignDetailPage opts into refetchInterval: 30_000.
Sending from the public (BIP-86) wallet to your own silent payment
address co-mingles the resulting silent-payment UTXOs with coins
already exposed on the public ledger, linking the private wallet back
to a known on-chain identity and defeating its purpose.
Unlike the private-wallet reuse guard (which offers a 'send anyway'
acknowledgement), this is disallowed outright: the Send button stays
disabled and a blocking notice explains why.
The previous commit split the wallet into strictly-isolated Public
(BIP-86) and Private (silent-payment) balances that can never be spent
together. Update the user-facing docs to match:
- /about: the Silent Payments card now notes private funds are kept in
a separate balance, never mixed with public ones.
- Activist Guide: the receiving section explains the Public/Private tabs
and why mixing would re-link silent-payment donations; a new callout
covers cashing out from the Private tab to an sp1… address and the
'Send anyway' address-reuse guard; the cash-out intro now points at
the Private tab.
- Wallet FAQ: notes the two strictly-separated tabs.
All copy mirrored across the 15 non-English locales.
Silent-payment (private) UTXOs were combined with BIP-86 (public) UTXOs
during coin selection, and silent-payment change was sent to a public
BIP-86 change address. Both linked private funds back to the public
wallet on-chain, defeating the unlinkability silent payments exist to
provide.
Enforce strict UTXO isolation at the PSBT-build layer: a spend now
carries a `walletScope` and the coin selector only ever sees the matching
input kind (public → BIP-86, private → SP). Private-wallet change is
routed to a fresh BIP-352 output back to the wallet's own sp1 address, so
it re-enters the private wallet instead of leaking to a public change
address. The two UTXO sets can no longer be spent together.
Surface the separation as Public / Private tabs on /wallet, each with its
own balance, receive QR (bc1 vs sp1), send flow, and transaction history.
Private sends to a bare on-chain address are gated by an address-reuse
guard: when the recipient chip is selected, a Blockbook getAccountInfo
probe checks for prior on-chain history, and the address is also checked
against the user's own public wallet. Either case requires an explicit
"Send anyway" acknowledgement; the Send button is disabled while the
probe is in flight. Sending a silent payment to an sp1 address has no
such warning.
- Patch react-router open redirect (GHSA-2j2x-hqr9-3h42) within 6.x
- Bump vitest to 4.x to fix UI server file read/exec (GHSA-5xrq-8626-4rwp)
npm audit now reports 0 vulnerabilities; full test suite passes.
The replies section heading ('Replies'/'Comments') and the count noun
('reply'/'replies'/'comment'/'comments') were hardcoded English literals
adjacent to the placeholder fixed in the previous commit. Move them to a
new postDetail locale section with i18next plural keys, translated across
all locales.
The placeholder used a hardcoded 'Write a reply...' literal and referenced
a nonexistent compose.activityPlaceholder key (which fell back to the raw
key string). Switch to translated replyModal.placeholder.writeReply /
writeComment keys and add writeReply to all locales.
- Add a 'Get the app' external link to the TopNav drawer menu, hidden
inside the native app.
- Move the home-page nudge out of the feed (it now lives on the actual
home page, CampaignsPage) and give it pt-8 breathing room above.
- Remove redundant pb-16 on main and shrink the footer's top gap on
mobile (pt-6 sm:pt-12) so spacing above the footer isn't doubled.
Remove the !loggedIn gate that hid the 'Explore campaigns' CTA from
logged-in users. The campaigns home page shouldn't gate this button
behind login state.
Prompt mobile-web visitors to install the native Android app from
Zapstore. Shows a card at the bottom of the home feed and a link in
the account switcher menu. Both are hidden inside the native app
(Capacitor.isNativePlatform) and on desktop (sm:hidden for the banner).
Adds nav.getApp and feed.getApp.* strings across all locales.
The Android 12 splash vector (splash_icon_vector.xml) and the legacy
splash PNGs still showed the old Ditto cat / single-bolt logo. Replace
the splash vector with the current double-bolt glyph from logo.svg and
regenerate splash_icon.png and all port/land splash PNGs as the white
double-bolt on the dark splash background.
The 720x880 logo.svg was force-rendered into a 512x512 square, stretching
the bolt horizontally. Render height-bounded (aspect-preserving) instead
and let the centered composite handle fit. Regenerate all Android, iOS,
and web/PWA/Zapstore icons.
The Android launcher icons and adaptive foreground were the old icon
(orange-circle single bolt / stale Ditto vector), and generate-icons.sh
still sourced the purple Ditto branding. Rewrite the generator to use
the current logo.svg double-bolt glyph in white on the brand orange
(#e9673f) circle, and regenerate:
- Android legacy + adaptive launcher icons (all densities)
- adaptive icon background color -> #e9673f
- iOS AppIcon
- web/PWA/Zapstore icons (logo.png, icon-192, icon-512, apple-touch-icon)
Also remove the unused stale adaptive foreground vector so it can't
shadow the regenerated foreground PNG.
packageRelease failed with 'Given final block not properly padded'
because the migrated PKCS12 entry was protected with the store password,
not the key password Gradle read from key.properties. Write the PKCS12
with a single uniform password ($KEY_PASSWORD) for store and entry, and
point both storePassword and keyPassword at it.
R8 release minification failed on a missing com.google.gson.annotations
.SerializedName referenced by the OutSystems barcode plugin. Suppress the
Gson missing-class warning, keep annotations, and keep the plugin's model
classes so serialized fields survive shrinking.
@capacitor/barcode-scanner v3 pulls in ionbarcode-android:2.0.1, which
declares minSdk 26. The inherited Ditto minSdk of 24 fails the manifest
merger. Raise the floor to 26 (Android 8.0) as the merger recommends.
The build-apk JKS->PKCS12 migration only supplied the store password,
so keytool prompted for the upload key's distinct password on the
non-interactive runner and failed with 'Too many failures - try later'.
Pass -srckeypass/-destkeypass ($KEY_PASSWORD) to match key.properties.
The qrcode library hard-codes inline width/height pixel styles on the
canvas, overriding the Tailwind sizing classes (h-auto w-full) callers
pass in. On viewports narrower than the QR's intrinsic size this made
the code spill outside its rounded box — visible on the campaign
details donate panel. Remove the inline styles after rendering so the
caller's className controls the responsive size.
Switching to a custom (manual-entry) wallet used to drop the friendly
accept picker entirely, leaving two unlabeled-purpose address inputs.
Restore the hand-holding: add an intro line restating the field-driven
model (public address, private code, or both) and label each input
with its meaning. The public/on-chain input is marked with a Bitcoin
icon and a 'Public. Anyone can see these donations.' caption; the
silent-payment input with an EyeOff icon and a privacy caption. Both
inputs keep the Wallet leading icon. Updates all 16 locales.
Replace the three terse jargon pills (Accept All / Public Only /
Private Only, captioned with 'on-chain' and 'silent payment') with a
vertical stack of selectable option cards. Each card has a friendly
icon, a plain-language title, and a one-sentence reassurance written
for an anxious first-time creator, with the SP-dependent options
clearly disabled when the login can't support them.
Also softens the wallet hero card: drop the linked-icon trio for a
simple campaign-to-wallet arrow, and rewrite the copy without the
key/posts technical aside or em dashes. Updates all 16 locales.
Redesign the 'My wallet' branch of the campaign wizard's donation
destination step. Replace the plain identity+balance row with a
primary-tinted hero card modelled on the onboarding 'Save your key'
surface: a linked-icon trio (campaign -> key -> wallet) explains that
donations land in the creator's own Agora wallet unlocked by the same
key that signs their posts, with the avatar+live-balance chip
confirming the exact destination and a ShieldCheck reassurance line
below.
Both PostDetailShell (Nostr event details) and ExternalContentPage
(NIP-73 external content like bitcoin:tx) rendered their <main> with no
max-width under the wide layout, stretching edge-to-edge on large
screens. Add w-full max-w-3xl mx-auto to match the narrow-layout column
width used elsewhere.
Drop the optional `deadline` tag from kind 33863 campaigns. Removes the
date input and validation from the create/edit form, the deadline chips
on the card, detail, and inline-preview surfaces, and the derived
"ended" state that disabled donations after the deadline. Cleans up the
associated locale keys and NIP.md documentation.
The home page serialized its first paint behind relay.ditto.pub:
useCampaignLists queried that one relay via nostr.relay(DITTO_RELAY)
(awaited, up to an 8s timeout) and every hero campaign was gated on
its result, so a slow ditto.pub stalled the whole page. Connection
sharing made it worse — pooled queries multiplexed onto the same
stalled socket.
Switch the home-critical moderation/list/discovery hooks from
single-relay nostr.relay(DITTO_RELAY) calls to the parallel pooled
nostr.query() fan-out:
- useCampaignLists: authors:[curator] filter enforces trust; relay
pin was unnecessary and headed the waterfall.
- useCampaignModeration: authors:[moderators] filter enforces trust.
- useFeaturedOrganizations: per-author filters enforce curation.
- useDiscoverCommunities: global discovery — fan-out broadens coverage.
useDashboardCounts stays pinned: NIP-45 COUNT is a single-relay
primitive and isn't mergeable across relays, and it's off the home
critical path.
Regression-of: 3d825aef
The home page hero row is already a moderator-curated kind-30003 list,
so re-filtering its members through the agora.moderation hide axis was
redundant: a campaign that shouldn't appear simply shouldn't be on the
list. The hidden-filter only mattered in the narrow window where a
listed campaign also carried a moderator hidden label, and it cost an
extra limit:2000 kind-1985 query to DITTO_RELAY on every landing-page
load for that edge case.
Render the curated list verbatim, in list order. Label-based hidden
moderation still lives on /campaigns and every other surface; only the
home hero row stops consulting it.
The home page (CampaignsPage) called useCampaignModeration() solely to
drop hidden campaigns from the WLC hero row, which fired a kind 1985
label query (limit 2000) on every initial load just to check ≤6
curated coords. Remove the dependency: the hero row now only reorders
to the moderator-curated list order. Hidden-campaign moderation already
lives entirely on /campaigns, so the home page no longer needs it.
The 'Browse all campaigns' Link on the home page renders an <ArrowRight>
lucide icon next to t('campaigns.home.browseAll'), but the translated
string itself ended in '→' (or '←' for RTL locales), so the button
displayed two arrows. Strip the literal arrow from all 16 locale files
and let the icon do the visual work — it already handles RTL via
rtl:rotate-180 in CampaignsPage.tsx.
The home page used to serialize two single-relay round-trips before any
campaign card could render: useCampaignModerators fetched the Team Soapbox
follow pack (kind 39089), and useCampaignLists waited on it to apply an
authors: gate. Each could stall up to an 8s EOSE timeout against the app
relay.
Both lookups are now eliminated from the critical path:
- CAMPAIGN_MODERATORS in agoraDefaults.ts is a hardcoded snapshot of the
pack's p tags. useCampaignModerators serves it synchronously (no
queryFn network call), keeping its useQuery return shape so all ~15
consumers work unchanged. The roster changes rarely; update the array
and re-cut a release when it does.
- Lists are an editorial surface curated by one identity (MK Fain / Team
Soapbox), not the whole moderator pack. useCampaignLists now pins
authors: to LIST_CURATOR_PUBKEY and no longer depends on the moderator
query at all. The multi-author allowlist remains for labels only
(approve/hide), where any pack member is trusted.
Regression-of: be1fadfc
CampaignCard now paints immediately and shows a dedicated skeleton for
the funding/progress bar while useCampaignDonations resolves, instead of
flashing a misleading "0 raised" before the on-chain balance lands.
useCampaignLists no longer serializes behind useCampaignModerators: the
list relay query fires immediately on the hashtag filter and the
moderator allowlist is applied client-side in foldCampaignLists. The two
single-relay round-trips (each up to an 8s EOSE timeout) now run in
parallel on cold sessions. The trust gate is unchanged — a list authored
by a non-moderator is dropped before it reaches the UI.
Replace the generic "Three things that make us not like the
others." with copy that names the actual mechanism and three
specific threat models the section addresses:
"Direct Bitcoin from donor to activist. No platform in the
way, no custodian holding the bag, no permission required."
Three short clauses, one per block:
• "No platform in the way" sets up block 1 (vs GoFundMe / Stripe /
Visa platform censorship).
• "No custodian holding the bag" sets up block 2 (vs other
"Bitcoin" platforms with Lightning custodians / LSPs).
• "No permission required" sets up block 3 (the public/private
receiving choice — your threat model, your call).
Updated across all 11 locales. The headline ("Built different.")
stays in place; the lede now carries the weight that the giant
Bebas Neue display headline needs as support.
Drop the trailing "and similar sites" qualifier across all 11
locales. Matches the shorter form already used in the
\`about.twoWays.noCustody.gofundme.heading\` key, so the two
surfaces ("home / why different" and "/about / no custody")
now read consistently.
Drop the brand-orange band entirely. The section now sits on the
canonical \`bg-background\` so it reads as a continuation of the
home page, not a separate marketing slab. No more navy/slate
surfaces.
New visual structure:
• Decorative spine — a soft vertical brand-orange gradient line
runs down the left margin (md+), evoking an editorial /
manifesto feel without changing the page surface.
• Eyebrow framed with brand-orange leader lines + tracking-wider
"WHY ÁGORA" wordmark — reads like a chapter marker.
• Giant Bebas Neue italic display headline at scale (text-5xl
→ text-7xl), uppercase, stroke-painted. The headline now
earns the visual weight that a colored background was doing
before. Matches the page hero typography exactly.
• Three numbered chapters (01 / 02 / 03), each anchored by a
massive italic Bebas Neue numeral in brand orange paired
with a thin orange seam line. No card chrome — chapters sit
directly on the page background so the section reads as
continuous editorial copy, not three boxed tiles. Each
chapter has heading + mission paragraph + brand-orange
✓ checklist for blocks 1-2.
• Block 3 uses a split-card public/private cell pair with
brand-orange (public) and muted (private) tints, framed in
a single rounded border — a tiny diagram of the "your
choice" framing rather than a generic bullet list.
• Soft brand-orange halo behind the headline (CSS only, blur-3xl)
for depth.
• Closing CTA is now a small text-link with an underlined wordmark
and a chevron that nudges on hover — quieter than a button,
consistent with the editorial idiom.
Drops the indigo accent that the v1 design used and standardizes
on brand-orange + neutral foreground/muted-foreground tokens,
which means dark mode inherits the canonical dark surface and
typography automatically.
No new translation keys; reuses the existing
\`campaigns.home.whyDifferent.*\` strings as-is. Dropped unused
\`Bitcoin\` and \`ShieldOff\` icon imports.
Background was cream-on-light / dark-navy-on-dark. Swap to a
brand-orange band (`bg-primary`) with dark slate type on top:
- Section heading: `text-slate-900` for ~9:1 contrast on orange
- Eyebrow: `text-white/90` (label-on-orange feel, AA on hsl(24 100% 50%))
- Lede: `text-slate-800/90`
- Cards: solid white in light mode (was `bg-white`-on-cream, now
reads as crisp surfaces lifted off the orange) and dark slate
in dark mode; copy is slate-700/-600
- Card shadows bumped to `shadow-md` so cards sit proud of the
saturated orange instead of disappearing into it
- Block 3 accent changed from indigo to neutral slate so the
third card doesn't compete chromatically with the orange band
- Read-the-full-breakdown CTA is now a solid dark-slate pill
with white text (instead of an outline button that disappeared
on the new background)
Three-block info band beneath the WLC hero row and topic-list
strip explaining what makes Ágora different:
1. Unlike GoFundMe and similar sites — no platform freeze, no
payment-processor middleman, zero platform fees.
2. Unlike other "Bitcoin" platforms — no central Lightning
node, custodian, or LSP; settles on-chain to a wallet you
control.
3. Public or private — receiving-option contrast (Bitcoin
on-chain vs BIP-352 silent payments) with a CTA to the
long-form breakdown at /about#how-it-works.
Visual idiom matches the AboutPage sections (cream / dark-navy
band, brand-orange eyebrow, Inter Bold heading, RailCard-style
cards with icon chip + checklist) so the home page reads as a
shorter front-door version of /about. Always visible — the
value prop is part of the home page identity, not gated on
auth state.
Strings live under `campaigns.home.whyDifferent.*` with full
translations in all ten canonical locales (ar, es, fa, fr, km,
ps, pt, ru, sn, zh). Technical tokens (GoFundMe, Stripe, Visa,
Bitcoin, Lightning, LSP, BIP-352, QR) and the {{appName}}
placeholder are preserved verbatim across locales.
A blank `translateWorkerUrl` saved to localStorage was shadowing the
build-time default in the config merge, so the Translate button's
"no worker configured" guard hid it even when VITE_TRANSLATE_WORKER_URL
was set. Coalesce an empty persisted value back to the default, and stop
the Advanced Settings field from persisting an empty string on blur.
The link encouraged users to navigate away from the home page to
see members beyond the visible cap. The home page is the
editorial surface; if a campaign isn't in the visible cap, that's
the curator's call. Cleanup drops the link, the campaigns.home.viewFullList
key across all 16 locales, and nothing else.
The home page's hero row was driven by kind-1985 'featured' /
'unfeatured' moderation labels (the campaign-specific Featured
axis). Now that curated lists exist, the WLC-published list with
d='world-liberty-congress' is a strictly better mechanism: same
trust model (moderator-published), explicit ordering (positional
'a' tags instead of a separate rank stream), and the membership is
edited through the same Add-to-list flow that powers every other
list.
Changes:
- CampaignsPage: replace the Featured row with a hero row backed by
useCampaignList('world-liberty-congress'). Capped at 6 entries
with a 'View the full list' link to the list's detail page when
there's overflow. The WLC avatar/name/check still anchor the
heading. The empty state covers both 'no list yet' and 'list
exists but empty'.
- CampaignCard: drop the verifiedBy prop and the WLC verified-by
chip. Nothing else passed verifiedBy.
- CampaignCard: stop opting into the 'featured' axis on the
moderator kebab. Only 'hide' remains for campaigns.
- ModerationMenu / ModerationOverlay: strip the reorder prop chain
(only the deleted Featured row consumed it). Pledge / group
surfaces keep their 'featured' axis since their featured shelves
are unchanged.
- Delete useReorderCampaign, ReorderableCampaignGrid,
ReorderProvider, reorderContext — the campaign-rank reordering
infrastructure they served is gone.
- Update i18n: drop campaigns.home.featured, featuredDesc,
verifiedByAria across all 16 locales. Add wlcDesc and
viewFullList. Translations dispatched in parallel.
The featuredCoords / featuredOrder fields in the shared moderation
fold (agoraModeration.ts) stay — they're still consumed by
useFeaturedOrganizations (groups) and usePledgeModeration (pledges).
Existing kind-1985 'featured' labels referencing campaign coords
become inert: nothing reads them, but the label namespace is
shared so we don't garbage-collect them.
The strip used overflow-x-auto with a thin scrollbar, which cut off
pills past the viewport edge on smaller screens. Switch to
flex-wrap so the pills flow onto multiple rows and stay fully
visible without any scroll affordance.
The home page is meant to be tightly curated — Featured row + topic
strip + browse-all CTA. Even keeping the Hidden collapsible closed-
by-default for moderators meant the home page was carrying a
review surface that belongs on /campaigns, where the Show-hidden
toggle is already available to everyone and the structured Hidden
collapsible already exists.
Drops the Hidden section's rendering and all of its supporting
state: the recent-stream useCampaigns call, the targeted hidden-
coord useCampaigns call, hiddenCoordList, hiddenCampaigns, isMod,
plus the imports they kept alive (EyeOff, ModeratorCollapsibleSection,
CampaignGridSkeleton).
The chronological 'All campaigns' grid on the home page duplicated
what /campaigns already does better (search, sort, country filters,
unbounded scroll). Swap it for the curated topic-list strip
(CampaignListsStrip) followed by a single 'Browse all campaigns'
CTA that links to /campaigns. The Verified hero row above and the
moderator-only Hidden section below are unchanged.
Removed the no-longer-needed allCampaignsChronological derived
state, featuredCoordSet O(1) lookup, useReorderCampaign /
useToast / onFeaturedMoveToTop/Up/Down callbacks, and the
ConditionalReorderProvider helper that wrapped the chronological
grid for moderators.
useCampaignLists caches its query for 30 seconds, so a moderator who
added a campaign to a list from one surface (e.g. the list detail
page) and then opened the per-campaign membership dialog for the
same campaign from another card would see stale 'Add' buttons for
those lists until the cache expired.
Invalidate the campaign-lists query whenever the dialog opens so the
membership state always reflects the latest published revisions
without requiring a page refresh.
Two fixes for the curated lists feature:
1. Clicking an Add/Added toggle in the per-campaign membership dialog
was navigating to the campaign's detail page. Although Radix Dialog
portals content to document.body, React's synthetic events still
bubble through the React tree — past the Link that wraps the
CampaignCard the moderator opened the kebab from. Stop propagation
on the toggle's click handler and at the DialogContent root.
Applied the same stopPropagation to ListFormDialog and IconPicker
since both can mount inside the membership flow.
2. The campaign-search dialog opened from a list detail page was
surfacing campaigns hidden by moderators. Filter the search
results through useCampaignModeration.hiddenCoords so suppressed
campaigns don't get encouraged into curated lists. Existing list
members that later get hidden remain visible in the dialog so a
moderator can still remove them.
Adds a new row at the top of the moderator dropdown on campaign cards
(both / and /campaigns) that opens a per-campaign list-membership
modal. Each known curated list renders as a row with the campaign's
current membership state — toggling immediately publishes a new
revision of the list event through useCampaignListActions, so a
moderator can multi-tag a campaign without leaving the dialog. The
modal also exposes a '+ New list' shortcut that runs the standard
create flow and auto-adds the campaign to the just-created list.
The membership dialog's state is owned by ModerationMenu (the kebab
trigger), not by the dropdown content. Radix unmounts content on
close, so a sibling dialog rendered inside DropdownMenuContent would
be torn down on the same tick the user clicks the item. Lifting the
state to the trigger lets the dialog survive the menu closing.
Lists are NIP-51 kind 30003 Bookmark Sets authored by Team Soapbox
moderators (the same allowlist gating Featured / Hidden), carrying
the 'agora.campaign-list' hashtag plus a custom 'icon' tag holding a
Lucide icon name. Membership order is encoded in the order of the
'a' tags on the event; the order of the topic strip itself is held
in a sentinel kind 30003 with d='agora.campaign-lists.index'.
Replaces the 'Your campaigns' shelf on /campaigns with a horizontal
strip of pill buttons (one per list). Each pill links to a new
/campaigns/lists/:slug detail page rendering the list members in
moderator-defined order. Moderators see a trailing '+' pill to
create a list, a per-pill kebab for edit/delete/move, and drag-and-
drop to reorder the strip on desktop. Inside each list, moderators
can search and add campaigns, remove members, and reorder via the
same native-HTML5 DnD pattern.
The icon picker is searchable over every named Lucide icon. The
registry is dynamically imported through a single shared module so
the full library lives in its own Vite chunk and the main bundle
isn't penalized; LucideIcon renders a 'List' fallback while the
chunk resolves.
The deploy-web job re-declared project-level CI/CD variables as `KEY: $KEY`.
When a source variable is out of scope for the job (e.g. a Protected variable
on an unprotected ref), GitLab leaves the reference unexpanded, so the literal
string "$VITE_TRANSLATE_WORKER_URL" got inlined into the build and surfaced in
the UI. Project-level variables are already in the job environment, so the
re-declaration is removed entirely.
The DeepL translate worker endpoint is now a configurable AppConfig field
(translateWorkerUrl), defaulting to the build-time VITE_TRANSLATE_WORKER_URL
env value with no hardcoded fallback. Users can override or clear it in
Advanced Settings (System section), and the setting syncs across devices via
encrypted NIP-78 settings. The Translate button hides itself when no worker
is configured.
The 6-card cap on the WLC Verified row meant moderators couldn't
reach featured campaigns at positions 7..N to reorder them — the
drag handles and kebab move rows only existed on cards in the
visible hero row.
Wrap the All Campaigns section in a ReorderProvider seeded with the
*full* featuredCoords list (not just what's visible) so every
WLC-chipped card in the chronological grid gets the same Move up /
Move down / Move to top rows in its kebab. The provider only mounts
for moderators; non-mods see no behavior change.
Non-featured cards aren't in the provider's byCoord lookup, so
their kebab simply doesn't show reorder rows — the moderation menu
already gates the section behind canMoveUp || canMoveDown.
No optimistic local reorder here: the chronological grid is sorted
by createdAt, not by featured rank, so a successful 'Move to top'
on a position-12 card lifts it into the Verified hero row above
(and out of the chronological feed, via the existing heroSet
dedupe) once the moderation pack invalidates and refetches.
Failures surface as a toast, matching ReorderableCampaignGrid.
A small ConditionalReorderProvider helper keeps the JSX clean and
spares non-mods the provider work.
Two corrections to the new 'All campaigns' section on the home page:
1. Deduplicate against the Verified hero row. Campaigns rendered in
the row above are now excluded from the chronological feed below
(matched by aTag against orderedFeatured). Over-cap featured
campaigns — the ones a moderator featured beyond the 6-card cap —
still appear here, and still pick up the WLC chip via
featuredCoordSet. The user sees each campaign at most once on the
home page.
2. Sort by createdAt ascending (oldest first), not descending. The
spec was 'chronological order from when they were created,' not
reverse-chronological. The allCampaignsDesc copy is also updated
in all 16 locales to drop the 'newest first' language.
The WLC Verified hero row now shows at most 6 campaigns (the two
large hero cards on top plus a single 4-up row), regardless of how
many a moderator has featured. Anything beyond the cap is still
featured for moderation purposes — it just doesn't earn a hero slot.
Below the Verified row, a new 'All campaigns' section displays every
campaign in the home page's recent stream (the existing 200-event
window from useCampaigns(limit: 200)) minus anything currently
hidden, sorted newest-first. Featured campaigns are intentionally
NOT removed from this chronological feed; instead, each card whose
coord is in featuredCoordSet still picks up the WLC chip via
verifiedBy. So a verified campaign appears twice on the home page —
once as a hero, once in chronological order — and both placements
make the WLC endorsement visible.
The 'Browse all campaigns →' link moves from the Verified section
footer to the All Campaigns section footer, where it makes more
sense as a gateway to /campaigns' search and sort surface for the
full censorship-resistant set beyond the 200-event window.
Five locale strings touched in all 16 locales: featured,
featuredDesc, verifiedByAria (existing), allCampaigns,
allCampaignsDesc (new).
Restructure the home Featured row into a two-tier layout when there
are 3+ featured campaigns:
- Top two: large 'hero' placement (full width on mobile/sm, half
width side-by-side on lg+).
- Rest: standard compact cards in rows of four on lg+, two on sm,
one on mobile.
Implementation is a single CSS grid with conditional col-spans on
the first two children rather than two separate grids — that keeps
the existing ReorderableCampaignGrid intact, including drag-and-drop
between hero cards and tail cards, optimistic reorder, and the
moderator kebab actions.
ReorderableCampaignGrid gains an itemClassName(index) prop so the
caller can paint per-position wrapper classes onto each card slot
without the grid component knowing about hero layouts.
The renderCard signature now also receives the display index — not
used yet, but kept aligned with itemClassName for future use cases.
≤2 featured campaigns keep the original adaptive layout (no spans
needed), and the single-campaign hero variant is unchanged.
The chip on featured campaign cards previously rendered the full
'World Liberty Congress' name, which truncated awkwardly at our 140px
max-width on smaller covers. Add an optional shortLabel to the
verifiedBy prop so the chip can display a compact 'WLC' while the
aria-label and avatar fallback continue to use the full name.
Two follow-up tweaks to the World Liberty Congress Verified row:
1. Heading reads '<avatar> World Liberty Congress Verified <check>'
instead of leading with the check icon, so the action verb sits
next to the brand and the check is the visual punctuation.
2. CampaignCard gains an optional verifiedBy={{ pubkey, npub,
defaultName }} prop. When set, it renders a translucent chip on
the top-left of the cover art with the verifier's avatar (pulled
from kind 0 metadata via useAuthor), name, and a BadgeCheck. The
chip is itself a Link to the verifier's profile and stops click
propagation so it doesn't trigger the outer campaign link.
CampaignsPage threads { WLC_PUBKEY, WLC_NPUB } through FeaturedRow
so every featured card on the home page picks up the WLC chip.
A new verifiedByAria locale key is added in all 16 locales for the
chip's aria-label.
The home page Featured row is curated in partnership with the World
Liberty Congress, so the heading should make the source of trust
explicit. Replace the plain 'Featured' heading with:
[avatar] World Liberty Congress [BadgeCheck] Verified
The brand name and avatar link to WLC's profile
(npub126e6hwd6a5std2upv9a22xwgvd8fyrhsx5wjjchv99g6nv3n4vhs5fr9g3),
the avatar is pulled live from kind 0 metadata via useAuthor, and a
lucide BadgeCheck conveys verification at a glance.
The localized 'featured' key now reads 'Verified' (and is translated
across all 16 locales); the brand name itself is intentionally not
translated. featuredDesc is rewritten to describe WLC verification
in every locale.
Two related curation tweaks:
- /campaigns now renders every non-hidden campaign in pure
reverse-chronological order. Featured-pinning on this shelf was
redundant with the home page's dedicated Featured row and obscured
newly-published campaigns. The active (search / sort / country)
branch was already unpinned; this only changes the idle landing.
- Featuring a campaign as a moderator now publishes an explicit
rank one below the smallest existing featured rank, so the new
card lands at the **bottom** of the Featured row instead of
jumping the queue. The display sort stays descending by rank, so
the existing Move-to-top / Move-up controls still work for
moderators who want to promote a fresh feature.
The pledge and organization moderation surfaces keep the legacy
created_at-fallback behavior — the bottom-append rank is computed in
CampaignItemsInner and threaded through the shared shell only for the
campaign branch.
Now that moderators can directly order the Featured row, the second
"Community Campaigns" bucket (approved + not-featured + not-hidden)
is redundant. This commit removes the approval axis end-to-end and
collapses the home page to a single curated section.
Protocol (NIP.md):
- `ModerationLabel` shrinks from six values to four — `hidden`,
`unhidden`, `featured`, `unfeatured`. The legacy `approved` /
`unapproved` labels are now ignored on read and MUST NOT be
published.
- `ModerationAxis` shrinks from three to two: `hide` and `featured`,
both supported by all three surfaces (campaigns, organizations,
pledges).
- The rank tag now only applies to `featured` labels.
- A migration note in NIP.md explains the retirement and tells
clients to ignore lingering approval-axis labels in relay
archives.
UI:
- CampaignsPage drops the Community Campaigns and Pending sections.
Home is now Featured (with the empty state in place when nothing
is featured) → Browse-all link → moderator-only Hidden section.
The labeled-coord targeted fetch shrinks to hidden coords only.
- ModerationMenu loses the Approve / Unapprove rows and the
`hasApproval` / `isApproved` plumbing.
- `CampaignCard`'s `axes` prop drops `'approval'`.
- `ReorderAxis` collapses to a single axis — the type and the
parameter are removed from the reorder hook, provider, context,
and grid component since every reorder targets the featured axis.
- Pledge and organization moderation hooks lose their defensive
`'approved' | 'unapproved'` rejection branches now that those
values are off the `ModerationLabel` union.
i18n (16 locales):
- Five moderation.menu keys removed: `approve`, `unapprove`,
`approvedState`, `toastApproved`, `toastUnapproved`.
- Five campaigns.home keys removed: `community`, `communityDesc`,
`pending`, `pendingDesc`, `pendingEmpty`.
- `campaigns.home.yourCampaignsDesc` rewritten across every locale
to drop the "appears on the homepage once a moderator approves"
copy; new copy points authors at /campaigns for discovery and
notes that the team curates a featured selection on the home page.
Test suite green: tsc, eslint, vitest, vite build all pass.
The first reorder implementation encoded list position directly in
the moderation label's `created_at` and republished the same axis
label with a chosen timestamp. That fights the fold's
newest-event-per-(coord,axis) rule the moment a moderator tries to
lower a campaign's position: the new label has an older
`created_at` than the existing one and the fold rejects it. The
relay accepts the publish, but every subsequent read folds back to
the higher-`created_at` predecessor and the move appears to revert.
Move-up worked (its new `created_at` was strictly newer); move-down,
drag-down, and any drag-to-midpoint that landed below an existing
neighbor silently no-op'd. Anything dragged into the middle of an
already-old list also picked a past timestamp that some relays
reject for being too far behind "now".
The fix decouples sort key from event recency:
- Reorder publishes always use `created_at = now`, so the fold's
newest-wins rule always picks them up.
- The chosen position is encoded as a `["rank", "<integer>"]`
tag on the label.
- `foldModerationLabels` extracts the rank with a `created_at`
fallback, so labels published before this change (and any normal
approve / hide / feature actions that don't carry a rank) still
sort by `created_at` exactly as they used to.
Ranks are sourced from `Date.now() * 1000` (microseconds since
epoch), so:
- Fresh "feature" / "approve" publishes always sit above legacy
labels whose effective rank is a seconds-since-epoch value.
- Midpoint inserts have ~1000x headroom per second of inter-rank
gap, comfortably enough for thousands of reorders before any
renumbering would matter.
- Headroom against `Number.MAX_SAFE_INTEGER` is ~150 years.
Callers downstream (CampaignsPage, CampaignsDiscoverySection,
PledgesDiscoverySection, useFeaturedOrganizations) still consume
`featuredOrder` / `approvedOrder` as `Map<coord, number>` sorted
descending — the map names and shapes are unchanged, only the
value computation is now "rank ?? created_at" instead of
"created_at".
NIP.md updated to document the rank tag, the fallback semantics,
and the reorder operations in terms of ranks.
Moderators can now feature any number of campaigns and the row
expands to fit. The cap was hardcoded as `MAX_FEATURED = 12` and
applied at three points (the sort+slice on coords, the
`useCampaigns` limit, and the sort+slice on the ordered list);
all three are gone.
`useCampaigns` already ignores its `limit` when `coordinates` is
set (it fans out into one per-author `#d` filter), so the relay
request was never actually capped — only the rendered slice was.
Dropping the slice is sufficient.
The skeleton placeholder still bounds itself at 8 cards so a
moderator who's featured 50+ campaigns doesn't get a screenful of
grey rectangles before the real cards land. The bound is renamed
`FEATURED_SKELETON_CAP` to make the intent obvious.
The Featured row already sorted by the moderator's `featured` label
`created_at`, but reordering required clicking Unfeature then Feature
again — clumsy, and the Community grid sorted only by campaign
`created_at` with no moderator input at all.
This commit promotes the existing axis-label `created_at` into a
first-class sort key on both lists and adds drag-and-drop + kebab-row
UI for moderators.
Protocol (no schema change):
- The Featured row sorts by the `featured` label's `created_at`,
newest first (existing behavior).
- The Community grid now sorts by the `approved` label's
`created_at`, newest first (mirroring the Featured row).
- Reordering = republishing the same axis label for the moved
campaign with a chosen `created_at`. Move-to-top stamps `now`;
move-up stamps `neighborAbove.t + 1`; move-down stamps
`neighborBelow.t - 1`. Drag-to-position picks a value between the
two new neighbors.
- No new tags, no new kinds, no new authority — readers that already
understand the moderation namespace pick up the order for free.
- Conflict model unchanged: newest label per (coord, axis) wins.
Implementation:
- `foldModerationLabels` now populates `approvedOrder` alongside
`featuredOrder`.
- `useCampaignModeration().moderate` accepts an optional explicit
`created_at` for the label event (omitted for normal
approve/hide/feature; passed by the reorder hook).
- New `useReorderCampaign` hook with `moveToTop`, `moveUp`,
`moveDown`, and a general `moveTo(toIndex)` used by drag-and-drop.
- New `ReorderableCampaignGrid` wraps a list of `CampaignCard`s:
- non-mods get a plain grid, zero overhead;
- mods on desktop get HTML5 drag-and-drop with a six-dot handle
on hover (the handle is the only `draggable` element so card
clicks still navigate the underlying `<Link>`);
- mods on mobile get Move up / Move down / Move to top rows
injected into the existing moderator kebab via a context
provider (`ReorderProvider` / `useReorderControlsFor`).
- An optimistic local order smooths the gap between publish and
refetch so the card snaps into the new position immediately; it
rolls back automatically on publish failure.
- Translations added in all 15 non-English locales.
- NIP.md documents the ordering convention in a new
"Moderator-driven Ordering" section under the campaign-moderation
surfacing rules.
The d-tag never appears in a user-visible URL. Campaign links are
naddr1… bech32 strings (see CampaignDetailPage.tsx:263 and
CreateCampaignPage.tsx:573 / navigate(`/${encodeCampaignNaddr(...)}`)),
which bundle the d-tag inside the encoded payload. Showing a
transliterated 'anqthwa-ahmd-wwaldh-…' string under the title input
and calling it 'your campaign URL' was actively misleading — the
user sees no such URL anywhere.
Rip out TitleSlugHint, the previewSlug memo, and the slugPreview /
slugFallbackNotice locale keys from all 16 locales. The
transliteration + random-fallback slug derivation in buildCampaignSlug
stays — that's still the right fix for the underlying bug — but it's
internal Nostr plumbing the user shouldn't have to see.
Regression-of: 12bc7219
Arabic, Persian, CJK, and other non-Latin titles were collapsing to an
empty d-tag because slugifyCampaignIdentifier only kept [a-z0-9] after
NFKD. NFKD doesn't transliterate Arabic to Latin, so a title like
حملة لمساعدة الأطفال slugified to "" and the user hit the cryptic
errorTitleInvalid message at submit time — after walking through the
entire wizard.
Route the title through the slugify package's charMap first (covers
Arabic, Persian, Cyrillic, Greek, Georgian, Armenian, Vietnamese, common
Latin diacritics, currency symbols, smart quotes). For inputs that still
produce no ASCII characters — emoji-only titles, CJK, Thai, Tamil —
buildCampaignSlug returns a random campaign-XXXXXX identifier so the
user can still publish; the human-readable title lives in the title tag,
not the URL.
Also strip Unicode bidi controls and zero-width characters
(RLM/LRM/FSI/PDI/ZWNJ/BOM) before they reach the title tag. RTL
keyboards routinely insert these invisibly, and preserving them in
display strings is a homograph/phishing vector.
Surface validation under the title input itself rather than at submit:
when the title transliterates cleanly, show the slug preview in a
muted-tone code block; when it doesn't, show an amber notice explaining
that a random URL identifier will be generated and that the title is
preserved verbatim. Hidden in edit mode where the d-tag is locked.
When Bitcoin transactions were rejected by the network for fee-related
reasons (min relay fee not met, mempool full, RBF replacement
underpriced), both the HD wallet Send dialog and the campaign Donate
dialog surfaced a destructive toast titled "Transaction failed" /
"Donation failed" with the raw bitcoind RPC error as the description.
The dialog stayed open with state preserved, but the donor:
- Saw an opaque, technical error string they couldn't act on.
- Got no affordance to recover. Re-tapping Send re-fired the same
rejected transaction. In HDSendBitcoinDialog the existing
two-tap arm wasn't reset on broadcast failure, so a second tap
immediately re-broadcasted with the same (rejected) parameters.
- In DonateDialog had no path to bump the fee without manually
backing out to the form step and re-picking a tier.
Three pieces, plus a small adjacent fix:
1. Classifier in src/lib/bitcoinBroadcastError.ts maps the verbatim
bitcoind / Blockbook-WebSocket / Esplora error strings onto a small
enum (feeTooLow, rbfReplacementFeeTooLow, mempoolFull,
mempoolConflict, tooLongChain, absurdlyHighFee, badInputs, network,
unknown). For the canonical 'min relay fee not met, A < B' form,
the actual and minimum sat/vB values are parsed out so the UI can
show "minimum right now is N sat/vB" and seed a custom fee. 17
unit tests covering real-world fixtures from mempool.space,
Blockstream Esplora, Blockbook framing, and bare bitcoind output.
2. Shared BroadcastErrorAlert in src/components/BroadcastErrorAlert.tsx
renders inline above the Send button. Replaces the toast for
classified errors (toast is retained only as a fallback for the
`unknown` bucket so something always surfaces). Fee-recoverable
kinds get a "Use a higher fee" action; `network` gets "Try again";
everything else has no action and waits for the donor to adjust
amount / recipient via the auto-dismiss effect. A `presetTiersOnly`
prop hides the bump button once a preset-only consumer (DonateDialog)
is on the fastest tier, surfacing "You're already on the fastest
tier" instead.
3. HDSendBitcoinDialog wiring — broadcast errors set a classified
state, the alert renders above Send, and a new bumpFeeForRetry
helper jumps to the next-faster preset OR, if already at the
top of the deduped preset list, switches to a custom rate seeded
from the strongest available hint (parsed minRelayFee + 1, or
1.5x the rejected rate, or current+1 as a last resort). Refetches
fee rates, opens the fee popover so the donor can see and tweak
the new rate, marks the picker as user-touched so the 40%-of-amount
auto-tune doesn't fight the bump, and resets the two-tap arm
unconditionally on every failure.
4. DonateDialog wiring — same alert in the confirm step. The dialog
has no custom-rate input by design (it's the simple donate flow),
so the bump action walks the preset chain economy -> hour ->
halfHour -> fastest. At the fastest tier the alert hides the
button and tells the donor to use an external wallet via the QR
panel on the campaign detail page.
5. i18n — 22 new keys under walletSend.broadcastError, translated
into all 15 non-English locales in parallel with placeholder and
technical-token preservation.
The auto-dismiss effects in both dialogs clear the alert as soon as
the donor adjusts a field that could plausibly resolve the failure
(recipient, amount, fee speed, custom rate) so the alert doesn't
linger once the donor has engaged with the fix.
Pasting a bare bc1…/sp1… address or a single-endpoint bitcoin: URI now
resolves directly to the recipient chip instead of dropping into the input
behind a one-row dropdown the donor still had to click. Pastes carrying
both an on-chain address and an sp1 code still fall through to the dropdown
so the donor picks privacy vs. compatibility.
Extracted the candidate-resolution logic into a shared resolveCandidates()
helper so the live input memo and the paste handler agree on what counts as
a valid destination; the paste handler resolves from the clipboard text
directly (query state hasn't updated yet inside the event) and
preventDefault()s the single-match case so the raw text never flickers in.
The campaign donate flow opens HDSendBitcoinDialog with a prefilled
bitcoin:bc1q…?sp=sp1… URI. BitcoinRecipientInput auto-opens its
dropdown so the donor picks between the on-chain address and the
silent-payment code — privacy vs. compatibility, the explicit choice
the picker was designed around (92608f14).
In practice the donor's eye lands on the amount presets first. Tapping
$100 counts as an outside click, dismisses the popover, and leaves
`recipient` null. The Send button is disabled (correctly — no
destination resolved), the input still shows the prefilled URI, and
nothing on screen tells the donor what's missing. They eventually
discover that re-tapping the recipient input reopens the dropdown.
Add a small amber hint with a warning icon directly beneath the
recipient input whenever the input has parseable candidates but no
selection AND the popover is closed. The whole hint is a button that
reopens the popover and refocuses the input on tap, so the recovery
takes one click instead of a guessing game.
Gate the hint on a new `hasOpenedForQuery` flag that flips true the
first time the popover opens for the current query and resets when the
query clears. That keeps the hint from flashing for one paint frame
between mount and the auto-open effect on prefilled inputs.
Regression-of: 92608f14
Swap the dropdown row order so "Send to Bitcoin address" renders above
"Send to silent payment address" — the broadly-compatible on-chain option
leads, with the privacy option following.
The candidate dropdown is a persistent choice list, but Radix's Popover
dismissed it whenever the input blurred or the user tapped elsewhere,
making the rows vanish even though a valid destination was still in the
field. Block the auto-dismiss-on-outside-interaction handlers so the
dropdown stays open as long as the input holds a candidate; it now closes
only on selection, on a cleared input, or via Escape.
Previously, clearing a selected chip in a prefilled flow (campaign "Pay
with Agora") restored the prefilled bitcoin: URI / address back into the
input. Removed that restore effect so X-ing out the chip now returns to an
empty field, letting the donor type or scan a fresh destination without
first deleting the prefill.
When the Send Bitcoin picker mounts pre-filled with a single valid
endpoint — e.g. a campaign with only a bc1 address (or only an sp1 code) —
it now auto-selects that candidate into the recipient chip instead of
leaving the bare value in the input behind a one-item dropdown the donor
still had to click.
Prefills carrying both an on-chain address and an sp1 code are left in the
input so the dropdown can surface both rows; picking privacy vs.
compatibility is a real choice the donor should make. Guarded by a
mount-once ref so it never overrides a manual selection or a clear-chip
restore.
The donate panel's copyable row only used a BIP-21 URI when a campaign
exposed both an on-chain address and a silent-payment code; single-
endpoint campaigns copied the bare bc1.../sp1... value instead. The QR
already encoded a bitcoin: URI in every case, so the copy row now mirrors
it — donors always get a wallet-parseable URI regardless of which
endpoints a campaign declares.
/campaigns was a redirect to / (the curated home), and the actual
all-campaigns directory lived at /campaigns/all. Flip the routing
so /campaigns IS the directory, the home page stays at /, and
/campaigns/all becomes a redirect to /campaigns for any external
links and bookmarks that still point there.
Rewrite every internal link/navigate target accordingly (TopNav,
the Browse-all CTA on the home page, the OnboardingGate donor
redirect, NoteCard's kind-33863 nounRoute) and refresh the
doc/comment references in NIP.md and the discovery hooks.
Two pieces of stale i18n caught up:
1. The 16 new campaignsCreate.categories.* keys (human-rights,
democracy, press-freedom, political-prisoners, humanitarian-aid,
civil-resistance, digital-rights, anti-corruption, women-girls,
refugees, legal-aid, emergency-relief, animal-rights, education,
medical, community) translated into all 15 non-English locales.
2. campaigns.all.sectionTagline rewritten across all 16 locales to
match the discovery-section fix that now lists featured campaigns
first and the rest of the network underneath, instead of
featured-only-with-fallback. Old copy ('Highlighted by moderators.
Search or sort to browse the full network.') implied search was
required to see non-featured campaigns, which is no longer true.
Swap the picker's preset list from the generic GoFundMe-style
catalog (adoption, animals, church, family, memorial, mission,
non-profit, event, first-responders, political) to a set that
reflects Agora's editorial focus on the kinds of activism HRF and
the World Liberty Congress champion: human rights, democracy,
press freedom, political prisoners, civil resistance, digital
rights, anti-corruption, women & girls, refugees & exiles, legal
aid. Plus humanitarian aid (per request), animal rights, emergency
relief, education, medical, and community to round out the
breadth.
16 entries total, ordered by editorial prominence (freedom /
democracy themes first, everyday humanitarian needs after). The
picker UI is unchanged \u2014 it iterates the array, so swapping
contents is enough.
Existing campaigns that selected one of the dropped slugs keep
their on-chain `t` tag intact \u2014 only the editor stops lighting up
a pill for them. No migration; we're pre-launch.
Strip the now-orphaned campaignsCreate.categories.* keys from the
15 non-English locales; the new English keys are in en.json only,
non-English locales will fall back to English at runtime until
proper translations land in a follow-up.
The home page community grid was missing approved campaigns whenever
the approval was older than the most recent 200 events on the
network. The grid was fed by a single `useCampaigns({ limit: 200 })`
call, so an approved campaign with a low `created_at` would silently
fall off the end of the chronological window and disappear from the
public surface even though its approval label was still active.
Two fixes here:
1. Add a second `useCampaigns` call keyed on every approved + hidden
coord, alongside the existing recent-stream query. Merge both
result sets, de-dupe by aTag, keep whichever revision is newer.
Approved coverage no longer depends on recency.
2. Restore the four-section layout the home page was supposed to
have: Featured, Community (approved only), Pending (mods-only),
Hidden (mods-only, collapsed by default). The single
chronological-all-with-toggle grid this commit replaces was the
wrong target \u2014 censorship-resistant viewing belongs on
/campaigns/all, the home page should be the moderator-curated
front door.
Extend ModeratorCollapsibleSection with an explicit `defaultOpen`
prop so the Hidden section can be forced closed independent of the
existing 'auto-open when short' heuristic.
mempool.space serves a 404 (instead of 429) to rate-limited clients,
which is common on carrier-NAT'd mobile connections where many users
share an egress IP. esploraFetch treated 404 as a legitimate "not
found", marked the endpoint healthy, and returned it WITHOUT failing
over — so getFeeRates threw 'Failed to fetch fee estimates', the query
swallowed it, and the on-chain Zap/donation dialogs showed no fee rates.
This is why fees loaded on WiFi but not LTE.
Add a per-call retryStatuses option to esploraFetch that extends the
retryable set (failover + cool-down) for that call, and apply [404] to
the paths that always exist on a healthy backend: /fee-estimates,
/address, /address/txs, /address/utxo, and the /tx broadcast. The
/tx/{txid} lookup keeps 404 meaningful (genuinely-unknown tx).
The idle (no search / no sort / no country) view of the campaigns
discovery section was a featured-only shelf with a fallback to
chronological only when nothing was featured at all. As soon as a
moderator featured one campaign, every approved-but-not-featured
campaign vanished until the viewer typed a search, picked a sort,
or filtered by country.
Switch idle mode to a true featured-first list: pin featured at the
top of the grid, then append every other non-hidden campaign in
chronological order, deduped against the featured set. Approved-
not-featured now shows up where viewers expect it.
Active mode is unchanged \u2014 it already rendered the full result set.
The section tagline still reads 'Highlighted by moderators. Search
or sort to browse the full network.' which is now slightly stale;
leaving the translation update for after we confirm the new
behavior in the wild.
The Show-hidden toggle in CampaignsDiscoverySection was gated to
moderators. Drop that gate so every viewer of /campaigns/all sees
the toggle and can unhide what mods have suppressed.
Rationale: moderation labels live on public relays regardless, so
hiding the toggle was security-by-obscurity. The Campaigns page is
the censorship-resistant browseable index; the only honest UX is
transparent moderation. The home page (/) keeps its curated
behavior \u2014 only mods see hidden campaigns there \u2014 and the Hidden
collapsible *below* the discovery section on /campaigns/all stays
mod-only because it's a review workflow with one-click hide/unhide
affordances, not a discovery surface.
The toggle's default is unchanged: off. Viewers see only non-hidden
campaigns until they opt in.
The HD-wallet Send dialog's fee popover relied on getUniqueBitcoinFeeSpeeds
falling back to all four preset tiers when rates hadn't loaded — rendering
clickable tiers with no sat/vB value (and no way to send at all when the
Blockbook estimate API was down).
- Show loading/error status (with a Retry) in the fee popover instead of
bare tiers when rates haven't loaded.
- Add a "Custom" fee tier with an inline sat/vB input so users can always
specify a rate, including when the estimate API is unavailable.
- Disable Send when the resolved rate is < 1 and surface an inline error.
- Add resolveBitcoinFeeRate + a PresetBitcoinFeeSpeed type so 'custom' is
handled distinctly from the preset tiers.
Comment out the two NAV_ITEMS entries (desktop nav and mobile drawer
share this array, so one edit covers both). Routes and feature code
stay intact \u2014 visiting /groups or /pledges still works, in-page CTAs
still link, only the persistent nav chrome stops promoting them.
Re-enable by uncommenting the two lines and re-adding the Users and
Megaphone icon imports.
The community grid stops gating on moderator approval and now lists
every kind-33863 campaign on the network, newest-first. A Switch in
the section header reveals the moderator-hidden bucket on demand (off
by default, count badge when something's there).
The moderator-only Pending and Hidden collapsibles disappear with
this change — Pending is now part of the main grid, and Hidden is one
toggle flip away. The non-mod 'Your campaigns' pending shelf goes
away for the same reason: a creator's not-yet-approved campaign
already shows up in the main grid.
Featured row, hero, and 'Browse all campaigns' link are untouched.
src/lib/countries.ts imported the full iso-3166 package solely to build
a Set of valid ISO 3166-2 subdivision codes for validation. That dataset
(~5000 objects with names, parents, and tree structure) landed in the
eagerly-preloaded countries chunk because NoteContent, ComposeBox, and
campaign.ts all import from countries.ts on the critical path.
Ship only the subdivision code strings instead, generated at build time
into src/lib/subdivisionCodes.ts via scripts/gen-subdivision-codes.mjs.
iso-3166 moves to devDependencies since only the generator script needs
it now. The strict-validation contract (rejecting US-ZZ etc.) is
preserved.
i18n.ts statically imported all 16 locale JSON files (~2.4 MB),
collapsing them into a single eager chunk that every user downloaded
on startup regardless of their language. Bundle the English fallback
only and fetch the other 15 locales on demand via dynamic import(),
so each language becomes its own lazily-loaded chunk.
This removes the 2.1 MB i18n chunk from the initial load; the eager
i18n chunk is now ~109 KB (runtime + English).
The previous commit left the home page with a single
CampaignsDiscoverySection (search/sort/country toolbar over one grid).
The original layout was richer and read better: a dedicated Featured
row, the Community Campaigns grid with a "Browse all" link, the
moderator-only Pending / Hidden collapsibles, and a per-viewer "Your
campaigns" shelf.
Rebuild that content area from moderation labels (useCampaignModeration
+ useCampaignModerators + useCampaigns), keeping the current hero and
leaving campaigns as the home page's sole focus. The shared discovery
components and the dedicated /campaigns/all, /groups, and /pledges pages
that consume them are untouched.
Regression-of: 7ccff2fb
The home page is the primary browse surface for campaigns and reads best
when it stays focused on them. Groups and Pledges each have their own
dedicated browse pages (/groups, /pledges), so surfacing all three on /
duplicated those experiences and diluted the page.
Drop the GroupsDiscoverySection and PledgesDiscoverySection from the home
page, leaving only CampaignsDiscoverySection. The shared discovery
components and the dedicated pages that consume them are untouched.
Regression-of: 7ccff2fb
The shortcut previously appeared only from step 3 onward. Once the
user fills the pledge amount on step 2 they're fully submittable —
title and description (the step 1 gate) plus a positive pledge
amount cover every required field. Forcing one extra Next click to
reach the shortcut on step 3 just to skip the rest was friction
for no benefit.
Moving the shortcut to step 2 reuses the same canAdvanceFromStep
gate the Next button does, so the button is visibly grayed out
until the pledge amount resolves to a positive sats value. Once
the amount is filled, both Next (continue to Cover) and Skip and
Launch (publish now) light up together and the user picks the
path. A minimal pledge is now two Next clicks plus a Skip and
Launch tap.
Two coordinated tweaks to the pledge create flow:
1. The free-form tag input is replaced with the same pill-style
CategoryPicker that campaigns and groups already use, drawing
from the curated 15-entry CAMPAIGN_CATEGORIES vocabulary. The
tag list emitted on publish is now ordered canonically (the
CAMPAIGN_CATEGORIES order) rather than insertion-order from
the comma-separated input — same posture campaigns and groups
adopted when they swapped pickers. Side effects:
- parseContentTagInput is no longer imported by this file
(still used by CreateEventPage and CreateCommunityEventDialog).
- pledges.create.tagsPlaceholder is dropped from en.json and
all fifteen non-en locales, since the picker has no
free-text input to placeholder.
- The step subtitle stays "Help the right people find your
pledge"; the title is renamed "Country and categories" to
match the picker (groups uses the same string).
2. The dedicated Deadline step is folded into the Pledge-amount
step. The two questions answer the same beat — "how much, and
by when?" — and a step that often gets skipped felt like
padding next to the amount field it conceptually belongs
with. The timezone subsection still reveals only once a date
is chosen, the date is still required to be present-or-future,
and the deadline tag still publishes only when a date is set.
Step count drops from 5 to 4: Title+Description, Pledge+Deadline,
Cover, Country+Categories. The Skip Next and Launch shortcut keeps
its from-step-3 placement (both required gates still clear by the
end of step 2), so a minimal pledge takes two Next clicks plus
one Skip and Launch tap.
i18n: deletes pledges.create.wizard.deadlineStepTitle and
deadlineStepSubtitle from en.json (they exist only in en so no
locale cleanup is needed). Updates pledgeStepSubtitle to mention
the optional deadline. Renames tagsStepTitle to "Country and
categories" to match the picker.
Pledges followed the original single-page stacked form for every
create. With campaigns and groups both running through the captive
wizard overlay, pledges were the odd one out — the FAB / hero CTA
landed on a long scrolling form while every other create flow
opened a focused step-by-step layout. This brings them inline.
Five steps:
1. Title + Description (both required; step 1 gates on both)
2. Pledge amount (required; gates on a positive sats preview so
the BTC/USD price has resolved before publish)
3. Cover image (optional)
4. Deadline + timezone (optional; the timezone subsection still
reveals only when a date is chosen)
5. Country + Tags (optional, terminal)
Skip Next and Launch appears from step 3 onward. Steps 1 and 2 hide
the shortcut because publishing without their fields would trip a
server-side validation error; once both required gates are
cleared, the remaining three steps are explicitly optional and a
single-tap launch is the desired escape hatch. Matches the same
posture the campaign wizard uses for its required title + wallet
gates.
Side cleanups while in the file:
- The local CountrySelect is replaced with the shared one. The
pledges.create.{countryClearAria, flagOfAria, countryHint}
locale keys were already absent from non-en locales (cleaned
out during the earlier campaign/groups extraction), so this
just removes the now-orphan en.json entries.
- pledges.create.{publishing, uploadingCover} were dead since
the page was already reading forms.publishing /
forms.uploadingCover; deleted from all sixteen locales.
- OrganizationContextChip now rides along inside step 1 as a
step1Lead, same treatment the campaign wizard gives it. The
captive overlay swallows the page header chrome, so the
"publishing under <org>" affordance has to live inside the
step body to stay visible.
No edit-mode path: pledges aren't editable today, and the file
mirrors that — there's a single create branch and that's it. If
edit support is ever added, the campaign / groups pattern (the
single-page form lives behind an isEditMode branch above the
wizard return) is the template.
i18n: adds pledges.create.wizard.{titleStepTitle, titleStepSubtitle,
pledgeStepTitle, pledgeStepSubtitle, coverStepTitle,
coverStepSubtitle, deadlineStepTitle, deadlineStepSubtitle,
tagsStepTitle, tagsStepSubtitle, launchNow} to en.json. Other
locales fall back to English until translated.
Groups require only a name to publish. The shortcut used to appear
from step 2 onward, which still forced one mandatory Next click
before the user could opt out of the remaining optional steps.
Moving it to step 1 lets a minimal group publish in a single
action: type a name, tap Skip and Launch.
The shortcut shares its disabled state with the Next button via
canAdvanceFromStep, so on step 1 it only becomes clickable once
the name field is non-empty.
Also tightens Wizard's canSubmit calculation: the mid-wizard
shortcut now respects the same canAdvance gate the Next button
does. Previously a launch button placed on a gated step would
remain clickable even when the gate was unmet, then trip a
server-side validation error. The terminal step's own submit
button keeps its old behavior because by definition every gated
step has already been cleared by then.
Two changes that go together:
1. The group create wizard now exposes a Skip Next and Launch ghost
shortcut from step 2 onward. Name is the only required field
(it is the gate on step 1 and the slug source); once a user
clears that step, everything else is opt-in and they should
not have to click Next, Next, Next through three optional
screens just to publish a minimal group. Matches the same
shortcut the campaign wizard offers from step 3 onward.
2. Edit mode now renders the original single-page stacked form
instead of the wizard, mirroring the create-vs-edit split the
campaign flow uses. Editing a populated entity benefits from
seeing all fields at once: every wizard step would already be
pre-filled, and walking through them adds friction without
adding clarity. The edit form reuses the exact same section
bundles the wizard does (nameDescriptionSection, coverSection,
moderatorsSection, countryCategoriesSection) so create and
edit stay byte-identical in their field rendering. Ordering
matches the pre-wizard page: name, description, country,
categories, cover, moderators.
i18n: adds groups.create.wizard.launchNow ("Skip Next and Launch")
to en.json. Other locales fall back to English until translated.
Groups used to render every field on a single long form. Now they
share the same captive overlay the campaign flow uses — sticky
progress bar across the top, one focused decision per step, top-left
back chrome and top-right escape, big rounded primary CTA. Four
steps:
1. Name + Description (gated; name is required to advance)
2. Cover image
3. Moderators
4. Country + Categories
The free-form 'mutual-aid, local-news, digital-rights' tag input is
replaced with the same pill-style CategoryPicker the campaign flow
uses, drawing from the same 15-entry CAMPAIGN_CATEGORIES vocabulary
so the two creation surfaces feel like the same product. Country
input uses the shared CountrySelect.
Edit mode behaviors:
- The d-tag stays immutable (kept as editCommunity.community.dTag).
- The pre-fill loop only pre-selects existing tags that exist
in the curated category set. Arbitrary tags an older
free-form entry may have published (e.g. 'mutual-aid') are
intentionally dropped from the picker — the user has no way to
re-select them, and silently re-publishing tags they can't see
would be a stealth foot-gun. Same posture campaigns adopted when
their tag input was swapped.
- The preserved-tag list in the edit branch already strips every
; nothing else changes there.
No 'Skip Next & Launch' shortcut here. Groups are only four steps
and three of them are optional, so a mid-wizard submit shortcut
would clutter the footer without saving real effort.
i18n: adds groups.create.wizard.{name,cover,moderators,tags}Step{Title,Subtitle}
to en.json. The non-en locales fall back to English for these new
strings until they're translated.
The wizard scaffolding (progress bar, captive overlay, step-aware
header chrome, Enter-to-advance keyboard handling) had been living
inline at the bottom of CreateCampaignPage.tsx as CampaignWizard and
WizardStep, and the category-pill picker was inline as well. Both
need to drop into the group-creation flow next, so they get lifted
into src/components/Wizard.tsx and src/components/CategoryPicker.tsx
with no behavioral change for the campaign page.
The campaign-specific bits — the org chip and the 'Skip Next &
Launch' shortcut — survive the move as optional props (step1Lead,
launchNowLabel) so the group flow can omit them without dragging
along irrelevant chrome. The wizard's Back / Next / close labels now
read from common.back / common.next / common.goBack, which both
flows can reuse.
CountrySelect had already been pulled out into its own component for
the calendar-event flow; it now gains the same localization the
inline campaign copy had (countryClearAria, flagOfAria, countryHint
move to the shared forms.* namespace, replacing the hardcoded
English strings the calendar-event flow shipped with) plus an
optional id prop so callers that own their own <label htmlFor> can
keep wiring it explicitly.
The three localized strings used to live duplicated under
campaignsCreate.* and groups.create.*; both copies are removed from
en.json and from every non-en locale so the locales test passes.
The grid layout forced every chip to the width of the widest label,
which left half the pills with awkward whitespace and the rest with
truncation pressure. Switching to a flex-wrap row lets each pill
size to its own text — short labels (Family, Legal) take less room,
long labels (First Responders) take more, and the row breaks
whenever the next pill wouldn't fit. Some rows naturally fit three
pills, others fit four, depending on which labels neighbor each
other on a given line.
Also drops Current Events from the curated set (it overlaps heavily
with the Event category and was usually mis-selected as a synonym)
and bumps the chip font from text-xs back to text-sm now that the
text is no longer constrained by a narrow grid cell.
Drops Competitive, Creative, Evangelism, and Business — those four
were the weakest fit for the kinds of fundraisers that actually run
on Agora (memorial drives, medical emergencies, mission trips,
mutual-aid efforts), and including them in the curated set diluted
the signal of the other 16. Also renames Animals / Pets → Animals,
which reads cleaner in the chip and avoids the awkward slash.
Locks the picker to a three-column grid (was 2/3/4 responsive) so
the full label is always visible — at the wizard's narrow max-w-md
column the previous two-column layout left half the chips with
truncated labels, and the four-column layout never had room for
multi-word categories like 'First Responders' or 'Current Events'.
Three columns gives every short label its own line and lets the two
long ones wrap to two; a min-h-[3rem] keeps the grid uniform.
The free-form 'beach-cleanup, mutual-aid, …' input asked donors to
invent and spell their own taxonomy on the spot, which produced
sparse and inconsistent tag data (no two campaigns used the same
slug for 'medical', the picker on the discover page never had a
stable set to filter against, etc). Replaces it with a fixed
20-category multi-select grid — Adoption, Animals/Pets, Business,
Church, Community, Creative, Current Events, Education, Emergency,
Evangelism, Event, Family, First Responders, Legal, Medical,
Memorial, Mission, Non-Profit, Political, Competitive — each chip
rendered with its Lucide icon.
Selected categories are persisted as ordinary lowercase 't' tags,
identical at the protocol level to anything the old input would
have produced, so existing readers (relays, the discover feed,
cross-client viewers) need no changes. Edit mode intersects the
event's existing 't' tags with the curated slug set so a campaign
created under this picker round-trips cleanly.
Also restores the previously-merged 'goal & deadline' and 'country &
tags' wizard steps as separate screens — collapsing them into one
turned out to push the category picker too far down the page on
mobile to be the first thing the user sees on the final step.
The wizard's step titles, subtitles, and footer button labels lived
only in en.json, so every non-English user saw the captive create
flow in English — even after the locale fell back gracefully for the
rest of the page. Adds the wizard subobject to all 15 other locales
with idiomatic translations matching each file's established voice.
The wizard's last two screens were each only ~one field of work:
goal+deadline (a USD input and a date) and country+tags (a country
combobox and a comma-list). Asking users to advance twice through
near-empty steps was busywork — both screens fit comfortably on the
same step without breaking the captive flow's vertical rhythm.
Collapses them into a single 'Goal, deadline, and tags' step, which
becomes the new terminal step where the Launch button lives. The
shortcut still appears from the banner step onward, so the wizard
remains a five-step flow with the same opt-in tail.
A <form> with a single text input treats Enter as submit. The wizard
sets the form's onSubmit to the publish handler, so hitting Enter on
step 1 (title) would call submitMutation.mutate() — and for a logged-in
nsec user the wallet picker already defaults to a valid HD-wallet
'mine' / 'all' configuration, so the publish actually went through and
the campaign launched after a single Enter on the title field. There
was no opportunity to fill in anything else.
Intercept Enter on the form's onKeyDown:
* If we're on the terminal step, do nothing — Enter should submit.
* If the focused element is a <textarea>, do nothing — Enter is a
legitimate newline inside the field.
* If we're mid-IME composition, do nothing — let the IME finish.
* Otherwise preventDefault and call the same "advance" logic the Next
button uses, gated by submitting + canAdvance so the gate behaves
identically.
Also wrap each child of the custom-wallet header in a block <div> so
the "← Use my Agora wallet" link stacks beneath the "Custom wallet"
title instead of sitting on the same line. Both children were
inline-flex; the parent's space-y-1 only adds margin between block
children, so on wide enough viewports the two pieces ended up
side-by-side.
Four small refinements after first review:
* Drop the card chrome (border + bg) around the identity row and
remove the pencil. The row is now a plain avatar + name + balance
display sitting on the wizard's transparent background — visual
confirmation of the destination, not a button. The "Use a custom
wallet instead" sub-link beneath becomes the only affordance for
the swap.
* Stack the "← Use my Agora wallet" link beneath the "Custom wallet"
heading instead of placing it on the same row. Two pieces of
hierarchy fighting for the same line was too much; the swap link
reads more clearly on its own line.
* Drop the icons (sparkles / bitcoin / radar) from the accept-mode
toggle pills. Each pill now carries just its label. The icons
were trying to compress meaning into one glyph each and the
captions already say the same thing.
* Expand the toggle labels to "Accept All" / "Public Only" /
"Private Only" — full enough to read as commands rather than tags.
Cleans up the lucide imports (Pencil, Sparkles, Bitcoin, Radar) and
locale key (walletEditAria) the previous version introduced and the
new version no longer needs.
The wallet step previously stacked two generic dropdowns (source +
accept) on top of two custom-address inputs that the user had to expand
explicitly. Every donation flow starts the same way: pick "my wallet"
and accept everything. The redesign treats that path as the default
view, not one of two dropdown options.
What changed:
* The Source dropdown becomes an inline identity card — avatar +
display name on the left, live USD/BTC balance on the right
(modeled on the wallet-page treatment), pencil affordance on the
far right. Tapping anywhere on the card swaps the view into the
custom-wallet inputs; a quieter "Use a custom wallet instead"
sub-link beneath it offers the same swap. From custom mode a small
"← Use my Agora wallet" mirror-link snaps back.
* The Accept dropdown becomes a three-pill segmented ToggleGroup —
All / Public / Private — with icons (sparkles / bitcoin / radar)
and a one-line caption beneath that explains the current
selection. The All and Private buttons disable when silent
payments aren't supported by the current login. Default stays
'all' (HD wallet with SP); empty toggle deselects are coerced
back to the previous value since the field is required.
* Balance comes from the parent's existing useHdWallet hook (passed
in via new `totalBalance` + `balanceLoading` props) plus an
in-component useBtcPrice call. Loading state shows a small
Skeleton in place of the price line; missing price falls back to
BTC-only.
* When no HD wallet is available (extension / bunker logins) the
picker collapses to just the two custom inputs with the existing
intro copy — no card, no toggle.
Existing locale keys are reused where the strings still fit; new
ones cover the toggle short labels, the captions, and the swap
affordances. The wider "Custom" label widens to "Custom wallet" so
the segmented header reads cleanly. Other locales fall back to
English on the new keys until the copy settles.
The previous four-step layout bundled title with wallet and banner with
story. Each pairing forced the user to mentally context-switch inside a
single screen. Splitting them out makes every step ask exactly one
question:
1. title
2. wallet
3. banner
4. story
5. goal + deadline
6. country + tags (terminal)
The 'Skip Next & Launch' shortcut now appears from step 3 onward — once
both required steps (title @ 1, wallet @ 2) are cleared. Earlier steps
hide the shortcut entirely so the user can't try to publish before the
wallet picker has been shown.
The wizard signature changes from positional step1..step4 props to a
single `steps` array plus a `canAdvanceFromStep` predicate and a
`launchAvailableFromStep` cursor, so future step inserts / removals
don't ripple through the type. Step state moves from a `1|2|3|4`
literal union to `number`, validated against `steps.length` at runtime.
Step copy is rewritten to be concise — one question, one line of
context. Other locales already fell back to English; the wizard keys
they don't yet have stay untranslated until the copy settles.
Four small refinements on the captive overlay:
* Drop the 'Step N of 4' eyebrow above each title — the sticky
progress fill at the top of the overlay already carries that signal,
and removing the duplicate keeps the focus on the step heading.
* Rename the launch shortcut on steps 1-3 from 'Launch campaign' to
'Skip Next & Launch' so its relationship to the primary Next button
is unambiguous. Step 4's terminal button keeps the 'Launch campaign'
label (it isn't a shortcut, it's the only forward action).
* Move the per-step Back affordance from a text link under the launch
button up to a round icon button mirroring the close X in the
top-left corner. The two header buttons now bracket the dialog
symmetrically and the footer stays focused on forward motion.
* Reverse the order of the banner and story fields inside step 2 so
the banner upload sits on top — it's the first thing a donor sees on
the campaign card and feels like the natural first decision when
telling the story.
Campaign launches still navigate to the campaign details page on
success via encodeCampaignNaddr in submitMutation.onSuccess; no change
needed there.
Mounts the create-mode wizard as a 'fixed inset-0 z-50' dialog so it
sits above the persistent TopNav, matching the captive OnboardingGate
signup flow. Creating a campaign is now a focused, distraction-free
task without the app's regular chrome competing for attention.
The page-level back arrow + heading are replaced by an unobtrusive
top-right X (same affordance as the onboarding overlay). The
OrganizationContextChip — previously sat under the page heading —
moves inline into step 1 so the 'publishing under <org>' context
isn't lost.
Edit mode is unaffected — it still renders inside the normal
FundraiserLayout with the page header intact.
Swaps the segmented-pill progress indicator and boxed step body for the
visual language Chad established in OnboardingGate: a sticky single-bar
progress fill across the top, a centered narrow column per step, a
centered eyebrow / heading / subtitle block, a big rounded-full primary
CTA, and a subtle text 'Back' link. Steps now fade-and-slide in on
transition so the swap reads as navigation rather than a re-render.
Step boundaries are unchanged. Step 1 still holds the required fields
(title + wallet) and gates 'Next' on a non-empty title; every step from
1 onward surfaces a ghost 'Launch campaign' shortcut so the rest of the
wizard stays opt-in. Step 4 is terminal — its only forward action is
the primary 'Launch campaign' button.
Edit mode is unaffected — it keeps the single-page form.
New campaigns now use a multi-step flow modeled on AuthDialog's signup:
required fields (title + wallet) on step 1, story + banner on step 2,
goal + deadline on step 3, country + tags on step 4. A 'Launch campaign'
button sits next to 'Next' on every step from step 1 onward, so once the
required fields are filled the user can publish immediately and skip the
rest. Step 1's 'Next' is disabled until the title is non-empty.
Edit mode (?edit=naddr) keeps the original single-page layout — all
pre-populated fields stay visible and editable in one place, which the
linear wizard isn't optimized for.
Three small extractions that consolidate hand-rolled copies in the
discovery surfaces. No behavior change.
- getPledgeCoord → src/lib/pledges.ts. Was defined three times
(PledgesDiscoverySection, ActionsPage, ActionShareMenu), each with
the same '36639:<pubkey>:<d>' template. Lifted into the existing
pledges lib and typed structurally on { pubkey, id } so the lib
layer doesn't take a hook dep on Action.
- parseSort + toQuerySort → exported from useDiscoveryFilters and
useAllCampaigns respectively. AllCampaignsPage was carrying its own
copy of both with an apologetic comment ('mirroring the one in
useDiscoveryFilters'); CampaignsDiscoverySection had its own
toQuerySort. One source of truth each now, with the pages and the
section importing from the same module as the hook that consumes
the result.
- PledgeCardSkeleton → exported from PledgeCard. Replaces two
byte-identical ActionSkeleton components in PledgesDiscoverySection
and ActionsPage. Naming matches the existing CampaignCardSkeleton /
CommunityMiniCardSkeleton convention of placing the skeleton next
to its card.
Two related gates on the unified discovery sections:
- PledgesDiscoverySection: the chronological useActions({ limit: 300 })
query only feeds the idle render branch (via idlePledges), but it
was firing in active-search mode too. Active mode renders searchHits
from useNip50Search, which never reads rawActions. On every keystroke
that activates search we were burning a 300-event relay round-trip
whose results went nowhere. Gate the query on !isSearching so the
fetch happens only when the idle branch can actually consume it.
- CampaignsDiscoverySection: align the featured-coords useCampaigns
query's enabled flag with the pledges section's pattern. useCampaigns
already short-circuits on an empty coordinates array, so this is
purely about not creating an empty cache entry when moderators have
curated nothing — but it removes a small asymmetry that would have
made the next reviewer second-guess which pattern is intentional.
The home page now shows the same Campaigns / Groups / Pledges sections
as their dedicated pages (/campaigns/all, /groups, /pledges), with the
same titles, taglines, and search/sort/country toolbars instead of
'Browse all' shortcut links. Each surface's discovery logic lived
in its own page and the home page was about to grow a fourth copy of
it, so the section bodies move into reusable components:
CampaignsDiscoverySection src/components/discovery/
GroupsDiscoverySection
PledgesDiscoverySection
Each owns the section header (title / tagline switch on active
search), the DiscoverySearchToolbar, the idle featured grid, the
active search/sort/country grid, and the per-section empty / no-match
cards. Filter state (search input, sort, country, debouncing) lives
in a new useDiscoveryFilters hook which has two modes:
filterPersistence='url' - flat ?q=&sort=&country= params. Used by
the dedicated pages so search results
are shareable and survive refresh.
filterPersistence='local' - local-only state. Used by / where three
sections coexist and can't all own ?q=.
Refreshing the home lands on the curated
idle view, which matches what we want.
The dedicated pages keep their hero, optional Your-X shelf, and the
moderator-only Hidden collapsible — those stay page-level because
each page wants its own copy. They drive the section's Show-hidden
toolbar switch via a hoisted prop so the page-level Hidden
collapsible can read the same flag.
Side effects:
- ActionShareMenu moves from inside ActionsPage to its own file
so PledgesDiscoverySection can render it on every card without
re-importing the page module.
- useDiscoverCommunities is unchanged but only the dedicated
/groups page calls it now (for the Hidden collapsible /
hidden-count badge). The home page never triggers it.
- browseAllGroups and browseAllPledges locale keys drop from all
16 locales since the launchpad layout that needed them no
longer exists.
The home page used to be the canonical browse view for campaigns: hero
plus featured row, then the full community grid, then moderator-only
Pending/Hidden sections, then a per-viewer 'Your campaigns' shelf.
With /campaigns/all, /groups, and /pledges all now hosting their own
dedicated browse views (featured + search + sort + country in one
unified section), the home page no longer needs to duplicate the
campaigns browse experience.
Rebuild / as a three-section launchpad:
Hero -> unchanged (HeroLightningMap, Bebas Neue tagline, brand CTAs).
Featured campaigns -> capped at 4, links to /campaigns/all.
Featured groups -> capped at 8, links to /groups.
Featured pledges -> capped at 8, links to /pledges.
Each section pulls its featured set from the same moderation labels
that drive the dedicated page, so what surfaces here matches what
surfaces there — just truncated. Sections with no featured items
collapse silently (no empty card) so the page degrades gracefully if
moderators only curate one or two surfaces.
Each section's skeleton respects the dependency chain that gates its
underlying query: campaigns wait on useCampaignModeration, groups on
useOrganizationModeration (because useFeaturedOrganizations is
internally gated on it), pledges on usePledgeModeration. While those
are still resolving the section renders skeleton cards rather than
flashing an empty state.
Drop the unmoderated community grid, the Pending/Hidden moderator
sections, the 'Your campaigns' shelf, and the campaign-search
toolbar from the home page. All of that now lives on /campaigns/all
where viewers actually expect to browse and filter.
Add browseAllGroups and browseAllPledges to campaigns.home in all
16 locales so each section can link out with locale-appropriate copy.
useFeaturedOrganizations is internally gated on moderationReady — while
the organization moderation labels are loading, the underlying query
is disabled and reports isLoading: false / data: undefined. The Groups
page was using only that isLoading flag to decide whether to show the
skeleton, so during the moderation-loading window it rendered the
empty state for a moment before the curated grid popped in.
Track moderation readiness alongside the featured query and treat any
of the three states — moderation not ready, featured query in flight,
featured data not yet defined — as loading.
The Groups page was firing a global kind-34550 query through
useDiscoverCommunities, rendering the full results, then filtering
them client-side for the 'agora' client tag. This produced a brief
flash of unrelated communities before the curated set settled.
Drop the client-side Agora-tag filter entirely and stop using the
all-communities fetch for the idle render path. The unified Groups
section now renders moderator-featured groups directly, gated on
useFeaturedOrganizations's own loading state, so the page goes
skeleton → curated grid with no intermediate render.
useDiscoverCommunities is still called for moderators only — it
feeds the Hidden collapsible section and the hidden-count badge on
the toolbar. Non-moderators no longer trigger the global fetch at
all.
Campaigns, Groups, and Pledges each previously stacked a Featured
shelf above an All-X section. Collapse them into a single section
titled simply 'Campaigns' / 'Groups' / 'Pledges' that:
- Idle (no query, no sort, no country) shows the moderator-featured
grid. If nothing is featured yet, falls back to the chronological
all-X grid so the page is never blank.
- Active (the user typed, picked Top/New, or chose a country) shows
the full result set, ranked or chronological per the toolbar.
The shared toolbar drops the 'default' sort option from its dropdown
(now only Top and New). Clicking an already-active sort returns the
page to the curated idle view, giving users a clear exit affordance
now that 'default' is no longer an explicit menu choice.
Personal shelves (My pledges / My groups / Your campaigns) stay
above the unified section as separate, user-scoped lists.
The /feed page was firing one REQ per visible CampaignCard for
`{ kinds: [8333], '#a': [aTag], limit: 500 }` (kind 8333 donations
keyed to that card's campaign coordinate). With 25 Agora entities
per page that was ~25 parallel single-aTag REQs hitting the relay
in the same render tick — enough for relay.ditto.pub to drop the
socket with a 1005 close.
Mirror the existing single-#e pattern: detect single-#a filters,
collect them per (kinds, limit) shape over a microtask, then issue
one combined REQ with the merged `#a` array and group the result
events back to their callers by which addressable coordinate they
reference. Same approach `useAllCampaigns` already uses by hand for
its campaigns grid, now applied transparently to every per-card
hook (useCampaignDonations, useEventRSVPs, useMyRSVP, the badge and
livestream-chat single-coord lookups).
Anchor the recovery scan's default start height one block before the
earliest known affected transaction (the original $500 send 9fb78657…,
mined in block 951431) instead of a rolling 30-day lookback. No affected
output can predate that block, so this covers every stranded payment
while keeping the scan bounded. defaultFromHeight is now always defined,
so the page prefills the input on mount.
Adds a 'Double-tweak SP Fix' option that rescues silent payments stranded
on-chain by the historical double-tweak bug, where outputs landed at
Q = taproot_tweak(P_k) instead of P_k and were invisible to the normal
scanner.
- recovery.ts: scans indexer tweaks for taproot_tweak(P_k) candidates,
matches them against the block UTXO set, and builds/signs a sweep that
spends them with taprootTweakPrivKey(b_spend + t_k).
- useHdWalletDoubleTweakRecovery: in-memory range scan + match reporting,
no NIP-78 persistence (recovered coins are swept immediately).
- WalletDoubleTweakFixPage at /wallet/double-tweak-fix: scan controls,
recoverable total, and a one-tap sweep into a fresh BIP-86 address.
- Wired into the legacy recovery hub and AppRouter.
English strings only; other locales fall back to English at runtime.
When spending a previously-received silent-payment UTXO, its signing scalar
d_k contributes to the BIP-352 input sum A. The recipient's indexer rebuilds
A by lifting each input's on-chain x-only key to even-Y, so the sender must
contribute the even-Y-normalized scalar (-d_k when d_k·G is odd-Y). SP inputs
were passed with isTaproot:false, which skipped that negation, so for odd-Y
d_k the output landed at a key the recipient never derives and the payment
was invisible.
Pass SP inputs with isTaproot:true so deriveSilentPaymentOutputs applies the
even-Y normalization. Input signing is unaffected (BIP-340 handles parity in
signSpUtxoInput, which derives its own d_k). Add a sender→receiver round-trip
regression test covering an odd-Y SP input.
The BIP-352 sender derived the correct output key P_k but then passed it
through btc.p2tr(), which treats its argument as a Taproot internal key and
applies the BIP-341 TapTweak again. The on-chain output was therefore
taproot_tweak(P_k) instead of P_k, a key the recipient's scanner never
derives — so Agora-built silent payments were unspendable/undetectable by
the recipient.
Write the SP output script as the raw OP_1 push32 <P_k> program via
spP2trScriptPubKey, and fix encodeP2TR to encode the key verbatim (tr
output script) rather than re-tweaking it.
The headline ‘$X.XX pending’ badge reads Blockbook's account-level
`unconfirmedBalance`, which captures mempool credits to *any*
xpub-derived address — including a freshly-advertised receive address
with no prior confirmed history. The Transactions accordion, however,
only attributes a tx to the wallet if its inputs/outputs touch an
address in the `tokens=used` set Blockbook returns, and that set may
omit addresses whose only activity is mempool-only. The result: the
headline updated, but the row never appeared below.
Pre-derive the next 20 receive + 20 change addresses past
`firstUnusedIndex` on each chain and fold them into `ourAddresses`
inside `buildHdTransactions`. Cheap (one HMAC-SHA512 per address) and
adds no network traffic.
Also teach `TxRow` to render pending state explicitly: spinning
`RefreshCw` + orange ‘Pending’ label in place of the date, mirroring
the headline `PendingBadge`. Reuses the existing
`wallet.tx.pending` i18n key so no locale changes are needed.
Bookmark / Add to list / Add to sidebar don't map onto kind 33863
campaigns (addressable, with dedicated UI), so hide them when the
menu is opened from a campaign. Relabel "Mute Conversation" to
"Mute Campaign" in the same context.
resizeImage.ts and ImageCropDialog.getCroppedBlob were doing the same
five-step pipeline (decode -> optional crop -> downscale -> encode ->
File wrap) with mildly different defaults, so quality decisions had to
be made in two places. Adding the PNG-vs-JPEG comparison to crops, or
adjusting JPEG quality across the app, meant editing both.
Consolidates into a single encodeImage(source, options) in
@/lib/resizeImage:
- source can be File | Blob | string (URL); the helper fetches/decodes
- crop is an optional source-pixel rect (full image when omitted)
- maxOutputSize caps the long edge; 0/undefined means no cap
- compareFormats encodes both JPEG and PNG and returns the smaller
- passthroughIfWithinBounds short-circuits the re-encode for files
already within the cap and not being cropped
- returns { file, dimensions } with the correct mime/extension
resizeImage(file) is now a one-line wrapper preserved for existing
callers (ComposeBox, ImageUploadField).
ImageCropDialog.getCroppedBlob is gone; the dialog calls encodeImage
directly and now emits a File (JPEG or PNG, whichever is smaller)
instead of a hardcoded JPEG-only Blob. JPEG quality drops from 0.92 to
the lib default of 0.85 so cropped covers match the rest of the
upload pipeline.
The onCrop contract changes from (Blob) => void to (File) => void.
Updated both consumers (CoverImageField, ProfileSettings) — neither
needed the manual 'new File([blob], ...)' wrapping anymore.
CoverImageField unconditionally capped the crop canvas at 1600px on the
long edge, which was right for the default 'compressed' setting but
silently overrode the user's choice when they had opted into 'original'
via Network Settings. Other upload paths (ComposeBox, ImageUploadField)
already respect this preference; campaign/action banners were the only
holdout.
Now: still crop to the configured aspect (that's a framing decision, not
a quality knob), but only pass maxOutputSize through when imageQuality
is 'compressed'. Users on 'original' get a full-resolution JPEG at q=0.92
from the cropped region, capped only by the natural source dimensions.
CoverImageField now routes every file-input pick and drag-drop through
ImageCropDialog instead of uploading the raw source. The crop is locked
to 3:1 by default (banner aspect, matching the dropzone preview) and
capped at 1600px on the long edge, so a multi-megapixel phone photo
ends up well under a megabyte instead of a multi-megabyte JPEG.
ImageCropDialog gained an optional maxOutputSize prop that downscales
the canvas via the 9-arg drawImage form with high-quality smoothing.
The default is no cap — preserves ProfileSettings behavior for callers
that haven't opted in.
Template clicks and direct URL paste still skip the crop dialog; those
are already-finalized URLs we don't own.
useLoggedInAccounts runs its own kind-0 query with a hard 1.5s relay
timeout. When that comes back empty (slow relay, cold pool), every login
gets metadata: {}, the AccountSwitcher avatar drops through to the
AvatarFallback, and genUserName() returns the literal 'Anonymous' — so a
logged-in user sees an 'A' placeholder instead of their picture, even
though the rest of the app (which uses useAuthor) shows the right
profile.
Layer useAuthor on top of the existing currentUser. useAuthor is seeded
from IndexedDB and shared with every other consumer of the user's
kind-0, so the avatar now picks up cached metadata immediately and stops
showing the 'A' fallback on logged-in sessions.
When a scanned QR or pasted BIP-21 URI carries both an on-chain address
and an sp= silent-payment parameter, the recipient input now surfaces
both as separate rows in a Popover dropdown so the donor explicitly
picks privacy (sp1) vs. compatibility (bc1) — matching how Ditto's send
dialog handles the same ambiguity. Refocusing or clicking the input
while it still contains a URI reopens the dropdown so the choice can be
changed without retyping.
Picking a row swaps the input out for a chip showing the chosen kind,
a truncated address, and an X to return to the input view. Bare bc1
or sp1 input still resolves directly, and single-option scans (URI with
only one valid candidate, bare address, bare sp1) bypass the dropdown
and go straight to the chip.
QR scanning moves into the picker, so the dialog no longer needs its
own scanner dialog or BIP-21 routing logic. The picker only supports
bc1 and sp1 destinations — pasted npub/nprofile is silently ignored
(no account search), matching Agora's narrower scope vs. Ditto.
The campaign donate flow used to pass two props (bc1 + sp1) and the
dialog rendered a swap toggle under the input. With the dropdown now
handling that choice natively, the toggle is gone and the campaign
page just builds a combined bitcoin:bc1?sp=sp1 URI as the prefill.
The NIP-73 external content page recognized bitcoin:tx:<txid> and
bitcoin:address:<addr> identifiers (parsed and titled correctly), but
ExternalContentPage never rendered a body for them — visitors arriving
from a wallet transaction row just saw the 'Bitcoin Transaction'
header with nothing beneath it.
Port BitcoinTxHeader and BitcoinAddressHeader from Ditto: confirmed/
unconfirmed status, block/size/fee/amount stats, mempool.space-style
inputs-to-outputs flow, address balance hero with sats + USD,
recent-transaction list, and a footer link out to mempool.space. The
backing useBitcoinTx and useBitcoinAddress hooks compose Agora's
existing fetchTxDetail / fetchAddressData / fetchAddressTxs helpers
against the configured esploraApis from AppContext, and share the
spot price with useBtcPrice so the page doesn't double-fetch.
The Send dialog used to print 'Available: $X.XX (Y sats)' below the
recipient field once the amount exceeded the balance. That left the
Send button reading 'Send Bitcoin' (disabled) with a separate footnote
the user had to notice and connect to the disabled state.
Move the signal onto the button: it now reads 'Not enough Bitcoin'
when the amount + estimated fee exceeds the available balance, and the
standalone availability line is gone.
The qr-scanner library spins up its ZXing decoder inside a Web Worker
created from a blob URL. Our CSP allowed scripts and connections but
not workers, so the browser silently blocked worker creation — the
camera opened fine (media-src is permissive) but no frame was ever
decoded, leaving the user pointed at a QR code that never registered.
Add 'worker-src self blob:' and 'child-src self blob:' (the latter
covers older browsers that fall back to child-src for worker policy)
to match the directives Ditto already ships.
Regression-of: bae49e61
The receive address advances automatically when funds are detected, so
exposing a manual "next address" affordance is redundant and lets users
needlessly skip ahead in the derivation chain. Drop the RefreshCw button
to the left of the BIP-21 copy row and the now-unused
wallet.receiveDialog.newAddress key across all locales.
The recipient input on /wallet's Send dialog no longer:
- Shows a "Recipient" label above the field.
- Lists "npub…" in its placeholder (now just "bc1…, sp1…").
- Searches Nostr profiles by name as the user types, or renders a
dropdown of matching accounts.
- Shows a search icon inside the input.
Pasted/scanned NIP-19 identifiers (npub1…, nprofile1…) still resolve
to a Bitcoin address via the existing `resolveRecipient` path, and
the resolved profile chip still renders below the input so the
sender can confirm the destination — only the autocomplete UI is
gone.
The walletSend.recipient.label i18n key is removed from every locale.
The useSearchProfiles dependency on this component is dropped; the
hook stays for other callers (mention autocomplete, search page,
etc.).
- Drop the "≈ N sats" line that sat under the dollar amount. The
USD figure is the source of truth in this dialog; the sats
conversion was visual noise.
- Drop the "Network fee" label and move the fee-tier popover under
the Send button, centered. With only one popover in the dialog,
the label was redundant and the row above the Send button was
competing with the recipient input for attention.
- Remove the now-unused walletSend.approxSats and walletSend.networkFee
i18n keys from every locale.
The "Sending to a raw Bitcoin address." / "Sending to a Nostr
user's on-chain address." / "Sending via a silent payment…"
muted-text line below the recipient input is gone. The recipient
chip already shows who's being paid, and the soft amber privacy
disclaimer covers the raw-address case, so the extra status line
was just noise. The three now-unused i18n keys are removed from
every locale.
The disclaimer shown when sending to a bare bc1… address now uses
the existing `soft` amber tone instead of the destructive red one,
and no longer requires ticking an acknowledgement checkbox. The
checkbox-gating made the Send Bitcoin button appear permanently
disabled to users who hadn't noticed (or hadn't scrolled to) the
checkbox.
The `bitcoinPublic` disclaimer component already supported both
tones — only the Send dialog's wiring changes here. The unused
`walletSend.errors.acknowledgePrivacy` string is removed from
every locale.
The recipient input on /wallet's Send dialog now has a camera button
that opens a QR scanner. Bitcoin BIP-21 URIs are parsed and the
silent-payment fallback (?sp=) is preferred when present, falling
back to the on-chain address otherwise. Plain addresses, sp1… codes,
npub, and nprofile values are dropped into the input verbatim and
resolved by the existing recipient logic.
QrScannerDialog is a standalone component (ported from Ditto) that
owns the camera lifecycle via getUserMedia and the qr-scanner npm
package. It surfaces failure modes (insecure context, denied
permission, no camera, busy camera, overconstrained, ready timeout)
instead of a silent black screen, and offers a flash toggle when the
device supports it.
Android needed an explicit CAMERA permission in the manifest; iOS's
existing NSCameraUsageDescription string was extended to mention QR
scanning. No Capacitor camera plugin is required — the standard web
APIs work inside WKWebView and Android's WebView.
The campaign banner previously stretched edge-to-edge with
`object-cover` and a fixed aspect ratio (4:3 mobile, 21:9 sm, 3:1 lg),
which cropped landscape banners hard left/right on phones and clipped
any baked-in text near the image edges.
Replace it with a full-bleed banner that respects the image:
- Sharp foreground uses `object-contain` capped to the same
`max-w-6xl` reading column and `max-h-[70vh]`. The banner's
height is dictated by the image, never by an arbitrary aspect
ratio. Source pixels are never cropped.
- The bleed gutters around the contained image are filled by a
blurred, scaled-up copy of the same image (`object-cover scale-110
blur-2xl brightness-75`) on a `bg-black` base, with a soft
horizontal vignette (`from-black/55 via-transparent to-black/55`)
so the bleed recedes and the centered image reads as the subject.
- A subtle `shadow-lg shadow-black/25` lifts the banner off the
page below.
- Clicking the banner opens the cover in the shared
`Lightbox` from `@/components/ImageGallery` — same fullscreen
component already used by NoteContent, NoteCard, PostDetailPage,
etc. (portal, body-scroll lock, keyboard nav, swipe, pinch zoom,
Capacitor-safe download).
Move the back / edit / delete chrome to behave differently per
breakpoint. On mobile (where the banner image fills nearly the full
viewport width and the bleed gutters are too narrow to safely host
overlays) the controls live in a black band above the banner. On
`sm:` and up they overlay the banner inside the same `max-w-6xl`
column as glass chips. The black band wrapper collapses to zero
height on desktop so the banner sits flush with the page top.
Add a new `campaignsDetail.openCover` translation key (aria-label
for the cover button) to en.json and translate it into all fifteen
non-English locales.
The campaign detail hero overlaid the title, summary, byline, country/
deadline meta, and PostActionBar on top of the banner image, behind an
80%-tall bottom scrim. Two failure modes on every breakpoint:
- Banner images often carry baked-in text or branding. The scrim
covered the bottom 80% of the image, and `object-cover` cropped
the rest hard on phone-portrait containers (mobile used
`min-h-[92svh]`, roughly 1:1.6 portrait), so banners with side-
aligned text were sliced off.
- Long titles and tl;dr summaries overflowed the fixed-height hero.
With `overflow-hidden` on the header the top of the title got
clipped behind the upper edge of the banner.
Split the hero into two components. `CampaignHero` keeps only the
banner image and the floating back/admin chip buttons; aspect ratios
stay landscape at every breakpoint (4:3 mobile, 21:9 sm, 3:1 lg) so
`object-cover` only trims a thin strip top/bottom. The top scrim is
gone — the chip buttons already carry `bg-black/30 backdrop-blur-md`
backdrops that read on any image without darkening the banner.
`CampaignHeading` renders the title, summary, byline, meta, divider,
and `PostActionBar` in the normal page flow inside the existing
`max-w-6xl` column on `bg-background`. Title and summary no longer
need a text-shadow or line-clamp; the action bar drops the glass-chip
overrides and inherits its default styling against the page surface.
The Trans component's values={{ count: formatNumber(N) }} was spreading
after the numeric count prop, overriding it with a string. i18next v26
requires a numeric count for plural resolution — a string causes it to
return the raw key path (e.g. 'campaignsDetail.repost') instead of the
resolved plural form.
Rename the interpolation variable from count to formattedCount so it no
longer collides with the count prop that Trans uses for plural selection.
Update all 16 locale files to use {{formattedCount}} in the repost, quote,
and like translation strings.
Regression-of: 86a084f3
The Trans component's values={{ count: formatNumber(N) }} was spreading
after the numeric count prop, overriding it with a string. i18next v26
requires a numeric count for plural resolution — a string causes it to
return the raw key path (e.g. 'campaignsDetail.repost') instead of the
resolved plural form.
Rename the interpolation variable from count to formattedCount so it no
longer collides with the count prop that Trans uses for plural selection.
Update all 16 locale files to use {{formattedCount}} in the repost, quote,
and like translation strings.
Regression-of: 86a084f3
When FollowPage was removed in the unreachable-page cleanup, the
QR code on the profile kept encoding `${origin}/follow/<npub-or-nip05>`,
which falls through to NotFound because there is no `/follow/...`
route. Encode the bare identifier instead — Agora's universal
NIP-19 dispatcher at /:nip19 already resolves both npub and
`user@domain.com` to ProfilePage.
Regression-of: b975e557
The Create Campaign page initialised `walletSource` to `'custom'` and
relied on a post-mount effect to flip it to `'mine'` once
`hdWalletAvailable` became true. With Radix Select's controlled value
that left users staring at the "Choose a wallet" placeholder on the
initial paint and forced them to open the dropdown to see that their
Agora wallet was even an option.
Seed both `walletSource` and `mineAccept` lazily from the
synchronously-available HD-wallet availability so the trigger already
reads as the user's Agora wallet on first render. The existing
availability-change effects still cover the (rare) case where the hook
resolves a tick later or where the wallet disappears mid-session.
Opens the HD wallet's send dialog prefilled with the campaign's on-chain
address — same flow used at /wallet, so donors don't have to learn a
second send UI. Sits above 'Open external wallet' inside the donate
panel; the external button downgrades to the secondary variant when
both are present so only one orange CTA stacks.
When the campaign declares both an on-chain (bc1…) and a silent payment
(sp1…) endpoint, a swap link appears under the privacy disclaimer so
donors can flip to SP without leaving the modal. The toggle hides
itself if the donor manually edits the recipient field, so we never
trash their typed input.
Gated on useHdWalletAccess (nsec logins only) — extension and bunker
logins fall through to the external-wallet QR. The button is also
hidden for SP-only campaigns (no on-chain address to prefill) and for
the campaign owner themselves.
Add Kosovo (XK) and Western Sahara (EH) to the country list. Kosovo
has no Unicode emoji flag, so it follows the Tibet pattern with a
bundled SVG asset that CountryFlag swaps in.
Surface Tibet (CN-XZ) as a search-list entry so it can be picked from
country autocompletes and pickers. The on-wire identifier stays
iso3166:CN-XZ; only the picker pretends.
Route every remaining raw country.flag span through CountryFlag so
bundled SVGs render in autocomplete dropdowns, organizer selects, the
ComposeBox destination switcher, and the world stats dialog.
Picked the noun each locale uses in its existing last3h / last24h
preset translations so the standalone label stays consistent with
the dropdown options it sits underneath.
The Math.max(resume, target) clamp inside resolveWindowFromHeight
turned the Since dropdown into a no-op whenever the wallet's
scanHeight had caught up to the chosen window. Picking 'Last week'
one minute after a successful scan resolved to scanHeight + 1, not
to a week ago, so the user couldn't actually rescan history from
the primary control — they had to drop into Advanced -> From block.
Removed the clamp so Since presets honor the wall-clock window
literally. Re-scanning blocks we've already scanned is cheap (the
indexer is just iterating tweak data) and is exactly what the user
asked for. Also dropped the isPresetUpToDate predicate that, after
the original clamp landed, was the mechanism by which Start went
disabled after a scan completed. isManualUpToDate is kept so the
override path still flags 'block number past the tip'.
Regression-of: 38946cbc
Adds a Custom… entry to the Since select that, when picked, reveals
an hours number input directly under the select. The hours value is
parsed as a positive (fractional allowed) number, multiplied to
seconds, and fed through the same mempool.space resolver as the
fixed presets. The Start button stays disabled until a valid hours
value is entered so there's no ambiguous empty-equals-zero submit.
Power users who want sub-hour granularity (e.g. 0.5) get it without
having to drop into the Advanced block-height path.
Bitcoin block timestamps obey BIP-113's median-time-past rule — they
must exceed the median of the previous 11 timestamps but don't have
to be strictly monotonic. Empirically, 1-2-block inversions show up
in the live chain regularly; 3-block rewinds covered the common case
but could still miss a payment landed in a block whose timestamp ran
backwards near the user's selected cutoff. 11 is the principled
upper bound (no inversion can extend further under MTP) and costs
only ~8 extra block fetches per scan.
Regression-of: 46d9952a
mempool.space's timestamp-to-block endpoint is the sole source of
truth now. The Blockbook binary search fallback was sequential and
could stall the UI for ~20 round-trips with no progress feedback
when mempool.space was down. When the lookup fails, surface a toast
that points the user at the Advanced -> From block override and
auto-open the disclosure so the escape hatch is immediately visible.
Regression-of: d6e6d616
Two small fit-and-finish tweaks the previous restyle missed:
1. The tab strip's baseline border sat 1px above the rounded panel's top
edge, so the panel's rounded corners visually overlapped the active
tab's under-rule. TabsContent now carries an explicit mt-4 (16px) so
the panel floats cleanly under the tab strip with breathing room
above its rounded top corners.
2. Tab labels were spaced gap-1 (4px), too tight for labels that read
as section headers. Bumped to gap-8 (32px) so 'Comments & donations'
and 'Ledger' read as separate section titles.
CommentsSection's outer mt-4 is overridden with className=mt-0 at the
call site so the TabsContent gap controls the spacing — no more 32px
double-margin.
The shadcn default 'muted pill control' tab strip felt bolted onto the page
between the action bar and the comments panel. The tab labels also doubled
up with the 'Comments & donations' heading rendered inside the panel — two
section titles stacked.
Replaces the pill control with an underline-style tab strip that visually
serves as the section header for the panel below. The active tab label is
rendered at the same size and weight as the page's other h2 section
headings (text-lg font-semibold tracking-tight), inactive tabs are muted
siblings, and a 1px baseline border carries across the panel width while
the active tab paints a thicker primary under-rule that flows into the
content surface.
CommentsSection's title becomes optional and the campaign page omits it
now that the tab label owns the heading. The dead 'commentsAndDonations'
i18n key is removed from all 16 locales (tabComments carries the same
copy).
Flips the headline + subline on each ledger row: USD is now the prominent
top number and the BTC equivalent (full units, not sats) sits below it in
muted text. Falls back to BTC-as-headline when the BTC/USD price isn't
available yet. Renames the i18n leaf 'campaignsDetail.ledger.satsUnit' to
'btcUnit' across en.json and all 15 other locales — BTC is a universal
ticker, so the unit string itself is now 'BTC' everywhere.
Moves the existing comments + donations panel under a 'Comments & donations'
tab and introduces a sibling 'Ledger' tab that surfaces public on-chain
activity for the campaign's bc1 address — receives, sends, confirmation
status, block height, and the equivalent USD — sourced from the
already-configured Esplora endpoints with mempool.space deep links per row.
The Ledger tab is rendered only when the campaign declares an on-chain
endpoint ('bc1q…' / 'bc1p…'). Silent-payment-only campaigns intentionally
have no scannable address and degrade to the un-tabbed comments surface to
avoid showing a lone disabled tab.
A new fetchAddressTxs helper in src/lib/bitcoin.ts wraps Esplora's
/address/:addr/txs + /chain/:last_seen_txid pagination, and useAddressLedger
exposes it as an infinite query (50 confirmed tx page size, plus mempool on
the first page).
Sits in the Bitcoin Donations chapter right after 'Are donations on
{{appName}} public?', so the natural flow goes from how donations
work to where they show up to why some take a while to confirm.
Translated into all sixteen locales.
The campaign detail already verifies each donation receipt on-chain
and knows whether the underlying Bitcoin tx is confirmed, but the
flag was being dropped before it reached the UI. Mirror the wallet's
treatment so visitors can see funds that are inbound but still in
the mempool.
Three changes:
* Extract PendingBadge (orange + spinning RefreshCw) and use it on
both the wallet headline and the campaign donate column. The
wallet's inline JSX is replaced with the shared component.
* Widen useCampaignDonations to expose pendingSats (the mempool
delta from Esplora) and a confirmedByTxid lookup built from the
already-verified receipts.
* DonateColumn now renders the pending sats under the raised total
and DonorPreviewList swaps the relative timestamp for the badge
on any row whose underlying tx is still unconfirmed.
CreateActionPage, CreateCommunityPage, and CreateEventPage each
showed a dead-end 'Back to pledges' / 'Back to groups' / 'Back to
events' link as their only logged-out CTA. Mirror the campaign-gate
fix and render LoginArea instead so visitors can join or log in
without leaving the page, and drop the three now-unused i18n keys
from every locale.
Regression-of: 2870d7a6
The gate previously offered only a 'Go home' link, leaving visitors
to find the login flow themselves. Render the existing LoginArea CTA
in its place so the 'Join' / 'Log in' modal opens directly from the
gate, and drop the now-unused campaignsCreate.goHome string from all
sixteen locales.
Wraps the title/toolbar row with flex-col on mobile so the search input,
filter dropdown, and country picker no longer cram next to the section
title at narrow widths. The toolbar also lets the search input grow to
fill the row on mobile (flex-1) while keeping its compact 16rem width
from the sm breakpoint up.
Applies to /groups, /pledges, and /campaigns/all — every place the
DiscoverySearchToolbar sits on a section heading row.
The brand mark in the top nav was rendered in the default UI font
(Inter Bold at text-lg). Swap it for the same typographic recipe the
CampaignsPage hero uses — Bebas Neue (font-display), uppercase, tracked,
with a 0.022em currentColor text-stroke that fattens the weight-400
letterforms without the fuzz a synthetic-bold would produce. The size
is bumped to text-3xl and the bolt icon to size-9 so the wordmark reads
as a deliberate logo lockup instead of a piece of UI chrome.
Two deviations from the hero recipe, tuned for the nav-bar context:
- The hero uses Bebas Neue's native italic (~12° skew). Here we
render the roman face and apply a softer skewX(-6deg) transform.
Sharp italics at nav-bar size start to read as a glitch rather
than personality; a gentle oblique keeps the family identity
without sacrificing legibility next to the four right-leaning
nav links.
- A scaleX(1.1) widens the letterforms slightly. Bebas is naturally
tall+narrow, which gets visually crowded at this size next to the
chunky bolt mark — the horizontal scale rebalances the proportions
so the wordmark sits as a peer to the icon instead of receding.
transform-origin: 0 100% anchors the skew to the baseline so the icon-
to-text relationship stays stable, and -ml-0.5 tucks the wordmark
tight against the bolt to read as a single lockup.
While here, change the active page indicator in the same nav from
text-foreground (white in dark / black in light) to text-primary so
the current page reads in brand orange. The mobile drawer's active
state already had a bg-primary/10 wash but kept text-foreground on
top; switch its text to text-primary as well so the orange wash and
the orange label reinforce each other instead of fighting.
Every caller wraps the grid in a `max-w-7xl mx-auto px-4 sm:px-6`
page container that already supplies the page-edge gutter — matching
the Campaigns and Pledges discovery pages. CommunityGrid then added a
second `px-4 sm:px-6` of its own, which every caller defensively
overrode with `className="px-0"`. The override only neutralized the
base utility: tailwind-merge happily kept the responsive variant, so
the `sm:px-6` survived. The result was group cards inset 24px further
than campaign and pledge cards at every `sm` breakpoint and up.
Drop the internal padding from CommunityGrid and remove the redundant
`px-0` overrides on the eleven CommunitiesPage callers. The page
container is now the single source of truth for horizontal padding on
all three discovery surfaces.
A community is its own scope — narrowing groups by country isn't a
useful axis the way it is for campaigns and pledges (which are about
local action). Drop the country prop from CommunitiesPage's
DiscoverySearchToolbar call so the Globe button doesn't render at
all there. Campaigns and Pledges keep theirs.
The pledges page already had a Globe-icon country popover sitting next
to a sort dropdown and the relay-driven search toolbar. The three
side-by-side filter affordances duplicated each other and pulled focus
away from the section heading. Collapse the cluster into the shared
DiscoverySearchToolbar:
- Drop the page-local Recent / Bounty / Deadline sort dropdown on
pledges. The toolbar's Default / Top / New sort, backed by NIP-50,
already covers the same ground and applies to every discovery
surface; pledges fall through to a newest-first chronological view
inside each lifecycle section.
- Add the country picker as a first-class prop on
DiscoverySearchToolbar (`country` + `onCountryChange`) so all
three discovery pages share the same Globe button without a generic
trailing-slot escape hatch. Brand-orange icon in the neutral state,
country flag emoji when selected.
- Extend useNip50Search with an optional `iTags` array, forwarded as
a standard `#i` filter alongside the `search` field. A non-empty
iTags array also activates the hook so picking a country with no
typed query produces a country-scoped grid, narrowing kind 34550 /
36639 by NIP-73 external identifier.
- Extend useAllCampaigns with a `countryCode` parameter that adds
the same `#i` filter to the relay query. AllCampaignsPage tracks
the country on the URL alongside the existing `sort` and `q`
params so country-scoped views are linkable.
- Brand-orange the ListFilter icon on the toolbar to match the
pledges-page aesthetic the user wanted to spread to the other
discovery pages.
The DiscoverySearchToolbar (search input + sort + show-hidden) was
duplicating affordances the dedicated /campaigns/all page already
provides. The home page is now a curated landing: hero, featured row,
community grid, and moderator/creator sections.
The three discovery pages (Campaigns, Pledges, Communities) already
pass sortMode and getKeywordHaystack to useNip50Search, but the hook
only supported 'top' / 'new' and did no client-side keyword filter —
so 'default' fell back to 'new' (a chronological feed of the entire
kind on first paint, not the curated layout the page actually wants)
and queries for kinds whose human-readable title lives in tags
(33863 campaigns, 34550 organizations, 36639 pledges) silently
returned nothing whenever the relay's NIP-50 index only indexed
'content'.
Extend Nip50Sort with a 'default' variant: empty-query + 'default'
deactivates the hook so the page renders its featured/curated layout,
while a typed query still issues a chronological search. 'top' and
'new' keep their existing semantics; 'new' now also drives an empty-
query chronological feed (previously only 'top' could). Plumb the
new option through DiscoverySearchToolbar as a LayoutGrid-iconed
segmented button and adjust the active-filter detector.
Add an optional getKeywordHaystack callback that lets callers supply
per-event strings (title / name / summary tag values + content) for
a case-insensitive substring re-filter over the relay response. This
costs a small amount of recall (we still rely on the relay to surface
the candidate) but fixes the 'search returns nothing' failure mode
for tag-titled kinds without changing the relay protocol or pinning
to a different index.
Regression-of: c61e9a06
The moderation menu / overlay / review-queue refactor wired pledges
into the same shared moderation components as campaigns and groups,
and three call sites (ActionsPage, ModerationMenu, ModerationOverlay)
imported usePledgeModeration from @/hooks/usePledgeModeration. The
hook file itself was never staged, so the tree didn't build and any
fresh clone would have failed tsc at those imports.
Add the hook (two-axis model — hide + featured, no approval gate, same
agora.moderation namespace and Team Soapbox moderator pack as
campaigns/organizations) and document the kind 36639 surface in
NIP.md alongside the existing 33863 / 34550 entries so the spec
matches the implementation.
Regression-of: c61e9a06
The /pledges page introduces large bold section headers above its grid
("text-2xl sm:text-3xl font-bold tracking-tight" + muted tagline +
right-side controls). /campaigns/all had no section heading at all, and
/groups was using the smaller SectionHeader pattern ("text-base
font-semibold") for its 'My groups' and 'Featured groups' shelves —
even though its own search-mode branch already used the large-bold
pattern. Three pages, three different section-header treatments.
Aligns both pages with the /pledges style:
- /campaigns/all: add a heading above the grid that switches between
Search / Top / New labels based on toolbar state, with a constant
tagline ("Browse every cause on the network").
- /groups: replace the SectionHeader usages on the My groups / Featured
groups shelves with the same large-bold heading + tagline pattern;
drop the now-unused SectionHeader import. Inner shelves
(CommunityGrid, EmptyShelf, show-more) get their internal
px-4 sm:px-6 stripped since the outer container now provides it.
- Moderator review sections use the same px-0 grid + collapsed trigger
padding inside the new wrapper.
/ (Index → Feed) keeps tabs as its visual navigation, so it doesn't
need a heading — that exception is the rule that proves the pattern.
ActionCard rendered two kebabs side-by-side: ActionShareMenu (Copy link +
owner Delete) and the shared ModerationOverlay's mod-only kebab. The two
buttons were also styled differently — the share menu had no banner
backdrop, so on photo-heavy cards it floated against the image while
the moderator kebab next to it sat in a translucent pill.
Extract ModerationMenuItems from ModerationMenu — the dropdown rows
themselves (label + items) without the trigger/content wrapper. Standalone
ModerationMenu and ModerationOverlay still work the same; cards that need
to embed moderator actions inside an existing dropdown now compose them
directly:
<DropdownMenuContent>
<DropdownMenuItem onClick={copy}>Copy link</DropdownMenuItem>
{isOwner && <DropdownMenuItem onClick={del}>Delete</DropdownMenuItem>}
{isMod && <DropdownMenuSeparator />}
<ModerationMenuItems coord={…} surface="pledge" axes={…} entityTitle={…} />
</DropdownMenuContent>
ModerationOverlay grows a showMenu prop (default true) so a card can still
get the mod-gated 'Hidden' badge in its banner corner without the
redundant second kebab when the moderation items have been embedded in
another dropdown.
Then on ActionCard:
- ActionShareMenu absorbs ModerationMenuItems under a separator, so the
card carries exactly one kebab.
- Its trigger now uses the same 'h-8 w-8 bg-background/80 backdrop-blur
text-muted-foreground hover:text-foreground' classes as the campaign
and group card kebabs so the pill is consistent across surfaces.
- The mod-only 'Hidden' badge still renders, via ModerationOverlay with
showMenu={false}.
- Adds a generic pledges.card.actionsAriaLabel ('Pledge actions')
aria-label since the kebab is no longer just for sharing — translated
into all 15 non-English locales.
Campaigns, pledges, and groups each had their own moderation kebab,
'Hidden' badge, and collapsible review sections. The three implementations
had drifted: the campaign card inlined the badge + menu while the pledge
and group cards used per-surface overlay wrappers, the pledge cards
imported the kebab without its 'Hidden' badge (so hidden pledges showed
the kebab but no visible status), pledges had no page-level Pending /
Hidden moderator queues at all, and each page reimplemented the
collapsible review section as a local component.
Consolidate everything into src/components/moderation/:
HiddenBadge — the shared destructive chip with EyeOff + 'Hidden'.
ModerationMenu — the shared kebab with an `axes` config prop, so
campaigns can request ['approval', 'hide', 'featured'] and pledges /
groups can request ['hide', 'featured']. Per-surface inner components
each mount exactly one moderation hook so a pledge card never
subscribes to the campaign label query (and vice versa).
ModerationOverlay — the absolutely-positioned wrapper that bundles
HiddenBadge + ModerationMenu in a card corner. Same gating contract:
returns null for non-moderators.
ModeratorCollapsibleSection — the shared 'Pending / Hidden' collapsible
used by CampaignsPage, CommunitiesPage, and now ActionsPage.
Migrate the three card surfaces and three index pages onto these shared
pieces, fixing two concrete bugs along the way:
ActionCard now renders a 'Hidden' badge on hidden pledges. Previously
it imported PledgeModerationMenu directly (kebab only) instead of
PledgeModerationOverlay (badge + kebab), so moderators saw the
kebab but had no inline cue that a card was hidden.
ActionsPage now has page-level Pending and Hidden moderator review
queues at the bottom, mirroring the queues already present on
CampaignsPage and CommunitiesPage. Pledges share the two-axis model
with groups, so 'Pending' here means 'no curation decision yet' —
pledges not yet featured or hidden.
useActions grows an optional `enabled` flag so the page can skip the
moderator-only review-queue query entirely for non-moderators rather
than firing it with limit:0.
Locale work: add the new moderation namespace and the pledges.list
needsReview / hidden / *Desc / *Empty keys to en.json and all 15
non-English locales (ar, es, fa, fr, hi, id, km, ps, pt, ru, sn, sw,
tr, zh, zh-Hant).
Build a single search affordance shared by Campaigns home, All Campaigns,
Communities, and Pledges so the four discovery surfaces stop having
four different toolbars (or none at all).
New shared pieces
-----------------
DebouncedSearchInput renders a controlled shadcn Input with the lucide
Search icon on the left and a clear (X) button on the right that
appears once the user has typed something. Rounded-square corners
(rounded-lg, matching the global SearchPage). Stateless — the caller
pairs it with useDebounce and feeds the debounced value into a query
hook so the same component works for URL-synced searches, in-memory
searches, or anywhere else.
DiscoverySearchToolbar wraps DebouncedSearchInput with a compact filter
button on the right that opens a popover containing:
- Top / New sort pills wired to NIP-50 'sort:top' (Top) and no sort
token (New).
- An optional Show-hidden switch — only rendered when the caller
passes a showHidden prop, so surfaces without a hidden axis
(Pledges) just hide the row.
The toolbar matches the visual idiom of the global SearchPage filter
button (40-ish-px rounded-lg square, primary-tinted border when any
modifier is active). Inline against the page background — no card
framing — so the hero anchors visual hierarchy. Fully controlled:
parent owns search / sort / show-hidden state, the toolbar is purely
presentational.
useNip50Search<T> issues a kind-scoped NIP-50 search against
nostr.group(DITTO_RELAYS). Routing through the Ditto group rather
than the default pool keeps results predictable: most non-NIP-50
relays either ignore the 'search' field (returning everything matching
the other filters) or return nothing — both modes drown the result
set in noise. Hook is generic over an event parser so each surface
parses its own kind (parseCampaign / parseCommunityEvent / parseAction).
Sort modes
----------
- new (default): no sort token in the search payload; relay returns
chronological results.
- top: appends ' sort:top' to the search payload; relay scores by
engagement. Also works with an empty keyword (sends just
'sort:top') so 'Top' alone is a valid 'most-engaged' feed.
Empty keyword + New sort is the hook's only 'inactive' state, in
which case the page falls through to its existing curated layout
(featured rows, my-groups shelf, active-pledges sections, etc.).
Per-page wiring
---------------
Campaigns home (/campaigns) — toolbar with show-hidden; results
filtered against useCampaignModeration.hiddenCoords. Empty + New
falls through to the existing Featured / Community / moderator
sections.
Communities (/communities) — toolbar with show-hidden; results
filtered against useOrganizationModeration.hiddenCoords. The hook
is lifted to the page level so search results can drop hidden
groups (or include them via the switch). The ModeratorReviewSections
subtree still calls its own copy — query results are cached so the
second call is free.
Pledges (/pledges) — toolbar without show-hidden. Pledges lack a
moderator-driven hidden axis today, so the switch wouldn't have
anything to toggle. Adding it is tracked as a follow-up.
All Campaigns (/campaigns/all) — gets a new HeroBanner +
HeroAtmosphere hero matching the Pledges / Communities shape
(kicker, two-line heading, body, glass stat pill, glass CTA, warm
hope palette), so the route looks like a real discovery surface
instead of a bare grid under a plain H1. The page also migrates
from its bespoke inline toolbar to the shared one, with a small
adapter mapping the toolbar's 'top'/'new' vocabulary to the legacy
'top'/'none' URL param so existing share links keep working.
i18n
----
New common.* keys (sortAriaLabel, sortTop, sortNew, showHidden,
filtersAriaLabel) plus campaigns.all.* hero keys (heroKicker,
heroHeading, heroHeadingLine2, heroBody, campaignsCount_one,
campaignsCount_other). Translated across all sixteen locales.
The Communities page rendered its content column at max-w-5xl while
the Campaigns and Pledges bodies use max-w-7xl, making Communities
look squeezed by comparison on wide displays. Bump Communities to
max-w-7xl so the three discovery pages share a content width.
Pin / Unpin previously rendered as a full-width header bar above every
comment, adding vertical noise to every row even when no comment was
pinned. Replace the bar with a single absolute-positioned slot in the
note's top-right corner driven by three states:
- Not pinned, can manage → 'Pin' button. Hidden until hover on
hover-capable pointers; always visible on coarse pointers (touch)
so mobile moderators can find it.
- Pinned, can manage → 'Unpin' button, always visible.
- Pinned, cannot manage → 'Pinned' badge, always visible.
- Not pinned, cannot manage → nothing rendered.
ThreadedReplyList wraps each note in 'relative group/note' so the
corner overlay positions against the note rather than the surrounding
thread, and the hover variant matches the right note. Visibility on
non-hover devices uses the [@media(hover:hover)] arbitrary variant so
the affordance doesn't depend on a hover event that touch can't fire.
The campaign detail page presented comments inside a muted, primary-
tinted card with rounded corners and retinted per-note dividers, while
the community and pledge detail pages rendered the composer and
threaded reply list bare on the page background. The result was that
the same conceptual area read as three different surfaces.
Extract the campaign-detail treatment into CommentsSection: a thin
wrapper that owns the heading + optional count chip and the muted
panel. Each detail page passes in its own composer, reply list,
skeletons, and empty state, so per-page behavior (pin headers,
campaigner badges, pledge-specific placeholder) stays where it
belongs.
Apply CommentsSection to Campaigns, Communities, and Pledges. Also
swap the campaign-detail hero's bespoke avatar/Link block for
AuthorByline variant='hero', removing the now-dead Avatar /
useProfileUrl / useAuthor / genUserName imports and the
creatorName / creatorPicture / creatorProfileUrl plumbing through
CampaignHeroProps.
Groups, Pledges, and Campaigns each shipped a different author
treatment in their card footers: groups used a raw <img> with no
fallback, campaigns had no avatar at all, and pledges localized the
'by X' string while the others left it as a hardcoded English literal.
Extract AuthorByline as the canonical author element. It uses the
shadcn Avatar primitive (initials fallback), resolves the display name
through the centralized getDisplayName helper, and links to the
author's profile via useProfileUrl. The 'by Name' label is sourced
from the shared common.byAuthor i18n key so every surface ships the
same translated string in every locale.
Inside a card, the byline renders as a <button> that navigates and
stops propagation so the outer card <Link> keeps wrapping the whole
card without nesting <a> inside <a>.
CampaignCard also picks up a localized donor count via the new
common.donors plural key, replacing the inline English 'donor' /
'donors' ternary.
Two changes to ActionsPage that landed together:
- Replace the bare Plus icon on the 'Create pledge' CTA (both the
hero button and the in-feed Section CTA) with PlusCircle so the
Pledges page matches the Communities and Campaigns CTAs.
- Replace the bespoke 'by {name}' footer on ActionCard with the new
shared AuthorByline component, dropping the now-dead useAuthor /
getDisplayName / Trans imports. The byline carries the same avatar
+ i18n'd 'by Name' label + profile-navigation behavior as every
other discovery-page card.
The Pledges and Communities heroes layer their stat pills on top of
black scrims and protest photography. The pills used bg-background/55
plus text-muted-foreground / text-primary, which in light mode rendered
as low-contrast gray text on a dark photo — the "4 pledges open right
now" counter was effectively unreadable.
Switch both pills to a translucent dark surface (bg-black/30) with
explicit white text, white/85 secondary text, theme-tinted icons
(amber-200 for Pledges, cyan-200 for Communities), and drop-shadows
matching the hero headline above. The pill now reads the same in light
and dark mode.
The kind 33863 Querying section previously told clients to aggregate
verified kind 8333 receipts for the campaign total. That undercounts
the campaign whenever a donor pays the BIP-21 QR with a native wallet
(no Nostr receipt published) — which the spec itself documents as a
supported donation path.
Document the actual implementation: the headline 'raised' amount is
cumulative chain_stats.funded_txo_sum on the on-chain `w` address,
fetched from an Esplora endpoint (default mempool.space). Kind 8333
receipts remain the source of the recent-donation list (donor pubkey,
comment, timestamp) but no longer drive the headline total.
Update the Wallet Modes UI matrix to match.
The full equirectangular viewBox (-90..+90 lat) leaves a tall empty
ocean band at the bottom of the hero on wide viewports. Antarctica's
coastline only reaches ~lat -75 around its perimeter, so the bottom
~5° of the viewBox is always empty. Trim to -85..+85 so 'slice' fills
the hero with land texture instead of empty space. No distortion —
preserveAspectRatio still scales uniformly.
Cards in a multi-column grid now have a deterministic, predictable
silhouette. The body region is a fixed stack — title (1 line, truncates)
+ summary (1 line, truncates with a non-breaking-space placeholder when
absent) + progress (invisible-bar placeholder absorbs no-goal and
silent-payment cases) + creator footer — so no card has dead space or
ragged bottoms regardless of which optional fields are populated.
Country, deadline, hidden badge, and moderation menu all moved onto the
banner image as glass chips overlaid on a bottom gradient (chips
appear only when there's data to show, so a chip-free banner stays
clean). The donor-count chip became an inline aside on the creator
line. The variable meta row is gone entirely from the body.
CampaignPrivateNotice was rebuilt to mirror CampaignProgress's
vertical footprint (invisible bar + one text row) so silent-payment
cards line up with public-progress cards beside them.
Progress bars on cards and the detail-page DonateColumn use
`bg-foreground/15` for the track instead of the primitive's default
`bg-secondary`, which in dark mode shares its color with the card
surface and made the empty portion of the bar invisible. The new
foreground-tinted track reads cleanly on the card in both themes.
- Wrap composer + comments / composer + feed in a rounded muted panel
with continuous border-primary rails.
- Composer and campaign sidebar use a near-white surface with a
brand-orange border; dark mode swaps to a near-black warm surface.
- Per-article borders and backgrounds retinted via scoped selectors
so NoteCard stays untouched globally.
- Add hideCommentContext, authorBadge to NoteCard and
renderAuthorBadge, leafCardClassName to ThreadedReplyList; move the
Campaigner badge into the author row.
- Light-mode tokens: --card to 0 0% 98%, --muted to 30 12% 94% so
cards and muted regions read as real surface steps.
- Drop the unused campaignsDetail.commentCount locale key everywhere.
The wallet derives a 24-word BIP-39 seed phrase importable into any
standard Bitcoin wallet (Sparrow, Electrum, BlueWallet, Phoenix, Trezor,
Ledger) at BIP-86 + BIP-352 paths, but the FAQ never said so. Adds a
new payments-category entry covering the export flow, the nsec-only
restriction (extension/bunker logins can't derive the seed), and the
fact that the imported wallet is the same wallet — not a transfer.
Rewrites connect-wallet to lead with self-custodial and link to the
new entry. Rewrites the activist guide's movePromptly steps so the
sweep step describes importing the seed into a desktop/hardware wallet
(rather than implying the Agora wallet isn't one you control), and the
dontSit step reframes from 'campaign address as mailbox' to 'browser
session as warm storage; seed offline = cold storage'.
Updates all 15 non-English locales in lockstep.
Updates connect-wallet to mention the wallet is self-custodial and points
at the new export-wallet FAQ. Adds export-wallet covering BIP-39 seed
backup, import into Sparrow/Electrum/BlueWallet/Phoenix/Trezor/Ledger
via BIP-86 + BIP-352 paths, and the nsec-only restriction. Rewrites the
movePromptly steps in the activist guide to describe seed export and
offline backup.
The Swahili locale (9a3f1cac) was generated against an en.json snapshot
that still contained six receiveDialog keys (onChain, silentPayment,
onChainIntro, silentIntro, noIndexer, silentBalance). Upstream
a83df0f5 had already removed those keys when it combined SP and
on-chain into a single BIP-21 QR. The rebase silently carried the
extra keys through, which the locales test caught.
Regression-of: 9a3f1cac
The selected-language indicator collapsed every BCP-47 tag down to its
base subtag before comparing against the supported list, so picking
'zh-Hant' set i18n.language to 'zh-Hant' (and the UI strings switched
to Traditional Chinese as expected) but the checkmark moved to the
'zh' (Simplified) row because the comparison only saw 'zh' on both
sides.
Match in three passes instead:
1. Exact case-insensitive match against SUPPORTED_LANGUAGES — keeps
'zh-Hant' checked when active.
2. Alias map for zh-TW / zh-HK (registered as resource aliases in
i18n.ts) so device locales from Taiwan and Hong Kong land on the
zh-Hant row in the switcher.
3. Base-subtag fallback for en-US -> en, pt-BR -> pt, etc., preserved
from the original behavior.
The scan now always runs to the indexer tip. Users can hit Cancel to
stop mid-scan, so a configurable upper bound just complicates the UI
for a case nobody hits in practice.
Standard / Kiswahili sanifu (East African Standard) — the variety
used in BBC Swahili / VOA Swahili / DW Kiswahili news coverage,
intelligible across Tanzania, Kenya, Uganda, Rwanda, and eastern DRC.
Register: formal-conversational, not bureaucratic, not academic.
Reaches the WLC governance roster's East African leadership (Rwanda,
DRC, Burundi, and the broader ~230M-speaker activist audience).
Key choices:
- pochi for crypto wallet (modern Swahili tech vocab)
- mfadhili / wafadhili for donor(s), mwanaharakati / wanaharakati for
activist(s), mchango / michango for donation(s), kampeni for campaign
- relei (transliteration) for Nostr relay
- malipo ya kimya for silent payments, iliyogawanywa for decentralized,
inastahimili udhibiti for censorship-resistant
- Polite 2nd person wewe; m-/wa- and n-/n- noun classes used
consistently for actor/wallet concord
- heroTagline avoids the orphan-period bug: trailing period sits
inside <0>...</0> as in the English source
Sub-agent flagged several judgment calls (above-ground activism,
bleeding-edge, push notifications, honeypot metaphor) where the
English idiom doesn't carry across cleanly — these are v1 choices
that East African native speakers may want to refine. Treat this
locale as 'shippable v1, expect refinement from user feedback', same
quality bar as the other African / South-Asian locales we've shipped.
Key parity verified: 1677 leaves, same as en.json, zero missing or
extra, all {{placeholders}} and <N>markup</N> tokens preserved.
DialogContent is a CSS grid container, and grid items default to
`min-width: auto` — i.e. the intrinsic content width. Long
unbreakable strings (e.g. a combined BIP-21 `bitcoin:<bc1>?sp=<sp1>`
URI in the wallet receive dialog) refuse to shrink, blow past
`max-w-lg` / `w-[calc(100%-2rem)]`, and the `flex-1 min-w-0 truncate`
pattern on the inner span never engages because the parent already
accepted the full intrinsic width.
Adding `[&>*]:min-w-0` to the grid container fixes truncation for
every dialog without touching individual call sites.
Mirrors CampaignWalletDonatePanel's payload format
(`bitcoin:<bc1>?sp=<sp1>`) so BIP-352-aware wallets pick the
`sp=` parameter and legacy wallets fall back to the on-chain
address. The dialog now shows one QR and one copyable row with the
full URI, the tabs and per-tab intro copy are gone, and the
"Scan for payments" action lives in the wallet's overflow menu
instead of inside the receive dialog.
Strip the redundant in-dialog header, the BIP-86/Taproot explainer, and
both the Reveal and Copy buttons from the seed-phrase backup UI. The
seed-phrase box itself is now the reveal affordance — tap to expose the
words, tap again to hide them. Copy-to-clipboard is gone entirely (users
write the words down rather than paste them).
- WalletBackupMnemonic: drop the inline "Wallet seed phrase" h2 + KeyRound
icon (the DialogTitle already renders the same heading), drop the
Nostr-key-derivation explainer paragraph, drop the two-button row at the
bottom, drop the copied-state and useToast wiring. Wrap the seed-phrase
box in a <button> with aria-pressed and aria-label so screen readers know
it's a toggle.
- en.json:
- Rewrite walletBackup.dialogDescription to the new short copy: "You can
access your Agora wallet in any Bitcoin wallet by importing this 24-word
backup."
- Rewrite walletBackup.hidden to "Tap to display your 24-word seed
phrase." (dropping the "Tap Reveal" wording, since there is no Reveal
button anymore).
- Add walletBackup.revealAria / hideAria for the toggle button's
aria-label.
- Remove walletBackup.explainer, .reveal, .hide, .copy, .copied,
.copyFailedTitle, .copyFailedDescription.
- Mirror the same restructuring across all 14 other locales. Four locales
(hi, id, tr, zh-Hant) didn't previously have a walletBackup block at all
and were falling back to English — they now ship full translations.
The Back up wallet menu item on /wallet now opens the existing
WalletBackupMnemonicDialog inline instead of navigating to a dedicated
page. The dialog already existed in src/components/WalletBackupMnemonic.tsx
but wasn't wired up anywhere — this finally uses it.
- WalletPage: import WalletBackupMnemonicDialog, hold an open/closed state,
flip the menu item from <Link to="/wallet/backup"> to a regular
DropdownMenuItem that calls setBackupOpen(true) onSelect. Render the
dialog alongside the existing Send / Receive / SP-scan dialogs. The
dialog's internal gating still hides for extension/bunker signers, but
the menu only renders in the 'available' branch of WalletPage so that
fallback isn't user-visible in practice.
- AppRouter: drop the lazy import and route for WalletBackupPage, replace
/wallet/backup with a redirect to /wallet so old bookmarks land somewhere
sensible. Point /wallet/settings/backup at /wallet for the same reason.
- Delete WalletBackupPage.tsx \u2014 no longer reachable.
- Locales: remove the now-unused walletBackupPage block (six keys:
seoTitle, seoDescription, title, subtitle, loggedOut, unsupported) from
en + 14 other locales. The dialog reuses walletBackup.* and
walletSettings.backup.label, which are unchanged.
The cog in the top-right of /wallet now opens a 3-dots (MoreVertical)
dropdown with two items — Back up wallet and Legacy wallet recovery —
each linking straight to the existing /wallet/backup and /wallet/legacy
pages. /wallet/settings (the intermediate Apple-style settings hub) is
gone; the route now redirects to /wallet for any old bookmarks.
- WalletPage: swap the Settings <Link> for a DropdownMenu with two
Link-backed items. Reuse the existing walletSettings.{backup,legacy}.label
strings as menu labels.
- AppRouter: drop the lazy import + route for BitcoinWalletSettingsPage,
replace /wallet/settings with a redirect to /wallet, keep the existing
/wallet/settings/{backup,legacy} redirects.
- WalletBackupPage / LegacyWalletRecoveryPage: backTo now points at
/wallet directly instead of the removed hub.
- Delete BitcoinWalletSettingsPage.tsx — no longer reachable.
- Locales: rename wallet.openSettings -> wallet.openMenu and prune the
walletSettings hub strings (seoTitle, seoDescription, title, subtitle,
per-row descriptions) across en + 14 other locales, keeping only the
two label values that the menu items now read.
The Suspense fallback for code-split routes was a max-w-6xl skeleton
shaped like a generic landing-style page (header bar + two text rows +
a 288px hero block). It ended up wrong-shaped for most routes — narrow
settings pages, the max-w-sm wallet screen, etc. — and on a fast chunk
load it would flash a wide block of placeholder geometry that bore no
resemblance to the page that actually rendered a moment later.
Replace it with a neutral centered Loader2 spinner. A spinner reads as
"loading" without committing to any particular page shape, so it works
equally well as the fallback for every lazy route in the app.
Also revert the balance skeleton on /wallet — that one was tuned to the
final balance shape (h-10 w-40 + h-4 w-24) and reads fine; it was the
route-level fallback that needed to change.
Regression-of: 9c16b300
The Wallet Settings hub stretched its header across the full layout width
while the menu list was capped at max-w-md, so the title floated centered
above a much narrower card. Constrain the page (header included) to the
same max-w-md container so the back arrow, title, and list line up. Apply
the same fix to /wallet/backup and /wallet/legacy.
Also tighten /wallet itself: the settings cog used to sit in a full-width
row while the balance + send/receive controls were max-w-sm, leaving the
cog floating off to the right. Pull the cog into the same max-w-sm
container so it sits flush with the rest of the UI.
The balance-loading state used two stacked skeletons (h-10 w-40, h-4 w-24)
that didn't match the final shape of the rendered balance — replace them
with a centered RefreshCw spinner. Drop the now-unused Skeleton import.
Flatten the sub-routes from /wallet/settings/backup -> /wallet/backup and
/wallet/settings/legacy -> /wallet/legacy. The deeper paths were redundant
since these are leaf pages reached only via the settings hub. Add Navigate
redirects from the old paths so any existing links / muscle memory still
resolve.
The wallet home (/wallet) now ships an Apple-style cog in the top-right
that leads to a new /wallet/settings hub with two rows:
- Back up wallet -> /wallet/settings/backup
- Legacy wallet recovery -> /wallet/settings/legacy
The inline 'Back up wallet' text link is removed from /wallet — the
seed-phrase reveal lives behind the cog now. The 'Move funds to your new
wallet' migration banner is removed too, along with the
useHdWalletV1Migration call that powered it. The same detection still
runs inside the legacy hub, but only when the user actually opens that
page; visiting /wallet no longer issues any Blockbook xpub scan or
NIP-78 query for legacy funds.
The Legacy Wallet Recovery hub surfaces the two previous Agora wallet
generations as separate options:
- V2 Prelaunch Beta Wallet — sweep via the existing /wallet/migrate-v1
flow (BIP-86 nsec-as-seed, plus its BIP-352 silent-payment UTXOs).
- V1 Breeze Wallet — sweep via /wallet/recovery, the previously
orphaned route for the Pathos-era Lightning custody. Reachable from
the UI for the first time.
Both options are documented as one-way sweeps into the user's current
wallet; the legacy wallet itself is not restored.
i18n: drop the now-unused wallet.backupAction and wallet.migration.*
keys. New namespaces walletSettings, walletBackupPage, and walletLegacy
added to en.json and the 14 other locales (ar, es, fa, fr, hi, id, km,
ps, pt, ru, sn, tr, zh, zh-Hant).
Derived from the existing Simplified Chinese (zh) locale via OpenCC-style
s2twp conversion (Simplified -> Traditional with Taiwan phrase
preferences), then hand-corrected for the cases where automated
conversion picks the wrong vocabulary swap:
- 支援 only for technical 'X supports Y feature' contexts; 支持 retained
for 'support a cause/person/activist' which is the more common sense
for an activism platform.
- 連結 (link/connect) rather than 連線 (online) in the hero tagline
'connecting activists to unstoppable funds'.
- 代碼 rather than 程式碼 for silent-payment / donation 'code'
identifiers (the source is a payment-address token, not source code).
- 實例 rather than 例項 for 'instance' (self-hosted weserv).
- 影片 rather than 視頻 for 'video' (Taiwan vocab).
- Fixed an automated-conversion artifact 聚整合員 -> 聚集成員.
The Traditional resource is also registered under zh-TW and zh-HK so
that browsers reporting those device locales route directly to the
Traditional file instead of falling back to Simplified zh.
Mainland zh-CN continues to resolve to zh (Simplified) via i18next's
nonExplicitSupportedLngs. The language switcher dropdown shows both
'简体中文' and '繁體中文' as distinct choices.
Key parity verified: 1677 leaves, same as en.json and zh.json, zero
missing or extra keys, all {{placeholders}} and <N>markup</N> tokens
preserved verbatim from the source.
Bebas Neue (the .font-display family) ships only Latin glyphs, so
Chinese hero headlines fall back to system fonts and lose their
industrial display character. Add @fontsource/noto-sans-tc weight 900
and a :lang() rule that swaps it in for any .font-display element
while the page language is a Chinese variant (zh, zh-Hant, zh-TW,
zh-HK).
The fontsource CSS uses unicode-range descriptors, so non-Chinese
users do not download the Han glyph slices (effectively zero cost
for Latin-only locales).
The rule reverses Tailwind's italic, uppercase, tracking, and the
hero's 0.022em -webkit-text-stroke fatten trick — none of those are
meaningful for CJK text and the stroke trick muddies strokes at
weight 900.
The HD wallet seed is now BIP-39-compatible. Pipeline:
entropy = HKDF-SHA256(nsec, info="agora/v1", length=32)
mnemonic = BIP-39 encoding of (entropy || checksum) // 24 words
seed = PBKDF2-HMAC-SHA512(mnemonic, salt="mnemonic", iters=2048)
The 24 words import cleanly into Sparrow, Electrum, Trezor, Ledger,
BlueWallet, Phoenix, etc., at the BIP-86 / BIP-352 paths. HKDF domain
separation means a leaked mnemonic compromises only the wallet, not
the Nostr identity (unlike the raw nsec).
v1 derivation (nsec used directly as BIP-32 master seed) is retained
as migration-only code. A new /wallet/migrate-v1 page detects funds
at the legacy addresses and builds a single sweep PSBT to consolidate
them into the v2 wallet. A persistent banner on /wallet surfaces the
flow when v1 funds exist.
The mnemonic shows up in two places: a "Back up wallet" dialog on
/wallet, and a section in Profile -> Advanced next to the nsec
backup. nsec backup copy updated to explain the relationship.
Locked test vectors pin the entire derivation pipeline (nsec -> 24
words -> first BIP-86 address -> sp1q...) so any future drift fails
loudly. Regenerate via scripts/derive_vectors.mjs.
Other changes:
- Re-key SP storage NIP-78 d-tag to /v2 so v1 and v2 UTXOs do not mix
- Re-key the persisted receive-address cursor to :v2: namespace
- Relax SP spend-key helper to 16-64 byte seeds (BIP-32 range) so the
migration sweep can sign with the legacy 32-byte v1 seed too
- Remove stale NIP-SP references from derivation comments (the draft
was not relevant to our use case)
- Document the wallet derivation scheme in NIP.md
- Translate every new string to all 10 non-English locales
Adds a complete Türkçe translation. Turkish is spoken by ~90M people
and has an unusually strong mission fit for Agora: Turkey has
documented Bitcoin demand from currency-crisis users, journalist
crackdowns, and an active diaspora that often funds back home.
Register is modern İstanbul Turkish (BBC Türkçe / WhatsApp Türkçe
style) — polite-but-direct, formal siz throughout. English loanwords
kept where they're standard in everyday Turkish (Bitcoin, Lightning,
Nostr, röle, zap); Turkish equivalents used where they read naturally
(cüzdan, gönderi, anlık bildirim).
{{appName}} suffixing uses the standard Turkish apostrophe convention
for proper nouns ({{appName}}'da, {{appName}}'nın, etc.). Suffixes
are pinned to back-vowel harmony because "Agora" ends in 'a' — this
will break if AppConfig.appName is ever changed to a front-vowel word
like "Eylem" or "Yardım", but that's a deliberate trade-off for
not having to fork the JSON across deployments.
heroTagline mirrors the English design rhythm — line break before the
highlight, with the inline-block orange box on its own line as the
visual centerpiece:
"Aktivistleri<1></1><0>durdurulamaz</0> finansmana bağlıyoruz."
The trailing period is glued to a multi-word phrase rather than
sitting alone after the inline-block, avoiding the wrap bug we fixed
for Portuguese.
Turkish casing note: the hero h1 has text-transform: uppercase. Turkish
has dotted/dotless i (İ/i, I/ı). CSS uppercase honors the lang="tr"
attribute that applyDocumentDirection sets on <html>, so dotted
lowercase "i" uppercases to "İ" correctly without manual handling.
i18n auto-detection picks up the new locale on devices set to Turkish
(navigator.language returns tr/tr-TR).
Validation: en/tr leaf counts both 1677, no extra/missing keys,
locales.test.ts passes (120 tests, +2 for the new locale).
Adds a complete Bahasa Indonesia translation. Indonesian is the
eleventh-most-spoken language worldwide (~200M speakers) and brings
Agora to Indonesia's growing Bitcoin-aware activist community plus the
broader maritime Southeast Asia.
Register is standard Bahasa Indonesia (formal-but-conversational,
the style used by Kompas, Tempo, Tirto). Avoids Malaysian-Malay-only
vocabulary so Malaysian users (handled via nonExplicitSupportedLngs
folding ms-* → id is NOT happening here; ms users will fall back to
English until we add ms separately, which is acceptable given the
~85% mutual intelligibility). Uses Anda for the second person, the
established UI convention. Standard Indonesian tech vocabulary where
present (dompet, kampanye, donasi, donatur, ikrar, penggalangan dana,
pengaturan); English loanwords kept where they're normal in modern
Indonesian software (Bitcoin, Lightning, Nostr, feed, post, relay,
zap, NIP, BIP).
heroTagline uses the period-inside-highlight pattern:
"Menghubungkan aktivis dengan<1></1>pendanaan <0>tak terbendung.</0>"
The trailing period lives inside the orange highlight span, so it
can't orphan to its own line at the hero's text-8xl size — the same
fix we applied for Portuguese.
i18n auto-detection picks up the new locale on devices set to
Indonesian (navigator.language returns id/id-ID).
Validation: en/id leaf counts both 1677, no extra/missing keys,
locales.test.ts passes (118 tests, +2 for the new locale).
Adds a complete Hindi translation alongside the existing 11 locales.
Hindi (हिन्दी) is the fourth-most-spoken language in the world (~610M
speakers) and brings Agora to a large activist population currently
served only by the English fallback.
Translation register is Hindustani / news-style (BBC Hindi-flavored),
not Sanskrit-leaning शुद्ध हिन्दी — chosen for accessibility to the
broadest Hindi-speaking audience, including users who'd otherwise
struggle with formal government Hindi. Uses आप throughout for the
second person. Standard tech loanwords kept transliterated where they
read naturally (वॉलेट, पोस्ट, कैंपेन, फ़ीड); brand names and
protocol tokens (Bitcoin, Nostr, BIP-352, nsec, sp1…) kept in Latin
script.
heroTagline uses the safe structure — short highlight on the adjective
"अजेय" with a trailing phrase ending in danda (।), avoiding the
inline-block-followed-by-lone-punctuation wrap bug that previously hit
the Portuguese locale.
i18n auto-detection picks up the new locale on devices set to Hindi
(navigator.language returns hi/hi-IN), with nonExplicitSupportedLngs
folding regional variants into the hi bucket. The runtime fallback to
English still works for any keys we might miss in future edits.
Validation: en/hi leaf counts both 1677, no extra/missing keys,
locales.test.ts passes (116 tests, +2 for the new locale).
The Portuguese heroTagline read
"Conectando ativistas a<1></1><0>financiamento incontrolável</0>."
where the period sits outside the <0>...</0> orange highlighter span.
That span is rendered as inline-block w-fit (CampaignsPage.tsx:203-207),
and the boundary between an inline-block and the following bare text
node is a soft-wrap opportunity. When the highlighted phrase
"financiamento incontrolável" filled the line — which it does at the
hero's text-7xl/8xl sizes inside max-w-2xl — the trailing period
wrapped to its own line.
Restructure the markup so only the adjective is highlighted and the
period lives inside the orange box, mirroring the design intent of the
English original (where the highlight is on the adjective and the noun
trails it):
"Conectando ativistas a<1></1>financiamento <0>incontrolável.</0>"
"financiamento " is now plain text before the box, and the period is
part of the box's content, so there is no wrap opportunity between the
last visible character and the period.
Other locales (fr, ru) have the same structural shape but short enough
highlighted words that the issue doesn't manifest in practice; leaving
them alone.
Adds an Internationalization section to AGENTS.md spelling out that
edits to user-facing strings must propagate across every locale in the
same change, not just en.json. Lists the ten translated locales (ar,
es, fa, fr, km, ps, pt, ru, sn, zh), gives rules for edits / new keys /
removals, notes that locales.test.ts only catches the structural
direction (extra keys in a locale) — missing keys silently fall back
to English — and recommends parallelizing the per-language work with
subagents for one-string-across-ten-locales edits.
The donation address derivation FAQ said "derived from your Nostr public
key," but the HD wallet (src/lib/hdwallet/derivation.ts) actually uses
the user's nsec (secret key) as the BIP-32 master seed for both the
BIP-86 Taproot wallet and the BIP-352 silent-payments wallet. Updated
the wording across why-not-rotating-addresses, what-is-nostr, the donor
guide's arrivesDirectly step, and the activist guide's howReceiving
intro.
The 'Does Agora support silent payments?' answer only mentioned the
activist receive side. Agora's own wallet supports sending silent
payments too (HDSendBitcoinDialog accepts sp1… addresses via the
BIP-352 sender in src/lib/hdwallet/sp/sender.ts), so the answer now
leads with that on the send side and follows with the existing
activist-receive paragraphs.
Propagated to all 10 non-English locales (ar, es, fa, fr, km, ps, pt,
ru, sn, zh).
Catches the ru/fr/pt locales up with the keys added upstream while the
initial translations were in flight: translate, forms additions,
organizationContext, groups.detail, calendarEvents, follow,
campaignsDetail.openInWallet, the assorted countryPlaceholder /
showLess / readMore entries, and a handful of scattered new keys.
Adds src/content/privacy/{ru,fr,pt}.md and src/content/csae/{ru,fr,pt}.md,
and registers them in usePolicyMarkdown.ts so the policy pages serve the
matching translation when the user's language is set to one of the three
new locales (falling back to en otherwise).
The 16x16 favicon frame and favicon-16.png were saved as 8bpp indexed
images with binary alpha. Antialiased edges of the orange logo got
quantized to opaque palette entries, baking in a dark halo that browser
tab strips revealed as an ugly outline around the glyph.
Re-render from public/logo.svg, preserving its native 720:880 aspect on
a transparent square canvas (the previous fix squished the logo into a
square), downsampling with Lanczos to each target size, forcing PNG
color-type 6 (RGBA) and a multi-frame ICO where every size is 32bpp.
Add scripts/generate-favicons.sh so the next logo change can't silently
reintroduce palette quantization or aspect-ratio stretching.
Bootstraps src/locales/{ru,fr,pt}.json from en.json as a starting
point and wires all three into i18n.ts. Strings are still English
pending the per-section translation passes that follow.
Fills the last two gaps in CSAE policy coverage. Khmer and Shona
markdown files land beside the existing six (en, es, zh, ar, fa, ps),
and both entries are registered in the usePolicyMarkdown loader.
Every long-form policy / guide surface is now translated in all eight
shipped locales.
Splits the guide content same way the FAQ was split:
* Structure (block order, kinds, audience, callout variant, option
grid chips/hrefs) stays in helpContent.ts as a typed array of
GuideBlockStructure descriptors with stable IDs.
* Every user-visible string moves to en.json under guides.donor.*,
guides.activist.*, and guides.shared.* (badge labels, tldr eyebrow,
payment comparison table headers + rows).
PaymentComparisonTable, InlinePaymentBadge, and GuideTLDR call
useTranslation() so a language switch triggers re-render. DonorGuidePage
and ActivistGuidePage already do, so getDonorGuideBlocks /
getActivistGuideBlocks re-run on every render and pick up fresh i18n
values automatically.
This commit lands the en strings only; non-en locales follow in the
next commit.
Ships the `faq.*` namespace in every non-English locale. Each locale
gets all 4 category labels and all 23 items (15 visible across About
Agora / Bitcoin Donations / About Nostr + 8 hidden legacy items used by
`HelpTip` call sites in settings pages).
Missing-key fallback to English still works via i18next's default
fallback chain — if a future FAQ item lands in en.json before the other
locales catch up, those locales will fall back to English at that key
without breaking the renderer.
Splits the FAQ definition in `helpContent.ts` into two layers:
* a structural template (category order, item IDs, hidden flag) that
stays in TS, and
* a flat `faq.*` namespace in `locales/*.json` that holds every
user-visible string (category labels, questions, answer paragraph
arrays).
`getFAQCategories` / `getFAQItems` / `getFAQItem` keep the same
`(appName)` signature and `FAQCategory` / `FAQItem` shape — internally
they resolve strings through `i18n.t()`, with `{appName}` literals
rewritten to `{{appName}}` for i18next interpolation. Answer arrays use
`returnObjects: true` so a missing locale entry falls back to English
without breaking the renderer.
`HelpFAQSection` reads `i18n.language` to re-resolve on language switch;
`HelpTip` calls `useTranslation()` for the same reason. The Donor /
Activist guide blocks below are still keyed off the original
single-brace `{appName}` literal — that's a separate i18n pass.
Aggressive cleanup of 359 exports across 153 files identified as
having zero importers outside their declaring module:
- 105 symbols deleted entirely (no internal uses either)
- 254 symbols un-exported (still referenced file-locally; dropped the
`export` keyword to shrink the public surface)
- ~70 cascade cleanups of locals that became dead once their sole
consumer was removed
Notable shrinkage:
- src/hooks/useShakespeare.ts: 626 \u2192 22 lines (unwired AI chat surface;
only the ChatMessage type is consumed)
- src/hooks/useTrending.ts: only useEventStats survives; trending feed
hooks were never wired up
- src/hooks/useTrustedCountryStats.ts: dead type re-exports removed
- src/lib/bitcoin.ts: PSBT helpers \u2014 unused wallet feature scaffolding
- src/lib/communityUtils.ts: unused NIP-72 moderation helpers
- src/lib/extraKinds.ts, src/lib/colorUtils.ts: unused helpers
- src/lib/logger.ts: bare debug/info/warn/error exports dropped;
consumers use the `logger` object
- src/lib/aiChatSystemPrompt.ts: trimmed to the
DEFAULT_SYSTEM_PROMPT_TEMPLATE constant
- src/components/music/MusicTrackRow.tsx: dead row component removed;
only the skeleton is consumed
src/hooks/useNostr.ts (intentional decoy) and src/i18n.ts
(side-effect import) were preserved per their respective contracts.
Extracts the ~285-line JSX prose body into per-language markdown files
served through the existing `usePolicyMarkdown` hook + `PolicyMarkdown`
component shipped with the privacy page.
Ships en/es/zh/ar/fa/ps. Khmer and Shona are left unregistered for now
and fall back to English at runtime via the loader's en fallback path —
the policy text is dense and translators will want a careful pass.
No new infrastructure; reuses the loader + renderer end-to-end.
Extracts the ~110-line JSX prose body into per-language markdown files
and renders it through a new `usePolicyMarkdown` hook + `PolicyMarkdown`
component. Eight locales ship beside `en.md`; missing locales fall back
to English at runtime.
The loader uses dynamic `import()` of `*.md?raw` (Vite + Bun friendly,
no `import.meta.glob`) keyed on a static `{ slug: { lng: loader } }`
table so bundlers can code-split per locale and we keep one shared chunk
for the markdown ecosystem. `{{appName}}` placeholders are interpolated
at render time with backslash-escape of markdown specials, and the
rendered output goes through rehype-sanitize.
This is the reusable loader infrastructure for the long-form policy
pages — CSAEPolicyPage is the next consumer.
Four files with zero importers across the repo:
- src/components/TeamSoapboxCard.tsx
- src/components/discovery/ProfileCard.tsx (superseded by
src/components/ProfileCard.tsx)
- src/components/letter/StickerPicker.tsx (superseded by
src/components/StickerPicker.tsx)
- src/hooks/useDominantColor.ts
src/hooks/useNostr.ts is also orphan but intentional \u2014 it exists as
a re-export decoy and is kept.
Drop 13 packages that have zero imports across src/, configs, and
native projects:
- @radix-ui/react-{menubar,navigation-menu,aspect-ratio,context-menu}
(no shadcn primitive consumes them)
- react-leaflet, leaflet, @types/leaflet (no map usage)
- smol-toml, fflate, html-to-image, input-otp
- react-resizable-panels, react-day-picker
Also drops the orphaned .leaflet-* CSS overrides in src/index.css.
The 11 cover images in /public/challenge-covers/ were superseded by
Blossom-hosted URLs in DEFAULT_ACTION_COVERS. No code referenced the
local files; the comment claiming otherwise was stale.
Reclaims ~4.4 MB from the bundle.
Adds the about.* namespace covering the entire /about landing page:
- hero — eyebrow ('About {appName}'), the 3-part headline with the
highlighter span (split into headlinePart1 / appName / headlinePart2
so each language can put the verb where it naturally belongs), body
paragraph, the three trust chips (Decentralized, Open source,
Censorship resistant), the two CTA buttons, and every string in the
tilted sample-campaign card (Venezuelan vigil alt text, org name,
campaign title, two-line description, 'raised / of $10,000',
'N donors · N countries' line, Donate Bitcoin button).
- howItWorks — section header + lede, plus three step cards (image alt
text, title, body) covering signup / send / spend.
- twoWays — section header, the two RailCards (kicker, tagline, title,
description, three bullets, tradeoff title + intro + bullets each),
the trade-off intro for Public Payments goes through <Trans /> with
inline links to the Donor and Activist guides, and the No-custody
banner including title, body, and the three GoFundMe / GiveSendGo /
'other Bitcoin' comparison items.
- faq — section eyebrow + title, and the three FAQ chapter labels +
descriptions (Getting started / Bitcoin donations / About Nostr).
FAQ_CHAPTERS stays a module-level constant; its label/description
fields just became labelKey/descriptionKey suffixes that AboutPage
hands to t() at render time.
- guides — section header + lede, both Donor and Activist guide cards
(image alt, role chip, title, description, three bullets each, cta),
and the closing 'Still stuck? Follow Team Soapbox' line.
The FAQ accordion bodies themselves come from helpContent.ts via
HelpFAQSection — that file (701 lines of prose) is the Stage E2
candidate and stays English here.
Added rtl:rotate-180 to the two hero CTA arrow icons and the guide-
card arrow icons so they flip in RTL.
ar / es / zh / fa / ps / km / sn translations drafted inline; km, sn,
ps, fa still pending native-speaker review.
Adds the policyPages.* namespace with five sub-namespaces, one per
page, all just translating the PageHeader title, SEO title/description,
and any chrome strings the page wraps around its (untranslated) body:
- privacy.* — PrivacyPolicyPage header. Body prose stays English for
E2 markdown extraction.
- csae.* — CSAEPolicyPage header. Same — body is policy prose.
- donorGuide.* — DonorGuidePage title + subtitle for the GuideHero.
Prose blocks come from helpContent.ts (still English; that file is
the E2 candidate).
- activistGuide.* — ActivistGuidePage hero same shape as donor.
- changelog.* — ChangelogPage chrome: title, error/empty states, Past
releases divider, Show less / Read more, pre-release banner ('Pre-
release build', body line, View unreleased changes link), and a
category-tooltip map (Added / Changed / Deprecated / Removed / Fixed
/ Security). The markdown body itself stays raw English — Read more
truncates whatever the parser produced.
ar / es / zh / fa / ps / km / sn translations drafted inline; km, sn,
ps, fa still pending native-speaker review.
Adds two large namespaces:
noteCard.* — the action labels surfaced under reaction / repost / poll
vote actor rows (reacted / reposted / voted), the Read more / Show less
truncation toggle, the live-stream status badges (LIVE / ENDED /
PLANNED / UNKNOWN), the Bot account avatar title, the donation prefix
on synthetic zap cards (Donated X to / Donated to), and the entire
KIND_HEADER_MAP — every 'X did a Y' header for the 24 kinds Agora
renders an action header for (photos, encrypted messages, letters,
treasures + finds, decks, emoji packs, groups, campaigns, badges,
streams, Zapstore app/release/asset, generic apps, git repos / patches
/ PRs, NIPs, nsites, zaps, pledges, follow packs, follow sets).
The KIND_HEADER_MAP refactor pulls the same trick as profileSettings
presets: each entry stores i18n keys instead of English strings, and
EventActionHeader runs them through t() at render time. publishedAtKey
is just publishedAtAction returning keys instead of phrases. The map
itself stays a module-level constant.
noteMoreMenu.* — the entire 'more' overflow menu hanging off every
NoteCard: every menu item (View post details, View Event JSON,
Bookmark, Add to list, Add/Remove from sidebar, Pin/Unpin to profile,
Pin/Unpin to country feed, Mute Conversation / mute @user / Report /
Remove from group / Delete post), the Encrypted content fallback in
the post preview, every toast (success + failure for each mutation),
the Delete confirmation alert dialog, and the Event JSON dialog (title,
Event ID label, Raw JSON label, Broadcast Event button + Broadcasting
state, plus the {{label}} copied toast).
ar / es / zh / fa / ps / km / sn translations drafted inline; km, sn,
ps, fa still pending native-speaker review.
Adds the compose.* namespace covering ComposeBox: placeholders
(default, poll, country-scoped, content warning), preview / edit toggle,
poll mode (Add option, Option N, Single/Multiple choice, Back to post,
Publish poll, success/failure toasts), voice recording (Cancel, Send,
Sending, mic-denied toast, voice-message tooltip), toolbar
(Attach file, Emoji/GIF, More, Poll, Spoiler, Emoji, Stickers tabs),
submit (Posting / Posted toasts, upload-failed / publish-failed),
and destination dropdown (Post to label, Global / community
explainer popover, Choose another country picker, search placeholder,
empty state, Set as default, default-updated toasts).
Also adds replyModal.* for ReplyComposeModal — the six title fallbacks
(New poll, New comment, Comment on profile, Reply to post, Quote post,
New post), three placeholder fallbacks, and the Bluesky disclaimer.
ComposeBox's hardcoded default placeholder ('What's on your mind?') and
submitLabel ('Post!') props moved from prop defaults to t() fallbacks
inside the component so existing call sites can keep passing translated
strings unchanged. Renamed an inner .map((t) => …) variable in the poll
type/duration row to .map((pt) => …) since t shadowed the i18next t.
Added rtl:rotate-180 to the 'Back to post' chevron since it visually
points left/back.
ar / es / zh / fa / ps / km / sn translations drafted inline; km, sn,
ps, fa still pending native-speaker review.
Two issues left over from the previous restore:
- `ps-1` adds 0.25rem of padding-inline-start. In LTR that pushes
the first letter rightward off the box's left edge, creating a
visible gap. Drop to `ps-0` so the letter sits flush with the
start edge.
- Bebas Neue's italic skew shifts the visible left edge of "U"
rightward of its geometric box, leaving an apparent gap even with
zero padding. Apply `text-indent: -0.06em` to pull the letter back
into the box. The shift is small enough that other scripts
(Arabic, Khmer, Chinese) tolerate it.
Extends the feed.* namespace with five new sub-namespaces:
compose.placeholder, tabs (Follows / Following / Global), empty (the
six per-tab + per-mode empty-state messages plus the three CTA labels
that show under them), modeSwitcher (the home feed's mode-picker dropdown
with Agora / All Nostr / Following options, the dropdown trigger's aria
label, and the disabled-Following tooltip), replyContext (Replying to,
the and-joiner, plus a pluralized andOthers_one/andOthers_other), and
actions (Reply / Repost / Undo repost / React / Zap / Share / More + the
Link-copied toast).
PostActionBar's replyLabel prop default went from 'Reply' to undefined;
the component falls back to t('feed.actions.reply') at render time so
existing call sites that pass an already-translated string keep working
unchanged. FeedModeSwitcher's OPTIONS array swapped its hard-coded
label string for an i18nKey suffix, materialized through t() inside the
component.
ar / es / zh / fa / ps / km / sn translations drafted inline; km, sn,
ps, fa still pending native-speaker review.
Commit ab8457a8 migrated the home-page hero tagline to <Trans /> and
passed the wrapper elements via the OBJECT form of the `components`
prop:
<Trans components={{ 0: <span className="bg-primary ..." /> }} />
In our react-i18next version (17.0.4), the object form silently drops
the indexed tags — the translated text renders, but stripped of the
wrapping element, so the orange highlighter span never makes it into
the DOM.
Switching to the ARRAY form makes i18next pick up the indexed tags:
<Trans components={[ <span className="bg-primary ..." />, <br /> ]} />
While here:
- Add an index-1 <br /> so English keeps its original two-line layout
(Connecting activists to / unstoppable funding.). Translations that
prefer inline flow simply omit <1></1> from their string.
- Switch the highlighter's padding from `pl-1 pr-3` to logical
`ps-1 pe-3` so the asymmetric flourish extending past the word's
trailing edge flips correctly for RTL languages (ar, fa, ps).
Regression-of: ab8457a8
Adds the profileSettings.* namespace covering ProfileSettings:
PageHeader (title, subtitle, Save button), intro section, profile
fields (Website, Lightning, label/value/ticker/address placeholders),
the seven field-preset pills (Music, Photo, Video, Email, Wallet, Link,
Weather, Custom) with labels, descriptions, and value placeholders,
media mismatch warnings (6 keys covering audio / image / video × wrong
type / unknown extension), the upload tooltip, crop dialog titles,
mobile fields preview button, Advanced section + Bot Account row, save
toasts, and the BackupKeySection (Your Key heading, extension / bunker
body, secret-key explainer + warning, copy / reveal / hide aria labels,
Back Up Key button, all backup-related toasts).
Module-level structures pulled the same way as elsewhere this session:
FIELD_PRESETS / CUSTOM_PRESET became key-only skeletons
(FIELD_PRESET_SKELETONS / CUSTOM_PRESET_SKELETON), and a useFieldPresets
hook materializes them inside the component over t(). The locale-
independent fields (icon, type, accept, formatHint, defaultLabel
emojis) stay at module scope. getMediaMismatchWarning was renamed to
getMediaMismatchWarningKey and now returns a translation key suffix
instead of an English string — the SortableFieldRow translates the key
at render time.
ar / es / zh / fa / ps / km / sn translations drafted inline; km, sn,
ps, fa still pending native-speaker review.
Adds the profile.* namespace covering ProfilePage (the more menu, follow
toast, image lightbox, follow/follower modals, Bitcoin QR modal, inline
profile fields, NIP-05 not-found states) and every sub-component the page
mounts: ProfileIdentityRail (action bar, stats, campaigns/pledges/groups
sections, RailPledgeCard, RailOrgCell), ProfileActivityTab,
ProfileCampaignsTab, ProfilePledgesTab, and OrganizationsAllDialog.
The desktop and mobile tab arrays (DESKTOP_TAB_LABELS / MOBILE_TAB_LABELS)
moved from English label strings to label keys so the tab maps are
translated through t('profile.tabs.*') at render time. CORE_TAB_IDS now
keys off the same identifier slugs to keep the click-through routing
intact.
Inner sub-components (RailCampaignsSection, RailPledgeCard, RailOrgCell,
StatList, ActionBar, ProfilePledgeCard, ProfileFieldInline,
BitcoinQRModal, FollowingListModal, FollowersListModal,
ProfileImageLightbox, ProfileMoreMenu) each pick up their own
useTranslation() per the established pattern — no t prop drilling.
ar / es / zh / fa / ps / km / sn translations drafted inline; km, sn, ps,
fa still pending native-speaker review.
Adds a top-level search.* namespace covering the global search page:
- SEO + PageHeader title, three tab labels (Agora / Nostr / Accounts).
- The whole filters popover: title + Reset button, 'From' author-scope
segmented control (Anyone / Follows / People), 'Sort' segmented
control (Recent / Hot / Trending), the four selects (Media,
Protocol, Language, Kind), the custom-kind input placeholder, and
the 'Include replies' switch label.
- The active-filter chip strip (built in a useMemo) — 'No replies',
'Images', 'Videos', 'Shorts & Divines', 'No media', 'Mastodon',
'Bluesky', 'Hot', 'Trending', 'All kinds', 'Kind: {{kind}}',
'Kind {{kind}}', kindsCount_one / _other, 'My follows', and
authorsCount_one / _other.
- The Clear shortcut button, the 'search:' debug-string label, and
the 'New posts' pill with newPosts_one / _other plural.
- Five empty-state messages (Posts results / Posts prompt / Accounts
results / Follows prompt / Agora results / Agora prompt), plus the
EmptyState 'Active filters:' header and 'Clear all filters' button.
- The 'Following' badge title and 🤖 'Bot account' tooltip on
AccountItem + FollowItem (each needs its own useTranslation()).
- The SearchInput placeholder.
All 7 non-English locales drafted inline. Native-speaker review
still pending for km, sn, ps, fa.
Adds a top-level notifSettings.* namespace covering the
/settings/notifications page: SEO meta, page header (title +
subtitle), Push Notifications section (heading, enable-push row,
unsupported/denied banner copy), Android-only Delivery Method
section (Push / Persistent radio with descriptions), 'Notify Me
About' section with the Filter / Types sub-headings, 'Only from
people I follow' row, and the eight notification type rows
(reactions, reposts, zaps, mentions, comments, badges, letters,
highlights) — each with its own label + description under
notifSettings.types.{key}.
NOTIFICATION_TYPES rows now carry labelKey / descriptionKey
strings instead of literal English, looked up via t() inside the
component render. The enable-failure toast (title + description)
also runs through t().
All 7 non-English locales drafted inline. Native-speaker review
still pending for km, sn, ps, fa.
Adds a top-level notifications.* namespace covering the entire
notifications feed. Highlights:
- The 48-entry NOTIFICATION_KIND_NOUNS map (kind → bare noun used
in 'reacted to your <noun>' / 'reposted your <noun>' etc.) was
moved into notifications.kinds.* in the locale files and looked
up via a new useNotificationKindNoun() hook. The component-side
table now only maps kind → i18n key suffix so it stays small and
diffable when new kinds are added.
- Action-verb strings for every notification type are interpolated:
reactedToYour / repostedYour use {{noun}}; zappedYou has a
zappedYouWithAmount variant taking {{sats}}; commentedOnYour
takes {{noun}}; highlightedYour takes {{noun}}; mentionedYou,
repliedToYourNote, repliedToYourComment, sentYouLetter,
awardedBadge, and awardedBadges are flat.
- The condensed group subject uses <Trans i18nKey='subject.twoActors'
components={{0: ActorLink, 1: ActorLink}}> so component order can
flip in RTL languages; the 'and N others' branch uses
andOthers_one / _other and {{count}}.
- '+N more' actor-overflow counter uses interpolation.
- Letter notification's 'View all letters' / 'Reply' buttons,
SEO meta, tab labels (All / Mentions), the empty state and
logged-out CTA all routed through t().
All 7 non-English locales drafted inline. Native-speaker review
still pending for km, sn, ps, fa.
Adds a top-level spScan.* namespace covering the BIP-352 silent-
payment scan dialog: header (title + description), the from/to block
inputs (label + 'tip' placeholder), the indexer-tip / last-scanned
footer line (with 'never' fallback), the include-already-spent
toggle + its long explanation, the in-progress block-counter and
matches_one/_other plural, the post-scan summary
(scannedRange + foundOutputs_one/_other / noNewPayments), and the
'Reconcile spent UTXOs' subsection (title, long description,
checking interpolation, checked_one/_other plural with prune count,
Reconciling… / Reconcile now button). Action buttons reuse
common.cancel + common.close.
All 7 non-English locales drafted inline. Native-speaker review
still pending for km, sn, ps, fa.
Two top-level namespaces:
walletSend.* — the Send Bitcoin dialog at /wallet: dialog title,
amount approx-sats interpolation, recipient label/placeholder, the
three recipient descriptions (silent payment / Nostr / raw address),
fee speed labels (10 min / 30 min / 1 hour / 1 day) and the
{{rate}} sat/vB display, network fee row, available-balance footer,
the four progress strings (building / signing / broadcasting /
sending), the 10-error error catalogue thrown into setError() +
the mutation onError toast title, two-tap arming ('Tap again to
confirm'), and the SuccessScreen (title, sats fallback, view
transaction, done).
bitcoinPublic.* — the BitcoinPublicDisclaimer shared component
used by both the wallet's Send dialog and the campaign DonateDialog.
Lead sentence, 'Learn more' link, the two long body variants
(with/without cash-out advice), and the 'I understand this
transaction is public.' acknowledgement label. The leadText prop
override is preserved; only the default is now translated.
The FEE_SPEED_LABELS module constant was replaced by an in-component
useMemo so the labels stay reactive to language switches.
All 7 non-English locales drafted inline. Native-speaker review
still pending for km, sn, ps, fa.
Adds a top-level walletConnect.* namespace covering the NWC settings
component used by the Advanced settings page: status section (WebLN
+ NWC cards with Ready / Not Found / None badges), the Add button,
empty state, per-connection rows (active badge, defaultWalletName
fallback, set-active and remove icon-button titles), the help text
shown when no signing method is available, and the Connect-NWC
dialog (title, description, alias placeholder, Connect /
Connecting... button).
Toast titles+descriptions for URI-required validation and active-
wallet-changed feedback also go through t(). The connectedCount_one
/ _other plural lets each locale handle pluralisation natively (zh
collapses to one form, ar has separate one/other, etc.).
All 7 non-English locales drafted inline. Native-speaker review
still pending for km, sn, ps, fa.
Adds a top-level walletRecovery.* namespace covering the legacy
Breez/Spark Lightning sweep flow: SEO meta, page chrome, logged-out
empty state, destination address card, recovery-phrase input
(including the NIP-78 backup detection alert, the amber 'only paste
phrases you trust' warning, and the sweep button), the in-progress
loader with all five progress strings (loading SDK, connecting,
checking balance, preparing transfer with sats interpolation,
broadcasting), success card (sats interpolation + view-transaction),
error card, toast on successful relay-backup decrypt, and the six
walletRecovery.errors.* strings thrown into setError().
Back-to-wallet arrow flipped with rtl:rotate-180. All 7 non-English
locales drafted inline. Native-speaker review still pending for km,
sn, ps, fa.
Adds a top-level wallet.* namespace covering the Bitcoin wallet
page: SEO meta, logged-out / unsupported-signer empty states, balance
display (refresh + pending), Send / Receive buttons, the Receive
dialog (on-chain + silent-payment tabs incl. wsrv-style <0>...</0>
Trans interpolations for the bip352IndexerUrl mono span and silent
balance highlight), and the transactions list (relative date helper
now takes t + i18n.language).
formatTxDate was lifted from a closure constant to a parameterised
helper so 'Today' / 'Yesterday' / '{count}d ago' / 'Pending' route
through t() and toLocaleDateString uses the active i18n language
(with an en-US fallback if the locale is unknown to Intl).
All 7 non-English locales drafted inline. Native-speaker review still
pending for km, sn, ps, fa.
Conflicts in NetworkSettingsPage.tsx and SettingsPage.tsx, both
introduced by upstream's Low-Bandwidth Mode + image proxy feature
landing on top of my i18n refactor of the same settings pages.
Resolution:
- Translated the new Low-Bandwidth Mode and Image Proxy block under
settings.network.{lowBandwidthHeading,reduceDataUsage,
reduceDataUsageDesc,useImageProxy,useImageProxyDesc,proxyUrl,
proxyApiDesc,reset}, using <Trans> for the wsrv.nl link in
proxyApiDesc (<0>...</0> component slot).
- Updated settings.network.subtitle and settings.sections.networkDesc
in en.json to match the new upstream English ("Manage data usage,
relays, and file upload servers." / "Data usage, relays, and file
upload servers.").
- Mirrored the new keys + updated values into all 7 non-English
locales. Native-speaker review still pending for km, sn, ps, fa.
Extract user-facing strings on the campaign detail page into the
campaignsDetail.* namespace. Covers the hero (back/edit/delete chips,
author attribution, deadline pill, comment action label), the
engagement counter row above the comments (repost/quote/like counts
with pluralized labels and a bold count wrapper), the comments +
donations section header and empty state, the delete-confirm
AlertDialog, the donate sidebar (raised/of-goal labels, donation
count, recent-donations list, share button, ended state), the story
component, and pin/unpin and deletion toasts.
Chevron + arrow icons flip with rtl:rotate-180. Uses i18next plural
suffixes for the four count-driven labels (reposts/quotes/likes,
comments, donations, days-left).
This completes Priority 1 of the i18n rollout (Pledges +
Communities/Groups + Campaigns verticals — list pages, create forms,
detail pages).
Native-speaker review still pending for: km, sn, ps, fa.
Extract user-facing strings on the create-campaign form (and its edit
variant) into the campaignsCreate.* namespace. Covers the four gate
states (login, invalid edit link, loading, not-author), the wallet
picker (source dropdown, accept-types dropdown, custom address +
silent-payment inputs with inline validation), the field labels and
placeholders, the country selector, success/error toasts, and the
mutation's twenty user-facing error messages.
Back-arrow flips with rtl:rotate-180.
Native-speaker review still pending for: km, sn, ps, fa.
Extract user-facing strings on the create-group form (and its edit-mode
variant) into the groups.create.* namespace. Covers all four
non-success states (login gate, invalid edit link, loading-group
spinner, not-the-founder gate), the field labels and placeholders, the
URL preview footer with mono-spaced slug, moderator chip rows and
remove-button aria, the cover-image and country selectors, the submit
button's create/edit/uploading variants, and the mutation's eight
user-facing error messages.
Back-arrow flips with rtl:rotate-180.
Native-speaker review still pending for: km, sn, ps, fa.
Previously the tap-to-load placeholder only kicked in when the proxy was
also disabled. The proxy still saves bandwidth, but users who flipped on
low-bandwidth mode generally want the explicit consent step on every
image — proxied or not. Make the two settings independent:
- ImageGallery / ProxiedImage: gate on lowBandwidthMode alone.
- LinkPreview: suppress thumbnail whenever lowBandwidthMode is on.
- Settings copy + AppConfig JSDoc updated to match.
Extract user-facing strings on the groups index into the groups.list.*
namespace: hero copy, the moderator review rails (Needs review / Hidden),
the My-groups and Featured-groups shelves, the empty states for both
logged-out viewers and logged-in users with no groups, the ticker stat
labels, and the show-more/show-less collapsible toggle.
Uses i18next plural suffixes for the three ticker stats (campaigns
raised, featured groups, countries posting today).
Native-speaker review still pending for: km, sn, ps, fa.
Translate the pledge detail page chrome into the pledges.detail.*
namespace, including the hero (back button, deadline pill, author
attribution, share/submit actions), the funding sidebar (funded/of
amount/trust note/share button), the submissions section
(skeleton/empty state/composer placeholder), pin/unpin toasts, and
the loading variant rendered by NIP19Page while the addressable
coordinate decodes.
Uses i18next plural suffixes for submission counts and days-left
labels. Chevron + arrow icons flip with rtl:rotate-180.
Native-speaker review still pending for: km, sn, ps, fa.
Extract user-facing strings on the create-pledge form into the
pledges.create.* namespace and add per-form-section labels via a new
forms.* namespace shared with FormSection (Required/Recommended/
Optional badge).
Translates the login gate, field labels, placeholders, the
mutation's user-facing error messages, success / failure toasts, the
country search box, and the country-hint footnote (with a <0> wrapper
around the iso3166 monospace span). The back-arrow flips with
rtl:rotate-180 for Arabic/Farsi/Pashto.
Native-speaker review still pending for: km, sn, ps, fa.
Two new AppConfig fields cover the data-saving story:
- `imageProxy` (default `https://wsrv.nl`) — wsrv.nl/weserv-compatible
image-resizing proxy. Empty string disables it. `proxyImageUrl(src, width)`
and `useImageProxy()` rewrite URLs to WebP at quality 75; the `default=`
param redirects to the origin if the proxy can't fetch upstream. The
proxy base URL is parsed through `URL` and rejected unless it's
`https:`, so user input from the settings field can't smuggle non-https
schemes into <img src>.
- `lowBandwidthMode` (default `false`) — forces autoplay off everywhere
(VideoPlayer, LiveStreamPlayer), skips `useVideoThumbnail`'s background
frame-grab, and (when the proxy is also disabled) gates feed images and
link-preview thumbnails behind a tap-to-load placeholder.
The two settings are independent. A privacy-conscious user can run with
the proxy off without being forced into tap-to-load, and a metered-data
user can keep the proxy on without ever seeing a placeholder. Tap-to-load
only kicks in when both "I'm low-bandwidth" and "the proxy is off"
are true (or the proxy errors in a gated context).
Three new shared pieces:
- `src/lib/proxyImageUrl.ts` — pure URL rewriter
- `src/hooks/useImageProxy.ts` — memoized `(src, width) => string`
- `src/components/MediaPlaceholder.tsx` — tap-to-load pill with
optional blurhash background; rendered as `<div role="button">`
so it nests cleanly inside InlineImage's outer lightbox button
- `src/components/ProxiedImage.tsx` — `<img>` with proxy + onError
fallback + optional placeholder gating
Wired call sites with per-context widths:
Inline post images (InlineImage, w=600, gated)
Image gallery tiles (GridImage, w=600, gated)
Lightbox (LightboxImage, w=1200)
Profile banner (ProfileBannerImage, w=1200)
Profile hover banner (ProfileHoverCard, w=400)
Link preview thumb (LinkPreview, w=400, suppressed when
low-bandwidth + no proxy)
Sidebar media tile (ProfileRightSidebar, w=300)
Avatars (via shadcn) (AvatarImage, w=96 default,
w=128 hover card,
w=256 profile header)
Custom emojis (CustomEmojiImg, w=48)
Badge thumbnails (BadgeThumbnail, w=max(size*2, 128))
Badge hero + glare mask (BadgeContent, w=256;
BadgeDetailContent, w=320;
EmbeddedNaddr, w=192)
Music / podcast art (AudioKindContent, w=600;
MusicTrackRow, w=96;
MusicDetailContent, w=320 hero / 96 row;
PodcastDetailContent, w=320)
Settings UI lives in NetworkSettingsPage with the Low-Bandwidth Mode
toggle at the top (above relay / Blossom plumbing) and the Image Proxy
section between Blossom Servers and Image Uploads. The low-bandwidth
description text adapts to whether the proxy is configured, so the
proxy↔tap-to-load coupling is visible at the toggle.
Video Tier 1:
- VideoPlayer overrides `autoPlay` to false in low-bandwidth
- LiveStreamPlayer no longer hardcodes `autoPlay`, reads config
- useVideoThumbnail skips its background frame-grab entirely
`lowBandwidthMode` is added to EncryptedSettingsSchema so it crosses
devices alongside `autoplayVideos`. `imageProxy` is local-only (privacy
/ network choice, not a UX preference).
Extract all user-facing strings on the Pledges index page into the
pledges.* namespace and supply translations for the 7 non-English
locales. Includes the hero copy, the card chrome (Pledged label,
ended badge, share menu, attribution), the sort/filter controls, the
section dividers, the empty state, and the SEO title/description.
Uses i18next plural suffixes (_one/_other) for the open-count pill and
the show-more button uses {{count}} interpolation.
Native-speaker review still pending for: km, sn, ps, fa.
Add campaigns.home.* namespace covering the hero (tagline, body,
three CTAs), the Featured and Community Campaigns section headings and
descriptions, the Browse-all link, the moderator-only Pending and
Hidden section labels (title/description/emptyText), the non-mod
"Your campaigns" section, and the empty-state card.
The hero tagline uses <Trans i18nKey="campaigns.home.heroTagline"
components={{0: <span/>}}> so each locale controls where the orange
highlighter falls. The English-only inner-span optical alignment
(negative margin to counter Bebas Neue italic skew on a leading 'u')
is dropped — it was overfit to one word and would have produced ugly
results in any non-English layout.
The ArrowRight icon in the "How it works" CTA picks up rtl:rotate-180
so it points the right way in RTL languages.
Add feed.indexTagline, notFound.*, and campaigns.all.* namespaces. Wire
useTranslation into:
- Index.tsx: SEO description tagline ("Your content. Your vibe. Your
rules.")
- NotFound.tsx: 404 heading + "Go home" button + SEO meta
- AllCampaignsPage.tsx: page title, SEO meta, search aria/placeholder,
clear-search aria, sort radio labels (Top/New) + sort group aria,
show-hidden switch, start-campaign CTA, all three empty states (no
match, all hidden, empty) + their hints
CampaignsPage landing page still needs translation; deferred to next
commit because it has hero sections and featured-slot copy that's worth
a dedicated pass.
Add settings.appearance.*, settings.network.*, settings.advanced.*, and
settings.wallet.* namespaces. Wire useTranslation into the four small
settings sub-pages so the page header, intro text, theme picker labels,
relay/Blossom section headings, image upload quality toggle, and
collapsible Wallet trigger all switch language live.
Leaves the Notification, Profile, and Organizers settings sub-pages
plus the embedded WalletSettings / RelayListManager / BlossomSettings /
AdvancedSettings component bodies for a follow-up commit.
Wire useTranslation into TopNav (nav items, mobile drawer, profile menu,
search button aria-label, brand home aria-label, open/close menu labels,
mobile footer links) and SiteFooter (tagline + footer nav links).
Add nav.* namespace to en.json + all 7 locale files. The mobile drawer
and desktop nav now switch language live the moment a user picks a new
language in /settings/language.
Phase 1 multi-language foundation:
- Register 8 locales in i18next (en, es, ar, fa, ps, km, sn, zh) with
static-bundled JSON. Wire languageChanged listener to set
document.documentElement.lang and dir for RTL support.
- Expose SUPPORTED_LANGUAGES (code + nativeName) so the switcher and
validator pick up new entries automatically.
- Restructure en.json: drop ~280 dead wallet-port keys, introduce
settings.sections.*, language.*, and a tightened common.* namespace.
Keep organizers.* and the keys actually consumed by t() calls today.
- Translate common, settings, language, and organizers namespaces in
all 7 non-English locales (machine drafts; km/sn/ps/fa flagged for
native-speaker QA before announcement).
- Add LanguageSettingsPage at /settings/language with a radio-style
picker rendering each language's native name in its own script/dir.
- Add Language entry to settingsSections, between Appearance and Network.
- Translate SettingsPage strings (page title, section labels, delete
button) using the new keys. Flip the chevron with rtl:rotate-180.
- Add src/test/locales.test.ts validator that fails CI on any locale
introducing keys absent from en.json (typo / stale-translation guard).
No visible UX change in English. Switching language now translates the
Settings hub, the Language sub-page, and the existing Organizers admin
end to end.
The shared HorizontalScroll component was removed from main during
orphaned component cleanup. Instead of restoring the shared file,
inline the small helper directly in MyDashboardPage since it is the
only consumer.
Agora's colors and fonts are now hardcoded in the bundle. There is no
runtime CSS-variable injection, no remote-loaded theme, no font-family
override from event data, no background image, no recolored favicon.
Switching themes only toggles the .dark class on <html>.
Removed:
- src/themes.ts: ThemeConfig, ThemesConfig, CoreThemeColors, ThemeFont,
ThemeBackground, themePresets (22 presets), buildThemeCssFromCore,
deriveTokensFromCore, the 'custom' Theme variant. Now exports only
resolveTheme(theme) -> 'light' | 'dark'.
- src/lib/fontLoader.ts: deleted. Mounted arbitrary @font-face rules with
event-sourced URLs and font-family overrides — the primary CSS-injection
vector. sanitizeCssString moved to src/lib/cssSanitize.ts (still used by
the Letter feature).
- AppProvider hooks useApplyFonts / useApplyBackground / useApplyFavicon.
- useTheme.applyCustomTheme — the entrypoint that wrote external palettes
to global theme state.
- ColorMomentEyeButton + its callers in NoteCard and PostDetailPage. Color
Moments (kind 3367) still render as palette art, but the 'Set as theme'
button is gone.
- LetterAttachment in LetterDetailSheet — the gift-box UI that applied an
attached color moment as the recipient's theme.
- paletteToTheme() from colorMomentUtils — only getColors() remains.
- customTheme / themes fields from AppConfig, AppConfigSchema,
EncryptedSettings, EncryptedSettingsSchema. Existing customTheme values
in localStorage and encrypted NIP-78 settings are now ignored.
- 14 unused @fontsource packages. Only the 10 letter fonts and the 2 base
UI fonts (Inter Variable, Bebas Neue) remain. noto-sans-nushu is kept
for the encrypted-letter obfuscation indicator.
Added:
- Static :root {} and .dark {} blocks in src/index.css with the 19 shadcn
tokens hardcoded. These were previously injected at runtime by
AppProvider; without them the app would lose all colors when the runtime
injector was removed.
- src/lib/cssSanitize.ts holding the sanitizeCssString helper for the
Letter feature's font-family interpolation.
Simplified:
- public/theme.js: no longer reads customTheme or themes from localStorage,
just resolves system/light/dark with hardcoded built-ins. Must stay in
sync with src/index.css colors.
- src/lib/fonts.ts: trimmed to only the 10 letter fonts. findBundledFont
and resolveCssFamily removed (they were only used by fontLoader).
- Rename MySquarePage -> MyDashboardPage (file, component, default export)
- Change route from /my-square to /my-dashboard
- Update user-facing copy: SEO title, logged-out heading, JSDoc
- Add 'My Dashboard' link to AccountSwitcher dropdown
- Add 'My Dashboard' entry to TopNav mobile/profile menu
- Use LayoutDashboard icon to distinguish from the existing Dashboard
- Existing /dashboard route and EventDashboardPage are untouched
The About page (and `HelpFAQSection`) shipped extensive `dark:` overrides
in 76597ae7 and b05ded03, but they never fired in the in-app theme
toggle because tailwind.config.ts left `darkMode` unset. Tailwind
defaults to `media`, so `dark:bg-...` and `dark:text-...` utilities
only respect the OS `prefers-color-scheme` — not the `.dark` class
that `useTheme` writes to <html> when the user picks a dark theme
inside Agora.
Setting `darkMode: 'class'` wires every `dark:` utility in the
codebase (About page, HelpFAQSection, alert variants, chart, et al.)
to the in-app theme toggle. The About page now actually goes dark in
dark mode.
Regression-of: 76597ae7
Replaces the previous near-uniform dark surfaces (#0e1218 / #11151c)
with genuinely dark, alternating tones plus a subtle world-map
texture on two sections, so the page reads as an editorial document
in dark mode the same way it does in light mode.
Section background mapping (dark mode)
Section Was Now
----------------------- ---------- ------------
Hero #0a0c14 #0a0c14 (unchanged)
Three steps #11151c #0a0c14 + texture
Two ways to get paid #0e1218 #13181f + texture
Frequently asked #11151c #0a0c14
Pick the side #0e1218 #13181f
The two alternating tones (#0a0c14 / #13181f) preserve the
cream/white rhythm from light mode. Sections 2 and 3 gain a
dark-only world-map background image at 5-6% opacity (the same
texture used in the hero) so they don't read as flat slabs. The
texture is gated behind 'hidden dark:block' so it has zero impact
on the light-mode rendering.
Card surface lifted
bg-[#1a1f29] → bg-[#1c2230] across StepCard, RailCard, GuideCard,
the FAQ accordion card-row (reference mode), the FAQ card variant,
and the FAQ tab pill. Hover-state for the tab pill follows from
#222937 to #252b3a. Net effect: cards now sit ~12 lightness above
the deepest section background and ~6 above the lifted one, keeping
clear elevation against both tones.
Every section of the About page now renders correctly in dark mode.
The hero stays dark in both modes (its identity is anchored on the
dark navy backdrop), and the four light sections (Three steps, Two
ways to get paid, Frequently asked, Pick the side) each gain a
dark-mode counterpart so the page reads consistently inside the
app's theme.
Section background mapping
Section Light bg Dark bg
----------------------- ----------- -----------
Hero #0a0c14 #0a0c14 (unchanged)
Three steps #faf8f4 #11151c
Two ways to get paid white #0e1218
Frequently asked #f5f1eb #11151c
Pick the side white #0e1218
Card surfaces (StepCard, RailCard, GuideCard, FAQ accordion item)
map bg-white -> dark:bg-[#1a1f29] and border-gray-200 ->
dark:border-white/10. Hover shadows pick up a darker variant
(dark:hover:shadow-[0_8px_24px_rgba(0,0,0,0.4)]) on cards that have
a hover-lift effect.
Text-color mapping (applied throughout)
text-gray-900 -> dark:text-white (headings)
text-gray-800 -> dark:text-gray-100 (inline strongs)
text-gray-700 -> dark:text-gray-300 (bullet body)
text-gray-600 -> dark:text-gray-300 (body prose)
text-amber-600 / 700 -> dark:text-amber-400 (trade-off label
+ icon)
border-gray-100 -> dark:border-white/10 (separators)
No-Custody comparison banner
The light gradient card (white -> primary/5) converts to a glass
tile in dark mode (white/0.04 -> primary/0.08) with primary/30
border. The inner Comparison grid's separator border picks up
dark:border-white/10. ComparisonItem's light theme path now also
sets dark:text-gray-300 on its body copy so it reads on the dark
glass tile.
HelpFAQSection
Both the 'reference' card-row accordion (used by the About page)
and the 'cards' masonry variant gain dark-mode card surfaces and
text-color mapping. The category tab pills get a dark-mode
inactive state. The active pill (bg-primary) is already correct in
both modes.
These three embed components rendered third-party content in
iframes (Twitter's platform.twitter.com widget, Spotify's
embed-iframe, Reddit's embed.reddit.com widget). Removed wholesale:
- SpotifyEmbed, TweetEmbed, RedditEmbed components.
- extractTweetId, extractSpotifyEmbed, extractRedditPost, and the
SpotifyEmbedInfo type from lib/linkEmbed.ts.
- The Twitter/Spotify/Reddit branches in LinkEmbed and
isEmbeddableUrl/embedLabel.
Tweet, Spotify, and Reddit URLs now fall through to the generic
LinkPreview card. useLinkPreview keeps its native Spotify/Reddit
oEmbed shortcuts so those previews still render with rich title +
thumbnail metadata instead of going through the link-preview proxy.
The iframe.diy-based sandbox infrastructure powered two features:
the nsite preview dialog (Run button on NsiteCard and AppHandlerContent)
and the webxdc embed (cartridge launcher + sandboxed iframe runtime
with kind 4932/20932 sync events).
Both are removed wholesale:
- iframe sandbox plumbing: SandboxFrame, src/lib/sandbox/*,
iframeSubdomain, previewInjectedScript.
- nsite preview: NsitePreviewDialog, NsitePermissionManager,
NsitePermissionPrompt, useNsiteSignerRpc, nsitePermissions,
nsiteNostrProvider, NsitePlayerContext.
- webxdc runtime: WebxdcEmbed, Webxdc, GameControls, useWebxdc,
webxdcMeta, public/cartridge.png, NOSTR_WEBXDC.md,
@webxdc/types dependency, kindLabels/signerWithNudge entries
for kinds 4932/20932.
- AppConfig: drop sandboxDomain, showWebxdc, feedIncludeWebxdc.
- extraKinds: drop kind 1063 webxdc entry; sidebar drops 'webxdc'.
- ComposeBox: .xdc uploads now flow through the generic file path
(no UUID injection, no manifest extraction, .xdc removed from
the file picker accept list).
- NoteContent / FileMetadataContent: webxdc branches removed; .xdc
attachments fall through to the generic file card.
- LayoutContext / CenterColumnContext: only consumed by the removed
fullscreen preview panels — deleted along with its provider in
AppRouter.
NsiteCard keeps its rich link-preview card but loses the Run button
and preview dialog. AppHandlerContent keeps a kind 35128 `a`-tag
reference but replaces 'Run' with an external 'Open Site' link to
`<pubkeyB36><dTag>.nsite.lol`. The standard HTML iframe `sandbox`
attribute used by SpotifyEmbed/TweetEmbed/RedditEmbed is unrelated
to iframe.diy and stays.
Squashed re-application of 15 local commits onto the new route-level
layout system (refactor: replace useLayoutOptions store with route-level
layout choice). Drops the obsolete useLayoutOptions({ fullBleed: true })
calls; the About page and the two guide pages instead live under the
wide FundraiserLayout route group in AppRouter.
Routing
- /about, /about/donors, /about/activists are now the canonical paths,
in the wide layout (no max-width cap so sections can span the
viewport with their own backgrounds).
- /help, /help/donors, /help/activists become <Navigate> redirects so
existing bookmarks and links keep working.
About page (new src/pages/AboutPage.tsx)
A landing-style document modeled on https://soapbox.pub/agora,
brought in-app to explain how the platform works. Five sections:
1. Hero (dark navy + world-map texture + orange halos), Bebas Neue
italic headline with an inline orange highlighter behind the
brand name, three trust chips, and Donor / Activist Guide CTAs.
Tilted Venezuelan sample-campaign card on lg+, hidden on mobile.
On mobile the H1 fits on one line via text-4xl + a conditional
<br className="hidden sm:inline" />.
2. Three steps. No middleman. (cream) 3-up white cards with 4:3
step images and corner 01 / 02 / 03 numerals.
3. Two ways to get paid. (white) Bitcoin Public Payments vs.
Bitcoin Silent Payments compare cards with gradient header
strips. Public-Payments trade-off carries the above-ground-
activism warning; Silent-Payments trade-off is five bold-headline
bullets (few wallets, slow, no push notifications, buggy,
no public counts). Below: a primary-tinted No-Custody banner
plus a 3-column comparison grid (Unlike GoFundMe / Unlike
GiveSendGo / Unlike other 'Bitcoin' platforms).
4. Frequently asked. (cream) Three integrated FAQ chapters in
page flow (Getting started / Bitcoin donations / About Nostr),
each with a Bebas Neue numeral + Inter Bold heading + card-row
accordion items with a left orange accent on hover/open.
5. Pick the side you're on. (white) Two large image-led guide
cards (Donor / Activist) using the soapbox.pub photography.
Closes with a quiet 'Still stuck? Follow Team Soapbox' line
linking in-app to the pack via the /:nip19 route.
Typography is Bebas Neue (font-display) italic font-normal with
WebkitTextStroke for the hero H1 and the step numerals only; every
other heading uses Inter Bold (font-sans font-bold tracking-tight).
Bebas Neue is never font-bold (synthetic bold renders as smear at
display sizes).
Em dashes have been removed throughout the page and the
HelpFAQSection component. Box-drawing chars (U+2500) in section
banner comments are not em dashes and stay.
Section backgrounds alternate dark → cream → white → cream → white.
The dark and cream sections keep their literal palette in both light
and dark mode (an editorial choice that gives the page its
landing-page identity rather than being just another themed surface).
Donor + Activist Guides (new block-based design)
Both pages now compose from a typed sequence of GuideBlock variants
defined in helpContent.ts. Each block kind is rendered by a dedicated
component under src/components/guide/:
- GuideTLDR top-of-page summary card with lede + checklist
- GuideSteps numbered vertical flow of short steps
- PaymentComparisonTable Public vs. Silent side-by-side. Three-column
grid on desktop, two stacked tinted cards on
mobile. Row content driven by audience flag.
- CalloutCard tinted info / warning / danger / success blocks
- OptionGrid two-column tile grid for privacy and cash-out
options
- GuideProse plain prose escape hatch
- InlinePaymentBadge small pill that distinguishes the two payment
options
- index.ts barrel
Content is rewritten throughout to reflect current reality: campaigns
can accept Public only, Silent only, or both; the QR code carries
both endpoints when both are accepted; wallets without silent-
payments support fall back to a regular Bitcoin transaction; silent
payments are slow, scan-based, lack push notifications, are
bleeding-edge, and produce no public donation counts.
Activist Guide structure:
TLDR
How receiving works
What everyone can see (intentionally before the table)
Public vs. Silent comparison
A note on silent payments today (calm prose, not an alarm callout)
Move donations promptly
Cashing out privately (silent-payments hop, Lightning
swap, coinjoin, P2P with brokers,
spend it directly)
Avoid centralized tumblers
Donor Guide structure:
TLDR
How a donation flows
Public vs. Silent comparison
Public donations are visible on-chain forever callout
Donating privately option grid
Consumer apps can't make you anonymous callout
A note on silent payments today
Other touchpoints
- Sidebar (sidebarItems.tsx): Help label → About, icon LifeBuoy → Info,
path /help → /about.
- Top nav profile menu (TopNav.tsx): Help → About.
- Site footer (AppRouter.tsx inline): Help → About.
- AccountSwitcher dropdown: Help → About.
- LandingHero FAQ button → /about#faq.
- HelpTip popover footer link → /about#faq.
- GuideHero back link → /about, label 'Back to About', wider
max-w-5xl on lg+ container so it sits well on the now-full-bleed
hero. Inner overlay min-height bumped to 320px on lg+.
- CampaignsPage 'How it works' button → /about.
New assets in /public/about/ pulled from soapbox.pub:
- world-map-bg.png (hero + textures)
- venezuela-libertad-presos-politicos.png (hero sample-card image)
- donor-guide-freedom-libertad.jpeg
- activist-guide-unity.png
Step photos in /public/help/ (step-1-account.jpg, step-2-send.jpg,
step-3-spend.jpg) for the Three Steps section.
HelpFAQSection gains:
- variant: 'list' | 'cards' (default 'list')
- tabs: boolean (only meaningful with variant='cards')
- listTone: 'default' | 'reference' (quieter category labels and more
breathable accordion items for the About page; existing inline
callers keep the default pill style)
In 'reference' mode each accordion item gets a card-row treatment
(rounded white card, subtle border, hover lifts to primary/40 border,
left orange accent rule driven off data-state=open).
helpContent.ts FAQ content (FAQItem / FAQCategory and templates) is
left untouched. Only the donor/activist guide section was rewritten
into GuideBlock[] arrays.
The Ditto-era LayoutStore let pages push layout config (FAB, sidebars,
arc styling, max-width) up to MainLayout via an external store. After
the fundraiser refocus the only knob left is noMaxWidth, and the
`useLayoutEffect + deferred-rAF-reset` dance had a real race condition
where the previous page's options could bleed into the next page
during Suspense transitions — visible as max-w-3xl appearing on pages
that should be wide.
Switch to idiomatic React: two layout variants picked by the router.
- Narrow group (`max-w-3xl`): /feed, /notifications, /search, /profile,
/t/:tag, /g/:geohash, /settings/*, /wallet*, /bitcoin, /help/*,
/privacy, /safety, /changelog, /organizers, /remoteloginsuccess
- Wide group (no max-width): /, /campaigns/*, /groups/*, /pledges/*,
/dashboard, /i/*, /:nip19, catch-all NotFound
`FundraiserLayout` now takes a `narrow` prop and is mounted twice in
the route tree, once per group. Pages no longer reach upward — the
width is a property of the route they live in.
Delete useLayoutOptions and its 13 call sites, useLayoutSnapshot,
LayoutOptions, LayoutStore, and LayoutStoreContext. `LayoutContext.ts`
shrinks to just CenterColumnContext + useCenterColumn (still used by
WebxdcEmbed and NsitePreviewDialog for portal targets).
After removing the sidebar / mobile drawer chrome the registry's
remaining consumers are useFeedSettings, ProfileSearchDropdown, and
CONTENT_KIND_ICONS. Drop the helpers no one calls anymore:
- isSidebarDivider, nostrUriToNip19, nsiteUriToSubdomain
- getSidebarItem, sidebarItemIcon, itemLabel, itemPath, isItemActive
- The internal OPTIONAL_SIDEBAR_ITEMS / ALL_SIDEBAR_ITEMS /
SIDEBAR_ITEM_MAP machinery that only served those helpers
Fold the previously-optional 'dashboard' entry into the main
SIDEBAR_ITEMS list so SIDEBAR_ITEM_IDS still recognizes it.
The inlined FundraiserLayout only reads noMaxWidth and wrapperClassName
from the layout snapshot, so all other LayoutOptions fields (showFAB,
fabKind, fabHref, onFabClick, fabIcon, fabMenu, hasSubHeader,
rightSidebar, scrollContainer, noOverscroll, noArcs, hideTopBar,
hideBottomNav, fullBleed) are dead.
- Strip those fields from every useLayoutOptions call site (~25 pages)
- Remove the now-empty calls entirely where the result is { }
- Slim LayoutOptions to just noMaxWidth + wrapperClassName
- Remove the fullBleed preset expansion in useLayoutOptions
- Drop DrawerContext, useOpenDrawer, NavHiddenContext, useNavHidden
(no useful consumers without mobile bottom nav)
- Inline navHidden=false at the two SubHeaderBar / ProfileTabs sites
and drop the dead conditional classes
- Keep CenterColumnContext + useCenterColumn (still used by
WebxdcEmbed and NsitePreviewDialog for portal targets)
- Drop LiveStreamPage's chatSidebar (it relied on rightSidebar which
no longer exists)
- Drop ProfileSettings's useLayoutOptions sidebar injection
(ProfileRightSidebar is still rendered inline in the page)
The router-level layout introduced in 73bb2a17 hardcoded max-w-3xl on
every page, ignoring useLayoutOptions({ noMaxWidth: true }) and
wrapperClassName. Split FundraiserLayout into an outer provider and
an inner consumer so the inner component can read the snapshot, and
restore the conditional class composition the original
FundraiserLayout used. CampaignDetailPage and ActionDetailPage (and
any page using the fullBleed preset) render edge-to-edge again.
Move the protocol-level Nostr questions (what is Nostr, why is the
sign-in so long, what happens if I lose my key, password-manager use)
out of 'About Agora' and into a new 'About Nostr' category positioned
after the Bitcoin Donations section. Newcomers see what Agora is and
how Bitcoin works first, and only dig into Nostr's identity model
once they care.
Also reorder the silent-payments FAQ to sit above the Lightning one,
since silent payments are part of the answer to 'how do payments
work' while Lightning is the explanation of an absence.
Donations settle to the activist's wallet, not to abstract
'beneficiaries' — the wallet framing matches the rest of the app.
Adds a secondary outline button next to 'Start a campaign' that links
to /help so newcomers have an obvious next step before committing to
creating a campaign.
Silent payments still settle on-chain; the meaningful distinction is
public vs. private (a silent-payment transaction's output can't be
linked back to the recipient's reusable code). Reword help and guide
copy so 'on-chain' only appears where it correctly contrasts with
Lightning or describes chain mechanics that apply to both kinds of
payment.
Activists now pick public, private, or both when creating a campaign;
both generates a combined BIP-21 QR so SP-aware wallets pay privately
and others fall back to on-chain. Update the FAQ, Donor Guide, and
Activist Guide accordingly:
- Recommend Ditto Wallet and Dana for donors who want privacy.
- Lead the activist cashout path with moving funds into a silent-
payments wallet first, then spending onward with the trail broken.
- Flip the 'why no silent payments' FAQ into 'yes, we support them'.
- Note that silent-payment donations are excluded from public donor
lists and totals by design.
The BlindBit Oracle exposes only per-block endpoints, so a 144-block
'Scan recent' was up to ~288 sequential HTTP round trips against the
public mainnet indexer (~700ms each = ~200s wall clock before any ECDH
math). Profiling against the configured indexer showed latency, not
ECDH or bandwidth, was the dominant cost — and that the server happily
handled 10 concurrent requests with ~6x speedup.
Replace the sequential for-loop in scanRange with a sliding-window
pipeline: keep up to SCAN_FETCH_CONCURRENCY (8) fetchBlockEntries
calls in flight, but process completed blocks strictly in height order
so optimisticRef, matchesFound, scan-progress, and the contiguous
scanHeight advancement stay single-writer and monotonic.
Cancel and error semantics are preserved:
- cancelScan still aborts every in-flight fetch via the controller.
- First in-order fetch failure aborts the rest of the scan, same as
before. The finally block drains still-pending fetches so their
rejections don't surface as unhandled promise rejections.
- Card grid: remove the corner "Private" badge on silent-payment cards.
- Detail hero: remove the "Private campaign" badge above the title.
- Detail donate aside: drop the "Private campaign — totals not public"
line; silent-payment campaigns now show only the goal target (if any)
in place of the raised/progress block.
The donate panel already makes it clear which wallet endpoints are
available, so the redundant privacy chrome was just noise.
For dual-wallet campaigns the QR already encodes a single BIP-21 URI
(`bitcoin:<addr>?sp=<sp>`). Mirror that in the copyable row instead of
showing one row per endpoint — modern wallets parse the URI in their
recipient field and BIP-352-aware ones pick up the `sp=` parameter
automatically. Single-endpoint campaigns still show the raw value with
a label-appropriate toast.
Also remove the on-chain traceability disclaimer and the silent-payment
unlinkability notice — both are noise on the donation panel.
The chip + 'Add another address' UX was too clever. Replace it with:
- A 'wallet source' dropdown ('My wallet' / 'Custom'), defaulting to
'My wallet' for nsec users and matching the pre-dual-wallet form.
- An 'accept' dropdown beneath it (only shown when source is 'My
wallet') that picks which donation types to accept: all (default),
public only, or private only. The 'all' and 'private' options are
disabled when the active login can't derive a silent-payment code.
When source is 'Custom' we still surface separate bc1 and sp1 inputs
so the user can publish a dual-endpoint campaign with addresses they
hold elsewhere. At least one of them must parse.
Without nsec access the dropdowns are skipped entirely and the two
custom inputs are shown directly \u2014 same as before.
Edit mode still starts in 'Custom' with the existing values pre-filled
so a no-op edit doesn't surprise the user by re-deriving HD
endpoints (or burning a receive index).
The on-chain traceability and silent-payment privacy notices stay
where they matter — on the campaign detail page's donate panel, where
donors actually see them. The form page no longer needs them
duplicated under the picker.
A campaign may now declare up to two `w` tags — at most one mainnet
on-chain address (bc1q…/bc1p…) and at most one silent-payment code
(sp1…) — and the QR/payment panel combines them into a single BIP-21
URI (`bitcoin:<bc1>?sp=<sp1>`) when both are present. BIP-352-aware
wallets pick the SP parameter automatically; legacy wallets fall back
to the on-chain address.
The campaign form is reorganized around the dual-endpoint model. Users
with nsec access see two avatar chips — "My wallet" and "My private
wallet" — both selected by default and an "Add another address"
disclosure that reveals separate bc1 and sp1 inputs. A typed value
wins over the corresponding chip's HD-derived value, so a cold-storage
address can be substituted without giving up the SP code. Users
without nsec access (extension / bunker logins) see the two custom
inputs unconditionally. At least one of the four sources must resolve.
The on-chain receive-index cursor is still advanced only at publish
time, and now only when "My wallet" is selected AND no custom
on-chain value was provided — so the cursor never burns on a no-op
edit or on a publish where the user overrode the chip with their own
address.
`ParsedCampaign.wallet` is replaced by `ParsedCampaign.wallets`, a
`{ onchain?, sp? }` struct. Consumers (`useCampaignDonations`,
`useDonateCampaign`, `useProfileCampaignStats`, `useOnchainZaps`,
`CampaignCard`, `CampaignDetailPage`, profile rails) keep their
existing on-chain semantics by reading `wallets.onchain`. The
"Private campaign" badge and hidden-aggregates UI now trigger on
SP-only campaigns (no on-chain endpoint), matching the spec.
Replace og-image.jpg with the 1200x630 version of the Agora PR cover and
update index.html to reference the .jpg URL (the meta tags previously
pointed to a non-existent og-image.png).
- Change non-nsec wallet fallback from 'View wallet' to 'Wallet details'
- Replace exact Blockbook/Esplora call counts in module JSDoc with
behavior-focused wording that won't drift when hook internals change
Logged-in users reach Help through the account menu (AccountSwitcher
dropdown), but logged-out users have no equivalent affordance — the
default sidebar order doesn't include Help, so it was buried in the
"More…" menu. Force it into the main list when there's no user, and
suppress the duplicate in the hidden-items menu.
- Replace removed useBitcoinWallet with useHdWallet + useBtcPrice
- Show graceful 'View wallet' fallback for non-nsec logins
- Drop includeArchived and recipientPubkeys (removed from useCampaigns)
- Remove beneficiary campaign query and 'Started for you' shelf
(campaigns are now self-authored; author = beneficiary)
- Simplify hero card from 4 stat tiles to 3
- Update stale JSDoc referencing Esplora and old query costs
Per-campaign 'raised' was the sum of verified kind 8333 donation receipts:
each receipt's tx was re-fetched and its outputs paying the campaign's `w`
address were summed. That counted only donations whose donor published a
receipt — direct on-chain payments were ignored — and required N `/tx/`
lookups per campaign view.
Source `totalSats` from a single `/address/{w}` lookup against the
configured Esplora endpoint (default: mempool.space) and use
`chain_stats.funded_txo_sum` (lifetime received). Any payment to the
address now counts, and the progress bar does not regress when the
beneficiary spends.
Kind 8333 receipts are still fetched and verified to power the donor list,
donor count, and per-tx breakdown — they just no longer drive the headline
number.
Silent-payment campaigns are unchanged (no observable balance).
`useProfileCampaignStats` and the `SortedByTopGrid` on the profile
campaigns tab switch to the same address-balance source.
The nsec paste guard (useNsecPasteGuard) bails out when the paste
target has id="nsec" or sits inside [data-nsec-allowed], but the
login form had neither marker, so pasting an nsec into the login
field triggered the "Secret key detected" toast and was blocked.
Removes the 'Donate' button that opened the PSBT-signing
DonateDialog above the wallet QR/address panel. Agora no longer
runs the in-app on-chain donate flow — donors pay from an
external wallet via the QR code, the same path silent-payment
campaigns already used.
The DonateDialog component and useDonateCampaign hook stay in
the tree for now; they're still wired into the profile rail's
campaign-donate dropdown.
The contrastForeground() helper relied on isDarkTheme(), which only
treats a color as dark when its luminance < 0.2. The default orange
primary (24 100% 50%) has luminance ~0.31, so it was classified as a
light background and got dark text — black letters on orange buttons
throughout the site. Same problem hit most saturated mid-lightness
brand colors (red, blue, purple, green).
Raise the threshold to 0.55 so saturated mid-lightness colors get
white text while genuinely light pastels (pink theme, sunset, light
mode background) still get dark text.
The button used `text-primary-foreground`, which the theme derives via
auto-contrast against the orange primary. With the current orange (HSL
24 100% 50%) the contrast helper picks black, which clashes with the
hero's dark background and reads as low-effort. Force white explicitly
so the brand-orange pill keeps a consistent look regardless of how the
primary-foreground token shifts.
When 'Custom' is selected with an empty field, the long bc1q/bc1p/sp1
explanation read as noise. The input's placeholder ("bc1p… or sp1…")
already conveys the expected format; the invalid-input error still
fires when something unparseable is typed.
Remove the secondary captions ("A new on-chain address per campaign",
"Static silent-payment code", "Paste any mainnet bc1… or sp1… address")
from the three wallet options — the primary label already says what
the option is.
Give the Custom item a matching size-7 circle on the left, with a
Wallet icon centered in it, so the three items (and the closed-state
trigger) line up vertically. Without it, Custom sits flush against
the left padding while the other two are pushed in by an avatar.
The home page's Agora activity tab is driven by useAgoraFeed
(['agora-feed', ...]) and the mixed-mode composer (['mixed-feed', ...]).
None of the publishing paths invalidated those keys, so a freshly posted
comment / pledge / donation / campaign / kind 1 note didn't appear in
the home activity feed until the user refreshed the page.
* usePostComment now invalidates ['agora-feed'] and ['mixed-feed'] on
every comment publish, and additionally cascades to the parent
event's ['organization-activity', A], the predicate-matched
['community-activity-feed', aTagsKey], and the campaign-page
['event-comments', aTag] cache when the root carries an org A tag
or addressable root coords.
* useDonateCampaign, CreateActionPage, CreateActionDialog,
CreateCampaignPage, and ComposeBox (top-level kind 1, voice, poll)
each gain ['agora-feed'] / ['mixed-feed'] invalidations so their new
content lands in the activity feed without a refresh.
* useDeleteEvent's predicate sweep is extended to ['mixed-feed'] and
['nostr-layer'] so deletions also drop from the home activity feed
composition layers, not just the source useAgoraFeed query.
The shadcn "accent" token is the *interactive* surface used by
dropdown items, ghost buttons, calendar cells, command palettes,
etc. Previously `accent` was aliased to `primary` (the brand
color), which painted every menu hover state with the loud brand
color — most visibly on the new /campaigns/new wallet dropdown.
Repoint accent to a derived surface that sits one perceptible step
beyond `secondary`/`muted`:
- dark themes: lighten(background, 14) vs +8 for muted
- light themes: darken(background, 8) vs -4 for muted
The extra ~6 lightness points are deliberate. Without the gap,
`accent === muted` would mean a hovered menu row containing an
avatar (which uses `bg-muted` for its fallback) makes the avatar
disappear into the hover surface. The same applies to badges, code
chips, and inline pill tags that share the muted background.
`accent-foreground` becomes the page foreground (neutral text on
neutral surface) instead of the primary-foreground (light text
designed for the brand background).
Phase 3 of the invalidation cleanup.
* useDeleteEvent previously invalidated only ['feed'], ['profile-feed'],
['profile-likes-infinite'], ['replies'], and ['notifications']. A
deleted event can sit in many other surfaces — country feeds
(agora-feed-paginated / agora-feed-new-posts), comment threads
(['nostr', 'comments', ...], ['event-comments', ...], wall-comments),
campaign and pledge lists, community / organization activity feeds,
trending, and per-event caches. Switch to a predicate that sweeps a
curated allow-list of feed-shaped query-key prefixes so the deleted
post drops off every visible surface in a single refetch wave.
* useCampaignModeration only invalidated its own ['campaign-moderation']
cache. Moderation labels (approve / hide / feature) gate which
campaigns surface on the home page, discover shelf, and community
grids, so the list queries need refetching too. Cascade to
['campaigns'], ['campaigns-all'], and ['campaigns-all-scores'].
ReportDialog (kind 1984) and useRequestToVanish (kind 62) were reviewed
and intentionally left alone: ReportDialog has no UI consequence inside
Agora (reports only show up to external moderators), and Request to
Vanish logs the user out, after which any cached state is cleared
anyway.
Traceability cuts both ways on a campaign — donors are also exposed,
not just the organizer — so the lead reads "Donations are public and
can be traced." without scoping the trace to the campaign owner.
Phase 2 of the invalidation cleanup. Each mutation below already invalidated
some of its consumer queries but missed sibling surfaces that display the
same data, so users had to refresh to see their action reflected everywhere.
* ProfileReactionButton (kind 7 on a kind 0 profile) didn't refresh any
stats. Bump the profile's nip85-event-stats and nip85-addr-stats keys
via invalidateEventStats + an explicit '0:<pubkey>:' addr sweep.
* BanConfirmDialog only invalidated ['community-members', aTag], so a
removed post remained visible in the org's activity feed until refresh.
Mirror CommunityReportDialog's predicate-match on
['community-activity-feed', aTagsKey] and also refresh
['organization-activity', aTag].
* CreateActionDialog (the quick-create pledge dialog inside an org)
refreshed community-actions and the activity feed but skipped
['organization-activity', communityATag], so the new pledge didn't
appear on the same org-detail page that launched the dialog.
* ActionsPage delete handler only invalidated ['agora-actions'] and
['agora-action']. Extract any organization 'A' tag from the pledge
event and cascade to organization-activity, community-actions, and
the community-activity-feed predicate.
* CampaignDetailPage delete handler skipped campaigns-all (the discover
list), the campaign's organization shelf, and the country feed if the
campaign carried a country code. Add all three.
* CreateCampaignPage onSuccess refreshed only the single-campaign key
and one org-activity key. Add the campaigns/campaigns-all list keys
and the country-feed keys so newly launched or edited campaigns show
up everywhere they're displayed.
Replaces the freeform 'Bitcoin wallet' input on /campaigns/new with a
three-option Select:
1. "<Name>'s wallet" — derives a fresh bc1p… from the user's HD
wallet at submit time. Advances the persistent receive-index
cursor by 1, but only after every other field has validated, so
a failed publish doesn't burn an index.
2. "<Name>'s private wallet" — uses the static BIP-352 silent-
payment code (sp1…).
3. "Custom" — keeps the existing freeform input for any mainnet
bech32(m) address.
The two HD-wallet options are disabled for extension/bunker logins,
which can't expose the raw nsec the derivation needs. Edit mode always
starts in 'custom' with the existing w tag pre-filled — switching
wallets on a live campaign is an explicit user choice.
Adds two soft, informational disclaimers below the field that swap
based on the effective wallet mode:
- on-chain → BitcoinPublicDisclaimer (tone="soft") with new
popoverText override for the campaign-creator audience: "Bitcoin
is a public ledger. Transactions sent to this wallet will be
visible to everyone…"
- silent-payment → new BitcoinPrivateDisclaimer with the
"Experimental. Donations are private, but bugs may occur."
headline + popover explaining the recoverability/sync trade-offs.
Several mutations published Nostr events but invalidated cache keys that
no live query subscribed to, leaving the UI showing stale counts and
missing entries until the user manually refreshed.
* Campaign donations: useDonateCampaign and CampaignDetailPage invalidated
['campaign-donations', aTag], but useCampaignDonations subscribes to
['campaign-donations', 'events', aTag]. Use the correct key, cascade to
organization-activity / campaigns lists, and broaden via prefix sweep.
* Reactions / reposts / quotes: ReactionButton, RepostMenu, QuickReactMenu,
ComposeBox quote path, VinesFeedPage and ListDetailPage all wrote to
['event-stats', id], which no query reads. Counts are served by
useNip85EventStats / useNip85AddrStats at ['nip85-event-stats', id,
statsPubkey] and ['nip85-addr-stats', addr, statsPubkey]. Route
optimistic writes and invalidations through a new invalidateEventStats
helper that handles both the regular and addressable variants.
* Top-level posts on country pages: ComposeBox's createEvent path
invalidated ['feed'], but country pages subscribe to
['agora-feed-paginated', countryCode, ...] and
['agora-feed-new-posts', countryCode, ...]. Add the country-feed
invalidations to the kind 1, voice, and poll handlers — matching the
pattern usePostComment already uses for kind 1111 comments.
* Follow All inline reimplementations: FollowPage's FollowPackView,
TeamSoapboxCard, and FollowPackDetailContent published kind 3 events
inline with no invalidation, so follow buttons and the user's feed
stayed unchanged. Replace each with useFollowActions.followMany, which
already invalidates ['follow-list'], ['feed'], and ['following-feed'].
The freeform kind-0 profile fields (links, addresses, etc.) used to
sit at the very bottom of the rail/overview, after campaigns,
latest pledge, and organizations. Move them to the top so the
profile's own metadata is the first thing visitors read.
A long silent-payment scan over mostly-empty blocks would keep
resetting the 5s debounce timer and never actually fire an
intermediate republish — so closing the tab mid-scan could discard
many minutes of work. Switch to a leading-arm throttle: the timer is
armed once when a match lands, fires after at most 5s, and ignores
subsequent matches until it fires. Empty blocks never arm the timer,
so the user's signer isn't spammed during a 10k-block backfill.
The final flush in scanRange's finally still publishes
unconditionally so the advanced scanHeight is checkpointed even on
match-free ranges.
On mobile the profile page used to stack the full identity rail
(avatar / bio / actions / stats / campaigns / latest pledge / orgs
/ fields) above the tab bar. Users had to scroll past the entire
rail before they reached the tabs, and once the tabs did pin they
clashed with the main app top nav.
Reshape the mobile layout so the rail's content becomes a tab. The
avatar, name, bio, action bar, and Followers/Following/Raised stats
stay above the tab bar as a persistent identity header; everything
else moves into two new mobile-only tabs:
Mobile: Overview | Activity | Campaigns | Community | Pledges
Desktop: Activity | Campaigns | Pledges (unchanged)
Overview shows the campaigns preview, the fallback latest-pledge
card, and the freeform kind-0 profile fields. Community shows the
organizations grid. Desktop is byte-identical — the two-column
grid with the sticky 340px rail still renders the original three
content tabs.
Implementation:
- Split ProfileIdentityRail.tsx into reusable exports:
ProfileAvatarBlock, ProfileIdentityHeader, ProfileOverviewSections
(with an opt-out showOrganizations flag), and a standalone
ProfileOrganizationsSection for the Community tab. The original
ProfileIdentityRail wrapper still composes them in the same
two-layer structure used by the desktop sticky aside.
- ProfilePage.tsx now renders two parallel layouts toggled with
hidden / lg:hidden (no useIsMobile, so no first-render flicker).
A new ProfileTabContent helper routes the active tab id to its
body for both layouts.
- Initial activeTab is picked from matchMedia('(min-width: 1024px)')
so mobile defaults to 'overview' and desktop to 'activity'
without a wrong-tab flash. Resizing from mobile to desktop while
on overview / community redirects to activity.
The transaction list derived SP receive rows from the active UTXO set,
so a UTXO that got pruned (either by a send or by the manual reconcile
pass) silently vanished from history. The spending transaction itself
also mis-classified: Blockbook's xpub scan sees only the BIP-86 change
output, so a self-send appeared as a small unsolicited receive.
Archive SP UTXOs instead of deleting them:
- `SPStorageDocument` gains an optional `spent: SPStoredUtxo[]`
list; the parser, serialiser, and the publish-time merge handle it
alongside `utxos`.
- `pruneSpentUtxos` moves entries from `utxos` to `spent` via the
new `archiveSpentUtxos` helper rather than dropping them.
- The optimistic-vs-loaded heuristic compares combined (`utxos` +
`spent`) counts so a prune that shrinks `utxos` while growing
`spent` doesn't accidentally fall back to the stale relay copy.
Use the archive to fix the tx-history UI:
- The receive-history builder in `useHdWallet` merges active +
archived SP UTXOs, so historical receives stay visible.
- `buildHdTransactions` is reworked to do per-Blockbook-tx accounting
using raw `vin`/`vout` data (now plumbed through
`AccountScanResult.rawTransactions`). It accepts a map of SP
outpoints we own (active + archived) and subtracts `outflowsSp`
from the net delta — a tx whose vin matches one of our SP UTXOs
flips from 'receive of change' to 'send' with the correct amount.
Add a deep-rescan recovery path for state that was pruned before the
archive logic shipped. The BIP-352 indexer fetchers gain an
`includeSpent` flag; spent-flagged matches surface in
`SPMatchedUtxo.spent` and the orchestrator routes them straight into
the archive. Exposed as an 'Include already-spent' checkbox in the
existing scan dialog.
Regression-of: 3adfe5d8
The send-time prune from c983d406 only catches SP UTXOs the current
session spends. Any UTXO spent before that fix shipped — or spent on
another device — stays in the encrypted NIP-78 doc indefinitely,
inflating the displayed balance and offering already-spent inputs to
the next coin-selection pass. Blockbook's xpub scan can't observe SP
outputs (they aren't on the BIP-86 hierarchy), so chain refresh can't
fix it either.
Wrap Blockbook's WS `getTransaction` to read per-vout `spent` flags
and expose `reconcileSpentUtxos` from `useHdWalletSp`: it walks up
to 50 distinct stored txids per click, asks Blockbook which outputs
are spent, and feeds the spent set through the existing
`pruneSpentUtxos` helper. Surface as a 'Reconcile now' button in the
existing SP scan dialog — same place users already go to fix up SP
state.
Manual rather than automatic on-load because firing ≤50 WS calls on
every wallet page mount would be wasteful when the steady-state case
(after this and the send-time prune both land) is that nothing needs
fixing. The cap is mirrored from the existing block-timestamp backfill.
Regression-of: 3adfe5d8
Blockbook's xpub scan can't observe silent-payment outputs, so when the
send flow consumes one, nothing on the chain-scan side removes it from
the wallet's local NIP-78 UTXO doc. The previous `onSuccess` only
invalidated the doc query, but the relay copy still contained the spent
UTXO and `mergeUtxos` is insert-only — so the entry never went away.
The visible symptom: spending SP UTXOs made the wallet balance go *up*.
The send tx routed change to a fresh BIP-86 address, which credited to
Blockbook's xpub balance, while `silentPaymentBalance` kept counting
the consumed SP UTXOs as still spendable.
Surface the actually-consumed SP `(txid, vout)` set from
`buildHdSpendPsbt`, thread it through the send mutation, and have
`useHdWalletSp` apply a prune+republish that also strips the same
entries from the remote doc before merging (otherwise insert-only
`mergeUtxos` would re-add them on the next read-modify-write).
Regression-of: 3adfe5d8
The HD wallet can already derive its own sp1q… receive address and detect
incoming silent payments via the BlindBit indexer, but the Send dialog
only handled bare Bitcoin addresses (or npubs / nprofiles, which routed
through nostrPubkeyToBitcoinAddress). Silent-payment funds were stuck:
they showed in the balance but the dialog gated them with a "spending
isn't supported yet" notice.
Now the dialog handles both ends:
- Recipient resolution in parseHdRecipient accepts sp1… (mainnet, v0)
alongside bc1…, npub1…, and nprofile1…. The send mutation decodes the
address, derives the per-transaction P_k locally from the selected
inputs' BIP-341-tweaked private keys, and writes it as a regular P2TR
output. The on-chain transaction looks like any other Taproot spend;
the BIP-352 ECDH happens entirely off-chain.
- The coin selector now mixes BIP-86 UTXOs with SP UTXOs from the
NIP-78 storage doc. SP inputs are signed by computing
d_k = b_spend + t_k and writing tapKeySig directly, bypassing
@scure/btc-signer's automatic TapTweak (which would re-tweak the
already-on-chain P_k and produce an invalid signature).
- The "silent-payment-only balance" warning is gone — those funds are
now spendable. The privacy disclaimer still appears for bare bc1…
addresses but is suppressed for sp1… recipients, since the whole
point of silent payments is that the on-chain output is fresh and
unlinkable.
src/lib/hdwallet/sp/sender.ts contains the BIP-352 sender math (address
bech32m decode, outpoint serialisation, ECDH, P_k derivation) ported from
Ditto and adapted to noble-curves v2. src/lib/hdwallet/sp/spend.ts holds
the spend-side helpers (b_spend derivation, d_k = b_spend + t_k, manual
Schnorr signing for SP inputs). Both are covered by the BIP-352 canonical
taproot-only test vectors plus a sender↔receiver round-trip check that
the same (b_spend, t_k) the scanner persists really does produce the
P_k the spender signs against.
Replace the bitcoinjs-lib + ecpair + @bitcoinerlab/secp256k1 + Buffer-polyfill
stack with @scure/btc-signer (plus @noble/curves for BIP-352 point math)
across every consumer:
- src/lib/bitcoin.ts: P2TR payment + PSBT build/sign/finalize via btc.p2tr
and btc.Transaction. signPsbtLocal hands the raw 32-byte private key to
signIdx, which detects tapInternalKey and applies the BIP-341 TapTweak
internally — the ECPair + manual taggedHash('TapTweak', ...) song-and-dance
is gone. The empty-string-on-invalid-pubkey contract is preserved via an
explicit on-curve check using schnorr.utils.lift_x.
- src/lib/hdwallet/derivation.ts: deriveLeafTaprootSigner is removed in
favour of deriveLeafPrivateKey, which is now sufficient because
signIdx tweaks internally. The lazy ensureEcc / ECPairFactory plumbing
is gone.
- src/lib/hdwallet/transaction.ts: PSBT pipeline ported to btc.Transaction;
signHdPsbt now wipes the materialised leaf privkey after signIdx().
- src/lib/hdwallet/sp/{crypto,scanner}.ts: replace ecc.pointFromScalar /
pointAdd / pointMultiply with secp256k1.Point methods from @noble/curves.
pointMultiplyCompressed is exported for the scanner. Noble multiply is
strict (throws on scalar 0 or >= n) so the wrappers preserve the previous
"Failed to compute …" semantics.
- src/lib/campaign.ts: parseCampaignWallet uses the shared
validateBitcoinAddress helper instead of bitcoin.address.toOutputScript.
- src/lib/bitcoin-signers.ts: NSecSignerBtc no longer touches Buffer.
- src/lib/polyfills.ts + src/main.tsx: drop the global Buffer polyfill and
the bitcoin.initEccLib(ecc) bootstrap — neither is needed anymore.
package.json: removes bitcoinjs-lib, ecpair, @bitcoinerlab/secp256k1, and
the buffer polyfill package; adds @scure/btc-signer ^2.2.0. @noble/curves
and @scure/{base,bip32,bip39} were already in tree.
bitcoin.test.ts gains a PSBT round-trip regression block. The unsigned-PSBT
hex fixtures in those tests were captured from the bitcoinjs-lib pipeline
before the migration, so the new build path is asserted to produce
byte-for-byte identical PSBT envelopes (input layout, output ordering,
fee-vs-change decision, PSBT v0 serialisation). Signing uses random aux so
witness bytes differ run-to-run; the tests verify the resulting raw tx hex
has the right Schnorr-key-path witness shape (0x01 stack + 0x40-byte sig)
for every input, plus that signPsbtLocal still throws when no input belongs
to the signer.
All 25 bitcoin.test.ts tests pass; full \`npm run test\` (72 tests + tsc +
eslint + vite build) is green.
The on-chain wallet rewrite (3a703a26) replaced the single
`esploraBaseUrl` config field with an ordered `esploraApis` array
that `verifyOnchainZap` failovers across, but `ProfileCampaignsTab`
and `useProfileCampaignStats` still pulled the old name off
`AppConfig` and passed a string where the array is now required —
the two surviving call sites from the original Esplora API.
Switch both to destructure `esploraApis` and pass it through to
`verifyOnchainZap` to match the new signature.
Regression-of: 3a703a26
- Switch the hook headline to Bebas Neue (font-display family) at heavier
size with a synthetic webkit-text-stroke fatten, italic, uppercase, and
tight leading so 'Connecting activists to / unstoppable funding.' reads
as a single editorial statement.
- Force the orange highlight onto its own line via <br>; tune left/right
padding and inner text offset so the U sits flush with 'Connecting'
above while the box extends past the word as a flourish.
- Fix two horizontal slashes across the world map caused by
antimeridian-crossing rings (Russia, Antarctica): detect any longitude
step > 180° and close+restart the SVG subpath instead of drawing the
connecting line.
- Drop the 2008-era left-edge darkening gradient and the bottom
vignette behind the map.
- Dim the central radial brand-orange glow (~half alpha).
- Tighten the arc-flow dash period and pixel size.
The previous hero was a full-bleed user-uploaded campaign banner with a
3D spinning globe, an 8-hue palette that cycled every 6s, and a heavy
text-shadow on the headline to keep it legible against the photo. Three
structural problems: the hero's quality floor was whatever the worst
featured campaign uploaded, the brand orange was just one of eight
rotating hues, and the headline depended on a drop shadow to read.
New hero is brand-driven, type-led, and self-contained:
- HeroLightningMap renders a dark equirectangular world map (reusing
LAND_RINGS) with a curated set of glowing orange arcs between major
cities and pulsing city nodes. Pure SVG, no campaign coupling, looks
the same on every visit.
- Near-black backdrop (hsl(220 25% 6%)) gives the brand orange the
spotlight without competing with it. The CTA is a solid brand-orange
pill instead of the previous translucent glass treatment.
- A left-edge gradient inside HeroLightningMap creates a structural
quiet zone behind the headline column, so the H1 is fully legible
with no text-shadow at all. hero-text-shadow / hero-text-shadow-soft
are gone.
- Animations honor prefers-reduced-motion.
Drops HeroGlobe, HeroCampaignSpotlight, and CampaignHeroBackground
along with the spotlight cycling state, the campaign-banner pipeline,
and hopeHueFor() coupling on this page. HeroAtmosphere / HeroBanner /
HOPE_PALETTE remain in use on Communities, Actions, and Guide pages.
The previous fix translated the tabs up by their own height plus the
top-bar zone, but because the tab bar is notably taller than other
sub-headers and the top bar (z-20) paints over the tabs (z-10), the
top half of the bar was visibly clipped by the top bar mid-transition
— it looked like the tabs slid halfway up and then stopped.
Pair the slide with an opacity fade so the bar disappears as it
transits the top-bar zone, never visibly intersecting the top bar.
Regression-of: e1c66f3b
The MobileTopBar slides off-screen on scroll-down, but ProfileTabs
stayed pinned at `top-mobile-bar`, leaving a translucent gap above
the tabs where the top bar used to be — and on scroll-up, the
returning top bar (z-20) visibly crossed over the top of the tab bar
(z-10) until it docked flush above them.
Mirror the global `SubHeaderBar`'s default behavior: track
`useNavHidden()` and apply `nav-hidden-slide` with a transform
transition so the tabs ride up off-screen together with the top bar.
Regression-of: 121991f3
Delete the old Taproot single-address wallet (WalletPage, SendBitcoinDialog,
useBitcoinWallet) and rename HDWalletPage to WalletPage so the HD wallet now
lives at /wallet. The /hdwallet route is gone.
Five non-wallet callers (CreateActionPage, ActionsPage, ActionDetailPage,
CommunityDetailPage, CampaignDetailPage) imported useBitcoinWallet only for
its btcPrice field; they now use the standalone useBtcPrice hook.
WalletRecoveryPage (legacy Breez/Spark sweep) is preserved at /wallet/recovery
since it is a one-shot tool independent of the live wallet page.
The 'wallet unavailable' branch no longer points users at a non-existent
fallback wallet — it now tells extension/bunker users to sign in with their
nsec instead.
The 'opacity-75 + grayscale cover' treatment on ended pledges read as
dull/sad rather than informative. The 'Ended' badge already conveys the
state cleanly. Drop the dimming on all three pledge card surfaces:
- The compact RailPledgeCard in the profile rail.
- ProfilePledgesTab cards on the profile.
- ActionCard on the global /pledges directory.
The Ended badge stays — it's a clear, single-glance signal without
making the whole card look like inactive content.
Profiles with pledges but no campaigns had a noticeably empty rail —
the Campaigns section self-collapses, leaving just the stat block and
orgs (often empty too) above the Profile fields. Surface the user's
latest pledge as a fallback first-class Agora content slot.
Adds a 'pledges: Action[]' prop to ProfileIdentityRail (the page now
passes the same filtered list it gives ProfilePledgesTab) and a new
RailLatestPledgeSection that renders only when:
- campaigns.length === 0, AND
- the profile has at least one pledge.
The section picks the newest pledge by created_at and renders a compact
RailPledgeCard sized for the 340px rail (16:9 cover, single-line
pledged amount, optional country + deadline meta row). When there's
more than one pledge a 'See all N pledges →' link below jumps to the
Pledges tab.
If the profile has campaigns OR no pledges at all, the section returns
null and the rail's existing layout is unchanged.
Edit profile (ProfileSettings) renders an interactive ProfileCard for
the avatar/banner/bio editor. The card was showing the user's NIP-58
badge showcase grid, which doesn't belong on the edit form. Add a
showBadges prop to ProfileCard (default true to preserve all other
consumers — NoteCard, PostDetailPage, MusicArtistsTab, MusicDiscoverTab)
and pass showBadges={false} from ProfileSettings.
Profile 3-dots menu: drop the 'Add to sidebar / Remove from sidebar'
row. Sidebar pinning is a feed-management feature that doesn't belong
on a profile-action menu — add-to-list already covers the
list-management use case. Cleans out the supporting machinery
(addToSidebar / removeFromSidebar / orderedItems / sidebarId /
isInSidebar / handleToggleSidebar) and the now-unused Trash2 and
PanelLeft lucide icons.
When the user's only spendable balance is in silent-payment outputs,
the Send button stays disabled with no feedback because:
- The dialog's `ownedUtxos` is sourced from `scan.utxos` (BIP-86
only, populated by Blockbook).
- SP UTXOs live in a separate persisted store and aren't included.
- The PSBT signer can't spend them anyway: SP outputs use a
BIP-352 tweaked private key that isn't derivable from the
BIP-86 (chain, index) pair `signHdPsbt` reconstructs, so even
plumbing them into `ownedUtxos` would just move the failure
from "button disabled" to "signing throws".
`src/lib/hdwallet/sp/crypto.ts` is explicit: the wallet
"scans-and-displays SP receives but cannot spend or send them".
Surface that explicitly: when `totalBalance === 0` (no BIP-86
funds) but `silentPaymentBalance > 0`, render a one-line alert
above the Send button telling the user spending SP outputs isn't
supported yet and they need to receive on-chain to spend. Doesn't
remove the disabled state — there's nothing to spend regardless —
but at least the user now knows why.
The rail rendered <LinkFooter /> at the bottom of its content. On
desktop that worked — the rail is sticky and scrolls independently, so
the footer landed at the bottom of the rail's scroll container.
On mobile the rail is just the first stacked element above the right
column (tabs + feed). That meant the footer sat in the middle of the
page, between the rail's profile-fields section and the tab bar — a
weird mid-page footer band.
The global SiteFooter rendered by FundraiserLayout already handles
About / Privacy / Safety / Source / Changelog at the page level, so
the rail's LinkFooter was redundant on every breakpoint anyway. Drop
it.
Symptom: scrolling on top of the rail moved the page (and feed) instead
of the rail. Once the page reached the end of the feed, the rail would
finally scroll until pagination loaded more, then the feed took over
again. Frustrating for users on tall rails.
Cause: the rail aside was 'lg:sticky lg:top-4' with no height cap and no
internal scroll, so it scrolled with the page until its bottom reached
the viewport bottom — at which point sticky positioning kicked in. There
was nowhere for the rail to scroll independently because it had no
overflow container of its own.
Earlier I removed the rail's 'lg:overflow-y-auto' because it clipped
the avatar's '-mt-16' overhang above the rail's top edge. The fix is to
split the rail into two layers:
- Outer flex column (the aside contents) — owns the avatar, has no
overflow constraint, so the avatar's negative-margin overhang above
the aside's top is never clipped.
- Inner scroll container — wraps everything below the avatar with
'lg:flex-1 lg:min-h-0 lg:overflow-y-auto'. On lg+ this fills the
remaining height of a now-bounded sticky aside
('lg:h-[calc(100vh-2rem)]') and scrolls internally. Mouse wheel
over the rail scrolls the rail; mouse wheel over the right column
scrolls the page.
Below lg the inner container's lg-prefixed classes don't apply, so the
rail still flows naturally above the tab content as before.
The unified profile feed was only fetching kind 1/6 in the
'includeAuthorNotes' branch, which dropped articles (30023), photos (20),
videos (21/22), polls, and any other kind the user has enabled in their
feed settings. The legacy Posts tab pulled the full getEnabledFeedKinds
set, so the merged feed lost content compared to before.
Pipe getEnabledFeedKinds(feedSettings) through useAgoraFeed when
includeAuthorNotes is set. Always force kind 1 + 6 in if the user has
disabled them in settings — a profile feed without notes would be
useless. The post-filter now accepts any author-scoped event whose
kind is in that set, mirroring the relay request shape.
Profile pages had two tabs that both showed 'this person's stuff' — one
strict-Agora (Activity) and one general kind-1/6 (Posts). For a profile,
that distinction is noise: visitors want to see everything someone has
done on the network in one timeline.
Add an 'includeAuthorNotes' option to useAgoraFeed. When set alongside
'authors', the relay query gains a fifth filter '{ kinds: [1, 6],
authors }' and the post-filter relaxes the strict t:agora gate for
events authored by the requested set. The strong author scope is the
trust anchor — we know it's by this person, so we surface it.
ProfileActivityTab consumes the new option, becoming the single feed
for the profile. Drops the Posts tab, the Posts & replies overflow tab,
useProfileFeed integration on ProfilePage, the feedItems / currentItems
machinery, the MIN_VISIBLE_ITEMS auto-load effect, the pinned-posts
surface (was tied to the Posts tab; togglePin still exists elsewhere),
the PinnedLabel helper, the profile-pinned-events query, and a pile of
now-unused imports (NoteCard, usePinnedNotes, isEventMuted,
useProfileFeed, filterByTab, FeedItem, useQuery, useNostr from the
outer scope, useMuteList outside ProfileMoreMenu, Pin lucide icon).
Profile tabs are now: Activity / Campaigns / Pledges. The home /
mixed feed is unaffected because includeAuthorNotes defaults off.
Net: -150 LoC in ProfilePage.tsx; +28 LoC in useAgoraFeed; -8 LoC in
ProfileActivityTab.
Blockbook's WebSocket estimateFee returns feePerUnit in sat/**kB**,
not sat/byte. The TypeScript declaration in blockbook-api.ts
describes it as "sat/byte, Wei/gas, etc.", which was misleading
enough that I trusted the declaration and added a code comment to
match. The Go source is explicit in api/worker.go:
// fee is in sats/kB
fee, _ := w.cachedEstimateFee(i+1, true)
Confirmed against btc.trezor.io: at typical mempool conditions the
WS reports values around 3000–3500 (sat/kB), which the HD Send
dialog rendered verbatim as "3320 sat/vB" for the 10-minute tier.
Dividing by 1000 yields ~3 sat/vB, matching what /wallet shows.
Round up after the divide so we never underpay relative to the
backend's recommendation — under-paying by 0.5 sat/vB is the kind
of thing that gets a tx stuck for a day.
Regression-of: 2e5a2628
1. Double close buttons. shadcn's DialogContent always renders an
absolute-positioned X in the top-right corner; the dialog also drew
its own X in a header row. Hide the default with `[&>button]:hidden`,
matching SendBitcoinDialog.
2. Absurdly high fees / "can't send". The UI fee preview multiplied the
fee rate by `ownedUtxos.length`, but an HD wallet typically holds
many UTXOs across many addresses while a real send only consumes the
minimal set the coin selector picks. On an active wallet this
over-estimated by an order of magnitude, drove `totalSats` past
`totalBalance`, and disabled the Send button via `insufficient`.
Add `previewHdFee` in lib/hdwallet/transaction.ts that runs the same
`selectUtxos` logic as the real PSBT builder and returns the resulting
fee. Use it in both the live fee display and the auto-tune effect, so
the preview matches what the transaction will actually pay.
Also flag `selectionFailed` (positive amount but `previewHdFee`
returns 0) as `insufficient` so the UI doesn't claim a 0-sat fee is
spendable when coin selection failed.
3. Em dash on every fee tier. The popover trigger only had a value path
for `estimatedFeeSats > 0 && btcPrice`. With no amount entered yet,
or while fee rates are still loading, every tier displayed `—`. Fall
back to `<rate> sat/vB` when we have a rate but no amount-derived
USD fee.
4. Privacy checkbox unchecking on fee tier change. The reset effect
listed `currentFeeRate` in its deps, so picking a different fee tier
silently flipped `acknowledgedPublic` back to false. Split the resets:
`confirmArmed` still re-arms on amount/fee/price/recipient changes,
but `acknowledgedPublic` only resets when the recipient changes.
Regression-of: 522c2650
ProfileTabs had '-mx-4 sm:-mx-6 lg:mx-0' to bleed the bar to the page
edges, paired with matching positive padding on the inner scroll track.
On lg+ that worked (the bleed was suppressed) but below lg the negative
margin pushed the tab bar wider than the rest of the profile content
column, making the page width look broken once the rail stacked above
the right column.
Drop the bleed entirely. The tab bar now sits exactly at the column's
width at every breakpoint. The translucent backdrop and bottom border
still read as a visual separator without escaping the content area.
Tabs:
- Activity, Campaigns, Pledges, Posts. That's it.
- Media / Badges / Likes removed along with their renderers,
ProfileBadgesTab function (152 LoC), useProfileMedia + useProfileLikes
hooks, mediaEvents / likedItems / mediaFeedItems / likedFeedItems
memos, the sidebarMediaUrl state and its callbacks, and the
profile-media / profile-likes-infinite cache invalidations.
- The 'isCoreProfileTab' fallthrough now only handles posts/replies.
Visuals:
- New ProfileTabs component replaces the global SubHeaderBar +
TabButton pair on this page. It's a clean column-local sticky bar
with backdrop-blurred translucent background, hairline bottom
border, and an animated underline that slides between active tabs.
No arc decoration, no hover-slice tracking — just tabs.
- Sticks to top-mobile-bar on small screens (clears mobile chrome)
and top-0 from sidebar breakpoint up. Active tab auto-scrolls into
view when it overflows the column.
Net: -289 LoC in ProfilePage.tsx; +118 LoC for the new ProfileTabs.
The HD wallet's silent-payment receives synthesised their timestamp from
block height using a 600-seconds-per-block constant anchored at block
800,000. Real average block time is shorter than 600s, so cumulative
drift on recent heights pushes the estimate days into the future and the
tx list rendered '-11d ago' for fresh receives.
Fetch the actual block timestamp from Blockbook's getBlock at scan time
and persist it on each SPStoredUtxo. Existing docs without the field
are backfilled opportunistically on the first session that loads them
(bounded to 50 unique heights per session to avoid hammering Blockbook
on wallets with deep history).
The synthetic estimate is preserved as a fallback for the rare case
that Blockbook is unreachable and clamped to 'now' so it can never
report a future timestamp. The relative-time formatter in HDWalletPage
and WalletPage also clamps negative diffs to 'Today' as a final guard.
Regression-of: 059f75db
Rail:
- Drop the badge preview row.
- Pull Followers + Following onto a single inline horizontal row at
the top of the stat block. The campaigns-count row is gone (it was
redundant with the Campaigns section that sits right below it).
- Pledges and Raised remain as full rows in the secondary stat list.
Tabs:
- Remove the Overview tab. Activity is now the default, leftmost tab.
- Remove the Wall tab and all its supporting code: useWallComments
hook, wallReplyTarget memo, wallComments / orderedWallReplies
flatten, wallComposeOpen state, openWallCompose callback,
profileFollowsMe gate, FAB wall-compose wiring, the wall-comments
cache invalidation, and the Wall tab renderer.
- Remove the overflow '⋯' dropdown that exposed non-default core
tabs. Every core tab is now visible in the strip.
Fallouts:
- DEFAULT_TAB_LABELS = CORE_TAB_LABELS (every core tab is shown), so
CORE_TAB_LABELS is dropped.
- Delete src/components/profile/ProfileOverviewTab.tsx.
- Drop a pile of now-unused imports from ProfilePage.tsx
(MoreHorizontal, MessageSquare, DropdownMenu*, FeedCard,
ComposeBox, ReplyComposeModal, useWallComments,
FlatThreadedReplyList, ProfileOverviewTab) and the badge-related
imports from ProfileIdentityRail (BadgeThumbnail,
useBadgeDefinitions, useProfileBadges, nip19).
Net: -418 LoC across the page and rail.
The previous commit raised the avatar too far up the page by matching
the negative margin to the avatar's full height. The user's actual
complaint was z-stacking, not vertical position.
Restore -mt-16 md:-mt-20 so half the avatar overlaps the banner (the
classic profile look) and add 'relative z-10' to the avatar button so
it explicitly layers above the banner's stacking context regardless of
which parent creates a new context (e.g. the lg:sticky aside).
The avatar's negative margin was -mt-16/-mt-20 (-64/-80px) but the
avatar itself is 112/128px tall, so roughly half of it sat under the
rail boundary and got cut off behind the banner edge.
Match the negative margin to the avatar's full size — -mt-28 md:-mt-32 —
so the bottom edge of the avatar sits at the rail's top edge (which is
the banner's bottom edge). The whole avatar floats over the banner now.
Drop lg:overflow-y-auto + lg:max-h-[calc(100vh-2rem)] from the rail
<aside> so the avatar's overhang above the rail's top edge isn't
clipped. The rail still sticks via lg:sticky lg:top-4; when content is
taller than the viewport, sticky positioning naturally scrolls with
the page (matching GitHub's profile rail behavior).
Profile page was structurally broken: a stack of full-width sections
(banner, header, campaigns strip, orgs strip, tabs, content) with a
sidebar that only existed beside the tab content. The tab bar split
the page in two unrelated halves and the sidebar floated in the
middle.
Replace it with a GitHub-style two-column shell:
- LEFT: ProfileIdentityRail (new), sticky on lg+. Owns avatar
overlapping the banner via -mt-16, identity (name/NIP-05/website/
bio), badge preview, action bar with auto-flow wrap (Follow /
Donate / ⋯, or Edit profile / QR / ⋯ for own profile), a vertical
stat list (followers / following / campaigns / pledges / raised),
a compact campaigns section (cap 2 + 'See all → Campaigns tab'),
an organizations grid (cap 4 + 'See all' dialog), the freeform
profile fields, and LinkFooter.
- RIGHT: tab navigation and the active tab's content. Tabs stick to
the top of this column only, so they're clearly nav for that
column, not a page-wide divider.
Below lg the grid collapses to a single column and the rail stacks
above the tabs — the avatar still overlaps the banner because the rail
is the first child below it. Reads top-down like a document.
Drops ProfileCampaignsStrip and ProfileOrganizationsStrip as page-wide
strips; their content folds into the rail. Extracts the orgs overflow
modal into a small standalone component (OrganizationsAllDialog) so
the rail can use it without pulling in the strip files.
Trims ProfileOverviewTab to just Recent activity + Recent posts; the
'Featured campaign' section is redundant now that the rail carries
campaigns as standing facts.
Net: ProfilePage.tsx -930 LoC. The shape of the page no longer fights
with the way users read web pages.
The first Search tab is now 'Agora' (kindsOverride [33863 Campaigns,
36639 Pledges, 34550 Groups]) and additionally narrows results to
events whose NIP-89 'client' tag matches the running app name. Ditto's
relay (Agora's primary relay) indexes the multi-letter 'client' tag
server-side, so the constraint is fast and free; relays that don't
index it ignore the filter and return unfiltered results, which is a
graceful degradation rather than a correctness problem.
The second tab is renamed 'Nostr' to make its role explicit: it's the
unconstrained, cross-client firehose. The third tab (Accounts) is
unchanged.
Plumbing:
- useStreamPosts gains a clientName option that adds '#client': [name]
to both the search and stream subscriptions.
- AgoraSearchTab reads config.clientName ?? config.appName from
AppContext and passes it through.
- ?tab=communities and ?tab=activity continue to alias to ?tab=agora
for back-compat with linked URLs.
Empty-state copy and tab/comments are updated.
Three corrections from the previous profile-refocus pass:
1. ProfileOrganizationsStrip is now capped at 4 cards (the design always
intended a single hero row on lg+). Overflow surfaces in a 'See all
N organizations' modal triggered from the section header so a
power-affiliated profile no longer pushes the rest of the page down.
The grid switches to 1/2/4 cols at sm/lg to match the 4-card cap.
2. The supplementary rail moves from the left of the tab content to the
right. The two-column grid template flips from
'[300px_minmax(0,1fr)]' to '[minmax(0,1fr)_320px]', and the JSX
reorders <section> before <aside>. The page canvas widens from
max-w-6xl to max-w-7xl (matching CampaignsPage / AllCampaignsPage),
so on ultrawides the sidebar sits closer to the viewport's right
edge instead of floating in the middle of the content area.
3. Kind 16769 (Profile Tabs) is fully removed from ProfilePage. The
pencil edit-mode, drag-to-reorder via dnd-kit, '+' Add custom tab
menu, ProfileTabEditModal render, ProfileSavedFeedContent renderer,
CORE_TAB_FILTERS map, NoTabsEmptyState, and all backing state
(tabEditMode, localTabs, tabModalOpen, editingTab, dndSensors,
profileTabsQuery, profileTabsData, profileSavedTabs, profileVars,
publishProfileTabs) are deleted. The tab list is now the fixed
DEFAULT_TAB_LABELS shown identically to owner and visitor, with the
four legacy social tabs (Posts & replies, Media, Badges, Likes)
reachable through a single overflow dropdown. This drops six
imports (useProfileTabs, usePublishProfileTabs, ProfileTabEditModal,
useResolveTabFilter, useTabFeed, useActiveTabIndicator), three
dnd-kit module imports, the lucide icons Pencil, GripVertical, Plus,
and the DropdownMenuSeparator unused after the deletion. The shared
kind 16769 infrastructure stays in the codebase because SearchPage
and the people-list features still depend on it.
The 'hasTabs' gate that protected against the legacy empty-tab-list
case is now structurally true and is inlined into the hook calls
(useProfileMedia, useProfileLikesInfinite, useWallComments) and tab
conditional renderers. Net diff: 154 added, 512 removed in
ProfilePage.tsx.
The old Communities tab pinned kindsOverride to [34550] — communities
only. The new Activity tab pins it to [33863 Campaigns, 36639 Pledges]
so /search opens onto Agora's first-class non-kind-1 content. Kind 1
text posts (and their reposts) continue to live on the Posts tab; the
Accounts tab is unchanged.
URL contract:
- The new default is ?tab= absent → Activity.
- ?tab=activity is the explicit form.
- ?tab=communities still resolves (parseTab aliases it to activity) so
existing links don't 404, but they now show the campaigns/pledges
stream rather than a communities-only stream.
Empty-state copy and stale 'communities tab' comments are updated.
Search previously defaulted to 'all kinds' — every kind in the picker
(50+), which means a user typing 'bitcoin' into the search bar got a
firehose of music tracks, podcasts, bird detections, geocaches, and
every other supported NIP. The Posts tab is meant to surface Agora
content; the long-tail kinds belong behind an explicit opt-in.
Introduce 'agora' as a new sentinel value for the Kind filter that
expands to AGORA_PRESET_KIND_VALUES (Campaigns, Pledges, Communities,
Posts, Articles, Events, Polls, Photos, Videos). Make it the default
in DEFAULT_FILTERS, teach parseKindFilter to resolve it, and render
both 'Agora content' and 'All kinds' as top-level selectable rows in
the KindPicker (with the existing Agora-curated quick-list still
appearing as individual selectable items below).
The active-filter chip summary suppresses a chip when the default
'agora' is in effect, surfaces 'All kinds' as a chip when the user
explicitly broadens the search, and continues to show individual kind
labels for everything else.
Removes the border-t border-border line above the global footer and
adds pt-12 so the footer separates from page content via whitespace
instead of a hairline divider.
PostDetailPage wrapped the focused post (with ancestor previews,
ancestor thread chain, and the focused event itself) in one rounded
FeedCard surface and the replies list in another, with an extra
max-w-6xl mx-auto px-4 sm:px-6 outer container on top of the layout's
own column. That made the post detail page look boxy and inset
compared to the Activity feed and the Search results, which render
edge-to-edge in bare divs.
Match the Activity feed pattern exactly: drop every FeedCard wrapper
(focused post + replies + skeleton), drop the redundant outer max-w
+ px container, and let NoteCard's self-applied px-4 py-3 + bottom
border handle the row layout. Replies / reply-skeleton lists become
bare divs (with divide-y on the skeleton, since skeleton rows don't
self-border).
Search results were rendered inside FeedCard — a rounded, bordered,
margin-inset card surface — while the main Activity feed renders posts
edge-to-edge in a bare div. The mismatch made search results look
'boxed in' compared to every other feed in the app.
Replace every FeedCard wrapper on the Search page (Posts, Communities,
Accounts, Follows lists, and their skeletons) with the bare-div pattern
used by Feed.tsx. NoteCard self-applies a bottom border, so the result
lists drop divide-y while skeleton lists keep it (skeleton rows don't
self-border).
The bookmark-style 'Add to feed' button next to the search input has
been removed. Saving a search as a home-feed tab or profile tab is
rarely used and clutters the search bar's filter affordances. The
underlying useSavedFeeds / usePublishProfileTabs hooks remain available
for callers that need them.
Also drops the now-unused SaveDestinationRow helper, the savePopover
state, and a handful of imports (BookmarkPlus, Check, Loader2, User,
TabFilter type, useSavedFeeds, useProfileTabs, usePublishProfileTabs,
useCurrentUser) that only existed to support the removed UI.
Add a Search icon button to the TopNav right cluster (visible on all
breakpoints) that links to /search, so users no longer have to dig into
the mobile drawer or profile menu to reach search.
Surface Agora's main content kinds in the Search filter Kind picker:
- Add Campaigns (33863) and Pledges (36639) to buildKindOptions(), since
they are first-class Agora content but live outside EXTRA_KINDS (which
drives the sidebar / feed-settings UI and shouldn't be polluted with
kinds that don't have their own toggleable feed/sidebar items).
- Group the curated Agora set — Campaigns, Pledges, Communities, Posts,
Articles, Events, Polls, Photos, Videos — under an 'Agora content'
section at the top of the KindPicker dropdown, with the long-tail list
of every other supported kind under an 'All kinds' section below. When
the user types in the search box the partition collapses to a flat
result list.
Re-order CORE_TAB_LABELS so the Agora-native tabs lead and the legacy
social tabs fall back to the overflow menu:
Overview · Campaigns · Pledges · Activity · Posts · Wall (default)
+ Posts & replies · Media · Badges · Likes (overflow)
Overview becomes the default landing tab and replaces 'Posts' as the
first visible card on a fresh profile. CORE_TAB_FILTERS gets matching
NIP-01 filter entries for each new tab so kind 16769 events remain
interpretable by non-Agora clients (Campaigns → kinds:[33863] + author,
Pledges → kinds:[36639] + author, Activity → the Agora-feed kind set
plus #t:agora, Overview → the same Posts shape as a safe default).
The four new tab renderers live in src/components/profile/:
- ProfileOverviewTab — composite: featured campaign + recent Agora
activity preview + 3 most recent posts, with section-level 'see all'
links into the deeper tabs. Empty-state shows own-profile CTAs to
start a campaign / create a pledge.
- ProfileCampaignsTab — full grid of the user's campaigns with
New / Top sort and a Show-hidden toggle gated to the owner + Team
Soapbox moderators. 'Top' sort fans out one receipts query +
per-receipt verification across the visible set via useQueries (no
rules-of-hooks violation), keying into the same caches as
useCampaignDonations so verifier results are shared.
- ProfilePledgesTab — pledges created by the user, split into Active
and Ended groups, with a card visual that mirrors /pledges. 'Pledges
backed' (zapped submissions on others' pledges) is intentionally
deferred to v2 per the design plan.
- ProfileActivityTab — useAgoraFeed scoped to a single author with
infinite scroll; renders the mixed-kind timeline through NoteCard.
The 'I Have No Mouth, and I Must Scream' empty state on profiles whose
kind 16769 publishes an empty tab list is replaced with a neutral
one-liner — the Harlan Ellison quote rotation was an upstream Easter
egg that clashes with Agora's framing.
Insert two responsive hero rows between the profile header and the tab
strip:
- ProfileCampaignsStrip — renders the profile owner's campaigns as a
full CampaignCard grid (1/2/3/4 columns at sm/lg/xl), capped at 6
with a 'View all' link into the (forthcoming) Campaigns tab.
Filters hidden campaigns out for visitors; own-profile sees them so
creators understand their moderation state.
- ProfileOrganizationsStrip — renders orgs the profile founded or
moderates as CommunityMiniCards with a Founder / Moderator badge
overlay. Backed by a new useProfileOrganizations hook that takes any
pubkey and only surfaces public signals — kind 34550 author and
kind 34550 #p moderator entries. The 'follows' axis (kind 10004
bookmarks) is intentionally not shown because bookmarks are private
state that another viewer can't see.
Both strips self-collapse when empty, so a Nostr-native profile with no
Agora activity remains compact.
The body below the tab strip becomes a two-column grid at lg+: a
300 px sticky left rail (the existing ProfileRightSidebar, now
inlined into a grid cell via a new variant='inline' prop) and a
min-w-0 right column for the tab content. Below lg the rail is hidden
and tab content flows full-width — the rail's profile-fields content
is already shown inline in the mobile header, so nothing is lost.
The legacy 'xl:hidden' inline profile-fields breakpoint shifts to
'lg:hidden' so the rail and the inline fields don't double up at lg+.
Replace the kind-1 posting streak chip with Agora-native stat chips
(Campaigns / Pledges / Raised), opt out of FundraiserLayout's
max-w-3xl cap so the profile can use a max-w-6xl canvas like
CommunityDetailPage, and surface a Donate button (single-campaign
direct, multi-campaign dropdown) next to Follow when the profile has
at least one on-chain campaign.
The stat chips wrap on narrow viewports and click through to the
corresponding tab id ("campaigns" / "pledges") — those tabs land
in commit 3.
Adds useProfileCampaignStats, which fans receipt-verification queries
out across the profile's campaigns in parallel using the same kind
8333 -> Esplora verification path as useCampaignDonations.
Drops two leftover Ditto comments (relay.ditto.pub media note,
"Ditto-style profiles" shape cleanup). The dead useLayoutOptions
rightSidebar registration is removed — FundraiserLayout silently
ignored it.
Touches user-facing labels only:
- TopNav: Support -> Campaigns, Organize -> Groups
- Sidebar: Organize -> Groups
- MobileBottomNav: Organize -> Groups
- /communities hero kicker: Organize -> Groups
Routes, hooks, and the country-organizers admin feature
(`OrganizersPage` / `useOrganizers` — a separate concept covering
appointed pinners for country feeds) are left alone. Code comments
referring to the "Organize hero" are kept as-is so future readers can
still find their way around by structural name.
Drops MainLayout's default 600px column cap on /campaigns/all (the same
`noMaxWidth: true` trick the Pledge index uses) and lifts the grid
from `sm:grid-cols-2` to `sm:grid-cols-2 lg:grid-cols-3
xl:grid-cols-4`. Mobile and small tablets stay 1/2 columns so the
cards keep their tappable size; the inner `max-w-7xl` wrapper keeps
the page from sprawling on ultrawide monitors. No hero added — the
existing toolbar still sits directly under the page title.
Skeleton placeholder count bumped 4 → 8 so the loading state fills the
wider grid instead of leaving most of the row empty.
Organizations now use a two-axis moderation model — featured and hidden.
The approval axis is campaign-only. Every Agora-tagged organization is
publicly visible by default; moderators curate by lifting orgs into the
Featured shelf or suppressing them with a Hidden label, nothing in
between.
Concretely:
- CommunityModerationMenu drops the Approve / Unapprove items. The org
side never publishes those labels; useOrganizationModeration's
mutate() rejects them defensively so a stray UI bug can't poison the
label stream with axis-mixed events. The shared ModerationData type
still tracks approvedCoords for symmetry with the campaign side, but
the org UI never reads or writes it.
- /communities loses the in-flight 'Approved organizations' grid that
was never finished and was conceptually wrong here. The moderator-only
rail formerly called 'Pending review' is renamed 'Needs review' and
re-derived as t:agora AND not featured AND not hidden.
Two perf fixes that should make Featured paint noticeably faster:
- The 200-event discovery query and the 2000-event label fold that
power the moderator rails now live INSIDE ModeratorReviewSections.
Non-moderator viewers (the overwhelming majority on /communities)
never trigger either query — they used to fire at the page level
whether they were used or not.
- Every CommunityMiniCard used to subscribe to useOrganizationModeration
individually so it could overlay the Hidden badge and kebab. With
18+ cards per page, that meant 18 component subscriptions to a
query no non-mod cared about. The badge + kebab now live inside a
single CommunityModerationOverlay that's gated on isMod up front
and returns null for everyone else; non-mods never subscribe.
- staleTime on useOrganizationModeration and useFeaturedOrganizations
bumped from 30s to 5m, with a 1-hour gcTime. Moderators feature and
hide on human timescales, not seconds — repeat visits to /communities
shouldn't pay a fresh relay round-trip every half-minute. Local
changes still invalidate the keys explicitly so moderator actions
show up immediately.
Also reverts two pieces of unrelated scope creep I built by mistake:
the 'Approved campaigns' section on the org detail page and the
moderator kebab on the campaign detail page. Those weren't asked for —
they were chasing a misread of an earlier instruction.
NIP.md updated to reflect the campaign-vs-org axis split and the new
'Needs review' surface name.
Three related changes:
1. Pending review on /communities now filters to orgs that carry the
t:agora marker. Without this gate every kind 34550 community on the
network ends up in the moderator queue — badge-gated NIP-72 spaces,
music scenes, anything else — none of which Agora moderators are
expected to triage. ParsedCommunity now carries its source event's
tags so the check can run without re-fetching, and a hasAgoraTag()
helper joins withAgoraTag() in src/lib/agoraNoteTags.ts.
2. Campaign detail page gets the same moderator Feature/Approve/Hide
kebab the campaign cards already have. CampaignModerationMenu drops
into the hero's top-right row next to the creator's Edit/Delete
buttons; it self-gates on Team Soapbox membership and renders null
for non-moderators, so creators who aren't moderators see no change.
Moderation state is read from the same useCampaignModeration cache
the rest of the page consumes.
3. Org detail page gets a dedicated 'Approved campaigns' section that
mirrors the home page's surfacing rule
(approvedCoords ∩ !hiddenCoords). The org's campaigns are split
client-side: approved-and-not-hidden go into the new grid section
above the mixed activity rail, everything else stays in the
existing OfficialActivityShelves to avoid double-rendering. The
section only renders when at least one campaign is approved, so
unmoderated orgs don't show an empty rail.
Per the user's constraint, no org moderation UI is exposed on the org
detail page itself — moderation actions for organizations stay on
CommunityMiniCard (grid cards) only. The detail-page banner dropdown
remains founder-only (Edit, Delete, View leadership).
Mirror the home page's moderator review rails on the organizations page.
Below 'Featured organizations,' moderators (Team Soapbox pack members)
now see two collapsible sections:
- Pending review — kind 34550 orgs with no approve or hide label yet.
- Hidden — orgs whose latest hide-axis label is 'hidden.'
Both sections fetch a 200-event slice via useDiscoverCommunities and
fold it against useOrganizationModeration's approved/hidden coords. The
existing CommunityMiniCard kebab menu lets moderators feature, approve,
hide, or unhide directly from each card.
Sections are collapsed by default when long (>6 orgs), expanded
otherwise — same heuristic as the campaign-side ModeratorSection.
Non-moderators see no change to the page.
Replace the hardcoded FEATURED_ORGANIZATION_AUTHORS allowlist with the
same NIP-32 label flow that curates featured campaigns: Team Soapbox
pack members publish kind 1985 labels in the agora.moderation namespace
tagging an organization's 34550:<pubkey>:<d> coordinate as featured,
hidden, or approved, and useFeaturedOrganizations folds those labels
into the /communities Featured shelf.
The campaign and organization label streams share a single namespace
and a single moderator pack — they're separated purely by which kind
prefix the 'a' tag carries. To keep that contract enforced in one
place, the constants, types, and folding logic are now in
src/lib/agoraModeration.ts; useCampaignModeration and the new
useOrganizationModeration both call foldModerationLabels with their
respective kind. The campaign hook's external surface
(AGORA_MODERATION_NAMESPACE, ModerationLabel, CampaignModerationData)
is preserved via re-exports so existing call sites don't move.
Moderators see a CommunityModerationMenu kebab overlaid on every
CommunityMiniCard exposing approve/unapprove, hide/unhide, and
feature/unfeature. Mounting reads moderation state once per page from
the shared TanStack cache, mirroring CampaignCard. Non-moderators see
no overlay (the menu returns null) and no card affordances change.
The 'My organizations' shelf intentionally ignores moderation — a
user's own founded, moderated, or followed organizations always render
regardless of label state. Only the Featured shelf consumes the
curation rollup.
The Featured grid is uncapped: moderators control how many orgs
surface by labeling, and ordering follows the recency of each
'featured' label so re-publishing bumps an org to the top.
NIP.md's 'Campaign Moderation Labels' section is renamed to 'Agora
Moderation Labels' and documents the kind-34550 coord form and the
'My organizations ignores moderation' rule.
Note: existing surfaced organizations will disappear from the shelf
until a moderator publishes featured labels for them.
Add a 'Delete organization' item to the banner-overlay dropdown on the
community detail page, gated by the existing isFounder check. Clicking
it opens an AlertDialog that publishes a NIP-09 (kind 5) deletion
request referencing the community definition by both 'e' and 'a' tags
via the shared useDeleteEvent hook.
On success we invalidate every org-related query key the hook doesn't
touch — addr-event for this community, community-definition,
manageable-organizations, featured-organizations, and the
followed-organizations sub-queries — then navigate back to
/communities so the user lands on a list that already reflects the
deletion. Errors surface as a destructive toast.
The confirmation copy is explicit about NIP-09's advisory nature
(well-behaved relays will honor it; campaigns and pledges published
under the organization stay on-chain) and points users toward 'Edit
organization' as the non-destructive alternative.
The /communities page previously rendered both 'My organizations' and
'Featured organizations' as horizontally-scrolling shelves of 256px
cards, which hid most content off-screen and made it tedious to browse.
Now both shelves use a new responsive CommunityGrid (1/2/3/4 columns by
breakpoint) inside the existing max-w-5xl content column. Card visuals
stay identical at the desktop breakpoint (~232-256px wide); the only
difference is that cards now wrap onto subsequent rows.
'My organizations' starts collapsed at the first 4 entries with a
'Show N more' / 'Show less' toggle, replacing the prior 18-card cap so
power users can see everything in one place when they want to.
Featured stays a full grid (the curated list is intentionally short)
and its loading skeleton count is bumped from 4 to 8 to fill two rows.
Campaign-launch events (kind 33863) previously fell through to the
generic text-note path in NoteCard, rendering as just the author row
plus the campaign's raw markdown story. No banner, no title, no
progress, no donor count, no goal, no deadline, no link to the
campaign page.
Wire campaigns through the same polished CampaignCard component the
campaign directory already uses:
- New CampaignNoteCardContent component — thin wrapper that parses the
event and renders a CampaignCard. Malformed events silently drop.
- NoteCard: add isCampaign flag, exclude from isTextNote, add the
dispatch branch, and import HandHeart for the action header.
- KIND_HEADER_MAP: 33863 entry gives 'Alice launched a campaign'
header above the rich card (uses publishedAtAction so edits read
'updated a' instead of 'launched a'). nounRoute points at
/campaigns/all so the bold 'campaign' word is clickable.
- CommentContext: register 33863 in KIND_LABELS ('a campaign'),
KIND_SUFFIXES ('campaign'), and KIND_ICONS (HandHeart) so comments
on campaigns render with proper context labels instead of falling
back to 'an unsupported event'.
The whole feed card now links to the campaign's naddr-based detail
route via CampaignCard's existing <Link> wrapper.
Embedded quote-preview rendering for kind 33863 and notification-stack
integration are deferred — out of scope for the activity-feed card.
After paginating the All Nostr feed past a certain depth the visible
items would degenerate back into Agora-only content. The root cause:
- The Nostr (kind 1) firehose is dense — one page of 30 events covers
~minutes of real time.
- The Agora layer is sparse — one page of 25 events covers ~days.
- With lockstep pagination both layers' `until` cursors advanced at
roughly the same rate per page, so after several pages the Nostr
cursor was at -30 minutes while Agora was at -30 days.
- When scrolling down through the chronologically-sorted merge, the
user eventually reached timestamps below the Nostr cursor's reach,
where the only remaining buffered items were old Agora ones.
Fix:
1. Compute the Nostr layer's "floor" (oldest loaded timestamp) and
only render Agora items at or above that floor. Older Agora items
are held back until Nostr catches up.
2. Smarter pagination — in mixed mode, always advance Nostr (it's the
dense layer driving the scroll), but only advance Agora when its
floor is at or above Nostr's. Otherwise we'd fetch Agora pages
that can't be displayed because they sit below the visible window.
3. `hasNextPage` is now Nostr-driven in mixed modes — once Nostr is
exhausted the feed is genuinely done, even if Agora still has more
buffered older items (they were already shown earlier in scroll).
Pure Agora mode is unaffected (still paginates Agora directly).
The All Nostr layer was previously routing through `useFeed('global')`,
which has two problems for a 'show me everything' surface:
- It uses Ditto's `search: 'sort:hot protocol:nostr'` NIP-50 extension.
Relays that don't honor this extension return nothing; relays that do
return only curated 'hot' content, not the firehose.
- It filters by `getEnabledFeedKinds(feedSettings)`, so only kinds the
user explicitly enabled in their feed-kind settings come through.
Replace the indirection with a dedicated `useNostrLayer` infinite query
inside `useMixedFeed` that pulls kinds [1, 6, 16] (notes + reposts) with
no `search:` filter and no kind-settings gate. The result is a straight
chronological pull of recent Nostr activity, which is what 'All Nostr'
should actually mean.
Following mode reuses the same layer with an `authors:` filter, replacing
the previous `useFeed('network')` path. The follow-list gate is preserved
so a logged-in user doesn't briefly see the global mix before their
follows arrive.
The post-to-country picker previously only listed countries the user
followed, so a user following VE couldn't post about Iran without
following Iran first. Two changes fix this:
- New "Choose another country…" item at the bottom of the destination
dropdown opens a searchable CommandDialog over the full COUNTRY_LIST.
Search matches both name and ISO code ("iran" and "IR" both work).
- The dropdown's quick-pick list now also includes any ad-hoc country
the user has selected via the picker, even if not in their follow
list, so they have a one-tap way back to it.
- canChooseDestination no longer requires followedCountries.length > 0;
any logged-in user composing a top-level kind 1 can now pick a country.
- Snap-back guard now only fires when the selected code is invalid
(deleted from the country directory), not just because it isn't in
the follow list.
Also: the orange Post! / Publish poll button text is now forced white
via a className override. Previously it relied on the theme's
--primary-foreground token which was producing low-contrast text on
the orange background.
Five small UX improvements bundled together.
Default post-to-country with localStorage persistence
- New `useDefaultPostCountry` hook (localStorage-backed) hydrates the
composer's destination from a saved preference on every fresh compose.
- ComposeBox's country-destination picker (formerly a shadcn Select)
becomes a DropdownMenu so it can mix country options with an action
item.
- New "Set as default" item appears at the bottom of the dropdown
when the current selection is not already the saved default; clicking
it persists the choice and shows a toast.
- A passive "Country X is your default" label replaces the action
item when the current selection already is the default.
- resetComposeState now resets to the saved default instead of
hardcoded 'world', so the next compose lands where the user expects.
- The existing snap-back-to-world guard now also clears the saved
default if it points at a country the user has just unfollowed.
Remove weather from country feed pages
- Drop `WeatherVitalsRow`'s weather panel — the row keeps the
population / languages / currency vitals but no longer renders
temperature, sky description, or icon.
- Remove the live day/night sky-overlay flip on the country hero;
default to the warm daytime gradient.
- Remove the `PrecipitationEffect` overlay (animated rain/snow) from
country pages.
- Delete the now-orphan `useWeather` hook and
`PrecipitationEffect` component.
Remove organization activity feed from /communities
- Drop the `OrganizationActivityFeed` section and its helpers.
- /communities is now a directory page: hero + My organizations shelf
+ Featured organizations shelf.
- Delete the now-orphan `useOrganizationHomeActivityFeed` and
`useOrganizationMembersOnlyFilter` hooks.
Compact FeedModeSwitcher
- Strip the per-item descriptions ("Campaigns, pledges, donations,
and Agora posts", etc.) from the home-feed mode dropdown.
- Each menu item is now a single line: icon + label + optional check.
- Shrink menu width from w-72 to w-56 to match the new content density.
- Keep the disabled-Following tooltip — that's a state explanation,
not help text.
The Agora activity feed now filters strictly to Agora-created content via
the relay-indexed single-letter `t:agora` tag. Multi-letter tags like
NIP-89 `client` are not indexed by relays and cannot serve this purpose.
Every event Agora publishes that represents first-class Agora content
now carries `["t", "agora"]`, added via a new `withAgoraTag` helper
in `src/lib/agoraNoteTags.ts` that dedupes against any user-supplied
`t:agora` tag.
Tagged at publish time:
- Communities (kind 34550) — CreateCommunityPage
- Campaigns (kind 30223) — CreateCampaignPage, useArchiveCampaign
- Pledges (kind 36639) — CreateActionPage (alongside agora-action)
- Calendar events (kinds 31922 / 31923) — CreateEventPage and
CreateCommunityEventDialog
- Onchain zaps (kind 8333) — useOnchainZap, useDonateCampaign,
SendBitcoinDialog
- Zap goals (kind 9041) — CreateGoalDialog
- NIP-22 comments (kind 1111) — usePostComment, covering every comment
authored from within the app regardless of root kind
- Kind 1 notes — already covered by ComposeBox default tags
Intentionally not tagged: reactions, reposts, follows, profile metadata,
lists, settings, badges, vanish requests, encrypted DMs, live chat.
useAgoraFeed tightened:
- Entity kinds and Agora-comment kinds now require `#t=agora` at the
relay layer (server-side filter).
- World layer (kind 1111 / 1068 with `#k=iso3166|geo`) remains
unfiltered — intentionally cross-client.
- `#Agora`-tagged kind 1 notes still surface from any author (preserves
viral / opt-in discovery via user-typed hashtags).
- Donation enrichment now requires the Agora marker on zap receipts.
- `isRelevantAgoraEvent` rewritten as a strict checker that demands
the marker for everything outside the world layer.
Legacy content without the marker disappears from the feed. It remains
reachable by direct link and via kind-specific directories (e.g.
`/campaigns/all`). Authors who edit a legacy event through the Agora UI
will automatically add the marker via the helper.
NIP.md updated with a new "Agora Content Marker" section under "Agora
Protocols" — documents the tagged-kind table, the untagged-kind list,
the canonical query shape, and the backward-compatibility behavior.
The CountryFlagBackdrop rendered a faded full-width Wikipedia flag image
across the top of every country-rooted (kind 1111, iso3166-rooted) post.
It cluttered the feed and competed with post content. Drop it.
The CountryCommentPill in the upper-right of the card header is retained
— it remains the sole country chrome for world posts.
Removed:
- CountryFlagBackdrop component from CommentContext.tsx
- Both NoteCard render sites (threaded + normal layouts)
- The CountryFlagBackdrop import in NoteCard
- Dead imports in CommentContext: useState, getWikipediaTitle,
customFlagAsset, useFlagPalette, useWikipediaSummary
Updated jsdoc on useIsCountryRooted to reflect that country chrome
is now just the pill, not pill+backdrop.
The home /feed page now offers a top-left dropdown to switch between three
chronological streams:
- Agora: campaigns, pledges, donations, communities, comments on Agora
entities, and #Agora-tagged kind 1 notes (the existing useAgoraFeed mix,
widened for NIP-72 communities).
- All Nostr: the global kind 1 stream interleaved with the full Agora mix.
- Following: same content scoped to authors in the logged-in user's
follow list (gracefully gated when the follow list is loading or empty).
Implementation:
- useMixedFeed orchestrates the three modes, paginating the Agora and
kind 1 layers in lockstep and merging chronologically.
- useAgoraFeed now accepts an optional authors filter (server-side) so
Following mode doesn't fetch and discard the global Agora mix. It also
includes new community definitions (34550) and community-scoped
comments (1111 with A=34550:...).
- FeedModeSwitcher is the new top-left picker: large text2xl trigger,
shadcn DropdownMenu with iconified options and active checkmark.
Following is disabled (with tooltip) for logged-out users.
- AGORA_DEFAULT_NOTE_TAGS moved to src/lib/agoraNoteTags.ts; ComposeBox
now auto-attaches t:agora to every top-level kind 1 from anywhere in
the app (replies, quotes-as-replies, polls, and comments are unaffected).
- Feed mode persistence upgraded from sessionStorage to localStorage so
preference sticks across sessions.
- Feed entry added to TopNav as the first item.
The globe backdrop, hue rotation, and translucent card treatment are
removed for a cleaner solid-background presentation. Specialized
feed pages (kind-specific, tag-filtered) keep the original Follows /
Global tab pair unchanged.
The parenthetical '(<campaign title>)' after 'organizer' was awkward
and redundant -- the donor already knows which campaign they're on.
Strip it and remove the now-unused `campaignTitle` prop from
CampaignWalletDonatePanel.
The 'URL preview' line showed '/<slug>', but campaigns are routed via
NIP-19 naddr (`/:nip19`), not by slug. The actual URL is
'/naddr1...' which only exists after publish. Showing the slug
masquerading as a URL was confidently wrong, so drop it.
Replaces every kind 30223 surface with kind 33863 -- the self-authored
fundraising campaign with a single `w` Bitcoin wallet endpoint. Hard
cutoff: no migration, no dual-read, no legacy support.
Schema (src/lib/campaign.ts):
- `CAMPAIGN_KIND` constant bumped 30223 -> 33863.
- New `CampaignWallet` type with `onchain` (`bc1q`/`bc1p`) and `sp`
(`sp1`) modes, prefix-disambiguated. Bitcoin-mainnet only;
testnet/regtest/lightning prefixes are rejected at parse time.
- New `parseCampaignWallet()` validates bech32 via bitcoinjs-lib for
on-chain addresses and shape-checks silent-payment codes.
- `ParsedCampaign` drops `recipients`, `category`, `tags`,
`location`, `archived`, `image` (-> `banner`), `goalSats` (->
`goalUsd`). Adds `wallet` and `bannerImeta` (parsed NIP-92).
- `CAMPAIGN_CATEGORIES`, `CampaignCategory`,
`LEGACY_CAMPAIGN_CATEGORY_ALIASES`, `getCampaignPrimaryTagLabel`,
`splitDonation`, `minDonationForSplit`, `DonationSplit`,
`CampaignRecipient` removed.
Publishing (useDonateCampaign):
- Single-output PSBT paying `campaign.wallet.value`.
- Kind 8333 receipt has NO `p` tags -- campaigns are not Nostr-identity
recipients. `i`, `amount`, `a`, `K`, `alt` only.
- SP campaigns are refused with a clear error directing donors to
external BIP-352-capable wallets via the on-page QR/copy panel.
Verification (useOnchainZaps.verifyOnchainZap):
- Two modes: identity-recipient (existing `p`-tag derivation) and
campaign-wallet (match outputs against `campaign.w`). The branch is
selected by whether the receipt has an `a` tag pointing at a kind
33863 campaign. SP-targeted receipts are rejected.
Querying (useCampaigns, useAllCampaigns, useCampaignDonations):
- Drop `category`, `recipientPubkeys`, `includeArchived` options.
- `useCampaignDonations` now takes a `ParsedCampaign` and verifies
every receipt on-chain against the campaign's `w` address before
counting it. SP campaigns short-circuit to zeros.
- Search drops `location` and `t`-tag branches; title/summary/story
only.
UI:
- `CampaignCard`, `HeroCampaignSpotlight`, `CampaignsPage`: drop
recipient counts, archived/category badges, `location`; use
`banner`. Silent-payment campaigns render a "Private -- totals not
public" notice instead of progress.
- `CampaignDetailPage`: archive flow replaced with NIP-09 kind 5
deletion. Drops the multi-beneficiary recipient column. Donate column
shows the in-app PSBT donate button (on-chain) plus the always-
available external-wallet QR/copy panel. SP campaigns show the panel
only -- no in-app donate.
- `CreateCampaignPage`: drops Beneficiaries section, tag input, and
USD-to-sats conversion. Adds a Wallet field with mode-aware
validation hint. Goal is integer USD. Banner upload captures NIP-94
tags and converts to NIP-92 `imeta` at publish.
- `DonateDialog`: collapses ~1200 LOC of split logic into a single-
output flow. Form -> Confirm -> Success. Logged-out and signer-
unsupported users are pointed at the external-wallet panel.
- New `CampaignWalletDonatePanel` component (replaces the
pubkey-derived `BeneficiaryDonateDialog`). QR + copy + open-in-wallet
for any `bc1`/`sp1` endpoint, with mode-appropriate privacy notice.
Removed:
- `useArchiveCampaign` hook (closure via NIP-09 deletion only).
- `ClaimPage` and its `/claim` route (claim-for-someone-else flow no
longer applies -- campaigns are self-authored).
- `BeneficiaryDonateDialog.tsx` (replaced by
`CampaignWalletDonatePanel.tsx`).
- Community-donate synthesis hack in `CommunityDetailPage` (no more
fabricating a `ParsedCampaign` from community moderators).
NIP.md was updated separately to specify Kind 33863.
Replaces the kind 30223 Campaign spec with a clean kind 33863 design.
Hard cutoff: no migration, no dual-read, no legacy support.
Key changes:
- Kind number 33863 (FUND on T9 keypad).
- Single `w` tag carries one bech32(m) wallet endpoint. Prefix
selects the mode: `bc1q`/`bc1p` for public on-chain, `sp1` for
silent payments (BIP-352). Other prefixes are rejected.
- Recipient `p` tags removed. Campaigns are self-authored; the
event author is the beneficiary and owns the wallet in `w`.
- No split weights, no dust calculations, no BIP-340/341 Taproot
derivation from Nostr pubkeys.
- `t` topic tags and `agora.category` labels removed. Discovery is
via search, country (`#i`), and moderator curation.
- `image` -> `banner`, with required NIP-92 `imeta` for dim,
blurhash, MIME, and SHA-256.
- `goal` is a single integer USD value, no unit, no currency code.
- `status` tag removed. To close a campaign, publish a NIP-09 kind
5 deletion referencing the campaign's `a` coordinate.
- `location` legacy tag removed.
- Kind 8333 receipts for campaigns carry no `p` tags; verification
matches tx outputs against the campaign's `w` address. SP
campaigns publish no receipts and hide all aggregate UI by design.
Drop the HKDF stretch from both wallets. The 32 bytes of nsec are now
fed straight to BIP-32's master step:
master = HMAC-SHA512("Bitcoin seed", nsec)
bip86 = master / 86' / 0' / 0' / chain / index
bip352 = master / 352' / 0' / 0' / {0',1'} / 0
For silent payments this matches NIP-SP §2.2 exactly — Agora's sp1q…
is now interoperable with every other NIP-SP-compliant client (Ditto,
reference implementations) for the same nsec.
BIP-86 also moves to direct nsec → BIP-32 for symmetry. BIP-32's
hardened derivation at the purpose level (86' vs 352') already
provides cryptographic isolation between the two branches; the HKDF
sub-tags were redundant with that guarantee.
Trade-off: the previous HKDF design domain-separated the Bitcoin
sub-system from the nsec's other uses (Schnorr signing, NIP-04 ECDH,
NIP-44 ECDH). That property is dropped in favor of spec compliance
and recoverability from nsec alone. In practice the operations on
nsec across these protocols are independent enough that no known
interaction leaks the scalar through any one of them.
Existing wallet addresses change (the seed bytes change); since this
feature has no users yet, no migration is needed.
Replace the previous "agora-hdwallet:bip86:v1" HKDF info string with a
two-step HKDF design suitable for proposal as a NIP:
PRK = HKDF-Extract(salt = "NostrWallet", IKM = nsec)
seed_<purp> = HKDF-Expand(PRK, info = "NostrWallet/<Purp>", L = 64)
Registered purposes:
"NostrWallet/Bip32" — generic BIP-32 master (BIP-44/49/84/86)
"NostrWallet/SilentPayments" — BIP-352 master
The salt is a protocol-level constant (no app-specific string), so any
"NostrWallet"-compliant client recovers the same wallets from the same
nsec. Per-purpose info tags give the BIP-86 and BIP-352 wallets
cryptographically independent BIP-32 masters — neither's keys reveal
the other's.
This deliberately diverges from NIP-SP §2.2, which specifies the nsec
itself as the BIP-32 seed for silent payments. The HKDF step preserves
domain separation from every other use of nsec (Schnorr, NIP-04,
NIP-44) at the cost of incompatible sp1q… addresses with §2.2-only
clients. NIP-SP is a draft; this is the design we believe should land.
Remove the Discover page and route entirely. In the top nav, swap the
Discover entry for a Support link pointing at /campaigns/all (the all
campaigns directory).
Also drops the now-orphaned DiscoverHero component and useDiscoverFeed
hook, and updates the NIP.md campaign moderation note that referenced
the deleted /discover route.
Derive a static sp1q… identifier from the user's nsec via BIP-352's
spend/scan key paths (m/352'/0'/0'/0'/0 and m/352'/0'/0'/1'/0), then
bech32m-encode scan_pubkey || spend_pubkey with HRP "sp" and version 0.
The Receive dialog now has two tabs: the existing BIP86 fresh-address
flow and a Silent payment tab that shows the static address with QR
and copy. The SP tab is labelled receive-only — the wallet doesn't yet
scan for incoming silent payments, so funds sent there won't appear in
the balance until scan + spend support is wired in.
/hdwallet was using useBtcPrice, which calls mempool.space's /v1/prices
Esplora extension. That kept the HD wallet quietly dependent on a second
backend (mempool.space) even after the rest of the page moved entirely
to Blockbook.
Blockbook itself ships a getCurrentFiatRates WebSocket method that
returns { ts, rates: { usd: <number> } }. Adding a thin wrapper around
it and a dedicated useHdBtcPrice hook keeps /hdwallet's network surface
contained to the single Blockbook endpoint the user has configured;
errors surface in one place and there's no soft dependency on
mempool.space anymore.
The app-wide useBtcPrice continues to serve /wallet, zap UI, NoteCard,
CampaignCard, etc. — unchanged.
The canonical endpoint Trezor Suite itself uses is btc.trezor.io
(unnumbered). The numbered mirrors (btc1..btc5) resolve to the same
Cloudflare-fronted backend pool but aren't enumerated in Suite's
defaults, so matching Suite's choice is the lowest-surprise option.
Existing users keep their persisted blockbookBaseUrl; only the
out-of-the-box default and docstrings change.
The public Blockbook REST endpoints (btc1.trezor.io etc.) don't send
CORS headers, so browsers reject every response. Blockbook also exposes
a WebSocket API at wss://<host>/websocket — Trezor Suite's actual
production transport — which has no same-origin restriction and lets us
multiplex every request over one persistent connection.
The module keeps its existing public API (fetchXpubSnapshot,
fetchXpubUtxos, fetchFeeRates, broadcastBlockbookTx, fetchBlockbookStatus)
so scan.ts and HDSendBitcoinDialog don't change. Under the hood:
- BlockbookSocket: one persistent WS per base URL, lazy connect,
id-keyed request/response correlation, per-call AbortSignal + timeout,
idle auto-disconnect after 90s, fail-all on close.
- fetchFeeRates now uses a single estimateFee call with blocks=[1,3,6,144]
instead of four parallel REST round-trips. The WS API returns sat/vB
directly, removing the BTC/kB conversion.
- URL transform: https://host -> wss://host/websocket (idempotent).
The HD wallet at /hdwallet now talks exclusively to a single Blockbook
endpoint (default: https://btc1.trezor.io, configurable via AppConfig's
new blockbookBaseUrl). One scan refresh is exactly two HTTP calls
regardless of wallet size:
- GET /api/v2/xpub/<tr(xpub)>?details=txs&tokens=used
Returns account-level balance, the list of used derived addresses
with their BIP32 paths, and the full tx history -- everything we
need to populate the UI -- in one response.
- GET /api/v2/utxo/<tr(xpub)>
Returns the UTXO set with paths attached, so the coin selector
and signer can recover (chain, index) without redoing the
derivation walk.
This replaces the previous Esplora architecture which made dozens of
per-address requests per refresh and routinely tripped mempool.space's
public rate limits.
What changed:
- New src/lib/hdwallet/blockbook.ts: HTTP client for Blockbook's xpub,
utxo, estimatefee, and sendtx endpoints. No failover list, no
fallback to other indexers; errors surface to the user. Per-request
timeout still applies (20s) so a hung connection doesn't lock the UI.
- src/lib/hdwallet/derivation.ts gains accountToBip86Descriptor() which
wraps account.accountNode.publicExtendedKey as `tr(<xpub>)`. The
bare xpub prefix would default Blockbook to BIP44; the `tr(...)`
wrapper selects BIP86 Taproot.
- src/lib/hdwallet/scan.ts is now a thin translator from the Blockbook
response shape into the existing AccountScanResult shape consumed by
the page and the send dialog. The previous gap-walk, snapshot
derivation, cache hydration, and inter-batch pacing are all gone --
Blockbook indexes the xpub server-side. Every server-returned
address is re-derived locally and discarded if it doesn't match, so
a compromised backend can't redirect funds to its own addresses.
- src/lib/hdwallet/snapshot.ts and cache.ts deleted (Esplora-era only).
- useHdWallet drops esploraApis dependency, reads blockbookBaseUrl,
bumps refresh to 60s (was 120s; with only 2 calls per refresh we can
afford it).
- HDSendBitcoinDialog reads fee rates via fetchFeeRates (Blockbook
/estimatefee for blocks 1/3/6/144 in parallel) and broadcasts via
broadcastBlockbookTx. UTXOs come from the shared scan result, no
separate fetch.
- AppConfig: new blockbookBaseUrl: string field, defaulted to
https://btc1.trezor.io in App.tsx, TestApp, and the Zod schema.
/wallet and the rest of the app continue to use Esplora for the
single-address wallet, on-chain zaps, NIP-73 tx/address pages, and
campaign donations. No shared backend abstraction yet; this is
deliberate -- Blockbook's xpub endpoint is unique to HD wallets.
Privacy note: the full account xpub now goes to the configured
Blockbook server on every request. Users who don't want that exposure
can self-host Blockbook and point blockbookBaseUrl at it. Default
remains Trezor's public mirror.
Two bugs working together caused the HD wallet to make ~60 /txs requests
on every page refresh (well over public Esplora rate limits, visible as
clusters of HTTP 429s in devtools).
**Bug 1: cache hydration race.** useHdWallet used a useEffect-driven
ref to mirror the persisted PersistedScan into livePrevRef. On the first
render, useCurrentUser/useNostrLogin hadn't resolved yet, so pubkey was
"" and useSecureLocalStorage returned the default for the unknown
"hdwallet:scan:none" key. The hydration effect ran, populated
livePrevRef with an empty stub, and the "already populated" guard
prevented it from re-hydrating once pubkey became real. Result: every
single page refresh ran a cold gap-limit scan even though localStorage
held a perfectly good cached skeleton.
Fix: drop the effect entirely. Inside queryFn, read the cache directly
via secureStorage.getItem(scanCacheKey(pubkey)). The query is gated on
`pubkey !== ''` so by the time queryFn runs, the key is real. After the
scan completes, both livePrevRef (in-memory) and the persisted copy
are updated. No effect, no race, no flicker.
**Bug 2: burst concurrency.** Even with the cache fixed, a true cold
scan (fresh install) was still firing two chains × Promise.all(5) = 10
in-flight requests at once, plus the warm path's refresh-known-used
and walk-forward-from-index ran in parallel = double again on warm
scans. mempool.space rate-limits at the burst level, so even moderate
concurrency tripped 429s.
Fixes in scan.ts:
- SCAN_BATCH_SIZE 5 -> 3.
- INTER_BATCH_DELAY_MS = 250 between consecutive batches inside one
chain walk, with a sleep() helper that honours the abort signal.
- scanChain warm path: refreshKnownUsed then walkForwardFromIndex,
serially (was Promise.all).
- scanAccount: receive chain then change chain, serially (was Promise.all).
Net effect on a steady-state wallet with the cache populated:
~3-6 requests per refresh (only known-used addresses + tail probe),
paced ~250ms apart, spread over ~1-2s. First-ever cold scan is
~40 requests but paced into 14 batches over ~3.5s, well under any
sensible rate limit.
Also removed the unused EMPTY_PERSISTED_SCAN export from cache.ts
(no longer needed now that useHdWallet reads storage directly).
Reduce cognitive load on the Content settings page by collapsing the
two-section toggle layout, group sub-headers, sub-kind rows, kind
badges, and column headers into a single flat list of 14 toggles
ordered by importance: Posts, Replies, Reposts, Articles, Highlights,
Photos, Videos, Voice Messages, Events, Polls, Organizations, Badges,
Reactions, Zaps.
Each row is now a plain label + one-line description + switch. No
content-kind icons, no [1234] kind-number badges, no Media / Social /
Whimsy sub-headers, no Normal/Short video or Badge Definitions /
Profile Badges / Badge Awards sub-rows (the parent toggle now governs
all sub-kinds together).
Combine kind 6 (Reposted Notes) and kind 16 (Reposted Other Content)
into a single "Reposts" toggle via extraFeedKinds: [16]. The old
feedIncludeGenericReposts flag stays in the schema for backwards
compat but no longer surfaces in UI.
Rename "Comments" -> "Replies" — Nostr's NIP-22 threading is most
naturally called replies.
Strip NIP / kind-number references from all curated descriptions
(NIP-22, NIP-52, NIP-58, NIP-68, NIP-71, NIP-72, NIP-84, NIP-A0,
"kind 30009", etc.). Plain English only.
Merge the standalone /settings/content page (mutes + sensitive
content) into /settings/feed as inline sections under the toggle
list, since both are about "what you see in the feed." Delete
ContentPage.tsx and its route; remove the Content entry from the
settings index. Drop the giant ShieldAlert icon from the sensitive
content intro.
Rename "Home Feed Tabs" -> "Saved Feeds" in the page section heading.
Three layered optimizations to stay under public Esplora rate limits
(mempool.space's ~30 req/min) without giving up the 60s-class refresh feel:
1. Collapse three calls into one per used address.
New fetchAddressSnapshot() (src/lib/hdwallet/snapshot.ts) calls
/address/:addr/txs once and derives AddressData, the simplified
Transaction[], and the UTXO set from the same response. Drops the
separate /address/:addr and /address/:addr/utxo calls the scan
was making per address. UTXOs are reconstructed by spent-output
bookkeeping (output to us minus input from us, the standard
Electrum-style trick). Esplora caps the response at 25 confirmed
txs; we flag that case via `historyCapped` for callers that need
uncapped totals -- our gap-limit scan does not.
2. Incremental warm-scan.
scanAccount(account, esploraApis, signal, prev?) now accepts a
previous result. When supplied, it refreshes only known-used
addresses + a tail past prev.firstUnusedIndex (parallel) instead
of re-walking the full BIP44 gap from zero. The cold path is
unchanged; only the steady-state cost drops.
3. Persist the scan skeleton across reloads.
New src/lib/hdwallet/cache.ts defines a minimal versioned
PersistedScan (used-index lists + firstUnusedIndex per chain),
stored via useSecureLocalStorage keyed by pubkey. useHdWallet
hydrates this into a stub AccountScanResult on mount and feeds
it as `prev` to the very first scanAccount call after a reload
-- so the wallet does a warm scan, not a cold scan, on every
page load after the first ever.
Supporting changes:
- The separate tx-history query is gone; tx aggregation moved into
the pure buildHdTransactions(scan) helper that runs in memory from
snapshot data. Previous implementation duplicated every used
address's /txs fetch every refresh.
- Refresh interval bumped from 60s to 120s. With the incremental
scan it's only ~5 requests/refresh on a steady wallet (down from
~50+).
- Disabled refetchOnWindowFocus on the scan query to avoid a
request storm when the user tabs back in mid-interval.
- HDWalletPage gets an explicit Refresh button (with isFetching
spinner) so users have a manual override now that the auto-refresh
is less aggressive.
The settings UI iterates EXTRA_KINDS and renders a toggle row per kind,
which exposed every Nostr content type the app understands (vines,
treasures, colors, decks, webxdc, birdstar, emoji packs, music,
podcasts, development, etc.) regardless of whether they fit Agora's
activist-utopian framing. The result was a wall of toggles with no
meaningful default.
Add an `agora` boolean to ExtraKindDef and mark only the curated set:
posts, comments, reposts, generic-reposts, reactions, zaps, articles,
highlights, photos, videos (with sub-toggles), voice messages, events,
polls, organizations (NIP-72 communities), and badges. Filter the
"Basic Home Feed Options" and "Show More Content Types" sections to
`def.agora === true`. Move badges from the "Whimsy" section into
"Social" so the Whimsy and Development groups vanish entirely after
filtering.
Enable zaps in the home feed by default (they're core engagement,
not noise) and drop "Disabled by default" from the zaps description.
Other pages (KindFeedPage deep-links, ExternalContentHeader quoted
events, etc.) still see the full EXTRA_KINDS registry, so external
content from non-curated kinds still renders correctly when linked.
Remove the spellbook-themed settings index: drop the "Codex of
Configuration" heading, the gradient ornaments with ✦/◆ dividers, the
sigil that appeared after two minutes of inactivity, and the IntroImage
illustration tiles on every section row and sub-page intro block. The
index is now a flat divider-separated list of labels and one-line
descriptions, with breathing room on both sides.
Delete the Magic settings page, its CursorFireEffect overlay, the
animate-sigil-glow / animate-pulse-slow keyframes, the magicMouse
AppConfig flag (schema, default, test fixture), and the /settings/magic
route. Delete the now-unreferenced IntroImage component and the ten
*-intro.png assets it masked.
Disable content types that don't fit an activist tool by default: vines,
treasures (geocaches + found logs), colors, decks, webxdc, birdstar
(detections / birdex / constellations), emoji packs, custom emojis, user
statuses, music, podcasts, and development. They remain available in
settings — just off out of the box. Highlights is bumped on by default
to pair with Articles. Posts, comments, reposts, articles, highlights,
events, polls, communities, people lists, badges, photos, videos, and
voice messages stay on.
Replace the single `esploraBaseUrl: string` with `esploraApis: string[]`
and route every Esplora REST call through a new `esploraFetch` helper
that handles ordered failover across multiple API endpoints.
The failover client:
- Tries URLs in order with a per-attempt 15s timeout. mempool.space has
a shadowban-style rate-limit behaviour where requests are silently
absorbed and never reply; the timeout converts that hang into a
regular failover signal so the next URL is tried.
- On `429` / `5xx` / network error / timeout, parks the URL in a
module-level cool-down with exponential backoff (30s, 60s, 120s,
240s, 300s cap) and advances to the next.
- Resets a URL's failure count on the first 2xx response, so the
primary comes back into rotation as soon as it recovers.
- Treats configurable `skipStatuses` (e.g. `404` on `/v1/prices`) as
endpoint-capability mismatches: skip without penalising the endpoint.
This lets non-mempool backends like Blockstream coexist in the list
even though they don't expose the price extension.
- Composes a caller-supplied AbortSignal with the per-attempt timeout
via AbortSignal.any. Caller aborts (e.g. TanStack Query queryFn
unmounts) propagate immediately; timeouts mark the endpoint failed
and try the next URL.
- Falls back to cooled-down endpoints when *every* URL is in cool-down,
rather than failing outright.
Default list is mempool.space \u2192 mempool.emzy.de \u2192 blockstream.info.
Every helper in `src/lib/bitcoin.ts`, `src/lib/hdwallet/scan.ts`, and
`verifyOnchainZap` now takes `(input, esploraApis: string[], signal?: AbortSignal)`.
Every TanStack Query caller threads its `queryFn` signal through.
Mutations (broadcasts, send/donate/onchain-zap flows) still call
without an explicit signal but get the 15s per-attempt timeout.
A production-grade BIP86 Taproot HD wallet, separate from the single-address
wallet at /wallet. The seed is derived deterministically from the user's nsec
via HKDF-SHA-256 with an app-specific info string, so there is no new secret
for the user to back up \u2014 if they have their nsec they have the wallet.
Architecture:
- src/lib/hdwallet/derivation.ts \u2014 HKDF seed, BIP86 (m/86'/0'/0'),
receive (0) and change (1) chains, per-leaf P2TR addresses, TapTweaked
signing keys.
- src/lib/hdwallet/scan.ts \u2014 Standard gap-limit (20) scan across both
chains via Esplora. Aggregated UTXO set, balance, and tx history
(merged by txid so send-with-change shows as one row).
- src/lib/hdwallet/transaction.ts \u2014 Largest-first coin selection
(confirmed first), multi-input P2TR PSBT build with per-input
tapInternalKey from re-derived child keys, fresh change addresses on
the internal chain (no address reuse).
- useHdWalletAccess \u2014 Gates on login type === 'nsec'. Extension and
bunker logins keep the key isolated, so the page shows an explanatory
card with a link back to /wallet.
- useHdWallet \u2014 Scan + tx history queries (60 s refresh), persisted
receive-cursor in secure storage (Keychain on native, localStorage on
web), auto-advance when chain catches up so old addresses are never
re-shown.
- HDWalletPage \u2014 Mirrors /wallet's clean UX: big USD balance, send
button, QR + truncated address, 'New address' rotator, collapsible tx
history.
- HDSendBitcoinDialog \u2014 Mirrors SendBitcoinDialog (USD presets, fee
speed picker, two-tap arm for large amounts, raw-address privacy
disclaimer, success screen) but uses the HD UTXO set across many
addresses and signs with per-input HD-derived keys.
The amber warning alert used bg-amber-500/5 (5% opacity — nearly invisible)
and text-amber-900. The faint background combined with potential
tailwind-merge ambiguity between the Alert variant's text-foreground and
the override text-amber-900 resulted in poor readability in light mode.
Align with the established BitcoinPublicDisclaimer pattern:
- bg-amber-500/5 → bg-amber-50 (solid visible amber tint)
- text-amber-900 → text-amber-950 (darkest amber for max contrast)
- border-amber-500/50 → border-amber-300/60 + dark:border-amber-500/30
- dark:bg-amber-950/50 for a distinct dark-mode background
- Icon: !text-amber-500 → !text-amber-600 dark:!text-amber-400
No wallet logic, sweep behavior, or component structure changed.
The useMyCommunities hook was removed in a68cad44. Replace with
useUserOrganizations which returns the same community data (founded,
moderated, and followed organizations) under the UserOrganization type.
SectionHeader has built-in px-4, which stacked with the page container's
px-4 to produce 32px inset on mobile. Override with px-0 so the page
container provides the sole inset, matching the hero card and scroll
items.
New page at /my-square with personal hero, wallet summary, notification
previews, and grouped campaign sections (mine, country, community).
Includes useNotificationPreview hook for lightweight notification
display and a compact layout tweak to CampaignCard.
RoboSats trades Lightning Bitcoin, not on-chain, so a user cashing
out from their Agora address can't drop straight into it. Update the
Donor Guide ('use non-KYC Bitcoin'), the Activist Guide
('Peer-to-peer exchange'), and the cash-out comparison row to spell
out the two-step path: Boltz to swap on-chain → Lightning, then
RoboSats to trade for fiat. Bisq and HodlHodl still trade on-chain
directly and are listed separately.
The previous answer to 'Why doesn't Agora generate a new address for
every donation?' focused on the single-point-of-failure angle. Lead
with the bigger reason: rotating addresses would mean Agora has to
take custody of the Bitcoin before forwarding it on to activists,
which makes Agora a money-transmitting service. That brings
regulatory exposure and creates a real chokepoint someone could
shut down to stop every donation flowing through the platform.
Also reorder the Bitcoin Donations section so 'Why not Monero or
another cryptocurrency?' sits as the very last item, after the
silent-payments and rotating-addresses explanations.
The eyebrow label in the top-right of the guide hero was redundant
with the headline beside it. Cut the eyebrow prop and use the page
name ('Donor Guide' / 'Activist Guide') as the actual headline. The
'Back to Help' chip stays on its own row at the top of the hero.
Replace the plain sticky 'Back to Help' bar on the two guide pages
with a hero section in the same recipe as the Organize and Actions
homepage heros: rotating photo banner (HeroBanner) + atmospheric tint
(HeroAtmosphere) + scrims + overlay copy. Sub-page sized — ~280px
instead of the 460px homepage heros — and embeds a glassy
'Back to Help' chip in the eyebrow row so navigation out stays
prominent without a separate sticky strip.
- Donor Guide reuses the World Liberty Congress photos in /public/hero
with the cool palette: reads as community / supporters.
- Activist Guide reuses the protest cover gallery from
DEFAULT_ACTION_COVERS with the warm hope palette: reads as people in
motion.
Both pages drop into the new shared GuideHero component to keep the
two pages DRY.
FAQ updates in the Bitcoin Donations section:
- 'Why on-chain Bitcoin?' now spells out that on-chain donations
require zero extra setup for activists who already have a Nostr
account and zero extra setup for donors who already hold Bitcoin —
the accessibility argument that makes Agora viable for normal
people.
- 'Why doesn't Agora use Lightning?' now names Strike and Breez
alongside Wallet of Satoshi as examples of the popular custodial
Lightning wallets that can be shut down or pressured.
- New 'Why not Monero or another cryptocurrency?' item: Bitcoin's
adoption is what makes it easiest for donors to send and activists
to receive and spend; niche coins create a barrier neither side
should have to clear.
The Donor Guide and a few related FAQ items singled out Cash App as
the example of a custodial consumer Bitcoin app. Replace those with a
broader list — Cash App, Coinbase, Strike, Venmo, PayPal, Kraken,
Binance — so it doesn't read as picking on one product. The Bisq
Pros/Cons and the 'What consumer apps can't do' section heading
follow the same edit.
Also move the 'Back to Help' navigation on the Donor and Activist
guide pages from a bottom button into a sticky top bar, PWA-style. It
sits right under the TopNav (top-16, z-30) and stays visible while
scrolling so users can return to /help from anywhere on the page
without scrolling back up. Replaces the previous PageHeader, which
hid its back arrow on desktop and made navigating out of the guides
awkward.
The 'What is Agora for?' item duplicated 'What is Agora?' from the
About Agora section. Drop it.
The Network & Safety section was carrying Nostr-protocol explainers
(feed, relays, Blossom, Mastodon/Bluesky comparison, profile fields,
reporting) that aren't core to Agora's donation flow. Drop the whole
section from the visible FAQ.
The six IDs in that section that other pages reference via HelpTip
(fyp, what-are-relays, what-are-blossom, report-content, vs-mastodon-
bluesky, profile-fields) move into the existing hidden 'Legacy'
category alongside the Lightning/zap stubs, so the call sites on
NetworkSettingsPage, ContentSettingsPage, ContentPage, SearchPage,
RelayListManager, and ProfileSettings continue to resolve.
The 'Bitcoin Donations' and 'About Bitcoin Payments on Agora' FAQ
sections were saying overlapping things in two places. Combine them
into a single 'Bitcoin Donations on Agora' section, placed in the
higher slot (right after 'About Agora').
Agora's donation flow is on-chain only, so Lightning and zap items are
removed from the visible FAQ. The two IDs that other pages reference
via HelpTip (send-bitcoin-lightning in ZapDialog, what-are-zaps in
ProfileSettings) are kept in a new hidden 'Legacy' category so those
call sites don't break. HelpFAQSection skips hidden categories on the
default render but the per-ID lookup used by HelpTip still finds them.
Also adds a 'Back to Help' button at the bottom of both the Donor
Guide and Activist Guide pages so users don't have to scroll back up
to the header to navigate out.
The FAQ accordion was carrying a lot of generic Nostr-client content
(profile field formats, app-store availability, Mastodon/Bluesky
comparisons, marketing copy) that wasn't relevant to Agora's core: on-
chain Bitcoin donations to activists.
Restructure into four focused categories:
- 'About Agora' (formerly 'Getting Started') — what Agora is, what
Nostr is, key management, cost.
- 'Bitcoin Donations' — how sending works, the wallet, zaps,
censorship resistance.
- 'Network & Safety' — relays, Blossom, reporting, profile fields.
- 'About Bitcoin Payments on Agora' (formerly 'About Agora') — the
design-rationale Q&A added in the previous commit (why not
Lightning / silent payments / rotating addresses).
All FAQ item IDs referenced by HelpTip on other pages are preserved
(connect-wallet, fyp, profile-fields, what-are-zaps, vs-mastodon-
bluesky, send-bitcoin-onchain, send-bitcoin-lightning, what-is-nostr,
what-are-relays, what-are-blossom, report-content, censorship-
resistance) — their content has been rewritten to be relevant to
Agora's donation flow without breaking the callers.
Also moves the 'Need help? Meet Team Soapbox' follow-pack card from
the top of the page to the bottom, after the FAQ.
Help page now opens with an amber disclaimer that Agora is recommended
only for above-ground activism, followed by two large buttons routing to
new /help/donors and /help/activists guide pages.
The guides cover how on-chain donations work on Agora, why they're
publicly visible on the Bitcoin blockchain and Nostr, and the main paths
for protecting donor privacy or cashing out privately (non-KYC purchase,
coinjoin, Lightning swaps via Boltz, peer-to-peer exchanges like Bisq
and RoboSats). Each tradeoff section is rendered as Pros/Cons bullets.
Also adds an 'About Agora' FAQ category to the existing accordion
covering the design rationale for not using Lightning, silent payments,
or server-rotated addresses.
The inline-markup renderer used by FAQ answers is extracted to
src/lib/helpMarkup.tsx so it can be reused by the guide pages.
Drop the destructive treatment from the disclaimer on campaign donation
surfaces — donating isn't itself dangerous, just publicly traceable, so
red + alert icon + a hard checkbox gate overstated the risk.
Add a 'soft' tone to BitcoinPublicDisclaimer (amber, no icon, no role=
alert) and an includeCashOutAdvice flag so the popover can omit the 'or
cash out at an exchange' line — relevant for the wallet (donor holds
sats) but not for a campaign donor sending money away.
The wallet's Send dialog keeps the destructive variant with the
acknowledgement checkbox unchanged.
User-visible copy now matches the Organization rebrand. Internal
symbols, file names, query keys, routes, and storage keys are
intentionally left alone for this pass — they're still pinned to
"community" / "communities" until a dedicated rename commit.
Touched strings:
- `MobileBottomNav` and `sidebarItems` labels: "Communities" →
"Organize", matching the existing TopNav copy.
- `CommunityDetailPage` hero fallback ("Unnamed Community" →
"Unnamed Organization") and "About this organization" aria-label.
- `CommunityContent` thumbnail fallback name.
- `ExternalContentHeader.CommunityPreview` row label and fallback name.
- `NoteCard` kind-34550 noun "community" → "organization" (used in
feed-card action lines like "created an organization") and the
article switches from "a" to "an".
- `NoteMoreMenu` overflow-menu labels: "Report post to community" →
"Report post to organization", "Remove from community" → "Remove
from organization".
- `BanConfirmDialog` title, description, and success/failure toasts.
- `CommunityContentWarning` reporter pluralization and the
fallback report-type label ("community guidelines" →
"organization guidelines"); reporters are now scoped to founder /
moderators per the commit 4 cleanup, so the wording reflects that.
- `CommunityReportDialog` description copy.
- `CreateGoalDialog` placeholder example.
- `CreateActionDialog` org-scoped description string.
- `CreateCommunityEventDialog` NIP-31 `alt` tag prefix.
- `CommentContext` kind-34550 entries in the action-noun and
rendered-noun maps ("a community" / "community" → "an
organization" / "organization").
- `extraKinds` kind-34550 entry: label, description, and blurb.
- `kindLabels` kinds 4550, 10004, 34550.
- `DiscoverHero` ticker stat copy.
- `GetFeedTool` error message drops "communities" since the
Following feed no longer includes organization activity (removed
in the badge-runtime commit).
Removes NIP-58 badge-award membership validation from Agora's
organization model. Authorization collapses to two roles:
- Founder = author of the kind 34550 event (only the founder can edit
organization metadata, enforced by replaceable-event semantics).
- Moderators = `p` tags with role "moderator" on that event (can hide
content via kind 1984 content-bans; cannot edit the org).
There is no "member" tier any more. The kind 8 / kind 30009 chain
that previously gated discussion access, the members-only feed
filter, the avatar-stack member count, and member-ban moderation
actions are all gone.
What changes:
- `useCommunityMembers` returns `{ founderPubkey, moderatorPubkeys }`
read directly from the parsed community event. The kind 8 badge
query, `resolveMembership`, `isAuthorizedAward`, and the rank-1
member tier are removed. The hook still queries kind 1984 events
scoped to the org so content-bans and soft reports keep working.
- `applyCommunityModerationToEvents` keeps content-ban filtering for
founder/mods. `resolveCommunityModeration` is simplified to a
single pass — only founder/moderators can publish moderation
actions, and the parsed report classification drops the
`member-ban` action since wholesale member bans no longer exist.
- `BanConfirmDialog` drops `mode="member"`. Only event-level content
bans remain. `NoteMoreMenu` drops the "Ban from community"
affordance accordingly.
- `ParsedCommunity` loses `memberBadgeATag` and
`memberBadgeRelayHint`. `parseCommunityEvent` no longer reads the
`['a', '30009:…', '', 'member']` tag. `CreateCommunityPage` no
longer mints a "Member of <org>" badge or attaches the member-badge
tag, and on edit it strips any pre-existing member-badge tag so
legacy wiring doesn't linger.
- The "Voices from everywhere" cross-organization activity feed is
deleted from the Organize page. It duplicated per-org activity (now
served by the official-activity shelves on each org detail page)
and was the only consumer of `useCommunityActivityFeed`.
`useFollowingFeed` drops its community-feed leg for the same
reason. `useMyCommunities`, `useMembersOnlyFilter`, and
`MembersOnlyToggle` go with it.
- `CommunityDetailPage` loses the MembersOnlyToggle in the hero, the
"Add members" and "Edit badge" overflow-menu items, and the
per-member ban affordance in the members dialog. The avatar stack
now renders founder + moderators with a "Founder + N moderators"
label and the dialog title becomes "Leadership".
- Deleted: `AddMemberDialog`, `CommunityBadgePanel` (which exported
`CommunityBadgeEditorDialog`), `useCommunityActivityFeed`,
`useMyCommunities`, `useMembersOnlyFilter`, `MembersOnlyToggle`,
plus three already-unused legacy components (`CommunityChatPanel`,
`CommunityPulsePanel`, `CreateCommunityDialog`) and
`useCommunityChatMessages`. `PersonSearch` is extracted from the
deleted `AddMemberDialog` into its own file so the create-campaign
and create-community forms keep their member-picker UI.
The standalone NIP-58 badge tooling (`BadgesPage`, `GiveBadgeDialog`,
`useBadgeFeed`, etc.) is untouched — it's a separate feature surface
from organization membership and continues to work.
Out of scope: file/symbol renames (Community* → Organization*).
That's the next commit.
Updates the Organize page (/communities) for the organization-first
direction:
- All user-visible "Community" copy becomes "Organization" — page
title, hero subtitle, section headers, empty states, logged-out
prompts, members-only filter messaging, and the create-organization
CTA. The route, file name, and internal symbols are unchanged for
this commit; the codebase-wide rename is the next pass.
- "My organizations" replaces "My communities". Wired to
`useManageableOrganizations` instead of `useMyCommunities`, so the
shelf reflects the same trust model used for implicit org tagging in
the create flows — organizations the user either founded (author of
the kind 34550 event) or is listed as a moderator on (`p` tag with
role "moderator"). The badge-award member-validation path is no
longer surfaced here.
- "Featured organizations" replaces "Discover communities". Backed by
a new `useFeaturedOrganizations` hook with a hardcoded list of
curator-approved kind 34550 event IDs in
`FEATURED_ORGANIZATION_EVENT_IDS`. Pinning by event ID locks the
card to a specific revision; bumping a featured org means swapping
in a newer ID. This is intentionally a stopgap until a configurable
featuring mechanism (AppConfig field, NIP-51 list, etc.) lands.
- The hero stat ticker still rotates donations / orgs / countries, but
the "orgs" entry now counts featured organizations instead of all
discoverable communities.
`useMyCommunities` and `useDiscoverCommunities` stay in place for now
because `useCommunityActivityFeed` and `DiscoverPage` still depend on
them. The badge-award removal pass will follow.
Restructures the org detail page body to match the rhythm already
established on /campaigns/:naddr and /pledges/:naddr:
- Container widens from `max-w-3xl` to `max-w-6xl` and the hero gets
the same `sm:aspect-[21/9]` treatment so it doesn't feel cramped at
the wider viewport width.
- The tab strip is removed entirely. Activity was the only visible tab
and the Pulse / Chat triggers were already hidden. With one section
left there's no reason to keep the `<Tabs>` wrapper or its plumbing
(`activeTab`, `setActiveTab`, `fabAvailable`).
- Below the hero: the existing Donate / Share action row, then the
official-activity shelves, then a new pledge-style engagement card
(stats counters + `<PostActionBar>`), then a NIP-22 comments section.
- No funding progress bar — an organization isn't a fundraising target
itself. Campaigns continue to provide that for fundraising.
- Comments render in the same `rounded-2xl bg-card border` frame the
pledge page uses, with the same "No comments yet" dashed empty
card. The legacy interleaved "initiatives + discussion" feed is
gone — official campaigns / pledges / events now live in the
shelves, and the comments section is purely NIP-22 replies.
- Adds `useEventStats`, `InteractionsModal`, `NoteMoreMenu`, and a
separate `replyOpen` slot for the engagement bar's Reply button
(the FAB's "New post" item keeps its own `composeOpen` slot so the
two entry points don't interleave state).
- Removes the now-unused `<CommunityChatPanel>`, `<CommunityPulsePanel>`,
`<ComposeBox>`, `<NoteCard>`, and `<FeedCard>` imports, along with
the `useCommunityGoals` / `useCommunityActions` / `useCommunityEvents`
hooks and the chain of derived state (`moderatedGoals`, `activeGoals`,
`pastGoals`, `eventItems`, `actionEvents`, `activeInitiatives`,
`pastInitiatives`, `activityItems`) that fed the old feed. Those
components still exist in the codebase for now; commit 4 will prune
the badge-award member runtime separately.
The FAB is now always available (single-column page) and routes
`New campaign` / `New pledge` to their dedicated create pages with
`?org=<naddr>`. `New event` still opens the in-page calendar dialog
since there's no dedicated create page for that yet.
The org detail page now surfaces three horizontally-scrolling shelves
above the activity feed:
- Campaigns (kind 30223)
- Pledges (kind 36639)
- Upcoming events (NIP-52 kinds 31922 / 31923)
All three shelves are powered by the useOrganization* hooks added in
5b5e8fe8, which author-filter to founder + moderators before querying
by the org's uppercase `A` root-scope tag. Anyone can publish an
event with an org's `A` tag, so the author filter is the actual
trust boundary that decides what counts as "official" activity for
that organization.
Each shelf is suppressed entirely when it has nothing to show, so an
org with no campaigns/pledges/events keeps the discussion feed at the
top of the viewport.
The activity-tab FAB now routes campaign and pledge creation to the
dedicated create pages with `?org=<naddr>` in the query string,
closing the loop with the implicit-tagging change from 8bef15a3. The
in-page `CreateGoalDialog` and `CreateActionDialog` are removed —
zap goals are being deprecated in favor of campaigns, and pledges now
go through the dedicated create page. The calendar-event dialog stays
in-page since there's no dedicated create page yet; it already emits
the uppercase `A` tag.
Pulse and Chat tab triggers are hidden from the tab strip while the
organization-first redesign is in progress. The corresponding
`<TabsContent>` panels are left intact so the code paths stay
verified — only the trigger affordances are suppressed.
The stats row had a `pb-2` and the PostActionBar received a `pt-3`
className when stats were present — leftover spacing from when a
horizontal divider sat between them. With the divider removed the
padding became an unjustified gap above the action bar.
Drop both so the action bar sits flush against the stats row with
even top/bottom spacing inside the engagement card, matching the
no-divider look the campaign details page already has when stats
are absent.
Restore the `pt-3 border-t border-border/60` divider/padding
that separates engagement counts from the action bar, identical
to the campaign details page. The earlier `pt-3` without the
border left dead space; with the border back, the two pages
share the same visual rhythm.
Pledge cards render their cover image with a built-in fallback:
when `action.image` is unset or fails to load, they fall back to
`DEFAULT_COVER_IMAGE`. The detail-page hero only handled the
unset case (with a gradient + icon) and never recovered from a
broken URL.
Mirror the card pattern in the hero: render an `<img>` with an
`onError` handler that swaps in `DEFAULT_COVER_IMAGE` on load
failure, so pledges without a cover (or with a dead one) still
look intentional instead of blank.
Remove visual clutter from the pledge detail page:
- Drop the "Pledge" badge and description preview from the hero
overlay so the cover image and title carry the framing.
- Remove the divider between engagement counts and the action bar.
- Drop the "Remaining" and "Stored as sats" tiles from the
funding card — donors only need funded vs. pledged totals.
The hero cover image is unchanged; it sources `action.image`
sanitized by `sanitizeUrl`, which falls back to the gradient
placeholder only when the event has no `image` tag.
Pledges are now open-ended by default — drop the optional start
date input and stop publishing the legacy `start` tag. The kind
36639 `created_at` already marks when the pledge becomes active.
Hide the built-in cover image templates so the upload area is the
only path for cover art, encouraging pledgers to supply their own
visuals. The thumbnail strip remains available via the
`templates` prop on `CoverImageField` for other surfaces.
Rename the description heading from "Story" to "Description"
to avoid implying a narrative expectation, and label the optional
tag input as "Recommended" to nudge categorization.
Mirror the same changes in `CreateActionDialog` (the community
scoped variant) and mark `start` as legacy in `NIP.md`.
Reverts the user-controlled OrganizationSelector added in b3997ec5.
Selecting an organization from inside a create form was the wrong
affordance: if a user starts a pledge or campaign from outside any
organization, the publication is implicitly under their own identity,
and if they start it from inside an organization, the org tag is
already implied by where the create flow was launched.
CreateCampaignPage and CreateActionPage now read `?org=<naddr|aTag>`
from the URL instead of rendering a selector field. When the param is
present, useManageableOrganizations checks whether the current user is
the founder or a moderator of that org; if so, the form emits the
`A` / `K` / `P` tags on publish. If not, the param is silently
dropped (so a stale or copy-pasted link can't forge an org-tagged
event), and a small note explains the publication is going under the
user's account.
OrganizationContextChip is a tiny shared component that surfaces the
resolved org as a non-interactive chip under the form title. The
organization detail page CTAs (next commit) will populate `?org=`
when launching the create flow from inside an org.
OrganizationSelector.tsx is removed — no other call sites.
Adds an optional Organization selector to the create-campaign and
create-pledge forms. Only NIP-72 communities where the current user
is the founder or a listed moderator are offered, so the resulting
event's uppercase `A` root-scope tag lines up with the trust filter
applied by useOrganizationCampaigns / useOrganizationPledges.
Technically anyone could publish a kind 30223 or 36639 with another
org's `A` tag outside the Agora client. Restricting the selector
inside the client is the first line of defense; the author-filter in
the org-detail queries (next commits) is the actual security boundary.
Changes:
- useManageableOrganizations queries kind 34550 events the user
founded (`authors`) or is moderator-tagged on (`#p`), then keeps
only the ones where they're founder OR moderator.
- OrganizationSelector renders those orgs in a popover combobox with
Founder / Moderator badges, plus a 'No organization' option.
- CreateCampaignPage and CreateActionPage gain a new optional 'Publish
under organization' field. On publish, the form emits
`A: 34550:<pubkey>:<d>` plus the `K` and `P` companion tags.
- CreateCampaignPage hydrates the selector from an existing campaign's
`A` tag when editing.
Existing campaigns and pledges without an `A` tag continue to work
unchanged.
Introduces the Agora Organization model on top of NIP-72 communities:
only the founder (kind 34550 event author) can edit metadata, and the
founder plus listed moderators (p tags with role "moderator") can
moderate the feed. Membership/badge-award validation will be dropped
from the runtime path in follow-up commits.
src/lib/communityUtils.ts grows four small helpers:
- isOrganizationFounder
- isOrganizationModerator
- canEditOrganization (founder only)
- canModerateOrganization (founder OR moderator)
- getOrganizationOfficialAuthors
src/hooks/useOrganizationActivity.ts adds three trust-filtered queries:
- useOrganizationCampaigns
- useOrganizationPledges
- useOrganizationEvents
Each query passes the founder-plus-moderators list as the `authors`
filter so forged events tagged with the organization's uppercase `A`
root-scope tag from non-moderator pubkeys never surface as official
activity. This matches the nostr-security skill's guidance on
author-filtering trust-sensitive queries.
The hooks do not yet ship in the Organization page; that wiring is in
the next commit.
Extract the Send dialog's raw-Bitcoin-address privacy warning into a
shared BitcoinPublicDisclaimer component and reuse it across every
on-chain payment surface on campaign pages:
- BeneficiaryDonatePanel (single-beneficiary BIP-21 "Open in wallet",
also used inside BeneficiaryDonateDialog for multi-beneficiary rows).
- DonateDialog FormView: replaces the milder "public, irreversible,
takes time" alert. The irreversible / network-fee note stays as a
separate muted line below.
- DonateDialog ExternalPayView: the logged-out external-wallet
fallback for single-recipient campaigns.
Each surface now gates its primary action (Open in wallet / Review /
Copy payment URI) on the donor checking "I understand this transaction
is public." The acknowledgement resets when the dialog reopens.
"Community-submitted fundraisers approved by moderators" was
procedural; replace with "Help fund the changes worth making" so the
section reads aspirationally rather than as a moderation explainer.
Top (most sats raised) is now the default sort; visiting /campaigns/all
shows the ranked list immediately. The chronological pill is renamed
from Newest to New for brevity.
URL state inverts accordingly: ?sort=none is the explicit value, Top
omits the param to keep the canonical URL clean.
The previous NIP-50 sort:top/sort:hot path against Ditto was a dead end
for kind 30223: Ditto's engagement scoring is built for kind 1 notes
(likes / reposts / replies), none of which apply to fundraising
campaigns. All three sort modes returned indistinguishable output, and
the relay-side search: field returned nothing for campaign content.
Switch to client-side ranking and filtering using campaign-native
signals:
- Sort is now "Newest" (chronological, default) and "Top" (most sats
raised across kind 8333 donation receipts).
- Newest is the default since it gives full relay coverage with no extra
data dependency.
- Top batch-fetches every kind 8333 receipt tagging any visible campaign
in a single round-trip, sums the amount tag, and ranks by total sats
with donor count + created_at as tiebreakers. While scores load the
list stays chronological so the page renders something useful
immediately.
- Search filters client-side across title, summary, story, location,
and t-tags. Substring, case-insensitive.
Drop the Hot mode entirely \u2014 7-day donation activity is too noisy with
current campaign volume to justify a separate UI affordance, and a
single Top vs. Newest choice is clearer.
Remove the page subtitle ("Every campaign published on Agora, including
ones awaiting moderation\u2026") at the user's request.
The hook no longer routes any queries to Ditto's relay group; the
default user-configured pool is used throughout, so campaigns published
only to non-Ditto relays are now discoverable too.
Top/Hot/None sort and a free-text search bar, capped at 2 cards per row.
Top is the default.
Top and Hot use NIP-50 extensions (`sort:top`, `sort:hot`) that Ditto
implements; the page routes those queries (and any free-text search) to
the Ditto relay group via `nostr.group(DITTO_RELAYS)`. None with no
search uses the default user-configured pool so campaigns published only
to non-Ditto relays are still discoverable.
If Top/Hot returns nothing — cold cache, or Ditto doesn't yet weight
engagement for kind 30223 — we silently retry against Ditto without the
`sort:` field rather than show an empty page. Mirrors useMusicFeed.
The search input debounces at 300ms via the existing useDebounce hook.
Sort and search are independent: `search: "<query> sort:top"` works.
URL state (`?sort=top|hot|none&q=<query>`) makes results shareable.
Default values are omitted from the URL so the canonical path stays
clean.
Refactored useCampaigns to expose parseCampaignEvents — a pure helper
that handles the (pubkey, d) dedupe, archive filter, and parse step.
useAllCampaigns reuses it and passes `sortByCreatedAt: false` for
top/hot/search so we don't undo the relay's score order. No behavior
change for existing useCampaigns callers.
Featured was a hardcoded array of naddrs in src/lib/featuredCampaigns.ts.
Promote it to a third moderation axis (`featured` / `unfeatured`)
alongside `approved` and `hidden`, managed by Team Soapbox pack members
through the same kebab menu on each campaign card.
The Featured row on the homepage now:
- Reads from `moderation.featuredCoords`, ordered newest-featured-label first.
- Caps at 4 campaigns.
- Adapts its grid to 1/2/3/4 desktop columns based on count (mobile stays
one column), collapses when nothing is featured, and surfaces the hero
`variant="featured"` card only when exactly one campaign is featured.
- Hide still wins: a featured-then-hidden campaign disappears from the row.
Rename the homepage's second section from "All campaigns" to "Community
Campaigns", which more accurately reflects that it's the approved-not-
hidden set with featured campaigns deduplicated out.
Add a new /campaigns/all page that lists every campaign found on relays
(approved, pending, and unmoderated alike), with a "Show hidden" toggle
that adds hidden campaigns back in. The Discover page's "All campaigns"
link now points here instead of /, since the homepage is no longer a
truly-all view post-moderation. Also surface a small "Browse all
campaigns" link beneath the Community Campaigns grid so the new page is
discoverable from home.
Update NIP.md to document the third axis and the home-page surfacing
rules (Featured row, Community Campaigns grid, Discover shelf).
Delete src/lib/featuredCampaigns.ts entirely — there's no longer a build-
time list to maintain.
Move campaign curation from a hardcoded HIDDEN_CAMPAIGN_COORDS set to a
real moderation system. Team Soapbox (kind 39089 follow pack
k4p5w0n22suf) is the moderator roster; each member signs NIP-32 kind
1985 labels in the agora.moderation namespace to approve or hide a
campaign. The home page and Discore shelf render the approved-and-
not-hidden set; moderators additionally see Pending + Hidden sections
and a per-card kebab menu. Non-moderator authors get a Your Campaigns
section explaining their campaign is live on Nostr but awaiting a
homepage approval.
The goal input accepts USD but the published event stores sats. The UI
gave no indication of this, so the displayed USD goal silently drifted
as BTC price changed — confusing creators.
Create mode: the preview now reads "Saved as X sats · about $Y today.
The USD estimate may change with BTC price."
Edit mode: a goalTouched flag tracks whether the user actually changed
the goal field. If untouched, the submit handler preserves the original
goalSats exactly instead of round-tripping through USD at the current
price. A helper note shows the current saved sats so the creator knows
the field is pre-filled from a reverse conversion.
The previous attempt replaced NoteContent with plain text, losing rich
rendering of hashtags, mentions, custom emoji, and nostr identifiers.
Reverting to NoteContent while keeping the overflow fix needed two
changes to make line-clamp-3 work:
- Render NoteContent as a <span> (as="span") so it participates in
the parent's -webkit-box line counting. The default <div> wrapper
with overflow-hidden created a separate block formatting context
that defeated line-clamp entirely.
- Add disableNoteEmbeds to prevent block-level EmbeddedNote/
EmbeddedNaddr cards from appearing inside the compact preview.
The outer container keeps overflow-wrap-anywhere so long URLs break
safely within the clamped area.
Regression-of: fc950865
Two follow-ups to the new /communities/new page:
1. Stop rendering the founder as an 'anonymous' chip at the top of the
Moderators section. The row was synthesized from just a pubkey
(genUserName fallback, no avatar), which looked broken even though
the founder is always implicitly the first moderator on the
published kind 34550. Drop the row entirely; the founder remains
pubkey #0 in the moderator list when we publish, just isn't
visible as a chip.
2. Wire the page to handle ?edit=<naddr> the same way
CreateCampaignPage handles its edit param:
- Decode the naddr, reject anything that isn't a kind 34550 with
an 'Invalid edit link' guard card.
- Fetch the existing community (inline useQuery against
['community', pubkey, dTag] since there's no useCommunity hook
yet). Show a 'Loading community…' card while it resolves.
- Prefill name, description, image, and moderators when the data
lands. Resolve each moderator's kind-0 profile via the same
two-step cache-then-network pattern campaigns use for recipients,
so chips render with proper avatars and names instead of fallback
stubs.
- Show a 'Community cannot be edited' guard if the viewer isn't
the founder, mirroring the campaign edit author check.
- On submit, fetchFreshEvent + publish kind 34550 with prev. Strip
d/name/description/image/alt and any p-with-role-moderator tags
from the previous tag set; rebuild them from form state, then
re-append the preserved tags (badge a-tag, relay hints, …) and a
fresh alt. The implicit member badge is left alone in edit mode
(matches CreateCommunityDialog's edit branch).
CommunityDetailPage's 'Edit community' dropdown item now navigates to
/communities/new?edit=<naddr> instead of opening CreateCommunityDialog.
The dialog mount and its editCommunityOpen state are removed.
The dialog file is left in the tree even though nothing imports it
anymore — keeping it makes a revert cleaner if the user changes their
mind.
CommunitiesPage used to open CreateCommunityDialog when the user hit
'Create community' in the hero, the My Communities shelf, or the empty
state. Replace those entry points with navigate('/communities/new'),
mirroring how CreateCampaignPage and CreateActionPage already work.
The new page covers the same three NIP-72 fields the dialog handled
(name + description + cover image) plus a Moderators section that
the dialog never exposed:
- Name, with a live URL preview of the derived slug for transparency.
- Description, in the same Textarea shape as the dialog.
- Cover image, via the shared <CoverImageField> so drag-and-drop +
paste-URL behavior matches the campaign and action create pages.
- Moderators, via the same <PersonSearch> CreateCampaignPage uses for
recipients. The founder is pinned at the top of the list as a
non-removable row labeled 'Founder'; PersonSearch is told to exclude
the founder so they can't be added a second time. Extra moderators
go into the kind 34550 event as additional ['p', pk, '', 'moderator']
tags alongside the founder's.
Submit logic is the kind 30009 badge mint + kind 34550 community
publish from CreateCommunityDialog's create branch, including the
d-tag and badge-d-tag collision checks. Cache keys touched on success
match the dialog: ['addr-event', 34550, pubkey, dTag] is seeded,
['my-communities'] and ['community-activity-feed'] are invalidated.
CreateCommunityDialog itself stays in the tree because
CommunityDetailPage still opens it in edit mode. A unified edit page
that folds in 'View members', 'Add members', and 'Edit badge' is
explicitly out of scope for this change.
Both forms had their own CoverPicker / dropzone implementation, and the
two had diverged: the action page learned to accept drag-and-drop while
the campaign page was still click-only. Extract the entire affordance
(dashed dropzone + sanitized preview + remove button + template strip +
URL input) into <CoverImageField> in src/components/, used by both
pages.
The new component takes a controlled value/onChange pair and an optional
templates array, so the campaign page (no templates) and the action page
(six Blossom-hosted defaults) reuse the same dropzone and the same
drag-and-drop, MIME-checked upload path. Clicking a template fills both
the dropzone preview and the URL input from a single source of truth.
While here, dedup three more copy-pastes between the two pages:
- Lift FormSection (the titled section wrapper with the Required /
Recommended / Optional badge) into src/components/FormSection.tsx.
Both pages now import the same component instead of redeclaring it.
- Move getTodayDateInput() into src/lib/dateInput.ts. Both pages need
the same YYYY-MM-DD-in-local-tz string for the deadline picker's min
attribute; keeping it in one place means future timezone tweaks land
in one file.
- Run the action page's coverImage through sanitizeUrl() at submit
time the same way the campaign page does, so a paste-in cover URL
that isn't well-formed https:// drops out of the published 'image'
tag instead of getting written verbatim.
No user-visible behavior change on the action page; the campaign page
gains drag-and-drop and the 'Click or drag an image here' prompt copy.
Net diff: -318 / +13 in the page files.
The template gallery used to point at relative paths under
/challenge-covers/, which meant any kind-36639 event whose author
picked a template published an 'image' tag like
'/challenge-covers/cover9.png'. That string only resolves on Agora's
own origin, so the cover broke as soon as another Nostr client
rendered the event.
Replace each DEFAULT_ACTION_COVERS entry with the public Blossom URL
of the same image so the tag we publish is portable across clients.
DEFAULT_COVER_IMAGE (the fallback for action cards whose author never
set one) now points at the Blossom-hosted Justice image too.
The original /public/challenge-covers/ files are kept in place because
the Actions hero banner still reads them as static assets through Vite.
The dropzone label previously only opened the file picker on click. Wire
up onDragOver / onDragEnter / onDragLeave / onDrop so dragging an image
from the OS file manager (or another browser window) onto the box
uploads it through the same useUploadFile path the click flow uses.
- Highlight the dropzone (border-primary + light primary fill) while a
file is being dragged over it so users get a clear hit target.
- Validate the dropped file's MIME type against the same image/png,
image/jpeg, image/webp set the file input's accept attribute enforces,
so a stray PDF dragged in doesn't get posted to Blossom.
- Update the placeholder copy to 'Click or drag an image here' so
the affordance is discoverable.
- Reject the dropped file silently while an upload is already in
flight (matches the pointer-events-none on the click path).
- Reorder the fields to match how authors think about an action: Title,
Type + Bounty on the same row, Description, Country, Cover image,
Start date + Deadline on the same row, then the Timezone block.
- Default the Type select to "Action" so the most general bucket is
picked unless the author opts into a more specific kind.
- Block past dates in the Deadline picker (min={today}) and reject them
at submit time with a clear error, mirroring CreateCampaignPage.
- Promote Country and Cover image from Optional to Recommended now that
the actions index leans heavily on both for filtering and visual scan.
- Replace the cover field with the campaign-style picker: a clickable
dashed dropzone (image preview + remove button + ImagePlus prompt),
a thumbnail strip in between, and a URL input below. Clicking a
thumbnail just fills the URL input, no permanent default selection
is forced on the user, and the URL stays editable.
- Trim the default cover gallery to seven curated images by dropping
the four overlapping/redundant entries (Protest March, Unity,
Resistance, Change, Demonstration).
The actions index used to open CreateActionDialog from three places (hero
CTA, FAB, empty state). Now all three navigate to /actions/new, which
mirrors CreateCampaignPage's layout — back arrow + page title, a single
rounded form panel of FormSection blocks, and a full-width submit
button — while preserving every field, validation rule, and tag-emitting
behavior of the old modal.
CreateActionDialog itself stays in the tree because CommunityDetailPage
still opens it inline to attach an action to a NIP-72 community.
The rotating banner, spotlight card, and globe markers now all draw
from the hand-picked featured pool only. Previously the spotlight
loop also pulled in every campaign returned by useCampaigns, so the
banner image, spotlight card, and globe pins cycled through community
submissions alongside the two featured slots.
Featured campaigns still flow through the existing country/coords
gate, so anything without a resolvable country is dropped from the
globe — and from the banner — to keep the three in sync.
User flagged that the /i/iso3166:VE page still looked disjointed:
the cinematic hero bled edge-to-edge, the ExternalActionBar sat as
a bare Twitter-style border row below it, the ComposeBox sat raw,
and only the Pinned + Recent feeds were wrapped in their own
FeedCards underneath. Five separate stripes stacked vertically with
no shared container.
Restructure the country branch so the whole surface lives inside
one rounded FeedCard:
- CountryContentHeader (cinematic hero) becomes the top of the card
with its rounded corners clipped by the FeedCard's overflow-hidden.
- Hero gradient's bottom stop changes from hsl(var(--background)) to
hsl(var(--card)) so the fade meets the card surface, not the page
background, eliminating the dark-mode color seam.
- Drop the section's mb-2 since the action bar now sits flush.
- ExternalActionBar sits flush inside the card; its existing
border-b reads as an internal section separator.
- ComposeBox renders with hideBorder + bg-transparent so it inherits
the card surface. Added an optional className prop to ComposeBox
so callers can override the default bg-background/85.
- Pinned section gets a border-t border-border heading band; its
loading state inlines NoteCard-shaped skeletons rather than the
FeedCard-wrapped CommentsSkeleton (which would have card chrome
inside card chrome).
- Recent section's heading becomes a border-t band between Pinned
and Recent when both are present; its loading state also inlines
skeletons; FlatThreadedReplyList sits flush in the card.
- The infinite-scroll sentinel + empty state move outside the card.
URL / unknown content types keep the previous edge-to-edge action
bar treatment (their content headers already render their own
chrome and don't need the unified card).
The previous sweep wrapped vertical NoteCard feeds in FeedCard but
missed three surfaces that share the same Twitter-style edge-to-edge
problem:
- PostDetailPage's focused post (`/nevent…`, `/note…`): the
ancestor previews + AncestorThread + focused <article> all sat
bare on the page background while the replies below were wrapped
in a FeedCard. The main post read like background noise and the
replies read like the actual content. Wrap the entire focused
group (previews + ancestors + focused article — all seven kind
variants funnel through this) in one FeedCard so the page reads
"thread context → this post → replies" as two cohesive cards.
Also wrap the ProfileBadgesDetailView's bare top NoteCard.
- CommunityDetailPage's Activity tab (`/naddr…` community kind
34550): bare `divide-y divide-border` for the activity stream
AND the past-initiatives stream. Replace with FeedCard. The
community page already supplies its own `max-w-3xl px-4 sm:px-6`
wrapper, so the FeedCard opts out of its own margins via `mx-0
sm:mx-0` to avoid doubling up the side padding. The Past-Initiatives
heading gains a `border-t border-border` so it reads as a section
divider inside the card rather than a floating header.
- CommunityPulsePanel: same divide-y → FeedCard fix.
- ExternalContentPage's country pages (`/i/iso3166:VE`): Pinned
posts list was bare while Recent posts below it sat in a FeedCard
— same Pinned-bare / Recent-card mismatch on the same screen. Wrap
Pinned in FeedCard. Add a "Recent" eyebrow heading above the
recent posts feed when both sections are visible, so the two
sections are clearly delineated. Move the Pinned heading's
horizontal padding from `px-4` to `px-4 sm:px-6` so it lines
up with the card margins on tablet+.
After fixing /discover's 'Voices from everywhere' feed, the same
Twitter-style edge-to-edge divided-list pattern was hiding in 24
other feed sections across the app: notifications, profile wall,
search results (posts + accounts + community posts + the empty-state
FollowsList), bookmarks, badges, trends, list members + their posts,
domain feeds, relay feeds, events feed, communities feed, the
Bluesky page, external-content comments + book reviews, post detail
replies (in three render paths), and the FollowPage members tab. On
the GoFundMe-shaped layout they all read as 'random separators
floating in space and posts with no sides.'
Introduce a shared FeedCard component that bakes in the standard
canvas — mx-4 sm:mx-6 rounded-2xl bg-card border border-border/60
shadow-sm overflow-hidden — and replace the bare divide-y wrappers
at all 25 feed-list sites with it. NoteCard rows already self-apply
px-4 py-3 border-b border-border, so live feeds don't need a
divide-y; pure skeleton lists (rows with no own borders) keep
divide-y on the FeedCard className.
Sites intentionally left alone:
- The 8 popover/autocomplete dropdown lists inside max-h-* scrollers
on BlueskyPage, WikipediaPage, BooksPage, and ArchivePage (already
inside bg-popover rounded-lg chrome — double-wrapping clashes).
- CampaignDetailPage's beneficiaries list (already inside a Card,
the divide-y is a section separator).
- CampaignDetailPage's comments card (uses a custom -mx-2 sm:-mx-4
inside the article column; FeedCard's mx-4 sm:mx-6 is wrong for
that nesting).
Also fix TrendsPage's hashtag skeleton: it used a vertical divide-y
list shape but the loaded UI is a flex-wrap of pill badges. Replace
TrendSkeleton with a small h-7 w-24 rounded-full so the skeleton
matches the loaded shape and doesn't pop on transition.
DiscoverPage's 'Voices from everywhere' feed was the page's odd one
out: every section above (country pulse strip, Help raise hope
shelf, Find your people shelf) renders inside its own banded or
rounded canvas, but the Voices feed used the legacy Twitter timeline
treatment — edge-to-edge rows separated by floating divide-y
hairlines with no container chrome. On a GoFundMe-shaped page next
to rounded shelves above, the rows read as 'separators floating in
space and posts with no sides'.
Wrap the feed (loading skeleton, populated, and empty states) in
the same rounded-2xl bg-card border border-border/60 shadow-sm
overflow-hidden surface I introduced for the campaign-page comments
card. Drop the redundant divide-y on the populated state — NoteCard
already self-applies px-4 py-3 border-b border-border, so the
container only needed dividers in the skeleton state where rows have
no border of their own. Add the same border-b to the CampaignCard
branch of DiscoverFeedRow so campaign rows separate correctly
between NoteCard neighbours inside the card.
The first chip-row pass touched the campaign page (PostActionBar +
NoteCard). The post-detail page, book feed, and external/Bluesky
content rows still rendered the old spread-out pill toolbar — visible
when clicking any note from /discover, the book index, or a Bluesky
syndication. Bring them in line:
- PostDetailPage.tsx: replace the five remaining inline action rows
(standard post, repost card, zap card, profile detail, vanish event)
with <PostActionBar event={event} onReply onMore />. Drops ~270
lines of duplicated reply/repost/react/share/more JSX and removes
the now-dead handleShare, repostTotal, encodedEventId locals and
the MessageCircle / MoreHorizontal / Share2 / ReactionButton /
RepostMenu / toast / shareOrCopy imports that fed them. Update the
loading skeleton to use chip-shaped placeholders.
- BookFeedItem.tsx: same migration. The component already had a
bespoke action row that mirrored PostActionBar minus the share
button — using PostActionBar restores parity and removes the
hand-rolled zap branch, the canZapAuthor / isZapped / useUserZap
glue, and the MessageCircle / RepostIcon / Zap / formatNumber
imports.
- ExternalContentHeader.tsx + BlueskyPage.tsx: these can't switch to
PostActionBar because the comment/repost handlers publish to
Bluesky's external semantics, not Nostr. Hand-restyle the rows to
the chip aesthetic instead (h-9 px-3 rounded-full, label fallback
on sm+, share/more pushed right with a flex spacer).
- ExternalReactionButton.tsx: mirror the variant: 'pill' | 'chip'
prop added to ReactionButton in the previous commit, so the
external-content rows can opt into the chip look without affecting
ExternalContentPage's sidebar toolbar which still uses the pill.
PodcastDetailContent, MusicDetailContent, and PhotoBottomBar still
use their own deliberate aesthetics (large circular play button with
matching side buttons; immersive lightbox bar). Left alone — they
don't read as Twitter rows.
The campaign page (and every post feed) inherited Ditto's spread-out
pill action bar — icons across the full width, framed by a heavy
top+bottom border band, with cascading dots on the 'Show more replies'
thread connector. It read as a Twitter toolbar, which is wrong for a
fundraising client.
Restyle the shared action row across PostActionBar and NoteCard:
- Chip-style buttons (h-9 px-3 rounded-full) with inline counts.
- Engagement actions cluster left, share/more pushed right with a
flex spacer instead of justify-between.
- Drop the unconditional top+bottom border band; pages add their own
separator via className when needed.
- Show 'React'/'Reply'/'Repost' word labels on sm+ when the count is
zero, so the bar reads as labelled affordances rather than icon
pills floating in space.
- Add a 'chip' variant to ReactionButton so it can switch between the
legacy pill (still used by PhotoBottomBar, PodcastDetailContent,
MusicDetailContent, BookFeedItem, PostDetailPage, etc.) and the new
chip look.
ThreadedReplyList's 'Show N more replies' button drops the four
cascading dots in favour of a single soft connector that brightens on
hover.
On the campaign page itself, wrap the engagement stats + action bar
in a soft card, add a 'Comments & donations' section heading with a
count, and replace the bare 'No comments yet' line with a dashed
empty-state CTA that opens the reply composer.
The desktop sticky donate column capped its height with
`lg:max-h-[calc(100vh-2rem)] lg:overflow-y-auto`, intending to keep
tall donate cards (QR + many beneficiaries) reachable on short
viewports. In practice this swapped one problem for another: instead
of overflowing the viewport the column rendered with an inner
scrollbar and visually clipped the beneficiary list at the bottom of
the card — exactly the report from the team-soapbox campaign (11
beneficiaries).
Drop the height cap and wrap the column contents in a sticky inner
`div`. While the column is shorter than the viewport it sticks 1rem
below the top as before. When it's taller, the sticky wrapper rides
along with the page scroll until the flex row ends, exposing the
column's bottom via the normal page scroll instead of trapping it
behind a nested scrollbar.
Regression-of: 2bce20ba03
Beneficiary rows on multi-beneficiary campaigns linked to /${pubkey} —
the raw hex pubkey — which falls through the /:nip19 catch-all to the
404 page. Single-beneficiary campaigns hid the beneficiary's profile
preview entirely, on the assumption that the campaign organizer above
the panel was the same person; that's not true when an organizer runs
a campaign on someone else's behalf.
Switch all three call sites — RecipientRow, BeneficiaryDonatePanel's
profile row, and the hero "by {creatorName}" link — to the canonical
useProfileUrl helper, which picks a verified NIP-05 path when available
and falls back to the npub. Drop the hideProfile prop on
BeneficiaryDonatePanel so single-beneficiary campaigns always show the
beneficiary's avatar and name with a working profile link.
Replaces the generic key emoji / lucide Key icons on the welcome,
generate, and secure steps with AgoraBoltIcon (matching the rest of
the app's onboarding chrome). Personalizes the dialog title with the
app name and clarifies the welcome buttons to 'Create a new Nostr
account' / 'Log in to an existing account'.
Replaces the separate Log in / Sign up entry points with one Join
button that opens AuthDialog (welcome -> create-account or log-in).
Drops the full-screen SetupQuestionnaire signup flow, LoginDialog,
SignupDialog, and the useOnboarding context.
Removes InitialSyncGate so the app no longer blocks on initial
encrypted-settings / relay-list / mute-list sync. The same side
effects now run via InitialSyncRunner, mounted alongside NostrSync
at the top of the tree, so settings still get pulled and seeded
into the query cache on login \u2014 just in the background.
The unmount cleanup deferred its store.reset() to a rAF so navigating
pages had a chance to overwrite the store first. The deferred reset
checked whether the store still held this hook's snapshot \u2014 if yes,
reset.
But Suspense (and React StrictMode dev double-invoke) trigger a
cleanup-then-resetup cycle on the same hook instance: the cleanup
fires, schedules the rAF, then the setup re-runs but doesn't write
a new snapshot (shallowEqualOptions sees identical options and
skips). When the rAF fires, the store still has the original
snapshot, so the check passes and the store is wrongly reset \u2014
silently dropping the page's layout options (noMaxWidth,
wrapperClassName, etc.) mid-life.
The symptom: campaign pages and other noMaxWidth pages narrowed to
the default max-w-3xl cap a few seconds after opening, whenever
something downstream triggered a Suspense boundary to re-resolve.
Fix: a per-instance 'unmounting' ref. Cleanup flips it to true,
setup flips it back to false. The rAF bails if the hook re-mounted
in between, so only genuine unmounts trigger the reset.
Regression-of: 2bce20ba
Splits CampaignDetailContent into a main article column (story +
comments + threaded donation receipts) and a sticky right donate
column that holds raised stats / progress / primary CTA / share /
beneficiaries / recent donors. On lg+ the column sticks beside the
article; on mobile it collapses inline below the hero so the donate
CTA stays above the fold.
Extracts CampaignHero, CampaignStory, DonateColumn,
SingleBeneficiaryActions, MultiBeneficiaryActions, DonorPreviewList
as named subcomponents so the two recipient variants stay in one
file but each has a self-contained code path.
The donor list ('Recent donations') shows up to 5 aggregated kind 8333
receipts (amount + relative time, no avatar) with a 'See all' button
that scrolls to the inline activity feed below where every receipt
is already rendered alongside comments.
Drops CampaignProgress in favor of a raw Progress bar so the column's
raised/of-goal text isn't duplicated by the helper's inline label.
The button now lives inside BeneficiaryDonatePanel directly under the
copyable address, so it's always present wherever the panel renders
(inline on single-beneficiary campaign pages, and inside the dialog
for multi-beneficiary campaigns).
Drops the top primary donate button on single-beneficiary campaign
pages \u2014 redundant now that the panel has its own \u2014 and lets the
Share button take the full row.
Regression-of: 6488a0ed
For campaigns with exactly one recipient, the QR + Bitcoin address +
copyable string that BeneficiaryDonateDialog used to host in a modal
is now embedded directly in the 'Beneficiary' section of the campaign
page. The big primary button becomes 'Open in wallet' and links to
the same BIP-21 URI as the inline QR.
Extracts BeneficiaryDonatePanel as the reusable body; the dialog
keeps wrapping it for the multi-beneficiary case where each row's
Donate button still opens a modal.
Regression-of: 69929fc0
- NoteMoreMenu: use overflow-wrap-anywhere so long URLs break safely
within the 3-line post preview instead of overflowing the dialog
- CreateCampaignPage: add min-w-0 to the slug truncate span and show
trailing '...' when the slug was clipped to the 64-char limit
- index.css: move Leaflet top offset from margin-top on individual
controls to top on the .leaflet-top container, preventing the
visual jump that occurred when Leaflet re-rendered controls on zoom
The previous version was overbuilt. Drop the title, description,
recipient identity strip, and the heads-up alert. Keep the QR code,
the copyable address, and the Open-in-wallet button. Remove the
Bitcoin icon from the Donate trigger on the campaign page too.
The required-by-Radix DialogTitle / DialogDescription are kept but
moved to sr-only so screen-reader users still get context.
Each beneficiary listed on a campaign page now has a Donate button
next to it. Clicking it opens a dialog that shows the beneficiary's
Bitcoin (Taproot) address — derived from their Nostr pubkey — as a
scannable BIP-21 QR code and a copyable address.
This is distinct from the existing campaign DonateDialog, which
splits across all recipients. The new dialog targets a single
beneficiary directly, so it intentionally skips amount entry and
the campaign-tally flow.
- Push Leaflet zoom controls below the sticky header on /world so they
remain clickable instead of sitting behind the 64px top bar.
- Replace plain <a> tags with React Router <Link> in the site footer so
navigation to /help, /privacy, /safety, and /changelog is client-side
instead of triggering a full page reload.
- Use plain-text preview with line-clamp-3 in the post more-menu instead
of NoteContent + a conflicting max-h hard clip, so long content
truncates with an ellipsis rather than cutting off abruptly.
- Switch the campaign detail Donate/Share row from a rigid 4-column grid
to flex so the Share button gets its natural width instead of being
cramped into 25% of the row on mobile.
- Make the campaign URL preview in /campaigns/new truncate with an
ellipsis for long slugs instead of overflowing or clipping silently.
A single Bitcoin transaction with N outputs now produces a single kind
8333 onchain-zap event listing every recipient under its own `p` tag,
instead of one event per recipient. The `amount` tag carries the total
sats paid to the listed recipients (the full donation, excluding the
donor's change).
This is straight-forward forward-compatibility: legacy single-recipient
events are just the degenerate case (one `p` tag, amount equal to the
one recipient's slice). Aggregators (`useCampaignDonations`,
`useGlobalDonations`) simplify to summing the `amount` tag across every
matching event — under both schemas an event's `amount` is the total
paid to the recipients listed in that event, so the sum across all
events for a campaign is the campaign total either way.
The verifier (`verifyOnchainZap`) now sums tx outputs paying any listed
recipient's derived Taproot address and strips the sender from the
recipient set so a tx that includes the sender plus legitimate
recipients still verifies. The notifications surface uses a new
`getZapAmountSatsForRecipient` helper to attribute only the viewer's
estimated slice (amount / p_count) rather than crediting them with the
full multi-recipient donation. `CampaignDetailPage` keeps its
group-by-(txid, donor) reply rendering so legacy multi-event donations
still collapse to a single donation card.
Apply getStableCount to the participants/full-state-list section so
per-state numbers are consistent with the leaderboard and distribution
chart. Previously the participants list used raw event-based counts
while the other sections used NIP-45 COUNT floors, causing visible
mismatches (e.g. Miranda showing 847 in the list but 859 in the donut).
In municipalities view this is a no-op — getStableCount returns the raw
feed.count unchanged since there are no per-municipality COUNT queries.
Live/activity indicators still derive from loaded events.
Pass max-w-5xl mx-auto sm:px-6 to PageHeader via its className prop so
the title and action buttons sit inside the same centered column as the
dashboard body. This is a local override — the shared PageHeader
component is not modified.
Add pb-8 to the content container for comfortable breathing room between
the last dashboard section and the footer.
Normalize the dashboard layout and card styling for visual consistency
with the rest of the app. No data fetching or behavioral changes.
Page container:
- Widen content from max-w-4xl to max-w-5xl (justifies noMaxWidth opt-out)
- Add responsive padding (px-4 sm:px-6)
- Replace non-standard pb-24 with pb-16 sidebar:pb-0
- Add min-h-screen to prevent short-page footer ride-up
- Error state now uses the same max-w-5xl container (no layout jump)
Header actions:
- Replace Trash2 icon with PanelLeftClose for sidebar toggle (less alarming)
- Convert sidebar toggle from outline button with text to ghost icon button
- Add aria-label attributes for accessibility
- Tighten gap from gap-2 to gap-1.5 for compact icon-button row
- Move statusBadge/headerActions above the error early-return so both
code paths share the same header
Chart cards (ActivityChart, TopRegionsChart, DistributionDonut):
- Replace raw rounded-2xl border divs with shadcn Card/CardHeader/CardContent
- Picks up consistent rounded-lg, bg-card, shadow-sm, and standard padding
List cards (ParticipantsList, RecentActivityList):
- Normalize from rounded-2xl to rounded-lg with bg-card and shadow-sm
- Preserve overflow-hidden and custom internal grid layouts
Skeleton:
- Add tabs placeholder skeleton
- Use Card/CardHeader/CardContent for chart skeletons
- Normalize table skeleton wrapper to match new card styles
Tabs:
- Add bg-muted/50 to TabsList for subtle visual grounding
Extends the prior Tibet/CountryFlag work so the Snow Lion flag wins
everywhere a CN-XZ post or page surfaces, not just in the Discover
country pulse strip.
- Comment context (NoteCard + PostDetailPage)
* Country pill on the card header swaps in the SVG via
CountryFlag (was bare emoji span).
* Pill hover card uses the subdivision's own name, drops the
parent-country sub-line, and labels it as 'Country' rather
than 'Region' for codes with a custom flag.
* CountryFlagBackdrop (the faded full-bleed flag behind a
country-rooted note) prefers the bundled SVG over the
Wikipedia lead image, which for Tibet returns an
administrative map.
- PostDetailPage 'country above the post' chip
* CountryPreview now routes through CountryFlag and prefers
info.subdivisionName when a custom flag is registered, so
the chip reads as 'Tibet' instead of 'China'.
- Country page (/i/iso3166:CN-XZ)
* Hero banner driven by customFlagAsset(code) when present,
sharing the same <img>+skyOverlay pipeline as Wikipedia
photos so the day/night tint and bottom fade still apply.
* Subline beneath the title no longer falls into the
'subdivision = show parent country' branch for custom-flag
codes; it now reads the Wikipedia description / official
name like other countries do.
* Big flag slot uses CountryFlag too, bypassing the Wikipedia
subdivision thumbnail.
- Helpers split out of CountryFlag.tsx into src/lib/customFlags.ts
(hasCustomFlag, customFlagAsset) so the component file only
exports a component — fixes the react-refresh warning that came
out of the first pass.
- Action cards (feed + detail) now render their country chip
through CountryFlag, picking up the SVG for any future Tibet-
tagged action.
The older Pathos/Agora codebase treated CN-XZ as country-level Tibet
with a bundled Wikimedia Snow Lion SVG (commits f03d2400, 351b3be4,
6e04b80d). That fell out somewhere in the port — restore it.
- public/flag-tibet.svg recovered verbatim from f03d2400.
- New CountryFlag component centralises the country-flag rendering
decision: emoji for everyone Unicode covers, bundled SVG for the
short list of recognised flags that don't have an emoji
codepoint (Tibet today, room for more later).
- CountryPulseStrip special-cases CN-XZ as country-level: renders
'Tibet' (not 'Tibet Autonomous Region, China') and drops the
XZ subdivision-token badge.
Also adds the subdivisionFlag() helper for RGI tag-sequence
subdivisions (England, Scotland, Wales) — Unicode actually does
ship those, and the strip now picks them up automatically.
Other Unicode-missing subdivisions (US states, Canadian provinces)
still render as parent country flag plus a typographic ISO 3166-2
badge. They have no emoji codepoint and bundling a flag pack for
every state is out of scope for this change.
Cap the Discover page at max-w-5xl (down from max-w-7xl) so the
hero, country pulse, and shelves stop sprawling on widescreen
displays. Tighten the mixed feed below to max-w-2xl so each row
reads at a comfortable line length, the same reading column width
as the rest of Agora's NoteCard feeds — while the horizontal
shelves keep their wider canvas above.
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 example in .env.example still suggested ditto.pub as the canonical
share origin. Use agora.spot so contributors copying the example don't
end up generating share URLs that point at a different app.
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.
Renames the Capacitor app identifier from pub.agora.app to
spot.agora.app and cleans up Ditto-branded artifacts that don't refer
to upstream Ditto-the-project or Ditto-stack services.
App identifier (pub.agora.app -> spot.agora.app):
- capacitor.config.ts appId
- android applicationId, namespace, package_name string, custom_url_scheme
- iOS PRODUCT_BUNDLE_IDENTIFIER (Debug + Release)
- public/.well-known/assetlinks.json package_name
- public/.well-known/apple-app-site-association app id
- Info.plist BGTaskSchedulerPermittedIdentifiers and the matching
Swift bgTaskIdentifier (previously mismatched: plist said
pub.agora.app.notification-refresh, Swift said
pub.ditto.app.notification-refresh, so background refresh would
silently fail to register)
- src/lib/helpContent.ts Zapstore URLs
- .gitlab-ci.yml --package_name for fastlane supply
Android Java package (pub.ditto.app -> spot.agora.app):
- Move android/app/src/main/java/pub/ditto/app/ ->
android/app/src/main/java/spot/agora/app/ (4 files: MainActivity,
DittoNotificationPlugin, NostrPoller, NotificationRelayService)
- Update package declarations to match the new Android namespace
(was a hard build failure with namespace = spot.agora.app)
- Update proguard -keep rule
- Update NotificationRelayService ACTION_FETCH intent string
pub.ditto.app.ACTION_FETCH -> spot.agora.app.ACTION_FETCH
Fastlane (pub.ditto.app -> spot.agora.app):
- Appfile, Matchfile, Fastfile provisioning profile specifiers.
Matchfile still points at Soapbox's certificates git repo; a new
match repo with certs for spot.agora.app is required before iOS CI
signing works.
IPA artifact name (Ditto.ipa -> Agora.ipa):
- Fastfile output_name and matching CI artifact paths
- .gitlab-ci.yml: artifacts/Ditto.ipa references and the GitLab
Generic Packages path from /packages/generic/ditto/ ->
/packages/generic/agora/ (matches how APK/AAB are already
published). Existing release artifacts at the old path remain
reachable; new releases land at the new path.
Release-notes script fallback (Ditto vX.Y.Z -> Agora vX.Y.Z):
- scripts/extract-release-notes.mjs fallback used as the App Store /
Play Store 'What's New' blurb when a changelog section has no
summary.
manifest.webmanifest:
- Update related_applications Play Store entry to spot.agora.app.
- Remove the iTunes related_applications entry that pointed at
the existing Ditto App Store listing; not applicable to Agora
until Agora has its own listing.
Capacitor sync incidentals:
- npm run cap:sync picked up @capacitor/barcode-scanner registration
that had been missed in a prior plugin install
(android/app/capacitor.build.gradle, capacitor.settings.gradle,
ios/App/CapApp-SPM/Package.swift).
Intentionally NOT touched:
- ditto.json filename, DittoConfigSchema, DittoConfig, and JSDoc
references to ditto.json. The config-system shape is shared with
upstream Ditto by design.
- relay.ditto.pub, blossom.ditto.pub, ditto.pub/api/* and other
Ditto-stack services Agora actively consumes.
- The DittoNotificationPlugin Android/iOS class name, the
DittoNotification JS bridge name, ditto_notification_config
SharedPreferences keys, ic_stat_ditto drawables, and the
DittoBridgeViewController. Renaming requires a coordinated
JS-side rename plus a SharedPreferences migration or existing
users on the Ditto fork lose their notification config on upgrade.
- Ditto references in skill docs, NIP.md kind comments, README, and
zapstore.yaml attribution \u2014 those correctly describe the upstream
Ditto project that Agora forked from.
Follow-ups required before CI succeeds end-to-end (out of scope here):
- Stand up a new fastlane match git repo containing certs +
provisioning profiles for spot.agora.app, or update Matchfile
git_url to point at it.
- Register spot.agora.app in App Store Connect for team GZLTTH5DLM
and create a new App Store listing.
- Create a new Google Play Console listing for spot.agora.app
(package name is immutable per app on Play; the existing
pub.agora.app listing cannot be reused).
- Re-publish to Zapstore under spot.agora.app so the URLs in
helpContent.ts resolve.
The FundraiserLayout overhaul dropped the bottom nav along with the
rest of the Twitter-style chrome. Bring it back and unhide it above
the 900px sidebar breakpoint so the Search / Communities / Feed /
Notifications / World row is available on every viewport.
- Mount <MobileBottomNav /> in FundraiserLayoutInner, outside the
flex column so its fixed positioning behaves normally.
- Drop the 'sidebar:hidden' class on the nav element.
- Pad the layout root by --bottom-nav-height + safe-area-inset-bottom
so the SiteFooter still clears the fixed bar.
Regression-of: 704cb42e
The layout outlet had no max-width, so pages without their own `max-w-*`
wrapper (e.g. /help, the home feed) stretched edge-to-edge on widescreen
monitors. Add a default `max-w-3xl` cap on the center column and wire
the existing `noMaxWidth` and `wrapperClassName` LayoutOptions through,
so pages that need wider canvases keep working — CampaignsPage,
CampaignDetailPage, CreateCampaignPage, EventDashboardPage, and WorldPage
already opt out via `noMaxWidth: true` or the `fullBleed` preset.
The previous overhaul left the campaigns content nested inside the
Twitter-style three-column MainLayout (LeftSidebar + 600-px center
column + WidgetSidebar + mobile FAB + mobile bottom nav). It looked
like a Nostr client that happened to render campaign cards instead of
a fundraising site.
This commit takes the chrome down to studs:
- New FundraiserLayout: a sticky GoFundMe-style TopNav with logo,
Discover / Start a campaign / About links, the existing LoginArea
on the right (so the avatar dropdown / Log in & Sign up buttons all
keep working unchanged), and a primary "Start a campaign" pill.
Mobile collapses to a hamburger drawer with the same items plus
quick shortcuts to Wallet / Bitcoin / Notifications / Profile /
Settings for logged-in users.
- One full-width content area below the nav and a slim site footer.
No LeftSidebar, no WidgetSidebar, no FAB, no MobileTopBar/BottomNav.
- The old layout still provides LayoutStoreContext / DrawerContext /
CenterColumnContext / NavHiddenContext so every page that calls
useLayoutOptions(...) keeps mounting cleanly. FAB / sidebar /
scroll-direction options are simply ignored.
Routing changes:
- / now renders CampaignsPage directly (instead of dispatching
through a configurable HomePage). /campaigns redirects to /.
- The orphaned HomePage.tsx is removed.
Campaign pages were calibrated for the old 600-px center column.
Re-flowed them to take advantage of the full canvas:
- Hero copy is recentred under max-w-7xl with GoFundMe-style language
("Where successful fundraisers start.").
- Campaign grid grows to four columns on xl screens.
- CampaignDetailPage drops its local sticky sub-header (redundant
under the global TopNav) and the donation rail re-anchors to the
new nav height.
- CreateCampaignPage drops its sticky sub-header and reads as a
proper landing form.
The legacy MainLayout / LeftSidebar / WidgetSidebar / MobileTopBar /
MobileBottomNav / MobileDrawer / FloatingComposeButton components
remain on disk but are no longer mounted; they tree-shake out of the
production bundle.
Pivot the homepage from a Twitter-style social feed to a GoFundMe-style
fundraising hub. Introduces a new addressable kind 30223 "Campaign" that
carries the marketing-style metadata (title, summary, cover image, story,
category, goal, deadline, location) plus a list of recipient pubkeys with
optional split weights. Documented in NIP.md alongside the kind 8333
onchain-zap spec it builds on.
Donations are sent as a single multi-output Bitcoin transaction (one
output per recipient, derived Taproot addresses) using the existing
buildUnsignedMultiOutputPsbt + useBitcoinSigner infrastructure that
backs community on-chain zaps. After broadcast, the client publishes
one kind 8333 receipt per recipient with the campaign's `a` coordinate
so the donation aggregates into the campaign's totals.
UI surfaces:
- /campaigns is now the default homePage. Hero, two featured slots
(placeholders in src/lib/featuredCampaigns.ts), then a grid of
user-submitted campaigns.
- /campaigns/new is a full create form with cover upload, slug
collision check, recipient builder with per-row weights, and
preset/custom donation-amount UX.
- naddr1 identifiers for kind 30223 route to CampaignDetailPage via
NIP19Page (full story rendered through the existing ArticleContent
markdown component, plus a sticky donate rail with progress).
- DonateDialog presets are tuned for on-chain amounts (10K-1M sats)
with a dust-aware minimum guard derived from the split math.
- Fundraisers sidebar item with a HandHeart icon.
Kept the existing social-feed pages addressable from the sidebar; the
overhaul is scoped to the home/landing experience rather than removing
the underlying Nostr features.
- Re-add the Messages item to the left sidebar with its previous
MessageSquareMore lucide icon. Drop requiresAuth so logged-out users
also see the entry — the page is a static recommendation.
- Restore 'messages' to the default sidebarOrder in App.tsx.
- Add WhiteNoiseIcon (the logomark from whitenoise.chat, recolored to
currentColor so it adapts to theme) and use it on the /messages
install-CTA card in place of the generic Lock glyph.
The @samthomson/nostr-messaging library opens fresh NRelay1 sockets per
participant per relay outside the shared NPool, fanning out to every
conversation partner's NIP-65 + NIP-17 inbox relays plus all
discoveryRelays in hybrid mode. In practice this drives connection counts
to several hundred relays per session.
Rather than band-aid the fan-out, drop the feature entirely and point
users to White Noise for end-to-end encrypted Nostr chat.
- Replace /messages with a 'Install White Noise' CTA card (route kept)
- Delete MessagingSettingsPage, DMProviderWrapper, messaging-intro.png
- Remove DMProvider wrapper and PROTOCOL_MODE config from App.tsx
- Drop messaging config from AppConfig, AppConfigSchema,
EncryptedSettingsSchema, EncryptedSettings, and the NostrSync /
useInitialSync sync paths
- Remove messages sidebar entry, default sidebarOrder slot, and
SettingsPage messaging card
- Uninstall @samthomson/nostr-messaging and drop its tailwind content
glob and vitest deps.inline entry
- Update copy in PrivacyPolicy, AdvancedSettings delete-account warning,
ProfileSettings nsec warning, RequestToVanishDialog deletion checklist,
MainLayout comment, and NIP.md
- Leave kind 4 rendering (EncryptedMessageContent) intact so DM events
authored elsewhere still display in feeds and quote embeds
Regression-of: 5b8d2d5c
The previous SW eviction commit wiped caches and called clients.claim()
on activate, but that only changes which SW handles future fetches — it
does not re-render a tab that already finished loading the stale bundle.
In practice, returning users had to manually close and reopen the tab
before seeing the new build.
Fix: after clients.claim(), iterate self.clients.matchAll({ type: 'window' })
and call client.navigate(client.url) on each one. Since this SW has no
fetch handler, the navigation falls through to the network and the tab
re-renders against the fresh index.html + hashed bundle.
Caveats:
- Users mid-interaction (typing a post, scrolling) lose their unsaved
state. Acceptable trade — the alternative is they stay on a broken
cached bundle indefinitely.
- Fires exactly once per user (only on the install -> activate transition
for a byte-different /sw.js). No reload loop.
Also corrected the misleading comment on the main.tsx registration: that
registration is forward-looking insurance for future cache busts, not the
mechanism that evicts the old SW. The browser's own SW update check is
what re-fetches /sw.js out-of-band; our in-page JS never runs on a tab
the old precache SW is controlling.
A previous version of Agora deployed at agora.spot shipped a precaching
service worker that is still controlling returning browsers and serving
them stale HTML/JS — they never see new deploys.
The fix has three parts:
1. public/sw.js — on activate, delete every Cache Storage entry the old
SW left behind. This SW has no fetch handler, so once it takes over
nothing re-populates the cache.
2. src/main.tsx — register /sw.js unconditionally on every web page load.
Previously only usePushNotifications registered it, which meant users
who never visited NotificationSettings stayed pinned to the old SW
forever. Native (Capacitor) skips this — there is no stale SW on the
filesystem origin.
3. .gitlab-ci.yml — the deploy-web rsync was excluding sw.js from the
first pass and never re-adding it to the second pass, so deploys
silently never updated sw.js. Now it ships in the second pass
alongside index.html (after hashed assets land).
test + deploy-web already cover what build-web was doing — the test
stage validates the build via 'npm run test' (which runs vite build),
and deploy-web builds and ships the dist/ to the live site. Keeping
build-web around just burned a runner slot to produce a dist/ artifact
nobody consumed.
Mirrors the venus/rrsync pattern documented in GITLAB_DEPLOY.md and the
deploy job from the old agora-v1 repo. Requires DEPLOY_SSH_KEY and
DEPLOY_TARGET protected CI/CD variables, which have been migrated over
from soapbox-pub/agora-v1.
SubHeaderBar switched to ArcBackground variant="rect" (commit 207794e7)
when navigation was restyled with V-angled bars, but 15 pages still
rendered a 20px ARC_OVERHANG_PX spacer div directly below the tab bar
to leave room for the now-removed downward arc. That spacer is the
empty band the user reported underneath the tabs.
Drop the spacer divs and their now-unused ARC_OVERHANG_PX imports across
Feed, Search, Notifications, Profile, Videos, Photos, Relay, Letters,
Music, ExternalContent, ArticleEditor, PeopleListDetail, BadgeDetail,
Communities, and Badges. FollowPage keeps the import because it still
renders an actual <ArcBackground variant="down" /> at the top of its
profile feed scrollbox.
Lets users post either to the global Nostr feed (kind 1) or to one of
the country communities they follow (kind 1111 rooted on the country
ISO 3166 identifier, mirroring the country page's compose flow).
Layout choices that ended up sticking after iteration:
- Dropdown lives on its own row above the toolbar so the 'Post to'
label can anchor its semantic meaning without competing with the
attach / emoji / mic / poll icons below.
- Trigger renders only the flag emoji to stay compact on mobile;
the open list shows flag + country name so options remain
distinguishable.
- A small help popover next to 'Post to' explains Global vs. country
community for users new to the concept, with the second item's
flag swapping to the currently-selected (or first followed)
country so the explanation feels tangible.
- Toggle only renders when the user is logged in, followed at least
one country, isn't replying, and isn't in poll / customPublish mode.
Resets to Global after each publish so country-mode never sticks
silently across posts.
The gradient surface, ring, shadow, and country label were doing too
much work now that the flag backdrop carries the visual weight of
'this is a country post'. Reduce the pill to just the flag emoji
inside a Link with a hover scale and the existing hover-card preview.
Cleaner read in the card header; the eye sees flag-backdrop fading
into the body of the post, then a clean flag emoji as the
right-anchored 'tap to go to country feed' affordance.
The giant blurred emoji fallback never quite matched the eventual
Wikipedia flag image visually, so when the network was slow the card
would visibly swap from emoji to image. Replace the fallback with the
sampled flag-color gradient (already used by the country pill via
useFlagPalette) which matches the upcoming image's color palette
closely and shares the same opacity/mask shape — so the eventual swap
to the real flag image is seamless.
Also wires the flag-mode foreground treatment through both NoteCard
layouts (normal + threaded): when the backdrop is active, the action
header + author row pick up white text with a strong text-shadow for
readability against the dark wash. The country pill stays scoped out
of that flip so it keeps its own gradient surface.
Includes prior tweaks from this arc: full-width flag banner, taller
backdrop area (h-64 / h-72), mask-image bottom fade, increased dark
wash for text contrast, original (not thumbnail) Wikipedia source for
sharpness, no blur on the image, lighter drop-shadow on the pill's
flag emoji.
The country detail page's hero already uses Wikipedia's page-summary
lead image (which is the flag for country articles) — mirror that
source in CountryFlagBackdrop instead of falling back to the giant
emoji as the primary asset.
useWikipediaSummary is gated by title, so non-country posts never
fetch, and TanStack Query's 24h staleTime / 7d gcTime means a feed of
N posts from the same country only pays the network cost once. The
emoji backdrop is still kept as a fallback while Wikipedia resolves or
when its response has no lead image.
Country-rooted kind-1111 cards now wear a giant blurred flag emoji
anchored upper-right, fading through hsl(var(--background)) before it
reaches the post body. Echoes the country detail hero's
'background + linear-gradient fade' technique, scaled down for a card.
The gating logic for the pill and the backdrop is factored into a
shared useCountryRootContext hook so they always appear or disappear
together. NoteCard's <article> picks up 'relative isolate' so the
absolute backdrop stays contained inside the card it belongs to.
Render each flag emoji to an offscreen canvas, sample the middle stripe,
bucket pixels by hue, and return the top three saturated colors ordered
by their left-to-right position. The country pill uses that palette as
its background gradient so Venezuelan posts get yellow/blue/red,
Brazilian ones get green/yellow, etc.
The work is memoised per-emoji and deferred via requestAnimationFrame.
While the palette is being extracted (or if canvas access fails in a
test/SSR environment), the pill falls back to the primary->accent
gradient. White text gets a drop shadow so it stays legible against
even the lightest flag palettes.
The disc was fighting the gradient surface. Let the flag sit directly
on the primary->accent gradient with a soft drop shadow for depth, tighten
gap, and even out the pill padding.
The two-line stack inside the pill was overkill — gradient surface,
flag disc, and country name already say it. Drop the uppercase tracker
and let the pill breathe.
The flat tinted chip didn't sell 'this is a country-level post'. Replace it
with a gradient pill (primary -> accent) carrying a white flag disc, a
'POSTED FROM' tracker label, and a soft primary-tinted shadow that
intensifies on hover. The country preview popover also picks up a matching
gradient accent bar.
Pill stays anchored to the upper right of NoteCard's header and hides
inside its own country feed, matching the previous visibility rules.
The old 'Commenting on \ud83c\uddfb\ud83c\uddea Venezuela' muted-text row visually
disappeared in the World feed. Swap it for a rounded primary-tinted
pill with a larger flag and the country name in semibold, keeping the
existing hover-card preview. Each country post now has a clear
neighborhood badge anchoring it in the feed without overwhelming
neighbouring cards.
P1546 (motto) is sparsely populated on Wikidata — Venezuela, the UK,
Germany, and many others have no claim even though their Wikipedia
infoboxes carry a motto. Pulling the missing values would require
parsing wikitext infobox templates, which vary per language and per
template version, so the cost outweighs the value of a one-liner.
Remove the field entirely (Wikidata SPARQL OPTIONAL, the CountryFacts
TypeScript field, and the below-hero render block) rather than leave
a tooltip-quality field that works for some countries and silently
omits for others.
Two stacked sub-bars under the hero felt chunky on tall mobile
viewports. Collapsed them into a single flex row: weather + capital on
the left, vitals (population / language / currency) right-aligned via
justify-between. The row wraps cleanly on narrow screens — vitals fall
under the weather group rather than getting crushed beside it.
Combined component (WeatherVitalsRow) renders nothing when both sides
are empty, so countries with no Wikidata facts and no weather still
collapse gracefully to hero + Wikipedia extract.
Wikidata's P37 (official language) is inclusive — it lists every legally
recognised language including signed ones, so Venezuela returns both
Spanish and Venezuelan Sign Language. For the destination header the
user is asking 'what do they speak?' Signed languages are accessibility
metadata, not a postcard answer. Add a FILTER NOT EXISTS on
P31/P279* wd:Q34228 in the SPARQL query so signed languages never reach
the client.
Also gate the px-4 space-y-6 pb-4 wrapper on non-country pages — it was
mounting empty (with bottom padding) on country pages, adding a dead
band between the top of the column and the start of the hero photo.
The country page now mounts the hero flush with the top of the column.
A circular back-arrow button overlaid on the top-left of the hero
photo (mirroring the top-right follow button's white-on-glass style)
replaces the back arrow that previously lived in the page header bar.
Same 'sidebar:hidden' rule the original header used — the back button
hides on wide layouts where the persistent left sidebar already
provides navigation.
URL / ISBN / unknown content types keep the original page header bar
because their content-specific headers don't include a back arrow.
The weather-station city duplicated a less-meaningful place name next
to the country capital and added visual noise (a literal '· Caracas'
next to 'Caracas'). The capital is the stable national place anchor;
the station city is whatever Open-Meteo nearest-match returned and is
rarely the city the user is thinking about.
Capital relocates from the vitals row up onto the weather line — it
reads more naturally next to the current weather-station city than next
to population / language / currency. A Landmark icon distinguishes the
two place names visually when both are present.
The motto moves out of the cramped hero overlay into its own borderless
line below the hero, where it has full column width to read as a proper
national epigraph rather than a 12-pixel afterthought truncated under a
flag.
The Stats pill button is removed from the page header. Stats now live
behind a 'View stats' item in the existing 3-dots menu on the action
bar, which is where users look for secondary actions on every other
page. CountryStatsDrawer is renamed to CountryStatsDialog and converted
to controlled-only (open / onOpenChange props) so the dropdown can drive
its open state without rendering a second visible trigger.
The Wikipedia extract loses its pt-3 — the divide-y border between the
preceding section and the extract was already providing visual
separation, and the extra padding made the extract feel disconnected
from the rest of the header.
Three bugs were stacking on top of each other to make the anthem button
silently do nothing:
1. **Double-encoded URL.** SPARQL returns Commons audio as
`Special:FilePath/<percent-encoded-filename>`. The commonsUrl()
helper re-encoded the already-encoded string, so spaces became %2520
and the resulting URL 404'd. Replaced with two helpers: commonsImageUrl
(decodes then re-encodes, returns https://) and commonsFilename
(decodes once to a plain filename) and routed the anthem through
the latter so we can hit MediaWiki API instead.
2. **OGG Vorbis doesn't play in Safari / iOS WKWebView.** Commons anthems
are almost always Ogg Vorbis; Apple browsers can't decode them. New
useCommonsAudio hook queries the MediaWiki videoinfo API to get the
full derivatives list (original OGG + server-side MP3 transcode) and
renders one <source> per format on the <audio> element. The button
sorts MP3 first so Safari picks the playable one, while Chrome/Firefox
are happy either way.
3. **play() called from a useEffect, not the click handler.** The old
AnthemButton lazy-mounted the audio element on first click via state,
then tried to call play() from an effect after the re-render. By that
point browsers had dropped the user-gesture token and silently
rejected the play promise. Now the <audio> is always mounted with
preload="none" (no bytes fetched upfront) and play() runs
synchronously inside the click handler.
Also added error handling: the play() promise rejection is now caught
and surfaced via toast, the <audio> onError fires a toast, and the
button doesn't render at all when no playable derivative exists, so
the user never sees a dead button.
The fast-facts grid was eight tiles of mixed cognitive weight (capital,
population, area, languages, currency, government, established, demonym).
Demonym, government, area, and inception read as encyclopedic rather than
destination-y, so the row collapses to four signals — Capital · Population
· Languages · Currency — inline with bullet separators instead of a grid.
Renders nothing if all four are missing, so sparsely-documented countries
don't get a half-empty row.
The weather widget loses feels-like, humidity, wind, and the day/night
indicator. The hero gradient and weather icon already signal time-of-day;
the rest belonged in a forecast widget, not a destination header.
Coat of arms moves out of its awkward 'flag-of-row + label' container and
sits inline next to the flag emoji in the hero title block, where it reads
as heraldic identity. Falls back to nothing on image error.
National anthem becomes a small circular play button next to the country
name in the hero — icon-only, with the anthem title in the tooltip. The
\"Play anthem\" placeholder label is gone; the country name is the label.
Result: most countries now render hero + one weather line + one vitals
line + Wikipedia extract. Optional flair (coat of arms, anthem, motto,
official native name) layers in only when Wikidata has it.
Replace the boxed CountryContentHeader with a cinematic edge-to-edge hero
backed by Wikipedia's high-res country photo. The image fades into the page
background via a multi-stop gradient that tints warm amber/rose during the
destination's daytime and deep indigo/violet at night (driven by the existing
weather.isDay signal). Flag, country name, official native name, and Wikidata
motto sit bottom-anchored over the photo with text-shadow for legibility; the
Follow button moves onto the hero in white-on-glass style.
A new useCountryFacts hook fetches richer Wikidata via a single SPARQL query:
capital, population, area, languages, currencies, government type, inception
date, demonym, anthem audio (Commons), coat of arms image (Commons), motto,
and official native names. The hook only runs for sovereign alpha-2 codes;
subdivisions like US-CA fall back to the existing flag/name/Wikipedia path.
Below the hero, the weather widget is reskinned as a borderless inline strip
and a new fast-facts grid surfaces the Wikidata fields without card chrome.
The optional coat of arms renders inline; the optional anthem mounts a lazy
<audio> element only after the user clicks Play, so multi-megabyte OGG files
from Commons aren't fetched for every page view.
The header is lifted out of ExternalContentPage's px-4 wrapper so its edges
bleed flush to the column rails — the 'you have arrived' feeling depends on
the photo touching the rails rather than floating in a padded box.
When a kind 1111 reply rooted to an iso3166 identifier is rendered inside
that same country's feed page, the 'Commenting on <country>' header is
redundant — the post is a top-level neighborhood entry, not a contextual
reply. CountryCommentContext now consults CountryFeedContext and renders
nothing when its identifier matches the surrounding country feed. The
header still appears everywhere else (profiles, notifications, search)
so users can see what a post is rooted to.
Wrap the hero banner and tab strip in a shared image+gradient backdrop
so the banner image continues underneath the tabs and fades into the
page background, removing the hard seam between them. The gradient
holds heavy darkness through the tab strip (kept legible with light
tab text + drop-shadowed underline) and drops to the page bg only at
the very bottom edge.
Reduce banner cognitive load: move the description behind an Info
button next to the title (drop the inline line-clamp and its
ResizeObserver-based clipping detection), promote the avatar stack
above the title row, and shorten the banner aspect ratio (2:1 mobile,
21:9 desktop).
The persistent vaul bottom sheet ate half the map on phones and tablets
and made the docked discovery panel crush the map between 900px and
1280px. Swap it for a centered Dialog modal opened by a single button
anchored top-right next to Leaflet's zoom controls.
The docked WorldDiscoveryPanel now hides below xl (1280px) instead of
the sidebar breakpoint (900px) so the map stays usable when the panel
would otherwise crowd it. CommunityStatsPanel drops its rounded-2xl
card border in compact mode so it doesn't render box-in-a-box inside
the modal or docked panel.
Initial map zoom bumps from 2 to 3 below xl so phones and tablets
don't see ocean bands above and below the world tiles. Default
viewport center moves to Venezuela. Leaflet zoom controls picked up
themed background / foreground / border / primary-on-hover styling to
match the rest of the UI, and the country search header is opaque so
it no longer renders blurry inside the modal.
The community detail page previously fanned out the FAB into a stack of
chips positioned in the page's bottom-right corner, which drifted away
from the actual FAB on desktop (where the FAB is sticky inside the
center column). Move the menu into FloatingComposeButton itself: when a
page declares a `fabMenu` via useLayoutOptions, the FAB renders as a
Radix Popover trigger and the menu opens anchored to it on both mobile
and desktop. Hover state inverts to primary surface + foreground so
icons stop sitting on a same-color background (`--accent` mirrors
`--primary` in this theme system).
Initiatives now renders goals + events as one chronological list. The
sub-toggle is gone; active events sort ascending by start date, then
active goals by newest, then a single Past section in descending order
by closing/end timestamp.
The community detail page now mirrors the adventure-detail / follow-pack
banner pattern: the hero image fills the top area with a gradient
overlay, and the title, description, member avatar stack, follow toggle,
members-only filter, edit, and share controls all sit inside it. The
former Members tab is gone; tapping the avatar stack opens a dedicated
members dialog that hosts the badge panel, leadership and rank-and-file
sections, ban controls, and (for founders/mods) an inline AddMemberPanel
so search-and-add happens in the same surface instead of a second
dialog hop.
To support that embed, AddMemberDialog's form body is extracted into a
reusable AddMemberPanel export; the thin Dialog wrapper is kept for any
existing callers and now delegates submit/reset/close to an onComplete
callback.
Replace the smooth arc shapes shared by the mobile top bar, sub-header
tabs, and bottom nav with angled V polylines centralized in
ArcBackground. The top bar and sub-header now use flat rectangles, and
the bottom nav has a sharp V apex that cradles a centered Agora-bolt
Feed button. The bottom nav row layout changes from
[Home, Search, Notifications, Profile] to
[Search, Communities, _apex_, Notifications, World] with smaller outer
items, and the apex links to the configured home/feed page with
scroll-to-top + invalidation on re-tap.
Also drop the redundant 'Feed' page header on the home feed and the
border under the compact ComposeBox so it blends with the tabs strip
below it.
- useFollowingFeed now also queries posts for the user's followed
hashtag interests (NIP-51 kind 10015 t tags) and merges them into
the combined Following feed, subject to the same recency floor.
- Drop the per-hashtag and per-geotag tabs from the home feed
subheader. Legacy 'hashtag:'/'geotag:' session-storage values fall
back to the Following tab.
- Invalidate the new following-feed query keys when interests change
so the Following feed refreshes immediately on follow/unfollow.
- Remove the now-dead HashtagFeedContent and GeotagFeedContent
components and their unused imports.
- Split the home feed's old Follows tab into 'Following' (combined) and
'Network' (people-only, original behavior preserved).
- Add country follows via NIP-51 kind 10015 i tags (iso3166:XX), with
a Follow/Unfollow button on country pages reusing FollowToggleButton.
- New useFollowingFeed merges network + community activity + followed
country events, sorted strictly by recency. A recency floor (oldest
loaded network item, or now-14d when network is empty) prevents
sparse sources from surfacing old events too early.
- Empty state on Following is country-centric and routes to the World
tab to encourage country discovery.
- Invalidate the new feed query keys on follow/unfollow and
community-bookmark mutations.
The default behavior of waiting for an explicit commit request leads
to leaving the working tree dirty between turns. Agora's expectation
is the opposite — finish a task, commit the result, let the user
decide when to push.
Agora is a fork of Ditto and periodically pulls in upstream changes.
The skill documents the remote setup, fetch/merge/validate flow, and
— most importantly — Agora's deliberate divergences (no Blobbi, no
onchain wallet, Breeze Lightning only) so future conflict resolution
sides with Agora's direction instead of silently reintroducing removed
features.
Two post-merge regressions from the ditto/main merge:
1. Left sidebar collapsed to icon-only column at desktop widths.
Ditto's responsive aside (`hidden sidebar:flex ... lg:w-1/4
lg:max-w-[300px]`) sizes itself off the flex parent. Agora wraps
`<LeftSidebar />` in an extra `<div className="hidden
sidebar:block">`, which had no width — so `w-1/4` computed
against zero and the sidebar collapsed.
Removed the wrapper div in MainLayout — the `<aside>` now handles
its own hiding and width directly inside the flex parent, matching
Ditto's structure.
2. Kind 3 contact lists rendered as empty/broken cards.
Ditto unified kinds 3/30000/39089 under `PeopleListContent` (uses
`parsePeopleList` which synthesizes a "{Name}'s follows" title
for kind 3 since the event carries no title/description/image of
its own). Agora's NoteCard still used the old `FollowPackContent`
path that only matched 30000/39089, so kind 3 fell through to the
default note render.
Swapped `FollowPackContent` for `PeopleListContent` and added
kind 3 to the `isFollowPack` check.
Regression-of: 740fc1c6
Left over from the ditto/main merge — version was bumped back to
2.8.0 (Agora's version) in package.json post-merge but the lockfile
wasn't regenerated until npm install ran.
Two new Feed-section toggles in Content Settings, both disabled by
default (existing users don't suddenly get a noisy feed of every like
and zap their follows hand out):
- Reactions (kind 7)
- Zaps (kind 9735 Lightning + kind 8333 on-chain — one combined
toggle since users don't think in terms of payment rails)
When enabled, reactions and zaps from followed users surface in the
Follows feed as a header above the target post — same shape as the
existing kind 6 / 16 repost overlay ("X reacted to" / "X zapped
1,234 sats" / "X reposted"). The reaction overlay renders the kind 7
event's actual emoji via ReactionEmoji (handling unicode, "+"/"-"
likes, and NIP-30 custom emojis) rather than a generic smiley. The
target event is unwrapped by useFeed and useProfileFeed in a single
batched ids query, then deduped so a direct post always wins over any
overlay for the same event.
The verb in each overlay header is a Link to the underlying reaction
/ repost / zap event's /:nip19 page, matching the new behavior in
Notifications. Reposts now carry the wrapper event (`repostEvent`)
through FeedItem so this works for them too without a separate fetch.
Global feed continues to exclude reposts, and now also excludes
reactions and zaps for the same reason — they need an author filter
to be useful and would otherwise drown out direct posts.
Some LNURL providers omit the `amount` tag entirely and only encode
the value inside the bolt11 invoice. NotificationsPage's local
`getZapAmountSats` didn't parse bolt11, so those zaps showed up as
"X zapped you" with no number. Move the helper to a shared module
and route the 9735 branch through `extractZapAmount`, which already
falls back amount tag → description JSON → bolt11.
While in this code, wrap the "reacted to", "reposted", and "zapped"
verbs in a Link pointing at the underlying event's nevent so readers
can jump straight to the reaction / repost / zap detail page.
Splits the existing Follow All button on people-list, follow-pack, follow-set, badge, and Team Soapbox detail views into a primary Follow All + a caret dropdown whose lone option is Mute All. Mute All opens an AlertDialog and, on confirm, merges every pubkey in the list into the viewer's NIP-51 kind 10000 mute list. Lets you treat any people list as a mute source as well as a follow source.
To make mute meaningful for users who already followed someone before muting, follow-scoped feed queries now subtract muted pubkeys from the authors filter at query time, via a shared useMutedAuthorFilter hook that builds the muted-pubkey Set once per mute-list change and exposes a single excludeMuted helper. The hook replaces ten inline copies of the same filter — including two that allocated a new Set per follow-list element inside a .filter() callback. Render-layer mute filtering stays in place as a second line of defense.
Also adds reusable hooks for the bulk operations (useFollowActions.followMany, useMuteList.muteManyPubkeys) and replaces four duplicated inline Follow All implementations with a single FollowAllSplitButton component.
Hardcoded MEMPOOL_API constant in src/lib/bitcoin.ts becomes a baseUrl
parameter on every fetch helper, sourced from a new `esploraBaseUrl`
field on AppConfig (default `https://mempool.space/api`). The wallet,
zap dialogs, on-chain zap verification, and NIP-73 Bitcoin tx/address
pages now read the URL from useAppContext and pass it through, so
self-hosted Esplora deployments (or Blockstream's) work without code
changes. The mempool.space-specific `/v1/prices` extension is still
appended by fetchBtcPrice.
Use relay-side COUNT as a stable floor for totalPosts and state-level
leaderboard/distribution counts. Falls back gracefully to event-based
counts if the relay does not support NIP-45. Surfaces legacy
content-scan municipality matches as a separate hint on the KPI tile.
- totalPosts: globalCount ?? viewPosts.length
- Leaderboard/distribution: Math.max(eventCount, stateCountFromRelay)
- Participants: stays fully event-based (preserves live/activity semantics)
- Municipalities: stays fully event-based (no per-muni COUNT queries)
- legacyDetected: deduplicated count of posts attributed via content scan
The release-summary paragraph (max 500 chars by convention) skipped the
truncation that the legacy first-bullet fallback applied, so toasts could
render an entire paragraph. Truncate both branches uniformly on a word
boundary with an ellipsis, matching the prior 60-character cap.
Regression-of: d044218c
Consolidates Bitcoin-related HTTP onto a single host — the rest of the
wallet already uses mempool.space for addresses, txs, UTXOs, fees, and
broadcast — so dropping the CoinGecko dependency removes one external
service from the connect-src surface and simplifies CSP / privacy review.
mempool.space's /api/v1/prices returns USD (and several other fiat
currencies) at the same shape we need, so fetchBtcPrice keeps its
`Promise<number>` signature and every caller continues to read from
the same React Query cache key.
fastlane's deliver action invokes Apple's iTMSTransporter / altool to
push the IPA to App Store Connect, and those tools only ship inside
Xcode. On a generic ruby:3.3 Linux container the upload step crashed
with 'No such file or directory @ dir_chdir0' from
JavaTransporterExecutor#execute, because Helper.itms_path resolved
to a missing Xcode path.
Move publish-app-store onto the same self-hosted Mac runner as
build-ipa (tags: [macos]), drop the now-unnecessary 'gem install
fastlane' (the Mac has it on PATH via ~/.bash_profile), and unset
APP_STORE_CONNECT_API_KEY_PATH to mirror build-ipa's defense against
fastlane's env-var collision (match expects a JSON descriptor there;
we pass the API key inline via the Fastfile).
Update AGENTS.md and the release / ci-cd-publishing / mac-runner
skills, which all incorrectly described publish-app-store as a
Linux-only API call.
Regression-of: b8773c47
The featured-article card was showing the raw `title` field
(e.g. "Japan_Cup") and opening Wikipedia in a new tab. Use the
API's `normalizedtitle` for display and route the click through
/i/ so users land on the in-app article view.
Each CHANGELOG.md release section now begins with a single plaintext
paragraph (max ~500 chars) before any `### Category` heading. That
paragraph drives the release blurb in three storefronts and the
in-app version-update toast, so we no longer ship a marketing-grade
description in one place and a raw bullet list in another.
scripts/extract-release-notes.mjs is the single source of truth for
extraction. It emits the full section (summary + lists) by default
and only the summary paragraph with --summary, with a
`Ditto vX.Y.Z` fallback for legacy entries that have no summary.
CI changes:
- New `release-notes` job (build stage, default node:22 image)
produces `artifacts/release-notes.md` and
`artifacts/release-notes-summary.txt` once per pipeline.
- `release` job pulls release-notes.md as the GitLab Release
description (replaces the old inline awk extraction). It now uses
`needs:` with `artifacts: false` for build-apk/build-ipa to
avoid re-downloading the .apk/.aab/.ipa it doesn't open.
- `publish-app-store` copies release-notes-summary.txt to
`ios/fastlane/metadata/en-US/release_notes.txt` (replaces its
own awk extraction).
- `publish-google-play` drops `--skip_upload_changelogs`, writes
the summary to
`android/fastlane/metadata/android/en-US/changelogs/<versionCode>.txt`
and points fastlane supply at `--metadata_path`. This is the
first time we upload a What's New text to the Play Store from CI.
App-side changes:
- `src/lib/changelog.ts` parser captures the leading non-blank
paragraph (before any bullet or category heading) into
`entry.summary`.
- `VersionCheck.tsx` toast uses `entry.summary` when present,
falling back to the legacy 60-char first-bullet excerpt for
backward compatibility.
- `ChangelogPage` renders the summary as a lede paragraph above
the bullet list in both LatestRelease and ChangelogEntryCard.
Changelog content:
- Added summary paragraphs to v2.14.3, v2.14.2, v2.14.1.
Skill + AGENTS.md updates:
- `release` skill documents the summary paragraph format, the
500-char convention, and the seven-job pipeline.
- `ci-cd-publishing` skill gains a 'Release notes pipeline' section
mapping each storefront to its source artifact.
- AGENTS.md pipeline summary mentions release-notes and the summary
flow into both store "What's new" fields.
Mirror the existing Android publishing flow for iOS. The pipeline
gains two jobs: build-ipa runs on a self-hosted Mac runner and
produces a signed App Store IPA; publish-app-store runs on a shared
Linux runner and submits the prebuilt IPA to App Store Connect.
Build pipeline (.gitlab-ci.yml):
- build-ipa (Mac, stage build, parallel with build-apk): decodes the
ASC API key, runs match (with api_key, so cert validity is verified
against Apple before xcodebuild starts), builds web assets, syncs
Capacitor, stamps MARKETING_VERSION. Uploads Ditto-${CI_COMMIT_TAG}
.ipa to GitLab's Generic Packages registry.
- publish-app-store (Linux ruby:3.3, needs: [build-ipa]): gem
install fastlane, decode the ASC API key, extract the changelog
section into release_notes.txt, fastlane submit_release with
IPA_PATH pointing at the inherited artifact. No Xcode, no signing,
no keychain \u2014 pure Apple API call.
- release job now needs both build-apk and build-ipa, and links three
assets (APK / AAB / IPA).
fastlane (ios/fastlane/Fastfile, Matchfile, Appfile, metadata/):
- Four lanes: build_ipa (CI build), submit_release (CI publish, reads
IPA_PATH from env), release (single-step convenience for local
dev), submit_only (debug lane to re-submit an already-uploaded
build).
- Match config points at the private gitlab.com/soapbox-pub
/certificates repo. App Store Connect API key is built inline in
the Fastfile to avoid a collision with match's APP_STORE_CONNECT
_API_KEY_PATH env var (match wants a JSON descriptor, the action
writes a raw .p8). CI overrides CODE_SIGN_STYLE=Manual via xcargs
so the Xcode project can stay on Automatic for local development.
Vite config (vite.config.ts):
- Renames the build-time config override env var from CONFIG_FILE to
DITTO_CONFIG_FILE. GitLab Runner sets CONFIG_FILE to its own TOML
config in job env, which broke vite's loader.
App-side changes:
- ios/App/App.xcodeproj/project.pbxproj: team GZLTTH5DLM stamped in;
MARKETING_VERSION gets stamped from the tag at build time.
- public/CHANGELOG.md, package.json: v2.14.3.
Skills + AGENTS.md updated to reflect the six-job pipeline (test /
deploy unchanged, build now has two jobs, release / publish updated)
and to document Mac-runner operations, fastlane match cert rotation,
and local debugging workflows.
Introduce queryAll, a portable helper that exhausts a Nostr filter by
paging with the until cursor, capped at 5,000 events / 10 pages so
worst-case cost stays bounded. Works against any relay regardless of
its internal page size.
Migrate useCommunityMembers and useCommunityActivityFeed so membership
and moderation state are complete for any community that fits within
the cap, instead of silently truncating at 500 events.
Extract isAuthorizedAward helper as the single source of truth for
membership award validation, used by both resolveMembership and
useMyCommunities. Simplify resolveCommunityModeration by dropping
the dead banned-reporter guard from pass 1 (impossible under strict
rank ordering). Flip useMembersOnlyFilter default to opt-in to match
the spec's MAY wording, and reword the NIP to match.
- Rename tentative label to 'Interested' (Facebook-style, Star icon)
- Auto-enroll event authors as 'accepted' when publishing
- Let authors change their own RSVP from the detail page
- Restyle RSVP section to match About/Attendees headers
- Remove optional note field; click a button to submit immediately
- Move Attendees above RSVP
Places a NIP-51 kind 10004 bookmark button between the edit and share
buttons so users can save a community while viewing it, not just from
the feed card's more-menu.
Bookmarking a kind 34550 community now writes to the NIP-51 Communities
list (kind 10004) keyed by the addressable coordinate, so the reference
stays valid across community updates. My Communities merges bookmarked
communities as a third discovery source alongside founded and member-of,
with Founder/Member/Bookmarked badges on each card.
Bookmark toasts live on the mutation itself so they survive the more-menu
dialog unmounting between .mutate() and publish resolution.
- CreateCommunityDialog now only publishes kind 34550 (name, image, description)
- New AddMemberDialog on the community detail page handles membership:
- Founder can add moderators and members
- Moderators can add members only
- Badge definition (kind 30009) created lazily on first member add
- Community definition republished once with all changes batched
- Kind 8 badge awards published for each member
- Add Members button on Members tab, visible to rank 0 users
- Search dropdown moved outside ScrollArea to prevent clipping
- Add CreateCommunityDialog with name, image upload, description, and moderator type-ahead search
- Publish kind 30009 badge definition + kind 34550 community definition with d-tag collision check
- Context-aware FAB on My Communities tab opens the create dialog
- Default Search page tab to Communities instead of Posts
- Add Search to default sidebar order for new accounts
- Improve empty states on both Activities and My Communities tabs to guide users toward discovery
NoteCard's reaction/repost/zap/poll-vote branches return early with their
own ActivityCard layouts, skipping the inline 'X reposted' header that
the normal note layout renders below. As a result, when one of these
events appeared in a feed via a kind 6/16 repost, the reposter
attribution was silently dropped.
Add an optional `header` slot to ActivityCard and pass the repost header
into all four early-return branches when `repostedBy` is set.
Stacked avatars in PeopleAvatarStack are now clickable, navigating to
the user's npub profile so readers can jump straight to a member from a
follow list, follow set, or follow pack — not only from the surrounding
post. Each avatar is wrapped in a Link with a stopPropagation handler so
the click doesn't bubble up to the card-level navigation, and the focus
ring is now visible on keyboard focus.
Kind 3 follow-list events are legacy replaceable kinds (NIP-01) but
fell outside the 10000–19999 range that NoteCard, EmbeddedPeopleListCard,
PostDetailPage, and NoteMoreMenu all special-cased — so clicking a
follow list in a feed went to a per-event nevent that pinned to a stale
revision instead of the stable naddr. The four call sites are now
unified behind a new lib/encodeEvent.ts helper that treats kinds 0, 3,
and 41 as replaceable, alongside 10000–19999 and 30000–39999. The same
helper exposes encodeEventNevent for callers that intentionally want to
reference a historical version (e.g. the profile-recovery dialog).
Lightning (kind 9735) and on-chain (kind 8333) zaps now share a single
"zap" group bucket in the notifications page and render with the same
header, sats label, and Zap icon. The Zaps preference toggles both kinds
together; native and push notification queries pick up 8333 automatically
through the shared kind list.
Position the Restore button (or Current badge) absolutely in the
top-right corner of the embedded post, replacing the row beneath it.
Each snapshot now occupies just one container's worth of vertical
space, and the action sits next to the content it acts on instead of
detached below it. The overlay stops click and keyboard propagation so
clicking Restore doesn't also navigate away to the embedded card's
link target.
The double-container effect — outer card frame around an embedded post
that already had its own border — wasted vertical space and made each
snapshot read like two stacked boxes. Drop the outer frame so the
embedded card is the only container, with a primary-colored ring on the
current version. The redundant date row also goes (the embedded card
displays its own timestamp), leaving just a right-aligned Restore button
or 'Current' badge below each snapshot.
The note 3-dots menu now exposes a 'Restore previous version' option for
replaceable and addressable events the current user owns, sitting next
to Delete. It opens a generic EventRecoveryDialog modeled after the
existing profile/mute-list/badge recovery dialogs — querying past
versions with nostr.req() (to bypass NPool's NSet deduplication) using
the same (kind, authors[, #d]) filter shape, and rendering each
historical version through EmbeddedPost so any kind displays correctly.
Restoring republishes the chosen snapshot's content and tags via
useNostrPublish with the snapshot passed as 'prev' so published_at is
preserved. Inline isAddressableKind helpers in useDeleteEvent and
useNostrPublish are now sourced from a shared src/lib/eventKinds.ts.
The deletion event for a NIP-37 draft wrap (kind 31234) only carried
the addressable `a` coordinate. Per NIP-09, a deletion should also
reference the specific event by id when available, so relays and
clients that key their deletion logic on `e` tags don't miss it.
Look up the draft's event id from the TanStack Query cache (the drafts
list already stores it as `eventId` when parsing the wrap) and append
an `e` tag alongside the existing `a` tag. Falls back gracefully to
`a` only if the event id can't be resolved.
Regression-of: e93c6651
- Add aria-label to close button and companion selection buttons
- Replace flex-wrap with horizontal scroll (max-w-[18rem]) so only ~5
blobbis are visible at once; overflow scrolls horizontally
- Add visible thin scrollbar (.scrollbar-thin utility) overriding global
scrollbar-hiding, plus a right-edge fade gradient to hint at overflow
- Add flex-shrink-0 to prevent items from collapsing
- Break BlobbiWidgetContent destructuring across multiple lines for
readability
The on-chain zap kind used to fall through the "unknown kind" path in
every surface except InteractionsModal: a bare NIP-31 alt-tag tombstone
on PostDetailPage, a generic embedded preview for nostr: quote URIs,
and a plain "This event kind is not supported" string inside the reply
composer's parent preview. Visually it was nothing like a zap.
Route kind 8333 through dedicated cards that mirror the 9735 Lightning
treatment pixel-for-pixel: amber bolt bubble, sender avatar, "zapped"
verb, amber sats amount, italic comment. Per NIP.md we verify the
claimed amount against mempool.space before displaying it, so the new
`useVerifiedOnchainZap` hook short-circuits to the single-event path of
`verifyOnchainZap`. Until verification resolves (or if it fails) the
card shows a muted "verifying…" / "unverified" hint next to the amount
so we don't silently lie.
Covers three surfaces in one pass:
- Detail page (nevent URL): new isOnchainZap branch in PostDetailPage
- Embedded quotes: new EmbeddedOnchainZapCard in EmbeddedNote
- Reply composer parent preview: uses EmbeddedPost -> EmbeddedNote,
so it inherits the fix for free.
The Zaps tab only rendered NIP-57 receipts (kind 9735), so a post that
had been zapped only on-chain appeared to have no zappers at all. Merge
the two rails into a single unified view-model and render them with
identical rows — same avatar, same name line, same amber amount badge,
same chevron link. The dedup and on-chain verification are already
handled by useOnchainZaps upstream; this change is just plumbing.
The modal now takes the full target event instead of a bare eventId so
the on-chain query can compute the `a` coordinate for addressable kinds.
Updated all call sites (PostDetailPage, PodcastDetailContent,
MusicDetailContent) accordingly.
Previously the bolt icon next to reply/repost/react was stateless: an
outlined zap icon whether you'd zapped the post or not. This matched
neither the repost button (flips to the accent color when reposted) nor
the reaction button (fills when you've reacted), and the gap was most
noticeable with on-chain zaps where users expected the same visual
confirmation they get for Lightning.
Add a useUserZap hook that consults both rails in one REQ: kind 8333
filtered by authors+#e (our on-chain zap, self-authored), and kind 9735
filtered by #e with a client-side extractZapSender match (NIP-57
receipts are authored by the LNURL server, not the zapper). The send
hooks (useOnchainZap, useZaps) optimistically set the cache to true on
success so the icon fills immediately, without waiting for the relay to
echo the event back.
Wired into every action-bar renderer that carries the zap button:
NoteCard, PostActionBar, PhotoBottomBar, VinesFeedPage, BookFeedItem.
The wallet doc covered sending Bitcoin and NIP-73 tx/address pages but
never mentioned that the zap-dialog flow also publishes a kind 8333
attestation pairing the tx with the zapped Nostr event. Add a short
section describing the tags and cross-link the full spec in NIP.md.
Remove the auto-close progress bar, the "Sent via Bitcoin/Lightning" rail
indicator, and the sats subtext under the USD amount. The screen now
dismisses only via the Done button, so the rail-specific plumbing
(autoCloseMs, kind) is gone from the component API as well.
Regression-of: 5c2c3513
Previously, a successful send from the Zap dialog auto-closed and surfaced a
toast. That undersold what just happened — the user sent Bitcoin. Now both
rails (on-chain + Lightning) flip the dialog over to a dedicated success
screen with an animated check, amount, recipient card, rail indicator, and
(for on-chain) a "View transaction" link to mempool.space. The dialog
auto-dismisses after six seconds if the user walks away.
The dev editor read tags and content from the TanStack Query cache
(companion.allTags / companion.event.content) and published without
prev, risking overwrite of concurrent changes (e.g. social
consolidation advancing the checkpoint on another device).
Apply the standard read-modify-write pattern: fetchFreshEvent before
merge, use prev.tags/content as the base, and pass prev to
publishEvent so published_at is preserved.
Users with multiple Blobbis can now change which one is displayed in the
widget without navigating to the full Blobbi page. A new ArrowLeftRight
icon appears below the companion (Footprints) button and opens a popover
with all available Blobbis for quick selection.
Move effectiveSince computation from useMemo into queryFn so
Date.now() is evaluated fresh on each TanStack Query refetch.
Previously, long-lived pages froze the window floor at mount time,
causing interactions from friends to go undetected after hours.
Also document the intentional boost→feed reaction animation reuse.
- Gate social actions by projected stat thresholds (< 70) so visitors
can only help stats in visual distress
- Add energy category with Energy Drink and Power Nap Pillow items
- Apply 6-hour recency window to interaction queries (limit 30)
- Fix BlobbiActionsProvider tree placement so BlobbiPage shares context
with the companion layer
- Preserve event content in dev editor (don't overwrite checkpoint JSON)
- Show Needs Now summary in activity tab with priority badges
- Remove unused need-driven consolidation infrastructure
Regression-of: 9aecefff
When the user has no NIP-65 write relays configured, the nostrconnect://
URI was built with a single relay (wss://relay.ditto.pub). Fall back to
the full APP_RELAYS write list instead so the remote signer has more
connection options during the handshake.
The Send and Pay-with-WebLN buttons no longer carry a lightning-bolt icon; the on-chain tab's primary button is text-only too, so this brings the flow in line. Sats are no longer shown anywhere on the Lightning screens — the USD amount is the whole story as far as the user is concerned, and the sats figure is just an implementation detail the LNURL flow handles.
Presets drop from $1/$5/$10/$25/$100 to $0.10/$0.50/$1/$2/$5 and the default amount moves from $5 to $0.50. Lightning zaps are tip-jar-shaped, not dinner-shaped — the on-chain tab can stay where it is because a fixed-fee on-chain send doesn't make sense below a few dollars.
Both tabs now lead with a big clickable USD amount and the same //// preset row, with the sats figure relegated to a small secondary line. Dropped the Lightning-only comment textarea and emoji picker — the on-chain flow never had them and keeping two different preambles made the tabs feel like different products. Preset buttons are shorter (h-8) in both tabs.
The invoice screen now shows USD primary / sats secondary in the header and renders the QR through QRCodeCanvas instead of a data-URL img, matching the on-chain fallback's QR styling. Large-amount two-tap confirmation (>= $100) now applies to Lightning too.
When the requested amount plus fee exceeds the user's balance, the
big dollar readout and send button both go destructive-red, the
button text becomes 'Not enough Bitcoin' and disables, and the
'Balance: $X' footnote in the fee row is hidden. The zero-balance
case ("you don't have any Bitcoin yet") still shows the balance line
so users understand why send is disabled.
The dialog collapses to: amount, presets, Send button, fee. Removes
the sats-per-amount subtitle under the big number, the collapsible
'Add a comment' accordion, and the gauge icon next to the fee line.
Comments ride along as empty strings on the zap payload, so the
backing 8333 publish still works, it just doesn't carry user text.
- Center the 'Add a comment' accordion toggle and make its textarea
span the full width when opened.
- Tighten the 'How does sending Bitcoin work?' FAQ: leads with the
Nostr-key-as-wallet framing, drops the extra paragraph about
balances, and keeps the fee and public/irreversible points concise.
- Move the fee/balance line below the Send button so it reads like a
footnote under the primary action instead of competing with it.
Redesign the on-chain flow around a sleek 'Send $X' experience: click
the big number to edit, presets sit underneath, comment collapses behind
a chevron, recipient address and OR divider are gone, the send button
just says 'Send $5.20' with the fee included, and fee speeds are
deduplicated so duplicate sat/vB tiers don't repeat.
The fee speed now auto-adjusts when the amount changes to keep the fee
below 40% of the send amount — once the user manually picks a speed,
auto-adjustment is disabled for the session.
Dialog framing switches from 'Send a Zap' to 'Send Bitcoin', the
redundant description line is dropped, and the (?) popover routes to
one of two new tab-specific FAQ entries (send-bitcoin-onchain or
send-bitcoin-lightning).
Nav items and the 'Search for...' entry in the autocomplete dropdown had
primary-colored icons on a primary-tinted circle, which lost contrast
against the primary-tinted row background when highlighted. Swap both to
accent-foreground tones when the row is selected.
Ignore .agents skill bundles from eslint (template files not part of
the build), enable eslint --cache and tsc --incremental so warm re-runs
skip unchanged files.
The combined skill conflated two unrelated jobs: (1) design-time
decisions when authoring a new kind (NIP-vs-custom, ranges, tag design,
NIP.md) and (2) implementation-time checklist for wiring rendering into
Ditto's many UI touchpoints. The single description sentence was
unwieldy, and its trigger ('introducing a new kind... or registering a
kind in the UI') was phrased around the author's perspective — it
didn't match user phrasing like 'support displaying kind X' or 'render
NIP-Y', so I skipped loading it when implementing NIP-84 and missed
half the registration points.
Split into:
- nostr-kind-design — NIP-vs-custom decision, kind ranges, tag design,
content-vs-tags, NIP.md. Loads when minting or extending a schema.
- nostr-kind-rendering — the multi-location UI registration checklist.
Loads when rendering a kind Ditto doesn't yet display, or when asked
to 'support / display / render' a NIP or kind number.
Expanded the rendering checklist with the points I missed during the
NIP-84 pass: the six-file notification stack, the four-file AppConfig
triple for feed-toggle keys, sidebar icon registration, AppRouter route
wiring, shouldHideFeedEvent spam guards, and a 'bugs that signal a
missed step' section so the checklist reads as diagnostic too. Also
flagged the embedded-previews trap where skipping the dispatcher branch
silently feeds quoted prose through the kind-1 tokenizer.
Updated both AGENTS.md references to point at the two new skills.
The OpenCode system prompt's bash-tool instructions include 'Only create
commits when requested by the user' and 'NEVER commit changes unless
the user explicitly asks you to.' Those rules were overriding the
existing AGENTS.md directive and causing the agent to stop at
'validation passed' without committing, repeatedly, across sessions.
Spell out the conflict by name (linking upstream PR #25198), state
explicitly that AGENTS.md takes precedence, and enumerate the failure
modes (asking permission, waiting to be asked, treating uncommitted
changes as 'done') so the agent has something concrete to match against
when it catches itself hesitating.
New BirdexChorusButton plays every species' Wikipedia recording at once
— a dawn chorus for the whole life list. Hides itself when no species
has usable audio, and the feed-card variant swallows clicks so toggling
playback doesn't navigate away from the NoteCard.
Render highlight excerpts as pull-quotes with source attribution across
feed cards, detail pages, and quote embeds. Without this, kind 9802
events fell through to UnknownKindContent in cards and — worse — had
their quoted prose fed through the kind-1 tokenizer in embeds,
auto-linkifying URLs and hashtags that were part of the original source,
not the highlight author's post.
Integration points:
- HighlightContent renders the excerpt with an accent blockquote, wraps
the highlighted span in <mark> when a context tag is present, and
attributes the source via EmbeddedNaddr (a-tag), EmbeddedNote
(e-tag), or a sanitized URL chip (r-tag).
- EmbeddedHighlightCard gives quoted highlights a dedicated compact
card instead of the generic-embed fallback.
- Added 9802 to NoteCard + PostDetailPage dispatch, KIND_HEADER_MAP,
CommentContext labels/icons, NOTIFICATION_KIND_NOUNS, and the
EmbeddedNote dispatcher.
- Registered an EXTRA_KINDS entry with feedIncludeHighlights (off by
default) and showHighlights (on), plus a /highlights route backed by
KindFeedPage.
- Added a highlights notification type with its own subscription
template, preference toggle, grouped notification row, and
author-ownership filter so users are only notified when their own
content is highlighted.
- feedUtils hides empty highlights with no source reference.
Three inconsistencies between /i/ pages cleaned up:
* Action bar now always renders three universal interaction buttons
(Comment, React, Share) with the Comment button opening the
compose modal and showing the top-level comment count. ISBN keeps
its Write Review (star) button as a fourth. Previously Bitcoin
and other non-book identifiers showed only React + Share.
* Book content tabs (Comments / Reviews) switched from the flat
shadcn Tabs primitive to SubHeaderBar + TabButton so the curved
arc styling matches Feed, Profile, Search, and every other tabbed
page in the app.
* Extracted ExternalCommentsSection — the inline ComposeBox +
threaded list + loading/empty states are now shared between the
default single-list layout and the ISBN Comments tab instead of
duplicated.
Every context except the Feed composer follows the compose box with
content that needs visual separation (comment lists, wall posts,
threaded replies), so add a default border-b and let Feed opt out with
a new hideBorder prop — it sits directly above SubHeaderBar's arc
background, which already provides its own separator.
Modal usage (forceExpanded) stays borderless since it lives inside
a dialog container.
ExternalContentPage already supported fetching comments for non-URL
external roots (bitcoin:tx:..., isbn:..., iso3166:..., etc.), but the
inline ComposeBox and the FAB's ReplyComposeModal were both gated on
the URL-only commentRootUrl — leaving those pages with no way to post.
Widen ComposeBox's replyTo and ReplyComposeModal's event to accept
`#${string}` NIP-73 identifiers alongside URLs and events, route them
through the existing NIP-22 publish path (usePostComment already
handled string roots), and wire the page up to the combined
commentRoot.
text-primary was applied directly to the Shield icon, overriding the
ghost button's hover:text-accent-foreground. On hover the background
turned into accent while the icon stayed primary, producing a
low-contrast pairing. Move text-primary up to the Button so the
ghost variant's hover rule can take over, and let the icon inherit
currentColor.
- Remove the app-name subtitle under "Permissions"; the site is already
identified by the nav bar directly above the popover.
- Drop the trash icon from the "Revoke all" button so the destructive
action reads as plain text like the row items.
- Remove the per-row allow/deny toggle. Stored permissions are always
"allowed" in practice (a denied prompt doesn't surface a row the user
would want to keep around), so the toggle added noise without a
realistic use case. Users who change their mind can remove the row and
re-prompt.
- Drop the status check/X icon on the left of each row now that the
toggle is gone and every listed permission is implicitly allowed.
- Show the remove button always (was opacity-0 until hover) and switch
it from a trash icon to an X, matching the close-affordance idiom used
elsewhere in the app.
- Drop the siteName prop from NsitePermissionManager; nothing uses it
anymore.
The target pubkey was rendered as a truncated hex string in the prompt,
which is noise to the user: they can't verify it, it doesn't scope the
stored permission (which is global-to-the-app), and showing a pubkey
next to "Allow" misleadingly suggests the decision applies only to that
peer. Drop the field from NsitePromptState and stop threading it through
the four encrypt/decrypt RPC branches.
Sandbox iframes live on a cross-origin subdomain, so most capability APIs
are blocked unless the parent delegates them with allow=. Add a permissive
policy covering media (camera, microphone, display-capture, encrypted-media,
picture-in-picture, autoplay, speaker-selection), sensors (accelerometer,
gyroscope, magnetometer, ambient-light-sensor, compute-pressure, battery),
input (gamepad, midi, keyboard-map, xr-spatial-tracking), and UX features
(fullscreen, geolocation, idle-detection, screen-wake-lock, clipboard-write,
web-share, window-management, storage-access) so nsites and webxdc apps can
use anything a regular web app would.
Deliberately omitted for security: payment, publickey-credentials-*,
otp-credentials, identity-credentials-get (all phishing/account-takeover
vectors), local-fonts (fingerprinting), bluetooth/hid/serial/usb/
clipboard-read (raw device access left off for now).
Drop WebxdcIframe's narrow allow= override so webxdc apps get the same
broad policy instead of downgrading to just autoplay/fullscreen/gamepad.
Also split safe-area-top from content padding in the NsitePreviewDialog
and WebxdcEmbed nav bars: the outer element reserves space for the
notch inset, the inner row keeps a fixed px-3 py-2 flex layout so
content stays vertically centered inside the intended 44px bar height.
Introduces a new sidebar item type for nsites that auto-opens the nsite
preview when clicked, using React Router state to prevent external URLs
from triggering auto-launch.
- Add isNsiteUri/nsiteUriToSubdomain helpers and parseNsiteSubdomain
- Create NsiteSidebarItem with site favicon and link preview title label
- Wire nsite:// dispatch in SidebarNavList, useFeedSettings, SidebarMoreMenu
- NoteMoreMenu pins named nsite events as nsite:// URIs instead of nostr:
- NsiteCard gains a Pin/Unpin button and an autoPlayKey prop that re-opens
the player each time the sidebar item is clicked
- NsitePlayerContext tracks the active subdomain for sidebar highlighting,
provided in MainLayout so sidebar and pages share state
- PostDetailContent consumes nsiteAutoPlay router state and clears it
after consumption so a page refresh doesn't re-trigger auto-play
When a logged-in user opens an nsite preview, a window.nostr provider is
injected into the sandboxed iframe. The provider proxies signEvent, nip04,
and nip44 calls to the parent signer over the existing JSON-RPC bridge.
A permission system gates each operation:
- getPublicKey is auto-allowed (clicking Run implies consent)
- signEvent prompts are granular per event kind (like Amber)
- encrypt/decrypt prompts are per operation type
- Users can check 'Remember for this site' to persist decisions
- Permissions are scoped to (userPubkey, siteId) in localStorage
The nsite preview nav bar gains a shield icon that opens a popover for
managing stored permissions.
Kind labels for the signer nudge, the permission prompt, and the post-
detail loading title now route through a central KIND_LABELS registry
(src/lib/kindLabels.ts) instead of three divergent inline maps.
The native SandboxPlugin (iOS WKWebView / Android WebView overlay) is
removed; SandboxFrame now always uses iframe.diy, so native behavior
matches web. This drops ~1100 lines of native code, the Android-only
blob prefetch workaround in NsitePreviewDialog, and the createPluginCall
registration in MainActivity and capacitor.config.json.
The sing action uses getUserMedia + MediaRecorder, which in a browser is
gated only by the standard web mic prompt. In Capacitor's Android
WebView it additionally requires the RECORD_AUDIO permission to be
declared in AndroidManifest.xml; without it the WebView rejects with
NotAllowedError and no system prompt is ever shown, so tapping record
silently fails on the Android app while working fine in the browser.
Also add MODIFY_AUDIO_SETTINGS, which some devices require for the
echoCancellation / noiseSuppression / autoGainControl constraints that
InlineSingCard passes to getUserMedia.
Separately, reorder AUDIO_MIME_CANDIDATES to prefer audio/mp4/aac over
audio/webm;codecs=opus. iOS WKWebView cannot decode WebM/Opus in an
<audio> element, so the recorded Blob's preview URL failed to load on
iOS. Android WebView and desktop Chromium both support mp4/aac, so
preferring it first is safe cross-platform. This mirrors the ordering
already used by useVoiceRecorder.ts.
When a user tapped "Open Signer App", the dialog previously stayed
frozen on the same screen — same button, same copy-URI fallback, no
feedback — until the login either succeeded (and the dialog dismissed)
or timed out after two minutes. With slow or flaky signers (Amber's
current listening-REQ bug being the immediate trigger, but any NIP-46
signer that takes more than a second or two to respond hits the same
hole) this looked indistinguishable from a hang. Users retapped the
button, closed the dialog, gave up.
Now the dialog swaps the QR / Open Signer App area for a centered
spinner with a live status line as the handshake advances:
- "Waiting for signer connection…" while the signer app has the user
and we're listening on kind 24133 for the connect-ack.
- "Getting public key…" once the connect-ack arrives and we're
issuing the NIP-46 get_public_key RPC.
On mobile the swap happens synchronously when the user taps "Open
Signer App" so they see the progress state the moment they return
from the signer — this is the most important window, since that's
exactly when the original UI left them staring at a button they
were worried they needed to re-tap. On desktop the QR stays visible
through the awaiting-connect phase (they may still be scanning with
a different device) and only swaps in once the signer has
acknowledged.
The progress view includes a Cancel link (primary color, matches the
"Create account" affordance) that aborts the in-flight subscription
and regenerates fresh connect params — equivalent to the existing
Retry path, but reachable while the handshake is live instead of
only after a failure.
The handshake phases are surfaced via the new `onStatus` callback
on `NLogin.fromNostrConnect` in @nostrify/react 0.6.0. Bumps
@nostrify/react to ^0.6.0 and @nostrify/nostrify / @nostrify/types
to their matching versions (^0.52.0 / ^0.37.0) to avoid duplicate
nested package copies that would otherwise split type identity.
Incidental cleanup while editing the dialog: the Copy URI button and
the "Tap to open your signer app" / "Scan with your signer app"
status lines are removed. The primary Open Signer App button is
self-explanatory on mobile, and the QR on desktop doesn't need a
caption.
The nostrconnect listening effect depended on `login`, `onLogin`,
`onClose`, and `isWaitingForConnect`. `login` is a fresh object from
useLoginActions on every render, and every call site passes inline
arrow functions for onLogin/onClose, so the effect re-ran on every
parent render. Each re-run fired the cleanup and flipped a local
`cancelled = true` flag, and when the signer's NIP-46 response
eventually arrived, the success branch saw `cancelled === true` and
silently skipped `onLogin()` / `onClose()` — the user was logged in on
the backend but the dialog never closed.
Stabilize onLogin/onClose/login via latest-value refs, narrow the
effect deps to `[nostrConnectParams]` only, and gate the success
branch on `controller.signal.aborted` (which is only true when the
dialog was explicitly closed or handleRetry fired). Drop the unused
`isWaitingForConnect` state. Also abort the in-flight controller from
handleRetry before regenerating params, so the prior subscription
doesn't linger.
The bug was masked by most signers responding fast enough (<1s) that
parent re-renders didn't happen during the wait. It surfaced during
an upstream Amber bug that delays its listening REQ by ~8+ seconds,
giving render cycles time to fire (https://github.com/greenart7c3/Amber/pull/420).
The Amber bug is getting fixed separately; this Ditto fix stands on
its own — any signer that takes a few seconds to respond could trip
the same race.
The paginated backfill loop could mark itself as 'done' even when pages
timed out or when a retry overlap page returned only already-known events.
This caused counts to differ between reloads depending on network timing.
Changes to useMultiHashtagFeed.ts:
- Add reachedEnd flag: backfillDone only set when a definitive end condition
is confirmed (empty batch, reached since, sub-limit overlap, or MAX_EVENTS)
- Timeout/abort sets completedCleanly=false, preventing premature mark
- Resume cursor derived from eventMap minimum timestamp on retry
- Saturated boundary handling: if overlap page at limit returns zero new
events, fall back to exclusive cursor (oldestInBatch - 1) to probe older
- MAX_EVENTS (10,000) hard cap as explicit constant
- Query DITTO_RELAY directly via NRelay1 to avoid NPool eoseTimeout
Also includes: ConfigDrawer, territorial coverage utilities, content
fallback for municipality attribution, and real relay data integration.
VineMedia (kind 22 / 34236 in regular feeds) always started muted on
click-to-play, even when the "autoplay videos" setting was disabled.
That made click-to-play behave differently from the regular VideoPlayer,
where a user-gesture click plays with sound.
Now VineMedia only starts muted when autoplayVideos is on (browsers
require autoplay to be muted). When the setting is off, a click is an
explicit user gesture and plays with audio. The <video> muted attribute
is also bound to state so it tracks the mute toggle correctly. If play
is rejected unmuted, we retry muted as a fallback.
Regression-of: 23e845eb
- Add stripped venezuelaTerritorial.ts (24 states + 379 municipalities, no parroquias)
- Add useEventDashboardConfig hook with hardcoded default config
- Add useMultiHashtagFeed hook with backfill/polling/dedup and enabled option
- Add useEventDashboard adapter hook: config → feed → aggregation → typed outputs
- Wire EventDashboardPage to real data; show loading skeleton and error states
- RecentActivityList now resolves profiles via useAuthor
- Non-admin users never trigger relay queries (enabled: false guards all queries)
- No ConfigDrawer, parroquias, territorialCoverage, verified badges, or content
fallback ported — those remain deferred to Phase 3
- Add admin-gated /event-dashboard route with visual-only mock dashboard
- Add requiresAdmin support to SidebarItemDef; filter admin-only items
from desktop sidebar, mobile drawer, More menu, and search suggestions
- Dashboard components: KPI grid, activity chart, top regions bar chart,
distribution donut, participants list, recent activity feed, skeleton
- Page-level access control mirrors OrganizersPage pattern (login prompt
for logged-out users, locked card for non-admins, full content for admins)
- Uses noMaxWidth layout for wider charts within the standard app shell
- All data is static mock (Venezuelan municipality codes in real CNE format)
- No relay fetching, config drawer, dedup/backfill, or localStorage yet
Long-form articles (kind 30023) previously ran their content through
react-markdown with no Nostr awareness, so `nostr:` URIs, bare URLs,
hashtags, and custom emoji all surfaced as literal text. This composes
NoteContent into the markdown renderer instead, so mentions get hover
cards, quoted notes render as embedded cards, URLs get link previews,
and custom emoji resolve — matching regular note behavior exactly, with
zero duplicated parsing.
NoteContent gains an `as: 'div' | 'span'` prop so it can be embedded
inline inside markdown elements without invalid-HTML nesting. Markdown
paragraphs render as `<div>` (with reproduced prose paragraph spacing)
so block-level quote cards and images are legal children. Headings
suppress block embeds to avoid quote cards inside an `<h1>`.
Also strips Typography's default always-on link underline in favor of
hover-only, matching the rest of the app.
Replace blobbi reference equality with stable visual-identity primitive
comparisons in MemoizedBlobbiVisual memo and SVG renderer useMemo deps.
This prevents SVG DOM rebuilds (which restart SMIL animateTransform)
when the upstream companion object gets a new reference during the
imperative gradient-drain loop.
Lift useFillLevelUpdate above MemoizedBlobbiVisual so the memo boundary
can block the 60fps re-render cascade during level-only changes. The
arePropsEqual comparison now uses a pre-computed recipeFingerprint
(which excludes angerRise.level) instead of recipe reference equality.
During nausea drain, the structural recipe fingerprint stays constant,
so MemoizedBlobbiVisual blocks all re-renders — keeping the SVG DOM
stable and SMIL spiral-eye animations running uninterrupted. The fill
level still updates imperatively via useFillLevelUpdate called from
BlobbiCompanionVisual's root ref.
Vomit now triggers on any sufficiently hard shake (peakIntensity >= 0.7)
regardless of hunger level. Green nausea fill remains hunger-gated.
Remove the MAX_SPLATS = 3 cap so puddles accumulate freely until the
user clicks/taps them away.
Add a static mouthAnchor lookup table that maps baby/adult forms to
their actual mouth Y-ratio (including the +0.12 visual offset), so
the vomit drop spawns from the correct mouth position per form.
- Falling drop z-index 10002 (above companion) so it visibly exits mouth
- Landed puddle stays at z-index 9998 (below companion)
- Land position changed to 20px below Blobbi's container bottom, clamped
- Spawn position adjusted to config.size * 0.65 for mouth area
- Unified transform anchor to translate(-50%, -100%) in both states
- Keyframe ends at scale(1) opacity(1) to prevent pop on landing
- Reset vomitedThisCycle + peakIntensity in onDragStart during active
cycle so each new qualifying drag can vomit independently
User relays are no longer used until the user explicitly opts in via
Settings > Network. Adds a useUserRelays toggle alongside the existing
useAppRelays toggle in RelayListManager, defaulting to false. Fresh
installs and new accounts will only query app-default relays until the
user enables their personal NIP-65 list.
The user's relay list (kind 10002) is still synced from Nostr and
managed in the UI when logged in — the toggle only controls whether it
is included in the effective relay set used by NostrProvider's pool
and useNativeNotifications. The setting is persisted to AppConfig and
synced cross-device via NIP-78 encrypted settings.
getEffectiveRelays now takes both flags and short-circuits accordingly,
producing an empty list when both are off (instead of the previous
behavior of always returning user relays).
- Seed ['event', id] query cache from the community activity feed so
embedded previews resolve without a second fetch.
- Add placeholderData and a 30-minute gcTime to the community activity
feed so navigation and background refetches don't flash empty.
- Surface useGoalProgress's isPartial flag in GoalCard with a '~'
prefix and tooltip so users know when a tally hit the safety cap.
Drop LNURL signer resolution and NIP-57 receipt validation from goal
progress tallying. This removes a network request per beneficiary for a
trust level that is still spoofable and that no other zap display in the
app enforces. Revert this commit to restore strict validation.
Include communitiesLoading in the hook's isLoading so the skeleton
shows while the dependent communities query is still resolving,
instead of briefly rendering the empty state.
Goals use lowercase 'a' tags (not uppercase 'A' like NIP-22 comments)
to link to communities. The activity feed's moderation filter, members-
only filter, and CommunityModerationContext provider lookup all only
checked uppercase 'A', so goals bypassed moderation and had no '...'
menu. Now all three check both tag casings.
Content-banned goals and goals from member-banned authors are now
filtered out of the fundraising tab via applyCommunityModerationToEvents,
matching the behavior of the comments tab.
Replace standalone GoalCard with NoteCard in the community fundraising
tab so goals get the same '...' menu with remove/ban actions that
comments have. Strip GoalCard down to just the compact inline renderer
(no variant prop, no skeleton, no card-only imports). Simplify
useCommunityGoals to return plain events instead of parsed wrappers.
- Unify GoalCard and GoalContent into a single component with variant prop
- Extract useGoalDisplay hook for shared display logic (author, progress,
community link, deadline, image)
- Add useNow(60s) interval so deadline labels refresh automatically
- Add generic parseATagCoordinate utility to nostrEvents.ts
- Replace DOM-mutating image onError with React state
- Remove dead isGoalFunded export and redundant created_at in publish
- Delete GoalContent.tsx (-144 net lines)
- PostDetailPage: render GoalContent for kind 9041 instead of plain text
- CommunityDetailPage: add floating action button on comments (compose) and fundraising (new goal) tabs, remove inline New Goal button
- CreateGoalDialog: support controlled open/onOpenChange props for external triggers
Implement zap goals (kind 9041) linked to communities via a-tag.
Includes goal creation dialog, progress tracking from zap receipts,
recipient profile/lightning address display, community link, and
members-only filtering. Goals appear in community detail Fundraising
tab, activity feed, and main feed via NoteCard.
- Move vomit trigger from delayed dizzy timer to immediate on drag
release when conditions are met (cycleHadNausea + peakIntensity >= 0.7)
- Add vomitedThisCycle ref to prevent duplicate emissions per cycle
- Change landY from viewport bottom to short distance below Blobbi
(renderedPosition.y + size*0.9, clamped to floor limit)
- Replace puddle <div role=button> with semantic <button type=button>
- Use translate(-50%, -100%) so puddle bottom sits at the landing point
- Update click handler type from PointerEvent to MouseEvent
When Blobbi is shaken hard enough while nauseous (hunger >= 90 and
peak intensity >= 0.7), the dizzy phase now escalates to a brief
'vomiting' phase. A green drop falls from Blobbi's mouth to the ground
and becomes a persistent puddle that stays fixed in place until the
user clicks/taps it to clean up. Max 3 puddles; oldest removed on
overflow.
New files:
- VomitSplat.tsx: falling drop + landed puddle (SVG, click-to-remove)
Modified:
- useShakeReaction.ts: new 'vomiting' phase, peakIntensity tracking,
VomitEvent signal, vomitTimer with proper cleanup
- BlobbiCompanionLayer.tsx: splat state management, coordinate
calculation, renders VomitSplat siblings in the overlay
- index.css: vomit-fall keyframe animation
Move the useId()-based instance ID generation into a shared hook so the
logic and comment live in one place instead of being duplicated across
BlobbiAdultSvgRenderer and BlobbiBabySvgRenderer.
When a Blobbi appeared in multiple spots simultaneously (hero + drawer
grid, hero + floating companion, feed card + companion), all SVG
instances shared the same clip-path IDs (e.g. blobbi-blink-clip-{d}-left).
The browser resolves clip-path: url(#id) to the first matching element
in document order, so the hero's eyes would use the drawer thumbnail's
non-animated clip-rects — eyes stayed open while CSS eyelid animations
still ran.
Use React's useId() to generate a unique instanceId per component
instance instead of reusing blobbi.id, ensuring clip-path and gradient
IDs are unique across all concurrent renders of the same Blobbi.
Regression-of: 384936f1
The right sidebar previously required xl (1280px) to appear, so horizontal
iPad (1024px) viewports saw only the left sidebar + main column. Use the
existing lg breakpoint (1024px) to control right-sidebar visibility, and
let the sidebars scale fluidly with the viewport by setting them to w-1/4
max-w-[300px] instead of fixed pixel widths. The center column (flex-1)
absorbs whatever space remains, so the layout fills the viewport smoothly
from 1024px up through the 1200px wrapper cap instead of leaving dead
space at intermediate widths. Below the lg breakpoint, the left sidebar
keeps its fixed 300px width.
The wrapper div around BlobbiStageVisual in BlobbiRoomHero had an
unconditional pointer-events-none class, which blocked click/tap
events from reaching EggGraphic's handleEggClick handler. Now the
class is only applied when the companion is NOT in egg stage, so
the egg vibration interaction works on /blobbi just like in the
blobbi page view.
Tags like #70-706 and #bitcoin-conference were split at the hyphen,
since the regex only matched letters, numbers, and underscores. Allow
internal hyphens (but not leading or trailing ones, so #nostr- still
captures just #nostr) and share the pattern across NoteContent,
BioContent, and the compose boxes so posted t-tags match what renders.
Country, book, and Bitcoin tx/address pages were querying with a stray
`#` prefix on the NIP-73 identifier, so the filter never matched any
real comments. Pass the raw identifier like PostDetailPage does.
Regression-of: 363e39d7
Wikipedia/Wikimedia Commons hosts editorially-curated, mostly Xeno-
Canto-sourced recordings on bird species articles. Surface them on
Ditto's /i/ page whenever the article exposes one: an emerald play
button sits inline with the article title, looping the song on
click and swapping its play triangle for an animated equaliser to
indicate active playback. When a recording is present, the footer
row gains a second link crediting the recordist and license and
pointing at the Commons file-description page for verification.
Adapted from Birdstar's BirdInfoDialog / useBirdSound / useWikipedia
Sound hooks; the iNaturalist fallback from the original is dropped
per request — Ditto only uses Wikipedia/Commons.
Birdstar kind 2473 events now carry the species' scientific binomial
name in an authoritative 'n' tag, added to the NIP so clients can
label a detection without round-tripping Wikidata. Ditto was still
scraping the name out of the 'alt' tag's parenthetical, which loses
the label when the publisher emits a bare alt like "Bird detection".
Prefer the 'n' tag and fall back to 'alt' parsing only for older
events authored before the tag was part of the NIP. Also show the
scientific name as a persistent italic sub-label on Birdex tiles,
matching how detection cards stack the two labels.
Regression-of: b2634d2f
A Birdex is a replaceable per-author index of every bird species the
author has ever confirmed via kind 2473, stored as positional i/n tag
pairs in chronological first-seen order. In feeds, show a compact
tile strip of the most recently-added species with a "+N" capstone
when the list overflows — mirroring how kind 3 follow lists preview
members as an avatar stack plus "+N more". On the post-detail page,
render every species as a responsive grid so visitors can browse the
author's whole life list.
Each tile resolves the species' Wikidata entity through English
Wikipedia to pull a thumbnail and common-name label, reusing the
same fetch path as kind 2473 detection cards. The Wikidata URL is
sanitized before being routed, and the paired n tag provides a
scientific-name fallback while the remote lookup is in flight.
Previously getDisplayName() and ~50 inline sites only consulted
metadata.name, ignoring display_name. A handful of other sites used the
opposite priority (display_name || name), so the same user could render
under different names across the UI.
Standardize on `name || display_name || genUserName(pubkey)` in the
helper and at every call site, and widen two local inline metadata types
in RightSidebar and SearchPage that did not declare display_name.
When the Bitcoin tab only had a dead-end error panel, ZapDialog auto-
switched to Lightning whenever capability was unsupported and the author
had a Lightning address. Now that the Bitcoin tab renders a QR fallback
the user can actually use, that forced redirect prevented them from
clicking into it: every render while activeTab === 'onchain' flipped it
back to 'lightning'.
Drop the mid-session auto-flip effect. The initial tab choice still
biases toward Lightning (via the useState initializer and the open-reset
effect), but manual navigation into Bitcoin is respected.
Regression-of: bf540fb5
When the user's signer can't sign PSBTs (extension without signPsbt, or a
bunker that rejected sign_psbt), the onchain zap tab previously showed a
dead-end 'Bitcoin zaps aren't available' panel. Replace it with an
amount selector + BIP-21 QR code the user can scan from any external
Bitcoin wallet, plus copy buttons for the address and payment link.
Because Ditto never sees the resulting transaction, no kind 8333 is
published in this flow. A warning explains that the zap won't show up
as theirs on Nostr even though the recipient still gets the Bitcoin.
Also gate the mempool.space UTXO and fee-rate queries on the capability
being non-unsupported to avoid pointless network calls in this branch.
The previous `private readonly secretKeyBytes` was compile-time-only:
at runtime the field was a plain enumerable property, readable as
`user.signer.secretKeyBytes`. This regressed the runtime privacy
boundary that the parent `NSecSigner` deliberately enforces with its
ES `#private` field.
Switch to `#secretKeyBytes` so the bytes are unreachable via property
enumeration or reflection on the instance, matching the parent class's
protection.
Add prev to 5 KIND_BLOBBI_STATE publish paths that already fetch a fresh
base event but were not passing it to useNostrPublish. Without prev, the
replaceable-event published_at ordering is unguarded and concurrent
writes (debounce persist vs action hook vs sleep/care activity) can cause
a relay to accept an older-content event over a newer one, resetting
hatch/evolve mission progress.
Affected paths:
- useBlobbiDirectAction (canonical.companion.event)
- useBlobbiUseInventoryItem (canonical.companion.event)
- useBlobbiItemUse standalone (companion.event from relay fetch)
- useBlobbiSleepToggle (companion.event from relay fetch)
- useBlobbiCareActivity (freshCompanion.event from relay fetch)
No content serialization, tag update, or mission definition changes.
Replace the hook-internal fetchFreshProfile useCallback with the shared
fetchFreshBlobbonautProfile helper, which has identical semantics (query
both kind ranges, prefer 11125 over 31125, sort by created_at desc) plus
an explicit 10s timeout signal.
This eliminates the only remaining duplication between the migration hook
and the shared helper introduced in the previous commit.
- Add usePersistDailyProgress hook: debounced persistence of daily mission
progress to kind 11125 content, with flush on visibility-hidden and unmount
- Fix hydration race: deterministic merge strategy prefers persisted missions
when they carry real progress; keeps local if persisted has zero progress
- Fix content preservation: all kind 11125 write paths now preserve existing
content instead of publishing content: '' which wiped daily mission data
- Fix stale-read-then-write: useBlobbiPurchaseItem, profile normalization,
and onboarding auto-fix now use fetchFreshEvent before publishing
- Add isLoading state to useDailyMissions for proper loading/empty exclusion
- Convert take_photo missions from event to tally tracking
- Extract trackInventoryDailyActions for DRY daily mission tracking
- Handle legacy EventMission → TallyMission conversion in trackTally
- Add dev-only Reset Daily Missions button in BlobbiDevEditor
The content: '' pattern originated in 251ea43e and several subsequent commits
that created kind 11125 write paths. It only became a data-loss bug when daily
mission persistence was introduced. No single regression-of commit is
identifiable — it is a systemic gap across multiple files.
When the home-feed Follows tab is empty, the 'Discover people to follow'
button links to /packs. That page is also a Follows/Global tabbed feed,
and it likewise defaulted to Follows — so a user who follows nobody
landed on another empty view.
Pre-seed the /packs feed tab to 'global' in sessionStorage before
navigating, so the link lands on a populated view.
Regression-of: 399df4da
The alt-tag fallback shipped in 9813a226 let several display paths fall
through to other tags (title, name, summary, description, d) or to the
raw content when alt was absent. For an unknown kind like attestr.xyz
(31871), that surfaced the opaque d-tag identifier
'e5272de9:289bce03a0b7:1777206698' as the preview and leaked raw content
into the hover-card reply indicator — both worse than the 'This event
kind is not supported' tombstone the feature was meant to produce.
Tighten the fallback everywhere an unknown kind might render:
- getEventFallbackText: only the NIP-31 alt tag; no title/name/d.
- CommentContext.getEventDisplayName: known kinds keep title/name/d,
unknown kinds consider only alt. getKindLabel returns 'an unsupported
event' instead of 'a post', so 'Commenting on ...' never implies the
root is a text note.
- EmbeddedNote tagMeta: alt only, no title/name/description fallback.
- ExternalContentHeader.AddressableEventPreview: drop d-tag fallback.
- EmbeddedNaddr: gate rich title/description/content rendering behind
a known-kind check; unknown kinds render UnknownKindContent instead
of extractMetadata, which was leaking plaintext content as the
description when the body wasn't JSON.
Regression-of: 9813a226
Previously, any kind not explicitly handled by NoteCard or
PostDetailContent fell through to the kind-1 text-note renderer, which
ran the URL/hashtag/nostr: tokenizer over arbitrary content — broken
for events whose content is JSON or empty.
Add an UnknownKindContent component that displays the NIP-31 'alt' tag
(falling back to title/name/summary/d) in a rounded card, or a dashed
'This event kind is not supported' tombstone when the event carries no
fallback text. Route to it from both dispatchers when the kind isn't
1, 11, or 1111.
Extend the same handling to embedded quote previews (EmbeddedNote,
EmbeddedNaddr, AddressableEventPreview) so reply-context hover cards,
compose previews, more-menu previews, notification references, and
inline nostr: mentions all display unknown kinds consistently instead
of feeding JSON or arbitrary content to the kind-1 tokenizer.
Detail page: render the kind 2473 / 30621 action header ("Alex Gleason
heard a bird", "… drew a constellation") alongside the existing
per-kind headers, using the same phrasing as KIND_HEADER_MAP.
Bird-detection card: add a Discuss link next to the Wikipedia link,
routing to /i/<wikidata-url> so comments on the species' NIP-73
identifier aggregate across detections.
Regression-of: c957041c
Surface a "View on Birdstar" external action that opens the constellation
on birdstar.app via its naddr1, which is more useful than the edge count
— users can actually interact with the figure there.
Regression-of: c957041c
Adds feed support for kind 2473 (bird-by-ear detections) and kind 30621
(user-drawn star figures) from Birdstar. Detections render as species
cards using the existing Wikidata + Wikipedia summary hooks; constellations
render as gnomonically-projected SVG star-maps backed by the Hipparcos
catalog from d3-celestial. The 1.1 MB catalog is code-split via lazy() so
it only loads when a constellation event is actually viewed.
Introduce external social interactions for Blobbi companions. Other users
can feed, play, clean, and medicate a Blobbi they don't own via kind 1124
events. The owner's canonical 31124 state absorbs these interactions
through a checkpoint-based consolidation system with auto-sync on page load.
Key additions:
- Kind 1124 event schema with validation, parsing, and deterministic sort
- Social projection pipeline (read-only stat overlay from pending interactions)
- Owner-side consolidation into canonical state with checkpoint advancement
- Auto-sync hook (useCanonicalSync) triggered when owner opens /blobbi
- Social permission toggle (open/closed tag on 31124)
- Interaction UI: popover with item carousel on feed cards, detail pages,
and the owner dashboard action bar
- Interaction reactions: facial expressions, sparkles, bubbles, floating
hearts with phase-based animation system
- Activity tab showing interaction history with caretaker attribution
- BlobbiSocialActions component with egg gating and cooldown logic
- NIP.md documentation for the new kind
- Extract useTypewriter hook to shared module (was duplicated in both
BlobbiHatchingCeremony and BlobbiEvolveCeremony)
- Extract hexToRgb, blendToWhite, buildRevealGradient to shared
ceremony-colors module (was duplicated across both ceremonies)
- Remove unused updateCompanionEvent prop and NostrEvent import from
BlobbiEvolveCeremony
- Add prefers-reduced-motion override for evolve ceremony animations
via data-evolve-ceremony attribute selector
Replicate the letter-by-letter reveal from the hatching ceremony. The
first line types out immediately when the adult appears, then the second
line starts typing once the first completes. Both lines show a blinking
cursor while typing.
The text was only appearing in a separate dialog phase at the very end,
barely readable before fadeout. Now it fades in 1.5s into the reveal
phase so users have plenty of time to read it alongside the adult.
The onComplete callback was an inline arrow in BlobbiPage, so every
parent re-render (triggered by the evolve mutation updating companion
data) created a new reference and restarted the entire useEffect timer
chain. Stabilize both onComplete and onEvolve behind refs so the
timeline runs exactly once regardless of parent re-renders.
Background 0.8s -> 0.15s, adult blobbi 0.5s -> 0.15s, glow 0.8s -> 0.2s,
shine 0.5s -> 0.15s, halo 0.8s -> 0.2s. Flash gap 0.8s -> 0.4s.
The adult and its backdrop now pop in almost immediately after the flash.
The gap between baby vanishing and adult appearing was ~1.8s (1.4s flash
+ 400ms delay). Now the reveal starts 0.8s after the flash and the adult
fades in immediately, cutting the dead time to under a second.
The Follows tab of the profile recovery dialog previously showed only
a generic Users icon, a follow count, and a timestamp — making every
historical snapshot look identical and giving users no way to
distinguish one version from another. Now each snapshot renders an
avatar stack of the followed profiles, which makes differences between
snapshots visible at a glance.
Changes along the way:
- Extract the avatar-stack block from PeopleListContent into a shared
PeopleAvatarStack component (sm/md/lg sizes, hover-to-pop-forward
with display-name tooltip, "+N more" overflow).
- Reverse `p` tags in kind 3 displays so the newest follows surface
first. Kind 3 grows by appending, so the natural order is
oldest-first — the same early follows would otherwise dominate every
preview. Implemented as a getDisplayPubkeys(event, pubkeys) helper
used at display sites only; mutations and filters keep the original
array.
- Compact the snapshot cards in ProfileRecoveryDialog by moving the
Restore button into the top-right slot that already hosted the
Current badge, and dropping the redundant "<icon> N follows" title
row from the Follows card since the avatars communicate the same
thing more clearly.
The hatching ceremony reveal phase used a hardcoded blue background
regardless of the blobbi's color. Now the background derives from the
baby's baseColor, blended toward white for a soft pastel, with a
vignette shadow overlay so the blobbi pops against same-hue backgrounds.
The baby-to-adult evolution transition previously had no animation at
all. A new BlobbiEvolveCeremony component provides an immersive
full-screen experience: energy particles spiral inward, the baby glows
and scales up, a flash fires the evolve mutation, and the adult form
is revealed with color-matched sparkles and radiant glow.
The feed toggle at Settings > Content > Social was labeled 'Follow Packs'
with kind badge [39089], hiding the fact that it also controls kind 3
follow lists and kind 30000 people sets (both bundled via extraFeedKinds).
Rename the label to 'People Lists', expand the description to name all
three variants, and rename the storage keys:
feedIncludePacks -> feedIncludePeopleLists
showPacks -> showPeopleLists
No migration. AppProvider shallow-merges stored feedSettings on top of
defaultConfig, so existing users (whose blob has the old keys but not
the new ones) fall through to the default 'true' on the new keys and
get people lists re-enabled. The old keys linger as dead fields in
localStorage and encrypted relay settings until the next write.
The sidebar, /packs route, and AddToListDialog still say 'Follow Packs'
where that narrower term is correct; only the feed-inclusion toggle,
whose scope covers all three kinds, was renamed.
useFeed already runs shouldHideFeedEvent to drop deprecated kind 30000
follow sets (reserved d-tags, empty lists), unlisted decks, hidden
treasures, and empty emoji packs. useProfileFeed didn't, so those events
reached NoteCard just to be filtered out there, costing wasted mounts
and diverging the two code paths.
rawCount still uses pre-filter counts so pagination-exhaustion detection
matches useFeed.
The questionnaire that used to drive this block was simplified away in
e88f9e5f, but the save handler kept writing a full hardcoded feedSettings
object. That clobbered App.tsx's defaultConfig with a narrower preset and,
on the phase === 'not-found' path, could overwrite a returning user's
tuned settings if the encrypted-settings fetch returned empty. It also
drifted every time a new setting was added to App.tsx.
Trust defaultConfig for fresh users and encrypted-settings sync for
returning users. The follow-list check and step routing (outro vs follows)
are what the callback is actually for.
Two fixes on the members-only filter UI:
1. Toggling the shield now updates feeds live, without a reload.
The previous implementation used `useLocalStorage` in two separate
components. Each call instantiates its own `useState`, so writes from
one didn't flow to the other's reader. `localStorage`'s `storage`
event only fires cross-tab, not in the tab that wrote — so same-tab
consumers stayed stale until a remount.
Replaced with a module-level singleton store subscribed via
`useSyncExternalStore`. All consumers share one source of truth;
toggling rerenders every subscriber in the same tab instantly. The
store still persists to localStorage and listens for cross-tab
`storage` events, so behaviour across tabs is unchanged.
2. Move the shield off the CommunityDetailPage tab row.
Placing the toggle inline with the TabsList made it sit on the
bottom-border stroke that belongs to the tabs, reading as if the
shield itself were an underlined tab. Moved it up one row, right-
justified on the "Founded by" label row. Visually cleaner and still
scopes the filter to the entire community (all content feeds under
the tabs, current and future), not any single tab.
Two NIP-alignment fixes:
Gap 1 — Report warnings now require `p` match (correctness).
Previously `CommunityContentWarning` looked up reports by event id only,
so any community member could publish a kind 1984 pairing a victim
event's id with their own pubkey on the `p` tag to force a warning
overlay onto an arbitrary event. Added `getApplicableReports` in
communityUtils mirroring `hasApplicableContentBan`, and use it to
require `report.targetPubkey === event.pubkey` before the warning
renders. Matches NIP.md §Reports — Content Warnings: "report warnings
MUST only attach to content when the target event's id matches the
report's `e` tag and the target event's pubkey matches the report's
`p` tag."
Gap 2 — Members-only filter toggle.
The NIP recommends canonical community feeds discard non-member
content by default. Added a shield-icon toggle that controls this as
a presentation-layer filter, defaulting on. When active, community
feeds (Activities feed, per-community Comments tab, and any future
community-scoped content surfaces) only show events authored by
chain-validated members. When off, everything scoped to the
community is shown regardless of authorship.
- `useMembersOnlyFilter` — localStorage-backed hook with cross-tab
sync; one preference shared across all community surfaces.
- `MembersOnlyToggle` — shield / shield-off icon button with tooltip
explaining current state.
- Filtering is applied post-query in the consumer pages, so toggling
is instant and doesn't invalidate the query cache.
- Community definition events (kind 34550) are never filtered — they
represent the community itself, not user-generated content.
- Toggle placement: in `CommunitiesPage` header (scopes the global
Activities feed); in `CommunityDetailPage` alongside the tabs
(scopes every content feed in that community, now and future).
- Empty-state copy hints at the filter when a list is empty only
because of it.
Drops the read-only calendar-events (kind 31922/31923) listing from
CommunityDetailPage. The feature was partial — events could be listed
but not created from the community context — and the moderation /
authorship model for community-scoped events needs its own design
pass. Keeping it half-shipped complicates the moderation foundation
this branch is establishing.
A proper community events implementation will land in its own MR with
clearer scope: creation, RSVP handling, moderation rules for
community-scoped NIP-52 events, and whether the activity feed should
surface them.
General (non-community) calendar event support is unaffected —
EventsFeedPage, CalendarEventContent, CalendarEventDetailPage, RSVP
hooks, and the feed dispatch all remain. The community activity feed
already did not include kind 31922/31923, so no change there.
Two fixes prompted by external review:
1. resolveCommunityModeration now takes the community A tag and filters
events by matching `A` tag as its first pass. The previous change
removed the A-tag existence check from parseCommunityReport on the
assumption that callers scope by relay `#A` filter; that was an
invariant of the current callers, not a property of the API. Moving
the check to the resolver restores the trust boundary at the public
API surface while keeping parseCommunityReport a pure single-event
parser. The activity feed's pre-grouping pass is dropped since the
resolver now handles per-community filtering itself.
2. Drop the `['community-members', aTag]` cache seeding from the
activity feed. The activity feed uses shared relay limits across
every subscribed community (500 awards and 500 reports total), so
per-community results can be truncated. Seeding the per-community
members cache with incomplete data would silently corrupt membership,
authority, and moderation state on community detail pages.
useCommunityMembers remains the authoritative per-community fetch.
- Extract community content warning's context subscription into the
wrapper itself so NoteCard's memo() boundary no longer depends on
moderation data. Refetches now re-render only the warning and the
three-dot menu, not the whole card.
- Rename useCommunityModeration -> useCommunityModerationForEvent and
return the full context value; PostDetailPage installs it as a
Provider, removing 7 manual communityContext prop passes. Unifies the
three previous paths for computing CommunityMenuContext down to one.
- Seed the per-community members cache from the activity feed so
opening a community detail page after the feed loads hits warm cache
instead of re-querying kind 8 awards and kind 1984 reports.
- Single-pass parse in resolveCommunityModeration (was parsing each
kind 1984 event twice across the ban and report passes).
- Drop the redundant A-tag existence check in parseCommunityReport;
callers scope events via the relay's #A filter.
- Scope ban/report cache invalidation with a predicate that only
matches activity feeds containing the affected community's A tag.
- Drop CommunityMembership.totalCount (was just members.length) and
consolidate scattered EMPTY_* sentinels into EMPTY_MEMBERSHIP and
EMPTY_RANK_MAP in communityUtils.
Rename memberMap -> rankMap to clarify it is a pre-moderation rank lookup
(includes banned members) and should not be used to list active members.
Extract canBanTarget(), getViewerAuthority(), isEventAllowedByModeration(),
CommunityMenuContext, and EMPTY_MODERATION into communityUtils as shared
primitives, eliminating duplicated logic across hooks and components.
Remove unused ApplyCommunityModerationOptions dead code.
Rework resolveCommunityModeration into a two-pass approach so that
members who are themselves banned cannot retain moderation authority:
Pass 1: collect valid ban candidates, sort by reporter rank ascending,
then apply them — skipping any candidate whose reporter was already
banned by a higher-ranked member earlier in the pass.
Pass 2: collect non-ban reports, skipping reporters who ended up in
the banned set from pass 1.
The NoteMoreMenu 'Remove post' and 'Ban' options were only visible on
the community detail page where CommunityModerationContext was provided.
Now they also appear in the activities feed and post detail page.
- Add useCommunityModeration hook for PostDetailPage (resolves community
context from event's A tag with lazy queries)
- Extend useCommunityActivityFeed to expose per-community memberMap and
moderation data (zero extra queries — reuses already-fetched data)
- Wrap each NoteCard in ActivitiesTab with CommunityModerationContext
- NoteCard itself is untouched — no performance impact on other feeds
- Eliminate double resolveMembership call by filtering banned members post-hoc
- Memoize community context derivation in NoteMoreMenu
- Hoist viewerMember lookup out of render loop in CommunityDetailPage
- Only mount BanConfirmDialog when viewer has ban authority
- Deduplicate NIP-56 report type definitions into canonical source
Reinstatement via kind 5 deletions will be implemented in a future branch.
Removing it now eliminates an overly-broad unscoped query and a security
issue where any pubkey could reinstate banned content.
Add two-tier moderation system for hierarchical communities using kind 1984
events scoped via A tags. Authoritative bans use NIP-32 labels
([l, ban, moderation]) and require rank authority. Soft reports use standard
NIP-56 types and trigger content warnings for any valid member.
- Update NIP.md with ban/report classification, NIP-32 label schema, and
reinstatement via kind 5
- Add parseCommunityReport(), resolveCommunityModeration() to communityUtils
- Update resolveMembership() to apply moderation overlay (remove banned members)
- Update useCommunityMembers to fetch kind 1984/5 and resolve moderation
- Add CommunityModerationContext for propagating moderation state
- Add CommunityReportDialog for soft reports (NIP-56 types)
- Add BanConfirmDialog for content removal and member bans with optional reason
- Add CommunityContentWarning component for click-to-reveal reported content
- Wire moderation into NoteMoreMenu (auto-detects community context)
- Wire moderation into CommunityDetailPage (member ban buttons, feed filtering)
- Add Remove content / Ban @user menu items to NoteMoreMenu
- Remove Copy Link to Post and Mention @user from NoteMoreMenu
- Move Mute Conversation into the mute/report section
Replace the plain launch card with a Game Boy-style cartridge (using
public/cartridge.png) whose label region centers the app icon. The whole
cartridge scales as one image and reacts to the pointer with the
existing useCardTilt 3D effect.
The cartridge is tinted by the icon's dominant color: a new
useDominantColor hook samples the icon in an off-screen canvas, picks
the heaviest hue bucket, and exposes it as HSL. A mask-image layer
masked to the cartridge silhouette blends the color over the grayscale
PNG with mix-blend-mode: color, preserving the shading. Grayscale or
CORS-blocked icons fall back to the original gray cartridge.
The app name moves out from under the cartridge and into the description
card in FileMetadataContent — rendered larger and bolder above the
note's content — while WebxdcEmbed still renders its own name card when
a parent isn't providing one (e.g. the kind 1 imeta path).
Deletes the DM implementation (DMProvider, DMContext, useDMContext,
useConversationMessages, DMChatArea, DMConversationList,
DMMessagingInterface, DMStatusInfo, dmMessageStore, dmUtils,
dmConstants, the orphaned pages/Messages.tsx, and the
nostr-direct-messages skill) and removes the corresponding wrapper
from the provider tree in App.tsx.
The feature was already disabled (dmConfig.enabled = false), so this
removes no user-visible functionality -- only ~1,600 lines in
DMProvider and the associated UI/context/hooks. The nip44/nip04 signer
paths used by drafts, letters, mute lists, and encrypted settings are
unrelated and remain. Kind 1222 voice messages are a public-feed
feature and stay.
Documentation cleanup: strip the three DM mentions from AGENTS.md
(Project Structure, App.tsx provider list, Specialized Workflows skill
pointer) and the Private Messaging bullet from README.md's feature
list. Historical CHANGELOG entries are preserved.
Extract eleven topic areas into loadable skills so AGENTS.md can serve
as a scannable overview instead of a specification dump. The file
shrinks from 1480 to 358 lines (~76%) while keeping every concrete
rule, critical code pattern, and pointer that an agent needs on first
read.
New Ditto-specific skills:
- nostr-kinds: NIP-vs-custom-kind decision framework, kind ranges,
tag design, content-vs-tags, NIP.md update rule, and Ditto's
seven-location UI registration checklist for new kinds (NoteCard,
PostDetailPage, extraKinds.ts, KIND_LABELS/KIND_ICONS in
CommentContext, WELL_KNOWN_KIND_LABELS in ExternalContentHeader,
EmbeddedNote/EmbeddedNaddr, ReplyComposeModal).
- nostr-publishing: useNostrPublish, the read-modify-write pattern
via fetchFreshEvent + prev for replaceable/addressable events,
published_at contract, and d-tag collision prevention.
- nostr-queries: the standard useNostr + useQuery pattern,
combining kinds into one filter to avoid rate limits, and the
NIP-52 validator walkthrough.
- theming: @fontsource install flow, the Ditto runtime font-loader
path (sanitizeUrl + sanitizeCssString), color scheme variables,
useTheme toggle, and the isolate + negative-z-index gotcha.
- ci-cd-publishing: Zapstore NIP-46 bunker auth (zsp +
nip46-auth.mjs), nsite deploys (nsyte nbunksec + configured
relays/servers), and Google Play AAB uploads via fastlane supply
(service-account JSON base64 encoding and rotation).
- capacitor-compat: WKWebView/WebView limitations, the
downloadTextFile / openUrl helpers in src/lib/downloadFile.ts,
platform detection, and the full plugin list.
- git-workflow: pre-commit validation order and the Regression-of:
trailer convention used by the release skill's changelog
generator.
Ported from mkstack, lightly adapted where needed:
- nip19-routing: root-level /:nip19 routing and filter construction
patterns (adapted to reference Ditto's existing NIP19Page).
- nostr-relay-pools: nostr.relay() and nostr.group() for targeted
queries.
- nostr-encryption: NIP-44 / NIP-04 via the user's signer.
- file-uploads: useUploadFile + Blossom + NIP-94 imeta tag
construction.
AGENTS.md itself now follows mkstack's density — concrete rules inline,
one code example per section, pointer to the matching skill for details.
The enumerations that previously bloated it (every shadcn primitive,
every hook, every Capacitor plugin, the full NostrMetadata type dump,
the NIP-19 prefix reference table, etc.) are either removed in favor
of "ls the directory" or moved into their skill.
Adds three new skills extracted from mkstack's restructured AGENTS.md
and trims the corresponding AGENTS.md sections to match.
- nostr-security: XSS threat model, URL and CSS sanitization patterns,
author filtering for trust-sensitive queries, NIP-72 moderation
walkthrough, and a pre-merge checklist. The skill's references to
sanitizeUrl and sanitizeCssString are pointed at Ditto's existing
helpers in src/lib/sanitizeUrl.ts and src/lib/fontLoader.ts.
- testing: Vitest + TestApp conventions, mocked browser APIs, and the
project policy on when (not) to create new test files.
- nip85-stats: reference documentation for NIP-85 Trusted Assertion
stats (kinds 30382, 30383, 30384) including a ready-to-copy
useNip85Stats hook for future use; not currently wired into Ditto.
AGENTS.md changes:
- Shrink the Nostr Security Model section from a verbose kinds-and-URLs
walkthrough into a compact rule list plus a spoof-vs-authors example,
with a pointer to the new skill.
- Trim the Writing Tests section to the policy + skill pointer, moving
the TestApp example and browser-API mocks into the skill.
- Demote Loading States / Empty States from a top-level section to a
subsection under CRITICAL Design Standards so the document's
top-level headings describe domains, not presentation details.
Net: AGENTS.md 1654 -> 1480 lines (~10%).
Replace inline arrow functions with useCallback, matching the pattern
already used in CareBar. This keeps the onFocusChange prop identity
stable across renders so ItemCarousel's internal prev/next callbacks
are not needlessly recreated.
- ItemCarousel: add effect to realign index when initialItemId changes
after mount (e.g. Blobbi switch triggers useLocalStorage key change)
- CareBar: add effect to sync focusedMeta when storedFocusId changes,
so contextual side buttons match the restored carousel item
- CareBar: remove eslint-disable on handleFocusChange by adding
setStoredFocusId to the dependency array
- Use logged-in user pubkey (useCurrentUser) instead of
companion.event.pubkey for all localStorage keys, making them
explicitly user-scoped
Remember the last user-selected room per Blobbi, and the last focused
carousel item per room. Both survive page refresh and room switching.
Room persistence:
- storedRoom written only on user-driven navigation (not sleep override)
- Sleeping temporarily forces 'rest'; waking returns to the stored room
- Falls back to profile.room tag then DEFAULT_INITIAL_ROOM
Carousel persistence:
- New initialItemId prop on ItemCarousel seeds the focused index on mount
- Each bar (Home, Kitchen, Care) stores its focused item id in localStorage
- Stale ids safely fall back to the first item (existing clamp logic)
- CareBar also initialises focusedMeta from the stored item so side
buttons render correctly on mount
Wire up pointerdown/up and the glare overlay so press-and-drag drives
the tilt on touch, matching the BadgeDetailContent behaviour. A quick
tap still opens the lightbox via the inner button. touch-action: pan-y
keeps vertical page scrolling working.
gatherer.wizards.com URLs (e.g. /BNG/en-us/156/xenagos-god-of-revels or
the legacy ?multiverseid=...) are now resolved through the Scryfall API
and rendered as actual Magic: The Gathering cards throughout the app:
- /i/<gatherer-url>: GathererCardHeader shows the card art at 280px max
width with properly rounded corners, a mouse-driven 3D tilt + specular
glare matching the badge showcase, a click-to-open lightbox, and a
face toggle for DFC/MDFC/split cards.
- Page <title> on /i/<gatherer-url> uses the real card name.
- 'Commenting on …' breadcrumbs under kind 1111 comments show the card
name with the CardsIcon and a hover-card preview.
- Parent context on PostDetailPage (e.g. /nevent1… for a comment rooted
on a Gatherer URL) shows a compact preview row matching the ISBN and
country patterns: small card art, 'Magic Card' label, card name, set.
Scryfall integration is centralised in src/lib/scryfall.ts (image URLs
and typed JSON fetching) and src/hooks/useScryfallCard.ts. MagicDeckContent
has been refactored to use the shared image helper. All four call sites
share a single TanStack Query cache keyed on the lookup, so one card
triggers one network request.
Adds special handling for Wikidata entity URLs (https://www.wikidata.org/entity/ID
and https://www.wikidata.org/wiki/ID) on the /i/ external content page.
When a Wikidata URL is used, the entity's enwiki sitelink is resolved via the
Wikidata Action API and the page renders the same rich Wikipedia embed that
would appear for the Wikipedia URL directly. Falls back to a generic link
preview when the entity has no English Wikipedia article.
Delete unused components that were superseded by inline UI in BlobbiPage:
- shop: BlobbiInventoryModal, BlobbiShopModal, BlobbiPurchaseDialog, BlobbiShopItemRow
- actions: SingModal, BlobbiMissionsModal, TasksPanel, DailyMissionsPanel,
HatchTasksPanel, StartIncubationDialog, StartEvolutionDialog
Clean up related barrel exports in actions/index.ts, stale JSDoc
in ItemEffectDisplay.tsx, a stale comment in BlobbiPage.tsx, and
an unnecessary export on findGuideItemForStat.
No behavior, UX, balance, mutation, decay, or query changes.
Extract maybeOverfeedPoop() helper so both handleFeedItem (tap/fridge)
and handleFeedFromDrag (drag-to-feed) run the same overfeed threshold
and random poop roll. Previously drag-to-feed bypassed the check
entirely, meaning overfeed poop could never trigger via drag.
Also fix orphaned JSDoc in generators.ts: move CHEW_CYCLE_SEC and its
docblock above the generateChewingMouth function so each symbol's
JSDoc attaches to the correct declaration.
Without a key, React recycles the CrumbBurst instance when a second
feed fires within the 1200ms crumb window. This prevents CSS
animations from restarting and the reward word from re-randomizing.
Using feedSeqRef.current as the key forces a full re-mount on each
new feed sequence.
The eating mouth used hardcoded rx=6/ry=7 in absolute SVG units, which
looked correct for babies (100x100 viewBox) but was proportionally too
small for adults (200x200 viewBox) and absurdly tiny for Froggi (110px
mouth width). The chewing mouth used controlY directly as the center,
which overshoots downward on deep smiles (Froggi: 12.5px too low).
New shared helper computeActionMouthGeometry(mouth) derives:
- cy from 55% of the baseline→controlY curve (visual midpoint)
- rx from clamp(halfWidth * 0.18, 4, 9) (proportional, not explosive)
- eating/chewing ry values from rx (consistent transition)
Both generateEatingMouth and generateChewingMouth now use this helper,
so the eating→chewing transition preserves the same anchor and size.
Both emit data-blobbi-mouth for DOM-based crumb positioning.
The eating recipe switches from roundMouth to the new eatingMouth flag.
Crumb spawning in BlobbiPage is now wrapped in requestAnimationFrame so
the DOM query sees the committed chewing mouth rather than the previous
eating/neutral mouth (React 18 batches setState synchronously).
Regression-of: 6903712b
The crumb origin was hardcoded at 67% of the visual wrapper height,
which misaligned with variants whose mouths sit higher (leafy 50%,
rosey 53%) or lower (froggi 72.5%, mushie 76.5%).
generateChewingMouth now:
- Emits data-blobbi-mouth="1" on the ellipse for DOM querying
- Scales rx from the detected mouth width (clamped 4-14) so wide-
mouthed variants like Froggi get a proportional chomp
BlobbiPage crumb spawning now:
- Queries [data-blobbi-mouth] inside [data-blobbi-visual]
- Uses its bounding rect center for both crumbX and crumbY
- Falls back to the old 67% ratio when the marker is absent (Owli)
- Reward text remains anchored via the visual rect (unchanged)
Crumbs now originate from per-particle sx/sy offsets across a compact
16px-wide mouth strip with slight vertical curve, then tumble mostly
downward with reduced lateral spread. Particle sizes shrunk to 2-4px
for a crumb feel. CSS keyframe no longer teleports crumbs sideways at
frame 0 — they start at their spawn point and drift to (dx, dy).
Also expanded REWARD_WORDS from 4 to 10 cute variations.
Reduce dx/dy values across all three rings so the burst stays close to
the chewing mouth instead of spraying across the face. Particle count,
colors, delays, sizes, and all other feed-reward logic are unchanged.
Extend the chewing phase (700ms → 1200ms) and crumb burst so the feed
reward feels more satisfying. Crumbs go from 6 to 12 particles in warm
amber/orange/yellow tones with wider spread and a longer CSS animation
(600ms → 1100ms). A random floating word (yum!/nom!/mmm!) drifts up
from the mouth during the burst.
All durations are now named constants (CHEW_DURATION_MS, CRUMB_DURATION_MS,
HAPPY_DURATION_MS) for easy tuning.
Both crumb and reward-pop animations respect prefers-reduced-motion.
Mutation, Nostr publishing, XP, decays, streaks remain untouched.
Drag-to-feed:
- Press a food item in the Kitchen carousel to start dragging.
- A ghost emoji follows the pointer via direct DOM mutation (no React
re-renders during pointermove).
- Near Blobbi's mouth the ghost scales down and Blobbi opens its mouth.
- Drop near the mouth to feed; drop anywhere else to cancel.
- The drag lifecycle is owned by global window listeners (pointermove,
pointerup, pointercancel, blur), not by the carousel button. This
eliminates ghost-stuck and ghost-reappear bugs caused by React
re-renders swapping button-level handlers mid-capture.
- Every listener checks pointerId + monotonic session counter.
- Cleanup is idempotent and runs on feed/miss/cancel/blur/unmount.
Chewing animation (Phase 2 polish):
- On successful feed, Blobbi shows a 700ms chewing animation (SMIL
mouth oscillation) with a crumb particle burst at the mouth.
- The item-use mutation fires immediately on drop — no delay.
- After the chewing phase, if the mutation succeeded, Blobbi shows a
happy expression for 1500ms before returning to status-based state.
- If the mutation fails, chewing clears without showing happy.
- feedSeqRef + mountedRef prevent stale timers/promises from writing
state after a newer sequence starts or the component unmounts.
- actionCleanupRef shared between tap-to-use and drag-to-feed paths
prevents a stale tap-to-use timer from clobbering drag-feed emotion.
Supporting changes:
- New 'chewing' emotion with generateChewingMouth() SMIL generator.
- replaceCurrentMouth regex handles animated <ellipse> elements.
- BlobbiRoomHero wrapped in React.memo to prevent SVG animation
restarts from unrelated parent re-renders.
- useStatusReaction memoizes the override recipe so the same
actionOverride produces a stable object reference.
- @keyframes crumb-fall added to index.css with reduced-motion support.
- CrumbBurst component renders 6 CSS-animated particles at the mouth.
- ItemCarousel.centerPointerHandlers reduced to { onPointerDown }.
- DEBUG_FOOD_DRAG flag for development tracing.
Declare the Google Play and App Store listings in the PWA manifest so
browsers can surface the native apps where appropriate. Set
prefer_related_applications to false so the PWA install path remains
the default.
PoopOverlay renders all poops in every room, not filtered by
poop.room. The old comment described a room-aware filtering
strategy that was removed in an earlier commit.
The probability check was only in generateInitialPoops (mount time).
The live feeding path in KitchenBar always spawned a poop. Now both
paths use the shared OVERFEED_CHANCE constant.
PoopOverlay (passive) now renders all poops regardless of poop.room.
The room field still controls where the shovel can clean them
(InteractivePoopOverlay filters by roomId).
The overlays now filter by poop.room so the data model is respected
end-to-end: poop-system assigns a room, overlays render only poops
matching their room. Currently all poops spawn in kitchen so the
behavior is identical, but enabling multi-room spawning later only
requires changing the room assignment in poop-system.ts — no UI
changes needed.
- PoopOverlay and InteractivePoopOverlay take a roomId prop and
filter via getPoopsInRoom()
- Restore MAX_POOPS to 3 (was raised to 6 in previous commit)
- Fix indentation in generateInitialPoops
- All poops spawn with room='kitchen' (reverts random room assignment)
- PassivePoopOverlay renders poop emojis in Home, Care, and Rest rooms
as static display-only elements (no interaction)
- KitchenPoopOverlay renders interactive poops with drag hit-test refs
- Shovel button remains exclusively in the kitchen bar
- useShovelDrag simplified: no longer takes roomId parameter
- Remove room-aware toast ('Try another room'); simple 'Nothing to
clean!' message when tapping shovel with no poop
- Poops now spawn in random rooms (care, kitchen, home, rest) and each
room renders them with a draggable shovel via shared PoopOverlay +
ShovelButton components
- Extract drag-to-clean logic into useShovelDrag hook (reused by all
room bars, eliminating ~100 lines of duplicated code from KitchenBar)
- Overfeed poop is now 60% probability instead of deterministic
- Time-based poop cap raised from 3 to MAX_POOPS (6), so longer
absences produce more poops proportionally
- Shovel replaces the left action button in each room when poop is
present (e.g. replaces Photo in Home, Towel in Care); reverts
when poop is cleaned
- Room-aware toast: 'Try another room' when poop exists elsewhere
The auto-resize effect only depended on content, so switching back
from preview mode remounted the textarea without recalculating its
height. Adding previewMode to the dependency array ensures the
textarea is resized immediately after remounting.
Add dir="auto" to text content elements across articles (title,
summary, prose), all compose inputs (ComposeBox, ArticleEditor,
MilkdownEditor, PhotoComposeModal, ComposeLetterSheet, LetterEditor),
and letter view components (LetterDetailSheet, LetterCard body/closing/
signature).
- Replace toggle-mode shovel with drag-and-drop: drag the shovel icon
onto a poop to clean it (works on both desktop mouse and mobile touch)
- Shovel button is now always visible in the kitchen (no more disappearing)
- Always show 'Shovel' label (removed 'Done' toggle text)
- Show toast when tapping shovel with no poop present
- Fix invisible poops: all poops now spawn in kitchen only (previously
time-based poops randomly spawned in rooms that never rendered them)
- Spawn poop immediately when feeding a blobbi with hunger >= 95
- Add addPoop() to PoopState for reactive poop creation
- Convert RoomActionButton to forwardRef with touch/mouse event props
- Cap total poops at 4 to prevent accumulation
BABY_DECAY.energy and ADULT_DECAY.energy were objects with an unused
sleeping field (6.0 / 5.0) that was overridden by the standalone
BABY_SLEEP_ENERGY_REGEN (40.0) and ADULT_SLEEP_ENERGY_REGEN (35.0)
constants. Flatten energy to a plain number to avoid misleading readers.
No behavior change — the ternaries already used the standalone constants.
Regression-of: 4d4d8a43
All callers now pass careState. The old status prop, its default value,
and the three ternary fallback branches were dead code.
Regression-of: 08be5e99
The old threshold was replaced by the segment-model care badge in
08be5e99 but kept with a void suppression. The new companionNeedsCare
function is self-documenting; the old value adds no reference value.
Regression-of: 08be5e99
The fridge overlay was computing segment deltas from companion.stats
(persisted), while the stat rings show projected stats with decay
applied. This caused the preview to disagree with the visible UI.
Thread currentStats (from useProjectedBlobbiState) through
RoomBottomBarProps so KitchenBar uses the same decay-projected values
the user sees in the stat rings.
The rooms-based UI (KitchenBar, CareBar, HomeBar) fully replaced the old
BlobbiActionInventoryModal / BlobbiActionsModal flow. The inventoryAction
state was never set to a non-null value, so the modal could never open.
Deleted:
- BlobbiActionInventoryModal.tsx (311 lines)
- BlobbiActionsModal.tsx (201 lines)
Removed from blobbi-action-utils.ts:
- previewStatChanges (superseded by previewStatChangesWithSegments)
- previewMedicineForEgg, previewCleanForEgg, EggStatPreview
- filterInventoryByAction, FilterInventoryOptions, ResolvedInventoryItem
Removed from BlobbiPage.tsx:
- inventoryAction state, handleUseItem (modal version),
handleOpenShopFromAction, modal JSX block
Kept live:
- previewStatChangesWithSegments (used by KitchenBar fridge overlay)
- usingItemId / setUsingItemId (used by room loading indicators)
- All stage/action restriction helpers (used by live item-use hooks)
Add previewStatChangesWithSegments() helper that computes before/after
segment counts using getBlobbiStatDisplayState, so preview badges can
communicate bar changes alongside raw stat deltas.
UI updates:
- Action inventory modal: shows (+1 bar) after the raw delta
- Kitchen fridge overlay: shows compact +N▮ segment indicator
- Egg stage omits segment info (always protected/full)
Includes 13 tests covering baby, adult, egg, and edge cases.
Baby (4 segments):
- hunger -8/hr, happiness -4.5/hr, hygiene -6/hr, energy -9/hr
- health base -0.4/hr (was -0.75)
- health penalty thresholds aligned to segment boundaries:
mild at < 50 (attention), strong at < 25 (urgent)
— was < 70/40, which fired penalties in the 'okay' range
- health regen threshold lowered to 76 (baby good = 4/4 starts at 76)
— was 80
Adult (10 segments):
- hunger -5/hr, happiness -2.5/hr, hygiene -4/hr, energy -5.5/hr
- health base -0.25/hr (was -0.4)
- penalty thresholds unchanged (already align with 10-segment model)
- regen threshold unchanged at 80
Pacing:
- Baby first 'okay' stat at ~2.7hr, first 'attention' at ~5-6hr
- Adult first 'okay' stat at ~5-6hr, first 'attention' at ~7-8hr
- Growing up feels like increased resilience, not more annoyance
Sleep behaviour, item values, careState mapping, segmented UI
rendering, and Nostr persistence are unchanged. Tests updated to
cover new rates, penalty alignment, and regen threshold.
Part 1 — Segmented rings:
Replace the continuous strokeDasharray progress ring with discrete arc
segments driven by the segment display model. Baby/egg shows 4 segments,
adult shows 10. StatIndicator exports a reusable SegmentedRing component.
When the segments prop is absent, the old continuous ring renders as a
backward-compatible fallback.
Part 2 — Ring gap fix:
Switch from strokeLinecap round to butt so gaps are not consumed by cap
extensions. Increase gapDeg to 16/20 (md/sm), bump muted opacity to 0.12.
Offset the start angle by half a gap so the first gap straddles 12-o'clock,
making the ring visually centred and symmetrical.
Part 3 — Sleep modifiers:
Sleeping is now restorative instead of punitive:
- Energy regen: +40/hr (baby), +35/hr (adult) — up from +6/+5
- Hunger/happiness/hygiene: decay at 20% of awake rates
- Health base decay: pauses (0) while sleeping
- Health penalties: reduced to 25% of awake strength
Awake decay rates, health penalty thresholds, item/action values,
hibernating behaviour, and Nostr persistence are unchanged.
Adds 17 focused unit tests covering baby/adult sleeping, awake decay
baseline, and hibernating-is-not-sleeping.
Replace scattered warning/critical status checks with careState from
getBlobbiStatDisplayState in the three UI consumers:
- StatIndicator (shared): new careState prop takes precedence over
deprecated status prop; badge shown for attention/urgent, pulse for
urgent only.
- BlobbiRoomHero (inline indicator): same careState-driven logic.
- BlobbiWidget: passes careState instead of getStatStatus result.
- BlobbiPage companion selector: care badge now shows when any stat is
urgent OR two-plus stats are attention (was: any stat < 40). Eggs are
always protected so they never trigger the badge.
Old threshold constants and getStatStatus are kept — no deletions.
No changes to decay rates, sleep, items, status-reactions,
needDetection, SVG ring rendering, or Nostr persistence.
Introduce getBlobbiStatDisplayState() — a read-only helper that derives
UI segment counts, care states, and badge/pulse flags from internal
1–100 stats without changing any gameplay behaviour.
Egg is always 'protected' with full segments. Baby maps to 4 segments
(urgent/attention/okay/good). Adult maps to 10 segments with wider
threshold bands. Values are clamped to STAT_MIN–STAT_MAX.
Includes 38 unit tests covering all boundary values, clamping, and
flag correctness.
- Remove orphaned JSDoc and dead isShort code path in VideoContent
- Unify vine mute state into shared vineGlobalMute module so mute
preference carries between NoteCard feeds and VinesFeedPage
- Fix fetchNextPage race cascade by using a ref guard instead of
putting isFetchingNextPage in the useCallback dep array
- Batch event ingestion in useStreamPosts so the event map is sorted
and flushed to state once per page instead of per-event
- Don't permanently kill pagination on transient network errors
- Move sort modifiers into searchParts before the join for consistency
- Wrap full VideoGridCard/ShortThumb content in ContentWarningGuard
so metadata doesn't leak when policy is blur
Kind 22 (Short-form Portrait Video) was rendering through VideoContent
with a shrunken max-width and a redundant "Short" badge, duplicating
the vine experience poorly. Now kind 22 shares the VineMedia component
with kind 34236, rendering full-width with play/pause and mute toggle.
- Unified isVine to match both kind 22 and 34236
- Removed isShortVideo flag and the small/badged VideoContent path
- Added mute/unmute button to VineMedia with shared module-level state
- Mute preference persists across shorts as you scroll through a feed
- Removed dead parseImeta function (replaced by parseVideoImeta)
useStreamPosts previously fetched a single batch of 40 events with no
way to load more. Refactored to track the oldest event timestamp and
expose fetchNextPage/hasNextPage/isFetchingNextPage for cursor-based
pagination using the same NIP-50 search filter with an `until` param.
SearchPage now renders an IntersectionObserver sentinel below the post
list that triggers loading the next page when scrolled into view.
The autoplayVideos config controls inline video players in normal
feeds. Vines use snap-scroll where the active slide should always
autoplay — gating on this setting made vines not play at all when
the user had autoplay disabled.
The guide step effect only handled the forward transition (room →
item/action on entering the target room). Add the reverse: when the
step is item or action but the user has navigated away, revert to
the room step so the directional arrow resumes blinking.
VideosFeedPage and VinesFeedPage rendered their own card components
(VideoGridCard, ShortThumb, VineCard) without any content warning
checks, bypassing the ContentWarningGuard used elsewhere. Videos
with NIP-36 content-warning tags displayed without blur or filtering.
- Wrap VideoGridCard and ShortThumb thumbnails in ContentWarningGuard
- Add full-screen dark CW overlay to VineCard matching vine aesthetic
- Filter out CW events when contentWarningPolicy is "hide" in both pages
- Prevent video autoplay while CW overlay is shown in VineCard
Previously only low-status (warning/critical) stat icons were clickable
and the guide auto-cleared when the stat recovered to normal. Now every
stat icon fires onGuide on tap regardless of status, and the guide is
only dismissed by completion events (item used, sleep started, or a
different stat clicked).
Changes:
- StatsCrown: remove the status-gate from onClick and cursor-pointer
- StatsCrown: add z-10 so stat icons layer above the Blobbi visual
- Blobbi animation wrapper: pointer-events-none so it cannot intercept
taps on overlapping stat icons
- BlobbiDashboard: remove the effect that cleared the guide when the
stat was normal; drop unused getStatStatus import
- Update comments and prop docs to reflect the new behaviour
- Extract readSeedUint32() for raw 32-bit reads instead of misusing
deriveIndexFromSeed with max=0x100000000 (which was a no-op modulus).
deriveIndexFromSeed is now only used for bounded array indexing.
- Guard deriveAdultFormFromSeed against NaN from short/invalid seeds.
- Guard adjustSeedForAdultType against indexOf returning -1 for
unknown form values.
Cache getBoundingClientRect() result in OverstimulationBlockOverlay
to avoid four forced reflows on the same element per activation.
Add sanitizeSvgColor() guard in generateAngerRiseEffect() so that
config.color is validated before interpolation into SVG stop-color
attributes, preventing injection if a future caller passes untrusted
color strings.
Scroll to top before applying the zoom transform and restore the
saved scroll position after zoom-out completes. The transform on
#root creates a new containing block that breaks sticky positioning
of the mobile top/bottom nav bars when the user is scrolled down.
- useFillLevelUpdate: shared recipe fingerprint + imperative gradient
stop updates, extracted from BlobbiAdultSvgRenderer and
BlobbiBabySvgRenderer (removed ~60 duplicated lines from each)
- useReactionDrain: shared rAF-based level drain loop with throttled
React state push, extracted from useOverstimulationReaction and
useShakeReaction (removed ~170 duplicated lines from each)
Net reduction: ~285 lines across the 4 consumer files.
The previous approach put a ref on the pointer-events-auto wrapper div,
which is a full-width block element -- getBoundingClientRect returned
the page width, not Blobbi's position. Now we query the companion's
actual fixed-position container via [data-blobbi-companion] to get
the true visual bounding rect.
When Blobbi hits max overstimulation, the entire UI now zooms toward
Blobbi's face (transform on #root) while a radial shockwave expands
from the companion and a red vignette dims the screen edges. On
recovery the zoom eases back out and the vignette fades.
The overlay is portaled onto document.body so it stays at viewport
scale while #root is scaled. Body overflow is hidden during the zoom
to prevent scrollbar flash.
Removes the canvas-based crumble/debris system (crumbleEngine.ts) in
favor of this simpler CSS-driven approach.
Replace the invisible click shield with a dramatic visual sequence when
Blobbi reaches max overstimulation: a radial shockwave expands from
Blobbi, a dark backdrop covers the UI (Blobbi stands alone in the void),
and canvas debris particles rain down like rubble. On recovery the
backdrop fades and debris converges back.
Also fix the debug bypass in useShakeReaction (true || threshold) that
shipped nausea on every shake regardless of hunger stat, and remove the
toast notification replaced by the visual overlay.
Pass lookMode="follow-pointer" to BlobbiStateCard in PostDetailPage,
matching the feed card behavior so Blobbi eyes follow the cursor on the
detail view as well.
Remove the intermediate popover + "Guide me" button step. Tapping a
low-status stat icon now calls onGuide immediately, starting the room
navigation or item/action highlight with zero friction.
Deletes the StatIndicatorWithHelp component (~120 lines) and its
associated imports (Popover, STAT_HELP_TEXT, Navigation, React state/
ref/callback/effect hooks). All stat icons now render through the
same StatIndicator; low-status icons get cursor-pointer and onClick
on their positioning wrapper.
Low-stat indicators now glow/pulse at warning and critical levels across
BlobbiPage and widgets via a shared StatIndicator. On BlobbiPage, hovering
(desktop) or tapping (mobile) a low stat shows contextual help with a
Guide me button that visually leads the user to the correct room and
item/action through a sequential glow chain: stat popover → room nav
arrow → carousel arrow → target item/action.
Implementation details:
- Synchronized low-status icon animations via a shared CSS @property
clock on the StatsCrown parent, so all icons pulse in phase regardless
of mount timing
- Split warning (stat-glow, 2s) and critical (stat-glow-critical, 2s)
into separate keyframes with distinct visual weight
- Guide glow uses a quick-blink-and-pause rhythm (1.1s cycle) distinct
from the status pulse
- Stable popover hover zone shared between trigger and content with
paired open/close timers; onOpenAutoFocus prevented to avoid flicker
- Guide cleanup on item use, action execution, new guide start, or stat
recovery
- ItemCarousel index only clamped when actually out of bounds, preventing
guide visual instability from unrelated re-renders
- Energy modeled as a first-class action target (sleep) rather than a
special case
- companionNeedsCare() uses calculateProjectedDecay() instead of raw
persisted stats, consistent with all other low-status UI
- Add useWorldFeed hook combining infinite-scroll pagination with live
streaming and 'X new posts' buffer/flush pattern
- World feed queries all country-tagged events globally with a diversity
cap (max 4 posts per country per page)
- Live streaming via persistent relay subscription with scroll-aware
buffering and highlight animation on flush
- Rename Ditto tab to World across Feed, ContentSettings, and useFeedTab
- Migrate localStorage key from ditto:showDittoFeed to agora:showWorldFeed
Add dir="auto" to NoteContent, BioContent, and DM message bubbles
so the browser's Unicode Bidirectional Algorithm automatically detects
text direction from the first strong directional character.
The path started and ended at the same point (100,105), making it a
triangle instead of a quadrilateral. The bottom-right hexagon vertex
(140,130) was missing entirely. Changed to trace center → right-mid →
bottom-right vertex → inner bottom-right point.
Fixed in all four locations: both .svg source files and both inlined
constants in adult-svg-data.ts.
The Poll/Spoiler popover opened side="bottom" from the toolbar, which
is near the bottom edge of the dialog. Because the dialog container
uses overflow-hidden (needed for flex layout containment) and the
PortalContainerProvider portals content inside the dialog DOM, the
popover was clipped.
Switch to side="top" so the menu opens upward into the visible area
of the modal.
The app renders Blobbi SVGs from inlined string constants in
adult-svg-data.ts, not from the .svg source files. The previous commits
only fixed the source files. This syncs all three fixes into the
inlined CRYSTI_BASE and CRYSTI_SLEEPING constants:
- Add animateTransform groups to CRYSTI_BASE sparkle circles
- Fix self-intersecting pink facet path in both constants
- Match sleeping facet opacities to base values
The sleeping SVG had all six facet opacities reduced by 0.2 compared to
the base, making it look washed out. Only the eyes/mouth should differ
between states — the body colors should stay vibrant, consistent with
how bloomi handles its sleeping variant.
The base SVG sparkle circles were missing animateTransform tags, causing
them to render static instead of orbiting like the sleeping variant.
Wrapped them in two animated groups matching crysti-sleeping.
The top-left pink facet (crystiFacet2) had a self-intersecting path
(bowtie shape) that caused inconsistent fill rendering. Reordered the
vertices to trace a proper convex quadrilateral in both base and sleeping
SVGs.
Remove the 'Make it yours' theme strip from the landing hero and the
ThemeStep from the signup/onboarding flow. Add an Appearance settings
page at /settings/appearance with three options (System, Light, Dark)
defaulting to System.
When the total debit (amount + network fee) crosses $100 USD, flip the
primary action into a confirmation affordance instead of silently
sending. Normal sub-threshold amounts are unchanged — still one tap.
OnchainZapContent: first tap arms a destructive-variant button,
second tap actually sends. Editing the amount or fee speed re-arms.
SendBitcoinDialog ConfirmView: adds a neutral informational note
("Sending $X — double-check the recipient and amount.") and flips
the Confirm & Send button to the destructive variant, so the second
click carries visible weight without extra friction.
Threshold and helper (`isLargeAmount`) live in lib/bitcoin.ts with
6 new tests covering the boundary, price-unavailable, and negative
input cases.
BlobbiStateCard gains an optional lookMode prop (default: 'forward'),
threaded through to BlobbiStageVisual. Only the NoteCard feed call site
passes 'follow-pointer'; post detail, embedded notes, and embedded naddr
keep the default forward gaze.
The global pointer listener in useBlobbiEyes now also tracks touchstart
and touchmove so the effect works on mobile.
The amber "Network fee is ~N% of your zap" message was alarmist and
added no useful signal — the user already sees the exact fee in the
Fee line above and the final total when they submit.
Regression-of: bddfe4b8
- Add 13 regression tests for Taproot address derivation, pubkey
validation, npub→address, and mainnet address validation
- Validate pubkey hex format (/^[0-9a-fA-F]{64}$/) in
nostrPubkeyToBitcoinAddress to fail fast on malformed input
- Match tapInternalKey against the signer's x-only pubkey in
signPsbtLocal, per the BITCOIN-SIGNING.md spec ("inputs whose
tapInternalKey does not match the signer's key MUST be left
unchanged"). Throw if no owned inputs are found.
- Use >= DUST_LIMIT (not >) for change-output dust check, so a change
of exactly 546 sats is preserved rather than donated to fees
- Extract formatBTC() helper into lib/bitcoin.ts; remove duplicated
replace(/\.?0+$/, '') from WalletPage, SendBitcoinDialog, and
BitcoinContentHeader
- Register kind 8333 ("Bitcoin zap") in CommentContext KIND_LABELS,
CommentContext KIND_ICONS, NoteCard KIND_HEADER_MAP,
signerWithNudge KIND_LABELS, and shellTitleForKind
- Disambiguate sign_psbt errors in NConnectSignerBtc: only re-wrap as
"doesn't support sending Bitcoin" when the error message looks like
a capability failure (unknown method, not implemented, etc.);
propagate transient errors unchanged
- Show the recipient's derived Bitcoin address in OnchainZapContent
so users can verify the destination before signing
- Clear knownUnsupportedBunkers on logout so a fresh login with an
upgraded bunker isn't tainted by a previous session's rejection
- Reject self-zaps in verifyOnchainZap (sender == recipient)
- Update NIP.md to specify: change-output handling, amount-cap vs
discard semantics, self-zap rejection, mempool/confirmation policy,
and mainnet-only scope
- Delete unused useNsecAccess hook
User-facing strings about signer capability referenced 'signPsbt',
'sign_psbt', 'PSBT', 'nsec', 'NIP-07', and 'NIP-46' — implementation
details a normal user shouldn't have to parse. Each site now says
'your browser extension doesn't support sending Bitcoin' (or the
bunker / generic variants), and points users at the 'secret key'
login option by its friendly name.
Changed sites:
* OnchainZapContent — unsupported-capability panel
* useOnchainZap — pre-send capability error
* bitcoin-signers.ts — NBrowserSignerBtc and NConnectSignerBtc
error strings (these surface as toasts in SendBitcoinDialog)
* SendBitcoinDialog — 'Signing Not Available' panel and the
in-mutation guard error
isSignerCapabilityError still matches the new copy (they all contain
"doesn't support"), so the capability-detection flow that flips the
UI from 'unknown' to 'unsupported' continues to work.
Regression-of: 008f3979
Previously, when a user's signer couldn't sign PSBTs, the Bitcoin zap
flow only discovered this after the user pressed Zap — surfacing a
toast after an otherwise-normal submission. The zap button was offered
as if it would work, and failure felt like a bug rather than a
capability limit.
Now useBitcoinSigner returns a three-state `capability`:
* supported — nsec login, or extension with window.nostr.signPsbt
present.
* unsupported — extension without signPsbt, OR a bunker that has
already rejected sign_psbt once in this session.
* unknown — bunker login with no capability info yet (NIP-46
has no capability-discovery RPC). Attempt is allowed
and if it fails with a 'does not support' error, the
hook calls reportSignerUnsupported(pubkey) to flip
the capability to 'unsupported' for the rest of the
session. A DOM event broadcasts the change so
consumer hooks re-render without a shared store.
OnchainZapContent renders an explicit 'Bitcoin zaps aren't available'
panel whenever capability === 'unsupported', with copy tailored to the
login type (different hints for nsec/extension/bunker). Inside
useOnchainZap, capability errors no longer show the generic failure
toast — the UI replacement is the only feedback the user sees.
ZapDialog defaults to the Lightning tab when Bitcoin is unsupported
and Lightning is available, and auto-switches mid-session if a bunker
rejects sign_psbt while the dialog is open — so the user is never
stranded on an unusable tab.
The Bitcoin zap dialog was heavier than the Lightning one, with a Review
step, a confirm screen, a balance card, a dropdown for transaction
speed, and a success view — all before you could actually send a zap.
The Lightning flow is presets → optional comment → Zap. Now Bitcoin is
the same shape.
Changes:
* Drop the form→confirm→success wizard. Single screen, single button.
The 'Zap' button does the whole thing; success closes the dialog
via the existing onSuccess callback (the hook already shows a toast).
* Remove the always-visible balance card. Balance only appears when
the amount exceeds available funds (or funds are zero).
* Collapse transaction speed into a one-line fee readout like
'Fee ≈ $0.12 · ~30 min' that opens a popover of the 4 speed options
when clicked. No dropdown taking up vertical space by default.
* Drop the 'Paying to <address>' card, the 'transactions are final'
warning, and the dedicated confirm screen — all redundant for a
small zap flow.
* Button label now reads 'Zap $5 · 5,123 sats' so users see both the
fiat amount they chose and the sats they're committing, without
needing a separate confirm screen to see either.
Fee-dominated warning becomes a single line of amber text instead of a
destructive alert; errors become a single line of destructive text.
Result is roughly half the vertical space and one click instead of three
to send a zap.
8333 is the Bitcoin mainnet P2P port, creating a clean semantic parallel
with NIP-57: kind 9735 (Lightning's P2P port) for Lightning zaps, kind
8333 for on-chain zaps. 3043 was the first free kind the generator
returned and carried no meaning.
Introduce kind 3043, a new Nostr event that attests an on-chain Bitcoin
payment against a target event or profile. Because every Nostr pubkey
deterministically maps to a Taproot address, any user can receive an
on-chain zap without configuring lud06/lud16 — the zap button now
appears on every post whose author is not the current user.
Publishing flow: sender builds and broadcasts a Bitcoin transaction
paying the recipient's derived Taproot address, then publishes a
kind 3043 event with an `i` tag (`bitcoin:tx:<txid>`), the recipient's
`p`, the target's `e` / `a`, and a self-reported `amount` in sats.
Before displaying or counting a kind 3043 event clients verify the
referenced transaction on-chain and use the sum of outputs paying the
recipient's address as the authoritative amount, capping the sender's
claim at the verified value to prevent spoofing.
Lightning zaps remain available as an opt-in tab inside the zap dialog
whenever the author has a Lightning address configured; otherwise the
dialog is purely on-chain. Defaults favour on-chain: USD amount presets
($1 / $5 / $10 / $25 / $100), fee-speed selection, and a 3-step
form → confirm → success flow mirroring SendBitcoinDialog.
The module-level setupInFlightFor guard had a race condition: the
effect cleanup unconditionally deleted the pubkey from the guard set
even when setup() was already mid-flight. If a parent re-render caused
the component to unmount/remount during the async publish window, the
new instance passed all guards and created a second egg.
Fix: track whether setup() has started in a ref. Cleanup only releases
the guard when the timer was cancelled before setup began; otherwise
setup() releases it in its own finally block.
Also stabilize the eggOnly completion timer by reading onComplete
through a ref, preventing the 1500ms timer from resetting on every
parent re-render that creates a new inline callback reference.
- Remove hexToHslLocal from blobbi.ts; reuse shared hexToHsl from
color-guardrails.ts (eliminates duplicate implementation)
- Add abort flag to useSeedIdentitySync useEffect so the async sync
loop stops on unmount and never calls updateCompanionEvent after
teardown
- Replace relative date wording in compat cutoff comment with
absolute date only
- Fix stale STEP numbering in BlobbiPage.tsx (5/6 → 4/5)
- Unify adult-form derivation: replace charCode hash in
deriveAdultFormFromSeed with the canonical seed-slice algorithm
(offset [40..48]), and remove the duplicate deriveAdultTypeFromSeed
from blobbi.ts (all call sites now use the single canonical function)
- Guard unconditional console.log in parseBlobbiEvent behind
import.meta.env.DEV so it no longer spams production consoles
- Remove dead deriveColorsFromSeed (zero callers, was deprecated on
arrival) and its stale JSDoc reference in adjustSeedForAdultType
- Replace brute-force loop in adjustSeedForAdultType with a direct
O(1) computation: since the derivation is parseInt(slice, 16) % len,
the target index itself is always a valid candidate
- Add fetchFreshEvent to useSeedIdentitySync before each publish,
matching the project convention for replaceable event mutations and
preventing stale-cache overwrites on multi-device usage
Pandi now applies baseColor and secondaryColor instead of staying
hardcoded black and white.
Light areas (body, head): a very soft tinted-white derived from
baseColor's hue at L=95 S=min(baseSat,30). Clearly not pure white,
but stays close — preserves the hue family without going full-
strength. Stroke uses the same hue at L=90 S=20.
Dark areas (ear patches, eye patches, inner ears, arms, legs, nose,
mouth): derived from secondaryColor's hue forced to L=20 S=30
(primary dark) and L=27 S=20 (lighter dark for gradients and inner
fills). Maintains proper panda light-vs-dark contrast.
Eye color: unchanged — still applied via pandiPupil3D gradient
replacement in the existing applyPupilGradient path.
No other adult form customizers were modified.
The adult form dropdown now works with the seed-truth model: selecting
a different form calls adjustSeedForAdultType() and writes the adjusted
seed through the normal update path. syncMirrorTagsToSeed then derives
all mirror tags (adult_type, colors, pattern, etc.) from the new seed.
Previously the dropdown wrote adult_type as a raw tag that was
immediately overwritten by the seed-derived value — effectively a no-op.
Also adds a read-only seed display and a note explaining that changing
the form re-derives the visual identity.
BlobbiCompanion.adultType now derives from the effective seed for adult
Blobbies instead of reading the (potentially stale) stored tag. Falls
back to the tag only for legacy events without a seed.
Renames to reflect the broadened scope of the sync system:
- needsColorSync -> needsSeedIdentitySync
- eventNeedsColorSync -> eventNeedsSeedIdentitySync
No behavior change beyond making adultType consistent with the seed-
truth model and aligning names with what the code already does.
Seed now determines the complete visual identity: colors, pattern,
special_mark, size, and adult_type. All corresponding tags are persisted
mirrors that get overwritten on every republish via syncMirrorTagsToSeed.
Key changes:
deriveAdultTypeFromSeed: new derivation at seed offset [40..48], indexing
into the 16-element ADULT_FORMS array via deriveIndexFromSeed.
deriveSeedIdentity: replaces deriveColorsFromSeed as the single entry
point for the complete seed-derived visual trait set.
Temporary adult-type compatibility (cutoff: 2026-05-01 UTC):
For existing adult Blobbies whose stored adult_type doesn't match the
seed-derived form, adjustSeedForAdultType brute-forces the seed bytes
at offset [40..48] to produce the stored form. This preserves existing
adult forms during the transition. After the cutoff, parseBlobbiEvent
skips this adjustment automatically and the code becomes dead.
eventNeedsColorSync: now checks all mirror tags (colors, pattern, mark,
size, adult_type for adults), not just colors.
syncMirrorTagsToSeed: expanded to overwrite all mirror tags including
adult_type on every republish through the merge pipeline.
useSeedIdentitySync hook: new hook wired into BlobbiPage that checks
filteredCompanions on load and republishes any with stale mirror tags.
Tracks synced d-tags in a ref to avoid loops. Processes sequentially
to avoid relay rate-limiting.
BlobbiPage: calls useSeedIdentitySync(filteredCompanions) after the
existing dedup/filter step, so only visible companions are synced.
When a seed exists, base_color / secondary_color / eye_color are now
always derived from the seed via deriveColorsFromSeed(). Explicit color
tags no longer override seed-derived values -- they are persisted as
mirrors for relay indexing and backward compatibility.
Changes:
- deriveVisualTraits: seed path ignores color tags entirely; no-seed
legacy path unchanged
- deriveColorsFromSeed: new single entry point for canonical color
derivation (seed → HSL → guardrails)
- syncColorTagsToSeed: overwrites stale color tags on every republish
via mergeBlobbiStateTagsForRepublish
- eventNeedsColorSync: detects events whose stored color tags differ
from seed-derived values
- BlobbiCompanion.needsColorSync: lightweight flag for UI-driven
republish of stale events
Existing Blobbies with a seed will change appearance on next render
(seed-derived colors replace old palette-indexed tags). Events are
backfilled on their next republish through the merge pipeline.
Blobbi colors are now derived as full-spectrum HSL values from the seed
hash instead of indexing into fixed 10/10/8-element palette arrays.
Generation changes:
- deriveBaseColorFromSeed: splits 32-bit seed value into H(0-359),
S(30-100), L(30-75) via successive division
- deriveSecondaryColorFromSeed: harmonized from base — same saturation,
hue shifted ±20°, lightness +12..25 above base (guarantees visible
3D gradient)
- deriveEyeColorFromSeed: independent H(0-359), S(40-100), L(10-55)
for dark vivid pupils
Both deriveVisualTraits() and buildEggTags() now pipe seed-derived
colors through applyColorGuardrails() before use. Guardrails are never
applied to explicit tag values — the tag-priority rule is preserved.
Legacy palette arrays are marked @deprecated but kept for reference.
No rendering code, customizers, or Pandi behavior changed.
Pure HSL-based validation/adjustment functions that will make arbitrary
color generation safe in a follow-up step. Guardrails ensure:
- base colors stay within a lightness range where the SVG gradient
pipeline (lighten/darken) produces visible 3D shading
- secondary colors are perceptually distinct from base colors so body
gradients don't collapse into flat fills
- eye colors have enough contrast to remain visible on white sclera
and visually distinct from the body
Generation-side only: no rendering code, customizers, or existing
tagged colors are touched.
Derive the migration petId from sha256(pubkey + legacyD) instead of
crypto.getRandomValues(). The same legacy Blobbi now always produces the
same canonical d-tag, seed, and visual traits regardless of which device
or session triggers the migration.
The equivalence guard (findCanonicalEquivalent) still runs first, so
pre-existing canonicals from the random-petId era are reused and no
duplicate is created.
filterMigratedLegacyCompanions now runs a second pass that groups
canonical companions by their migrated_from tag. Within each group
only the newest event (highest created_at) is kept; the rest are
hidden from the collection UI. Canonical companions without the tag
are never grouped — they pass through untouched.
This closes the remaining duplicate-in-UI gap left intentionally by
the earlier legacy→canonical dedup work.
Replace the name-only equivalence rule with a tiered priority:
1. migrated_from exact match (canonical event's migrated_from tag equals
the legacy d-tag) — strongest signal, written during migration and
preserved across all subsequent Blobbi updates.
2. Same normalized name + same raw base_color tag — covers older canonical
copies created before migrated_from existed, where both events have an
explicit base_color tag that matches.
3. Same normalized name when the legacy event has no base_color tag —
weakest fallback for genuinely old bare legacy events with no visual
tags to compare.
All tiers still require the legacy d-tag to be absent from profile.has
(the migration-completion guard).
Audited that migrated_from survives all Blobbi lifecycle operations:
mergeBlobbiStateTagsForRepublish preserves it as an unknown tag,
validateAndRepairBlobbiTags passes it through (not in schema, not
deprecated), and stage transition cleanup does not touch it.
Legacy Blobbi events (d=blobbi-{name}) persisted on relays after migration
to canonical format (d=blobbi-{hex}-{hex}), causing them to appear alongside
their canonical copies in the UI. Interacting with a still-visible legacy
Blobbi triggered another migration each time, creating unbounded duplicates.
Three changes:
1. Filter migrated legacy Blobbies from the rendered collection. A legacy
Blobbi is hidden only when a canonical Blobbi with the same normalized
name exists AND the legacy d-tag is no longer in profile.has (confirming
migration already occurred).
2. Guard ensureCanonicalBlobbiBeforeAction against re-migration. Before
creating a new canonical event, query all companions and look for an
existing canonical equivalent by normalized name. If found, reuse it
and fix up profile.has/current_companion instead of migrating again.
3. Store a migrated_from tag on newly migrated events for future stronger
equivalence lookups (additive, not depended on by current dedup logic).
The dismiss animation only translated the image strip, leaving the top
bar, nav buttons, dot indicators, and bottom bar stationary — visible
as a jarring flicker of controls. The backdrop also flashed back to full
opacity for one frame before the portal unmounted.
Wrap all visible content (everything except the backdrop) in a single
container that receives the translateY transform so the entire UI sweeps
away as one unit. Reorder the setTimeout callback so onClose fires
before clearing the animating lock, and add an unmount-cleanup effect as
a safety net against stuck controls.
Regression-of: cc655891
App.tsx had a useEffect that unconditionally set SystemBarsStyle.Dark
(white icons) on mount, overriding the theme-aware logic in main.tsx
that had already set the correct style. On light themes this produced
white-on-white status bar text.
Remove the hardcoded override entirely — main.tsx handles initial
detection and MutationObservers cover all subsequent theme changes.
The hover wobble animation was triggering on touch devices via the
sticky :hover pseudo-class, rotating the envelope while the user
was trying to tap it. Restrict the wobble to true pointer-hover
devices with @media (hover: hover) and (pointer: fine).
Also tighten the entrance animation: remove rotation so tap targets
stay stable, reduce duration from 0.4s to 0.3s, start closer to
final size (0.85 vs 0.6), and cap stagger delay at 300ms so later
envelopes settle before the user can scroll to them.
Users can now swipe up or down to dismiss the full-screen image
lightbox, matching the native mobile pattern of flicking an image
away instead of reaching for the X button. The image follows the
finger with opacity fade, and commits the dismiss after 15% of
viewport height. When zoomed in the gesture is disabled so it
doesn't conflict with panning.
Applies to both the main Lightbox (feeds, galleries, media collage)
and the ProfileImageLightbox (avatar/banner taps).
When a user typed in the inline ComposeBox then tapped the FAB (which
covers the Post button), the modal opened with the same draft text.
After posting from the modal, the inline ComposeBox still showed the
old text because it was a separate React instance with its own state,
leading to accidental double-posts.
Bump a key on the inline ComposeBox after a successful modal post so
React remounts it, picking up the already-cleared localStorage draft.
The deletion and report queries were unscoped (fetching globally) and the
moderation overlay needs more design work. Strip it out for now and leave
TODOs for a follow-up.
Adds a new autoplayVideos config field and a toggle in Settings > Content >
Video Playback. When enabled, videos auto-play muted in feeds, collage
thumbnails, profile sidebar tiles, the Vines feed, and the VideoPlayer
component. The preference syncs across devices via encrypted settings.
usePlayerControls now listens for the volumechange event to keep the
volume UI in sync when the video is programmatically muted for autoplay.
Eyebrow Y position was calculated as eye.cy + offsetY, which broke on
forms with large eye whites (catti ry=16, froggi/owli r=22, droppi/
pandi/rocky r=12) because the fixed offset did not account for the
distance between eye center and eye top.
Propagate eye white vertical radius through the pipeline: write
data-eye-rx/ry on blink groups in addEyeAnimation, read data-eye-ry
in detectFromProcessedSvg, add eyeWhiteRy to EyePosition type. The
eyebrow formula now rebases recipe offsets from center-relative to
top-relative using the actual eye white radius, producing a consistent
gap above the eye top regardless of eye size.
Remove FORM_EYEBROW_OFFSETS (owli, froggi) — the radius-aware formula
handles all forms correctly without per-form overrides. Baby eyebrow
formula is unchanged.
detectBodyPath() only matched <path> elements, so 10 of 16 adult forms
failed body detection — anger-rise was silently skipped and dirt/dust
fell back to hardcoded positions.
Add data-blobbi-body="true" marker to the primary body element of all
16 forms (base + sleeping = 32 elements). Extend detectBodyPath() with
a marker-first strategy that supports <circle>, <ellipse>, and <rect>
via path synthesis. Update the anger-rise overlay insertion regex in
apply.ts to find the marked element instead of only matching <path>.
Existing gradient-name and comment-based fallbacks remain for backwards
compatibility.
The eyeColor fallback replaced known pupil fills with arbitrary colors,
causing isPupilElement() to no longer recognize them via PUPIL_COLORS.
Inject a data-blobbi-pupil marker during the fill replacement and check
for it first in both isPupilElement() copies so detection is independent
of the actual fill color value.
Regression-of: 9c20102d
eyeColor was silently dropped for 12 of 16 adult forms because they use
hardcoded fill attributes on pupil circles instead of gradient references.
Add a scoped flat-fill fallback in applyPupilGradient() that replaces the
known default pupil color within the <!-- Pupils --> comment block only.
secondaryColor was threaded through the type system but never read by any
adult customizer. Following the baby two-tone pattern (secondaryColor at
center, baseColor at edge), add an optional innerColor parameter to the
gradient builders and pass secondaryColor to each form's main body
gradient. Pandi remains excluded from body color changes by design.
Pandi's 4 ear circles and 2 eye-patch circles use flat dark fills
(#1f2937, #374151) that match PUPIL_COLORS, causing the detector to
find 8 "pupils" instead of 2. This produced phantom eye groups,
broke blink/gaze targeting, and left the real pupils orphaned.
Add data-blobbi-skip attribute to the 6 non-pupil dark circles in
Pandi's SVGs, and add an early-return in isPupilElement() to respect
it. No other form has this attribute, so behavior is unchanged for
all non-Pandi adults.
Catti's dual cat-mouth (two mirrored Q-curve paths sharing a center
start point) caused extractMouthPositionFromElements to return only
the left half's coordinates. Every mouth generator then computed
cx=(100+82)/2=91 instead of the true center x=100.
The fix scans all Q-curve paths in the mouth section and computes the
full horizontal extent. For Catti: startX=82, endX=118, center=100.
Single-path mouths are unaffected (the extra-path loop simply doesn't
execute). Froggi's dual-path mouth already had symmetric bounds so its
result is unchanged.
replaceMouthSection() used a global regex matching any Q-curve <path>
with a stroke attribute. Catti's whiskers are Q-curve paths that appear
after the mouth in document order, so they were matched and deleted
whenever a status reaction replaced the mouth.
The fix adds a marker-bounded replacement strategy: when a <!-- Mouth -->
comment marker exists, the regex is scoped to only the section between
that marker and the next SVG section. All 16 adult forms and the baby
SVG already have this marker. The global regex is preserved as a
fallback for SVGs without markers.
The MoreTabContent grid rendered BlobbiStageVisual without a sleeping
recipe, so sleeping companions got the awake base SVG with open eyes.
The sleeping visual (closed clip-rects, closed-eye lines, Zzz) is
entirely recipe-driven — without the recipe the SVG renderer always
produces the awake appearance.
Pass buildSleepingRecipe() and recipeLabel='sleeping' for companions
whose state is 'sleeping' in the tab grid call site.
When Blobbi transitions from sleeping to awake, the eyes now visibly
open over 400ms with an ease-in-out curve before the normal blink/gaze
loop resumes. The wake-up animation mirrors the sleep-entry animation:
- runWakeUpAnimation() queries fresh DOM elements from containerRef
- Sets clip-rects to the closed position (using BLINK_CLOSED_AMOUNT)
- Animates from closed to open, then calls onComplete
- The normal awake animation loop only starts after completion
- This prevents two rAF loops from fighting over clip-rect attributes
The animation only fires on genuine sleeping→awake transitions via
wasSleepingRef, not on mount or refresh when Blobbi is already awake.
If cancelled mid-animation (e.g. quick sleep re-toggle), the onComplete
callback is still invoked so the hook does not get stuck.
When publishing kind 1 or kind 1111 reply events, also deliver them to
the read (inbox) relays of p-tagged users. This follows the NIP-65
recommendation that clients send events to the read relays of each
tagged user so recipients are more likely to see replies.
The inbox delivery is fire-and-forget after the main publish succeeds,
so it does not slow down the UI or block the publish flow.
When Blobbi transitions from awake to sleeping, the eyes now visibly
close over 400ms with an ease-in-out curve before settling into the
stable sleeping state. The closed-eye lines fade in during the last
40% of the animation.
The animation is driven by a standalone rAF loop in runSleepEntryAnimation()
that queries fresh DOM elements from containerRef.current — never from
stale cached refs. A wasSleepingRef tracks the previous isSleeping value
so the animation only fires on genuine awake→sleep transitions, not on
mount or refresh when Blobbi is already sleeping.
At t=1 the DOM exactly matches the recipe's static closed state, so
there is no visual discontinuity when the animation completes.
Clear stale cached DOM refs in useBlobbiEyes when entering sleep.
The awake animation loop caches blink/gaze SVG elements, but
dangerouslySetInnerHTML replaces the entire SVG when the sleeping
recipe is applied. The old refs' open-eye clip geometry was being
used to querySelector into the new sleeping SVG and reset the
clip-paths back to the open position, causing both open eyes and
closed-eye lines to render simultaneously.
The sleeping recipe already sets clip rects to the closed position
in the SVG string, so no JS-side clip-path reset is needed.
Clearing the caches prevents stale operations and lets fresh
caching happen naturally when Blobbi wakes up.
The debounce hook was re-publishing a kind 31124 event with identical
evolution content 5s after every interaction, because the primary
write path already persisted the same data inline. Now compares the
serialized content against the fresh event before publishing and
skips when they match. The hook still fires for the one case where
it is genuinely needed: event-based backfill from Nostr queries.
Five targeted fixes to the evolution mission persistence flow:
1. Inline evolution content into interaction write paths: all three
action hooks (direct action, inventory item, companion item use) now
read the updated evolution from the session store and embed it into
the 31124 content in the same publish. The debounce hook remains as
a safety net for event-based backfill, not the primary persistence.
2. Scope ensuredRef per Blobbi: the 'ensure missions exist' guard in
useHatchTasks and useEvolveTasks was a plain boolean ref that would
not re-run when switching companions. Now keyed by pubkey:d.
3. Add companionD to query keys: hatch-tasks and evolve-tasks queries
were keyed by pubkey only, causing stale cache reuse across Blobbis.
4. Filter persist hook by d-tag: usePersistEvolutionProgress now checks
detail.d against companionD so it only reacts to evolution updates
for the active companion.
5. Clear evolution in switch mode: when switching incubation from one
Blobbi to another, the stopped Blobbi's 31124 content now has its
evolution[] cleared, and its session store entry is removed.
Evolution/hatch mission progress was stored in the shared Blobbonaut
profile (kind 11125) content JSON, causing split-brain state between
the per-user profile and per-Blobbi events. After reload, progress
could disappear or get overwritten across Blobbis because the session
store was keyed by pubkey only and persisted via a debounced write to
the wrong event.
Now:
- Daily missions remain on kind 11125 (per-user, correct)
- Evolution missions live on kind 31124 content JSON (per-Blobbi)
- Session store split: daily keyed by pubkey, evolution by pubkey:d
- usePersistEvolutionProgress writes to 31124 instead of 11125
- useHatchTasks/useEvolveTasks read from companion.evolution
- serializeProfileContent strips legacy evolution from 11125
- Start/stop incubation/evolution seed/clear 31124 content directly
- Interaction tallies pass companion d-tag for per-Blobbi tracking
- StartIncubationDialog: display progressionState instead of state in
restart dialog text (was showing 'active' instead of 'incubating')
- blobbi-tag-schema: update deprecated tag replacedBy and category
comment to reference progression_started_at instead of state_started_at
- blobbi.ts: update deprecation comments for incubation_time and
start_incubation to reference progression_started_at
Update JSDoc comments in useBlobbiIncubation to reference
progression_state/progression_started_at instead of the deprecated
state/state_started_at. Rename stateStartedAt to progressionStartedAt
in StartIncubationResult and StartEvolutionResult interfaces.
Sleep/wake toggles overwrote 'evolving' with 'sleeping', permanently
destroying evolution progress. The root cause was using a single state
tag for two orthogonal concerns: activity (active/sleeping/hibernating)
and progression (incubating/evolving).
Introduce progression_state and progression_started_at as new tags
orthogonal to the activity state tag. Sleep, wake, and hibernation
changes now never touch progression. Parser auto-migrates legacy
events that stored progression in the state tag on read.
- ExternalContentPage: new URL() on non-URL NIP-73 identifiers (isbn:,
iso3166:) threw TypeError crashing the page; now URL is only created
for url-type content and #-prefixed strings are used for other types
- ExternalContentPage & RelayPage: decodeURIComponent on malformed
percent-encoded URL params threw URIError; now wrapped in try/catch
- useMastodonPost: new URL() on invalid URLs inside queryFn now returns
null instead of letting the error propagate
- colorUtils/themeEvent: malformed hex color values from theme events
produced NaN CSS variables; added isValidHex guard in parseColorTags
Sleep/wake toggles overwrote 'evolving' with 'sleeping', permanently
destroying evolution progress. The root cause was using a single state
tag for two orthogonal concerns: activity (active/sleeping/hibernating)
and progression (incubating/evolving).
Introduce progression_state and progression_started_at as new tags
orthogonal to the activity state tag. Sleep, wake, and hibernation
changes now never touch progression. Parser auto-migrates legacy
events that stored progression in the state tag on read.
Validate blurhash hashes before passing them to react-blurhash's
<Blurhash> component, which throws when the encoded length doesn't
match the component count header. Malformed hashes from third-party
events (e.g. length 92 instead of 94) now gracefully fall back to a
skeleton placeholder instead of crashing the page.
- Remove temporary `true ||` debug bypasses that made nausea trigger
regardless of hunger stat. Nausea now correctly requires hunger >= 90.
- Track `cycleHadNausea` so the recipe resolver uses a consistent
nauseated face recipe for the entire reaction cycle, even after the
green fill drains to 0. This prevents a structural SVG rebuild
mid-reaction that killed SMIL spiral eye animations.
- Make shake reactions additive: starting a new shake during dizzy or
recovering no longer resets the reaction. Instead, the phase
transitions back to shaking, nausea level can only rise (max of
current and new), and the dizzy hold timer extends.
Regression-of: 91de4f80
Shake reaction system
---------------------
Introduce a reusable shake detection + reaction pipeline that triggers
dizzy visuals when Blobbi is shaken during drag, with progressive green
nausea body fill when hunger is high (>= 90, currently debug-bypassed).
Architecture follows the same phase/level/profile pattern as click
overstimulation for future extensibility (personality variants,
additional physical-stress reactions).
New files:
- shakeDetection.ts: pure motion sampling (direction reversals,
speed accumulation, energy integral)
- useShakeReaction.ts: 4-phase state machine (idle → shaking → dizzy
→ recovering), profile system, recipe resolution with nausea fill
Phases:
- idle: no shake reaction active
- shaking: user actively shaking (dizzy face + live green fill rise)
- dizzy: post-release hold (3-8s scaled by intensity, fill drains)
- recovering: nausea draining via rAF, then back to idle
SMIL animation stability fix
-----------------------------
The nausea fill level changes ~12×/sec during drain, creating a new
recipe object each tick. This broke the React.memo barrier on
MemoizedBlobbiVisual (reference equality), triggering full SVG DOM
replacement via dangerouslySetInnerHTML — killing all SMIL animations
(dizzy spirals, sleepy blinks) on every update.
Fix: both SvgRenderers now compute a structural recipe fingerprint
that clones the recipe and strips only bodyEffects.angerRise.level.
The customizedSvg useMemo depends on this string (compared by value),
so level-only changes skip the SVG rebuild. The fill level is applied
imperatively via gradient stop setAttribute() in a separate useEffect,
preserving the existing DOM and all running SMIL animations.
Reaction retrigger fix
----------------------
Both shake and overstimulation reactions had cycle-scoped refs that
were not cleaned up when the reaction drained to idle via the natural
rAF path, preventing immediate retrigger:
- Overstimulation: clicksRef (stale timestamps) now cleared at idle
- Shake: toastShownRef now reset at idle
Body fill improvements
----------------------
- angerRise generator now accepts caller-controlled bottomOpacity and
edgeOpacity so nausea (strong green) and anger (moderate red) can
have different visual intensity through the same shared generator
- Static-level fill mode uses real body bounds from path detection
instead of hardcoded coordinates
- Nausea fill drains during the dizzy hold (not only after it ends)
for a smooth continuous descent
Temporary debug bypass still active:
true || hungerRef.current >= _NAUSEA_HUNGER_THRESHOLD
Must be removed before final merge.
Revert the interval/update-in-place approach and replace with a single
toast call that displays the chosen blocked duration (e.g. 'calm down
for 3s'). No interval, no toast handle refs, no countdown state.
Removed from previous version:
- toastHandleRef and countdownIntervalRef refs
- clearCountdown() helper
- setInterval countdown loop with toast.update() calls
- clearCountdown() calls in block-end, deactivation, and unmount paths
Regression-of: 6d9e7502
Show remaining seconds in the blocked toast (e.g. 'calm down for 4s')
and update it every second via the toast update mechanism. The toast is
automatically dismissed when the blocked phase ends. All timer state is
owned by useOverstimulationReaction so the UI layer stays simple.
Add a transparent fullscreen overlay (fixed, inset 0, z-index 99999) that
renders only while phase === 'blocked'. This prevents clicks on buttons,
links, inputs, feeds, menus, etc. while Blobbi is overwhelmed. The overlay
is removed automatically when the blocked phase ends.
Update the module doc comment and inline constant comments to accurately
describe the real escalation and cooling timelines:
- 4 clicks: mild angry face
- 6 clicks: red body fill begins (level crosses 0.2)
- 15 clicks: max level, blocked for 2-4s
- 1.5s delay + ~4s drain = ~5.5s total recovery from max
Two bugs prevented the overstimulation system from working:
1. Phase never entered 'rising' because phaseRef was mutated before
calling pushVisible(), making the phaseChanged check always false.
pushVisible is now the single owner of phase transitions — callers
must not pre-mutate phaseRef. Same bug existed in the rising→cooling
transition inside rafTick.
2. applyVisualRecipe() dropped the 'level' field when building the
bodySpec for angerRise, so the level-controlled static gradient
path was never reached.
Implement a profile-based overstimulation system that reacts to rapid
repeated clicks anywhere in the app. The system tracks a continuous
level (0-1) that rises with rapid clicks and cools down gradually when
clicks stop, with support for temporary click blocking at max level.
Architecture:
- useOverstimulationReaction hook with OverstimulationProfile interface
for future personality-based branching (angry, confused, nervous, etc.)
- Level-controlled anger-rise body effect via new 'level' parameter on
the existing angerRise spec (preserves SMIL path for existing callers)
- Visible state throttled to ~6-10fps via delta threshold to avoid
SVG re-render churn
Behavior:
- 5+ rapid clicks in 2s window triggers mild angry face
- Additional clicks progressively fill the red body effect from bottom
- 2s of no clicks starts gradual cooling (level decreases over time)
- Clicking during cooldown resumes from current level
- At max level: Blobbi blocks clicks for 2-4s with a toast notification
- After block ends: level cools naturally from 1.0 back to idle
Replace the manual parent-walk loop with closest('a, button,
[role="button"]') — simpler, handles role="button" elements, and
returns null cleanly when no clickable ancestor exists (so only the
raw pointer fallback is used).
Clamp the resolved click-origin Y to 55% of viewport height so Blobbi
looks across toward the sidebar rather than sharply downward when the
clicked item is near the bottom of the screen.
Store the nearest clickable ancestor (<a>/<button>) on pointerdown
alongside the raw coordinates. At route-change time, re-read the
element's getBoundingClientRect() to get its current visible center,
which accounts for any scroll shifts between the click and the React
effect. Falls back to the raw pointer coordinates if the element has
been unmounted.
Track the last pointerdown position in a ref. On route change, if a
recent click exists (<1s), glance at that position for ~700ms first,
then look at the center-top of the new page for 2-6s. Programmatic
navigation (no recent click) falls back to immediate center-top.
Single-file change in useRouteReaction.ts. No changes to attention,
gaze, or state hooks.
Guard makeDecision() so it bails out while attentionTarget is active.
Previously the decision loop could fire during the attending window,
transitioning Blobbi to walking/idle and breaking the attend-ui gaze
lock. The attention-end handler already resumes decisions when it
clears, so this guard is safe.
On route change, immediately trigger a preliminary attention target at
viewport center-top before the 250ms DOM-mount delay. This ensures the
gaze system never falls to random or mouse-follow mode during the gap,
which previously caused Blobbi to drift leftward toward the sidebar.
The delayed reaction still fires at 250ms and replaces the preliminary
target with a precise DOM-measured position.
Keep previous attention alive during the 250ms route-reaction delay
instead of clearing it immediately. This prevents the gaze system from
falling to random mode (which could point toward the sidebar) while
waiting for the new page's DOM to mount.
- Split cancelReaction into cancelPendingTimeouts (timeouts only) and
full cancel (timeouts + attention); route changes use the former
- Add bypassCooldown option to triggerAttention so the delayed reaction
can override the kept-alive attention without being blocked by cooldown
- Stabilize triggerAttention via uiAttentionRef instead of stale closure
clearAttention() now resets lastAttentionTimeRef to 0 so that a
triggerAttention call immediately after a forced clear is not silently
rejected by the 1500ms cooldown guard.
Remove right-sidebar detection and multi-target chaining. The generic
reaction now fires a single attention target at the top-center of the
main content area for a random 2-6 seconds.
- Remove findRightSidebarPosition() and all sidebar scan logic
- Replace fixed 1200ms duration with random 2000-6000ms
- Compute target position at reaction start via live DOM query
and window.innerWidth/Height fallback (no stale closure)
- Cancel both pending timeouts and active attention on drag
- Remove viewport prop from hook options (no longer needed)
- Update docstrings to describe the simplified behavior
On page navigation the companion now briefly pauses and scans the
layout areas that changed. Center content is always scanned first,
followed by the right sidebar if a non-placeholder sidebar is
detected in the DOM.
Implementation:
- useRouteReaction.ts: thin orchestration hook that watches pathname,
determines changed areas, and chains triggerAttention calls via
setTimeout. Cancels on new route change, drag, or unmount.
- useBlobbiCompanion.ts: wires the new hook with existing
triggerAttention/clearAttention from useBlobbiAttention.
No changes to the attention system, state machine, gaze hook, motion
hook, or entry animation. The existing attending state and attend-ui
gaze mode handle all the visual behavior.
Includes an empty ROUTE_REACTIONS map for future per-route overrides.
window.location.origin resolves to capacitor://localhost on iOS and
https://localhost on Android, which produces broken QR codes, broken
copy-link actions, and a broken remote-login callback URL on native
builds.
Add an optional shareOrigin field to AppConfig and a useShareOrigin
hook that falls back to window.location.origin when unset. Replace
all 13 call sites that build shareable URLs.
The origin can be configured three ways, in order of precedence:
user localStorage > ditto.json > VITE_SHARE_ORIGIN env var. Native
deployments can set VITE_SHARE_ORIGIN=https://ditto.pub at build time
so that shared URLs resolve correctly when opened on another device
(and get caught by DeepLinkHandler when opened on the same app via
Universal/App Links).
Regression-of: a12d5db5
- Remove dead export useMusicTracksByGenre from useMusicData
- Sanitize metadata?.picture through sanitizeUrl() in ProfileCard,
MusicHeroCard, MusicTrackCard, and MusicTrackRow
- Fix MusicDiscoverTab featured section where skeleton and loaded
content could render simultaneously (make mutually exclusive)
- Add error state handling to all 4 music tab components
- Move genre filtering from client-side to relay-level #t filtering
in MusicTracksTab via new genre param on useMusicFeed
Kind 3 (NIP-02 follow list), kind 30000 (NIP-51 follow set), and kind 39089
(follow pack) are all the same semantic thing — an event containing a list of
p-tagged pubkeys — but were being rendered three different ways, with kind 3
having no rendering at all, kind 30000 routing to a bespoke ListDetailPage, and
kind 39089 using its own FollowPackDetailContent.
Merge the three into a single PeopleListContent (feed card) and
PeopleListDetailContent (full detail). The detail component hosts every
feature from the predecessors: Follow All with existing p-tag preservation,
Save-as-copy for non-owners, owner-mode member removal for kind 30000, the
Feed/Members/Comments tabs, sidebar integration, and the share/copy-link
menu. For kind 3 the event has no title of its own, so we fall back to the
author's display name.
Additional refinements bundled in:
- Register kinds 3 and 30000 at every previously-missing rendering point:
KIND_HEADER_MAP, shellTitleForKind, CommentContext KIND_LABELS/ICONS,
extraKinds specific labels and icons, and ExternalContentHeader fallbacks.
Kind 3 and 30000 now share the packs feed toggle via extraFeedKinds.
- Add infinite scroll to the people-list Feed tab via useTabFeed +
IntersectionObserver sentinel, replacing the useStreamPosts 40-post cap.
- Add Comments tab alongside Feed and Members, powered by useComments
(NIP-22 kind 1111). Drop the redundant variant badge. Allow kind 39089
packs to be pinned to the sidebar.
- Trim redundant chrome: drop the member-count pill in the feed card,
drop the member-count line in the detail header, and stop pulling the
author's 'about' and 'banner' into kind 3 follow list views.
- Add a dedicated FollowListCommentContext branch so comments on kind 3
show '@Name's follow list' instead of 'a follow list'.
- Replace the three-dots DropdownMenu on the detail view with the shared
PostActionBar (reply/repost/react/zap/share/more), matching other
detail views.
- Promote EmbeddedPost from ReplyComposeModal into a shared component
that dispatches to EmbeddedNote / EmbeddedNaddr, with a new
EmbeddedPeopleListCard for kinds 3/30000/39089 so quote posts, reply
indicators, hover cards, and the More menu all render follow lists
correctly.
- Link the 'N following' count on profile pages to a naddr of the kind 3
event (routing to the new detail view) instead of a bespoke modal.
Delete FollowingListModal. Using naddr rather than nevent ensures the
link always resolves to the latest replaceable event.
Delete ListDetailPage, FollowPackDetailContent, and FollowingListModal
entirely. All three kinds now route through AddrPostDetailPage →
PeopleListDetailContent.
When the Ditto relay lacks engagement data for music event kinds
(36787, 34139), sort:hot and sort:top queries return nothing. Add a
chronological fallback in useMusicFeed and the Discover tab's inline
New Tracks query: if the sorted query returns zero events, retry
against the default relay pool without the search param so users
always see content.
Add sort and scope filter controls to all three music pages:
Discover tab:
- New Tracks section gets sort (Hot/Top/New) and scope (Global/Following)
controls. Global scope queries curated artists; Following scope queries
the user's follow list. Hot/Top use Ditto relay NIP-50 search extensions.
Tracks tab:
- Replace useFeed('global') with useMusicFeed hook that supports sort and
scope. Infinite scroll pagination preserved. Genre chips still work as
client-side filter on top of the sorted results.
Playlists tab:
- Replace one-shot useMusicPlaylists with useMusicFeed for infinite scroll
with sort and scope. Album/playlist type toggle preserved.
New shared components:
- MusicSortFilterBar: Pill-style sort (Hot/Top/New with icons) and scope
(Global/Following) controls. Following only shown when logged in.
- useMusicFeed: Infinite scroll hook that maps sort modes to Ditto relay
NIP-50 search extensions and restricts authors for Following scope.
Not enough curated artists yet to justify one-per-artist filtering,
which leaves the section too sparse. Restore chronological ordering
so the feed fills up. The genre chips below the header change is kept.
Genre filter chips now appear below the 'New Tracks' section title
instead of above it, making it clear they filter that section.
New Tracks section now shows at most one track per artist (most
recent from each), preventing a prolific artist from dominating
the entire section. This applies to both the 'All' default state
and genre-filtered states.
Playlist detail page now clamps the description to 3 lines with a
'Show more' button that expands to show the full text. Uses CSS
line-clamp with a scrollHeight check to only show the toggle when
the text actually overflows.
DOMPurify 3.4.0 is a security release that fixes multiple issues
including mXSS via re-contextualization and closing tags, prototype
pollution via CUSTOM_ELEMENT_HANDLING and USE_PROFILES, ADD_ATTR
predicates skipping URI validation, and ADD_TAGS/FORBID_TAGS
precedence bugs.
The project uses DOMPurify to sanitize user-supplied SVGs in
sanitizeSvg.ts and sanitizeBlobbiSvg.ts, so pulling in these fixes
hardens our SVG rendering path against hostile inputs.
useAddrEvent only treated kinds in 10000-19999 as replaceable, so any
naddr with a kind outside that range got a '#d' filter applied. For
legacy replaceable kinds like 0 and 3, real events don't carry a 'd'
tag, so the query matched nothing even when the relay had the event.
Invert the check to only apply the '#d' filter for true addressable
events (30000-39999). Legacy replaceable kinds and 10000-19999 are
now queried by kind+author alone.
Regression-of: 9b5df28b
When a playlist has no artwork or its image fails to load, resolve
the first track from the playlist's a-tag refs and use its artwork
as a fallback cover image.
- Add usePlaylistCoverArt hook: lightweight single-track query that
only fires when the playlist's own artwork is missing. Returns the
playlist art if present, or the first track's artwork otherwise.
- MusicPlaylistCard: uses the hook for cover art with per-URL error
tracking so a broken playlist image triggers the fallback without
breaking a working track image.
- PlaylistDetail: derives cover art from already-resolved trackEvents
(no extra query needed since tracks are already loaded).
When an image URL from a Nostr event returns an error (404, stale
URL, server down), the img element now falls back to the existing
gradient/icon placeholder instead of showing a broken image.
Applied to MusicPlaylistCard, MusicTrackCard, MusicHeroCard,
MusicTrackRow, and MusicDetailContent (track hero, playlist hero,
and PlaylistTrackRow). Each uses a local imgError state that flips
on the img onError event to swap in the fallback.
The previous 'updated their Blobbi' wording felt mechanical for what is
really a care interaction (feeding, cleaning, playing, etc.). 'Cared for'
better reflects the user's intent.
Images inside overflow-x-auto scroll containers don't reliably
trigger the browser's lazy load IntersectionObserver, causing
playlist and track card artwork to not load in horizontal scroll
sections. Remove loading=lazy from MusicPlaylistCard and
MusicTrackCard since they render a small fixed number of cards
that should load immediately. MusicTrackRow (vertical list)
keeps lazy loading since it works correctly there.
Move Artists and Playlists above Genre chips + New Tracks on the
Discover page. Playlists now query the Ditto relay with sort:hot
so the most engaging playlists surface first.
New section order: Hero > Featured > Artists > Playlists > Genre
chips > New Tracks > CTA.
When sort:hot distinct:author returns fewer than 5 tracks (common
with limited engagement data), issue a second query for recent tracks
with distinct:author and merge them in, skipping authors already
present. Ensures the Featured section always has enough variety.
Rework the Music Discover tab so every section is gated through the
curator's lists, ensuring only high-quality content appears.
- Add useFeaturedMusicTracks hook: queries Ditto relay with sort:hot
and distinct:author for curated artists. The #1 hot track becomes
the hero; the rest populate the Featured horizontal scroll with no
artist repeats.
- Add useMusicCuratorFollows hook: fetches Heather's kind 3 follow
list to filter playlists to people she follows.
- Add authors param to useMusicPlaylists and useMusicTracksByGenre
so both can be restricted to specific pubkeys.
- Rewire MusicDiscoverTab: New Tracks and genre filtering now use
curated artists only; playlists use curator follows; section renamed
from 'Recently Added' to 'New Tracks'.
On Capacitor iOS, leaving user-scalable unrestricted let WKWebView's
scroll view pinch recognizer engage intermittently, then get stuck
disabled once it fired. Adding maximum-scale=1 and user-scalable=no
disables browser-driven pinch zoom consistently across web, iOS, and
Android.
The in-app lightbox (LightboxImage in ImageGallery.tsx) already
implements its own pinch-to-zoom with custom touch handlers and CSS
transforms, so it continues to work. Future components that want
pinch-zoom can follow the same pattern.
When a commit fixes a bug introduced by an identifiable prior commit,
the fix should record the offending short SHA in a Regression-of:
trailer at the bottom of the commit message body.
This is a standard Git trailer (parseable by git interpret-trailers)
that makes intra-release regression detection trivial: the release
skill can now read the trailer directly instead of hunting through
git log and git blame to figure out whether a 'Fixed' entry actually
describes a bug a shipped user ever saw.
- AGENTS.md: new 'Attributing Regressions' subsection under Using Git
with the convention, when-to-add/skip rules, and tracing tips.
- .agents/skills/release/SKILL.md: Step 5.2 now has a fast path that
reads Regression-of trailers via 'git log --format=%(trailers:...)',
with the existing manual git log/blame approach as fallback.
- CONTRIBUTING.md: brief mention in the Bug fixes section and a new
self-review checklist item pointing at AGENTS.md.
Update useCuratedMusicArtists to fetch the externally maintained
kind 30000 follow set from npub1nl8r463... instead of looking for
a d:music-artists list from the app curator pubkey. The list is
maintained on Listr and contains 56 curated music artist pubkeys.
Fallback pubkeys updated to a subset of the same list.
Adds a Changelog Quality Checklist to the release skill covering:
- Diffing code between tags (not just reading commit messages)
- Tracing every 'Fixed' entry to its origin commit
- The 'would a user on the previous version notice this?' test
- A worked example of the intra-release bug pattern
Removes the 'expanded emoji picker background' fix from the v2.9.0
changelog -- that bug was both introduced and fixed within the 2.9.0
release window, so no shipped user ever saw it.
- Add usePlaylistTracks hook to resolve a-tag refs into ordered track events
- Render track list in PlaylistDetail with Play All button and per-track
playlist playback via playPlaylist()
- Support albums as playlists tagged with t:album, showing release date,
label, and Disc3 icon
- Add All/Playlists/Albums filter toggle to MusicPlaylistsTab
- Show Album badge on playlist cards in grid views
- Add KIND_HEADER_MAP entries for music tracks (36787) and playlists (34139)
- Fix shellTitleForKind to return 'Playlist Details' for kind 34139
- Document music kinds and album convention in NIP.md
The full emoji picker in QuickReactMenu had no background because
EmojiPicker sets its shadow DOM background to transparent, and the
wrapper div only had rounded-xl/shadow-xl without a background class.
Added bg-popover and border-border so the picker matches the quick-react
pill bar styling.
Closes#235
Replace the generic KindFeedPage-based music feed with a dedicated
music discovery page featuring:
- **Discover tab** (default): Hero card with featured track, horizontal
scroll of featured tracks from curated artists, genre chip filters,
recently added track rows, playlists section, artist showcase, and
"Share Your Music on Nostr" CTA card
- **Tracks tab**: Infinite-scroll list of all music tracks with genre
filtering, using useFeed for standard pagination
- **Playlists tab**: 2-column grid of kind 34139 playlist cards
- **Artists tab**: 3-column grid of artist profile cards, curated
artists shown first
Architecture decisions:
- Shared discovery components in src/components/discovery/ (SectionHeader,
TagChips, HorizontalScroll, ProfileCard, ContentCTACard) designed for
reuse by podcasts and other future content-type discovery pages
- Music-specific components in src/components/music/ (MusicHeroCard,
MusicTrackRow, MusicTrackCard, MusicPlaylistCard)
- Single base query (useMusicData) fetches kind 36787 tracks and derives
genres + artists client-side to avoid redundant relay requests
- Curated artist list via kind 30000 follow set (d:music-artists) from
curator pubkey, with hardcoded fallback of 9 verified Wavlake artists
- Global by default (no follows/global tab split for discovery)
- Full now-playing state indicators across all components via useAudioPlayer
- Complete loading skeletons and empty states for every section
New files:
- src/components/discovery/{SectionHeader,TagChips,HorizontalScroll,
ProfileCard,ContentCTACard}.tsx
- src/components/music/{MusicHeroCard,MusicTrackRow,MusicTrackCard,
MusicPlaylistCard,MusicDiscoverTab,MusicTracksTab,MusicPlaylistsTab,
MusicArtistsTab}.tsx
- src/hooks/{useCuratedMusicArtists,useMusicData,useMusicPlaylists}.ts
- src/pages/MusicPage.tsx
Modified: src/AppRouter.tsx (route update)
Orphaned: src/pages/MusicFeedPage.tsx (no longer imported)
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
These activity-style detail pages previously rendered only a slim action
row, missing the stats summary (Reposts / Quotes / Likes / Zaps), the
client + full-date row, and the InteractionsModal affordance. Users had
no way to see or browse the interactions the event itself had received.
Extract the stats + date row into a shared JSX block and replace the
four inline action-button grids with PostActionBar, bringing these
detail views in line with the standard post layout while keeping their
compact emoji/icon headers.
Add migration logic so users with stale persisted evolution missions
(e.g. containing the removed create_post mission) get their mission
list rebuilt to match current definitions while preserving progress.
Declares kind 8 as a third sub-kind under the existing Badges
ExtraKindDef with its own 'showBadgeAwards' / 'feedIncludeBadgeAwards'
toggles. The home feed and profile feed both derive their kinds list
from getEnabledFeedKinds, so both pick up badge awards automatically.
The Badges page's follows feed is a hardcoded list, so kind 8 is added
there explicitly.
Defaults match existing badge settings: enabled in hardcodedConfig (new
users see them), conservative in InitialSyncGate and TestApp. The
ContentSettings UI auto-generates a new Badge Awards toggle row.
Removes the now-redundant KIND_SPECIFIC_LABELS/ICONS entries for kind 8
since the sub-kind carries that metadata.
Badge awards previously only appeared as notifications when you were the
recipient. Now they render as full feed cards — showcase image, badge
metadata, recipient row, and an Accept button for logged-in recipients —
so issuers can share awards and feeds can surface community recognition.
Extracts parseBadgeATag, unslugify, and AcceptBadgeButton out of
NotificationsPage.tsx into shared modules, adds a compact embedded card
for kind 8 nevent references, and wires the kind through NoteCard,
PostDetailPage, CommentContext, and extraKinds registries.
User-facing display strings now read from config.appName so forks can
rebrand without code changes, and localStorage keys are namespaced by
config.appId so forks running on the same origin don't clobber each
other's preferences. Module-level cache-key constants that previously
hardcoded 'ditto:' have been refactored into hook-scoped reads from
config.appId (via a new getStorageKey() helper). The helpContent FAQ
template now uses {appName} placeholders substituted at read-time
through getFAQCategories(appName)/getFAQItem(appName, id).
The HTTPS check was a leftover from when the client name was derived
from the hostname. Now that it comes from appConfig, the tag should
be added unconditionally.
The signup and onboarding profile steps rendered ProfileCard without
passing onAvatarShape, so emoji shape selections were silent no-ops and
never made it into the published kind 0 event.
The interactions tally mission was silently dropped because
trackEvolutionTally maps over the evolution[] array — if it's empty,
nothing gets incremented. This happened when evolution missions
weren't persisted to kind 11125 or weren't hydrated on page load.
Both useHatchTasks and useEvolveTasks now have a safety-net effect:
if the companion is in an active task process (incubating/evolving)
but evolution[] is empty, they re-populate from the static mission
definitions. This ensures tally tracking works immediately regardless
of hydration timing.
Lets users with a local-nsec login reveal, copy, and back up their secret
key from /settings/profile. Uses saveNsec() so iOS gets iCloud Keychain,
Android gets Credential Manager with a file fallback, and web gets a
.nsec.txt download plus an opportunistic PasswordCredential save.
Renders an explanatory message for NIP-07 extension and NIP-46 bunker
logins, where the key is not accessible from the app.
- Remove dead code: useSyncTaskCompletions, incrementInteractionTaskTags,
getInteractionCount, getEvolveInteractionCount, unused lookup maps
- Fix task progress showing 0/N on load: compute event-based task counts
directly from Nostr query results (authoritative) instead of relying
solely on the evolution mission store which may not be hydrated yet.
Use max(queryCount, missionCount) so progress displays immediately.
- Fix hydration race: useDailyMissions raw memo now waits for hydration
before creating fresh missions, preventing overwrite of persisted
evolution[] with empty array. Also preserve evolution missions across
daily resets during hydration.
- Fix session store miss: use ensureSessionStore in incubation/evolution
start so evolution missions are always populated even if the store
hasn't been hydrated yet.
- Extract duplicate findMission to shared findEvolutionMission in
evolution-missions.ts
- Document evolution[] field on kind 11125 in NIP.md
Addresses confusion on the key-save step during signup:
- Rename the primary button from 'Continue' to 'Save Key' with a
Download icon, so the label matches the action it performs.
- Change saveNsec() to return 'saved' | 'saved-to-file' | 'dismissed'
instead of throwing on native dismissal. Dismissing the iCloud
Keychain prompt is a legitimate user choice so the handler now
proceeds silently rather than blocking with a 'Save failed' toast.
- Add an in-flight guard on the Save Key button with a spinner and
'Saving…' label. The finally block guarantees the disabled state is
cleared, so users can never get stuck on an unresponsive button —
fixing the 'button became disabled after I dismissed the prompt'
complaint by construction.
- On de-Googled Android builds (GrapheneOS, /e/OS, etc.) the AndroidX
Credential Manager has no provider to delegate to, so the keychain
save fails immediately. Fall back to writing the key to the app's
Documents directory so the user always has a persistent backup, and
surface a toast telling them where the file is.
- iOS keeps its original behaviour: dismissing the iCloud Keychain
sheet is a deliberate user choice, no automatic fallback. The
Documents folder on iOS is accessible via the Files app without
authentication, so silently dropping a plaintext nsec there would
violate user intent.
- Use the app name (from config.appName) as the filename slug for any
.nsec.txt file written to disk. On Capacitor location.hostname is
always 'localhost', so passing the app name is the only way to get
a meaningful filename. Drop the redundant 'nostr-' prefix since the
'.nsec.txt' extension already identifies the file.
- Rewrite the description and title on the save step: 'Your secret
key' + a single paragraph explaining what the key is and why it
matters.
- When the user reveals the key via the eye toggle, show an amber
callout with sharing/screenshotting warnings and a 'Learn more' link
to the Managing Nostr keys blog post. The warning appears at the
moment risk is highest.
- Auto-select the full nsec on focus/click so users copying into a
password manager don't have to fight mobile selection handles.
- Use openUrl() for the external 'Learn more' link so it works
correctly inside Capacitor's WKWebView.
- Singularise the keygen step copy ('cryptographic key' / 'Generate
my key') to stay consistent with the save step which presents a
single secret key.
- Restore full interactive chat widget with ScrollArea, streaming messages,
input area, and conversation cache that was regressed in ec9b6c43
- Extract useShakespeareCredits hook so credits gating is DRY between the
widget and the full AI chat page
- Show Dork ASCII mascot consistently across all empty/logged-out states
instead of the generic Bot icon
- Add RateLimitError class with Retry-After header parsing
- Distinguish insufficient_quota 429 from rate-limit 429
- Friendly Dork-themed error banners for rate limiting and out-of-credits
- Clean no-credits empty state with directive CTA and Get Credits button
- Hide model selector, trash, and input when user has no credits
- Hide page title on mobile, align model selector right
- Simplify sidebar widget to Shakespeare CTA
- Sanitize event-sourced URLs before CSS url() interpolation in
ProfileCard banner and letter stationery background (closes H-1, H-2)
- Sanitize event-sourced font families at the parse layer and in letter
card/detail consumers that bypass resolveStationery (closes M-6)
- Export sanitizeCssString for broader reuse
- Route NWC wallet connection URIs and active pointer through a new
useSecureLocalStorage hook, storing in iOS Keychain / Android KeyStore
on native (closes M-1)
- Add removeItem to secureStorage
- Add Android backup/data-extraction rules that exclude WebView storage
and Capacitor secure-storage SharedPreferences so wallet credentials
don't leak via Google Auto Backup (closes M-5)
- Document that GOOGLE_PLAY_SERVICE_ACCOUNT_JSON must be base64-encoded
to match what the CI job expects (closes M-2)
`ThemeFontSchema.url` and `ThemeBackgroundSchema.url` previously accepted
any string, relying entirely on downstream `sanitizeUrl()` calls for
protocol enforcement. Tightening the schema to `z.url()` rejects
obviously malformed inputs up front and matches the approach already used
for the relay list (`BlossomServersEventSchema`). `sanitizeUrl()` remains
the authoritative guard for `https:` enforcement at render time.
The per-device ephemeral key used to sign nostr-push RPC events was
previously stored unconditionally in localStorage. On Capacitor builds
this bypassed the iOS Keychain / Android KeyStore wrapper that every
other persistent key in the app already uses.
Route the key through `secureStorage`, which keeps the native path
encrypted at rest and falls back to localStorage on web (where it was
before). Because the key is now loaded asynchronously, convert the
`NostrPushClient` constructor into a private constructor plus a public
`create()` factory, and restructure `usePushNotifications` bring-up to
await the client before registering the service worker.
The key is ephemeral and per-device, so compromise only reveals which
Nostr events this device subscribes to -- not the user's identity --
but matching the existing secure-storage contract closes an obvious
inconsistency.
The `picture` and `banner` fields parsed from a kind 31990 NIP-89 event's
JSON content were passed directly to `<img src>` attributes without any
scheme validation. Non-https URLs could leak the user's IP to arbitrary
hosts, and data: URIs could be used for fingerprinting.
The same event's `website` URL was already sanitized; apply the same
treatment to the image URLs for consistency. The app's CSP `img-src`
already blocks most of these at the browser level, so this is
defense-in-depth.
Previously the resolver accepted any string value from a domain's
.well-known/nostr.json `names` map and persisted it to IndexedDB. A
malicious or misconfigured NIP-05 server could return arbitrary data
(non-hex, wrong length, HTML, etc.) that would then be cached and
passed to downstream consumers as a pubkey.
Exploitation impact is limited because invalid hex simply fails to
match anywhere in the Nostr filter API, but hygiene and cache
integrity warrant rejecting malformed values outright. Enforce the
standard 64-char lowercase hex shape and evict any cached entry that
fails validation.
Previously the SandboxFrame iframe relied entirely on cross-origin
subdomain isolation (the HMAC-derived `<id>.sandbox.ditto.pub` origin)
for containment. That does give origin-keyed storage and postMessage
isolation, but it does not restrict top-frame navigation, pointer lock,
or other capabilities that a hostile nsite/webxdc app could abuse.
The highest-value protection here is blocking `allow-top-navigation`:
without it, a malicious nsite could do `window.top.location = evilUrl`
and redirect the entire Ditto tab to a phishing page that impersonates
the app. The user opened a preview expecting to stay inside Ditto, so
this is a realistic and impactful attack.
The policy grants the capabilities that real web apps legitimately use
(scripts, same-origin storage + Service Workers per iframe.diy's
architecture, forms, modals, popups that escape the sandbox, downloads)
while withholding the ones that are either attacks (top navigation) or
unused niche features (pointer lock, presentation API, orientation
lock).
Also Omit 'sandbox' from the spread props so consumers cannot
accidentally weaken the policy.
NIP-17 requires that clients verify `messageEvent.pubkey === sealEvent.pubkey`
before trusting a gift-wrapped direct message. Without this check, any
attacker can construct a rumor claiming to be from another user and
gift-wrap it to the victim -- the seal signature only authenticates the
seal author, not the (unsigned) inner rumor.
Ditto's primary sender display uses sealEvent.pubkey so the headline
impersonation case is mitigated in practice, but the inner event's fields
(including its pubkey) are passed whole to NoteContent for kind 15 file
attachments, which could leak into downstream zap/reply targeting. Add
the spec-mandated check to prevent any trust in the inner pubkey.
The in-memory session store doesn't survive page refresh. Add
usePersistEvolutionProgress hook that listens for evolution mission
changes and debounce-publishes (5s) to kind 11125 content JSON via
fetchFreshEvent + serializeProfileContent. Wired into BlobbiPage.
Migrate the hatch/evolve task system to use MissionsContent.evolution[]
on kind 11125 (Blobbonaut Profile) instead of task/task_completed tags
on kind 31124 (Blobbi State).
- Add evolution-missions.ts with static definitions for hatch and evolve
task pools (TallyMission for interactions, EventMission for themes,
color moments, posts, profile edits)
- Populate evolution[] in session store on incubation/evolution start;
clear on stop
- Switch interaction tracking from incrementInteractionTaskTags (kind
31124 tag manipulation) to trackEvolutionMissionTally (session store)
- Rewrite useHatchTasks/useEvolveTasks to read progress from evolution[]
and backfill event IDs from retroactive Nostr queries
- Remove useSyncTaskCompletions and the task tag sync effect from
BlobbiPage
WIP: type errors and barrel exports still need cleanup.
Add -webkit-touch-callout: none and -webkit-user-select: none inline
styles to the GameControls container. The existing Tailwind select-none
class (user-select: none) is not sufficient on iOS, where WKWebView
still triggers the long-press callout/highlight gesture on held buttons.
The platform check was cached as a module-level constant, which could
evaluate before the Capacitor bridge was ready. Moved to per-call checks
matching the pattern used everywhere else in the codebase. Also replaced
silent .catch(() => {}) with console.warn so failures are visible in
Safari Web Inspector / Xcode console.
- Remove dead deprecated exports: isValidEvolvePost, EVOLVE_REQUIRED_POSTS,
BLOBBI_EVOLVE_POST_PREFIX, isValidBlobbiPost, sanitizeToHashtag
- Remove corresponding barrel re-exports from actions/index.ts
- Simplify hatch/evolve query keys to ['...-tasks', pubkey] since
retroactive queries no longer depend on stateStartedAt
- Drop stateStartedAt from enabled guards so retroactive queries
aren't blocked when the timestamp is missing
- Align BlobbiHatchingCeremony hatch path: babies now start as
'evolving' with state_started_at set, matching useBlobbiStageTransition
- Ceremony fakePreview for existing eggs preserves companion's actual state
New eggs now start in 'incubating' state with state_started_at set at
adoption time, so hatch tasks begin tracking immediately.
Newly hatched babies now start in 'evolving' state with a fresh
state_started_at, so evolution tasks begin tracking immediately.
The evolving state is applied after validateAndRepairBlobbiTags (which
would otherwise repair task-process states to 'active' via cleanupTaskTags).
Existing/older Blobbis are unaffected -- no migration is performed.
Stop incubation/evolution actions continue to work as before.
Content-type missions (theme, color moment, post, profile edit) now query
the user's full Nostr history instead of filtering by state_started_at.
Only Blobbi-specific tasks (interactions, maintain_stats) still require
actions on the current Blobbi instance.
Egg incubation:
- create_theme, color_moment: retroactive (no since: filter)
- create_post: retroactive, simplified to any post with #blobbi tag
- interactions: still Blobbi-specific (7x care actions)
Baby evolution:
- create_themes, color_moments, edit_profile: retroactive
- create_posts task removed entirely
- interactions: still Blobbi-specific (21x care actions)
- maintain_stats: still Blobbi-specific (dynamic, all stats >= 80)
Replace the manual parent-walk loop with closest('a, button,
[role="button"]') — simpler, handles role="button" elements, and
returns null cleanly when no clickable ancestor exists (so only the
raw pointer fallback is used).
Clamp the resolved click-origin Y to 55% of viewport height so Blobbi
looks across toward the sidebar rather than sharply downward when the
clicked item is near the bottom of the screen.
Store the nearest clickable ancestor (<a>/<button>) on pointerdown
alongside the raw coordinates. At route-change time, re-read the
element's getBoundingClientRect() to get its current visible center,
which accounts for any scroll shifts between the click and the React
effect. Falls back to the raw pointer coordinates if the element has
been unmounted.
Track the last pointerdown position in a ref. On route change, if a
recent click exists (<1s), glance at that position for ~700ms first,
then look at the center-top of the new page for 2-6s. Programmatic
navigation (no recent click) falls back to immediate center-top.
Single-file change in useRouteReaction.ts. No changes to attention,
gaze, or state hooks.
Guard makeDecision() so it bails out while attentionTarget is active.
Previously the decision loop could fire during the attending window,
transitioning Blobbi to walking/idle and breaking the attend-ui gaze
lock. The attention-end handler already resumes decisions when it
clears, so this guard is safe.
On route change, immediately trigger a preliminary attention target at
viewport center-top before the 250ms DOM-mount delay. This ensures the
gaze system never falls to random or mouse-follow mode during the gap,
which previously caused Blobbi to drift leftward toward the sidebar.
The delayed reaction still fires at 250ms and replaces the preliminary
target with a precise DOM-measured position.
Keep previous attention alive during the 250ms route-reaction delay
instead of clearing it immediately. This prevents the gaze system from
falling to random mode (which could point toward the sidebar) while
waiting for the new page's DOM to mount.
- Split cancelReaction into cancelPendingTimeouts (timeouts only) and
full cancel (timeouts + attention); route changes use the former
- Add bypassCooldown option to triggerAttention so the delayed reaction
can override the kept-alive attention without being blocked by cooldown
- Stabilize triggerAttention via uiAttentionRef instead of stale closure
clearAttention() now resets lastAttentionTimeRef to 0 so that a
triggerAttention call immediately after a forced clear is not silently
rejected by the 1500ms cooldown guard.
Remove right-sidebar detection and multi-target chaining. The generic
reaction now fires a single attention target at the top-center of the
main content area for a random 2-6 seconds.
- Remove findRightSidebarPosition() and all sidebar scan logic
- Replace fixed 1200ms duration with random 2000-6000ms
- Compute target position at reaction start via live DOM query
and window.innerWidth/Height fallback (no stale closure)
- Cancel both pending timeouts and active attention on drag
- Remove viewport prop from hook options (no longer needed)
- Update docstrings to describe the simplified behavior
On page navigation the companion now briefly pauses and scans the
layout areas that changed. Center content is always scanned first,
followed by the right sidebar if a non-placeholder sidebar is
detected in the DOM.
Implementation:
- useRouteReaction.ts: thin orchestration hook that watches pathname,
determines changed areas, and chains triggerAttention calls via
setTimeout. Cancels on new route change, drag, or unmount.
- useBlobbiCompanion.ts: wires the new hook with existing
triggerAttention/clearAttention from useBlobbiAttention.
No changes to the attention system, state machine, gaze hook, motion
hook, or entry animation. The existing attending state and attend-ui
gaze mode handle all the visual behavior.
Includes an empty ROUTE_REACTIONS map for future per-route overrides.
- Skip appending protocol:nostr if the resolved filter already contains it
- Add comment explaining why the 2-element prefix key correctly invalidates
the full 5-element useTabFeed query key via TanStack prefix matching
Split createBitcoinTransaction into buildUnsignedPsbt, signPsbtLocal,
and finalizePsbt so the signing step can be delegated to any signer.
Introduce BtcSigner interface and three extended signer classes:
- NSecSignerBtc: local Taproot signing with the raw private key
- NBrowserSignerBtc: delegates to window.nostr.signPsbt() (NIP-07)
- NConnectSignerBtc: sends sign_psbt RPC over NIP-46 relay channel
useCurrentUser now constructs Btc-extended signers instead of base
ones. signerWithNudge forwards signPsbt when present on the wrapped
signer. SendBitcoinDialog uses the new useBitcoinSigner hook instead
of useNsecAccess, enabling sending from all login types.
Implement sending Bitcoin transactions from the wallet page. The send
flow uses a 3-step dialog: form entry, confirmation review, and success
result. Only available for nsec logins since extension/bunker signers
don't expose the raw private key needed for Taproot signing.
Fixes over the reference implementation:
- Send Max correctly subtracts estimated fees
- Address validation via bitcoinjs-lib (checksum + format)
- Fee estimation accounts for actual output count (1 vs 2)
- Confirmation step before broadcast (irreversible action)
- All API calls use mempool.space (consistent with existing code)
- Success links to in-app NIP-73 tx detail page
New files:
- src/hooks/useNsecAccess.ts: extract private key from nsec login
- src/components/SendBitcoinDialog.tsx: 3-step send dialog
New functions in src/lib/bitcoin.ts:
- fetchUTXOs, getFeeRates, broadcastTransaction
- validateBitcoinAddress, estimateFee, maxSendable
- createBitcoinTransaction (PSBT construction + Taproot signing)
- npubToBitcoinAddress, btcToSats
The link was using inline-flex items-center with child spans at different
font sizes (text-sm 'transaction' + text-xs monospace hash). Flexbox
center-aligns by box center, not text baseline, causing the smaller text
to appear shifted up. Changed to plain inline text flow so the browser's
natural baseline alignment handles mixed font sizes correctly.
Integrate Bitcoin content into the /i/* external content system using NIP-73
identifiers (bitcoin:tx:{txid} and bitcoin:address:{address}).
- Add bitcoin-tx and bitcoin-address types to ExternalContent parser
- Create BitcoinTxHeader with mempool.space-style inputs/outputs flow view
- Create BitcoinAddressHeader with balance, stats, and recent transactions
- Add useBitcoinTx and useBitcoinAddress hooks (mempool.space Esplora API)
- Switch all Bitcoin API calls from blockstream.info to mempool.space
- Update WalletPage to link transactions to /i/bitcoin:tx:{txid} pages
- Remove unused blockExplorerAddress/blockExplorerTx config fields
- Add compact Bitcoin previews for embedded note contexts
Add blockExplorerAddress and blockExplorerTx fields as RFC 6570 URI
templates with {address} and {txid} variables respectively. Default to
mempool.space instead of blockstream.info. Wallet page uses UriTemplate
to fill the configured templates.
Transactions button with chevron replaces the 'View on explorer' link.
Clicking toggles the tx list open/closed with a smooth accordion slide
using CSS grid-template-rows animation. Chevron rotates on open.
Fetch transactions from Blockstream Esplora API, compute net amount per
tx relative to the user's address, and display as a list below the QR
code. Each row shows receive/send direction, relative date, USD amount
(with BTC underneath), and links to the block explorer. Includes loading
skeletons and empty state.
Fetch BTC/USD price from CoinGecko (refreshes every 60s). Display USD
as the hero balance, BTC amount as the secondary line. Remove sats
display entirely. Pending amounts also shown in USD.
Remove outer Balance card wrapper, stats grid, and How It Works section.
Balance is now the hero element, centered with QR code below and a
compact pill-shaped address with inline copy. Clean, minimal layout.
Derive a bc1p... Taproot address directly from the user's Nostr public key
(both use secp256k1 x-only keys) and display balance via Blockstream API.
Includes QR code, copy-to-clipboard, balance with pending detection, and
a WALLET.md documenting the derivation algorithm. Sending is not yet
implemented.
The previous useStreamPosts always injected 'protocol:nostr' into the
NIP-50 search string, which is a Ditto relay extension that filters for
native Nostr events. Without it, useTabFeed's queries return stale or
fewer results because the relay doesn't scope to the Nostr protocol.
Augment the resolved filter's search field with 'protocol:nostr' before
passing it to useTabFeed, matching the old behavior.
SavedFeedContent was using useStreamPosts which stores data in React
component state (useState). When navigating to a post detail page the
component unmounts and all state is destroyed, forcing a full re-fetch
on back navigation — losing the user's scroll position and content.
Replace useStreamPosts with useTabFeed (useInfiniteQuery) to match how
the Home, Ditto, and Global feeds work. TanStack Query caches all
fetched pages independently of component lifecycle (gcTime = 30 min),
so navigating back renders content instantly from cache, preserving
scroll position.
This also adds proper infinite scroll pagination and repost unwrapping
to custom saved feeds, which previously loaded a single batch.
Closes#217
ScrollToTop was calling window.scrollTo(0, 0) on every pathname change,
including back/forward (POP) navigation. This destroyed the browser's
native scroll restoration, forcing users back to the top of the feed.
Use useNavigationType() to only scroll to top on PUSH navigation (user
clicked a link), preserving scroll position on POP (back/forward).
Closes#217
2026-04-05 17:44:13 -05:00
1129 changed files with 104153 additions and 123489 deletions
description: Browser-API gotchas inside Capacitor's WKWebView (iOS) and Android WebView — which common web APIs silently fail, the downloadTextFile/openUrl helpers that bridge web and native, platform detection, and the installed Capacitor plugins. Load when writing code that interacts with file downloads, external URLs, or platform-specific behavior.
---
# Capacitor Compatibility
Ditto runs inside Capacitor's WKWebView on iOS and WebView on Android. Several common web APIs **do not work** in this environment. Always account for native platforms when writing code that interacts with browser-specific features.
## What Doesn't Work in WKWebView (iOS)
- **`<a download>` file downloads** — programmatically creating an anchor with `a.download` and clicking it silently fails. WKWebView ignores the `download` attribute entirely.
- **`<a target="_blank">` new tabs** — programmatic clicks on anchors with `target="_blank"` are blocked. There are no tabs in a native app.
- **`window.open()`** — may be blocked or behave unexpectedly without user-gesture context.
For a deeper list of Apple Lockdown Mode restrictions that also affect WKWebView, load the **`lockdown-mode`** skill.
## File Downloads and URL Opening
`src/lib/downloadFile.ts` provides two utilities that handle the web/native split automatically. **Always use these** instead of manually constructing anchors.
### `downloadTextFile(filename, content)`
Saves a text file to the user's device. On web it uses the `<a download>` pattern. On native it writes to the Capacitor cache directory via `@capacitor/filesystem` and presents the native share sheet via `@capacitor/share`.
```typescript
import{downloadTextFile}from'@/lib/downloadFile';
awaitdownloadTextFile('backup.txt',fileContents);
```
### `openUrl(url)`
Opens a URL in a new browser tab on web, or presents the native share sheet on Capacitor.
```typescript
import{openUrl}from'@/lib/downloadFile';
awaitopenUrl('https://example.com/image.jpg');
```
**CRITICAL**: Never use `document.createElement('a')` with `.click()` for downloads or opening URLs. The utilities above work correctly on all platforms; manual anchors silently fail on iOS.
## Detecting Native Platforms
Use `Capacitor.isNativePlatform()` from `@capacitor/core` when you need platform-specific behavior:
```typescript
import{Capacitor}from'@capacitor/core';
if(Capacitor.isNativePlatform()){
// iOS or Android
}else{
// Web browser
}
```
Reserve platform forks for cases where behavior genuinely differs (share sheets, secure storage, haptics). Most UI code should stay platform-agnostic.
## Installed Capacitor Plugins
-`@capacitor/app` — app lifecycle events (deep links, back button)
-`@capacitor/core` — core runtime and platform detection
-`@capacitor/filesystem` — read/write files on the native filesystem
-`@capacitor/haptics` — native haptics
-`@capacitor/keyboard` — keyboard control (hide accessory bar, etc.)
-`@capacitor/local-notifications` — schedule local push notifications
-`@capacitor/share` — native share sheet
-`@capacitor/status-bar` — control the native status-bar style
-`@capgo/capacitor-autofill-save-password` — iOS keychain autofill for nsec
description: Ditto's release and publishing pipeline — cutting a version tag, Zapstore APK publishing with NIP-46 bunker auth, nsite web deploys via nsyte, and Google Play AAB uploads via fastlane supply. Includes GitLab CI variable setup and credential rotation.
---
# CI/CD Pipeline and Publishing
Ditto uses GitLab CI (`.gitlab-ci.yml`) to run tests on every commit, deploy the web app to nsite on every default-branch push, and build + publish Android binaries to Zapstore and Google Play on every tag. Load this skill when setting up CI credentials, rotating a signing key, diagnosing a failed publish, or adding a new publishing target.
This creates a tag in the format `v2026.03.14+abc1234` (date + short commit hash) and pushes it to GitLab, which triggers the `build-apk`, `release`, `publish-zapstore`, and `publish-google-play` jobs.
For the full versioning / changelog / native-build workflow, load the **`release`** skill.
## Zapstore Publishing
The `publish-zapstore` CI job uploads signed APKs to [Zapstore](https://zapstore.dev/) using the [`zsp`](https://github.com/zapstore/zsp) CLI and NIP-46 bunker signing via Amber.
NIP-46 bunker signing requires two keys: the **user's key** (held by Amber) and a **client key** (the CI runner's identity). The bunker authorizes specific client pubkeys — once authorized, the client can request signatures without re-approval.
The `publish-zapstore` job restores the client key from `ZAPSTORE_CLIENT_KEY` into `~/.config/zsp/bunker-keys/<bunker-pubkey>.key` before running `zsp`, so the bunker recognizes the CI runner as an already-authorized client.
### Initial setup (one-time)
Run the NIP-46 client-initiated auth script:
```bash
node scripts/nip46-auth.mjs
```
This generates a `nostrconnect://` URI. Import/paste it into Amber and approve the connection. The script outputs the `bunker://` URI and client key hex, and writes the client key to `~/.config/zsp/bunker-keys/`. Update the GitLab CI/CD variables with the printed values.
Options:
-`--relay <url>` — relay for NIP-46 communication (default: `wss://relay.ditto.pub`)
-`--name <name>` — app name shown to the signer (default: `Ditto`)
-`--timeout <sec>` — how long to wait for approval (default: 300)
After authorization, the bunker recognizes the client key and no secret or manual approval is needed for CI runs. If the client key is rotated, run the script again and update the GitLab variables.
## nsite Publishing
The `deploy-nsite` CI job deploys the Vite build to [nsite](https://nsite.run) on every push to the default branch using [nsyte](https://github.com/sandwichfarm/nsyte). The job uploads `dist/` to Blossom servers and publishes site manifest events to Nostr relays.
nsyte uses a NIP-46 bunker credential called **nbunksec** — a bech32-encoded string bundling the bunker pubkey, client secret key, and relay info into a single self-contained token. It's passed to nsyte via `--sec`.
This guides you through connecting a NIP-46 bunker (e.g. Amber) and outputs an `nbunksec1...` string. The credential is shown only once.
3. Add the `nbunksec1...` value as `NSITE_NBUNKSEC` in GitLab CI/CD settings. Mark it as **Protected** and **Masked**.
### Configured relays and servers
Relays the deploy job publishes to:
- `wss://relay.ditto.pub`
- `wss://relay.nsite.lol`
- `wss://relay.dreamith.to`
- `wss://relay.primal.net`
Blossom servers:
- `https://blossom.primal.net`
- `https://blossom.ditto.pub`
- `https://blossom.dreamith.to`
The `--use-fallback-relays` and `--use-fallback-servers` flags include nsyte's built-in defaults for broader coverage. The `--fallback "/index.html"` flag enables SPA client-side routing.
### Credential rotation
To rotate the nsite credential:
1. Revoke the old bunker connection in your signer app.
2. Run `nsyte ci` again to generate a new `nbunksec1...` string.
3. Update the `NSITE_NBUNKSEC` variable in GitLab CI/CD settings.
## Google Play Publishing
The `publish-google-play` CI job uploads Android AABs to [Google Play](https://play.google.com/store/apps/details?id=pub.ditto.app) using [fastlane supply](https://docs.fastlane.tools/actions/supply/). It runs after a successful AAB build and uploads directly to the production track.
| `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON` | **Base64-encoded** contents of the Google Play API service account key JSON. The CI job decodes with `base64 -d` before passing to `fastlane supply`. | Yes | Yes | No |
### Initial setup (one-time)
1. Create or reuse a project in [Google Cloud Console](https://console.cloud.google.com/projectcreate).
2. Enable the [Google Play Developer API](https://console.developers.google.com/apis/api/androidpublisher.googleapis.com/) for that project.
3. In Google Cloud Console, go to [Service Accounts](https://console.cloud.google.com/iam-admin/serviceaccounts), create a service account, and download a JSON key file for it.
4. In Google Play Console, go to [Users & Permissions](https://play.google.com/console/users-and-permissions), click **Invite new users**, enter the service account email, and grant it permission to manage releases for `pub.ditto.app`.
5. **Base64-encode** the key file:
```bash
# Linux
base64 -w0 service-account.json
# macOS
base64 -i service-account.json | tr -d '\n'
```
6. Add the base64-encoded value as `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON` in GitLab CI/CD settings. Mark it as **Protected** and **Masked**. **Do not paste the raw JSON** — the CI script expects base64 and will fail to decode a raw value.
### Key points
- The job uploads the signed **AAB** (not APK) — Google Play requires App Bundles.
- Uploads go directly to the **production** track. Google's review process still applies before the update reaches users.
- Metadata, screenshots, and store-listing description are managed in the Play Console (the job uses `--skip_upload_metadata`, `--skip_upload_images`, `--skip_upload_screenshots`).
- **Changelogs ("What's new in this version")** are uploaded from `android/fastlane/metadata/android/en-US/changelogs/<versionCode>.txt`, generated at CI time from the release summary paragraph in `CHANGELOG.md`. See "Release notes pipeline" below.
- The same signing keystore used for Zapstore is reused here (`ANDROID_KEYSTORE_BASE64`, `KEYSTORE_PASSWORD`, `KEY_PASSWORD`).
## App Store Publishing
Ditto's iOS pipeline is split across two jobs:
- **`build-ipa`** (stage `build`, `tags: [macos]`) runs on the self-hosted Mac runner. Decodes the App Store Connect API key, fetches the encrypted distribution cert + provisioning profile via fastlane match, builds the web assets, runs `cap sync ios`, stamps the marketing version into `project.pbxproj`, then `fastlane build_ipa` produces a signed App Store IPA at `artifacts/Ditto.ipa`. The IPA is uploaded to the GitLab Generic Packages registry as `Ditto-${CI_COMMIT_TAG}.ipa` (mirrors how `build-apk` publishes the APK and AAB) and exposed as a CI artifact for downstream jobs.
- **`publish-app-store`** (stage `publish`, `tags: [macos]`) also runs on the self-hosted Mac runner. Consumes the IPA artifact via `needs: [build-ipa]` and the release-notes artifact via `needs: [release-notes]`. Decodes the API key, copies the release-notes summary into `ios/fastlane/metadata/en-US/release_notes.txt`, and runs `fastlane submit_release` which calls `deliver` to upload metadata + push the prebuilt IPA + auto-submit for App Store review. **macOS is required** even though the IPA is already signed: `fastlane deliver` shells out to Apple's iTMSTransporter / altool to upload the binary, and those tools only ship inside Xcode. A Linux container ran into `No such file or directory @ dir_chdir0` from `JavaTransporterExecutor#execute` because `Helper.itms_path` resolved to a missing Xcode path.
The Mac runner is therefore used for both iOS jobs. For runner administration (operating the Mac, restarting the agent, viewing logs, rotating signing certs), load the **`mac-runner`** skill.
**Configuration files:**
- `ios/fastlane/Fastfile` — exposes four lanes:
- `build_ipa` — setup_ci → match (readonly, with API key) → increment_build_number → build_app. Used by CI's `build-ipa`.
- `submit_release` — reads `IPA_PATH` env var, calls deliver against the prebuilt IPA. Used by CI's `publish-app-store`.
- `release` — combines build_ipa + submit_release; convenience for local one-shot runs.
- `submit_only` — debug lane that skips build/upload and only runs deliver against an already-uploaded build (set `BUILD_NUMBER` + `VERSION` env vars). See the `mac-runner` skill.
- `ios/fastlane/Appfile` — bundle identifier and team ID
- `ios/fastlane/Matchfile` — points at the shared `soapbox-pub/certificates` repo
- `ios/fastlane/metadata/en-US/release_notes.txt` — placeholder; CI overwrites it with the release summary paragraph from `CHANGELOG.md` per release
- `.gitlab-ci.yml` — `build-ipa` and `publish-app-store` both run on the Mac runner (`tags: [macos]`)
**Code signing storage**: a private GitLab repo `soapbox-pub/certificates` holds encrypted distribution certs and provisioning profiles, managed by [fastlane match](https://docs.fastlane.tools/actions/match/). Match handles cert/profile lifecycle: one passphrase decrypts everything; the same repo can hold signing material for multiple Soapbox iOS apps under team `GZLTTH5DLM`.
**App Store Connect auth**: a long-lived [App Store Connect API key](https://developer.apple.com/documentation/appstoreconnectapi/creating-api-keys-for-app-store-connect-api) (`.p8` file + key ID + issuer ID) authenticates `match`, `deliver`, and `pilot`. Avoids 2FA prompts that would interrupt CI.
**Distribution**: `submit_for_review: true` automatically pushes the build into Apple's review queue once uploaded. `automatic_release: false` keeps a human-controlled final gate — once Apple approves, you click "Release" in the App Store Connect web UI to publish to users. To remove the manual gate, flip `automatic_release` to `true` in `ios/fastlane/Fastfile`.
**Release notes**: copied from the `release-notes` job's artifact `artifacts/release-notes-summary.txt` (the leading plaintext paragraph of the version's `CHANGELOG.md` section) into `ios/fastlane/metadata/en-US/release_notes.txt`, uploaded by `deliver` as the App Store "What's New in This Version" text. See "Release notes pipeline" below.
**IPA distribution beyond the App Store**: `build-ipa` uploads the signed IPA to the GitLab Generic Packages registry, and the `release` job links it from the GitLab Release page. The IPA is signed with the App Store distribution profile, so it isn't directly sideloadable — installation goes through Apple's review process — but having it as a stable artifact lays the groundwork for AltStore or ad-hoc distribution later (which would require a separate provisioning profile).
| `MATCH_PASSWORD` | Symmetric passphrase used by match to encrypt/decrypt certs and profiles. The single most important secret — losing it makes the cert repo unreadable. | Yes | Yes | Yes |
| `MATCH_GIT_BASIC_AUTHORIZATION` | Base64 of `username:deploy-token` for HTTPS clone of the certificates repo. Generated from a `read_repository`-scoped deploy token on `soapbox-pub/certificates`. | Yes | Yes | Yes |
| `APP_STORE_CONNECT_API_KEY_ID` | App Store Connect API key ID (10 chars). | Yes | No | Yes |
| `APP_STORE_CONNECT_API_KEY_ISSUER_ID` | App Store Connect issuer ID (UUID). | Yes | No | Yes |
| `APP_STORE_CONNECT_API_KEY_P8_BASE64` | Base64-encoded contents of the `.p8` private key file. CI decodes with `base64 -d` into `~/.private_keys/AuthKey_<KEY_ID>.p8` and removes it in `after_script`. | Yes | Yes | Yes |
| `FASTLANE_KEYCHAIN_PASSWORD` | Password for the ephemeral keychain `setup_ci` creates per build. Random per setup; keep stable across runs. | Yes | Yes | Yes |
### Initial setup (one-time)
1. **Provision the Mac runner.** See the **`mac-runner`** skill for hardware/launchd setup, Xcode, Homebrew, fastlane, and `gitlab-runner` registration.
2. **Create the App Store Connect API key.** Log in to [App Store Connect](https://appstoreconnect.apple.com) → Users and Access → Integrations → App Store Connect API → Generate. Use the **App Manager** role (sufficient for `deliver`'s upload + submit-for-review). Download the `.p8` file (one-time download — Apple won't show it again). Note the **Key ID** (10-char string next to the key) and the **Issuer ID** (UUID at the top of the API page).
Set the three GitLab CI variables:
```bash
# Replace <ISSUER_ID>, <KEY_ID>, and the path to your .p8
base64 -i AuthKey_<KEY_ID>.p8 | tr -d '\n' # paste this as APP_STORE_CONNECT_API_KEY_P8_BASE64 (masked)
```
3. **Create the certificates repo.** A private GitLab repo at `soapbox-pub/certificates` holds match-encrypted certs/profiles. Create a project deploy token on it (Settings → Repository → Deploy tokens) with `read_repository` scope. Encode `username:token` as base64 → set as `MATCH_GIT_BASIC_AUTHORIZATION` (protected, masked, raw).
4. **Generate `MATCH_PASSWORD` and `FASTLANE_KEYCHAIN_PASSWORD`.** Both are arbitrary strong random strings — `openssl rand -base64 32 | tr -d '=+/' | head -c 32` works. Store them as protected, masked GitLab variables.
5. **Bootstrap match certs via a one-shot CI job** (preferred over running match locally — avoids the macOS keychain UI permission dialogs that fastlane bug [#15185](https://github.com/fastlane/fastlane/issues/15185) trips on newer macOS):
a. Create a temporary write-scoped GitLab variable. The deploy token is `read_repository`; for the initial cert creation match needs to push. Encode `username:write-pat` as base64 and set it as `MATCH_GIT_BASIC_AUTHORIZATION_WRITE` (Protected, Masked, Raw).
b. Add a temporary `setup-match` job to `.gitlab-ci.yml` that runs on the macos runner with `setup_ci` (which creates an ephemeral keychain — bypasses the GUI permission issue):
d. Once the job succeeds (cert + profile pushed to the certificates repo), **delete the `setup-match` job from `.gitlab-ci.yml` and the `MATCH_GIT_BASIC_AUTHORIZATION_WRITE` variable**. They're only needed for bootstrap.
### Yearly cert renewal
Apple distribution certs expire annually. Renewal is one command per year, run on any Mac:
```bash
cd ~/Projects/ditto/ios
fastlane match nuke distribution # revokes old cert in Apple's portal, removes from match repo
fastlane match appstore # creates new cert + profile, encrypts, commits, pushes
```
CI's next tag run picks up the new files automatically (`match(... readonly: true)`).
### Disaster recovery (Mac dies / new developer joins)
fastlane match appstore --readonly # decrypts existing certs/profiles using MATCH_PASSWORD
```
No re-issuance of certs needed — the cert repo is the source of truth.
### App Store Connect API key rotation
App Store Connect API keys can be revoked anytime. To rotate:
1. App Store Connect → Users and Access → Integrations → App Store Connect API → Generate new key
2. Download the new `.p8`, note the new key ID
3. Update `APP_STORE_CONNECT_API_KEY_ID` and `APP_STORE_CONNECT_API_KEY_P8_BASE64` in GitLab variables
4. (Issuer ID stays the same — it's per-team, not per-key)
5. Revoke the old key in App Store Connect
### Key points
- `build-ipa` (Mac) produces a signed **IPA** (App Store distribution format) and uploads it to GitLab's Generic Packages registry. `publish-app-store` (also Mac) submits it to Apple via `deliver`.
- Builds go to **App Store Connect**, automatically submit for review, but do **not** auto-release after approval. The final "Release" click is manual in the web UI.
- Marketing version comes from the git tag (`v2.1.0` → `MARKETING_VERSION = 2.1.0`); build number comes from `CI_PIPELINE_IID`.
- Release notes ("What's New in This Version") come from the release-notes summary paragraph (see "Release notes pipeline" below).
- `setup_ci` (in `build-ipa`) creates an ephemeral keychain per build, so the runner never touches the login keychain — works whether or not a GUI session is logged in.
- `publish-app-store` does no code signing, but it still needs macOS: `fastlane deliver` shells out to Apple's iTMSTransporter / altool to upload the binary, and those tools only ship inside Xcode.
## Release notes pipeline
Release notes for all three storefronts (App Store, Google Play, GitLab Release page) and the in-app version-update toast are derived from a single source: `CHANGELOG.md`.
**The `release-notes` job** (stage `build`, default `node:22` image, runs only on `v*` tags) calls `scripts/extract-release-notes.mjs` twice and publishes two artifacts:
- `artifacts/release-notes.md` — the full section for this version (summary paragraph + `### Added` / `### Changed` / etc. lists). Used as the GitLab Release description.
- `artifacts/release-notes-summary.txt` — only the leading plaintext paragraph (max 500 chars by convention). Used as the App Store / Play Store "What's new" text. Falls back to `Ditto vX.Y.Z` if the section has no summary paragraph.
**Downstream consumers** all pull from the `release-notes` job via `needs:`:
| App Store "What's New" | `publish-app-store` | `release-notes-summary.txt` → copied to `ios/fastlane/metadata/en-US/release_notes.txt` → uploaded by `deliver` |
| Play Store "What's new" | `publish-google-play` | `release-notes-summary.txt` → copied to `android/fastlane/metadata/android/en-US/changelogs/<versionCode>.txt` → uploaded by `supply` |
| In-app toast | `src/components/VersionCheck.tsx` (runtime) | Re-parses `public/CHANGELOG.md` via `parseChangelog()` and reads `entry.summary` (with a fallback to the legacy first-bullet behavior) |
**The summary format** is documented in the `release` skill — a single plaintext paragraph immediately under the `## [X.Y.Z] - YYYY-MM-DD` heading, before any `### Category`. The script enforces nothing on the parser side; CI emits a warning when the summary exceeds 500 chars but does not fail the build.
**To preview locally** what each storefront will receive:
```bash
node scripts/extract-release-notes.mjs vX.Y.Z # full GitLab Release body
description: Upload files (images, media, attachments) from the browser to a Blossom server via the useUploadFile hook, and attach them to Nostr events with NIP-94 imeta tags.
---
# File Uploads on Nostr
This project includes a `useUploadFile` hook that uploads files to Blossom servers and returns NIP-94-compatible tags. Use it whenever a feature needs to accept a user-provided file (avatars, banners, post attachments, etc.).
Append the URL to `content`, and add one `imeta` tag per file. `imeta` carries the NIP-94 metadata (mime type, dimensions, blurhash, etc.) that the uploader returned:
```ts
consttags=awaituploadFile(file);// e.g. [["url", "https://..."], ["m", "image/png"], ["dim", "1024x768"], ...]
consturl=tags[0][1];
// Flatten the NIP-94 tags into a single imeta tag value.
Repeat the pattern (one `imeta` tag per file) for multiple attachments.
## Common Patterns
- **Avatar / banner pickers:** wrap an `<input type="file" accept="image/*">` and call `uploadFile` on change; on success, update the relevant profile field and publish a kind 0 event.
- **Post composers:** call `uploadFile` for each selected file before publishing the note, then build `imeta` tags alongside `content`.
- **Progress UI:** use `isPending` from the mutation to disable the submit button and show a spinner or skeleton.
- **Error handling:** wrap `uploadFile` in `try/catch` and surface failures via `useToast` — network and Blossom-server errors are common and should never break the UI.
## Constraints
- The hook requires a logged-in user (Blossom auth is signed by the user's signer). Guard uploads behind `useCurrentUser`.
- Don't store or display raw `File` objects after upload — always use the returned URL.
- Large files may take time; prefer `mutateAsync` over `mutate` so the caller can `await` completion before publishing an event that references the URL.
description: Ditto's git conventions — validating changes before committing, writing commit messages that match project style, and attributing regressions with a Regression-of trailer so the release changelog skill can filter them from the "Fixed" section.
---
# Git Workflow
Ditto expects every completed task to end with a git commit. This skill covers the pre-commit validation loop, commit-message conventions, and the `Regression-of:` trailer used by the release skill to filter intra-release regressions from the changelog.
## Pre-commit Validation
**Your task is not finished until the code type-checks and builds without errors.** In priority order:
The full `npm run test` script runs all of these in sequence; running it is equivalent to steps 1–4.
## Using Git
Use `git status` and `git diff` to review changes, and `git log` to learn the project's commit-message conventions before writing a new one. If you make a mistake, `git checkout` restores files.
When your changes are complete and validated, create a commit with a message that focuses on **why** the change was made (not just **what**). Summaries should fit on one line; a body is warranted for non-trivial changes.
**Always commit when you are finished making changes. Non-negotiable — every completed task ends with a commit. Don't leave uncommitted changes.**
## Contributing Guide
When preparing changes for a merge request, also follow the guidelines in `CONTRIBUTING.md`. It includes a self-review checklist (step 8) that should be run against your diff before committing.
## Attributing Regressions
When a commit fixes a bug that was introduced by an identifiable prior commit, add a `Regression-of:` trailer at the bottom of the commit message body referencing the offending commit's short SHA:
```
Fix missing background on expanded emoji picker in feeds
The compose box overhaul accidentally dropped the bg-background class
when refactoring the picker out of QuickReactMenu.
Regression-of: 3aa08ba9
```
This is a standard Git trailer (compatible with `git interpret-trailers`) that records the cause-and-effect link directly in history. It is consumed by the `release` skill to detect intra-release regressions and exclude them from the changelog's "Fixed" section, and it makes future debugging and post-mortems substantially faster.
### When to add it
- The commit fixes a bug (not a new feature, refactor, or doc change).
- The introducing commit is identifiable with reasonable effort.
### When to skip it
- The bug is pre-existing with no clear single origin.
- The behavior was always wrong (no regression).
- The introducing commit cannot be determined after a brief search.
### Finding the introducing commit
-`git log -S '<removed-or-changed-string>'` — find commits that touched a specific string.
-`git log --oneline -- path/to/file` — list all commits touching a file.
-`git blame -L <start>,<end> -- path/to/file` — find who last changed specific lines.
This convention is **strongly recommended but not required.** When the origin is non-obvious, prioritize shipping the fix over hunting indefinitely.
description: Operate the self-hosted GitLab Runner on the Mac that builds Ditto's iOS IPA. Covers SSH access, restarting the runner, viewing logs, updating Xcode, debugging fastlane locally, and rotating match certificates.
---
# Mac Runner Operations
Ditto's iOS pipeline runs two CI jobs on a self-hosted GitLab Runner on a MacBook in the rack: `build-ipa` (signs and builds the IPA via Xcode + fastlane match) and `publish-app-store` (uploads the IPA via `fastlane deliver`, which shells out to Apple's iTMSTransporter — that tool only ships inside Xcode, so this job can't run on Linux). This skill covers operating the Mac.
This skill covers operating the runner: SSH access, restarting after crashes or Xcode updates, watching logs, debugging fastlane locally, and rotating the match certificates. For initial provisioning, App Store Connect API key creation, and GitLab CI variable setup, load the **`ci-cd-publishing`** skill.
- **User**: `alex` (the runner runs in user-mode so it can access keychain and Xcode UI tooling)
- **Tooling**: Homebrew (`/opt/homebrew`), `gitlab-runner`, `node@22`, `ruby@3.3`, fastlane installed as a user gem under `~/.gem/ruby/3.3.0/`
- **Service**: launchd LaunchAgent at `~/Library/LaunchAgents/gitlab-runner.plist`. `KeepAlive=true` (auto-restart on crash) and `RunAtLoad=true` (starts on login). The agent loads when `alex` logs in via auto-login at boot.
- **Tags**: `macos`, `ios`, `xcode` — both `build-ipa` and `publish-app-store` in `.gitlab-ci.yml` target this runner. `publish-app-store` doesn't sign anything, but it still needs Xcode's bundled iTMSTransporter to push the IPA to App Store Connect.
- **Shell setup**: `~/.bash_profile` sources brew shellenv and prepends `~/.gem/ruby/3.3.0/bin` and `/opt/homebrew/opt/ruby@3.3/bin` to `PATH` so `bash --login` (the runner's executor) finds fastlane + ruby 3.3.
### Why Ruby 3.3, not the brewed 4.0
Brewed `fastlane` (current version) ships running on Ruby 4.0 from `brew install ruby`. Ruby 4.0's OpenSSL bindings hit fastlane bug [#20553](https://github.com/fastlane/fastlane/issues/20553) — `OpenSSL::PKey::EC.new(pem)` raises "invalid curve name" for `prime256v1` keys, which breaks every App Store Connect API key signing operation. Ruby 3.3.x doesn't have this bug. So we install fastlane via `gem install fastlane --user-install` on `ruby@3.3` instead of `brew install fastlane`.
### Why IPv6 is disabled on Wi-Fi
`networksetup -setv6off Wi-Fi` is set because Ruby's net/http on this machine attempted IPv6 to `rubygems.org` first and timed out (~30 s per request). Disabling IPv6 on the Wi-Fi interface forces IPv4 immediately. To re-enable: `sudo networksetup -setv6automatic Wi-Fi`.
Or, to debug *just* the submission against an already-uploaded build without rebuilding, use the `submit_only` lane (see "Debugging App Store submission with the `submit_only` lane" below).
## Rotating match certificates (yearly)
Apple distribution certs expire one year after issuance. To renew:
```bash
ssh alex@alexs-air.lan
cd ~/Projects/ditto/ios
eval"$(/opt/homebrew/bin/brew shellenv)"
# Set Apple credentials (API key path)
exportMATCH_PASSWORD='<from GitLab CI variables>'
# Revoke the expiring cert in Apple's portal and remove from the match repo
fastlane match nuke distribution
# Issue a new cert, generate a new App Store profile, encrypt, commit, push
CI's next tag run picks up the new files via `match(... readonly: true)`. No GitLab variables to update.
## Debugging App Store submission with the `submit_only` lane
The `Fastfile` exposes a second lane, `submit_only`, that skips build/archive/upload and just runs `deliver` against an already-uploaded build. Useful when the binary is fine but the metadata/submission step is failing — iterate in ~30 seconds instead of waiting for a full ~6-minute CI build.
The lane expects the version to exist in App Store Connect with a `VALID` build attached. It uploads metadata (`./fastlane/metadata/en-US/release_notes.txt`) and calls `submit_for_review`. If Apple rejects, fix the Fastfile, re-run — no rebuild needed.
If Apple has already accepted the submission for that version, you'll need to "Remove from Review" in App Store Connect (only available while state is `WAITING_FOR_REVIEW`, not `IN_REVIEW`) before re-running, or bump the build number.
## Inspecting App Store Connect state directly
When fastlane's error messages aren't enough, query Apple's API directly. There's no installed CLI — use the JWT signing recipe Apple documents. A working Ruby snippet lives in this skill's troubleshooting history; the short version:
| Runner shows offline in GitLab | Mac rebooted, auto-login disabled, or LaunchAgent unloaded | SSH in, `gitlab-runner status`, `gitlab-runner restart` |
| Build fails: "unable to find Xcode" | Xcode auto-updated and changed path, or command-line tools missing | `xcode-select --install`, `sudo xcodebuild -license accept` |
| Build fails: "no signing certificate found" | match cert expired, was revoked manually, or `MATCH_PASSWORD` mismatched | Run yearly rotation procedure above |
| Build fails: keychain locked / "User interaction is not allowed" | `setup_ci` failed to create the temporary keychain | Verify `FASTLANE_KEYCHAIN_PASSWORD` is set in GitLab CI variables |
| Build fails: ASC API key invalid | Key was revoked or rotated | Generate a new key and update `APP_STORE_CONNECT_API_KEY_*` variables |
| "Build already exists" from `deliver` | Previous tag's IPA had the same `CFBundleVersion`; fastlane's `increment_build_number` didn't bump because the value already matched `CI_PIPELINE_IID` | Push a new tag (each new tag has a new pipeline ID) |
| Apple precheck rejects metadata | Encryption export compliance, IDFA, content rights flags don't match `Fastfile` | Update `submission_information` in `ios/fastlane/Fastfile` |
| `OpenSSL::PKey::PKeyError: invalid curve name` | fastlane is running on brewed Ruby 4.0, which has a broken OpenSSL EC parser ([fastlane#20553](https://github.com/fastlane/fastlane/issues/20553)) | Use `ruby@3.3` from brew and install fastlane as a user gem (`gem install fastlane --user-install`); ensure `~/.bash_profile` puts `~/.gem/ruby/3.3.0/bin` on PATH ahead of `/opt/homebrew/bin` |
| `gem install` / `bundle install` hangs for >30s per request | Ruby's net/http tries IPv6 to rubygems.org and times out on this network | `sudo networksetup -setv6off Wi-Fi` (per-interface, persistent until reboot) |
| `Unresolved conflict between options: 'api_key_path' and 'api_key'` | `app_store_connect_api_key` action sets `APP_STORE_CONNECT_API_KEY_PATH` env var (path to `.p8`), match's same-named env var expects a JSON descriptor | Build the API key hash inline in the Fastfile (don't call `app_store_connect_api_key`); read `.p8` from a non-conflicting var like `ASC_KEY_PATH` |
| `[match] Could not find the newly generated certificate installed` when running match interactively on macOS 26+ | [fastlane#15185](https://github.com/fastlane/fastlane/issues/15185) — the new-cert verification step trips on partition list and keychain trust | Run cert generation **in CI** via the bootstrap procedure in the `ci-cd-publishing` skill (uses `setup_ci`'s ephemeral keychain). Don't run `fastlane match appstore` interactively. |
| iOS build fails: `No "iOS Development" signing certificate matching team ID` | The Xcode project uses `CODE_SIGN_STYLE=Automatic`; xcodebuild tries to find a Development cert even for Release builds | Override via `xcargs: "CODE_SIGN_STYLE=Manual CODE_SIGN_IDENTITY='Apple Distribution' PROVISIONING_PROFILE_SPECIFIER='match AppStore <bundle-id>' DEVELOPMENT_TEAM=<team>"` in the Fastfile (already configured) |
| `vite.config.ts: Unexpected token 'c', "concurrent"... is not valid JSON` | GitLab Runner sets `CONFIG_FILE=/Users/alex/.gitlab-runner/config.toml` in the job environment, which collides with vite's `process.env.CONFIG_FILE ?? "./ditto.json"` lookup | Already fixed: use `DITTO_CONFIG_FILE` for the override env var |
| `whatsNew is missing` from `submit_for_review` | `metadata_path: "./metadata"` resolves relative to fastlane's cwd (`ios/`), not its config dir (`ios/fastlane/`); fastlane silently uploads zero locales | Use `metadata_path: "./fastlane/metadata"` (already configured) |
| `appStoreVersions ... is not in valid state` | Apple won't accept submission because the version is past `PREPARE_FOR_SUBMISSION` (already submitted, in review, or shipped) | "Remove from Review" in App Store Connect if `WAITING_FOR_REVIEW`, or cut a new version |
| `An attribute value is not acceptable for the current resource state. - contentRightsDeclaration` | Apple rejects PATCH on locked App-level fields when `submission_information` includes `content_rights_*` | Drop `content_rights_*` from `submission_information` in the Fastfile (already configured) |
## When the Mac dies
1. Get a replacement Mac. Install Xcode from the App Store.
2. Run the **`ci-cd-publishing`** skill's "Initial setup" — but skip the App Store Connect API key step (you already have it). Re-register the runner with the same `macos` tag.
3. Restore signing identity: `cd ditto/ios && fastlane match appstore --readonly` decrypts the existing certs/profiles using `MATCH_PASSWORD`.
4. No reissuance, no revocation, no GitLab variable updates needed. The certificates repo is the source of truth.
description: Merge upstream changes from the Ditto repo (which Agora is a fork of) into Agora's main branch. Load when the user asks to "merge upstream", "pull from Ditto", "sync with Ditto", or otherwise update Agora with new commits from soapbox-pub/ditto.
---
# Merge Upstream from Ditto
Agora is a fork of [Ditto](https://gitlab.com/soapbox-pub/ditto). This skill walks through pulling new commits from upstream Ditto and merging them into Agora's `main` branch, while making philosophy-aware decisions on merge conflicts.
## Philosophy: Agora vs. Ditto
Agora has diverged from Ditto on purpose in several areas. When resolving conflicts, side with Agora's direction unless the upstream change is clearly a generic bug fix or improvement that applies to both. Known divergences:
- **No Blobbi** — Agora has removed Blobbi support. If an upstream change adds or modifies Blobbi-related code, prefer to drop the Blobbi parts rather than reintroduce them.
- **Lightning-only wallet** — Agora uses a Breeze Lightning wallet. **No onchain functionality exists in Agora**, even though Ditto includes it. Reject upstream onchain wallet code; keep onchain-related conflicts resolved to Agora's Lightning-only path.
- **General rule** — if upstream reintroduces a feature Agora deliberately removed, the deliberate removal wins. When in doubt, ask the user before resolving a conflict that touches a known divergence.
Spend a moment scanning the conflict for these themes before mechanically resolving line-by-line.
## Procedure
### Step 1: Ensure the `ditto` remote exists
Check the current remotes:
```bash
git remote -v
```
If `ditto` is not listed (pointing to `https://gitlab.com/soapbox-pub/ditto.git` or the equivalent `git@gitlab.com:soapbox-pub/ditto.git`), add it:
If a `ditto` remote exists but points elsewhere, fix it with `git remote set-url ditto <url>`.
### Step 2: Confirm a clean working tree on `main`
```bash
git status
git branch --show-current
```
The working tree must be clean and the current branch must be `main`. If not, stop and ask the user how to proceed — do not stash or switch branches automatically.
### Step 3: Fetch from Ditto
```bash
git fetch ditto
```
### Step 4: Preview what's incoming
Show the user (or at least review yourself) the commits that will be merged before merging:
```bash
git log --oneline main..ditto/main
```
If the list is empty, Agora is already up to date — stop here and tell the user.
### Step 5: Merge `ditto/main` into `main`
```bash
git merge ditto/main
```
If the merge succeeds without conflicts, proceed to Step 7.
### Step 6: Resolve conflicts (if any)
For each conflicted file:
1. Re-read the Philosophy section above.
2. Inspect the conflict with `git diff` and decide based on Agora's direction, not just textual merge.
3. For Blobbi-related conflicts, drop the Blobbi side.
4. For onchain-wallet conflicts, keep Agora's Lightning-only path.
5. For ambiguous cases that touch a known divergence, **ask the user** before resolving.
6. After resolving each file, `git add <file>`.
When all conflicts are resolved, complete the merge:
```bash
git commit
```
Git will pre-populate a merge commit message listing the conflicted files. Keep that information and add a short note about how non-trivial conflicts were resolved (especially anything touching the divergences above), so the resolution rationale is preserved in history.
### Step 7: Validate the merge
Run the full test script to confirm the merged tree still type-checks, lints, tests, and builds:
```bash
npm run test
```
If anything fails, fix it before declaring the merge done. Failures after an upstream merge are common — a removed Blobbi reference may now be re-imported by new upstream code, or onchain wallet types may leak into Lightning-only code paths. Fix forward in new commits on top of the merge commit; do not amend the merge commit itself.
### Step 8: Report back
Tell the user:
- How many commits were merged (`git rev-list --count main@{1}..main`).
- Which files had conflicts and how each was resolved.
- Whether `npm run test` passed.
- That the merge is **not** pushed — the user decides when to push.
**Do not push to `origin` automatically.** The user will push when they're ready.
description: Implement or populate the root-level NIP-19 router (/:nip19) that handles npub, nprofile, note, nevent, and naddr identifiers. Covers decoding, secure filter construction, and type-specific rendering for profiles, notes, events, and addressable events.
---
# NIP-19 Identifier Routing
NIP-19 defines the bech32-encoded identifiers used throughout Nostr (`npub1...`, `note1...`, `naddr1...`, etc.). This project routes all of them through a single root-level page at `/:nip19`, implemented by `src/pages/NIP19Page.tsx`.
Use this skill when the user wants to populate the `NIP19Page` sections with real views, add a new identifier type, or build links that point into the Nostr routing system.
| `nsec1` | Private key | **Never display or route** — treat as a 404 |
| `nrelay1` | Relay URL | Deprecated |
### `note1` vs `nevent1`
-`note1` carries only an event ID, and is canonically tied to kind:1 text notes.
-`nevent1` can reference **any** kind and can carry relay hints + author pubkey. Prefer `nevent1` for non-kind-1 events or when you want to ship relay hints with a link.
### `npub1` vs `nprofile1`
-`npub1` is just a pubkey.
-`nprofile1` adds relay hints and a petname. Prefer it for shareable profile links where discoverability matters.
## Routing Rules
1.**All NIP-19 identifiers are handled at the URL root**: `/:nip19` in `AppRouter.tsx`. Never nest them under paths like `/note/:id` or `/profile/:npub`.
2.**Invalid, vacant, or unsupported identifiers** (including `nsec1` and `nrelay1`) render the 404 page. The `NIP19Page` boilerplate already handles this.
3.**Addressable event URLs must include the author**. `naddr1` already encodes `pubkey` + `kind` + `identifier`, which is exactly what a secure query filter needs. If you ever design an alternative URL, use the shape `/:npub/:dtag`, never `/:dtag` alone — otherwise anyone can publish a conflicting event with the same `d` tag.
## Decoding and Filtering
Nostr relay filters only accept hex strings. Always decode the NIP-19 identifier before building a filter.
```ts
import{nip19}from'nostr-tools';
constdecoded=nip19.decode(value);// throws on invalid input
Inside each branch, pass the decoded payload (not the raw bech32 string) to a child component. That keeps filter construction colocated with the fetching hook and removes any chance of a re-decode mismatch.
Always encode with the **most specific** identifier you have context for (`nprofile` > `npub`, `nevent` > `note`, `naddr` for addressable). The extra metadata makes links more robust across relays.
## Security Recap
- Decode **before** querying.
- For addressable events, always include `authors: [pubkey]` in the filter — the `d` tag alone is not a trust boundary.
- Treat `nsec1` and any unknown/invalid identifier as 404. Never render, log, or echo a decoded `nsec`.
description: Fetch pre-computed engagement stats (follower count, post count, reply count, reaction count, zap amounts, etc.) for users, events, and addressable events via a NIP-85 Trusted Assertion provider. Provides useNip85UserStats, useNip85EventStats, and useNip85AddrStats hooks backed by a configurable provider pubkey in AppConfig.
---
# NIP-85 Trusted Assertion Stats
[NIP-85](https://github.com/nostr-protocol/nips/blob/master/85.md) defines "Trusted Assertions" — events published by a service provider that carry pre-computed stats (follower counts, reaction counts, zap totals, etc.) for users and events. Clients that would otherwise need to load thousands of events to compute these numbers can instead query a single addressable event from a trusted provider.
This skill adds three hooks — `useNip85UserStats`, `useNip85EventStats`, `useNip85AddrStats` — and a configurable `nip85StatsPubkey` field on `AppConfig` so you can swap providers.
The hooks query one replaceable event at a time (`limit: 1`), filtered by `authors: [statsPubkey]` and `#d`. **Filtering by `authors` is required** — without it, anyone could publish a fake assertion with the same `d` tag and the client would accept it.
Copy `.agents/skills/nip85-stats/files/hooks/useNip85Stats.ts` into `src/hooks/useNip85Stats.ts`. It imports `@nostrify/react`, `@tanstack/react-query`, and `@/hooks/useAppContext`, all already present in the template.
### 2. Add `nip85StatsPubkey` to `AppConfig`
In `src/contexts/AppContext.ts`, add the field to the `AppConfig` interface:
```typescript
exportinterfaceAppConfig{
// ...existing fields...
/** Hex pubkey of the NIP-85 Trusted Assertion provider. Empty = disabled. */
nip85StatsPubkey: string;
}
```
### 3. Update the Zod Schema in `AppProvider.tsx`
In `src/components/AppProvider.tsx`, add the field to `AppConfigSchema`:
- **Graceful degradation:** The hooks return `null` (not an error) when `nip85StatsPubkey` is empty or the provider has no assertion for the subject. Always render defensively — NIP-85 is an optimization, not a source of truth.
- **Short timeouts:** Each query is wrapped in a 2-second `AbortSignal.timeout` so a slow stats relay never blocks the UI.
- **Cached by TanStack Query:** `staleTime` is 30s for event/addr stats and 60s for user stats. Results are keyed on `[kind, subject, statsPubkey]`, so swapping providers invalidates the cache automatically.
- **Missing tags = 0:** A tag absent from the assertion is reported as `0` rather than `undefined`, matching NIP-85's "no data" semantics.
- **Not the source of truth:** For interactive features (did *this* user like *this* post?) you still need to query the underlying reaction/zap/repost events. NIP-85 only provides aggregate counts.
## Extending the Stats
The hooks expose a small subset of the tags defined in NIP-85. To surface more (e.g. `zap_amt_sent`, `rank`, `first_created_at`), extend the return types and pull additional tags via `getIntTag`:
```typescript
exportinterfaceNip85UserStats{
followers: number;
postCount: number;
rank: number;// new
zapAmtReceived: number;// new
}
// inside useNip85UserStats queryFn
return{
followers: getIntTag(tags,'followers'),
postCount: getIntTag(tags,'post_cnt'),
rank: getIntTag(tags,'rank'),
zapAmtReceived: getIntTag(tags,'zap_amt_recd'),
};
```
See the full tag table in [NIP-85](https://github.com/nostr-protocol/nips/blob/master/85.md).
## Exposing a Provider Picker (Optional)
If you want the user to change providers at runtime, add an input bound to `config.nip85StatsPubkey` and call `updateConfig` with a validated 64-char hex value:
```tsx
import{useAppContext}from'@/hooks/useAppContext';
functionStatsProviderInput() {
const{config,updateConfig}=useAppContext();
return(
<input
value={config.nip85StatsPubkey}
onChange={(e)=>{
constv=e.target.value.trim().toLowerCase();
if(v===''||/^[0-9a-f]{64}$/.test(v)){
updateConfig(()=>({nip85StatsPubkey: v}));
}
}}
placeholder="64-char hex pubkey (blank to disable)"
description: Implement Nostr direct messaging features, build chat interfaces, or work with encrypted peer-to-peer communication (NIP-04 and NIP-17).
---
# Direct Messaging on Nostr
This project includes a complete direct messaging system supporting both NIP-04 (legacy) and NIP-17 (modern, more private) encrypted messages with real-time subscriptions, optimistic updates, and a persistent cache-first local storage.
**The DM system is not enabled by default** - follow the setup instructions below to add messaging functionality to your application.
## Setup Instructions
### 1. Add DMProvider to Your App
First, add the `DMProvider` to your app's provider tree in `src/App.tsx`:
The `DMConfig` object supports the following options:
-`enabled` (boolean, default: `false`) - Enable/disable entire DM system. When false, no messages are loaded, stored, or processed.
-`protocolMode` (ProtocolMode, default: `PROTOCOL_MODE.NIP17_ONLY`) - Which protocols to support:
-`PROTOCOL_MODE.NIP04_ONLY` - Legacy encryption only
-`PROTOCOL_MODE.NIP17_ONLY` - Modern private messages (recommended)
-`PROTOCOL_MODE.NIP04_OR_NIP17` - Support both protocols (for backwards compatibility)
**Note**: The DM system uses domain-based IndexedDB naming (`nostr-dm-store-${hostname}`) to prevent conflicts between multiple apps on the same domain.
description: Encrypt and decrypt content for Nostr direct messages, gift wraps, or any feature that needs NIP-44 (or legacy NIP-04) ciphertext, using the logged-in user's signer.
---
# Nostr Encryption and Decryption
The logged-in user exposes a `signer` object that matches the NIP-07 signer interface. The signer handles all cryptographic operations internally — including ECDH, conversation-key derivation, and AEAD — so your code never touches a private key.
**Always use the signer interface for encryption. Never ask the user for their private key, and never derive a shared secret yourself.**
## NIP-44 (preferred)
NIP-44 is the modern, authenticated encryption scheme used for DMs (NIP-17), gift wraps (NIP-59), and most new encrypted payloads.
For gift-wrapped DMs, you'll typically decrypt the outer wrap, then the inner seal, then read the rumor's content. Each decryption uses the *sender* of that specific layer as the peer pubkey.
### Guarding the UI
Always check `user.signer.nip44` (or `nip04`) before calling encryption methods. Remote signers and older browser extensions may not implement every interface, and catching the missing-capability case lets you show a useful message ("Please upgrade your signer") instead of an unhandled promise rejection.
description: Decide whether to reuse an existing NIP or mint a new kind, design tag structures that relays can index, choose what goes in content vs. tags, and document new kinds or extensions in NIP.md. Load when authoring a new schema — not when wiring up rendering for a kind that already exists (use nostr-kind-rendering for that).
---
# Nostr Kinds — Design and Schema
Load this skill when:
- Minting a new event kind for a Ditto feature.
- Extending an existing NIP with new tags.
- Deciding whether an existing NIP covers a use case or whether a custom kind is warranted.
- Documenting a custom kind or extension in `NIP.md`.
**Not this skill** — if an existing NIP/kind covers your use case and you only need to render it in Ditto's UI, use the **`nostr-kind-rendering`** skill instead.
## Choosing Between Existing NIPs and Custom Kinds
1.**Thorough NIP review first.** Browse the NIP index, then read candidate NIPs in detail. The goal is to find the closest existing solution.
2.**Prefer extending existing NIPs** over creating custom kinds, even at the cost of minor schema compromises. Custom kinds fragment the ecosystem.
3.**When an existing NIP is close but not perfect**, use its kind as the base and add domain-specific tags. Document the extension in `NIP.md`.
4.**Only mint a new kind** when no existing NIP covers the core functionality, the data structure is fundamentally different, or the use case requires different storage characteristics (regular vs. replaceable vs. addressable).
5.**If a tool to generate a new kind number is available, you MUST call it.** Never pick an arbitrary number.
6.**Custom kinds MUST include a NIP-31 `alt` tag** with a human-readable description of the event's purpose.
**Example decision:**
```
Need: Equipment marketplace for farmers
Options:
1. NIP-15 (Marketplace) — too structured for peer-to-peer sales
2. NIP-99 (Classifieds) — good fit, extensible with farming tags
3. Custom kind — perfect fit, no interoperability
Decision: NIP-99 + farming-specific tags.
```
## Kind Ranges
An event's kind number determines storage semantics:
- **Regular** (1000 ≤ kind < 10000) — stored permanently by relays. Notes, articles, etc.
- **Replaceable** (10000 ≤ kind < 20000) — only the latest event per `pubkey+kind` is kept. Profile metadata, contact lists, mute lists.
- **Addressable** (30000 ≤ kind < 40000) — identified by `pubkey+kind+d-tag`; only the latest per combo is kept. Long-form content, products, definitions.
Kinds below 1000 are "legacy"; storage is per-kind (e.g. kind 1 is regular, kind 3 is replaceable).
## Tag Design Principles
- **Kind = schema, tags = semantics.** Don't mint a new kind just to represent a different category of the same data.
- **Relays only index single-letter tags.** Use `t` for categories so filters like `'#t': ['electronics']` work at the relay level. Multi-letter tags (`product_type`, etc.) force inefficient client-side filtering.
- **For Ditto-specific niches** (community apps, regional variants), tag events with a `t` value and query on it. Don't do this for generic platforms — it would silo content.
## Content vs. Tags
- **`content`** — large freeform text or existing industry-standard JSON (GeoJSON, FHIR, Tiled maps). Kind 0 is the one exception where structured JSON goes in content.
- **Tags** — queryable metadata, structured data, anything you might filter on.
- **Empty content is fine.** `content: ""` is idiomatic for tag-only events.
- **If you need to filter by a field, it must be a tag** — relays don't index content.
`NIP.md` documents Ditto's custom kinds and any extensions to existing NIPs. Whenever you mint a new kind or change a custom schema, **create or update `NIP.md`** with the tag list, content format, and intended usage. If a kind you add is effectively the same shape as an existing NIP, note the NIP reference rather than duplicating the spec.
Standard NIPs (like NIP-84 Highlights, NIP-23 Articles) do **not** go in `NIP.md` — only Ditto-custom kinds and Ditto-specific extensions.
## After Designing — What's Next?
Once you've settled on a kind number and tag shape, you still need to render it in Ditto's UI. Load the **`nostr-kind-rendering`** skill for the full multi-location registration checklist (feed cards, detail pages, embedded previews, kind-label maps, notifications, feed-toggle registration).
description: Add UI rendering for an event kind Ditto doesn't yet display — feed cards, detail pages, embedded previews, notifications, routes, feed-toggle registration, and the several kind-label maps (KIND_LABELS, KIND_HEADER_MAP, NOTIFICATION_KIND_NOUNS, CommentContext) that must stay in sync. Load when asked to "support / display / render" a NIP or kind number, when a kind renders blank or as "Kind 12345", or when quote embeds of a kind show "This event kind is not supported".
---
# Nostr Kinds — UI Rendering Checklist
Ditto's kind dispatch is **spread across many files** by design — feed cards, detail pages, embedded previews, notifications, and several kind-label maps each have their own rendering requirements. The central `KIND_LABELS` registry covers the easy cases, but most context-specific maps (grammar, icons, verbs) cannot be derived mechanically and must be updated manually.
**Missing any location causes visible bugs**: a kind might render blank in quote posts, show "Kind 12345" as a label, skip its action header, tombstone as "This event kind is not supported" in embeds, or — worst of all — have its content fed through the kind-1 tokenizer and auto-linkify URLs/hashtags that weren't authored by the event creator.
**When in doubt, grep for an existing kind number like `30617` or `9802`** — you'll find every registration point you need to mirror.
## Decision: Feed-toggle + dedicated page, or just rendering?
Before touching code, pick one:
- **Just render it everywhere Nostr content appears** (no feed toggle, no dedicated page). Use when the kind is niche or only reached via direct links / quote embeds. Minimal surface — steps 1–6 below.
- **Add a feed toggle + optional dedicated page.** Use when users should be able to browse events of this kind or opt them in/out of their home feed. Requires the feed registration (step 7) and `AppConfig` triple (step 8).
When the user asks generally to "support" a kind, ask which direction they want if it's not obvious from context.
## Checklist
### 1. Content card component (`src/components/`)
Create `<MyKindCard event={event} />` that renders the event's tags/content appropriately.
- **Never run event content through the kind-1 tokenizer** (`<NoteContent>` / `<TruncatedNoteContent>`) unless the kind's content is actually free-form user prose. Quote-type content (highlights, snippets, citations) contains URLs and hashtags from the *source*, not the event author — tokenizing them is misleading.
- Render plaintext with `whitespace-pre-wrap break-words` inside a `<p>` instead.
- Route any event-sourced URLs (`r` tags, media URLs, source links) through `sanitizeUrl()` from `@/lib/sanitizeUrl` before using them in `href`/`src`.
- Support an `expanded` prop if the card looks different on the detail page than in the feed.
2.**`isTextNote` negation list** (around lines 440–475): add `&& !isMyKind`. Without this, unknown kinds fall through to `UnknownKindContent` (showing only the `alt` tag).
The loading-state title uses `shellTitleForKind()`, which falls through to `KIND_LABELS` — no override needed unless the kind belongs to a group ("Music Details") or needs composite grammar.
### 4. Central kind label (`src/lib/kindLabels.ts`)
Add a **capitalized noun phrase, no articles** to the `KIND_LABELS` map:
```ts
9802: 'Highlight',
```
This is consumed by the detail-page loading title, nsite permission prompt, signer nudge toasts, and addressable-event preview headers. Ignoring this gives "Kind 9802" everywhere it appears.
### 5. Context-specific label and icon maps
Each of these maps exists because the surrounding UI needs a different grammatical form. They are **not derived** from `KIND_LABELS` and must be updated manually.
- **`src/components/CommentContext.tsx`** — `KIND_LABELS` (uses articles: `'a highlight'`, `'an article'`) and `KIND_ICONS` (lucide component reference). Rendered as "Commenting on {label}". Without an entry you get "an unsupported event".
- **`src/pages/NotificationsPage.tsx`** — `NOTIFICATION_KIND_NOUNS` (bare lowercase nouns: `'highlight'`, `'article'`). Rendered as "reacted to your {noun}". Without an entry you get "post" as a fallback.
- **`src/components/NoteCard.tsx`** — `KIND_HEADER_MAP` (already covered in step 2).
The quote-embed dispatcher in `EmbeddedNote` (around lines 65–110) routes kinds to dedicated compact cards. **Without a branch here, non-content kinds fall through to `EmbeddedNoteCard`, which either:**
- Shows only the NIP-31 `alt` tag (if present), or
- Tombstones as "This event kind is not supported", or
- **Feeds the event's `content` through the kind-1 tokenizer** if the kind is mistakenly treated as a content-kind — auto-linkifying URLs and hashtags that weren't authored by the event creator. This is a security/UX bug.
For any kind whose `content` isn't freeform user prose, add an explicit dispatch branch even if it just renders a minimal compact card. Pattern:
Then define the compact card using `EmbeddedCardShell` for the author row + navigation, and render the kind-specific body inside. See `EmbeddedHighlightCard` and `EmbeddedBadgeAwardCard` for reference.
`src/components/EmbeddedNaddr.tsx` works similarly for addressable kinds — add a branch there if your kind is addressable.
Only needed if you decided on "feed-toggle + dedicated page" above. Add an `ExtraKindDef`:
```ts
{
kind: 9802,
id: 'highlights',
showKey: 'showHighlights',
feedKey: 'feedIncludeHighlights',
label: 'Highlights',
description: 'Noteworthy excerpts from articles, posts, and the web (NIP-84)',
route: 'highlights', // omit for feed-only registration
addressable: false,
section: 'social', // feed | media | social | development | whimsy
blurb: 'Longer marketing copy shown in the info modal.',
},
```
Then:
- **Sidebar icon** (`src/lib/sidebarItems.tsx`) — add `{ id: "highlights", label: "Highlights", path: "/highlights", icon: Highlighter }` to `SIDEBAR_ITEMS`, and import the icon at the top. `CONTENT_KIND_ICONS` picks up the icon automatically from the sidebar definition.
- **Route** (`src/AppRouter.tsx`) — add `const highlightsDef = getExtraKindDef("highlights")!;` at the top of the file and a `<Route path="/highlights" element={<KindFeedPage kind={highlightsDef.kind} title={highlightsDef.label} icon={sidebarItemIcon("highlights", "size-5")} />} />` above the catch-all `*` route.
### 8. `AppConfig` triple (required if you added feed/sidebar toggle keys in step 7)
Three files must stay in sync, or the build fails or the setting silently no-ops:
1. **`src/contexts/AppContext.ts`** — add the fields to the `FeedSettings` interface with JSDoc comments.
2. **`src/lib/schemas.ts`** — add the same fields to `FeedSettingsSchema` as `z.boolean().optional()`. `DittoConfigSchema` is derived from `AppConfigSchema` with `.strict()` mode, so any `ditto.json` field missing from Zod is a build error.
3. **`src/App.tsx`** — add the default value in the initial `feedSettings` block.
4. **`src/test/TestApp.tsx`** — mirror the default in test config so component tests work.
Convention: `show*` toggles default to `true` (sidebar entries visible), `feedInclude*` toggles default to `false` for niche content, `true` for core feed content.
### 9. Notification integration (if applicable)
Load this step when the kind represents an interaction with the user's content (reactions, reposts, highlights, awards, etc.) — i.e. when an event author "does something with" another user's content via an `e`/`a`/`p` tag.
**Six files** to update:
1. **`src/hooks/useEncryptedSettings.ts`** — add `highlights?: boolean` (or equivalent) to the `notificationPreferences` object.
2. **`src/lib/notificationKinds.ts`** — add the kind to `ALL_NOTIFICATION_KINDS` and add a `if (p.X !== false) kinds.push(XXXX);` line in `getEnabledNotificationKinds`.
3. **`src/lib/notificationTemplates.ts`** — add a `NOTIFICATION_TEMPLATES` entry with a title and body for nostr-push server-side notifications.
4. **`src/pages/NotificationSettings.tsx`** — extend `NotificationPrefKey` union, add a row to `NOTIFICATION_TYPES` with icon/label/description/kinds.
5. **`src/hooks/useNotifications.ts`** — extend `groupKey` (decide if events of this kind group by referenced event or stand alone), and if it's a "did something to your content" kind, add it to the author-ownership filter so users only get notified for interactions with their own content.
6. **`src/pages/NotificationsPage.tsx`** — add a case to `GroupedNotificationView`'s switch; write `MyKindNotification` + `MyKindNotificationGroup` components modeled on `RepostNotification` / `LikeNotification`.
### 10. Spam guards (`src/lib/feedUtils.ts`)
If the kind has required tags (NIP-spec-mandated references, minimum content, etc.), add a check in `shouldHideFeedEvent` to hide events that don't meet the minimum bar. This pre-filters events before `NoteCard` mounts them, avoiding layout shifts from components that would return `null`.
Example:
```ts
// NIP-84 highlights with no excerpt AND no source reference.
const hasSource = event.tags.some(([n]) => n === 'a' || n === 'e' || n === 'r');
if (!hasContent && !hasSource) return true;
}
```
### 11. `NIP.md` (custom kinds only)
If the kind is a Ditto-custom kind or a Ditto-specific extension of an existing NIP, document it in `NIP.md` — see the **`nostr-kind-design`** skill for the format. Standard NIPs (like NIP-84, NIP-23) do not go in `NIP.md`.
## Validation
After making changes, run `npm run test` — it runs `tsc --noEmit`, `eslint`, `vitest`, and `vite build` in sequence. All must pass. Additions to the `AppConfig` triple in particular frequently break the build if one of the four files is missed.
## Why so many locations?
These are genuinely different UI contexts (feed cards, detail pages, embedded previews, comment-context labels, notifications, sidebar routes) with different rendering requirements and grammar needs. The central `KIND_LABELS` in `src/lib/kindLabels.ts` handles the common "what to call this kind" case, but feed headers, comment-context text, and notification verbs each need their own grammar, and notification integration involves a whole independent subsystem.
## Bugs that signal a missed step
- **"Kind 12345" shown as a label** → step 4 (`KIND_LABELS`).
description: Publish Nostr events with useNostrPublish. Covers the basic publishing pattern, safely mutating replaceable and addressable events (read-modify-write via fetchFreshEvent + prev), published_at preservation, and d-tag collision prevention for new addressable content.
---
# Publishing Nostr Events
Use this skill when a feature needs to publish events — notes, reactions, list updates, profile edits, addressable content, etc. Covers the `useNostrPublish` hook, the correct read-modify-write pattern for replaceable/addressable lists, and d-tag collision prevention.
## The `useNostrPublish` Hook
`useNostrPublish` publishes an event through the app's connection pool and auto-adds a `client` tag. Always guard calls with `useCurrentUser` — publishing requires a signer.
Prefer `mutateAsync` over `mutate` when the caller needs to `await` the published event (e.g. to navigate to the new event's page, or to chain another publish).
## Mutating Replaceable and Addressable Events (CRITICAL)
Replaceable (kind 10000-19999) and addressable (kind 30000-39999) events require a **read-modify-write** cycle: fetch the current event, modify its tags, publish a new version. **Never read from the TanStack Query cache before mutating** — the cache can be stale from another device or a rapid prior operation, and republishing stale data silently drops the user's data.
Use `fetchFreshEvent()` from `src/lib/fetchFreshEvent.ts` inside every mutation, and **always pass the fetched event as `prev`** so `useNostrPublish` can preserve `published_at`:
This applies to all list-type hooks (bookmarks, pins, interests, follow sets, badges, etc.). See `useFollowActions` and `useMuteList` for complete examples.
### The `prev` Property on Event Templates
`useNostrPublish` accepts an optional `prev` property on the event template — the **previous version** of the event being replaced. The hook uses it to manage the `published_at` tag (NIP-24) automatically:
- **First publish (no `prev`)** — `published_at` is set equal to `created_at`.
- **Update (`prev` provided)** — `published_at` is preserved from the old event.
- **Old event lacks `published_at`** — nothing is fabricated.
- **Caller already set `published_at` in tags** — left alone.
**Convention**: name the local variable `prev` at the call site (not `freshEvent` or `latestEvent`) so it reads naturally when passed to `publishEvent`:
`prev` is stripped from the template before signing — it never appears in the published Nostr event.
## D-Tag Collision Prevention for Addressable Events
Addressable events (kind 30000-39999) are identified by `pubkey + kind + d-tag`. Publishing an event with the same d-tag as an existing one **silently replaces** it. This is by design for intentional updates (edit flows), but dangerous when creating *new* content with user-derived d-tags (slugs from titles, user-entered identifiers, etc.).
### When to check for collisions
- **Must check** when the d-tag is derived from user input (slugified titles, user-entered identifiers, etc.).
- **No check needed** when the d-tag is a `crypto.randomUUID()`, a canonical format with an embedded pubkey prefix, or intentionally the same as an existing event (edit/update flows).
### Implementation pattern
Before publishing a **new** addressable event with a user-derived d-tag, query for an existing event with that d-tag. If one exists, block the publish and tell the user to change the identifier.
**Skip the check in edit mode** — when the user explicitly loaded an existing event to update, overwriting is the intended behavior.
Prefer UUID or canonical formats when the d-tag doesn't need to be human-readable. Only use slugified input when the d-tag will appear in URLs or needs to be meaningful to users, and always add a collision check.
description: Query Nostr events efficiently with useNostr + TanStack Query. Covers the standard useQuery pattern, combining related kinds into a single request to avoid rate limiting, and validating events with required tags or strict schemas.
---
# Querying Nostr Events
Use this skill when building a hook that fetches Nostr events. Covers the standard `useNostr` + `useQuery` pattern, efficient query design (combining kinds to avoid relay round-trips), and event validation for kinds with required tags.
## The Standard Pattern
Combine `useNostr` with TanStack Query in a custom hook. Pass the abort signal from `c.signal` into `nostr.query` so cancelled queries free relay resources:
```typescript
import{useNostr}from'@nostrify/react';
import{useQuery}from'@tanstack/react-query';
functionusePosts() {
const{nostr}=useNostr();
returnuseQuery({
queryKey:['posts'],
queryFn: async(c)=>{
constevents=awaitnostr.query(
[{kinds:[1],limit: 20}],
{signal: c.signal},
);
returnevents;
},
});
}
```
Transform events into a domain model inside the `queryFn` if needed — callers should rarely see raw `NostrEvent`s. Multiple calls to `nostr.query()` inside one `queryFn` are fine for compound queries that can't be expressed as a single filter.
## Efficient Query Design
**Always minimize the number of separate round-trips** to relays. Each query consumes relay capacity and may count against rate limits.
**✅ Efficient — single query with multiple kinds:**
1.**Combine kinds** into one filter: `kinds: [1, 6, 16]`.
2.**Use multiple filter objects** in a single `nostr.query()` call when different tag filters are needed simultaneously.
3.**Raise the `limit`** when combining kinds so you still receive enough of each type.
4.**Split by kind in JavaScript**, not by making separate requests.
5.**Respect relay capacity** — heavy parallel queries can trigger rate limits even when each individually would be fine.
## Event Validation
For kinds with required tags or strict schemas (most custom kinds, anything beyond kind 1), filter query results through a validator before returning them. Loose kinds (kind 1 text notes) rarely need validation — all tags are optional and `content` is freeform.
Validation is a correctness layer, not a security layer. For trust-sensitive queries (admin actions, addressable events, moderator approvals), also constrain `authors` — see the `nostr-security` skill.
description: Query or publish to specific Nostr relays or curated relay groups using nostr.relay() and nostr.group(), instead of the default connection pool. Useful for debugging, testing, specialized relays, or geographically-targeted publishing.
---
# Targeted Nostr Relay Connections
By default, the `nostr` object returned from `useNostr` uses the app's connection pool: it reads from one of the configured relays and publishes to all of them. For most features this is exactly what you want.
Use this skill when you need **more granular control** — talking to a single relay, a curated group of relays, or debugging a specific relay's behavior.
| Single-relay debugging or specialized relay access | `nostr.relay(url)` |
## Tips
- **Don't hard-code user-facing relay lists.** If a feature should publish to "the user's write relays", read from `AppContext.config.relayMetadata` (NIP-65) instead of hard-coding URLs.
- **Compose with TanStack Query.** Wrap `relay.query(...)` / `group.query(...)` inside a `useQuery` hook exactly as you would with the default `nostr` object; the caching layer is identical.
- **Handle unreachable relays.** Specific relays can be offline, rate-limited, or slow. Always wrap calls in `try/catch` and respect the abort signal from the query function (`c.signal`).
- **Avoid leaking subscriptions.** When using `.req(...)` for streaming, always close the subscription on unmount (`controller.abort()` or the returned disposer).
description: Threat model and defenses for Ditto as a web Nostr client — why XSS is catastrophic when nsec keys live in localStorage, CSP as defense-in-depth, URL and CSS sanitization for untrusted event data, and author filtering for trust-sensitive queries (admin actions, moderators, addressable events, NIP-72 communities). Load when building trust-boundary features, rendering user-controlled URLs or markup, interpolating event data into CSS, or reviewing the app's security posture.
---
# Nostr Security
## Threat model
**Nostr private keys (`nsec`) are stored in plaintext in `localStorage`.** Any JavaScript running on the origin can read them with `localStorage.getItem('nostr-login')`. A successful XSS = instant, silent, irreversible key theft — no rotation, no revocation, permanent impersonation across every Nostr client the user ever touches. External signers (NIP-07 extension, NIP-46 bunker) don't change this: an XSS can still ask the active signer to sign arbitrary events, drain funds via zaps, or scrape DMs as they decrypt.
**Treat every piece of untrusted data as a script-injection vector** — event tags, `content`, metadata, URL params, relay responses.
## Defense-in-depth
**Content Security Policy.**`index.html` ships a restrictive CSP: `default-src 'none'`, `script-src 'self'` (no inline scripts, no `eval`), `base-uri 'self'`, `connect-src 'self' https: wss:`. The one intentional gap is `style-src 'unsafe-inline'` — required by Tailwind/shadcn — which means **CSS injection is not blocked by CSP; sanitization is on you**. When modifying CSP, only narrow it. Never add `'unsafe-eval'`, `'unsafe-inline'` on `script-src`, `http:`, or wildcard sources.
**Never use `dangerouslySetInnerHTML`, `innerHTML`, `insertAdjacentHTML`, or `document.write`** with event data, URL params, or any other untrusted string. React's JSX auto-escapes interpolated strings — the moment you bypass that, CSP alone won't save you. If you must render HTML from event data, pipe it through a strict allowlist sanitizer (DOMPurify, already installed) at the parse layer.
**Sanitize URLs and CSS values** — see §1 and §2.
## 1. URL sanitization
Any URL from event tags, `content`, metadata fields (`picture`, `banner`, `website`, `nip05`, etc.), or relay hints is untrusted. Threats beyond `javascript:` XSS: `data:` resource exhaustion / phishing, `http://` IP leaks, relative paths triggering same-origin requests, malformed strings crashing downstream parsers.
**Use the shipped helper at `src/lib/sanitizeUrl.ts`:**
```ts
import{sanitizeUrl}from'@/lib/sanitizeUrl';
// Single URL — returns the normalised href, or undefined if not valid https
consturl=sanitizeUrl(getTag(event.tags,'url'));
if(url){
// safe to use in any context
}
// Array of URLs — filter out invalid entries
constlinks=getAllTags(event.tags,'r')
.map(([,v])=>sanitizeUrl(v))
.filter((v):visstring=>!!v);
```
`sanitizeUrl` returns the normalised `href` string only when the URL parses successfully **and** uses the `https:` protocol. All other inputs (malformed URLs, `javascript:`, `data:`, `http:`, relative paths, etc.) return `undefined`.
**Sanitize at the parse layer.** When writing a parser function that extracts URLs from event tags (e.g. `parseThemeDefinition`, `parseBadgeDefinition`), apply `sanitizeUrl()` before returning the parsed data. This way every downstream consumer is automatically protected without needing to remember to sanitize at each usage site.
**When sanitization is NOT required:** URLs matched by a regex that constrains the protocol (e.g. `NoteContent`'s tokenizer matching `https?://...` — the regex *is* the sanitizer), hardcoded/app-generated URLs (relay configs, internal routes), and strings rendered as plain text that never land in an attribute, CSS value, or network request.
## 2. CSS injection
Event data interpolated into CSS (a `<style>` element, `style=""`, or an injected stylesheet) is a CSS injection vector. A `"`, `)`, `}`, or `;` in the value can break out of the string context and inject rules — overlay phishing, hide UI, exfiltrate via `background-image: url()` requests.
- **URLs in `url()`** — use `sanitizeUrl()`. The `URL` constructor percent-encodes `"`, `)`, `\` and rejects non-`https:`. This is already done for theme event background and font URLs in `src/lib/themeEvent.ts`.
- **Non-URL strings** (font-family, animation names) — use `sanitizeCssString()` from `src/lib/fontLoader.ts`, which allowlists Unicode letters/numbers, spaces, hyphens, underscores, apostrophes, and periods:
If you can't justify the exact characters you're allowing, the policy is wrong.
## 3. Author filtering for trust-sensitive queries
Even with perfect XSS defenses, an attacker can publish forged events your UI will trust unless queries constrain `authors`. Relays are dumb pipes — any matching event comes back.
**Filter by `authors` when:**
- Querying admin/moderator/owner events — use a hardcoded trusted-pubkey list (e.g. `ADMIN_PUBKEYS` from `src/lib/admins`).
- Querying addressable events (kinds 30000–39999) — the `d` tag alone is not a trust boundary; the `(kind, pubkey, d)` triple is.
@@ -87,6 +87,8 @@ Prepend a new section to `CHANGELOG.md` directly below the `# Changelog` heading
```markdown
## [X.Y.Z] - YYYY-MM-DD
A short single-paragraph summary of this release written in plain prose -- max 500 characters. This appears on the App Store, Google Play, and the in-app "what's new" toast.
### Added
- Description of new features
@@ -100,7 +102,100 @@ Prepend a new section to `CHANGELOG.md` directly below the `# Changelog` heading
- Description of removed features
```
**Rules:**
#### The Summary Paragraph
Every release section MUST start with a single plaintext paragraph (not a bullet, not a heading) that summarises the release for app-store-style audiences:
- **Single paragraph, plain prose.** No bullets, no headings, no Markdown formatting beyond plain text.
- **Max ~500 characters.** Apple App Store and Google Play both cap "What's new" text at 500. The CI `release-notes` job warns when the summary is longer.
- **Audience: end users discovering the update.** Describe the most noticeable user-visible changes; omit internal cleanups even if they're in the bullets below.
- **Tone matches the bullets.** Present-tense, no Nostr jargon, no NIP/kind numbers (see Rules below).
- **Maintenance releases** -- write a one-sentence summary like `A behind-the-scenes maintenance release with no user-facing changes.` Don't leave it blank; the CI fallback `Ditto vX.Y.Z` is a last resort for legacy entries, not new ones.
The same paragraph is used in three places automatically:
- **App Store** -- "What's New in This Version" via fastlane `deliver`
- **Google Play** -- "What's new in this version" via fastlane `supply``metadata/android/<lang>/changelogs/<versionCode>.txt`
- **In-app toast** -- the `What's new in vX.Y.Z` toast that fires when users load a new version (see `src/components/VersionCheck.tsx`)
- The full section (summary + lists) goes into the GitLab Release description.
Extraction is handled by `scripts/extract-release-notes.mjs`; you don't have to write store-specific copy.
#### Changelog Quality Checklist
Before drafting any entries, run through this checklist. It is NOT optional -- skipping steps here is the most common way a release goes out with misleading notes.
##### 5.1. Diff the code, not just the commit log
Commit messages describe intent at the moment of commit; they over- and under-represent the cumulative effect at release time. Before drafting entries, **run a real diff** for each area of substantial change:
Only the diff reveals intra-release churn (commits that cancel each other out, bugs introduced and then fixed, refactors that land and get reverted). Reading commit messages alone is insufficient.
##### 5.2. Trace every candidate "Fixed" entry to its origin commit
For each bug fix you're considering listing, find the commit that introduced the bug.
**Fast path -- check for `Regression-of:` trailers** (see AGENTS.md "Attributing Regressions"). If the fix commit declares its origin in a trailer, you don't need to hunt:
```bash
# List all commits in the release window with their Regression-of trailers (if any)
**If the introducing commit is also in this release window (i.e. after the previous tag), the bug is intra-release.** The user on the previous version never experienced it. Do NOT list it as a "Fixed" entry. Fold it into the relevant "Added" or "Changed" entry, or omit it entirely.
##### 5.3. The "Would a user on the previous version notice this?" test
The changelog describes the delta between the previous release and this one **from the user's perspective** -- not the development history. Before writing each entry, ask:
> "Did a user on the previous published version experience this exact thing?"
- If they experienced a broken state that is now fixed: **"Fixed" entry**
- If they experienced the old behavior and now see new behavior: **"Changed" or "Added" entry**
- If they never saw either state (introduced AND resolved within this release window): **omit entirely**
This applies to more than just bugs:
- A feature added and then reverted in the same release: omit both
- A refactor that was done and then undone: omit both
- A performance regression introduced and then fixed: omit both
- A typo introduced in a new string and then corrected: mention the new string (if user-facing) as a single "Added"/"Changed" entry, with no "Fixed" entry
##### 5.4. Worked example -- intra-release bug
> **Scenario:** Commit A overhauls the compose box and, as a side effect, breaks the background of the expanded emoji picker. Commit B, later in the same release window, restores the background.
>
> **Correct changelog:** One "Added" entry describing the compose box overhaul. The emoji picker background is part of the finished state the user receives.
>
> **Incorrect changelog:** An "Added" entry for the overhaul AND a "Fixed" entry for the emoji picker background. The user on the previous version never saw the broken background; listing it invents a problem they didn't have and makes the release notes read like a developer changelog.
#### Rules
- Only include categories that have entries (omit empty categories)
- Write **user-facing descriptions**, not raw commit messages
- Keep descriptions concise -- one line per change
@@ -109,9 +204,9 @@ Prepend a new section to `CHANGELOG.md` directly below the `# Changelog` heading
- Focus on what the user sees/experiences, not internal implementation details
- Use the current date in YYYY-MM-DD format
- **Never use Nostr protocol jargon.** NIP numbers (e.g., "NIP-89", "NIP-17"), kind numbers (e.g., "kind 30078"), and other protocol-level references must not appear in the changelog. Describe the feature in plain language from the user's perspective. For example, write "App cards for Nostr apps" instead of "App cards for Nostr apps (NIP-89)". The changelog audience is end users, not protocol developers.
- **Collapse related work into one entry.** If a feature was added and then fixed/tweaked across multiple commits in the same release, present the finished result as a single "Added" entry. Never list something as "Added" and then also list fixes for that same thing -- the user sees the end product, not the development history.
- **Only ship what the user sees.** If a bug was introduced AND fixed within this release, the user never saw it -- omit the fix entirely (or fold the net result into the relevant Added/Changed entry). The same applies to features that were added and reverted, refactors that cancel out, and any other intra-release churn. See the Changelog Quality Checklist above (especially 5.2 and 5.3) for the procedure to verify this.
- **Collapse related work into one entry.** If a feature was added and then tweaked across multiple commits in the same release, present the finished result as a single "Added" entry. Never list something as "Added" and then also list fixes for that same thing -- the user sees the end product, not the development history.
- **Omit purely internal changes.** CI fixes, build pipeline tweaks, developer tooling, and infrastructure changes should be omitted from the changelog entirely unless they have a direct, visible impact on the user experience. The changelog is for users, not developers.
- **Compare the actual code between versions** to understand what really changed, rather than just reading commit messages. Commit messages may over- or under-represent the significance of changes.
### Step 6: Update Version in All Files
@@ -190,8 +285,12 @@ git push origin main vX.Y.Z
This triggers the GitLab CI pipeline which will:
1. Build a signed Android APK and AAB
2.Create a GitLab Release with download links
3.Publish the APK to Zapstore
2.Build a signed iOS IPA on the self-hosted Mac runner
3.Extract release notes (full body + summary paragraph) from `CHANGELOG.md`
4. Create a GitLab Release with APK / AAB / IPA download links
5. Publish the APK to Zapstore
6. Publish the AAB to Google Play (production track) with the summary as the "What's new" text
7. Submit the iOS IPA to App Store Connect for review with the summary as the "What's New" text
### Step 12: Confirm
@@ -212,11 +311,15 @@ After pushing, inform the user:
## CI Pipeline
The CI pipeline (`.gitlab-ci.yml`) is triggered by tags matching the pattern `/^v\d+\.\d+\.\d+$/` (e.g., `v2.1.0`). It runs three jobs:
The CI pipeline (`.gitlab-ci.yml`) is triggered by tags matching the pattern `/^v\d+\.\d+\.\d+$/` (e.g., `v2.1.0`). It runs seven jobs:
1.**build-apk**: Builds signed Android APK and AAB, stamps `versionName` and `versionCode` into the build
2.**release**: Creates a GitLab Release with the changelog content and download links
3.**publish-zapstore**: Publishes the APK to Zapstore
2.**build-ipa**: Builds the signed App Store IPA on the self-hosted Mac runner (`tags: [macos]`); stamps `MARKETING_VERSION` and `CFBundleVersion` into the Xcode project. The IPA is uploaded to GitLab's Generic Packages registry and exposed as a CI artifact for downstream jobs
3.**release-notes**: Extracts the version's changelog section and summary paragraph from `CHANGELOG.md` into two artifacts (`release-notes.md` and `release-notes-summary.txt`) consumed by `release`, `publish-app-store`, and `publish-google-play`
4.**release**: Creates a GitLab Release with the full changelog section and APK / AAB / IPA download links
5.**publish-zapstore**: Publishes the APK to Zapstore
6.**publish-google-play**: Uploads the AAB to Google Play production track and writes the release summary to `metadata/android/en-US/changelogs/<versionCode>.txt`
7.**publish-app-store**: Submits the prebuilt IPA to App Store Connect for review with the release summary as the "What's New" text. Runs on the self-hosted Mac runner (`tags: [macos]`) because `fastlane deliver` shells out to Apple's iTMSTransporter to upload the IPA, and that tool only ships inside Xcode — the previous Linux runner crashed at the upload step with `No such file or directory @ dir_chdir0` because `Helper.itms_path` resolved to a missing Xcode path. The build appears in App Store Connect within ~30 minutes; Apple's human review then takes 24-48 hours typically. Once approved, you must release manually in App Store Connect (`automatic_release: false`) — this is the final human gate. For runner operations, match cert rotation, and debugging, load the **`mac-runner`** skill.
description: Write Vitest unit tests for React components and hooks using the project's `TestApp` wrapper, jsdom environment, and pre-mocked browser APIs (localStorage, matchMedia, scrollTo, IntersectionObserver, ResizeObserver). Also covers the project policy on when to create new test files.
---
# Testing
Load this skill when the user asks you to write a test, diagnose a bug with a test, or add coverage for a component/hook. Running the existing test script is a standing requirement (see `AGENTS.md` → *Validating Your Changes*) and doesn't require this skill.
## Policy: when to create new test files
**Do not create new test files unless one of these applies:**
1. The user explicitly asks for tests.
2. The user describes a specific bug and asks for tests to diagnose it.
3. The user says a problem persists after you tried to fix it.
Never write tests because tool results show failures, because you think tests would be helpful, or because you added a new feature. The request must come from the user.
If none of the above apply, stop — don't create a test file. Keep running the existing test script as usual.
## Test setup
The project uses **Vitest + jsdom** with **React Testing Library** and **jest-dom** matchers. Global setup lives in `src/test/setup.ts` and mocks these browser APIs that jsdom doesn't provide (or that Node's built-ins conflict with):
-`localStorage` — a Map-backed mock, because Node 22's built-in `localStorage` lacks the Web Storage API surface jsdom expects
-`window.matchMedia`
-`window.scrollTo`
-`IntersectionObserver`
-`ResizeObserver`
If your component needs another browser API, extend `src/test/setup.ts` rather than mocking per-file.
## Writing a component test
Wrap rendered components in `TestApp` (`src/test/TestApp.tsx`) so all context providers — `UnheadProvider`, `AppProvider`, `QueryClientProvider`, `NostrLoginProvider`, `NostrProvider`, `BrowserRouter`, etc. — are available. Without it, hooks like `useQuery`, `useNostr`, `useAppContext`, or `useNavigate` will throw.
Files placed next to the code under test with the `.test.ts` / `.test.tsx` suffix are picked up automatically. For reference, see `src/test/ErrorBoundary.test.tsx`.
## Running tests
The `npm test` script runs `tsc --noEmit`, `eslint`, `vitest run`, and `vite build` in sequence. Always run it after changes — a passing test file alone doesn't mean your task is done.
description: Customize Ditto's visual design — install Google Fonts via @fontsource, change the color scheme, configure light/dark themes, and apply consistent component styling patterns with Tailwind and CSS variables.
---
# Theming, Fonts, and Color Schemes
Use this skill when the user wants to change fonts, colors, light/dark appearance, or general visual styling. Ditto ships with a light/dark theme system built on CSS custom properties and Tailwind v3, plus a `useTheme` hook for runtime switching.
## Adding Fonts
Any Google Font can be installed via the `@fontsource` / `@fontsource-variable` packages.
1.**Install the font package.** Prefer the variable version when available.
```bash
npm install @fontsource-variable/inter
```
Package naming:
- `@fontsource-variable/<font-name>` — variable fonts (preferred; one file, all weights)
- `@fontsource/<font-name>` — static fonts
2. **Import the font once** in `src/main.tsx`:
```ts
import '@fontsource-variable/inter';
```
3. **Register the family** in `tailwind.config.ts`:
For expressive hierarchies, pair a sans body font with a display/serif heading font (e.g. Inter + Playfair Display) and expose the second family as `fontFamily.serif` or `fontFamily.display` in Tailwind.
### Runtime font loading from Nostr events
Ditto also supports loading fonts referenced from Nostr events (theme events, letter stationery, etc.) through `src/lib/fontLoader.ts`. That path is separate from the build-time `@fontsource` approach — it constructs `@font-face` rules at runtime from sanitized URLs. Never feed event data through the `@fontsource` path; always go through `fontLoader` so the URL and family name are passed through `sanitizeUrl()` and `sanitizeCssString()` (see the `nostr-security` skill).
## Color Schemes
Colors are defined as CSS custom properties in `src/index.css` under two selectors:
- `:root` — light-mode values
- `.dark` — dark-mode overrides
When the user requests a new color scheme:
1. **Update both `:root` and `.dark`** in `src/index.css`. Each variable is an HSL triplet (no `hsl()` wrapper), e.g. `--primary: 222 47% 11%;`.
2. **Keep contrast ratios ≥ 4.5:1** for body text and interactive elements. Test both modes.
3. **Prefer extending Tailwind's palette** (`tailwind.config.ts`) over hard-coding hex values in components — this keeps the theme consistent and dark-mode-friendly.
4. **Apply colors through semantic tokens** (`bg-primary`, `text-muted-foreground`, `border-input`) rather than raw palette names when possible, so future theme changes propagate.
The shadcn/ui components consume these semantic tokens, so changing the variables automatically restyles the entire component library.
## Light/Dark Theme Switching
Ditto includes:
- **`useTheme` hook** (`src/hooks/useTheme.ts`) — read and set the current theme programmatically.
- **CSS custom properties** in `src/index.css` — one set in `:root`, dark overrides in `.dark`.
- **Automatic persistence** via the `AppContext` config (`config.theme`), saved to local storage.
- **Class merging:** use the `cn()` utility (`@/lib/utils`) to combine conditional classes and override defaults without class-order bugs.
- **Variants:** follow shadcn/ui's `class-variance-authority` pattern for component variants (`variant`, `size`). Copy an existing `ui/` component as a template.
- **Responsive design:** lean on Tailwind breakpoints (`sm:`, `md:`, `lg:`) rather than JS media queries. Use `useIsMobile` only when layout must change based on JS-measured viewport.
- **Interactive states:** always define `hover:`, `focus-visible:`, and `disabled:` states for clickable elements. Focus rings should use `ring-ring` / `ring-offset-background` so they pick up theme colors.
- **Spacing:** an 8px grid (Tailwind's default 4-based scale) keeps visual rhythm consistent. Common paddings: `p-4`, `p-6`; gaps: `gap-2`, `gap-4`.
- **Depth:** soft shadows (`shadow-sm`, `shadow-md`), subtle gradients, and `rounded-lg` / `rounded-xl` corners match Ditto's aesthetic. Avoid heavy drop shadows.
### Negative z-index gotcha
When placing decorative elements behind content with `-z-10` (e.g. blurred background gradients), **add `isolate` to the parent container**. Without `isolate`, the negative z-index escapes the local stacking context and the element disappears behind the page's background color.
Thanks for contributing to Ditto! Please read [CONTRIBUTING.md](CONTRIBUTING.md) in full before submitting -- it covers everything you need to get your MR accepted.
Thanks for contributing to Agora! Please read [CONTRIBUTING.md](CONTRIBUTING.md) in full before submitting -- it covers everything you need to get your MR accepted.
## Related Issue
@@ -29,9 +29,9 @@ Closes #
## Philosophy Alignment
<!-- Answer this question for your change: -->
<!-- "Does this make Ditto more magnetic, more threatening to the status quo, -->
<!-- "Does this make Agora more magnetic, more threatening to the status quo, -->
- Emoji and mention autocomplete dropdowns no longer get clipped by the compose box
- Emoji shortcodes now render as color emoji instead of plain text glyphs
- Dialogs and input fields on Android are no longer obscured by the virtual keyboard
- Signing requests on Android are more reliable and no longer silently fail after switching apps
## [2.6.5] - 2026-04-11
The Venezuela earthquake relief appeal now rallies behind every campaign on the ground, not just one. The home banner and relief page showcase a live, swipeable carousel of all Venezuelan relief efforts, with a running total of everything raised so far — so you can pick exactly who to help.
### Changed
- Apps and games load significantly faster on Android with smarter prefetching and server affinity
- Native loading spinners replace HTML-based ones on iOS and Android for a smoother experience
### Fixed
- External API requests on Android no longer fail due to hostname restrictions
- iOS App Store compliance issues resolved
- The Venezuela relief appeal now showcases every Venezuelan relief campaign in a live, auto-scrolling carousel you can swipe through, with a combined raised total across all of them, instead of featuring a single campaign.
## [2.6.4] - 2026-04-11
## [2.9.0] - 2026-06-25
A big one. Private messaging arrives with a fast, searchable inbox you can reply to in your own language. Campaign organizers can now get verified by trusted reviewers through a guided sign-up, and verified badges appear right on campaigns. There's a new Venezuela earthquake relief appeal that takes you straight to donations, plus optional Tor routing on Android, separate Public and Private wallets, faster donation scanning, and refreshed profiles, settings, and login.
### Added
- iCloud Keychain integration on iOS -- your login credentials are now saved and restored automatically across devices
- Private direct messages: a dedicated inbox you can search by name, start new chats with inline recipient lookup, page back through old conversations, and read incoming messages translated into your language.
- Get verified: a guided sign-up for trusted reviewers to set up a verifier profile, publish a public "how we verify" statement, and vouch for campaigns. Verified badges now show on campaign pages, and a short tutorial walks you through it.
- A Venezuela earthquake relief appeal — a home-page banner, a one-time popup, and a shareable page that bakes in the relief campaign so you can read its story and donate without leaving.
- Optional Tor routing on Android for added privacy.
- Separate Public and Private wallets, keeping your spending and your private silent-payment funds cleanly apart.
- A "Don't have Bitcoin?" prompt on campaign pages that points first-time donors to Cash App.
- An always-visible language switcher in the top navigation, and a corporate sponsorship page.
### Changed
- Empty feeds show a friendlier state with a discover button to help you find people to follow
-Signup flow simplified -- cleaner profile step with a single Continue button
-Redesigned profiles with cleaner stats, a merged campaigns view, and a themed raised total.
- Redesigned Settings into an Apple-style grouped layout.
- Reworked the login and onboarding flow, including a new welcome screen built around the Agora brand.
- Silent-payment donations now scan faster and keep working in the background, with a progress bar on the private wallet.
- The home page now shows every featured campaign instead of capping the list.
### Fixed
- Avatar fallback now shows the user's initial instead of a question mark
- Android 16+ devices no longer have content hidden behind system bars
- Signup dialog background clears properly when switching between light and dark themes
- Sticky compose button stays anchored to the bottom even on empty feeds
## [2.6.3] - 2026-04-10
- The audio, music, and podcast pages no longer crash.
- Backfilled and corrected translations across all sixteen languages.
## [2.8.9] - 2026-06-02
Adds an in-app prompt to grab the Android app from Zapstore, makes it easier to start or explore campaigns right from the home page, and irons out a batch of language and display fixes.
### Added
- Lightning invoices embedded in posts now render as tappable payment cards
-Blobbi companions in the feed reflect their current condition and projected health
-A prompt to download the Android app from Zapstore, shown to mobile web visitors on the home page, in the account menu, and in the slide-out menu.
- A "Start a campaign" button alongside "Browse all" in the middle of the home page.
### Changed
- Profile headers are cleaner -- lightning addresses and verification badges moved out of the way, and website URLs no longer show a trailing slash
-Login credentials are saved to your browser's built-in password manager for easier sign-in across sessions
- "Request to Vanish" renamed to "Delete Account" for clarity
-The "Explore campaigns" button now appears for everyone, not just logged-out visitors.
### Fixed
- Badge image uploads now show a recommended 1:1 aspect ratio hint so your badges don't get cropped unexpectedly
- Security hardening for URLs and styles sourced from the network
## [2.6.2] - 2026-04-08
- Switching languages now takes effect immediately instead of showing stale text.
- The reply box and the replies heading on a post now show up in your chosen language.
- Account balances keep their Latin numerals regardless of display language.
- Filled in missing translations on the "Why Agora" screen.
### Added
- Share follow packs and follow sets via link -- recipients see an immersive preview with member avatars, a "Follow All" button, and a combined feed from everyone in the pack
- Curated home feed with a mix of photos, short videos, livestreams, and music -- content types are spaced out so your timeline stays fresh and varied
- "Write a letter" option on profile menus for a more personal way to reach out
- Push vs persistent notification delivery option on Android
## [2.8.8] - 2026-06-02
Fixes the app icon proportions and updates the loading splash to the Agora bolt.
### Fixed
- App icon no longer appears squashed.
- Loading splash now shows the Agora bolt instead of the old logo.
## [2.8.7] - 2026-06-02
Fixes the top navigation bar rendering behind the status bar on Android.
### Fixed
- Top navigation bar now clears the system status bar on Android.
## [2.8.6] - 2026-06-02
Refreshes the app icon to the orange Agora bolt mark across Android, iOS, and the web.
### Changed
- Webxdc games and apps always open fullscreen for a more immersive experience
- Login credentials are now stored in the device's secure keychain on iOS and Android instead of plain local storage
- Profile fields now appear inline instead of in a separate right sidebar
- Trending hashtags removed from the logged-out homepage for a cleaner first impression
### Fixed
- Webxdc and nsites work natively on iOS and Android without relying on browser sandboxing tricks
- File downloads now save directly to Documents on iOS and Android instead of silently failing
- Mobile search no longer scrolls the page behind it and properly hides the bottom navigation bar
- iOS swipe-back navigation works correctly throughout the app
- Blobbi companions appear reliably on profiles instead of sometimes going missing
- IndexedDB no longer crashes on devices with Lockdown Mode enabled
- Update the app icon to the current Agora bolt on a brand-orange background.
## [2.6.1] - 2026-04-06
## [2.8.5] - 2026-06-02
A maintenance release that fixes the Android build so signed releases publish correctly. No user-facing changes.
## [2.8.4] - 2026-06-02
A maintenance release that fixes the Android build so signed releases publish correctly. No user-facing changes.
## [2.8.3] - 2026-06-02
A maintenance release that fixes the Android build so signed releases publish correctly. No user-facing changes.
## [2.8.2] - 2026-06-02
A maintenance release that fixes the Android build pipeline so signed releases publish correctly. No user-facing changes.
## [2.8.1] - 2026-06-02
Agora becomes a home for putting your money where your heart is. Launch and back fundraising campaigns, rally around organizations with their own events and pledges, and send support straight from a built-in Bitcoin and Lightning wallet. Explore the world through immersive country pages, chat with a new AI agent, and move through a faster, cleaner app with a fresh look throughout.
### Added
- Manage your interest tabs (hashtags and locations) from the settings page
-Edit button on custom profile tabs so you can tweak them without recreating from scratch
-Follow packs and follow sets now show author info and action headers in the feed
-Posts now show whether they were created or updated, so you can tell when something's been edited
-Fundraising campaigns as the new home surface — create, edit, and back campaigns, set goals, add beneficiaries, and follow progress.
-Organizations with their own events, pledges, members, and moderation tools.
-Built-in wallet for sending Bitcoin and Lightning payments, with transaction history and balances shown in USD.
- One-tap support: zap posts, profiles, campaigns, and organizations.
- AI agent chat with a model selector, tool-calling, and slash commands.
- Immersive country pages with anthems, flags, weather, and a country-scoped feed, plus a new Discover square for exploring the world.
- Comments and reactions on campaigns, and donation receipts shown inline.
### Changed
- Webxdc games and apps run in a more secure sandbox with stricter content policies and private subdomains
- Nsite previews now use the same secure sandbox as webxdc apps
- Blobbi items work as instant abilities instead of consumable inventory -- no more fiddly quantity pickers
### Fixed
-Desktop tab bar no longer overflows when you have lots of tabs -- scroll arrows appear automatically
-Mobile compose box no longer randomly collapses or becomes unclickable
- Profile avatar and banner lightbox no longer hides behind the right sidebar
- Infinite scroll on custom profile tab feeds no longer reloads the same content
- Reaction emoji are now visible on each row in the interactions modal
- Missing bottom border on collapsed thread expand button restored
- Refreshed Agora branding, navigation, and app icons throughout.
-Streamlined onboarding with country and people follows.
-Polished campaign, organization, and donation flows end to end.
## [2.6.0] - 2026-04-05
### Removed
- Direct messaging and ephemeral geo chat.
## [1.0.0] - 2026-04-30
### Added
- Follow links and QR codes -- share a link or scannable code that lets anyone follow you with one tap, complete with your themed profile preview and recent posts
- Immersive Blobbi hatching ceremony -- crack your egg through cinematic stages with shaking animations, a burst of light, sparkles, typewriter dialog, and a naming moment
### Changed
- Footer links redesigned as compact icon chips for a cleaner look
- Custom emoji now render crisp at small sizes with pixel-perfect scaling
### Fixed
- Custom themes now apply correctly when logging in on a new device
- Settings and preferences sync reliably across devices
- Mobile sidebar links no longer clip into the safe area
- Blobbi page background overlay now appears properly on custom themes
- Blobbi companion state no longer resets unexpectedly from stale cache data
- Letter compose picker no longer gets hidden behind the top navigation arc
## [2.5.2] - 2026-04-04
### Added
- See who voted on each poll option -- tap the vote count to open a voters list with avatar stacks and per-option filter tabs
- Poll votes now appear as activity cards in feeds and on detail pages
### Fixed
- Threads and replies load more reliably by following relay and author hints when fetching parent events
## [2.5.1] - 2026-04-03
### Fixed
- Lightbox now reliably appears above all content, not just when opened from photo galleries
## [2.5.0] - 2026-04-03
### Added
- Run nsites and web apps directly inside Ditto -- hit the "Run" button on any nsite or app card to preview it in an overlay without leaving your feed
- File uploads in the poll composer -- attach images and media to your polls
- Blobbi posts now appear in the homepage feed
### Changed
- Profile media sidebar fills remaining slots with photos from text posts when there aren't enough dedicated media posts
- App cards now show banner images and improved layout
### Fixed
- Lightbox no longer appears behind the right sidebar
- Compose box corners are properly rounded
- Clicking buttons or links inside a post card no longer accidentally navigates to the post detail page
## [2.4.1] - 2026-04-02
### Added
- Rich cards for Zapstore app releases and assets -- see download links, version info, platform badges, and hashes right in your feed
### Fixed
- First-hatch tour now shows for accounts that were onboarded before the tour existed, so no one misses the hatching moment
## [2.4.0] - 2026-04-02
### Added
- First-hatch tour: a guided experience for hatching your very first Blobbi egg, with progressive crack animations, an inline card flow, and a reveal moment
- Customizable bottom bar: rearrange or hide any item in the navigation bar to make Ditto feel like yours
- Mission surface card in the feed that surfaces your active quests at a glance
### Changed
- Missions redesigned as a quest board with collapsible cards and a lighter aesthetic
- "Edit Profile" mission now completes when you update any profile field, not just wall-specific edits
- Media tab on profiles now shows only photos, videos, and other media -- not plain text posts
- Blobbi onboarding state now syncs to your profile so it follows you across devices
### Fixed
- Notification dot no longer reappears after you've already marked notifications as read
- Dialogs no longer fly up when the mobile keyboard opens
## [2.3.1] - 2026-04-02
### Changed
- Drafts now save instantly to your device and sync to relays in the background, with a cloud sync indicator so you always know the status
### Fixed
- Dialogs stay visible above the keyboard on mobile instead of getting hidden behind it
- Editing an existing article no longer incorrectly warns about a duplicate slug
- Switching between rich text and markdown source mode no longer clears your content
- Fix crash when editing in markdown source mode
## [2.3.0] - 2026-04-02
### Added
- In-app article editor with a rich text toolbar, image uploads, auto-saving drafts, and a "My Articles" tab to manage drafts and published articles
### Fixed
- Custom emoji no longer stretch to fill their container
- Mobile drawer now closes when tapping footer links like Changelog or Privacy
- Logged-out users now default to the global tab on content feeds instead of seeing an empty follows tab
## [2.2.11] - 2026-04-02
### Fixed
- Fix crash caused by the "What's new" toast firing outside the router
## [2.2.10] - 2026-04-02
### Added
- App cards for Nostr apps now display in feeds and detail pages with hero images, icons, and quick-launch buttons
- "What's new" toast appears after an app update with a changelog preview and link to the full changelog
### Changed
- Changelog page redesigned with a hero section for the latest release, collapsible older entries, and category icons inline with each item
### Fixed
- Compose box now fully resets to its collapsed state after posting, including poll options and media trays
## [2.2.9] - 2026-04-01
### Added
- Emoji pack creator and editor with drag-and-drop image upload, auto-generated identifiers, and description field
- Blobbi companions now appear in feeds and post detail pages
### Changed
- Blobbi shop redesigned with a tile layout and instant buy -- no more categories or accessory tabs
- Emoji packs without any valid emojis are now hidden from feeds
- Custom emoji shortcode collisions across packs are automatically resolved with pack-prefixed names
## [2.2.8] - 2026-04-01
### Added
- Full threaded reply trees on post detail pages with collapsible deep branches and "Show X more replies" for sibling threads
- Broadcast button in the Event JSON dialog to re-publish any event to your relays
### Changed
- My Badges tab overhauled with drag-and-drop reordering, a scrollable list, and a showcase-style carousel for pending badges
- Encrypted letter envelopes now show the mailing side first (sender and recipient), then flip to reveal the wax seal
- Blobbi companions are more expressive -- richer status reactions, sleeping visuals, and body effects like dirt and hunger cues
### Fixed
- Notification dot not clearing after marking notifications as read
- Followers/following modal staying open after navigating to a profile
## [2.2.7] - 2026-03-31
### Fixed
- Nushu script in encrypted letters now renders correctly on Android and iOS
## [2.2.6] - 2026-03-31
### Added
- Encrypted letters now appear as interactive 3D envelopes with Nushu script -- flip and open them to reveal the secret writing inside
- Zap receipts and profile metadata events now render in feeds and detail pages
- Remote signer callback page for login flows with Amber, Primal, and other signing apps
### Changed
- Post action buttons extracted into a reusable PostActionBar component
- Badge detail page streamlined with unified tab bar
### Fixed
- Hashtags now support accented and Unicode characters
- Letter compose opens correctly from notifications and the letters page
- Letter font picker loads fonts so each option previews in the correct typeface
- Zap comment positioned inside the right column instead of floating with offset
- Safe-area padding on pinned SubHeaderBar only applies when scrolled to top
## [2.2.5] - 2026-03-30
### Fixed
- Crash when dragging profile tabs to reorder them
## [2.2.4] - 2026-03-30
### Changed
- Profiles now have an emoji reaction button instead of a zap button -- express yourself with any emoji or custom emoji right on someone's profile
- Zap moved to the profile overflow menu so it's still one tap away
### Fixed
- Crash on the notifications page caused by malformed badge award tags
- Deleting a badge now also deletes all awards you issued for it
- Custom emoji reactions missing their image tag no longer render as broken shortcodes
- Deletion requests for addressable events now include both `e` and `a` tags for broader relay compatibility
- Profile reactions no longer collapse into a single grouped notification
- Oversized reaction emoji in comment context headers
## [2.2.3] - 2026-03-30
### Added
- Letters now have an overflow menu, reply button, and a grid layout for browsing
- Independent feed toggles for comments and generic reposts in content settings
- Sidebar items are now visible to logged-out users so newcomers can explore everything
### Changed
- Compose textarea expands smoothly as you type instead of snapping to a new height
- Blobbi stickers auto-shrink near card edges and clip cleanly at rounded boundaries
### Fixed
- Feed gaps when replies are disabled no longer cause missing posts
- Avatar shape no longer flashes on load
- Top bar arc no longer flickers during navigation transitions
- Letter drawing-only sends, sticker drag bounds, and theme event preservation
- Notification rendering for badges and letters
- Duplicate React keys in content settings
- Layout rendering warning when switching views
## [2.2.2] - 2026-03-29
### Added
- Dedicated photo upload flow for sharing photos
- Pull-to-refresh on all feed pages
- 3D tilt effect on badge images -- hover over badges to see them pop
- Multi-select badge awarding with indicators for already-sent badges
- Badge list recovery dialog for restoring profile badge lists
- Compact badge row preview in embedded profile badges events
- Custom emoji usage tracking so your most-used custom emojis appear in the quick-react bar
- Release notes now included in Zapstore publishing
- Changelog link in the app footer
### Changed
- "Vines" renamed to "Divines" everywhere in the app
- Custom emojis appear first in the emoji picker, right after recent
- Threaded comment view now shows the parent event as a NoteCard with kind action headers
### Fixed
- Delete post dialog no longer freezes the feed on desktop
- Amber login on Android now properly retries when returning from the background
- Key downloads on Android save to the correct location
- Custom emoji SVGs render correctly in the emoji-mart picker
- Double-tap reactions now properly show the emoji on the post
- Emoji shortcode autocomplete text and highlight colors
- Profile skeleton no longer flickers for brand-new users with no metadata
- Event links now route correctly for all event types
- Badge notifications are now clickable
- Custom profile tab form no longer retains fields from a previously edited tab
- Double line under profile tabs in edit mode
- Inconsistent use of "geocache" vs "treasures" terminology
- Search page "N new posts" pill no longer shows unfiltered count
- Stale-cache overwrites in replaceable event mutations
- Click-through on delete confirmation and note menu items
## [2.2.1] - 2026-03-28
### Fixed
- New posts no longer cause scroll jumps -- they buffer while you're reading and appear with a tap
- Mobile header no longer shows double-layered backgrounds on notched devices
- Pinned tabs stay properly positioned when scrolling on mobile
- Signer approval toasts no longer fire in rapid succession on unstable connections
- Toasts are easier to swipe away on mobile
- Content warnings now blur thumbnails in the media grid
## [2.2.0] - 2026-03-28
### Added
- Blobbi virtual pets -- adopt an egg, hatch it, evolve it into one of 16 adult forms, and care for it with feeding, cleaning, medicine, music, and singing
- Blobbi companion that follows you around the app, tracks your cursor, blinks, expresses emotions, and reacts to what you're doing
- Blobbi shop and inventory system with items that affect your pet's stats
- Daily missions with reroll, care streaks, and stage-based rewards
- Immersive full-screen divines experience on both mobile and desktop with floating controls
- Relay information panel on the network settings page
- Link preview cards now display inside quoted posts instead of raw URLs
- Nsec paste guard warns you before accidentally pasting private keys outside the login field
- Remote signer UX improvements for Amber users on Android
- Badge awards now trigger push notifications
- Badges display in profile bio section with a "Give badge" option in the profile menu
### Changed
- Notification "Mentions" tab now shows only pure mentions, filtering out replies
- Notification preferences ("only from people I follow") now properly apply to push notifications, native Android notifications, and the unread dot
- Upgraded from React 18 to React 19
- Reduced initial bundle size by ~50% with improved code splitting and lazy loading
### Fixed
- Zapping Primal users no longer produces an error
- Hashtag feeds now match case-insensitively for parity with search results
- Mobile top bar arc no longer lingers on pages without tabs
- Give Badge dialog and profile menu action handlers
## [2.1.1] - 2026-03-27
### Added
- Emoji picker and shortcode autocomplete in zap comment box
- Zap button on badge detail view
- Theme descriptions now display on "updated their theme" posts and detail pages
- Badge thumbnail previews in award notifications
- Letter notifications with envelope card preview
- Kind-specific labels in notification text instead of generic "post"
### Fixed
- Compose modal no longer closes when dismissing emoji picker on mobile
- Compose preview overflow is now scrollable in modal
- Toast notifications swipe up to dismiss on mobile instead of sideways
- File downloads and URL opening work correctly on iOS
- Badges page no longer shows infinite skeleton when logged out
## [2.1.0] - 2026-03-26
### Added
- Letters -- a Wii Mail-inspired inbox for sending decorated letters to friends, complete with custom stationery, hand-drawn stickers, emoji stickers, fonts, and a send animation with envelope and wax seal
- Attach a color moment or theme to your letter as a gift -- recipients can tap to apply it instantly
- Stationery picker pulls from your color moments, followed users' themes, and built-in presets
- Freehand drawing canvas for creating one-of-a-kind sticker doodles
- Letters page added to the sidebar with a custom mailbox icon
## [2.0.1] - 2026-03-26
### Added
- Tap the version number in settings to see what's new
## [2.0.0] - 2026-03-26
Initial release of Ditto 2.0 -- a complete rewrite of Ditto.
We welcome contributions, but we have high standards. Ditto is a carefully designed product with a specific vision, and every merge request must meet that bar. This guide exists to help you succeed.
We welcome contributions, but we have high standards. Agora is a carefully designed product with a specific vision, and every merge request must meet that bar. This guide exists to help you succeed.
**Required reading before you start:**
- [Ditto Philosophy](https://about.ditto.pub/philosophy) -- the product vision. Your change must align with it.
-[Contributing Guide](https://about.ditto.pub/contributing) -- the upstream contribution process.
- [Understanding Agora](#understanding-agora) -- the product vision. Your change must align with it.
-This `CONTRIBUTING.md` guide -- the contribution process for this repository.
-`AGENTS.md` in this repo -- the codebase conventions. Your AI tool should load this file.
## Understanding Ditto
## Understanding Agora
Ditto is a carnival, not a platform. Before contributing, you need to understand what that means.
Agora is a carnival, not a platform. Before contributing, you need to understand what that means.
### The product decision filter
Every change to Ditto should pass this test:
Every change to Agora should pass this test:
> *Does this make Ditto more magnetic, more threatening to the status quo, and more peaceful to inhabit?*
> *Does this make Agora more magnetic, more threatening to the status quo, and more peaceful to inhabit?*
- **Magnetic** -- Ditto attracts through experience, not ideology. People don't need to understand Nostr to love it. They need to feel something they haven't felt online since the early web. Features should be odd, intriguing, and captivating -- not generic social media clones.
- **Threatening to the status quo** -- Ditto threatens mainstream platforms when someone opens it and thinks: *"Why can't my platform do this?"* Theming, games, treasure hunts, interoperable micro-apps -- these are things walled gardens can't replicate.
- **Peaceful to inhabit** -- Ditto displaces argument with creation, conformity with expression, and consumption with participation. No ads, no engagement-optimized algorithms, no outrage incentives.
- **Magnetic** -- Agora attracts through experience, not ideology. People don't need to understand Nostr to love it. They need to feel something they haven't felt online since the early web. Features should be odd, intriguing, and captivating -- not generic social media clones.
- **Threatening to the status quo** -- Agora threatens mainstream platforms when someone opens it and thinks: *"Why can't my platform do this?"* Theming, games, treasure hunts, interoperable micro-apps -- these are things walled gardens can't replicate.
- **Peaceful to inhabit** -- Agora displaces argument with creation, conformity with expression, and consumption with participation. No ads, no engagement-optimized algorithms, no outrage incentives.
If a change does all three, it belongs. If it only does one, think harder. If it does none, it doesn't belong here.
### What Ditto is NOT
### What Agora is NOT
- A Twitter/X clone with decentralization bolted on
- A place to replicate features that mainstream platforms already do well
- A showcase for generic UI components or boilerplate social features
### What Ditto IS
### What Agora IS
- A convergence point for interoperable Nostr experiences (games, treasure hunts, magic decks, themes, color moments, live streams, and things nobody has imagined yet)
- A place where profiles feel like worlds, not business cards
- The most fun you've had on the internet in years
Read the [full philosophy](https://about.ditto.pub/philosophy) for the complete vision.
Read the full "Understanding Agora" section above for the complete vision.
## What we accept
@@ -44,17 +44,19 @@ Read the [full philosophy](https://about.ditto.pub/philosophy) for the complete
One bug, one merge request. Fix exactly one thing. Don't bundle unrelated changes, don't sneak in refactors, don't "clean up while you're in there." Small, focused MRs get reviewed fast. Large ones sit.
When the bug was introduced by an identifiable prior commit, add a `Regression-of: <short-sha>` trailer to the bottom of your commit message. See AGENTS.md "Attributing Regressions" for the convention.
### New features and significant changes
Every feature MR must link to an existing open issue and clearly align with the [Ditto Philosophy](https://about.ditto.pub/philosophy). The philosophy alignment section in the MR template is where you make the case for why your change belongs in Ditto. If you can't articulate that clearly, the change probably doesn't belong.
Every feature MR must link to an existing open issue and clearly align with the "Understanding Agora" section in this file. The philosophy alignment section in the MR template is where you make the case for why your change belongs in Agora. If you can't articulate that clearly, the change probably doesn't belong.
If you have an idea for a feature that doesn't have an issue yet:
1. Build it as a standalone Nostr app first (see [Contributing Guide](https://about.ditto.pub/contributing)).
1. Build it as a standalone Nostr app first (then document traction/feedback in the linked issue).
2. Prove it works and get user feedback.
3. Open an issue to discuss integration.
**Feature MRs that don't link to an issue or don't align with the Ditto Philosophy will be closed.** Our open issues are our internal roadmap -- some require deep product context. If your implementation doesn't match the product vision, it will be closed regardless of code quality.
**Feature MRs that don't link to an issue or don't align with the Agora Philosophy will be closed.** Our open issues are our internal roadmap -- some require deep product context. If your implementation doesn't match the product vision, it will be closed regardless of code quality.
## Required tools
@@ -80,7 +82,7 @@ Read `AGENTS.md` in the repo root. This is the single source of truth for how co
### 4. Read the philosophy
Read the [Ditto Philosophy](https://about.ditto.pub/philosophy). Ditto is a carnival, not a platform. Your change should feel like it belongs in Ditto -- not like it was transplanted from a generic social media template. Apply the product decision filter above.
Read "Understanding Agora" in this file. Agora is a carnival, not a platform. Your change should feel like it belongs in Agora -- not like it was transplanted from a generic social media template. Apply the product decision filter above.
### 5. Plan before you code
@@ -131,6 +133,7 @@ maintain it long-term. For each finding, state the file, line, and issue.
- [ ] Are there any new images >100KB or other large binary assets that should be hosted externally?
- [ ] Is there any use of dangerouslySetInnerHTML, eval, innerHTML, or SVG string interpolation?
- [ ] Is any data from a Nostr event (tags, content, pubkey, URLs) used in a security-sensitive context (href, src, query filter, trust decision) without validation?
- [ ] If this is a bug fix and the offending commit is identifiable, does the commit message include a `Regression-of: <short-sha>` trailer? (See AGENTS.md "Attributing Regressions".)
Skip anything a linter or type checker would catch. Focus on logic, data flow, and intent.
@@ -163,7 +166,7 @@ Fill out every field in the MR template. Incomplete MRs will not be reviewed.
## What gets your MR closed without review
- No linked issue
- Feature MRs with no clear alignment with the [Ditto Philosophy](https://about.ditto.pub/philosophy)
- Feature MRs with no clear alignment with "Understanding Agora" in this file
- Features that fail the product decision filter (not magnetic, not threatening to the status quo, not peaceful)
- Incomplete MR template (missing checklist, screenshots, or preview URL)
- Changes that go beyond what was asked for (scope creep)
This NIP defines how to share and run [webxdc](https://webxdc.org/) apps over Nostr. Webxdc apps are `.xdc` (ZIP) files containing sandboxed HTML5 applications. They are attached to regular Nostr events using `imeta` tags (NIP-92), and state is coordinated through a unique identifier.
This spec covers public webxdc communication only. Private communication may be addressed in a future update.
## Attachment
A webxdc app is attached to any event by including the `.xdc` file URL in the content and an `imeta` tag with MIME type `application/x-webxdc`.
The `imeta` tag SHOULD include a `webxdc` property with a randomly generated unique string. This serves as the coordination identifier for state updates and realtime channels. If omitted, the app can still run but state won't work.
```json
{
"kind":1,
"content":"Let's play chess! https://blossom.example.com/abc123.xdc",
"tags":[
["imeta",
"url https://blossom.example.com/abc123.xdc",
"m application/x-webxdc",
"x a1b2c3d4e5f6...",
"webxdc 9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d"
]
]
}
```
A webxdc MAY also be published as a kind `1063` (NIP-94) file metadata event:
```json
{
"kind":1063,
"content":"A collaborative chess game. Play with friends over Nostr!",
"tags":[
["url","https://blossom.example.com/abc123.xdc"],
["m","application/x-webxdc"],
["x","a1b2c3d4e5f6..."],
["alt","Webxdc app: Chess"],
["webxdc","9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d"]
]
}
```
## Kind `4932`: State Update
A regular event carrying a state update, mapping to the webxdc [`sendUpdate()`](https://webxdc.org/docs/spec/sendUpdate.html) API. Updates are ordered by `created_at` and assigned serial numbers by the client.
### Tags
-`i`: The `webxdc` identifier from the originating event (required)
An ephemeral event carrying realtime data, mapping to the webxdc [`joinRealtimeChannel`](https://webxdc.org/docs/spec/joinRealtimeChannel.html) API. Relays forward these to active subscribers but do not store them.
### Tags
-`i`: The `webxdc` identifier from the originating event (required)
1. A user uploads a `.xdc` file (e.g. to Blossom) and publishes an event with the URL in content and an `imeta` tag. The `imeta` SHOULD include a `webxdc` property.
2. A client detects the `imeta` tag, downloads the `.xdc`, extracts it, and runs `index.html` in a sandboxed iframe or webview.
3.`sendUpdate()` publishes a kind `4932` event with the `webxdc` identifier in an `i` tag.
4. The client subscribes to kind `4932` events with `#i` matching the identifier and delivers them via `setUpdateListener()`.
5.`joinRealtimeChannel()` subscribes to kind `20932` events with `#i` matching the identifier. `send()` publishes ephemeral kind `20932` events. `leave()` closes the subscription.
6.`selfAddr` and `selfName` MAY map to the user's npub and display name, or any other values.
## Security Considerations
- Webxdc apps MUST be sandboxed with no network access, per the [webxdc spec](https://webxdc.org/docs/spec/messenger.html).
- Clients SHOULD verify the `.xdc` file hash (`x` tag) before running it.
- All communication in this spec is public. Webxdc apps designed for private chats or small groups may not work as expected.
- Webxdc apps have no access to Nostr signatures or identity verification. Any participant can claim to be anyone within the app. Apps should not rely on `selfAddr` or `selfName` for trust decisions.
Eranos is a Nostr client focused on community ownership, expressive identity, and censorship resistance. This repository is the Eranos-branded app, a Grin-only fork of Agora (itself built from the Ditto codebase). All Bitcoin and Lightning payment rails have been removed; Grin payments land in a later phase.
Ditto is an open-source, decentralized social media client built on the Nostr protocol. It's designed for people who want to have fun online without feeding the Big Tech machine. Express yourself with custom themes, Lightning payments, and an ever-growing set of content types -- all while owning your identity and data.
Use Docker Compose when you want the nginx reverse-proxy stack (necessary if you want decryptable media in messages - kind 15s of NIP 17):
```sh
git clone <this-repo>
cd eranos
cp .env.example .env
docker compose up --build
```
Proxy URL: `http://localhost:8083`
This starts:
-`vite` service on the internal Docker network (`vite:8080`)
-`web` service (`nginx`) on host port `8082`, proxying to Vite with websocket support
Stop stack:
```sh
docker compose down
```
Production-style container build:
```sh
docker compose -f docker-compose.prod.yml up --build
```
### Build
@@ -44,66 +74,58 @@ The dev server starts at `http://localhost:8080`.
npm run build
```
The built site is output to`dist/`.
Build output:`dist/`
### Test
Runs type-checking, linting, unit tests, and a production build:
### Validate
```sh
npm test
```
This runs type-checking, linting, unit tests, and production build checks.
## Configuration
Ditto is configured through a `ditto.json` file at the project root, read at build time. This file is gitignored so each deployment can have its own configuration.
Build-time config is read from `eranos.json` (gitignored by default so each deployment can provide its own values).
Configuration is resolved in three layers (highest priority first):
Configuration priority (highest first):
1.**User settings** stored in localStorage
2.**Build config** from `ditto.json`
3.**Hardcoded defaults**
1. User settings (local storage)
2. Build config (`eranos.json`)
3. Hardcoded app defaults
Use an alternate config file path with: `CONFIG_FILE=./my-config.json npm run build`
Use a custom config path:
### Custom Branding
For self-hosted instances:
- Replace `public/logo.svg` and `public/logo.png` with your logo
- Update the app name in `index.html` and `public/manifest.webmanifest`
- Replace `public/og-image.jpg` for social sharing previews
- Set default relays and upload servers in `ditto.json`
```sh
CONFIG_FILE=./my-config.json npm run build
```
## Deployment
Ditto builds to static files and can be deployed anywhere that serves HTML.
Eranos builds to static files and can be deployed to any static host.
-**GitHub Pages / GitLab Pages** -- Push to `main` and CI auto-deploys
-**Netlify / Vercel** -- Connect your fork and deploy. A `_redirects` file is included for SPA routing
-**VPS / Any web server** -- Build and copy `dist/` to your server. Configure SPA routing (e.g., Nginx `try_files $uri $uri/ /index.html`)
- GitLab/GitHub Pages
- Netlify/Vercel
- VPS or any web server with SPA routing fallback
### Android
Build a native Android app with [Capacitor](https://capacitorjs.com/):
For Android:
```sh
npm run build
@@ -114,41 +136,23 @@ npx cap open android
## Tech Stack
| Layer | Technology |
|---|---|
|--- | ---|
| Framework | React 18 |
| Build | Vite |
| Language | TypeScript |
| Styling | TailwindCSS 3 + shadcn/ui |
| Routing | React Router 6 |
| Routing | React Router |
| Data | TanStack Query |
| Nostr | Nostrify + nostr-tools |
| Mobile | Capacitor |
| Testing | Vitest + React Testing Library |
## Project Structure
```
src/
components/ UI components (100+), including shadcn/ui primitives
hooks/ Custom React hooks (65+)
pages/ Page components for each route (30+)
contexts/ React context providers
lib/ Utilities and shared logic
test/ Test setup and helpers
public/ Static assets, icons, manifest
```
## Contributing
We welcome contributions but have high standards. Please read the full [Contributing Guide](CONTRIBUTING.md) before submitting a merge request. The short version:
- **Bug fixes**: One bug, one MR. Keep it small and focused.
- **New features**: Must link to an existing issue and align with the [Ditto Philosophy](https://about.ditto.pub/philosophy).
- **Required**: Live preview URL, before/after screenshots, completed self-review checklist.
- **Required tools**: Claude Opus 4.6 (or latest frontier model), an AI coding agent with plan mode.
Read the [Ditto Philosophy](https://about.ditto.pub/philosophy) to understand what Ditto is and isn't.
Read [CONTRIBUTING.md](CONTRIBUTING.md) before opening a merge request.
## License
[AGPL-3.0](LICENSE)
🤖 Built with AI pair-programming assistance (Claude)
This document describes the two separate but overlapping theme features in Ditto: the **App Theme** (which controls the local UI) and the **Profile Theme** (which is published to Nostr for others to see). Understanding the distinction is key to working with this codebase.
## Overview
| Concept | Purpose | Scope | Persistence |
|---|---|---|---|
| **App Theme** | Controls colors, fonts, and background of the local UI | Local to the user's browser | localStorage + encrypted NIP-78 sync |
| **Profile Theme** | A set of theme values published as a Nostr event | Public, visible to other users | Kind 16767 replaceable event |
The App Theme and Profile Theme share the same underlying data structure (`ThemeConfig`), and there is an optional bridge between them (`autoShareTheme`), but they are fundamentally independent systems.
---
## Part 1: App Theme
The App Theme controls what the user sees in their own browser. It has no inherent connection to Nostr.
### Core Concept: 3 Colors Define Everything
The entire theme is derived from just 3 core colors, defined by the `CoreThemeColors` interface in `src/themes.ts:8`:
```typescript
interfaceCoreThemeColors{
background: string;// HSL string, e.g. "228 20% 10%"
From these 3 values, the system auto-derives 19 CSS tokens (the full `ThemeTokens` set) via `deriveTokensFromCore()` in `src/lib/colorUtils.ts:141`. The derivation algorithm:
- Detects dark/light mode from background luminance (threshold: 0.2)
- Derives `card` and `popover` surfaces by slightly lightening the background (dark mode) or using it directly (light mode)
- Derives `secondary` and `muted` surfaces by adjusting background lightness
- Derives `border` using the primary hue with reduced saturation
- Computes `mutedForeground` as a dimmer version of the text color
- Sets `accent = primary` and `ring = primary`
- Auto-computes `primaryForeground` using WCAG contrast detection (white or dark)
- Uses fixed red values for `destructive` / `destructiveForeground`
### Theme Modes
The `Theme` type (`src/contexts/AppContext.ts:9`) has four values:
| Mode | Behavior |
|---|---|
| `"light"` | Uses the builtin (or configured) light color set |
| `"dark"` | Uses the builtin (or configured) dark color set |
| `"system"` | Resolves to `"light"` or `"dark"` based on `prefers-color-scheme`, with a live media query listener |
| `"custom"` | Uses user-defined colors stored in `config.customTheme` |
**Builtin themes** are defined in `src/themes.ts:102`:
Self-hosters can override these at build time via `ditto.json` (injected through `import.meta.env.DITTO_CONFIG` in `vite.config.ts`), or at runtime via the `ThemesConfig` in `AppConfig.themes`.
### ThemeConfig
The `ThemeConfig` type (`src/themes.ts:50`) wraps the 3 core colors with optional extras:
This is the canonical type used everywhere: in `AppConfig.customTheme`, in encrypted settings, and in Nostr theme events.
### Theme Presets
Named presets are defined in `src/themes.ts:136` (e.g. `pink`, `toxic`, `sunset`). Each preset includes core colors and optionally a font and background image. Applying a preset sets the app theme to `"custom"` and stores the preset's config as `customTheme`.
### How Themes Apply to the DOM
The theme pipeline has three stages designed to prevent any flash of wrong colors:
Components use standard Tailwind classes like `bg-primary`, `text-foreground`, `border-border`, etc. These resolve to `hsl(var(--primary))`, which picks up whichever values are currently set on `:root`.
The `cn()` utility in `src/lib/utils.ts` combines `clsx` (conditional class joining) with `tailwind-merge` (intelligent Tailwind class deduplication).
#### Static CSS
`src/index.css` applies base styles using theme tokens:
```css
*{@applyborder-border;}
body{@applybg-backgroundtext-foreground;}
```
The only static CSS custom property is `--radius: 0.75rem`. All color variables are injected dynamically.
### ScopedTheme
The `ScopedTheme` component (`src/components/ScopedTheme.tsx`) applies a different set of theme colors to a DOM subtree by setting CSS variables as inline `style`:
{/* Children here see different --background, --primary, etc. */}
</ScopedTheme>
```
It also sets `data-theme-mode="dark"` or `"light"` based on background luminance, for CSS targeting.
### App Theme Persistence
#### Layer 1: localStorage (immediate)
The `useLocalStorage` hook (`src/hooks/useLocalStorage.ts`) stores the full `AppConfig` under key `"nostr:app-config"`. This includes `theme`, `customTheme`, `autoShareTheme`, and `themes`. Changes are reflected immediately and support cross-tab sync via `StorageEvent`.
The `useEncryptedSettings` hook (`src/hooks/useEncryptedSettings.ts`) stores theme preferences in a kind 30078 addressable event, encrypted to self via NIP-44. The `EncryptedSettings` interface includes `theme`, `customTheme`, and `autoShareTheme` among other app settings.
Key behaviors:
- Query is delayed 5 seconds after login to avoid competing with feed load
- Uses optimistic updates with a `pendingSettings` ref for rapid successive mutations
- A `recentlyWritten()` guard returns true for 10 seconds after a local write to prevent `NostrSync` from overwriting the value that was just saved
#### Sync via NostrSync
The `NostrSync` component (`src/components/NostrSync.tsx`) runs globally and syncs encrypted settings from Nostr on login. For theme-related fields, it:
1. Seeds a `lastSyncedTimestamp` ref on first load to prevent stale events from overwriting local config
2. Skips application if `recentlyWritten()` is true
3. Only applies changes if the remote timestamp is newer
4. Handles legacy theme value migration (`"black"`, `"pink"` to `"custom"`)
5. Diffs each field individually to avoid unnecessary re-renders
---
## Part 2: Profile Theme
The Profile Theme is a public Nostr event that represents a user's chosen theme. Other clients can read it to style that user's profile page, or users can browse and copy each other's themes.
### Nostr Event Kinds
#### Kind 36767: Theme Definition (addressable, multiple per user)
A shareable, named theme that a user has created. Think of these as "published theme presets." Tags:
| `description` | Optional description | `["description", "A deep blue theme"]` |
Colors are stored as **hex** in `c` tags (converted to/from HSL internally). The `content` field is empty (legacy events may have JSON in content for backward compatibility).
#### Kind 16767: Active Profile Theme (replaceable, one per user)
The user's currently active profile theme. Same tag structure as kind 36767 but without `d` or `description` tags, and with an optional `a` tag referencing the source theme definition:
-`titleToSlug()` - Generate d-tag identifiers from titles
Backward compatibility: if `c` tags are missing, the parser falls back to reading legacy JSON from `content` (handling both the old 19-token format and the 4-color format).
---
## Part 3: The Bridge Between App Theme and Profile Theme
The two systems are connected by the **autoShareTheme** setting and the NostrSync component.
### App Theme -> Profile Theme
When `autoShareTheme` is enabled (default: `true`) and the user applies a custom theme via `applyCustomTheme()`, the `useTheme` hook automatically publishes the custom theme as a kind 16767 active profile theme, debounced by 2 seconds.
```
User picks a custom theme
-> applyCustomTheme() in useTheme.ts:88
-> Updates local config (localStorage)
-> Syncs to encrypted NIP-78 storage (1s debounce)
-> If autoShareTheme: publishes kind 16767 (2s debounce)
```
### Profile Theme -> App Theme
On page load, if `autoShareTheme` is enabled, `NostrSync` (line 174) fetches the user's kind 16767 event and applies it as `customTheme`**without changing the theme mode**. This means:
- If the user is on `theme: "dark"`, their profile theme is stored as `customTheme` but the UI stays in dark mode
- If the user is on `theme: "custom"`, the profile theme's colors are applied to the UI
- This allows the profile theme to stay in sync across devices without forcing the user into custom mode
### Theme Definitions (Kind 36767)
Theme definitions are independent of the app theme. Users can create, publish, edit, and delete named themes. Other users can view them in feeds (via `ThemeUpdateCard`) and copy them. These are purely social objects on the Nostr network.
---
## Font System
Fonts are managed by `src/lib/fontLoader.ts` and `src/lib/fonts.ts`.
### Bundled Fonts
10 fonts are bundled via `@fontsource` packages with lazy loading (dynamic imports):
| Category | Fonts |
|---|---|
| Sans | Inter, DM Sans, Outfit, Montserrat |
| Serif | Lora, Merriweather, Playfair Display |
| Mono | JetBrains Mono |
| Display | Comfortaa |
| Handwriting | Comic Relief |
Each has a `load()` function and a `cdnUrl` for Nostr event publishing.
### Font Application
Three `<style>` elements manage fonts:
| ID | Purpose |
|---|---|
| `theme-font-faces` | `@font-face` rules for remote fonts |
| `tokensToCoreColors` | Extract 3 core colors from a legacy 19-token object |
All colors are stored internally as HSL strings without the `hsl()` wrapper (e.g. `"228 20% 10%"`). The `hsl()` wrapper is added by Tailwind's config (`hsl(var(--background))`).
---
## Validation
Theme data is validated with Zod schemas in `src/lib/schemas.ts`:
-`CoreThemeColorsSchema` - Validates the 3 HSL string fields
-`ThemeConfigSchema` - Full config with optional font/background
-`ThemeConfigCompatSchema` - Accepts both `ThemeConfig` and bare `CoreThemeColors`
-`ThemeColorsCompatSchema` - Union of current 3-color, old 4-color, and legacy 19-token formats
-`AppConfigSchema` - Full app config including all theme fields
-`EncryptedSettingsSchema` - Encrypted settings including theme fields
The `AppProvider` deserializer (`src/components/AppProvider.tsx:32`) validates each top-level field individually with `safeParse`, so a single invalid field doesn't nuke the entire config.
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.