Compare commits

...

368 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
Chad Curtis 6eaef58db3 Remove leftover arc overhang spacer below tab bars
SubHeaderBar switched to ArcBackground variant="rect" (commit 207794e7)
when navigation was restyled with V-angled bars, but 15 pages still
rendered a 20px ARC_OVERHANG_PX spacer div directly below the tab bar
to leave room for the now-removed downward arc. That spacer is the
empty band the user reported underneath the tabs.

Drop the spacer divs and their now-unused ARC_OVERHANG_PX imports across
Feed, Search, Notifications, Profile, Videos, Photos, Relay, Letters,
Music, ExternalContent, ArticleEditor, PeopleListDetail, BadgeDetail,
Communities, and Badges. FollowPage keeps the import because it still
renders an actual <ArcBackground variant="down" /> at the top of its
profile feed scrollbox.
2026-05-16 00:51:35 -05:00
Chad Curtis 4838ec3556 Drop unused Dialog/Drawer Trigger imports in ActionsPage 2026-05-16 00:46:20 -05:00
Chad Curtis a90ac34508 Add destination dropdown to the home-feed compose box
Lets users post either to the global Nostr feed (kind 1) or to one of
the country communities they follow (kind 1111 rooted on the country
ISO 3166 identifier, mirroring the country page's compose flow).

Layout choices that ended up sticking after iteration:
- Dropdown lives on its own row above the toolbar so the 'Post to'
  label can anchor its semantic meaning without competing with the
  attach / emoji / mic / poll icons below.
- Trigger renders only the flag emoji to stay compact on mobile;
  the open list shows flag + country name so options remain
  distinguishable.
- A small help popover next to 'Post to' explains Global vs. country
  community for users new to the concept, with the second item's
  flag swapping to the currently-selected (or first followed)
  country so the explanation feels tangible.
- Toggle only renders when the user is logged in, followed at least
  one country, isn't replying, and isn't in poll / customPublish mode.
  Resets to Global after each publish so country-mode never sticks
  silently across posts.
2026-05-16 00:28:08 -05:00
Chad Curtis deb10d0972 Match community follow-button styling on country headers 2026-05-16 00:03:37 -05:00
Chad Curtis 90b17255af Use double-bolt logo mark in place of single-bolt zap 2026-05-15 23:59:55 -05:00
Chad Curtis 8e19ccd518 Restyle Actions page: less boxy, default covers, canonical FAB 2026-05-15 23:58:48 -05:00
Chad Curtis f11a149448 Strip the country pill down to a bare flag emoji link
The gradient surface, ring, shadow, and country label were doing too
much work now that the flag backdrop carries the visual weight of
'this is a country post'. Reduce the pill to just the flag emoji
inside a Link with a hover scale and the existing hover-card preview.

Cleaner read in the card header; the eye sees flag-backdrop fading
into the body of the post, then a clean flag emoji as the
right-anchored 'tap to go to country feed' affordance.
2026-05-15 23:53:39 -05:00
Chad Curtis 4fec16c281 Drop flag emoji fallback; render flag-color gradient instead
The giant blurred emoji fallback never quite matched the eventual
Wikipedia flag image visually, so when the network was slow the card
would visibly swap from emoji to image. Replace the fallback with the
sampled flag-color gradient (already used by the country pill via
useFlagPalette) which matches the upcoming image's color palette
closely and shares the same opacity/mask shape — so the eventual swap
to the real flag image is seamless.

Also wires the flag-mode foreground treatment through both NoteCard
layouts (normal + threaded): when the backdrop is active, the action
header + author row pick up white text with a strong text-shadow for
readability against the dark wash. The country pill stays scoped out
of that flip so it keeps its own gradient surface.

Includes prior tweaks from this arc: full-width flag banner, taller
backdrop area (h-64 / h-72), mask-image bottom fade, increased dark
wash for text contrast, original (not thumbnail) Wikipedia source for
sharpness, no blur on the image, lighter drop-shadow on the pill's
flag emoji.
2026-05-15 23:51:24 -05:00
Chad Curtis 1fe896f858 Use the real Wikipedia flag image behind country posts
The country detail page's hero already uses Wikipedia's page-summary
lead image (which is the flag for country articles) — mirror that
source in CountryFlagBackdrop instead of falling back to the giant
emoji as the primary asset.

useWikipediaSummary is gated by title, so non-country posts never
fetch, and TanStack Query's 24h staleTime / 7d gcTime means a feed of
N posts from the same country only pays the network cost once. The
emoji backdrop is still kept as a fallback while Wikipedia resolves or
when its response has no lead image.
2026-05-15 23:29:00 -05:00
Chad Curtis 5a60bf7b8c Lay a fading flag emoji behind country-rooted posts
Country-rooted kind-1111 cards now wear a giant blurred flag emoji
anchored upper-right, fading through hsl(var(--background)) before it
reaches the post body. Echoes the country detail hero's
'background + linear-gradient fade' technique, scaled down for a card.

The gating logic for the pill and the backdrop is factored into a
shared useCountryRootContext hook so they always appear or disappear
together. NoteCard's <article> picks up 'relative isolate' so the
absolute backdrop stays contained inside the card it belongs to.
2026-05-15 23:25:20 -05:00
Chad Curtis 22ef2619d7 Tint country pill with colors sampled from the flag emoji
Render each flag emoji to an offscreen canvas, sample the middle stripe,
bucket pixels by hue, and return the top three saturated colors ordered
by their left-to-right position. The country pill uses that palette as
its background gradient so Venezuelan posts get yellow/blue/red,
Brazilian ones get green/yellow, etc.

The work is memoised per-emoji and deferred via requestAnimationFrame.
While the palette is being extracted (or if canvas access fails in a
test/SSR environment), the pill falls back to the primary->accent
gradient. White text gets a drop shadow so it stays legible against
even the lightest flag palettes.
2026-05-15 23:18:45 -05:00
Chad Curtis 983d355b75 Drop white flag disc; flag rides the gradient
The disc was fighting the gradient surface. Let the flag sit directly
on the primary->accent gradient with a soft drop shadow for depth, tighten
gap, and even out the pill padding.
2026-05-15 23:12:49 -05:00
Chad Curtis 6923b44f58 Strip 'Posted from' label from country pill
The two-line stack inside the pill was overkill — gradient surface,
flag disc, and country name already say it. Drop the uppercase tracker
and let the pill breathe.
2026-05-15 23:12:12 -05:00
Chad Curtis e5a74a85f6 Promote country pill into a glassy passport stamp
The flat tinted chip didn't sell 'this is a country-level post'. Replace it
with a gradient pill (primary -> accent) carrying a white flag disc, a
'POSTED FROM' tracker label, and a soft primary-tinted shadow that
intensifies on hover. The country preview popover also picks up a matching
gradient accent bar.

Pill stays anchored to the upper right of NoteCard's header and hides
inside its own country feed, matching the previous visibility rules.
2026-05-15 23:09:41 -05:00
Chad Curtis 63e7b7f5e7 Render country comment context as a bold flag pill
The old 'Commenting on \ud83c\uddfb\ud83c\uddea Venezuela' muted-text row visually
disappeared in the World feed. Swap it for a rounded primary-tinted
pill with a larger flag and the country name in semibold, keeping the
existing hover-card preview. Each country post now has a clear
neighborhood badge anchoring it in the feed without overwhelming
neighbouring cards.
2026-05-15 23:01:52 -05:00
Chad Curtis 402acdcd16 Drop motto from country header
P1546 (motto) is sparsely populated on Wikidata — Venezuela, the UK,
Germany, and many others have no claim even though their Wikipedia
infoboxes carry a motto. Pulling the missing values would require
parsing wikitext infobox templates, which vary per language and per
template version, so the cost outweighs the value of a one-liner.

Remove the field entirely (Wikidata SPARQL OPTIONAL, the CountryFacts
TypeScript field, and the below-hero render block) rather than leave
a tooltip-quality field that works for some countries and silently
omits for others.
2026-05-15 22:55:33 -05:00
Chad Curtis 3294cc331a Merge weather and vitals into a single justify-between row
Two stacked sub-bars under the hero felt chunky on tall mobile
viewports. Collapsed them into a single flex row: weather + capital on
the left, vitals (population / language / currency) right-aligned via
justify-between. The row wraps cleanly on narrow screens — vitals fall
under the weather group rather than getting crushed beside it.

Combined component (WeatherVitalsRow) renders nothing when both sides
are empty, so countries with no Wikidata facts and no weather still
collapse gracefully to hero + Wikipedia extract.
2026-05-15 22:52:34 -05:00
Chad Curtis d1f5bdda22 Filter sign languages out of country header; drop empty wrapper gap
Wikidata's P37 (official language) is inclusive — it lists every legally
recognised language including signed ones, so Venezuela returns both
Spanish and Venezuelan Sign Language. For the destination header the
user is asking 'what do they speak?' Signed languages are accessibility
metadata, not a postcard answer. Add a FILTER NOT EXISTS on
P31/P279* wd:Q34228 in the SPARQL query so signed languages never reach
the client.

Also gate the px-4 space-y-6 pb-4 wrapper on non-country pages — it was
mounting empty (with bottom padding) on country pages, adding a dead
band between the top of the column and the start of the hero photo.
2026-05-15 22:50:59 -05:00
Chad Curtis 3d984481fc Move back arrow into the country hero; drop the redundant page header
The country page now mounts the hero flush with the top of the column.
A circular back-arrow button overlaid on the top-left of the hero
photo (mirroring the top-right follow button's white-on-glass style)
replaces the back arrow that previously lived in the page header bar.

Same 'sidebar:hidden' rule the original header used — the back button
hides on wide layouts where the persistent left sidebar already
provides navigation.

URL / ISBN / unknown content types keep the original page header bar
because their content-specific headers don't include a back arrow.
2026-05-15 22:47:54 -05:00
Chad Curtis 75713b1e35 Drop weather-station city from the weather line
The weather-station city duplicated a less-meaningful place name next
to the country capital and added visual noise (a literal '· Caracas'
next to 'Caracas'). The capital is the stable national place anchor;
the station city is whatever Open-Meteo nearest-match returned and is
rarely the city the user is thinking about.
2026-05-15 22:46:15 -05:00
Chad Curtis e3f297e49d Tighten country header; stats move to the action-bar menu
Capital relocates from the vitals row up onto the weather line — it
reads more naturally next to the current weather-station city than next
to population / language / currency. A Landmark icon distinguishes the
two place names visually when both are present.

The motto moves out of the cramped hero overlay into its own borderless
line below the hero, where it has full column width to read as a proper
national epigraph rather than a 12-pixel afterthought truncated under a
flag.

The Stats pill button is removed from the page header. Stats now live
behind a 'View stats' item in the existing 3-dots menu on the action
bar, which is where users look for secondary actions on every other
page. CountryStatsDrawer is renamed to CountryStatsDialog and converted
to controlled-only (open / onOpenChange props) so the dropdown can drive
its open state without rendering a second visible trigger.

The Wikipedia extract loses its pt-3 — the divide-y border between the
preceding section and the extract was already providing visual
separation, and the extra padding made the extract feel disconnected
from the rest of the header.
2026-05-15 22:44:37 -05:00
Chad Curtis 673c09c08d Fix anthem playback: MP3 transcodes, gesture-preserving play()
Three bugs were stacking on top of each other to make the anthem button
silently do nothing:

1. **Double-encoded URL.** SPARQL returns Commons audio as
   `Special:FilePath/<percent-encoded-filename>`. The commonsUrl()
   helper re-encoded the already-encoded string, so spaces became %2520
   and the resulting URL 404'd. Replaced with two helpers: commonsImageUrl
   (decodes then re-encodes, returns https://) and commonsFilename
   (decodes once to a plain filename) and routed the anthem through
   the latter so we can hit MediaWiki API instead.

2. **OGG Vorbis doesn't play in Safari / iOS WKWebView.** Commons anthems
   are almost always Ogg Vorbis; Apple browsers can't decode them. New
   useCommonsAudio hook queries the MediaWiki videoinfo API to get the
   full derivatives list (original OGG + server-side MP3 transcode) and
   renders one <source> per format on the <audio> element. The button
   sorts MP3 first so Safari picks the playable one, while Chrome/Firefox
   are happy either way.

3. **play() called from a useEffect, not the click handler.** The old
   AnthemButton lazy-mounted the audio element on first click via state,
   then tried to call play() from an effect after the re-render. By that
   point browsers had dropped the user-gesture token and silently
   rejected the play promise. Now the <audio> is always mounted with
   preload="none" (no bytes fetched upfront) and play() runs
   synchronously inside the click handler.

Also added error handling: the play() promise rejection is now caught
and surfaced via toast, the <audio> onError fires a toast, and the
button doesn't render at all when no playable derivative exists, so
the user never sees a dead button.
2026-05-15 22:39:53 -05:00
Chad Curtis 3014470398 Cut country header noise; coat of arms and anthem move into the hero
The fast-facts grid was eight tiles of mixed cognitive weight (capital,
population, area, languages, currency, government, established, demonym).
Demonym, government, area, and inception read as encyclopedic rather than
destination-y, so the row collapses to four signals — Capital · Population
· Languages · Currency — inline with bullet separators instead of a grid.
Renders nothing if all four are missing, so sparsely-documented countries
don't get a half-empty row.

The weather widget loses feels-like, humidity, wind, and the day/night
indicator. The hero gradient and weather icon already signal time-of-day;
the rest belonged in a forecast widget, not a destination header.

Coat of arms moves out of its awkward 'flag-of-row + label' container and
sits inline next to the flag emoji in the hero title block, where it reads
as heraldic identity. Falls back to nothing on image error.

National anthem becomes a small circular play button next to the country
name in the hero — icon-only, with the anthem title in the tooltip. The
\"Play anthem\" placeholder label is gone; the country name is the label.

Result: most countries now render hero + one weather line + one vitals
line + Wikipedia extract. Optional flair (coat of arms, anthem, motto,
official native name) layers in only when Wikidata has it.
2026-05-15 22:34:02 -05:00
Chad Curtis 61dca177c7 Transform country page into an immersive destination
Replace the boxed CountryContentHeader with a cinematic edge-to-edge hero
backed by Wikipedia's high-res country photo. The image fades into the page
background via a multi-stop gradient that tints warm amber/rose during the
destination's daytime and deep indigo/violet at night (driven by the existing
weather.isDay signal). Flag, country name, official native name, and Wikidata
motto sit bottom-anchored over the photo with text-shadow for legibility; the
Follow button moves onto the hero in white-on-glass style.

A new useCountryFacts hook fetches richer Wikidata via a single SPARQL query:
capital, population, area, languages, currencies, government type, inception
date, demonym, anthem audio (Commons), coat of arms image (Commons), motto,
and official native names. The hook only runs for sovereign alpha-2 codes;
subdivisions like US-CA fall back to the existing flag/name/Wikipedia path.

Below the hero, the weather widget is reskinned as a borderless inline strip
and a new fast-facts grid surfaces the Wikidata fields without card chrome.
The optional coat of arms renders inline; the optional anthem mounts a lazy
<audio> element only after the user clicks Play, so multi-megabyte OGG files
from Commons aren't fetched for every page view.

The header is lifted out of ExternalContentPage's px-4 wrapper so its edges
bleed flush to the column rails — the 'you have arrived' feeling depends on
the photo touching the rails rather than floating in a padded box.
2026-05-15 22:27:34 -05:00
Chad Curtis 835b13b7e9 Hide country comment context on its own feed page
When a kind 1111 reply rooted to an iso3166 identifier is rendered inside
that same country's feed page, the 'Commenting on <country>' header is
redundant — the post is a top-level neighborhood entry, not a contextual
reply. CountryCommentContext now consults CountryFeedContext and renders
nothing when its identifier matches the surrounding country feed. The
header still appears everywhere else (profiles, notifications, search)
so users can see what a post is rooted to.
2026-05-15 22:11:36 -05:00
Chad Curtis 0bb59fd4ba Move country stats into a header button modal 2026-05-15 22:09:51 -05:00
lemon e066bdb482 Add boost action and improve quote rendering 2026-05-15 19:37:23 -07:00
lemon a486de06ba Improve Agent inline code contrast 2026-05-15 19:37:23 -07:00
lemon 4a71c15b28 Align Agent feed sources with app tabs 2026-05-15 19:37:23 -07:00
lemon 07fb778c4c Improve Agent world feed and loading states 2026-05-15 19:37:23 -07:00
lemon c7c7dd8b68 Remove Agent credits gate 2026-05-15 19:37:23 -07:00
lemon 331b4a9b08 Use Agora bolt mark in onboarding screens 2026-05-15 19:37:23 -07:00
lemon 2cb55ee44d Keep mobile safe area covered by top bar 2026-05-15 19:37:23 -07:00
lemon 5dd185d711 Refine mobile bottom navigation 2026-05-15 19:37:23 -07:00
lemon 06764648a5 Refine community cards and agent suggestion 2026-05-15 19:37:23 -07:00
lemon 825e5c790b Simplify mobile drawer shape 2026-05-15 19:37:23 -07:00
lemon b459081dfb Use bolt mark in Agora header lockups 2026-05-15 19:37:23 -07:00
lemon 3a772af66e Update Agora app icons 2026-05-15 19:37:23 -07:00
lemon 5c7691c426 Align default navigation and primary brand color 2026-05-15 19:37:23 -07:00
lemon 003251028f Fix duplicate AI chat error display 2026-05-15 19:37:23 -07:00
lemon 6e101e89ef Configure AI provider settings 2026-05-15 19:37:23 -07:00
lemon 37513cd44c Add slash commands with autocomplete, /tools listing, and styled notice messages 2026-05-15 19:37:23 -07:00
lemon 28ef1d725e Harden AI chat: SSRF protection, capacity tracking, scoped storage, and error handling 2026-05-15 19:37:23 -07:00
lemon e2d30255bc Add AI Agent chat with tool-calling, model selector, and sidebar integration
- Implement 5 read-only tools: get_feed, search_users, search_follow_packs, fetch_page, fetch_event
- Upgrade useShakespeare streaming to support tool calls, AbortSignal, and robust SSE parsing
- Create useAIChatSession hook with streaming, 10-round tool loop, localStorage persistence
- Rewrite AIChatPage with modular architecture, streaming UI, tool call badges, and empty-bubble handling
- Add Agent settings section with model dropdown selector and pre-populated system prompt editor
- Add Agent to left sidebar navigation and right widget sidebar defaults
- Add aiModel and aiSystemPrompt config fields with encrypted settings sync
2026-05-15 19:37:23 -07:00
Chad Curtis 9d6c9c2a40 Blend community banner into tabs and tighten its layout
Wrap the hero banner and tab strip in a shared image+gradient backdrop
so the banner image continues underneath the tabs and fades into the
page background, removing the hard seam between them. The gradient
holds heavy darkness through the tab strip (kept legible with light
tab text + drop-shadowed underline) and drops to the page bg only at
the very bottom edge.

Reduce banner cognitive load: move the description behind an Info
button next to the title (drop the inline line-clamp and its
ResizeObserver-based clipping detection), promote the avatar stack
above the title row, and shorten the banner aspect ratio (2:1 mobile,
21:9 desktop).
2026-05-15 19:37:23 -07:00
Chad Curtis 9b9a04d468 Add icon and hover-to-unfollow options to FollowToggleButton 2026-05-15 19:37:23 -07:00
Chad Curtis 12f03ce75d Restructure community tabs: fuse discussion into Activity, add Pulse, expandable description 2026-05-15 19:37:23 -07:00
Chad Curtis 2d260b37b5 Replace the world page bottom drawer with a floating discovery launcher
The persistent vaul bottom sheet ate half the map on phones and tablets
and made the docked discovery panel crush the map between 900px and
1280px. Swap it for a centered Dialog modal opened by a single button
anchored top-right next to Leaflet's zoom controls.

The docked WorldDiscoveryPanel now hides below xl (1280px) instead of
the sidebar breakpoint (900px) so the map stays usable when the panel
would otherwise crowd it. CommunityStatsPanel drops its rounded-2xl
card border in compact mode so it doesn't render box-in-a-box inside
the modal or docked panel.

Initial map zoom bumps from 2 to 3 below xl so phones and tablets
don't see ocean bands above and below the world tiles. Default
viewport center moves to Venezuela. Leaflet zoom controls picked up
themed background / foreground / border / primary-on-hover styling to
match the rest of the UI, and the country search header is opaque so
it no longer renders blurry inside the modal.
2026-05-15 19:37:23 -07:00
Chad Curtis 49a11e545b Anchor the community FAB menu to the FAB and merge Goals + Events
The community detail page previously fanned out the FAB into a stack of
chips positioned in the page's bottom-right corner, which drifted away
from the actual FAB on desktop (where the FAB is sticky inside the
center column). Move the menu into FloatingComposeButton itself: when a
page declares a `fabMenu` via useLayoutOptions, the FAB renders as a
Radix Popover trigger and the menu opens anchored to it on both mobile
and desktop. Hover state inverts to primary surface + foreground so
icons stop sitting on a same-color background (`--accent` mirrors
`--primary` in this theme system).

Initiatives now renders goals + events as one chronological list. The
sub-toggle is gone; active events sort ascending by start date, then
active goals by newest, then a single Past section in descending order
by closing/end timestamp.
2026-05-15 19:37:23 -07:00
Chad Curtis 873c9abf32 Move community members into the banner with an inline add-member dialog
The community detail page now mirrors the adventure-detail / follow-pack
banner pattern: the hero image fills the top area with a gradient
overlay, and the title, description, member avatar stack, follow toggle,
members-only filter, edit, and share controls all sit inside it. The
former Members tab is gone; tapping the avatar stack opens a dedicated
members dialog that hosts the badge panel, leadership and rank-and-file
sections, ban controls, and (for founders/mods) an inline AddMemberPanel
so search-and-add happens in the same surface instead of a second
dialog hop.

To support that embed, AddMemberDialog's form body is extracted into a
reusable AddMemberPanel export; the thin Dialog wrapper is kept for any
existing callers and now delegates submit/reset/close to an onComplete
callback.
2026-05-15 19:37:23 -07:00
Chad Curtis 207794e714 Restyle navigation with V-angled bars and Agora bolt feed button
Replace the smooth arc shapes shared by the mobile top bar, sub-header
tabs, and bottom nav with angled V polylines centralized in
ArcBackground. The top bar and sub-header now use flat rectangles, and
the bottom nav has a sharp V apex that cradles a centered Agora-bolt
Feed button. The bottom nav row layout changes from
[Home, Search, Notifications, Profile] to
[Search, Communities, _apex_, Notifications, World] with smaller outer
items, and the apex links to the configured home/feed page with
scroll-to-top + invalidation on re-tap.

Also drop the redundant 'Feed' page header on the home feed and the
border under the compact ComposeBox so it blends with the tabs strip
below it.
2026-05-15 19:37:23 -07:00
lemon 37b315f06f Simplify default sidebar order 2026-05-15 19:37:22 -07:00
lemon 897a3f94a1 Update onboarding follow pack and world CTA 2026-05-15 19:37:22 -07:00
lemon a9b6665ba0 Fold followed hashtags into the Following feed and drop tag tabs
- useFollowingFeed now also queries posts for the user's followed
  hashtag interests (NIP-51 kind 10015 t tags) and merges them into
  the combined Following feed, subject to the same recency floor.
- Drop the per-hashtag and per-geotag tabs from the home feed
  subheader. Legacy 'hashtag:'/'geotag:' session-storage values fall
  back to the Following tab.
- Invalidate the new following-feed query keys when interests change
  so the Following feed refreshes immediately on follow/unfollow.
- Remove the now-dead HashtagFeedContent and GeotagFeedContent
  components and their unused imports.
2026-05-15 19:37:22 -07:00
lemon 0fc86aaa5e Add Following feed combining people, communities, and countries
- Split the home feed's old Follows tab into 'Following' (combined) and
  'Network' (people-only, original behavior preserved).
- Add country follows via NIP-51 kind 10015 i tags (iso3166:XX), with
  a Follow/Unfollow button on country pages reusing FollowToggleButton.
- New useFollowingFeed merges network + community activity + followed
  country events, sorted strictly by recency. A recency floor (oldest
  loaded network item, or now-14d when network is empty) prevents
  sparse sources from surfacing old events too early.
- Empty state on Following is country-centric and routes to the World
  tab to encourage country discovery.
- Invalidate the new feed query keys on follow/unfollow and
  community-bookmark mutations.
2026-05-15 19:37:22 -07:00
Alex Gleason 61b02fe011 Make AGENTS.md require committing at the end of every task
The default behavior of waiting for an explicit commit request leads
to leaving the working tree dirty between turns. Agora's expectation
is the opposite — finish a task, commit the result, let the user
decide when to push.
2026-05-15 14:55:23 -05:00
Alex Gleason 5b4138046a Add merge-upstream skill for syncing with Ditto
Agora is a fork of Ditto and periodically pulls in upstream changes.
The skill documents the remote setup, fetch/merge/validate flow, and
— most importantly — Agora's deliberate divergences (no Blobbi, no
onchain wallet, Breeze Lightning only) so future conflict resolution
sides with Agora's direction instead of silently reintroducing removed
features.
2026-05-15 14:54:20 -05:00
Alex Gleason ecda6619a5 Don't never commit 2026-05-15 14:50:32 -05:00
Sam Thomson cd5b9adc5e Merge branch 'feat/agora3-dashboard-count-queries' into 'main'
Add mock Event Dashboard page to agora-3

Closes #11

See merge request soapbox-pub/agora-3!21
2026-05-14 13:24:26 +00:00
filemon 0b4a88a83e Stabilize totalPosts KPI by clamping COUNT to local post count for states view 2026-05-13 19:40:13 -03:00
filemon eed5c2fc5a Add hybrid NIP-45 COUNT stabilizer for dashboard KPIs
Use relay-side COUNT as a stable floor for totalPosts and state-level
leaderboard/distribution counts. Falls back gracefully to event-based
counts if the relay does not support NIP-45. Surfaces legacy
content-scan municipality matches as a separate hint on the KPI tile.

- totalPosts: globalCount ?? viewPosts.length
- Leaderboard/distribution: Math.max(eventCount, stateCountFromRelay)
- Participants: stays fully event-based (preserves live/activity semantics)
- Municipalities: stays fully event-based (no per-muni COUNT queries)
- legacyDetected: deduplicated count of posts attributed via content scan
2026-05-12 22:52:40 -03:00
filemon 20088b3d2b Fix dashboard review issues: remove dead status branch, clamp pagination, clean sidebar dividers 2026-05-12 20:25:32 -03:00
filemon 6ae0736576 Merge branch 'main' into feat/agora3-event-dashboard 2026-05-12 12:27:44 -03:00
filemon b895c6c696 Rename user-facing 'Event Dashboard' label to 'Dashboard'
Move canonical route to /dashboard with a redirect from /event-dashboard.
Update sidebar item id to 'dashboard'. Internal file/hook names unchanged.
2026-05-04 22:08:58 -03:00
filemon 259d33b71b Fix Event Dashboard self-review findings: stale KPIs, error visibility, safe assertions 2026-05-04 21:48:38 -03:00
filemon be1d6f1ece Fix Event Dashboard backfill instability: prevent premature completion on timeout
The paginated backfill loop could mark itself as 'done' even when pages
timed out or when a retry overlap page returned only already-known events.
This caused counts to differ between reloads depending on network timing.

Changes to useMultiHashtagFeed.ts:
- Add reachedEnd flag: backfillDone only set when a definitive end condition
  is confirmed (empty batch, reached since, sub-limit overlap, or MAX_EVENTS)
- Timeout/abort sets completedCleanly=false, preventing premature mark
- Resume cursor derived from eventMap minimum timestamp on retry
- Saturated boundary handling: if overlap page at limit returns zero new
  events, fall back to exclusive cursor (oldestInBatch - 1) to probe older
- MAX_EVENTS (10,000) hard cap as explicit constant
- Query DITTO_RELAY directly via NRelay1 to avoid NPool eoseTimeout

Also includes: ConfigDrawer, territorial coverage utilities, content
fallback for municipality attribution, and real relay data integration.
2026-05-04 20:52:12 -03:00
filemon 2a4cf19488 Connect Event Dashboard to real relay data (Phase 2)
- Add stripped venezuelaTerritorial.ts (24 states + 379 municipalities, no parroquias)
- Add useEventDashboardConfig hook with hardcoded default config
- Add useMultiHashtagFeed hook with backfill/polling/dedup and enabled option
- Add useEventDashboard adapter hook: config → feed → aggregation → typed outputs
- Wire EventDashboardPage to real data; show loading skeleton and error states
- RecentActivityList now resolves profiles via useAuthor
- Non-admin users never trigger relay queries (enabled: false guards all queries)
- No ConfigDrawer, parroquias, territorialCoverage, verified badges, or content
  fallback ported — those remain deferred to Phase 3
2026-05-04 17:53:05 -03:00
filemon 7031b09f45 Add mock Event Dashboard page to agora-3
- Add admin-gated /event-dashboard route with visual-only mock dashboard
- Add requiresAdmin support to SidebarItemDef; filter admin-only items
  from desktop sidebar, mobile drawer, More menu, and search suggestions
- Dashboard components: KPI grid, activity chart, top regions bar chart,
  distribution donut, participants list, recent activity feed, skeleton
- Page-level access control mirrors OrganizersPage pattern (login prompt
  for logged-out users, locked card for non-admins, full content for admins)
- Uses noMaxWidth layout for wider charts within the standard app shell
- All data is static mock (Venezuelan municipality codes in real CNE format)
- No relay fetching, config drawer, dedup/backfill, or localStorage yet
2026-05-04 16:54:56 -03:00
385 changed files with 35676 additions and 21316 deletions
+109
View File
@@ -0,0 +1,109 @@
---
name: merge-upstream
description: Merge upstream changes from the Ditto repo (which Agora is a fork of) into Agora's main branch. Load when the user asks to "merge upstream", "pull from Ditto", "sync with Ditto", or otherwise update Agora with new commits from soapbox-pub/ditto.
---
# Merge Upstream from Ditto
Agora is a fork of [Ditto](https://gitlab.com/soapbox-pub/ditto). This skill walks through pulling new commits from upstream Ditto and merging them into Agora's `main` branch, while making philosophy-aware decisions on merge conflicts.
## Philosophy: Agora vs. Ditto
Agora has diverged from Ditto on purpose in several areas. When resolving conflicts, side with Agora's direction unless the upstream change is clearly a generic bug fix or improvement that applies to both. Known divergences:
- **No Blobbi** — Agora has removed Blobbi support. If an upstream change adds or modifies Blobbi-related code, prefer to drop the Blobbi parts rather than reintroduce them.
- **Lightning-only wallet** — Agora uses a Breeze Lightning wallet. **No onchain functionality exists in Agora**, even though Ditto includes it. Reject upstream onchain wallet code; keep onchain-related conflicts resolved to Agora's Lightning-only path.
- **General rule** — if upstream reintroduces a feature Agora deliberately removed, the deliberate removal wins. When in doubt, ask the user before resolving a conflict that touches a known divergence.
Spend a moment scanning the conflict for these themes before mechanically resolving line-by-line.
## Procedure
### Step 1: Ensure the `ditto` remote exists
Check the current remotes:
```bash
git remote -v
```
If `ditto` is not listed (pointing to `https://gitlab.com/soapbox-pub/ditto.git` or the equivalent `git@gitlab.com:soapbox-pub/ditto.git`), add it:
```bash
git remote add ditto https://gitlab.com/soapbox-pub/ditto.git
```
If a `ditto` remote exists but points elsewhere, fix it with `git remote set-url ditto <url>`.
### Step 2: Confirm a clean working tree on `main`
```bash
git status
git branch --show-current
```
The working tree must be clean and the current branch must be `main`. If not, stop and ask the user how to proceed — do not stash or switch branches automatically.
### Step 3: Fetch from Ditto
```bash
git fetch ditto
```
### Step 4: Preview what's incoming
Show the user (or at least review yourself) the commits that will be merged before merging:
```bash
git log --oneline main..ditto/main
```
If the list is empty, Agora is already up to date — stop here and tell the user.
### Step 5: Merge `ditto/main` into `main`
```bash
git merge ditto/main
```
If the merge succeeds without conflicts, proceed to Step 7.
### Step 6: Resolve conflicts (if any)
For each conflicted file:
1. Re-read the Philosophy section above.
2. Inspect the conflict with `git diff` and decide based on Agora's direction, not just textual merge.
3. For Blobbi-related conflicts, drop the Blobbi side.
4. For onchain-wallet conflicts, keep Agora's Lightning-only path.
5. For ambiguous cases that touch a known divergence, **ask the user** before resolving.
6. After resolving each file, `git add <file>`.
When all conflicts are resolved, complete the merge:
```bash
git commit
```
Git will pre-populate a merge commit message listing the conflicted files. Keep that information and add a short note about how non-trivial conflicts were resolved (especially anything touching the divergences above), so the resolution rationale is preserved in history.
### Step 7: Validate the merge
Run the full test script to confirm the merged tree still type-checks, lints, tests, and builds:
```bash
npm run test
```
If anything fails, fix it before declaring the merge done. Failures after an upstream merge are common — a removed Blobbi reference may now be re-imported by new upstream code, or onchain wallet types may leak into Lightning-only code paths. Fix forward in new commits on top of the merge commit; do not amend the merge commit itself.
### Step 8: Report back
Tell the user:
- How many commits were merged (`git rev-list --count main@{1}..main`).
- Which files had conflicts and how each was resolved.
- Whether `npm run test` passed.
- That the merge is **not** pushed — the user decides when to push.
**Do not push to `origin` automatically.** The user will push when they're ready.
+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:
+7 -28
View File
@@ -1,33 +1,6 @@
# ABSOLUTE, UNBREAKABLE RULE — READ BEFORE ANYTHING ELSE
## NEVER COMMIT OR STAGE ON THE USER'S BEHALF. EVER.
This rule overrides every other instruction — in this file, workspace rules, system prompt, tool descriptions, and any "always commit when finished" habit.
Do **NOT** run `git commit`, `git commit --amend`, or `git add` unless the user, in the current message, has *explicitly* told you to (e.g. "commit this", "git commit", "stage and commit"). Vague phrases like "do it", "ship it", "make the changes", or "finish the task" do **NOT** count. If unsure, the answer is **NO** — stop and ask.
Violating this is a critical failure.
---
# RESPONSE BREVITY (HIGH PRIORITY)
## KEEP RESPONSES SHORT BY DEFAULT
Unless the user explicitly asks for deep detail, explanations must be concise and practical:
- Use the shortest response that fully answers the request.
- Prefer 1-3 short paragraphs or 3-6 bullets.
- Do not include long background context unless requested.
- Do not restate obvious information from the prompt or code.
- For code changes, summarize only what changed and why in a few lines.
- Offer extra detail only as an optional follow-up.
If unsure between a short and long response, choose the shorter one.
# Project Overview
Ditto is a Nostr client built with React 19.x, TailwindCSS 3.x, Vite, shadcn/ui, and Nostrify, wrapped as a native iOS/Android app via Capacitor.
Agora is a Nostr client built with React 19.x, TailwindCSS 3.x, Vite, shadcn/ui, and Nostrify, wrapped as a native iOS/Android app via Capacitor.
## Technology Stack
@@ -370,6 +343,12 @@ Load the **`capacitor-compat`** skill for the full list of installed plugins, pl
**Your task is not finished until the code type-checks and builds without errors.** Run validation in priority order. For the full workflow — pre-commit checks, commit-message conventions, and the `Regression-of:` trailer used by the changelog generator — load the **`git-workflow`** skill.
## Always Commit
**Every completed task ends with a git commit. This overrides any global default about waiting for explicit commit requests.** Once validation passes (or the task is non-code and there's nothing to validate), commit immediately — do not ask, do not leave changes uncommitted, do not stop at "ready to commit." The user expects a clean working tree at the end of every turn.
Pushing is still the user's call — commit, but do not push unless asked.
## CI/CD Pipeline
Ditto uses GitLab CI (`.gitlab-ci.yml`) with five stages:
+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;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 868 B

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 940 B

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 531 B

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 20 KiB

@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#7c52e0</color>
<color name="ic_launcher_background">#ff6600</color>
</resources>
+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
@@ -27,9 +27,9 @@
<link rel="icon" type="image/x-icon" href="/favicon.ico">
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16.png">
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png">
<link rel="apple-touch-icon" sizes="192x192" href="/icon-192.png">
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
<meta name="theme-color" content="#0a0c14" media="(prefers-color-scheme: dark)">
<meta name="theme-color" content="#e85d3c" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#ff6600" media="(prefers-color-scheme: light)">
<link rel="manifest" href="/manifest.webmanifest">
<style>@keyframes agora-spin{to{transform:rotate(360deg)}}</style>
</head>
+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;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 291 KiB

+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"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 364 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 396 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 569 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 441 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 520 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 614 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 403 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 496 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 441 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 981 B

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

+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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 226 KiB

After

Width:  |  Height:  |  Size: 98 KiB

+3 -15
View File
@@ -1,17 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
>
<path
d="M13 2L3 14h8l-1 8 10-12h-8l1-8z"
fill="black"
stroke="black"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 720 880" fill="black">
<path d="M415.596 0L0 417.12L284.123 702.287L346.922 468.3H189.533L415.596 0Z" />
<path d="M431.879 127.936L363.762 381.328H527.876L258.808 880L720 417.114L431.879 127.936Z" />
</svg>

Before

Width:  |  Height:  |  Size: 320 B

After

Width:  |  Height:  |  Size: 303 B

+3 -8
View File
@@ -5,7 +5,7 @@
"start_url": "/",
"display": "standalone",
"background_color": "#0a0c14",
"theme_color": "#e85d3c",
"theme_color": "#ff6600",
"icons": [
{
"src": "/icon-192.png",
@@ -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`);
}
}
+15 -28
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",
@@ -119,17 +116,15 @@ const hardcodedConfig: AppConfig = {
followsFeedShowReplies: true,
},
sidebarOrder: [
"wallet",
"verified",
"actions",
"polls",
"world",
"badges",
"feed",
"notifications",
"messages",
"communities",
"world",
"wallet",
"agent",
"messages",
"profile",
"notifications",
"search",
"settings",
],
nip85StatsPubkey:
@@ -156,15 +151,12 @@ const hardcodedConfig: AppConfig = {
sidebarWidgets: [
{ id: 'trends' },
{ 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: 'google/gemma-4-26b',
aiSystemPrompt: '',
};
/**
@@ -207,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>
+38 -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,27 +45,32 @@ 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 })));
const NotificationSettings = lazy(() => import("./pages/NotificationSettings").then(m => ({ default: m.NotificationSettings })));
const NotificationsPage = lazy(() => import("./pages/NotificationsPage").then(m => ({ default: m.NotificationsPage })));
const OrganizersPage = lazy(() => import("./pages/OrganizersPage").then(m => ({ default: m.OrganizersPage })));
const EventDashboardPage = lazy(() => import("./pages/EventDashboardPage").then(m => ({ default: m.EventDashboardPage })));
const PhotosFeedPage = lazy(() => import("./pages/PhotosFeedPage").then(m => ({ default: m.PhotosFeedPage })));
const PodcastsFeedPage = lazy(() => import("./pages/PodcastsFeedPage").then(m => ({ default: m.PodcastsFeedPage })));
const PrivacyPolicyPage = lazy(() => import("./pages/PrivacyPolicyPage").then(m => ({ default: m.PrivacyPolicyPage })));
@@ -75,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")!;
@@ -152,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 />} />
@@ -175,7 +194,6 @@ export function AppRouter() {
path="/settings/notifications"
element={<NotificationSettings />}
/>
<Route path="/settings/messaging" element={<MessagingSettingsPage />} />
<Route
path="/settings/advanced"
element={<AdvancedSettingsPage />}
@@ -184,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 */}
@@ -267,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 />} />
@@ -277,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 />} />
@@ -291,7 +315,13 @@ 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 />} />
<Route path="/event-dashboard" element={<Navigate to="/dashboard" replace />} />
{/* Callback target for remote signers (e.g. Amber, Primal) after NIP-46 approval */}
<Route path="/remoteloginsuccess" element={<RemoteLoginSuccessPage />} />
+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>
);
}
-802
View File
@@ -1,802 +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[];
}
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 { 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);
const [portalContainer, setPortalContainer] = useState<HTMLElement | undefined>(undefined);
const dialogContentRef = useCallback((node: HTMLElement | null) => {
setPortalContainer(node ?? undefined);
}, []);
// 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);
}, []);
const handleOpenChange = useCallback((nextOpen: boolean) => {
if (!nextOpen) resetForm();
onOpenChange(nextOpen);
}, [onOpenChange, resetForm]);
// ── 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` });
handleOpenChange(false);
} 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, handleOpenChange, applyOptimisticMembership, isBadgeImageUploading,
]);
if (!user) return null;
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<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 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>
</ScrollArea>
</PortalContainerProvider>
</DialogContent>
</Dialog>
);
}
// ── 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>
);
}
+228 -7
View File
@@ -1,15 +1,21 @@
import { useState } from 'react';
import { ChevronDown, ChevronUp, Bug, RotateCcw, AlertTriangle } from 'lucide-react';
import { useState, useEffect } from 'react';
import { ChevronDown, ChevronUp, RotateCcw, AlertTriangle, Eye, EyeOff } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
import { RequestToVanishDialog } from '@/components/RequestToVanishDialog';
import { useAppContext } from '@/hooks/useAppContext';
import { useToast } from '@/hooks/useToast';
import { useEncryptedSettings } from '@/hooks/useEncryptedSettings';
import { useCurrentUser } from '@/hooks/useCurrentUser';
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 = 'google/gemma-4-26b';
/** The build-time default DSN from the environment variable. */
const DEFAULT_SENTRY_DSN = import.meta.env.VITE_SENTRY_DSN || '';
@@ -20,6 +26,7 @@ export function AdvancedSettings() {
const { updateSettings } = useEncryptedSettings();
const { user } = useCurrentUser();
const [systemOpen, setSystemOpen] = useState(true);
const [aiOpen, setAiOpen] = useState(false);
const [sentryOpen, setSentryOpen] = useState(false);
const [dangerOpen, setDangerOpen] = useState(false);
const [vanishDialogOpen, setVanishDialogOpen] = useState(false);
@@ -28,6 +35,73 @@ export function AdvancedSettings() {
const [linkPreviewUrl, setLinkPreviewUrl] = useState(config.linkPreviewUrl);
const [corsProxy, setCorsProxy] = useState(config.corsProxy);
const [sentryDsn, setSentryDsn] = useState(config.sentryDsn);
const [baseUrlDraft, setBaseUrlDraft] = useState(config.aiBaseURL);
const [apiKeyDraft, setApiKeyDraft] = useState(config.aiApiKey);
const [modelDraft, setModelDraft] = useState(config.aiModel);
const [showApiKey, setShowApiKey] = useState(false);
const [systemPromptDraft, setSystemPromptDraft] = useState(config.aiSystemPrompt || DEFAULT_SYSTEM_PROMPT_TEMPLATE);
useEffect(() => { setBaseUrlDraft(config.aiBaseURL); }, [config.aiBaseURL]);
useEffect(() => { setApiKeyDraft(config.aiApiKey); }, [config.aiApiKey]);
useEffect(() => { setModelDraft(config.aiModel); }, [config.aiModel]);
const commitBaseUrl = () => {
const trimmed = baseUrlDraft.trim().replace(/\/+$/, '');
if (!trimmed) {
setBaseUrlDraft(DEFAULT_AI_BASE_URL);
if (config.aiBaseURL !== DEFAULT_AI_BASE_URL) {
updateConfig((current) => ({ ...current, aiBaseURL: DEFAULT_AI_BASE_URL }));
toast({ title: 'Base URL reset to default' });
}
return;
}
if (trimmed !== config.aiBaseURL) {
updateConfig((current) => ({ ...current, aiBaseURL: trimmed }));
toast({ title: 'AI base URL updated' });
}
};
const commitApiKey = () => {
const trimmed = apiKeyDraft.trim();
if (trimmed !== config.aiApiKey) {
updateConfig((current) => ({ ...current, aiApiKey: trimmed }));
toast({ title: trimmed ? 'API key updated' : 'API key cleared (using NIP-98 auth)' });
}
};
const commitModel = () => {
const trimmed = modelDraft.trim();
if (!trimmed) {
setModelDraft(DEFAULT_AI_MODEL);
if (config.aiModel !== DEFAULT_AI_MODEL) {
updateConfig((current) => ({ ...current, aiModel: DEFAULT_AI_MODEL }));
toast({ title: 'AI model reset to default' });
}
return;
}
if (trimmed !== config.aiModel) {
updateConfig((current) => ({ ...current, aiModel: trimmed }));
toast({ title: 'AI model updated' });
}
};
const resetProviderDefaults = () => {
setBaseUrlDraft(DEFAULT_AI_BASE_URL);
setApiKeyDraft('');
setModelDraft(DEFAULT_AI_MODEL);
updateConfig((current) => ({
...current,
aiBaseURL: DEFAULT_AI_BASE_URL,
aiApiKey: '',
aiModel: DEFAULT_AI_MODEL,
}));
toast({ title: 'Provider settings reset to defaults' });
};
const providerIsDefault =
config.aiBaseURL === DEFAULT_AI_BASE_URL &&
config.aiApiKey === '' &&
config.aiModel === DEFAULT_AI_MODEL;
const handleStatsPubkeyChange = (value: string) => {
setStatsPubkey(value);
@@ -42,6 +116,156 @@ export function AdvancedSettings() {
return (
<div>
{/* Agent Section */}
<div>
<Collapsible open={aiOpen} onOpenChange={setAiOpen}>
<CollapsibleTrigger asChild>
<Button
variant="ghost"
className="relative w-full justify-between px-3 py-3.5 h-auto hover:bg-muted/20 hover:text-foreground rounded-none"
>
<span className="text-base font-semibold">Agent</span>
{aiOpen ? (
<ChevronUp className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
)}
<div className="absolute bottom-0 left-0 right-0 h-1 bg-primary rounded-full" />
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="px-3 pt-3 pb-4 space-y-5 border-b border-border">
{/* AI Base URL */}
<div>
<Label htmlFor="ai-base-url" className="text-sm font-medium">
Base URL
</Label>
<p className="text-xs text-muted-foreground mt-1 mb-2">
OpenAI-compatible <code className="bg-muted px-1 rounded">/v1</code> endpoint. An API key is required for endpoints that don't support NIP-98 auth.
</p>
<Input
id="ai-base-url"
type="url"
value={baseUrlDraft}
onChange={(e) => setBaseUrlDraft(e.target.value)}
onBlur={commitBaseUrl}
placeholder={DEFAULT_AI_BASE_URL}
className="font-mono text-base md:text-sm"
autoComplete="off"
spellCheck={false}
/>
</div>
{/* API Key */}
<div>
<Label htmlFor="ai-api-key" className="text-sm font-medium">
API key
</Label>
<p className="text-xs text-muted-foreground mt-1 mb-2">
Optional. Required for endpoints that use standard API-key auth (e.g. OpenAI, Anthropic, OpenRouter).
</p>
<div className="flex gap-2">
<Input
id="ai-api-key"
type={showApiKey ? 'text' : 'password'}
value={apiKeyDraft}
onChange={(e) => setApiKeyDraft(e.target.value)}
onBlur={commitApiKey}
placeholder="Leave empty to use NIP-98 auth"
className="font-mono text-base md:text-sm"
autoComplete="off"
spellCheck={false}
/>
<Button
type="button"
variant="outline"
size="icon"
onClick={() => setShowApiKey((value) => !value)}
aria-label={showApiKey ? 'Hide API key' : 'Show API key'}
>
{showApiKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button>
</div>
</div>
{/* AI Model */}
<div>
<Label htmlFor="ai-model" className="text-sm font-medium">
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">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"
type="text"
value={modelDraft}
onChange={(e) => setModelDraft(e.target.value)}
onBlur={commitModel}
placeholder={DEFAULT_AI_MODEL}
className="font-mono text-base md:text-sm"
autoComplete="off"
spellCheck={false}
/>
{!providerIsDefault && (
<Button
variant="ghost"
size="sm"
className="h-7 text-xs text-muted-foreground mt-2"
onClick={resetProviderDefaults}
>
<RotateCcw className="h-3 w-3 mr-1" />
Reset provider to default
</Button>
)}
</div>
{/* AI System Prompt */}
<div>
<Label htmlFor="ai-system-prompt" className="text-sm font-medium">
System Prompt
</Label>
<p className="text-xs text-muted-foreground mt-1 mb-2">
The base system prompt sent to the AI. Supports <code className="bg-muted px-1 rounded">{'{{SAVED_FEEDS}}'}</code> and <code className="bg-muted px-1 rounded">{'{{USER_IDENTITY}}'}</code> placeholders.
</p>
<Textarea
id="ai-system-prompt"
value={systemPromptDraft}
onChange={(e) => setSystemPromptDraft(e.target.value)}
onBlur={() => {
const trimmed = systemPromptDraft.trim();
const defaultPrompt = DEFAULT_SYSTEM_PROMPT_TEMPLATE;
// If the user reverted back to the default text, store empty (meaning "use default")
const valueToStore = trimmed === defaultPrompt ? '' : trimmed;
if (valueToStore !== config.aiSystemPrompt) {
updateConfig(() => ({ aiSystemPrompt: valueToStore }));
toast({ title: valueToStore ? 'System prompt updated' : 'System prompt reset to default' });
}
}}
className="min-h-[120px] max-h-[400px] resize-y font-mono text-base leading-relaxed"
/>
{config.aiSystemPrompt && (
<Button
variant="ghost"
size="sm"
className="h-7 text-xs text-muted-foreground mt-2"
onClick={() => {
setSystemPromptDraft(DEFAULT_SYSTEM_PROMPT_TEMPLATE);
updateConfig(() => ({ aiSystemPrompt: '' }));
toast({ title: 'System prompt reset to default' });
}}
>
<RotateCcw className="h-3 w-3 mr-1" />
Reset to default
</Button>
)}
</div>
</div>
</CollapsibleContent>
</Collapsible>
</div>
{/* System Section (includes Stats Source) */}
<div>
<Collapsible open={systemOpen} onOpenChange={setSystemOpen}>
@@ -188,10 +412,7 @@ export function AdvancedSettings() {
variant="ghost"
className="relative w-full justify-between px-3 py-3.5 h-auto hover:bg-muted/20 hover:text-foreground rounded-none"
>
<span className="flex items-center gap-2 text-base font-semibold">
<Bug className="h-4 w-4" />
Error Reporting
</span>
<span className="text-base font-semibold">Error Reporting</span>
{sentryOpen ? (
<ChevronUp className="h-4 w-4 text-muted-foreground" />
) : (
@@ -300,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
+14 -8
View File
@@ -6,11 +6,15 @@ export const ARC_OVERHANG_PX = 20;
/** Larger overhang for the upward arc (bottom nav) so the harsher curve isn't clipped. */
export const ARC_UP_OVERHANG_PX = 28;
/** SVG path for a downward arc (used by top bar and sub-header bar). */
const ARC_DOWN_PATH = 'M0,0 L100,0 L100,44 Q50,64 0,44 Z';
/** SVG path for a downward angled bar (used by top bar and sub-header bar).
* Bottom edge slopes from each corner down to a center apex, forming an
* inverted-V that points toward the content below. */
const ARC_DOWN_PATH = 'M0,0 L100,0 L100,34 L50,46 L0,34 Z';
/** SVG path for an upward arc (used by bottom nav). */
const ARC_UP_PATH = 'M0,30 Q50,0 100,30 L100,64 L0,64 Z';
/** SVG path for an upward angled bar (used by bottom nav).
* Top edge slopes from each corner up to a center apex, forming a V that
* points away from the content. */
const ARC_UP_PATH = 'M0,40 L50,16 L100,40 L100,64 L0,64 Z';
/** SVG path for a plain rectangle with no arc. */
const RECT_PATH = 'M0,0 L100,0 L100,64 L0,64 Z';
@@ -29,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;
}
/**
@@ -36,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';
@@ -53,9 +59,9 @@ export function ArcBackground({ variant, className }: ArcBackgroundProps) {
preserveAspectRatio="none"
style={hasArc ? (variant === 'up' ? arcUpHeightStyle : arcDownHeightStyle) : fullHeightStyle}
>
<path d={path} className="fill-background/85" />
{variant === 'down' && <path d="M0,44 Q50,64 100,44" fill="none" className="stroke-border" strokeWidth="1" vectorEffect="non-scaling-stroke" />}
{variant === 'up' && <path d="M0,30 Q50,0 100,30" fill="none" className="stroke-border" strokeWidth="1" vectorEffect="non-scaling-stroke" />}
<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>
);
}
-2
View File
@@ -13,7 +13,6 @@ import { Skeleton } from '@/components/ui/skeleton';
import { ComposeBox } from '@/components/ComposeBox';
import { NoteCard } from '@/components/NoteCard';
import { FlatThreadedReplyList } from '@/components/ThreadedReplyList';
import { ARC_OVERHANG_PX } from '@/components/ArcBackground';
import { SubHeaderBar } from '@/components/SubHeaderBar';
import { TabButton } from '@/components/TabButton';
import { useAuthor } from '@/hooks/useAuthor';
@@ -232,7 +231,6 @@ export function BadgeDetailContent({ event }: { event: NostrEvent }) {
</SubHeaderBar>
{/* Tab content */}
<div style={{ height: ARC_OVERHANG_PX }} />
{activeTab === 'awarded' ? (
<AwardedToTab
awardedPubkeys={awardedPubkeys}
+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>
);
}
+278 -87
View File
@@ -1,10 +1,10 @@
import type React from 'react';
import { type ReactNode, useMemo } from 'react';
import { type ReactNode, useMemo, useState } from 'react';
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';
@@ -29,7 +29,13 @@ import { useLinkPreview } from '@/hooks/useLinkPreview';
import { useScryfallCard } from '@/hooks/useScryfallCard';
import { getDisplayName } from '@/lib/getDisplayName';
import { genUserName } from '@/lib/genUserName';
import { getCountryInfo } from '@/lib/countries';
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';
import { useWikipediaSummary } from '@/hooks/useWikipediaSummary';
import { extractGathererCard, type GathererCard } from '@/lib/linkEmbed';
import { cardPrimaryImage } from '@/lib/scryfall';
@@ -140,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',
@@ -174,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,
@@ -240,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',
@@ -379,6 +385,7 @@ function EventHoverLink({ display, link, hoverContent }: EventHoverLinkProps) {
interface CommentContextProps {
event: NostrEvent;
className?: string;
prefix?: string;
}
/**
@@ -386,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];
@@ -401,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;
}
@@ -440,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);
@@ -471,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}`}
@@ -494,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}`}
@@ -516,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;
@@ -538,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 }}
@@ -566,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;
@@ -584,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 ? (
@@ -604,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,
@@ -613,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) };
@@ -635,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} />
) : (
@@ -646,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);
@@ -654,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"
@@ -681,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);
@@ -691,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" />
) : (
@@ -718,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.
@@ -732,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"
@@ -759,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)}`;
@@ -773,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>
@@ -799,64 +806,246 @@ function UrlCommentContext({ url, className }: { url: string; className?: string
);
}
/** Comment context for ISO 3166 country/subdivision identifiers — shows flag and name with hover preview. */
function CountryCommentContext({ identifier, className }: { identifier: string; className?: string }) {
/** Internal: clickable country flag emoji that opens the country feed. */
function CountryPillBadge({ identifier, className }: { identifier: string; className?: string }) {
const code = identifier.slice('iso3166:'.length);
const info = getCountryInfo(code);
const link = `/i/${encodeURIComponent(identifier)}`;
const displayText = info
? info.subdivisionName
? `${info.flag} ${info.subdivisionName}`
: `${info.flag} ${info.name}`
: 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 (
<CommentContextRow prefix="Commenting on" className={className}>
<HoverCard openDelay={300} closeDelay={150}>
<HoverCardTrigger asChild>
<Link
to={link}
className="text-primary hover:underline truncate cursor-pointer"
onClick={(e) => e.stopPropagation()}
>
{displayText}
</Link>
</HoverCardTrigger>
<HoverCardContent
side="bottom"
align="start"
sideOffset={4}
className="w-64 p-0 rounded-2xl shadow-lg"
<HoverCard openDelay={300} closeDelay={150}>
<HoverCardTrigger asChild>
<Link
to={link}
onClick={(e) => e.stopPropagation()}
aria-label={`Posted from ${info?.name ?? code}`}
className={cn(
'group/flag inline-flex items-center justify-center shrink-0',
'text-xl leading-none',
'transition-transform duration-200',
'motion-safe:hover:scale-110 motion-safe:active:scale-95',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background rounded-full',
className,
)}
>
<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}>
{info?.flag ?? '🌍'}
</span>
<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>
</div>
<p className="text-sm font-medium truncate mt-0.5">
{info?.subdivisionName ?? info?.name ?? code}
</p>
{info?.subdivisionName && info.name && (
<p className="text-xs text-muted-foreground truncate">
{info.name}
</p>
)}
{/* 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
side="bottom"
align="end"
sideOffset={6}
className="w-64 p-0 rounded-2xl shadow-lg overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center gap-3 px-4 py-3">
<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>{tierLabel}</span>
</div>
<p className="text-sm font-medium truncate mt-0.5">
{displayLabel}
</p>
{subLabel && (
<p className="text-xs text-muted-foreground truncate">
{subLabel}
</p>
)}
</div>
</HoverCardContent>
</HoverCard>
</CommentContextRow>
</div>
</HoverCardContent>
</HoverCard>
);
}
/**
* Resolve whether a kind-1111 event should display country-rooted UI in the
* current context. Shared by `CountryCommentPill` (renders a pill in the
* card header) and `CountryFlagBackdrop` (renders a faded flag behind the
* card). Returns `null` to suppress both; otherwise returns the resolved
* country identifier so callers don't re-parse tags.
*/
function useCountryRootContext(event: NostrEvent): { iTag: string; code: string } | null {
const countryFeed = useCountryFeed();
if (event.kind !== 1111) return null;
// If the direct parent is another kind-1111 comment, the body shows
// "Replying to @user" — country chrome would be visual noise.
const parentKind = event.tags.find(([name]) => name === 'k')?.[1];
if (parentKind === '1111') return null;
// Root I tag (uppercase = root identifier per NIP-22)
const iTag = event.tags.find(([name]) => name === 'I')?.[1];
if (!iTag || !iTag.startsWith('iso3166:')) return null;
const code = iTag.slice('iso3166:'.length);
// Suppress when already inside the matching country feed page — there the
// chrome would just point back to the page the user is already on.
if (countryFeed && countryFeed.countryCode === code.toUpperCase()) {
return null;
}
return { iTag, code };
}
/**
* Whether the given event is rendering with country chrome (pill + flag
* backdrop) in the current context. Useful for sibling components that want
* to coordinate styling — e.g. NoteCard switching its text to white when a
* flag is showing through behind the author row.
*/
// eslint-disable-next-line react-refresh/only-export-components
export function useIsCountryRooted(event: NostrEvent): boolean {
return useCountryRootContext(event) !== null;
}
/**
* Standalone country flag pill for kind-1111 events whose root is an ISO 3166
* identifier. Intended to be rendered in the upper-right of `NoteCard`'s
* header row so the post reads as a neighborhood entry instead of a comment.
*
* Visibility rules: see `useCountryRootContext`.
*
* Because the pill takes over the country-root presentation, the body-level
* `CommentContext` is silent for country roots — see `CountryCommentContext`.
*/
export function CountryCommentPill({ event, className }: { event: NostrEvent; className?: string }) {
const ctx = useCountryRootContext(event);
if (!ctx) return null;
return <CountryPillBadge identifier={ctx.iTag} className={className} />;
}
/**
* Decorative flag backdrop for country-rooted kind-1111 posts. Renders the
* country's Wikipedia lead image (the flag, for country articles) faded
* behind the post, echoing the country detail page's hero
* (`CountryContentHeader` in `ExternalContentHeader.tsx`) but scaled down
* to a card. Pairs with `CountryCommentPill`.
*
* Designed to be rendered as the first child of a `relative overflow-hidden`
* parent. The wrapper is absolutely positioned at `z-0`; its foreground
* siblings must declare `relative` (any positioned value works) so they
* paint above the backdrop. Pointer events are disabled so the post body
* stays fully interactive.
*
* The Wikipedia summary fetch is cached for 24 h across all cards
* referencing the same country code, so a feed of N Venezuelan posts only
* pays the network cost once.
*
* Visibility rules: see `useCountryRootContext` (identical to the pill).
*/
export function CountryFlagBackdrop({ event }: { event: NostrEvent }) {
const ctx = useCountryRootContext(event);
const info = ctx ? getCountryInfo(ctx.code) : null;
const wikiTitle = ctx ? getWikipediaTitle(ctx.code) : null;
const { data: wiki } = useWikipediaSummary(wikiTitle);
// Sample dominant colors from the flag emoji at render time. Used as the
// fallback gradient while Wikipedia is still resolving and after image
// load failures, so the backdrop never reverts to a giant blurred emoji.
const palette = useFlagPalette(info?.flag);
// Track image load failures so we cleanly fall back to the flag-color
// gradient. Wikipedia hosts these PNGs from upload.wikimedia.org which is
// generally CORS-friendly, but hotlink-protection or transient 4xx
// responses can still happen.
const [imageFailed, setImageFailed] = useState(false);
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
? (bundledAsset ?? wiki?.originalImage?.source ?? wiki?.thumbnail?.source ?? null)
: null;
// Pre-built gradient using the palette (sampled from the flag emoji at
// mount). Used as the fallback when Wikipedia hasn't returned an image or
// its image failed to load. Single-color palettes get duplicated so
// linear-gradient still has two stops.
const paletteGradient =
palette && palette.length > 0
? `linear-gradient(135deg, ${palette.length === 1 ? `${palette[0]}, ${palette[0]}` : palette.join(', ')})`
: null;
return (
<div aria-hidden className="pointer-events-none absolute inset-0 z-0 overflow-hidden">
<div className="absolute top-0 left-0 right-0 h-64 sm:h-72">
{flagImage ? (
// Full-width flag banner across the top of the card. A mask-image
// gradient fades the image to nothing at its bottom edge, so the
// flag dissolves into the card with no hard seam.
<img
src={flagImage}
alt=""
decoding="async"
onError={() => setImageFailed(true)}
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%)',
}}
/>
) : paletteGradient ? (
// Wikipedia not yet resolved (or its image failed) — paint the
// flag-color gradient as a placeholder/fallback. Same opacity and
// mask shape as the image so the visual swap is seamless when the
// image arrives.
<div
className="absolute inset-0 opacity-20"
style={{
backgroundImage: paletteGradient,
maskImage: 'linear-gradient(to bottom, black 0%, black 35%, transparent 100%)',
WebkitMaskImage: 'linear-gradient(to bottom, black 0%, black 35%, transparent 100%)',
}}
/>
) : null}
</div>
</div>
);
}
/**
* Body-level comment context for ISO 3166 roots — intentionally renders
* nothing. The country pill is hoisted into the card header via
* `CountryCommentPill`, so we suppress the in-body version to avoid
* duplication.
*/
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)}`;
@@ -865,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
@@ -927,10 +1116,12 @@ function GathererCardCommentContext({
card,
url,
className,
prefix,
}: {
card: GathererCard;
url: string;
className?: string;
prefix: string;
}) {
const lookup = useMemo(() => (
card.kind === 'multiverse'
@@ -944,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>
);
}
+33 -81
View File
@@ -4,12 +4,7 @@ import { Bookmark, Crown, Shield, Users } from 'lucide-react';
import { nip19 } from 'nostr-tools';
import type { NostrEvent } from '@nostrify/nostrify';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { useAuthor } from '@/hooks/useAuthor';
import { useProfileUrl } from '@/hooks/useProfileUrl';
import { parseCommunityEvent, COMMUNITY_DEFINITION_KIND } from '@/lib/communityUtils';
import { genUserName } from '@/lib/genUserName';
import { cn } from '@/lib/utils';
interface CommunityCardProps {
@@ -36,10 +31,6 @@ export function CommunityCard({
className,
}: CommunityCardProps) {
const community = useMemo(() => parseCommunityEvent(event), [event]);
const founderAuthor = useAuthor(event.pubkey);
const founderMeta = founderAuthor.data?.metadata;
const founderName = founderMeta?.display_name || founderMeta?.name || genUserName(event.pubkey);
const founderProfileUrl = useProfileUrl(event.pubkey, founderMeta);
if (!community) return null;
@@ -53,91 +44,52 @@ export function CommunityCard({
<Link
to={`/${naddr}`}
className={cn(
'group block rounded-xl border border-border hover:border-primary/30 transition-all hover:shadow-md overflow-hidden',
'group relative block min-h-[240px] overflow-hidden rounded-2xl bg-muted shadow-sm transition-all hover:shadow-lg sm:min-h-[260px]',
className,
)}
>
{/* Image banner */}
<div className="absolute right-3 top-3 z-10 flex gap-1.5 [text-shadow:none]">
{isFounded && (
<span className="flex size-7 items-center justify-center rounded-full bg-black/35 text-white shadow-sm backdrop-blur-sm" title="Founder" aria-label="Founder">
<Crown className="size-3.5" />
</span>
)}
{isMember && (
<span className="flex size-7 items-center justify-center rounded-full bg-black/35 text-white shadow-sm backdrop-blur-sm" title="Member" aria-label="Member">
<Shield className="size-3.5" />
</span>
)}
{isBookmarked && (
<span className="flex size-7 items-center justify-center rounded-full bg-black/35 text-white shadow-sm backdrop-blur-sm" title="Following" aria-label="Following">
<Bookmark className="size-3.5 fill-current" />
</span>
)}
</div>
{/* Image backdrop */}
{community.image ? (
<div className="relative h-28 overflow-hidden bg-muted">
<img
src={community.image}
alt={community.name}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent" />
</div>
<img
src={community.image}
alt={community.name}
className="absolute inset-0 size-full object-cover transition-transform duration-500 group-hover:scale-105"
/>
) : (
<div className="relative h-28 bg-gradient-to-br from-primary/15 via-primary/5 to-transparent flex items-center justify-center">
<Users className="size-10 text-primary/20" />
<div className="absolute inset-0 bg-gradient-to-br from-primary/50 via-primary/25 to-primary/5">
<Users className="absolute left-1/2 top-1/3 size-16 -translate-x-1/2 -translate-y-1/2 text-white/20" />
</div>
)}
{/* Content */}
<div className="p-3 space-y-2">
{/* Name + founder badge */}
<div className="flex items-start gap-2">
<h3 className="text-sm font-semibold truncate flex-1 group-hover:text-primary transition-colors">
{community.name}
</h3>
{isFounded ? (
<Badge variant="secondary" className="text-[10px] gap-1 shrink-0 bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20">
<Crown className="size-2.5" />
Founder
</Badge>
) : isMember ? (
<Badge variant="secondary" className="text-[10px] gap-1 shrink-0 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20">
<Shield className="size-2.5" />
Member
</Badge>
) : isBookmarked ? (
<Badge variant="secondary" className="text-[10px] gap-1 shrink-0 bg-primary/10 text-primary border-primary/20">
<Bookmark className="size-2.5 fill-current" />
Following
</Badge>
) : null}
</div>
<div className="absolute inset-0 bg-[linear-gradient(to_bottom,rgba(0,0,0,0.04)_0%,rgba(0,0,0,0.16)_38%,rgba(0,0,0,0.78)_74%,rgba(0,0,0,0.92)_100%)]" />
<div className="absolute inset-x-0 bottom-0 p-4 pt-16 [text-shadow:0_1px_4px_rgba(0,0,0,0.75)]">
<h3 className="mb-2 truncate text-lg font-bold leading-tight text-white transition-colors group-hover:text-white">
{community.name}
</h3>
{/* Description */}
{community.description && (
<p className="text-xs text-muted-foreground line-clamp-2 leading-relaxed">
<p className="line-clamp-1 text-xs leading-relaxed text-white/80">
{community.description}
</p>
)}
{/* Footer: founder + stats */}
<div className="flex items-center justify-between pt-1">
<Link
to={founderProfileUrl}
onClick={(e) => e.stopPropagation()}
className="flex items-center gap-1.5 min-w-0"
>
<Avatar className="size-5">
<AvatarImage src={founderMeta?.picture} />
<AvatarFallback className="text-[8px] bg-muted">
{founderName.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<span className="text-[11px] text-muted-foreground truncate hover:underline">
{founderName}
</span>
</Link>
<div className="flex items-center gap-2 shrink-0">
{community.moderatorPubkeys.length > 0 && (
<span className="flex items-center gap-1 text-[11px] text-muted-foreground">
<Shield className="size-3" />
{community.moderatorPubkeys.length}
</span>
)}
{community.memberBadgeATag && (
<span className="flex items-center gap-1 text-[11px] text-muted-foreground">
<Users className="size-3" />
Member badge
</span>
)}
</div>
</div>
</div>
</Link>
);
-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
+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">
+22 -4
View File
@@ -55,11 +55,22 @@ export function CommunityStatsPanel({ countryCode, className, compact = false }:
const [tf, setTf] = useState<StatsTimeframe>('7d');
if (isLoading && !stats) return <PanelSkeleton className={className} />;
if (isLoading && !stats) return <PanelSkeleton className={className} compact={compact} />;
if (!stats) return null;
return (
<section className={cn('rounded-2xl border border-border bg-background/40 p-4 space-y-4', className)}>
<section
className={cn(
// Standalone usage gets a card-style border. Compact usage is
// embedded inside another bordered surface (the world discovery
// modal / docked panel) where an extra border produces a
// box-in-a-box look — drop it and rely on spacing alone.
compact
? 'space-y-4'
: 'rounded-2xl border border-border bg-background/40 p-4 space-y-4',
className,
)}
>
<PanelHeader stats={stats} timeframe={tf} onTimeframeChange={setTf} />
<AggregateCounts stats={stats} timeframe={tf} compact={compact} />
<Leaderboards stats={stats} timeframe={tf} compact={compact} />
@@ -371,9 +382,16 @@ function RankBadge({ rank }: { rank: number }) {
// ── Skeleton ─────────────────────────────────────────────────────────────────
function PanelSkeleton({ className }: { className?: string }) {
function PanelSkeleton({ className, compact }: { className?: string; compact?: boolean }) {
return (
<section className={cn('rounded-2xl border border-border bg-background/40 p-4 space-y-4', className)}>
<section
className={cn(
compact
? 'space-y-4'
: 'rounded-2xl border border-border bg-background/40 p-4 space-y-4',
className,
)}
>
<Skeleton className="h-5 w-40" />
<div className="grid grid-cols-2 sm:grid-cols-5 gap-2">
{Array.from({ length: 5 }).map((_, i) => (
+165 -6
View File
@@ -1,6 +1,6 @@
import { lazy, Suspense, useState, useRef, useCallback, useMemo, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { Paperclip, Smile, AlertTriangle, X, Loader2, Mic, Square, Sticker, BarChart3, Plus, ChevronLeft } from 'lucide-react';
import { Paperclip, Smile, AlertTriangle, X, Loader2, Mic, Square, Sticker, BarChart3, Plus, ChevronLeft, HelpCircle } from 'lucide-react';
import { nip19 } from 'nostr-tools';
import { encode as blurhashEncode } from 'blurhash';
import type { NostrEvent } from '@nostrify/nostrify';
@@ -12,6 +12,7 @@ import { Input } from '@/components/ui/input';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select';
import { useCustomEmojis } from '@/hooks/useCustomEmojis';
import { useFeedSettings } from '@/hooks/useFeedSettings';
import { GifPicker } from '@/components/GifPicker';
@@ -27,6 +28,9 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { usePostComment } from '@/hooks/usePostComment';
import { useUploadFile } from '@/hooks/useUploadFile';
import { useCountryFollows } from '@/hooks/useCountryFollows';
import { getCountryInfo } from '@/lib/countries';
import { createCountryIdentifier } from '@/lib/countryIdentifiers';
import { useQueryClient } from '@tanstack/react-query';
import { useToast } from '@/hooks/useToast';
import { useAppContext } from '@/hooks/useAppContext';
@@ -128,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. */
@@ -148,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. */
@@ -198,6 +210,7 @@ export function ComposeBox({
forceExpanded = false,
hideAvatar = false,
hideBorder = false,
className,
previewMode: controlledPreviewMode,
onHasPreviewableContentChange,
initialContent = '',
@@ -205,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);
@@ -239,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);
@@ -249,6 +264,28 @@ export function ComposeBox({
// Poll mode state
const [mode, setMode] = useState<'post' | 'poll'>(initialMode);
// Country-destination toggle. Only meaningful for top-level new posts
// from the home feed (no replyTo, not a custom-kind publish). When a
// country code is selected, the post is published as a NIP-22 kind
// 1111 comment rooted on that country instead of a plain kind 1 note.
// Dropdown lists only the countries the user follows, with "Global"
// always at the top.
const { followedCountries } = useCountryFollows();
const canChooseDestination =
!replyTo && !customPublish && mode === 'post' && !!user && followedCountries.length > 0;
/** `'world'` for a regular kind-1 note, or an ISO 3166 country code for a kind-1111 community post. */
const [destination, setDestination] = useState<'world' | string>('world');
const selectedCountryCode = destination !== 'world' ? destination : null;
const selectedCountryInfo = selectedCountryCode ? getCountryInfo(selectedCountryCode) : null;
// If the user unfollows the currently-selected country mid-session,
// snap back to world so we don't try to publish a kind 1111 with
// a root the user no longer cares about.
useEffect(() => {
if (selectedCountryCode && !followedCountries.includes(selectedCountryCode)) {
setDestination('world');
}
}, [selectedCountryCode, followedCountries]);
const [pollOptions, setPollOptions] = useState([
{ id: pollOptionId(), label: '' },
{ id: pollOptionId(), label: '' },
@@ -274,7 +311,7 @@ export function ComposeBox({
setContent('');
setCwEnabled(false);
setCwText('');
setExpanded(false);
setExpanded(defaultExpanded);
setPickerOpen(false);
setTrayOpen(false);
setInternalPreviewMode(false);
@@ -286,9 +323,10 @@ export function ComposeBox({
setUploadedFileGroups(new Map());
setWebxdcUuids(new Map());
setWebxdcMetas(new Map());
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;
@@ -1069,11 +1107,17 @@ export function ComposeBox({
}
await postComment({ root, reply, content: finalContent, tags });
} else if (canChooseDestination && selectedCountryCode) {
// No replyTo, but the user picked a followed country as the
// destination — publish as a NIP-22 kind 1111 rooted on the
// country identifier (mirrors the country page's compose flow).
const countryRoot = new URL(createCountryIdentifier(selectedCountryCode));
await postComment({ root: countryRoot, reply: undefined, content: finalContent, tags });
} else {
await createEvent({
kind: 1,
content: finalContent,
tags,
tags: [...defaultTags, ...tags],
created_at: Math.floor(Date.now() / 1000),
});
}
@@ -1184,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 && (
@@ -1245,7 +1290,13 @@ export function ComposeBox({
onPointerDown={expand}
onFocus={expand}
onPaste={handlePaste}
placeholder={mode === 'poll' ? 'Ask a question…' : placeholder}
placeholder={
mode === 'poll'
? 'Ask a question…'
: selectedCountryInfo
? `What's happening in ${selectedCountryInfo.name}?`
: placeholder
}
className={cn(
'w-full bg-transparent text-foreground placeholder:text-muted-foreground resize-none outline-none text-lg pt-2.5 pb-2 opacity-85 break-words overflow-hidden transition-[min-height] duration-200 ease-in-out',
isExpanded ? 'min-h-[100px]' : 'min-h-[44px]',
@@ -1419,6 +1470,114 @@ export function ComposeBox({
</div>{/* end flex-1 content column */}
</div>{/* end avatar + content row */}
{/* Destination row — its own line above the toolbar so the
"Post to" label gives it semantic context. The dropdown lists
"Global" first, then every country the user follows. The
help icon opens a popover that explains the difference
between a global post and a country community post. Shown
only for new top-level posts from a logged-in user with
at least one followed country. */}
{canChooseDestination && isExpanded && (
<div className="mt-2 flex items-center justify-end gap-2">
<span className="text-xs font-medium text-muted-foreground">
Post to
</span>
<Popover>
<PopoverTrigger asChild>
<button
type="button"
aria-label="What's the difference between global and community posts?"
className={cn(
'inline-flex items-center justify-center size-5 rounded-full shrink-0',
'text-muted-foreground/70 hover:text-foreground hover:bg-muted/60',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50',
'transition-colors',
)}
>
<HelpCircle className="size-3.5" />
</button>
</PopoverTrigger>
<PopoverContent
side="top"
align="end"
className="w-72 p-0 rounded-2xl overflow-hidden"
>
{(() => {
// Use the currently-selected country (falling back to
// the first followed one) only as the *flag emoji* in
// the example. The text stays generic so the popover
// reads as an explanation of the feature, not a
// description of the user's current pick.
const exampleCode = selectedCountryCode ?? followedCountries[0];
const exampleFlag = exampleCode ? getCountryInfo(exampleCode)?.flag : null;
return (
<div className="p-4 space-y-3">
<div className="flex gap-3">
<span className="text-2xl leading-none shrink-0" aria-hidden="true">🌍</span>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold">Global</p>
<p className="text-xs text-muted-foreground mt-0.5">
A regular post visible to everyone on Nostr. Anyone, anywhere can see it.
</p>
</div>
</div>
<div className="flex gap-3">
<span className="text-2xl leading-none shrink-0" aria-hidden="true">
{exampleFlag ?? '🌐'}
</span>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold">Your country community</p>
<p className="text-xs text-muted-foreground mt-0.5">
Shown in that country's local feed alongside posts from neighbors. Best for community-relevant updates.
</p>
</div>
</div>
</div>
);
})()}
</PopoverContent>
</Popover>
<Select value={destination} onValueChange={setDestination}>
<SelectTrigger
aria-label="Post destination"
className={cn(
'h-8 w-auto gap-1.5 px-2.5 py-1 text-base leading-none',
'border-0 bg-muted/50 hover:bg-muted shadow-none',
'focus:ring-2 focus:ring-primary/50 focus:ring-offset-0',
'rounded-lg',
)}
>
{/* Show just the flag in the trigger to keep the row
compact on mobile. The list items below carry the
country name so users can still tell them apart. */}
<span aria-hidden="true">
{selectedCountryInfo?.flag ?? '🌍'}
</span>
</SelectTrigger>
<SelectContent align="end" className="min-w-[180px]">
<SelectItem value="world">
<span className="inline-flex items-center gap-2">
<span aria-hidden="true">🌍</span>
<span>Global</span>
</span>
</SelectItem>
{followedCountries.map((code) => {
const info = getCountryInfo(code);
if (!info) return null;
return (
<SelectItem key={code} value={code}>
<span className="inline-flex items-center gap-2">
<span aria-hidden="true">{info.flag}</span>
<span>{info.name}</span>
</span>
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
)}
{/* Toolbar + post button — full width, not indented by avatar */}
{isExpanded && (
voiceRecorder.isRecording || isPublishingVoice ? (
+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>
);
}

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