Compare commits

...

810 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
Alex Gleason 2fc7a9ac41 Fix left sidebar collapse and kind-3 follow list rendering
Two post-merge regressions from the ditto/main merge:

1. Left sidebar collapsed to icon-only column at desktop widths.

   Ditto's responsive aside (`hidden sidebar:flex ... lg:w-1/4
   lg:max-w-[300px]`) sizes itself off the flex parent. Agora wraps
   `<LeftSidebar />` in an extra `<div className="hidden
   sidebar:block">`, which had no width — so `w-1/4` computed
   against zero and the sidebar collapsed.

   Removed the wrapper div in MainLayout — the `<aside>` now handles
   its own hiding and width directly inside the flex parent, matching
   Ditto's structure.

2. Kind 3 contact lists rendered as empty/broken cards.

   Ditto unified kinds 3/30000/39089 under `PeopleListContent` (uses
   `parsePeopleList` which synthesizes a "{Name}'s follows" title
   for kind 3 since the event carries no title/description/image of
   its own). Agora's NoteCard still used the old `FollowPackContent`
   path that only matched 30000/39089, so kind 3 fell through to the
   default note render.

   Swapped `FollowPackContent` for `PeopleListContent` and added
   kind 3 to the `isFollowPack` check.

Regression-of: 740fc1c6
2026-05-13 19:33:37 -05:00
Alex Gleason e6ea96d69f Sync package-lock.json to version 2.8.0
Left over from the ditto/main merge — version was bumped back to
2.8.0 (Agora's version) in package.json post-merge but the lockfile
wasn't regenerated until npm install ran.
2026-05-13 19:33:26 -05:00
Alex Gleason 740fc1c63c Merge ditto/main into agora
Pulls in 387 commits from ditto/main while preserving Agora-specific
features. Where the two codebases diverged on the same concept, kept
the Agora side per project direction.

Kept Agora-specific:
- SparkWallet stack (over Ditto's nostr-derived Bitcoin wallet)
- Communities (NIP-72 + chat + members), Messages, Organizers,
  Actions, Verified, Appearance settings
- DMProviderWrapper, country/organizer moderation in NoteMoreMenu
- 'Agora' branding, pub.agora.app bundle ID, version 2.8.0
- Built-in theme system (src/themes.ts) only

Rejected from Ditto:
- All Blobbi virtual pet code (80+ files, route, provider, sidebar,
  kind labels, feed setting, NIP.md entries, CSS animations)
- Custom theme events (kinds 36767/16767) — ThemesPage, ThemeContent,
  active profile themes, theme snapshot recovery
- On-chain zaps (kind 8333) and the entire Bitcoin wallet implementation
  (useBitcoinWallet, bitcoin-signers, BitcoinContentHeader,
  bitcoinjs-lib / @bitcoinerlab/secp256k1 / ecpair / tiny-secp256k1)
- ZapSuccessScreen (depended on dropped bitcoin lib)

Pulled in from Ditto:
- .agents/skills/* (12 new specialized skills, slim AGENTS.md)
- @nostrify bumps to 0.52 / 0.6 / 0.37
- New routes/pages: Music, Podcasts, Videos, Vines, Wikipedia, Books,
  Bluesky, Archive, AIChat, Trends, Webxdc, Highlights, Decks, Emojis,
  Development, Treasures, Colors, Packs
- Birdstar feed integration (kinds 2473, 12473, 30621)
- Wikipedia/Wikidata/Scryfall lookup in ExternalContentPage
- release-notes CI job + extract-release-notes.mjs script
- nsite:// URI handling in feed/sidebar
- iOS fastlane setup
- src/lib/avatarShape.ts + Avatar shape prop (kept for new Music/People
  components that depend on it)

Preserved Agora's ABSOLUTE 'NEVER COMMIT' rule at the top of AGENTS.md
and dropped Ditto's contradicting 'Commit at the end of every task'
section.

Validation: npm run test passes (tsc, eslint, 40/40 vitest, vite build).
2026-05-13 18:35:03 -05:00
filemon 0b4a88a83e Stabilize totalPosts KPI by clamping COUNT to local post count for states view 2026-05-13 19:40:13 -03:00
Alex Gleason 4138e12d5e Add feed toggles for reactions and zaps, rendered as overlays on the target post
Two new Feed-section toggles in Content Settings, both disabled by
default (existing users don't suddenly get a noisy feed of every like
and zap their follows hand out):

  - Reactions (kind 7)
  - Zaps (kind 9735 Lightning + kind 8333 on-chain — one combined
    toggle since users don't think in terms of payment rails)

When enabled, reactions and zaps from followed users surface in the
Follows feed as a header above the target post — same shape as the
existing kind 6 / 16 repost overlay ("X reacted to" / "X zapped
1,234 sats" / "X reposted"). The reaction overlay renders the kind 7
event's actual emoji via ReactionEmoji (handling unicode, "+"/"-"
likes, and NIP-30 custom emojis) rather than a generic smiley. The
target event is unwrapped by useFeed and useProfileFeed in a single
batched ids query, then deduped so a direct post always wins over any
overlay for the same event.

The verb in each overlay header is a Link to the underlying reaction
/ repost / zap event's /:nip19 page, matching the new behavior in
Notifications. Reposts now carry the wrapper event (`repostEvent`)
through FeedItem so this works for them too without a separate fetch.

Global feed continues to exclude reposts, and now also excludes
reactions and zaps for the same reason — they need an author filter
to be useful and would otherwise drown out direct posts.
2026-05-13 15:33:06 -05:00
Alex Gleason 2ede59d2db Show zap amounts in notifications and link the verbs to the underlying event
Some LNURL providers omit the `amount` tag entirely and only encode
the value inside the bolt11 invoice. NotificationsPage's local
`getZapAmountSats` didn't parse bolt11, so those zaps showed up as
"X zapped you" with no number. Move the helper to a shared module
and route the 9735 branch through `extractZapAmount`, which already
falls back amount tag → description JSON → bolt11.

While in this code, wrap the "reacted to", "reposted", and "zapped"
verbs in a Link pointing at the underlying event's nevent so readers
can jump straight to the reaction / repost / zap detail page.
2026-05-13 15:22:11 -05:00
Alex Gleason 4cceaf652d Add a Mute All dropdown next to every Follow All button
Splits the existing Follow All button on people-list, follow-pack, follow-set, badge, and Team Soapbox detail views into a primary Follow All + a caret dropdown whose lone option is Mute All. Mute All opens an AlertDialog and, on confirm, merges every pubkey in the list into the viewer's NIP-51 kind 10000 mute list. Lets you treat any people list as a mute source as well as a follow source.

To make mute meaningful for users who already followed someone before muting, follow-scoped feed queries now subtract muted pubkeys from the authors filter at query time, via a shared useMutedAuthorFilter hook that builds the muted-pubkey Set once per mute-list change and exposes a single excludeMuted helper. The hook replaces ten inline copies of the same filter — including two that allocated a new Set per follow-list element inside a .filter() callback. Render-layer mute filtering stays in place as a second line of defense.

Also adds reusable hooks for the bulk operations (useFollowActions.followMany, useMuteList.muteManyPubkeys) and replaces four duplicated inline Follow All implementations with a single FollowAllSplitButton component.
2026-05-13 14:54:51 -05:00
Alex Gleason 474ec6cc99 Make the Esplora API base URL configurable
Hardcoded MEMPOOL_API constant in src/lib/bitcoin.ts becomes a baseUrl
parameter on every fetch helper, sourced from a new `esploraBaseUrl`
field on AppConfig (default `https://mempool.space/api`). The wallet,
zap dialogs, on-chain zap verification, and NIP-73 Bitcoin tx/address
pages now read the URL from useAppContext and pass it through, so
self-hosted Esplora deployments (or Blockstream's) work without code
changes. The mempool.space-specific `/v1/prices` extension is still
appended by fetchBtcPrice.
2026-05-13 11:23:36 -05: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
Sam Thomson 6453aa71fc Merge branch 'feat/flatten-community' into 'main'
Community creation & events, flatten membership, add feed and bookmarking

See merge request soapbox-pub/agora-3!19
2026-05-12 12:57:53 +00:00
lemon 4d405996f9 Follow Communities instead of Bookmarking 2026-05-11 23:00:48 -07:00
lemon c798e2a53e Refine community member UI 2026-05-11 22:20:50 -07:00
Alex Gleason 0022b86299 Truncate the version-update toast excerpt to 60 characters
The release-summary paragraph (max 500 chars by convention) skipped the
truncation that the legacy first-bullet fallback applied, so toasts could
render an entire paragraph. Truncate both branches uniformly on a word
boundary with an ellipsis, matching the prior 60-character cap.

Regression-of: d044218c
2026-05-11 23:19:33 -05:00
Alex Gleason abc37151ad Fetch BTC price from mempool.space instead of CoinGecko
Consolidates Bitcoin-related HTTP onto a single host — the rest of the
wallet already uses mempool.space for addresses, txs, UTXOs, fees, and
broadcast — so dropping the CoinGecko dependency removes one external
service from the connect-src surface and simplifies CSP / privacy review.

mempool.space's /api/v1/prices returns USD (and several other fiat
currencies) at the same shape we need, so fetchBtcPrice keeps its
`Promise<number>` signature and every caller continues to read from
the same React Query cache key.
2026-05-11 23:04:13 -05:00
Alex Gleason 814d1909f6 release: v2.14.4 2026-05-11 14:01:54 -07:00
Alex Gleason f5bb8afaec Run publish-app-store on the Mac runner instead of Linux
fastlane's deliver action invokes Apple's iTMSTransporter / altool to
push the IPA to App Store Connect, and those tools only ship inside
Xcode. On a generic ruby:3.3 Linux container the upload step crashed
with 'No such file or directory @ dir_chdir0' from
JavaTransporterExecutor#execute, because Helper.itms_path resolved
to a missing Xcode path.

Move publish-app-store onto the same self-hosted Mac runner as
build-ipa (tags: [macos]), drop the now-unnecessary 'gem install
fastlane' (the Mac has it on PATH via ~/.bash_profile), and unset
APP_STORE_CONNECT_API_KEY_PATH to mirror build-ipa's defense against
fastlane's env-var collision (match expects a JSON descriptor there;
we pass the API key inline via the Fastfile).

Update AGENTS.md and the release / ci-cd-publishing / mac-runner
skills, which all incorrectly described publish-app-store as a
Linux-only API call.

Regression-of: b8773c47
2026-05-11 14:00:13 -07:00
Alex Gleason ba0a144afd Render the Wikipedia widget's TFA title without underscores and link to /i/
The featured-article card was showing the raw `title` field
(e.g. "Japan_Cup") and opening Wikipedia in a new tab. Use the
API's `normalizedtitle` for display and route the click through
/i/ so users land on the in-app article view.
2026-05-11 13:45:15 -07:00
Alex Gleason d044218c6a Use a release-summary paragraph for App Store, Play Store, and the in-app toast
Each CHANGELOG.md release section now begins with a single plaintext
paragraph (max ~500 chars) before any `### Category` heading. That
paragraph drives the release blurb in three storefronts and the
in-app version-update toast, so we no longer ship a marketing-grade
description in one place and a raw bullet list in another.

scripts/extract-release-notes.mjs is the single source of truth for
extraction. It emits the full section (summary + lists) by default
and only the summary paragraph with --summary, with a
`Ditto vX.Y.Z` fallback for legacy entries that have no summary.

CI changes:
- New `release-notes` job (build stage, default node:22 image)
  produces `artifacts/release-notes.md` and
  `artifacts/release-notes-summary.txt` once per pipeline.
- `release` job pulls release-notes.md as the GitLab Release
  description (replaces the old inline awk extraction). It now uses
  `needs:` with `artifacts: false` for build-apk/build-ipa to
  avoid re-downloading the .apk/.aab/.ipa it doesn't open.
- `publish-app-store` copies release-notes-summary.txt to
  `ios/fastlane/metadata/en-US/release_notes.txt` (replaces its
  own awk extraction).
- `publish-google-play` drops `--skip_upload_changelogs`, writes
  the summary to
  `android/fastlane/metadata/android/en-US/changelogs/<versionCode>.txt`
  and points fastlane supply at `--metadata_path`. This is the
  first time we upload a What's New text to the Play Store from CI.

App-side changes:
- `src/lib/changelog.ts` parser captures the leading non-blank
  paragraph (before any bullet or category heading) into
  `entry.summary`.
- `VersionCheck.tsx` toast uses `entry.summary` when present,
  falling back to the legacy 60-char first-bullet excerpt for
  backward compatibility.
- `ChangelogPage` renders the summary as a lede paragraph above
  the bullet list in both LatestRelease and ChangelogEntryCard.

Changelog content:
- Added summary paragraphs to v2.14.3, v2.14.2, v2.14.1.

Skill + AGENTS.md updates:
- `release` skill documents the summary paragraph format, the
  500-char convention, and the seven-job pipeline.
- `ci-cd-publishing` skill gains a 'Release notes pipeline' section
  mapping each storefront to its source artifact.
- AGENTS.md pipeline summary mentions release-notes and the summary
  flow into both store "What's new" fields.
2026-05-11 13:13:33 -07:00
lemon 06872186a8 Move community badge into members tab 2026-05-11 13:08:10 -07:00
Alex Gleason b8773c47d7 Automate App Store releases via self-hosted Mac runner
Mirror the existing Android publishing flow for iOS. The pipeline
gains two jobs: build-ipa runs on a self-hosted Mac runner and
produces a signed App Store IPA; publish-app-store runs on a shared
Linux runner and submits the prebuilt IPA to App Store Connect.

Build pipeline (.gitlab-ci.yml):
- build-ipa (Mac, stage build, parallel with build-apk): decodes the
  ASC API key, runs match (with api_key, so cert validity is verified
  against Apple before xcodebuild starts), builds web assets, syncs
  Capacitor, stamps MARKETING_VERSION. Uploads Ditto-${CI_COMMIT_TAG}
  .ipa to GitLab's Generic Packages registry.
- publish-app-store (Linux ruby:3.3, needs: [build-ipa]): gem
  install fastlane, decode the ASC API key, extract the changelog
  section into release_notes.txt, fastlane submit_release with
  IPA_PATH pointing at the inherited artifact. No Xcode, no signing,
  no keychain \u2014 pure Apple API call.
- release job now needs both build-apk and build-ipa, and links three
  assets (APK / AAB / IPA).

fastlane (ios/fastlane/Fastfile, Matchfile, Appfile, metadata/):
- Four lanes: build_ipa (CI build), submit_release (CI publish, reads
  IPA_PATH from env), release (single-step convenience for local
  dev), submit_only (debug lane to re-submit an already-uploaded
  build).
- Match config points at the private gitlab.com/soapbox-pub
  /certificates repo. App Store Connect API key is built inline in
  the Fastfile to avoid a collision with match's APP_STORE_CONNECT
  _API_KEY_PATH env var (match wants a JSON descriptor, the action
  writes a raw .p8). CI overrides CODE_SIGN_STYLE=Manual via xcargs
  so the Xcode project can stay on Automatic for local development.

Vite config (vite.config.ts):
- Renames the build-time config override env var from CONFIG_FILE to
  DITTO_CONFIG_FILE. GitLab Runner sets CONFIG_FILE to its own TOML
  config in job env, which broke vite's loader.

App-side changes:
- ios/App/App.xcodeproj/project.pbxproj: team GZLTTH5DLM stamped in;
  MARKETING_VERSION gets stamped from the tag at build time.
- public/CHANGELOG.md, package.json: v2.14.3.

Skills + AGENTS.md updated to reflect the six-job pipeline (test /
deploy unchanged, build now has two jobs, release / publish updated)
and to document Mac-runner operations, fastlane match cert rotation,
and local debugging workflows.
2026-05-11 12:59:04 -07:00
lemon 9184e6e09f Add editable community badge panel 2026-05-11 12:53:27 -07:00
lemon 5fa1dd1594 Search follow packs when adding members 2026-05-11 12:49:28 -07:00
lemon 9cb8eea636 Fix all-day event end date display 2026-05-11 12:21:44 -07:00
lemon dbed5bb7af Allow optional event end times 2026-05-11 12:15:31 -07:00
lemon 11e33c36c0 Refine event end date selection 2026-05-11 12:14:03 -07:00
lemon 9a1a530156 Refine add member role selection 2026-05-11 11:38:23 -07:00
lemon d02527b751 Fix event edit error toast 2026-05-11 11:26:16 -07:00
lemon 9129cb8301 Fix calendar event feed sorting 2026-05-11 11:22:18 -07:00
lemon 61e46e1479 Fix avatar compatibility after rebase 2026-05-11 11:20:42 -07:00
lemon bb2846ea17 Refine community chat message bubbles 2026-05-11 11:12:13 -07:00
lemon c8d0c8fbd9 Refine community chat and member workflows 2026-05-11 11:12:13 -07:00
lemon c49cf68b78 Enhance community chat composer 2026-05-11 11:12:13 -07:00
lemon 10f8d3c2c2 Refine community chat layout 2026-05-11 11:12:13 -07:00
lemon 53e7122302 Add community chat tab 2026-05-11 11:12:13 -07:00
lemon 5013d3d8c3 Fix world feed refresh dependencies 2026-05-11 11:12:13 -07:00
lemon 5cf1157636 Rename fundraising goals to goals 2026-05-11 11:12:13 -07:00
lemon 1d5320eb33 Add community member feed tab 2026-05-11 11:12:13 -07:00
lemon 1873823b4c Portal tooltip overlays above sidebars 2026-05-11 11:12:13 -07:00
lemon 8c8c7f3bad Refresh goal progress after zaps 2026-05-11 11:12:13 -07:00
lemon c1f3cc172d Check member badge identifier collisions 2026-05-11 11:12:13 -07:00
lemon 0c1e36d20a Refresh community caches after member updates 2026-05-11 11:12:13 -07:00
lemon 16704b415d Page community activity streams independently 2026-05-11 11:12:13 -07:00
lemon ceb3b2df69 Prevent banned community moderators from acting 2026-05-11 11:12:13 -07:00
lemon 6af71ad5f4 Page community awards and reports exhaustively
Introduce queryAll, a portable helper that exhausts a Nostr filter by
paging with the until cursor, capped at 5,000 events / 10 pages so
worst-case cost stays bounded. Works against any relay regardless of
its internal page size.

Migrate useCommunityMembers and useCommunityActivityFeed so membership
and moderation state are complete for any community that fits within
the cap, instead of silently truncating at 500 events.
2026-05-11 11:12:13 -07:00
lemon ea4295cb89 Tighten flat community primitives
Extract isAuthorizedAward helper as the single source of truth for
membership award validation, used by both resolveMembership and
useMyCommunities. Simplify resolveCommunityModeration by dropping
the dead banned-reporter guard from pass 1 (impossible under strict
rank ordering). Flip useMembersOnlyFilter default to opt-in to match
the spec's MAY wording, and reword the NIP to match.
2026-05-11 11:12:13 -07:00
lemon 6b72d20af8 Clean up flat community language 2026-05-11 11:12:13 -07:00
lemon 773e3830f5 Flatten community membership resolution 2026-05-11 11:12:13 -07:00
lemon 5e99ac817b Document flat community membership 2026-05-11 11:12:13 -07:00
lemon 61308656ac Add calendar event editing 2026-05-11 11:12:13 -07:00
lemon fd2a049d93 Share image upload field across dialogs 2026-05-11 11:12:13 -07:00
lemon 35d1c34ed8 Add image uploads to event creation 2026-05-11 11:12:13 -07:00
lemon 136ca99f25 Add engagement actions to calendar events 2026-05-11 11:12:13 -07:00
lemon 2d3b636bfa Add RSVP controls to calendar event details
- Rename tentative label to 'Interested' (Facebook-style, Star icon)
- Auto-enroll event authors as 'accepted' when publishing
- Let authors change their own RSVP from the detail page
- Restyle RSVP section to match About/Attendees headers
- Remove optional note field; click a button to submit immediately
- Move Attendees above RSVP
2026-05-11 11:12:13 -07:00
lemon 0bd6bd8baa Use event dialog on events page 2026-05-11 11:12:13 -07:00
lemon b6eebe497d Add community event creation dialog 2026-05-11 11:12:13 -07:00
lemon 7126ee1329 Add community events tab 2026-05-11 11:12:13 -07:00
lemon c707a6ff97 Improve community bookmark reliability 2026-05-11 11:12:13 -07:00
lemon f968149a72 Add bookmark toggle to community detail page top bar
Places a NIP-51 kind 10004 bookmark button between the edit and share
buttons so users can save a community while viewing it, not just from
the feed card's more-menu.
2026-05-11 11:12:13 -07:00
lemon a9ea21e3d4 Show bookmarked communities in My Communities via NIP-51 kind 10004
Bookmarking a kind 34550 community now writes to the NIP-51 Communities
list (kind 10004) keyed by the addressable coordinate, so the reference
stays valid across community updates. My Communities merges bookmarked
communities as a third discovery source alongside founded and member-of,
with Founder/Member/Bookmarked badges on each card.

Bookmark toasts live on the mutation itself so they survive the more-menu
dialog unmounting between .mutate() and publish resolution.
2026-05-11 11:12:13 -07:00
lemon aca019ff69 fix: remove duplicate community share action 2026-05-11 11:12:13 -07:00
lemon 0fadf3b23a feat: add community editing 2026-05-11 11:11:07 -07:00
lemon dfd4fa6be7 fix: improve community member management 2026-05-11 11:11:07 -07:00
lemon 97d81f2295 refactor: split community creation into two steps
- CreateCommunityDialog now only publishes kind 34550 (name, image, description)
- New AddMemberDialog on the community detail page handles membership:
  - Founder can add moderators and members
  - Moderators can add members only
  - Badge definition (kind 30009) created lazily on first member add
  - Community definition republished once with all changes batched
  - Kind 8 badge awards published for each member
- Add Members button on Members tab, visible to rank 0 users
- Search dropdown moved outside ScrollArea to prevent clipping
2026-05-11 11:11:07 -07:00
lemon 91d50c2d83 feat: add community creation flow and improve discovery UX
- Add CreateCommunityDialog with name, image upload, description, and moderator type-ahead search
- Publish kind 30009 badge definition + kind 34550 community definition with d-tag collision check
- Context-aware FAB on My Communities tab opens the create dialog
- Default Search page tab to Communities instead of Posts
- Add Search to default sidebar order for new accounts
- Improve empty states on both Activities and My Communities tabs to guide users toward discovery
2026-05-11 11:11:07 -07:00
Alex Gleason c85f65a99a release: v2.14.2 2026-05-11 09:47:26 -07:00
Chad Curtis f525f9c393 Merge branch 'fix-blobbi-widget' into 'main'
Add switch-blobbi button to BlobbiWidget

Closes #277

See merge request soapbox-pub/ditto!220
2026-05-11 12:12:54 +00:00
Chad Curtis 2adc0a763b Merge branch 'fix/prevent-blobbi-1124-event-spam' into 'main'
Bound Blobbi social care interactions and energy flow

Closes #276

See merge request soapbox-pub/ditto!219
2026-05-11 12:12:31 +00:00
Sam Thomson d939934b7b Merge branch 'ui/gut-shapes' into 'main'
ui/gut-shapes

See merge request soapbox-pub/agora-3!20
2026-05-11 07:51:03 +00:00
sam e12716722a remove shape stuff 2026-05-11 14:49:11 +07:00
Alex Gleason ac901ac096 release: v2.14.1 2026-05-10 19:42:40 -07:00
Alex Gleason e54d7c8155 Show repost header on reposted reactions, zaps, reposts, and poll votes
NoteCard's reaction/repost/zap/poll-vote branches return early with their
own ActivityCard layouts, skipping the inline 'X reposted' header that
the normal note layout renders below. As a result, when one of these
events appeared in a feed via a kind 6/16 repost, the reposter
attribution was silently dropped.

Add an optional `header` slot to ActivityCard and pass the repost header
into all four early-return branches when `repostedBy` is set.
2026-05-10 19:21:43 -07:00
Alex Gleason d84f2b790f Link people-list avatars to profiles and prefer naddr for kind 3
Stacked avatars in PeopleAvatarStack are now clickable, navigating to
the user's npub profile so readers can jump straight to a member from a
follow list, follow set, or follow pack — not only from the surrounding
post. Each avatar is wrapped in a Link with a stopPropagation handler so
the click doesn't bubble up to the card-level navigation, and the focus
ring is now visible on keyboard focus.

Kind 3 follow-list events are legacy replaceable kinds (NIP-01) but
fell outside the 10000–19999 range that NoteCard, EmbeddedPeopleListCard,
PostDetailPage, and NoteMoreMenu all special-cased — so clicking a
follow list in a feed went to a per-event nevent that pinned to a stale
revision instead of the stable naddr. The four call sites are now
unified behind a new lib/encodeEvent.ts helper that treats kinds 0, 3,
and 41 as replaceable, alongside 10000–19999 and 30000–39999. The same
helper exposes encodeEventNevent for callers that intentionally want to
reference a historical version (e.g. the profile-recovery dialog).
2026-05-09 17:06:10 -07:00
Alex Gleason a2dbc169b2 Show kind 8333 on-chain zaps as notifications
Lightning (kind 9735) and on-chain (kind 8333) zaps now share a single
"zap" group bucket in the notifications page and render with the same
header, sats label, and Zap icon. The Zaps preference toggles both kinds
together; native and push notification queries pick up 8333 automatically
through the shared kind list.
2026-05-09 16:53:17 -07:00
Alex Gleason 845c270f60 release: v2.14.0 2026-05-09 16:36:05 -07:00
Alex Gleason ed6ac39015 Move the Restore button inside the embedded snapshot card
Position the Restore button (or Current badge) absolutely in the
top-right corner of the embedded post, replacing the row beneath it.
Each snapshot now occupies just one container's worth of vertical
space, and the action sits next to the content it acts on instead of
detached below it. The overlay stops click and keyboard propagation so
clicking Restore doesn't also navigate away to the embedded card's
link target.
2026-05-09 16:31:55 -07:00
Alex Gleason ba4b95972f Tighten the event recovery dialog layout
The double-container effect — outer card frame around an embedded post
that already had its own border — wasted vertical space and made each
snapshot read like two stacked boxes. Drop the outer frame so the
embedded card is the only container, with a primary-colored ring on the
current version. The redundant date row also goes (the embedded card
displays its own timestamp), leaving just a right-aligned Restore button
or 'Current' badge below each snapshot.
2026-05-09 16:24:56 -07:00
Alex Gleason 440e00fb47 Add a Restore button to recover previous versions of replaceable events
The note 3-dots menu now exposes a 'Restore previous version' option for
replaceable and addressable events the current user owns, sitting next
to Delete. It opens a generic EventRecoveryDialog modeled after the
existing profile/mute-list/badge recovery dialogs — querying past
versions with nostr.req() (to bypass NPool's NSet deduplication) using
the same (kind, authors[, #d]) filter shape, and rendering each
historical version through EmbeddedPost so any kind displays correctly.

Restoring republishes the chosen snapshot's content and tags via
useNostrPublish with the snapshot passed as 'prev' so published_at is
preserved. Inline isAddressableKind helpers in useDeleteEvent and
useNostrPublish are now sourced from a shared src/lib/eventKinds.ts.
2026-05-09 16:19:14 -07:00
Alex Gleason 0a41cee6bf Include an e tag in draft article deletion events
The deletion event for a NIP-37 draft wrap (kind 31234) only carried
the addressable `a` coordinate. Per NIP-09, a deletion should also
reference the specific event by id when available, so relays and
clients that key their deletion logic on `e` tags don't miss it.

Look up the draft's event id from the TanStack Query cache (the drafts
list already stores it as `eventId` when parsing the wrap) and append
an `e` tag alongside the existing `a` tag. Falls back gracefully to
`a` only if the event id can't be resolved.

Regression-of: e93c6651
2026-05-08 12:09:06 -07:00
sam aa96c0089c blobbi-- 2026-05-08 11:59:00 +07:00
Sam Thomson da4116a1d1 Merge branch 'feat/fundraising' into 'main'
Add NIP-75 community fundraising goals

Closes #9

See merge request soapbox-pub/agora-3!12
2026-05-08 04:48:37 +00:00
Patrick PReis 54bf5efa1f Improve blobbi switcher a11y and constrain to horizontal scroll
- Add aria-label to close button and companion selection buttons
- Replace flex-wrap with horizontal scroll (max-w-[18rem]) so only ~5
  blobbis are visible at once; overflow scrolls horizontally
- Add visible thin scrollbar (.scrollbar-thin utility) overriding global
  scrollbar-hiding, plus a right-edge fade gradient to hint at overflow
- Add flex-shrink-0 to prevent items from collapsing
- Break BlobbiWidgetContent destructuring across multiple lines for
  readability
2026-05-07 23:52:02 -03:00
Alex Gleason 9c590f4560 Render kind 8333 like a zap in embeds and the detail page
The on-chain zap kind used to fall through the "unknown kind" path in
every surface except InteractionsModal: a bare NIP-31 alt-tag tombstone
on PostDetailPage, a generic embedded preview for nostr: quote URIs,
and a plain "This event kind is not supported" string inside the reply
composer's parent preview. Visually it was nothing like a zap.

Route kind 8333 through dedicated cards that mirror the 9735 Lightning
treatment pixel-for-pixel: amber bolt bubble, sender avatar, "zapped"
verb, amber sats amount, italic comment. Per NIP.md we verify the
claimed amount against mempool.space before displaying it, so the new
`useVerifiedOnchainZap` hook short-circuits to the single-event path of
`verifyOnchainZap`. Until verification resolves (or if it fails) the
card shows a muted "verifying…" / "unverified" hint next to the amount
so we don't silently lie.

Covers three surfaces in one pass:
  - Detail page (nevent URL): new isOnchainZap branch in PostDetailPage
  - Embedded quotes: new EmbeddedOnchainZapCard in EmbeddedNote
  - Reply composer parent preview: uses EmbeddedPost -> EmbeddedNote,
    so it inherits the fix for free.
2026-05-07 15:59:00 -07:00
Alex Gleason 589a5f159e Show kind 8333 zaps in the interactions modal Zaps tab
The Zaps tab only rendered NIP-57 receipts (kind 9735), so a post that
had been zapped only on-chain appeared to have no zappers at all. Merge
the two rails into a single unified view-model and render them with
identical rows — same avatar, same name line, same amber amount badge,
same chevron link. The dedup and on-chain verification are already
handled by useOnchainZaps upstream; this change is just plumbing.

The modal now takes the full target event instead of a bare eventId so
the on-chain query can compute the `a` coordinate for addressable kinds.
Updated all call sites (PostDetailPage, PodcastDetailContent,
MusicDetailContent) accordingly.
2026-05-07 15:23:50 -07:00
Alex Gleason ae11c91674 Fill the action-bar zap button after a successful zap
Previously the bolt icon next to reply/repost/react was stateless: an
outlined zap icon whether you'd zapped the post or not. This matched
neither the repost button (flips to the accent color when reposted) nor
the reaction button (fills when you've reacted), and the gap was most
noticeable with on-chain zaps where users expected the same visual
confirmation they get for Lightning.

Add a useUserZap hook that consults both rails in one REQ: kind 8333
filtered by authors+#e (our on-chain zap, self-authored), and kind 9735
filtered by #e with a client-side extractZapSender match (NIP-57
receipts are authored by the LNURL server, not the zapper). The send
hooks (useOnchainZap, useZaps) optimistically set the cache to true on
success so the icon fills immediately, without waiting for the relay to
echo the event back.

Wired into every action-bar renderer that carries the zap button:
NoteCard, PostActionBar, PhotoBottomBar, VinesFeedPage, BookFeedItem.
2026-05-07 15:09:32 -07:00
Alex Gleason 72989349e8 Document on-chain zaps (kind 8333) in WALLET.md
The wallet doc covered sending Bitcoin and NIP-73 tx/address pages but
never mentioned that the zap-dialog flow also publishes a kind 8333
attestation pairing the tx with the zapped Nostr event. Add a short
section describing the tags and cross-link the full spec in NIP.md.
2026-05-07 15:09:23 -07:00
Alex Gleason 1283b56be9 Simplify zap success screen
Remove the auto-close progress bar, the "Sent via Bitcoin/Lightning" rail
indicator, and the sats subtext under the USD amount. The screen now
dismisses only via the Done button, so the rail-specific plumbing
(autoCloseMs, kind) is gone from the component API as well.

Regression-of: 5c2c3513
2026-05-07 15:00:01 -07:00
Alex Gleason 5c2c35130f Show a grand success screen after a successful zap
Previously, a successful send from the Zap dialog auto-closed and surfaced a
toast. That undersold what just happened — the user sent Bitcoin. Now both
rails (on-chain + Lightning) flip the dialog over to a dedicated success
screen with an animated check, amount, recipient card, rail indicator, and
(for on-chain) a "View transaction" link to mempool.space. The dialog
auto-dismisses after six seconds if the user walks away.
2026-05-07 14:55:57 -07:00
Patrick PReis da9f88d181 Add close (X) button to blobbi switcher popover 2026-05-07 15:36:01 -03:00
filemon 59929e9c4d Fix stale-write in dev editor by fetching fresh 31124 before mutation
The dev editor read tags and content from the TanStack Query cache
(companion.allTags / companion.event.content) and published without
prev, risking overwrite of concurrent changes (e.g. social
consolidation advancing the checkpoint on another device).

Apply the standard read-modify-write pattern: fetchFreshEvent before
merge, use prev.tags/content as the base, and pass prev to
publishEvent so published_at is preserved.
2026-05-06 23:12:25 -03:00
Patrick PReis 55f8d946f9 Add switch-blobbi button to BlobbiWidget
Users with multiple Blobbis can now change which one is displayed in the
widget without navigating to the full Blobbi page. A new ArrowLeftRight
icon appears below the companion (Footprints) button and opens a popover
with all available Blobbis for quick selection.
2026-05-06 23:04:04 -03:00
filemon e3127e8555 Fix stale 6-hour window in social interaction queries
Move effectiveSince computation from useMemo into queryFn so
Date.now() is evaluated fresh on each TanStack Query refetch.
Previously, long-lived pages froze the window floor at mount time,
causing interactions from friends to go undetected after hours.

Also document the intentional boost→feed reaction animation reuse.
2026-05-06 22:58:33 -03:00
filemon 478f53177e Bound social care history and simplify Blobbi social consolidation
- Gate social actions by projected stat thresholds (< 70) so visitors
      can only help stats in visual distress
    - Add energy category with Energy Drink and Power Nap Pillow items
    - Apply 6-hour recency window to interaction queries (limit 30)
    - Fix BlobbiActionsProvider tree placement so BlobbiPage shares context
      with the companion layer
    - Preserve event content in dev editor (don't overwrite checkpoint JSON)
    - Show Needs Now summary in activity tab with priority badges
    - Remove unused need-driven consolidation infrastructure

   Regression-of: 9aecefff
2026-05-06 22:27:50 -03:00
Alex Gleason 4b9fe24b25 Fix example keys in WALLET.md 2026-05-06 14:09:57 -05:00
Alex Gleason 9810f813a8 release: v2.13.1 2026-05-05 23:06:07 -05:00
Alex Gleason 9090ecfa2b Use APP_RELAYS as nostrconnect fallback
When the user has no NIP-65 write relays configured, the nostrconnect://
URI was built with a single relay (wss://relay.ditto.pub). Fall back to
the full APP_RELAYS write list instead so the remote signer has more
connection options during the handshake.
2026-05-05 23:03:32 -05:00
Alex Gleason 6c58b087ae release: v2.13.0 2026-05-05 20:48:38 -05:00
Alex Gleason 3555cbcf99 Pare down Lightning zap buttons and drop sats from the display
The Send and Pay-with-WebLN buttons no longer carry a lightning-bolt icon; the on-chain tab's primary button is text-only too, so this brings the flow in line. Sats are no longer shown anywhere on the Lightning screens — the USD amount is the whole story as far as the user is concerned, and the sats figure is just an implementation detail the LNURL flow handles.

Presets drop from $1/$5/$10/$25/$100 to $0.10/$0.50/$1/$2/$5 and the default amount moves from $5 to $0.50. Lightning zaps are tip-jar-shaped, not dinner-shaped — the on-chain tab can stay where it is because a fixed-fee on-chain send doesn't make sense below a few dollars.
2026-05-05 20:07:10 -05:00
Alex Gleason 9a08c6e488 Align Lightning zap dialog with on-chain design
Both tabs now lead with a big clickable USD amount and the same //// preset row, with the sats figure relegated to a small secondary line. Dropped the Lightning-only comment textarea and emoji picker — the on-chain flow never had them and keeping two different preambles made the tabs feel like different products. Preset buttons are shorter (h-8) in both tabs.

The invoice screen now shows USD primary / sats secondary in the header and renders the QR through QRCodeCanvas instead of a data-URL img, matching the on-chain fallback's QR styling. Large-amount two-tap confirmation (>= $100) now applies to Lightning too.
2026-05-05 20:01:05 -05:00
Alex Gleason b1c49c06a3 Surface insufficient-balance state directly on the zap amount + button
When the requested amount plus fee exceeds the user's balance, the
big dollar readout and send button both go destructive-red, the
button text becomes 'Not enough Bitcoin' and disables, and the
'Balance: $X' footnote in the fee row is hidden. The zero-balance
case ("you don't have any Bitcoin yet") still shows the balance line
so users understand why send is disabled.
2026-05-05 19:42:36 -05:00
Alex Gleason 9b81175e85 Strip sats subtitle, comment, and fee icon from on-chain zap dialog
The dialog collapses to: amount, presets, Send button, fee. Removes
the sats-per-amount subtitle under the big number, the collapsible
'Add a comment' accordion, and the gauge icon next to the fee line.

Comments ride along as empty strings on the zap payload, so the
backing 8333 publish still works, it just doesn't carry user text.
2026-05-05 19:39:46 -05:00
Alex Gleason 29fa317689 Polish on-chain zap dialog microcopy and layout
- Center the 'Add a comment' accordion toggle and make its textarea
  span the full width when opened.
- Tighten the 'How does sending Bitcoin work?' FAQ: leads with the
  Nostr-key-as-wallet framing, drops the extra paragraph about
  balances, and keeps the fee and public/irreversible points concise.
- Move the fee/balance line below the Send button so it reads like a
  footnote under the primary action instead of competing with it.
2026-05-05 19:38:24 -05:00
Alex Gleason 7be92b8eec Simplify on-chain zap dialog
Redesign the on-chain flow around a sleek 'Send $X' experience: click
the big number to edit, presets sit underneath, comment collapses behind
a chevron, recipient address and OR divider are gone, the send button
just says 'Send $5.20' with the fee included, and fee speeds are
deduplicated so duplicate sat/vB tiers don't repeat.

The fee speed now auto-adjusts when the amount changes to keep the fee
below 40% of the send amount — once the user manually picks a speed,
auto-adjustment is disabled for the session.

Dialog framing switches from 'Send a Zap' to 'Send Bitcoin', the
redundant description line is dropped, and the (?) popover routes to
one of two new tab-specific FAQ entries (send-bitcoin-onchain or
send-bitcoin-lightning).
2026-05-05 19:35:52 -05:00
Alex Gleason 3caad76477 Improve icon contrast on highlighted search dropdown items
Nav items and the 'Search for...' entry in the autocomplete dropdown had
primary-colored icons on a primary-tinted circle, which lost contrast
against the primary-tinted row background when highlighted. Swap both to
accent-foreground tones when the row is selected.
2026-05-05 19:23:38 -05:00
Alex Gleason 65788705c3 Speed up npm run test with eslint/tsc caching
Ignore .agents skill bundles from eslint (template files not part of
the build), enable eslint --cache and tsc --incremental so warm re-runs
skip unchanged files.
2026-05-05 19:00:53 -05:00
Alex Gleason 4a4ed9bc2d Split nostr-kinds skill into design and rendering
The combined skill conflated two unrelated jobs: (1) design-time
decisions when authoring a new kind (NIP-vs-custom, ranges, tag design,
NIP.md) and (2) implementation-time checklist for wiring rendering into
Ditto's many UI touchpoints. The single description sentence was
unwieldy, and its trigger ('introducing a new kind... or registering a
kind in the UI') was phrased around the author's perspective — it
didn't match user phrasing like 'support displaying kind X' or 'render
NIP-Y', so I skipped loading it when implementing NIP-84 and missed
half the registration points.

Split into:

- nostr-kind-design — NIP-vs-custom decision, kind ranges, tag design,
  content-vs-tags, NIP.md. Loads when minting or extending a schema.
- nostr-kind-rendering — the multi-location UI registration checklist.
  Loads when rendering a kind Ditto doesn't yet display, or when asked
  to 'support / display / render' a NIP or kind number.

Expanded the rendering checklist with the points I missed during the
NIP-84 pass: the six-file notification stack, the four-file AppConfig
triple for feed-toggle keys, sidebar icon registration, AppRouter route
wiring, shouldHideFeedEvent spam guards, and a 'bugs that signal a
missed step' section so the checklist reads as diagnostic too. Also
flagged the embedded-previews trap where skipping the dispatcher branch
silently feeds quoted prose through the kind-1 tokenizer.

Updated both AGENTS.md references to point at the two new skills.
2026-05-05 17:33:11 -05:00
Alex Gleason 70efa971eb Strengthen AGENTS.md commit mandate to override system-prompt default
The OpenCode system prompt's bash-tool instructions include 'Only create
commits when requested by the user' and 'NEVER commit changes unless
the user explicitly asks you to.' Those rules were overriding the
existing AGENTS.md directive and causing the agent to stop at
'validation passed' without committing, repeatedly, across sessions.

Spell out the conflict by name (linking upstream PR #25198), state
explicitly that AGENTS.md takes precedence, and enumerate the failure
modes (asking permission, waiting to be asked, treating uncommitted
changes as 'done') so the agent has something concrete to match against
when it catches itself hesitating.
2026-05-05 17:26:14 -05:00
Alex Gleason a6bfd2cb68 Add Birdex chorus button
New BirdexChorusButton plays every species' Wikipedia recording at once
— a dawn chorus for the whole life list. Hides itself when no species
has usable audio, and the feed-card variant swallows clicks so toggling
playback doesn't navigate away from the NoteCard.
2026-05-05 17:24:57 -05:00
Alex Gleason df38cfdbca Remove share button from PostActionBar
Share action is still available from the NoteMoreMenu. The inline button
in the action bar is redundant and adds visual noise to the row.
2026-05-05 17:24:49 -05:00
Alex Gleason 234d3a21a3 Support NIP-84 Highlight events (kind 9802)
Render highlight excerpts as pull-quotes with source attribution across
feed cards, detail pages, and quote embeds. Without this, kind 9802
events fell through to UnknownKindContent in cards and — worse — had
their quoted prose fed through the kind-1 tokenizer in embeds,
auto-linkifying URLs and hashtags that were part of the original source,
not the highlight author's post.

Integration points:
- HighlightContent renders the excerpt with an accent blockquote, wraps
  the highlighted span in <mark> when a context tag is present, and
  attributes the source via EmbeddedNaddr (a-tag), EmbeddedNote
  (e-tag), or a sanitized URL chip (r-tag).
- EmbeddedHighlightCard gives quoted highlights a dedicated compact
  card instead of the generic-embed fallback.
- Added 9802 to NoteCard + PostDetailPage dispatch, KIND_HEADER_MAP,
  CommentContext labels/icons, NOTIFICATION_KIND_NOUNS, and the
  EmbeddedNote dispatcher.
- Registered an EXTRA_KINDS entry with feedIncludeHighlights (off by
  default) and showHighlights (on), plus a /highlights route backed by
  KindFeedPage.
- Added a highlights notification type with its own subscription
  template, preference toggle, grouped notification row, and
  author-ownership filter so users are only notified when their own
  content is highlighted.
- feedUtils hides empty highlights with no source reference.
2026-05-05 17:24:42 -05:00
Alex Gleason 64a4643503 Merge branch 'main' of gitlab.com:soapbox-pub/ditto 2026-05-05 16:11:00 -05:00
Alex Gleason c7e0234896 Unify /i/ external content page UI
Three inconsistencies between /i/ pages cleaned up:

* Action bar now always renders three universal interaction buttons
  (Comment, React, Share) with the Comment button opening the
  compose modal and showing the top-level comment count. ISBN keeps
  its Write Review (star) button as a fourth. Previously Bitcoin
  and other non-book identifiers showed only React + Share.

* Book content tabs (Comments / Reviews) switched from the flat
  shadcn Tabs primitive to SubHeaderBar + TabButton so the curved
  arc styling matches Feed, Profile, Search, and every other tabbed
  page in the app.

* Extracted ExternalCommentsSection — the inline ComposeBox +
  threaded list + loading/empty states are now shared between the
  default single-list layout and the ISBN Comments tab instead of
  duplicated.
2026-05-05 16:00:41 -05:00
Alex Gleason c69aee40a2 Add bottom border to ComposeBox by default
Every context except the Feed composer follows the compose box with
content that needs visual separation (comment lists, wall posts,
threaded replies), so add a default border-b and let Feed opt out with
a new hideBorder prop — it sits directly above SubHeaderBar's arc
background, which already provides its own separator.

Modal usage (forceExpanded) stays borderless since it lives inside
a dialog container.
2026-05-05 15:49:55 -05:00
Alex Gleason 908c5b248c Enable commenting on NIP-73 identifier /i/ pages
ExternalContentPage already supported fetching comments for non-URL
external roots (bitcoin:tx:..., isbn:..., iso3166:..., etc.), but the
inline ComposeBox and the FAB's ReplyComposeModal were both gated on
the URL-only commentRootUrl — leaving those pages with no way to post.

Widen ComposeBox's replyTo and ReplyComposeModal's event to accept
`#${string}` NIP-73 identifiers alongside URLs and events, route them
through the existing NIP-22 publish path (usePostComment already
handled string roots), and wire the page up to the combined
commentRoot.
2026-05-05 15:46:30 -05:00
Alex Gleason 546b1aff9b Let shield icon inherit hover color from button
text-primary was applied directly to the Shield icon, overriding the
ghost button's hover:text-accent-foreground. On hover the background
turned into accent while the icon stayed primary, producing a
low-contrast pairing. Move text-primary up to the Button so the
ghost variant's hover rule can take over, and let the icon inherit
currentColor.
2026-05-05 15:01:13 -05:00
Chad Curtis 9aecefff40 Merge branch 'feat/blobbi-1124-interactions' into 'main'
Add Blobbi social interactions (kind 1124)

Closes #265

See merge request soapbox-pub/ditto!211
2026-05-05 19:11:09 +00:00
Chad Curtis a9ff5c43f0 Merge branch 'fix-blobbi-sleepy-eyes' into 'main'
Fix sleepy eye animation breaking when same Blobbi rendered in multiple places

Closes #272

See merge request soapbox-pub/ditto!217
2026-05-05 19:09:58 +00:00
Alex Gleason d34a155922 Simplify permission manager popover
- Remove the app-name subtitle under "Permissions"; the site is already
  identified by the nav bar directly above the popover.
- Drop the trash icon from the "Revoke all" button so the destructive
  action reads as plain text like the row items.
- Remove the per-row allow/deny toggle. Stored permissions are always
  "allowed" in practice (a denied prompt doesn't surface a row the user
  would want to keep around), so the toggle added noise without a
  realistic use case. Users who change their mind can remove the row and
  re-prompt.
- Drop the status check/X icon on the left of each row now that the
  toggle is gone and every listed permission is implicitly allowed.
- Show the remove button always (was opacity-0 until hover) and switch
  it from a trash icon to an X, matching the close-affordance idiom used
  elsewhere in the app.
- Drop the siteName prop from NsitePermissionManager; nothing uses it
  anymore.
2026-05-05 14:00:28 -05:00
Alex Gleason c2c5b5c3be Drop target-pubkey from nsite encryption/decryption prompts
The target pubkey was rendered as a truncated hex string in the prompt,
which is noise to the user: they can't verify it, it doesn't scope the
stored permission (which is global-to-the-app), and showing a pubkey
next to "Allow" misleadingly suggests the decision applies only to that
peer. Drop the field from NsitePromptState and stop threading it through
the four encrypt/decrypt RPC branches.
2026-05-05 13:57:15 -05:00
Alex Gleason 5e729f74cd Grant broad permissions-policy and fix nav-bar safe-area spacing on sandbox frames
Sandbox iframes live on a cross-origin subdomain, so most capability APIs
are blocked unless the parent delegates them with allow=. Add a permissive
policy covering media (camera, microphone, display-capture, encrypted-media,
picture-in-picture, autoplay, speaker-selection), sensors (accelerometer,
gyroscope, magnetometer, ambient-light-sensor, compute-pressure, battery),
input (gamepad, midi, keyboard-map, xr-spatial-tracking), and UX features
(fullscreen, geolocation, idle-detection, screen-wake-lock, clipboard-write,
web-share, window-management, storage-access) so nsites and webxdc apps can
use anything a regular web app would.

Deliberately omitted for security: payment, publickey-credentials-*,
otp-credentials, identity-credentials-get (all phishing/account-takeover
vectors), local-fonts (fingerprinting), bluetooth/hid/serial/usb/
clipboard-read (raw device access left off for now).

Drop WebxdcIframe's narrow allow= override so webxdc apps get the same
broad policy instead of downgrading to just autoplay/fullscreen/gamepad.

Also split safe-area-top from content padding in the NsitePreviewDialog
and WebxdcEmbed nav bars: the outer element reserves space for the
notch inset, the inner row keeps a fixed px-3 py-2 flex layout so
content stays vertically centered inside the intended 44px bar height.
2026-05-05 12:52:49 -05:00
Alex Gleason 9e6ed02ce1 Add nsite:// sidebar pinning with auto-launch, favicon, and highlight
Introduces a new sidebar item type for nsites that auto-opens the nsite
preview when clicked, using React Router state to prevent external URLs
from triggering auto-launch.

- Add isNsiteUri/nsiteUriToSubdomain helpers and parseNsiteSubdomain
- Create NsiteSidebarItem with site favicon and link preview title label
- Wire nsite:// dispatch in SidebarNavList, useFeedSettings, SidebarMoreMenu
- NoteMoreMenu pins named nsite events as nsite:// URIs instead of nostr:
- NsiteCard gains a Pin/Unpin button and an autoPlayKey prop that re-opens
  the player each time the sidebar item is clicked
- NsitePlayerContext tracks the active subdomain for sidebar highlighting,
  provided in MainLayout so sidebar and pages share state
- PostDetailContent consumes nsiteAutoPlay router state and clears it
  after consumption so a page refresh doesn't re-trigger auto-play
2026-05-05 12:38:26 -05:00
Alex Gleason 973acd7e9b Inject NIP-07 signer into nsites and drop native sandbox path
When a logged-in user opens an nsite preview, a window.nostr provider is
injected into the sandboxed iframe. The provider proxies signEvent, nip04,
and nip44 calls to the parent signer over the existing JSON-RPC bridge.

A permission system gates each operation:
- getPublicKey is auto-allowed (clicking Run implies consent)
- signEvent prompts are granular per event kind (like Amber)
- encrypt/decrypt prompts are per operation type
- Users can check 'Remember for this site' to persist decisions
- Permissions are scoped to (userPubkey, siteId) in localStorage

The nsite preview nav bar gains a shield icon that opens a popover for
managing stored permissions.

Kind labels for the signer nudge, the permission prompt, and the post-
detail loading title now route through a central KIND_LABELS registry
(src/lib/kindLabels.ts) instead of three divergent inline maps.

The native SandboxPlugin (iOS WKWebView / Android WebView overlay) is
removed; SandboxFrame now always uses iframe.diy, so native behavior
matches web. This drops ~1100 lines of native code, the Android-only
blob prefetch workaround in NsitePreviewDialog, and the createPluginCall
registration in MainActivity and capacitor.config.json.
2026-05-05 12:15:31 -05:00
Alex Gleason d2cf678491 Fix sing-to-Blobbi mic access on Android and preview playback on iOS
The sing action uses getUserMedia + MediaRecorder, which in a browser is
gated only by the standard web mic prompt. In Capacitor's Android
WebView it additionally requires the RECORD_AUDIO permission to be
declared in AndroidManifest.xml; without it the WebView rejects with
NotAllowedError and no system prompt is ever shown, so tapping record
silently fails on the Android app while working fine in the browser.

Also add MODIFY_AUDIO_SETTINGS, which some devices require for the
echoCancellation / noiseSuppression / autoGainControl constraints that
InlineSingCard passes to getUserMedia.

Separately, reorder AUDIO_MIME_CANDIDATES to prefer audio/mp4/aac over
audio/webm;codecs=opus. iOS WKWebView cannot decode WebM/Opus in an
<audio> element, so the recorded Blob's preview URL failed to load on
iOS. Android WebView and desktop Chromium both support mp4/aac, so
preferring it first is safe cross-platform. This mirrors the ordering
already used by useVoiceRecorder.ts.
2026-05-05 09:20:01 -05:00
Alex Gleason 9c74ddcaa9 release: v2.12.2 2026-05-04 21:57:23 -05:00
Alex Gleason b63d2ba343 Fix outdated npm run release reference in AGENTS.md 2026-05-04 21:54:53 -05:00
Alex Gleason 0497aa33c9 Show live progress during the nostrconnect login handshake
When a user tapped "Open Signer App", the dialog previously stayed
frozen on the same screen — same button, same copy-URI fallback, no
feedback — until the login either succeeded (and the dialog dismissed)
or timed out after two minutes. With slow or flaky signers (Amber's
current listening-REQ bug being the immediate trigger, but any NIP-46
signer that takes more than a second or two to respond hits the same
hole) this looked indistinguishable from a hang. Users retapped the
button, closed the dialog, gave up.

Now the dialog swaps the QR / Open Signer App area for a centered
spinner with a live status line as the handshake advances:

- "Waiting for signer connection…" while the signer app has the user
  and we're listening on kind 24133 for the connect-ack.
- "Getting public key…" once the connect-ack arrives and we're
  issuing the NIP-46 get_public_key RPC.

On mobile the swap happens synchronously when the user taps "Open
Signer App" so they see the progress state the moment they return
from the signer — this is the most important window, since that's
exactly when the original UI left them staring at a button they
were worried they needed to re-tap. On desktop the QR stays visible
through the awaiting-connect phase (they may still be scanning with
a different device) and only swaps in once the signer has
acknowledged.

The progress view includes a Cancel link (primary color, matches the
"Create account" affordance) that aborts the in-flight subscription
and regenerates fresh connect params — equivalent to the existing
Retry path, but reachable while the handshake is live instead of
only after a failure.

The handshake phases are surfaced via the new `onStatus` callback
on `NLogin.fromNostrConnect` in @nostrify/react 0.6.0. Bumps
@nostrify/react to ^0.6.0 and @nostrify/nostrify / @nostrify/types
to their matching versions (^0.52.0 / ^0.37.0) to avoid duplicate
nested package copies that would otherwise split type identity.

Incidental cleanup while editing the dialog: the Copy URI button and
the "Tap to open your signer app" / "Scan with your signer app"
status lines are removed. The primary Open Signer App button is
self-explanatory on mobile, and the QR on desktop doesn't need a
caption.
2026-05-04 21:42:45 -05:00
Alex Gleason 0d1fe7bbca Fix nostrconnect listen effect losing successful logins on re-render
The nostrconnect listening effect depended on `login`, `onLogin`,
`onClose`, and `isWaitingForConnect`. `login` is a fresh object from
useLoginActions on every render, and every call site passes inline
arrow functions for onLogin/onClose, so the effect re-ran on every
parent render. Each re-run fired the cleanup and flipped a local
`cancelled = true` flag, and when the signer's NIP-46 response
eventually arrived, the success branch saw `cancelled === true` and
silently skipped `onLogin()` / `onClose()` — the user was logged in on
the backend but the dialog never closed.

Stabilize onLogin/onClose/login via latest-value refs, narrow the
effect deps to `[nostrConnectParams]` only, and gate the success
branch on `controller.signal.aborted` (which is only true when the
dialog was explicitly closed or handleRetry fired). Drop the unused
`isWaitingForConnect` state. Also abort the in-flight controller from
handleRetry before regenerating params, so the prior subscription
doesn't linger.

The bug was masked by most signers responding fast enough (<1s) that
parent re-renders didn't happen during the wait. It surfaced during
an upstream Amber bug that delays its listening REQ by ~8+ seconds,
giving render cycles time to fire (https://github.com/greenart7c3/Amber/pull/420).
The Amber bug is getting fixed separately; this Ditto fix stands on
its own — any signer that takes a few seconds to respond could trip
the same race.
2026-05-04 21:16:38 -05: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
Alex Gleason b5675802f4 Replace feed-style page-load skeleton with a neutral spinner 2026-05-04 19:12:31 -05: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
Alex Gleason 14e5a82b1e Play shortform videos unmuted when autoplay is off
VineMedia (kind 22 / 34236 in regular feeds) always started muted on
click-to-play, even when the "autoplay videos" setting was disabled.
That made click-to-play behave differently from the regular VideoPlayer,
where a user-gesture click plays with sound.

Now VineMedia only starts muted when autoplayVideos is on (browsers
require autoplay to be muted). When the setting is off, a click is an
explicit user gesture and plays with audio. The <video> muted attribute
is also bound to state so it tracks the mute toggle correctly. If play
is rejected unmuted, we retry muted as a fallback.

Regression-of: 23e845eb
2026-05-04 18:13:04 -05: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
filemon bc2131ed52 Merge branch 'blobbi-drag-to-feed-clean' into feat/blobbi-1124-interactions 2026-05-04 16:01:20 -03:00
filemon af21eee389 Merge branch 'main' into blobbi-drag-to-feed-clean 2026-05-04 13:36:50 -03:00
filemon 20d7aa199d Merge branch 'main' into feat/blobbi-1124-interactions 2026-05-04 13:29:31 -03:00
Patrick PReis 16222d0145 Merge branch 'main' into fix-blobbi-sleepy-eyes
# Conflicts:
#	src/blobbi/ui/BlobbiAdultSvgRenderer.tsx
#	src/blobbi/ui/BlobbiBabySvgRenderer.tsx
2026-05-04 13:00:37 -03:00
Alex Gleason 64ca61bb32 Merge branch 'main' of gitlab.com:soapbox-pub/ditto 2026-05-04 09:52:00 -05:00
Chad Curtis 135666c956 Merge branch 'feat/blobbi-shake-reaction-stability' into 'main'
Add stable shake-to-dizzy reaction flow and preserve SVG animations

Closes #237

See merge request soapbox-pub/ditto!191
2026-05-04 14:42:07 +00:00
root 46cdbe08eb Linkify nostr URIs, URLs, hashtags, and emoji in article bodies
Long-form articles (kind 30023) previously ran their content through
react-markdown with no Nostr awareness, so `nostr:` URIs, bare URLs,
hashtags, and custom emoji all surfaced as literal text. This composes
NoteContent into the markdown renderer instead, so mentions get hover
cards, quoted notes render as embedded cards, URLs get link previews,
and custom emoji resolve — matching regular note behavior exactly, with
zero duplicated parsing.

NoteContent gains an `as: 'div' | 'span'` prop so it can be embedded
inline inside markdown elements without invalid-HTML nesting. Markdown
paragraphs render as `<div>` (with reproduced prose paragraph spacing)
so block-level quote cards and images are legal children. Headings
suppress block embeds to avoid quote cards inside an `<h1>`.

Also strips Typography's default always-on link underline in favor of
hover-only, matching the rest of the app.
2026-05-04 04:28:41 +00:00
filemon 62cc2611ea Fix dizzy-eye SMIL restart during nausea recovery drain
Replace blobbi reference equality with stable visual-identity primitive
comparisons in MemoizedBlobbiVisual memo and SVG renderer useMemo deps.
This prevents SVG DOM rebuilds (which restart SMIL animateTransform)
when the upstream companion object gets a new reference during the
imperative gradient-drain loop.
2026-05-03 19:18:49 -03:00
filemon 65b6c2afb6 Fix frozen dizzy eyes during nausea recovery drain
Lift useFillLevelUpdate above MemoizedBlobbiVisual so the memo boundary
can block the 60fps re-render cascade during level-only changes. The
arePropsEqual comparison now uses a pre-computed recipeFingerprint
(which excludes angerRise.level) instead of recipe reference equality.

During nausea drain, the structural recipe fingerprint stays constant,
so MemoizedBlobbiVisual blocks all re-renders — keeping the SVG DOM
stable and SMIL spiral-eye animations running uninterrupted. The fill
level still updates imperatively via useFillLevelUpdate called from
BlobbiCompanionVisual's root ref.
2026-05-03 19:18:49 -03:00
filemon d80f8ad70c Decouple vomit from hunger, uncap puddles, fix mouth spawn position
Vomit now triggers on any sufficiently hard shake (peakIntensity >= 0.7)
regardless of hunger level. Green nausea fill remains hunger-gated.

Remove the MAX_SPLATS = 3 cap so puddles accumulate freely until the
user clicks/taps them away.

Add a static mouthAnchor lookup table that maps baby/adult forms to
their actual mouth Y-ratio (including the +0.12 visual offset), so
the vomit drop spawns from the correct mouth position per form.
2026-05-03 19:18:49 -03:00
filemon 7ba94d72e4 Fix vomit visibility, landing position, re-vomit guard, and animation jank
- Falling drop z-index 10002 (above companion) so it visibly exits mouth
- Landed puddle stays at z-index 9998 (below companion)
- Land position changed to 20px below Blobbi's container bottom, clamped
- Spawn position adjusted to config.size * 0.65 for mouth area
- Unified transform anchor to translate(-50%, -100%) in both states
- Keyframe ends at scale(1) opacity(1) to prevent pop on landing
- Reset vomitedThisCycle + peakIntensity in onDragStart during active
  cycle so each new qualifying drag can vomit independently
2026-05-03 19:18:49 -03:00
Alex Gleason 1597e7540b Disable user relays by default
User relays are no longer used until the user explicitly opts in via
Settings > Network. Adds a useUserRelays toggle alongside the existing
useAppRelays toggle in RelayListManager, defaulting to false. Fresh
installs and new accounts will only query app-default relays until the
user enables their personal NIP-65 list.

The user's relay list (kind 10002) is still synced from Nostr and
managed in the UI when logged in — the toggle only controls whether it
is included in the effective relay set used by NostrProvider's pool
and useNativeNotifications. The setting is persisted to AppConfig and
synced cross-device via NIP-78 encrypted settings.

getEffectiveRelays now takes both flags and short-circuits accordingly,
producing an empty list when both are off (instead of the previous
behavior of always returning user relays).
2026-05-03 09:00:43 -05:00
filemon abe12fdefa Fix vomit timing to trigger on release, land near Blobbi
- Move vomit trigger from delayed dizzy timer to immediate on drag
  release when conditions are met (cycleHadNausea + peakIntensity >= 0.7)
- Add vomitedThisCycle ref to prevent duplicate emissions per cycle
- Change landY from viewport bottom to short distance below Blobbi
  (renderedPosition.y + size*0.9, clamped to floor limit)
- Replace puddle <div role=button> with semantic <button type=button>
- Use translate(-50%, -100%) so puddle bottom sits at the landing point
- Update click handler type from PointerEvent to MouseEvent
2026-05-02 19:00:40 -03:00
filemon b02a3b604e Add vomit escalation to shake/nausea reaction
When Blobbi is shaken hard enough while nauseous (hunger >= 90 and
peak intensity >= 0.7), the dizzy phase now escalates to a brief
'vomiting' phase. A green drop falls from Blobbi's mouth to the ground
and becomes a persistent puddle that stays fixed in place until the
user clicks/taps it to clean up. Max 3 puddles; oldest removed on
overflow.

New files:
- VomitSplat.tsx: falling drop + landed puddle (SVG, click-to-remove)

Modified:
- useShakeReaction.ts: new 'vomiting' phase, peakIntensity tracking,
  VomitEvent signal, vomitTimer with proper cleanup
- BlobbiCompanionLayer.tsx: splat state management, coordinate
  calculation, renders VomitSplat siblings in the overlay
- index.css: vomit-fall keyframe animation
2026-05-02 18:52:25 -03:00
Patrick PReis 305af8ad93 Extract useBlobbiInstanceId hook to deduplicate SVG renderers
Move the useId()-based instance ID generation into a shared hook so the
logic and comment live in one place instead of being duplicated across
BlobbiAdultSvgRenderer and BlobbiBabySvgRenderer.
2026-05-02 18:32:38 -03:00
Patrick PReis c03705d6d6 Fix sleepy eye animation breaking when same Blobbi rendered in multiple places
When a Blobbi appeared in multiple spots simultaneously (hero + drawer
grid, hero + floating companion, feed card + companion), all SVG
instances shared the same clip-path IDs (e.g. blobbi-blink-clip-{d}-left).
The browser resolves clip-path: url(#id) to the first matching element
in document order, so the hero's eyes would use the drawer thumbnail's
non-animated clip-rects — eyes stayed open while CSS eyelid animations
still ran.

Use React's useId() to generate a unique instanceId per component
instance instead of reusing blobbi.id, ensuring clip-path and gradient
IDs are unique across all concurrent renders of the same Blobbi.

Regression-of: 384936f1
2026-05-02 18:32:31 -03:00
filemon 189411ff77 Merge branch 'main' into feat/blobbi-shake-reaction-stability 2026-05-02 18:05:57 -03:00
Chad Curtis ebfa8fc6d2 Merge branch 'fix-egg-vibrate' into 'main'
Fix egg tap-to-wiggle not working on /blobbi page

Closes #271

See merge request soapbox-pub/ditto!216
2026-05-02 08:28:14 +00:00
Alex Gleason b639bd7a58 release: v2.12.1 2026-05-01 22:22:46 -05:00
Alex Gleason 74a2522af1 Show right widget sidebar at iPad-landscape width
The right sidebar previously required xl (1280px) to appear, so horizontal
iPad (1024px) viewports saw only the left sidebar + main column. Use the
existing lg breakpoint (1024px) to control right-sidebar visibility, and
let the sidebars scale fluidly with the viewport by setting them to w-1/4
max-w-[300px] instead of fixed pixel widths. The center column (flex-1)
absorbs whatever space remains, so the layout fills the viewport smoothly
from 1024px up through the 1200px wrapper cap instead of leaving dead
space at intermediate widths. Below the lg breakpoint, the left sidebar
keeps its fixed 300px width.
2026-05-01 22:10:11 -05:00
Patrick PReis ea53a1b0dd Fix egg tap-to-wiggle not working on /blobbi page
The wrapper div around BlobbiStageVisual in BlobbiRoomHero had an
unconditional pointer-events-none class, which blocked click/tap
events from reaching EggGraphic's handleEggClick handler. Now the
class is only applied when the companion is NOT in egg stage, so
the egg vibration interaction works on /blobbi just like in the
blobbi page view.
2026-04-30 17:26:51 -03:00
Chad Curtis 5d99337cd2 Allow hyphens inside hashtags
Tags like #70-706 and #bitcoin-conference were split at the hyphen,
since the regex only matched letters, numbers, and underscores. Allow
internal hyphens (but not leading or trailing ones, so #nostr- still
captures just #nostr) and share the pattern across NoteContent,
BioContent, and the compose boxes so posted t-tags match what renders.
2026-04-30 11:22:07 -05:00
Chad Curtis e2ec2892ab Fix missing comments on non-URL /i/ pages
Country, book, and Bitcoin tx/address pages were querying with a stray
`#` prefix on the NIP-73 identifier, so the filter never matched any
real comments. Pass the raw identifier like PostDetailPage does.

Regression-of: 363e39d7
2026-04-30 11:17:59 -05:00
Alex Gleason af67e7f812 release: v2.12.0 2026-04-30 10:52:37 -05:00
Alex Gleason 9a34fa0102 Play bird-song recordings on Wikipedia species pages
Wikipedia/Wikimedia Commons hosts editorially-curated, mostly Xeno-
Canto-sourced recordings on bird species articles. Surface them on
Ditto's /i/ page whenever the article exposes one: an emerald play
button sits inline with the article title, looping the song on
click and swapping its play triangle for an animated equaliser to
indicate active playback. When a recording is present, the footer
row gains a second link crediting the recordist and license and
pointing at the Commons file-description page for verification.

Adapted from Birdstar's BirdInfoDialog / useBirdSound / useWikipedia
Sound hooks; the iNaturalist fallback from the original is dropped
per request — Ditto only uses Wikipedia/Commons.
2026-04-30 01:57:51 -05:00
Alex Gleason 7eb70f3a61 Prefer the n tag for scientific name on Bird Detection cards
Birdstar kind 2473 events now carry the species' scientific binomial
name in an authoritative 'n' tag, added to the NIP so clients can
label a detection without round-tripping Wikidata. Ditto was still
scraping the name out of the 'alt' tag's parenthetical, which loses
the label when the publisher emits a bare alt like "Bird detection".

Prefer the 'n' tag and fall back to 'alt' parsing only for older
events authored before the tag was part of the NIP. Also show the
scientific name as a persistent italic sub-label on Birdex tiles,
matching how detection cards stack the two labels.

Regression-of: b2634d2f
2026-04-30 01:52:01 -05:00
Alex Gleason b2634d2fcb Render Birdstar Birdex events (kind 12473) as tiled life lists
A Birdex is a replaceable per-author index of every bird species the
author has ever confirmed via kind 2473, stored as positional i/n tag
pairs in chronological first-seen order. In feeds, show a compact
tile strip of the most recently-added species with a "+N" capstone
when the list overflows — mirroring how kind 3 follow lists preview
members as an avatar stack plus "+N more". On the post-detail page,
render every species as a responsive grid so visitors can browse the
author's whole life list.

Each tile resolves the species' Wikidata entity through English
Wikipedia to pull a thumbnail and common-name label, reusing the
same fetch path as kind 2473 detection cards. The Wikidata URL is
sanitized before being routed, and the paired n tag provides a
scientific-name fallback while the remote lookup is in flight.
2026-04-30 01:44:15 -05:00
Alex Gleason 981e4f0726 Prefer metadata.name, fall back to metadata.display_name everywhere
Previously getDisplayName() and ~50 inline sites only consulted
metadata.name, ignoring display_name. A handful of other sites used the
opposite priority (display_name || name), so the same user could render
under different names across the UI.

Standardize on `name || display_name || genUserName(pubkey)` in the
helper and at every call site, and widen two local inline metadata types
in RightSidebar and SearchPage that did not declare display_name.
2026-04-30 01:30:07 -05:00
Alex Gleason 476e99ab81 Allow clicking the Bitcoin tab when the signer is unsupported
When the Bitcoin tab only had a dead-end error panel, ZapDialog auto-
switched to Lightning whenever capability was unsupported and the author
had a Lightning address. Now that the Bitcoin tab renders a QR fallback
the user can actually use, that forced redirect prevented them from
clicking into it: every render while activeTab === 'onchain' flipped it
back to 'lightning'.

Drop the mid-session auto-flip effect. The initial tab choice still
biases toward Lightning (via the useState initializer and the open-reset
effect), but manual navigation into Bitcoin is respected.

Regression-of: bf540fb5
2026-04-29 23:33:46 -05:00
filemon 9483fbc99a Merge branch 'main' into feat/blobbi-1124-interactions 2026-04-30 00:56:01 -03:00
Alex Gleason bf540fb5c1 Offer QR-code fallback for unsupported signers in onchain zap dialog
When the user's signer can't sign PSBTs (extension without signPsbt, or a
bunker that rejected sign_psbt), the onchain zap tab previously showed a
dead-end 'Bitcoin zaps aren't available' panel. Replace it with an
amount selector + BIP-21 QR code the user can scan from any external
Bitcoin wallet, plus copy buttons for the address and payment link.

Because Ditto never sees the resulting transaction, no kind 8333 is
published in this flow. A warning explains that the zap won't show up
as theirs on Nostr even though the recipient still gets the Bitcoin.

Also gate the mempool.space UTXO and fee-rate queries on the capability
being non-unsupported to avoid pointless network calls in this branch.
2026-04-29 22:39:27 -05:00
Alex Gleason 0cae729335 Merge branch 'wallet' into 'main'
Add Bitcoin wallet page deriving Taproot address from Nostr pubkey

See merge request soapbox-pub/ditto!215
2026-04-30 03:13:51 +00:00
Alex Gleason 3b48359aa7 Use true private field for secret key bytes in NSecSignerBtc
The previous `private readonly secretKeyBytes` was compile-time-only:
at runtime the field was a plain enumerable property, readable as
`user.signer.secretKeyBytes`. This regressed the runtime privacy
boundary that the parent `NSecSigner` deliberately enforces with its
ES `#private` field.

Switch to `#secretKeyBytes` so the bytes are unreachable via property
enumeration or reflection on the instance, matching the parent class's
protection.
2026-04-29 21:52:36 -05:00
Alex Gleason c5d5165f84 Merge remote-tracking branch 'origin/main' into wallet
# Conflicts:
#	package-lock.json
#	src/components/CommentContext.tsx
#	src/components/ExternalContentHeader.tsx
#	src/components/NoteCard.tsx
#	src/pages/VinesFeedPage.tsx
2026-04-29 21:44:07 -05:00
filemon 94f531cdd4 Merge branch 'main' into blobbi-drag-to-feed-clean 2026-04-29 15:03:02 -03:00
filemon 222f641123 Merge branch 'main' into feat/blobbi-1124-interactions 2026-04-29 14:58:01 -03:00
Chad Curtis 30b10fd435 Merge branch 'fix/blobbi-31124-progression-persistence' into 'main'
fix: pass prev to kind 31124 action publishes to prevent stale overwrites

Closes #270

See merge request soapbox-pub/ditto!214
2026-04-29 11:17:25 +00:00
Chad Curtis 19cc0d13c9 Merge branch 'add-evolve-animation' into 'main'
Add evolve ceremony animation and fix hatch reveal background color

Closes #267 and #266

See merge request soapbox-pub/ditto!210
2026-04-29 11:16:50 +00:00
Chad Curtis 8016ecb32d Merge branch 'fix/blobbonaut-profile-content-preservation' into 'main'
Fix daily bounty hydration and persistence

Closes #269

See merge request soapbox-pub/ditto!213
2026-04-29 11:09:32 +00:00
Chad Curtis 43b2ac91b6 Merge branch 'fix/blobbi-daily-missions' into 'main'
Fix daily bounty hydration and persistence

Closes #268

See merge request soapbox-pub/ditto!212
2026-04-29 11:08:48 +00:00
Chad Curtis 0078ba90cb Merge branch 'audit/blobbi-cleanup' into 'main'
Remove 11 dead Blobbi components (shop modals, old action dialogs/panels)

Closes #262

See merge request soapbox-pub/ditto!207
2026-04-29 11:03:09 +00:00
filemon dc168bc978 fix: pass prev to kind 31124 action publishes to prevent stale overwrites
Add prev to 5 KIND_BLOBBI_STATE publish paths that already fetch a fresh
base event but were not passing it to useNostrPublish. Without prev, the
replaceable-event published_at ordering is unguarded and concurrent
writes (debounce persist vs action hook vs sleep/care activity) can cause
a relay to accept an older-content event over a newer one, resetting
hatch/evolve mission progress.

Affected paths:
- useBlobbiDirectAction (canonical.companion.event)
- useBlobbiUseInventoryItem (canonical.companion.event)
- useBlobbiItemUse standalone (companion.event from relay fetch)
- useBlobbiSleepToggle (companion.event from relay fetch)
- useBlobbiCareActivity (freshCompanion.event from relay fetch)

No content serialization, tag update, or mission definition changes.
2026-04-29 00:53:51 -03:00
filemon 44c3888ac1 Remove duplicated fetchFreshProfile from useBlobbiMigration
Replace the hook-internal fetchFreshProfile useCallback with the shared
    fetchFreshBlobbonautProfile helper, which has identical semantics (query
    both kind ranges, prefer 11125 over 31125, sort by created_at desc) plus
    an explicit 10s timeout signal.

    This eliminates the only remaining duplication between the migration hook
    and the shared helper introduced in the previous commit.
2026-04-29 00:25:02 -03:00
filemon 7918ee3662 Harden kind 11125 writes to never drop profile content
Every mutation that updates an existing Blobbonaut profile now follows a
safe read-modify-write pattern: fetch freshest event from relays, build
tags from fresh data, preserve event.content, and pass prev.

Adds fetchFreshBlobbonautProfile() shared helper to avoid duplicating the
relay-fetch + parse + prefer-current-kind logic across write sites.

Fixed paths:
- useBlobbiMigration: migrateLegacyBlobbi and ensureCanonical now
  preserve content and pass prev
- useBlobbiOnboarding: rerollPreview and adoptPreview fetch fresh before
  mutating
- BlobbiHatchingCeremony: 'add egg to has[]' and 'mark onboarding done'
  fetch fresh when profile already existed

Paths already safe (unchanged): usePersistDailyProgress,
useClaimMissionReward, useBlobbiPurchaseItem,
useBlobbonautProfileNormalization, BlobbiPage auto-fix + companion set,
BlobbiWidget companion set.
2026-04-28 23:58:11 -03:00
filemon 98644047eb Fix daily bounty hydration and persistence
- Add usePersistDailyProgress hook: debounced persistence of daily mission
  progress to kind 11125 content, with flush on visibility-hidden and unmount
- Fix hydration race: deterministic merge strategy prefers persisted missions
  when they carry real progress; keeps local if persisted has zero progress
- Fix content preservation: all kind 11125 write paths now preserve existing
  content instead of publishing content: '' which wiped daily mission data
- Fix stale-read-then-write: useBlobbiPurchaseItem, profile normalization,
  and onboarding auto-fix now use fetchFreshEvent before publishing
- Add isLoading state to useDailyMissions for proper loading/empty exclusion
- Convert take_photo missions from event to tally tracking
- Extract trackInventoryDailyActions for DRY daily mission tracking
- Handle legacy EventMission → TallyMission conversion in trackTally
- Add dev-only Reset Daily Missions button in BlobbiDevEditor

The content: '' pattern originated in 251ea43e and several subsequent commits
that created kind 11125 write paths. It only became a data-loss bug when daily
mission persistence was introduced. No single regression-of commit is
identifiable — it is a systemic gap across multiple files.
2026-04-28 23:17:38 -03:00
Alex Gleason 423d53ea58 Send 'Discover people' link to the Global tab on /packs
When the home-feed Follows tab is empty, the 'Discover people to follow'
button links to /packs. That page is also a Follows/Global tabbed feed,
and it likewise defaulted to Follows — so a user who follows nobody
landed on another empty view.

Pre-seed the /packs feed tab to 'global' in sessionStorage before
navigating, so the link lands on a populated view.

Regression-of: 399df4da
2026-04-28 20:45:12 -05:00
Alex Gleason 460926fa99 release: v2.11.2 2026-04-28 14:47:03 -05:00
Alex Gleason cf2f466772 Restrict unknown-kind previews to the NIP-31 alt tag
The alt-tag fallback shipped in 9813a226 let several display paths fall
through to other tags (title, name, summary, description, d) or to the
raw content when alt was absent. For an unknown kind like attestr.xyz
(31871), that surfaced the opaque d-tag identifier
'e5272de9:289bce03a0b7:1777206698' as the preview and leaked raw content
into the hover-card reply indicator — both worse than the 'This event
kind is not supported' tombstone the feature was meant to produce.

Tighten the fallback everywhere an unknown kind might render:

- getEventFallbackText: only the NIP-31 alt tag; no title/name/d.
- CommentContext.getEventDisplayName: known kinds keep title/name/d,
  unknown kinds consider only alt. getKindLabel returns 'an unsupported
  event' instead of 'a post', so 'Commenting on ...' never implies the
  root is a text note.
- EmbeddedNote tagMeta: alt only, no title/name/description fallback.
- ExternalContentHeader.AddressableEventPreview: drop d-tag fallback.
- EmbeddedNaddr: gate rich title/description/content rendering behind
  a known-kind check; unknown kinds render UnknownKindContent instead
  of extractMetadata, which was leaking plaintext content as the
  description when the body wasn't JSON.

Regression-of: 9813a226
2026-04-28 14:44:48 -05:00
Alex Gleason 71fe5aaa3a release: v2.11.1 2026-04-28 14:10:21 -05:00
Alex Gleason 9813a226ec Render unknown event kinds with a NIP-31 alt-tag fallback
Previously, any kind not explicitly handled by NoteCard or
PostDetailContent fell through to the kind-1 text-note renderer, which
ran the URL/hashtag/nostr: tokenizer over arbitrary content — broken
for events whose content is JSON or empty.

Add an UnknownKindContent component that displays the NIP-31 'alt' tag
(falling back to title/name/summary/d) in a rounded card, or a dashed
'This event kind is not supported' tombstone when the event carries no
fallback text. Route to it from both dispatchers when the kind isn't
1, 11, or 1111.

Extend the same handling to embedded quote previews (EmbeddedNote,
EmbeddedNaddr, AddressableEventPreview) so reply-context hover cards,
compose previews, more-menu previews, notification references, and
inline nostr: mentions all display unknown kinds consistently instead
of feeding JSON or arbitrary content to the kind-1 tokenizer.
2026-04-28 14:05:51 -05:00
Patrick PReis 8eb31223a5 Merge remote-tracking branch 'origin/main' into add-evolve-animation 2026-04-28 16:01:06 -03:00
Alex Gleason 00412385c8 release: v2.11.0 2026-04-28 12:05:35 -05:00
Chad Curtis 5a79c7cbe0 Merge branch 'fix/carousel-persistence-behavior' into 'main'
Persist Blobbi room and carousel focus in localStorage

Closes #263

See merge request soapbox-pub/ditto!208
2026-04-28 15:23:54 +00:00
Alex Gleason fcfcb381a8 Add detail-page header and a Discuss button for bird detections
Detail page: render the kind 2473 / 30621 action header ("Alex Gleason
heard a bird", "… drew a constellation") alongside the existing
per-kind headers, using the same phrasing as KIND_HEADER_MAP.

Bird-detection card: add a Discuss link next to the Wikipedia link,
routing to /i/<wikidata-url> so comments on the species' NIP-73
identifier aggregate across detections.

Regression-of: c957041c
2026-04-28 03:23:48 -05:00
Alex Gleason 3f3d99e25a Swap constellation edge count for a Birdstar deep-link
Surface a "View on Birdstar" external action that opens the constellation
on birdstar.app via its naddr1, which is more useful than the edge count
— users can actually interact with the figure there.

Regression-of: c957041c
2026-04-28 03:14:23 -05:00
Alex Gleason c957041cf3 Render Birdstar bird detections and custom constellations
Adds feed support for kind 2473 (bird-by-ear detections) and kind 30621
(user-drawn star figures) from Birdstar. Detections render as species
cards using the existing Wikidata + Wikipedia summary hooks; constellations
render as gnomonically-projected SVG star-maps backed by the Hipparcos
catalog from d3-celestial. The 1.1 MB catalog is code-split via lazy() so
it only loads when a constellation event is actually viewed.
2026-04-28 03:12:24 -05:00
filemon 6684efd146 Add Blobbi social interactions (kind 1124)
Introduce external social interactions for Blobbi companions. Other users
can feed, play, clean, and medicate a Blobbi they don't own via kind 1124
events. The owner's canonical 31124 state absorbs these interactions
through a checkpoint-based consolidation system with auto-sync on page load.

Key additions:
- Kind 1124 event schema with validation, parsing, and deterministic sort
- Social projection pipeline (read-only stat overlay from pending interactions)
- Owner-side consolidation into canonical state with checkpoint advancement
- Auto-sync hook (useCanonicalSync) triggered when owner opens /blobbi
- Social permission toggle (open/closed tag on 31124)
- Interaction UI: popover with item carousel on feed cards, detail pages,
  and the owner dashboard action bar
- Interaction reactions: facial expressions, sparkles, bubbles, floating
  hearts with phase-based animation system
- Activity tab showing interaction history with caretaker attribution
- BlobbiSocialActions component with egg gating and cooldown logic
- NIP.md documentation for the new kind
2026-04-27 23:02:19 -03:00
Patrick PReis b75d8dc16b Start evolve ceremony fade-out 2 seconds earlier 2026-04-27 22:16:04 -03:00
Patrick PReis 4a4e6e4398 Address self-review: deduplicate shared code and fix a11y
- Extract useTypewriter hook to shared module (was duplicated in both
  BlobbiHatchingCeremony and BlobbiEvolveCeremony)
- Extract hexToRgb, blendToWhite, buildRevealGradient to shared
  ceremony-colors module (was duplicated across both ceremonies)
- Remove unused updateCompanionEvent prop and NostrEvent import from
  BlobbiEvolveCeremony
- Add prefers-reduced-motion override for evolve ceremony animations
  via data-evolve-ceremony attribute selector
2026-04-27 22:08:48 -03:00
Patrick PReis 9054decb16 Use typewriter effect for evolve ceremony text
Replicate the letter-by-letter reveal from the hatching ceremony. The
first line types out immediately when the adult appears, then the second
line starts typing once the first completes. Both lines show a blinking
cursor while typing.
2026-04-27 22:02:51 -03:00
Patrick PReis 3f8d6a6c56 Show evolve text during reveal with a 1.5s fade-in delay
The text was only appearing in a separate dialog phase at the very end,
barely readable before fadeout. Now it fades in 1.5s into the reveal
phase so users have plenty of time to read it alongside the adult.
2026-04-27 21:57:11 -03:00
Patrick PReis 3708730c7d Extend adult reveal by 2 more seconds and remove redundant evolve toast
The ceremony dialog already tells the user their blobbi evolved, so the
toast was duplicating the message.
2026-04-27 21:44:39 -03:00
Patrick PReis 40c3e1d025 Extend adult reveal phase by 2 seconds 2026-04-27 21:40:13 -03:00
Patrick PReis 6242940985 Fix evolve ceremony playing twice
The onComplete callback was an inline arrow in BlobbiPage, so every
parent re-render (triggered by the evolve mutation updating companion
data) created a new reference and restarted the entire useEffect timer
chain. Stabilize both onComplete and onEvolve behind refs so the
timeline runs exactly once regardless of parent re-renders.
2026-04-27 21:10:37 -03:00
Patrick PReis 88fd6a74d8 Make evolve reveal near-instantaneous
Background 0.8s -> 0.15s, adult blobbi 0.5s -> 0.15s, glow 0.8s -> 0.2s,
shine 0.5s -> 0.15s, halo 0.8s -> 0.2s. Flash gap 0.8s -> 0.4s.
The adult and its backdrop now pop in almost immediately after the flash.
2026-04-27 20:56:12 -03:00
Patrick PReis fe800401ad Halve evolve reveal transition durations
Background transition 2s -> 0.8s, adult blobbi fade-in 1s -> 0.5s,
center shine 1s -> 0.5s, halo 2s -> 0.8s, rotating glow 2.5s -> 0.8s.
The adult now appears almost immediately after the flash clears.
2026-04-27 19:10:46 -03:00
Patrick PReis 2c0e32a039 Tighten evolve ceremony flash-to-reveal timing
The gap between baby vanishing and adult appearing was ~1.8s (1.4s flash
+ 400ms delay). Now the reveal starts 0.8s after the flash and the adult
fades in immediately, cutting the dead time to under a second.
2026-04-27 19:05:08 -03:00
Patrick PReis f9f9a8b0d2 Remove redundant surge phase from evolve ceremony
Simplify to two visual transitions matching the hatch ceremony pattern:
gather (baby + particles) -> flash -> reveal (adult + sparkles) -> dialog.
2026-04-27 18:34:23 -03:00
Alex Gleason 480e0aa97f Render follow lists with avatar stacks in profile recovery
The Follows tab of the profile recovery dialog previously showed only
a generic Users icon, a follow count, and a timestamp — making every
historical snapshot look identical and giving users no way to
distinguish one version from another. Now each snapshot renders an
avatar stack of the followed profiles, which makes differences between
snapshots visible at a glance.

Changes along the way:

- Extract the avatar-stack block from PeopleListContent into a shared
  PeopleAvatarStack component (sm/md/lg sizes, hover-to-pop-forward
  with display-name tooltip, "+N more" overflow).
- Reverse `p` tags in kind 3 displays so the newest follows surface
  first. Kind 3 grows by appending, so the natural order is
  oldest-first — the same early follows would otherwise dominate every
  preview. Implemented as a getDisplayPubkeys(event, pubkeys) helper
  used at display sites only; mutations and filters keep the original
  array.
- Compact the snapshot cards in ProfileRecoveryDialog by moving the
  Restore button into the top-right slot that already hosted the
  Current badge, and dropping the redundant "<icon> N follows" title
  row from the Follows card since the avatars communicate the same
  thing more clearly.
2026-04-27 15:58:07 -05:00
Patrick PReis e66ab53562 Add evolve ceremony animation and fix hatch reveal background color
The hatching ceremony reveal phase used a hardcoded blue background
regardless of the blobbi's color. Now the background derives from the
baby's baseColor, blended toward white for a soft pastel, with a
vignette shadow overlay so the blobbi pops against same-hue backgrounds.

The baby-to-adult evolution transition previously had no animation at
all. A new BlobbiEvolveCeremony component provides an immersive
full-screen experience: energy particles spiral inward, the baby glows
and scales up, a flash fires the evolve mutation, and the adult form
is revealed with color-matched sparkles and radiant glow.
2026-04-27 17:02:23 -03:00
Alex Gleason da0bffdac2 Surface people lists in feed settings as 'People Lists'
The feed toggle at Settings > Content > Social was labeled 'Follow Packs'
with kind badge [39089], hiding the fact that it also controls kind 3
follow lists and kind 30000 people sets (both bundled via extraFeedKinds).

Rename the label to 'People Lists', expand the description to name all
three variants, and rename the storage keys:

  feedIncludePacks  -> feedIncludePeopleLists
  showPacks         -> showPeopleLists

No migration. AppProvider shallow-merges stored feedSettings on top of
defaultConfig, so existing users (whose blob has the old keys but not
the new ones) fall through to the default 'true' on the new keys and
get people lists re-enabled. The old keys linger as dead fields in
localStorage and encrypted relay settings until the next write.

The sidebar, /packs route, and AddToListDialog still say 'Follow Packs'
where that narrower term is correct; only the feed-inclusion toggle,
whose scope covers all three kinds, was renamed.
2026-04-27 14:19:26 -05:00
Alex Gleason 7afbfb4307 Filter deprecated events from profile feed
useFeed already runs shouldHideFeedEvent to drop deprecated kind 30000
follow sets (reserved d-tags, empty lists), unlisted decks, hidden
treasures, and empty emoji packs. useProfileFeed didn't, so those events
reached NoteCard just to be filtered out there, costing wasted mounts
and diverging the two code paths.

rawCount still uses pre-filter counts so pagination-exhaustion detection
matches useFeed.
2026-04-27 14:19:17 -05:00
Alex Gleason ee5d3415ac Drop hardcoded feedSettings block from onboarding
The questionnaire that used to drive this block was simplified away in
e88f9e5f, but the save handler kept writing a full hardcoded feedSettings
object. That clobbered App.tsx's defaultConfig with a narrower preset and,
on the phase === 'not-found' path, could overwrite a returning user's
tuned settings if the encrypted-settings fetch returned empty. It also
drifted every time a new setting was added to App.tsx.

Trust defaultConfig for fresh users and encrypted-settings sync for
returning users. The follow-list check and step routing (outro vs follows)
are what the callback is actually for.
2026-04-27 14:19:11 -05:00
Alex Gleason e9def50a85 Render webxdc embeds as a tilted, color-tinted cartridge
Replace the plain launch card with a Game Boy-style cartridge (using
public/cartridge.png) whose label region centers the app icon. The whole
cartridge scales as one image and reacts to the pointer with the
existing useCardTilt 3D effect.

The cartridge is tinted by the icon's dominant color: a new
useDominantColor hook samples the icon in an off-screen canvas, picks
the heaviest hue bucket, and exposes it as HSL. A mask-image layer
masked to the cartridge silhouette blends the color over the grayscale
PNG with mix-blend-mode: color, preserving the shading. Grayscale or
CORS-blocked icons fall back to the original gray cartridge.

The app name moves out from under the cartridge and into the description
card in FileMetadataContent — rendered larger and bolder above the
note's content — while WebxdcEmbed still renders its own name card when
a parent isn't providing one (e.g. the kind 1 imeta path).
2026-04-27 01:15:54 -05:00
Alex Gleason e883309791 Remove direct messaging feature and skill
Deletes the DM implementation (DMProvider, DMContext, useDMContext,
useConversationMessages, DMChatArea, DMConversationList,
DMMessagingInterface, DMStatusInfo, dmMessageStore, dmUtils,
dmConstants, the orphaned pages/Messages.tsx, and the
nostr-direct-messages skill) and removes the corresponding wrapper
from the provider tree in App.tsx.

The feature was already disabled (dmConfig.enabled = false), so this
removes no user-visible functionality -- only ~1,600 lines in
DMProvider and the associated UI/context/hooks. The nip44/nip04 signer
paths used by drafts, letters, mute lists, and encrypted settings are
unrelated and remain. Kind 1222 voice messages are a public-feed
feature and stay.

Documentation cleanup: strip the three DM mentions from AGENTS.md
(Project Structure, App.tsx provider list, Specialized Workflows skill
pointer) and the Private Messaging bullet from README.md's feature
list. Historical CHANGELOG entries are preserved.
2026-04-26 23:21:54 -05:00
Alex Gleason bd68a32708 Split AGENTS.md into skills; compress to 358 lines
Extract eleven topic areas into loadable skills so AGENTS.md can serve
as a scannable overview instead of a specification dump. The file
shrinks from 1480 to 358 lines (~76%) while keeping every concrete
rule, critical code pattern, and pointer that an agent needs on first
read.

New Ditto-specific skills:
- nostr-kinds: NIP-vs-custom-kind decision framework, kind ranges,
  tag design, content-vs-tags, NIP.md update rule, and Ditto's
  seven-location UI registration checklist for new kinds (NoteCard,
  PostDetailPage, extraKinds.ts, KIND_LABELS/KIND_ICONS in
  CommentContext, WELL_KNOWN_KIND_LABELS in ExternalContentHeader,
  EmbeddedNote/EmbeddedNaddr, ReplyComposeModal).
- nostr-publishing: useNostrPublish, the read-modify-write pattern
  via fetchFreshEvent + prev for replaceable/addressable events,
  published_at contract, and d-tag collision prevention.
- nostr-queries: the standard useNostr + useQuery pattern,
  combining kinds into one filter to avoid rate limits, and the
  NIP-52 validator walkthrough.
- theming: @fontsource install flow, the Ditto runtime font-loader
  path (sanitizeUrl + sanitizeCssString), color scheme variables,
  useTheme toggle, and the isolate + negative-z-index gotcha.
- ci-cd-publishing: Zapstore NIP-46 bunker auth (zsp +
  nip46-auth.mjs), nsite deploys (nsyte nbunksec + configured
  relays/servers), and Google Play AAB uploads via fastlane supply
  (service-account JSON base64 encoding and rotation).
- capacitor-compat: WKWebView/WebView limitations, the
  downloadTextFile / openUrl helpers in src/lib/downloadFile.ts,
  platform detection, and the full plugin list.
- git-workflow: pre-commit validation order and the Regression-of:
  trailer convention used by the release skill's changelog
  generator.

Ported from mkstack, lightly adapted where needed:
- nip19-routing: root-level /:nip19 routing and filter construction
  patterns (adapted to reference Ditto's existing NIP19Page).
- nostr-relay-pools: nostr.relay() and nostr.group() for targeted
  queries.
- nostr-encryption: NIP-44 / NIP-04 via the user's signer.
- file-uploads: useUploadFile + Blossom + NIP-94 imeta tag
  construction.

AGENTS.md itself now follows mkstack's density — concrete rules inline,
one code example per section, pointer to the matching skill for details.
The enumerations that previously bloated it (every shadcn primitive,
every hook, every Capacitor plugin, the full NostrMetadata type dump,
the NIP-19 prefix reference table, etc.) are either removed in favor
of "ls the directory" or moved into their skill.
2026-04-26 23:13:30 -05:00
Alex Gleason 7675d010c2 Port nostr-security, testing, and nip85-stats skills from mkstack
Adds three new skills extracted from mkstack's restructured AGENTS.md
and trims the corresponding AGENTS.md sections to match.

- nostr-security: XSS threat model, URL and CSS sanitization patterns,
  author filtering for trust-sensitive queries, NIP-72 moderation
  walkthrough, and a pre-merge checklist. The skill's references to
  sanitizeUrl and sanitizeCssString are pointed at Ditto's existing
  helpers in src/lib/sanitizeUrl.ts and src/lib/fontLoader.ts.
- testing: Vitest + TestApp conventions, mocked browser APIs, and the
  project policy on when (not) to create new test files.
- nip85-stats: reference documentation for NIP-85 Trusted Assertion
  stats (kinds 30382, 30383, 30384) including a ready-to-copy
  useNip85Stats hook for future use; not currently wired into Ditto.

AGENTS.md changes:
- Shrink the Nostr Security Model section from a verbose kinds-and-URLs
  walkthrough into a compact rule list plus a spoof-vs-authors example,
  with a pointer to the new skill.
- Trim the Writing Tests section to the policy + skill pointer, moving
  the TestApp example and browser-API mocks into the skill.
- Demote Loading States / Empty States from a top-level section to a
  subsection under CRITICAL Design Standards so the document's
  top-level headings describe domains, not presentation details.

Net: AGENTS.md 1654 -> 1480 lines (~10%).
2026-04-26 23:04:06 -05:00
filemon a2f088f86a Memoize onFocusChange handlers in HomeBar and KitchenBar
Replace inline arrow functions with useCallback, matching the pattern
already used in CareBar. This keeps the onFocusChange prop identity
stable across renders so ItemCarousel's internal prev/next callbacks
are not needlessly recreated.
2026-04-26 03:15:50 -03:00
filemon de9a7b0c39 Fix persistence edge cases in room/carousel state
- ItemCarousel: add effect to realign index when initialItemId changes
  after mount (e.g. Blobbi switch triggers useLocalStorage key change)
- CareBar: add effect to sync focusedMeta when storedFocusId changes,
  so contextual side buttons match the restored carousel item
- CareBar: remove eslint-disable on handleFocusChange by adding
  setStoredFocusId to the dependency array
- Use logged-in user pubkey (useCurrentUser) instead of
  companion.event.pubkey for all localStorage keys, making them
  explicitly user-scoped
2026-04-26 03:09:32 -03:00
filemon c25d772bca Persist Blobbi room and carousel focus in localStorage
Remember the last user-selected room per Blobbi, and the last focused
carousel item per room. Both survive page refresh and room switching.

Room persistence:
- storedRoom written only on user-driven navigation (not sleep override)
- Sleeping temporarily forces 'rest'; waking returns to the stored room
- Falls back to profile.room tag then DEFAULT_INITIAL_ROOM

Carousel persistence:
- New initialItemId prop on ItemCarousel seeds the focused index on mount
- Each bar (Home, Kitchen, Care) stores its focused item id in localStorage
- Stale ids safely fall back to the first item (existing clamp logic)
- CareBar also initialises focusedMeta from the stored item so side
  buttons render correctly on mount
2026-04-26 02:59:42 -03:00
filemon 75f1b14551 Merge branch 'main' into audit/blobbi-cleanup 2026-04-26 01:06:24 -03:00
Alex Gleason 9d914a430c Add touch support to Magic card 3D tilt
Wire up pointerdown/up and the glare overlay so press-and-drag drives
the tilt on touch, matching the BadgeDetailContent behaviour. A quick
tap still opens the lightbox via the inner button. touch-action: pan-y
keeps vertical page scrolling working.
2026-04-25 21:09:06 -05:00
Alex Gleason aa8541298e Render Gatherer card URLs as Magic cards via Scryfall
gatherer.wizards.com URLs (e.g. /BNG/en-us/156/xenagos-god-of-revels or
the legacy ?multiverseid=...) are now resolved through the Scryfall API
and rendered as actual Magic: The Gathering cards throughout the app:

- /i/<gatherer-url>: GathererCardHeader shows the card art at 280px max
  width with properly rounded corners, a mouse-driven 3D tilt + specular
  glare matching the badge showcase, a click-to-open lightbox, and a
  face toggle for DFC/MDFC/split cards.
- Page <title> on /i/<gatherer-url> uses the real card name.
- 'Commenting on …' breadcrumbs under kind 1111 comments show the card
  name with the CardsIcon and a hover-card preview.
- Parent context on PostDetailPage (e.g. /nevent1… for a comment rooted
  on a Gatherer URL) shows a compact preview row matching the ISBN and
  country patterns: small card art, 'Magic Card' label, card name, set.

Scryfall integration is centralised in src/lib/scryfall.ts (image URLs
and typed JSON fetching) and src/hooks/useScryfallCard.ts. MagicDeckContent
has been refactored to use the shared image helper. All four call sites
share a single TanStack Query cache keyed on the lookup, so one card
triggers one network request.
2026-04-25 20:46:52 -05:00
Alex Gleason 4fdbb4d960 Render Wikidata URLs as their Wikipedia article on /i/
Adds special handling for Wikidata entity URLs (https://www.wikidata.org/entity/ID
and https://www.wikidata.org/wiki/ID) on the /i/ external content page.

When a Wikidata URL is used, the entity's enwiki sitelink is resolved via the
Wikidata Action API and the page renders the same rich Wikipedia embed that
would appear for the Wikipedia URL directly. Falls back to a generic link
preview when the entity has no English Wikipedia article.
2026-04-25 19:34:33 -05:00
filemon cb48434f96 Remove 11 dead Blobbi components (shop modals, old action dialogs/panels)
Delete unused components that were superseded by inline UI in BlobbiPage:
- shop: BlobbiInventoryModal, BlobbiShopModal, BlobbiPurchaseDialog, BlobbiShopItemRow
- actions: SingModal, BlobbiMissionsModal, TasksPanel, DailyMissionsPanel,
  HatchTasksPanel, StartIncubationDialog, StartEvolutionDialog

Clean up related barrel exports in actions/index.ts, stale JSDoc
in ItemEffectDisplay.tsx, a stale comment in BlobbiPage.tsx, and
an unnecessary export on findGuideItemForStat.

No behavior, UX, balance, mutation, decay, or query changes.
2026-04-25 20:35:35 -03:00
Chad Curtis f4f8e49627 Merge branch 'feat/update-blobbi-decay' into 'main'
Add pure segment-display helper for stage-based Blobbi status UI

Closes #255

See merge request soapbox-pub/ditto!204
2026-04-25 22:29:04 +00:00
filemon 2602182bb7 Share overfeed poop check between tap-feed and drag-feed paths
Extract maybeOverfeedPoop() helper so both handleFeedItem (tap/fridge)
and handleFeedFromDrag (drag-to-feed) run the same overfeed threshold
and random poop roll.  Previously drag-to-feed bypassed the check
entirely, meaning overfeed poop could never trigger via drag.

Also fix orphaned JSDoc in generators.ts: move CHEW_CYCLE_SEC and its
docblock above the generateChewingMouth function so each symbol's
JSDoc attaches to the correct declaration.
2026-04-25 19:07:55 -03:00
filemon ca39448605 Key CrumbBurst by feed sequence to restart animations on rapid re-feed
Without a key, React recycles the CrumbBurst instance when a second
feed fires within the 1200ms crumb window.  This prevents CSS
animations from restarting and the reward word from re-randomizing.

Using feedSeqRef.current as the key forces a full re-mount on each
new feed sequence.
2026-04-25 18:57:58 -03:00
filemon 841d10c39c Add cute feeding sound feedback 2026-04-25 18:42:21 -03:00
filemon f12e2a72da Use shared variant-aware action-mouth geometry for eating and chewing
The eating mouth used hardcoded rx=6/ry=7 in absolute SVG units, which
looked correct for babies (100x100 viewBox) but was proportionally too
small for adults (200x200 viewBox) and absurdly tiny for Froggi (110px
mouth width). The chewing mouth used controlY directly as the center,
which overshoots downward on deep smiles (Froggi: 12.5px too low).

New shared helper computeActionMouthGeometry(mouth) derives:
  - cy from 55% of the baseline→controlY curve (visual midpoint)
  - rx from clamp(halfWidth * 0.18, 4, 9) (proportional, not explosive)
  - eating/chewing ry values from rx (consistent transition)

Both generateEatingMouth and generateChewingMouth now use this helper,
so the eating→chewing transition preserves the same anchor and size.
Both emit data-blobbi-mouth for DOM-based crumb positioning.

The eating recipe switches from roundMouth to the new eatingMouth flag.

Crumb spawning in BlobbiPage is now wrapped in requestAnimationFrame so
the DOM query sees the committed chewing mouth rather than the previous
eating/neutral mouth (React 18 batches setState synchronously).

Regression-of: 6903712b
2026-04-25 17:53:22 -03:00
filemon dec3d04ca5 Anchor crumb burst to actual chewing mouth position via data-blobbi-mouth
The crumb origin was hardcoded at 67% of the visual wrapper height,
which misaligned with variants whose mouths sit higher (leafy 50%,
rosey 53%) or lower (froggi 72.5%, mushie 76.5%).

generateChewingMouth now:
- Emits data-blobbi-mouth="1" on the ellipse for DOM querying
- Scales rx from the detected mouth width (clamped 4-14) so wide-
  mouthed variants like Froggi get a proportional chomp

BlobbiPage crumb spawning now:
- Queries [data-blobbi-mouth] inside [data-blobbi-visual]
- Uses its bounding rect center for both crumbX and crumbY
- Falls back to the old 67% ratio when the marker is absent (Owli)
- Reward text remains anchored via the visual rect (unchanged)
2026-04-25 17:53:17 -03:00
filemon ca581e37c2 Make crumb burst spawn from mouth-shaped strip instead of radial confetti
Crumbs now originate from per-particle sx/sy offsets across a compact
16px-wide mouth strip with slight vertical curve, then tumble mostly
downward with reduced lateral spread. Particle sizes shrunk to 2-4px
for a crumb feel. CSS keyframe no longer teleports crumbs sideways at
frame 0 — they start at their spawn point and drift to (dx, dy).

Also expanded REWARD_WORDS from 4 to 10 cute variations.
2026-04-25 17:53:13 -03:00
filemon 8353f125ff Tighten crumb particle spread to mouth size
Reduce dx/dy values across all three rings so the burst stays close to
the chewing mouth instead of spraying across the face.  Particle count,
colors, delays, sizes, and all other feed-reward logic are unchanged.
2026-04-25 17:53:07 -03:00
filemon dd00cbff24 Enhance drag-to-feed visual reward with longer chewing, richer crumbs, and floating text
Extend the chewing phase (700ms → 1200ms) and crumb burst so the feed
reward feels more satisfying.  Crumbs go from 6 to 12 particles in warm
amber/orange/yellow tones with wider spread and a longer CSS animation
(600ms → 1100ms).  A random floating word (yum!/nom!/mmm!) drifts up
from the mouth during the burst.

All durations are now named constants (CHEW_DURATION_MS, CRUMB_DURATION_MS,
HAPPY_DURATION_MS) for easy tuning.

Both crumb and reward-pop animations respect prefers-reduced-motion.
Mutation, Nostr publishing, XP, decays, streaks remain untouched.
2026-04-25 17:53:02 -03:00
filemon c98b738290 Add drag-to-feed with chewing animation for Kitchen room
Drag-to-feed:
- Press a food item in the Kitchen carousel to start dragging.
- A ghost emoji follows the pointer via direct DOM mutation (no React
  re-renders during pointermove).
- Near Blobbi's mouth the ghost scales down and Blobbi opens its mouth.
- Drop near the mouth to feed; drop anywhere else to cancel.
- The drag lifecycle is owned by global window listeners (pointermove,
  pointerup, pointercancel, blur), not by the carousel button.  This
  eliminates ghost-stuck and ghost-reappear bugs caused by React
  re-renders swapping button-level handlers mid-capture.
- Every listener checks pointerId + monotonic session counter.
- Cleanup is idempotent and runs on feed/miss/cancel/blur/unmount.

Chewing animation (Phase 2 polish):
- On successful feed, Blobbi shows a 700ms chewing animation (SMIL
  mouth oscillation) with a crumb particle burst at the mouth.
- The item-use mutation fires immediately on drop — no delay.
- After the chewing phase, if the mutation succeeded, Blobbi shows a
  happy expression for 1500ms before returning to status-based state.
- If the mutation fails, chewing clears without showing happy.
- feedSeqRef + mountedRef prevent stale timers/promises from writing
  state after a newer sequence starts or the component unmounts.
- actionCleanupRef shared between tap-to-use and drag-to-feed paths
  prevents a stale tap-to-use timer from clobbering drag-feed emotion.

Supporting changes:
- New 'chewing' emotion with generateChewingMouth() SMIL generator.
- replaceCurrentMouth regex handles animated <ellipse> elements.
- BlobbiRoomHero wrapped in React.memo to prevent SVG animation
  restarts from unrelated parent re-renders.
- useStatusReaction memoizes the override recipe so the same
  actionOverride produces a stable object reference.
- @keyframes crumb-fall added to index.css with reduced-motion support.
- CrumbBurst component renders 6 CSS-animated particles at the mouth.
- ItemCarousel.centerPointerHandlers reduced to { onPointerDown }.
- DEBUG_FOOD_DRAG flag for development tracing.
2026-04-25 17:51:49 -03:00
Alex Gleason f2a8cd75b9 Add related_applications to web manifest
Declare the Google Play and App Store listings in the PWA manifest so
browsers can surface the native apps where appropriate. Set
prefer_related_applications to false so the PWA install path remains
the default.
2026-04-25 12:14:18 -05:00
Chad Curtis 0ba6bacaf5 release: v2.10.5 2026-04-25 03:30:27 -05:00
Chad Curtis 3f02fb83f9 Merge branch 'fix-shovel' into 'main'
Rework Blobbi Shovel: drag-to-clean

Closes #257, #256, #258, and #259

See merge request soapbox-pub/ditto!205
2026-04-25 08:18:52 +00:00
Patrick PReis cd2afb8300 Clean up shovel rework: fix touch class, remove dead state, dedupe toast, cap poops
- Fix touch-action-none → touch-none (correct Tailwind class)
- Remove vestigial shovelMode/setShovelMode from PoopState
- Remove duplicate 'Nothing to clean!' toast from useShovelDrag
- Cap generateInitialPoops at MAX_POOPS when overfeed poop present
2026-04-24 23:07:18 -03:00
Patrick PReis 9120cff708 Fix misleading module doc comment in RoomPoopLayer
PoopOverlay renders all poops in every room, not filtered by
poop.room. The old comment described a room-aware filtering
strategy that was removed in an earlier commit.
2026-04-24 22:42:45 -03:00
Patrick PReis 482dca78ec Raise overfeed poop chance to 40% 2026-04-24 21:13:49 -03:00
Patrick PReis 10fc3bf0a7 Apply 20% overfeed chance to reactive feeding, not just page load
The probability check was only in generateInitialPoops (mount time).
The live feeding path in KitchenBar always spawned a poop. Now both
paths use the shared OVERFEED_CHANCE constant.
2026-04-24 21:12:12 -03:00
Patrick PReis d3462f42dc Lower overfeed poop chance from 60% to 20% 2026-04-24 20:50:27 -03:00
Patrick PReis 357d108c7e Show poops in every room, not just the room they spawned in
PoopOverlay (passive) now renders all poops regardless of poop.room.
The room field still controls where the shovel can clean them
(InteractivePoopOverlay filters by roomId).
2026-04-24 20:36:04 -03:00
Patrick PReis 755f3b9fb0 Make poop overlays room-aware and restore max 3 poop cap
The overlays now filter by poop.room so the data model is respected
end-to-end: poop-system assigns a room, overlays render only poops
matching their room. Currently all poops spawn in kitchen so the
behavior is identical, but enabling multi-room spawning later only
requires changing the room assignment in poop-system.ts — no UI
changes needed.

- PoopOverlay and InteractivePoopOverlay take a roomId prop and
  filter via getPoopsInRoom()
- Restore MAX_POOPS to 3 (was raised to 6 in previous commit)
- Fix indentation in generateInitialPoops
2026-04-24 20:30:56 -03:00
Patrick PReis 7aaf9f1cad Poops spawn in kitchen only, visible in all rooms, shovel only in kitchen
- All poops spawn with room='kitchen' (reverts random room assignment)
- PassivePoopOverlay renders poop emojis in Home, Care, and Rest rooms
  as static display-only elements (no interaction)
- KitchenPoopOverlay renders interactive poops with drag hit-test refs
- Shovel button remains exclusively in the kitchen bar
- useShovelDrag simplified: no longer takes roomId parameter
- Remove room-aware toast ('Try another room'); simple 'Nothing to
  clean!' message when tapping shovel with no poop
2026-04-24 20:26:04 -03:00
Patrick PReis 9be0c22b03 Render poops in all rooms and tune poop generation
- Poops now spawn in random rooms (care, kitchen, home, rest) and each
  room renders them with a draggable shovel via shared PoopOverlay +
  ShovelButton components
- Extract drag-to-clean logic into useShovelDrag hook (reused by all
  room bars, eliminating ~100 lines of duplicated code from KitchenBar)
- Overfeed poop is now 60% probability instead of deterministic
- Time-based poop cap raised from 3 to MAX_POOPS (6), so longer
  absences produce more poops proportionally
- Shovel replaces the left action button in each room when poop is
  present (e.g. replaces Photo in Home, Towel in Care); reverts
  when poop is cleaned
- Room-aware toast: 'Try another room' when poop exists elsewhere
2026-04-24 20:16:23 -03:00
Chad Curtis a55233fdb1 Fix compose textarea losing its expanded height when toggling preview
The auto-resize effect only depended on content, so switching back
from preview mode remounted the textarea without recalculating its
height. Adding previewMode to the dependency array ensures the
textarea is resized immediately after remounting.
2026-04-24 17:36:03 -05:00
Chad Curtis 50e9aee290 Expand dir="auto" RTL support to articles, compose, and letter views
Add dir="auto" to text content elements across articles (title,
summary, prose), all compose inputs (ComposeBox, ArticleEditor,
MilkdownEditor, PhotoComposeModal, ComposeLetterSheet, LetterEditor),
and letter view components (LetterDetailSheet, LetterCard body/closing/
signature).
2026-04-24 17:36:03 -05:00
Patrick PReis 97aacd96aa Rework blobbi shovel into drag-to-clean and fix poop system bugs
- Replace toggle-mode shovel with drag-and-drop: drag the shovel icon
  onto a poop to clean it (works on both desktop mouse and mobile touch)
- Shovel button is now always visible in the kitchen (no more disappearing)
- Always show 'Shovel' label (removed 'Done' toggle text)
- Show toast when tapping shovel with no poop present
- Fix invisible poops: all poops now spawn in kitchen only (previously
  time-based poops randomly spawned in rooms that never rendered them)
- Spawn poop immediately when feeding a blobbi with hunger >= 95
- Add addPoop() to PoopState for reactive poop creation
- Convert RoomActionButton to forwardRef with touch/mouse event props
- Cap total poops at 4 to prevent accumulation
2026-04-24 19:29:21 -03:00
filemon 30adbdc947 Merge branch 'main' into feat/update-blobbi-decay 2026-04-24 18:41:07 -03:00
filemon 6b52926da1 Remove dead sleeping fields from decay config objects
BABY_DECAY.energy and ADULT_DECAY.energy were objects with an unused
sleeping field (6.0 / 5.0) that was overridden by the standalone
BABY_SLEEP_ENERGY_REGEN (40.0) and ADULT_SLEEP_ENERGY_REGEN (35.0)
constants. Flatten energy to a plain number to avoid misleading readers.

No behavior change — the ternaries already used the standalone constants.

Regression-of: 4d4d8a43
2026-04-24 17:34:35 -03:00
filemon e14c727568 Remove deprecated status prop from shared StatIndicator
All callers now pass careState. The old status prop, its default value,
and the three ternary fallback branches were dead code.

Regression-of: 08be5e99
2026-04-24 17:11:58 -03:00
filemon 7d69f48bf6 Remove dead _CARE_THRESHOLD constant
The old threshold was replaced by the segment-model care badge in
08be5e99 but kept with a void suppression. The new companionNeedsCare
function is self-documenting; the old value adds no reference value.

Regression-of: 08be5e99
2026-04-24 16:47:25 -03:00
filemon 1d9cd2cd3f Use projected stats for Kitchen fridge segment previews
The fridge overlay was computing segment deltas from companion.stats
(persisted), while the stat rings show projected stats with decay
applied. This caused the preview to disagree with the visible UI.

Thread currentStats (from useProjectedBlobbiState) through
RoomBottomBarProps so KitchenBar uses the same decay-projected values
the user sees in the stat rings.
2026-04-24 16:33:59 -03:00
filemon 8ab7be43dd Remove dead item-action modal flow
The rooms-based UI (KitchenBar, CareBar, HomeBar) fully replaced the old
BlobbiActionInventoryModal / BlobbiActionsModal flow. The inventoryAction
state was never set to a non-null value, so the modal could never open.

Deleted:
- BlobbiActionInventoryModal.tsx (311 lines)
- BlobbiActionsModal.tsx (201 lines)

Removed from blobbi-action-utils.ts:
- previewStatChanges (superseded by previewStatChangesWithSegments)
- previewMedicineForEgg, previewCleanForEgg, EggStatPreview
- filterInventoryByAction, FilterInventoryOptions, ResolvedInventoryItem

Removed from BlobbiPage.tsx:
- inventoryAction state, handleUseItem (modal version),
  handleOpenShopFromAction, modal JSX block

Kept live:
- previewStatChangesWithSegments (used by KitchenBar fridge overlay)
- usingItemId / setUsingItemId (used by room loading indicators)
- All stage/action restriction helpers (used by live item-use hooks)
2026-04-24 16:23:00 -03:00
filemon 0c6479f17e Show segment impact in item/action previews
Add previewStatChangesWithSegments() helper that computes before/after
segment counts using getBlobbiStatDisplayState, so preview badges can
communicate bar changes alongside raw stat deltas.

UI updates:
- Action inventory modal: shows (+1 bar) after the raw delta
- Kitchen fridge overlay: shows compact +N▮ segment indicator
- Egg stage omits segment info (always protected/full)

Includes 13 tests covering baby, adult, egg, and edge cases.
2026-04-24 15:56:20 -03:00
filemon 4eaec7fead Rebalance item effects for segmented status model
Tune item stat values so basic items restore roughly 1 baby segment
(+25), medium items provide meaningful upgrades (+35-50), and premium
items have strong multi-stat effects (+70-75).

Food: Apple +15→+25 hunger, Burger +40→+45, Pizza +35→+40,
  Sushi +30→+35, Cake +20→+25.
Toys: Teddy +40→+45 happiness and -15→-5 energy (premium = low cost).
Medicine: Bandage +15→+25, Vitamins +20→+25 health and add +5 energy,
  Elixir +80→+75 health.
Hygiene: Soap +30→+25, Bubble Bath +60→+70 hygiene and +20→+25
  happiness.
Direct actions unchanged (play_music +15, sing +20).

Adds 20 focused tests covering item catalog values, applyItemEffects
clamping, tier relationships, and direct action effects.

No changes to decay rates, sleep behaviour, careState mapping,
segmented UI, item previews, level unlocks, or Nostr persistence.
2026-04-24 15:41:40 -03:00
filemon 4d4d8a43e0 Tune awake decay rates for segment-aligned status model
Baby (4 segments):
- hunger -8/hr, happiness -4.5/hr, hygiene -6/hr, energy -9/hr
- health base -0.4/hr (was -0.75)
- health penalty thresholds aligned to segment boundaries:
  mild at < 50 (attention), strong at < 25 (urgent)
  — was < 70/40, which fired penalties in the 'okay' range
- health regen threshold lowered to 76 (baby good = 4/4 starts at 76)
  — was 80

Adult (10 segments):
- hunger -5/hr, happiness -2.5/hr, hygiene -4/hr, energy -5.5/hr
- health base -0.25/hr (was -0.4)
- penalty thresholds unchanged (already align with 10-segment model)
- regen threshold unchanged at 80

Pacing:
- Baby first 'okay' stat at ~2.7hr, first 'attention' at ~5-6hr
- Adult first 'okay' stat at ~5-6hr, first 'attention' at ~7-8hr
- Growing up feels like increased resilience, not more annoyance

Sleep behaviour, item values, careState mapping, segmented UI
rendering, and Nostr persistence are unchanged. Tests updated to
cover new rates, penalty alignment, and regen threshold.
2026-04-24 13:58:58 -03:00
filemon 63143db9db Render segmented stat rings, fix gap alignment, and make sleep restorative
Part 1 — Segmented rings:
Replace the continuous strokeDasharray progress ring with discrete arc
segments driven by the segment display model. Baby/egg shows 4 segments,
adult shows 10. StatIndicator exports a reusable SegmentedRing component.
When the segments prop is absent, the old continuous ring renders as a
backward-compatible fallback.

Part 2 — Ring gap fix:
Switch from strokeLinecap round to butt so gaps are not consumed by cap
extensions. Increase gapDeg to 16/20 (md/sm), bump muted opacity to 0.12.
Offset the start angle by half a gap so the first gap straddles 12-o'clock,
making the ring visually centred and symmetrical.

Part 3 — Sleep modifiers:
Sleeping is now restorative instead of punitive:
- Energy regen: +40/hr (baby), +35/hr (adult) — up from +6/+5
- Hunger/happiness/hygiene: decay at 20% of awake rates
- Health base decay: pauses (0) while sleeping
- Health penalties: reduced to 25% of awake strength

Awake decay rates, health penalty thresholds, item/action values,
hibernating behaviour, and Nostr persistence are unchanged.

Adds 17 focused unit tests covering baby/adult sleeping, awake decay
baseline, and hibernating-is-not-sleeping.
2026-04-24 13:26:11 -03:00
filemon 08be5e9985 Wire segment display model into stat indicators and care badge
Replace scattered warning/critical status checks with careState from
getBlobbiStatDisplayState in the three UI consumers:

- StatIndicator (shared): new careState prop takes precedence over
  deprecated status prop; badge shown for attention/urgent, pulse for
  urgent only.
- BlobbiRoomHero (inline indicator): same careState-driven logic.
- BlobbiWidget: passes careState instead of getStatStatus result.
- BlobbiPage companion selector: care badge now shows when any stat is
  urgent OR two-plus stats are attention (was: any stat < 40). Eggs are
  always protected so they never trigger the badge.

Old threshold constants and getStatStatus are kept — no deletions.
No changes to decay rates, sleep, items, status-reactions,
needDetection, SVG ring rendering, or Nostr persistence.
2026-04-24 13:03:09 -03:00
Chad Curtis 8405d42902 Merge branch 'feat/status-guide' into 'main'
Add status guided UX with glow indicators and Guide me flow

Closes #254

See merge request soapbox-pub/ditto!203
2026-04-24 16:02:40 +00:00
filemon 03dcc37083 Add pure segment-display helper for stage-based Blobbi status UI
Introduce getBlobbiStatDisplayState() — a read-only helper that derives
UI segment counts, care states, and badge/pulse flags from internal
1–100 stats without changing any gameplay behaviour.

Egg is always 'protected' with full segments. Baby maps to 4 segments
(urgent/attention/okay/good). Adult maps to 10 segments with wider
threshold bands. Values are clamped to STAT_MIN–STAT_MAX.

Includes 38 unit tests covering all boundary values, clamping, and
flag correctness.
2026-04-24 12:54:34 -03:00
Chad Curtis 6b9aeddb06 Clean up review findings from video/vine/pagination commits
- Remove orphaned JSDoc and dead isShort code path in VideoContent
- Unify vine mute state into shared vineGlobalMute module so mute
  preference carries between NoteCard feeds and VinesFeedPage
- Fix fetchNextPage race cascade by using a ref guard instead of
  putting isFetchingNextPage in the useCallback dep array
- Batch event ingestion in useStreamPosts so the event map is sorted
  and flushed to state once per page instead of per-event
- Don't permanently kill pagination on transient network errors
- Move sort modifiers into searchParts before the join for consistency
- Wrap full VideoGridCard/ShortThumb content in ContentWarningGuard
  so metadata doesn't leak when policy is blur
2026-04-24 10:11:32 -05:00
Chad Curtis 23e845ebc1 Render kind 22 shorts as vines in feeds with volume control
Kind 22 (Short-form Portrait Video) was rendering through VideoContent
with a shrunken max-width and a redundant "Short" badge, duplicating
the vine experience poorly. Now kind 22 shares the VineMedia component
with kind 34236, rendering full-width with play/pause and mute toggle.

- Unified isVine to match both kind 22 and 34236
- Removed isShortVideo flag and the small/badged VideoContent path
- Added mute/unmute button to VineMedia with shared module-level state
- Mute preference persists across shorts as you scroll through a feed
- Removed dead parseImeta function (replaced by parseVideoImeta)
2026-04-24 10:02:49 -05:00
Chad Curtis 5a80df05f5 Add infinite scroll pagination to search page
useStreamPosts previously fetched a single batch of 40 events with no
way to load more. Refactored to track the oldest event timestamp and
expose fetchNextPage/hasNextPage/isFetchingNextPage for cursor-based
pagination using the same NIP-50 search filter with an `until` param.

SearchPage now renders an IntersectionObserver sentinel below the post
list that triggers loading the next page when scrolled into view.
2026-04-24 09:51:50 -05:00
Chad Curtis cc3a5b3415 Fix vines always autoplay regardless of autoplayVideos setting
The autoplayVideos config controls inline video players in normal
feeds. Vines use snap-scroll where the active slide should always
autoplay — gating on this setting made vines not play at all when
the user had autoplay disabled.
2026-04-24 09:44:33 -05:00
filemon 9a48d039db Fix guide not resuming room arrow when leaving target room
The guide step effect only handled the forward transition (room →
item/action on entering the target room). Add the reverse: when the
step is item or action but the user has navigated away, revert to
the room step so the directional arrow resumes blinking.
2026-04-24 11:28:23 -03:00
Chad Curtis fdacb2029a Fix content warnings not applying to video and vine feed pages
VideosFeedPage and VinesFeedPage rendered their own card components
(VideoGridCard, ShortThumb, VineCard) without any content warning
checks, bypassing the ContentWarningGuard used elsewhere. Videos
with NIP-36 content-warning tags displayed without blur or filtering.

- Wrap VideoGridCard and ShortThumb thumbnails in ContentWarningGuard
- Add full-screen dark CW overlay to VineCard matching vine aesthetic
- Filter out CW events when contentWarningPolicy is "hide" in both pages
- Prevent video autoplay while CW overlay is shown in VineCard
2026-04-24 09:25:25 -05:00
filemon 1eb126bdf8 Make all stat icons trigger the guided-care flow
Previously only low-status (warning/critical) stat icons were clickable
and the guide auto-cleared when the stat recovered to normal. Now every
stat icon fires onGuide on tap regardless of status, and the guide is
only dismissed by completion events (item used, sleep started, or a
different stat clicked).

Changes:
- StatsCrown: remove the status-gate from onClick and cursor-pointer
- StatsCrown: add z-10 so stat icons layer above the Blobbi visual
- Blobbi animation wrapper: pointer-events-none so it cannot intercept
  taps on overlapping stat icons
- BlobbiDashboard: remove the effect that cleared the guide when the
  stat was normal; drop unused getStatStatus import
- Update comments and prop docs to reflect the new behaviour
2026-04-24 11:16:10 -03:00
filemon ca260497cc Merge branch 'main' into feat/status-guide 2026-04-24 10:24:54 -03:00
Chad Curtis 846c4f794a Merge branch 'audit/blobbi-adult-color-application-and-generation' into 'main'
Make Blobbi visual identity seed-driven and sync mirror tags

See merge request soapbox-pub/ditto!200
2026-04-24 04:04:48 +00:00
Chad Curtis 3a9f41892f Harden seed derivation: extract readSeedUint32, guard edge cases
- Extract readSeedUint32() for raw 32-bit reads instead of misusing
  deriveIndexFromSeed with max=0x100000000 (which was a no-op modulus).
  deriveIndexFromSeed is now only used for bounded array indexing.
- Guard deriveAdultFormFromSeed against NaN from short/invalid seeds.
- Guard adjustSeedForAdultType against indexOf returning -1 for
  unknown form values.
2026-04-23 22:54:12 -05:00
Chad Curtis b6b5a46f4f Merge branch 'feat/blobbi-click-overstimulation-reaction' into 'main'
Add Blobbi overstimulation reaction for repeated clicks

Closes #236

See merge request soapbox-pub/ditto!190
2026-04-24 03:47:04 +00:00
Chad Curtis 8b0eb97abb Fix redundant layout reflows and sanitize SVG color interpolation
Cache getBoundingClientRect() result in OverstimulationBlockOverlay
to avoid four forced reflows on the same element per activation.

Add sanitizeSvgColor() guard in generateAngerRiseEffect() so that
config.color is validated before interpolation into SVG stop-color
attributes, preventing injection if a future caller passes untrusted
color strings.
2026-04-23 22:39:27 -05:00
Chad Curtis 5463206d84 Fix mobile nav bar rendering during overstimulation zoom
Scroll to top before applying the zoom transform and restore the
saved scroll position after zoom-out completes. The transform on
#root creates a new containing block that breaks sticky positioning
of the mobile top/bottom nav bars when the user is scrolled down.
2026-04-23 22:31:32 -05:00
Chad Curtis ed637bc9df Compact reaction system: inline drain logic, trim verbosity
Delete useReactionDrain (120 lines) — the abstraction added more
complexity than it saved. Each hook now inlines a ~15-line rAF drain.

Rewrite all major files for density:
- useOverstimulationReaction: 375 → 175 lines
- useShakeReaction: 407 → 188 lines (was 205 with shared hook)
- shakeDetection: 222 → 93 lines
- OverstimulationBlockOverlay: 216 → 112 lines

Total branch diff: 1297 → 900 insertions.
2026-04-23 22:27:49 -05:00
Chad Curtis f465cb7347 Extract shared hooks to deduplicate reaction and SVG renderer code
- useFillLevelUpdate: shared recipe fingerprint + imperative gradient
  stop updates, extracted from BlobbiAdultSvgRenderer and
  BlobbiBabySvgRenderer (removed ~60 duplicated lines from each)

- useReactionDrain: shared rAF-based level drain loop with throttled
  React state push, extracted from useOverstimulationReaction and
  useShakeReaction (removed ~170 duplicated lines from each)

Net reduction: ~285 lines across the 4 consumer files.
2026-04-23 22:21:54 -05:00
Chad Curtis 49a5461fbe Fix zoom origin: query companion DOM element directly via data attribute
The previous approach put a ref on the pointer-events-auto wrapper div,
which is a full-width block element -- getBoundingClientRect returned
the page width, not Blobbi's position. Now we query the companion's
actual fixed-position container via [data-blobbi-companion] to get
the true visual bounding rect.
2026-04-23 22:14:06 -05:00
Chad Curtis 75f6283d9b Lower zoom origin to bottom edge of Blobbi's bounding box 2026-04-23 22:09:38 -05:00
Chad Curtis a144193cb4 Lower zoom origin to 80% of Blobbi's height 2026-04-23 22:09:18 -05:00
Chad Curtis 2ec57ad027 Lower zoom origin to 65% of Blobbi's height 2026-04-23 22:08:56 -05:00
Chad Curtis ff412bbb29 Crank overstimulation zoom to 5x for screen-hogging close-up 2026-04-23 22:07:43 -05:00
Chad Curtis 12d299a7ec Replace crumble effect with UI zoom + radial shockwave on overstimulation
When Blobbi hits max overstimulation, the entire UI now zooms toward
Blobbi's face (transform on #root) while a radial shockwave expands
from the companion and a red vignette dims the screen edges. On
recovery the zoom eases back out and the vignette fades.

The overlay is portaled onto document.body so it stays at viewport
scale while #root is scaled. Body overflow is hidden during the zoom
to prevent scrollbar flash.

Removes the canvas-based crumble/debris system (crumbleEngine.ts) in
favor of this simpler CSS-driven approach.
2026-04-23 22:05:30 -05:00
Chad Curtis 8fe0751a67 Add overstimulation visual feedback: shockwave, UI crumble, and debris
Replace the invisible click shield with a dramatic visual sequence when
Blobbi reaches max overstimulation: a radial shockwave expands from
Blobbi, a dark backdrop covers the UI (Blobbi stands alone in the void),
and canvas debris particles rain down like rubble. On recovery the
backdrop fades and debris converges back.

Also fix the debug bypass in useShakeReaction (true || threshold) that
shipped nausea on every shake regardless of hunger stat, and remove the
toast notification replaced by the visual overlay.
2026-04-23 21:55:28 -05:00
Chad Curtis 1b940b262c Merge branch 'main' of gitlab.com:soapbox-pub/ditto into feat/blobbi-click-overstimulation-reaction 2026-04-23 21:21:44 -05:00
Chad Curtis 6ea1d0da2b Merge branch 'fix/blobbi-deterministic-legacy-migration' into 'main'
Make legacy Blobbi migration deterministic

Closes #248

See merge request soapbox-pub/ditto!199
2026-04-24 02:18:58 +00:00
Chad Curtis 4dd487e0b2 Merge branch 'feat/improve-blobbi-companion-transitions' into 'main'
Add generic route-transition reaction for Blobbi companion

Closes #225

See merge request soapbox-pub/ditto!182
2026-04-24 02:09:45 +00:00
Chad Curtis 82f97aa1e2 Merge branch 'feat/blobbi-eyes-feed' into 'main'
Enable Blobbi eye tracking on feed cards (feed-only, with touch support)

Closes #249

See merge request soapbox-pub/ditto!201
2026-04-24 01:56:35 +00:00
Chad Curtis 1be0b3f101 Enable Blobbi eye tracking on detail page
Pass lookMode="follow-pointer" to BlobbiStateCard in PostDetailPage,
matching the feed card behavior so Blobbi eyes follow the cursor on the
detail view as well.
2026-04-23 20:53:35 -05:00
filemon 1afafb7abd Simplify guided-care UX: click low-status icon to start guide directly
Remove the intermediate popover + "Guide me" button step. Tapping a
low-status stat icon now calls onGuide immediately, starting the room
navigation or item/action highlight with zero friction.

Deletes the StatIndicatorWithHelp component (~120 lines) and its
associated imports (Popover, STAT_HELP_TEXT, Navigation, React state/
ref/callback/effect hooks). All stat icons now render through the
same StatIndicator; low-status icons get cursor-pointer and onClick
on their positioning wrapper.
2026-04-23 22:27:03 -03:00
filemon afce15d2d4 Add low-status guided UX with glow indicators and Guide me flow
Low-stat indicators now glow/pulse at warning and critical levels across
    BlobbiPage and widgets via a shared StatIndicator. On BlobbiPage, hovering
    (desktop) or tapping (mobile) a low stat shows contextual help with a
    Guide me button that visually leads the user to the correct room and
    item/action through a sequential glow chain: stat popover → room nav
    arrow → carousel arrow → target item/action.

    Implementation details:
    - Synchronized low-status icon animations via a shared CSS @property
      clock on the StatsCrown parent, so all icons pulse in phase regardless
      of mount timing
    - Split warning (stat-glow, 2s) and critical (stat-glow-critical, 2s)
      into separate keyframes with distinct visual weight
    - Guide glow uses a quick-blink-and-pause rhythm (1.1s cycle) distinct
      from the status pulse
    - Stable popover hover zone shared between trigger and content with
      paired open/close timers; onOpenAutoFocus prevented to avoid flicker
    - Guide cleanup on item use, action execution, new guide start, or stat
      recovery
    - ItemCarousel index only clamped when actually out of bounds, preventing
      guide visual instability from unrelated re-renders
    - Energy modeled as a first-class action target (sleep) rather than a
      special case
    - companionNeedsCare() uses calculateProjectedDecay() instead of raw
      persisted stats, consistent with all other low-status UI
2026-04-23 22:21:52 -03:00
Chad Curtis 360a8c88e3 Merge branch 'fix-crysti' into 'main'
Fix crysti blobbi: add missing sparkle animations and fix pink facet path

Closes #252

See merge request soapbox-pub/ditto!202
2026-04-23 22:21:02 +00:00
Patrick PReis feca8bc357 Revert unintended package-lock.json changes
The test script ran npm install which modified package-lock.json with
version bumps and dev flag additions unrelated to the crysti fixes.
2026-04-23 17:58:43 -03:00
Chad Curtis 5080970366 release: v2.10.4 2026-04-23 15:35:54 -05:00
Chad Curtis be4a741a73 Add RTL support for Arabic, Hebrew, and other RTL languages
Add dir="auto" to NoteContent, BioContent, and DM message bubbles
so the browser's Unicode Bidirectional Algorithm automatically detects
text direction from the first strong directional character.
2026-04-23 15:29:52 -05:00
Patrick PReis 589fb8ebba Fix crysti facet5 degenerate path (bottom-right purple section)
The path started and ended at the same point (100,105), making it a
triangle instead of a quadrilateral. The bottom-right hexagon vertex
(140,130) was missing entirely. Changed to trace center → right-mid →
bottom-right vertex → inner bottom-right point.

Fixed in all four locations: both .svg source files and both inlined
constants in adult-svg-data.ts.
2026-04-23 17:27:48 -03:00
Chad Curtis 0156a82629 Fix overflow menu rendering inside compose modal
The Poll/Spoiler popover opened side="bottom" from the toolbar, which
is near the bottom edge of the dialog.  Because the dialog container
uses overflow-hidden (needed for flex layout containment) and the
PortalContainerProvider portals content inside the dialog DOM, the
popover was clipped.

Switch to side="top" so the menu opens upward into the visible area
of the modal.
2026-04-23 15:25:44 -05:00
Patrick PReis 8497d87238 Sync crysti fixes into inlined SVG data constants
The app renders Blobbi SVGs from inlined string constants in
adult-svg-data.ts, not from the .svg source files. The previous commits
only fixed the source files. This syncs all three fixes into the
inlined CRYSTI_BASE and CRYSTI_SLEEPING constants:

- Add animateTransform groups to CRYSTI_BASE sparkle circles
- Fix self-intersecting pink facet path in both constants
- Match sleeping facet opacities to base values
2026-04-23 17:00:50 -03:00
Patrick PReis 787e0f6902 Match crysti sleeping facet opacities to base variant
The sleeping SVG had all six facet opacities reduced by 0.2 compared to
the base, making it look washed out. Only the eyes/mouth should differ
between states — the body colors should stay vibrant, consistent with
how bloomi handles its sleeping variant.
2026-04-23 15:27:39 -03:00
Patrick PReis 6ac7bdf826 Fix crysti blobbi: add missing sparkle animations and fix pink facet path
The base SVG sparkle circles were missing animateTransform tags, causing
them to render static instead of orbiting like the sleeping variant.
Wrapped them in two animated groups matching crysti-sleeping.

The top-left pink facet (crystiFacet2) had a self-intersecting path
(bowtie shape) that caused inconsistent fill rendering. Reordered the
vertices to trace a proper convex quadrilateral in both base and sleeping
SVGs.
2026-04-23 15:02:40 -03:00
Alex Gleason cbfd4a1f60 Require two-tap confirmation for Bitcoin sends over $100
When the total debit (amount + network fee) crosses $100 USD, flip the
primary action into a confirmation affordance instead of silently
sending. Normal sub-threshold amounts are unchanged — still one tap.

OnchainZapContent: first tap arms a destructive-variant button,
second tap actually sends. Editing the amount or fee speed re-arms.

SendBitcoinDialog ConfirmView: adds a neutral informational note
("Sending $X — double-check the recipient and amount.") and flips
the Confirm & Send button to the destructive variant, so the second
click carries visible weight without extra friction.

Threshold and helper (`isLargeAmount`) live in lib/bitcoin.ts with
6 new tests covering the boundary, price-unavailable, and negative
input cases.
2026-04-22 17:14:03 -05:00
filemon 2a2ebd6a46 Enable Blobbi eye tracking on feed cards (feed-only, with touch support)
BlobbiStateCard gains an optional lookMode prop (default: 'forward'),
threaded through to BlobbiStageVisual. Only the NoteCard feed call site
passes 'follow-pointer'; post detail, embedded notes, and embedded naddr
keep the default forward gaze.

The global pointer listener in useBlobbiEyes now also tracks touchstart
and touchmove so the effect works on mobile.
2026-04-22 18:58:36 -03:00
Alex Gleason ef7af83e5d Remove fee-percentage warning from Bitcoin zap flow
The amber "Network fee is ~N% of your zap" message was alarmist and
added no useful signal — the user already sees the exact fee in the
Fee line above and the final total when they submit.

Regression-of: bddfe4b8
2026-04-22 16:49:24 -05:00
Alex Gleason b5b7424472 Harden Bitcoin zap implementation from code review
- Add 13 regression tests for Taproot address derivation, pubkey
  validation, npub→address, and mainnet address validation
- Validate pubkey hex format (/^[0-9a-fA-F]{64}$/) in
  nostrPubkeyToBitcoinAddress to fail fast on malformed input
- Match tapInternalKey against the signer's x-only pubkey in
  signPsbtLocal, per the BITCOIN-SIGNING.md spec ("inputs whose
  tapInternalKey does not match the signer's key MUST be left
  unchanged"). Throw if no owned inputs are found.
- Use >= DUST_LIMIT (not >) for change-output dust check, so a change
  of exactly 546 sats is preserved rather than donated to fees
- Extract formatBTC() helper into lib/bitcoin.ts; remove duplicated
  replace(/\.?0+$/, '') from WalletPage, SendBitcoinDialog, and
  BitcoinContentHeader
- Register kind 8333 ("Bitcoin zap") in CommentContext KIND_LABELS,
  CommentContext KIND_ICONS, NoteCard KIND_HEADER_MAP,
  signerWithNudge KIND_LABELS, and shellTitleForKind
- Disambiguate sign_psbt errors in NConnectSignerBtc: only re-wrap as
  "doesn't support sending Bitcoin" when the error message looks like
  a capability failure (unknown method, not implemented, etc.);
  propagate transient errors unchanged
- Show the recipient's derived Bitcoin address in OnchainZapContent
  so users can verify the destination before signing
- Clear knownUnsupportedBunkers on logout so a fresh login with an
  upgraded bunker isn't tainted by a previous session's rejection
- Reject self-zaps in verifyOnchainZap (sender == recipient)
- Update NIP.md to specify: change-output handling, amount-cap vs
  discard semantics, self-zap rejection, mempool/confirmation policy,
  and mainnet-only scope
- Delete unused useNsecAccess hook
2026-04-22 16:39:21 -05:00
Alex Gleason 3805bf39a5 Replace technical jargon in Bitcoin-signing error copy
User-facing strings about signer capability referenced 'signPsbt',
'sign_psbt', 'PSBT', 'nsec', 'NIP-07', and 'NIP-46' — implementation
details a normal user shouldn't have to parse. Each site now says
'your browser extension doesn't support sending Bitcoin' (or the
bunker / generic variants), and points users at the 'secret key'
login option by its friendly name.

Changed sites:
  * OnchainZapContent — unsupported-capability panel
  * useOnchainZap — pre-send capability error
  * bitcoin-signers.ts — NBrowserSignerBtc and NConnectSignerBtc
    error strings (these surface as toasts in SendBitcoinDialog)
  * SendBitcoinDialog — 'Signing Not Available' panel and the
    in-mutation guard error

isSignerCapabilityError still matches the new copy (they all contain
"doesn't support"), so the capability-detection flow that flips the
UI from 'unknown' to 'unsupported' continues to work.

Regression-of: 008f3979
2026-04-22 16:06:00 -05:00
Alex Gleason 008f3979e1 Detect Bitcoin signer capability before submitting a zap
Previously, when a user's signer couldn't sign PSBTs, the Bitcoin zap
flow only discovered this after the user pressed Zap — surfacing a
toast after an otherwise-normal submission. The zap button was offered
as if it would work, and failure felt like a bug rather than a
capability limit.

Now useBitcoinSigner returns a three-state `capability`:
  * supported   — nsec login, or extension with window.nostr.signPsbt
                  present.
  * unsupported — extension without signPsbt, OR a bunker that has
                  already rejected sign_psbt once in this session.
  * unknown     — bunker login with no capability info yet (NIP-46
                  has no capability-discovery RPC). Attempt is allowed
                  and if it fails with a 'does not support' error, the
                  hook calls reportSignerUnsupported(pubkey) to flip
                  the capability to 'unsupported' for the rest of the
                  session. A DOM event broadcasts the change so
                  consumer hooks re-render without a shared store.

OnchainZapContent renders an explicit 'Bitcoin zaps aren't available'
panel whenever capability === 'unsupported', with copy tailored to the
login type (different hints for nsec/extension/bunker). Inside
useOnchainZap, capability errors no longer show the generic failure
toast — the UI replacement is the only feedback the user sees.

ZapDialog defaults to the Lightning tab when Bitcoin is unsupported
and Lightning is available, and auto-switches mid-session if a bunker
rejects sign_psbt while the dialog is open — so the user is never
stranded on an unusable tab.
2026-04-22 15:57:00 -05:00
Alex Gleason 01980918bc Simplify Bitcoin zap flow to match Lightning's simplicity
The Bitcoin zap dialog was heavier than the Lightning one, with a Review
step, a confirm screen, a balance card, a dropdown for transaction
speed, and a success view — all before you could actually send a zap.
The Lightning flow is presets → optional comment → Zap. Now Bitcoin is
the same shape.

Changes:
  * Drop the form→confirm→success wizard. Single screen, single button.
    The 'Zap' button does the whole thing; success closes the dialog
    via the existing onSuccess callback (the hook already shows a toast).
  * Remove the always-visible balance card. Balance only appears when
    the amount exceeds available funds (or funds are zero).
  * Collapse transaction speed into a one-line fee readout like
    'Fee ≈ $0.12 · ~30 min' that opens a popover of the 4 speed options
    when clicked. No dropdown taking up vertical space by default.
  * Drop the 'Paying to <address>' card, the 'transactions are final'
    warning, and the dedicated confirm screen — all redundant for a
    small zap flow.
  * Button label now reads 'Zap $5 · 5,123 sats' so users see both the
    fiat amount they chose and the sats they're committing, without
    needing a separate confirm screen to see either.

Fee-dominated warning becomes a single line of amber text instead of a
destructive alert; errors become a single line of destructive text.
Result is roughly half the vertical space and one click instead of three
to send a zap.
2026-04-22 15:43:00 -05:00
Alex Gleason ca63c21080 Rename 'on-chain' to 'Bitcoin' in UI copy
Every user-facing string that previously said 'on-chain' or 'onchain'
now says 'Bitcoin'. Lightning stays as 'Lightning'. Code identifiers
(hook names, types, query keys, component names, tab state values) are
unchanged — only text the user actually reads.

Changes:
  Tab label 'On-chain' → 'Bitcoin'
  Dialog subtitle 'Send Bitcoin on-chain' → 'Send Bitcoin'
  Balance label 'Your on-chain balance' → 'Your Bitcoin balance'
  Confirm warning 'On-chain transactions…' → 'Bitcoin transactions…'
  Success 'sent on-chain' → 'sent via Bitcoin'
  Toasts 'On-chain zap sent/failed' → 'Bitcoin zap sent/failed'
  Error 'on-chain wallet has no funds' → 'Bitcoin wallet has no funds'
  PSBT error 'send on-chain zaps' → 'send Bitcoin zaps'
  NIP-31 alt 'On-chain zap' → 'Bitcoin zap'
  Secondary Lightning subtitle → 'Send a Lightning payment…'
2026-04-22 15:38:14 -05:00
Alex Gleason 0d637a55b1 Rename onchain zap kind 3043 → 8333
8333 is the Bitcoin mainnet P2P port, creating a clean semantic parallel
with NIP-57: kind 9735 (Lightning's P2P port) for Lightning zaps, kind
8333 for on-chain zaps. 3043 was the first free kind the generator
returned and carried no meaning.
2026-04-22 15:23:17 -05:00
Alex Gleason bddfe4b838 Add on-chain Bitcoin zaps as the default zap method
Introduce kind 3043, a new Nostr event that attests an on-chain Bitcoin
payment against a target event or profile. Because every Nostr pubkey
deterministically maps to a Taproot address, any user can receive an
on-chain zap without configuring lud06/lud16 — the zap button now
appears on every post whose author is not the current user.

Publishing flow: sender builds and broadcasts a Bitcoin transaction
paying the recipient's derived Taproot address, then publishes a
kind 3043 event with an `i` tag (`bitcoin:tx:<txid>`), the recipient's
`p`, the target's `e` / `a`, and a self-reported `amount` in sats.
Before displaying or counting a kind 3043 event clients verify the
referenced transaction on-chain and use the sum of outputs paying the
recipient's address as the authoritative amount, capping the sender's
claim at the verified value to prevent spoofing.

Lightning zaps remain available as an opt-in tab inside the zap dialog
whenever the author has a Lightning address configured; otherwise the
dialog is purely on-chain. Defaults favour on-chain: USD amount presets
($1 / $5 / $10 / $25 / $100), fee-speed selection, and a 3-step
form → confirm → success flow mirroring SendBitcoinDialog.
2026-04-22 15:17:09 -05:00
Alex Gleason 664a555fbd Merge remote-tracking branch 'origin/main' into wallet
# Conflicts:
#	package-lock.json
#	package.json
2026-04-22 14:57:05 -05:00
filemon 4d00ba9542 Fix duplicate egg creation when adopting another Blobbi
The module-level setupInFlightFor guard had a race condition: the
effect cleanup unconditionally deleted the pubkey from the guard set
even when setup() was already mid-flight. If a parent re-render caused
the component to unmount/remount during the async publish window, the
new instance passed all guards and created a second egg.

Fix: track whether setup() has started in a ref. Cleanup only releases
the guard when the timer was cancelled before setup began; otherwise
setup() releases it in its own finally block.

Also stabilize the eggOnly completion timer by reading onComplete
through a ref, preventing the 1500ms timer from resetting on every
parent re-render that creates a new inline callback reference.
2026-04-22 13:14:42 -03:00
filemon ea99fdf288 Clean up self-review findings: deduplicate hexToHsl, add abort handling, fix comments
- Remove hexToHslLocal from blobbi.ts; reuse shared hexToHsl from
  color-guardrails.ts (eliminates duplicate implementation)
- Add abort flag to useSeedIdentitySync useEffect so the async sync
  loop stops on unmount and never calls updateCompanionEvent after
  teardown
- Replace relative date wording in compat cutoff comment with
  absolute date only
- Fix stale STEP numbering in BlobbiPage.tsx (5/6 → 4/5)
2026-04-22 10:31:31 -03:00
filemon 56650efe74 Fix self-review findings: unify adult derivation, remove dead code, harden sync
- Unify adult-form derivation: replace charCode hash in
  deriveAdultFormFromSeed with the canonical seed-slice algorithm
  (offset [40..48]), and remove the duplicate deriveAdultTypeFromSeed
  from blobbi.ts (all call sites now use the single canonical function)
- Guard unconditional console.log in parseBlobbiEvent behind
  import.meta.env.DEV so it no longer spams production consoles
- Remove dead deriveColorsFromSeed (zero callers, was deprecated on
  arrival) and its stale JSDoc reference in adjustSeedForAdultType
- Replace brute-force loop in adjustSeedForAdultType with a direct
  O(1) computation: since the derivation is parseInt(slice, 16) % len,
  the target index itself is always a valid candidate
- Add fetchFreshEvent to useSeedIdentitySync before each publish,
  matching the project convention for replaceable event mutations and
  preventing stale-cache overwrites on multi-device usage
2026-04-21 23:47:12 -03:00
filemon ef64668fac Add Pandi color customizer: tinted-white body, dark-tinted patches
Pandi now applies baseColor and secondaryColor instead of staying
hardcoded black and white.

Light areas (body, head): a very soft tinted-white derived from
baseColor's hue at L=95 S=min(baseSat,30). Clearly not pure white,
but stays close — preserves the hue family without going full-
strength. Stroke uses the same hue at L=90 S=20.

Dark areas (ear patches, eye patches, inner ears, arms, legs, nose,
mouth): derived from secondaryColor's hue forced to L=20 S=30
(primary dark) and L=27 S=20 (lighter dark for gradients and inner
fills). Maintains proper panda light-vs-dark contrast.

Eye color: unchanged — still applied via pandiPupil3D gradient
replacement in the existing applyPupilGradient path.

No other adult form customizers were modified.
2026-04-21 23:30:42 -03:00
filemon ce4550cae5 Update dev editor to adjust seed when changing adult form
The adult form dropdown now works with the seed-truth model: selecting
a different form calls adjustSeedForAdultType() and writes the adjusted
seed through the normal update path. syncMirrorTagsToSeed then derives
all mirror tags (adult_type, colors, pattern, etc.) from the new seed.

Previously the dropdown wrote adult_type as a raw tag that was
immediately overwritten by the seed-derived value — effectively a no-op.

Also adds a read-only seed display and a note explaining that changing
the form re-derives the visual identity.
2026-04-21 23:19:08 -03:00
filemon d951aab997 Derive adultType from effectiveSeed, rename sync terminology
BlobbiCompanion.adultType now derives from the effective seed for adult
Blobbies instead of reading the (potentially stale) stored tag. Falls
back to the tag only for legacy events without a seed.

Renames to reflect the broadened scope of the sync system:
- needsColorSync -> needsSeedIdentitySync
- eventNeedsColorSync -> eventNeedsSeedIdentitySync

No behavior change beyond making adultType consistent with the seed-
truth model and aligning names with what the code already does.
2026-04-21 23:07:51 -03:00
filemon 3dac492e31 Seed-truth for full visual identity including adult_type, with compat window
Seed now determines the complete visual identity: colors, pattern,
special_mark, size, and adult_type. All corresponding tags are persisted
mirrors that get overwritten on every republish via syncMirrorTagsToSeed.

Key changes:

deriveAdultTypeFromSeed: new derivation at seed offset [40..48], indexing
  into the 16-element ADULT_FORMS array via deriveIndexFromSeed.

deriveSeedIdentity: replaces deriveColorsFromSeed as the single entry
  point for the complete seed-derived visual trait set.

Temporary adult-type compatibility (cutoff: 2026-05-01 UTC):
  For existing adult Blobbies whose stored adult_type doesn't match the
  seed-derived form, adjustSeedForAdultType brute-forces the seed bytes
  at offset [40..48] to produce the stored form. This preserves existing
  adult forms during the transition. After the cutoff, parseBlobbiEvent
  skips this adjustment automatically and the code becomes dead.

eventNeedsColorSync: now checks all mirror tags (colors, pattern, mark,
  size, adult_type for adults), not just colors.

syncMirrorTagsToSeed: expanded to overwrite all mirror tags including
  adult_type on every republish through the merge pipeline.

useSeedIdentitySync hook: new hook wired into BlobbiPage that checks
  filteredCompanions on load and republishes any with stale mirror tags.
  Tracks synced d-tags in a ref to avoid loops. Processes sequentially
  to avoid relay rate-limiting.

BlobbiPage: calls useSeedIdentitySync(filteredCompanions) after the
  existing dedup/filter step, so only visible companions are synced.
2026-04-21 22:54:50 -03:00
filemon 907370e270 Make seed the canonical source of truth for Blobbi colors
When a seed exists, base_color / secondary_color / eye_color are now
always derived from the seed via deriveColorsFromSeed(). Explicit color
tags no longer override seed-derived values -- they are persisted as
mirrors for relay indexing and backward compatibility.

Changes:
- deriveVisualTraits: seed path ignores color tags entirely; no-seed
  legacy path unchanged
- deriveColorsFromSeed: new single entry point for canonical color
  derivation (seed → HSL → guardrails)
- syncColorTagsToSeed: overwrites stale color tags on every republish
  via mergeBlobbiStateTagsForRepublish
- eventNeedsColorSync: detects events whose stored color tags differ
  from seed-derived values
- BlobbiCompanion.needsColorSync: lightweight flag for UI-driven
  republish of stale events

Existing Blobbies with a seed will change appearance on next render
(seed-derived colors replace old palette-indexed tags). Events are
backfilled on their next republish through the merge pipeline.
2026-04-21 22:14:39 -03:00
filemon 1eeaf4c10e Replace fixed color palettes with arbitrary HSL generation from seed
Blobbi colors are now derived as full-spectrum HSL values from the seed
hash instead of indexing into fixed 10/10/8-element palette arrays.

Generation changes:
- deriveBaseColorFromSeed: splits 32-bit seed value into H(0-359),
  S(30-100), L(30-75) via successive division
- deriveSecondaryColorFromSeed: harmonized from base — same saturation,
  hue shifted ±20°, lightness +12..25 above base (guarantees visible
  3D gradient)
- deriveEyeColorFromSeed: independent H(0-359), S(40-100), L(10-55)
  for dark vivid pupils

Both deriveVisualTraits() and buildEggTags() now pipe seed-derived
colors through applyColorGuardrails() before use. Guardrails are never
applied to explicit tag values — the tag-priority rule is preserved.

Legacy palette arrays are marked @deprecated but kept for reference.
No rendering code, customizers, or Pandi behavior changed.
2026-04-21 21:10:54 -03:00
filemon c5140bf118 Add color guardrail utilities for Blobbi visual trait generation
Pure HSL-based validation/adjustment functions that will make arbitrary
color generation safe in a follow-up step. Guardrails ensure:
- base colors stay within a lightness range where the SVG gradient
  pipeline (lighten/darken) produces visible 3D shading
- secondary colors are perceptually distinct from base colors so body
  gradients don't collapse into flat fills
- eye colors have enough contrast to remain visible on white sclera
  and visually distinct from the body

Generation-side only: no rendering code, customizers, or existing
tagged colors are touched.
2026-04-21 20:29:30 -03:00
filemon f0f54d76c5 Merge branch 'main' into fix/blobbi-deterministic-legacy-migration 2026-04-21 19:54:07 -03:00
filemon 819d0a88f1 Make legacy Blobbi migration deterministic
Derive the migration petId from sha256(pubkey + legacyD) instead of
crypto.getRandomValues(). The same legacy Blobbi now always produces the
same canonical d-tag, seed, and visual traits regardless of which device
or session triggers the migration.

The equivalence guard (findCanonicalEquivalent) still runs first, so
pre-existing canonicals from the random-petId era are reused and no
duplicate is created.
2026-04-21 19:45:19 -03:00
Chad Curtis 08e61eea89 Merge branch 'fix/blobbi-adult-polish' into 'main'
Fix adult Blobbi colors, reactions, and eye/eyebrow alignment

Closes #241, #242, #243, #244, #245, and #246

See merge request soapbox-pub/ditto!196
2026-04-21 22:29:35 +00:00
Chad Curtis 273469eda8 Merge branch 'fix/blobbi-progression-task-persistence' into 'main'
Move Blobbi progression missions from kind 11125 to kind 31124

Closes #239

See merge request soapbox-pub/ditto!193
2026-04-21 22:29:05 +00:00
Chad Curtis 97a219aa8c Merge branch 'fix/blobbi-sleep-eye-overlay-initial-render' into 'main'
Fix open-eye flash on first sleep transition

Closes #240

See merge request soapbox-pub/ditto!195
2026-04-21 22:28:31 +00:00
Chad Curtis 5dafdf85f7 Merge branch 'fix/blobbi-legacy-new-deduplication' into 'main'
Fix Blobbi legacy/new-format deduplication to prevent infinite duplicates

Closes #247

See merge request soapbox-pub/ditto!198
2026-04-21 22:28:00 +00:00
filemon 7830269ea1 Collapse canonical Blobbi duplicates created by migration races
filterMigratedLegacyCompanions now runs a second pass that groups
canonical companions by their migrated_from tag. Within each group
only the newest event (highest created_at) is kept; the rest are
hidden from the collection UI. Canonical companions without the tag
are never grouped — they pass through untouched.

This closes the remaining duplicate-in-UI gap left intentionally by
the earlier legacy→canonical dedup work.
2026-04-21 18:37:38 -03:00
filemon 118b0c11ab Strengthen legacy->canonical equivalence with migrated_from and base_color
Replace the name-only equivalence rule with a tiered priority:

1. migrated_from exact match (canonical event's migrated_from tag equals
   the legacy d-tag) — strongest signal, written during migration and
   preserved across all subsequent Blobbi updates.

2. Same normalized name + same raw base_color tag — covers older canonical
   copies created before migrated_from existed, where both events have an
   explicit base_color tag that matches.

3. Same normalized name when the legacy event has no base_color tag —
   weakest fallback for genuinely old bare legacy events with no visual
   tags to compare.

All tiers still require the legacy d-tag to be absent from profile.has
(the migration-completion guard).

Audited that migrated_from survives all Blobbi lifecycle operations:
mergeBlobbiStateTagsForRepublish preserves it as an unknown tag,
validateAndRepairBlobbiTags passes it through (not in schema, not
deprecated), and stage transition cleanup does not touch it.
2026-04-21 17:52:24 -03:00
filemon 4ad0a9cfb4 Fix Blobbi legacy/new-format deduplication to prevent infinite duplicates
Legacy Blobbi events (d=blobbi-{name}) persisted on relays after migration
to canonical format (d=blobbi-{hex}-{hex}), causing them to appear alongside
their canonical copies in the UI. Interacting with a still-visible legacy
Blobbi triggered another migration each time, creating unbounded duplicates.

Three changes:

1. Filter migrated legacy Blobbies from the rendered collection. A legacy
   Blobbi is hidden only when a canonical Blobbi with the same normalized
   name exists AND the legacy d-tag is no longer in profile.has (confirming
   migration already occurred).

2. Guard ensureCanonicalBlobbiBeforeAction against re-migration. Before
   creating a new canonical event, query all companions and look for an
   existing canonical equivalent by normalized name. If found, reuse it
   and fix up profile.has/current_companion instead of migrating again.

3. Store a migrated_from tag on newly migrated events for future stronger
   equivalence lookups (additive, not depended on by current dedup logic).
2026-04-21 17:32:50 -03:00
filemon 3e5840b9a2 Merge branch 'main' into feat/blobbi-shake-reaction-stability 2026-04-21 12:35:25 -03:00
Chad Curtis c23af72da7 Fix lightbox swipe-to-dismiss flicker and locked controls race
The dismiss animation only translated the image strip, leaving the top
bar, nav buttons, dot indicators, and bottom bar stationary — visible
as a jarring flicker of controls. The backdrop also flashed back to full
opacity for one frame before the portal unmounted.

Wrap all visible content (everything except the backdrop) in a single
container that receives the translateY transform so the entire UI sweeps
away as one unit. Reorder the setTimeout callback so onClose fires
before clearing the animating lock, and add an unmount-cleanup effect as
a safety net against stuck controls.

Regression-of: cc655891
2026-04-20 20:00:50 -05:00
Chad Curtis bfee3dfdf1 release: v2.10.3 2026-04-20 19:40:48 -05:00
Chad Curtis b29f7ec4d5 Fix white status bar text on light theme (iOS)
App.tsx had a useEffect that unconditionally set SystemBarsStyle.Dark
(white icons) on mount, overriding the theme-aware logic in main.tsx
that had already set the correct style. On light themes this produced
white-on-white status bar text.

Remove the hardcoded override entirely — main.tsx handles initial
detection and MutationObservers cover all subsequent theme changes.
2026-04-20 19:12:06 -05:00
Chad Curtis a42e5f085e Fix envelope cards hard to tap on mobile
The hover wobble animation was triggering on touch devices via the
sticky :hover pseudo-class, rotating the envelope while the user
was trying to tap it. Restrict the wobble to true pointer-hover
devices with @media (hover: hover) and (pointer: fine).

Also tighten the entrance animation: remove rotation so tap targets
stay stable, reduce duration from 0.4s to 0.3s, start closer to
final size (0.85 vs 0.6), and cap stagger delay at 300ms so later
envelopes settle before the user can scroll to them.
2026-04-20 19:05:31 -05:00
Chad Curtis cc655891d5 Add swipe-to-dismiss gesture to lightbox overlays
Users can now swipe up or down to dismiss the full-screen image
lightbox, matching the native mobile pattern of flicking an image
away instead of reaching for the X button. The image follows the
finger with opacity fade, and commits the dismiss after 15% of
viewport height. When zoomed in the gesture is disabled so it
doesn't conflict with panning.

Applies to both the main Lightbox (feeds, galleries, media collage)
and the ProfileImageLightbox (avatar/banner taps).
2026-04-20 19:01:03 -05:00
Chad Curtis 708c25d938 Clear inline wall compose box after posting from modal
When a user typed in the inline ComposeBox then tapped the FAB (which
covers the Post button), the modal opened with the same draft text.
After posting from the modal, the inline ComposeBox still showed the
old text because it was a separate React instance with its own state,
leading to accidental double-posts.

Bump a key on the inline ComposeBox after a successful modal post so
React remounts it, picking up the already-cleared localStorage draft.
2026-04-20 18:54:21 -05:00
Chad Curtis a7cd13228b Add autoplay videos setting (default off)
Adds a new autoplayVideos config field and a toggle in Settings > Content >
Video Playback. When enabled, videos auto-play muted in feeds, collage
thumbnails, profile sidebar tiles, the Vines feed, and the VideoPlayer
component. The preference syncs across devices via encrypted settings.

usePlayerControls now listens for the volumechange event to keep the
volume UI in sync when the video is programmatically muted for autoplay.
2026-04-20 12:26:58 -05:00
filemon 618655e921 Merge branch 'main' into fix/blobbi-adult-polish 2026-04-20 01:16:43 -03:00
filemon 436324fe8f Fix adult eyebrow alignment by anchoring to eye top instead of eye center
Eyebrow Y position was calculated as eye.cy + offsetY, which broke on
forms with large eye whites (catti ry=16, froggi/owli r=22, droppi/
pandi/rocky r=12) because the fixed offset did not account for the
distance between eye center and eye top.

Propagate eye white vertical radius through the pipeline: write
data-eye-rx/ry on blink groups in addEyeAnimation, read data-eye-ry
in detectFromProcessedSvg, add eyeWhiteRy to EyePosition type. The
eyebrow formula now rebases recipe offsets from center-relative to
top-relative using the actual eye white radius, producing a consistent
gap above the eye top regardless of eye size.

Remove FORM_EYEBROW_OFFSETS (owli, froggi) — the radius-aware formula
handles all forms correctly without per-form overrides. Baby eyebrow
formula is unchanged.
2026-04-19 21:15:58 -03:00
filemon d0a11e266f Fix body detection for adult forms using circle, ellipse, and rect bodies
detectBodyPath() only matched <path> elements, so 10 of 16 adult forms
failed body detection — anger-rise was silently skipped and dirt/dust
fell back to hardcoded positions.

Add data-blobbi-body="true" marker to the primary body element of all
16 forms (base + sleeping = 32 elements). Extend detectBodyPath() with
a marker-first strategy that supports <circle>, <ellipse>, and <rect>
via path synthesis. Update the anger-rise overlay insertion regex in
apply.ts to find the marked element instead of only matching <path>.

Existing gradient-name and comment-based fallbacks remain for backwards
compatibility.
2026-04-19 20:55:06 -03:00
filemon 5bf99176bb Fix eye tracking lost on flat-fill adults after eyeColor replacement
The eyeColor fallback replaced known pupil fills with arbitrary colors,
causing isPupilElement() to no longer recognize them via PUPIL_COLORS.
Inject a data-blobbi-pupil marker during the fill replacement and check
for it first in both isPupilElement() copies so detection is independent
of the actual fill color value.

Regression-of: 9c20102d
2026-04-19 20:34:51 -03:00
filemon 9c20102dad Apply eyeColor and secondaryColor consistently to all adult Blobbi forms
eyeColor was silently dropped for 12 of 16 adult forms because they use
hardcoded fill attributes on pupil circles instead of gradient references.
Add a scoped flat-fill fallback in applyPupilGradient() that replaces the
known default pupil color within the <!-- Pupils --> comment block only.

secondaryColor was threaded through the type system but never read by any
adult customizer. Following the baby two-tone pattern (secondaryColor at
center, baseColor at edge), add an optional innerColor parameter to the
gradient builders and pass secondaryColor to each form's main body
gradient. Pandi remains excluded from body color changes by design.
2026-04-19 20:06:25 -03:00
Chad Curtis 8b311bde81 Merge branch 'ditto-music-feed' into 'main'
feat: redesign Music page with curated discovery experience

Closes #233

See merge request soapbox-pub/ditto!189
2026-04-19 22:27:56 +00:00
Chad Curtis b4e42778fa Merge remote-tracking branch 'origin/main' into ditto-music-feed
# Conflicts:
#	src/components/NoteCard.tsx
2026-04-19 17:27:12 -05:00
Chad Curtis 986adeb901 Reset package-lock.json to origin/main 2026-04-19 17:25:26 -05:00
Mary Kate Fain 1ce9beeaf5 Remove stray Cloudflare 524 error page committed as '&1' 2026-04-19 17:19:23 -05:00
filemon e704399c3d Fix Pandi eye detection misidentifying ears and eye patches as pupils
Pandi's 4 ear circles and 2 eye-patch circles use flat dark fills
(#1f2937, #374151) that match PUPIL_COLORS, causing the detector to
find 8 "pupils" instead of 2. This produced phantom eye groups,
broke blink/gaze targeting, and left the real pupils orphaned.

Add data-blobbi-skip attribute to the 6 non-pupil dark circles in
Pandi's SVGs, and add an early-return in isPupilElement() to respect
it. No other form has this attribute, so behavior is unchanged for
all non-Pandi adults.
2026-04-19 00:39:49 -03:00
filemon d1ae988024 Fix Catti reaction mouths rendering left-shifted instead of centered
Catti's dual cat-mouth (two mirrored Q-curve paths sharing a center
start point) caused extractMouthPositionFromElements to return only
the left half's coordinates. Every mouth generator then computed
cx=(100+82)/2=91 instead of the true center x=100.

The fix scans all Q-curve paths in the mouth section and computes the
full horizontal extent. For Catti: startX=82, endX=118, center=100.
Single-path mouths are unaffected (the extra-path loop simply doesn't
execute). Froggi's dual-path mouth already had symmetric bounds so its
result is unchanged.
2026-04-18 23:38:41 -03:00
filemon 27736c7047 Fix Catti whiskers destroyed by mouth replacement recipes
replaceMouthSection() used a global regex matching any Q-curve <path>
with a stroke attribute. Catti's whiskers are Q-curve paths that appear
after the mouth in document order, so they were matched and deleted
whenever a status reaction replaced the mouth.

The fix adds a marker-bounded replacement strategy: when a <!-- Mouth -->
comment marker exists, the regex is scoped to only the section between
that marker and the next SVG section. All 16 adult forms and the baby
SVG already have this marker. The global regex is preserved as a
fallback for SVGs without markers.
2026-04-18 23:04:55 -03:00
filemon 6f68153306 Fix sleeping Blobbi showing open eyes in Blobbies tab grid
The MoreTabContent grid rendered BlobbiStageVisual without a sleeping
recipe, so sleeping companions got the awake base SVG with open eyes.
The sleeping visual (closed clip-rects, closed-eye lines, Zzz) is
entirely recipe-driven — without the recipe the SVG renderer always
produces the awake appearance.

Pass buildSleepingRecipe() and recipeLabel='sleeping' for companions
whose state is 'sleeping' in the tab grid call site.
2026-04-18 22:19:42 -03:00
filemon 3260350377 Merge branch 'main' into feat/blobbi-click-overstimulation-reaction 2026-04-18 20:23:04 -03:00
filemon 03d174e5cc Add one-shot eye-open animation on wake-up
When Blobbi transitions from sleeping to awake, the eyes now visibly
open over 400ms with an ease-in-out curve before the normal blink/gaze
loop resumes. The wake-up animation mirrors the sleep-entry animation:

- runWakeUpAnimation() queries fresh DOM elements from containerRef
- Sets clip-rects to the closed position (using BLINK_CLOSED_AMOUNT)
- Animates from closed to open, then calls onComplete
- The normal awake animation loop only starts after completion
- This prevents two rAF loops from fighting over clip-rect attributes

The animation only fires on genuine sleeping→awake transitions via
wasSleepingRef, not on mount or refresh when Blobbi is already awake.
If cancelled mid-animation (e.g. quick sleep re-toggle), the onComplete
callback is still invoked so the hook does not get stuck.
2026-04-18 19:26:24 -03:00
Chad Curtis 243ce98dd4 Merge branch 'feat/inbox-relay-delivery' into main 2026-04-18 16:53:26 -05:00
Chad Curtis f14316f024 Send reply events to tagged users' inbox relays (NIP-65)
When publishing kind 1 or kind 1111 reply events, also deliver them to
the read (inbox) relays of p-tagged users. This follows the NIP-65
recommendation that clients send events to the read relays of each
tagged user so recipients are more likely to see replies.

The inbox delivery is fire-and-forget after the main publish succeeds,
so it does not slow down the UI or block the publish flow.
2026-04-18 16:42:48 -05:00
filemon 399a3586b2 Add one-shot eye-close animation on sleep entry
When Blobbi transitions from awake to sleeping, the eyes now visibly
close over 400ms with an ease-in-out curve before settling into the
stable sleeping state. The closed-eye lines fade in during the last
40% of the animation.

The animation is driven by a standalone rAF loop in runSleepEntryAnimation()
that queries fresh DOM elements from containerRef.current — never from
stale cached refs. A wasSleepingRef tracks the previous isSleeping value
so the animation only fires on genuine awake→sleep transitions, not on
mount or refresh when Blobbi is already sleeping.

At t=1 the DOM exactly matches the recipe's static closed state, so
there is no visual discontinuity when the animation completes.
2026-04-18 18:36:17 -03:00
filemon 3bba781f49 Fix open-eye flash on first sleep transition
Clear stale cached DOM refs in useBlobbiEyes when entering sleep.
The awake animation loop caches blink/gaze SVG elements, but
dangerouslySetInnerHTML replaces the entire SVG when the sleeping
recipe is applied. The old refs' open-eye clip geometry was being
used to querySelector into the new sleeping SVG and reset the
clip-paths back to the open position, causing both open eyes and
closed-eye lines to render simultaneously.

The sleeping recipe already sets clip rects to the closed position
in the SVG string, so no JS-side clip-path reset is needed.
Clearing the caches prevents stale operations and lets fresh
caching happen naturally when Blobbi wakes up.
2026-04-18 18:09:12 -03:00
Alex Gleason 91fe272bea release: v2.10.2 2026-04-18 09:19:01 -05:00
filemon 0618a1ca13 Skip redundant evolution persist when content is already up-to-date
The debounce hook was re-publishing a kind 31124 event with identical
evolution content 5s after every interaction, because the primary
write path already persisted the same data inline. Now compares the
serialized content against the fresh event before publishing and
skips when they match. The hook still fires for the one case where
it is genuinely needed: event-based backfill from Nostr queries.
2026-04-18 09:33:06 -03:00
filemon 3fe1256381 Harden per-Blobbi evolution persistence across interaction paths
Five targeted fixes to the evolution mission persistence flow:

1. Inline evolution content into interaction write paths: all three
   action hooks (direct action, inventory item, companion item use) now
   read the updated evolution from the session store and embed it into
   the 31124 content in the same publish. The debounce hook remains as
   a safety net for event-based backfill, not the primary persistence.

2. Scope ensuredRef per Blobbi: the 'ensure missions exist' guard in
   useHatchTasks and useEvolveTasks was a plain boolean ref that would
   not re-run when switching companions. Now keyed by pubkey:d.

3. Add companionD to query keys: hatch-tasks and evolve-tasks queries
   were keyed by pubkey only, causing stale cache reuse across Blobbis.

4. Filter persist hook by d-tag: usePersistEvolutionProgress now checks
   detail.d against companionD so it only reacts to evolution updates
   for the active companion.

5. Clear evolution in switch mode: when switching incubation from one
   Blobbi to another, the stopped Blobbi's 31124 content now has its
   evolution[] cleared, and its session store entry is removed.
2026-04-18 09:23:08 -03:00
filemon 1bce67d21d Move Blobbi progression missions from kind 11125 to kind 31124
Evolution/hatch mission progress was stored in the shared Blobbonaut
profile (kind 11125) content JSON, causing split-brain state between
the per-user profile and per-Blobbi events. After reload, progress
could disappear or get overwritten across Blobbis because the session
store was keyed by pubkey only and persisted via a debounced write to
the wrong event.

Now:
- Daily missions remain on kind 11125 (per-user, correct)
- Evolution missions live on kind 31124 content JSON (per-Blobbi)
- Session store split: daily keyed by pubkey, evolution by pubkey:d
- usePersistEvolutionProgress writes to 31124 instead of 11125
- useHatchTasks/useEvolveTasks read from companion.evolution
- serializeProfileContent strips legacy evolution from 11125
- Start/stop incubation/evolution seed/clear 31124 content directly
- Interaction tallies pass companion d-tag for per-Blobbi tracking
2026-04-18 08:59:19 -03:00
Chad Curtis 00fa9cad57 Merge branch 'fix/blobbi-progression-state-separation' into 'main'
Separate progression state from activity state to prevent evolution reset

Closes #238

See merge request soapbox-pub/ditto!192
2026-04-18 11:35:58 +00:00
filemon 9b9abaa855 Fix stale references to state_started_at in progression migration cleanup
- StartIncubationDialog: display progressionState instead of state in
  restart dialog text (was showing 'active' instead of 'incubating')
- blobbi-tag-schema: update deprecated tag replacedBy and category
  comment to reference progression_started_at instead of state_started_at
- blobbi.ts: update deprecation comments for incubation_time and
  start_incubation to reference progression_started_at
2026-04-18 08:10:56 -03:00
filemon 06b53dbc82 Fix stale comments and result types from progression-state migration
Update JSDoc comments in useBlobbiIncubation to reference
progression_state/progression_started_at instead of the deprecated
state/state_started_at. Rename stateStartedAt to progressionStartedAt
in StartIncubationResult and StartEvolutionResult interfaces.
2026-04-18 07:34:07 -03:00
filemon bf6788c141 Separate progression state from activity state to prevent evolution reset
Sleep/wake toggles overwrote 'evolving' with 'sleeping', permanently
destroying evolution progress. The root cause was using a single state
tag for two orthogonal concerns: activity (active/sleeping/hibernating)
and progression (incubating/evolving).

Introduce progression_state and progression_started_at as new tags
orthogonal to the activity state tag. Sleep, wake, and hibernation
changes now never touch progression. Parser auto-migrates legacy
events that stored progression in the state tag on read.
2026-04-18 07:03:52 -03:00
Chad Curtis 363e39d72c Harden against malformed untrusted data that crashes pages
- ExternalContentPage: new URL() on non-URL NIP-73 identifiers (isbn:,
  iso3166:) threw TypeError crashing the page; now URL is only created
  for url-type content and #-prefixed strings are used for other types
- ExternalContentPage & RelayPage: decodeURIComponent on malformed
  percent-encoded URL params threw URIError; now wrapped in try/catch
- useMastodonPost: new URL() on invalid URLs inside queryFn now returns
  null instead of letting the error propagate
- colorUtils/themeEvent: malformed hex color values from theme events
  produced NaN CSS variables; added isValidHex guard in parseColorTags
2026-04-18 04:50:34 -05:00
filemon e2ce575b25 Separate progression state from activity state to prevent evolution reset
Sleep/wake toggles overwrote 'evolving' with 'sleeping', permanently
destroying evolution progress. The root cause was using a single state
tag for two orthogonal concerns: activity (active/sleeping/hibernating)
and progression (incubating/evolving).

Introduce progression_state and progression_started_at as new tags
orthogonal to the activity state tag. Sleep, wake, and hibernation
changes now never touch progression. Parser auto-migrates legacy
events that stored progression in the state tag on read.
2026-04-18 06:46:17 -03:00
Chad Curtis 36373400f8 Fix crash on invalid blurhash strings from Nostr events
Validate blurhash hashes before passing them to react-blurhash's
<Blurhash> component, which throws when the encoded length doesn't
match the component count header. Malformed hashes from third-party
events (e.g. length 92 instead of 94) now gracefully fall back to a
skeleton placeholder instead of crashing the page.
2026-04-18 04:39:14 -05:00
filemon e12d8eebdd Fix shake reaction: remove debug bypass, preserve SMIL eyes, stack shakes
- Remove temporary `true ||` debug bypasses that made nausea trigger
  regardless of hunger stat. Nausea now correctly requires hunger >= 90.
- Track `cycleHadNausea` so the recipe resolver uses a consistent
  nauseated face recipe for the entire reaction cycle, even after the
  green fill drains to 0. This prevents a structural SVG rebuild
  mid-reaction that killed SMIL spiral eye animations.
- Make shake reactions additive: starting a new shake during dizzy or
  recovering no longer resets the reaction. Instead, the phase
  transitions back to shaking, nausea level can only rise (max of
  current and new), and the dizzy hold timer extends.

Regression-of: 91de4f80
2026-04-18 05:32:36 -03:00
filemon 91de4f80d8 Add shake-to-dizzy reaction with nausea fill and fix SMIL animation stability
Shake reaction system
---------------------
Introduce a reusable shake detection + reaction pipeline that triggers
dizzy visuals when Blobbi is shaken during drag, with progressive green
nausea body fill when hunger is high (>= 90, currently debug-bypassed).

Architecture follows the same phase/level/profile pattern as click
overstimulation for future extensibility (personality variants,
additional physical-stress reactions).

New files:
- shakeDetection.ts: pure motion sampling (direction reversals,
  speed accumulation, energy integral)
- useShakeReaction.ts: 4-phase state machine (idle → shaking → dizzy
  → recovering), profile system, recipe resolution with nausea fill

Phases:
- idle:       no shake reaction active
- shaking:    user actively shaking (dizzy face + live green fill rise)
- dizzy:      post-release hold (3-8s scaled by intensity, fill drains)
- recovering: nausea draining via rAF, then back to idle

SMIL animation stability fix
-----------------------------
The nausea fill level changes ~12×/sec during drain, creating a new
recipe object each tick. This broke the React.memo barrier on
MemoizedBlobbiVisual (reference equality), triggering full SVG DOM
replacement via dangerouslySetInnerHTML — killing all SMIL animations
(dizzy spirals, sleepy blinks) on every update.

Fix: both SvgRenderers now compute a structural recipe fingerprint
that clones the recipe and strips only bodyEffects.angerRise.level.
The customizedSvg useMemo depends on this string (compared by value),
so level-only changes skip the SVG rebuild. The fill level is applied
imperatively via gradient stop setAttribute() in a separate useEffect,
preserving the existing DOM and all running SMIL animations.

Reaction retrigger fix
----------------------
Both shake and overstimulation reactions had cycle-scoped refs that
were not cleaned up when the reaction drained to idle via the natural
rAF path, preventing immediate retrigger:
- Overstimulation: clicksRef (stale timestamps) now cleared at idle
- Shake: toastShownRef now reset at idle

Body fill improvements
----------------------
- angerRise generator now accepts caller-controlled bottomOpacity and
  edgeOpacity so nausea (strong green) and anger (moderate red) can
  have different visual intensity through the same shared generator
- Static-level fill mode uses real body bounds from path detection
  instead of hardcoded coordinates
- Nausea fill drains during the dizzy hold (not only after it ends)
  for a smooth continuous descent

Temporary debug bypass still active:
  true || hungerRef.current >= _NAUSEA_HUNGER_THRESHOLD
Must be removed before final merge.
2026-04-18 04:58:10 -03:00
filemon c2af41c7f2 Simplify blocked toast to show duration once without live countdown
Revert the interval/update-in-place approach and replace with a single
toast call that displays the chosen blocked duration (e.g. 'calm down
for 3s'). No interval, no toast handle refs, no countdown state.

Removed from previous version:
- toastHandleRef and countdownIntervalRef refs
- clearCountdown() helper
- setInterval countdown loop with toast.update() calls
- clearCountdown() calls in block-end, deactivation, and unmount paths

Regression-of: 6d9e7502
2026-04-18 02:00:05 -03:00
filemon 6d9e750251 Add live countdown to Blobbi overstimulation toast
Show remaining seconds in the blocked toast (e.g. 'calm down for 4s')
and update it every second via the toast update mechanism. The toast is
automatically dismissed when the blocked phase ends. All timer state is
owned by useOverstimulationReaction so the UI layer stays simple.
2026-04-18 01:53:19 -03:00
filemon 12c19ac4c2 Block all pointer interactions during Blobbi overstimulation phase
Add a transparent fullscreen overlay (fixed, inset 0, z-index 99999) that
renders only while phase === 'blocked'. This prevents clicks on buttons,
links, inputs, feeds, menus, etc. while Blobbi is overwhelmed. The overlay
is removed automatically when the blocked phase ends.
2026-04-18 01:47:32 -03:00
filemon 7768588dbd Fix documentation to match actual overstimulation math
Update the module doc comment and inline constant comments to accurately
describe the real escalation and cooling timelines:
- 4 clicks: mild angry face
- 6 clicks: red body fill begins (level crosses 0.2)
- 15 clicks: max level, blocked for 2-4s
- 1.5s delay + ~4s drain = ~5.5s total recovery from max
2026-04-18 01:07:25 -03:00
filemon 0f759de671 Fix overstimulation reaction: phase transition ownership and angerRise level propagation
Two bugs prevented the overstimulation system from working:

1. Phase never entered 'rising' because phaseRef was mutated before
   calling pushVisible(), making the phaseChanged check always false.
   pushVisible is now the single owner of phase transitions — callers
   must not pre-mutate phaseRef. Same bug existed in the rising→cooling
   transition inside rafTick.

2. applyVisualRecipe() dropped the 'level' field when building the
   bodySpec for angerRise, so the level-controlled static gradient
   path was never reached.
2026-04-18 01:07:25 -03:00
filemon 53b0281dc8 Add click-overstimulation reaction system for Blobbi companion
Implement a profile-based overstimulation system that reacts to rapid
repeated clicks anywhere in the app. The system tracks a continuous
level (0-1) that rises with rapid clicks and cools down gradually when
clicks stop, with support for temporary click blocking at max level.

Architecture:
- useOverstimulationReaction hook with OverstimulationProfile interface
  for future personality-based branching (angry, confused, nervous, etc.)
- Level-controlled anger-rise body effect via new 'level' parameter on
  the existing angerRise spec (preserves SMIL path for existing callers)
- Visible state throttled to ~6-10fps via delta threshold to avoid
  SVG re-render churn

Behavior:
- 5+ rapid clicks in 2s window triggers mild angry face
- Additional clicks progressively fill the red body effect from bottom
- 2s of no clicks starts gradual cooling (level decreases over time)
- Clicking during cooldown resumes from current level
- At max level: Blobbi blocks clicks for 2-4s with a toast notification
- After block ends: level cools naturally from 1.0 back to idle
2026-04-18 01:06:15 -03:00
filemon f85d345821 Clean up route-reaction hook: fix stale click reuse, remove unused scaffolding, document stable callbacks 2026-04-18 01:06:15 -03:00
filemon ff44d9022c Use closest() for click-target detection, clamp Y to prevent downward gaze
Replace the manual parent-walk loop with closest('a, button,
[role="button"]') — simpler, handles role="button" elements, and
returns null cleanly when no clickable ancestor exists (so only the
raw pointer fallback is used).

Clamp the resolved click-origin Y to 55% of viewport height so Blobbi
looks across toward the sidebar rather than sharply downward when the
clicked item is near the bottom of the screen.
2026-04-18 01:06:15 -03:00
filemon cb9d183d7d Use live element rect for click-origin glance position
Store the nearest clickable ancestor (<a>/<button>) on pointerdown
alongside the raw coordinates. At route-change time, re-read the
element's getBoundingClientRect() to get its current visible center,
which accounts for any scroll shifts between the click and the React
effect. Falls back to the raw pointer coordinates if the element has
been unmounted.
2026-04-18 01:06:15 -03:00
filemon a3874a77f4 Blobbi glances at click origin before looking at main content on route change
Track the last pointerdown position in a ref. On route change, if a
recent click exists (<1s), glance at that position for ~700ms first,
then look at the center-top of the new page for 2-6s. Programmatic
navigation (no recent click) falls back to immediate center-top.

Single-file change in useRouteReaction.ts. No changes to attention,
gaze, or state hooks.
2026-04-18 01:06:15 -03:00
filemon 4264fb4aba Fix Blobbi gaze drifting away during route-reaction attention
Guard makeDecision() so it bails out while attentionTarget is active.
Previously the decision loop could fire during the attending window,
transitioning Blobbi to walking/idle and breaking the attend-ui gaze
lock. The attention-end handler already resumes decisions when it
clears, so this guard is safe.
2026-04-18 01:06:15 -03:00
filemon f2c479ea3a Eliminate sidebar-look during route transitions by setting immediate center-top attention
On route change, immediately trigger a preliminary attention target at
viewport center-top before the 250ms DOM-mount delay. This ensures the
gaze system never falls to random or mouse-follow mode during the gap,
which previously caused Blobbi to drift leftward toward the sidebar.

The delayed reaction still fires at 250ms and replaces the preliminary
target with a precise DOM-measured position.
2026-04-18 01:06:15 -03:00
filemon 7fa856224e Fix Blobbi looking at sidebar during route transitions
Keep previous attention alive during the 250ms route-reaction delay
instead of clearing it immediately. This prevents the gaze system from
falling to random mode (which could point toward the sidebar) while
waiting for the new page's DOM to mount.

- Split cancelReaction into cancelPendingTimeouts (timeouts only) and
  full cancel (timeouts + attention); route changes use the former
- Add bypassCooldown option to triggerAttention so the delayed reaction
  can override the kept-alive attention without being blocked by cooldown
- Stabilize triggerAttention via uiAttentionRef instead of stale closure
2026-04-18 01:06:15 -03:00
filemon 379c21c458 Reset attention cooldown on clearAttention to fix rapid route changes
clearAttention() now resets lastAttentionTimeRef to 0 so that a
triggerAttention call immediately after a forced clear is not silently
rejected by the 1500ms cooldown guard.
2026-04-18 01:06:15 -03:00
filemon 3b576685b7 Simplify route reaction to single center-look with random duration
Remove right-sidebar detection and multi-target chaining. The generic
reaction now fires a single attention target at the top-center of the
main content area for a random 2-6 seconds.

- Remove findRightSidebarPosition() and all sidebar scan logic
- Replace fixed 1200ms duration with random 2000-6000ms
- Compute target position at reaction start via live DOM query
  and window.innerWidth/Height fallback (no stale closure)
- Cancel both pending timeouts and active attention on drag
- Remove viewport prop from hook options (no longer needed)
- Update docstrings to describe the simplified behavior
2026-04-18 01:06:15 -03:00
filemon c95287e5a4 Add generic route-transition reaction for Blobbi companion
On page navigation the companion now briefly pauses and scans the
layout areas that changed. Center content is always scanned first,
followed by the right sidebar if a non-placeholder sidebar is
detected in the DOM.

Implementation:
- useRouteReaction.ts: thin orchestration hook that watches pathname,
  determines changed areas, and chains triggerAttention calls via
  setTimeout. Cancels on new route change, drag, or unmount.
- useBlobbiCompanion.ts: wires the new hook with existing
  triggerAttention/clearAttention from useBlobbiAttention.

No changes to the attention system, state machine, gaze hook, motion
hook, or entry animation. The existing attending state and attend-ui
gaze mode handle all the visual behavior.

Includes an empty ROUTE_REACTIONS map for future per-route overrides.
2026-04-18 01:06:15 -03:00
Alex Gleason 54a49f1ece release: v2.10.1 2026-04-17 22:43:30 -05:00
Alex Gleason a2f2d9ff89 Add configurable shareOrigin to AppConfig
window.location.origin resolves to capacitor://localhost on iOS and
https://localhost on Android, which produces broken QR codes, broken
copy-link actions, and a broken remote-login callback URL on native
builds.

Add an optional shareOrigin field to AppConfig and a useShareOrigin
hook that falls back to window.location.origin when unset. Replace
all 13 call sites that build shareable URLs.

The origin can be configured three ways, in order of precedence:
user localStorage > ditto.json > VITE_SHARE_ORIGIN env var. Native
deployments can set VITE_SHARE_ORIGIN=https://ditto.pub at build time
so that shared URLs resolve correctly when opened on another device
(and get caught by DeepLinkHandler when opened on the same app via
Universal/App Links).

Regression-of: a12d5db5
2026-04-17 22:39:44 -05:00
Alex Gleason cb26238729 release: v2.10.0 2026-04-17 19:34:53 -05:00
Mary Kate Fain 800e0bbe47 Address self-review checklist findings
- Remove dead export useMusicTracksByGenre from useMusicData
- Sanitize metadata?.picture through sanitizeUrl() in ProfileCard,
  MusicHeroCard, MusicTrackCard, and MusicTrackRow
- Fix MusicDiscoverTab featured section where skeleton and loaded
  content could render simultaneously (make mutually exclusive)
- Add error state handling to all 4 music tab components
- Move genre filtering from client-side to relay-level #t filtering
  in MusicTracksTab via new genre param on useMusicFeed
2026-04-17 19:09:08 -05:00
Alex Gleason f4ae344b30 Unify follow list, follow set, and follow pack rendering
Kind 3 (NIP-02 follow list), kind 30000 (NIP-51 follow set), and kind 39089
(follow pack) are all the same semantic thing — an event containing a list of
p-tagged pubkeys — but were being rendered three different ways, with kind 3
having no rendering at all, kind 30000 routing to a bespoke ListDetailPage, and
kind 39089 using its own FollowPackDetailContent.

Merge the three into a single PeopleListContent (feed card) and
PeopleListDetailContent (full detail). The detail component hosts every
feature from the predecessors: Follow All with existing p-tag preservation,
Save-as-copy for non-owners, owner-mode member removal for kind 30000, the
Feed/Members/Comments tabs, sidebar integration, and the share/copy-link
menu. For kind 3 the event has no title of its own, so we fall back to the
author's display name.

Additional refinements bundled in:

- Register kinds 3 and 30000 at every previously-missing rendering point:
  KIND_HEADER_MAP, shellTitleForKind, CommentContext KIND_LABELS/ICONS,
  extraKinds specific labels and icons, and ExternalContentHeader fallbacks.
  Kind 3 and 30000 now share the packs feed toggle via extraFeedKinds.
- Add infinite scroll to the people-list Feed tab via useTabFeed +
  IntersectionObserver sentinel, replacing the useStreamPosts 40-post cap.
- Add Comments tab alongside Feed and Members, powered by useComments
  (NIP-22 kind 1111). Drop the redundant variant badge. Allow kind 39089
  packs to be pinned to the sidebar.
- Trim redundant chrome: drop the member-count pill in the feed card,
  drop the member-count line in the detail header, and stop pulling the
  author's 'about' and 'banner' into kind 3 follow list views.
- Add a dedicated FollowListCommentContext branch so comments on kind 3
  show '@Name's follow list' instead of 'a follow list'.
- Replace the three-dots DropdownMenu on the detail view with the shared
  PostActionBar (reply/repost/react/zap/share/more), matching other
  detail views.
- Promote EmbeddedPost from ReplyComposeModal into a shared component
  that dispatches to EmbeddedNote / EmbeddedNaddr, with a new
  EmbeddedPeopleListCard for kinds 3/30000/39089 so quote posts, reply
  indicators, hover cards, and the More menu all render follow lists
  correctly.
- Link the 'N following' count on profile pages to a naddr of the kind 3
  event (routing to the new detail view) instead of a bespoke modal.
  Delete FollowingListModal. Using naddr rather than nevent ensures the
  link always resolves to the latest replaceable event.

Delete ListDetailPage, FollowPackDetailContent, and FollowingListModal
entirely. All three kinds now route through AddrPostDetailPage →
PeopleListDetailContent.
2026-04-17 18:52:00 -05:00
Mary Kate Fain 61d3c261fe Default tracks and playlists tabs to 'new' sort 2026-04-17 18:15:04 -05:00
Mary Kate Fain ae32b62552 Show 3 playlist columns on desktop instead of 2 2026-04-17 18:03:46 -05:00
Mary Kate Fain 781aa2579b Fix empty results for hot/top sort on music tracks and playlists
When the Ditto relay lacks engagement data for music event kinds
(36787, 34139), sort:hot and sort:top queries return nothing. Add a
chronological fallback in useMusicFeed and the Discover tab's inline
New Tracks query: if the sorted query returns zero events, retry
against the default relay pool without the search param so users
always see content.
2026-04-17 17:55:58 -05:00
Mary Kate Fain 6999da3e45 feat: add Hot/Top/New sort and Global/Following scope filters to music pages
Add sort and scope filter controls to all three music pages:

Discover tab:
- New Tracks section gets sort (Hot/Top/New) and scope (Global/Following)
  controls. Global scope queries curated artists; Following scope queries
  the user's follow list. Hot/Top use Ditto relay NIP-50 search extensions.

Tracks tab:
- Replace useFeed('global') with useMusicFeed hook that supports sort and
  scope. Infinite scroll pagination preserved. Genre chips still work as
  client-side filter on top of the sorted results.

Playlists tab:
- Replace one-shot useMusicPlaylists with useMusicFeed for infinite scroll
  with sort and scope. Album/playlist type toggle preserved.

New shared components:
- MusicSortFilterBar: Pill-style sort (Hot/Top/New with icons) and scope
  (Global/Following) controls. Following only shown when logged in.
- useMusicFeed: Infinite scroll hook that maps sort modes to Ditto relay
  NIP-50 search extensions and restricts authors for Following scope.
2026-04-17 17:31:35 -05:00
Mary Kate Fain 2c5528774f revert: remove New Tracks artist deduplication
Not enough curated artists yet to justify one-per-artist filtering,
which leaves the section too sparse. Restore chronological ordering
so the feed fills up. The genre chips below the header change is kept.
2026-04-17 17:21:21 -05:00
Mary Kate Fain 78db2568e0 fix: move genre chips below New Tracks header and deduplicate by artist
Genre filter chips now appear below the 'New Tracks' section title
instead of above it, making it clear they filter that section.

New Tracks section now shows at most one track per artist (most
recent from each), preventing a prolific artist from dominating
the entire section. This applies to both the 'All' default state
and genre-filtered states.
2026-04-17 17:16:50 -05:00
Mary Kate Fain 64db8b2ce0 feat: collapse long playlist descriptions with show more/less toggle
Playlist detail page now clamps the description to 3 lines with a
'Show more' button that expands to show the full text. Uses CSS
line-clamp with a scrollHeight check to only show the toggle when
the text actually overflows.
2026-04-17 16:53:55 -05:00
Alex Gleason 8f6361f6fc Bump dompurify to 3.4.0 for security fixes
DOMPurify 3.4.0 is a security release that fixes multiple issues
including mXSS via re-contextualization and closing tags, prototype
pollution via CUSTOM_ELEMENT_HANDLING and USE_PROFILES, ADD_ATTR
predicates skipping URI validation, and ADD_TAGS/FORBID_TAGS
precedence bugs.

The project uses DOMPurify to sanitize user-supplied SVGs in
sanitizeSvg.ts and sanitizeBlobbiSvg.ts, so pulling in these fixes
hardens our SVG rendering path against hostile inputs.
2026-04-17 16:53:16 -05:00
Alex Gleason 85894b98f5 Add stronger language to commit rule in AGENTS.md 2026-04-17 16:50:14 -05:00
Alex Gleason 3b052d3eb6 Fix naddr lookup for legacy replaceable kinds (0, 3, etc.)
useAddrEvent only treated kinds in 10000-19999 as replaceable, so any
naddr with a kind outside that range got a '#d' filter applied. For
legacy replaceable kinds like 0 and 3, real events don't carry a 'd'
tag, so the query matched nothing even when the relay had the event.

Invert the check to only apply the '#d' filter for true addressable
events (30000-39999). Legacy replaceable kinds and 10000-19999 are
now queried by kind+author alone.

Regression-of: 9b5df28b
2026-04-17 16:47:09 -05:00
Mary Kate Fain ecb61d44a5 feat: fall back to first track's artwork for playlist covers
When a playlist has no artwork or its image fails to load, resolve
the first track from the playlist's a-tag refs and use its artwork
as a fallback cover image.

- Add usePlaylistCoverArt hook: lightweight single-track query that
  only fires when the playlist's own artwork is missing. Returns the
  playlist art if present, or the first track's artwork otherwise.
- MusicPlaylistCard: uses the hook for cover art with per-URL error
  tracking so a broken playlist image triggers the fallback without
  breaking a working track image.
- PlaylistDetail: derives cover art from already-resolved trackEvents
  (no extra query needed since tracks are already loaded).
2026-04-17 16:44:08 -05:00
Mary Kate Fain 5789b34b5a fix: add onError fallback for broken images in music components
When an image URL from a Nostr event returns an error (404, stale
URL, server down), the img element now falls back to the existing
gradient/icon placeholder instead of showing a broken image.

Applied to MusicPlaylistCard, MusicTrackCard, MusicHeroCard,
MusicTrackRow, and MusicDetailContent (track hero, playlist hero,
and PlaylistTrackRow). Each uses a local imgError state that flips
on the img onError event to swap in the fallback.
2026-04-17 16:39:21 -05:00
Alex Gleason 5810c86e07 Reword Blobbi state action header to 'cared for their Blobbi'
The previous 'updated their Blobbi' wording felt mechanical for what is
really a care interaction (feeding, cleaning, playing, etc.). 'Cared for'
better reflects the user's intent.
2026-04-17 16:30:05 -05:00
Mary Kate Fain 3312621f1d fix: remove loading=lazy from horizontal scroll cards
Images inside overflow-x-auto scroll containers don't reliably
trigger the browser's lazy load IntersectionObserver, causing
playlist and track card artwork to not load in horizontal scroll
sections. Remove loading=lazy from MusicPlaylistCard and
MusicTrackCard since they render a small fixed number of cards
that should load immediately. MusicTrackRow (vertical list)
keeps lazy loading since it works correctly there.
2026-04-17 16:26:57 -05:00
Mary Kate Fain ccf1e0f137 feat: reorder Discover sections and sort playlists by hot
Move Artists and Playlists above Genre chips + New Tracks on the
Discover page. Playlists now query the Ditto relay with sort:hot
so the most engaging playlists surface first.

New section order: Hero > Featured > Artists > Playlists > Genre
chips > New Tracks > CTA.
2026-04-17 16:23:40 -05:00
Mary Kate Fain 2ba987f532 fix: backfill featured tracks with recent when hot results are sparse
When sort:hot distinct:author returns fewer than 5 tracks (common
with limited engagement data), issue a second query for recent tracks
with distinct:author and merge them in, skipping authors already
present. Ensures the Featured section always has enough variety.
2026-04-17 16:20:12 -05:00
Mary Kate Fain f677c131c0 feat: curator-gated Discover page with hot featured tracks
Rework the Music Discover tab so every section is gated through the
curator's lists, ensuring only high-quality content appears.

- Add useFeaturedMusicTracks hook: queries Ditto relay with sort:hot
  and distinct:author for curated artists. The #1 hot track becomes
  the hero; the rest populate the Featured horizontal scroll with no
  artist repeats.
- Add useMusicCuratorFollows hook: fetches Heather's kind 3 follow
  list to filter playlists to people she follows.
- Add authors param to useMusicPlaylists and useMusicTracksByGenre
  so both can be restricted to specific pubkeys.
- Rewire MusicDiscoverTab: New Tracks and genre filtering now use
  curated artists only; playlists use curator follows; section renamed
  from 'Recently Added' to 'New Tracks'.
2026-04-17 16:16:59 -05:00
Alex Gleason 650a45729e Disable global pinch-to-zoom via viewport meta
On Capacitor iOS, leaving user-scalable unrestricted let WKWebView's
scroll view pinch recognizer engage intermittently, then get stuck
disabled once it fired. Adding maximum-scale=1 and user-scalable=no
disables browser-driven pinch zoom consistently across web, iOS, and
Android.

The in-app lightbox (LightboxImage in ImageGallery.tsx) already
implements its own pinch-to-zoom with custom touch handlers and CSS
transforms, so it continues to work. Future components that want
pinch-zoom can follow the same pattern.
2026-04-17 16:13:40 -05:00
Alex Gleason ab2f574ff3 Document Regression-of trailer convention for commit messages
When a commit fixes a bug introduced by an identifiable prior commit,
the fix should record the offending short SHA in a Regression-of:
trailer at the bottom of the commit message body.

This is a standard Git trailer (parseable by git interpret-trailers)
that makes intra-release regression detection trivial: the release
skill can now read the trailer directly instead of hunting through
git log and git blame to figure out whether a 'Fixed' entry actually
describes a bug a shipped user ever saw.

- AGENTS.md: new 'Attributing Regressions' subsection under Using Git
  with the convention, when-to-add/skip rules, and tracing tips.
- .agents/skills/release/SKILL.md: Step 5.2 now has a fast path that
  reads Regression-of trailers via 'git log --format=%(trailers:...)',
  with the existing manual git log/blame approach as fallback.
- CONTRIBUTING.md: brief mention in the Bug fixes section and a new
  self-review checklist item pointing at AGENTS.md.
2026-04-17 16:03:51 -05:00
Mary Kate Fain bf59fd6dc2 fix: point curated music artists to Listr-maintained list
Update useCuratedMusicArtists to fetch the externally maintained
kind 30000 follow set from npub1nl8r463... instead of looking for
a d:music-artists list from the app curator pubkey. The list is
maintained on Listr and contains 56 curated music artist pubkeys.

Fallback pubkeys updated to a subset of the same list.
2026-04-17 16:00:04 -05:00
Alex Gleason 6bca0922f1 Improve release skill and remove intra-release fix from v2.9.0 notes
Adds a Changelog Quality Checklist to the release skill covering:
- Diffing code between tags (not just reading commit messages)
- Tracing every 'Fixed' entry to its origin commit
- The 'would a user on the previous version notice this?' test
- A worked example of the intra-release bug pattern

Removes the 'expanded emoji picker background' fix from the v2.9.0
changelog -- that bug was both introduced and fixed within the 2.9.0
release window, so no shipped user ever saw it.
2026-04-17 15:57:14 -05:00
Alex Gleason 482c99281c release: v2.9.0 2026-04-17 15:43:30 -05:00
Mary Kate Fain 72a25d09aa feat: show tracks on playlist detail page with full playback and album support
- Add usePlaylistTracks hook to resolve a-tag refs into ordered track events
- Render track list in PlaylistDetail with Play All button and per-track
  playlist playback via playPlaylist()
- Support albums as playlists tagged with t:album, showing release date,
  label, and Disc3 icon
- Add All/Playlists/Albums filter toggle to MusicPlaylistsTab
- Show Album badge on playlist cards in grid views
- Add KIND_HEADER_MAP entries for music tracks (36787) and playlists (34139)
- Fix shellTitleForKind to return 'Playlist Details' for kind 34139
- Document music kinds and album convention in NIP.md
2026-04-17 15:25:06 -05:00
Mary Kate Fain bb2bd15a71 Merge remote-tracking branch 'origin/main' into ditto-music-feed 2026-04-17 15:04:44 -05:00
Mary Kate Fain baf77c95aa Fix missing background on expanded emoji picker in feeds
The full emoji picker in QuickReactMenu had no background because
EmojiPicker sets its shadow DOM background to transparent, and the
wrapper div only had rounded-xl/shadow-xl without a background class.
Added bg-popover and border-border so the picker matches the quick-react
pill bar styling.

Closes #235
2026-04-17 14:55:57 -05:00
shakespeare.diy de8a39f78a feat: add ZapTrax and Sunami to music external app links
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-04-17 14:26:20 -05:00
shakespeare.diy 4f6f6beff3 feat: redesign Music page with curated discovery experience
Replace the generic KindFeedPage-based music feed with a dedicated
music discovery page featuring:

- **Discover tab** (default): Hero card with featured track, horizontal
  scroll of featured tracks from curated artists, genre chip filters,
  recently added track rows, playlists section, artist showcase, and
  "Share Your Music on Nostr" CTA card
- **Tracks tab**: Infinite-scroll list of all music tracks with genre
  filtering, using useFeed for standard pagination
- **Playlists tab**: 2-column grid of kind 34139 playlist cards
- **Artists tab**: 3-column grid of artist profile cards, curated
  artists shown first

Architecture decisions:
- Shared discovery components in src/components/discovery/ (SectionHeader,
  TagChips, HorizontalScroll, ProfileCard, ContentCTACard) designed for
  reuse by podcasts and other future content-type discovery pages
- Music-specific components in src/components/music/ (MusicHeroCard,
  MusicTrackRow, MusicTrackCard, MusicPlaylistCard)
- Single base query (useMusicData) fetches kind 36787 tracks and derives
  genres + artists client-side to avoid redundant relay requests
- Curated artist list via kind 30000 follow set (d:music-artists) from
  curator pubkey, with hardcoded fallback of 9 verified Wavlake artists
- Global by default (no follows/global tab split for discovery)
- Full now-playing state indicators across all components via useAudioPlayer
- Complete loading skeletons and empty states for every section

New files:
- src/components/discovery/{SectionHeader,TagChips,HorizontalScroll,
  ProfileCard,ContentCTACard}.tsx
- src/components/music/{MusicHeroCard,MusicTrackRow,MusicTrackCard,
  MusicPlaylistCard,MusicDiscoverTab,MusicTracksTab,MusicPlaylistsTab,
  MusicArtistsTab}.tsx
- src/hooks/{useCuratedMusicArtists,useMusicData,useMusicPlaylists}.ts
- src/pages/MusicPage.tsx

Modified: src/AppRouter.tsx (route update)
Orphaned: src/pages/MusicFeedPage.tsx (no longer imported)

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-04-17 14:26:13 -05:00
Alex Gleason d224035d28 Merge branch 'main' of gitlab.com:soapbox-pub/ditto 2026-04-17 13:30:22 -05:00
Alex Gleason e914109b4b Show full interactions on reaction/repost/zap/poll-vote detail views
These activity-style detail pages previously rendered only a slim action
row, missing the stats summary (Reposts / Quotes / Likes / Zaps), the
client + full-date row, and the InteractionsModal affordance. Users had
no way to see or browse the interactions the event itself had received.

Extract the stats + date row into a shared JSX block and replace the
four inline action-button grids with PostActionBar, bringing these
detail views in line with the standard post layout while keeping their
compact emoji/icon headers.
2026-04-17 13:29:27 -05:00
Chad Curtis 1e694a6cf8 Remove blobbi post requirement from evolution hatch missions
Add migration logic so users with stale persisted evolution missions
(e.g. containing the removed create_post mission) get their mission
list rebuilt to match current definitions while preserving progress.
2026-04-17 12:30:57 -05:00
Alex Gleason 8e6bd29be0 Include kind 8 badge awards in home, profile, and Badges feeds
Declares kind 8 as a third sub-kind under the existing Badges
ExtraKindDef with its own 'showBadgeAwards' / 'feedIncludeBadgeAwards'
toggles. The home feed and profile feed both derive their kinds list
from getEnabledFeedKinds, so both pick up badge awards automatically.
The Badges page's follows feed is a hardcoded list, so kind 8 is added
there explicitly.

Defaults match existing badge settings: enabled in hardcodedConfig (new
users see them), conservative in InitialSyncGate and TestApp. The
ContentSettings UI auto-generates a new Badge Awards toggle row.

Removes the now-redundant KIND_SPECIFIC_LABELS/ICONS entries for kind 8
since the sub-kind carries that metadata.
2026-04-17 11:39:54 -05:00
Alex Gleason ab1f95f2df Render NIP-58 badge award events (kind 8) in feeds
Badge awards previously only appeared as notifications when you were the
recipient. Now they render as full feed cards — showcase image, badge
metadata, recipient row, and an Accept button for logged-in recipients —
so issuers can share awards and feeds can surface community recognition.

Extracts parseBadgeATag, unslugify, and AcceptBadgeButton out of
NotificationsPage.tsx into shared modules, adds a compact embedded card
for kind 8 nevent references, and wires the kind through NoteCard,
PostDetailPage, CommentContext, and extraKinds registries.
2026-04-17 11:31:57 -05:00
Alex Gleason fe11513a6f Merge branch 'main' of gitlab.com:soapbox-pub/ditto 2026-04-17 11:06:33 -05:00
Alex Gleason 52e42fcd6e Replace hardcoded 'Ditto' with appConfig.appName and appConfig.appId
User-facing display strings now read from config.appName so forks can
rebrand without code changes, and localStorage keys are namespaced by
config.appId so forks running on the same origin don't clobber each
other's preferences. Module-level cache-key constants that previously
hardcoded 'ditto:' have been refactored into hook-scoped reads from
config.appId (via a new getStorageKey() helper). The helpContent FAQ
template now uses {appName} placeholders substituted at read-time
through getFAQCategories(appName)/getFAQItem(appName, id).
2026-04-17 11:01:04 -05:00
Chad Curtis 3aa08ba93e Overhaul compose box UX: inline picker, draft autosave, prevent accidental dismiss
- Move emoji/GIF/sticker picker from popover to inline panel in all contexts
- Add pill-style tab highlights for inline picker (emoji, GIF, stickers)
- Auto-save compose drafts to localStorage with debounce, keyed by context
- Prevent accidental modal dismissal (backdrop click blocked)
- Theme emoji picker to match Ditto design system (search, nav, scrollbar)
- Fix emoji picker width (dynamicWidth + shadow DOM overrides)
- Create StickerPicker component with search bar
- Restructure modal layout: scrollable content, sticky toolbar, fixed picker
- Mobile keyboard handling (blur/refocus textarea around picker)
- Move Tenor attribution into GIF search bar as inline hint
- Bump picker height to 280px
- Auto-focus textarea on modal open (iOS keyboard fix)
- Remove rounded corners from feed compose box
2026-04-17 00:01:38 -05:00
Alex Gleason 9837c23a96 Always add NIP-89 client tag, including on localhost
The HTTPS check was a leftover from when the client name was derived
from the hostname. Now that it comes from appConfig, the tag should
be added unconditionally.
2026-04-16 23:48:07 -05:00
filemon 2b9dd6ed6a Clean up route-reaction hook: fix stale click reuse, remove unused scaffolding, document stable callbacks 2026-04-15 20:22:22 -03:00
filemon 8ccc2c4a7a Merge branch 'main' into feat/improve-blobbi-companion-transitions 2026-04-15 19:37:32 -03:00
filemon b4b94698b4 Merge branch 'main' into feat/improve-blobbi-companion-transitions 2026-04-13 17:27:15 -03:00
filemon 7fa751492b Use closest() for click-target detection, clamp Y to prevent downward gaze
Replace the manual parent-walk loop with closest('a, button,
[role="button"]') — simpler, handles role="button" elements, and
returns null cleanly when no clickable ancestor exists (so only the
raw pointer fallback is used).

Clamp the resolved click-origin Y to 55% of viewport height so Blobbi
looks across toward the sidebar rather than sharply downward when the
clicked item is near the bottom of the screen.
2026-04-13 17:25:24 -03:00
filemon 496dfd48e0 Use live element rect for click-origin glance position
Store the nearest clickable ancestor (<a>/<button>) on pointerdown
alongside the raw coordinates. At route-change time, re-read the
element's getBoundingClientRect() to get its current visible center,
which accounts for any scroll shifts between the click and the React
effect. Falls back to the raw pointer coordinates if the element has
been unmounted.
2026-04-13 17:20:07 -03:00
filemon bae285dd8f Blobbi glances at click origin before looking at main content on route change
Track the last pointerdown position in a ref. On route change, if a
recent click exists (<1s), glance at that position for ~700ms first,
then look at the center-top of the new page for 2-6s. Programmatic
navigation (no recent click) falls back to immediate center-top.

Single-file change in useRouteReaction.ts. No changes to attention,
gaze, or state hooks.
2026-04-13 17:13:33 -03:00
filemon d628619eca Fix Blobbi gaze drifting away during route-reaction attention
Guard makeDecision() so it bails out while attentionTarget is active.
Previously the decision loop could fire during the attending window,
transitioning Blobbi to walking/idle and breaking the attend-ui gaze
lock. The attention-end handler already resumes decisions when it
clears, so this guard is safe.
2026-04-13 16:52:54 -03:00
filemon edc4163852 Eliminate sidebar-look during route transitions by setting immediate center-top attention
On route change, immediately trigger a preliminary attention target at
viewport center-top before the 250ms DOM-mount delay. This ensures the
gaze system never falls to random or mouse-follow mode during the gap,
which previously caused Blobbi to drift leftward toward the sidebar.

The delayed reaction still fires at 250ms and replaces the preliminary
target with a precise DOM-measured position.
2026-04-12 19:36:57 -03:00
filemon 08cc77dbdc Fix Blobbi looking at sidebar during route transitions
Keep previous attention alive during the 250ms route-reaction delay
instead of clearing it immediately. This prevents the gaze system from
falling to random mode (which could point toward the sidebar) while
waiting for the new page's DOM to mount.

- Split cancelReaction into cancelPendingTimeouts (timeouts only) and
  full cancel (timeouts + attention); route changes use the former
- Add bypassCooldown option to triggerAttention so the delayed reaction
  can override the kept-alive attention without being blocked by cooldown
- Stabilize triggerAttention via uiAttentionRef instead of stale closure
2026-04-12 19:25:47 -03:00
filemon 4764202a44 Reset attention cooldown on clearAttention to fix rapid route changes
clearAttention() now resets lastAttentionTimeRef to 0 so that a
triggerAttention call immediately after a forced clear is not silently
rejected by the 1500ms cooldown guard.
2026-04-11 20:43:55 -03:00
filemon 3d951cdaea Simplify route reaction to single center-look with random duration
Remove right-sidebar detection and multi-target chaining. The generic
reaction now fires a single attention target at the top-center of the
main content area for a random 2-6 seconds.

- Remove findRightSidebarPosition() and all sidebar scan logic
- Replace fixed 1200ms duration with random 2000-6000ms
- Compute target position at reaction start via live DOM query
  and window.innerWidth/Height fallback (no stale closure)
- Cancel both pending timeouts and active attention on drag
- Remove viewport prop from hook options (no longer needed)
- Update docstrings to describe the simplified behavior
2026-04-11 20:23:05 -03:00
filemon aadd2908e2 Add generic route-transition reaction for Blobbi companion
On page navigation the companion now briefly pauses and scans the
layout areas that changed. Center content is always scanned first,
followed by the right sidebar if a non-placeholder sidebar is
detected in the DOM.

Implementation:
- useRouteReaction.ts: thin orchestration hook that watches pathname,
  determines changed areas, and chains triggerAttention calls via
  setTimeout. Cancels on new route change, drag, or unmount.
- useBlobbiCompanion.ts: wires the new hook with existing
  triggerAttention/clearAttention from useBlobbiAttention.

No changes to the attention system, state machine, gaze hook, motion
hook, or entry animation. The existing attending state and attend-ui
gaze mode handle all the visual behavior.

Includes an empty ROUTE_REACTIONS map for future per-route overrides.
2026-04-11 19:52:21 -03:00
Alex Gleason 5cb731e557 Merge remote-tracking branch 'origin/main' into wallet 2026-04-07 23:20:35 -05:00
Alex Gleason 5660a1cb1b Add multi-signer PSBT signing for Bitcoin transactions
Split createBitcoinTransaction into buildUnsignedPsbt, signPsbtLocal,
and finalizePsbt so the signing step can be delegated to any signer.

Introduce BtcSigner interface and three extended signer classes:
- NSecSignerBtc: local Taproot signing with the raw private key
- NBrowserSignerBtc: delegates to window.nostr.signPsbt() (NIP-07)
- NConnectSignerBtc: sends sign_psbt RPC over NIP-46 relay channel

useCurrentUser now constructs Btc-extended signers instead of base
ones. signerWithNudge forwards signPsbt when present on the wrapped
signer. SendBitcoinDialog uses the new useBitcoinSigner hook instead
of useNsecAccess, enabling sending from all login types.
2026-04-07 00:16:36 -05:00
Alex Gleason aa618edc43 Document Bitcoin sending flow in WALLET.md 2026-04-06 23:06:34 -05:00
Alex Gleason c49afc7add Add Bitcoin send functionality with 3-step confirmation flow
Implement sending Bitcoin transactions from the wallet page. The send
flow uses a 3-step dialog: form entry, confirmation review, and success
result. Only available for nsec logins since extension/bunker signers
don't expose the raw private key needed for Taproot signing.

Fixes over the reference implementation:
- Send Max correctly subtracts estimated fees
- Address validation via bitcoinjs-lib (checksum + format)
- Fee estimation accounts for actual output count (1 vs 2)
- Confirmation step before broadcast (irreversible action)
- All API calls use mempool.space (consistent with existing code)
- Success links to in-app NIP-73 tx detail page

New files:
- src/hooks/useNsecAccess.ts: extract private key from nsec login
- src/components/SendBitcoinDialog.tsx: 3-step send dialog

New functions in src/lib/bitcoin.ts:
- fetchUTXOs, getFeeRates, broadcastTransaction
- validateBitcoinAddress, estimateFee, maxSendable
- createBitcoinTransaction (PSBT construction + Taproot signing)
- npubToBitcoinAddress, btcToSats
2026-04-06 21:35:13 -05:00
Alex Gleason 64bac10758 Fix baseline alignment of Bitcoin txid/address in comment context rows
The link was using inline-flex items-center with child spans at different
font sizes (text-sm 'transaction' + text-xs monospace hash). Flexbox
center-aligns by box center, not text baseline, causing the smaller text
to appear shifted up. Changed to plain inline text flow so the browser's
natural baseline alignment handles mixed font sizes correctly.
2026-04-06 21:14:37 -05:00
Alex Gleason e74cd1efbb Add NIP-73 Bitcoin transaction and address detail pages
Integrate Bitcoin content into the /i/* external content system using NIP-73
identifiers (bitcoin:tx:{txid} and bitcoin:address:{address}).

- Add bitcoin-tx and bitcoin-address types to ExternalContent parser
- Create BitcoinTxHeader with mempool.space-style inputs/outputs flow view
- Create BitcoinAddressHeader with balance, stats, and recent transactions
- Add useBitcoinTx and useBitcoinAddress hooks (mempool.space Esplora API)
- Switch all Bitcoin API calls from blockstream.info to mempool.space
- Update WalletPage to link transactions to /i/bitcoin:tx:{txid} pages
- Remove unused blockExplorerAddress/blockExplorerTx config fields
- Add compact Bitcoin previews for embedded note contexts
2026-04-06 20:58:17 -05:00
Alex Gleason 773592f9dd Make block explorer URLs configurable via AppConfig URI templates
Add blockExplorerAddress and blockExplorerTx fields as RFC 6570 URI
templates with {address} and {txid} variables respectively. Default to
mempool.space instead of blockstream.info. Wallet page uses UriTemplate
to fill the configured templates.
2026-04-06 19:57:28 -05:00
Alex Gleason 995088842a Hide transactions button and list when there are no transactions 2026-04-06 19:49:52 -05:00
Alex Gleason 4abc45a849 Replace explorer link with collapsible Transactions toggle
Transactions button with chevron replaces the 'View on explorer' link.
Clicking toggles the tx list open/closed with a smooth accordion slide
using CSS grid-template-rows animation. Chevron rotates on open.
2026-04-06 19:43:56 -05:00
Alex Gleason 5ce2d3d8b4 Add transaction history to wallet page
Fetch transactions from Blockstream Esplora API, compute net amount per
tx relative to the user's address, and display as a list below the QR
code. Each row shows receive/send direction, relative date, USD amount
(with BTC underneath), and links to the block explorer. Includes loading
skeletons and empty state.
2026-04-06 19:34:16 -05:00
Alex Gleason 4391743695 Show wallet balance in USD with BTC underneath
Fetch BTC/USD price from CoinGecko (refreshes every 60s). Display USD
as the hero balance, BTC amount as the secondary line. Remove sats
display entirely. Pending amounts also shown in USD.
2026-04-06 19:28:21 -05:00
Alex Gleason a145f92bcb Simplify wallet page to modern crypto wallet UX
Remove outer Balance card wrapper, stats grid, and How It Works section.
Balance is now the hero element, centered with QR code below and a
compact pill-shaped address with inline copy. Clean, minimal layout.
2026-04-06 19:20:05 -05:00
Alex Gleason 2c853ff02a Rename page title and header from 'Bitcoin Wallet' to 'Wallet' 2026-04-06 19:13:02 -05:00
Alex Gleason c8d46b3611 Rename sidebar label from 'Bitcoin Wallet' to 'Wallet' 2026-04-06 19:10:49 -05:00
Alex Gleason a75fef039d Add Bitcoin Wallet to the left sidebar 2026-04-06 19:06:39 -05:00
Alex Gleason cf6fcc353c Add Bitcoin wallet page deriving Taproot address from Nostr pubkey
Derive a bc1p... Taproot address directly from the user's Nostr public key
(both use secp256k1 x-only keys) and display balance via Blockstream API.
Includes QR code, copy-to-clipboard, balance with pending detection, and
a WALLET.md documenting the derivation algorithm. Sending is not yet
implemented.
2026-04-06 19:04:17 -05:00
594 changed files with 53719 additions and 24975 deletions
+73
View File
@@ -0,0 +1,73 @@
---
name: capacitor-compat
description: Browser-API gotchas inside Capacitor's WKWebView (iOS) and Android WebView — which common web APIs silently fail, the downloadTextFile/openUrl helpers that bridge web and native, platform detection, and the installed Capacitor plugins. Load when writing code that interacts with file downloads, external URLs, or platform-specific behavior.
---
# Capacitor Compatibility
Ditto runs inside Capacitor's WKWebView on iOS and WebView on Android. Several common web APIs **do not work** in this environment. Always account for native platforms when writing code that interacts with browser-specific features.
## What Doesn't Work in WKWebView (iOS)
- **`<a download>` file downloads** — programmatically creating an anchor with `a.download` and clicking it silently fails. WKWebView ignores the `download` attribute entirely.
- **`<a target="_blank">` new tabs** — programmatic clicks on anchors with `target="_blank"` are blocked. There are no tabs in a native app.
- **`window.open()`** — may be blocked or behave unexpectedly without user-gesture context.
For a deeper list of Apple Lockdown Mode restrictions that also affect WKWebView, load the **`lockdown-mode`** skill.
## File Downloads and URL Opening
`src/lib/downloadFile.ts` provides two utilities that handle the web/native split automatically. **Always use these** instead of manually constructing anchors.
### `downloadTextFile(filename, content)`
Saves a text file to the user's device. On web it uses the `<a download>` pattern. On native it writes to the Capacitor cache directory via `@capacitor/filesystem` and presents the native share sheet via `@capacitor/share`.
```typescript
import { downloadTextFile } from '@/lib/downloadFile';
await downloadTextFile('backup.txt', fileContents);
```
### `openUrl(url)`
Opens a URL in a new browser tab on web, or presents the native share sheet on Capacitor.
```typescript
import { openUrl } from '@/lib/downloadFile';
await openUrl('https://example.com/image.jpg');
```
**CRITICAL**: Never use `document.createElement('a')` with `.click()` for downloads or opening URLs. The utilities above work correctly on all platforms; manual anchors silently fail on iOS.
## Detecting Native Platforms
Use `Capacitor.isNativePlatform()` from `@capacitor/core` when you need platform-specific behavior:
```typescript
import { Capacitor } from '@capacitor/core';
if (Capacitor.isNativePlatform()) {
// iOS or Android
} else {
// Web browser
}
```
Reserve platform forks for cases where behavior genuinely differs (share sheets, secure storage, haptics). Most UI code should stay platform-agnostic.
## Installed Capacitor Plugins
- `@capacitor/app` — app lifecycle events (deep links, back button)
- `@capacitor/core` — core runtime and platform detection
- `@capacitor/filesystem` — read/write files on the native filesystem
- `@capacitor/haptics` — native haptics
- `@capacitor/keyboard` — keyboard control (hide accessory bar, etc.)
- `@capacitor/local-notifications` — schedule local push notifications
- `@capacitor/share` — native share sheet
- `@capacitor/status-bar` — control the native status-bar style
- `@capgo/capacitor-autofill-save-password` — iOS keychain autofill for nsec
- `capacitor-secure-storage-plugin` — OS-level secure storage (iOS Keychain / Android KeyStore)
After adding or removing plugins, run `npm run cap:sync` to update the native projects.
+350
View File
@@ -0,0 +1,350 @@
---
name: ci-cd-publishing
description: Ditto's release and publishing pipeline — cutting a version tag, Zapstore APK publishing with NIP-46 bunker auth, nsite web deploys via nsyte, and Google Play AAB uploads via fastlane supply. Includes GitLab CI variable setup and credential rotation.
---
# CI/CD Pipeline and Publishing
Ditto uses GitLab CI (`.gitlab-ci.yml`) to run tests on every commit, deploy the web app to nsite on every default-branch push, and build + publish Android binaries to Zapstore and Google Play on every tag. Load this skill when setting up CI credentials, rotating a signing key, diagnosing a failed publish, or adding a new publishing target.
## Pipeline Overview
| Stage | Runs on | Job |
|-----------|---------------------------|-----------------------------------------|
| `test` | every commit (not tags) | `npm run test` |
| `deploy` | default branch only | `deploy-nsite` (Vite build → nsyte) |
| `build` | tags only | `build-apk` (signed APK + AAB) + `build-ipa` (signed IPA on the Mac runner) |
| `release` | tags only | GitLab Release with APK / AAB / IPA links |
| `publish` | tags only | `publish-zapstore` + `publish-google-play` + `publish-app-store` |
## Creating a Release
Releases are triggered by pushing a version tag:
```bash
npm run release
```
This creates a tag in the format `v2026.03.14+abc1234` (date + short commit hash) and pushes it to GitLab, which triggers the `build-apk`, `release`, `publish-zapstore`, and `publish-google-play` jobs.
For the full versioning / changelog / native-build workflow, load the **`release`** skill.
## Zapstore Publishing
The `publish-zapstore` CI job uploads signed APKs to [Zapstore](https://zapstore.dev/) using the [`zsp`](https://github.com/zapstore/zsp) CLI and NIP-46 bunker signing via Amber.
**Configuration files:**
- `zapstore.yaml` — app metadata for Zapstore (name, tags, icon, supported NIPs)
- `.gitlab-ci.yml` — the `publish-zapstore` job definition
**GitLab CI/CD variables** (Settings → CI/CD → Variables):
| Variable | Description | Protected | Masked | Raw |
|---|---|---|---|---|
| `ZAPSTORE_BUNKER_URL` | NIP-46 bunker URL (`bunker://<pubkey>?relay=...`). No `secret` param needed after initial auth. | Yes | No | Yes |
| `ZAPSTORE_CLIENT_KEY` | Hex private key used as the NIP-46 client identity for bunker communication | Yes | Yes | Yes |
| `ANDROID_KEYSTORE_BASE64` | Base64-encoded Android signing keystore | Yes | Yes | Yes |
| `KEYSTORE_PASSWORD` | Android keystore password | Yes | Yes | Yes |
| `KEY_PASSWORD` | Android key password | Yes | Yes | Yes |
### How NIP-46 bunker auth works in CI
NIP-46 bunker signing requires two keys: the **user's key** (held by Amber) and a **client key** (the CI runner's identity). The bunker authorizes specific client pubkeys — once authorized, the client can request signatures without re-approval.
The `publish-zapstore` job restores the client key from `ZAPSTORE_CLIENT_KEY` into `~/.config/zsp/bunker-keys/<bunker-pubkey>.key` before running `zsp`, so the bunker recognizes the CI runner as an already-authorized client.
### Initial setup (one-time)
Run the NIP-46 client-initiated auth script:
```bash
node scripts/nip46-auth.mjs
```
This generates a `nostrconnect://` URI. Import/paste it into Amber and approve the connection. The script outputs the `bunker://` URI and client key hex, and writes the client key to `~/.config/zsp/bunker-keys/`. Update the GitLab CI/CD variables with the printed values.
Options:
- `--relay <url>` — relay for NIP-46 communication (default: `wss://relay.ditto.pub`)
- `--name <name>` — app name shown to the signer (default: `Ditto`)
- `--timeout <sec>` — how long to wait for approval (default: 300)
After authorization, the bunker recognizes the client key and no secret or manual approval is needed for CI runs. If the client key is rotated, run the script again and update the GitLab variables.
## nsite Publishing
The `deploy-nsite` CI job deploys the Vite build to [nsite](https://nsite.run) on every push to the default branch using [nsyte](https://github.com/sandwichfarm/nsyte). The job uploads `dist/` to Blossom servers and publishes site manifest events to Nostr relays.
nsyte uses a NIP-46 bunker credential called **nbunksec** — a bech32-encoded string bundling the bunker pubkey, client secret key, and relay info into a single self-contained token. It's passed to nsyte via `--sec`.
**GitLab CI/CD variables:**
| Variable | Description | Protected | Masked | Raw |
|---|---|---|---|---|
| `NSITE_NBUNKSEC` | nbunksec credential from `nsyte ci`. Must start with `nbunksec1`. | Yes | Yes | Yes |
### Initial setup (one-time)
1. Install nsyte locally:
```bash
curl -fsSL https://nsyte.run/get/install.sh | bash
```
2. Generate the CI credential:
```bash
nsyte ci
```
This guides you through connecting a NIP-46 bunker (e.g. Amber) and outputs an `nbunksec1...` string. The credential is shown only once.
3. Add the `nbunksec1...` value as `NSITE_NBUNKSEC` in GitLab CI/CD settings. Mark it as **Protected** and **Masked**.
### Configured relays and servers
Relays the deploy job publishes to:
- `wss://relay.ditto.pub`
- `wss://relay.nsite.lol`
- `wss://relay.dreamith.to`
- `wss://relay.primal.net`
Blossom servers:
- `https://blossom.primal.net`
- `https://blossom.ditto.pub`
- `https://blossom.dreamith.to`
The `--use-fallback-relays` and `--use-fallback-servers` flags include nsyte's built-in defaults for broader coverage. The `--fallback "/index.html"` flag enables SPA client-side routing.
### Credential rotation
To rotate the nsite credential:
1. Revoke the old bunker connection in your signer app.
2. Run `nsyte ci` again to generate a new `nbunksec1...` string.
3. Update the `NSITE_NBUNKSEC` variable in GitLab CI/CD settings.
## Google Play Publishing
The `publish-google-play` CI job uploads Android AABs to [Google Play](https://play.google.com/store/apps/details?id=pub.ditto.app) using [fastlane supply](https://docs.fastlane.tools/actions/supply/). It runs after a successful AAB build and uploads directly to the production track.
**GitLab CI/CD variables:**
| Variable | Description | Protected | Masked | Raw |
|---|---|---|---|---|
| `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON` | **Base64-encoded** contents of the Google Play API service account key JSON. The CI job decodes with `base64 -d` before passing to `fastlane supply`. | Yes | Yes | No |
### Initial setup (one-time)
1. Create or reuse a project in [Google Cloud Console](https://console.cloud.google.com/projectcreate).
2. Enable the [Google Play Developer API](https://console.developers.google.com/apis/api/androidpublisher.googleapis.com/) for that project.
3. In Google Cloud Console, go to [Service Accounts](https://console.cloud.google.com/iam-admin/serviceaccounts), create a service account, and download a JSON key file for it.
4. In Google Play Console, go to [Users & Permissions](https://play.google.com/console/users-and-permissions), click **Invite new users**, enter the service account email, and grant it permission to manage releases for `pub.ditto.app`.
5. **Base64-encode** the key file:
```bash
# Linux
base64 -w0 service-account.json
# macOS
base64 -i service-account.json | tr -d '\n'
```
6. Add the base64-encoded value as `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON` in GitLab CI/CD settings. Mark it as **Protected** and **Masked**. **Do not paste the raw JSON** — the CI script expects base64 and will fail to decode a raw value.
### Key points
- The job uploads the signed **AAB** (not APK) — Google Play requires App Bundles.
- Uploads go directly to the **production** track. Google's review process still applies before the update reaches users.
- Metadata, screenshots, and store-listing description are managed in the Play Console (the job uses `--skip_upload_metadata`, `--skip_upload_images`, `--skip_upload_screenshots`).
- **Changelogs ("What's new in this version")** are uploaded from `android/fastlane/metadata/android/en-US/changelogs/<versionCode>.txt`, generated at CI time from the release summary paragraph in `CHANGELOG.md`. See "Release notes pipeline" below.
- The same signing keystore used for Zapstore is reused here (`ANDROID_KEYSTORE_BASE64`, `KEYSTORE_PASSWORD`, `KEY_PASSWORD`).
## App Store Publishing
Ditto's iOS pipeline is split across two jobs:
- **`build-ipa`** (stage `build`, `tags: [macos]`) runs on the self-hosted Mac runner. Decodes the App Store Connect API key, fetches the encrypted distribution cert + provisioning profile via fastlane match, builds the web assets, runs `cap sync ios`, stamps the marketing version into `project.pbxproj`, then `fastlane build_ipa` produces a signed App Store IPA at `artifacts/Ditto.ipa`. The IPA is uploaded to the GitLab Generic Packages registry as `Ditto-${CI_COMMIT_TAG}.ipa` (mirrors how `build-apk` publishes the APK and AAB) and exposed as a CI artifact for downstream jobs.
- **`publish-app-store`** (stage `publish`, `tags: [macos]`) also runs on the self-hosted Mac runner. Consumes the IPA artifact via `needs: [build-ipa]` and the release-notes artifact via `needs: [release-notes]`. Decodes the API key, copies the release-notes summary into `ios/fastlane/metadata/en-US/release_notes.txt`, and runs `fastlane submit_release` which calls `deliver` to upload metadata + push the prebuilt IPA + auto-submit for App Store review. **macOS is required** even though the IPA is already signed: `fastlane deliver` shells out to Apple's iTMSTransporter / altool to upload the binary, and those tools only ship inside Xcode. A Linux container ran into `No such file or directory @ dir_chdir0` from `JavaTransporterExecutor#execute` because `Helper.itms_path` resolved to a missing Xcode path.
The Mac runner is therefore used for both iOS jobs. For runner administration (operating the Mac, restarting the agent, viewing logs, rotating signing certs), load the **`mac-runner`** skill.
**Configuration files:**
- `ios/fastlane/Fastfile` — exposes four lanes:
- `build_ipa` — setup_ci → match (readonly, with API key) → increment_build_number → build_app. Used by CI's `build-ipa`.
- `submit_release` — reads `IPA_PATH` env var, calls deliver against the prebuilt IPA. Used by CI's `publish-app-store`.
- `release` — combines build_ipa + submit_release; convenience for local one-shot runs.
- `submit_only` — debug lane that skips build/upload and only runs deliver against an already-uploaded build (set `BUILD_NUMBER` + `VERSION` env vars). See the `mac-runner` skill.
- `ios/fastlane/Appfile` — bundle identifier and team ID
- `ios/fastlane/Matchfile` — points at the shared `soapbox-pub/certificates` repo
- `ios/fastlane/metadata/en-US/release_notes.txt` — placeholder; CI overwrites it with the release summary paragraph from `CHANGELOG.md` per release
- `.gitlab-ci.yml` — `build-ipa` and `publish-app-store` both run on the Mac runner (`tags: [macos]`)
**Code signing storage**: a private GitLab repo `soapbox-pub/certificates` holds encrypted distribution certs and provisioning profiles, managed by [fastlane match](https://docs.fastlane.tools/actions/match/). Match handles cert/profile lifecycle: one passphrase decrypts everything; the same repo can hold signing material for multiple Soapbox iOS apps under team `GZLTTH5DLM`.
**App Store Connect auth**: a long-lived [App Store Connect API key](https://developer.apple.com/documentation/appstoreconnectapi/creating-api-keys-for-app-store-connect-api) (`.p8` file + key ID + issuer ID) authenticates `match`, `deliver`, and `pilot`. Avoids 2FA prompts that would interrupt CI.
**Distribution**: `submit_for_review: true` automatically pushes the build into Apple's review queue once uploaded. `automatic_release: false` keeps a human-controlled final gate — once Apple approves, you click "Release" in the App Store Connect web UI to publish to users. To remove the manual gate, flip `automatic_release` to `true` in `ios/fastlane/Fastfile`.
**Release notes**: copied from the `release-notes` job's artifact `artifacts/release-notes-summary.txt` (the leading plaintext paragraph of the version's `CHANGELOG.md` section) into `ios/fastlane/metadata/en-US/release_notes.txt`, uploaded by `deliver` as the App Store "What's New in This Version" text. See "Release notes pipeline" below.
**IPA distribution beyond the App Store**: `build-ipa` uploads the signed IPA to the GitLab Generic Packages registry, and the `release` job links it from the GitLab Release page. The IPA is signed with the App Store distribution profile, so it isn't directly sideloadable — installation goes through Apple's review process — but having it as a stable artifact lays the groundwork for AltStore or ad-hoc distribution later (which would require a separate provisioning profile).
**GitLab CI/CD variables:**
| Variable | Description | Protected | Masked | Raw |
|---|---|---|---|---|
| `MATCH_PASSWORD` | Symmetric passphrase used by match to encrypt/decrypt certs and profiles. The single most important secret — losing it makes the cert repo unreadable. | Yes | Yes | Yes |
| `MATCH_GIT_BASIC_AUTHORIZATION` | Base64 of `username:deploy-token` for HTTPS clone of the certificates repo. Generated from a `read_repository`-scoped deploy token on `soapbox-pub/certificates`. | Yes | Yes | Yes |
| `APP_STORE_CONNECT_API_KEY_ID` | App Store Connect API key ID (10 chars). | Yes | No | Yes |
| `APP_STORE_CONNECT_API_KEY_ISSUER_ID` | App Store Connect issuer ID (UUID). | Yes | No | Yes |
| `APP_STORE_CONNECT_API_KEY_P8_BASE64` | Base64-encoded contents of the `.p8` private key file. CI decodes with `base64 -d` into `~/.private_keys/AuthKey_<KEY_ID>.p8` and removes it in `after_script`. | Yes | Yes | Yes |
| `FASTLANE_KEYCHAIN_PASSWORD` | Password for the ephemeral keychain `setup_ci` creates per build. Random per setup; keep stable across runs. | Yes | Yes | Yes |
### Initial setup (one-time)
1. **Provision the Mac runner.** See the **`mac-runner`** skill for hardware/launchd setup, Xcode, Homebrew, fastlane, and `gitlab-runner` registration.
2. **Create the App Store Connect API key.** Log in to [App Store Connect](https://appstoreconnect.apple.com) → Users and Access → Integrations → App Store Connect API → Generate. Use the **App Manager** role (sufficient for `deliver`'s upload + submit-for-review). Download the `.p8` file (one-time download — Apple won't show it again). Note the **Key ID** (10-char string next to the key) and the **Issuer ID** (UUID at the top of the API page).
Set the three GitLab CI variables:
```bash
# Replace <ISSUER_ID>, <KEY_ID>, and the path to your .p8
curl -X POST -H "PRIVATE-TOKEN: $GITLAB_TOKEN" \
"https://gitlab.com/api/v4/projects/$PROJECT_ID/variables" \
--data-urlencode "key=APP_STORE_CONNECT_API_KEY_ISSUER_ID" \
--data-urlencode "value=<ISSUER_ID>" \
--data-urlencode "protected=true" --data-urlencode "raw=true"
# repeat for APP_STORE_CONNECT_API_KEY_ID
# for the .p8, base64 first:
base64 -i AuthKey_<KEY_ID>.p8 | tr -d '\n' # paste this as APP_STORE_CONNECT_API_KEY_P8_BASE64 (masked)
```
3. **Create the certificates repo.** A private GitLab repo at `soapbox-pub/certificates` holds match-encrypted certs/profiles. Create a project deploy token on it (Settings → Repository → Deploy tokens) with `read_repository` scope. Encode `username:token` as base64 → set as `MATCH_GIT_BASIC_AUTHORIZATION` (protected, masked, raw).
4. **Generate `MATCH_PASSWORD` and `FASTLANE_KEYCHAIN_PASSWORD`.** Both are arbitrary strong random strings — `openssl rand -base64 32 | tr -d '=+/' | head -c 32` works. Store them as protected, masked GitLab variables.
5. **Bootstrap match certs via a one-shot CI job** (preferred over running match locally — avoids the macOS keychain UI permission dialogs that fastlane bug [#15185](https://github.com/fastlane/fastlane/issues/15185) trips on newer macOS):
a. Create a temporary write-scoped GitLab variable. The deploy token is `read_repository`; for the initial cert creation match needs to push. Encode `username:write-pat` as base64 and set it as `MATCH_GIT_BASIC_AUTHORIZATION_WRITE` (Protected, Masked, Raw).
b. Add a temporary `setup-match` job to `.gitlab-ci.yml` that runs on the macos runner with `setup_ci` (which creates an ephemeral keychain — bypasses the GUI permission issue):
```yaml
setup-match:
stage: publish
tags: [macos]
rules:
- if: $SETUP_MATCH == "1"
when: manual
script:
- export ASC_KEY_PATH="$HOME/.private_keys/AuthKey_${APP_STORE_CONNECT_API_KEY_ID}.p8"
- mkdir -p "$HOME/.private_keys" && chmod 700 "$HOME/.private_keys"
- echo "$APP_STORE_CONNECT_API_KEY_P8_BASE64" | base64 -d > "$ASC_KEY_PATH"
- chmod 600 "$ASC_KEY_PATH"
- cd ios
- export MATCH_GIT_BASIC_AUTHORIZATION="$MATCH_GIT_BASIC_AUTHORIZATION_WRITE"
- unset APP_STORE_CONNECT_API_KEY_PATH || true
- |
cat > Fastfile.setup <<'RUBY'
default_platform(:ios)
platform :ios do
lane :setup do
setup_ci
api_key = {
key_id: ENV.fetch("APP_STORE_CONNECT_API_KEY_ID"),
issuer_id: ENV.fetch("APP_STORE_CONNECT_API_KEY_ISSUER_ID"),
key: File.binread(ENV.fetch("ASC_KEY_PATH")),
duration: 1200,
in_house: false,
}
match(type: "appstore", readonly: false, api_key: api_key, force_for_new_devices: true)
end
end
RUBY
- mv fastlane/Fastfile fastlane/Fastfile.bak
- mv Fastfile.setup fastlane/Fastfile
- fastlane setup
- mv fastlane/Fastfile.bak fastlane/Fastfile
after_script:
- rm -f "$HOME/.private_keys"/AuthKey_*.p8 || true
```
c. Trigger the pipeline manually with `SETUP_MATCH=1`:
```bash
curl -X POST -H "PRIVATE-TOKEN: $GITLAB_TOKEN" \
"https://gitlab.com/api/v4/projects/$PROJECT_ID/pipeline" \
--data-urlencode "ref=main" \
--data-urlencode "variables[][key]=SETUP_MATCH" \
--data-urlencode "variables[][value]=1"
# Then play the manual setup-match job
```
d. Once the job succeeds (cert + profile pushed to the certificates repo), **delete the `setup-match` job from `.gitlab-ci.yml` and the `MATCH_GIT_BASIC_AUTHORIZATION_WRITE` variable**. They're only needed for bootstrap.
### Yearly cert renewal
Apple distribution certs expire annually. Renewal is one command per year, run on any Mac:
```bash
cd ~/Projects/ditto/ios
fastlane match nuke distribution # revokes old cert in Apple's portal, removes from match repo
fastlane match appstore # creates new cert + profile, encrypts, commits, pushes
```
CI's next tag run picks up the new files automatically (`match(... readonly: true)`).
### Disaster recovery (Mac dies / new developer joins)
```bash
git clone https://gitlab.com/soapbox-pub/ditto.git
cd ditto/ios
fastlane match appstore --readonly # decrypts existing certs/profiles using MATCH_PASSWORD
```
No re-issuance of certs needed — the cert repo is the source of truth.
### App Store Connect API key rotation
App Store Connect API keys can be revoked anytime. To rotate:
1. App Store Connect → Users and Access → Integrations → App Store Connect API → Generate new key
2. Download the new `.p8`, note the new key ID
3. Update `APP_STORE_CONNECT_API_KEY_ID` and `APP_STORE_CONNECT_API_KEY_P8_BASE64` in GitLab variables
4. (Issuer ID stays the same — it's per-team, not per-key)
5. Revoke the old key in App Store Connect
### Key points
- `build-ipa` (Mac) produces a signed **IPA** (App Store distribution format) and uploads it to GitLab's Generic Packages registry. `publish-app-store` (also Mac) submits it to Apple via `deliver`.
- Builds go to **App Store Connect**, automatically submit for review, but do **not** auto-release after approval. The final "Release" click is manual in the web UI.
- Marketing version comes from the git tag (`v2.1.0` → `MARKETING_VERSION = 2.1.0`); build number comes from `CI_PIPELINE_IID`.
- Release notes ("What's New in This Version") come from the release-notes summary paragraph (see "Release notes pipeline" below).
- `setup_ci` (in `build-ipa`) creates an ephemeral keychain per build, so the runner never touches the login keychain — works whether or not a GUI session is logged in.
- `publish-app-store` does no code signing, but it still needs macOS: `fastlane deliver` shells out to Apple's iTMSTransporter / altool to upload the binary, and those tools only ship inside Xcode.
## Release notes pipeline
Release notes for all three storefronts (App Store, Google Play, GitLab Release page) and the in-app version-update toast are derived from a single source: `CHANGELOG.md`.
**The `release-notes` job** (stage `build`, default `node:22` image, runs only on `v*` tags) calls `scripts/extract-release-notes.mjs` twice and publishes two artifacts:
- `artifacts/release-notes.md` — the full section for this version (summary paragraph + `### Added` / `### Changed` / etc. lists). Used as the GitLab Release description.
- `artifacts/release-notes-summary.txt` — only the leading plaintext paragraph (max 500 chars by convention). Used as the App Store / Play Store "What's new" text. Falls back to `Ditto vX.Y.Z` if the section has no summary paragraph.
**Downstream consumers** all pull from the `release-notes` job via `needs:`:
| Consumer | Job | Artifact used |
|---|---|---|
| GitLab Release description | `release` | `release-notes.md` |
| App Store "What's New" | `publish-app-store` | `release-notes-summary.txt` → copied to `ios/fastlane/metadata/en-US/release_notes.txt` → uploaded by `deliver` |
| Play Store "What's new" | `publish-google-play` | `release-notes-summary.txt` → copied to `android/fastlane/metadata/android/en-US/changelogs/<versionCode>.txt` → uploaded by `supply` |
| In-app toast | `src/components/VersionCheck.tsx` (runtime) | Re-parses `public/CHANGELOG.md` via `parseChangelog()` and reads `entry.summary` (with a fallback to the legacy first-bullet behavior) |
**The summary format** is documented in the `release` skill — a single plaintext paragraph immediately under the `## [X.Y.Z] - YYYY-MM-DD` heading, before any `### Category`. The script enforces nothing on the parser side; CI emits a warning when the summary exceeds 500 chars but does not fail the build.
**To preview locally** what each storefront will receive:
```bash
node scripts/extract-release-notes.mjs vX.Y.Z # full GitLab Release body
node scripts/extract-release-notes.mjs vX.Y.Z --summary # storefront blurb
```
+83
View File
@@ -0,0 +1,83 @@
---
name: file-uploads
description: Upload files (images, media, attachments) from the browser to a Blossom server via the useUploadFile hook, and attach them to Nostr events with NIP-94 imeta tags.
---
# File Uploads on Nostr
This project includes a `useUploadFile` hook that uploads files to Blossom servers and returns NIP-94-compatible tags. Use it whenever a feature needs to accept a user-provided file (avatars, banners, post attachments, etc.).
## The `useUploadFile` Hook
```tsx
import { useUploadFile } from "@/hooks/useUploadFile";
function MyComponent() {
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
const handleUpload = async (file: File) => {
try {
// Returns an array of NIP-94-compatible tags.
// The first tag is the `url` tag; its second element is the file URL.
const tags = await uploadFile(file);
const url = tags[0][1];
// ...use the url
} catch (error) {
// ...handle errors (show a toast, etc.)
}
};
// ...rest of component
}
```
The hook is a TanStack Query mutation, so `isPending` can drive loading UI and `mutateAsync` integrates cleanly with `async`/`await` flows.
## Attaching Files to Events
### Kind 0 (profile metadata)
Use the plain URL in the relevant JSON field:
```ts
const tags = await uploadFile(file);
const url = tags[0][1];
createEvent({
kind: 0,
content: JSON.stringify({ ...existingMetadata, picture: url }),
});
```
### Kind 1 (text notes) and other content events
Append the URL to `content`, and add one `imeta` tag per file. `imeta` carries the NIP-94 metadata (mime type, dimensions, blurhash, etc.) that the uploader returned:
```ts
const tags = await uploadFile(file); // e.g. [["url", "https://..."], ["m", "image/png"], ["dim", "1024x768"], ...]
const url = tags[0][1];
// Flatten the NIP-94 tags into a single imeta tag value.
const imeta = tags.map(([name, value]) => `${name} ${value}`);
createEvent({
kind: 1,
content: `Check this out ${url}`,
tags: [["imeta", ...imeta]],
});
```
Repeat the pattern (one `imeta` tag per file) for multiple attachments.
## Common Patterns
- **Avatar / banner pickers:** wrap an `<input type="file" accept="image/*">` and call `uploadFile` on change; on success, update the relevant profile field and publish a kind 0 event.
- **Post composers:** call `uploadFile` for each selected file before publishing the note, then build `imeta` tags alongside `content`.
- **Progress UI:** use `isPending` from the mutation to disable the submit button and show a spinner or skeleton.
- **Error handling:** wrap `uploadFile` in `try/catch` and surface failures via `useToast` — network and Blossom-server errors are common and should never break the UI.
## Constraints
- The hook requires a logged-in user (Blossom auth is signed by the user's signer). Guard uploads behind `useCurrentUser`.
- Don't store or display raw `File` objects after upload — always use the returned URL.
- Large files may take time; prefer `mutateAsync` over `mutate` so the caller can `await` completion before publishing an event that references the URL.
+66
View File
@@ -0,0 +1,66 @@
---
name: git-workflow
description: Ditto's git conventions — validating changes before committing, writing commit messages that match project style, and attributing regressions with a Regression-of trailer so the release changelog skill can filter them from the "Fixed" section.
---
# Git Workflow
Ditto expects every completed task to end with a git commit. This skill covers the pre-commit validation loop, commit-message conventions, and the `Regression-of:` trailer used by the release skill to filter intra-release regressions from the changelog.
## Pre-commit Validation
**Your task is not finished until the code type-checks and builds without errors.** In priority order:
1. **Type Checking** (required) — `tsc --noEmit`
2. **Building/Compilation** (required) — `vite build`
3. **Linting** (recommended; fix anything critical) — `eslint`
4. **Tests** (if available) — `vitest run`
5. **Git commit** (required)
The full `npm run test` script runs all of these in sequence; running it is equivalent to steps 14.
## Using Git
Use `git status` and `git diff` to review changes, and `git log` to learn the project's commit-message conventions before writing a new one. If you make a mistake, `git checkout` restores files.
When your changes are complete and validated, create a commit with a message that focuses on **why** the change was made (not just **what**). Summaries should fit on one line; a body is warranted for non-trivial changes.
**Always commit when you are finished making changes. Non-negotiable — every completed task ends with a commit. Don't leave uncommitted changes.**
## Contributing Guide
When preparing changes for a merge request, also follow the guidelines in `CONTRIBUTING.md`. It includes a self-review checklist (step 8) that should be run against your diff before committing.
## Attributing Regressions
When a commit fixes a bug that was introduced by an identifiable prior commit, add a `Regression-of:` trailer at the bottom of the commit message body referencing the offending commit's short SHA:
```
Fix missing background on expanded emoji picker in feeds
The compose box overhaul accidentally dropped the bg-background class
when refactoring the picker out of QuickReactMenu.
Regression-of: 3aa08ba9
```
This is a standard Git trailer (compatible with `git interpret-trailers`) that records the cause-and-effect link directly in history. It is consumed by the `release` skill to detect intra-release regressions and exclude them from the changelog's "Fixed" section, and it makes future debugging and post-mortems substantially faster.
### When to add it
- The commit fixes a bug (not a new feature, refactor, or doc change).
- The introducing commit is identifiable with reasonable effort.
### When to skip it
- The bug is pre-existing with no clear single origin.
- The behavior was always wrong (no regression).
- The introducing commit cannot be determined after a brief search.
### Finding the introducing commit
- `git log -S '<removed-or-changed-string>'` — find commits that touched a specific string.
- `git log --oneline -- path/to/file` — list all commits touching a file.
- `git blame -L <start>,<end> -- path/to/file` — find who last changed specific lines.
This convention is **strongly recommended but not required.** When the origin is non-obvious, prioritize shipping the fix over hunting indefinitely.
+249
View File
@@ -0,0 +1,249 @@
---
name: mac-runner
description: Operate the self-hosted GitLab Runner on the Mac that builds Ditto's iOS IPA. Covers SSH access, restarting the runner, viewing logs, updating Xcode, debugging fastlane locally, and rotating match certificates.
---
# Mac Runner Operations
Ditto's iOS pipeline runs two CI jobs on a self-hosted GitLab Runner on a MacBook in the rack: `build-ipa` (signs and builds the IPA via Xcode + fastlane match) and `publish-app-store` (uploads the IPA via `fastlane deliver`, which shells out to Apple's iTMSTransporter — that tool only ships inside Xcode, so this job can't run on Linux). This skill covers operating the Mac.
This skill covers operating the runner: SSH access, restarting after crashes or Xcode updates, watching logs, debugging fastlane locally, and rotating the match certificates. For initial provisioning, App Store Connect API key creation, and GitLab CI variable setup, load the **`ci-cd-publishing`** skill.
## Quick reference
| Need | Command |
|---|---|
| SSH in | `ssh alex@alexs-air.lan` |
| Runner status | `gitlab-runner status` |
| Restart runner | `gitlab-runner restart` (after `eval "$(/opt/homebrew/bin/brew shellenv)"`) |
| Stdout log | `tail -f ~/gitlab-runner.out.log` |
| Stderr log | `tail -f ~/gitlab-runner.err.log` |
| Runner config | `~/.gitlab-runner/config.toml` |
| LaunchAgent plist | `~/Library/LaunchAgents/gitlab-runner.plist` |
## Architecture
- **Host**: `alexs-air.lan` (Apple Silicon MacBook, macOS 26+, Xcode 26+)
- **User**: `alex` (the runner runs in user-mode so it can access keychain and Xcode UI tooling)
- **Tooling**: Homebrew (`/opt/homebrew`), `gitlab-runner`, `node@22`, `ruby@3.3`, fastlane installed as a user gem under `~/.gem/ruby/3.3.0/`
- **Service**: launchd LaunchAgent at `~/Library/LaunchAgents/gitlab-runner.plist`. `KeepAlive=true` (auto-restart on crash) and `RunAtLoad=true` (starts on login). The agent loads when `alex` logs in via auto-login at boot.
- **Tags**: `macos`, `ios`, `xcode` — both `build-ipa` and `publish-app-store` in `.gitlab-ci.yml` target this runner. `publish-app-store` doesn't sign anything, but it still needs Xcode's bundled iTMSTransporter to push the IPA to App Store Connect.
- **Shell setup**: `~/.bash_profile` sources brew shellenv and prepends `~/.gem/ruby/3.3.0/bin` and `/opt/homebrew/opt/ruby@3.3/bin` to `PATH` so `bash --login` (the runner's executor) finds fastlane + ruby 3.3.
### Why Ruby 3.3, not the brewed 4.0
Brewed `fastlane` (current version) ships running on Ruby 4.0 from `brew install ruby`. Ruby 4.0's OpenSSL bindings hit fastlane bug [#20553](https://github.com/fastlane/fastlane/issues/20553) — `OpenSSL::PKey::EC.new(pem)` raises "invalid curve name" for `prime256v1` keys, which breaks every App Store Connect API key signing operation. Ruby 3.3.x doesn't have this bug. So we install fastlane via `gem install fastlane --user-install` on `ruby@3.3` instead of `brew install fastlane`.
### Why IPv6 is disabled on Wi-Fi
`networksetup -setv6off Wi-Fi` is set because Ruby's net/http on this machine attempted IPv6 to `rubygems.org` first and timed out (~30 s per request). Disabling IPv6 on the Wi-Fi interface forces IPv4 immediately. To re-enable: `sudo networksetup -setv6automatic Wi-Fi`.
## Verifying the runner is healthy
From any machine:
```bash
curl -s -H "PRIVATE-TOKEN: $GITLAB_TOKEN" \
"https://gitlab.com/api/v4/runners/53111580" \
| python3 -c "import json,sys;d=json.load(sys.stdin);print(d['status'], d['online'])"
```
Expected: `online True`. If `offline` or `not_connected`, SSH in and check:
```bash
ssh alex@alexs-air.lan
gitlab-runner status
ps aux | grep gitlab-runner
tail -50 ~/gitlab-runner.err.log
```
## Restarting the runner
After a Mac reboot, the runner should start automatically via the LaunchAgent. To restart manually:
```bash
ssh alex@alexs-air.lan
eval "$(/opt/homebrew/bin/brew shellenv)"
gitlab-runner restart
```
If `gitlab-runner restart` reports "service not installed", reinstall:
```bash
gitlab-runner install
gitlab-runner start
```
This rewrites the LaunchAgent plist.
## Watching a CI job run live
```bash
ssh alex@alexs-air.lan 'tail -f ~/gitlab-runner.out.log'
```
The runner streams build output to stdout. The same output appears in the GitLab job UI.
## Updating Xcode
After a major Xcode update:
```bash
ssh alex@alexs-air.lan
sudo xcodebuild -license accept # accept the new license non-interactively
xcode-select --install # ensure command-line tools are present
xcodebuild -version # confirm version
```
Then trigger a no-op tag rebuild (e.g. cut a patch release) to verify the runner still works.
## Debugging fastlane locally
If `build-ipa` fails in CI, reproduce on the Mac. The env vars below mirror what CI sets up:
```bash
ssh alex@alexs-air.lan
cd ~/Projects/ditto
git pull origin main
eval "$(/opt/homebrew/bin/brew shellenv)"
# Match what CI provides
export CI_COMMIT_TAG=v2.x.y
export CI_PIPELINE_IID=99999
export MATCH_PASSWORD='<from GitLab CI variables>'
export MATCH_GIT_BASIC_AUTHORIZATION='<base64 of ci-readonly:gldt-...>'
export APP_STORE_CONNECT_API_KEY_ID=<key-id>
export APP_STORE_CONNECT_API_KEY_ISSUER_ID=<issuer-id>
export ASC_KEY_PATH=~/.private_keys/AuthKey_<key-id>.p8
# Build web assets and sync to Capacitor iOS project (CI does this in before_script)
npm ci
npx vite build -l error
cp dist/index.html dist/404.html
npx cap sync ios
node scripts/patch-cap-config.mjs
# Stamp marketing version (CI does this in script)
VERSION="${CI_COMMIT_TAG#v}"
sed -i '' "s/MARKETING_VERSION = [0-9.]*;/MARKETING_VERSION = ${VERSION};/g" ios/App/App.xcodeproj/project.pbxproj
# Run the build lane
cd ios
fastlane build_ipa
```
This produces the IPA at `../artifacts/Ditto.ipa` exactly like CI. Add `--verbose` for detailed output.
To also test the submission step end-to-end (this calls Apple, so be ready to "Remove from Review" in App Store Connect afterward):
```bash
export IPA_PATH="$HOME/Projects/ditto/artifacts/Ditto.ipa"
fastlane submit_release
```
Or, to debug *just* the submission against an already-uploaded build without rebuilding, use the `submit_only` lane (see "Debugging App Store submission with the `submit_only` lane" below).
## Rotating match certificates (yearly)
Apple distribution certs expire one year after issuance. To renew:
```bash
ssh alex@alexs-air.lan
cd ~/Projects/ditto/ios
eval "$(/opt/homebrew/bin/brew shellenv)"
# Set Apple credentials (API key path)
export MATCH_PASSWORD='<from GitLab CI variables>'
# Revoke the expiring cert in Apple's portal and remove from the match repo
fastlane match nuke distribution
# Issue a new cert, generate a new App Store profile, encrypt, commit, push
fastlane match appstore \
--api_key_path ~/.private_keys/AuthKey_<KEY_ID>.p8 \
--api_key_id <KEY_ID> \
--api_issuer_id <ISSUER_ID>
```
CI's next tag run picks up the new files via `match(... readonly: true)`. No GitLab variables to update.
## Debugging App Store submission with the `submit_only` lane
The `Fastfile` exposes a second lane, `submit_only`, that skips build/archive/upload and just runs `deliver` against an already-uploaded build. Useful when the binary is fine but the metadata/submission step is failing — iterate in ~30 seconds instead of waiting for a full ~6-minute CI build.
```bash
ssh alex@alexs-air.lan
export PATH="$HOME/.gem/ruby/3.3.0/bin:/opt/homebrew/opt/ruby@3.3/bin:$PATH"
cd ~/Projects/ditto/ios
# Make sure the .p8 is on disk; CI's after_script wipes it after each job
scp $LAPTOP:/path/to/AuthKey_<KEY_ID>.p8 ~/.private_keys/
export ASC_KEY_PATH=$HOME/.private_keys/AuthKey_<KEY_ID>.p8
export APP_STORE_CONNECT_API_KEY_ID=<KEY_ID>
export APP_STORE_CONNECT_API_KEY_ISSUER_ID=<ISSUER_ID>
export BUILD_NUMBER=<existing-build-number-on-ASC>
export VERSION=<marketing-version, e.g. 2.14.3>
fastlane submit_only
```
The lane expects the version to exist in App Store Connect with a `VALID` build attached. It uploads metadata (`./fastlane/metadata/en-US/release_notes.txt`) and calls `submit_for_review`. If Apple rejects, fix the Fastfile, re-run — no rebuild needed.
If Apple has already accepted the submission for that version, you'll need to "Remove from Review" in App Store Connect (only available while state is `WAITING_FOR_REVIEW`, not `IN_REVIEW`) before re-running, or bump the build number.
## Inspecting App Store Connect state directly
When fastlane's error messages aren't enough, query Apple's API directly. There's no installed CLI — use the JWT signing recipe Apple documents. A working Ruby snippet lives in this skill's troubleshooting history; the short version:
```ruby
require "json"; require "openssl"; require "net/http"; require "base64"
key_pem = File.read(ENV["ASC_KEY_PATH"])
ec = OpenSSL::PKey::EC.new(key_pem)
header = { alg: "ES256", kid: ENV["APP_STORE_CONNECT_API_KEY_ID"], typ: "JWT" }
payload = { iss: ENV["APP_STORE_CONNECT_API_KEY_ISSUER_ID"], iat: Time.now.to_i, exp: Time.now.to_i + 1200, aud: "appstoreconnect-v1" }
def b64(s); Base64.urlsafe_encode64(s, padding: false); end
si = b64(JSON.generate(header)) + "." + b64(JSON.generate(payload))
sig_der = ec.sign(OpenSSL::Digest::SHA256.new, si)
asn = OpenSSL::ASN1.decode(sig_der)
r = asn.value[0].value.to_s(2); s = asn.value[1].value.to_s(2)
r = ("\x00".b * (32 - r.bytesize)) + r if r.bytesize < 32
s = ("\x00".b * (32 - s.bytesize)) + s if s.bytesize < 32
jwt = si + "." + b64(r + s)
# Now: GET https://api.appstoreconnect.apple.com/v1/apps?filter[bundleId]=pub.ditto.app
# with header Authorization: Bearer <jwt>
```
Useful endpoints:
- `GET /v1/apps?filter[bundleId]=pub.ditto.app` → app id
- `GET /v1/apps/<id>/appStoreVersions` → version list with `appStoreState`
- `GET /v1/apps/<id>/builds?sort=-uploadedDate` → recent builds and processing state
- `GET /v1/appStoreVersions/<id>/appStoreVersionLocalizations` → release notes (`whatsNew`)
## What can go wrong
| Symptom | Likely cause | Fix |
|---|---|---|
| Runner shows offline in GitLab | Mac rebooted, auto-login disabled, or LaunchAgent unloaded | SSH in, `gitlab-runner status`, `gitlab-runner restart` |
| Build fails: "unable to find Xcode" | Xcode auto-updated and changed path, or command-line tools missing | `xcode-select --install`, `sudo xcodebuild -license accept` |
| Build fails: "no signing certificate found" | match cert expired, was revoked manually, or `MATCH_PASSWORD` mismatched | Run yearly rotation procedure above |
| Build fails: keychain locked / "User interaction is not allowed" | `setup_ci` failed to create the temporary keychain | Verify `FASTLANE_KEYCHAIN_PASSWORD` is set in GitLab CI variables |
| Build fails: ASC API key invalid | Key was revoked or rotated | Generate a new key and update `APP_STORE_CONNECT_API_KEY_*` variables |
| "Build already exists" from `deliver` | Previous tag's IPA had the same `CFBundleVersion`; fastlane's `increment_build_number` didn't bump because the value already matched `CI_PIPELINE_IID` | Push a new tag (each new tag has a new pipeline ID) |
| Apple precheck rejects metadata | Encryption export compliance, IDFA, content rights flags don't match `Fastfile` | Update `submission_information` in `ios/fastlane/Fastfile` |
| `OpenSSL::PKey::PKeyError: invalid curve name` | fastlane is running on brewed Ruby 4.0, which has a broken OpenSSL EC parser ([fastlane#20553](https://github.com/fastlane/fastlane/issues/20553)) | Use `ruby@3.3` from brew and install fastlane as a user gem (`gem install fastlane --user-install`); ensure `~/.bash_profile` puts `~/.gem/ruby/3.3.0/bin` on PATH ahead of `/opt/homebrew/bin` |
| `gem install` / `bundle install` hangs for >30s per request | Ruby's net/http tries IPv6 to rubygems.org and times out on this network | `sudo networksetup -setv6off Wi-Fi` (per-interface, persistent until reboot) |
| `Unresolved conflict between options: 'api_key_path' and 'api_key'` | `app_store_connect_api_key` action sets `APP_STORE_CONNECT_API_KEY_PATH` env var (path to `.p8`), match's same-named env var expects a JSON descriptor | Build the API key hash inline in the Fastfile (don't call `app_store_connect_api_key`); read `.p8` from a non-conflicting var like `ASC_KEY_PATH` |
| `[match] Could not find the newly generated certificate installed` when running match interactively on macOS 26+ | [fastlane#15185](https://github.com/fastlane/fastlane/issues/15185) — the new-cert verification step trips on partition list and keychain trust | Run cert generation **in CI** via the bootstrap procedure in the `ci-cd-publishing` skill (uses `setup_ci`'s ephemeral keychain). Don't run `fastlane match appstore` interactively. |
| iOS build fails: `No "iOS Development" signing certificate matching team ID` | The Xcode project uses `CODE_SIGN_STYLE=Automatic`; xcodebuild tries to find a Development cert even for Release builds | Override via `xcargs: "CODE_SIGN_STYLE=Manual CODE_SIGN_IDENTITY='Apple Distribution' PROVISIONING_PROFILE_SPECIFIER='match AppStore <bundle-id>' DEVELOPMENT_TEAM=<team>"` in the Fastfile (already configured) |
| `vite.config.ts: Unexpected token 'c', "concurrent"... is not valid JSON` | GitLab Runner sets `CONFIG_FILE=/Users/alex/.gitlab-runner/config.toml` in the job environment, which collides with vite's `process.env.CONFIG_FILE ?? "./ditto.json"` lookup | Already fixed: use `DITTO_CONFIG_FILE` for the override env var |
| `whatsNew is missing` from `submit_for_review` | `metadata_path: "./metadata"` resolves relative to fastlane's cwd (`ios/`), not its config dir (`ios/fastlane/`); fastlane silently uploads zero locales | Use `metadata_path: "./fastlane/metadata"` (already configured) |
| `appStoreVersions ... is not in valid state` | Apple won't accept submission because the version is past `PREPARE_FOR_SUBMISSION` (already submitted, in review, or shipped) | "Remove from Review" in App Store Connect if `WAITING_FOR_REVIEW`, or cut a new version |
| `An attribute value is not acceptable for the current resource state. - contentRightsDeclaration` | Apple rejects PATCH on locked App-level fields when `submission_information` includes `content_rights_*` | Drop `content_rights_*` from `submission_information` in the Fastfile (already configured) |
## When the Mac dies
1. Get a replacement Mac. Install Xcode from the App Store.
2. Run the **`ci-cd-publishing`** skill's "Initial setup" — but skip the App Store Connect API key step (you already have it). Re-register the runner with the same `macos` tag.
3. Restore signing identity: `cd ditto/ios && fastlane match appstore --readonly` decrypts the existing certs/profiles using `MATCH_PASSWORD`.
4. No reissuance, no revocation, no GitLab variable updates needed. The certificates repo is the source of truth.
+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.
+146
View File
@@ -0,0 +1,146 @@
---
name: nip19-routing
description: Implement or populate the root-level NIP-19 router (/:nip19) that handles npub, nprofile, note, nevent, and naddr identifiers. Covers decoding, secure filter construction, and type-specific rendering for profiles, notes, events, and addressable events.
---
# NIP-19 Identifier Routing
NIP-19 defines the bech32-encoded identifiers used throughout Nostr (`npub1...`, `note1...`, `naddr1...`, etc.). This project routes all of them through a single root-level page at `/:nip19`, implemented by `src/pages/NIP19Page.tsx`.
Use this skill when the user wants to populate the `NIP19Page` sections with real views, add a new identifier type, or build links that point into the Nostr routing system.
## Identifier Reference
| Prefix | Payload | Use when… |
|--------------|------------------------------------------------------------------|--------------------------------------------------------------|
| `npub1` | 32-byte public key | Simple user reference |
| `nprofile1` | Public key + optional relay hints + petname | User reference with relay context |
| `note1` | 32-byte event ID (kind:1 text notes only, per NIP-10) | Referencing a short text note/thread |
| `nevent1` | Event ID + optional relay hints + author pubkey + kind | Any event kind, or notes where you need relay/author context |
| `naddr1` | `kind` + `pubkey` + `identifier` (`d` tag) + optional relay hints | Addressable events (kind 30000-39999): articles, products |
| `nsec1` | Private key | **Never display or route** — treat as a 404 |
| `nrelay1` | Relay URL | Deprecated |
### `note1` vs `nevent1`
- `note1` carries only an event ID, and is canonically tied to kind:1 text notes.
- `nevent1` can reference **any** kind and can carry relay hints + author pubkey. Prefer `nevent1` for non-kind-1 events or when you want to ship relay hints with a link.
### `npub1` vs `nprofile1`
- `npub1` is just a pubkey.
- `nprofile1` adds relay hints and a petname. Prefer it for shareable profile links where discoverability matters.
## Routing Rules
1. **All NIP-19 identifiers are handled at the URL root**: `/:nip19` in `AppRouter.tsx`. Never nest them under paths like `/note/:id` or `/profile/:npub`.
2. **Invalid, vacant, or unsupported identifiers** (including `nsec1` and `nrelay1`) render the 404 page. The `NIP19Page` boilerplate already handles this.
3. **Addressable event URLs must include the author**. `naddr1` already encodes `pubkey` + `kind` + `identifier`, which is exactly what a secure query filter needs. If you ever design an alternative URL, use the shape `/:npub/:dtag`, never `/:dtag` alone — otherwise anyone can publish a conflicting event with the same `d` tag.
## Decoding and Filtering
Nostr relay filters only accept hex strings. Always decode the NIP-19 identifier before building a filter.
```ts
import { nip19 } from 'nostr-tools';
const decoded = nip19.decode(value); // throws on invalid input
switch (decoded.type) {
case 'npub': {
const pubkey = decoded.data; // hex string
return nostr.query([{ kinds: [0], authors: [pubkey], limit: 1 }]);
}
case 'nprofile': {
const { pubkey /*, relays */ } = decoded.data;
return nostr.query([{ kinds: [0], authors: [pubkey], limit: 1 }]);
}
case 'note': {
const id = decoded.data;
return nostr.query([{ ids: [id], kinds: [1], limit: 1 }]);
}
case 'nevent': {
const { id /*, relays, author, kind */ } = decoded.data;
return nostr.query([{ ids: [id], limit: 1 }]);
}
case 'naddr': {
const { kind, pubkey, identifier } = decoded.data;
return nostr.query([{
kinds: [kind],
authors: [pubkey], // critical: prevents d-tag spoofing
'#d': [identifier],
limit: 1,
}]);
}
default:
// nsec, nrelay, unknown → 404
throw new Error('Unsupported Nostr identifier');
}
```
### Common mistakes
```ts
// ❌ Passing bech32 into a filter
nostr.query([{ ids: [naddr] }]);
// ❌ Addressable lookup without the author — anyone can spoof the d-tag
nostr.query([{ kinds: [30023], '#d': [slug] }]);
// ✅ Decode first, then include author
const { kind, pubkey, identifier } = nip19.decode(naddr).data;
nostr.query([{ kinds: [kind], authors: [pubkey], '#d': [identifier] }]);
```
## Populating `NIP19Page`
`src/pages/NIP19Page.tsx` already:
- Decodes `params.nip19` with `nip19.decode`.
- Branches on `decoded.type` with a section for each supported identifier.
- Redirects invalid / unsupported identifiers to the 404 page.
- Provides a responsive container wrapper.
To turn it into a real router, replace each placeholder section with a concrete component:
| `decoded.type` | Typical view |
|-----------------------|---------------------------------------------------------------|
| `npub` / `nprofile` | Profile page: header from kind 0, feed of the user's events |
| `note` | Single kind:1 text note with thread + replies |
| `nevent` | Generic event renderer; branch on `kind` for specialized UIs |
| `naddr` | Addressable-event view (article, product, community, etc.) |
Inside each branch, pass the decoded payload (not the raw bech32 string) to a child component. That keeps filter construction colocated with the fetching hook and removes any chance of a re-decode mismatch.
## Linking to NIP-19 Routes
When building links elsewhere in the app:
```tsx
import { nip19 } from 'nostr-tools';
import { Link } from 'react-router-dom';
// To a profile
<Link to={`/${nip19.npubEncode(pubkey)}`}>Profile</Link>
// To an addressable event (article, product, …)
<Link to={`/${nip19.naddrEncode({ kind, pubkey, identifier, relays })}`}>
Open
</Link>
// To a specific event of any kind, with relay hints
<Link to={`/${nip19.neventEncode({ id, relays, author, kind })}`}>Open</Link>
```
Always encode with the **most specific** identifier you have context for (`nprofile` > `npub`, `nevent` > `note`, `naddr` for addressable). The extra metadata makes links more robust across relays.
## Security Recap
- Decode **before** querying.
- For addressable events, always include `authors: [pubkey]` in the filter — the `d` tag alone is not a trust boundary.
- Treat `nsec1` and any unknown/invalid identifier as 404. Never render, log, or echo a decoded `nsec`.
+190
View File
@@ -0,0 +1,190 @@
---
name: nip85-stats
description: Fetch pre-computed engagement stats (follower count, post count, reply count, reaction count, zap amounts, etc.) for users, events, and addressable events via a NIP-85 Trusted Assertion provider. Provides useNip85UserStats, useNip85EventStats, and useNip85AddrStats hooks backed by a configurable provider pubkey in AppConfig.
---
# NIP-85 Trusted Assertion Stats
[NIP-85](https://github.com/nostr-protocol/nips/blob/master/85.md) defines "Trusted Assertions" — events published by a service provider that carry pre-computed stats (follower counts, reaction counts, zap totals, etc.) for users and events. Clients that would otherwise need to load thousands of events to compute these numbers can instead query a single addressable event from a trusted provider.
This skill adds three hooks — `useNip85UserStats`, `useNip85EventStats`, `useNip85AddrStats` — and a configurable `nip85StatsPubkey` field on `AppConfig` so you can swap providers.
## Kinds Used
| Kind | Subject | `d` tag value |
| ----- | ---------------------------- | -------------------------- |
| 30382 | User | user pubkey (hex) |
| 30383 | Event (regular, kind 1 etc.) | event id (hex) |
| 30384 | Addressable event | `<kind>:<pubkey>:<d-tag>` |
The hooks query one replaceable event at a time (`limit: 1`), filtered by `authors: [statsPubkey]` and `#d`. **Filtering by `authors` is required** — without it, anyone could publish a fake assertion with the same `d` tag and the client would accept it.
## Files Provided by This Skill
| Skill file | Copy to |
|---|---|
| `files/hooks/useNip85Stats.ts` | `src/hooks/useNip85Stats.ts` |
## Setup Instructions
### 1. Copy the Hooks File
Copy `.agents/skills/nip85-stats/files/hooks/useNip85Stats.ts` into `src/hooks/useNip85Stats.ts`. It imports `@nostrify/react`, `@tanstack/react-query`, and `@/hooks/useAppContext`, all already present in the template.
### 2. Add `nip85StatsPubkey` to `AppConfig`
In `src/contexts/AppContext.ts`, add the field to the `AppConfig` interface:
```typescript
export interface AppConfig {
// ...existing fields...
/** Hex pubkey of the NIP-85 Trusted Assertion provider. Empty = disabled. */
nip85StatsPubkey: string;
}
```
### 3. Update the Zod Schema in `AppProvider.tsx`
In `src/components/AppProvider.tsx`, add the field to `AppConfigSchema`:
```typescript
const AppConfigSchema = z.object({
// ...existing fields...
nip85StatsPubkey: z.string().refine(
(val) => val.length === 0 || /^[0-9a-f]{64}$/i.test(val),
{ message: 'Must be empty or a 64-character hex pubkey' },
),
}) satisfies z.ZodType<AppConfig>;
```
### 4. Set the Default in `App.tsx`
Pick a provider pubkey and add it to `defaultConfig`. The ditto.pub provider is a reasonable default:
```typescript
const defaultConfig: AppConfig = {
// ...existing fields...
nip85StatsPubkey: '5f68e85ee174102ca8978eef302129f081f03456c884185d5ec1c1224ab633ea',
};
```
Set to `''` to ship with stats disabled.
### 5. Update `TestApp.tsx`
In `src/test/TestApp.tsx`, add the field to the test default config. Use an empty string so tests don't hit a live provider:
```typescript
const defaultConfig: AppConfig = {
// ...existing fields...
nip85StatsPubkey: '',
};
```
## Usage
### User stats (kind 30382)
```tsx
import { useNip85UserStats } from '@/hooks/useNip85Stats';
function FollowerCount({ pubkey }: { pubkey: string }) {
const { data: stats } = useNip85UserStats(pubkey);
if (!stats) return null; // no provider configured or no assertion yet
return <span>{stats.followers.toLocaleString()} followers</span>;
}
```
### Event stats (kind 30383)
```tsx
import { useNip85EventStats } from '@/hooks/useNip85Stats';
function NoteStats({ eventId }: { eventId: string }) {
const { data: stats } = useNip85EventStats(eventId);
if (!stats) return null;
return (
<div className="flex gap-3 text-sm text-muted-foreground">
<span>{stats.reactionCount} reactions</span>
<span>{stats.repostCount} reposts</span>
<span>{stats.commentCount} comments</span>
<span>{stats.zapAmount} sats</span>
</div>
);
}
```
### Addressable event stats (kind 30384)
The `addr` argument is the full NIP-01 event address `<kind>:<pubkey>:<d-tag>`:
```tsx
import { useNip85AddrStats } from '@/hooks/useNip85Stats';
function ArticleStats({ kind, pubkey, identifier }: { kind: number; pubkey: string; identifier: string }) {
const { data: stats } = useNip85AddrStats(`${kind}:${pubkey}:${identifier}`);
if (!stats) return null;
return <span>{stats.reactionCount} reactions</span>;
}
```
## Behavior Notes
- **Graceful degradation:** The hooks return `null` (not an error) when `nip85StatsPubkey` is empty or the provider has no assertion for the subject. Always render defensively — NIP-85 is an optimization, not a source of truth.
- **Short timeouts:** Each query is wrapped in a 2-second `AbortSignal.timeout` so a slow stats relay never blocks the UI.
- **Cached by TanStack Query:** `staleTime` is 30s for event/addr stats and 60s for user stats. Results are keyed on `[kind, subject, statsPubkey]`, so swapping providers invalidates the cache automatically.
- **Missing tags = 0:** A tag absent from the assertion is reported as `0` rather than `undefined`, matching NIP-85's "no data" semantics.
- **Not the source of truth:** For interactive features (did *this* user like *this* post?) you still need to query the underlying reaction/zap/repost events. NIP-85 only provides aggregate counts.
## Extending the Stats
The hooks expose a small subset of the tags defined in NIP-85. To surface more (e.g. `zap_amt_sent`, `rank`, `first_created_at`), extend the return types and pull additional tags via `getIntTag`:
```typescript
export interface Nip85UserStats {
followers: number;
postCount: number;
rank: number; // new
zapAmtReceived: number; // new
}
// inside useNip85UserStats queryFn
return {
followers: getIntTag(tags, 'followers'),
postCount: getIntTag(tags, 'post_cnt'),
rank: getIntTag(tags, 'rank'),
zapAmtReceived: getIntTag(tags, 'zap_amt_recd'),
};
```
See the full tag table in [NIP-85](https://github.com/nostr-protocol/nips/blob/master/85.md).
## Exposing a Provider Picker (Optional)
If you want the user to change providers at runtime, add an input bound to `config.nip85StatsPubkey` and call `updateConfig` with a validated 64-char hex value:
```tsx
import { useAppContext } from '@/hooks/useAppContext';
function StatsProviderInput() {
const { config, updateConfig } = useAppContext();
return (
<input
value={config.nip85StatsPubkey}
onChange={(e) => {
const v = e.target.value.trim().toLowerCase();
if (v === '' || /^[0-9a-f]{64}$/.test(v)) {
updateConfig(() => ({ nip85StatsPubkey: v }));
}
}}
placeholder="64-char hex pubkey (blank to disable)"
/>
);
}
```
## Related NIPs
- [NIP-85](https://github.com/nostr-protocol/nips/blob/master/85.md) — Trusted Assertions (this skill)
- [NIP-01](https://github.com/nostr-protocol/nips/blob/master/01.md) — Addressable event addressing (`<kind>:<pubkey>:<d-tag>`)
- [NIP-57](https://github.com/nostr-protocol/nips/blob/master/57.md) — Zaps (the underlying events `zap_amount` / `zap_cnt` aggregate)
@@ -0,0 +1,156 @@
import { useNostr } from '@nostrify/react';
import { useQuery } from '@tanstack/react-query';
import { useAppContext } from '@/hooks/useAppContext';
/** Engagement counts exposed by NIP-85 kind 30383 (events) and 30384 (addressable events). */
export interface Nip85EventStats {
commentCount: number;
repostCount: number;
reactionCount: number;
zapCount: number;
/** Zap amount in sats. */
zapAmount: number;
}
/** A subset of NIP-85 kind 30382 (user) stats — extend as needed. */
export interface Nip85UserStats {
followers: number;
postCount: number;
}
/**
* Read an integer tag value from a NIP-85 assertion event. Returns 0 when missing
* or unparseable, which mirrors the semantics of "no data" in NIP-85.
*/
function getIntTag(tags: string[][], tagName: string): number {
const tag = tags.find(([name]) => name === tagName);
if (!tag?.[1]) return 0;
const n = parseInt(tag[1], 10);
return Number.isFinite(n) ? n : 0;
}
/**
* Fetches NIP-85 event stats (kind 30383) from the configured stats pubkey.
* Returns `null` when no stats pubkey is configured or the provider has no
* assertion for this event.
*/
export function useNip85EventStats(eventId: string | undefined) {
const { nostr } = useNostr();
const { config } = useAppContext();
const statsPubkey = config.nip85StatsPubkey;
return useQuery<Nip85EventStats | null>({
queryKey: ['nip85-event-stats', eventId, statsPubkey],
queryFn: async ({ signal }) => {
if (!eventId || !statsPubkey) return null;
const combined = AbortSignal.any([signal, AbortSignal.timeout(2000)]);
try {
const events = await nostr.query(
[{ kinds: [30383], authors: [statsPubkey], '#d': [eventId], limit: 1 }],
{ signal: combined },
);
if (events.length === 0) return null;
const { tags } = events[0];
return {
commentCount: getIntTag(tags, 'comment_cnt'),
repostCount: getIntTag(tags, 'repost_cnt'),
reactionCount: getIntTag(tags, 'reaction_cnt'),
zapCount: getIntTag(tags, 'zap_cnt'),
zapAmount: getIntTag(tags, 'zap_amount'),
};
} catch {
return null;
}
},
enabled: !!eventId && !!statsPubkey,
staleTime: 30 * 1000,
retry: false,
});
}
/**
* Fetches NIP-85 user stats (kind 30382) from the configured stats pubkey.
* Returns `null` when no stats pubkey is configured or the provider has no
* assertion for this pubkey.
*/
export function useNip85UserStats(pubkey: string | undefined) {
const { nostr } = useNostr();
const { config } = useAppContext();
const statsPubkey = config.nip85StatsPubkey;
return useQuery<Nip85UserStats | null>({
queryKey: ['nip85-user-stats', pubkey, statsPubkey],
queryFn: async ({ signal }) => {
if (!pubkey || !statsPubkey) return null;
const combined = AbortSignal.any([signal, AbortSignal.timeout(2000)]);
try {
const events = await nostr.query(
[{ kinds: [30382], authors: [statsPubkey], '#d': [pubkey], limit: 1 }],
{ signal: combined },
);
if (events.length === 0) return null;
const { tags } = events[0];
return {
followers: getIntTag(tags, 'followers'),
postCount: getIntTag(tags, 'post_cnt'),
};
} catch {
return null;
}
},
enabled: !!pubkey && !!statsPubkey,
staleTime: 60 * 1000,
retry: false,
});
}
/**
* Fetches NIP-85 addressable event stats (kind 30384) from the configured
* stats pubkey. The `addr` argument is the full NIP-01 event address string,
* e.g. `30023:<pubkey>:<d-tag>`.
*/
export function useNip85AddrStats(addr: string | undefined) {
const { nostr } = useNostr();
const { config } = useAppContext();
const statsPubkey = config.nip85StatsPubkey;
return useQuery<Nip85EventStats | null>({
queryKey: ['nip85-addr-stats', addr, statsPubkey],
queryFn: async ({ signal }) => {
if (!addr || !statsPubkey) return null;
const combined = AbortSignal.any([signal, AbortSignal.timeout(2000)]);
try {
const events = await nostr.query(
[{ kinds: [30384], authors: [statsPubkey], '#d': [addr], limit: 1 }],
{ signal: combined },
);
if (events.length === 0) return null;
const { tags } = events[0];
return {
commentCount: getIntTag(tags, 'comment_cnt'),
repostCount: getIntTag(tags, 'repost_cnt'),
reactionCount: getIntTag(tags, 'reaction_cnt'),
zapCount: getIntTag(tags, 'zap_cnt'),
zapAmount: getIntTag(tags, 'zap_amount'),
};
} catch {
return null;
}
},
enabled: !!addr && !!statsPubkey,
staleTime: 30 * 1000,
retry: false,
});
}
@@ -1,478 +0,0 @@
---
name: nostr-direct-messages
description: Implement Nostr direct messaging features, build chat interfaces, or work with encrypted peer-to-peer communication (NIP-04 and NIP-17).
---
# Direct Messaging on Nostr
This project includes a complete direct messaging system supporting both NIP-04 (legacy) and NIP-17 (modern, more private) encrypted messages with real-time subscriptions, optimistic updates, and a persistent cache-first local storage.
**The DM system is not enabled by default** - follow the setup instructions below to add messaging functionality to your application.
## Setup Instructions
### 1. Add DMProvider to Your App
First, add the `DMProvider` to your app's provider tree in `src/App.tsx`:
```tsx
// Add these imports at the top of src/App.tsx
import { DMProvider, type DMConfig } from '@/components/DMProvider';
import { PROTOCOL_MODE } from '@/lib/dmConstants';
// Add this configuration before your App component
const dmConfig: DMConfig = {
// Enable or disable DMs entirely
enabled: true, // Set to true to enable messaging functionality
// Choose one protocol mode:
// PROTOCOL_MODE.NIP04_ONLY - Force NIP-04 (legacy) only
// PROTOCOL_MODE.NIP17_ONLY - Force NIP-17 (private) only
// PROTOCOL_MODE.NIP04_OR_NIP17 - Allow users to choose between NIP-04 and NIP-17 (defaults to NIP-17)
protocolMode: PROTOCOL_MODE.NIP17_ONLY, // Recommended for new apps
};
// Then wrap your app components with DMProvider:
export function App() {
return (
<UnheadProvider head={head}>
<AppProvider storageKey="nostr:app-config" defaultConfig={defaultConfig}>
<QueryClientProvider client={queryClient}>
<NostrLoginProvider storageKey='nostr:login'>
<NostrProvider>
<NostrSync />
<DMProvider config={dmConfig}>
<TooltipProvider>
<Toaster />
<Suspense>
<AppRouter />
</Suspense>
</TooltipProvider>
</DMProvider>
</NostrProvider>
</NostrLoginProvider>
</QueryClientProvider>
</AppProvider>
</UnheadProvider>
);
}
```
### 2. Configure DM Settings
The `DMConfig` object supports the following options:
- `enabled` (boolean, default: `false`) - Enable/disable entire DM system. When false, no messages are loaded, stored, or processed.
- `protocolMode` (ProtocolMode, default: `PROTOCOL_MODE.NIP17_ONLY`) - Which protocols to support:
- `PROTOCOL_MODE.NIP04_ONLY` - Legacy encryption only
- `PROTOCOL_MODE.NIP17_ONLY` - Modern private messages (recommended)
- `PROTOCOL_MODE.NIP04_OR_NIP17` - Support both protocols (for backwards compatibility)
**Note**: The DM system uses domain-based IndexedDB naming (`nostr-dm-store-${hostname}`) to prevent conflicts between multiple apps on the same domain.
## Quick Start
### 1. Send Messages
```tsx
import { useDMContext } from '@/hooks/useDMContext';
import { MESSAGE_PROTOCOL } from '@/lib/dmConstants';
function ComposeMessage({ recipientPubkey }: { recipientPubkey: string }) {
const { sendMessage } = useDMContext();
const [content, setContent] = useState('');
const handleSend = async () => {
await sendMessage({
recipientPubkey,
content,
protocol: MESSAGE_PROTOCOL.NIP17, // Uses NIP-44 encryption + gift wrapping
});
setContent('');
};
return (
<form onSubmit={(e) => { e.preventDefault(); handleSend(); }}>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="Type a message..."
/>
<button type="submit">Send</button>
</form>
);
}
```
### 2. Display Conversations
```tsx
import { useDMContext } from '@/hooks/useDMContext';
import { useAuthor } from '@/hooks/useAuthor';
import { genUserName } from '@/lib/genUserName';
function ConversationList({ onSelectConversation }: { onSelectConversation: (pubkey: string) => void }) {
const { conversations, isLoading } = useDMContext();
if (isLoading) {
return <div>Loading conversations...</div>;
}
return (
<div className="space-y-2">
{conversations.map((conversation) => (
<ConversationItem
key={conversation.pubkey}
conversation={conversation}
onClick={() => onSelectConversation(conversation.pubkey)}
/>
))}
</div>
);
}
function ConversationItem({ conversation, onClick }: {
conversation: ConversationSummary;
onClick: () => void;
}) {
const author = useAuthor(conversation.pubkey);
const displayName = author.data?.metadata?.name || genUserName(conversation.pubkey);
const avatarUrl = author.data?.metadata?.picture;
return (
<button onClick={onClick} className="w-full p-3 hover:bg-accent rounded-lg">
<div className="flex items-center gap-3">
<Avatar>
<AvatarImage src={avatarUrl} />
<AvatarFallback>{displayName.slice(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
<div className="flex-1 text-left">
<div className="font-medium">{displayName}</div>
<div className="text-sm text-muted-foreground truncate">
{conversation.lastMessage?.decryptedContent || 'No messages yet'}
</div>
</div>
</div>
</button>
);
}
```
### 3. Display Messages in a Conversation
```tsx
import { useConversationMessages } from '@/hooks/useConversationMessages';
import { useCurrentUser } from '@/hooks/useCurrentUser';
function MessageThread({ conversationPubkey }: { conversationPubkey: string }) {
const { user } = useCurrentUser();
const { messages, hasMoreMessages, loadEarlierMessages } = useConversationMessages(conversationPubkey);
return (
<div className="flex flex-col space-y-2">
{hasMoreMessages && (
<button onClick={loadEarlierMessages} className="text-sm text-muted-foreground">
Load earlier messages
</button>
)}
{messages.map((message) => {
const isFromMe = message.pubkey === user?.pubkey;
return (
<div
key={message.id}
className={cn(
"flex",
isFromMe ? "justify-end" : "justify-start"
)}
>
<div className={cn(
"max-w-[70%] rounded-lg px-4 py-2",
isFromMe ? "bg-primary text-primary-foreground" : "bg-muted"
)}>
{message.error ? (
<span className="text-red-500">🔒 {message.error}</span>
) : (
<p className="whitespace-pre-wrap break-words">
{message.decryptedContent}
</p>
)}
{message.isSending && (
<span className="text-xs opacity-50">Sending...</span>
)}
</div>
</div>
);
})}
</div>
);
}
```
## Using the Complete Messaging Interface
For a fully-featured messaging UI out of the box, use the `DMMessagingInterface` component:
```tsx
import { DMMessagingInterface } from "@/components/dm/DMMessagingInterface";
function MessagesPage() {
return (
<div className="container mx-auto p-4 h-screen">
<DMMessagingInterface />
</div>
);
}
```
The `DMMessagingInterface` component provides a complete messaging UI with:
- Conversation list with Active/Requests tabs
- Message thread view with pagination
- Compose area with file upload support
- Real-time message updates
- Mobile-responsive layout (shows one panel at a time on mobile)
It requires no props and works automatically when wrapped in `DMProvider`.
**For custom layouts**, see the "Building Custom Messaging UIs" section below for individual components (`DMConversationList`, `DMChatArea`, `DMStatusInfo`).
## Sending Files with Messages
```tsx
import { useDMContext } from '@/hooks/useDMContext';
import { useUploadFile } from '@/hooks/useUploadFile';
import { MESSAGE_PROTOCOL } from '@/lib/dmConstants';
import type { FileAttachment } from '@/contexts/DMContext';
function ComposeWithFiles({ recipientPubkey }: { recipientPubkey: string }) {
const { sendMessage } = useDMContext();
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
const [content, setContent] = useState('');
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const handleSend = async () => {
let attachments: FileAttachment[] | undefined;
// Upload file if one is selected
if (selectedFile) {
const tags = await uploadFile(selectedFile);
attachments = [{
url: tags[0][1], // URL from first tag
mimeType: selectedFile.type,
size: selectedFile.size,
name: selectedFile.name,
tags: tags
}];
}
await sendMessage({
recipientPubkey,
content,
protocol: MESSAGE_PROTOCOL.NIP17,
attachments,
});
setContent('');
setSelectedFile(null);
};
return (
<form onSubmit={(e) => { e.preventDefault(); handleSend(); }}>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="Type a message..."
/>
<input
type="file"
onChange={(e) => setSelectedFile(e.target.files?.[0] || null)}
/>
{selectedFile && <div>Selected: {selectedFile.name}</div>}
<button type="submit" disabled={isUploading}>
{isUploading ? 'Uploading...' : 'Send'}
</button>
</form>
);
}
```
## Protocol Comparison
### NIP-04 (Legacy)
- **Encryption**: NIP-04 (simpler, older)
- **Metadata**: Sender and recipient visible to relays
- **Event Kind**: Kind 4
- **Use When**: Compatibility with older clients
### NIP-17 (Modern & Private)
- **Encryption**: NIP-44 (stronger)
- **Metadata**: Hidden via gift wrapping (NIP-59)
- **Event Kinds**: Kind 14 (text), Kind 15 (files)
- **Wrapped In**: Kind 1059 (Gift Wrap) with ephemeral keys
- **Use When**: Maximum privacy (recommended)
**Key Privacy Features of NIP-17:**
- Sender identity hidden (uses random ephemeral keys)
- Timestamps randomized (±2 days) to hide send time
- Dual gift wraps (recipient + sender) for message history
## Advanced Features
### Conversation Categorization
The system automatically categorizes conversations:
```tsx
const { conversations } = useDMContext();
// Filter by category
const knownConversations = conversations.filter(c => c.isKnown);
const requestConversations = conversations.filter(c => c.isRequest);
// isKnown = true if user has sent at least one message
// isRequest = true if only received messages, never replied
```
### Loading States
```tsx
const { isLoading, loadingPhase, scanProgress } = useDMContext();
// Check overall loading state
if (isLoading) {
console.log('Current phase:', loadingPhase);
// LOADING_PHASES.CACHE - Loading from local cache
// LOADING_PHASES.RELAYS - Querying relays
// LOADING_PHASES.SUBSCRIPTIONS - Setting up real-time updates
// LOADING_PHASES.READY - Fully loaded
}
// Display scan progress for large message histories
if (scanProgress.nip17) {
console.log(`NIP-17: ${scanProgress.nip17.current} messages - ${scanProgress.nip17.status}`);
}
```
### Clear Cache and Refresh
```tsx
import { useDMContext } from '@/hooks/useDMContext';
function SettingsButton() {
const { clearCacheAndRefetch } = useDMContext();
const handleClearCache = async () => {
await clearCacheAndRefetch();
// Clears IndexedDB cache and reloads all messages from relays
};
return (
<button onClick={handleClearCache}>
Clear Message Cache
</button>
);
}
```
## Architecture Notes
### Data Flow
1. **Cache First**: Messages load instantly from encrypted IndexedDB cache
2. **Background Sync**: New messages fetched from relays in parallel
3. **Real-time Updates**: WebSocket subscriptions for live messages
4. **Optimistic UI**: Sent messages appear immediately, confirmed on relay response
### Storage
- **IndexedDB**: All messages stored locally with NIP-44 encryption
- **Per-User Storage**: Separate encrypted store for each logged-in user
- **Automatic Sync**: Debounced writes (15s) + immediate on new messages
### Performance
- **Parallel Queries**: NIP-04 and NIP-17 messages fetched simultaneously
- **Batched Loading**: Messages loaded in batches (1000/batch, 20k limit)
- **Pagination**: Conversation messages paginated (25/page)
- **Deduplication**: Automatic filtering of duplicate messages by ID
### Security
- **NIP-44 Encryption**: Modern authenticated encryption for all NIP-17 messages
- **Local Encryption**: IndexedDB storage encrypted with user's NIP-44 key
- **Ephemeral Keys**: Random keys for NIP-17 gift wraps (sender anonymity)
- **No Plaintext**: Decrypted content never persisted unencrypted
- **Domain Isolation**: IndexedDB databases are namespaced by hostname to prevent data conflicts
## Building Custom Messaging UIs
For advanced use cases, you can use the individual DM components to build custom layouts:
### Available Components
**`DMConversationList`** - Conversation sidebar with tabs
```tsx
import { DMConversationList } from '@/components/dm/DMConversationList';
<DMConversationList
selectedPubkey={selectedPubkey}
onSelectConversation={(pubkey) => setSelectedPubkey(pubkey)}
onStatusClick={() => setShowStatus(true)} // optional
className="h-full"
/>
```
**`DMChatArea`** - Message thread and compose area
```tsx
import { DMChatArea } from '@/components/dm/DMChatArea';
<DMChatArea
pubkey={selectedPubkey}
onBack={() => setSelectedPubkey(null)} // optional, for mobile back button
className="h-full"
/>
```
**`DMStatusInfo`** - Debug/status panel
```tsx
import { DMStatusInfo } from '@/components/dm/DMStatusInfo';
<DMStatusInfo clearCacheAndRefetch={clearCacheAndRefetch} />
```
### Custom Layout Example
```tsx
import { useState } from 'react';
import { DMConversationList } from '@/components/dm/DMConversationList';
import { DMChatArea } from '@/components/dm/DMChatArea';
function CustomMessagingLayout() {
const [selectedPubkey, setSelectedPubkey] = useState<string | null>(null);
return (
<div className="flex h-screen">
{/* Custom sidebar */}
<aside className="w-64 border-r">
<DMConversationList
selectedPubkey={selectedPubkey}
onSelectConversation={setSelectedPubkey}
/>
</aside>
{/* Custom main area */}
<main className="flex-1">
{selectedPubkey ? (
<DMChatArea pubkey={selectedPubkey} />
) : (
<div className="flex items-center justify-center h-full">
<p>Select a conversation to start messaging</p>
</div>
)}
</main>
</div>
);
}
```
+81
View File
@@ -0,0 +1,81 @@
---
name: nostr-encryption
description: Encrypt and decrypt content for Nostr direct messages, gift wraps, or any feature that needs NIP-44 (or legacy NIP-04) ciphertext, using the logged-in user's signer.
---
# Nostr Encryption and Decryption
The logged-in user exposes a `signer` object that matches the NIP-07 signer interface. The signer handles all cryptographic operations internally — including ECDH, conversation-key derivation, and AEAD — so your code never touches a private key.
**Always use the signer interface for encryption. Never ask the user for their private key, and never derive a shared secret yourself.**
## NIP-44 (preferred)
NIP-44 is the modern, authenticated encryption scheme used for DMs (NIP-17), gift wraps (NIP-59), and most new encrypted payloads.
```ts
import { useCurrentUser } from "@/hooks/useCurrentUser";
function useEncryptedNote() {
const { user } = useCurrentUser();
if (!user) throw new Error("Must be logged in");
// Guard: older signers may not support NIP-44 yet.
if (!user.signer.nip44) {
throw new Error(
"Please upgrade your signer extension to a version that supports NIP-44 encryption",
);
}
// Encrypt a message to a recipient (use your own pubkey to encrypt to self).
const ciphertext = await user.signer.nip44.encrypt(
recipientPubkey,
"hello world",
);
// Decrypt a message from a sender (use the *other party's* pubkey).
const plaintext = await user.signer.nip44.decrypt(senderPubkey, ciphertext);
return plaintext;
}
```
### Key points
- `encrypt(peerPubkey, plaintext)``peerPubkey` is the **other party's** hex public key. For self-encryption (notes, backups), pass `user.pubkey`.
- `decrypt(peerPubkey, ciphertext)``peerPubkey` is the author of the ciphertext you're decrypting (for an incoming DM, this is the sender's pubkey).
- Both methods are async and may throw if the signer rejects the request or the ciphertext is malformed. Wrap calls in `try/catch`.
- The signer handles conversation-key caching; repeated calls for the same peer are cheap.
## NIP-04 (legacy)
NIP-04 is only needed when interacting with older clients that haven't adopted NIP-44. The API mirrors NIP-44:
```ts
if (!user.signer.nip04) {
throw new Error("Signer does not support NIP-04");
}
const ciphertext = await user.signer.nip04.encrypt(peerPubkey, plaintext);
const plaintext = await user.signer.nip04.decrypt(peerPubkey, ciphertext);
```
Prefer NIP-44 for anything new. Only fall back to NIP-04 when a spec or peer explicitly requires it.
## Patterns
### Encrypt-to-self (drafts, private notes)
```ts
const ciphertext = await user.signer.nip44.encrypt(user.pubkey, draft);
createEvent({ kind: 30078, content: ciphertext, tags: [["d", "my-draft"]] });
```
### Decrypt an incoming DM (NIP-17 / NIP-59)
For gift-wrapped DMs, you'll typically decrypt the outer wrap, then the inner seal, then read the rumor's content. Each decryption uses the *sender* of that specific layer as the peer pubkey.
### Guarding the UI
Always check `user.signer.nip44` (or `nip04`) before calling encryption methods. Remote signers and older browser extensions may not implement every interface, and catching the missing-capability case lets you show a useful message ("Please upgrade your signer") instead of an unhandled promise rejection.
+89
View File
@@ -0,0 +1,89 @@
---
name: nostr-kind-design
description: Decide whether to reuse an existing NIP or mint a new kind, design tag structures that relays can index, choose what goes in content vs. tags, and document new kinds or extensions in NIP.md. Load when authoring a new schema — not when wiring up rendering for a kind that already exists (use nostr-kind-rendering for that).
---
# Nostr Kinds — Design and Schema
Load this skill when:
- Minting a new event kind for a Ditto feature.
- Extending an existing NIP with new tags.
- Deciding whether an existing NIP covers a use case or whether a custom kind is warranted.
- Documenting a custom kind or extension in `NIP.md`.
**Not this skill** — if an existing NIP/kind covers your use case and you only need to render it in Ditto's UI, use the **`nostr-kind-rendering`** skill instead.
## Choosing Between Existing NIPs and Custom Kinds
1. **Thorough NIP review first.** Browse the NIP index, then read candidate NIPs in detail. The goal is to find the closest existing solution.
2. **Prefer extending existing NIPs** over creating custom kinds, even at the cost of minor schema compromises. Custom kinds fragment the ecosystem.
3. **When an existing NIP is close but not perfect**, use its kind as the base and add domain-specific tags. Document the extension in `NIP.md`.
4. **Only mint a new kind** when no existing NIP covers the core functionality, the data structure is fundamentally different, or the use case requires different storage characteristics (regular vs. replaceable vs. addressable).
5. **If a tool to generate a new kind number is available, you MUST call it.** Never pick an arbitrary number.
6. **Custom kinds MUST include a NIP-31 `alt` tag** with a human-readable description of the event's purpose.
**Example decision:**
```
Need: Equipment marketplace for farmers
Options:
1. NIP-15 (Marketplace) — too structured for peer-to-peer sales
2. NIP-99 (Classifieds) — good fit, extensible with farming tags
3. Custom kind — perfect fit, no interoperability
Decision: NIP-99 + farming-specific tags.
```
## Kind Ranges
An event's kind number determines storage semantics:
- **Regular** (1000 ≤ kind < 10000) — stored permanently by relays. Notes, articles, etc.
- **Replaceable** (10000 ≤ kind < 20000) — only the latest event per `pubkey+kind` is kept. Profile metadata, contact lists, mute lists.
- **Addressable** (30000 ≤ kind < 40000) — identified by `pubkey+kind+d-tag`; only the latest per combo is kept. Long-form content, products, definitions.
Kinds below 1000 are "legacy"; storage is per-kind (e.g. kind 1 is regular, kind 3 is replaceable).
## Tag Design Principles
- **Kind = schema, tags = semantics.** Don't mint a new kind just to represent a different category of the same data.
- **Relays only index single-letter tags.** Use `t` for categories so filters like `'#t': ['electronics']` work at the relay level. Multi-letter tags (`product_type`, etc.) force inefficient client-side filtering.
- **Filter at the relay**, not in JavaScript:
```ts
// ❌ Fetch everything, filter locally
const events = await nostr.query([{ kinds: [30402] }]);
const filtered = events.filter((e) => hasTag(e, 'product_type', 'electronics'));
// ✅ Filter at the relay
const events = await nostr.query([{ kinds: [30402], '#t': ['electronics'] }]);
```
- **For Ditto-specific niches** (community apps, regional variants), tag events with a `t` value and query on it. Don't do this for generic platforms — it would silo content.
## Content vs. Tags
- **`content`** — large freeform text or existing industry-standard JSON (GeoJSON, FHIR, Tiled maps). Kind 0 is the one exception where structured JSON goes in content.
- **Tags** — queryable metadata, structured data, anything you might filter on.
- **Empty content is fine.** `content: ""` is idiomatic for tag-only events.
- **If you need to filter by a field, it must be a tag** — relays don't index content.
```json
// ✅ Queryable
{ "kind": 30402, "content": "",
"tags": [["d", "product-123"], ["title", "Camera"], ["price", "250"], ["t", "photography"]] }
// ❌ Structured data buried in content
{ "kind": 30402, "content": "{\"title\":\"Camera\",\"price\":250}", "tags": [["d", "product-123"]] }
```
## `NIP.md`
`NIP.md` documents Ditto's custom kinds and any extensions to existing NIPs. Whenever you mint a new kind or change a custom schema, **create or update `NIP.md`** with the tag list, content format, and intended usage. If a kind you add is effectively the same shape as an existing NIP, note the NIP reference rather than duplicating the spec.
Standard NIPs (like NIP-84 Highlights, NIP-23 Articles) do **not** go in `NIP.md` — only Ditto-custom kinds and Ditto-specific extensions.
## After Designing — What's Next?
Once you've settled on a kind number and tag shape, you still need to render it in Ditto's UI. Load the **`nostr-kind-rendering`** skill for the full multi-location registration checklist (feed cards, detail pages, embedded previews, kind-label maps, notifications, feed-toggle registration).
@@ -0,0 +1,185 @@
---
name: nostr-kind-rendering
description: Add UI rendering for an event kind Ditto doesn't yet display — feed cards, detail pages, embedded previews, notifications, routes, feed-toggle registration, and the several kind-label maps (KIND_LABELS, KIND_HEADER_MAP, NOTIFICATION_KIND_NOUNS, CommentContext) that must stay in sync. Load when asked to "support / display / render" a NIP or kind number, when a kind renders blank or as "Kind 12345", or when quote embeds of a kind show "This event kind is not supported".
---
# Nostr Kinds — UI Rendering Checklist
Ditto's kind dispatch is **spread across many files** by design — feed cards, detail pages, embedded previews, notifications, and several kind-label maps each have their own rendering requirements. The central `KIND_LABELS` registry covers the easy cases, but most context-specific maps (grammar, icons, verbs) cannot be derived mechanically and must be updated manually.
**Missing any location causes visible bugs**: a kind might render blank in quote posts, show "Kind 12345" as a label, skip its action header, tombstone as "This event kind is not supported" in embeds, or — worst of all — have its content fed through the kind-1 tokenizer and auto-linkify URLs/hashtags that weren't authored by the event creator.
**When in doubt, grep for an existing kind number like `30617` or `9802`** — you'll find every registration point you need to mirror.
## Decision: Feed-toggle + dedicated page, or just rendering?
Before touching code, pick one:
- **Just render it everywhere Nostr content appears** (no feed toggle, no dedicated page). Use when the kind is niche or only reached via direct links / quote embeds. Minimal surface — steps 16 below.
- **Add a feed toggle + optional dedicated page.** Use when users should be able to browse events of this kind or opt them in/out of their home feed. Requires the feed registration (step 7) and `AppConfig` triple (step 8).
When the user asks generally to "support" a kind, ask which direction they want if it's not obvious from context.
## Checklist
### 1. Content card component (`src/components/`)
Create `<MyKindCard event={event} />` that renders the event's tags/content appropriately.
- **Never run event content through the kind-1 tokenizer** (`<NoteContent>` / `<TruncatedNoteContent>`) unless the kind's content is actually free-form user prose. Quote-type content (highlights, snippets, citations) contains URLs and hashtags from the *source*, not the event author — tokenizing them is misleading.
- Render plaintext with `whitespace-pre-wrap break-words` inside a `<p>` instead.
- Route any event-sourced URLs (`r` tags, media URLs, source links) through `sanitizeUrl()` from `@/lib/sanitizeUrl` before using them in `href`/`src`.
- Support an `expanded` prop if the card looks different on the detail page than in the feed.
### 2. Feed card dispatch (`src/components/NoteCard.tsx`)
Three edits in this file:
1. **Flag block** (around lines 384435): add `const isMyKind = event.kind === XXXX;`.
2. **`isTextNote` negation list** (around lines 440475): add `&& !isMyKind`. Without this, unknown kinds fall through to `UnknownKindContent` (showing only the `alt` tag).
3. **Content dispatch ternary** (around lines 578692): add `) : isMyKind ? (<MyKindCard event={event} />`.
4. **`KIND_HEADER_MAP`** (around lines 1710+): add an entry so the feed shows "Alice shared a *noun*" or similar. Pattern:
```ts
9802: {
icon: Highlighter,
action: "shared a",
noun: "highlight",
nounRoute: "/highlights", // omit if no dedicated page
},
```
5. Import the card component and any new lucide icons.
### 3. Detail page dispatch (`src/pages/PostDetailPage.tsx`)
Mirror the three NoteCard edits:
1. **Flag block** (around lines 10211098): `const isMyKind = event.kind === XXXX;`.
2. **`isTextNote` negation list**: add `&& !isMyKind`.
3. **Content dispatch ternary** (around lines 21472251): add `) : isMyKind ? (<MyKindCard event={event} expanded />`.
The loading-state title uses `shellTitleForKind()`, which falls through to `KIND_LABELS` — no override needed unless the kind belongs to a group ("Music Details") or needs composite grammar.
### 4. Central kind label (`src/lib/kindLabels.ts`)
Add a **capitalized noun phrase, no articles** to the `KIND_LABELS` map:
```ts
9802: 'Highlight',
```
This is consumed by the detail-page loading title, nsite permission prompt, signer nudge toasts, and addressable-event preview headers. Ignoring this gives "Kind 9802" everywhere it appears.
### 5. Context-specific label and icon maps
Each of these maps exists because the surrounding UI needs a different grammatical form. They are **not derived** from `KIND_LABELS` and must be updated manually.
- **`src/components/CommentContext.tsx`** — `KIND_LABELS` (uses articles: `'a highlight'`, `'an article'`) and `KIND_ICONS` (lucide component reference). Rendered as "Commenting on {label}". Without an entry you get "an unsupported event".
- **`src/pages/NotificationsPage.tsx`** — `NOTIFICATION_KIND_NOUNS` (bare lowercase nouns: `'highlight'`, `'article'`). Rendered as "reacted to your {noun}". Without an entry you get "post" as a fallback.
- **`src/components/NoteCard.tsx`** — `KIND_HEADER_MAP` (already covered in step 2).
### 6. Embedded previews (`src/components/EmbeddedNote.tsx`)
The quote-embed dispatcher in `EmbeddedNote` (around lines 65110) routes kinds to dedicated compact cards. **Without a branch here, non-content kinds fall through to `EmbeddedNoteCard`, which either:**
- Shows only the NIP-31 `alt` tag (if present), or
- Tombstones as "This event kind is not supported", or
- **Feeds the event's `content` through the kind-1 tokenizer** if the kind is mistakenly treated as a content-kind — auto-linkifying URLs and hashtags that weren't authored by the event creator. This is a security/UX bug.
For any kind whose `content` isn't freeform user prose, add an explicit dispatch branch even if it just renders a minimal compact card. Pattern:
```tsx
if (event.kind === 9802) {
return <EmbeddedHighlightCard event={event} className={className} disableHoverCards={disableHoverCards} />;
}
```
Then define the compact card using `EmbeddedCardShell` for the author row + navigation, and render the kind-specific body inside. See `EmbeddedHighlightCard` and `EmbeddedBadgeAwardCard` for reference.
`src/components/EmbeddedNaddr.tsx` works similarly for addressable kinds — add a branch there if your kind is addressable.
### 7. Feed/sidebar registration (`src/lib/extraKinds.ts`)
Only needed if you decided on "feed-toggle + dedicated page" above. Add an `ExtraKindDef`:
```ts
{
kind: 9802,
id: 'highlights',
showKey: 'showHighlights',
feedKey: 'feedIncludeHighlights',
label: 'Highlights',
description: 'Noteworthy excerpts from articles, posts, and the web (NIP-84)',
route: 'highlights', // omit for feed-only registration
addressable: false,
section: 'social', // feed | media | social | development | whimsy
blurb: 'Longer marketing copy shown in the info modal.',
},
```
Then:
- **Sidebar icon** (`src/lib/sidebarItems.tsx`) — add `{ id: "highlights", label: "Highlights", path: "/highlights", icon: Highlighter }` to `SIDEBAR_ITEMS`, and import the icon at the top. `CONTENT_KIND_ICONS` picks up the icon automatically from the sidebar definition.
- **Route** (`src/AppRouter.tsx`) — add `const highlightsDef = getExtraKindDef("highlights")!;` at the top of the file and a `<Route path="/highlights" element={<KindFeedPage kind={highlightsDef.kind} title={highlightsDef.label} icon={sidebarItemIcon("highlights", "size-5")} />} />` above the catch-all `*` route.
### 8. `AppConfig` triple (required if you added feed/sidebar toggle keys in step 7)
Three files must stay in sync, or the build fails or the setting silently no-ops:
1. **`src/contexts/AppContext.ts`** — add the fields to the `FeedSettings` interface with JSDoc comments.
2. **`src/lib/schemas.ts`** — add the same fields to `FeedSettingsSchema` as `z.boolean().optional()`. `DittoConfigSchema` is derived from `AppConfigSchema` with `.strict()` mode, so any `ditto.json` field missing from Zod is a build error.
3. **`src/App.tsx`** — add the default value in the initial `feedSettings` block.
4. **`src/test/TestApp.tsx`** — mirror the default in test config so component tests work.
Convention: `show*` toggles default to `true` (sidebar entries visible), `feedInclude*` toggles default to `false` for niche content, `true` for core feed content.
### 9. Notification integration (if applicable)
Load this step when the kind represents an interaction with the user's content (reactions, reposts, highlights, awards, etc.) — i.e. when an event author "does something with" another user's content via an `e`/`a`/`p` tag.
**Six files** to update:
1. **`src/hooks/useEncryptedSettings.ts`** — add `highlights?: boolean` (or equivalent) to the `notificationPreferences` object.
2. **`src/lib/notificationKinds.ts`** — add the kind to `ALL_NOTIFICATION_KINDS` and add a `if (p.X !== false) kinds.push(XXXX);` line in `getEnabledNotificationKinds`.
3. **`src/lib/notificationTemplates.ts`** — add a `NOTIFICATION_TEMPLATES` entry with a title and body for nostr-push server-side notifications.
4. **`src/pages/NotificationSettings.tsx`** — extend `NotificationPrefKey` union, add a row to `NOTIFICATION_TYPES` with icon/label/description/kinds.
5. **`src/hooks/useNotifications.ts`** — extend `groupKey` (decide if events of this kind group by referenced event or stand alone), and if it's a "did something to your content" kind, add it to the author-ownership filter so users only get notified for interactions with their own content.
6. **`src/pages/NotificationsPage.tsx`** — add a case to `GroupedNotificationView`'s switch; write `MyKindNotification` + `MyKindNotificationGroup` components modeled on `RepostNotification` / `LikeNotification`.
### 10. Spam guards (`src/lib/feedUtils.ts`)
If the kind has required tags (NIP-spec-mandated references, minimum content, etc.), add a check in `shouldHideFeedEvent` to hide events that don't meet the minimum bar. This pre-filters events before `NoteCard` mounts them, avoiding layout shifts from components that would return `null`.
Example:
```ts
// NIP-84 highlights with no excerpt AND no source reference.
if (event.kind === 9802) {
const hasContent = event.content.trim().length > 0;
const hasSource = event.tags.some(([n]) => n === 'a' || n === 'e' || n === 'r');
if (!hasContent && !hasSource) return true;
}
```
### 11. `NIP.md` (custom kinds only)
If the kind is a Ditto-custom kind or a Ditto-specific extension of an existing NIP, document it in `NIP.md` — see the **`nostr-kind-design`** skill for the format. Standard NIPs (like NIP-84, NIP-23) do not go in `NIP.md`.
## Validation
After making changes, run `npm run test` — it runs `tsc --noEmit`, `eslint`, `vitest`, and `vite build` in sequence. All must pass. Additions to the `AppConfig` triple in particular frequently break the build if one of the four files is missed.
## Why so many locations?
These are genuinely different UI contexts (feed cards, detail pages, embedded previews, comment-context labels, notifications, sidebar routes) with different rendering requirements and grammar needs. The central `KIND_LABELS` in `src/lib/kindLabels.ts` handles the common "what to call this kind" case, but feed headers, comment-context text, and notification verbs each need their own grammar, and notification integration involves a whole independent subsystem.
## Bugs that signal a missed step
- **"Kind 12345" shown as a label** → step 4 (`KIND_LABELS`).
- **"an unsupported event" in CommentContext** → step 5 (`CommentContext` maps).
- **"reacted to your **post**"** when it should say "highlight" → step 5 (`NOTIFICATION_KIND_NOUNS`).
- **No action header above a feed card** → step 2.4 (`KIND_HEADER_MAP`).
- **Blank / `alt`-only card in quote embeds** → step 6 (`EmbeddedNote` dispatcher).
- **URLs/hashtags in quoted text auto-linkified** → step 6 (embedded dispatcher forgot to bypass the kind-1 tokenizer).
- **Kind doesn't appear in the home feed even with the toggle on** → step 7 (`ExtraKindDef` missing `feedKey`).
- **Build error mentioning a missing `FeedSettings` field** → step 8 (one of the three files out of sync).
- **Users not notified when their content is interacted with** → step 9 (notification stack).
+115
View File
@@ -0,0 +1,115 @@
---
name: nostr-publishing
description: Publish Nostr events with useNostrPublish. Covers the basic publishing pattern, safely mutating replaceable and addressable events (read-modify-write via fetchFreshEvent + prev), published_at preservation, and d-tag collision prevention for new addressable content.
---
# Publishing Nostr Events
Use this skill when a feature needs to publish events — notes, reactions, list updates, profile edits, addressable content, etc. Covers the `useNostrPublish` hook, the correct read-modify-write pattern for replaceable/addressable lists, and d-tag collision prevention.
## The `useNostrPublish` Hook
`useNostrPublish` publishes an event through the app's connection pool and auto-adds a `client` tag. Always guard calls with `useCurrentUser` — publishing requires a signer.
```tsx
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
export function PostForm() {
const { user } = useCurrentUser();
const { mutate: createEvent } = useNostrPublish();
if (!user) return <span>You must be logged in to post.</span>;
return (
<button onClick={() => createEvent({ kind: 1, content: 'hello' })}>
Post
</button>
);
}
```
Prefer `mutateAsync` over `mutate` when the caller needs to `await` the published event (e.g. to navigate to the new event's page, or to chain another publish).
## Mutating Replaceable and Addressable Events (CRITICAL)
Replaceable (kind 10000-19999) and addressable (kind 30000-39999) events require a **read-modify-write** cycle: fetch the current event, modify its tags, publish a new version. **Never read from the TanStack Query cache before mutating** — the cache can be stale from another device or a rapid prior operation, and republishing stale data silently drops the user's data.
Use `fetchFreshEvent()` from `src/lib/fetchFreshEvent.ts` inside every mutation, and **always pass the fetched event as `prev`** so `useNostrPublish` can preserve `published_at`:
```typescript
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
// Inside a mutation function:
const prev = await fetchFreshEvent(nostr, {
kinds: [10003],
authors: [user.pubkey],
});
const currentTags = prev?.tags ?? [];
// ...modify tags...
await publishEvent({
kind: 10003,
content: prev?.content ?? '',
tags: newTags,
prev: prev ?? undefined,
});
```
This applies to all list-type hooks (bookmarks, pins, interests, follow sets, badges, etc.). See `useFollowActions` and `useMuteList` for complete examples.
### The `prev` Property on Event Templates
`useNostrPublish` accepts an optional `prev` property on the event template — the **previous version** of the event being replaced. The hook uses it to manage the `published_at` tag (NIP-24) automatically:
- **First publish (no `prev`)** — `published_at` is set equal to `created_at`.
- **Update (`prev` provided)** — `published_at` is preserved from the old event.
- **Old event lacks `published_at`** — nothing is fabricated.
- **Caller already set `published_at` in tags** — left alone.
**Convention**: name the local variable `prev` at the call site (not `freshEvent` or `latestEvent`) so it reads naturally when passed to `publishEvent`:
```typescript
const prev = await fetchFreshEvent(nostr, { kinds: [3], authors: [user.pubkey] });
// ...
await publishEvent({ kind: 3, content: prev?.content ?? '', tags: newTags, prev: prev ?? undefined });
```
`prev` is stripped from the template before signing — it never appears in the published Nostr event.
## D-Tag Collision Prevention for Addressable Events
Addressable events (kind 30000-39999) are identified by `pubkey + kind + d-tag`. Publishing an event with the same d-tag as an existing one **silently replaces** it. This is by design for intentional updates (edit flows), but dangerous when creating *new* content with user-derived d-tags (slugs from titles, user-entered identifiers, etc.).
### When to check for collisions
- **Must check** when the d-tag is derived from user input (slugified titles, user-entered identifiers, etc.).
- **No check needed** when the d-tag is a `crypto.randomUUID()`, a canonical format with an embedded pubkey prefix, or intentionally the same as an existing event (edit/update flows).
### Implementation pattern
Before publishing a **new** addressable event with a user-derived d-tag, query for an existing event with that d-tag. If one exists, block the publish and tell the user to change the identifier.
```typescript
// Before publishing a new addressable event:
const slug = slugify(title, { lower: true, strict: true });
const existing = await nostr.query([
{ kinds: [30023], authors: [user.pubkey], '#d': [slug], limit: 1 },
]);
if (existing.length > 0) {
toast({
title: 'Slug already in use',
description: 'Change the slug or edit the existing item.',
variant: 'destructive',
});
return;
}
// Safe to publish
publishEvent({ kind: 30023, content, tags: [['d', slug], ...otherTags] });
```
**Skip the check in edit mode** — when the user explicitly loaded an existing event to update, overwriting is the intended behavior.
Prefer UUID or canonical formats when the d-tag doesn't need to be human-readable. Only use slugified input when the d-tag will appear in URLs or needs to be meaningful to users, and always add a collision check.
+117
View File
@@ -0,0 +1,117 @@
---
name: nostr-queries
description: Query Nostr events efficiently with useNostr + TanStack Query. Covers the standard useQuery pattern, combining related kinds into a single request to avoid rate limiting, and validating events with required tags or strict schemas.
---
# Querying Nostr Events
Use this skill when building a hook that fetches Nostr events. Covers the standard `useNostr` + `useQuery` pattern, efficient query design (combining kinds to avoid relay round-trips), and event validation for kinds with required tags.
## The Standard Pattern
Combine `useNostr` with TanStack Query in a custom hook. Pass the abort signal from `c.signal` into `nostr.query` so cancelled queries free relay resources:
```typescript
import { useNostr } from '@nostrify/react';
import { useQuery } from '@tanstack/react-query';
function usePosts() {
const { nostr } = useNostr();
return useQuery({
queryKey: ['posts'],
queryFn: async (c) => {
const events = await nostr.query(
[{ kinds: [1], limit: 20 }],
{ signal: c.signal },
);
return events;
},
});
}
```
Transform events into a domain model inside the `queryFn` if needed — callers should rarely see raw `NostrEvent`s. Multiple calls to `nostr.query()` inside one `queryFn` are fine for compound queries that can't be expressed as a single filter.
## Efficient Query Design
**Always minimize the number of separate round-trips** to relays. Each query consumes relay capacity and may count against rate limits.
**✅ Efficient — single query with multiple kinds:**
```typescript
// Query repost variants in one request
const events = await nostr.query([{
kinds: [1, 6, 16],
'#e': [eventId],
limit: 150,
}]);
// Separate by kind in JavaScript
const notes = events.filter((e) => e.kind === 1);
const reposts = events.filter((e) => e.kind === 6);
const genericReposts = events.filter((e) => e.kind === 16);
```
**❌ Inefficient — three separate round-trips:**
```typescript
const [notes, reposts, genericReposts] = await Promise.all([
nostr.query([{ kinds: [1], '#e': [eventId] }]),
nostr.query([{ kinds: [6], '#e': [eventId] }]),
nostr.query([{ kinds: [16], '#e': [eventId] }]),
]);
```
### Optimization rules
1. **Combine kinds** into one filter: `kinds: [1, 6, 16]`.
2. **Use multiple filter objects** in a single `nostr.query()` call when different tag filters are needed simultaneously.
3. **Raise the `limit`** when combining kinds so you still receive enough of each type.
4. **Split by kind in JavaScript**, not by making separate requests.
5. **Respect relay capacity** — heavy parallel queries can trigger rate limits even when each individually would be fine.
## Event Validation
For kinds with required tags or strict schemas (most custom kinds, anything beyond kind 1), filter query results through a validator before returning them. Loose kinds (kind 1 text notes) rarely need validation — all tags are optional and `content` is freeform.
```typescript
import type { NostrEvent } from '@nostrify/nostrify';
// Example validator for NIP-52 calendar events
function validateCalendarEvent(event: NostrEvent): boolean {
if (![31922, 31923].includes(event.kind)) return false;
const d = event.tags.find(([n]) => n === 'd')?.[1];
const title = event.tags.find(([n]) => n === 'title')?.[1];
const start = event.tags.find(([n]) => n === 'start')?.[1];
if (!d || !title || !start) return false;
// Date-based events require YYYY-MM-DD
if (event.kind === 31922 && !/^\d{4}-\d{2}-\d{2}$/.test(start)) return false;
// Time-based events require a unix timestamp
if (event.kind === 31923) {
const ts = parseInt(start);
if (isNaN(ts) || ts <= 0) return false;
}
return true;
}
function useCalendarEvents() {
const { nostr } = useNostr();
return useQuery({
queryKey: ['calendar-events'],
queryFn: async (c) => {
const events = await nostr.query(
[{ kinds: [31922, 31923], limit: 20 }],
{ signal: c.signal },
);
return events.filter(validateCalendarEvent);
},
});
}
```
Validation is a correctness layer, not a security layer. For trust-sensitive queries (admin actions, addressable events, moderator approvals), also constrain `authors` — see the `nostr-security` skill.
+92
View File
@@ -0,0 +1,92 @@
---
name: nostr-relay-pools
description: Query or publish to specific Nostr relays or curated relay groups using nostr.relay() and nostr.group(), instead of the default connection pool. Useful for debugging, testing, specialized relays, or geographically-targeted publishing.
---
# Targeted Nostr Relay Connections
By default, the `nostr` object returned from `useNostr` uses the app's connection pool: it reads from one of the configured relays and publishes to all of them. For most features this is exactly what you want.
Use this skill when you need **more granular control** — talking to a single relay, a curated group of relays, or debugging a specific relay's behavior.
## Single Relay: `nostr.relay(url)`
```ts
import { useNostr } from '@nostrify/react';
function useSpecificRelay() {
const { nostr } = useNostr();
// Connect to a specific relay
const relay = nostr.relay('wss://relay.damus.io');
// Query from this relay only
const events = await relay.query([{ kinds: [1], limit: 15 }]);
// Publish to this relay only
await relay.event({ kind: 1, content: 'Hello from a specific relay!' });
}
```
**Good fits:**
- Testing a relay's behavior in isolation
- Debugging connectivity or rate-limiting issues
- Querying content that only lives on a specialized relay (paid relays, private relays, niche communities)
- Health checks / admin tooling
## Relay Group: `nostr.group(urls)`
```ts
import { useNostr } from '@nostrify/react';
function useRelayGroup() {
const { nostr } = useNostr();
// Create a group of specific relays
const relayGroup = nostr.group([
'wss://relay.damus.io',
'wss://relay.primal.net',
'wss://nos.lol',
]);
// Query from all relays in the group (deduplicated)
const events = await relayGroup.query([{ kinds: [1], limit: 15 }]);
// Publish to all relays in the group
await relayGroup.event({ kind: 1, content: 'Hello from a relay group!' });
}
```
**Good fits:**
- Publishing to a curated set of trusted relays for a specific feature
- Community-scoped queries (e.g. a set of relays known to host a particular topic)
- Geographic/region-targeted delivery
- Load-balancing reads across a known-good subset
## API Consistency
Both the `relay` object and the `group` object expose the **same interface** as the top-level `nostr` object:
- `.query(filters, opts?)` — request events matching filters
- `.req(filters, opts?)` — open a streaming subscription
- `.event(event)` — publish a signed event
- All other Nostrify methods
This means you can drop them into any existing hook or helper that expects a `nostr`-shaped object.
## Choosing Between Pool, Group, and Single Relay
| Scenario | Use |
|----------------------------------------------------|---------------------|
| Default app queries, best reach for publishing | `nostr` (pool) |
| Trusted subset, community-specific publishing | `nostr.group([…])` |
| Single-relay debugging or specialized relay access | `nostr.relay(url)` |
## Tips
- **Don't hard-code user-facing relay lists.** If a feature should publish to "the user's write relays", read from `AppContext.config.relayMetadata` (NIP-65) instead of hard-coding URLs.
- **Compose with TanStack Query.** Wrap `relay.query(...)` / `group.query(...)` inside a `useQuery` hook exactly as you would with the default `nostr` object; the caching layer is identical.
- **Handle unreachable relays.** Specific relays can be offline, rate-limited, or slow. Always wrap calls in `try/catch` and respect the abort signal from the query function (`c.signal`).
- **Avoid leaking subscriptions.** When using `.req(...)` for streaming, always close the subscription on unmount (`controller.abort()` or the returned disposer).
+140
View File
@@ -0,0 +1,140 @@
---
name: nostr-security
description: Threat model and defenses for Ditto as a web Nostr client — why XSS is catastrophic when nsec keys live in localStorage, CSP as defense-in-depth, URL and CSS sanitization for untrusted event data, and author filtering for trust-sensitive queries (admin actions, moderators, addressable events, NIP-72 communities). Load when building trust-boundary features, rendering user-controlled URLs or markup, interpolating event data into CSS, or reviewing the app's security posture.
---
# Nostr Security
## Threat model
**Nostr private keys (`nsec`) are stored in plaintext in `localStorage`.** Any JavaScript running on the origin can read them with `localStorage.getItem('nostr-login')`. A successful XSS = instant, silent, irreversible key theft — no rotation, no revocation, permanent impersonation across every Nostr client the user ever touches. External signers (NIP-07 extension, NIP-46 bunker) don't change this: an XSS can still ask the active signer to sign arbitrary events, drain funds via zaps, or scrape DMs as they decrypt.
**Treat every piece of untrusted data as a script-injection vector** — event tags, `content`, metadata, URL params, relay responses.
## Defense-in-depth
**Content Security Policy.** `index.html` ships a restrictive CSP: `default-src 'none'`, `script-src 'self'` (no inline scripts, no `eval`), `base-uri 'self'`, `connect-src 'self' https: wss:`. The one intentional gap is `style-src 'unsafe-inline'` — required by Tailwind/shadcn — which means **CSS injection is not blocked by CSP; sanitization is on you**. When modifying CSP, only narrow it. Never add `'unsafe-eval'`, `'unsafe-inline'` on `script-src`, `http:`, or wildcard sources.
**Never use `dangerouslySetInnerHTML`, `innerHTML`, `insertAdjacentHTML`, or `document.write`** with event data, URL params, or any other untrusted string. React's JSX auto-escapes interpolated strings — the moment you bypass that, CSP alone won't save you. If you must render HTML from event data, pipe it through a strict allowlist sanitizer (DOMPurify, already installed) at the parse layer.
**Sanitize URLs and CSS values** — see §1 and §2.
## 1. URL sanitization
Any URL from event tags, `content`, metadata fields (`picture`, `banner`, `website`, `nip05`, etc.), or relay hints is untrusted. Threats beyond `javascript:` XSS: `data:` resource exhaustion / phishing, `http://` IP leaks, relative paths triggering same-origin requests, malformed strings crashing downstream parsers.
**Use the shipped helper at `src/lib/sanitizeUrl.ts`:**
```ts
import { sanitizeUrl } from '@/lib/sanitizeUrl';
// Single URL — returns the normalised href, or undefined if not valid https
const url = sanitizeUrl(getTag(event.tags, 'url'));
if (url) {
// safe to use in any context
}
// Array of URLs — filter out invalid entries
const links = getAllTags(event.tags, 'r')
.map(([, v]) => sanitizeUrl(v))
.filter((v): v is string => !!v);
```
`sanitizeUrl` returns the normalised `href` string only when the URL parses successfully **and** uses the `https:` protocol. All other inputs (malformed URLs, `javascript:`, `data:`, `http:`, relative paths, etc.) return `undefined`.
**Sanitize at the parse layer.** When writing a parser function that extracts URLs from event tags (e.g. `parseThemeDefinition`, `parseBadgeDefinition`), apply `sanitizeUrl()` before returning the parsed data. This way every downstream consumer is automatically protected without needing to remember to sanitize at each usage site.
**When sanitization is NOT required:** URLs matched by a regex that constrains the protocol (e.g. `NoteContent`'s tokenizer matching `https?://...` — the regex *is* the sanitizer), hardcoded/app-generated URLs (relay configs, internal routes), and strings rendered as plain text that never land in an attribute, CSS value, or network request.
## 2. CSS injection
Event data interpolated into CSS (a `<style>` element, `style=""`, or an injected stylesheet) is a CSS injection vector. A `"`, `)`, `}`, or `;` in the value can break out of the string context and inject rules — overlay phishing, hide UI, exfiltrate via `background-image: url()` requests.
Common surfaces: `background-image: url("${url}")`, `font-family: "${family}"`, `@font-face { src: url("${url}") }`.
**Mitigation:**
- **URLs in `url()`** — use `sanitizeUrl()`. The `URL` constructor percent-encodes `"`, `)`, `\` and rejects non-`https:`. This is already done for theme event background and font URLs in `src/lib/themeEvent.ts`.
- **Non-URL strings** (font-family, animation names) — use `sanitizeCssString()` from `src/lib/fontLoader.ts`, which allowlists Unicode letters/numbers, spaces, hyphens, underscores, apostrophes, and periods:
```ts
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import { sanitizeCssString } from '@/lib/fontLoader';
// ❌ UNSAFE
style.textContent = `body { background-image: url("${rawUrl}"); font-family: "${rawFamily}"; }`;
// ✅ SAFE — validate URLs, allowlist identifiers
const bgUrl = sanitizeUrl(rawUrl);
const family = sanitizeCssString(rawFamily ?? '');
if (bgUrl && family) {
style.textContent = `body { background-image: url("${bgUrl}"); font-family: "${family}"; }`;
}
```
If you can't justify the exact characters you're allowing, the policy is wrong.
## 3. Author filtering for trust-sensitive queries
Even with perfect XSS defenses, an attacker can publish forged events your UI will trust unless queries constrain `authors`. Relays are dumb pipes — any matching event comes back.
**Filter by `authors` when:**
- Querying admin/moderator/owner events — use a hardcoded trusted-pubkey list (e.g. `ADMIN_PUBKEYS` from `src/lib/admins`).
- Querying addressable events (kinds 3000039999) — the `d` tag alone is not a trust boundary; the `(kind, pubkey, d)` triple is.
- Querying user-owned replaceable events (profile metadata, relay lists, mute lists) — `authors: [userPubkey]`.
**Do NOT filter by `authors`** for public UGC (kind 1 notes, reactions, zaps, discovery feeds) — anyone can post there by design.
```ts
// ❌ Anyone can publish kind 30078 with this d-tag and self-appoint
nostr.query([{ kinds: [30078], '#d': ['pathos-organizers'], limit: 1 }]);
// ✅ Only trust the admin list
nostr.query([{ kinds: [30078], authors: ADMIN_PUBKEYS, '#d': ['pathos-organizers'], limit: 1 }]);
```
**Routes for addressable/replaceable events must include the author** — otherwise the route handler can't construct a secure filter:
```tsx
// ❌ Any pubkey can squat the slug
<Route path="/article/:slug" element={<Article />} />
// ✅ Filter can include authors
<Route path="/article/:npub/:slug" element={<Article />} />
```
### NIP-72 community moderation
Kind 4550 approvals are only trustworthy if signed by a moderator from the community definition (kind 34550). Two-step query:
```ts
// 1. Fetch community definition — author-filter by the owner.
const [community] = await nostr.query([{
kinds: [34550], authors: [communityOwnerPubkey], '#d': [communityId], limit: 1,
}]);
if (!community) return [];
// 2. Extract moderator pubkeys from `p` tags with role "moderator".
const moderators = community.tags
.filter(([n, , , role]) => n === 'p' && role === 'moderator')
.map(([, pubkey]) => pubkey);
// 3. Query approvals — only from moderators.
const approvals = await nostr.query([{
kinds: [4550],
authors: moderators,
'#a': [`34550:${communityOwnerPubkey}:${communityId}`],
limit: 100,
}]);
```
Without step 3's `authors` filter, anyone can publish a kind 4550 "approval".
## Pre-merge checklist
- [ ] No `dangerouslySetInnerHTML` / `innerHTML` / `document.write` with untrusted data.
- [ ] CSP unchanged or narrowed; no new `'unsafe-eval'`, `'unsafe-inline'` on `script-src`, `http:`, or wildcards.
- [ ] Every event-sourced URL passes `sanitizeUrl()` before reaching `href`, `src`, `srcSet`, `poster`, iframe `src`, or CSS.
- [ ] Every event-sourced string in CSS passes `sanitizeUrl()` (URLs) or `sanitizeCssString()` (identifiers).
- [ ] Every trust-sensitive query includes `authors`.
- [ ] Routes for addressable/replaceable events carry the author in the URL.
+111 -8
View File
@@ -87,6 +87,8 @@ Prepend a new section to `CHANGELOG.md` directly below the `# Changelog` heading
```markdown
## [X.Y.Z] - YYYY-MM-DD
A short single-paragraph summary of this release written in plain prose -- max 500 characters. This appears on the App Store, Google Play, and the in-app "what's new" toast.
### Added
- Description of new features
@@ -100,7 +102,100 @@ Prepend a new section to `CHANGELOG.md` directly below the `# Changelog` heading
- Description of removed features
```
**Rules:**
#### The Summary Paragraph
Every release section MUST start with a single plaintext paragraph (not a bullet, not a heading) that summarises the release for app-store-style audiences:
- **Single paragraph, plain prose.** No bullets, no headings, no Markdown formatting beyond plain text.
- **Max ~500 characters.** Apple App Store and Google Play both cap "What's new" text at 500. The CI `release-notes` job warns when the summary is longer.
- **Audience: end users discovering the update.** Describe the most noticeable user-visible changes; omit internal cleanups even if they're in the bullets below.
- **Tone matches the bullets.** Present-tense, no Nostr jargon, no NIP/kind numbers (see Rules below).
- **Maintenance releases** -- write a one-sentence summary like `A behind-the-scenes maintenance release with no user-facing changes.` Don't leave it blank; the CI fallback `Ditto vX.Y.Z` is a last resort for legacy entries, not new ones.
The same paragraph is used in three places automatically:
- **App Store** -- "What's New in This Version" via fastlane `deliver`
- **Google Play** -- "What's new in this version" via fastlane `supply` `metadata/android/<lang>/changelogs/<versionCode>.txt`
- **In-app toast** -- the `What's new in vX.Y.Z` toast that fires when users load a new version (see `src/components/VersionCheck.tsx`)
- The full section (summary + lists) goes into the GitLab Release description.
Extraction is handled by `scripts/extract-release-notes.mjs`; you don't have to write store-specific copy.
#### Changelog Quality Checklist
Before drafting any entries, run through this checklist. It is NOT optional -- skipping steps here is the most common way a release goes out with misleading notes.
##### 5.1. Diff the code, not just the commit log
Commit messages describe intent at the moment of commit; they over- and under-represent the cumulative effect at release time. Before drafting entries, **run a real diff** for each area of substantial change:
```bash
# Full diff between tags
git diff v<prev>..HEAD
# Or narrowed to an area you're unsure about
git diff v<prev>..HEAD -- src/components/ComposeBox.tsx
```
Only the diff reveals intra-release churn (commits that cancel each other out, bugs introduced and then fixed, refactors that land and get reverted). Reading commit messages alone is insufficient.
##### 5.2. Trace every candidate "Fixed" entry to its origin commit
For each bug fix you're considering listing, find the commit that introduced the bug.
**Fast path -- check for `Regression-of:` trailers** (see AGENTS.md "Attributing Regressions"). If the fix commit declares its origin in a trailer, you don't need to hunt:
```bash
# List all commits in the release window with their Regression-of trailers (if any)
git log v<prev>..HEAD --no-merges \
--format='%h %s%n Regression-of: %(trailers:key=Regression-of,valueonly,separator=%x20)'
```
For each `Regression-of: <sha>` entry, check whether `<sha>` is also in the release window:
```bash
# Returns 0 if <sha> is BEFORE v<prev> (pre-existing bug -> legit "Fixed" entry)
# Returns non-zero if <sha> is AFTER v<prev> (intra-release -> omit from "Fixed")
git merge-base --is-ancestor <sha> v<prev>
```
**Fallback -- manual tracing** (when no trailer is present):
```bash
# Show the history of a file across all commits
git log --oneline v<prev>..HEAD -- path/to/file.tsx
# Or blame the specific lines the fix touched
git blame -L <start>,<end> -- path/to/file.tsx
```
**If the introducing commit is also in this release window (i.e. after the previous tag), the bug is intra-release.** The user on the previous version never experienced it. Do NOT list it as a "Fixed" entry. Fold it into the relevant "Added" or "Changed" entry, or omit it entirely.
##### 5.3. The "Would a user on the previous version notice this?" test
The changelog describes the delta between the previous release and this one **from the user's perspective** -- not the development history. Before writing each entry, ask:
> "Did a user on the previous published version experience this exact thing?"
- If they experienced a broken state that is now fixed: **"Fixed" entry**
- If they experienced the old behavior and now see new behavior: **"Changed" or "Added" entry**
- If they never saw either state (introduced AND resolved within this release window): **omit entirely**
This applies to more than just bugs:
- A feature added and then reverted in the same release: omit both
- A refactor that was done and then undone: omit both
- A performance regression introduced and then fixed: omit both
- A typo introduced in a new string and then corrected: mention the new string (if user-facing) as a single "Added"/"Changed" entry, with no "Fixed" entry
##### 5.4. Worked example -- intra-release bug
> **Scenario:** Commit A overhauls the compose box and, as a side effect, breaks the background of the expanded emoji picker. Commit B, later in the same release window, restores the background.
>
> **Correct changelog:** One "Added" entry describing the compose box overhaul. The emoji picker background is part of the finished state the user receives.
>
> **Incorrect changelog:** An "Added" entry for the overhaul AND a "Fixed" entry for the emoji picker background. The user on the previous version never saw the broken background; listing it invents a problem they didn't have and makes the release notes read like a developer changelog.
#### Rules
- Only include categories that have entries (omit empty categories)
- Write **user-facing descriptions**, not raw commit messages
- Keep descriptions concise -- one line per change
@@ -109,9 +204,9 @@ Prepend a new section to `CHANGELOG.md` directly below the `# Changelog` heading
- Focus on what the user sees/experiences, not internal implementation details
- Use the current date in YYYY-MM-DD format
- **Never use Nostr protocol jargon.** NIP numbers (e.g., "NIP-89", "NIP-17"), kind numbers (e.g., "kind 30078"), and other protocol-level references must not appear in the changelog. Describe the feature in plain language from the user's perspective. For example, write "App cards for Nostr apps" instead of "App cards for Nostr apps (NIP-89)". The changelog audience is end users, not protocol developers.
- **Collapse related work into one entry.** If a feature was added and then fixed/tweaked across multiple commits in the same release, present the finished result as a single "Added" entry. Never list something as "Added" and then also list fixes for that same thing -- the user sees the end product, not the development history.
- **Only ship what the user sees.** If a bug was introduced AND fixed within this release, the user never saw it -- omit the fix entirely (or fold the net result into the relevant Added/Changed entry). The same applies to features that were added and reverted, refactors that cancel out, and any other intra-release churn. See the Changelog Quality Checklist above (especially 5.2 and 5.3) for the procedure to verify this.
- **Collapse related work into one entry.** If a feature was added and then tweaked across multiple commits in the same release, present the finished result as a single "Added" entry. Never list something as "Added" and then also list fixes for that same thing -- the user sees the end product, not the development history.
- **Omit purely internal changes.** CI fixes, build pipeline tweaks, developer tooling, and infrastructure changes should be omitted from the changelog entirely unless they have a direct, visible impact on the user experience. The changelog is for users, not developers.
- **Compare the actual code between versions** to understand what really changed, rather than just reading commit messages. Commit messages may over- or under-represent the significance of changes.
### Step 6: Update Version in All Files
@@ -190,8 +285,12 @@ git push origin main vX.Y.Z
This triggers the GitLab CI pipeline which will:
1. Build a signed Android APK and AAB
2. Create a GitLab Release with download links
3. Publish the APK to Zapstore
2. Build a signed iOS IPA on the self-hosted Mac runner
3. Extract release notes (full body + summary paragraph) from `CHANGELOG.md`
4. Create a GitLab Release with APK / AAB / IPA download links
5. Publish the APK to Zapstore
6. Publish the AAB to Google Play (production track) with the summary as the "What's new" text
7. Submit the iOS IPA to App Store Connect for review with the summary as the "What's New" text
### Step 12: Confirm
@@ -212,11 +311,15 @@ After pushing, inform the user:
## CI Pipeline
The CI pipeline (`.gitlab-ci.yml`) is triggered by tags matching the pattern `/^v\d+\.\d+\.\d+$/` (e.g., `v2.1.0`). It runs three jobs:
The CI pipeline (`.gitlab-ci.yml`) is triggered by tags matching the pattern `/^v\d+\.\d+\.\d+$/` (e.g., `v2.1.0`). It runs seven jobs:
1. **build-apk**: Builds signed Android APK and AAB, stamps `versionName` and `versionCode` into the build
2. **release**: Creates a GitLab Release with the changelog content and download links
3. **publish-zapstore**: Publishes the APK to Zapstore
2. **build-ipa**: Builds the signed App Store IPA on the self-hosted Mac runner (`tags: [macos]`); stamps `MARKETING_VERSION` and `CFBundleVersion` into the Xcode project. The IPA is uploaded to GitLab's Generic Packages registry and exposed as a CI artifact for downstream jobs
3. **release-notes**: Extracts the version's changelog section and summary paragraph from `CHANGELOG.md` into two artifacts (`release-notes.md` and `release-notes-summary.txt`) consumed by `release`, `publish-app-store`, and `publish-google-play`
4. **release**: Creates a GitLab Release with the full changelog section and APK / AAB / IPA download links
5. **publish-zapstore**: Publishes the APK to Zapstore
6. **publish-google-play**: Uploads the AAB to Google Play production track and writes the release summary to `metadata/android/en-US/changelogs/<versionCode>.txt`
7. **publish-app-store**: Submits the prebuilt IPA to App Store Connect for review with the release summary as the "What's New" text. Runs on the self-hosted Mac runner (`tags: [macos]`) because `fastlane deliver` shells out to Apple's iTMSTransporter to upload the IPA, and that tool only ships inside Xcode — the previous Linux runner crashed at the upload step with `No such file or directory @ dir_chdir0` because `Helper.itms_path` resolved to a missing Xcode path. The build appears in App Store Connect within ~30 minutes; Apple's human review then takes 24-48 hours typically. Once approved, you must release manually in App Store Connect (`automatic_release: false`) — this is the final human gate. For runner operations, match cert rotation, and debugging, load the **`mac-runner`** skill.
## Troubleshooting
+87
View File
@@ -0,0 +1,87 @@
---
name: testing
description: Write Vitest unit tests for React components and hooks using the project's `TestApp` wrapper, jsdom environment, and pre-mocked browser APIs (localStorage, matchMedia, scrollTo, IntersectionObserver, ResizeObserver). Also covers the project policy on when to create new test files.
---
# Testing
Load this skill when the user asks you to write a test, diagnose a bug with a test, or add coverage for a component/hook. Running the existing test script is a standing requirement (see `AGENTS.md`*Validating Your Changes*) and doesn't require this skill.
## Policy: when to create new test files
**Do not create new test files unless one of these applies:**
1. The user explicitly asks for tests.
2. The user describes a specific bug and asks for tests to diagnose it.
3. The user says a problem persists after you tried to fix it.
Never write tests because tool results show failures, because you think tests would be helpful, or because you added a new feature. The request must come from the user.
If none of the above apply, stop — don't create a test file. Keep running the existing test script as usual.
## Test setup
The project uses **Vitest + jsdom** with **React Testing Library** and **jest-dom** matchers. Global setup lives in `src/test/setup.ts` and mocks these browser APIs that jsdom doesn't provide (or that Node's built-ins conflict with):
- `localStorage` — a Map-backed mock, because Node 22's built-in `localStorage` lacks the Web Storage API surface jsdom expects
- `window.matchMedia`
- `window.scrollTo`
- `IntersectionObserver`
- `ResizeObserver`
If your component needs another browser API, extend `src/test/setup.ts` rather than mocking per-file.
## Writing a component test
Wrap rendered components in `TestApp` (`src/test/TestApp.tsx`) so all context providers — `UnheadProvider`, `AppProvider`, `QueryClientProvider`, `NostrLoginProvider`, `NostrProvider`, `BrowserRouter`, etc. — are available. Without it, hooks like `useQuery`, `useNostr`, `useAppContext`, or `useNavigate` will throw.
```tsx
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { TestApp } from '@/test/TestApp';
import { MyComponent } from './MyComponent';
describe('MyComponent', () => {
it('renders correctly', () => {
render(<TestApp><MyComponent /></TestApp>);
expect(screen.getByText('Expected text')).toBeInTheDocument();
});
});
```
## Writing a hook test
Use `renderHook` from `@testing-library/react` and pass `TestApp` as the `wrapper`:
```tsx
import { describe, it, expect } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { TestApp } from '@/test/TestApp';
import { useMyHook } from './useMyHook';
describe('useMyHook', () => {
it('returns expected data', async () => {
const { result } = renderHook(() => useMyHook(), { wrapper: TestApp });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toBeDefined();
});
});
```
Files placed next to the code under test with the `.test.ts` / `.test.tsx` suffix are picked up automatically. For reference, see `src/test/ErrorBoundary.test.tsx`.
## Running tests
The `npm test` script runs `tsc --noEmit`, `eslint`, `vitest run`, and `vite build` in sequence. Always run it after changes — a passing test file alone doesn't mean your task is done.
For fast iteration, run just Vitest:
```bash
npx vitest run
```
Or in watch mode while editing:
```bash
npx vitest
```
+127
View File
@@ -0,0 +1,127 @@
---
name: theming
description: Customize Ditto's visual design — install Google Fonts via @fontsource, change the color scheme, configure light/dark themes, and apply consistent component styling patterns with Tailwind and CSS variables.
---
# Theming, Fonts, and Color Schemes
Use this skill when the user wants to change fonts, colors, light/dark appearance, or general visual styling. Ditto ships with a light/dark theme system built on CSS custom properties and Tailwind v3, plus a `useTheme` hook for runtime switching.
## Adding Fonts
Any Google Font can be installed via the `@fontsource` / `@fontsource-variable` packages.
1. **Install the font package.** Prefer the variable version when available.
```bash
npm install @fontsource-variable/inter
```
Package naming:
- `@fontsource-variable/<font-name>` — variable fonts (preferred; one file, all weights)
- `@fontsource/<font-name>` — static fonts
2. **Import the font once** in `src/main.tsx`:
```ts
import '@fontsource-variable/inter';
```
3. **Register the family** in `tailwind.config.ts`:
```ts
export default {
theme: {
extend: {
fontFamily: {
sans: ['Inter Variable', 'Inter', 'system-ui', 'sans-serif'],
},
},
},
};
```
### Suggested families by use case
- **Modern / Clean:** Inter Variable, Outfit Variable, Manrope
- **Professional / Corporate:** Roboto, Open Sans, Source Sans Pro
- **Creative / Artistic:** Poppins, Nunito, Comfortaa
- **Monospace / Code:** JetBrains Mono, Fira Code, Source Code Pro
For expressive hierarchies, pair a sans body font with a display/serif heading font (e.g. Inter + Playfair Display) and expose the second family as `fontFamily.serif` or `fontFamily.display` in Tailwind.
### Runtime font loading from Nostr events
Ditto also supports loading fonts referenced from Nostr events (theme events, letter stationery, etc.) through `src/lib/fontLoader.ts`. That path is separate from the build-time `@fontsource` approach — it constructs `@font-face` rules at runtime from sanitized URLs. Never feed event data through the `@fontsource` path; always go through `fontLoader` so the URL and family name are passed through `sanitizeUrl()` and `sanitizeCssString()` (see the `nostr-security` skill).
## Color Schemes
Colors are defined as CSS custom properties in `src/index.css` under two selectors:
- `:root` — light-mode values
- `.dark` — dark-mode overrides
When the user requests a new color scheme:
1. **Update both `:root` and `.dark`** in `src/index.css`. Each variable is an HSL triplet (no `hsl()` wrapper), e.g. `--primary: 222 47% 11%;`.
2. **Keep contrast ratios ≥ 4.5:1** for body text and interactive elements. Test both modes.
3. **Prefer extending Tailwind's palette** (`tailwind.config.ts`) over hard-coding hex values in components — this keeps the theme consistent and dark-mode-friendly.
4. **Apply colors through semantic tokens** (`bg-primary`, `text-muted-foreground`, `border-input`) rather than raw palette names when possible, so future theme changes propagate.
The shadcn/ui components consume these semantic tokens, so changing the variables automatically restyles the entire component library.
## Light/Dark Theme Switching
Ditto includes:
- **`useTheme` hook** (`src/hooks/useTheme.ts`) — read and set the current theme programmatically.
- **CSS custom properties** in `src/index.css` — one set in `:root`, dark overrides in `.dark`.
- **Automatic persistence** via the `AppContext` config (`config.theme`), saved to local storage.
To add a theme toggle:
```tsx
import { useTheme } from '@/hooks/useTheme';
import { Button } from '@/components/ui/button';
import { Moon, Sun } from 'lucide-react';
export function ThemeToggle() {
const { theme, setTheme } = useTheme();
return (
<Button
variant="ghost"
size="icon"
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
>
{theme === 'dark' ? <Sun className="size-4" /> : <Moon className="size-4" />}
</Button>
);
}
```
## Component Styling Patterns
- **Class merging:** use the `cn()` utility (`@/lib/utils`) to combine conditional classes and override defaults without class-order bugs.
- **Variants:** follow shadcn/ui's `class-variance-authority` pattern for component variants (`variant`, `size`). Copy an existing `ui/` component as a template.
- **Responsive design:** lean on Tailwind breakpoints (`sm:`, `md:`, `lg:`) rather than JS media queries. Use `useIsMobile` only when layout must change based on JS-measured viewport.
- **Interactive states:** always define `hover:`, `focus-visible:`, and `disabled:` states for clickable elements. Focus rings should use `ring-ring` / `ring-offset-background` so they pick up theme colors.
- **Spacing:** an 8px grid (Tailwind's default 4-based scale) keeps visual rhythm consistent. Common paddings: `p-4`, `p-6`; gaps: `gap-2`, `gap-4`.
- **Depth:** soft shadows (`shadow-sm`, `shadow-md`), subtle gradients, and `rounded-lg` / `rounded-xl` corners match Ditto's aesthetic. Avoid heavy drop shadows.
### Negative z-index gotcha
When placing decorative elements behind content with `-z-10` (e.g. blurred background gradients), **add `isolate` to the parent container**. Without `isolate`, the negative z-index escapes the local stacking context and the element disappears behind the page's background color.
```tsx
<section className="relative isolate">
<div className="absolute inset-0 -z-10 bg-gradient-to-br from-primary/20 to-transparent" />
{/* content */}
</section>
```
## Design Quality Checklist
Before finishing a visual change, verify:
- [ ] Both light and dark modes look correct — no hard-coded colors, all text readable.
- [ ] Contrast ratios meet WCAG AA (≥ 4.5:1 for body, ≥ 3:1 for large text).
- [ ] Interactive elements have visible `hover`, `focus-visible`, and `disabled` states.
- [ ] Layout is responsive down to ~360px width without horizontal scroll.
- [ ] Animations respect `prefers-reduced-motion` (Tailwind: `motion-safe:` / `motion-reduce:`).
- [ ] Spacing is consistent — no one-off `p-[13px]` style values.
+4
View File
@@ -3,5 +3,9 @@ VITE_PLAUSIBLE_DOMAIN="example.tld"
VITE_PLAUSIBLE_ENDPOINT="https://plausible.example.tld/api/event"
# Hex pubkey of the nostr-push server (found in nostr-push startup logs as "worker_pubkey")
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://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="*"
+2
View File
@@ -11,6 +11,8 @@ node_modules
dist
dist-ssr
*.local
.eslintcache
.tsbuildinfo
yarn.lock
deploy.sh
+219 -24
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,21 +93,38 @@ deploy-nsite:
--use-fallback-relays
--use-fallback-servers
build-web:
release-notes:
stage: build
timeout: 10 minutes
timeout: 2 minutes
needs: []
rules:
- if: $CI_COMMIT_TAG
when: never
- if: $CI_COMMIT_REF_NAME == $CI_DEFAULT_BRANCH
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
script:
- npm ci
- npm run build
- cp dist/index.html dist/404.html
# Extract release notes from CHANGELOG.md for this tag.
# 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 "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
- echo "--- release-notes.md ---"
- cat artifacts/release-notes.md
- echo "--- release-notes-summary.txt (length $(wc -c < artifacts/release-notes-summary.txt)) ---"
- cat artifacts/release-notes-summary.txt
- echo "------------------------"
# Warn (don't fail) when the summary exceeds the documented 500-character
# limit so the user spots it before App Store / Play Store reject the upload.
- |
SUMMARY_LEN=$(wc -c < artifacts/release-notes-summary.txt)
if [ "$SUMMARY_LEN" -gt 501 ]; then
echo "WARNING: release-notes-summary.txt is $SUMMARY_LEN bytes; convention is <=500."
fi
artifacts:
paths:
- dist/
- artifacts/release-notes.md
- artifacts/release-notes-summary.txt
expire_in: 90 days
build-apk:
stage: build
@@ -183,28 +232,99 @@ build-apk:
- android/.gradle/
- .gradle/
build-ipa:
stage: build
tags:
- macos
timeout: 20 minutes
needs: []
rules:
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
variables:
LANG: en_US.UTF-8
LC_ALL: en_US.UTF-8
FASTLANE_HIDE_CHANGELOG: "1"
FASTLANE_SKIP_UPDATE_CHECK: "1"
before_script:
# PATH is set up via ~/.bash_profile on the runner host (brew + Ruby 3.3 + user gems)
- node --version
- ruby --version
- fastlane --version | head -3
# Decode the App Store Connect API key (.p8) into a private location.
# The Fastfile reads this directly via File.binread. We pass the API
# key into match so it contacts Apple's portal to verify the cert is
# still valid for the team — fails fast on a revoked / expired cert.
- mkdir -p "$HOME/.private_keys"
- chmod 700 "$HOME/.private_keys"
- export ASC_KEY_PATH="$HOME/.private_keys/AuthKey_${APP_STORE_CONNECT_API_KEY_ID}.p8"
- echo "$APP_STORE_CONNECT_API_KEY_P8_BASE64" | base64 -d > "$ASC_KEY_PATH"
- chmod 600 "$ASC_KEY_PATH"
# Avoid env-var collision: match's APP_STORE_CONNECT_API_KEY_PATH expects
# a JSON descriptor; we pass the API key inline via the Fastfile.
- unset APP_STORE_CONNECT_API_KEY_PATH || true
# Build web assets and sync to Capacitor iOS project
- npm ci
- npx vite build -l error
- cp dist/index.html dist/404.html
- npx cap sync ios
- node scripts/patch-cap-config.mjs
script:
# Stamp marketing version from the git tag (e.g. v2.1.0 -> 2.1.0)
- VERSION="${CI_COMMIT_TAG#v}"
- echo "Building iOS version $VERSION (build ${CI_PIPELINE_IID}) from tag $CI_COMMIT_TAG"
- >-
/usr/bin/sed -i ''
"s/MARKETING_VERSION = [0-9.]*;/MARKETING_VERSION = ${VERSION};/g"
ios/App/App.xcodeproj/project.pbxproj
# Run match (cert verify + decrypt) and build_app to produce the IPA.
# 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/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/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/Agora.ipa
expire_in: 90 days
release:
stage: release
image: registry.gitlab.com/gitlab-org/release-cli:latest
needs:
- build-apk
- job: build-apk
artifacts: false
- job: build-ipa
artifacts: false
- job: release-notes
artifacts: true
rules:
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
script:
- echo "Creating release for $CI_COMMIT_TAG"
# Extract the latest changelog section for the release description.
# Reads from "## [version]" to the next "## [" or end of file.
- |
VERSION="${CI_COMMIT_TAG#v}"
RELEASE_NOTES=$(awk "/^## \\[${VERSION}\\]/{found=1; next} /^## \\[/{if(found) exit} found{print}" CHANGELOG.md)
if [ -z "$RELEASE_NOTES" ]; then
RELEASE_NOTES="Agora ${CI_COMMIT_TAG}"
fi
- echo "$RELEASE_NOTES" > release-notes.md
- test -f artifacts/release-notes.md
- echo "--- release-notes.md ---"
- cat artifacts/release-notes.md
- echo "------------------------"
release:
tag_name: $CI_COMMIT_TAG
name: $CI_COMMIT_TAG
description: './release-notes.md'
description: './artifacts/release-notes.md'
assets:
links:
- name: Agora-${CI_COMMIT_TAG}.apk
@@ -213,6 +333,9 @@ 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: 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:
stage: publish
@@ -244,7 +367,10 @@ publish-google-play:
stage: publish
image: ruby:3.3
needs:
- build-apk
- job: build-apk
artifacts: true
- job: release-notes
artifacts: true
rules:
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
script:
@@ -253,18 +379,87 @@ publish-google-play:
# Decode base64-encoded service account JSON to a temp file
- echo "$GOOGLE_PLAY_SERVICE_ACCOUNT_JSON" | base64 -d > /tmp/play-service-account.json
# Upload the AAB to Google Play production track
# Build the fastlane supply metadata layout for the changelog.
# supply maps changelogs/<versionCode>.txt to the Play Console "What's
# new in this version" field. versionCode matches what build-apk stamped
# into build.gradle (= CI_PIPELINE_IID).
- VERSION_CODE="${CI_PIPELINE_IID}"
- CHANGELOG_DIR="android/fastlane/metadata/android/en-US/changelogs"
- mkdir -p "$CHANGELOG_DIR"
- cp artifacts/release-notes-summary.txt "${CHANGELOG_DIR}/${VERSION_CODE}.txt"
- echo "--- ${CHANGELOG_DIR}/${VERSION_CODE}.txt ---"
- cat "${CHANGELOG_DIR}/${VERSION_CODE}.txt"
- echo "-------------------------------------------"
# Upload the AAB to Google Play production track with the changelog.
- >-
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
--skip_upload_metadata
--skip_upload_changelogs
--skip_upload_images
--skip_upload_screenshots
--skip_upload_apk
# Clean up
- rm -f /tmp/play-service-account.json
publish-app-store:
stage: publish
# Runs on the self-hosted Mac runner, same as build-ipa. fastlane's `deliver`
# action shells out to Apple's iTMSTransporter / altool to upload the IPA
# binary, and those tools ship inside Xcode. On a generic Linux container
# the upload step crashes with `No such file or directory @ dir_chdir0`
# because `Helper.itms_path` resolves to a path inside Xcode that doesn't
# exist. The IPA is already signed in `build-ipa`; we just need an Apple
# tool to push it, which means macOS.
tags:
- macos
needs:
- job: build-ipa
artifacts: true
- job: release-notes
artifacts: true
rules:
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
variables:
LANG: en_US.UTF-8
LC_ALL: en_US.UTF-8
FASTLANE_HIDE_CHANGELOG: "1"
FASTLANE_SKIP_UPDATE_CHECK: "1"
before_script:
# PATH is set up via ~/.bash_profile on the runner host (brew + Ruby 3.3 + user gems)
- ruby --version
- fastlane --version | head -3
# Decode the App Store Connect API key (.p8) into a private location.
# The Fastfile reads this directly via File.binread.
- mkdir -p "$HOME/.private_keys"
- chmod 700 "$HOME/.private_keys"
- export ASC_KEY_PATH="$HOME/.private_keys/AuthKey_${APP_STORE_CONNECT_API_KEY_ID}.p8"
- echo "$APP_STORE_CONNECT_API_KEY_P8_BASE64" | base64 -d > "$ASC_KEY_PATH"
- chmod 600 "$ASC_KEY_PATH"
# Avoid env-var collision: match's APP_STORE_CONNECT_API_KEY_PATH expects
# a JSON descriptor; we pass the API key inline via the Fastfile.
- unset APP_STORE_CONNECT_API_KEY_PATH || true
script:
- 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.
# Generated by the release-notes job from CHANGELOG.md.
- mkdir -p ios/fastlane/metadata/en-US
- cp artifacts/release-notes-summary.txt ios/fastlane/metadata/en-US/release_notes.txt
- echo "--- release_notes.txt ---"
- cat ios/fastlane/metadata/en-US/release_notes.txt
- echo "-------------------------"
# Submit the prebuilt IPA from build-ipa to App Store Connect for review.
- export IPA_PATH="$CI_PROJECT_DIR/artifacts/Agora.ipa"
- cd ios
- fastlane submit_release
after_script:
- rm -f "$HOME/.private_keys"/AuthKey_*.p8 || true
+190 -1475
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -44,6 +44,8 @@ Read the full "Understanding Agora" section above for the complete vision.
One bug, one merge request. Fix exactly one thing. Don't bundle unrelated changes, don't sneak in refactors, don't "clean up while you're in there." Small, focused MRs get reviewed fast. Large ones sit.
When the bug was introduced by an identifiable prior commit, add a `Regression-of: <short-sha>` trailer to the bottom of your commit message. See AGENTS.md "Attributing Regressions" for the convention.
### New features and significant changes
Every feature MR must link to an existing open issue and clearly align with the "Understanding Agora" section in this file. The philosophy alignment section in the MR template is where you make the case for why your change belongs in Agora. If you can't articulate that clearly, the change probably doesn't belong.
@@ -131,6 +133,7 @@ maintain it long-term. For each finding, state the file, line, and issue.
- [ ] Are there any new images >100KB or other large binary assets that should be hosted externally?
- [ ] Is there any use of dangerouslySetInnerHTML, eval, innerHTML, or SVG string interpolation?
- [ ] Is any data from a Nostr event (tags, content, pubkey, URLs) used in a security-sensitive context (href, src, query filter, trust decision) without validation?
- [ ] If this is a bug fix and the offending commit is identifiable, does the commit message include a `Regression-of: <short-sha>` trailer? (See AGENTS.md "Attributing Regressions".)
Skip anything a linter or type checker would catch. Focus on logic, data flow, and intent.
+579 -207
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -7,14 +7,14 @@ 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
versionName "2.8.0"
versionName "2.14.4"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
+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 * {
+2
View File
@@ -58,4 +58,6 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
</manifest>
@@ -1,552 +0,0 @@
package pub.ditto.app;
import android.graphics.Color;
import android.graphics.PorterDuff;
import android.os.Handler;
import android.os.Looper;
import android.util.Base64;
import android.util.Log;
import android.view.Gravity;
import android.view.View;
import android.view.ViewGroup;
import android.webkit.JavascriptInterface;
import android.webkit.WebResourceRequest;
import android.webkit.WebResourceResponse;
import android.webkit.WebSettings;
import android.webkit.WebView;
import android.webkit.WebViewClient;
import android.widget.FrameLayout;
import android.widget.ProgressBar;
import androidx.coordinatorlayout.widget.CoordinatorLayout;
import com.getcapacitor.JSObject;
import com.getcapacitor.Plugin;
import com.getcapacitor.PluginCall;
import com.getcapacitor.PluginMethod;
import com.getcapacitor.annotation.CapacitorPlugin;
import org.json.JSONException;
import org.json.JSONObject;
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
/**
* Capacitor plugin that creates isolated Android WebViews for sandboxed content.
*
* Each sandbox uses shouldInterceptRequest to intercept all requests and forward
* them to the JS layer as fetch events — the same protocol iframe.diy uses.
* The React code can serve files identically regardless of platform.
*/
@CapacitorPlugin(name = "SandboxPlugin")
public class SandboxPlugin extends Plugin {
private static final String TAG = "SandboxPlugin";
private final Map<String, SandboxInstance> sandboxes = new HashMap<>();
private final Handler mainHandler = new Handler(Looper.getMainLooper());
@PluginMethod
public void create(PluginCall call) {
String sandboxId = call.getString("id");
if (sandboxId == null) {
call.reject("Missing required parameter: id");
return;
}
JSObject frame = call.getObject("frame");
if (frame == null) {
call.reject("Missing required parameter: frame");
return;
}
int x = frame.optInt("x", 0);
int y = frame.optInt("y", 0);
int width = frame.optInt("width", 0);
int height = frame.optInt("height", 0);
if (sandboxes.containsKey(sandboxId)) {
call.reject("Sandbox already exists: " + sandboxId);
return;
}
float density = getActivity().getResources().getDisplayMetrics().density;
int pxX = Math.round(x * density);
int pxY = Math.round(y * density);
int pxWidth = Math.round(width * density);
int pxHeight = Math.round(height * density);
mainHandler.post(() -> {
SandboxInstance sandbox = new SandboxInstance(sandboxId, this);
sandboxes.put(sandboxId, sandbox);
// Add the container (WebView + spinner overlay) on top of the
// Capacitor WebView. The parent is a CoordinatorLayout — using
// the wrong LayoutParams type causes a ClassCastException when
// it intercepts touch events.
View capWebView = getBridge().getWebView();
ViewGroup parent = (ViewGroup) capWebView.getParent();
CoordinatorLayout.LayoutParams params = new CoordinatorLayout.LayoutParams(pxWidth, pxHeight);
params.leftMargin = pxX;
params.topMargin = pxY;
parent.addView(sandbox.container, params);
// The spinner is now visible. Navigation is deferred until the
// JS layer calls navigate() — this allows the caller to
// pre-fetch blobs while the spinner animates.
call.resolve();
});
}
@PluginMethod
public void navigate(PluginCall call) {
String sandboxId = call.getString("id");
if (sandboxId == null) {
call.reject("Missing required parameter: id");
return;
}
mainHandler.post(() -> {
SandboxInstance sandbox = sandboxes.get(sandboxId);
if (sandbox == null) {
call.reject("Sandbox not found: " + sandboxId);
return;
}
sandbox.webView.loadUrl("https://" + sandboxId + ".sandbox.native/index.html");
call.resolve();
});
}
@PluginMethod
public void updateFrame(PluginCall call) {
String sandboxId = call.getString("id");
if (sandboxId == null) {
call.reject("Missing required parameter: id");
return;
}
JSObject frame = call.getObject("frame");
if (frame == null) {
call.reject("Missing required parameter: frame");
return;
}
int x = frame.optInt("x", 0);
int y = frame.optInt("y", 0);
int width = frame.optInt("width", 0);
int height = frame.optInt("height", 0);
float density = getActivity().getResources().getDisplayMetrics().density;
int pxX = Math.round(x * density);
int pxY = Math.round(y * density);
int pxWidth = Math.round(width * density);
int pxHeight = Math.round(height * density);
mainHandler.post(() -> {
SandboxInstance sandbox = sandboxes.get(sandboxId);
if (sandbox == null) {
call.reject("Sandbox not found: " + sandboxId);
return;
}
CoordinatorLayout.LayoutParams params = new CoordinatorLayout.LayoutParams(pxWidth, pxHeight);
params.leftMargin = pxX;
params.topMargin = pxY;
sandbox.container.setLayoutParams(params);
call.resolve();
});
}
@PluginMethod
public void respondToFetch(PluginCall call) {
String sandboxId = call.getString("id");
if (sandboxId == null) {
call.reject("Missing required parameter: id");
return;
}
String requestId = call.getString("requestId");
if (requestId == null) {
call.reject("Missing required parameter: requestId");
return;
}
JSObject response = call.getObject("response");
if (response == null) {
call.reject("Missing required parameter: response");
return;
}
SandboxInstance sandbox = sandboxes.get(sandboxId);
if (sandbox == null) {
call.reject("Sandbox not found: " + sandboxId);
return;
}
int status = response.optInt("status", 200);
String statusText = response.optString("statusText", "OK");
String bodyBase64 = response.optString("body", null);
Map<String, String> headers = new HashMap<>();
JSONObject headersObj = response.optJSONObject("headers");
if (headersObj != null) {
for (java.util.Iterator<String> it = headersObj.keys(); it.hasNext(); ) {
String key = it.next();
headers.put(key, headersObj.optString(key));
}
}
sandbox.resolveRequest(requestId, status, statusText, headers, bodyBase64);
call.resolve();
}
@PluginMethod
public void postMessage(PluginCall call) {
String sandboxId = call.getString("id");
if (sandboxId == null) {
call.reject("Missing required parameter: id");
return;
}
JSObject message = call.getObject("message");
if (message == null) {
call.reject("Missing required parameter: message");
return;
}
SandboxInstance sandbox = sandboxes.get(sandboxId);
if (sandbox == null) {
call.reject("Sandbox not found: " + sandboxId);
return;
}
mainHandler.post(() -> sandbox.postMessageToWebView(message.toString()));
call.resolve();
}
@PluginMethod
public void destroy(PluginCall call) {
String sandboxId = call.getString("id");
if (sandboxId == null) {
call.reject("Missing required parameter: id");
return;
}
mainHandler.post(() -> {
SandboxInstance sandbox = sandboxes.remove(sandboxId);
if (sandbox != null) {
ViewGroup parent = (ViewGroup) sandbox.container.getParent();
if (parent != null) {
parent.removeView(sandbox.container);
}
sandbox.webView.destroy();
}
call.resolve();
});
}
void emitFetchRequest(String sandboxId, String requestId, JSObject request) {
JSObject data = new JSObject();
data.put("id", sandboxId);
data.put("requestId", requestId);
data.put("request", request);
notifyListeners("fetch", data);
}
void emitScriptMessage(String sandboxId, JSObject message) {
JSObject data = new JSObject();
data.put("id", sandboxId);
data.put("message", message);
notifyListeners("scriptMessage", data);
}
/**
* A single sandboxed WebView instance.
*/
private static class SandboxInstance {
final String id;
/** Wrapper layout that holds the WebView and the loading overlay. */
final FrameLayout container;
final WebView webView;
final SandboxPlugin plugin;
private final ConcurrentHashMap<String, PendingRequest> pendingRequests = new ConcurrentHashMap<>();
/** Native spinner overlay, shown while the sandbox content loads. */
private ProgressBar spinner;
SandboxInstance(String id, SandboxPlugin plugin) {
this.id = id;
this.plugin = plugin;
this.container = new FrameLayout(plugin.getActivity());
this.webView = new WebView(plugin.getActivity());
WebSettings settings = webView.getSettings();
settings.setJavaScriptEnabled(true);
settings.setDomStorageEnabled(true);
settings.setAllowFileAccess(false);
settings.setAllowContentAccess(false);
settings.setDatabaseEnabled(true);
webView.setBackgroundColor(Color.parseColor("#14161f"));
// Add JavaScript interface for script->native communication.
webView.addJavascriptInterface(new SandboxBridge(this), "__sandboxNative");
// Inject the bridge script and intercept requests.
webView.setWebViewClient(new SandboxWebViewClient(this));
// Build the container: WebView fills it, spinner overlays on top.
container.addView(webView, new FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT));
// Native spinner overlay — uses the Android indeterminate
// ProgressBar which animates on the render thread, so it keeps
// spinning even when the main/IO threads are busy.
spinner = new ProgressBar(plugin.getActivity());
spinner.setIndeterminate(true);
spinner.getIndeterminateDrawable().setColorFilter(
Color.parseColor("#7c5cdc"), PorterDuff.Mode.SRC_IN);
FrameLayout.LayoutParams spinnerParams = new FrameLayout.LayoutParams(
dpToPx(plugin, 32), dpToPx(plugin, 32), Gravity.CENTER);
container.addView(spinner, spinnerParams);
// Dark background behind the spinner.
View overlay = new View(plugin.getActivity());
overlay.setBackgroundColor(Color.parseColor("#14161f"));
// Insert the overlay between the WebView (index 0) and spinner (index 1)
// so it covers the WebView but sits behind the spinner.
container.addView(overlay, 1, new FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT));
}
/** Remove the native loading overlay. Safe to call multiple times. */
void hideSpinner() {
if (spinner != null) {
// Remove spinner and overlay (indices 2 and 1 after WebView at 0).
if (container.getChildCount() > 2) container.removeViewAt(2); // spinner
if (container.getChildCount() > 1) container.removeViewAt(1); // overlay
spinner = null;
}
}
private static int dpToPx(SandboxPlugin plugin, int dp) {
float density = plugin.getActivity().getResources().getDisplayMetrics().density;
return Math.round(dp * density);
}
void postMessageToWebView(String jsonString) {
String js = "(function() { " +
"if (window.__sandboxBridge && window.__sandboxBridge.onMessage) { " +
"window.__sandboxBridge.onMessage(" + jsonString + "); " +
"} " +
"})();";
webView.evaluateJavascript(js, null);
}
void resolveRequest(String requestId, int status, String statusText,
Map<String, String> headers, String bodyBase64) {
PendingRequest pending = pendingRequests.remove(requestId);
if (pending == null) return;
byte[] bodyBytes = null;
if (bodyBase64 != null && !bodyBase64.equals("null")) {
try {
bodyBytes = Base64.decode(bodyBase64, Base64.DEFAULT);
} catch (Exception e) {
Log.w(TAG, "Base64 decode failed for request " + requestId, e);
}
}
String contentType = headers.getOrDefault("Content-Type", "application/octet-stream");
String encoding = contentType.contains("text/") ? "UTF-8" : null;
InputStream body = bodyBytes != null
? new ByteArrayInputStream(bodyBytes)
: new ByteArrayInputStream(new byte[0]);
WebResourceResponse response = new WebResourceResponse(
contentType, encoding, status, statusText, headers, body
);
pending.resolve(response);
}
}
/**
* WebViewClient that intercepts all requests and forwards them to JS.
*/
private static class SandboxWebViewClient extends WebViewClient {
private final SandboxInstance sandbox;
private boolean bridgeInjected = false;
SandboxWebViewClient(SandboxInstance sandbox) {
this.sandbox = sandbox;
}
@Override
public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) {
String url = request.getUrl().toString();
// Only intercept requests to the sandbox domain.
if (!url.contains(".sandbox.native")) {
return null;
}
String requestId = UUID.randomUUID().toString();
// Create a pending request with a blocking latch.
PendingRequest pending = new PendingRequest();
sandbox.pendingRequests.put(requestId, pending);
// Rewrite URL to include the sandbox ID for the JS handler.
String path = request.getUrl().getPath();
if (path == null || path.isEmpty()) path = "/";
String rewrittenURL = "https://" + sandbox.id + ".sandbox.native" + path;
// Serialise the request.
JSObject serialisedRequest = new JSObject();
serialisedRequest.put("url", rewrittenURL);
serialisedRequest.put("method", request.getMethod());
JSObject headers = new JSObject();
for (Map.Entry<String, String> entry : request.getRequestHeaders().entrySet()) {
headers.put(entry.getKey(), entry.getValue());
}
serialisedRequest.put("headers", headers);
serialisedRequest.put("body", JSONObject.NULL);
// Emit to JS.
sandbox.plugin.emitFetchRequest(sandbox.id, requestId, serialisedRequest);
// Block until JS responds. Each asset is fetched from a Blossom
// server over the network, so we need a generous timeout. The
// WebView IO thread pool has ~6 threads; if all are blocked,
// subsequent requests queue until a thread frees up.
WebResourceResponse response = pending.awaitResponse(60000);
if (response != null) {
return response;
}
// Timeout — return error response.
sandbox.pendingRequests.remove(requestId);
return new WebResourceResponse(
"text/plain", "UTF-8", 504,
"Gateway Timeout", new HashMap<>(),
new ByteArrayInputStream("Request timed out".getBytes())
);
}
@Override
public void onPageFinished(WebView view, String url) {
super.onPageFinished(view, url);
if (!bridgeInjected) {
bridgeInjected = true;
view.evaluateJavascript(getBridgeScript(), null);
}
// Remove the native spinner once the first page has finished
// loading (all initial resources resolved). This runs on the
// main thread, so the removal is safe.
sandbox.hideSpinner();
}
private String getBridgeScript() {
return "(function() {" +
"'use strict';" +
"var messageListeners = [];" +
"window.__sandboxBridge = {" +
" onMessage: function(data) {" +
" var event = {" +
" data: data," +
" origin: 'https://" + sandbox.id + ".sandbox.native'," +
" source: window.parent," +
" type: 'message'" +
" };" +
" for (var i = 0; i < messageListeners.length; i++) {" +
" try { messageListeners[i](event); } catch(e) {}" +
" }" +
" }" +
"};" +
"var origAdd = window.addEventListener;" +
"window.addEventListener = function(type, fn, opts) {" +
" if (type === 'message' && typeof fn === 'function') messageListeners.push(fn);" +
" return origAdd.call(window, type, fn, opts);" +
"};" +
"var origRemove = window.removeEventListener;" +
"window.removeEventListener = function(type, fn, opts) {" +
" if (type === 'message') {" +
" var idx = messageListeners.indexOf(fn);" +
" if (idx !== -1) messageListeners.splice(idx, 1);" +
" }" +
" return origRemove.call(window, type, fn, opts);" +
"};" +
"if (!window.parent || window.parent === window) window.parent = {};" +
"window.parent.postMessage = function(data) {" +
" if (data && typeof data === 'object' && data.jsonrpc === '2.0') {" +
" try { window.__sandboxNative.postMessage(JSON.stringify(data)); } catch(e) {}" +
" }" +
"};" +
"})();";
}
}
/**
* JavaScript interface exposed to the sandbox WebView.
*/
private static class SandboxBridge {
private final SandboxInstance sandbox;
SandboxBridge(SandboxInstance sandbox) {
this.sandbox = sandbox;
}
@JavascriptInterface
public void postMessage(String json) {
try {
JSONObject obj = new JSONObject(json);
JSObject jsObj = new JSObject();
for (java.util.Iterator<String> it = obj.keys(); it.hasNext(); ) {
String key = it.next();
jsObj.put(key, obj.get(key));
}
sandbox.plugin.emitScriptMessage(sandbox.id, jsObj);
} catch (JSONException e) {
Log.w(TAG, "Failed to parse script message", e);
}
}
}
/**
* A pending request that blocks the WebViewClient IO thread until JS
* responds with the complete resource.
*/
private static class PendingRequest {
private volatile WebResourceResponse response;
private final CountDownLatch latch = new CountDownLatch(1);
void resolve(WebResourceResponse response) {
this.response = response;
latch.countDown();
}
WebResourceResponse awaitResponse(long timeoutMs) {
try {
latch.await(timeoutMs, TimeUnit.MILLISECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return response;
}
}
}
@@ -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;
@@ -19,7 +19,6 @@ public class MainActivity extends BridgeActivity {
protected void onCreate(Bundle savedInstanceState) {
// Register native plugins before super.onCreate.
registerPlugin(DittoNotificationPlugin.class);
registerPlugin(SandboxPlugin.class);
super.onCreate(savedInstanceState);
@@ -64,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: {
+1 -1
View File
@@ -8,7 +8,7 @@ import htmlParser from "@html-eslint/parser";
import customRules from "./eslint-rules/index.js";
export default tseslint.config(
{ ignores: ["dist", "android", "ios"] },
{ ignores: ["dist", "android", "ios", ".agents"] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ["**/*.{ts,tsx}"],
+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>
+3 -7
View File
@@ -15,7 +15,6 @@
504EC30F1FED79650016851F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30E1FED79650016851F /* Assets.xcassets */; };
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; };
50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; };
B1A2C3D40001000100000001 /* SandboxPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1A2C3D40001000100000002 /* SandboxPlugin.swift */; };
B1A2C3D40002000100000001 /* DittoBridgeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1A2C3D40002000100000002 /* DittoBridgeViewController.swift */; };
B1A2C3D40005000100000001 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = B1A2C3D40005000100000002 /* PrivacyInfo.xcprivacy */; };
B1A2C3D40006000100000001 /* DittoNotificationPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1A2C3D40006000100000002 /* DittoNotificationPlugin.swift */; };
@@ -33,7 +32,6 @@
504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = "<group>"; };
958DCC722DB07C7200EA8C5F /* debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = debug.xcconfig; path = ../debug.xcconfig; sourceTree = SOURCE_ROOT; };
B1A2C3D40001000100000002 /* SandboxPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SandboxPlugin.swift; sourceTree = "<group>"; };
B1A2C3D40002000100000002 /* DittoBridgeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DittoBridgeViewController.swift; sourceTree = "<group>"; };
B1A2C3D40004000100000002 /* App.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = App.entitlements; sourceTree = "<group>"; };
B1A2C3D40005000100000002 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
@@ -76,7 +74,6 @@
50379B222058CBB4000EE86E /* capacitor.config.json */,
B1A2C3D40004000100000002 /* App.entitlements */,
504EC3071FED79650016851F /* AppDelegate.swift */,
B1A2C3D40001000100000002 /* SandboxPlugin.swift */,
B1A2C3D40002000100000002 /* DittoBridgeViewController.swift */,
B1A2C3D40006000100000002 /* DittoNotificationPlugin.swift */,
B1A2C3D40007000100000002 /* NostrPoller.swift */,
@@ -174,7 +171,6 @@
buildActionMask = 2147483647;
files = (
504EC3081FED79650016851F /* AppDelegate.swift in Sources */,
B1A2C3D40001000100000001 /* SandboxPlugin.swift in Sources */,
B1A2C3D40002000100000001 /* DittoBridgeViewController.swift in Sources */,
B1A2C3D40006000100000001 /* DittoNotificationPlugin.swift in Sources */,
B1A2C3D40007000100000001 /* NostrPoller.swift in Sources */,
@@ -327,9 +323,9 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 2.8.0;
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;
@@ -352,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>
-541
View File
@@ -1,541 +0,0 @@
import Foundation
import Capacitor
import WebKit
// MARK: - Plugin
/// Capacitor plugin that creates isolated WKWebViews for sandboxed content.
///
/// Each sandbox gets a unique custom URL scheme (`sbx-<id>://`) so that
/// every embedded app has its own origin (separate localStorage, cookies, etc.).
/// All requests on the custom scheme are intercepted via `WKURLSchemeHandler`
/// and forwarded to the JS layer as fetch events the same protocol
/// iframe.diy uses. This lets the existing React code serve files identically.
@objc(SandboxPlugin)
public class SandboxPlugin: CAPPlugin, CAPBridgedPlugin {
public let identifier = "SandboxPlugin"
public let jsName = "SandboxPlugin"
public let pluginMethods: [CAPPluginMethod] = [
CAPPluginMethod(name: "create", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "navigate", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "updateFrame", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "respondToFetch", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "postMessage", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "destroy", returnType: CAPPluginReturnPromise),
]
/// Active sandbox instances, keyed by sandbox ID.
private var sandboxes: [String: SandboxInstance] = [:]
// MARK: - Plugin Methods
@objc func create(_ call: CAPPluginCall) {
guard let sandboxId = call.getString("id") else {
call.reject("Missing required parameter: id")
return
}
guard let frame = call.getObject("frame"),
let x = frame["x"] as? Double,
let y = frame["y"] as? Double,
let width = frame["width"] as? Double,
let height = frame["height"] as? Double else {
call.reject("Missing or invalid parameter: frame")
return
}
if sandboxes[sandboxId] != nil {
call.reject("Sandbox already exists: \(sandboxId)")
return
}
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
let webViewFrame = CGRect(x: x, y: y, width: width, height: height)
let sandbox = SandboxInstance(
id: sandboxId,
frame: webViewFrame,
plugin: self
)
self.sandboxes[sandboxId] = sandbox
// Add the container (WebView + spinner overlay) on top of
// the Capacitor WebView.
if let bridge = self.bridge,
let webView = bridge.webView {
webView.superview?.addSubview(sandbox.containerView)
}
call.resolve()
}
}
@objc func navigate(_ call: CAPPluginCall) {
guard let sandboxId = call.getString("id") else {
call.reject("Missing required parameter: id")
return
}
DispatchQueue.main.async { [weak self] in
guard let sandbox = self?.sandboxes[sandboxId] else {
call.reject("Sandbox not found: \(sandboxId)")
return
}
sandbox.navigateToApp()
call.resolve()
}
}
@objc func updateFrame(_ call: CAPPluginCall) {
guard let sandboxId = call.getString("id") else {
call.reject("Missing required parameter: id")
return
}
guard let frame = call.getObject("frame"),
let x = frame["x"] as? Double,
let y = frame["y"] as? Double,
let width = frame["width"] as? Double,
let height = frame["height"] as? Double else {
call.reject("Missing or invalid parameter: frame")
return
}
DispatchQueue.main.async { [weak self] in
guard let sandbox = self?.sandboxes[sandboxId] else {
call.reject("Sandbox not found: \(sandboxId)")
return
}
sandbox.containerView.frame = CGRect(x: x, y: y, width: width, height: height)
call.resolve()
}
}
@objc func respondToFetch(_ call: CAPPluginCall) {
guard let sandboxId = call.getString("id") else {
call.reject("Missing required parameter: id")
return
}
guard let requestId = call.getString("requestId") else {
call.reject("Missing required parameter: requestId")
return
}
guard let response = call.getObject("response") else {
call.reject("Missing required parameter: response")
return
}
guard let sandbox = sandboxes[sandboxId] else {
call.reject("Sandbox not found: \(sandboxId)")
return
}
sandbox.schemeHandler.resolveRequest(
requestId: requestId,
status: response["status"] as? Int ?? 200,
statusText: response["statusText"] as? String ?? "OK",
headers: response["headers"] as? [String: String] ?? [:],
bodyBase64: response["body"] as? String
)
call.resolve()
}
@objc func postMessage(_ call: CAPPluginCall) {
guard let sandboxId = call.getString("id") else {
call.reject("Missing required parameter: id")
return
}
guard let message = call.getObject("message") else {
call.reject("Missing required parameter: message")
return
}
guard let sandbox = sandboxes[sandboxId] else {
call.reject("Sandbox not found: \(sandboxId)")
return
}
DispatchQueue.main.async {
sandbox.postMessageToWebView(message)
}
call.resolve()
}
@objc func destroy(_ call: CAPPluginCall) {
guard let sandboxId = call.getString("id") else {
call.reject("Missing required parameter: id")
return
}
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
if let sandbox = self.sandboxes.removeValue(forKey: sandboxId) {
sandbox.containerView.removeFromSuperview()
sandbox.schemeHandler.cancelAll()
}
call.resolve()
}
}
// MARK: - Event Forwarding
/// Forward a fetch request from the native WebView to JS.
func emitFetchRequest(sandboxId: String, requestId: String, request: [String: Any]) {
notifyListeners("fetch", data: [
"id": sandboxId,
"requestId": requestId,
"request": request,
])
}
/// Forward a script message from the sandbox to JS.
func emitScriptMessage(sandboxId: String, message: [String: Any]) {
notifyListeners("scriptMessage", data: [
"id": sandboxId,
"message": message,
])
}
}
// MARK: - SandboxInstance
/// Manages a single sandboxed WKWebView instance.
private class SandboxInstance: NSObject, WKScriptMessageHandler, WKNavigationDelegate {
let id: String
let webView: WKWebView
let schemeHandler: SandboxSchemeHandler
private weak var plugin: SandboxPlugin?
private let customScheme: String
/// Container view that holds the WebView and spinner overlay.
let containerView: UIView
/// Native spinner overlay, removed when the first page finishes loading.
private var spinnerOverlay: UIView?
init(id: String, frame: CGRect, plugin: SandboxPlugin) {
self.id = id
self.plugin = plugin
// Each sandbox gets a unique custom URL scheme so that WKWebView
// assigns a distinct origin, isolating localStorage/IndexedDB/cookies.
self.customScheme = "sbx-\(id)"
self.schemeHandler = SandboxSchemeHandler(
sandboxId: id,
scheme: self.customScheme,
plugin: plugin
)
let config = WKWebViewConfiguration()
config.setURLSchemeHandler(self.schemeHandler, forURLScheme: self.customScheme)
// Add a script message handler for communication from injected scripts.
let userContentController = WKUserContentController()
// Inject a bridge script that:
// 1. Provides window.parent.postMessage()-like functionality
// 2. Routes messages through the native bridge
let bridgeScript = WKUserScript(
source: SandboxInstance.bridgeScript(scheme: self.customScheme),
injectionTime: .atDocumentStart,
forMainFrameOnly: false
)
userContentController.addUserScript(bridgeScript)
config.userContentController = userContentController
config.preferences.javaScriptCanOpenWindowsAutomatically = false
config.defaultWebpagePreferences.allowsContentJavaScript = true
// Container view that holds the WebView + spinner overlay.
self.containerView = UIView(frame: frame)
self.webView = WKWebView(frame: containerView.bounds, configuration: config)
self.webView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
self.webView.isOpaque = false
self.webView.backgroundColor = UIColor(red: 0x14/255.0, green: 0x16/255.0, blue: 0x1f/255.0, alpha: 1)
self.webView.scrollView.backgroundColor = self.webView.backgroundColor
self.webView.scrollView.bounces = false
self.containerView.addSubview(self.webView)
// Dark overlay behind the spinner.
let overlay = UIView(frame: containerView.bounds)
overlay.autoresizingMask = [.flexibleWidth, .flexibleHeight]
overlay.backgroundColor = UIColor(red: 0x14/255.0, green: 0x16/255.0, blue: 0x1f/255.0, alpha: 1)
self.containerView.addSubview(overlay)
// Native spinner uses UIActivityIndicatorView which animates on
// the render thread independently of JS/main-thread work.
let spinner = UIActivityIndicatorView(style: .medium)
spinner.color = UIColor(red: 124/255.0, green: 92/255.0, blue: 220/255.0, alpha: 1)
spinner.translatesAutoresizingMaskIntoConstraints = false
spinner.startAnimating()
overlay.addSubview(spinner)
NSLayoutConstraint.activate([
spinner.centerXAnchor.constraint(equalTo: overlay.centerXAnchor),
spinner.centerYAnchor.constraint(equalTo: overlay.centerYAnchor),
])
self.spinnerOverlay = overlay
super.init()
// Register the message handler and navigation delegate after super.init().
userContentController.add(self, name: "sandboxBridge")
self.webView.navigationDelegate = self
}
/// Navigate the WebView to the sandbox's entry point.
func navigateToApp() {
let initialURL = URL(string: "\(customScheme)://app/index.html")!
webView.load(URLRequest(url: initialURL))
}
/// Remove the native loading overlay. Safe to call multiple times.
func hideSpinner() {
spinnerOverlay?.removeFromSuperview()
spinnerOverlay = nil
}
/// Post a JSON-RPC message to injected scripts inside the WebView.
func postMessageToWebView(_ message: [String: Any]) {
guard let jsonData = try? JSONSerialization.data(withJSONObject: message),
let jsonString = String(data: jsonData, encoding: .utf8) else {
return
}
let js = """
(function() {
if (window.__sandboxBridge && window.__sandboxBridge.onMessage) {
window.__sandboxBridge.onMessage(\(jsonString));
}
})();
"""
webView.evaluateJavaScript(js, completionHandler: nil)
}
// MARK: - WKScriptMessageHandler
/// Receive messages from injected scripts via webkit.messageHandlers.sandboxBridge.
func userContentController(
_ userContentController: WKUserContentController,
didReceive message: WKScriptMessage
) {
guard message.name == "sandboxBridge",
let body = message.body as? [String: Any] else {
return
}
plugin?.emitScriptMessage(sandboxId: id, message: body)
}
// MARK: - WKNavigationDelegate
/// Remove the spinner overlay once the first page finishes loading.
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
hideSpinner()
}
// MARK: - Bridge Script
/// JavaScript injected at document start that provides:
/// - `window.parent.postMessage()` emulation via WKScriptMessageHandler
/// - `window.__sandboxBridge.onMessage()` for receiving messages from parent
/// - `window.addEventListener("message", ...)` support for injected scripts
private static func bridgeScript(scheme: String) -> String {
return """
(function() {
'use strict';
// Message listeners registered by injected scripts.
var messageListeners = [];
// Bridge object for native communication.
window.__sandboxBridge = {
onMessage: function(data) {
// Dispatch to all registered message listeners.
var event = {
data: data,
origin: '\(scheme)://app',
source: window.parent,
type: 'message'
};
for (var i = 0; i < messageListeners.length; i++) {
try {
messageListeners[i](event);
} catch (e) {
console.error('[SandboxBridge] Listener error:', e);
}
}
}
};
// Override addEventListener to capture "message" listeners.
var originalAddEventListener = window.addEventListener;
window.addEventListener = function(type, listener, options) {
if (type === 'message' && typeof listener === 'function') {
messageListeners.push(listener);
}
return originalAddEventListener.call(window, type, listener, options);
};
var originalRemoveEventListener = window.removeEventListener;
window.removeEventListener = function(type, listener, options) {
if (type === 'message') {
var idx = messageListeners.indexOf(listener);
if (idx !== -1) messageListeners.splice(idx, 1);
}
return originalRemoveEventListener.call(window, type, listener, options);
};
// Emulate window.parent.postMessage for scripts that use it
// (e.g. the webxdc bridge script, preview injected script).
if (!window.parent || window.parent === window) {
window.parent = {};
}
window.parent.postMessage = function(data, targetOrigin, transfer) {
if (data && typeof data === 'object' && data.jsonrpc === '2.0') {
try {
window.webkit.messageHandlers.sandboxBridge.postMessage(data);
} catch (e) {
console.error('[SandboxBridge] postMessage failed:', e);
}
}
};
})();
""";
}
}
// MARK: - SandboxSchemeHandler
/// WKURLSchemeHandler that intercepts all requests on the sandbox's custom
/// URL scheme and forwards them to the JS layer as fetch events.
private class SandboxSchemeHandler: NSObject, WKURLSchemeHandler {
private let sandboxId: String
private let scheme: String
private weak var plugin: SandboxPlugin?
/// Pending scheme tasks waiting for a response from JS.
/// Key: requestId (UUID string), Value: the WKURLSchemeTask to respond to.
private var pendingTasks: [String: WKURLSchemeTask] = [:]
private let lock = NSLock()
init(sandboxId: String, scheme: String, plugin: SandboxPlugin) {
self.sandboxId = sandboxId
self.scheme = scheme
self.plugin = plugin
}
func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) {
let request = urlSchemeTask.request
guard let url = request.url else {
urlSchemeTask.didFailWithError(NSError(
domain: "SandboxPlugin", code: -1,
userInfo: [NSLocalizedDescriptionKey: "No URL in request"]
))
return
}
let requestId = UUID().uuidString
lock.lock()
pendingTasks[requestId] = urlSchemeTask
lock.unlock()
// Serialise the request for the fetch event.
// Rewrite the URL so it looks like a normal HTTP URL to the parent
// (e.g. "sbx-abc123://app/index.html" -> "https://<sandboxId>.sandbox.native/index.html")
// The JS side only cares about the pathname.
var headers: [String: String] = [:]
if let allHeaders = request.allHTTPHeaderFields {
headers = allHeaders
}
var bodyBase64: String? = nil
if let bodyData = request.httpBody {
bodyBase64 = bodyData.base64EncodedString()
}
let path = url.path.isEmpty ? "/" : url.path
let rewrittenURL = "https://\(sandboxId).sandbox.native\(path)"
let serialisedRequest: [String: Any] = [
"url": rewrittenURL,
"method": request.httpMethod ?? "GET",
"headers": headers,
"body": bodyBase64 as Any,
]
plugin?.emitFetchRequest(
sandboxId: sandboxId,
requestId: requestId,
request: serialisedRequest
)
}
func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) {
// Remove the task from pending JS response will be ignored if it arrives later.
lock.lock()
let removed = pendingTasks.first(where: { $0.value === urlSchemeTask })
if let key = removed?.key {
pendingTasks.removeValue(forKey: key)
}
lock.unlock()
}
/// Called by the plugin when JS responds to a fetch request.
func resolveRequest(
requestId: String,
status: Int,
statusText: String,
headers: [String: String],
bodyBase64: String?
) {
lock.lock()
guard let task = pendingTasks.removeValue(forKey: requestId) else {
lock.unlock()
return
}
lock.unlock()
// Decode the base64 body.
var bodyData: Data? = nil
if let b64 = bodyBase64 {
bodyData = Data(base64Encoded: b64)
}
// Build the response.
// Use the task's original URL for the response.
let responseURL = task.request.url ?? URL(string: "\(scheme)://app/")!
let response = HTTPURLResponse(
url: responseURL,
statusCode: status,
httpVersion: "HTTP/1.1",
headerFields: headers
)!
DispatchQueue.main.async {
task.didReceive(response)
if let data = bodyData {
task.didReceive(data)
}
task.didFinish()
}
}
/// Cancel all pending tasks (called on destroy).
func cancelAll() {
lock.lock()
let tasks = pendingTasks
pendingTasks.removeAll()
lock.unlock()
for (_, task) in tasks {
task.didFailWithError(NSError(
domain: "SandboxPlugin", code: -999,
userInfo: [NSLocalizedDescriptionKey: "Sandbox destroyed"]
))
}
}
}
+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"),
+2
View File
@@ -0,0 +1,2 @@
app_identifier("spot.agora.app")
team_id("GZLTTH5DLM")
+146
View File
@@ -0,0 +1,146 @@
default_platform(:ios)
platform :ios do
# ─── Lanes ────────────────────────────────────────────────────────────
desc "Build and sign the App Store IPA. Output at ../artifacts/Agora.ipa."
lane :build_ipa do
setup_lane_signing!
build_release_ipa!
end
desc "Submit an already-built IPA to App Store Connect for review. " \
"Set IPA_PATH to the IPA's location."
lane :submit_release do
ipa_path = ENV.fetch("IPA_PATH") do
UI.user_error!("submit_release requires the IPA_PATH env var")
end
UI.user_error!("IPA not found at #{ipa_path}") unless File.exist?(ipa_path)
submit_release_for_review!(ipa_path)
end
desc "Build, sign, and submit Agora to the App Store for review (single-step convenience)."
lane :release do
setup_lane_signing!
build_release_ipa!
# Use the IPA path set by build_app rather than recomputing it from
# __dir__, which gets fragile across fastlane-relative paths.
ipa_path = lane_context[SharedValues::IPA_OUTPUT_PATH]
UI.user_error!("build_app did not set IPA_OUTPUT_PATH") unless ipa_path
submit_release_for_review!(ipa_path)
end
desc "Submit an already-uploaded build for review (skip build/upload). " \
"Use BUILD_NUMBER and VERSION env vars."
lane :submit_only do
submit_release_for_review!(nil)
end
# ─── Helpers ──────────────────────────────────────────────────────────
def setup_lane_signing!
# Create an ephemeral keychain so we never touch the login keychain.
setup_ci
api_key = build_api_key!
# Fetch encrypted distribution cert + provisioning profile from the
# shared certificates repo. --readonly: never mutate from CI.
# Passing api_key makes match contact Apple's portal to verify the
# cert is still valid for the team — fails fast on revoked/expired
# certs instead of letting xcodebuild stumble later.
match(type: "appstore", readonly: true, api_key: api_key)
api_key
end
def build_api_key!
# Build the API key hash inline. We avoid the app_store_connect_api_key
# action because it sets APP_STORE_CONNECT_API_KEY_PATH (path to the .p8)
# which collides with match's APP_STORE_CONNECT_API_KEY_PATH (path to a
# JSON descriptor). Same env name, different formats.
@api_key ||= {
key_id: ENV.fetch("APP_STORE_CONNECT_API_KEY_ID"),
issuer_id: ENV.fetch("APP_STORE_CONNECT_API_KEY_ISSUER_ID"),
key: File.binread(ENV.fetch("ASC_KEY_PATH")),
duration: 1200,
in_house: false,
}
end
def build_release_ipa!
# Stamp build number from CI pipeline ID so every release is monotonically increasing.
increment_build_number(
xcodeproj: "App/App.xcodeproj",
build_number: ENV.fetch("CI_PIPELINE_IID"),
)
# Marketing version is set externally (sed in CI) before this lane runs.
build_app(
project: "App/App.xcodeproj",
scheme: "App",
configuration: "Release",
export_method: "app-store",
output_directory: "../artifacts",
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
# ephemeral keychain; tell xcodebuild to use them explicitly so it
# doesn't also try to find an iOS Development cert (which we never
# provision in CI).
xcargs: [
"CODE_SIGN_STYLE=Manual",
"CODE_SIGN_IDENTITY='Apple Distribution'",
"PROVISIONING_PROFILE_SPECIFIER='match AppStore spot.agora.app'",
"DEVELOPMENT_TEAM=GZLTTH5DLM",
].join(" "),
export_options: {
method: "app-store",
signingStyle: "manual",
teamID: "GZLTTH5DLM",
provisioningProfiles: {
"spot.agora.app" => "match AppStore spot.agora.app",
},
},
)
end
# If ipa_path is nil, deliver picks up the latest processed build for the
# configured app version (used by the submit_only lane).
def submit_release_for_review!(ipa_path)
api_key = build_api_key!
options = {
api_key: api_key,
submit_for_review: true,
automatic_release: false,
force: true,
precheck_include_in_app_purchases: false,
# Don't try to PATCH content rights on every submit — Apple's API
# rejects updates to contentRightsDeclaration once the listing has
# an established state. The values stay as set in the App Store
# Connect UI / from a prior submission.
submission_information: {
export_compliance_uses_encryption: false,
},
skip_screenshots: true,
# Keep skip_app_version_update=false: deliver needs to PATCH the
# version's whatsNew (release notes) and platform-version metadata
# before submit_for_review will accept the version.
skip_app_version_update: false,
skip_metadata: false,
metadata_path: "./fastlane/metadata",
run_precheck_before_submit: false,
}
options[:ipa] = ipa_path if ipa_path
if ENV["BUILD_NUMBER"]
options[:build_number] = ENV["BUILD_NUMBER"]
options[:skip_binary_upload] = true
end
options[:app_version] = ENV["VERSION"] if ENV["VERSION"]
deliver(**options)
end
end
+5
View File
@@ -0,0 +1,5 @@
git_url("https://gitlab.com/soapbox-pub/certificates.git")
storage_mode("git")
type("appstore")
app_identifier(["spot.agora.app"])
team_id("GZLTTH5DLM")
@@ -0,0 +1 @@
Placeholder. CI overwrites this file with the release summary paragraph from CHANGELOG.md (the leading plaintext paragraph in the section for the current version).
+199 -228
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",
@@ -64,9 +65,9 @@
"@milkdown/utils": "^7.20.0",
"@noble/curves": "^2.2.0",
"@noble/hashes": "^1.8.0",
"@nostrify/nostrify": "^0.51.1",
"@nostrify/react": "^0.5.1",
"@nostrify/types": "^0.36.9",
"@nostrify/nostrify": "^0.52.0",
"@nostrify/react": "^0.6.0",
"@nostrify/types": "^0.37.0",
"@plausible-analytics/tracker": "^0.4.4",
"@radix-ui/react-accordion": "^1.2.0",
"@radix-ui/react-alert-dialog": "^1.1.1",
@@ -95,21 +96,23 @@
"@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",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
"d3-celestial": "^0.7.35",
"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",
@@ -122,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",
@@ -353,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": {
@@ -2601,11 +2626,11 @@
}
},
"node_modules/@nostrify/nostrify": {
"version": "0.51.1",
"resolved": "https://registry.npmjs.org/@nostrify/nostrify/-/nostrify-0.51.1.tgz",
"integrity": "sha512-oPJhUiO1TlV5sGYizqAP4GvLijib34Uwh48wxlFimR/2MoCuSmab4AppcztGPNwxQoTKkJbLJwsSpl42V+WIXA==",
"version": "0.52.0",
"resolved": "https://registry.npmjs.org/@nostrify/nostrify/-/nostrify-0.52.0.tgz",
"integrity": "sha512-x+gc8rxJ4C+mnoFgd4Zzi0JnXUz0acQA69nKqR0fnWhpc/KiQosgIILfaNUTWkecTPJ92iazT4Es+TrUUSFcRg==",
"dependencies": {
"@nostrify/types": "0.36.9",
"@nostrify/types": "0.37.0",
"@scure/base": "^2.0.0",
"@smithy/util-base64": "^4.3.0",
"@smithy/util-hex-encoding": "^4.2.0",
@@ -2642,12 +2667,12 @@
"license": "MIT"
},
"node_modules/@nostrify/react": {
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/@nostrify/react/-/react-0.5.1.tgz",
"integrity": "sha512-gQUct8A7KLKvoLtv4bHpVDfmvzJlIHjZZI6DMui8vrSuzm8IqMRdAYADbR3ry1mlIQp8/c4EeR24piBpHK0WUw==",
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/@nostrify/react/-/react-0.6.0.tgz",
"integrity": "sha512-6vjF5UagAW5QRpxAu/of9lyI7837wwoyX/NLGQbEs6fcMQXjTo/m7wUBPipoj0E460QvyNXff5O8Byn72enWbQ==",
"dependencies": {
"@nostrify/nostrify": "0.51.1",
"@nostrify/types": "0.36.9"
"@nostrify/nostrify": "0.52.0",
"@nostrify/types": "0.37.0"
},
"peerDependencies": {
"@tanstack/react-query": "^5.69.0",
@@ -2657,9 +2682,9 @@
}
},
"node_modules/@nostrify/types": {
"version": "0.36.9",
"resolved": "https://registry.npmjs.org/@nostrify/types/-/types-0.36.9.tgz",
"integrity": "sha512-tMx/r0W+QoVRRgs8d6ltaSgrftasOXuFsi33kW8WirswCy2b3UR1tqRgc0iBU9zRa9XR0nlej/wJZW+6wUFi+Q=="
"version": "0.37.0",
"resolved": "https://registry.npmjs.org/@nostrify/types/-/types-0.37.0.tgz",
"integrity": "sha512-P0AKR+qcNeBBTA5UDnJM6SxLAQbgud2+ZcdVyheoP37XGQvi7rUncQUDKwebG+Ui5kswp/IPEmvqNtCMQpwRoA=="
},
"node_modules/@ocavue/utils": {
"version": "1.6.0",
@@ -6170,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",
@@ -7715,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",
@@ -7735,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",
@@ -7783,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",
@@ -7896,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",
@@ -8346,6 +8400,12 @@
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT"
},
"node_modules/d3": {
"version": "3.5.17",
"resolved": "https://registry.npmjs.org/d3/-/d3-3.5.17.tgz",
"integrity": "sha512-yFk/2idb8OHPKkbAL8QaOaqENNoMhIaSHZerk3oQsECwkObkCpJyjYwCe+OHiq6UEdhe1m8ZGARRRO3ljFjlKg==",
"license": "BSD-3-Clause"
},
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
@@ -8358,6 +8418,15 @@
"node": ">=12"
}
},
"node_modules/d3-celestial": {
"version": "0.7.35",
"resolved": "https://registry.npmjs.org/d3-celestial/-/d3-celestial-0.7.35.tgz",
"integrity": "sha512-cURxIl0E+FGWnYj6gTDt80SjuiM9lklcGykj/skVy7glDg5nj/QxTUoPPArU+bpEQ+1fLy5hi920OvJ/TgliRw==",
"license": "BSD-3-Clause",
"dependencies": {
"d3": "^3.5.17"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
@@ -8716,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",
@@ -9318,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",
@@ -11644,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",
@@ -12003,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",
@@ -12336,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",
@@ -14084,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"
@@ -14701,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",
@@ -15039,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",
@@ -16641,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",
@@ -16781,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",
+9 -7
View File
@@ -6,7 +6,7 @@
"scripts": {
"dev": "npm i --silent && vite",
"build": "npm i --silent && vite build -l error && cp dist/index.html dist/404.html && echo 'Project built successfully!'",
"test": "npm i --silent && tsc --noEmit && eslint && vitest run --reporter=dot --silent && vite build -l error && cp dist/index.html dist/404.html && echo 'All tests passed!'",
"test": "npm i --silent && tsc --noEmit && eslint --cache && vitest run --reporter=dot --silent && vite build -l error && cp dist/index.html dist/404.html && echo 'All tests passed!'",
"cap:sync": "npx cap sync && node scripts/patch-cap-config.mjs",
"keygen": "keytool -genkey -v -keystore android/app/my-upload-key.keystore -keyalg RSA -keysize 2048 -validity 10000 -alias upload",
"icons": "bash scripts/generate-icons.sh"
@@ -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",
@@ -71,9 +72,9 @@
"@milkdown/utils": "^7.20.0",
"@noble/curves": "^2.2.0",
"@noble/hashes": "^1.8.0",
"@nostrify/nostrify": "^0.51.1",
"@nostrify/react": "^0.5.1",
"@nostrify/types": "^0.36.9",
"@nostrify/nostrify": "^0.52.0",
"@nostrify/react": "^0.6.0",
"@nostrify/types": "^0.37.0",
"@plausible-analytics/tracker": "^0.4.4",
"@radix-ui/react-accordion": "^1.2.0",
"@radix-ui/react-alert-dialog": "^1.1.1",
@@ -102,21 +103,23 @@
"@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",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
"d3-celestial": "^0.7.35",
"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",
@@ -129,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: 245 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

+10 -2
View File
@@ -5,7 +5,7 @@
"start_url": "/",
"display": "standalone",
"background_color": "#0a0c14",
"theme_color": "#e85d3c",
"theme_color": "#ff6600",
"icons": [
{
"src": "/icon-192.png",
@@ -87,5 +87,13 @@
"type": "image/png",
"form_factor": "narrow"
}
]
],
"related_applications": [
{
"platform": "play",
"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}`,
);
+141
View File
@@ -0,0 +1,141 @@
#!/usr/bin/env node
/**
* Extract release notes from CHANGELOG.md for a given version.
*
* The CHANGELOG follows Keep a Changelog format with one extension: each release
* section MAY begin with a single plaintext paragraph (the "summary") before any
* `### Added` / `### Changed` / etc. heading. The summary is used as the release
* blurb on the App Store, Play Store, and the in-app version-update toast. The
* full section body is used as the GitLab Release description.
*
* Format:
*
* ## [X.Y.Z] - YYYY-MM-DD
*
* A short single-paragraph summary (max 500 characters by convention).
*
* ### Added
* - bullet
* - bullet
*
* ### Changed
* - bullet
*
* Usage:
* node scripts/extract-release-notes.mjs <version> [--summary] [--changelog <path>]
*
* --summary Print only the summary paragraph (no headings, no bullets).
* 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
* not found in the changelog.
*/
import { readFileSync } from 'node:fs';
import { argv, exit, stderr, stdout } from 'node:process';
function parseArgs(args) {
let version;
let summary = false;
let changelog = 'CHANGELOG.md';
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === '--summary') summary = true;
else if (arg === '--changelog') changelog = args[++i];
else if (!arg.startsWith('--') && !version) version = arg;
else {
stderr.write(`Unknown argument: ${arg}\n`);
exit(2);
}
}
if (!version) {
stderr.write('Usage: extract-release-notes.mjs <version> [--summary] [--changelog <path>]\n');
exit(2);
}
// Strip a leading "v" so callers can pass either "v2.14.3" or "2.14.3".
if (version.startsWith('v')) version = version.slice(1);
return { version, summary, changelog };
}
/**
* Extract the lines belonging to a single version section from changelog text,
* not including the version heading itself.
*/
function extractSection(markdown, version) {
const lines = markdown.split('\n');
const headingPattern = new RegExp(
`^## \\[${version.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\$&')}\\]`,
);
const nextHeadingPattern = /^## \[/;
let inSection = false;
const out = [];
for (const line of lines) {
if (!inSection) {
if (headingPattern.test(line)) {
inSection = true;
continue;
}
} else {
if (nextHeadingPattern.test(line)) break;
out.push(line);
}
}
return inSection ? out : null;
}
/**
* Pull the leading non-blank paragraph from a section, stopping at the first
* `###` category heading or `-` bullet. Returns null if no summary paragraph.
*/
function extractSummary(sectionLines) {
const paragraph = [];
let started = false;
for (const line of sectionLines) {
const trimmed = line.trim();
if (!started) {
if (!trimmed) continue;
// If the very first non-blank line is a heading or bullet, there's no summary.
if (trimmed.startsWith('#') || trimmed.startsWith('- ')) return null;
started = true;
paragraph.push(trimmed);
continue;
}
// We're inside the paragraph. A blank line, a heading, or a bullet ends it.
if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('- ')) break;
paragraph.push(trimmed);
}
return paragraph.length ? paragraph.join(' ') : null;
}
/** Trim leading and trailing blank lines from a list of lines. */
function trimBlankEdges(lines) {
let start = 0;
let end = lines.length;
while (start < end && !lines[start].trim()) start++;
while (end > start && !lines[end - 1].trim()) end--;
return lines.slice(start, end);
}
const { version, summary, changelog } = parseArgs(argv.slice(2));
const markdown = readFileSync(changelog, 'utf8');
const section = extractSection(markdown, version);
if (!section) {
stderr.write(`Version ${version} not found in ${changelog}\n`);
exit(1);
}
if (summary) {
const text = extractSummary(section);
stdout.write(text ?? `Agora v${version}`);
stdout.write('\n');
} else {
const body = trimBlankEdges(section).join('\n');
if (body) {
stdout.write(body);
stdout.write('\n');
} else {
stdout.write(`Agora v${version}\n`);
}
}
+31 -43
View File
@@ -1,15 +1,12 @@
// NOTE: This file should normally not be modified unless you are adding a new provider.
// To add new routes, edit the AppRouter.tsx file.
import { Capacitor, SystemBars, SystemBarsStyle } from "@capacitor/core";
import { NostrLoginProvider } from "@nostrify/react/login";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { InferSeoMetaPlugin } from "@unhead/addons";
import { createHead, UnheadProvider } from "@unhead/react/client";
import { useEffect } from "react";
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";
@@ -21,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({
@@ -45,11 +40,13 @@ const queryClient = new QueryClient({
const hardcodedConfig: AppConfig = {
appName: "Agora",
appId: "agora",
homePage: "feed",
shareOrigin: import.meta.env.VITE_SHARE_ORIGIN || undefined,
homePage: "campaigns",
client: "naddr1qvzqqqru7cpzq7q6z5ns2hm5c8msyv83qwzxpxe52j8c4d4q5m92wsp9sflelkh9qqzkg6t5w3hswjl4yp",
magicMouse: false,
theme: "system",
useAppRelays: true,
useUserRelays: false,
relayMetadata: {
relays: [],
updatedAt: 0,
@@ -59,8 +56,12 @@ const hardcodedConfig: AppConfig = {
feedIncludeComments: true,
feedIncludeReposts: true,
feedIncludeGenericReposts: true,
feedIncludeReactions: false,
feedIncludeZaps: false,
feedIncludeArticles: true,
showArticles: true,
showHighlights: true,
feedIncludeHighlights: false,
showEvents: true,
feedIncludeEvents: true,
showVines: true,
@@ -69,13 +70,13 @@ const hardcodedConfig: AppConfig = {
showTreasureGeocaches: true,
showTreasureFoundLogs: true,
showColors: true,
showPacks: true,
showPeopleLists: true,
feedIncludeVines: true,
feedIncludePolls: true,
feedIncludeTreasureGeocaches: true,
feedIncludeTreasureFoundLogs: true,
feedIncludeColors: true,
feedIncludePacks: true,
feedIncludePeopleLists: true,
showDecks: true,
feedIncludeDecks: true,
showWebxdc: true,
@@ -103,23 +104,27 @@ const hardcodedConfig: AppConfig = {
showBadges: true,
showBadgeDefinitions: true,
showProfileBadges: true,
showBadgeAwards: true,
feedIncludeBadgeDefinitions: true,
feedIncludeProfileBadges: true,
feedIncludeBadgeAwards: true,
feedIncludeVanish: true,
showBirdstar: true,
feedIncludeBirdDetections: true,
feedIncludeBirdex: true,
feedIncludeConstellations: true,
followsFeedShowReplies: true,
},
sidebarOrder: [
"wallet",
"verified",
"actions",
"polls",
"world",
"badges",
"feed",
"notifications",
"messages",
"communities",
"world",
"wallet",
"agent",
"messages",
"profile",
"notifications",
"search",
"settings",
],
nip85StatsPubkey:
@@ -138,21 +143,20 @@ const hardcodedConfig: AppConfig = {
plausibleDomain: import.meta.env.VITE_PLAUSIBLE_DOMAIN || "",
plausibleEndpoint: import.meta.env.VITE_PLAUSIBLE_ENDPOINT || "",
savedFeeds: [],
autoplayVideos: false,
imageQuality: 'compressed',
curatorPubkey: '932614571afcbad4d17a191ee281e39eebbb41b93fac8fd87829622aeb112f4d',
sandboxDomain: 'iframe.diy',
esploraBaseUrl: 'https://mempool.space/api',
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: '',
};
/**
@@ -185,17 +189,6 @@ const defaultConfig: AppConfig = {
export function App() {
useNsecPasteGuard();
useEffect(() => {
// Initialize system bars for mobile apps.
// On Android 16+ (API 36), edge-to-edge is enforced by the OS so
// setOverlaysWebView / setBackgroundColor no longer work. The new
// SystemBars API (bundled with @capacitor/core 8+) is the replacement.
if (Capacitor.isNativePlatform()) {
SystemBars.setStyle({ style: SystemBarsStyle.Dark }).catch(() => {
// SystemBars may not be available on all platforms
});
}
}, []);
return (
<UnheadProvider head={head}>
@@ -206,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>
+157 -13
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";
@@ -21,54 +21,88 @@ import MessagesPage from "./pages/Messages";
// Lazy-loaded compose modal (pulls in emoji-mart ~620K)
const ReplyComposeModal = lazy(() => import("@/components/ReplyComposeModal").then(m => ({ default: m.ReplyComposeModal })));
// HomePage eagerly imported all page components; now lazy-loaded
const HomePage = lazy(() => import("./pages/HomePage").then(m => ({ default: m.HomePage })));
// Lazy-loaded emoji pack dialog
const EmojiPackDialog = lazy(() => import("@/components/EmojiPackDialog").then(m => ({ default: m.EmojiPackDialog })));
// 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 })));
const ArchivePage = lazy(() => import("./pages/ArchivePage").then(m => ({ default: m.ArchivePage })));
const ArticleEditorPage = lazy(() => import("./pages/ArticleEditorPage").then(m => ({ default: m.ArticleEditorPage })));
const BadgesPage = lazy(() => import("./pages/BadgesPage").then(m => ({ default: m.BadgesPage })));
const CommunitiesPage = lazy(() => import("./pages/CommunitiesPage").then(m => ({ default: m.CommunitiesPage })));
const BlueskyPage = lazy(() => import("./pages/BlueskyPage").then(m => ({ default: m.BlueskyPage })));
const BookmarksPage = lazy(() => import("./pages/BookmarksPage").then(m => ({ default: m.BookmarksPage })));
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 })));
const ProfileSettings = lazy(() => import("./pages/ProfileSettings").then(m => ({ default: m.ProfileSettings })));
const RelayPage = lazy(() => import("./pages/RelayPage").then(m => ({ default: m.RelayPage })));
const SearchPage = lazy(() => import("./pages/SearchPage").then(m => ({ default: m.SearchPage })));
const SettingsPage = lazy(() => import("./pages/SettingsPage").then(m => ({ default: m.SettingsPage })));
const TreasuresPage = lazy(() => import("./pages/TreasuresPage").then(m => ({ default: m.TreasuresPage })));
const TrendsPage = lazy(() => import("./pages/TrendsPage").then(m => ({ default: m.TrendsPage })));
const UserListsPage = lazy(() => import("./pages/UserListsPage").then(m => ({ default: m.UserListsPage })));
const WalletSettingsPage = lazy(() => import("./pages/WalletSettingsPage").then(m => ({ default: m.WalletSettingsPage })));
const WalletPage = lazy(() => import("./pages/WalletPage").then(m => ({ default: m.WalletPage })));
const WorldPage = lazy(() => import("./pages/WorldPage").then(m => ({ default: m.WorldPage })));
const VerifiedPage = lazy(() => import("./pages/VerifiedPage").then(m => ({ default: m.VerifiedPage })));
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")!;
const colorsDef = getExtraKindDef("colors")!;
const packsDef = getExtraKindDef("packs")!;
const articlesDef = getExtraKindDef("articles")!;
const decksDef = getExtraKindDef("decks")!;
const emojisDef = getExtraKindDef("emojis")!;
const developmentDef = getExtraKindDef("development")!;
const highlightsDef = getExtraKindDef("highlights")!;
/** Polls feed page with a FAB that opens the compose modal (poll mode via + menu). */
function PollsFeedPage() {
@@ -90,6 +124,26 @@ function PollsFeedPage() {
);
}
/** Emoji feed page with a FAB that opens the emoji pack creation dialog. */
function EmojiFeedPage() {
const [composeOpen, setComposeOpen] = useState(false);
return (
<>
<KindFeedPage
kind={emojisDef.kind}
title={emojisDef.label}
icon={sidebarItemIcon("emojis", "size-5")}
onFabClick={() => setComposeOpen(true)}
/>
{composeOpen && (
<Suspense fallback={null}>
<EmojiPackDialog open={composeOpen} onOpenChange={setComposeOpen} />
</Suspense>
)}
</>
);
}
/** Redirects /profile to the user's canonical profile URL (nip05 or npub). */
function ProfileRedirect() {
const { user, metadata } = useCurrentUser();
@@ -111,14 +165,21 @@ 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 />} />
<Route path="/trends" element={<TrendsPage />} />
<Route path="/profile" element={<ProfileRedirect />} />
<Route path="/t/:tag" element={<HashtagPage />} />
<Route path="/g/:geohash" element={<GeotagPage />} />
@@ -133,7 +194,6 @@ export function AppRouter() {
path="/settings/notifications"
element={<NotificationSettings />}
/>
<Route path="/settings/messaging" element={<MessagingSettingsPage />} />
<Route
path="/settings/advanced"
element={<AdvancedSettingsPage />}
@@ -142,8 +202,40 @@ 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 */}
<Route
path="/streams"
element={<Navigate to="/videos" replace />}
/>
<Route path="/vines" element={<VinesFeedPage />} />
<Route path="/music" element={<MusicPage />} />
<Route path="/podcasts" element={<PodcastsFeedPage />} />
<Route path="/polls" element={<PollsFeedPage />} />
<Route path="/treasures" element={<TreasuresPage />} />
<Route
path="/colors"
element={
<KindFeedPage
kind={colorsDef.kind}
title={colorsDef.label}
icon={sidebarItemIcon("colors", "size-5")}
/>
}
/>
<Route
path="/packs"
element={
<KindFeedPage
kind={packsDef.kind}
title={packsDef.label}
icon={sidebarItemIcon("packs", "size-5")}
/>
}
/>
<Route path="/webxdc" element={<WebxdcFeedPage />} />
<Route path="/articles/new" element={<ArticleEditorPage />} />
<Route path="/articles/edit/:naddr" element={<ArticleEditorPage />} />
<Route
@@ -157,16 +249,62 @@ export function AppRouter() {
/>
}
/>
<Route path="/bookmarks" element={<BookmarksPage />} />
<Route
path="/highlights"
element={
<KindFeedPage
kind={highlightsDef.kind}
title={highlightsDef.label}
icon={sidebarItemIcon("highlights", "size-5")}
showFAB={false}
/>
}
/>
<Route
path="/decks"
element={
<KindFeedPage
kind={decksDef.kind}
title={decksDef.label}
icon={sidebarItemIcon("decks", "size-5")}
/>
}
/>
<Route path="/emojis" element={<EmojiFeedPage />} />
<Route
path="/development"
element={
<KindFeedPage
kind={[
developmentDef.kind,
...(developmentDef.extraFeedKinds ?? []),
]}
title={developmentDef.label}
icon={sidebarItemIcon("development", "size-5")}
showFAB={false}
/>
}
/>
<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 />} />
<Route path="/world" element={<WorldPage />} />
<Route path="/badges" element={<BadgesPage />} />
<Route path="/books" element={<BooksPage />} />
<Route path="/archive" element={<ArchivePage />} />
<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 />} />
@@ -177,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 />} />

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