User-visible copy now matches the Organization rebrand. Internal
symbols, file names, query keys, routes, and storage keys are
intentionally left alone for this pass — they're still pinned to
"community" / "communities" until a dedicated rename commit.
Touched strings:
- `MobileBottomNav` and `sidebarItems` labels: "Communities" →
"Organize", matching the existing TopNav copy.
- `CommunityDetailPage` hero fallback ("Unnamed Community" →
"Unnamed Organization") and "About this organization" aria-label.
- `CommunityContent` thumbnail fallback name.
- `ExternalContentHeader.CommunityPreview` row label and fallback name.
- `NoteCard` kind-34550 noun "community" → "organization" (used in
feed-card action lines like "created an organization") and the
article switches from "a" to "an".
- `NoteMoreMenu` overflow-menu labels: "Report post to community" →
"Report post to organization", "Remove from community" → "Remove
from organization".
- `BanConfirmDialog` title, description, and success/failure toasts.
- `CommunityContentWarning` reporter pluralization and the
fallback report-type label ("community guidelines" →
"organization guidelines"); reporters are now scoped to founder /
moderators per the commit 4 cleanup, so the wording reflects that.
- `CommunityReportDialog` description copy.
- `CreateGoalDialog` placeholder example.
- `CreateActionDialog` org-scoped description string.
- `CreateCommunityEventDialog` NIP-31 `alt` tag prefix.
- `CommentContext` kind-34550 entries in the action-noun and
rendered-noun maps ("a community" / "community" → "an
organization" / "organization").
- `extraKinds` kind-34550 entry: label, description, and blurb.
- `kindLabels` kinds 4550, 10004, 34550.
- `DiscoverHero` ticker stat copy.
- `GetFeedTool` error message drops "communities" since the
Following feed no longer includes organization activity (removed
in the badge-runtime commit).
Removes NIP-58 badge-award membership validation from Agora's
organization model. Authorization collapses to two roles:
- Founder = author of the kind 34550 event (only the founder can edit
organization metadata, enforced by replaceable-event semantics).
- Moderators = `p` tags with role "moderator" on that event (can hide
content via kind 1984 content-bans; cannot edit the org).
There is no "member" tier any more. The kind 8 / kind 30009 chain
that previously gated discussion access, the members-only feed
filter, the avatar-stack member count, and member-ban moderation
actions are all gone.
What changes:
- `useCommunityMembers` returns `{ founderPubkey, moderatorPubkeys }`
read directly from the parsed community event. The kind 8 badge
query, `resolveMembership`, `isAuthorizedAward`, and the rank-1
member tier are removed. The hook still queries kind 1984 events
scoped to the org so content-bans and soft reports keep working.
- `applyCommunityModerationToEvents` keeps content-ban filtering for
founder/mods. `resolveCommunityModeration` is simplified to a
single pass — only founder/moderators can publish moderation
actions, and the parsed report classification drops the
`member-ban` action since wholesale member bans no longer exist.
- `BanConfirmDialog` drops `mode="member"`. Only event-level content
bans remain. `NoteMoreMenu` drops the "Ban from community"
affordance accordingly.
- `ParsedCommunity` loses `memberBadgeATag` and
`memberBadgeRelayHint`. `parseCommunityEvent` no longer reads the
`['a', '30009:…', '', 'member']` tag. `CreateCommunityPage` no
longer mints a "Member of <org>" badge or attaches the member-badge
tag, and on edit it strips any pre-existing member-badge tag so
legacy wiring doesn't linger.
- The "Voices from everywhere" cross-organization activity feed is
deleted from the Organize page. It duplicated per-org activity (now
served by the official-activity shelves on each org detail page)
and was the only consumer of `useCommunityActivityFeed`.
`useFollowingFeed` drops its community-feed leg for the same
reason. `useMyCommunities`, `useMembersOnlyFilter`, and
`MembersOnlyToggle` go with it.
- `CommunityDetailPage` loses the MembersOnlyToggle in the hero, the
"Add members" and "Edit badge" overflow-menu items, and the
per-member ban affordance in the members dialog. The avatar stack
now renders founder + moderators with a "Founder + N moderators"
label and the dialog title becomes "Leadership".
- Deleted: `AddMemberDialog`, `CommunityBadgePanel` (which exported
`CommunityBadgeEditorDialog`), `useCommunityActivityFeed`,
`useMyCommunities`, `useMembersOnlyFilter`, `MembersOnlyToggle`,
plus three already-unused legacy components (`CommunityChatPanel`,
`CommunityPulsePanel`, `CreateCommunityDialog`) and
`useCommunityChatMessages`. `PersonSearch` is extracted from the
deleted `AddMemberDialog` into its own file so the create-campaign
and create-community forms keep their member-picker UI.
The standalone NIP-58 badge tooling (`BadgesPage`, `GiveBadgeDialog`,
`useBadgeFeed`, etc.) is untouched — it's a separate feature surface
from organization membership and continues to work.
Out of scope: file/symbol renames (Community* → Organization*).
That's the next commit.
Updates the Organize page (/communities) for the organization-first
direction:
- All user-visible "Community" copy becomes "Organization" — page
title, hero subtitle, section headers, empty states, logged-out
prompts, members-only filter messaging, and the create-organization
CTA. The route, file name, and internal symbols are unchanged for
this commit; the codebase-wide rename is the next pass.
- "My organizations" replaces "My communities". Wired to
`useManageableOrganizations` instead of `useMyCommunities`, so the
shelf reflects the same trust model used for implicit org tagging in
the create flows — organizations the user either founded (author of
the kind 34550 event) or is listed as a moderator on (`p` tag with
role "moderator"). The badge-award member-validation path is no
longer surfaced here.
- "Featured organizations" replaces "Discover communities". Backed by
a new `useFeaturedOrganizations` hook with a hardcoded list of
curator-approved kind 34550 event IDs in
`FEATURED_ORGANIZATION_EVENT_IDS`. Pinning by event ID locks the
card to a specific revision; bumping a featured org means swapping
in a newer ID. This is intentionally a stopgap until a configurable
featuring mechanism (AppConfig field, NIP-51 list, etc.) lands.
- The hero stat ticker still rotates donations / orgs / countries, but
the "orgs" entry now counts featured organizations instead of all
discoverable communities.
`useMyCommunities` and `useDiscoverCommunities` stay in place for now
because `useCommunityActivityFeed` and `DiscoverPage` still depend on
them. The badge-award removal pass will follow.
Restructures the org detail page body to match the rhythm already
established on /campaigns/:naddr and /pledges/:naddr:
- Container widens from `max-w-3xl` to `max-w-6xl` and the hero gets
the same `sm:aspect-[21/9]` treatment so it doesn't feel cramped at
the wider viewport width.
- The tab strip is removed entirely. Activity was the only visible tab
and the Pulse / Chat triggers were already hidden. With one section
left there's no reason to keep the `<Tabs>` wrapper or its plumbing
(`activeTab`, `setActiveTab`, `fabAvailable`).
- Below the hero: the existing Donate / Share action row, then the
official-activity shelves, then a new pledge-style engagement card
(stats counters + `<PostActionBar>`), then a NIP-22 comments section.
- No funding progress bar — an organization isn't a fundraising target
itself. Campaigns continue to provide that for fundraising.
- Comments render in the same `rounded-2xl bg-card border` frame the
pledge page uses, with the same "No comments yet" dashed empty
card. The legacy interleaved "initiatives + discussion" feed is
gone — official campaigns / pledges / events now live in the
shelves, and the comments section is purely NIP-22 replies.
- Adds `useEventStats`, `InteractionsModal`, `NoteMoreMenu`, and a
separate `replyOpen` slot for the engagement bar's Reply button
(the FAB's "New post" item keeps its own `composeOpen` slot so the
two entry points don't interleave state).
- Removes the now-unused `<CommunityChatPanel>`, `<CommunityPulsePanel>`,
`<ComposeBox>`, `<NoteCard>`, and `<FeedCard>` imports, along with
the `useCommunityGoals` / `useCommunityActions` / `useCommunityEvents`
hooks and the chain of derived state (`moderatedGoals`, `activeGoals`,
`pastGoals`, `eventItems`, `actionEvents`, `activeInitiatives`,
`pastInitiatives`, `activityItems`) that fed the old feed. Those
components still exist in the codebase for now; commit 4 will prune
the badge-award member runtime separately.
The FAB is now always available (single-column page) and routes
`New campaign` / `New pledge` to their dedicated create pages with
`?org=<naddr>`. `New event` still opens the in-page calendar dialog
since there's no dedicated create page for that yet.
The org detail page now surfaces three horizontally-scrolling shelves
above the activity feed:
- Campaigns (kind 30223)
- Pledges (kind 36639)
- Upcoming events (NIP-52 kinds 31922 / 31923)
All three shelves are powered by the useOrganization* hooks added in
5b5e8fe8, which author-filter to founder + moderators before querying
by the org's uppercase `A` root-scope tag. Anyone can publish an
event with an org's `A` tag, so the author filter is the actual
trust boundary that decides what counts as "official" activity for
that organization.
Each shelf is suppressed entirely when it has nothing to show, so an
org with no campaigns/pledges/events keeps the discussion feed at the
top of the viewport.
The activity-tab FAB now routes campaign and pledge creation to the
dedicated create pages with `?org=<naddr>` in the query string,
closing the loop with the implicit-tagging change from 8bef15a3. The
in-page `CreateGoalDialog` and `CreateActionDialog` are removed —
zap goals are being deprecated in favor of campaigns, and pledges now
go through the dedicated create page. The calendar-event dialog stays
in-page since there's no dedicated create page yet; it already emits
the uppercase `A` tag.
Pulse and Chat tab triggers are hidden from the tab strip while the
organization-first redesign is in progress. The corresponding
`<TabsContent>` panels are left intact so the code paths stay
verified — only the trigger affordances are suppressed.
The stats row had a `pb-2` and the PostActionBar received a `pt-3`
className when stats were present — leftover spacing from when a
horizontal divider sat between them. With the divider removed the
padding became an unjustified gap above the action bar.
Drop both so the action bar sits flush against the stats row with
even top/bottom spacing inside the engagement card, matching the
no-divider look the campaign details page already has when stats
are absent.
Restore the `pt-3 border-t border-border/60` divider/padding
that separates engagement counts from the action bar, identical
to the campaign details page. The earlier `pt-3` without the
border left dead space; with the border back, the two pages
share the same visual rhythm.
Pledge cards render their cover image with a built-in fallback:
when `action.image` is unset or fails to load, they fall back to
`DEFAULT_COVER_IMAGE`. The detail-page hero only handled the
unset case (with a gradient + icon) and never recovered from a
broken URL.
Mirror the card pattern in the hero: render an `<img>` with an
`onError` handler that swaps in `DEFAULT_COVER_IMAGE` on load
failure, so pledges without a cover (or with a dead one) still
look intentional instead of blank.
Remove visual clutter from the pledge detail page:
- Drop the "Pledge" badge and description preview from the hero
overlay so the cover image and title carry the framing.
- Remove the divider between engagement counts and the action bar.
- Drop the "Remaining" and "Stored as sats" tiles from the
funding card — donors only need funded vs. pledged totals.
The hero cover image is unchanged; it sources `action.image`
sanitized by `sanitizeUrl`, which falls back to the gradient
placeholder only when the event has no `image` tag.
Pledges are now open-ended by default — drop the optional start
date input and stop publishing the legacy `start` tag. The kind
36639 `created_at` already marks when the pledge becomes active.
Hide the built-in cover image templates so the upload area is the
only path for cover art, encouraging pledgers to supply their own
visuals. The thumbnail strip remains available via the
`templates` prop on `CoverImageField` for other surfaces.
Rename the description heading from "Story" to "Description"
to avoid implying a narrative expectation, and label the optional
tag input as "Recommended" to nudge categorization.
Mirror the same changes in `CreateActionDialog` (the community
scoped variant) and mark `start` as legacy in `NIP.md`.
Reverts the user-controlled OrganizationSelector added in b3997ec5.
Selecting an organization from inside a create form was the wrong
affordance: if a user starts a pledge or campaign from outside any
organization, the publication is implicitly under their own identity,
and if they start it from inside an organization, the org tag is
already implied by where the create flow was launched.
CreateCampaignPage and CreateActionPage now read `?org=<naddr|aTag>`
from the URL instead of rendering a selector field. When the param is
present, useManageableOrganizations checks whether the current user is
the founder or a moderator of that org; if so, the form emits the
`A` / `K` / `P` tags on publish. If not, the param is silently
dropped (so a stale or copy-pasted link can't forge an org-tagged
event), and a small note explains the publication is going under the
user's account.
OrganizationContextChip is a tiny shared component that surfaces the
resolved org as a non-interactive chip under the form title. The
organization detail page CTAs (next commit) will populate `?org=`
when launching the create flow from inside an org.
OrganizationSelector.tsx is removed — no other call sites.
Adds an optional Organization selector to the create-campaign and
create-pledge forms. Only NIP-72 communities where the current user
is the founder or a listed moderator are offered, so the resulting
event's uppercase `A` root-scope tag lines up with the trust filter
applied by useOrganizationCampaigns / useOrganizationPledges.
Technically anyone could publish a kind 30223 or 36639 with another
org's `A` tag outside the Agora client. Restricting the selector
inside the client is the first line of defense; the author-filter in
the org-detail queries (next commits) is the actual security boundary.
Changes:
- useManageableOrganizations queries kind 34550 events the user
founded (`authors`) or is moderator-tagged on (`#p`), then keeps
only the ones where they're founder OR moderator.
- OrganizationSelector renders those orgs in a popover combobox with
Founder / Moderator badges, plus a 'No organization' option.
- CreateCampaignPage and CreateActionPage gain a new optional 'Publish
under organization' field. On publish, the form emits
`A: 34550:<pubkey>:<d>` plus the `K` and `P` companion tags.
- CreateCampaignPage hydrates the selector from an existing campaign's
`A` tag when editing.
Existing campaigns and pledges without an `A` tag continue to work
unchanged.
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.
"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.
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.
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.
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.
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.
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.
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.
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
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.
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.
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.
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.
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).
- 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).
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.
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.
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).
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+.
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.
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.
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.
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.