Three small extractions that consolidate hand-rolled copies in the
discovery surfaces. No behavior change.
- getPledgeCoord → src/lib/pledges.ts. Was defined three times
(PledgesDiscoverySection, ActionsPage, ActionShareMenu), each with
the same '36639:<pubkey>:<d>' template. Lifted into the existing
pledges lib and typed structurally on { pubkey, id } so the lib
layer doesn't take a hook dep on Action.
- parseSort + toQuerySort → exported from useDiscoveryFilters and
useAllCampaigns respectively. AllCampaignsPage was carrying its own
copy of both with an apologetic comment ('mirroring the one in
useDiscoveryFilters'); CampaignsDiscoverySection had its own
toQuerySort. One source of truth each now, with the pages and the
section importing from the same module as the hook that consumes
the result.
- PledgeCardSkeleton → exported from PledgeCard. Replaces two
byte-identical ActionSkeleton components in PledgesDiscoverySection
and ActionsPage. Naming matches the existing CampaignCardSkeleton /
CommunityMiniCardSkeleton convention of placing the skeleton next
to its card.
Two related gates on the unified discovery sections:
- PledgesDiscoverySection: the chronological useActions({ limit: 300 })
query only feeds the idle render branch (via idlePledges), but it
was firing in active-search mode too. Active mode renders searchHits
from useNip50Search, which never reads rawActions. On every keystroke
that activates search we were burning a 300-event relay round-trip
whose results went nowhere. Gate the query on !isSearching so the
fetch happens only when the idle branch can actually consume it.
- CampaignsDiscoverySection: align the featured-coords useCampaigns
query's enabled flag with the pledges section's pattern. useCampaigns
already short-circuits on an empty coordinates array, so this is
purely about not creating an empty cache entry when moderators have
curated nothing — but it removes a small asymmetry that would have
made the next reviewer second-guess which pattern is intentional.
The home page now shows the same Campaigns / Groups / Pledges sections
as their dedicated pages (/campaigns/all, /groups, /pledges), with the
same titles, taglines, and search/sort/country toolbars instead of
'Browse all' shortcut links. Each surface's discovery logic lived
in its own page and the home page was about to grow a fourth copy of
it, so the section bodies move into reusable components:
CampaignsDiscoverySection src/components/discovery/
GroupsDiscoverySection
PledgesDiscoverySection
Each owns the section header (title / tagline switch on active
search), the DiscoverySearchToolbar, the idle featured grid, the
active search/sort/country grid, and the per-section empty / no-match
cards. Filter state (search input, sort, country, debouncing) lives
in a new useDiscoveryFilters hook which has two modes:
filterPersistence='url' - flat ?q=&sort=&country= params. Used by
the dedicated pages so search results
are shareable and survive refresh.
filterPersistence='local' - local-only state. Used by / where three
sections coexist and can't all own ?q=.
Refreshing the home lands on the curated
idle view, which matches what we want.
The dedicated pages keep their hero, optional Your-X shelf, and the
moderator-only Hidden collapsible — those stay page-level because
each page wants its own copy. They drive the section's Show-hidden
toolbar switch via a hoisted prop so the page-level Hidden
collapsible can read the same flag.
Side effects:
- ActionShareMenu moves from inside ActionsPage to its own file
so PledgesDiscoverySection can render it on every card without
re-importing the page module.
- useDiscoverCommunities is unchanged but only the dedicated
/groups page calls it now (for the Hidden collapsible /
hidden-count badge). The home page never triggers it.
- browseAllGroups and browseAllPledges locale keys drop from all
16 locales since the launchpad layout that needed them no
longer exists.
The home page used to be the canonical browse view for campaigns: hero
plus featured row, then the full community grid, then moderator-only
Pending/Hidden sections, then a per-viewer 'Your campaigns' shelf.
With /campaigns/all, /groups, and /pledges all now hosting their own
dedicated browse views (featured + search + sort + country in one
unified section), the home page no longer needs to duplicate the
campaigns browse experience.
Rebuild / as a three-section launchpad:
Hero -> unchanged (HeroLightningMap, Bebas Neue tagline, brand CTAs).
Featured campaigns -> capped at 4, links to /campaigns/all.
Featured groups -> capped at 8, links to /groups.
Featured pledges -> capped at 8, links to /pledges.
Each section pulls its featured set from the same moderation labels
that drive the dedicated page, so what surfaces here matches what
surfaces there — just truncated. Sections with no featured items
collapse silently (no empty card) so the page degrades gracefully if
moderators only curate one or two surfaces.
Each section's skeleton respects the dependency chain that gates its
underlying query: campaigns wait on useCampaignModeration, groups on
useOrganizationModeration (because useFeaturedOrganizations is
internally gated on it), pledges on usePledgeModeration. While those
are still resolving the section renders skeleton cards rather than
flashing an empty state.
Drop the unmoderated community grid, the Pending/Hidden moderator
sections, the 'Your campaigns' shelf, and the campaign-search
toolbar from the home page. All of that now lives on /campaigns/all
where viewers actually expect to browse and filter.
Add browseAllGroups and browseAllPledges to campaigns.home in all
16 locales so each section can link out with locale-appropriate copy.
useFeaturedOrganizations is internally gated on moderationReady — while
the organization moderation labels are loading, the underlying query
is disabled and reports isLoading: false / data: undefined. The Groups
page was using only that isLoading flag to decide whether to show the
skeleton, so during the moderation-loading window it rendered the
empty state for a moment before the curated grid popped in.
Track moderation readiness alongside the featured query and treat any
of the three states — moderation not ready, featured query in flight,
featured data not yet defined — as loading.
The Groups page was firing a global kind-34550 query through
useDiscoverCommunities, rendering the full results, then filtering
them client-side for the 'agora' client tag. This produced a brief
flash of unrelated communities before the curated set settled.
Drop the client-side Agora-tag filter entirely and stop using the
all-communities fetch for the idle render path. The unified Groups
section now renders moderator-featured groups directly, gated on
useFeaturedOrganizations's own loading state, so the page goes
skeleton → curated grid with no intermediate render.
useDiscoverCommunities is still called for moderators only — it
feeds the Hidden collapsible section and the hidden-count badge on
the toolbar. Non-moderators no longer trigger the global fetch at
all.
Campaigns, Groups, and Pledges each previously stacked a Featured
shelf above an All-X section. Collapse them into a single section
titled simply 'Campaigns' / 'Groups' / 'Pledges' that:
- Idle (no query, no sort, no country) shows the moderator-featured
grid. If nothing is featured yet, falls back to the chronological
all-X grid so the page is never blank.
- Active (the user typed, picked Top/New, or chose a country) shows
the full result set, ranked or chronological per the toolbar.
The shared toolbar drops the 'default' sort option from its dropdown
(now only Top and New). Clicking an already-active sort returns the
page to the curated idle view, giving users a clear exit affordance
now that 'default' is no longer an explicit menu choice.
Personal shelves (My pledges / My groups / Your campaigns) stay
above the unified section as separate, user-scoped lists.
Anchor the recovery scan's default start height one block before the
earliest known affected transaction (the original $500 send 9fb78657…,
mined in block 951431) instead of a rolling 30-day lookback. No affected
output can predate that block, so this covers every stranded payment
while keeping the scan bounded. defaultFromHeight is now always defined,
so the page prefills the input on mount.
Adds a 'Double-tweak SP Fix' option that rescues silent payments stranded
on-chain by the historical double-tweak bug, where outputs landed at
Q = taproot_tweak(P_k) instead of P_k and were invisible to the normal
scanner.
- recovery.ts: scans indexer tweaks for taproot_tweak(P_k) candidates,
matches them against the block UTXO set, and builds/signs a sweep that
spends them with taprootTweakPrivKey(b_spend + t_k).
- useHdWalletDoubleTweakRecovery: in-memory range scan + match reporting,
no NIP-78 persistence (recovered coins are swept immediately).
- WalletDoubleTweakFixPage at /wallet/double-tweak-fix: scan controls,
recoverable total, and a one-tap sweep into a fresh BIP-86 address.
- Wired into the legacy recovery hub and AppRouter.
English strings only; other locales fall back to English at runtime.
When spending a previously-received silent-payment UTXO, its signing scalar
d_k contributes to the BIP-352 input sum A. The recipient's indexer rebuilds
A by lifting each input's on-chain x-only key to even-Y, so the sender must
contribute the even-Y-normalized scalar (-d_k when d_k·G is odd-Y). SP inputs
were passed with isTaproot:false, which skipped that negation, so for odd-Y
d_k the output landed at a key the recipient never derives and the payment
was invisible.
Pass SP inputs with isTaproot:true so deriveSilentPaymentOutputs applies the
even-Y normalization. Input signing is unaffected (BIP-340 handles parity in
signSpUtxoInput, which derives its own d_k). Add a sender→receiver round-trip
regression test covering an odd-Y SP input.
The BIP-352 sender derived the correct output key P_k but then passed it
through btc.p2tr(), which treats its argument as a Taproot internal key and
applies the BIP-341 TapTweak again. The on-chain output was therefore
taproot_tweak(P_k) instead of P_k, a key the recipient's scanner never
derives — so Agora-built silent payments were unspendable/undetectable by
the recipient.
Write the SP output script as the raw OP_1 push32 <P_k> program via
spP2trScriptPubKey, and fix encodeP2TR to encode the key verbatim (tr
output script) rather than re-tweaking it.
The headline ‘$X.XX pending’ badge reads Blockbook's account-level
`unconfirmedBalance`, which captures mempool credits to *any*
xpub-derived address — including a freshly-advertised receive address
with no prior confirmed history. The Transactions accordion, however,
only attributes a tx to the wallet if its inputs/outputs touch an
address in the `tokens=used` set Blockbook returns, and that set may
omit addresses whose only activity is mempool-only. The result: the
headline updated, but the row never appeared below.
Pre-derive the next 20 receive + 20 change addresses past
`firstUnusedIndex` on each chain and fold them into `ourAddresses`
inside `buildHdTransactions`. Cheap (one HMAC-SHA512 per address) and
adds no network traffic.
Also teach `TxRow` to render pending state explicitly: spinning
`RefreshCw` + orange ‘Pending’ label in place of the date, mirroring
the headline `PendingBadge`. Reuses the existing
`wallet.tx.pending` i18n key so no locale changes are needed.
Bookmark / Add to list / Add to sidebar don't map onto kind 33863
campaigns (addressable, with dedicated UI), so hide them when the
menu is opened from a campaign. Relabel "Mute Conversation" to
"Mute Campaign" in the same context.
resizeImage.ts and ImageCropDialog.getCroppedBlob were doing the same
five-step pipeline (decode -> optional crop -> downscale -> encode ->
File wrap) with mildly different defaults, so quality decisions had to
be made in two places. Adding the PNG-vs-JPEG comparison to crops, or
adjusting JPEG quality across the app, meant editing both.
Consolidates into a single encodeImage(source, options) in
@/lib/resizeImage:
- source can be File | Blob | string (URL); the helper fetches/decodes
- crop is an optional source-pixel rect (full image when omitted)
- maxOutputSize caps the long edge; 0/undefined means no cap
- compareFormats encodes both JPEG and PNG and returns the smaller
- passthroughIfWithinBounds short-circuits the re-encode for files
already within the cap and not being cropped
- returns { file, dimensions } with the correct mime/extension
resizeImage(file) is now a one-line wrapper preserved for existing
callers (ComposeBox, ImageUploadField).
ImageCropDialog.getCroppedBlob is gone; the dialog calls encodeImage
directly and now emits a File (JPEG or PNG, whichever is smaller)
instead of a hardcoded JPEG-only Blob. JPEG quality drops from 0.92 to
the lib default of 0.85 so cropped covers match the rest of the
upload pipeline.
The onCrop contract changes from (Blob) => void to (File) => void.
Updated both consumers (CoverImageField, ProfileSettings) — neither
needed the manual 'new File([blob], ...)' wrapping anymore.
CoverImageField unconditionally capped the crop canvas at 1600px on the
long edge, which was right for the default 'compressed' setting but
silently overrode the user's choice when they had opted into 'original'
via Network Settings. Other upload paths (ComposeBox, ImageUploadField)
already respect this preference; campaign/action banners were the only
holdout.
Now: still crop to the configured aspect (that's a framing decision, not
a quality knob), but only pass maxOutputSize through when imageQuality
is 'compressed'. Users on 'original' get a full-resolution JPEG at q=0.92
from the cropped region, capped only by the natural source dimensions.
CoverImageField now routes every file-input pick and drag-drop through
ImageCropDialog instead of uploading the raw source. The crop is locked
to 3:1 by default (banner aspect, matching the dropzone preview) and
capped at 1600px on the long edge, so a multi-megapixel phone photo
ends up well under a megabyte instead of a multi-megabyte JPEG.
ImageCropDialog gained an optional maxOutputSize prop that downscales
the canvas via the 9-arg drawImage form with high-quality smoothing.
The default is no cap — preserves ProfileSettings behavior for callers
that haven't opted in.
Template clicks and direct URL paste still skip the crop dialog; those
are already-finalized URLs we don't own.
useLoggedInAccounts runs its own kind-0 query with a hard 1.5s relay
timeout. When that comes back empty (slow relay, cold pool), every login
gets metadata: {}, the AccountSwitcher avatar drops through to the
AvatarFallback, and genUserName() returns the literal 'Anonymous' — so a
logged-in user sees an 'A' placeholder instead of their picture, even
though the rest of the app (which uses useAuthor) shows the right
profile.
Layer useAuthor on top of the existing currentUser. useAuthor is seeded
from IndexedDB and shared with every other consumer of the user's
kind-0, so the avatar now picks up cached metadata immediately and stops
showing the 'A' fallback on logged-in sessions.
When a scanned QR or pasted BIP-21 URI carries both an on-chain address
and an sp= silent-payment parameter, the recipient input now surfaces
both as separate rows in a Popover dropdown so the donor explicitly
picks privacy (sp1) vs. compatibility (bc1) — matching how Ditto's send
dialog handles the same ambiguity. Refocusing or clicking the input
while it still contains a URI reopens the dropdown so the choice can be
changed without retyping.
Picking a row swaps the input out for a chip showing the chosen kind,
a truncated address, and an X to return to the input view. Bare bc1
or sp1 input still resolves directly, and single-option scans (URI with
only one valid candidate, bare address, bare sp1) bypass the dropdown
and go straight to the chip.
QR scanning moves into the picker, so the dialog no longer needs its
own scanner dialog or BIP-21 routing logic. The picker only supports
bc1 and sp1 destinations — pasted npub/nprofile is silently ignored
(no account search), matching Agora's narrower scope vs. Ditto.
The campaign donate flow used to pass two props (bc1 + sp1) and the
dialog rendered a swap toggle under the input. With the dropdown now
handling that choice natively, the toggle is gone and the campaign
page just builds a combined bitcoin:bc1?sp=sp1 URI as the prefill.
The NIP-73 external content page recognized bitcoin:tx:<txid> and
bitcoin:address:<addr> identifiers (parsed and titled correctly), but
ExternalContentPage never rendered a body for them — visitors arriving
from a wallet transaction row just saw the 'Bitcoin Transaction'
header with nothing beneath it.
Port BitcoinTxHeader and BitcoinAddressHeader from Ditto: confirmed/
unconfirmed status, block/size/fee/amount stats, mempool.space-style
inputs-to-outputs flow, address balance hero with sats + USD,
recent-transaction list, and a footer link out to mempool.space. The
backing useBitcoinTx and useBitcoinAddress hooks compose Agora's
existing fetchTxDetail / fetchAddressData / fetchAddressTxs helpers
against the configured esploraApis from AppContext, and share the
spot price with useBtcPrice so the page doesn't double-fetch.
The Send dialog used to print 'Available: $X.XX (Y sats)' below the
recipient field once the amount exceeded the balance. That left the
Send button reading 'Send Bitcoin' (disabled) with a separate footnote
the user had to notice and connect to the disabled state.
Move the signal onto the button: it now reads 'Not enough Bitcoin'
when the amount + estimated fee exceeds the available balance, and the
standalone availability line is gone.
The qr-scanner library spins up its ZXing decoder inside a Web Worker
created from a blob URL. Our CSP allowed scripts and connections but
not workers, so the browser silently blocked worker creation — the
camera opened fine (media-src is permissive) but no frame was ever
decoded, leaving the user pointed at a QR code that never registered.
Add 'worker-src self blob:' and 'child-src self blob:' (the latter
covers older browsers that fall back to child-src for worker policy)
to match the directives Ditto already ships.
Regression-of: bae49e61
The receive address advances automatically when funds are detected, so
exposing a manual "next address" affordance is redundant and lets users
needlessly skip ahead in the derivation chain. Drop the RefreshCw button
to the left of the BIP-21 copy row and the now-unused
wallet.receiveDialog.newAddress key across all locales.
The recipient input on /wallet's Send dialog no longer:
- Shows a "Recipient" label above the field.
- Lists "npub…" in its placeholder (now just "bc1…, sp1…").
- Searches Nostr profiles by name as the user types, or renders a
dropdown of matching accounts.
- Shows a search icon inside the input.
Pasted/scanned NIP-19 identifiers (npub1…, nprofile1…) still resolve
to a Bitcoin address via the existing `resolveRecipient` path, and
the resolved profile chip still renders below the input so the
sender can confirm the destination — only the autocomplete UI is
gone.
The walletSend.recipient.label i18n key is removed from every locale.
The useSearchProfiles dependency on this component is dropped; the
hook stays for other callers (mention autocomplete, search page,
etc.).
- Drop the "≈ N sats" line that sat under the dollar amount. The
USD figure is the source of truth in this dialog; the sats
conversion was visual noise.
- Drop the "Network fee" label and move the fee-tier popover under
the Send button, centered. With only one popover in the dialog,
the label was redundant and the row above the Send button was
competing with the recipient input for attention.
- Remove the now-unused walletSend.approxSats and walletSend.networkFee
i18n keys from every locale.
The "Sending to a raw Bitcoin address." / "Sending to a Nostr
user's on-chain address." / "Sending via a silent payment…"
muted-text line below the recipient input is gone. The recipient
chip already shows who's being paid, and the soft amber privacy
disclaimer covers the raw-address case, so the extra status line
was just noise. The three now-unused i18n keys are removed from
every locale.
The disclaimer shown when sending to a bare bc1… address now uses
the existing `soft` amber tone instead of the destructive red one,
and no longer requires ticking an acknowledgement checkbox. The
checkbox-gating made the Send Bitcoin button appear permanently
disabled to users who hadn't noticed (or hadn't scrolled to) the
checkbox.
The `bitcoinPublic` disclaimer component already supported both
tones — only the Send dialog's wiring changes here. The unused
`walletSend.errors.acknowledgePrivacy` string is removed from
every locale.
The recipient input on /wallet's Send dialog now has a camera button
that opens a QR scanner. Bitcoin BIP-21 URIs are parsed and the
silent-payment fallback (?sp=) is preferred when present, falling
back to the on-chain address otherwise. Plain addresses, sp1… codes,
npub, and nprofile values are dropped into the input verbatim and
resolved by the existing recipient logic.
QrScannerDialog is a standalone component (ported from Ditto) that
owns the camera lifecycle via getUserMedia and the qr-scanner npm
package. It surfaces failure modes (insecure context, denied
permission, no camera, busy camera, overconstrained, ready timeout)
instead of a silent black screen, and offers a flash toggle when the
device supports it.
Android needed an explicit CAMERA permission in the manifest; iOS's
existing NSCameraUsageDescription string was extended to mention QR
scanning. No Capacitor camera plugin is required — the standard web
APIs work inside WKWebView and Android's WebView.