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