Commit Graph

4310 Commits

Author SHA1 Message Date
mkfain 5bbd86ea90 Cap WLC Verified row at 6; add chronological 'All campaigns' section
The WLC Verified hero row now shows at most 6 campaigns (the two
large hero cards on top plus a single 4-up row), regardless of how
many a moderator has featured. Anything beyond the cap is still
featured for moderation purposes — it just doesn't earn a hero slot.

Below the Verified row, a new 'All campaigns' section displays every
campaign in the home page's recent stream (the existing 200-event
window from useCampaigns(limit: 200)) minus anything currently
hidden, sorted newest-first. Featured campaigns are intentionally
NOT removed from this chronological feed; instead, each card whose
coord is in featuredCoordSet still picks up the WLC chip via
verifiedBy. So a verified campaign appears twice on the home page —
once as a hero, once in chronological order — and both placements
make the WLC endorsement visible.

The 'Browse all campaigns →' link moves from the Verified section
footer to the All Campaigns section footer, where it makes more
sense as a gateway to /campaigns' search and sort surface for the
full censorship-resistant set beyond the 200-event window.

Five locale strings touched in all 16 locales: featured,
featuredDesc, verifiedByAria (existing), allCampaigns,
allCampaignsDesc (new).
2026-06-01 19:07:19 +02:00
mkfain 65481d1280 Featured: two large hero cards on top, rest in 4-up rows
Restructure the home Featured row into a two-tier layout when there
are 3+ featured campaigns:

  - Top two: large 'hero' placement (full width on mobile/sm, half
    width side-by-side on lg+).
  - Rest: standard compact cards in rows of four on lg+, two on sm,
    one on mobile.

Implementation is a single CSS grid with conditional col-spans on
the first two children rather than two separate grids — that keeps
the existing ReorderableCampaignGrid intact, including drag-and-drop
between hero cards and tail cards, optimistic reorder, and the
moderator kebab actions.

ReorderableCampaignGrid gains an itemClassName(index) prop so the
caller can paint per-position wrapper classes onto each card slot
without the grid component knowing about hero layouts.

The renderCard signature now also receives the display index — not
used yet, but kept aligned with itemClassName for future use cases.

≤2 featured campaigns keep the original adaptive layout (no spans
needed), and the single-campaign hero variant is unchanged.
2026-06-01 18:56:21 +02:00
mkfain 3d4b40188e Show 'WLC' on the featured-card verifier chip
The chip on featured campaign cards previously rendered the full
'World Liberty Congress' name, which truncated awkwardly at our 140px
max-width on smaller covers. Add an optional shortLabel to the
verifiedBy prop so the chip can display a compact 'WLC' while the
aria-label and avatar fallback continue to use the full name.
2026-06-01 18:52:34 +02:00
mkfain a7f28e3963 Move check icon after 'Verified' and add WLC chip to featured cards
Two follow-up tweaks to the World Liberty Congress Verified row:

1. Heading reads '<avatar> World Liberty Congress  Verified <check>'
   instead of leading with the check icon, so the action verb sits
   next to the brand and the check is the visual punctuation.

2. CampaignCard gains an optional verifiedBy={{ pubkey, npub,
   defaultName }} prop. When set, it renders a translucent chip on
   the top-left of the cover art with the verifier's avatar (pulled
   from kind 0 metadata via useAuthor), name, and a BadgeCheck. The
   chip is itself a Link to the verifier's profile and stops click
   propagation so it doesn't trigger the outer campaign link.

   CampaignsPage threads { WLC_PUBKEY, WLC_NPUB } through FeaturedRow
   so every featured card on the home page picks up the WLC chip.

A new verifiedByAria locale key is added in all 16 locales for the
chip's aria-label.
2026-06-01 18:49:58 +02:00
mkfain d2b6785ca7 Rebrand home Featured row as 'World Liberty Congress Verified'
The home page Featured row is curated in partnership with the World
Liberty Congress, so the heading should make the source of trust
explicit. Replace the plain 'Featured' heading with:

  [avatar] World Liberty Congress [BadgeCheck] Verified

The brand name and avatar link to WLC's profile
(npub126e6hwd6a5std2upv9a22xwgvd8fyrhsx5wjjchv99g6nv3n4vhs5fr9g3),
the avatar is pulled live from kind 0 metadata via useAuthor, and a
lucide BadgeCheck conveys verification at a glance.

The localized 'featured' key now reads 'Verified' (and is translated
across all 16 locales); the brand name itself is intentionally not
translated. featuredDesc is rewritten to describe WLC verification
in every locale.
2026-06-01 18:42:48 +02:00
mkfain 2bab7ebe6e Drop featured pinning from /campaigns and append new features to the bottom of the row
Two related curation tweaks:

- /campaigns now renders every non-hidden campaign in pure
  reverse-chronological order. Featured-pinning on this shelf was
  redundant with the home page's dedicated Featured row and obscured
  newly-published campaigns. The active (search / sort / country)
  branch was already unpinned; this only changes the idle landing.

- Featuring a campaign as a moderator now publishes an explicit
  rank one below the smallest existing featured rank, so the new
  card lands at the **bottom** of the Featured row instead of
  jumping the queue. The display sort stays descending by rank, so
  the existing Move-to-top / Move-up controls still work for
  moderators who want to promote a fresh feature.

The pledge and organization moderation surfaces keep the legacy
created_at-fallback behavior — the bottom-append rank is computed in
CampaignItemsInner and threaded through the shared shell only for the
campaign branch.
2026-05-31 15:38:31 +02:00
Chad Curtis e6fc7931b6 onboarding: campaign-framed profile heading for new campaign creators 2026-05-30 21:12:19 -05:00
Chad Curtis c845f7286b onboarding: hide bio behind Advanced toggle; show profile step first for new campaign creators 2026-05-30 20:14:56 -05:00
mkfain b0759402cf Retire the approval axis; Featured becomes the sole positive-curation mechanism
Now that moderators can directly order the Featured row, the second
"Community Campaigns" bucket (approved + not-featured + not-hidden)
is redundant. This commit removes the approval axis end-to-end and
collapses the home page to a single curated section.

Protocol (NIP.md):

- `ModerationLabel` shrinks from six values to four — `hidden`,
  `unhidden`, `featured`, `unfeatured`. The legacy `approved` /
  `unapproved` labels are now ignored on read and MUST NOT be
  published.
- `ModerationAxis` shrinks from three to two: `hide` and `featured`,
  both supported by all three surfaces (campaigns, organizations,
  pledges).
- The rank tag now only applies to `featured` labels.
- A migration note in NIP.md explains the retirement and tells
  clients to ignore lingering approval-axis labels in relay
  archives.

UI:

- CampaignsPage drops the Community Campaigns and Pending sections.
  Home is now Featured (with the empty state in place when nothing
  is featured) → Browse-all link → moderator-only Hidden section.
  The labeled-coord targeted fetch shrinks to hidden coords only.
- ModerationMenu loses the Approve / Unapprove rows and the
  `hasApproval` / `isApproved` plumbing.
- `CampaignCard`'s `axes` prop drops `'approval'`.
- `ReorderAxis` collapses to a single axis — the type and the
  parameter are removed from the reorder hook, provider, context,
  and grid component since every reorder targets the featured axis.
- Pledge and organization moderation hooks lose their defensive
  `'approved' | 'unapproved'` rejection branches now that those
  values are off the `ModerationLabel` union.

i18n (16 locales):

- Five moderation.menu keys removed: `approve`, `unapprove`,
  `approvedState`, `toastApproved`, `toastUnapproved`.
- Five campaigns.home keys removed: `community`, `communityDesc`,
  `pending`, `pendingDesc`, `pendingEmpty`.
- `campaigns.home.yourCampaignsDesc` rewritten across every locale
  to drop the "appears on the homepage once a moderator approves"
  copy; new copy points authors at /campaigns for discovery and
  notes that the team curates a featured selection on the home page.

Test suite green: tsc, eslint, vitest, vite build all pass.
2026-05-30 22:48:21 +02:00
mkfain ef9c2eff89 Fix campaign reorders silently reverting when moving downward
The first reorder implementation encoded list position directly in
the moderation label's `created_at` and republished the same axis
label with a chosen timestamp. That fights the fold's
newest-event-per-(coord,axis) rule the moment a moderator tries to
lower a campaign's position: the new label has an older
`created_at` than the existing one and the fold rejects it. The
relay accepts the publish, but every subsequent read folds back to
the higher-`created_at` predecessor and the move appears to revert.

Move-up worked (its new `created_at` was strictly newer); move-down,
drag-down, and any drag-to-midpoint that landed below an existing
neighbor silently no-op'd. Anything dragged into the middle of an
already-old list also picked a past timestamp that some relays
reject for being too far behind "now".

The fix decouples sort key from event recency:

- Reorder publishes always use `created_at = now`, so the fold's
  newest-wins rule always picks them up.
- The chosen position is encoded as a `["rank", "<integer>"]`
  tag on the label.
- `foldModerationLabels` extracts the rank with a `created_at`
  fallback, so labels published before this change (and any normal
  approve / hide / feature actions that don't carry a rank) still
  sort by `created_at` exactly as they used to.

Ranks are sourced from `Date.now() * 1000` (microseconds since
epoch), so:

- Fresh "feature" / "approve" publishes always sit above legacy
  labels whose effective rank is a seconds-since-epoch value.
- Midpoint inserts have ~1000x headroom per second of inter-rank
  gap, comfortably enough for thousands of reorders before any
  renumbering would matter.
- Headroom against `Number.MAX_SAFE_INTEGER` is ~150 years.

Callers downstream (CampaignsPage, CampaignsDiscoverySection,
PledgesDiscoverySection, useFeaturedOrganizations) still consume
`featuredOrder` / `approvedOrder` as `Map<coord, number>` sorted
descending — the map names and shapes are unchanged, only the
value computation is now "rank ?? created_at" instead of
"created_at".

NIP.md updated to document the rank tag, the fallback semantics,
and the reorder operations in terms of ranks.
2026-05-30 22:22:09 +02:00
mkfain 2cde8fe1f8 Remove the 12-campaign cap on the home-page Featured row
Moderators can now feature any number of campaigns and the row
expands to fit. The cap was hardcoded as `MAX_FEATURED = 12` and
applied at three points (the sort+slice on coords, the
`useCampaigns` limit, and the sort+slice on the ordered list);
all three are gone.

`useCampaigns` already ignores its `limit` when `coordinates` is
set (it fans out into one per-author `#d` filter), so the relay
request was never actually capped — only the rendered slice was.
Dropping the slice is sufficient.

The skeleton placeholder still bounds itself at 8 cards so a
moderator who's featured 50+ campaigns doesn't get a screenful of
grey rectangles before the real cards land. The bound is renamed
`FEATURED_SKELETON_CAP` to make the intent obvious.
2026-05-30 22:13:46 +02:00
mkfain 9e26bb8209 Let moderators reorder Featured and Community campaign lists
The Featured row already sorted by the moderator's `featured` label
`created_at`, but reordering required clicking Unfeature then Feature
again — clumsy, and the Community grid sorted only by campaign
`created_at` with no moderator input at all.

This commit promotes the existing axis-label `created_at` into a
first-class sort key on both lists and adds drag-and-drop + kebab-row
UI for moderators.

Protocol (no schema change):

- The Featured row sorts by the `featured` label's `created_at`,
  newest first (existing behavior).
- The Community grid now sorts by the `approved` label's
  `created_at`, newest first (mirroring the Featured row).
- Reordering = republishing the same axis label for the moved
  campaign with a chosen `created_at`. Move-to-top stamps `now`;
  move-up stamps `neighborAbove.t + 1`; move-down stamps
  `neighborBelow.t - 1`. Drag-to-position picks a value between the
  two new neighbors.
- No new tags, no new kinds, no new authority — readers that already
  understand the moderation namespace pick up the order for free.
- Conflict model unchanged: newest label per (coord, axis) wins.

Implementation:

- `foldModerationLabels` now populates `approvedOrder` alongside
  `featuredOrder`.
- `useCampaignModeration().moderate` accepts an optional explicit
  `created_at` for the label event (omitted for normal
  approve/hide/feature; passed by the reorder hook).
- New `useReorderCampaign` hook with `moveToTop`, `moveUp`,
  `moveDown`, and a general `moveTo(toIndex)` used by drag-and-drop.
- New `ReorderableCampaignGrid` wraps a list of `CampaignCard`s:
    - non-mods get a plain grid, zero overhead;
    - mods on desktop get HTML5 drag-and-drop with a six-dot handle
      on hover (the handle is the only `draggable` element so card
      clicks still navigate the underlying `<Link>`);
    - mods on mobile get Move up / Move down / Move to top rows
      injected into the existing moderator kebab via a context
      provider (`ReorderProvider` / `useReorderControlsFor`).
- An optimistic local order smooths the gap between publish and
  refetch so the card snaps into the new position immediately; it
  rolls back automatically on publish failure.
- Translations added in all 15 non-English locales.
- NIP.md documents the ordering convention in a new
  "Moderator-driven Ordering" section under the campaign-moderation
  surfacing rules.
2026-05-30 22:07:53 +02:00
mkfain 7c14115119 Drop the campaign title slug preview hint
The d-tag never appears in a user-visible URL. Campaign links are
naddr1… bech32 strings (see CampaignDetailPage.tsx:263 and
CreateCampaignPage.tsx:573 / navigate(`/${encodeCampaignNaddr(...)}`)),
which bundle the d-tag inside the encoded payload. Showing a
transliterated 'anqthwa-ahmd-wwaldh-…' string under the title input
and calling it 'your campaign URL' was actively misleading — the
user sees no such URL anywhere.

Rip out TitleSlugHint, the previewSlug memo, and the slugPreview /
slugFallbackNotice locale keys from all 16 locales. The
transliteration + random-fallback slug derivation in buildCampaignSlug
stays — that's still the right fix for the underlying bug — but it's
internal Nostr plumbing the user shouldn't have to see.

Regression-of: 12bc7219
2026-05-30 21:40:09 +02:00
mkfain 12bc721952 Let campaign titles in any language produce a valid URL slug
Arabic, Persian, CJK, and other non-Latin titles were collapsing to an
empty d-tag because slugifyCampaignIdentifier only kept [a-z0-9] after
NFKD. NFKD doesn't transliterate Arabic to Latin, so a title like
حملة لمساعدة الأطفال slugified to "" and the user hit the cryptic
errorTitleInvalid message at submit time — after walking through the
entire wizard.

Route the title through the slugify package's charMap first (covers
Arabic, Persian, Cyrillic, Greek, Georgian, Armenian, Vietnamese, common
Latin diacritics, currency symbols, smart quotes). For inputs that still
produce no ASCII characters — emoji-only titles, CJK, Thai, Tamil —
buildCampaignSlug returns a random campaign-XXXXXX identifier so the
user can still publish; the human-readable title lives in the title tag,
not the URL.

Also strip Unicode bidi controls and zero-width characters
(RLM/LRM/FSI/PDI/ZWNJ/BOM) before they reach the title tag. RTL
keyboards routinely insert these invisibly, and preserving them in
display strings is a homograph/phishing vector.

Surface validation under the title input itself rather than at submit:
when the title transliterates cleanly, show the slug preview in a
muted-tone code block; when it doesn't, show an amber notice explaining
that a random URL identifier will be generated and that the title is
preserved verbatim. Hidden in edit mode where the d-tag is locked.
2026-05-30 21:32:26 +02:00
mkfain 6c5205cc75 Make insufficient-fee broadcast failures actionable
When Bitcoin transactions were rejected by the network for fee-related
reasons (min relay fee not met, mempool full, RBF replacement
underpriced), both the HD wallet Send dialog and the campaign Donate
dialog surfaced a destructive toast titled "Transaction failed" /
"Donation failed" with the raw bitcoind RPC error as the description.
The dialog stayed open with state preserved, but the donor:

- Saw an opaque, technical error string they couldn't act on.
- Got no affordance to recover. Re-tapping Send re-fired the same
  rejected transaction. In HDSendBitcoinDialog the existing
  two-tap arm wasn't reset on broadcast failure, so a second tap
  immediately re-broadcasted with the same (rejected) parameters.
- In DonateDialog had no path to bump the fee without manually
  backing out to the form step and re-picking a tier.

Three pieces, plus a small adjacent fix:

1. Classifier in src/lib/bitcoinBroadcastError.ts maps the verbatim
   bitcoind / Blockbook-WebSocket / Esplora error strings onto a small
   enum (feeTooLow, rbfReplacementFeeTooLow, mempoolFull,
   mempoolConflict, tooLongChain, absurdlyHighFee, badInputs, network,
   unknown). For the canonical 'min relay fee not met, A < B' form,
   the actual and minimum sat/vB values are parsed out so the UI can
   show "minimum right now is N sat/vB" and seed a custom fee. 17
   unit tests covering real-world fixtures from mempool.space,
   Blockstream Esplora, Blockbook framing, and bare bitcoind output.

2. Shared BroadcastErrorAlert in src/components/BroadcastErrorAlert.tsx
   renders inline above the Send button. Replaces the toast for
   classified errors (toast is retained only as a fallback for the
   `unknown` bucket so something always surfaces). Fee-recoverable
   kinds get a "Use a higher fee" action; `network` gets "Try again";
   everything else has no action and waits for the donor to adjust
   amount / recipient via the auto-dismiss effect. A `presetTiersOnly`
   prop hides the bump button once a preset-only consumer (DonateDialog)
   is on the fastest tier, surfacing "You're already on the fastest
   tier" instead.

3. HDSendBitcoinDialog wiring — broadcast errors set a classified
   state, the alert renders above Send, and a new bumpFeeForRetry
   helper jumps to the next-faster preset OR, if already at the
   top of the deduped preset list, switches to a custom rate seeded
   from the strongest available hint (parsed minRelayFee + 1, or
   1.5x the rejected rate, or current+1 as a last resort). Refetches
   fee rates, opens the fee popover so the donor can see and tweak
   the new rate, marks the picker as user-touched so the 40%-of-amount
   auto-tune doesn't fight the bump, and resets the two-tap arm
   unconditionally on every failure.

4. DonateDialog wiring — same alert in the confirm step. The dialog
   has no custom-rate input by design (it's the simple donate flow),
   so the bump action walks the preset chain economy -> hour ->
   halfHour -> fastest. At the fastest tier the alert hides the
   button and tells the donor to use an external wallet via the QR
   panel on the campaign detail page.

5. i18n — 22 new keys under walletSend.broadcastError, translated
   into all 15 non-English locales in parallel with placeholder and
   technical-token preservation.

The auto-dismiss effects in both dialogs clear the alert as soon as
the donor adjusts a field that could plausibly resolve the failure
(recipient, amount, fee speed, custom rate) so the alert doesn't
linger once the donor has engaged with the fix.
2026-05-30 20:48:11 +02:00
Alex Gleason 737b197aa8 Merge branch 'main' of gitlab.com:soapbox-pub/agora 2026-05-30 20:13:03 +02:00
Alex Gleason 2cf3db0a51 Convert single-candidate pastes straight into a recipient chip
Pasting a bare bc1…/sp1… address or a single-endpoint bitcoin: URI now
resolves directly to the recipient chip instead of dropping into the input
behind a one-row dropdown the donor still had to click. Pastes carrying
both an on-chain address and an sp1 code still fall through to the dropdown
so the donor picks privacy vs. compatibility.

Extracted the candidate-resolution logic into a shared resolveCandidates()
helper so the live input memo and the paste handler agree on what counts as
a valid destination; the paste handler resolves from the clipboard text
directly (query state hasn't updated yet inside the event) and
preventDefault()s the single-match case so the raw text never flickers in.
2026-05-30 20:12:33 +02:00
mkfain c54008cd3d Surface a hint when the recipient picker is closed with no selection
The campaign donate flow opens HDSendBitcoinDialog with a prefilled
bitcoin:bc1q…?sp=sp1… URI. BitcoinRecipientInput auto-opens its
dropdown so the donor picks between the on-chain address and the
silent-payment code — privacy vs. compatibility, the explicit choice
the picker was designed around (92608f14).

In practice the donor's eye lands on the amount presets first. Tapping
$100 counts as an outside click, dismisses the popover, and leaves
`recipient` null. The Send button is disabled (correctly — no
destination resolved), the input still shows the prefilled URI, and
nothing on screen tells the donor what's missing. They eventually
discover that re-tapping the recipient input reopens the dropdown.

Add a small amber hint with a warning icon directly beneath the
recipient input whenever the input has parseable candidates but no
selection AND the popover is closed. The whole hint is a button that
reopens the popover and refocuses the input on tap, so the recovery
takes one click instead of a guessing game.

Gate the hint on a new `hasOpenedForQuery` flag that flips true the
first time the popover opens for the current query and resets when the
query clears. That keeps the hint from flashing for one paint frame
between mount and the auto-open effect on prefilled inputs.

Regression-of: 92608f14
2026-05-30 20:04:27 +02:00
Alex Gleason 7f16678acc Order the Bitcoin address row above silent payment in the recipient dropdown
Swap the dropdown row order so "Send to Bitcoin address" renders above
"Send to silent payment address" — the broadly-compatible on-chain option
leads, with the privacy option following.
2026-05-30 20:01:12 +02:00
Alex Gleason e77876ed16 Keep the recipient dropdown open when the input loses focus
The candidate dropdown is a persistent choice list, but Radix's Popover
dismissed it whenever the input blurred or the user tapped elsewhere,
making the rows vanish even though a valid destination was still in the
field. Block the auto-dismiss-on-outside-interaction handlers so the
dropdown stays open as long as the input holds a candidate; it now closes
only on selection, on a cleared input, or via Escape.
2026-05-30 19:59:02 +02:00
Alex Gleason 03b68c3a24 Clear the recipient input when the chip is X'd out
Previously, clearing a selected chip in a prefilled flow (campaign "Pay
with Agora") restored the prefilled bitcoin: URI / address back into the
input. Removed that restore effect so X-ing out the chip now returns to an
empty field, letting the donor type or scan a fresh destination without
first deleting the prefill.
2026-05-30 19:53:31 +02:00
Alex Gleason 2ab45a27d5 Preselect the recipient chip for single-endpoint Pay with Agora prefills
When the Send Bitcoin picker mounts pre-filled with a single valid
endpoint — e.g. a campaign with only a bc1 address (or only an sp1 code) —
it now auto-selects that candidate into the recipient chip instead of
leaving the bare value in the input behind a one-item dropdown the donor
still had to click.

Prefills carrying both an on-chain address and an sp1 code are left in the
input so the dropdown can surface both rows; picking privacy vs.
compatibility is a real choice the donor should make. Guarded by a
mount-once ref so it never overrides a manual selection or a clear-chip
restore.
2026-05-30 19:42:38 +02:00
Alex Gleason e40f32a54f Always copy a bitcoin: URI on campaign donate panels
The donate panel's copyable row only used a BIP-21 URI when a campaign
exposed both an on-chain address and a silent-payment code; single-
endpoint campaigns copied the bare bc1.../sp1... value instead. The QR
already encoded a bitcoin: URI in every case, so the copy row now mirrors
it — donors always get a wallet-parseable URI regardless of which
endpoints a campaign declares.
2026-05-30 19:36:41 +02:00
mkfain c53e476dee Move the all-campaigns directory from /campaigns/all to /campaigns
/campaigns was a redirect to / (the curated home), and the actual
all-campaigns directory lived at /campaigns/all. Flip the routing
so /campaigns IS the directory, the home page stays at /, and
/campaigns/all becomes a redirect to /campaigns for any external
links and bookmarks that still point there.

Rewrite every internal link/navigate target accordingly (TopNav,
the Browse-all CTA on the home page, the OnboardingGate donor
redirect, NoteCard's kind-33863 nounRoute) and refresh the
doc/comment references in NIP.md and the discovery hooks.
2026-05-30 13:19:29 +02:00
mkfain 3a06dcd4cb Translate the new HRF/WLC category set and the refreshed campaigns tagline
Two pieces of stale i18n caught up:

1. The 16 new campaignsCreate.categories.* keys (human-rights,
   democracy, press-freedom, political-prisoners, humanitarian-aid,
   civil-resistance, digital-rights, anti-corruption, women-girls,
   refugees, legal-aid, emergency-relief, animal-rights, education,
   medical, community) translated into all 15 non-English locales.

2. campaigns.all.sectionTagline rewritten across all 16 locales to
   match the discovery-section fix that now lists featured campaigns
   first and the rest of the network underneath, instead of
   featured-only-with-fallback. Old copy ('Highlighted by moderators.
   Search or sort to browse the full network.') implied search was
   required to see non-featured campaigns, which is no longer true.
2026-05-30 13:12:54 +02:00
mkfain d7144200fb Replace generic campaign categories with HRF/WLC-aligned set
Swap the picker's preset list from the generic GoFundMe-style
catalog (adoption, animals, church, family, memorial, mission,
non-profit, event, first-responders, political) to a set that
reflects Agora's editorial focus on the kinds of activism HRF and
the World Liberty Congress champion: human rights, democracy,
press freedom, political prisoners, civil resistance, digital
rights, anti-corruption, women & girls, refugees & exiles, legal
aid. Plus humanitarian aid (per request), animal rights, emergency
relief, education, medical, and community to round out the
breadth.

16 entries total, ordered by editorial prominence (freedom /
democracy themes first, everyday humanitarian needs after). The
picker UI is unchanged \u2014 it iterates the array, so swapping
contents is enough.

Existing campaigns that selected one of the dropped slugs keep
their on-chain `t` tag intact \u2014 only the editor stops lighting up
a pill for them. No migration; we're pre-launch.

Strip the now-orphaned campaignsCreate.categories.* keys from the
15 non-English locales; the new English keys are in en.json only,
non-English locales will fall back to English at runtime until
proper translations land in a follow-up.
2026-05-30 13:07:49 +02:00
mkfain b5cb884004 Swap Campaigns and Activity order in the main nav
Campaigns is the primary surface of Agora; lead with it.
2026-05-30 12:49:42 +02:00
mkfain 0800b854ae Restore four-section home page and stop dropping approved campaigns
The home page community grid was missing approved campaigns whenever
the approval was older than the most recent 200 events on the
network. The grid was fed by a single `useCampaigns({ limit: 200 })`
call, so an approved campaign with a low `created_at` would silently
fall off the end of the chronological window and disappear from the
public surface even though its approval label was still active.

Two fixes here:

1. Add a second `useCampaigns` call keyed on every approved + hidden
   coord, alongside the existing recent-stream query. Merge both
   result sets, de-dupe by aTag, keep whichever revision is newer.
   Approved coverage no longer depends on recency.

2. Restore the four-section layout the home page was supposed to
   have: Featured, Community (approved only), Pending (mods-only),
   Hidden (mods-only, collapsed by default). The single
   chronological-all-with-toggle grid this commit replaces was the
   wrong target \u2014 censorship-resistant viewing belongs on
   /campaigns/all, the home page should be the moderator-curated
   front door.

Extend ModeratorCollapsibleSection with an explicit `defaultOpen`
prop so the Hidden section can be forced closed independent of the
existing 'auto-open when short' heuristic.
2026-05-30 12:47:10 +02:00
Alex Gleason 3a98e38f7b Merge branch 'main' of gitlab.com:soapbox-pub/agora 2026-05-30 12:36:31 +02:00
mkfain e198e8d572 Bump home-page Featured cap from 4 to 12 2026-05-30 12:35:51 +02:00
Alex Gleason cf6364a84b Fail over on 404 from always-present Esplora paths
mempool.space serves a 404 (instead of 429) to rate-limited clients,
which is common on carrier-NAT'd mobile connections where many users
share an egress IP. esploraFetch treated 404 as a legitimate "not
found", marked the endpoint healthy, and returned it WITHOUT failing
over — so getFeeRates threw 'Failed to fetch fee estimates', the query
swallowed it, and the on-chain Zap/donation dialogs showed no fee rates.
This is why fees loaded on WiFi but not LTE.

Add a per-call retryStatuses option to esploraFetch that extends the
retryable set (failover + cool-down) for that call, and apply [404] to
the paths that always exist on a healthy backend: /fee-estimates,
/address, /address/txs, /address/utxo, and the /tx broadcast. The
/tx/{txid} lookup keeps 404 meaningful (genuinely-unknown tx).
2026-05-30 12:35:48 +02:00
mkfain 34cae4c9ad Stop hiding approved-not-featured campaigns on /campaigns/all
The idle (no search / no sort / no country) view of the campaigns
discovery section was a featured-only shelf with a fallback to
chronological only when nothing was featured at all. As soon as a
moderator featured one campaign, every approved-but-not-featured
campaign vanished until the viewer typed a search, picked a sort,
or filtered by country.

Switch idle mode to a true featured-first list: pin featured at the
top of the grid, then append every other non-hidden campaign in
chronological order, deduped against the featured set. Approved-
not-featured now shows up where viewers expect it.

Active mode is unchanged \u2014 it already rendered the full result set.
The section tagline still reads 'Highlighted by moderators. Search
or sort to browse the full network.' which is now slightly stale;
leaving the translation update for after we confirm the new
behavior in the wild.
2026-05-30 12:32:40 +02:00
mkfain 0c686a2091 Let anyone unhide hidden campaigns on the Campaigns page
The Show-hidden toggle in CampaignsDiscoverySection was gated to
moderators. Drop that gate so every viewer of /campaigns/all sees
the toggle and can unhide what mods have suppressed.

Rationale: moderation labels live on public relays regardless, so
hiding the toggle was security-by-obscurity. The Campaigns page is
the censorship-resistant browseable index; the only honest UX is
transparent moderation. The home page (/) keeps its curated
behavior \u2014 only mods see hidden campaigns there \u2014 and the Hidden
collapsible *below* the discovery section on /campaigns/all stays
mod-only because it's a review workflow with one-click hide/unhide
affordances, not a discovery surface.

The toggle's default is unchanged: off. Viewers see only non-hidden
campaigns until they opt in.
2026-05-30 12:26:58 +02:00
Alex Gleason d07bc64032 Add custom fee rate to wallet Send; stop showing empty fee tiers
The HD-wallet Send dialog's fee popover relied on getUniqueBitcoinFeeSpeeds
falling back to all four preset tiers when rates hadn't loaded — rendering
clickable tiers with no sat/vB value (and no way to send at all when the
Blockbook estimate API was down).

- Show loading/error status (with a Retry) in the fee popover instead of
  bare tiers when rates haven't loaded.
- Add a "Custom" fee tier with an inline sat/vB input so users can always
  specify a rate, including when the estimate API is unavailable.
- Disable Send when the resolved rate is < 1 and surface an inline error.
- Add resolveBitcoinFeeRate + a PresetBitcoinFeeSpeed type so 'custom' is
  handled distinctly from the preset tiers.
2026-05-30 12:15:45 +02:00
mkfain e8acf45656 Hide Groups and Pledges from main nav for launch
Comment out the two NAV_ITEMS entries (desktop nav and mobile drawer
share this array, so one edit covers both). Routes and feature code
stay intact \u2014 visiting /groups or /pledges still works, in-page CTAs
still link, only the persistent nav chrome stops promoting them.

Re-enable by uncommenting the two lines and re-adding the Users and
Megaphone icon imports.
2026-05-30 11:34:42 +02:00
mkfain 3c28e2b789 Show every campaign on the home page, with a hidden toggle
The community grid stops gating on moderator approval and now lists
every kind-33863 campaign on the network, newest-first. A Switch in
the section header reveals the moderator-hidden bucket on demand (off
by default, count badge when something's there).

The moderator-only Pending and Hidden collapsibles disappear with
this change — Pending is now part of the main grid, and Hidden is one
toggle flip away. The non-mod 'Your campaigns' pending shelf goes
away for the same reason: a creator's not-yet-approved campaign
already shows up in the main grid.

Featured row, hero, and 'Browse all campaigns' link are untouched.
2026-05-30 10:46:54 +02:00
Alex Gleason dc43f723fb Trim the eager countries chunk from 244 KB to 47 KB
src/lib/countries.ts imported the full iso-3166 package solely to build
a Set of valid ISO 3166-2 subdivision codes for validation. That dataset
(~5000 objects with names, parents, and tree structure) landed in the
eagerly-preloaded countries chunk because NoteContent, ComposeBox, and
campaign.ts all import from countries.ts on the critical path.

Ship only the subdivision code strings instead, generated at build time
into src/lib/subdivisionCodes.ts via scripts/gen-subdivision-codes.mjs.
iso-3166 moves to devDependencies since only the generator script needs
it now. The strict-validation contract (rejecting US-ZZ etc.) is
preserved.
2026-05-30 02:20:01 +02:00
Alex Gleason c7ed31305d Lazy-load locale bundles to shrink the initial bundle
i18n.ts statically imported all 16 locale JSON files (~2.4 MB),
collapsing them into a single eager chunk that every user downloaded
on startup regardless of their language. Bundle the English fallback
only and fetch the other 15 locales on demand via dynamic import(),
so each language becomes its own lazily-loaded chunk.

This removes the 2.1 MB i18n chunk from the initial load; the eager
i18n chunk is now ~109 KB (runtime + English).
2026-05-30 02:10:35 +02:00
Alex Gleason 441eea160f Restore the full campaigns content area on the home page
The previous commit left the home page with a single
CampaignsDiscoverySection (search/sort/country toolbar over one grid).
The original layout was richer and read better: a dedicated Featured
row, the Community Campaigns grid with a "Browse all" link, the
moderator-only Pending / Hidden collapsibles, and a per-viewer "Your
campaigns" shelf.

Rebuild that content area from moderation labels (useCampaignModeration
+ useCampaignModerators + useCampaigns), keeping the current hero and
leaving campaigns as the home page's sole focus. The shared discovery
components and the dedicated /campaigns/all, /groups, and /pledges pages
that consume them are untouched.

Regression-of: 7ccff2fb
2026-05-29 16:41:12 -05:00
Alex Gleason 4f056dfac0 Show only campaigns on the home page, not groups and pledges
The home page is the primary browse surface for campaigns and reads best
when it stays focused on them. Groups and Pledges each have their own
dedicated browse pages (/groups, /pledges), so surfacing all three on /
duplicated those experiences and diluted the page.

Drop the GroupsDiscoverySection and PledgesDiscoverySection from the home
page, leaving only CampaignsDiscoverySection. The shared discovery
components and the dedicated pages that consume them are untouched.

Regression-of: 7ccff2fb
2026-05-29 16:36:56 -05:00
lemon f16d5ea334 Use wallet price source in dashboard balance card 2026-05-29 14:16:34 -07:00
lemon ef8e6f9564 Use wallet price source in header balance 2026-05-29 14:12:22 -07:00
lemon 3b35b084fd Translate the wallet step's accept-mode pills, hints, and custom-wallet toggle into every shipping locale 2026-05-29 13:50:06 -07:00
lemon 0ade19c51e Translate the pledge and group wizard strings, plus the campaign categories, into every shipping locale 2026-05-29 13:50:06 -07:00
lemon 81f3c9e755 Show the Skip and Launch shortcut on the pledge wizard's Set Your Pledge step
The shortcut previously appeared only from step 3 onward. Once the
user fills the pledge amount on step 2 they're fully submittable —
title and description (the step 1 gate) plus a positive pledge
amount cover every required field. Forcing one extra Next click to
reach the shortcut on step 3 just to skip the rest was friction
for no benefit.

Moving the shortcut to step 2 reuses the same canAdvanceFromStep
gate the Next button does, so the button is visibly grayed out
until the pledge amount resolves to a positive sats value. Once
the amount is filled, both Next (continue to Cover) and Skip and
Launch (publish now) light up together and the user picks the
path. A minimal pledge is now two Next clicks plus a Skip and
Launch tap.
2026-05-29 13:50:06 -07:00
lemon 657c0e43e3 Align the pledge wizard tags step with campaigns and drop the dedicated deadline step
Two coordinated tweaks to the pledge create flow:

1. The free-form tag input is replaced with the same pill-style
   CategoryPicker that campaigns and groups already use, drawing
   from the curated 15-entry CAMPAIGN_CATEGORIES vocabulary. The
   tag list emitted on publish is now ordered canonically (the
   CAMPAIGN_CATEGORIES order) rather than insertion-order from
   the comma-separated input — same posture campaigns and groups
   adopted when they swapped pickers. Side effects:

     - parseContentTagInput is no longer imported by this file
       (still used by CreateEventPage and CreateCommunityEventDialog).
     - pledges.create.tagsPlaceholder is dropped from en.json and
       all fifteen non-en locales, since the picker has no
       free-text input to placeholder.
     - The step subtitle stays "Help the right people find your
       pledge"; the title is renamed "Country and categories" to
       match the picker (groups uses the same string).

2. The dedicated Deadline step is folded into the Pledge-amount
   step. The two questions answer the same beat — "how much, and
   by when?" — and a step that often gets skipped felt like
   padding next to the amount field it conceptually belongs
   with. The timezone subsection still reveals only once a date
   is chosen, the date is still required to be present-or-future,
   and the deadline tag still publishes only when a date is set.

Step count drops from 5 to 4: Title+Description, Pledge+Deadline,
Cover, Country+Categories. The Skip Next and Launch shortcut keeps
its from-step-3 placement (both required gates still clear by the
end of step 2), so a minimal pledge takes two Next clicks plus
one Skip and Launch tap.

i18n: deletes pledges.create.wizard.deadlineStepTitle and
deadlineStepSubtitle from en.json (they exist only in en so no
locale cleanup is needed). Updates pledgeStepSubtitle to mention
the optional deadline. Renames tagsStepTitle to "Country and
categories" to match the picker.
2026-05-29 13:50:06 -07:00
lemon 5a72cf1fd0 Convert the pledge create flow into the wizard layout
Pledges followed the original single-page stacked form for every
create. With campaigns and groups both running through the captive
wizard overlay, pledges were the odd one out — the FAB / hero CTA
landed on a long scrolling form while every other create flow
opened a focused step-by-step layout. This brings them inline.

Five steps:

  1. Title + Description (both required; step 1 gates on both)
  2. Pledge amount (required; gates on a positive sats preview so
     the BTC/USD price has resolved before publish)
  3. Cover image (optional)
  4. Deadline + timezone (optional; the timezone subsection still
     reveals only when a date is chosen)
  5. Country + Tags (optional, terminal)

Skip Next and Launch appears from step 3 onward. Steps 1 and 2 hide
the shortcut because publishing without their fields would trip a
server-side validation error; once both required gates are
cleared, the remaining three steps are explicitly optional and a
single-tap launch is the desired escape hatch. Matches the same
posture the campaign wizard uses for its required title + wallet
gates.

Side cleanups while in the file:

  - The local CountrySelect is replaced with the shared one. The
    pledges.create.{countryClearAria, flagOfAria, countryHint}
    locale keys were already absent from non-en locales (cleaned
    out during the earlier campaign/groups extraction), so this
    just removes the now-orphan en.json entries.
  - pledges.create.{publishing, uploadingCover} were dead since
    the page was already reading forms.publishing /
    forms.uploadingCover; deleted from all sixteen locales.
  - OrganizationContextChip now rides along inside step 1 as a
    step1Lead, same treatment the campaign wizard gives it. The
    captive overlay swallows the page header chrome, so the
    "publishing under <org>" affordance has to live inside the
    step body to stay visible.

No edit-mode path: pledges aren't editable today, and the file
mirrors that — there's a single create branch and that's it. If
edit support is ever added, the campaign / groups pattern (the
single-page form lives behind an isEditMode branch above the
wizard return) is the template.

i18n: adds pledges.create.wizard.{titleStepTitle, titleStepSubtitle,
pledgeStepTitle, pledgeStepSubtitle, coverStepTitle,
coverStepSubtitle, deadlineStepTitle, deadlineStepSubtitle,
tagsStepTitle, tagsStepSubtitle, launchNow} to en.json. Other
locales fall back to English until translated.
2026-05-29 13:50:06 -07:00
lemon b3163ea2c9 Show the Skip and Launch shortcut on step 1 of the group wizard
Groups require only a name to publish. The shortcut used to appear
from step 2 onward, which still forced one mandatory Next click
before the user could opt out of the remaining optional steps.
Moving it to step 1 lets a minimal group publish in a single
action: type a name, tap Skip and Launch.

The shortcut shares its disabled state with the Next button via
canAdvanceFromStep, so on step 1 it only becomes clickable once
the name field is non-empty.

Also tightens Wizard's canSubmit calculation: the mid-wizard
shortcut now respects the same canAdvance gate the Next button
does. Previously a launch button placed on a gated step would
remain clickable even when the gate was unmet, then trip a
server-side validation error. The terminal step's own submit
button keeps its old behavior because by definition every gated
step has already been cleared by then.
2026-05-29 13:50:06 -07:00
lemon 1f545e7361 Add a Skip Next and Launch shortcut to the group create wizard, restore the single-page form for edits
Two changes that go together:

1. The group create wizard now exposes a Skip Next and Launch ghost
   shortcut from step 2 onward. Name is the only required field
   (it is the gate on step 1 and the slug source); once a user
   clears that step, everything else is opt-in and they should
   not have to click Next, Next, Next through three optional
   screens just to publish a minimal group. Matches the same
   shortcut the campaign wizard offers from step 3 onward.

2. Edit mode now renders the original single-page stacked form
   instead of the wizard, mirroring the create-vs-edit split the
   campaign flow uses. Editing a populated entity benefits from
   seeing all fields at once: every wizard step would already be
   pre-filled, and walking through them adds friction without
   adding clarity. The edit form reuses the exact same section
   bundles the wizard does (nameDescriptionSection, coverSection,
   moderatorsSection, countryCategoriesSection) so create and
   edit stay byte-identical in their field rendering. Ordering
   matches the pre-wizard page: name, description, country,
   categories, cover, moderators.

i18n: adds groups.create.wizard.launchNow ("Skip Next and Launch")
to en.json. Other locales fall back to English until translated.
2026-05-29 13:50:06 -07:00
lemon 1b21edef19 Convert the group create / edit flow into the wizard layout
Groups used to render every field on a single long form. Now they
share the same captive overlay the campaign flow uses — sticky
progress bar across the top, one focused decision per step, top-left
back chrome and top-right escape, big rounded primary CTA. Four
steps:

  1. Name + Description (gated; name is required to advance)
  2. Cover image
  3. Moderators
  4. Country + Categories

The free-form 'mutual-aid, local-news, digital-rights' tag input is
replaced with the same pill-style CategoryPicker the campaign flow
uses, drawing from the same 15-entry CAMPAIGN_CATEGORIES vocabulary
so the two creation surfaces feel like the same product. Country
input uses the shared CountrySelect.

Edit mode behaviors:

  - The d-tag stays immutable (kept as editCommunity.community.dTag).
  - The pre-fill loop only pre-selects existing  tags that exist
    in the curated category set. Arbitrary  tags an older
    free-form entry may have published (e.g. 'mutual-aid') are
    intentionally dropped from the picker — the user has no way to
    re-select them, and silently re-publishing tags they can't see
    would be a stealth foot-gun. Same posture campaigns adopted when
    their tag input was swapped.
  - The preserved-tag list in the edit branch already strips every
    ; nothing else changes there.

No 'Skip Next & Launch' shortcut here. Groups are only four steps
and three of them are optional, so a mid-wizard submit shortcut
would clutter the footer without saving real effort.

i18n: adds groups.create.wizard.{name,cover,moderators,tags}Step{Title,Subtitle}
to en.json. The non-en locales fall back to English for these new
strings until they're translated.
2026-05-29 13:50:06 -07:00