Commit Graph

4678 Commits

Author SHA1 Message Date
lemon 3e433e70cb Hoist three duplicated discovery helpers to shared modules
Three small extractions that consolidate hand-rolled copies in the
discovery surfaces. No behavior change.

- getPledgeCoord → src/lib/pledges.ts. Was defined three times
  (PledgesDiscoverySection, ActionsPage, ActionShareMenu), each with
  the same '36639:<pubkey>:<d>' template. Lifted into the existing
  pledges lib and typed structurally on { pubkey, id } so the lib
  layer doesn't take a hook dep on Action.

- parseSort + toQuerySort → exported from useDiscoveryFilters and
  useAllCampaigns respectively. AllCampaignsPage was carrying its own
  copy of both with an apologetic comment ('mirroring the one in
  useDiscoveryFilters'); CampaignsDiscoverySection had its own
  toQuerySort. One source of truth each now, with the pages and the
  section importing from the same module as the hook that consumes
  the result.

- PledgeCardSkeleton → exported from PledgeCard. Replaces two
  byte-identical ActionSkeleton components in PledgesDiscoverySection
  and ActionsPage. Naming matches the existing CampaignCardSkeleton /
  CommunityMiniCardSkeleton convention of placing the skeleton next
  to its card.
2026-05-28 16:16:05 -07:00
lemon 3525f685bb Skip wasted discovery-section queries
Two related gates on the unified discovery sections:

- PledgesDiscoverySection: the chronological useActions({ limit: 300 })
  query only feeds the idle render branch (via idlePledges), but it
  was firing in active-search mode too. Active mode renders searchHits
  from useNip50Search, which never reads rawActions. On every keystroke
  that activates search we were burning a 300-event relay round-trip
  whose results went nowhere. Gate the query on !isSearching so the
  fetch happens only when the idle branch can actually consume it.

- CampaignsDiscoverySection: align the featured-coords useCampaigns
  query's enabled flag with the pledges section's pattern. useCampaigns
  already short-circuits on an empty coordinates array, so this is
  purely about not creating an empty cache entry when moderators have
  curated nothing — but it removes a small asymmetry that would have
  made the next reviewer second-guess which pattern is intentional.
2026-05-28 16:16:05 -07:00
lemon a358a5d95c Extract three reusable discovery sections shared by home and dedicated pages
The home page now shows the same Campaigns / Groups / Pledges sections
as their dedicated pages (/campaigns/all, /groups, /pledges), with the
same titles, taglines, and search/sort/country toolbars instead of
'Browse all' shortcut links. Each surface's discovery logic lived
in its own page and the home page was about to grow a fourth copy of
it, so the section bodies move into reusable components:

  CampaignsDiscoverySection  src/components/discovery/
  GroupsDiscoverySection
  PledgesDiscoverySection

Each owns the section header (title / tagline switch on active
search), the DiscoverySearchToolbar, the idle featured grid, the
active search/sort/country grid, and the per-section empty / no-match
cards. Filter state (search input, sort, country, debouncing) lives
in a new useDiscoveryFilters hook which has two modes:

  filterPersistence='url'   - flat ?q=&sort=&country= params. Used by
                              the dedicated pages so search results
                              are shareable and survive refresh.
  filterPersistence='local' - local-only state. Used by / where three
                              sections coexist and can't all own ?q=.
                              Refreshing the home lands on the curated
                              idle view, which matches what we want.

The dedicated pages keep their hero, optional Your-X shelf, and the
moderator-only Hidden collapsible — those stay page-level because
each page wants its own copy. They drive the section's Show-hidden
toolbar switch via a hoisted prop so the page-level Hidden
collapsible can read the same flag.

Side effects:

  - ActionShareMenu moves from inside ActionsPage to its own file
    so PledgesDiscoverySection can render it on every card without
    re-importing the page module.

  - useDiscoverCommunities is unchanged but only the dedicated
    /groups page calls it now (for the Hidden collapsible /
    hidden-count badge). The home page never triggers it.

  - browseAllGroups and browseAllPledges locale keys drop from all
    16 locales since the launchpad layout that needed them no
    longer exists.
2026-05-28 16:16:05 -07:00
lemon ba2ab78995 Turn the home page into a featured-only launchpad for all three surfaces
The home page used to be the canonical browse view for campaigns: hero
plus featured row, then the full community grid, then moderator-only
Pending/Hidden sections, then a per-viewer 'Your campaigns' shelf.
With /campaigns/all, /groups, and /pledges all now hosting their own
dedicated browse views (featured + search + sort + country in one
unified section), the home page no longer needs to duplicate the
campaigns browse experience.

Rebuild / as a three-section launchpad:

  Hero  -> unchanged (HeroLightningMap, Bebas Neue tagline, brand CTAs).
  Featured campaigns  -> capped at 4, links to /campaigns/all.
  Featured groups     -> capped at 8, links to /groups.
  Featured pledges    -> capped at 8, links to /pledges.

Each section pulls its featured set from the same moderation labels
that drive the dedicated page, so what surfaces here matches what
surfaces there — just truncated. Sections with no featured items
collapse silently (no empty card) so the page degrades gracefully if
moderators only curate one or two surfaces.

Each section's skeleton respects the dependency chain that gates its
underlying query: campaigns wait on useCampaignModeration, groups on
useOrganizationModeration (because useFeaturedOrganizations is
internally gated on it), pledges on usePledgeModeration. While those
are still resolving the section renders skeleton cards rather than
flashing an empty state.

Drop the unmoderated community grid, the Pending/Hidden moderator
sections, the 'Your campaigns' shelf, and the campaign-search
toolbar from the home page. All of that now lives on /campaigns/all
where viewers actually expect to browse and filter.

Add browseAllGroups and browseAllPledges to campaigns.home in all
16 locales so each section can link out with locale-appropriate copy.
2026-05-28 16:16:05 -07:00
lemon bc7e3b8547 Keep skeleton up while moderation labels are still resolving
useFeaturedOrganizations is internally gated on moderationReady — while
the organization moderation labels are loading, the underlying query
is disabled and reports isLoading: false / data: undefined. The Groups
page was using only that isLoading flag to decide whether to show the
skeleton, so during the moderation-loading window it rendered the
empty state for a moment before the curated grid popped in.

Track moderation readiness alongside the featured query and treat any
of the three states — moderation not ready, featured query in flight,
featured data not yet defined — as loading.
2026-05-28 16:16:05 -07:00
lemon f19562cf64 Render featured groups directly without intermediate event flash
The Groups page was firing a global kind-34550 query through
useDiscoverCommunities, rendering the full results, then filtering
them client-side for the 'agora' client tag. This produced a brief
flash of unrelated communities before the curated set settled.

Drop the client-side Agora-tag filter entirely and stop using the
all-communities fetch for the idle render path. The unified Groups
section now renders moderator-featured groups directly, gated on
useFeaturedOrganizations's own loading state, so the page goes
skeleton → curated grid with no intermediate render.

useDiscoverCommunities is still called for moderators only — it
feeds the Hidden collapsible section and the hidden-count badge on
the toolbar. Non-moderators no longer trigger the global fetch at
all.
2026-05-28 16:16:05 -07:00
lemon 9244bb2f16 Merge Featured and All sections on discovery pages
Campaigns, Groups, and Pledges each previously stacked a Featured
shelf above an All-X section. Collapse them into a single section
titled simply 'Campaigns' / 'Groups' / 'Pledges' that:

- Idle (no query, no sort, no country) shows the moderator-featured
  grid. If nothing is featured yet, falls back to the chronological
  all-X grid so the page is never blank.
- Active (the user typed, picked Top/New, or chose a country) shows
  the full result set, ranked or chronological per the toolbar.

The shared toolbar drops the 'default' sort option from its dropdown
(now only Top and New). Clicking an already-active sort returns the
page to the curated idle view, giving users a clear exit affordance
now that 'default' is no longer an explicit menu choice.

Personal shelves (My pledges / My groups / Your campaigns) stay
above the unified section as separate, user-scoped lists.
2026-05-28 16:16:05 -07:00
lemon 41c3fd62ac Shorten All groups tagline 2026-05-28 16:16:05 -07:00
lemon ca4855718a Hide group shelves until content is known
Regression-of: 5607f5fa
2026-05-28 16:16:05 -07:00
lemon 749c325b91 Hide Featured headers when no events are surfaced
Regression-of: 9663b05e
2026-05-28 16:16:05 -07:00
lemon 73df1484b7 Hide My pledges header when the user has no pledges 2026-05-28 16:16:05 -07:00
lemon ad43d540e0 Hide My campaigns header when the user has no campaigns 2026-05-28 16:16:05 -07:00
lemon ad31e12803 Hide My groups header when the user has no groups 2026-05-28 16:16:05 -07:00
Alex Gleason 28d0f1ab2c Default double-tweak scan to block 951430
Anchor the recovery scan's default start height one block before the
earliest known affected transaction (the original $500 send 9fb78657…,
mined in block 951431) instead of a rolling 30-day lookback. No affected
output can predate that block, so this covers every stranded payment
while keeping the scan bounded. defaultFromHeight is now always defined,
so the page prefills the input on mount.
2026-05-28 16:50:17 -05:00
Alex Gleason eb836e0eea Remove redundant Back button on double-tweak fix page
The PageHeader already provides a back affordance; the in-body Back
button duplicated it.
2026-05-28 16:43:22 -05:00
Alex Gleason 7927b9806b Add double-tweak SP recovery flow under /wallet/legacy
Adds a 'Double-tweak SP Fix' option that rescues silent payments stranded
on-chain by the historical double-tweak bug, where outputs landed at
Q = taproot_tweak(P_k) instead of P_k and were invisible to the normal
scanner.

- recovery.ts: scans indexer tweaks for taproot_tweak(P_k) candidates,
  matches them against the block UTXO set, and builds/signs a sweep that
  spends them with taprootTweakPrivKey(b_spend + t_k).
- useHdWalletDoubleTweakRecovery: in-memory range scan + match reporting,
  no NIP-78 persistence (recovered coins are swept immediately).
- WalletDoubleTweakFixPage at /wallet/double-tweak-fix: scan controls,
  recoverable total, and a one-tap sweep into a fresh BIP-86 address.
- Wired into the legacy recovery hub and AppRouter.

English strings only; other locales fall back to English at runtime.
2026-05-28 16:42:00 -05:00
Alex Gleason 6607066961 Merge branch 'main' of gitlab.com:soapbox-pub/agora 2026-05-28 16:13:50 -05:00
Alex Gleason a1ee51c29a Normalize odd-Y silent payment input scalars in sender sum
When spending a previously-received silent-payment UTXO, its signing scalar
d_k contributes to the BIP-352 input sum A. The recipient's indexer rebuilds
A by lifting each input's on-chain x-only key to even-Y, so the sender must
contribute the even-Y-normalized scalar (-d_k when d_k·G is odd-Y). SP inputs
were passed with isTaproot:false, which skipped that negation, so for odd-Y
d_k the output landed at a key the recipient never derives and the payment
was invisible.

Pass SP inputs with isTaproot:true so deriveSilentPaymentOutputs applies the
even-Y normalization. Input signing is unaffected (BIP-340 handles parity in
signSpUtxoInput, which derives its own d_k). Add a sender→receiver round-trip
regression test covering an odd-Y SP input.
2026-05-28 16:02:34 -05:00
Alex Gleason 10f128fb34 Fix double-tweaked silent payment output key
The BIP-352 sender derived the correct output key P_k but then passed it
through btc.p2tr(), which treats its argument as a Taproot internal key and
applies the BIP-341 TapTweak again. The on-chain output was therefore
taproot_tweak(P_k) instead of P_k, a key the recipient's scanner never
derives — so Agora-built silent payments were unspendable/undetectable by
the recipient.

Write the SP output script as the raw OP_1 push32 <P_k> program via
spP2trScriptPubKey, and fix encodeP2TR to encode the key verbatim (tr
output script) rather than re-tweaking it.
2026-05-28 15:51:24 -05:00
Chad Curtis 239ec43fbd Split OnboardingContext into def + provider files 2026-05-28 15:42:46 -05:00
Chad Curtis f390a88f29 Add captive onboarding flow with split create/give entry 2026-05-28 15:40:53 -05:00
Alex Gleason 33f9975262 Merge branch 'main' of gitlab.com:soapbox-pub/agora 2026-05-28 15:12:22 -05:00
Alex Gleason e5ac01f8a0 Surface pending receives in the wallet Transactions accordion
The headline ‘$X.XX pending’ badge reads Blockbook's account-level
`unconfirmedBalance`, which captures mempool credits to *any*
xpub-derived address — including a freshly-advertised receive address
with no prior confirmed history. The Transactions accordion, however,
only attributes a tx to the wallet if its inputs/outputs touch an
address in the `tokens=used` set Blockbook returns, and that set may
omit addresses whose only activity is mempool-only. The result: the
headline updated, but the row never appeared below.

Pre-derive the next 20 receive + 20 change addresses past
`firstUnusedIndex` on each chain and fold them into `ourAddresses`
inside `buildHdTransactions`. Cheap (one HMAC-SHA512 per address) and
adds no network traffic.

Also teach `TxRow` to render pending state explicitly: spinning
`RefreshCw` + orange ‘Pending’ label in place of the date, mirroring
the headline `PendingBadge`. Reuses the existing
`wallet.tx.pending` i18n key so no locale changes are needed.
2026-05-28 15:10:01 -05:00
Chad Curtis 820404bed3 Tailor NoteMoreMenu items for campaigns
Bookmark / Add to list / Add to sidebar don't map onto kind 33863
campaigns (addressable, with dedicated UI), so hide them when the
menu is opened from a campaign. Relabel "Mute Conversation" to
"Mute Campaign" in the same context.
2026-05-28 14:37:50 -05:00
Chad Curtis 43fa17a7f8 Unify canvas encode pipeline for resize and crop
resizeImage.ts and ImageCropDialog.getCroppedBlob were doing the same
five-step pipeline (decode -> optional crop -> downscale -> encode ->
File wrap) with mildly different defaults, so quality decisions had to
be made in two places. Adding the PNG-vs-JPEG comparison to crops, or
adjusting JPEG quality across the app, meant editing both.

Consolidates into a single encodeImage(source, options) in
@/lib/resizeImage:

  - source can be File | Blob | string (URL); the helper fetches/decodes
  - crop is an optional source-pixel rect (full image when omitted)
  - maxOutputSize caps the long edge; 0/undefined means no cap
  - compareFormats encodes both JPEG and PNG and returns the smaller
  - passthroughIfWithinBounds short-circuits the re-encode for files
    already within the cap and not being cropped
  - returns { file, dimensions } with the correct mime/extension

resizeImage(file) is now a one-line wrapper preserved for existing
callers (ComposeBox, ImageUploadField).

ImageCropDialog.getCroppedBlob is gone; the dialog calls encodeImage
directly and now emits a File (JPEG or PNG, whichever is smaller)
instead of a hardcoded JPEG-only Blob. JPEG quality drops from 0.92 to
the lib default of 0.85 so cropped covers match the rest of the
upload pipeline.

The onCrop contract changes from (Blob) => void to (File) => void.
Updated both consumers (CoverImageField, ProfileSettings) — neither
needed the manual 'new File([blob], ...)' wrapping anymore.
2026-05-28 14:32:44 -05:00
Chad Curtis 99b4b2a5c7 Honor imageQuality opt-out for cover image crop cap
CoverImageField unconditionally capped the crop canvas at 1600px on the
long edge, which was right for the default 'compressed' setting but
silently overrode the user's choice when they had opted into 'original'
via Network Settings. Other upload paths (ComposeBox, ImageUploadField)
already respect this preference; campaign/action banners were the only
holdout.

Now: still crop to the configured aspect (that's a framing decision, not
a quality knob), but only pass maxOutputSize through when imageQuality
is 'compressed'. Users on 'original' get a full-resolution JPEG at q=0.92
from the cropped region, capped only by the natural source dimensions.
2026-05-28 14:32:44 -05:00
Chad Curtis d9c69fb961 Crop and downscale cover images before upload
CoverImageField now routes every file-input pick and drag-drop through
ImageCropDialog instead of uploading the raw source. The crop is locked
to 3:1 by default (banner aspect, matching the dropzone preview) and
capped at 1600px on the long edge, so a multi-megapixel phone photo
ends up well under a megabyte instead of a multi-megabyte JPEG.

ImageCropDialog gained an optional maxOutputSize prop that downscales
the canvas via the 9-arg drawImage form with high-quality smoothing.
The default is no cap — preserves ProfileSettings behavior for callers
that haven't opted in.

Template clicks and direct URL paste still skip the crop dialog; those
are already-finalized URLs we don't own.
2026-05-28 14:32:44 -05:00
Alex Gleason 6d69676394 Merge branch 'main' of gitlab.com:soapbox-pub/agora 2026-05-28 14:04:54 -05:00
mkfain 822446b3a9 Fall back to useAuthor metadata in top-nav avatar
useLoggedInAccounts runs its own kind-0 query with a hard 1.5s relay
timeout. When that comes back empty (slow relay, cold pool), every login
gets metadata: {}, the AccountSwitcher avatar drops through to the
AvatarFallback, and genUserName() returns the literal 'Anonymous' — so a
logged-in user sees an 'A' placeholder instead of their picture, even
though the rest of the app (which uses useAuthor) shows the right
profile.

Layer useAuthor on top of the existing currentUser. useAuthor is seeded
from IndexedDB and shared with every other consumer of the user's
kind-0, so the avatar now picks up cached metadata immediately and stops
showing the 'A' fallback on logged-in sessions.
2026-05-28 21:03:34 +02:00
Alex Gleason 92608f1471 Pick BIP-21 destination from a dropdown in Send dialog
When a scanned QR or pasted BIP-21 URI carries both an on-chain address
and an sp= silent-payment parameter, the recipient input now surfaces
both as separate rows in a Popover dropdown so the donor explicitly
picks privacy (sp1) vs. compatibility (bc1) — matching how Ditto's send
dialog handles the same ambiguity. Refocusing or clicking the input
while it still contains a URI reopens the dropdown so the choice can be
changed without retyping.

Picking a row swaps the input out for a chip showing the chosen kind,
a truncated address, and an X to return to the input view. Bare bc1
or sp1 input still resolves directly, and single-option scans (URI with
only one valid candidate, bare address, bare sp1) bypass the dropdown
and go straight to the chip.

QR scanning moves into the picker, so the dialog no longer needs its
own scanner dialog or BIP-21 routing logic. The picker only supports
bc1 and sp1 destinations — pasted npub/nprofile is silently ignored
(no account search), matching Agora's narrower scope vs. Ditto.

The campaign donate flow used to pass two props (bc1 + sp1) and the
dialog rendered a swap toggle under the input. With the dropdown now
handling that choice natively, the toggle is gone and the campaign
page just builds a combined bitcoin:bc1?sp=sp1 URI as the prefill.
2026-05-28 14:00:53 -05:00
mkfain ea6aeda368 Render Bitcoin tx and address pages at /i/bitcoin:tx:* and /i/bitcoin:address:*
The NIP-73 external content page recognized bitcoin:tx:<txid> and
bitcoin:address:<addr> identifiers (parsed and titled correctly), but
ExternalContentPage never rendered a body for them — visitors arriving
from a wallet transaction row just saw the 'Bitcoin Transaction'
header with nothing beneath it.

Port BitcoinTxHeader and BitcoinAddressHeader from Ditto: confirmed/
unconfirmed status, block/size/fee/amount stats, mempool.space-style
inputs-to-outputs flow, address balance hero with sats + USD,
recent-transaction list, and a footer link out to mempool.space. The
backing useBitcoinTx and useBitcoinAddress hooks compose Agora's
existing fetchTxDetail / fetchAddressData / fetchAddressTxs helpers
against the configured esploraApis from AppContext, and share the
spot price with useBtcPrice so the page doesn't double-fetch.
2026-05-28 20:44:11 +02:00
Alex Gleason 50b408cf9e Add breathing room between Send button and Transactions toggle 2026-05-28 07:09:50 -05:00
Alex Gleason 2262fccc8e Surface insufficient-funds state in the Send button itself
The Send dialog used to print 'Available: $X.XX (Y sats)' below the
recipient field once the amount exceeded the balance. That left the
Send button reading 'Send Bitcoin' (disabled) with a separate footnote
the user had to notice and connect to the disabled state.

Move the signal onto the button: it now reads 'Not enough Bitcoin'
when the amount + estimated fee exceeds the available balance, and the
standalone availability line is gone.
2026-05-28 06:44:40 -05:00
Alex Gleason bab370ae87 Allow blob: workers in CSP so QR scanner can decode
The qr-scanner library spins up its ZXing decoder inside a Web Worker
created from a blob URL. Our CSP allowed scripts and connections but
not workers, so the browser silently blocked worker creation — the
camera opened fine (media-src is permissive) but no frame was ever
decoded, leaving the user pointed at a QR code that never registered.

Add 'worker-src self blob:' and 'child-src self blob:' (the latter
covers older browsers that fall back to child-src for worker policy)
to match the directives Ditto already ships.

Regression-of: bae49e61
2026-05-28 06:38:05 -05:00
Alex Gleason e0917733a7 Remove manual address-advance button on wallet receive
The receive address advances automatically when funds are detected, so
exposing a manual "next address" affordance is redundant and lets users
needlessly skip ahead in the derivation chain. Drop the RefreshCw button
to the left of the BIP-21 copy row and the now-unused
wallet.receiveDialog.newAddress key across all locales.
2026-05-28 06:28:36 -05:00
Alex Gleason c10434b336 Strip Nostr search from Send dialog recipient input
The recipient input on /wallet's Send dialog no longer:
- Shows a "Recipient" label above the field.
- Lists "npub…" in its placeholder (now just "bc1…, sp1…").
- Searches Nostr profiles by name as the user types, or renders a
  dropdown of matching accounts.
- Shows a search icon inside the input.

Pasted/scanned NIP-19 identifiers (npub1…, nprofile1…) still resolve
to a Bitcoin address via the existing `resolveRecipient` path, and
the resolved profile chip still renders below the input so the
sender can confirm the destination — only the autocomplete UI is
gone.

The walletSend.recipient.label i18n key is removed from every locale.
The useSearchProfiles dependency on this component is dropped; the
hook stays for other callers (mention autocomplete, search page,
etc.).
2026-05-28 06:22:45 -05:00
Alex Gleason beb0665a30 Tighten Send dialog layout
- Drop the "≈ N sats" line that sat under the dollar amount. The
  USD figure is the source of truth in this dialog; the sats
  conversion was visual noise.
- Drop the "Network fee" label and move the fee-tier popover under
  the Send button, centered. With only one popover in the dialog,
  the label was redundant and the row above the Send button was
  competing with the recipient input for attention.
- Remove the now-unused walletSend.approxSats and walletSend.networkFee
  i18n keys from every locale.
2026-05-28 06:18:40 -05:00
Alex Gleason 5e46806bb5 Drop recipient-kind status line from Send dialog
The "Sending to a raw Bitcoin address." / "Sending to a Nostr
user's on-chain address." / "Sending via a silent payment…"
muted-text line below the recipient input is gone. The recipient
chip already shows who's being paid, and the soft amber privacy
disclaimer covers the raw-address case, so the extra status line
was just noise. The three now-unused i18n keys are removed from
every locale.
2026-05-28 06:16:47 -05:00
Alex Gleason 9ed0237da8 Soften privacy warning on Send dialog
The disclaimer shown when sending to a bare bc1… address now uses
the existing `soft` amber tone instead of the destructive red one,
and no longer requires ticking an acknowledgement checkbox. The
checkbox-gating made the Send Bitcoin button appear permanently
disabled to users who hadn't noticed (or hadn't scrolled to) the
checkbox.

The `bitcoinPublic` disclaimer component already supported both
tones — only the Send dialog's wiring changes here. The unused
`walletSend.errors.acknowledgePrivacy` string is removed from
every locale.
2026-05-28 06:11:25 -05:00
Alex Gleason bae49e6123 Add QR scanner to Send dialog
The recipient input on /wallet's Send dialog now has a camera button
that opens a QR scanner. Bitcoin BIP-21 URIs are parsed and the
silent-payment fallback (?sp=) is preferred when present, falling
back to the on-chain address otherwise. Plain addresses, sp1… codes,
npub, and nprofile values are dropped into the input verbatim and
resolved by the existing recipient logic.

QrScannerDialog is a standalone component (ported from Ditto) that
owns the camera lifecycle via getUserMedia and the qr-scanner npm
package. It surfaces failure modes (insecure context, denied
permission, no camera, busy camera, overconstrained, ready timeout)
instead of a silent black screen, and offers a flash toggle when the
device supports it.

Android needed an explicit CAMERA permission in the manifest; iOS's
existing NSCameraUsageDescription string was extended to mention QR
scanning. No Capacitor camera plugin is required — the standard web
APIs work inside WKWebView and Android's WebView.
2026-05-28 06:02:58 -05:00
Chad Curtis 843fb29f26 Tighten mobile top-nav gap between menu and logo 2026-05-28 05:09:47 -05:00
lemon 687fc9cb7d Add hidden moderator section to group and campaign indexes 2026-05-28 02:41:54 -07:00
lemon 56dca6e9a0 Reorganize discovery pages around My, Featured, and All 2026-05-28 02:41:54 -07:00
lemon 652980b448 Add campaign review queues to all campaigns 2026-05-28 02:41:54 -07:00
Chad Curtis f55325042a Merge branch 'missing-translation-string' into 'main'
Fix raw campaign engagement translation keys

Closes #27

See merge request soapbox-pub/agora!37
2026-05-28 09:20:28 +00:00
filemon b2f6f372f3 Merge branch 'main' into missing-translation-string 2026-05-28 11:11:14 +02:00
lemon 9465eb2215 Pin group reads to app relay 2026-05-28 01:53:19 -07:00
lemon 01d98fa7bb Pin pledge reads to app relay 2026-05-28 01:50:08 -07:00
lemon dcbc2737be Show note share button on desktop 2026-05-28 01:46:16 -07:00
lemon 99822afb82 Show skeletons while featured groups load 2026-05-28 01:40:33 -07:00