Compare commits

...

218 Commits

Author SHA1 Message Date
2ro 565a2d1eca brand: phoenix in the remaining mobile/PWA assets; drop dead splash rasters
Deploy to GitHub Pages / deploy (push) Waiting to run
Test / test (push) Waiting to run
Rounds out the phoenix rebrand for assets the earlier logo.svg swap
missed:

- public/icon-192.png / icon-512.png (PWA install icons, referenced by
  manifest.webmanifest) still had the old bolt-on-circle mark.
- Android's push-notification status-bar icon (ic_stat_ditto, all
  densities) was carrying an even older mark — a ringed-planet icon
  from 'Ditto', the project two forks back. Regenerated as a white
  phoenix silhouette (status-bar icons must be flat white on
  transparent; the OS tints them).
- android/.../splash_icon_vector.xml, the actual live Android 12+
  splash icon (wired via styles.xml's windowSplashScreenAnimatedIcon),
  had the old double-bolt path hardcoded. Replaced with the phoenix
  path and recomputed the scale/center math for its 1446x1246 viewBox.
- ios/.../Splash.imageset (wired into LaunchScreen.storyboard) had an
  even older blue crossed-arrow mark predating Agora. Regenerated as
  the brand-orange phoenix on white, matching the existing small
  centered-mark proportions.

Also deleted the legacy per-density drawable*/splash.png and
drawable/splash_icon.png rasters: confirmed unreferenced by any theme,
manifest, or code (the app fully migrated to the Android 12
SplashScreen API / splash_icon_vector.xml), so they were dead weight
carrying outdated branding no user could ever see.

generate-icons.sh now produces all of the above so future logo changes
regenerate everything in one pass instead of missing assets again.

Note: unlike the web assets, the Android/iOS changes here only take
effect on the next native app build + store release — there's no web
deploy step for them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 13:41:57 -04:00
2ro aa936b59f9 brand: recreate og-image.jpg with Eranos marks; drop 'Who made Eranos?' FAQ
Test / test (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Has been cancelled
og-image.jpg (the link-preview image for eranos.fund) still carried the
Agora bolt logo and wordmark, plus an in-app mockup labeled 'Agora'.
Recreated it in-place via a headless-Chromium render: same world-map/
phone-mockup background, Agora branding swapped for the Eranos phoenix
in both spots.

Also removed the 'Who made Eranos?' FAQ entry, which credited Soapbox/
Ditto (the upstream Agora fork) rather than describing Eranos itself.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 03:07:15 -04:00
2ro ba8ba5fef9 content: fix donor guide privacy section — nonlogs.io link was on the wrong card
Test / test (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Has been cancelled
The nonlogs.io link previously sat on 'Skip the public receipt', a
behavioral tip with no relation to exchanges — clicking through led
somewhere the surrounding copy didn't explain. Looked up NonLogs (a
privacy-first exchange, no KYC/logs/account required, listed by the
Grin community for GRIN/USDT trading) and gave it its own card next to
'Acquire Grin without KYC', where it actually belongs. 'Skip the
public receipt' is now a plain tip with no dangling external link.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 03:00:41 -04:00
2ro 68fdc01fc9 content: donor guide privacy section — Bisq as an example, nonlogs.io link, softer exchange warning
Test / test (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Has been cancelled
- 'Acquire Grin without KYC' copy now names Bisq as one option, not the
  only one.
- 'Skip the public receipt' now links to nonlogs.io (no-KYC exchange)
  instead of a coinjoin wallet, which didn't match the card's point.
- 'Exchanges know who you are' overstated that all exchanges track
  withdrawals; softened to 'some' and points to nonlogs.io as the
  no-KYC alternative.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 02:16:52 -04:00
2ro 3f8c14ab69 content: recipient guide cash-out — swap exchange link, drop spend-directly
Deploy to GitHub Pages / deploy (push) Has been cancelled
Test / test (push) Has been cancelled
Point the Exchanges tile at nonlogs.io (no-KYC) and match its chip/copy
to the Peer-to-peer tile's 'no KYC' framing. Drop the Spend it directly
tile — no vetted resource to link yet.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 23:36:53 -04:00
2ro d19cdace19 brand: feed empty-state hero says Eranos, not leftover Agora
Deploy to GitHub Pages / deploy (push) Has been cancelled
Test / test (push) Has been cancelled
LandingHero (shown as the splash above the feed) hardcoded the
fork-original 'ÁGORA' brand name instead of config.appName like
TopNav does.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 23:27:58 -04:00
2ro 7c173af097 brand: swap Bitcoin coin for Grin/MimbleWimble coin on About page step 2
Deploy to GitHub Pages / deploy (push) Has been cancelled
Test / test (push) Has been cancelled
step-2-send.jpg showed a Bitcoin-B coin; Eranos is Grin-only. Replaced
with the MW smiley-coin, matching the alt text already in en.json.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 23:20:19 -04:00
2ro 4e75f56d4d brand: phoenix in the inline LogoIcon/AgoraBoltIcon components
Deploy to GitHub Pages / deploy (push) Has been cancelled
Test / test (push) Has been cancelled
The header, sidebar, hero, onboarding, and auth screens draw the brand
mark from inline React SVG components, not /logo.svg — so the previous
logo swap didn't reach them. LogoIcon now carries the phoenix path
(currentColor); AgoraBoltIcon wraps it in fixed brand yellow.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 18:45:05 -04:00
2ro 8933c00974 brand: new phoenix logo
Deploy to GitHub Pages / deploy (push) Has been cancelled
Test / test (push) Has been cancelled
Replace the diamond-bolt mark with the phoenix (brand yellow #fcd414).
Regenerate favicons, web logo.png, and Android/iOS launcher icons from
the new public/logo.svg; update the generator scripts for the new
viewBox (1446x1246) and fill color.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 18:17:12 -04:00
2ro efb13c7fbe copy: purge leftover Bitcoin/silent-payments text, describe the real Grin flow
Deploy to GitHub Pages / deploy (push) Has been cancelled
Test / test (push) Has been cancelled
2026-07-02 14:56:33 -04:00
Goblin ce63127e69 eranos: use our own relay (nrelay.us-ea.st), not Agora's ditto relays
Deploy to GitHub Pages / deploy (push) Has been cancelled
Test / test (push) Has been cancelled
Starts clean instead of showing every Agora campaign.
2026-07-02 09:21:15 -04:00
Goblin f131198feb Eranos: Grin-only fundraising (rebrand + Grin payments + gold)
Deploy to GitHub Pages / deploy (push) Has been cancelled
Test / test (push) Has been cancelled
Rebrand Agora to Eranos and strip the non-Grin rails. Add Grin donations:
a GoblinPay client + GrinPayDialog, on-chain payment-proof verification
(receiver-sig + kernel-on-chain + dedupe), and a proof-verified campaign
tally (kind 3414). Shift the brand from orange to gold. 118 tests green.
2026-07-02 08:12:51 -04:00
Chad Curtis 24009cabdd release: v2.9.1
Deploy to GitHub Pages / deploy (push) Has been cancelled
Test / test (push) Has been cancelled
2026-06-27 12:48:41 -05:00
Chad Curtis 817144c146 Fix Venezuela relief popup donate button navigating to home
The popup's donate button and learn-more link used a React Router
<Link> inside the Radix dialog. The dialog's click handling swallowed
the SPA navigation, so the click only closed the popup and left the user
on the home page — looking like the popup cleared itself out and went
nowhere.

Point both at the absolute production URL (https://agora.spot/venezuela-relief)
via a plain <a href>, which does a full, reliable navigation regardless
of the dialog's event handling.
2026-06-27 12:47:04 -05:00
Chad Curtis 6e03a4abb9 Showcase all Venezuela relief campaigns instead of one
Replace the single baked-in terremoto-venezuela campaign with a live,
agnostic showcase: every Venezuela-located campaign tagged
`humanitarian-aid` or `emergency-relief` and created since the
earthquake is pulled in and rendered as a card carousel across the home
hero and the dedicated relief page.

- New useVenezuelaReliefCampaigns hook queries kind 33863 filtered by
  `#i: [iso3166:VE]` + `#t: [humanitarian-aid, emergency-relief]` +
  `since` (quake timestamp), and aggregates on-chain raised totals the
  same way useProfileCampaignStats does (one /address lookup per
  campaign, summed; no per-receipt /tx fan-out). The flagship
  terremoto-venezuela campaign predates the geo-tag convention and
  carries no `iso3166:VE` tag, so it's pinned by coordinate and merged
  ahead of the filtered results, deduped by aTag.
- Extend useCampaigns with `categories` (`#t`) and `since` options.
- New VenezuelaReliefShowcase: an auto-panning marquee of CampaignCards
  (seamless -50% loop, hover/focus pause, click-drag + momentum, edge
  fades, prefers-reduced-motion fallback to a native scroll rail),
  ported from the surveil deck shelf.
- Hero/page CTAs and the popup donate button now point at the relief
  page (no more single-campaign naddr deep-link); the page CTA scrolls
  to the showcase. Drop the embedded CampaignDetailPage, the
  .relief-donate-flash CSS, and the now-unused scroll-to-campaign logic.
- VenezuelaReliefGoal now reads the aggregate hook and shows the
  combined raised total + matching-campaign count only (no goal bar —
  an aggregate goal across independent campaigns is meaningless).
- Tighten the hero height so the carousel sits near the fold.
- Locale updates across all 16 languages: drop `startCampaign`/`goalOf`,
  add `showcaseTitle` + `campaignCount`.
2026-06-27 12:40:18 -05:00
Chad Curtis 89eb5fb6ac release: v2.9.0 2026-06-25 16:16:05 -05:00
Chad Curtis 89f78914c2 Bake Venezuela relief campaign into appeal surfaces
Resolve the terremoto-venezuela campaign (naddr) directly in the relief
banner, popup, and dedicated page, turning each into an info + donation
hybrid:

- New useVenezuelaReliefCampaign hook + VenezuelaReliefGoal component
  surface the campaign's live raised/goal progress in all three surfaces.
- Donate CTAs deep-link to the campaign naddr; on the dedicated page the
  full campaign detail (story, donate panel, ledger, comments) is embedded
  below the appeal hero, and "Donate to relief" smooth-scrolls to the
  donate panel and flashes a highlight ring (mobile inline + desktop
  sidebar). Scroll margin clears the sticky nav.
- Drop the "Raise funds for Venezuela" button; shrink the page hero.
- Add the goalOf locale key across all 16 locales.
2026-06-25 16:13:47 -05:00
Chad Curtis eb35eeaf63 Merge branch 'venezuela-relief-popup' into 'main'
Add Venezuela relief banner to home page

See merge request soapbox-pub/agora!43
2026-06-25 16:10:03 +00:00
Chad Curtis 96335c1472 Add Venezuela relief popup, shareable page, and share buttons
Extend the home-page Venezuela earthquake relief appeal beyond the hero
banner so it can reach more visitors and be shared directly:

- Session popup (VenezuelaReliefPopup) mounted site-wide in AppRouter.
  Shows once per browser session (sessionStorage) on a fresh load of any
  route, carrying the same photo, headline, and donate CTA as the hero.
- Dedicated /venezuela-relief page (VenezuelaReliefPage) so the appeal
  has its own shareable URL with SEO/OG metadata and a "how your donation
  helps" non-custodial explainer. The hero and popup link to it via
  "Learn more".
- Share buttons (native share sheet, clipboard fallback via shareOrCopy +
  useShareOrigin) on the hero banner, popup, and page.
- Shared constants in lib/venezuelaRelief.ts (route, image gallery,
  donate deep-link, popup sessionStorage key) keep all three surfaces in
  sync.
- Fix the highlighted "you" word to match the home hero idiom
  (leading-[0.95] so the orange block hugs the cap height).

The popup decides whether to open exactly once per page load via a
module-level guard, so React 19 StrictMode's double-mount can't write the
"seen" flag on the first mount and then suppress the popup on the remount
(which made it flash open and immediately dismiss).

New venezuelaRelief locale keys added to en.json and translated across
all fifteen other locales.
2026-06-25 10:53:43 -05:00
Chad Curtis ee189f6455 Remove orphaned common.donors locale keys
The donors_one/donors_other plural keys were the last consumer of the
donor count, which the previous commit dropped from the campaign card.
Delete them from en.json and all fifteen other locales.
2026-06-25 10:04:50 -05:00
Chad Curtis 88bb768658 Drop donor count from campaign cards to skip the per-receipt tx fan-out
The card footer showed "x donors", sourced from useCampaignDonations'
donorCount. Populating that count required fetching every kind 8333
receipt for the campaign and then verifying each one with a per-receipt
Esplora /tx call — a fan-out that, across a ~200-card grid, hammered
every configured Esplora backend.

Cards only ever render the raised total (the progress bar), which needs
just the single /address balance lookup. Add a receipts option to
useCampaignDonations that skips the receipt fetch and verification
fan-out, and have CampaignCard pass receipts: false. Remove the now-dead
donor-count UI and its unused t()/useTranslation import.
2026-06-25 10:04:50 -05:00
Chad Curtis 9362ebf8d6 Add Venezuela earthquake relief banner to home page
Pin a full-bleed emergency relief banner to the top of the home page
during the Venezuela earthquake response. It rotates through news
photographs from Caracas via the shared HeroBanner (slow crossfade +
Ken-Burns pan), with a humanitarian headline and two CTAs: Donate to
relief (deep-links to /campaigns?country=VE) and Raise funds for
Venezuela (auth-gated campaign creation).

Also fix a latent HeroBanner crossfade bug where new layers mounted at
their final opacity, making transitions snap instead of fade; layers now
mount hidden and flip visible on the next animation frame.

Copy lives under campaigns.home.venezuelaRelief.* and is translated
across all 16 locales.
2026-06-25 10:04:50 -05:00
Alex Gleason fe16f2ac07 read: false from relay.primal.net and relay.damus.io 2026-06-25 15:48:33 +03:00
Alex Gleason 8adf351ae6 Localize "no Bitcoin" Cash App prompt 2026-06-25 15:44:56 +03:00
Alex Gleason 3f4746a72d Use official Cash App logo in "no Bitcoin" prompt 2026-06-25 15:35:57 +03:00
Alex Gleason 059dff3686 Add "Don't have Bitcoin?" Cash App prompt to campaign pages 2026-06-25 15:32:00 +03:00
lemon 9bfc3498da Remove unused WLC hero image 2026-06-24 10:02:32 -07:00
lemon 47a35d35cf Make DM backfill progressive and cursor-safe
Keep the fast pooled first render, but merge each read relay's DM backfill
into the visible inbox as soon as that relay finishes instead of waiting for
all relays. Track per-relay sent/received cursors so older pagination cannot
skip dense relay history because another relay returned older sparse events.

Timed-out or failed relays are kept retryable instead of being marked
exhausted, while successful relay cursors unlock deterministic pagination.
Backfilled events still dedupe by id and update latest previews/sort order.
2026-06-23 12:47:44 -07:00
lemon 734be77be7 Render DM inbox fast, then backfill slower relays
Use the shared pooled query for the initial messages page so the inbox keeps
Ditto-style fast first paint with the provider's short eoseTimeout. Page 0 is
marked pending backfill, then a background effect queries each configured read
relay individually, merges those slower results into the same infinite-query
cache entry, and unlocks older-page pagination after backfill completes.

Also update conversation merging so a backfilled newer event updates the row's
latest preview and sort order.
2026-06-23 12:37:34 -07:00
lemon 301309f03c Fetch DMs from every read relay instead of racing the pool
The inbox query went through the shared pool, whose eoseTimeout resolves as
soon as the first relay sends EOSE (+300ms). DMs held only by slower relays
were dropped, so each hard refresh returned a different, incomplete set of
conversations. Fan the kind-4 query out to each configured read relay
individually (Promise.allSettled, bounded by the existing 8s cap) and merge
deduped by event id, so every relay contributes its full set.

The inbox query key now carries the read-relay list; update the send-message
optimistic cache writes to match ['direct-messages', pubkey] by prefix so the
append still lands.
2026-06-23 12:27:00 -07:00
lemon 05c1d1bf39 Hide muted DM conversation immediately on mute
handleMute awaited addMute's relay round-trip (fetch fresh kind 10000 +
publish) before hiding the conversation, leaving the muted thread visible
for a few seconds. Hide the peer optimistically the moment the user
confirms; the relay-backed mute list still reconciles on reload.
2026-06-23 12:22:27 -07:00
lemon cf9535ff39 Add new-chat compose flow to /messages with inline recipient autocomplete
Adds a 'New message' button to the conversation list (and the empty-pane
prompt) that opens a fresh compose pane in the right column instead of a
modal. The pane has a 'To:' field that drives debounced profile search via
useSearchProfiles (name / NIP-05 / npub / nprofile), rendering suggestions
inline beneath the field — no popover. Arrow-key navigation + Enter select a
recipient; choosing one opens the normal thread (existing conversation or a
fresh blank thread). Adds the supporting locale strings across all locales.
2026-06-23 09:26:06 -07:00
lemon a76d53e686 Fix status bubble visibility — use top offset instead of bottom-full
bottom-full anchored the bubble above the avatar block's top edge, which
margin-collapses up into the banner, pushing the bubble off the visible
area. Position it with a top offset within the same coordinate space the
original used, lowered so its tail rests at the banner's bottom edge.
2026-06-23 08:53:37 -07:00
lemon 7e6fe43b86 Polish profile: lower status bubble, plain campaign cards, themed raised total
- Anchor the NIP-38 status bubble to the bottom edge of the banner so it
  no longer overlaps the follow/donate action buttons.
- Drop the founder/verified footer badges from the profile Agora tab so its
  campaign cards render identically to the campaigns page CampaignCard.
- Recolor the 'raised' stat from text-orange-500 to text-primary to match
  bio/profile hyperlink coloring.
2026-06-23 08:50:32 -07:00
lemon 809f4aa7dc Align login back arrow vertically with the dialog close button
The back arrow used top-5 on a size-9 padded button, sitting lower than
the X close button (top-4, size-5 icon). Move it to left-2 top-2 so its
icon center lines up with the X on the opposite side.
2026-06-21 00:13:40 -07:00
lemon c380ef70ae Match login secret-key input and upload button height to submit button
Bump the nsec input and key-file upload icon button to h-12 / size-12 so
they line up with the full-width 'Log in' submit button below them.
2026-06-21 00:11:33 -07:00
lemon 97f5f82b05 Rework login modal: translations, layout, and UX
Overhaul AuthDialog's login step:
- Add translation keys for every previously-hardcoded string (titles,
  buttons, placeholders, status labels, validation/errors) across all
  sixteen locales.
- The secret-key form is no longer collapsible — it's always open and is
  the first option, followed by 'Log in with extension', then a
  text-with-arrow link to the remote-signer step.
- Move the key-file upload icon onto the same row as the nsec input
  (instead of beside the submit button); submit button is now full-width
  below.
- Surface extension-login errors as a destructive toast rather than
  writing them into the nsec input's inline error.
- Replace the centered 'Back' text buttons with a back arrow in the
  top-left of the dialog header.

Also correct AGENTS.md's i18n section: the project ships fifteen
non-English locales (hi, id, sw, tr, zh-Hant were missing from the list).
2026-06-21 00:08:52 -07:00
lemon b01f573bf9 Hide nav language switcher when logged in; swap hero CTA to Verify campaigns
Logged-in users change language via Settings, so the top-nav globe now
appears only when logged out (matching the search icon). On the homepage
hero, replace the 'Explore campaigns' button with 'Verify campaigns' and
order it before 'How it works'. Drop the now-unused exploreCampaigns
locale key across all locales.
2026-06-20 23:55:03 -07:00
lemon 59538e1798 Add search to account dropdown 2026-06-20 23:43:32 -07:00
lemon e132587b6d Add profile message action 2026-06-20 23:40:52 -07:00
lemon c78a7ac089 Match profile group card sizing 2026-06-20 23:30:26 -07:00
lemon 9498411e13 Avoid cramped profile Agora cards 2026-06-20 23:25:11 -07:00
lemon 5e07f72fb8 Refine profile Agora campaign grid 2026-06-20 23:21:46 -07:00
lemon e7ca4cc9b1 Merge profile Agora tabs 2026-06-20 23:13:34 -07:00
lemon 4d148218ac Move profile fields below bio 2026-06-20 22:59:35 -07:00
lemon 716579d91e Simplify profile overflow menu 2026-06-20 22:55:47 -07:00
lemon bba7fb045a Remove profile reaction action 2026-06-20 22:52:14 -07:00
lemon 557063c0f3 Match profile action button heights 2026-06-20 22:50:14 -07:00
lemon 65aeb724a3 Move profile stats above bio 2026-06-20 22:49:26 -07:00
lemon 21306afdb2 Inline raised profile stat 2026-06-20 22:48:20 -07:00
lemon 34b5babc01 Blend verification request button 2026-06-20 22:45:01 -07:00
lemon 43dee910b8 Remove verification request button border 2026-06-20 22:43:22 -07:00
lemon 979cc1d097 Refine verification request button 2026-06-20 22:42:16 -07:00
lemon 00579e5de2 Request verification by message 2026-06-20 22:38:49 -07:00
lemon 978ee5f77e Show verifier statements in messages 2026-06-20 22:32:13 -07:00
lemon 66bc7e80f2 Translate received direct messages 2026-06-20 21:38:56 -07:00
lemon 1b5d2bf771 Filter messages by followed users 2026-06-20 21:22:52 -07:00
lemon 495c2c7ce0 Add message thread header actions 2026-06-20 21:16:08 -07:00
lemon cdf47e2900 Smooth direct message sending 2026-06-20 21:00:08 -07:00
lemon 4d7a7acbae Fix direct message thread decryption 2026-06-20 20:55:49 -07:00
lemon 790787aa43 Remove message count badges 2026-06-20 20:51:16 -07:00
lemon ca972704fb Update messages composer controls 2026-06-20 20:50:25 -07:00
lemon 514db0cc59 Refine message send icon 2026-06-20 20:47:18 -07:00
lemon b34660f7d4 Soften messages input styling 2026-06-20 20:45:48 -07:00
lemon 4e0c97482a Restyle message send icon 2026-06-20 20:43:24 -07:00
lemon bf1770eec2 Unify message pane background 2026-06-20 20:40:54 -07:00
lemon e0f122aaad Remove messages pane borders 2026-06-20 20:37:29 -07:00
lemon b244e9d645 Fix messages viewport overflow 2026-06-20 20:35:50 -07:00
lemon 728a56af3f Search message peers by display name 2026-06-20 20:07:01 -07:00
lemon 5553a31ffa Constrain messages page scrolling 2026-06-20 20:01:03 -07:00
lemon 7a4259db6a Paginate direct messages like feeds 2026-06-20 19:54:24 -07:00
lemon 1e4c0735e6 Redesign and speed up messages page 2026-06-20 19:45:14 -07:00
lemon 2e0defa977 Page direct-message history backwards so old conversations load 2026-06-20 19:38:40 -07:00
lemon 0e75799cf0 Add a NIP-04 direct messages page 2026-06-20 11:35:51 -07:00
mkfain e4b0f9a80d Add a corporate sponsorship page
Add /sponsors, a landing-style page for companies that want to partner
with the platform. Lays out the three ways to get involved: an
unrestricted gift to the Agora Seed Fund (BTC or USD), a matching pool
for an individual campaign, a featured campaign, or a curated list of
campaigns (e.g. political prisoners or women's sovereignty in Africa),
and promoting Agora donations to a customer base as a philanthropic
initiative.

Modeled on AboutPage's section recipe (dark hero, alternating section
backgrounds, hand-rolled cards) and routed under the wide
FundraiserLayout above the /:nip19 catch-all. Adds a Sponsors link to
the site footer. English copy only for now; other locales fall back to
English until the copy settles.
2026-06-19 10:54:13 +03:00
lemon 374a1d62de Localize the 'Your name' placeholder in the profile editor
ProfileCard's namePlaceholder default was a hardcoded 'Your name'
literal used by the profile/org editor onboarding steps. Default it to
the new profile.namePlaceholder key (translated across all locales) and
resolve it via t() in the component body; callers that pass their own
placeholder (e.g. the campaign name step) are unaffected.
2026-06-14 17:46:59 -07:00
lemon ccf6b59ae1 Backfill missing translations across all 15 non-English locales
Keys added to en.json over time (notably the verifier create-account
onboarding flow, the walletDoubleTweak recovery namespace, and the
campaign-wizard tag step) were never propagated to the other locales,
so they silently fell back to English at runtime — which is why the
verify-campaign onboarding steps still rendered in English under e.g.
Chinese despite the components calling t().

Translate every key present in en.json but missing from each locale
(84-157 keys each, 1553 total) so all sixteen locales are now complete
with zero English fallbacks. Placeholders, technical tokens, and the
Agora name are preserved verbatim; plural categories follow each
language's grammar.
2026-06-14 17:39:16 -07:00
lemon 5375466e15 Translate the secret-key field aria-label in onboarding
The nsec reveal input in the onboarding 'Save your key' step carried a
hardcoded English aria-label. Route it through
onboarding.secure.secretKeyAriaLabel and translate across all locales.

This was the last untranslated user-facing string in the verifier
create-account flow.
2026-06-14 17:26:01 -07:00
lemon d1e579dda3 Redesign the auth welcome step around the Agora brand
Drop the 'Welcome to Agora' header and the Nostr-flavored subtext from
the welcome step. The Agora bolt mark and wordmark are now the focal
point — laid out inline with the same tight spacing and Bebas display
recipe as the top-nav wordmark, just larger — and the wordmark doubles
as the dialog's accessible title.

Retitle the two buttons to 'Create new account' / 'Log in with
existing' (no Nostr phrasing) and route them through new auth.* keys,
translated across all sixteen locales.
2026-06-14 17:22:38 -07:00
lemon 786938f9fc Translate the image crop dialog and verify-tutorial goal label
The crop modal used in the verifier 'set up your organization'
onboarding step had hardcoded English controls (Reset, the
reposition/zoom hint, Cancel, Processing, Apply Crop, and the default
title). Route them through a new top-level imageCrop namespace and
add translations for all sixteen locales.

The VerifyTutorial demo card's 'of … goal' label was also hardcoded;
reuse the existing campaignsDetail.ofGoal key.
2026-06-14 17:09:58 -07:00
lemon 0486ff3c9f Use a globe icon and tidy the language switcher column
Swap the top-nav language trigger from the Languages glyph to Globe — the
more universal "change language" affordance.

Stop flipping individual RTL rows: the previous per-item dir="rtl" pushed
Arabic / Persian / Pashto names to the far edge while LTR rows hugged the
left, leaving a ragged, jarring column. Force every row LTR-aligned
(text-left) so the selected dot and the names share one clean left edge,
while each name keeps its own lang attribute so the browser still shapes
Arabic/Persian/Khmer/CJK glyphs correctly.
2026-06-14 16:48:00 -07:00
lemon f6c5b69b5c Add an always-visible language switcher to the top nav
A visitor who lands in a language they can't read had no discoverable way
to change it: on desktop the only chrome was the search icon + login, and
the picker was buried at /settings/language (itself only reachable through
the mobile hamburger's "Settings" — a word they may not be able to read).

Add a globe/languages icon to the top-nav right cluster, visible to
everyone regardless of login state. It opens a dropdown listing every
supported language by its own native name (each row rendered in its own
lang/dir), so the right entry is recognizable no matter the current UI
language. Selecting one switches in place via changeAppLanguage without
navigating away, so scroll position and in-progress form state survive.
The /settings/language page remains the canonical deep-link.

New key nav.language added to en.json and all 15 other locales (reusing
each locale's existing language.title translation).
2026-06-14 16:43:58 -07:00
lemon b034716fca Translate strings introduced during the verify-onboarding work
Wire the branch-new hardcoded UI strings through i18n:

- ProfileCard's shared image-edit menu ("Upload file", "Paste URL",
  "Remove") and the website-slot placeholder now use t(); the component
  gains useTranslation.
- The campaign profile step's name placeholder regressed from a
  translated string to a literal "Campaign title" when it switched to the
  shared identity card; restore it via a new
  onboarding.profile.campaignNamePlaceholder key.

New keys added to en.json and translated into all 15 other locales:
profile.imageMenu.{upload,pasteUrl,remove}, profile.websitePlaceholder,
and onboarding.profile.campaignNamePlaceholder.

The plural keys flagged by a first pass (campaignsCount, seeAllCampaigns,
followersTitle, profile.verified.count, etc.) already resolve via
i18next's _one/_other suffixes and needed no change. Pre-existing
untranslated strings that predate this branch (e.g. ImageCropDialog) were
left untouched.

Regression-of: a7a9ed06
2026-06-14 16:34:03 -07:00
lemon 3534a0c4f7 Consolidate duplicated profile-edit logic in onboarding and campaigns
These onboarding/campaign surfaces accumulated several copies of the same
logic over the iterative work. Fold them into shared modules:

- Kind-0 publishing: the campaign-creator profile step reimplemented the same
  read-modify-write publish that usePublishOrgProfile already had (fetch fresh
  -> parse -> merge name/picture/banner/about -> publish with prev), plus its
  own parseProfileMetadata. Route it through the shared hook.

- Draft type: the identical ProfileIdentityDraft and OrgProfileDraft
  interfaces become one ProfileDraft in src/lib/profileDraft.ts, alongside
  parseProfileMetadata/mergeProfileDraft helpers. The hook no longer imports
  its draft type from a component module.

- Auto-grow textarea: the campaign story and verifier bio fields shared an
  identical borderless textarea with copy-pasted height-resize handlers and
  styling. Extract an AutoGrowTextarea component plus an autoGrowTextarea
  helper (also reused by ProfileCard's editable fields).
2026-06-14 16:23:30 -07:00
lemon 222968286b Shorten profile edit button label to 'Edit'
The action now sits in a compact row beside the avatar, so the verbose
'Edit profile' label is unnecessary. Shorten profile.header.editProfile
to just 'Edit' across all locales.
2026-06-14 16:15:12 -07:00
lemon 37dd7dc3e7 Move profile actions onto the avatar row on mobile
On sm/md the Edit Profile / QR / more (or Follow / Donate) buttons sat in
a full-width row below the bio. Put them on the same row as the avatar,
bottom-right, Twitter/X-style: ProfileIdentityHeader gains a hideActionBar
prop, ActionBar is exported with an align prop, and the mobile layout
renders the avatar and a right-justified ActionBar (align="end") together.
With align="end" the Edit Profile button sizes to its label instead of
stretching full-width.
2026-06-14 16:13:32 -07:00
lemon c39caab068 Use the larger profile avatar size at every breakpoint
The profile avatar was size-28 below md and size-32 at md+, so it shrank
on small screens. Pin it to size-32 everywhere and drop the matching
responsive overhang (-mt-20), status-bubble offset, and skeleton
placeholders to the larger variant so the layout stays aligned.
2026-06-14 16:09:45 -07:00
lemon b0fc77ada1 Let verify tutorial demo card fill the /verify card width
On /verify the VerifyTutorial rendered without the stacked prop, so its
DemoStage stayed capped at max-w-md inside the much wider max-w-2xl
verifier panel, leaving the animated campaign card looking too small for
its parent. Pass stacked so the demo spans the full container width.

Regression-of: 96c7acfc
2026-06-14 16:07:38 -07:00
lemon 7c4ee25379 Keep profile banner height constant and inscribe avatar crop circle
The recent profile banner/sticky-rail experiments (the 3:1 cover crop,
desktop banner isolation, square corners, and the various sticky/pinned
identity-rail attempts) were reverted off this branch; they added
complexity without landing cleanly.

Re-apply the two wanted tweaks on the clean baseline instead:

- Banner height is now h-48 at every breakpoint rather than shrinking to
  h-36 below md, so it no longer gets needlessly shorter on small screens.
- The avatar crop dialog draws a dashed circle inscribed in the existing
  (square) crop boundary, tracking the cropper's actual rendered crop size
  via onCropSizeChange so it stays flush as the boundary resizes. Wired in
  for the avatar (picture) crops in ProfileSettings and the onboarding
  ProfileIdentityEditor; banner/cover crops are unaffected.
2026-06-14 16:03:34 -07:00
lemon 96c7acfca2 Widen VerifyTutorial demo card on the verify page
The centered (non-stacked) demo card was capped at max-w-sm, which looked
undersized inside the wide card on the /verify page. Bump it to max-w-md so
it better fills the parent. The onboarding flow uses the stacked/full-width
path and is unaffected.
2026-06-14 16:03:34 -07:00
lemon 741ce22d97 Show logged-in user's avatar in verify page tutorial 2026-06-14 16:03:34 -07:00
lemon 1e7bfae560 Replace verify nav tab with CTA buttons on home and campaigns 2026-06-14 16:03:34 -07:00
lemon cf635a19ff Restore verify page editor 2026-06-14 16:03:34 -07:00
lemon 0a6a5830d0 Add verify navigation route 2026-06-14 16:03:34 -07:00
lemon a7a9ed06a3 Reuse organization banner card for campaign title step 2026-06-14 16:03:34 -07:00
lemon 6ac6c0b22f Put campaign banner above name and match the profile name field style 2026-06-14 16:03:34 -07:00
lemon c27dfb212a Restyle the campaign story field to match the org bio step
Swap the FormSection-wrapped mono textarea on "Tell your story" for the
borderless, auto-growing muted textarea used by the organization bio
step, so the two long-form surfaces look the same.
2026-06-14 16:03:34 -07:00
lemon 35b8786e77 Combine the campaign name and banner into one wizard step
Fold the standalone "Add a banner" step into the "Name your campaign"
step (title required, banner optional), removing one wizard step.
Adjust the launch-shortcut step index and block advancing while the
banner is still uploading.
2026-06-14 16:03:34 -07:00
lemon 9ef5d2cf6a Reduce campaign profile step to avatar and name only
Add showBanner and a bioField "none" option to ProfileCard (threaded
through ProfileIdentityEditor), and drop the banner and bio from the
campaign creator's "Put a face to your campaign" step so it asks only
for an avatar and name.
2026-06-14 16:03:34 -07:00
lemon 814589e535 Reuse the org identity card on the campaign profile step
Extract the verifier identity step's ProfileCard + crop/upload/paste/
remove machinery into a shared ProfileIdentityEditor, parameterized by
bioField ('website' for organizations, 'about' for campaigners) and an
aboutPlaceholder. VerifierIdentityStep now wraps it for the org flow.

The campaign creator's "Put a face to your campaign" wizard step now
renders the same banner + avatar + name + bio card instead of the old
plain name/avatar/collapsible-about form, with "A little about you…" as
the bio placeholder. The wizard already supplies the back arrow and
progress bar, and the published kind-0 now carries the banner too.
2026-06-14 16:03:34 -07:00
lemon 1a27b30e77 Move verifier withdraw to the profile "How We Verify" card
Remove the inline Withdraw button (and its props) from
VerifierStatementEditor, and drop the success toast from the onboarding
"Publish your verifier statement" step. Withdrawing now lives in the
top-right of the "How We Verify" card on the user's own profile — gated
on isOwnProfile, mirroring the Edit Profile affordance — with an
AlertDialog confirmation before retracting the kind 14672 statement.
2026-06-14 16:03:34 -07:00
lemon fb10250d1b Share avatar/banner image menu with working Remove action
Deduplicate the avatar and banner edit menus into a single ImageEditMenu
in ProfileCard: "Upload file" (replacing "Change avatar"/"Change banner"),
optional "Paste URL", and a generic "Remove" (replacing "Remove avatar").
The banner gains the Remove action via a new onRemoveBanner prop.

Fix the non-working Remove in the organization setup step: the verifier
identity step never passed remove handlers, and its onChange ignored
picture/banner — so removing an image did nothing. Wire onRemoveAvatar /
onRemoveBanner to clear the draft fields directly. Pass onRemoveBanner in
ProfileSettings too.
2026-06-14 16:03:34 -07:00
lemon 560aca69ee Hide the Groups and Pledges profile tabs for now 2026-06-14 16:03:34 -07:00
lemon b916b1193e Move "How We Verify" statement from Overview to the Verified tab 2026-06-14 16:03:34 -07:00
lemon 4d271a11ec Match verifier bio and statement editors, narrow org steps
The org bio textarea now uses the same muted, borderless fill as the
'Your name' field on the identity step, starts at min-h-200, and
auto-grows as the user types. The verifier statement (Milkdown) editor
gets the same muted wrapper and a scoped 200px min-height so the two
steps match. The bio, statement, and how-to steps drop from max-w-3xl
to max-w-xl so the boxes and the tutorial's last step aren't oversized.
2026-06-14 16:03:34 -07:00
lemon fb66ed284b Rasterize pasted images at a usable width for cropping
A pasted SVG (or any small source) was rasterized by the proxy at its
tiny intrinsic size, so the cropper showed a speck in the preview box.
Request a target width (1500 banner / 1024 avatar) with fit=inside so
small and vector sources are enlarged to a workable crop resolution.
2026-06-14 16:03:34 -07:00
lemon f534500075 Fetch pasted organization images as files before cropping
Pasted image URLs were handed to the cropper as a raw remote src, so
encodeImage's canvas fetch hit the origin directly and failed CORS
(e.g. nips.nostr.com SVGs, or any host when the image proxy is off).
Now the paste handler fetches the bytes through the image proxy into an
object URL and feeds them through the same crop -> Blossom-upload flow
as a local file, so the cropper only ever sees a same-origin blob:.
2026-06-14 16:03:34 -07:00
lemon e07f04fad2 Fix pasted organization image crop processing 2026-06-14 16:03:33 -07:00
lemon c6eef561b3 Make organization setup card transparent 2026-06-14 16:03:33 -07:00
lemon 00daa2dfaa Open pasted organization images in crop editor 2026-06-14 16:03:33 -07:00
lemon 30da87ddf4 Gate campaigns button until tutorial completes 2026-06-14 16:03:33 -07:00
lemon 3e1de5bf4f Use two second verifier tutorial timing 2026-06-14 16:03:33 -07:00
lemon f58128ede0 Tune verifier tutorial reveal timing 2026-06-14 16:03:33 -07:00
lemon 1dd51c7029 Slow verifier tutorial reveal timing 2026-06-14 16:03:33 -07:00
lemon b34a7e0d54 Simplify verifier tutorial timing loop 2026-06-14 16:03:33 -07:00
lemon 7c46f4cd73 Fix verifier tutorial replay loop 2026-06-14 16:03:33 -07:00
lemon da9862c53a Match verifier bio textarea to statement editor 2026-06-14 16:03:33 -07:00
lemon 2b3b6900dc Make verify tutorial a passive looping demo
Drop the clickable step buttons that let users scrub/pause the demo.
The animation already auto-advances and wraps around, so the gesture
now replays on an endless loop and users learn purely by watching.
The step list becomes a non-interactive read-out synced to the
animation. Removes the goto/paused reducer state and the
resume-after-scrub timer.
2026-06-14 16:03:33 -07:00
lemon 933f1eb5c6 Remove redundant label from verifier bio step
The 'About your organization' sub-header duplicated the step title.
Drop the visible Label and keep it as an aria-label so the textarea
stays accessible. Input text size already matches the verifier
statement editor (1.125rem / text-lg).
2026-06-14 16:03:33 -07:00
lemon b1ec7538b3 Mirror a real campaign and the live verified badge in the verify tutorial
The how-to demo card now reflects a real published campaign (the Agora
App Development Fund) instead of invented placeholder copy: real title,
organizer, banner image, and goal/progress. The verified badge is now a
faithful copy of the live overlay CampaignVerificationBadge (dark
translucent pill, ring-bordered avatar, sky-300 check, no count) for a
single verifier, so the preview matches exactly how a verification
surfaces on a real card.

Drops the now-unused demo i18n keys (campaignTitle, campaignOrganizer,
verifiedBadge) across all locales.
2026-06-14 16:03:33 -07:00
lemon 92091c8570 Match bio step sizing to the verifier statement step
The bio step now uses the same wide (max-w-3xl) layout and a large
text surface (min-h-[400px], text-lg) so it visually matches the
markdown statement editor, minus the markdown formatting.
2026-06-14 16:03:33 -07:00
lemon 35ce3f87e1 Tighten verifier onboarding step subtitles 2026-06-14 16:03:33 -07:00
lemon 9640d8790f Preview verifier badge on the how-to step and tighten step copy
Remove the badge preview from the org bio step and surface it on the
'How to verify a campaign' step instead, where the demo card now shows
the org's own avatar + name in place of the generic 'Verified by you'
label. Shorten the tutorial step descriptions.
2026-06-14 16:03:33 -07:00
lemon ff3804777a Add 'Paste URL' option to org setup avatar and banner pickers 2026-06-14 16:03:33 -07:00
lemon 207775b6b9 Drop 'your' from verifier role finder note 2026-06-14 16:03:33 -07:00
lemon abad9eca9d Blend the verify tutorial into the step; remove it from /organizations
Add hideHeader, bare, and stacked props to VerifyTutorial. The how-to step
now renders it borderless with the header hidden (single step header) and
the demo card stacked full-width above the step list so it matches the
button width below. Remove the tutorial from /organizations, leaving just
the 'Start verifying' CTA card.
2026-06-14 16:03:33 -07:00
lemon 528e87e9e4 Simplify the statement step; remove the editor from /organizations
Drop the 'Become a verifier' publish button: the statement step's primary
Continue button now publishes the kind 14672 statement and advances, with
an inline Withdraw for returning verifiers. Collapse the duplicated prompt /
disclaimer copy into a single header + subtext and make the editor
borderless. Replace the /organizations functional editor with a CTA that
launches the verifier onboarding flow.
2026-06-14 16:03:33 -07:00
lemon 716d3ee406 Mirror the verification badge in the bio-step preview
Replace the plain avatar+name+website header with a preview that mirrors the
inline verification badge (stacked avatar + check + name), so the user sees
how their logo and name will surface to donors. Drops the website from the
preview.
2026-06-14 16:03:33 -07:00
lemon 7ac8c58426 Make website replace the bio slot; relax identity requirements
Add a bioField prop to ProfileCard so the editable slot below the name can
edit the website instead of the bio. The verifier identity step now edits
the website inline (no separate input) and only requires avatar + name;
banner and website are optional, with website still https-validated when
entered.
2026-06-14 16:03:33 -07:00
lemon 9cc08c4989 Tighten role-picker copy to single lines
Shorten the verifier description so it fits on one line and update the
donor finder note to '100% of your donation goes to the campaign.'
2026-06-14 16:03:33 -07:00
lemon be5c7ac854 Add verifier sub-flow step 4: how to verify + finish
Replace the placeholder shell with the real how-to step, reusing the
animated VerifyTutorial and a terminal 'View campaigns' CTA that cancels the
overlay and navigates to /campaigns. Widen the captive content wrapper for
the statement and how-to steps to fit the editor and two-column tutorial.
2026-06-14 16:03:33 -07:00
lemon f04aa41aae Extract shared VerifierStatementEditor; embed as sub-flow step 3
Pull the kind 14672 publish/update/withdraw editor out of OrganizationsPage
into a reusable VerifierStatementEditor with an onPublishedChange callback.
OrganizationsPage now consumes it (logged-out gate and verify tutorial
unchanged), and the captive verifier sub-flow renders it as step 3 with a
Continue button that unlocks once a statement is published.
2026-06-14 16:03:33 -07:00
lemon e181c01b0b Publish the verifier organization profile (kind 0)
Add usePublishOrgProfile, which merges the collected org draft (name,
website, picture, banner, about) onto any existing kind-0 via
fetchFreshEvent + prev, guarded by an expectedPubkey check so a failed
signup auto-login can't overwrite another account. The bio step's continue
now publishes the profile before advancing; failure is non-fatal (toast +
proceed).
2026-06-14 16:03:33 -07:00
lemon 42cb23b48a Add verifier sub-flow step 2: organization bio
A required bio textarea (kind-0 about) with a small avatar + name preview
header carried over from the identity step for continuity. The bio is added
to the shared profile draft; publishing of the assembled kind-0 profile is
wired up in the next commit.
2026-06-14 16:03:33 -07:00
lemon 900689acab Add verifier sub-flow step 1: organization identity
Build the organization-identity screen on the shared ProfileCard (circular
avatar, rectangular banner, inline name) plus ImageCropDialog for uploads,
with a dedicated required website field. All four fields are required and
the website must be a valid https URL before continuing. The draft is held
in the captive overlay and published later; nothing is written here.
2026-06-14 16:03:33 -07:00
lemon 124a4d868f Scaffold the captive verifier sub-flow state machine
Add four sub-flow step keys (orgIdentity, orgBio, orgStatement,
orgVerifyHowto) that the verifier role enters instead of navigating away.
Wire the progress bar to the extended verifier step list, branch back/next
navigation through the sub-flow, and render placeholder shells. The
per-step UI lands in following commits.
2026-06-14 16:03:33 -07:00
lemon 77a50189d8 Add 'Verify campaigns' as a third onboarding role
Extend OnboardingRole and StartSignupOptions to include 'verifier', add a
third RoleCard to the role picker, and wire the pick handler to branch on
the new role. For now the verifier pick routes to the public /organizations
onboarding tool; a later commit replaces this with a captive sub-flow.
2026-06-14 16:03:33 -07:00
Alex Gleason 45f882e3aa Drop active-account indicator dot from avatar dropdown 2026-06-14 13:17:01 -05:00
Alex Gleason 47a8e35334 Slim down avatar dropdown menu
Remove Profile, Search, About, and Get the app items from the account
switcher dropdown. Show the active profile at the top of the profile
list (linking to your profile instead of switching), and reorder the
remaining items to Wallet, Notifications, My Dashboard, Settings.
2026-06-14 13:12:43 -05:00
Alex Gleason 7ce1ca87e9 Pin arti dependency and reconcile Tor activation docs
Address two follow-ups from the Tor (arti) MR review.

Supply-chain hardening for the arti-mobile AAR, a native artifact with
network-proxy privileges:

- Pin the gpmaven Maven source to an immutable commit SHA
  (guardianproject/gpmaven@b3ee2a6) instead of the mutable `master`
  branch, so a force-push or new commit can't silently change what we
  resolve.
- Verify the resolved AAR's SHA-256 at build time
  (verifyArtiChecksum, wired ahead of assemble/bundle). A mismatch fails
  the build before any APK is produced. Scoped to the one privileged
  artifact rather than enabling global dependency verification, which
  would force-verify every transitive dep.

Reconcile stale "apply on relaunch" / "next app launch" doc comments in
AppContext.ts, tor.ts, useTor.ts, TorController.java, and TorPlugin.java
with the actual behavior: the Advanced Settings toggle activates Tor
live via start/stop (arti starts/stops immediately, relay layer
remounts); the persisted flag only governs cold-launch auto-start.
2026-06-13 14:32:28 -05:00
Alex Gleason 0d334e89e7 Merge branch 'tor-arti-android' into 'main'
Add optional Tor (arti) routing on Android

Closes #28

See merge request soapbox-pub/agora!42
2026-06-13 19:25:39 +00:00
Barrett O. 886d3ece18 Add optional Tor (arti) routing on Android
Adds an opt-in Tor mode that routes all app traffic through a local
SOCKS5 proxy backed by arti (Tor in Rust), bundled via the
org.torproject:arti-mobile:1.7.0.1 AAR.

- TorController starts/stops arti and installs a fail-closed WebView
  proxy override (no direct fallback) so traffic can't leak while Tor is
  connecting or down. Connectivity is verified against
  check.torproject.org (IsTor) and re-checked continuously; the exit IP
  is surfaced for verification, and the status isn't latched so a dropped
  circuit downgrades honestly.
- TorPlugin bridges enable/disable/status to the Capacitor/JS layer.
  Toggling applies live, in place, with no app restart.
- UI: a slim fail-closed status banner (replacing the old full-screen
  gate), the Tor toggle in Advanced settings reachable while logged out,
  and Settings/Search/About added to the logged-out menu.
- R8 keep rules for org.torproject.arti.** so the JNI native-method
  classes aren't stripped/renamed; androidx.webkit on the compile
  classpath for the WebView proxy APIs.
2026-06-13 16:26:58 +00:00
Alex Gleason 7ffaccb304 Fix Agora meta description to reflect crowdfunding focus
The meta description still framed Agora as a generic Nostr social
client. Update it to describe the actual product: peer-to-peer
crowdfunding with an integrated non-custodial on-chain Bitcoin wallet.
2026-06-12 17:13:37 -05:00
Alex Gleason e196227a23 Add animated verify-campaign tutorial after publishing statement
Once an organization publishes its verifier statement on /organizations,
show an interactive walkthrough demonstrating how to verify a campaign:
a looping three-step animation on a mock campaign card where a cursor opens
the three-dots menu and selects 'Verify this campaign'. Step list is
clickable to scrub; motion is gated behind prefers-reduced-motion.
2026-06-12 16:00:35 -05:00
Alex Gleason 818afe9bbf Fix campaign card navigation when clicking inside verify dialog
The campaign card wraps its contents in a react-router <Link>. The
moderation menu's VerificationDialog and CampaignListMembershipDialog
portal their DOM out of the card, but React synthetic events still
bubble through the component tree to the Link — so clicking anywhere
in either dialog navigated to the campaign page. Wrap both dialogs in
a span that stops click/pointerdown propagation, mirroring the guards
already on the dropdown trigger and content.
2026-06-12 15:41:41 -05:00
Alex Gleason c2179fef2b Let verifiers reach the campaign verify action
The 'Verify this campaign' control was gated on the hardcoded moderator
pack. Extend eligibility to self-declared verifiers (accounts with a
kind 14672 verifier statement): useCampaignVerifications now exposes
canVerify (moderator OR verifier) and its verify mutation accepts
verifiers.

The campaign moderation kebab (ModerationMenu / ModerationOverlay) now
renders for verifiers too, but shows only the verify row — the
moderator-only items (Hide, Add to list) and the Hidden badge stay
gated on moderator status. Pledge and group surfaces are unchanged.
2026-06-12 15:35:56 -05:00
Alex Gleason d0d315a9b2 Add 'Verified' tab to verifier profiles, defaulting to it
Verifier profiles (accounts with a kind 14672 statement) now get a
'Verified' tab listing the campaigns they have vouched for, resolved
from the account's own agora.verified (kind 1985) labels via the new
useVerifiedCampaigns hook. The tab is surfaced first and becomes the
default on initial load (without overriding a manual tab selection),
on both desktop and mobile. Non-verifiers never see the tab.

Adds ProfileVerifiedTab, the useVerifiedCampaigns hook, and profile.tabs
/ profile.verified i18n keys across all locales.
2026-06-12 15:19:33 -05:00
Alex Gleason debeaddba2 Move verifier statement back into the left identity rail
Restore ProfileVerifierSection to its original spot as the first item in
ProfileOverviewSections (left sidebar on desktop, overview tab on mobile),
reverting the full-width-above-tabs placement. Keeps the 'How We Verify'
heading inside the box and no shield icon.
2026-06-12 15:02:15 -05:00
Alex Gleason 1324506b20 Move 'How We Verify' heading inside the verifier statement box 2026-06-12 14:54:27 -05:00
Alex Gleason 34556ceed2 Move verifier statement full-width above profile tabs
Surface a verifier's kind 14672 statement as a full-width band above the
profile tabs (below the header) on both desktop and mobile, instead of
tucked into the left identity rail / overview tab.

Drop the shield-check icon and rename the heading to 'How We Verify' —
the statement is a self-authored claim, not a platform endorsement, so
the heading stays neutral with no trust-implying badge. Rename the
verifier.profileSectionTitle key to verifier.howWeVerifyTitle across all
locales.
2026-06-12 14:50:49 -05:00
Alex Gleason 706a3ef2eb Remove 'Why it matters' section from /organizations
Drop the benefit-cards section between the steps and the editor, along
with the now-unused BenefitCard component, Users/Eye/cn imports, and the
organizations.why.* i18n keys across all locales.
2026-06-12 14:32:57 -05:00
Alex Gleason 43fecb6e6f Add WYSIWYG markdown editor for verification statements
Restore the deleted Milkdown WYSIWYG editor (its deps and CSS were still
installed) as a reusable component under src/components/markdown/:
MilkdownEditor, a formatting toolbar (bold/italic/headings/lists/links/
quote/code/hr + markdown help popover), and an insert-link dialog. All
strings are now i18n-driven via a new mdEditor locale block, translated
across every locale.

Wire it into the /organizations verifier-statement editor, replacing the
raw textarea + separate live preview with rich-text editing that emits
markdown. Fix the editor placeholder so it only shows while empty by
toggling a has-content class on the ProseMirror DOM.
2026-06-12 14:29:08 -05:00
Alex Gleason 33852f60fb Redesign /organizations as a marketing + onboarding landing page
Rework the /organizations page to match the /about landing style so it
doubles as marketing and a functional tool: a dark world-map hero with a
scroll-to CTA, a three-step onboarding section, benefit cards, and a
'get started' section housing the verifier-statement editor. Logged out
shows the org-profile login gate; logged in shows the full publish /
update / withdraw editor.

Move the route to the wide FundraiserLayout so section backgrounds span
the viewport, and expand the organizations i18n block (hero, steps, why,
getStarted) across all locales.
2026-06-12 14:16:03 -05:00
Alex Gleason 2e5d160a9e Add /organizations onboarding page, move verifier form off settings
Replace the /settings/verifier route with a public /organizations help
page that onboards organizations onto Agora. Logged-out visitors are
prompted to log in with — or create — their organization's Nostr
profile; once signed in they get the full verifier-statement editor
that previously lived under settings.

Remove the Verifier entry from the settings list, delete
VerifierSettingsPage, and link /organizations from the site footer
instead. The old /settings/verifier path now redirects to
/organizations.
2026-06-12 14:06:24 -05:00
Alex Gleason 21465ebc5f Remove verifier header subtitle from settings page
The Verifier page was the last settings page still rendering an in-header
subtitle. Drop it from both the logged-in and login-gate headers (the
verifier.subtitle string is still used as the SEO meta description).
2026-06-12 13:49:44 -05:00
Alex Gleason 8f0a215d54 Fix settings header/body width mismatch and tighten back-arrow gap
Move the header's horizontal padding onto the inner content row so the
constrained column matches the body's max-w column exactly (previously the
header column was 32px wider than the body content under the narrow
max-w-3xl layout). Reduce the back-arrow-to-title gap from gap-4 to gap-1.5.
Migrate EventDashboard's header layout classes from className to
contentClassName to match the new model.
2026-06-12 13:48:32 -05:00
Alex Gleason 8bd8fe7d05 Remove Bot account toggle from profile settings
The bot value stays in the form schema and is preserved on save; only the
toggle UI is removed. Drops the now-unused Switch and Form sub-component
imports.
2026-06-12 13:40:08 -05:00
Alex Gleason 8f750a222f Remove profile field editing UI from edit-profile view
Delete the Website, Lightning address, sortable custom-fields list, and
add-field preset pills (the entire Profile Fields block) from the profile
settings form. Existing values are still loaded into form state, so they
are preserved on save and continue to render in the profile card and the
live sidebar preview — only the editing controls are gone.

Also removes the now-dead helpers, handlers, presets, SortableFieldRow
component, and unused imports.
2026-06-12 13:36:50 -05:00
Alex Gleason 0c4465bed3 Move Profile Fields into the Advanced section on profile settings 2026-06-12 13:27:47 -05:00
Alex Gleason e67c5dba75 Fix Settings header alignment and trim verbose copy
- Add an opt-in contentClassName to PageHeader so a page can constrain the
  header row to the same centered column as its body. Settings sub-pages now
  pass max-w-2xl/max-w-xl so the title and back arrow line up with the cards
  instead of floating against the viewport edge.
- Drop the entire header from the Settings hub (no title/back arrow); the
  grouped card list now reads the same on mobile and desktop.
- Remove redundant subtitles and intro blurbs from Appearance, Language,
  Network, Advanced, Profile, and Notifications.
- Delete the now-orphaned i18n keys across all locales.
2026-06-12 13:24:49 -05:00
Alex Gleason e8a9f679f9 Redesign Settings with Apple-inspired grouped UI
Rework the Settings hub and sub-pages into iOS-style grouped inset cards:
- Hub groups rows into Account / App / System sections, each row gets a
  colored gradient icon tile, with a rounded bg-card container and hairline
  dividers instead of the flat full-width list.
- Appearance and Language pickers use the same inset-card list rather than
  bordered selectable buttons.
- Network and Advanced replace the heavy accent-bar section headers with
  uppercase group labels above clean bg-card panels.
- Add settings.groups.{account,app,system} strings across all locales.
2026-06-12 13:00:13 -05:00
Alex Gleason 59c0d25fa6 Change verifier statement kind to 14672 2026-06-12 12:55:00 -05:00
Alex Gleason b271c4e889 Add verifier statements (kind 15063)
Anyone can become a verifier by publishing a kind 15063 replaceable
event whose Markdown content describes how they vet campaigns. The
statement is surfaced prominently in the profile overview so donors
can judge whether to trust the account's judgement.

- Document kind 15063 in NIP.md
- useVerifierStatement / useSetVerifierStatement hooks (read-modify-write)
- /settings/verifier form page with live preview, publish/withdraw
- ProfileVerifierSection rendered first in the profile overview
- Localize all strings across every locale
2026-06-12 12:42:50 -05:00
Alex Gleason f5398acb22 Correct AGENTS.md to reflect on-chain Bitcoin crowdfunding
Agora is a P2P crowdfunding client using on-chain Bitcoin with a
non-custodial HD wallet (BIP-86 Taproot, BIP-352 silent payments),
not a Lightning project. Update the overview, document the on-chain
wallet and crowdfunding hooks, and demote Lightning to secondary
tipping. Drop the stale useShakespeare hook reference.
2026-06-12 11:02:21 -05:00
Alex Gleason 4db37f9217 Merge branch 'main' of gitlab.com:soapbox-pub/agora 2026-06-11 17:06:49 -05:00
Alex Gleason 10a1c53e6a fix: don't advance SP scan cursor over unscanned gaps
A forward-jumping scan range (e.g. "Scan recent" on a wallet whose
cursor was far behind) recorded the skipped gap as scanned: runScan set
scanHeight = max(scanHeight, highestContiguousScanned) where contiguity
was measured only from the range's own fromHeight. The auto-scanner then
resumed from the new cursor, so payments received in the gap were
silently never discovered.

The cursor now only advances when the scan range is contiguous with the
effective persisted cursor (relay scanHeight or local checkpoint).
Non-contiguous scans still merge any UTXOs they find — only the cursor
is held back, so a later auto-scan pass walks the gap. Never-scanned
wallets (cursor 0) keep the documented bootstrap jump to a recent
window.
2026-06-11 16:37:52 -05:00
lemon b20e49bf20 Localize campaign list category headers 2026-06-10 22:45:36 -07:00
Alex Gleason c9d77d06a1 Rename "activist" to "recipient" in donation copy
The people receiving donations are not necessarily activists — they are
often simply people in need (e.g. in Venezuela and Gaza). Rename the
user-facing term from "activist" to "recipient" across all copy and the
underlying code.

- Rename ActivistGuidePage -> RecipientGuidePage and the /about/activists
  route -> /about/recipients (legacy URL kept as a redirect).
- Rename guide structure/helpers, the paymentComparison audience literal,
  and the related i18n key namespaces (activistGuide -> recipientGuide,
  about.guides.activist -> .recipient, guides.activist -> guides.recipient,
  activistRows/activistHeader -> recipientRows/recipientHeader,
  ctaActivist -> ctaRecipient).
- Translate the term in all 16 locales, plus fix stale "activists" hero
  taglines left over from 0233a75d in id/hi/sw/tr/zh-Hant.
2026-06-10 15:43:08 -05:00
Alex Gleason ab1a4ba0e8 Show scan progress bar on private wallet tab
The Private wallet tab only showed a spinner plus 'Scanning… block X / Y'
text while the silent-payment scanner ran, which gave no sense of how far
along the scan was. Render a Progress bar (matching the Scan options
dialog) above the block-count text so completeness is visible at a glance.
2026-06-10 15:17:04 -05:00
Alex Gleason 101926e961 fix: restore AudioPlayerProvider so audio/music/podcast pages don't crash
Profile and detail pages that render music tracks, podcast episodes, or
audio kinds call useAudioPlayer(), but the AudioPlayerProvider and its
companion floating-bar / navigation-guard components were deleted as
"orphaned" in 256560cf — they were never wired into App.tsx, so the hook
always threw "useAudioPlayer must be used within AudioPlayerProvider".

Restore the provider, MinimizedAudioBar, and AudioNavigationGuard, re-export
AudioPlayerContext from the context def, wrap AppRouter in the provider, and
render the two router-dependent components inside BrowserRouter.

Regression-of: 256560cf
2026-06-10 14:57:38 -05:00
Alex Gleason 9351d3e243 Speed up silent-payment scanning
Three changes target the scan pipeline's two bottlenecks (HTTP latency
and main-thread ECDH):

- Cache per-block tweak + UTXO data in IndexedDB (blockCache.ts) and
  route fetches through it. Mined blocks below the indexer tip are
  immutable, so a repeat or overlapping scan now costs zero round-trips
  for blocks already seen. Keyed by (indexerUrl, height); degrades
  gracefully when IndexedDB is unavailable.

- Move the secp256k1 ECDH + per-output Pk derivation into a dedicated
  Web Worker (scan.worker.ts + scanWorkerClient.ts). The scan loop no
  longer has to setTimeout(0) every 64 entries to keep the UI
  responsive, and fetching now overlaps with ECDH. The client falls back
  to a main-thread scan if the worker can't be constructed or errors on
  a block. bscan stays on-device — a same-origin Worker is not the
  remote scan-helper pattern BIP-352 contemplates.

- Make fetch concurrency tunable via AppConfig.bip352ScanConcurrency
  (clamped to [1, 32], default 8) so a fast self-hosted indexer can run
  hotter than the polite public-host default.
2026-06-10 14:29:02 -05:00
Alex Gleason 0233a75d5c Update hero tagline from 'activists' to 'the world' 2026-06-10 10:43:43 -05:00
Alex Gleason 450989f6ca feat: add moderation menu to campaign detail page hero
Moderators had no way to hide/verify/add-to-list a campaign from its
detail page — the hero toolbar only showed creator (edit/delete) controls.

Follow the same pattern as ActionDetailPage (pledge detail): place a
ModerationMenu in both the mobile and desktop toolbars of CampaignHero.
It returns null for non-moderators so no conditional wrapping is needed.

- Add coord and entityTitle props to CampaignHeroProps; thread them from
  CampaignDetailContent (campaign.aTag, campaign.title)
- Mobile: ModerationMenu sits in the right-side controls group, before
  the creator edit/delete buttons
- Desktop: ModerationMenu appears at the far right of the top overlay bar
  (after creator controls when present), styled with the same
  bg-black/30 backdrop-blur chip as ActionDetailPage
2026-06-10 09:22:46 -05:00
Alex Gleason cf0caa8c85 fix: merge moderation actions into list-member kebab to prevent overlay collision
On /campaigns/lists/:slug, ListMemberCard rendered its own reorder/remove
kebab at top-3 right-3 z-20, directly on top of CampaignCard's ModerationOverlay
(also top-3 right-3 z-10). The ListMemberCard kebab always won stacking order,
blocking moderators from accessing moderation actions on list-page cards.

- Add showModerationMenu prop to CampaignCard (default true); when false,
  ModerationOverlay renders only the Hidden badge (no menu trigger), keeping
  hide-state visibility without adding a second kebab in the same corner.
- ListMemberCard now passes showModerationMenu={false} to CampaignCard and
  embeds ModerationMenuItems directly into its own dropdown (below a separator
  after the reorder/remove items), giving moderators a single combined menu
  with both list-management and moderation actions.
- Dialog state (CampaignListMembershipDialog, VerificationDialog) is hoisted
  to ListMemberCard so the dropdown-unmount lifecycle doesn't tear them down.
2026-06-09 19:58:04 -05:00
Alex Gleason 2242692794 Checkpoint silent-payment scan progress locally every 5s
Scan progress was only persisted to the relay on a match or at end-of-scan,
so refreshing mid-scan re-scanned every block since the last completed scan.
Add a free, synchronous localStorage checkpoint of the advancing scanHeight
(throttled to 5s, plus a final write on finish) scoped per pubkey+indexer.
The auto-scanner now resumes from max(relay scanHeight, local checkpoint),
bounding mid-scan-refresh re-scan to ~5s of blocks without adding any
encrypt/sign/broadcast traffic. Relay publishing stays gated to matches and
end-of-scan for cross-device sync; manual scans still honor the exact
fromHeight the user picks.
2026-06-08 18:15:33 -05:00
Alex Gleason 8473a4990f Scan for silent payments automatically in the background
Lift the silent-payment orchestrator into an HdWalletSpProvider mounted at
the app root so a single shared scan instance keeps running across page
navigation. Add a background auto-scanner that resumes from the last
persisted scanHeight and keeps up with the chain tip without any user
action, bounding the first pass on a never-scanned wallet to ~7 days of
blocks. Surface live scan state on the Private wallet tab and add an
auto-scan toggle to the scan dialog.
2026-06-08 16:11:54 -05:00
Alex Gleason e5277f004e Show verification badge on campaign detail page
Add an inline variant of CampaignVerificationBadge that reads on a light
page surface (bordered chip with a 'Verified' label) and render it next to
the author byline in the campaign detail heading. The overlay variant (dark
pill) is unchanged for cards over banners.

New i18n key campaignVerification.verifiedLabel across all locales.
2026-06-06 19:24:27 -05:00
Alex Gleason 93bcf04ae9 Confirm campaign verification through a dialog
Clicking 'Verify this campaign' now opens a VerificationDialog instead of
publishing immediately. The dialog previews the moderator's own avatar
with the verification checkmark (as it appears on verified campaign cards)
and shows the attestation the moderator is making before confirming.

- New VerificationDialog component (avatar + checkmark preview + attestation
  text + Cancel / Verify actions).
- ModerationMenu owns the dialog state and the verify mutation, hoisted out
  of the dropdown content (which unmounts on select) like the existing
  add-to-list dialog; passes onRequestVerify down to CampaignVerifyItem.
- Removing a verification stays inline (no confirmation).
- New i18n keys dialogTitle + attestation across all locales.
2026-06-06 19:18:39 -05:00
Alex Gleason f95ab1b422 Treat campaign verification as a moderator action
Verification is gated by the existing campaign moderator pack
(useCampaignModerators / CAMPAIGN_MODERATORS), not a separate allowlist.

- Remove the config.labelers field (AppContext interface, Zod schema,
  App.tsx and TestApp defaults) and delete useCampaignLabelers.
- useCampaignVerifications now reads/writes agora.verified labels gated
  on the moderator pack (isModerator), same authors filter as the other
  label streams.
- Move the verify / remove-verification row INSIDE the moderation kebab's
  'Moderator actions' section (leadingExtra slot of ModerationItemsShell),
  no longer a top-level item above the section label.
- Revert the isMod || isLabeler widening in ModerationMenu/Overlay back to
  plain isMod.
- Remove the trailing 'Verified' checkmark text from 'Remove my
  verification'.
- Rename labeler->moderator in agoraVerification, the badge component, and
  all locale strings; drop now-unused notVerified / verifiedState keys.
- Update NIP.md to document verification as a moderator action.
2026-06-06 19:11:48 -05:00
Alex Gleason a4be9d9fbb Put verify row at top of campaign moderation menu
The verify / remove-verification row now renders above the moderator
rows. The moderator section's own 'Moderator actions' label provides the
visual separator, so no extra divider is needed between them.
2026-06-06 18:56:20 -05:00
Alex Gleason 1557e2fff9 Move campaign verify action into moderation kebab menu
The verify / remove-verification controls now live as rows in the
campaign 'Moderator actions' 3-dots menu instead of inside the
verification badge popover. The badge over the card is now display-only
(stacked verifier avatars + a popover listing them, linking to profiles).

Because labelers are a distinct allowlist from the moderator pack, the
campaign moderation menu/overlay now mounts for a user who is a mod OR a
labeler; the verify row itself is gated on labeler membership, while the
hide/add-to-list rows stay moderator-only.
2026-06-06 18:50:00 -05:00
Alex Gleason ba4a7f4e35 Add Me and MK as default campaign labelers 2026-06-06 18:46:02 -05:00
Alex Gleason b8c1bc7409 Add agora.verified campaign verification labels
Trusted labelers (AppConfig.labelers) can vouch for campaigns via NIP-32
kind 1985 labels in the new agora.verified namespace. Verifier avatars
render as a stacked badge on campaign cards; hovering/clicking opens a
popover listing verifiers (linking to their profiles). Logged-in labelers
get verify / remove-verification controls — verify publishes a label,
unverify issues a kind 5 deletion of their own label.

The read query filters by authors: labelers so verifications from outside
the allowlist are never honored. Kind 1985 label reqs are routed to the
search relays (relay.ditto.pub, relay.dreamith.to) in NostrProvider.
2026-06-06 18:06:36 -05:00
Alex Gleason 15718a575f Add Bitcoin backend config to Advanced settings
Expose the Esplora API endpoints, Blockbook URL, and BIP-352 silent
payment indexer (already wired through AppConfig) in a new Bitcoin
section on the Advanced settings page. Each field validates input,
normalizes trailing slashes, and offers a reset-to-default button;
clearing the indexer URL disables silent-payment scanning.
2026-06-05 18:34:25 -05:00
Alex Gleason 7775c0477f Merge branch 'main' of gitlab.com:soapbox-pub/agora 2026-06-05 17:15:50 -05:00
mkfain 07f77b8a99 Show all featured campaigns on home page instead of capping at 6
The home hero row sliced the Featured Campaigns list to the first 6
members (FEATURED_HERO_CAP). Drop the cap so the row renders every
campaign the curator has added — the existing hero layout (two
column-spanning cards + 4-up tail) already scales to any count. The
skeleton placeholder count stays bounded by FEATURED_SKELETON_CAP for
the loading state only.
2026-06-05 17:04:55 -05:00
mkfain 872e8428d2 Source home hero row from Featured Campaigns list
Switch the home-page hero row from the World Liberty Congress curated
list (d=world-liberty-congress) to the Featured Campaigns list
(d=featured-campaigns), both curator-published kind-30003 NIP-51
bookmark sets.

Replace the WLC org-branded heading (avatar, name, npub profile link
pulled via useAuthor) with a plain 'Featured Campaigns' heading that
links to /campaigns/lists/featured-campaigns. Drop the now-unused
Avatar / useAuthor / genUserName imports and the WLC pubkey/npub
constants.

Rename the WLC_* constants to FEATURED_* and replace the wlcDesc
locale key with featuredTitle + featuredDesc across all 16 locales.
2026-06-05 17:04:55 -05:00
Alex Gleason a8b2fe5ddf Stop /campaigns from hammering Esplora until every backend rate-limits
The campaigns grid renders up to 200 CampaignCards at once with no
virtualization, and each card eagerly ran useCampaignDonations: an
Esplora /address lookup per card (polling every 30s via refetchInterval)
plus a /tx verification call per kind-8333 donation receipt. On first
paint that's hundreds of /address calls and potentially thousands of
/tx calls in one burst, then 200 more /address calls every 30s forever.
Once mempool.space starts returning 429, the failover client retries
each call across every configured endpoint, cascading the rate-limit to
all of them.

Gate the donation lookup behind an IntersectionObserver so only on-screen
cards talk to Esplora, and drop the per-card 30s polling. The detail page
(a single instance, not a grid) opts back into the 30s live refresh via a
new refetchInterval option.

- Add useInView hook (once-only, rootMargin pre-arm, IO-less fallback).
- useCampaignDonations gains enabled + refetchInterval options; default
  no polling, longer staleTime; treat 'not enabled yet' as loading so
  off-screen cards show a skeleton instead of flashing '0 raised'.
- Gate CampaignCard and CampaignInlinePreview on visibility.
- CampaignDetailPage opts into refetchInterval: 30_000.
2026-06-05 16:54:22 -05:00
Alex Gleason a2dd16fc94 Merge branch 'main' of gitlab.com:soapbox-pub/agora 2026-06-05 16:46:39 -05:00
Alex Gleason 0c455c6d6f Block public-wallet sends to your own silent payment address
Sending from the public (BIP-86) wallet to your own silent payment
address co-mingles the resulting silent-payment UTXOs with coins
already exposed on the public ledger, linking the private wallet back
to a known on-chain identity and defeating its purpose.

Unlike the private-wallet reuse guard (which offers a 'send anyway'
acknowledgement), this is disallowed outright: the Send button stays
disabled and a blocking notice explains why.
2026-06-05 16:33:00 -05:00
mkfain f0af799647 Document Public/Private wallet split in About and guides
The previous commit split the wallet into strictly-isolated Public
(BIP-86) and Private (silent-payment) balances that can never be spent
together. Update the user-facing docs to match:

- /about: the Silent Payments card now notes private funds are kept in
  a separate balance, never mixed with public ones.
- Activist Guide: the receiving section explains the Public/Private tabs
  and why mixing would re-link silent-payment donations; a new callout
  covers cashing out from the Private tab to an sp1… address and the
  'Send anyway' address-reuse guard; the cash-out intro now points at
  the Private tab.
- Wallet FAQ: notes the two strictly-separated tabs.

All copy mirrored across the 15 non-English locales.
2026-06-05 15:47:53 -05:00
Alex Gleason 4cd725daf1 Merge branch 'main' of gitlab.com:soapbox-pub/agora 2026-06-05 15:19:25 -05:00
Alex Gleason e7439611b1 Separate wallet into isolated Public and Private wallets
Silent-payment (private) UTXOs were combined with BIP-86 (public) UTXOs
during coin selection, and silent-payment change was sent to a public
BIP-86 change address. Both linked private funds back to the public
wallet on-chain, defeating the unlinkability silent payments exist to
provide.

Enforce strict UTXO isolation at the PSBT-build layer: a spend now
carries a `walletScope` and the coin selector only ever sees the matching
input kind (public → BIP-86, private → SP). Private-wallet change is
routed to a fresh BIP-352 output back to the wallet's own sp1 address, so
it re-enters the private wallet instead of leaking to a public change
address. The two UTXO sets can no longer be spent together.

Surface the separation as Public / Private tabs on /wallet, each with its
own balance, receive QR (bc1 vs sp1), send flow, and transaction history.
Private sends to a bare on-chain address are gated by an address-reuse
guard: when the recipient chip is selected, a Blockbook getAccountInfo
probe checks for prior on-chain history, and the address is also checked
against the user's own public wallet. Either case requires an explicit
"Send anyway" acknowledgement; the Send button is disabled while the
probe is in flight. Sending a silent payment to an sp1 address has no
such warning.
2026-06-05 15:18:51 -05:00
mkfain 168ca2d067 Drop "Plain English, no walls of text." from activist guide subtitle 2026-06-05 13:57:59 -05:00
Alex Gleason e9eebaeeca Fix npm audit vulnerabilities
- Patch react-router open redirect (GHSA-2j2x-hqr9-3h42) within 6.x
- Bump vitest to 4.x to fix UI server file read/exec (GHSA-5xrq-8626-4rwp)

npm audit now reports 0 vulnerabilities; full test suite passes.
2026-06-05 10:43:42 -05:00
Chad Curtis 7a18d500ee release: v2.8.9 2026-06-02 09:15:30 -05:00
Chad Curtis 54c711b3be Point NIP-89 client tag to Agora handler naddr 2026-06-02 09:12:00 -05:00
Chad Curtis 79c6e7e516 Translate replies/comments header and count on post detail
The replies section heading ('Replies'/'Comments') and the count noun
('reply'/'replies'/'comment'/'comments') were hardcoded English literals
adjacent to the placeholder fixed in the previous commit. Move them to a
new postDetail locale section with i18next plural keys, translated across
all locales.
2026-06-02 09:03:20 -05:00
Chad Curtis 9717a6827f Fix untranslated reply composer placeholder on post detail
The placeholder used a hardcoded 'Write a reply...' literal and referenced
a nonexistent compose.activityPlaceholder key (which fell back to the raw
key string). Switch to translated replyModal.placeholder.writeReply /
writeComment keys and add writeReply to all locales.
2026-06-02 08:57:55 -05:00
Chad Curtis f6c7bc366d Add app download link to slide-out menu; tune nudge spacing
- Add a 'Get the app' external link to the TopNav drawer menu, hidden
  inside the native app.
- Move the home-page nudge out of the feed (it now lives on the actual
  home page, CampaignsPage) and give it pt-8 breathing room above.
- Remove redundant pb-16 on main and shrink the footer's top gap on
  mobile (pt-6 sm:pt-12) so spacing above the footer isn't doubled.
2026-06-02 08:54:43 -05:00
Chad Curtis 4c7d059b0b Add Start a campaign button to middle home CTA
Place a 'Start a campaign' button next to the centered 'Browse all'
CTA in the middle of the campaigns home page, stacking on mobile.
2026-06-02 08:51:38 -05:00
Chad Curtis eae5e1c3a7 Always show Explore campaigns button on home page
Remove the !loggedIn gate that hid the 'Explore campaigns' CTA from
logged-in users. The campaigns home page shouldn't gate this button
behind login state.
2026-06-02 08:46:45 -05:00
Chad Curtis e7c488af63 Add Zapstore app download nudge
Prompt mobile-web visitors to install the native Android app from
Zapstore. Shows a card at the bottom of the home feed and a link in
the account switcher menu. Both are hidden inside the native app
(Capacitor.isNativePlatform) and on desktop (sm:hidden for the banner).

Adds nav.getApp and feed.getApp.* strings across all locales.
2026-06-02 08:40:58 -05:00
lemon 4153792e54 Add missing Why Agora translations 2026-06-02 04:29:26 -07:00
lemon c731256efb Preserve Latin display font for balances 2026-06-02 04:18:04 -07:00
lemon 702d374a06 Sync lockfile version 2026-06-02 04:10:04 -07:00
lemon b174152566 Fix stale language switching 2026-06-02 04:09:44 -07:00
Chad Curtis 6a5c426648 release: v2.8.8 2026-06-02 04:57:32 -05:00
Chad Curtis f8547668b2 Replace old Ditto splash icon with Agora double-bolt
The Android 12 splash vector (splash_icon_vector.xml) and the legacy
splash PNGs still showed the old Ditto cat / single-bolt logo. Replace
the splash vector with the current double-bolt glyph from logo.svg and
regenerate splash_icon.png and all port/land splash PNGs as the white
double-bolt on the dark splash background.
2026-06-02 04:56:53 -05:00
Chad Curtis 49049f98e7 Fix squashed app icon: render logo SVG with preserved aspect ratio
The 720x880 logo.svg was force-rendered into a 512x512 square, stretching
the bolt horizontally. Render height-bounded (aspect-preserving) instead
and let the centered composite handle fit. Regenerate all Android, iOS,
and web/PWA/Zapstore icons.
2026-06-02 04:53:57 -05:00
Chad Curtis 048878b699 fix zapstore info v3 2026-06-02 04:52:25 -05:00
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
371 changed files with 19927 additions and 43686 deletions
+9 -5
View File
@@ -1,6 +1,10 @@
# Project Overview
Agora is a Nostr client built with React 19.x, TailwindCSS 3.x, Vite, shadcn/ui, and Nostrify, wrapped as a native iOS/Android app via Capacitor.
Agora is a peer-to-peer crowdfunding Nostr client built with React 19.x, TailwindCSS 3.x, Vite, shadcn/ui, and Nostrify, wrapped as a native iOS/Android app via Capacitor.
Donations are **on-chain Bitcoin** — donors pay a campaign's Bitcoin address directly. Agora ships an integrated **non-custodial HD Bitcoin wallet** (deterministically derived from the user's Nostr key) with BIP-86 Taproot and **BIP-352 silent-payment** support. The app never custodies or converts funds; it is a non-custodial UI that connects donors and campaigns peer-to-peer.
**This is not a Lightning project.** Lightning (`useZaps`, `useWallet`, `useNWC`, LNURL/NWC/WebLN) survives only as a secondary *tipping* path for notes/profiles and a deprecated Breez/Spark wallet in recovery-only mode — never for campaign donations. The crowdfunding core is strictly on-chain.
## Technology Stack
@@ -17,7 +21,7 @@ Agora is a Nostr client built with React 19.x, TailwindCSS 3.x, Vite, shadcn/ui,
## Project Structure
- `/src/components/` — UI components. `ui/` holds shadcn primitives; `auth/` holds login components.
- `/src/hooks/` — custom hooks. Discover the full set with `ls src/hooks/`. Key ones: `useNostr`, `useAuthor`, `useCurrentUser`, `useNostrPublish`, `useUploadFile`, `useAppContext`, `useTheme`, `useToast`, `useLoggedInAccounts`, `useLoginActions`, `useIsMobile`, `useZaps`, `useWallet`, `useNWC`, `useShakespeare`.
- `/src/hooks/` — custom hooks. Discover the full set with `ls src/hooks/`. Core Nostr: `useNostr`, `useAuthor`, `useCurrentUser`, `useNostrPublish`, `useUploadFile`, `useAppContext`, `useTheme`, `useToast`, `useLoggedInAccounts`, `useLoginActions`, `useIsMobile`. **On-chain wallet & crowdfunding (the headline feature):** `useHdWallet`, `useHdWalletSp` (BIP-352 silent payments), `useBitcoinSigner`, `useDonateCampaign`, `useCampaign`/`useCampaigns`, `useCampaignDonations`, `useOnchainZap`. **Lightning (secondary tipping only, not campaigns):** `useZaps`, `useWallet` (NWC/WebLN status — *not* the on-chain wallet), `useNWC`.
- `/src/pages/` — page components wired into `AppRouter.tsx`. The catch-all `/:nip19` route is handled by `NIP19Page.tsx` (see the `nip19-routing` skill).
- `/src/lib/` — utility functions and shared logic.
- `/src/contexts/` — React context providers (`AppContext`, `NWCContext`).
@@ -262,16 +266,16 @@ The router provides automatic scroll-to-top on navigation and a 404 `NotFound` p
## Internationalization
All user-facing strings live in `src/locales/<lang>.json`. `en.json` is the source of truth; ten other locales ship alongside it: `ar`, `es`, `fa`, `fr`, `km`, `ps`, `pt`, `ru`, `sn`, `zh`.
All user-facing strings live in `src/locales/<lang>.json`. `en.json` is the source of truth; fifteen other locales ship alongside it: `ar`, `es`, `fa`, `fr`, `hi`, `id`, `km`, `ps`, `pt`, `ru`, `sn`, `sw`, `tr`, `zh`, `zh-Hant`.
**When you edit, add, or remove a translated string, update every locale in the same change — not just `en.json`.** Leaving the other locales stale ships an inconsistent app: users in other languages either see outdated copy or get an English fallback in the middle of a localized screen. This applies to FAQ entries, guide bodies, button labels, error messages — every value reachable through `t()`.
Concrete rules:
- **Edits to an existing key** — change the value in `en.json` first, then update the corresponding key in all ten other locales. Translate the new content into each language; don't paste English. Preserve `{{interpolation}}` placeholders, markdown links, and technical tokens (`sp1…`, `BIP-352`, kind numbers, etc.) verbatim.
- **Edits to an existing key** — change the value in `en.json` first, then update the corresponding key in all fifteen other locales. Translate the new content into each language; don't paste English. Preserve `{{interpolation}}` placeholders, markdown links, and technical tokens (`sp1…`, `BIP-352`, kind numbers, etc.) verbatim.
- **New keys** — add to `en.json` first, then add the same key with a translated value in every other locale. `src/test/locales.test.ts` fails the build if any locale ships a key that doesn't exist in `en.json`, but the inverse (a key missing from a non-English locale) is allowed and falls back to English at runtime — which is exactly the user-visible mess you're trying to avoid.
- **Removed keys** — delete from `en.json` and every other locale together. Leftover keys are dead translations and clutter future diffs.
- **Parallelize the translation work** — when updating one English string across all ten locales, dispatch the per-language edits to subagents in parallel rather than translating ten files sequentially. Provide each subagent the new English source, the existing translation snippet (so it matches established voice), and explicit instructions to preserve placeholders and technical tokens.
- **Parallelize the translation work** — when updating one English string across all fifteen locales, dispatch the per-language edits to subagents in parallel rather than translating fifteen files sequentially. Provide each subagent the new English source, the existing translation snippet (so it matches established voice), and explicit instructions to preserve placeholders and technical tokens.
Always run `npm run test` after locale changes — `locales.test.ts` catches structural drift, and the wider suite catches any `t()` calls that referenced a key you renamed.
+80
View File
@@ -1,5 +1,85 @@
# Changelog
## [2.9.1] - 2026-06-27
The Venezuela earthquake relief appeal now rallies behind every campaign on the ground, not just one. The home banner and relief page showcase a live, swipeable carousel of all Venezuelan relief efforts, with a running total of everything raised so far — so you can pick exactly who to help.
### Changed
- The Venezuela relief appeal now showcases every Venezuelan relief campaign in a live, auto-scrolling carousel you can swipe through, with a combined raised total across all of them, instead of featuring a single campaign.
## [2.9.0] - 2026-06-25
A big one. Private messaging arrives with a fast, searchable inbox you can reply to in your own language. Campaign organizers can now get verified by trusted reviewers through a guided sign-up, and verified badges appear right on campaigns. There's a new Venezuela earthquake relief appeal that takes you straight to donations, plus optional Tor routing on Android, separate Public and Private wallets, faster donation scanning, and refreshed profiles, settings, and login.
### Added
- Private direct messages: a dedicated inbox you can search by name, start new chats with inline recipient lookup, page back through old conversations, and read incoming messages translated into your language.
- Get verified: a guided sign-up for trusted reviewers to set up a verifier profile, publish a public "how we verify" statement, and vouch for campaigns. Verified badges now show on campaign pages, and a short tutorial walks you through it.
- A Venezuela earthquake relief appeal — a home-page banner, a one-time popup, and a shareable page that bakes in the relief campaign so you can read its story and donate without leaving.
- Optional Tor routing on Android for added privacy.
- Separate Public and Private wallets, keeping your spending and your private silent-payment funds cleanly apart.
- A "Don't have Bitcoin?" prompt on campaign pages that points first-time donors to Cash App.
- An always-visible language switcher in the top navigation, and a corporate sponsorship page.
### Changed
- Redesigned profiles with cleaner stats, a merged campaigns view, and a themed raised total.
- Redesigned Settings into an Apple-style grouped layout.
- Reworked the login and onboarding flow, including a new welcome screen built around the Agora brand.
- Silent-payment donations now scan faster and keep working in the background, with a progress bar on the private wallet.
- The home page now shows every featured campaign instead of capping the list.
### Fixed
- The audio, music, and podcast pages no longer crash.
- Backfilled and corrected translations across all sixteen languages.
## [2.8.9] - 2026-06-02
Adds an in-app prompt to grab the Android app from Zapstore, makes it easier to start or explore campaigns right from the home page, and irons out a batch of language and display fixes.
### Added
- A prompt to download the Android app from Zapstore, shown to mobile web visitors on the home page, in the account menu, and in the slide-out menu.
- A "Start a campaign" button alongside "Browse all" in the middle of the home page.
### Changed
- The "Explore campaigns" button now appears for everyone, not just logged-out visitors.
### Fixed
- Switching languages now takes effect immediately instead of showing stale text.
- The reply box and the replies heading on a post now show up in your chosen language.
- Account balances keep their Latin numerals regardless of display language.
- Filled in missing translations on the "Why Agora" screen.
## [2.8.8] - 2026-06-02
Fixes the app icon proportions and updates the loading splash to the Agora bolt.
### Fixed
- App icon no longer appears squashed.
- Loading splash now shows the Agora bolt instead of the old logo.
## [2.8.7] - 2026-06-02
Fixes the top navigation bar rendering behind the status bar on Android.
### Fixed
- Top navigation bar now clears the system status bar on Android.
## [2.8.6] - 2026-06-02
Refreshes the app icon to the orange Agora bolt mark across Android, iOS, and the web.
### Changed
- 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.
+101
View File
@@ -15,6 +15,7 @@
| 33863 | Campaign | Self-authored fundraising campaign with a single Bitcoin wallet endpoint (`bc1...` or `sp1...`) |
| 30385 | Community Stats Snapshot | Pre-computed per-country / global community leaderboards |
| 36639 | Pledge | Donor pledge for concrete submissions, stored as sats |
| 14672 | Verifier Statement | Self-authored statement describing how the author verifies campaigns (one per user) |
### Agora Protocols
@@ -23,6 +24,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 (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. |
| Campaign Verification | 33863, 1985 | Positive trust signal: moderator-signed NIP-32 labels in the `agora.verified` namespace (value `verified`) vouching for a campaign. Gated by the same moderator pack as hide/feature; retracted via kind 5 deletion. |
| 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
@@ -695,8 +697,107 @@ Step 3 — fold by `(coord, axis)`, latest-`created_at`-wins, filtering to the r
- 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.
#### Campaign Verification Labels (`agora.verified`)
Separately from the hide/feature moderation axes above, Agora supports a positive **verification** signal: a campaign moderator vouches for a specific campaign. Verification is a distinct NIP-32 label namespace, `agora.verified`, with a single value `verified`. It rides the same kind 1985 label kind and the **same moderator pack** as the hide/feature labels, but is otherwise independent of `agora.moderation` — no axes, no rank, purely additive.
A verification label points at one campaign coordinate (`33863:<pubkey>:<d>`):
```json
{
"kind": 1985,
"content": "",
"tags": [
["L", "agora.verified"],
["l", "verified", "agora.verified"],
["a", "33863:<campaign-pubkey>:<campaign-d>"],
["alt", "Campaign verification"]
]
}
```
**Trust model.** The set of pubkeys whose `agora.verified` labels are honored is the campaign moderator pack — the same allowlist that governs hide/feature labels (the Team Soapbox follow pack `p` tags). Clients MUST filter the read query by `authors: <moderators>` — a `verified` label signed by any pubkey outside the pack MUST be ignored, otherwise the badge is forgeable by anyone. As with moderation labels, clients MUST NOT run the query with an empty `authors:` filter.
**Reading.** One filter fetches every verification across all moderators:
```json
{
"kinds": [1985],
"authors": ["<moderator-1>", "<moderator-2>"],
"#L": ["agora.verified"],
"#l": ["verified"],
"limit": 2000
}
```
Fold by `(coord, moderator)`, keeping the newest label per pair. A campaign is "verified by" the set of moderators with a surviving label; clients SHOULD render the moderators' avatars stacked as a badge, with multiple moderators forming a stack.
**Retraction.** There is no `unverified` value. A moderator retracts a verification by publishing a NIP-09 kind 5 deletion of their own label event (referenced by `e` tag plus `k: 1985`). A kind 5 only takes effect on events authored by the signer, so a moderator can only remove their own verification.
**Client behavior.**
- Verification is a moderator action: clients SHOULD render the verify / remove-verification control inside the campaign moderator menu (alongside hide / add-to-list), gated on moderator membership.
- Verification is purely additive — it never hides or promotes a campaign on its own. It is a trust hint layered over whatever moderation/discovery state already applies.
- The label kind 1985 read is routed to Agora's search relays (`relay.ditto.pub`, `relay.dreamith.to`) where these labels are published.
---
## Kind 14672: Verifier Statement
### Summary
Replaceable event kind for a **self-authored statement describing how the author verifies campaigns**. Anyone can "become a verifier" simply by publishing one of these events — there is no gatekeeper. The statement is a public, freeform explanation of the diligence process the author applies before vouching for a campaign, so donors can judge whether to trust that author's judgement.
Exactly one statement per user (replaceable, no `d` tag): publishing a new event replaces the previous one. Clients surface the statement prominently on the author's profile page.
This kind is **distinct from** the `agora.verified` campaign-verification labels (kind 1985, see Kind 33863 above). Those are moderator-signed, gated by the Team Soapbox follow pack, and vouch for one specific campaign. A kind 14672 statement is an open, self-published description of an author's *general* verification methodology and confers no special authority — it is a reputation signal donors read, not an access-control mechanism.
### Event Structure
```json
{
"kind": 14672,
"pubkey": "<author-pubkey>",
"content": "I personally visit each campaign organizer over video call, confirm their identity against a government ID, and cross-check the cause with at least two independent local sources before I vouch for it. ...",
"tags": [
["alt", "Verifier statement: how this account verifies campaigns"],
["t", "agora"]
]
}
```
### Content
The `content` field is the verifier statement, formatted as **Markdown**. Clients SHOULD render it with the same Markdown renderer they use for other long-form Agora content (campaign stories, policy pages). Empty or whitespace-only content means the author has **withdrawn** their verifier statement — clients MUST treat an empty-content event the same as no event (the author is no longer a verifier) and MUST NOT render a verifier section for it.
### Tags
| Tag | Required | Description |
|-------|-------------|-----------------------------------------------------------------------------|
| `alt` | Recommended | NIP-31 human-readable fallback describing the event's purpose. |
| `t` | Optional | Agora content marker (`t:agora`). Added at publish time via `withAgoraTag`. |
The statement carries no queryable fields beyond the author and kind — it is identified entirely by `(14672, pubkey)`.
### Querying
**Fetch a user's verifier statement:**
```json
{ "kinds": [14672], "authors": ["<pubkey>"], "limit": 1 }
```
Clients MUST filter by `authors` — a verifier statement only describes the diligence of the pubkey that signed it, so an unfiltered query would be meaningless (and would let anyone's statement be attributed to anyone).
### Client Behavior
- **Becoming a verifier:** a user publishes a kind 14672 event with their statement in `content`. No approval, allowlist, or moderation gate applies.
- **Withdrawing:** a user republishes the event with empty `content`, or publishes a NIP-09 kind 5 deletion referencing the event. Either way clients stop rendering the verifier section.
- **Rendering:** clients SHOULD surface the statement prominently on the author's profile (e.g. a dedicated "Verifier" section in the profile overview), rendering the Markdown content sanitized.
- **Editing:** because the kind is replaceable, the latest event per `(14672, pubkey)` wins. Clients performing an edit SHOULD pass the previous event as `prev` so `published_at` is preserved (NIP-23 convention).
---
## Kind 16769: Profile Tabs
### Summary
+14 -13
View File
@@ -1,22 +1,21 @@
# Agora
# Eranos
Power to the people.
Agora is a Nostr client focused on community ownership, expressive identity, and censorship resistance. This repository (`agora-3`) is the Agora-branded app built from the Ditto codebase.
Eranos is a Nostr client focused on community ownership, expressive identity, and censorship resistance. This repository is the Eranos-branded app, a Grin-only fork of Agora (itself built from the Ditto codebase). All Bitcoin and Lightning payment rails have been removed; Grin payments land in a later phase.
**[agora.spot](https://agora.spot)** | **[Source](https://gitlab.com/soapbox-pub/agora-3)**
**[eranos.fund](https://eranos.fund)** | **Upstream: [Agora](https://gitlab.com/soapbox-pub/agora-3)**
## What This Repo Is
- Agora product identity (name, theme, assets, native IDs)
- Eranos product identity (name, theme, assets, native IDs)
- Ditto-derived implementation with broad Nostr feature coverage
- Configurable deployment defaults via `agora.json`
- Configurable deployment defaults via `eranos.json`
## Features
- **Community-first social client**: notes, articles, comments, reposts, reactions, and rich event rendering
- **Theming system**: built-in presets + custom color/font/background themes that can be shared as events
- **Lightning support**: zaps with Nostr Wallet Connect and WebLN
- **Private messaging**: NIP-04 and NIP-17 direct messages
- **Mobile app shell**: Capacitor-powered Android/iOS wrappers
- **Self-hostable**: static web build + configurable relay and upload infrastructure
@@ -31,8 +30,8 @@ Agora is a Nostr client focused on community ownership, expressive identity, and
### Development
```sh
git clone https://gitlab.com/soapbox-pub/agora-3.git
cd agora-3
git clone <this-repo>
cd eranos
npm install
npm run dev
```
@@ -44,8 +43,8 @@ Development server: `http://localhost:8080`
Use Docker Compose when you want the nginx reverse-proxy stack (necessary if you want decryptable media in messages - kind 15s of NIP 17):
```sh
git clone https://gitlab.com/soapbox-pub/agora-3.git
cd agora-3
git clone <this-repo>
cd eranos
cp .env.example .env
docker compose up --build
```
@@ -87,7 +86,7 @@ This runs type-checking, linting, unit tests, and production build checks.
## Configuration
Build-time config is read from `agora.json` (gitignored by default so each deployment can provide its own values).
Build-time config is read from `eranos.json` (gitignored by default so each deployment can provide its own values).
```jsonc
{
@@ -109,7 +108,7 @@ Build-time config is read from `agora.json` (gitignored by default so each deplo
Configuration priority (highest first):
1. User settings (local storage)
2. Build config (`agora.json`)
2. Build config (`eranos.json`)
3. Hardcoded app defaults
Use a custom config path:
@@ -120,7 +119,7 @@ CONFIG_FILE=./my-config.json npm run build
## Deployment
Agora builds to static files and can be deployed to any static host.
Eranos builds to static files and can be deployed to any static host.
- GitLab/GitHub Pages
- Netlify/Vercel
@@ -155,3 +154,5 @@ Read [CONTRIBUTING.md](CONTRIBUTING.md) before opening a merge request.
## License
[AGPL-3.0](LICENSE)
🤖 Built with AI pair-programming assistance (Claude)
+63 -3
View File
@@ -7,15 +7,22 @@ if (keystorePropertiesFile.exists()) {
}
android {
namespace = "spot.agora.app"
namespace = "fund.eranos.app"
compileSdk = rootProject.ext.compileSdkVersion
defaultConfig {
applicationId "spot.agora.app"
applicationId "fund.eranos.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "2.8.5"
versionName "2.9.1"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
// The arti-mobile AAR bundles large native Rust libraries for every
// ABI (~45 MB total). Restrict to the ABIs we actually ship/test:
// arm64-v8a + armeabi-v7a (real devices) and x86_64 (emulators).
// Drop x86_64 here if you only ever test on physical devices.
ndk {
abiFilters 'arm64-v8a', 'armeabi-v7a', 'x86_64'
}
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
@@ -51,10 +58,20 @@ 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')
implementation 'com.squareup.okhttp3:okhttp:4.12.0'
// Tor in Rust (arti) — prebuilt AAR from Guardian Project's gpmaven repo
// (source pinned to an immutable commit in the root build.gradle).
// Provides org.torproject.arti.ArtiProxy used by TorController.
// The resolved AAR is checksum-verified below (verifyArtiChecksum).
implementation 'org.torproject:arti-mobile:1.7.0.1'
// arti pulls androidx.webkit in transitively but only at runtime; we
// compile against ProxyController/WebViewFeature in TorController, so
// declare it explicitly on the app's compile classpath.
implementation "androidx.webkit:webkit:$androidxWebkitVersion"
testImplementation "junit:junit:$junitVersion"
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
@@ -63,6 +80,49 @@ dependencies {
apply from: 'capacitor.build.gradle'
// Supply-chain pin for the arti-mobile AAR. It carries a native library with
// network-proxy privileges and is sourced from Guardian Project's gpmaven repo,
// so we verify the resolved artifact's SHA-256 against a value pinned here.
// A mismatch fails the build before any APK is assembled. To bump arti, update
// the version + repo commit (root build.gradle) and replace this checksum after
// re-verifying a fresh download.
ext.artiMobileSha256 = 'cbdb34ce3cdb32f755f25f6dd05a2d1eb9a44025a17ec9202729816e2a3af05b'
task verifyArtiChecksum {
doLast {
def artifact = configurations.releaseRuntimeClasspath
.resolvedConfiguration
.resolvedArtifacts
.find { it.moduleVersion.id.group == 'org.torproject' && it.moduleVersion.id.name == 'arti-mobile' }
if (artifact == null) {
throw new GradleException("arti-mobile artifact not found on the runtime classpath; cannot verify Tor native library.")
}
def actual = java.security.MessageDigest.getInstance("SHA-256")
.digest(artifact.file.bytes)
.collect { String.format('%02x', it) }
.join('')
if (actual != project.ext.artiMobileSha256) {
throw new GradleException(
"arti-mobile AAR checksum mismatch!\n" +
" expected: ${project.ext.artiMobileSha256}\n" +
" actual: ${actual}\n" +
" file: ${artifact.file}\n" +
"Refusing to build a Tor proxy from an unverified native artifact."
)
}
logger.lifecycle("Verified arti-mobile AAR checksum (${actual}).")
}
}
afterEvaluate {
tasks.matching { it.name.startsWith('assemble') || it.name.startsWith('bundle') }.configureEach {
dependsOn verifyArtiChecksum
}
}
try {
def servicesJSON = file('google-services.json')
if (servicesJSON.text) {
+5 -1
View File
@@ -7,7 +7,7 @@
# Keep Capacitor classes (WebView JS bridge)
-keep class com.getcapacitor.** { *; }
-keep class spot.agora.app.** { *; }
-keep class fund.eranos.app.** { *; }
# Keep WebView JS interfaces
-keepclassmembers class * {
@@ -27,6 +27,10 @@
-keepattributes *Annotation*
-keep class com.outsystems.plugins.barcode.** { *; }
# Keep arti (Tor) classes ArtiJNI declares native methods invoked from the
# Rust .so via JNI, so its names must not be obfuscated/stripped.
-keep class org.torproject.arti.** { *; }
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
+2 -2
View File
@@ -24,12 +24,12 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Deep links: open agora.spot URLs in the app -->
<!-- Deep links: open eranos.fund URLs in the app -->
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="agora.spot" />
<data android:scheme="https" android:host="eranos.fund" />
</intent-filter>
</activity>
@@ -1,4 +1,4 @@
package spot.agora.app;
package fund.eranos.app;
import android.app.ForegroundServiceStartNotAllowedException;
import android.content.Context;
@@ -1,4 +1,4 @@
package spot.agora.app;
package fund.eranos.app;
import android.app.ForegroundServiceStartNotAllowedException;
import android.content.Context;
@@ -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;
@@ -19,6 +25,14 @@ public class MainActivity extends BridgeActivity {
protected void onCreate(Bundle savedInstanceState) {
// Register native plugins before super.onCreate.
registerPlugin(DittoNotificationPlugin.class);
registerPlugin(TorPlugin.class);
// If the user enabled Tor (apply on relaunch), start arti BEFORE
// super.onCreate so the WebView SOCKS proxy override is installed
// before the WebView issues any network request no leak window.
if (TorController.isEnabled(this)) {
TorController.getInstance().start(getApplicationContext());
}
super.onCreate(savedInstanceState);
@@ -47,6 +61,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
@@ -63,7 +106,7 @@ public class MainActivity extends BridgeActivity {
private void handleNotificationIntent(Intent intent) {
if (intent == null) return;
Uri data = intent.getData();
if (data != null && "agora.spot".equals(data.getHost())) {
if (data != null && "eranos.fund".equals(data.getHost())) {
String path = data.getPath();
if (path != null && !path.isEmpty()) {
// Wait for WebView to be ready, then navigate
@@ -1,4 +1,4 @@
package spot.agora.app;
package fund.eranos.app;
import android.app.NotificationChannel;
import android.app.NotificationManager;
@@ -337,7 +337,7 @@ public class NostrPoller {
if (manager == null) return;
Intent intent = new Intent(context, MainActivity.class);
intent.setData(Uri.parse("https://agora.spot/notifications"));
intent.setData(Uri.parse("https://eranos.fund/notifications"));
intent.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP);
PendingIntent pendingIntent = PendingIntent.getActivity(
context, id, intent,
@@ -1,4 +1,4 @@
package spot.agora.app;
package fund.eranos.app;
import android.app.AlarmManager;
import android.app.ForegroundServiceStartNotAllowedException;
@@ -83,7 +83,7 @@ public class NotificationRelayService extends Service {
// + REQ + up to 5 events + EOSE + metadata fetch + disconnect.
private static final long FETCH_WAKELOCK_TIMEOUT_MS = 30_000;
private static final String ACTION_FETCH = "spot.agora.app.ACTION_FETCH";
private static final String ACTION_FETCH = "fund.eranos.app.ACTION_FETCH";
// Backoff bounds for relay connect failures (separate from alarm interval).
private static final long INITIAL_BACKOFF_MS = 1_000;
@@ -0,0 +1,381 @@
package fund.eranos.app;
import android.content.Context;
import android.content.SharedPreferences;
import android.util.Log;
import androidx.annotation.Nullable;
import androidx.webkit.ProxyConfig;
import androidx.webkit.ProxyController;
import androidx.webkit.WebViewFeature;
import org.json.JSONException;
import org.json.JSONObject;
import java.net.InetSocketAddress;
import java.net.Proxy;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.torproject.arti.ArtiProxy;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
/**
* Process-wide controller for the optional Tor (arti) mode on Android.
*
* <p>When enabled, this starts a local SOCKS5 proxy backed by arti (Tor in
* Rust) and — via {@link ArtiProxy.ArtiProxyBuilder#setWrapWebView(boolean)} —
* installs an Android {@code ProxyController} override so that <em>all</em>
* Capacitor WebView traffic (every {@code fetch} and relay {@code WebSocket})
* is routed through Tor. No changes to the TypeScript HTTP layer are needed.
*
* <p>The enabled flag is persisted to {@link SharedPreferences} by
* {@link TorPlugin} and read here at startup from {@link MainActivity}, so arti
* auto-starts on a cold launch <em>before</em> the WebView loads — there is no
* pre-bootstrap leak window. Beyond that, activation is live: the settings
* toggle calls {@link #start}/{@link #stop} (bridged through {@link TorPlugin}),
* which start or stop arti immediately while also updating the persisted flag.
*
* <p>Pluggable transports (obfs4 via IPtProxy) are intentionally not wired up
* yet — the builder already exposes {@code setObfs4Port}/{@code setBridgeLines}
* for a future censorship-resistance layer.
*/
public class TorController {
private static final String TAG = "TorController";
/** Local SOCKS5 port arti listens on (arti's own default). */
public static final int SOCKS_PORT = 9150;
static final String PREFS_NAME = "tor_config";
static final String KEY_ENABLED = "enabled";
/** Endpoint used to confirm a working Tor circuit (small JSON response). */
private static final String PROBE_URL = "https://check.torproject.org/api/ip";
// Re-verify continuously (gently) so the status reflects current reality.
private static final long PROBE_INTERVAL_SECONDS = 10;
/** After this long without a successful probe, surface a soft "failed". */
private static final long SOFT_TIMEOUT_SECONDS = 120;
// Status values mirrored to JS (see src/lib/tor.ts TorStatus).
public static final String STATUS_DISABLED = "disabled";
public static final String STATUS_CONNECTING = "connecting";
public static final String STATUS_CONNECTED = "connected";
public static final String STATUS_FAILED = "failed";
/** Receives status changes so the Capacitor plugin can forward them to JS. */
public interface StatusListener {
void onTorStatus(String status, int bootstrapPercent, @Nullable String error, @Nullable String exitIp);
}
private static volatile TorController instance;
private final Object lock = new Object();
private final AtomicBoolean started = new AtomicBoolean(false);
private ArtiProxy artiProxy;
private ScheduledExecutorService scheduler;
private volatile String status = STATUS_DISABLED;
private volatile int bootstrapPercent = 0;
@Nullable private volatile String error = null;
/** Tor exit-node IP from the last successful check (for verification UI). */
@Nullable private volatile String exitIp = null;
/** Consecutive failed probes; used to debounce CONNECTED -> reconnecting. */
private int consecutiveFailures = 0;
@Nullable private volatile StatusListener listener;
private volatile long startedAtMs = 0;
private TorController() {}
public static TorController getInstance() {
if (instance == null) {
synchronized (TorController.class) {
if (instance == null) {
instance = new TorController();
}
}
}
return instance;
}
/** Whether Tor is enabled in persisted preferences (read at cold-launch startup). */
public static boolean isEnabled(Context context) {
SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
return prefs.getBoolean(KEY_ENABLED, false);
}
/**
* Persist the enabled flag only. This controls whether arti auto-starts on
* the next cold launch; it does not start or stop arti now. For live
* activation call {@link #start}/{@link #stop}, which also persist the flag.
*/
public static void setEnabled(Context context, boolean enabled) {
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.edit()
.putBoolean(KEY_ENABLED, enabled)
.apply();
}
public void setListener(@Nullable StatusListener listener) {
this.listener = listener;
// Replay the current status so a freshly-attached listener is in sync.
if (listener != null) {
listener.onTorStatus(status, bootstrapPercent, error, exitIp);
}
}
public String getStatus() {
return status;
}
public int getBootstrapPercent() {
return bootstrapPercent;
}
@Nullable
public String getError() {
return error;
}
@Nullable
public String getExitIp() {
return exitIp;
}
/**
* Start arti and install the WebView proxy override. Idempotent: a second
* call while already running is a no-op. Heavy work runs off the caller's
* thread so this is safe to invoke from {@code MainActivity.onCreate}.
*/
public void start(Context context) {
if (!started.compareAndSet(false, true)) {
return;
}
final Context appContext = context.getApplicationContext();
exitIp = null;
consecutiveFailures = 0;
// Install the fail-closed WebView proxy override synchronously, BEFORE
// the WebView loads (start() is called from MainActivity.onCreate ahead
// of super.onCreate). With no direct fallback, any request that arti
// can't carry fails instead of leaking out directly — even during the
// bootstrap window when arti isn't connected yet.
applyWebViewProxy();
updateStatus(STATUS_CONNECTING, 0, null);
startedAtMs = System.currentTimeMillis();
Thread t = new Thread(() -> {
try {
synchronized (lock) {
// NB: we do NOT use setWrapWebView(true) — arti's helper
// appends a DIRECT fallback (fail-open). We set our own
// fail-closed override in applyWebViewProxy() instead.
artiProxy = ArtiProxy.Builder(appContext)
.setSocksPort(SOCKS_PORT)
.setLogListener(this::onArtiLog)
.build();
artiProxy.start();
}
Log.d(TAG, "arti started on socks://127.0.0.1:" + SOCKS_PORT);
beginConnectivityProbe();
} catch (Throwable e) {
Log.e(TAG, "Failed to start arti", e);
updateStatus(STATUS_FAILED, bootstrapPercent, String.valueOf(e.getMessage()));
}
}, "arti-start");
t.setDaemon(true);
t.start();
}
/** Stop arti and route the WebView back to a direct connection. Safe to
* call live (toggle off) — clears the SOCKS proxy override so traffic
* doesn't get stranded on the now-stopped proxy. */
public void stop() {
// Remove the WebView SOCKS override first so new requests go direct.
clearWebViewProxy();
synchronized (lock) {
if (scheduler != null) {
scheduler.shutdownNow();
scheduler = null;
}
if (artiProxy != null) {
try {
artiProxy.stop();
} catch (Throwable e) {
Log.w(TAG, "Error stopping arti", e);
}
artiProxy = null;
}
}
started.set(false);
exitIp = null;
updateStatus(STATUS_DISABLED, 0, null);
}
/** Re-run the connectivity probe (used by a "Retry" action in the gate). */
public void retry() {
if (!started.get()) {
return;
}
consecutiveFailures = 0;
startedAtMs = System.currentTimeMillis();
if (!STATUS_CONNECTED.equals(status)) {
updateStatus(STATUS_CONNECTING, bootstrapPercent, null);
}
beginConnectivityProbe();
}
// --- internals -------------------------------------------------------
/**
* Route the WebView through arti's SOCKS proxy, FAIL-CLOSED. There is no
* {@code addDirect()} fallback, so when Tor can't carry a request it fails
* rather than leaking to a direct connection. localhost is bypassed (it's
* the local Capacitor asset server, never remote traffic).
*/
private void applyWebViewProxy() {
try {
if (WebViewFeature.isFeatureSupported(WebViewFeature.PROXY_OVERRIDE)) {
ProxyConfig config = new ProxyConfig.Builder()
.addProxyRule("socks://127.0.0.1:" + SOCKS_PORT)
// No addDirect() — fail closed.
.addBypassRule("localhost")
.addBypassRule("127.0.0.1")
.build();
ProxyController.getInstance().setProxyOverride(config, Runnable::run, () -> {});
}
} catch (Throwable e) {
Log.w(TAG, "Error applying WebView proxy override", e);
}
}
/** Remove the app-wide WebView SOCKS proxy override so the WebView reverts
* to a direct connection. */
private void clearWebViewProxy() {
try {
if (WebViewFeature.isFeatureSupported(WebViewFeature.PROXY_OVERRIDE)) {
ProxyController.getInstance().clearProxyOverride(Runnable::run, () -> {});
}
} catch (Throwable e) {
Log.w(TAG, "Error clearing WebView proxy override", e);
}
}
private static final Pattern PERCENT = Pattern.compile("(\\d{1,3})\\s*%");
private void onArtiLog(String line) {
if (line == null) return;
Log.d("artilog", line);
// Best-effort bootstrap progress for the UI. arti's log format isn't a
// stable API, so the connectivity probe (below) remains authoritative
// for the definitive "connected" signal.
Matcher m = PERCENT.matcher(line);
if (m.find()) {
try {
int pct = Integer.parseInt(m.group(1));
if (pct >= 0 && pct <= 100 && pct >= bootstrapPercent
&& !STATUS_CONNECTED.equals(status)) {
updateStatus(STATUS_CONNECTING, pct, null);
}
} catch (NumberFormatException ignored) {
}
}
}
private void beginConnectivityProbe() {
synchronized (lock) {
if (scheduler != null) {
scheduler.shutdownNow();
}
scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
Thread th = new Thread(r, "tor-probe");
th.setDaemon(true);
return th;
});
final ScheduledExecutorService s = scheduler;
final OkHttpClient client = new OkHttpClient.Builder()
.proxy(new Proxy(Proxy.Type.SOCKS, new InetSocketAddress("127.0.0.1", SOCKS_PORT)))
.connectTimeout(20, TimeUnit.SECONDS)
.readTimeout(20, TimeUnit.SECONDS)
.build();
// Probe continuously (no shutdown on success). check.torproject.org
// reports whether the request actually exited via Tor, so we only
// report CONNECTED when IsTor is true — and we keep re-verifying so a
// dropped circuit downgrades the status instead of lying.
s.scheduleWithFixedDelay(() -> {
Request req = new Request.Builder()
.url(PROBE_URL)
.header("Accept", "application/json")
.build();
try (Response resp = client.newCall(req).execute()) {
String body = resp.body() != null ? resp.body().string() : "";
boolean isTor = false;
String ip = null;
try {
JSONObject json = new JSONObject(body);
isTor = json.optBoolean("IsTor", false);
ip = json.has("IP") ? json.optString("IP", null) : null;
} catch (JSONException ignored) {
// Non-JSON response — treat as not-via-Tor below.
}
if (resp.isSuccessful() && isTor) {
consecutiveFailures = 0;
exitIp = ip;
updateStatus(STATUS_CONNECTED, 100, null);
} else if (resp.isSuccessful()) {
// Reached the internet but NOT through Tor — a leak/bypass.
// This should not happen with the SOCKS proxy, but report
// it honestly rather than claiming a Tor connection.
consecutiveFailures = 0;
exitIp = ip;
updateStatus(STATUS_FAILED, bootstrapPercent,
"Connected to the internet, but not through Tor.");
} else {
handleProbeFailure();
}
} catch (Exception e) {
handleProbeFailure();
}
}, 0, PROBE_INTERVAL_SECONDS, TimeUnit.SECONDS);
}
}
/** A probe couldn't reach Tor. Debounce CONNECTED, surface FAILED after the
* soft timeout while still connecting. */
private void handleProbeFailure() {
consecutiveFailures++;
if (STATUS_CONNECTED.equals(status)) {
// Tolerate a couple of transient blips before downgrading.
if (consecutiveFailures >= 3) {
exitIp = null;
updateStatus(STATUS_CONNECTING, bootstrapPercent,
"Lost the Tor circuit; reconnecting…");
}
return;
}
long elapsed = (System.currentTimeMillis() - startedAtMs) / 1000;
if (elapsed >= SOFT_TIMEOUT_SECONDS && !STATUS_FAILED.equals(status)) {
updateStatus(STATUS_FAILED, bootstrapPercent,
"Couldn't reach the Tor network. Your network may be blocking Tor.");
}
}
private void updateStatus(String newStatus, int percent, @Nullable String err) {
this.status = newStatus;
this.bootstrapPercent = percent;
this.error = err;
StatusListener l = this.listener;
if (l != null) {
l.onTorStatus(newStatus, percent, err, exitIp);
}
}
}
@@ -0,0 +1,102 @@
package fund.eranos.app;
import com.getcapacitor.JSObject;
import com.getcapacitor.Plugin;
import com.getcapacitor.PluginCall;
import com.getcapacitor.PluginMethod;
import com.getcapacitor.annotation.CapacitorPlugin;
/**
* Capacitor bridge for the Tor (arti) mode.
*
* <p>Mirrors {@link DittoNotificationPlugin}'s pattern: JS configures native
* state, native owns the work. On a cold launch arti auto-starts from
* {@link MainActivity} based on the persisted flag. At runtime the settings
* toggle activates Tor live via {@link #start}/{@link #stop}, which start or
* stop arti immediately and update the persisted flag. ({@link #setEnabled}
* persists the flag only, without touching the running proxy.) Live bootstrap
* status is pushed to JS via the {@code torStatus} event.
*
* <p>JS interface: see {@code src/lib/tor.ts}.
*/
@CapacitorPlugin(name = "Tor")
public class TorPlugin extends Plugin {
private static final String EVENT_STATUS = "torStatus";
@Override
public void load() {
// Forward native status changes to JS listeners. Attaching also replays
// the current status, keeping a newly-mounted JS gate in sync.
TorController.getInstance().setListener((status, bootstrapPercent, error, exitIp) -> {
JSObject data = new JSObject();
data.put("status", status);
data.put("bootstrapPercent", bootstrapPercent);
data.put("error", error);
data.put("exitIp", exitIp);
notifyListeners(EVENT_STATUS, data);
});
}
/** Whether Tor is enabled in persisted preferences. */
@PluginMethod
public void isEnabled(PluginCall call) {
JSObject ret = new JSObject();
ret.put("enabled", TorController.isEnabled(getContext()));
call.resolve(ret);
}
/**
* Persist the enabled flag only, without starting or stopping arti now.
* Controls whether arti auto-starts on the next cold launch. For live
* activation use {@link #start}/{@link #stop}.
*/
@PluginMethod
public void setEnabled(PluginCall call) {
Boolean enabled = call.getBoolean("enabled");
if (enabled == null) {
call.reject("Missing 'enabled' boolean");
return;
}
TorController.setEnabled(getContext(), enabled);
call.resolve();
}
/** Start arti now (live activation). Also persists enabled=true so it
* auto-starts on the next cold launch. */
@PluginMethod
public void start(PluginCall call) {
TorController.setEnabled(getContext(), true);
TorController.getInstance().start(getContext());
call.resolve();
}
/** Stop arti now (live deactivation) and clear the WebView proxy. Also
* persists enabled=false. */
@PluginMethod
public void stop(PluginCall call) {
TorController.setEnabled(getContext(), false);
TorController.getInstance().stop();
call.resolve();
}
/** Current connection status (synchronous snapshot). */
@PluginMethod
public void getStatus(PluginCall call) {
TorController controller = TorController.getInstance();
JSObject ret = new JSObject();
ret.put("enabled", TorController.isEnabled(getContext()));
ret.put("status", controller.getStatus());
ret.put("bootstrapPercent", controller.getBootstrapPercent());
ret.put("error", controller.getError());
ret.put("exitIp", controller.getExitIp());
call.resolve(ret);
}
/** Re-run the connectivity probe (for a "Retry" action in the gate). */
@PluginMethod
public void retry(PluginCall call) {
TorController.getInstance().retry();
call.resolve();
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 762 B

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 487 B

After

Width:  |  Height:  |  Size: 652 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

@@ -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: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

File diff suppressed because one or more lines are too long
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 751 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 487 B

After

Width:  |  Height:  |  Size: 652 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 3.7 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>
+4 -4
View File
@@ -1,7 +1,7 @@
<?xml version='1.0' encoding='utf-8'?>
<resources>
<string name="app_name">Agora</string>
<string name="title_activity_main">Agora</string>
<string name="package_name">spot.agora.app</string>
<string name="custom_url_scheme">spot.agora.app</string>
<string name="app_name">Eranos</string>
<string name="title_activity_main">Eranos</string>
<string name="package_name">fund.eranos.app</string>
<string name="custom_url_scheme">fund.eranos.app</string>
</resources>
+12
View File
@@ -21,6 +21,18 @@ allprojects {
repositories {
google()
mavenCentral()
// Guardian Project's experimental Maven repo, hosting the prebuilt
// org.torproject:arti-mobile AAR (Tor in Rust) used for the optional Tor mode.
//
// Pinned to an immutable commit SHA rather than the mutable `master`
// branch: this artifact ships a native library with network-proxy
// privileges, so we don't want a force-push or new commit to gpmaven
// silently changing what we resolve. To bump arti, update both the
// commit below and the checksum pin in `app/build.gradle`, and re-verify
// the SHA-256 against a fresh download.
//
// Commit: guardianproject/gpmaven@b3ee2a63eec4ce37ea22fcc6b1ff009f406f2b13
maven { url "https://raw.githubusercontent.com/guardianproject/gpmaven/b3ee2a63eec4ce37ea22fcc6b1ff009f406f2b13" }
}
}
+3 -10
View File
@@ -1,8 +1,8 @@
import type { CapacitorConfig } from '@capacitor/cli';
const config: CapacitorConfig = {
appId: 'spot.agora.app',
appName: 'Agora',
appId: 'fund.eranos.app',
appName: 'Eranos',
webDir: 'dist',
server: {
androidScheme: 'https',
@@ -16,14 +16,7 @@ const config: CapacitorConfig = {
ios: {
backgroundColor: '#14161f',
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',
},
scheme: 'Eranos'
},
};
+3 -3
View File
@@ -10,7 +10,7 @@ services:
depends_on:
- vite
networks:
- agora-network
- eranos-network
vite:
image: node:22-alpine
@@ -22,9 +22,9 @@ services:
environment:
- NODE_ENV=development
networks:
- agora-network
- eranos-network
restart: unless-stopped
networks:
agora-network:
eranos-network:
driver: bridge
+9 -9
View File
@@ -1,27 +1,27 @@
<!DOCTYPE html>
<html lang="en" class="dark">
<head>
<title>Agora — Power to the people.</title>
<title>Eranos — Power to the people.</title>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content" />
<meta name="description" content="Agora — a Nostr social client for communities, creativity, and ownership." />
<meta name="description" content="Eranos — a peer-to-peer crowdfunding app on Nostr. Fund campaigns directly with Grin, no middlemen." />
<!-- Open Graph -->
<meta property="og:title" content="Agora" />
<meta property="og:title" content="Eranos" />
<meta property="og:description" content="Power to the people." />
<meta property="og:image" content="https://agora.spot/og-image.jpg" />
<meta property="og:image" content="https://eranos.fund/og-image.jpg" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:image:type" content="image/jpeg" />
<meta property="og:type" content="website" />
<meta property="og:url" content="https://agora.spot" />
<meta property="og:site_name" content="Agora" />
<meta property="og:url" content="https://eranos.fund" />
<meta property="og:site_name" content="Eranos" />
<!-- Twitter / X -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Agora" />
<meta name="twitter:title" content="Eranos" />
<meta name="twitter:description" content="Power to the people." />
<meta name="twitter:image" content="https://agora.spot/og-image.jpg" />
<meta name="twitter:image" content="https://eranos.fund/og-image.jpg" />
<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">
@@ -29,7 +29,7 @@
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png">
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
<meta name="theme-color" content="#0a0c14" media="(prefers-color-scheme: dark)">
<meta name="theme-color" content="#ff6600" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#faa805" media="(prefers-color-scheme: light)">
<link rel="manifest" href="/manifest.webmanifest">
<style>@keyframes agora-spin{to{transform:rotate(360deg)}}</style>
</head>
+4 -4
View File
@@ -323,9 +323,9 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 2.8.5;
MARKETING_VERSION = 2.9.1;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = spot.agora.app;
PRODUCT_BUNDLE_IDENTIFIER = fund.eranos.app;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_VERSION = 5.0;
@@ -347,8 +347,8 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 2.8.5;
PRODUCT_BUNDLE_IDENTIFIER = spot.agora.app;
MARKETING_VERSION = 2.9.1;
PRODUCT_BUNDLE_IDENTIFIER = fund.eranos.app;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
SWIFT_VERSION = 5.0;
+2 -2
View File
@@ -4,8 +4,8 @@
<dict>
<key>com.apple.developer.associated-domains</key>
<array>
<string>webcredentials:agora.spot</string>
<string>webcredentials:agora.spot?mode=developer</string>
<string>webcredentials:eranos.fund</string>
<string>webcredentials:eranos.fund?mode=developer</string>
</array>
</dict>
</plist>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 291 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 72 KiB

+1 -1
View File
@@ -33,7 +33,7 @@ public class DittoNotificationPlugin: CAPPlugin, CAPBridgedPlugin {
// MARK: - Constants
static let bgTaskIdentifier = "spot.agora.app.notification-refresh"
static let bgTaskIdentifier = "fund.eranos.app.notification-refresh"
private static let prefsKey = "ditto_notification_config"
// MARK: - Plugin Methods
+5 -5
View File
@@ -7,7 +7,7 @@
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>Agora</string>
<string>Eranos</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
@@ -48,11 +48,11 @@
<key>UIViewControllerBasedStatusBarAppearance</key>
<true/>
<key>NSPhotoLibraryUsageDescription</key>
<string>Agora needs access to your photo library to upload images to your posts and profile.</string>
<string>Eranos 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, and to scan QR codes when sending Bitcoin.</string>
<string>Eranos needs camera access to take photos and videos for your posts, and to scan QR codes.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Agora needs access to your microphone to record voice messages.</string>
<string>Eranos needs access to your microphone to record voice messages.</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>UIBackgroundModes</key>
@@ -61,7 +61,7 @@
</array>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>spot.agora.app.notification-refresh</string>
<string>fund.eranos.app.notification-refresh</string>
</array>
</dict>
</plist>
+1 -1
View File
@@ -1,2 +1,2 @@
app_identifier("spot.agora.app")
app_identifier("fund.eranos.app")
team_id("GZLTTH5DLM")
+5 -5
View File
@@ -3,7 +3,7 @@ default_platform(:ios)
platform :ios do
# ─── Lanes ────────────────────────────────────────────────────────────
desc "Build and sign the App Store IPA. Output at ../artifacts/Agora.ipa."
desc "Build and sign the App Store IPA. Output at ../artifacts/Eranos.ipa."
lane :build_ipa do
setup_lane_signing!
build_release_ipa!
@@ -19,7 +19,7 @@ platform :ios do
submit_release_for_review!(ipa_path)
end
desc "Build, sign, and submit Agora to the App Store for review (single-step convenience)."
desc "Build, sign, and submit Eranos to the App Store for review (single-step convenience)."
lane :release do
setup_lane_signing!
build_release_ipa!
@@ -83,7 +83,7 @@ platform :ios do
configuration: "Release",
export_method: "app-store",
output_directory: "../artifacts",
output_name: "Agora.ipa",
output_name: "Eranos.ipa",
clean: true,
# Override the Xcode project's Automatic signing for this build only.
# Match has already installed the AppStore cert + profile into the
@@ -93,7 +93,7 @@ platform :ios do
xcargs: [
"CODE_SIGN_STYLE=Manual",
"CODE_SIGN_IDENTITY='Apple Distribution'",
"PROVISIONING_PROFILE_SPECIFIER='match AppStore spot.agora.app'",
"PROVISIONING_PROFILE_SPECIFIER='match AppStore fund.eranos.app'",
"DEVELOPMENT_TEAM=GZLTTH5DLM",
].join(" "),
export_options: {
@@ -101,7 +101,7 @@ platform :ios do
signingStyle: "manual",
teamID: "GZLTTH5DLM",
provisioningProfiles: {
"spot.agora.app" => "match AppStore spot.agora.app",
"fund.eranos.app" => "match AppStore fund.eranos.app",
},
},
)
+1 -1
View File
@@ -1,5 +1,5 @@
git_url("https://gitlab.com/soapbox-pub/certificates.git")
storage_mode("git")
type("appstore")
app_identifier(["spot.agora.app"])
app_identifier(["fund.eranos.app"])
team_id("GZLTTH5DLM")
+222 -2387
View File
File diff suppressed because it is too large Load Diff
+5 -11
View File
@@ -1,7 +1,7 @@
{
"name": "agora",
"name": "eranos",
"private": true,
"version": "2.8.5",
"version": "2.9.1",
"type": "module",
"scripts": {
"dev": "npm i --silent && vite",
@@ -15,7 +15,6 @@
"node": ">=22"
},
"dependencies": {
"@breeztech/breez-sdk-spark": "^0.10.0",
"@capacitor/app": "^8.0.0",
"@capacitor/barcode-scanner": "^3.0.2",
"@capacitor/core": "^8.1.0",
@@ -44,7 +43,6 @@
"@fontsource/pirata-one": "^5.2.8",
"@fontsource/silkscreen": "^5.2.8",
"@fontsource/special-elite": "^5.2.8",
"@getalby/sdk": "^5.1.1",
"@hookform/resolvers": "^5.2.2",
"@milkdown/core": "^7.20.0",
"@milkdown/ctx": "^7.20.0",
@@ -57,8 +55,9 @@
"@milkdown/prose": "^7.20.0",
"@milkdown/react": "^7.20.0",
"@milkdown/utils": "^7.20.0",
"@noble/curves": "^2.2.0",
"@noble/curves": "^1.2.0",
"@noble/hashes": "^1.8.0",
"@scure/base": "^1.1.1",
"@nostrify/nostrify": "^0.52.2",
"@nostrify/react": "^0.6.2",
"@nostrify/types": "^0.37.0",
@@ -86,10 +85,6 @@
"@radix-ui/react-toggle": "^1.1.0",
"@radix-ui/react-toggle-group": "^1.1.0",
"@radix-ui/react-tooltip": "^1.2.8",
"@scure/base": "^1.1.1",
"@scure/bip32": "^2.2.0",
"@scure/bip39": "^1.6.0",
"@scure/btc-signer": "^2.2.0",
"@sentry/react": "^10.42.0",
"@tanstack/react-query": "^5.56.2",
"@unhead/addons": "^2.1.13",
@@ -149,7 +144,6 @@
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"@webbtc/webln-types": "^3.0.0",
"autoprefixer": "^10.4.20",
"eslint": "^9.9.0",
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
@@ -163,7 +157,7 @@
"typescript": "^5.5.3",
"typescript-eslint": "^8.0.1",
"vite": "^8.0.3",
"vitest": "^3.1.4"
"vitest": "^4.1.8"
},
"overrides": {
"react": "$react",
+80
View File
@@ -1,5 +1,85 @@
# Changelog
## [2.9.1] - 2026-06-27
The Venezuela earthquake relief appeal now rallies behind every campaign on the ground, not just one. The home banner and relief page showcase a live, swipeable carousel of all Venezuelan relief efforts, with a running total of everything raised so far — so you can pick exactly who to help.
### Changed
- The Venezuela relief appeal now showcases every Venezuelan relief campaign in a live, auto-scrolling carousel you can swipe through, with a combined raised total across all of them, instead of featuring a single campaign.
## [2.9.0] - 2026-06-25
A big one. Private messaging arrives with a fast, searchable inbox you can reply to in your own language. Campaign organizers can now get verified by trusted reviewers through a guided sign-up, and verified badges appear right on campaigns. There's a new Venezuela earthquake relief appeal that takes you straight to donations, plus optional Tor routing on Android, separate Public and Private wallets, faster donation scanning, and refreshed profiles, settings, and login.
### Added
- Private direct messages: a dedicated inbox you can search by name, start new chats with inline recipient lookup, page back through old conversations, and read incoming messages translated into your language.
- Get verified: a guided sign-up for trusted reviewers to set up a verifier profile, publish a public "how we verify" statement, and vouch for campaigns. Verified badges now show on campaign pages, and a short tutorial walks you through it.
- A Venezuela earthquake relief appeal — a home-page banner, a one-time popup, and a shareable page that bakes in the relief campaign so you can read its story and donate without leaving.
- Optional Tor routing on Android for added privacy.
- Separate Public and Private wallets, keeping your spending and your private silent-payment funds cleanly apart.
- A "Don't have Bitcoin?" prompt on campaign pages that points first-time donors to Cash App.
- An always-visible language switcher in the top navigation, and a corporate sponsorship page.
### Changed
- Redesigned profiles with cleaner stats, a merged campaigns view, and a themed raised total.
- Redesigned Settings into an Apple-style grouped layout.
- Reworked the login and onboarding flow, including a new welcome screen built around the Agora brand.
- Silent-payment donations now scan faster and keep working in the background, with a progress bar on the private wallet.
- The home page now shows every featured campaign instead of capping the list.
### Fixed
- The audio, music, and podcast pages no longer crash.
- Backfilled and corrected translations across all sixteen languages.
## [2.8.9] - 2026-06-02
Adds an in-app prompt to grab the Android app from Zapstore, makes it easier to start or explore campaigns right from the home page, and irons out a batch of language and display fixes.
### Added
- A prompt to download the Android app from Zapstore, shown to mobile web visitors on the home page, in the account menu, and in the slide-out menu.
- A "Start a campaign" button alongside "Browse all" in the middle of the home page.
### Changed
- The "Explore campaigns" button now appears for everyone, not just logged-out visitors.
### Fixed
- Switching languages now takes effect immediately instead of showing stale text.
- The reply box and the replies heading on a post now show up in your chosen language.
- Account balances keep their Latin numerals regardless of display language.
- Filled in missing translations on the "Why Agora" screen.
## [2.8.8] - 2026-06-02
Fixes the app icon proportions and updates the loading splash to the Agora bolt.
### Fixed
- App icon no longer appears squashed.
- Loading splash now shows the Agora bolt instead of the old logo.
## [2.8.7] - 2026-06-02
Fixes the top navigation bar rendering behind the status bar on Android.
### Fixed
- Top navigation bar now clears the system status bar on Android.
## [2.8.6] - 2026-06-02
Refreshes the app icon to the orange Agora bolt mark across Android, iOS, and the web.
### Changed
- 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.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 414 B

After

Width:  |  Height:  |  Size: 448 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 985 B

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 272 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 45 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 226 KiB

After

Width:  |  Height:  |  Size: 45 KiB

+1 -5
View File
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 303 B

After

Width:  |  Height:  |  Size: 5.4 KiB

+4 -10
View File
@@ -1,11 +1,11 @@
{
"name": "Agora",
"short_name": "Agora",
"name": "Eranos",
"short_name": "Eranos",
"description": "Power to the people. Organize, create, and connect across the open Nostr network.",
"start_url": "/",
"display": "standalone",
"background_color": "#0a0c14",
"theme_color": "#ff6600",
"theme_color": "#faa805",
"icons": [
{
"src": "/icon-192.png",
@@ -38,12 +38,6 @@
"purpose": "any"
}
],
"related_applications": [
{
"platform": "play",
"url": "https://play.google.com/store/apps/details?id=spot.agora.app",
"id": "spot.agora.app"
}
],
"related_applications": [],
"prefer_related_applications": false
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

After

Width:  |  Height:  |  Size: 113 KiB

+6 -6
View File
@@ -1,5 +1,5 @@
/**
* Agora Service Worker
* Eranos Service Worker
*
* Handles incoming Web Push notifications from the nostr-push server and
* opens/focuses the app when the user taps a notification.
@@ -14,17 +14,17 @@ self.addEventListener('push', (event) => {
try {
payload = event.data.json();
} catch {
payload = { title: 'Agora', body: event.data.text() };
payload = { title: 'Eranos', body: event.data.text() };
}
const title = payload.title ?? 'Agora';
const title = payload.title ?? 'Eranos';
const options = {
body: payload.body ?? '',
icon: payload.icon ?? '/icon-192.png',
badge: payload.badge ?? '/icon-192.png',
data: payload.data ?? {},
requireInteraction: false,
tag: payload.data?.subscription_id ?? 'agora-notification',
tag: payload.data?.subscription_id ?? 'eranos-notification',
renotify: true,
};
@@ -42,7 +42,7 @@ self.addEventListener('notificationclick', (event) => {
self.clients
.matchAll({ type: 'window', includeUncontrolled: true })
.then((clientList) => {
// Focus an existing Agora tab if one is open
// Focus an existing Eranos tab if one is open
for (const client of clientList) {
if (new URL(client.url).origin === self.location.origin) {
client.navigate('/notifications');
@@ -58,7 +58,7 @@ self.addEventListener('notificationclick', (event) => {
// --- Activate immediately ---
//
// On activate:
// 1. Wipe every Cache Storage entry. A previous version of Agora deployed
// 1. Wipe every Cache Storage entry. A previous version of Eranos deployed
// a precaching service worker (Workbox-style) that's still serving stale
// HTML/JS to returning users on this origin. Clearing caches means future
// requests bypass anything the old SW left behind.
+1 -1
View File
@@ -2,7 +2,7 @@
// preloader background before first paint. Runs as a blocking <script> so
// there's no flash of the wrong theme.
//
// Agora's colors are hardcoded in src/index.css via :root {} and .dark {}
// Eranos's colors are hardcoded in src/index.css via :root {} and .dark {}
// blocks. There is no custom-theme branch; the only thing this script
// does is set the right class on <html> and paint the preloader with the
// matching background + primary color so the page doesn't flash white
+7 -6
View File
@@ -55,16 +55,17 @@ COLORED_SVG="$TMPDIR/logo_colored.svg"
RAW_PNG="$TMPDIR/raw.png"
MASTER_PNG="$TMPDIR/master.png"
# Recolor the SVG's black fill to the brand color, on transparent background.
# The phoenix logo.svg carries its own brand yellow (#fcd414); the sed only
# recolors legacy black-fill sources, so it's a no-op for the phoenix.
sed 's/fill="black"/fill="'"$LOGO_COLOR"'"/g' "$SOURCE_SVG" > "$COLORED_SVG"
# The SVG's viewBox is 720x880 (taller than wide). Render at its native
# aspect ratio first so we don't squish the logo horizontally.
# The SVG's viewBox is 1446x1246 (wider than tall). Render at its native
# aspect ratio first so we don't squish the logo vertically.
MASTER_BOX=512 # final square canvas size
MASTER_H=$MASTER_BOX # render the longer side at full size
MASTER_W=$(( MASTER_BOX * 720 / 880 )) # preserve 720:880 aspect
MASTER_W=$MASTER_BOX # render the longer side at full size
MASTER_H=$(( MASTER_BOX * 1246 / 1446 )) # preserve 1446:1246 aspect
echo "Rendering ${MASTER_W}x${MASTER_H} from $SOURCE_SVG (preserving 720:880 aspect)..."
echo "Rendering ${MASTER_W}x${MASTER_H} from $SOURCE_SVG (preserving 1446:1246 aspect)..."
if [ "$SVG_RENDERER" = "inkscape" ]; then
inkscape --export-type=png --export-filename="$RAW_PNG" \
-w "$MASTER_W" -h "$MASTER_H" --export-background-opacity=0 \
+92 -15
View File
@@ -42,21 +42,42 @@ 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. The phoenix logo.svg
# declares fill="#fcd414"; older sources used black/purple, kept for safety.
sed -e 's/fill="black"/fill="#ffffff"/g' \
-e 's/#000000/#ffffff/g' \
-e 's/#7c52e0/#ffffff/g' \
-e 's/#fcd414/#ffffff/g' "$SOURCE_SVG" > "$LOGO_WHITE_SVG"
echo "Rendering white SVG at 512x512..."
echo "Rendering white SVG (preserving aspect ratio)..."
# Render at 1024px tall and let the renderer derive the width from the SVG
# viewBox, so the non-square logo (720x880) is NOT stretched into a square.
# The composite steps below use -resize WxH which fits-inside (aspect-
# preserving), keeping the glyph's true proportions.
if [ "$SVG_RENDERER" = "inkscape" ]; then
inkscape --export-type=png --export-filename="$LOGO_WHITE" -w 512 -h 512 "$LOGO_WHITE_SVG" 2>/dev/null
inkscape --export-type=png --export-filename="$LOGO_WHITE" -h 1024 "$LOGO_WHITE_SVG" 2>/dev/null
else
rsvg-convert -w 512 -h 512 "$LOGO_WHITE_SVG" -o "$LOGO_WHITE"
rsvg-convert -h 1024 "$LOGO_WHITE_SVG" -o "$LOGO_WHITE"
fi
# Orange-fill render, for marks placed on a white/light background (iOS splash).
LOGO_ORANGE_SVG="$TMPDIR/logo_orange.svg"
LOGO_ORANGE="$TMPDIR/logo_orange.png"
sed -e 's/fill="black"/fill="'"$BG_COLOR"'"/g' \
-e 's/#000000/'"$BG_COLOR"'/g' \
-e 's/#7c52e0/'"$BG_COLOR"'/g' \
-e 's/#fcd414/'"$BG_COLOR"'/g' "$SOURCE_SVG" > "$LOGO_ORANGE_SVG"
if [ "$SVG_RENDERER" = "inkscape" ]; then
inkscape --export-type=png --export-filename="$LOGO_ORANGE" -h 1024 "$LOGO_ORANGE_SVG" 2>/dev/null
else
rsvg-convert -h 1024 "$LOGO_ORANGE_SVG" -o "$LOGO_ORANGE"
fi
# ── Adaptive icon foreground PNGs (transparent bg, white logo, safe-zone padding) ──
@@ -82,23 +103,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 +133,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}" \) \
@@ -122,23 +147,55 @@ make_legacy_square 96 android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
make_legacy_square 144 android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
make_legacy_square 192 android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
# Web logo.png (AppDownloadNudge) uses the same round brand-mark style.
make_legacy_round 512 public/logo.png
# PWA install icons (manifest.webmanifest "any" + "maskable" purposes).
make_legacy_round 192 public/icon-192.png
make_legacy_round 512 public/icon-512.png
make_legacy_round 48 android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
make_legacy_round 72 android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
make_legacy_round 96 android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
make_legacy_round 144 android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
make_legacy_round 192 android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
# ── Android push-notification status-bar icon (ic_stat_ditto) ──
# Must be a flat white silhouette on transparent — the OS tints it, any
# color/gradient gets ignored or looks wrong. Referenced directly by
# NostrPoller.java / NotificationRelayService.java via R.drawable.ic_stat_ditto.
echo "Generating notification status-bar icon (ic_stat_ditto)..."
make_notification_icon() {
local size=$1
local content_size=$(echo "$size * 80 / 100" | bc)
local dest=$2
$MAGICK -size "${size}x${size}" "xc:none" \
\( "$LOGO_WHITE" -resize "${content_size}x${content_size}" \) \
-gravity center -compose over -composite \
"$dest"
}
for dir in drawable mipmap; do
make_notification_icon 24 android/app/src/main/res/${dir}-mdpi/ic_stat_ditto.png
make_notification_icon 48 android/app/src/main/res/${dir}-xhdpi/ic_stat_ditto.png
make_notification_icon 72 android/app/src/main/res/${dir}-xxhdpi/ic_stat_ditto.png
make_notification_icon 96 android/app/src/main/res/${dir}-xxxhdpi/ic_stat_ditto.png
done
make_notification_icon 36 android/app/src/main/res/drawable-hdpi/ic_stat_ditto.png
# Update background color
BACKGROUND_COLOR_FILE="android/app/src/main/res/values/ic_launcher_background.xml"
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 +203,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 \
@@ -156,11 +213,31 @@ else
echo -e " ${YELLOW}Skipped: $IOS_ICON_DIR not found${NC}"
fi
# ── iOS launch screen (Splash.imageset, 2732x2732, white bg + small mark) ──
# Referenced by LaunchScreen.storyboard. Ships one image for all 3 scale
# slots (1x/2x/3x), matching how the project already had it set up.
echo "Generating iOS launch screen..."
IOS_SPLASH_DIR="ios/App/App/Assets.xcassets/Splash.imageset"
if [ -d "$IOS_SPLASH_DIR" ]; then
$MAGICK -size "2732x2732" "xc:white" \
\( "$LOGO_ORANGE" -resize "160x160" \) \
-gravity center -compose over -composite \
"$IOS_SPLASH_DIR/splash-2732x2732.png"
cp "$IOS_SPLASH_DIR/splash-2732x2732.png" "$IOS_SPLASH_DIR/splash-2732x2732-1.png"
cp "$IOS_SPLASH_DIR/splash-2732x2732.png" "$IOS_SPLASH_DIR/splash-2732x2732-2.png"
echo -e " ${GREEN}${NC} $IOS_SPLASH_DIR (3 files)"
else
echo -e " ${YELLOW}Skipped: $IOS_SPLASH_DIR not found${NC}"
fi
# Cleanup temp files
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)"
+41 -22
View File
@@ -15,9 +15,11 @@ import { SentryProvider } from "@/components/SentryProvider";
import { TooltipProvider } from "@/components/ui/tooltip";
import { useAppContext } from "@/hooks/useAppContext";
import { useNsecPasteGuard } from "@/hooks/useNsecPasteGuard";
import { useTor } from "@/hooks/useTor";
import type { AppConfig } from "@/contexts/AppContext";
import { NWCProvider } from "@/contexts/NWCContext";
import { AudioPlayerProvider } from "@/contexts/AudioPlayerContext";
import { OnboardingProvider } from "@/contexts/OnboardingProvider";
import { BuildConfigSchema, type BuildConfig } from "@/lib/schemas";
import { secureStorage } from "@/lib/secureStorage";
@@ -39,11 +41,11 @@ const queryClient = new QueryClient({
/** Hardcoded fallback values. Always provides every required field. */
const hardcodedConfig: AppConfig = {
appName: "Agora",
appId: "agora",
appName: "Eranos",
appId: "eranos",
shareOrigin: import.meta.env.VITE_SHARE_ORIGIN || undefined,
homePage: "campaigns",
client: "naddr1qvzqqqru7cpzq7q6z5ns2hm5c8msyv83qwzxpxe52j8c4d4q5m92wsp9sflelkh9qqzkg6t5w3hswjl4yp",
client: "naddr1qvzqqqru7cpzq7q6z5ns2hm5c8msyv83qwzxpxe52j8c4d4q5m92wsp9sflelkh9qqzkzem0wfssdl264k",
theme: "system",
useAppRelays: true,
useUserRelays: false,
@@ -57,7 +59,6 @@ const hardcodedConfig: AppConfig = {
feedIncludeReposts: true,
feedIncludeGenericReposts: true,
feedIncludeReactions: false,
feedIncludeZaps: true,
feedIncludeArticles: true,
showArticles: true,
showHighlights: true,
@@ -117,7 +118,6 @@ const hardcodedConfig: AppConfig = {
"feed",
"communities",
"world",
"wallet",
"agent",
"messages",
"profile",
@@ -145,14 +145,8 @@ const hardcodedConfig: AppConfig = {
imageQuality: 'compressed',
imageProxy: 'https://wsrv.nl',
lowBandwidthMode: false,
torEnabled: false,
curatorPubkey: '932614571afcbad4d17a191ee281e39eebbb41b93fac8fd87829622aeb112f4d',
esploraApis: [
'https://mempool.emzy.de/api',
'https://mempool.space/api',
'https://blockstream.info/api',
],
blockbookBaseUrl: 'https://btc.trezor.io',
bip352IndexerUrl: 'https://silentpayments.dev/blindbit/mainnet',
sidebarWidgets: [
{ id: 'trends' },
{ id: 'hot-posts' },
@@ -163,6 +157,13 @@ const hardcodedConfig: AppConfig = {
aiModel: 'google/gemma-4-26b',
aiSystemPrompt: '',
translateWorkerUrl: import.meta.env.VITE_TRANSLATE_WORKER_URL || '',
// Grin payments (Plan 2, C1). The GoblinPay instance URL/token are
// deployment-specific and land via build config (APP_CONFIG) or env;
// empty disables the in-app GoblinPay path. The node is read-only
// (kernel lookups for the payment-proof tally).
goblinPayUrl: import.meta.env.VITE_GOBLINPAY_URL || '',
goblinPayApiToken: import.meta.env.VITE_GOBLINPAY_API_TOKEN || '',
grinNodeUrl: import.meta.env.VITE_GRIN_NODE_URL || 'https://api.grin.money',
};
/**
@@ -192,6 +193,24 @@ const defaultConfig: AppConfig = {
feedSettings: { ...hardcodedConfig.feedSettings, ...buildConfig.feedSettings },
};
/**
* Wraps NostrProvider with a key that changes when Tor routing changes, so the
* relay layer remounts: existing connections close and reopen under the new
* routing (direct ⇄ fail-closed Tor), and reconnect immediately once Tor is up
* rather than waiting out the relay reconnect backoff. No-op off Android (the
* key is always "direct").
*/
function RelayProvider({ children }: { children: React.ReactNode }) {
const { config } = useAppContext();
const { status } = useTor();
const key = !config.torEnabled
? "direct"
: status === "connected"
? "tor-connected"
: "tor-pending";
return <NostrProvider key={key}>{children}</NostrProvider>;
}
export function App() {
useNsecPasteGuard();
@@ -203,19 +222,19 @@ export function App() {
<PlausibleProvider>
<QueryClientProvider client={queryClient}>
<NostrLoginProvider storageKey="nostr:login" storage={secureStorage}>
<NostrProvider>
<RelayProvider>
<NostrSync />
<InitialSyncRunner />
<NativeNotifications />
<NWCProvider>
<OnboardingProvider>
<TooltipProvider>
<AppRouter />
</TooltipProvider>
</OnboardingProvider>
</NWCProvider>
</NostrProvider>
<OnboardingProvider>
<TooltipProvider>
<AudioPlayerProvider>
<AppRouter />
</AudioPlayerProvider>
</TooltipProvider>
</OnboardingProvider>
</RelayProvider>
</NostrLoginProvider>
</QueryClientProvider>
</PlausibleProvider>
+41 -32
View File
@@ -7,6 +7,9 @@ import { TopNav } from "./components/TopNav";
import { OnboardingGate } from "./components/OnboardingGate";
import { ScrollToTop } from "./components/ScrollToTop";
import { VersionCheck } from "./components/VersionCheck";
import { MinimizedAudioBar } from "./components/MinimizedAudioBar";
import { AudioNavigationGuard } from "./components/AudioNavigationGuard";
import { TorStatusBanner } from "./components/TorStatusBanner";
import { useCurrentUser } from "./hooks/useCurrentUser";
import { useProfileUrl } from "./hooks/useProfileUrl";
import { cn } from "@/lib/utils";
@@ -36,27 +39,24 @@ const CSAEPolicyPage = lazy(() => import("./pages/CSAEPolicyPage").then(m => ({
const ExternalContentPage = lazy(() => import("./pages/ExternalContentPage").then(m => ({ default: m.ExternalContentPage })));
const GeotagPage = lazy(() => import("./pages/GeotagPage").then(m => ({ default: m.GeotagPage })));
const HashtagPage = lazy(() => import("./pages/HashtagPage").then(m => ({ default: m.HashtagPage })));
const MessagesPage = lazy(() => import("./pages/MessagesPage").then(m => ({ default: m.MessagesPage })));
const MyDashboardPage = lazy(() => import("./pages/MyDashboardPage").then(m => ({ default: m.MyDashboardPage })));
const AboutPage = lazy(() => import("./pages/AboutPage").then(m => ({ default: m.AboutPage })));
const DonorGuidePage = lazy(() => import("./pages/DonorGuidePage").then(m => ({ default: m.DonorGuidePage })));
const ActivistGuidePage = lazy(() => import("./pages/ActivistGuidePage").then(m => ({ default: m.ActivistGuidePage })));
const RecipientGuidePage = lazy(() => import("./pages/RecipientGuidePage").then(m => ({ default: m.RecipientGuidePage })));
const CorporateSponsorshipPage = lazy(() => import("./pages/CorporateSponsorshipPage").then(m => ({ default: m.CorporateSponsorshipPage })));
const LanguageSettingsPage = lazy(() => import("./pages/LanguageSettingsPage").then(m => ({ default: m.LanguageSettingsPage })));
const NetworkSettingsPage = lazy(() => import("./pages/NetworkSettingsPage").then(m => ({ default: m.NetworkSettingsPage })));
const NIP19Page = lazy(() => import("./pages/NIP19Page").then(m => ({ default: m.NIP19Page })));
const NotificationSettings = lazy(() => import("./pages/NotificationSettings").then(m => ({ default: m.NotificationSettings })));
const NotificationsPage = lazy(() => import("./pages/NotificationsPage").then(m => ({ default: m.NotificationsPage })));
const OrganizationsPage = lazy(() => import("./pages/OrganizationsPage").then(m => ({ default: m.OrganizationsPage })));
const OrganizersPage = lazy(() => import("./pages/OrganizersPage").then(m => ({ default: m.OrganizersPage })));
const EventDashboardPage = lazy(() => import("./pages/EventDashboardPage").then(m => ({ default: m.EventDashboardPage })));
const PrivacyPolicyPage = lazy(() => import("./pages/PrivacyPolicyPage").then(m => ({ default: m.PrivacyPolicyPage })));
const ProfileSettings = lazy(() => import("./pages/ProfileSettings").then(m => ({ default: m.ProfileSettings })));
const SearchPage = lazy(() => import("./pages/SearchPage").then(m => ({ default: m.SearchPage })));
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 })));
const RemoteLoginSuccessPage = lazy(() => import("./pages/RemoteLoginSuccessPage").then(m => ({ default: m.RemoteLoginSuccessPage })));
/** Redirects /profile to the user's canonical profile URL (nip05 or npub). */
@@ -84,7 +84,7 @@ function PageSkeleton() {
function SiteFooter() {
const { t } = useTranslation();
return (
<footer className="bg-background mt-auto pt-12">
<footer className="bg-background mt-auto pt-6 sm:pt-12">
<div className="mx-auto max-w-7xl px-4 sm:px-6 py-8 flex flex-col sm:flex-row items-center justify-between gap-4 text-xs text-muted-foreground">
<button
type="button"
@@ -95,6 +95,8 @@ function SiteFooter() {
</button>
<nav className="flex items-center gap-5">
<Link to="/about" className="hover:text-foreground motion-safe:transition-colors">{t('nav.about')}</Link>
<Link to="/sponsors" className="hover:text-foreground motion-safe:transition-colors">{t('nav.sponsors')}</Link>
<Link to="/verify" className="hover:text-foreground motion-safe:transition-colors">{t('nav.verify')}</Link>
<Link to="/privacy" className="hover:text-foreground motion-safe:transition-colors">{t('nav.privacy')}</Link>
<Link to="/safety" className="hover:text-foreground motion-safe:transition-colors">{t('nav.safety')}</Link>
<Link to="/changelog" className="hover:text-foreground motion-safe:transition-colors">{t('nav.changelog')}</Link>
@@ -111,18 +113,18 @@ function SiteFooter() {
* form/prose-style pages, wide (full width) for landing / dashboard / detail
* pages that render their own internal layout.
*/
function FundraiserLayout({ narrow }: { narrow: boolean }) {
function FundraiserLayout({ narrow, hideFooter }: { narrow: boolean; hideFooter?: boolean }) {
return (
<div className="min-h-dvh flex flex-col bg-background">
<div className={cn('flex flex-col bg-background', hideFooter ? 'h-dvh overflow-hidden' : 'min-h-dvh')}>
<TopNav />
<Suspense fallback={<PageSkeleton />}>
<div
className={cn("flex-1 min-w-0 w-full mx-auto", narrow && "max-w-3xl")}
className={cn('min-w-0 w-full flex-1 mx-auto', hideFooter && 'min-h-0', narrow && 'max-w-3xl')}
>
<Outlet />
</div>
</Suspense>
<SiteFooter />
{!hideFooter && <SiteFooter />}
</div>
);
}
@@ -133,6 +135,11 @@ export function AppRouter() {
<Toaster />
<VersionCheck />
<ScrollToTop />
<AudioNavigationGuard />
<MinimizedAudioBar />
{/* App-wide Tor status banner. Must live inside BrowserRouter — it
renders a <Link> to the Tor settings, which needs Router context. */}
<TorStatusBanner />
<OnboardingGate>
<Routes>
{/* Narrow layout — `max-w-3xl` center column. The default for
@@ -149,40 +156,31 @@ export function AppRouter() {
<Route path="/settings/appearance" element={<AppearanceSettingsPage />} />
<Route path="/settings/language" element={<LanguageSettingsPage />} />
<Route path="/settings/profile" element={<ProfileSettings />} />
<Route path="/settings/wallet" element={<WalletSettingsPage />} />
<Route path="/settings/notifications" element={<NotificationSettings />} />
<Route path="/settings/advanced" element={<AdvancedSettingsPage />} />
<Route path="/settings/network" element={<NetworkSettingsPage />} />
<Route path="/wallet" element={<WalletPage />} />
<Route path="/wallet/legacy" element={<LegacyWalletRecoveryPage />} />
{/* Old nested paths kept as redirects so any existing links / muscle
memory still land on the right page. `/wallet/settings` was an
intermediate hub that has been replaced by an overflow menu on
`/wallet`, so it redirects to the wallet home. `/wallet/backup`
is now an in-page dialog opened from that menu, so it also
redirects home. */}
<Route path="/wallet/settings" element={<Navigate to="/wallet" replace />} />
<Route path="/wallet/backup" element={<Navigate to="/wallet" replace />} />
<Route path="/wallet/settings/backup" element={<Navigate to="/wallet" replace />} />
<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
under the wide layout below. */}
<Route path="/help" element={<Navigate to="/about" replace />} />
<Route path="/help/donors" element={<Navigate to="/about/donors" replace />} />
<Route path="/help/activists" element={<Navigate to="/about/activists" replace />} />
<Route path="/help/activists" element={<Navigate to="/about/recipients" replace />} />
<Route path="/help/recipients" element={<Navigate to="/about/recipients" replace />} />
<Route path="/privacy" element={<PrivacyPolicyPage />} />
<Route path="/safety" element={<CSAEPolicyPage />} />
<Route path="/changelog" element={<ChangelogPage />} />
<Route path="/organizers" element={<OrganizersPage />} />
{/* `/settings/verifier` moved to the public `/verify` onboarding
page. Keep the old path as a redirect so existing links resolve. */}
<Route path="/settings/verifier" element={<Navigate to="/verify" replace />} />
{/* Callback target for remote signers (e.g. Amber, Primal) after NIP-46 approval */}
<Route path="/remoteloginsuccess" element={<RemoteLoginSuccessPage />} />
</Route>
<Route element={<FundraiserLayout narrow={false} hideFooter />}>
<Route path="/messages" element={<MessagesPage />} />
</Route>
{/* Wide layout — no max-width on the center column. Used by landing /
list / detail pages that render their own internal width
constraints. */}
@@ -202,11 +200,22 @@ export function AppRouter() {
<Route path="/pledges/new" element={<CreateActionPage />} />
<Route path="/dashboard" element={<EventDashboardPage />} />
<Route path="/i/*" element={<ExternalContentPage />} />
{/* About page + Donor / Activist guides. Full-bleed landing-style
{/* About page + Donor / Recipient guides. Full-bleed landing-style
layouts that render their own internal max-widths. */}
<Route path="/about" element={<AboutPage />} />
<Route path="/about/donors" element={<DonorGuidePage />} />
<Route path="/about/activists" element={<ActivistGuidePage />} />
<Route path="/about/recipients" element={<RecipientGuidePage />} />
{/* Corporate sponsorship / partnership marketing page. Wide layout
so the hero and section backgrounds span the viewport like /about. */}
<Route path="/sponsors" element={<CorporateSponsorshipPage />} />
{/* Verification onboarding / marketing page. Wide layout so the
hero and section backgrounds can span the viewport like /about. */}
<Route path="/verify" element={<OrganizationsPage />} />
<Route path="/organizations" element={<Navigate to="/verify" replace />} />
{/* Legacy URL: the recipient guide lived at `/about/activists`
before the "activist" → "recipient" copy change. Redirect so
external links and bookmarks still resolve. */}
<Route path="/about/activists" element={<Navigate to="/about/recipients" replace />} />
{/* NIP-19 route for npub1, note1, naddr1, nevent1, nprofile1.
Goes through the wide layout because the dispatch may resolve to
a profile, campaign, action, or community page — all of which
+2 -5
View File
@@ -5,11 +5,10 @@ import type { NostrEvent } from '@nostrify/nostrify';
import { Camera, Clock, DollarSign, Info, Megaphone, Palette } from 'lucide-react';
import { parseAction, type Action } from '@/hooks/useActions';
import { useBtcPrice } from '@/hooks/useBtcPrice';
import { countryCodeToFlag, getGeoDisplayName } from '@/lib/countries';
import { CountryFlag } from '@/components/CountryFlag';
import { DEFAULT_COVER_IMAGE } from '@/lib/defaultActionCovers';
import { formatSats, satsToUSDWhole } from '@/lib/bitcoin';
import { formatPledgeAmount } from '@/lib/pledges';
import { cn } from '@/lib/utils';
const ACTION_ICONS = {
@@ -28,7 +27,6 @@ function actionNaddr(action: Action): string {
}
export function ActionContent({ event, compact = true }: { event: NostrEvent; compact?: boolean }) {
const { data: btcPrice } = useBtcPrice();
const action = parseAction(event);
if (!action) return null;
@@ -98,9 +96,8 @@ export function ActionContent({ event, compact = true }: { event: NostrEvent; co
<div className="flex items-center gap-2 text-sm">
<DollarSign className="size-4 shrink-0 text-primary" />
<span className="font-semibold">
{btcPrice ? satsToUSDWhole(action.bounty, btcPrice) : `${formatSats(action.bounty)} sats`}
{formatPledgeAmount(action.bounty)}
</span>
{btcPrice && <span className="text-xs text-muted-foreground">~{formatSats(action.bounty)} sats</span>}
{action.countryCode && (
<>
<span className="text-muted-foreground/50">·</span>
+16 -39
View File
@@ -1,18 +1,16 @@
import type { NostrEvent } from '@nostrify/nostrify';
import type { ReactNode } from 'react';
import { CalendarClock, HandHeart, MapPin, Megaphone, ShieldCheck, Users } from 'lucide-react';
import { CalendarClock, HandHeart, MapPin, Megaphone, Users } from 'lucide-react';
import { nip19 } from 'nostr-tools';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useBtcPrice } from '@/hooks/useBtcPrice';
import { useCampaignDonations } from '@/hooks/useCampaignDonations';
import { useAuthor } from '@/hooks/useAuthor';
import { parseAction } from '@/hooks/useActions';
import { getGeoDisplayName } from '@/lib/countries';
import { parseCampaign, getCampaignCountryLabel } from '@/lib/campaign';
import { parseCommunityEvent } from '@/lib/communityUtils';
import { formatCampaignAmount, formatUsdGoal, satsToUsd } from '@/lib/formatCampaignAmount';
import { formatUsdGoal } from '@/lib/formatCampaignAmount';
import { formatCompactPledgeDeadline, formatPledgeAmount } from '@/lib/pledges';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import { cn } from '@/lib/utils';
@@ -53,9 +51,8 @@ function InlineShell({
}
export function CampaignInlinePreview({ event }: { event: NostrEvent }) {
const { t } = useTranslation();
const campaign = parseCampaign(event);
const { data: btcPrice } = useBtcPrice();
const { data: stats } = useCampaignDonations(campaign ?? undefined);
const author = useAuthor(event.pubkey);
if (!campaign) return null;
@@ -65,14 +62,9 @@ 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 isSilentPayment = !campaign.wallets.onchain;
const goalLabel = campaign.goalUsd && campaign.goalUsd > 0 ? formatUsdGoal(campaign.goalUsd) : undefined;
const raisedSats = stats?.totalSats ?? 0;
const raisedLabel = isSilentPayment ? undefined : formatCampaignAmount(raisedSats, btcPrice);
const raisedUsd = isSilentPayment ? undefined : satsToUsd(raisedSats, btcPrice);
const progress = campaign.goalUsd && raisedUsd !== undefined
? Math.min(100, Math.round((raisedUsd / campaign.goalUsd) * 100))
: 0;
const goalLabel = campaign.goalUsd && campaign.goalUsd > 0
? t('campaignsDetail.target', { amount: formatUsdGoal(campaign.goalUsd) })
: undefined;
return (
<Link to={`/${naddr}`} onClick={(e) => e.stopPropagation()} className="block rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background">
@@ -82,29 +74,15 @@ export function CampaignInlinePreview({ event }: { event: NostrEvent }) {
title={campaign.title}
description={campaign.story}
meta={(
<div className="space-y-2 pt-1">
{campaign.goalUsd && !isSilentPayment ? (
<div className="h-1.5 overflow-hidden rounded-full bg-foreground/15">
<div className="h-full rounded-full bg-primary" style={{ width: `${progress}%` }} />
</div>
) : null}
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5 text-xs text-muted-foreground">
{isSilentPayment ? (
<span className="inline-flex items-center gap-1.5">
<ShieldCheck className="size-3.5" />
{goalLabel ?? 'Private campaign'}
</span>
) : (
<span className="font-semibold text-foreground">
{raisedLabel}<span className="font-normal text-muted-foreground"> {goalLabel ? `/ ${goalLabel}` : 'raised'}</span>
</span>
)}
{countryLabel && (
<span className="inline-flex items-center gap-1.5">
{countryLabel}
</span>
)}
</div>
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5 pt-1 text-xs text-muted-foreground">
{goalLabel && (
<span className="font-semibold text-foreground">{goalLabel}</span>
)}
{countryLabel && (
<span className="inline-flex items-center gap-1.5">
{countryLabel}
</span>
)}
</div>
)}
/>
@@ -115,7 +93,6 @@ export function CampaignInlinePreview({ event }: { event: NostrEvent }) {
export function PledgeInlinePreview({ event }: { event: NostrEvent }) {
const { t } = useTranslation();
const pledge = parseAction(event);
const { data: btcPrice } = useBtcPrice();
if (!pledge) return null;
const naddr = nip19.naddrEncode({ kind: 36639, pubkey: pledge.pubkey, identifier: pledge.id });
@@ -133,7 +110,7 @@ export function PledgeInlinePreview({ event }: { event: NostrEvent }) {
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5 pt-1 text-xs text-muted-foreground">
<span className="inline-flex items-baseline gap-1.5">
<span className="font-semibold uppercase tracking-wide text-primary">{t('pledges.card.pledged')}</span>
<span className="text-sm font-bold text-foreground">{formatPledgeAmount(pledge.bounty, btcPrice)}</span>
<span className="text-sm font-bold text-foreground">{formatPledgeAmount(pledge.bounty)}</span>
</span>
{countryLabel && (
<span className="inline-flex items-center gap-1.5">
+50
View File
@@ -0,0 +1,50 @@
import { Capacitor } from '@capacitor/core';
import { useTranslation } from 'react-i18next';
import { ArrowRight } from 'lucide-react';
import { useAppContext } from '@/hooks/useAppContext';
import { ZAPSTORE_URL } from '@/lib/zapstore';
import { cn } from '@/lib/utils';
/**
* Zapstore download nudge — prompts mobile-web visitors to install the native
* Android app. Hidden inside the native app (you're already in it) and on
* desktop (`sm:hidden`), where downloading works differently.
*/
export function AppDownloadNudge({ className }: { className?: string }) {
const { t } = useTranslation();
const { config } = useAppContext();
if (Capacitor.isNativePlatform()) return null;
return (
<div className={cn('sm:hidden px-4 pt-8 pb-4', className)}>
<p className="text-xs font-semibold uppercase tracking-widest text-muted-foreground mb-3">
{t('feed.getApp.eyebrow')}
</p>
<div className="flex items-center gap-3">
<img
src="/logo.png"
alt={config.appName}
className="h-10 w-10 shrink-0 rounded-xl"
/>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-foreground">
{t('feed.getApp.title', { appName: config.appName })}
</p>
<p className="text-xs text-muted-foreground">
{t('feed.getApp.subtitle', { appName: config.appName })}
</p>
</div>
<a
href={ZAPSTORE_URL}
target="_blank"
rel="noopener noreferrer"
className="shrink-0 inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-primary/10 hover:bg-primary/20 text-primary text-xs font-medium transition-colors"
>
{t('feed.getApp.download')}
<ArrowRight className="h-3 w-3" />
</a>
</div>
</div>
);
}
+25
View File
@@ -0,0 +1,25 @@
import { useEffect, useRef } from 'react';
import { useLocation } from 'react-router-dom';
import { useAudioPlayer } from '@/contexts/audioPlayerContextDef';
/**
* Auto-minimizes the audio player when the user navigates to a different page.
* No dialog — audio just keeps playing in the floating mini-bar.
*/
export function AudioNavigationGuard() {
const { currentTrack, minimized, minimize } = useAudioPlayer();
const location = useLocation();
const prevPath = useRef(location.pathname);
useEffect(() => {
if (location.pathname !== prevPath.current) {
// Route changed — minimize if playing and expanded
if (currentTrack && !minimized) {
minimize();
}
prevPath.current = location.pathname;
}
}, [location.pathname, currentTrack, minimized, minimize]);
return null;
}
-149
View File
@@ -1,149 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import { cn } from '@/lib/utils';
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;
}
export function BitcoinAmountPicker({
usdAmount,
onUsdAmountChange,
presets,
maxLabel = 'MAX',
maxSelected = false,
maxDisabled = false,
onMaxSelect,
insufficient = false,
satsLabel,
onAmountChangeStart,
}: BitcoinAmountPickerProps) {
const [editingAmount, setEditingAmount] = useState(false);
const amountInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (editingAmount) {
amountInputRef.current?.focus();
amountInputRef.current?.select();
}
}, [editingAmount]);
const commitAmountEdit = useCallback(() => {
setEditingAmount(false);
if (typeof usdAmount === 'string' && usdAmount.trim() === '') {
onUsdAmountChange(0);
}
}, [onUsdAmountChange, usdAmount]);
const currentUsd = typeof usdAmount === 'string' ? parseFloat(usdAmount) : usdAmount;
return (
<>
<div className="flex flex-col items-center pt-2">
{editingAmount ? (
<div className="flex items-baseline justify-center">
<span className={cn('text-4xl font-semibold', insufficient ? 'text-destructive' : 'text-muted-foreground')}>$</span>
<input
ref={amountInputRef}
type="number"
inputMode="decimal"
min={0}
step="0.01"
value={usdAmount}
onChange={(e) => {
onAmountChangeStart?.();
onUsdAmountChange(e.target.value);
}}
onBlur={commitAmountEdit}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
commitAmountEdit();
}
}}
aria-label="Amount in USD"
className={cn(
'bg-transparent border-0 outline-none text-4xl font-semibold text-center [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none',
insufficient && 'text-destructive',
)}
style={{ width: `${Math.max(2, String(usdAmount).length + 1)}ch` }}
/>
</div>
) : (
<button
type="button"
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"
>
{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 && (
<span className="text-xs text-muted-foreground mt-1 tabular-nums">
{satsLabel}
</span>
)}
</div>
<ToggleGroup
type="single"
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);
}
}}
className="grid grid-cols-5 gap-1 w-full"
>
{presets.map((preset) => (
<ToggleGroupItem
key={preset}
value={String(preset)}
className="h-8 min-w-0 text-xs font-semibold px-1"
>
${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
@@ -1,641 +0,0 @@
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>
);
}
-136
View File
@@ -1,136 +0,0 @@
import type { ReactNode } from 'react';
import { AlertTriangle } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Checkbox } from '@/components/ui/checkbox';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { cn } from '@/lib/utils';
/**
* - `destructive`: red, with a warning icon. Used in high-stakes contexts
* like the wallet's Send dialog where the disclaimer also gates an
* acknowledgement checkbox.
* - `soft`: amber, no icon. Used as an informational notice in lower-stakes
* contexts (e.g. campaign donation surfaces) where we don't want to
* imply the donor is about to do something dangerous.
*/
type Tone = 'destructive' | 'soft';
interface BitcoinPublicDisclaimerProps {
/**
* When provided, render an "I understand this transaction is public"
* acknowledgement checkbox below the warning. Callers should typically
* gate the primary action (Send / Donate / Review / Open in wallet) on
* `acknowledged === true`. When omitted, the disclaimer renders as an
* informational notice with no interactive control.
*/
acknowledged?: boolean;
onAcknowledgedChange?: (acknowledged: boolean) => void;
/** Optional override for the lead sentence (e.g. "Donations" instead of "Money"). */
leadText?: string;
/** Visual treatment. Defaults to `destructive` for backwards compatibility with the wallet's Send dialog. */
tone?: Tone;
/**
* Whether the "Learn more" popover should include the
* "or cash out at an exchange" advice. Relevant in the wallet (the
* user holds Bitcoin and could cash out) but not on a campaign page
* (the donor is sending money away, not deciding what to do with it).
* Defaults to `true` for backwards compatibility.
*/
includeCashOutAdvice?: boolean;
/**
* Override the popover body. When set, replaces the entire "Bitcoin
* is a public ledger…" paragraph (including the cash-out advice). Use
* when the calling surface has a meaningfully different audience —
* e.g. a campaign *creator* configuring a receive address, vs. the
* sender flow this component was originally written for.
*/
popoverText?: ReactNode;
}
/**
* Privacy disclaimer for on-chain Bitcoin payments. Bitcoin is a public
* ledger and the transaction can be traced back to the sender forever.
* Used wherever the user initiates an on-chain payment — wallet sends to
* raw addresses, campaign donations (BIP-21 panels, in-app PSBT
* donations, external-wallet fallbacks).
*/
export function BitcoinPublicDisclaimer({
acknowledged,
onAcknowledgedChange,
leadText,
tone = 'destructive',
includeCashOutAdvice = true,
popoverText,
}: BitcoinPublicDisclaimerProps) {
const { t } = useTranslation();
const showCheckbox = onAcknowledgedChange !== undefined;
const isSoft = tone === 'soft';
const resolvedLeadText = leadText ?? t('bitcoinPublic.lead');
return (
<Alert
// For `soft` we drop the role="alert" semantics — it's informational,
// not an active warning the user must respond to.
role={isSoft ? 'note' : 'alert'}
className={cn(
isSoft
// Use the project's foreground token (not raw amber-900) so
// the text always contrasts against the page in both light
// and dark themes. The faint amber tint keeps the
// "informational notice" cue without leaning on hard-coded
// amber text that disappears on the wrong backdrop.
? 'border-amber-500/30 bg-amber-500/10 text-foreground'
: 'border-destructive/50 bg-destructive/5 text-destructive dark:border-destructive',
)}
>
{/* Icon only on the destructive variant. The shadcn Alert reserves
left padding for an icon via `[&>svg~*]:pl-7`, so omitting the
icon also reclaims the indent. */}
{!isSoft && <AlertTriangle className="size-4 text-destructive" />}
<AlertDescription className="text-xs">
<p>
{resolvedLeadText}{' '}
<Popover>
<PopoverTrigger asChild>
<button
type="button"
className="underline underline-offset-2 font-medium hover:opacity-80 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-sm"
>
{t('bitcoinPublic.learnMore')}
</button>
</PopoverTrigger>
<PopoverContent side="top" align="start" className="w-72 text-xs leading-relaxed">
{popoverText ?? (
includeCashOutAdvice
? t('bitcoinPublic.bodyWithCashOut')
: t('bitcoinPublic.body')
)}
</PopoverContent>
</Popover>
</p>
{showCheckbox && (
<label className="mt-2 flex items-start gap-2 cursor-pointer select-none">
<Checkbox
checked={acknowledged ?? false}
onCheckedChange={(checked) => onAcknowledgedChange(checked === true)}
className={cn(
'mt-0.5',
isSoft
? 'border-amber-600 data-[state=checked]:bg-amber-600 data-[state=checked]:text-white dark:border-amber-400 dark:data-[state=checked]:bg-amber-500'
: 'border-destructive data-[state=checked]:bg-destructive data-[state=checked]:text-destructive-foreground',
)}
aria-label={t('bitcoinPublic.iUnderstand')}
/>
<span>{t('bitcoinPublic.iUnderstand')}</span>
</label>
)}
</AlertDescription>
</Alert>
);
}
-610
View File
@@ -1,610 +0,0 @@
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 { Input } from '@/components/ui/input';
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 {
/** 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;
/**
* 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;
}
/**
* 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();
// 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);
// ── 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 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 (trimmed.length === 0) {
setOpen(false);
setHasOpenedForQuery(false);
return;
}
if (hasSp || hasBtc) setOpen(true);
}, [trimmed, hasSp, hasBtc]);
// 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={popoverOpen} onOpenChange={setOpen}>
<PopoverAnchor asChild>
<div className="relative flex items-center">
<Input
ref={inputRef}
id="hd-recipient-input"
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 (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}
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>
</PopoverAnchor>
<PopoverContent
align="start"
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()}
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"
>
<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>
{/* 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>
);
}
// ---------------------------------------------------------------------------
// Dropdown rows
// ---------------------------------------------------------------------------
/** 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 BtcAddressRow({
address,
onClick,
}: {
address: string;
onClick: (address: string) => void;
}) {
const { t } = useTranslation();
return (
<button
type="button"
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"
>
<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">
<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
@@ -1,197 +0,0 @@
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>
);
}
@@ -504,7 +504,6 @@ export function CalendarEventDetailPage({ event }: { event: NostrEvent }) {
<PostActionBar
event={event}
replyLabel={t('calendarEvents.detail.comment')}
hideZap
showShareInSidebar
onReply={() => setReplyOpen(true)}
onMore={() => setMoreMenuOpen(true)}
+33 -112
View File
@@ -1,17 +1,15 @@
import { useMemo } from 'react';
import type { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { HandHeart, ShieldCheck } from 'lucide-react';
import { HandHeart, Target } from 'lucide-react';
import { AuthorByline } from '@/components/AuthorByline';
import { CampaignVerificationBadge } from '@/components/CampaignVerificationBadge';
import { Card } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
import { Skeleton } from '@/components/ui/skeleton';
import { ModerationOverlay } from '@/components/moderation';
import { useAuthor } from '@/hooks/useAuthor';
import { useBtcPrice } from '@/hooks/useBtcPrice';
import { useCampaignDonations } from '@/hooks/useCampaignDonations';
import { useEventTranslation } from '@/hooks/useEventTranslation';
import {
type ParsedCampaign,
@@ -19,107 +17,31 @@ import {
getCampaignCountryLabel,
parseCampaign,
} from '@/lib/campaign';
import { formatCampaignAmount, formatUsdGoal, satsToUsd } from '@/lib/formatCampaignAmount';
import { formatUsdGoal } from '@/lib/formatCampaignAmount';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import { cn } from '@/lib/utils';
/**
* Short helper rendered both inline (cards) and in the detail page.
*
* Per NIP.md Kind 33863, the campaign **goal** is integer USD and the
* **raised** total is the sum of verified sats. We render both in the
* goal's unit (USD) for consistency, converting the sats total at view
* time using the live BTC price. While the price is loading the raised
* amount falls back to sats.
* Goal row rendered both inline (cards) and in the detail page. Shows the
* campaign goal as a target (integer USD per NIP.md Kind 33863). The
* raised-so-far tally returns with the Grin payment-proof tally in a later
* phase; the invisible bar keeps every card's vertical footprint identical
* in the meantime.
*/
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
? Math.min(100, Math.round((raisedUsd / goalUsd!) * 100))
: 0;
// Always reserve a bar row so cards with and without a goal occupy
// the same vertical space. The bar is rendered invisibly when
// there's no goal — same height, no visual weight.
//
// The primitive's default `bg-secondary` track is too close to the
// card surface in both light and dark modes (in dark mode they're
// both `0 0% 18%`, making the empty portion of the bar invisible).
// `bg-foreground/15` overrides it with a foreground-tinted track
// that has real contrast against the card in either theme.
return (
<div className={cn('space-y-1.5', className)}>
<Progress
value={pct}
className={cn('h-2 bg-foreground/15', !hasGoal && 'invisible')}
aria-hidden={!hasGoal}
/>
<div className="flex items-baseline justify-between gap-2 text-sm">
<span className="font-semibold">
{formatCampaignAmount(raisedSats, btcPrice)}
{!hasGoal && <span className="ml-1 font-normal text-muted-foreground">raised</span>}
</span>
{hasGoal && (
<span className="text-muted-foreground">of {formatUsdGoal(goalUsd!)} goal</span>
)}
</div>
</div>
);
}
/**
* Replaces {@link CampaignProgress} for silent-payment campaigns, where
* on-chain totals are unobservable by design. Shows the goal as a target
* (if set) but no progress bar or raised amount.
*/
function CampaignPrivateNotice({
function CampaignGoalRow({
goalUsd,
className,
}: {
goalUsd?: number;
className?: string;
}) {
// Mirrors CampaignProgress's vertical footprint (invisible bar + one
// text row) so a silent-payment card lines up visually with a
// public-progress card alongside it.
return (
<div className={cn('space-y-1.5', className)}>
<Progress value={0} className="h-2 invisible" aria-hidden />
<div className="flex items-baseline justify-between gap-2 text-sm">
<span className="inline-flex items-center gap-1.5 text-muted-foreground">
<ShieldCheck className="size-3.5" />
Private campaign
<Target className="size-3.5" />
Fundraiser
</span>
{goalUsd && goalUsd > 0 && (
<span className="text-muted-foreground">Target: {formatUsdGoal(goalUsd)}</span>
@@ -146,33 +68,35 @@ interface CampaignCardProps {
className?: string;
/** Optional footer affordance rendered opposite the author line. */
footerBadge?: ReactNode;
/**
* When false, the moderator kebab inside the card overlay is suppressed.
* The "Hidden" badge still renders so mods can see hide state, but the
* menu trigger is omitted. Pass false when the card is wrapped by a
* parent (e.g. `ListMemberCard`) that already provides its own combined
* kebab containing the moderation actions — avoids two overlapping menus
* in the same top-right corner.
*/
showModerationMenu?: boolean;
}
/**
* Renders a single campaign as a clickable card. The whole card is a
* `<Link>` to the campaign's naddr-based detail route.
*/
export function CampaignCard({ campaign, variant = 'compact', className, footerBadge }: CampaignCardProps) {
const { t } = useTranslation();
export function CampaignCard({ campaign, variant = 'compact', className, footerBadge, showModerationMenu = true }: CampaignCardProps) {
const { translatedEvent, translateAction } = useEventTranslation(campaign.event, {
iconOnly: true,
buttonClassName: 'size-8 rounded-full p-0 text-muted-foreground hover:text-primary hover:bg-primary/10',
});
const displayCampaign = parseCampaign(translatedEvent) ?? campaign;
const author = useAuthor(campaign.pubkey);
const { data: stats, isLoading: donationsLoading } = useCampaignDonations(campaign);
const { data: btcPrice } = useBtcPrice();
const naddr = useMemo(() => encodeCampaignNaddr(campaign), [campaign]);
const authorMetadata = author.data?.metadata;
const cover = sanitizeUrl(displayCampaign.banner)
?? sanitizeUrl(authorMetadata?.banner)
?? sanitizeUrl(authorMetadata?.picture);
const raisedSats = stats?.totalSats ?? 0;
const countryLabel = getCampaignCountryLabel(campaign);
// SP-only campaigns hide aggregate totals; dual-endpoint campaigns
// show on-chain aggregates per spec.
const isSilentPayment = !campaign.wallets.onchain;
const isFeaturedVariant = variant === 'featured';
const isShelfVariant = variant === 'shelf';
@@ -231,6 +155,16 @@ 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"
/>
{/* Top-left verification badge — stacked moderator avatars for
campaigns a moderator has verified. Renders nothing for
unverified campaigns. Display-only; the verify action lives in
the moderation kebab. */}
<CampaignVerificationBadge
coord={campaign.aTag}
title={campaign.title}
className="absolute top-3 left-3 z-10"
/>
{/* 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)]">
@@ -248,6 +182,7 @@ export function CampaignCard({ campaign, variant = 'compact', className, footerB
surface="campaign"
axes={['hide']}
badgeSize="default"
showMenu={showModerationMenu}
className="absolute top-3 right-3 z-10 flex items-center gap-2"
/>
</div>
@@ -280,25 +215,11 @@ export function CampaignCard({ campaign, variant = 'compact', className, footerB
</p>
</div>
{isSilentPayment ? (
<CampaignPrivateNotice goalUsd={campaign.goalUsd} />
) : (
<CampaignProgress
raisedSats={raisedSats}
goalUsd={campaign.goalUsd}
btcPrice={btcPrice}
isLoading={donationsLoading}
/>
)}
<CampaignGoalRow goalUsd={campaign.goalUsd} />
<div className="flex items-center justify-between gap-3 border-t border-border/60 pt-3 text-xs text-muted-foreground">
<div className="flex min-w-0 items-center gap-2">
<AuthorByline pubkey={campaign.pubkey} insideLink />
{!isSilentPayment && stats && stats.donorCount > 0 && (
<span className="shrink-0 text-muted-foreground/80">
· {t('common.donors', { count: stats.donorCount })}
</span>
)}
</div>
{(footerBadge || translateAction) && (
<div className="flex shrink-0 items-center gap-1.5">

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