Compare commits

...

192 Commits

Author SHA1 Message Date
Chad Curtis 476a3856ec release: v2.8.7 2026-06-02 04:34:48 -05:00
Chad Curtis d69cfa0862 fix zapstore info v2 2026-06-02 04:32:59 -05:00
Chad Curtis a5cc9c5163 Break walletHeroNote onto two lines 2026-06-02 04:24:57 -05:00
Chad Curtis 42ac269a56 release: v2.8.6 2026-06-02 04:24:53 -05:00
Chad Curtis caa8e70703 fix zapstore info 2026-06-02 04:19:11 -05:00
Chad Curtis 8f53e3e53b Regenerate app icons as white double-bolt on orange circle
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.
2026-06-02 04:17:23 -05:00
Chad Curtis 5d4d0825c6 Remove "no payout setup" from wallet hero note copy 2026-06-02 04:10:33 -05:00
Chad Curtis b9b7351361 release: v2.8.5 2026-06-02 03:40:31 -05:00
Chad Curtis 6dcae6385a ci: use uniform PKCS12 password for signing keystore
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.
2026-06-02 03:39:54 -05:00
Chad Curtis 13386bf0fd release: v2.8.4 2026-06-02 03:29:34 -05:00
Chad Curtis 2ae2a3da18 android: add R8 keep rules for barcode-scanner Gson references
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.
2026-06-02 03:28:48 -05:00
Chad Curtis 1c06e070cd release: v2.8.3 2026-06-02 03:22:02 -05:00
Chad Curtis f0c3ff1a80 android: raise minSdk to 26 for barcode-scanner plugin
@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.
2026-06-02 03:21:22 -05:00
Chad Curtis 13a0bb3e3a release: v2.8.2 2026-06-02 03:09:16 -05:00
Chad Curtis 646ed9777f ci: pass alias key password to keytool keystore migration
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.
2026-06-02 03:07:04 -05:00
Chad Curtis 437613641a release: v2.8.1 2026-06-02 03:02:12 -05:00
Chad Curtis d0836328a4 qrcode: stop fixed pixel styles overflowing the container
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.
2026-06-02 02:02:58 -05:00
Chad Curtis 123f53e7a6 campaigns: carry public/private framing into custom wallet mode
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.
2026-06-02 01:59:44 -05:00
Chad Curtis 977fd000ea campaigns: make the donation-accept picker friendlier
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.
2026-06-02 01:59:44 -05:00
Chad Curtis 5132141aa2 campaigns: give the wallet step a hero coupling card
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.
2026-06-02 01:59:44 -05:00
Chad Curtis b6dc57eb85 Merge branch 'feat/send-MAX' into 'main'
Feat/send max

See merge request soapbox-pub/agora!40
2026-06-02 06:36:55 +00:00
Chad Curtis 016a7b4a7d Constrain event detail pages to max-w-3xl
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.
2026-06-02 01:30:55 -05:00
mkfain 7ae63883e9 campaigns: surface 'Add to list' in the detail-page three-dots menu 2026-06-02 00:28:08 +02:00
mkfain d4cf4ba0d8 campaigns: collapse curated lists strip after first 5 pills 2026-06-02 00:28:08 +02:00
Chad Curtis 399dc53395 Merge branch 'remove-pin' into 'main'
Remove redundant campaign location pin icons

See merge request soapbox-pub/agora!39
2026-06-01 22:26:29 +00:00
filemon 699e505fb5 Merge remote-tracking branch 'origin/main' into remove-pin
# Conflicts:
#	src/components/CampaignCard.tsx
2026-06-02 00:23:07 +02:00
lemon 20839f4de3 wallet: match silent inputs without prevout index 2026-06-01 15:18:13 -07:00
lemon 4e9da2d168 wallet: prune rediscovered spent silent outputs 2026-06-01 15:18:13 -07:00
lemon 32b477bd01 wallet: refresh history after pruning silent inputs 2026-06-01 15:18:13 -07:00
lemon 564459e12d wallet: disable send button when balance is unspendable 2026-06-01 15:18:13 -07:00
lemon c97d0723a6 wallet: add max send option 2026-06-01 15:18:12 -07:00
filemon 53da626461 Remove redundant campaign location pin icons
Regression-of: ba2c541c
2026-06-02 00:13:35 +02:00
Chad Curtis c79699ca71 campaigns: remove deadline from events and form
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.
2026-06-01 17:03:30 -05:00
Alex Gleason e58c031a85 Merge branch 'main' of gitlab.com:soapbox-pub/agora 2026-06-01 23:17:58 +02:00
Alex Gleason bc80dba826 home: fan out single-relay queries to fix load waterfall
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
2026-06-01 23:16:45 +02:00
Chad Curtis 611f97488e home: drop label-based hidden filter from the WLC hero row
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.
2026-06-01 16:04:22 -05:00
Alex Gleason a948725245 home: stop fetching kind 1985 moderation labels
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.
2026-06-01 22:51:19 +02:00
mkfain dde9865284 campaigns: drop duplicate arrow from browseAll button label
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.
2026-06-01 22:41:06 +02:00
Chad Curtis 3d825aef04 campaigns: hardcode moderators, gate lists on a single curator
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
2026-06-01 15:36:08 -05:00
Chad Curtis 575603554b home: decouple funding-bar skeleton from card, parallelize list queries
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.
2026-06-01 15:36:08 -05:00
Alex Gleason dfb0a52603 Upgrade Nostrify 2026-06-01 22:20:43 +02:00
mkfain 545e6cf4be home: rewrite whyDifferent lede as a concrete manifesto strapline
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.
2026-06-01 22:09:54 +02:00
mkfain eb978d651c home: trim block1 heading to just "Unlike GoFundMe"
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.
2026-06-01 22:03:08 +02:00
mkfain 7a52631eb2 home: redesign whyDifferent as a manifesto-style editorial section
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.
2026-06-01 21:49:46 +02:00
mkfain d48094ff68 home: recolor whyDifferent section to brand orange band
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)
2026-06-01 21:49:46 +02:00
mkfain 247fbefa9b home: add "Why Ágora is different" info section at the bottom
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.
2026-06-01 21:49:46 +02:00
Chad Curtis 4a3c5df519 Don't let an empty persisted translateWorkerUrl hide the Translate button
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.
2026-06-01 14:32:09 -05:00
mkfain 74478ee8ac Remove the 'View the full list' link under the WLC hero row
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.
2026-06-01 21:18:56 +02:00
mkfain da94609855 Replace the Featured campaign concept with the World Liberty Congress list
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.
2026-06-01 21:18:56 +02:00
mkfain 8b90ef90f7 Wrap campaign list pills instead of horizontal scroll
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.
2026-06-01 21:18:07 +02:00
mkfain 49f0ec2765 Remove the moderator-only Hidden section from the home page
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).
2026-06-01 21:18:07 +02:00
mkfain 72c2170139 Replace the home page 'All campaigns' grid with the topic-lists strip
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.
2026-06-01 21:17:44 +02:00
mkfain a0082cbbcd Refetch list membership when the Add-to-List dialog opens
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.
2026-06-01 21:17:01 +02:00
mkfain a8561f46f9 Fix list-membership dialog navigation + hide hidden campaigns from add-to-list search
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.
2026-06-01 21:17:01 +02:00
mkfain 2c248f8269 Add 'Add to list…' row to the campaign moderator kebab
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.
2026-06-01 21:17:01 +02:00
mkfain b8749f7064 Add moderator-curated campaign lists to /campaigns
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.
2026-06-01 21:17:01 +02:00
Chad Curtis f800d55451 Stop clobbering VITE_* CI vars with literal placeholders
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.
2026-06-01 14:15:38 -05:00
Chad Curtis ee8414f694 Make translation worker URL user-configurable via AppConfig
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.
2026-06-01 13:53:52 -05:00
lemon 23ac55af6b Merge campaign profile setup into wizard 2026-06-01 11:28:54 -07:00
lemon 2ef0642f6d Keep wizard chrome above body content 2026-06-01 11:15:18 -07:00
lemon 18aacad290 Require campaign creator profiles 2026-06-01 11:09:47 -07:00
mkfain e82f0146d2 Allow reordering over-cap featured campaigns from All Campaigns
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.
2026-06-01 19:51:51 +02:00
mkfain 973defcd28 All campaigns: deduplicate vs hero row, sort oldest-first
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.
2026-06-01 19:21:05 +02:00
mkfain 5bbd86ea90 Cap WLC Verified row at 6; add chronological 'All campaigns' section
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).
2026-06-01 19:07:19 +02:00
mkfain 65481d1280 Featured: two large hero cards on top, rest in 4-up rows
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.
2026-06-01 18:56:21 +02:00
mkfain 3d4b40188e Show 'WLC' on the featured-card verifier chip
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.
2026-06-01 18:52:34 +02:00
mkfain a7f28e3963 Move check icon after 'Verified' and add WLC chip to featured cards
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.
2026-06-01 18:49:58 +02:00
mkfain d2b6785ca7 Rebrand home Featured row as 'World Liberty Congress Verified'
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.
2026-06-01 18:42:48 +02:00
mkfain 2bab7ebe6e Drop featured pinning from /campaigns and append new features to the bottom of the row
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.
2026-05-31 15:38:31 +02:00
Chad Curtis e6fc7931b6 onboarding: campaign-framed profile heading for new campaign creators 2026-05-30 21:12:19 -05:00
Chad Curtis c845f7286b onboarding: hide bio behind Advanced toggle; show profile step first for new campaign creators 2026-05-30 20:14:56 -05:00
Alex Gleason f5cdbb6f3a Upgrade Nostrify (this fixes everything) 2026-05-31 02:52:50 +02:00
mkfain b0759402cf Retire the approval axis; Featured becomes the sole positive-curation mechanism
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.
2026-05-30 22:48:21 +02:00
mkfain ef9c2eff89 Fix campaign reorders silently reverting when moving downward
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.
2026-05-30 22:22:09 +02:00
mkfain 2cde8fe1f8 Remove the 12-campaign cap on the home-page Featured row
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.
2026-05-30 22:13:46 +02:00
mkfain 9e26bb8209 Let moderators reorder Featured and Community campaign lists
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.
2026-05-30 22:07:53 +02:00
mkfain 7c14115119 Drop the campaign title slug preview hint
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
2026-05-30 21:40:09 +02:00
mkfain 12bc721952 Let campaign titles in any language produce a valid URL slug
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.
2026-05-30 21:32:26 +02:00
mkfain 6c5205cc75 Make insufficient-fee broadcast failures actionable
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.
2026-05-30 20:48:11 +02:00
Alex Gleason 737b197aa8 Merge branch 'main' of gitlab.com:soapbox-pub/agora 2026-05-30 20:13:03 +02:00
Alex Gleason 2cf3db0a51 Convert single-candidate pastes straight into a recipient chip
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.
2026-05-30 20:12:33 +02:00
mkfain c54008cd3d Surface a hint when the recipient picker is closed with no selection
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
2026-05-30 20:04:27 +02:00
Alex Gleason 7f16678acc Order the Bitcoin address row above silent payment in the recipient dropdown
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.
2026-05-30 20:01:12 +02:00
Alex Gleason e77876ed16 Keep the recipient dropdown open when the input loses focus
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.
2026-05-30 19:59:02 +02:00
Alex Gleason 03b68c3a24 Clear the recipient input when the chip is X'd out
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.
2026-05-30 19:53:31 +02:00
Alex Gleason 2ab45a27d5 Preselect the recipient chip for single-endpoint Pay with Agora prefills
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.
2026-05-30 19:42:38 +02:00
Alex Gleason e40f32a54f Always copy a bitcoin: URI on campaign donate panels
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.
2026-05-30 19:36:41 +02:00
mkfain c53e476dee Move the all-campaigns directory from /campaigns/all to /campaigns
/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.
2026-05-30 13:19:29 +02:00
mkfain 3a06dcd4cb Translate the new HRF/WLC category set and the refreshed campaigns tagline
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.
2026-05-30 13:12:54 +02:00
mkfain d7144200fb Replace generic campaign categories with HRF/WLC-aligned set
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.
2026-05-30 13:07:49 +02:00
mkfain b5cb884004 Swap Campaigns and Activity order in the main nav
Campaigns is the primary surface of Agora; lead with it.
2026-05-30 12:49:42 +02:00
mkfain 0800b854ae Restore four-section home page and stop dropping approved campaigns
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.
2026-05-30 12:47:10 +02:00
Alex Gleason 3a98e38f7b Merge branch 'main' of gitlab.com:soapbox-pub/agora 2026-05-30 12:36:31 +02:00
mkfain e198e8d572 Bump home-page Featured cap from 4 to 12 2026-05-30 12:35:51 +02:00
Alex Gleason cf6364a84b Fail over on 404 from always-present Esplora paths
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).
2026-05-30 12:35:48 +02:00
mkfain 34cae4c9ad Stop hiding approved-not-featured campaigns on /campaigns/all
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.
2026-05-30 12:32:40 +02:00
mkfain 0c686a2091 Let anyone unhide hidden campaigns on the Campaigns page
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.
2026-05-30 12:26:58 +02:00
Alex Gleason d07bc64032 Add custom fee rate to wallet Send; stop showing empty fee tiers
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.
2026-05-30 12:15:45 +02:00
mkfain e8acf45656 Hide Groups and Pledges from main nav for launch
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.
2026-05-30 11:34:42 +02:00
mkfain 3c28e2b789 Show every campaign on the home page, with a hidden toggle
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.
2026-05-30 10:46:54 +02:00
Alex Gleason dc43f723fb Trim the eager countries chunk from 244 KB to 47 KB
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.
2026-05-30 02:20:01 +02:00
Alex Gleason c7ed31305d Lazy-load locale bundles to shrink the initial bundle
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).
2026-05-30 02:10:35 +02:00
Alex Gleason 441eea160f Restore the full campaigns content area on the home page
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
2026-05-29 16:41:12 -05:00
Alex Gleason 4f056dfac0 Show only campaigns on the home page, not groups and pledges
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
2026-05-29 16:36:56 -05:00
lemon f16d5ea334 Use wallet price source in dashboard balance card 2026-05-29 14:16:34 -07:00
lemon ef8e6f9564 Use wallet price source in header balance 2026-05-29 14:12:22 -07:00
Lemon 40f3179a63 Merge branch 'style/campaign-wizard' into 'main'
Campaign Wizard

See merge request soapbox-pub/agora!38
2026-05-29 13:52:20 -07:00
lemon 3b35b084fd Translate the wallet step's accept-mode pills, hints, and custom-wallet toggle into every shipping locale 2026-05-29 13:50:06 -07:00
lemon 0ade19c51e Translate the pledge and group wizard strings, plus the campaign categories, into every shipping locale 2026-05-29 13:50:06 -07:00
lemon 81f3c9e755 Show the Skip and Launch shortcut on the pledge wizard's Set Your Pledge step
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.
2026-05-29 13:50:06 -07:00
lemon 657c0e43e3 Align the pledge wizard tags step with campaigns and drop the dedicated deadline step
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.
2026-05-29 13:50:06 -07:00
lemon 5a72cf1fd0 Convert the pledge create flow into the wizard layout
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.
2026-05-29 13:50:06 -07:00
lemon b3163ea2c9 Show the Skip and Launch shortcut on step 1 of the group wizard
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.
2026-05-29 13:50:06 -07:00
lemon 1f545e7361 Add a Skip Next and Launch shortcut to the group create wizard, restore the single-page form for edits
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.
2026-05-29 13:50:06 -07:00
lemon 1b21edef19 Convert the group create / edit flow into the wizard layout
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.
2026-05-29 13:50:06 -07:00
lemon 2ba19fc135 Extract Wizard, CategoryPicker into reusable components
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.
2026-05-29 13:50:06 -07:00
lemon 934495a7d3 Reflow the category picker into auto-wrapping pills
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.
2026-05-29 13:50:06 -07:00
lemon a2a4c8b2a7 Trim the campaign category picker down to a 16-tile, 3-col grid
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.
2026-05-29 13:50:06 -07:00
lemon c560bd8acd Replace the wizard's tag input with a curated category picker
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.
2026-05-29 13:50:06 -07:00
lemon 21907014e0 Translate the campaign wizard step titles into every shipping locale
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.
2026-05-29 13:50:05 -07:00
lemon 0b77980fc7 Merge goal, deadline, country, and tags into one final wizard step
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.
2026-05-29 13:50:05 -07:00
lemon 3bab0ef3e0 Don't let Enter on a non-terminal step silently publish the campaign
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.
2026-05-29 13:39:18 -07:00
lemon cb52920259 Quiet down the wallet step's identity row and accept-mode pills
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.
2026-05-29 13:39:18 -07:00
lemon 6e4eff602a Redesign the wizard's wallet step around the user's wallet card
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.
2026-05-29 13:39:18 -07:00
lemon 337d18951a Split the campaign wizard into six single-purpose steps
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.
2026-05-29 13:39:18 -07:00
lemon 31154f382d Polish the campaign wizard's header and shortcut affordance
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.
2026-05-29 13:39:18 -07:00
lemon 236e6aa211 Render the campaign wizard as a fullscreen captive overlay
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.
2026-05-29 13:39:18 -07:00
lemon 8c684aeef2 Restyle the campaign wizard after the captive onboarding flow
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.
2026-05-29 13:39:18 -07:00
lemon ab59960233 Break the new-campaign form into a four-step wizard
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.
2026-05-29 13:39:18 -07:00
lemon 3565ebf098 Hoist three duplicated discovery helpers to shared modules
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.
2026-05-29 13:39:18 -07:00
lemon 0dcc2f2b93 Skip wasted discovery-section queries
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.
2026-05-29 13:39:18 -07:00
lemon 4cbc9f64c1 Extract three reusable discovery sections shared by home and dedicated pages
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.
2026-05-29 13:39:18 -07:00
lemon 7ccff2fbad Turn the home page into a featured-only launchpad for all three surfaces
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.
2026-05-29 13:39:18 -07:00
lemon 83554c726d Keep skeleton up while moderation labels are still resolving
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.
2026-05-29 13:39:18 -07:00
lemon 3adaf9709f Render featured groups directly without intermediate event flash
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.
2026-05-29 13:39:18 -07:00
lemon eebb6bf424 Merge Featured and All sections on discovery pages
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.
2026-05-29 13:39:18 -07:00
lemon 51d3acd076 Shorten All groups tagline 2026-05-29 13:39:18 -07:00
lemon 58bcd56787 Hide group shelves until content is known
Regression-of: 5607f5fa
2026-05-29 13:39:18 -07:00
lemon 2b00cf9d7b Hide Featured headers when no events are surfaced
Regression-of: 9663b05e
2026-05-29 13:39:18 -07:00
lemon 2bfe712e2c Hide My pledges header when the user has no pledges 2026-05-29 13:39:18 -07:00
lemon 30258d8ad1 Hide My campaigns header when the user has no campaigns 2026-05-29 13:39:18 -07:00
lemon f8b9fdf8b9 Hide My groups header when the user has no groups 2026-05-29 13:39:18 -07:00
Chad Curtis 7761d01c79 Batch single-#a query filters in NostrBatcher
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).
2026-05-29 14:01:41 -05:00
Alex Gleason 28d0f1ab2c Default double-tweak scan to block 951430
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.
2026-05-28 16:50:17 -05:00
Alex Gleason eb836e0eea Remove redundant Back button on double-tweak fix page
The PageHeader already provides a back affordance; the in-body Back
button duplicated it.
2026-05-28 16:43:22 -05:00
Alex Gleason 7927b9806b Add double-tweak SP recovery flow under /wallet/legacy
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.
2026-05-28 16:42:00 -05:00
Alex Gleason 6607066961 Merge branch 'main' of gitlab.com:soapbox-pub/agora 2026-05-28 16:13:50 -05:00
Alex Gleason a1ee51c29a Normalize odd-Y silent payment input scalars in sender sum
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.
2026-05-28 16:02:34 -05:00
Alex Gleason 10f128fb34 Fix double-tweaked silent payment output key
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.
2026-05-28 15:51:24 -05:00
Chad Curtis 239ec43fbd Split OnboardingContext into def + provider files 2026-05-28 15:42:46 -05:00
Chad Curtis f390a88f29 Add captive onboarding flow with split create/give entry 2026-05-28 15:40:53 -05:00
Alex Gleason 33f9975262 Merge branch 'main' of gitlab.com:soapbox-pub/agora 2026-05-28 15:12:22 -05:00
Alex Gleason e5ac01f8a0 Surface pending receives in the wallet Transactions accordion
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.
2026-05-28 15:10:01 -05:00
Chad Curtis 820404bed3 Tailor NoteMoreMenu items for campaigns
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.
2026-05-28 14:37:50 -05:00
Chad Curtis 43fa17a7f8 Unify canvas encode pipeline for resize and crop
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.
2026-05-28 14:32:44 -05:00
Chad Curtis 99b4b2a5c7 Honor imageQuality opt-out for cover image crop cap
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.
2026-05-28 14:32:44 -05:00
Chad Curtis d9c69fb961 Crop and downscale cover images before upload
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.
2026-05-28 14:32:44 -05:00
Alex Gleason 6d69676394 Merge branch 'main' of gitlab.com:soapbox-pub/agora 2026-05-28 14:04:54 -05:00
mkfain 822446b3a9 Fall back to useAuthor metadata in top-nav avatar
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.
2026-05-28 21:03:34 +02:00
Alex Gleason 92608f1471 Pick BIP-21 destination from a dropdown in Send dialog
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.
2026-05-28 14:00:53 -05:00
mkfain ea6aeda368 Render Bitcoin tx and address pages at /i/bitcoin:tx:* and /i/bitcoin:address:*
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.
2026-05-28 20:44:11 +02:00
Alex Gleason 50b408cf9e Add breathing room between Send button and Transactions toggle 2026-05-28 07:09:50 -05:00
Alex Gleason 2262fccc8e Surface insufficient-funds state in the Send button itself
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.
2026-05-28 06:44:40 -05:00
Alex Gleason bab370ae87 Allow blob: workers in CSP so QR scanner can decode
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
2026-05-28 06:38:05 -05:00
Alex Gleason e0917733a7 Remove manual address-advance button on wallet receive
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.
2026-05-28 06:28:36 -05:00
Alex Gleason c10434b336 Strip Nostr search from Send dialog recipient input
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.).
2026-05-28 06:22:45 -05:00
Alex Gleason beb0665a30 Tighten Send dialog layout
- 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.
2026-05-28 06:18:40 -05:00
Alex Gleason 5e46806bb5 Drop recipient-kind status line from Send dialog
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.
2026-05-28 06:16:47 -05:00
Alex Gleason 9ed0237da8 Soften privacy warning on Send dialog
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.
2026-05-28 06:11:25 -05:00
Alex Gleason bae49e6123 Add QR scanner to Send dialog
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.
2026-05-28 06:02:58 -05:00
Chad Curtis 843fb29f26 Tighten mobile top-nav gap between menu and logo 2026-05-28 05:09:47 -05:00
lemon 687fc9cb7d Add hidden moderator section to group and campaign indexes 2026-05-28 02:41:54 -07:00
lemon 56dca6e9a0 Reorganize discovery pages around My, Featured, and All 2026-05-28 02:41:54 -07:00
lemon 652980b448 Add campaign review queues to all campaigns 2026-05-28 02:41:54 -07:00
Chad Curtis f55325042a Merge branch 'missing-translation-string' into 'main'
Fix raw campaign engagement translation keys

Closes #27

See merge request soapbox-pub/agora!37
2026-05-28 09:20:28 +00:00
filemon b2f6f372f3 Merge branch 'main' into missing-translation-string 2026-05-28 11:11:14 +02:00
lemon 9465eb2215 Pin group reads to app relay 2026-05-28 01:53:19 -07:00
lemon 01d98fa7bb Pin pledge reads to app relay 2026-05-28 01:50:08 -07:00
lemon dcbc2737be Show note share button on desktop 2026-05-28 01:46:16 -07:00
lemon 99822afb82 Show skeletons while featured groups load 2026-05-28 01:40:33 -07:00
lemon 324cdda5b9 Pin moderation reads to app relay 2026-05-28 01:26:11 -07:00
lemon b29635762c Log rendered event relay sources 2026-05-28 01:19:56 -07:00
lemon 7a6ef7a58b Log relay race diagnostics 2026-05-28 01:16:51 -07:00
lemon 7f91afdc66 Remove Damus and nos.lol defaults 2026-05-28 00:59:49 -07:00
lemon 20abad9ee5 Target pledge moderation queries 2026-05-28 00:52:39 -07:00
lemon 655fe98a46 Make placeholder icons opaque 2026-05-28 00:52:39 -07:00
lemon 9cea09b407 Make pledge icons opaque 2026-05-28 00:52:39 -07:00
lemon 7a5164d6fc Make empty pledge icon opaque 2026-05-28 00:51:27 -07:00
lemon 861281cfe7 Hide moderated pledges from discovery 2026-05-28 00:51:27 -07:00
Chad Curtis 2745732731 Redesign campaign banner to respect the source image
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.
2026-05-28 02:42:54 -05:00
Chad Curtis 4e1854a9d9 Move campaign title and actions out of the hero overlay
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.
2026-05-28 02:42:53 -05:00
filemon c1de844922 Fix raw i18n key shown for campaign engagement counters
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
2026-05-27 19:24:14 +02:00
205 changed files with 19222 additions and 5507 deletions
+20 -10
View File
@@ -37,12 +37,12 @@ deploy-web:
- if: $CI_COMMIT_TAG
when: never
- if: $CI_COMMIT_REF_NAME == $CI_DEFAULT_BRANCH && $DEPLOY_SSH_KEY && $DEPLOY_TARGET
variables:
# Vite inlines VITE_* env vars at build time. Sourced from GitLab CI/CD
# variables so values can be rotated without a code change.
VITE_TRANSLATE_WORKER_URL: $VITE_TRANSLATE_WORKER_URL
VITE_PLAUSIBLE_DOMAIN: $VITE_PLAUSIBLE_DOMAIN
VITE_PLAUSIBLE_ENDPOINT: $VITE_PLAUSIBLE_ENDPOINT
# Vite inlines VITE_* env vars at build time. These are sourced directly from
# project-level CI/CD variables, which are already present in the job
# environment — do NOT re-declare them here as `KEY: $KEY`. That self-reference
# overwrites the real value with the literal string "$KEY" whenever the source
# variable is out of scope (e.g. a Protected variable on an unprotected ref),
# which is how "$VITE_TRANSLATE_WORKER_URL" leaked into the built app.
script:
# Build the web app
- npm ci
@@ -167,23 +167,33 @@ build-apk:
# Write local.properties for Gradle
- echo "sdk.dir=$ANDROID_SDK_ROOT" > android/local.properties
# Decode signing keystore and migrate JKS -> PKCS12 for Gradle compatibility
# Decode signing keystore and migrate JKS -> PKCS12 for Gradle compatibility.
# PKCS12 conceptually uses one password for the store and every entry; if the
# store and key passwords differ, keytool protects the migrated entry with the
# STORE password regardless of -destkeypass, so Gradle's later read with the
# key password fails ("Given final block not properly padded"). Unlock the
# source key with its own password ($KEY_PASSWORD), then write the PKCS12 with
# a single uniform password ($KEY_PASSWORD) for both store and entry so the
# key.properties below is internally consistent.
- echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > android/app/my-upload-key.jks
- keytool -importkeystore
-srckeystore android/app/my-upload-key.jks
-destkeystore android/app/my-upload-key.keystore
-deststoretype pkcs12
-srcstorepass "$KEYSTORE_PASSWORD"
-deststorepass "$KEYSTORE_PASSWORD"
-srcalias upload
-destalias upload
-srckeypass "$KEY_PASSWORD"
-deststorepass "$KEY_PASSWORD"
-destkeypass "$KEY_PASSWORD"
-noprompt
- rm android/app/my-upload-key.jks
# Write key.properties from CI/CD variables
# Write key.properties from CI/CD variables. The PKCS12 above uses
# $KEY_PASSWORD uniformly, so both storePassword and keyPassword point to it.
- |
cat > android/key.properties << EOF
storePassword=$KEYSTORE_PASSWORD
storePassword=$KEY_PASSWORD
keyPassword=$KEY_PASSWORD
keyAlias=upload
storeFile=my-upload-key.keystore
+56
View File
@@ -1,5 +1,61 @@
# Changelog
## [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
- Update the app icon to the current Agora bolt on a brand-orange background.
## [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
- 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
- Refreshed Agora branding, navigation, and app icons throughout.
- Streamlined onboarding with country and people follows.
- Polished campaign, organization, and donation flows end to end.
### Removed
- Direct messaging and ephemeral geo chat.
## [1.0.0] - 2026-04-30
### Added
+61 -23
View File
@@ -22,7 +22,7 @@
|--------------------------|-----------------------------------------|-----------------------------------------------------------------|
| Flat Communities | 34550, 30009, 8, 1111, 1984 | One-level badge membership with explicit moderators (NIP-72 ext) |
| Community Chat | 34550, 1311 | Realtime member chat scoped to a NIP-72 community |
| Campaign Moderation | 33863, 34550, 36639, 1985, 39089 | Discovery curation (approved / hidden / featured axes) via moderator-signed labels in the `agora.moderation` namespace, gated by a follow-pack moderator roster. Covers campaigns (all three axes), organizations (hidden + featured), and pledges (hidden + featured). |
| Campaign Moderation | 33863, 34550, 36639, 1985, 39089 | Discovery curation (hidden + featured axes) via moderator-signed labels in the `agora.moderation` namespace, gated by a follow-pack moderator roster. Covers campaigns, organizations, and pledges identically. |
| HD Wallet Derivation | — | BIP-39 mnemonic deterministically derived from the user's nsec via HKDF; seeds a BIP-86 Taproot + BIP-352 silent-payment wallet importable into any BIP-39-compatible wallet (see [Agora HD Wallet](#agora-hd-wallet-derivation) below). |
### Agora Content Marker
@@ -70,7 +70,7 @@ Clients filter both case variants (`agora` and `Agora`) because Nostr `t` tags a
#### Backward compatibility
Events published before this marker was adopted do not carry `t:agora` and therefore do not appear in the Agora activity feed. They remain reachable by direct link and via kind-specific directories (e.g. the moderator-curated `/campaigns/all`). Authors who wish to surface a legacy event in the feed can republish it (any edit through the Agora UI will add the marker automatically).
Events published before this marker was adopted do not carry `t:agora` and therefore do not appear in the Agora activity feed. They remain reachable by direct link and via kind-specific directories (e.g. the moderator-curated `/campaigns`). Authors who wish to surface a legacy event in the feed can republish it (any edit through the Agora UI will add the marker automatically).
### Community Chat
@@ -282,11 +282,11 @@ The two zap kinds are complementary. Clients SHOULD sum verified amounts from bo
### Summary
Addressable event representing a **self-authored fundraising campaign**. A campaign carries marketing-style metadata (title, summary, banner image, markdown story, optional goal, optional deadline, optional country) and one or two Bitcoin wallet endpoints declared in `w` tags. Each wallet endpoint is either a public on-chain bech32(m) address (`bc1q…`, `bc1p…`) or a silent-payment code (`sp1…`, per BIP-352). The mode of each endpoint is inferred from the prefix — the client renders a QR code that combines the present endpoints and adjusts the donation-progress UI accordingly. A campaign MAY declare **at most one** endpoint per mode (at most one on-chain address and at most one silent-payment code).
Addressable event representing a **self-authored fundraising campaign**. A campaign carries marketing-style metadata (title, summary, banner image, markdown story, optional goal, optional country) and one or two Bitcoin wallet endpoints declared in `w` tags. Each wallet endpoint is either a public on-chain bech32(m) address (`bc1q…`, `bc1p…`) or a silent-payment code (`sp1…`, per BIP-352). The mode of each endpoint is inferred from the prefix — the client renders a QR code that combines the present endpoints and adjusts the donation-progress UI accordingly. A campaign MAY declare **at most one** endpoint per mode (at most one on-chain address and at most one silent-payment code).
The author of the event is also the beneficiary. Campaigns are never authored on behalf of someone else; the event creator owns the wallet declared in `w` and receives the donations. To stop accepting donations, the creator publishes a NIP-09 kind 5 deletion request referencing the campaign's `a` coordinate.
The kind is addressable so the creator can edit the story, banner, goal, deadline, and wallet over the life of the campaign without minting new identifiers. The `d` tag is the campaign's slug.
The kind is addressable so the creator can edit the story, banner, goal, and wallet over the life of the campaign without minting new identifiers. The `d` tag is the campaign's slug.
### Event Structure
@@ -315,7 +315,6 @@ The kind is addressable so the creator can edit the story, banner, goal, deadlin
["w", "sp1qq...verylongsilentpaymentcode..."],
["goal", "25000"],
["deadline", "1735689600"],
["i", "iso3166:US"],
["k", "iso3166"],
@@ -352,7 +351,6 @@ The `content` field is the **campaign story**, formatted as Markdown. Clients SH
| `banner` | Recommended | HTTPS URL of the wide banner image. Clients MUST sanitize the URL (see `sanitizeUrl()` in `nostr-security`) before rendering, and SHOULD pair the URL with a NIP-92 `imeta` tag for dimensions, blurhash, MIME type, and SHA-256. |
| `imeta` | Recommended | NIP-92 media metadata for the banner. The first `url <value>` pair MUST match the `banner` URL; clients SHOULD ignore an `imeta` whose URL does not match. |
| `goal` | Optional | Fundraising goal in **integer US Dollars** (no unit suffix, no decimals). Clients MAY display an estimated sat-equivalent at view time using a live exchange rate. |
| `deadline`| Optional | Unix timestamp (seconds) at which the campaign closes for new donations. After the deadline, clients SHOULD show the campaign as ended but MAY still accept donations. |
| `i` | Recommended | NIP-73 country identifier. SHOULD be `iso3166:<code>` with an uppercase ISO 3166-1 alpha-2 country code (e.g. `iso3166:VE`). |
| `k` | Recommended if `i` is present | NIP-73 external content kind. For country identifiers this SHOULD be `iso3166`. |
| `t` | Optional | User-entered discovery/category tags. Agora also adds `t:agora` as the app marker; other `t` values are freeform topics such as `legal-defense` or `mutual-aid`. |
@@ -498,7 +496,7 @@ The `pinnedEvents` array is ordered newest pin first. Pinning an already-pinned
### Agora Moderation Labels
Agora curates which kind 33863 campaigns appear on the homepage (`/`) and on the Support directory (`/campaigns/all`), which kind 34550 organizations appear in the Featured shelf on `/communities`, and which kind 36639 pledges appear in the discovery surfaces on `/pledges`, via moderator-signed NIP-32 label events (kind 1985) in a dedicated label namespace. The labeled event itself is never modified — surfacing is purely a client-side rollup of label events.
Agora curates which kind 33863 campaigns appear on the homepage (`/`) and on the Support directory (`/campaigns`), which kind 34550 organizations appear in the Featured shelf on `/communities`, and which kind 36639 pledges appear in the discovery surfaces on `/pledges`, via moderator-signed NIP-32 label events (kind 1985) in a dedicated label namespace. The labeled event itself is never modified — surfacing is purely a client-side rollup of label events.
Campaigns, organizations, and pledges share a single label namespace and a single moderator pack (Team Soapbox); the only thing distinguishing the three streams is the kind prefix on the `a` tag of each label:
@@ -521,27 +519,26 @@ Each label event carries the namespace twice, per NIP-32:
#### Label values
Three independent axes are defined; the newest moderator-signed label per axis per coordinate wins. **Campaigns** use all three axes (`approval`, `hide`, `featured`). **Organizations** and **pledges** use only two — `hide` and `featured` — because every Agora-tagged organization or pledge is publicly visible by default; there is no approval gate. Moderators MUST NOT publish `approved` or `unapproved` labels against kind 34550 or kind 36639 coordinates, and clients MUST ignore any such labels they receive.
Two independent axes are defined; the newest moderator-signed label per axis per coordinate wins. All three surfaces (campaigns, organizations, pledges) use the same two axes — every Agora-tagged entity is publicly visible by default, and moderation reduces to suppressing unwanted entries (`hide`) and lifting curated ones into a featured row (`featured`).
| Axis | Values | Surfaces | Meaning |
|----------|---------------------------|------------------------------------|-------------------------------------------------------------------------|
| approval | `approved`, `unapproved` | campaigns only | `approved` allows the campaign on its discovery surfaces. `unapproved` retracts a previous approval. |
| hide | `hidden`, `unhidden` | campaigns, organizations, pledges | `hidden` suppresses the target everywhere it would otherwise appear. `unhidden` retracts a previous hide. |
| featured | `featured`, `unfeatured` | campaigns, organizations, pledges | `featured` places the target in a hand-picked Featured row. `unfeatured` retracts. |
> **Legacy `approved` / `unapproved` labels.** A previous revision of this spec defined a third axis ("approval") used only by campaigns to gate which campaigns appeared on the home page. The axis was retired once `featured` became the single positive-curation mechanism on the home page. Clients MUST ignore `approved` / `unapproved` labels and SHOULD NOT publish new ones. Existing labels in relay archives are dead data.
Surfacing rules (hide always wins):
**Campaigns**
- **Featured row on `/`** — iff the latest featured label is `featured` AND the latest hide label is not `hidden`. Ordered newest-`created_at`-of-`featured`-label first. Featured is independent of Approved at the protocol level; a campaign may be featured without being approved (the home page treats Featured and Approved as deduplicated bins, with Featured taking precedence).
- **Community Campaigns grid on `/`** — iff approved, not hidden, and not featured (featured campaigns get their own row above).
- **Discover shelf** — iff approved AND not hidden.
- **Moderator-only "Pending"** — iff neither approved nor hidden.
- **Moderator-only "Hidden"** — iff hidden.
- **Featured row on `/`** — iff the latest featured label is `featured` AND the latest hide label is not `hidden`. Ordered by the featured label's effective rank (see Moderator-driven Ordering), descending.
- **Discover shelf on `/campaigns`** — iff the latest hide label is not `hidden`. Every non-hidden campaign on the network is enumerable here; the home page's Featured row is a curated subset, not a gate.
- **Moderator-only "Hidden"** — iff hidden. Surfaces the suppressed set so moderators can unhide.
**Organizations**
- **Featured shelf on `/communities`** — iff the latest featured label is `featured` AND the latest hide label is not `hidden`. Ordered newest-`created_at`-of-`featured`-label first.
- **Featured shelf on `/communities`** — iff the latest featured label is `featured` AND the latest hide label is not `hidden`. Ordered by the featured label's effective rank, descending (see Moderator-driven Ordering).
- **"My organizations" shelf on `/communities`** — intentionally ignores all moderation labels. A user's own founded, moderated, or followed organizations always render regardless of label state.
- **Moderator-only "Needs review"** — iff `t:agora` AND not featured AND not hidden. Surfaces orgs minted through Agora's create flow that haven't been triaged into Featured or Hidden yet.
- **Moderator-only "Hidden"** — iff hidden.
@@ -554,6 +551,28 @@ Surfacing rules (hide always wins):
- **Direct-URL access** — a pledge's detail page (`/<naddr>`) renders regardless of moderation state. Hidden pledges remain reachable by anyone who has the link; moderation only governs which surfaces enumerate them.
- **Featured** — reserved for a future curated pledge shelf. The `featured` axis is defined for symmetry with campaigns/organizations, and clients MAY use it when implementing such a shelf.
#### Moderator-driven Ordering
The Featured row is sorted by the **effective rank** of the moderator's latest `featured` label per campaign coordinate, descending.
A label's effective rank is the numeric value of its `["rank", "<number>"]` tag if present, falling back to the label's `created_at` when no rank tag is set. Labels published before this feature existed — and any normal hide / feature actions that don't carry a rank — surface with their `created_at` as the effective rank, so newer feature actions naturally float to the top.
The fold rule per `(coord, axis)` is unchanged: the newest event by `created_at` wins. Encoding order in the `created_at` itself would conflict with that rule the moment a moderator tried to lower a campaign's position — the new label would have an older `created_at` than the existing one and lose the fold. The rank tag decouples sort key from event recency so reorder publishes always use `created_at = now` and the fold always picks them up.
A moderator MAY reorder the row by republishing the `featured` label for a campaign with a `rank` tag carrying a chosen integer. Three operations cover the common cases:
- **Move to top** — publish with `rank = max(freshRank, currentTopRank + 1)`, where `freshRank` is a strictly-monotonic integer the client SHOULD source from current wall-clock time at sub-second resolution (Agora uses `Date.now() * 1000`). The `max` guard handles a (rare) clock-skewed existing rank that's already above `freshRank`.
- **Move up by one** — publish with `rank = neighborAbove.rank + 1`, where `neighborAbove` is the label sorted directly above the campaign being moved.
- **Move down by one** — publish with `rank = neighborBelow.rank - 1`. Only the moved campaign's label is republished; the neighbor below is untouched.
A general "drop at index `j`" (e.g. drag-and-drop in a moderator UI) is implemented by computing the two new neighbors of the moved campaign in the rearranged list and choosing any integer rank strictly between their ranks. When the gap is too tight (`prev.rank - next.rank < 2`), clients SHOULD pick `next.rank + 1` and accept that the rendered list may briefly be off by one until the next reorder leaves a wider gap. Using a sub-second-resolution `freshRank` keeps inter-rank gaps wide enough for many midpoint inserts before any renumbering is needed.
The conflict model matches the rest of the moderation namespace: the newest label per `(coord, axis)` from any moderator wins. Concurrent reorders by two moderators resolve to whoever's publish lands later; clients SHOULD refetch labels after a reorder publish to surface the authoritative order.
Reorder labels remain valid moderation labels in every other respect. Clients that don't recognize the `rank` tag simply read the label's axis state and ignore the rank — the labels are not a separate kind, not a separate namespace, and not a new tag namespace. Non-Agora clients see exactly the same hide / feature state they always have.
The featured row is the only Agora surface that uses moderator-driven ordering today. The same mechanism MAY be applied to the organization or pledge featured shelves if those grow a curation UI; until then, those shelves sort by `created_at` (the legacy behavior, identical to using a missing rank tag).
#### Event Structure
```json
@@ -562,9 +581,9 @@ Surfacing rules (hide always wins):
"content": "",
"tags": [
["L", "agora.moderation"],
["l", "approved", "agora.moderation"],
["l", "featured", "agora.moderation"],
["a", "33863:<author-pubkey>:<campaign-d-tag>"],
["alt", "Campaign moderation: approved"]
["alt", "Campaign moderation: featured"]
]
}
```
@@ -606,6 +625,26 @@ Required tags:
- `a` referencing the target coordinate (`33863:<pubkey>:<d>` for a campaign, `34550:<pubkey>:<d>` for an organization, `36639:<pubkey>:<d>` for a pledge).
- `alt` (NIP-31) — clients without label support will display this string. The `alt` value SHOULD identify the surface (e.g. `Campaign moderation: featured`, `Organization moderation: featured`, or `Pledge moderation: hidden`) so non-Agora clients can read it.
Optional tags:
- `rank` — single string element parsed as an integer. Used on `featured` labels to position the target within the moderator-curated Featured row; see Moderator-driven Ordering above. Labels without this tag sort by `created_at` (descending), which is the correct behavior for all non-reorder uses.
A label with a rank tag looks like:
```json
{
"kind": 1985,
"content": "",
"tags": [
["L", "agora.moderation"],
["l", "featured", "agora.moderation"],
["a", "33863:<author-pubkey>:<campaign-d-tag>"],
["rank", "1700000000123000"],
["alt", "Campaign moderation: featured"]
]
}
```
#### Trust Model
Only label events authored by current members of the **Team Soapbox** follow pack are honored. The pack is a kind 39089 (NIP-51 follow pack) addressable event:
@@ -618,8 +657,8 @@ d-tag: k4p5w0n22suf
The pack `p` tags are the authoritative moderator list. Clients MUST pin `authors:` on their label REQ to the pack `p` tags; events from non-pack authors MUST be ignored. This means:
- Self-approval is impossible unless the pack author has added you.
- A moderator removed from the pack immediately loses moderation authority — campaigns/organizations kept alive only by their labels return to "pending" until another moderator approves them.
- Self-promotion is impossible unless the pack author has added you.
- A moderator removed from the pack immediately loses moderation authority — campaigns kept alive on the Featured row only by their labels fall off the row until another moderator features them.
- The pack author (single signer) can reset the entire moderator roster by republishing the pack.
The same moderator set governs both campaign and organization labels. Carving out per-surface moderator subsets is out of scope; clients that need that distinction would have to introduce a second follow pack and a second label namespace.
@@ -652,10 +691,9 @@ Step 3 — fold by `(coord, axis)`, latest-`created_at`-wins, filtering to the r
#### Client Behavior
- Clients SHOULD render approve/hide/feature controls only for users whose pubkey appears in the pack.
- Clients MAY display "Hidden" badges on hidden campaigns/organizations when viewed by a moderator, and SHOULD NOT render them at all to non-moderators.
- Non-moderator authors viewing the homepage SHOULD see their own pending campaigns in a separate explained section so they understand why their campaign isn't yet on the homepage. The campaign URL remains live and donatable regardless of moderation state.
- Organization authors are not shown an equivalent "pending" surface today — organizations are visible at their NIP-19 route regardless of moderation, and the only moderation surface is the Featured shelf.
- Clients SHOULD render hide/feature controls only for users whose pubkey appears in the pack.
- Clients MAY display "Hidden" badges on hidden campaigns/organizations/pledges when viewed by a moderator, and SHOULD NOT render them at all to non-moderators.
- Authors' own campaigns, organizations, and pledges are visible at their NIP-19 routes regardless of moderation state. The campaign URL remains live and donatable even when the campaign is not on the home page's Featured row.
---
+2 -1
View File
@@ -14,7 +14,7 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "2.14.4"
versionName "2.8.7"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
@@ -51,6 +51,7 @@ repositories {
dependencies {
implementation fileTree(include: ['*.jar'], dir: 'libs')
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
implementation "androidx.core:core:$androidxCoreVersion"
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
implementation project(':capacitor-android')
+8
View File
@@ -19,6 +19,14 @@
-dontwarn okio.**
-keep class okhttp3.** { *; }
# Barcode scanner plugin (@capacitor/barcode-scanner -> OutSystems ionbarcode)
# references Gson's @SerializedName, but Gson isn't on the release classpath.
# Suppress the missing-class warning, keep the annotation attribute, and keep
# the plugin's model classes so R8 doesn't strip/rename serialized fields.
-dontwarn com.google.gson.**
-keepattributes *Annotation*
-keep class com.outsystems.plugins.barcode.** { *; }
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
+2
View File
@@ -60,4 +60,6 @@
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
</manifest>
@@ -8,6 +8,12 @@ import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.util.Log;
import android.view.View;
import android.webkit.WebView;
import androidx.core.graphics.Insets;
import androidx.core.view.ViewCompat;
import androidx.core.view.WindowInsetsCompat;
import com.getcapacitor.BridgeActivity;
@@ -47,6 +53,35 @@ public class MainActivity extends BridgeActivity {
// Handle notification tap deep link
handleNotificationIntent(getIntent());
// The Android WebView reports env(safe-area-inset-*) as 0, so inject the
// real system-bar insets as CSS variables (--safe-area-inset-top/bottom)
// that the web layer consumes (see src/index.css). Without this, the top
// nav renders behind the status bar in the APK.
applySafeAreaInsets();
}
/**
* Read the status-bar (top) and navigation-bar (bottom) insets and write
* them into the WebView as CSS pixel variables. Re-applies on every inset
* change (rotation, status-bar show/hide, etc.).
*/
private void applySafeAreaInsets() {
final WebView webView = getBridge().getWebView();
if (webView == null) return;
ViewCompat.setOnApplyWindowInsetsListener(webView, (v, insets) -> {
Insets bars = insets.getInsets(WindowInsetsCompat.Type.systemBars());
float density = getResources().getDisplayMetrics().density;
int topPx = Math.round(bars.top / density);
int bottomPx = Math.round(bars.bottom / density);
String js =
"document.documentElement.style.setProperty('--safe-area-inset-top','" + topPx + "px');" +
"document.documentElement.style.setProperty('--safe-area-inset-bottom','" + bottomPx + "px');";
v.post(() -> webView.evaluateJavascript(js, null));
return insets;
});
ViewCompat.requestApplyInsets(webView);
}
@Override
@@ -1,50 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="100"
android:viewportHeight="100">
<!--
Ditto logo from public/logo.svg.
SVG viewBox is "-5 -10 100 100", so we shift all paths by (+5, +10)
to place the origin at (0,0) for the 100x100 viewport.
Then scale to 60% around the content center (50, 40) to fit within
Android's adaptive icon safe zone (66% of 108dp).
-->
<group
android:translateX="5"
android:translateY="10"
android:scaleX="0.7"
android:scaleY="0.7"
android:pivotX="50"
android:pivotY="40">
<!-- path1: bottom arc / bottom-right swash -->
<path
android:fillColor="#FFFFFF"
android:pathData="m 71.719615,49.36907 -0.62891,0.37109 c -0.12891,0.07031 -0.26172,0.14844 -0.39062,0.21875 -3.9883,10.309 -14.008,17.617 -25.699,17.617 -4.1211,0 -8.0312,-0.89844 -11.539,-2.5391 -0.12891,0.03906 -0.26172,0.07031 -0.39063,0.10156 l -0.35156,0.08984 h -0.02734 l -0.25,0.05859 -0.07813,0.01953 -0.10938,0.03125 c -0.55859,0.12891 -1.1289,0.26172 -1.6992,0.39062 -0.10156,0.03125 -0.19922,0.05078 -0.30078,0.07031 l -0.30078,0.10156 -0.18359,0.0078 c -0.26953,0.05859 -1.3086,0.26953 -1.3086,0.26953 -0.28906,0.05859 -0.55859,0.10937 -0.82813,0.17187 4.9805,3.3086 10.961,5.2305 17.371,5.2305 15.059,0 27.699,-10.602 30.828,-24.738 -0.75,0.48828 -1.5195,0.96875 -2.2891,1.4414 -0.59375,0.36328 -1.2031,0.72656 -1.8242,1.0859 z" />
<!-- path2: small left accent dot/arc -->
<path
android:fillColor="#FFFFFF"
android:pathData="m 30.926615,29.47807 c 0.36328,-0.48828 0.75,-0.95312 1.1523,-1.3828 0.75781,-0.80469 0.71484,-2.0703 -0.08984,-2.8281 -0.80469,-0.75781 -2.0703,-0.71484 -2.8281,0.08984 -0.50781,0.53906 -0.99219,1.125 -1.4492,1.7383 -0.65625,0.88672 -0.47266,2.1406 0.41406,2.7969 0.35938,0.26562 0.77344,0.39453 1.1875,0.39453 0.61719,0 1.2227,-0.27734 1.6133,-0.80859 z" />
<!-- path3: left vertical bar -->
<path
android:fillColor="#FFFFFF"
android:pathData="m 26.742615,32.67807 c -1.0586,-0.3125 -2.1719,0.29687 -2.4805,1.3594 -0.55859,1.9062 -0.83984,3.9141 -0.83984,5.9609 0,2.3789 0.39062,4.7227 1.1602,6.9609 0.28516,0.82812 1.0625,1.3516 1.8906,1.3516 0.21484,0 0.43359,-0.03516 0.64844,-0.10938 1.043,-0.35938 1.6016,-1.4961 1.2422,-2.543 -0.625,-1.8203 -0.94141,-3.7227 -0.94141,-5.6602 0,-1.668 0.22656,-3.2969 0.67969,-4.8398 0.30859,-1.0586 -0.30078,-2.168 -1.3594,-2.4805 z" />
<!-- path4: main ring arc -->
<path
android:fillColor="#FFFFFF"
android:pathData="m 14.691615,48.83807 c 0.10156,0.33984 0.19922,0.67969 0.32812,1.0195 0.42969,1.3516 0.94922,2.6484 1.5781,3.9102 0.10156,-0.01172 0.21094,-0.01172 0.32031,-0.01953 l 0.16016,-0.01172 0.80078,-0.07031 c 0.37109,-0.03906 0.67188,-0.07031 0.98047,-0.10156 0.51172,-0.05859 1.0195,-0.12109 1.5586,-0.19922 l 0.21875,-0.03125 c 0.07031,-0.01172 0.14062,-0.01953 0.21094,-0.03125 -1.2188,-2.2109 -2.1484,-4.6016 -2.7305,-7.1211 -0.16016,-0.71094 -0.30078,-1.4297 -0.39844,-2.1602 -0.19922,-1.3086 -0.30078,-2.6602 -0.30078,-4.0312 0,-0.89844 0.03906,-1.7812 0.12891,-2.6484 0.07031,-0.71094 0.16016,-1.4102 0.28906,-2.1016 2.25,-12.949 13.57,-22.828 27.16,-22.828 6.0508,0 11.648,1.9609 16.211,5.3008 0.57031,0.41016 1.1289,0.85938 1.6719,1.3203 1.6914,1.4219 3.2109,3.0703 4.5,4.8789 0.42969,0.60156 0.83984,1.2109 1.2188,1.8398 1.3203,2.1602 2.3398,4.5117 3.0195,7 0.23828,-0.17188 0.42969,-0.30859 0.62891,-0.46875 0.64844,-0.48047 1.2109,-0.92188 1.7383,-1.3398 0.28125,-0.23047 0.5,-0.41016 0.71094,-0.57812 0.10156,-0.07813 0.19141,-0.16016 0.28125,-0.23828 -0.42969,-1.3516 -0.96094,-2.6484 -1.5898,-3.8984 -0.14062,-0.32812 -0.30859,-0.64844 -0.48047,-0.96875 -0.32812,-0.64844 -0.69922,-1.2891 -1.0898,-1.9102 -1.6797,-2.7109 -3.7695,-5.1406 -6.1719,-7.2188 -0.57031,-0.48828 -1.1484,-0.96875 -1.7617,-1.4102 -0.55859,-0.42188 -1.1406,-0.82812 -1.7305,-1.2109 -4.9414,-3.2188 -10.852,-5.0898 -17.16,-5.0898 -14.961,0 -27.531,10.469 -30.75,24.469 -0.17188,0.67969 -0.30859,1.3711 -0.42188,2.0703 -0.12891,0.73828 -0.21875,1.5 -0.28906,2.2617 -0.07813,0.91016 -0.12109,1.8398 -0.12109,2.7812 0,2.3008 0.25,4.5508 0.71875,6.7109 0.17188,0.71484 0.35156,1.4258 0.5625,2.125 z" />
<!-- path5: top-right swash / outer arc with tail -->
<path
android:fillColor="#FFFFFF"
android:pathData="m 90.441615,21.60007 c -2.1797,-5.3398 -9.4102,-7.3984 -21,-6.0391 1.8906,1.8906 3.5391,3.9688 4.9297,6.2109 0.28906,0.46094 0.55859,0.92187 0.80859,1.3789 5.5391,-0.12109 7.6094,1.0391 7.8398,1.4492 0.12891,0.46875 -0.55078,2.7305 -4.5898,6.4805 -0.01953,0.01953 -0.03125,0.03125 -0.03906,0.03906 -0.26172,0.23828 -0.51953,0.48047 -0.80078,0.71875 -0.19922,0.17969 -0.41016,0.35938 -0.62891,0.53906 -0.10938,0.10156 -0.21875,0.19141 -0.33984,0.28906 -0.23828,0.19922 -0.5,0.41016 -0.76172,0.62109 -0.12891,0.10156 -0.26172,0.21094 -0.39844,0.32031 -0.42969,0.33984 -0.89063,0.69141 -1.3711,1.0508 -0.26953,0.21094 -0.53906,0.41016 -0.82812,0.60938 -0.32031,0.23047 -0.64062,0.46875 -0.98047,0.69922 0,0.01172 -0.01172,0.01172 -0.01172,0.01172 -0.26953,0.19141 -0.55078,0.37891 -0.82812,0.57031 -0.28125,0.19141 -0.55859,0.37109 -0.85156,0.55859 -0.25,0.16016 -0.5,0.32812 -0.76172,0.48828 -6,3.8984 -13.48,7.7188 -21.379,10.922 -8.0117,3.2383 -15.871,5.6602 -22.93,7.0391 -0.30078,0.05859 -0.60156,0.12109 -0.89062,0.17188 -0.60938,0.12109 -1.2188,0.21875 -1.8203,0.32031 -0.07031,0.01172 -0.12891,0.01953 -0.19922,0.03125 h -0.01953 c -0.28906,0.05078 -0.57031,0.08984 -0.83984,0.12891 -0.30859,0.05078 -0.60938,0.08984 -0.91016,0.12891 -0.57031,0.07813 -1.1094,0.14844 -1.6406,0.21094 -0.35156,0.03906 -0.69141,0.07031 -1.0195,0.10156 -0.30078,0.03125 -0.58984,0.05078 -0.87891,0.07813 -0.48047,0.03125 -0.92969,0.05859 -1.3711,0.07813 -0.39844,0.01953 -0.78125,0.03125 -1.1484,0.03906 -5.5116996,0.10938 -7.5702996,-1.0391 -7.8007996,-1.4492 -0.12891,-0.48047 0.55078,-2.7383 4.6093996,-6.5 -0.12891,-0.48828 -0.26172,-1 -0.37891,-1.5391 -0.51953,-2.4219 -0.78906,-4.8906 -0.78906,-7.3594 0,-0.17969 0,-0.37109 0.01172,-0.55078 -9.2733996,7.082 -13.0229996,13.59 -10.8749996,18.949 1.7383,4.2695 6.7188,6.4492 14.5899996,6.4492 2.8594,0 6.1016,-0.28906 9.7109,-0.87109 0.17188,-0.03125 0.33984,-0.05859 0.51953,-0.08984 0.17188,-0.03125 0.35156,-0.05859 0.51953,-0.08984 l 1.2188,-0.21875 c 0.57031,-0.10156 1.1484,-0.21875 1.7305,-0.33984 0.53125,-0.10938 1.0508,-0.21094 1.5781,-0.32812 0.01172,0 0.03125,-0.01172 0.03906,-0.01172 0.05078,-0.01172 0.08984,-0.01953 0.14062,-0.03125 0.07031,-0.01172 0.12891,-0.03125 0.19922,-0.05078 0.57812,-0.12891 1.1602,-0.26172 1.7383,-0.39844 0.05078,-0.01172 0.10156,-0.03125 0.14844,-0.03906 0.21094,-0.05078 0.42188,-0.10156 0.64062,-0.14844 h 0.01172 c 6.0898,-1.5117 12.559,-3.6406 19.102,-6.2891 6.5508,-2.6602 12.68,-5.6289 18.109,-8.7812 0.21875,-0.12891 0.44141,-0.26172 0.66016,-0.39062 0.58984,-0.33984 1.1797,-0.69141 1.7617,-1.0508 1.5703,-0.96094 3.0586,-1.9297 4.4805,-2.9102 0.12891,-0.08984 0.26172,-0.17969 0.39062,-0.26953 11.242,-7.8477 15.941,-15.09 13.594,-20.938 z" />
</group>
</vector>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 965 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 730 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 14 KiB

@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#ff6600</color>
<color name="ic_launcher_background">#e9673f</color>
</resources>
+1 -1
View File
@@ -1,5 +1,5 @@
ext {
minSdkVersion = 24
minSdkVersion = 26
compileSdkVersion = 36
targetSdkVersion = 36
androidxActivityVersion = '1.11.0'
-7
View File
@@ -18,13 +18,6 @@ const config: CapacitorConfig = {
contentInset: 'never',
scheme: 'Agora'
},
plugins: {
SystemBars: {
// Inject --safe-area-inset-* CSS variables on Android to work around
// a Chromium bug (<140) where env(safe-area-inset-*) reports 0.
insetsHandling: 'css',
},
},
};
export default config;
+1 -1
View File
@@ -23,7 +23,7 @@
<meta name="twitter:description" content="Power to the people." />
<meta name="twitter:image" content="https://agora.spot/og-image.jpg" />
<meta http-equiv="content-security-policy" content="default-src 'none'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; frame-src 'self' https:; font-src 'self' https:; base-uri 'self'; manifest-src 'self'; connect-src 'self' blob: https: wss:; img-src 'self' data: blob: https:; media-src 'self' blob: https:">
<meta http-equiv="content-security-policy" content="default-src 'none'; script-src 'self' 'wasm-unsafe-eval'; worker-src 'self' blob:; child-src 'self' blob:; style-src 'self' 'unsafe-inline'; frame-src 'self' https:; font-src 'self' https:; base-uri 'self'; manifest-src 'self'; connect-src 'self' blob: https: wss:; img-src 'self' data: blob: https:; media-src 'self' blob: https:">
<link rel="icon" type="image/x-icon" href="/favicon.ico">
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16.png">
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png">
+2 -2
View File
@@ -323,7 +323,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 2.14.4;
MARKETING_VERSION = 2.8.7;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = spot.agora.app;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -347,7 +347,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 2.8.0;
MARKETING_VERSION = 2.8.7;
PRODUCT_BUNDLE_IDENTIFIER = spot.agora.app;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
Binary file not shown.

Before

Width:  |  Height:  |  Size: 291 KiB

After

Width:  |  Height:  |  Size: 99 KiB

+1 -1
View File
@@ -50,7 +50,7 @@
<key>NSPhotoLibraryUsageDescription</key>
<string>Agora needs access to your photo library to upload images to your posts and profile.</string>
<key>NSCameraUsageDescription</key>
<string>Agora needs camera access to take photos and videos for your posts.</string>
<string>Agora needs camera access to take photos and videos for your posts, and to scan QR codes when sending Bitcoin.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Agora needs access to your microphone to record voice messages.</string>
<key>ITSAppUsesNonExemptEncryption</key>
+120 -39
View File
@@ -1,12 +1,12 @@
{
"name": "agora",
"version": "2.8.0",
"version": "2.8.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "agora",
"version": "2.8.0",
"version": "2.8.6",
"dependencies": {
"@breeztech/breez-sdk-spark": "^0.10.0",
"@capacitor/app": "^8.0.0",
@@ -52,8 +52,8 @@
"@milkdown/utils": "^7.20.0",
"@noble/curves": "^2.2.0",
"@noble/hashes": "^1.8.0",
"@nostrify/nostrify": "^0.52.0",
"@nostrify/react": "^0.6.0",
"@nostrify/nostrify": "^0.52.2",
"@nostrify/react": "^0.6.2",
"@nostrify/types": "^0.37.0",
"@plausible-analytics/tracker": "^0.4.4",
"@radix-ui/react-accordion": "^1.2.0",
@@ -102,9 +102,9 @@
"i18next": "^26.0.5",
"i18next-browser-languagedetector": "^8.2.1",
"idb": "^8.0.3",
"iso-3166": "^4.4.0",
"lucide-react": "^1.8.0",
"nostr-tools": "^2.13.0",
"qr-scanner": "^1.4.2",
"qrcode": "^1.5.4",
"react": "^19.2.4",
"react-blurhash": "^0.3.0",
@@ -148,6 +148,7 @@
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
"eslint-plugin-react-refresh": "^0.4.9",
"globals": "^15.9.0",
"iso-3166": "^4.4.0",
"jsdom": "^26.1.0",
"postcss": "^8.4.47",
"rollup-plugin-visualizer": "^7.0.1",
@@ -195,6 +196,44 @@
"lru-cache": "^10.4.3"
}
},
"node_modules/@aws-crypto/crc32": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz",
"integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==",
"license": "Apache-2.0",
"dependencies": {
"@aws-crypto/util": "^5.2.0",
"@aws-sdk/types": "^3.222.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/@aws-crypto/util": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz",
"integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/types": "^3.222.0",
"@smithy/util-utf8": "^2.0.0",
"tslib": "^2.6.2"
}
},
"node_modules/@aws-sdk/types": {
"version": "3.973.9",
"resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.9.tgz",
"integrity": "sha512-kuBfgQVdcz5Bmapc4A13YbpVw/pXkesfhetcFYwbntqas8sF41OHyd4o28+/TG2ZQdHBsv90Lsu5y6oitvYCdg==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/types": "^4.14.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@babel/code-frame": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
@@ -2467,9 +2506,9 @@
}
},
"node_modules/@nostrify/nostrify": {
"version": "0.52.0",
"resolved": "https://registry.npmjs.org/@nostrify/nostrify/-/nostrify-0.52.0.tgz",
"integrity": "sha512-x+gc8rxJ4C+mnoFgd4Zzi0JnXUz0acQA69nKqR0fnWhpc/KiQosgIILfaNUTWkecTPJ92iazT4Es+TrUUSFcRg==",
"version": "0.52.2",
"resolved": "https://registry.npmjs.org/@nostrify/nostrify/-/nostrify-0.52.2.tgz",
"integrity": "sha512-X4pteBW9p2sVhBX9Dxt7Wf+beJYI7ophfEopcNmaTipNdj/u1LeS5ufze2fKozTvje53s4MoK7+DkMpRtFSKDg==",
"dependencies": {
"@nostrify/types": "0.37.0",
"@scure/base": "^2.0.0",
@@ -2484,18 +2523,18 @@
}
},
"node_modules/@nostrify/nostrify/node_modules/@scure/base": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz",
"integrity": "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==",
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.2.0.tgz",
"integrity": "sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg==",
"license": "MIT",
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@nostrify/nostrify/node_modules/@types/node": {
"version": "24.12.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz",
"integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==",
"version": "24.12.4",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.4.tgz",
"integrity": "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==",
"license": "MIT",
"dependencies": {
"undici-types": "~7.16.0"
@@ -2508,11 +2547,11 @@
"license": "MIT"
},
"node_modules/@nostrify/react": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/@nostrify/react/-/react-0.6.0.tgz",
"integrity": "sha512-6vjF5UagAW5QRpxAu/of9lyI7837wwoyX/NLGQbEs6fcMQXjTo/m7wUBPipoj0E460QvyNXff5O8Byn72enWbQ==",
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/@nostrify/react/-/react-0.6.2.tgz",
"integrity": "sha512-D7SXjhEQ74Gd3aEjlG4FOzrDZ/uPMb3LgWwGmZg48F8noRWKAUjDBS9i7d3J6lShPBydw/BLg7Yhue2GValAhg==",
"dependencies": {
"@nostrify/nostrify": "0.52.0",
"@nostrify/nostrify": "0.52.2",
"@nostrify/types": "0.37.0"
},
"peerDependencies": {
@@ -5831,10 +5870,36 @@
"react": "^16.14.0 || 17.x || 18.x || 19.x"
}
},
"node_modules/@smithy/core": {
"version": "3.24.6",
"resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.24.6.tgz",
"integrity": "sha512-wBXDRup6UU97VKyaiRo8AssnfStPtG0oAAfpq/bC0a1YYau8pM86YB4kM6ccoVi1mS8l/UHbn9oDM+7uozr/ug==",
"license": "Apache-2.0",
"dependencies": {
"@aws-crypto/crc32": "5.2.0",
"@smithy/types": "^4.14.3",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/is-array-buffer": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.2.2.tgz",
"integrity": "sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow==",
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz",
"integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.6.2"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/@smithy/types": {
"version": "4.14.3",
"resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.3.tgz",
"integrity": "sha512-YupL0ZWmFtJexUN2cHzkvvF/b9pKrtAIfT1o7/oY/Ppu8IYeZ+lDPM5vZdQJaSeA132dJCqojjGC9NhXeF71VQ==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.6.2"
@@ -5844,13 +5909,12 @@
}
},
"node_modules/@smithy/util-base64": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.3.2.tgz",
"integrity": "sha512-XRH6b0H/5A3SgblmMa5ErXQ2XKhfbQB+Fm/oyLZ2O2kCUrwgg55bU0RekmzAhuwOjA9qdN5VU2BprOvGGUkOOQ==",
"version": "4.4.6",
"resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.4.6.tgz",
"integrity": "sha512-V6ApAGvCQnb7Wy1Sy60AQc+7UOEaNQxvAXBLdMi5Zzm66cmX0srvfAxDmg7BGuJ+9H9ez0PPWS/AeFgWxwGavA==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/util-buffer-from": "^4.2.2",
"@smithy/util-utf8": "^4.2.2",
"@smithy/core": "^3.24.6",
"tslib": "^2.6.2"
},
"engines": {
@@ -5858,24 +5922,25 @@
}
},
"node_modules/@smithy/util-buffer-from": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.2.2.tgz",
"integrity": "sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q==",
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz",
"integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/is-array-buffer": "^4.2.2",
"@smithy/is-array-buffer": "^2.2.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
"node": ">=14.0.0"
}
},
"node_modules/@smithy/util-hex-encoding": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.2.tgz",
"integrity": "sha512-Qcz3W5vuHK4sLQdyT93k/rfrUwdJ8/HZ+nMUOyGdpeGA1Wxt65zYwi3oEl9kOM+RswvYq90fzkNDahPS8K0OIg==",
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.3.6.tgz",
"integrity": "sha512-ooo5MQdstAtIlgS0bchoMkVsQ3x1wLLPtFilpeIV8wVtpwZYY8PoSdlvR79+yw0aJU9hjd8stKsmzIxrmAQ6fw==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/core": "^3.24.6",
"tslib": "^2.6.2"
},
"engines": {
@@ -5883,16 +5948,16 @@
}
},
"node_modules/@smithy/util-utf8": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.2.2.tgz",
"integrity": "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw==",
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz",
"integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/util-buffer-from": "^4.2.2",
"@smithy/util-buffer-from": "^2.2.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
"node": ">=14.0.0"
}
},
"node_modules/@standard-schema/utils": {
@@ -6226,6 +6291,12 @@
"undici-types": "~6.21.0"
}
},
"node_modules/@types/offscreencanvas": {
"version": "2019.7.3",
"resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.7.3.tgz",
"integrity": "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==",
"license": "MIT"
},
"node_modules/@types/qrcode": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.5.tgz",
@@ -9326,6 +9397,7 @@
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/iso-3166/-/iso-3166-4.4.0.tgz",
"integrity": "sha512-I6ylkNQgxVh7cYADMUJpqBUdremGvyGZkDRSk9Cdic/ITBUemsllQnUeRpz7yDKyfgAXI9oPa5A9dia+7IXLqw==",
"dev": true,
"license": "MIT",
"funding": {
"type": "github",
@@ -11997,6 +12069,15 @@
"node": ">=6"
}
},
"node_modules/qr-scanner": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/qr-scanner/-/qr-scanner-1.4.2.tgz",
"integrity": "sha512-kV1yQUe2FENvn59tMZW6mOVfpq9mGxGf8l6+EGaXUOd4RBOLg7tRC83OrirM5AtDvZRpdjdlXURsHreAOSPOUw==",
"license": "MIT",
"dependencies": {
"@types/offscreencanvas": "^2019.6.4"
}
},
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
+5 -4
View File
@@ -1,7 +1,7 @@
{
"name": "agora",
"private": true,
"version": "2.8.0",
"version": "2.8.7",
"type": "module",
"scripts": {
"dev": "npm i --silent && vite",
@@ -59,8 +59,8 @@
"@milkdown/utils": "^7.20.0",
"@noble/curves": "^2.2.0",
"@noble/hashes": "^1.8.0",
"@nostrify/nostrify": "^0.52.0",
"@nostrify/react": "^0.6.0",
"@nostrify/nostrify": "^0.52.2",
"@nostrify/react": "^0.6.2",
"@nostrify/types": "^0.37.0",
"@plausible-analytics/tracker": "^0.4.4",
"@radix-ui/react-accordion": "^1.2.0",
@@ -109,9 +109,9 @@
"i18next": "^26.0.5",
"i18next-browser-languagedetector": "^8.2.1",
"idb": "^8.0.3",
"iso-3166": "^4.4.0",
"lucide-react": "^1.8.0",
"nostr-tools": "^2.13.0",
"qr-scanner": "^1.4.2",
"qrcode": "^1.5.4",
"react": "^19.2.4",
"react-blurhash": "^0.3.0",
@@ -155,6 +155,7 @@
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
"eslint-plugin-react-refresh": "^0.4.9",
"globals": "^15.9.0",
"iso-3166": "^4.4.0",
"jsdom": "^26.1.0",
"postcss": "^8.4.47",
"rollup-plugin-visualizer": "^7.0.1",
+56
View File
@@ -1,5 +1,61 @@
# Changelog
## [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
- Update the app icon to the current Agora bolt on a brand-orange background.
## [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
- 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
- Refreshed Agora branding, navigation, and app icons throughout.
- Streamlined onboarding with country and people follows.
- Polished campaign, organization, and donation flows end to end.
### Removed
- Direct messaging and ephemeral geo chat.
## [1.0.0] - 2026-04-30
### Added
Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 40 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 226 KiB

After

Width:  |  Height:  |  Size: 40 KiB

+39
View File
@@ -0,0 +1,39 @@
// Generate src/lib/subdivisionCodes.ts — the authoritative list of ISO 3166-2
// subdivision codes, extracted from the `iso-3166` package.
//
// We ship only the code strings (~42 KB) instead of importing the full
// `iso-3166` dataset (~244 KB of objects with names, parents, and tree
// structure) into the critical-path bundle. The only thing the runtime needs
// these for is validating that a `CC-XX` code is a real subdivision
// (see src/lib/countries.ts `isValidSubdivisionCode`).
//
// Run with: node scripts/gen-subdivision-codes.mjs
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { iso31662 } from 'iso-3166';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = path.resolve(__dirname, '..');
const OUTPUT = path.join(REPO_ROOT, 'src/lib/subdivisionCodes.ts');
const codes = [...new Set(iso31662.map((s) => s.code))].sort();
const header = `// AUTO-GENERATED — do not edit by hand.
//
// The authoritative list of ISO 3166-2 subdivision codes, extracted from the
// \`iso-3166\` package at build time. We ship only the code strings (~42 KB)
// instead of importing the full \`iso-3166\` dataset (~244 KB of objects with
// names, parents, and tree structure) into the critical-path bundle, since
// the only thing the runtime needs these for is validating that a \`CC-XX\`
// code is a real subdivision.
//
// Regenerate with: node scripts/gen-subdivision-codes.mjs
`;
const body = `export const SUBDIVISION_CODES: readonly string[] = ${JSON.stringify(codes)};\n`;
fs.writeFileSync(OUTPUT, header + body);
console.log(`Wrote ${path.relative(REPO_ROOT, OUTPUT)} (${codes.length} codes)`);
+19 -12
View File
@@ -42,14 +42,17 @@ if [ ! -f "$SOURCE_SVG" ]; then
fi
# Brand colors
BG_COLOR="#7c52e0" # Ditto purple
BG_COLOR="#e9673f" # Agora orange (hsl(14 79% 58%))
TMPDIR=$(mktemp -d)
LOGO_WHITE_SVG="$TMPDIR/logo_white.svg"
LOGO_WHITE="$TMPDIR/logo_white.png"
# Recolor the SVG fill to white before rasterizing.
sed 's/#7c52e0/#ffffff/g' "$SOURCE_SVG" > "$LOGO_WHITE_SVG"
# Recolor the SVG fill to white before rasterizing. logo.svg declares the
# glyph with fill="black", so recolor both the attribute form and any hex.
sed -e 's/fill="black"/fill="#ffffff"/g' \
-e 's/#000000/#ffffff/g' \
-e 's/#7c52e0/#ffffff/g' "$SOURCE_SVG" > "$LOGO_WHITE_SVG"
echo "Rendering white SVG at 512x512..."
@@ -82,23 +85,27 @@ make_foreground 192 android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foregrou
# ── Legacy launcher icons (ic_launcher.png and ic_launcher_round.png) ──
# These are used on pre-API-26 devices and as fallback on some launchers.
# They must have the logo composited onto the purple background — NOT just
# a solid color fill.
# Both are the white logo composited onto an orange circle (brand mark).
echo "Generating legacy launcher icons (ic_launcher.png and ic_launcher_round.png)..."
# make_legacy_square: logo on flat purple square background
# make_legacy_square: white logo on an orange circle (transparent corners)
make_legacy_square() {
local size=$1
local content_size=$(echo "$size * 60 / 100" | bc)
local dest=$2
local mask="$TMPDIR/circle_mask_sq_${size}.png"
$MAGICK -size "${size}x${size}" "xc:none" \
-fill white -draw "circle $((size/2)),$((size/2)) $((size/2)),0" \
"$mask"
$MAGICK -size "${size}x${size}" "xc:${BG_COLOR}" \
"$mask" -compose dst-in -composite \
\( "$LOGO_WHITE" -resize "${content_size}x${content_size}" \) \
-gravity center -compose over -composite \
"$dest"
}
# make_legacy_round: logo on circular purple background (alpha-masked circle)
# make_legacy_round: white logo on circular orange background (alpha-masked circle)
make_legacy_round() {
local size=$1
local content_size=$(echo "$size * 60 / 100" | bc)
@@ -108,7 +115,7 @@ make_legacy_round() {
$MAGICK -size "${size}x${size}" "xc:none" \
-fill white -draw "circle $((size/2)),$((size/2)) $((size/2)),0" \
"$mask"
# Fill purple, apply circle mask, composite logo
# Fill orange, apply circle mask, composite logo
$MAGICK -size "${size}x${size}" "xc:${BG_COLOR}" \
"$mask" -compose dst-in -composite \
\( "$LOGO_WHITE" -resize "${content_size}x${content_size}" \) \
@@ -134,11 +141,11 @@ mkdir -p android/app/src/main/res/values
cat > "$BACKGROUND_COLOR_FILE" << 'EOF'
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#7c52e0</color>
<color name="ic_launcher_background">#e9673f</color>
</resources>
EOF
# ── iOS App Icon (1024x1024, white logo on purple background) ──
# ── iOS App Icon (1024x1024, white logo on orange background) ──
echo "Generating iOS app icon..."
@@ -146,7 +153,7 @@ IOS_ICON_DIR="ios/App/App/Assets.xcassets/AppIcon.appiconset"
if [ -d "$IOS_ICON_DIR" ]; then
IOS_ICON="$IOS_ICON_DIR/AppIcon-512@2x.png"
# Logo at ~60% of canvas, centered on purple background (matches legacy Android style)
# Logo at ~60% of canvas, centered on orange background (matches Android style)
$MAGICK -size "1024x1024" "xc:${BG_COLOR}" \
\( "$LOGO_WHITE" -resize "614x614" \) \
-gravity center -compose over -composite \
@@ -160,7 +167,7 @@ fi
rm -rf "$TMPDIR"
echo -e "\n${GREEN}App icons generated successfully!${NC}"
echo -e "Icon: white Ditto logo on ${GREEN}${BG_COLOR}${NC} (Ditto purple)"
echo -e "Icon: white Agora logo on ${GREEN}${BG_COLOR}${NC} (Agora orange)"
echo -e "Generated:"
echo -e " Android:"
echo -e " - ic_launcher_foreground.png (adaptive, all densities)"
+7 -3
View File
@@ -18,6 +18,7 @@ import { TooltipProvider } from "@/components/ui/tooltip";
import { useNsecPasteGuard } from "@/hooks/useNsecPasteGuard";
import type { AppConfig } from "@/contexts/AppContext";
import { NWCProvider } from "@/contexts/NWCContext";
import { OnboardingProvider } from "@/contexts/OnboardingProvider";
import { BuildConfigSchema, type BuildConfig } from "@/lib/schemas";
import { secureStorage } from "@/lib/secureStorage";
import AppRouter from "./AppRouter";
@@ -161,6 +162,7 @@ const hardcodedConfig: AppConfig = {
aiApiKey: '',
aiModel: 'google/gemma-4-26b',
aiSystemPrompt: '',
translateWorkerUrl: import.meta.env.VITE_TRANSLATE_WORKER_URL || '',
};
/**
@@ -207,9 +209,11 @@ export function App() {
<NativeNotifications />
<NWCProvider>
<TooltipProvider>
<AppRouter />
</TooltipProvider>
<OnboardingProvider>
<TooltipProvider>
<AppRouter />
</TooltipProvider>
</OnboardingProvider>
</NWCProvider>
</NostrProvider>
</NostrLoginProvider>
+14 -4
View File
@@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next";
import { Loader2 } from "lucide-react";
import { Toaster } from "./components/ui/toaster";
import { TopNav } from "./components/TopNav";
import { OnboardingGate } from "./components/OnboardingGate";
import { ScrollToTop } from "./components/ScrollToTop";
import { VersionCheck } from "./components/VersionCheck";
import { useCurrentUser } from "./hooks/useCurrentUser";
@@ -20,6 +21,7 @@ import NotFound from "./pages/NotFound";
const CampaignsPage = lazy(() => import("./pages/CampaignsPage").then(m => ({ default: m.CampaignsPage })));
const CreateCampaignPage = lazy(() => import("./pages/CreateCampaignPage").then(m => ({ default: m.CreateCampaignPage })));
const AllCampaignsPage = lazy(() => import("./pages/AllCampaignsPage").then(m => ({ default: m.AllCampaignsPage })));
const CampaignListDetailPage = lazy(() => import("./pages/CampaignListDetailPage").then(m => ({ default: m.CampaignListDetailPage })));
// All other pages: code-split via React.lazy
const ActionsPage = lazy(() => import("./pages/ActionsPage"));
@@ -51,6 +53,7 @@ const SearchPage = lazy(() => import("./pages/SearchPage").then(m => ({ default:
const SettingsPage = lazy(() => import("./pages/SettingsPage").then(m => ({ default: m.SettingsPage })));
const WalletPage = lazy(() => import("./pages/WalletPage").then(m => ({ default: m.WalletPage })));
const WalletMigrateV1Page = lazy(() => import("./pages/WalletMigrateV1Page").then(m => ({ default: m.WalletMigrateV1Page })));
const WalletDoubleTweakFixPage = lazy(() => import("./pages/WalletDoubleTweakFixPage").then(m => ({ default: m.WalletDoubleTweakFixPage })));
const WalletRecoveryPage = lazy(() => import("./pages/WalletRecoveryPage").then(m => ({ default: m.WalletRecoveryPage })));
const WalletSettingsPage = lazy(() => import("./pages/WalletSettingsPage").then(m => ({ default: m.WalletSettingsPage })));
const LegacyWalletRecoveryPage = lazy(() => import("./pages/LegacyWalletRecoveryPage").then(m => ({ default: m.LegacyWalletRecoveryPage })));
@@ -130,7 +133,8 @@ export function AppRouter() {
<Toaster />
<VersionCheck />
<ScrollToTop />
<Routes>
<OnboardingGate>
<Routes>
{/* Narrow layout `max-w-3xl` center column. The default for
form/prose-style pages. */}
<Route element={<FundraiserLayout narrow />}>
@@ -163,6 +167,7 @@ export function AppRouter() {
<Route path="/wallet/settings/legacy" element={<Navigate to="/wallet/legacy" replace />} />
<Route path="/wallet/recovery" element={<WalletRecoveryPage />} />
<Route path="/wallet/migrate-v1" element={<WalletMigrateV1Page />} />
<Route path="/wallet/double-tweak-fix" element={<WalletDoubleTweakFixPage />} />
<Route path="/bitcoin" element={<Navigate to="/wallet" replace />} />
{/* Legacy /help routes redirect to /about so existing links keep
working. The About page and the two guides themselves live
@@ -183,9 +188,13 @@ export function AppRouter() {
constraints. */}
<Route element={<FundraiserLayout narrow={false} />}>
<Route path="/" element={<CampaignsPage />} />
<Route path="/campaigns" element={<Navigate to="/" replace />} />
<Route path="/campaigns" element={<AllCampaignsPage />} />
<Route path="/campaigns/new" element={<CreateCampaignPage />} />
<Route path="/campaigns/all" element={<AllCampaignsPage />} />
<Route path="/campaigns/lists/:slug" element={<CampaignListDetailPage />} />
{/* Legacy URL: the all-campaigns directory lived at
`/campaigns/all` for a while. Keep it as a redirect so
external links and bookmarks still resolve. */}
<Route path="/campaigns/all" element={<Navigate to="/campaigns" replace />} />
<Route path="/groups" element={<CommunitiesPage />} />
<Route path="/groups/new" element={<CreateCommunityPage />} />
<Route path="/events/new" element={<CreateEventPage />} />
@@ -207,7 +216,8 @@ export function AppRouter() {
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}
<Route path="*" element={<NotFound />} />
</Route>
</Routes>
</Routes>
</OnboardingGate>
</BrowserRouter>
);
}
+193
View File
@@ -0,0 +1,193 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQueryClient } from '@tanstack/react-query';
import { nip19 } from 'nostr-tools';
import {
Check,
Link as LinkIcon,
Loader2,
MoreHorizontal,
Trash2,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { ModerationMenuItems } from '@/components/moderation';
import { useCampaignModerators } from '@/hooks/useCampaignModerators';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useShareOrigin } from '@/hooks/useShareOrigin';
import { useToast } from '@/hooks/useToast';
import { getPledgeCoord } from '@/lib/pledges';
import type { Action } from '@/hooks/useActions';
/**
* Per-card kebab menu for pledges. Surfaces:
* Delete (owner only) NIP-09 with both `e` and `a` tags so
* relays that ignore a-tag-only deletions still drop the event.
* Copy link naddr1 URL on the current share origin.
* Moderation actions (mods only) hide / feature, under a
* separator that only renders when the viewer is a moderator.
*
* Lives outside `ActionsPage` so both the page and the reusable
* `PledgesDiscoverySection` can pin it to the card's `topRight` slot
* without duplicating the logic.
*/
export function ActionShareMenu({
action,
displayTitle,
}: {
action: Action;
displayTitle: string;
}) {
const { t } = useTranslation();
const { user } = useCurrentUser();
const { data: moderators } = useCampaignModerators();
const { mutateAsync: createEvent } = useNostrPublish();
const { toast } = useToast();
const shareOrigin = useShareOrigin();
const queryClient = useQueryClient();
const [copied, setCopied] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const isOwner = user?.pubkey === action.pubkey;
// Moderator gate is identical to the one in `ModerationMenuItems`,
// duplicated here so we can decide whether to render the trailing
// separator that introduces the moderator section.
// `ModerationMenuItems` returns `null` for non-mods, so without
// this check we'd render an orphaned separator at the bottom of
// the dropdown.
const isMod = !!user && !!moderators && moderators.includes(user.pubkey);
const naddr = nip19.naddrEncode({
kind: 36639,
pubkey: action.pubkey,
identifier: action.id,
});
const actionUrl = `${shareOrigin}/${naddr}`;
const handleCopyLink = async () => {
try {
await navigator.clipboard.writeText(actionUrl);
setCopied(true);
toast({ title: t('pledges.card.linkCopied') });
setTimeout(() => setCopied(false), 2000);
} catch (error) {
console.error('Failed to copy link:', error);
toast({ title: t('pledges.card.linkCopyFailed'), variant: 'destructive' });
}
};
const handleDelete = async (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (!user || !isOwner) return;
const confirmed = window.confirm(t('pledges.card.confirmDelete'));
if (!confirmed) return;
setIsDeleting(true);
try {
// NIP-09 deletion. Include both 'e' and 'a' tags — some relays don't
// honour a-tag-only deletions for addressable events.
await createEvent({
kind: 5,
content: t('pledges.card.deletedContent'),
tags: [
['e', action.event.id],
['a', getPledgeCoord(action)],
],
});
// Extract any organization `A` tag the pledge was associated with so
// the org's activity shelf and community feeds refresh too.
const orgATag = action.event.tags.find(([n]) => n === 'A')?.[1];
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['agora-actions'] }),
queryClient.invalidateQueries({ queryKey: ['agora-action'] }),
...(orgATag
? [
queryClient.invalidateQueries({
queryKey: ['organization-activity', orgATag],
}),
queryClient.invalidateQueries({
queryKey: ['community-actions', orgATag],
}),
queryClient.invalidateQueries({
predicate: (q) => {
const [root, aTagsKey] = q.queryKey;
return (
root === 'community-activity-feed' &&
typeof aTagsKey === 'string' &&
aTagsKey.split(',').includes(orgATag)
);
},
}),
]
: []),
]);
toast({ title: t('pledges.card.deleted') });
} catch (error) {
console.error('Failed to delete pledge:', error);
toast({ title: t('pledges.card.deleteFailed'), variant: 'destructive' });
} finally {
setIsDeleting(false);
}
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild onClick={(e) => e.preventDefault()}>
<Button
variant="ghost"
size="icon"
aria-label={t('pledges.card.actionsAriaLabel')}
className="h-8 w-8 bg-background/80 backdrop-blur text-muted-foreground hover:text-foreground"
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" onClick={(e) => e.stopPropagation()}>
{isOwner && (
<>
<DropdownMenuItem onClick={handleDelete} disabled={isDeleting}>
{isDeleting ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Trash2 className="h-4 w-4 mr-2" />
)}
{t('pledges.card.deletePledge')}
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem onClick={handleCopyLink}>
{copied ? (
<Check className="h-4 w-4 mr-2 text-primary" />
) : (
<LinkIcon className="h-4 w-4 mr-2" />
)}
{t('pledges.card.copyLink')}
</DropdownMenuItem>
{/* Moderator actions appear under a separator when the viewer
is a Team Soapbox moderator. `ModerationMenuItems` returns
null for non-mods, so we gate the trailing separator on
the same `isMod` check to avoid an orphan separator at
the bottom of non-mod dropdowns. */}
{isMod && <DropdownMenuSeparator />}
<ModerationMenuItems
coord={getPledgeCoord(action)}
entityTitle={displayTitle}
surface="pledge"
axes={['hide', 'featured']}
/>
</DropdownMenuContent>
</DropdownMenu>
);
}
+1 -1
View File
@@ -126,7 +126,7 @@ export function AddMembersDialog({ open, onOpenChange, listId, listPubkeys }: Ad
<div ref={listRef} className="overflow-y-auto flex-1">
{!query.trim() ? (
<div className="py-12 text-center text-muted-foreground text-sm">
<UserPlus className="size-8 mx-auto mb-2 opacity-50" />
<UserPlus className="size-8 mx-auto mb-2" />
Search for people to add to this list.
</div>
) : isLoading && !searchResults ? (
+1 -1
View File
@@ -143,7 +143,7 @@ export function AddToListDialog({ pubkey, displayName, open, onOpenChange }: Add
</div>
) : !hasAny ? (
<div className="py-8 px-4 text-center text-sm text-muted-foreground">
<List className="size-8 mx-auto mb-2 text-muted-foreground/40" />
<List className="size-8 mx-auto mb-2 text-muted-foreground" />
No lists or packs yet. Create one below.
</div>
) : (
+38
View File
@@ -17,6 +17,9 @@ import { DEFAULT_SYSTEM_PROMPT_TEMPLATE } from '@/lib/aiChatSystemPrompt';
const DEFAULT_AI_BASE_URL = 'https://ai.shakespeare.diy/v1';
const DEFAULT_AI_MODEL = 'google/gemma-4-26b';
/** Build-time default translation worker URL from the environment variable. */
const DEFAULT_TRANSLATE_WORKER_URL = import.meta.env.VITE_TRANSLATE_WORKER_URL || '';
/** The build-time default DSN from the environment variable. */
const DEFAULT_SENTRY_DSN = import.meta.env.VITE_SENTRY_DSN || '';
@@ -34,6 +37,7 @@ export function AdvancedSettings() {
const [faviconUrl, setFaviconUrl] = useState(config.faviconUrl);
const [linkPreviewUrl, setLinkPreviewUrl] = useState(config.linkPreviewUrl);
const [corsProxy, setCorsProxy] = useState(config.corsProxy);
const [translateWorkerUrl, setTranslateWorkerUrl] = useState(config.translateWorkerUrl);
const [sentryDsn, setSentryDsn] = useState(config.sentryDsn);
const [baseUrlDraft, setBaseUrlDraft] = useState(config.aiBaseURL);
const [apiKeyDraft, setApiKeyDraft] = useState(config.aiApiKey);
@@ -399,6 +403,40 @@ export function AdvancedSettings() {
<span className="font-mono break-all">https://proxy.shakespeare.diy/?url={'{href}'}</span>
</div>
</div>
{/* Translation Worker URL */}
<div>
<Label htmlFor="translate-worker-url" className="text-sm font-medium">
Translation Worker URL
</Label>
<p className="text-xs text-muted-foreground mt-1 mb-2">
DeepL-backed worker endpoint used by the "Translate" button on notes. Receives a POST with the text and target language.
</p>
<Input
id="translate-worker-url"
type="url"
value={translateWorkerUrl}
onChange={(e) => setTranslateWorkerUrl(e.target.value)}
onBlur={async () => {
const trimmed = translateWorkerUrl.trim();
if (trimmed && trimmed !== config.translateWorkerUrl) {
updateConfig(() => ({ translateWorkerUrl: trimmed }));
if (user) await updateSettings.mutateAsync({ translateWorkerUrl: trimmed });
toast({ title: 'Translation worker URL updated' });
}
}}
placeholder={DEFAULT_TRANSLATE_WORKER_URL || 'https://example.workers.dev'}
className="font-mono text-base md:text-sm"
autoComplete="off"
spellCheck={false}
/>
{DEFAULT_TRANSLATE_WORKER_URL && (
<div className="text-xs text-muted-foreground mt-2">
<span className="font-medium">Default: </span>
<span className="font-mono break-all">{DEFAULT_TRANSLATE_WORKER_URL}</span>
</div>
)}
</div>
</div>
</CollapsibleContent>
</Collapsible>
-19
View File
@@ -17,17 +17,6 @@ import { formatCompactPledgeDeadline, formatPledgeAmount } from '@/lib/pledges';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import { cn } from '@/lib/utils';
function getDeadlineLabel(unixSeconds: number): { label: string; isPast: boolean } {
const now = Math.floor(Date.now() / 1000);
const diff = unixSeconds - now;
if (diff <= 0) return { label: 'Ended', isPast: true };
const days = Math.ceil(diff / 86_400);
if (days <= 1) return { label: 'Ends today', isPast: false };
if (days < 30) return { label: `${days} days left`, isPast: false };
const months = Math.round(days / 30);
return { label: `${months} mo left`, isPast: false };
}
function InlineShell({
image,
fallbackIcon,
@@ -76,7 +65,6 @@ export function CampaignInlinePreview({ event }: { event: NostrEvent }) {
?? sanitizeUrl(authorMetadata?.picture);
const naddr = nip19.naddrEncode({ kind: event.kind, pubkey: event.pubkey, identifier: campaign.identifier });
const countryLabel = getCampaignCountryLabel(campaign);
const deadline = campaign.deadline ? getDeadlineLabel(campaign.deadline) : undefined;
const isSilentPayment = !campaign.wallets.onchain;
const goalLabel = campaign.goalUsd && campaign.goalUsd > 0 ? formatUsdGoal(campaign.goalUsd) : undefined;
const raisedSats = stats?.totalSats ?? 0;
@@ -113,16 +101,9 @@ export function CampaignInlinePreview({ event }: { event: NostrEvent }) {
)}
{countryLabel && (
<span className="inline-flex items-center gap-1.5">
<MapPin className="size-3.5" />
{countryLabel}
</span>
)}
{deadline && (
<span className={cn('inline-flex items-center gap-1.5', deadline.isPast && 'text-destructive')}>
<CalendarClock className="size-3.5" />
{deadline.label}
</span>
)}
</div>
</div>
)}
+2 -3
View File
@@ -159,7 +159,7 @@ export function AppHandlerContent({ event, compact }: AppHandlerContentProps) {
/>
) : (
<div className="size-14 rounded-xl bg-primary/10 flex items-center justify-center shrink-0 border-3 border-background shadow-sm">
<Package className="size-6 text-primary/50" />
<Package className="size-6 text-primary" />
</div>
)}
</div>
@@ -264,7 +264,7 @@ export function AppHandlerContent({ event, compact }: AppHandlerContentProps) {
/>
) : (
<div className="size-20 rounded-2xl bg-primary/10 flex items-center justify-center shrink-0 border-4 border-background shadow-sm">
<Package className="size-8 text-primary/50" />
<Package className="size-8 text-primary" />
</div>
)}
</div>
@@ -333,4 +333,3 @@ export function AppHandlerContent({ event, compact }: AppHandlerContentProps) {
</div>
);
}
+6
View File
@@ -72,6 +72,12 @@ export function AppProvider(props: AppProviderProps) {
const config = {
...defaultConfig,
...rawConfig,
// An empty persisted translateWorkerUrl must not shadow the build-time
// default — fall back to the default so the Translate button stays
// available. (Earlier builds could persist "" by merely opening Settings.)
translateWorkerUrl: rawConfig.translateWorkerUrl?.trim()
? rawConfig.translateWorkerUrl
: defaultConfig.translateWorkerUrl,
// Deep-merge feedSettings so new keys added to the default are visible
// even for existing users who have an older feedSettings in localStorage.
feedSettings: { ...defaultConfig.feedSettings, ...rawConfig.feedSettings },
+4 -4
View File
@@ -88,7 +88,7 @@ export function MusicTrackContent({ event }: { event: NostrEvent }) {
</div>
) : (
<div className="relative flex items-center justify-center bg-gradient-to-br from-primary/10 via-primary/5 to-transparent h-[140px] cursor-pointer" onClick={handlePlay}>
<Music className="size-10 text-primary/20" />
<Music className="size-10 text-primary" />
<div className="absolute inset-0 flex items-center justify-center">
<PlayButton isPlaying={player.isPlaying} isActive={isNowPlaying} onClick={handlePlay} size="lg" />
</div>
@@ -129,7 +129,7 @@ export function MusicPlaylistContent({ event }: { event: NostrEvent }) {
</div>
) : (
<div className="flex items-center justify-center bg-gradient-to-br from-primary/10 via-primary/5 to-transparent h-[100px]">
<ListMusic className="size-10 text-primary/20" />
<ListMusic className="size-10 text-primary" />
</div>
)}
@@ -193,7 +193,7 @@ export function PodcastEpisodeContent({ event }: { event: NostrEvent }) {
</div>
) : (
<div className="relative flex items-center justify-center bg-gradient-to-br from-primary/10 via-primary/5 to-transparent h-[140px] cursor-pointer" onClick={handlePlay}>
<Podcast className="size-10 text-primary/20" />
<Podcast className="size-10 text-primary" />
<div className="absolute inset-0 flex items-center justify-center">
<PlayButton isPlaying={player.isPlaying} isActive={isNowPlaying} onClick={handlePlay} size="lg" />
</div>
@@ -252,7 +252,7 @@ export function PodcastTrailerContent({ event }: { event: NostrEvent }) {
>
{/* Compact header — clicking anywhere here plays/pauses */}
<div className="flex items-center justify-center bg-gradient-to-br from-primary/10 via-primary/5 to-transparent h-[100px] relative cursor-pointer" onClick={handlePlay}>
<Podcast className="size-8 text-primary/20" />
<Podcast className="size-8 text-primary" />
<div className="absolute inset-0 flex items-center justify-center">
<PlayButton isPlaying={player.isPlaying} isActive={isNowPlaying} onClick={handlePlay} size="lg" />
</div>
+3 -3
View File
@@ -128,7 +128,7 @@ export function AwardBadgeDialog({ open, onOpenChange, badgeATag, badgeName }: A
</AvatarFallback>
</Avatar>
<span className="max-w-[100px] truncate">{name}</span>
<X className="size-3 opacity-60" />
<X className="size-3" />
</button>
);
})}
@@ -154,12 +154,12 @@ export function AwardBadgeDialog({ open, onOpenChange, badgeATag, badgeName }: A
</div>
) : query.trim().length > 0 ? (
<div className="flex flex-col items-center justify-center py-10 text-muted-foreground text-sm">
<Search className="size-8 mb-2 opacity-30" />
<Search className="size-8 mb-2" />
No users found
</div>
) : (
<div className="flex flex-col items-center justify-center py-10 text-muted-foreground text-sm">
<Search className="size-8 mb-2 opacity-30" />
<Search className="size-8 mb-2" />
Search to find users
</div>
)}
+1 -1
View File
@@ -152,7 +152,7 @@ function BadgeShowcaseFallback({
const body = (
<div className="mt-3 rounded-2xl border border-dashed border-border py-10 px-6 flex flex-col items-center gap-3">
<div className="size-20 rounded-2xl bg-gradient-to-br from-primary/10 via-primary/5 to-transparent flex items-center justify-center">
<Award className="size-8 text-primary/40" />
<Award className="size-8 text-primary" />
</div>
{name ? (
<p className="text-sm font-semibold text-center">{name}</p>
+1 -1
View File
@@ -155,7 +155,7 @@ function BadgeImageTilt({ heroImage, badgeName }: { heroImage?: string; badgeNam
/>
) : (
<div className="size-28 rounded-2xl bg-gradient-to-br from-primary/10 via-primary/5 to-transparent flex items-center justify-center">
<Award className="size-12 text-primary/30" />
<Award className="size-12 text-primary" />
</div>
)}
{/* Specular glare overlay */}
+5 -5
View File
@@ -131,7 +131,7 @@ export function BadgeDetailContent({ event }: { event: NostrEvent }) {
<BadgeHero heroImage={heroImage} badgeName={badge.name} />
) : (
<div className="flex items-center justify-center h-[180px]">
<Award className="size-16 text-primary/20" />
<Award className="size-16 text-primary" />
</div>
)}
@@ -286,7 +286,7 @@ function AwardedToTab({ awardedPubkeys, previewPubkeys, membersMap, membersLoadi
if (awardedPubkeys.length === 0) {
return (
<div className="py-16 flex flex-col items-center gap-3 text-center px-8">
<Users className="size-8 text-muted-foreground/30" />
<Users className="size-8 text-muted-foreground" />
<p className="text-sm text-muted-foreground">No one has been awarded this badge yet.</p>
</div>
);
@@ -387,7 +387,7 @@ function HoldersFeedTab({ awardedPubkeys, awardsLoading }: {
if (awardedPubkeys.length === 0) {
return (
<div className="py-16 flex flex-col items-center gap-3 text-center px-8">
<Newspaper className="size-8 text-muted-foreground/30" />
<Newspaper className="size-8 text-muted-foreground" />
<p className="text-sm text-muted-foreground">No one has been awarded this badge yet, so there's no feed to show.</p>
</div>
);
@@ -396,7 +396,7 @@ function HoldersFeedTab({ awardedPubkeys, awardsLoading }: {
if (feedEvents.length === 0) {
return (
<div className="py-16 flex flex-col items-center gap-3 text-center px-8">
<Newspaper className="size-8 text-muted-foreground/30" />
<Newspaper className="size-8 text-muted-foreground" />
<p className="text-sm text-muted-foreground">No posts from badge holders yet.</p>
</div>
);
@@ -436,7 +436,7 @@ function CommentsTab({ event, orderedReplies, commentsLoading }: {
<FlatThreadedReplyList replies={orderedReplies} />
) : (
<div className="py-16 flex flex-col items-center gap-3 text-center px-8">
<MessageCircle className="size-8 text-muted-foreground/30" />
<MessageCircle className="size-8 text-muted-foreground" />
<p className="text-sm text-muted-foreground">No comments yet. Be the first to comment on this badge.</p>
</div>
)}
+1 -1
View File
@@ -84,7 +84,7 @@ export function BadgeThumbnail({ badge, size = 48, className }: BadgeThumbnailPr
)}
style={{ width: size, height: size }}
>
<Award className="text-primary/30" style={{ width: size * 0.5, height: size * 0.5 }} />
<Award className="text-primary" style={{ width: size * 0.5, height: size * 0.5 }} />
</div>
)}
</div>
+37 -6
View File
@@ -7,6 +7,10 @@ interface BitcoinAmountPickerProps {
usdAmount: number | string;
onUsdAmountChange: (amount: number | string) => void;
presets: readonly number[];
maxLabel?: string;
maxSelected?: boolean;
maxDisabled?: boolean;
onMaxSelect?: () => void;
insufficient?: boolean;
satsLabel?: string;
onAmountChangeStart?: () => void;
@@ -16,6 +20,10 @@ export function BitcoinAmountPicker({
usdAmount,
onUsdAmountChange,
presets,
maxLabel = 'MAX',
maxSelected = false,
maxDisabled = false,
onMaxSelect,
insufficient = false,
satsLabel,
onAmountChangeStart,
@@ -74,14 +82,25 @@ export function BitcoinAmountPicker({
) : (
<button
type="button"
onClick={() => setEditingAmount(true)}
onClick={() => {
onAmountChangeStart?.();
setEditingAmount(true);
}}
aria-label="Edit amount"
className="flex items-baseline justify-center rounded-md px-2 -mx-2 hover:bg-muted/50 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring transition-colors"
>
<span className={cn('text-4xl font-semibold', insufficient ? 'text-destructive' : 'text-muted-foreground')}>$</span>
<span className={cn('text-4xl font-semibold tabular-nums', insufficient && 'text-destructive')}>
{Number.isFinite(currentUsd) && currentUsd > 0 ? currentUsd : 0}
</span>
{maxSelected ? (
<span className={cn('text-4xl font-semibold tracking-tight', insufficient && 'text-destructive')}>
{maxLabel}
</span>
) : (
<>
<span className={cn('text-4xl font-semibold', insufficient ? 'text-destructive' : 'text-muted-foreground')}>$</span>
<span className={cn('text-4xl font-semibold tabular-nums', insufficient && 'text-destructive')}>
{Number.isFinite(currentUsd) && currentUsd > 0 ? currentUsd : 0}
</span>
</>
)}
</button>
)}
{satsLabel && (
@@ -93,10 +112,15 @@ export function BitcoinAmountPicker({
<ToggleGroup
type="single"
value={presets.includes(Number(usdAmount)) ? String(usdAmount) : ''}
value={maxSelected ? 'max' : presets.includes(Number(usdAmount)) ? String(usdAmount) : ''}
onValueChange={(value) => {
if (value) {
onAmountChangeStart?.();
if (value === 'max') {
onMaxSelect?.();
setEditingAmount(false);
return;
}
onUsdAmountChange(Number(value));
setEditingAmount(false);
}
@@ -112,6 +136,13 @@ export function BitcoinAmountPicker({
${preset}
</ToggleGroupItem>
))}
<ToggleGroupItem
value="max"
disabled={maxDisabled}
className="h-8 min-w-0 text-xs font-semibold px-1"
>
{maxLabel}
</ToggleGroupItem>
</ToggleGroup>
</>
);
+641
View File
@@ -0,0 +1,641 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import {
ArrowDownLeft,
ArrowRight,
ArrowUpRight,
Bitcoin,
Check,
Clock,
Copy,
ExternalLink,
Hash,
Layers,
RefreshCw,
Weight,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { useBitcoinTx } from '@/hooks/useBitcoinTx';
import { useBitcoinAddress } from '@/hooks/useBitcoinAddress';
import { satsToBTC, satsToUSD, formatSats, formatBTC } from '@/lib/bitcoin';
import type { TxDetail, TxInput, TxOutput } from '@/lib/bitcoin';
// ---------------------------------------------------------------------------
// Shared helpers
// ---------------------------------------------------------------------------
function truncateMiddle(str: string, startLen = 8, endLen = 8): string {
if (str.length <= startLen + endLen + 3) return str;
return `${str.slice(0, startLen)}...${str.slice(-endLen)}`;
}
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// clipboard not available
}
};
return (
<button
onClick={handleCopy}
className="p-1 rounded hover:bg-muted/50 transition-colors text-muted-foreground hover:text-foreground cursor-pointer"
title="Copy"
>
{copied ? <Check className="size-3.5 text-green-500" /> : <Copy className="size-3.5" />}
</button>
);
}
/** Format a unix timestamp as a readable date string. */
function formatBlockTime(timestamp: number): string {
const date = new Date(timestamp * 1000);
return date.toLocaleString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: true,
});
}
/** Format a large number with locale separators. */
function formatNumber(n: number): string {
return n.toLocaleString();
}
// ---------------------------------------------------------------------------
// Bitcoin Transaction Header
// ---------------------------------------------------------------------------
export function BitcoinTxHeader({ txid }: { txid: string }) {
const { tx, btcPrice, isLoading, error } = useBitcoinTx(txid);
if (isLoading) return <TxSkeleton />;
if (error || !tx) {
return (
<div className="rounded-2xl border border-border p-6 text-center space-y-3">
<Bitcoin className="size-10 mx-auto text-muted-foreground/40" />
<p className="text-sm text-destructive">Failed to load transaction</p>
<p className="text-xs text-muted-foreground font-mono break-all">{txid}</p>
<a
href={`https://mempool.space/tx/${txid}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<Bitcoin className="size-3.5" />
<span>View on mempool.space</span>
<ExternalLink className="size-3" />
</a>
</div>
);
}
return (
<div className="rounded-2xl border border-border overflow-hidden">
{/* Header */}
<div className="p-5 space-y-4">
<div className="flex items-center gap-3">
<div className={`flex items-center justify-center size-10 rounded-full ${
tx.confirmed
? 'bg-green-500/10 text-green-600 dark:text-green-400'
: 'bg-orange-500/10 text-orange-600 dark:text-orange-400'
}`}>
{tx.confirmed ? <Check className="size-5" /> : <Clock className="size-5" />}
</div>
<div>
<h2 className="text-lg font-bold">
{tx.confirmed ? 'Confirmed' : 'Unconfirmed'}
</h2>
{tx.blockTime && (
<p className="text-sm text-muted-foreground">{formatBlockTime(tx.blockTime)}</p>
)}
</div>
</div>
{/* Transaction ID */}
<div className="space-y-1.5">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Transaction ID</p>
<div className="flex items-center gap-2">
<p className="text-sm font-mono text-foreground break-all">{tx.txid}</p>
<CopyButton text={tx.txid} />
</div>
</div>
{/* Stats grid */}
<div className="grid grid-cols-2 gap-3">
{tx.confirmed && tx.blockHeight !== undefined && (
<StatCard icon={<Layers className="size-3.5" />} label="Block" value={formatNumber(tx.blockHeight)} />
)}
<StatCard icon={<Weight className="size-3.5" />} label="Size" value={`${formatNumber(tx.weight / 4)} vB`} />
<StatCard
icon={<Bitcoin className="size-3.5" />}
label="Fee"
value={`${formatSats(tx.fee)} sat`}
subtitle={`${(tx.fee / (tx.weight / 4)).toFixed(1)} sat/vB`}
/>
<StatCard
icon={<Hash className="size-3.5" />}
label="Amount"
value={`${formatBTC(tx.totalOutput)} BTC`}
subtitle={btcPrice ? satsToUSD(tx.totalOutput, btcPrice) : undefined}
/>
</div>
</div>
{/* Inputs → Outputs flow */}
<div className="border-t border-border">
<TxFlow tx={tx} btcPrice={btcPrice} />
</div>
{/* Footer: link to mempool.space */}
<div className="border-t border-border px-5 py-2.5">
<a
href={`https://mempool.space/tx/${txid}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<Bitcoin className="size-3.5" />
<span>View on mempool.space</span>
<ExternalLink className="size-3" />
</a>
</div>
</div>
);
}
function StatCard({ icon, label, value, subtitle }: { icon: React.ReactNode; label: string; value: string; subtitle?: string }) {
return (
<div className="rounded-xl bg-secondary/40 px-3.5 py-2.5 space-y-0.5">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
{icon}
<span>{label}</span>
</div>
<p className="text-sm font-semibold">{value}</p>
{subtitle && <p className="text-xs text-muted-foreground">{subtitle}</p>}
</div>
);
}
/** Inputs → Outputs visualization, mempool.space-style. */
function TxFlow({ tx, btcPrice }: { tx: TxDetail; btcPrice?: number }) {
return (
<div className="p-4 space-y-3">
<div className="flex items-center gap-2 text-xs font-medium text-muted-foreground uppercase tracking-wider px-1">
<span>{tx.inputs.length} Input{tx.inputs.length !== 1 ? 's' : ''}</span>
<ArrowRight className="size-3" />
<span>{tx.outputs.length} Output{tx.outputs.length !== 1 ? 's' : ''}</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{/* Inputs */}
<div className="space-y-1.5">
{tx.inputs.slice(0, 10).map((input, i) => (
<TxInputRow key={`${input.txid}-${input.vout}-${i}`} input={input} btcPrice={btcPrice} />
))}
{tx.inputs.length > 10 && (
<p className="text-xs text-muted-foreground text-center py-1">
+{tx.inputs.length - 10} more input{tx.inputs.length - 10 !== 1 ? 's' : ''}
</p>
)}
</div>
{/* Outputs */}
<div className="space-y-1.5">
{tx.outputs.slice(0, 10).map((output, i) => (
<TxOutputRow key={`${output.address ?? 'op_return'}-${i}`} output={output} btcPrice={btcPrice} />
))}
{tx.outputs.length > 10 && (
<p className="text-xs text-muted-foreground text-center py-1">
+{tx.outputs.length - 10} more output{tx.outputs.length - 10 !== 1 ? 's' : ''}
</p>
)}
</div>
</div>
</div>
);
}
function TxInputRow({ input, btcPrice }: { input: TxInput; btcPrice?: number }) {
if (input.isCoinbase) {
return (
<div className="rounded-lg bg-amber-500/5 border border-amber-500/20 px-3 py-2">
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-medium text-amber-600 dark:text-amber-400">Coinbase</span>
<span className="text-xs font-mono">{formatBTC(input.value)} BTC</span>
</div>
</div>
);
}
return (
<div className="rounded-lg bg-red-500/5 border border-red-500/10 px-3 py-2 space-y-0.5">
<div className="flex items-center justify-between gap-2">
{input.address ? (
<Link
to={`/i/bitcoin:address:${input.address}`}
className="text-xs font-mono text-red-600 dark:text-red-400 hover:underline truncate"
>
{truncateMiddle(input.address, 10, 6)}
</Link>
) : (
<span className="text-xs text-muted-foreground">Unknown</span>
)}
<span className="text-xs font-mono shrink-0">{formatBTC(input.value)} BTC</span>
</div>
{btcPrice !== undefined && (
<p className="text-[10px] text-muted-foreground text-right">{satsToUSD(input.value, btcPrice)}</p>
)}
</div>
);
}
function TxOutputRow({ output, btcPrice }: { output: TxOutput; btcPrice?: number }) {
const isOpReturn = output.scriptpubkeyType === 'op_return';
if (isOpReturn) {
return (
<div className="rounded-lg bg-secondary/60 border border-border/50 px-3 py-2">
<span className="text-xs text-muted-foreground">OP_RETURN</span>
</div>
);
}
return (
<div className="rounded-lg bg-green-500/5 border border-green-500/10 px-3 py-2 space-y-0.5">
<div className="flex items-center justify-between gap-2">
{output.address ? (
<Link
to={`/i/bitcoin:address:${output.address}`}
className="text-xs font-mono text-green-600 dark:text-green-400 hover:underline truncate"
>
{truncateMiddle(output.address, 10, 6)}
</Link>
) : (
<span className="text-xs text-muted-foreground">Unknown</span>
)}
<span className="text-xs font-mono shrink-0">{formatBTC(output.value)} BTC</span>
</div>
{btcPrice !== undefined && (
<p className="text-[10px] text-muted-foreground text-right">{satsToUSD(output.value, btcPrice)}</p>
)}
</div>
);
}
function TxSkeleton() {
return (
<div className="rounded-2xl border border-border overflow-hidden">
<div className="p-5 space-y-4">
<div className="flex items-center gap-3">
<Skeleton className="size-10 rounded-full" />
<div className="space-y-1.5">
<Skeleton className="h-5 w-28" />
<Skeleton className="h-3.5 w-40" />
</div>
</div>
<div className="space-y-1.5">
<Skeleton className="h-3 w-24" />
<Skeleton className="h-4 w-full" />
</div>
<div className="grid grid-cols-2 gap-3">
<Skeleton className="h-16 rounded-xl" />
<Skeleton className="h-16 rounded-xl" />
<Skeleton className="h-16 rounded-xl" />
<Skeleton className="h-16 rounded-xl" />
</div>
</div>
<div className="border-t border-border p-4 space-y-3">
<Skeleton className="h-3 w-32" />
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Skeleton className="h-12 rounded-lg" />
<Skeleton className="h-12 rounded-lg" />
</div>
<div className="space-y-1.5">
<Skeleton className="h-12 rounded-lg" />
<Skeleton className="h-12 rounded-lg" />
</div>
</div>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Bitcoin Address Header
// ---------------------------------------------------------------------------
export function BitcoinAddressHeader({ address }: { address: string }) {
const { addressDetail, btcPrice, isLoading, error, refetch } = useBitcoinAddress(address);
if (isLoading) return <AddressSkeleton />;
if (error || !addressDetail) {
return (
<div className="rounded-2xl border border-border p-6 text-center space-y-3">
<Bitcoin className="size-10 mx-auto text-muted-foreground/40" />
<p className="text-sm text-destructive">Failed to load address</p>
<p className="text-xs text-muted-foreground font-mono break-all">{address}</p>
<Button variant="outline" size="sm" onClick={() => refetch()}>
<RefreshCw className="size-3.5 mr-1.5" />
Retry
</Button>
</div>
);
}
return (
<div className="rounded-2xl border border-border overflow-hidden">
{/* Header */}
<div className="p-5 space-y-4">
<div className="flex items-center gap-3">
<div className="flex items-center justify-center size-10 rounded-full bg-primary/10 text-primary">
<Bitcoin className="size-5" />
</div>
<div>
<h2 className="text-lg font-bold">Bitcoin Address</h2>
<p className="text-xs text-muted-foreground">
{addressDetail.txCount + addressDetail.pendingTxCount} transaction{(addressDetail.txCount + addressDetail.pendingTxCount) !== 1 ? 's' : ''}
</p>
</div>
</div>
{/* Address */}
<div className="space-y-1.5">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Address</p>
<div className="flex items-center gap-2">
<p className="text-sm font-mono text-foreground break-all">{address}</p>
<CopyButton text={address} />
</div>
</div>
{/* Balance hero */}
<div className="rounded-xl bg-secondary/40 p-4 text-center space-y-1">
<p className="text-xs text-muted-foreground uppercase tracking-wider">Balance</p>
<p className="text-3xl font-bold tracking-tight">
{btcPrice ? satsToUSD(addressDetail.totalBalance, btcPrice) : `${formatBTC(addressDetail.totalBalance)} BTC`}
</p>
<p className="text-sm text-muted-foreground">
{formatBTC(addressDetail.totalBalance)} BTC
</p>
{addressDetail.pendingBalance !== 0 && (
<p className="flex items-center justify-center gap-1 text-xs text-orange-500 dark:text-orange-400 pt-1">
<RefreshCw className="size-3 animate-spin" />
{btcPrice
? `${satsToUSD(addressDetail.pendingBalance, btcPrice)} pending`
: `${formatBTC(addressDetail.pendingBalance)} BTC pending`}
</p>
)}
</div>
{/* Stats grid */}
<div className="grid grid-cols-2 gap-3">
<StatCard
icon={<ArrowDownLeft className="size-3.5" />}
label="Total Received"
value={`${formatBTC(addressDetail.totalReceived)} BTC`}
subtitle={btcPrice ? satsToUSD(addressDetail.totalReceived, btcPrice) : undefined}
/>
<StatCard
icon={<ArrowUpRight className="size-3.5" />}
label="Total Sent"
value={`${formatBTC(addressDetail.totalSent)} BTC`}
subtitle={btcPrice ? satsToUSD(addressDetail.totalSent, btcPrice) : undefined}
/>
</div>
</div>
{/* Recent Transactions */}
{addressDetail.recentTxs.length > 0 && (
<div className="border-t border-border">
<div className="px-5 py-3">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Recent Transactions
</p>
</div>
<div className="divide-y divide-border">
{addressDetail.recentTxs.slice(0, 10).map((tx) => (
<AddressTxRow key={tx.txid} tx={tx} btcPrice={btcPrice} />
))}
</div>
{addressDetail.recentTxs.length > 10 && (
<div className="px-5 py-3 text-center">
<p className="text-xs text-muted-foreground">
{addressDetail.txCount - 10} more transaction{addressDetail.txCount - 10 !== 1 ? 's' : ''}
</p>
</div>
)}
</div>
)}
{/* Footer: link to mempool.space */}
<div className="border-t border-border px-5 py-2.5">
<a
href={`https://mempool.space/address/${address}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<Bitcoin className="size-3.5" />
<span>View on mempool.space</span>
<ExternalLink className="size-3" />
</a>
</div>
</div>
);
}
function AddressTxRow({ tx, btcPrice }: { tx: { txid: string; amount: number; type: 'receive' | 'send'; confirmed: boolean; timestamp?: number }; btcPrice?: number }) {
const isReceive = tx.type === 'receive';
return (
<Link
to={`/i/bitcoin:tx:${tx.txid}`}
className="flex items-center justify-between py-3 px-5 hover:bg-muted/50 transition-colors"
>
<div className="flex items-center gap-3">
<div className={`flex items-center justify-center size-8 rounded-full ${
isReceive
? 'bg-green-500/10 text-green-600 dark:text-green-400'
: 'bg-red-500/10 text-red-600 dark:text-red-400'
}`}>
{isReceive ? <ArrowDownLeft className="size-4" /> : <ArrowUpRight className="size-4" />}
</div>
<div>
<p className="text-sm font-medium">{isReceive ? 'Received' : 'Sent'}</p>
<p className="text-xs text-muted-foreground font-mono">{truncateMiddle(tx.txid, 8, 8)}</p>
</div>
</div>
<div className="text-right">
<p className={`text-sm font-medium ${
isReceive ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'
}`}>
{isReceive ? '+' : '-'}{formatBTC(tx.amount)} BTC
</p>
{btcPrice && (
<p className="text-xs text-muted-foreground">
{satsToUSD(tx.amount, btcPrice)}
</p>
)}
</div>
</Link>
);
}
function AddressSkeleton() {
return (
<div className="rounded-2xl border border-border overflow-hidden">
<div className="p-5 space-y-4">
<div className="flex items-center gap-3">
<Skeleton className="size-10 rounded-full" />
<div className="space-y-1.5">
<Skeleton className="h-5 w-32" />
<Skeleton className="h-3.5 w-24" />
</div>
</div>
<div className="space-y-1.5">
<Skeleton className="h-3 w-16" />
<Skeleton className="h-4 w-full" />
</div>
<div className="rounded-xl bg-secondary/40 p-4 space-y-2 flex flex-col items-center">
<Skeleton className="h-3 w-12" />
<Skeleton className="h-9 w-40" />
<Skeleton className="h-4 w-28" />
</div>
<div className="grid grid-cols-2 gap-3">
<Skeleton className="h-16 rounded-xl" />
<Skeleton className="h-16 rounded-xl" />
</div>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Compact previews (used in NoteCard embeds, hover cards, etc.)
// ---------------------------------------------------------------------------
/** Compact preview for a Bitcoin transaction — fetches real data. */
export function BitcoinTxPreview({ txid, link }: { txid: string; link: string }) {
const { tx, btcPrice, isLoading } = useBitcoinTx(txid);
if (isLoading) {
return (
<div className="px-4 py-3 border-b border-border">
<div className="flex items-center gap-3">
<Skeleton className="size-12 rounded-lg shrink-0" />
<div className="flex-1 min-w-0 space-y-1.5">
<Skeleton className="h-3 w-32" />
<Skeleton className="h-4 w-3/4" />
</div>
</div>
</div>
);
}
const amount = tx ? tx.totalOutput : 0;
const fee = tx?.fee ?? 0;
return (
<Link
to={link}
className="flex items-center gap-3 px-4 py-3 border-b border-border hover:bg-secondary/30 transition-colors"
>
<div className="size-12 rounded-lg bg-orange-500/10 flex items-center justify-center shrink-0">
<Bitcoin className="size-5 text-orange-600 dark:text-orange-400" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Bitcoin className="size-3 shrink-0" />
<span>Bitcoin Transaction</span>
{tx && (
<span className={tx.confirmed
? 'text-green-600 dark:text-green-400'
: 'text-yellow-600 dark:text-yellow-400'
}>
{tx.confirmed ? 'Confirmed' : 'Unconfirmed'}
</span>
)}
</div>
<p className="text-sm font-medium truncate mt-0.5">
{tx ? `${satsToBTC(amount)} BTC` : truncateMiddle(txid, 12, 8)}
{tx && btcPrice ? (
<span className="text-muted-foreground font-normal"> ({satsToUSD(amount, btcPrice)})</span>
) : null}
</p>
{tx && (
<p className="text-xs text-muted-foreground truncate">
Fee {formatSats(fee)} sats
{tx.blockHeight ? ` · Block ${tx.blockHeight.toLocaleString()}` : ''}
</p>
)}
</div>
<ExternalLink className="size-4 text-muted-foreground shrink-0" />
</Link>
);
}
/** Compact preview for a Bitcoin address — fetches real data. */
export function BitcoinAddressPreview({ address, link }: { address: string; link: string }) {
const { addressDetail, btcPrice, isLoading } = useBitcoinAddress(address);
if (isLoading) {
return (
<div className="px-4 py-3 border-b border-border">
<div className="flex items-center gap-3">
<Skeleton className="size-12 rounded-lg shrink-0" />
<div className="flex-1 min-w-0 space-y-1.5">
<Skeleton className="h-3 w-28" />
<Skeleton className="h-4 w-3/4" />
</div>
</div>
</div>
);
}
const balance = addressDetail?.totalBalance ?? 0;
const txCount = addressDetail ? addressDetail.txCount + addressDetail.pendingTxCount : 0;
return (
<Link
to={link}
className="flex items-center gap-3 px-4 py-3 border-b border-border hover:bg-secondary/30 transition-colors"
>
<div className="size-12 rounded-lg bg-orange-500/10 flex items-center justify-center shrink-0">
<Bitcoin className="size-5 text-orange-600 dark:text-orange-400" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Bitcoin className="size-3 shrink-0" />
<span>Bitcoin Address</span>
</div>
<p className="text-sm font-medium truncate mt-0.5">
{addressDetail ? `${satsToBTC(balance)} BTC` : truncateMiddle(address, 12, 8)}
{addressDetail && btcPrice ? (
<span className="text-muted-foreground font-normal"> ({satsToUSD(balance, btcPrice)})</span>
) : null}
</p>
{addressDetail && (
<p className="text-xs text-muted-foreground truncate">
{txCount.toLocaleString()} transaction{txCount !== 1 ? 's' : ''}
{' · '}
<span className="font-mono">{truncateMiddle(address, 8, 6)}</span>
</p>
)}
</div>
<ExternalLink className="size-4 text-muted-foreground shrink-0" />
</Link>
);
}
+555 -111
View File
@@ -1,166 +1,610 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { Loader2, Search } from 'lucide-react';
import { nip19 } from 'nostr-tools';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { ClipboardEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { AlertTriangle, Bitcoin, EyeOff, QrCode, X } from 'lucide-react';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Input } from '@/components/ui/input';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { EmojifiedText } from '@/components/CustomEmoji';
import { useAuthor } from '@/hooks/useAuthor';
import { useSearchProfiles, type SearchProfile } from '@/hooks/useSearchProfiles';
import { genUserName } from '@/lib/genUserName';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import {
Popover,
PopoverAnchor,
PopoverContent,
} from '@/components/ui/popover';
import { QrScannerDialog } from '@/components/QrScannerDialog';
import { useToast } from '@/hooks/useToast';
import { parseBitcoinUri, validateBitcoinAddress } from '@/lib/bitcoin';
import {
isSilentPaymentAddress,
validateSilentPaymentAddress,
} from '@/lib/hdwallet/sp/sender';
import { cn } from '@/lib/utils';
// ---------------------------------------------------------------------------
// Public types
// ---------------------------------------------------------------------------
/**
* The resolved recipient produced by {@link BitcoinRecipientInput}.
*
* Either a bare on-chain Bitcoin address (`kind === 'address'`) or a BIP-352
* silent payment address (`kind === 'sp'`). The dialog consumes this shape
* directly when building the PSBT.
*/
export interface ResolvedRecipient {
/**
* For `kind === 'address'`: a validated mainnet on-chain address.
* For `kind === 'sp'`: the `sp1…` string (the real P2TR `P_k` is derived
* at PSBT-build time, after coin selection).
*/
address: string;
/** Recipient kind — determines how the PSBT builder routes the output. */
kind: 'address' | 'sp';
/**
* Raw text the user typed / pasted / scanned. Kept so the picker can
* round-trip a chip back into the input on clear if we ever need it
* (currently unused; the chip just dismisses).
*/
raw: string;
}
// ---------------------------------------------------------------------------
// Candidate extraction
// ---------------------------------------------------------------------------
/**
* Resolve a piece of recipient text into the valid on-chain and/or
* silent-payment candidates it carries.
*
* Handles bare `bc1…` / `sp1…` addresses and `bitcoin:` BIP-21 URIs (which
* may carry an on-chain path, an `sp=` parameter, or both). Returns empty
* strings for whichever kind isn't present/valid. Shared by the live
* input memo and the paste handler so both agree on what counts.
*/
function resolveCandidates(text: string): { btc: string; sp: string } {
const trimmed = text.trim();
if (!trimmed) return { btc: '', sp: '' };
const bip21 = parseBitcoinUri(trimmed);
// On-chain: the URI path (when present) or the raw input. SP addresses
// live in the `sp` field; don't double-count them as on-chain.
const btcRaw = bip21 ? bip21.address : trimmed;
const btc =
btcRaw && !isSilentPaymentAddress(btcRaw) && validateBitcoinAddress(btcRaw)
? btcRaw
: '';
// Silent payment: prefer the URI `sp=` parameter; otherwise the path may
// itself be an sp1 address (rare but legal — `bitcoin:sp1…` is a URI
// without an on-chain fallback), or the raw input is a bare sp1.
const spRaw = bip21 ? (bip21.sp ?? bip21.address) : trimmed;
const sp =
spRaw && isSilentPaymentAddress(spRaw) && validateSilentPaymentAddress(spRaw)
? spRaw
: '';
return { btc, sp };
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
interface BitcoinRecipientInputProps {
value: string;
onChange: (value: string) => void;
/** Currently-selected recipient, or `null` when nothing has been picked. */
value: ResolvedRecipient | null;
/** Called when the user picks a recipient (from the dropdown / QR scan) or clears. */
onChange: (value: ResolvedRecipient | null) => void;
/** Input placeholder text. */
placeholder: string;
resolvedPubkey?: string;
/**
* Optional initial input value applied when the picker mounts with no
* `value`. Used by callers (e.g. campaign donate flow) that want to
* pre-fill a `bitcoin:…` URI or bare address so the donor only needs to
* pick from the dropdown.
*
* Applied on mount only. Clearing a selected chip (value null) returns
* to an empty input rather than restoring the prefill.
*/
initialInput?: string;
}
function shouldSkipProfileSearch(value: string): boolean {
const lower = value.trim().toLowerCase();
if (!lower) return true;
/**
* Recipient input for the Send Bitcoin dialog. Combines a text input, an
* inline QR-scanner button, and a Radix Popover dropdown that surfaces the
* recognised destination(s) extracted from the input.
*
* Recognised destinations:
*
* - Bare on-chain Bitcoin address (any standard mainnet type) "Send to
* Bitcoin address" row.
* - Bare BIP-352 silent payment address (`sp1…`) "Send to silent payment
* address" row.
* - `bitcoin:` BIP-21 URI with an on-chain path and/or an `sp=` parameter
* one row per valid candidate (so a URI carrying both shows two rows and
* the donor picks privacy vs. compatibility).
*
* Clicking a row swaps the input out for a {@link SelectedRecipientChip} via
* `onChange`. Clicking the chip's X button calls `onChange(null)`, which
* returns to the input view.
*
* Anything else (npub, nprofile, free text) is silently ignored there is
* no account search here, by design. The dropdown stays open as long as the
* input holds at least one valid candidate; it doesn't dismiss when the
* input loses focus or the user taps elsewhere. It closes only on selection,
* when the input is cleared, or on Escape.
*/
export function BitcoinRecipientInput({
value,
onChange,
placeholder,
initialInput,
}: BitcoinRecipientInputProps) {
const { t } = useTranslation();
const { toast } = useToast();
// Keep Bitcoin and silent-payment recipients as plain text. npub/nprofile
// intentionally still search so pasted Nostr IDs can resolve to a person row.
return (
lower.startsWith('bc1') ||
lower.startsWith('sp1') ||
lower.startsWith('bitcoin:') ||
lower.startsWith('1') ||
lower.startsWith('3')
);
}
export function BitcoinRecipientInput({ value, onChange, placeholder, resolvedPubkey }: BitcoinRecipientInputProps) {
const [dropdownOpen, setDropdownOpen] = useState(false);
// Local input state. Independent of `value` so the user can keep typing
// after dismissing the dropdown without losing their query, and so the
// chip-cleared view starts blank instead of repopulating the previous
// selection. `initialInput` only seeds the field on first mount —
// clearing the chip (value → null) returns to an empty input, not the
// prefill.
const [query, setQuery] = useState<string>(initialInput ?? '');
const [open, setOpen] = useState(false);
// Tracks whether the popover has been opened at least once for the
// current query. The "choose a payment method" hint suppresses on the
// very first render so callers prefilling the input don't see the hint
// flash for one frame before the auto-open effect runs.
const [hasOpenedForQuery, setHasOpenedForQuery] = useState(false);
const [scannerOpen, setScannerOpen] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const searchQuery = shouldSkipProfileSearch(value) ? '' : value;
const { data: profiles, isFetching } = useSearchProfiles(searchQuery);
const filteredProfiles = useMemo(
() => (profiles ?? []).slice(0, 6),
[profiles],
// ── Candidate extraction ──────────────────────────────────────────────
//
// BIP-21 `bitcoin:` URI handling. If the input is a URI, we route the
// same way the QR scanner does: surface every valid candidate as its own
// row so the user explicitly picks privacy (sp) vs. compatibility
// (on-chain). A raw bc1…/sp1… input falls through here unchanged: `bip21`
// is null and the candidate is just the trimmed query.
const trimmed = query.trim();
const { btc: btcCandidate, sp: spCandidate } = useMemo(
() => resolveCandidates(trimmed),
[trimmed],
);
const hasResults = filteredProfiles.length > 0;
const shouldShowSearch = searchQuery.trim().length > 0;
const hasBtc = !!btcCandidate;
const hasSp = !!spCandidate;
const totalItems = (hasSp ? 1 : 0) + (hasBtc ? 1 : 0);
// Auto-open the dropdown whenever a candidate is available, auto-close on
// empty input.
useEffect(() => {
if (shouldShowSearch && hasResults) {
setDropdownOpen(true);
} else if (!shouldShowSearch) {
setDropdownOpen(false);
if (trimmed.length === 0) {
setOpen(false);
setHasOpenedForQuery(false);
return;
}
}, [hasResults, shouldShowSearch]);
if (hasSp || hasBtc) setOpen(true);
}, [trimmed, hasSp, hasBtc]);
const selectProfile = (profile: SearchProfile) => {
onChange(nip19.npubEncode(profile.pubkey));
setDropdownOpen(false);
inputRef.current?.focus();
};
// Track the first time the popover opens for the current query, so the
// "choose a payment method" hint only appears after the donor has had a
// chance to see (and dismiss) the dropdown — not flash for one paint
// frame between mount and the auto-open effect above.
useEffect(() => {
if (open) setHasOpenedForQuery(true);
}, [open]);
// ── Selection callbacks ───────────────────────────────────────────────
const selectBtc = useCallback(
(address: string) => {
onChange({ address, kind: 'address', raw: query });
setQuery('');
setOpen(false);
inputRef.current?.blur();
},
[onChange, query],
);
const selectSp = useCallback(
(address: string) => {
onChange({ address, kind: 'sp', raw: query });
setQuery('');
setOpen(false);
inputRef.current?.blur();
},
[onChange, query],
);
// ── Mount-time auto-select for single-endpoint prefills ────────────────
//
// When the picker mounts pre-filled (e.g. the campaign "Pay with Agora"
// flow) and `initialInput` resolves to exactly one valid candidate, skip
// the dropdown and select it directly so it lands as a chip. When the
// prefill carries *both* an on-chain address and an sp1 code we leave it
// in the input and let the dropdown surface both rows — that's a genuine
// choice the donor must make (privacy vs. compatibility).
//
// Guarded by a ref so it fires once per mount and never overrides a
// selection the user has already made or a `clear chip → restore prefill`
// transition (the picker is keyed on each open in the dialog, so a fresh
// mount is the right granularity).
const autoSelectedRef = useRef(false);
useEffect(() => {
if (autoSelectedRef.current) return;
autoSelectedRef.current = true;
if (value || !initialInput) return;
if (totalItems !== 1) return;
if (hasSp) {
selectSp(spCandidate);
} else if (hasBtc) {
selectBtc(btcCandidate);
}
// Intentionally mount-only: candidates are derived from `initialInput`
// (via the initial `query`), so reading them here reflects the prefill.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// ── Paste auto-select ──────────────────────────────────────────────────
//
// When the user pastes text that resolves to exactly one valid candidate
// (a bare `bc1…` / `sp1…` address or a single-endpoint `bitcoin:` URI),
// convert it straight into a chip instead of making them click the lone
// dropdown row. A paste carrying *both* an on-chain address and an sp1
// code falls through to the normal dropdown so the donor picks privacy
// vs. compatibility.
//
// We resolve from the pasted text directly because `query` state hasn't
// updated yet inside the paste event. Returning early on a single match
// lets us `preventDefault()` so the input never flickers the raw text.
const handlePaste = useCallback(
(e: ClipboardEvent<HTMLInputElement>) => {
const pasted = e.clipboardData.getData('text');
if (!pasted) return;
const { btc, sp } = resolveCandidates(pasted);
const count = (btc ? 1 : 0) + (sp ? 1 : 0);
if (count !== 1) return; // 0 → let it land as text; 2 → use the dropdown.
e.preventDefault();
if (btc) {
onChange({ address: btc, kind: 'address', raw: pasted.trim() });
} else {
onChange({ address: sp, kind: 'sp', raw: pasted.trim() });
}
setQuery('');
setOpen(false);
inputRef.current?.blur();
},
[onChange],
);
// ── QR scan handling ──────────────────────────────────────────────────
/**
* Interpret a freshly-scanned QR code.
*
* - **BIP-21 URI with valid bc1 *and* sp1** drop the URI into the input
* and open the dropdown so the donor picks between them.
* - **BIP-21 URI with only `sp=` valid** select SP directly (creates
* the chip, bypasses the dropdown).
* - **Bare bitcoin address** select on-chain directly.
* - **Bare `sp1…` address** select SP directly.
* - **Anything else** toast.
*/
const handleScan = useCallback(
(scanned: string) => {
setScannerOpen(false);
const text = scanned.trim();
const parsed = parseBitcoinUri(text);
const candidate = parsed ? parsed.address : text;
const sp = parsed?.sp;
const hasValidBtc = !!candidate && validateBitcoinAddress(candidate);
const hasValidSp =
!!sp && isSilentPaymentAddress(sp) && validateSilentPaymentAddress(sp);
// Both options — show the dropdown.
if (parsed && hasValidBtc && hasValidSp) {
setQuery(text);
setOpen(true);
// Focus is best-effort; on mobile the scanner dialog dismissal will
// already steal focus and the dropdown stays usable via tap.
inputRef.current?.focus();
return;
}
// SP-only via `bitcoin:…?sp=sp1…`.
if (hasValidSp && sp) {
selectSp(sp);
return;
}
// Direct on-chain.
if (hasValidBtc) {
selectBtc(candidate);
return;
}
// Bare sp1 (no `bitcoin:` prefix).
if (
isSilentPaymentAddress(candidate)
&& validateSilentPaymentAddress(candidate)
) {
selectSp(candidate);
return;
}
toast({
title: t('walletSend.scanError.title'),
description: t('walletSend.scanError.description'),
variant: 'destructive',
});
},
[selectBtc, selectSp, t, toast],
);
// ── Chip view ─────────────────────────────────────────────────────────
if (value) {
return (
<SelectedRecipientChip value={value} onClear={() => onChange(null)} />
);
}
// ── Input + dropdown ──────────────────────────────────────────────────
//
// `popoverOpen` derives from the manual `open` flag AND the presence of
// actionable candidates. This prevents an empty/garbage input from
// popping the dropdown.
const popoverOpen = open && totalItems > 0;
return (
<div className="space-y-2">
<Popover open={dropdownOpen} onOpenChange={setDropdownOpen}>
<PopoverTrigger asChild>
<Popover open={popoverOpen} onOpenChange={setOpen}>
<PopoverAnchor asChild>
<div className="relative flex items-center">
<Search className="absolute left-3 size-4 text-muted-foreground pointer-events-none" />
{isFetching && shouldShowSearch && (
<Loader2 className="absolute right-3 size-4 text-muted-foreground animate-spin" />
)}
<Input
ref={inputRef}
id="hd-recipient-input"
value={value}
onChange={(e) => onChange(e.target.value)}
value={query}
onChange={(e) => setQuery(e.target.value)}
onPaste={handlePaste}
// Reopen on focus so a user can recover the dropdown after an
// outside-click dismiss (the value is still in the field).
onFocus={() => {
if (shouldShowSearch && hasResults) setDropdownOpen(true);
if (totalItems > 0) setOpen(true);
}}
// `onFocus` only fires on the first tap; subsequent taps while
// the input is still focused need their own opener so the user
// can reopen the choice list without un-focusing first.
onClick={() => {
if (totalItems > 0) setOpen(true);
}}
placeholder={placeholder}
autoComplete="off"
spellCheck={false}
className="pl-10 pr-10 font-mono text-base md:text-sm"
role="combobox"
aria-expanded={popoverOpen}
aria-haspopup="listbox"
aria-autocomplete="list"
className={cn('font-mono text-base md:text-sm pr-11')}
/>
<button
type="button"
onClick={() => setScannerOpen(true)}
aria-label={t('walletSend.recipient.scan')}
className="absolute right-1 top-1/2 -translate-y-1/2 size-8 rounded-full text-muted-foreground hover:text-foreground hover:bg-secondary/60 flex items-center justify-center motion-safe:transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<QrCode className="size-4" />
</button>
</div>
</PopoverTrigger>
</PopoverAnchor>
<PopoverContent
align="start"
side="bottom"
sideOffset={6}
// Keep typing focus in the input on open/close — Radix's default
// is to focus the popover content, which would steal focus from
// the input and dismiss the mobile keyboard mid-type.
onOpenAutoFocus={(e) => e.preventDefault()}
className="z-[270] w-[var(--radix-popover-trigger-width)] rounded-xl border-border p-0 shadow-lg overflow-hidden"
onCloseAutoFocus={(e) => e.preventDefault()}
// The dropdown is a persistent choice list, not a transient
// hover-popover: it should stay open even when the input loses
// focus or the user taps elsewhere on the page, so blurring out
// doesn't make the candidate rows vanish. We block Radix's
// auto-dismiss-on-outside-interaction and instead close the
// dropdown explicitly — on selection, on a cleared input
// (the auto-open effect), or via Escape (still honored below).
onFocusOutside={(e) => e.preventDefault()}
onPointerDownOutside={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
style={{ width: 'var(--radix-popover-trigger-width)' }}
className="p-0 w-[--radix-popover-trigger-width] max-h-none rounded-xl border border-border bg-popover shadow-lg overflow-hidden"
>
{hasResults && (
<div className="max-h-[200px] overflow-y-auto py-1">
{filteredProfiles.map((profile) => (
<RecipientSearchResult key={profile.pubkey} profile={profile} onClick={selectProfile} />
))}
</div>
)}
<div role="listbox" className="max-h-[280px] overflow-y-auto py-1">
{/* BTC comes before SP the on-chain address is the
broadly-compatible default; the silent-payment option
follows for donors who want privacy. */}
{hasBtc && (
<BtcAddressRow address={btcCandidate} onClick={selectBtc} />
)}
{hasSp && (
<SpAddressRow address={spCandidate} onClick={selectSp} />
)}
</div>
</PopoverContent>
</Popover>
{resolvedPubkey && <ResolvedRecipientPreview pubkey={resolvedPubkey} />}
{/* Picker-closed reminder. When the input holds parseable candidates
but the donor hasn't actually picked one yet typically because
they tapped an amount preset, which counts as an outside-click
and dismisses the popover the Send button is disabled with no
visible reason. Surface an actionable hint that re-opens the
dropdown so the donor doesn't have to guess that they're meant
to tap the recipient input again.
Gated on `hasOpenedForQuery` so the hint doesn't flash for one
paint frame between mount and the auto-open effect on prefilled
inputs (campaign donate flow). */}
{hasOpenedForQuery && !popoverOpen && totalItems > 0 && (
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
setOpen(true);
inputRef.current?.focus();
}}
className="flex items-center gap-1.5 text-xs text-amber-600 dark:text-amber-500 hover:text-amber-700 dark:hover:text-amber-400 motion-safe:transition-colors text-left"
>
<AlertTriangle className="size-3.5 shrink-0" />
<span>{t('walletSend.recipient.choosePaymentMethod')}</span>
</button>
)}
<QrScannerDialog
isOpen={scannerOpen}
onClose={() => setScannerOpen(false)}
onScan={handleScan}
title={t('walletSend.recipient.scan')}
/>
</div>
);
}
function ResolvedRecipientPreview({ pubkey }: { pubkey: string }) {
const author = useAuthor(pubkey);
const metadata = author.data?.metadata;
const displayName = metadata?.display_name || metadata?.name || genUserName(pubkey);
const avatarUrl = sanitizeUrl(metadata?.picture);
const encoded = nip19.npubEncode(pubkey);
const fallbackLabel = `${encoded.slice(0, 12)}${encoded.slice(-8)}`;
// ---------------------------------------------------------------------------
// Dropdown rows
// ---------------------------------------------------------------------------
return (
<div className="flex items-center gap-3 rounded-lg border bg-muted/30 px-3 py-2">
<Avatar className="size-8 shrink-0">
<AvatarImage src={avatarUrl} alt={displayName} />
<AvatarFallback className="bg-primary/20 text-primary text-xs">
{displayName[0]?.toUpperCase() || '?'}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<span className="text-sm font-medium truncate block">{displayName}</span>
<span className="text-xs text-muted-foreground truncate block">
{metadata?.nip05?.startsWith('_@') ? metadata.nip05.slice(2) : metadata?.nip05 || fallbackLabel}
</span>
</div>
</div>
);
/** Truncate long addresses with an ellipsis so they don't overflow the row. */
function truncateAddress(address: string): string {
return address.length > 28
? `${address.slice(0, 14)}${address.slice(-10)}`
: address;
}
function RecipientSearchResult({ profile, onClick }: { profile: SearchProfile; onClick: (profile: SearchProfile) => void }) {
const { metadata, pubkey } = profile;
const displayName = metadata.display_name || metadata.name || genUserName(pubkey);
const avatarUrl = sanitizeUrl(metadata.picture);
function BtcAddressRow({
address,
onClick,
}: {
address: string;
onClick: (address: string) => void;
}) {
const { t } = useTranslation();
return (
<button
type="button"
className="w-full flex items-center gap-3 px-3 py-2 text-left transition-colors cursor-pointer hover:bg-secondary/60"
onClick={() => onClick(profile)}
role="option"
aria-selected={false}
onClick={() => onClick(address)}
// Prevent the input from blurring on mousedown — otherwise the popover
// closes before `onClick` fires and the row never resolves.
onMouseDown={(e) => e.preventDefault()}
className="w-full flex items-center gap-3 px-3 py-2 text-left transition-colors cursor-pointer hover:bg-secondary/60"
>
<Avatar className="size-8 shrink-0">
<AvatarImage src={avatarUrl} alt={displayName} />
<AvatarFallback className="bg-primary/20 text-primary text-xs">
{displayName[0]?.toUpperCase() || '?'}
</AvatarFallback>
</Avatar>
<div className="size-9 shrink-0 rounded-full bg-orange-500/10 flex items-center justify-center">
<Bitcoin className="size-4 text-orange-500" />
</div>
<div className="flex-1 min-w-0">
<span className="text-sm font-medium truncate block">
<EmojifiedText tags={profile.event.tags}>{displayName}</EmojifiedText>
</span>
{metadata.nip05 && (
<span className="text-xs text-muted-foreground truncate block">
{metadata.nip05.startsWith('_@') ? metadata.nip05.slice(2) : metadata.nip05}
</span>
)}
<div className="font-semibold text-sm truncate">
{t('walletSend.recipient.sendToOnchain')}
</div>
<div className="text-xs text-muted-foreground truncate font-mono">
{truncateAddress(address)}
</div>
</div>
</button>
);
}
/**
* Dropdown row for BIP-352 silent payment addresses. We give it a distinct
* label and icon (privacy eye-off) so the user can tell at a glance that
* this is a static, unlinkable address rather than a regular Bitcoin
* scriptPubKey the privacy story is materially different.
*/
function SpAddressRow({
address,
onClick,
}: {
address: string;
onClick: (address: string) => void;
}) {
const { t } = useTranslation();
return (
<button
type="button"
role="option"
aria-selected={false}
onClick={() => onClick(address)}
onMouseDown={(e) => e.preventDefault()}
className="w-full flex items-center gap-3 px-3 py-2 text-left transition-colors cursor-pointer hover:bg-secondary/60"
>
<div className="size-9 shrink-0 rounded-full bg-violet-500/10 flex items-center justify-center">
<EyeOff className="size-4 text-violet-500" />
</div>
<div className="flex-1 min-w-0">
<div className="font-semibold text-sm truncate">
{t('walletSend.recipient.sendToSilentPayment')}
</div>
<div className="text-xs text-muted-foreground truncate font-mono">
{truncateAddress(address)}
</div>
</div>
</button>
);
}
// ---------------------------------------------------------------------------
// Selected recipient chip
// ---------------------------------------------------------------------------
/**
* Compact panel that replaces the input once a recipient has been picked.
* Renders a coloured icon (orange Bitcoin / violet EyeOff for SP), the kind
* label, a truncated monospace address, and an X button that clears the
* selection and returns the user to the input view.
*/
function SelectedRecipientChip({
value,
onClear,
}: {
value: ResolvedRecipient;
onClear: () => void;
}) {
const { t } = useTranslation();
const { address, kind } = value;
const displayName =
kind === 'sp'
? t('walletSend.recipient.silentPayment')
: t('walletSend.recipient.bitcoinAddress');
const subtitle = truncateAddress(address);
return (
<div className="flex items-center gap-3 rounded-2xl border border-border bg-muted/40 pl-2 pr-2 py-1.5 w-full min-w-0 max-w-full">
{kind === 'sp' ? (
<div className="size-9 shrink-0 rounded-full bg-violet-500/10 flex items-center justify-center">
<EyeOff className="size-4 text-violet-500" />
</div>
) : (
<div className="size-9 shrink-0 rounded-full bg-orange-500/10 flex items-center justify-center">
<Bitcoin className="size-4 text-orange-500" />
</div>
)}
<div className="flex-1 min-w-0 overflow-hidden">
<div className="text-[11px] text-muted-foreground leading-tight">
{t('walletSend.recipient.toLabel')}
</div>
<div className="text-sm font-medium truncate">{displayName}</div>
<div className="text-xs text-muted-foreground truncate font-mono">
{subtitle}
</div>
</div>
<button
type="button"
onClick={onClear}
aria-label={t('walletSend.recipient.clear')}
className="p-1.5 rounded-full text-muted-foreground hover:text-foreground hover:bg-secondary/60 transition-colors shrink-0"
>
<X className="size-4" />
</button>
</div>
);
}
+197
View File
@@ -0,0 +1,197 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { AlertTriangle } from 'lucide-react';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import {
getUniqueBitcoinFeeSpeeds,
type BitcoinFeeRates,
type BitcoinFeeSpeed,
} from '@/lib/bitcoinFeeSpeed';
import {
isFeeRecoverable,
type BroadcastErrorKind,
} from '@/lib/bitcoinBroadcastError';
interface BroadcastErrorAlertProps {
/** Classifier output from {@link classifyBroadcastError}. */
error: BroadcastErrorKind;
/** Currently-resolved sat/vB rate, used to decide whether bump can do anything. */
currentFeeRate: number | undefined;
/** Currently-selected fee tier. */
feeSpeed: BitcoinFeeSpeed;
/** Loaded fee rates, used to compute the de-duped preset tier list. */
feeRates: BitcoinFeeRates | undefined;
/** Whether the underlying mutation is in flight (disables actions). */
isPending: boolean;
/** Bump-fee recovery action. */
onBumpFee: () => void;
/** Plain retry recovery action (used for `network` failures). */
onRetry: () => void;
/**
* When `true` the component knows there's no custom-rate input available
* in the consumer (e.g. {@link DonateDialog}), so we hide the bump button
* and surface a static "you're on the fastest tier" message once the
* user is already on the top preset.
*/
presetTiersOnly?: boolean;
}
/**
* Inline alert rendered above a Bitcoin transaction's Send button when a
* broadcast attempt is rejected. The classifier in
* {@link ../lib/bitcoinBroadcastError} maps the raw relay error onto a
* small enum; each kind gets specific copy and, where recovery is
* possible, an action button.
*
* Action button rules:
*
* - **Fee-recoverable kinds** (`feeTooLow`, `mempoolFull`,
* `rbfReplacementFeeTooLow`) get **Use a higher fee**, which calls
* `onBumpFee`. In `presetTiersOnly` consumers, the button is disabled
* when the user is already on the top preset and a separate hint
* suggests donating from an external wallet.
* - **`network`** gets **Try again**, which re-fires the mutation as-is.
* - **Everything else** gets no action button the user has to adjust
* amount or recipient (which the consumer's auto-dismiss effect uses
* to clear the alert) before retrying.
*
* The toast surface is intentionally not used for classified failures.
* Toasts auto-dismiss and are visually disconnected from the fee picker;
* an inline alert directly above Send keeps the recovery in the donor's
* line of sight.
*/
export function BroadcastErrorAlert({
error,
currentFeeRate,
feeSpeed,
feeRates,
isPending,
onBumpFee,
onRetry,
presetTiersOnly,
}: BroadcastErrorAlertProps) {
const { t } = useTranslation();
const { title, body } = useMemo(() => {
switch (error.kind) {
case 'feeTooLow':
return {
title: t('walletSend.broadcastError.feeTooLowTitle'),
body: error.minRelayFeeRate
? t('walletSend.broadcastError.feeTooLowBodyWithMin', { min: error.minRelayFeeRate })
: t('walletSend.broadcastError.feeTooLowBody'),
};
case 'rbfReplacementFeeTooLow':
return {
title: t('walletSend.broadcastError.rbfTitle'),
body: t('walletSend.broadcastError.rbfBody'),
};
case 'mempoolFull':
return {
title: t('walletSend.broadcastError.mempoolFullTitle'),
body: t('walletSend.broadcastError.mempoolFullBody'),
};
case 'network':
return {
title: t('walletSend.broadcastError.networkTitle'),
body: t('walletSend.broadcastError.networkBody'),
};
case 'mempoolConflict':
return {
title: t('walletSend.broadcastError.mempoolConflictTitle'),
body: t('walletSend.broadcastError.mempoolConflictBody'),
};
case 'tooLongChain':
return {
title: t('walletSend.broadcastError.tooLongChainTitle'),
body: t('walletSend.broadcastError.tooLongChainBody'),
};
case 'badInputs':
return {
title: t('walletSend.broadcastError.badInputsTitle'),
body: t('walletSend.broadcastError.badInputsBody'),
};
case 'absurdlyHighFee':
return {
title: t('walletSend.broadcastError.absurdlyHighFeeTitle'),
body: t('walletSend.broadcastError.absurdlyHighFeeBody'),
};
case 'unknown':
default:
return {
title: t('walletSend.broadcastError.unknownTitle'),
// Fall back to the raw bitcoind / framing message so the donor
// (or a support thread) has something concrete to act on. Empty
// when the classifier had no message to preserve.
body: 'raw' in error && error.raw ? error.raw : '',
};
}
}, [error, t]);
// Decide whether the bump-fee CTA is actually useful here. For consumers
// that ship a custom-rate input (the HD wallet flow), the bump is always
// useful — we either jump to a faster preset or escalate to a custom
// rate seeded from the error. For preset-only consumers (the donate
// flow), the button only makes sense while a faster preset exists; once
// the user is on the top preset they need to switch to an external
// wallet.
const uniquePresets = feeRates ? getUniqueBitcoinFeeSpeeds(feeRates) : [];
const isCustom = feeSpeed === 'custom';
const isOnTopPreset =
!isCustom
&& uniquePresets.length > 0
// Cast through the preset union to avoid `.indexOf` narrowing
// `feeSpeed` for the rest of the function body.
&& uniquePresets.indexOf(feeSpeed as Exclude<BitcoinFeeSpeed, 'custom'>) === 0;
const haveFeeHint =
error.kind === 'feeTooLow'
&& !!(error.minRelayFeeRate || error.actualFeeRate);
const showBumpFee = isFeeRecoverable(error.kind) && !(presetTiersOnly && isOnTopPreset);
const showAtMaxHint = presetTiersOnly && isOnTopPreset && isFeeRecoverable(error.kind);
const canBumpUsefully =
!isOnTopPreset || haveFeeHint || isCustom || !!currentFeeRate;
const showRetry = error.kind === 'network';
return (
<Alert variant="destructive" className="py-2.5">
<AlertTriangle className="size-4" />
<AlertTitle className="text-sm">{title}</AlertTitle>
{body && <AlertDescription className="text-xs mt-1">{body}</AlertDescription>}
{showAtMaxHint && (
<AlertDescription className="text-xs mt-1 font-medium">
{t('walletSend.broadcastError.atMaxFeeTier')}
</AlertDescription>
)}
{(showBumpFee || showRetry) && (
<div className="mt-2 flex flex-wrap gap-2">
{showBumpFee && (
<Button
type="button"
size="sm"
variant="outline"
onClick={onBumpFee}
disabled={isPending || !canBumpUsefully}
>
{t('walletSend.broadcastError.useHigherFee')}
</Button>
)}
{showRetry && (
<Button
type="button"
size="sm"
variant="outline"
onClick={onRetry}
disabled={isPending}
>
{t('walletSend.broadcastError.tryAgain')}
</Button>
)}
</div>
)}
</Alert>
);
}
+1 -1
View File
@@ -230,7 +230,7 @@ export function CalendarEventContent({ event, compact, className }: CalendarEven
</div>
) : (
<div className="flex items-center justify-center bg-gradient-to-br from-primary/10 via-primary/5 to-transparent py-8">
<CalendarDays className="h-10 w-10 text-primary/30" />
<CalendarDays className="h-10 w-10 text-primary" />
</div>
)}
+1 -1
View File
@@ -418,7 +418,7 @@ export function CalendarEventDetailPage({ event }: { event: NostrEvent }) {
<img src={image} alt="" className="absolute inset-0 size-full object-cover" />
) : (
<div className="absolute inset-0 flex items-center justify-center">
<CalendarDays className="size-20 text-primary/40" />
<CalendarDays className="size-20 text-primary" />
</div>
)}
<div aria-hidden className="absolute inset-x-0 bottom-0 top-[20%] bg-gradient-to-t from-black/95 via-black/80 to-transparent" />
+37 -33
View File
@@ -2,7 +2,7 @@ import { useMemo } from 'react';
import type { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { CalendarClock, HandHeart, MapPin, ShieldCheck } from 'lucide-react';
import { HandHeart, ShieldCheck } from 'lucide-react';
import { AuthorByline } from '@/components/AuthorByline';
import { Card } from '@/components/ui/card';
@@ -23,17 +23,6 @@ import { formatCampaignAmount, formatUsdGoal, satsToUsd } from '@/lib/formatCamp
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import { cn } from '@/lib/utils';
function formatDeadline(unixSeconds: number): { label: string; isPast: boolean } {
const now = Math.floor(Date.now() / 1000);
const diff = unixSeconds - now;
if (diff <= 0) return { label: 'Ended', isPast: true };
const days = Math.ceil(diff / 86_400);
if (days <= 1) return { label: 'Ends today', isPast: false };
if (days < 30) return { label: `${days} days left`, isPast: false };
const months = Math.round(days / 30);
return { label: `${months} mo left`, isPast: false };
}
/**
* Short helper rendered both inline (cards) and in the detail page.
*
@@ -47,13 +36,33 @@ function CampaignProgress({
raisedSats,
goalUsd,
btcPrice,
isLoading,
className,
}: {
raisedSats: number;
goalUsd?: number;
btcPrice?: number;
/**
* True while the donation totals are still being fetched. The bar gets
* its own skeleton independent of the card, which paints immediately
* so we never flash a misleading "0 raised" before the on-chain balance
* lands. Footprint matches the loaded state (bar row + one text row).
*/
isLoading?: boolean;
className?: string;
}) {
if (isLoading) {
return (
<div className={cn('space-y-1.5', className)}>
<Skeleton className="h-2 w-full" />
<div className="flex items-baseline justify-between gap-2">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-4 w-24" />
</div>
</div>
);
}
const hasGoal = !!goalUsd && goalUsd > 0;
const raisedUsd = satsToUsd(raisedSats, btcPrice);
const pct = hasGoal && raisedUsd !== undefined
@@ -127,6 +136,9 @@ interface CampaignCardProps {
*
* - `compact` default grid item.
* - `featured` hero placement (wider, side-by-side on `sm+`).
* The token is purely visual it names the layout, not a
* curation state and stayed after the moderator-level
* "Featured" concept was retired in favor of curated lists.
* - `shelf` fixed-width card for horizontal scroll rails (e.g. group
* official-activity). Caller no longer hand-rolls the size wrapper.
*/
@@ -148,7 +160,7 @@ export function CampaignCard({ campaign, variant = 'compact', className, footerB
});
const displayCampaign = parseCampaign(translatedEvent) ?? campaign;
const author = useAuthor(campaign.pubkey);
const { data: stats } = useCampaignDonations(campaign);
const { data: stats, isLoading: donationsLoading } = useCampaignDonations(campaign);
const { data: btcPrice } = useBtcPrice();
const naddr = useMemo(() => encodeCampaignNaddr(campaign), [campaign]);
@@ -156,7 +168,6 @@ export function CampaignCard({ campaign, variant = 'compact', className, footerB
const cover = sanitizeUrl(displayCampaign.banner)
?? sanitizeUrl(authorMetadata?.banner)
?? sanitizeUrl(authorMetadata?.picture);
const deadline = campaign.deadline ? formatDeadline(campaign.deadline) : null;
const raisedSats = stats?.totalSats ?? 0;
const countryLabel = getCampaignCountryLabel(campaign);
// SP-only campaigns hide aggregate totals; dual-endpoint campaigns
@@ -181,7 +192,7 @@ export function CampaignCard({ campaign, variant = 'compact', className, footerB
isFeaturedVariant && 'sm:flex-row sm:items-stretch',
)}
>
{/* Cover image. Optional metadata (country, deadline) is
{/* Cover image. Optional metadata (country) is
overlaid on the banner as glass chips so the body below can
stay structurally deterministic. A bottom gradient keeps
the chips legible against any photo; a top scrim does the
@@ -201,14 +212,14 @@ export function CampaignCard({ campaign, variant = 'compact', className, footerB
/>
) : (
<div className="absolute inset-0 flex items-center justify-center">
<HandHeart className="size-12 text-primary/40" />
<HandHeart className="size-12 text-primary" />
</div>
)}
{/* Bottom gradient only present when there are bottom chips
to display, so a banner with no overlays stays visually
clean. */}
{(countryLabel || deadline) && (
{(countryLabel) && (
<div
aria-hidden
className="absolute inset-x-0 bottom-0 h-1/2 bg-gradient-to-t from-black/70 via-black/30 to-transparent"
@@ -220,26 +231,14 @@ export function CampaignCard({ campaign, variant = 'compact', className, footerB
className="absolute inset-x-0 top-0 h-16 bg-gradient-to-b from-black/30 to-transparent"
/>
{/* Bottom-left meta chips — country + deadline. */}
{(countryLabel || deadline) && (
{/* Bottom-left meta chips — country. */}
{(countryLabel) && (
<div className="absolute bottom-3 left-3 z-10 flex flex-wrap items-center gap-1.5 [text-shadow:0_1px_2px_rgba(0,0,0,0.6)]">
{countryLabel && (
<span className="inline-flex items-center gap-1.5 rounded-full bg-black/35 backdrop-blur-md px-2.5 py-1 text-[11px] font-medium text-white">
<MapPin className="size-3.5" />
{countryLabel}
</span>
)}
{deadline && (
<span
className={cn(
'inline-flex items-center gap-1.5 rounded-full bg-black/35 backdrop-blur-md px-2.5 py-1 text-[11px] font-medium text-white',
deadline.isPast && 'bg-destructive/60',
)}
>
<CalendarClock className="size-3.5" />
{deadline.label}
</span>
)}
</div>
)}
@@ -247,7 +246,7 @@ export function CampaignCard({ campaign, variant = 'compact', className, footerB
coord={campaign.aTag}
entityTitle={campaign.title}
surface="campaign"
axes={['approval', 'hide', 'featured']}
axes={['hide']}
badgeSize="default"
className="absolute top-3 right-3 z-10 flex items-center gap-2"
/>
@@ -284,7 +283,12 @@ export function CampaignCard({ campaign, variant = 'compact', className, footerB
{isSilentPayment ? (
<CampaignPrivateNotice goalUsd={campaign.goalUsd} />
) : (
<CampaignProgress raisedSats={raisedSats} goalUsd={campaign.goalUsd} btcPrice={btcPrice} />
<CampaignProgress
raisedSats={raisedSats}
goalUsd={campaign.goalUsd}
btcPrice={btcPrice}
isLoading={donationsLoading}
/>
)}
<div className="flex items-center justify-between gap-3 border-t border-border/60 pt-3 text-xs text-muted-foreground">
+20 -21
View File
@@ -51,14 +51,12 @@ function buildQrPayload(wallets: CampaignWallets): string {
* Inline panel rendering the campaign's wallet endpoints as a scannable
* QR code, a copyable string, and an "Open in wallet" button.
*
* Behavior:
* Behavior the QR and the copyable row always carry a `bitcoin:`
* BIP-21 URI, regardless of which endpoints the campaign exposes:
*
* - **on-chain only** (`bc1q…` / `bc1p…`) BIP-21 QR with the address
* and a copyable row for the raw address.
* - **silent payment only** (`sp1`) raw silent-payment code QR and a
* copyable row for the raw SP code.
* - **both** combined BIP-21 URI in the QR and a single copyable row
* containing the same `bitcoin:<addr>?sp=<sp>` URI; BIP-352-aware
* - **on-chain only** (`bc1q…` / `bc1p…`) `bitcoin:<bc1>`.
* - **silent payment only** (`sp1…`) `bitcoin:?sp=<sp1>`.
* - **both** combined `bitcoin:<bc1>?sp=<sp1>` URI; BIP-352-aware
* wallets pick the SP path automatically, legacy wallets fall back to
* the on-chain address.
*
@@ -72,17 +70,13 @@ export function CampaignWalletDonatePanel({
}: CampaignWalletDonatePanelProps) {
const { t } = useTranslation();
const qrPayload = buildQrPayload(wallets);
const { onchain, sp } = wallets;
// When both endpoints are present, donors copy the same BIP-21 URI
// that the QR encodes — modern wallets parse it in their recipient
// field. When only one endpoint exists, the raw value is friendlier.
const copyValue = onchain && sp ? qrPayload : (onchain?.value ?? sp?.value ?? '');
const copyLabel = onchain && sp
? 'Payment URI'
: sp
? 'Silent-payment code'
: 'Bitcoin address';
// Donors always copy the same BIP-21 URI that the QR encodes — modern
// wallets parse it in their recipient field, and a `bitcoin:` URI
// round-trips through any wallet whether the campaign exposes an
// on-chain address, a silent-payment code, or both.
const copyValue = qrPayload;
const copyLabel = 'Payment URI';
return (
<div className="space-y-5">
@@ -91,17 +85,22 @@ export function CampaignWalletDonatePanel({
Error-correction level H tolerates the centered occlusion
(~30% of modules can be missing and the code still scans). */}
<div className="flex justify-center">
<div className="relative rounded-2xl bg-white p-4 shadow-sm">
<QRCodeCanvas value={qrPayload} size={280} level="H" />
<div className="relative w-full max-w-[280px] rounded-2xl bg-white p-4 shadow-sm">
<QRCodeCanvas
value={qrPayload}
size={280}
level="H"
className="block h-auto w-full"
/>
<div
aria-hidden
className="absolute inset-0 flex items-center justify-center pointer-events-none"
>
<div className="rounded-full bg-primary p-2 ring-[6px] ring-white">
<div className="flex aspect-square w-[28%] items-center justify-center rounded-full bg-primary ring-[6px] ring-white">
<img
src="/logo.svg"
alt=""
className="size-16 object-contain brightness-0 invert"
className="aspect-square w-3/5 object-contain brightness-0 invert"
draggable={false}
/>
</div>
+69
View File
@@ -0,0 +1,69 @@
import { useTranslation } from 'react-i18next';
import { CAMPAIGN_CATEGORIES } from '@/lib/campaignCategories';
import { cn } from '@/lib/utils';
export interface CategoryPickerProps {
/** Set of currently-selected category slugs. */
selected: Set<string>;
/** Called with the slug whenever a pill is tapped. */
onToggle: (slug: string) => void;
}
/**
* Multi-select pill row of curated content categories shared by
* Agora's campaign and group creation flows. Each chip renders a
* Lucide icon + a localized label, and toggling it adds or removes
* that category's slug from the parent's selection set.
*
* The picker has no protocol-level awareness the parent serializes
* each selected slug as an ordinary `['t', slug]` tag, which keeps
* the published events fully readable by any Nostr client that
* already understands content tags.
*
* Layout is a free-flowing `flex flex-wrap` row: each pill sizes to
* its own text, and the row breaks whenever the next pill wouldn't
* fit. The category list is intentionally shared between campaigns
* and groups so the same Lucide vocabulary feels consistent across
* the two flows.
*/
export function CategoryPicker({ selected, onToggle }: CategoryPickerProps) {
const { t } = useTranslation();
return (
// Free-flowing pill row: each chip sizes to its own text, the row
// wraps to a new line whenever the next chip wouldn't fit. Some
// rows naturally land at three pills, others at four — driven by
// the labels' intrinsic widths rather than a fixed column count.
// Each pill is fully rounded with generous horizontal padding so
// it reads as a tag, not a grid cell.
<div className="flex flex-wrap gap-2">
{CAMPAIGN_CATEGORIES.map(({ slug, labelKey, Icon }) => {
const isSelected = selected.has(slug);
return (
<button
key={slug}
type="button"
onClick={() => onToggle(slug)}
aria-pressed={isSelected}
className={cn(
'inline-flex items-center gap-1.5 rounded-full border px-3.5 py-2 text-sm whitespace-nowrap transition-colors motion-safe:transition-shadow',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
isSelected
? 'border-primary bg-primary/10 text-foreground shadow-sm'
: 'border-border bg-background hover:border-primary/40 hover:bg-primary/5 text-muted-foreground hover:text-foreground',
)}
>
<Icon
className={cn(
'size-4 shrink-0',
isSelected ? 'text-primary' : 'text-muted-foreground',
)}
aria-hidden="true"
/>
<span>{t(labelKey)}</span>
</button>
);
})}
</div>
);
}
+2 -2
View File
@@ -989,7 +989,7 @@ function IsbnCommentContext({ identifier, className, prefix }: { identifier: str
/>
) : (
<div className="w-9 h-12 rounded bg-secondary flex items-center justify-center shrink-0">
<BookOpen className="size-4 text-muted-foreground/40" />
<BookOpen className="size-4 text-muted-foreground" />
</div>
)}
<div className="flex-1 min-w-0">
@@ -1070,7 +1070,7 @@ function GathererCardCommentContext({
/>
) : (
<div className="w-9 h-12 rounded bg-secondary flex items-center justify-center shrink-0">
<CardsIcon className="size-4 text-muted-foreground/40" />
<CardsIcon className="size-4 text-muted-foreground" />
</div>
)}
<div className="flex-1 min-w-0">
+5 -4
View File
@@ -31,6 +31,7 @@ import { PostActionBar } from '@/components/PostActionBar';
import { CommentsSection } from '@/components/CommentsSection';
import { DetailCommentComposer } from '@/components/DetailCommentComposer';
import { PinnedCommentHeader } from '@/components/PinnedCommentHeader';
import { StartCampaignLink } from '@/components/StartCampaignLink';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import {
AlertDialog,
@@ -284,7 +285,7 @@ function CalendarEventShelfCard({ event }: { event: NostrEvent }) {
/>
) : (
<div className="absolute inset-0 flex items-center justify-center">
<CalendarDays className="size-12 text-primary/40" />
<CalendarDays className="size-12 text-primary" />
</div>
)}
{dateLabel && (
@@ -515,7 +516,7 @@ function GroupActionColumn({
</div>
<div className="relative grid grid-cols-2 gap-3">
<Link
<StartCampaignLink
to={`/campaigns/new${createQuery}`}
className="group col-span-2 overflow-hidden rounded-2xl border border-primary/20 bg-primary text-primary-foreground shadow-sm transition-all hover:-translate-y-0.5 hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background motion-reduce:hover:translate-y-0"
>
@@ -534,7 +535,7 @@ function GroupActionColumn({
</span>
</div>
</div>
</Link>
</StartCampaignLink>
<Link
to={`/pledges/new${createQuery}`}
@@ -833,7 +834,7 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
<img src={cover} alt="" className="absolute inset-0 size-full object-cover" />
) : (
<div className="absolute inset-0 flex items-center justify-center bg-gradient-to-br from-primary/50 via-primary/25 to-secondary">
<Users className="size-20 text-primary/40" />
<Users className="size-20 text-primary" />
</div>
)}
<div aria-hidden className="absolute inset-x-0 bottom-0 top-[18%] bg-gradient-to-t from-black/95 via-black/80 to-transparent" />
+56 -14
View File
@@ -1,38 +1,76 @@
import { useMemo, useState } from 'react';
import { useId, useMemo, useState } from 'react';
import { useTranslation, Trans } from 'react-i18next';
import { MapPin, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Input } from '@/components/ui/input';
import { CountryFlag } from '@/components/CountryFlag';
import { Input } from '@/components/ui/input';
import { getCountryInfo, searchCountries, type CountryEntry } from '@/lib/countries';
import { cn } from '@/lib/utils';
interface CountrySelectProps {
id: string;
export interface CountrySelectProps {
/** Current free-text query in the input. */
query: string;
/** Currently-selected ISO 3166 code (e.g. "US"). Empty string when none. */
selectedCode: string;
onQueryChange: (value: string) => void;
onSelect: (country: CountryEntry) => void;
onClear: () => void;
/**
* Explicit DOM `id` for the input. Optional a stable `useId()`
* value is generated when not provided. Callers that already wire
* their own form labels (e.g. a `<label htmlFor={…}>` outside the
* picker) should pass a known id; the wizard flows leave it
* auto-generated.
*/
id?: string;
/** Override the localized "Search countries" placeholder. */
placeholder?: string;
/**
* Hide the i18n hint that explains the `i: iso3166:<code>` tag
* we publish. Default `false` the creation flows show it; the
* event-detail dialog hides it because the surrounding card already
* documents the behavior.
*/
hideHint?: boolean;
}
/**
* Combobox-style country picker used across Agora's creation flows
* (campaigns, groups, calendar events, ). Shows a `MapPin` icon, a
* clear button when a value is present, and a dropdown of
* `searchCountries(query)` results with full keyboard support
* (ArrowUp/Down/Enter/Escape).
*
* The selection produces a country code (`onSelect(country.code)`)
* that the parent serializes as `['i', 'iso3166:<CC>']` + `['k',
* 'iso3166']` on its event.
*
* All i18n strings live under the shared `forms.*` namespace so the
* picker drops into any flow without per-page key duplication.
*/
export function CountrySelect({
id,
query,
selectedCode,
onQueryChange,
onSelect,
onClear,
id,
placeholder,
hideHint = false,
}: CountrySelectProps) {
const { t } = useTranslation();
// `useId` gives us a stable, unique pair of ids for the
// combobox/listbox association without forcing the caller to pass
// a name — important when the wizard mounts the picker multiple
// times across step navigations.
const generatedId = useId();
const inputId = id ?? generatedId;
const listboxId = `${inputId}-results`;
const [open, setOpen] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(0);
const selectedCountry = selectedCode ? getCountryInfo(selectedCode) : undefined;
const results = useMemo(() => searchCountries(query), [query]);
const showResults = open && results.length > 0;
const resultsId = `${id}-results`;
const selectCountry = (country: CountryEntry) => {
onSelect(country);
@@ -45,7 +83,7 @@ export function CountrySelect({
<div className="relative">
<MapPin className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
id={id}
id={inputId}
value={query}
onChange={(e) => {
onQueryChange(e.target.value);
@@ -74,14 +112,14 @@ export function CountrySelect({
autoComplete="off"
role="combobox"
aria-expanded={showResults}
aria-controls={resultsId}
aria-controls={listboxId}
/>
{(query || selectedCode) && (
<button
type="button"
onClick={onClear}
className="absolute right-2 top-1/2 rounded-full p-1 -translate-y-1/2 text-muted-foreground hover:bg-muted hover:text-foreground motion-safe:transition-colors"
aria-label="Clear country"
aria-label={t('forms.countryClearAria')}
>
<X className="size-4" />
</button>
@@ -89,7 +127,7 @@ export function CountrySelect({
{showResults && (
<div
id={resultsId}
id={listboxId}
role="listbox"
className="absolute z-20 mt-2 max-h-[200px] w-full overflow-y-auto rounded-xl border border-border bg-popover py-1 shadow-lg"
>
@@ -110,7 +148,7 @@ export function CountrySelect({
<CountryFlag
code={country.code}
emoji={country.flag}
label={`Flag of ${country.name}`}
label={t('forms.flagOfAria', { name: country.name })}
className="text-lg"
/>
</span>
@@ -124,9 +162,13 @@ export function CountrySelect({
)}
</div>
{selectedCountry && (
{selectedCountry && !hideHint && (
<p className="text-xs text-muted-foreground">
Publishes <span className="font-mono text-foreground">i: iso3166:{selectedCode}</span> for country sorting.
<Trans
i18nKey="forms.countryHint"
values={{ code: selectedCode }}
components={{ 0: <span className="font-mono text-foreground" /> }}
/>
</p>
)}
</div>
+73 -8
View File
@@ -2,7 +2,9 @@ import { useEffect, useState } from 'react';
import { ImagePlus, Loader2, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { ImageCropDialog } from '@/components/ImageCropDialog';
import { Input } from '@/components/ui/input';
import { useAppContext } from '@/hooks/useAppContext';
import { useToast } from '@/hooks/useToast';
import { useUploadFile } from '@/hooks/useUploadFile';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
@@ -36,6 +38,24 @@ interface CoverImageFieldProps {
onUploadComplete?: (nip94Tags: string[][]) => void;
/** Optional template gallery shown between the dropzone and the URL input. */
templates?: readonly CoverImageTemplate[];
/**
* Aspect ratio (width / height) the crop dialog enforces. Defaults to
* `3` (3:1 banner). Pass a different value for non-banner cover surfaces
* if/when one appears for now every consumer (campaigns, events,
* communities, actions) renders the cover at roughly 3:1.
*/
cropAspect?: number;
/**
* Maximum long-edge size (px) of the cropped JPEG. Defaults to `1600`
* plenty for 2x retina at typical banner widths while keeping uploads
* well under 1 MB at q=0.92. Pass `0` to disable the cap.
*
* Honored only when the user's `imageQuality` preference is
* `'compressed'` (the default). Users who opt into `'original'` via
* Network Settings get the full-resolution crop with no dimension cap,
* matching the behavior of ComposeBox / ImageUploadField.
*/
cropMaxOutputSize?: number;
}
/**
@@ -54,11 +74,16 @@ interface CoverImageFieldProps {
* anything other than a well-formed https URL that's deliberate, since
* the same value is what gets published in the Nostr event's `image` tag.
*/
export function CoverImageField({ value, onChange, onUploadingChange, onUploadComplete, templates }: CoverImageFieldProps) {
export function CoverImageField({ value, onChange, onUploadingChange, onUploadComplete, templates, cropAspect = 3, cropMaxOutputSize = 1600 }: CoverImageFieldProps) {
const { t } = useTranslation();
const { config } = useAppContext();
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
const { toast } = useToast();
const [isDragging, setIsDragging] = useState(false);
// Crop state holds the object URL of the user's source file while the
// dialog is open. We revoke it on every exit path (confirm, cancel,
// unmount) so blob: URLs don't leak across multiple picks.
const [cropImageSrc, setCropImageSrc] = useState<string | null>(null);
const sanitized = sanitizeUrl(value);
@@ -67,11 +92,13 @@ export function CoverImageField({ value, onChange, onUploadingChange, onUploadCo
}, [isUploading, onUploadingChange]);
/**
* Shared upload path used by both the file-input change handler and
* the drag-and-drop handler. Validates the MIME type up front so a
* stray dragged-in PDF or video doesn't end up posted to Blossom.
* Shared entry point for both the file-input change handler and the
* drag-and-drop handler. Validates the MIME type up front so a stray
* dragged-in PDF or video doesn't open the cropper, then hands off to
* `ImageCropDialog`. The actual Blossom upload happens after the user
* confirms the crop in `handleCropConfirm`.
*/
const uploadCoverFile = async (file: File) => {
const handleSourceFile = (file: File) => {
if (!/^image\/(png|jpeg|webp)$/.test(file.type)) {
toast({
title: 'Unsupported file type',
@@ -80,6 +107,18 @@ export function CoverImageField({ value, onChange, onUploadingChange, onUploadCo
});
return;
}
// Discard any prior object URL before allocating a new one — picking
// a second file without confirming the first would otherwise leak.
if (cropImageSrc) URL.revokeObjectURL(cropImageSrc);
setCropImageSrc(URL.createObjectURL(file));
};
const handleCropConfirm = async (file: File) => {
const src = cropImageSrc;
setCropImageSrc(null);
if (src) URL.revokeObjectURL(src);
// The crop dialog hands back a fully-formed File (JPEG or PNG,
// whichever encoded smaller — see encodeImage in @/lib/resizeImage).
try {
const tags = await uploadFile(file);
const [[, url]] = tags;
@@ -105,6 +144,20 @@ export function CoverImageField({ value, onChange, onUploadingChange, onUploadCo
}
};
const handleCropCancel = () => {
const src = cropImageSrc;
setCropImageSrc(null);
if (src) URL.revokeObjectURL(src);
};
// Revoke any lingering object URL on unmount so a navigation-away
// while the dialog is open doesn't leak the blob.
useEffect(() => {
return () => {
if (cropImageSrc) URL.revokeObjectURL(cropImageSrc);
};
}, [cropImageSrc]);
const handleDragOver = (e: React.DragEvent<HTMLLabelElement>) => {
// Without preventDefault, the browser navigates to the dropped file
// instead of letting our onDrop handler claim it.
@@ -122,13 +175,13 @@ export function CoverImageField({ value, onChange, onUploadingChange, onUploadCo
setIsDragging(false);
};
const handleDrop = async (e: React.DragEvent<HTMLLabelElement>) => {
const handleDrop = (e: React.DragEvent<HTMLLabelElement>) => {
e.preventDefault();
setIsDragging(false);
if (isUploading) return;
const file = e.dataTransfer.files?.[0];
if (!file) return;
await uploadCoverFile(file);
handleSourceFile(file);
};
return (
@@ -187,7 +240,7 @@ export function CoverImageField({ value, onChange, onUploadingChange, onUploadCo
onChange={(e) => {
const file = e.target.files?.[0];
e.currentTarget.value = '';
if (file) void uploadCoverFile(file);
if (file) handleSourceFile(file);
}}
/>
</label>
@@ -230,6 +283,18 @@ export function CoverImageField({ value, onChange, onUploadingChange, onUploadCo
value={value}
onChange={(e) => onChange(e.target.value)}
/>
{cropImageSrc && (
<ImageCropDialog
open
imageSrc={cropImageSrc}
aspect={cropAspect}
maxOutputSize={config.imageQuality === 'compressed' ? (cropMaxOutputSize || undefined) : undefined}
title="Crop cover image"
onCancel={handleCropCancel}
onCrop={handleCropConfirm}
/>
)}
</>
);
}
@@ -133,7 +133,14 @@ export function DiscoverySearchToolbar({
key={value}
checked={sort === value}
onCheckedChange={(checked) => {
// `checked === false` means the user clicked the
// currently-active item — return to the curated
// `default` view (featured-first) rather than leaving
// them stuck on Top/New with no exit affordance now
// that `default` is no longer an exposed option in the
// dropdown.
if (checked) onSortChange(value);
else onSortChange('default');
}}
// The checkbox slot on the left is hidden in favour of an
// explicit `Check` on the right (matches the
+74 -7
View File
@@ -37,6 +37,7 @@ import {
import { Skeleton } from '@/components/ui/skeleton';
import { Textarea } from '@/components/ui/textarea';
import AuthDialog from '@/components/auth/AuthDialog';
import { BroadcastErrorAlert } from '@/components/BroadcastErrorAlert';
import { useAppContext } from '@/hooks/useAppContext';
import { useBitcoinSigner } from '@/hooks/useBitcoinSigner';
import { useCurrentUser } from '@/hooks/useCurrentUser';
@@ -53,6 +54,10 @@ import {
usdToSats,
type FeeRates,
} from '@/lib/bitcoin';
import {
classifyBroadcastError,
type BroadcastErrorKind,
} from '@/lib/bitcoinBroadcastError';
import {
type ParsedCampaign,
} from '@/lib/campaign';
@@ -130,15 +135,30 @@ export function DonateDialog({ campaign, open, onOpenChange, btcPrice }: DonateD
const [comment, setComment] = useState('');
const [feeSpeed, setFeeSpeed] = useState<DonationFeeSpeed>('fastest');
const [result, setResult] = useState<DonateCampaignResult | null>(null);
/**
* Classified failure from the most recent broadcast attempt. Renders as
* an inline {@link BroadcastErrorAlert} above the Send button in the
* confirm step. Cleared when the donor adjusts the fee speed or returns
* to the form step.
*/
const [broadcastError, setBroadcastError] = useState<BroadcastErrorKind | null>(null);
// Reset when the dialog reopens for a fresh donation.
useEffect(() => {
if (open) {
setStep('form');
setResult(null);
setBroadcastError(null);
}
}, [open]);
// Clear the broadcast-error alert whenever the donor adjusts the fee
// speed — the explicit recovery action — so the alert disappears when
// they engage with the picker.
useEffect(() => {
setBroadcastError(null);
}, [feeSpeed]);
const effectiveUsd = customUsd.trim()
? parseUsdInput(customUsd)
: amountUsd;
@@ -171,12 +191,21 @@ export function DonateDialog({ campaign, open, onOpenChange, btcPrice }: DonateD
}
},
onError: (error: unknown) => {
const msg = error instanceof Error ? error.message : String(error);
toast({
title: 'Donation failed',
description: msg,
variant: 'destructive',
});
const classified = classifyBroadcastError(error);
setBroadcastError(classified);
// Inline `<BroadcastErrorAlert>` in the confirm step is the primary
// recovery surface for classified failures; a destructive toast on
// top would just be noise. Keep the toast as a fallback for the
// catch-all `unknown` bucket so the donor always sees *something*
// even when we can't recognise the reject reason.
if (classified.kind === 'unknown') {
const msg = error instanceof Error ? error.message : String(error);
toast({
title: 'Donation failed',
description: msg,
variant: 'destructive',
});
}
},
});
@@ -249,8 +278,21 @@ export function DonateDialog({ campaign, open, onOpenChange, btcPrice }: DonateD
feeSpeed={feeSpeed}
btcPrice={btcPrice}
isPending={donateMutation.isPending}
onBack={() => setStep('form')}
broadcastError={broadcastError}
onBack={() => {
setBroadcastError(null);
setStep('form');
}}
onSubmit={() => donateMutation.mutate()}
onBumpFee={() => {
// Step toward the fastest preset. BITCOIN_FEE_SPEED_ORDER is
// declared fast → slow; index 0 is `fastest`, so "bump" means
// moving toward index 0.
const order: DonationFeeSpeed[] = ['fastest', 'halfHour', 'hour', 'economy'];
const idx = order.indexOf(feeSpeed);
if (idx > 0) setFeeSpeed(order[idx - 1]);
// `useEffect([feeSpeed])` clears the broadcastError alert.
}}
/>
)}
@@ -448,8 +490,12 @@ interface ConfirmViewProps {
feeSpeed: DonationFeeSpeed;
btcPrice: number | undefined;
isPending: boolean;
/** Classified failure from the most recent broadcast attempt, if any. */
broadcastError: BroadcastErrorKind | null;
onBack: () => void;
onSubmit: () => void;
/** Steps `feeSpeed` toward the fastest preset; no-op once at `fastest`. */
onBumpFee: () => void;
}
function ConfirmView({
@@ -460,8 +506,10 @@ function ConfirmView({
feeSpeed,
btcPrice,
isPending,
broadcastError,
onBack,
onSubmit,
onBumpFee,
}: ConfirmViewProps) {
const { user } = useCurrentUser();
const { config } = useAppContext();
@@ -552,6 +600,25 @@ function ConfirmView({
)}
</div>
{/* Classified broadcast failure with an actionable bump-fee recovery.
Sits between the donation rows and the Send button so the donor
sees the alert in the same visual region they're about to tap.
`presetTiersOnly` hides the bump button once they're on the
fastest preset at that point the recommendation is to switch
to an external wallet via the panel on the campaign detail page. */}
{broadcastError && (
<BroadcastErrorAlert
error={broadcastError}
currentFeeRate={feeRatesQuery.data ? feeRateForSpeed(feeRatesQuery.data, feeSpeed) : undefined}
feeSpeed={feeSpeed}
feeRates={feeRatesQuery.data}
isPending={isPending}
onBumpFee={onBumpFee}
onRetry={onSubmit}
presetTiersOnly
/>
)}
<Button
size="lg"
className="w-full"
+1 -1
View File
@@ -183,7 +183,7 @@ function EmbeddedBadgeCard({ event, className }: { event: NostrEvent; className?
/>
) : (
<div className="size-20 rounded-xl bg-gradient-to-br from-primary/10 via-primary/5 to-transparent flex items-center justify-center">
<Award className="size-8 text-primary/30" />
<Award className="size-8 text-primary" />
</div>
)}
</div>
+1 -1
View File
@@ -471,7 +471,7 @@ export function EmojiPackDialog({ open, onOpenChange, editEvent }: EmojiPackDial
} ${emojis.length > 0 ? 'h-20' : 'h-28'} ${isSubmitting ? 'pointer-events-none opacity-50' : ''}`}
>
<div className="flex flex-col items-center gap-1.5 text-muted-foreground">
<Upload className="size-4 opacity-50" />
<Upload className="size-4" />
<span className="text-xs text-center px-4">
Drop images or a folder here, or click to browse
</span>
+4 -5
View File
@@ -486,7 +486,7 @@ export function BookContentHeader({ isbn }: { isbn: string }) {
</div>
) : (
<div className="shrink-0 w-[120px] sm:w-[140px] h-[180px] sm:h-[210px] rounded-lg bg-secondary flex items-center justify-center">
<BookOpen className="size-10 text-muted-foreground/40" />
<BookOpen className="size-10 text-muted-foreground" />
</div>
)}
@@ -873,7 +873,7 @@ export function CountryContentHeader({ code }: { code: string }) {
if (!info) {
return (
<div className="rounded-2xl border border-border p-5 text-center mx-4">
<MapPin className="size-8 mx-auto mb-2 text-muted-foreground/40" />
<MapPin className="size-8 mx-auto mb-2 text-muted-foreground" />
<p className="text-muted-foreground">Unknown country code: {code}</p>
</div>
);
@@ -1199,7 +1199,7 @@ function BookPreview({ isbn, link }: { isbn: string; link: string }) {
/>
) : (
<div className="w-9 h-12 rounded bg-secondary flex items-center justify-center shrink-0">
<BookOpen className="size-4 text-muted-foreground/40" />
<BookOpen className="size-4 text-muted-foreground" />
</div>
)}
@@ -1312,7 +1312,7 @@ export function CommunityPreview({ addr }: { addr: { kind: number; pubkey: strin
/>
) : (
<div className="size-12 rounded-lg bg-primary/10 flex items-center justify-center shrink-0">
<Users className="size-5 text-primary/50" />
<Users className="size-5 text-primary" />
</div>
)}
@@ -1390,4 +1390,3 @@ export function ProfilePreview({ pubkey }: { pubkey: string }) {
// ---------------------------------------------------------------------------
// Addressable event preview (vines, music, articles, etc.)
// ---------------------------------------------------------------------------
+20
View File
@@ -23,6 +23,7 @@ import { isEventMuted } from '@/lib/muteHelpers';
import { SubHeaderBar } from '@/components/SubHeaderBar';
import { TabButton } from '@/components/TabButton';
import { cn } from '@/lib/utils';
import { getEventRelaySource } from '@/lib/relayDebug';
import type { FeedItem } from '@/lib/feedUtils';
type CoreFeedTab = 'follows' | 'network' | 'global' | 'communities' | 'world' | 'agora';
@@ -186,6 +187,25 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
});
}, [isHomeAgoraFeed, mixedFeed.items, rawData?.pages, muteItems]);
useEffect(() => {
if (feedItems.length === 0) return;
console.groupCollapsed('[nostr feed render]', {
feedId,
mode: isHomeAgoraFeed ? homeFeedMode : activeTab,
count: feedItems.length,
});
console.table(feedItems.map((item, index) => ({
index,
relay: getEventRelaySource(item.event.id) ?? 'unknown',
kind: item.event.kind,
id: item.event.id,
created_at: item.event.created_at,
repostedBy: item.repostedBy ?? '',
})));
console.groupEnd();
}, [feedId, isHomeAgoraFeed, homeFeedMode, activeTab, feedItems]);
// Show skeletons while loading.
const showSkeleton = isHomeAgoraFeed
? mixedFeed.isLoading && feedItems.length === 0
+1 -1
View File
@@ -45,7 +45,7 @@ function PackFeedTab({ pubkeys }: { pubkeys: string[] }) {
if (pubkeys.length === 0) {
return (
<div className="py-16 text-center text-muted-foreground">
<Users className="size-8 mx-auto mb-2 opacity-50" />
<Users className="size-8 mx-auto mb-2" />
<p className="text-sm">No members in this pack yet.</p>
</div>
);
+2 -3
View File
@@ -41,7 +41,7 @@ function GifThumbnail({ gif, onClick }: { gif: GifResult; onClick: (gif: GifResu
{/* Error state */}
{error && (
<div className="absolute inset-0 flex items-center justify-center bg-muted rounded-lg">
<ImageOff className="size-5 text-muted-foreground/40" />
<ImageOff className="size-5 text-muted-foreground" />
</div>
)}
@@ -173,7 +173,7 @@ export function GifPicker({ onSelect }: GifPickerProps) {
</div>
) : isError ? (
<div className="flex flex-col items-center justify-center h-48 text-muted-foreground">
<ImageOff className="size-8 mb-2 opacity-40" />
<ImageOff className="size-8 mb-2" />
<p className="text-sm">Failed to load GIFs</p>
<p className="text-xs mt-1">Please try again</p>
</div>
@@ -189,4 +189,3 @@ export function GifPicker({ onSelect }: GifPickerProps) {
</div>
);
}
+1 -1
View File
@@ -237,7 +237,7 @@ export function GiveBadgeDialog({ open, onOpenChange, recipientPubkey, recipient
</div>
) : (
<div className="flex flex-col items-center justify-center py-10 text-muted-foreground text-sm">
<Award className="size-8 mb-2 opacity-30" />
<Award className="size-8 mb-2" />
<p>You haven't created any badges yet.</p>
<p className="text-xs mt-1">Create badges on the <a href="/badges" className="text-primary hover:underline">Badges page</a>.</p>
</div>
+391 -297
View File
@@ -1,7 +1,6 @@
import { useState, useCallback, useMemo, useRef, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { nip19 } from 'nostr-tools';
import {
AlertTriangle,
Check,
@@ -23,9 +22,14 @@ import {
PopoverTrigger,
} from '@/components/ui/popover';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Input } from '@/components/ui/input';
import { BitcoinAmountPicker } from '@/components/BitcoinAmountPicker';
import { BitcoinPublicDisclaimer } from '@/components/BitcoinPublicDisclaimer';
import { BitcoinRecipientInput } from '@/components/BitcoinRecipientInput';
import {
BitcoinRecipientInput,
type ResolvedRecipient,
} from '@/components/BitcoinRecipientInput';
import { BroadcastErrorAlert } from '@/components/BroadcastErrorAlert';
import { HelpTip } from '@/components/HelpTip';
import { cn } from '@/lib/utils';
@@ -37,24 +41,26 @@ import { notificationSuccess } from '@/lib/haptics';
import {
getBitcoinFeeRate,
getUniqueBitcoinFeeSpeeds,
resolveBitcoinFeeRate,
type BitcoinFeeSpeed,
} from '@/lib/bitcoinFeeSpeed';
import {
isLargeAmount,
nostrPubkeyToBitcoinAddress,
satsToUSD,
} from '@/lib/bitcoin';
classifyBroadcastError,
type BroadcastErrorKind,
} from '@/lib/bitcoinBroadcastError';
import { formatSats, isLargeAmount, satsToUSD } from '@/lib/bitcoin';
import {
broadcastBlockbookTx,
fetchFeeRates,
} from '@/lib/hdwallet/blockbook';
import {
buildHdSpendPsbt,
buildHdMaxSpendPsbt,
finalizeHdPsbt,
type HdInput,
type HdSpendableSpUtxo,
type HdSpendableUtxo,
parseHdRecipient,
previewHdMaxSpend,
previewHdFee,
signHdPsbt,
} from '@/lib/hdwallet/transaction';
@@ -64,78 +70,10 @@ import { useQuery } from '@tanstack/react-query';
// Constants
// ---------------------------------------------------------------------------
const USD_PRESETS = [1, 5, 10, 25, 100];
const USD_PRESETS = [5, 10, 25, 100];
type FeeSpeed = BitcoinFeeSpeed;
// ---------------------------------------------------------------------------
// Recipient resolution
// ---------------------------------------------------------------------------
interface ResolvedRecipient {
/**
* Final P2TR/P2WPKH/etc. address used as the PSBT output.
*
* For silent-payment (`sp1…`) recipients this is the original `sp1…`
* string the real on-chain `P_k` is derived at build time, after coin
* selection. The dialog never displays this value directly when
* `kind === 'sp'`; it's kept here so {@link buildHdSpendPsbt} can route
* by recipient kind.
*/
address: string;
/** Optional Nostr pubkey when the recipient was an npub/nprofile. */
pubkey?: string;
/** Raw text the user typed (for re-display). */
raw: string;
/**
* Recipient kind. `'address'` for bare Bitcoin addresses (including
* Nostr-derived ones); `'sp'` for BIP-352 silent-payment addresses.
*/
kind: 'address' | 'sp';
}
/**
* Parse the recipient input as one of:
* - bare Bitcoin address (mainnet, any standard type)
* - silent-payment address (`sp1…`, mainnet, v0)
* - npub1 P2TR derived from the Nostr pubkey
* - nprofile1 P2TR derived from the encoded pubkey
*
* Returns `null` for unparseable input. The caller should treat `null` as
* "input still in progress" rather than "error" until the user submits.
*/
function resolveRecipient(input: string): ResolvedRecipient | null {
const trimmed = input.trim();
if (!trimmed) return null;
// Try bare Bitcoin / silent-payment via the unified parser.
const parsed = parseHdRecipient(trimmed);
if (parsed) {
if (parsed.kind === 'address') {
return { address: parsed.address, raw: trimmed, kind: 'address' };
}
return { address: parsed.spAddress, raw: trimmed, kind: 'sp' };
}
// Try NIP-19 npub / nprofile.
if (trimmed.startsWith('npub1') || trimmed.startsWith('nprofile1')) {
try {
const decoded = nip19.decode(trimmed);
if (decoded.type === 'npub') {
const address = nostrPubkeyToBitcoinAddress(decoded.data);
if (address) return { address, pubkey: decoded.data, raw: trimmed, kind: 'address' };
} else if (decoded.type === 'nprofile') {
const address = nostrPubkeyToBitcoinAddress(decoded.data.pubkey);
if (address) return { address, pubkey: decoded.data.pubkey, raw: trimmed, kind: 'address' };
}
} catch {
// fall through
}
}
return null;
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
@@ -146,30 +84,20 @@ interface HDSendBitcoinDialogProps {
/** BTC/USD price — passed in to avoid duplicate fetches. */
btcPrice?: number;
/**
* Optional recipient (bare address, `sp1…` code, npub, or `bitcoin:` URI)
* to prefill the recipient field with when the dialog opens. Used by
* callers like the campaign detail page that already know the
* destination, so the donor only needs to enter an amount.
* Optional initial recipient string to prefill the recipient field. May
* be a bare on-chain address (`bc1…`), a silent payment address
* (`sp1…`), or a `bitcoin:` BIP-21 URI. When the URI carries both an
* on-chain path and an `sp=` parameter, the picker's dropdown surfaces
* both as separate rows so the donor explicitly picks which payment
* path to use (no separate "swap" toggle needed picking happens in
* the dropdown).
*
* The field stays editable donors can clear and retype if they want
* to send somewhere else. The prefill applies on each open transition
* (false true) so reopening after a successful send loads the same
* destination again.
* The prefill is re-applied on each open transition (false true) so
* reopening after a successful send loads the same destination again,
* and is also re-applied when the user clears a previously-selected
* chip so they don't have to retype.
*/
initialRecipient?: string;
/**
* Optional *alternate* recipient. When supplied alongside
* {@link initialRecipient}, the dialog shows a small "Use silent
* payment instead" / "Use on-chain address instead" toggle under the
* recipient field. Clicking it swaps the input between the two values
* so donors can pick whichever the campaign provides without leaving
* the modal.
*
* Campaign detail page wires the on-chain `bc1…` address as
* {@link initialRecipient} (default) and the silent-payment `sp1…`
* code as `initialRecipientAlt`.
*/
initialRecipientAlt?: string;
}
interface SendResult {
@@ -194,7 +122,7 @@ interface SendResult {
* the HD wallet's UTXO set across many addresses, signs with per-input HD-derived
* keys, and emits change to a fresh internal address.
*/
export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipient, initialRecipientAlt }: HDSendBitcoinDialogProps) {
export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipient }: HDSendBitcoinDialogProps) {
const { t } = useTranslation();
const availability = useHdWalletAccess();
const {
@@ -217,34 +145,53 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipien
halfHour: t('walletSend.feeSpeed.halfHour'),
hour: t('walletSend.feeSpeed.hour'),
economy: t('walletSend.feeSpeed.economy'),
custom: t('walletSend.feeSpeed.custom'),
}),
[t],
);
// ── Form state ───────────────────────────────────────────────
const [recipientInput, setRecipientInput] = useState('');
//
// The picker owns its input text internally and emits a resolved
// recipient (or null) to us. We only see the final picked destination.
const [recipient, setRecipient] = useState<ResolvedRecipient | null>(null);
const [usdAmount, setUsdAmount] = useState<number | string>(5);
const [sendMax, setSendMax] = useState(false);
const [feeSpeed, setFeeSpeed] = useState<FeeSpeed>('halfHour');
/** Raw text for the custom sat/vB rate input (only used when feeSpeed === 'custom'). */
const [customFeeRate, setCustomFeeRate] = useState('');
const [error, setError] = useState('');
const [feePopoverOpen, setFeePopoverOpen] = useState(false);
const [success, setSuccess] = useState<SendResult | null>(null);
/**
* Classified failure from the most recent broadcast attempt. Renders as an
* inline {@link BroadcastErrorAlert} above the Send button with a recovery
* action (typically "Use a higher fee"). Cleared automatically whenever
* the user adjusts any field that could plausibly resolve the failure,
* and on every successful submit.
*/
const [broadcastError, setBroadcastError] = useState<BroadcastErrorKind | null>(null);
const feeSpeedUserChanged = useRef(false);
const recipient = useMemo(() => resolveRecipient(recipientInput), [recipientInput]);
// ── Fee rates ────────────────────────────────────────────────
const { data: feeRates } = useQuery({
const {
data: feeRates,
isLoading: feeRatesLoading,
isError: feeRatesError,
refetch: refetchFeeRates,
} = useQuery({
queryKey: ['blockbook-fee-rates', blockbookBaseUrl],
queryFn: ({ signal }) => fetchFeeRates(blockbookBaseUrl, signal),
enabled: isOpen && isReady,
staleTime: 30_000,
});
const currentFeeRate = useMemo(() => {
if (!feeRates) return undefined;
return getBitcoinFeeRate(feeRates, feeSpeed);
}, [feeRates, feeSpeed]);
const currentFeeRate = useMemo(
() => resolveBitcoinFeeRate(feeSpeed, feeRates, customFeeRate),
[feeSpeed, feeRates, customFeeRate],
);
// ── Owned UTXO set ───────────────────────────────────────────
//
@@ -284,6 +231,11 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipien
return Math.round((usd / btcPrice) * 100_000_000);
}, [usdAmount, btcPrice]);
const maxSpend = useMemo(
() => (currentFeeRate ? previewHdMaxSpend(ownedInputs, currentFeeRate) : null),
[ownedInputs, currentFeeRate],
);
// ── Fee estimate (matches the actual coin selection) ────────
//
// Crucially we do NOT use `ownedInputs.length` as the input count: an HD
@@ -296,18 +248,21 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipien
return previewHdFee(ownedInputs, amountSats, currentFeeRate);
}, [ownedInputs, currentFeeRate, amountSats]);
const totalSats = amountSats + estimatedFeeSats;
const effectiveAmountSats = sendMax ? (maxSpend?.amountSats ?? 0) : amountSats;
const effectiveFeeSats = sendMax ? (maxSpend?.fee ?? 0) : estimatedFeeSats;
const totalSats = effectiveAmountSats + effectiveFeeSats;
// `previewHdFee` returns 0 when the coin selector can't cover `amount + fee`.
// Treat that as insufficient so the UI doesn't claim a 0-sat fee is fine.
const selectionFailed =
amountSats > 0 && !!currentFeeRate && ownedInputs.length > 0 && estimatedFeeSats === 0;
const selectionFailed = sendMax
? !!currentFeeRate && ownedInputs.length > 0 && !maxSpend
: amountSats > 0 && !!currentFeeRate && ownedInputs.length > 0 && estimatedFeeSats === 0;
const insufficient = selectionFailed || (totalBalance > 0 && totalSats > totalBalance);
const showBalance = insufficient || (amountSats > 0 && totalBalance === 0);
// Auto-tune fee speed to keep fees < 40% of the send amount, unless the
// user has manually overridden.
useEffect(() => {
if (feeSpeedUserChanged.current) return;
if (sendMax) return;
if (!ownedInputs.length || !feeRates || amountSats <= 0) return;
const uniqueSpeeds = getUniqueBitcoinFeeSpeeds(feeRates);
@@ -320,12 +275,14 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipien
if (fee > 0 && fee <= threshold) { target = speed; break; }
}
setFeeSpeed((prev) => (prev === target ? prev : target));
}, [amountSats, feeRates, ownedInputs, totalBalance]);
}, [amountSats, feeRates, ownedInputs, sendMax, totalBalance]);
const handleFeeSpeedChange = useCallback((speed: FeeSpeed) => {
feeSpeedUserChanged.current = true;
setFeeSpeed(speed);
setFeePopoverOpen(false);
// Keep the popover open for 'custom' so the user can type a rate; close
// it for preset tiers since the choice is complete.
if (speed !== 'custom') setFeePopoverOpen(false);
}, []);
// ── Two-tap arm + raw-address disclaimer ─────────────────────
@@ -333,62 +290,26 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipien
// SP recipients (`sp1…`) produce a fresh, unlinkable Taproot output per
// payment — they do NOT have the privacy concern of a reused on-chain
// address. The public disclaimer is only needed for bare BTC addresses
// typed in directly (no Nostr identity attached, no SP).
const isRawAddress =
!!recipient && recipient.kind === 'address' && !recipient.pubkey;
// picked from the dropdown (no SP).
const isRawAddress = !!recipient && recipient.kind === 'address';
const [confirmArmed, setConfirmArmed] = useState(false);
const [acknowledgedPublic, setAcknowledgedPublic] = useState(false);
// Recipient swap target. When the caller supplied two alternate
// destinations (e.g. campaign detail page passing both `bc1…` and
// `sp1…` from the campaign's wallet endpoints) and the current input
// still matches one of them, expose a one-tap toggle to swap. If the
// donor manually edited the field to something else, the toggle hides
// itself so we don't trash their typed input.
//
// Lives next to `isRawAddress` so the render block can place the
// toggle adjacent to the privacy disclaimer (its natural home — both
// are about whether to expose a reusable on-chain address).
const recipientSwap = useMemo<{ swapTo: string; labelKey: string } | null>(() => {
if (!initialRecipient || !initialRecipientAlt) return null;
const trimmed = recipientInput.trim();
let swapTo: string | null = null;
if (trimmed === initialRecipient) swapTo = initialRecipientAlt;
else if (trimmed === initialRecipientAlt) swapTo = initialRecipient;
if (!swapTo) return null;
// The label tells the user *what they're switching to* — detect by
// prefix so an `sp1…` swap target advertises silent payment.
const labelKey = swapTo.toLowerCase().startsWith('sp1')
? 'walletSend.recipient.useSilentPayment'
: 'walletSend.recipient.useOnchain';
return { swapTo, labelKey };
}, [initialRecipient, initialRecipientAlt, recipientInput]);
useEffect(() => {
setConfirmArmed(false);
}, [amountSats, currentFeeRate, btcPrice, recipient?.address]);
}, [effectiveAmountSats, currentFeeRate, btcPrice, recipient?.address]);
// Reset the privacy acknowledgement only when the recipient changes —
// not when the user adjusts the amount or fee tier. Toggling between
// fee speeds should not silently uncheck the warning.
useEffect(() => {
setAcknowledgedPublic(false);
}, [recipient?.address]);
// Prefill the recipient field on every dialog open transition (closed →
// open). Callers like the campaign donate column already know the
// destination, so the donor only needs to type an amount. The field
// stays editable; reopening after a send re-applies the prefill instead
// of remembering the cleared value. Tracked via a ref so reopens with a
// stable string still re-apply (the value alone can't tell us whether
// the dialog has reopened).
// Track open transitions so we can re-key the picker on each
// closed → open transition. Re-keying remounts the picker with a fresh
// `initialInput`, restoring the prefilled recipient after a successful
// send (which closes and reopens with the same prefill).
const [openCount, setOpenCount] = useState(0);
const wasOpenRef = useRef(false);
useEffect(() => {
if (isOpen && !wasOpenRef.current && initialRecipient) {
setRecipientInput(initialRecipient);
if (isOpen && !wasOpenRef.current) {
setOpenCount((c) => c + 1);
}
wasOpenRef.current = isOpen;
}, [isOpen, initialRecipient]);
}, [isOpen]);
const requiresArm = isLarge || isRawAddress;
@@ -402,32 +323,57 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipien
}
if (!recipient) throw new Error(t('walletSend.errors.enterRecipient'));
if (!ownedInputs.length) throw new Error(t('walletSend.errors.noSpendable'));
if (!feeRates) throw new Error(t('walletSend.errors.feesNotLoaded'));
if (recipient.pubkey === availability.pubkey) throw new Error(t('walletSend.errors.cantSendToSelf'));
if (amountSats <= 0) throw new Error(t('walletSend.errors.enterAmount'));
if (feeSpeed !== 'custom' && !feeRates) throw new Error(t('walletSend.errors.feesNotLoaded'));
if (effectiveAmountSats <= 0) throw new Error(t('walletSend.errors.enterAmount'));
if (insufficient) throw new Error(t('walletSend.errors.insufficient'));
const rate = getBitcoinFeeRate(feeRates, feeSpeed);
const rate = resolveBitcoinFeeRate(feeSpeed, feeRates, customFeeRate);
if (!rate || rate < 1) throw new Error(t('walletSend.errors.feeRateTooLow'));
const nextChangeIndex = scan?.change.firstUnusedIndex ?? 0;
const resolvedRecipient = recipient.kind === 'sp'
? { kind: 'sp' as const, spAddress: recipient.address }
: { kind: 'address' as const, address: recipient.address };
setProgress('building');
const built = buildHdSpendPsbt({
account: availability.account,
inputs: ownedInputs,
recipient:
recipient.kind === 'sp'
? { kind: 'sp', spAddress: recipient.address }
: { kind: 'address', address: recipient.address },
amountSats,
feeRate: rate,
nextChangeIndex,
seed: availability.seed,
});
let psbtHex: string;
let fee: number;
let sentAmountSats = effectiveAmountSats;
let inputDescriptors: Parameters<typeof signHdPsbt>[1];
let consumedSpUtxos: Array<{ txid: string; vout: number }>;
if (sendMax) {
const built = buildHdMaxSpendPsbt({
account: availability.account,
inputs: ownedInputs,
recipient: resolvedRecipient,
feeRate: rate,
seed: availability.seed,
});
psbtHex = built.psbtHex;
fee = built.fee;
sentAmountSats = built.amountSats;
inputDescriptors = built.inputDescriptors;
consumedSpUtxos = built.consumedSpUtxos;
} else {
const built = buildHdSpendPsbt({
account: availability.account,
inputs: ownedInputs,
recipient: resolvedRecipient,
amountSats: effectiveAmountSats,
feeRate: rate,
nextChangeIndex,
seed: availability.seed,
});
psbtHex = built.psbtHex;
fee = built.fee;
inputDescriptors = built.inputDescriptors;
consumedSpUtxos = built.consumedSpUtxos;
}
setProgress('signing');
const signedHex = signHdPsbt(
built.psbtHex,
built.inputDescriptors,
psbtHex,
inputDescriptors,
availability.account,
availability.seed,
);
@@ -436,12 +382,11 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipien
setProgress('broadcasting');
const txid = await broadcastBlockbookTx(blockbookBaseUrl, txHex);
return { txid, amountSats, fee: built.fee, consumedSpUtxos: built.consumedSpUtxos };
return { txid, amountSats: sentAmountSats, fee, consumedSpUtxos };
},
onSuccess: (result) => {
notificationSuccess();
setSuccess(result);
queryClient.invalidateQueries({ queryKey: ['hdwallet-scan'] });
// Remove the SP UTXOs we just spent from local storage and
// republish the NIP-78 doc. Blockbook's xpub scan can't see SP
// outputs, so without this the spent UTXOs would linger forever:
@@ -453,28 +398,124 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipien
if (result.consumedSpUtxos.length > 0) {
pruneSpentSilentPaymentUtxos(result.consumedSpUtxos);
}
// Refresh after pruning so transaction history can classify mixed
// BIP-86 + SP sends with the spent SP outpoints already archived.
queryClient.invalidateQueries({ queryKey: ['hdwallet-scan'] });
void refetchWallet();
},
onError: (err) => {
toast({ title: t('walletSend.toast.failedTitle'), description: err.message, variant: 'destructive' });
const classified = classifyBroadcastError(err);
setBroadcastError(classified);
// Force a re-arm on every failure so the donor explicitly re-confirms
// after seeing the error — without this, a second tap of an
// already-armed Send would immediately re-broadcast with the same
// (rejected) parameters.
setConfirmArmed(false);
// The inline alert is the primary surface for classified errors;
// a toast on top would be noisy. Keep the toast only for the
// catch-all `unknown` bucket so something always surfaces even when
// we can't recognise the reject reason.
if (classified.kind === 'unknown') {
toast({
title: t('walletSend.toast.failedTitle'),
description: err.message,
variant: 'destructive',
});
}
},
onSettled: () => setProgress('idle'),
});
// Clear the broadcast-error alert as soon as the donor adjusts anything
// that could plausibly resolve the failure. Recipient / amount / fee rate
// changes are the obvious cases; we don't clear on a btcPrice tick alone
// because that's just a passive refresh.
useEffect(() => {
setBroadcastError(null);
}, [recipient?.address, effectiveAmountSats, feeSpeed, customFeeRate]);
/**
* Recovery action for fee-related broadcast failures.
*
* Strategy:
* - If the user is on a preset and a faster preset exists in the
* *deduped* tier list, jump to it.
* - Otherwise (already on the fastest tier, or only one unique tier
* loaded), switch to a custom rate seeded from the strongest hint
* we have: the parsed minRelayFee from the error, the parsed actual
* rate * 1.5, or the current fastest preset + 1. Open the fee popover
* so the donor can see the new rate and tweak it further.
*
* Either way: refetch fee rates, mark the picker as user-touched (so the
* auto-tune effect doesn't override the bump on the next render), clear
* the broadcast-error alert, and reset `confirmArmed`.
*/
const bumpFeeForRetry = useCallback(() => {
feeSpeedUserChanged.current = true;
setConfirmArmed(false);
setBroadcastError(null);
void refetchFeeRates();
const uniqueSpeeds = feeRates ? getUniqueBitcoinFeeSpeeds(feeRates) : [];
const presetIndex = uniqueSpeeds.indexOf(feeSpeed as Exclude<FeeSpeed, 'custom'>);
if (feeSpeed !== 'custom' && presetIndex > 0) {
// A faster preset exists — jump to it.
setFeeSpeed(uniqueSpeeds[presetIndex - 1]);
return;
}
// Either at the fastest preset already, or on `custom`. Fall back to
// a custom rate using the strongest available hint.
const fastestPresetRate = feeRates?.fastestFee ?? 1;
const fromError =
broadcastError?.kind === 'feeTooLow'
? (broadcastError.minRelayFeeRate ?? broadcastError.actualFeeRate)
: undefined;
const seed = (() => {
if (broadcastError?.kind === 'feeTooLow' && broadcastError.minRelayFeeRate) {
// +1 sat/vB over the network minimum so we clear it comfortably.
return Math.max(broadcastError.minRelayFeeRate + 1, fastestPresetRate);
}
if (fromError) {
// No minimum surfaced but we know the rejected rate — 1.5× as a
// safe escalation step.
return Math.max(Math.ceil(fromError * 1.5), fastestPresetRate + 1);
}
// No usable hint — nudge above the current fastest tier.
const current = currentFeeRate ?? fastestPresetRate;
return Math.max(current + 1, fastestPresetRate + 1);
})();
setFeeSpeed('custom');
setCustomFeeRate(String(Math.max(1, Math.ceil(seed))));
setFeePopoverOpen(true);
}, [
broadcastError,
currentFeeRate,
feeRates,
feeSpeed,
refetchFeeRates,
]);
const handleSend = useCallback(() => {
setError('');
if (availability.status !== 'available') {
setError(t('walletSend.errors.unavailable')); return;
}
if (!recipient) { setError(t('walletSend.errors.enterRecipient')); return; }
if (recipient.pubkey === availability.pubkey) { setError(t('walletSend.errors.cantSendToSelf')); return; }
if (!btcPrice) { setError(t('walletSend.errors.waitingPrice')); return; }
if (amountSats <= 0) { setError(t('walletSend.errors.enterAmount')); return; }
if (effectiveAmountSats <= 0) { setError(t('walletSend.errors.enterAmount')); return; }
if (!ownedInputs.length) { setError(t('walletSend.errors.noneYet')); return; }
if (insufficient) { setError(t('walletSend.errors.insufficient')); return; }
if (isRawAddress && !acknowledgedPublic) {
setError(t('walletSend.errors.acknowledgePrivacy')); return;
if (!currentFeeRate || currentFeeRate < 1) {
setError(
feeSpeed === 'custom'
? t('walletSend.errors.feeRateTooLow')
: t('walletSend.errors.feesNotLoadedYet'),
);
return;
}
if (insufficient) { setError(t('walletSend.errors.insufficient')); return; }
if (requiresArm && !confirmArmed) { setConfirmArmed(true); return; }
sendMutation.mutate();
}, [
@@ -482,11 +523,11 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipien
availability,
recipient,
btcPrice,
amountSats,
effectiveAmountSats,
ownedInputs.length,
currentFeeRate,
feeSpeed,
insufficient,
isRawAddress,
acknowledgedPublic,
requiresArm,
confirmArmed,
sendMutation,
@@ -498,12 +539,15 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipien
onClose();
// defer to allow exit animation
setTimeout(() => {
setRecipientInput('');
setRecipient(null);
setUsdAmount(5);
setSendMax(false);
setError('');
setFeeSpeed('halfHour');
setCustomFeeRate('');
setConfirmArmed(false);
setAcknowledgedPublic(false);
setSuccess(null);
setBroadcastError(null);
feeSpeedUserChanged.current = false;
}, 200);
}, [onClose, sendMutation.isPending]);
@@ -519,6 +563,7 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipien
}
}
if (confirmArmed) return t('walletSend.tapAgainToConfirm');
if (insufficient) return t('walletSend.notEnoughBitcoin');
return t('walletSend.send');
})();
@@ -526,10 +571,15 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipien
sendMutation.isPending ||
!recipient ||
!btcPrice ||
amountSats <= 0 ||
effectiveAmountSats <= 0 ||
insufficient ||
!ownedInputs.length ||
(isRawAddress && !acknowledgedPublic);
!currentFeeRate ||
currentFeeRate < 1;
const maxAmountLabel = sendMax && effectiveAmountSats > 0 && btcPrice
? `${satsToUSD(effectiveAmountSats, btcPrice)} · ${t('walletSend.success.satsAmount', { sats: formatSats(effectiveAmountSats) })}`
: undefined;
// ── Render ───────────────────────────────────────────────────
return (
@@ -564,117 +614,46 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipien
<div className="grid gap-4 px-4 py-4 w-full overflow-y-auto">
<BitcoinAmountPicker
usdAmount={usdAmount}
onUsdAmountChange={setUsdAmount}
onUsdAmountChange={(amount) => {
setSendMax(false);
setUsdAmount(amount);
}}
presets={USD_PRESETS}
maxLabel={t('walletSend.max')}
maxSelected={sendMax}
maxDisabled={!ownedInputs.length || !currentFeeRate || !maxSpend}
onMaxSelect={() => {
setError('');
setSendMax(true);
}}
insufficient={insufficient}
satsLabel={amountSats > 0 && btcPrice
? t('walletSend.approxSats', { sats: amountSats.toLocaleString() })
: undefined}
onAmountChangeStart={() => setError('')}
satsLabel={maxAmountLabel}
onAmountChangeStart={() => {
setError('');
setSendMax(false);
}}
/>
{/* Recipient */}
<div className="grid gap-1">
<label className="text-xs text-muted-foreground" htmlFor="hd-recipient-input">
{t('walletSend.recipient.label')}
</label>
<BitcoinRecipientInput
value={recipientInput}
onChange={setRecipientInput}
placeholder={t('walletSend.recipient.placeholder')}
resolvedPubkey={recipient?.pubkey}
/>
{recipient && (
<p className="text-xs text-muted-foreground">
{recipient.kind === 'sp'
? t('walletSend.recipient.sendingSp')
: recipient.pubkey
? t('walletSend.recipient.sendingNostr')
: t('walletSend.recipient.sendingRaw')}
</p>
)}
</div>
{/* Recipient text input + Popover dropdown surfacing the
BIP-21 candidates, with an inline QR-scanner button. The
picker swaps itself out for a chip once a destination is
selected; clicking the chip's X returns to the input. */}
<BitcoinRecipientInput
key={openCount}
value={recipient}
onChange={(next) => {
setRecipient(next);
setError('');
}}
placeholder={t('walletSend.recipient.placeholder')}
initialInput={initialRecipient}
/>
{/* Privacy disclaimer for raw addresses + companion
swap-to-silent-payment toggle. Both are about whether
the donation will land on a reusable, publicly-tied
address; grouping them lets a donor who reads the
warning flip straight to SP without hunting for the
control. When the disclaimer is absent (recipient is
already SP or a Nostr identity), the toggle still
renders so the donor can swap back to on-chain. */}
{(isRawAddress || recipientSwap) && (
<div className="grid gap-2">
{isRawAddress && (
<BitcoinPublicDisclaimer
acknowledged={acknowledgedPublic}
onAcknowledgedChange={setAcknowledgedPublic}
/>
)}
{recipientSwap && (
<button
type="button"
onClick={() => setRecipientInput(recipientSwap.swapTo)}
className="self-start text-xs text-primary hover:underline motion-safe:transition-colors"
>
{t(recipientSwap.labelKey)}
</button>
)}
</div>
)}
{/* Fee speed */}
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">{t('walletSend.networkFee')}</span>
<Popover open={feePopoverOpen} onOpenChange={setFeePopoverOpen}>
<PopoverTrigger asChild>
<button
type="button"
className="flex items-center gap-1.5 hover:text-foreground transition-colors text-muted-foreground tabular-nums"
>
{estimatedFeeSats > 0 && btcPrice ? (
<> {satsToUSD(estimatedFeeSats, btcPrice)}</>
) : currentFeeRate ? (
<>{t('walletSend.satPerVB', { rate: currentFeeRate })}</>
) : (
<></>
)}
<span className="opacity-60">·</span>
{feeSpeedLabels[feeSpeed]}
</button>
</PopoverTrigger>
<PopoverContent className="w-44 p-1" align="end">
<div className="grid gap-0.5">
{getUniqueBitcoinFeeSpeeds(feeRates).map((speed) => (
<button
key={speed}
type="button"
onClick={() => handleFeeSpeedChange(speed)}
className={cn(
'flex justify-between items-center px-3 py-1.5 rounded-md text-xs hover:bg-muted/50 transition-colors',
feeSpeed === speed && 'bg-muted',
)}
>
<span>{feeSpeedLabels[speed]}</span>
{feeRates && (
<span className="text-muted-foreground tabular-nums">
{t('walletSend.satPerVB', { rate: getBitcoinFeeRate(feeRates, speed) })}
</span>
)}
</button>
))}
</div>
</PopoverContent>
</Popover>
</div>
{showBalance && totalBalance > 0 && btcPrice && (
<p className="text-xs text-muted-foreground text-center">
{t('walletSend.available', {
usd: satsToUSD(totalBalance, btcPrice),
sats: totalBalance.toLocaleString(),
})}
</p>
{/* Privacy disclaimer for raw on-chain addresses. SP
recipients produce a fresh unlinkable output per payment
and don't need the warning. */}
{isRawAddress && (
<BitcoinPublicDisclaimer tone="soft" />
)}
{/* Error */}
@@ -685,6 +664,24 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipien
</Alert>
)}
{/* Classified broadcast failure with an actionable recovery.
Replaces the older raw-toast UX so the donor can see why
the network rejected the tx (fee too low, mempool full,
RBF replacement underpriced, etc.) AND act on it without
guessing. Cleared automatically the moment they touch a
field that could resolve the failure. */}
{broadcastError && (
<BroadcastErrorAlert
error={broadcastError}
currentFeeRate={currentFeeRate}
feeSpeed={feeSpeed}
feeRates={feeRates}
isPending={sendMutation.isPending}
onBumpFee={bumpFeeForRetry}
onRetry={() => sendMutation.mutate()}
/>
)}
{/* Send button */}
<Button
type="button"
@@ -698,6 +695,103 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipien
{sendMutation.isPending && <Loader2 className="size-4 mr-2 animate-spin" />}
{sendButtonLabel}
</Button>
{/* Fee speed under the Send button, centered. The label is
implicit: the only thing in the dialog you'd open a popover
for here is the network-fee tier. */}
<div className="flex justify-center text-xs">
<Popover open={feePopoverOpen} onOpenChange={setFeePopoverOpen}>
<PopoverTrigger asChild>
<button
type="button"
className="flex items-center gap-1.5 hover:text-foreground transition-colors text-muted-foreground tabular-nums"
>
{effectiveFeeSats > 0 && btcPrice ? (
<> {satsToUSD(effectiveFeeSats, btcPrice)}</>
) : currentFeeRate ? (
<>{t('walletSend.satPerVB', { rate: currentFeeRate })}</>
) : feeRatesLoading && feeSpeed !== 'custom' ? (
<>{t('walletSend.fee.loading')}</>
) : feeRatesError && feeSpeed !== 'custom' ? (
<>{t('walletSend.fee.unavailable')}</>
) : (
<></>
)}
<span className="opacity-60">·</span>
{feeSpeed === 'custom' && currentFeeRate
? t('walletSend.satPerVB', { rate: currentFeeRate })
: feeSpeedLabels[feeSpeed]}
</button>
</PopoverTrigger>
<PopoverContent className="w-56 p-1" align="center">
<div className="grid gap-0.5">
{feeRatesError && (
<div className="px-3 py-1.5 text-xs text-muted-foreground">
<p className="text-destructive">{t('walletSend.fee.loadFailed')}</p>
<button
type="button"
onClick={() => refetchFeeRates()}
className="mt-1 underline hover:text-foreground transition-colors"
>
{t('walletSend.fee.retry')}
</button>
<p className="mt-1">{t('walletSend.fee.orCustom')}</p>
</div>
)}
{feeRatesLoading && !feeRatesError && (
<div className="flex items-center gap-2 px-3 py-1.5 text-xs text-muted-foreground">
<Loader2 className="size-3 animate-spin" />
{t('walletSend.fee.loadingTiers')}
</div>
)}
{feeRates && getUniqueBitcoinFeeSpeeds(feeRates).map((speed) => (
<button
key={speed}
type="button"
onClick={() => handleFeeSpeedChange(speed)}
className={cn(
'flex justify-between items-center px-3 py-1.5 rounded-md text-xs hover:bg-muted/50 transition-colors',
feeSpeed === speed && 'bg-muted',
)}
>
<span>{feeSpeedLabels[speed]}</span>
<span className="text-muted-foreground tabular-nums">
{t('walletSend.satPerVB', { rate: getBitcoinFeeRate(feeRates, speed) })}
</span>
</button>
))}
{/* Custom fee rate */}
<button
type="button"
onClick={() => handleFeeSpeedChange('custom')}
className={cn(
'flex justify-between items-center px-3 py-1.5 rounded-md text-xs hover:bg-muted/50 transition-colors',
feeSpeed === 'custom' && 'bg-muted',
)}
>
<span>{feeSpeedLabels.custom}</span>
</button>
{feeSpeed === 'custom' && (
<div className="flex items-center gap-1.5 px-3 py-1.5">
<Input
type="number"
inputMode="decimal"
min={1}
step={1}
autoFocus
value={customFeeRate}
onChange={(e) => setCustomFeeRate(e.target.value)}
placeholder={t('walletSend.fee.customPlaceholder')}
className="h-7 text-xs"
aria-label={t('walletSend.fee.customAriaLabel')}
/>
<span className="text-xs text-muted-foreground whitespace-nowrap">sat/vB</span>
</div>
)}
</div>
</PopoverContent>
</Popover>
</div>
</div>
</>
)}
+164
View File
@@ -0,0 +1,164 @@
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Loader2, Search } from 'lucide-react';
import { Input } from '@/components/ui/input';
import {
Dialog,
DialogContent,
DialogDescription,
DialogTitle,
} from '@/components/ui/dialog';
import { getAllLucideIcons } from '@/lib/lucideIconRegistry';
import { cn } from '@/lib/utils';
interface IconPickerProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** Currently-selected icon name, used to highlight the active cell. */
value?: string;
/** Called with the chosen icon's PascalCase name when the user picks one. */
onSelect: (name: string) => void;
}
type IconEntry = {
name: string;
Component: React.ComponentType<{ className?: string; 'aria-hidden'?: boolean }>;
};
/**
* Searchable picker over every named Lucide icon.
*
* The icon set is loaded on demand via {@link getAllLucideIcons}, which
* dynamically imports `lucide-react` once per session and emits the whole
* library as a separate Vite chunk. Until the chunk resolves the dialog
* shows a spinner; subsequent opens read from the cached promise and are
* effectively instant.
*
* **Search semantics.** Case-insensitive substring match against the
* icon's PascalCase name with the camel-case word boundaries flattened
* into spaces so `arrow up` matches `ArrowUp`. Empty query shows the
* full registry.
*
* **Rendering.** A windowed grid via plain CSS we render the filtered
* results up to a soft cap (`MAX_VISIBLE`) so the DOM stays manageable
* for unfiltered queries. With ~1500 icons total this caps the picker
* at a few hundred initial cells; typing narrows the set quickly.
*/
const MAX_VISIBLE = 600;
export function IconPicker({ open, onOpenChange, value, onSelect }: IconPickerProps) {
const { t } = useTranslation();
const [icons, setIcons] = useState<IconEntry[] | null>(null);
const [query, setQuery] = useState('');
useEffect(() => {
if (!open) return;
if (icons) return;
let cancelled = false;
getAllLucideIcons()
.then((all) => {
if (cancelled) return;
setIcons(all);
})
.catch(() => {
if (cancelled) return;
setIcons([]);
});
return () => {
cancelled = true;
};
}, [open, icons]);
const filtered = useMemo<IconEntry[]>(() => {
if (!icons) return [];
const q = query.trim().toLowerCase().replace(/\s+/g, '');
if (!q) return icons.slice(0, MAX_VISIBLE);
const out: IconEntry[] = [];
for (const entry of icons) {
// Flatten name to lowercase for substring match.
if (entry.name.toLowerCase().includes(q)) {
out.push(entry);
if (out.length >= MAX_VISIBLE) break;
}
}
return out;
}, [icons, query]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="max-w-2xl max-h-[80dvh] rounded-2xl flex flex-col overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
<DialogTitle>{t('campaigns.lists.iconPicker.title')}</DialogTitle>
<DialogDescription className="text-sm text-muted-foreground">
{t('campaigns.lists.iconPicker.description')}
</DialogDescription>
<div className="relative">
<Search
className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground pointer-events-none"
aria-hidden
/>
<Input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t('campaigns.lists.iconPicker.search')}
aria-label={t('campaigns.lists.iconPicker.search')}
className="pl-9"
autoFocus
/>
</div>
<div className="flex-1 min-h-0 overflow-y-auto -mx-6 px-6">
{icons === null ? (
<div className="flex items-center justify-center py-16 text-muted-foreground">
<Loader2 className="size-5 animate-spin" />
</div>
) : filtered.length === 0 ? (
<div className="py-16 text-center text-sm text-muted-foreground">
{t('campaigns.lists.iconPicker.empty')}
</div>
) : (
<div
className="grid gap-1.5 py-2"
style={{
gridTemplateColumns: 'repeat(auto-fill, minmax(80px, 1fr))',
}}
>
{filtered.map(({ name, Component }) => {
const isSelected = name === value;
return (
<button
key={name}
type="button"
onClick={() => {
onSelect(name);
onOpenChange(false);
}}
title={name}
aria-pressed={isSelected}
className={cn(
'flex flex-col items-center justify-center gap-1 rounded-lg border px-2 py-3 motion-safe:transition-colors',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
isSelected
? 'border-primary bg-primary/10 text-primary'
: 'border-transparent hover:border-border hover:bg-accent text-foreground',
)}
>
<Component className="size-5" aria-hidden />
<span className="text-[10px] leading-tight text-muted-foreground truncate max-w-full">
{name}
</span>
</button>
);
})}
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}
+29 -30
View File
@@ -5,43 +5,32 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '
import { Button } from '@/components/ui/button';
import { Slider } from '@/components/ui/slider';
import { ZoomIn, ZoomOut, RotateCcw } from 'lucide-react';
import { encodeImage } from '@/lib/resizeImage';
interface ImageCropDialogProps {
open: boolean;
imageSrc: string;
aspect: number;
title?: string;
/**
* Cap on the output's long edge, in pixels. When the selected crop
* region in source-pixel space exceeds this, the canvas downscales with
* `drawImage`'s built-in bilinear filter. Omit (or pass `0`) to preserve
* the source-pixel crop size 1:1 historical behavior, kept as the
* default so existing callers (avatar/banner in ProfileSettings) aren't
* silently down-rezzed without opting in.
*/
maxOutputSize?: number;
onCancel: () => void;
onCrop: (croppedBlob: Blob) => void;
/**
* Receives the cropped result as a `File` (JPEG or PNG, whichever
* encoded smaller see `encodeImage` in `@/lib/resizeImage`). The
* mime/extension on the file reflects the winning format.
*/
onCrop: (croppedFile: File) => void;
}
async function getCroppedBlob(imageSrc: string, pixelCrop: Area): Promise<Blob> {
const image = await createImageBitmap(await (await fetch(imageSrc)).blob());
const canvas = document.createElement('canvas');
canvas.width = pixelCrop.width;
canvas.height = pixelCrop.height;
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('Canvas context unavailable');
ctx.drawImage(
image,
pixelCrop.x,
pixelCrop.y,
pixelCrop.width,
pixelCrop.height,
0,
0,
pixelCrop.width,
pixelCrop.height,
);
return new Promise<Blob>((resolve, reject) => {
canvas.toBlob((blob) => {
if (blob) resolve(blob);
else reject(new Error('Failed to create blob'));
}, 'image/jpeg', 0.92);
});
}
export function ImageCropDialog({ open, imageSrc, aspect, title = 'Crop Image', onCancel, onCrop }: ImageCropDialogProps) {
export function ImageCropDialog({ open, imageSrc, aspect, title = 'Crop Image', maxOutputSize, onCancel, onCrop }: ImageCropDialogProps) {
const [crop, setCrop] = useState<Point>({ x: 0, y: 0 });
const [zoom, setZoom] = useState(1);
const [croppedAreaPixels, setCroppedAreaPixels] = useState<Area | null>(null);
@@ -60,8 +49,18 @@ export function ImageCropDialog({ open, imageSrc, aspect, title = 'Crop Image',
if (!croppedAreaPixels) return;
setIsProcessing(true);
try {
const blob = await getCroppedBlob(imageSrc, croppedAreaPixels);
onCrop(blob);
// Delegate to the shared encoder so cover-image crops pick up the
// same JPEG-vs-PNG comparison and quality defaults used by the
// upload paths in ComposeBox / ImageUploadField. JPEG quality stays
// at the lib default (0.85) — the previous 0.92 was set when this
// dialog was JPEG-only and not coordinating with the rest of the
// app's resize pipeline.
const { file } = await encodeImage(imageSrc, {
crop: croppedAreaPixels,
maxOutputSize,
filename: 'cropped',
});
onCrop(file);
} finally {
setIsProcessing(false);
}
+1 -1
View File
@@ -147,7 +147,7 @@ export function ImageUploadField({
</>
) : (
<div className="flex flex-col items-center gap-2 px-4 text-muted-foreground">
<Upload className="size-5 opacity-50" />
<Upload className="size-5" />
<span className="text-xs">{uploadText}</span>
</div>
)}
+1 -1
View File
@@ -148,7 +148,7 @@ export function LiveStreamChat({ aTag, className }: LiveStreamChatProps) {
</div>
) : messages.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<MessageCircle className="size-8 text-muted-foreground/40 mb-2" />
<MessageCircle className="size-8 text-muted-foreground mb-2" />
<p className="text-sm text-muted-foreground">No messages yet</p>
<p className="text-xs text-muted-foreground/60 mt-1">Be the first to say something!</p>
</div>
+1 -1
View File
@@ -195,7 +195,7 @@ export function LiveStreamPage({ event }: LiveStreamPageProps) {
) : (
<div className="aspect-video lg:rounded-2xl bg-muted flex items-center justify-center border-y lg:border border-border">
<div className="text-center space-y-2">
<Radio className="size-8 text-muted-foreground/40 mx-auto" />
<Radio className="size-8 text-muted-foreground mx-auto" />
<p className="text-sm text-muted-foreground">
{status === 'planned' ? 'Stream has not started yet' : 'No stream URL available'}
</p>
+64
View File
@@ -0,0 +1,64 @@
import { useEffect, useState } from 'react';
import { List as ListFallback } from 'lucide-react';
import { getLucideIcon } from '@/lib/lucideIconRegistry';
import { cn } from '@/lib/utils';
interface LucideIconProps {
/** PascalCase Lucide icon name (e.g. `"Heart"`). */
name: string;
/** Optional className passed through to the rendered icon. */
className?: string;
/** Optional aria-label; defaults to hidden from assistive tech. */
ariaLabel?: string;
}
/**
* Renders a Lucide icon resolved by name at runtime. The icon registry is
* loaded via a single shared dynamic import (see `lucideIconRegistry.ts`),
* so the whole icon set lives in a separate Vite chunk and only pays its
* bundle cost once per session.
*
* **Fallback.** While the registry resolves, and for any name that fails
* to resolve (event published with an icon we don't recognize), the
* generic `List` icon is rendered already statically imported by other
* parts of the app, so the fallback never causes a layout shift waiting
* for a network round-trip.
*/
export function LucideIcon({ name, className, ariaLabel }: LucideIconProps) {
// `Component` starts at `null` so the first paint always uses the
// fallback. Once the dynamic import resolves, the matching component
// takes over. We deliberately don't suspend on the import — the
// fallback is a perfectly serviceable icon and suspending would force
// every list pill to wait for the same chunk before anything renders.
const [Component, setComponent] = useState<React.ComponentType<{
className?: string;
'aria-hidden'?: boolean;
'aria-label'?: string;
}> | null>(null);
useEffect(() => {
let cancelled = false;
setComponent(null);
getLucideIcon(name)
.then((c) => {
if (cancelled) return;
setComponent(() => c);
})
.catch(() => {
// Network error loading the chunk — keep fallback.
});
return () => {
cancelled = true;
};
}, [name]);
const Icon = Component ?? ListFallback;
return (
<Icon
className={cn(className)}
aria-hidden={!ariaLabel}
aria-label={ariaLabel}
/>
);
}
+3 -3
View File
@@ -114,7 +114,7 @@ function TrackDetail({ event }: { event: NostrEvent }) {
<img src={proxy(parsed.artwork, 320)} alt={parsed.title} className="w-full h-full object-cover" onError={() => setImgError(true)} />
) : (
<div className="w-full h-full flex items-center justify-center bg-primary/10">
<Music className="size-12 text-primary/30" />
<Music className="size-12 text-primary" />
</div>
)}
</div>
@@ -399,7 +399,7 @@ function PlaylistDetail({ event }: { event: NostrEvent }) {
<img src={proxy(coverArt, 320)} alt={parsed?.title ?? ''} className="w-full h-full object-cover" onError={() => setImgError(true)} />
) : (
<div className="w-full h-full flex items-center justify-center bg-primary/10">
<FallbackIcon className="size-12 text-primary/30" />
<FallbackIcon className="size-12 text-primary" />
</div>
)}
</div>
@@ -575,7 +575,7 @@ function PlaylistTrackRow({
<img src={proxy(parsed.artwork, 96)} alt={parsed.title} className="size-full object-cover" loading="lazy" onError={() => setImgError(true)} />
) : (
<div className="size-full flex items-center justify-center bg-primary/10">
<Music className="size-5 text-primary/30" />
<Music className="size-5 text-primary" />
</div>
)}
</div>
+3 -1
View File
@@ -6,6 +6,7 @@ import type { NostrSigner } from '@nostrify/types';
import { useAppContext } from '@/hooks/useAppContext';
import { getEffectiveRelays, DITTO_RELAYS, DIVINE_RELAY, ZAPSTORE_RELAY } from '@/lib/appRelays';
import { NostrBatcher } from '@/lib/NostrBatcher';
import { DebugRelay } from '@/lib/relayDebug';
interface NostrProviderProps {
children: React.ReactNode;
@@ -71,7 +72,7 @@ const NostrProvider: React.FC<NostrProviderProps> = (props) => {
if (!pool.current) {
pool.current = new NPool({
open(url: string) {
return new NRelay1(url, {
const relay = new NRelay1(url, {
// NIP-42: Respond to relay AUTH challenges by signing a kind
// 22242 ephemeral event with the current user's signer.
auth: async (challenge: string) => {
@@ -90,6 +91,7 @@ const NostrProvider: React.FC<NostrProviderProps> = (props) => {
});
},
});
return new DebugRelay(url, relay);
},
reqRouter(filters: NostrFilter[]): Map<URL['href'], NostrFilter[]> {
const routes = new Map<string, NostrFilter[]>();
+8
View File
@@ -367,6 +367,14 @@ export function NostrSync() {
changed = true;
}
if (
encryptedSettings.translateWorkerUrl &&
encryptedSettings.translateWorkerUrl !== current.translateWorkerUrl
) {
updates.translateWorkerUrl = encryptedSettings.translateWorkerUrl;
changed = true;
}
// Return the same reference if nothing changed to prevent re-render
return changed ? updates : current;
});
+15 -2
View File
@@ -107,6 +107,7 @@ import { timeAgo } from "@/lib/timeAgo";
import { formatNumber } from "@/lib/formatNumber";
import { publishedAtAction } from "@/lib/publishedAtAction";
import { getEffectiveStreamStatus } from "@/lib/streamStatus";
import { getEventRelaySource } from "@/lib/relayDebug";
import { cn } from "@/lib/utils";
import { hasGoalZapSplits } from "@/lib/goalUtils";
@@ -424,6 +425,18 @@ export const NoteCard = memo(function NoteCard({
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
const [replyOpen, setReplyOpen] = useState(false);
useEffect(() => {
console.debug('[nostr note render]', {
relay: getEventRelaySource(event.id) ?? 'unknown',
kind: event.kind,
id: event.id,
actionRelay: actionEvent ? getEventRelaySource(actionEvent.id) ?? 'unknown' : undefined,
actionKind: actionEvent?.kind,
actionId: actionEvent?.id,
path: window.location.pathname,
});
}, [event.id, event.kind, actionEvent]);
// Check if the current user can zap this event's author
// TODO: Enable zapping split-recipient NIP-75 goals once zap split payments are supported.
const canZapAuthor = user && canZap(actionMetadata) && !hasGoalZapSplits(actionTarget);
@@ -899,7 +912,7 @@ export const NoteCard = memo(function NoteCard({
<div className="flex-1" />
<button
className="inline-flex items-center justify-center h-9 w-9 rounded-full text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors sidebar:hidden"
className="inline-flex items-center justify-center h-9 w-9 rounded-full text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors"
title={t('feed.actions.share')}
onClick={async (e) => {
e.stopPropagation();
@@ -1854,7 +1867,7 @@ const KIND_HEADER_MAP: Record<number, KindHeaderConfig> = {
icon: HandHeart,
action: (event) => publishedAtKey(event, { created: "noteCard.kindHeader.campaignLaunched", updated: "noteCard.kindHeader.campaignUpdated", fallback: "noteCard.kindHeader.campaignFallback" }),
noun: "noteCard.kindHeader.campaignNoun",
nounRoute: "/campaigns/all",
nounRoute: "/campaigns",
},
8: {
icon: Award,
+62 -13
View File
@@ -44,9 +44,11 @@ import { EmojifiedText } from '@/components/CustomEmoji';
import { ReportDialog } from '@/components/ReportDialog';
import { CommunityReportDialog } from '@/components/CommunityReportDialog';
import { AddToListDialog } from '@/components/AddToListDialog';
import { CampaignListMembershipDialog } from '@/components/campaign-lists/CampaignListMembershipDialog';
import { useNostr } from '@nostrify/react';
import { useBookmarks } from '@/hooks/useBookmarks';
import { usePinnedNotes } from '@/hooks/usePinnedNotes';
import { useCampaignListActions } from '@/hooks/useCampaignListActions';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAuthor } from '@/hooks/useAuthor';
import { useMuteList } from '@/hooks/useMuteList';
@@ -61,6 +63,7 @@ import { type CommunityMenuContext, canBanTarget, getViewerAuthority } from '@/l
// `useCommunityModerationContext()`. Parents install a
// `CommunityModerationContext.Provider` to enable community-aware menu items.
import { isAdmin } from '@/lib/admins';
import { CAMPAIGN_KIND } from '@/lib/campaign';
import { genUserName } from '@/lib/genUserName';
import { timeAgo } from '@/lib/timeAgo';
import { toast } from '@/hooks/useToast';
@@ -210,6 +213,7 @@ export function NoteMoreMenu({ event, open, onOpenChange }: NoteMoreMenuProps) {
const [reportOpen, setReportOpen] = useState(false);
const [banContentOpen, setBanContentOpen] = useState(false);
const [addToListOpen, setAddToListOpen] = useState(false);
const [addToCampaignListOpen, setAddToCampaignListOpen] = useState(false);
const [eventJsonOpen, setEventJsonOpen] = useState(false);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
@@ -232,6 +236,16 @@ export function NoteMoreMenu({ event, open, onOpenChange }: NoteMoreMenuProps) {
const nip19Id = encodeEventNip19(event);
// Campaign-specific membership-dialog inputs. Only meaningful when
// `event.kind === CAMPAIGN_KIND`; the dialog row that uses them is
// gated inside the menu content the same way.
const isCampaign = event.kind === CAMPAIGN_KIND;
const campaignDTag = event.tags.find(([n]) => n === 'd')?.[1] ?? '';
const campaignCoord = isCampaign
? `${CAMPAIGN_KIND}:${event.pubkey}:${campaignDTag}`
: '';
const campaignTitle = event.tags.find(([n]) => n === 'title')?.[1] ?? '';
const handleDelete = () => {
const dTag = event.tags.find(([name]) => name === 'd')?.[1];
deleteEvent(
@@ -268,6 +282,10 @@ export function NoteMoreMenu({ event, open, onOpenChange }: NoteMoreMenuProps) {
onOpenChange(false);
setTimeout(() => setAddToListOpen(true), 150);
}}
onAddToCampaignList={() => {
onOpenChange(false);
setTimeout(() => setAddToCampaignListOpen(true), 150);
}}
onViewEventJson={() => {
onOpenChange(false);
setTimeout(() => setEventJsonOpen(true), 150);
@@ -306,6 +324,15 @@ export function NoteMoreMenu({ event, open, onOpenChange }: NoteMoreMenuProps) {
onOpenChange={setAddToListOpen}
/>
{isCampaign && (
<CampaignListMembershipDialog
open={addToCampaignListOpen}
onOpenChange={setAddToCampaignListOpen}
campaignCoord={campaignCoord}
campaignTitle={campaignTitle}
/>
)}
<EventJsonDialog
event={event}
nip19Id={nip19Id}
@@ -346,11 +373,12 @@ interface NoteMoreMenuContentProps extends NoteMoreMenuProps {
onReport: () => void;
onBanContent: () => void;
onAddToList: () => void;
onAddToCampaignList: () => void;
onViewEventJson: () => void;
onDelete: () => void;
}
function NoteMoreMenuContent({ event, open, onOpenChange, communityContext, onReport, onBanContent, onAddToList, onViewEventJson, onDelete }: NoteMoreMenuContentProps) {
function NoteMoreMenuContent({ event, open, onOpenChange, communityContext, onReport, onBanContent, onAddToList, onAddToCampaignList, onViewEventJson, onDelete }: NoteMoreMenuContentProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const { user } = useCurrentUser();
@@ -360,6 +388,16 @@ function NoteMoreMenuContent({ event, open, onOpenChange, communityContext, onRe
const pinned = isPinned(event.id);
const isOwnPost = user?.pubkey === event.pubkey;
// Bookmark / Add to list / Add to sidebar don't map cleanly to campaigns
// (kind 33863 — addressable, with their own dedicated UI). Hide them there.
const isCampaign = event.kind === CAMPAIGN_KIND;
// Campaign moderators get a dedicated "Add to list" row that toggles
// the campaign's membership in the curated topic lists. `isMod` is a
// synchronous boolean — no loading state to handle.
const campaignListActions = useCampaignListActions();
const canManageCampaignLists = isCampaign && campaignListActions.isMod;
// Country-feed pin/unpin context (organizer/admin action). `useCountryFeed`
// returns null outside of a country page; we only enable usePinnedPosts when
// the viewer is actually authorized to pin so we avoid extra relay traffic
@@ -532,23 +570,34 @@ function NoteMoreMenuContent({ event, open, onOpenChange, communityContext, onRe
label={t('noteMoreMenu.viewEventJson')}
onClick={onViewEventJson}
/>
<MenuItem
icon={<Bookmark className={cn("size-5", bookmarked && "fill-current")} />}
label={bookmarked ? t('noteMoreMenu.removeBookmark') : t('noteMoreMenu.bookmark')}
onClick={handleBookmark}
/>
{user && (
{!isCampaign && (
<MenuItem
icon={<Bookmark className={cn("size-5", bookmarked && "fill-current")} />}
label={bookmarked ? t('noteMoreMenu.removeBookmark') : t('noteMoreMenu.bookmark')}
onClick={handleBookmark}
/>
)}
{user && !isCampaign && (
<MenuItem
icon={<ListPlus className="size-5" />}
label={t('noteMoreMenu.addToList')}
onClick={() => { onAddToList(); }}
/>
)}
<MenuItem
icon={isInSidebar ? <Trash2 className="size-5" /> : <PanelLeft className="size-5" />}
label={isInSidebar ? t('noteMoreMenu.removeFromSidebar') : t('noteMoreMenu.addToSidebar')}
onClick={handleToggleSidebar}
/>
{canManageCampaignLists && (
<MenuItem
icon={<ListPlus className="size-5" />}
label={t('campaigns.lists.membershipTitle')}
onClick={() => { onAddToCampaignList(); }}
/>
)}
{!isCampaign && (
<MenuItem
icon={isInSidebar ? <Trash2 className="size-5" /> : <PanelLeft className="size-5" />}
label={isInSidebar ? t('noteMoreMenu.removeFromSidebar') : t('noteMoreMenu.addToSidebar')}
onClick={handleToggleSidebar}
/>
)}
{isOwnPost && (
<MenuItem
icon={<Pin className={cn("size-5", pinned && "fill-current")} />}
@@ -566,7 +615,7 @@ function NoteMoreMenuContent({ event, open, onOpenChange, communityContext, onRe
{!isOwnPost && (
<MenuItem
icon={<BellOff className="size-5" />}
label={t('noteMoreMenu.muteConversation')}
label={isCampaign ? t('noteMoreMenu.muteCampaign') : t('noteMoreMenu.muteConversation')}
onClick={handleMuteConversation}
/>
)}
+488
View File
@@ -0,0 +1,488 @@
import {
useCallback,
useMemo,
useState,
type ReactNode,
} from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
ArrowLeft,
ArrowRight,
Bitcoin,
Download,
Eye,
EyeOff,
HandCoins,
Link2,
Loader2,
Megaphone,
User,
X,
} from 'lucide-react';
import { generateSecretKey, getPublicKey, nip19 } from 'nostr-tools';
import { AgoraBoltIcon } from '@/components/icons/AgoraBoltIcon';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useLoginActions } from '@/hooks/useLoginActions';
import { useOnboarding, type OnboardingRole } from '@/contexts/onboardingContextDef';
import { useToast } from '@/hooks/useToast';
import { downloadTextFile } from '@/lib/downloadFile';
import { cn } from '@/lib/utils';
/**
* Step state machine for the captive signup flow.
*
* Order:
* keygen secure role
*
* Three screens total. The old flow had a separate "wallet-coupling explainer"
* step and a separate "outro" celebration screen; both were folded in. The
* coupling explainer was redundant with `secure` (both screens are about the
* key), so the secure step now carries the "this key is your account AND
* your wallet" framing inline. The outro was a glorified tap-to-continue
* the role step's primary button already navigates somewhere meaningful, so
* the role pick *is* the outro.
*
* Login is handled by the existing `AuthDialog` modal the captive flow is
* only ever opened by an explicit `startSignup()` call (e.g. from
* AuthDialog's "Create a new Nostr account" button), so the user has
* already picked "signup" by the time we mount.
*/
type Step = 'keygen' | 'secure' | 'role';
const SIGNUP_STEPS: Step[] = ['keygen', 'secure', 'role'];
/**
* The captive onboarding gate. Render this as a sibling of `<AppRouter />`;
* it renders nothing when inactive and a fullscreen `fixed inset-0 z-50`
* overlay when `useOnboarding().active === true`.
*
* The flow guides a brand-new user through:
* 1. Key generation
* 2. Save the nsec (with inline wallet-coupling framing)
* 3. Role pick primary CTA navigates by intent: creator /campaigns/new,
* donor / (campaign grid)
*
* The overlay sits above all app chrome and cannot be dismissed by clicking
* outside; users must either complete the flow or use the explicit Close (X)
* button in the top-right corner.
*/
export function OnboardingGate({ children }: { children: ReactNode }) {
const { active } = useOnboarding();
return (
<>
{children}
{active && <CaptiveOverlay />}
</>
);
}
/** Inner overlay component only mounted while the flow is active so the
* per-flow state resets cleanly between sessions. */
function CaptiveOverlay() {
const { t } = useTranslation();
const { cancel, role: contextRole, setRole: setContextRole } = useOnboarding();
const navigate = useNavigate();
const { toast } = useToast();
const login = useLoginActions();
const { user } = useCurrentUser();
// Decide the entry step.
// - Already-authenticated users normally land on `role` directly.
const initialStep: Step = useMemo(() => {
if (user) return 'role';
return 'keygen';
}, [user]);
const [step, setStep] = useState<Step>(initialStep);
// Signup state
const [nsec, setNsec] = useState('');
const [isGenerating, setIsGenerating] = useState(false);
const [showKey, setShowKey] = useState(false);
// Linear progress bar position. Every step in the machine counts toward
// the bar.
const currentProgressIndex = SIGNUP_STEPS.indexOf(step);
const progress = currentProgressIndex < 0
? 0
: ((currentProgressIndex + 1) / SIGNUP_STEPS.length) * 100;
// Navigation helpers ------------------------------------------------------
const goTo = useCallback((target: Step) => {
setStep(target);
}, []);
const showBackButton = !(step === 'keygen' && isGenerating);
const handleBack = useCallback(() => {
if (step === 'keygen') {
cancel();
} else if (step === 'secure') {
goTo('keygen');
} else {
if (user) cancel();
else goTo('secure');
}
}, [step, user, cancel, goTo]);
// Role pick is the final step. Picking a role both records the choice
// (used by the role-pick CTA labels) and navigates to the matching
// surface: creator → campaign-creation form, donor → full campaign grid
// (`/campaigns`, not `/`, so they land on the browse-everything view
// rather than the curated home with its own marketing hero). No separate
// outro / celebration screen.
const handleRolePick = useCallback(
(next: 'creator' | 'donor') => {
setContextRole(next);
cancel();
if (next === 'creator') {
navigate('/campaigns/new');
} else {
navigate('/campaigns');
}
},
[setContextRole, cancel, navigate],
);
// Key generation ----------------------------------------------------------
const handleGenerateKey = useCallback(() => {
setIsGenerating(true);
// Brief visible spinner — the generation itself is instantaneous, but
// an instant transition feels too "did anything happen?" given the
// weight of what just got created.
setTimeout(() => {
const sk = generateSecretKey();
setNsec(nip19.nsecEncode(sk));
setIsGenerating(false);
goTo('secure');
}, 700);
}, [goTo]);
// Download + install nsec into the login store ---------------------------
const handleDownloadAndContinue = useCallback(async () => {
try {
const decoded = nip19.decode(nsec);
if (decoded.type !== 'nsec') throw new Error('Invalid nsec');
const pubkey = getPublicKey(decoded.data);
const npub = nip19.npubEncode(pubkey);
const filename = `nostr-${location.hostname.replaceAll(/\./g, '-')}-${npub.slice(5, 9)}.nsec.txt`;
await downloadTextFile(filename, nsec);
login.nsec(nsec);
goTo('role');
} catch {
toast({
title: t('onboarding.secure.downloadFailedTitle'),
description: t('onboarding.secure.downloadFailedDescription'),
variant: 'destructive',
});
}
}, [nsec, login, goTo, toast, t]);
// Step renderer -----------------------------------------------------------
const stepBody = (() => {
switch (step) {
case 'keygen':
// First step. Back closes the captive flow entirely — the user got
// here from the AuthDialog and already chose "signup".
return (
<KeygenStep
isGenerating={isGenerating}
onGenerate={handleGenerateKey}
/>
);
case 'secure':
return (
<SecureStep
nsec={nsec}
showKey={showKey}
onToggleShow={() => setShowKey((v) => !v)}
onContinue={handleDownloadAndContinue}
/>
);
case 'role':
// Final step. Picking a role navigates to the matching surface
// (creator → /campaigns/new, donor → /); Back goes to secure if the
// user signed up through the full flow, or cancels the overlay if
// they were already-authenticated and landed here directly.
return (
<RoleStep
role={contextRole}
onPick={handleRolePick}
/>
);
}
})();
return (
<div
className="fixed inset-0 z-50 bg-background overflow-y-auto flex flex-col"
role="dialog"
aria-modal="true"
aria-label={t('onboarding.ariaLabel')}
>
{/* Progress bar — every step in the flow counts toward it. */}
<div className="sticky top-0 z-10 h-1 bg-muted">
<div
className="h-full bg-primary transition-all duration-500 ease-out"
style={{ width: `${progress}%` }}
/>
</div>
{showBackButton && (
<button
type="button"
onClick={handleBack}
aria-label={t('common.back')}
className="absolute left-4 top-4 z-20 sm:left-6 sm:top-6 inline-flex h-9 w-9 items-center justify-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
>
<ArrowLeft className="h-5 w-5 rtl:rotate-180" />
</button>
)}
{/* Top-right close. Lets users escape if they truly don't want to
continue but it's deliberately unobtrusive vs. a backdrop click
so casual taps don't drop them out of the flow. */}
<button
type="button"
onClick={cancel}
aria-label={t('onboarding.close')}
className="absolute right-4 top-4 z-20 sm:right-6 sm:top-6 inline-flex h-9 w-9 items-center justify-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
>
<X className="h-5 w-5" />
</button>
<div className="flex-1 flex items-start sm:items-center justify-center px-6 pt-16 pb-12">
<div
key={step}
className="w-full max-w-md mx-auto animate-in fade-in slide-in-from-bottom-2 duration-300"
>
{stepBody}
</div>
</div>
</div>
);
}
// =============================================================================
// Step components
// =============================================================================
interface RoleStepProps {
role: OnboardingRole;
onPick: (role: 'creator' | 'donor') => void;
}
/**
* Two-card role picker, modeled on the Treasures CreateCacheLanding pattern.
* Both cards use primary-tinted icon chips (both roles are first-class) and
* the three-line "title / what you do / what the other side sees"
* structure that makes the choice feel like a role rather than a feature
* menu.
*/
function RoleStep({ role, onPick }: RoleStepProps) {
const { t } = useTranslation();
return (
<div className="space-y-6">
<div className="space-y-2 text-center">
<h2 className="text-2xl font-bold tracking-tight">{t('onboarding.role.title')}</h2>
<p className="text-sm text-muted-foreground">{t('onboarding.role.subtitle')}</p>
</div>
<div className="space-y-3">
<RoleCard
icon={<Megaphone className="h-5 w-5 md:h-6 md:w-6 text-primary" />}
title={t('onboarding.role.creator.title')}
description={t('onboarding.role.creator.description')}
finderNote={t('onboarding.role.creator.finderNote')}
selected={role === 'creator'}
onClick={() => onPick('creator')}
/>
<RoleCard
icon={<HandCoins className="h-5 w-5 md:h-6 md:w-6 text-primary" />}
title={t('onboarding.role.donor.title')}
description={t('onboarding.role.donor.description')}
finderNote={t('onboarding.role.donor.finderNote')}
selected={role === 'donor'}
onClick={() => onPick('donor')}
/>
</div>
</div>
);
}
interface RoleCardProps {
icon: ReactNode;
title: string;
description: string;
finderNote: string;
selected: boolean;
onClick: () => void;
}
/** A single role card. Three text lines, primary-tinted icon chip, hover and
* selected states matching the Treasures pattern. */
function RoleCard({ icon, title, description, finderNote, selected, onClick }: RoleCardProps) {
return (
<button
type="button"
onClick={onClick}
className={cn(
'w-full text-left rounded-xl border bg-card p-5 md:p-6 transition-all',
'hover:border-primary/50 hover:shadow-md',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2',
selected && 'border-primary shadow-md ring-1 ring-primary',
)}
>
<div className="flex items-start gap-3">
<div className="flex-shrink-0 w-10 h-10 md:w-12 md:h-12 rounded-full bg-primary/10 flex items-center justify-center">
{icon}
</div>
<div className="flex-1 min-w-0">
<h3 className="text-sm md:text-base font-semibold text-foreground">{title}</h3>
<p className="text-xs md:text-sm text-muted-foreground mt-0.5">{description}</p>
<p className="text-xs md:text-sm font-medium text-primary mt-1.5">{finderNote}</p>
</div>
<ArrowRight className="h-4 w-4 md:h-5 md:w-5 text-muted-foreground flex-shrink-0 mt-1 rtl:rotate-180" />
</div>
</button>
);
}
interface KeygenStepProps {
isGenerating: boolean;
onGenerate: () => void;
}
/** Key generation step a single CTA that fires off `generateSecretKey()`
* with a brief visible spinner for tactile feedback. */
function KeygenStep({ isGenerating, onGenerate }: KeygenStepProps) {
const { t } = useTranslation();
return (
<div className="space-y-6 text-center">
<div className="relative w-24 h-24 mx-auto">
{isGenerating ? (
<div className="absolute inset-0 flex items-center justify-center">
<Loader2 className="w-14 h-14 text-primary animate-spin" />
</div>
) : (
<div className="absolute inset-0 flex items-center justify-center">
<AgoraBoltIcon className="size-20 drop-shadow-md" />
</div>
)}
</div>
<div className="space-y-2">
<h2 className="text-2xl font-bold tracking-tight">
{isGenerating ? t('onboarding.keygen.generatingTitle') : t('onboarding.keygen.title')}
</h2>
<p className="text-sm text-muted-foreground">
{isGenerating ? t('onboarding.keygen.generatingDescription') : t('onboarding.keygen.description')}
</p>
</div>
{!isGenerating && (
<Button onClick={onGenerate} className="w-full h-12 text-base rounded-full">
{t('onboarding.keygen.button')}
</Button>
)}
</div>
);
}
interface SecureStepProps {
nsec: string;
showKey: boolean;
onToggleShow: () => void;
onContinue: () => void;
}
/**
* Reveals + downloads the nsec, then installs it into the login store and
* advances.
*
* Three stacked elements communicate the weight of saving the key:
* 1. The key itself (revealable input + download button).
* 2. A "your account and your wallet share this key" coupling callout
* large linked icons make the relationship the visual centerpiece.
* 3. A no-recovery emphasis block calm, informational tone (not a red
* destructive alert) but typographically dominant so the user can't
* breeze past the "there is no way to get this back" point.
*
* This is the only captive-flow surface that explains the coupling and the
* permanence to brand-new users, so it has to carry weight without scaring
* them.
*/
function SecureStep({ nsec, showKey, onToggleShow, onContinue }: SecureStepProps) {
const { t } = useTranslation();
return (
<div className="space-y-6">
<div className="space-y-2 text-center">
<h2 className="text-2xl font-bold tracking-tight">{t('onboarding.secure.title')}</h2>
<p className="text-sm text-muted-foreground">{t('onboarding.secure.subtitle')}</p>
</div>
<div className="relative">
<Input
type={showKey ? 'text' : 'password'}
value={nsec}
readOnly
className="pr-10 font-mono text-sm"
aria-label="Secret key"
/>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-0 top-0 h-full px-3 hover:bg-transparent"
onClick={onToggleShow}
>
{showKey ? (
<EyeOff className="h-4 w-4 text-muted-foreground" />
) : (
<Eye className="h-4 w-4 text-muted-foreground" />
)}
</Button>
</div>
{/* Single coupling+permanence card. The linked-icon visual at the
top communicates "your account and your wallet share this key";
the typographically heavier line below it makes the no-recovery
point inescapable without resorting to red/warning iconography.
Both messages live in one card so the user can't skim past
either. */}
<div className="rounded-xl bg-primary/10 border-2 border-primary/30 p-5 space-y-4">
<div className="flex items-center justify-center gap-3">
<div className="w-14 h-14 rounded-full bg-background ring-2 ring-primary/30 flex items-center justify-center shadow-sm">
<User className="h-7 w-7 text-primary" />
</div>
<div className="w-7 h-7 rounded-full bg-primary text-primary-foreground flex items-center justify-center shadow">
<Link2 className="h-4 w-4" />
</div>
<div className="w-14 h-14 rounded-full bg-background ring-2 ring-primary/30 flex items-center justify-center shadow-sm">
<Bitcoin className="h-7 w-7 text-primary" />
</div>
</div>
<p className="text-sm text-foreground text-center leading-relaxed">
{t('onboarding.secure.couplingNote')}
</p>
<div className="border-t border-primary/20 pt-3 text-center">
<p className="text-base font-bold tracking-tight text-foreground leading-snug">
{t('onboarding.secure.permanenceHeadline')}
</p>
<p className="text-xs text-muted-foreground mt-1.5 leading-relaxed">
{t('onboarding.secure.permanenceBody')}
</p>
</div>
</div>
<Button onClick={onContinue} className="w-full h-12 text-base rounded-full">
<Download className="w-4 h-4 mr-2" />
{t('onboarding.secure.button')}
</Button>
</div>
);
}
+28
View File
@@ -8,6 +8,7 @@ import { nip19 } from 'nostr-tools';
import { AuthorByline } from '@/components/AuthorByline';
import { Badge } from '@/components/ui/badge';
import { Card } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { useEventTranslation } from '@/hooks/useEventTranslation';
import { parseAction, type Action } from '@/hooks/useActions';
import { getGeoDisplayName } from '@/lib/countries';
@@ -172,3 +173,30 @@ export function PledgeCard({
</Link>
);
}
/**
* Loading placeholder that matches `PledgeCard`'s grid-variant shape:
* 16:9 cover, then title, two lines of body, a progress bar row, and
* a footer line. Sized to slot into the same `<DiscoveryGrid>` / 4-col
* grids as the real card so the skeleton row doesn't reflow when data
* arrives.
*
* Lives next to `PledgeCard` for parity with `CampaignCardSkeleton`
* and `CommunityMiniCardSkeleton`, which sit next to their cards too.
* Was duplicated as `ActionSkeleton` in `PledgesDiscoverySection` and
* `ActionsPage` before this consolidation.
*/
export function PledgeCardSkeleton() {
return (
<Card className="overflow-hidden border-border/70 shadow-sm h-full flex flex-col">
<Skeleton className="aspect-[16/9] w-full rounded-none" />
<div className="flex-1 p-5 space-y-3">
<Skeleton className="h-5 w-3/4" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-4/5" />
<Skeleton className="h-2 w-full" />
<Skeleton className="h-4 w-2/3" />
</div>
</Card>
);
}
+2 -2
View File
@@ -112,7 +112,7 @@ function EpisodeDetail({ event }: { event: NostrEvent }) {
<img src={proxy(parsed.artwork, 320)} alt={parsed.title} className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center bg-primary/10">
<Podcast className="size-12 text-primary/30" />
<Podcast className="size-12 text-primary" />
</div>
)}
</div>
@@ -316,7 +316,7 @@ function TrailerDetail({ event }: { event: NostrEvent }) {
<div className="px-4 flex gap-5 items-start">
<div className="shrink-0 w-32 sm:w-40 aspect-square rounded-2xl overflow-hidden bg-muted shadow-lg">
<div className="w-full h-full flex items-center justify-center bg-primary/10">
<Podcast className="size-12 text-primary/30" />
<Podcast className="size-12 text-primary" />
</div>
</div>
+311
View File
@@ -0,0 +1,311 @@
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Camera, Loader2, X, ZapOff, Zap } from 'lucide-react';
import QrScanner from 'qr-scanner';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { cn } from '@/lib/utils';
interface QrScannerDialogProps {
isOpen: boolean;
onClose: () => void;
/** Called with the decoded QR text the first time a code is read. */
onScan: (value: string) => void;
/** Override the dialog title (defaults to the localized "Scan QR code"). */
title?: string;
}
/** How long to wait after `start()` resolves before declaring the camera dead. */
const VIDEO_READY_TIMEOUT_MS = 6000;
/**
* Camera-based QR scanner dialog. Works in browsers, Capacitor's WKWebView
* (iOS), and Android's WebView, all via `getUserMedia` + the `qr-scanner`
* library (ZXing / BarcodeDetector under the hood).
*
* The dialog owns the camera lifecycle: it spins up the scanner when opened
* and tears it down on close, so callers only need to manage `isOpen` and
* react to `onScan`.
*
* Failure modes we explicitly surface (instead of a silent black screen):
* - Insecure context (HTTP) getUserMedia is unavailable.
* - Camera permission denied.
* - No camera on the device.
* - `facingMode: 'environment'` not satisfiable (some laptops, some
* locked-down WebViews). We retry with the front camera.
* - `start()` resolves but the video never emits `loadedmetadata` within
* `VIDEO_READY_TIMEOUT_MS` usually means the worker engine failed to
* initialize or another app is holding the camera.
*/
export function QrScannerDialog({ isOpen, onClose, onScan, title }: QrScannerDialogProps) {
const { t } = useTranslation();
// Callback ref so we know the moment the element is attached. Radix
// Dialog mounts content lazily inside a Portal, so a plain `useRef` is
// still null on the first effect tick after `isOpen` flips to true.
// A state-backed ref re-runs the effect once the <video> actually
// exists in the DOM.
const [video, setVideo] = useState<HTMLVideoElement | null>(null);
const scannerRef = useRef<QrScanner | null>(null);
// Keep the latest onScan in a ref so the start effect doesn't tear down
// the camera every time the parent passes a new callback identity.
const onScanRef = useRef(onScan);
onScanRef.current = onScan;
const [status, setStatus] = useState<'idle' | 'starting' | 'running' | 'error'>('idle');
const [error, setError] = useState<string | null>(null);
const [hasFlash, setHasFlash] = useState(false);
const [flashOn, setFlashOn] = useState(false);
useEffect(() => {
if (!isOpen) return;
if (!video) return;
// Secure-context guard — getUserMedia is only available on https / localhost.
if (typeof window !== 'undefined' && window.isSecureContext === false) {
setStatus('error');
setError(t('qrScanner.errors.insecure'));
return;
}
if (!navigator.mediaDevices?.getUserMedia) {
setStatus('error');
setError(t('qrScanner.errors.unsupported'));
return;
}
let cancelled = false;
let readyTimer: ReturnType<typeof setTimeout> | undefined;
setStatus('starting');
setError(null);
const handleDecode = (result: QrScanner.ScanResult) => {
if (cancelled) return;
cancelled = true;
scanner.stop();
onScanRef.current(result.data);
};
let scanner: QrScanner;
try {
scanner = new QrScanner(
video,
handleDecode,
{
returnDetailedScanResult: true,
highlightScanRegion: true,
highlightCodeOutline: true,
preferredCamera: 'environment',
maxScansPerSecond: 5,
},
);
} catch (err) {
setStatus('error');
setError(humanizeCameraError(err, t));
return;
}
scannerRef.current = scanner;
/**
* Watch the video element. If it never reaches `HAVE_METADATA` within the
* timeout, the scanner is silently broken. Surface that instead of a
* black screen.
*/
const armReadyTimeout = () => {
readyTimer = setTimeout(() => {
if (cancelled) return;
if (video.readyState < 1 /* HAVE_METADATA */) {
cancelled = true;
scanner.stop();
setStatus('error');
setError(t('qrScanner.errors.didntStart'));
}
}, VIDEO_READY_TIMEOUT_MS);
};
const clearReadyTimeout = () => {
if (readyTimer) {
clearTimeout(readyTimer);
readyTimer = undefined;
}
};
const onLoadedMetadata = () => clearReadyTimeout();
video.addEventListener('loadedmetadata', onLoadedMetadata, { once: true });
/**
* Some devices reject `facingMode: 'environment'` with OverconstrainedError
* (laptops without a rear camera, some Android WebViews). Retry without a
* camera preference so the browser picks any available device.
*/
const startWithFallback = async () => {
try {
await scanner.start();
} catch (err) {
if (cancelled) return;
if (isOverconstrainedError(err)) {
try {
await scanner.setCamera('user');
return;
} catch {
// Fall through and report the original error.
}
}
throw err;
}
};
startWithFallback()
.then(async () => {
if (cancelled) return;
clearReadyTimeout();
setStatus('running');
armReadyTimeout();
try {
const flashAvailable = await scanner.hasFlash();
if (!cancelled) setHasFlash(flashAvailable);
} catch {
// Flash detection is best-effort; ignore.
}
})
.catch((err: unknown) => {
if (cancelled) return;
clearReadyTimeout();
setStatus('error');
setError(humanizeCameraError(err, t));
});
// Arm an initial timeout in case `start()` neither resolves nor rejects
// (e.g. the worker engine wedges on CSP-blocked blob creation, or the
// OS permission dialog is dismissed without a callback firing).
armReadyTimeout();
return () => {
cancelled = true;
clearReadyTimeout();
video.removeEventListener('loadedmetadata', onLoadedMetadata);
scanner.stop();
scanner.destroy();
scannerRef.current = null;
setHasFlash(false);
setFlashOn(false);
};
}, [isOpen, video, t]);
const toggleFlash = async () => {
const scanner = scannerRef.current;
if (!scanner) return;
try {
await scanner.toggleFlash();
setFlashOn(scanner.isFlashOn());
} catch {
// Ignore — some devices report `hasFlash` true but error on toggle.
}
};
return (
<Dialog open={isOpen} onOpenChange={(open) => { if (!open) onClose(); }}>
<DialogContent className="max-w-[min(380px,calc(100vw-2rem))] max-h-[calc(100svh-2rem)] rounded-2xl p-0 gap-0 border-border overflow-hidden flex flex-col [&>button]:hidden">
<div className="flex items-center justify-between px-4 h-12 shrink-0">
<DialogTitle className="text-base font-semibold flex items-center gap-1.5">
{title ?? t('qrScanner.title')}
</DialogTitle>
<button
onClick={onClose}
className="p-1.5 -mr-1.5 rounded-full text-muted-foreground hover:text-foreground hover:bg-secondary/60 motion-safe:transition-colors"
aria-label={t('qrScanner.close')}
>
<X className="size-5" />
</button>
</div>
<div
className="relative w-full aspect-square bg-black overflow-hidden shrink-0"
style={{ maxHeight: 'min(380px, calc(100vw - 2rem))' }}
>
<video
ref={setVideo}
className={cn(
'absolute inset-0 w-full h-full object-cover',
status !== 'running' && 'opacity-0',
)}
playsInline
muted
/>
{status === 'starting' && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 text-white/80">
<Loader2 className="size-8 animate-spin" />
<p className="text-sm">{t('qrScanner.starting')}</p>
</div>
)}
{status === 'error' && (
<div className="absolute inset-0 flex items-center justify-center p-6">
<Alert variant="destructive" className="bg-background">
<Camera className="size-4" />
<AlertDescription className="text-xs">
{error || t('qrScanner.errors.generic')}
</AlertDescription>
</Alert>
</div>
)}
{status === 'running' && hasFlash && (
<button
type="button"
onClick={toggleFlash}
aria-label={flashOn ? t('qrScanner.flashOff') : t('qrScanner.flashOn')}
className="absolute bottom-3 right-3 size-10 rounded-full bg-black/50 backdrop-blur-sm text-white flex items-center justify-center hover:bg-black/70 motion-safe:transition-colors"
>
{flashOn ? <ZapOff className="size-5" /> : <Zap className="size-5" />}
</button>
)}
</div>
<div className="px-4 py-3 shrink-0">
<p className="text-xs text-muted-foreground text-center">
{t('qrScanner.aim')}
</p>
{status === 'error' && (
<Button onClick={onClose} variant="outline" className="w-full mt-3">
{t('qrScanner.close')}
</Button>
)}
</div>
</DialogContent>
</Dialog>
);
}
function isOverconstrainedError(err: unknown): boolean {
if (!err || typeof err !== 'object') return false;
const name = (err as { name?: unknown }).name;
if (name === 'OverconstrainedError') return true;
const msg = err instanceof Error ? err.message : '';
return /overconstrained|constraint/i.test(msg);
}
function humanizeCameraError(err: unknown, t: (key: string) => string): string {
const name = (err && typeof err === 'object' ? (err as { name?: unknown }).name : undefined);
const msg = err instanceof Error ? err.message : String(err);
if (name === 'NotFoundError' || /no camera/i.test(msg) || /not found/i.test(msg)) {
return t('qrScanner.errors.notFound');
}
if (name === 'NotAllowedError' || /permission/i.test(msg) || /denied/i.test(msg)) {
return t('qrScanner.errors.denied');
}
if (name === 'NotReadableError' || /in use|busy|readable/i.test(msg)) {
return t('qrScanner.errors.busy');
}
if (name === 'SecurityError' || /secure context/i.test(msg) || /https/i.test(msg)) {
return t('qrScanner.errors.insecure');
}
if (name === 'OverconstrainedError' || /overconstrained|constraint/i.test(msg)) {
return t('qrScanner.errors.overconstrained');
}
return msg || t('qrScanner.errors.generic');
}
+36
View File
@@ -0,0 +1,36 @@
import { forwardRef, useState, type ButtonHTMLAttributes, type MouseEvent } from 'react';
import { useNavigate } from 'react-router-dom';
import AuthDialog from '@/components/auth/AuthDialog';
import { useCurrentUser } from '@/hooks/useCurrentUser';
type StartCampaignLinkProps = ButtonHTMLAttributes<HTMLButtonElement> & {
to?: string;
};
export const StartCampaignLink = forwardRef<HTMLButtonElement, StartCampaignLinkProps>(function StartCampaignLink(
{ onClick, to = '/campaigns/new', type = 'button', ...props },
ref,
) {
const { user } = useCurrentUser();
const navigate = useNavigate();
const [authOpen, setAuthOpen] = useState(false);
const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
onClick?.(event);
if (event.defaultPrevented) return;
if (user) {
navigate(to);
} else {
setAuthOpen(true);
}
};
return (
<>
<button ref={ref} type={type} onClick={handleClick} {...props} />
<AuthDialog isOpen={authOpen} onClose={() => setAuthOpen(false)} />
</>
);
});
+1 -1
View File
@@ -34,7 +34,7 @@ export function StickerPicker({ customEmojis, onSelect, height = 350, autoFocus
if (customEmojis.length === 0) {
return (
<div className="flex flex-col items-center justify-center text-muted-foreground gap-2" style={{ height }}>
<Sticker className="size-8 opacity-40" />
<Sticker className="size-8" />
<p className="text-sm">No sticker packs yet</p>
<p className="text-xs">Add emoji packs to your profile to use stickers</p>
</div>
+2 -2
View File
@@ -121,7 +121,7 @@ export function SubHeaderBar({ children, className, innerClassName, backgroundFi
onClick={() => scrollBy('left')}
className="hidden sidebar:flex absolute left-0 top-0 bottom-0 z-10 items-center pl-0.5 pr-1 bg-gradient-to-r from-background via-background to-transparent cursor-pointer"
>
<ChevronLeft className="size-4 text-foreground/60 drop-shadow-md" strokeWidth={4} />
<ChevronLeft className="size-4 text-foreground drop-shadow-md" strokeWidth={4} />
</button>
)}
<div
@@ -138,7 +138,7 @@ export function SubHeaderBar({ children, className, innerClassName, backgroundFi
onClick={() => scrollBy('right')}
className="hidden sidebar:flex absolute right-0 top-0 bottom-0 z-10 items-center pr-0.5 pl-1 bg-gradient-to-l from-background via-background to-transparent cursor-pointer"
>
<ChevronRight className="size-4 text-foreground/60 drop-shadow-md" strokeWidth={4} />
<ChevronRight className="size-4 text-foreground drop-shadow-md" strokeWidth={4} />
</button>
)}
</div>
+1 -1
View File
@@ -136,7 +136,7 @@ export function TimezoneSwitcher({ value, onChange, className }: TimezoneSwitche
)}
</span>
</div>
<ChevronDown className="h-4 w-4 opacity-50 shrink-0" />
<ChevronDown className="h-4 w-4 shrink-0" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[320px] p-0" align="start" sideOffset={4}>
+14 -10
View File
@@ -7,12 +7,10 @@ import {
HandHeart,
Info,
LayoutDashboard,
Megaphone,
Menu,
Search,
Settings,
User,
Users,
Wallet,
X,
} from 'lucide-react';
@@ -22,9 +20,9 @@ import { LoginArea } from '@/components/auth/LoginArea';
import { LogoIcon } from '@/components/icons/LogoIcon';
import { Sheet, SheetContent } from '@/components/ui/sheet';
import { useAppContext } from '@/hooks/useAppContext';
import { useBtcPrice } from '@/hooks/useBtcPrice';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useFeedSettings } from '@/hooks/useFeedSettings';
import { useHdBtcPrice } from '@/hooks/useHdBtcPrice';
import { useHdWallet } from '@/hooks/useHdWallet';
import { satsToUSD } from '@/lib/bitcoin';
import { cn } from '@/lib/utils';
@@ -39,10 +37,16 @@ interface NavItem {
}
const NAV_ITEMS: NavItem[] = [
{ labelKey: 'nav.campaigns', to: '/campaigns', icon: HandHeart },
{ labelKey: 'nav.activity', to: '/feed', icon: Activity },
{ labelKey: 'nav.campaigns', to: '/campaigns/all', icon: HandHeart },
{ labelKey: 'nav.groups', to: '/groups', icon: Users },
{ labelKey: 'nav.pledge', to: '/pledges', icon: Megaphone },
// Groups and Pledges are intentionally hidden from the main nav for
// launch — keep the routes and feature code intact so we can re-add
// them later by uncommenting these two lines (and re-importing the
// `Users` and `Megaphone` icons from `lucide-react` at the top of
// this file). Both pages still work when visited directly and are
// still linked from in-page CTAs and user-authored content.
// { labelKey: 'nav.groups', to: '/groups', icon: Users },
// { labelKey: 'nav.pledge', to: '/pledges', icon: Megaphone },
];
interface MobileLinkItem extends NavItem {
@@ -63,8 +67,8 @@ export function TopNav() {
const [mobileOpen, setMobileOpen] = useState(false);
return (
<header className="sticky top-0 z-40 w-full border-b border-border bg-background/85 backdrop-blur supports-[backdrop-filter]:bg-background/70">
<div className="mx-auto flex h-16 max-w-7xl items-center gap-4 px-4 sm:px-6">
<header className="safe-area-top sticky top-0 z-40 w-full border-b border-border bg-background/85 backdrop-blur supports-[backdrop-filter]:bg-background/70">
<div className="mx-auto flex h-16 max-w-7xl items-center gap-1 md:gap-4 px-4 sm:px-6">
{/* Mobile menu trigger */}
<button
type="button"
@@ -184,7 +188,7 @@ export function TopNav() {
/**
* Compact USD balance pill in the top-nav right cluster, replacing the
* previous search icon. Reads the HD-wallet sats balance via {@link useHdWallet}
* and converts to USD via {@link useBtcPrice}. Renders nothing when the wallet
* and converts to USD via {@link useHdBtcPrice}. Renders nothing when the wallet
* isn't available (logged out, extension/bunker login, still loading, or no
* price yet) so the chrome stays quiet rather than flashing placeholder text.
*/
@@ -201,7 +205,7 @@ function DeferredWalletBalancePill() {
function WalletBalancePill() {
const { t } = useTranslation();
const { availability, totalBalance, isLoading, error } = useHdWallet();
const { data: btcPrice } = useBtcPrice();
const { data: btcPrice } = useHdBtcPrice();
if (availability.status !== 'available') return null;
if (isLoading || error || !btcPrice) return null;
+8 -3
View File
@@ -3,11 +3,10 @@ import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { useAppContext } from "@/hooks/useAppContext";
import { prepareForTranslation, restoreTokens } from "@/lib/prepareTranslation";
import { cn } from "@/lib/utils";
const DEFAULT_TRANSLATE_WORKER_URL = "https://agora-translate.mk-cc1.workers.dev";
const LANG_MAP: Record<string, string> = {
en: "EN-US",
es: "ES",
@@ -54,6 +53,7 @@ export function TranslateButton({
className,
}: TranslateButtonProps) {
const { t, i18n } = useTranslation();
const { config } = useAppContext();
const [loading, setLoading] = useState(false);
const [error, setError] = useState(false);
@@ -66,6 +66,9 @@ export function TranslateButton({
if (!text.trim()) return;
const translateUrl = config.translateWorkerUrl.trim();
if (!translateUrl) return;
setLoading(true);
setError(false);
@@ -73,7 +76,6 @@ export function TranslateButton({
const languagePrefix = i18n.language.split("-")[0].toLowerCase();
const targetLang = LANG_MAP[languagePrefix] ?? "EN-US";
const prepared = (texts && texts.length > 0 ? texts : [text]).map(prepareForTranslation);
const translateUrl = import.meta.env.VITE_TRANSLATE_WORKER_URL ?? DEFAULT_TRANSLATE_WORKER_URL;
const response = await fetch(translateUrl, {
method: "POST",
@@ -100,6 +102,9 @@ export function TranslateButton({
}
};
// No translation worker configured — hide the button entirely.
if (!config.translateWorkerUrl.trim()) return null;
return (
<Button
type="button"
+1 -1
View File
@@ -140,7 +140,7 @@ export function WalletSettings() {
{connections.length === 0 ? (
<Card className="border-dashed">
<CardContent className="py-10 text-center">
<WalletMinimal className="size-8 mx-auto mb-3 text-muted-foreground/50" />
<WalletMinimal className="size-8 mx-auto mb-3 text-muted-foreground" />
<p className="text-sm text-muted-foreground mb-1">{t('walletConnect.empty.title')}</p>
<p className="text-xs text-muted-foreground/70">{t('walletConnect.empty.description')}</p>
</CardContent>
+280
View File
@@ -0,0 +1,280 @@
import { useState, type FormEvent, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { ArrowLeft, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
export interface WizardStep {
/** Centered heading at the top of the step. Concise — one short phrase. */
title: string;
/** Muted single-line subtitle beneath the heading. Optional. */
subtitle?: string;
/** The form fields for this step. */
body: ReactNode;
}
export interface WizardProps {
/**
* Accessibility label for the wizard's dialog role. Should describe
* what the user is creating e.g. "Create a campaign", "Create a
* group". Used as the `aria-label` on the outer `role="dialog"`.
*/
headingAriaLabel: string;
/** 1-indexed list of steps. Length determines the total. */
steps: WizardStep[];
/**
* Optional lead content rendered above the first step's body. The
* campaign wizard uses this for the "publishing under <org>" chip so
* the publishing-context is the very first thing the user sees on
* step 1. Hidden on every other step.
*/
step1Lead?: ReactNode;
/** Error alert rendered beneath each step's body. Pass null when no error. */
errorAlert?: ReactNode;
/**
* Content rendered inside the terminal step's submit button typically
* "Launch campaign" / "Create group" with a leading icon, and a spinner +
* "Publishing…" copy while submitting.
*/
submitButtonContent: ReactNode;
/** True while the parent mutation is in flight; disables all forward actions. */
submitting: boolean;
/**
* Predicate gating forward progress from a given (1-indexed) step.
* Return `false` to disable Next on that step. Steps not gated by
* this fn are always allowed to advance.
*/
canAdvanceFromStep: (step: number) => boolean;
/** Optional async guard called before moving from a non-terminal step. */
onBeforeAdvance?: (step: number) => boolean | Promise<boolean>;
/**
* 1-indexed step from which the "Skip Next & Launch" shortcut may
* appear. The shortcut is *only* rendered when `launchNowLabel` is
* also provided pass `Infinity` (or omit `launchNowLabel`) to
* disable the shortcut entirely. Earlier steps render only the Next
* button.
*/
launchAvailableFromStep?: number;
/**
* Label for the optional ghost shortcut that submits the form
* mid-wizard without finishing the remaining (optional) steps. Pass
* `undefined` to hide the shortcut.
*/
launchNowLabel?: string;
onSubmit: (e: FormEvent) => void;
onClose: () => void;
}
/**
* Multi-step layout used by Agora's creation flows (campaigns,
* groups, ).
*
* Rendered as a **fullscreen captive overlay** (`fixed inset-0 z-50`)
* so it sits above the persistent TopNav the same treatment Chad's
* onboarding flow uses for signup. From the user's perspective each
* creation flow is a focused, distraction-free task, not "another
* page in the app."
*
* Visually: a sticky single-bar progress fill across the top, a
* top-right X to escape, a top-left back arrow from step 2 onward, a
* centered narrow column for each step, and a big rounded-full
* primary CTA at the bottom.
*
* Earlier required steps are gated by {@link WizardProps.canAdvanceFromStep};
* an optional "Skip Next & Launch" ghost shortcut appears from
* {@link WizardProps.launchAvailableFromStep} onward when a
* {@link WizardProps.launchNowLabel} is provided. The last step is
* terminal its only forward action is the primary submit button.
*
* The `<form>` lives inside this wrapper (not the parent) so the
* submit button wherever it ends up in the wizard submits the
* same form and reuses the parent's `onSubmit`.
*/
export function Wizard({
headingAriaLabel,
steps,
step1Lead,
errorAlert,
submitButtonContent,
submitting,
canAdvanceFromStep,
onBeforeAdvance,
launchAvailableFromStep = Infinity,
launchNowLabel,
onSubmit,
onClose,
}: WizardProps) {
const { t } = useTranslation();
const [step, setStep] = useState(1);
const [isAdvancing, setIsAdvancing] = useState(false);
const totalSteps = steps.length;
const current = steps[step - 1];
const isTerminal = step === totalSteps;
const progress = (step / totalSteps) * 100;
const launchVisible = !!launchNowLabel && step >= launchAvailableFromStep;
const canAdvance = canAdvanceFromStep(step);
// The terminal step's own submit honors only `submitting` — its
// required fields have already been cleared by the gates on
// previous steps. The mid-wizard shortcut, on the other hand,
// sits *on* a potentially-gated step, so it must respect the
// same `canAdvance` check the Next button does — otherwise a
// user could click "Skip Next & Launch" with a still-empty
// required field and trip a server-side validation error.
const canSubmit = isTerminal
? !submitting && !isAdvancing
: launchVisible && canAdvance && !submitting && !isAdvancing;
const handleAdvance = async () => {
if (submitting || isAdvancing || !canAdvance) return;
setIsAdvancing(true);
try {
const shouldAdvance = await onBeforeAdvance?.(step);
if (shouldAdvance === false) return;
setStep((s) => Math.min(s + 1, totalSteps));
} catch {
// The parent owns user-visible errors for async step validation.
} finally {
setIsAdvancing(false);
}
};
return (
<div
className="fixed inset-0 z-50 bg-background overflow-y-auto flex flex-col"
role="dialog"
aria-modal="true"
aria-label={headingAriaLabel}
>
{/* Sticky single-bar progress indicator, mirroring the captive
onboarding flow. */}
<div className="sticky top-0 z-10 h-1 bg-muted">
<div
className="h-full bg-primary transition-all duration-500 ease-out"
style={{ width: `${progress}%` }}
/>
</div>
{/* Top-right close. Lets users escape if they truly don't want to
continue deliberately unobtrusive so casual taps don't drop
them out of the flow. */}
<button
type="button"
onClick={onClose}
aria-label={t('common.goBack')}
className="absolute right-4 top-4 z-20 sm:right-6 sm:top-6 inline-flex h-9 w-9 items-center justify-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
>
<X className="h-5 w-5" />
</button>
{/* Top-left back. Mirrors the close button so the user can step
back through the wizard without scrolling to the footer. Only
rendered from step 2 onward step 1's escape route is the X. */}
{step > 1 && (
<button
type="button"
onClick={() => setStep((s) => Math.max(s - 1, 1))}
disabled={submitting || isAdvancing}
aria-label={t('common.back')}
className="absolute left-4 top-4 z-20 sm:left-6 sm:top-6 inline-flex h-9 w-9 items-center justify-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground transition-colors disabled:opacity-50"
>
<ArrowLeft className="h-5 w-5 rtl:rotate-180" />
</button>
)}
<form
className="flex-1 flex items-start sm:items-center justify-center px-6 pt-16 pb-12"
onSubmit={onSubmit}
// Hitting Enter inside an <input> normally triggers the
// form's default submit — and on a non-terminal wizard step
// that would silently publish the entity. Intercept Enter on
// non-terminal steps and treat it as "advance" instead, so
// keyboard users get the same flow as clicking Next.
//
// Textarea Enter is left alone — that's a legitimate newline
// character inside the field.
onKeyDown={(e) => {
if (e.key !== 'Enter') return;
if (isTerminal) return;
const target = e.target as HTMLElement;
if (target.tagName === 'TEXTAREA') return;
// IME composition still in progress — don't hijack.
if (e.nativeEvent.isComposing) return;
e.preventDefault();
if (submitting || isAdvancing || !canAdvance) return;
void handleAdvance();
}}
>
<div
key={step}
className="w-full max-w-md mx-auto space-y-6 animate-in fade-in slide-in-from-bottom-2 duration-300"
>
{/* Centered title block captive-onboarding cadence: large
heading + muted subtitle, no progress eyebrow (the
top-of-page bar carries that signal). */}
<div className="space-y-2 text-center">
<h2 className="text-2xl font-bold tracking-tight">{current.title}</h2>
{current.subtitle && (
<p className="text-sm text-muted-foreground">{current.subtitle}</p>
)}
</div>
{/* Step body. Step 1's optional lead (e.g. the campaign
wizard's org chip) rides along here so the
"publishing-as" context is the first thing the user
sees. No card chrome onboarding keeps the content
area visually quiet so the focus stays on the fields. */}
<div className="space-y-3">
{step === 1 && step1Lead}
{current.body}
</div>
{errorAlert}
{/* Footer.
- Non-terminal steps: primary "Next" advances the wizard.
When `launchNowLabel` is provided and the user has
cleared `launchAvailableFromStep`, a ghost shortcut sits
beneath Next so the remaining steps are opt-in.
- Terminal step: the primary submit button is the only
forward action.
- Back navigation lives in the top-left header chrome,
not here. */}
<div className="space-y-3 pt-1">
{isTerminal ? (
<Button
type="submit"
disabled={!canSubmit}
className="w-full h-12 text-base rounded-full"
>
{submitButtonContent}
</Button>
) : (
<>
<Button
type="button"
onClick={() => void handleAdvance()}
disabled={submitting || isAdvancing || !canAdvance}
className="w-full h-12 text-base rounded-full"
>
{t('common.next')}
</Button>
{launchVisible && (
<Button
type="submit"
variant="ghost"
disabled={!canSubmit}
className="w-full"
>
{submitting ? submitButtonContent : launchNowLabel}
</Button>
)}
</>
)}
</div>
</div>
</form>
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More