Compare commits

...

300 Commits

Author SHA1 Message Date
mkfain 00509f979a Activist Guide: drop redundant 'state-level threats' and 'Private Mode tradeoffs' callouts
Both callouts repeated information already covered by the mode
comparison table directly above them (table rows 'Best for' and
'Watch out for' on the Private side carry the same content).
2026-05-21 14:27:39 -05:00
mkfain a7550f3e49 Activist Guide: move 'donation history' prose to directly below the mode table
The donor-history visibility note is most useful as immediate context
on what the table just compared, so it now sits right after the
modeComparison block instead of being the page's closing section.
2026-05-21 14:26:54 -05:00
mkfain 65e9bd72a1 Donor Guide: clarify Fast vs Private privacy risk in 'Donating privately'
- The warning callout above the OptionGrid now spells out the
  difference: Fast Mode publishes every donation to one persistent
  public address, while Private Mode hides the campaign's receiving
  side entirely. The donor's sending side still looks the same on
  chain in both modes, but Private Mode breaks the most direct link
  between donor and cause.

- The OptionGrid intro now opens with 'these steps matter most when
  donating to Fast Mode campaigns,' notes that the casual-observer
  risk is lower for Private Mode (no public donor list to enumerate),
  and reminds the reader that targeted analysis of their sending
  wallet is still possible in both modes, so high-sender-risk donors
  should still stack these privacy steps.
2026-05-21 14:25:03 -05:00
mkfain a0c3e34e14 Remove em dashes from FAQ and guide copy
Replaces every em dash inside user-facing FAQ and Donor / Activist guide
content with a period, comma, colon, or parenthesis depending on what
fits the sentence. Affects:

- src/lib/helpContent.ts: all FAQ answers (active and hidden legacy
  items) and both guide block templates (TLDR ledes, comparison
  footnotes, callout bodies, step bodies, OptionGrid item copy).
- src/components/guide/ModeComparisonTable.tsx: the donor and activist
  row content strings.

JSDoc comments inside source files are left alone (developer-facing,
never rendered).
2026-05-21 14:23:52 -05:00
mkfain f3b95157dc Redesign Donor and Activist guides as a typed block kit
Replaces 7 (donor) and 12 (activist) sequential prose Cards with a
small typed block kit that the page dispatches on. Each guide drops
roughly 40-50% of body text and trades walls of paragraphs for
scannable visual blocks.

helpContent.ts:
- Replace GuideSection with a GuideBlock discriminated union (tldr,
  steps, modeComparison, callout, optionGrid, prose).
- Rewrite the Donor Guide as 6 blocks: TLDR, 3-step flow, Fast vs.
  Private comparison, on-chain visibility warning, donate-privately
  OptionGrid, consumer-apps callout.
- Rewrite the Activist Guide as 8 blocks: TLDR, mode comparison
  (centerpiece), state-actor warning, Private Mode tradeoffs callout,
  2-step move-funds flow, cash-out OptionGrid (now includes Bitrefill
  for direct gift-card spending), avoid-tumblers callout, brief prose
  on donation history visibility.
- Voice shifts to direct second-person across both guides.

New components in src/components/guide/:
- InlineModeBadge - pill that visually distinguishes Fast (primary
  tint) and Private (indigo tint).
- GuideTLDR - hero-adjacent two-column summary with a lede and 2-3
  checkmark next-actions.
- GuideSteps - numbered vertical step list.
- CalloutCard - tinted (info/warning/danger/success) icon callouts.
- ModeComparisonTable - 3-column grid on desktop; collapses to two
  stacked tinted cards on mobile (no sideways scroll). Audience flag
  switches between donor and activist row copy.
- OptionGrid + inline OptionCard - 2-up grid of compact tiles with
  chips and optional external link.
- GuideProse - small escape hatch.
- index.ts barrel.

Pages:
- DonorGuidePage.tsx and ActivistGuidePage.tsx now just fetch their
  block array and dispatch each block to the right component via a
  small switch.
- Body width tightened from max-w-3xl to max-w-2xl for line length;
  block spacing increased from space-y-4 to space-y-6.

Deleted:
- src/components/GuideSectionCard.tsx (no remaining consumers).

renderInlineMarkup, GuideHero, and the FAQ data shape are unchanged.
2026-05-21 14:16:24 -05:00
mkfain bd8e0b5c5c FAQ copy edits: trim wording, drop sign-in question, expand Lightning answer
- Remove the 'Why is my sign-in so different and long?' question entirely.
- send-bitcoin-onchain: drop the 'Agora doesn't hold or forward the
  money' clause; the campaign-address line stands on its own.
- connect-wallet: drop 'not a personal wallet shared across Agora'.
- what-are-silent-payments: drop 'non-custodial' from the bridge
  description.
- silent-payments-supported: cut the second paragraph about non-custody
  / one-shot addresses / Agora not holding spendable funds.
- private-mode-reliability: drop the stale-address / maintenance-window
  examples; soften 'can't guarantee' to 'can't 100% guarantee' and cut
  the 'honest cost of privacy' line.
- choose-mode-as-activist: drop 'in plain English.'
- why-not-lightning: replace the Cash-App/Coinbase paragraph with a
  framing about every Bitcoin holder + add a paragraph on cash-out
  reliability (on-chain reliably converts to spendable money; Lightning
  liquidity is patchy and custodial Lightning wallets can freeze
  withdrawals).
2026-05-21 13:59:34 -05:00
mkfain 4d827e01f4 Style Donor/Activist Guide buttons as campaign-style cover cards
The two Help page guide buttons now mirror CampaignCard's structure: a
16:9 cover image on top with a soft hover scale, a rounded icon badge
floating in the top-left corner, and title + description in the body.

Donor Guide reuses the World Liberty Congress hero image already used
on the Donor Guide page; Activist Guide reuses the 'Raised Fists' image
that opens the Activist Guide's hero gallery, so each button visually
previews its destination.
2026-05-21 13:49:55 -05:00
mkfain 4c32b93f5e Remove 'Read this first' alert from Help page
The amber state-actor warning at the top of /help duplicated copy that
now lives in the Fast Mode section of the Activist Guide and would read
as overly conservative once Private Mode campaigns exist. The two large
guide buttons below it already point readers at the long-form content.
2026-05-21 13:42:18 -05:00
mkfain 45dae078ac Update FAQs and guides for Fast Mode / Private Mode donations
Introduces per-campaign privacy-mode framing across all donor- and
activist-facing help content, ahead of the campaign-creation UI that
will actually expose the choice.

- FAQ (src/lib/helpContent.ts):
  - Rewrite send-bitcoin-onchain, connect-wallet, and
    donations-are-public-general to reflect per-campaign addresses
    (not npub-derived) and the two-mode model.
  - Replace why-not-silent-payments with silent-payments-supported
    (yes, via a non-custodial bridge that lets donors keep using any
    Bitcoin wallet).
  - Replace why-not-rotating-addresses with fast-vs-private-mode and
    add what-are-silent-payments, private-mode-reliability, and
    choose-mode-as-activist.
  - Soften censorship-resistance and why-onchain to acknowledge the
    bridge dependency in Private Mode without overclaiming.

- Donor Guide (src/lib/helpContent.ts):
  - Fold the silent-payments donor-side note into how-donating-works;
    flag the fresh-address-per-visit behaviour so donors don't read it
    as a phishing attempt.
  - Update why-public to clarify that donor-side advice still applies
    on Private Mode campaigns.

- Activist Guide (src/lib/helpContent.ts):
  - Rewrite how-receiving-works to introduce the per-campaign choice.
  - Add choose-receive-mode (with pros/cons), when-to-use-which,
    fast-mode-warning (absorbs the deleted page-level alert), and
    private-mode-failure-modes (concrete reliability disclosures).
  - Qualify why-public, dont-keep-funds, cashout-overview, and
    donors-can-be-seen with mode-specific framing. Cash-out sections
    themselves are unchanged.

- Page chrome (src/pages/DonorGuidePage.tsx, ActivistGuidePage.tsx):
  - Remove the amber 'Recommended for above-ground activism' alert from
    both pages. The state-actor warning now lives in
    fast-mode-warning, where it's contextually accurate (Private Mode
    is the actual mitigation, not 'read the sections below').
  - Drop the now-unused Alert/AlertTriangle imports.

No product code or campaign-creation flow is touched in this commit -
this is content-only, ahead of the UI work.
2026-05-21 13:40:30 -05:00
Mary Kate 25ef304e42 Merge branch 'fix/wallet-recovery-warning-contrast' into 'main'
Fix wallet recovery warning contrast in light mode

Closes #24

See merge request soapbox-pub/agora!29
2026-05-21 14:48:41 +00:00
Lemon 590e592cf0 Merge branch 'ui/fix-home-page-contrast' into 'main'
ui/fix-home-page-contrast

See merge request soapbox-pub/agora!30
2026-05-21 01:08:03 -07:00
sam 7dc1afc5a1 fix white text on light background 2026-05-21 01:03:13 -07:00
sam a42522dda2 mid light/dark coloured gradient/shadows that thus contrast on light and dark mode 2026-05-21 01:02:56 -07:00
lemon 9a5d3e56fe Replace Discover with Agora feed 2026-05-21 00:50:14 -07:00
lemon fe43906cf1 Remove mobile FAB bottom-nav offset 2026-05-21 00:47:38 -07:00
lemon 0d1d782437 Refine feed composer and FAB placement 2026-05-21 00:43:25 -07:00
lemon 7f93dcb3af Make home feed Agora-only 2026-05-21 00:38:50 -07:00
lemon 3b9eef908f Tag default notes as Agora 2026-05-21 00:30:37 -07:00
lemon 948e6b70b6 Ignore spammy Agora hashtag author 2026-05-21 00:20:27 -07:00
lemon afe2bf1c28 Enlarge feed backdrop globe 2026-05-21 00:17:19 -07:00
lemon ae3daef072 Add Agora feed tab 2026-05-21 00:15:04 -07:00
lemon da6cab8784 Use Agora logo for compose FAB 2026-05-20 23:52:56 -07:00
lemon 2722ee1dcd Restore feed compose FAB 2026-05-20 23:48:26 -07:00
lemon 475843cd27 Add feed content filter layer 2026-05-20 23:37:45 -07:00
lemon fd97b76fbb Make feed surfaces transparent 2026-05-20 23:36:14 -07:00
lemon 587d7eb5ba Extend feed wash across viewport 2026-05-20 23:32:49 -07:00
lemon 5c6b9b3baf Soften feed backdrop transitions 2026-05-20 23:28:17 -07:00
lemon f6947aca9b Refine feed globe backdrop 2026-05-20 23:21:36 -07:00
lemon 4df6197a9a Add globe backdrop to feed 2026-05-20 23:19:55 -07:00
filemon 559a52f46f Fix wallet recovery warning contrast in light mode
The amber warning alert used bg-amber-500/5 (5% opacity — nearly invisible)
and text-amber-900. The faint background combined with potential
tailwind-merge ambiguity between the Alert variant's text-foreground and
the override text-amber-900 resulted in poor readability in light mode.

Align with the established BitcoinPublicDisclaimer pattern:
- bg-amber-500/5  → bg-amber-50        (solid visible amber tint)
- text-amber-900  → text-amber-950      (darkest amber for max contrast)
- border-amber-500/50 → border-amber-300/60 + dark:border-amber-500/30
- dark:bg-amber-950/50 for a distinct dark-mode background
- Icon: !text-amber-500 → !text-amber-600 dark:!text-amber-400

No wallet logic, sweep behavior, or component structure changed.
2026-05-20 20:42:39 -03:00
mkfain c4778471bb Clarify that RoboSats is Lightning-only; needs Boltz swap first
RoboSats trades Lightning Bitcoin, not on-chain, so a user cashing
out from their Agora address can't drop straight into it. Update the
Donor Guide ('use non-KYC Bitcoin'), the Activist Guide
('Peer-to-peer exchange'), and the cash-out comparison row to spell
out the two-step path: Boltz to swap on-chain → Lightning, then
RoboSats to trade for fiat. Bisq and HodlHodl still trade on-chain
directly and are listed separately.
2026-05-20 16:41:32 -05:00
Chad Curtis a3964662fa Merge branch 'feat/organize+pledge' into 'main'
Communities -> Organizations & Actions -> Pledges

See merge request soapbox-pub/agora!27
2026-05-20 21:16:27 +00:00
mkfain 97cf2763a5 Reframe 'why no rotating addresses' around money-transmitter risk; move Monero last
The previous answer to 'Why doesn't Agora generate a new address for
every donation?' focused on the single-point-of-failure angle. Lead
with the bigger reason: rotating addresses would mean Agora has to
take custody of the Bitcoin before forwarding it on to activists,
which makes Agora a money-transmitting service. That brings
regulatory exposure and creates a real chokepoint someone could
shut down to stop every donation flowing through the platform.

Also reorder the Bitcoin Donations section so 'Why not Monero or
another cryptocurrency?' sits as the very last item, after the
silent-payments and rotating-addresses explanations.
2026-05-20 16:05:12 -05:00
Alex Gleason 0bb55ebb97 Merge branch 'main' of gitlab.com:soapbox-pub/agora 2026-05-20 15:36:57 -05:00
mkfain 5983583388 Drop guide hero eyebrow; promote 'Donor Guide' / 'Activist Guide' to headline
The eyebrow label in the top-right of the guide hero was redundant
with the headline beside it. Cut the eyebrow prop and use the page
name ('Donor Guide' / 'Activist Guide') as the actual headline. The
'Back to Help' chip stays on its own row at the top of the hero.
2026-05-20 15:33:37 -05:00
mkfain b1d4237bee Give Donor and Activist guides photo heros and expand payment Q&A
Replace the plain sticky 'Back to Help' bar on the two guide pages
with a hero section in the same recipe as the Organize and Actions
homepage heros: rotating photo banner (HeroBanner) + atmospheric tint
(HeroAtmosphere) + scrims + overlay copy. Sub-page sized — ~280px
instead of the 460px homepage heros — and embeds a glassy
'Back to Help' chip in the eyebrow row so navigation out stays
prominent without a separate sticky strip.

- Donor Guide reuses the World Liberty Congress photos in /public/hero
  with the cool palette: reads as community / supporters.
- Activist Guide reuses the protest cover gallery from
  DEFAULT_ACTION_COVERS with the warm hope palette: reads as people in
  motion.

Both pages drop into the new shared GuideHero component to keep the
two pages DRY.

FAQ updates in the Bitcoin Donations section:

- 'Why on-chain Bitcoin?' now spells out that on-chain donations
  require zero extra setup for activists who already have a Nostr
  account and zero extra setup for donors who already hold Bitcoin —
  the accessibility argument that makes Agora viable for normal
  people.
- 'Why doesn't Agora use Lightning?' now names Strike and Breez
  alongside Wallet of Satoshi as examples of the popular custodial
  Lightning wallets that can be shut down or pressured.
- New 'Why not Monero or another cryptocurrency?' item: Bitcoin's
  adoption is what makes it easiest for donors to send and activists
  to receive and spend; niche coins create a barrier neither side
  should have to clear.
2026-05-20 15:33:37 -05:00
mkfain a20a91de0d Broaden consumer-app examples in Donor Guide and add sticky guide nav
The Donor Guide and a few related FAQ items singled out Cash App as
the example of a custodial consumer Bitcoin app. Replace those with a
broader list — Cash App, Coinbase, Strike, Venmo, PayPal, Kraken,
Binance — so it doesn't read as picking on one product. The Bisq
Pros/Cons and the 'What consumer apps can't do' section heading
follow the same edit.

Also move the 'Back to Help' navigation on the Donor and Activist
guide pages from a bottom button into a sticky top bar, PWA-style. It
sits right under the TopNav (top-16, z-30) and stays visible while
scrolling so users can return to /help from anywhere on the page
without scrolling back up. Replaces the previous PageHeader, which
hid its back arrow on desktop and made navigating out of the guides
awkward.
2026-05-20 15:33:37 -05:00
mkfain be262fe0d6 Cut 'What is Agora for?' and the entire Network & Safety FAQ section
The 'What is Agora for?' item duplicated 'What is Agora?' from the
About Agora section. Drop it.

The Network & Safety section was carrying Nostr-protocol explainers
(feed, relays, Blossom, Mastodon/Bluesky comparison, profile fields,
reporting) that aren't core to Agora's donation flow. Drop the whole
section from the visible FAQ.

The six IDs in that section that other pages reference via HelpTip
(fyp, what-are-relays, what-are-blossom, report-content, vs-mastodon-
bluesky, profile-fields) move into the existing hidden 'Legacy'
category alongside the Lightning/zap stubs, so the call sites on
NetworkSettingsPage, ContentSettingsPage, ContentPage, SearchPage,
RelayListManager, and ProfileSettings continue to resolve.
2026-05-20 15:33:37 -05:00
mkfain 8f065379a0 Merge Bitcoin Donations FAQ section and drop Lightning/zap content
The 'Bitcoin Donations' and 'About Bitcoin Payments on Agora' FAQ
sections were saying overlapping things in two places. Combine them
into a single 'Bitcoin Donations on Agora' section, placed in the
higher slot (right after 'About Agora').

Agora's donation flow is on-chain only, so Lightning and zap items are
removed from the visible FAQ. The two IDs that other pages reference
via HelpTip (send-bitcoin-lightning in ZapDialog, what-are-zaps in
ProfileSettings) are kept in a new hidden 'Legacy' category so those
call sites don't break. HelpFAQSection skips hidden categories on the
default render but the per-ID lookup used by HelpTip still finds them.

Also adds a 'Back to Help' button at the bottom of both the Donor
Guide and Activist Guide pages so users don't have to scroll back up
to the header to navigate out.
2026-05-20 15:33:37 -05:00
mkfain 99e4fd0406 Trim Help FAQs to core Agora features and rename categories
The FAQ accordion was carrying a lot of generic Nostr-client content
(profile field formats, app-store availability, Mastodon/Bluesky
comparisons, marketing copy) that wasn't relevant to Agora's core: on-
chain Bitcoin donations to activists.

Restructure into four focused categories:

- 'About Agora' (formerly 'Getting Started') — what Agora is, what
  Nostr is, key management, cost.
- 'Bitcoin Donations' — how sending works, the wallet, zaps,
  censorship resistance.
- 'Network & Safety' — relays, Blossom, reporting, profile fields.
- 'About Bitcoin Payments on Agora' (formerly 'About Agora') — the
  design-rationale Q&A added in the previous commit (why not
  Lightning / silent payments / rotating addresses).

All FAQ item IDs referenced by HelpTip on other pages are preserved
(connect-wallet, fyp, profile-fields, what-are-zaps, vs-mastodon-
bluesky, send-bitcoin-onchain, send-bitcoin-lightning, what-is-nostr,
what-are-relays, what-are-blossom, report-content, censorship-
resistance) — their content has been rewritten to be relevant to
Agora's donation flow without breaking the callers.

Also moves the 'Need help? Meet Team Soapbox' follow-pack card from
the top of the page to the bottom, after the FAQ.
2026-05-20 15:33:37 -05:00
mkfain e7f7d9419d Add Donor and Activist guide pages with privacy-focused content
Help page now opens with an amber disclaimer that Agora is recommended
only for above-ground activism, followed by two large buttons routing to
new /help/donors and /help/activists guide pages.

The guides cover how on-chain donations work on Agora, why they're
publicly visible on the Bitcoin blockchain and Nostr, and the main paths
for protecting donor privacy or cashing out privately (non-KYC purchase,
coinjoin, Lightning swaps via Boltz, peer-to-peer exchanges like Bisq
and RoboSats). Each tradeoff section is rendered as Pros/Cons bullets.

Also adds an 'About Agora' FAQ category to the existing accordion
covering the design rationale for not using Lightning, silent payments,
or server-rotated addresses.

The inline-markup renderer used by FAQ answers is extracted to
src/lib/helpMarkup.tsx so it can be reused by the guide pages.
2026-05-20 15:33:37 -05:00
Alex Gleason 907fdc1b70 Soften the campaign donation public-ledger notices
Drop the destructive treatment from the disclaimer on campaign donation
surfaces — donating isn't itself dangerous, just publicly traceable, so
red + alert icon + a hard checkbox gate overstated the risk.

Add a 'soft' tone to BitcoinPublicDisclaimer (amber, no icon, no role=
alert) and an includeCashOutAdvice flag so the popover can omit the 'or
cash out at an exchange' line — relevant for the wallet (donor holds
sats) but not for a campaign donor sending money away.

The wallet's Send dialog keeps the destructive variant with the
acknowledgement checkbox unchanged.
2026-05-20 15:05:12 -05:00
lemon d0e8c5b64b Align organize feed with discover layout 2026-05-20 12:44:28 -07:00
lemon 904df8a776 Fix organization activity pagination 2026-05-20 12:32:57 -07:00
lemon c085c2017f Tighten organize page organization queries 2026-05-20 12:32:57 -07:00
lemon c322f2796a Limit featured organizations author list 2026-05-20 12:32:57 -07:00
lemon 2a66968198 Restore organization activity feed 2026-05-20 12:32:57 -07:00
lemon 5831013baf Show followed organizations on Organize 2026-05-20 12:32:57 -07:00
lemon 5d7547f70b Query featured organizations by author 2026-05-20 12:32:57 -07:00
lemon b9e82da61e Deduplicate organization publishing helpers 2026-05-20 12:32:57 -07:00
lemon 90ebc19e79 Sanitize organization avatar URLs 2026-05-20 12:32:57 -07:00
lemon 9f9271cd64 Keep organization activity caches fresh 2026-05-20 12:32:57 -07:00
lemon c494750efd Reduce organization activity relay load 2026-05-20 12:32:57 -07:00
lemon 1cf3646b2a Restrict organization create panel 2026-05-20 12:32:57 -07:00
lemon ce95c6c12c Refine organization create panel 2026-05-20 12:32:57 -07:00
lemon c35a5b942c Combine organization activity rail 2026-05-20 12:32:57 -07:00
lemon 5f3af5e206 Expand organization event cards 2026-05-20 12:32:57 -07:00
lemon 39949bb439 Match organization pledge card design 2026-05-20 12:32:57 -07:00
lemon 146d569b88 Clarify event organization context 2026-05-20 12:32:57 -07:00
lemon 68e62ae705 Add event creation page 2026-05-20 12:32:57 -07:00
lemon b3b9e73c9f Add organization create panel 2026-05-20 12:32:57 -07:00
lemon ac3cdf34b2 Rename user-facing 'Community' → 'Organization'
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).
2026-05-20 12:32:57 -07:00
lemon a68cad44c3 Drop badge-award member runtime
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.
2026-05-20 12:32:57 -07:00
lemon 743390edf7 Rebrand Organize page: My organizations + Featured organizations
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.
2026-05-20 12:32:57 -07:00
lemon 558e5affea Align organization detail page layout with campaign and pledge pages
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.
2026-05-20 12:32:57 -07:00
lemon ec7d7f4326 Add official-activity shelves to organization detail page
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.
2026-05-20 12:32:57 -07:00
lemon 3520c0ad6b Fix orphan spacing above pledge details action bar
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.
2026-05-20 12:32:57 -07:00
lemon c4f52b8aa7 Match pledge action bar to campaign details
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.
2026-05-20 12:32:57 -07:00
lemon df44b1b2c6 Drop primary-tag badge from pledge card cover 2026-05-20 12:32:57 -07:00
lemon 8c6721a3fc Match pledge hero cover handling to the card
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.
2026-05-20 12:32:57 -07:00
lemon fb7676b760 Simplify pledge detail hero and funding card
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.
2026-05-20 12:32:57 -07:00
lemon f97792b81e Drop deadline helper subtext from pledge form 2026-05-20 12:32:57 -07:00
lemon f3c9a74b0f Simplify pledge creation form
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`.
2026-05-20 12:32:57 -07:00
lemon 248b07f45c Drop org selector from create forms; derive org from ?org= query param
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.
2026-05-20 12:32:57 -07:00
lemon a759e653de Let campaigns and pledges publish under an organization
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.
2026-05-20 12:32:57 -07:00
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
Alex Gleason 77db5965a9 Add per-beneficiary donate dialog on campaign page
Each beneficiary listed on a campaign page now has a Donate button
next to it. Clicking it opens a dialog that shows the beneficiary's
Bitcoin (Taproot) address — derived from their Nostr pubkey — as a
scannable BIP-21 QR code and a copyable address.

This is distinct from the existing campaign DonateDialog, which
splits across all recipients. The new dialog targets a single
beneficiary directly, so it intentionally skips amount entry and
the campaign-tally flow.
2026-05-18 18:38:17 -05:00
filemon 2f8569c302 Fix five small UI polish issues
- Push Leaflet zoom controls below the sticky header on /world so they
  remain clickable instead of sitting behind the 64px top bar.
- Replace plain <a> tags with React Router <Link> in the site footer so
  navigation to /help, /privacy, /safety, and /changelog is client-side
  instead of triggering a full page reload.
- Use plain-text preview with line-clamp-3 in the post more-menu instead
  of NoteContent + a conflicting max-h hard clip, so long content
  truncates with an ellipsis rather than cutting off abruptly.
- Switch the campaign detail Donate/Share row from a rigid 4-column grid
  to flex so the Share button gets its natural width instead of being
  cramped into 25% of the row on mobile.
- Make the campaign URL preview in /campaigns/new truncate with an
  ellipsis for long slugs instead of overflowing or clipping silently.
2026-05-18 20:09:36 -03:00
Alex Gleason 7465ad01d4 Hide top-nav Start Campaign button when logged out 2026-05-18 17:45:40 -05:00
Alex Gleason ab9e8bfcd6 Hide campaign progress bar when no goal is set 2026-05-18 16:55:45 -05:00
Alex Gleason d71d6de05f Publish a single kind 8333 receipt per donation tx
A single Bitcoin transaction with N outputs now produces a single kind
8333 onchain-zap event listing every recipient under its own `p` tag,
instead of one event per recipient. The `amount` tag carries the total
sats paid to the listed recipients (the full donation, excluding the
donor's change).

This is straight-forward forward-compatibility: legacy single-recipient
events are just the degenerate case (one `p` tag, amount equal to the
one recipient's slice). Aggregators (`useCampaignDonations`,
`useGlobalDonations`) simplify to summing the `amount` tag across every
matching event — under both schemas an event's `amount` is the total
paid to the recipients listed in that event, so the sum across all
events for a campaign is the campaign total either way.

The verifier (`verifyOnchainZap`) now sums tx outputs paying any listed
recipient's derived Taproot address and strips the sender from the
recipient set so a tx that includes the sender plus legitimate
recipients still verifies. The notifications surface uses a new
`getZapAmountSatsForRecipient` helper to attribute only the viewer's
estimated slice (amount / p_count) rather than crediting them with the
full multi-recipient donation. `CampaignDetailPage` keeps its
group-by-(txid, donor) reply rendering so legacy multi-event donations
still collapse to a single donation card.
2026-05-18 16:36:32 -05:00
filemon f9eec18adb Merge branch 'main' into dashboard-ui-polish 2026-05-18 16:10:50 -03:00
filemon ba08d749ac Remove sidebar add/remove button from dashboard header 2026-05-18 15:55:44 -03:00
filemon 6eccacc06a Update getStableCount comment to reflect current usage
The comment said it was not used in participants, but it now is.
2026-05-18 14:59:48 -03:00
Chad Curtis 8feaccf5dd Increase world map default zoom and recenter on Atlantic 2026-05-18 12:55:18 -05:00
Chad Curtis 1ac62aac06 Add photo banner heroes to organize and actions 2026-05-18 12:52:54 -05:00
lemon 041979de07 Tighten mobile nav spacing 2026-05-18 10:48:44 -07:00
lemon 59556406a8 Move mobile footer links below CTA 2026-05-18 10:47:04 -07:00
lemon 58bb3046e7 Reorganize sidebar navigation 2026-05-18 10:43:49 -07:00
filemon 45242292d6 Use stable COUNT floor for participants list counts
Apply getStableCount to the participants/full-state-list section so
per-state numbers are consistent with the leaderboard and distribution
chart. Previously the participants list used raw event-based counts
while the other sections used NIP-45 COUNT floors, causing visible
mismatches (e.g. Miranda showing 847 in the list but 859 in the donut).

In municipalities view this is a no-op — getStableCount returns the raw
feed.count unchanged since there are no per-municipality COUNT queries.

Live/activity indicators still derive from loaded events.
2026-05-18 14:32:50 -03:00
lemon ef9adb29e8 Update primary navigation 2026-05-18 10:30:15 -07:00
lemon 1d5f0541d7 Clone discover hero ticker 2026-05-18 10:26:04 -07:00
lemon 0671910e67 Match organize hero ticker style 2026-05-18 10:24:51 -07:00
filemon 7bb960b6b3 Align dashboard header with content column and add bottom spacing
Pass max-w-5xl mx-auto sm:px-6 to PageHeader via its className prop so
the title and action buttons sit inside the same centered column as the
dashboard body.  This is a local override — the shared PageHeader
component is not modified.

Add pb-8 to the content container for comfortable breathing room between
the last dashboard section and the footer.
2026-05-18 14:24:20 -03:00
lemon e71d95fcc6 Refine organize hero copy 2026-05-18 10:23:43 -07:00
lemon 84496d30a1 Align organize hero with discover 2026-05-18 10:17:35 -07:00
filemon a0ca42af26 Polish dashboard page to match app design system
Normalize the dashboard layout and card styling for visual consistency
with the rest of the app. No data fetching or behavioral changes.

Page container:
- Widen content from max-w-4xl to max-w-5xl (justifies noMaxWidth opt-out)
- Add responsive padding (px-4 sm:px-6)
- Replace non-standard pb-24 with pb-16 sidebar:pb-0
- Add min-h-screen to prevent short-page footer ride-up
- Error state now uses the same max-w-5xl container (no layout jump)

Header actions:
- Replace Trash2 icon with PanelLeftClose for sidebar toggle (less alarming)
- Convert sidebar toggle from outline button with text to ghost icon button
- Add aria-label attributes for accessibility
- Tighten gap from gap-2 to gap-1.5 for compact icon-button row
- Move statusBadge/headerActions above the error early-return so both
  code paths share the same header

Chart cards (ActivityChart, TopRegionsChart, DistributionDonut):
- Replace raw rounded-2xl border divs with shadcn Card/CardHeader/CardContent
- Picks up consistent rounded-lg, bg-card, shadow-sm, and standard padding

List cards (ParticipantsList, RecentActivityList):
- Normalize from rounded-2xl to rounded-lg with bg-card and shadow-sm
- Preserve overflow-hidden and custom internal grid layouts

Skeleton:
- Add tabs placeholder skeleton
- Use Card/CardHeader/CardContent for chart skeletons
- Normalize table skeleton wrapper to match new card styles

Tabs:
- Add bg-muted/50 to TabsList for subtle visual grounding
2026-05-18 14:14:32 -03:00
lemon 9905d39e19 Redesign communities landing page 2026-05-18 10:14:21 -07:00
lemon e91f4a2c63 Move help to account menu 2026-05-18 09:55:18 -07:00
lemon 98976c9ce9 Add search nav link 2026-05-18 09:53:50 -07:00
lemon b1e0bcda63 Add organize nav link 2026-05-18 09:50:53 -07:00
lemon 634e161085 Remove ephemeral geo chat 2026-05-18 09:49:31 -07:00
lemon ae41290b68 Hide blocked campaign 2026-05-18 09:45:30 -07:00
lemon 77b35995eb Align top nav controls 2026-05-18 09:41:59 -07:00
lemon 94bcf23b68 Improve account menu 2026-05-18 09:37:46 -07:00
lemon 847b2f2f00 Update Agora navigation 2026-05-18 09:33:44 -07:00
lemon 1eace996f5 Simplify community tab selector 2026-05-18 00:22:48 -07:00
lemon 9d4116b478 Polish donation success state 2026-05-18 00:20:38 -07:00
lemon c281764bd9 Reuse donation dialog for communities 2026-05-18 00:16:49 -07:00
lemon a3e3202f21 Use review step for community zaps 2026-05-18 00:10:43 -07:00
lemon 09dac639c9 Improve donation review details 2026-05-18 00:01:50 -07:00
lemon b3bdf69d61 Polish community and donation flows 2026-05-17 23:58:31 -07:00
lemon 59fd1b2d14 Make campaign submit full width 2026-05-17 23:12:13 -07:00
lemon de26235621 Remove campaign form subtext 2026-05-17 23:12:13 -07:00
lemon f4f07ce91f Remove empty beneficiary placeholder 2026-05-17 23:12:13 -07:00
lemon 93d00ea4c0 Polish campaign deadline input 2026-05-17 23:12:13 -07:00
lemon 2571f9d216 Polish campaign goal input 2026-05-17 23:12:13 -07:00
lemon f665ffa0c0 Flatten campaign form details 2026-05-17 23:12:13 -07:00
lemon 58ca29fb62 Inline beneficiary notify action 2026-05-17 23:12:13 -07:00
lemon a4e785e574 Use canonical invite links 2026-05-17 23:12:13 -07:00
lemon f3b277bc23 Share campaign recipient invites 2026-05-17 23:12:13 -07:00
lemon 42b901d769 Polish campaign country selector 2026-05-17 23:12:13 -07:00
lemon ba2c541c31 Add country tags to campaigns 2026-05-17 23:12:13 -07:00
Chad Curtis 735de6ece9 Treat Tibet as country-level across post + country chrome
Extends the prior Tibet/CountryFlag work so the Snow Lion flag wins
everywhere a CN-XZ post or page surfaces, not just in the Discover
country pulse strip.

  - Comment context (NoteCard + PostDetailPage)
      * Country pill on the card header swaps in the SVG via
        CountryFlag (was bare emoji span).
      * Pill hover card uses the subdivision's own name, drops the
        parent-country sub-line, and labels it as 'Country' rather
        than 'Region' for codes with a custom flag.
      * CountryFlagBackdrop (the faded full-bleed flag behind a
        country-rooted note) prefers the bundled SVG over the
        Wikipedia lead image, which for Tibet returns an
        administrative map.

  - PostDetailPage 'country above the post' chip
      * CountryPreview now routes through CountryFlag and prefers
        info.subdivisionName when a custom flag is registered, so
        the chip reads as 'Tibet' instead of 'China'.

  - Country page (/i/iso3166:CN-XZ)
      * Hero banner driven by customFlagAsset(code) when present,
        sharing the same <img>+skyOverlay pipeline as Wikipedia
        photos so the day/night tint and bottom fade still apply.
      * Subline beneath the title no longer falls into the
        'subdivision = show parent country' branch for custom-flag
        codes; it now reads the Wikipedia description / official
        name like other countries do.
      * Big flag slot uses CountryFlag too, bypassing the Wikipedia
        subdivision thumbnail.

  - Helpers split out of CountryFlag.tsx into src/lib/customFlags.ts
    (hasCustomFlag, customFlagAsset) so the component file only
    exports a component — fixes the react-refresh warning that came
    out of the first pass.

  - Action cards (feed + detail) now render their country chip
    through CountryFlag, picking up the SVG for any future Tibet-
    tagged action.
2026-05-17 23:39:23 -05:00
Chad Curtis e5f7ece942 Bring back Tibet as a country with Snow Lion flag SVG
The older Pathos/Agora codebase treated CN-XZ as country-level Tibet
with a bundled Wikimedia Snow Lion SVG (commits f03d2400, 351b3be4,
6e04b80d). That fell out somewhere in the port — restore it.

  - public/flag-tibet.svg recovered verbatim from f03d2400.
  - New CountryFlag component centralises the country-flag rendering
    decision: emoji for everyone Unicode covers, bundled SVG for the
    short list of recognised flags that don't have an emoji
    codepoint (Tibet today, room for more later).
  - CountryPulseStrip special-cases CN-XZ as country-level: renders
    'Tibet' (not 'Tibet Autonomous Region, China') and drops the
    XZ subdivision-token badge.

Also adds the subdivisionFlag() helper for RGI tag-sequence
subdivisions (England, Scotland, Wales) — Unicode actually does
ship those, and the strip now picks them up automatically.

Other Unicode-missing subdivisions (US states, Canadian provinces)
still render as parent country flag plus a typographic ISO 3166-2
badge. They have no emoji codepoint and bundling a flag pack for
every state is out of scope for this change.
2026-05-17 23:39:23 -05:00
Chad Curtis 5ebc988190 Narrow /discover content widths
Cap the Discover page at max-w-5xl (down from max-w-7xl) so the
hero, country pulse, and shelves stop sprawling on widescreen
displays. Tighten the mixed feed below to max-w-2xl so each row
reads at a comfortable line length, the same reading column width
as the rest of Agora's NoteCard feeds — while the horizontal
shelves keep their wider canvas above.
2026-05-17 23:39:23 -05:00
Chad Curtis 53cc92d9d0 Make /discover the public square: globe, country pulse, mixed feed
The 'Discover' nav link used to drop visitors on /feed — a plain
kind-1 timeline that didn't connect any of the three things Agora is
actually about. This wires a new /discover page that weaves them
together while the old plain feed stays put at /feed.

Page composition:

  - DiscoverHero — reuses the hand-drawn HeroGlobe but reframes it
    around the world itself, not any one campaign. Three marker
    layers (campaign hearts, community rings, country-pulse dots)
    sit on the same sphere, the HOPE_PALETTE slowly drifts every 9s,
    and a rotating ticker pill surfaces immutable network-wide
    stats: total sats raised on-chain, communities online, countries
    posting today.
  - CountryPulseStrip — horizontal strip of country flag chips
    ordered by trailing-window activity from the trusted kind 30385
    snapshots. Click opens /i/iso3166:XX.
  - 'Help raise hope' — horizontal CampaignCard shelf.
  - 'Find your people' — horizontal CommunityMiniCard shelf.
  - 'Voices from everywhere' — useDiscoverFeed infinite timeline
    mixing new campaigns, country posts, community comments, and
    Agora actions, rendered with the kind-appropriate card.

HeroGlobe gains an optional GlobeMarkerKind on each marker so the
campaigns page keeps its hearts-only behaviour while Discover layers
in rings and warm dots.

New hooks:
  - useDiscoverCommunities — global kind 34550 discovery
  - useDiscoverFeed — paginated mixed feed (30223 + 1111 + 36639)
  - useGlobalDonations — network-wide kind 8333 aggregate for the
    hero ticker
2026-05-17 23:39:23 -05:00
Alex Gleason 5c3dc851bc Merge branch 'main' of gitlab.com:soapbox-pub/agora 2026-05-17 23:05:55 -05:00
Alex Gleason 4e8cf62418 Drop redundant Wallet header from /wallet
The PageHeader served no purpose beyond labeling the page: the wallet
UI sits right under it with the balance front and center, the mobile
bolt now opens this route directly, and the page title still wires
through useSeoMeta for the browser tab. Removing it tightens the
mobile layout and saves vertical space above the balance.
2026-05-17 23:04:37 -05:00
Alex Gleason 883b5b5760 Point bottom-nav bolt at /wallet
The apex bolt button in the mobile bottom nav previously routed to the
user's configured home page (defaulting to /), duplicating the Feed
sidebar entry. With /bitcoin folded into /wallet there's no longer a
prominent path to the wallet from mobile, so wire the bolt directly to
/wallet. Tapping it while already on /wallet scrolls to top; the old
feed-cache invalidation no longer applies.
2026-05-17 23:02:13 -05:00
Alex Gleason ff671bda39 Init bitcoinjs-lib ECC eagerly in main.tsx
nostrPubkeyToBitcoinAddress and the PSBT build helpers call
bitcoin.payments.p2tr / Psbt.sign, which require an ECC library to be
registered via bitcoin.initEccLib() first. The lazy init lived inside
getECPair(), which is only reached on the signing path — so render-time
callers (WalletPage, SendBitcoinDialog) blew up on first paint with
'No ECC Library provided'.

Ditto initializes ECC eagerly in main.tsx; agora's bitcoin.ts came from
that port but the main.tsx side never did. Add it.

Regression-of: 9190f62b
2026-05-17 22:56:21 -05:00
Alex Gleason 9190f62b9e Consolidate /bitcoin into /wallet, drop Lightning custody
Agora previously shipped two parallel wallets: a heavy 6,400-line Breez
SDK Lightning wallet at /wallet and a lightweight on-chain Taproot view
at /bitcoin derived from the user's Nostr pubkey. Maintaining two key
custody models, two send flows, two zap paths (Lightning via Spark,
on-chain via PSBT), and the Spark-specific UI (CreateWallet, mnemonic
backup/restore, lock screen, payment history, etc.) didn't pay for itself
once on-chain Bitcoin signing via NIP-07/NIP-46 became viable.

This consolidation aligns Agora with Ditto's wallet model:

  - The on-chain Taproot view from /bitcoin becomes the only /wallet UI.
  - /bitcoin redirects to /wallet for back-compat; sidebar and TopNav
    drop the duplicate Bitcoin entry.
  - The Breez/Spark wallet stack is removed: SparkWalletProvider,
    SparkWalletContext, all of src/components/SparkWallet/*, useSparkWallet,
    useCommunityBatchZaps, usePaymentContext, WalletSettingsContent, and
    LightningEffect are deleted (~6,400 lines).
  - Ditto's mature bitcoin/zap stack is ported: useOnchainZap (single-event
    on-chain zaps + kind 8333 receipts), OnchainZapContent, ZapDialog with
    Bitcoin/Lightning tabs, ZapSuccessScreen, BitcoinContentHeader, and the
    larger SendBitcoinDialog. useZaps loses its breezService branch and
    falls back to NWC → WebLN → manual QR.
  - bitcoin.ts now threads esploraBaseUrl through every call, matching
    AppConfig and allowing future relay/Esplora customization.
  - CommunityZapDialog is bitcoin-only; CommunityDetailPage drops the
    sibling Lightning trigger.

Lightning recovery remains intentional. A small "Looking for your old
wallet?" link on /wallet routes to /wallet/recovery, which lazy-loads
@breeztech/breez-sdk-spark (now in its own 67 KB chunk plus the WASM)
only when a user needs to evacuate funds. The recovery page:

  - Auto-detects the NIP-78 kind-30078 d="spark-wallet-backup" relay
    backup and offers one-click NIP-44 decrypt via the user's signer.
  - Accepts a manual 12-word mnemonic as fallback.
  - Connects Breez in-memory, sweeps the entire on-chain balance to the
    user's Nostr-derived Taproot address, then disconnects. Nothing is
    persisted; the old wallet is never "restored" — only evacuated.

Other small carry-overs from Ditto needed by the ported code:
useFormatMoney + AppConfig.currencyDisplay ("usd" | "sats"), and the
nostrId helper (HexId branded type + isNostrId validator).

48 files changed, 2,464 insertions(+), 9,743 deletions(-).
2026-05-17 22:53:20 -05:00
Chad Curtis 707b24f41e Raise hero globe minimum size so it doesn't shrink as much 2026-05-17 22:46:50 -05:00
Chad Curtis 7e93dcba6c Stop hero globe from snapping back on spotlight change
The rotation rAF loop was being keyed by `useEffect([markers,
ringSizes, selectedKey])`. Each time `HeroCampaignSpotlight` cycled
to the next campaign, `selectedKey` changed, the effect tore down,
the new effect re-initialized `start = null`, and the elapsed-time
calculation snapped rotation back to 0°.

Hold `markers` and `selectedKey` in refs that the rAF loop reads
on each frame, and drop them from the effect's dep list so the loop
runs uninterrupted for the lifetime of the component.

Also dresses the hero's primary CTA up as an Apple-style liquid glass
pill: faint warm-tinted translucent body (white→amber→rose at low
opacity), heavy backdrop blur, hair-thin inner edge, soft warm-tinted
drop shadow. Hover lifts the tint and shadow a hair without changing
the pill's character — no specular streaks, no halo, no shadow
bloom. Slightly taller than the default `size=lg` (h-12, px-7,
text-base) so it reads as primary without feeling chunky.
2026-05-17 21:53:28 -05:00
Chad Curtis 42abac7527 Polish hero into hopeful beacon with per-campaign atmosphere
- Anchor the globe's center to the right edge of the `max-w-7xl` content
  container (matching the TopNav account switcher), nudged inward via a
  percentage translate so a substantial slice of the sphere always reads
  inside the hero regardless of viewport width.
- Drop the per-breakpoint width classes in favor of a fluid
  `clamp(360px, 46dvw, 820px)` so the globe scales smoothly with the
  viewport instead of in three discrete jumps. HeroGlobe accepts a
  `style` prop so the page can pass the clamp() inline.

Make the globe feel like a beacon of hope:

- New outer halo div behind the SVG with a wide hue-tinted radial glow,
  heavy blur, and a slow opacity-only breathing animation
  (`hero-globe-halo-breath`, 6.5s) so the layout never shifts.
- Sphere base gradient warmed from cream/cool-earth to dawn-gold/honey
  — the disc reads as 'lit from within' instead of dirt-colored.
- Outer dark rim swapped for a soft back-lit limb light tinted with the
  active hope hue. Narrow band, low opacity — suggests atmosphere
  rather than a neon ring.

Tie the globe to the surrounding atmosphere:

- New `src/lib/hopePalette.ts` exports a curated set of warm sunrise /
  dawn hues (`scrim`, `glow`, `rim` per entry) plus
  `hopeHueFor(seed)` that deterministically hashes a string (e.g. the
  campaign aTag) to a stable palette entry.
- New `HeroAtmosphere` mounts a fresh layer of tinted gradients each
  time the active seed changes and crossfades over 1.5s to match
  `CampaignHeroBackground`. Uses `mix-blend-mode: screen` so it warms
  the photo instead of flattening it.
- `HeroGlobe` takes the active `HopeHue` so the halo and limb tint
  agree with the rest of the hero.

Layer order is now: photo BG → atmosphere → globe → readability scrim →
content. The scrim sits *above* the globe so it can darken whatever
slice of the sphere ends up behind the headline, and is hidden at lg+
where the globe is already pushed outside the headline column.
2026-05-17 21:53:28 -05:00
mkfain 937da49cd7 Drop templated reply on /claim, copy npub only
Per design feedback, the npub card on /claim's empty state no longer
offers a templated reply message — just a single 'Copy my npub'
action backed by the same click-to-copy npub block above it.
2026-05-17 21:34:31 -05:00
mkfain a26269ebbe Show npub copy card on /claim empty state
Freshly-signed-up invitees who reach /claim with no campaigns yet now
see a primary card with their own npub formatted for copy. Two copy
actions:

- 'Copy reply message' (templated: "I finished setting up my Agora
  account! My npub is: npub1… — you can add me as a beneficiary now.")
- 'Copy npub only' for pasting into an existing thread.

The old 'No campaigns found yet' card stays as a secondary, demoted
'Expecting a campaign already?' message below the npub card.

Pure UX — no URL params, no inviter awareness, no analytics ping back.
The invitee still sends the message manually through whatever channel
the invite originally came from.
2026-05-17 21:32:57 -05:00
Chad Curtis 0fcce88409 Nudge the hero globe ~10% left at each breakpoint 2026-05-17 21:15:05 -05:00
Chad Curtis 0ea1e55ee4 Slow the hero globe to ~140s per revolution 2026-05-17 21:15:05 -05:00
Chad Curtis 2c8cd11153 Rebuild campaigns hero around photo BG + globe + spotlight
The hero is now layered like Treasures' HeroGallery:

- CampaignHeroBackground (new) — full-bleed banner image from the
  currently-spotlit campaign, crossfading over ~1.5 s and panning left.
  Warm tint + film grain overlay so foreground text stays legible.
- HeroGlobe — pushed to the right edge with a larger radius, slightly
  translucent so the photo bleeds through. Hearts replace the old dots
  for marker symbols; clicking one selects that campaign.
- HeroCampaignSpotlight (new) — minimal text overlay anchored to the
  bottom-left of the hero container (title, summary, avatar + author,
  location, progress bar with goal, 'View' link). No card chrome.

Land polygons are now the full Natural Earth 110m fidelity (~10.5k
vertices) instead of being heavily Douglas-Peucker'd, so coastlines
look organic rather than chunky. Back-hemisphere rings are now
properly hidden by walking each edge and either dropping back-side
vertices outright or interpolating to the sphere limb where a ring
crosses it — fixes the 'phantom continents through the front' bug.
Rings additionally fade in/out over a narrow z-band near the limb
instead of popping at z = 0.

Markers also have proper z-fade and pull off-canvas when on the back
so they can't intercept clicks they aren't visible for. Selected
markers scale 1.35x with a stronger glow so the user can tell which
campaign the spotlight refers to.

Other cleanup:

- formatCampaignAmount + formatSatsShort move out of CampaignCard.tsx
  into src/lib/formatCampaignAmount.ts so CampaignCard stops failing
  the react-refresh/only-export-components lint.
- Hero CTAs drop the 'Unstoppable fundraising on Nostr' pill and the
  em dash from the supporting copy.
- New keyframes (heroPanLeft / heroPanRight) for the slow Ken-Burns
  pan on the background photos, with prefers-reduced-motion respected.
2026-05-17 21:15:05 -05:00
Chad Curtis 2a69747744 Add slow-spinning globe behind campaigns hero
Drops the 'Unstoppable fundraising on Nostr' pill and the em dash from the
hero copy, and adds an ambient SVG globe sitting behind the headline.

The globe is a pure-SVG orthographic projection (no WebGL, no canvas). It
renders Natural Earth 110m country boundaries pre-simplified down to ~1.5k
vertices (17 KB inline). Coloring is intentionally warm — cream sphere with
sandy-amber land — to avoid the satellite/HUD aesthetic. Campaigns whose
location string resolves to an ISO 3166-1 country appear as small glowing
markers, deduped by country.

Rotation is driven by requestAnimationFrame and applied imperatively via
refs (no React re-renders during animation), and respects
prefers-reduced-motion by holding at a static angle.
2026-05-17 21:15:05 -05:00
mkfain 48881677b5 Add /receive and /claim landing pages + invite/notify shortcuts
When creating a campaign, organizers can now invite or notify
beneficiaries directly from the recipient picker:

- 'Recipient not here yet? Invite them' button below the search box
  copies a templated message that links to /receive — a signup-focused
  landing page that pitches Agora to people who don't have a Nostr
  account yet, then redirects them to /claim after onboarding.

- Each selected recipient row gains a 'Send {name} a message about
  this campaign' button that copies a templated message linking to
  /claim — a sign-in landing page that shows the user every campaign
  whose 'p' tag includes their pubkey (using a new recipientPubkeys
  option on useCampaigns that adds an #p filter).

Both landing pages live outside FundraiserLayout so they read as
standalone marketing/landing screens, matching the FollowPage pattern.
Copy templates match the spec wording from the request.
2026-05-17 21:13:08 -05:00
mkfain c9f3a304e6 Frame logged-out donate flow as a chooser, log-in path first
The previous behavior sent logged-out single-recipient donors straight
into the external-pay (BIP-21 QR) view, which is the lossy path —
externally-paid donations never publish a kind 8333 receipt, so they
don't count toward the campaign goal or show up in the donor list.

Now the dialog opens on a LoggedOutChooserView that presents the
ideal path first:

1. **Log in & donate** (recommended, highlighted card). Opens the
   standard LoginDialog inline; on success the outer DonateDialog
   re-renders into the normal donate form.
2. **Donate to {Recipient} directly** — secondary option, only shown
   for single-recipient campaigns. Copy makes the tradeoff explicit:
   the recipient still receives the funds, but the donation won't
   count toward the campaign goal.

Multi-recipient campaigns hide the secondary option (the split
fundamentally needs the donor's signed PSBT) and explain why.

ExternalPayView gains an optional onBack so users can return to the
chooser without closing the dialog.
2026-05-17 20:50:03 -05:00
mkfain ad364e4b19 Let logged-out donors pay single-recipient campaigns externally
The split-PSBT flow legitimately needs a Nostr signature, but a campaign
with one recipient is just a regular Bitcoin payment — no reason to gate
that on a Nostr login. For single-recipient campaigns, the DonateDialog
now opens an ExternalPayView for logged-out users (and for logged-in
users whose signer can't build PSBTs) with:

- the recipient's Taproot address (Nostr-pubkey-derived) with copy
- a QR code embedding a BIP-21 `bitcoin:<addr>?amount=<btc>` URI
- optional USD amount input that drops into the URI as BTC
- copy URI / open-in-wallet actions
- a heads-up that externally-paid donations won't appear in Agora's
  donor list or progress bar, since no kind 8333 receipt is published

Multi-recipient campaigns still require login (the split needs the
donor's signed PSBT to construct one tx with N outputs).
2026-05-17 20:37:23 -05:00
mkfain f413d29fa1 Remove duplicate close X in mobile hamburger menu
The mobile nav drawer in TopNav has its own X button inside the panel
header, but SheetContent was also rendering the shadcn primitive close
button just outside the panel — two X buttons for the same sheet.

Add an opt-in `hideClose` prop to SheetContent and set it on the
TopNav drawer. Other Sheet consumers (MobileDrawer, etc.) keep the
default built-in close.
2026-05-17 20:30:45 -05:00
mkfain 323c613222 Add archive flow for campaigns
Authors can soft-close a campaign by republishing it with a
`["status", "archived"]` tag. Archived campaigns are hidden from the
main fundraisers feed and the donate button is disabled, but the detail
page still loads by direct link so existing donors can find it and
past donations remain attached. The author sees Archive / Reopen
buttons on the detail page and an Archived badge on cards.

useCampaigns gains an `includeArchived` option (default false) so a
future profile view can opt in. NIP.md documents the new status tag.
2026-05-17 20:20:44 -05:00
mkfain babfbc5b10 Round campaign USD amounts to whole dollars
Cents are visual noise on zap goal progress displays. Add satsToUSDWhole
helper and use it in CampaignCard, CampaignDetailPage, and the goal
preview in CreateCampaignPage. Wallet and send-bitcoin flows continue to
use satsToUSD with cents.
2026-05-17 20:11:14 -05:00
lemon 640a8328cf Polish campaign editing form 2026-05-17 18:03:46 -07:00
lemon e56523b819 Add campaign editing flow 2026-05-17 18:03:46 -07:00
lemon 177caded5c Refine campaign creation form 2026-05-17 18:03:46 -07:00
Chad Curtis d9d99d6b0b Point Discover nav link to /feed 2026-05-17 19:55:26 -05:00
lemon 7dbfc31f04 Render zaps with comment card layout 2026-05-17 17:40:21 -07:00
lemon fb6f157c42 Align zap cards with comments 2026-05-17 17:40:21 -07:00
lemon 3504a24be5 Realign zap card with the regular comment layout
The zap card was an ActivityCard with a chunky amber circle in
the avatar slot and a compact ActorRow up top. That visual
language reads as 'an activity log entry' which clashes with the
NIP-22 comments alongside it on the campaign page. Rebuild it
to mirror NoteCard's normal layout exactly:

- Donor avatar takes the standard size-11 (size-10 in threaded
  mode) slot, with a small amber zap badge anchored bottom-right
  to keep the kind signal.
- Author block uses the same font-bold name + nip05 + timeAgo
  stack as a regular note, with the verb ('donated' / 'sent')
  and amount inlined on the name row so the card reads as one
  sentence.
- For campaign targets the amount is followed by 'to <Campaign
  Title>' where the title is a clickable Link to the naddr —
  same routing the regular CommentContext header would use.
  Resolved via a single useAddrEvent call gated on the receipt's
  a-tag so non-campaign zaps incur no fetch.
- The donor comment renders below the author row as muted italic
  text — same spot a normal note's body sits — instead of being
  tucked under the actor row.
- Action bar uses the shared {actionButtons} JSX with the exact
  same spacing as a comment, so reply / repost / react / zap /
  share / more line up vertically across cards in the thread.

The amber Zap import and ProfileHoverCard / Avatar imports were
already in scope, so no new imports beyond useAddrEvent.
2026-05-17 17:40:21 -07:00
lemon f49c20787e Polish zap cards and clamp the campaign story
Four related changes:

1. Campaign story now clips to three lines (~4.5rem) behind a
   soft fade overlay, with a Read more / Show less toggle.
   When there's no story yet the empty placeholder renders
   unclipped as before.

2. Zap cards in NoteCard gained the same reply/repost/react/
   share action bar as a regular note. Each kind 9735 / 8333
   event is a valid Nostr event in its own right, so NIP-22
   replies target it directly via its event id and reactions
   bind to it the same way.

3. The 'zapped' label is now 'donated' when the receipt's a-tag
   points at a kind 30223 campaign, and 'sent' otherwise. The
   target kind is read from the addressable coordinate; pure
   e-tag Lightning zaps fall back to 'sent' without a fetch.

4. Zap amounts render in USD when a BTC→USD price is cached,
   with the raw sats string moved to the title tooltip. Falls
   back to sats when the price hasn't loaded. A new useBtcPrice
   hook shares the existing 'btc-price' cache key so all
   call sites (NoteCard, CampaignCard, useBitcoinWallet) dedupe
   to one in-flight request.
2026-05-17 17:40:21 -07:00
lemon 39fed90296 Trim campaign donate card
- Drop the extra divider above the action bar; PostActionBar
  already carries its own border-t/b.
- Remove hex pubkey lines under organizer and beneficiary names;
  show the NIP-05 instead when available, otherwise just the name.
- Drop the Donors section entirely. Donations now appear inline
  in the comments thread, so a separate list is redundant.
- Relocate the organizer attribution from a dedicated card section
  to a small 'by {name}' link next to the title in the hero
  overlay. Same subtle styling, just a less intrusive spot.
2026-05-17 17:40:21 -07:00
lemon 760e11138d Show donation receipts in campaign comments
Three changes work together so campaign pages reflect how
campaigns actually receive support — via on-chain donations,
not Lightning zaps:

1. NoteCard's zap-receipt layout now renders kind 8333 in
   addition to kind 9735. The helpers (getZapAmountSats,
   getZapSenderPubkey) already branched correctly; only the
   isZap gate and the amount/message extraction were 9735-only.

2. PostActionBar gained a hideZap prop. Campaigns set it so the
   action bar shows only reply / repost / react / share — a
   generic Lightning zap is the wrong CTA when the campaign
   has its own donation flow.

3. CampaignDetailPage interleaves kind 8333 donation receipts
   into the comments thread, sorted by created_at alongside
   kind 1111 comments. Each donation produces one receipt per
   beneficiary, so we dedupe by (txid, donor) and rewrite the
   canonical receipt's amount tag to the summed total so the
   card shows the full donation rather than one share.
2026-05-17 17:39:39 -07:00
lemon 534b8f0102 Add comments and reactions to campaign pages
Campaigns (kind 30223) now expose the standard PostActionBar
(reply/repost/react/zap/share/more) plus a NIP-22 threaded
comments list, mirroring how PostDetailPage handles other
addressable kinds. A stats row above the action bar opens the
existing InteractionsModal.
2026-05-17 17:39:39 -07:00
lemon 5cb4c9f950 Align campaign support section 2026-05-17 17:39:18 -07:00
lemon e37552c8ce Refine campaign support card 2026-05-17 17:39:18 -07:00
lemon 3af32e167c Simplify campaign support card 2026-05-17 17:39:18 -07:00
lemon 3927a50633 Combine campaign support sections 2026-05-17 17:39:18 -07:00
lemon 0712034720 Clarify campaign organizer and recipients 2026-05-17 17:39:18 -07:00
lemon c0a23061ee Move beneficiaries above campaign story 2026-05-17 17:38:27 -07:00
lemon 44be9e6e35 Promote campaign donation panel 2026-05-17 17:38:27 -07:00
lemon fa813ed084 Restyle campaign category meta 2026-05-17 17:38:27 -07:00
lemon 2c58a7b0fd Tune campaign hero actions 2026-05-17 17:38:27 -07:00
lemon 532ff57c29 Overlay campaign hero details 2026-05-17 17:38:27 -07:00
lemon f99c1d0b17 Avoid duplicate campaign story image 2026-05-17 17:38:27 -07:00
lemon 703bb6d3ab Use people search for campaign beneficiaries 2026-05-17 17:38:27 -07:00
lemon 162d4eee43 Make campaigns USD-first 2026-05-17 17:38:27 -07:00
Alex Gleason 810cbfba00 Set default featured campaigns 2026-05-17 19:34:51 -05:00
Chad Curtis 3e5af1922d Build campaign creator link with useProfileUrl
The 'organized by' link on the campaign detail page was using a raw
hex pubkey URL (/<hex>). Switch to useProfileUrl so the link prefers
the creator's verified NIP-05 identifier when one is available and
falls back to the npub otherwise — same pattern the rest of the app
uses for profile navigation.
2026-05-17 19:00:25 -05:00
Chad Curtis 3a540ffaa1 Point TopNav mobile drawer Profile link at the user's npub
The mobile drawer's Profile link was hardcoded to /profile, which has
no route and fell through to the catch-all NIP19Page. nip19.decode
threw on 'profile' and the profile page rendered 'Please log in to
view your profile' even when the user was logged in.

Encode the current user's pubkey as an npub for the link target,
matching how every other profile link in the app is built.

Regression-of: 704cb42e
2026-05-17 19:00:20 -05:00
Chad Curtis 2b9ea24238 Resolve hex pubkey URLs on the profile page
NIP19Page renders <ProfilePage /> for raw 64-char hex identifiers that
relays resolve to a kind-0 author, but ProfilePage only knew how to
decode NIP-19 and NIP-05. The hex param fell through nip19.decode (which
throws), returned undefined, and the page rendered 'Please log in to
view your profile' — even for logged-in users visiting somebody else's
hex URL.

Accept raw hex pubkeys in the pubkey resolver, and replace the
misleading log-in copy with 'User not found' (the no-param case can't
reach this branch from the router anyway).

Regression-of: d58f4bb6
2026-05-17 19:00:14 -05:00
Chad Curtis 308f3098f3 Update VITE_SHARE_ORIGIN example to agora.spot
The example in .env.example still suggested ditto.pub as the canonical
share origin. Use agora.spot so contributors copying the example don't
end up generating share URLs that point at a different app.
2026-05-17 18:42:46 -05:00
Chad Curtis 77eee4f872 Switch credential domain and Android deep links from ditto.pub to agora.spot
The iOS Associated Domains entitlement, Android intent filters, AASA
file, and assetlinks.json already reference agora.spot. Three call
sites still hard-coded ditto.pub:

- CREDENTIAL_DOMAIN in src/lib/credentialManager.ts, which keys iCloud
  Keychain Shared Web Credentials by domain. Saved nsecs were being
  filed under ditto.pub and so could never be matched against the
  agora.spot AASA file.
- MainActivity.handleNotificationIntent host check, which only routed
  the WebView when the tapped notification's URI host equaled
  ditto.pub.
- NostrPoller.showNotification, which built notification PendingIntents
  pointing at https://ditto.pub/notifications.
2026-05-17 18:42:46 -05:00
Chad Curtis 1b4399df68 Rebrand app identifier and IPA name from Ditto to Agora
Renames the Capacitor app identifier from pub.agora.app to
spot.agora.app and cleans up Ditto-branded artifacts that don't refer
to upstream Ditto-the-project or Ditto-stack services.

App identifier (pub.agora.app -> spot.agora.app):
- capacitor.config.ts appId
- android applicationId, namespace, package_name string, custom_url_scheme
- iOS PRODUCT_BUNDLE_IDENTIFIER (Debug + Release)
- public/.well-known/assetlinks.json package_name
- public/.well-known/apple-app-site-association app id
- Info.plist BGTaskSchedulerPermittedIdentifiers and the matching
  Swift bgTaskIdentifier (previously mismatched: plist said
  pub.agora.app.notification-refresh, Swift said
  pub.ditto.app.notification-refresh, so background refresh would
  silently fail to register)
- src/lib/helpContent.ts Zapstore URLs
- .gitlab-ci.yml --package_name for fastlane supply

Android Java package (pub.ditto.app -> spot.agora.app):
- Move android/app/src/main/java/pub/ditto/app/ ->
  android/app/src/main/java/spot/agora/app/ (4 files: MainActivity,
  DittoNotificationPlugin, NostrPoller, NotificationRelayService)
- Update package declarations to match the new Android namespace
  (was a hard build failure with namespace = spot.agora.app)
- Update proguard -keep rule
- Update NotificationRelayService ACTION_FETCH intent string
  pub.ditto.app.ACTION_FETCH -> spot.agora.app.ACTION_FETCH

Fastlane (pub.ditto.app -> spot.agora.app):
- Appfile, Matchfile, Fastfile provisioning profile specifiers.
  Matchfile still points at Soapbox's certificates git repo; a new
  match repo with certs for spot.agora.app is required before iOS CI
  signing works.

IPA artifact name (Ditto.ipa -> Agora.ipa):
- Fastfile output_name and matching CI artifact paths
- .gitlab-ci.yml: artifacts/Ditto.ipa references and the GitLab
  Generic Packages path from /packages/generic/ditto/ ->
  /packages/generic/agora/ (matches how APK/AAB are already
  published). Existing release artifacts at the old path remain
  reachable; new releases land at the new path.

Release-notes script fallback (Ditto vX.Y.Z -> Agora vX.Y.Z):
- scripts/extract-release-notes.mjs fallback used as the App Store /
  Play Store 'What's New' blurb when a changelog section has no
  summary.

manifest.webmanifest:
- Update related_applications Play Store entry to spot.agora.app.
- Remove the iTunes related_applications entry that pointed at
  the existing Ditto App Store listing; not applicable to Agora
  until Agora has its own listing.

Capacitor sync incidentals:
- npm run cap:sync picked up @capacitor/barcode-scanner registration
  that had been missed in a prior plugin install
  (android/app/capacitor.build.gradle, capacitor.settings.gradle,
  ios/App/CapApp-SPM/Package.swift).

Intentionally NOT touched:
- ditto.json filename, DittoConfigSchema, DittoConfig, and JSDoc
  references to ditto.json. The config-system shape is shared with
  upstream Ditto by design.
- relay.ditto.pub, blossom.ditto.pub, ditto.pub/api/* and other
  Ditto-stack services Agora actively consumes.
- The DittoNotificationPlugin Android/iOS class name, the
  DittoNotification JS bridge name, ditto_notification_config
  SharedPreferences keys, ic_stat_ditto drawables, and the
  DittoBridgeViewController. Renaming requires a coordinated
  JS-side rename plus a SharedPreferences migration or existing
  users on the Ditto fork lose their notification config on upgrade.
- Ditto references in skill docs, NIP.md kind comments, README, and
  zapstore.yaml attribution \u2014 those correctly describe the upstream
  Ditto project that Agora forked from.

Follow-ups required before CI succeeds end-to-end (out of scope here):
- Stand up a new fastlane match git repo containing certs +
  provisioning profiles for spot.agora.app, or update Matchfile
  git_url to point at it.
- Register spot.agora.app in App Store Connect for team GZLTTH5DLM
  and create a new App Store listing.
- Create a new Google Play Console listing for spot.agora.app
  (package name is immutable per app on Play; the existing
  pub.agora.app listing cannot be reused).
- Re-publish to Zapstore under spot.agora.app so the URLs in
  helpContent.ts resolve.
2026-05-17 18:42:46 -05:00
Alex Gleason aacfb66e2c Restore MobileBottomNav across all screen sizes
The FundraiserLayout overhaul dropped the bottom nav along with the
rest of the Twitter-style chrome. Bring it back and unhide it above
the 900px sidebar breakpoint so the Search / Communities / Feed /
Notifications / World row is available on every viewport.

- Mount <MobileBottomNav /> in FundraiserLayoutInner, outside the
  flex column so its fixed positioning behaves normally.
- Drop the 'sidebar:hidden' class on the nav element.
- Pad the layout root by --bottom-nav-height + safe-area-inset-bottom
  so the SiteFooter still clears the fixed bar.

Regression-of: 704cb42e
2026-05-17 18:24:34 -05:00
Alex Gleason a1be35f1f2 Cap center column width app-wide in FundraiserLayout
The layout outlet had no max-width, so pages without their own `max-w-*`
wrapper (e.g. /help, the home feed) stretched edge-to-edge on widescreen
monitors. Add a default `max-w-3xl` cap on the center column and wire
the existing `noMaxWidth` and `wrapperClassName` LayoutOptions through,
so pages that need wider canvases keep working — CampaignsPage,
CampaignDetailPage, CreateCampaignPage, EventDashboardPage, and WorldPage
already opt out via `noMaxWidth: true` or the `fullBleed` preset.
2026-05-17 18:10:58 -05:00
Alex Gleason fe5a622998 Merge branch 'main' of gitlab.com:soapbox-pub/agora 2026-05-17 17:41:02 -05:00
Alex Gleason 0f1103a607 Reframe homepage hero around activist funding mission 2026-05-17 17:32:09 -05:00
Chad Curtis 8975d762ef Switch default AI model to google/gemma-4-26b 2026-05-17 17:31:13 -05:00
Alex Gleason 704cb42e99 Replace MainLayout with a top-nav-only FundraiserLayout
The previous overhaul left the campaigns content nested inside the
Twitter-style three-column MainLayout (LeftSidebar + 600-px center
column + WidgetSidebar + mobile FAB + mobile bottom nav). It looked
like a Nostr client that happened to render campaign cards instead of
a fundraising site.

This commit takes the chrome down to studs:

- New FundraiserLayout: a sticky GoFundMe-style TopNav with logo,
  Discover / Start a campaign / About links, the existing LoginArea
  on the right (so the avatar dropdown / Log in & Sign up buttons all
  keep working unchanged), and a primary "Start a campaign" pill.
  Mobile collapses to a hamburger drawer with the same items plus
  quick shortcuts to Wallet / Bitcoin / Notifications / Profile /
  Settings for logged-in users.
- One full-width content area below the nav and a slim site footer.
  No LeftSidebar, no WidgetSidebar, no FAB, no MobileTopBar/BottomNav.
- The old layout still provides LayoutStoreContext / DrawerContext /
  CenterColumnContext / NavHiddenContext so every page that calls
  useLayoutOptions(...) keeps mounting cleanly. FAB / sidebar /
  scroll-direction options are simply ignored.

Routing changes:

- / now renders CampaignsPage directly (instead of dispatching
  through a configurable HomePage). /campaigns redirects to /.
- The orphaned HomePage.tsx is removed.

Campaign pages were calibrated for the old 600-px center column.
Re-flowed them to take advantage of the full canvas:

- Hero copy is recentred under max-w-7xl with GoFundMe-style language
  ("Where successful fundraisers start.").
- Campaign grid grows to four columns on xl screens.
- CampaignDetailPage drops its local sticky sub-header (redundant
  under the global TopNav) and the donation rail re-anchors to the
  new nav height.
- CreateCampaignPage drops its sticky sub-header and reads as a
  proper landing form.

The legacy MainLayout / LeftSidebar / WidgetSidebar / MobileTopBar /
MobileBottomNav / MobileDrawer / FloatingComposeButton components
remain on disk but are no longer mounted; they tree-shake out of the
production bundle.
2026-05-17 16:58:58 -05:00
Alex Gleason 1db8b4d5b0 Add fundraising campaigns as the new home surface
Pivot the homepage from a Twitter-style social feed to a GoFundMe-style
fundraising hub. Introduces a new addressable kind 30223 "Campaign" that
carries the marketing-style metadata (title, summary, cover image, story,
category, goal, deadline, location) plus a list of recipient pubkeys with
optional split weights. Documented in NIP.md alongside the kind 8333
onchain-zap spec it builds on.

Donations are sent as a single multi-output Bitcoin transaction (one
output per recipient, derived Taproot addresses) using the existing
buildUnsignedMultiOutputPsbt + useBitcoinSigner infrastructure that
backs community on-chain zaps. After broadcast, the client publishes
one kind 8333 receipt per recipient with the campaign's `a` coordinate
so the donation aggregates into the campaign's totals.

UI surfaces:

- /campaigns is now the default homePage. Hero, two featured slots
  (placeholders in src/lib/featuredCampaigns.ts), then a grid of
  user-submitted campaigns.
- /campaigns/new is a full create form with cover upload, slug
  collision check, recipient builder with per-row weights, and
  preset/custom donation-amount UX.
- naddr1 identifiers for kind 30223 route to CampaignDetailPage via
  NIP19Page (full story rendered through the existing ArticleContent
  markdown component, plus a sticky donate rail with progress).
- DonateDialog presets are tuned for on-chain amounts (10K-1M sats)
  with a dust-aware minimum guard derived from the split math.
- Fundraisers sidebar item with a HandHeart icon.

Kept the existing social-feed pages addressable from the sidebar; the
overhaul is scoped to the home/landing experience rather than removing
the underlying Nostr features.
2026-05-17 16:48:06 -05:00
Alex Gleason b62da321f7 Restore Messages sidebar entry, add WhiteNoise logo to /messages page
- Re-add the Messages item to the left sidebar with its previous
  MessageSquareMore lucide icon. Drop requiresAuth so logged-out users
  also see the entry — the page is a static recommendation.
- Restore 'messages' to the default sidebarOrder in App.tsx.
- Add WhiteNoiseIcon (the logomark from whitenoise.chat, recolored to
  currentColor so it adapts to theme) and use it on the /messages
  install-CTA card in place of the generic Lock glyph.
2026-05-17 14:41:34 -05:00
Alex Gleason c5b929187a Remove Nostr direct messaging feature
The @samthomson/nostr-messaging library opens fresh NRelay1 sockets per
participant per relay outside the shared NPool, fanning out to every
conversation partner's NIP-65 + NIP-17 inbox relays plus all
discoveryRelays in hybrid mode. In practice this drives connection counts
to several hundred relays per session.

Rather than band-aid the fan-out, drop the feature entirely and point
users to White Noise for end-to-end encrypted Nostr chat.

- Replace /messages with a 'Install White Noise' CTA card (route kept)
- Delete MessagingSettingsPage, DMProviderWrapper, messaging-intro.png
- Remove DMProvider wrapper and PROTOCOL_MODE config from App.tsx
- Drop messaging config from AppConfig, AppConfigSchema,
  EncryptedSettingsSchema, EncryptedSettings, and the NostrSync /
  useInitialSync sync paths
- Remove messages sidebar entry, default sidebarOrder slot, and
  SettingsPage messaging card
- Uninstall @samthomson/nostr-messaging and drop its tailwind content
  glob and vitest deps.inline entry
- Update copy in PrivacyPolicy, AdvancedSettings delete-account warning,
  ProfileSettings nsec warning, RequestToVanishDialog deletion checklist,
  MainLayout comment, and NIP.md
- Leave kind 4 rendering (EncryptedMessageContent) intact so DM events
  authored elsewhere still display in feeds and quote embeds
2026-05-17 14:37:49 -05:00
Alex Gleason 119307d13b Auto-reload open tabs on SW activate so returning users see fresh build immediately
Regression-of: 5b8d2d5c

The previous SW eviction commit wiped caches and called clients.claim()
on activate, but that only changes which SW handles future fetches — it
does not re-render a tab that already finished loading the stale bundle.
In practice, returning users had to manually close and reopen the tab
before seeing the new build.

Fix: after clients.claim(), iterate self.clients.matchAll({ type: 'window' })
and call client.navigate(client.url) on each one. Since this SW has no
fetch handler, the navigation falls through to the network and the tab
re-renders against the fresh index.html + hashed bundle.

Caveats:
- Users mid-interaction (typing a post, scrolling) lose their unsaved
  state. Acceptable trade — the alternative is they stay on a broken
  cached bundle indefinitely.
- Fires exactly once per user (only on the install -> activate transition
  for a byte-different /sw.js). No reload loop.

Also corrected the misleading comment on the main.tsx registration: that
registration is forward-looking insurance for future cache busts, not the
mechanism that evicts the old SW. The browser's own SW update check is
what re-fetches /sw.js out-of-band; our in-page JS never runs on a tab
the old precache SW is controlling.
2026-05-17 14:05:20 -05:00
Alex Gleason 5b8d2d5c06 Evict stale precache service worker from old Agora deployment
A previous version of Agora deployed at agora.spot shipped a precaching
service worker that is still controlling returning browsers and serving
them stale HTML/JS — they never see new deploys.

The fix has three parts:

1. public/sw.js — on activate, delete every Cache Storage entry the old
   SW left behind. This SW has no fetch handler, so once it takes over
   nothing re-populates the cache.

2. src/main.tsx — register /sw.js unconditionally on every web page load.
   Previously only usePushNotifications registered it, which meant users
   who never visited NotificationSettings stayed pinned to the old SW
   forever. Native (Capacitor) skips this — there is no stale SW on the
   filesystem origin.

3. .gitlab-ci.yml — the deploy-web rsync was excluding sw.js from the
   first pass and never re-adding it to the second pass, so deploys
   silently never updated sw.js. Now it ships in the second pass
   alongside index.html (after hashed assets land).
2026-05-17 13:48:24 -05:00
Alex Gleason e0024567ff ci: remove redundant build-web job
test + deploy-web already cover what build-web was doing — the test
stage validates the build via 'npm run test' (which runs vite build),
and deploy-web builds and ships the dist/ to the live site. Keeping
build-web around just burned a runner slot to produce a dist/ artifact
nobody consumed.
2026-05-17 13:41:37 -05:00
Alex Gleason 0316331fd2 Add deploy-web job to push to agora.spot on every main push
Mirrors the venus/rrsync pattern documented in GITLAB_DEPLOY.md and the
deploy job from the old agora-v1 repo. Requires DEPLOY_SSH_KEY and
DEPLOY_TARGET protected CI/CD variables, which have been migrated over
from soapbox-pub/agora-v1.
2026-05-17 13:35:45 -05:00
Lemon 7807c994ff Merge branch 'feat/on-chain' into 'main'
Re-Design UI, Add On-Chain, Enhance Country Profiles

See merge request soapbox-pub/agora!23
2026-05-17 11:23:16 -07:00
lemon cd90cbce0e Retry profile banners as blob URLs 2026-05-17 10:59:31 -07:00
lemon 650ba868b3 Fix profile banner image fallback 2026-05-17 10:34:52 -07:00
lemon 256e22f0bd Default Bitcoin community zaps to 1000 sats 2026-05-17 01:20:08 -07:00
lemon 3926a1c886 Initialize Bitcoin ECC before address derivation 2026-05-17 01:16:56 -07:00
lemon b87b70fa72 Polish Bitcoin dust limit handling 2026-05-17 01:13:53 -07:00
lemon ac48231e82 Document batch on-chain zaps 2026-05-17 01:12:16 -07:00
lemon 5f891bbce4 Add community Bitcoin zap action 2026-05-17 01:11:18 -07:00
lemon e17dbdc9c2 Reuse community zap dialog for Bitcoin 2026-05-17 01:10:09 -07:00
lemon a6ed6cd4da Add community on-chain zap hook 2026-05-17 01:08:12 -07:00
lemon 84bd0c9e17 Support multi-output Bitcoin transactions 2026-05-17 01:07:03 -07:00
lemon 11999c0e8b Add Bitcoin wallet page 2026-05-17 01:05:51 -07:00
lemon 6c2cedf8ec Add Bitcoin signing support 2026-05-17 01:00:49 -07:00
lemon 0e117fa417 Add Bitcoin wallet primitives 2026-05-17 00:59:36 -07:00
lemon c1f942210a Tighten community zap amount layout 2026-05-17 00:28:14 -07:00
lemon 302b756c54 Simplify community zap amount controls 2026-05-17 00:26:11 -07:00
lemon d239948757 Use primary community zap progress 2026-05-17 00:23:13 -07:00
lemon c8a126ffd6 Refine community zap dialog progress 2026-05-17 00:21:23 -07:00
lemon ba19a1045e Match community zap action sizing 2026-05-17 00:18:20 -07:00
lemon d53988aa0d Refine community zap controls 2026-05-17 00:16:20 -07:00
lemon 0cbaffb77f Keep transaction refresh control stable 2026-05-17 00:01:09 -07:00
lemon 24a7ae014d Stabilize transaction refresh control 2026-05-16 23:57:21 -07:00
lemon 1e6ce6fb30 Stabilize wallet deposit section 2026-05-16 23:49:35 -07:00
lemon c4cfc0bd2c Use stable Breez Spark SDK 2026-05-16 23:38:37 -07:00
lemon d4595d55bf Reuse in-flight Spark wallet sync 2026-05-16 23:21:37 -07:00
lemon 15f10cd58a Make Spark diagnostics timeout tolerant 2026-05-16 23:18:23 -07:00
lemon d5bf21c853 Add Spark wallet diagnostics 2026-05-16 23:15:26 -07:00
lemon d7996c49db Isolate Spark wallet SDK storage 2026-05-16 23:12:53 -07:00
lemon 8ea55d2c53 Require hold to submit community zaps 2026-05-16 22:46:33 -07:00
lemon a38a80fdec Skip self in community zaps 2026-05-16 22:45:08 -07:00
lemon cd97f854c0 Add community batch zaps 2026-05-16 22:37:13 -07:00
lemon 3ae03c3d17 Polish community hero tabs 2026-05-16 18:27:42 -07:00
lemon 966b71f0d8 Split community member management actions 2026-05-16 16:50:39 -07:00
lemon 2d1b270e8a Match community tabs to feed styling 2026-05-16 16:36:41 -07:00
lemon 0ac1db2085 Use theme-aware community hero fade 2026-05-16 16:32:46 -07:00
lemon 39b2f79e38 Increase world event flag backdrop opacity 2026-05-16 11:20:41 -07:00
lemon a70caae2da Allow dashboard page sidebar add 2026-05-16 11:18:03 -07:00
lemon 5cfd9a049f Remove dashboard from sidebar 2026-05-16 11:15:46 -07:00
lemon b5f2d9bebb Simplify country flag card backdrop 2026-05-16 11:04:18 -07:00
lemon d3fadeca09 Make dashboard public and optional in sidebar 2026-05-16 11:01:29 -07:00
lemon 343085684e Adapt country flag cards for light mode 2026-05-16 10:58:15 -07:00
lemon 7b66c795fe Fix unused action icon imports 2026-05-16 09:04:55 -07:00
lemon 6d4d8ee9fb Document community actions 2026-05-16 09:03:45 -07:00
lemon b772be3139 Include actions in community activity feed 2026-05-16 09:02:25 -07:00
lemon fd6b6b41bc Add community-scoped actions 2026-05-16 09:01:38 -07:00
lemon bc0b8f83d4 Render actions as feed cards 2026-05-16 09:00:02 -07:00
lemon 9dad7c2488 Extract reusable action dialog 2026-05-16 08:57:31 -07:00
lemon baf91fef89 Allow user-created optional-country actions 2026-05-16 08:54:43 -07:00
lemon 77752f8b65 Rename action challenge internals 2026-05-16 08:53:03 -07:00
lemon 5084c99367 Reorder community tabs 2026-05-16 08:30:34 -07:00
lemon 4dbd52e914 Add dashboard admin 2026-05-16 07:58:48 -07:00
lemon 9d6f2cefec Align Network empty state 2026-05-16 06:59:25 -07:00
lemon f92e013347 Clarify Following empty state 2026-05-16 06:59:25 -07:00
lemon c833021eea Default home feed to Following 2026-05-16 06:59:25 -07:00
lemon cbffd73953 Refresh feeds after bulk follows 2026-05-16 06:59:25 -07:00
lemon 7701ce006d Fix onboarding country check layering 2026-05-16 06:59:25 -07:00
lemon 9a94d2b639 Simplify onboarding country picker 2026-05-16 06:59:25 -07:00
lemon 98077d4738 Polish onboarding country grid 2026-05-16 06:59:25 -07:00
lemon 9cedada01d Improve onboarding country picker 2026-05-16 06:59:25 -07:00
lemon b01e7e8fe5 Add country follows to onboarding 2026-05-16 06:59:25 -07:00
291 changed files with 27375 additions and 20232 deletions
+1 -1
View File
@@ -5,7 +5,7 @@ VITE_PLAUSIBLE_ENDPOINT="https://plausible.example.tld/api/event"
VITE_NOSTR_PUSH_PUBKEY=""
# Canonical origin used when generating shareable URLs (QR codes, copy-link, remote-login callbacks).
# Primarily useful for native (Capacitor) builds, where window.location.origin is capacitor://localhost.
# Example: VITE_SHARE_ORIGIN="https://ditto.pub"
# Example: VITE_SHARE_ORIGIN="https://agora.spot"
VITE_SHARE_ORIGIN=""
# Set to "*" to allow any host in the Vite dev server (eg. when proxying through a custom domain)
# ALLOWED_HOSTS="*"
+43 -27
View File
@@ -26,6 +26,38 @@ test:
script:
- npm run test
# Deploy the built web app to agora.spot on venus.vps via rsync over SSH.
# Uses the per-site jailed deploy key documented in GITLAB_DEPLOY.md.
# DEPLOY_SSH_KEY and DEPLOY_TARGET are protected CI/CD variables; they're
# only exposed to jobs on the protected default branch.
deploy-web:
stage: deploy
timeout: 10 minutes
rules:
- if: $CI_COMMIT_TAG
when: never
- if: $CI_COMMIT_REF_NAME == $CI_DEFAULT_BRANCH && $DEPLOY_SSH_KEY && $DEPLOY_TARGET
script:
# Build the web app
- npm ci
- npm run build
- cp dist/index.html dist/404.html
# Install rsync + ssh client and load the deploy key
- apt-get update -qq && apt-get install -y --no-install-recommends rsync openssh-client >/dev/null
- mkdir -p ~/.ssh && chmod 700 ~/.ssh
- echo "$DEPLOY_SSH_KEY" | tr -d '\r' > ~/.ssh/id_ed25519 && chmod 600 ~/.ssh/id_ed25519
- ssh-keyscan -H "${DEPLOY_TARGET##*@}" >> ~/.ssh/known_hosts 2>/dev/null
# Two-phase rsync: upload hashed assets first, then index.html and sw.js,
# so the site never serves an index.html that points at assets that
# haven't finished uploading. sw.js is in the second pass for the same
# reason — it's a stable filename that all browsers re-fetch to check
# for updates, so we want it to land last. The destination ":/" is the
# rrsync jail root on venus, which maps to /var/www/agora.spot/.
- rsync -av --exclude=/sw.js --exclude=/index.html -e "ssh -i ~/.ssh/id_ed25519" dist/ "${DEPLOY_TARGET}:/"
- rsync -av -e "ssh -i ~/.ssh/id_ed25519" dist/index.html dist/sw.js "${DEPLOY_TARGET}:/"
# Disabled: nsite deploy not needed right now; re-enable by restoring the
# rules below to run on default branch (and ensure NSITE_NBUNKSEC is set).
deploy-nsite:
@@ -61,22 +93,6 @@ deploy-nsite:
--use-fallback-relays
--use-fallback-servers
build-web:
stage: build
timeout: 10 minutes
needs: []
rules:
- if: $CI_COMMIT_TAG
when: never
- if: $CI_COMMIT_REF_NAME == $CI_DEFAULT_BRANCH
script:
- npm ci
- npm run build
- cp dist/index.html dist/404.html
artifacts:
paths:
- dist/
release-notes:
stage: build
timeout: 2 minutes
@@ -88,7 +104,7 @@ release-notes:
# release-notes.md is the full section (summary + bulleted lists), used as
# the GitLab Release description. release-notes-summary.txt is the leading
# plaintext paragraph only, used as the App Store / Play Store release
# blurb. Falls back to "Ditto vX.Y.Z" when the section has no summary.
# blurb. Falls back to "Agora vX.Y.Z" when the section has no summary.
- mkdir -p artifacts
- node scripts/extract-release-notes.mjs "$CI_COMMIT_TAG" > artifacts/release-notes.md
- node scripts/extract-release-notes.mjs "$CI_COMMIT_TAG" --summary > artifacts/release-notes-summary.txt
@@ -264,27 +280,27 @@ build-ipa:
ios/App/App.xcodeproj/project.pbxproj
# Run match (cert verify + decrypt) and build_app to produce the IPA.
# build_app writes ./artifacts/Ditto.ipa relative to the project root.
# build_app writes ./artifacts/Agora.ipa relative to the project root.
- cd ios
- fastlane build_ipa
- cd ..
# Move the IPA to a stable name in the artifact directory.
- ls -lh artifacts/
- test -f artifacts/Ditto.ipa
- test -f artifacts/Agora.ipa
# Upload to the Generic Packages registry for a stable public download URL,
# mirroring how build-apk publishes the APK and AAB.
- |
curl --fail --header "JOB-TOKEN: ${CI_JOB_TOKEN}" \
--upload-file "artifacts/Ditto.ipa" \
"${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/ditto/${CI_COMMIT_TAG}/Ditto-${CI_COMMIT_TAG}.ipa"
--upload-file "artifacts/Agora.ipa" \
"${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/agora/${CI_COMMIT_TAG}/Agora-${CI_COMMIT_TAG}.ipa"
after_script:
# Wipe the API key so nothing sensitive sticks around between jobs.
- rm -f "$HOME/.private_keys"/AuthKey_*.p8 || true
artifacts:
paths:
- artifacts/Ditto.ipa
- artifacts/Agora.ipa
expire_in: 90 days
release:
@@ -317,8 +333,8 @@ release:
- name: Agora-${CI_COMMIT_TAG}.aab
url: ${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/agora/${CI_COMMIT_TAG}/Agora-${CI_COMMIT_TAG}.aab
link_type: package
- name: Ditto-${CI_COMMIT_TAG}.ipa
url: ${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/ditto/${CI_COMMIT_TAG}/Ditto-${CI_COMMIT_TAG}.ipa
- name: Agora-${CI_COMMIT_TAG}.ipa
url: ${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/agora/${CI_COMMIT_TAG}/Agora-${CI_COMMIT_TAG}.ipa
link_type: package
publish-zapstore:
@@ -379,7 +395,7 @@ publish-google-play:
- >-
fastlane supply
--aab artifacts/Agora.aab
--package_name pub.agora.app
--package_name spot.agora.app
--track production
--json_key /tmp/play-service-account.json
--metadata_path android/fastlane/metadata/android
@@ -430,7 +446,7 @@ publish-app-store:
# a JSON descriptor; we pass the API key inline via the Fastfile.
- unset APP_STORE_CONNECT_API_KEY_PATH || true
script:
- test -f artifacts/Ditto.ipa
- test -f artifacts/Agora.ipa
- test -f artifacts/release-notes-summary.txt
# Use the release summary paragraph as the App Store "What's New" text.
@@ -442,7 +458,7 @@ publish-app-store:
- echo "-------------------------"
# Submit the prebuilt IPA from build-ipa to App Store Connect for review.
- export IPA_PATH="$CI_PROJECT_DIR/artifacts/Ditto.ipa"
- export IPA_PATH="$CI_PROJECT_DIR/artifacts/Agora.ipa"
- cd ios
- fastlane submit_release
after_script:
+320 -123
View File
@@ -12,10 +12,9 @@
| Kind | Name | Description |
|-------|----------------------------|----------------------------------------------------------------|
| 20000 | Ephemeral Geo Chat (public) | Geo-anchored ephemeral chat message (kind 20000, public) |
| 20001 | Ephemeral Geo Heartbeat | Geo-anchored ephemeral presence heartbeat (kind 20001) |
| 30223 | Campaign | Fundraising campaign with a list of on-chain Bitcoin recipients |
| 30385 | Community Stats Snapshot | Pre-computed per-country / global community leaderboards |
| 36639 | Activist Action | Country-scoped activist challenge with a sats bounty |
| 36639 | Pledge | Donor pledge for concrete submissions, stored as sats |
### Agora Protocols
@@ -23,6 +22,7 @@
|--------------------------|-----------------------------------------|-----------------------------------------------------------------|
| Flat Communities | 34550, 30009, 8, 1111, 1984 | One-level badge membership with explicit moderators (NIP-72 ext) |
| Community Chat | 34550, 1311 | Realtime member chat scoped to a NIP-72 community |
| Campaign Moderation | 30223, 1985, 39089 | Homepage curation (approved / hidden / featured axes) via moderator-signed labels in the `agora.moderation` namespace, gated by a follow-pack moderator roster |
### Community Chat
@@ -72,6 +72,8 @@ Because every Nostr keypair deterministically maps to a Bitcoin Taproot (P2TR) a
### Event Structure
Single-recipient zap (the common case — tipping a post or profile):
```json
{
"kind": 8333,
@@ -87,6 +89,26 @@ Because every Nostr keypair deterministically maps to a Bitcoin Taproot (P2TR) a
}
```
Multi-recipient zap (one transaction paying multiple recipients — campaign donations, community splits):
```json
{
"kind": 8333,
"pubkey": "<sender-pubkey>",
"content": "Great campaign!",
"tags": [
["i", "bitcoin:tx:<txid>"],
["p", "<recipient-1-pubkey>"],
["p", "<recipient-2-pubkey>"],
["p", "<recipient-3-pubkey>"],
["amount", "<total-sats-paid-to-all-listed-recipients>"],
["a", "30223:<campaign-author>:<campaign-d-tag>"],
["K", "30223"],
["alt", "Donation: 75000 sats across 3 recipients"]
]
}
```
### Content
The `content` field is a human-readable comment from the sender (may be empty). It is NOT a zap request JSON (unlike NIP-57 kind 9735).
@@ -96,21 +118,41 @@ The `content` field is a human-readable comment from the sender (may be empty).
| Tag | Required | Description |
|----------|----------|----------------------------------------------------------------------------------------------|
| `i` | Yes | NIP-73 external content identifier. MUST be `bitcoin:tx:<txid>` where `<txid>` is a 64-char lowercase hex Bitcoin transaction ID. |
| `p` | Yes | 32-byte hex pubkey of the zap **recipient** (the author being paid). |
| `amount` | Yes | Amount paid to the recipient in **satoshis** (decimal integer). This is the sum of outputs in the tx that paid the recipient's derived Taproot address*not* the total tx value. |
| `p` | Yes (≥1) | 32-byte hex pubkey of a zap **recipient** (an author being paid). A single event MAY include multiple `p` tags when the transaction has one output per recipient — each `p` tag MUST correspond to at least one tx output paying that recipient's derived Taproot address. |
| `amount` | Yes | **Total** amount paid in **satoshis** (decimal integer). This is the sum of outputs in the tx that paid the derived Taproot addresses of **all** listed `p` recipients combined — *not* the total tx value (it excludes the sender's change output). For single-recipient events this is the amount paid to that one recipient. |
| `e` | If zapping an event | 32-byte hex ID of the event being zapped. Include a relay hint as the 3rd element where possible. |
| `a` | If zapping an addressable event | Addressable event coordinate `<kind>:<pubkey>:<d-tag>`. Used instead of (or alongside) `e` for kinds 3000039999. |
| `alt` | Yes | NIP-31 human-readable fallback. |
If neither `e` nor `a` is present, the zap targets the recipient's **profile** (i.e. a tip to the pubkey, not to a specific event).
If neither `e` nor `a` is present, the zap targets the recipients' **profiles** (i.e. a tip to the pubkey(s), not to a specific event).
### Publishing Flow
1. Sender builds a Bitcoin transaction paying the recipient's derived Taproot address (`nostrPubkeyToBitcoinAddress(recipientPubkey)`).
1. Sender builds a Bitcoin transaction with one output per intended recipient, each paying the recipient's derived Taproot address (`nostrPubkeyToBitcoinAddress(recipientPubkey)`).
2. Sender broadcasts the transaction to the Bitcoin network and obtains the `txid`.
3. Sender signs and publishes a kind 8333 event referencing that `txid` with the appropriate `e`/`a`/`p` tags.
3. Sender signs and publishes a **single** kind 8333 event referencing that `txid` with one `p` tag per recipient and an `amount` tag carrying the total paid across all of them.
4. The event is published **after** broadcast; the txid is already final at that point.
### Batch / Community Zaps
A single Bitcoin transaction MAY pay multiple recipients by including one output per recipient. Clients SHOULD publish **one kind 8333 event per transaction**, listing every recipient under its own `p` tag and putting the combined total in the single `amount` tag. Per-recipient amounts are not encoded in the event — clients that need them recompute them from the on-chain transaction during verification (each `p` tag's derived Taproot address is matched against tx outputs).
For community-level zaps, clients MAY include the community addressable coordinate in an `a` tag and the community kind in a `K` tag:
```json
[
["i", "bitcoin:tx:<txid>"],
["p", "<recipient-1-pubkey>"],
["p", "<recipient-2-pubkey>"],
["amount", "5000"],
["a", "34550:<community-author>:<community-d-tag>"],
["K", "34550"],
["alt", "Bitcoin zap: 5000 sats across 2 recipients"]
]
```
The `amount` tag MUST be the sum of outputs paying the listed recipients; it MUST NOT include the sender's change output.
### Client Behavior
**Querying onchain zaps for an event:**
@@ -119,7 +161,7 @@ If neither `e` nor `a` is present, the zap targets the recipient's **profile** (
{ "kinds": [8333], "#e": ["<target-event-id>"], "limit": 100 }
```
For addressable events, use `"#a": ["<kind>:<pubkey>:<d-tag>"]` instead. For profile-level zaps, use `"#p": ["<pubkey>"]`.
For addressable events, use `"#a": ["<kind>:<pubkey>:<d-tag>"]` instead. For profile-level zaps targeting a specific user, use `"#p": ["<pubkey>"]` — this matches both single-recipient events tagging that user and multi-recipient events where the user is one of several recipients.
**Verification (REQUIRED before trusting amounts):**
@@ -127,15 +169,17 @@ Clients MUST verify a kind 8333 event on-chain before counting it toward a zap t
1. Extract the txid from the `i` tag.
2. Fetch the transaction from a Bitcoin data source (e.g. a mempool.space-compatible Esplora API).
3. Derive the recipient's expected Taproot address from the `p` tag pubkey.
4. Sum the values of all outputs in the transaction that pay that address. This is the **verified amount**. Change outputs paying back to the **sender's** derived Taproot address MUST NOT be counted toward the verified amount — only outputs to the recipient.
5. If the verified amount is 0, the event SHOULD be discarded.
3. For each `p` tag, derive the recipient's expected Taproot address.
4. Sum the values of all outputs in the transaction that pay any of the derived recipient addresses. This is the **verified amount**. Change outputs paying back to the **sender's** derived Taproot address MUST NOT be counted toward the verified amount — only outputs to listed recipients.
5. If the verified amount is 0 (no listed recipient received anything in the tx), the event SHOULD be discarded.
6. If the sender's `amount` tag exceeds the verified amount, clients MAY discard the event or MAY display the smaller verified amount (capping). Clients MUST NOT display or count the claimed amount when it exceeds the verified amount.
7. Unconfirmed transactions MAY be displayed as pending; clients MAY require confirmation before counting them toward public totals. Because unconfirmed transactions can be evicted (RBF, double-spend), clients SHOULD either exclude them from aggregate zap totals or clearly label them as pending.
**Sender/recipient identity:** Clients SHOULD reject events where the sender's pubkey (`event.pubkey`) equals the recipient pubkey from the `p` tag. Self-zaps are trivial to fabricate (the sender already controls the destination address) and contribute nothing meaningful to zap totals.
When a client needs to attribute a multi-recipient event's amount to one specific recipient (e.g. rendering a profile zap-history entry), it MAY sum only the tx outputs paying that one recipient's derived Taproot address. Per-recipient amounts are not stored in the event — they are recomputed from the transaction at display time.
**Deduplication:** Clients SHOULD deduplicate events that reference the same `txid` (an attacker could publish many events pointing at one real transaction). One kind 8333 event per (txid, target) pair is canonical — when multiple events reference the same `txid` for the same target, the earliest is preferred.
**Sender/recipient identity:** Clients SHOULD reject events where the sender's pubkey (`event.pubkey`) appears in any `p` tag. Self-zaps are trivial to fabricate (the sender already controls the destination address) and contribute nothing meaningful to zap totals. Outputs in the underlying transaction that pay the sender's own derived Taproot address are change outputs and MUST NOT be counted toward the verified amount regardless of the tag set.
**Deduplication:** Clients SHOULD deduplicate events that reference the same `txid` (an attacker could publish many events pointing at one real transaction). One kind 8333 event per `txid` is canonical — when multiple events reference the same `txid`, the earliest is preferred.
**Network scope:** This specification applies to Bitcoin **mainnet** only. Testnet, signet, and other networks are out of scope; addresses and txids on those networks MUST NOT be used in kind 8333 events.
@@ -155,42 +199,240 @@ The two zap kinds are complementary. Clients SHOULD sum verified amounts from bo
---
## Standard NIPs: Direct Messaging
## Kind 30223: Campaign
This application implements encrypted direct messaging using two standard Nostr protocols:
### Summary
### NIP-04 (Legacy Encrypted DMs)
Addressable event representing a **fundraising campaign**. A campaign carries the marketing-style metadata you would expect on GoFundMe, Kickstarter, or GiveSendGo (title, summary, cover image, story, category, goal, optional deadline, and recommended country), and — most importantly — a list of recipient pubkeys (`p` tags) that share the proceeds of any donation.
| Field | Value |
|-------|-------|
| Kind | 4 |
| Spec | [NIP-04](https://github.com/nostr-protocol/nips/blob/master/04.md) |
Donations are sent as a **single Bitcoin on-chain transaction** with one output per recipient. The donor's wallet derives each recipient's Taproot address from their pubkey via BIP-340/BIP-341 (the same scheme used by kind 8333 onchain zaps), so the campaign event itself does not need to carry Bitcoin addresses. After broadcasting the funding tx, the donor's client publishes one kind 8333 event referencing the `txid`, listing every campaign recipient under its own `p` tag, and tagging the campaign via `a` / `K`. The donation then shows up in the campaign's totals and in each recipient's profile zap history (the `#p` filter matches every listed recipient).
Legacy encrypted direct messages. Content is encrypted with AES-256-CBC using a shared secret derived from the sender's private key and recipient's public key. The recipient is identified by a `p` tag.
The kind is addressable so the creator can edit the story, image, goal, deadline, and recipient list over the life of the campaign without minting new identifiers. The `d` tag is the campaign's slug.
Used for backward compatibility with older Nostr clients that do not support NIP-17.
### Event Structure
### NIP-17 (Private Direct Messages)
```json
{
"kind": 30223,
"pubkey": "<creator-pubkey>",
"content": "<markdown story>",
"tags": [
["d", "save-the-bookstore"],
["title", "Save the Last Bookstore"],
["summary", "Help our 40-year-old neighborhood bookstore make rent through winter."],
["image", "https://example.com/cover.jpg"],
["t", "human-rights"],
["t", "legal-defense"],
["goal", "10000000"],
["deadline", "1735689600"],
["i", "iso3166:VE"],
["k", "iso3166"],
["p", "<recipient-1-hex-pubkey>", "wss://relay.example", "2"],
["p", "<recipient-2-hex-pubkey>", "wss://relay.example", "1"],
["p", "<recipient-3-hex-pubkey>"],
["alt", "Fundraising campaign: Save the Last Bookstore"]
]
}
```
| Field | Value |
|-------|-------|
| Kinds | 1059 (Gift Wrap), 1060 (Seal) |
| Spec | [NIP-17](https://github.com/nostr-protocol/nips/blob/master/17.md) |
### Content
Modern private direct messages using the Gift Wrap protocol. Messages are triple-layered:
The `content` field is the **campaign story**, formatted as Markdown. Clients SHOULD render it with the same Markdown renderer they use for NIP-23 long-form content. Empty content is permitted (e.g. for a campaign that lives entirely in its summary).
1. **Rumor** (kind 14) — unsigned plaintext message
2. **Seal** (kind 13) — rumor encrypted to the recipient, signed by the sender
3. **Gift Wrap** (kind 1059) — seal encrypted to the recipient, signed by a random ephemeral key
### Tags
This provides metadata protection: relays and observers cannot determine the sender, recipient, or content. The application uses NIP-17 as the default send protocol, with optional NIP-04 compatibility for older clients.
| Tag | Required | Description |
|------------|----------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `d` | Yes | Campaign slug, unique per author. Forms the addressable coordinate `30223:<pubkey>:<d>`. |
| `title` | Yes | Display title of the campaign (plain text, max ~200 chars). |
| `summary` | Recommended | Short one-paragraph tagline shown in feed cards and previews. |
| `image` | Recommended | HTTPS URL of the cover image (jpg/png/webp). Clients MUST sanitize and verify the URL before rendering. |
| `t` | Recommended | Topic tag for discovery and filtering (e.g. `human-rights`, `legal-defense`, `independent-media`). Multiple `t` tags MAY be used. Clients SHOULD normalize user-entered tag labels by removing a leading `#`, lowercasing, and replacing whitespace with hyphens. |
| `goal` | Recommended | Fundraising goal in **satoshis** (decimal integer). Omit if the campaign has no fixed goal. |
| `deadline` | Optional | Unix timestamp (seconds) at which the campaign closes. After the deadline, clients SHOULD show the campaign as ended but MAY still accept donations. |
| `i` | Recommended | NIP-73 country identifier for sorting and discovery. SHOULD be `iso3166:<code>` with an uppercase ISO 3166-1 alpha-2 country code (e.g. `iso3166:VE`). |
| `k` | Recommended if `i` is present | NIP-73 external content kind. For country identifiers this SHOULD be `iso3166`. |
| `location` | Legacy | Human-readable location string used by older campaign events. New events SHOULD prefer `i` + `k` country tags. Clients MAY display this as a fallback only. |
| `status` | Optional | Lifecycle status. The only defined value is `archived`, which marks the campaign closed without deleting it. Other values SHOULD be ignored. See *Closing & archiving* below. |
| `p` | Yes (≥1) | Recipient pubkey. The 2nd element is the hex pubkey; the 3rd (optional) is a relay hint; the 4th (optional) is a positive decimal **weight** for split allocation. |
| `alt` | Recommended | NIP-31 human-readable fallback. |
### Protocol Configuration
### Recipient Split Rules
Users can configure their preferred send protocol via Settings > Messages:
When a donor sends an amount `T` in satoshis to a campaign:
- **NIP-17 only** (default) — maximum privacy, only modern clients can read
- **NIP-04 + NIP-17** — sends via both protocols for compatibility with legacy clients
1. Read all `p` tags from the campaign event.
2. Parse the weight of each `p` tag from the 4th element. If absent, malformed, or non-positive, the weight defaults to **1**.
3. Compute each recipient's share as `floor(T * weight_i / sum_of_weights)` satoshis.
4. Any remainder from rounding (at most N1 sats) MAY be appended to the largest share or kept by the donor as change — clients SHOULD prefer appending the remainder to the largest share so the full amount reaches the campaign.
5. If any computed share is below the Bitcoin dust limit (546 sats for P2TR), the donor's client MUST refuse the donation and surface a minimum-amount error.
Equal splits are the default: omit the weight on every `p` tag, and all recipients receive `floor(T / N)` sats each.
### Donation Flow
1. Donor opens the campaign and chooses an amount in sats (preset or custom).
2. Donor's client computes per-recipient amounts using the split rules above.
3. Donor's client builds a **single PSBT** with one output per recipient (paying each recipient's derived Taproot address) plus a change output back to the donor.
4. Donor signs the PSBT with their Nostr key (Taproot key-path spend) and broadcasts the resulting transaction.
5. Donor's client publishes **one kind 8333 event for the whole transaction**, listing every recipient under its own `p` tag. The event MUST include:
```json
[
["i", "bitcoin:tx:<txid>"],
["p", "<recipient-1-pubkey>"],
["p", "<recipient-2-pubkey>"],
["p", "<recipient-3-pubkey>"],
["amount", "<total-sats-paid-to-all-recipients>"],
["a", "30223:<campaign-author-pubkey>:<campaign-d-tag>"],
["K", "30223"],
["alt", "Donation to <campaign-title>: <total-amount> sats"]
]
```
The `amount` tag is the sum of the outputs paying the listed recipients (i.e. the full donation, excluding the donor's change). Per-recipient amounts are not encoded in the event; clients that need them recompute them from the on-chain transaction by matching each recipient's derived Taproot address against the tx outputs.
This mirrors the community batch-zap pattern documented in the kind 8333 section above, with the campaign's addressable coordinate replacing the community coordinate.
### Querying
**List campaigns (newest first):**
```json
{ "kinds": [30223], "limit": 50 }
```
**Filter by category:**
```json
{ "kinds": [30223], "#t": ["medical"], "limit": 50 }
```
**Fetch a specific campaign:**
```json
{ "kinds": [30223], "authors": ["<creator-pubkey>"], "#d": ["<slug>"], "limit": 1 }
```
**Aggregate donations for a campaign:**
```json
{ "kinds": [8333], "#a": ["30223:<creator-pubkey>:<slug>"], "limit": 500 }
```
Clients MUST verify each kind 8333 event on-chain before counting it toward the campaign total, per the verification rules in the kind 8333 section.
### Client Behavior
- **Recipient validity:** clients SHOULD reject `p` tag entries whose pubkey is not 64 hex characters and SHOULD ignore weights that are not positive finite decimals.
- **Dust protection:** when a donor enters an amount that would assign any recipient less than the dust limit, the client MUST block the donation and either suggest the minimum viable total or prompt the donor to remove recipients.
- **Editability:** the creator MAY republish the same `(kind, pubkey, d)` triple to update the campaign. Clients SHOULD keep `published_at` from the first publish on subsequent edits (NIP-23 convention).
- **Closing & archiving:** the creator MAY soft-close a campaign by republishing it with a `["status", "archived"]` tag. Clients SHOULD hide archived campaigns from discovery feeds and disable the donate flow, but MUST keep them reachable by direct link so existing donors can still find them and donation history is preserved. The creator can reopen the campaign by republishing without the status tag (or with any other status value). For a hard delete, the creator MAY publish a NIP-09 kind 5 deletion request referencing the campaign's `a` coordinate; clients SHOULD continue to render past donations against the campaign even after deletion.
### Campaign Moderation Labels
Agora curates which kind 30223 campaigns appear on the homepage (`/`) and on Discover (`/discover`) via moderator-signed NIP-32 label events (kind 1985) in a dedicated label namespace. The campaign event itself is never modified — surfacing is purely a client-side rollup of label events.
#### Namespace
```
agora.moderation
```
Each label event carries the namespace twice, per NIP-32:
- A capital-`L` "namespace" tag (relay-indexed for queries).
- A lowercase `l` tag where the 2nd element is the label value and the 3rd is the namespace.
#### Label values
Three independent axes; the newest moderator-signed label per axis per campaign wins.
| Axis | Values | Meaning |
|----------|---------------------------|-------------------------------------------------------------------------|
| approval | `approved`, `unapproved` | `approved` allows the campaign on `/` and Discover. `unapproved` retracts a previous approval. |
| hide | `hidden`, `unhidden` | `hidden` suppresses the campaign everywhere it would otherwise appear. `unhidden` retracts a previous hide. |
| featured | `featured`, `unfeatured` | `featured` places the campaign in the hand-picked Featured row on `/`. `unfeatured` retracts. |
Surfacing rules (hide always wins):
- **Featured row on `/`** — iff the latest featured label is `featured` AND the latest hide label is not `hidden`. Ordered newest-`created_at`-of-`featured`-label first. Featured is independent of Approved at the protocol level; a campaign may be featured without being approved (the home page treats Featured and Approved as deduplicated bins, with Featured taking precedence).
- **Community Campaigns grid on `/`** — iff approved, not hidden, and not featured (featured campaigns get their own row above).
- **Discover shelf** — iff approved AND not hidden.
- **Moderator-only "Pending"** — iff neither approved nor hidden.
- **Moderator-only "Hidden"** — iff hidden.
#### Event Structure
```json
{
"kind": 1985,
"content": "",
"tags": [
["L", "agora.moderation"],
["l", "approved", "agora.moderation"],
["a", "30223:<author-pubkey>:<campaign-d-tag>"],
["alt", "Campaign moderation: approved"]
]
}
```
A `featured` label has the same shape with `["l", "featured", "agora.moderation"]` and `["alt", "Campaign moderation: featured"]`.
Required tags:
- `L` set to `agora.moderation`.
- `l` with the label value as the 2nd element and `agora.moderation` as the 3rd.
- `a` referencing the campaign coordinate `30223:<pubkey>:<d>`.
- `alt` (NIP-31) — clients without label support will display this string.
#### Trust Model
Only label events authored by current members of the **Team Soapbox** follow pack are honored. The pack is a kind 39089 (NIP-51 follow pack) addressable event:
```
kind: 39089
pubkey: 932614571afcbad4d17a191ee281e39eebbb41b93fac8fd87829622aeb112f4d
d-tag: k4p5w0n22suf
```
The pack `p` tags are the authoritative moderator list. Anyone may publish a kind 1985 event in the `agora.moderation` namespace, but events from non-pack authors are silently ignored at the relay-filter layer (`authors:` is pinned to the pack `p` tags). This means:
- Self-approval is impossible unless the pack author has added you.
- A moderator removed from the pack immediately loses moderation authority — campaigns kept alive only by their labels return to "pending" until another moderator approves them.
- The pack author (single signer) can reset the entire moderator roster by republishing the pack.
#### Querying
Step 1 — fetch the pack:
```json
{
"kinds": [39089],
"authors": ["932614571afcbad4d17a191ee281e39eebbb41b93fac8fd87829622aeb112f4d"],
"#d": ["k4p5w0n22suf"],
"limit": 1
}
```
Step 2 — fetch label events from pack members in the namespace:
```json
{
"kinds": [1985],
"authors": ["<pack p-tag 1>", "<pack p-tag 2>", "..."],
"#L": ["agora.moderation"],
"limit": 2000
}
```
Step 3 — fold by `(campaign-coord, axis)`, latest-`created_at`-wins. Then fetch only the approved-and-not-hidden campaign coordinates with one filter per author (bundled in a single REQ).
#### Client Behavior
- Clients SHOULD render approve/hide controls only for users whose pubkey appears in the pack.
- Clients MAY display "Hidden" badges on hidden campaigns when viewed by a moderator, and SHOULD NOT render them at all to non-moderators.
- Non-moderator authors viewing the homepage SHOULD see their own pending campaigns in a separate explained section so they understand why their campaign isn't yet on the homepage. The campaign URL remains live and donatable regardless of moderation state.
---
@@ -290,22 +532,19 @@ After resolution (assuming `$follows` = `["pk1", "pk2"]`):
---
## Kind 36639: Activist Action
## Kind 36639: Pledge
### Summary
Addressable event kind for publishing **activist actions** (called "challenges" internally for backwards compatibility). An action is a country-scoped task — take a photo, make art, gather information, or take direct action — with an optional sats bounty paid out via NIP-57 zaps to the best **submissions**.
Addressable event kind for publishing **pledges**. A pledge is donor intent to fund a concrete action, evidence request, or outcome — take a photo, make art, gather information, clean a beach, or take direct action — with an optional country scope, optional community scope, and a sats-denominated pledge amount paid out via zaps or donation receipts to the best **submissions**.
Submissions are **NIP-22 comments** (kind 1111) authored under the action's coordinate, ranked by zap totals. There is no separate submission kind; an earlier draft (kind 36640) was deprecated in favor of NIP-22 reuse.
Submissions are **NIP-22 comments** (kind 1111) authored under the pledge's coordinate, ranked by zap totals. There is no separate submission kind; an earlier draft (kind 36640) was deprecated in favor of NIP-22 reuse.
### Trust model
Anyone can publish a kind 36639 event, but clients SHOULD only display actions whose author is either:
Pledges are user-generated. Anyone can publish a kind 36639 event, and Agora displays valid pledges without platform-admin or country-organizer author filtering.
1. A platform-level admin (see `src/lib/admins.ts`), or
2. An organizer for the action's country (see kind 30078 `agora-organizers`).
This authorization model is identical to the per-country pin model — see Kind 30078 in this document for the storage shape.
Community-scoped pledges inherit the community's moderation context. Clients rendering a specific community SHOULD query by the community `A` tag and apply that community's moderation and membership filters.
### Event Structure
@@ -316,14 +555,17 @@ This authorization model is identical to the per-country pin model — see Kind
"tags": [
["d", "plant-a-tree-1729000000000"],
["title", "Plant a tree in your neighborhood"],
["challenge-type", "photo"],
["bounty", "10000"],
["i", "iso3166:US"],
["A", "34550:<community-pubkey>:<community-d-tag>"],
["K", "34550"],
["P", "<community-pubkey>"],
["t", "agora-action"],
["t", "tree-planting"],
["t", "local-action"],
["image", "https://example.com/cover.jpg"],
["start", "1729000000"],
["deadline", "1729604800"],
["alt", "Agora activist action: Plant a tree in your neighborhood"]
["alt", "Agora pledge: Plant a tree in your neighborhood"]
]
}
```
@@ -334,30 +576,36 @@ This authorization model is identical to the per-country pin model — see Kind
|------------------|----------|----------------------------------------------------------------------------------------------------------|
| `d` | Yes | Unique identifier (typically slug + timestamp). Forms the addressable coordinate `36639:<pubkey>:<d>`. |
| `title` | Yes | Short title shown on cards. |
| `challenge-type` | Yes | One of `photo`, `art`, `info`, `action`. Drives the display icon and submission expectations. |
| `bounty` | Yes | Bounty in **sats**, as an unsigned integer string. Paid out via zaps to the chosen submission(s). |
| `i` | Yes | NIP-73 country identifier: `iso3166:XX` (preferred). Legacy `geo:XX` (length 6, country code only) is accepted as a read alias. Optionally combined with a `location` tag fallback. |
| `t` | Yes | Discovery tag. Canonical write value is `agora-action`. Read aliases: `pathos-challenge`, `agora-challenge`. |
| `bounty` | Yes | Pledge amount in **sats**, as an unsigned integer string. Paid out via zaps or donation receipts to chosen submission(s). |
| `i` | No | NIP-73 country identifier: `iso3166:XX` (preferred). Legacy `geo:XX` (length 6, country code only) is accepted as a read alias. Optionally combined with a `location` tag fallback. |
| `A` | No | Community root coordinate for community-scoped pledges, e.g. `34550:<pubkey>:<d-tag>`. |
| `K` | No | Root kind hint for community-scoped pledges. Use `34550` when `A` points to a NIP-72 community. |
| `P` | No | Root author hint for community-scoped pledges. Use the community definition author pubkey. |
| `t` | Yes | Discovery and category tags. Canonical write value includes `agora-action`; additional `t` tags are optional hashtags/categories. Read aliases: `pathos-challenge`, `agora-challenge`. |
| `image` | No | Cover image URL. |
| `start` | No | Unix timestamp when the action becomes active. Defaults to `created_at`. |
| `deadline` | No | Unix timestamp when the action expires. Defaults to `start + 48h`. |
| `alt` | Yes | NIP-31 human-readable fallback. Convention: `"Agora activist action: <title>"`. |
| `start` | No | Legacy. Unix timestamp when the pledge becomes active. Defaults to `created_at`. New pledges omit it; the `created_at` is the start. |
| `deadline` | No | Optional Unix timestamp when the pledge expires. Omit for open-ended pledges. |
| `alt` | Yes | NIP-31 human-readable fallback. Convention: `"Agora pledge: <title>"`. |
### Content
Long-form description of the action. Plain text or light markdown. Clients render this as the action's body on the detail page.
Long-form description of the pledge. Plain text or light markdown. Clients render this as the pledge's body on the detail page.
### Categories
Clients SHOULD use optional `t` tags for filtering and discovery instead of the deprecated `challenge-type` tag. Suggested user-entered tags include values like `beach-cleanup`, `protest-documentation`, `internet-blackout`, `legal-defense`, or `mutual-aid`.
### Submissions
Submissions are kind 1111 NIP-22 comments addressed to the action's coordinate (`["A", "36639:<pubkey>:<d>"]` and `["P", "<pubkey>"]`). Clients SHOULD:
Submissions are kind 1111 NIP-22 comments addressed to the pledge's coordinate (`["A", "36639:<pubkey>:<d>"]` and `["P", "<pubkey>"]`). Clients SHOULD:
- Sort top-level submissions by **total zap amount** (sum of NIP-57 zap receipts on each submission), descending.
- Show the bounty as the prize pool that organizers can distribute to top submissions via zaps.
- Hide submissions with `created_at` after the action's `deadline` for "past" leaderboards (or surface them separately as "late submissions").
- Sort top-level submissions by **total funded amount** (sum of kind 9735 zap receipts and kind 8333 donation receipts on each submission), descending.
- Show the pledge amount, total funded, and remaining amount as a trust-based progress indicator. There is no escrow guarantee.
- Hide submissions with `created_at` after the pledge's `deadline` for "past" leaderboards (or surface them separately as "late submissions"). Open-ended pledges have no deadline cutoff.
### Discovery
Clients querying actions globally:
Clients querying pledges globally:
```json
{ "kinds": [36639], "#t": ["agora-action", "pathos-challenge", "agora-challenge"], "limit": 50 }
@@ -374,7 +622,17 @@ Per country:
}
```
After fetching, clients MUST filter the results down to events whose author is either an admin or an organizer for the event's country.
Per community:
```json
{
"kinds": [36639],
"#A": ["34550:<community-pubkey>:<community-d-tag>"],
"limit": 50
}
```
Country and community scopes are independent. A future action MAY include both `i` and `A`/`K`/`P` tags when both scopes are useful.
---
@@ -492,66 +750,6 @@ After fetching, take the event with the highest `created_at` and parse it. Cache
---
## Kinds 20000 / 20001: Ephemeral Geo Chat
### Summary
Ephemeral events used to power realtime location-anchored chat on the world map. Both kinds live in NIP-01's ephemeral range (`20000 ≤ kind < 30000`), so relays MUST NOT persist them — they are short-lived signals only.
- **Kind 20000** — public chat message. The `content` field carries the message text.
- **Kind 20001** — presence "heartbeat". Same tag schema, but `content` MAY be empty (the event simply broadcasts that someone is listening at the geohash).
This kind range is shared with the wider Bitchat / geo-chat ecosystem; Agora interoperates with Pathos and other clients producing the same shape.
### Tags
| Tag | Required | Purpose |
|-----|----------|-------------------------------------------------------------------------|
| `g` | Yes | Geohash anchoring the message. Any precision is allowed; the dialog filters by exact-match `g` value, while the map clusters by full geohash. |
| `n` | No | Display nickname (≤ 16 chars after client-side truncation). Anonymous senders pick a random "ghost" handle; logged-in senders may use their account display name. |
Events without a `g` tag MUST be ignored — they cannot be plotted.
### Identity
There are two valid signing paths:
1. **Real identity** — a logged-in user signs with their existing Nostr key (typically via NIP-07 / NIP-46). Other clients can correlate the chat message with the author's public profile.
2. **Ephemeral "ghost" identity** — the client generates a fresh in-memory keypair (never persisted) and signs locally. Only the chosen `n` nickname is persisted (in `localStorage`) so the user keeps a stable handle even though the pubkey rotates per session.
Clients SHOULD let logged-in users toggle between modes per-session and SHOULD default to the ghost mode when no account is available.
### Relay Routing
Because ephemeral events are not stored, latency dominates the experience. Clients SHOULD:
1. Always include a baseline of widely-reachable relays (`wss://nos.lol`, `wss://relay.damus.io`, `wss://relay.primal.net`).
2. Augment with geo-located relays drawn from the [permissionlesstech/georelays](https://github.com/permissionlesstech/georelays) CSV catalogue (`relayUrl,latitude,longitude` per line).
3. For a specific geohash conversation, prefer the relays nearest the decoded coordinates (Haversine distance, top-N).
4. For the global map heatmap, take a rotating window (e.g. 8 relays, rotated every 5 minutes) so coverage spreads without saturating any single relay.
### Time Window
Clients SHOULD only surface events from the last hour (`since = now - 3600`). Older ephemeral events are uninteresting for "what's happening right now" and most relays will have dropped them anyway.
### Example
```json
{
"kind": 20000,
"created_at": 1734567890,
"pubkey": "...",
"tags": [
["g", "u4pruydqqvj"],
["n", "stealthranger4242"]
],
"content": "anyone in berlin tonight?",
"sig": "..."
}
```
---
## Flat Communities
Flat communities on Nostr, composed from existing event kinds. Communities have one membership badge, explicit moderators, and no recursive badge-chain authority.
@@ -1119,4 +1317,3 @@ Albums are represented as kind 34139 playlist events with a `["t", "album"]` tag
- Albums display release date and label information when available
- Track ordering follows the order of `a` tags in the event
- The same detail view, playback, and commenting features apply to both albums and playlists
+2 -2
View File
@@ -7,10 +7,10 @@ if (keystorePropertiesFile.exists()) {
}
android {
namespace = "pub.agora.app"
namespace = "spot.agora.app"
compileSdk = rootProject.ext.compileSdkVersion
defaultConfig {
applicationId "pub.agora.app"
applicationId "spot.agora.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
+1
View File
@@ -10,6 +10,7 @@ android {
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {
implementation project(':capacitor-app')
implementation project(':capacitor-barcode-scanner')
implementation project(':capacitor-filesystem')
implementation project(':capacitor-haptics')
implementation project(':capacitor-keyboard')
+1 -1
View File
@@ -7,7 +7,7 @@
# Keep Capacitor classes (WebView JS bridge)
-keep class com.getcapacitor.** { *; }
-keep class pub.ditto.app.** { *; }
-keep class spot.agora.app.** { *; }
# Keep WebView JS interfaces
-keepclassmembers class * {
@@ -1,4 +1,4 @@
package pub.ditto.app;
package spot.agora.app;
import android.app.ForegroundServiceStartNotAllowedException;
import android.content.Context;
@@ -1,4 +1,4 @@
package pub.ditto.app;
package spot.agora.app;
import android.app.ForegroundServiceStartNotAllowedException;
import android.content.Context;
@@ -63,7 +63,7 @@ public class MainActivity extends BridgeActivity {
private void handleNotificationIntent(Intent intent) {
if (intent == null) return;
Uri data = intent.getData();
if (data != null && "ditto.pub".equals(data.getHost())) {
if (data != null && "agora.spot".equals(data.getHost())) {
String path = data.getPath();
if (path != null && !path.isEmpty()) {
// Wait for WebView to be ready, then navigate
@@ -1,4 +1,4 @@
package pub.ditto.app;
package spot.agora.app;
import android.app.NotificationChannel;
import android.app.NotificationManager;
@@ -337,7 +337,7 @@ public class NostrPoller {
if (manager == null) return;
Intent intent = new Intent(context, MainActivity.class);
intent.setData(Uri.parse("https://ditto.pub/notifications"));
intent.setData(Uri.parse("https://agora.spot/notifications"));
intent.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP);
PendingIntent pendingIntent = PendingIntent.getActivity(
context, id, intent,
@@ -1,4 +1,4 @@
package pub.ditto.app;
package spot.agora.app;
import android.app.AlarmManager;
import android.app.ForegroundServiceStartNotAllowedException;
@@ -83,7 +83,7 @@ public class NotificationRelayService extends Service {
// + REQ + up to 5 events + EOSE + metadata fetch + disconnect.
private static final long FETCH_WAKELOCK_TIMEOUT_MS = 30_000;
private static final String ACTION_FETCH = "pub.ditto.app.ACTION_FETCH";
private static final String ACTION_FETCH = "spot.agora.app.ACTION_FETCH";
// Backoff bounds for relay connect failures (separate from alarm interval).
private static final long INITIAL_BACKOFF_MS = 1_000;
+2 -2
View File
@@ -2,6 +2,6 @@
<resources>
<string name="app_name">Agora</string>
<string name="title_activity_main">Agora</string>
<string name="package_name">pub.agora.app</string>
<string name="custom_url_scheme">pub.agora.app</string>
<string name="package_name">spot.agora.app</string>
<string name="custom_url_scheme">spot.agora.app</string>
</resources>
+3
View File
@@ -5,6 +5,9 @@ project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/
include ':capacitor-app'
project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android')
include ':capacitor-barcode-scanner'
project(':capacitor-barcode-scanner').projectDir = new File('../node_modules/@capacitor/barcode-scanner/android')
include ':capacitor-filesystem'
project(':capacitor-filesystem').projectDir = new File('../node_modules/@capacitor/filesystem/android')
+1 -1
View File
@@ -1,7 +1,7 @@
import type { CapacitorConfig } from '@capacitor/cli';
const config: CapacitorConfig = {
appId: 'pub.agora.app',
appId: 'spot.agora.app',
appName: 'Agora',
webDir: 'dist',
server: {
+2 -2
View File
@@ -325,7 +325,7 @@
);
MARKETING_VERSION = 2.14.4;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = pub.agora.app;
PRODUCT_BUNDLE_IDENTIFIER = spot.agora.app;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_VERSION = 5.0;
@@ -348,7 +348,7 @@
"@executable_path/Frameworks",
);
MARKETING_VERSION = 2.8.0;
PRODUCT_BUNDLE_IDENTIFIER = pub.agora.app;
PRODUCT_BUNDLE_IDENTIFIER = spot.agora.app;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
SWIFT_VERSION = 5.0;
+1 -1
View File
@@ -33,7 +33,7 @@ public class DittoNotificationPlugin: CAPPlugin, CAPBridgedPlugin {
// MARK: - Constants
static let bgTaskIdentifier = "pub.ditto.app.notification-refresh"
static let bgTaskIdentifier = "spot.agora.app.notification-refresh"
private static let prefsKey = "ditto_notification_config"
// MARK: - Plugin Methods
+1 -1
View File
@@ -61,7 +61,7 @@
</array>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>pub.agora.app.notification-refresh</string>
<string>spot.agora.app.notification-refresh</string>
</array>
</dict>
</plist>
+2
View File
@@ -13,6 +13,7 @@ let package = Package(
dependencies: [
.package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", exact: "8.2.0"),
.package(name: "CapacitorApp", path: "../../../node_modules/@capacitor/app"),
.package(name: "CapacitorBarcodeScanner", path: "../../../node_modules/@capacitor/barcode-scanner"),
.package(name: "CapacitorFilesystem", path: "../../../node_modules/@capacitor/filesystem"),
.package(name: "CapacitorHaptics", path: "../../../node_modules/@capacitor/haptics"),
.package(name: "CapacitorKeyboard", path: "../../../node_modules/@capacitor/keyboard"),
@@ -28,6 +29,7 @@ let package = Package(
.product(name: "Capacitor", package: "capacitor-swift-pm"),
.product(name: "Cordova", package: "capacitor-swift-pm"),
.product(name: "CapacitorApp", package: "CapacitorApp"),
.product(name: "CapacitorBarcodeScanner", package: "CapacitorBarcodeScanner"),
.product(name: "CapacitorFilesystem", package: "CapacitorFilesystem"),
.product(name: "CapacitorHaptics", package: "CapacitorHaptics"),
.product(name: "CapacitorKeyboard", package: "CapacitorKeyboard"),
+1 -1
View File
@@ -1,2 +1,2 @@
app_identifier("pub.ditto.app")
app_identifier("spot.agora.app")
team_id("GZLTTH5DLM")
+5 -5
View File
@@ -3,7 +3,7 @@ default_platform(:ios)
platform :ios do
# ─── Lanes ────────────────────────────────────────────────────────────
desc "Build and sign the App Store IPA. Output at ../artifacts/Ditto.ipa."
desc "Build and sign the App Store IPA. Output at ../artifacts/Agora.ipa."
lane :build_ipa do
setup_lane_signing!
build_release_ipa!
@@ -19,7 +19,7 @@ platform :ios do
submit_release_for_review!(ipa_path)
end
desc "Build, sign, and submit Ditto to the App Store for review (single-step convenience)."
desc "Build, sign, and submit Agora to the App Store for review (single-step convenience)."
lane :release do
setup_lane_signing!
build_release_ipa!
@@ -83,7 +83,7 @@ platform :ios do
configuration: "Release",
export_method: "app-store",
output_directory: "../artifacts",
output_name: "Ditto.ipa",
output_name: "Agora.ipa",
clean: true,
# Override the Xcode project's Automatic signing for this build only.
# Match has already installed the AppStore cert + profile into the
@@ -93,7 +93,7 @@ platform :ios do
xcargs: [
"CODE_SIGN_STYLE=Manual",
"CODE_SIGN_IDENTITY='Apple Distribution'",
"PROVISIONING_PROFILE_SPECIFIER='match AppStore pub.ditto.app'",
"PROVISIONING_PROFILE_SPECIFIER='match AppStore spot.agora.app'",
"DEVELOPMENT_TEAM=GZLTTH5DLM",
].join(" "),
export_options: {
@@ -101,7 +101,7 @@ platform :ios do
signingStyle: "manual",
teamID: "GZLTTH5DLM",
provisioningProfiles: {
"pub.ditto.app" => "match AppStore pub.ditto.app",
"spot.agora.app" => "match AppStore spot.agora.app",
},
},
)
+1 -1
View File
@@ -1,5 +1,5 @@
git_url("https://gitlab.com/soapbox-pub/certificates.git")
storage_mode("git")
type("appstore")
app_identifier(["pub.ditto.app"])
app_identifier(["spot.agora.app"])
team_id("GZLTTH5DLM")
+168 -213
View File
@@ -8,7 +8,8 @@
"name": "agora",
"version": "2.8.0",
"dependencies": {
"@breeztech/breez-sdk-spark": "^0.13.2-dev1",
"@bitcoinerlab/secp256k1": "^1.2.0",
"@breeztech/breez-sdk-spark": "^0.10.0",
"@capacitor/app": "^8.0.0",
"@capacitor/barcode-scanner": "^3.0.2",
"@capacitor/core": "^8.1.0",
@@ -95,12 +96,12 @@
"@radix-ui/react-toggle": "^1.1.0",
"@radix-ui/react-toggle-group": "^1.1.0",
"@radix-ui/react-tooltip": "^1.2.8",
"@samthomson/nostr-messaging": "^0.17.1",
"@scure/bip39": "^1.6.0",
"@sentry/react": "^10.42.0",
"@tanstack/react-query": "^5.56.2",
"@unhead/addons": "^2.1.13",
"@unhead/react": "^2.1.13",
"bitcoinjs-lib": "^7.0.1",
"blurhash": "^2.0.5",
"buffer": "^6.0.3",
"capacitor-secure-storage-plugin": "^0.13.0",
@@ -111,6 +112,7 @@
"d3-scale": "^4.0.2",
"date-fns": "^3.6.0",
"dompurify": "^3.3.3",
"ecpair": "^3.0.1",
"embla-carousel-react": "^8.3.0",
"emoji-mart": "^5.6.0",
"fflate": "^0.8.2",
@@ -123,7 +125,6 @@
"iso-3166": "^4.4.0",
"leaflet": "^1.9.4",
"lucide-react": "^1.8.0",
"ngeohash": "^0.6.3",
"nostr-tools": "^2.13.0",
"qrcode": "^1.5.4",
"react": "^19.2.4",
@@ -354,17 +355,40 @@
"node": ">=6.9.0"
}
},
"node_modules/@bitcoinerlab/secp256k1": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@bitcoinerlab/secp256k1/-/secp256k1-1.2.0.tgz",
"integrity": "sha512-jeujZSzb3JOZfmJYI0ph1PVpCRV5oaexCgy+RvCXV8XlY+XFB/2n3WOcvBsKLsOw78KYgnQrQWb2HrKE4be88Q==",
"license": "MIT",
"dependencies": {
"@noble/curves": "^1.7.0"
}
},
"node_modules/@bitcoinerlab/secp256k1/node_modules/@noble/curves": {
"version": "1.9.7",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz",
"integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "1.8.0"
},
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@breeztech/breez-sdk-spark": {
"version": "0.13.2-dev1",
"resolved": "https://registry.npmjs.org/@breeztech/breez-sdk-spark/-/breez-sdk-spark-0.13.2-dev1.tgz",
"integrity": "sha512-W7udRIz+ehjqzCFGCmzJ6fYhSPZ6AGsXyO/X3upOmbJdHXw2DtIVaRYz5sxHLlmIHre8MYAbNUFS3nRqMMVfVQ==",
"version": "0.10.0",
"resolved": "https://registry.npmjs.org/@breeztech/breez-sdk-spark/-/breez-sdk-spark-0.10.0.tgz",
"integrity": "sha512-eBsh0oX2B8uGuWfCMmtH3SNXmSkED5du/CiWQKh1Ei1r0LsO6jlVnUmh94j7R5W4siIi7M6CC7ywll3FQ47rYQ==",
"license": "MIT",
"engines": {
"node": ">=22"
},
"optionalDependencies": {
"better-sqlite3": "^12.2.0",
"pg": "^8.18.0"
"better-sqlite3": "^12.2.0"
}
},
"node_modules/@capacitor/android": {
@@ -6171,39 +6195,6 @@
"win32"
]
},
"node_modules/@samthomson/nostr-messaging": {
"version": "0.17.1",
"resolved": "https://registry.npmjs.org/@samthomson/nostr-messaging/-/nostr-messaging-0.17.1.tgz",
"integrity": "sha512-TfgC3L/7sKnkLSqod1UyF9Bt/F36kH02nRffWjm5YEMfLvHLEYlT5ECgzyrnt9QVpYXG25rVAhEpXF9wxmPX0w==",
"license": "MIT",
"dependencies": {
"blurhash": "^2.0.5",
"buffer": "^6.0.3",
"fuse.js": "^7.1.0",
"idb": "^8.0.3",
"nostr-tools": "^2.13.0",
"react-blurhash": "^0.3.0"
},
"peerDependencies": {
"@nostrify/nostrify": ">=0.47.0",
"@nostrify/react": ">=0.2.0",
"@radix-ui/react-avatar": "^1.1.0",
"@radix-ui/react-dialog": "^1.1.2",
"@radix-ui/react-popover": "^1.1.0",
"@radix-ui/react-scroll-area": "^1.1.0",
"@radix-ui/react-select": "^2.1.1",
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-tabs": "^1.1.0",
"@radix-ui/react-tooltip": "^1.1.4",
"@tanstack/react-query": "^5.56.2",
"clsx": "^2.0.0",
"lucide-react": "^0.462.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.0.0",
"tailwind-merge": "^2.0.0"
}
},
"node_modules/@scure/base": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.1.tgz",
@@ -7716,6 +7707,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/base-x": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/base-x/-/base-x-5.0.1.tgz",
"integrity": "sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg==",
"license": "MIT"
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@@ -7736,6 +7733,12 @@
],
"license": "MIT"
},
"node_modules/bech32": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/bech32/-/bech32-2.0.0.tgz",
"integrity": "sha512-LcknSilhIGatDAsY1ak2I8VtGaHNhgMSYVxFrGLXv+xLHytaKZKcaUJJUE7qmBr7h33o5YQwP55pMI0xmkpJwg==",
"license": "MIT"
},
"node_modules/better-sqlite3": {
"version": "12.9.0",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.9.0.tgz",
@@ -7784,6 +7787,37 @@
"file-uri-to-path": "1.0.0"
}
},
"node_modules/bip174": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/bip174/-/bip174-3.0.0.tgz",
"integrity": "sha512-N3vz3rqikLEu0d6yQL8GTrSkpYb35NQKWMR7Hlza0lOj6ZOlvQ3Xr7N9Y+JPebaCVoEUHdBeBSuLxcHr71r+Lw==",
"license": "MIT",
"dependencies": {
"uint8array-tools": "^0.0.9",
"varuint-bitcoin": "^2.0.0"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/bitcoinjs-lib": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/bitcoinjs-lib/-/bitcoinjs-lib-7.0.1.tgz",
"integrity": "sha512-vwEmpL5Tpj0I0RBdNkcDMXePoaYSTeKY6mL6/l5esbnTs+jGdPDuLp4NY1hSh6Zk5wSgePygZ4Wx5JJao30Pww==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "^1.2.0",
"bech32": "^2.0.0",
"bip174": "^3.0.0",
"bs58check": "^4.0.0",
"uint8array-tools": "^0.0.9",
"valibot": "^1.2.0",
"varuint-bitcoin": "^2.0.0"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/bl": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
@@ -7897,6 +7931,25 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/bs58": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/bs58/-/bs58-6.0.0.tgz",
"integrity": "sha512-PD0wEnEYg6ijszw/u8s+iI3H17cTymlrwkKhDhPZq+Sokl3AU4htyBFTjAeNAlCCmg0f53g6ih3jATyCKftTfw==",
"license": "MIT",
"dependencies": {
"base-x": "^5.0.0"
}
},
"node_modules/bs58check": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/bs58check/-/bs58check-4.0.0.tgz",
"integrity": "sha512-FsGDOnFg9aVI9erdriULkd/JjEWONV/lQE5aYziB5PoBsXRind56lh8doIZIc9X4HoxT5x4bLjMWN1/NB8Zp5g==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "^1.2.0",
"bs58": "^6.0.0"
}
},
"node_modules/buffer": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
@@ -8732,6 +8785,29 @@
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/ecpair": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/ecpair/-/ecpair-3.0.1.tgz",
"integrity": "sha512-uz8wMFvtdr58TLrXnAesBsoMEyY8UudLOfApcyg40XfZjP+gt1xO4cuZSIkZ8hTMTQ8+ETgt7xSIV4eM7M6VNw==",
"license": "MIT",
"dependencies": {
"uint8array-tools": "^0.0.8",
"valibot": "^1.2.0",
"wif": "^5.0.0"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/ecpair/node_modules/uint8array-tools": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/uint8array-tools/-/uint8array-tools-0.0.8.tgz",
"integrity": "sha512-xS6+s8e0Xbx++5/0L+yyexukU7pz//Yg6IHg3BKhXotg1JcYtgxVcUctQ0HxLByiJzpAkNFawz1Nz5Xadzo82g==",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.149",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.149.tgz",
@@ -9334,19 +9410,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/fuse.js": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.3.0.tgz",
"integrity": "sha512-plz8RVjfcDedTGfVngWH1jmJvBvAwi1v2jecfDerbEnMcmOYUEEwKFTHbNoCiYyzaK2Ws8lABkTCcRSqCY1q4w==",
"license": "Apache-2.0",
"engines": {
"node": ">=10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/krisk"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
@@ -11660,15 +11723,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/ngeohash": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/ngeohash/-/ngeohash-0.6.3.tgz",
"integrity": "sha512-kltF0cOxgx1AbmVzKxYZaoB0aj7mOxZeHaerEtQV0YaqnkXNq26WWqMmJ6lTqShYxVRWZ/mwvvTrNeOwdslWiw==",
"license": "MIT",
"engines": {
"node": ">=v0.2.0"
}
},
"node_modules/node-abi": {
"version": "3.89.0",
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.89.0.tgz",
@@ -12019,102 +12073,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/pg": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz",
"integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==",
"license": "MIT",
"optional": true,
"dependencies": {
"pg-connection-string": "^2.12.0",
"pg-pool": "^3.13.0",
"pg-protocol": "^1.13.0",
"pg-types": "2.2.0",
"pgpass": "1.0.5"
},
"engines": {
"node": ">= 16.0.0"
},
"optionalDependencies": {
"pg-cloudflare": "^1.3.0"
},
"peerDependencies": {
"pg-native": ">=3.0.1"
},
"peerDependenciesMeta": {
"pg-native": {
"optional": true
}
}
},
"node_modules/pg-cloudflare": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz",
"integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==",
"license": "MIT",
"optional": true
},
"node_modules/pg-connection-string": {
"version": "2.12.0",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz",
"integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==",
"license": "MIT",
"optional": true
},
"node_modules/pg-int8": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
"license": "ISC",
"optional": true,
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/pg-pool": {
"version": "3.13.0",
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz",
"integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==",
"license": "MIT",
"optional": true,
"peerDependencies": {
"pg": ">=8.0"
}
},
"node_modules/pg-protocol": {
"version": "1.13.0",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz",
"integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==",
"license": "MIT",
"optional": true
},
"node_modules/pg-types": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
"license": "MIT",
"optional": true,
"dependencies": {
"pg-int8": "1.0.1",
"postgres-array": "~2.0.0",
"postgres-bytea": "~1.0.0",
"postgres-date": "~1.0.4",
"postgres-interval": "^1.1.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/pgpass": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
"license": "MIT",
"optional": true,
"dependencies": {
"split2": "^4.1.0"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -12352,49 +12310,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=4"
}
},
"node_modules/postgres-bytea": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-date": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-interval": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
"license": "MIT",
"optional": true,
"dependencies": {
"xtend": "^4.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/powershell-utils": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz",
@@ -14100,7 +14015,7 @@
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"devOptional": true,
"dev": true,
"license": "ISC",
"engines": {
"node": ">= 10.x"
@@ -14717,6 +14632,15 @@
"integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==",
"license": "MIT"
},
"node_modules/uint8array-tools": {
"version": "0.0.9",
"resolved": "https://registry.npmjs.org/uint8array-tools/-/uint8array-tools-0.0.9.tgz",
"integrity": "sha512-9vqDWmoSXOoi+K14zNaf6LBV51Q8MayF0/IiQs3GlygIKUYtog603e6virExkjjFosfJUBI4LhbQK1iq8IG11A==",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
@@ -15055,6 +14979,38 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/valibot": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/valibot/-/valibot-1.4.0.tgz",
"integrity": "sha512-iC/x7fVcSyOwlm/VSt7RlHnzNGLGvR9GnxdifUeWoCJo0q4ZZvrVkIHC6faTlkxG47I2Y4UrFquPuVHCrOnrLg==",
"license": "MIT",
"peerDependencies": {
"typescript": ">=5"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/varuint-bitcoin": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/varuint-bitcoin/-/varuint-bitcoin-2.0.0.tgz",
"integrity": "sha512-6QZbU/rHO2ZQYpWFDALCDSRsXbAs1VOEmXAxtbtjLtKuMJ/FQ8YbhfxlaiKv5nklci0M6lZtlZyxo9Q+qNnyog==",
"license": "MIT",
"dependencies": {
"uint8array-tools": "^0.0.8"
}
},
"node_modules/varuint-bitcoin/node_modules/uint8array-tools": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/uint8array-tools/-/uint8array-tools-0.0.8.tgz",
"integrity": "sha512-xS6+s8e0Xbx++5/0L+yyexukU7pz//Yg6IHg3BKhXotg1JcYtgxVcUctQ0HxLByiJzpAkNFawz1Nz5Xadzo82g==",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/vaul": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vaul/-/vaul-1.1.2.tgz",
@@ -16657,6 +16613,15 @@
"node": ">=8"
}
},
"node_modules/wif": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/wif/-/wif-5.0.0.tgz",
"integrity": "sha512-iFzrC/9ne740qFbNjTZ2FciSRJlHIXoxqk/Y5EnE08QOXu1WjJyCCswwDTYbohAOEnlCtLaAAQBhyaLRFh2hMA==",
"license": "MIT",
"dependencies": {
"bs58check": "^4.0.0"
}
},
"node_modules/word-wrap": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
@@ -16797,16 +16762,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=0.4"
}
},
"node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
+4 -3
View File
@@ -15,7 +15,8 @@
"node": ">=22"
},
"dependencies": {
"@breeztech/breez-sdk-spark": "^0.13.2-dev1",
"@bitcoinerlab/secp256k1": "^1.2.0",
"@breeztech/breez-sdk-spark": "^0.10.0",
"@capacitor/app": "^8.0.0",
"@capacitor/barcode-scanner": "^3.0.2",
"@capacitor/core": "^8.1.0",
@@ -102,12 +103,12 @@
"@radix-ui/react-toggle": "^1.1.0",
"@radix-ui/react-toggle-group": "^1.1.0",
"@radix-ui/react-tooltip": "^1.2.8",
"@samthomson/nostr-messaging": "^0.17.1",
"@scure/bip39": "^1.6.0",
"@sentry/react": "^10.42.0",
"@tanstack/react-query": "^5.56.2",
"@unhead/addons": "^2.1.13",
"@unhead/react": "^2.1.13",
"bitcoinjs-lib": "^7.0.1",
"blurhash": "^2.0.5",
"buffer": "^6.0.3",
"capacitor-secure-storage-plugin": "^0.13.0",
@@ -118,6 +119,7 @@
"d3-scale": "^4.0.2",
"date-fns": "^3.6.0",
"dompurify": "^3.3.3",
"ecpair": "^3.0.1",
"embla-carousel-react": "^8.3.0",
"emoji-mart": "^5.6.0",
"fflate": "^0.8.2",
@@ -130,7 +132,6 @@
"iso-3166": "^4.4.0",
"leaflet": "^1.9.4",
"lucide-react": "^1.8.0",
"ngeohash": "^0.6.3",
"nostr-tools": "^2.13.0",
"qrcode": "^1.5.4",
"react": "^19.2.4",
@@ -1,7 +1,7 @@
{
"webcredentials": {
"apps": [
"GZLTTH5DLM.pub.agora.app"
"GZLTTH5DLM.spot.agora.app"
]
}
}
+1 -1
View File
@@ -5,7 +5,7 @@
],
"target": {
"namespace": "android_app",
"package_name": "pub.agora.app",
"package_name": "spot.agora.app",
"sha256_cert_fingerprints": [
"7C:05:A8:5A:07:0F:84:AE:43:DE:85:67:A4:5F:7F:FB:42:0A:05:05:27:CE:B6:8C:DA:AF:A5:E0:12:E0:9E:71",
"E5:B1:A9:13:C9:37:35:3C:A5:E7:27:89:C0:9D:3D:0D:A5:4F:F5:26:88:06:BD:24:46:21:AB:61:6B:CC:C5:E5"
+236
View File
@@ -0,0 +1,236 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="1152" height="720">
<defs>
<linearGradient id="grad">
<stop stop-color="#84be86" offset="0"/>
<stop stop-color="#328c4e" offset="1"/>
</linearGradient>
</defs>
<path fill="#f4e109" d="m0,0,0,720,1152,0,0-720z"/>
<path fill="#da251c" d="m596.99,620,555.01,56.187,0-634.28-1110,0,0,634.28"/>
<g fill="#29166f">
<path d="m597,359.06,0-317.16-277.69,0z"/>
<path d="m597,359.06,555-317.16-278.03,0z"/>
<path d="m1152,200.47,0,158.59-1110,0,0,158.56z"/>
<path d="m1152,676.14,0-158.51-1110-317.16,0-158.56z"/>
</g>
<path fill="#f4e109" d="m392.87,329.97,102.05,17.726c-0.408,3.732-0.632,7.516-0.632,11.355,0,3.868,0.225,7.685,0.64,11.441l-102.04,17.813,102.92-11.563c1.158,6.703,2.963,13.182,5.344,19.374l-94.205,43.224,96.647-37.403c1.266,2.774,2.643,5.487,4.14,8.123l178.54,0.001c1.495-2.636,2.874-5.349,4.14-8.123l96.646,37.403-94.206-43.224c2.382-6.192,4.187-12.671,5.345-19.374l102.92,11.563-102.04-17.813c0.414-3.757,0.641-7.573,0.641-11.441,0-3.839-0.227-7.623-0.632-11.355l102.05-17.726-102.94,11.476c-1.148-6.719-2.944-13.211-5.321-19.419l94.22-43.177-96.657,37.357c-3.226-7.082-7.215-13.735-11.879-19.849l78.264-68.147-82.245,63.261c-10.31-11.931-23.281-21.47-38.01-27.65l33.979-98.248-39.859,96.01c-10.556-3.681-21.882-5.707-33.686-5.707-11.803,0-23.128,2.026-33.684,5.707l-39.86-96.01,33.98,98.248c-14.729,6.181-27.701,15.719-38.01,27.65l-82.246-63.261,78.266,68.147c-4.665,6.114-8.654,12.768-11.878,19.849l-96.659-37.357,94.22,43.177c-2.378,6.208-4.171,12.7-5.321,19.419z"/>
<path fill="#fff" d="M596.99,359.05,1152,676.19h-1110l554.99-317.14z"/>
<g fill="#da251c" stroke="#000" stroke-width="0.476">
<path fill="#f1c700" d="m629.96,593.89c0,18.271-14.811,33.082-33.081,33.082-18.268,0-33.082-14.812-33.082-33.082,0-18.269,14.814-33.079,33.082-33.079,18.27,0,33.081,14.81,33.081,33.079z"/>
<path d="m624.99,593.89c0,15.526-12.586,28.112-28.112,28.112s-28.112-12.586-28.112-28.112,12.586-28.112,28.112-28.112,28.112,12.586,28.112,28.112z"/>
<path fill="#e87817" d="m620.54,593.89c0,13.069-10.594,23.663-23.663,23.663s-23.663-10.594-23.663-23.663,10.594-23.663,23.663-23.663,23.663,10.594,23.663,23.663z"/>
<path fill="#29166f" d="m620.54,593.9c0,13.065-10.594,23.661-23.663,23.661s-23.702-10.596-23.702-23.661c0-6.541,5.301-11.858,11.848-11.858,6.552,0,11.836,5.317,11.836,11.858,0,6.542,5.296,11.849,11.85,11.849,6.542-0.001,11.831-5.307,11.831-11.849z"/>
<path fill="#e87817" d="m588.41,593.89c0,1.868-1.509,3.382-3.38,3.382s-3.382-1.514-3.382-3.382c0-1.864,1.511-3.384,3.382-3.384s3.38,1.519,3.38,3.384z"/>
<path fill="#29166f" d="m612.11,593.89c0,1.868-1.508,3.382-3.38,3.382-1.871,0-3.385-1.514-3.385-3.382,0-1.864,1.514-3.384,3.385-3.384,1.873,0,3.38,1.519,3.38,3.384z"/>
<path d="m596.87,556.27c-1.38,0-2.501-1.117-2.501-2.5,0-1.385,1.121-2.507,2.501-2.507,1.384,0,2.502,1.122,2.502,2.507,0,1.382-1.118,2.5-2.502,2.5z"/>
<path d="m596.81,558.2c0,1.385-1.117,2.506-2.502,2.506s-2.501-1.121-2.501-2.506c0-1.378,1.116-2.502,2.501-2.502s2.502,1.124,2.502,2.502z"/>
<path d="m601.94,558.2c0,1.385-1.116,2.506-2.5,2.506-1.386,0-2.506-1.121-2.506-2.506,0-1.378,1.12-2.502,2.506-2.502,1.384,0,2.5,1.124,2.5,2.502z"/>
<path d="m596.87,631.52c1.379,0,2.506,1.12,2.506,2.5,0,1.384-1.127,2.502-2.506,2.502-1.384,0-2.5-1.118-2.5-2.502,0-1.38,1.116-2.5,2.5-2.5z"/>
<path d="m596.94,629.58c0-1.38,1.118-2.5,2.502-2.5,1.382,0,2.504,1.12,2.504,2.5,0,1.386-1.122,2.506-2.504,2.506-1.384,0-2.502-1.12-2.502-2.506z"/>
<path d="m591.81,629.58c0-1.38,1.116-2.5,2.5-2.5s2.505,1.12,2.505,2.5c0,1.386-1.121,2.506-2.505,2.506s-2.5-1.12-2.5-2.506z"/>
<path d="m639.5,593.89c0,1.3813-1.1197,2.501-2.501,2.501s-2.501-1.1197-2.501-2.501,1.1197-2.501,2.501-2.501,2.501,1.1197,2.501,2.501z"/>
<path d="m635.07,596.46c0,1.3824-1.1206,2.503-2.503,2.503s-2.503-1.1206-2.503-2.503,1.1206-2.503,2.503-2.503,2.503,1.1206,2.503,2.503z"/>
<path d="m632.57,588.83c-1.381,0-2.502,1.115-2.502,2.5s1.121,2.504,2.502,2.504c1.382,0,2.502-1.119,2.502-2.504s-1.12-2.5-2.502-2.5z"/>
<path d="m559.25,593.89c0-1.378-1.115-2.504-2.5-2.504-1.384,0-2.501,1.126-2.501,2.504,0,1.383,1.117,2.5,2.501,2.5,1.385,0,2.5-1.118,2.5-2.5z"/>
<path d="m563.68,591.33c0,1.3824-1.1206,2.503-2.503,2.503s-2.503-1.1206-2.503-2.503,1.1206-2.503,2.503-2.503,2.503,1.1206,2.503,2.503z"/>
<path d="m563.68,596.45c0,1.3818-1.1202,2.502-2.502,2.502s-2.502-1.1202-2.502-2.502,1.1202-2.502,2.502-2.502,2.502,1.1202,2.502,2.502z"/>
</g>
<g stroke-width="0.476">
<g fill="#e0609b" stroke="#000">
<path fill="#e87817" d="m600.33,516.36c0,1.84-1.487,3.326-3.325,3.326-1.84,0-3.326-1.486-3.326-3.326,0-1.838,1.486-3.324,3.326-3.324,1.838,0,3.325,1.486,3.325,3.324z"/>
<path fill="#e12211" d="m622.04,486.76c-1.398,5.898-12.077,10.491-25.054,10.491-13.008,0-23.717-4.616-25.07-10.542-4.437,2.376-7.1,5.4-7.1,8.691,0,7.66,14.382,13.867,32.124,13.867,17.74,0,32.118-6.207,32.118-13.867,0-3.268-2.629-6.274-7.018-8.64z"/>
<path d="m597.44,497.25c-2.56,0-5.03-0.179-7.36-0.514-0.354,0.997-2.361,8.101,7.313,12.53,7.429-2.847,7.275-10.72,6.511-12.41-2.063,0.254-4.228,0.394-6.464,0.394z"/>
<path d="m571.8,485.93c-1.184,0.502-7.366,2.488-5.974,13.064,9.224,2.535,10.25-5.694,10.28-6.938-2.553-1.761-4.104-3.864-4.306-6.126z"/>
<path d="m588.87,496.54c0.079,1.021,0.6,9.042-9.55,10.257-7.443-6.215-2.961-13.113-1.307-13.851,2.84,1.615,6.58,2.866,10.857,3.594z"/>
<path d="m617.82,492.06c0.027,1.243,1.056,9.473,10.279,6.938,1.392-10.576-4.792-12.563-5.976-13.064-0.198,2.261-1.752,4.364-4.303,6.126z"/>
<path d="m615.91,492.95c1.654,0.737,6.138,7.636-1.308,13.851-10.149-1.215-9.625-9.235-9.55-10.257,4.278-0.728,8.019-1.979,10.858-3.594z"/>
<path fill="#f1c700" d="m627.78,491.52c0.831,1.236,1.284,2.536,1.284,3.881,0,7.66-14.378,13.867-32.118,13.867-17.741,0-32.124-6.207-32.124-13.867,0-1.237,0.381-2.438,1.089-3.585-3.936,2.409-6.231,5.296-6.231,8.405,0,8.402,16.749,15.207,37.411,15.207,20.669,0,37.421-6.805,37.421-15.207,0.001-3.238-2.495-6.233-6.732-8.701z"/>
</g>
<g fill="#fff">
<path d="m597.12,504.23c-1.458-2.815-0.844-5.285-0.803-6.996,0.16,0.004,0.317,0.005,0.478,0.005-0.06,0.894-0.052,3.593,0.325,6.991z"/>
<path d="m572.84,488.82c-0.661,0.648-2.937,3.078-4.184,6.476,2.1-2.619,4.031-5.024,4.688-5.757-0.181-0.236-0.351-0.477-0.504-0.719z"/>
<path d="m582.4,494.9c-0.296,0.59-1.664,3.014-1.664,6.553,1.033-3.603,2.047-5.626,2.423-6.3-0.261-0.084-0.511-0.164-0.759-0.253z"/>
<path d="m620.59,489.54c0.657,0.732,2.588,3.138,4.688,5.757-1.247-3.397-3.522-5.827-4.184-6.476-0.154,0.242-0.321,0.483-0.504,0.719z"/>
<path d="m610.77,495.16c0.373,0.674,1.387,2.697,2.419,6.3,0-3.539-1.367-5.963-1.664-6.553-0.247,0.089-0.496,0.169-0.755,0.253z"/>
</g>
<g stroke="#000">
<path fill="#e12211" stroke-linejoin="round" stroke-linecap="round" d="m576.26,461.85s-2.332-2.805-5.756-4.99c-3.421-2.194-5.785-0.894-9.277-1.612-3.316-0.688-10.426-2.966-8.386-15.539,2.053,11.032,7.61,7.926,8.077,6.467,0.581-1.814-0.708-4.537-2.603-5.753-2.393-1.538-6.708-5.063-6.708-10.131,0-1.56,0.377-3.218,0.901-4.756,0.566-1.675,3.003-3.86,2.557-6.782,1.879,4.552-2.285,4.675,2.022,11.125,1.16,1.736,2.86,0.363,3.148-0.958,0.442-2.052-0.822-4.79-1.779-6.984-0.962-2.188-1.82-5.789-0.962-8.896,1.235-4.453,3.868-5.447,4.794-8.768,0.561-2.009-2.656-6.852,2.054-10.679-2.584,5.996,0.959,7.939,1.779,9.723,0.822,1.78-0.417,5.209-0.547,6.435-0.171,1.612,2.178,2.282,3.695,1.265,1.955-1.3,1.678-5.234,1.819-7.422,0.136-2.197,1.985-3.532,4.311-4.627,2.329-1.094,6.149-5.379,3.219-11.946,7.264,5.734,5.718,11.09,5.029,13.282-0.683,2.191-2.052,6.301-1.771,9.035,0.27,2.742,2.184,2.602,2.868,1.508,0.688-1.095-0.373-4.674-0.138-6.573,0.4-3.227,5.537-3.862,5.804-6.267,0.222-2.001-4.978-6.153-4.694-9.609,0.445-5.436,4.978-3.62,3.275-9.52,3.697,3.697,1.232,6.499,0.959,7.867-0.273,1.371,0.055,3.479,1.78,3.291,1.233-0.141,2.381-1.729,3.149-4.795,2.085-8.319-4.873-6.527-4.146-12.84,2.566,4.148,5.99,1.523,9.867,7.603,1.874,2.938,0.988,5.373,0.579,7.425-0.411,2.058,1.371,4.114,2.603,2.195,2.794-4.354-1.274-5.243,1.916-10.888-1.149,8.436,3.851,5.978,3.147,14.858-0.291,3.675-4.79,6.982-5.206,10-0.347,2.572,0.449,5.575,1.406,6.533,1.584,1.586,3.188,1.326,3.699-0.549,0.407-1.503-0.413-3.114-0.962-4.759-0.621-1.868,0.601-3.651,2.979-4.243,1.821-0.453,3.977-3.488,3.56-6.16-0.426-2.792-4.774-4.581-0.408-11.643-2.45,7.937,4.134,6.937,4.962,12.396,0.353,2.322-0.43,3.844-0.648,5.063-0.282,1.556,4.211,3.806,2.811-2.258,4.718,3.862,5.063,8.629,4.517,10.407-0.551,1.78-2.056,3.968-1.098,5.886s3.249,2.671,4.52,0.822c1.059-1.541,1.366-3.745,0.582-6.299-0.844-2.749-4.469-3.581-0.513-9.793,0,6.212,4.919,8.503,5.714,11.504,0.393,1.481-0.442,3.219,0.242,4.176,0.685,0.962,4.938,0.58,1.645-6.843,5.86,4.789,2.737,10.437,0.82,11.948-1.914,1.506-5.339,5.441-5.339,8.867,0,3.422,3.704,6.521,4.929,3.559,1.353-3.271-1.383-6.007,1.507-10.547-0.765,4.582,0.693,9.567,2.872,9.859,1.819,0.244,2.356-2.655,2.982-4.622,1.839,5.22-2.16,7.878-2.982,9.109-0.822,1.229-1.197,4.484-2.738,5.342-1.29,0.713-4.38,1.406-6.296,2.769-1.916,1.368-4.174,4.275-0.683,4.799,3.212,0.479,5.377-0.508,7.391-5.48,2.98,9.223-5.41,12.905-9.586,13.423-3.398,0.415-9.174,0.544-10.818,1.776-1.643,1.231-1.643,3.214-1.643,3.214s-2.602,4.729-7.805,5.824c-5.201,1.097-19.718,1.917-23.962,0.273s-7.4-3.84-9.036-6.097zm66.648-45.118c-5.9,5.665-0.803,12.679,0.446,8.628,1.39-4.507-2.096-3.241-0.446-8.628zm-16.259,21.773c-1.02,1.02-0.98,2.195-0.445,2.808,0.445,0.512,1.472,0.037,2.156-0.475,0.687-0.517,1.664-1.645,1.715-3.082,0.09-2.557-2.178-2.633-2.433-5.856-1.2,2.4,2.089,3.523-0.993,6.605zm-5.342-23.107c-1.089,3.51,1.18,4.697,1.493,6.006,0.308,1.287-0.256,1.855,0.151,2.93,0.411,1.075,1.594,2.105,2.26,0.769,0.75-1.494,0.432-2.606-0.099-3.952-0.686-1.743-3.805-1.993-3.805-5.753zm-6.11-12.98c1.332,2.896,0.14,3.552,0.716,6.829,0.206,1.17,1.912,3.409,2.519,1.591,0.512-1.535,0.41-3.078-0.617-4.105-2.288-2.287-0.356-2.552-2.618-4.315zm-6.574,11.655c4.591,5.847-0.048,11.04-1.026,11.864-0.977,0.821-1.542,1.692-1.797,2.513-0.255,0.826-0.052,2.363,1.384,2.004,1.439-0.361,2.673-1.848,3.339-3.081,0.668-1.229,1.598-4.002,1.696-6.265,0.15-3.439-1.505-6.773-3.596-7.035zm-6.832-1.077c-1.868,2.38,0.052,3.88-0.203,5.494-0.149,0.946-0.924,1.438-0.768,2.206,0.15,0.773,1.229,1.593,1.949,0.976,0.716-0.614,1.334-2.668,0.82-3.539-0.511-0.875-2.979-2.11-1.798-5.137zm-10.214-6.161c-4.343,5.054-1.492,10.216-0.927,10.889,0.566,0.664,2.617,0.929,2.72-0.98,0.208-3.886-3.511-3.886-1.793-9.909zm-5.958,9.653c-2.987,8.139,0.14,8.804,0.716,8.42,0.772-0.511,1.334-1.54,0.979-2.258-0.361-0.72-2.432-0.107-1.695-6.162zm-6.676-3.597c0.979,4.318-1.437,4.527-2.058,9.865-0.236,2.036,0.67,4.361,1.079,5.182,0.409,0.826,1.596,1.42,2.314,0.46,0.77-1.025,0.973-2.206,0.408-3.337-0.463-0.928-2.241-1.436-2.211-3.542,0.033-2.143,2.449-6.185,0.468-8.628zm-10.94,0.363c-2.247,4.872,2.253,5.163,2.93,6.979,0.414,1.11,1.229,2.056,1.897,0.98,0.67-1.079,0.413-3.081-0.465-3.906-0.871-0.819-4.362-1.265-4.362-4.053zm-4.108,8.473c-1.598,2.482,1.319,3.732,0.512,6.723-0.445,1.646-0.615,2.771-0.308,3.393,0.308,0.615,1.695,0.924,2.313-0.258,0.615-1.178,0.702-2.16,0.511-3.135-0.357-1.824-3.028-3.174-3.028-6.723zm11.4-17.44c-0.453,0.623-0.874,2.411-0.566,3.54,0.311,1.131,1.802,0.835,2.107,0.257,0.463-0.871-0.183-1.63-0.053-2.613,0.253-1.906,2.894-1.781,2.208-5.139-0.553,3.096-3.03,3.029-3.696,3.955zm18.637-2.621c-0.215,2.016,0.618,5.6,1.899,6.776,1.285,1.182,2.946-0.305,3.028-1.744,0.104-1.831-1.932-2.553-2.253-4.616-0.617-3.965,4.399-5.675,0.409-10.788,1.866,5.655-2.465,4.573-3.083,10.372z"/>
<path fill="#f1c700" d="m615.28,477.31c1.009,1.263,1.566,2.65,1.566,4.11,0,5.835-8.932,10.569-19.953,10.569-11.018,0-19.952-4.734-19.952-10.569,0-1.4,0.521-2.734,1.455-3.958-4.11,2.104-6.614,4.901-6.614,7.977,0,6.521,11.282,11.808,25.208,11.808,13.924,0,25.213-5.286,25.213-11.808,0-3.154-2.634-6.012-6.923-8.129z"/>
<path fill="#e12211" d="m615.59,477.73c-0.229,0.014-0.333,0.168-0.563-0.064-0.336-0.343-1.774-5.841-2.258-7.72-3.385,1.708-9.229,2.841-15.875,2.841-6.458,0-12.236-1.075-15.655-2.727-0.51,1.98-1.901,7.273-2.233,7.605-0.296,0.305-0.373-0.063-0.834,0.111-0.788,1.137-1.228,2.361-1.228,3.644,0,5.835,8.935,10.569,19.952,10.569,11.021,0,19.953-4.734,19.953-10.569,0-1.299-0.451-2.542-1.259-3.69z"/>
<path fill="#e87817" d="m579.67,467.35c1.768,4.648-2.587,10.577-1.52,13.252,1.063,2.667,3.491,4.079,5.861,4.878,5.991,2.019,7.075-3.606,8.075-14.169-3.69,0-9.599-1.144-12.416-3.961z"/>
<path fill="#29166f" d="m614.32,467.35c-1.768,4.648,2.587,10.577,1.52,13.252-1.063,2.667-3.491,4.079-5.861,4.878-5.991,2.019-7.075-3.606-8.075-14.169,3.691,0,9.599-1.144,12.416-3.961z"/>
<path fill="#fff" d="m602.32,471.78c0.75,2.538,2.585,11.549,1.657,13.4-1.125,2.239-4.041,3.375-6.981,3.406s-5.856-1.167-6.981-3.406c-0.928-1.852,0.907-10.862,1.657-13.4h10.648z"/>
<path fill="#e87817" d="m579.97,432c-5.032-3.902-7.604-3.569-7.604-3.569s-2.019,1.635-2.66,7.97c-0.405,4.006-0.633,10.451,2.174,16.628,2.774,6.112,7.654,12.183,9.485,14.039,1.834,1.854,3.345,3.692,3.345,3.692l10.187-4.366s-0.293-2.36-0.374-4.969c-0.077-2.607-1.494-10.163-4.002-16.389-2.543-6.29-7.373-10.568-10.551-13.036z"/>
<path fill="#29166f" d="m613.86,432c5.032-3.902,7.604-3.569,7.604-3.569s2.02,1.635,2.66,7.97c0.406,4.006,0.633,10.451-2.173,16.628-2.774,6.112-7.654,12.183-9.485,14.039-1.834,1.854-3.345,3.692-3.345,3.692l-10.187-4.366s0.293-2.36,0.374-4.969c0.077-2.607,1.494-10.163,4.002-16.389,2.542-6.29,7.372-10.568,10.55-13.036z"/>
<path fill="#fff" d="m602.57,430.27c-3.088-5.569-5.582-6.276-5.582-6.276s-2.5,0.707-5.586,6.276c-1.951,3.521-4.7,9.355-4.556,16.139,0.142,6.711,2.234,14.213,3.185,16.641,0.956,2.428,1.62,4.713,1.62,4.713h11.083s0.66-2.285,1.614-4.713c0.956-2.428,2.632-9.93,2.779-16.641,0.143-6.783-2.609-12.617-4.557-16.139z"/>
<path fill="#f4e109" d="m596.59,470.93c-9.241,0-17.013-2.043-19.26-4.805-0.03,0.133-0.047,0.266-0.047,0.404,0,3.451,8.643,6.256,19.307,6.256,10.662,0,19.308-2.805,19.308-6.256,0-0.139-0.019-0.271-0.048-0.404-2.251,2.762-10.023,4.805-19.26,4.805z"/>
<path fill="#e87817" d="m596.59,468.84c-9.572,0-17.604-2.131-19.827-5.008-0.063,0.21-0.099,0.432-0.099,0.646,0,3.564,8.917,6.457,19.926,6.457,11.001,0,19.924-2.893,19.924-6.457,0-0.215-0.036-0.437-0.099-0.646-2.226,2.877-10.255,5.008-19.825,5.008z"/>
<path fill="#f4e109" d="m596.59,466.98c-9.833,0-18.088-2.182-20.402-5.132-0.021,0.121-0.037,0.248-0.037,0.369,0,3.654,9.148,6.62,20.439,6.62,11.283,0,20.435-2.966,20.435-6.62,0-0.121-0.013-0.248-0.033-0.369-2.314,2.95-10.573,5.132-20.402,5.132z"/>
</g>
<g fill="#000">
<path d="m577.27,430.13c0.364,0.851-0.835,2.477-2.762,3.303s-3.931,0.573-4.296-0.277l-0.109,0.557c0.364,0.851,2.463,1.506,4.75,0.524,2.288-0.98,3.261-2.951,2.896-3.803l-0.479-0.304z"/>
<path d="m581.32,433.08c0.569,1.327-1.313,4.192-4.564,5.585-3.25,1.395-6.623,0.784-7.192-0.545l-0.042,0.734c0.568,1.328,4.137,2.432,7.703,0.902,3.565-1.528,5.226-4.873,4.656-6.202l-0.561-0.474z"/>
<path d="m584.63,436.13c0.718,1.674-1.6,5.621-5.78,7.415-4.183,1.793-8.64,0.749-9.357-0.924l0.065,0.862c0.718,1.674,5.294,3.135,9.784,1.21,4.489-1.925,6.586-6.248,5.867-7.921l-0.579-0.642z"/>
<path d="M586.84,446.47c-1.25,1.38-3.02,2.7-5.18,3.62-5.01,2.15-10.19,1.29-11.04-0.68l0.16,0.68c0.85,1.98,6.07,3.49,11.38,1.22,1.98-0.85,3.6-2.09,4.78-3.37-0.03-0.5-0.09-0.98-0.1-1.47z"/>
<path d="m616.56,430.13c-0.364,0.851,0.834,2.477,2.762,3.303,1.927,0.826,3.931,0.573,4.295-0.277l0.11,0.557c-0.364,0.851-2.463,1.506-4.751,0.524-2.287-0.98-3.26-2.951-2.895-3.803l0.479-0.304z"/>
<path d="m612.52,433.08c-0.569,1.327,1.313,4.192,4.563,5.585,3.251,1.395,6.623,0.784,7.193-0.545l0.041,0.734c-0.568,1.328-4.137,2.432-7.702,0.902-3.565-1.528-5.226-4.873-4.656-6.202l0.561-0.474z"/>
<path d="m609.21,436.13c-0.718,1.674,1.6,5.621,5.78,7.415,4.182,1.793,8.64,0.749,9.357-0.924l-0.066,0.862c-0.717,1.674-5.293,3.135-9.783,1.21-4.489-1.925-6.586-6.248-5.867-7.921l0.579-0.642z"/>
<path d="M607.12,446.59c-0.01,0.5-0.06,1-0.09,1.5,1.17,1.24,2.71,2.4,4.63,3.22,5.3,2.27,10.56,0.76,11.4-1.22l0.16-0.68c-0.85,1.97-6.02,2.83-11.03,0.68-2.09-0.89-3.82-2.17-5.07-3.5z"/>
<path d="m600.83,427.49c0,0.926-1.742,1.948-3.839,1.948s-3.839-1.022-3.839-1.948l-0.32,0.469c0,0.926,1.67,2.354,4.159,2.354s4.159-1.429,4.159-2.354l-0.32-0.469z"/>
<path d="m603.39,431.79c0,1.445-2.858,3.336-6.396,3.336s-6.396-1.891-6.396-3.336l-0.328,0.658c0,1.445,2.844,3.865,6.724,3.865s6.724-2.42,6.724-3.865l-0.328-0.658z"/>
<path d="m605.23,435.9c0,1.821-3.686,4.536-8.235,4.536s-8.235-2.715-8.235-4.536l-0.279,0.818c0,1.821,3.63,4.968,8.515,4.968s8.515-3.146,8.515-4.968l-0.29-0.82z"/>
<path d="m607,443.28c0,2.152-4.238,5.593-10.007,5.593s-10.007-3.44-10.007-5.593l0.143-0.714c0,2.152,4.412,4.994,9.864,4.994s9.864-2.842,9.864-4.994l0.16,0.71z"/>
</g>
</g>
<g id="lion" stroke="#000" stroke-width="0.476" stroke-linejoin="round" stroke-linecap="round">
<path fill="#f4e109" d="m578.1,509.26c6.627,6.627-0.549,11.553-4.858,8.988,0.618-1.141,1.062-3.734,1.062-3.734s4.872,0.986,3.796-5.254z"/>
<g fill="#fff" stroke-width="1.429">
<path d="m445.45,607.5s-4.234,8.459-7.507,10.099c-0.995,0.498-1.821,0.36-1.821,0.36s4.128,3.283,6.071,4.015c1.943,0.726,5.344,2.427,6.436,2.061,1.095-0.362,4.86-2.188,7.652-3.037,2.795-0.849,4.738-0.728,5.952,0.122,1.216,0.854,3.28,3.768,2.552,4.982-0.729,1.213-2.309,2.064-2.309,2.064s0.242,1.094-0.608,2.063c-0.849,0.972-2.914,1.334-2.914,1.334l-0.972,2.554c-0.484,1.335-2.064,1.455-2.064,1.455s-0.122,3.16-1.215,4.735c-1.093,1.583-2.793,1.945-4.857,1.703-2.065-0.242-5.345-1.818-6.316-1.942-0.971-0.121-2.672-1.094-3.157-2.309-0.488-1.214-2.065-2.915-3.766-4.009-1.701-1.092-11.173-5.1-14.453-5.584-3.279-0.489-13.117-3.158-13.117-3.158s8.381-6.803,9.109-12.756c0.729-5.948,2.309-9.105,9.717-7.404,7.406,1.7,17.587,2.652,17.587,2.652z"/>
<path d="m513.73,538.17s2.722-2.971,6.152-4.2c6.747-2.421,9.257-0.081,9.257-0.081s0.136-2.521,5.48-4.223c5.344-1.703,8.259-0.727,8.259-0.727s0.972-3.403,3.888-4.617c2.914-1.215,5.829-1.945,6.315-3.885,0.484-1.943-0.73-5.83,0.241-8.018,0.973-2.188,3.771-4.163,6.406-2.825,2.277,1.155,2.127-0.121,3.826,0.362,1.699,0.49,2.458,1.367,2.458,1.367s1.398-1.182,3.828-0.451c2.427,0.725,3.399,1.547,3.399,1.547s2.429,1.457,2.188,3.156c-0.246,1.704-0.974,4.132-3.887,4.617-2.915,0.485-5.584-0.243-7.53,0.727-1.942,0.976-4.372,4.616-5.102,6.56-0.728,1.945-5.828,15.304-13.115,20.647-7.288,5.342-18.219,16.518-21.133,17.245-2.916,0.731-7.287,1.7-7.287,1.7s1.214-16.518-0.729-21.375c-1.944-4.855-2.914-7.526-2.914-7.526z"/>
</g>
<g fill="url(#grad)" stroke-width="0.667">
<path d="m375.46,542.44s6.964-6.071,13.295,1.129c6.426,7.311,2.914,15.123-0.366,18.218-3.278,3.098-7.604,6.334-13.298,5.285,1.856-2.311,1.032-3.699,0.785-4.513-0.547-1.786,1.408-1.575,3.362-4.122,1.434-1.861,2.518-5.51,1.625-8.188-1.856-5.57-5.403-7.809-5.403-7.809z"/>
<path d="m302.22,567.26c5.333-4.255,8.208,3.433,15.121,0.731,3.417-1.335,6.922-8.2,11.295-8.932,4.373-0.727,9.525,2.734,12.752,0.551,5.647-3.826,0.911-13.302,13.117-18.765,8.628-3.862,18.4-3.101,22.956,1.639,4.554,4.733,5.647,11.476,4.19,14.211-1.458,2.731-5.466,6.011-5.466,6.011l-6.377,1.092s-2.186-4.738-6.739-5.465c-4.554-0.729-6.922,2.369-9.109,5.83-2.62,4.148-9.11,9.475-19.312,7.469-3.77-0.739-7.834,4.918-17.672,2.187-4.889-1.357-7.986-6.626-14.756-6.559z"/>
<path d="m307.31,581.61c4.309-3.92,8.75,0.238,10.765,0.221,3.659-0.029,6.921-2.55,8.015-5.827,1.092-3.281,4.19-6.194,8.015-6.558,3.826-0.367,10.02,0.543,12.752-3.101,2.734-3.642,7.652-14.209,14.028-15.485,6.377-1.272,9.11,0.729,9.838,3.826,0.729,3.1-0.912,9.84-0.912,9.84l-2.549,5.463-8.564,2.918s-0.18,4.371-4.007,5.827c-3.825,1.46-12.752-0.543-17.854,0.729-5.1,1.275-4.653,3.521-11.295,4.737-11.548,2.116-12.173-5.009-18.232-2.59z"/>
<path d="m303.13,600.59c5.109-6.156,10.974,2.27,17.296-1.594,3.454-2.11,4.564-10.246,8.754-11.705,4.191-1.457,9.473,1.277,12.388-2.55,2.915-3.826,0.547-9.841,6.558-12.572,6.014-2.729,10.932-0.728,10.932-0.728s5.283-1.821,8.199-1.459c2.914,0.365,9.656,4.012,9.656,4.012s1.456,8.377-4.01,13.297c-5.465,4.921-10.565,7.471-15.303,5.83-3.05-1.055-4.917-1.641-7.285,0-2.37,1.641-2.37,4.373-6.924,4.373s-8.198-2.914-10.93-1.094c-2.733,1.819-4.158,5.658-8.928,6.377-12.231,1.844-13.419-5.218-20.403-2.187z"/>
<path d="m411.96,622.09s-3.158,2.917-5.102,3.767c-1.944,0.851-6.804,2.913-6.804,2.913s4.374,0.489,4.982,1.823c0.606,1.338-1.104,4.906-5.83,2.308,1.976,4.849,9.664,2.724,11.779,1.823,1.767-0.752,4.858-1.336,5.588-0.364,0.728,0.971-0.266,4.265-5.344,3.033,8.016,4.294,11.66-0.729,14.089-2.185,2.43-1.459,4.615-2.551,5.345-1.459,0.729,1.094,1.207,4.313-4.617,4.373,4.886,1.564,8.016-0.485,9.596-2.064,1.577-1.577,2.269-2.009,3.728-2.496,1.457-0.486,6.594-2.361,7.444-5.032,0.852-2.673,0.729-4.616-0.485-6.194-1.215-1.58-2.672-1.945-3.644-1.824-0.972,0.123-1.578,0.73-1.578,1.462,0,0.726,0.848,3.034-1.579,3.884-2.429,0.851-4.98,1.7-7.41,0.973-2.429-0.729-5.586-1.46-7.894-1.336-2.307,0.12-3.886,1.821-5.83,0.849-1.941-0.975-6.434-4.254-6.434-4.254z"/>
<path d="m547.98,521.16c1.943-1.212,4.13-2.668,7.044-0.483,2.916,2.187,5.155,6.253,8.258,8.987,7.825,6.896,5.394,11.146,1.7,16.516,2.193-9.182-4.856-11.494-4.126-7.041,0.563,3.434,3.069,5.672-1.946,15.06,0.391-3.2,0.244-5.831-0.484-7.531-0.729-1.698-2.672-1.942-2.672,0.245,0,2.182,3.234,10.336-6.073,17.971,7.12-10.635-0.428-14.188-1.944-9.957-2.623,7.322-0.748,14.635-11.173,16.03,8.362-6.896,4.658-12.913,1.215-9.47-4.158,4.158-1.54,11.074-9.961,16.03,4.636-7.661-0.893-7.363-3.155-6.073-1.702,0.971-8.504,5.341-8.504,5.341s0.972-4.37,1.217-8.256c0.24-3.886,1.456-4.614,1.456-4.614s6.315-0.728,8.258-4.617c1.944-3.887,3.158-9.231,7.531-11.172,4.372-1.945,12.631-3.645,14.817-7.771,2.186-4.13,1.701-6.076-0.242-8.988-1.944-2.919-4.131-4.854-3.888-6.559,0.243-1.703,1.186-2.718,2.672-3.648z"/>
<path d="m543.61,529.36c-2.599-2.209-8.987,0.305-8.987,0.305s4.978-1.125,6.618,1.246c0.909,1.307,0.092,2.912-0.273,2.912,0,0-0.001,0.004-0.003,0.004-0.602-0.506-1.366-0.822-2.213-0.822-1.911,0-3.46,1.55-3.46,3.461,0,1.864,1.467,3.346,3.295,3.593,2.319,0.313,4.523-0.357,5.842-1.771,2.023-2.171,2.825-5.83-0.819-8.928z"/>
<path d="m527.3,533.01c-2.945-0.79-8.198,1.273-8.198,1.273s5.968-0.32,7.378,1.457c1.167,1.472,0.584,2.716,0.376,3.006-0.519-0.248-1.098-0.393-1.712-0.393-2.181,0-3.948,1.766-3.948,3.949,0,2.179,1.768,3.947,3.948,3.947,0.183,0,1.249-0.166,1.609-0.344,0.58-0.287,1.353-0.709,1.829-1.141,1.935-1.756,2.5-3.363,2.726-6.291,0.14-1.814-1.063-4.67-4.008-5.463z"/>
<path d="m442.89,610.35s-0.639,3.826-5.102,5.559c-2.21,0.857-4.357,0.055-5.591-0.609,0.699-0.781,1.127-1.812,1.127-2.945,0-2.449-1.982-4.434-4.432-4.434-2.239,0-4.089,1.661-4.389,3.817-0.093,0.667-0.15,1.32,0.017,2.058,0.495,2.16,2.297,4.455,5.524,5.301,3.826,1,8.157-0.08,10.475-2.825,2.46-2.917,2.371-5.922,2.371-5.922z"/>
<path d="m502.07,460.92c19.043-1.048,17.543,12.202,9.716,15.062,0,0-3.765-0.242-8.016,0.123-4.801,0.409-5.969-5.17-0.094-5.92,5.737-0.733,9.562-5.813-1.606-9.265z"/>
<path d="m437.46,479.14c7.594-9.643,15.844,0.732,22.349-3.158,3.758-2.248,7.529-7.775,13.36-7.047,5.83,0.729,9.23,1.943,12.145,0.242,2.914-1.697,7.775-6.557,14.333-4.613,3.462,1.029,4.989,3.785,5.28,5.225,0.417,2.055,0.306,6.435,0.306,6.435l-10.201,1.7c-3.888,1.701-7.774,4.375-7.774,4.375s-4.129-5.831-7.773-6.075c-3.644-0.241-9.472,0.244-12.146,2.429-2.672,2.188-6.779,6.01-11.174,4.616-4.798-1.522-8.798-8.334-18.705-4.129z"/>
<path d="m428.23,491.77c11.636-9.71,11.386,3.228,21.761-6.71,4.08-3.907,8.063-1.188,13.46-4.463,5.13-3.113,9.715-9.23,16.031-7.287,6.316,1.939,9.961,7.529,9.961,7.529s-8.017,5.586-9.717,8.984c-1.701,3.402-10.033-1.645-14.575-1.214-5.101,0.489-7.685,3.819-12.389,6.075-14.333,6.877-13.583-5.686-24.532-2.914z"/>
<path d="m415.36,520.92c9.572,5.393,15.879,2.891,17.973,0,2.939-4.056,3.144-9.014,7.287-10.928,4.055-1.873,8.56,0.189,10.689-2.432,3.157-3.885-0.703-16.087,6.072-20.648,12.631-8.5,24.29,0.488,24.29,0.488s-5.708,5.828-6.558,13.848c-0.718,6.766,2.187,10.442,2.187,10.442s-3.479,6.896-13.118,5.59c-11.751-1.593-13.053-0.324-18.751,4.907-6.874,6.313-20.595,9.499-30.071-1.267z"/>
<path d="m405.88,539.38c8.234-5.445,10.359,1.742,19.433,1.214,4.143-0.241,7.289-2.185,9.959-6.798,2.672-4.617,4.616-7.047,9.959-7.286,5.344-0.245,9.716-0.977,11.902-4.378,2.187-3.398-1.699-12.875,3.887-19.187,5.588-6.317,14.333-4.376,14.333-4.376s-1.194,5.555,0.122,9.594c1.7,5.223,4.493,5.469,4.493,5.469s-5.068,2.232-6.314,8.621c-0.96,4.917,0.97,9.355,0.97,9.355s-6.316,6.559-16.76,5.102c-10.445-1.458-11.417-1.945-15.061,0.728-3.644,2.671-6.719,6.966-15.059,7.286-12.005,0.461-11.505-7.977-21.864-5.344z"/>
</g>
<g stroke-width="1.429" fill="#fff">
<path d="m387.91,572.9c-9.666,7.408-12.791,1.471-20.041-1.459-1.994-0.806-5.344-0.242-5.344-0.242l-1.699-3.888s-0.85-2.671,0.606-3.886c1.458-1.217,5.952-0.85,6.802,0.363,0.851,1.217,3.399,5.104,4.857,6.014,1.458,0.906,4.858,1.883,7.409,1.032,2.55-0.853,8.017-5.222,13.846-9.351,5.83-4.131,10.808-6.317,16.031-6.196,5.223,0.12,9.961,2.184,13.967,2.429,4.01,0.246,8.988-1.822,12.997-4.127,4.007-2.311,15.008-4.386,24.776-12.025,3.811-2.98,12.631-12.995,14.938-15.787,2.309-2.798,12.631,1.943,14.576,3.4,1.944,1.458,10.444,0.484,10.444,0.484s4.979-2.063,6.679-2.309c1.701-0.242,2.186,0.729,2.186,0.729s6.195,8.627,8.138,15.428c1.946,6.797,1.946,21.252,1.095,26.473-0.851,5.227-1.823,14.456-8.624,18.828-6.803,4.371-28.056,14.087-38.986,16.517-10.932,2.429-17.611,6.317-22.592,6.071-4.98-0.24-16.762-3.396-21.01-3.884-4.25-0.487-8.501-0.606-8.501-0.606s-0.487,6.68-2.309,10.808c-1.822,4.127-8.859,10.615-14.204,12.071-5.342,1.459-13.245,2.016-13.729,2.621-0.488,0.61-0.73,2.432,0,3.766,0.727,1.34,4.613,8.383,5.951,9.355,1.552,1.129,5.886,2.124,7.771,1.821,1.815-0.292,4.25,0,5.102,1.335,0.85,1.336,0.851,4.372,0.364,4.979-0.486,0.61-1.578,1.096-1.578,1.096s-0.244,2.188-1.215,2.796c-0.972,0.604-2.673,1.092-2.673,1.092s-0.122,0.847-1.215,1.696c-1.093,0.853-1.785,0.725-1.785,0.725s-1.369,0.237-2.464,0.978c-1.805,1.221-7.742-0.242-10.082-2.43-1.821-1.701-2.188-5.342-2.671-6.559-0.486-1.212-2.066-2.79-3.402-3.765-1.336-0.973-21.618-25.263-21.618-27.811,0-2.552,2.31-9.476,5.346-10.809,3.034-1.338,8.621-3.159,9.351-5.103,0.729-1.942-1.848-11.221,0.242-17.734,2.977-9.277,4.29-9.465,12.268-14.936z"/>
<path d="m363.62,573.09c4.938,1.414,1.375,8.039-3.461,5.828-2.339-1.069-4.281-4.279-3.279-6.833,1.001-2.546,2.825-3.277,4.098-3.005,1.275,0.271,3.644,1.824,3.644,1.824l-3.735,1.91c-0.762,1.458-0.364,2.732,1.094,3.464,1.305,0.651,3.077-0.711,1.639-3.188z"/>
<path d="m474.02,563.19s13.489,1.655,17.775,5.622c4.885,4.523,3.845,8.465,3.845,8.465s7.59-0.119,10.687,3.039c2.536,2.587,4.07,4.248,4.07,4.248l9.534,2.672c3.28,2.673,4.676,5.223,4.676,5.223s2.246-0.119,6.132,1.336c3.887,1.459,6.559,2.916,8.744,2.55,2.187-0.362,6.802-1.577,7.774-3.767,0.973-2.186-0.607-7.526,1.82-9.957,2.43-2.428,3.887-3.034,5.587-2.549,1.703,0.484,3.279,4.492,3.279,4.492l4.253,2.917c0.713,1.906,0,4.005,0,4.005s0.363,0.731,0.971,2.794c0.607,2.063-0.304,2.978-0.304,2.978s0.061,2.488-1.274,3.461c-1.338,0.973-3.158,1.216-3.158,1.216s0.561,2.091-0.168,3.425c-0.729,1.333-1.429,1.785-2.5,2.262-0.831,0.368-2.434,0.141-3.405,1.116-0.973,0.967-1.94,3.396-5.101,2.913-3.159-0.486-4.131-2.428-8.26-3.279-4.128-0.849-18.581-2.065-27.083-3.648-8.502-1.574-15.302-1.574-23.563-1.695-8.26-0.121-20.282-1.459-20.769-5.224-0.486-3.764,2.552-7.53,2.186-9.472-0.364-1.944-2.307-8.016-2.307-10.203s-1.568-10.701,0.849-13.848c1.216-1.579,4.008-1.579,5.71-1.092z"/>
<path d="m364.89,565.07c-0.399,3.365-3.587,1.303-2.824-0.64,0.581-1.479,3.279-1.641,4.646-1.003,1.366,0.639,3.644,3.464,3.825,4.372,0.183,0.911,0.51-3.675-1.183-5.555-1.641-1.825-5.193-2.825-8.018-1.367-2.445,1.261-3.279,3.188-2.915,5.919,0.313,2.352,2.565,3.931,4.646,4.192,3.548,0.448,6.298-3.177,1.823-5.918z"/>
<path d="m375.09,567.07c5.21-2.135,0.96-7.26-1.458-6.922-2.263,0.316-3.593,2.115-3.828,3.643-0.24,1.564,0.296,3.826,1.185,4.826,0.684,0.771,2.445,1.321,2.445,1.321s-1.904-2.741-0.714-4.597c1.142-1.78,3.58-2.093,2.37,1.729z"/>
</g>
<path fill="#fff" stroke="none" d="m463.89,559c8.617,3.146,9.46,3.168,15.442,4.337,3.271,0.64,2.942,4.616,0.585,7.446s-0.472,10.842-2.357,13.199-6.579,5.318-6.579,5.318c-4.263-3.434-8.504-4.848-8.976-11.918-0.47-7.08,1.89-18.39,1.89-18.39z"/>
<g fill="#f4e109">
<path d="m399.81,658.08s-2.623-1.095-3.349,0.82c-0.728,1.916,0.488,2.89,0.488,2.89s4.104,0.651,1.55,5.729c6.804-3.952,1.311-9.439,1.311-9.439z"/>
<path d="m403.26,655.94s-2.242-0.728-2.632,1.055c-0.389,1.783,0.772,3.008,0.772,3.008s2.961,0.75,1.796,4.503c5.353-4.504,0.064-8.566,0.064-8.566z"/>
<path d="m409.39,649.88s-0.831-0.381-1.438,0.833c-0.606,1.218-0.005,2.833-0.005,2.833s3.294,0.391,3.339,4.549c4.642-6.596-1.896-8.215-1.896-8.215z"/>
<path d="m454.1,634.6s-2.087-0.613-2.574,2.422c-0.403,2.519,1.482,2.802,1.482,2.802s3.485-1.263,3.643,2.913c5.405-4.739-2.551-8.137-2.551-8.137z"/>
<path d="m457.37,630.71s-1.765-0.27-1.817,2.491c-0.043,2.289,1.474,2.295,1.474,2.295s3.4-1.688,4.208,2.357c4.254-4.857-3.865-7.143-3.865-7.143z"/>

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 322 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 272 KiB

+2 -7
View File
@@ -91,13 +91,8 @@
"related_applications": [
{
"platform": "play",
"url": "https://play.google.com/store/apps/details?id=pub.ditto.app",
"id": "pub.ditto.app"
},
{
"platform": "itunes",
"url": "https://apps.apple.com/us/app/ditto-fun-social-media/id6761851821",
"id": "6761851821"
"url": "https://play.google.com/store/apps/details?id=spot.agora.app",
"id": "spot.agora.app"
}
],
"prefer_related_applications": false
Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

+35 -1
View File
@@ -56,8 +56,42 @@ self.addEventListener('notificationclick', (event) => {
});
// --- Activate immediately ---
//
// On activate:
// 1. Wipe every Cache Storage entry. A previous version of Agora deployed
// a precaching service worker (Workbox-style) that's still serving stale
// HTML/JS to returning users on this origin. Clearing caches means future
// requests bypass anything the old SW left behind.
// 2. Take control of all open clients via clients.claim().
// 3. Force each controlled tab to navigate to its own URL. clients.claim()
// only changes which SW handles future fetches — it does not re-render
// pages that already finished loading. Without the explicit navigate,
// the user is stuck on the old rendered bundle until they manually
// close and reopen the tab. Since this SW has no fetch handler, the
// navigation falls through to the network and gets the new build.
//
// This SW has no 'fetch' handler, so it never repopulates a cache — push
// notifications are the only thing it intercepts.
self.addEventListener('install', () => self.skipWaiting());
self.addEventListener('activate', (event) => {
event.waitUntil(self.clients.claim());
event.waitUntil(
(async () => {
const keys = await caches.keys();
await Promise.all(keys.map((key) => caches.delete(key)));
await self.clients.claim();
// Soft-reload every open same-origin tab so it picks up the fresh
// index.html + hashed bundle from the network. WindowClient.navigate()
// is same-origin-only by spec, which is exactly what we want.
const windowClients = await self.clients.matchAll({ type: 'window' });
await Promise.all(
windowClients.map((client) =>
'navigate' in client
? client.navigate(client.url).catch(() => {})
: Promise.resolve(),
),
);
})(),
);
});
+120
View File
@@ -0,0 +1,120 @@
// Build a heavily-simplified land-polygon dataset for the hero globe.
//
// Input: Natural Earth 110m countries TopoJSON
// (https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json)
//
// Output: src/lib/landPolygons.ts — an array of rings (each ring is a flat
// array [lng0, lat0, lng1, lat1, ...]) representing landmasses.
//
// Run with: node scripts/build-land-polygons.mjs
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = path.resolve(__dirname, '..');
const INPUT = process.argv[2] ?? '/tmp/opencode/countries-110m.json';
const OUTPUT = path.join(REPO_ROOT, 'src/lib/landPolygons.ts');
const topo = JSON.parse(fs.readFileSync(INPUT, 'utf8'));
const layer = topo.objects.countries;
const transform = topo.transform;
/** Decode a topojson arc into absolute [lng, lat] pairs. */
function decodeArc(arc) {
const out = [];
let x = 0;
let y = 0;
for (const [dx, dy] of arc) {
x += dx;
y += dy;
out.push([
x * transform.scale[0] + transform.translate[0],
y * transform.scale[1] + transform.translate[1],
]);
}
return out;
}
const arcs = topo.arcs.map(decodeArc);
/** Resolve a topojson arc index (negative means reversed) into points. */
function resolveArc(i) {
if (i < 0) {
const arc = arcs[~i];
return arc.slice().reverse();
}
return arcs[i];
}
/** Build a ring from an array of arc indices. */
function buildRing(arcIndices) {
const ring = [];
for (let i = 0; i < arcIndices.length; i++) {
const seg = resolveArc(arcIndices[i]);
// Skip the duplicated joining point between consecutive arcs.
if (i === 0) ring.push(...seg);
else ring.push(...seg.slice(1));
}
return ring;
}
const rings = [];
for (const feature of layer.geometries) {
if (feature.type === 'Polygon') {
for (const arcIndices of feature.arcs) {
rings.push(buildRing(arcIndices));
}
} else if (feature.type === 'MultiPolygon') {
for (const polygon of feature.arcs) {
for (const arcIndices of polygon) {
rings.push(buildRing(arcIndices));
}
}
}
}
// Use the full Natural Earth 110m resolution. We skip Douglas-Peucker
// entirely so coastlines look organic at hero scale rather than blocky.
// We still quantize to 0.1° (well below the rendered pixel size on a
// ~600 px globe) which is a free ~30 % byte saving with no visible loss.
const MIN_VERTS = 3;
const simplifiedRings = [];
for (const ring of rings) {
if (ring.length < MIN_VERTS) continue;
const flat = [];
for (const [lng, lat] of ring) {
flat.push(Math.round(lng * 10) / 10, Math.round(lat * 10) / 10);
}
simplifiedRings.push(flat);
}
const totalCoords = simplifiedRings.reduce((sum, r) => sum + r.length / 2, 0);
const banner = `/**
* Simplified land polygons for the hero globe.
*
* Generated from Natural Earth 110m country boundaries via
* \`scripts/build-land-polygons.mjs\`. Each entry is a flat \`[lng, lat, lng,
* lat, ...]\` ring. We keep the data inline (rather than fetching a TopoJSON
* blob at runtime) so the hero renders instantly, with no network jitter and
* no extra runtime dependency.
*
* Do not edit by hand — re-run the script to regenerate.
*/
`;
const body = `export const LAND_RINGS: readonly (readonly number[])[] = [\n${
simplifiedRings.map((r) => ` [${r.join(',')}],`).join('\n')
}\n];\n`;
fs.writeFileSync(OUTPUT, banner + body);
console.log(
`Wrote ${OUTPUT}`,
`\n rings: ${simplifiedRings.length}`,
`\n vertices: ${totalCoords}`,
`\n bytes: ${fs.statSync(OUTPUT).size}`,
);
+3 -3
View File
@@ -25,7 +25,7 @@
* node scripts/extract-release-notes.mjs <version> [--summary] [--changelog <path>]
*
* --summary Print only the summary paragraph (no headings, no bullets).
* Falls back to "Ditto vX.Y.Z" if the section has no summary.
* Falls back to "Agora vX.Y.Z" if the section has no summary.
* --changelog Path to the changelog file. Defaults to CHANGELOG.md.
*
* Exits 0 with the extracted text on stdout. Exits non-zero if the version is
@@ -128,7 +128,7 @@ if (!section) {
if (summary) {
const text = extractSummary(section);
stdout.write(text ?? `Ditto v${version}`);
stdout.write(text ?? `Agora v${version}`);
stdout.write('\n');
} else {
const body = trimBlankEdges(section).join('\n');
@@ -136,6 +136,6 @@ if (summary) {
stdout.write(body);
stdout.write('\n');
} else {
stdout.write(`Ditto v${version}\n`);
stdout.write(`Agora v${version}\n`);
}
}
+5 -21
View File
@@ -6,8 +6,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { InferSeoMetaPlugin } from "@unhead/addons";
import { createHead, UnheadProvider } from "@unhead/react/client";
import { AppProvider } from "@/components/AppProvider";
import { DMProviderWrapper } from "@/components/DMProviderWrapper";
import { InitialSyncGate } from "@/components/InitialSyncGate";
import { InitialSyncRunner } from "@/components/InitialSyncRunner";
import { NativeNotifications } from "@/components/NativeNotifications";
import NostrProvider from "@/components/NostrProvider";
import { NostrSync } from "@/components/NostrSync";
@@ -19,10 +18,8 @@ import { TooltipProvider } from "@/components/ui/tooltip";
import { useNsecPasteGuard } from "@/hooks/useNsecPasteGuard";
import type { AppConfig } from "@/contexts/AppContext";
import { NWCProvider } from "@/contexts/NWCContext";
import { SparkWalletProvider } from "@/contexts/SparkWalletContext";
import { BuildConfigSchema, type BuildConfig } from "@/lib/schemas";
import { secureStorage } from "@/lib/secureStorage";
import { PROTOCOL_MODE } from "@samthomson/nostr-messaging/core";
import AppRouter from "./AppRouter";
const head = createHead({
@@ -44,7 +41,7 @@ const hardcodedConfig: AppConfig = {
appName: "Agora",
appId: "agora",
shareOrigin: import.meta.env.VITE_SHARE_ORIGIN || undefined,
homePage: "feed",
homePage: "campaigns",
client: "naddr1qvzqqqru7cpzq7q6z5ns2hm5c8msyv83qwzxpxe52j8c4d4q5m92wsp9sflelkh9qqzkg6t5w3hswjl4yp",
magicMouse: false,
theme: "system",
@@ -156,17 +153,9 @@ const hardcodedConfig: AppConfig = {
{ id: 'hot-posts' },
{ id: 'ai-chat' },
],
messaging: {
enabled: true,
relayMode: 'hybrid',
protocolMode: PROTOCOL_MODE.NIP17_ONLY,
renderInlineMedia: true,
soundEnabled: false,
devMode: false,
},
aiBaseURL: 'https://ai.shakespeare.diy/v1',
aiApiKey: '',
aiModel: 'grok-4.1-fast',
aiModel: 'google/gemma-4-26b',
aiSystemPrompt: '',
};
@@ -210,18 +199,13 @@ export function App() {
<NostrLoginProvider storageKey="nostr:login" storage={secureStorage}>
<NostrProvider>
<NostrSync />
<InitialSyncRunner />
<NativeNotifications />
<NWCProvider>
<SparkWalletProvider>
<DMProviderWrapper>
<TooltipProvider>
<InitialSyncGate>
<AppRouter />
</InitialSyncGate>
<AppRouter />
</TooltipProvider>
</DMProviderWrapper>
</SparkWalletProvider>
</NWCProvider>
</NostrProvider>
</NostrLoginProvider>
+34 -8
View File
@@ -6,7 +6,7 @@ import { MinimizedAudioBar } from "@/components/MinimizedAudioBar";
import { AudioPlayerProvider } from "@/contexts/AudioPlayerContext";
import { sidebarItemIcon } from "@/lib/sidebarItems";
import { Toaster } from "./components/ui/toaster";
import { MainLayout } from "./components/MainLayout";
import { FundraiserLayout } from "./components/FundraiserLayout";
import { ScrollToTop } from "./components/ScrollToTop";
import { VersionCheck } from "./components/VersionCheck";
import { useCurrentUser } from "./hooks/useCurrentUser";
@@ -24,11 +24,16 @@ const ReplyComposeModal = lazy(() => import("@/components/ReplyComposeModal").th
// Lazy-loaded emoji pack dialog
const EmojiPackDialog = lazy(() => import("@/components/EmojiPackDialog").then(m => ({ default: m.EmojiPackDialog })));
// HomePage eagerly imported all page components; now lazy-loaded
const HomePage = lazy(() => import("./pages/HomePage").then(m => ({ default: m.HomePage })));
// Campaigns: home + create. (Campaign detail is dispatched from NIP19Page
// when an naddr resolves to kind 30223.) The campaigns list IS the homepage;
// the configurable HomePage delegation from the Twitter-era app is gone.
const CampaignsPage = lazy(() => import("./pages/CampaignsPage").then(m => ({ default: m.CampaignsPage })));
const CreateCampaignPage = lazy(() => import("./pages/CreateCampaignPage").then(m => ({ default: m.CreateCampaignPage })));
const AllCampaignsPage = lazy(() => import("./pages/AllCampaignsPage").then(m => ({ default: m.AllCampaignsPage })));
// All other pages: code-split via React.lazy
const ActionsPage = lazy(() => import("./pages/ActionsPage"));
const CreateActionPage = lazy(() => import("./pages/CreateActionPage").then(m => ({ default: m.CreateActionPage })));
const AdvancedSettingsPage = lazy(() => import("./pages/AdvancedSettingsPage").then(m => ({ default: m.AdvancedSettingsPage })));
const AIChatPage = lazy(() => import("./pages/AIChatPage").then(m => ({ default: m.AIChatPage })));
const AppearanceSettingsPage = lazy(() => import("./pages/AppearanceSettingsPage").then(m => ({ default: m.AppearanceSettingsPage })));
@@ -40,21 +45,25 @@ const BookmarksPage = lazy(() => import("./pages/BookmarksPage").then(m => ({ de
const BooksPage = lazy(() => import("./pages/BooksPage").then(m => ({ default: m.BooksPage })));
const ChangelogPage = lazy(() => import("./pages/ChangelogPage").then(m => ({ default: m.ChangelogPage })));
const CommunitiesPage = lazy(() => import("./pages/CommunitiesPage").then(m => ({ default: m.CommunitiesPage })));
const CreateCommunityPage = lazy(() => import("./pages/CreateCommunityPage").then(m => ({ default: m.CreateCommunityPage })));
const CreateEventPage = lazy(() => import("./pages/CreateEventPage").then(m => ({ default: m.CreateEventPage })));
const ContentPage = lazy(() => import("./pages/ContentPage").then(m => ({ default: m.ContentPage })));
const ContentSettingsPage = lazy(() => import("./pages/ContentSettingsPage").then(m => ({ default: m.ContentSettingsPage })));
const CSAEPolicyPage = lazy(() => import("./pages/CSAEPolicyPage").then(m => ({ default: m.CSAEPolicyPage })));
const DiscoverPage = lazy(() => import("./pages/DiscoverPage").then(m => ({ default: m.DiscoverPage })));
const DomainFeedPage = lazy(() => import("./pages/DomainFeedPage").then(m => ({ default: m.DomainFeedPage })));
const EventsFeedPage = lazy(() => import("./pages/EventsFeedPage").then(m => ({ default: m.EventsFeedPage })));
const ExternalContentPage = lazy(() => import("./pages/ExternalContentPage").then(m => ({ default: m.ExternalContentPage })));
const GeotagPage = lazy(() => import("./pages/GeotagPage").then(m => ({ default: m.GeotagPage })));
const HashtagPage = lazy(() => import("./pages/HashtagPage").then(m => ({ default: m.HashtagPage })));
const HelpPage = lazy(() => import("./pages/HelpPage").then(m => ({ default: m.HelpPage })));
const DonorGuidePage = lazy(() => import("./pages/DonorGuidePage").then(m => ({ default: m.DonorGuidePage })));
const ActivistGuidePage = lazy(() => import("./pages/ActivistGuidePage").then(m => ({ default: m.ActivistGuidePage })));
const KindFeedPage = lazy(() => import("./pages/KindFeedPage").then(m => ({ default: m.KindFeedPage })));
const LetterComposePage = lazy(() => import("./pages/LetterComposePage").then(m => ({ default: m.LetterComposePage })));
const LetterPreferencesPage = lazy(() => import("./pages/LetterPreferencesPage").then(m => ({ default: m.LetterPreferencesPage })));
const LettersPage = lazy(() => import("./pages/LettersPage").then(m => ({ default: m.LettersPage })));
const MagicSettingsPage = lazy(() => import("./pages/MagicSettingsPage").then(m => ({ default: m.MagicSettingsPage })));
const MessagingSettingsPage = lazy(() => import("./pages/MessagingSettingsPage").then(m => ({ default: m.MessagingSettingsPage })));
const MusicPage = lazy(() => import("./pages/MusicPage").then(m => ({ default: m.MusicPage })));
const NetworkSettingsPage = lazy(() => import("./pages/NetworkSettingsPage").then(m => ({ default: m.NetworkSettingsPage })));
const NIP19Page = lazy(() => import("./pages/NIP19Page").then(m => ({ default: m.NIP19Page })));
@@ -76,11 +85,14 @@ const VerifiedPage = lazy(() => import("./pages/VerifiedPage").then(m => ({ defa
const VideosFeedPage = lazy(() => import("./pages/VideosFeedPage").then(m => ({ default: m.VideosFeedPage })));
const VinesFeedPage = lazy(() => import("./pages/VinesFeedPage").then(m => ({ default: m.VinesFeedPage })));
const WalletPage = lazy(() => import("./pages/WalletPage").then(m => ({ default: m.WalletPage })));
const WalletRecoveryPage = lazy(() => import("./pages/WalletRecoveryPage").then(m => ({ default: m.WalletRecoveryPage })));
const WalletSettingsPage = lazy(() => import("./pages/WalletSettingsPage").then(m => ({ default: m.WalletSettingsPage })));
const WebxdcFeedPage = lazy(() => import("./pages/WebxdcFeedPage").then(m => ({ default: m.WebxdcFeedPage })));
const WikipediaPage = lazy(() => import("./pages/WikipediaPage").then(m => ({ default: m.WikipediaPage })));
const WorldPage = lazy(() => import("./pages/WorldPage").then(m => ({ default: m.WorldPage })));
const FollowPage = lazy(() => import("./pages/FollowPage").then(m => ({ default: m.FollowPage })));
const ReceivePage = lazy(() => import("./pages/ReceivePage").then(m => ({ default: m.ReceivePage })));
const ClaimPage = lazy(() => import("./pages/ClaimPage").then(m => ({ default: m.ClaimPage })));
const RemoteLoginSuccessPage = lazy(() => import("./pages/RemoteLoginSuccessPage").then(m => ({ default: m.RemoteLoginSuccessPage })));
const pollsDef = getExtraKindDef("polls")!;
@@ -153,11 +165,17 @@ export function AppRouter() {
<Routes>
{/* Auto-follow deep link: fullscreen immersive (no sidebars/nav) */}
<Route path="/follow/:npub" element={<FollowPage />} />
<Route path="/receive" element={<ReceivePage />} />
<Route path="/claim" element={<ClaimPage />} />
{/* All routes share the persistent MainLayout (sidebar + nav) */}
<Route element={<MainLayout />}>
<Route path="/" element={<HomePage />} />
{/* All routes share the persistent FundraiserLayout (top nav + footer) */}
<Route element={<FundraiserLayout />}>
<Route path="/" element={<CampaignsPage />} />
<Route path="/discover" element={<DiscoverPage />} />
<Route path="/feed" element={<Index />} />
<Route path="/campaigns" element={<Navigate to="/" replace />} />
<Route path="/campaigns/new" element={<CreateCampaignPage />} />
<Route path="/campaigns/all" element={<AllCampaignsPage />} />
<Route path="/notifications" element={<NotificationsPage />} />
<Route path="/messages" element={<MessagesPage />} />
<Route path="/search" element={<SearchPage />} />
@@ -176,7 +194,6 @@ export function AppRouter() {
path="/settings/notifications"
element={<NotificationSettings />}
/>
<Route path="/settings/messaging" element={<MessagingSettingsPage />} />
<Route
path="/settings/advanced"
element={<AdvancedSettingsPage />}
@@ -185,6 +202,7 @@ export function AppRouter() {
<Route path="/settings/network" element={<NetworkSettingsPage />} />
<Route path="/lists" element={<UserListsPage />} />
<Route path="/events" element={<EventsFeedPage />} />
<Route path="/events/new" element={<CreateEventPage />} />
<Route path="/photos" element={<PhotosFeedPage />} />
<Route path="/videos" element={<VideosFeedPage />} />
{/* /streams redirects to /videos for backward compatibility */}
@@ -268,6 +286,8 @@ export function AppRouter() {
}
/>
<Route path="/wallet" element={<WalletPage />} />
<Route path="/wallet/recovery" element={<WalletRecoveryPage />} />
<Route path="/bitcoin" element={<Navigate to="/wallet" replace />} />
<Route path="/bookmarks" element={<BookmarksPage />} />
<Route path="/ai-chat" element={<AIChatPage />} />
<Route path="/verified" element={<VerifiedPage />} />
@@ -278,10 +298,13 @@ export function AppRouter() {
<Route path="/bluesky" element={<BlueskyPage />} />
<Route path="/wikipedia" element={<WikipediaPage />} />
<Route path="/communities" element={<CommunitiesPage />} />
<Route path="/communities/new" element={<CreateCommunityPage />} />
<Route path="/letters" element={<LettersPage />} />
<Route path="/letters/compose" element={<LetterComposePage />} />
<Route path="/settings/letters" element={<LetterPreferencesPage />} />
<Route path="/help" element={<HelpPage />} />
<Route path="/help/donors" element={<DonorGuidePage />} />
<Route path="/help/activists" element={<ActivistGuidePage />} />
<Route path="/privacy" element={<PrivacyPolicyPage />} />
<Route path="/safety" element={<CSAEPolicyPage />} />
<Route path="/changelog" element={<ChangelogPage />} />
@@ -292,6 +315,9 @@ export function AppRouter() {
/>
<Route path="/i/*" element={<ExternalContentPage />} />
<Route path="/actions" element={<ActionsPage />} />
<Route path="/actions/new" element={<CreateActionPage />} />
<Route path="/pledges" element={<ActionsPage />} />
<Route path="/pledges/new" element={<CreateActionPage />} />
<Route path="/agent" element={<AIChatPage />} />
<Route path="/organizers" element={<OrganizersPage />} />
<Route path="/dashboard" element={<EventDashboardPage />} />
+114
View File
@@ -0,0 +1,114 @@
import { Link } from 'react-router-dom';
import { format } from 'date-fns';
import { nip19 } from 'nostr-tools';
import type { NostrEvent } from '@nostrify/nostrify';
import { Camera, Clock, DollarSign, Info, Megaphone, Palette } from 'lucide-react';
import { parseAction, type Action } from '@/hooks/useActions';
import { useBtcPrice } from '@/hooks/useBtcPrice';
import { countryCodeToFlag, getGeoDisplayName } from '@/lib/countries';
import { CountryFlag } from '@/components/CountryFlag';
import { DEFAULT_COVER_IMAGE } from '@/lib/defaultActionCovers';
import { formatSats, satsToUSDWhole } from '@/lib/bitcoin';
import { cn } from '@/lib/utils';
const ACTION_ICONS = {
photo: Camera,
art: Palette,
info: Info,
action: Megaphone,
} as const;
function actionNaddr(action: Action): string {
return nip19.naddrEncode({
kind: 36639,
pubkey: action.pubkey,
identifier: action.id,
});
}
export function ActionContent({ event, compact = true }: { event: NostrEvent; compact?: boolean }) {
const { data: btcPrice } = useBtcPrice();
const action = parseAction(event);
if (!action) return null;
const Icon = ACTION_ICONS[action.type];
const now = Date.now() / 1000;
const startTime = action.startTime ?? action.createdAt;
const isUpcoming = startTime > now;
const isExpired = !!action.deadline && action.deadline <= now;
const coverImage = action.image ?? DEFAULT_COVER_IMAGE;
const href = `/${actionNaddr(action)}`;
return (
<Link
to={href}
className={cn(
'mt-2 block overflow-hidden rounded-xl border border-border bg-card transition-colors hover:bg-muted/30',
isExpired && 'opacity-75',
)}
onClick={(e) => e.stopPropagation()}
>
<div className={cn('relative overflow-hidden bg-muted', compact ? 'h-36' : 'h-56')}>
<img
src={coverImage}
alt={action.title}
className={cn('h-full w-full object-cover transition-transform duration-300 hover:scale-[1.02]', isExpired && 'grayscale')}
loading="lazy"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-black/10 to-transparent" />
{action.countryCode && (
<CountryFlag
code={action.countryCode}
emoji={countryCodeToFlag(action.countryCode)}
label={getGeoDisplayName(action.countryCode)}
className="absolute left-3 top-3 text-2xl drop-shadow-md"
/>
)}
<div className="absolute bottom-3 left-3 right-3 flex items-center justify-between gap-2 text-white">
<span className="inline-flex items-center gap-1.5 rounded-full bg-black/45 px-2.5 py-1 text-xs font-semibold backdrop-blur-sm">
<Icon className="size-3.5" />
Pledge
</span>
{isExpired ? (
<span className="inline-flex items-center gap-1 rounded-full bg-black/45 px-2.5 py-1 text-xs font-medium backdrop-blur-sm">
<Clock className="size-3" /> Expired
</span>
) : isUpcoming ? (
<span className="inline-flex items-center gap-1 rounded-full bg-black/45 px-2.5 py-1 text-xs font-medium backdrop-blur-sm">
<Clock className="size-3" /> Starts {format(startTime * 1000, 'MMM d')}
</span>
) : action.deadline ? (
<span className="inline-flex items-center gap-1 rounded-full bg-black/45 px-2.5 py-1 text-xs font-medium backdrop-blur-sm">
<Clock className="size-3" /> Due {format(action.deadline * 1000, 'MMM d')}
</span>
) : null}
</div>
</div>
<div className="space-y-2 p-3">
<div className="flex items-start gap-2">
<Megaphone className="mt-0.5 size-5 shrink-0 text-primary" />
<h3 className="line-clamp-2 text-base font-bold leading-tight">{action.title}</h3>
</div>
{action.description.trim() && (
<p className="line-clamp-3 text-sm leading-relaxed text-muted-foreground">
{action.description}
</p>
)}
<div className="flex items-center gap-2 text-sm">
<DollarSign className="size-4 shrink-0 text-primary" />
<span className="font-semibold">
{btcPrice ? satsToUSDWhole(action.bounty, btcPrice) : `${formatSats(action.bounty)} sats`}
</span>
{btcPrice && <span className="text-xs text-muted-foreground">~{formatSats(action.bounty)} sats</span>}
{action.countryCode && (
<>
<span className="text-muted-foreground/50">·</span>
<span className="truncate text-xs text-muted-foreground">{getGeoDisplayName(action.countryCode)}</span>
</>
)}
</div>
</div>
</Link>
);
}
-841
View File
@@ -1,841 +0,0 @@
import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
import { UserPlus, Loader2, X, Search, Crown, Users, PartyPopper } from 'lucide-react';
import { useNostr } from '@nostrify/react';
import { useQueryClient } from '@tanstack/react-query';
import type { NostrEvent } from '@nostrify/nostrify';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import { BadgeThumbnail } from '@/components/BadgeThumbnail';
import { ImageUploadField } from '@/components/ImageUploadField';
import { EmojifiedText } from '@/components/CustomEmoji';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useToast } from '@/hooks/useToast';
import { useBadgeDefinitions } from '@/hooks/useBadgeDefinitions';
import { useSearchProfiles, type SearchProfile } from '@/hooks/useSearchProfiles';
import { useSearchPeopleLists, type PeopleListSearchResult } from '@/hooks/useSearchPeopleLists';
import { PortalContainerProvider } from '@/hooks/usePortalContainer';
import { parseAuthorEvent } from '@/hooks/useAuthor';
import { genUserName } from '@/lib/genUserName';
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import {
COMMUNITY_DEFINITION_KIND,
BADGE_DEFINITION_KIND,
BADGE_AWARD_KIND,
EMPTY_MODERATION,
type CommunityMember,
type CommunityMembership,
type CommunityModeration,
type ParsedCommunity,
} from '@/lib/communityUtils';
// ── Types ─────────────────────────────────────────────────────────────────────
type MemberRole = 'moderator' | 'member';
interface PendingMember {
profile: SearchProfile;
role: MemberRole;
}
interface BadgeRef {
pubkey: string;
identifier: string;
}
interface CommunityMembersCacheValue {
membership: CommunityMembership;
moderation: CommunityModeration;
rankMap: Map<string, CommunityMember>;
}
interface AddMemberDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** The raw community definition event. */
communityEvent: NostrEvent;
/** Parsed community data. */
community: ParsedCommunity;
/** Whether the current user is the founder (can add moderators). */
isFounder: boolean;
/** Existing active members and moderators, excluded from duplicate adds. */
existingMemberPubkeys: string[];
}
interface AddMemberPanelProps {
/** The raw community definition event. */
communityEvent: NostrEvent;
/** Parsed community data. */
community: ParsedCommunity;
/** Whether the current user is the founder (can add moderators). */
isFounder: boolean;
/** Existing active members and moderators, excluded from duplicate adds. */
existingMemberPubkeys: string[];
/** Called after a successful publish so the host (dialog/page) can close or refresh. */
onComplete?: () => void;
}
function parseBadgeATag(aTag: string | undefined): BadgeRef | undefined {
if (!aTag) return undefined;
const [kind, pubkey, ...identifierParts] = aTag.split(':');
const identifier = identifierParts.join(':');
if (kind !== String(BADGE_DEFINITION_KIND) || !pubkey || !identifier) return undefined;
return { pubkey, identifier };
}
function isHexPubkey(value: string): boolean {
return /^[0-9a-f]{64}$/i.test(value);
}
function makeFallbackProfile(pubkey: string): SearchProfile {
return {
pubkey,
metadata: {},
event: {
id: '',
pubkey,
created_at: 0,
kind: 0,
tags: [],
content: '{}',
sig: '',
},
};
}
function profileFromEvent(event: NostrEvent): SearchProfile {
const parsed = parseAuthorEvent(event);
return { pubkey: event.pubkey, metadata: parsed.metadata ?? {}, event };
}
// ── Component ─────────────────────────────────────────────────────────────────
export function AddMemberDialog({
open,
onOpenChange,
communityEvent,
community,
isFounder,
existingMemberPubkeys,
}: AddMemberDialogProps) {
const { user } = useCurrentUser();
const [portalContainer, setPortalContainer] = useState<HTMLElement | undefined>(undefined);
const dialogContentRef = useCallback((node: HTMLElement | null) => {
setPortalContainer(node ?? undefined);
}, []);
if (!user) return null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent ref={dialogContentRef} className="sm:max-w-md gap-0 p-0 overflow-visible">
<PortalContainerProvider value={portalContainer}>
<DialogHeader className="px-5 pt-5 pb-3">
<DialogTitle className="flex items-center gap-2">
<UserPlus className="size-5 text-primary" />
Add Members
</DialogTitle>
<DialogDescription>Add to community</DialogDescription>
</DialogHeader>
<ScrollArea className="max-h-[60vh]">
<div className="px-5 pb-5">
<AddMemberPanel
communityEvent={communityEvent}
community={community}
isFounder={isFounder}
existingMemberPubkeys={existingMemberPubkeys}
onComplete={() => onOpenChange(false)}
/>
</div>
</ScrollArea>
</PortalContainerProvider>
</DialogContent>
</Dialog>
);
}
/**
* Inline form that searches for people and adds them as community members or
* moderators. Pulled out of `AddMemberDialog` so the same flow can be
* embedded inside other surfaces — e.g. the members dialog on
* `CommunityDetailPage` — without nesting a second `Dialog`.
*/
export function AddMemberPanel({
communityEvent,
community,
isFounder,
existingMemberPubkeys,
onComplete,
}: AddMemberPanelProps) {
const { user } = useCurrentUser();
const { nostr } = useNostr();
const { toast } = useToast();
const queryClient = useQueryClient();
// Form state
const [pendingMembers, setPendingMembers] = useState<PendingMember[]>([]);
const [badgeImageUrl, setBadgeImageUrl] = useState('');
const [isBadgeImageUploading, setIsBadgeImageUploading] = useState(false);
const [isPublishing, setIsPublishing] = useState(false);
// Mutations
const { mutateAsync: publishEvent } = useNostrPublish();
// Does this community already have a member badge definition?
const existingBadgeATag = community.memberBadgeATag;
const hasBadge = !!existingBadgeATag;
const existingBadgeRef = useMemo(() => parseBadgeATag(existingBadgeATag), [existingBadgeATag]);
const existingBadgeRefs = useMemo(() => existingBadgeRef ? [existingBadgeRef] : [], [existingBadgeRef]);
const { badgeMap, isLoading: isBadgeLoading, isError: isBadgeError } = useBadgeDefinitions(existingBadgeRefs);
const existingBadge = existingBadgeATag ? badgeMap.get(existingBadgeATag) : undefined;
// Are there any pending members with the "member" role?
const hasPendingMembers = pendingMembers.some((m) => m.role === 'member');
// Will we need to create a badge? (members added + no badge exists yet)
const needsBadgeCreation = hasPendingMembers && !hasBadge;
const resetForm = useCallback(() => {
setPendingMembers([]);
setBadgeImageUrl('');
setIsBadgeImageUploading(false);
setIsPublishing(false);
}, []);
// ── People management ─────────────────────────────────────────────────────
const addPerson = useCallback((profile: SearchProfile) => {
if (!user) return;
if (profile.pubkey === community.founderPubkey) {
toast({ title: 'Already the founder' });
return;
}
if (existingMemberPubkeys.includes(profile.pubkey)) {
toast({ title: 'Already in the community' });
return;
}
if (pendingMembers.some((m) => m.profile.pubkey === profile.pubkey)) {
toast({ title: 'Already added' });
return;
}
setPendingMembers((prev) => [...prev, { profile, role: 'member' }]);
}, [user, community.founderPubkey, existingMemberPubkeys, pendingMembers, toast]);
const addPeople = useCallback((profiles: SearchProfile[], sourceTitle?: string) => {
if (!user) return;
const excluded = new Set([
community.founderPubkey,
...existingMemberPubkeys,
...pendingMembers.map((m) => m.profile.pubkey),
]);
const nextProfiles: SearchProfile[] = [];
for (const profile of profiles) {
if (excluded.has(profile.pubkey)) continue;
excluded.add(profile.pubkey);
nextProfiles.push(profile);
}
if (nextProfiles.length === 0) {
toast({ title: 'No new people to add', description: 'Everyone in that follow pack is already included.' });
return;
}
setPendingMembers((prev) => [...prev, ...nextProfiles.map((profile) => ({ profile, role: 'member' as const }))]);
if (sourceTitle) {
toast({ title: `Added ${nextProfiles.length} from ${sourceTitle}` });
}
}, [user, community.founderPubkey, existingMemberPubkeys, pendingMembers, toast]);
const removePerson = useCallback((pubkey: string) => {
setPendingMembers((prev) => prev.filter((m) => m.profile.pubkey !== pubkey));
}, []);
const setRole = useCallback((pubkey: string, role: MemberRole) => {
if (!isFounder) return; // Only founder can appoint moderators
setPendingMembers((prev) => prev.map((m) =>
m.profile.pubkey === pubkey
? { ...m, role }
: m,
));
}, [isFounder]);
const applyOptimisticMembership = useCallback((members: PendingMember[], awardEvents: Map<string, NostrEvent>) => {
queryClient.setQueryData<CommunityMembersCacheValue>(['community-members', community.aTag], (prev) => {
const moderation = prev?.moderation ?? EMPTY_MODERATION;
const rankMap = new Map(prev?.rankMap ?? []);
const membershipByPubkey = new Map(
(prev?.membership.members ?? []).map((member) => [member.pubkey, member] as const),
);
const seedRankZero = (pubkey: string) => {
if (moderation.bannedPubkeys.has(pubkey)) return;
const member: CommunityMember = { pubkey, rank: 0 };
if (!membershipByPubkey.has(pubkey)) membershipByPubkey.set(pubkey, member);
if (!rankMap.has(pubkey)) rankMap.set(pubkey, member);
};
seedRankZero(community.founderPubkey);
community.moderatorPubkeys.forEach(seedRankZero);
for (const pending of members) {
if (moderation.bannedPubkeys.has(pending.profile.pubkey)) continue;
const nextMember: CommunityMember = pending.role === 'moderator'
? { pubkey: pending.profile.pubkey, rank: 0 }
: {
pubkey: pending.profile.pubkey,
rank: 1,
awardEvent: awardEvents.get(pending.profile.pubkey),
awardedBy: user?.pubkey,
};
const current = membershipByPubkey.get(nextMember.pubkey);
if (!current || nextMember.rank < current.rank) {
membershipByPubkey.set(nextMember.pubkey, nextMember);
}
const currentRank = rankMap.get(nextMember.pubkey);
if (!currentRank || nextMember.rank < currentRank.rank) {
rankMap.set(nextMember.pubkey, nextMember);
}
}
const membership: CommunityMembership = {
members: Array.from(membershipByPubkey.values()).sort((a, b) => a.rank - b.rank),
};
return { membership, moderation, rankMap };
});
}, [community.aTag, community.founderPubkey, community.moderatorPubkeys, queryClient, user?.pubkey]);
// ── Publish ───────────────────────────────────────────────────────────────
const handleSubmit = useCallback(async () => {
if (!user || pendingMembers.length === 0) return;
if (isBadgeImageUploading) {
toast({ title: 'Image is still uploading', description: 'Please wait for the upload to finish.' });
return;
}
if (badgeImageUrl.trim() && !sanitizeUrl(badgeImageUrl.trim())) {
toast({ title: 'Image URL must be a valid https URL', variant: 'destructive' });
return;
}
if (needsBadgeCreation && !isFounder) {
toast({ title: 'Member badge is missing', description: 'Only the founder can initialize community membership.', variant: 'destructive' });
return;
}
setIsPublishing(true);
try {
const newModerators = pendingMembers.filter((m) => m.role === 'moderator');
const newMembers = pendingMembers.filter((m) => m.role === 'member');
let badgeATag = existingBadgeATag;
// Step 1: Create badge definition if needed
if (newMembers.length > 0 && !hasBadge) {
const badgeDTag = `${community.dTag}-member`;
const existingBadge = await nostr.query([{
kinds: [BADGE_DEFINITION_KIND],
authors: [user.pubkey],
'#d': [badgeDTag],
limit: 1,
}]);
if (existingBadge.length > 0) {
toast({
title: 'Member badge ID already in use',
description: 'This community needs a member badge, but that badge identifier already exists on your account.',
variant: 'destructive',
});
setIsPublishing(false);
return;
}
const badgeTags: string[][] = [
['d', badgeDTag],
['name', 'Member'],
['description', `Member of ${community.name}`],
];
const sanitizedBadgeImage = sanitizeUrl(badgeImageUrl.trim());
if (sanitizedBadgeImage) {
badgeTags.push(['image', sanitizedBadgeImage, '1024x1024']);
}
badgeTags.push(['alt', `Badge definition: Member of ${community.name}`]);
const badgeEvent = await publishEvent({
kind: BADGE_DEFINITION_KIND,
content: '',
tags: badgeTags,
} as Omit<NostrEvent, 'id' | 'pubkey' | 'sig'>);
badgeATag = `${BADGE_DEFINITION_KIND}:${badgeEvent.pubkey}:${badgeDTag}`;
}
// Step 2: Republish community definition if needed
// Needed when: adding moderators (new p tags) OR badge was just created (new a tag)
const needsCommunityUpdate = newModerators.length > 0 || (newMembers.length > 0 && !hasBadge);
if (needsCommunityUpdate) {
// Fetch fresh community event to avoid stale overwrites
const prev = await fetchFreshEvent(nostr, {
kinds: [COMMUNITY_DEFINITION_KIND],
authors: [communityEvent.pubkey],
'#d': [community.dTag],
});
const baseTags = prev?.tags ?? communityEvent.tags;
const updatedTags = [...baseTags];
// Add new moderator p tags
for (const mod of newModerators) {
// Don't add if already exists
const exists = updatedTags.some(
([n, v, , role]) => n === 'p' && v === mod.profile.pubkey && role === 'moderator',
);
if (!exists) {
updatedTags.push(['p', mod.profile.pubkey, '', 'moderator']);
}
}
// Add badge a tag if badge was just created
if (badgeATag && !hasBadge) {
updatedTags.push(['a', badgeATag, '', 'member']);
}
const updatedEvent = await publishEvent({
kind: COMMUNITY_DEFINITION_KIND,
content: prev?.content ?? '',
tags: updatedTags,
prev: prev ?? undefined,
} as Omit<NostrEvent, 'id' | 'pubkey' | 'sig'> & { prev?: NostrEvent });
queryClient.setQueryData(
['addr-event', COMMUNITY_DEFINITION_KIND, communityEvent.pubkey, community.dTag],
updatedEvent,
);
queryClient.setQueryData(['event', updatedEvent.id], updatedEvent);
}
// Step 3: Publish badge awards for each member
const memberAwardEvents = new Map<string, NostrEvent>();
if (newMembers.length > 0 && badgeATag) {
for (const member of newMembers) {
const awardEvent = await publishEvent({
kind: BADGE_AWARD_KIND,
content: '',
tags: [
['a', badgeATag],
['p', member.profile.pubkey],
['alt', `Badge award: Member in ${community.name}`],
],
} as Omit<NostrEvent, 'id' | 'pubkey' | 'sig'>);
memberAwardEvents.set(member.profile.pubkey, awardEvent);
}
}
applyOptimisticMembership(pendingMembers, memberAwardEvents);
queryClient.invalidateQueries({ queryKey: ['community-members', community.aTag], refetchType: 'inactive' });
queryClient.invalidateQueries({ queryKey: ['community-activity-feed'], exact: false });
queryClient.invalidateQueries({ queryKey: ['my-communities'], exact: false });
if (!hasBadge && newMembers.length > 0) {
queryClient.invalidateQueries({ queryKey: ['badge-feed'] });
}
const addedCount = pendingMembers.length;
toast({ title: `Added ${addedCount} ${addedCount === 1 ? 'person' : 'people'} to the community` });
resetForm();
onComplete?.();
} catch (err) {
toast({
title: 'Failed to add members',
description: err instanceof Error ? err.message : 'Please try again.',
variant: 'destructive',
});
} finally {
setIsPublishing(false);
}
}, [
user, pendingMembers, existingBadgeATag, hasBadge, needsBadgeCreation, isFounder, community, communityEvent,
badgeImageUrl, nostr, publishEvent, queryClient, toast, resetForm, onComplete, applyOptimisticMembership, isBadgeImageUploading,
]);
if (!user) return null;
return (
<div className="space-y-4">
{/* People search */}
<div className="space-y-1.5">
<Label>Search</Label>
<PersonSearch
onAdd={addPerson}
onAddMany={addPeople}
excludePubkeys={[
community.founderPubkey,
...existingMemberPubkeys,
...pendingMembers.map((m) => m.profile.pubkey),
]}
/>
</div>
{/* Pending members list */}
{pendingMembers.length > 0 && (
<div className="space-y-1.5">
<Label>
People to add
<span className="text-muted-foreground font-normal ml-1">({pendingMembers.length})</span>
</Label>
<div className="space-y-1">
{pendingMembers.map((pm) => (
<PendingMemberChip
key={pm.profile.pubkey}
pending={pm}
onRemove={removePerson}
onRoleChange={isFounder ? setRole : undefined}
/>
))}
</div>
</div>
)}
{hasPendingMembers && (
<div className="space-y-2">
<Label>Member badge</Label>
{hasBadge ? (
<div className="rounded-xl border border-border/60 bg-secondary/30 p-3">
{isBadgeError ? (
<p className="text-sm text-destructive">Failed to load the current member badge.</p>
) : isBadgeLoading ? (
<div className="flex items-center gap-3">
<div className="size-12 animate-pulse rounded-lg bg-muted" />
<div className="space-y-2">
<div className="h-4 w-28 animate-pulse rounded bg-muted" />
<div className="h-3 w-44 animate-pulse rounded bg-muted" />
</div>
</div>
) : existingBadge ? (
<div className="flex items-center gap-3">
<BadgeThumbnail badge={existingBadge} size={48} className="shrink-0" />
<div className="min-w-0">
<p className="text-sm font-medium truncate">{existingBadge.name}</p>
<p className="text-xs text-muted-foreground line-clamp-2">
{existingBadge.description || 'Selected members will receive this badge.'}
</p>
</div>
</div>
) : (
<p className="text-sm text-destructive">The configured member badge could not be found.</p>
)}
</div>
) : (
<ImageUploadField
id="member-badge-image"
label={<>Create Member Badge Image <span className="text-muted-foreground font-normal">(optional)</span></>}
value={badgeImageUrl}
onChange={setBadgeImageUrl}
onUploadingChange={setIsBadgeImageUploading}
uploadToastTitle="Badge image uploaded"
previewAlt="Badge preview"
objectFit="contain"
dropAreaClassName="min-h-24"
/>
)}
</div>
)}
{/* Submit button */}
<Button
onClick={handleSubmit}
disabled={pendingMembers.length === 0 || isPublishing || isBadgeImageUploading}
className="w-full gap-2"
>
{isPublishing ? (
<><Loader2 className="size-4 animate-spin" /> Adding...</>
) : (
<><UserPlus className="size-4" /> Add {pendingMembers.length || ''} {pendingMembers.length === 1 ? 'Person' : pendingMembers.length > 1 ? 'People' : 'Members'}</>
)}
</Button>
</div>
);
}
// ── Sub-Components ────────────────────────────────────────────────────────────
/** Inline type-ahead person search. */
function PersonSearch({
onAdd,
onAddMany,
excludePubkeys,
}: {
onAdd: (profile: SearchProfile) => void;
onAddMany: (profiles: SearchProfile[], sourceTitle?: string) => void;
excludePubkeys: string[];
}) {
const { nostr } = useNostr();
const { toast } = useToast();
const [query, setQuery] = useState('');
const [dropdownOpen, setDropdownOpen] = useState(false);
const [isAddingPack, setIsAddingPack] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const { data: profiles, isFetching } = useSearchProfiles(query);
const { data: peopleLists, isFetching: isFetchingPeopleLists } = useSearchPeopleLists(query);
const excludeSet = useMemo(() => new Set(excludePubkeys), [excludePubkeys]);
const filteredProfiles = useMemo(
() => (profiles ?? []).filter((p) => !excludeSet.has(p.pubkey)),
[profiles, excludeSet],
);
const filteredPeopleLists = useMemo(
() => (peopleLists ?? []).filter((pack) => pack.pubkeys.some((pubkey) => isHexPubkey(pubkey) && !excludeSet.has(pubkey.toLowerCase()))),
[peopleLists, excludeSet],
);
const hasResults = filteredProfiles.length > 0 || filteredPeopleLists.length > 0;
const isSearching = isFetching || isFetchingPeopleLists || isAddingPack;
useEffect(() => {
if (query.trim().length > 0 && hasResults) {
setDropdownOpen(true);
} else if (query.trim().length === 0) {
setDropdownOpen(false);
}
}, [hasResults, query]);
const handleSelect = useCallback((profile: SearchProfile) => {
onAdd(profile);
setQuery('');
setDropdownOpen(false);
inputRef.current?.focus();
}, [onAdd]);
const handleSelectPeopleList = useCallback(async (pack: PeopleListSearchResult) => {
const eligiblePubkeys = Array.from(new Set(
pack.pubkeys
.map((pubkey) => pubkey.toLowerCase())
.filter((pubkey) => isHexPubkey(pubkey) && !excludeSet.has(pubkey)),
));
if (eligiblePubkeys.length === 0) {
toast({ title: 'No new people to add', description: 'Everyone in that follow pack is already included.' });
return;
}
if (eligiblePubkeys.length > 20 && !window.confirm(`Add ${eligiblePubkeys.length} people from ${pack.title}?`)) {
return;
}
setIsAddingPack(true);
try {
const events = await nostr.query(
[{ kinds: [0], authors: eligiblePubkeys, limit: eligiblePubkeys.length }],
{ signal: AbortSignal.timeout(8000) },
);
const latestByPubkey = new Map<string, NostrEvent>();
for (const event of events) {
const existing = latestByPubkey.get(event.pubkey);
if (!existing || event.created_at > existing.created_at) latestByPubkey.set(event.pubkey, event);
}
const profilesToAdd = eligiblePubkeys.map((pubkey) => {
const event = latestByPubkey.get(pubkey);
return event ? profileFromEvent(event) : makeFallbackProfile(pubkey);
});
onAddMany(profilesToAdd, pack.title);
setQuery('');
setDropdownOpen(false);
inputRef.current?.focus();
} catch (error) {
toast({
title: 'Failed to load follow pack members',
description: error instanceof Error ? error.message : 'Please try again.',
variant: 'destructive',
});
} finally {
setIsAddingPack(false);
}
}, [excludeSet, nostr, onAddMany, toast]);
return (
<Popover open={dropdownOpen} onOpenChange={setDropdownOpen}>
<PopoverTrigger asChild>
<div className="relative flex items-center">
<Search className="absolute left-3 size-4 text-muted-foreground pointer-events-none" />
{isSearching && query.trim() && (
<Loader2 className="absolute right-3 size-4 text-muted-foreground animate-spin" />
)}
<Input
ref={inputRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
onFocus={() => {
if (query.trim().length > 0 && hasResults) {
setDropdownOpen(true);
}
}}
placeholder="Search people..."
className="pl-10 pr-10 rounded-full bg-secondary border-0 focus-visible:ring-0 focus-visible:ring-offset-0 h-9 text-sm"
autoComplete="off"
/>
</div>
</PopoverTrigger>
<PopoverContent
align="start"
side="bottom"
sideOffset={6}
onOpenAutoFocus={(e) => e.preventDefault()}
className="z-[270] w-[var(--radix-popover-trigger-width)] rounded-xl border-border p-0 shadow-lg overflow-hidden"
>
{hasResults ? (
<div className="max-h-[200px] overflow-y-auto py-1">
{filteredProfiles.map((profile) => (
<SearchResultItem key={profile.pubkey} profile={profile} onClick={handleSelect} />
))}
{filteredPeopleLists.map((pack) => (
<PeopleListSearchResultItem key={`${pack.event.kind}:${pack.event.pubkey}:${pack.event.tags.find(([name]) => name === 'd')?.[1] ?? pack.event.id}`} pack={pack} onClick={handleSelectPeopleList} />
))}
</div>
) : query.trim().length >= 2 && !isSearching ? (
<div className="py-4 text-center text-sm text-muted-foreground">
No people or follow packs found
</div>
) : null}
</PopoverContent>
</Popover>
);
}
/** A follow pack / follow set search result row. */
function PeopleListSearchResultItem({ pack, onClick }: { pack: PeopleListSearchResult; onClick: (pack: PeopleListSearchResult) => void }) {
return (
<button
className="w-full flex items-center gap-3 px-3 py-2 text-left transition-colors cursor-pointer hover:bg-secondary/60"
onClick={() => onClick(pack)}
onMouseDown={(e) => e.preventDefault()}
>
<div className="size-8 shrink-0 rounded-full bg-primary/10 text-primary flex items-center justify-center">
<PartyPopper className="size-4" />
</div>
<div className="flex-1 min-w-0">
<span className="text-sm font-medium truncate block">{pack.title}</span>
<span className="text-xs text-muted-foreground truncate block">
Follow pack · {pack.pubkeys.length} people
</span>
</div>
</button>
);
}
/** A pending member chip with role toggle and remove button. */
function PendingMemberChip({
pending,
onRemove,
onRoleChange,
}: {
pending: PendingMember;
onRemove: (pubkey: string) => void;
onRoleChange?: (pubkey: string, role: MemberRole) => void;
}) {
const { profile, role } = pending;
const { metadata, pubkey } = profile;
const displayName = metadata.display_name || metadata.name || genUserName(pubkey);
return (
<div className="flex items-center gap-2 p-2 rounded-lg bg-secondary/30 border border-border/50">
<Avatar className="size-7 shrink-0">
<AvatarImage src={metadata.picture} alt={displayName} />
<AvatarFallback className="bg-primary/20 text-primary text-[10px]">
{displayName[0]?.toUpperCase() || '?'}
</AvatarFallback>
</Avatar>
<span className="flex-1 text-sm truncate">
<EmojifiedText tags={profile.event.tags}>{displayName}</EmojifiedText>
</span>
{onRoleChange ? (
<ToggleGroup
type="single"
value={role}
onValueChange={(value) => {
if (value === 'member' || value === 'moderator') onRoleChange(pubkey, value);
}}
className="shrink-0 rounded-full bg-muted p-0.5"
aria-label={`Role for ${displayName}`}
>
<ToggleGroupItem value="member" size="sm" className="h-7 rounded-full px-2 text-xs data-[state=on]:bg-primary data-[state=on]:text-primary-foreground" aria-label="Member">
<Users className="size-3 sm:mr-1" />
<span className="hidden sm:inline">Member</span>
</ToggleGroupItem>
<ToggleGroupItem value="moderator" size="sm" className="h-7 rounded-full px-2 text-xs data-[state=on]:bg-amber-500 data-[state=on]:text-white" aria-label="Moderator">
<Crown className="size-3 sm:mr-1" />
<span className="hidden sm:inline">Moderator</span>
</ToggleGroupItem>
</ToggleGroup>
) : (
<span className="flex shrink-0 items-center gap-1 rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
<Users className="size-3" />
Member
</span>
)}
<button
type="button"
onClick={() => onRemove(pubkey)}
className="shrink-0 size-6 rounded-full hover:bg-destructive/10 flex items-center justify-center transition-colors"
title="Remove"
>
<X className="size-3.5 text-muted-foreground hover:text-destructive" />
</button>
</div>
);
}
/** A profile search result row. */
function SearchResultItem({ profile, onClick }: { profile: SearchProfile; onClick: (profile: SearchProfile) => void }) {
const { metadata, pubkey } = profile;
const displayName = metadata.display_name || metadata.name || genUserName(pubkey);
return (
<button
className="w-full flex items-center gap-3 px-3 py-2 text-left transition-colors cursor-pointer hover:bg-secondary/60"
onClick={() => onClick(profile)}
onMouseDown={(e) => e.preventDefault()}
>
<Avatar className="size-8 shrink-0">
<AvatarImage src={metadata.picture} alt={displayName} />
<AvatarFallback className="bg-primary/20 text-primary text-xs">
{displayName[0]?.toUpperCase() || '?'}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<span className="text-sm font-medium truncate block">
<EmojifiedText tags={profile.event.tags}>{displayName}</EmojifiedText>
</span>
{metadata.nip05 && (
<span className="text-xs text-muted-foreground truncate block">
{metadata.nip05.startsWith('_@') ? metadata.nip05.slice(2) : metadata.nip05}
</span>
)}
</div>
</button>
);
}
+3 -3
View File
@@ -15,7 +15,7 @@ import { DEFAULT_SYSTEM_PROMPT_TEMPLATE } from '@/lib/aiChatSystemPrompt';
/** Hardcoded default values for Agent provider fields. Used for reset buttons. */
const DEFAULT_AI_BASE_URL = 'https://ai.shakespeare.diy/v1';
const DEFAULT_AI_MODEL = 'grok-4.1-fast';
const DEFAULT_AI_MODEL = 'google/gemma-4-26b';
/** The build-time default DSN from the environment variable. */
const DEFAULT_SENTRY_DSN = import.meta.env.VITE_SENTRY_DSN || '';
@@ -195,7 +195,7 @@ export function AdvancedSettings() {
Model
</Label>
<p className="text-xs text-muted-foreground mt-1 mb-2">
Model ID sent to the provider (e.g. <code className="bg-muted px-1 rounded">grok-4.1-fast</code>, <code className="bg-muted px-1 rounded">claude-opus-4.6</code>, <code className="bg-muted px-1 rounded">gpt-4o</code>).
Model ID sent to the provider (e.g. <code className="bg-muted px-1 rounded">google/gemma-4-26b</code>, <code className="bg-muted px-1 rounded">claude-opus-4.6</code>, <code className="bg-muted px-1 rounded">gpt-4o</code>).
</p>
<Input
id="ai-model"
@@ -521,7 +521,7 @@ export function AdvancedSettings() {
<h3 className="text-sm font-medium">Delete Account</h3>
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
Permanently delete your data from the network, including your profile,
posts, reactions, and direct messages. This action is irreversible.
posts, and reactions. This action is irreversible.
</p>
</div>
<Button
+4 -2
View File
@@ -33,6 +33,8 @@ interface ArcBackgroundProps {
variant: 'down' | 'up' | 'rect';
/** Extra classes on the <svg> element. */
className?: string;
/** Extra classes on the filled background path. */
fillClassName?: string;
}
/**
@@ -40,7 +42,7 @@ interface ArcBackgroundProps {
* MobileBottomNav. Draws a semi-transparent filled shape (rectangle + optional
* curved arc) as a single path so there are no sub-pixel seams between layers.
*/
export function ArcBackground({ variant, className }: ArcBackgroundProps) {
export function ArcBackground({ variant, className, fillClassName }: ArcBackgroundProps) {
const path = variant === 'down' ? ARC_DOWN_PATH : variant === 'up' ? ARC_UP_PATH : RECT_PATH;
const hasArc = variant !== 'rect';
@@ -57,7 +59,7 @@ export function ArcBackground({ variant, className }: ArcBackgroundProps) {
preserveAspectRatio="none"
style={hasArc ? (variant === 'up' ? arcUpHeightStyle : arcDownHeightStyle) : fullHeightStyle}
>
<path d={path} className="fill-background/85" />
<path d={path} className={cn('fill-background/85', fillClassName)} />
{variant === 'down' && <path d="M0,34 L50,46 L100,34" fill="none" className="stroke-border" strokeWidth="1" vectorEffect="non-scaling-stroke" strokeLinejoin="round" />}
{variant === 'up' && <path d="M0,40 L50,16 L100,40" fill="none" className="stroke-border" strokeWidth="1" vectorEffect="non-scaling-stroke" strokeLinejoin="round" />}
</svg>
+22 -58
View File
@@ -18,42 +18,27 @@ import {
} from '@/lib/communityUtils';
// ── Props ─────────────────────────────────────────────────────────────────────
//
// Only content-level bans remain. Agora's organization trust model has no
// "member" tier any more, so banning a user wholesale is no longer
// modeled — hide each unwanted post individually instead.
interface BanContentProps {
/** Ban a specific post. */
mode: 'content';
interface BanConfirmDialogProps {
/** The event ID to ban. */
eventId: string;
/** The event author's pubkey. */
targetPubkey: string;
/** Display name for the dialog description. */
displayName?: string;
}
interface BanMemberProps {
/** Ban a member. */
mode: 'member';
eventId?: never;
/** The pubkey of the member to ban. */
targetPubkey: string;
/** Display name for the dialog description. */
displayName?: string;
}
type BanMode = BanContentProps | BanMemberProps;
type BanConfirmDialogProps = BanMode & {
/** The community `A` tag coordinate. */
communityATag: string;
/** Display name for the dialog description. */
displayName?: string;
open: boolean;
onOpenChange: (open: boolean) => void;
};
}
export function BanConfirmDialog({
mode,
eventId,
targetPubkey,
displayName,
communityATag,
open,
onOpenChange,
@@ -62,23 +47,15 @@ export function BanConfirmDialog({
const { mutateAsync: publishEvent, isPending } = useNostrPublish();
const [reason, setReason] = useState('');
const title = mode === 'content' ? 'Remove from community' : `Ban ${displayName ? `@${displayName}` : 'member'} from community`;
const description = mode === 'content'
? 'This will hide the post from canonical community views.'
: `This will ban ${displayName ? `@${displayName}` : 'this member'} from the community. Their recruits remain unaffected.`;
const handleSubmit = async () => {
try {
const tags: string[][] = [];
if (mode === 'content' && eventId) {
tags.push(['e', eventId, 'other']);
}
tags.push(['p', targetPubkey, 'other']);
tags.push(['A', communityATag]);
tags.push(['L', MODERATION_LABEL_NAMESPACE]);
tags.push(['l', MODERATION_BAN_LABEL, MODERATION_LABEL_NAMESPACE]);
const tags: string[][] = [
['e', eventId, 'other'],
['p', targetPubkey, 'other'],
['A', communityATag],
['L', MODERATION_LABEL_NAMESPACE],
['l', MODERATION_BAN_LABEL, MODERATION_LABEL_NAMESPACE],
];
await publishEvent({
kind: REPORT_KIND,
@@ -87,36 +64,23 @@ export function BanConfirmDialog({
});
// Invalidate community queries so the moderation overlay updates
// immediately (removes banned content/members without a page refresh).
// The activity feed's key is `['community-activity-feed', <aTagsKey>]`
// where aTagsKey is a comma-joined list of the viewer's subscribed A
// tags. We match any feed whose aTagsKey contains this communityATag.
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['community-members', communityATag] }),
queryClient.invalidateQueries({
predicate: (q) => {
const [root, aTagsKey] = q.queryKey;
return root === 'community-activity-feed'
&& typeof aTagsKey === 'string'
&& aTagsKey.split(',').includes(communityATag);
},
}),
]);
// immediately (removes banned content without a page refresh).
await queryClient.invalidateQueries({ queryKey: ['community-members', communityATag] });
toast({ title: mode === 'content' ? 'Post removed from community' : 'Member banned from community' });
toast({ title: 'Post removed from organization' });
setReason('');
onOpenChange(false);
} catch {
toast({ title: mode === 'content' ? 'Failed to remove post from community' : 'Failed to ban member from community', variant: 'destructive' });
toast({ title: 'Failed to remove post from organization', variant: 'destructive' });
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md rounded-2xl flex flex-col overflow-hidden">
<DialogTitle>{title}</DialogTitle>
<DialogTitle>Remove from organization</DialogTitle>
<DialogDescription className="text-sm text-muted-foreground">
{description}
This will hide the post from canonical organization views.
</DialogDescription>
<div className="space-y-2">
@@ -146,7 +110,7 @@ export function BanConfirmDialog({
disabled={isPending}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isPending ? 'Submitting...' : (mode === 'content' ? 'Remove' : 'Ban')}
{isPending ? 'Submitting...' : 'Remove'}
</Button>
</div>
</DialogContent>
+190
View File
@@ -0,0 +1,190 @@
import { useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { AlertTriangle, Check, Copy, ExternalLink } from 'lucide-react';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { BitcoinPublicDisclaimer } from '@/components/BitcoinPublicDisclaimer';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import { QRCodeCanvas } from '@/components/ui/qrcode';
import { useAuthor } from '@/hooks/useAuthor';
import { useProfileUrl } from '@/hooks/useProfileUrl';
import { useToast } from '@/hooks/useToast';
import { nostrPubkeyToBitcoinAddress } from '@/lib/bitcoin';
import { genUserName } from '@/lib/genUserName';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
interface BeneficiaryDonatePanelProps {
/** Hex pubkey of the beneficiary. */
pubkey: string;
}
/**
* Inline panel rendering a beneficiary's Taproot address as a scannable
* BIP-21 QR code, a copyable string, and an "Open in wallet" button.
*
* Used both by `BeneficiaryDonateDialog` (modal context) and embedded
* directly into the campaign page when there's a single beneficiary.
*
* Always shows the beneficiary's profile preview (avatar + name) as a
* link to their Nostr profile — even when the surrounding page also
* identifies a campaign organizer, the beneficiary is a distinct party
* (the organizer may be running the campaign on someone else's behalf).
*
* Intentionally minimal: no amount input, no PSBT/in-app wallet flow —
* that's `DonateDialog`'s job.
*/
export function BeneficiaryDonatePanel({
pubkey,
}: BeneficiaryDonatePanelProps) {
const { toast } = useToast();
const [copied, setCopied] = useState(false);
const author = useAuthor(pubkey);
const metadata = author.data?.metadata;
const displayName =
metadata?.display_name || metadata?.name || genUserName(pubkey);
const picture = sanitizeUrl(metadata?.picture);
const profileUrl = useProfileUrl(pubkey, metadata);
const address = useMemo(
() => nostrPubkeyToBitcoinAddress(pubkey),
[pubkey],
);
// BIP-21 URI: most wallets recognize the `bitcoin:` scheme when scanning.
// No amount field — donor picks one in their wallet.
const bip21 = address ? `bitcoin:${address}` : '';
const copyAddress = async () => {
if (!address) return;
try {
await navigator.clipboard.writeText(address);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
toast({ title: 'Address copied' });
} catch {
toast({
title: 'Copy failed',
description: 'Select and copy the address manually.',
variant: 'destructive',
});
}
};
if (!address) {
return (
<div className="flex items-center gap-2 text-sm">
<AlertTriangle className="size-5 text-orange-500 shrink-0" />
<span>We couldn't derive a Bitcoin address for this beneficiary.</span>
</div>
);
}
return (
<div className="space-y-4">
<Link
to={profileUrl}
className="flex items-center gap-3 rounded-md -mx-2 px-2 py-1.5 motion-safe:transition-colors hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<Avatar className="size-10 ring-1 ring-border">
{picture && <AvatarImage src={picture} alt="" />}
<AvatarFallback className="bg-primary/20 text-primary text-sm">
{displayName.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="min-w-0">
<div className="font-medium truncate">{displayName}</div>
</div>
</Link>
{/* QR code */}
<div className="flex justify-center">
<div className="rounded-2xl bg-white p-4 shadow-sm">
<QRCodeCanvas value={bip21} size={200} level="M" />
</div>
</div>
{/* Copyable address */}
<div className="space-y-2">
<Label className="text-xs text-muted-foreground uppercase tracking-wide">
Bitcoin address
</Label>
<button
type="button"
onClick={copyAddress}
className="w-full flex items-center justify-between gap-2 rounded-lg border bg-muted/40 px-3 py-2.5 font-mono text-xs break-all text-left hover:bg-muted/60 motion-safe:transition-colors"
aria-label="Copy Bitcoin address"
>
<span className="break-all">{address}</span>
{copied ? (
<Check className="size-4 text-green-500 shrink-0" />
) : (
<Copy className="size-4 text-muted-foreground shrink-0" />
)}
</button>
</div>
{/* Privacy notice — informational only. Bitcoin is a public
ledger, so the donation can be traced back to the donor's
wallet. */}
<BitcoinPublicDisclaimer
tone="soft"
includeCashOutAdvice={false}
leadText="Donations are public and can be traced back to you."
/>
{/* Open in wallet — relies on the `bitcoin:` URI handler. */}
<Button asChild className="w-full">
<a href={bip21}>
<ExternalLink className="size-4 mr-1.5" />
Open in wallet
</a>
</Button>
</div>
);
}
interface BeneficiaryDonateDialogProps {
/** Hex pubkey of the beneficiary. */
pubkey: string;
open: boolean;
onOpenChange: (open: boolean) => void;
}
/**
* Modal wrapper around `BeneficiaryDonatePanel` for places that still want
* the dialog UX (e.g. multi-beneficiary campaigns, where each row's
* "Donate" button opens this dialog).
*/
export function BeneficiaryDonateDialog({
pubkey,
open,
onOpenChange,
}: BeneficiaryDonateDialogProps) {
const author = useAuthor(pubkey);
const metadata = author.data?.metadata;
const displayName =
metadata?.display_name || metadata?.name || genUserName(pubkey);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Donate to {displayName}</DialogTitle>
<DialogDescription className="sr-only">
Scan the QR code or copy the Bitcoin address below to donate.
</DialogDescription>
</DialogHeader>
<BeneficiaryDonatePanel pubkey={pubkey} />
</DialogContent>
</Dialog>
);
}
+631
View File
@@ -0,0 +1,631 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import {
ArrowDownLeft,
ArrowRight,
ArrowUpRight,
Bitcoin,
Check,
Clock,
Copy,
ExternalLink,
Hash,
Layers,
RefreshCw,
Weight,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { useBitcoinTx } from '@/hooks/useBitcoinTx';
import { useBitcoinAddress } from '@/hooks/useBitcoinAddress';
import { satsToBTC, satsToUSD, formatSats, formatBTC } from '@/lib/bitcoin';
import type { TxDetail, TxInput, TxOutput } from '@/lib/bitcoin';
// ---------------------------------------------------------------------------
// Shared helpers
// ---------------------------------------------------------------------------
function truncateMiddle(str: string, startLen = 8, endLen = 8): string {
if (str.length <= startLen + endLen + 3) return str;
return `${str.slice(0, startLen)}...${str.slice(-endLen)}`;
}
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// clipboard not available
}
};
return (
<button
onClick={handleCopy}
className="p-1 rounded hover:bg-muted/50 transition-colors text-muted-foreground hover:text-foreground cursor-pointer"
title="Copy"
>
{copied ? <Check className="size-3.5 text-green-500" /> : <Copy className="size-3.5" />}
</button>
);
}
/** Format a unix timestamp as a readable date string. */
function formatBlockTime(timestamp: number): string {
const date = new Date(timestamp * 1000);
return date.toLocaleString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: true,
});
}
/** Format a large number with locale separators. */
function formatNumber(n: number): string {
return n.toLocaleString();
}
// ---------------------------------------------------------------------------
// Bitcoin Transaction Header
// ---------------------------------------------------------------------------
export function BitcoinTxHeader({ txid }: { txid: string }) {
const { tx, btcPrice, isLoading, error } = useBitcoinTx(txid);
if (isLoading) return <TxSkeleton />;
if (error || !tx) {
return (
<div className="rounded-2xl border border-border p-6 text-center space-y-3">
<Bitcoin className="size-10 mx-auto text-muted-foreground/40" />
<p className="text-sm text-destructive">Failed to load transaction</p>
<p className="text-xs text-muted-foreground font-mono break-all">{txid}</p>
</div>
);
}
return (
<div className="rounded-2xl border border-border overflow-hidden">
{/* Header */}
<div className="p-5 space-y-4">
<div className="flex items-center gap-3">
<div className={`flex items-center justify-center size-10 rounded-full ${
tx.confirmed
? 'bg-green-500/10 text-green-600 dark:text-green-400'
: 'bg-orange-500/10 text-orange-600 dark:text-orange-400'
}`}>
{tx.confirmed ? <Check className="size-5" /> : <Clock className="size-5" />}
</div>
<div>
<h2 className="text-lg font-bold">
{tx.confirmed ? 'Confirmed' : 'Unconfirmed'}
</h2>
{tx.blockTime && (
<p className="text-sm text-muted-foreground">{formatBlockTime(tx.blockTime)}</p>
)}
</div>
</div>
{/* Transaction ID */}
<div className="space-y-1.5">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Transaction ID</p>
<div className="flex items-center gap-2">
<p className="text-sm font-mono text-foreground break-all">{tx.txid}</p>
<CopyButton text={tx.txid} />
</div>
</div>
{/* Stats grid */}
<div className="grid grid-cols-2 gap-3">
{tx.confirmed && tx.blockHeight !== undefined && (
<StatCard icon={<Layers className="size-3.5" />} label="Block" value={formatNumber(tx.blockHeight)} />
)}
<StatCard icon={<Weight className="size-3.5" />} label="Size" value={`${formatNumber(tx.weight / 4)} vB`} />
<StatCard
icon={<Bitcoin className="size-3.5" />}
label="Fee"
value={`${formatSats(tx.fee)} sat`}
subtitle={`${(tx.fee / (tx.weight / 4)).toFixed(1)} sat/vB`}
/>
<StatCard
icon={<Hash className="size-3.5" />}
label="Amount"
value={`${formatBTC(tx.totalOutput)} BTC`}
subtitle={btcPrice ? satsToUSD(tx.totalOutput, btcPrice) : undefined}
/>
</div>
</div>
{/* Inputs → Outputs flow */}
<div className="border-t border-border">
<TxFlow tx={tx} btcPrice={btcPrice} />
</div>
{/* Footer: link to mempool.space */}
<div className="border-t border-border px-5 py-2.5">
<a
href={`https://mempool.space/tx/${txid}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<Bitcoin className="size-3.5" />
<span>View on mempool.space</span>
<ExternalLink className="size-3" />
</a>
</div>
</div>
);
}
function StatCard({ icon, label, value, subtitle }: { icon: React.ReactNode; label: string; value: string; subtitle?: string }) {
return (
<div className="rounded-xl bg-secondary/40 px-3.5 py-2.5 space-y-0.5">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
{icon}
<span>{label}</span>
</div>
<p className="text-sm font-semibold">{value}</p>
{subtitle && <p className="text-xs text-muted-foreground">{subtitle}</p>}
</div>
);
}
/** Inputs → Outputs visualization, mempool.space-style. */
function TxFlow({ tx, btcPrice }: { tx: TxDetail; btcPrice?: number }) {
return (
<div className="p-4 space-y-3">
<div className="flex items-center gap-2 text-xs font-medium text-muted-foreground uppercase tracking-wider px-1">
<span>{tx.inputs.length} Input{tx.inputs.length !== 1 ? 's' : ''}</span>
<ArrowRight className="size-3" />
<span>{tx.outputs.length} Output{tx.outputs.length !== 1 ? 's' : ''}</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{/* Inputs */}
<div className="space-y-1.5">
{tx.inputs.slice(0, 10).map((input, i) => (
<TxInputRow key={`${input.txid}-${input.vout}-${i}`} input={input} btcPrice={btcPrice} />
))}
{tx.inputs.length > 10 && (
<p className="text-xs text-muted-foreground text-center py-1">
+{tx.inputs.length - 10} more input{tx.inputs.length - 10 !== 1 ? 's' : ''}
</p>
)}
</div>
{/* Outputs */}
<div className="space-y-1.5">
{tx.outputs.slice(0, 10).map((output, i) => (
<TxOutputRow key={`${output.address ?? 'op_return'}-${i}`} output={output} btcPrice={btcPrice} />
))}
{tx.outputs.length > 10 && (
<p className="text-xs text-muted-foreground text-center py-1">
+{tx.outputs.length - 10} more output{tx.outputs.length - 10 !== 1 ? 's' : ''}
</p>
)}
</div>
</div>
</div>
);
}
function TxInputRow({ input, btcPrice }: { input: TxInput; btcPrice?: number }) {
if (input.isCoinbase) {
return (
<div className="rounded-lg bg-amber-500/5 border border-amber-500/20 px-3 py-2">
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-medium text-amber-600 dark:text-amber-400">Coinbase</span>
<span className="text-xs font-mono">{formatBTC(input.value)} BTC</span>
</div>
</div>
);
}
return (
<div className="rounded-lg bg-red-500/5 border border-red-500/10 px-3 py-2 space-y-0.5">
<div className="flex items-center justify-between gap-2">
{input.address ? (
<Link
to={`/i/bitcoin:address:${input.address}`}
className="text-xs font-mono text-red-600 dark:text-red-400 hover:underline truncate"
>
{truncateMiddle(input.address, 10, 6)}
</Link>
) : (
<span className="text-xs text-muted-foreground">Unknown</span>
)}
<span className="text-xs font-mono shrink-0">{formatBTC(input.value)} BTC</span>
</div>
{btcPrice !== undefined && (
<p className="text-[10px] text-muted-foreground text-right">{satsToUSD(input.value, btcPrice)}</p>
)}
</div>
);
}
function TxOutputRow({ output, btcPrice }: { output: TxOutput; btcPrice?: number }) {
const isOpReturn = output.scriptpubkeyType === 'op_return';
if (isOpReturn) {
return (
<div className="rounded-lg bg-secondary/60 border border-border/50 px-3 py-2">
<span className="text-xs text-muted-foreground">OP_RETURN</span>
</div>
);
}
return (
<div className="rounded-lg bg-green-500/5 border border-green-500/10 px-3 py-2 space-y-0.5">
<div className="flex items-center justify-between gap-2">
{output.address ? (
<Link
to={`/i/bitcoin:address:${output.address}`}
className="text-xs font-mono text-green-600 dark:text-green-400 hover:underline truncate"
>
{truncateMiddle(output.address, 10, 6)}
</Link>
) : (
<span className="text-xs text-muted-foreground">Unknown</span>
)}
<span className="text-xs font-mono shrink-0">{formatBTC(output.value)} BTC</span>
</div>
{btcPrice !== undefined && (
<p className="text-[10px] text-muted-foreground text-right">{satsToUSD(output.value, btcPrice)}</p>
)}
</div>
);
}
function TxSkeleton() {
return (
<div className="rounded-2xl border border-border overflow-hidden">
<div className="p-5 space-y-4">
<div className="flex items-center gap-3">
<Skeleton className="size-10 rounded-full" />
<div className="space-y-1.5">
<Skeleton className="h-5 w-28" />
<Skeleton className="h-3.5 w-40" />
</div>
</div>
<div className="space-y-1.5">
<Skeleton className="h-3 w-24" />
<Skeleton className="h-4 w-full" />
</div>
<div className="grid grid-cols-2 gap-3">
<Skeleton className="h-16 rounded-xl" />
<Skeleton className="h-16 rounded-xl" />
<Skeleton className="h-16 rounded-xl" />
<Skeleton className="h-16 rounded-xl" />
</div>
</div>
<div className="border-t border-border p-4 space-y-3">
<Skeleton className="h-3 w-32" />
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Skeleton className="h-12 rounded-lg" />
<Skeleton className="h-12 rounded-lg" />
</div>
<div className="space-y-1.5">
<Skeleton className="h-12 rounded-lg" />
<Skeleton className="h-12 rounded-lg" />
</div>
</div>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Bitcoin Address Header
// ---------------------------------------------------------------------------
export function BitcoinAddressHeader({ address }: { address: string }) {
const { addressDetail, btcPrice, isLoading, error, refetch } = useBitcoinAddress(address);
if (isLoading) return <AddressSkeleton />;
if (error || !addressDetail) {
return (
<div className="rounded-2xl border border-border p-6 text-center space-y-3">
<Bitcoin className="size-10 mx-auto text-muted-foreground/40" />
<p className="text-sm text-destructive">Failed to load address</p>
<p className="text-xs text-muted-foreground font-mono break-all">{address}</p>
<Button variant="outline" size="sm" onClick={() => refetch()}>
<RefreshCw className="size-3.5 mr-1.5" />
Retry
</Button>
</div>
);
}
return (
<div className="rounded-2xl border border-border overflow-hidden">
{/* Header */}
<div className="p-5 space-y-4">
<div className="flex items-center gap-3">
<div className="flex items-center justify-center size-10 rounded-full bg-primary/10 text-primary">
<Bitcoin className="size-5" />
</div>
<div>
<h2 className="text-lg font-bold">Bitcoin Address</h2>
<p className="text-xs text-muted-foreground">
{addressDetail.txCount + addressDetail.pendingTxCount} transaction{(addressDetail.txCount + addressDetail.pendingTxCount) !== 1 ? 's' : ''}
</p>
</div>
</div>
{/* Address */}
<div className="space-y-1.5">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Address</p>
<div className="flex items-center gap-2">
<p className="text-sm font-mono text-foreground break-all">{address}</p>
<CopyButton text={address} />
</div>
</div>
{/* Balance hero */}
<div className="rounded-xl bg-secondary/40 p-4 text-center space-y-1">
<p className="text-xs text-muted-foreground uppercase tracking-wider">Balance</p>
<p className="text-3xl font-bold tracking-tight">
{btcPrice ? satsToUSD(addressDetail.totalBalance, btcPrice) : `${formatBTC(addressDetail.totalBalance)} BTC`}
</p>
<p className="text-sm text-muted-foreground">
{formatBTC(addressDetail.totalBalance)} BTC
</p>
{addressDetail.pendingBalance !== 0 && (
<p className="flex items-center justify-center gap-1 text-xs text-orange-500 dark:text-orange-400 pt-1">
<RefreshCw className="size-3 animate-spin" />
{btcPrice
? `${satsToUSD(addressDetail.pendingBalance, btcPrice)} pending`
: `${formatBTC(addressDetail.pendingBalance)} BTC pending`}
</p>
)}
</div>
{/* Stats grid */}
<div className="grid grid-cols-2 gap-3">
<StatCard
icon={<ArrowDownLeft className="size-3.5" />}
label="Total Received"
value={`${formatBTC(addressDetail.totalReceived)} BTC`}
subtitle={btcPrice ? satsToUSD(addressDetail.totalReceived, btcPrice) : undefined}
/>
<StatCard
icon={<ArrowUpRight className="size-3.5" />}
label="Total Sent"
value={`${formatBTC(addressDetail.totalSent)} BTC`}
subtitle={btcPrice ? satsToUSD(addressDetail.totalSent, btcPrice) : undefined}
/>
</div>
</div>
{/* Recent Transactions */}
{addressDetail.recentTxs.length > 0 && (
<div className="border-t border-border">
<div className="px-5 py-3">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Recent Transactions
</p>
</div>
<div className="divide-y divide-border">
{addressDetail.recentTxs.slice(0, 10).map((tx) => (
<AddressTxRow key={tx.txid} tx={tx} btcPrice={btcPrice} />
))}
</div>
{addressDetail.recentTxs.length > 10 && (
<div className="px-5 py-3 text-center">
<p className="text-xs text-muted-foreground">
{addressDetail.txCount - 10} more transaction{addressDetail.txCount - 10 !== 1 ? 's' : ''}
</p>
</div>
)}
</div>
)}
{/* Footer: link to mempool.space */}
<div className="border-t border-border px-5 py-2.5">
<a
href={`https://mempool.space/address/${address}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<Bitcoin className="size-3.5" />
<span>View on mempool.space</span>
<ExternalLink className="size-3" />
</a>
</div>
</div>
);
}
function AddressTxRow({ tx, btcPrice }: { tx: { txid: string; amount: number; type: 'receive' | 'send'; confirmed: boolean; timestamp?: number }; btcPrice?: number }) {
const isReceive = tx.type === 'receive';
return (
<Link
to={`/i/bitcoin:tx:${tx.txid}`}
className="flex items-center justify-between py-3 px-5 hover:bg-muted/50 transition-colors"
>
<div className="flex items-center gap-3">
<div className={`flex items-center justify-center size-8 rounded-full ${
isReceive
? 'bg-green-500/10 text-green-600 dark:text-green-400'
: 'bg-red-500/10 text-red-600 dark:text-red-400'
}`}>
{isReceive ? <ArrowDownLeft className="size-4" /> : <ArrowUpRight className="size-4" />}
</div>
<div>
<p className="text-sm font-medium">{isReceive ? 'Received' : 'Sent'}</p>
<p className="text-xs text-muted-foreground font-mono">{truncateMiddle(tx.txid, 8, 8)}</p>
</div>
</div>
<div className="text-right">
<p className={`text-sm font-medium ${
isReceive ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'
}`}>
{isReceive ? '+' : '-'}{formatBTC(tx.amount)} BTC
</p>
{btcPrice && (
<p className="text-xs text-muted-foreground">
{satsToUSD(tx.amount, btcPrice)}
</p>
)}
</div>
</Link>
);
}
function AddressSkeleton() {
return (
<div className="rounded-2xl border border-border overflow-hidden">
<div className="p-5 space-y-4">
<div className="flex items-center gap-3">
<Skeleton className="size-10 rounded-full" />
<div className="space-y-1.5">
<Skeleton className="h-5 w-32" />
<Skeleton className="h-3.5 w-24" />
</div>
</div>
<div className="space-y-1.5">
<Skeleton className="h-3 w-16" />
<Skeleton className="h-4 w-full" />
</div>
<div className="rounded-xl bg-secondary/40 p-4 space-y-2 flex flex-col items-center">
<Skeleton className="h-3 w-12" />
<Skeleton className="h-9 w-40" />
<Skeleton className="h-4 w-28" />
</div>
<div className="grid grid-cols-2 gap-3">
<Skeleton className="h-16 rounded-xl" />
<Skeleton className="h-16 rounded-xl" />
</div>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Compact previews (used in NoteCard embeds, hover cards, etc.)
// ---------------------------------------------------------------------------
/** Compact preview for a Bitcoin transaction — fetches real data. */
export function BitcoinTxPreview({ txid, link }: { txid: string; link: string }) {
const { tx, btcPrice, isLoading } = useBitcoinTx(txid);
if (isLoading) {
return (
<div className="px-4 py-3 border-b border-border">
<div className="flex items-center gap-3">
<Skeleton className="size-12 rounded-lg shrink-0" />
<div className="flex-1 min-w-0 space-y-1.5">
<Skeleton className="h-3 w-32" />
<Skeleton className="h-4 w-3/4" />
</div>
</div>
</div>
);
}
const amount = tx ? tx.totalOutput : 0;
const fee = tx?.fee ?? 0;
return (
<Link
to={link}
className="flex items-center gap-3 px-4 py-3 border-b border-border hover:bg-secondary/30 transition-colors"
>
<div className="size-12 rounded-lg bg-orange-500/10 flex items-center justify-center shrink-0">
<Bitcoin className="size-5 text-orange-600 dark:text-orange-400" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Bitcoin className="size-3 shrink-0" />
<span>Bitcoin Transaction</span>
{tx && (
<span className={tx.confirmed
? 'text-green-600 dark:text-green-400'
: 'text-yellow-600 dark:text-yellow-400'
}>
{tx.confirmed ? 'Confirmed' : 'Unconfirmed'}
</span>
)}
</div>
<p className="text-sm font-medium truncate mt-0.5">
{tx ? `${satsToBTC(amount)} BTC` : truncateMiddle(txid, 12, 8)}
{tx && btcPrice ? (
<span className="text-muted-foreground font-normal"> ({satsToUSD(amount, btcPrice)})</span>
) : null}
</p>
{tx && (
<p className="text-xs text-muted-foreground truncate">
Fee {formatSats(fee)} sats
{tx.blockHeight ? ` · Block ${tx.blockHeight.toLocaleString()}` : ''}
</p>
)}
</div>
<ExternalLink className="size-4 text-muted-foreground shrink-0" />
</Link>
);
}
/** Compact preview for a Bitcoin address — fetches real data. */
export function BitcoinAddressPreview({ address, link }: { address: string; link: string }) {
const { addressDetail, btcPrice, isLoading } = useBitcoinAddress(address);
if (isLoading) {
return (
<div className="px-4 py-3 border-b border-border">
<div className="flex items-center gap-3">
<Skeleton className="size-12 rounded-lg shrink-0" />
<div className="flex-1 min-w-0 space-y-1.5">
<Skeleton className="h-3 w-28" />
<Skeleton className="h-4 w-3/4" />
</div>
</div>
</div>
);
}
const balance = addressDetail?.totalBalance ?? 0;
const txCount = addressDetail ? addressDetail.txCount + addressDetail.pendingTxCount : 0;
return (
<Link
to={link}
className="flex items-center gap-3 px-4 py-3 border-b border-border hover:bg-secondary/30 transition-colors"
>
<div className="size-12 rounded-lg bg-orange-500/10 flex items-center justify-center shrink-0">
<Bitcoin className="size-5 text-orange-600 dark:text-orange-400" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Bitcoin className="size-3 shrink-0" />
<span>Bitcoin Address</span>
</div>
<p className="text-sm font-medium truncate mt-0.5">
{addressDetail ? `${satsToBTC(balance)} BTC` : truncateMiddle(address, 12, 8)}
{addressDetail && btcPrice ? (
<span className="text-muted-foreground font-normal"> ({satsToUSD(balance, btcPrice)})</span>
) : null}
</p>
{addressDetail && (
<p className="text-xs text-muted-foreground truncate">
{txCount.toLocaleString()} transaction{txCount !== 1 ? 's' : ''}
{' · '}
<span className="font-mono">{truncateMiddle(address, 8, 6)}</span>
</p>
)}
</div>
<ExternalLink className="size-4 text-muted-foreground shrink-0" />
</Link>
);
}
+118
View File
@@ -0,0 +1,118 @@
import { AlertTriangle } from 'lucide-react';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Checkbox } from '@/components/ui/checkbox';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { cn } from '@/lib/utils';
/**
* - `destructive`: red, with a warning icon. Used in high-stakes contexts
* like the wallet's Send dialog where the disclaimer also gates an
* acknowledgement checkbox.
* - `soft`: amber, no icon. Used as an informational notice in lower-stakes
* contexts (e.g. campaign donation surfaces) where we don't want to
* imply the donor is about to do something dangerous.
*/
type Tone = 'destructive' | 'soft';
interface BitcoinPublicDisclaimerProps {
/**
* When provided, render an "I understand this transaction is public"
* acknowledgement checkbox below the warning. Callers should typically
* gate the primary action (Send / Donate / Review / Open in wallet) on
* `acknowledged === true`. When omitted, the disclaimer renders as an
* informational notice with no interactive control.
*/
acknowledged?: boolean;
onAcknowledgedChange?: (acknowledged: boolean) => void;
/** Optional override for the lead sentence (e.g. "Donations" instead of "Money"). */
leadText?: string;
/** Visual treatment. Defaults to `destructive` for backwards compatibility with the wallet's Send dialog. */
tone?: Tone;
/**
* Whether the "Learn more" popover should include the
* "or cash out at an exchange" advice. Relevant in the wallet (the
* user holds Bitcoin and could cash out) but not on a campaign page
* (the donor is sending money away, not deciding what to do with it).
* Defaults to `true` for backwards compatibility.
*/
includeCashOutAdvice?: boolean;
}
/**
* Privacy disclaimer for on-chain Bitcoin payments. Bitcoin is a public
* ledger and the transaction can be traced back to the sender forever.
* Used wherever the user initiates an on-chain payment — wallet sends to
* raw addresses, campaign donations (BIP-21 panels, in-app PSBT
* donations, external-wallet fallbacks).
*/
export function BitcoinPublicDisclaimer({
acknowledged,
onAcknowledgedChange,
leadText = 'Money you send is public and can be traced back to you.',
tone = 'destructive',
includeCashOutAdvice = true,
}: BitcoinPublicDisclaimerProps) {
const showCheckbox = onAcknowledgedChange !== undefined;
const isSoft = tone === 'soft';
return (
<Alert
// For `soft` we drop the role="alert" semantics — it's informational,
// not an active warning the user must respond to.
role={isSoft ? 'note' : 'alert'}
className={cn(
isSoft
? 'border-amber-300/60 bg-amber-50 text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-100'
: 'border-destructive/50 bg-destructive/5 text-destructive dark:border-destructive',
)}
>
{/* Icon only on the destructive variant. The shadcn Alert reserves
left padding for an icon via `[&>svg~*]:pl-7`, so omitting the
icon also reclaims the indent. */}
{!isSoft && <AlertTriangle className="size-4 text-destructive" />}
<AlertDescription className="text-xs">
<p>
{leadText}{' '}
<Popover>
<PopoverTrigger asChild>
<button
type="button"
className="underline underline-offset-2 font-medium hover:opacity-80 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-sm"
>
Learn more
</button>
</PopoverTrigger>
<PopoverContent side="top" align="start" className="w-72 text-xs leading-relaxed">
Bitcoin is a public ledger. Transactions you send can
be traced back to you forever, even after being
exchanged by multiple people. Send it only to those
you wish to support publicly
{includeCashOutAdvice ? ', or cash out at an exchange.' : '.'}
</PopoverContent>
</Popover>
</p>
{showCheckbox && (
<label className="mt-2 flex items-start gap-2 cursor-pointer select-none">
<Checkbox
checked={acknowledged ?? false}
onCheckedChange={(checked) => onAcknowledgedChange(checked === true)}
className={cn(
'mt-0.5',
isSoft
? 'border-amber-600 data-[state=checked]:bg-amber-600 data-[state=checked]:text-white dark:border-amber-400 dark:data-[state=checked]:bg-amber-500'
: 'border-destructive data-[state=checked]:bg-destructive data-[state=checked]:text-destructive-foreground',
)}
aria-label="I understand this transaction is public"
/>
<span>I understand this transaction is public.</span>
</label>
)}
</AlertDescription>
</Alert>
);
}
+8 -68
View File
@@ -1,30 +1,23 @@
import { useMemo, useState, useRef, useEffect, useCallback } from 'react';
import { Link } from 'react-router-dom';
import { BookOpen, MessageCircle, MessageSquare, MoreHorizontal, Star, Zap, AlertTriangle } from 'lucide-react';
import { BookOpen, MessageSquare, Star, AlertTriangle } from 'lucide-react';
import { nip19 } from 'nostr-tools';
import { RepostIcon } from '@/components/icons/RepostIcon';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { NoteContent } from '@/components/NoteContent';
import { ReactionButton } from '@/components/ReactionButton';
import { RepostMenu } from '@/components/RepostMenu';
import { PostActionBar } from '@/components/PostActionBar';
import { ProfileHoverCard } from '@/components/ProfileHoverCard';
import { EmojifiedText } from '@/components/CustomEmoji';
import { NoteMoreMenu } from '@/components/NoteMoreMenu';
import { ReplyComposeModal } from '@/components/ReplyComposeModal';
import { ZapDialog } from '@/components/ZapDialog';
import { useAuthor } from '@/hooks/useAuthor';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useEventStats } from '@/hooks/useTrending';
import { useUserZap } from '@/hooks/useUserZap';
import { useProfileUrl } from '@/hooks/useProfileUrl';
import { useOpenPost } from '@/hooks/useOpenPost';
import { useBookSummary } from '@/hooks/useBookSummary';
import { getDisplayName } from '@/lib/getDisplayName';
import { timeAgo } from '@/lib/timeAgo';
import { formatNumber } from '@/lib/formatNumber';
import { cn } from '@/lib/utils';
import { BOOKSTR_KINDS, extractISBNFromEvent, parseBookReview, ratingToStars } from '@/lib/bookstr';
import type { NostrEvent } from '@nostrify/nostrify';
@@ -49,18 +42,13 @@ function encodeEventId(event: NostrEvent): string {
}
export function BookFeedItem({ event, className }: BookFeedItemProps) {
const { user } = useCurrentUser();
const author = useAuthor(event.pubkey);
const metadata = author.data?.metadata;
const displayName = getDisplayName(metadata, event.pubkey);
const profileUrl = useProfileUrl(event.pubkey, metadata);
const { data: stats } = useEventStats(event.id, event);
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
const [replyOpen, setReplyOpen] = useState(false);
const canZapAuthor = !!user && user.pubkey !== event.pubkey;
const isZapped = useUserZap(canZapAuthor ? event.id : undefined) === true;
const isbn = useMemo(() => extractISBNFromEvent(event), [event]);
const isReview = event.kind === BOOKSTR_KINDS.BOOK_REVIEW;
const isComment = event.kind === 1111;
@@ -220,60 +208,12 @@ export function BookFeedItem({ event, className }: BookFeedItemProps) {
{isbn && <InlineBookCard isbn={isbn} />}
{/* Action buttons */}
<div className="flex items-center gap-5 mt-3 -ml-2">
<button
className="flex items-center gap-1.5 p-2 rounded-full text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors"
title="Reply"
onClick={(e) => { e.stopPropagation(); setReplyOpen(true); }}
>
<MessageCircle className="size-5" />
{stats?.replies ? <span className="text-sm tabular-nums">{formatNumber(stats.replies)}</span> : null}
</button>
<RepostMenu event={event}>
{(isReposted: boolean) => (
<button
className={`flex items-center gap-1.5 p-2 rounded-full transition-colors ${isReposted ? 'text-accent hover:text-accent/80 hover:bg-accent/10' : 'text-muted-foreground hover:text-accent hover:bg-accent/10'}`}
title={isReposted ? 'Undo repost' : 'Repost'}
>
<RepostIcon className="size-5" />
{(stats?.reposts || stats?.quotes) ? <span className="text-sm tabular-nums">{formatNumber((stats?.reposts ?? 0) + (stats?.quotes ?? 0))}</span> : null}
</button>
)}
</RepostMenu>
<ReactionButton
eventId={event.id}
eventPubkey={event.pubkey}
eventKind={event.kind}
reactionCount={stats?.reactions}
/>
{canZapAuthor && (
<ZapDialog target={event}>
<button
className={cn(
'flex items-center gap-1.5 p-2 rounded-full transition-colors',
isZapped
? 'text-amber-500 hover:text-amber-500/80 hover:bg-amber-500/10'
: 'text-muted-foreground hover:text-amber-500 hover:bg-amber-500/10',
)}
title={isZapped ? 'Zapped' : 'Zap'}
>
<Zap className="size-5" fill={isZapped ? 'currentColor' : 'none'} />
{stats?.zapAmount ? <span className="text-sm tabular-nums">{formatNumber(stats.zapAmount)}</span> : null}
</button>
</ZapDialog>
)}
<button
className="p-2 rounded-full text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors"
title="More"
onClick={(e) => { e.stopPropagation(); setMoreMenuOpen(true); }}
>
<MoreHorizontal className="size-5" />
</button>
</div>
<PostActionBar
event={event}
onReply={() => setReplyOpen(true)}
onMore={() => setMoreMenuOpen(true)}
className="mt-3"
/>
</div>
</div>
+271
View File
@@ -0,0 +1,271 @@
import { useMemo } from 'react';
import { Link } from 'react-router-dom';
import { CalendarClock, HandHeart, MapPin, Target, Users, Archive } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Card } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
import { Skeleton } from '@/components/ui/skeleton';
import { CampaignModerationMenu } from '@/components/CampaignModerationMenu';
import { useAuthor } from '@/hooks/useAuthor';
import { useBtcPrice } from '@/hooks/useBtcPrice';
import { useCampaignDonations } from '@/hooks/useCampaignDonations';
import { useCampaignModeration } from '@/hooks/useCampaignModeration';
import {
type ParsedCampaign,
encodeCampaignNaddr,
getCampaignCountryLabel,
getCampaignPrimaryTagLabel,
} from '@/lib/campaign';
import { formatCampaignAmount } from '@/lib/formatCampaignAmount';
import { genUserName } from '@/lib/genUserName';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import { cn } from '@/lib/utils';
function formatDeadline(unixSeconds: number): { label: string; isPast: boolean } {
const now = Math.floor(Date.now() / 1000);
const diff = unixSeconds - now;
if (diff <= 0) return { label: 'Ended', isPast: true };
const days = Math.ceil(diff / 86_400);
if (days <= 1) return { label: 'Ends today', isPast: false };
if (days < 30) return { label: `${days} days left`, isPast: false };
const months = Math.round(days / 30);
return { label: `${months} mo left`, isPast: false };
}
/** Short helper rendered both inline (cards) and in the detail page. */
export function CampaignProgress({
raisedSats,
goalSats,
btcPrice,
className,
}: {
raisedSats: number;
goalSats?: number;
btcPrice?: number;
className?: string;
}) {
const hasGoal = !!goalSats && goalSats > 0;
const pct = hasGoal ? Math.min(100, Math.round((raisedSats / goalSats!) * 100)) : 0;
return (
<div className={cn('space-y-1.5', className)}>
{hasGoal && <Progress value={pct} className="h-2" />}
<div className="flex items-baseline justify-between gap-2 text-sm">
<span className="font-semibold">
{formatCampaignAmount(raisedSats, btcPrice)}
{!hasGoal && <span className="ml-1 font-normal text-muted-foreground">raised</span>}
</span>
{hasGoal && (
<span className="text-muted-foreground">of {formatCampaignAmount(goalSats!, btcPrice)} goal</span>
)}
</div>
</div>
);
}
interface CampaignCardProps {
campaign: ParsedCampaign;
/** Visual variant: `compact` for grid items, `featured` for hero placement. */
variant?: 'compact' | 'featured';
className?: string;
}
/**
* Renders a single campaign as a clickable card. The whole card is a
* `<Link>` to the campaign's naddr-based detail route.
*/
export function CampaignCard({ campaign, variant = 'compact', className }: CampaignCardProps) {
const author = useAuthor(campaign.pubkey);
const { data: stats } = useCampaignDonations(campaign.aTag);
const { data: btcPrice } = useBtcPrice();
const { data: moderation } = useCampaignModeration();
const naddr = useMemo(() => encodeCampaignNaddr(campaign), [campaign]);
const cover = sanitizeUrl(campaign.image);
const creatorName =
author.data?.metadata?.display_name ||
author.data?.metadata?.name ||
genUserName(campaign.pubkey);
const deadline = campaign.deadline ? formatDeadline(campaign.deadline) : null;
const raisedSats = stats?.totalSats ?? 0;
const countryLabel = getCampaignCountryLabel(campaign);
const tagLabel = getCampaignPrimaryTagLabel(campaign);
const isFeaturedVariant = variant === 'featured';
const isApproved = moderation.approvedCoords.has(campaign.aTag);
const isHidden = moderation.hiddenCoords.has(campaign.aTag);
const isFeatured = moderation.featuredCoords.has(campaign.aTag);
return (
<Link
to={`/${naddr}`}
className={cn(
'group block rounded-xl overflow-hidden focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background motion-safe:transition-transform motion-safe:duration-200 motion-safe:hover:-translate-y-0.5',
className,
)}
>
<Card
className={cn(
'overflow-hidden border-border/70 shadow-sm motion-safe:transition-shadow motion-safe:duration-200 group-hover:shadow-lg h-full flex flex-col',
isFeaturedVariant && 'sm:flex-row sm:items-stretch',
)}
>
{/* Cover image */}
<div
className={cn(
'relative w-full bg-gradient-to-br from-primary/15 via-primary/5 to-secondary',
isFeaturedVariant ? 'aspect-[16/10] sm:aspect-auto sm:w-1/2 sm:min-h-[280px]' : 'aspect-[16/9]',
)}
>
{cover ? (
<img
src={cover}
alt=""
loading="lazy"
className="absolute inset-0 size-full object-cover"
/>
) : (
<div className="absolute inset-0 flex items-center justify-center">
<HandHeart className="size-12 text-primary/40" />
</div>
)}
{tagLabel && (
<Badge
variant="secondary"
className="absolute top-3 left-3 backdrop-blur bg-background/80 border-border/40"
>
{tagLabel}
</Badge>
)}
<div className="absolute top-3 right-3 flex items-center gap-2">
{campaign.archived && (
<Badge
variant="secondary"
className="backdrop-blur bg-background/85 border-border/40"
>
<Archive className="size-3.5 mr-1" />
Archived
</Badge>
)}
{isHidden && (
<Badge
variant="secondary"
className="backdrop-blur bg-destructive/15 text-destructive border-destructive/30"
>
Hidden
</Badge>
)}
<CampaignModerationMenu
coord={campaign.aTag}
campaignTitle={campaign.title}
isApproved={isApproved}
isHidden={isHidden}
isFeatured={isFeatured}
/>
</div>
</div>
{/* Body */}
<div className={cn('flex flex-col gap-3 p-5', isFeaturedVariant && 'sm:w-1/2 sm:p-6')}>
<div className="space-y-2">
<h3
className={cn(
'font-bold leading-tight tracking-tight',
isFeaturedVariant ? 'text-2xl sm:text-3xl' : 'text-lg',
)}
>
{campaign.title}
</h3>
{campaign.summary && (
<p
className={cn(
'text-muted-foreground',
isFeaturedVariant ? 'text-base line-clamp-3' : 'text-sm line-clamp-2',
)}
>
{campaign.summary}
</p>
)}
</div>
<div className="flex-1" />
<CampaignProgress raisedSats={raisedSats} goalSats={campaign.goalSats} btcPrice={btcPrice} />
{/* Meta row */}
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5 text-xs text-muted-foreground pt-1">
<span className="inline-flex items-center gap-1.5">
<Users className="size-3.5" />
{campaign.recipients.length}{' '}
{campaign.recipients.length === 1 ? 'recipient' : 'recipients'}
</span>
{stats && stats.donorCount > 0 && (
<span className="inline-flex items-center gap-1.5">
<Target className="size-3.5" />
{stats.donorCount} {stats.donorCount === 1 ? 'donor' : 'donors'}
</span>
)}
{countryLabel && (
<span className="inline-flex items-center gap-1.5">
<MapPin className="size-3.5" />
{countryLabel}
</span>
)}
{deadline && (
<span
className={cn(
'inline-flex items-center gap-1.5',
deadline.isPast && 'text-destructive',
)}
>
<CalendarClock className="size-3.5" />
{deadline.label}
</span>
)}
</div>
<div className="text-xs text-muted-foreground border-t border-border/60 pt-3 truncate">
by <span className="font-medium text-foreground">{creatorName}</span>
</div>
</div>
</Card>
</Link>
);
}
/** Loading placeholder mirroring {@link CampaignCard} dimensions. */
export function CampaignCardSkeleton({
variant = 'compact',
className,
}: {
variant?: 'compact' | 'featured';
className?: string;
}) {
const isFeatured = variant === 'featured';
return (
<Card
className={cn(
'overflow-hidden border-border/70 shadow-sm h-full flex flex-col',
isFeatured && 'sm:flex-row sm:items-stretch',
className,
)}
>
<Skeleton
className={cn(
'w-full rounded-none',
isFeatured ? 'aspect-[16/10] sm:aspect-auto sm:w-1/2 sm:min-h-[280px]' : 'aspect-[16/9]',
)}
/>
<div className={cn('flex-1 p-5 space-y-3', isFeatured && 'sm:w-1/2 sm:p-6')}>
<Skeleton className={cn('w-3/4', isFeatured ? 'h-7' : 'h-5')} />
<div className="space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-4/5" />
</div>
<div className="flex-1" />
<Skeleton className="h-2 w-full" />
<Skeleton className="h-3 w-32" />
</div>
</Card>
);
}
+114
View File
@@ -0,0 +1,114 @@
import { useEffect, useRef, useState } from 'react';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import { cn } from '@/lib/utils';
interface CampaignHeroBackgroundProps {
/**
* Image URL for the active campaign. Each new URL crossfades over the
* previous one — we keep up to two layers mounted at a time so the
* transition is smooth even when the source changes mid-fade.
*/
imageUrl: string | undefined;
/** Optional className for the outer wrapper. */
className?: string;
}
interface Layer {
/** Stable key so React doesn't tear down the layer mid-transition. */
id: number;
/** Sanitized URL (or `null` for the gradient-only fallback). */
url: string | null;
}
const FADE_MS = 1500;
/**
* Full-bleed crossfading background built from the active campaign's banner
* image. Modelled after Treasures' HeroGallery: each image gets its own
* stacked layer and we toggle opacity to crossfade. The previous layer
* unmounts after the fade completes, so we never accumulate more than a
* couple of layers in the DOM.
*
* A warm tint + subtle film-grain SVG sit on top so headlines stay readable
* over any photo.
*/
export function CampaignHeroBackground({ imageUrl, className }: CampaignHeroBackgroundProps) {
const idRef = useRef(0);
const [layers, setLayers] = useState<Layer[]>([]);
const lastUrlRef = useRef<string | null>(null);
useEffect(() => {
const safe = sanitizeUrl(imageUrl) ?? null;
if (safe === lastUrlRef.current) return;
lastUrlRef.current = safe;
const id = ++idRef.current;
// Add the new layer; existing layers stay mounted so the crossfade has
// something to fade from.
setLayers((prev) => [...prev, { id, url: safe }]);
// After the fade completes, drop everything except the most recent
// layer to keep the DOM tidy.
const timeout = window.setTimeout(() => {
setLayers((prev) => prev.filter((l) => l.id === id));
}, FADE_MS + 50);
return () => window.clearTimeout(timeout);
}, [imageUrl]);
return (
<div className={cn('absolute inset-0 overflow-hidden', className)} aria-hidden="true">
{layers.map((layer, i) => {
const isTop = i === layers.length - 1;
return (
<div
key={layer.id}
className="absolute inset-0"
style={{
opacity: isTop ? 1 : 0,
transition: `opacity ${FADE_MS}ms ease-in-out`,
}}
>
{layer.url ? (
<img
src={layer.url}
alt=""
loading="eager"
decoding="async"
// Slow continuous pan toward the left — pairs with the
// right-anchored globe so the scene reads as moving toward
// the headline copy.
className="absolute inset-0 w-full h-full object-cover hero-pan-left"
/>
) : (
<div className="absolute inset-0 bg-gradient-to-br from-primary/30 via-background to-secondary/40" />
)}
</div>
);
})}
{/* Dark vertical scrim — strong at the bottom (spotlight card) and
lighter at the top so the photo still reads. Uses black instead of
background so the overlay is consistent across light/dark themes. */}
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/45 to-black/15" />
{/* Warm primary tint — gives the hero its brand feel. */}
<div className="absolute inset-0 bg-gradient-to-br from-primary/20 via-transparent to-primary/10" />
{/* Left wash — mobile only, where the globe arc crosses the headline.
Dark so white headline text has a reliable backdrop. */}
<div className="absolute inset-0 bg-gradient-to-r from-black/75 via-black/35 to-transparent sm:hidden" />
{/* Film grain — same trick as Treasures' HeroGallery. Helps the
composited globe + photo feel like one image. */}
<svg
className="absolute inset-0 w-full h-full pointer-events-none"
style={{ opacity: 0.18 }}
>
<filter id="hero-grain">
<feTurbulence type="fractalNoise" baseFrequency="0.7" numOctaves="2" stitchTiles="stitch" />
<feColorMatrix type="saturate" values="0" />
</filter>
<rect width="100%" height="100%" filter="url(#hero-grain)" />
</svg>
</div>
);
}
+144
View File
@@ -0,0 +1,144 @@
import { useState } from 'react';
import { Check, EyeOff, Eye, Loader2, MoreHorizontal, ShieldCheck, ShieldOff, Sparkles, SparklesIcon } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useCampaignModeration, type ModerationLabel } from '@/hooks/useCampaignModeration';
import { useCampaignModerators } from '@/hooks/useCampaignModerators';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useToast } from '@/hooks/useToast';
interface CampaignModerationMenuProps {
/** The campaign's `30223:<pubkey>:<d>` coordinate. */
coord: string;
/** Visible label for the campaign (for toast feedback). */
campaignTitle: string;
/** Whether the campaign is currently approved. */
isApproved: boolean;
/** Whether the campaign is currently hidden. */
isHidden: boolean;
/** Whether the campaign is currently featured. */
isFeatured: boolean;
className?: string;
}
/**
* Per-card kebab menu exposing the six moderation actions:
* Approve / Unapprove (axis = approval)
* Hide / Unhide (axis = hide)
* Feature / Unfeature (axis = featured)
*
* Renders `null` for users who are not Team Soapbox pack members. Sits
* inside the clickable `CampaignCard` `<Link>`, so the trigger swallows
* its own click + the dropdown content stops propagation, otherwise every
* menu interaction would navigate to the campaign detail page.
*/
export function CampaignModerationMenu({
coord,
campaignTitle,
isApproved,
isHidden,
isFeatured,
className,
}: CampaignModerationMenuProps) {
const { user } = useCurrentUser();
const { data: moderators } = useCampaignModerators();
const { moderate } = useCampaignModeration();
const { toast } = useToast();
const [busy, setBusy] = useState<ModerationLabel | null>(null);
const isMod = !!user && !!moderators && moderators.includes(user.pubkey);
if (!isMod) return null;
const runAction = async (action: ModerationLabel, verbPast: string) => {
if (busy) return;
setBusy(action);
try {
await moderate.mutateAsync({ coord, action });
toast({ title: `${verbPast}`, description: campaignTitle });
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
toast({
title: `Failed to ${action}`,
description: message,
variant: 'destructive',
});
} finally {
setBusy(null);
}
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild onClick={(e) => e.preventDefault()}>
<Button
variant="ghost"
size="icon"
aria-label="Moderate campaign"
className={className ?? 'h-8 w-8 bg-background/80 backdrop-blur text-muted-foreground hover:text-foreground'}
>
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <MoreHorizontal className="h-4 w-4" />}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" onClick={(e) => e.stopPropagation()}>
<DropdownMenuLabel className="text-xs text-muted-foreground">
Moderator actions
</DropdownMenuLabel>
<DropdownMenuSeparator />
{isApproved ? (
<DropdownMenuItem onClick={() => runAction('unapproved', 'Removed from homepage')}>
<ShieldOff className="h-4 w-4 mr-2" />
Unapprove
<span className="ml-auto text-xs text-muted-foreground inline-flex items-center gap-1">
<Check className="h-3 w-3" /> Approved
</span>
</DropdownMenuItem>
) : (
<DropdownMenuItem onClick={() => runAction('approved', 'Approved for homepage')}>
<ShieldCheck className="h-4 w-4 mr-2" />
Approve
</DropdownMenuItem>
)}
{isHidden ? (
<DropdownMenuItem onClick={() => runAction('unhidden', 'Unhidden')}>
<Eye className="h-4 w-4 mr-2" />
Unhide
<span className="ml-auto text-xs text-muted-foreground inline-flex items-center gap-1">
<Check className="h-3 w-3" /> Hidden
</span>
</DropdownMenuItem>
) : (
<DropdownMenuItem
onClick={() => runAction('hidden', 'Hidden')}
className="text-destructive focus:text-destructive"
>
<EyeOff className="h-4 w-4 mr-2" />
Hide
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
{isFeatured ? (
<DropdownMenuItem onClick={() => runAction('unfeatured', 'Removed from featured')}>
<SparklesIcon className="h-4 w-4 mr-2" />
Unfeature
<span className="ml-auto text-xs text-muted-foreground inline-flex items-center gap-1">
<Check className="h-3 w-3" /> Featured
</span>
</DropdownMenuItem>
) : (
<DropdownMenuItem onClick={() => runAction('featured', 'Featured on homepage')}>
<Sparkles className="h-4 w-4 mr-2" />
Feature
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}
+80 -67
View File
@@ -4,7 +4,7 @@ import { Link } from 'react-router-dom';
import { nip19 } from 'nostr-tools';
import {
Award, BarChart3, Bird, BookOpen, Camera, Clapperboard, FileText, Film,
GitBranch, GitPullRequest, Highlighter, Mail, MapPin, MessageSquare, Mic, Music,
GitBranch, GitPullRequest, Highlighter, Mail, MapPin, Megaphone, MessageSquare, Mic, Music,
Package, Palette, PartyPopper, Podcast, Radio, Rocket, SmilePlus,
Stars, Target, Users, UserCheck, Vote, Zap,
} from 'lucide-react';
@@ -30,6 +30,8 @@ import { useScryfallCard } from '@/hooks/useScryfallCard';
import { getDisplayName } from '@/lib/getDisplayName';
import { genUserName } from '@/lib/genUserName';
import { getCountryInfo, getWikipediaTitle } from '@/lib/countries';
import { CountryFlag } from '@/components/CountryFlag';
import { customFlagAsset, hasCustomFlag } from '@/lib/customFlags';
import { useCountryFeed } from '@/contexts/CountryFeedContext';
import { cn } from '@/lib/utils';
import { useFlagPalette } from '@/lib/flagPalette';
@@ -144,10 +146,10 @@ const KIND_LABELS: Record<number, string> = {
32267: 'a Zapstore app',
34139: 'a playlist',
34236: 'a divine',
34550: 'a community',
34550: 'an organization',
9041: 'a goal',
35128: 'an nsite',
36639: 'an action',
36639: 'a pledge',
36787: 'a track',
37381: 'a Magic deck',
37516: 'a treasure',
@@ -178,7 +180,7 @@ const KIND_ICONS: Partial<Record<number, React.ComponentType<{ className?: strin
1618: GitPullRequest,
15128: Rocket,
35128: Rocket,
36639: Zap,
36639: Megaphone,
10008: Award,
30008: Award,
30009: Award,
@@ -244,7 +246,7 @@ const KIND_SUFFIXES: Partial<Record<number, string>> = {
37381: 'deck',
37516: 'treasure',
30621: 'constellation',
34550: 'community',
34550: 'organization',
30054: 'episode',
30055: 'trailer',
34139: 'playlist',
@@ -383,6 +385,7 @@ function EventHoverLink({ display, link, hoverContent }: EventHoverLinkProps) {
interface CommentContextProps {
event: NostrEvent;
className?: string;
prefix?: string;
}
/**
@@ -390,7 +393,7 @@ interface CommentContextProps {
* When the parent item (lowercase k tag) is another kind 1111 comment, shows "Replying to @user"
* using the lowercase p tag (parent author). Otherwise shows "Commenting on [root]".
*/
export function CommentContext({ event, className }: CommentContextProps) {
export function CommentContext({ event, className, prefix = 'Commenting on' }: CommentContextProps) {
// If the direct parent is another comment (k="1111"), show "Replying to @user"
const parentKind = event.tags.find(([name]) => name === 'k')?.[1];
const parentAuthorPubkey = event.tags.findLast(([name]) => name === 'p')?.[1];
@@ -405,11 +408,11 @@ export function CommentContext({ event, className }: CommentContextProps) {
switch (root.type) {
case 'addr':
return <AddrCommentContext root={root} className={className} />;
return <AddrCommentContext root={root} className={className} prefix={prefix} />;
case 'event':
return <EventCommentContext root={root} className={className} />;
return <EventCommentContext root={root} className={className} prefix={prefix} />;
case 'external':
return <ExternalCommentContext root={root} className={className} />;
return <ExternalCommentContext root={root} className={className} prefix={prefix} />;
default:
return null;
}
@@ -444,27 +447,27 @@ function ReplyToCommentContext({ pubkey, eventId, className }: { pubkey: string;
}
/** Comment context for addressable event roots (A tag). */
function AddrCommentContext({ root, className }: { root: CommentRoot; className?: string }) {
function AddrCommentContext({ root, className, prefix }: { root: CommentRoot; className?: string; prefix: string }) {
// Kind 0 (profile) roots get special treatment — show "@DisplayName" with a profile link
if (root.addr?.kind === 0) {
return <ProfileCommentContext pubkey={root.addr.pubkey} className={className} />;
return <ProfileCommentContext pubkey={root.addr.pubkey} className={className} prefix={prefix} />;
}
// Kind 10008 or 30008 (profile badges) roots — show "@User's profile badges"
if (root.addr?.kind === 10008 || root.addr?.kind === 30008) {
return <ProfileBadgesCommentContext root={root} className={className} />;
return <ProfileBadgesCommentContext root={root} className={className} prefix={prefix} />;
}
// Kind 3 follow lists have no title of their own — synthesize one from the author's name
if (root.addr?.kind === 3) {
return <FollowListCommentContext pubkey={root.addr.pubkey} className={className} />;
return <FollowListCommentContext pubkey={root.addr.pubkey} className={className} prefix={prefix} />;
}
return <GenericAddrCommentContext root={root} className={className} />;
return <GenericAddrCommentContext root={root} className={className} prefix={prefix} />;
}
/** Comment context for kind 3 (follow list) roots — shows "Commenting on @Name's follow list". */
function FollowListCommentContext({ pubkey, className }: { pubkey: string; className?: string }) {
function FollowListCommentContext({ pubkey, className, prefix }: { pubkey: string; className?: string; prefix: string }) {
const author = useAuthor(pubkey);
const metadata = author.data?.metadata;
const displayName = metadata?.name ?? metadata?.display_name ?? genUserName(pubkey);
@@ -475,7 +478,7 @@ function FollowListCommentContext({ pubkey, className }: { pubkey: string; class
);
return (
<CommentContextRow prefix="Commenting on" className={className} loading={author.isLoading}>
<CommentContextRow prefix={prefix} className={className} loading={author.isLoading}>
<ProfileHoverCard pubkey={pubkey} asChild>
<Link
to={`/${npubEncoded}`}
@@ -498,14 +501,14 @@ function FollowListCommentContext({ pubkey, className }: { pubkey: string; class
}
/** Comment context for kind 0 (profile) roots — shows "Commenting on @Name". */
function ProfileCommentContext({ pubkey, className }: { pubkey: string; className?: string }) {
function ProfileCommentContext({ pubkey, className, prefix }: { pubkey: string; className?: string; prefix: string }) {
const author = useAuthor(pubkey);
const metadata = author.data?.metadata;
const displayName = metadata?.name ?? metadata?.display_name ?? genUserName(pubkey);
const npubEncoded = useMemo(() => nip19.npubEncode(pubkey), [pubkey]);
return (
<CommentContextRow prefix="Commenting on" className={className} loading={author.isLoading}>
<CommentContextRow prefix={prefix} className={className} loading={author.isLoading}>
<ProfileHoverCard pubkey={pubkey} asChild>
<Link
to={`/${npubEncoded}`}
@@ -520,7 +523,7 @@ function ProfileCommentContext({ pubkey, className }: { pubkey: string; classNam
}
/** Comment context for kind 10008/30008 (profile badges) roots — shows "Commenting on profile badges by @User". */
function ProfileBadgesCommentContext({ root, className }: { root: CommentRoot; className?: string }) {
function ProfileBadgesCommentContext({ root, className, prefix }: { root: CommentRoot; className?: string; prefix: string }) {
const pubkey = root.addr?.pubkey ?? '';
const author = useAuthor(pubkey);
const metadata = author.data?.metadata;
@@ -542,7 +545,7 @@ function ProfileBadgesCommentContext({ root, className }: { root: CommentRoot; c
) : undefined;
return (
<CommentContextRow prefix="Commenting on" className={className} loading={author.isLoading}>
<CommentContextRow prefix={prefix} className={className} loading={author.isLoading}>
{link && hoverContent ? (
<EventHoverLink
display={{ text: 'profile badges', icon: Award }}
@@ -570,11 +573,11 @@ function ProfileBadgesCommentContext({ root, className }: { root: CommentRoot; c
}
/** Comment context for non-profile addressable event roots (A tag). */
function GenericAddrCommentContext({ root, className }: { root: CommentRoot; className?: string }) {
function GenericAddrCommentContext({ root, className, prefix }: { root: CommentRoot; className?: string; prefix: string }) {
const { data: event, isLoading } = useAddrEvent(root.addr, root.relayHint ? [root.relayHint] : undefined);
const isCommunity = root.rootKind === '34550' || root.addr?.kind === 34550;
const prefix = isCommunity ? 'Posted in' : 'Commenting on';
const rowPrefix = isCommunity && prefix === 'Commenting on' ? 'Posted in' : prefix;
const display = event ? getEventDisplayName(event) : { text: getRootKindLabel(root.rootKind) };
const link = event ? getRootLink(event) : undefined;
@@ -588,7 +591,7 @@ function GenericAddrCommentContext({ root, className }: { root: CommentRoot; cla
) : undefined;
return (
<CommentContextRow prefix={prefix} className={className} loading={isLoading}>
<CommentContextRow prefix={rowPrefix} className={className} loading={isLoading}>
{link && hoverContent ? (
<EventHoverLink display={display} link={link} hoverContent={hoverContent} />
) : link ? (
@@ -608,7 +611,7 @@ function GenericAddrCommentContext({ root, className }: { root: CommentRoot; cla
}
/** Comment context for regular event roots (E tag). */
function EventCommentContext({ root, className }: { root: CommentRoot; className?: string }) {
function EventCommentContext({ root, className, prefix }: { root: CommentRoot; className?: string; prefix: string }) {
const { data: event, isLoading } = useEvent(
root.eventId,
root.relayHint ? [root.relayHint] : undefined,
@@ -617,12 +620,12 @@ function EventCommentContext({ root, className }: { root: CommentRoot; className
// Kind 7 reactions get special treatment
if (event?.kind === 7) {
return <ReactionCommentContext event={event} className={className} />;
return <ReactionCommentContext event={event} className={className} prefix={prefix} />;
}
// Kind 1018 poll votes get special treatment
if (event?.kind === 1018) {
return <PollVoteCommentContext event={event} className={className} />;
return <PollVoteCommentContext event={event} className={className} prefix={prefix} />;
}
const display = event ? getEventDisplayName(event) : { text: getRootKindLabel(root.rootKind) };
@@ -639,7 +642,7 @@ function EventCommentContext({ root, className }: { root: CommentRoot; className
) : undefined;
return (
<CommentContextRow prefix="Commenting on" className={className} loading={isLoading}>
<CommentContextRow prefix={prefix} className={className} loading={isLoading}>
{link && hoverContent ? (
<EventHoverLink display={display} link={link} hoverContent={hoverContent} />
) : (
@@ -650,7 +653,7 @@ function EventCommentContext({ root, className }: { root: CommentRoot; className
}
/** Comment context for kind 7 reaction roots — shows "Commenting on {emoji} by @{name}". */
function ReactionCommentContext({ event, className }: { event: NostrEvent; className?: string }) {
function ReactionCommentContext({ event, className, prefix }: { event: NostrEvent; className?: string; prefix: string }) {
const author = useAuthor(event.pubkey);
const metadata = author.data?.metadata;
const displayName = getDisplayName(metadata, event.pubkey);
@@ -658,7 +661,7 @@ function ReactionCommentContext({ event, className }: { event: NostrEvent; class
const profileLink = `/${nip19.npubEncode(event.pubkey)}`;
return (
<CommentContextRow prefix="Commenting on" className={className}>
<CommentContextRow prefix={prefix} className={className}>
<Link
to={reactionLink}
className="text-primary hover:underline shrink-0 cursor-pointer"
@@ -685,7 +688,7 @@ function ReactionCommentContext({ event, className }: { event: NostrEvent; class
}
/** Comment context for kind 1018 poll vote roots — shows "Commenting on @{name}'s vote for {option}". */
function PollVoteCommentContext({ event, className }: { event: NostrEvent; className?: string }) {
function PollVoteCommentContext({ event, className, prefix }: { event: NostrEvent; className?: string; prefix: string }) {
const author = useAuthor(event.pubkey);
const metadata = author.data?.metadata;
const displayName = getDisplayName(metadata, event.pubkey);
@@ -695,7 +698,7 @@ function PollVoteCommentContext({ event, className }: { event: NostrEvent; class
const voteLabel = usePollVoteLabel(event);
return (
<CommentContextRow prefix="Commenting on" className={className}>
<CommentContextRow prefix={prefix} className={className}>
{author.isLoading ? (
<Skeleton className="h-3.5 w-16 inline-block" />
) : (
@@ -722,12 +725,12 @@ function PollVoteCommentContext({ event, className }: { event: NostrEvent; class
}
/** Comment context for external content roots (I tag). */
function ExternalCommentContext({ root, className }: { root: CommentRoot; className?: string }) {
function ExternalCommentContext({ root, className, prefix }: { root: CommentRoot; className?: string; prefix: string }) {
const identifier = root.identifier ?? '';
// ISBN identifiers get special treatment — show book title instead of raw ISBN
if (identifier.startsWith('isbn:')) {
return <IsbnCommentContext identifier={identifier} className={className} />;
return <IsbnCommentContext identifier={identifier} className={className} prefix={prefix} />;
}
// URL identifiers get special treatment — show page title with favicon.
@@ -736,21 +739,21 @@ function ExternalCommentContext({ root, className }: { root: CommentRoot; classN
if (identifier.startsWith('http://') || identifier.startsWith('https://')) {
const gathererCard = extractGathererCard(identifier);
if (gathererCard) {
return <GathererCardCommentContext card={gathererCard} url={identifier} className={className} />;
return <GathererCardCommentContext card={gathererCard} url={identifier} className={className} prefix={prefix} />;
}
return <UrlCommentContext url={identifier} className={className} />;
return <UrlCommentContext url={identifier} className={className} prefix={prefix} />;
}
// ISO 3166 country/subdivision identifiers get special treatment
if (identifier.startsWith('iso3166:')) {
return <CountryCommentContext identifier={identifier} className={className} />;
return <CountryCommentContext identifier={identifier} className={className} prefix={prefix} />;
}
// Generic fallback for other external identifiers
const link = `/i/${encodeURIComponent(identifier)}`;
return (
<CommentContextRow prefix="Commenting on" className={className}>
<CommentContextRow prefix={prefix} className={className}>
<Link
to={link}
className="text-primary hover:underline truncate"
@@ -763,7 +766,7 @@ function ExternalCommentContext({ root, className }: { root: CommentRoot; classN
}
/** Comment context for URL identifiers — fetches and displays the page title with favicon. */
function UrlCommentContext({ url, className }: { url: string; className?: string }) {
function UrlCommentContext({ url, className, prefix }: { url: string; className?: string; prefix: string }) {
const { data: preview, isLoading } = useLinkPreview(url);
const link = `/i/${encodeURIComponent(url)}`;
@@ -777,7 +780,7 @@ function UrlCommentContext({ url, className }: { url: string; className?: string
const title = preview?.title;
return (
<CommentContextRow prefix="Commenting on" className={className} loading={isLoading}>
<CommentContextRow prefix={prefix} className={className} loading={isLoading}>
<ExternalFavicon url={url} size={14} className="shrink-0" />
<HoverCard openDelay={300} closeDelay={150}>
<HoverCardTrigger asChild>
@@ -810,6 +813,16 @@ function CountryPillBadge({ identifier, className }: { identifier: string; class
const link = `/i/${encodeURIComponent(identifier)}`;
const flag = info?.flag ?? '🌍';
// Treat ISO codes with a curated custom flag (Tibet) as country-level
// throughout the pill chrome — display its own name, drop the parent
// country sub-line, and label it as a country rather than a region.
const treatAsCountry = hasCustomFlag(code);
const displayLabel = treatAsCountry
? info?.subdivisionName ?? info?.name ?? code
: info?.subdivisionName ?? info?.name ?? code;
const subLabel = !treatAsCountry && info?.subdivisionName && info.name ? info.name : null;
const tierLabel = info?.subdivisionName && !treatAsCountry ? 'Region' : 'Country';
const ariaLabel = info ? `Flag of ${displayLabel}` : code;
return (
<HoverCard openDelay={300} closeDelay={150}>
@@ -827,9 +840,9 @@ function CountryPillBadge({ identifier, className }: { identifier: string; class
className,
)}
>
<span role="img" aria-label={info ? `Flag of ${info.name}` : code}>
{flag}
</span>
{/* CountryFlag swaps in a bundled SVG (Tibet's Snow Lion etc.)
when the ISO code has no Unicode flag — emoji otherwise. */}
<CountryFlag code={code} emoji={flag} label={ariaLabel} />
</Link>
</HoverCardTrigger>
<HoverCardContent
@@ -840,20 +853,23 @@ function CountryPillBadge({ identifier, className }: { identifier: string; class
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center gap-3 px-4 py-3">
<span className="text-2xl leading-none shrink-0" role="img" aria-label={info ? `Flag of ${info.name}` : code}>
{flag}
</span>
<CountryFlag
code={code}
emoji={flag}
label={ariaLabel}
className="text-2xl shrink-0"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<MapPin className="size-3 shrink-0" />
<span>{info?.subdivisionName ? 'Region' : 'Country'}</span>
<span>{tierLabel}</span>
</div>
<p className="text-sm font-medium truncate mt-0.5">
{info?.subdivisionName ?? info?.name ?? code}
{displayLabel}
</p>
{info?.subdivisionName && info.name && (
{subLabel && (
<p className="text-xs text-muted-foreground truncate">
{info.name}
{subLabel}
</p>
)}
</div>
@@ -958,12 +974,18 @@ export function CountryFlagBackdrop({ event }: { event: NostrEvent }) {
if (!ctx) return null;
// Bundled-asset override: for codes with a curated flag SVG (currently
// CN-XZ / Tibet) we skip Wikipedia entirely — its lead image is often
// a parent-country map or an administrative-region inset, neither of
// which reads as a flag behind a note. The Snow Lion SVG is what the
// post is editorially "about", so it earns the backdrop slot.
const bundledAsset = customFlagAsset(ctx.code);
// For country articles Wikipedia returns the flag as the page's lead image
// — the same source used by `CountryContentHeader`. Prefer the original
// (full-resolution) over the 330px thumbnail; the thumbnail gets upscaled
// and looks fuzzy when stretched across a full-width feed card.
const flagImage = !imageFailed
? (wiki?.originalImage?.source ?? wiki?.thumbnail?.source ?? null)
? (bundledAsset ?? wiki?.originalImage?.source ?? wiki?.thumbnail?.source ?? null)
: null;
// Pre-built gradient using the palette (sampled from the flag emoji at
@@ -987,7 +1009,7 @@ export function CountryFlagBackdrop({ event }: { event: NostrEvent }) {
alt=""
decoding="async"
onError={() => setImageFailed(true)}
className="w-full h-full object-cover opacity-60 select-none"
className="w-full h-full object-cover opacity-20 select-none"
style={{
maskImage: 'linear-gradient(to bottom, black 0%, black 35%, transparent 100%)',
WebkitMaskImage: 'linear-gradient(to bottom, black 0%, black 35%, transparent 100%)',
@@ -999,7 +1021,7 @@ export function CountryFlagBackdrop({ event }: { event: NostrEvent }) {
// mask shape as the image so the visual swap is seamless when the
// image arrives.
<div
className="absolute inset-0 opacity-60"
className="absolute inset-0 opacity-20"
style={{
backgroundImage: paletteGradient,
maskImage: 'linear-gradient(to bottom, black 0%, black 35%, transparent 100%)',
@@ -1007,17 +1029,6 @@ export function CountryFlagBackdrop({ event }: { event: NostrEvent }) {
}}
/>
) : null}
{/* Black wash for foreground readability. Mirrors the mask shape
so the wash itself fades along with the flag — no hard edge. */}
<div
className="absolute inset-0"
style={{
backgroundImage:
'linear-gradient(to bottom, rgba(0,0,0,0.7) 0%, rgba(0,0,0,0.75) 50%, rgba(0,0,0,0) 100%)',
maskImage: 'linear-gradient(to bottom, black 0%, black 35%, transparent 100%)',
WebkitMaskImage: 'linear-gradient(to bottom, black 0%, black 35%, transparent 100%)',
}}
/>
</div>
</div>
);
@@ -1029,12 +1040,12 @@ export function CountryFlagBackdrop({ event }: { event: NostrEvent }) {
* `CountryCommentPill`, so we suppress the in-body version to avoid
* duplication.
*/
function CountryCommentContext(_props: { identifier: string; className?: string }) {
function CountryCommentContext(_props: { identifier: string; className?: string; prefix: string }) {
return null;
}
/** Comment context for ISBN identifiers — fetches and displays the book title with hover preview. */
function IsbnCommentContext({ identifier, className }: { identifier: string; className?: string }) {
function IsbnCommentContext({ identifier, className, prefix }: { identifier: string; className?: string; prefix: string }) {
const isbn = identifier.slice('isbn:'.length);
const { data: bookInfo, isLoading } = useBookInfo(isbn);
const link = `/i/${encodeURIComponent(identifier)}`;
@@ -1043,7 +1054,7 @@ function IsbnCommentContext({ identifier, className }: { identifier: string; cla
const authors = bookInfo?.authors?.map((a) => a.name).join(', ');
return (
<CommentContextRow prefix="Commenting on" className={className} loading={isLoading}>
<CommentContextRow prefix={prefix} className={className} loading={isLoading}>
<HoverCard openDelay={300} closeDelay={150}>
<HoverCardTrigger asChild>
<Link
@@ -1105,10 +1116,12 @@ function GathererCardCommentContext({
card,
url,
className,
prefix,
}: {
card: GathererCard;
url: string;
className?: string;
prefix: string;
}) {
const lookup = useMemo(() => (
card.kind === 'multiverse'
@@ -1122,7 +1135,7 @@ function GathererCardCommentContext({
const coverUrl = scryCard ? cardPrimaryImage(scryCard, 'small') : undefined;
return (
<CommentContextRow prefix="Commenting on" className={className} loading={isLoading}>
<CommentContextRow prefix={prefix} className={className} loading={isLoading}>
<HoverCard openDelay={300} closeDelay={150}>
<HoverCardTrigger asChild>
<Link
-335
View File
@@ -1,335 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Award, Loader2 } from 'lucide-react';
import { useNostr } from '@nostrify/react';
import { useQueryClient } from '@tanstack/react-query';
import type { NostrEvent } from '@nostrify/nostrify';
import { BadgeThumbnail } from '@/components/BadgeThumbnail';
import { ImageUploadField } from '@/components/ImageUploadField';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { useBadgeDefinitions, type BadgeDefinition } from '@/hooks/useBadgeDefinitions';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useToast } from '@/hooks/useToast';
import { BADGE_DEFINITION_KIND, COMMUNITY_DEFINITION_KIND, type ParsedCommunity } from '@/lib/communityUtils';
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
interface BadgeRef {
pubkey: string;
identifier: string;
}
interface CommunityBadgePanelProps {
communityEvent: NostrEvent;
community: ParsedCommunity;
isFounder: boolean;
}
function parseBadgeATag(aTag: string | undefined): BadgeRef | undefined {
if (!aTag) return undefined;
const [kind, pubkey, ...identifierParts] = aTag.split(':');
const identifier = identifierParts.join(':');
if (kind !== String(BADGE_DEFINITION_KIND) || !pubkey || !identifier) return undefined;
return { pubkey, identifier };
}
function buildBadgeTags(baseTags: string[][], dTag: string, name: string, description: string, imageUrl: string): string[][] {
const tags = baseTags.filter(([tagName]) => !['d', 'name', 'description', 'image', 'thumb', 'alt'].includes(tagName));
const nextTags: string[][] = [
['d', dTag],
['name', name.trim()],
];
if (description.trim()) {
nextTags.push(['description', description.trim()]);
}
const image = sanitizeUrl(imageUrl.trim());
if (image) {
nextTags.push(['image', image, '1024x1024']);
}
nextTags.push(...tags);
nextTags.push(['alt', `Badge definition: ${name.trim()}`]);
return nextTags;
}
function buildCommunityBadgeTags(baseTags: string[][], badgeATag: string): string[][] {
return [
...baseTags.filter(([tagName, value, , role]) => !(tagName === 'a' && value?.startsWith(`${BADGE_DEFINITION_KIND}:`) && role === 'member')),
['a', badgeATag, '', 'member'],
];
}
export function CommunityBadgePanel({ communityEvent, community, isFounder }: CommunityBadgePanelProps) {
const [editOpen, setEditOpen] = useState(false);
const badgeRef = useMemo(() => parseBadgeATag(community.memberBadgeATag), [community.memberBadgeATag]);
const badgeRefs = useMemo(() => badgeRef ? [badgeRef] : [], [badgeRef]);
const { badgeMap, isLoading, isError } = useBadgeDefinitions(badgeRefs);
const badge = community.memberBadgeATag ? badgeMap.get(community.memberBadgeATag) : undefined;
const badgeButtonLabel = badge ? `Edit ${badge.name} badge` : 'Set member badge';
const badgeVisual = isLoading ? (
<div className="size-10 animate-pulse rounded-lg bg-muted" />
) : badge ? (
<BadgeThumbnail badge={badge} size={40} className="shrink-0" />
) : (
<div className="flex size-10 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
<Award className="size-4" />
</div>
);
return (
<div className="min-w-0 flex-1">
<p className="mb-1 text-[11px] font-medium text-muted-foreground uppercase tracking-wider">Member badge</p>
<div className="flex items-center gap-3 py-1">
{isFounder ? (
<button
type="button"
onClick={() => setEditOpen(true)}
className="shrink-0 rounded-lg transition-opacity hover:opacity-80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
aria-label={badgeButtonLabel}
title={badgeButtonLabel}
>
{badgeVisual}
</button>
) : badgeVisual}
<div className="min-w-0 flex-1">
{isError ? (
<p className="text-sm text-destructive">Failed to load badge</p>
) : isLoading ? (
<div className="space-y-1.5">
<div className="h-4 w-24 animate-pulse rounded bg-muted" />
<div className="h-3 w-16 animate-pulse rounded bg-muted" />
</div>
) : badge ? (
<>
<p className="truncate text-sm font-medium">{badge.name}</p>
<p className="truncate text-xs text-muted-foreground">Community member badge</p>
</>
) : (
<>
<p className="truncate text-sm font-medium">Member badge</p>
<p className="truncate text-xs text-muted-foreground">
{isFounder ? 'Click the badge image to set one' : 'No badge set yet'}
</p>
</>
)}
</div>
</div>
{isFounder && (
<CommunityBadgeDialog
open={editOpen}
onOpenChange={setEditOpen}
communityEvent={communityEvent}
community={community}
badge={badge}
/>
)}
</div>
);
}
function CommunityBadgeDialog({
open,
onOpenChange,
communityEvent,
community,
badge,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
communityEvent: NostrEvent;
community: ParsedCommunity;
badge?: BadgeDefinition;
}) {
const { user } = useCurrentUser();
const { nostr } = useNostr();
const { toast } = useToast();
const queryClient = useQueryClient();
const { mutateAsync: publishEvent } = useNostrPublish();
const [name, setName] = useState('Member');
const [description, setDescription] = useState('');
const [imageUrl, setImageUrl] = useState('');
const [isPublishing, setIsPublishing] = useState(false);
const [isImageUploading, setIsImageUploading] = useState(false);
const canEditExistingBadge = !!badge && !!user && badge.event.pubkey === user.pubkey;
const canSave = !badge || canEditExistingBadge;
const resetForm = useCallback(() => {
setName(badge?.name || 'Member');
setDescription(badge?.description || `Member of ${community.name}`);
setImageUrl(badge?.image || badge?.thumbs[0]?.url || '');
setIsPublishing(false);
setIsImageUploading(false);
}, [badge, community.name]);
useEffect(() => {
if (open) resetForm();
}, [open, resetForm]);
const handleOpenChange = useCallback((nextOpen: boolean) => {
if (!nextOpen) resetForm();
onOpenChange(nextOpen);
}, [onOpenChange, resetForm]);
const handleSave = useCallback(async () => {
if (!user || user.pubkey !== communityEvent.pubkey) return;
if (!name.trim()) {
toast({ title: 'Enter a badge name', variant: 'destructive' });
return;
}
if (isImageUploading) {
toast({ title: 'Image is still uploading', description: 'Please wait for the upload to finish.' });
return;
}
if (imageUrl.trim() && !sanitizeUrl(imageUrl.trim())) {
toast({ title: 'Badge image must be a valid https URL', variant: 'destructive' });
return;
}
if (badge && !canEditExistingBadge) {
toast({ title: 'Badge cannot be edited', description: 'Only the badge issuer can edit this member badge.', variant: 'destructive' });
return;
}
setIsPublishing(true);
try {
const targetDTag = badge?.identifier || `${community.dTag}-member`;
const prevBadge = await fetchFreshEvent(nostr, {
kinds: [BADGE_DEFINITION_KIND],
authors: [user.pubkey],
'#d': [targetDTag],
});
const baseBadge = prevBadge ?? badge?.event;
const badgeEvent = await publishEvent({
kind: BADGE_DEFINITION_KIND,
content: baseBadge?.content ?? '',
tags: buildBadgeTags(baseBadge?.tags ?? [['d', targetDTag]], targetDTag, name, description, imageUrl),
prev: prevBadge ?? undefined,
});
const badgeATag = `${BADGE_DEFINITION_KIND}:${badgeEvent.pubkey}:${targetDTag}`;
if (!community.memberBadgeATag) {
const prevCommunity = await fetchFreshEvent(nostr, {
kinds: [COMMUNITY_DEFINITION_KIND],
authors: [communityEvent.pubkey],
'#d': [community.dTag],
});
const baseCommunity = prevCommunity ?? communityEvent;
const updatedCommunity = await publishEvent({
kind: COMMUNITY_DEFINITION_KIND,
content: baseCommunity.content,
tags: buildCommunityBadgeTags(baseCommunity.tags, badgeATag),
prev: prevCommunity ?? undefined,
});
queryClient.setQueryData(['addr-event', COMMUNITY_DEFINITION_KIND, updatedCommunity.pubkey, community.dTag], updatedCommunity);
}
queryClient.setQueryData(['addr-event', BADGE_DEFINITION_KIND, badgeEvent.pubkey, targetDTag], badgeEvent);
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['badge-definitions-batch'], exact: false }),
queryClient.invalidateQueries({ queryKey: ['community-members', community.aTag], exact: false }),
queryClient.invalidateQueries({ queryKey: ['community-activity-feed'], exact: false }),
queryClient.invalidateQueries({ queryKey: ['my-communities'], exact: false }),
]);
toast({ title: badge ? 'Member badge updated' : 'Member badge added' });
handleOpenChange(false);
} catch (error) {
toast({
title: 'Failed to update member badge',
description: error instanceof Error ? error.message : 'Please try again.',
variant: 'destructive',
});
} finally {
setIsPublishing(false);
}
}, [
user, communityEvent, name, isImageUploading, imageUrl, badge, canEditExistingBadge, community, nostr,
publishEvent, description, queryClient, toast, handleOpenChange,
]);
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-md gap-0 p-0 overflow-hidden">
<DialogHeader className="px-5 pt-5 pb-3">
<DialogTitle className="flex items-center gap-2">
<Award className="size-5 text-primary" />
Member Badge
</DialogTitle>
<DialogDescription>
This badge is awarded to members of {community.name}.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 px-5 pb-5">
{badge && !canEditExistingBadge && (
<p className="rounded-lg border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
This badge was issued by another account, so it cannot be edited here.
</p>
)}
<div className="space-y-1.5">
<Label htmlFor="community-member-badge-name">Badge name *</Label>
<Input
id="community-member-badge-name"
value={name}
onChange={(e) => setName(e.target.value)}
disabled={!canSave || isPublishing}
maxLength={80}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="community-member-badge-description">Description</Label>
<Textarea
id="community-member-badge-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
disabled={!canSave || isPublishing}
rows={2}
/>
</div>
<ImageUploadField
id="community-member-badge-image"
label={<>Badge image <span className="text-muted-foreground font-normal">(recommended)</span></>}
value={imageUrl}
onChange={setImageUrl}
onUploadingChange={setIsImageUploading}
uploadToastTitle="Badge image uploaded"
previewAlt="Member badge preview"
objectFit="contain"
dropAreaClassName="min-h-28"
disabled={!canSave || isPublishing}
/>
<Button
onClick={handleSave}
disabled={!canSave || !name.trim() || isPublishing || isImageUploading}
className="w-full gap-2"
>
{isPublishing ? <><Loader2 className="size-4 animate-spin" /> Saving...</> : <><Award className="size-4" /> Save Badge</>}
</Button>
</div>
</DialogContent>
</Dialog>
);
}
-213
View File
@@ -1,213 +0,0 @@
import { useCallback, useMemo } from 'react';
import { Link } from 'react-router-dom';
import { MessageSquare } from 'lucide-react';
import { useQueryClient } from '@tanstack/react-query';
import type { NostrEvent, NostrMetadata } from '@nostrify/nostrify';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Skeleton } from '@/components/ui/skeleton';
import { ComposeBox } from '@/components/ComposeBox';
import { ContentWarningGuard } from '@/components/ContentWarningGuard';
import { NoteContent } from '@/components/NoteContent';
import { useAuthor } from '@/hooks/useAuthor';
import { useCommunityChatMessages, COMMUNITY_CHAT_KIND } from '@/hooks/useCommunityChatMessages';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useProfileUrl } from '@/hooks/useProfileUrl';
import { getDisplayName } from '@/lib/getDisplayName';
import type { CommunityMember, CommunityModeration } from '@/lib/communityUtils';
import { cn } from '@/lib/utils';
interface CommunityChatPanelProps {
communityATag: string;
moderation: CommunityModeration;
rankMap: ReadonlyMap<string, CommunityMember>;
isMembershipLoading: boolean;
}
function shortTimeAgo(timestamp: number): string {
const diff = Math.max(0, Math.floor(Date.now() / 1000) - timestamp);
if (diff < 60) return 'now';
if (diff < 3600) return `${Math.floor(diff / 60)}m`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h`;
return `${Math.floor(diff / 86400)}d`;
}
export function CommunityChatPanel({
communityATag,
moderation,
rankMap,
isMembershipLoading,
}: CommunityChatPanelProps) {
const queryClient = useQueryClient();
const { user } = useCurrentUser();
const { data: messages, isLoading, isError, error, queryKey } = useCommunityChatMessages(communityATag, moderation);
const isBanned = !!user && moderation.bannedPubkeys.has(user.pubkey);
const isMember = !!user && rankMap.has(user.pubkey) && !isBanned;
const disabledReason = !user
? 'Log in to chat with this community.'
: isMembershipLoading
? 'Loading membership...'
: isBanned
? 'You are banned from this community.'
: !isMember
? 'Only community members can chat.'
: undefined;
const canSend = !disabledReason;
const chatPublish = useMemo(() => ({
kind: COMMUNITY_CHAT_KIND,
tags: [['a', communityATag, '', 'root']],
suppressSuccessToast: true,
}), [communityATag]);
const handlePublished = useCallback((event: NostrEvent) => {
queryClient.setQueryData<NostrEvent[]>(queryKey, (old = []) => {
if (old.some((existing) => existing.id === event.id)) return old;
return [...old, event].sort((a, b) => b.created_at - a.created_at);
});
}, [queryClient, queryKey]);
return (
<div>
<div>
{disabledReason && (
<p className="px-4 pt-3 text-center text-xs text-muted-foreground">{disabledReason}</p>
)}
{canSend && (
<ComposeBox
compact
placeholder="What's up?"
customPublish={chatPublish}
hidePoll
submitLabel="Send"
onPublished={handlePublished}
/>
)}
</div>
<div>
{isLoading ? (
<CommunityChatSkeleton />
) : isError ? (
<div className="py-12 px-4 text-center text-sm text-destructive">
{error instanceof Error ? error.message : 'Failed to load community chat.'}
</div>
) : messages.length === 0 ? (
<div className="flex flex-col items-center justify-center px-4 py-12 text-center">
<div className="mb-3 rounded-full bg-primary/10 p-3">
<MessageSquare className="size-6 text-primary" />
</div>
<p className="text-sm font-medium">No messages yet</p>
<p className="mt-1 text-xs text-muted-foreground">Start the first live conversation here.</p>
</div>
) : (
<div>
{messages.map((event, index) => {
const previous = messages[index - 1];
const showAvatar = !previous
|| previous.pubkey !== event.pubkey
|| previous.created_at - event.created_at > 300;
return <CommunityChatMessage key={event.id} event={event} showAvatar={showAvatar} />;
})}
</div>
)}
</div>
</div>
);
}
function CommunityChatSkeleton() {
return (
<div className="space-y-4 px-2 py-3">
{Array.from({ length: 6 }).map((_, index) => (
<div key={index} className="flex items-start gap-3">
<Skeleton className="size-8 rounded-full" />
<div className="flex-1 space-y-2 pt-1">
<Skeleton className="h-3 w-24" />
<Skeleton className={cn('h-4', index % 2 === 0 ? 'w-4/5' : 'w-2/3')} />
</div>
</div>
))}
</div>
);
}
function CommunityChatMessage({ event, showAvatar }: { event: NostrEvent; showAvatar: boolean }) {
const { user } = useCurrentUser();
const author = useAuthor(event.pubkey);
const metadata: NostrMetadata | undefined = author.data?.metadata;
const displayName = getDisplayName(metadata, event.pubkey);
const profileUrl = useProfileUrl(event.pubkey, metadata);
const isOwnMessage = user?.pubkey === event.pubkey;
return (
<div
className={cn(
'group flex gap-3 px-4 py-3 transition-colors hover:bg-secondary/40',
!showAvatar && 'py-2',
isOwnMessage && 'justify-end',
)}
>
{!isOwnMessage && <ChatMessageAvatar showAvatar={showAvatar} profileUrl={profileUrl} metadata={metadata} displayName={displayName} createdAt={event.created_at} />}
<div className={cn('min-w-0 flex-1', isOwnMessage && 'flex flex-col items-end')}>
{showAvatar && (
<div className={cn('mb-0.5 flex items-baseline gap-2', isOwnMessage && 'justify-end')}>
<Link
to={profileUrl}
className={cn('truncate text-xs font-semibold text-primary hover:underline', isOwnMessage && 'order-2')}
onClick={(event) => event.stopPropagation()}
>
{displayName}
</Link>
<span className={cn('text-[10px] text-muted-foreground/60', isOwnMessage && 'order-1')}>{shortTimeAgo(event.created_at)}</span>
</div>
)}
<ContentWarningGuard event={event} className="w-full max-w-[64%] sm:max-w-xs">
<div
className={cn(
'inline-block w-fit max-w-[64%] break-words rounded-2xl px-3 py-2 text-sm leading-relaxed sm:max-w-xs',
isOwnMessage ? 'rounded-tr-md bg-primary text-primary-foreground text-right' : 'rounded-tl-md bg-secondary/60',
)}
>
<NoteContent event={event} disableNoteEmbeds />
</div>
</ContentWarningGuard>
</div>
{isOwnMessage && <ChatMessageAvatar showAvatar={showAvatar} profileUrl={profileUrl} metadata={metadata} displayName={displayName} createdAt={event.created_at} />}
</div>
);
}
function ChatMessageAvatar({
showAvatar,
profileUrl,
metadata,
displayName,
createdAt,
}: {
showAvatar: boolean;
profileUrl: string;
metadata: NostrMetadata | undefined;
displayName: string;
createdAt: number;
}) {
return (
<div className="w-8 shrink-0">
{showAvatar ? (
<Link to={profileUrl} onClick={(event) => event.stopPropagation()}>
<Avatar className="size-8">
<AvatarImage src={metadata?.picture} alt={displayName} />
<AvatarFallback className="bg-primary/15 text-[10px] text-primary">
{displayName.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
</Link>
) : (
<span className="hidden pt-0.5 text-[10px] text-muted-foreground/60 group-hover:block">
{shortTimeAgo(createdAt)}
</span>
)}
</div>
);
}
+1 -1
View File
@@ -11,7 +11,7 @@ function getTag(tags: string[][], name: string): string | undefined {
}
function parseCommunityEvent(event: NostrEvent) {
const name = getTag(event.tags, 'name') || getTag(event.tags, 'd') || 'Unnamed Community';
const name = getTag(event.tags, 'name') || getTag(event.tags, 'd') || 'Unnamed Organization';
const description = getTag(event.tags, 'description') || '';
const image = getTag(event.tags, 'image');
+3 -3
View File
@@ -14,7 +14,7 @@ const REPORT_TYPE_LABELS: Record<Nip56ReportType, string> = {
illegal: 'illegal content',
malware: 'malware',
impersonation: 'impersonation',
other: 'community guidelines',
other: 'organization guidelines',
};
interface CommunityContentWarningProps {
@@ -68,8 +68,8 @@ export function CommunityContentWarning({ event, children, className }: Communit
<p className="text-sm font-medium text-foreground">Reported Content</p>
<p className="text-xs text-muted-foreground leading-relaxed">
{reporterCount === 1
? `Reported by a community member for ${typeLabels}.`
: `Reported by ${reporterCount} community members for ${typeLabels}.`}
? `Reported by an organization moderator for ${typeLabels}.`
: `Reported by ${reporterCount} organization moderators for ${typeLabels}.`}
</p>
</div>
<Button
File diff suppressed because it is too large Load Diff
-173
View File
@@ -1,173 +0,0 @@
/**
* CommunityPulsePanel
*
* "Pulse" tab on the community detail page — an infinite-scrolling feed of
* posts published by community members *outside* this community. The intent
* is to surface what members are sharing in the wider Nostr ecosystem, as
* opposed to the in-community Activity tab.
*
* Implementation notes:
* - Authors come from the community `rankMap` (founders + moderators +
* members). Without authors the relay would return the entire global
* timeline.
* - Kinds come from `getEnabledFeedKinds(feedSettings)` so the feed
* respects the user's "Notes / Articles / Reposts / etc." preferences,
* exactly like the home feed.
* - Events tagged with this community's `a` reference are dropped — those
* belong on the Activity tab.
* - Replies (NIP-10 / NIP-22) are dropped so the Pulse reads like a
* timeline of top-level posts, not threaded responses.
* - Mute list, content-warning, and repost unwrap behavior come for free
* by reusing `useTabFeed` + the `feedUtils` helpers.
*/
import { useEffect, useMemo } from 'react';
import { useInView } from 'react-intersection-observer';
import { Loader2 } from 'lucide-react';
import type { NostrFilter } from '@nostrify/nostrify';
import { NoteCard } from '@/components/NoteCard';
import { Skeleton } from '@/components/ui/skeleton';
import { useTabFeed } from '@/hooks/useProfileFeed';
import { useMuteList } from '@/hooks/useMuteList';
import { isEventMuted } from '@/lib/muteHelpers';
import { shouldHideFeedEvent } from '@/lib/feedUtils';
import { isReplyEvent } from '@/lib/nostrEvents';
interface CommunityPulsePanelProps {
/** `34550:<pubkey>:<d>` — used both for the cache key and the in-community filter. */
communityATag: string;
/** Author allowlist — founders + moderators + members. */
memberPubkeys: string[];
/** True while membership is still resolving; suppresses an empty-state flash. */
isMembershipLoading: boolean;
}
export function CommunityPulsePanel({
communityATag,
memberPubkeys,
isMembershipLoading,
}: CommunityPulsePanelProps) {
const { muteItems } = useMuteList();
const { ref: sentinelRef, inView } = useInView({ threshold: 0, rootMargin: '400px' });
// Build the TabFeed filter — kinds default to the user's enabled feed kinds
// (handled inside useTabFeed when `kinds` is omitted from the filter).
const filter = useMemo<NostrFilter | null>(
() => (memberPubkeys.length > 0 ? { authors: memberPubkeys } : null),
[memberPubkeys],
);
const {
data,
isLoading,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useTabFeed(filter, `community-pulse-${communityATag}`, memberPubkeys.length > 0);
// Fetch next page when the sentinel scrolls into view.
useEffect(() => {
if (inView && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]);
/**
* Drop events that reference *this* community via an `a` tag — they belong
* to the Activity tab, not Pulse. We check both the original event and the
* embedded event of a repost.
*/
const referencesThisCommunity = (tags: string[][]): boolean => {
for (const tag of tags) {
if (tag[0] === 'a' && tag[1] === communityATag) return true;
}
return false;
};
// Flatten pages, dedupe, and apply mute / content-warning / reply /
// in-community filters.
const feedItems = useMemo(() => {
if (!data?.pages) return [];
const seen = new Set<string>();
return data.pages
.flatMap((page) => page.items)
.filter((item) => {
const key = item.repostedBy ? `repost-${item.repostedBy}-${item.event.id}` : item.event.id;
if (seen.has(key)) return false;
seen.add(key);
if (shouldHideFeedEvent(item.event)) return false;
if (muteItems.length > 0 && isEventMuted(item.event, muteItems)) return false;
// Hide replies on original (non-repost) text notes; a repost of a
// reply is still a legitimate top-level surface.
if (item.event.kind === 1 && !item.repostedBy && isReplyEvent(item.event)) {
return false;
}
// Drop anything authored against this community — that's Activity.
if (referencesThisCommunity(item.event.tags)) return false;
if (item.repostEvent && referencesThisCommunity(item.repostEvent.tags)) return false;
return true;
});
// `referencesThisCommunity` and `communityATag` referenced via closure —
// adding `communityATag` to deps is sufficient.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data?.pages, muteItems, communityATag]);
// ── States ────────────────────────────────────────────────────────────────
if (memberPubkeys.length === 0 && !isMembershipLoading) {
return (
<div className="py-12 text-center text-muted-foreground text-sm px-5">
No community members yet nothing to surface here.
</div>
);
}
if ((isLoading || isMembershipLoading) && feedItems.length === 0) {
return (
<div className="divide-y divide-border">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="px-4 py-3">
<div className="flex gap-3">
<Skeleton className="size-11 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-48" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
</div>
</div>
</div>
))}
</div>
);
}
if (feedItems.length === 0) {
return (
<div className="py-12 text-center text-muted-foreground text-sm px-5">
No posts from community members elsewhere yet.
</div>
);
}
return (
<div>
{feedItems.map((item) => (
<NoteCard
key={item.repostedBy ? `repost-${item.repostedBy}-${item.event.id}` : item.event.id}
event={item.event}
repostedBy={item.repostedBy}
/>
))}
{hasNextPage && (
<div ref={sentinelRef} className="flex justify-center py-6">
{isFetchingNextPage && <Loader2 className="size-5 animate-spin text-muted-foreground" />}
</div>
)}
</div>
);
}
+1 -1
View File
@@ -91,7 +91,7 @@ export function CommunityReportDialog({
<DialogContent className="max-w-md max-h-[85dvh] rounded-2xl flex flex-col overflow-hidden">
<DialogTitle>Report post</DialogTitle>
<DialogDescription className="text-sm text-muted-foreground">
Select a reason for reporting this post to the community.
Select a reason for reporting this post to the organization.
</DialogDescription>
<div className="flex-1 overflow-y-auto min-h-0 -mx-6 px-6">
+16 -4
View File
@@ -132,6 +132,10 @@ interface ComposeBoxProps {
hideAvatar?: boolean;
/** If true, suppresses the bottom border. Use when the composer sits directly above a visually distinct section (e.g. tabs with an arc background) that already provides separation. */
hideBorder?: boolean;
/** Extra class names merged onto the outer wrapper. Useful for
* overriding the default `bg-background/85` when the composer is
* rendered inside a card surface. */
className?: string;
/** Controlled preview mode (for modal usage). */
previewMode?: boolean;
/** Callback to notify parent of previewable content changes. */
@@ -152,6 +156,10 @@ interface ComposeBoxProps {
hidePoll?: boolean;
/** Label for the primary submit button. */
submitLabel?: string;
/** Tags added to new top-level kind 1 notes without putting them in content. */
defaultTags?: string[][];
/** If true, the composer starts expanded without taking modal/flex behavior. */
defaultExpanded?: boolean;
}
/** Circular progress ring for character count. */
@@ -202,6 +210,7 @@ export function ComposeBox({
forceExpanded = false,
hideAvatar = false,
hideBorder = false,
className,
previewMode: controlledPreviewMode,
onHasPreviewableContentChange,
initialContent = '',
@@ -209,6 +218,8 @@ export function ComposeBox({
customPublish,
hidePoll = false,
submitLabel = 'Post!',
defaultTags = [],
defaultExpanded = false,
}: ComposeBoxProps) {
const { user, metadata, isLoading: isProfileLoading } = useCurrentUser();
const userProfileUrl = useProfileUrl(user?.pubkey ?? '', metadata);
@@ -243,7 +254,7 @@ export function ComposeBox({
return '';
}
});
const [expanded, setExpanded] = useState(false);
const [expanded, setExpanded] = useState(defaultExpanded);
const [cwEnabled, setCwEnabled] = useState(false);
const [cwText, setCwText] = useState('');
const [pickerOpen, setPickerOpen] = useState(false);
@@ -300,7 +311,7 @@ export function ComposeBox({
setContent('');
setCwEnabled(false);
setCwText('');
setExpanded(false);
setExpanded(defaultExpanded);
setPickerOpen(false);
setTrayOpen(false);
setInternalPreviewMode(false);
@@ -315,7 +326,7 @@ export function ComposeBox({
setDestination('world');
// Clear the auto-saved draft
try { localStorage.removeItem(draftKey); } catch { /* ignore */ }
}, [initialMode, draftKey]);
}, [initialMode, draftKey, defaultExpanded]);
// Use controlled preview mode if provided, otherwise use internal state
const previewMode = controlledPreviewMode !== undefined ? controlledPreviewMode : internalPreviewMode;
@@ -1106,7 +1117,7 @@ export function ComposeBox({
await createEvent({
kind: 1,
content: finalContent,
tags,
tags: [...defaultTags, ...tags],
created_at: Math.floor(Date.now() / 1000),
});
}
@@ -1217,6 +1228,7 @@ export function ComposeBox({
forceExpanded ? "flex-1 min-h-0 rounded-2xl" : "",
pickerOpen ? "pb-0" : "pb-3",
!forceExpanded && !hideBorder && "border-b border-border",
className,
)}>
{/* Preview toggle at top when not controlled and has previewable content */}
{hasPreviewableContent && controlledPreviewMode === undefined && (
+63
View File
@@ -0,0 +1,63 @@
import { cn } from '@/lib/utils';
import { customFlagAsset } from '@/lib/customFlags';
interface CountryFlagProps {
/**
* ISO 3166-1 alpha-2 country code (e.g. `US`, `BR`) or ISO 3166-2
* subdivision code (e.g. `CN-XZ`, `GB-SCT`). Case-insensitive.
*/
code: string;
/** The flag emoji to render when no custom asset is available. */
emoji: string;
/** Accessible label / `alt` for the flag. */
label: string;
/** Optional extra classes applied to the rendering element. */
className?: string;
}
/**
* Render a flag for a country or subdivision. For codes with a bundled
* SVG (currently Tibet) we emit an `<img>` that visually matches the
* surrounding emoji line-height; for everything else we drop the emoji
* straight into a `<span>` so it inherits font color and selection
* behaviour like the rest of the text run.
*
* Callers control sizing via Tailwind classes — pass `text-3xl` to size
* the emoji and the SVG will scale to match (`h-[1em] w-auto`).
*/
export function CountryFlag({ code, emoji, label, className }: CountryFlagProps) {
const customAsset = customFlagAsset(code);
if (customAsset) {
return (
// The wrapper span carries the font-size class so the inner image
// can size itself in `em` units and stay in lockstep with the
// emoji glyphs on neighbouring chips. A thin border + tiny radius
// keeps the SVG reading as a *flag* and not a colored rectangle
// when it's shrunk down inside a small chip.
<span
className={cn('inline-flex items-center leading-none', className)}
role="img"
aria-label={label}
>
<img
src={customAsset}
alt=""
aria-hidden="true"
className="inline-block h-[1em] w-auto rounded-[2px] align-middle ring-1 ring-black/10 dark:ring-white/15 shadow-sm"
loading="lazy"
/>
</span>
);
}
return (
<span
className={cn('leading-none select-none', className)}
role="img"
aria-label={label}
>
{emoji}
</span>
);
}
+212
View File
@@ -0,0 +1,212 @@
import { useEffect, useState } from 'react';
import { ImagePlus, Loader2, X } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { useToast } from '@/hooks/useToast';
import { useUploadFile } from '@/hooks/useUploadFile';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import { cn } from '@/lib/utils';
/**
* Template thumbnail row: each click sets the cover URL to that template's
* URL. The thumbnail strip is optional — pass `templates` to enable it.
*/
export interface CoverImageTemplate {
id: string;
/** Sanitized https URL the picker will publish if this template is chosen. */
url: string;
/** Display name for `title` / `aria-label`. */
name: string;
}
interface CoverImageFieldProps {
/** Current cover URL (controlled). Empty string means "no cover". */
value: string;
onChange: (url: string) => void;
/** Notifies parent forms so they can block submit while Blossom upload runs. */
onUploadingChange?: (uploading: boolean) => void;
/** Optional template gallery shown between the dropzone and the URL input. */
templates?: readonly CoverImageTemplate[];
}
/**
* Unified cover-image affordance shared by CreateActionPage and
* CreateCampaignPage. Includes:
*
* - A dashed dropzone (`<label>`) that accepts both click-to-open and
* native drag-and-drop. Both paths funnel through the same MIME check
* and `useUploadFile` upload.
* - An optional template gallery — clicking a thumbnail just sets the
* controlled `value`, so the URL input and dropzone preview both
* update from a single source of truth.
* - A plain URL `<Input>` so users can paste any https:// image.
*
* The dropzone preview goes through `sanitizeUrl()`, which rejects
* anything other than a well-formed https URL — that's deliberate, since
* the same value is what gets published in the Nostr event's `image` tag.
*/
export function CoverImageField({ value, onChange, onUploadingChange, templates }: CoverImageFieldProps) {
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
const { toast } = useToast();
const [isDragging, setIsDragging] = useState(false);
const sanitized = sanitizeUrl(value);
useEffect(() => {
onUploadingChange?.(isUploading);
}, [isUploading, onUploadingChange]);
/**
* Shared upload path used by both the file-input change handler and
* the drag-and-drop handler. Validates the MIME type up front so a
* stray dragged-in PDF or video doesn't end up posted to Blossom.
*/
const uploadCoverFile = async (file: File) => {
if (!/^image\/(png|jpeg|webp)$/.test(file.type)) {
toast({
title: 'Unsupported file type',
description: 'Cover image must be PNG, JPG, or WEBP.',
variant: 'destructive',
});
return;
}
try {
const [[, url]] = await uploadFile(file);
onChange(url);
} catch (error) {
toast({
title: 'Upload failed',
description: error instanceof Error ? error.message : String(error),
variant: 'destructive',
});
}
};
const handleDragOver = (e: React.DragEvent<HTMLLabelElement>) => {
// Without preventDefault, the browser navigates to the dropped file
// instead of letting our onDrop handler claim it.
e.preventDefault();
if (isUploading) return;
e.dataTransfer.dropEffect = 'copy';
if (!isDragging) setIsDragging(true);
};
const handleDragLeave = (e: React.DragEvent<HTMLLabelElement>) => {
// Only clear the highlight when the cursor actually leaves the label.
// Dragging over a child element fires dragleave on the parent in some
// browsers, so we re-check relatedTarget.
if (e.currentTarget.contains(e.relatedTarget as Node | null)) return;
setIsDragging(false);
};
const handleDrop = async (e: React.DragEvent<HTMLLabelElement>) => {
e.preventDefault();
setIsDragging(false);
if (isUploading) return;
const file = e.dataTransfer.files?.[0];
if (!file) return;
await uploadCoverFile(file);
};
return (
<>
<label
onDragOver={handleDragOver}
onDragEnter={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={cn(
'relative block h-40 w-full cursor-pointer overflow-hidden rounded-xl border-2 border-dashed border-border bg-gradient-to-br from-muted/40 via-background to-muted/20 motion-safe:transition-colors hover:border-primary sm:h-48',
isDragging && 'border-primary bg-primary/5',
isUploading && 'opacity-70 pointer-events-none',
)}
>
{sanitized ? (
<>
<img
src={sanitized}
alt=""
className="absolute inset-0 size-full object-cover"
/>
<button
type="button"
onClick={(e) => {
e.preventDefault();
onChange('');
}}
className="absolute top-3 right-3 rounded-full bg-background/85 backdrop-blur p-1.5 hover:bg-background motion-safe:transition-colors"
aria-label="Remove image"
>
<X className="size-4" />
</button>
</>
) : (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2 text-muted-foreground">
{isUploading ? (
<>
<Loader2 className="size-8 animate-spin" />
<span className="text-sm">Uploading</span>
</>
) : (
<>
<ImagePlus className="size-8" />
<span className="text-sm">Click or drag an image here</span>
<span className="text-xs">PNG, JPG, or WEBP</span>
</>
)}
</div>
)}
<input
type="file"
accept="image/png,image/jpeg,image/webp"
className="sr-only"
disabled={isUploading}
onChange={(e) => {
const file = e.target.files?.[0];
e.currentTarget.value = '';
if (file) void uploadCoverFile(file);
}}
/>
</label>
{templates && templates.length > 0 && (
<div className="relative w-full overflow-hidden">
<div className="flex gap-2 overflow-x-auto pb-2 -mx-1 px-1">
{templates.map((template) => {
const isActive = value === template.url;
return (
<button
key={template.id}
type="button"
onClick={() => onChange(template.url)}
className={cn(
'relative h-20 w-28 flex-shrink-0 rounded-md overflow-hidden border-2 transition-all',
isActive
? 'border-primary ring-2 ring-primary/50'
: 'border-border hover:border-primary/50',
)}
title={template.name}
aria-label={`Use ${template.name} cover`}
>
<img
src={template.url}
alt={template.name}
className="w-full h-full object-cover"
/>
</button>
);
})}
</div>
</div>
)}
<Input
type="url"
inputMode="url"
placeholder="Or paste an https:// image URL"
value={value}
onChange={(e) => onChange(e.target.value)}
/>
</>
);
}
+366
View File
@@ -0,0 +1,366 @@
import { useMemo, useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { Check, ChevronRight, Clock, Loader2, Megaphone, Plus, Upload } from 'lucide-react';
import { TimezoneSwitcher } from '@/components/TimezoneSwitcher';
import { Button } from '@/components/ui/button';
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Drawer, DrawerContent, DrawerDescription, DrawerHeader, DrawerTitle } from '@/components/ui/drawer';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Textarea } from '@/components/ui/textarea';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useBtcPrice } from '@/hooks/useBtcPrice';
import { useIsMobile } from '@/hooks/useIsMobile';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useToast } from '@/hooks/useToast';
import { useUploadFile } from '@/hooks/useUploadFile';
import { createCountryIdentifier } from '@/lib/countryIdentifiers';
import { countryCodeToFlag, getAllCountries, getGeoDisplayName } from '@/lib/countries';
import { DEFAULT_COVER_IMAGE } from '@/lib/defaultActionCovers';
import { createOrganizationAssociationTags } from '@/lib/organizationContext';
import { unixSecondsInTimezone } from '@/lib/timezone';
import { usdToSats } from '@/lib/bitcoin';
import { cn } from '@/lib/utils';
interface CreateActionDialogProps {
countryCode?: string;
communityATag?: string;
open: boolean;
onOpenChange: (open: boolean) => void;
}
interface CreateActionFormState {
title: string;
description: string;
tagInput: string;
pledgeUsd: string;
deadline: string;
time: string;
coverImage: string;
selectedCountry: string;
timezone: string;
}
function normalizePledgeTag(value: string): string {
return value.trim().replace(/^#+/, '').toLowerCase().replace(/\s+/g, '-');
}
function parsePledgeTagInput(value: string): string[] {
const seen = new Set<string>();
const tags: string[] = [];
for (const part of value.split(',')) {
const tag = normalizePledgeTag(part);
if (!tag || seen.has(tag)) continue;
seen.add(tag);
tags.push(tag);
}
return tags;
}
function CreateActionForm({
formData,
setFormData,
isSubmitting,
handleSubmit,
onCancel,
pageCountryCode,
}: {
formData: CreateActionFormState;
setFormData: (data: CreateActionFormState) => void;
isSubmitting: boolean;
handleSubmit: () => void;
onCancel: () => void;
pageCountryCode?: string;
}) {
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
const { data: btcPrice } = useBtcPrice();
const allCountries = useMemo(() => getAllCountries(), []);
const [countryPickerOpen, setCountryPickerOpen] = useState(false);
const countryOptions = useMemo(() => {
const options: Array<{ value: string; label: string; flag: string }> = [
{ value: 'none', label: 'No country', flag: '🌍' },
];
if (pageCountryCode) {
options.push({
value: pageCountryCode,
label: getGeoDisplayName(pageCountryCode),
flag: countryCodeToFlag(pageCountryCode),
});
}
allCountries.forEach((country) => {
if (country.code !== pageCountryCode) {
options.push({ value: country.code, label: country.name, flag: countryCodeToFlag(country.code) });
}
});
return options;
}, [pageCountryCode, allCountries]);
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
try {
const [[, url]] = await uploadFile(file);
setFormData({ ...formData, coverImage: url });
} catch (error) {
console.error('Failed to upload cover image:', error);
}
};
return (
<>
<div className="space-y-4 py-2 px-4 max-w-full overflow-hidden">
<div className="space-y-2">
<Label htmlFor="country">Country (optional)</Label>
<Popover open={countryPickerOpen} onOpenChange={setCountryPickerOpen}>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" aria-expanded={countryPickerOpen} className="w-full justify-between">
{formData.selectedCountry ? (
<span className="flex items-center gap-2">
<span>{countryCodeToFlag(formData.selectedCountry)}</span>
<span>{getGeoDisplayName(formData.selectedCountry)}</span>
</span>
) : (
<span>No country</span>
)}
<ChevronRight className="ml-2 h-4 w-4 shrink-0 opacity-50 rotate-90" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[280px] p-0" align="start" sideOffset={4}>
<Command>
<CommandInput placeholder="Search..." />
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup>
{countryOptions.map((option) => (
<CommandItem
key={option.value}
value={`${option.label} ${option.value}`}
onSelect={() => {
setFormData({ ...formData, selectedCountry: option.value === 'none' ? '' : option.value });
setCountryPickerOpen(false);
}}
className="gap-2"
>
<span>{option.flag}</span>
<span className="flex-1">{option.label}</span>
<Check className={cn('h-4 w-4', (formData.selectedCountry || 'none') === option.value ? 'opacity-100' : 'opacity-0')} />
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<div className="space-y-2">
<Label>Cover image</Label>
<div className="relative w-full h-32 rounded-lg overflow-hidden border border-border">
<img src={formData.coverImage || DEFAULT_COVER_IMAGE} alt="Cover preview" className="w-full h-full object-cover" />
</div>
<div className="flex items-center gap-2">
<Label htmlFor="cover-upload" className="flex-1 cursor-pointer flex items-center justify-center gap-2 px-4 py-2 border border-border rounded-lg hover:bg-primary/10 transition-colors">
{isUploading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
<span className="text-sm">Upload custom</span>
</Label>
<input id="cover-upload" type="file" accept="image/*" className="hidden" onChange={handleFileUpload} disabled={isUploading} />
</div>
</div>
<div className="space-y-2">
<Label htmlFor="title">Title</Label>
<Input id="title" placeholder="What needs to happen?" value={formData.title} onChange={(e) => setFormData({ ...formData, title: e.target.value })} />
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
placeholder="Explain the action, evidence, or outcome you want to inspire and what submissions should include..."
className="min-h-[80px]"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="pledge-tags">Tags</Label>
<Input id="pledge-tags" placeholder="beach-cleanup, legal-defense" value={formData.tagInput} onChange={(e) => setFormData({ ...formData, tagInput: e.target.value })} />
</div>
<div className="space-y-2">
<Label htmlFor="pledgeUsd">Pledge</Label>
<div className="relative">
<span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-sm text-muted-foreground">
$
</span>
<Input
id="pledgeUsd"
type="text"
inputMode="decimal"
placeholder="100"
value={formData.pledgeUsd}
onChange={(e) => setFormData({ ...formData, pledgeUsd: e.target.value })}
className="pl-7 pr-14"
/>
<span className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-xs font-medium text-muted-foreground">
USD
</span>
</div>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="deadline">Deadline (optional)</Label>
<Input id="deadline" type="date" className="w-full min-w-0" value={formData.deadline} onChange={(e) => setFormData({ ...formData, deadline: e.target.value })} />
{formData.deadline && <Input id="time" type="time" className="w-full min-w-0" value={formData.time} onChange={(e) => setFormData({ ...formData, time: e.target.value })} />}
</div>
{formData.deadline && (
<div className="space-y-2 bg-muted/30 p-3 rounded-lg border border-border/50 animate-in slide-in-from-top-2 duration-200">
<Label className="text-sm font-medium flex items-center gap-2"><Clock className="h-4 w-4" /> Timezone</Label>
<TimezoneSwitcher value={formData.timezone} onChange={(timezone) => setFormData({ ...formData, timezone })} />
<p className="text-xs text-muted-foreground">Deadline time will be interpreted in this timezone.</p>
</div>
)}
</div>
<div className="flex flex-col gap-2 p-4 pt-2">
<Button onClick={handleSubmit} disabled={!formData.title || !formData.description || !formData.pledgeUsd || usdToSats(Number(formData.pledgeUsd.replace(/[, $]/g, '')), btcPrice) <= 0 || isSubmitting} className="gap-2 w-full">
{isSubmitting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plus className="h-4 w-4" />}
Create pledge
</Button>
<Button variant="outline" onClick={onCancel} className="w-full">Cancel</Button>
</div>
</>
);
}
export function CreateActionDialog({ countryCode, communityATag, open, onOpenChange }: CreateActionDialogProps) {
const [isSubmitting, setIsSubmitting] = useState(false);
const { user } = useCurrentUser();
const { mutateAsync: createEvent } = useNostrPublish();
const { data: btcPrice } = useBtcPrice();
const queryClient = useQueryClient();
const isMobile = useIsMobile();
const { toast } = useToast();
const browserTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
const [formData, setFormData] = useState<CreateActionFormState>({
title: '',
description: '',
tagInput: '',
pledgeUsd: '',
deadline: '',
time: '',
coverImage: DEFAULT_COVER_IMAGE,
selectedCountry: countryCode || '',
timezone: browserTimezone,
});
const handleSubmit = async () => {
if (!user) return;
setIsSubmitting(true);
try {
const now = Date.now();
const slug = formData.title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
const dTag = `${slug || 'pledge'}-${now}`;
const pledgeSats = usdToSats(Number(formData.pledgeUsd.replace(/[, $]/g, '')), btcPrice);
if (pledgeSats <= 0) throw new Error('Waiting for BTC/USD price to calculate the pledge amount.');
const pledgeTags = parsePledgeTagInput(formData.tagInput);
const tags: string[][] = [
['d', dTag],
['title', formData.title],
['bounty', String(pledgeSats)],
['t', 'agora-action'],
['alt', `Agora pledge: ${formData.title}`],
];
for (const tag of pledgeTags) tags.push(['t', tag]);
if (formData.selectedCountry) tags.push(['i', createCountryIdentifier(formData.selectedCountry.toUpperCase())]);
if (communityATag) {
tags.push(...createOrganizationAssociationTags(communityATag));
}
if (formData.coverImage) tags.push(['image', formData.coverImage]);
if (formData.deadline) {
const [year, month, day] = formData.deadline.split('-').map(Number);
const [hours, minutes] = formData.time ? formData.time.split(':').map(Number) : [23, 59];
tags.push(['deadline', String(unixSecondsInTimezone(year, month, day, hours, minutes, formData.timezone))]);
}
await createEvent({ kind: 36639, content: formData.description, tags });
await queryClient.invalidateQueries({ queryKey: ['agora-actions'] });
await queryClient.refetchQueries({ queryKey: ['agora-actions'] });
if (communityATag) {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['community-actions', communityATag] }),
queryClient.invalidateQueries({
predicate: (q) => {
const [root, aTagsKey] = q.queryKey;
return root === 'community-activity-feed'
&& typeof aTagsKey === 'string'
&& aTagsKey.split(',').includes(communityATag);
},
}),
]);
}
setFormData({
title: '', description: '', tagInput: '', pledgeUsd: '',
deadline: '', time: '',
coverImage: DEFAULT_COVER_IMAGE,
selectedCountry: countryCode || '',
timezone: browserTimezone,
});
onOpenChange(false);
toast({ title: 'Pledge created' });
} catch (error) {
console.error('Failed to create pledge:', error);
toast({ title: 'Failed to create pledge', variant: 'destructive' });
} finally {
setIsSubmitting(false);
}
};
if (!user) return null;
const description = communityATag
? 'New organization pledge. You can optionally choose a country below.'
: countryCode
? `New pledge for ${getGeoDisplayName(countryCode)}.`
: 'New pledge. You can optionally choose a country below.';
if (isMobile) {
return (
<Drawer open={open} onOpenChange={onOpenChange}>
<DrawerContent className="h-[85dvh] max-h-[85dvh]">
<DrawerHeader className="text-left">
<DrawerTitle className="flex items-center gap-2"><Megaphone className="h-5 w-5 text-primary" /> Create pledge</DrawerTitle>
<DrawerDescription>{description}</DrawerDescription>
</DrawerHeader>
<div className="overflow-y-auto flex-1 pb-safe">
<CreateActionForm formData={formData} setFormData={setFormData} isSubmitting={isSubmitting} handleSubmit={handleSubmit} onCancel={() => onOpenChange(false)} pageCountryCode={countryCode} />
</div>
</DrawerContent>
</Drawer>
);
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md sm:max-w-lg md:max-w-2xl max-h-[85vh] w-[calc(100vw-2rem)] sm:w-full overflow-hidden flex flex-col p-0">
<DialogHeader className="px-6 pt-6 pb-4 flex-shrink-0">
<DialogTitle className="flex items-center gap-2"><Megaphone className="h-5 w-5 text-primary" /> Create pledge</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<div className="overflow-y-auto overflow-x-hidden flex-1 min-h-0">
<CreateActionForm formData={formData} setFormData={setFormData} isSubmitting={isSubmitting} handleSubmit={handleSubmit} onCancel={() => onOpenChange(false)} pageCountryCode={countryCode} />
</div>
</DialogContent>
</Dialog>
);
}
-331
View File
@@ -1,331 +0,0 @@
import { useState, useCallback, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { Users, Loader2 } from 'lucide-react';
import { useNostr } from '@nostrify/react';
import { useQueryClient } from '@tanstack/react-query';
import { nip19 } from 'nostr-tools';
import type { NostrEvent } from '@nostrify/nostrify';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { ScrollArea } from '@/components/ui/scroll-area';
import { ImageUploadField } from '@/components/ImageUploadField';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useToast } from '@/hooks/useToast';
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
import { BADGE_DEFINITION_KIND, COMMUNITY_DEFINITION_KIND, type ParsedCommunity } from '@/lib/communityUtils';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
// ── Helpers ───────────────────────────────────────────────────────────────────
/** Convert text into a URL-safe slug for the d-tag identifier. */
function slugify(text: string): string {
return text
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, '')
.replace(/[\s_]+/g, '-')
.replace(/^-+|-+$/g, '');
}
// ── Types ─────────────────────────────────────────────────────────────────────
interface CreateCommunityDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** Existing community event when editing. Omit to create a new community. */
communityEvent?: NostrEvent;
/** Parsed existing community data when editing. */
community?: ParsedCommunity;
}
// ── Component ─────────────────────────────────────────────────────────────────
export function CreateCommunityDialog({ open, onOpenChange, communityEvent, community }: CreateCommunityDialogProps) {
const { user } = useCurrentUser();
const { nostr } = useNostr();
const { toast } = useToast();
const navigate = useNavigate();
const queryClient = useQueryClient();
const isEditing = !!communityEvent && !!community;
// Form state
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [imageUrl, setImageUrl] = useState('');
const [isPublishing, setIsPublishing] = useState(false);
const [isImageUploading, setIsImageUploading] = useState(false);
// Mutations
const { mutateAsync: publishEvent } = useNostrPublish();
// Derived
const effectiveSlug = isEditing && community ? community.dTag : slugify(name);
const populateFromCommunity = useCallback(() => {
setName(community?.name ?? '');
setDescription(community?.description ?? '');
setImageUrl(community?.image ?? '');
setIsPublishing(false);
setIsImageUploading(false);
}, [community]);
const resetForm = useCallback(() => {
if (isEditing) {
populateFromCommunity();
} else {
setName('');
setDescription('');
setImageUrl('');
setIsPublishing(false);
setIsImageUploading(false);
}
}, [isEditing, populateFromCommunity]);
useEffect(() => {
if (open && isEditing) {
populateFromCommunity();
}
}, [open, isEditing, populateFromCommunity]);
const handleOpenChange = useCallback((nextOpen: boolean) => {
if (!nextOpen) resetForm();
onOpenChange(nextOpen);
}, [onOpenChange, resetForm]);
const buildUpdatedCommunityTags = useCallback((baseTags: string[][]): string[][] => {
const tags = baseTags.filter(([name]) => !['d', 'name', 'description', 'image', 'alt'].includes(name));
const nextTags: string[][] = [
['d', effectiveSlug],
['name', name.trim()],
];
if (description.trim()) {
nextTags.push(['description', description.trim()]);
}
const sanitizedImage = sanitizeUrl(imageUrl.trim());
if (sanitizedImage) {
nextTags.push(['image', sanitizedImage]);
}
nextTags.push(...tags);
nextTags.push(['alt', `Community: ${name.trim()}`]);
return nextTags;
}, [description, effectiveSlug, imageUrl, name]);
// ── Publish ───────────────────────────────────────────────────────────────
const handleCreate = useCallback(async () => {
if (!user || !name.trim() || !effectiveSlug) return;
if (isImageUploading) {
toast({ title: 'Image is still uploading', description: 'Please wait for the upload to finish.' });
return;
}
if (imageUrl.trim() && !sanitizeUrl(imageUrl.trim())) {
toast({ title: 'Image URL must be a valid https URL', variant: 'destructive' });
return;
}
setIsPublishing(true);
try {
if (isEditing && communityEvent && community) {
const prev = await fetchFreshEvent(nostr, {
kinds: [COMMUNITY_DEFINITION_KIND],
authors: [communityEvent.pubkey],
'#d': [community.dTag],
});
const updatedEvent = await publishEvent({
kind: COMMUNITY_DEFINITION_KIND,
content: prev?.content ?? communityEvent.content,
tags: buildUpdatedCommunityTags(prev?.tags ?? communityEvent.tags),
prev: prev ?? undefined,
} as Omit<NostrEvent, 'id' | 'pubkey' | 'sig'> & { prev?: NostrEvent });
queryClient.setQueryData(
['addr-event', COMMUNITY_DEFINITION_KIND, communityEvent.pubkey, community.dTag],
updatedEvent,
);
queryClient.invalidateQueries({ queryKey: ['community-activity-feed'], exact: false });
queryClient.invalidateQueries({ queryKey: ['my-communities'], exact: false });
toast({ title: 'Community updated!' });
handleOpenChange(false);
return;
}
// Check for d-tag collision (same author, same kind, same d-tag)
const existing = await nostr.query([{
kinds: [COMMUNITY_DEFINITION_KIND],
authors: [user.pubkey],
'#d': [effectiveSlug],
limit: 1,
}]);
if (existing.length > 0) {
toast({
title: 'Name already in use',
description: 'You already have a community with this name. Please choose a different name.',
variant: 'destructive',
});
setIsPublishing(false);
return;
}
const badgeDTag = `${effectiveSlug}-member`;
const existingBadge = await nostr.query([{
kinds: [BADGE_DEFINITION_KIND],
authors: [user.pubkey],
'#d': [badgeDTag],
limit: 1,
}]);
if (existingBadge.length > 0) {
toast({
title: 'Member badge ID already in use',
description: 'Choose a different community name so the member badge can be created safely.',
variant: 'destructive',
});
setIsPublishing(false);
return;
}
const badgeEvent = await publishEvent({
kind: BADGE_DEFINITION_KIND,
content: '',
tags: [
['d', badgeDTag],
['name', 'Member'],
['description', `Member of ${name.trim()}`],
['alt', `Badge definition: Member of ${name.trim()}`],
],
} as Omit<NostrEvent, 'id' | 'pubkey' | 'sig'>);
// Founder as moderator (p tag) plus one flat member badge reference.
const communityTags = buildUpdatedCommunityTags([
['a', `${BADGE_DEFINITION_KIND}:${badgeEvent.pubkey}:${badgeDTag}`, '', 'member'],
['p', user.pubkey, '', 'moderator'],
]);
// Publish community definition (kind 34550)
const createdEvent = await publishEvent({
kind: COMMUNITY_DEFINITION_KIND,
content: '',
tags: communityTags,
} as Omit<NostrEvent, 'id' | 'pubkey' | 'sig'>);
// Navigate to the new community
const naddr = nip19.naddrEncode({
kind: COMMUNITY_DEFINITION_KIND,
pubkey: createdEvent.pubkey,
identifier: effectiveSlug,
});
toast({ title: 'Community created!' });
handleOpenChange(false);
navigate(`/${naddr}`);
} catch (err) {
toast({
title: isEditing ? 'Failed to update community' : 'Failed to create community',
description: err instanceof Error ? err.message : 'Please try again.',
variant: 'destructive',
});
} finally {
setIsPublishing(false);
}
}, [
user, name, effectiveSlug, isEditing, communityEvent, community, nostr, isImageUploading, imageUrl,
publishEvent, buildUpdatedCommunityTags, queryClient, toast, handleOpenChange, navigate,
]);
if (!user) return null;
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-lg gap-0 p-0 overflow-hidden">
<DialogHeader className="px-5 pt-5 pb-3">
<DialogTitle className="flex items-center gap-2">
<Users className="size-5 text-primary" />
{isEditing ? 'Edit Community' : 'Create a Community'}
</DialogTitle>
<DialogDescription>
{isEditing
? 'Update the name, image, and description. Moderators are preserved.'
: "Start a new community on Nostr. You'll be the founder."}
</DialogDescription>
</DialogHeader>
<ScrollArea className="max-h-[calc(100vh-9rem)] sm:max-h-none">
<div className="px-5 pb-5 space-y-4">
{/* Community name */}
<div className="space-y-1.5">
<Label htmlFor="community-name">Community Name *</Label>
<Input
id="community-name"
placeholder="e.g. The Arbiter's Guard"
value={name}
onChange={(e) => setName(e.target.value)}
maxLength={100}
/>
{name.trim() && (
<p className="text-xs text-muted-foreground font-mono">
ID: {effectiveSlug || '...'}{isEditing ? ' (unchanged)' : ''}
</p>
)}
</div>
<ImageUploadField
id="community-image"
label={<>Community Image <span className="text-muted-foreground font-normal">(recommended)</span></>}
value={imageUrl}
onChange={setImageUrl}
onUploadingChange={setIsImageUploading}
previewAlt="Community image preview"
dropAreaClassName="min-h-32"
/>
{/* Description */}
<div className="space-y-1.5">
<Label htmlFor="community-description">
Description
<span className="text-muted-foreground font-normal ml-1">(recommended)</span>
</Label>
<Textarea
id="community-description"
placeholder="What is this community about?"
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
/>
</div>
{/* Submit button */}
<Button
onClick={handleCreate}
disabled={!name.trim() || !effectiveSlug || isPublishing || isImageUploading}
className="w-full gap-2"
>
{isPublishing ? (
<><Loader2 className="size-4 animate-spin" /> {isEditing ? 'Saving...' : 'Creating...'}</>
) : (
<><Users className="size-4" /> {isEditing ? 'Save Changes' : 'Create Community'}</>
)}
</Button>
</div>
</ScrollArea>
</DialogContent>
</Dialog>
);
}
+4 -11
View File
@@ -23,6 +23,7 @@ import { useNostrPublish } from '@/hooks/useNostrPublish';
import { usePublishRSVP } from '@/hooks/usePublishRSVP';
import { useToast } from '@/hooks/useToast';
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
import { createOrganizationAssociationTags } from '@/lib/organizationContext';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
interface CreateCommunityEventDialogProps {
@@ -80,11 +81,6 @@ function toLocalTimestamp(date: string, time: string): number {
return Math.floor(new Date(`${date}T${time}:00`).getTime() / 1000);
}
function parseCommunityAuthor(communityATag: string): string | undefined {
const [, pubkey] = communityATag.split(':');
return pubkey || undefined;
}
export function CreateCommunityEventDialog({ communityATag, open, onOpenChange, event }: CreateCommunityEventDialogProps) {
const { user } = useCurrentUser();
const { nostr } = useNostr();
@@ -243,16 +239,12 @@ export function CreateCommunityEventDialog({ communityATag, open, onOpenChange,
const tags: string[][] = [
['d', dTag],
['title', trimmedTitle],
['alt', `${isCommunityEvent ? 'Community event' : 'Calendar event'}: ${trimmedTitle}`],
['alt', `${isCommunityEvent ? 'Organization event' : 'Calendar event'}: ${trimmedTitle}`],
...preservedTags,
];
if (effectiveCommunityATag) {
const communityAuthor = parseCommunityAuthor(effectiveCommunityATag);
tags.push(['A', effectiveCommunityATag], ['K', '34550']);
if (communityAuthor) {
tags.push(['P', communityAuthor]);
}
tags.push(...createOrganizationAssociationTags(effectiveCommunityATag));
}
if (description.trim()) {
@@ -339,6 +331,7 @@ export function CreateCommunityEventDialog({ communityATag, open, onOpenChange,
queryClient.invalidateQueries({ queryKey: ['addr-event', kind, publishedEvent.pubkey, dTag] }),
...(effectiveCommunityATag ? [
queryClient.invalidateQueries({ queryKey: ['community-events', effectiveCommunityATag] }),
queryClient.invalidateQueries({ queryKey: ['organization-activity', effectiveCommunityATag] }),
queryClient.invalidateQueries({
predicate: (q) => {
const [root, aTagsKey] = q.queryKey;
+1 -1
View File
@@ -165,7 +165,7 @@ export function CreateGoalDialog({ communityATag, children, open: controlledOpen
<Label htmlFor="goal-title">Title</Label>
<Input
id="goal-title"
placeholder="e.g. Community meetup expenses"
placeholder="e.g. Organization meetup expenses"
value={title}
onChange={(e) => setTitle(e.target.value)}
required
-123
View File
@@ -1,123 +0,0 @@
import { type ReactNode, useCallback, useMemo } from "react";
import { DMProvider } from "@samthomson/nostr-messaging/core";
import { DEFAULT_NEW_MESSAGE_SOUNDS } from "@samthomson/nostr-messaging/core";
import type { NostrEvent } from "@nostrify/nostrify";
import { useNostr } from "@nostrify/react";
import { toast } from "@/hooks/useToast";
import { useCurrentUser } from "@/hooks/useCurrentUser";
import { useAppContext } from "@/hooks/useAppContext";
import { useNostrPublish } from "@/hooks/useNostrPublish";
import { useUploadFile } from "@/hooks/useUploadFile";
import { useProfileSupplementary } from "@/hooks/useProfileData";
import { useIsMobile } from "@/hooks/useIsMobile";
import { getDisplayName } from "@/lib/getDisplayName";
import { getEffectiveRelays } from "@/lib/appRelays";
import { useAuthors } from "@/hooks/useAuthors";
interface DMProviderWrapperProps {
children: ReactNode;
}
export function DMProviderWrapper({ children }: DMProviderWrapperProps) {
const { nostr } = useNostr();
const { user } = useCurrentUser();
const { config } = useAppContext();
const { mutateAsync: publishEvent } = useNostrPublish();
const { mutateAsync: uploadFileMutation } = useUploadFile();
const isMobile = useIsMobile();
const { data: profileData } = useProfileSupplementary(user?.pubkey);
const follows = useMemo(() => profileData?.following ?? [], [profileData]);
const handlePublishEvent = useCallback(async (
event: Omit<NostrEvent, "id" | "pubkey" | "sig">,
): Promise<void> => {
await publishEvent(event);
}, [publishEvent]);
const handleUploadFile = useCallback(async (file: File): Promise<string> => {
const tags = await uploadFileMutation(file);
return tags[0][1] ?? "";
}, [uploadFileMutation]);
const handleGetDisplayName = useCallback((
pubkey: string,
metadata?: Parameters<typeof getDisplayName>[0],
) => {
return getDisplayName(metadata, pubkey);
}, []);
const handleNotify = useCallback((options: { title?: string; description?: string; variant?: "default" | "destructive" }) => {
toast(options);
}, []);
const messaging = useMemo(() => config.messaging ?? {}, [config.messaging]);
const discoveryRelays = useMemo(() => {
if (messaging.discoveryRelays?.length) {
return messaging.discoveryRelays;
}
return getEffectiveRelays(config.relayMetadata, config.useAppRelays, config.useUserRelays).relays
.filter((relay) => relay.read)
.map((relay) => relay.url);
}, [messaging.discoveryRelays, config.relayMetadata, config.useAppRelays, config.useUserRelays]);
const relayMode = messaging.relayMode ?? "hybrid";
const protocolMode = messaging.protocolMode;
const messagingEnabled = messaging.enabled ?? true;
const renderInlineMedia = messaging.renderInlineMedia ?? true;
const soundEnabled = messaging.soundEnabled ?? false;
const soundId = messaging.soundId ?? DEFAULT_NEW_MESSAGE_SOUNDS[0]?.id ?? "";
const devMode = messaging.devMode ?? false;
const messagingConfig = useMemo(() => ({
enabled: messagingEnabled,
discoveryRelays,
relayMode,
protocolMode,
renderInlineMedia,
devMode,
appName: config.appName,
appDescription: `Direct messages on ${config.appName}`,
soundPref: {
options: DEFAULT_NEW_MESSAGE_SOUNDS,
value: { enabled: soundEnabled, soundId },
onChange: () => {},
},
}), [
messagingEnabled,
discoveryRelays,
relayMode,
protocolMode,
renderInlineMedia,
devMode,
config.appName,
soundEnabled,
soundId,
]);
const uiConfig = useMemo(() => ({
showShorts: false,
showSearch: true,
showHeader: false,
isMobile,
}), [isMobile]);
return (
<DMProvider
nostr={nostr}
user={user ?? null}
messagingConfig={messagingConfig}
onNotify={handleNotify}
getDisplayName={handleGetDisplayName}
fetchAuthorsBatch={useAuthors}
publishEvent={handlePublishEvent}
uploadFile={handleUploadFile}
follows={follows}
ui={uiConfig}
>
{children}
</DMProvider>
);
}
File diff suppressed because it is too large Load Diff
+5
View File
@@ -7,6 +7,7 @@ import { Award, Image, MessageSquareOff } from 'lucide-react';
import type { NostrEvent } from '@nostrify/nostrify';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { ActionContent } from '@/components/ActionContent';
import { Skeleton } from '@/components/ui/skeleton';
import { EmojifiedText } from '@/components/CustomEmoji';
import { EmbeddedCardShell } from '@/components/EmbeddedCardShell';
@@ -88,6 +89,10 @@ export function EmbeddedNaddr({ addr, className, disableHoverCards }: EmbeddedNa
return <EmbeddedProfileBadgesCard event={event} className={className} />;
}
if (event.kind === 36639) {
return <ActionContent event={event} />;
}
return <EmbeddedNaddrCard event={event} className={className} disableHoverCards={disableHoverCards} />;
}
+59 -31
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Link } from 'react-router-dom';
import { ArrowLeft, BookOpen, Coins, ExternalLink, FileText, Globe, Landmark, Languages, MapPin, MessageCircle, Package, Pause, Play, Repeat2, Share2, User, UserCheck, UserMinus, UserPlus, Users, Zap } from 'lucide-react';
import { ArrowLeft, BookOpen, Coins, ExternalLink, FileText, Globe, Landmark, Languages, MapPin, Megaphone, MessageCircle, Package, Pause, Play, Repeat2, Share2, User, UserCheck, UserMinus, UserPlus, Users } from 'lucide-react';
import { nip19 } from 'nostr-tools';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { Skeleton } from '@/components/ui/skeleton';
@@ -26,6 +26,8 @@ import { useWeather } from '@/hooks/useWeather';
import { useToast } from '@/hooks/useToast';
import { genUserName } from '@/lib/genUserName';
import { getCountryInfo, getWikipediaTitle } from '@/lib/countries';
import { CountryFlag } from '@/components/CountryFlag';
import { customFlagAsset, hasCustomFlag } from '@/lib/customFlags';
import { useWikipediaSummary } from '@/hooks/useWikipediaSummary';
import { useCountryFacts, type CountryFacts } from '@/hooks/useCountryFacts';
import { useCommonsAudio } from '@/hooks/useCommonsAudio';
@@ -231,30 +233,39 @@ function BlueskyPostHeader({ author, rkey, url }: { author: string; rkey: string
)}
{/* Action buttons */}
<div className="flex items-center gap-5 mt-3 -ml-2">
<div className="flex flex-wrap items-center gap-1 sm:gap-2 mt-3">
<button
type="button"
onClick={handleComment}
className="inline-flex items-center gap-1.5 p-2 rounded-full text-muted-foreground hover:text-sky-500 hover:bg-sky-500/10 transition-colors"
className="inline-flex items-center gap-2 h-9 px-3 rounded-full text-sm font-medium text-muted-foreground hover:text-sky-500 hover:bg-sky-500/10 transition-colors"
title="Comment"
>
<MessageCircle className="size-[18px]" />
{post.replyCount > 0 && <span className="text-sm tabular-nums">{formatCount(post.replyCount)}</span>}
{post.replyCount > 0 ? (
<span className="tabular-nums">{formatCount(post.replyCount)}</span>
) : (
<span className="hidden sm:inline">Comment</span>
)}
</button>
<button
type="button"
onClick={handleRepost}
className="inline-flex items-center gap-1.5 p-2 rounded-full text-muted-foreground hover:text-green-500 hover:bg-green-500/10 transition-colors"
className="inline-flex items-center gap-2 h-9 px-3 rounded-full text-sm font-medium text-muted-foreground hover:text-green-500 hover:bg-green-500/10 transition-colors"
title="Share to feed"
>
<Repeat2 className="size-[18px]" />
{post.repostCount > 0 && <span className="text-sm tabular-nums">{formatCount(post.repostCount)}</span>}
{post.repostCount > 0 ? (
<span className="tabular-nums">{formatCount(post.repostCount)}</span>
) : (
<span className="hidden sm:inline">Repost</span>
)}
</button>
<ExternalReactionButton content={externalContent} iconSize="size-[18px]" count={post.likeCount} />
<ExternalReactionButton content={externalContent} count={post.likeCount} variant="chip" />
<div className="flex-1" />
<button
type="button"
onClick={handleShare}
className="inline-flex items-center p-2 rounded-full text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors"
className="inline-flex items-center justify-center h-9 w-9 rounded-full text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors"
title="Share link"
>
<Share2 className="size-[18px]" />
@@ -899,15 +910,20 @@ export function CountryContentHeader({ code }: { code: string }) {
);
}
const heroImage = wiki?.originalImage?.source ?? wiki?.thumbnail?.source ?? null;
// For codes with a bundled flag asset (Tibet's Snow Lion), drive the
// hero banner from that SVG instead of Wikipedia's lead image. The
// Wikipedia article for `Tibet (autonomous region)` typically returns a
// map or administrative photo, which contradicts the editorial choice
// to surface Tibet as a country in its own right.
const heroImage = customFlagAsset(code) ?? wiki?.originalImage?.source ?? wiki?.thumbnail?.source ?? null;
const isDay = weather?.isDay ?? true;
// Sky-tint gradient layered above the hero photo. Warm amber/rose during
// local daytime, deep indigo/violet at night. Same gradient shape, only
// the colour palette flips — preserves the cinematic curve while the mood
// follows the destination.
const skyOverlay = isDay
? 'bg-[linear-gradient(to_bottom,rgba(254,202,87,0.18)_0%,rgba(255,107,107,0.12)_30%,rgba(0,0,0,0.65)_70%,hsl(var(--background))_100%)]'
: 'bg-[linear-gradient(to_bottom,rgba(30,27,75,0.55)_0%,rgba(15,23,42,0.55)_30%,rgba(0,0,0,0.85)_70%,hsl(var(--background))_100%)]';
? 'bg-[linear-gradient(to_bottom,rgba(254,202,87,0.18)_0%,rgba(255,107,107,0.12)_30%,rgba(0,0,0,0.65)_70%,hsl(var(--card))_100%)]'
: 'bg-[linear-gradient(to_bottom,rgba(30,27,75,0.55)_0%,rgba(15,23,42,0.55)_30%,rgba(0,0,0,0.85)_70%,hsl(var(--card))_100%)]';
// Whether to show the coat of arms inside the hero. Subdivisions get a
// thumbnail in the flag slot already (from Wikipedia), so we skip the coat
@@ -920,7 +936,7 @@ export function CountryContentHeader({ code }: { code: string }) {
// hero replaces the page header (it carries its own back arrow + follow
// button overlaid on the photo), so no negative top margin is needed to
// tuck under a sibling header band.
<section className="relative isolate overflow-hidden mb-2">
<section className="relative isolate overflow-hidden">
{/* Hero — Wikipedia photo (or gradient fallback) with day/night sky
overlay that fades into the page background. Aspect ratio scales
from a compact 2:1 on phones to a cinematic 21:9 on tablets+. */}
@@ -992,23 +1008,25 @@ export function CountryContentHeader({ code }: { code: string }) {
white text legible against any underlying photo. */}
<div className="absolute bottom-0 left-0 right-0 px-5 pb-4 pt-10 [text-shadow:0_1px_4px_rgba(0,0,0,0.7),0_2px_8px_rgba(0,0,0,0.4)]">
<div className="flex items-end gap-3">
{/* Flag + (optional) coat of arms. Subdivisions show a small
Wikipedia thumbnail in the same slot when available. */}
{/* Flag + (optional) coat of arms. Subdivisions normally
show a small Wikipedia thumbnail in the same slot when
available; entries with a bundled custom flag asset
(Tibet's Snow Lion) bypass that branch so our editorial
flag wins. */}
<div className="flex items-end gap-2 [text-shadow:none] shrink-0">
{info.subdivision && wiki?.thumbnail ? (
{info.subdivision && wiki?.thumbnail && !hasCustomFlag(code) ? (
<img
src={wiki.thumbnail.source}
alt={info.subdivisionName ?? info.subdivision}
className="size-14 sm:size-16 rounded-md object-cover shadow-lg border border-white/20"
/>
) : (
<span
className="text-5xl sm:text-6xl leading-none drop-shadow-[0_2px_8px_rgba(0,0,0,0.6)]"
role="img"
aria-label={`Flag of ${info.name}`}
>
{info.flag}
</span>
<CountryFlag
code={code}
emoji={info.flag}
label={`Flag of ${info.subdivisionName ?? info.name}`}
className="text-5xl sm:text-6xl drop-shadow-[0_2px_8px_rgba(0,0,0,0.6)]"
/>
)}
{showCoatOfArms && (
<img
@@ -1030,7 +1048,7 @@ export function CountryContentHeader({ code }: { code: string }) {
<AnthemButton filename={facts.anthemFilename} title={facts.anthemTitle} />
)}
</div>
{info.subdivision ? (
{info.subdivision && !hasCustomFlag(code) ? (
<p className="text-sm text-white/85 mt-0.5 truncate">
{info.name}{info.subdivisionName ? '' : ` · ${info.subdivision}`}
</p>
@@ -1236,14 +1254,24 @@ function BookPreview({ isbn, link }: { isbn: string; link: string }) {
function CountryPreview({ code, link }: { code: string; link: string }) {
const info = getCountryInfo(code);
// For ISO 3166-2 codes we treat editorially as countries (Tibet today),
// prefer the subdivision's own name and let `CountryFlag` swap in the
// bundled Snow Lion SVG instead of the parent-country emoji.
const displayName = hasCustomFlag(code)
? info?.subdivisionName ?? info?.name ?? code
: info?.name ?? code;
return (
<Link
to={link}
className="flex items-center gap-3 px-4 py-3 border-b border-border hover:bg-secondary/30 transition-colors"
>
<span className="text-2xl leading-none shrink-0" role="img" aria-label={info ? `Flag of ${info.name}` : code}>
{info?.flag ?? '🌍'}
</span>
<CountryFlag
code={code}
emoji={info?.flag ?? '🌍'}
label={`Flag of ${displayName}`}
className="text-2xl shrink-0"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
@@ -1251,7 +1279,7 @@ function CountryPreview({ code, link }: { code: string; link: string }) {
<span>Country</span>
</div>
<p className="text-sm font-medium truncate mt-0.5">
{info?.name ?? code}
{displayName}
</p>
</div>
@@ -1275,7 +1303,7 @@ export function CommunityPreview({ addr }: { addr: { kind: number; pubkey: strin
const communityName = event?.tags.find(([n]) => n === 'name')?.[1]
|| event?.tags.find(([n]) => n === 'd')?.[1]
|| 'Community';
|| 'Organization';
const communityImage = event?.tags.find(([n]) => n === 'image')?.[1];
const communityDescription = event?.tags.find(([n]) => n === 'description')?.[1];
const moderatorCount = event?.tags.filter(([n, , , role]) => n === 'p' && role === 'moderator').length ?? 0;
@@ -1319,7 +1347,7 @@ export function CommunityPreview({ addr }: { addr: { kind: number; pubkey: strin
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Users className="size-3 shrink-0" />
<span>Community</span>
<span>Organization</span>
{moderatorCount > 0 && (
<span className="text-muted-foreground/60">&middot; {moderatorCount} mod{moderatorCount !== 1 ? 's' : ''}</span>
)}
@@ -1433,7 +1461,7 @@ const WELL_KNOWN_KIND_LABELS: Record<number, string> = {
3063: 'Zapstore Asset',
15128: 'Nsite',
35128: 'Nsite',
36639: 'Action',
36639: 'Pledge',
};
export function AddressableEventPreview({ addr }: { addr: { kind: number; pubkey: string; identifier: string } }) {
@@ -1458,7 +1486,7 @@ export function AddressableEventPreview({ addr }: { addr: { kind: number; pubkey
// Fallback icons for well-known kinds not in EXTRA_KINDS
if (addr.kind === 31990 || addr.kind === 32267 || addr.kind === 30063 || addr.kind === 3063) return Package;
if (addr.kind === 15128 || addr.kind === 35128) return Globe;
if (addr.kind === 36639) return Zap;
if (addr.kind === 36639) return Megaphone;
return FileText;
}, [kindDef, addr.kind]);
@@ -7,6 +7,7 @@ import { useMemo } from 'react';
import { cn } from '@/lib/utils';
import { parseExternalUri, headerLabel } from '@/lib/externalContent';
import { getCountryInfo } from '@/lib/countries';
import { CountryFlag } from '@/components/CountryFlag';
import { ExternalFavicon } from '@/components/ExternalFavicon';
import { useBookInfo } from '@/hooks/useBookInfo';
@@ -31,7 +32,14 @@ function ExternalSidebarIcon({ id }: { id: string }) {
if (content.type === 'iso3166') {
const info = getCountryInfo(content.code);
if (info?.flag) {
return <span className="text-lg leading-none shrink-0">{info.flag}</span>;
return (
<CountryFlag
code={content.code}
emoji={info.flag}
label={info.subdivisionName ?? info.name}
className="text-lg shrink-0"
/>
);
}
}
+19 -5
View File
@@ -43,6 +43,13 @@ interface ExternalReactionButtonProps {
count?: number;
/** Extra class names on the trigger button. */
className?: string;
/**
* Visual variant.
* - `pill` (default): compact icon-pill matching the legacy action bar.
* - `chip`: rounded chip with a label fallback when there's no count,
* matching the GoFundMe-style PostActionBar / NoteCard action row.
*/
variant?: 'pill' | 'chip';
}
/**
@@ -51,7 +58,7 @@ interface ExternalReactionButtonProps {
* Includes hover-to-open emoji picker via `QuickReactMenu`, optimistic UI,
* and displays the user's existing reaction & total count.
*/
export function ExternalReactionButton({ content, iconSize = 'size-5', count, className }: ExternalReactionButtonProps) {
export function ExternalReactionButton({ content, iconSize = 'size-5', count, className, variant = 'pill' }: ExternalReactionButtonProps) {
const { user } = useCurrentUser();
const { mutate: publishEvent } = useNostrPublish();
const queryClient = useQueryClient();
@@ -132,7 +139,10 @@ export function ExternalReactionButton({ content, iconSize = 'size-5', count, cl
<PopoverTrigger asChild>
<button
className={cn(
'flex items-center gap-1.5 p-2 rounded-full transition-colors',
'transition-colors',
variant === 'chip'
? 'inline-flex items-center gap-2 h-9 px-3 rounded-full text-sm font-medium'
: 'flex items-center gap-1.5 p-2 rounded-full',
hasReacted
? 'text-pink-500'
: 'text-muted-foreground hover:text-pink-500 hover:bg-pink-500/10',
@@ -155,9 +165,13 @@ export function ExternalReactionButton({ content, iconSize = 'size-5', count, cl
) : (
<Heart className={iconSize} />
)}
{(count ?? reactionCount) > 0 && (
<span className="text-sm tabular-nums">{formatNumber(count ?? reactionCount)}</span>
)}
{(count ?? reactionCount) > 0 ? (
<span className={cn('tabular-nums', variant === 'chip' ? '' : 'text-sm')}>
{formatNumber(count ?? reactionCount)}
</span>
) : variant === 'chip' ? (
<span className="hidden sm:inline">React</span>
) : null}
</button>
</PopoverTrigger>
<PopoverContent
+120 -318
View File
@@ -1,36 +1,31 @@
import { useState, useEffect, useMemo, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { useState, useEffect, useMemo } from 'react';
import { useInView } from 'react-intersection-observer';
import { usePageRefresh } from '@/hooks/usePageRefresh';
import { ComposeBox } from '@/components/ComposeBox';
import { HeroAtmosphere } from '@/components/HeroAtmosphere';
import { HeroGlobe } from '@/components/HeroGlobe';
import { LandingHero } from '@/components/LandingHero';
import { NoteCard } from '@/components/NoteCard';
import { PullToRefresh } from '@/components/PullToRefresh';
import { FeedEmptyState } from '@/components/FeedEmptyState';
import { Skeleton } from '@/components/ui/skeleton';
import { Globe2, Loader2 } from 'lucide-react';
import LoginDialog from '@/components/auth/LoginDialog';
import { useOnboarding } from '@/hooks/useOnboarding';
import { Loader2 } from 'lucide-react';
import AuthDialog from '@/components/auth/AuthDialog';
import { useFeed } from '@/hooks/useFeed';
import { useFollowingFeed } from '@/hooks/useFollowingFeed';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useFeedTab } from '@/hooks/useFeedTab';
import { useMuteList } from '@/hooks/useMuteList';
import { useTabFeed } from '@/hooks/useProfileFeed';
import { useSavedFeeds } from '@/hooks/useSavedFeeds';
import { useResolveTabFilter } from '@/hooks/useResolveTabFilter';
import { useWorldFeed } from '@/hooks/useWorldFeed';
import { useAgoraFeed } from '@/hooks/useAgoraFeed';
import { shouldHideFeedEvent } from '@/lib/feedUtils';
import { HOPE_PALETTE } from '@/lib/hopePalette';
import { isEventMuted } from '@/lib/muteHelpers';
import { SubHeaderBar } from '@/components/SubHeaderBar';
import { TabButton } from '@/components/TabButton';
import { Button } from '@/components/ui/button';
import { useNavHidden } from '@/contexts/LayoutContext';
import { cn } from '@/lib/utils';
import type { FeedItem } from '@/lib/feedUtils';
import type { SavedFeed } from '@/contexts/AppContext';
type CoreFeedTab = 'follows' | 'network' | 'global' | 'communities' | 'world';
type CoreFeedTab = 'follows' | 'network' | 'global' | 'communities' | 'world' | 'agora';
type FeedTab = CoreFeedTab | string; // string = saved feed id
interface FeedProps {
@@ -48,69 +43,59 @@ interface FeedProps {
feedId?: string;
}
const FEED_BACKDROP_HUE_INTERVAL_MS = 45_000;
const FEED_BACKDROP_HUE_FADE_MS = 18_000;
const AGORA_DEFAULT_NOTE_TAGS = [['t', 'agora']];
function FeedGlobeBackground() {
const [hueIndex, setHueIndex] = useState(0);
useEffect(() => {
const id = window.setInterval(() => {
setHueIndex((i) => (i + 1) % HOPE_PALETTE.length);
}, FEED_BACKDROP_HUE_INTERVAL_MS);
return () => window.clearInterval(id);
}, []);
const activeHue = HOPE_PALETTE[hueIndex];
return (
<div className="fixed inset-0 z-0 pointer-events-none overflow-hidden bg-secondary/30" aria-hidden="true">
<HeroAtmosphere hue={activeHue} fadeMs={FEED_BACKDROP_HUE_FADE_MS} className="opacity-55" />
<div className="absolute inset-0 bg-gradient-to-b from-background/10 via-background/20 to-background/55" />
<div className="absolute inset-0 flex items-center justify-center">
<HeroGlobe
hue={activeHue}
className="aspect-square max-w-none opacity-70 drop-shadow-2xl"
style={{ width: 'clamp(552px, 86.4dvw, 984px)' }}
/>
</div>
<div className="absolute inset-0 bg-background/70" />
</div>
);
}
export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, feedId = 'home' }: FeedProps = {}) {
const { user } = useCurrentUser();
const navigate = useNavigate();
const { muteItems } = useMuteList();
const { savedFeeds } = useSavedFeeds();
const navHidden = useNavHidden();
// Tab settings from localStorage
const showGlobalFeed = (() => {
const stored = localStorage.getItem('ditto:showGlobalFeed');
return stored !== null ? stored === 'true' : false;
})();
const showWorldFeed = (() => {
const stored = localStorage.getItem('agora:showWorldFeed');
return stored !== null ? stored === 'true' : true;
})();
const showCommunityFeed = (() => {
const stored = localStorage.getItem('ditto:showCommunityFeed');
return stored !== null ? stored === 'true' : false;
})();
const communityLabel = (() => {
try {
const stored = localStorage.getItem('ditto:community');
if (stored) {
const community = JSON.parse(stored);
return community.label || 'Community';
}
} catch {
// Fall through
}
return 'Community';
})();
const [rawActiveTab, handleSetActiveTab] = useFeedTab<FeedTab>(feedId);
const [loginDialogOpen, setLoginDialogOpen] = useState(false);
const { startSignup } = useOnboarding();
const [authDialogOpen, setAuthDialogOpen] = useState(false);
const isHomeAgoraFeed = !kinds && !tagFilters;
// Kind-specific pages only support Follows + Global. Clamp any other
// persisted tab (e.g. 'world', 'communities') back to the appropriate default.
// Logged-out users on the home feed land on 'world' to see global content.
// The home feed is Agora-only. Specialized feed pages keep Follows + Global.
const activeTab: FeedTab = (() => {
if (isHomeAgoraFeed) return 'agora';
if (!kinds) {
// Migrate legacy 'ditto' tab to 'world'
if (rawActiveTab === 'ditto') return 'world';
// Legacy hashtag:/geotag: tabs are now part of the combined Following
// feed; surface them there instead of rendering a missing sub-feed.
if (rawActiveTab.startsWith('hashtag:') || rawActiveTab.startsWith('geotag:')) return 'follows';
return rawActiveTab;
if (rawActiveTab === 'global') return 'global';
if (rawActiveTab === 'follows' && user) return 'follows';
return user ? 'follows' : 'global';
}
if (rawActiveTab === 'global') return 'global';
if (rawActiveTab === 'follows' && user) return 'follows';
return user ? 'follows' : 'global';
})();
// Is the active tab a saved feed?
const activeSavedFeed = useMemo(
() => savedFeeds.find((f) => f.id === activeTab) ?? null,
[savedFeeds, activeTab],
);
// Migrate legacy hashtag:/geotag: tabs (which used to render their own
// sub-feeds) back to the home Following feed. Followed hashtags/geotags
// now contribute to the combined Following feed instead of getting
@@ -122,22 +107,15 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
}, [rawActiveTab, handleSetActiveTab]);
// Kind-specific pages (e.g. Development, WebXDC) only show Follows + Global tabs.
// Extra tabs (World, Community, saved feeds) are only for the home feed.
const isKindSpecificPage = !!kinds;
// When logged out (and not on a kind-specific page), show the World feed.
const useWorldForLoggedOut = !user && !kinds;
// When the World tab is active (logged in), show the world feed.
// Disabled on kind-specific pages — the World tab is not shown there.
const useWorldTab = activeTab === 'world' && !kinds;
// Is the world feed active?
const isWorldActive = useWorldForLoggedOut || !!useWorldTab;
// When the Agora tab is active, show the mixed Agora activity feed.
// Disabled on kind-specific pages — the Agora tab is not shown there.
const isAgoraActive = isHomeAgoraFeed;
// Standard feed query (used when logged in, or on kind-specific pages, or core tabs)
const isHomeFollowingActive = activeTab === 'follows' && !isKindSpecificPage && !tagFilters;
const isCoreFeedTab = activeTab === 'follows' || activeTab === 'network' || activeTab === 'global' || activeTab === 'communities' || activeTab === 'world';
const isCoreFeedTab = activeTab === 'follows' || activeTab === 'network' || activeTab === 'global' || activeTab === 'communities' || activeTab === 'world' || activeTab === 'agora';
type UseFeedTab = 'follows' | 'network' | 'global' | 'communities';
const feedTabForQuery: UseFeedTab =
activeTab === 'follows'
@@ -146,35 +124,28 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
? (activeTab as UseFeedTab)
: 'global';
const standardFeedOptions = (kinds || tagFilters)
? { kinds, tagFilters, enabled: !isHomeFollowingActive }
: { enabled: !isHomeFollowingActive };
? { kinds, tagFilters, enabled: !isHomeFollowingActive && !isAgoraActive }
: { enabled: !isHomeFollowingActive && !isAgoraActive };
const feedQuery = useFeed(
isCoreFeedTab && !isWorldActive ? feedTabForQuery : 'global',
isCoreFeedTab && !isAgoraActive ? feedTabForQuery : 'global',
standardFeedOptions,
);
const followingFeed = useFollowingFeed(isHomeFollowingActive);
// World feed: all country-tagged events with diversity cap + live streaming.
const worldFeed = useWorldFeed(isWorldActive);
const { flushStreamBuffer } = worldFeed;
const agoraFeed = useAgoraFeed(isAgoraActive);
// For non-world tabs, use the standard feed query
const queryKey = useMemo(
() => isWorldActive
? ['world-feed']
: isHomeFollowingActive
? [['feed', 'network'], ['community-activity-feed'], ['following-country-feed']]
: ['feed', activeTab],
[isWorldActive, isHomeFollowingActive, activeTab],
() => isAgoraActive
? ['agora-feed']
: isHomeFollowingActive
? [['feed', 'network'], ['community-activity-feed'], ['following-country-feed']]
: ['feed', activeTab],
[isAgoraActive, isHomeFollowingActive, activeTab],
);
const handleRefresh = usePageRefresh(queryKey);
const handleWorldRefresh = useCallback(async () => {
flushStreamBuffer();
await handleRefresh();
window.scrollTo({ top: 0, behavior: 'smooth' });
}, [flushStreamBuffer, handleRefresh]);
const {
data: rawData,
@@ -186,16 +157,16 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
} = isHomeFollowingActive ? followingFeed : feedQuery;
// Unify pagination interface
const fetchNextPage = isWorldActive ? worldFeed.fetchNextPage : fetchNextPageStandard;
const hasNextPage = isWorldActive ? worldFeed.hasNextPage : hasNextPageStandard;
const isFetchingNextPage = isWorldActive ? worldFeed.isFetchingNextPage : isFetchingNextPageStandard;
const fetchNextPage = isAgoraActive ? agoraFeed.fetchNextPage : fetchNextPageStandard;
const hasNextPage = isAgoraActive ? agoraFeed.hasNextPage : hasNextPageStandard;
const isFetchingNextPage = isAgoraActive ? agoraFeed.isFetchingNextPage : isFetchingNextPageStandard;
// Auto-fetch page 2 as soon as page 1 arrives for smoother scrolling
useEffect(() => {
if (!isHomeFollowingActive && !isWorldActive && hasNextPage && !isFetchingNextPage && rawData?.pages?.length === 1) {
if (!isHomeFollowingActive && !isAgoraActive && hasNextPage && !isFetchingNextPage && rawData?.pages?.length === 1) {
fetchNextPage();
}
}, [isHomeFollowingActive, isWorldActive, hasNextPage, isFetchingNextPage, rawData?.pages?.length, fetchNextPage]);
}, [isHomeFollowingActive, isAgoraActive, hasNextPage, isFetchingNextPage, rawData?.pages?.length, fetchNextPage]);
// Intersection observer for infinite scroll
const { ref: scrollRef, inView } = useInView({
@@ -211,9 +182,8 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
// Flatten, deduplicate, and filter muted content.
const feedItems = useMemo(() => {
if (isWorldActive) {
// World feed: events are already filtered/deduped by useWorldFeed
return worldFeed.events.map((event): FeedItem => ({ event, sortTimestamp: event.created_at }));
if (isAgoraActive) {
return agoraFeed.events.map((event): FeedItem => ({ event, sortTimestamp: event.created_at }));
}
if (!rawData?.pages) return [];
@@ -229,86 +199,55 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
if (muteItems.length > 0 && isEventMuted(item.event, muteItems)) return false;
return true;
});
}, [isWorldActive, worldFeed.events, rawData?.pages, muteItems]);
}, [isAgoraActive, agoraFeed.events, rawData?.pages, muteItems]);
// Show skeletons while loading.
const showSkeleton = isWorldActive
? worldFeed.isLoading
: (isPending || (isLoading && !rawData));
const showSkeleton = isAgoraActive
? agoraFeed.isLoading
: (isPending || (isLoading && !rawData));
const showSavedFeedTabs = user && !isKindSpecificPage && !tagFilters;
const useGlobeBackdrop = feedId === 'home' && !kinds && !tagFilters && !header;
const translucentCardClassName = useGlobeBackdrop
? 'bg-transparent border-border/50 hover:bg-transparent'
: undefined;
const transparentFeedSurfaceClassName = useGlobeBackdrop ? 'bg-transparent' : undefined;
return (
<main className="flex-1 min-w-0 min-h-dvh">
{header}
<main className={cn('flex-1 min-w-0 min-h-dvh', useGlobeBackdrop && 'relative isolate overflow-x-clip')}>
{useGlobeBackdrop && <FeedGlobeBackground />}
{/* CTA (logged out, main feed only) */}
{!user && !kinds && (
<LandingHero
onLoginClick={() => setLoginDialogOpen(true)}
onSignupClick={startSignup}
/>
)}
<div className={cn(useGlobeBackdrop && 'relative z-10')}>
{header}
{!hideCompose && <ComposeBox compact hideBorder />}
{/* CTA (logged out, main feed only) */}
{!user && !kinds && (
<LandingHero onJoinClick={() => setAuthDialogOpen(true)} />
)}
{/* Tabs (logged in) */}
{user && (
<SubHeaderBar>
<TabButton label={isKindSpecificPage || tagFilters ? 'Follows' : 'Following'} active={activeTab === 'follows'} onClick={() => handleSetActiveTab('follows')} />
{!isKindSpecificPage && !tagFilters && (
<TabButton label="Network" active={activeTab === 'network'} onClick={() => handleSetActiveTab('network')} />
)}
{!isKindSpecificPage && showWorldFeed && (
<TabButton label="World" active={activeTab === 'world'} onClick={() => handleSetActiveTab('world')} />
)}
{!isKindSpecificPage && showCommunityFeed && (
<TabButton label={communityLabel} active={activeTab === 'communities'} onClick={() => handleSetActiveTab('communities')} />
)}
{(isKindSpecificPage || showGlobalFeed) && (
{!hideCompose && (
<ComposeBox
compact
hideBorder
className={transparentFeedSurfaceClassName}
defaultTags={AGORA_DEFAULT_NOTE_TAGS}
defaultExpanded
placeholder="What's happening?"
/>
)}
{/* Tabs are only kept for specialized feed pages. The home feed is Agora-only. */}
{user && (isKindSpecificPage || tagFilters) && (
<SubHeaderBar backgroundFillClassName={transparentFeedSurfaceClassName && 'fill-transparent'}>
<TabButton label={isKindSpecificPage || tagFilters ? 'Follows' : 'Following'} active={activeTab === 'follows'} onClick={() => handleSetActiveTab('follows')} />
<TabButton label="Global" active={activeTab === 'global'} onClick={() => handleSetActiveTab('global')} />
)}
{showSavedFeedTabs && savedFeeds.map((feed) => (
<TabButton
key={feed.id}
label={feed.label}
active={activeTab === feed.id}
onClick={() => handleSetActiveTab(feed.id)}
/>
))}
</SubHeaderBar>
)}
</SubHeaderBar>
)}
{/* Feed content — saved feed tab gets its own stream */}
{activeSavedFeed ? (
<SavedFeedContent feed={activeSavedFeed} />
) : (
<PullToRefresh onRefresh={isWorldActive ? handleWorldRefresh : handleRefresh}>
{/* "X new posts" pill for World tab */}
{isWorldActive && worldFeed.newPostCount > 0 && (
<div
className={cn(
'sticky new-posts-pill z-10 flex justify-center pointer-events-none',
'max-sidebar:transition-opacity max-sidebar:duration-300 max-sidebar:ease-in-out',
navHidden && 'max-sidebar:opacity-0 max-sidebar:pointer-events-none',
)}
style={{ marginBottom: '-3rem' }}
>
<button
onClick={() => {
worldFeed.flushStreamBuffer();
window.scrollTo({ top: 0, behavior: 'smooth' });
}}
className="pointer-events-auto px-4 py-1.5 rounded-full bg-primary text-primary-foreground text-sm font-medium shadow-lg hover:bg-primary/90 transition-colors animate-in fade-in slide-in-from-top-2 duration-300"
>
{worldFeed.newPostCount} new post{worldFeed.newPostCount !== 1 ? 's' : ''}
</button>
</div>
)}
<PullToRefresh onRefresh={handleRefresh}>
{showSkeleton ? (
<div className="divide-y divide-border">
{Array.from({ length: 5 }).map((_, i) => (
<NoteCardSkeleton key={i} />
<NoteCardSkeleton key={i} className={translucentCardClassName} />
))}
</div>
) : feedItems.length > 0 ? (
@@ -318,7 +257,7 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
key={item.repostedBy ? `repost-${item.repostedBy}-${item.event.id}` : item.event.id}
event={item.event}
repostedBy={item.repostedBy}
highlight={isWorldActive && worldFeed.flushedIds.has(item.event.id)}
className={translucentCardClassName}
/>
))}
{hasNextPage && (
@@ -331,157 +270,42 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
</div>
)}
</div>
) : isHomeFollowingActive && !emptyMessage ? (
<FollowingEmptyState onExploreWorld={() => navigate('/world')} />
) : (
<FeedEmptyState
message={
emptyMessage ?? (
activeTab === 'follows' || activeTab === 'network'
activeTab === 'follows'
? 'Your feed is empty. Follow some people to see their posts here.'
: activeTab === 'world'
? 'No world posts yet. Check back soon for global activity.'
: activeTab === 'agora'
? 'No Agora activity found. Check your relay connections or come back soon.'
: 'No posts found. Check your relay connections or come back soon.'
)
}
showDiscover={!emptyMessage && (activeTab === 'follows' || activeTab === 'network')}
showDiscover={!emptyMessage && activeTab === 'follows'}
onSwitchToGlobal={
(activeTab === 'follows' || activeTab === 'network') && showGlobalFeed
activeTab === 'follows'
? () => handleSetActiveTab('global')
: undefined
}
/>
)}
</PullToRefresh>
)}
{/* Login/Signup dialogs (only needed on main feed) */}
{!kinds && (
<LoginDialog
isOpen={loginDialogOpen}
onClose={() => setLoginDialogOpen(false)}
onLogin={() => setLoginDialogOpen(false)}
onSignupClick={startSignup}
/>
)}
{/* Auth dialog (only needed on main feed) */}
{!kinds && (
<AuthDialog
isOpen={authDialogOpen}
onClose={() => setAuthDialogOpen(false)}
/>
)}
</div>
</main>
);
}
/** Renders a saved search feed using useTabFeed (TanStack Query cached, infinite scroll). */
function SavedFeedContent({ feed }: { feed: SavedFeed }) {
const { ref: scrollRef, inView } = useInView({ threshold: 0, rootMargin: '400px' });
const { user } = useCurrentUser();
const { muteItems } = useMuteList();
// Resolve variable placeholders ($follows etc.) the same way profile tabs do
const { filter: resolvedFilter, isLoading: isResolving } = useResolveTabFilter(
feed.filter,
feed.vars ?? [],
user?.pubkey ?? '',
);
// Augment the resolved filter with protocol:nostr (NIP-50 Ditto extension)
// to match the behavior of the core feeds and ensure latest native Nostr
// posts are returned.
const augmentedFilter = useMemo(() => {
if (!resolvedFilter) return null;
const existing = resolvedFilter.search ?? '';
const search = existing.includes('protocol:nostr')
? existing
: existing
? `${existing} protocol:nostr`
: 'protocol:nostr';
return { ...resolvedFilter, search };
}, [resolvedFilter]);
const {
data: rawData,
isLoading: isFeedLoading,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useTabFeed(augmentedFilter, `saved-${feed.id}`, !isResolving);
const isLoading = isResolving || isFeedLoading;
// Prefix key -- usePageRefresh does prefix matching, so this invalidates
// the full ['tab-feed', tabKey, kindsKey, authorsKey, searchKey] used by useTabFeed.
const queryKey = useMemo(
() => ['tab-feed', `saved-${feed.id}`],
[feed.id],
);
const handleRefresh = usePageRefresh(queryKey);
// Infinite scroll: fetch next page when sentinel is in view
useEffect(() => {
if (inView && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]);
// Flatten pages, deduplicate, and filter muted content
const feedItems = useMemo(() => {
if (!rawData?.pages) return [];
const seen = new Set<string>();
return rawData.pages
.flatMap((page) => page.items)
.filter((item) => {
const key = item.repostedBy ? `repost-${item.repostedBy}-${item.event.id}` : item.event.id;
if (!key || seen.has(key)) return false;
seen.add(key);
if (shouldHideFeedEvent(item.event)) return false;
if (muteItems.length > 0 && isEventMuted(item.event, muteItems)) return false;
return true;
});
}, [rawData?.pages, muteItems]);
if (isLoading && feedItems.length === 0) {
return (
<div className="divide-y divide-border">
{Array.from({ length: 5 }).map((_, i) => (
<NoteCardSkeleton key={i} />
))}
</div>
);
}
if (feedItems.length === 0) {
return (
<PullToRefresh onRefresh={handleRefresh}>
<FeedEmptyState message={`No posts found for "${feed.label}". Try adjusting your relay connections or check back later.`} />
</PullToRefresh>
);
}
function NoteCardSkeleton({ className }: { className?: string }) {
return (
<PullToRefresh onRefresh={handleRefresh}>
<div>
{feedItems.map((item) => (
<NoteCard
key={item.repostedBy ? `repost-${item.repostedBy}-${item.event.id}` : item.event.id}
event={item.event}
repostedBy={item.repostedBy}
/>
))}
{hasNextPage && (
<div ref={scrollRef} className="py-4">
{isFetchingNextPage && (
<div className="flex justify-center">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
)}
</div>
)}
{!hasNextPage && <div ref={scrollRef} className="py-2" />}
</div>
</PullToRefresh>
);
}
function NoteCardSkeleton() {
return (
<div className="px-4 py-3 border-b border-border">
<div className={cn('px-4 py-3 border-b border-border', className)}>
<div className="flex items-center gap-3">
<Skeleton className="size-11 rounded-full shrink-0" />
<div className="min-w-0 space-y-1.5">
@@ -502,25 +326,3 @@ function NoteCardSkeleton() {
</div>
);
}
function FollowingEmptyState({ onExploreWorld }: { onExploreWorld: () => void }) {
return (
<div className="py-20 px-8 flex flex-col items-center gap-6 text-center">
<div className="p-4 rounded-full bg-primary/10">
<Globe2 className="size-8 text-primary" />
</div>
<div className="space-y-2 max-w-xs">
<h2 className="text-xl font-bold">No countries yet</h2>
<p className="text-muted-foreground text-sm">
Explore the World page and follow countries to build your Following feed.
</p>
</div>
<div className="flex flex-col gap-2 w-full max-w-xs">
<Button className="rounded-full" onClick={onExploreWorld}>
<Globe2 className="size-4 mr-2" />
Explore World
</Button>
</div>
</div>
);
}
+39
View File
@@ -0,0 +1,39 @@
import { forwardRef } from 'react';
import { cn } from '@/lib/utils';
interface FeedCardProps extends React.HTMLAttributes<HTMLDivElement> {
/** Extra class names merged after the defaults. */
className?: string;
/** Children — typically a list of NoteCards, member rows, notification rows, etc. */
children?: React.ReactNode;
}
/**
* Soft rounded card surface used to wrap vertical feed lists (NoteCard
* feeds, author lists, notification rows, etc.) so they sit inside a
* GoFundMe-style canvas instead of running edge-to-edge like a Twitter
* timeline.
*
* Rows inside are expected to supply their own per-row separator
* (NoteCard self-applies `border-b border-border`). For pure skeleton
* lists where rows don't self-border, pass `divide` on the className.
*
* `overflow-hidden` ensures the last row's bottom border tucks under
* the card's rounded corner instead of poking out.
*/
export const FeedCard = forwardRef<HTMLDivElement, FeedCardProps>(
function FeedCard({ className, children, ...rest }, ref) {
return (
<div
ref={ref}
className={cn(
'mx-4 sm:mx-6 rounded-2xl bg-card border border-border/60 shadow-sm overflow-hidden',
className,
)}
{...rest}
>
{children}
</div>
);
},
);
+50 -19
View File
@@ -1,6 +1,7 @@
import { lazy, Suspense, useState } from 'react';
import { Plus, Construction } from 'lucide-react';
import { Construction } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { LogoIcon } from '@/components/icons/LogoIcon';
import { Button } from '@/components/ui/button';
import {
Dialog,
@@ -41,26 +42,43 @@ export function FloatingComposeButton({ kind = 1, href, onFabClick, icon, menu }
return null;
}
const renderedIcon = icon ?? <Plus strokeWidth={4} size={16} />;
const hasCustomIcon = icon !== undefined;
const renderedIcon = icon;
const logoButtonClassName = "relative size-20 text-primary transition-transform hover:scale-105 active:scale-95 disabled:opacity-40 disabled:pointer-events-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-sm";
const logoButtonStyle = { filter: 'drop-shadow(0 3px 10px hsl(var(--primary) / 0.28))' };
// ── Menu mode — anchor a Popover to the FAB itself ────────────────────────
if (menu && menu.length > 0) {
return (
<Popover open={menuOpen} onOpenChange={setMenuOpen}>
<PopoverTrigger asChild>
<button
type="button"
aria-label="Add"
aria-expanded={menuOpen}
aria-haspopup="menu"
className="relative size-16 transition-transform hover:scale-105 active:scale-95 disabled:opacity-40 disabled:pointer-events-none"
style={{ filter: 'drop-shadow(0 2px 8px hsl(var(--primary) / 0.25))' }}
>
<div className="absolute inset-0 bg-primary rounded-full" />
<span className="absolute inset-0 flex items-center justify-center text-primary-foreground">
{renderedIcon}
</span>
</button>
{hasCustomIcon ? (
<button
type="button"
aria-label="Add"
aria-expanded={menuOpen}
aria-haspopup="menu"
className="relative size-16 transition-transform hover:scale-105 active:scale-95 disabled:opacity-40 disabled:pointer-events-none"
style={{ filter: 'drop-shadow(0 2px 8px hsl(var(--primary) / 0.25))' }}
>
<div className="absolute inset-0 bg-primary rounded-full" />
<span className="absolute inset-0 flex items-center justify-center text-primary-foreground">
{renderedIcon}
</span>
</button>
) : (
<button
type="button"
aria-label="Add"
aria-expanded={menuOpen}
aria-haspopup="menu"
className={logoButtonClassName}
style={logoButtonStyle}
>
<span className="absolute inset-[-8%] rounded-full bg-primary/15 blur-xl" aria-hidden />
<LogoIcon className="relative size-full" />
</button>
)}
</PopoverTrigger>
<PopoverContent
side="top"
@@ -108,10 +126,23 @@ export function FloatingComposeButton({ kind = 1, href, onFabClick, icon, menu }
return (
<>
<FabButton
onClick={handleClick}
icon={renderedIcon}
/>
{hasCustomIcon ? (
<FabButton
onClick={handleClick}
icon={renderedIcon}
/>
) : (
<button
type="button"
onClick={handleClick}
aria-label="Add"
className={logoButtonClassName}
style={logoButtonStyle}
>
<span className="absolute inset-[-8%] rounded-full bg-primary/15 blur-xl" aria-hidden />
<LogoIcon className="relative size-full" />
</button>
)}
{/* Kind 1: Compose modal (lazy-loaded) */}
{kind === 1 && composeOpen && (
+29
View File
@@ -0,0 +1,29 @@
/**
* Section wrapper used by the long single-column form pages
* (`CreateActionPage`, `CreateCampaignPage`). Each section is a titled
* `<section>` with a small muted requirement badge so users can scan the
* form at a glance for "what do I have to fill in?".
*/
export function FormSection({
title,
requirement,
children,
}: {
title: string;
requirement: 'Required' | 'Recommended' | 'Optional';
children: React.ReactNode;
}) {
return (
<section className="space-y-2.5 rounded-xl p-3 sm:p-4">
<div className="space-y-0.5">
<h2 className="flex items-center gap-2 text-lg font-semibold">
{title}
<span className="text-xs font-medium text-muted-foreground">
{requirement}
</span>
</h2>
</div>
<div className="space-y-2.5">{children}</div>
</section>
);
}
+128
View File
@@ -0,0 +1,128 @@
import { Suspense, useCallback, useMemo, useRef, useState } from 'react';
import { Link, Outlet } from 'react-router-dom';
import { FloatingComposeButton } from '@/components/FloatingComposeButton';
import { TopNav } from '@/components/TopNav';
import { Skeleton } from '@/components/ui/skeleton';
import {
CenterColumnContext,
DrawerContext,
LayoutStore,
LayoutStoreContext,
NavHiddenContext,
useLayoutSnapshot,
} from '@/contexts/LayoutContext';
import { cn } from '@/lib/utils';
/**
* Persistent app shell for the fundraising-platform overhaul.
*
* Replaces the previous Twitter-style three-column `MainLayout` with a
* GoFundMe-style top-nav-only chrome. Routes render in a single full-width
* content area below the {@link TopNav}.
*
* Compatibility surface:
* - We still provide `LayoutStoreContext`, so pages that call
* `useLayoutOptions(...)` keep working. Most options (FAB, sidebars,
* mobile arc) are intentionally ignored here because the new shell has
* no FAB and no sidebars. The store drives two width-related escape
* hatches: `wrapperClassName` (extra classes on the center column) and
* `noMaxWidth` (drops the default `max-w-3xl` cap). The `fullBleed`
* preset expands to both, so edge-to-edge pages keep working.
* - `CenterColumnContext` exposes the content `<div>` so legacy components
* (e.g. nsite preview overlay) can still portal into it.
* - `DrawerContext` and `NavHiddenContext` are kept as no-op providers so
* pages that read them don't crash.
*/
function PageSkeleton() {
return (
<div className="mx-auto w-full max-w-6xl px-4 sm:px-6 py-8 space-y-4">
<Skeleton className="h-8 w-1/3" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-4/5" />
<Skeleton className="h-72 w-full rounded-xl" />
</div>
);
}
function FundraiserLayoutInner() {
const centerColumnRef = useRef<HTMLDivElement>(null);
const [centerColumnEl, setCenterColumnEl] = useState<HTMLElement | null>(null);
const { noMaxWidth, wrapperClassName, showFAB = false, fabKind = 1, fabHref, onFabClick, fabIcon, fabMenu } = useLayoutSnapshot();
// Mobile drawer is owned by TopNav now, so consumers of `useOpenDrawer`
// become no-ops. Keeping the context shape avoids touching every page that
// pulls the hook.
const openDrawer = useCallback(() => {}, []);
return (
<CenterColumnContext.Provider value={centerColumnEl}>
<DrawerContext.Provider value={openDrawer}>
<NavHiddenContext.Provider value={false}>
<div className="min-h-dvh flex flex-col bg-background">
<TopNav />
<Suspense fallback={<PageSkeleton />}>
<div
ref={(el) => {
centerColumnRef.current = el;
setCenterColumnEl(el);
}}
className={cn(
'flex-1 min-w-0 w-full mx-auto',
// App-wide cap on the center column so pages like /help
// don't stretch across widescreen monitors. Pages that
// need a wider canvas opt out via `noMaxWidth: true` (or
// the `fullBleed` preset), which expands to `!max-w-none`
// through `wrapperClassName`.
!noMaxWidth && 'max-w-3xl',
wrapperClassName,
)}
>
<Outlet />
</div>
</Suspense>
{showFAB && (
<div className="fixed bottom-fab right-6 z-30 pointer-events-none sidebar:right-[max(1.5rem,calc((100vw-48rem)/2-7rem))]">
<div className="pointer-events-auto">
<FloatingComposeButton kind={fabKind} href={fabHref} onFabClick={onFabClick} icon={fabIcon} menu={fabMenu} />
</div>
</div>
)}
<SiteFooter />
</div>
</NavHiddenContext.Provider>
</DrawerContext.Provider>
</CenterColumnContext.Provider>
);
}
function SiteFooter() {
return (
<footer className="border-t border-border bg-background mt-auto">
<div className="mx-auto max-w-7xl px-4 sm:px-6 py-8 flex flex-col sm:flex-row items-center justify-between gap-4 text-xs text-muted-foreground">
<span>&copy; {new Date().getFullYear()} Agora. Fundraisers on Nostr.</span>
<nav className="flex items-center gap-5">
<Link to="/help" className="hover:text-foreground motion-safe:transition-colors">Help</Link>
<Link to="/privacy" className="hover:text-foreground motion-safe:transition-colors">Privacy</Link>
<Link to="/safety" className="hover:text-foreground motion-safe:transition-colors">Safety</Link>
<Link to="/changelog" className="hover:text-foreground motion-safe:transition-colors">Changelog</Link>
</nav>
</div>
</footer>
);
}
export function FundraiserLayout() {
const store = useMemo(() => new LayoutStore(), []);
return (
<LayoutStoreContext.Provider value={store}>
<FundraiserLayoutInner />
</LayoutStoreContext.Provider>
);
}
export default FundraiserLayout;
+95
View File
@@ -0,0 +1,95 @@
import { useEffect, useState } from 'react';
import { ArrowLeft } from 'lucide-react';
import { Link } from 'react-router-dom';
import { HeroAtmosphere } from '@/components/HeroAtmosphere';
import { HeroBanner } from '@/components/HeroBanner';
import { HOPE_PALETTE, type HopeHue } from '@/lib/hopePalette';
interface GuideHeroProps {
/** Large hero headline. */
title: string;
/** Short subtitle under the headline. */
subtitle: string;
/** Rotating banner images. Pass at least one. */
images: readonly string[];
/**
* Color palette to cycle through for the atmospheric tint. Defaults
* to {@link HOPE_PALETTE} (warm). Pass {@link COOL_PALETTE} for the
* blue/green Organize-style vibe.
*/
palette?: readonly HopeHue[];
}
/**
* Compact photo hero shared by the Donor Guide and Activist Guide pages.
*
* Same structural recipe as the Organize / Actions homepage heroes
* ({@link HeroBanner} + {@link HeroAtmosphere} + scrims + overlay copy),
* but tuned smaller because these are sub-pages, not primary destinations.
* Also embeds a "Back to Help" link in the top-left as the page's
* primary navigation out — so a separate sticky bar isn't needed.
*/
export function GuideHero({
title,
subtitle,
images,
palette = HOPE_PALETTE,
}: GuideHeroProps) {
// Cycle through the palette on a slow cadence so the photo never
// feels static even when a single banner image is on screen.
const [hueIndex, setHueIndex] = useState(0);
useEffect(() => {
if (palette.length <= 1) return;
const id = window.setInterval(() => {
setHueIndex((i) => (i + 1) % palette.length);
}, 9_000);
return () => window.clearInterval(id);
}, [palette]);
const activeHue = palette[hueIndex];
return (
<section className="relative overflow-hidden border-b border-border bg-secondary/30">
<HeroBanner images={images} />
<HeroAtmosphere hue={activeHue} />
{/* Top + bottom scrims so the overlay text stays legible across
every photo in the rotation. */}
<div
className="absolute inset-x-0 top-0 h-48 sm:h-56 pointer-events-none bg-gradient-to-b from-black/75 via-black/45 to-transparent"
aria-hidden="true"
/>
<div
className="absolute inset-x-0 bottom-0 h-32 sm:h-40 pointer-events-none bg-gradient-to-t from-black/60 via-black/25 to-transparent"
aria-hidden="true"
/>
<div className="relative max-w-3xl mx-auto px-4 sm:px-6 py-6 sm:py-8 min-h-[240px] sm:min-h-[280px] flex flex-col">
{/* Back-to-Help action sits on its own row at the top so it
doubles as both the navigation out and the breadcrumb. */}
<div>
<Link
to="/help"
className="inline-flex items-center gap-1.5 rounded-full bg-black/30 hover:bg-black/45 backdrop-blur-sm border border-white/20 px-3 py-1.5 text-xs sm:text-sm font-medium text-white drop-shadow focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/70 transition-colors"
>
<ArrowLeft className="size-3.5" />
Back to Help
</Link>
</div>
{/* Headline + subtitle anchored to the bottom of the hero so the
photo gets room to breathe up top. */}
<div className="flex-1 min-h-[40px]" aria-hidden="true" />
<div className="space-y-2 max-w-2xl">
<h1 className="text-3xl sm:text-4xl lg:text-5xl font-bold tracking-tight leading-[1.05] text-white drop-shadow-[0_2px_12px_rgb(0_0_0/0.55)]">
{title}
</h1>
<p className="text-sm sm:text-base text-white/85 drop-shadow-[0_1px_6px_rgb(0_0_0/0.5)]">
{subtitle}
</p>
</div>
</div>
</section>
);
}
+8 -48
View File
@@ -8,54 +8,7 @@ import {
} from '@/components/ui/accordion';
import { useAppContext } from '@/hooks/useAppContext';
import { getFAQCategories, type FAQCategory, type FAQItem } from '@/lib/helpContent';
// ── Inline markup renderer ────────────────────────────────────────────────────
/**
* Very lightweight inline markup: **bold** and [text](url).
* Returns an array of React nodes.
*/
function renderInlineMarkup(text: string): React.ReactNode[] {
const nodes: React.ReactNode[] = [];
// Match **bold** or [text](url)
const regex = /\*\*(.+?)\*\*|\[([^\]]+)\]\(([^)]+)\)/g;
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = regex.exec(text)) !== null) {
// Push text before this match
if (match.index > lastIndex) {
nodes.push(text.slice(lastIndex, match.index));
}
if (match[1] !== undefined) {
// **bold**
nodes.push(<strong key={match.index} className="font-semibold text-foreground">{match[1]}</strong>);
} else if (match[2] !== undefined && match[3] !== undefined) {
// [text](url)
nodes.push(
<a
key={match.index}
href={match[3]}
target="_blank"
rel="noopener noreferrer"
className="text-primary underline underline-offset-2 hover:text-primary/80 transition-colors"
>
{match[2]}
</a>,
);
}
lastIndex = match.index + match[0].length;
}
// Trailing text
if (lastIndex < text.length) {
nodes.push(text.slice(lastIndex));
}
return nodes;
}
import { renderInlineMarkup } from '@/lib/helpMarkup';
// ── Component ─────────────────────────────────────────────────────────────────
@@ -93,6 +46,13 @@ export function HelpFAQSection({ categories, items, hideHeadings, className }: H
const filteredCategories = useMemo(() => {
let cats: FAQCategory[] = getFAQCategories(config.appName);
// Drop hidden categories from the default render. They still exist in
// the underlying template so `HelpTip` can look up individual items by
// ID, but they don't show up in the FAQ accordion.
if (!categories && !items) {
cats = cats.filter((c) => !c.hidden);
}
// Filter to specific categories
if (categories) {
cats = cats.filter((c) => categories.includes(c.id));
+1 -36
View File
@@ -4,42 +4,7 @@ import { Link } from 'react-router-dom';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { useAppContext } from '@/hooks/useAppContext';
import { getFAQItem } from '@/lib/helpContent';
/**
* Renders **bold** and [text](url) markup in FAQ answer strings.
*/
function renderInlineMarkup(text: string): React.ReactNode[] {
const nodes: React.ReactNode[] = [];
const regex = /\*\*(.+?)\*\*|\[([^\]]+)\]\(([^)]+)\)/g;
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = regex.exec(text)) !== null) {
if (match.index > lastIndex) {
nodes.push(text.slice(lastIndex, match.index));
}
if (match[1] !== undefined) {
nodes.push(<strong key={match.index} className="font-semibold text-foreground">{match[1]}</strong>);
} else if (match[2] !== undefined && match[3] !== undefined) {
nodes.push(
<a
key={match.index}
href={match[3]}
target="_blank"
rel="noopener noreferrer"
className="text-primary underline underline-offset-2 hover:text-primary/80 transition-colors"
>
{match[2]}
</a>,
);
}
lastIndex = match.index + match[0].length;
}
if (lastIndex < text.length) {
nodes.push(text.slice(lastIndex));
}
return nodes;
}
import { renderInlineMarkup } from '@/lib/helpMarkup';
// ── Component ─────────────────────────────────────────────────────────────────
+126
View File
@@ -0,0 +1,126 @@
import { useEffect, useRef, useState } from 'react';
import { hopeHueFor, type HopeHue } from '@/lib/hopePalette';
import { cn } from '@/lib/utils';
interface HeroAtmosphereProps {
/**
* Stable seed for the current campaign — typically the campaign's
* `aTag`. The same seed always picks the same hue from
* {@link HOPE_PALETTE}. Pass `null`/`undefined` when no campaign is
* spotlit and the atmosphere will default to the first palette entry.
*
* Ignored when `hue` is provided. Optional only when `hue` is given;
* the campaign hero still depends on seed-based selection.
*/
seed?: string | undefined | null;
/**
* Explicit hue override. When set, the atmosphere skips seed-based
* palette selection and crossfades whenever this hue changes. Use this
* when the page already rotates hues itself (e.g. the Organize hero
* cycles a cool palette every few seconds) or when the seed-derived
* warm palette is the wrong vibe.
*/
hue?: HopeHue;
/** Crossfade duration in milliseconds. Defaults to the campaign hero timing. */
fadeMs?: number;
/** Extra classes for the outer wrapper. */
className?: string;
}
interface AtmosphereLayer {
/** Render-order id so React doesn't tear the layer down mid-fade. */
id: number;
hue: HopeHue;
}
/** Has to match {@link CampaignHeroBackground}'s FADE_MS so the entire
* hero transitions as a single moment instead of in two staggered steps. */
const FADE_MS = 1500;
/**
* Soft, hue-tinted "sunrise" atmosphere layer for the campaigns hero.
*
* Two layered gradients sit on top of the photo background:
* - a left-to-right warm scrim that gives the headline area an emotional
* color cast, and
* - a large soft radial glow centered on the headline that reads as a
* sunrise / dawn light pooling behind the text.
*
* The hue is derived from {@link hopeHueFor} so every campaign gets a
* stable, slightly different warm color. When the active campaign
* changes we mount a fresh layer with the new hue and crossfade it over
* the old one, matching the timing of the photo crossfade so the whole
* hero blooms together.
*/
export function HeroAtmosphere({ seed, hue: hueOverride, fadeMs = FADE_MS, className }: HeroAtmosphereProps) {
const idRef = useRef(0);
const [layers, setLayers] = useState<AtmosphereLayer[]>([]);
const lastHueRef = useRef<string | null>(null);
useEffect(() => {
const hue = hueOverride ?? hopeHueFor(seed ?? null);
if (hue.name === lastHueRef.current) return;
lastHueRef.current = hue.name;
const id = ++idRef.current;
setLayers((prev) => [...prev, { id, hue }]);
// Drop everything except the most recent layer once the crossfade is
// safely past, so the DOM never accumulates stale gradients.
const timeout = window.setTimeout(() => {
setLayers((prev) => prev.filter((l) => l.id === id));
}, fadeMs + 50);
return () => window.clearTimeout(timeout);
}, [seed, hueOverride, fadeMs]);
return (
<div className={cn('absolute inset-0 pointer-events-none', className)} aria-hidden="true">
{layers.map((layer, i) => {
const isTop = i === layers.length - 1;
return (
<div
key={layer.id}
className="absolute inset-0"
style={{
opacity: isTop ? 1 : 0,
transition: `opacity ${fadeMs}ms ease-in-out`,
}}
>
{/* Warm directional scrim — pulls the photo toward the active
hue without flattening it. Anchored on the left so the
headline area gets the strongest tint. */}
<div
className="absolute inset-0"
style={{
backgroundImage: `linear-gradient(115deg, ${layer.hue.scrim} 0%, transparent 60%)`,
}}
/>
{/* Big soft radial glow — reads as dawn light pooling behind
the headline. mix-blend-screen so it lightens warmly
instead of just adding a flat color. */}
<div
className="absolute inset-0"
style={{
backgroundImage: `radial-gradient(45rem 32rem at 20% 35%, ${layer.hue.glow} 0%, transparent 70%)`,
mixBlendMode: 'screen',
}}
/>
{/* Thin sliver of sunrise light along the top edge. Subtle —
you should feel it more than see it. */}
<div
className="absolute inset-x-0 top-0 h-1/3"
style={{
backgroundImage: `linear-gradient(to bottom, ${layer.hue.rim} 0%, transparent 100%)`,
mixBlendMode: 'screen',
opacity: 0.55,
}}
/>
</div>
);
})}
</div>
);
}
+151
View File
@@ -0,0 +1,151 @@
import { useEffect, useRef, useState } from 'react';
import { cn } from '@/lib/utils';
/**
* Default rotation: photos from past World Liberty Congress events, used
* by the Organize / Communities hero. Each image lives in
* `/public/hero/wlc-N.webp` and is referenced by absolute path so the
* browser caches them across navigations and `<link rel="preload">` can
* pick them up if we ever add it.
*
* Other pages can pass their own list via the `images` prop (e.g. the
* Actions hero rotates through the action cover gallery).
*/
const DEFAULT_BANNER_IMAGES: readonly string[] = [
'/hero/wlc-1.webp',
'/hero/wlc-2.webp',
'/hero/wlc-3.webp',
];
interface HeroBannerProps {
/**
* Ordered list of image URLs to rotate through. Defaults to the
* Organize hero's WLC photos. Pass at least one URL; if the list has
* a single entry the banner renders it as a still image.
*/
images?: readonly string[];
/** Optional className for the outer wrapper. */
className?: string;
/**
* Time between crossfades, in ms. Defaults to 7s — long enough for
* faces to register, short enough that the page never feels static.
*/
intervalMs?: number;
}
interface Layer {
/** Stable key so React doesn't tear the layer down mid-transition. */
id: number;
/** URL of the image rendered on this layer. */
url: string;
}
const FADE_MS = 1500;
/**
* Full-bleed crossfading banner of event photos. Modelled after
* {@link CampaignHeroBackground}: each new image gets its own stacked
* layer and we toggle opacity to crossfade. The previous layer unmounts
* after the fade completes so the DOM never accumulates more than two
* `<img>` elements.
*
* The component is self-driving — it advances through `images` on a
* fixed interval and stops the timer when `prefers-reduced-motion` is
* set, leaving the first image as a static banner.
*/
export function HeroBanner({
images = DEFAULT_BANNER_IMAGES,
className,
intervalMs = 7_000,
}: HeroBannerProps) {
const [index, setIndex] = useState(0);
const idRef = useRef(0);
const [layers, setLayers] = useState<Layer[]>(() =>
images.length > 0 ? [{ id: 0, url: images[0] }] : [],
);
// Honor the user's reduced-motion preference. We freeze the rotation
// and let the first image act as a still banner.
const reducedMotion = useRef(false);
useEffect(() => {
if (typeof window === 'undefined' || !window.matchMedia) return;
const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
reducedMotion.current = mq.matches;
const handler = (e: MediaQueryListEvent) => {
reducedMotion.current = e.matches;
};
mq.addEventListener('change', handler);
return () => mq.removeEventListener('change', handler);
}, []);
// Advance the index on a fixed interval. We deliberately keep this
// separate from the layer effect below so swapping the interval (e.g.
// for tests) doesn't force a full crossfade restart.
useEffect(() => {
if (images.length <= 1) return;
const id = window.setInterval(() => {
if (reducedMotion.current) return;
setIndex((i) => (i + 1) % images.length);
}, intervalMs);
return () => window.clearInterval(id);
}, [images, intervalMs]);
// Whenever the active index changes, push a new layer on top. Old
// layers are reaped after the crossfade completes.
useEffect(() => {
if (images.length === 0) return;
const url = images[index % images.length];
const id = ++idRef.current;
setLayers((prev) => [...prev, { id, url }]);
const timeout = window.setTimeout(() => {
setLayers((prev) => prev.filter((l) => l.id === id));
}, FADE_MS + 50);
return () => window.clearTimeout(timeout);
}, [index, images]);
// Preload the next image during idle time so the next crossfade
// doesn't blink. Cheap — the browser will dedupe with the eventual
// <img> request once the layer mounts.
useEffect(() => {
if (images.length <= 1) return;
const next = images[(index + 1) % images.length];
const img = new Image();
img.decoding = 'async';
img.src = next;
}, [index, images]);
return (
<div
className={cn('absolute inset-0 overflow-hidden', className)}
aria-hidden="true"
>
{layers.map((layer, i) => {
const isTop = i === layers.length - 1;
return (
<div
key={layer.id}
className="absolute inset-0"
style={{
opacity: isTop ? 1 : 0,
transition: `opacity ${FADE_MS}ms ease-in-out`,
}}
>
<img
src={layer.url}
alt=""
// First image eager so the hero never starts empty; the
// rest can wait until they're scheduled to come in.
loading={layer.id === 0 ? 'eager' : 'lazy'}
decoding="async"
// Subtle slow pan — same keyframe used by the campaigns
// hero — so each photo feels alive on its turn instead of
// sitting frozen for 7 seconds.
className="absolute inset-0 w-full h-full object-cover hero-pan-left"
/>
</div>
);
})}
</div>
);
}
+143
View File
@@ -0,0 +1,143 @@
import { Link } from 'react-router-dom';
import { ArrowRight, MapPin } from 'lucide-react';
import { Skeleton } from '@/components/ui/skeleton';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { cn } from '@/lib/utils';
import { encodeCampaignNaddr, getCampaignCountryLabel, type ParsedCampaign } from '@/lib/campaign';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import { useAuthor } from '@/hooks/useAuthor';
import { genUserName } from '@/lib/genUserName';
import { useCampaignDonations } from '@/hooks/useCampaignDonations';
import { useBtcPrice } from '@/hooks/useBtcPrice';
import { formatCampaignAmount } from '@/lib/formatCampaignAmount';
interface HeroCampaignSpotlightProps {
/** Campaign to feature. `null` renders the empty placeholder. */
campaign: ParsedCampaign | null;
/** Show a skeleton while the parent is still loading featured campaigns. */
isLoading?: boolean;
/** Extra classes for the outer wrapper. */
className?: string;
}
/**
* Banner-overlay spotlight for the active campaign — title, summary,
* author, location, and a "View campaign" CTA — rendered directly on the
* hero photo (no card chrome). The hero photo IS the background, so this
* component is purely a text overlay.
*
* Parent (`CampaignsPage`) drives the `campaign` prop, cycling on a timer
* or pinning to whichever marker the user clicked on the globe.
*/
export function HeroCampaignSpotlight({
campaign,
isLoading = false,
className,
}: HeroCampaignSpotlightProps) {
// useAuthor must be called unconditionally to keep hook order stable —
// when there's no campaign yet we pass an empty pubkey and ignore the
// (no-op) result below. Same for donations + BTC price.
const author = useAuthor(campaign?.pubkey ?? '');
const { data: stats } = useCampaignDonations(campaign?.aTag);
const { data: btcPrice } = useBtcPrice();
if (isLoading && !campaign) {
return (
<div className={cn('space-y-1.5', className)}>
<Skeleton className="h-5 w-52 bg-white/20" />
<Skeleton className="h-3 w-64 bg-white/20" />
<Skeleton className="h-3 w-40 bg-white/20" />
</div>
);
}
if (!campaign) return null;
const naddr = encodeCampaignNaddr(campaign);
const meta = author.data?.metadata;
const authorName = meta?.display_name || meta?.name || genUserName(campaign.pubkey);
const authorPicture = sanitizeUrl(meta?.picture);
const countryLabel = getCampaignCountryLabel(campaign);
return (
<div
className={cn(
// Compact text block over the photo — always white regardless of
// theme since the hero is always a dark-scrimed photo.
'space-y-1.5 text-white hero-text-shadow-soft',
className,
)}
>
<p className="text-base font-semibold leading-snug line-clamp-1">
{campaign.title}
</p>
{campaign.summary && (
<p className="text-xs text-white/80 line-clamp-2 max-w-xs">
{campaign.summary}
</p>
)}
{/* Progress / goal. Hand-rolled instead of using <CampaignProgress>
so we can tune the bar for legibility on top of a photo: dark
translucent track, glowing primary fill. When the campaign has no
goal tag, the bar is omitted entirely and we only show the raised
total. */}
{(() => {
const raised = stats?.totalSats ?? 0;
const goal = campaign.goalSats;
const hasGoal = !!goal && goal > 0;
const pct = hasGoal ? Math.min(100, Math.round((raised / goal!) * 100)) : 0;
return (
<div className="space-y-1.5 pt-1 max-w-xs">
{hasGoal && (
<div className="relative h-1.5 w-full overflow-hidden rounded-full bg-black/40 ring-1 ring-white/15">
<div
className="absolute inset-y-0 left-0 rounded-full bg-primary shadow-[0_0_8px_hsl(var(--primary)/0.7)] motion-safe:transition-[width] motion-safe:duration-500"
style={{ width: `${pct}%` }}
/>
</div>
)}
<div className="flex items-baseline justify-between gap-2 text-[11px] [text-shadow:none]">
<span className="font-semibold text-white">
{formatCampaignAmount(raised, btcPrice)}
{!hasGoal && <span className="ml-1 font-normal text-white/70">raised</span>}
</span>
{hasGoal && (
<span className="text-white/70">
of {formatCampaignAmount(goal!, btcPrice)} goal
</span>
)}
</div>
</div>
);
})()}
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-white/75 pt-0.5">
<span className="inline-flex items-center gap-1.5">
<Avatar className="size-4 ring-1 ring-white/40">
{authorPicture && <AvatarImage src={authorPicture} alt="" />}
<AvatarFallback className="text-[8px]">
{authorName.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<span className="font-medium">{authorName}</span>
</span>
{countryLabel && (
<span className="inline-flex items-center gap-1">
<MapPin className="size-3" />
<span className="truncate max-w-[16ch]">{countryLabel}</span>
</span>
)}
<Link
to={`/${naddr}`}
className="inline-flex items-center gap-1 font-medium text-primary hover:text-primary/80 focus-visible:outline-none focus-visible:underline"
>
View
<ArrowRight className="size-3" />
</Link>
</div>
</div>
);
}
+470
View File
@@ -0,0 +1,470 @@
import type { CSSProperties } from 'react';
import { useEffect, useMemo, useRef } from 'react';
import { LAND_RINGS } from '@/lib/landPolygons';
import { HOPE_PALETTE, type HopeHue } from '@/lib/hopePalette';
/** Geographic point used by the globe projection. */
interface GeoPoint {
/** Latitude in degrees, [-90, 90]. */
lat: number;
/** Longitude in degrees, [-180, 180]. */
lng: number;
}
/**
* Visual variant for a globe marker. Each kind gets its own glyph + halo
* so the three "threads" of Discover — campaigns, communities, and
* country activity — read distinctly without needing legend chrome.
*/
export type GlobeMarkerKind = 'campaign' | 'community' | 'country-pulse';
interface CampaignMarker extends GeoPoint {
/** Stable key for the marker (e.g. the campaign aTag). */
key: string;
/** Tooltip / accessible label shown on hover. */
label?: string;
/**
* Visual style of this marker. Defaults to `'campaign'` so existing
* callers (the campaigns hero) keep their heart markers unchanged.
*/
kind?: GlobeMarkerKind;
}
interface HeroGlobeProps {
/** Markers to plot on top of the globe — one per geo-located campaign. */
markers?: CampaignMarker[];
/**
* Marker the user has selected. The selected marker gets a stronger glow
* and a slightly larger heart so it reads as the "live" one.
*/
selectedKey?: string | null;
/** Fires when the user clicks a marker. */
onMarkerClick?: (key: string) => void;
/**
* Active hopeful hue. Drives the outer halo color and the back-lit
* limb tint so the globe agrees with the surrounding {@link HeroAtmosphere}.
*/
hue?: HopeHue;
/** Optional className applied to the outer container. */
className?: string;
/** Optional inline style applied to the outer container (e.g. fluid width via `clamp()`). */
style?: CSSProperties;
}
/** Pre-parsed land rings as arrays of {lat, lng} points. */
const LANDMASSES: readonly GeoPoint[][] = LAND_RINGS.map((flat) => {
const out: GeoPoint[] = [];
for (let i = 0; i < flat.length; i += 2) {
out.push({ lng: flat[i], lat: flat[i + 1] });
}
return out;
});
const RADIUS = 285;
const CENTER = 300;
/** Seconds per full revolution. Slow on purpose so the motion is ambient. */
const ROTATION_PERIOD_SECONDS = 140;
/**
* Orthographic projection: turns a (lat, lng) pair into 2D screen
* coordinates plus a `z` depth value. Points with `z <= 0` are on the
* back hemisphere and should be hidden (or drawn with low opacity).
*/
function project(lat: number, lng: number, rotationDeg: number) {
const phi = (lat * Math.PI) / 180;
// Subtract rotation so the globe appears to spin west-to-east.
const lambda = ((lng - rotationDeg) * Math.PI) / 180;
const cosPhi = Math.cos(phi);
const x = cosPhi * Math.sin(lambda);
const y = Math.sin(phi);
const z = cosPhi * Math.cos(lambda);
return {
x: CENTER + x * RADIUS,
// Negate so positive latitudes render upward in SVG.
y: CENTER - y * RADIUS,
z,
};
}
/**
* Slowly-rotating SVG globe rendered with pure SVG (no WebGL, no canvas).
*
* Visuals are intentionally warm and hand-drawn rather than satellite/HUD:
* - a soft cream sphere lit from the upper-left,
* - sandy-amber landmasses (real Natural Earth continent shapes,
* pre-simplified to ~1.5k vertices), and
* - small glowing marker dots for active campaigns.
*
* Rotation is driven by `requestAnimationFrame` and applied imperatively via
* refs so the component never re-renders during animation. Respects
* `prefers-reduced-motion` by holding at a static angle.
*/
export function HeroGlobe({
markers = [],
selectedKey = null,
onMarkerClick,
hue = HOPE_PALETTE[0],
className,
style,
}: HeroGlobeProps) {
const landRef = useRef<SVGGElement | null>(null);
const markersRef = useRef<SVGGElement | null>(null);
// Stable per-ring point counts so the animation loop knows how many polygon
// elements to update without re-reading the DOM each frame.
const ringSizes = useMemo(() => LANDMASSES.map((r) => r.length), []);
// Live refs so the rAF loop can read the latest markers / selection
// without retriggering the effect — otherwise every spotlight tick
// would tear down the loop and snap rotation back to 0°.
const markersRefValue = useRef(markers);
const selectedKeyRef = useRef(selectedKey);
useEffect(() => {
markersRefValue.current = markers;
selectedKeyRef.current = selectedKey;
}, [markers, selectedKey]);
useEffect(() => {
const prefersReducedMotion =
typeof window !== 'undefined' &&
window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
let rafId = 0;
let start: number | null = null;
const tick = (timestamp: number) => {
if (start === null) start = timestamp;
const elapsedSeconds = (timestamp - start) / 1000;
const rotation = prefersReducedMotion
? 25 // Hold at a flattering static angle.
: (elapsedSeconds / ROTATION_PERIOD_SECONDS) * 360;
// --- Landmass polygons ---
//
// For each ring we walk vertex-by-vertex projecting through the
// orthographic camera. Vertices on the *front* of the sphere
// (z > 0) are kept as-is. Vertices on the *back* (z < 0) would
// otherwise project on top of front-side land — orthographic
// projection collapses depth — so we drop them.
//
// Where a ring crosses the visible limb (front ↔ back) we emit an
// interpolated point on the limb itself, so polygons that wrap
// around the side of the globe close cleanly along the sphere's
// outline instead of cutting across the disc interior.
//
// We also fade rings out over a narrow band near the limb so they
// don't pop on/off when crossing z = 0. Anything with maxZ below
// FADE_OUT is considered fully hidden; rings between FADE_OUT and
// FADE_IN ease in/out.
const FADE_OUT = 0.0;
const FADE_IN = 0.08;
const landEl = landRef.current;
if (landEl) {
const polygons = landEl.children;
for (let i = 0; i < LANDMASSES.length; i++) {
const ring = LANDMASSES[i];
const polygon = polygons[i] as SVGPolygonElement | undefined;
if (!polygon) continue;
// First pass: project every vertex, remembering z so we can
// detect front/back transitions cheaply.
const n = ring.length;
const xs = new Array<number>(n);
const ys = new Array<number>(n);
const zs = new Array<number>(n);
let maxZ = -1;
for (let j = 0; j < n; j++) {
const p = project(ring[j].lat, ring[j].lng, rotation);
xs[j] = p.x;
ys[j] = p.y;
zs[j] = p.z;
if (p.z > maxZ) maxZ = p.z;
}
if (maxZ <= FADE_OUT) {
polygon.setAttribute('opacity', '0');
continue;
}
// Second pass: emit only the visible portion. For each edge we
// include the endpoint when it's in front, and any limb-crossing
// we step over gets an interpolated point on the sphere edge.
const parts: string[] = [];
for (let j = 0; j < n; j++) {
const k = (j + 1) % n;
const zj = zs[j];
const zk = zs[k];
if (zj > 0) parts.push(`${xs[j].toFixed(1)},${ys[j].toFixed(1)}`);
if ((zj > 0) !== (zk > 0)) {
// Find the parameter t in [0,1] along this edge where z=0.
const t = zj / (zj - zk);
const ex = xs[j] + (xs[k] - xs[j]) * t;
const ey = ys[j] + (ys[k] - ys[j]) * t;
// Project the limb point onto the actual sphere edge so it
// never lands inside the disc.
const dx = ex - CENTER;
const dy = ey - CENTER;
const d = Math.hypot(dx, dy) || 1;
const lx = CENTER + (dx / d) * RADIUS;
const ly = CENTER + (dy / d) * RADIUS;
parts.push(`${lx.toFixed(1)},${ly.toFixed(1)}`);
}
}
if (parts.length < 3) {
polygon.setAttribute('opacity', '0');
continue;
}
polygon.setAttribute('points', parts.join(' '));
// Smooth fade as rings come around the limb. `fade` clamps to
// [0,1] over the narrow FADE_OUT→FADE_IN band, then we keep
// adding the small depth-based dimming used before.
const fade = Math.min(1, Math.max(0, (maxZ - FADE_OUT) / (FADE_IN - FADE_OUT)));
polygon.setAttribute('opacity', (fade * Math.min(1, 0.55 + maxZ * 0.55)).toFixed(2));
}
}
// --- Campaign markers ---
const markersEl = markersRef.current;
const liveMarkers = markersRefValue.current;
const liveSelectedKey = selectedKeyRef.current;
if (markersEl) {
const groups = markersEl.children;
for (let i = 0; i < liveMarkers.length; i++) {
const m = liveMarkers[i];
const group = groups[i] as SVGGElement | undefined;
if (!group) continue;
const p = project(m.lat, m.lng, rotation);
if (p.z <= 0) {
group.setAttribute('opacity', '0');
// Pull off-canvas so backside markers don't intercept clicks.
group.setAttribute('transform', 'translate(-1000 -1000)');
continue;
}
// Selected marker scales up subtly to read as "you are here".
const scale = m.key === liveSelectedKey ? 1.35 : 1;
group.setAttribute(
'transform',
`translate(${p.x.toFixed(2)} ${p.y.toFixed(2)}) scale(${scale})`,
);
group.setAttribute('opacity', (0.55 + p.z * 0.45).toFixed(2));
}
}
if (!prefersReducedMotion) {
rafId = requestAnimationFrame(tick);
}
};
rafId = requestAnimationFrame(tick);
return () => cancelAnimationFrame(rafId);
// `markers` and `selectedKey` are read inside `tick` via refs above,
// so we deliberately omit them from this dep list to keep the
// rotation loop alive across spotlight cycles.
}, [ringSizes]);
return (
<div className={className} style={style}>
{/* Wrapper so the outer halo can sit behind the SVG. The halo is a
plain div (not part of the SVG) so its blur extends past the
sphere without needing a giant viewBox, and so we can drive it
with a CSS keyframe animation independent of the rotation. */}
<div className="relative size-full">
{/* Outer atmospheric halo. Scaled larger than the wrapper so light
spills out into the photo, blurred for softness, and tinted
with the active campaign's hopeful hue. Breathes slowly via
the .hero-globe-halo-breath class defined in index.css. */}
<div
className="hero-globe-halo-breath absolute inset-[-15%] pointer-events-none"
aria-hidden="true"
style={{
backgroundImage: `radial-gradient(closest-side, ${hue.glow} 0%, ${hue.rim} 30%, transparent 70%)`,
filter: 'blur(40px)',
// background-image isn't actually transitionable across
// gradient stops in CSS, but keeping the declaration here
// documents that the hue swap is driven by React re-renders
// synced to the HeroAtmosphere crossfade.
}}
/>
<svg
viewBox="0 0 600 600"
className="relative size-full"
role="img"
aria-label="Globe showing locations of active fundraising campaigns"
focusable="false"
>
<defs>
{/* Sphere base: warm dawn gold lit from the upper-left, fading
into a deeper honey shadow on the lower-right. The whole
sphere is meant to read as "lit from within" — like the
moment before sunrise — not as a slab of dirt. */}
<radialGradient id="hero-globe-base" cx="32%" cy="28%" r="78%">
<stop offset="0%" stopColor="hsl(46 100% 96% / 0.92)" />
<stop offset="40%" stopColor="hsl(38 90% 82% / 0.82)" />
<stop offset="100%" stopColor="hsl(28 65% 60% / 0.72)" />
</radialGradient>
{/* Back-lit limb light. Reads as light pooling on the inside of
the sphere edge — Earthrise rather than satellite. Tinted
with the active hopeful hue, kept narrow + low-opacity so it
feels like atmosphere, not a neon ring. */}
<radialGradient id="hero-globe-rim" cx="50%" cy="50%" r="50%">
<stop offset="86%" stopColor={hue.rim} stopOpacity="0" />
<stop offset="97%" stopColor={hue.rim} stopOpacity="0.55" />
<stop offset="100%" stopColor={hue.glow} stopOpacity="0" />
</radialGradient>
{/* Soft highlight in the upper-left to sell the sphere shape. */}
<radialGradient id="hero-globe-highlight" cx="30%" cy="25%" r="35%">
<stop offset="0%" stopColor="hsl(50 100% 98% / 0.58)" />
<stop offset="100%" stopColor="hsl(50 100% 98% / 0)" />
</radialGradient>
{/* Marker glow halo. Soft, warm, no pulsing. */}
<radialGradient id="hero-marker-glow" cx="50%" cy="50%" r="50%">
<stop offset="0%" stopColor="hsl(var(--primary))" stopOpacity="0.55" />
<stop offset="70%" stopColor="hsl(var(--primary))" stopOpacity="0.12" />
<stop offset="100%" stopColor="hsl(var(--primary))" stopOpacity="0" />
</radialGradient>
{/* Stronger halo used for the selected marker so it visibly leads
the eye to whatever the spotlight card is currently showing. */}
<radialGradient id="hero-marker-glow-active" cx="50%" cy="50%" r="50%">
<stop offset="0%" stopColor="hsl(var(--primary))" stopOpacity="0.9" />
<stop offset="55%" stopColor="hsl(var(--primary))" stopOpacity="0.3" />
<stop offset="100%" stopColor="hsl(var(--primary))" stopOpacity="0" />
</radialGradient>
{/* Clip everything to the sphere so polygons straddling the
terminator don't leak outside the circle. */}
<clipPath id="hero-globe-clip">
<circle cx={CENTER} cy={CENTER} r={RADIUS} />
</clipPath>
</defs>
{/* Base sphere with light shading. */}
<circle cx={CENTER} cy={CENTER} r={RADIUS} fill="url(#hero-globe-base)" />
{/* Landmasses, clipped to the sphere. */}
<g clipPath="url(#hero-globe-clip)">
<g
ref={landRef}
fill="hsl(30 55% 52%)"
stroke="hsl(28 50% 40% / 0.25)"
strokeWidth="0.3"
strokeLinejoin="round"
>
{LANDMASSES.map((_, i) => (
<polygon key={i} opacity={0} />
))}
</g>
</g>
{/* Warm highlight + rim shading sit above the land so the sphere
still reads as a lit ball, not a flat map. */}
<circle
cx={CENTER}
cy={CENTER}
r={RADIUS}
fill="url(#hero-globe-highlight)"
pointerEvents="none"
/>
<circle
cx={CENTER}
cy={CENTER}
r={RADIUS}
fill="url(#hero-globe-rim)"
pointerEvents="none"
/>
{/* Campaign markers — a small heart glyph with a warm glow halo.
Each marker is a button: clicking selects the campaign, which
the parent uses to populate the spotlight card.
On the Discover page the same `<g>` slots are reused for
community and country-pulse markers, distinguished by `m.kind`
and rendered with a softer glyph + halo so campaigns stay the
visual lead. */}
<g ref={markersRef}>
{markers.map((m) => {
const isSelected = m.key === selectedKey;
const kind: GlobeMarkerKind = m.kind ?? 'campaign';
return (
<g
key={m.key}
opacity={0}
transform="translate(-1000 -1000)"
role={onMarkerClick ? 'button' : undefined}
tabIndex={onMarkerClick ? 0 : undefined}
aria-label={m.label ?? 'View campaign'}
aria-pressed={onMarkerClick ? isSelected : undefined}
onClick={onMarkerClick ? () => onMarkerClick(m.key) : undefined}
onKeyDown={
onMarkerClick
? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onMarkerClick(m.key);
}
}
: undefined
}
style={{
cursor: onMarkerClick ? 'pointer' : undefined,
outline: 'none',
}}
>
{kind === 'campaign' ? (
<>
{/* Glow halo (stronger for the active marker). */}
<circle
r={isSelected ? 16 : 12}
fill={`url(#hero-marker-glow${isSelected ? '-active' : ''})`}
/>
{/* Heart glyph. Path is centered at the origin (~14×12 units)
so the parent <g>'s translate+scale lands it on the globe. */}
<path
d="M0,3.5 C-3.5,1 -7,-1.5 -7,-4.5 C-7,-7 -5,-8.5 -3,-8.5 C-1.5,-8.5 -0.5,-7.5 0,-6.5 C0.5,-7.5 1.5,-8.5 3,-8.5 C5,-8.5 7,-7 7,-4.5 C7,-1.5 3.5,1 0,3.5 Z"
fill="hsl(var(--primary))"
stroke="hsl(40 100% 98%)"
strokeWidth="0.6"
strokeLinejoin="round"
/>
{/* Tiny inner highlight to make the heart pop on the warm
landmass without needing a heavy outline. */}
<ellipse cx={-2.5} cy={-5.5} rx={1.5} ry={1} fill="hsl(40 100% 98% / 0.55)" />
</>
) : kind === 'community' ? (
<>
{/* Community: a softly-glowing ring. Reads as a circle of
people, gathered. Smaller than the heart so campaigns
stay the dominant signal. */}
<circle r={10} fill="url(#hero-marker-glow)" />
<circle
r={4.2}
fill="hsl(40 100% 96% / 0.92)"
stroke="hsl(28 65% 45% / 0.55)"
strokeWidth="0.7"
/>
<circle r={1.4} fill="hsl(28 70% 50%)" />
</>
) : (
<>
{/* Country pulse: tiny warm sun-dot, no halo button feel.
These are decorative — they trace where the world is
currently posting without inviting interaction. */}
<circle r={6} fill="url(#hero-marker-glow)" opacity={0.65} />
<circle r={1.8} fill="hsl(38 100% 70%)" />
</>
)}
{/* Transparent hit target — much easier to click/tap than the
tiny visible glyph, especially on touch. */}
<circle
r={14}
fill="transparent"
style={{ cursor: onMarkerClick ? 'pointer' : 'default' }}
/>
</g>
);
})}
</g>
</svg>
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
+15
View File
@@ -0,0 +1,15 @@
import { useInitialSync } from "@/hooks/useInitialSync";
/**
* Non-rendering component that runs the initial sync side effects
* (seeding relay list, blossom servers, encrypted settings, mute list
* into the query cache and app config) when a user logs in.
*
* Mounted at the top of the React tree so it runs in parallel with the
* rest of the app — it does NOT block render. NostrSync continues to
* keep settings up to date in the background after the initial pass.
*/
export function InitialSyncRunner() {
useInitialSync();
return null;
}
+4 -8
View File
@@ -4,11 +4,10 @@ import { AgoraBoltIcon } from '@/components/icons/AgoraBoltIcon';
import { Button } from '@/components/ui/button';
interface LandingHeroProps {
onLoginClick: () => void;
onSignupClick: () => void;
onJoinClick: () => void;
}
export function LandingHero({ onLoginClick, onSignupClick }: LandingHeroProps) {
export function LandingHero({ onJoinClick }: LandingHeroProps) {
return (
<div className="landing-hero">
{/* ── Hero Header ── */}
@@ -27,11 +26,8 @@ export function LandingHero({ onLoginClick, onSignupClick }: LandingHeroProps) {
</div>
<div className="flex gap-3 justify-center landing-hero-fade" style={{ animationDelay: '160ms' }}>
<Button onClick={onSignupClick} className="rounded-full px-6" size="sm">
Sign up
</Button>
<Button onClick={onLoginClick} variant="outline" className="rounded-full px-6" size="sm">
Log in
<Button onClick={onJoinClick} className="rounded-full px-6" size="sm">
Join
</Button>
<Button variant="outline" className="rounded-full px-6" size="sm" asChild>
<Link to="/help">FAQ</Link>
+2 -4
View File
@@ -13,9 +13,8 @@ import { ProfileSearchDropdown } from '@/components/ProfileSearchDropdown';
import { SidebarNavList } from '@/components/SidebarNavItem';
import { SidebarMoreMenu } from '@/components/SidebarMoreMenu';
import LoginDialog from '@/components/auth/LoginDialog';
import AuthDialog from '@/components/auth/AuthDialog';
import { FollowQRDialog } from '@/components/FollowQRDialog';
import { useOnboarding } from '@/hooks/useOnboarding';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useLoggedInAccounts, type Account } from '@/hooks/useLoggedInAccounts';
import { useLoginActions } from '@/hooks/useLoginActions';
@@ -75,7 +74,6 @@ export function LeftSidebar() {
const hasUnread = useHasUnreadNotifications();
const userProfileUrl = useProfileUrl(user?.pubkey ?? '', metadata);
const [loginDialogOpen, setLoginDialogOpen] = useState(false);
const { startSignup } = useOnboarding();
const [accountPopoverOpen, setAccountPopoverOpen] = useState(false);
const [followQROpen, setFollowQROpen] = useState(false);
const [editing, setEditing] = useState(false);
@@ -340,7 +338,7 @@ export function LeftSidebar() {
</div>
)}
<LoginDialog isOpen={loginDialogOpen} onClose={() => setLoginDialogOpen(false)} onLogin={() => setLoginDialogOpen(false)} onSignupClick={startSignup} />
<AuthDialog isOpen={loginDialogOpen} onClose={() => setLoginDialogOpen(false)} />
<FollowQRDialog open={followQROpen} onOpenChange={setFollowQROpen} />
</aside>
);
+5
View File
@@ -40,6 +40,11 @@ export function LinkFooter({ onNavigate }: LinkFooterProps) {
Privacy
</Link>
<Link to="/safety" className={chipClass} onClick={onNavigate}>
<Shield className={iconClass} />
Safety
</Link>
<a
href="https://gitlab.com/soapbox-pub/agora-3"
className={chipClass}
+2 -6
View File
@@ -3,7 +3,6 @@ import { Outlet } from 'react-router-dom';
import { LeftSidebar } from '@/components/LeftSidebar';
import { MobileTopBar } from '@/components/MobileTopBar';
import { MobileDrawer } from '@/components/MobileDrawer';
import { MobileBottomNav } from '@/components/MobileBottomNav';
import { FloatingComposeButton } from '@/components/FloatingComposeButton';
import { CursorFireEffect } from '@/components/CursorFireEffect';
import { Skeleton } from '@/components/ui/skeleton';
@@ -51,7 +50,7 @@ function PageSkeleton() {
/** Inner component that reads layout options from the context store. */
function MainLayoutInner() {
const { rightSidebar, showFAB = false, fabKind = 1, fabHref, onFabClick, fabIcon, fabMenu, wrapperClassName, noOverscroll, noMaxWidth, scrollContainer, hasSubHeader, hideTopBar, hideBottomNav } = useLayoutSnapshot();
const { rightSidebar, showFAB = false, fabKind = 1, fabHref, onFabClick, fabIcon, fabMenu, wrapperClassName, noOverscroll, noMaxWidth, scrollContainer, hasSubHeader, hideTopBar } = useLayoutSnapshot();
const [drawerOpen, setDrawerOpen] = useState(false);
const openDrawer = useCallback(() => setDrawerOpen(true), []);
const centerColumnRef = useRef<HTMLDivElement>(null);
@@ -109,16 +108,13 @@ function MainLayoutInner() {
(unset) falls back to the default. We distinguish these because
`??` would otherwise treat `null` the same as unset and render
the default — which silently breaks pages that intend to be
full-bleed (e.g. /world, /messages). */}
full-bleed (e.g. /world). */}
{rightSidebar === undefined
? <Suspense fallback={<div className="w-[300px] shrink-0 hidden xl:block" />}><WidgetSidebar /></Suspense>
: rightSidebar}
</Suspense>
</div>
{/* Mobile bottom nav - only on small screens, slides out on scroll */}
{!hideBottomNav && <MobileBottomNav />}
{/* Mobile FAB — fixed to viewport, hidden on desktop where the
in-column sticky FAB (above) takes over. Mirrors bottom nav
hide/show transition on scroll. */}
-61
View File
@@ -1,61 +0,0 @@
import { Shield, ShieldOff } from 'lucide-react';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { useMembersOnlyFilter } from '@/hooks/useMembersOnlyFilter';
import { cn } from '@/lib/utils';
interface MembersOnlyToggleProps {
/** Additional classes for the trigger button. */
className?: string;
}
/**
* Shield-icon toggle that controls the "members only" filter for community
* surfaces. When active, community feeds only show content authored by
* validated members. When inactive (default), the feed shows every event
* scoped to the community regardless of author.
*
* Per the flat-communities spec, members-only is a MAY feature — the
* protocol makes no recommendation, so the toggle is an opt-in UX choice.
*
* The preference is persisted in localStorage via `useMembersOnlyFilter` and
* is global across community surfaces (Activities feed, per-community
* Comments tab, etc.).
*/
export function MembersOnlyToggle({ className }: MembersOnlyToggleProps) {
const { membersOnly, toggle } = useMembersOnlyFilter();
const label = membersOnly ? 'Showing members only' : 'Showing everyone';
const hint = membersOnly
? 'Click to show posts from anyone scoped to this community.'
: 'Click to limit posts to validated community members.';
return (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={toggle}
aria-pressed={membersOnly}
aria-label={label}
className={cn(
'p-2 rounded-full transition-colors',
membersOnly
? 'text-primary hover:bg-primary/10'
: 'text-muted-foreground hover:bg-secondary',
className,
)}
>
{membersOnly
? <Shield className="size-5" />
: <ShieldOff className="size-5" />}
</button>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-[220px] text-center">
<p className="text-xs font-medium">{label}</p>
<p className="text-xs text-muted-foreground mt-0.5">{hint}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
+12 -22
View File
@@ -1,6 +1,5 @@
import { useCallback, useState } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import { Bell, Earth, Search, Users } from 'lucide-react';
import { AgoraBoltIcon } from '@/components/icons/AgoraBoltIcon';
import { cn } from '@/lib/utils';
@@ -8,9 +7,7 @@ import { selectionChanged } from '@/lib/haptics';
import { useHasUnreadNotifications } from '@/hooks/useHasUnreadNotifications';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useScrollDirection } from '@/hooks/useScrollDirection';
import { useAppContext } from '@/hooks/useAppContext';
import { useLayoutSnapshot } from '@/contexts/LayoutContext';
import { getSidebarItem } from '@/lib/sidebarItems';
import { ArcBackground, ARC_UP_OVERHANG_PX } from '@/components/ArcBackground';
import { MobileSearchSheet } from '@/components/MobileSearchSheet';
@@ -55,16 +52,11 @@ function NavItem({ icon: Icon, label, active, badge, onClick, to, size = 'md' }:
export function MobileBottomNav() {
const location = useLocation();
const queryClient = useQueryClient();
const { user } = useCurrentUser();
const hasUnread = useHasUnreadNotifications();
const { scrollContainer, noArcs } = useLayoutSnapshot();
const { hidden } = useScrollDirection(scrollContainer);
const { config } = useAppContext();
const homeItem = getSidebarItem(config.homePage);
const homePath = homeItem?.path ?? '/';
const [searchOpen, setSearchOpen] = useState(false);
const handleSearchClick = useCallback((e: React.MouseEvent) => {
@@ -73,21 +65,19 @@ export function MobileBottomNav() {
setSearchOpen((v) => !v);
}, []);
const handleFeedClick = useCallback((e: React.MouseEvent) => {
const handleWalletClick = useCallback((e: React.MouseEvent) => {
selectionChanged();
setSearchOpen(false);
if (location.pathname === '/' || location.pathname === homePath) {
if (location.pathname === '/wallet') {
e.preventDefault();
window.scrollTo({ top: 0, behavior: 'smooth' });
void queryClient.invalidateQueries({ queryKey: ['feed'] });
void queryClient.invalidateQueries({ queryKey: ['ditto-curated-feed'] });
}
}, [location.pathname, homePath, queryClient]);
}, [location.pathname]);
// Hide the nav when search sheet is open so it doesn't compete for space
const isHidden = hidden || searchOpen;
const isOnFeed = location.pathname === '/' || location.pathname === homePath;
const isOnWallet = location.pathname === '/wallet';
const isOnCommunities = location.pathname === '/communities' || location.pathname.startsWith('/communities/');
const isOnWorld = location.pathname === '/world' || location.pathname.startsWith('/world/');
const isOnNotifications = location.pathname === '/notifications';
@@ -98,7 +88,7 @@ export function MobileBottomNav() {
<nav
className={cn(
'fixed bottom-0 left-0 right-0 z-40 sidebar:hidden will-change-transform',
'fixed bottom-0 left-0 right-0 z-40 will-change-transform',
'transition-transform duration-300 ease-in-out',
)}
style={isHidden ? hiddenStyle : undefined}
@@ -117,10 +107,10 @@ export function MobileBottomNav() {
size="sm"
/>
{/* Communities */}
{/* Organizations */}
<NavItem
icon={Users}
label="Communities"
label="Organize"
active={isOnCommunities}
to="/communities"
onClick={() => { selectionChanged(); setSearchOpen(false); }}
@@ -151,11 +141,11 @@ export function MobileBottomNav() {
</div>
{/* Apex Feed button — Agora bolt mark cradled in the V notch, with label below. */}
{/* Apex Wallet button — Agora bolt mark cradled in the V notch. */}
<Link
to={homePath}
onClick={handleFeedClick}
aria-label={homeItem?.label ?? 'Feed'}
to="/wallet"
onClick={handleWalletClick}
aria-label="Wallet"
className={cn(
'absolute left-1/2 -translate-x-1/2 z-10 -top-6',
'flex items-center',
@@ -165,7 +155,7 @@ export function MobileBottomNav() {
<AgoraBoltIcon
className={cn(
'size-16 drop-shadow-md',
isOnFeed && 'drop-shadow-[0_0_8px_hsl(var(--primary)/0.6)]',
isOnWallet && 'drop-shadow-[0_0_8px_hsl(var(--primary)/0.6)]',
)}
/>
</Link>
+2 -6
View File
@@ -9,9 +9,8 @@ import { SidebarMoreMenu } from '@/components/SidebarMoreMenu';
import { LoginArea } from '@/components/auth/LoginArea';
import { LinkFooter } from '@/components/LinkFooter';
import { EmojifiedText } from '@/components/CustomEmoji';
import LoginDialog from '@/components/auth/LoginDialog';
import AuthDialog from '@/components/auth/AuthDialog';
import { FollowQRDialog } from '@/components/FollowQRDialog';
import { useOnboarding } from '@/hooks/useOnboarding';
import { genUserName } from '@/lib/genUserName';
import { VerifiedNip05Text } from '@/components/Nip05Badge';
import { useCurrentUser } from '@/hooks/useCurrentUser';
@@ -51,7 +50,6 @@ export function MobileDrawer({ open, onOpenChange }: MobileDrawerProps) {
const [accountExpanded, setAccountExpanded] = useState(false);
const [loginDialogOpen, setLoginDialogOpen] = useState(false);
const [followQROpen, setFollowQROpen] = useState(false);
const { startSignup } = useOnboarding();
const { theme, customTheme, themes } = useTheme();
// NIP-38 status
@@ -369,11 +367,9 @@ export function MobileDrawer({ open, onOpenChange }: MobileDrawerProps) {
</SheetContent>
</Sheet>
<LoginDialog
<AuthDialog
isOpen={loginDialogOpen}
onClose={() => setLoginDialogOpen(false)}
onLogin={() => setLoginDialogOpen(false)}
onSignupClick={startSignup}
/>
<FollowQRDialog open={followQROpen} onOpenChange={setFollowQROpen} />
</>
-32
View File
@@ -392,38 +392,6 @@ export function NostrSync() {
changed = true;
}
if (encryptedSettings.messaging) {
type MessagingSettings = NonNullable<typeof current.messaging>;
const currentMessaging: MessagingSettings = current.messaging ?? {};
const remoteMessaging = encryptedSettings.messaging;
const scalarKeys: Array<Exclude<keyof MessagingSettings, "discoveryRelays">> = [
"enabled",
"relayMode",
"protocolMode",
"renderInlineMedia",
"soundEnabled",
"soundId",
"devMode",
];
const scalarChanged = scalarKeys.some((key) => {
const incoming = remoteMessaging[key];
return incoming !== undefined && incoming !== currentMessaging[key];
});
const discoveryRelaysChanged =
remoteMessaging.discoveryRelays !== undefined &&
JSON.stringify(remoteMessaging.discoveryRelays) !==
JSON.stringify(currentMessaging.discoveryRelays);
const messagingChanged = scalarChanged || discoveryRelaysChanged;
if (messagingChanged) {
updates.messaging = { ...currentMessaging, ...remoteMessaging };
changed = true;
}
}
// Return the same reference if nothing changed to prevent re-render
return changed ? updates : current;
});
+176 -115
View File
@@ -7,6 +7,7 @@ import {
GitBranch,
GitPullRequest,
Mail,
Megaphone,
MessageCircle,
Rocket,
MoreHorizontal,
@@ -30,6 +31,7 @@ import {
PodcastEpisodeContent,
PodcastTrailerContent,
} from "@/components/AudioKindContent";
import { ActionContent } from "@/components/ActionContent";
import { BadgeContent } from "@/components/BadgeContent";
import { CommunityContent } from "@/components/CommunityContent";
import { CalendarEventContent } from "@/components/CalendarEventContent";
@@ -37,7 +39,7 @@ import {
ColorMomentContent,
ColorMomentEyeButton,
} from "@/components/ColorMomentContent";
import { CommentContext, CountryCommentPill, CountryFlagBackdrop, useIsCountryRooted } from "@/components/CommentContext";
import { CommentContext, CountryCommentPill, CountryFlagBackdrop } from "@/components/CommentContext";
import { CommunityContentWarning } from "@/components/CommunityContentWarning";
import { ContentWarningGuard } from "@/components/ContentWarningGuard";
import { EmojifiedText, ReactionEmoji } from "@/components/CustomEmoji";
@@ -90,7 +92,10 @@ import { useProfileUrl } from "@/hooks/useProfileUrl";
import { toast } from "@/hooks/useToast";
import { useEventStats } from "@/hooks/useTrending";
import { canZap } from "@/lib/canZap";
import { extractZapAmount, extractZapSender, extractZapMessage } from "@/hooks/useEventInteractions";
import { extractZapSender, extractZapMessage } from "@/hooks/useEventInteractions";
import { getZapAmountSats } from "@/lib/zapHelpers";
import { satsToUSD } from "@/lib/bitcoin";
import { useBtcPrice } from "@/hooks/useBtcPrice";
import { getContentWarning } from "@/lib/contentWarning";
import { genUserName } from "@/lib/genUserName";
import { getDisplayName } from "@/lib/getDisplayName";
@@ -241,6 +246,10 @@ interface NoteCardProps {
highlight?: boolean;
/** If true, suppress the kind-derived action header (e.g. "created a badge"). Used when the parent already provides context. */
hideKindHeader?: boolean;
/** Override the NIP-22 context row prefix. Used by synthetic zap cards. */
commentContextPrefix?: string;
/** Event used for actions/navigation when the displayed card is synthetic. */
actionEvent?: NostrEvent;
}
/** Gets a tag value by name. */
@@ -304,6 +313,58 @@ function isDeprecatedFollowSet(event: NostrEvent): boolean {
return false;
}
function isStringTag(value: unknown): value is string[] {
return Array.isArray(value) && value.every((item) => typeof item === "string");
}
function getZapRequestTags(event: NostrEvent): string[][] {
if (event.kind !== 9735) return [];
const description = event.tags.find(([name]) => name === "description")?.[1];
if (!description) return [];
try {
const parsed = JSON.parse(description) as unknown;
if (!parsed || typeof parsed !== "object" || !("tags" in parsed)) return [];
const tags = (parsed as { tags?: unknown }).tags;
if (!Array.isArray(tags)) return [];
return tags.filter(isStringTag);
} catch {
return [];
}
}
function findZapTargetTag(event: NostrEvent, requestTags: string[][], name: string): string[] | undefined {
return event.tags.find(([tagName]) => tagName === name) ?? requestTags.find(([tagName]) => tagName === name);
}
function buildZapCommentEvent(event: NostrEvent, requestTags: string[][], senderPubkey: string, content: string): NostrEvent {
const tags: string[][] = [];
const aTag = findZapTargetTag(event, requestTags, "a");
const eTag = findZapTargetTag(event, requestTags, "e");
const recipientTag = findZapTargetTag(event, requestTags, "p");
const kindTag = findZapTargetTag(event, requestTags, "K") ?? findZapTargetTag(event, requestTags, "k");
if (aTag?.[1]) {
tags.push(["A", aTag[1], aTag[2] ?? ""]);
const targetKind = kindTag?.[1] ?? aTag[1].split(":")[0];
if (targetKind) tags.push(["K", targetKind]);
} else if (eTag?.[1]) {
tags.push(["E", eTag[1], eTag[2] ?? "", eTag[3] ?? recipientTag?.[1] ?? ""]);
if (kindTag?.[1]) tags.push(["K", kindTag[1]]);
if (recipientTag?.[1]) tags.push(["P", recipientTag[1]]);
} else if (recipientTag?.[1]) {
tags.push(["A", `0:${recipientTag[1]}:`], ["K", "0"]);
}
return {
...event,
pubkey: senderPubkey,
kind: 1111,
content,
tags,
};
}
export const NoteCard = memo(function NoteCard({
event,
className,
@@ -314,19 +375,28 @@ export const NoteCard = memo(function NoteCard({
threadedLast,
highlight,
hideKindHeader,
commentContextPrefix,
actionEvent,
}: NoteCardProps) {
const actionTarget = actionEvent ?? event;
const { config } = useAppContext();
const { user } = useCurrentUser();
const author = useAuthor(event.pubkey);
const zapSenderPubkey = useMemo(() => event.kind === 9735 ? extractZapSender(event) : '', [event]);
const zapSender = useAuthor(zapSenderPubkey || undefined);
const zapSenderMeta = zapSender.data?.metadata;
const zapSenderName = getDisplayName(zapSenderMeta, zapSenderPubkey);
const zapSenderUrl = useProfileUrl(zapSenderPubkey, zapSenderMeta);
const actionAuthor = useAuthor(actionEvent?.pubkey);
// Kind 9735 (Lightning zap) sender lives in the receipt's `P` tag / embedded
// zap-request `pubkey`; kind 8333 (on-chain Bitcoin zap) is signed by the
// donor directly so the event's own pubkey IS the sender.
const zapSenderPubkey = useMemo(() => {
if (event.kind === 9735) return extractZapSender(event);
if (event.kind === 8333) return event.pubkey;
return '';
}, [event]);
const zapRequestTags = useMemo(() => getZapRequestTags(event), [event]);
const pollVoteLabel = usePollVoteLabel(event);
const metadata = author.data?.metadata;
const actionMetadata = actionEvent ? actionAuthor.data?.metadata : metadata;
const displayName = getDisplayName(metadata, event.pubkey);
const nip05 = metadata?.nip05;
const { data: nip05Verified, isPending: nip05Pending } = useNip05Verify(
@@ -334,14 +404,17 @@ export const NoteCard = memo(function NoteCard({
event.pubkey,
);
const profileUrl = useProfileUrl(event.pubkey, metadata);
const encodedId = useMemo(() => encodeEventId(event), [event]);
const { data: stats } = useEventStats(event.id, event);
const encodedId = useMemo(() => encodeEventId(actionTarget), [actionTarget]);
const { data: stats } = useEventStats(actionTarget.id, actionTarget);
// Cached BTC→USD spot price. Always queried (cheap, shared cache key) so the
// zap-card layout below can render amounts as USD when available.
const { data: btcPrice } = useBtcPrice();
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
const [replyOpen, setReplyOpen] = useState(false);
// Check if the current user can zap this event's author
// TODO: Enable zapping split-recipient NIP-75 goals once zap split payments are supported.
const canZapAuthor = user && canZap(metadata) && !hasGoalZapSplits(event);
const canZapAuthor = user && canZap(actionMetadata) && !hasGoalZapSplits(actionTarget);
const { onClick: openPost, onAuxClick: auxOpenPost } = useOpenPost(
`/${encodedId}`,
@@ -400,6 +473,7 @@ export const NoteCard = memo(function NoteCard({
const isBadge = isBadgeDefinition || isProfileBadges;
const isCommunity = event.kind === 34550;
const isZapGoal = event.kind === 9041;
const isAction = event.kind === 36639;
const isReaction = event.kind === 7;
const isPollVote = event.kind === 1018;
const isRepost = event.kind === 6 || event.kind === 16;
@@ -425,7 +499,7 @@ export const NoteCard = memo(function NoteCard({
const isEncryptedDM = event.kind === 4;
const isLetter = event.kind === 8211;
const isVanish = event.kind === 62;
const isZap = event.kind === 9735;
const isZap = event.kind === 9735 || event.kind === 8333;
const isProfile = event.kind === 0;
const isDevKind = isGitRepo || isPatch || isPullRequest || isCustomNip || isNsite;
const isTextNote =
@@ -445,6 +519,7 @@ export const NoteCard = memo(function NoteCard({
!isBadge &&
!isCommunity &&
!isZapGoal &&
!isAction &&
!isReaction &&
!isPollVote &&
!isRepost &&
@@ -463,10 +538,6 @@ export const NoteCard = memo(function NoteCard({
!isProfile;
const isComment = event.kind === 1111;
// True when CountryFlagBackdrop is rendering — used to flip the header
// strip to high-contrast white text so the author/timestamp stay legible
// against the dark wash overlaid on the flag.
const flagMode = useIsCountryRooted(event);
const isReply = isTextNote && !isComment && isReplyEvent(event);
// Find all people being replied to (for "Replying to @user1 and @user2")
@@ -553,7 +624,7 @@ export const NoteCard = memo(function NoteCard({
const contentBlock = (
<CommunityContentWarning event={event}>
{/* Reply context (kind 1) or comment context (kind 1111) — shown above content */}
{isComment && <CommentContext event={event} />}
{isComment && <CommentContext event={event} prefix={commentContextPrefix} />}
{isReply && (
<ReplyContext
pubkeys={replyToPubkeys}
@@ -609,6 +680,9 @@ export const NoteCard = memo(function NoteCard({
) : isZapGoal ? (
<GoalCard event={event} />
) : isAction ? (
<ActionContent event={event} />
) : isVoiceMessage ? (
<VoiceMessagePlayer event={event} />
) : isCalendarEvent ? (
@@ -742,53 +816,59 @@ export const NoteCard = memo(function NoteCard({
// ── Shared action buttons (used in all layouts) ──
const actionButtons = (
<div className="flex items-center gap-5 mt-3 -ml-2">
<div className="flex flex-wrap items-center gap-1 sm:gap-2 mt-3">
<button
className="flex items-center gap-1.5 p-2 rounded-full text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors"
className="inline-flex items-center gap-2 h-9 px-3 rounded-full text-sm font-medium text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors"
title="Reply"
onClick={(e) => {
e.stopPropagation();
setReplyOpen(true);
}}
>
<MessageCircle className="size-5" />
{stats?.replies ? (
<span className="text-sm tabular-nums">{formatNumber(stats.replies)}</span>
<MessageCircle className="size-[18px]" />
{stats?.replies ? (
<span className="tabular-nums">{formatNumber(stats.replies)}</span>
) : null}
</button>
<RepostMenu event={actionTarget}>
{(isReposted: boolean) => (
<button
className={cn(
"inline-flex items-center gap-2 h-9 px-3 rounded-full text-sm font-medium transition-colors",
isReposted
? "text-accent hover:text-accent/80 hover:bg-accent/10"
: "text-muted-foreground hover:text-accent hover:bg-accent/10",
)}
title={isReposted ? "Undo repost" : "Repost"}
>
<RepostIcon className="size-[18px]" />
{stats?.reposts || stats?.quotes ? (
<span className="tabular-nums">
{formatNumber((stats?.reposts ?? 0) + (stats?.quotes ?? 0))}
</span>
) : null}
</button>
)}
</RepostMenu>
<RepostMenu event={event}>
{(isReposted: boolean) => (
<button
className={`flex items-center gap-1.5 p-2 rounded-full transition-colors ${isReposted ? "text-accent hover:text-accent/80 hover:bg-accent/10" : "text-muted-foreground hover:text-accent hover:bg-accent/10"}`}
title={isReposted ? "Undo repost" : "Repost"}
>
<RepostIcon className="size-5" />
{stats?.reposts || stats?.quotes ? (
<span className="text-sm tabular-nums">
{formatNumber((stats?.reposts ?? 0) + (stats?.quotes ?? 0))}
</span>
) : null}
</button>
)}
</RepostMenu>
<ReactionButton
eventId={event.id}
eventPubkey={event.pubkey}
eventKind={event.kind}
<ReactionButton
eventId={actionTarget.id}
eventPubkey={actionTarget.pubkey}
eventKind={actionTarget.kind}
reactionCount={stats?.reactions}
variant="chip"
/>
{canZapAuthor && (
<ZapDialog target={event}>
<ZapDialog target={actionTarget}>
<button
className="flex items-center gap-1.5 p-2 rounded-full text-muted-foreground hover:text-amber-500 hover:bg-amber-500/10 transition-colors"
className="inline-flex items-center gap-2 h-9 px-3 rounded-full text-sm font-medium text-muted-foreground hover:text-amber-500 hover:bg-amber-500/10 transition-colors"
title="Zap"
>
<Zap className="size-5" />
<Zap className="size-[18px]" />
{stats?.zapAmount ? (
<span className="text-sm tabular-nums">
<span className="tabular-nums">
{formatNumber(stats.zapAmount)}
</span>
) : null}
@@ -796,8 +876,10 @@ export const NoteCard = memo(function NoteCard({
</ZapDialog>
)}
<div className="flex-1" />
<button
className="p-2 rounded-full text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors sidebar:hidden"
className="inline-flex items-center justify-center h-9 w-9 rounded-full text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors sidebar:hidden"
title="Share"
onClick={async (e) => {
e.stopPropagation();
@@ -807,18 +889,18 @@ export const NoteCard = memo(function NoteCard({
if (result === "copied") toast({ title: "Link copied to clipboard" });
}}
>
<Share2 className="size-5" />
<Share2 className="size-[18px]" />
</button>
<button
className="p-2 rounded-full text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors"
className="inline-flex items-center justify-center h-9 w-9 rounded-full text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors"
title="More"
onClick={(e) => {
e.stopPropagation();
setMoreMenuOpen(true);
}}
>
<MoreHorizontal className="size-5" />
<MoreHorizontal className="size-[18px]" />
</button>
</div>
);
@@ -849,8 +931,8 @@ export const NoteCard = memo(function NoteCard({
{!compact && (
<>
{actionButtons}
<NoteMoreMenu event={event} open={moreMenuOpen} onOpenChange={setMoreMenuOpen} />
<ReplyComposeModal event={event} open={replyOpen} onOpenChange={setReplyOpen} />
<NoteMoreMenu event={actionTarget} open={moreMenuOpen} onOpenChange={setMoreMenuOpen} />
<ReplyComposeModal event={actionTarget} open={replyOpen} onOpenChange={setReplyOpen} />
</>
)}
</div>
@@ -873,12 +955,12 @@ export const NoteCard = memo(function NoteCard({
<>
{actionButtons}
<NoteMoreMenu
event={event}
event={actionTarget}
open={moreMenuOpen}
onOpenChange={setMoreMenuOpen}
/>
<ReplyComposeModal
event={event}
event={actionTarget}
open={replyOpen}
onOpenChange={setReplyOpen}
/>
@@ -928,33 +1010,36 @@ export const NoteCard = memo(function NoteCard({
);
}
// ── Zap receipt layout (kind 9735) ──
// ── Zap receipt layout (kind 9735 Lightning, kind 8333 on-chain Bitcoin) ──
// Render as a synthetic NIP-22 card so spacing, header, body, and actions
// stay identical to comments while keeping actions tied to the zap receipt.
if (isZap) {
const zapAmountSats = Math.floor(extractZapAmount(event) / 1000);
const zapMessage = extractZapMessage(event);
const iconSize = threaded || threadedLast ? "size-10" : "size-11";
const zapAmountSats = getZapAmountSats(event);
const zapMessage = (event.kind === 8333 ? event.content : extractZapMessage(event)).trim();
const usdLabel = btcPrice ? satsToUSD(zapAmountSats, btcPrice) : undefined;
const satsLabel = `${formatNumber(zapAmountSats)} ${zapAmountSats === 1 ? 'sat' : 'sats'}`;
const amountText = usdLabel ?? satsLabel;
const donationPrefix = zapAmountSats > 0 ? `Donated ${amountText} to` : "Donated to";
const zapCommentEvent = buildZapCommentEvent(
event,
zapRequestTags,
zapSenderPubkey || event.pubkey,
zapMessage,
);
return (
<ActivityCard
icon={
<div className={cn("flex items-center justify-center rounded-full bg-amber-500/10 shrink-0", iconSize)}>
<Zap className="size-5 text-amber-500 fill-amber-500" />
</div>
}
actorRow={
<ActorRow pubkey={zapSenderPubkey} profileUrl={zapSenderUrl} picture={zapSenderMeta?.picture}
displayName={zapSenderName} authorEvent={zapSender.data?.event} isLoading={zapSender.isLoading} label="zapped" timestampLabel={timeAgo(event.created_at)}
extra={zapAmountSats > 0 ? (
<span className="text-sm font-semibold text-amber-500 shrink-0">
{formatNumber(zapAmountSats)} {zapAmountSats === 1 ? 'sat' : 'sats'}
</span>
) : undefined}
/>
}
threaded={threaded} threadedLast={threadedLast} threadedLineClassName={threadedLineClassName}
className={className} onClick={handleCardClick} onAuxClick={handleAuxClick}
>
{zapMessage && <p className="text-xs text-muted-foreground italic mt-1">&ldquo;{zapMessage}&rdquo;</p>}
</ActivityCard>
<NoteCard
event={zapCommentEvent}
actionEvent={event}
className={className}
compact={compact}
threaded={threaded}
threadedLineClassName={threadedLineClassName}
threadedLast={threadedLast}
highlight={highlight}
hideKindHeader
commentContextPrefix={donationPrefix}
/>
);
}
@@ -1031,12 +1116,7 @@ export const NoteCard = memo(function NoteCard({
absolute backdrop layer rendered by CountryFlagBackdrop. */}
<div className="relative">
{threadedKindHeader && (
<div
className={cn(
flagMode &&
"text-white [&_a]:text-white [&_.text-muted-foreground]:text-white/85 [text-shadow:0_1px_3px_rgba(0,0,0,0.85),0_2px_8px_rgba(0,0,0,0.55)]",
)}
>
<div>
{threadedKindHeader}
</div>
)}
@@ -1049,16 +1129,7 @@ export const NoteCard = memo(function NoteCard({
</div>
<div className={cn("flex-1 min-w-0", threaded && "pb-3")}>
<div className="flex items-center justify-between gap-2">
{/* authorInfo wears flag-mode white text + shadow when a flag
backdrop is showing; the pill stays in its own gradient
styling so we scope the flip to authorInfo only. */}
<div
className={cn(
"min-w-0 flex-1",
flagMode &&
"text-white [&_a]:text-white [&_.text-muted-foreground]:text-white/85 [text-shadow:0_1px_3px_rgba(0,0,0,0.85),0_2px_8px_rgba(0,0,0,0.55)]",
)}
>
<div className="min-w-0 flex-1">
{authorInfo}
</div>
<CountryCommentPill event={event} className="shrink-0 [text-shadow:none]" />
@@ -1066,12 +1137,12 @@ export const NoteCard = memo(function NoteCard({
{contentBlock}
{actionButtons}
<NoteMoreMenu
event={event}
event={actionTarget}
open={moreMenuOpen}
onOpenChange={setMoreMenuOpen}
/>
<ReplyComposeModal
event={event}
event={actionTarget}
open={replyOpen}
onOpenChange={setReplyOpen}
/>
@@ -1097,17 +1168,7 @@ export const NoteCard = memo(function NoteCard({
{/* Foreground wrapper — `relative` lifts the entire post above the
absolute backdrop layer rendered by CountryFlagBackdrop. */}
<div className="relative">
{/* Header strip — sits over the flag backdrop's dark wash when
present. Flips to white text with a drop shadow in flag mode so
the name/handle/timestamp stay legible against any flag.
CountryCommentPill is rendered outside this wrapper so it keeps
its own gradient/text-shadow styling untouched. */}
<div
className={cn(
flagMode &&
"text-white [&_a]:text-white [&_.text-muted-foreground]:text-white/85 [text-shadow:0_1px_3px_rgba(0,0,0,0.85),0_2px_8px_rgba(0,0,0,0.55)]",
)}
>
<div>
{/* Action header — repost takes priority, otherwise derived from event kind */}
{repostedBy ? (
<EventActionHeader
@@ -1170,12 +1231,12 @@ export const NoteCard = memo(function NoteCard({
<>
{actionButtons}
<NoteMoreMenu
event={event}
event={actionTarget}
open={moreMenuOpen}
onOpenChange={setMoreMenuOpen}
/>
<ReplyComposeModal
event={event}
event={actionTarget}
open={replyOpen}
onOpenChange={setReplyOpen}
/>
@@ -1749,8 +1810,8 @@ const KIND_HEADER_MAP: Record<number, KindHeaderConfig> = {
},
34550: {
icon: Users,
action: (event) => publishedAtAction(event, { created: "created a", updated: "updated a", fallback: "shared a" }),
noun: "community",
action: (event) => publishedAtAction(event, { created: "created an", updated: "updated an", fallback: "shared an" }),
noun: "organization",
nounRoute: "/communities",
},
30009: {
@@ -1836,10 +1897,10 @@ const KIND_HEADER_MAP: Record<number, KindHeaderConfig> = {
action: "zapped",
},
36639: {
icon: Zap,
action: (event) => publishedAtAction(event, { created: "posted an", updated: "updated an", fallback: "posted an" }),
noun: "action",
nounRoute: "/actions",
icon: Megaphone,
action: (event) => publishedAtAction(event, { created: "created a", updated: "updated a", fallback: "created a" }),
noun: "pledge",
nounRoute: "/pledges",
},
39089: {
icon: PartyPopper,
+7 -7
View File
@@ -128,7 +128,7 @@ describe('NoteContent', () => {
expect(nostrHashtag).toHaveAttribute('href', '/t/nostr');
});
it('generates deterministic names for users without metadata and styles them differently', async () => {
it('falls back to @Anonymous for users without metadata and styles them differently', async () => {
// Use a valid npub for testing
const event: NostrEvent = {
id: 'test-id',
@@ -146,17 +146,17 @@ describe('NoteContent', () => {
</TestApp>
);
// The mention should be rendered with a deterministic name
// The mention should be rendered with the Anonymous fallback
const mention = await screen.findByRole('link');
expect(mention).toBeInTheDocument();
// Should have muted styling for generated names (muted-foreground instead of primary)
// Should have muted styling for fallback names (muted-foreground instead of primary)
expect(mention).toHaveClass('text-muted-foreground');
expect(mention).not.toHaveClass('text-primary');
// The text should start with @ and contain a generated name (not a truncated npub)
// The text should be the Anonymous fallback (not a truncated npub)
const linkText = mention.textContent;
expect(linkText).not.toMatch(/^@npub1/); // Should not be a truncated npub
expect(linkText).toEqual("@Swift Falcon");
expect(linkText).toEqual('@Anonymous');
});
});
+17 -42
View File
@@ -17,7 +17,6 @@ import {
Check,
Radio,
ShieldBan,
Ban,
} from 'lucide-react';
import {
Dialog,
@@ -206,7 +205,6 @@ export function NoteMoreMenu({ event, open, onOpenChange }: NoteMoreMenuProps) {
// These states live here (not in NoteMoreMenuContent) so they persist after the menu closes
const [reportOpen, setReportOpen] = useState(false);
const [banContentOpen, setBanContentOpen] = useState(false);
const [banMemberOpen, setBanMemberOpen] = useState(false);
const [addToListOpen, setAddToListOpen] = useState(false);
const [eventJsonOpen, setEventJsonOpen] = useState(false);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
@@ -262,10 +260,6 @@ export function NoteMoreMenu({ event, open, onOpenChange }: NoteMoreMenuProps) {
onOpenChange(false);
setTimeout(() => setBanContentOpen(true), 150);
}}
onBanMember={() => {
onOpenChange(false);
setTimeout(() => setBanMemberOpen(true), 150);
}}
onAddToList={() => {
onOpenChange(false);
setTimeout(() => setAddToListOpen(true), 150);
@@ -293,23 +287,13 @@ export function NoteMoreMenu({ event, open, onOpenChange }: NoteMoreMenuProps) {
)}
{communityContext?.canBan && (
<>
<BanConfirmDialog
mode="content"
eventId={event.id}
targetPubkey={event.pubkey}
communityATag={communityContext.communityATag}
open={banContentOpen}
onOpenChange={setBanContentOpen}
/>
<BanConfirmDialog
mode="member"
targetPubkey={event.pubkey}
communityATag={communityContext.communityATag}
open={banMemberOpen}
onOpenChange={setBanMemberOpen}
/>
</>
<BanConfirmDialog
eventId={event.id}
targetPubkey={event.pubkey}
communityATag={communityContext.communityATag}
open={banContentOpen}
onOpenChange={setBanContentOpen}
/>
)}
<AddToListDialog
@@ -357,13 +341,12 @@ interface NoteMoreMenuContentProps extends NoteMoreMenuProps {
communityContext?: CommunityMenuContext;
onReport: () => void;
onBanContent: () => void;
onBanMember: () => void;
onAddToList: () => void;
onViewEventJson: () => void;
onDelete: () => void;
}
function NoteMoreMenuContent({ event, open, onOpenChange, communityContext, onReport, onBanContent, onBanMember, onAddToList, onViewEventJson, onDelete }: NoteMoreMenuContentProps) {
function NoteMoreMenuContent({ event, open, onOpenChange, communityContext, onReport, onBanContent, onAddToList, onViewEventJson, onDelete }: NoteMoreMenuContentProps) {
const navigate = useNavigate();
const { user } = useCurrentUser();
const { isBookmarked, toggleBookmark } = useBookmarks();
@@ -520,11 +503,11 @@ function NoteMoreMenuContent({ event, open, onOpenChange, communityContext, onRe
<span className="text-muted-foreground shrink-0">·</span>
<span className="text-muted-foreground shrink-0 text-xs">{timeAgo(event.created_at)}</span>
</div>
<div className="mt-0.5 text-sm text-muted-foreground line-clamp-3 max-h-[4.5em] overflow-hidden">
<div className="mt-0.5 text-sm text-muted-foreground line-clamp-3 overflow-wrap-anywhere">
{/^[A-Za-z0-9+/=_-]{20,}$/.test(event.content.trim()) ? (
<span className="italic">Encrypted content</span>
) : (
<NoteContent event={event} className="text-sm leading-relaxed" disableEmbeds />
<NoteContent event={event} className="text-sm leading-snug whitespace-normal" disableEmbeds disableNoteEmbeds as="span" />
)}
</div>
</div>
@@ -592,26 +575,18 @@ function NoteMoreMenuContent({ event, open, onOpenChange, communityContext, onRe
{!isOwnPost && (
<MenuItem
icon={<Flag className="size-5" />}
label={communityContext ? 'Report post to community' : `Report @${displayName}`}
label={communityContext ? 'Report post to organization' : `Report @${displayName}`}
onClick={onReport}
destructive
/>
)}
{!isOwnPost && communityContext?.canBan && (
<>
<MenuItem
icon={<ShieldBan className="size-5" />}
label="Remove from community"
onClick={onBanContent}
destructive
/>
<MenuItem
icon={<Ban className="size-5" />}
label={`Ban @${displayName} from community`}
onClick={onBanMember}
destructive
/>
</>
<MenuItem
icon={<ShieldBan className="size-5" />}
label="Remove from organization"
onClick={onBanContent}
destructive
/>
)}
{isOwnPost && (
<MenuItem
+626
View File
@@ -0,0 +1,626 @@
import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
import { AlertTriangle, Loader2, Bitcoin, Copy, Check } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { QRCodeCanvas } from '@/components/ui/qrcode';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useBitcoinSigner } from '@/hooks/useBitcoinSigner';
import { useOnchainZap, type OnchainFeeSpeed } from '@/hooks/useOnchainZap';
import { useToast } from '@/hooks/useToast';
import { useAppContext } from '@/hooks/useAppContext';
import { useNostrLogin } from '@nostrify/react/login';
import {
nostrPubkeyToBitcoinAddress,
fetchUTXOs,
fetchBtcPrice,
getFeeRates,
estimateFee,
isLargeAmount,
satsToUSD,
formatSats,
} from '@/lib/bitcoin';
import type { NostrEvent } from '@nostrify/nostrify';
const USD_PRESETS = [1, 5, 10, 25, 100];
const FEE_SPEED_LABELS: Record<OnchainFeeSpeed, string> = {
fastest: '~10 min',
halfHour: '~30 min',
hour: '~1 hour',
economy: '~1 day',
};
const FEE_SPEED_ORDER: OnchainFeeSpeed[] = ['fastest', 'halfHour', 'hour', 'economy'];
/**
* Given the raw mempool fee rates (sat/vB), return a deduplicated list of
* speed tiers. When multiple tiers share the same rate (common when the
* mempool is empty and everything collapses to 1 sat/vB), we keep only the
* fastest-labeled tier for that rate. This prevents rows like "~10 min 2
* sat/vB / ~30 min 2 sat/vB / ~1 hour 2 sat/vB" in the UI.
*/
function getRateForSpeed(rates: { fastestFee: number; halfHourFee: number; hourFee: number; economyFee: number }, speed: OnchainFeeSpeed): number {
switch (speed) {
case 'fastest': return rates.fastestFee;
case 'halfHour': return rates.halfHourFee;
case 'hour': return rates.hourFee;
case 'economy': return rates.economyFee;
}
}
function getUniqueFeeSpeeds(
rates: { fastestFee: number; halfHourFee: number; hourFee: number; economyFee: number } | undefined,
): OnchainFeeSpeed[] {
if (!rates) return FEE_SPEED_ORDER;
const seen = new Set<number>();
const result: OnchainFeeSpeed[] = [];
for (const speed of FEE_SPEED_ORDER) {
const rate = getRateForSpeed(rates, speed);
if (!seen.has(rate)) {
seen.add(rate);
result.push(speed);
}
}
return result;
}
interface OnchainZapContentProps {
target: NostrEvent;
/** Called with the tx result when a zap successfully broadcasts. */
onSuccess?: (result: { txid: string; amountSats: number }) => void;
/** Called when the user dismisses without a send (e.g. "Done" in the
* unsupported-signer QR fallback). */
onClose?: () => void;
}
/**
* Bitcoin zap flow. Publishes a BTC transaction paying the target author's
* derived Taproot address, then publishes a kind 8333 event linking the tx
* to the target event.
*
* UX mirrors the Lightning zap flow: one screen, one button, no review step.
* Balance, fee breakdown, and confirmation are all hidden unless needed.
*/
export function OnchainZapContent({ target, onSuccess, onClose }: OnchainZapContentProps) {
const { user } = useCurrentUser();
const { capability } = useBitcoinSigner();
const { logins } = useNostrLogin();
const { config } = useAppContext();
const { esploraBaseUrl } = config;
const loginType = logins[0]?.type;
const [usdAmount, setUsdAmount] = useState<number | string>(5);
const [feeSpeed, setFeeSpeed] = useState<OnchainFeeSpeed>('halfHour');
const [error, setError] = useState('');
const [feePopoverOpen, setFeePopoverOpen] = useState(false);
const [editingAmount, setEditingAmount] = useState(false);
const amountInputRef = useRef<HTMLInputElement>(null);
// Tracks whether the user has manually picked a fee speed. Once true, we
// stop auto-adjusting the fee in response to amount changes.
const feeSpeedUserChanged = useRef(false);
const senderAddress = user ? nostrPubkeyToBitcoinAddress(user.pubkey) : '';
const recipientAddress = useMemo(() => nostrPubkeyToBitcoinAddress(target.pubkey), [target.pubkey]);
const truncatedRecipient = recipientAddress
? `${recipientAddress.slice(0, 10)}${recipientAddress.slice(-8)}`
: '';
const { data: btcPrice } = useQuery({
queryKey: ['btc-price', esploraBaseUrl],
queryFn: () => fetchBtcPrice(esploraBaseUrl),
staleTime: 30_000,
});
const { data: utxos } = useQuery({
queryKey: ['bitcoin-utxos', esploraBaseUrl, senderAddress],
queryFn: () => fetchUTXOs(senderAddress, esploraBaseUrl),
enabled: !!senderAddress && capability !== 'unsupported',
staleTime: 30_000,
});
const { data: feeRates } = useQuery({
queryKey: ['bitcoin-fee-rates', esploraBaseUrl],
queryFn: () => getFeeRates(esploraBaseUrl),
enabled: capability !== 'unsupported',
staleTime: 30_000,
});
const totalBalance = useMemo(() => utxos?.reduce((s, u) => s + u.value, 0) ?? 0, [utxos]);
const currentFeeRate = useMemo(() => {
if (!feeRates) return 0;
return getRateForSpeed(feeRates, feeSpeed);
}, [feeRates, feeSpeed]);
// Convert the USD amount to sats
const amountSats = useMemo(() => {
if (!btcPrice) return 0;
const usd = typeof usdAmount === 'string' ? parseFloat(usdAmount) : usdAmount;
if (!Number.isFinite(usd) || usd <= 0) return 0;
const btc = usd / btcPrice;
return Math.round(btc * 100_000_000);
}, [usdAmount, btcPrice]);
const estimatedFeeSats = useMemo(() => {
if (!utxos?.length || !currentFeeRate || !amountSats) return 0;
const fee2 = estimateFee(utxos.length, 2, currentFeeRate);
const change = totalBalance - amountSats - fee2;
const numOutputs = change > 546 ? 2 : 1;
return estimateFee(utxos.length, numOutputs, currentFeeRate);
}, [utxos, currentFeeRate, amountSats, totalBalance]);
const totalSats = amountSats + estimatedFeeSats;
const insufficient = totalBalance > 0 && totalSats > totalBalance;
const showBalance = insufficient || (amountSats > 0 && totalBalance === 0);
// Auto-adjust fee speed when the amount changes, unless the user has
// already picked a speed manually. Aim for a fee below 40% of the amount
// by stepping down through the unique speed tiers. If every tier still
// blows past 40% (tiny amount), fall back to the cheapest tier so we at
// least minimize the hit.
useEffect(() => {
if (feeSpeedUserChanged.current) return;
if (!utxos?.length || !feeRates || amountSats <= 0) return;
const uniqueSpeeds = getUniqueFeeSpeeds(feeRates);
const threshold = amountSats * 0.4;
let target: OnchainFeeSpeed = uniqueSpeeds[uniqueSpeeds.length - 1];
for (const speed of uniqueSpeeds) {
const rate = getRateForSpeed(feeRates, speed);
const fee2 = estimateFee(utxos.length, 2, rate);
const change = totalBalance - amountSats - fee2;
const outputs = change > 546 ? 2 : 1;
const fee = estimateFee(utxos.length, outputs, rate);
if (fee <= threshold) {
target = speed;
break;
}
}
setFeeSpeed((prev) => (prev === target ? prev : target));
}, [amountSats, feeRates, utxos, totalBalance]);
const handleFeeSpeedChange = useCallback((speed: OnchainFeeSpeed) => {
feeSpeedUserChanged.current = true;
setFeeSpeed(speed);
setFeePopoverOpen(false);
}, []);
// For large amounts, require a two-tap confirmation on the primary button.
// This catches fat-finger sends without nagging on normal amounts.
const isLarge = isLargeAmount(totalSats, btcPrice);
const [confirmArmed, setConfirmArmed] = useState(false);
// Re-arm (i.e. clear confirmation) whenever the amount, fee rate, or price
// moves — so editing after arming forces another deliberate click.
useEffect(() => {
setConfirmArmed(false);
}, [amountSats, currentFeeRate, btcPrice]);
const { zapAsync, isZapping, progress } = useOnchainZap(target, (result) => {
// Forward the txid + amount so the dialog can render its success screen.
onSuccess?.({ txid: result.txid, amountSats: result.amountSats });
});
const handleZap = useCallback(async () => {
setError('');
if (!user) { setError('You must be logged in.'); return; }
if (user.pubkey === target.pubkey) { setError("You can't zap yourself."); return; }
// `capability === 'unsupported'` is already handled by the UI replacement
// above; 'supported' and 'unknown' both proceed (the latter may fail at
// sign time, which will then flip the UI to the unsupported state).
if (!btcPrice) { setError('Waiting for BTC price…'); return; }
if (amountSats <= 0) { setError('Enter an amount.'); return; }
if (!utxos?.length) { setError("You don't have any Bitcoin yet. Receive some first."); return; }
if (insufficient) { setError('Not enough Bitcoin for this amount + network fee.'); return; }
// Two-tap safety for large amounts: first click arms, second click sends.
if (isLarge && !confirmArmed) {
setConfirmArmed(true);
return;
}
try {
await zapAsync({ amountSats, comment: '', feeSpeed });
// onSuccess (passed to useOnchainZap) closes the dialog; toast is shown by the hook.
} catch (err) {
// Capability errors flip the UI via `reportSignerUnsupported` in the
// hook's `onError`; no need to surface a form-level error for those.
const msg = err instanceof Error ? err.message : 'Zap failed';
const isCapability = /does not support|doesn't support|signpsbt|sign_psbt/i.test(msg);
if (!isCapability) setError(msg);
}
}, [user, target.pubkey, btcPrice, amountSats, utxos, insufficient, zapAsync, feeSpeed, isLarge, confirmArmed]);
// ── Signer not supported ──────────────────────────────────────
// The user's signer can't sign PSBTs locally (extension without signPsbt,
// or a bunker that rejected sign_psbt). Instead of a dead-end, show a QR
// they can scan with any external Bitcoin wallet. We can't observe the
// resulting txid, so we don't publish a kind 8333 — the user is warned
// that the zap won't be attributed to them on Nostr.
const currentUsd = typeof usdAmount === 'string' ? parseFloat(usdAmount) : usdAmount;
const hasValidAmount = Number.isFinite(currentUsd) && currentUsd > 0;
const totalUsdString = btcPrice ? satsToUSD(totalSats, btcPrice) : '';
const uniqueFeeSpeeds = useMemo(() => getUniqueFeeSpeeds(feeRates), [feeRates]);
// Clicking the big amount flips it into edit mode. Auto-focus and
// select-all so typing overwrites the current value.
useEffect(() => {
if (editingAmount) {
amountInputRef.current?.focus();
amountInputRef.current?.select();
}
}, [editingAmount]);
const commitAmountEdit = useCallback(() => {
setEditingAmount(false);
// Normalize empty string to 0 so the display doesn't show "$" alone.
if (typeof usdAmount === 'string' && usdAmount.trim() === '') {
setUsdAmount(0);
}
}, [usdAmount]);
if (user && capability === 'unsupported') {
return (
<UnsupportedSignerQR
recipientAddress={recipientAddress}
truncatedRecipient={truncatedRecipient}
amountSats={amountSats}
btcPrice={btcPrice}
usdAmount={usdAmount}
setUsdAmount={setUsdAmount}
loginType={loginType}
onClose={onClose}
/>
);
}
return (
<div className="grid gap-4 px-4 py-4 w-full overflow-hidden">
{/* Amount — big number on top, editable by clicking. */}
<div className="flex flex-col items-center pt-2">
{editingAmount ? (
<div className="flex items-baseline justify-center">
<span className={`text-4xl font-semibold ${insufficient ? 'text-destructive' : 'text-muted-foreground'}`}>$</span>
<input
ref={amountInputRef}
type="number"
inputMode="decimal"
min={0}
step="0.01"
value={usdAmount}
onChange={(e) => { setUsdAmount(e.target.value); setError(''); }}
onBlur={commitAmountEdit}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
commitAmountEdit();
}
}}
aria-label="Amount in USD"
className={`bg-transparent border-0 outline-none text-4xl font-semibold text-center [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none ${insufficient ? 'text-destructive' : ''}`}
style={{ width: `${Math.max(2, String(usdAmount).length + 1)}ch` }}
/>
</div>
) : (
<button
type="button"
onClick={() => setEditingAmount(true)}
aria-label="Edit amount"
className="flex items-baseline justify-center rounded-md px-2 -mx-2 hover:bg-muted/50 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring transition-colors"
>
<span className={`text-4xl font-semibold ${insufficient ? 'text-destructive' : 'text-muted-foreground'}`}>$</span>
<span className={`text-4xl font-semibold tabular-nums ${insufficient ? 'text-destructive' : ''}`}>
{hasValidAmount ? currentUsd : 0}
</span>
</button>
)}
</div>
{/* Preset buttons sit under the big number. */}
<ToggleGroup
type="single"
value={USD_PRESETS.includes(Number(usdAmount)) ? String(usdAmount) : ''}
onValueChange={(v) => { if (v) { setUsdAmount(Number(v)); setError(''); setEditingAmount(false); } }}
className="grid grid-cols-5 gap-1 w-full"
>
{USD_PRESETS.map((v) => (
<ToggleGroupItem
key={v}
value={String(v)}
className="h-8 min-w-0 text-xs font-semibold px-1"
>
${v}
</ToggleGroupItem>
))}
</ToggleGroup>
{/* Error */}
{error && (
<p className="text-xs text-destructive">{error}</p>
)}
<Button
onClick={handleZap}
disabled={!btcPrice || amountSats <= 0 || isZapping || insufficient}
variant={(insufficient || isLarge) && !isZapping ? 'destructive' : 'default'}
className="w-full"
>
{isZapping ? (
<>
<Loader2 className="size-4 mr-1.5 animate-spin" />
{progressLabel(progress)}
</>
) : insufficient ? (
<>Not enough Bitcoin</>
) : isLarge && confirmArmed ? (
<>Tap again to send {totalUsdString}</>
) : (
<>Send {totalUsdString || (hasValidAmount ? `$${currentUsd}` : '')}</>
)}
</Button>
{/* Fee line — click to open speed picker */}
{amountSats > 0 && (
<div className="flex items-center justify-center gap-3 -mt-1 text-xs">
<Popover open={feePopoverOpen} onOpenChange={setFeePopoverOpen}>
<PopoverTrigger asChild>
<button
type="button"
className="inline-flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors"
>
<span>
Fee{' '}
{estimatedFeeSats > 0 && btcPrice
? `${satsToUSD(estimatedFeeSats, btcPrice)}`
: '…'}
<span className="opacity-60"> · {FEE_SPEED_LABELS[feeSpeed]}</span>
</span>
</button>
</PopoverTrigger>
<PopoverContent align="center" sideOffset={6} className="w-56 p-1">
<div className="flex flex-col">
{uniqueFeeSpeeds.map((speed) => {
const rate = feeRates ? getRateForSpeed(feeRates, speed) : 0;
const selected = speed === feeSpeed;
return (
<button
key={speed}
type="button"
onClick={() => handleFeeSpeedChange(speed)}
className={`flex items-center justify-between px-2 py-1.5 rounded-sm text-xs text-left hover:bg-muted transition-colors ${selected ? 'bg-muted font-medium' : ''}`}
>
<span>{FEE_SPEED_LABELS[speed]}</span>
<span className="text-muted-foreground">{rate} sat/vB</span>
</button>
);
})}
</div>
</PopoverContent>
</Popover>
{showBalance && !insufficient && btcPrice && (
<span className="text-muted-foreground">
Balance: {satsToUSD(totalBalance, btcPrice)}
</span>
)}
</div>
)}
</div>
);
}
function progressLabel(progress: 'idle' | 'building' | 'signing' | 'broadcasting' | 'publishing'): string {
switch (progress) {
case 'building': return 'Building…';
case 'signing': return 'Signing…';
case 'broadcasting': return 'Broadcasting…';
case 'publishing': return 'Publishing…';
default: return 'Processing…';
}
}
// ──────────────────────────────────────────────────────────────
// Unsupported-signer QR fallback
// ──────────────────────────────────────────────────────────────
interface UnsupportedSignerQRProps {
recipientAddress: string;
truncatedRecipient: string;
amountSats: number;
btcPrice: number | undefined;
usdAmount: number | string;
setUsdAmount: (v: number | string) => void;
loginType: string | undefined;
onClose?: () => void;
}
/**
* Fallback shown when the user's signer can't sign PSBTs locally. Renders a
* BIP-21 QR the user can scan with any external Bitcoin wallet. Because we
* never see the resulting tx, we skip publishing the kind 8333 zap event and
* explicitly warn the user about that.
*/
function UnsupportedSignerQR({
recipientAddress,
truncatedRecipient,
amountSats,
btcPrice,
usdAmount,
setUsdAmount,
loginType,
onClose,
}: UnsupportedSignerQRProps) {
const { toast } = useToast();
const [copied, setCopied] = useState<'address' | 'uri' | null>(null);
// BIP-21 URI. Include `amount` (in BTC, 8 decimals) only when > 0 so an
// empty-amount placeholder QR doesn't include `?amount=0`.
const bip21 = useMemo(() => {
if (!recipientAddress) return '';
if (amountSats <= 0) return `bitcoin:${recipientAddress}`;
const btc = (amountSats / 100_000_000).toFixed(8);
return `bitcoin:${recipientAddress}?amount=${btc}`;
}, [recipientAddress, amountSats]);
const explanation =
loginType === 'extension'
? "Your browser extension can't sign Bitcoin transactions."
: loginType === 'bunker'
? "Your remote signer can't sign Bitcoin transactions."
: "Your signer can't sign Bitcoin transactions.";
const copy = useCallback(
async (value: string, which: 'address' | 'uri', label: string) => {
try {
await navigator.clipboard.writeText(value);
setCopied(which);
toast({ title: 'Copied', description: `${label} copied to clipboard` });
setTimeout(() => setCopied(null), 2000);
} catch {
toast({ title: 'Copy failed', description: 'Please copy manually.', variant: 'destructive' });
}
},
[toast],
);
const currentUsd = typeof usdAmount === 'string' ? parseFloat(usdAmount) : usdAmount;
const hasAmount = amountSats > 0;
return (
<div className="grid gap-3 px-4 py-4 w-full overflow-hidden">
<p className="text-xs text-muted-foreground">
{explanation} You can still zap by scanning this QR from any Bitcoin wallet.
</p>
{/* Amount presets (USD) */}
<ToggleGroup
type="single"
value={USD_PRESETS.includes(Number(usdAmount)) ? String(usdAmount) : ''}
onValueChange={(v) => { if (v) setUsdAmount(Number(v)); }}
className="grid grid-cols-5 gap-1 w-full"
>
{USD_PRESETS.map((v) => (
<ToggleGroupItem
key={v}
value={String(v)}
className="h-8 min-w-0 text-xs font-semibold px-1"
>
${v}
</ToggleGroupItem>
))}
</ToggleGroup>
<div className="flex items-center gap-2">
<div className="h-px flex-1 bg-muted" />
<span className="text-xs text-muted-foreground">OR</span>
<div className="h-px flex-1 bg-muted" />
</div>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-sm text-muted-foreground">$</span>
<Input
type="number"
inputMode="decimal"
min={0}
step="0.01"
placeholder="Custom amount (USD)"
value={usdAmount}
onChange={(e) => setUsdAmount(e.target.value)}
className="pl-6"
/>
</div>
{/* QR / placeholder */}
<div className="flex justify-center">
{hasAmount && bip21 ? (
<div className="bg-white p-3 rounded-xl" aria-label="Bitcoin payment QR code">
<QRCodeCanvas value={bip21} size={220} level="M" className="block" />
</div>
) : (
<div className="size-[220px] rounded-xl border border-dashed flex items-center justify-center text-xs text-muted-foreground text-center px-4">
{btcPrice
? 'Choose an amount above to generate a payment QR.'
: 'Loading BTC price…'}
</div>
)}
</div>
{/* Amount summary */}
{hasAmount && btcPrice && (
<div className="text-center text-sm">
<span className="font-medium">
{currentUsd > 0 ? `$${currentUsd}` : ''}
</span>
<span className="text-muted-foreground">
{' · '}{formatSats(amountSats)} sats
</span>
</div>
)}
{/* Recipient */}
{recipientAddress && (
<div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
<div className="flex items-center gap-1.5 min-w-0">
<Bitcoin className="size-3.5 text-orange-500 shrink-0" />
<span className="shrink-0">To:</span>
<span className="font-mono truncate" title={recipientAddress}>{truncatedRecipient}</span>
</div>
</div>
)}
{/* Copy buttons */}
<div className="grid grid-cols-2 gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => copy(recipientAddress, 'address', 'Address')}
disabled={!recipientAddress}
className="text-xs"
>
{copied === 'address' ? <Check className="size-3.5 mr-1.5" /> : <Copy className="size-3.5 mr-1.5" />}
Copy address
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => copy(bip21, 'uri', 'Payment link')}
disabled={!hasAmount || !bip21}
className="text-xs"
>
{copied === 'uri' ? <Check className="size-3.5 mr-1.5" /> : <Copy className="size-3.5 mr-1.5" />}
Copy link
</Button>
</div>
{/* Warning: no kind 8333 will be published */}
<Alert>
<AlertTriangle className="size-4" />
<AlertDescription className="text-xs">
Because we can't see your transaction, this zap won't show up as yours on Nostr. The recipient will still get the Bitcoin.
</AlertDescription>
</Alert>
{onClose && (
<Button type="button" variant="secondary" onClick={onClose} className="w-full">
Done
</Button>
)}
</div>
);
}
+116
View File
@@ -0,0 +1,116 @@
import { Users } from 'lucide-react';
interface OrganizationContextChipProps {
/** The org `A` tag coordinate currently attached to this draft (or empty). */
aTag: string;
/**
* The org entry resolved from the `?org=` query parameter when the
* current user is authorized to publish under it (founder or moderator).
* `null` when the param is missing, malformed, or points at an org the
* user can't publish under.
*/
authorizedOrg: { community: { aTag: string; name: string } } | null;
/** The raw `?org=` value from the URL (used to decide which message to show). */
param: string | null;
/** The decoded `?org=` result. `null` when the value didn't parse. */
paramDecoded: { aTag: string } | null;
/** True while `useManageableOrganizations` is still resolving. */
manageableLoading: boolean;
/**
* When true, the chip is rendered for an *edit* flow show whatever
* org the existing event is already attached to (no permission checks
* here, because we may not have the user's manageable orgs cached).
*/
isEditMode?: boolean;
}
/**
* Small inline indicator surfaced under the create form's title when
* the create flow was initiated from inside an organization. The chip
* is deliberately uncontrolled there's no UI to clear, change, or
* attach an org from the create page. The user attaches by entering
* the create flow from inside the org's page, and detaches by entering
* it from outside.
*
* Shared between CreateCampaignPage and CreateActionPage.
*/
export function OrganizationContextChip({
aTag,
authorizedOrg,
param,
paramDecoded,
manageableLoading,
isEditMode = false,
}: OrganizationContextChipProps) {
// Edit mode: surface the org the event is already attached to. No
// permission check here — the underlying publish flow re-resolves the
// user's authority before emitting the tags.
if (isEditMode) {
if (!aTag) return null;
return (
<div className="mt-3 ml-9 flex items-start gap-3 rounded-xl border border-primary/30 bg-primary/10 px-4 py-3 text-primary shadow-sm">
<div className="mt-0.5 flex size-9 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground">
<Users className="size-4" />
</div>
<div className="min-w-0 space-y-0.5">
<p className="text-xs font-semibold uppercase tracking-wide">Attached to organization</p>
<p className="truncate text-sm font-semibold text-foreground">
{authorizedOrg?.community.name ?? 'Organization'}
</p>
<p className="text-xs text-muted-foreground">
Updates will stay connected to this organization's official activity.
</p>
</div>
</div>
);
}
// Create mode, no `?org=` in the URL: personal publication. Render
// nothing — the absence of the chip is the indicator.
if (!param) return null;
// `?org=` present but malformed.
if (!paramDecoded) {
return (
<p className="mt-2 ml-9 text-xs text-muted-foreground">
Couldn't read the organization in the link. Publishing under your account.
</p>
);
}
// `?org=` present and valid, but we haven't resolved the user's
// authorization yet. Don't claim "publishing under" until we know.
if (manageableLoading) {
return (
<div className="mt-3 ml-9 rounded-xl border border-border bg-card px-4 py-3 text-sm text-muted-foreground">
Checking organization permissions
</div>
);
}
// `?org=` present and valid, but the current user isn't a founder or
// moderator of that org. Drop silently so a stale link can't forge
// an org-tagged event.
if (!authorizedOrg) {
return (
<p className="mt-2 ml-9 text-xs text-muted-foreground">
You aren't a founder or moderator of that organization. Publishing under your account.
</p>
);
}
return (
<div className="mt-3 ml-9 flex items-start gap-3 rounded-xl border border-primary/30 bg-primary/10 px-4 py-3 text-primary shadow-sm">
<div className="mt-0.5 flex size-9 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground">
<Users className="size-4" />
</div>
<div className="min-w-0 space-y-0.5">
<p className="text-xs font-semibold uppercase tracking-wide">Publishing as organization</p>
<p className="truncate text-sm font-semibold text-foreground">{authorizedOrg.community.name}</p>
<p className="text-xs text-muted-foreground">
This will appear as official organization activity instead of only under your profile.
</p>
</div>
</div>
);
}
+239
View File
@@ -0,0 +1,239 @@
import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
import { Loader2, Search, PartyPopper } from 'lucide-react';
import { useNostr } from '@nostrify/react';
import type { NostrEvent } from '@nostrify/nostrify';
import { Input } from '@/components/ui/input';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { EmojifiedText } from '@/components/CustomEmoji';
import { useToast } from '@/hooks/useToast';
import { useSearchProfiles, type SearchProfile } from '@/hooks/useSearchProfiles';
import { useSearchPeopleLists, type PeopleListSearchResult } from '@/hooks/useSearchPeopleLists';
import { parseAuthorEvent } from '@/hooks/useAuthor';
import { genUserName } from '@/lib/genUserName';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
function isHexPubkey(value: string): boolean {
return /^[0-9a-f]{64}$/i.test(value);
}
function makeFallbackProfile(pubkey: string): SearchProfile {
return {
pubkey,
metadata: {},
event: {
id: '',
pubkey,
created_at: 0,
kind: 0,
tags: [],
content: '{}',
sig: '',
},
};
}
function profileFromEvent(event: NostrEvent): SearchProfile {
const parsed = parseAuthorEvent(event);
return { pubkey: event.pubkey, metadata: parsed.metadata ?? {}, event };
}
/** Inline type-ahead person search. */
export function PersonSearch({
onAdd,
onAddMany,
excludePubkeys,
}: {
onAdd: (profile: SearchProfile) => void;
onAddMany: (profiles: SearchProfile[], sourceTitle?: string) => void;
excludePubkeys: string[];
}) {
const { nostr } = useNostr();
const { toast } = useToast();
const [query, setQuery] = useState('');
const [dropdownOpen, setDropdownOpen] = useState(false);
const [isAddingPack, setIsAddingPack] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const { data: profiles, isFetching } = useSearchProfiles(query);
const { data: peopleLists, isFetching: isFetchingPeopleLists } = useSearchPeopleLists(query);
const excludeSet = useMemo(() => new Set(excludePubkeys), [excludePubkeys]);
const filteredProfiles = useMemo(
() => (profiles ?? []).filter((p) => !excludeSet.has(p.pubkey)),
[profiles, excludeSet],
);
const filteredPeopleLists = useMemo(
() => (peopleLists ?? []).filter((pack) => pack.pubkeys.some((pubkey) => isHexPubkey(pubkey) && !excludeSet.has(pubkey.toLowerCase()))),
[peopleLists, excludeSet],
);
const hasResults = filteredProfiles.length > 0 || filteredPeopleLists.length > 0;
const isSearching = isFetching || isFetchingPeopleLists || isAddingPack;
useEffect(() => {
if (query.trim().length > 0 && hasResults) {
setDropdownOpen(true);
} else if (query.trim().length === 0) {
setDropdownOpen(false);
}
}, [hasResults, query]);
const handleSelect = useCallback((profile: SearchProfile) => {
onAdd(profile);
setQuery('');
setDropdownOpen(false);
inputRef.current?.focus();
}, [onAdd]);
const handleSelectPeopleList = useCallback(async (pack: PeopleListSearchResult) => {
const eligiblePubkeys = Array.from(new Set(
pack.pubkeys
.map((pubkey) => pubkey.toLowerCase())
.filter((pubkey) => isHexPubkey(pubkey) && !excludeSet.has(pubkey)),
));
if (eligiblePubkeys.length === 0) {
toast({ title: 'No new people to add', description: 'Everyone in that follow pack is already included.' });
return;
}
if (eligiblePubkeys.length > 20 && !window.confirm(`Add ${eligiblePubkeys.length} people from ${pack.title}?`)) {
return;
}
setIsAddingPack(true);
try {
const events = await nostr.query(
[{ kinds: [0], authors: eligiblePubkeys, limit: eligiblePubkeys.length }],
{ signal: AbortSignal.timeout(8000) },
);
const latestByPubkey = new Map<string, NostrEvent>();
for (const event of events) {
const existing = latestByPubkey.get(event.pubkey);
if (!existing || event.created_at > existing.created_at) latestByPubkey.set(event.pubkey, event);
}
const profilesToAdd = eligiblePubkeys.map((pubkey) => {
const event = latestByPubkey.get(pubkey);
return event ? profileFromEvent(event) : makeFallbackProfile(pubkey);
});
onAddMany(profilesToAdd, pack.title);
setQuery('');
setDropdownOpen(false);
inputRef.current?.focus();
} catch (error) {
toast({
title: 'Failed to load follow pack members',
description: error instanceof Error ? error.message : 'Please try again.',
variant: 'destructive',
});
} finally {
setIsAddingPack(false);
}
}, [excludeSet, nostr, onAddMany, toast]);
return (
<Popover open={dropdownOpen} onOpenChange={setDropdownOpen}>
<PopoverTrigger asChild>
<div className="relative flex items-center">
<Search className="absolute left-3 size-4 text-muted-foreground pointer-events-none" />
{isSearching && query.trim() && (
<Loader2 className="absolute right-3 size-4 text-muted-foreground animate-spin" />
)}
<Input
ref={inputRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
onFocus={() => {
if (query.trim().length > 0 && hasResults) {
setDropdownOpen(true);
}
}}
placeholder="Search people..."
className="pl-10 pr-10 rounded-full bg-secondary border-0 focus-visible:ring-0 focus-visible:ring-offset-0 h-9 text-sm"
autoComplete="off"
/>
</div>
</PopoverTrigger>
<PopoverContent
align="start"
side="bottom"
sideOffset={6}
onOpenAutoFocus={(e) => e.preventDefault()}
className="z-[270] w-[var(--radix-popover-trigger-width)] rounded-xl border-border p-0 shadow-lg overflow-hidden"
>
{hasResults ? (
<div className="max-h-[200px] overflow-y-auto py-1">
{filteredProfiles.map((profile) => (
<SearchResultItem key={profile.pubkey} profile={profile} onClick={handleSelect} />
))}
{filteredPeopleLists.map((pack) => (
<PeopleListSearchResultItem key={`${pack.event.kind}:${pack.event.pubkey}:${pack.event.tags.find(([name]) => name === 'd')?.[1] ?? pack.event.id}`} pack={pack} onClick={handleSelectPeopleList} />
))}
</div>
) : query.trim().length >= 2 && !isSearching ? (
<div className="py-4 text-center text-sm text-muted-foreground">
No people or follow packs found
</div>
) : null}
</PopoverContent>
</Popover>
);
}
/** A follow pack / follow set search result row. */
function PeopleListSearchResultItem({ pack, onClick }: { pack: PeopleListSearchResult; onClick: (pack: PeopleListSearchResult) => void }) {
return (
<button
className="w-full flex items-center gap-3 px-3 py-2 text-left transition-colors cursor-pointer hover:bg-secondary/60"
onClick={() => onClick(pack)}
onMouseDown={(e) => e.preventDefault()}
>
<div className="size-8 shrink-0 rounded-full bg-primary/10 text-primary flex items-center justify-center">
<PartyPopper className="size-4" />
</div>
<div className="flex-1 min-w-0">
<span className="text-sm font-medium truncate block">{pack.title}</span>
<span className="text-xs text-muted-foreground truncate block">
Follow pack · {pack.pubkeys.length} people
</span>
</div>
</button>
);
}
/** A profile search result row. */
function SearchResultItem({ profile, onClick }: { profile: SearchProfile; onClick: (profile: SearchProfile) => void }) {
const { metadata, pubkey } = profile;
const displayName = metadata.display_name || metadata.name || genUserName(pubkey);
const avatarUrl = sanitizeUrl(metadata.picture);
return (
<button
className="w-full flex items-center gap-3 px-3 py-2 text-left transition-colors cursor-pointer hover:bg-secondary/60"
onClick={() => onClick(profile)}
onMouseDown={(e) => e.preventDefault()}
>
<Avatar className="size-8 shrink-0">
<AvatarImage src={avatarUrl} alt={displayName} />
<AvatarFallback className="bg-primary/20 text-primary text-xs">
{displayName[0]?.toUpperCase() || '?'}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<span className="text-sm font-medium truncate block">
<EmojifiedText tags={profile.event.tags}>{displayName}</EmojifiedText>
</span>
{metadata.nip05 && (
<span className="text-xs text-muted-foreground truncate block">
{metadata.nip05.startsWith('_@') ? metadata.nip05.slice(2) : metadata.nip05}
</span>
)}
</div>
</button>
);
}
+48 -18
View File
@@ -15,6 +15,7 @@ import { canZap } from '@/lib/canZap';
import { formatNumber } from '@/lib/formatNumber';
import { hasGoalZapSplits } from '@/lib/goalUtils';
import { shareOrCopy } from '@/lib/share';
import { cn } from '@/lib/utils';
interface PostActionBarProps {
event: NostrEvent;
@@ -22,6 +23,10 @@ interface PostActionBarProps {
replyLabel?: string;
onReply: () => void;
onMore: () => void;
/** Hide the zap button entirely. Useful for events with their own donation
* flow (e.g. fundraising campaigns) where a generic Lightning zap is the
* wrong primary CTA. Defaults to false. */
hideZap?: boolean;
/** Extra classes on the outer wrapper div. */
className?: string;
}
@@ -31,6 +36,7 @@ export function PostActionBar({
replyLabel = 'Reply',
onReply,
onMore,
hideZap = false,
className,
}: PostActionBarProps) {
const { toast } = useToast();
@@ -38,7 +44,7 @@ export function PostActionBar({
const author = useAuthor(event.pubkey);
const metadata = author.data?.metadata;
// TODO: Enable zapping split-recipient NIP-75 goals once zap split payments are supported.
const canZapAuthor = user && canZap(metadata) && !hasGoalZapSplits(event);
const canZapAuthor = !hideZap && user && canZap(metadata) && !hasGoalZapSplits(event);
const { data: stats } = useEventStats(event.id, event);
const repostTotal = (stats?.reposts ?? 0) + (stats?.quotes ?? 0);
@@ -59,30 +65,48 @@ export function PostActionBar({
}, [event, toast]);
return (
<div className={`flex items-center justify-between py-1 border-t border-b border-border${className ? ` ${className}` : ''}`}>
<div
className={cn(
// Soft chip-style action row. Buttons cluster to the left
// (engagement) with share/more pushed right. No heavy
// top/bottom border band — pages can add their own separator
// via `className` if they need one.
'flex flex-wrap items-center gap-1 sm:gap-2',
className,
)}
>
{/* Reply / Comments */}
<button
className="flex items-center gap-1.5 p-2 rounded-full text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors"
className="inline-flex items-center gap-2 h-9 px-3 rounded-full text-sm font-medium text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors"
title={replyLabel}
onClick={onReply}
>
<MessageCircle className="size-5" />
<MessageCircle className="size-[18px]" />
{stats?.replies ? (
<span className="text-sm tabular-nums">{formatNumber(stats.replies)}</span>
) : null}
<span className="tabular-nums">{formatNumber(stats.replies)}</span>
) : (
<span className="hidden sm:inline">{replyLabel}</span>
)}
</button>
{/* Repost */}
<RepostMenu event={event}>
{(isReposted: boolean) => (
<button
className={`flex items-center gap-1.5 p-2 rounded-full transition-colors ${isReposted ? 'text-accent hover:text-accent/80 hover:bg-accent/10' : 'text-muted-foreground hover:text-accent hover:bg-accent/10'}`}
className={cn(
'inline-flex items-center gap-2 h-9 px-3 rounded-full text-sm font-medium transition-colors',
isReposted
? 'text-accent hover:text-accent/80 hover:bg-accent/10'
: 'text-muted-foreground hover:text-accent hover:bg-accent/10',
)}
title={isReposted ? 'Undo repost' : 'Repost'}
>
<RepostIcon className="size-5" />
<RepostIcon className="size-[18px]" />
{repostTotal > 0 ? (
<span className="text-sm tabular-nums">{formatNumber(repostTotal)}</span>
) : null}
<span className="tabular-nums">{formatNumber(repostTotal)}</span>
) : (
<span className="hidden sm:inline">Repost</span>
)}
</button>
)}
</RepostMenu>
@@ -93,39 +117,45 @@ export function PostActionBar({
eventPubkey={event.pubkey}
eventKind={event.kind}
reactionCount={stats?.reactions}
variant="chip"
/>
{/* Zap */}
{canZapAuthor && (
<ZapDialog target={event}>
<button
className="flex items-center gap-1.5 p-2 rounded-full text-muted-foreground hover:text-amber-500 hover:bg-amber-500/10 transition-colors"
className="inline-flex items-center gap-2 h-9 px-3 rounded-full text-sm font-medium text-muted-foreground hover:text-amber-500 hover:bg-amber-500/10 transition-colors"
title="Zap"
>
<Zap className="size-5" />
<Zap className="size-[18px]" />
{stats?.zapAmount ? (
<span className="text-sm tabular-nums">{formatNumber(stats.zapAmount)}</span>
) : null}
<span className="tabular-nums">{formatNumber(stats.zapAmount)}</span>
) : (
<span className="hidden sm:inline">Zap</span>
)}
</button>
</ZapDialog>
)}
{/* Spacer pushes share/more to the right */}
<div className="flex-1" />
{/* Share */}
<button
className="p-2 rounded-full text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors sidebar:hidden"
className="inline-flex items-center justify-center h-9 w-9 rounded-full text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors sidebar:hidden"
title="Share"
onClick={handleShare}
>
<Share2 className="size-5" />
<Share2 className="size-[18px]" />
</button>
{/* More */}
<button
className="p-2 rounded-full text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors"
className="inline-flex items-center justify-center h-9 w-9 rounded-full text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors"
title="More"
onClick={onMore}
>
<MoreHorizontal className="size-5" />
<MoreHorizontal className="size-[18px]" />
</button>
</div>
);
+28 -7
View File
@@ -27,6 +27,14 @@ interface ReactionButtonProps {
className?: string;
/** Show a filled heart icon instead of outline. */
filledHeart?: boolean;
/**
* Visual variant.
* - `pill` (default): compact icon-pill matching the legacy NoteCard
* action bar.
* - `chip`: rounded chip with label fallback when there's no count,
* matching the GoFundMe-style PostActionBar / NoteCard action row.
*/
variant?: 'pill' | 'chip';
}
export function ReactionButton({
@@ -36,6 +44,7 @@ export function ReactionButton({
reactionCount = 0,
className,
filledHeart = false,
variant = 'pill',
}: ReactionButtonProps) {
const { user } = useCurrentUser();
const { nostr } = useNostr();
@@ -129,8 +138,10 @@ export function ReactionButton({
<PopoverTrigger asChild>
<button
className={cn(
'flex items-center gap-1.5 p-2 rounded-full transition-colors focus:outline-none',
'text-muted-foreground hover:text-pink-500 hover:bg-pink-500/10',
'transition-colors focus:outline-none',
variant === 'chip'
? 'inline-flex items-center gap-2 h-9 px-3 rounded-full text-sm font-medium text-muted-foreground hover:text-pink-500 hover:bg-pink-500/10'
: 'flex items-center gap-1.5 p-2 rounded-full text-muted-foreground hover:text-pink-500 hover:bg-pink-500/10',
className,
hasReacted && 'text-pink-500',
)}
@@ -189,13 +200,23 @@ export function ReactionButton({
{filledHeart ? (
<Heart className="size-6" fill={hasReacted ? 'currentColor' : 'none'} />
) : hasReacted && userReaction ? (
<RenderResolvedEmoji emoji={userReaction} className="h-5 w-5 object-contain leading-none translate-y-px" />
<RenderResolvedEmoji
emoji={userReaction}
className={cn(
'object-contain leading-none translate-y-px',
variant === 'chip' ? 'h-[18px] w-[18px]' : 'h-5 w-5',
)}
/>
) : (
<Heart className="size-5" />
)}
{reactionCount > 0 && (
<span className={cn('text-sm tabular-nums', hasReacted && 'text-pink-500')}>{formatNumber(reactionCount)}</span>
<Heart className={variant === 'chip' ? 'size-[18px]' : 'size-5'} />
)}
{reactionCount > 0 ? (
<span className={cn('tabular-nums', variant === 'chip' ? '' : 'text-sm', hasReacted && 'text-pink-500')}>
{formatNumber(reactionCount)}
</span>
) : variant === 'chip' ? (
<span className="hidden sm:inline">React</span>
) : null}
</button>
</PopoverTrigger>
<PopoverContent
+3
View File
@@ -13,6 +13,8 @@ import { ComposeBox, type ExternalReplyRoot } from '@/components/ComposeBox';
import { LinkEmbed } from '@/components/LinkEmbed';
import { cn } from '@/lib/utils';
const AGORA_DEFAULT_NOTE_TAGS = [['t', 'agora']];
interface ReplyComposeModalProps {
/** The event being replied to, a URL for commenting on web content, or a NIP-73 identifier (e.g. `bitcoin:tx:...`, `isbn:...`). When `null`, the modal acts as a "New post" composer. */
event?: NostrEvent | ExternalReplyRoot | null;
@@ -160,6 +162,7 @@ export function ReplyComposeModal({ event, quotedEvent, open, onOpenChange, onSu
onHasPreviewableContentChange={setHasPreviewableContent}
initialContent={initialContent}
initialMode={initialMode}
defaultTags={!isReply && !isQuote && initialMode !== 'poll' ? AGORA_DEFAULT_NOTE_TAGS : undefined}
/>
</div>
</PortalContainerProvider>
-1
View File
@@ -20,7 +20,6 @@ interface RequestToVanishDialogProps {
const DELETION_ITEMS = [
{ id: 'profile', label: 'Your profile and metadata' },
{ id: 'posts', label: 'All posts, replies, and reactions' },
{ id: 'messages', label: 'Direct messages' },
{ id: 'settings', label: 'Follow lists and settings' },
{ id: 'other', label: 'All other events submitted to the network' },
] as const;

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