Commit Graph

4447 Commits

Author SHA1 Message Date
lemon 14939ff534 Add organization role helpers and official-activity hooks
Introduces the Agora Organization model on top of NIP-72 communities:
only the founder (kind 34550 event author) can edit metadata, and the
founder plus listed moderators (p tags with role "moderator") can
moderate the feed. Membership/badge-award validation will be dropped
from the runtime path in follow-up commits.

src/lib/communityUtils.ts grows four small helpers:

  - isOrganizationFounder
  - isOrganizationModerator
  - canEditOrganization        (founder only)
  - canModerateOrganization    (founder OR moderator)
  - getOrganizationOfficialAuthors

src/hooks/useOrganizationActivity.ts adds three trust-filtered queries:

  - useOrganizationCampaigns
  - useOrganizationPledges
  - useOrganizationEvents

Each query passes the founder-plus-moderators list as the `authors`
filter so forged events tagged with the organization's uppercase `A`
root-scope tag from non-moderator pubkeys never surface as official
activity. This matches the nostr-security skill's guidance on
author-filtering trust-sensitive queries.

The hooks do not yet ship in the Organization page; that wiring is in
the next commit.
2026-05-20 12:32:57 -07:00
lemon c9b48aeaae Align pledges with campaign patterns 2026-05-20 12:32:57 -07:00
lemon 24aa3a32d9 Polish pledge navigation and amount input 2026-05-20 12:32:57 -07:00
lemon 3dd229edfb Reframe actions as pledges 2026-05-20 12:32:57 -07:00
Alex Gleason 0e13455c7a Gate campaign donations on the wallet's public-ledger disclaimer
Extract the Send dialog's raw-Bitcoin-address privacy warning into a
shared BitcoinPublicDisclaimer component and reuse it across every
on-chain payment surface on campaign pages:

- BeneficiaryDonatePanel (single-beneficiary BIP-21 "Open in wallet",
  also used inside BeneficiaryDonateDialog for multi-beneficiary rows).
- DonateDialog FormView: replaces the milder "public, irreversible,
  takes time" alert. The irreversible / network-fee note stays as a
  separate muted line below.
- DonateDialog ExternalPayView: the logged-out external-wallet
  fallback for single-recipient campaigns.

Each surface now gates its primary action (Open in wallet / Review /
Copy payment URI) on the donor checking "I understand this transaction
is public." The acknowledgement resets when the dialog reopens.
2026-05-20 14:24:15 -05:00
Chad Curtis 75a3453daa Merge branch 'dashboard-ui-polish' into 'main'
Polish dashboard page to match app design system

Closes #12

See merge request soapbox-pub/agora!24
2026-05-20 19:18:46 +00:00
Chad Curtis 7d0f565101 Merge branch 'ui-polish' into 'main'
Fix five small UI polish issues

Closes #13

See merge request soapbox-pub/agora!25
2026-05-20 19:15:13 +00:00
Chad Curtis 3d744518b2 Merge branch 'update-campaign-goal' into 'main'
Clarify that campaign goal is saved as sats, prevent edit drift

Closes #17

See merge request soapbox-pub/agora!26
2026-05-20 19:13:19 +00:00
Alex Gleason d4590a9340 Brighten the account switcher chevron on hover 2026-05-20 14:00:21 -05:00
Alex Gleason 3c330efaa9 Compact the top-nav account switcher trigger 2026-05-20 13:58:54 -05:00
Alex Gleason 89c392fa63 Remove Start Campaign button from top nav; brighten Join contrast 2026-05-20 13:49:36 -05:00
Alex Gleason a76a971321 Remove World link from the top nav 2026-05-20 13:22:42 -05:00
mkfain d2eca1811d Reword Community Campaigns subtitle
"Community-submitted fundraisers approved by moderators" was
procedural; replace with "Help fund the changes worth making" so the
section reads aspirationally rather than as a moderation explainer.
2026-05-19 18:39:36 -05:00
mkfain fdb849aa1d Default All Campaigns to Top; rename Newest pill to New
Top (most sats raised) is now the default sort; visiting /campaigns/all
shows the ranked list immediately. The chronological pill is renamed
from Newest to New for brevity.

URL state inverts accordingly: ?sort=none is the explicit value, Top
omits the param to keep the canonical URL clean.
2026-05-19 18:35:55 -05:00
mkfain d3e0d177a5 Rank All Campaigns by donation volume; drop NIP-50 sort/search
The previous NIP-50 sort:top/sort:hot path against Ditto was a dead end
for kind 30223: Ditto's engagement scoring is built for kind 1 notes
(likes / reposts / replies), none of which apply to fundraising
campaigns. All three sort modes returned indistinguishable output, and
the relay-side search: field returned nothing for campaign content.

Switch to client-side ranking and filtering using campaign-native
signals:

- Sort is now "Newest" (chronological, default) and "Top" (most sats
  raised across kind 8333 donation receipts).
- Newest is the default since it gives full relay coverage with no extra
  data dependency.
- Top batch-fetches every kind 8333 receipt tagging any visible campaign
  in a single round-trip, sums the amount tag, and ranks by total sats
  with donor count + created_at as tiebreakers. While scores load the
  list stays chronological so the page renders something useful
  immediately.
- Search filters client-side across title, summary, story, location,
  and t-tags. Substring, case-insensitive.

Drop the Hot mode entirely \u2014 7-day donation activity is too noisy with
current campaign volume to justify a separate UI affordance, and a
single Top vs. Newest choice is clearer.

Remove the page subtitle ("Every campaign published on Agora, including
ones awaiting moderation\u2026") at the user's request.

The hook no longer routes any queries to Ditto's relay group; the
default user-configured pool is used throughout, so campaigns published
only to non-Ditto relays are now discoverable too.
2026-05-19 18:31:59 -05:00
mkfain b7c88ecca8 Add sort + search to the All Campaigns page
Top/Hot/None sort and a free-text search bar, capped at 2 cards per row.
Top is the default.

Top and Hot use NIP-50 extensions (`sort:top`, `sort:hot`) that Ditto
implements; the page routes those queries (and any free-text search) to
the Ditto relay group via `nostr.group(DITTO_RELAYS)`. None with no
search uses the default user-configured pool so campaigns published only
to non-Ditto relays are still discoverable.

If Top/Hot returns nothing — cold cache, or Ditto doesn't yet weight
engagement for kind 30223 — we silently retry against Ditto without the
`sort:` field rather than show an empty page. Mirrors useMusicFeed.

The search input debounces at 300ms via the existing useDebounce hook.
Sort and search are independent: `search: "<query> sort:top"` works.

URL state (`?sort=top|hot|none&q=<query>`) makes results shareable.
Default values are omitted from the URL so the canonical path stays
clean.

Refactored useCampaigns to expose parseCampaignEvents — a pure helper
that handles the (pubkey, d) dedupe, archive filter, and parse step.
useAllCampaigns reuses it and passes `sortByCreatedAt: false` for
top/hot/search so we don't undo the relay's score order. No behavior
change for existing useCampaigns callers.
2026-05-19 18:16:22 -05:00
mkfain 09bd4096e2 Make Featured a moderation axis and add an All Campaigns page
Featured was a hardcoded array of naddrs in src/lib/featuredCampaigns.ts.
Promote it to a third moderation axis (`featured` / `unfeatured`)
alongside `approved` and `hidden`, managed by Team Soapbox pack members
through the same kebab menu on each campaign card.

The Featured row on the homepage now:
- Reads from `moderation.featuredCoords`, ordered newest-featured-label first.
- Caps at 4 campaigns.
- Adapts its grid to 1/2/3/4 desktop columns based on count (mobile stays
  one column), collapses when nothing is featured, and surfaces the hero
  `variant="featured"` card only when exactly one campaign is featured.
- Hide still wins: a featured-then-hidden campaign disappears from the row.

Rename the homepage's second section from "All campaigns" to "Community
Campaigns", which more accurately reflects that it's the approved-not-
hidden set with featured campaigns deduplicated out.

Add a new /campaigns/all page that lists every campaign found on relays
(approved, pending, and unmoderated alike), with a "Show hidden" toggle
that adds hidden campaigns back in. The Discover page's "All campaigns"
link now points here instead of /, since the homepage is no longer a
truly-all view post-moderation. Also surface a small "Browse all
campaigns" link beneath the Community Campaigns grid so the new page is
discoverable from home.

Update NIP.md to document the third axis and the home-page surfacing
rules (Featured row, Community Campaigns grid, Discover shelf).

Delete src/lib/featuredCampaigns.ts entirely — there's no longer a build-
time list to maintain.
2026-05-19 18:04:28 -05:00
Chad Curtis ed923bcde6 Drop the Team Soapbox attribution from the All campaigns subheader 2026-05-19 16:19:04 -05:00
Chad Curtis d065580e47 Curate the campaigns homepage with Team Soapbox labels
Move campaign curation from a hardcoded HIDDEN_CAMPAIGN_COORDS set to a
real moderation system. Team Soapbox (kind 39089 follow pack
k4p5w0n22suf) is the moderator roster; each member signs NIP-32 kind
1985 labels in the agora.moderation namespace to approve or hide a
campaign. The home page and Discore shelf render the approved-and-
not-hidden set; moderators additionally see Pending + Hidden sections
and a per-card kebab menu. Non-moderator authors get a Your Campaigns
section explaining their campaign is live on Nostr but awaiting a
homepage approval.
2026-05-19 16:14:45 -05:00
filemon cb32405e55 Clarify that campaign goal is saved as sats, prevent edit drift
The goal input accepts USD but the published event stores sats. The UI
gave no indication of this, so the displayed USD goal silently drifted
as BTC price changed — confusing creators.

Create mode: the preview now reads "Saved as X sats · about $Y today.
The USD estimate may change with BTC price."

Edit mode: a goalTouched flag tracks whether the user actually changed
the goal field. If untouched, the submit handler preserves the original
goalSats exactly instead of round-tripping through USD at the current
price. A helper note shows the current saved sats so the creator knows
the field is pre-filled from a reverse conversion.
2026-05-19 17:05:12 -03:00
filemon b9fee19510 Merge branch 'main' into ui-polish 2026-05-19 12:25:15 -03:00
filemon f6b209949a Fix post menu preview: keep NoteContent with working line-clamp
The previous attempt replaced NoteContent with plain text, losing rich
rendering of hashtags, mentions, custom emoji, and nostr identifiers.
Reverting to NoteContent while keeping the overflow fix needed two
changes to make line-clamp-3 work:

- Render NoteContent as a <span> (as="span") so it participates in
  the parent's -webkit-box line counting. The default <div> wrapper
  with overflow-hidden created a separate block formatting context
  that defeated line-clamp entirely.
- Add disableNoteEmbeds to prevent block-level EmbeddedNote/
  EmbeddedNaddr cards from appearing inside the compact preview.

The outer container keeps overflow-wrap-anywhere so long URLs break
safely within the clamped area.

Regression-of: fc950865
2026-05-19 12:17:27 -03:00
Alex Gleason 949bd5fde4 Harden cover image forms and self-zap feedback 2026-05-19 00:33:16 -07:00
lemon 27d65bc389 Reuse the Create Community page for editing, drop founder row
Two follow-ups to the new /communities/new page:

1. Stop rendering the founder as an 'anonymous' chip at the top of the
   Moderators section. The row was synthesized from just a pubkey
   (genUserName fallback, no avatar), which looked broken even though
   the founder is always implicitly the first moderator on the
   published kind 34550. Drop the row entirely; the founder remains
   pubkey #0 in the moderator list when we publish, just isn't
   visible as a chip.

2. Wire the page to handle ?edit=<naddr> the same way
   CreateCampaignPage handles its edit param:

   - Decode the naddr, reject anything that isn't a kind 34550 with
     an 'Invalid edit link' guard card.
   - Fetch the existing community (inline useQuery against
     ['community', pubkey, dTag] since there's no useCommunity hook
     yet). Show a 'Loading community…' card while it resolves.
   - Prefill name, description, image, and moderators when the data
     lands. Resolve each moderator's kind-0 profile via the same
     two-step cache-then-network pattern campaigns use for recipients,
     so chips render with proper avatars and names instead of fallback
     stubs.
   - Show a 'Community cannot be edited' guard if the viewer isn't
     the founder, mirroring the campaign edit author check.
   - On submit, fetchFreshEvent + publish kind 34550 with prev. Strip
     d/name/description/image/alt and any p-with-role-moderator tags
     from the previous tag set; rebuild them from form state, then
     re-append the preserved tags (badge a-tag, relay hints, …) and a
     fresh alt. The implicit member badge is left alone in edit mode
     (matches CreateCommunityDialog's edit branch).

CommunityDetailPage's 'Edit community' dropdown item now navigates to
/communities/new?edit=<naddr> instead of opening CreateCommunityDialog.
The dialog mount and its editCommunityOpen state are removed.

The dialog file is left in the tree even though nothing imports it
anymore — keeping it makes a revert cleaner if the user changes their
mind.
2026-05-19 00:19:38 -07:00
lemon f2805ed9d8 Add a dedicated Create Community page
CommunitiesPage used to open CreateCommunityDialog when the user hit
'Create community' in the hero, the My Communities shelf, or the empty
state. Replace those entry points with navigate('/communities/new'),
mirroring how CreateCampaignPage and CreateActionPage already work.

The new page covers the same three NIP-72 fields the dialog handled
(name + description + cover image) plus a Moderators section that
the dialog never exposed:

- Name, with a live URL preview of the derived slug for transparency.
- Description, in the same Textarea shape as the dialog.
- Cover image, via the shared <CoverImageField> so drag-and-drop +
  paste-URL behavior matches the campaign and action create pages.
- Moderators, via the same <PersonSearch> CreateCampaignPage uses for
  recipients. The founder is pinned at the top of the list as a
  non-removable row labeled 'Founder'; PersonSearch is told to exclude
  the founder so they can't be added a second time. Extra moderators
  go into the kind 34550 event as additional ['p', pk, '', 'moderator']
  tags alongside the founder's.

Submit logic is the kind 30009 badge mint + kind 34550 community
publish from CreateCommunityDialog's create branch, including the
d-tag and badge-d-tag collision checks. Cache keys touched on success
match the dialog: ['addr-event', 34550, pubkey, dTag] is seeded,
['my-communities'] and ['community-activity-feed'] are invalidated.

CreateCommunityDialog itself stays in the tree because
CommunityDetailPage still opens it in edit mode. A unified edit page
that folds in 'View members', 'Add members', and 'Edit badge' is
explicitly out of scope for this change.
2026-05-19 00:19:38 -07:00
lemon 0745d99e85 Share the cover-image picker between Action and Campaign forms
Both forms had their own CoverPicker / dropzone implementation, and the
two had diverged: the action page learned to accept drag-and-drop while
the campaign page was still click-only. Extract the entire affordance
(dashed dropzone + sanitized preview + remove button + template strip +
URL input) into <CoverImageField> in src/components/, used by both
pages.

The new component takes a controlled value/onChange pair and an optional
templates array, so the campaign page (no templates) and the action page
(six Blossom-hosted defaults) reuse the same dropzone and the same
drag-and-drop, MIME-checked upload path. Clicking a template fills both
the dropzone preview and the URL input from a single source of truth.

While here, dedup three more copy-pastes between the two pages:

- Lift FormSection (the titled section wrapper with the Required /
  Recommended / Optional badge) into src/components/FormSection.tsx.
  Both pages now import the same component instead of redeclaring it.
- Move getTodayDateInput() into src/lib/dateInput.ts. Both pages need
  the same YYYY-MM-DD-in-local-tz string for the deadline picker's min
  attribute; keeping it in one place means future timezone tweaks land
  in one file.
- Run the action page's coverImage through sanitizeUrl() at submit
  time the same way the campaign page does, so a paste-in cover URL
  that isn't well-formed https:// drops out of the published 'image'
  tag instead of getting written verbatim.

No user-visible behavior change on the action page; the campaign page
gains drag-and-drop and the 'Click or drag an image here' prompt copy.
Net diff: -318 / +13 in the page files.
2026-05-19 00:19:38 -07:00
lemon 4bba4159f1 Serve default action covers from Blossom
The template gallery used to point at relative paths under
/challenge-covers/, which meant any kind-36639 event whose author
picked a template published an 'image' tag like
'/challenge-covers/cover9.png'. That string only resolves on Agora's
own origin, so the cover broke as soon as another Nostr client
rendered the event.

Replace each DEFAULT_ACTION_COVERS entry with the public Blossom URL
of the same image so the tag we publish is portable across clients.
DEFAULT_COVER_IMAGE (the fallback for action cards whose author never
set one) now points at the Blossom-hosted Justice image too.

The original /public/challenge-covers/ files are kept in place because
the Actions hero banner still reads them as static assets through Vite.
2026-05-19 00:19:38 -07:00
lemon 23977a64ca Let the cover-image dropzone accept drag-and-drop
The dropzone label previously only opened the file picker on click. Wire
up onDragOver / onDragEnter / onDragLeave / onDrop so dragging an image
from the OS file manager (or another browser window) onto the box
uploads it through the same useUploadFile path the click flow uses.

- Highlight the dropzone (border-primary + light primary fill) while a
  file is being dragged over it so users get a clear hit target.
- Validate the dropped file's MIME type against the same image/png,
  image/jpeg, image/webp set the file input's accept attribute enforces,
  so a stray PDF dragged in doesn't get posted to Blossom.
- Update the placeholder copy to 'Click or drag an image here' so
  the affordance is discoverable.
- Reject the dropped file silently while an upload is already in
  flight (matches the pointer-events-none on the click path).
2026-05-19 00:19:38 -07:00
lemon c73c15de22 Polish the Create Action page form
- Reorder the fields to match how authors think about an action: Title,
  Type + Bounty on the same row, Description, Country, Cover image,
  Start date + Deadline on the same row, then the Timezone block.
- Default the Type select to "Action" so the most general bucket is
  picked unless the author opts into a more specific kind.
- Block past dates in the Deadline picker (min={today}) and reject them
  at submit time with a clear error, mirroring CreateCampaignPage.
- Promote Country and Cover image from Optional to Recommended now that
  the actions index leans heavily on both for filtering and visual scan.
- Replace the cover field with the campaign-style picker: a clickable
  dashed dropzone (image preview + remove button + ImagePlus prompt),
  a thumbnail strip in between, and a URL input below. Clicking a
  thumbnail just fills the URL input, no permanent default selection
  is forced on the user, and the URL stays editable.
- Trim the default cover gallery to seven curated images by dropping
  the four overlapping/redundant entries (Protest March, Unity,
  Resistance, Change, Demonstration).
2026-05-19 00:19:38 -07:00
lemon a5159e040b Convert Create Action modal into a dedicated /actions/new page
The actions index used to open CreateActionDialog from three places (hero
CTA, FAB, empty state). Now all three navigate to /actions/new, which
mirrors CreateCampaignPage's layout — back arrow + page title, a single
rounded form panel of FormSection blocks, and a full-width submit
button — while preserving every field, validation rule, and tag-emitting
behavior of the old modal.

CreateActionDialog itself stays in the tree because CommunityDetailPage
still opens it inline to attach an action to a NIP-72 community.
2026-05-19 00:19:34 -07:00
Alex Gleason c2bf0bd88e Restrict the campaigns hero rotation to featured campaigns
The rotating banner, spotlight card, and globe markers now all draw
from the hand-picked featured pool only. Previously the spotlight
loop also pulled in every campaign returned by useCampaigns, so the
banner image, spotlight card, and globe pins cycled through community
submissions alongside the two featured slots.

Featured campaigns still flow through the existing country/coords
gate, so anything without a resolvable country is dropped from the
globe — and from the banner — to keep the three in sync.
2026-05-19 00:37:56 -05:00
Alex Gleason 9fec863f18 Put the entire country page inside one FeedCard
User flagged that the /i/iso3166:VE page still looked disjointed:
the cinematic hero bled edge-to-edge, the ExternalActionBar sat as
a bare Twitter-style border row below it, the ComposeBox sat raw,
and only the Pinned + Recent feeds were wrapped in their own
FeedCards underneath. Five separate stripes stacked vertically with
no shared container.

Restructure the country branch so the whole surface lives inside
one rounded FeedCard:

- CountryContentHeader (cinematic hero) becomes the top of the card
  with its rounded corners clipped by the FeedCard's overflow-hidden.
- Hero gradient's bottom stop changes from hsl(var(--background)) to
  hsl(var(--card)) so the fade meets the card surface, not the page
  background, eliminating the dark-mode color seam.
- Drop the section's mb-2 since the action bar now sits flush.
- ExternalActionBar sits flush inside the card; its existing
  border-b reads as an internal section separator.
- ComposeBox renders with hideBorder + bg-transparent so it inherits
  the card surface. Added an optional className prop to ComposeBox
  so callers can override the default bg-background/85.
- Pinned section gets a border-t border-border heading band; its
  loading state inlines NoteCard-shaped skeletons rather than the
  FeedCard-wrapped CommentsSkeleton (which would have card chrome
  inside card chrome).
- Recent section's heading becomes a border-t band between Pinned
  and Recent when both are present; its loading state also inlines
  skeletons; FlatThreadedReplyList sits flush in the card.
- The infinite-scroll sentinel + empty state move outside the card.

URL / unknown content types keep the previous edge-to-edge action
bar treatment (their content headers already render their own
chrome and don't need the unified card).
2026-05-19 00:14:27 -05:00
Alex Gleason 6aeed26642 Wrap focused post, community feeds, and pinned posts in FeedCard
The previous sweep wrapped vertical NoteCard feeds in FeedCard but
missed three surfaces that share the same Twitter-style edge-to-edge
problem:

- PostDetailPage's focused post (`/nevent…`, `/note…`): the
  ancestor previews + AncestorThread + focused <article> all sat
  bare on the page background while the replies below were wrapped
  in a FeedCard. The main post read like background noise and the
  replies read like the actual content. Wrap the entire focused
  group (previews + ancestors + focused article — all seven kind
  variants funnel through this) in one FeedCard so the page reads
  "thread context → this post → replies" as two cohesive cards.
  Also wrap the ProfileBadgesDetailView's bare top NoteCard.

- CommunityDetailPage's Activity tab (`/naddr…` community kind
  34550): bare `divide-y divide-border` for the activity stream
  AND the past-initiatives stream. Replace with FeedCard. The
  community page already supplies its own `max-w-3xl px-4 sm:px-6`
  wrapper, so the FeedCard opts out of its own margins via `mx-0
  sm:mx-0` to avoid doubling up the side padding. The Past-Initiatives
  heading gains a `border-t border-border` so it reads as a section
  divider inside the card rather than a floating header.

- CommunityPulsePanel: same divide-y → FeedCard fix.

- ExternalContentPage's country pages (`/i/iso3166:VE`): Pinned
  posts list was bare while Recent posts below it sat in a FeedCard
  — same Pinned-bare / Recent-card mismatch on the same screen. Wrap
  Pinned in FeedCard. Add a "Recent" eyebrow heading above the
  recent posts feed when both sections are visible, so the two
  sections are clearly delineated. Move the Pinned heading's
  horizontal padding from `px-4` to `px-4 sm:px-6` so it lines
  up with the card margins on tablet+.
2026-05-19 00:02:58 -05:00
Alex Gleason 3530754518 Wrap NoteCard feeds across the app in a soft FeedCard surface
After fixing /discover's 'Voices from everywhere' feed, the same
Twitter-style edge-to-edge divided-list pattern was hiding in 24
other feed sections across the app: notifications, profile wall,
search results (posts + accounts + community posts + the empty-state
FollowsList), bookmarks, badges, trends, list members + their posts,
domain feeds, relay feeds, events feed, communities feed, the
Bluesky page, external-content comments + book reviews, post detail
replies (in three render paths), and the FollowPage members tab. On
the GoFundMe-shaped layout they all read as 'random separators
floating in space and posts with no sides.'

Introduce a shared FeedCard component that bakes in the standard
canvas — mx-4 sm:mx-6 rounded-2xl bg-card border border-border/60
shadow-sm overflow-hidden — and replace the bare divide-y wrappers
at all 25 feed-list sites with it. NoteCard rows already self-apply
px-4 py-3 border-b border-border, so live feeds don't need a
divide-y; pure skeleton lists (rows with no own borders) keep
divide-y on the FeedCard className.

Sites intentionally left alone:
- The 8 popover/autocomplete dropdown lists inside max-h-* scrollers
  on BlueskyPage, WikipediaPage, BooksPage, and ArchivePage (already
  inside bg-popover rounded-lg chrome — double-wrapping clashes).
- CampaignDetailPage's beneficiaries list (already inside a Card,
  the divide-y is a section separator).
- CampaignDetailPage's comments card (uses a custom -mx-2 sm:-mx-4
  inside the article column; FeedCard's mx-4 sm:mx-6 is wrong for
  that nesting).

Also fix TrendsPage's hashtag skeleton: it used a vertical divide-y
list shape but the loaded UI is a flex-wrap of pill badges. Replace
TrendSkeleton with a small h-7 w-24 rounded-full so the skeleton
matches the loaded shape and doesn't pop on transition.
2026-05-18 23:47:08 -05:00
Alex Gleason 5049116a6f Wrap the Voices feed in a soft card
DiscoverPage's 'Voices from everywhere' feed was the page's odd one
out: every section above (country pulse strip, Help raise hope
shelf, Find your people shelf) renders inside its own banded or
rounded canvas, but the Voices feed used the legacy Twitter timeline
treatment — edge-to-edge rows separated by floating divide-y
hairlines with no container chrome. On a GoFundMe-shaped page next
to rounded shelves above, the rows read as 'separators floating in
space and posts with no sides'.

Wrap the feed (loading skeleton, populated, and empty states) in
the same rounded-2xl bg-card border border-border/60 shadow-sm
overflow-hidden surface I introduced for the campaign-page comments
card. Drop the redundant divide-y on the populated state — NoteCard
already self-applies px-4 py-3 border-b border-border, so the
container only needed dividers in the skeleton state where rows have
no border of their own. Add the same border-b to the CampaignCard
branch of DiscoverFeedRow so campaign rows separate correctly
between NoteCard neighbours inside the card.
2026-05-18 23:14:46 -05:00
Alex Gleason 3b641a8d7c Sweep remaining Twitter-style action rows into PostActionBar / chip style
The first chip-row pass touched the campaign page (PostActionBar +
NoteCard). The post-detail page, book feed, and external/Bluesky
content rows still rendered the old spread-out pill toolbar — visible
when clicking any note from /discover, the book index, or a Bluesky
syndication. Bring them in line:

- PostDetailPage.tsx: replace the five remaining inline action rows
  (standard post, repost card, zap card, profile detail, vanish event)
  with <PostActionBar event={event} onReply onMore />. Drops ~270
  lines of duplicated reply/repost/react/share/more JSX and removes
  the now-dead handleShare, repostTotal, encodedEventId locals and
  the MessageCircle / MoreHorizontal / Share2 / ReactionButton /
  RepostMenu / toast / shareOrCopy imports that fed them. Update the
  loading skeleton to use chip-shaped placeholders.

- BookFeedItem.tsx: same migration. The component already had a
  bespoke action row that mirrored PostActionBar minus the share
  button — using PostActionBar restores parity and removes the
  hand-rolled zap branch, the canZapAuthor / isZapped / useUserZap
  glue, and the MessageCircle / RepostIcon / Zap / formatNumber
  imports.

- ExternalContentHeader.tsx + BlueskyPage.tsx: these can't switch to
  PostActionBar because the comment/repost handlers publish to
  Bluesky's external semantics, not Nostr. Hand-restyle the rows to
  the chip aesthetic instead (h-9 px-3 rounded-full, label fallback
  on sm+, share/more pushed right with a flex spacer).

- ExternalReactionButton.tsx: mirror the variant: 'pill' | 'chip'
  prop added to ReactionButton in the previous commit, so the
  external-content rows can opt into the chip look without affecting
  ExternalContentPage's sidebar toolbar which still uses the pill.

PodcastDetailContent, MusicDetailContent, and PhotoBottomBar still
use their own deliberate aesthetics (large circular play button with
matching side buttons; immersive lightbox bar). Left alone — they
don't read as Twitter rows.
2026-05-18 23:04:39 -05:00
Alex Gleason 3e31d26660 Replace Twitter-style action bar with GoFundMe-style chip row
The campaign page (and every post feed) inherited Ditto's spread-out
pill action bar — icons across the full width, framed by a heavy
top+bottom border band, with cascading dots on the 'Show more replies'
thread connector. It read as a Twitter toolbar, which is wrong for a
fundraising client.

Restyle the shared action row across PostActionBar and NoteCard:
- Chip-style buttons (h-9 px-3 rounded-full) with inline counts.
- Engagement actions cluster left, share/more pushed right with a
  flex spacer instead of justify-between.
- Drop the unconditional top+bottom border band; pages add their own
  separator via className when needed.
- Show 'React'/'Reply'/'Repost' word labels on sm+ when the count is
  zero, so the bar reads as labelled affordances rather than icon
  pills floating in space.
- Add a 'chip' variant to ReactionButton so it can switch between the
  legacy pill (still used by PhotoBottomBar, PodcastDetailContent,
  MusicDetailContent, BookFeedItem, PostDetailPage, etc.) and the new
  chip look.

ThreadedReplyList's 'Show N more replies' button drops the four
cascading dots in favour of a single soft connector that brightens on
hover.

On the campaign page itself, wrap the engagement stats + action bar
in a soft card, add a 'Comments & donations' section heading with a
count, and replace the bare 'No comments yet' line with a dashed
empty-state CTA that opens the reply composer.
2026-05-18 22:47:18 -05:00
Alex Gleason 65633bcac9 Fix donate column bottom getting cut off on tall campaigns
The desktop sticky donate column capped its height with
`lg:max-h-[calc(100vh-2rem)] lg:overflow-y-auto`, intending to keep
tall donate cards (QR + many beneficiaries) reachable on short
viewports. In practice this swapped one problem for another: instead
of overflowing the viewport the column rendered with an inner
scrollbar and visually clipped the beneficiary list at the bottom of
the card — exactly the report from the team-soapbox campaign (11
beneficiaries).

Drop the height cap and wrap the column contents in a sticky inner
`div`. While the column is shorter than the viewport it sticks 1rem
below the top as before. When it's taller, the sticky wrapper rides
along with the page scroll until the flex row ends, exposing the
column's bottom via the normal page scroll instead of trapping it
behind a nested scrollbar.

Regression-of: 2bce20ba03
2026-05-18 22:33:10 -05:00
Alex Gleason 3cf8b20e97 Link beneficiary profiles to NIP-19 routes on campaign pages
Beneficiary rows on multi-beneficiary campaigns linked to /${pubkey} —
the raw hex pubkey — which falls through the /:nip19 catch-all to the
404 page. Single-beneficiary campaigns hid the beneficiary's profile
preview entirely, on the assumption that the campaign organizer above
the panel was the same person; that's not true when an organizer runs
a campaign on someone else's behalf.

Switch all three call sites — RecipientRow, BeneficiaryDonatePanel's
profile row, and the hero "by {creatorName}" link — to the canonical
useProfileUrl helper, which picks a verified NIP-05 path when available
and falls back to the npub. Drop the hideProfile prop on
BeneficiaryDonatePanel so single-beneficiary campaigns always show the
beneficiary's avatar and name with a working profile link.
2026-05-18 21:09:25 -05:00
Alex Gleason aa5f5d7640 Brand the auth dialog with the Agora bolt mark
Replaces the generic key emoji / lucide Key icons on the welcome,
generate, and secure steps with AgoraBoltIcon (matching the rest of
the app's onboarding chrome). Personalizes the dialog title with the
app name and clarifies the welcome buttons to 'Create a new Nostr
account' / 'Log in to an existing account'.
2026-05-18 20:57:29 -05:00
Alex Gleason 0b1caeffa7 Unify auth into a single Join button using MKStack AuthDialog
Replaces the separate Log in / Sign up entry points with one Join
button that opens AuthDialog (welcome -> create-account or log-in).
Drops the full-screen SetupQuestionnaire signup flow, LoginDialog,
SignupDialog, and the useOnboarding context.

Removes InitialSyncGate so the app no longer blocks on initial
encrypted-settings / relay-list / mute-list sync. The same side
effects now run via InitialSyncRunner, mounted alongside NostrSync
at the top of the tree, so settings still get pulled and seeded
into the query cache on login \u2014 just in the background.
2026-05-18 20:53:15 -05:00
Alex Gleason dfeeb81ab8 Fix useLayoutOptions resetting store on Suspense / StrictMode unmount
The unmount cleanup deferred its store.reset() to a rAF so navigating
pages had a chance to overwrite the store first. The deferred reset
checked whether the store still held this hook's snapshot \u2014 if yes,
reset.

But Suspense (and React StrictMode dev double-invoke) trigger a
cleanup-then-resetup cycle on the same hook instance: the cleanup
fires, schedules the rAF, then the setup re-runs but doesn't write
a new snapshot (shallowEqualOptions sees identical options and
skips). When the rAF fires, the store still has the original
snapshot, so the check passes and the store is wrongly reset \u2014
silently dropping the page's layout options (noMaxWidth,
wrapperClassName, etc.) mid-life.

The symptom: campaign pages and other noMaxWidth pages narrowed to
the default max-w-3xl cap a few seconds after opening, whenever
something downstream triggered a Suspense boundary to re-resolve.

Fix: a per-instance 'unmounting' ref. Cleanup flips it to true,
setup flips it back to false. The rAF bails if the hook re-mounted
in between, so only genuine unmounts trigger the reset.

Regression-of: 2bce20ba
2026-05-18 20:25:37 -05:00
Alex Gleason 2bce20ba03 Restructure campaign page into 2-column GoFundMe-style layout
Splits CampaignDetailContent into a main article column (story +
comments + threaded donation receipts) and a sticky right donate
column that holds raised stats / progress / primary CTA / share /
beneficiaries / recent donors. On lg+ the column sticks beside the
article; on mobile it collapses inline below the hero so the donate
CTA stays above the fold.

Extracts CampaignHero, CampaignStory, DonateColumn,
SingleBeneficiaryActions, MultiBeneficiaryActions, DonorPreviewList
as named subcomponents so the two recipient variants stay in one
file but each has a self-contained code path.

The donor list ('Recent donations') shows up to 5 aggregated kind 8333
receipts (amount + relative time, no avatar) with a 'See all' button
that scrolls to the inline activity feed below where every receipt
is already rendered alongside comments.

Drops CampaignProgress in favor of a raw Progress bar so the column's
raised/of-goal text isn't duplicated by the helper's inline label.
2026-05-18 19:58:40 -05:00
Alex Gleason 208296f841 Move 'Open in wallet' button under the Bitcoin address in beneficiary panel
The button now lives inside BeneficiaryDonatePanel directly under the
copyable address, so it's always present wherever the panel renders
(inline on single-beneficiary campaign pages, and inside the dialog
for multi-beneficiary campaigns).

Drops the top primary donate button on single-beneficiary campaign
pages \u2014 redundant now that the panel has its own \u2014 and lets the
Share button take the full row.

Regression-of: 6488a0ed
2026-05-18 19:41:48 -05:00
Alex Gleason 6488a0ed63 Inline single-beneficiary QR panel into campaign page
For campaigns with exactly one recipient, the QR + Bitcoin address +
copyable string that BeneficiaryDonateDialog used to host in a modal
is now embedded directly in the 'Beneficiary' section of the campaign
page. The big primary button becomes 'Open in wallet' and links to
the same BIP-21 URI as the inline QR.

Extracts BeneficiaryDonatePanel as the reusable body; the dialog
keeps wrapping it for the multi-beneficiary case where each row's
Donate button still opens a modal.

Regression-of: 69929fc0
2026-05-18 19:34:50 -05:00
Alex Gleason 69929fc00d Route logged-out single-beneficiary donate button to per-beneficiary dialog 2026-05-18 19:26:52 -05:00
Alex Gleason 83b4290e62 Show recipient name and avatar in beneficiary donate dialog 2026-05-18 19:09:30 -05:00
Alex Gleason e8bf01b149 Replace animal-name fallback with "Anonymous" 2026-05-18 19:03:41 -05:00
filemon fc950865c4 Fix post menu URL overflow, campaign slug ellipsis, and zoom control jump
- NoteMoreMenu: use overflow-wrap-anywhere so long URLs break safely
  within the 3-line post preview instead of overflowing the dialog
- CreateCampaignPage: add min-w-0 to the slug truncate span and show
  trailing '...' when the slug was clipped to the 64-char limit
- index.css: move Leaflet top offset from margin-top on individual
  controls to top on the .leaflet-top container, preventing the
  visual jump that occurred when Leaflet re-rendered controls on zoom
2026-05-18 20:43:05 -03:00
Alex Gleason 043d70fbe0 Strip donate-beneficiary dialog to QR + address + open-in-wallet
The previous version was overbuilt. Drop the title, description,
recipient identity strip, and the heads-up alert. Keep the QR code,
the copyable address, and the Open-in-wallet button. Remove the
Bitcoin icon from the Donate trigger on the campaign page too.

The required-by-Radix DialogTitle / DialogDescription are kept but
moved to sr-only so screen-reader users still get context.
2026-05-18 18:40:31 -05:00