Compare commits

...

1148 Commits

Author SHA1 Message Date
lemon 4830443c26 Add community member feed tab 2026-05-07 09:41:29 -07:00
lemon 98c2d69c02 Fix duplicate AI chat error display 2026-05-06 23:18:10 -07:00
lemon cedc5db249 Configure AI provider settings 2026-05-06 22:52:12 -07:00
lemon f043d45331 Add slash commands with autocomplete, /tools listing, and styled notice messages 2026-05-06 22:52:12 -07:00
lemon 12f1bbd00d Harden AI chat: SSRF protection, capacity tracking, scoped storage, and error handling 2026-05-06 22:52:12 -07:00
lemon aa962386c6 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-06 22:52:12 -07:00
lemon 5cc1428c06 Portal tooltip overlays above sidebars 2026-05-06 22:09:15 -07:00
lemon cb35176f60 Refresh goal progress after zaps 2026-05-06 22:01:40 -07:00
lemon 0ea17672c7 Check member badge identifier collisions 2026-05-06 22:00:40 -07:00
lemon 6f888b8d36 Refresh community caches after member updates 2026-05-06 21:59:38 -07:00
lemon d5dff04056 Page community activity streams independently 2026-05-06 21:58:34 -07:00
lemon 8c6be4c57d Prevent banned community moderators from acting 2026-05-06 21:57:23 -07:00
lemon 064e0832df 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-06 21:45:20 -07:00
lemon 891cf72af8 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-06 21:45:08 -07:00
lemon a3563305c4 Clean up flat community language 2026-05-06 21:11:40 -07:00
lemon 14733b3b5c Flatten community membership resolution 2026-05-06 20:39:01 -07:00
lemon 4a8fd245a1 Document flat community membership 2026-05-06 20:34:44 -07:00
lemon a159f97a43 Add calendar event editing 2026-05-06 19:50:57 -07:00
lemon eb03f3fcc0 Share image upload field across dialogs 2026-05-06 19:40:46 -07:00
lemon 003e7d3624 Add image uploads to event creation 2026-05-06 19:27:34 -07:00
lemon 28043378c3 Add engagement actions to calendar events 2026-05-06 13:13:51 -07:00
lemon 432eae4f79 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-06 12:55:57 -07:00
lemon e0a52a5c32 Use event dialog on events page 2026-05-04 23:29:01 -07:00
lemon 725d6970c5 Add community event creation dialog 2026-05-04 23:23:55 -07:00
lemon 558b666220 Add community events tab 2026-05-04 23:02:43 -07:00
lemon 72d7962632 Improve community bookmark reliability 2026-05-04 21:44:34 -07:00
lemon 5e91f1d328 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-03 22:10:34 -07:00
lemon efe5d3db1c 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-03 21:59:36 -07:00
lemon 9ac379b259 fix: remove duplicate community share action 2026-05-03 21:28:00 -07:00
lemon c8b3961da6 feat: add community editing 2026-05-03 21:28:00 -07:00
lemon 259c657c33 fix: improve community member management 2026-05-03 21:28:00 -07:00
lemon 2f6aeb05e4 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-03 21:27:41 -07:00
lemon 5cea93de34 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-03 21:26:48 -07:00
lemon bd6852041e Cache feed events, keep pages on refetch, flag partial goal tallies
- Seed ['event', id] query cache from the community activity feed so
  embedded previews resolve without a second fetch.
- Add placeholderData and a 30-minute gcTime to the community activity
  feed so navigation and background refetches don't flash empty.
- Surface useGoalProgress's isPartial flag in GoalCard with a '~'
  prefix and tooltip so users know when a tally hit the safety cap.
2026-05-03 00:17:45 -07:00
lemon e15c2b312c Use effective relay hints for fundraising goals 2026-05-02 23:59:21 -07:00
lemon 17e7bbd07e Paginate fundraising goal and community activity queries 2026-05-02 23:53:00 -07:00
lemon 910d759155 Allow goals without valid relay URLs, fall back to user's relays 2026-05-02 23:40:06 -07:00
lemon a8d5a1538c Deduplicate useNow, memoize moderation context, sanitize goal image URL 2026-05-02 23:40:06 -07:00
lemon 3f982e2241 Simplify goal progress to tally zaps at face value, matching rest of app
Drop LNURL signer resolution and NIP-57 receipt validation from goal
progress tallying. This removes a network request per beneficiary for a
trust level that is still spoofable and that no other zap display in the
app enforces. Revert this commit to restore strict validation.
2026-05-02 23:40:06 -07:00
lemon 418909f531 Fix BOLT11 parser to correctly extract HRP and handle zero-amount invoices 2026-05-02 23:40:05 -07:00
lemon 44098af247 Extract shared BOLT11 parser and deduplicate LNURL signer resolution 2026-05-02 23:40:05 -07:00
lemon 268b171ba4 Propagate abort signal to LNURL fetch in goal progress 2026-05-02 23:40:05 -07:00
lemon 2e44d2a677 Stabilize goal progress queryKey by keying on lightning address 2026-05-02 23:40:05 -07:00
lemon 4277a8fe7d Sort past fundraising goals by deadline descending 2026-05-02 23:40:05 -07:00
lemon 9045ff3c41 Harden fundraising goal zaps 2026-05-02 23:40:05 -07:00
lemon f56ff2f305 Fix activity feed empty state flash on refresh
Include communitiesLoading in the hook's isLoading so the skeleton
shows while the dependent communities query is still resolving,
instead of briefly rendering the empty state.
2026-05-02 23:40:05 -07:00
lemon 699bc6ca33 Fix moderation and context for goals in activity feed
Goals use lowercase 'a' tags (not uppercase 'A' like NIP-22 comments)
to link to communities. The activity feed's moderation filter, members-
only filter, and CommunityModerationContext provider lookup all only
checked uppercase 'A', so goals bypassed moderation and had no '...'
menu. Now all three check both tag casings.
2026-05-02 23:40:05 -07:00
lemon 16ec99b327 Apply community moderation filtering to fundraising goals
Content-banned goals and goals from member-banned authors are now
filtered out of the fundraising tab via applyCommunityModerationToEvents,
matching the behavior of the comments tab.
2026-05-02 23:40:05 -07:00
lemon 09e211f48a Render fundraising goals via NoteCard for moderation support
Replace standalone GoalCard with NoteCard in the community fundraising
tab so goals get the same '...' menu with remove/ban actions that
comments have. Strip GoalCard down to just the compact inline renderer
(no variant prop, no skeleton, no card-only imports). Simplify
useCommunityGoals to return plain events instead of parsed wrappers.
2026-05-02 23:40:05 -07:00
lemon 27b60b2a6f Refactor goal components: deduplicate GoalCard/GoalContent, fix staleness
- Unify GoalCard and GoalContent into a single component with variant prop
- Extract useGoalDisplay hook for shared display logic (author, progress,
  community link, deadline, image)
- Add useNow(60s) interval so deadline labels refresh automatically
- Add generic parseATagCoordinate utility to nostrEvents.ts
- Replace DOM-mutating image onError with React state
- Remove dead isGoalFunded export and redundant created_at in publish
- Delete GoalContent.tsx (-144 net lines)
2026-05-02 23:40:05 -07:00
lemon 07ea1f94d1 Render full zap goal card on detail page and add FAB to community tabs
- PostDetailPage: render GoalContent for kind 9041 instead of plain text
- CommunityDetailPage: add floating action button on comments (compose) and fundraising (new goal) tabs, remove inline New Goal button
- CreateGoalDialog: support controlled open/onOpenChange props for external triggers
2026-05-02 23:40:05 -07:00
lemon d1017697a4 Add NIP-75 community fundraising goals
Implement zap goals (kind 9041) linked to communities via a-tag.
Includes goal creation dialog, progress tracking from zap receipts,
recipient profile/lightning address display, community link, and
members-only filtering. Goals appear in community detail Fundraising
tab, activity feed, and main feed via NoteCard.
2026-05-02 23:39:07 -07:00
Sam Thomson 2788127894 Merge branch 'ui/gut-blobbi' into 'main'
ui/gut-blobbi

See merge request soapbox-pub/agora-3!14
2026-04-30 07:11:33 +00:00
sam 9f425366c0 Merge branch 'main' into ui/gut-blobbi 2026-04-30 13:53:14 +07:00
Sam Thomson 0436949797 Merge branch 'refactor/changelog' into 'main'
refactor/changelog

See merge request soapbox-pub/agora-3!13
2026-04-30 06:49:31 +00:00
sam 8fdb5cf1ad reset changelog to agora 2026-04-30 13:38:02 +07:00
sam b46703eaed remove blobbis 2026-04-30 13:19:22 +07:00
Sam Thomson ecbee21d34 Merge branch 'ui/gut-custom-themes' into 'main'
ui/gut-custom-themes

See merge request soapbox-pub/agora-3!11
2026-04-29 15:08:41 +00:00
sam 3f28bf571a remove all theme stuff 2026-04-29 22:04:39 +07:00
sam 2e7eee66ee gut theme customisation 2026-04-29 21:47:36 +07:00
Sam Thomson 7c4d3012ec Merge branch 'fix/discovery-relay-selection' into 'main'
fix/discovery-relay-selection

See merge request soapbox-pub/agora-3!10
2026-04-29 13:47:31 +00:00
sam 01af784953 fix discovery relay selection 2026-04-29 20:41:41 +07:00
Sam Thomson da8a5e1dde Merge branch 'refactor/tidy-some-pages' into 'main'
refactor/tidy-some-pages

See merge request soapbox-pub/agora-3!9
2026-04-28 12:48:42 +00:00
sam e3b16a3c5b Merge branch 'main' into refactor/tidy-some-pages 2026-04-28 19:48:08 +07:00
sam a5849fc747 copy updates 2026-04-28 19:41:58 +07:00
sam 42430e510d update messaging dep so that syncing thing stays visible 2026-04-28 19:24:31 +07:00
sam 09c364b060 hide the messaging header 2026-04-28 19:24:19 +07:00
sam d96361c578 updated messaging dep 2026-04-28 18:39:32 +07:00
sam 1346112f36 feed title for consistency 2026-04-28 16:03:31 +07:00
sam 44b1019d98 header to notifs page 2026-04-28 15:51:08 +07:00
sam b4c5db0c0e dont wash out inactive cards so much, let errors be conveyed. and fixed layout issues with action sponsor overflows 2026-04-28 15:47:21 +07:00
sam a56b4839c8 let images fail fast and fix double header 2026-04-28 14:47:06 +07:00
sam 5768dc9183 llm -> fail fast, don't swallow errors 2026-04-28 14:46:20 +07:00
sam e871229248 update verified follow pack to new layout 2026-04-28 14:21:17 +07:00
sam 141166cdc8 copy 2026-04-28 14:20:03 +07:00
Sam Thomson b99590bc5e Merge branch 'feat/community-reporting' into 'main'
Community Moderation

See merge request soapbox-pub/agora-3!7
2026-04-28 04:10:13 +00:00
lemon b2f4cc3583 Make members-only toggle reactive and move it off the tab row
Two fixes on the members-only filter UI:

1. Toggling the shield now updates feeds live, without a reload.

The previous implementation used `useLocalStorage` in two separate
components. Each call instantiates its own `useState`, so writes from
one didn't flow to the other's reader. `localStorage`'s `storage`
event only fires cross-tab, not in the tab that wrote — so same-tab
consumers stayed stale until a remount.

Replaced with a module-level singleton store subscribed via
`useSyncExternalStore`. All consumers share one source of truth;
toggling rerenders every subscriber in the same tab instantly. The
store still persists to localStorage and listens for cross-tab
`storage` events, so behaviour across tabs is unchanged.

2. Move the shield off the CommunityDetailPage tab row.

Placing the toggle inline with the TabsList made it sit on the
bottom-border stroke that belongs to the tabs, reading as if the
shield itself were an underlined tab. Moved it up one row, right-
justified on the "Founded by" label row. Visually cleaner and still
scopes the filter to the entire community (all content feeds under
the tabs, current and future), not any single tab.
2026-04-27 09:22:08 -07:00
lemon e21ee2e4fc Enforce report pubkey match and add members-only filter toggle
Two NIP-alignment fixes:

Gap 1 — Report warnings now require `p` match (correctness).

Previously `CommunityContentWarning` looked up reports by event id only,
so any community member could publish a kind 1984 pairing a victim
event's id with their own pubkey on the `p` tag to force a warning
overlay onto an arbitrary event. Added `getApplicableReports` in
communityUtils mirroring `hasApplicableContentBan`, and use it to
require `report.targetPubkey === event.pubkey` before the warning
renders. Matches NIP.md §Reports — Content Warnings: "report warnings
MUST only attach to content when the target event's id matches the
report's `e` tag and the target event's pubkey matches the report's
`p` tag."

Gap 2 — Members-only filter toggle.

The NIP recommends canonical community feeds discard non-member
content by default. Added a shield-icon toggle that controls this as
a presentation-layer filter, defaulting on. When active, community
feeds (Activities feed, per-community Comments tab, and any future
community-scoped content surfaces) only show events authored by
chain-validated members. When off, everything scoped to the
community is shown regardless of authorship.

- `useMembersOnlyFilter` — localStorage-backed hook with cross-tab
  sync; one preference shared across all community surfaces.
- `MembersOnlyToggle` — shield / shield-off icon button with tooltip
  explaining current state.
- Filtering is applied post-query in the consumer pages, so toggling
  is instant and doesn't invalidate the query cache.
- Community definition events (kind 34550) are never filtered — they
  represent the community itself, not user-generated content.
- Toggle placement: in `CommunitiesPage` header (scopes the global
  Activities feed); in `CommunityDetailPage` alongside the tabs
  (scopes every content feed in that community, now and future).
- Empty-state copy hints at the filter when a list is empty only
  because of it.
2026-04-27 09:22:08 -07:00
lemon 8923aa87e2 Remove unfinished community events tab
Drops the read-only calendar-events (kind 31922/31923) listing from
CommunityDetailPage. The feature was partial — events could be listed
but not created from the community context — and the moderation /
authorship model for community-scoped events needs its own design
pass. Keeping it half-shipped complicates the moderation foundation
this branch is establishing.

A proper community events implementation will land in its own MR with
clearer scope: creation, RSVP handling, moderation rules for
community-scoped NIP-52 events, and whether the activity feed should
surface them.

General (non-community) calendar event support is unaffected —
EventsFeedPage, CalendarEventContent, CalendarEventDetailPage, RSVP
hooks, and the feed dispatch all remain. The community activity feed
already did not include kind 31922/31923, so no change there.
2026-04-27 09:22:08 -07:00
lemon 527b31247b Restore moderation trust boundary and remove unsafe cache seeding
Two fixes prompted by external review:

1. resolveCommunityModeration now takes the community A tag and filters
   events by matching `A` tag as its first pass. The previous change
   removed the A-tag existence check from parseCommunityReport on the
   assumption that callers scope by relay `#A` filter; that was an
   invariant of the current callers, not a property of the API. Moving
   the check to the resolver restores the trust boundary at the public
   API surface while keeping parseCommunityReport a pure single-event
   parser. The activity feed's pre-grouping pass is dropped since the
   resolver now handles per-community filtering itself.

2. Drop the `['community-members', aTag]` cache seeding from the
   activity feed. The activity feed uses shared relay limits across
   every subscribed community (500 awards and 500 reports total), so
   per-community results can be truncated. Seeding the per-community
   members cache with incomplete data would silently corrupt membership,
   authority, and moderation state on community detail pages.
   useCommunityMembers remains the authoritative per-community fetch.
2026-04-27 09:22:08 -07:00
lemon 864057f382 Tighten community moderation performance and unify context paths
- Extract community content warning's context subscription into the
  wrapper itself so NoteCard's memo() boundary no longer depends on
  moderation data. Refetches now re-render only the warning and the
  three-dot menu, not the whole card.
- Rename useCommunityModeration -> useCommunityModerationForEvent and
  return the full context value; PostDetailPage installs it as a
  Provider, removing 7 manual communityContext prop passes. Unifies the
  three previous paths for computing CommunityMenuContext down to one.
- Seed the per-community members cache from the activity feed so
  opening a community detail page after the feed loads hits warm cache
  instead of re-querying kind 8 awards and kind 1984 reports.
- Single-pass parse in resolveCommunityModeration (was parsing each
  kind 1984 event twice across the ban and report passes).
- Drop the redundant A-tag existence check in parseCommunityReport;
  callers scope events via the relay's #A filter.
- Scope ban/report cache invalidation with a predicate that only
  matches activity feeds containing the affected community's A tag.
- Drop CommunityMembership.totalCount (was just members.length) and
  consolidate scattered EMPTY_* sentinels into EMPTY_MEMBERSHIP and
  EMPTY_RANK_MAP in communityUtils.
2026-04-27 09:22:08 -07:00
lemon 7440b2d620 Improve community moderation structure and naming for reviewability
Rename memberMap -> rankMap to clarify it is a pre-moderation rank lookup
(includes banned members) and should not be used to list active members.

Extract canBanTarget(), getViewerAuthority(), isEventAllowedByModeration(),
CommunityMenuContext, and EMPTY_MODERATION into communityUtils as shared
primitives, eliminating duplicated logic across hooks and components.

Remove unused ApplyCommunityModerationOptions dead code.
2026-04-27 09:22:08 -07:00
lemon f48ba562ea Clarify community moderation UI labels 2026-04-27 09:22:08 -07:00
lemon c91bdc1d89 Harden community moderation foundation 2026-04-27 09:22:08 -07:00
lemon c7b3305ef4 Void bans and reports from banned members
Rework resolveCommunityModeration into a two-pass approach so that
members who are themselves banned cannot retain moderation authority:

Pass 1: collect valid ban candidates, sort by reporter rank ascending,
then apply them — skipping any candidate whose reporter was already
banned by a higher-ranked member earlier in the pass.

Pass 2: collect non-ban reports, skipping reporters who ended up in
the banned set from pass 1.
2026-04-27 09:22:08 -07:00
lemon 09c904917d Show moderation actions in three-dot menu across all community contexts
The NoteMoreMenu 'Remove post' and 'Ban' options were only visible on
the community detail page where CommunityModerationContext was provided.
Now they also appear in the activities feed and post detail page.

- Add useCommunityModeration hook for PostDetailPage (resolves community
  context from event's A tag with lazy queries)
- Extend useCommunityActivityFeed to expose per-community memberMap and
  moderation data (zero extra queries — reuses already-fetched data)
- Wrap each NoteCard in ActivitiesTab with CommunityModerationContext
- NoteCard itself is untouched — no performance impact on other feeds
2026-04-27 09:22:08 -07:00
lemon 3e099bb08d apply moderation filter to activites 2026-04-27 09:22:08 -07:00
lemon 0e99250a3b Unify more-menu sections and rename 'Remove content' to 'Remove post'
Merge the three middle sections into one, remove stale JSDoc referencing
deleted reinstatement logic, and align dialog/toast copy with menu label.
2026-04-27 09:22:08 -07:00
lemon 9be5650dcd Improve community moderation robustness and efficiency
- Eliminate double resolveMembership call by filtering banned members post-hoc
- Memoize community context derivation in NoteMoreMenu
- Hoist viewerMember lookup out of render loop in CommunityDetailPage
- Only mount BanConfirmDialog when viewer has ban authority
- Deduplicate NIP-56 report type definitions into canonical source
2026-04-27 09:22:08 -07:00
lemon 3efdcd5a63 Remove deletion/reinstatement logic from community moderation
Reinstatement via kind 5 deletions will be implemented in a future branch.
Removing it now eliminates an overly-broad unscoped query and a security
issue where any pubkey could reinstate banned content.
2026-04-27 09:22:07 -07:00
lemon fec7021a7f Implement community moderation: reporting, content bans, and member bans
Add two-tier moderation system for hierarchical communities using kind 1984
events scoped via A tags. Authoritative bans use NIP-32 labels
([l, ban, moderation]) and require rank authority. Soft reports use standard
NIP-56 types and trigger content warnings for any valid member.

- Update NIP.md with ban/report classification, NIP-32 label schema, and
  reinstatement via kind 5
- Add parseCommunityReport(), resolveCommunityModeration() to communityUtils
- Update resolveMembership() to apply moderation overlay (remove banned members)
- Update useCommunityMembers to fetch kind 1984/5 and resolve moderation
- Add CommunityModerationContext for propagating moderation state
- Add CommunityReportDialog for soft reports (NIP-56 types)
- Add BanConfirmDialog for content removal and member bans with optional reason
- Add CommunityContentWarning component for click-to-reveal reported content
- Wire moderation into NoteMoreMenu (auto-detects community context)
- Wire moderation into CommunityDetailPage (member ban buttons, feed filtering)
- Add Remove content / Ban @user menu items to NoteMoreMenu
- Remove Copy Link to Post and Mention @user from NoteMoreMenu
- Move Mute Conversation into the mute/report section
2026-04-27 09:22:07 -07:00
Sam Thomson 0940358fba Merge branch 'feat/dms' into 'main'
feat/dms

See merge request soapbox-pub/agora-3!8
2026-04-27 12:07:17 +00:00
sam 50637a4dc1 updated to latest messaging package to resolve peer dep issue 2026-04-27 18:44:30 +07:00
sam 89a3562a1e update nips used doc 2026-04-27 18:33:33 +07:00
sam 2852590e09 updated messaging dep 2026-04-27 13:09:54 +07:00
sam b5c941f9fb use latest messaging dep 2026-04-25 20:34:46 +07:00
sam 9cdbb7c9e8 expose legacy nip4 option in settings 2026-04-25 19:05:51 +07:00
sam 0c9da915ef bring messaging settings over 2026-04-25 18:20:03 +07:00
sam 94ca6d162f Merge branch 'main' into feat/dms 2026-04-25 00:41:19 +07:00
Sam Thomson f351443049 Merge branch 'feat/world-feed' into 'main'
Replace Ditto feed tab with World feed

See merge request soapbox-pub/agora-3!4
2026-04-24 17:39:02 +00:00
lemon 348bbf6522 Invalidate query cache on world feed pull-to-refresh 2026-04-23 17:02:31 -07:00
lemon 9aa7366c74 Remove diversity cap from world feed, sort purely by recency 2026-04-23 17:02:31 -07:00
lemon f68f257234 Replace Ditto feed tab with World feed
- Add useWorldFeed hook combining infinite-scroll pagination with live
  streaming and 'X new posts' buffer/flush pattern
- World feed queries all country-tagged events globally with a diversity
  cap (max 4 posts per country per page)
- Live streaming via persistent relay subscription with scroll-aware
  buffering and highlight animation on flush
- Rename Ditto tab to World across Feed, ContentSettings, and useFeedTab
- Migrate localStorage key from ditto:showDittoFeed to agora:showWorldFeed
2026-04-23 17:02:31 -07:00
sam d1ca846d30 updated messaging dep 2026-04-23 20:02:41 +05:45
sam 0240e77bf9 Merge branch 'main' into feat/dms 2026-04-23 12:18:16 +05:45
Sam Thomson cfcc4b8858 Merge branch 'fix/themes' into 'main'
Remove Ditto Themes and Set Defaults of System/Light/Dark

See merge request soapbox-pub/agora-3!6
2026-04-23 06:32:38 +00:00
lemon b3b7bdd20c replace theme showcase with simple System/Light/Dark appearance setting
Remove the 'Make it yours' theme strip from the landing hero and the
ThemeStep from the signup/onboarding flow. Add an Appearance settings
page at /settings/appearance with three options (System, Light, Dark)
defaulting to System.
2026-04-22 18:38:13 -07:00
sam 12c7676882 keep agent concise 2026-04-22 19:47:17 +05:45
sam 8411fb997d Merge branch 'main' into feat/dms 2026-04-22 12:19:16 +05:45
sam 3cc1e1dcec dont use the generic lazy loading for messages page, its looks daft and messaging has its own loading state 2026-04-22 12:17:53 +05:45
Sam Thomson ae622909f3 Merge branch 'feat/communities' into 'main'
Feature: Communities Foundation

See merge request soapbox-pub/agora-3!2
2026-04-21 02:35:14 +00:00
lemon 5fa021329e remove kind 5/1984 moderation from community membership resolution
The deletion and report queries were unscoped (fetching globally) and the
moderation overlay needs more design work. Strip it out for now and leave
TODOs for a follow-up.
2026-04-20 11:57:03 -07:00
sam ef100bfac1 guard messages if not authed 2026-04-20 18:39:19 +05:45
sam c82b256128 port conflict 2026-04-20 18:27:38 +05:45
sam a5c52c72be dms first pass 2026-04-20 18:14:35 +05:45
sam 865a472ef1 delete legacy mkstack dms 2026-04-20 18:10:23 +05:45
sam 85b8e68f52 ++ 2026-04-20 18:09:43 +05:45
sam c26aa709d0 nginx proxy for decrypting kind15s 2026-04-20 18:09:28 +05:45
lemon e1d4939c81 add hierarchical communities protocol spec to NIP.md 2026-04-19 17:55:20 -07:00
lemon 8c83758461 replace Follows tab with Activities tab showing community events and comments 2026-04-19 17:42:31 -07:00
lemon da1d872dd7 hide media, protocol, language, kind, and replies filters on communities search tab 2026-04-19 17:42:31 -07:00
lemon 70f74c6f9d simplify community NoteCard: remove moderators list, separator, and stats badges 2026-04-19 17:42:31 -07:00
lemon 556af013db add Communities tab to search page with global kind 34550 feed 2026-04-19 17:42:31 -07:00
lemon b7a128ad28 shorten empty events message to 'No events yet' 2026-04-19 17:42:31 -07:00
lemon c17be3d191 simplify empty events state: remove icon, border, and card background 2026-04-19 17:42:31 -07:00
lemon e2d3a164a6 remove separator line between founder and tabs 2026-04-19 17:42:31 -07:00
lemon 88d2fdd904 remove stats badges from community detail page header 2026-04-19 17:42:31 -07:00
lemon 6929097466 replace comment button with ComposeBox in community detail page 2026-04-19 17:42:31 -07:00
lemon 52dae96a61 add dedicated community detail page with members, events, and comments tabs 2026-04-19 17:42:31 -07:00
lemon c82c6f4179 add communities page with NIP-72 hierarchical community support 2026-04-19 17:42:31 -07:00
sam 0c389397d2 disable nsite publishing for now 2026-04-19 15:40:21 +05:45
Sam Thomson 7254f40fc9 Merge branch 'refactor/bring-over-missing-agora-features' into 'main'
refactor/bring-over-missing-agora-features

See merge request soapbox-pub/agora-3!1
2026-04-19 05:06:55 +00:00
sam 1ffa5289ba legacy aliases 2026-04-18 19:35:21 +05:45
sam 6d51f6eeac geo chat wip 2026-04-18 19:12:13 +05:45
sam bd6eb18022 WORLD++ 2026-04-18 18:40:02 +05:45
sam 5f2e88c0f3 old pathos/agora map 2026-04-18 17:17:22 +05:45
sam 55fe82adf9 community stats 2026-04-18 16:28:41 +05:45
sam 81a91f033b fix agents disobediance on git commits 2026-04-18 16:21:14 +05:45
sam 711a9527e9 left menu rearranging 2026-04-18 14:11:07 +05:45
sam ced5d00163 ported actions/challenges 2026-04-18 13:38:50 +05:45
sam b6dffa9828 organisers, country content, external content 2026-04-18 13:03:02 +05:45
sam 5a94ef10d7 verified follow packs 2026-04-18 11:45:10 +05:45
sam ec9f57476d ++ 2026-04-18 11:44:46 +05:45
sam 6a60612ba6 poll compose 2026-04-18 11:44:31 +05:45
sam 945ae3b126 ported country-scoped feed model from Pathos 2026-04-17 17:22:59 +05:45
sam a23a470eac don't git commit 2026-04-17 16:27:02 +05:45
sam 2ee979afc0 spark wallet+ 2026-04-17 16:22:33 +05:45
sam ba996d9878 drop wikipedia 2026-04-17 14:39:18 +05:45
sam e0e2300521 reconsidered sidebar items 2026-04-17 14:17:07 +05:45
sam 0f0ea01f9a ditto -> agora context in the readme 2026-04-17 12:29:00 +05:45
sam a56860a6ce logo/copy changes 2026-04-17 12:15:51 +05:45
sam 9550094ffb wip mega dump/migration from ditto 2026-04-17 12:10:11 +05:45
Alex Gleason 71918f8381 release: v2.8.0 2026-04-16 18:05:50 -05:00
Alex Gleason 99fefdda67 Merge branch 'main' of gitlab.com:soapbox-pub/ditto 2026-04-16 17:17:47 -05:00
Alex Gleason dabe3c1687 Fix avatar shape not saving during signup
The signup and onboarding profile steps rendered ProfileCard without
passing onAvatarShape, so emoji shape selections were silent no-ops and
never made it into the published kind 0 event.
2026-04-16 17:06:27 -05:00
Chad Curtis 1caf911f53 Merge branch 'ai-chat-429' into 'main'
Overhaul AI chat: handle 429 rate limiting, require Shakespeare credits

Closes #230

See merge request soapbox-pub/ditto!187
2026-04-16 22:03:57 +00:00
Chad Curtis c3f0e9d3fa Merge branch 'main' of gitlab.com:soapbox-pub/ditto into ai-chat-429
# Conflicts:
#	package-lock.json
2026-04-16 16:59:54 -05:00
Chad Curtis bc39c99d07 Merge branch 'feat/evolution-missions-to-kind-11125' into 'main'
Move hatch/evolve task progress from kind 31124 tags to kind 11125 evolution[]

Closes #234

See merge request soapbox-pub/ditto!186
2026-04-16 21:41:51 +00:00
Chad Curtis 377b536456 Merge remote-tracking branch 'origin/main' into feat/evolution-missions-to-kind-11125
# Conflicts:
#	package-lock.json
2026-04-16 16:35:38 -05:00
Chad Curtis bf0fde9d06 Fix interaction tally not incrementing: ensure evolution missions exist in session store
The interactions tally mission was silently dropped because
trackEvolutionTally maps over the evolution[] array — if it's empty,
nothing gets incremented. This happened when evolution missions
weren't persisted to kind 11125 or weren't hydrated on page load.

Both useHatchTasks and useEvolveTasks now have a safety-net effect:
if the companion is in an active task process (incubating/evolving)
but evolution[] is empty, they re-populate from the static mission
definitions. This ensures tally tracking works immediately regardless
of hydration timing.
2026-04-16 16:33:45 -05:00
Alex Gleason fb5278b891 Add nsec backup to Profile settings
Lets users with a local-nsec login reveal, copy, and back up their secret
key from /settings/profile. Uses saveNsec() so iOS gets iCloud Keychain,
Android gets Credential Manager with a file fallback, and web gets a
.nsec.txt download plus an opportunistic PasswordCredential save.

Renders an explanatory message for NIP-07 extension and NIP-46 bunker
logins, where the key is not accessible from the app.
2026-04-16 16:24:07 -05:00
Chad Curtis a27ee3af86 Fix self-review findings: remove dead code, fix task progress display, fix hydration race
- Remove dead code: useSyncTaskCompletions, incrementInteractionTaskTags,
  getInteractionCount, getEvolveInteractionCount, unused lookup maps
- Fix task progress showing 0/N on load: compute event-based task counts
  directly from Nostr query results (authoritative) instead of relying
  solely on the evolution mission store which may not be hydrated yet.
  Use max(queryCount, missionCount) so progress displays immediately.
- Fix hydration race: useDailyMissions raw memo now waits for hydration
  before creating fresh missions, preventing overwrite of persisted
  evolution[] with empty array. Also preserve evolution missions across
  daily resets during hydration.
- Fix session store miss: use ensureSessionStore in incubation/evolution
  start so evolution missions are always populated even if the store
  hasn't been hydrated yet.
- Extract duplicate findMission to shared findEvolutionMission in
  evolution-missions.ts
- Document evolution[] field on kind 11125 in NIP.md
2026-04-16 16:17:57 -05:00
Alex Gleason 7073cadb43 release: v2.7.1 2026-04-16 16:09:12 -05:00
Alex Gleason 2dfb880566 Improve signup save-key step
Addresses confusion on the key-save step during signup:

- Rename the primary button from 'Continue' to 'Save Key' with a
  Download icon, so the label matches the action it performs.
- Change saveNsec() to return 'saved' | 'saved-to-file' | 'dismissed'
  instead of throwing on native dismissal. Dismissing the iCloud
  Keychain prompt is a legitimate user choice so the handler now
  proceeds silently rather than blocking with a 'Save failed' toast.
- Add an in-flight guard on the Save Key button with a spinner and
  'Saving…' label. The finally block guarantees the disabled state is
  cleared, so users can never get stuck on an unresponsive button —
  fixing the 'button became disabled after I dismissed the prompt'
  complaint by construction.
- On de-Googled Android builds (GrapheneOS, /e/OS, etc.) the AndroidX
  Credential Manager has no provider to delegate to, so the keychain
  save fails immediately. Fall back to writing the key to the app's
  Documents directory so the user always has a persistent backup, and
  surface a toast telling them where the file is.
- iOS keeps its original behaviour: dismissing the iCloud Keychain
  sheet is a deliberate user choice, no automatic fallback. The
  Documents folder on iOS is accessible via the Files app without
  authentication, so silently dropping a plaintext nsec there would
  violate user intent.
- Use the app name (from config.appName) as the filename slug for any
  .nsec.txt file written to disk. On Capacitor location.hostname is
  always 'localhost', so passing the app name is the only way to get
  a meaningful filename. Drop the redundant 'nostr-' prefix since the
  '.nsec.txt' extension already identifies the file.
- Rewrite the description and title on the save step: 'Your secret
  key' + a single paragraph explaining what the key is and why it
  matters.
- When the user reveals the key via the eye toggle, show an amber
  callout with sharing/screenshotting warnings and a 'Learn more' link
  to the Managing Nostr keys blog post. The warning appears at the
  moment risk is highest.
- Auto-select the full nsec on focus/click so users copying into a
  password manager don't have to fight mobile selection handles.
- Use openUrl() for the external 'Learn more' link so it works
  correctly inside Capacitor's WKWebView.
- Singularise the keygen step copy ('cryptographic key' / 'Generate
  my key') to stay consistent with the save step which presents a
  single secret key.
2026-04-16 15:50:59 -05:00
Chad Curtis 13d4f667b6 Restore AI chat widget and extract shared credits hook
- Restore full interactive chat widget with ScrollArea, streaming messages,
  input area, and conversation cache that was regressed in ec9b6c43
- Extract useShakespeareCredits hook so credits gating is DRY between the
  widget and the full AI chat page
- Show Dork ASCII mascot consistently across all empty/logged-out states
  instead of the generic Bot icon
2026-04-16 15:47:33 -05:00
Chad Curtis d73460a617 Fix self-review findings: invalid HTML nesting, credits error handling, swallowed 400 errors 2026-04-16 15:36:09 -05:00
Chad Curtis ec9b6c43be Overhaul AI chat: handle 429 rate limiting, require Shakespeare credits
- Add RateLimitError class with Retry-After header parsing
- Distinguish insufficient_quota 429 from rate-limit 429
- Friendly Dork-themed error banners for rate limiting and out-of-credits
- Clean no-credits empty state with directive CTA and Get Credits button
- Hide model selector, trash, and input when user has no credits
- Hide page title on mobile, align model selector right
- Simplify sidebar widget to Shakespeare CTA
2026-04-16 15:28:09 -05:00
Alex Gleason 0d3b8ed23d Harden CSS/URL handling, NWC storage, and Android backup
- Sanitize event-sourced URLs before CSS url() interpolation in
  ProfileCard banner and letter stationery background (closes H-1, H-2)
- Sanitize event-sourced font families at the parse layer and in letter
  card/detail consumers that bypass resolveStationery (closes M-6)
- Export sanitizeCssString for broader reuse
- Route NWC wallet connection URIs and active pointer through a new
  useSecureLocalStorage hook, storing in iOS Keychain / Android KeyStore
  on native (closes M-1)
- Add removeItem to secureStorage
- Add Android backup/data-extraction rules that exclude WebView storage
  and Capacitor secure-storage SharedPreferences so wallet credentials
  don't leak via Google Auto Backup (closes M-5)
- Document that GOOGLE_PLAY_SERVICE_ACCOUNT_JSON must be base64-encoded
  to match what the CI job expects (closes M-2)
2026-04-16 14:20:26 -05:00
Alex Gleason a61925b821 Merge branch 'main' of gitlab.com:soapbox-pub/ditto 2026-04-16 13:50:42 -05:00
Alex Gleason cbfbca063e Validate theme font and background URLs at the schema layer
`ThemeFontSchema.url` and `ThemeBackgroundSchema.url` previously accepted
any string, relying entirely on downstream `sanitizeUrl()` calls for
protocol enforcement. Tightening the schema to `z.url()` rejects
obviously malformed inputs up front and matches the approach already used
for the relay list (`BlossomServersEventSchema`). `sanitizeUrl()` remains
the authoritative guard for `https:` enforcement at render time.
2026-04-16 13:47:16 -05:00
Alex Gleason f3393b2cc8 Store nostr-push device key in secure storage on native builds
The per-device ephemeral key used to sign nostr-push RPC events was
previously stored unconditionally in localStorage. On Capacitor builds
this bypassed the iOS Keychain / Android KeyStore wrapper that every
other persistent key in the app already uses.

Route the key through `secureStorage`, which keeps the native path
encrypted at rest and falls back to localStorage on web (where it was
before). Because the key is now loaded asynchronously, convert the
`NostrPushClient` constructor into a private constructor plus a public
`create()` factory, and restructure `usePushNotifications` bring-up to
await the client before registering the service worker.

The key is ephemeral and per-device, so compromise only reveals which
Nostr events this device subscribes to -- not the user's identity --
but matching the existing secure-storage contract closes an obvious
inconsistency.
2026-04-16 13:47:16 -05:00
Alex Gleason 2eb643f422 Sanitize app-handler picture and banner URLs
The `picture` and `banner` fields parsed from a kind 31990 NIP-89 event's
JSON content were passed directly to `<img src>` attributes without any
scheme validation. Non-https URLs could leak the user's IP to arbitrary
hosts, and data: URIs could be used for fingerprinting.

The same event's `website` URL was already sanitized; apply the same
treatment to the image URLs for consistency. The app's CSP `img-src`
already blocks most of these at the browser level, so this is
defense-in-depth.
2026-04-16 13:47:15 -05:00
Alex Gleason e22dbbe85c Validate NIP-05 resolver returns a 64-char hex pubkey
Previously the resolver accepted any string value from a domain's
.well-known/nostr.json `names` map and persisted it to IndexedDB. A
malicious or misconfigured NIP-05 server could return arbitrary data
(non-hex, wrong length, HTML, etc.) that would then be cached and
passed to downstream consumers as a pubkey.

Exploitation impact is limited because invalid hex simply fails to
match anywhere in the Nostr filter API, but hygiene and cache
integrity warrant rejecting malformed values outright. Enforce the
standard 64-char lowercase hex shape and evict any cached entry that
fails validation.
2026-04-16 13:47:15 -05:00
Alex Gleason e01ed039fb Add restrictive sandbox attribute to web sandbox iframe
Previously the SandboxFrame iframe relied entirely on cross-origin
subdomain isolation (the HMAC-derived `<id>.sandbox.ditto.pub` origin)
for containment. That does give origin-keyed storage and postMessage
isolation, but it does not restrict top-frame navigation, pointer lock,
or other capabilities that a hostile nsite/webxdc app could abuse.

The highest-value protection here is blocking `allow-top-navigation`:
without it, a malicious nsite could do `window.top.location = evilUrl`
and redirect the entire Ditto tab to a phishing page that impersonates
the app. The user opened a preview expecting to stay inside Ditto, so
this is a realistic and impactful attack.

The policy grants the capabilities that real web apps legitimately use
(scripts, same-origin storage + Service Workers per iframe.diy's
architecture, forms, modals, popups that escape the sandbox, downloads)
while withholding the ones that are either attacks (top navigation) or
unused niche features (pointer lock, presentation API, orientation
lock).

Also Omit 'sandbox' from the spread props so consumers cannot
accidentally weaken the policy.
2026-04-16 13:47:13 -05:00
Chad Curtis 17cdb87723 Merge branch 'fix/scroll-restoration-on-back-navigation' into 'main'
Fix scroll position lost when navigating back from post detail page

Closes #217

See merge request soapbox-pub/ditto!161
2026-04-16 18:35:28 +00:00
Alex Gleason a55ff61669 Verify NIP-17 inner rumor pubkey matches seal pubkey
NIP-17 requires that clients verify `messageEvent.pubkey === sealEvent.pubkey`
before trusting a gift-wrapped direct message. Without this check, any
attacker can construct a rumor claiming to be from another user and
gift-wrap it to the victim -- the seal signature only authenticates the
seal author, not the (unsigned) inner rumor.

Ditto's primary sender display uses sealEvent.pubkey so the headline
impersonation case is mitigated in practice, but the inner event's fields
(including its pubkey) are passed whole to NoteContent for kind 15 file
attachments, which could leak into downstream zap/reply targeting. Add
the spec-mandated check to prevent any trust in the inner pubkey.
2026-04-16 13:21:15 -05:00
Chad Curtis 5c215aeec5 Add debounced persistence for evolution mission progress
The in-memory session store doesn't survive page refresh. Add
usePersistEvolutionProgress hook that listens for evolution mission
changes and debounce-publishes (5s) to kind 11125 content JSON via
fetchFreshEvent + serializeProfileContent. Wired into BlobbiPage.
2026-04-16 13:11:41 -05:00
Chad Curtis 591ab57352 Fix lint: remove unused imports, wrap evolution in useMemo 2026-04-16 12:06:22 -05:00
Chad Curtis cb42b1b6a3 Move hatch/evolve task progress from kind 31124 tags to kind 11125 evolution[]
Migrate the hatch/evolve task system to use MissionsContent.evolution[]
on kind 11125 (Blobbonaut Profile) instead of task/task_completed tags
on kind 31124 (Blobbi State).

- Add evolution-missions.ts with static definitions for hatch and evolve
  task pools (TallyMission for interactions, EventMission for themes,
  color moments, posts, profile edits)
- Populate evolution[] in session store on incubation/evolution start;
  clear on stop
- Switch interaction tracking from incrementInteractionTaskTags (kind
  31124 tag manipulation) to trackEvolutionMissionTally (session store)
- Rewrite useHatchTasks/useEvolveTasks to read progress from evolution[]
  and backfill event IDs from retroactive Nostr queries
- Remove useSyncTaskCompletions and the task tag sync effect from
  BlobbiPage

WIP: type errors and barrel exports still need cleanup.
2026-04-16 11:56:16 -05:00
Chad Curtis 3039c46565 Merge branch 'feat/blobbi-retroactive-task-progression' into 'main'
Make hatch/evolve missions count retroactively from user history

Closes #222

See merge request soapbox-pub/ditto!185
2026-04-16 16:18:07 +00:00
Chad Curtis 2d74088b25 Add scroll-to-top feed refresh on Home re-tap and fix mobile tab hover artifact 2026-04-15 20:56:02 -05:00
Alex Gleason 2d52aa8a56 release: v2.7.0 2026-04-14 16:01:08 -05:00
Alex Gleason 02b83be58e Prevent text selection on long-press of gamepad controls on iOS
Add -webkit-touch-callout: none and -webkit-user-select: none inline
styles to the GameControls container. The existing Tailwind select-none
class (user-select: none) is not sufficient on iOS, where WKWebView
still triggers the long-press callout/highlight gesture on held buttons.
2026-04-14 15:49:16 -05:00
Alex Gleason 8c3371e968 Add native iOS notification polling with rich metadata and grouping
Implement background relay polling for iOS using BGTaskScheduler,
addressing Apple App Store rejection (Guideline 4.2 - Minimum Functionality).

- DittoNotificationPlugin: Capacitor plugin mirroring the Android interface,
  schedules BGAppRefreshTask whenever notifications are enabled (no settings
  change required — both push/persistent modes poll on iOS)
- NostrPoller: fetches notification events via URLSessionWebSocketTask,
  resolves author display names from kind 0 metadata (24h cache), verifies
  referenced event authorship for reactions/reposts/zaps
- Rich notifications with author names, content previews, zap amounts, and
  reaction emoji display
- iOS thread identifiers for native notification grouping per category+post
- Notification categories with summary formats
- Foreground notification display and tap-to-navigate handling
- Immediate poll on app foreground to catch up on missed notifications
- Hide Delivery Method picker on iOS (only meaningful on Android)
2026-04-14 14:58:49 -05:00
Alex Gleason 1a106545f7 Fix haptics: call isNativePlatform() at invocation time, log errors
The platform check was cached as a module-level constant, which could
evaluate before the Capacitor bridge was ready. Moved to per-call checks
matching the pattern used everywhere else in the codebase. Also replaced
silent .catch(() => {}) with console.warn so failures are visible in
Safari Web Inspector / Xcode console.
2026-04-14 14:08:32 -05:00
filemon 86c4594cdd Clean up self-review findings: remove dead exports, simplify query keys, align ceremony state flow
- Remove dead deprecated exports: isValidEvolvePost, EVOLVE_REQUIRED_POSTS,
  BLOBBI_EVOLVE_POST_PREFIX, isValidBlobbiPost, sanitizeToHashtag
- Remove corresponding barrel re-exports from actions/index.ts
- Simplify hatch/evolve query keys to ['...-tasks', pubkey] since
  retroactive queries no longer depend on stateStartedAt
- Drop stateStartedAt from enabled guards so retroactive queries
  aren't blocked when the timestamp is missing
- Align BlobbiHatchingCeremony hatch path: babies now start as
  'evolving' with state_started_at set, matching useBlobbiStageTransition
- Ceremony fakePreview for existing eggs preserves companion's actual state
2026-04-14 14:58:49 -03:00
filemon 6d157c0a65 Merge branch 'main' into feat/blobbi-retroactive-task-progression 2026-04-14 14:13:47 -03:00
filemon 43c75175f4 Auto-start incubation/evolution for new Blobbis
New eggs now start in 'incubating' state with state_started_at set at
adoption time, so hatch tasks begin tracking immediately.

Newly hatched babies now start in 'evolving' state with a fresh
state_started_at, so evolution tasks begin tracking immediately.

The evolving state is applied after validateAndRepairBlobbiTags (which
would otherwise repair task-process states to 'active' via cleanupTaskTags).

Existing/older Blobbis are unaffected -- no migration is performed.
Stop incubation/evolution actions continue to work as before.
2026-04-14 13:47:34 -03:00
Alex Gleason ffa1094f93 Add haptic feedback to Blobbi egg interactions
Hatching ceremony: escalating haptics on each crack click (light → medium
→ heavy → success notification on hatch). Egg tap-to-wiggle in feeds and
posts: light impact on each user-initiated tap. Auto-wiggle intervals are
excluded to avoid unwanted vibration.
2026-04-14 11:44:45 -05:00
Alex Gleason e890e913f5 Fix deep-linking on Google Play version (assetlinks.json update) 2026-04-14 11:42:40 -05:00
Alex Gleason 12a4966b84 Add haptic feedback to emoji reaction selection in QuickReactMenu
The popover emoji picker (both quick presets and full picker) was
publishing reactions internally without triggering haptic feedback.
Add impactLight() at the top of publishReaction() so every emoji
selection path gets tactile feedback.
2026-04-14 11:29:22 -05:00
filemon b68ea276db Make hatch/evolve missions count retroactively from user history
Content-type missions (theme, color moment, post, profile edit) now query
the user's full Nostr history instead of filtering by state_started_at.
Only Blobbi-specific tasks (interactions, maintain_stats) still require
actions on the current Blobbi instance.

Egg incubation:
- create_theme, color_moment: retroactive (no since: filter)
- create_post: retroactive, simplified to any post with #blobbi tag
- interactions: still Blobbi-specific (7x care actions)

Baby evolution:
- create_themes, color_moments, edit_profile: retroactive
- create_posts task removed entirely
- interactions: still Blobbi-specific (21x care actions)
- maintain_stats: still Blobbi-specific (dynamic, all stats >= 80)
2026-04-14 13:27:16 -03:00
Alex Gleason cc702027b0 Add native haptic feedback to all key interactions
Install @capacitor/haptics and add a centralized haptics utility
(src/lib/haptics.ts) that uses the native taptic engine on iOS/Android
and falls back to navigator.vibrate() on web.

Haptics added to:
- Switch component (covers 36+ toggle switches app-wide)
- PullToRefresh threshold (covers 15+ pages)
- MobileBottomNav tab taps
- ReactionButton (like/unlike, double-click heart)
- RepostMenu (repost/undo repost)
- ZapDialog button press + payment success (NWC and WebLN)
- FollowButton and ProfilePage follow toggle
- ComposeBox (post, voice message, and poll publish success)
- NoteMoreMenu (bookmark, pin, mute)
- VinesFeedPage reaction and repost buttons
- ProfileReactionButton and ExternalReactionButton
- NoteCard share button
- BlobbiRoomShell swipe navigation

Replaces raw navigator.vibrate() calls in GameControls and
SendAnimation with the new cross-platform haptics utility, fixing
haptic feedback on iOS where the Vibration API is not available.
2026-04-14 11:06:18 -05:00
Alex Gleason 328c858e4e Merge branch 'fix/emoji-autocomplete-click' into 'main'
Fix emoji/mention autocomplete dropdowns not clickable in compose modal

Closes #221

See merge request soapbox-pub/ditto!184
2026-04-14 02:25:11 +00:00
Mary Kate Fain dcf77aac2a Add pointer-events-auto to autocomplete dropdowns
Radix Dialog's DismissableLayer sets pointer-events: none on
document.body when the modal is open. Since the dropdowns are portaled
to document.body, they inherit this and silently swallow all mouse
events. Adding pointer-events-auto restores click delivery.
2026-04-13 21:14:52 -05:00
Mary Kate Fain cdf3391aad Fix emoji/mention autocomplete dropdowns not clickable in compose modal
The autocomplete dropdowns are portaled to document.body to escape
overflow clipping, which places them outside the Radix Dialog DOM tree.
Clicks on them were treated as 'interact outside' the dialog, preventing
mouse selection of emoji and mention suggestions.

Add data-autocomplete-dropdown attribute to both dropdown containers and
check for it in handleInteractOutside to prevent modal dismissal.
2026-04-13 21:09:58 -05:00
Mary Kate Fain 787446b4ee Update package-lock.json: remove stale dev flags from esbuild optional deps 2026-04-13 20:56:56 -05:00
Mary Kate Fain 5febdb2d7d Increase widget header icon size to match text-xl label 2026-04-13 20:54:08 -05:00
Mary Kate Fain 005f40b536 Increase widget header label to text-xl 2026-04-13 20:52:35 -05:00
Mary Kate Fain 01a6012a0a Simplify sidebar widgets: remove collapse, reposition drag handle, clean up borders 2026-04-13 20:48:36 -05:00
Chad Curtis c009eb4d5c Fix inline zap rendering: add EmbeddedZapCard for kind 9735
Zap receipts embedded via nostr:nevent1 references were falling through
to the generic EmbeddedNoteCard, which rendered the raw JSON content of
the zap request. Add a dedicated EmbeddedZapCard that extracts and
displays the sender, amount, and message using the existing zap utility
functions. Forwards disableHoverCards to prevent nested hover cards.
2026-04-13 19:05:18 -05:00
Chad Curtis 9bdfa1a485 Merge branch 'improve/blobbi-stuck-behavior' into 'main'
update: reduce stuck recovery chance threshold from 30% to 10%

See merge request soapbox-pub/ditto!183
2026-04-13 23:48:56 +00:00
Chad Curtis 6742792e90 Fix embedded quote review issues
- Add disableMediaEmbeds prop to NoteContent that suppresses images,
  galleries, and video/audio inside embedded quotes while preserving
  link preview cards and lightning invoices
- Render inline fallback links for nevent/naddr references when
  disableNoteEmbeds is true, instead of returning null and leaving
  invisible gaps in quoted text
- Restore tag-based title/description fallback for events with empty
  content (articles, custom addressable kinds) so they don't render
  blank cards
- Migrate EmbeddedProfileBadgesCard to useProfileUrl for consistent
  profile link routing
2026-04-13 18:15:31 -05:00
Chad Curtis 8f6d52a9f9 Fix embedded quote rendering: use NoteContent for DRY media/blobbi display
- Fix stateful global regex bug (IMETA_MEDIA_URL_REGEX) causing every
  other URL to be misclassified when used with .test() in loops; add
  non-global IMETA_MEDIA_URL_TEST_REGEX for safe .test() calls
- Rewrite EmbeddedNoteCard to render content via NoteContent (same as
  NoteCard) with a 260px height cap instead of reimplementing URL
  parsing and content truncation
- Pass disableEmbeds to NoteContent inside quotes to prevent recursive
  nostr:nevent/note references from spawning nested EmbeddedNote
  components
- Add overflow-aware 'Read more' toggle inline with attachment chips;
  fade gradient only renders when content actually overflows
- Add BlobbiStateCard rendering for kind 31124 in both EmbeddedNote
  and EmbeddedNaddr
- Extract EmbeddedCardShell with shared clickable card wrapper and
  author row, deduplicating ~150 lines across EmbeddedNoteCard,
  EmbeddedNaddrCard, and EmbeddedBlobbiCard
- Fix ComposeBox media URL detection using the same regex fix
- Fix EmbeddedNaddr profile links to use useProfileUrl instead of
  hardcoded npub paths
2026-04-13 18:03:09 -05:00
Chad Curtis 51a25919c7 Fix media rendering after quote posts in q-tag quotes
Video/audio/webxdc URLs were silently stripped from NoteContent's token
stream and rendered by parent components after NoteContent. When a quote
post's nostr: URI appeared at the end of the content, media was placed
after the quote embed instead of before it.

Render all media inline within NoteContent at their original content
position via a new media-embed token type. Remove the now-unused
NoteMedia component and the separate media rendering in NoteCard,
PostDetailPage, and ComposeBox.

Also:
- Append media-embed tokens for imeta-declared media not in content
  (gated to text note kinds 1/11/1111 only)
- Sanitize imeta-sourced URLs via sanitizeUrl()
- Skip useAuthor query when no media-embed tokens exist
- Memoize author display name derivation
2026-04-13 17:10:16 -05:00
Chad Curtis 1405b5e2c2 Merge branch 'feat/blobbi-rooms-progression' into 'main'
Rewrite progression, missions system in Blobbi

See merge request soapbox-pub/ditto!179
2026-04-13 21:43:16 +00:00
Chad Curtis 8b3b412b16 Persist poop cleanup XP to companion (debounced publish)
Each poop cleaned awards 5 XP to the companion's experience tag.
Multiple pickups are debounced into a single Nostr publish (1.5s
after the last cleanup) to avoid excessive relay traffic. Uses
ensureCanonicalBeforeAction for fresh-read safety.
2026-04-13 16:34:40 -05:00
Chad Curtis bbcefbb79e Fix review findings: wire up daily XP award, stale-read safety, poop toast, carousel index reset
- Wire useAwardDailyXp into BlobbiDashboard so daily mission XP is
  actually persisted (was exported but never called)
- Rewrite useAwardDailyXp to use fetchFreshEvent + prev pattern
  instead of reading from stale TanStack Query cache
- Remove misleading '+XP' toast from poop cleanup; delegate to parent
  via onPoopCleaned callback with honest 'Cleaned up!' message
- Fix room-config.ts comment to accurately describe room tag status
  (read on mount, not yet written back on room change)
- Make handleOpenShopFromAction navigate to kitchen room instead of
  silently closing the modal
- Reset ItemCarousel index to 0 when items array changes to prevent
  out-of-bounds access
- Derive KitchenBar foodEntries from foodItems memo instead of
  duplicating getLiveShopItems().filter(food)
- CareBar Treat button: memoize treat item, show its name as label,
  handle missing item gracefully
- Fix useItemCooldown: remove module-level side-effect subscription,
  use proper useSyncExternalStore subscribe contract
2026-04-13 16:29:51 -05:00
Chad Curtis 83f2f1de7e Merge origin/main into feat/blobbi-rooms-progression, fix lint warnings 2026-04-13 16:12:35 -05:00
filemon 3dd77c2fcc update: reduce stuck recovery chance threshold from 30% to 10% 2026-04-13 17:45:49 -03:00
Alex Gleason b51b11063f Merge branch 'new-sidebar' into 'main'
Add customizable widget sidebar for the main feed right column

Closes #175

See merge request soapbox-pub/ditto!181
2026-04-13 20:19:17 +00:00
Mary Kate Fain 4ffa3119a7 Fix self-review findings: CSS injection, stale reads, type safety, error states
- Sanitize AI tool background_url with sanitizeUrl() to prevent CSS injection
- Replace 'as unknown as' and 'as Partial<Record>' type escapes with proper
  ChatCompletionTool, ChatCompletionResponseMessage, and ChatCompletionToolCall
  types in useShakespeare
- BlueskyWidget: throw on !res.ok so useQuery retry works; type response
- WikipediaWidget: add explicit isError state instead of masking as 'no article'
- Pass prev (profileEvent) to publishEvent on KIND_BLOBBONAUT_PROFILE mutations
  in BlobbiWidget and BlobbiPage to preserve published_at
- Add profileEvent field to EnsureCanonicalResult interface
- useEncryptedSettings: fetch fresh event from relays before mutation instead
  of reading from stale TanStack cache (cross-device safety)
2026-04-12 18:29:36 -05:00
Mary Kate Fain dbf7ed9bb2 Fix unsanitized Nostr URLs, stale-read mutations, and missing prev on addressable events
- Sanitize imeta URLs at the parse layer in PhotoWidget (parseFirstPhoto)
- Sanitize all URLs from Nostr event tags in musicHelpers (parseMusicTrack,
  parseMusicPlaylist): audio URL, artwork, video, playlist artwork
- Fix stale-read-then-write in handleSetAsCompanion (BlobbiWidget + BlobbiPage):
  use ensureCanonicalBeforeAction to fetch fresh profile from relays instead of
  reading profile.allTags from TanStack Query cache
- Pass prev to publishEvent for KIND_BLOBBI_STATE (addressable kind 31124) in
  both BlobbiWidget and BlobbiPage handleRest to preserve published_at
- Fix usePublishStatus: fetch previous kind 30315 event before publishing to
  preserve published_at per addressable event convention
2026-04-12 17:57:28 -05:00
Mary Kate Fain 8f5f33560e Fix AI chat widget: sticky input at bottom, remove redundant Full chat link
For fillHeight widgets, WidgetCard now renders content in a plain
fixed-height div instead of a ScrollArea, so the widget's internal
flex layout can properly fill the container with messages scrolling
above and input pinned at the bottom.

Remove the 'Full chat' link since the widget header already links
to /ai-chat.
2026-04-12 17:18:09 -05:00
Mary Kate Fain 41392d9299 Extract BlobbiAwayState into shared component
Move the 'out exploring' UI into src/blobbi/ui/BlobbiAwayState.tsx with
size presets ('md' for page, 'sm' for widget). Both BlobbiPage and
BlobbiWidget now import from the shared component instead of rendering
the away state inline.
2026-04-12 17:07:36 -05:00
Mary Kate Fain 4623438652 Extract DorkThinking into shared component, use in AI chat widget
Move the animated Dork face (the <[o_o]> thinking animation) into
src/components/DorkThinking.tsx with a className prop for sizing.
Both AIChatPage and AIChatWidget now import from the shared component.
The widget uses text-[10px] for a compact fit inside the chat bubble.
2026-04-12 16:50:53 -05:00
Mary Kate Fain 6948938768 Fix AI chat widget layout with fillHeight option for fixed-height widgets
Add fillHeight property to WidgetDefinition. When true, WidgetCard uses
a fixed height instead of max-height on the ScrollArea, allowing the
widget's internal flex layout to properly fill the container. The AI chat
widget's messages area now scrolls correctly at a fixed height instead
of awkwardly growing with content.
2026-04-12 16:46:17 -05:00
Mary Kate Fain db9cdd04c5 Fix AI chat widget 400 error by fetching available models dynamically
The widget was hardcoding 'shakespeare' as the model name, which is
not a valid model ID. Now fetches available models from the API and
uses the cheapest one as default, matching how AIChatPage works.
2026-04-12 16:39:43 -05:00
Mary Kate Fain 528cf905fb Show 'out exploring' state in Blobbi widget when companion is floating
Uses useBlobbiCompanionData() to detect if this Blobbi is the active
floating companion (same check as BlobbiPage). When active, hides the
visual and stat wheels, showing a Footprints icon + 'Out exploring
with you' message + gradient 'Bring home' button instead.
2026-04-12 16:29:09 -05:00
Mary Kate Fain 2c08bcd94a Add Take Along companion toggle button to Blobbi widget 2026-04-12 16:21:14 -05:00
Mary Kate Fain 9de3fa7112 Unify stat rings and action buttons into clickable StatIndicator wheels
Extract StatIndicator into a shared component (src/blobbi/ui/StatIndicator.tsx)
with size ('sm'/'md') and onClick/disabled props. Reuse it in both
BlobbiPage (display-only, size='md') and BlobbiWidget (clickable, size='sm').

The widget now shows a single row of stat wheels that double as action
buttons: clicking the hunger wheel feeds, hygiene cleans, health heals,
happiness plays, and energy toggles sleep/wake. Removes the separate
action button row entirely.
2026-04-12 16:11:53 -05:00
Mary Kate Fain 28027cd7b2 Add Heal (medicine) quick action button to Blobbi widget 2026-04-12 16:00:48 -05:00
Mary Kate Fain e54fad61ae Replace stat bars with compact circular ring indicators in Blobbi widget
Uses the same SVG progress ring + lucide icon pattern as BlobbiPage,
scaled down to 36px circles. Shows warning/critical alert triangles
on low stats. Much more compact vertically than the horizontal bars.
2026-04-12 15:58:00 -05:00
Mary Kate Fain 31189801f8 Fix Blobbi widget: show status-reactive visuals and increase height for action buttons
- Pass useStatusReaction recipe to BlobbiStageVisual so the widget
  reflects the actual health state (dizzy eyes, stink clouds, etc.)
- Increase default widget height from 280px to 350px so quick action
  buttons aren't clipped by the scroll container
2026-04-12 15:53:50 -05:00
Mary Kate Fain d579e91bbd Enhance Blobbi widget with live stats and quick action buttons
- Syncs companion selection with BlobbiPage (localStorage + profile.has)
- Shows projected decay stats that update every 60s
- Adds Feed, Play, Clean, and Sleep/Wake quick action buttons
- Hides actions irrelevant to the current stage (eggs can't eat/play/sleep)
- Uses the same ensureCanonical + decay + publish flow as BlobbiPage
- Buttons disable while an action is in progress
2026-04-12 15:41:28 -05:00
Mary Kate Fain 27133d69f2 Use curator follow list for Articles and Events widgets when logged out 2026-04-12 15:29:40 -05:00
Mary Kate Fain 5e895e59ae Replace Photos and Music widgets with rich single-item formats
Photos widget shows the latest photo with image, author, and caption.
Music widget shows the latest track with artwork and playable controls
via the global audio player. Both scope to the user's follow list when
logged in, or the curator's follow list when logged out.
2026-04-12 15:23:34 -05:00
Mary Kate Fain c5f9f8be6c Remove Books sidebar widget 2026-04-12 15:14:51 -05:00
Mary Kate Fain 1a58875418 Make widget header labels clickable links to their full pages 2026-04-12 15:09:40 -05:00
Mary Kate Fain 8ee6388ab8 Fix extra empty space in widgets by using max-height instead of fixed height 2026-04-12 15:02:43 -05:00
Mary Kate Fain 5878b8ad5f Set default sidebar widgets to Trending, Hot Posts, and Wikipedia 2026-04-12 14:59:43 -05:00
Mary Kate Fain ec4359f1aa Add Hot Posts sidebar widget showing top posts from the hot feed 2026-04-12 14:57:19 -05:00
Mary Kate Fain f217394012 Merge remote-tracking branch 'origin/main' into new-sidebar 2026-04-12 14:47:09 -05:00
Alex Gleason 32908f7b4f release: v2.6.6 2026-04-12 14:32:14 -05:00
Alex Gleason bd333b9584 Fix Android WebView resize bugs caused by @capacitor/keyboard
Remove resizeOnFullScreen config which caused possiblyResizeChildOfContent()
to corrupt CoordinatorLayout height on Android 16 (API 36). Upgrade plugin
from 8.0.2 to 8.0.3 which adds a SystemBars guard as additional safety.
Platform-gate setAccessoryBarVisible to iOS only (unimplemented on Android).
2026-04-12 14:07:52 -05:00
Alex Gleason 3ac1dc6b0a Fix dialog obscured by virtual keyboard on Android Chrome
Add interactive-widget=resizes-content to the viewport meta tag so
Chrome on Android resizes the layout viewport when the on-screen
keyboard opens. This keeps fixed-position dialogs (compose, reply,
login, etc.) centered in the visible area above the keyboard.
2026-04-12 13:21:21 -05:00
Alex Gleason 025ecd8645 Upgrade nostrify: improve NIP-46 signing reliability 2026-04-12 12:02:45 -05:00
Alex Gleason 0fca39a1bd Remove androidResume utility and its foreground-resume retry logic
The visibility-change-based Android resume detection was causing more
problems than it solved. Remove the module and simplify LoginDialog and
signerWithNudge to operate without retry-on-resume behavior.
2026-04-12 11:37:10 -05:00
Chad Curtis 3152f7f0ec Merge branch 'fix/emoji-shortcode-autocomplete' into 'main'
Fix emoji shortcode autocomplete clipped by compose box and emojis rendering as text

Closes #216

See merge request soapbox-pub/ditto!160
2026-04-12 14:13:32 +00:00
Alex Gleason 7cba044b9d release: v2.6.5 2026-04-11 18:15:04 -05:00
Alex Gleason 4245b2aede Add Google Play publishing to CI release pipeline 2026-04-11 18:10:29 -05:00
Alex Gleason 3cdec3ceb6 Add more Zapstore publish relays to CI 2026-04-11 17:57:13 -05:00
Alex Gleason aa8f7539ae Fix iOS App Store blockers: bundle PrivacyInfo.xcprivacy and declare export compliance 2026-04-11 17:55:26 -05:00
Alex Gleason c6b3cb8758 Remove server.hostname to fix external API requests on Android
The WebView was intercepting all https://ditto.pub/* requests as local
assets, causing favicon and link-preview API calls to fail. Deep links
are unaffected as they use AndroidManifest intent-filters.
2026-04-11 17:37:57 -05:00
Alex Gleason 59f68efdc7 iOS: replace HTML spinner with native UIActivityIndicatorView overlay
The HTML spinner loaded via loadHTMLString was immediately replaced by
the real navigation and never had a chance to render. This is the same
problem Android had with its HTML spinner (though for a different
reason — Android's froze due to main thread saturation).

Use a native UIActivityIndicatorView on a dark overlay, matching the
Android approach with ProgressBar. The spinner is added as a subview
on top of the WKWebView inside a container UIView, and removed in
webView(_:didFinish:) via WKNavigationDelegate.

Also wraps the WKWebView in a container UIView (like Android's
FrameLayout) so the spinner overlay can sit on top independently.
2026-04-11 17:25:06 -05:00
Alex Gleason dc81585f9a Pre-fetch all nsite blobs on Android before WebView navigates
Android's shouldInterceptRequest blocks a pool of ~6 IO threads, each
waiting for JS to respond via the Capacitor bridge. With 200+ files
each requiring a network round-trip to Blossom, loading is painfully
slow. iOS doesn't have this problem — WKURLSchemeHandler is async.

Split the native plugin lifecycle into create() and navigate():
- create() adds the WebView container with spinner overlay (visible)
- navigate() loads the entry URL (triggers fetch interception)

On Android, onReady downloads all manifest blobs in parallel (12
concurrent fetches) into an in-memory cache while the native
ProgressBar spinner animates. Once navigate() fires, every resolveFile
call is an instant cache hit.

On iOS/web, onReady is a no-op and navigate() fires immediately.
2026-04-11 17:20:21 -05:00
Alex Gleason 54e6c964db Add Blossom server affinity to speed up nsite loading
The fetchFromBlossom function previously tried servers sequentially for
every file request. For nsites without server tags (falling back to 3
app default servers), each of the 200+ files paid a full round-trip
penalty when the first server returned 404 before falling through.

Now tracks a module-level preferred server. Once any server successfully
serves a blob it becomes preferred and is tried first for all subsequent
requests. This means only the first file pays the discovery cost; the
rest go directly to the server that has the content.
2026-04-11 17:20:06 -05:00
Alex Gleason dceda199c3 Add loading spinners to native sandbox WebViews
iOS: load inline spinner HTML (centered spinning ring on dark background)
before navigating to the real content URL. Supports light/dark mode via
prefers-color-scheme. The spinner is replaced when the real page loads.

Android: use a native ProgressBar overlay instead of HTML — the HTML
spinner froze because constant Capacitor bridge calls saturated the
main thread, starving the WebView compositor. The native ProgressBar
animates on the render thread independently. Wrapped in a FrameLayout
with a dark overlay behind the spinner.

Both platforms: set WebView background to #14161f (app dark theme)
instead of white. Increased Android shouldInterceptRequest timeout
from 10s to 60s to prevent premature timeouts on large nsites.
2026-04-11 17:20:01 -05:00
Alex Gleason 8967012035 release: v2.6.4 2026-04-11 15:43:47 -05:00
Alex Gleason 0b73d4aac5 Remove dedicated Share button from profile pages
The 'Copy profile link' option is already available in the more menu,
making the standalone Share button redundant.
2026-04-11 15:40:08 -05:00
Alex Gleason 6f53f7ad99 Fix avatar fallback showing '?' instead of name initial
ComposeBox and LeftSidebar avatar fallbacks only checked metadata.name,
ignoring display_name and genUserName. Now uses the same fallback chain
as ProfileCard: display_name -> name -> genUserName(pubkey). Also fixed
the getDisplayName helper in LeftSidebar to check display_name.
2026-04-11 15:36:47 -05:00
Alex Gleason 399df4da4d Improve empty feed state with icon and discover CTA
Redesign FeedEmptyState with a centered icon, cleaner layout, and
two actionable buttons for the follows tab: 'Discover people to
follow' linking to /packs, and 'Browse the Global feed' to switch
tabs. Other call sites are unaffected (new props are optional).
2026-04-11 15:29:10 -05:00
Alex Gleason c06a66ade4 Ensure sticky desktop FAB anchors to bottom on empty feeds
Add min-h-dvh to the Feed <main> element so it always fills at least
the viewport height. Without this, the sticky FAB (a sibling after
<main>) sits in normal flow right after the short content instead of
at the bottom of the center column.
2026-04-11 15:25:37 -05:00
Alex Gleason 1fca26ae2e Clean up signup profile step: hide pencil badges, remove extra fields
- Hide the small pencil icon on avatar and banner until an image is
  actually set (the hover overlay still shows so users can discover
  the action)
- Remove the Profile Fields collapsible from the signup flow to keep
  the onboarding lightweight
2026-04-11 15:12:28 -05:00
Alex Gleason ccd8f213f6 Replace Skip/Continue with single Continue button in profile step
handlePublishProfile already skips publishing when no data is entered,
so the Skip button was redundant. A single full-width Continue button
simplifies the UI.
2026-04-11 15:09:38 -05:00
Alex Gleason 1c25702453 Fix signup dialog not clearing background when switching to light/dark theme
ThemeStep was reading customTheme?.background?.url unconditionally,
so the background persisted even after selecting a built-in theme.
Now resolves the active theme config the same way AppProvider does,
only showing the background when the active theme actually has one.
2026-04-11 14:58:52 -05:00
Alex Gleason 357ba7d8c8 fix: migrate to SystemBars API for Android 16+ safe area inset support
Android 16 (API 36) enforces edge-to-edge rendering unconditionally,
breaking @capacitor/status-bar's setOverlaysWebView and setBackgroundColor.
Additionally, a Chromium bug (<140) causes env(safe-area-inset-*) to report
0 in some Android WebViews.

- Replace @capacitor/status-bar with SystemBars from @capacitor/core 8+
- Enable insetsHandling: 'css' in capacitor.config.ts so the SystemBars
  plugin injects --safe-area-inset-* CSS variables on Android
- Update all safe area CSS utilities and inline styles to use
  var(--safe-area-inset-*, env(safe-area-inset-*, 0px)) fallback pattern
- Remove @capacitor/status-bar dependency (no longer needed)
2026-04-11 14:47:15 -05:00
Alex Gleason 207ca6893a Add iCloud Keychain credential saving/restoring on iOS via @capgo/capacitor-autofill-save-password
- Use SecAddSharedWebCredential to prompt 'Save Password?' on signup
- Use ASAuthorizationPasswordProvider to restore credentials on login
- Add webcredentials:ditto.pub Associated Domains entitlement
- Deploy apple-app-site-association for domain validation
- Keep existing Chromium PasswordCredential flow as web fallback
- Add saveNsec() helper: native credential manager on iOS/Android,
  file download + bonus PasswordCredential on web
- Single 'Continue' button triggers the appropriate save method per platform
2026-04-11 14:01:34 -05:00
Chad Curtis 6dc7fb7ade Replace localStorage with in-memory Map for daily missions
Daily mission session state is now a pubkey-scoped Map instead of
localStorage. Hydrates from kind 11125 content on mount/account switch.
Completed missions are already persisted by useAwardDailyXp; intermediate
progress resets on refresh (low-impact).
2026-04-11 10:28:33 -05:00
Alex Gleason 37df5d0bd1 release: v2.6.3 2026-04-10 23:27:38 -05:00
Alex Gleason 19906cf918 Merge branch 'fix/badge-image-aspect-ratio-hint' into 'main'
Show recommended 1:1 aspect ratio hint on badge image upload

Closes #212

See merge request soapbox-pub/ditto!178
2026-04-11 03:49:14 +00:00
Alex Gleason 874010c4fe Store nsec in browser password manager via Credential Management API
Progressive enhancement using PasswordCredential (Chromium-only).
On sign-up, the nsec is offered to the browser's password manager
alongside the existing file download. The prompt appears while the
user is looking at their key on the download step. On login, stored
credentials are retrieved for one-tap login on supported browsers.

Safari/Firefox/iOS silently skip — existing flows are unchanged.
2026-04-10 21:49:14 -05:00
Chad Curtis d256acdef3 package-lock.json to main 2026-04-10 17:23:07 -05:00
Chad Curtis 98e0273bdb Fix sleeping blobbi not showing bedroom room 2026-04-10 17:21:51 -05:00
Mary Kate Fain e26407d740 Change default sidebar widgets to Trends, Bluesky, and Wikipedia 2026-04-10 17:18:08 -05:00
Chad Curtis b42f12ce77 Fridge as blur overlay, room UX refinements
- Fridge opens as full-page blur overlay with flex-wrap food grid
  and 2x2 stat icons per item (lucide icons, no boxes/borders)
- X dismiss button with strokeWidth 4, click negative space to close
- Overlay renders above navigation arrows (z-50)
- Sleeping Blobbi cannot leave bedroom (toast + gate on room change)
- Upgrade lucide-react, add arrow nudge keyframe animations
- Replace all emoji button/room icons with lucide equivalents
- Room indicator moved below Blobbi name in hero
- Touch swipe support on room shell
- Larger nav arrows (size-7/8, strokeWidth 4)
2026-04-10 17:16:17 -05:00
Chad Curtis 7a10e4a406 Room UX polish: swipe navigation, lucide icons, indicator below name
- Add touch swipe support to BlobbiRoomShell (50px threshold)
- Larger navigation arrows (size-7/8) with strokeWidth 4
- Move room indicator (icon + label + dots) below Blobbi name in hero
- Remove room header overlay from shell top
- Replace all emoji button icons with lucide: Refrigerator, ShowerHead,
  TowelRack, Candy, Shovel
- Replace room config emoji icons with lucide: Home, Refrigerator, Cross,
  Moon, Shirt
- Upgrade lucide-react 0.462.0 -> 1.8.0
- Add room-arrow-nudge keyframe animations to index.css
2026-04-10 17:03:52 -05:00
Chad Curtis eda18d8b93 Integrate room system into BlobbiPage
- Replace Care + Items tabs with room-based navigation
- Drawer shrinks to Quests + Blobbis only
- Room shell renders hero + nav arrows + dots + sleep overlay + poop
- Per-room bottom bars: HomeBar (toys/music/sing + photo + companion),
  KitchenBar (food carousel + shovel + fridge), CareBar (hygiene/medicine
  with context-sensitive side buttons), RestBar (sleep/wake), ClosetBar
- Remove CareTabContent, CareActionButton, ItemsTabContent, ItemTypeIndicator,
  StatIndicator and related constants (now in BlobbiRoomHero)
- Net reduction: -72 lines from BlobbiPage
2026-04-10 16:49:29 -05:00
Alex Gleason 126dce1dfc Surface account deletion as 'Delete Account' for App Store compliance
Add a 'Delete Account' pill button to the bottom of the Settings
page (Guideline 5.1.1v). Rename the Danger Zone heading in Advanced
Settings to match. Simplify the deletion dialog to a single screen:
plain-language warning, list of what gets deleted, type DELETE to
confirm, and Cancel/Delete buttons. Always broadcasts to all relays.

The underlying NIP-62 mechanism and components that render vanish
events to other users are unchanged.
2026-04-10 16:44:35 -05:00
Chad Curtis 70809a8c7c Add BlobbiRoomHero and BlobbiRoomShell components
- BlobbiRoomHero: focused props interface (15 props, not 80+), stats crown
  with arc layout, responsive sizing, sleep animation suppression,
  floating companion placeholder
- BlobbiRoomShell: children-based layout, room navigation arrows with
  destination labels, pagination dots, sleep overlay, ephemeral poop
  state management, hero/middle/children slot architecture
2026-04-10 16:24:17 -05:00
Chad Curtis 5b15300f23 Add room system foundation: config, layout, poop system, carousel, action button
- room-config.ts: room IDs, metadata, navigation helpers, default order (no hatchery)
- room-layout.ts: shared bottom bar class constant
- poop-system.ts: ephemeral poop generation/cleanup with XP reward
- ItemCarousel.tsx: single-focus item carousel with prev/next previews
- RoomActionButton.tsx: unified circular action button for room bottom bars
- Add 'room' tag to kind 11125 schema for cross-session persistence
- Barrel exports from rooms/index.ts
2026-04-10 16:17:04 -05:00
Alex Gleason 105da53e2e Add NSCameraUsageDescription to Info.plist
File inputs with accept="image/*" present a camera option on iOS.
Without this usage description, WKWebView crashes or fails to show
the permission dialog when the user selects 'Take Photo'.
2026-04-10 16:10:35 -05:00
Chad Curtis 8585dd4833 Add item cooldown module and poop cleanup XP constant
- item-cooldown.ts: shared singleton with per-item cooldown tracking,
  subscriber system for React integration
- useItemCooldown.ts: useSyncExternalStore hook for reactive cooldown state
- Add POOP_CLEANUP_XP (5) to blobbi-xp.ts
- Export all new APIs from actions barrel
2026-04-10 16:05:02 -05:00
Alex Gleason 7bc4a632b0 Add XCode DEVELOPMENT_TEAM to project.pbxproj 2026-04-10 16:03:38 -05:00
Chad Curtis 12bda76526 Rewrite progression and missions system: tags-first, minimal, DRY
- Add progression.ts: xpToLevel, levelToXp, xpProgress, getUnlocks (pure functions, ~110 lines)
- Add missions.ts: tally/event mission types, ProfileContent parse/serialize for kind 11125 content
- Add xp/level tags to kind 11125 BlobbonautProfile schema
- Rewrite daily-missions.ts: drop completed/claimed/currentCount, use target+count/events model
- Unify hatch/evolve into single 'evolution' key in missions content
- Replace coin rewards with XP rewards throughout
- Remove explicit claim flow (completion is implicit from progress >= target)
- Rewrite tracker, hooks, and UI consumers to new data shape
- Guard against old localStorage format during migration
2026-04-10 15:53:36 -05:00
Alex Gleason 0222248d76 Merge branch 'main' of gitlab.com:soapbox-pub/ditto 2026-04-10 15:49:15 -05:00
Alex Gleason a542dd3b36 Sanitize all event-sourced URLs and prevent CSS injection
Nostr events are untrusted user input. Any URL extracted from event tags
or metadata must be validated before use in any context — not just
navigable hrefs, but also img src, CSS url(), and style attributes.

Changes:
- Theme events (kind 16767/36767): validate background and font URLs
  through sanitizeUrl() at parse time in themeEvent.ts
- Badge definitions (kind 30009): validate image and thumb URLs through
  sanitizeUrl() at parse time in parseBadgeDefinition.ts
- Font family names: sanitize with an allowlist regex before
  interpolation into CSS declarations in fontLoader.ts
- Profile fields: replace weak startsWith('http://') checks with
  sanitizeUrl() in ProfileRightSidebar and ProfilePage
- Community descriptions: validate extracted URLs through sanitizeUrl()
  in CommunityContent.tsx
- AGENTS.md: mandate unconditional URL sanitization for all
  event-sourced URLs regardless of rendering context, document CSS
  injection prevention guidelines
2026-04-10 15:48:38 -05:00
Mary Kate Fain fc292a8654 Replace screenshots table with simpler Before/After format in MR template 2026-04-10 15:15:54 -05:00
Mary Kate Fain 9214bd823b Remove redundant Submission checklist from MR template 2026-04-10 15:14:54 -05:00
Mary Kate Fain 8f5b8264c9 Show recommended 1:1 aspect ratio hint on badge image upload 2026-04-10 15:13:18 -05:00
Alex Gleason 94f821d064 Merge branch 'contributor-quality-gates' into 'main'
Add contributor quality gates: CONTRIBUTING.md, MR template, and CI validation

See merge request soapbox-pub/ditto!177
2026-04-10 19:50:29 +00:00
Mary Kate 6d73e6d06b Add contributor quality gates: CONTRIBUTING.md, MR template, and CI validation 2026-04-10 19:50:28 +00:00
Alex Gleason bd724de1e8 Bump @unhead/addons and @unhead/react to ^2.1.13 to fix CVE-2026-39315
The vulnerability (GHSA-95h2-gj7x-gx9w) allows bypassing hasDangerousProtocol()
in useHeadSafe() via leading-zero padded HTML entities. Not currently reachable
in this codebase (we only use useSeoMeta), but closes the CVE in the dependency
tree.
2026-04-10 14:29:49 -05:00
Alex Gleason 9d899cfe87 Sanitize all user-supplied URLs from Nostr events to prevent javascript: XSS
Add a shared sanitizeUrl() utility that validates URLs are well-formed
https: before they reach href attributes, window.open(), or openUrl().

Apply sanitization across all components that render untrusted URLs:
- CalendarEventDetailPage: r-tag links
- ZapstoreAppContent: url and repository tags
- ZapstoreReleaseContent: asset url tags passed to openUrl()
- AppHandlerContent: web handler tags and metadata.website
- NsiteCard: source tag
- GitRepoCard: web tag URLs passed to openUrl()
- FileMetadataContent: url tag used in download href
- ProfilePage: metadata.website (tighten weak startsWith check)
- useUserStatus: r-tag URL

Document sanitizeUrl usage in AGENTS.md for future agent use.
2026-04-10 14:22:42 -05:00
Mary Kate Fain 173f789242 Extract shared portal dropdown logic into usePortalDropdown hook
Both EmojiShortcodeAutocomplete and MentionAutocomplete had identical
logic for fixed viewport positioning with viewport-flip, scroll/resize
dismissal, and portal rendering. Extract into a shared hook to reduce
duplication and centralize the positioning behavior.
2026-04-10 12:50:05 -05:00
Mary Kate Fain 5c8c33747e Guard against redundant protocol:nostr and document prefix queryKey
- Skip appending protocol:nostr if the resolved filter already contains it
- Add comment explaining why the 2-element prefix key correctly invalidates
  the full 5-element useTabFeed query key via TanStack prefix matching
2026-04-10 12:36:27 -05:00
Mary Kate Fain 07a9b956cb Remove dead WidgetContext: hook was never consumed by any component 2026-04-10 11:18:13 -05:00
Mary Kate Fain 0e7f847de0 Medium-priority fixes: decouple sparkline, Map lookup, memo widgets, fix drag closure
8. Extract TrendSparkline to its own file so TrendingWidget doesn't
   depend on the old RightSidebar (re-export kept for compat)
9. Widget definition lookup uses a pre-built Map instead of linear scan
10. SortableWidget wrapped in React.memo to skip re-renders when only
    sibling state changes (picker open, other widget collapse)
11. handleDragEnd computes indices from the updater's current array
    instead of closing over sortableIds (eliminates stale closure risk
    if a query refetch re-renders mid-drag)
2026-04-10 10:33:27 -05:00
Mary Kate Fain 4998ea8f5d Fix high-priority widget issues: scoped queries, Bluesky isolation, Capacitor compat
5. FeedWidget now scopes queries to followed authors when logged in,
   falls back to global when logged out, and requests exact limit
6. BlueskyWidget uses its own useQuery instead of sharing the infinite
   query with BlueskyPage (separate query key, single page, no memory leak)
7. WikipediaWidget uses openUrl() instead of <a target=_blank> which
   silently fails inside Capacitor WKWebView on iOS
2026-04-10 10:27:31 -05:00
Mary Kate Fain 0cc81cd35f Fix 4 blocker issues: error boundaries, resize perf, chat persistence, error handling
1. Wrap each widget in ErrorBoundary so one crash doesn't kill the sidebar
2. Resize uses local state during drag, commits to config only on pointerup
   (was hammering localStorage at 60fps)
3. AI chat messages persist in module-level cache across collapse/expand
   (collapsing previously destroyed the conversation)
4. StatusWidget catches rejected promises from mutateAsync and shows
   destructive toast instead of silently failing
2026-04-10 10:21:50 -05:00
Mary Kate Fain ed09c8947d Fix preset widgets disappearing on first add by falling back to merged config 2026-04-10 09:34:00 -05:00
Mary Kate Fain 2e79d93806 Match 'Add widget' button background to widget card style for visibility 2026-04-10 09:32:05 -05:00
Mary Kate Fain f05097087b Add customizable widget sidebar for the main feed right column
Replace the empty right sidebar placeholder with a user-configurable widget
system. Users can add, remove, reorder, collapse, and resize widgets via
drag-and-drop and a picker dialog. Config persists in localStorage (same
pattern as sidebarOrder) and syncs via encrypted settings.

v1 widgets: Trending Tags, Blobbi (mini pet), Status (NIP-38), AI Chat,
Wikipedia (featured article), Bluesky (trending posts), and feed widgets
for Photos, Music, Articles, Events, and Books.

Defaults: Trending + Blobbi for fresh installs. Desktop-only (hidden below
xl breakpoint). Profile pages retain their dedicated ProfileRightSidebar.
2026-04-10 09:28:04 -05:00
Chad Curtis 72268dfde6 Merge branch 'feat/feed-blobbi-status-visuals' into 'main'
Reflect companion condition in feed Blobbi cards

See merge request soapbox-pub/ditto!169
2026-04-10 13:05:44 +00:00
Alex Gleason 7b63f6112c Clean up profile header: remove lightning address, NIP-05 check icon, and trailing slash from website URLs 2026-04-09 22:30:38 -05:00
Alex Gleason ce61d8d1a6 Restore right sidebar for profile pages, keep fields mobile-only 2026-04-09 22:00:50 -05:00
filemon c4a10b1303 Merge branch 'main' into feat/feed-blobbi-status-visuals 2026-04-09 15:27:28 -03:00
Chad Curtis 76c6846e91 Render BOLT11 lightning invoices in note content
Detect lnbc/lntb/lnbcrt/lntbs invoices (with optional lightning: prefix)
in note text and render them as interactive cards with a theme-aware QR
code, decoded amount, copy button, and Open in Wallet action.

- Add lightning-invoice token type to NoteContent tokenizer
- Create LightningInvoiceCard with tap-to-expand square QR, cqw-scaled
  amount text, and responsive layout
- Extract shared theme-aware QR color logic into src/lib/qrColors.ts
  (deduplicate from FollowQRDialog)
2026-04-09 08:02:26 -05:00
Alex Gleason ac1e82b52d release: v2.6.2 2026-04-08 23:38:31 -05:00
Alex Gleason 437b8de652 Remove right sidebar content and show profile fields inline 2026-04-08 23:34:03 -05:00
Alex Gleason adadb6ed53 Fix native file downloads: save directly to Documents on iOS/Android 2026-04-08 22:54:46 -05:00
Alex Gleason f7c90a4a23 Remove trending hashtags section from logged-out homepage 2026-04-08 22:28:22 -05:00
Alex Gleason 82632bb76c Store nostr:login in secure storage on native platforms
Use capacitor-secure-storage-plugin to persist login credentials
(nsec keys) in iOS Keychain / Android KeyStore instead of plaintext
localStorage. Web behavior is unchanged. Existing native users are
auto-migrated on first launch: if secure storage is empty but
localStorage has data, it is moved over and the plaintext copy is
removed.

Also ignore ios/ directory in ESLint (Capacitor-generated files).
2026-04-08 22:20:48 -05:00
Alex Gleason 3a70d34e6d npm audit fix 2026-04-08 22:12:03 -05:00
Alex Gleason 221d3f4aff Merge branch 'mobile-search' 2026-04-08 22:11:38 -05:00
Alex Gleason 6a1a462ab0 Upgrade @nostrify/react to ^0.5.0 (async storage support)
Upgrade to the new version that includes the NLoginStorage interface
and storage/fallback props on NostrLoginProvider for pluggable async
storage backends (e.g. Capacitor Secure Storage).

- Add resolve.dedupe for react/react-dom to prevent dual-React issues
- Update NoteContent tests to use async findBy* queries since the
  provider now always awaits storage initialization
2026-04-08 22:08:56 -05:00
Alex Gleason 5ee8bc1cc0 Improve mobile search UX: lock scroll, hide bottom nav, dismiss accessory bar, and fix close behavior 2026-04-08 22:04:26 -05:00
Alex Gleason 76d53859cf Simplify webxdc to always open in fullscreen panel 2026-04-08 20:47:46 -05:00
Alex Gleason e482afbd3f Fix sandbox origin isolation and Android build issues 2026-04-08 20:47:42 -05:00
Alex Gleason 11ff27efe2 Enable iOS swipe-back navigation and fix bottom nav layout 2026-04-08 20:47:37 -05:00
Alex Gleason 8f6f678132 Add safe area padding and fix fullscreen sandbox on iOS 2026-04-08 20:47:32 -05:00
Alex Gleason f25139103c Add native SandboxPlugin for iOS and Android 2026-04-08 20:47:28 -05:00
Alex Gleason 0028b506e7 Fix webxdc bridge: serve script via resolveFile instead of injectedScripts
SandboxFrame's virtual script serving intercepted /webxdc.js and served
the empty placeholder content before resolveFile was ever called. The
dynamically generated bridge script (which embeds selfAddr etc.) was
never reaching the iframe.

Move bridge serving and HTML injection into resolveFileWithBridge so
the content is served from bridgeScriptRef after onReady populates it.
2026-04-08 16:55:01 -05:00
Alex Gleason 926c27d51c Fix webxdc race condition: await onReady before sending init
The sandbox frame was sending init immediately and calling onReady
concurrently, so fetch requests arrived before the archive was
downloaded and unzipped. Now onReady is awaited before init is sent,
matching the original Webxdc behavior.
2026-04-08 16:50:44 -05:00
Alex Gleason c4454ee2a1 Refactor iframe.diy usage into unified SandboxFrame component
Extract duplicated sandbox protocol logic from NsitePreviewDialog and
Webxdc into a single SandboxFrame component. Shared utilities (MIME
types, base64, HTML injection, JSON-RPC types) move to src/lib/sandbox/.

Add configurable sandboxDomain to AppConfig so the iframe.diy domain
can be overridden via ditto.json, preparing for native Capacitor
implementations.

Strip unused console/navigation/error RPC from previewInjectedScript,
leaving only the /index.html path normalization.
2026-04-08 16:41:23 -05:00
Chad Curtis e56737f776 Fix blobbi discovery: query by author instead of relying on profile.has[]
The Blobbi collection was previously discovered via the profile's has[] tag
list, meaning any blobbi whose d-tag was missing from that secondary index
would be invisible to the user despite existing on the relay.

Now useBlobbisCollection() without args queries all kind 31124 events by
author + ecosystem namespace tag — the user authored these events, so that
is the source of truth. The profile.has[] list is still used for selection
ordering preference, but no longer gates discovery.

The dList parameter remains available for targeted fetches (e.g. the
companion layer only needs one specific blobbi).
2026-04-08 11:02:03 -05:00
Chad Curtis feb6c1a9f6 Add drop shadow and solid gradient to overflow tab arrows 2026-04-08 10:27:17 -05:00
Chad Curtis 6f8d225597 Increase overflow tab arrow stroke to 4 and boost contrast 2026-04-08 10:22:04 -05:00
Chad Curtis 9ecd99a6a1 Add 'Write a letter' option to profile more menu
Adds a Mail-icon menu item in the profile more menu for other users'
profiles. Navigates to /letters/compose?to={npub} so the recipient is
pre-filled, matching the same flow used by the notification reply button.
2026-04-08 04:01:11 -05:00
Chad Curtis 287097627d Hide delivery method when push disabled; fix persistent description
Only show the delivery method radio group when push notifications are
enabled. Update the persistent option description to explain it is for
devices that don't support push notifications (e.g. GrapheneOS).
2026-04-08 00:20:20 -05:00
Chad Curtis 3ee491a63b Add push vs persistent notification delivery option for Android
Default to push mode (no foreground service). Persistent mode with
the always-on background polling service is opt-in via the new
Delivery Method section in notification settings.

- Add notificationStyle ('push' | 'persistent') to EncryptedSettings
- Show radio group in NotificationSettings on native platforms
- Pass notificationStyle through Capacitor plugin to SharedPreferences
- DittoNotificationPlugin starts/stops foreground service on style change
- MainActivity only starts service on launch when style is persistent
- Re-enable unread polling on native when push mode is active
2026-04-07 10:54:30 -05:00
Chad Curtis 7944f73da3 fix: use fetchFreshEvent and preserve non-p-tags in Follow All handlers
FollowPackDetailContent, TeamSoapboxCard, and InitialSyncGate all had
handleFollowAll implementations that queried kind 3 directly (bypassing
fetchFreshEvent) and rebuilt the tag array with only p-tags, silently
dropping all non-p-tags (relay hints, petnames, etc.). They also did
not pass prev for published_at preservation.

Align all three with the safe pattern already used in FollowPage and
useFollowActions.
2026-04-07 09:03:07 -05:00
Chad Curtis 17c1936817 Support follow pack/set naddr identifiers on /follow URL
The /follow route now accepts naddr1 identifiers for follow packs
(kind 39089) and follow sets (kind 30000) in addition to npub/nprofile.

Renders an immersive fullscreen layout with pack info hero, avatar
stack, big Follow All CTA with status indicator, and Feed/Members
tabs using the standard SubHeaderBar arc.

Follow All uses the safe fetch-fresh -> modify -> publish pattern
with prev for published_at preservation.

Shared components (PackFeedTab, MemberCard, MemberCardSkeleton) and
parsePackEvent are reused from FollowPackDetailContent and packUtils.

Also fixes SubHeaderBar tab indicator positioning when innerClassName
centers the tab container (adds containerOffset + ResizeObserver for
layout-dependent recalculation).
2026-04-07 08:55:27 -05:00
Chad Curtis c570f4689d Merge branch 'curated-ditto-feed' into 'main'
Curate Ditto feed by curator follow list with photos, divines, videos, and music

See merge request soapbox-pub/ditto!164
2026-04-07 12:52:23 +00:00
Chad Curtis 064ab1e101 Address MR review: extract feed hook, fix cache key, add error handling, make curator configurable
- Remove unused 'authors' parameter from useInfiniteHotFeed
- Extract inline query from Feed.tsx into useCuratedDittoFeed hook
- Use content-based fingerprint for query key instead of list length
- Add error state handling so curator fetch failure shows empty state
  instead of infinite skeletons for first-time visitors
- Move hardcoded curator pubkey to AppConfig (curatorPubkey) so it
  can be overridden via ditto.json without a code change
- Remove LANDING_KINDS/LANDING_WEBXDC_FILTER from Feed.tsx (now in hook)
2026-04-07 07:48:23 -05:00
Alex Gleason 9c0d49b904 Add OPFS as blocked API in lockdown-mode skill 2026-04-06 18:42:45 -05:00
Alex Gleason 69634e7c05 Update lockdown-mode skill with cross-platform availability info
Lockdown Mode is not iOS-only — it's available on iOS 16+, iPadOS 16+,
watchOS 10+, and macOS Ventura+. Add platform availability section with
Apple Support reference link, rename report file to ios-report.txt to
clarify it's iOS-specific, and broaden the skill description.
2026-04-06 16:13:57 -05:00
Alex Gleason db48ce7c40 Add raw diagnostic report as skill reference file 2026-04-06 16:05:52 -05:00
Alex Gleason 36c6e537a7 Add lockdown-mode agent skill with iOS Lockdown Mode API reference 2026-04-06 15:59:29 -05:00
Alex Gleason cbc3df0bef Allow any dev server host via ALLOWED_HOSTS env var 2026-04-06 14:40:31 -05:00
Alex Gleason 2ecd557430 Fix IndexedDB crash on iOS Lockdown Mode
openDatabase() now catches errors from idb's openDB() (which throws
synchronously when indexedDB is undefined) and returns null. All
consumers — profileCache, nip05Cache, dmMessageStore — check for null
and silently degrade to in-memory only.

The DM message store also stops re-throwing errors, which previously
could produce unhandled rejections in DMProvider.
2026-04-06 13:41:32 -05:00
filemon 61c84ed137 Fix conditional hook call in BlobbiStateCard
Move the early return for null companion below all hooks so useMemo
calls are unconditional. The null/egg guard is now inside the recipe
useMemo, and isSleeping/isEgg use optional chaining.
2026-04-06 13:46:50 -03:00
filemon a24b755e08 Use projected decay stats for feed Blobbi visuals
Replace raw companion.stats with calculateProjectedDecay() output so
feed cards reflect the Blobbi's real current condition after time-based
stat decay, matching what the room view shows via useProjectedBlobbiState.

The pure calculateProjectedDecay() function is called once per render
inside useMemo (no setInterval per card), keeping feed rendering
lightweight while staying consistent with the room's decay math.
2026-04-06 13:28:42 -03:00
filemon 46a970b900 Reflect companion condition in feed Blobbi cards
BlobbiStateCard now resolves the same status recipe used by the room
view (resolveStatusRecipe) from the on-chain stats, so feed Blobbis
show hunger, dirt, sleepiness, sadness, and sickness visuals.

A new attenuateRecipeForFeed() helper scales down body-effect particle
counts and removes flies to keep the smaller feed-card size readable.
Sleeping Blobbis get the buildSleepingRecipe() overlay, matching the
room behaviour.
2026-04-06 12:42:57 -03:00
Alex Gleason 594e7ea8fa ci: add build-web job to produce downloadable artifact
The old 'pages' job was removed when deploying switched to nsite,
which broke the artifact download URL on the docs site. This adds
a new build-web job that builds the web app on main and saves the
dist/ directory as a downloadable artifact.
2026-04-06 01:09:10 -05:00
Alex Gleason 0a5e72efd0 release: v2.6.1 2026-04-06 00:58:23 -05:00
Alex Gleason 0f1021e0d3 Switch nsite preview from local-shakespeare.dev to iframe.diy
Replace the local-shakespeare.dev preview domain with iframe.diy, which
provides a service-worker based sandbox. This brings the nsite preview
implementation in line with Shakespeare's approach.

Key changes:
- iframe.diy handshake: listen for 'ready', respond with 'init'
- Derive private HMAC-SHA256 subdomains via deriveIframeSubdomain('nsite', ...)
- Inject preview script into HTML responses for console forwarding,
  SPA navigation tracking, and /index.html path normalization
- Remove sandbox attribute (iframe.diy manages its own sandboxing)
- Serve injected script from virtual /__injected__/preview.js path
2026-04-06 00:51:39 -05:00
Alex Gleason be65c659b2 Derive private iframe.diy subdomains with HMAC-SHA256
The i-tag UUID used for webxdc coordination is attacker-controlled.
Using it directly as the iframe.diy subdomain lets a malicious event
author pick a subdomain that collides with another app's origin,
gaining access to its localStorage/IndexedDB.

Introduce a persistent random seed in localStorage (ditto:seed) and
derive the subdomain as base36(HMAC-SHA256(seed, prefix|identifier)).
The prefix (e.g. "webxdc") domain-separates different use-cases.
The subdomain is stable per device+app but unpredictable to event
authors.
2026-04-06 00:33:55 -05:00
Alex Gleason b64aa4b24a Add Content-Security-Policy to webxdc fetch responses
Apply a strict CSP header to every response served from the .xdc archive
to enforce the webxdc offline sandbox. Permits same-origin, inline, eval,
wasm, data: and blob: but blocks all external network access.
2026-04-05 23:20:21 -05:00
Alex Gleason f63d8943d8 Replace webxdc.app with iframe.diy for webxdc sandboxing
Migrate the webxdc iframe runtime from webxdc.app to iframe.diy. Instead of
sending ZIP bytes to the iframe and having the SW unzip them, the parent now
unzips the .xdc archive and serves files via iframe.diy's fetch-proxy RPC.
A webxdc bridge script is served as a virtual /webxdc.js file, and a
<script> tag is injected into HTML responses via DOMParser to load it.

- Rewrite Webxdc.tsx to use iframe.diy's ready/init/fetch protocol
- Unzip .xdc archives on the parent side and serve via fetch RPC responses
- Serve webxdc bridge as virtual /webxdc.js via the fetch handler
- Inject <script src="/webxdc.js"> into HTML using DOMParser
2026-04-05 22:15:16 -05:00
Mary Kate Fain e6efdc3539 Switch Ditto feed from sort:hot to latest-first chronological ordering
Replace useInfiniteHotFeed (sort:hot via NIP-50 search) with standard
NIP-01 reverse-chronological pagination for the curated Ditto feed.
Latest ordering provides a natural time-based spread of content types,
working better with the diversity algorithm and giving a fresher feel.
2026-04-05 20:39:13 -05:00
Alex Gleason 517a72cce7 Fix profile avatar/banner lightbox appearing behind right sidebar
Portal ProfileImageLightbox to document.body, matching the fix
already applied to the shared Lightbox component. Without the
portal, the lightbox was trapped inside the center column's z-0
stacking context from MainLayout, causing the right sidebar
(a sibling outside that context) to paint on top.
2026-04-05 20:37:09 -05:00
Mary Kate Fain ebe0cfdf03 Cap Blobbi at 10% of feed, add per-type cap overrides 2026-04-05 20:30:54 -05:00
Mary Kate Fain a501337fd3 Increase content-type gap from 3 to 4 for better diversity spacing 2026-04-05 20:11:37 -05:00
Mary Kate Fain e3916b3bc1 Fix same-type clustering: drop excess deferred items instead of dumping them
The final drain loop now tries all deferred items (not just the front),
and any items that still can't satisfy the gap constraint are dropped
rather than appended back-to-back. This prevents runs like 3 Blobbis
in a row that occurred when the graceful degradation path blindly
appended all leftover deferred items.
2026-04-05 19:59:32 -05:00
Mary Kate Fain de22e921d4 Fix diversity reordering causing feed jumps on new page load
Process each page independently with gap state carrying forward from
the previous page's tail. Earlier pages never change when new pages
arrive, eliminating the visible re-render/jump. The proportional cap
now applies per-page instead of across the full flattened list.
2026-04-05 19:55:19 -05:00
Mary Kate Fain 3a512f04e2 Add content-type diversity reordering to Ditto feed
Prevent the same content type from appearing within 3 positions of
itself and cap any single type at 20% of the feed. Uses a two-phase
algorithm: proportional cap first (trims excess from least-hot items),
then greedy gap-enforced interleave that keeps items as close to their
original hotness rank as possible. Only applies to the Ditto/landing
feed — follows, global, and other feeds are untouched.
2026-04-05 19:47:04 -05:00
Chad Curtis 6fc68766c9 Merge branch 'refactor/blobbi-remove-item-quantity-selection' into 'main'
Remove quantity selectors from Blobbi item usage flows

See merge request soapbox-pub/ditto!163
2026-04-06 00:38:27 +00:00
Mary Kate Fain bfd1daf7ba Curate Ditto feed by curator follow list with photos, divines, videos, and music
Filter the Ditto tab and logged-out landing feed to only show content
from people followed by the curator npub (npub1jvnpg4c6ljadf5t6ry0w9q0rnm4mksde87kglkrc993z46c39axsgq89sc),
inclusive of the curator. Add Kind 20 (photos), 21/22 (videos),
34236 (divines), and 36787/34139 (music) to the curated feed kinds.
2026-04-05 19:24:00 -05:00
filemon ae81c13cc1 Merge branch 'main' into fix-items-blobbi-companion 2026-04-05 21:21:31 -03:00
filemon 41358d27ce Update comments and docs to reflect item-as-ability model
Replace outdated references to 'inventory items', 'consume',
'quantity', and 'storage decrement' across 14 files. Comments
now consistently describe items as reusable abilities sourced
from the shop catalog, not consumable inventory.
2026-04-05 21:18:35 -03:00
Chad Curtis ac8bffba23 Merge branch 'fix-items-blobbi-companion' into 'main'
Remove inventory ownership requirement from Blobbi companion items

See merge request soapbox-pub/ditto!162
2026-04-05 23:59:18 +00:00
filemon 748365de40 Remove quantity selectors from Blobbi item usage flows
Items are now single-use abilities — tap item, press Use, effect
happens immediately. No confirmation dialogs or quantity selectors.

Changes:
- Remove BlobbiUseItemConfirmDialog and InventoryUseConfirmDialog
- Remove quantity state, selectors, and multi-use loops from modals
- Simplify mutation hooks to always apply item effects once
- Drop quantity parameter from UseItemFunction type signature
- Update all call sites through the full stack (BlobbiPage, context,
  companion layer, companion item use hook)
2026-04-05 20:58:31 -03:00
filemon 361f8b9506 Remove inventory ownership requirement from Blobbi companion items
Items are now treated as abilities/tools unlocked by stage, not
consumable inventory that must be purchased. All catalog items are
shown in the companion action menu regardless of inventory quantity.

Changes:
- Source items from shop catalog instead of user inventory storage
- Remove quantity validation and storage decrement on item use
- Remove quantity badges and 'in inventory' text from all modals
- Keep stage-based filtering (egg vs baby/adult restrictions)
- Cap quantity selector at 99 instead of inventory count
2026-04-05 19:59:30 -03:00
Mary Kate Fain 2fbc9e0409 Add protocol:nostr to saved feed queries for latest results
The previous useStreamPosts always injected 'protocol:nostr' into the
NIP-50 search string, which is a Ditto relay extension that filters for
native Nostr events. Without it, useTabFeed's queries return stale or
fewer results because the relay doesn't scope to the Nostr protocol.

Augment the resolved filter's search field with 'protocol:nostr' before
passing it to useTabFeed, matching the old behavior.
2026-04-05 17:59:13 -05:00
Mary Kate Fain 313222d12e Fix custom feed scroll position lost on back navigation
SavedFeedContent was using useStreamPosts which stores data in React
component state (useState). When navigating to a post detail page the
component unmounts and all state is destroyed, forcing a full re-fetch
on back navigation — losing the user's scroll position and content.

Replace useStreamPosts with useTabFeed (useInfiniteQuery) to match how
the Home, Ditto, and Global feeds work. TanStack Query caches all
fetched pages independently of component lifecycle (gcTime = 30 min),
so navigating back renders content instantly from cache, preserving
scroll position.

This also adds proper infinite scroll pagination and repost unwrapping
to custom saved feeds, which previously loaded a single batch.

Closes #217
2026-04-05 17:54:41 -05:00
Mary Kate Fain 46ba6978dd Fix scroll position lost when navigating back from post detail page
ScrollToTop was calling window.scrollTo(0, 0) on every pathname change,
including back/forward (POP) navigation. This destroyed the browser's
native scroll restoration, forcing users back to the top of the feed.

Use useNavigationType() to only scroll to top on PUSH navigation (user
clicked a link), preserving scroll position on POP (back/forward).

Closes #217
2026-04-05 17:44:13 -05:00
Mary Kate Fain f4363dcbff Fix emoji shortcode autocomplete clipped by compose box and emojis rendering as text
- Switch autocomplete dropdowns from absolute to fixed positioning so they
  aren't clipped by ancestor overflow containers (e.g. the compose modal's
  overflow-y-auto wrapper)
- Add viewport-relative coordinate calculation using getBoundingClientRect
- Add flip logic to show dropdown above cursor when near viewport bottom
- Dismiss dropdown on scroll/resize since fixed position doesn't track
- Add font-emoji utility class to force emoji presentation for native
  Unicode characters (star, fire, etc.) that may render as text glyphs
- Apply same fixes to MentionAutocomplete for consistency

Closes #216
2026-04-05 17:32:59 -05:00
Alex Gleason c1ec7a25ed Use published_at tag to show created/updated verbs in event action headers
Replaceable and addressable event headers now distinguish between
first publish and subsequent updates using the published_at tag:
- published_at == created_at → 'created' verb (e.g. 'created an emoji pack')
- published_at != created_at → 'updated' verb (e.g. 'updated an emoji pack')
- no published_at → 'shared' fallback for backward compatibility
2026-04-05 15:12:19 -05:00
Alex Gleason 272586d033 Merge branch 'main' of gitlab.com:soapbox-pub/ditto 2026-04-05 14:54:41 -05:00
Alex Gleason c77c098843 Add NIP-24 published_at to useNostrPublish for replaceable/addressable events
Extend useNostrPublish with an optional `prev` property on the event
template. For replaceable and addressable kinds, the hook automatically
manages published_at:

- First publish (no prev): set published_at equal to created_at
- Update (prev provided): preserve published_at from the old event
- Old event lacks published_at: don't fabricate one
- Caller already set published_at in tags: leave it alone

Callers pass `prev` when they have the old event from fetchFreshEvent,
giving the hook everything it needs without extra network requests.
Updated all 11 call sites that publish replaceable or addressable events.
Documents the prev convention in AGENTS.md.
2026-04-05 14:23:58 -05:00
Chad Curtis ea7afa94f7 Fix infinite scroll on custom profile tab feeds reloading same content
Two issues caused custom tab feeds (e.g. Magic Decks) to loop:

1. ProfileSavedFeedContent flattened pages without deduplication, so
   events returned by multiple pages rendered as visible duplicates.

2. useTabFeed only stopped paginating when rawCount === 0. For
   addressable events the relay keeps returning the same latest
   versions, so rawCount never hit zero. Changed to rawCount < limit
   (relay returned fewer than requested = exhausted).
2026-04-05 12:23:25 -05:00
Chad Curtis 0c29506402 Fix all 50 ESLint warnings by extracting non-component exports and adding missing deps
- Extract utility functions from component files into dedicated modules
  to fix react-refresh/only-export-components warnings:
  - parseBadgeDefinition -> src/lib/parseBadgeDefinition.ts
  - parseProfileBadges -> src/lib/parseProfileBadges.ts
  - getColors, paletteToTheme -> src/lib/colorMomentUtils.ts
  - parseDimToAspectRatio, eventToMediaItem -> src/lib/mediaUtils.ts
  - isAudioUrl, isImageUrl, isVideoUrl -> src/lib/mediaTypeDetection.ts
  - buildKindOptions, parseSelectedKinds -> src/lib/feedFilterUtils.ts
  - useVideoThumbnail -> src/hooks/useVideoThumbnail.ts
  - useEnvelopeDimensions -> src/hooks/useEnvelopeDimensions.ts
  - usePortalContainer -> src/hooks/usePortalContainer.ts
  - useAudioPlayer -> src/contexts/audioPlayerContextDef.ts
  - SubHeaderBar context/hooks -> src/components/SubHeaderBarContext.ts
  - EmotionDev hooks -> src/blobbi/dev/useEmotionDev.ts
  - BlobbiActions context def -> BlobbiActionsContextDef.ts

- Remove export from internal-only functions (useEventComments,
  parseEmojiPack, useScrollCarets, formatEffectSummary, getSortedEffectEntries)

- Fix react-hooks/exhaustive-deps warnings by adding missing dependencies
  to useEffect/useCallback/useMemo hooks across 14 files

- Fix logical expression dependency warnings by wrapping conditional
  values (tasks, pubkeys, authorPubkeys) in useMemo

- Move module-level constants (CORE_TAB_IDS, CORE_TAB_LABELS,
  DEFAULT_TAB_LABELS) out of ProfilePage component body

- Reorder usePushNotifications hooks so syncPreferences is defined
  before enable to fix block-scoped variable error
2026-04-05 11:57:31 -05:00
Chad Curtis b0609e7877 Make reaction emoji visible per-row in interactions modal
Replace grouped-by-emoji layout with a flat list where each reaction
row shows an inline emoji badge (similar to the zap amount badge).
Add an emoji summary bar at the top when multiple emoji types are
present. This makes it immediately obvious who reacted with what.
2026-04-05 11:07:55 -05:00
Chad Curtis 946be28b81 Use standard author layout for follow pack/set cards instead of content-first 2026-04-05 10:59:01 -05:00
Chad Curtis 89250c7472 Add kind action headers for follow packs and follow sets 2026-04-05 10:56:22 -05:00
Chad Curtis cfc7a0d31c Extract useActiveTabIndicator hook to deduplicate TabButton and SortableTabChip
The scroll-aware active indicator reporting and scroll listener logic was
duplicated between TabButton and SortableTabChip. Extract into a shared
useActiveTabIndicator hook in SubHeaderBar.
2026-04-05 10:47:34 -05:00
Chad Curtis 21003e3aed Add edit button for custom profile tabs and clear underline on tab removal
- Add pencil icon to SortableTabChip for editing existing custom tabs
- Wire onEdit to open ProfileTabEditModal with the existing tab data
- Clear the active arc underline when an active tab is removed (cleanup in useLayoutEffect)
- Round dnd-kit transform values to avoid sub-pixel rendering issues
2026-04-05 10:42:23 -05:00
Chad Curtis 93e8a6290f Merge branch 'fix/167-post-compose-box-renders-too-small-and-unclickable' into 'main'
fix: intermittent mobile compose modal collapse and unclickable input

Closes #167

See merge request soapbox-pub/ditto!146
2026-04-05 15:28:55 +00:00
Dmitriy E 47831ffa64 fix: intermittent mobile compose modal collapse and unclickable input 2026-04-05 10:27:43 -05:00
Chad Curtis 1533420320 Fix desktop tab overflow and add interest tab management in settings
SubHeaderBar: add left/right chevron scroll arrows on desktop when tabs
overflow, with gradient fade. Auto-scroll active tab into view and keep
arc hover/active indicators aligned during horizontal scroll.

ContentSettings: add Interest Tabs section with inline add/remove for
hashtags and geotags. Remove buttons always visible (mobile-friendly),
X icons use strokeWidth 4.
2026-04-05 10:20:58 -05:00
Chad Curtis e3ef542875 Fix desktop tab bar overflow: add scroll arrows and auto-scroll active tab into view
On desktop, overflowing feed tabs were completely inaccessible since the
scrollbar was hidden and there was no swipe gesture. Add left/right
chevron scroll buttons that appear only on desktop when tabs overflow,
with gradient fade indicators. Also auto-scrolls the active tab into
view when switching tabs, and keeps the arc hover/active indicators
aligned during horizontal scroll.
2026-04-05 10:15:01 -05:00
Chad Curtis 3bf55990c0 Fix missing bottom border on collapsed thread expand button
When a depth-collapsed 'Show X more replies' button was the last item
in a reply sequence, it lacked a bottom border separator. Added an
isLast prop to ExpandThreadButton that adds border-b when the button
terminates the visual sequence.
2026-04-05 09:50:52 -05:00
Chad Curtis 283b31813c release: v2.6.0 2026-04-05 08:31:35 -05:00
Chad Curtis 6e1197a067 Redesign LinkFooter as compact icon+label chips 2026-04-05 08:27:37 -05:00
Chad Curtis b7d1fbf860 Fix mobile sidebar bottom links clipping into safe area 2026-04-05 08:09:21 -05:00
Chad Curtis 8fde660075 Fix Blobbi page missing bg-background/85 overlay on custom themes
DashboardShell uses fixed positioning on mobile, placing it directly
over the body background image. Without the bg-background/85 class
that MainLayout's center column provides, the raw background image
showed through unthemed. Add the same 85% opacity background overlay
used consistently across the rest of the app.
2026-04-05 07:29:06 -05:00
Chad Curtis 50c7d67928 Fix blobbi state resets caused by stale cache reads and invalidation races
All blobbi mutations now follow the read-modify-write pattern: fetch fresh
state from relays before mutating, then optimistically update the cache.
This prevents two classes of bugs:

1. Stale cache reads: mutations were reading from TanStack Query cache
   (30s staleTime) instead of relays, causing newer events to be silently
   overwritten with old stats when actions happened within the cache window.

2. Invalidation races: every mutation called invalidateCompanion() after
   the optimistic update, which triggered a refetch from relays before the
   just-published event had propagated, overwriting the optimistic data
   with the pre-mutation state.

Changes:
- ensureCanonicalBlobbiBeforeAction now fetches fresh companion + profile
  from relays (the read step) instead of using cached closure values
- useBlobbiCareActivity fetches fresh companion before streak updates
- Removed all invalidateCompanion()/invalidateProfile() calls after
  optimistic updates across every action hook
- updateCompanionEvent now updates ALL blobbi-collection query caches
  for the user, not just the specific d-tag list it was instantiated with,
  keeping BlobbiPage and companion layer caches in sync
2026-04-05 07:20:26 -05:00
Chad Curtis e355c43925 Fix cross-device settings sync and smart sync gate
Settings (theme, sidebar, etc.) changed on one device were not applied
on other devices. Three root causes:

1. NostrSync seeded lastSyncedTimestamp to remoteSync on first load,
   then the guard (remoteSync <= lastSyncedTimestamp) blocked the same
   data from being applied. Settings were never applied on page reload.

2. The encrypted settings query had staleTime: Infinity and
   refetchOnWindowFocus: false, so remote changes were never fetched.

3. useInitialSync was missing customTheme, corsProxy, faviconUrl, and
   linkPreviewUrl fields.

To avoid gating every F5 behind a spinner, a lastSync timestamp is
now persisted to localStorage whenever settings are applied. On reload,
InitialSyncGate checks this: if present, render immediately from
localStorage and let NostrSync hot-swap remote changes in background.
If absent (new browser, cleared storage), show the spinner until
settings load.
2026-04-05 06:55:05 -05:00
Chad Curtis 696204870d Fix custom theme not applying on new device login
Initial sync applied the theme mode (e.g. 'custom') from encrypted
settings but not the customTheme config (colors, fonts, background),
so the theme appeared broken on first login requiring manual setup
which also triggered an unwanted kind 16767 publish.
2026-04-05 06:33:43 -05:00
Chad Curtis 0a7e01d17c Match own-profile follow link style to the following/already-following states
Use the same icon + primary semibold text + full-width button layout
instead of muted small text with an outline button.
2026-04-05 06:17:52 -05:00
Chad Curtis dd87bc96ec Fix top nav arc overlapping letter compose picker drawer
Set hasSubHeader on LetterComposePage so the MobileTopBar uses a flat
rect instead of the down-arc variant, preventing the 20px arc overhang
from painting over the LetterEditor picker panel.
2026-04-05 06:15:34 -05:00
Chad Curtis a12d5db560 Add follow URI system with QR sharing and immersive follow page
Introduce a /follow/:npub deep link that auto-follows a user when
visited by a logged-in user, or presents an immersive business card
with a 'Follow on Ditto' CTA for logged-out visitors. The page applies
the target user's profile theme, renders their feed with infinite
scroll, and uses the same banner/avatar/arc styling as the main profile.

Add a FollowQRDialog that generates a themed QR code for the follow
URL. The QR colors are derived from the active theme: primary color
for modules (with contrast-safe darkening/lightening), and background
color for the QR background. Foreground text color is used when it is
colorful and offers significantly better contrast.

Surface the QR dialog from: own profile page (top-level button),
profile more menu, desktop sidebar account popover, and mobile drawer.
2026-04-05 06:01:48 -05:00
Alex Gleason 614634789c Merge branch 'main' of nostr://npub10qdp2fc9ta6vraczxrcs8prqnv69fru2k6s2dj48gqjcylulmtjsg9arpj/relay.ngit.dev/ditto 2026-04-04 23:17:35 -05:00
Alex Gleason 29696fa3d3 Apply nearest-neighbor scaling to small custom emoji images
Custom emoji images with natural dimensions <= 16x16 now render with
image-rendering: pixelated to preserve crisp pixels instead of blurring.

Also consolidates 6 direct <img> sites to use the shared CustomEmojiImg
component so all custom emoji rendering benefits from this behavior.
2026-04-04 22:58:42 -05:00
Chad Curtis ffc31e8e8f Merge branch 'fix/blobbi-reuse-existing-eggs' into 'main'
Fix repeated egg creation and reuse existing eggs during ceremony

See merge request soapbox-pub/ditto!158
2026-04-05 02:09:45 +00:00
filemon 720a7e91fe Base ceremony decision on actual companion stages, not onboardingDone flag
The onboardingDone flag can be true on inconsistent accounts where the
user never actually hatched an egg. Now the ceremony check always waits
for companions to load and inspects their real stages:

- Any baby/adult exists: skip ceremony, auto-fix flag if needed
- Only eggs exist: ceremony with existing egg (regardless of flag)
- No companions resolved: ceremony creates a new egg

A ceremonyCheckDone flag prevents the effect from re-firing as
companion data updates during normal use.
2026-04-04 21:06:22 -03:00
filemon 05096e2cd9 Fix duplicate egg creation on every page load during onboarding
The ceremony was triggered whenever onboardingDone was false, without
waiting for companion data to load. This caused a new egg to be
published on every page visit/refresh for users mid-onboarding.

Now the decision tree waits for companions to load before deciding:
- No profile / no pets: ceremony creates a new egg (brand new user)
- Has baby/adult: skip ceremony, auto-fix onboardingDone flag
- Has only eggs: reuse an existing egg via existingCompanion prop
- Stale pet references: treat as new user

The chosen egg is locked in a ref so mid-ceremony refreshes don't
switch eggs or create duplicates.
2026-04-04 20:37:11 -03:00
filemon 05667460eb Fix first-time egg ceremony not covering RightSidebar
Portal the first-time hatching ceremony to document.body with z-[100],
matching the subsequent hatch ceremony implementation. The overlay was
previously rendered inline inside the center column's stacking context
(relative z-0), which prevented its fixed z-50 from painting over the
sibling RightSidebar.
2026-04-04 20:15:52 -03:00
Chad Curtis b10dae7655 Persist companion position across page navigations instead of replaying entry animation 2026-04-04 17:18:24 -05:00
Chad Curtis c799b9efd6 Fix crash when rendering egg: guard against undefined allTags from CompanionData cast 2026-04-04 17:14:55 -05:00
Chad Curtis fe4834e157 Remove deprecated dead code: selector modal state, useRerollMission plumbing, unused companion prop 2026-04-04 17:11:43 -05:00
Chad Curtis 5d972249a4 Fix all ESLint errors: remove unused imports, variables, and props across 4 files 2026-04-04 17:03:32 -05:00
Chad Curtis f607a01577 Fix ambiguous Tailwind duration-[2000ms] class warning 2026-04-04 16:50:56 -05:00
Chad Curtis 1e232e6a9e Blobbi hatching ceremony: immersive egg-to-blobbi experience with redesigned care UI
Replaces the old onboarding tour with a full hatching ceremony featuring golden aura,
sparkles, typewriter dialog, and fade-to-white reveal. Redesigns the BlobbiPage with
curved arc stats, floating action bubbles, overlay drawer tabs, and responsive layout.
Adds companion pill button, simplified photo modal, and egg animation styles.
Removes the old tour system (FirstHatchTour, tour hooks, tour types).
2026-04-04 16:49:51 -05:00
Alex Gleason 431c388129 release: v2.5.2 2026-04-04 13:54:13 -05:00
Alex Gleason 72b63dac21 Set default AppConfig.client to Ditto's kind 31990 handler naddr 2026-04-04 13:30:09 -05:00
Chad Curtis be82cb9626 Propagate relay and author hints to all event fetch call sites
Wire relay URL hints (from e/E tag position [2]) and author pubkey hints
(from e/E tag position [4] or p/P tag fallback) through every component
that fetches a referenced event:

- NoteCard: use getParentEventHints, pass hints through ReplyContext
- ReplyContext: accept and forward relay/author hints to EmbeddedNote
- CommentContext: extract hints from E/A tags in parseCommentRoot,
  pass to useEvent, useAddrEvent, and EmbeddedNote
- NotificationsPage: extract hints from e tag in ReferencedNoteCard
- usePollVoteLabel: extract hints from e tag for parent poll fetch
- ComposeBox: pass quotedEvent.pubkey as authorHint to EmbeddedNote
2026-04-04 06:03:33 -05:00
Chad Curtis c2c6f711b5 Fix parent author hint extraction and useEvent query cache keying
getParentEventHints only looked at position [4] of the e tag for the parent
author pubkey, but many clients (e.g. Wisp) omit it. When the relay hint
doesn't have the event, Tier 3 (NIP-65 outbox resolution) never fired
because authorHint was undefined. Now falls back to the first p tag, which
per NIP-10 convention holds the parent author's pubkey.

Also include relays and authorHint in the useEvent queryKey so calls with
different hints aren't served stale null results from a hint-less query.
2026-04-04 05:50:21 -05:00
Chad Curtis 3fba81a7d2 Fix ancestor thread fetching to use relay hints and author outbox relays
AncestorThread was calling useEvent(eventId) without relay hints or author
hints, so ancestor events only resolved via Tier 1 (user's configured relays).
Tiers 2 (relay hints from e tags) and 3 (author's NIP-65 outbox relays) were
never activated, causing parent events on personal relays to silently fail.

Added getParentEventHints() to extract relay URL and author pubkey from NIP-10
e tags, and wired both through AncestorThread's recursive chain.
2026-04-04 05:22:28 -05:00
Chad Curtis 6f2b51197f Add option filter bars to poll voters modal with scrollable overflow and accent divider 2026-04-04 03:23:39 -05:00
Chad Curtis 00c801e9dc Add poll voter interactions, kind 1018 vote rendering, and DRY activity card refactor
Poll voters:
- Clickable voter avatar stack + vote count on polls (before and after voting)
- Voters modal showing each voter with avatar, name, option, and nevent link
- Extract VoterAvatarsButton to DRY the avatar stack pattern

Kind 1018 vote rendering:
- Register in PostDetailPage as compact activity card with parent poll ancestor
- Register in NoteCard with threaded + normal variants (user avatar, not icon)
- Register in CommentContext with Vote icon, 'a vote' label, and rich hover showing voter + option
- Extract usePollVoteLabel hook to DRY vote label resolution across 3 call sites

ActivityCard refactor:
- Extract shared ActivityCard and ActorRow from NoteCard
- Refactor reaction (kind 7), repost (kind 6/16), zap (kind 9735), and poll vote (kind 1018)
- Reuse ActivityCard in PostDetailPage for vote detail view
- Net ~250 line reduction in NoteCard
2026-04-04 03:09:20 -05:00
Chad Curtis 47e7d05cb9 Add poll voter avatars, voters modal, and kind 1018 vote detail view
- Show clickable voter avatar stack + vote count on polls (both before and after voting)
- Clicking opens a voters modal listing each voter with avatar, name, voted option, and link to their vote nevent
- Extract VoterAvatarsButton to DRY the avatar stack pattern
- Register kind 1018 in PostDetailPage so vote nevents render as compact activity cards (avatar + 'voted' + label)
- Parent poll appears as threaded ancestor above the vote card
- Use PostActionBar for vote detail action buttons
2026-04-04 02:42:19 -05:00
Chad Curtis 4ef6d1b149 Revert "Use relaxed eoseTimeout (1000ms) for Blobbi queries to ensure freshest data"
This reverts commit ed083bfdad.
2026-04-04 01:56:40 -05:00
Alex Gleason badd19d27c Reorder default sidebar: Blobbi, Badges, Emojis, Letters, Themes 2026-04-04 00:25:16 -05:00
Alex Gleason e67f90582b release: v2.5.1 2026-04-03 23:31:09 -05:00
Alex Gleason 7fa6e574f8 Fix lightbox z-index by portaling inside Lightbox itself, not just ImageGallery
The previous fix (db502b46) only portaled the Lightbox when rendered
from ImageGallery. But Lightbox is also rendered directly by
NoteContent, MediaCollage, and MagicDeckContent — all still trapped
inside the center column's z-0 stacking context (added in 8e3f778f).

Move createPortal(…, document.body) into Lightbox so every consumer
escapes the stacking context automatically.
2026-04-03 23:27:53 -05:00
Alex Gleason 9b36bf3325 release: v2.5.0 2026-04-03 23:09:20 -05:00
Alex Gleason bc1c4cb7cf Merge branch 'main' of gitlab.com:soapbox-pub/ditto 2026-04-03 22:50:34 -05:00
Chad Curtis 119f684fb3 Fix sharp corners on compose box by adding rounded-2xl 2026-04-03 22:41:16 -05:00
Chad Curtis 45134ef9cc Allow file uploads in poll composer
Remove the separate pollQuestion state and poll builder branch. Poll
mode now reuses the normal textarea/preview ternary (with edit/preview
toggle, file uploads, paste handling, imeta tags) and renders poll
options and settings below it.
2026-04-03 22:29:32 -05:00
Chad Curtis db502b462c Fix lightbox appearing behind right sidebar by portaling to document.body 2026-04-03 21:52:05 -05:00
Chad Curtis ed083bfdad Use relaxed eoseTimeout (1000ms) for Blobbi queries to ensure freshest data
The default pool eoseTimeout (300ms) races and resolves shortly after the
fastest relay. Blobbi pet state and profile data are accuracy-sensitive —
stale data from a single fast relay can cause data loss when mutations
overwrite newer versions on other relays.

- Add eoseTimeout option to fetchFreshEvent and new fetchFreshEvents variant
- Update useBlobbisCollection, useBlobbonautProfile, and useBlobbiSleepToggle
  to use fetchFreshEvents/fetchFreshEvent with eoseTimeout: 1000
- Widen NostrBatcher.req() type to pass through eoseTimeout to NPool
- Gate unconditional console.log in parseBlobbiEvent behind import.meta.env.DEV
- Remove unconditional console.logs from useBlobbisCollection
2026-04-03 21:39:00 -05:00
Alex Gleason 47811f9190 Use NIP-5A canonical subdomains for nsite preview iframe origins
Instead of generating a random session ID for the iframe subdomain,
derive it from the nsite event using the NIP-5A canonical format:
- Root sites (kind 15128): npub subdomain
- Named sites (kind 35128): base36(pubkey) + d-tag subdomain

Extract hexToBase36 and getNsiteSubdomain into a shared utility
used by both NsiteCard and NsitePreviewDialog.
2026-04-03 18:37:28 -05:00
Alex Gleason ba99cdc51c Fix MIME type for nsite assets by always using extension-based detection
Blossom servers commonly return incorrect Content-Type headers (e.g. text/plain
for .js files), causing browsers to reject module scripts under strict MIME
checking. Since we always know the file path from the manifest, use guessMimeType
based on the file extension instead of trusting the Blossom response header.
2026-04-03 18:13:40 -05:00
Alex Gleason 7092f7306f Serve nsite previews directly from Blossom instead of proxying through nsite.lol gateway
NsitePreviewDialog now builds a path→sha256 manifest from the event's 'path'
tags and resolves files directly from Blossom servers (from the event's 'server'
tags, falling back to the user's configured app Blossom servers). Each fetch
request from the iframe is intercepted, the sha256 is looked up in the manifest,
and the blob is fetched from the first Blossom server that responds successfully.
Unknown paths fall back to /index.html to support SPA client-side routing.

- NsitePreviewDialog: remove nsiteUrl proxy, accept NostrEvent instead
- NsiteCard: pass event directly to dialog
- AppHandlerContent: use useAddrEvent to fetch the kind 35128 event by
  pubkey+d-tag from the 'a' tag, then pass the event to the dialog; disable
  Run button until the nsite event is loaded; remove unused hexToBase36
2026-04-03 18:10:22 -05:00
Alex Gleason 357dd56de0 Merge branch 'main' of gitlab.com:soapbox-pub/ditto 2026-04-03 17:54:42 -05:00
Alex Gleason fadec0574a Add Run button to NsiteCard for in-app nsite preview 2026-04-03 17:49:06 -05:00
Alex Gleason 469806886a Fix card navigation firing on button/link clicks in NoteCard 2026-04-03 17:45:36 -05:00
Alex Gleason f7ab980ecd Fix nsite preview panel height using measured column rect
Replace absolute/sticky positioning with fixed + inline styles derived
from a ResizeObserver on the center column element. The panel now sits
at exactly the column's left/top/width and fills to the bottom of the
viewport, unaffected by the column's pb-overscroll padding.
2026-04-03 17:41:09 -05:00
Alex Gleason c6b5ab2284 Replace address bar and external link with app icon and name in preview nav bar 2026-04-03 17:36:17 -05:00
Alex Gleason 2231673ee6 Fix nsite preview panel to fill exactly the center column
Add CenterColumnContext to LayoutContext and expose the center column DOM
element from MainLayout via a useState ref callback. NsitePreviewDialog now
portals into that element using absolute inset-0 instead of fixed positioning
with hardcoded sidebar insets, so it always covers exactly the center column
regardless of viewport width.
2026-04-03 17:30:00 -05:00
Alex Gleason f8907475f9 Link client tag name to /:naddr on post detail page 2026-04-03 17:28:29 -05:00
Alex Gleason 4252841125 Replace dialog with fixed center-column overlay panel for nsite preview
Remove the Radix Dialog and browser chrome (back/forward/refresh/fullscreen).
The preview now renders as a portal-based fixed panel that overlays exactly
the center column using responsive left/right insets matching the sidebar
widths (sidebar:left-[300px], xl:right-[300px]). A slim nav bar at the top
shows the nsite:// URL, an external-link button, and a close button.
2026-04-03 17:25:55 -05:00
Alex Gleason ee8220c1f0 Use nsite://<name><path> in preview address bar
Separate the proxy target (nsite.lol gateway URL) from the display URL.
Pass nsiteName through to the dialog so the address bar shows a clean
nsite:// scheme with no gateway hostname.
2026-04-03 17:11:45 -05:00
Alex Gleason 11e29646a7 Show nsite:// URL in preview address bar instead of gateway URL 2026-04-03 17:09:03 -05:00
Alex Gleason a9bab7f8e8 Remove default dialog close button from nsite preview 2026-04-03 17:03:09 -05:00
Alex Gleason 0b69ab51f4 Fix Content-Type header matching in nsite preview proxy
The iframe-fetch-client does an exact equality check for "text/html",
but real servers return "text/html; charset=UTF-8". Also, the browser
fetch() API lowercases all header names while main.js checks Title-Case
keys. Fix both: re-key headers to Title-Case and strip charset params
from Content-Type values before sending them to the iframe.
2026-04-03 17:01:59 -05:00
Alex Gleason 2a32e79b13 feat: change AppConfig.client to naddr1 format, decode relay hint per NIP-89
AppConfig.client now expects a NIP-19 naddr1 string pointing to the app's
kind 31990 handler event instead of a raw 'a' tag value. useNostrPublish
decodes the naddr at publish time to extract the 31990:<pubkey>:<d-tag>
addr and any embedded relay hint, producing a fully NIP-89-compliant
client tag: ["client", <name>, <addr>, <relay-hint>].
2026-04-03 16:57:57 -05:00
Alex Gleason 39fc7549ac Add Run button and nsite preview dialog to app handler cards
When a kind 31990 app event includes an 'a' tag pointing to a kind 35128
nsite, display a 'Run' button that opens an in-app preview dialog. The
dialog embeds the nsite in a sandboxed iframe via the Shakespeare
iframe-fetch-client protocol (local-shakespeare.dev), proxying fetch
requests from the iframe to the live nsite URL so the SPA renders
without needing CORS headers on the origin server.
2026-04-03 16:53:25 -05:00
Alex Gleason 414f42e339 Add Blobbi (kind 31124) to the Ditto homepage feed 2026-04-03 16:50:17 -05:00
Alex Gleason 8e3f778f5b Improve Zapstore and app handler card display
- Rename Zapstore kind labels to include 'Zapstore' prefix across all
  label registries (NoteCard, PostDetailPage, CommentContext,
  ExternalContentHeader, NotificationsPage, extraKinds)
- Wrap Zapstore (32267, 30063, 3063) compact and detail content in
  rounded bordered cards with hover effects; remove redundant mt-2/mt-3
  margins from component roots
- Replace useLinkPreview thumbnail with metadata banner/picture in kind
  31990 app handler cards (compact and full views)
- Add pt-4 to Zapstore detail card wrappers in PostDetailPage
- Fix sticky tab bar (SubHeaderBar z-10) being painted over by card
  content: remove z-10 from AppHandlerContent inner div and add z-0 to
  the main content column in MainLayout
2026-04-03 16:20:40 -05:00
Alex Gleason bc83d08961 Upgrade Nostrify 2026-04-03 13:56:48 -05:00
Alex Gleason 7d83273410 Simplify sidebar media query to a single useQuery with inline fallback logic 2026-04-03 00:53:45 -05:00
Alex Gleason fabcb4170d Fill profile media sidebar with kind 1 fallback when kind 20 results are sparse
When fewer than 9 media-native events (kind 20, 21, 22, etc.) are found for a
profile, perform a secondary query for kind 1 events with search:media:true and
append them to fill the remaining slots. Kind 20 events are always displayed first.
2026-04-03 00:36:44 -05:00
Alex Gleason 8b824f8cc9 release: v2.4.1 2026-04-02 23:12:45 -05:00
Alex Gleason 3e429fe0b0 Add rendering for Zapstore release (kind 30063) and asset (kind 3063) events
- New ZapstoreReleaseContent component: shows app icon/name fetched from the
  linked kind 32267, version badge, channel badge, release notes, and a
  downloads section that fetches and renders each linked kind 3063 asset
- New ZapstoreAssetContent component: shows MIME-type icon, platform/arch
  badges, file size, SHA-256 hash, commit hash, supported NIPs, and APK
  certificate hashes
- Register both kinds in NoteCard, PostDetailPage, extraKinds, CommentContext,
  ExternalContentHeader, and NotificationsPage label/icon maps
- Route kind 3063 to the Zapstore relay in NostrProvider and useEvent
- Kind 3063 is excluded from feeds (display-only on direct navigation)
2026-04-02 23:09:01 -05:00
Alex Gleason a261934ab0 ci: publish zsp to relay.ditto.pub and use blossom.ditto.pub; remove --publish-server-list from nsite 2026-04-02 22:48:46 -05:00
Alex Gleason 822ff13ac3 Merge branch 'update-first-egg-tour' into 'main'
Allow first-hatch tour for migrated accounts with blobbi_onboarding_done=true

See merge request soapbox-pub/ditto!156
2026-04-03 03:42:13 +00:00
filemon afa475ecef Allow first-hatch tour for migrated accounts with blobbi_onboarding_done=true
Older accounts had onboarding_done migrated to blobbi_onboarding_done=true
before the first-hatch tour existed. When the user has exactly 1 egg and
no baby/adult companions, skip the profileOnboardingDone gate so those
accounts can still enter the tour. The localStorage isCompleted check
still prevents re-triggering for users who already finished it.

This is a temporary migration safeguard. The long-term fix is a dedicated
blobbi_first_hatch_tour_done tag.
2026-04-03 00:34:51 -03:00
Alex Gleason 853b5ead9c release: v2.4.0 2026-04-02 21:47:33 -05:00
Alex Gleason a5746ee915 Merge branch 'update-hatch-action' into 'main'
Add first-hatch tour orchestration layer (state machine + activation)

See merge request soapbox-pub/ditto!153
2026-04-03 02:43:05 +00:00
filemon fa3376ac4f Remove legacy blobbi re-export wrappers and unused duplicate hooks
No imports remained pointing at the @/lib/blobbi* or @/hooks/use{ProjectedBlobbiState,BlobbisCollection,BlobbiMigration} paths.
Delete the transitional re-exports and the dead hook copies so only
src/blobbi/core/lib/ and src/blobbi/core/hooks/ remain as the single
source of truth.
2026-04-02 23:25:52 -03:00
filemon 6f0c10fe9b Address review feedback: deduplicate blobbi.ts, remove dead props and state
- Convert src/lib/blobbi*.ts files to thin re-exports from canonical
  src/blobbi/core/lib/ sources, eliminating duplicated logic
- Remove unused emoji, title, description props from TasksPanelProps
  and their call site in BlobbiMissionsModal
- Remove dead direction state from MissionSurfaceCard (was always 'right')
- Remove unused onContinue prop from FirstHatchTourCard and call site
2026-04-02 23:10:10 -03:00
Chad Curtis 2f1bf0bca5 Fix notification dot reappearing after marking as read
Remove the invalidateQueries call in markAsRead that raced with the
setQueriesData(false) update. The invalidation triggered an immediate
refetch whose queryFn closure still held the old notificationsCursor
(from a render before the settings cache update propagated). That stale
refetch re-queried the relay with the old since value, found the same
unread events, returned true, and overwrote the false just set --
causing the dot to reappear.

The setQueriesData(false) call provides the immediate UI update. The
60-second poll and real-time subscription naturally re-evaluate once
the cursor has fully propagated.
2026-04-02 20:35:09 -05:00
filemon 9be98d9a8d Merge branch 'main' into update-hatch-action 2026-04-02 20:47:21 -03:00
filemon c4dd8e7c3d Set blobbi_onboarding_done at tour completion, not at egg adoption
Adopting a first Blobbi egg should not mark onboarding as complete —
the user still needs to go through the first-hatch tutorial. Removed
the premature blobbi_onboarding_done:'true' write from adoptPreview()
in useBlobbiOnboarding.

The flag is now set to 'true' only when the first-hatch tour reaches
its final step (egg_hatching), right after the hatch mutation succeeds.
This is the correct semantic: onboarding means the full tutorial is
done, not just that the user created a profile or adopted an egg.
2026-04-02 20:40:17 -03:00
Chad Curtis 42832b72e3 Revert dialog fly-up on mobile keyboard open
The keyboard-aware repositioning of dialogs was too aggressive and broken.
Removes the CSS rule, dialog-keyboard-aware class, and global keyboard
detector mount. The useKeyboardVisible hook is preserved for ArticleEditor.
2026-04-02 18:20:56 -05:00
filemon e77436d02a Rename onboarding_done to blobbi_onboarding_done and make profile authoritative
The onboarding completion flag was stored as a generic 'onboarding_done'
tag on the kind 11125 Blobbonaut profile, while the first-hatch tour
relied solely on device-local localStorage. This caused issues with
multi-account usage on the same browser.

Changes:
- New profiles write 'blobbi_onboarding_done' (not 'onboarding_done')
- Parsing reads 'blobbi_onboarding_done' first, falls back to old tag
- Auto-migration: useBlobbonautProfileNormalization detects old tag
  and replaces it with the new one on next profile republish
- MANAGED_BLOBBONAUT_PROFILE_TAG_NAMES includes both tags so the
  merge logic can remove the old one during migration
- Tour activation now accepts profileOnboardingDone flag from the
  Blobbonaut profile as the authoritative completion source;
  localStorage remains a secondary fallback for in-progress UI state
- BlobbiPage passes profile.onboardingDone to the activation hook
2026-04-02 20:13:36 -03:00
filemon 302d7732ef Keep first-hatch card visible with completed state before advancing
When the user's hatch post is detected, the tour card now stays on
the 'show_hatch_card' step for 2 seconds showing a celebratory
completed state (large checkmark, 'Post shared!', 'Continuing in a
moment...') before auto-advancing to 'egg_glowing_waiting_click'.

Previously the effect called goTo() immediately on post detection,
so the checkmark was never visible — the card jumped straight to
the tap-egg phase.

Changes:
- BlobbiPage.tsx: wrap the goTo() in a 2s setTimeout
- FirstHatchTourCard.tsx: redesign completed state with centered
  checkmark, bold success text, and 'continuing' hint; remove the
  manual Continue button (auto-advance handles progression);
  update title/description to reflect the confirmed state
2026-04-02 19:28:05 -03:00
filemon b09b4938d2 Add lightweight collapsible sections to missions modal
Both Current Focus and Daily Bounties sections are now collapsible
via Radix Collapsible, defaulting to open. Section headers stay
visible when collapsed and show summary info at a glance:

- Current Focus: Hatch/Evolve badge + progress count (e.g. 2 / 5)
- Daily Bounties: coin progress + green dot for claimable count

A subtle animated chevron rotates on toggle. The collapsible
animation uses new collapsible-down/up keyframes added to the
Tailwind config (mirrors the existing accordion pattern but uses
--radix-collapsible-content-height).

Settings row stays non-collapsible to keep it simple.
2026-04-02 19:05:51 -03:00
filemon 0a0d6de111 Refactor missions modal to card-grid layout with expandable cards
- Add ExpandableMissionCard: shared component with compact collapsed
  state (icon + title + progress ring) and full-width expanded state
  with details, progress bar, action links, claim buttons
- Rework TasksPanel as a 2-col (3-col on sm+) grid of task cards;
  each card maps its task id to a specific lucide icon (Palette,
  Droplets, MessageSquare, Heart, UserPen, Activity)
- Rework DailyMissionsPanel as the same grid; each card maps its
  action type to an icon (Utensils, Moon, Camera, Mic, etc.)
- Only one card expanded at a time per section
- Add MissionTypeLegend popover in the header (? icon) explaining
  Daily / Hatch / Evolve mission types with color-coded dots
- Pass category prop (hatch | evolve | daily) through to cards for
  per-type accent colors (sky / violet / amber)
- Keep all existing behavior: claim, reroll, stop, CTA buttons
2026-04-02 18:47:29 -03:00
filemon 4e9b893822 Redesign missions modal with lighter quest-board aesthetic
- Remove all Collapsible wrappers; sections are always visible
- Restructure layout: Current Focus (hatch/evolve) on top, Daily
  Bounties below, settings toggle at footer
- Flatten TasksPanel: remove Card/CardHeader chrome, use minimal
  rows with soft rounded backgrounds and inline action links
- Lighten DailyMissionsPanel: compact mission rows, smaller claim
  buttons, muted claimed state, no heavy border cards
- Add empty focus state with Compass icon when no active process
- Sticky header with quest-themed subtitle
- ~100 fewer lines across the three files
2026-04-02 17:52:46 -03:00
filemon c60e87ad65 Merge branch 'main' into update-hatch-action 2026-04-02 16:49:03 -03:00
Alex Gleason 8e07ad515a Merge branch 'improve-baby-tasks' into 'main'
Broaden evolve 'Edit Wall' mission to accept profile metadata edits (kind 0)

See merge request soapbox-pub/ditto!155
2026-04-02 19:28:00 +00:00
filemon b4c4b8eb21 Rename wall-specific identifiers to profile-oriented naming
- KIND_WALL_EDIT → KIND_PROFILE_TABS
- wallEditEvents → profileTabsEvents
- edit_wall task id → edit_profile
- Split completion check into hasTabsEdit / hasMetadataEdit / hasProfileEdit
2026-04-02 15:52:54 -03:00
filemon 23ee6f1196 Broaden evolve 'Edit Wall' mission to accept profile metadata edits (kind 0)
The mission now completes when the user either:
- Edits custom profile tabs (kind 16769, existing behavior)
- Updates profile metadata (kind 0, new)

Both paths require the event's created_at to be after the evolution
start timestamp (stateStartedAt), so pre-existing events won't
auto-complete the task.

Updated UI copy: 'Edit Your Profile' / 'Update your profile info or
customize your profile tabs'.
2026-04-02 15:32:36 -03:00
Alex Gleason 4b97baa428 Merge branch 'exclude-text-from-media-sidebar' into 'main'
Exclude kind 1 and kind 1111 from profile Media sidebar

See merge request soapbox-pub/ditto!152
2026-04-02 16:51:27 +00:00
Alex Gleason c8e844a19a release: v2.3.1 2026-04-02 10:25:17 -05:00
Chad Curtis 205a252cac Fix slug collision check blocking edits to existing articles
Replace isEditMode guard with originalSlug comparison so the collision
check is skipped when republishing an article with the same slug it was
loaded with, but still runs if the user changes the slug to one that
would overwrite a different article.
2026-04-02 07:48:14 -05:00
Chad Curtis ad604eae68 Improve dialog UX on mobile: rounded corners, button spacing, keyboard awareness
- Add rounded-xl to Dialog and AlertDialog (was sm:rounded-lg only)
- Add consistent gap-2 to footer buttons on mobile (was no gap)
- Use w-[calc(100%-2rem)] for mobile side margins
- Push dialogs to top of viewport only when keyboard is visible via
  .keyboard-visible class on <html>, toggled by useKeyboardVisible
- Mount useKeyboardVisible globally in MainLayout so the class is
  always available for CSS-only consumers
2026-04-02 05:10:07 -05:00
Chad Curtis 57064b4f40 Save draft on blur and show cloud sync indicator
- Trigger silent draft save when title or editor loses focus
- Add onBlur prop to MilkdownEditor, wired to both WYSIWYG and source textarea
- Mark saved immediately after local write instead of waiting for relay
- Show persistent cloud icon in status; pulses while relay sync is in flight
2026-04-02 05:10:07 -05:00
Chad Curtis bb7b8da581 Always save drafts locally so they appear immediately in My Articles
Previously, drafts were only saved to localStorage on relay failure.
If the relay accepted the event but hadn't indexed it yet for queries,
the draft would show 'Saved' but not appear under My Articles. Now
we always persist locally first for instant visibility, then sync to
the relay in the background.
2026-04-02 05:10:07 -05:00
Chad Curtis 5683f6ea1e Fix source mode toggle clearing editor content
initialValueRef was only set once on mount, so toggling back from
source mode reinitialized Milkdown with stale content. Keep
initialValueRef and lastExternalValue in sync with the current value
so remounts and the replaceAll guard work correctly.
2026-04-02 05:10:07 -05:00
Chad Curtis 61c606822a Fix crash when editing in markdown source mode
The replaceAll effect tried to access editorViewCtx while in source
mode where the ProseMirror view isn't mounted, causing a 'Context
editorView not found' error. Skip the sync when sourceMode is active
and add a try/catch for the initial render race.
2026-04-02 05:10:07 -05:00
Chad Curtis bc12331cd4 Keep tab bar in article editor but make it non-sticky on mobile write mode
Add ARC_OVERHANG_PX spacer div after the header to prevent arc
overlapping content, matching the pattern used across other pages.
2026-04-02 05:10:07 -05:00
Chad Curtis 2478bf1c66 Improve mobile article editor UX when virtual keyboard is open
- Hide tab bar in write mode on mobile, replace with slim back+title header
- Hide publish FAB when keyboard is visible (was floating over content)
- Collapse metadata (summary, slug, tags) behind a 'Details' toggle on mobile
- Hide header image and stats bar when keyboard is up to maximize writing area
- Add useKeyboardVisible hook using Visual Viewport API
2026-04-02 05:10:07 -05:00
filemon ade9eb4999 Merge branch 'main' into update-hatch-action 2026-04-02 06:17:41 -03:00
Alex Gleason 213bbb21c1 release: v2.3.0 2026-04-02 03:57:37 -05:00
Alex Gleason dd3ae4da4e npm audit fix 2026-04-02 03:52:24 -05:00
Alex Gleason 681d2ab90b Merge branch 'main' of gitlab.com:soapbox-pub/ditto 2026-04-02 03:51:09 -05:00
Alex Gleason 24a645277e Fix custom emoji stretching by adding object-contain to all emoji images
Custom emoji images with non-1:1 aspect ratios were being stretched
into a square. Added object-contain to preserve natural aspect ratio
within the bounding box. Moved text sizing classes to parent containers
for reaction emoji bubbles so unicode emojis still size correctly.
2026-04-02 03:50:50 -05:00
Chad Curtis fa34922cce refactor: harden article editor — encryption, mobile UX, deduplication, source toggle
- Encrypt drafts with NIP-44 via NIP-37 (kind 31234) instead of
  plaintext kind 30024
- Fix slug auto-generation overwriting manual edits
- Guard auto-save state setters against unmount
- Deduplicate save logic, load handlers, tag extraction, and types
  via shared ArticleFields/parseArticleEvent helpers
- Replace derived state (wordCount/readingTime) with useMemo
- Mobile UX: sticky toolbar, touch-friendly header image swap,
  adaptive tooltips (pointer:fine only), FAB bottom clearance,
  responsive editor min-height
- Editor placeholder: hide on focus, handle trailing whitespace
- Tighten editor padding and paragraph spacing
- Add raw markdown source toggle (Eye/EyeOff) in toolbar
- Shrink slug/tag fields, consistent sizing
2026-04-02 03:48:10 -05:00
Chad Curtis 89c71ed073 Merge branch 'feat/article-editor' into 'main'
feat: add in-app article editor with Milkdown WYSIWYG

See merge request soapbox-pub/ditto!150
2026-04-02 08:47:37 +00:00
filemon 0f02563d3a Add mission card dismiss/toggle and fix More menu for hidden bar items
- Mission surface card now has an X dismiss button (onHide prop)
  that hides it via localStorage ('blobbi:mission-card-visible')
- BlobbiMissionsModal gains a 'Show mission card on main page'
  toggle at the bottom, reflecting the same preference
- Both controls share the same state: hiding from the card or
  toggling from the modal are equivalent
- More dropdown now conditionally shows items: if an action
  (Blobbies, Items, Missions, Photo, Companion) is visible in
  the bottom bar, it is skipped in More to avoid duplication;
  if removed from the bar, it appears in More so no action
  becomes inaccessible
2026-04-02 05:26:21 -03:00
Alex Gleason f49909dedf Close mobile drawer when clicking footer links (Changelog, Privacy) 2026-04-02 03:23:13 -05:00
Alex Gleason ab43225f0c Remove Nostr protocol jargon from changelog and add rule to release skill 2026-04-02 03:14:01 -05:00
Alex Gleason 2bb1b07dd6 release: v2.2.11 2026-04-02 03:05:10 -05:00
Alex Gleason f93c759bf2 Fix VersionCheck crash: move VersionCheck and Toaster inside BrowserRouter
VersionCheck and Toaster were rendering outside the BrowserRouter in App.tsx,
so the <Link> in the version update toast had no Router context. Moved both
into AppRouter.tsx inside BrowserRouter. Also truncate changelog excerpt
to 60 chars with ellipsis for cleaner toast display.
2026-04-02 03:01:32 -05:00
filemon 38630be23d Add customizable bottom bar, mission surface card, and action bar editor
Bottom bar simplification:
- Default to 3 visible items: Blobbies (left), Main Action (center),
  More (right). Items/Missions/Photo moved into More dropdown.
- All existing actions (Set as Companion, Evolve/Hatch, View Blobbi,
  dev tools) remain in More with existing guards.
- 'Edit action bar' entry in More opens the new editor.

Editable action bar preferences:
- New preference model (action-bar-preferences.ts) with localStorage
  persistence, validation, and migration support.
- Candidates: Blobbies, Missions, Items, Take Photo, Set as Companion.
- Up to 3 custom visible slots (Main Action + More are fixed).
- Each slot can be shown/hidden, reordered, or highlighted.
- ActionBarEditor modal for editing with reset-to-default option.

Mission surface card:
- MissionSurfaceCard renders below the Blobbi visual, above the bar.
- Shows one mission at a time with badge (Hatch/Evolve/Daily),
  progress bar, description, and coin reward for dailies.
- Priority: hatch/evolve tasks first, then unclaimed daily missions.
- Auto-rotates every 5s when multiple cards; manual tap cycles.
- 'View all missions' link opens existing missions modal.
- Hidden during first-hatch tour (preserves tour behavior).
2026-04-02 04:55:00 -03:00
Alex Gleason ef4ac2e3f4 release: v2.2.10 2026-04-02 02:48:34 -05:00
Alex Gleason 32b36b2f54 Merge branch 'main' of gitlab.com:soapbox-pub/ditto 2026-04-02 02:40:44 -05:00
Alex Gleason dee5c82fa8 Add 'deployed an nsite' action header to nsite detail page 2026-04-02 02:39:40 -05:00
Alex Gleason 22d66a28d7 Add Open App button to compact view, fix stopPropagation on all buttons 2026-04-02 02:37:13 -05:00
Alex Gleason 984a56c412 Add 'published an app' action header to app detail page 2026-04-02 02:34:14 -05:00
Alex Gleason 207e7a13a2 Move Shakespeare badge above Open App button, restore h-6 size 2026-04-02 02:25:44 -05:00
Alex Gleason cc7feebbb0 Replace small external link icon with prominent Visit Website button 2026-04-02 02:22:25 -05:00
filemon 9b8cff63da Polish first-hatch tour: center click hint over egg, keep crack visible during opening
- Move click hint emoji to centered overlay with larger size (text-4xl)
  so users clearly see it over the egg, not tucked in a corner
- Keep crack overlay visible during egg_opening state by including
  'opening' in tourShowCrack and mapping it to crack level 3
- The crack SVG lives inside the shell div, so it inherits the
  opening animation (scale/blur/fade) and disappears with the shell
- Suppress shake animation during opening so it doesn't conflict
  with the smooth open sequence
2026-04-02 04:12:00 -03:00
Alex Gleason 925619b13c Add background color to app icon for transparent images 2026-04-02 02:09:58 -05:00
Alex Gleason ceb7bbc718 Fix app icon z-index so it renders above the og:image hero 2026-04-02 02:06:41 -05:00
Alex Gleason 53a607fa53 Overlay app icon over og:image like a profile avatar 2026-04-02 01:57:56 -05:00
filemon e13473809d Fix egg crack progression, companion auto-assignment, and add dev tour controls
- Replace full-width crack with stage-specific SVG paths that grow
  outward from the egg center: level 0 shows a small central cluster,
  level 1 expands left/right with branches, level 2 reaches further
  with more fracture detail, level 3 spans near-full width
- Remove current_companion assignment during egg adoption so eggs
  are never auto-set as the floating companion
- Add first-hatch tour dev controls to BlobbiDevEditor: skip post
  requirement, restart tour, and reset-to-egg+tour buttons
2026-04-02 03:52:54 -03:00
Alex Gleason e9eeebc4b1 Rename 'App Handler' to 'App' in UI labels 2026-04-02 01:48:08 -05:00
Alex Gleason b42d241882 Fix Shakespeare clone URL to use NostrURI class 2026-04-02 01:43:33 -05:00
Alex Gleason 68da609a9e Hide app handler screenshot hero when no og:image, reduce image height 2026-04-02 01:38:07 -05:00
Chad Curtis 1afa78ae39 Merge branch 'fix/disappearing-post-box' into 'main'
Fix disappearing compose box after posting

See merge request soapbox-pub/ditto!141
2026-04-02 06:29:56 +00:00
filemon 00a9ad20de Merge branch 'main' into update-hatch-action 2026-04-02 03:13:16 -03:00
Alex Gleason e0ff462f12 Merge branch 'main' of gitlab.com:soapbox-pub/ditto 2026-04-02 01:06:25 -05:00
Alex Gleason f4e38123e4 Add display for kind 31990 NIP-89 app handler events 2026-04-02 01:00:32 -05:00
Alex Gleason eb1c873b9a Show 'What's new' with changelog excerpt in version update toast 2026-04-01 23:50:22 -05:00
Alex Gleason 22f13c1505 Replace __DITTO_CONFIG__ global with import.meta.env.DITTO_CONFIG and remove ThemeSchemaCompat
Move build-time ditto.json injection from a Vite define global to
import.meta.env.DITTO_CONFIG (a JSON string parsed and validated at
runtime via DittoConfigSchema). Remove the global type declaration
from vite-env.d.ts.

Drop ThemeSchemaCompat and its legacy "black"/"pink" migration code
from AppProvider and NostrSync — invalid theme values now simply fail
Zod validation.

Fix a latent bug where a partial feedSettings from ditto.json would
replace the full hardcoded defaults; defaultConfig now deep-merges
feedSettings.
2026-04-01 23:16:33 -05:00
Alex Gleason cbfc8f149f Redesign changelog page: hero latest release, collapsible entries, flat item list with category icons, tooltips, typography fixes, and search integration 2026-04-01 22:33:18 -05:00
Alex Gleason 2e41859747 Show update toast with changelog link when app version changes 2026-04-01 21:26:23 -05:00
Alex Gleason 3b176a3e8f release: v2.2.9 2026-04-01 21:12:42 -05:00
Alex Gleason a1e1e1d57f Merge branch 'main' of gitlab.com:soapbox-pub/ditto 2026-04-01 20:55:01 -05:00
Alex Gleason eb973cc20b Redesign shop/items dialog: tile layout, instant buy, remove accessories and categories 2026-04-01 20:53:56 -05:00
Alex Gleason f66ab92e51 Unify Shop/Inventory, remove Info modal and visibility, consolidate dev tools
- Combine Shop and Inventory into a single tabbed dialog (Shop tab
  with category sub-tabs, Inventory tab with item list and use flow)
- Remove BlobbiInfoModal entirely
- Move dev tools (Dev Hatch/Evolve, State Editor, Emotion Tester) into
  the bottom bar 'More' dropdown with yellow text, remove floating
  dev tools panel
- Remove 'visibleToOthers' / 'visible_to_others' concept from the
  entire codebase: types, interfaces, tag schemas, event construction,
  parsing, UI badges, dev editor, and documentation
2026-04-01 20:14:34 -05:00
Alex Gleason 4d573ffaa8 Merge branch 'main' of gitlab.com:soapbox-pub/ditto 2026-04-01 20:13:37 -05:00
Alex Gleason 081189886a Hide emoji packs without any valid emoji tags from feeds 2026-04-01 20:07:43 -05:00
Alex Gleason 1efc8de880 Add description field to emoji pack create/edit dialog
Parse and publish the 'about' tag for kind 30030 emoji sets.
EmojiPackContent already displays it.
2026-04-01 20:02:01 -05:00
Alex Gleason 8bf9db382e Fix emoji pack drag reorder, expand truncated grid, resolve shortcode collisions
- Replace chevron up/down buttons with @dnd-kit SortableList/SortableItem
  for proper drag-and-drop reorder in EmojiPackDialog
- Remove 'N emojis' badge from emoji pack display
- Make '+N' overflow indicator clickable to expand full emoji grid
- Stop click propagation on expand button to prevent feed navigation
- Resolve shortcode collisions across emoji packs by prefixing with pack
  d-tag identifier when two packs define the same shortcode with different URLs
2026-04-01 19:57:29 -05:00
Alex Gleason 103b9c71bf Move right-side floating controls into bottom bar 3-dots menu
Replace the cluster of floating action buttons on the right side of the
Blobbi dashboard with a single 'More' dropdown in the bottom control bar.
The menu contains: Inventory, Take a Photo, Set as Companion, Evolve/Hatch,
Blobbi Info, and View Blobbi. The floating controls now only show the back
button (left) and dev tools (right, localhost only).
2026-04-01 19:29:15 -05:00
Alex Gleason e27057788b Replace drag reorder with move buttons, remove colons and parenthetical count
- Swap native HTML drag-and-drop reorder for chevron up/down buttons,
  fixing scroll conflicts inside ScrollArea and drop zone interference
- Remove the colon decorations around shortcode inputs
- Show plain 'N emojis' count without parenthetical upload note
2026-04-01 19:20:30 -05:00
Alex Gleason 4983b3c1ef Use single upload icon in emoji drop zone 2026-04-01 19:14:35 -05:00
Alex Gleason 197ab6c28a Simplify BlobbiStateCard to show just the character and name 2026-04-01 19:14:01 -05:00
Alex Gleason fd0d47160d Defer all Blossom uploads and event signing until submit
Files are held as local blob previews while editing. Nothing is
uploaded or signed until the user clicks the publish button, at which
point all pending files are uploaded in parallel and then the event
is published in a single batch.
2026-04-01 19:10:09 -05:00
Alex Gleason 4697d269bc Put Title and ID side-by-side, auto-slug ID from Title, remove NIP jargon 2026-04-01 19:02:39 -05:00
Alex Gleason 73bf03cfab Add Blobbi view link and register kind 31124 in feed/detail views
Add a 3-dots menu to the Blobbi dashboard with a 'View Blobbi' link that
navigates to the naddr detail page. Register kind 31124 (Blobbi Pet State)
across all UI registration points so Blobbi events render properly in
feeds, detail pages, comment contexts, and embedded previews.
2026-04-01 19:01:47 -05:00
Alex Gleason c3d4d5f06e Add emoji pack create/edit dialog with drag-and-drop upload
Introduce EmojiPackDialog for publishing and editing kind 30030 custom
emoji sets (NIP-30). The dialog supports multi-file and folder
drag-and-drop, automatically extracting shortcodes from filenames. The
d-tag identifier is locked after initial publish. Existing packs show
an Edit button on the feed card for the author. The /emojis page FAB
now opens the create dialog.
2026-04-01 18:53:51 -05:00
Alex Gleason 4c201cc2d3 release: v2.2.8 2026-04-01 18:39:05 -05:00
Mary Kate Fain d28364531b Exclude kind 1 and kind 1111 from profile Media sidebar
Give ProfileRightSidebar its own query using a kind whitelist
(20, 21, 22, 34236, 36787, 34139, 30054, 30055) instead of
relying on the parent's search-based media query. This ensures
the desktop sidebar only shows media-native events, excluding
kind 1 text notes and kind 1111 comments at the query level.

The Media tab continues to use the broader useProfileMedia hook
with search: 'media:true' and is unaffected.
2026-04-01 18:34:32 -05:00
Alex Gleason c30a6a7bcd Merge branch 'remove-egg-task' into 'main'
Remove egg maintain_stats task and hide companion button for eggs

See merge request soapbox-pub/ditto!151
2026-04-01 23:15:11 +00:00
Alex Gleason c4354774ad Add Color Moments and Geocaching to community NIP specs, sort table by kind number 2026-04-01 18:05:52 -05:00
Alex Gleason 8a44f77fb1 Add community NIP specs to NIP.md (Letters, Weather, Blobbi)
Add a Community Kinds section to the overview table and a Community
NIP Specifications section with summaries and links to the full specs
maintained by Chad Curtis, Sam Thomson, and Danifra.
2026-04-01 17:56:50 -05:00
filemon f3eb4adba5 Fix first-hatch tour: full flow wiring, progressive cracks, hatch reveal
Tour flow fixes:
- Rename show_hatch_modal -> show_hatch_card (no longer a modal)
- Remove unused egg_ready_hint, await_create_post, tour_rewards_reveal,
  tour_set_companion_hint steps; simplify to 9 focused steps
- Auto-advance from idle -> show_hatch_card immediately
- Card stays visible through show_hatch_card + glowing + crack stages
- Post detection advances to egg_glowing_waiting_click automatically
- Crack stages are manual (1 click per stage, 3 clicks total)
- egg_opening and egg_hatching are auto-advance with timers
- egg_hatching triggers the actual useBlobbiHatch mutation + completes tour

Egg visual improvements:
- Initial crack (hairline) shown from show_hatch_card step onward
- Progressive crack SVG: level 0 (hairline) -> 1 (branches) -> 2 (more)
  -> 3 (full fracture pattern with large splits)
- Auto-wiggle every 2.5s during show_hatch_card and glowing_waiting_click
- Shell opening animation (scale + brightness + blur -> fade out)
- Bright white glow during opening/hatching for light burst effect
- onTourEggClick callback threaded through BlobbiStageVisual -> BlobbiEggVisual -> EggGraphic

Fake pointer hint:
- After 10s on egg_glowing_waiting_click, show bouncing pointer emoji
- Repeats every 5s if user doesn't click
- Disappears immediately on egg click

Layout during tour:
- Inline card rendered ABOVE stats section, directly below egg
- Stats section hidden entirely during first-hatch tour
- Dashboard controls + bottom bar + inline activities still hidden

FirstHatchTourCard improvements:
- Accepts currentStep prop for adaptive messaging
- Post step: shows mission card with required phrase + Create Post
- Click steps: shows 'Tap {Name} to hatch!' with tap icon hint
- Post completed state: shows checkmark + Continue button
2026-04-01 19:34:54 -03:00
Chad Curtis 9ebd9a304f Fix notification dot not clearing after marking as read
setQueryData requires an exact query key match, but the unread
notifications query uses a 4-element key (prefix, pubkey, kindsKey,
authorsKey). The markAsRead callback was calling setQueryData with only
2 elements, silently missing the cache entry. Switch to setQueriesData
which uses prefix matching, correctly hitting the real cache entry.
2026-04-01 16:24:23 -05:00
filemon 0487586af9 Replace first-hatch modal with inline card, hide dashboard controls during tour
UX change: the first-hatch experience is now a focused onboarding screen
instead of a modal interruption.

Layout during first-hatch tour:
- Egg visual (top, with tour animations)
- Stats (if any visible)
- FirstHatchTourCard inline below stats (mission + post CTA)
- No floating hero controls (camera, info, companion, incubation)
- No bottom action bar (blobbies, missions, actions, shop, inventory)
- No inline activity area (music, sing)

The page feels like a dedicated guided flow rather than a dashboard
with overlays. Normal dashboard controls return after tour completion.

Architecture: clean branch in BlobbiDashboard render --
isFirstHatchTourActive gates visibility of controls/bar/activities.
The inline card lives at the same level as other content sections.
The first egg is treated as already in the hatch onboarding path
without requiring the normal 'start incubation' entry point.
2026-04-01 18:11:48 -03:00
filemon 2c737ca322 Wire first-hatch tour into BlobbiPage with post phrase update and egg visuals
Tour integration:
- Call useFirstHatchTour + useFirstHatchTourActivation in BlobbiDashboard
- Auto-advance: idle -> egg_ready_hint (immediate) -> show_hatch_modal (3s)
- Poll for valid hatch post during show_hatch_modal/await_create_post
- On post detected, advance to egg_glowing_waiting_click
- Missions button opens tour modal instead of normal missions during tour
- Hide incubation button during tour (tour handles the flow)
- Badge shows tour-specific remaining count (1 post mission)

Post phrase update:
- New format: 'Posting to hatch {Name} #blobbi' (was: 'Hello Nostr! Posting to hatch #name #blobbi #ditto #nostr')
- Update isValidHatchPost to check for phrase anywhere in content
- Add buildHatchPhrase helper
- Simplify BlobbiPostModal validation and tag extraction

Egg visual layer:
- Add EggTourVisualState type ('idle' | 'ready_hint' | 'glowing_waiting_click')
- Thread tourVisualState prop: BlobbiStageVisual -> BlobbiEggVisual -> EggGraphic
- ready_hint: auto-wiggle every 2.5s using existing egg-tap-wiggle animation
- glowing_waiting_click: enlarged pulsing glow via new egg-tour-glow CSS animation
- Add reduced-motion support for new animation

FirstHatchTourModal component:
- Shows during show_hatch_modal/await_create_post steps
- Single mission: create a hatch post with the required phrase
- Continue button appears when post is detected
2026-04-01 17:42:12 -03:00
filemon c9823055fd Add first-hatch tour orchestration layer (state machine + activation)
New src/blobbi/tour/ module with:
- tour-types.ts: Generic TourStepDef/TourState/TourActions types, plus
  FirstHatchTourStepId enum and ordered FIRST_HATCH_TOUR_STEPS array
- useFirstHatchTour: Step-based state machine with localStorage
  persistence, advance/goTo/complete/reset actions, and derived
  booleans (isStep, isAnyStep, currentStepDef) for UI consumption
- useFirstHatchTourActivation: Precondition guard that auto-starts
  the tour when: exactly 1 Blobbi, egg stage, no baby/adult, not
  yet completed
- Barrel index.ts exporting all types, hooks, and constants

No visual/UI changes yet -- this is the orchestration foundation
that rendering layers will plug into.
2026-04-01 16:33:57 -03:00
filemon d2cd5f22bf Merge branch 'main' into update-hatch-action 2026-04-01 15:54:34 -03:00
filemon b223a9c1f2 Remove egg maintain_stats task and hide companion button for eggs
Egg stats no longer decay, so the 'Keep Egg Healthy' dynamic task is
unnecessary and misleading. Remove it along with HATCH_STAT_THRESHOLD.
The baby/adult 'Peak Condition' evolve task is unchanged.

Also hide the 'Set as Companion' button entirely for eggs instead of
rendering it as disabled.
2026-04-01 15:03:29 -03:00
Derek Ross 2d1a3ff6f5 chore: update package-lock.json after rebase 2026-04-01 14:03:17 -04:00
Derek Ross 90bd10d87a fix: remove unused imports and variables in ArticleEditor 2026-04-01 14:01:46 -04:00
Derek Ross 280bcbd5ab fix: prevent Save Draft button wrapping to second line on mobile 2026-04-01 14:01:46 -04:00
Derek Ross 65ecfca05e fix: show bottom navigation bar on article editor page 2026-04-01 14:01:46 -04:00
Derek Ross 91f5afc110 fix: default logged-out users to global tab on kind-specific feed pages
Kind-specific pages (articles, photos, videos, etc.) clamped the feed tab
to 'follows' for all users, but the follows query requires a logged-in
user. Logged-out users saw infinite skeleton loading with no way to switch
tabs. Now defaults to 'global' when no user is present.
2026-04-01 14:01:46 -04:00
Derek Ross 1c980fb039 refactor: simplify article editor to New/My Articles tabs with inline metadata
Remove Details tab and Save header icon. Metadata (image, summary, slug,
tags) now sits inline between title and editor body like Medium. Save Draft
button moved to bottom of compose form. Header tabs renamed to New and
My Articles.
2026-04-01 14:01:46 -04:00
Derek Ross e93c665123 feat: add in-app article editor with Milkdown WYSIWYG
Replace external Inkwell link with a built-in article creation experience.
Uses Milkdown editor with tabbed UI (Write/Details/Drafts) matching the
letters compose pattern, FAB publish button, relay+local draft support,
and kind 30023/30024 publishing.
2026-04-01 14:01:46 -04:00
Alex Gleason 6be49ec14a Merge branch 'hide-all-status' into 'main'
Centralize stat visibility threshold (<70) across all Blobbi stages

See merge request soapbox-pub/ditto!149
2026-04-01 17:58:18 +00:00
Alex Gleason 793b408e3f Fix encrypted letter envelope to show back (mailing) side first
The envelope previously showed flap-like V-fold lines on both sides,
which doesn't match how real envelopes work. Now the default view
shows the back/mailing side with sender name top-left and recipient
name centered, then flipping reveals the front with the triangular
flap and wax seal, and clicking opens the flap to reveal the Nushu
ciphertext.
2026-04-01 12:56:23 -05:00
filemon 213e8abf28 Merge branch 'main' into hide-all-status 2026-04-01 14:52:39 -03:00
filemon bac2f3a5c7 Centralize stat visibility threshold (<70) across all Blobbi stages
Move the hardcoded < 70 stat visibility checks from BlobbiPage.tsx into
the shared getVisibleStatsWithValues() utility in blobbi-decay.ts. This
ensures egg, baby, and adult stages all use the same STAT_VISIBILITY_THRESHOLD
constant, and any future UI consuming visibleStats gets the filtering for free.
2026-04-01 14:47:07 -03:00
Alex Gleason 1e38d9d2a2 Add clientName to Zod schema and document AppConfig update process 2026-04-01 12:25:45 -05:00
Alex Gleason 419c1ceb48 Add clientName config option for the NIP-89 client tag 2026-04-01 12:06:25 -05:00
Alex Gleason f6bde5871a Remove dead EGG_DECAY constants since egg stage no longer decays 2026-04-01 11:54:29 -05:00
Alex Gleason 9c56a7f987 Revert "Fix unused EGG_DECAY variable eslint errors by prefixing with underscore"
This reverts commit 2e8efab2aa.
2026-04-01 11:53:03 -05:00
Alex Gleason 2e8efab2aa Fix unused EGG_DECAY variable eslint errors by prefixing with underscore 2026-04-01 11:52:08 -05:00
Alex Gleason 0f45ce743f Consolidate 3 dev buttons into a single dropdown menu 2026-04-01 11:36:30 -05:00
Alex Gleason 7794cd5dbd Disable stat decay for egg stage and reset all stats to 100 on hatch 2026-04-01 11:31:58 -05:00
Alex Gleason c0cb6454ac Only show Health, Hygiene, and Happy gauges when value is below 70% 2026-04-01 11:27:33 -05:00
Alex Gleason 2f45a9bbf5 Replace Users icon with Egg icon for Blobbies button and dialog 2026-04-01 11:22:54 -05:00
Alex Gleason 11a61322e8 Remove unused isFetching prop from BlobbiDashboard 2026-04-01 11:17:44 -05:00
Alex Gleason cb1bc1a865 Add tap-to-wiggle interaction on Blobbi eggs
Clicking any egg triggers a playful rock-and-hop animation (0.6s)
that wobbles side to side with a small upward jump, then settles.
Uses CSS animation with onAnimationEnd to auto-reset state.
Respects prefers-reduced-motion and doesn't interrupt cracking.
2026-04-01 11:14:30 -05:00
Alex Gleason 622cb14813 Make bottom bar flow inline below stats, remove stage badge, update loading skeleton 2026-04-01 11:03:35 -05:00
Alex Gleason 4afea98e77 Align bottom bar max-width with content container after card removal 2026-04-01 10:58:46 -05:00
Alex Gleason 79f3cc85dd Merge branch 'implement-reactions-clean' into 'main'
Implement Blobbi XP Progression, Interaction Features, and Refactors

See merge request soapbox-pub/ditto!147
2026-04-01 15:46:44 +00:00
Alex Gleason 4052f865c9 Address MR review: sanitize instanceId in eye-animation, add blobbi-xp tests
- Sanitize instanceId in eye-animation.ts with the same regex pattern
  used in svg/ids.ts for defense-in-depth consistency
- Add comprehensive unit tests for blobbi-xp.ts covering all pure
  functions: calculateActionXP, calculateInventoryActionXP, applyXPGain,
  getXPGainSummary, formatXPGain, getXPGainMessage, and XP constants
2026-04-01 10:44:23 -05:00
Chad Curtis 5887f790c6 Style expanded thread siblings with distinct connector line
Add threadedLineClassName prop to NoteCard to allow customizing the
connector line color. Revealed hidden siblings use bg-primary/30
to visually distinguish them from the main thread chain. Remove
bottom border from the expand thread button for seamless flow.
2026-04-01 03:46:04 -05:00
Chad Curtis 6fc5d3ed97 Add 'Show X more replies' for branching threads
When a reply has multiple children, only the first child renders
inline in the thread chain. Remaining siblings are hidden behind
a 'Show N more replies' button placed between the parent and
its inline child. Clicking reveals them as threaded items with
the connector line. Removes bottom border from the expand button
so it flows seamlessly in the thread.
2026-04-01 03:42:13 -05:00
Chad Curtis 0eaf30cd8b Fix threaded replies: only show first child inline, hide siblings
The linear threading UI (connector lines) only works for single chains.
When a reply had multiple children, siblings after the first rendered
without any visual connection to their parent, making them look like
top-level replies. Fix by only including the first child in each node's
thread chain — additional siblings are hidden since there is no UI to
display branching threads.
2026-04-01 03:36:26 -05:00
Chad Curtis f1d5e8d4ca Move broadcast button from overflow menu to Event JSON dialog 2026-04-01 02:47:24 -05:00
Chad Curtis 7763aa2e0a Add broadcast event option to overflow menu 2026-04-01 02:45:00 -05:00
Chad Curtis 500f06b538 DRY up drag-and-drop: refactor profile field reorder to use shared SortableList/SortableItem
- Refactor ProfileSettings SortableFieldRow to use SortableItem instead
  of manual useSortable/GripVertical/CSS.Transform boilerplate
- Replace inline DndContext/SortableContext with SortableList wrapper
- Add gripClassName prop to SortableItem for width customization
  (w-6 h-9 for profile fields, default w-8 for badges/sidebar)
- Add space-y-3 to SortableList in profile fields for row padding
- Remove all direct @dnd-kit imports from ProfileSettings
- Remove unused onOpenCreate prop chain from MyBadgesTab
2026-04-01 02:37:27 -05:00
Chad Curtis 85227c2175 Polish My Badges tab: fix scroll areas, overflow menus, clean up chrome
- Fix ScrollArea to use fixed h-[24rem] with content inside (loading,
  empty, list) matching the lief sticker management pattern
- Remove background boxes from all badge rows and scroll containers
- Remove glowing border from pending badge area
- Extract shared BadgeOverflowMenu with View link for all badges,
  Award/Edit/Delete only shown for badges you created
- Replace inline action buttons on created badges with overflow menu
- Add rounded-full hover on pending nav arrows, strokeWidth 4
- Remove redundant New Badge button from Created section (FAB exists)
2026-04-01 02:29:56 -05:00
Chad Curtis a570b318d7 Revamp My Badges tab: draggable badge order, scroll areas, and pending carousel
- Extract reusable SortableList/SortableItem components sharing the same
  @dnd-kit pattern used by the sidebar edit view (DRY)
- Replace ChevronUp/ChevronDown reorder buttons with drag-and-drop on
  accepted badges list
- Wrap accepted and created badge sections in ScrollArea (max-h 420px)
- Redesign pending badges as a carousel showing one badge at a time in
  the notification-style BadgeContent presentation (rotating rays, 3D
  tilt), with left/right arrows to navigate the pending queue
2026-04-01 02:16:14 -05:00
Chad Curtis 99e32d9491 Fix followers/following modal staying open after navigating to a profile 2026-04-01 02:01:28 -05:00
Chad Curtis 74022e8181 Render full reply threads on post detail page
Replace the flat reply list (showing one sub-reply hint per reply) with
a recursive threaded tree on the post detail page. Threads deeper than
3 levels collapse behind a 'Show N more replies' button that expands
the subtree in-place.

Also fix useReplies to fetch iteratively — some clients only tag the
immediate parent in e-tags, not the thread root, so a single query
misses deeper replies. The hook now discovers the full tree by querying
for replies to each new batch of event IDs (up to 5 rounds).

Other pages (profile wall, external content, badges) keep the existing
flat preview via FlatThreadedReplyList.
2026-04-01 01:55:01 -05:00
Alex Gleason d0b5164e6d Deploy nsite as named site 'ditto' and drop publish-relay-list 2026-03-31 21:01:47 -05:00
Alex Gleason defc39c0f3 Replace GitLab Pages deploy with nsite deploy via nsyte
Use nsyte CLI with NIP-46 nbunksec bunker credential to deploy
the web app to nsite on every default branch push. Downloads the
nsyte binary, builds the Vite app, and uploads to configured
Blossom servers and Nostr relays with SPA fallback routing.
2026-03-31 20:29:29 -05:00
filemon a9844b3a4f fix(lint): remove unused gaze state variable in useBlobbiCompanionGaze 2026-03-31 22:12:25 -03:00
Alex Gleason 77b8498850 npm audix fix 2026-03-31 20:00:22 -05:00
filemon 4c34aba66d fix(blobbi-page): restore page-specific sleep toggle and sleeping recipe overlay
Part A — Restore BlobbiPage handleRest:
- Revert handleRest to the original blobbi-specific implementation that
  operates on the page-selected companion (via selectedD/companionsByD),
  not profile.currentCompanion. This ensures the BlobbiActionsModal
  sleep/wake button targets the correct Blobbi.
- The companion floating button continues to use useBlobbiSleepToggle
  independently (targets profile.currentCompanion). These are separate
  and correct targets for their respective contexts.
- Restore imports: KIND_BLOBBI_STATE, updateBlobbiTags, applyBlobbiDecay,
  trackDailyMissionProgress, getStreakTagUpdates.

Part B — Apply sleeping recipe overlay on BlobbiPage:
- Keep useStatusReaction enabled during sleep (was disabled with
  enabled: !isSleeping). Body effects (dirty, stink) and extras (food
  icon) still resolve while sleeping.
- Apply buildSleepingRecipe(rawStatusRecipe) when isSleeping is true,
  same pattern as BlobbiCompanionLayer. This overlays closed eyes,
  sleeping mouth, and Zzz while preserving compatible status effects.
- Suppress actionOverride during sleep (no happy/excited flash).
- Remove opacity-80 dim on sleeping Blobbi container (sleeping visuals
  are now expressed through the recipe, not opacity).

Part D — Sleepy vs sleeping verified:
- sleepyBlink (drowsy cycling animation) and sleepingClosed (permanent
  eye closure) are separate EyeRecipe fields that never overlap.
- buildSleepingRecipe never sets sleepyBlink; status reactions never
  set sleepingClosed. The guard in applyVisualRecipe (line 894) skips
  sleepingClosed when sleepyBlink is present, but this case never
  occurs in practice.
2026-03-31 21:58:23 -03:00
filemon 2bf4ed2af8 Merge branch 'main' into implement-reactions-clean 2026-03-31 21:45:48 -03:00
filemon 5afeac3c14 refactor(sleeping): recipe overlay instead of SVG swap, unify sleep paths
Part A — Remove sleeping SVG asset swap:
- Both renderers now always use the base (awake) SVG and run the full
  visual pipeline (eye animation, recipe, body effects). The isSleeping
  gate and sleeping SVG variant selection are removed.
- Sleeping visuals are achieved through the recipe system: permanently
  closed eyes via clip-path closure, closed-eye line overlays, sleeping
  mouth, and animated Zzz — all injected by applySleepingClosedEyes()
  in applyVisualRecipe() when recipe.eyes.sleepingClosed is set.
- Delete sleeping-animation.ts (dead code from previous approach).
- Remove opacity-70 dim on sleeping containers.

Part B — Sleeping as recipe overlay with selective coexistence:
- Add sleepingClosed field to EyeRecipe for permanently closed eyes
- Add buildSleepingRecipe() that takes a status recipe and produces a
  sleeping variant: overrides eyes/mouth/eyebrows, preserves body
  effects (dirty smudges, stink clouds) and food icon, strips drool/
  tears/watery eyes/dizzy spirals
- BlobbiCompanionLayer keeps useStatusReaction enabled during sleep
  (was previously disabled), applies buildSleepingRecipe overlay on
  top so body effects still render while the face shows sleeping state
- Action overrides are suppressed during sleep

Part C — Unify sleep action paths:
- BlobbiPage.handleRest now delegates to useBlobbiSleepToggle (same
  hook used by the companion radial menu), ensuring identical event
  publish, cache update, and companion state propagation regardless
  of which UI triggers sleep
- Fix useBlobbiSleepToggle cache update: use getQueriesData with
  partial key matching to find all blobbi-collection cache entries
  for the user, then setQueryData on each with exact keys. This
  ensures the optimistic update reaches the correct cache entry
  that useBlobbiCompanionData reads from
- Remove unused imports from BlobbiPage (KIND_BLOBBI_STATE,
  updateBlobbiTags, applyBlobbiDecay, trackDailyMissionProgress,
  getStreakTagUpdates) that were only used by the old handleRest
2026-03-31 21:32:18 -03:00
filemon 39e3c0b30f fix(companion): implement sleeping visuals, standalone sleep action, fix state types
Part A — Sleeping visuals:
- Add sleeping-animation.ts with CSS keyframe animations for the
  pre-baked sleeping SVG assets: Zzz text floats with staggered delays,
  body gently breathes via scaleY pulse
- Both BlobbiBabySvgRenderer and BlobbiAdultSvgRenderer now call
  applySleepingAnimation() in the isSleeping path instead of returning
  the raw static colorizedSvg

Part B — State propagation:
- Tighten CompanionData.state from 'string | undefined' to
  'BlobbiState | undefined' so the sleeping state is type-safe through
  the full chain: parseBlobbiEvent -> useBlobbisCollection ->
  useBlobbiCompanionData -> companionDataToBlobbi -> SVG renderers
- Remove the unnecessary 'as BlobbiState' cast in the adapter now that
  CompanionData.state is properly typed

Part C — Standalone companion sleep action:
- Add useBlobbiSleepToggle hook that independently fetches fresh event
  data from relays, applies decay, publishes the state change, and
  optimistically updates the TanStack cache. Works on any page without
  BlobbiPage being mounted
- Remove the toggleSleep registration plumbing from BlobbiActionsProvider
  and BlobbiActionsContext (ToggleSleepFunction type, toggleSleepRef,
  third parameter on useBlobbiActionsRegistration)
- BlobbiCompanionLayer now uses useBlobbiSleepToggle directly instead
  of reading toggleSleep from useBlobbiActions context
2026-03-31 20:47:00 -03:00
Alex Gleason d749718584 release: v2.2.7 2026-03-31 17:48:17 -05:00
Alex Gleason 922a66835a Fix Nushu script not rendering on Android by bundling Noto Sans Nushu font
Android system fonts don't include glyphs for the Nushu Unicode block
(U+1B170-U+1B2FF), causing the encrypted letter ciphertext to render as
empty boxes. Bundle @fontsource/noto-sans-nushu as a web font so the
glyphs render correctly on all platforms.
2026-03-31 17:44:30 -05:00
Alex Gleason 0d4a96e785 Fix zapstore publish: replace removed -y flag with --quiet
zsp v0.4.5 renamed the -y flag to --quiet. The old flag caused
the publish command to fail silently (exit 0 with usage printed
to stderr), so the CI job appeared to succeed.
2026-03-31 17:23:22 -05:00
Alex Gleason a3e10bc12b release: v2.2.6 2026-03-31 16:52:20 -05:00
Alex Gleason 49c482f2ba Allow scrolling Nushu text when container is too small
Switch from overflow-hidden to overflow-y-auto so the ciphertext can
be scrolled on small screens. The fade gradient becomes sticky so it
stays at the bottom of the visible area as a scroll hint.
2026-03-31 16:48:35 -05:00
Alex Gleason 0ad7a7892b Fix encryption notice overflow on mobile by truncating Nushu text
Make the inner letter sheet a flex column so the decorative rule and
'This message is encrypted' notice are always visible (shrink-0). The
Nushu text area takes the remaining space (flex-1 min-h-0 overflow-hidden)
with a bottom fade-out gradient mask when it overflows.
2026-03-31 16:46:46 -05:00
Alex Gleason 989b423714 Map base64 ciphertext 1:1 to first 64 Nushu characters
Each base64 symbol maps directly to a Nushu codepoint (U+1B170-1B1AF),
preserving the same information density as the original encoding rather
than reducing through an arbitrary modulo.
2026-03-31 16:44:30 -05:00
Alex Gleason 13f703a3ec Remove mail icon from envelope front face for cleaner sealed state 2026-03-31 16:41:08 -05:00
Alex Gleason aa7c8e038b Refine encrypted letter envelope: 3D tilt, curated Nushu, sizing
- Add useCardTilt hook for badge-style 3D hover/touch tilt effect
- Constrain envelope with max-w-md, centered with horizontal padding
- Replace dense Nushu encoding with curated set of simpler characters
  spaced with thin spaces for an elegant, sparse look
- Remove all hint text (flip/open/close) to invite curiosity instead
- Add 'This message is encrypted' with lock icon on the open state
- Use Lock icon import for the encryption notice
2026-03-31 16:38:56 -05:00
Alex Gleason 0469b6cec9 Display encrypted letters as interactive 3D envelopes with Nushu ciphertext
Register kind 8211 across the event rendering pipeline so encrypted
letters render as 3D interactive envelopes instead of raw ciphertext.
Back shows a sealed envelope with sender/recipient names in script font
and a wax seal avatar. Click flips the envelope (CSS 3D transform),
click again opens it to reveal the ciphertext rendered as Nushu
characters -- a real historical secret women's script from China.
2026-03-31 16:32:27 -05:00
filemon ef88ca4235 feat(companion): implement proper sleep/wake state, fix mobile tap interaction
- Fix sleep visuals on floating companion: companionDataToBlobbi adapter
  now passes through actual state and isSleeping instead of hardcoding
  'active'/false, so sleeping Blobbi renders closed eyes and Zzz

- Refactor companion sleep button as direct action: sleep/wake toggle
  is routed through BlobbiActionsProvider (toggleSleep registration)
  instead of the item-flow system. Companion menu button shows Wake up
  (sun emoji) when sleeping, Sleep (moon emoji) when awake

- Freeze companion movement during sleep: state machine respects
  isSleeping flag, clears all timers/targets, forces idle state.
  Float animation and sway CSS animation also disabled while sleeping.
  Blobbi stays parked exactly where sleep was triggered

- Fix mobile tap on companion: remove duplicate touch event handlers
  (touchstart/touchmove/touchend) that conflicted with pointer events.
  Pointer events handle mouse+touch+pen natively. Use containerRef for
  setPointerCapture instead of e.target for reliable cross-platform
  tracking. Remove preventDefault from pointerdown to avoid blocking
  browser touch-to-pointer synthesis
2026-03-31 12:32:48 -03:00
Chad Curtis 1adbe1c98a fix: add /remoteloginsuccess route for remote signer callback 2026-03-31 08:33:11 -05:00
Chad Curtis b97299ce0a fix: remove unused user and useCurrentUser left over from PostActionBar extraction 2026-03-31 08:25:58 -05:00
Chad Curtis 93eeffb1ad fix: remove dead ZapDialog and canZapAuthor imports left over from PostActionBar extraction 2026-03-31 08:22:14 -05:00
Chad Curtis 081ad9240f fix: move zap comment inside right column instead of pl-[52px] offset 2026-03-31 08:19:47 -05:00
Chad Curtis 7d3b92048b fix: load letter fonts so font picker options render in correct typeface 2026-03-31 07:50:38 -05:00
Chad Curtis 3c425a4e68 fix: badge detail and my badges UX improvements
- Move Award to… button inline with awarded count, right-aligned, styled as pill
- Accept Badge action moved to its own row below stats
- Always show organize buttons (move up/down, remove) on mobile in My Badges list
- Use Trash2 icon instead of X for remove badge button
2026-03-31 07:45:29 -05:00
Chad Curtis 4ae90080e8 refactor: extract PostActionBar and unify badge detail tab bar
- Extract shared PostActionBar component used by both PostDetailPage and BadgeDetailContent
- Replace badge detail inline reaction bar with PostActionBar (removes copy button, adds share + more)
- Replace badge detail hand-rolled sticky tab div with SubHeaderBar (pinned) for arc style and hide-on-scroll behaviour
- Add ARC_OVERHANG_PX spacer above tab content
2026-03-31 07:37:44 -05:00
Chad Curtis 2cdcd543a4 fix: only show safe-area padding on pinned SubHeaderBar when at top of viewport
Previously the safe-area padding was tied to navHidden, which fires after
just 8px of scroll — causing the spacer above profile tabs to appear while
the bar was still mid-page. Now a scroll listener checks the bar's actual
getBoundingClientRect().top against the measured safe-area-inset-top, so
the padding only appears once the bar has physically reached the top.
2026-03-31 07:21:23 -05:00
Chad Curtis 71f8ee0e16 fix: support accented and Unicode characters in hashtags
Replace /#\w+/g with /#[\p{L}\p{N}_]+/gu across all hashtag regexes
so that hashtags like #Bíblia and #verdade parse correctly. Affects
NoteContent, BioContent, ComposeBox, and PhotoComposeModal.
2026-03-31 06:48:36 -05:00
Chad Curtis 92634705b3 fix: notifications reply button navigates to /letters/compose instead of in-page 2026-03-31 06:45:10 -05:00
Chad Curtis 7aee4fe712 fix: navigate to /letters/compose instead of opening compose in-page
Reply button and FAB on LettersPage now navigate to the dedicated
/letters/compose route. The ?to= query param pre-fills the recipient
when replying to a received letter.
2026-03-31 05:31:23 -05:00
filemon 0e4ce974f0 fix(companion): remove baseLift from walking float offset
Complement to the wrapper-split fix — also remove the baseLift = -2
constant that biased the walking Y offset permanently upward.
2026-03-31 06:06:17 -03:00
filemon 4ddcee95d9 fix(companion): split float and sway wrappers to fix walking ground gap
Root cause: the CSS animation `animate-blobbi-sway` (blobbi-gentle-sway
keyframes) sets `transform: rotate(-2deg)` which **replaces** the entire
inline `transform` on the same element while the animation is active.
This dropped the `translateY(size * 0.12)` alignment shift (~13px) that
anchors the body to the ground, causing Blobbi to float above the shadow
during walking.

Fix: split the single wrapper into two nested divs:

  Float wrapper (outer): owns translateY + JS float offset (inline transform)
  Sway wrapper (inner):  owns CSS rotation animation only

The CSS keyframes now only override the sway wrapper's transform (which
has no positioning), while the float wrapper's translateY and float
offset remain unaffected. The SVG subtree stability is preserved —
MemoizedBlobbiVisual stays inside the sway wrapper with no changes.
2026-03-31 05:56:48 -03:00
filemon 4e1f7b6007 fix(blobbi): move flies to lower body, use soft smile for hungry mouth
Flies: Reposition all fly orbits (baby + adult) to the lower third of
the body, well below the face region. Orbits are tighter so flies stay
near the grimy lower body / feet area and never overlap eyes or mouth.

Hungry mouth: Replace round 'O' mouth with smallSmile at warning/high
severity. The round mouth read as surprise rather than hunger. A soft
smile pairs naturally with hopeful eyes and drool, reading as 'please
feed me'. Critical hunger still uses droopyMouth for the desperate
state. The priority system is unchanged — if another stat with higher
mouth priority contributes a round mouth, that still wins.
2026-03-31 05:38:58 -03:00
filemon 00f3deb5b2 redesign(blobbi): overhaul dirty effect with muddy smudges, odor wisps, and flies
Replace visually weak dirt marks with a deliberate, cartoon-style
dirty effect inspired by Tamagotchi/Pokémon status readability.

Dirt layer (on-body):
- Organic muddy smudge blobs in warm brown palette (replaces thin strokes)
- Small grime spots clustered near smudges for texture depth
- Subtle dusty haze patches for grimy tinting
- New intensity parameter controls opacity/density per severity

Smell layer (off-body):
- Wavy S-curve stink lines in muted sage green (replaces tiny clouds)
- Soft puff cloudlets with rise-and-fade animation
- Optional buzzing flies on elliptical orbits (critical severity only)
- Wisps are placed at body sides/top so they read as emanating outward

Severity escalation:
- Warning: light smudges (intensity 0.45), 2 faint wisps
- High: heavier grime (0.65), 3 wisps
- Critical: heavy grime (0.80), 4 wisps + 2 buzzing flies
2026-03-31 05:34:44 -03:00
filemon b8037c48e9 chore(blobbi): final validation pass — tighten comments, remove blank line noise
Regression review confirmed all flows intact. Minimal cleanup:
- Remove stray blank lines in SvgRenderer components
- Condense useExternalEyeOffset inline comments (replaced verbose
  per-line explanations with concise section labels)
2026-03-31 05:03:34 -03:00
filemon a3dfe25d13 Merge branch 'main' into implement-reactions-clean 2026-03-31 04:56:59 -03:00
filemon 50a834c4fc refactor(companion): reduce BlobbiCompanionLayer responsibility and remove dead gaze state
Cleanup pass with zero behavior changes.

Extracted from BlobbiCompanionLayer:
- DebugGroundOverlay: 76-line debug overlay moved to its own component
- useActionEmotionOverride: action emotion state + timer logic extracted
  into a focused hook, replacing the inline state/setTimeout/wrapper pattern

Removed dead code:
- gaze state from useBlobbiCompanionGaze return (internal state preserved
  for the hook's own mode-selection logic; only the unused external return
  field removed)
- gaze field from useBlobbiCompanion return and UseBlobbiCompanionResult
- GazeState import from useBlobbiCompanion (no longer in return type)
- gaze field from CompanionContextValue type (unused interface)
- companionRecipeProp / companionRecipeLabelProp identity aliases
- originalHandleItemUse unnecessary alias
- handleItemUseWithEmotion wrapper (replaced by inline triggerOverride call)

Clarified:
- BlobbiCompanionLayer docblock explains its orchestration-only role
- Section comments organize the wiring concerns (item reaction, action
  menu, item use, status reaction, render)
2026-03-31 04:31:47 -03:00
filemon f00332fca5 chore(blobbi): cleanup dead code and add architectural contracts
Stabilization pass — zero behavior changes.

Dead code removed:
- Unused containerRef in both SvgRenderer components (parent wrapper
  owns the DOM query boundary for eye hooks, not the renderer)
- Unused containerRef in BlobbiCompanionVisual
- Dead eyeOffset React state from useBlobbiCompanionGaze (only the ref
  is used now; the state was never updated after the ref-based fix)
- Dead eyeOffset value from useBlobbiCompanion return and
  BlobbiCompanionLayer destructure
- Deprecated AdultReactionState / BabyReactionState type aliases
  (no consumers)
- Deprecated ExternalEyeOffset re-exports from visual wrappers
  (canonical export is lib/types.ts)
- Stale JSDoc comment about containerRef forwarding in renderer

Contract comments added:
- SvgRenderer components: explicit MUST NOT list (no hooks, no modes,
  no reaction classes)
- Visual wrapper containerRef: explains it is the DOM query boundary
  for eye hooks
- MemoizedBlobbiVisual: stability contract listing what it must and
  must not depend on
- useExternalEyeOffset: clarified page vs companion usage for each
  offset prop
- BlobbiCompanionVisual direction prop: documented why it exists unused
2026-03-31 04:19:10 -03:00
filemon 384936f106 refactor(blobbi): extract pure SVG renderers and add explicit render mode
Architecture refactor for the Blobbi visual system:

1. Centralized debug helper (src/blobbi/ui/lib/debug.ts):
   - Replaces all scattered console.log/trace instrumentation
   - Single BLOBBI_DEBUG flag, only logs in DEV mode when enabled
   - Typed debug categories for filtering

2. Explicit render mode API (BlobbiRenderMode: 'page' | 'companion'):
   - Replaces implicit companion detection via eye offset prop sniffing
   - Controls tracking, reaction class suppression, and future behaviors
   - Default is 'page' — no changes needed for existing BlobbiPage callers

3. Pure SVG renderer extraction:
   - BlobbiAdultSvgRenderer: resolve → customize → animate → recipe → sanitize → innerHTML
   - BlobbiBabySvgRenderer: same pipeline for baby stage
   - These components know nothing about hooks, modes, or runtime state
   - Only rerender when visual content changes (blobbi, recipe, emotion, bodyEffects)

4. Visual wrappers simplified:
   - BlobbiAdultVisual/BlobbiBabyVisual own the containerRef, eye hooks,
     and reaction CSS classes — delegate SVG output to the renderers
   - ~480 lines removed across the visual layer

Net result: -305 lines, zero debug console spam, clean separation between
SVG pipeline, eye behavior, and companion runtime.
2026-03-31 03:53:17 -03:00
filemon 81966dac0d fix(companion): stabilize SVG DOM to prevent animation restarts
The companion rerender storm (~46 renders/2s from RAF loops) was causing
the animated SVG subtree to be replaced on every render, killing SMIL
and CSS animations (dizzy spirals, sleepy Zzz, etc.).

Three root causes fixed:

1. Ref-based gaze: eyeOffset was React state updated every frame in
   useBlobbiCompanionGaze, propagating rerenders through the entire
   companion tree. Now writes to a ref that useExternalEyeOffset reads
   imperatively via its own RAF loop — zero React rerenders for gaze.

2. Memoized SVG renderer: created MemoizedBlobbiVisual (React.memo)
   that only rerenders when visual content changes (blobbi, recipe,
   emotion, bodyEffects). Reaction CSS classes (sway/bounce) moved to
   an outer wrapper div in BlobbiCompanionVisual so className changes
   don't touch the dangerouslySetInnerHTML container.

3. Stable recipe references: resolveStatusRecipe() returned fresh {}
   objects for neutral state, defeating memo comparators. Now uses
   shared frozen EMPTY_RECIPE and NEUTRAL_STATUS_RESULT constants.
2026-03-31 03:39:06 -03:00
Chad Curtis 7c8e4f1735 Remove unused EventStats type import in PostDetailPage 2026-03-31 00:34:35 -05:00
Chad Curtis b9b9363468 Render kind 9735 zap receipts and kind 0 profiles in feed and detail pages
- Kind 9735: feed and detail cards mirror the reaction card layout exactly — zap icon bubble, sender avatar/name, 'zapped N sat(s)', timestamp (ml-auto), message indented under profile on the line below. Threaded variant included. Zap rows in InteractionsModal now link to the receipt nevent. ZapEntry gains eventId field.
- Kind 0: feed card renders ProfileCard inline; detail page renders ProfileCard directly with no action header
- CommentContext: add kind 0 (profile) and 9735 (zap) to KIND_LABELS and KIND_ICONS
- NoteCard KIND_HEADER_MAP: add kind 9735 zap header
- shellTitleForKind: 'Zap' for 9735, 'Profile' for 0
2026-03-31 00:24:02 -05:00
Chad Curtis 11ecfb1bcf Render kind 9735 zap receipts and kind 0 profiles in feed and detail pages
- Kind 9735: feed card shows sender, amount (pluralized sat/sats), message; detail page shows activity-style card with ancestor thread; zap rows in InteractionsModal now link to the receipt nevent instead of sender npub, and include eventId on ZapEntry
- Kind 0: feed card renders ProfileCard inline; detail page renders ProfileCard directly with no action header
- CommentContext: add kind 0 (profile) and 9735 (zap) to KIND_LABELS and KIND_ICONS
- NoteCard KIND_HEADER_MAP: add kind 9735 zap header
2026-03-30 23:53:57 -05:00
Alex Gleason 605f4e52fe release: v2.2.5 2026-03-30 22:53:56 -05:00
Alex Gleason a45e649374 Fix infinite re-render crash when dragging profile tabs in edit mode
Remove 'transform' from useLayoutEffect deps in SortableTabChip. During
a drag, useSortable produces a new transform object every frame, which
triggered onActive() -> SubHeaderBar re-render -> new transform ref ->
effect re-fires, causing React error #185 (maximum update depth exceeded).
The active indicator position only needs to update when the active tab
changes, not on every drag frame.
2026-03-30 22:48:31 -05:00
filemon 3f32c95b35 fix(blobbi): update eye helpers for nested gaze group structure
The eye injection and detection modules were broken after adding the
nested .blobbi-eye-gaze group. The issues were:

1. modifyEyeGroupContent used indexOf('</g>') which found the gaze
   group's closing tag instead of the eye group's, breaking effects
   that modify pupil/highlight content

2. injectIntoEyeTrackLayer used a naive regex that didn't handle
   nested groups

3. detectFromProcessedSvg had a rigid regex that required exact
   class order and couldn't handle the new nested structure

Fixes:
- Added findMatchingCloseTag() for balanced group parsing
- Added findGroupByClass() helper for finding group boundaries
- Updated modifyEyeGroupContent to target .blobbi-eye-gaze (innermost)
- Updated injectIntoEyeTrackLayer to use balanced parsing
- Updated detectFromProcessedSvg with flexible class matching
- Updated documentation to reflect new 3-layer eye structure:
  1. .blobbi-blink (outer) - clip-path for eyelid animation
  2. .blobbi-eye (middle) - CSS animations like sleepy wake-glance
  3. .blobbi-eye-gaze (inner) - gaze tracking transforms

This allows eye effects (sad highlights, star eyes, etc.) to work
correctly with the new gaze/animation layer separation.
2026-03-30 19:50:44 -03:00
Alex Gleason 2919bdf691 release: v2.2.4 2026-03-30 17:22:15 -05:00
filemon 6192dfc568 refactor(blobbi): separate eye animation and gaze layers to prevent conflicts
Previously, both CSS animations (like sleepy wake-glance) and JS gaze
tracking targeted the same .blobbi-eye group. This caused conflicts where
external gaze had to disable CSS animations to control the transform.

Now the eye structure has three layers:
1. .blobbi-blink (outer) - clip-path for eyelid/blink animation
2. .blobbi-eye (middle) - CSS animations like sleepy wake-glance
3. .blobbi-eye-gaze (inner) - JS-controlled translate for gaze tracking

This separation allows:
- Sleepy's wake-glance CSS animation to run on .blobbi-eye
- External gaze and mouse tracking to control .blobbi-eye-gaze
- Both effects to work together without disabling either
- Eyelid clip-path animation to remain independent

Changes:
- eye-animation.ts: Added nested .blobbi-eye-gaze group inside .blobbi-eye
- eyes/types.ts: Added gazeLeft, gazeRight, gaze to EYE_CLASSES
- useBlobbiEyes.ts: Updated to target .blobbi-eye-gaze for tracking
- useExternalEyeOffset.ts: Updated to target .blobbi-eye-gaze, removed
  animation disabling hack
2026-03-30 19:19:56 -03:00
filemon de57399301 Merge branch 'main' into implement-reactions-clean 2026-03-30 19:11:31 -03:00
filemon c6e6326b50 fix(blobbi): allow external gaze to override sleepy eye animation
The sleepy emotion uses a CSS animation (sleepy-wake-glance) on .blobbi-eye
elements that applies transform: translateX() for a periodic side-glance.
Previously, useExternalEyeOffset detected this animation and yielded to it,
causing eyes to stop tracking gaze entirely during sleepy.

Now, when external gaze is active and a CSS animation is detected on eye
elements, we disable the animation and take control of the transform. This
allows:

- External gaze tracking to work during sleepy emotion
- Sleepy's eyelid closing animation (SMIL on clip-path) to continue
- The drowsy heavy-lidded effect to layer with gaze tracking

The key insight is that sleepy has two visual effects:
1. Eye position animation (CSS transform) - now disabled for external gaze
2. Eyelid closing animation (SMIL clip-path) - preserved for drowsy look
2026-03-30 19:06:49 -03:00
Alex Gleason cf59b6d0da Fix crash on /notifications from malformed badge award a-tags
Validate that the pubkey extracted from a kind 8 badge award event's
a-tag is a valid 64-char hex string before passing it to
nip19.naddrEncode(). Malformed pubkeys (from permissionless Nostr
events) caused hexToBytes() to throw 'Invalid byte sequence'.
2026-03-30 16:57:25 -05:00
filemon d836b1f068 fix(blobbi): use RAF loop in useExternalEyeOffset to prevent stuck eyes
The previous useEffect-based approach only applied eye transforms when the
externalEyeOffset prop changed. This caused eyes to get stuck in the center
when the companion was idle because:

- The gaze RAF loop updates eyeOffset state continuously
- React batches state updates, so re-renders may not happen every frame
- useBlobbiEyes also runs a RAF loop for blinking that could interfere
- SVG content changes (emotion recipes) could reset transforms

The fix uses a RAF loop that continuously applies the transform, reading
the latest offset from a ref. This ensures eyes stay positioned correctly
regardless of React render timing or SVG DOM changes.

Dragging previously 'fixed' the stuck eyes because isDragging changes
caused guaranteed re-renders that triggered the old useEffect.
2026-03-30 18:38:23 -03:00
Alex Gleason 6071a28dd9 Include all badge awards in deletion request when deleting a badge definition 2026-03-30 16:28:07 -05:00
filemon 03fa16ded2 fix(blobbi): fix companion motion/state desync and gaze RAF instability
BUGS FIXED:

1. Motion/State Desynchronization
   - useBlobbiCompanionState was receiving a hardcoded static motion object
     instead of real live motion data from useBlobbiCompanionMotion
   - This caused state decisions (walking, idle, observation) to use stale
     position/dragging data, desyncing behavior from rendered position
   - FIX: Introduce shared motionRef that motion hook writes and state hook
     reads, solving the bidirectional dependency cleanly

2. Gaze Animation Loop Instability
   - The RAF effect for smooth eye movement depended on companionPosition,
     mousePosition, observationTarget, attentionPosition, entryInspectionDirection
   - Every position change caused the loop to be torn down and recreated
   - This caused jitter during movement and stuck eyes after entry animation
   - FIX: Use refs for all frequently-changing values, only depend on isActive
     to start/stop the loop. Loop reads fresh values from refs each frame.

3. Drag Detection in State Hook
   - Changed from motion.isDragging dependency (no longer available) to
     polling motionRef.current.isDragging via interval since refs don't
     trigger re-renders

ARCHITECTURE CHANGES:
- useBlobbiCompanion: Creates shared motionRef, passes to state and motion hooks
- useBlobbiCompanionMotion: Accepts optional sharedMotionRef, syncs motion to it
- useBlobbiCompanionState: Receives motionRef instead of motion object
- useBlobbiCompanionGaze: Uses refs for position/target values, stable RAF loop
2026-03-30 18:14:14 -03:00
Alex Gleason b8eb0a8549 Filter out malformed custom emoji reactions missing the emoji tag 2026-03-30 15:49:25 -05:00
filemon 5dac0214ea fix(blobbi): improve companion reaction animations and remove duplicate hooks
- Fix reaction logic in BlobbiCompanionVisual: now uses 'swaying' when walking
  instead of always returning 'idle' (previously dead code)
- Remove duplicate useBlobbiCompanion hooks from src/hooks/ and src/blobbi/core/hooks/
  (orphaned files, not imported anywhere)
- Verified lookMode='forward' does NOT block externalEyeOffset - eye tracking
  system correctly uses external offset when disableTracking is true
2026-03-30 16:57:01 -03:00
filemon 3ddb7c8ceb Merge branch 'main' into implement-reactions-clean 2026-03-30 16:35:29 -03:00
Alex Gleason 03d4b6c4f2 Include both 'e' and 'a' tags in deletion events for addressable events
NIP-09 deletion events for addressable events (kinds 30000-39999) now
include both an 'e' tag (event ID) and an 'a' tag (event coordinate)
to ensure deletion works on relays that only support one or the other.

- useDeleteEvent: accept optional pubkey/dTag params, auto-add 'a' tag
  for addressable kinds
- NoteMoreMenu: pass event pubkey and d-tag to useDeleteEvent
- BadgesPage: add missing 'e' tag to badge definition deletion
- useUserLists: add missing 'e' tag to list deletion
2026-03-30 13:47:05 -05:00
filemon 55b551f214 fix(blobbi): companion now updates reactively when stats change
ROOT CAUSE:
The companion layer was not updating live because:
1. It used a separate query ('companion-blobbi') that wasn't optimistically updated
2. It didn't use projected state, so it showed raw relay data without decay
3. Item use only invalidated queries without optimistic updates, causing relay latency

FIXES:
1. useBlobbiCompanionData now uses useBlobbisCollection (shared with BlobbiPage)
   - Shares the same query cache that gets optimistic updates
   - No longer has a separate stale query

2. useBlobbiCompanionData now applies projected state via useProjectedBlobbiState
   - Companion shows projected decay (recalculates every 60 seconds)
   - Same behavior as BlobbiPage

3. useBlobbiItemUse now optimistically updates the blobbi-collection cache
   - Uses setQueryData to immediately update the parsed companion
   - Companion visual updates instantly after actions
   - Also invalidates for background consistency check

DATA FLOW (after fix):
1. User performs action → useBlobbiItemUse publishes event
2. Optimistic update → setQueryData updates blobbi-collection cache
3. useBlobbisCollection returns new data → blobbi reference changes
4. useProjectedBlobbiState recalculates → projectedState changes
5. useBlobbiCompanionData creates new companion object with new stats
6. BlobbiCompanionLayer's companionStats memo recalculates
7. useStatusReaction sees new stats → resolves new recipe
8. Visual updates immediately

NO REMOUNT KEY NEEDED:
The fix works purely through React's normal reactivity:
- Object references change through the memo chain
- useStatusReaction's effect detects stat changes via reference comparison
- No forced remounts required
2026-03-30 15:38:53 -03:00
Alex Gleason 6fe17c1cfd Fix notifications collapsing multiple profile reactions from the same user
Profile reactions (kind 7 on kind 0) are intentionally allowed to be
multiple, unlike post reactions. Treat each profile reaction as a
standalone notification instead of grouping by referenced event ID,
which was causing only the latest reaction per user to be shown.
2026-03-30 13:19:00 -05:00
filemon 93ccb572e5 fix(blobbi): alternating spiral winding, body-aware food icon positioning
- Fix egg sick spiral winding: Inner 3 now uses clockwise=false for
  proper alternation across all 7 spirals (4 outer + 3 inner)
- Add body-aware food icon positioning for adults using detectBodyPath()
- Food icon now placed at upper-left relative to detected body bounds
- Update FoodIconConfig type to accept bodyPath for shape-aware placement
- Import detectBodyPath in recipe.ts for food icon positioning
2026-03-30 15:15:34 -03:00
Alex Gleason 03aa1e6dbc Fix oversized reaction emoji in comment context header 2026-03-30 13:03:25 -05:00
filemon 059fb67d26 fix(blobbi): layered egg spirals, natural adult dirt distribution, adult food icon
## Egg spiral layering:
- Added 7 spirals total (4 outer + 3 inner) for magical/dizzy effect
- Outer spirals: float around egg shell at varying distances
- Inner spirals: subtle spirals across the egg body itself
- Mixed colors: gray (#4b5563, #6b7280, #9ca3af) + white accents
- Varying sizes (0.45em to 1.1em), speeds (2s to 4s), directions
- All use true Archimedean spiral paths matching Blobbi dizzy eyes
- Counter-clockwise on left side, clockwise on right for visual balance

## Adult dirt distribution:
- Uses detected body bounds for natural placement
- Distributes across multiple zones (not clustered in center or edges):
  - Lower-left edge (primary)
  - Lower-right edge (primary)
  - Left-center lower area (secondary)
  - Right mid-lower contour (fill)
  - Left side contour (fill)
- Face region ends at 55% body height (all dirt below that)
- Mark length scales with body width (6% of width, min 3 units)

## Adult food icon position/size:
- Position: upper-left (x=55, y=45) instead of upper-right
- Size: 80% larger (scale=1.8) for better visibility
- Stroke width increased proportionally (1.5x)
- Higher opacity (0.75) vs baby (0.65)
- Baby unchanged: upper-right position, original size
2026-03-30 14:57:40 -03:00
filemon eec7f1d5b5 fix(blobbi): body-aware dirt placement, dizzy-style spirals, stronger front dust
## Adult dirt placement now uses real body silhouette:
- detectBodyPath() extracts full X/Y bounds from SVG path
- computeAdultDirtPositions() places marks relative to actual body
- Dirt at lower 35% of body height, near side edges
- Scales with body size (mark length = 5% of width)
- Fallback to conservative defaults if body not detected

## Egg spirals now match dizzy eye visual language:
- Uses same createSpiralPath() Archimedean spiral algorithm
- SVG-native animateTransform rotation (not CSS)
- Dark stroke color (#1f2937) matching dizzy eyes
- Positioned floating around egg, not inside shell
- Varying sizes and rotation speeds for visual interest

## Front/back dust distribution:
- Egg: 2 back particles below, 4 front particles at lower edges
- Baby: 3 back below body, 3 front at lower side edges
- Adult: 3 back below body, 3 front at lower edges (body-aware)
- Front dust: larger, higher opacity (0.75-0.8), darker color
- Back dust: smaller, lower opacity (0.55), lighter color
- All dust avoids face region, stays at lower body edges
2026-03-30 14:42:43 -03:00
filemon 5ab16fbbf3 fix(blobbi): reposition dirt marks to lower body edges, avoid facial zones
Protected zones (dirt marks NEVER appear here):
- Eyes, mouth, eyebrows
- Tears, saliva/drool, blush marks, sparkles
- Upper-center body area where face elements live

Preferred dirt placement zones:
- Lower-left edge of body silhouette
- Lower-right edge of body silhouette
- Bottom edge (well below face region)

Variant differences:
- Egg: dust at lower outer shell edges only, no center-front placement
- Baby (100x100): safe zone y > 72, prefer x < 35 or x > 65
- Adult (200x200): safe zone y > 120, prefer x < 85 or x > 115

Also updated dust particle positions to follow same rules.
2026-03-30 14:30:37 -03:00
filemon a74f7037ff fix(blobbi): variant-aware dirt marks, real spirals, and front-layer dust particles
- Add BlobbiVariant type to body effects system for coordinate scaling
- Separate dirt/stink positions for baby (100x100) vs adult (200x200) viewBox
- Pass variant through applyBodyEffects and applyVisualRecipe pipeline
- Replace egg sick curved paths with real Archimedean spirals using createEggSpiralPath()
- Add generateDustParticles() with front+back layer particles for stronger dirty read
- Increase dust particle opacity and use darker colors for visibility
- Add front-layer dirty particles to egg statusEffects
2026-03-30 14:14:32 -03:00
filemon 18cf251c7e feat(blobbi): add egg status effects and improve adult dirt placement
## Egg Form Visual Effects

### 1. Dirty State (new)
- Sweat droplet near upper-left of egg (blue gradient, slides down animation)
- Dust particles underneath the egg (gentle float-up animation)
- Triggered when recipe has dirtMarks or stinkClouds bodyEffects

### 2. Health/Sick State (new)
- Floating purple dizzy spirals around the egg (3 spirals, rotate animation)
- Replaces adult dizzy eyes since eggs don't have faces
- Triggered when recipe has dizzySpirals in eyes

### 3. Happy State (new)
- Golden sparkle stars around the egg (3 sparkles, twinkle animation)
- Simple 4-point or 8-point star shapes
- Triggered when reaction='happy' and no tears

### Implementation
- Added EggStatusEffects interface: { dirty, sick, happy }
- Props flow: BlobbiStageVisual → BlobbiEggVisual → EggGraphic
- Status effects derived from recipe in BlobbiStageVisual
- All animations respect prefers-reduced-motion

## Adult Form Dirt Placement

### Problem
Dirt marks were appearing outside the Blobbi body silhouette.

### Solution
- Repositioned dirt marks to be centered within body area
- X range: 42-56 (was 35-55) - more centered
- Y range: 55-78 (was 72-80) - better vertical spread
- Added positions for count=4-5 for severity escalation
- Reduced stroke width (1.3 vs 1.5) and opacity (0.55 vs 0.6) for subtlety
- Stink clouds also recentered (x: 44-56 vs 38-62)

New files/exports:
- EggStatusEffects type exported from @/blobbi/egg
- 4 new CSS animations: egg-sweat-drop, egg-dust-particle, egg-spiral, egg-sparkle
2026-03-30 13:46:31 -03:00
filemon 5de5488b24 fix(blobbi): critical health mouth overrides sleepy mouth priority
When health is critical, the dizzy round mouth now wins over sleepy mouth
regardless of energy being low. This ensures severe states read as
'urgent/sick/disoriented' rather than just 'tired'.

## New mouth precedence rule
Critical health bypasses the normal MOUTH_PRIORITY list entirely.
The check happens before pickPart() for mouth resolution.

## Scenarios where critical-health mouth now wins
- health critical + energy low → dizzy mouth (was: sleepy mouth)
- health critical + energy low + hunger low → dizzy mouth
- health critical + everything low → dizzy mouth
- any scenario with health=critical → dizzy mouth guaranteed

## Scenarios where sleepy mouth still wins
- energy low + health normal/warning/high → sleepy mouth
- energy low + any other stats (no critical health) → sleepy mouth
- ordinary tiredness without severe illness → sleepy mouth

The exception is minimal: one conditional check before normal priority
resolution, documented in both MOUTH_PRIORITY and inline comments.
2026-03-30 13:36:33 -03:00
filemon 83887b0516 docs(blobbi): cleanup pass for expression system consistency
## A) Fixed outdated/contradictory comments
- ENERGY_PARTS: Clarified that lower cycleDuration = heavier eyelids (not 'slower')
- MOUTH_PRIORITY: Updated doc to reflect hunger's severity progression (round→droopy)
- resolveStatusRecipe(): Updated example compositions to match current behavior
- recipe.ts module doc: Clarified two pathways (presets vs status-driven)

## B) Aligned EMOTION_RECIPES presets with status-driven behavior
- hungry preset: Updated to match 'high' severity (mouth 3.5x4.5, brows -14°)
- dirty preset: Updated to match 'high' severity (grimace 0.8/0.2, brows +10°)
- Added documentation explaining presets align with high/critical severity

## C) Drool semantics decision: kept hunger-driven
- Drool remains semantically tied to hunger (salivating for food)
- No other stat has natural reason to produce drool
- Architecture already supports it; no changes needed
- Added clarifying comment documenting this decision

## D) Validated multi-stat combinations
All tested combinations produce natural pet-like expressions:
- Single stats: Each has distinct, readable expression
- Multi-stats: Priority rules produce sensible compositions
- Extras additive: Multiple stats can contribute drool + tears
- Body effects: Dirt/stink shows regardless of facial expression

No priority changes needed — current rules work well.
2026-03-30 13:32:03 -03:00
Alex Gleason ec24c4cfae Add zap option to profile 3-dots menu
Move zap functionality into the ProfileMoreMenu so users with a
lightning address can still be zapped. The menu row opens the ZapDialog
after the more menu closes via a hidden trigger ref.
2026-03-30 11:25:03 -05:00
filemon 002461e7cb refactor(blobbi): refine expression quality, severity behavior, and drool positioning
## Hunger Progression
- warning: hopeful/asking (small round 'ooh' mouth, mild pleading brows)
- high: needy (bigger round mouth, more worried brows)
- critical: weak/desperate (droopy pleading mouth, very worried brows)

Hunger now feels like genuine plea progression from 'ooh, food?' to 'please...'
to 'I'm so hungry...' rather than same expression at all levels.

## Health Eye Priority
- Only CRITICAL health claims eyes (dizzy spirals)
- Warning/high health no longer override sadness/hunger eyes
- This lets sad/hungry eyes show through when health is merely warning/high

## Severity Escalation (all stats)
Each stat now has documented severity escalation:
- energy: sleepy → heavier sleepy → very drowsy (slower blink cycles)
- hunger: asking → needy → desperate (mouth shape progression)
- happiness: down → sad → crying (eye wetness + tears progression)
- hygiene: uncomfortable → gross → very gross (grimace + dirt escalation)
- health: weak → sick → dizzy (only critical gets dizzy spirals)

## Drool Positioning Fix
- Added computeDroolAnchor() to calculate drool position based on final mouth shape
- Added generateDroolAtAnchor() to render drool at computed anchor
- Drool now correctly attaches to roundMouth, droopyMouth, sadMouth edges
- Previously drool used original mouth position, looked detached with some shapes

## Priority Order (unchanged)
Eyes: health(critical) > energy > happiness > hunger > hygiene
Mouth: energy > health > happiness > hunger > hygiene
Eyebrows: health > hunger > happiness > hygiene > energy
2026-03-30 13:21:47 -03:00
Alex Gleason d12e75ae5c Replace zap button with emoji reaction button on user profiles
Add ProfileReactionButton component that opens an emoji picker to send
kind 7 reactions to a user's profile with a, e, and p tags. Update
notifications to display 'reacted to your profile' for profile reactions
and skip rendering the referenced card for kind 0 events.
2026-03-30 11:06:32 -05:00
filemon a480379fa5 refactor(blobbi): refine emotion presets and part contributions for natural pet-like expressions
- Update EMOTION_RECIPES with better documentation and design principles
- Add design comments explaining each preset's purpose and feeling
- Refine hungry preset: pleading/hopeful (not sad) with round anticipating mouth
- Refine dirty preset: uncomfortable/irritated grimace with furrowed brows
- Refine dizzy preset: add distressed raised brows for urgency
- Update ENERGY_PARTS: emphasize relaxed drowsiness, no forced brows
- Update HEALTH_PARTS: weak/unwell feeling distinct from sadness
- Update HUNGER_PARTS: pleading expression with open 'ooh food?' mouth
- Update HYGIENE_PARTS: irritated grimace, slightly furrowed brows
- Update HAPPINESS_PARTS: genuine emotional sadness with progressive tears

Each stat now has a distinct visual 'personality' that creates empathy:
- Hunger evokes nurturing (hopeful, pleading)
- Energy evokes tiredness (drowsy, fading)
- Health evokes concern (weak, unwell)
- Hygiene evokes discomfort (irritated, 'I feel gross')
- Happiness evokes emotional connection (genuine sadness)
2026-03-30 12:42:47 -03:00
filemon c37d0d15a6 feat(blobbi): part-priority status reaction system for natural expressions
Replace the old 'single winning preset' approach in resolveStatusRecipe()
with a part-priority composition system. Each low stat now contributes
independently to eyes, mouth, eyebrows, extras, and bodyEffects.

Architecture:
- Each stat has a PartContributionResolver that returns what it contributes
  to each facial/body part at each severity level (warning/high/critical).
- Exclusive parts (eyes, mouth, eyebrows) use per-part priority lists to
  decide which stat wins that slot when multiple stats are low.
- Additive parts (extras, bodyEffects) merge contributions from all low
  stats simultaneously.

Part priority rules:
- Eyes: health(critical/dizzy) > energy(sleepy) > happiness(watery) > hunger
- Mouth: energy(sleepy) > health(sad/round) > happiness(sad) > hunger(droopy)
- Eyebrows: health > hunger(worried) > happiness(lowered) > hygiene(flat)
- Extras: additive — drool+food(hunger), tears(happiness), all coexist
- BodyEffects: additive — dirt+stink(hygiene), anger-rise, all coexist

Severity escalation examples:
- Happiness tears only appear at high/critical, not warning
- Health switches from sad face to dizzy spirals at critical
- Hunger droopiness and dirt mark counts scale with severity
- Happiness eye water fill only at critical (full crying)

Combined stat examples:
- hunger + hygiene: hungry eyes, droopy mouth, worried brows, drool +
  food icon, dirt + stink clouds
- energy + hunger: sleepy eyes, sleepy mouth, hungry eyebrows, drool +
  food icon
- health(critical) + energy: dizzy eyes (beats sleepy), sleepy mouth,
  health eyebrows
- all stats low: prioritized eyes/mouth/brows, additive drool + tears

Also adds 'sick' and 'dirty' entries to LABEL_CYCLE_DURATIONS in the
hook to match the new stat-based label format.
2026-03-30 12:33:19 -03:00
filemon 79ccfd661a refactor(blobbi): final recipe-first consistency pass
- Rename emotionName → recipeLabel in applyVisualRecipe() signature and
  update SVG class names from blobbi-emotion to blobbi-recipe, since the
  parameter carries a recipe label (e.g. 'hungry-sleepy'), not strictly
  an emotion name.

- Guard against bodyEffects double-application in BlobbiAdultVisual and
  BlobbiBabyVisual: skip the manual applyBodyEffects() call when a
  recipeProp is provided, since applyVisualRecipe() already applies
  recipe.bodyEffects internally. The manual bodyEffects prop remains
  available for non-recipe use cases only.

- Update module-level docs in recipe.ts, status-reactions.ts, and
  useStatusReaction.ts to consistently describe the recipe-first
  architecture without leftover emotion-layer terminology.

- Remove unused BodyEffectConfig import from recipe.ts (pre-existing
  eslint error).
2026-03-30 12:14:40 -03:00
Alex Gleason 67e8c23020 release: v2.2.3 2026-03-30 09:59:34 -05:00
Alex Gleason 94f0c8308d Show all sidebar items to logged-out users and update sidebar order
Stop filtering requiresAuth items from navigation. Pages already render
their own LoginArea when the user is not logged in, so hiding the items
from the sidebar, mobile drawer, and search prevented feature discovery
without providing any benefit.

Also update the default sidebar order: remove bookmarks and profile,
add letters.
2026-03-30 09:56:02 -05:00
filemon c77b68eed2 fix(blobbi): eliminate body effects duplication, fix stat recovery, improve merged label timing
- Remove bodyEffects from StatusRecipeResult and useStatusReaction output.
  Body effects are folded into recipe.bodyEffects by resolveStatusRecipe()
  and applied once by applyVisualRecipe(). No separate channel needed.

- Fix stat recovery logic: re-resolve via resolveStatusRecipe() on every
  stat change instead of forcing neutral when previous triggering stat
  recovers. If energy recovers but hunger is still low, the hook now
  correctly transitions to the hungry recipe instead of neutral.

- Fix getRecipeCycleDuration() for merged labels (e.g. 'boring-sleepy'):
  compute Math.max() of all matching durations instead of returning the
  first match.

- Update all consumers (BlobbiPage, BlobbiCompanionLayer) to stop
  destructuring/passing bodyEffects from status reaction output.

- Update doc comments across visual components to clarify that the
  bodyEffects prop is for manual/external use only, not for status
  reaction data.
2026-03-30 11:52:00 -03:00
Chad Curtis 80820ae9c4 Expand compose textarea smoothly as you type 2026-03-30 07:36:16 -05:00
Chad Curtis 5288b7a718 Letters: overflow menu, reply button, grid layout, and UX polish
- EnvelopeCard: add ... overflow menu trigger using NoteMoreMenu (no duplicated logic)
- NoteMoreMenu: detect NIP-44 ciphertext by content shape, show 'Encrypted content'
- LetterDetailSheet: reply button (InkPenIcon, primary colors); gift above card; letter centered in viewport; click outside closes
- LettersPage: reply pre-fills sender npub; default stationery falls back to parchment when no custom theme; 2-col mobile / 4-col desktop grid
- NotificationsPage: reply button next to 'View all letters' in letter notifications
- ComposeLetterSheet: auto-focus textarea on open; fix To field overflow on narrow screens
- InkPenIcon: new custom icon replacing PenLine on FAB and reply buttons
2026-03-30 06:21:50 -05:00
Chad Curtis 4643830512 Sync letter improvements from lief
- Make LetterContent.body optional; a letter requires a non-empty body
  or at least one sticker
- Replace colors:[]/flatMode with event-stripping: flat color moment =
  event field stripped from Stationery, removing the colors? field
- Remove edgeScale: stickers render at their stored scale value with no
  edge-proximity size reduction, matching lief and the NIP
- Fix sticker shrinking near card edges: add max-width:none to override
  Tailwind preflight max-width:100% on img/svg elements
2026-03-30 04:30:00 -05:00
Chad Curtis ef04de67c0 Improve notification rendering for badges and letters
- Add hideKindHeader prop to NoteCard, used by ReferencedNoteCard to
  suppress redundant action headers in repost/reaction/zap notifications
- Redesign badge award notification: show full BadgeContent showcase card
  with prominent rounded-pill Accept Badge button below
- Redesign letter notification: larger centered envelope (minimal mode
  hides name/timestamp), click opens LetterDetailSheet inline, View All
  Letters button below
2026-03-30 00:13:01 -05:00
Chad Curtis 5847cceba6 Auto-shrink stickers near card edges and clip overflow at rounded boundary
Stickers now scale down proportionally as their center approaches any
edge of the letter card, preventing them from overflowing the rounded
corners. A 5% drag buffer keeps sticker centers away from the very
edge. The sticker overlay uses pointer-events-none so the textarea
remains clickable underneath.
2026-03-29 23:53:20 -05:00
Chad Curtis f62b86027c Remove unused eslint-disable directive in LayoutContext 2026-03-29 22:59:41 -05:00
Chad Curtis 8e5018d3b2 Fix 3 letter bugs: allow drawing-only sends, fix sticker drag bounds, preserve theme events
- Allow sending letters with only stickers/drawings (no body text required)
- Fix sticker drag positioning by using cardRef instead of page container
  ref, so percentage coordinates map correctly to the card boundaries
- Preserve stationery source events (color moments/themes) in preferences
  so they embed as gift attachments in sent letters
- Accept sticker-only letters during decryption validation
2026-03-29 22:41:03 -05:00
Chad Curtis 8df17f5ae7 Fix top bar arc flash by deferring layout cleanup past Suspense boundary 2026-03-29 22:23:23 -05:00
Alex Gleason dd31ce681f Add Blobbi to default sidebar order after Badges 2026-03-29 22:05:31 -05:00
filemon fd9a963b27 refactor(blobbi): complete recipe-first architecture, remove secondaryEmotion
Finish the migration from emotion-name composition to final visual
recipe resolution throughout the rendering pipeline.

Key changes:
- New emotion-types.ts: neutral type file for BlobbiEmotion/BlobbiVariant,
  breaking the import cycle between recipe.ts and emotions.ts
- status-reactions.ts: resolveStatusRecipe() now returns a fully resolved
  BlobbiVisualRecipe directly (merging sleepy+boring etc. internally)
- useStatusReaction: tracks resolved recipe state, outputs recipe+recipeLabel
  instead of emotion+secondaryEmotion
- Visual components (Adult, Baby, Stage): accept recipe+recipeLabel prop
  for recipe-first rendering; emotion prop kept as convenience for presets
- Companion components: pass recipe directly, no more secondaryEmotion
- BlobbiPage: passes resolved recipe from useStatusReaction to visuals
- emotions.ts: removed applyMergedEmotion() and mergeVisualRecipes re-export
- mergeVisualRecipes() stays in recipe.ts as an internal utility only used
  by status-reactions.ts for combining low-stat recipes

secondaryEmotion is fully eliminated from the codebase (0 occurrences).
The rendering path is now recipe-first end-to-end.
2026-03-29 23:37:27 -03:00
filemon 672d252492 refactor(blobbi): replace monolithic emotion system with part-based visual recipe architecture
Introduce BlobbiVisualRecipe as the central type for composing Blobbi
expressions from independent parts (eyes, mouth, eyebrows, bodyEffects,
extras). Named emotions are now presets that resolve into part-based
recipes via resolveVisualRecipe().

Key changes:
- New recipe.ts with BlobbiVisualRecipe types, EMOTION_RECIPES, and
  applyVisualRecipe() rendering pipeline
- emotions.ts becomes a thin public API delegating to recipe.ts
- Remove base/overlay emotion stacking model from status-reactions.ts
  and useStatusReaction.ts in favor of single emotion + secondaryEmotion
  for recipe-level merging
- Visual components (Adult, Baby, Stage) now resolve and merge recipes
  in a single pass instead of calling applyEmotion() twice
- Companion components updated to use secondaryEmotion prop
- All existing emotion presets preserved with identical visual output
- Backward-compatible: applyEmotion() API unchanged, legacy type aliases
  provided for EmotionConfig and EMOTION_CONFIGS
2026-03-29 23:16:25 -03:00
filemon bc4e00520e feat(blobbi): use stable idPrefix for body effect SVG element IDs
- applyEmotion now accepts optional instanceId parameter (5th arg)
- instanceId is passed through to applyBodyEffects as idPrefix
- BlobbiAdultVisual and BlobbiBabyVisual now pass blobbi.id as instanceId
- Anger-rise clip paths and gradients now use blobbi.id for stable IDs
  (e.g., blobbi-anger-clip-abc123 instead of random suffix)
- Random fallback still exists when instanceId is not provided
- Same Blobbi instance now produces deterministic SVG output
2026-03-29 22:03:00 -03:00
filemon d777d1bc98 refactor(blobbi): make bodyEffects/apply.ts the single entry point for body effects
- emotions.ts now delegates all body effects to applyBodyEffects()
- Removed direct imports of detectBodyPath, generateAngerRiseEffect,
  generateDirtMarks, generateStinkClouds from emotions.ts
- emotions.ts now only imports applyBodyEffects and BodyEffectsSpec
- Added unique ID generation for anger-rise clip paths and gradients
  (prevents collisions when multiple Blobbis render on same page)
- Body effects are applied after face overlays via single applyBodyEffects call
- Anger-rise overlay is still inserted right after body path for z-ordering
2026-03-29 21:53:30 -03:00
filemon 4cd97124da refactor(eyebrows): centralize class names and form offsets
- Add EYEBROW_CLASSES constant with all CSS class names
- Add FORM_EYEBROW_OFFSETS map for owli/froggi adjustments
- Rename keyframe from 'eyebrow-bounce' to 'blobbi-eyebrow-bounce'
- Export both constants from index.ts
- No behavior change, same public API
2026-03-29 21:08:42 -03:00
Alex Gleason 74345fdb2f Fix feed gaps when replies are disabled by over-fetching from relay
When followsFeedShowReplies is false, the relay limit was PAGE_SIZE (15)
but client-side reply filtering could discard most events, leaving only
a few visible posts per page with large time gaps between them.

Apply the same over-fetch pattern already used by useProfileFeed:
- Request PAGE_SIZE * 3 events when reply filtering is active
- Use rawCount (pre-filter) for pagination termination so pages where
  all items are replies don't prematurely stop pagination
2026-03-29 18:51:20 -05:00
filemon 7e7abdee3d simplify sleepy mouth to direct replacement
- Sleepy mouth is now clearly documented as a canonical standalone shape
- Direct replacement: no morph, transition, or interpolation between mouth states
- Keeps MouthAnchor architecture for stable positioning
- Updated docs across mouth/, types, and emotions.ts to be consistent
- Removed any wording suggesting transitions or morphing
2026-03-29 20:49:42 -03:00
filemon 9ed2127494 Introduce MouthAnchor for stable sleepy mouth positioning
applySleepyMouth no longer calls detectMouthPosition internally.
Instead, the orchestrator derives a MouthAnchor from the original
neutral SVG during the detection phase and passes it through.

This makes sleepy mouth placement reliable regardless of what base
emotion mouth (round ellipse, frown path, droopy, etc.) was applied
before the sleepy overlay runs.

mouth/types.ts:
- Added MouthAnchor interface ({ cx, cy })

mouth/detection.ts:
- Added mouthAnchorFromDetection(detection): derives { cx, cy } from
  a MouthDetectionResult (center of startX..endX, controlY)

mouth/generators.ts:
- applySleepyMouth now takes (svgText, anchor: MouthAnchor) instead of
  detecting internally. No more dependency on detectMouthPosition from
  within the sleepy mouth path. generateSleepyMouth is unchanged.

emotions.ts:
- Detection phase now computes mouthAnchor alongside mouth and eyes
- applySleepyAnimation receives the anchor and passes it through
- The anchor is always from the original unmodified SVG
2026-03-29 20:28:30 -03:00
filemon 30608ae8ed Merge branch 'main' into implement-reactions-clean 2026-03-29 20:24:04 -03:00
filemon ae43014cf2 Replace sleepy mouth morph with canonical breathing mouth shape
Remove the old sleepy mouth behavior that morphed the current mouth path
(smile → U-shape → smile via SMIL path animation). Replace with a
dedicated sleepy mouth: a small filled ellipse with a subtle breathing
animation (gentle expand/contract cycle, 3s period).

What changed:

mouth/generators.ts:
- Added generateSleepyMouth(centerX, centerY): produces a canonical
  small round ellipse (rx=2.8, ry=3.2) with SMIL breathing animation
- Added applySleepyMouth(svgText): detects current mouth position,
  generates the sleepy mouth, replaces whatever mouth is present
- Removed applySleepyMouthAnimation (the old morph-based approach)

mouth/detection.ts:
- Added replaceCurrentMouth(svgText, newMouthSvg): finds any element
  with blobbi-mouth class (path or ellipse, self-closing or with
  children) and replaces it. Falls back to Q-curve path matching.
  This handles all mouth types: base smile, sad frown, round mouth,
  droopy mouth, and previously-animated mouths.

mouth/types.ts:
- Removed SleepyMouthAnimationConfig (no longer needed)

emotions.ts:
- applySleepyAnimation no longer takes a mouth parameter
- Calls applySleepyMouth(svgText) instead of the old morph function
- Sleepy eye behavior (clip-path SMIL, closed-eye lines, wake-glance
  CSS, Zzz text) is completely unchanged

The sleepy mouth is now a proper canonical mouth shape in the mouth/
module, positioned at the detected mouth center, independent of
whatever base emotion mouth was applied before it.
2026-03-29 20:14:09 -03:00
filemon ea8d3dd0f3 Extract real implementations into mouth/, eyebrows/, bodyEffects/ modules
Complete the modularization of the visual emotion system. Each subsystem
now owns its implementation rather than re-exporting from emotions.ts.

mouth/ (554 lines):
- detection.ts: marker-based + regex fallback mouth detection, replacement
- generators.ts: round, sad, droopy, big smile, small smile, drool, food
  icon, sleepy mouth morph animation
- types.ts: MouthPosition, MouthDetectionResult, shape configs
- index.ts: barrel exports

eyebrows/ (194 lines):
- generators.ts: eyebrow generation with per-eye overrides,
  variant/form offsets, animated bounce styles
- types.ts: EyebrowConfig, AnimatedEyebrowsConfig
- index.ts: barrel exports

bodyEffects/ (392 lines):
- generators.ts: body path detection, dirt marks, stink clouds,
  anger-rise effect (full implementation moved from emotions.ts)
- apply.ts: applyBodyEffects() composable applicator
- types.ts: BodyEffectConfig, BodyPathInfo, BodyEffectsSpec
- index.ts: barrel exports

eyes/ (unchanged):
- Already correctly modular. Confirmed no stranded eye code remains
  in emotions.ts — the legacy applySadEyeWaterFill/applySadEyeHighlights
  functions were dead code (superseded by eyes/effects.ts) and removed.

emotions.ts (2135 → 628 lines):
- Now a pure orchestrator: recipe definitions + composition logic
- Imports all implementations from subsystem modules
- Contains only: BlobbiEmotion type, EMOTION_CONFIGS recipes,
  applyEmotion() orchestrator, tear generation (cross-cutting overlay),
  sleepy animation coordination (cross-cutting, touches eyes + mouth),
  and deprecated re-exports for backward compatibility
- No more detection internals, SVG geometry generators, or effect
  implementations
2026-03-29 19:55:27 -03:00
Alex Gleason 02231ea1f9 Fix avatar shape flash by computing mask URL synchronously
The Avatar component was initializing maskUrl as '' and loading it in a
useEffect. Since hasCustomShape was true immediately, rounded-full was
removed on the first render, but the mask wasn't applied until after the
effect fired — causing a visible square flash for one frame.

getAvatarMaskUrl is already synchronous (renders emoji to canvas, caches
the data-URL), so compute it inline during render instead of deferring
to an effect. The mask is now applied on the very first paint.
2026-03-29 17:47:57 -05:00
Alex Gleason effc704613 Fix 'Cannot update component while rendering' warning in useLayoutOptions
useLayoutOptions was calling store.setOptions() synchronously during
render, which triggered useSyncExternalStore listeners in MobileBottomNav
(and MainLayout) while Index was still rendering.

Move the store update into useLayoutEffect, which fires synchronously
after commit but before browser paint — same visual result without
violating React's setState-during-render rule.
2026-03-29 17:43:55 -05:00
Alex Gleason 51fb1fd1cb Give comments (kind 1111) and generic reposts (kind 16) independent feed toggles
Previously comments shared feedKey 'feedIncludePosts' with kind 1, and
generic reposts shared 'feedIncludeReposts' with kind 6. This made it
impossible to toggle them independently in settings.

Add feedIncludeComments and feedIncludeGenericReposts to FeedSettings
and wire them to their respective EXTRA_KINDS entries.
2026-03-29 17:33:56 -05:00
Alex Gleason 1988e1b849 Fix duplicate React keys in content settings
Multiple ExtraKindDef entries share the same feedKey (e.g. posts/comments
both use feedIncludePosts) and multiple subKinds share the same showKey
(e.g. both video sub-kinds use showVideos). Using these as React keys
caused 'duplicate key' warnings.

Use def.id (always unique) for ContentTypeRow keys and sub.feedKey
(unique per sub-kind) for SubKindRow keys.
2026-03-29 17:29:45 -05:00
Alex Gleason 1b782f65d1 Display QR code in terminal for NIP-46 auth script 2026-03-29 16:58:22 -05:00
Alex Gleason 6c4eddece7 Add NIP-46 client-initiated auth script for Zapstore CI signing 2026-03-29 16:47:36 -05:00
filemon cf0524a211 Extract composable visual modules and decouple dirty from face emotions
Foundation for migrating the monolithic emotion system toward a composable
architecture where each visual area (eyes, mouth, eyebrows, body effects)
is handled independently.

New modules created:
- bodyEffects/ — types, generators (dirt marks, stink clouds, anger rise),
  and applyBodyEffects() for applying body decorators independently of face
- mouth/ — types and re-exports of existing mouth detection/generation
- eyebrows/ — types and re-exports of existing eyebrow generation

Dirty emotion refactored:
- Removed face modifications (droopyMouth, eyebrows) from EMOTION_CONFIGS.dirty
- dirty is now a body-only decorator that adds dirt marks + stink clouds
  without touching eyes, mouth, or eyebrows
- Hygiene stat now maps to 'boring' as the face emotion (same as happiness)
- Body effects (dirty) are resolved independently in resolveStatusEmotions()
  and flow as a separate bodyEffects field through the entire pipeline:
  resolveStatusEmotions → useStatusReaction → BlobbiStageVisual →
  BlobbiAdultVisual/BlobbiBabyVisual → applyBodyEffects()
- Any face + dirty is now possible: boring+dirty, sleepy+dirty, dizzy+dirty

The existing emotion system (applyEmotion) still works unchanged for all
other emotions. The eyes/ module already existed. This is an incremental
step — no full migration yet.
2026-03-29 17:57:45 -03:00
Alex Gleason a796f279a5 release: v2.2.2 2026-03-29 15:48:26 -05:00
Alex Gleason efc491bad4 Add release notes to Zapstore publishing 2026-03-29 15:45:52 -05:00
filemon 8d04bbbdbe Complete baseEmotion + overlayEmotion migration across the visual pipeline
Finish the two-layer emotion architecture so resolveStatusEmotions() is
the single source of truth and both the main BlobbiPage and the floating
companion use the same flow.

useStatusReaction:
- Now returns baseEmotion, overlayEmotion, triggeringBaseStat,
  triggeringOverlayStat, isStatusReactionActive, currentSeverity,
  isOverrideActive (replaces the old single currentEmotion).
- Internally calls resolveStatusEmotions() on every check cycle and
  tracks base and overlay transitions independently with animation
  safety per layer.
- Action overrides replace the overlay; the base persists underneath.

status-reactions.ts:
- Remove combineEmotions() (no longer needed).
- Deprecate resolveStatusReaction() with a JSDoc notice.
- resolveStatusEmotions() is now the primary API.

BlobbiStageVisual:
- Accepts a new baseEmotion prop and forwards it to BlobbiBabyVisual
  and BlobbiAdultVisual.

BlobbiPage (main consumer):
- Destructures baseEmotion + overlayEmotion from the hook and passes
  both through to BlobbiStageVisual.

Companion system:
- CompanionData now carries full BlobbiStats and state.
- BlobbiCompanionLayer runs its own useStatusReaction to drive the
  companion's emotions from stats, including item-use action overrides.
- BlobbiCompanion and BlobbiCompanionVisual accept baseEmotion + emotion
  props and forward them to the underlying visual components.
2026-03-29 17:27:17 -03:00
Alex Gleason f6c08f8afa Add badge list recovery dialog for kind 10008 events
Allow users to browse and restore previous versions of their accepted
badges (profile badges) from relay history, matching the existing
mute list recovery pattern in /settings/content.
2026-03-29 15:22:51 -05:00
Alex Gleason 3197c53fcc Fix PageSkeleton to match actual page layout structure
The lazy-loading skeleton was missing center column borders
(sidebar:border-l/r) and the right sidebar widget backgrounds
(bg-background/85 rounded-xl). Updated to mirror the real Outlet
wrapper classes and RightSidebar widget card styling with three
distinct skeleton sections (Trends, Hot Posts, New Accounts).
2026-03-29 15:14:09 -05:00
Alex Gleason 7b793149b3 Reserve grid cell for +N overflow indicator in badge lists
When a badge list overflows PREVIEW_LIMIT, show one fewer badge to
make room for the +N button on the same row instead of widowing it.
The loading skeleton now also includes a placeholder for the overflow
cell when applicable.
2026-03-29 15:08:48 -05:00
Alex Gleason 24c938728a Replace spinner with skeleton grid for badge list loading state
Shows placeholder skeletons matching the badge grid layout (48px
rounded squares + name bars) instead of a centered spinner while
badge definitions are being fetched.
2026-03-29 15:06:33 -05:00
Alex Gleason 1c358a3c79 Add compact badge row preview for embedded profile badges events
Kind 10008/30008 profile badges events now render a compact card with
author info, a row of up to 6 badge thumbnails, and a badge count
when embedded in quotes or reply context. Works in both EmbeddedNote
(nevent references) and EmbeddedNaddr (naddr references).
2026-03-29 15:04:58 -05:00
Alex Gleason 3bbed8875c Remove inline compose box from profile badges detail view
The badge collection detail page (kind 10008/30008) no longer shows
a ComposeBox between the badge grid and the comments section.
2026-03-29 15:02:12 -05:00
filemon a3e6ff34db Add boring and dirty emotions to dev emotion tester panel
- Added boring emotion (😑) - low-energy, unamused expression
- Added dirty emotion (💩) - hygiene-specific with dirt/stink visuals
- Maintains existing emotion order with new emotions near the top
2026-03-29 17:01:48 -03:00
Alex Gleason 0f7fc673eb Make +N overflow indicator clickable to expand full badge grid
Clicking the '+2' (or similar) button at the end of a truncated badge
grid now reveals all badges instead of being a static indicator.
2026-03-29 15:01:17 -05:00
Alex Gleason 646c95a86f Show kind action header on threaded ancestor NoteCards
The KIND_HEADER_MAP action header (e.g. 'updated their badges',
'created a badge', 'shared a photo') was only rendered in the normal
NoteCard layout. Now it also appears in the threaded layout, so parent
events shown as ancestors in reply threads display their kind context.
2026-03-29 14:56:55 -05:00
Alex Gleason 87a8974c8c Show parent event as threaded NoteCard for kind 1111 comments
When viewing a NIP-22 comment (kind 1111) that references its parent
via an 'a' tag (addr coordinates) rather than an 'e' tag (event ID),
the parent event is now rendered as a full threaded NoteCard with a
connector line — matching how kind 1 reply threads display ancestors.

Previously these showed a compact AddressableEventPreview banner.
Now the parent badges list (or any other addr-referenced event) renders
inline in the thread, giving proper visual context for the comment.
2026-03-29 14:55:48 -05:00
Alex Gleason 9b5df28b93 Fix naddr routing for replaceable events (kind 10000-19999)
Replaceable events have no d-tag, so useAddrEvent must omit the #d
filter for kinds in the 10000-19999 range. Without this, querying
for a kind 10008 profile badges event via naddr would include
'#d': [''] in the filter, which fails to match events without a d-tag.
2026-03-29 14:47:52 -05:00
Alex Gleason e876e290da Encode replaceable events (kind 10000-19999) as naddr URLs
Replaceable events should use naddr encoding (kind + pubkey + empty
identifier) rather than nevent (event ID), since they are identified by
their coordinates. This fixes kind 10008 profile badge events linking
to /nevent1... instead of /naddr1... from the feed.
2026-03-29 14:45:45 -05:00
Alex Gleason f26f033b14 Support viewing kind 10008/30008 profile badges via nevent URLs
PostDetailPage (used for nevent1 identifiers) was missing the kind
10008/30008 branch, so profile badge events fell through to the generic
PostDetailContent. AddrPostDetailPage already had this handling via
ProfileBadgesDetailView — now PostDetailPage shares the same code path
for both badge definitions (30009) and profile badges (10008/30008).
2026-03-29 14:41:24 -05:00
Alex Gleason 565f323179 Add mouse-only 3D tilt to BadgeThumbnail with visible effect
Build the tilt directly into BadgeThumbnail instead of a separate
wrapper. Use aggressive parameters (35deg max tilt, 1.15x scale,
perspective = size*3) so the effect is clearly visible on small
28-48px thumbnails. Add a perspective parameter to useCardTilt.

Remove old group-hover:scale-110 from all badge grid call sites
(BadgeShowcaseGrid, ProfileBadgesContent, ProfilePage,
ProfileHoverCard) since the tilt+scale is now built into the
thumbnail itself.
2026-03-29 14:37:46 -05:00
filemon 82b2aeb294 Refactor Blobbi visual state system: separate base face from overlay animations
- Add 'boring' persistent face: low-energy, unamused expression (replaces sad as generic fallback)
  - Droopy mouth with shallow curve, flat eyebrows
  - Used for non-critical bad stats (health, happiness)

- Add 'dirty' persistent state: hygiene-specific visuals
  - Includes dirt marks on lower body (3 curved scratch-like lines)
  - Animated stink clouds floating upward
  - Uses boring face as base + hygiene effects

- Refactor 'sleepy' to be an overlay animation
  - KEY FIX: sleepy now animates the CURRENT mouth state instead of resetting to default smile
  - When Blobbi is unwell (boring/dirty/dizzy face), sleepy animation preserves that base face
  - Implementation: applySleepyMouthAnimation finds existing mouth path and animates from there
  - Example: boring face + sleepy = boring expression with sleepy animation on top

- Update status-reactions.ts emotion mapping
  - health: boring (not feeling good) → dizzy (critical)
  - hygiene: dirty (poor hygiene visuals)
  - happiness: boring (low energy, unamused)
  - energy: sleepy (now an overlay, not base-replacing)

- Add base + overlay emotion architecture to visual components
  - BlobbiAdultVisual and BlobbiBabyVisual now accept optional baseEmotion prop
  - Emotions applied sequentially: base first, then overlay
  - Preserves existing behavior when only one emotion provided

- Add resolveStatusEmotions() utility
  - Separates base emotions from overlay emotions
  - Returns StatusEmotionResult with baseEmotion and overlayEmotion
  - Enables proper multi-stat handling (e.g., low health + low energy = boring face with sleepy overlay)

Architecture notes:
- Base emotions (boring, dirty, dizzy, sad, happy, etc.): replace face completely
- Overlay emotions (sleepy): animate on top without replacing base
- Critical fix: Blobbi no longer visually resets to happy during sleepy cycle when in bad state
2026-03-29 16:36:16 -03:00
Alex Gleason 6b59658f00 Add mouse-only 3D tilt effect to badge images in feed cards
Reuse useCardTilt for the badge image in BadgeContent feed cards, but
only respond to mouse/pen pointer events. Touch events are explicitly
ignored and touch-action is set back to auto so tapping through to the
badge detail view and normal scrolling are unaffected. Includes the
specular glare overlay masked to the badge image shape.
2026-03-29 14:28:33 -05:00
Alex Gleason 2e1e4416b3 Add touch support to badge 3D tilt effect
Update useCardTilt to handle touch inputs via PointerEvent. Touch
interactions use a press-and-drag gesture: the tilt follows the finger
while down, then holds for 600ms after release before smoothly
resetting. touch-action: none prevents the browser from intercepting
the gesture for scrolling. Mouse behavior is unchanged.

Update BadgeHero glare overlay to match: glare follows touch position
during the drag and fades after the same linger delay on release.
2026-03-29 14:26:02 -05:00
Alex Gleason e92a2c571c Remove 'Badge' pill from badge detail issuer row
Remove the secondary Badge UI element showing '<Award icon> Badge' next to
the issuer name on the badge detail page. The context is already clear from
the page layout and hero image.
2026-03-29 14:20:52 -05:00
Alex Gleason d3a418b5ee Remove redundant header from profile badges feed card
Remove the 'Name's Badges (N badges)' header line from ProfileBadgesContent
to reduce visual clutter. The badge grid and NoteCard's KIND_HEADER_MAP
already provide sufficient context.
2026-03-29 14:18:37 -05:00
Alex Gleason c9945107e9 Replace Photos and Videos with Badges in default sidebar order 2026-03-29 14:14:55 -05:00
Alex Gleason 4c70133ca9 Clarify EmbeddedNote vs NoteCard compact prop in AGENTS.md
Rename the checklist item from 'Inline embeds / quote posts' to
'Embedded note cards' with explicit file paths, explain that kinds
with tag-based media may need attachment indicator updates, and add
a note distinguishing EmbeddedNote components from the NoteCard
compact prop to prevent confusion.
2026-03-29 14:11:51 -05:00
Alex Gleason 0df942cb9d Display kind 20 photo events in detail view and add Photo indicator
- Add isPhoto detection and PhotoDetailContent in PostDetailPage so kind 20
  events render their image gallery when viewed directly via nevent links
- Add parsePhotoUrls helper and ImageGallery import to PostDetailPage
- Add 'Photo' shell title for kind 20 loading state
- Add KIND_HEADER_MAP entry for kind 20 ('shared a photo') in NoteCard
- Add Photo attachment indicator in EmbeddedNote for kind 20 events in
  quote posts and reply context
2026-03-29 14:09:39 -05:00
Alex Gleason 37a63f068b Make version tag and date clickable links, remove external link icon 2026-03-29 13:48:07 -05:00
Alex Gleason cc8638e8b2 Show build date instead of commit hash in pre-release banner, compare to current commit 2026-03-29 13:46:26 -05:00
filemon fd20081ce8 fix: prevent reaction animations from being cut off by status recomputation
Refactored useStatusReaction hook to be more stateful and animation-aware:

- Track currently active reaction to avoid restarting same reaction
- Distinguish between persistent (sleepy, sad, dizzy, hungry) and one-shot reactions
- Persistent reactions loop continuously while condition remains active
- Only replace reactions when: type changes, higher priority interrupts, or one-shot completes
- Remove stats from useEffect dependencies to prevent reset on every recomputation
- Add animation cycle duration awareness to avoid mid-animation interruptions
- Use refs for stats/timing to maintain stable callback references

This ensures:
- Sleepy animation completes full cycle including slow eye opening
- Crying/sad reactions don't reset before tear cycle completes
- Dizzy animation doesn't keep resetting its visual motion
- Eyebrow/face reactions don't flicker from repeated reapplication
2026-03-29 15:17:33 -03:00
Alex Gleason cf9d409166 Migrate profile badges from kind 30008 to kind 10008
Profile badges should be a replaceable event (kind 10008), not an
addressable event (kind 30008) with a fixed d-tag. This follows the
same deprecation pattern used by NIP-51 lists.

All writes now publish kind 10008. All reads query both 10008 and
legacy 30008, picking whichever is newest, for backwards compatibility
during the transition period.
2026-03-29 13:10:22 -05:00
Chad Curtis 7d8ac49fe2 Document fetchFreshEvent pattern in AGENTS.md 2026-03-29 10:08:44 -05:00
Chad Curtis dc3be6564b Fix stale-cache overwrites in replaceable event mutations
Extract shared fetchFreshEvent() utility that fetches the freshest
version of a replaceable/addressable event directly from relays before
every mutation. This prevents data loss when the TanStack Query cache
is stale (cross-device edits, rapid sequential operations).

Previously only useFollowActions and useMuteList had this safety
pattern. Now all list-type hooks use the same shared primitive:
useAcceptBadge, useRemoveBadge, useBookmarks, usePinnedNotes,
useInterests, and useUserLists.
2026-03-29 10:05:58 -05:00
Chad Curtis 337e27f2b5 Add multi-select badge awarding with already-sent indicators 2026-03-29 09:47:05 -05:00
Chad Curtis c400437662 Add kinds 10015, 10030, 10063 to NostrBatcher REPLACEABLE_KINDS
Interests, custom emojis, and Blossom server list queries now batch
with profile/follow/mute queries instead of firing separate REQs,
reducing ~3 REQs on feed load.
2026-03-29 08:51:41 -05:00
Chad Curtis e8941e8ef6 Fix search page 'N new posts' pill showing unfiltered count
The stream buffer count was reported raw without applying client-side
filters (search query, media type, replies, protocol, mute list, etc.),
so the pill would show e.g. '10 new posts' when only 3 matched the
active search criteria. Extract the filtering predicate into a shared
matchesFilters callback and derive the pill count from filtered buffer
contents instead of the raw streamBufferCount.
2026-03-29 08:00:29 -05:00
Chad Curtis 169823980f Add arc overhang spacer to Photos and Videos pages
Both pages have a SubHeaderBar but were missing the ARC_OVERHANG_PX
spacer div that prevents content from sitting behind the arc background.
Every other page with tabs already includes this spacer.
2026-03-29 07:58:26 -05:00
Chad Curtis aa7376b357 Add pull-to-refresh to all feed pages via usePageRefresh hook
Missing pull-to-refresh on Photos, Videos, Trends, Search, Bookmarks,
TagFeed (#t/#g), DomainFeed pages meant Android users had no way to
refresh content without navigating away.

- Create usePageRefresh hook that wraps queryClient.invalidateQueries
  with a referentially-stable callback (ref-based) for PullToRefresh
- Wrap scrollable content in PullToRefresh on all affected pages
- Fix Feed.tsx: HashtagFeedContent, GeotagFeedContent, and
  SavedFeedContent tabs now include PullToRefresh (were outside wrapper)
- Refactor Events, Books, Themes pages to use usePageRefresh for
  consistency and reduced boilerplate
2026-03-29 06:54:58 -05:00
Chad Curtis 6a0e88cbf1 Fix gap between arc and top nav on relay feed page mobile 2026-03-29 06:36:14 -05:00
Chad Curtis 01b7e1cea2 Stop click propagation on delete confirmation AlertDialog content
Clicks inside the portaled AlertDialog bubble through React's synthetic
event tree to the NoteCard article, triggering post detail navigation.
Adding stopPropagation on AlertDialogContent prevents any click inside
the delete confirmation from reaching the card handler.
2026-03-29 06:23:01 -05:00
Chad Curtis 910f43e0a5 Stop click propagation in NoteMoreMenu items to prevent click-through
MenuItem button clicks inside the Radix Dialog portal bubble through
React's synthetic event system to the parent article's handleCardClick,
causing navigation to post details when selecting menu actions like
delete. Adding stopPropagation prevents this.
2026-03-29 06:21:15 -05:00
Chad Curtis 6bf630bb40 Fix delete post dialog freezing feed on desktop
Move the delete confirmation AlertDialog out of NoteMoreMenuContent
(where it was nested inside the more-menu Dialog) and into the parent
NoteMoreMenu component. The nested Radix dialogs caused overlapping
overlays and focus traps that left the page uninteractable after
confirming deletion. Now follows the same close-then-open pattern used
by Report, Mention, AddToList, and EventJson dialogs.
2026-03-29 06:16:23 -05:00
Chad Curtis 5e71b3f44a Merge branch 'fix/emphasize-custom-emojis' into 'main'
Custom emoji improvements & reaction fixes

See merge request soapbox-pub/ditto!140
2026-03-29 11:08:38 +00:00
filemon 5ffab157d7 Unify eye system contract and fix animation conflicts
- Standardize data attributes: data-cx/cy → data-eye-cx/cy with legacy fallback
- Replace hardcoded eye selectors with EYE_CLASSES constants from eyes/types
- Remove unused side-specific clip rect class variants from EYE_CLASSES
- Fix sleepy animation: skip JS blink when SMIL animations present
- Fix companion reaction support: skip eye transforms when CSS animations active
- Update detection.ts to try new attribute format first, fall back to legacy
- Update useBlobbiEyes and useExternalEyeOffset to respect CSS animations
2026-03-29 05:16:51 -03:00
filemon c6e791d18f Implement reactions 2026-03-29 04:41:42 -03:00
Lemon a80b306248 Reset feed composer to collapsed state after posting 2026-03-28 23:11:47 -07:00
Lemon c8c294a8ad Match ComposeBox background opacity with header and subheader (bg-background/85) 2026-03-28 23:11:47 -07:00
Lemon 5a30376f2c Fix emoji shortcode autocomplete text and highlight colors
The shortcode label used text-muted-foreground making it hard to read.
Changed to inherit text-popover-foreground so it matches the theme's
normal text color. The selection/hover highlight now uses bg-secondary/60,
matching the subtle hover shade used by the NoteMoreMenu items.
2026-03-28 23:07:21 -07:00
Lemon 373219ecfa Reorder emoji picker: Recent, Custom, then standard categories
Move the Custom (NIP-30) emoji tab from last position to second, right
after Recent, so users can quickly access their custom emoji packs.
2026-03-28 23:07:21 -07:00
Lemon 1ef1400699 Track custom emoji usage so they appear in quick-react bar
Custom emojis were explicitly excluded from usage tracking, so they could
never accumulate enough count to appear in the quick-react popover. The
bar already handles displaying and filtering custom emojis correctly
(including removing stale ones from deleted packs), so the only missing
piece was the tracking call.
2026-03-28 23:07:21 -07:00
Lemon 7966d07158 Fix custom emoji SVGs not rendering in emoji-mart picker
emoji-mart renders custom emoji <img> tags with only max-width/max-height
inline styles but no explicit width/height. SVG files that lack intrinsic
width/height attributes (even with a viewBox) collapse to 0x0 because
max-* constraints alone can't force dimensions on a dimensionless image.

Inject a CSS rule into emoji-mart's shadow DOM that gives custom emoji
images explicit 1em x 1em dimensions with object-fit: contain. The img
lives inside span.emoji-mart-emoji, not a button with data-emoji-set.
2026-03-28 23:07:21 -07:00
Lemon 9ffab3d2dd Fix double-tap reaction not showing emoji on the post
The double-click handler was setting the user-reaction query cache to a
plain string instead of a ResolvedEmoji object. RenderResolvedEmoji
expects { content: '❤️' }, not just '❤️'.
2026-03-28 23:07:21 -07:00
Alex Gleason dbcbd8928b Merge branch 'remove-music-files' into 'main'
Refactor Blobbi music system to use remote Blossom URLs

See merge request soapbox-pub/ditto!137
2026-03-29 03:24:16 +00:00
Chad Curtis a659611897 Fix profile skeleton flicker for new users with no kind:0 metadata 2026-03-28 22:09:53 -05:00
filemon 78b4716a2a Refactor Blobbi music system to use remote Blossom URLs
- Replace local audio files with remote Blossom server URLs
- Remove upload functionality from PlayMusicModal
- Rename BuiltInTrack → BlobbiTrack, AudioSource → SelectedTrack
- Rename blobbi-builtin-tracks.ts → blobbi-track-catalog.ts
- Delete ~4.7MB of local audio files from public/blobbi/audio/
- Fix import path for STAT_MIN/STAT_MAX constants
2026-03-29 00:06:11 -03:00
Chad Curtis 08e26e28d0 Fix key download path: save to Download/ instead of Documents/ 2026-03-28 21:58:53 -05:00
Chad Curtis b1c61a7888 Fix Amber login on Android: retry nostrconnect subscription on foreground resume 2026-03-28 21:51:17 -05:00
Chad Curtis e951a3b00a Fix key download on Android: save to Documents instead of share sheet 2026-03-28 21:47:47 -05:00
Chad Curtis 62b5aab753 Disable DM handler background processing 2026-03-28 21:34:06 -05:00
Alex Gleason 7b307ffe22 Replace window.open() calls with Capacitor-aware openUrl()
window.open() and target="_blank" silently fail inside WKWebView on
iOS. Replace all programmatic window.open() calls with the openUrl()
utility from src/lib/downloadFile.ts, which uses the native share sheet
on Capacitor and falls back to window.open() on web.

Fixed in: ZapDialog, TasksPanel, HatchTasksPanel, PullRequestCard,
CustomNipCard, GitRepoCard, PatchCard.

The middle-click handler in useOpenPost is left as-is since middle-click
is a web-only interaction with no equivalent on mobile.
2026-03-28 21:25:58 -05:00
Alex Gleason edee9f7030 Add iOS privacy manifest and usage description strings
- Add PrivacyInfo.xcprivacy declaring UserDefaults, file timestamp, and
  disk space API usage reasons, plus collected data types for crash
  reporting (Sentry) and analytics (Plausible)
- Add NSPhotoLibraryUsageDescription and NSMicrophoneUsageDescription to
  Info.plist for image uploads and voice message recording

Both are required for App Store submission.
2026-03-28 21:20:32 -05:00
Mary Kate 71949890da Merge branch 'photo-compose-modal' into 'main'
Add dedicated photo upload flow for NIP-68 kind 20 events

See merge request soapbox-pub/ditto!135
2026-03-28 22:03:58 +00:00
Mary Kate Fain 5ae233ff62 Add dedicated photo upload flow for NIP-68 kind 20 events
Enable the floating compose button on the photos page with a camera
icon. Clicking it opens a new PhotoComposeModal with image upload,
title, caption, alt text, and content warning support. Publishes
kind 20 picture events per the NIP-68 specification.
2026-03-28 16:30:21 -05:00
Mary Kate 19400a78e5 Merge branch 'fix/profile-tab-form-reset' into 'main'
Fix custom profile tab form retaining fields from previous tab

Closes #196

See merge request soapbox-pub/ditto!134
2026-03-28 21:08:25 +00:00
Mary Kate Fain 497d6979d0 Fix custom profile tab form retaining fields from previous tab
The ProfileTabEditModal reset form state inside a handleOpenChange
callback, but Radix Dialog does not fire onOpenChange when opened
programmatically via the `open` prop. This meant that when a parent
component set `open={true}` (e.g. after clicking 'Add custom tab'),
the reset logic never ran and the form kept stale values from the
last edit session.

Replace the handleOpenChange reset with a useEffect that triggers
whenever `open` transitions to true, ensuring the form always
initializes from the current `tab` prop (or clean defaults for a
new tab).

Closes #196
2026-03-28 16:01:17 -05:00
Mary Kate 59eab8afea Merge branch 'fix/badge-notification-click' into 'main'
Fix badge notifications not being clickable

Closes #201

See merge request soapbox-pub/ditto!133
2026-03-28 20:56:52 +00:00
Mary Kate Fain 74b84eb5ac Fix badge notifications not being clickable
Wrap badge cards in BadgeAwardNotification and BadgeAwardNotificationGroup
with Link components that navigate to the badge detail page via naddr1
encoded URLs. Both single and grouped badge notifications now link to
the badge definition page when clicked.

Closes #201
2026-03-28 13:38:11 -05:00
Mary Kate bfc864cc7c Merge branch 'rename-vines-to-divines' into 'main'
Rename "Vines" to "Divines" in all user-facing strings

Closes #194

See merge request soapbox-pub/ditto!132
2026-03-28 18:12:28 +00:00
Mary Kate Fain 6c067a3ae6 Rename "Vines" to "Divines" in all user-facing strings
Update sidebar label, page title, feed empty states, search filter
labels, notification kind nouns, comment context labels, and
documentation to use "Divines" instead of "Vines".

Closes #194
2026-03-28 13:05:08 -05:00
Mary Kate 503fed5fdb Merge branch 'changelog-footer-link' into 'main'
Add changelog link to footer section

Closes #203

See merge request soapbox-pub/ditto!131
2026-03-28 17:57:31 +00:00
Mary Kate Fain 32cb3eeba3 Add changelog link to footer section
Closes #203
2026-03-28 12:51:42 -05:00
Mary Kate 7e49e85495 Merge branch 'fix/geocache-treasure-terminology' into 'main'
Fix inconsistent use of 'geocache' vs 'treasures' terminology

Closes #198

See merge request soapbox-pub/ditto!130
2026-03-28 17:49:44 +00:00
Mary Kate Fain c3d7984d7a Fix inconsistent use of 'geocache' vs 'treasures' terminology
Closes https://gitlab.com/soapbox-pub/ditto/-/work_items/198
2026-03-28 12:44:28 -05:00
Mary Kate b024518f5e Merge branch 'fix/double-line-profile-tabs-editing' into 'main'
Fix double line under profile tabs in edit mode

Closes #197

See merge request soapbox-pub/ditto!129
2026-03-28 17:36:53 +00:00
Mary Kate Fain 83c1e9aa6c Fix double line under profile tabs in edit mode
Replace the flat h-1 indicator bar in SortableTabChip with the
SubHeaderBar arc-based active indicator, matching how TabButton works.
The flat bar was overlapping the ArcBackground border stroke, creating
a visible double line when editing profile tabs.
2026-03-28 12:29:38 -05:00
Chad Curtis 8a6cb02dc0 Convert blobbi audio from MP3 to M4A (AAC-LC 32kbps mono), enable R8 shrinking
Replace 36 MB of MP3 files with 4.6 MB of M4A (AAC-LC) files encoded
at 32kbps mono. M4A is required for iOS/Safari compatibility in
Capacitor's WKWebView.

Enable R8 minification and resource shrinking in the Android release
build to further reduce APK size. Add ProGuard rules to keep Capacitor
and OkHttp classes.
2026-03-28 11:35:49 -05:00
Alex Gleason 91237c252c Sanitize Blobbi SVG output with DOMPurify before DOM injection
Add defense-in-depth sanitization at the output boundary of the Blobbi
SVG rendering pipeline. The upstream pipeline validates user inputs
(normalizeHexColor, instanceId regex), but 3000+ lines of regex-based
SVG string manipulation feed directly into dangerouslySetInnerHTML with
no structural guarantee that the output is safe.

sanitizeBlobbiSvg() uses DOMPurify with an allowlist tuned for the
Blobbi pipeline (gradients, clip paths, animations, @keyframes) while
blocking scripts, event handlers, foreignObject, href, and other
dangerous constructs.
2026-03-28 11:33:10 -05:00
Chad Curtis f7821451c7 release: v2.2.1 2026-03-28 10:15:52 -05:00
Chad Curtis 9056b43696 Highlight flushed posts, fix new-posts pill positioning, fix desktop tab gap
- New posts flushed from the stream buffer now briefly highlight with a
  primary-tinted fade animation so users can see what appeared
- New-posts pill uses responsive CSS (new-posts-pill utility) so it sits
  correctly below the SubHeaderBar on both mobile and desktop
- SubHeaderBar desktop padding moved inside the inner wrapper so the arc
  background extends to the viewport edge, eliminating the gap above tabs
2026-03-28 10:12:11 -05:00
Chad Curtis cdf54e9eff Fix 'new posts' button on search page not loading posts
The button only called window.scrollTo() and relied on the scroll event
listener to auto-flush the stream buffer. This failed when smooth
scrolling didn't fire reliable scroll events (especially on mobile/
Capacitor WebView). Now explicitly calls flushStreamBuffer() on click.
2026-03-28 09:51:30 -05:00
Chad Curtis 7a49e9646c Bypass nudge for encrypt and decrypt, remove phase-transition toast
Encrypt and decrypt operations now call the signer directly without
nudge/timeout/retry wrapping. The sign operation already provides
the user-facing nudge when approval is needed, so the encrypt nudge
was redundant noise. Phase-transition toast and related constants
removed as dead code.
2026-03-28 09:43:43 -05:00
Chad Curtis 2189f5e7c4 Fix MobileTopBar double-opacity: remove bg from header, use dedicated safe-area fill
The header had bg-background/85 plus the ArcBackground SVG fill-background/85
stacking to ~98% opacity. Now the safe-area padding zone gets a single-layer
bg-background/85 fill div (same pattern as pinned SubHeaderBar), and the
ArcBackground provides the only fill for the content area.
2026-03-28 09:39:09 -05:00
Chad Curtis 2822b4c159 Remove decrypt nudge, fix pinned tab safe area fill
- Decrypt operations now bypass signerWithNudge entirely (no nudge toast)
- Pinned SubHeaderBar safe area uses a separate fill div matching
  MobileTopBar's bg-background/85, avoiding double-opacity stacking
  with the ArcBackground SVG below
2026-03-28 09:37:28 -05:00
Chad Curtis 3dd2591709 Fix pinned tab arc, signer toast spam, new-posts pill position, toast swipe
- Pinned SubHeaderBar uses safe-area-inset-top (top offset) instead of
  safe-area-top (padding) so the arc stays flush with the tab content
- Throttle signer nudge toasts (8s cooldown) to prevent rapid-fire storm
  when relay connection is unstable
- New posts pill fades out when nav hides instead of translating, avoiding
  it floating in the safe area zone
- Signer toasts use finite duration (120s) so Radix swipe-to-dismiss works
- Lower toast swipe threshold from 50px to 30px for easier dismissal
2026-03-28 09:32:08 -05:00
Chad Curtis ab2145ffe9 Fix mobile UX: sticky safe area, page padding, stream buffering, media CW
- SubHeaderBar pinned mode adds safe-area-top padding when nav is hidden
- Increase signer nudge delay to 10s for decrypt ops (reduces false triggers on Amber)
- Add arc overhang spacer to Search, Notifications, and Profile pages
- Add PageHeader to Search page for consistent top-level layout
- Buffer streamed posts when user is scrolled down to prevent scroll jumps
- Show 'N new posts' pill that tracks SubHeaderBar position and nav state
- MediaCollage respects NIP-36 content warnings (blur/hide/show policy)
- Normalize main element classes across Profile and Notifications pages
2026-03-28 09:16:00 -05:00
Chad Curtis 3ee880d1dd release: v2.2.0 2026-03-28 08:00:41 -05:00
Chad Curtis 586e103161 Merge branch 'fix/zap-primal-users' into 'main'
Fixing zap Primal users without error

See merge request soapbox-pub/ditto!110
2026-03-28 12:55:40 +00:00
Chad Curtis 5776bf2a51 Merge branch 'fix/remote-signer-ux-improvements' into 'main'
feat: remote signer UX improvements for Amber/NIP-46 users on Android

See merge request soapbox-pub/ditto!128
2026-03-28 12:51:15 +00:00
Chad Curtis ceb442ebf1 Merge remote-tracking branch 'origin/main' into fix/remote-signer-ux-improvements
# Conflicts:
#	src/hooks/useCurrentUser.ts
#	src/index.css
2026-03-28 07:47:26 -05:00
Chad Curtis e6a2bdc65f Clean up nsec paste guard toast wording for clarity 2026-03-28 07:37:59 -05:00
Chad Curtis c9205adbab Merge branch 'fix/nsec-paste-guard' into 'main'
feat: block nsec private key paste with warning

See merge request soapbox-pub/ditto!101
2026-03-28 12:37:28 +00:00
Chad Curtis 29d56daab3 fix: relay page arc header with inline NIP-11 info panel
- Use PageHeader + SubHeaderBar arc format matching other feed pages
- Add arc overhang spacer for consistent feed padding
- Move NIP-11 relay info into an inline expanding panel (maxHeight transition)
- Info toggle button in PageHeader top-right corner
- Accept string | undefined in useRelayInfo hook signature
- Register useLayoutOptions({ hasSubHeader: true }) for mobile nav
2026-03-28 07:28:32 -05:00
Chad Curtis 4d7ac5e619 Merge branch 'feat/relay-info' into 'main'
Adding NIP-11 relay information on network settings page

See merge request soapbox-pub/ditto!106
2026-03-28 12:11:44 +00:00
Chad Curtis fb3686fef4 Merge branch 'fix/embedded-note-link-preview' into 'main'
Show link preview cards in quoted posts

See merge request soapbox-pub/ditto!107
2026-03-28 12:05:15 +00:00
Chad Curtis ed14ef0cd9 Merge branch 'fix/notifications-136' into 'main'
Fix notification UX issues: real-time updates, zap/reaction visibility, comment counts

Closes #136

See merge request soapbox-pub/ditto!102
2026-03-28 12:04:20 +00:00
Chad Curtis fd90f90cbb Restore client-side reply count seeding with correct nip85-event-stats cache key 2026-03-28 06:59:01 -05:00
Chad Curtis b6cee104b9 Filter Mentions tab by literal nostr: URI mentions in content 2026-03-28 06:49:19 -05:00
Chad Curtis 5c6df95734 Fix review issues: remove duplicate subscription, fix zapAmount regression, remove dead cache seeding 2026-03-28 06:40:58 -05:00
Chad Curtis 8941aca968 Merge branch 'main' of gitlab.com:soapbox-pub/ditto into fix/notifications-136
# Conflicts:
#	src/hooks/useHasUnreadNotifications.ts
#	src/hooks/useTrending.ts
#	src/pages/NotificationsPage.tsx
2026-03-28 06:32:45 -05:00
Chad Curtis e1348f782e refactor: deduplicate theme dark/light detection into colorUtils
Extract getBackgroundThemeMode() and getBackgroundHex() into colorUtils.ts,
replacing duplicated CSS variable reading and luminance calculations in
EmojiPicker, main.tsx status bar, and TweetEmbed. Also fixes TweetEmbed
incorrectly treating custom themes as always light.
2026-03-28 06:27:21 -05:00
Chad Curtis b652976784 Merge branch 'fix/emoji-picker-autofocus' into 'main'
Fix: Auto-focus search box when emoji picker opens

Closes #162

See merge request soapbox-pub/ditto!114
2026-03-28 11:08:30 +00:00
Chad Curtis 530c0681d0 Merge branch 'main' of gitlab.com:soapbox-pub/ditto into fix/emoji-picker-autofocus
# Conflicts:
#	src/components/EmojiPicker.tsx
2026-03-28 06:01:21 -05:00
Chad Curtis e38f57f823 Merge branch 'fix/hashtag-query-eq' into 'main'
Fixing query case variants for tag feed parity with search

See merge request soapbox-pub/ditto!111
2026-03-28 10:57:46 +00:00
Chad Curtis f3b9eb9f73 Merge branch 'fix/disable-notifications' into 'main'
Fix notification preferences not filtering based on settings

See merge request soapbox-pub/ditto!93
2026-03-28 10:55:25 +00:00
Chad Curtis bd7be9590a Merge branch 'fix/feed-header-ordering' into 'main'
Fix: Consistent feed header ordering — title above tabs on all feed pages

Closes #153, #155, #156, #157, #158, and #159

See merge request soapbox-pub/ditto!112
2026-03-28 10:51:29 +00:00
Chad Curtis e1fa43c9f0 fix: add arc overhang spacer to BadgesPage to match other feeds 2026-03-28 05:48:07 -05:00
Chad Curtis ccb0d9ec71 fix: add background to PageSkeleton so it matches feed appearance 2026-03-28 05:46:41 -05:00
Chad Curtis eca4a5ba77 fix: PageHeader padding, opacity, and BooksPage search bar overlap
- Equal vertical padding (py-4) on PageHeader for balanced spacing
- Add bg-background/85 to PageHeader to match SubHeaderBar opacity
- Add top padding to BookSearchBar so it clears the arc overhang
2026-03-28 05:39:48 -05:00
Chad Curtis 6dd29c571f refactor: extract useInfiniteScroll hook and deduplicateEvents utility
Consolidate duplicated infinite-scroll boilerplate (auto-fetch page 2,
IntersectionObserver, scroll trigger) into a shared useInfiniteScroll
hook, and extract the flatten+dedup pattern into deduplicateEvents.

Also migrate BadgesPage and ThemesPage to use useFeedTab for
consistent tab persistence across all feed pages.
2026-03-28 05:31:49 -05:00
Chad Curtis 28f1e2b517 Merge branch 'main' of gitlab.com:soapbox-pub/ditto into fix/feed-header-ordering 2026-03-28 05:14:26 -05:00
Chad Curtis 21374b2cb4 Make vines desktop immersive like mobile, with floating tab bar and back button 2026-03-28 05:13:21 -05:00
Chad Curtis ada87468cc Merge branch 'main' of gitlab.com:soapbox-pub/ditto into fix/feed-header-ordering
# Conflicts:
#	src/pages/BadgesPage.tsx
2026-03-28 05:00:19 -05:00
Chad Curtis 0a4b488d69 Merge branch 'fix/vines-feed-header' into 'main'
Fix: Add Vines feed section header with info link to divine.video

Closes #154

See merge request soapbox-pub/ditto!113
2026-03-28 09:57:38 +00:00
Chad Curtis aa257b34ec Fix bottom nav flashing during vine swipe transitions
Remove the activeVinePlaying reset on index change. The old card's
onPlayingChange is already undefined after re-render, and the new
card's autoplay fires onPlay directly, so the state stays consistent
through transitions without a brief false→true flash.
2026-03-28 04:43:52 -05:00
Chad Curtis c48e6c7123 Add safe area insets to vine card overlay UI
Offset the bottom info strip, action sidebar, and mute button by
env(safe-area-inset-bottom) so they clear the home indicator on
notch/island devices. Applied to both the live UI and loading
skeleton.
2026-03-28 04:43:14 -05:00
Chad Curtis 827bc4b836 Show bottom nav when vine is paused, hide during playback
Lift playing state from VineCard to VinesFeedPage via onPlayingChange
callback. hideBottomNav is now driven by whether the active vine is
playing, so users can navigate when paused. Reset playing state on
swipe so the nav briefly appears while the next vine loads. Remove
noArcs so the bottom nav renders with its normal arc appearance.
2026-03-28 04:41:40 -05:00
Chad Curtis c7d115f873 Make vines page fully immersive on mobile
Hide the mobile top bar and bottom nav entirely on the vines page,
replacing them with a floating TikTok-style tab bar that overlays
directly on the video. The menu button (hamburger) is embedded in
the floating bar so users can still access navigation.

- Add hideTopBar and hideBottomNav layout options
- Add DrawerContext so pages can open the mobile drawer directly
- Move floating tab bar outside the scroll container to fix
  IntersectionObserver index tracking (autoplay on next video)
- Simplify vine-slide-height CSS to use full 100dvh
2026-03-28 04:36:41 -05:00
Alex Gleason 45c585a27d fix: add Vines feed section header with info link to divine.video
Fixes #154
2026-03-28 04:20:30 -05:00
Alex Gleason dc3fe02767 Upgrade Radix UI packages to fix infinite render loop with React 19
@radix-ui/react-popper 1.2.4 had a useEffect with no dependency array
that called setState (onAnchorChange) on every render, causing an
infinite loop. Fixed in 1.2.8 by tracking the previous anchor value
in a ref and only calling setState when it changes.

Upgraded all Radix packages that depend on react-popper:
- react-tooltip 1.2.4 -> 1.2.8
- react-popover 1.1.11 -> 1.1.15
- react-dropdown-menu 2.1.12 -> 2.1.16
- react-context-menu 2.2.12 -> 2.2.16
- react-hover-card 1.1.11 -> 1.1.15
- react-select 2.2.2 -> 2.2.6
- react-menubar 1.1.12 -> 1.1.16
- react-navigation-menu 1.2.10 -> 1.2.14
2026-03-28 02:29:17 -05:00
Lemon a1ef06510e Remove stale DEFAULT_KINDS fallback, skip polling until JS configures kinds 2026-03-28 00:20:21 -07:00
Alex Gleason 56002c68ca Add eslint rule to prohibit import.meta.glob usage 2026-03-28 02:19:55 -05:00
Alex Gleason 30bd73f8f9 Replace import.meta.glob with inlined SVG constants for Shakespeare compatibility
import.meta.glob is Vite-only and crashes in Shakespeare's esbuild bundler.
Generated baby-svg-data.ts and adult-svg-data.ts with SVG content as template
literal constants, keeping the existing resolver API unchanged.
2026-03-28 02:18:26 -05:00
Lemon 9d8a30f678 Add badge awards push notification template and pref key mapping 2026-03-28 00:13:21 -07:00
Lemon 2b0b99d598 Fix letters push toggle not propagating to nostr-push server 2026-03-28 00:07:28 -07:00
Lemon 8551852c9d Add badges and letters kinds to shared notificationKinds helper 2026-03-28 00:00:20 -07:00
Lemon 7ade0eaeb1 Clean up notification follow-filter code: remove dead ref, fix filter typing 2026-03-27 23:58:31 -07:00
Lemon debdbf770b Respect 'only from people I follow' in web push notifications
Send authors: ['$contacts'] in the nostr-push subscription filter
when onlyFollowing is enabled. The nostr-push server resolves this
macro to the user's kind 3 follow list. Toggling the setting off
removes the authors filter so all notifications are delivered again.
2026-03-27 23:58:31 -07:00
Lemon 01976685e8 Respect 'only from people I follow' in unread notification dot
Apply the same authors filter as useNotifications so the nav dot
won't appear for notifications from non-followed accounts.
2026-03-27 23:58:31 -07:00
Lemon bac5d71480 Respect 'only from people I follow' in native Android notifications
Pass followed pubkeys through the Capacitor plugin to the native
polling service. When onlyFollowing is enabled, the relay query
includes an authors filter so only events from followed accounts
trigger native Android notifications.
2026-03-27 23:58:31 -07:00
Lemon 0ad655d1cf Fix notification preferences not filtering push notifications or unread dot
Disabled notification types (e.g. reactions) still triggered push
notifications and showed the unread dot indicator, even though the
notification tab correctly filtered them out.

Three root causes fixed:
- useHasUnreadNotifications now uses getEnabledNotificationKinds to
  only query for enabled types, preventing phantom unread dots
- NotificationSettings now syncs type preference changes to the
  nostr-push server via updateSubscription (is_active toggle)
- Native Android poller now receives enabled kinds from the JS layer
  and uses them in the relay filter instead of hardcoded kinds
2026-03-27 23:58:31 -07:00
Chad Curtis a526e301da Fix MobileTopBar arc lingering when hidden on pages without tabs
The hide transform was missing the 20px arc overhang, so the bottom
curve remained visible after the bar slid up. Match the nav-hidden-slide
approach used by SubHeaderBar.
2026-03-28 00:46:29 -05:00
Chad Curtis 9c16c6df40 Fix MobileTopBar arc position with safe-area inset on native
The ArcBackground was positioned over the entire header including the
safe-area padding, causing the arc curve to sit too high on native apps.
Wrap content in a relative container so the arc only covers the content
area, and add bg-background/85 on the header to fill the safe-area region.
2026-03-28 00:46:29 -05:00
Chad Curtis 3220e9482b Fix profile and compose letter tabs hiding with mobile top bar
Add 'pinned' prop to SubHeaderBar that transitions top to 0 instead
of sliding off-screen when the nav hides. Applied to ProfilePage tabs
and ComposeLetterSheet toolbar.
2026-03-28 00:46:29 -05:00
Alex Gleason 9a033d7f91 Merge branch 'main' of gitlab.com:soapbox-pub/ditto 2026-03-28 00:38:33 -05:00
Chad Curtis 2329458a84 Fix settings letters section, drawer background/z-index, and top nav scroll hide
- Remove letters section from settings page (accessible from letters page)
- Add background to letter editor drawer panel
- Fix drawer z-index so letter content doesn't bleed through
- Fix compose sheet SubHeaderBar top offset in overlay context
- Hide top bar and sub-header tabs together on scroll down
2026-03-28 00:36:07 -05:00
Alex Gleason c613a7aedd npm audit fix 2026-03-28 00:30:35 -05:00
Alex Gleason d4d502f418 Loosen engines constraint to node >=22 2026-03-28 00:30:14 -05:00
Alex Gleason 7f37f16c7b Upgrade React from 18.3 to 19.2
- Upgrade react, react-dom to ^19.2.4 and @types/react, @types/react-dom to v19
- Upgrade @nostrify/react to 0.4.0 (peer deps fix for React 19)
- Upgrade vaul to 1.1.2 and react-day-picker to 9.14.0 for React 19 compatibility
- Fix useRef() calls to pass explicit initial values (required in React 19)
- Update RefObject types to include null (React 19 type change)
- Rewrite Calendar component for react-day-picker v9 classNames API
- Add npm overrides for @emoji-mart/react (only remaining React 18 holdout)
2026-03-28 00:29:37 -05:00
Chad Curtis a67d007435 Merge branch 'fix/badge-profile-and-give-badge' into 'main'
Add top badges to profile and 'Give badge' to profile menu

Closes #189 and #185

See merge request soapbox-pub/ditto!121
2026-03-28 05:19:02 +00:00
Chad Curtis a5c6645d2d Fix GiveBadgeDialog bugs and DRY up ProfileMoreMenu handlers 2026-03-28 00:16:18 -05:00
Alex Gleason cbe50a0232 Merge branch 'main' of gitlab.com:soapbox-pub/ditto 2026-03-28 00:01:19 -05:00
Alex Gleason a48ac48202 Trim Sentry bundle by tree-shaking unused replay/feedback/canvas modules
Use named imports in the dynamic import of @sentry/react so the bundler
can drop re-exported modules we never reference (replay 207K, feedback 67K,
replay-canvas 25K). Also set defaultIntegrations: undefined to prevent
Sentry from pulling those modules at runtime.

Sentry chunk: 431K → 128K (-70%).
2026-03-28 00:00:21 -05:00
Mary Kate Fain 0a7aaca6e5 Move badges from sidebar to profile bio section
Display badge preview inline in the profile bio area (after the
about text) as a horizontal row of thumbnails, matching the style
used in the profile hover card. This works on both desktop and
mobile since it's part of the main profile content.
2026-03-27 23:47:01 -05:00
Mary Kate Fain 6e41ea3b42 Add badges to profile sidebar and 'Give badge' to profile menu
- Show accepted badges in the profile right sidebar (fixes #189)
- Add 'Give badge' option to the profile 3-dot overflow menu,
  allowing users to award their created badges directly from
  a user's profile (fixes #185)
2026-03-27 23:47:01 -05:00
Chad Curtis 75ada621d9 Merge branch 'fix/font-picker-click-through' into 'main'
Fix: Prevent font picker clicks from passing through to theme selector

Closes #171

See merge request soapbox-pub/ditto!115
2026-03-28 04:44:53 +00:00
Alex Gleason 12d578ff57 Improve bundle chunking: lazy-load emoji picker, markdown, and remove runtime tailwind config
- Hardcode md breakpoint in useIsMobile and toaster to eliminate runtime
  import of tailwind.config (was pulling in tailwindcss, postcss-selector-parser,
  and plugin code ~100KB)
- Lazy-load EmojiPicker in ComposeBox (emoji-mart + data ~500KB deferred)
- Dynamic import webxdcMeta.ts (smol-toml + fflate only loaded for .xdc uploads)
- Lazy-load ArticleContent, PullRequestCard, CustomNipCard in NoteCard and
  PostDetailPage (react-markdown + unified pipeline ~147KB deferred)
- Consolidate 60+ lucide-react icon micro-chunks into a single chunk via
  manualChunks, reducing HTTP request overhead
- ReplyComposeModal chunk: 808KB -> 296KB (-63%)
- JS file count: 226 -> 181 (-45 files)
2026-03-27 23:44:20 -05:00
Chad Curtis 3486b7f503 Merge remote-tracking branch 'origin/main' into fix/font-picker-click-through
# Conflicts:
#	src/components/FontPicker.tsx
2026-03-27 23:43:46 -05:00
Chad Curtis 0aed5e0f31 Merge branch 'fix/emoji-picker-contrast' into 'main'
Fix: Improve contrast on focused emoji category text in picker

Closes #174

See merge request soapbox-pub/ditto!116
2026-03-28 04:41:26 +00:00
Alex Gleason 34c40980e3 Merge branch 'remove-dev-buttons' into 'main'
Removing dev buttons

See merge request soapbox-pub/ditto!127
2026-03-28 03:09:08 +00:00
Alex Gleason b13eb6012c Merge branch 'main' of gitlab.com:soapbox-pub/ditto 2026-03-27 20:59:00 -05:00
Alex Gleason ecc3284a94 Improve code splitting: reduce index chunk from 1021K to 494K
- Lazy-load BlobbiCompanionLayer (~450K blobbi code off critical path)
- Split BlobbiActionsProvider into lightweight file to avoid pulling
  heavy blobbi action system into the index chunk
- Fix HomePage eagerly importing all 18 page components; use lazy()
  so only the configured homepage's chunk is loaded
- Lazy-load ReplyComposeModal in AppRouter and FloatingComposeButton
  to defer emoji-mart (~620K) until compose is opened
- Fix barrel import in App.tsx pulling BlobbiDevEditor into index;
  use direct import from EmotionDevContext instead
2026-03-27 20:56:36 -05:00
filemon b10c8ff182 Hide Blobbi dev controls on deployed environments
Add isLocalhostDev() helper that checks both import.meta.env.DEV AND
hostname (localhost/127.0.0.1/0.0.0.0). This ensures dev buttons only
appear during local development, never on deployed apps.

Dev controls now hidden in production:
- Dev Hatch/Evolve instant transition buttons
- Dev State Editor button
- Dev Emotion Tester button
2026-03-27 22:40:17 -03:00
Alex Gleason d96e222a15 Merge branch 'main' of gitlab.com:soapbox-pub/ditto 2026-03-27 20:40:02 -05:00
filemon ec63533108 Merge branch 'main' into remove-dev-buttons 2026-03-27 22:32:47 -03:00
Alex Gleason 6f74366dd9 Restore code splitting with React.lazy for all non-critical page routes
Convert 44 page imports from static to React.lazy() with dynamic imports.
Only HomePage, Index, and NotFound remain eagerly loaded as critical-path
pages. The existing Suspense boundary in MainLayout (with PageSkeleton
fallback) already wraps the content area, so lazy pages show a skeleton
while loading without affecting the sidebar or navigation.
2026-03-27 20:30:32 -05:00
filemon 4b5825790a Fix daily missions UI not updating after reroll/claim
Replace useLocalStorage with direct localStorage reads that re-trigger
when the 'daily-missions-updated' event fires. The previous approach
cached state internally and didn't see same-tab localStorage writes.

Now when mutations write to localStorage and dispatch the event:
- Version counter bumps
- useMemo re-reads from localStorage
- UI updates immediately without page refresh
2026-03-27 22:29:20 -03:00
Alex Gleason c257e61fa7 Add bundle analyzer (rollup-plugin-visualizer) to the build 2026-03-27 20:26:21 -05:00
filemon f7391c0e0b Rebalance daily mission rewards to match shop economy
- Increase easy mission rewards to 25-30 coins
- Increase medium mission rewards to 45-50 coins
- Increase high-effort missions (photo, multiple feeds) to 55-70 coins
- Increase medicine missions to 60-70 coins (since medicine costs coins)
- Increase Daily Champion bonus from 50 to 80 coins

Target daily economy: 130-180 coins normal day, 210-250 with bonus
2026-03-27 22:24:48 -03:00
Alex Gleason dce3d5b411 Merge branch 'feat-blobbi' into 'main'
Blobbi: add core pet system, interactions, missions, etc

See merge request soapbox-pub/ditto!104
2026-03-28 01:23:11 +00:00
Alex Gleason a2490da3b4 Add badge kinds (30009, 30008) to the Ditto tab feed 2026-03-27 20:22:02 -05:00
Alex Gleason 0bd4877dd3 release: v2.1.1 2026-03-27 20:22:00 -05:00
filemon 1aacc0073f Fix egg-compatible missions and add medicine missions for reroll
Mission pool changes:
- Add 'medicine' action type for giving medicine to Blobbi
- Add medicine_1 (30 coins) and medicine_2 (50 coins) missions
- Mark clean, sing, play_music, medicine as available for ALL stages (egg+baby+adult)
- Keep interact, feed, sleep, take_photo as baby/adult only

Egg users now have 8 valid missions:
- clean_1, clean_2
- sing_1, sing_2
- play_music_1, play_music_2
- medicine_1, medicine_2

This ensures egg-only users always have alternatives when rerolling
(need at least 4 missions for 3 daily + 1 reroll target)
2026-03-27 22:14:40 -03:00
Alex Gleason 6195ae6901 Merge branch 'main' of gitlab.com:soapbox-pub/ditto 2026-03-27 20:10:58 -05:00
filemon 6f2d80b99e Fix mission reroll - expand pool and add state migration
- Expand DAILY_MISSION_POOL from 7 to 15 missions with more variety
  - Multiple difficulty tiers for feed, clean, interact, sing, play_music, take_photo
  - Ensures always having alternatives when rerolling
- Fix selectReplacementMission to properly exclude only active missions
- Add state migration for rerollsRemaining in both hooks
  - Old localStorage states without rerollsRemaining now get 3 rerolls
- Improve getRerollsRemaining to handle undefined/null values
- Better error message when pool is exhausted
2026-03-27 22:10:40 -03:00
filemon a8e7901eac Add mission reroll feature for daily missions
- Add rerollsRemaining to DailyMissionsState (max 3 per day, resets daily)
- Add rerollMission() function with stage-aware replacement selection
- Replacement avoids duplicates and the mission being replaced
- Add useRerollMission hook for mutation with toast feedback
- Add reroll button (RefreshCw icon) to incomplete missions
- Show remaining rerolls count at top of mission list
- Disable reroll for completed/claimed missions
- Bonus mission still works correctly after rerolling
2026-03-27 22:01:50 -03:00
filemon d4ae9d9611 Add stage-based daily mission filtering and bonus mission reward
- Add requiredStages property to mission definitions (all current missions require baby/adult)
- Update selectDailyMissions() to filter by user's available Blobbi stages
- Show 'Hatch Your Blobbi First' message when user only has eggs
- Add 50-coin 'Daily Champion' bonus mission after completing all regular missions
- Bonus mission appears locked until all regular missions are completed
- Update useClaimMissionReward hook to support claiming bonus rewards
- Pass availableStages through modal props for proper filtering
2026-03-27 21:56:41 -03:00
filemon d82a3cffe8 Allow empty name input during Blobbi egg adoption editing
Previously, clearing the name input would immediately restore it to 'Egg',
which made for a frustrating UX when trying to fully clear and retype.

Now:
- Name input can be fully cleared while editing
- Validation error shows when name is empty
- Adopt button is disabled when name is empty/whitespace
- Only validate on submit, not on every keystroke
2026-03-27 21:43:06 -03:00
filemon 05e189b938 Fix SVG ID prefix to use full Blobbi ID instead of first 8 chars
The previous fix used instanceId.slice(0, 8) for the prefix, but since
Blobbi IDs have the format 'blobbi-{pubkeyPrefix12}-{petId10}', the first
8 characters are always 'blobbi-' for all Blobbis owned by the same user.

This caused gradient ID collisions between different Blobbis.

Now using the full sanitized instanceId as the prefix:
  b_blobbi-abc123456789-xyz1234567

This ensures each Blobbi gets truly unique SVG IDs.
2026-03-27 21:39:01 -03:00
filemon d32d0b17d0 Fix gradient ID collisions causing wrong colors in Blobbi modal list
When multiple Blobbis are rendered on the same page (like in the selector modal),
they all shared the same SVG gradient IDs (e.g., cattiBody3D, blobbiBodyGradient).
The browser only uses the first definition of each ID, so all subsequent Blobbis
would use the first one's colors instead of their own.

Fixed by:
- Adding uniquifySvgIds() function to both adult and baby SVG customizers
- Generating unique prefixes from each Blobbi's ID (first 8 characters)
- Prefixing all SVG IDs and updating all references (url(), href, xlink:href)
2026-03-27 21:28:04 -03:00
Alex Gleason 3a8282255c Revert "Hide top nav on scroll and unify top padding across pages"
This reverts commit 488ce5750d.

Restores sticky (non-fixed) mobile top bar, removes scroll-based
hide/show behavior, reverts pt-mobile-bar back to -mt-mobile-bar
negative margin approach, and removes ARC_OVERHANG_PX spacers from
NotificationsPage and ProfilePage.
2026-03-27 19:26:14 -05:00
Mary Kate 79e97fae09 Merge branch 'fix/toast-swipe' into 'main'
Fix toast swipe direction on mobile

See merge request soapbox-pub/ditto!120
2026-03-28 00:18:25 +00:00
Mary Kate Fain 9b93881663 Merge remote-tracking branch 'origin/main' into fix/toast-swipe
# Conflicts:
#	src/components/ui/toast.tsx
#	src/components/ui/toaster.tsx
2026-03-27 19:14:08 -05:00
Mary Kate 21df47eccb Merge branch 'fix/compose-preview-overflow' into 'main'
Fix compose preview overflow not being scrollable in modal

Closes #179

See merge request soapbox-pub/ditto!126
2026-03-28 00:07:25 +00:00
filemon 1d87315426 Adjust eyebrow position higher for owli and froggi forms 2026-03-27 21:01:17 -03:00
Alex Gleason 4a1e21e820 Merge branch 'main' of gitlab.com:soapbox-pub/ditto 2026-03-27 19:01:02 -05:00
Alex Gleason a8181c45d0 Regenerate iOS app icon 2026-03-27 18:59:59 -05:00
Alex Gleason 84ca17ebc4 Add iOS icon generation to generate-icons.sh 2026-03-27 18:54:51 -05:00
filemon 4db0e8870d Fix clipPath ID collision causing eyes to disappear with multiple Blobbis 2026-03-27 20:54:06 -03:00
Mary Kate Fain 310993d57c Fix compose preview overflow not being scrollable in modal 2026-03-27 18:51:14 -05:00
Mary Kate d624b93d8c Merge branch 'fix/mobile-emoji-picker-close' into 'main'
Fix compose modal closing when dismissing emoji picker on mobile

See merge request soapbox-pub/ditto!125
2026-03-27 23:46:40 +00:00
Mary Kate Fain 872d319220 Fix compose modal closing when dismissing emoji picker on mobile
Prevent the compose modal from being accidentally dismissed when the user
taps the emoji/GIF picker overlay to close it.  On mobile this was very
easy to trigger, causing the draft to be lost.

Add onInteractOutside and onEscapeKeyDown handlers to the compose modal's
DialogContent that detect when a nested dialog (emoji picker) is open and
prevent the dismiss event from propagating to the parent modal.
2026-03-27 18:31:58 -05:00
Mary Kate cd44ae6bc0 Merge branch 'fix/notification-kind-labels' into 'main'
Use kind-specific labels in notification action text instead of generic 'post'

See merge request soapbox-pub/ditto!124
2026-03-27 23:25:35 +00:00
filemon deb59b314b Fix adult eye layering by excluding EyeBase gradients from eye white detection
The isEyeWhiteElement function was incorrectly matching colored eye rim gradients
like froggiEyeBase3D (green frog eye bulge) because it matched any gradient with
'Eye' in the name. This caused the eyelid to be placed behind the eye base layer,
making only the eyelid visible.

Now the detection:
- EXCLUDES EyeBase patterns (colored eye rims)
- INCLUDES EyeWhite patterns (actual white of eye)
- INCLUDES generic Eye gradients without 'Base' (baby Blobbi, etc.)
2026-03-27 20:25:32 -03:00
filemon 4c75d4f559 Add missing form-specific color customizers for adult Blobbi SVGs
Complete the color mapping system by adding customizers for:
- breezy: body, inner, veins, arms, legs, floating leaves
- bloomi: all 6 petals with color variations, center, pollen
- cacti: body, arms (pot keeps original red)
- cloudi: body, highlights, raindrops
- crysti: body, inner (facets keep colorful nature)
- owli: body, ears, wings (beak keeps yellow/orange)

Pandi intentionally excluded as it's a panda with black/white coloring by design.
2026-03-27 20:17:26 -03:00
Mary Kate Fain c5bc900212 Use kind-specific labels in notification action text instead of generic 'post'
Notifications now say 'reacted to your badge', 'reposted your theme',
'commented on your nsite', etc. instead of always saying 'your post'
or 'your note'. Uses the referenced event's kind to look up a
human-readable noun from a comprehensive kind-to-label map.
2026-03-27 18:14:18 -05:00
filemon 8bd2bca879 Add form-specific color customizers for adult Blobbi SVGs
Implement comprehensive gradient replacement for each adult form to ensure
Blobbi custom colors are properly applied to all visual elements (body, ears,
tail, arms, legs, petals, etc.) while preserving 3D shading gradients.

Forms with full color mapping: catti, droppi, flammi, froggi, leafy, mushie,
rocky, rosey, starri. Forms owli/pandi keep original colors by design.
2026-03-27 20:12:01 -03:00
Mary Kate 27283384bf Merge branch 'fix/zap-emoji-support' into 'main'
Add emoji picker and shortcode autocomplete to zap comment box

Closes #176

See merge request soapbox-pub/ditto!123
2026-03-27 23:02:55 +00:00
Mary Kate Fain 9901635008 Add zap button to badge detail view action bar
The BadgeDetailContent action bar had reactions, reposts, and comments
but was missing the zap button that NoteCard renders for the same
events in the feed.
2026-03-27 17:52:52 -05:00
Mary Kate Fain 1f5ce2546c Add emoji picker and shortcode autocomplete to zap comment box
Extract shared useInsertText hook to DRY up the duplicated text
insertion logic across ComposeBox, DMChatArea, and ZapDialog.
Add EmojiPicker (GUI) and EmojiShortcodeAutocomplete (:shortcode
typing) to the zap comment textarea, and also add shortcode
autocomplete to the DM chat input which was previously missing it.

Closes #176
2026-03-27 17:46:16 -05:00
Mary Kate 2f0adcce7c Merge branch 'fix/theme-description-expand' into 'main'
Show full theme description on theme detail page

Closes #124

See merge request soapbox-pub/ditto!122
2026-03-27 22:34:08 +00:00
Lemon 7bfab65042 Fix toast swipe direction to match entry direction on mobile
On mobile, toasts enter from the top but previously could only be swiped
right to dismiss. Now swipe direction is responsive: swipe up on mobile
(top-positioned), swipe right on desktop (bottom-right positioned). Exit
animations also match the swipe direction at each breakpoint.
2026-03-27 15:13:43 -07:00
Mary Kate Fain b59eeeca81 Show theme description on 'updated their theme' (kind 16767) posts
Kind 16767 events previously hardcoded description to undefined, so the
theme description never appeared on 'updated their theme' posts in the
feed or detail view.

Three changes:
- buildActiveThemeTags now accepts and includes a description tag, so
  future kind 16767 events carry the description directly
- setActiveTheme accepts description to thread it through publishing
- ThemeContent extracts the description tag from kind 16767 events, and
  for older events without one, falls back to querying the source theme
  definition via the a-tag reference
2026-03-27 17:09:08 -05:00
Mary Kate Fain 81e42f24c8 Exclude Capacitor native-only plugins from Vite dep optimization
@capacitor/filesystem and @capacitor/share are dynamically imported
behind a Capacitor.isNativePlatform() guard, but Vite's import analysis
plugin still tries to resolve them at transform time in dev mode. This
causes a 'Failed to resolve import' error when running the dev server.

Excluding them from optimizeDeps prevents Vite from pre-bundling these
packages, letting the dynamic imports resolve naturally at runtime.
2026-03-27 16:59:24 -05:00
Mary Kate Fain d4a928b682 Show full theme description on post detail page
On the feed, theme descriptions are truncated to a single line. On the
post detail page, the full description is now displayed so users can
read long descriptions that don't fit in the thumbnail card.

Closes #124
2026-03-27 16:52:20 -05:00
Alex Gleason da27054a9b Fix file downloads and URL opening on Capacitor iOS
The <a download> and <a target="_blank"> patterns don't work in
WKWebView. Add downloadTextFile() and openUrl() utilities in
src/lib/downloadFile.ts that use @capacitor/filesystem and
@capacitor/share on native platforms, falling back to standard
browser behavior on web.

Update all call sites: onboarding key download (InitialSyncGate,
SignupDialog), image lightbox buttons (ImageGallery, ProfilePage).

Document Capacitor compatibility constraints in AGENTS.md.
2026-03-27 16:42:36 -05:00
Alex Gleason 3c54cd27fe Fix package-lock name 2026-03-27 15:41:39 -05:00
Alex Gleason 8fe8525b06 Merge branch 'main' of gitlab.com:soapbox-pub/ditto 2026-03-27 15:40:40 -05:00
Mary Kate 9169cd5d1f Merge branch 'fix/letter-notifications' into 'main'
Fix: add letter (kind 8211) notifications

Closes #188

See merge request soapbox-pub/ditto!118
2026-03-27 20:30:42 +00:00
Mary Kate 490b8554e2 Merge branch 'badge-notification-preview' into 'main'
Show badge thumbnail preview in badge award notifications

Closes #186

See merge request soapbox-pub/ditto!119
2026-03-27 20:29:16 +00:00
filemon 97748dfd34 Remove page entry from streak triggers - only real interactions count
Streak now only updates from actual care interactions:
- Direct actions (play_music, sing)
- Inventory item use (feed, clean, treat, etc.)
- Stage transitions (hatch, evolve)
- Rest action (sleep/wake toggle)

Page visits and app opens no longer count toward streak.
2026-03-27 17:08:27 -03:00
filemon 0d8b320f31 Implement improved Blobbi care streak system with day-based tracking
Add new streak tags to kind 31124 events:
- care_streak: Consecutive days of care (starts at 1, resets to 1 if 2+ days missed)
- care_streak_last_at: Unix timestamp of last streak update
- care_streak_last_day: Local calendar day (YYYY-MM-DD) of last update

Streak validation rules:
- Initialize to 1 on first activity
- Increment when activity occurs on the next local day
- Same-day activity does not increment (at most once per day)
- Missing 2+ days resets streak to 1

Files added:
- blobbi-streak.ts: Centralized streak calculation logic
- useBlobbiCareActivity.ts: Hook for registering care activity

Streak integration points:
- Blobbi page entry (automatic check-in)
- Direct actions (play_music, sing)
- Inventory item use
- Stage transitions (hatch, evolve)
- Rest action (sleep/wake)
- Companion item use (outside BlobbiPage)
2026-03-27 17:01:35 -03:00
Alex Gleason 32b0cef65d Change toast swipe direction from right to up for mobile dismissal 2026-03-27 14:57:51 -05:00
Mary Kate Fain abcb51c0e2 Show badge thumbnail preview in badge award notifications
Closes #186. Badge award notifications now display a visual preview
card with the badge image, name, and description for both single and
grouped badge notifications.
2026-03-27 14:44:33 -05:00
Mary Kate Fain 8d02645e26 Enhance letter notification with envelope card preview
Show the sender's profile pic via the EnvelopeCard component (the same
Wii-Mail-inspired envelope tile used in the letters inbox). The card
auto-decrypts to display stationery colors and the sender's avatar as a
wax seal. Clicking the envelope navigates to /letters.
2026-03-27 14:31:05 -05:00
filemon 6ab05471b2 Migrate Blobbonaut profile from kind 31125 to 11125 with auto-onboarding
- Change KIND_BLOBBONAUT_PROFILE constant from 31125 to 11125
- Add KIND_BLOBBONAUT_PROFILE_LEGACY (31125) for migration support
- Add BLOBBONAUT_PROFILE_KINDS array to query both kinds
- Update useBlobbonautProfile to prefer 11125 over 31125
- Add needsKindMigration flag for legacy profile detection
- Extend useBlobbonautProfileNormalization to auto-migrate legacy kinds
- Refactor onboarding to auto-create profile using kind 0 name
- Remove manual name entry step from onboarding flow
- Delete unused BlobbiProfileOnboarding component
- Update all documentation comments to reference kind 11125
2026-03-27 16:27:26 -03:00
Mary Kate Fain f82adab05d Fix: add letter (kind 8211) notifications
Letters were completely absent from the notification pipeline — users had
to visit the Letters page to discover incoming letters. This integrates
kind 8211 into every layer of the notification system:

- useNotifications: query, grouping, and referenced-event exclusion
- useHasUnreadNotifications: unread dot indicator
- NotificationsPage: LetterNotification component with link to /letters
- NotificationSettings: toggleable Letters row
- notificationTemplates: web push template
- Android NotificationRelayService + NostrPoller: native push support
- EncryptedSettings + schema: letters preference field

Closes #188
2026-03-27 14:15:50 -05:00
filemon 1291a0e932 Fix: ensure t and client tags are removed from Blobbi events
The previous commit removed these tags from event building functions, but
the tag validation/repair system in blobbi-tag-schema.ts was re-adding them:

1. BLOBBI_TAG_SCHEMA had 't' marked as required:true with defaultValue:'blobbi'
2. RECOVERABLE_SYSTEM_TAGS had both 't' and 'client' with default values
3. DEPRECATED_TAG_SCHEMA did not include 't' or 'client'

Fixed by:
- Removing 't' and 'client' from BLOBBI_TAG_SCHEMA (no longer required)
- Removing 't' and 'client' from RECOVERABLE_SYSTEM_TAGS
- Adding 't' and 'client' to DEPRECATED_TAG_SCHEMA

Now the validateAndRepairBlobbiTags function will properly filter out
these tags during any republish/migration/update flow.
2026-03-27 15:58:44 -03:00
filemon a2d40c5cbf Remove Blobbi shape system and clean up t/client tags
- Remove BLOBBI_TOPIC_TAG and BLOBBI_CLIENT_TAG from event building
- Add t and client to DEPRECATED_BLOBBI_TAG_NAMES for migration cleanup
- Update validation functions to not require t tag
- Delete blobbiShapes.ts and BlobbiShapePicker.tsx entirely
- Simplify avatarShape.ts to only support emoji shapes
- Remove blobbi_shape task from useEvolveTasks
- Remove change_shape task from useHatchTasks
- Remove change_shape mission from daily-missions
- Clean up ProfileCard shape picker to only show emoji picker

The app's useNostrPublish hook already adds client tags automatically,
making the explicit client tag redundant. Old events with these tags
will have them stripped on next save.
2026-03-27 15:54:20 -03:00
Alex Gleason 17954e0504 Fix badges page showing infinite skeleton when logged out
The useBadgeFeed hook required a logged-in user before enabling the query,
causing the follows tab to show loading skeletons forever when logged out.
Now fetches the Team Soapbox follow pack (kind 39089) and uses its members
as the authors filter, giving logged-out users a curated badge feed.
2026-03-27 13:15:44 -05:00
filemon 2e5e6c9ad3 feat: add 'hungry' emotion for Blobbi
New emotion that conveys low energy + wanting food:
- Watery eyes (like sad) but WITHOUT blue water fill - longing, not crying
- Worried/sad eyebrows for that wanting/longing look
- Droopy mouth - less curved than sad frown, softer and more tired
- Small drool drop from corner of mouth with subtle wobble animation
- Fork & knife icon above head (subtle, 65% opacity)

Also adds new config types:
- DroolConfig: drool drop effect
- FoodIconConfig: utensils/plate icon above head
- DroopyMouthConfig: weak/tired frown with adjustable width and curve
2026-03-27 12:56:45 -03:00
filemon c1e9143483 fix: add missing pupil colors for adult Blobbi eye detection
Added #1e1b4b (dark indigo) for starri/crysti and #0891b2 (cyan) for
droppi to the PUPIL_COLORS array. Without these colors, the eye
animation system couldn't detect pupils in these adult forms, causing
eyes to not render properly (only showing eyelids).
2026-03-27 12:25:59 -03:00
filemon 774f7d2dbe Add sparkles around Blobbi and create excited variation B
Sparkles:
- Move sparkles from around eyes to around entire Blobbi body
- 11 sparkles distributed around the perimeter (top, sides, bottom)
- Subtle fade/twinkle animation with staggered timing
- Soft opacity (0.7 max) for gentle effect

Excited variations:
- Excited A (original): star eyes + big smile
- Excited B (new): star eyes + round 'O' mouth (like curious)
- Both include sparkles around the Blobbi
- Added excitedB to emotion tester panel for comparison
2026-03-27 12:16:25 -03:00
filemon de9eab1e4e Finalize emotion visual tweaks
Sleepy closed-eye lines:
- Increase curve depth (0.5x radius) to match eye curvature
- Position slightly lower (0.75x radius offset)
- Disappear immediately at 63% (as eyes start opening)

Excited star eyes:
- Reduce star scale from 1.4 to 0.9 for cuter look
- Insert stars INTO blobbi-eye groups so they track with eye movement
- Stars now follow mouse cursor like normal pupils

Excited sparkles:
- Add 4 animated sparkles around each eye
- Small 4-pointed star shapes that twinkle
- Staggered animations (0.3s delay between each)
- Positioned at orbit around the eyes
2026-03-27 12:07:27 -03:00
filemon 9b1925d6de Refine excited, sleepy, and adoring emotions
Excited eyes:
- Keep white eye circle visible behind the star
- Only hide pupils (.blobbi-eye), not entire blink group

Sleepy animation:
- Update to use new clip-path blink system
- Add SMIL animations to clip-path rects for eye closing
- Remove old scaleY CSS animation
- Eyes now close with natural eyelid-down effect

Adoring eyes:
- Add includeWaterFill option to PupilModification
- Adoring uses watery highlights but no blue fill
- Sad retains the blue watery semicircle
2026-03-27 12:02:24 -03:00
filemon a6ad1bdcdb Refine blink animation and eyelid system
Blink behavior:
- Change from scaleY to clip-path mask approach
- Eye keeps original size, visible area cropped from top to bottom
- Creates natural eyelid-closing effect revealing eyelid layer behind
- Add clipPath definitions and animate rect Y/height

Eyelid color:
- Reduce darken amount from 15% to 8%
- Subtle contrast that reads as eyelid, not shadow

Sleepy closed-eye lines:
- Lower position by 70% of eye radius
- Aligns with final closed eye position in clip-path system
2026-03-27 11:53:25 -03:00
filemon f9c8bbc4cc Add eyelid background layer and update excited emotion with star eyes
Eyelid layer:
- Add blobbi-eyelid ellipse behind each eye white
- Derive color from base body color (darkened by 15%)
- Pass baseColor to addEyeAnimation from visual components
- Ready for future blink animation integration

Excited emotion:
- Replace eyes with 5-pointed golden stars
- Add big smile (30% wider, 40% deeper curve)
- Hide normal eyes when stars are active
- Clean, readable across baby and adult variants
2026-03-27 11:44:30 -03:00
filemon 3634440c8b Update excited emotion and add adoring emotion
- excited: use normal eyes, keep animated eyebrows, use curious round mouth
- adoring: watery eyes (like sad), no eyebrows, curious round mouth
- Add adoring to emotion tester panel
2026-03-27 11:37:18 -03:00
Chad Curtis 488ce5750d Hide top nav on scroll and unify top padding across pages
- MobileTopBar: changed from sticky to fixed positioning with scroll-hide
  transform animation (mirrors bottom nav behavior via useScrollDirection)
- MainLayout: replaced -mt-mobile-bar overlap trick with pt-mobile-bar
  padding since the top bar is now fixed; added data-nav-hidden attribute
  to drive CSS transitions on sticky sub-headers
- SubHeaderBar/top-mobile-bar: sticky top offset transitions to 0 when
  the top bar hides, keeping sub-headers flush with the viewport top
- NotificationsPage, ProfilePage: added arc overhang spacer after
  SubHeaderBar to match Feed's spacing
2026-03-27 02:07:12 -05:00
filemon c963673a19 feat(blobbi): add dizzy, excited, mischievous emotions with animated effects
- Add dizzy emotion with rotating spiral eyes (counter-clockwise)
- Add excited emotion with watery eyes, bouncing sad-style eyebrows, and smile
- Add mischievous emotion with bouncing angry-style eyebrows and small smug smile
- Wrap eyebrows in groups to preserve rotation while CSS animates translateY
- Add new emotions to DEV emotion panel for testing
2026-03-27 02:17:49 -03:00
Alex Gleason 6e2589e125 fix: use project-relative path for release notes file 2026-03-26 23:57:24 -05:00
Alex Gleason d980fdf96d Merge branch 'main' of gitlab.com:soapbox-pub/ditto 2026-03-26 23:42:19 -05:00
Alex Gleason 89fe5b8937 fix: use native release: keyword instead of glab to fix 403 permission error 2026-03-26 23:42:04 -05:00
Chad Curtis 93bc669f24 Lazy-load hls.js in VideoPlayer to reduce main bundle by ~500KB 2026-03-26 23:33:09 -05:00
filemon 0602c1b59d refine(blobbi): adjust sleepy animation timing and transitions
Three targeted refinements to sleepy emotion:

1. Mouth transition simplified:
   - Now goes directly: smile → U-shaped → smile
   - Removed intermediate flat line phase
   - Smoother, more natural transition

2. Eyes fully hidden when closed:
   - Changed scaleY from 0.05 to 0 when fully closed
   - Original eye completely disappears
   - Only curved closed-eye line visible during sleep

3. Zzz appears from the beginning:
   - Starts at 0% with opacity 0
   - Fades in softly: 10% → 0.2, 20% → 0.4
   - Full opacity by 35% (during sleep)
   - Creates 'getting sleepy' feel from the start
2026-03-27 01:33:04 -03:00
Alex Gleason 6e2716d957 Strengthen commit requirement in AGENTS.md: always commit after changes 2026-03-26 23:31:53 -05:00
Alex Gleason 9c3ec58246 Rename package from mkstack to ditto 2026-03-26 23:31:24 -05:00
filemon d8a81879b1 feat(blobbi): redesign sleepy emotion as micro-sleep animation
Complete rewrite of sleepy emotion to be a 'micro-sleep' cycle:

Eye Animation:
- Slow eye closing over ~3 seconds (not partial blinks)
- Eyes fully close with scaleY near 0
- Curved closed-eye lines appear when eyes shut (like eyelids)
- Wake-up includes quick right-left glance before settling

Mouth Animation:
- Smile → flat line → small round mouth (while falling asleep)
- Small round mouth holds during sleep
- Reverses back: round → flat → smile (while waking)
- Uses SMIL path animation for smooth morphing

Sleep State:
- Zzz text appears above head during sleep
- Zzz fades in/out and floats upward
- Sleep holds for ~1 second before waking

Full 8-second cycle timeline:
- 0-10%: Awake, normal smile
- 10-35%: Getting sleepy, eyes closing, mouth flattening
- 35-50%: Eyes closed, mouth becomes round
- 50-62%: Asleep (hold with Zzz)
- 62-75%: Waking, eyes opening
- 75-90%: Quick right-left glance
- 90-100%: Return to normal, cycle repeats
2026-03-27 01:27:46 -03:00
filemon c054bc7bc7 Merge branch 'main' into feat-blobbi 2026-03-27 01:25:27 -03:00
Alex Gleason c46e7b98e0 npm audit fix 2026-03-26 23:22:21 -05:00
Alex Gleason 65762e8645 skill: fix MARKETING_VERSION occurrence count in release instructions 2026-03-26 23:20:38 -05:00
Alex Gleason 689ac34946 skill: pull before committing release to prevent tag/rebase issues 2026-03-26 23:18:22 -05:00
filemon daf35f6e41 feat(blobbi): add curious asymmetric eyebrow and sleepy animation
Curious emotion:
- Right eyebrow now raised slightly more than left for questioning look
- Added per-eye override support in EyebrowConfig (leftEyeOverride/rightEyeOverride)

Sleepy emotion:
- Implemented 3-stage tired blink animation cycle:
  1. Small blink (~25% closed)
  2. Medium blink (~55% closed)
  3. Heavy blink (~80% closed)
- Mouth animates from smile → flat → smile in sync with blinks
- Uses CSS keyframe animation for smooth, slow transitions
- 8-second cycle duration for natural tired feel
- Added SleepyAnimationConfig type for configuration
2026-03-27 01:17:05 -03:00
Alex Gleason 58a5c470bd release: v2.1.0 2026-03-26 23:14:03 -05:00
Chad Curtis aecddf6fb5 Fix drawing canvas 'done' button clipped by drawer max-height 2026-03-26 23:13:42 -05:00
Alex Gleason f702513a64 Merge branch 'main' of gitlab.com:soapbox-pub/ditto 2026-03-26 23:02:47 -05:00
Alex Gleason 3126ad2380 fix: pass CI_JOB_TOKEN to glab in release job 2026-03-26 23:02:06 -05:00
filemon d947a951ad feat(blobbi): add curious/surprised emotions and adjust baby eyebrows
- Add 'curious' emotion: small round mouth, soft curved eyebrows
- Add 'surprised' emotion: larger round mouth, raised curved eyebrows
- Add round mouth generation (replaces default mouth like sad/angry)
- Add curved eyebrow support via new 'curve' config option
- Move baby eyebrows slightly farther from eyes (-2px offset)
- Add variant parameter to applyEmotion for baby/adult differences
- Add both new emotions to DEV emotion panel
2026-03-27 01:00:01 -03:00
Chad Curtis 51fffc0ae1 Merge branch 'envelope-inbox' into 'main'
Add Letters inbox page with full letter composing from lief

See merge request soapbox-pub/ditto!117
2026-03-27 03:59:48 +00:00
filemon 273cf1094d fix(blobbi): fix sad eye highlights and add angry body animation
- Fix invalid SVG generated by sad highlight injection regex
- Refactor water fill to insert inside blink groups (below pupil, above eye white)
- Add anger-rise body effect that animates red color from bottom to top
- Uses clipPath to constrain effect to body shape
2026-03-27 00:51:32 -03:00
Chad Curtis 403946bac5 Use mailbox icon from lief for Letters page and sidebar 2026-03-26 22:43:11 -05:00
Alex Gleason cb81fd3315 fix: push only the specific release tag, never --tags 2026-03-26 22:40:06 -05:00
Alex Gleason 95db4e4dcf release: v2.0.1 2026-03-26 22:39:07 -05:00
Alex Gleason c5fb019702 Clarify release skill uses marketing versioning, not semver 2026-03-26 22:36:06 -05:00
filemon c907779b3c Fix sad eye highlight injection with robust parsing and debug logs
- Replace fragile regex with index-based string manipulation
- Find opening tag first, then locate closing tag by position
- Add DEV-only debug logging for eye detection and injection
- Log whether blobbi-eye groups are found and matched
- Log eye positions and water fill generation
- Should fix sad highlights not appearing in blobbi-eye groups
2026-03-27 00:35:24 -03:00
Alex Gleason c1b33e17c8 release: v2.1.0 2026-03-26 22:31:54 -05:00
Alex Gleason 5fed2f4182 Add pre-release indicator to settings version footer 2026-03-26 22:28:18 -05:00
Alex Gleason b2f62e12c7 Add build info env vars and pre-release banner to changelog
Expose COMMIT_SHA and COMMIT_TAG via import.meta.env at build time.
In CI, these come from GitLab env vars; locally they fall back to git.

The changelog page now shows:
- A pre-release banner when the build is untagged, with a link to
  the GitLab diff between the latest release and main
- An external link icon on each version card header linking to the
  GitLab release page for that version
2026-03-26 22:19:12 -05:00
Alex Gleason 79571cc5b3 Add spacing between changelog header and content 2026-03-26 22:05:41 -05:00
Chad Curtis 3e6b947893 Fix gift attachment blocking backdrop clicks to close letter dialog 2026-03-26 22:00:01 -05:00
Alex Gleason cc59035c62 Redesign changelog page with parsed structured entries
Parse Keep a Changelog markdown into structured data and render
version cards with colored category badges instead of raw markdown.
2026-03-26 21:57:49 -05:00
Chad Curtis 9b1615480f Polish letter attachment: use actual color moment pattern, theme-derived text color, more spacing 2026-03-26 21:56:22 -05:00
Alex Gleason ad59299581 fix: strip duplicate title from changelog page 2026-03-26 21:53:16 -05:00
Alex Gleason 4ac8651cc8 fix: use glab directly for releases instead of dotenv artifact
GitLab's dotenv artifact format doesn't support multi-line heredoc
values, causing the release job to fail with 400 Bad Request. The
release-cli image already includes glab, so use it directly with
--notes-file to pass multi-line changelog content safely.
2026-03-26 21:50:57 -05:00
Alex Gleason a11be64d94 Add in-app changelog page with runtime fetch from public/CHANGELOG.md 2026-03-26 21:47:10 -05:00
Chad Curtis a32c620b4e Add letter attachment: tap to apply embedded color moment or theme as your Ditto theme 2026-03-26 21:40:30 -05:00
Chad Curtis 7a8fbe3ee5 Fix review items 6-10: use default relays, lazy-load fonts, paginate letters, cap SVG size, fix exhaustive-deps 2026-03-26 21:32:18 -05:00
Chad Curtis 13480d528a Replace LetterRecipientInput with ProfileSearchDropdown, fix compose padding 2026-03-26 21:20:59 -05:00
Chad Curtis 7ddaf135b4 Link about dialogs to in-app routes for color moments, themes, and emoji packs 2026-03-26 20:48:05 -05:00
Chad Curtis 848ac15ef0 Fix CI: remove unused prop, centralize color helpers, extract constants, memoize Sets, move @types/dompurify to devDeps 2026-03-26 19:45:51 -05:00
Chad Curtis ae1f97eb08 Letters: bigger wax seals, adaptive text, inline labels, font loading, SVG sticker fix 2026-03-26 19:17:58 -05:00
Chad Curtis a2fa2a6b96 Letters: redesign inbox as Wii Mail-style envelope grid with modal detail view 2026-03-26 18:51:45 -05:00
filemon 2d9ff34ded Fix sad eye tracking/blinking integration and slow down tears
- Hide original highlights by adding opacity=0 inside blobbi-eye groups
- Inject sad highlights INTO blobbi-eye groups so they track with pupil movement
- Sad highlights now move with eye tracking and participate in blinking
- Blue water fill stays as overlay (on eye white, doesn't need tracking)
- Slow tears: duration 6s with 3s pause between cycles
- Alternating tear mode: tears switch sides each cycle (no flickering)
- Only affects SAD emotion, angry/neutral unchanged
2026-03-26 20:32:18 -03:00
filemon d1a85659ba Fix sad eye visuals: reposition water shape and highlights
- Position blue water shape relative to eye white (not pupil center)
- Water now sits at bottom of eye white like pooled tears
- Reposition highlights: upper (larger) and lower (smaller) with clear separation
- Upper highlight at cy - radius*0.55, lower at cy + radius*0.35
- Only affects SAD emotion (generateSadEyeEffects), angry unchanged
2026-03-26 20:28:44 -03:00
filemon d832e6e364 Fix Blobbi sad emotion visuals and dev panel labeling
- Lower sad mouth position by adding Y offset based on curve amount
- Fix sad eye highlights: larger top-left, smaller right-side
- Change blue watery fill to proper lower 1/3 semicircle shape using path
- Fix baby eye detection to match gradient fills (url(#...Pupil...))
- Swap sad/angry eyebrow angles: sad now worried (/\), angry now aggressive (\/)
- Replace mouth safely using regex-only approach (no section slicing)
- Update dev panel to label default as 'Default' with happy emoji
2026-03-26 20:24:05 -03:00
Chad Curtis 08a5c808f8 Letters: send animation with envelope, wax seal, Ditto logo, and pre-rendered letter 2026-03-26 18:22:15 -05:00
filemon 66cfe9ee45 Add Blobbi emotion system with DEV testing panel
- Add EmotionDevContext and BlobbiEmotionPanel for dev-only emotion testing
- Create emotions.ts with configurable emotion overlays (sad, happy, angry, surprised, sleepy)
- Use deterministic tear selection (hash-based) to prevent flickering
- Add marker-based SVG detection with regex fallback for mouth/eye elements
- Update visual components to pass emotion prop through hierarchy
- Add SVG comment markers to all Blobbi base SVGs for reliable element detection
2026-03-26 19:47:36 -03:00
Chad Curtis c2b14e4f07 Letters: send animation scoped to feed column, inbox updates immediately 2026-03-26 17:40:13 -05:00
Chad Curtis 628dd47772 Letters: send animation with envelope, wax seal, and Ditto logo 2026-03-26 17:35:31 -05:00
Chad Curtis f8612ee20e Letters: arc tab bars for compose, prefs, and inbox 2026-03-26 17:31:15 -05:00
Chad Curtis 172bebe24a Move letter tool buttons into SubHeaderBar below app top bar
- LetterEditor: replace stickyHeader/headerLeft with renderToolbar render prop;
  callers decide where to place the tool buttons
- Both ComposeLetterSheet and LetterPreferencesSection: use SubHeaderBar noArc
  + useLayoutOptions({ hasSubHeader: true }) so tools sit in the sticky sub-header
  matching every other tabbed/sub-header page in the app
2026-03-26 16:58:02 -05:00
Chad Curtis 3c45641ef4 Letters inbox: replace custom FAB with FabButton 2026-03-26 16:53:18 -05:00
Chad Curtis 5eb6af1ab6 Compose send: use FabButton matching app FAB shape; extract FabButton component
- Extract FabButton from FloatingComposeButton (avatar shape mask + primary bg)
- FloatingComposeButton now delegates to FabButton
- ComposeLetterSheet: replace inline send button with FabButton FAB,
  fixed bottom-right on mobile, sticky in column on desktop
- Remove separate page-level send button from compose header
2026-03-26 16:51:31 -05:00
Chad Curtis c1b48058d5 Compose header: use ArcBackground to match app top nav curve 2026-03-26 16:45:42 -05:00
Chad Curtis 33ebeec2ac Compose header: sticky, no border 2026-03-26 16:43:39 -05:00
Chad Curtis a3e5ff9f4a Fix letter preferences header to match app PageHeader style
- LetterEditor: add stickyHeader prop (default true); when false renders a plain
  border-b toolbar row instead of sticky backdrop-blurred header
- LetterPreferencesPage: restore PageHeader (back arrow + title) as the page header
- LetterPreferencesSection: use stickyHeader={false}, remove headerLeft slot usage
2026-03-26 16:41:06 -05:00
Chad Curtis 203ef9dd44 Fix letter stationery theme sync and remove drawer card shape
- useLetterPreferences: simplify to just expose raw saved prefs + isThemeDefault flag,
  no longer conflates theme stationery with saved stationery
- LetterPreferencesSection: always pull from useThemeStationery directly when
  isThemeDefault, persist only on explicit user picks (handleSetStationery),
  sync preview live when theme changes
- ComposeLetterSheet: same pattern — init from themeStationery, switch to saved
  pref once settings load, track explicit user picks to avoid theme override
- LetterEditor drawer: remove bg-background / rounded-b-3xl / border-b card shape
2026-03-26 16:27:31 -05:00
Chad Curtis bdfb8f9dc6 Fix letter compose/prefs: use Ditto theme, single toolbar, inline send button
- ComposeLetterSheet: use themeStationery immediately (no parchment fallback),
  switch to saved pref once encrypted settings load, sync with theme changes
- ComposeLetterSheet: move send button to inline flow below the card (no more fixed overlay)
- LetterPreferencesPage: remove PageHeader — back button + title now live inside
  LetterEditor's headerLeft slot, eliminating the double bar
2026-03-26 16:23:45 -05:00
Chad Curtis 865fabce98 Add Letters inbox page with full letter composing from lief
- Port letter protocol (kind 8211, NIP-44 encrypted) from lief
- LettersPage at /letters with inbox and sent tabs
- ComposeLetterSheet with full stationery, font, frame, sticker, drawing support
- LetterCard with expand-to-read animation and deletion
- LetterPreferencesSection stored in encrypted settings (NIP-78)
- /settings/letters route for letter preferences
- Letters added to sidebar nav
- All letter lib utilities: letterTypes, letterUtils, colorUtils extensions, sanitizeSvg, svgDrawing
- StationeryBackground, StationeryPicker, FramePicker, StickerPicker, DrawingCanvas all ported
2026-03-26 16:11:29 -05:00
Alex Gleason e530e38721 fix: prevent font picker clicks from passing through to theme selector
Fixes #171
2026-03-25 18:35:25 -05:00
Alex Gleason d98ae9cdbf fix: consistent feed header ordering — title above tabs on all feed pages
Fixes #153, #155, #156, #157, #158, #159
2026-03-25 18:29:52 -05:00
Alex Gleason 5100b76ad3 fix: improve contrast on focused emoji category text in picker
Increases the opacity of emoji-mart nav button text from 0.65 to 0.85
by injecting CSS overrides into the shadow DOM. This improves readability
and meets WCAG contrast requirements for the category navigation icons.

Fixes #174
2026-03-25 18:28:45 -05:00
Alex Gleason 8328af802f fix: auto-focus search box when emoji picker opens
Fixes #162
2026-03-25 18:27:43 -05:00
The Daniel 87914291c6 ci: retrigger pipeline 2026-03-25 15:27:53 -04:00
filemon 0c7daef65e Add DEV MODE Blobbi state editor for testing
- Create BlobbiDevEditor modal component for direct state editing
- Add useBlobbiDevUpdate hook using standard update/publish flow
- Support editing: stage, state, adult form, all stats
- Support editing: experience, care streak, generation, breeding ready, visibility
- Add stat presets: Max Stats, Starving, Exhausted, Dirty, Sad, Critical Health, etc.
- Add wrench icon button to hero section (DEV only)
- Wire to existing updateBlobbiTags pipeline for consistent Nostr events
- Only renders in development mode (import.meta.env.DEV)
2026-03-25 12:42:44 -03:00
filemon 09da778d3b Add need-based Blobbi reactions when items land
- Create centralized need detection system with configurable thresholds
- Add continuous gravity for items dropped mid-air (fall to ground)
- Blobbi now glances at items it doesn't need (brief look)
- Blobbi shows interest in items it needs (longer attention)
- Add ItemLandedData interface with position info for reactions
- Create useCompanionItemReaction hook for need-based behavior
- Expose triggerAttention from useBlobbiCompanion hook
2026-03-25 12:23:13 -03:00
filemon 2ad64bbca7 Filter egg-only items from companion interaction system
Since companions can only be baby or adult (not egg), egg-only items
like Shell Repair Kit should never appear in the companion flow.

Changes:
- Update resolveItemsForAction to use centralized canUseItemForStage
- Add item-stage validation in useBlobbiItemUse mutation
- Egg-only items are now filtered at both display and use layers

The filtering is now enforced by:
1. resolveItemsForAction - items won't appear in hanging items menu
2. useBlobbiItemUse - validation prevents use even if somehow displayed
2026-03-25 11:33:05 -03:00
filemon 247b94f3b3 Fix item effect display consistency across Blobbi UIs
- Create shared ItemEffectDisplay component as single source for effect rendering
- Update BlobbiInventoryModal to show ALL effects (was truncated to 2)
- Update BlobbiShopItemRow to show ALL effects (was truncated to 3)
- Update BlobbiPurchaseDialog to use shared component
- Use canonical stat display order: hunger, happiness, energy, hygiene, health
- Deprecate formatEffectSummary in favor of ItemEffectDisplay component

The root cause was effect display truncation in the UI, not inconsistent data.
All item definitions remain in blobbi-shop-items.ts (single source of truth).
2026-03-25 11:24:36 -03:00
filemon 8078ad5609 Refactor hanging items to support multiple dropped instances
- Replace releasedItemIds (Set) with releasedCountByItemId (Map) to track
  how many instances of each item type have been released
- Generate unique instanceId for each dropped item (format: itemId-timestamp-counter)
- Hanging items now show remaining quantity (quantity - releasedCount)
- Multiple instances of the same item type can exist on the ground simultaneously
- Each dropped instance tracks independently via instanceId
- Update all callbacks and state tracking to use instanceId instead of item.id
- When item is used successfully, decrement releasedCount for that item type

This enables the desired UX where clicking a hanging item immediately shows
a new copy in the hanging slot (if quantity > 1), while the released instance
falls independently.
2026-03-25 11:07:54 -03:00
filemon 7fd70ac0d9 fix(blobbi): make item-use work reliably from companion system
Major architectural fix for Blobbi companion item-use system:

1. Created shared useBlobbiItemUse hook
   - Works standalone outside of BlobbiPage
   - Uses same real item-use logic as BlobbiPage
   - Built-in per-item cooldown tracking
   - Fetches companion/profile data on-demand when needed

2. Refactored BlobbiActionsContext
   - Now has built-in fallback using useBlobbiItemUse
   - Item use works from ANY page, not just /blobbi
   - BlobbiPage registration is optional (provides better cache access)
   - No more 'canUseItems = false' when BlobbiPage not mounted

3. Fixed retry/flood issues in HangingItems
   - Added per-item cooldown (3s on failure, 0.5s on success)
   - Implemented zone ENTRY detection (not continuous overlap)
   - Items only trigger auto-use when ENTERING the Blobbi zone
   - Items must leave zone before re-triggering
   - Multiple protection layers prevent spam

4. Fixed useBlobbonautProfile side-effect
   - Moved setBootCache from useMemo to useEffect
   - Added ref-based signature tracking to prevent loops
   - Proper cleanup and stable dependencies

Files changed:
- NEW: src/blobbi/companion/interaction/useBlobbiItemUse.ts
- src/blobbi/companion/interaction/BlobbiActionsContext.tsx
- src/blobbi/companion/interaction/HangingItems.tsx
- src/blobbi/companion/interaction/index.ts
- src/blobbi/companion/components/BlobbiCompanionLayer.tsx
- src/hooks/useBlobbonautProfile.ts
2026-03-25 06:38:37 -03:00
filemon dbdaff2ada fix(blobbi): resolve performance issues in falling-item drag-drop system
Root cause analysis and fixes:

1. Drag-to-use freeze/loop (HangingItems):
   - Problem: When dropping on Blobbi, item position was set ON Blobbi,
     triggering contact detection to also call attemptUseItem, creating a loop
   - Problem: attemptUseItem had itemsBeingUsed as a dependency, so when it
     changed (inside the callback), the callback identity changed, re-triggering
     the contact detection effect
   - Fix: Changed itemsBeingUsed from state to ref to avoid callback recreation
   - Fix: When dropping on Blobbi, reset item to ORIGINAL position before
     attempting use (prevents contact detection from firing)
   - Fix: Made attemptUseItem have no dependencies (uses refs for everything)
   - Fix: Gated all console.log calls behind import.meta.env.DEV

2. useBlobbonautProfile console flood:
   - Problem: Unconditional console.log at line 63 ran on EVERY render
   - Problem: queryFn had multiple console.logs that ran on every query
   - Fix: Removed/commented out all console.logs in the hook
   - Analysis: The hook itself was NOT causing extra renders - it was just
     exposing the render frequency with its logging

3. BlobbiActionsContext registration instability:
   - Problem: Registration used useState which triggered re-renders on every
     update, and the registration effect depended on useItem identity
   - Fix: Refactored to use refs instead of state for registration data
   - Fix: Added subscription pattern for manual notification only when
     canUseItems actually changes (major state change)
   - Fix: Consumer hook's useItem callback is now stable (reads from ref)
   - Fix: Provider context value is now stable (never changes identity)

Guards now preventing repeated item-use attempts:
- itemsBeingUsedRef.current check at start of attemptUseItem
- Contact detection skips items in itemsBeingUsedRef
- Drag-drop resets item position BEFORE calling attemptUseItem
- attemptUseItem has no dependencies that could trigger recreation
2026-03-24 22:20:04 -03:00
filemon 3e53e368a4 fix(blobbi): wire BlobbiActionsContext correctly and add drag-drop for items
Part 1: Context Wiring Fix
- Refactored BlobbiActionsContext to use registration pattern
- BlobbiActionsProvider now mounted at app level in AppRouter (wraps BlobbiCompanionLayer)
- BlobbiPage registers its item-use function via useBlobbiActionsRegistration hook
- BlobbiCompanionLayer now receives real context with canUseItems: true
- Added debug logs to confirm context state changes

Part 2: Drag-and-Drop for Released Items
- Added 'dragging' state to ReleasedItemState
- Implemented pointer event handlers for drag detection (threshold-based)
- Items can be dragged after landing on the ground
- Visual feedback: items scale up when over Blobbi, glow effect on Blobbi
- Drop-on-Blobbi triggers real item use flow
- Drop elsewhere leaves item at drop position

All three item use paths (contact, click, drag-drop) use the same
real onItemUse callback, ensuring consistent behavior and proper
kind 31124 event publishing.
2026-03-24 22:05:23 -03:00
filemon 6a7c037ea8 feat(blobbi): implement real item use system for companion falling items
- Add BlobbiActionsContext to bridge companion UI with item use functionality
- Create useCompanionItemUse hook with category-to-action mapping (food→feed, toy→play, etc.)
- Update HangingItems with async onItemUse callback and success/failure handling
- Wire BlobbiCompanionLayer to use context-provided item actions
- Provide BlobbiActionsProvider in BlobbiPage so items actually update stats
- Items only disappear after successful use, stay on screen if use fails
2026-03-24 21:57:56 -03:00
filemon 2414441efa Merge branch 'main' into feat-blobbi 2026-03-24 21:32:47 -03:00
filemon 14deb86a7a Fix stuck rescue handoff: eliminate visual flash and improve gravity
Visual flash fix:
- Pass wasResolvedFromStuck flag through to BlobbiCompanion
- When entry was resolved from stuck and phase is 'complete', skip entry
  animation position and use motion.position directly
- This prevents the one-frame flash where 'complete' phase returns
  groundPosition before acknowledgeCompletion() runs

Gravity fix:
- Increase gravity from 800 to 3500 px/s² to match entry animation feel
- Previous value caused slow floaty descent after drag release
- New value creates responsive, natural-feeling fall that matches the
  scripted entry fall animation

The handoff now works cleanly:
1. User drags Blobbi while stuck_permanent
2. User releases → isDragging=false, motion.position=drag release point
3. resolvePermanentStuck() sets wasResolvedFromStuck=true, phase='complete'
4. BlobbiCompanion sees wasResolvedFromStuck+complete → uses motion.position
5. Physics system applies gravity from the exact release position
6. acknowledgeCompletion() runs → phase='idle' → normal motion continues
2026-03-24 21:24:38 -03:00
filemon 572c3b082e Fix stuck rescue handoff: preserve drag release position instead of snapping to groundPosition
- Add wasResolvedFromStuck flag to track whether entry completed via
  stuck rescue vs natural animation completion
- Skip setPosition(groundPosition) when entry was resolved from stuck
  rescue, since motion.position already has the correct drag release
  position from the user's drag interaction
- Motion system now continues naturally from the drag release position,
  handling gravity/falling as expected
2026-03-24 21:15:06 -03:00
filemon d2b466df93 Fix stuck_permanent visibility: increase chance to 40%, add wiggle animation, and prevent auto-resolve
- Increase trulyStuckChance from 20% to 40% for more visible stuck behavior
- Add wiggle/struggle animation when Blobbi is truly stuck at ceiling
- Fix bug where stuck_permanent would auto-resolve after 50ms because
  isDragging starts as false - now tracks whether user has actually
  started dragging before allowing resolve on release
2026-03-24 21:12:17 -03:00
filemon 3f11465a7e Fix shadow to be a floor shadow that only appears near ground
Ground Proximity Detection:
- BlobbiCompanion now calculates distanceFromGround from actual Y position
- isOnGround = not entering, not dragging, and within 5px of ground position
- Both values passed to BlobbiCompanionVisual as new props

Shadow Visibility Rules:
- Shadow only shows when isOnGround is true
- Shadow hidden during: dragging, entry animations (fall/rise), falling
- Shadow fades smoothly over SHADOW_FADE_DISTANCE (30px)
- Additional subtle fade during float animation for breathing effect

Shadow Visual Changes:
- Position: bottom -12px → -20px (farther from body, feels like floor)
- Width: 55% → 50% of size (slightly narrower)
- Height: 10% → 8% of size (thinner, more subtle)
- Blur: 3px → 4px (softer edge)
- Max opacity: 0.4 → 0.35 (more subtle)
- Added CSS opacity transition for smooth fade in/out

States Where Shadow Is Hidden:
- Being dragged (isDragging)
- Fall entry animation (isEntering, entryType='fall')
- Rise entry animation (isEntering, entryType='rise')
- Any state where distanceFromGround >= 30px
- Any off-ground position (y < groundPosition.y - 5)

States Where Shadow Is Visible:
- Idle on ground
- Walking on ground
- Floating (with subtle fade based on float offset)
2026-03-24 20:47:03 -03:00
filemon ece9a37af4 Increase Blobbi size 35% and move shadow farther away
Size Changes:
- Companion size: 80px → 108px (+35%)
- Changed in DEFAULT_COMPANION_CONFIG.size (central config)
- All derived calculations automatically use new size:
  - Entry animations
  - Drag behavior
  - Ground positioning
  - Movement bounds
  - Gaze calculations

Padding Adjustments:
- Left padding: 80px → 100px (more room for larger Blobbi)
- Right padding: 20px → 24px
- Bottom padding: 20px → 24px

Shadow Improvements:
- Position: bottom -4px → -12px (3x farther from body)
- Width: 60% → 55% of size (slightly narrower)
- Height: 12% → 10% of size (slightly thinner)
- Blur: 2px → 3px (softer edge)
- Gradient adjusted for cleaner fade

Action Menu Adjustment:
- Radius: 70px → 85px (maintains visual spacing from larger Blobbi)

HangingItems Default:
- Updated companionSize default: 80 → 108 to match config

All systems continue to work correctly:
- Entry/exit animations scale with size
- Drag centering uses config.size
- Contact detection uses passed companionSize
- Action menu follows rendered position
2026-03-24 20:41:17 -03:00
filemon 7f4cf8bdcd Fix first-click animation bug, reduce hanging/released item sizes
Animation Bug Fix:
- Root cause: useEffect with releasedItems dependency only ran AFTER
  state update, but animation check happened once at effect start
- Fix: Use refs to track animation state and latest releasedItems
- runAnimationLoop() is now a stable callback that reads from refs
- handleItemClick calls runAnimationLoop via setTimeout(0) to ensure
  state update is processed first
- isAnimatingRef prevents duplicate animation loops
- Animation now starts immediately on first item click

Size Reductions (Hanging Items):
- Circle size: 72px → 56px
- Emoji size: 2.25rem → 1.75rem
- Item spacing: 100px → 80px
- Line length: 120px → 100px
- Badge size: 24px → 20px

Size Reductions (Released/Landed Items):
- Falling emoji: 2.5rem → 1.875rem
- Landed hitbox: 48px → 40px
- Contact radius: 60px → 50px
- Fall duration: 700ms → 600ms

Preserved Behavior:
- Hanging items use line + circle
- Only emoji falls after release (no circle)
- Continuous object from falling to landed
- Landed items remain on ground
- Contact detection still removes items
2026-03-24 20:35:12 -03:00
filemon d6538aac50 Continuous item lifecycle: emoji-only fall, no respawn, Blobbi contact detection
Falling Visual - Only Emoji Falls:
- When clicked, the hanging circle/container disappears immediately
- Only the emoji itself falls (no enclosing circle, no badge)
- ReleasedItem component renders just the emoji with drop shadow
- Slightly larger emoji size for falling/landed state (2.5rem)

Continuous Object Lifecycle:
- Single ReleasedItemData tracks item through entire lifecycle
- States: hanging → falling → landed (same object throughout)
- Position animated via requestAnimationFrame (not CSS animation)
- No disappear-and-respawn: emoji smoothly transitions from fall to ground
- Fall uses eased animation: accelerates then slows near ground

Contact Detection with Blobbi:
- Receives companionPosition and companionSize from parent
- Checks distance between Blobbi center and each landed item
- Contact threshold: companionSize/2 + 60px radius
- On contact: item removed, onItemCollected callback fired
- Works both ways: Blobbi walks into item OR item lands near Blobbi
- Manual pickup also supported (clicking landed item)

State Model:
- releasedItemIds: Set<string> - tracks which items left hanging state
- releasedItems: Map<string, ReleasedItemData> - full lifecycle data
- ReleasedItemData contains: item, state, x, y, startY, targetY, fallStartTime

Future-Ready Structure:
- onItemCollected callback ready for effects/reactions
- Position data available for drag implementation
- State model supports attraction behavior
- Clean separation: hanging container vs released items
2026-03-24 20:25:25 -03:00
filemon 3a47fccf16 Improve hanging items: larger size, slide animations, landed items persist
Size Improvements:
- Circle size: 52px → 72px (38% larger)
- Emoji size: text-2xl → text-4xl (2.25rem)
- Item spacing: 80px → 100px (center to center)
- Line length: 100px → 120px
- Badge size: 20px → 24px

Open/Close Slide Animation:
- Container states: hidden → opening → open → closing → hidden
- Items descend from above when opening (slide down animation)
- Items ascend when closing (slide up animation)
- 350ms slide animation duration
- Items no longer abruptly appear/disappear

Landed Items System:
- Released items fall all the way to the ground (calculated from viewport)
- Items remain visible on ground after landing
- Landed items render as separate LandedItem components
- Landed items persist even after menu closes
- Landed items are clickable (placeholder for future pickup)

State Model:
- ContainerState: hidden | opening | open | closing
- ItemState: hanging | falling | landed
- ItemStateData tracks: id, state, fallStartX, landedY
- landedItems Map persists item positions for ground rendering

Future-Ready Structure:
- onPickup callback ready for landed items
- Separate LandedItem component for ground items
- State model supports drag, attraction, consumption
- CSS variables for dynamic fall distance
2026-03-24 20:16:26 -03:00
filemon c659eaeead Replace item bubbles with hanging items, fix action menu tracking
Action Menu Fixes:
- Remove useMemo for position calculations to avoid stale values
- Calculate button positions directly each render
- Menu now follows Blobbi continuously during all states
  (idle, walking, floating, dragging, settling)

Hanging Items System:
- New HangingItems component replaces CompanionItemBubbles
- Items appear as circles hanging from vertical lines at top of screen
- Wider horizontal spacing (80px between items)
- Playful, spatial presentation instead of modal-like container

Click-to-Fall Animation:
- Clicking an item releases it from the hanger
- Line and quantity badge disappear instantly
- Item falls with rotation animation (800ms)
- Structured for future extension (drag, attraction, reactions)

Pointer Events:
- Container uses pointer-events-none
- Individual items use pointer-events-auto
- All items are now properly clickable

Removed:
- CompanionItemBubbles component (deleted)
- Modal-like container presentation
- Close button (no longer needed)
2026-03-24 20:07:16 -03:00
filemon 37c7a37bdf Fix companion menu positioning and pointer-events
- Expose rendered position from BlobbiCompanion via onPositionUpdate callback
- Track actual visual position in BlobbiCompanionLayer (includes entry animation + float offset)
- Pass rendered position to CompanionActionMenu instead of logical motion.position
- Add pointer-events-auto to action menu backdrop, buttons, and item bubbles
- Fix pointer events hierarchy (parent layer has pointer-events-none)
- Fix unused variable lint errors (entryProgress, isPermanentlyStuck, etc.)
2026-03-24 19:56:34 -03:00
filemon d3f23544cc Add companion action menu and item bubbles interaction layer
Implement the first interaction layer for the Blobbi companion:

Click vs Drag detection:
- Created useClickDetection hook to distinguish clicks from drags
- Movement threshold: 5px (beyond = drag)
- Time threshold: 300ms (beyond = not a click)
- Updated BlobbiCompanion pointer/touch handlers to use detection

Action Menu (CompanionActionMenu):
- Radial/arc layout centered above Blobbi
- 5 actions: feed, play, medicine, clean, sleep
- Stage-aware: eggs only see medicine and clean
- Smooth fade-in + zoom animation with stagger
- Click outside closes menu
- Route change closes menu

Item Bubbles (CompanionItemBubbles):
- Horizontal row of item bubbles near top of screen
- Shows emoji + quantity badge for each item
- Resolves real inventory items for selected action
- Empty state message when no items available
- Staggered appearance animation

Architecture:
- /interaction/types.ts - Type definitions and config
- /interaction/useCompanionActionMenu.ts - Menu state hook
- /interaction/useClickDetection.ts - Click/drag detection
- /interaction/CompanionActionMenu.tsx - Radial menu component
- /interaction/CompanionItemBubbles.tsx - Item display component
- /interaction/index.ts - Module exports

Action to item category mapping:
- feed -> food items
- play -> toy items
- medicine -> medicine items
- clean -> hygiene items
- sleep -> (no items, direct action)

Future-ready for:
- Item falling animation
- Drag item to Blobbi
- Blobbi walking toward items
- Item consumption logic
- Per-item Blobbi reactions
2026-03-24 19:44:48 -03:00
filemon 9d5ea22806 Upgrade typing attention to caret-aware targeting with 4s timeout
Caret tracking implementation (priority order):
1. contenteditable: window.getSelection() + Range.getBoundingClientRect()
2. input/textarea: selectionStart + mirrored text measurement
3. Fallback: right-side typing region (where new text appears)
4. Last resort: field center

Changes from field-center version:
- Added getContentEditableCaretPosition() using Selection API
- Added getInputCaretPosition() with text width measurement via temp span
- Added getRightTypingRegion() as smart fallback (better than center)
- computeCaretPosition() tries each method in priority order

Event handling (event-driven, no polling):
- keydown: detect typing, update caret after DOM updates via rAF
- input: catch paste, autocomplete, non-keydown text changes
- selectionchange: update when caret moves via arrow keys or click
- focusin/focusout: track element changes, clean up on blur

Timing:
- Increased idle timeout from 2s to 4s for more stable observation
- Timeout resets on any typing event

Priority:
- Typing attention now uses 'high' priority
- Overrides generic modal attention while typing
- Keeps Blobbi focused on caret, not just modal center

Handles edge cases:
- Focus leaving field clears typing attention
- Modal close clears via overlay detection
- Switching fields updates target to new field's caret
- Graceful fallback when exact caret rect unavailable
- Caret position clamped to element bounds
2026-03-24 12:32:08 -03:00
filemon 1388d0e514 Add typing attention behavior for Blobbi in modal text fields
When user types in a text input inside a modal/dialog, Blobbi now:
- Detects focus on text inputs (input, textarea, contenteditable, role=textbox)
- Only activates when the field is inside an overlay (modal, dialog, sheet, drawer)
- Locks attention to the focused field's center while typing continues
- Releases attention after 2s idle timeout (no typing)
- Properly cleans up when focus changes or modal closes

Implementation:
- New useTypingAttention hook handles focus/blur/keydown events
- Integrated into useBlobbiAttention with priority below 'high' but above 'low'
- Typing attention overrides random gaze and mouse-follow during active typing
- Lightweight: uses event listeners, not polling; targets field center, not caret

Config:
- Added typingIdleTimeout (2000ms) to attention config

Edge cases handled:
- Focus moving between text fields updates attention target
- Focus leaving text inputs clears typing attention
- Modal close clears typing attention via overlay detection
- Only typing keys (chars, backspace, delete, enter) reset idle timer
2026-03-24 12:24:20 -03:00
filemon e3412fac46 Merge branch 'main' into feat-blobbi 2026-03-24 12:20:40 -03:00
filemon e43c0b1e2e Fix reactive companion visibility and add BlobbiPage duplicate prevention
Root cause: useBlobbiCompanionData had a separate query key (['companion-profile'])
from useBlobbonautProfile (['blobbonaut-profile']). When companion was
selected/removed via BlobbiPage, only the main profile query was invalidated,
leaving the companion layer with stale data.

Fix:
- Rewrote useBlobbiCompanionData to use useBlobbonautProfile instead of
  duplicating the profile query
- Now shares the same query cache, so profile updates (including currentCompanion
  changes) immediately propagate to the companion layer
- Added explicit null return when currentCompanionD is undefined for reactive removal

BlobbiPage duplicate prevention:
- Added check for active floating companion (isActiveFloatingCompanion)
- When the displayed Blobbi is the same as the floating companion, show a
  friendly message: '{name} is out exploring right now.' with Footprints icon
- Prevents seeing two identical Blobbis (one floating, one in-page)

This makes companion selection/removal/replacement fully reactive without
requiring page refresh.
2026-03-24 12:19:00 -03:00
filemon b13a5ae1ae Add reactive companion visibility with companionId tracking
- Track companionId in useBlobbiEntryAnimation to detect companion changes
- When companion is selected/changed, trigger new entry animation immediately
- Random entry direction (fall or rise) for new companion appearances
- No transition delay for companion changes (unlike route changes)
- Companion removal hides Blobbi via existing isVisible logic
2026-03-24 12:09:12 -03:00
filemon 8c52848212 Fix intermittent snap-to-ground bug in FALL entry animation
Root cause:
When entry animation completed, there was a race condition:
1. entryState.phase becomes 'complete'
2. Effect calls completeEntry() which sets isEntering = false
3. Component checks 'if (isEntering)' and switches to motion.position
4. But motion.position wasn't synced to groundPosition yet
5. Result: Blobbi snaps to old position instead of falling smoothly

The fix uses a two-phase handoff:

1. Component now checks 'isEntering || entryState.phase !== idle'
   - Keeps using entry animation position during 'complete' phase
   - The 'complete' phase returns groundPosition, so rendering is correct

2. Added acknowledgeCompletion() function
   - Called after position is synced AND rendered (via requestAnimationFrame)
   - Resets phase to 'idle' to allow normal motion to take over
   - Ensures no frame shows wrong position

3. Position sync effect now:
   - Detects phase === 'complete' (not just !isEntering)
   - Syncs position to groundPosition
   - Waits one frame with requestAnimationFrame
   - Then calls acknowledgeCompletion() to handoff to motion

Files changed:
- useBlobbiEntryAnimation.ts: Add acknowledgeCompletion()
- useBlobbiCompanion.ts: Use acknowledgeCompletion after sync
- BlobbiCompanion.tsx: Check phase !== 'idle' for entry position
2026-03-24 12:05:28 -03:00
filemon 053007e7ea Fix route change: hide Blobbi immediately, delay only new entry
Before: Blobbi stayed visible during 1s delay, then disappeared and restarted
After: Blobbi disappears immediately, 1s clean delay, then new entry starts

Changes:
- Add isHiddenForTransition state to entry animation hook
- Set true immediately on route change (before delay)
- Clear when new entry actually starts
- useBlobbiCompanion now uses shouldBeVisible = isVisible && !isHiddenForTransition

Flow:
1. Route changes
2. Blobbi hidden immediately (isHiddenForTransition = true)
3. Wait 1 second (screen is clean)
4. Start new entry (isHiddenForTransition = false, entry begins)
2026-03-24 11:57:08 -03:00
filemon 9889cb07d4 Simplify FALL entry to 2 vertical pulls, add rare permanent stuck behavior
FALL entry changes:
- Simplified to 2 pull attempts (was wiggle-based)
- Each pull: quick down, slower up (purely vertical, no diagonal)
- Normal flow (~80%): stuck -> pull_1 -> pause_1 -> pull_2 -> pause_2 -> fall -> land
- Rare flow (~20%): stuck -> pull_1 -> pause_1 -> pull_2 -> stuck_permanent

Permanent stuck behavior:
- 20% chance on FALL entry (configurable via trulyStuckChance)
- Blobbi hangs at top, won't fall automatically
- User must drag and release to rescue
- Resolves when drag ends (after any movement away from stuck position)

Route change handling:
- Cancels current entry immediately (no continuation)
- Waits 1 second after new page appears
- Then restarts entry animation for the new route

Timing (total ~1470ms for normal fall):
- stuck: 200ms (15% visible)
- pull_1: 280ms (10% drop)
- pause_1: 140ms
- pull_2: 300ms (14% drop)
- pause_2: 100ms
- fall: 450ms
- land: 200ms

Files changed:
- companion.types.ts: New phases (pulling_1/2, pause_1/2, stuck_permanent), isTrulyStuck flag
- companionConfig.ts: New timing config for 2-pull system
- animation.ts: Simplified vertical pull calculations
- useBlobbiEntryAnimation.ts: New state machine with 20% stuck chance
- useBlobbiCompanion.ts: Pass isDragging to entry hook
- BlobbiCompanion.tsx: Updated config mapping
2026-03-24 11:46:52 -03:00
filemon 142b144318 Add pause phase to FALL entry, refine butt wiggle motion
- Add 'pause' phase (180ms) between tugging and wiggling
  Creates readable 'hmm... still stuck' beat before wiggle

- Refine wiggle to feel like lower/back part:
  - Side-to-side primary motion (not diagonal whole-body)
  - Small downward pull synced with each wiggle (tugging loose)
  - Minimal rotation (2°) - just organic, not full-body tilt
  - ~1.25 cycles instead of 1.5 (brief and playful)

- Adjusted timing:
  - wiggleIntensity: 5 → 4px
  - wiggleRotation: 3 → 2°
  - wiggleDuration: 350 → 320ms

New sequence with clear beats:
  stuck (250ms) → tugging (300ms) → pause (180ms) →
  wiggling (320ms) → falling (500ms) → landing (200ms)

Total: ~1750ms
2026-03-24 11:28:53 -03:00
filemon a47e53ff2d Refine FALL entry: add tugging phase, reduce visible amount, subtler wiggle
- Add 'tugging' phase: tries to fall but gets stuck (down-up motion)
- Reduce stuckVisibleAmount from 25% to 15% (tiny butt peek)
- Make wiggle subtler: 5px intensity, 3° rotation (was 8px, 6°)
- Wiggle now diagonal movement, not just horizontal shaking
- New sequence: stuck -> tugging -> wiggling -> falling -> landing

Timing: 250ms stuck, 300ms tug, 350ms wiggle, 500ms fall, 200ms land
2026-03-24 11:23:41 -03:00
Sergey B. 25bfe4f0fa fix: query case variants for tag feed parity with search 2026-03-24 17:13:33 +03:00
filemon 8b88cd3cf6 Add stuck and wiggling phases to Blobbi fall entry animation
- Add 'stuck' phase where Blobbi's butt hangs from top edge (25% visible)
- Add 'wiggling' phase with left-right wiggle (±8px, ±6° rotation) to get loose
- Update entry state machine to handle new phases: stuck -> wiggling -> falling -> landing
- Add config values for stuckDuration (300ms) and wiggleDuration (400ms)
- Update animation utils with wiggle offset calculations
2026-03-24 11:12:33 -03:00
filemon 748bf43847 Replace sidebar entry with vertical entry based on navigation direction
- Add vertical entry system: fall from top when navigating DOWN sidebar,
  rise from bottom with inspection when navigating UP sidebar
- Add sidebarNavigation.ts utility to map routes to sidebar order
- Remove old sidebar-based entry (clipping, peeking from left)
- Fix motion sync to use groundPosition (center) instead of old restingPosition
- Entry now ends at center of screen, no teleport on completion
2026-03-24 10:58:12 -03:00
filemon 586c536c46 up animation 2026-03-24 10:16:03 -03:00
filemon 4a84a782db Add tab attention, post-route attention, and improve upward eye movement
Tab switch attention:
- Added TAB_SELECTORS for Radix UI tabs with data-state='active'
- Tab changes trigger brief glance (1.2s) instead of full attention (3s)
- Uses shorter glanceCooldown (0.8s) to allow noticing multiple tabs
- Low priority so overlays can still interrupt
- Skipped tooltips entirely (too noisy)

Post-route attention:
- After entry animation completes, Blobbi looks at main content
- findMainContentPosition() tries common selectors: main, [role=main], .main-content, article
- Falls back to center-top of viewport if no main content found
- Uses postRouteDuration (2.5s) for how long to look
- Small postRouteDelay (200ms) before triggering
- Low priority so modals can immediately interrupt

Improved upward pupil movement:
- Asymmetric vertical scaling in BlobbiBabyVisual and BlobbiAdultVisual
- Looking up: full range (4px/4.5px for baby/adult)
- Looking down: reduced range (2.4px/2.7px, 0.6x) to avoid droopy look
- Updated calculateEyeOffset() with asymmetric maxDistanceY:
  - 350px for looking up (easier to reach full upward gaze)
  - 500px for looking down (normal distance)
- Updated generateRandomScreenGaze() to favor upward looks (-0.6 to +0.5)

New config options:
- attention.glanceDuration: 1200ms for brief tab glances
- attention.glanceCooldown: 800ms between glances
- attention.postRouteDuration: 2500ms for post-route attention
- attention.postRouteDelay: 200ms delay before post-route attention

Files changed:
- companion.types.ts: New config options
- companionConfig.ts: New timing values
- companionMachine.ts: Asymmetric calculateEyeOffset
- useBlobbiAttention.ts: Tab detection, overlay/tab separation
- useBlobbiCompanion.ts: Post-route attention trigger
- useBlobbiCompanionGaze.ts: Improved random gaze range
- BlobbiBabyVisual.tsx: Asymmetric vertical eye movement
- BlobbiAdultVisual.tsx: Asymmetric vertical eye movement
2026-03-24 07:59:40 -03:00
filemon e2dbc0e1cf Improve Blobbi companion behavior: calmer, more observant, reactive to UI
Behavioral rebalancing:
- Reduced walk chance from 75% to 30% for much calmer behavior
- Increased idle time from 2-6s to 4-10s
- Increased random gaze interval from 0.8-2.5s to 1.5-4s for more deliberate observation
- Mouse follow now more frequent (35% vs 25%), longer duration (2.5s vs 1.5s)
- Observation look duration increased from 2-4s to 3-6s

New attention system for UI changes:
- Added useBlobbiAttention hook that watches for new UI elements
- Uses MutationObserver to detect modals, dialogs, sheets, popovers appearing
- Supports Radix UI, Vaul drawer, and common dialog patterns
- New 'attending' state has highest priority, interrupts walking
- Blobbi stops and looks at new UI element for ~3 seconds
- Priority system (low/normal/high) prevents spamming
- Cooldown prevents excessive reactions (1.5s minimum between events)

Architecture for future extensibility:
- AttentionTarget type with id, position, duration, priority, source
- AttentionPriority type for behavior hierarchy
- Exported via module index for external use
- Easy to add video/audio/notification attention types later

Files changed:
- companion.types.ts: Added 'attending' state, 'attend-ui' gaze mode, AttentionTarget type
- companionConfig.ts: Rebalanced all timing values, added attention config
- companionMachine.ts: Reduced walk chance, handle attending in motion
- useBlobbiAttention.ts: New hook for UI attention detection
- useBlobbiCompanionState.ts: Handle attending state, save/restore behavior
- useBlobbiCompanionGaze.ts: Handle attend-ui gaze mode with fast snap
- useBlobbiCompanion.ts: Wire attention system through
- index.ts: Export new hook and types
2026-03-24 07:44:25 -03:00
filemon 2b434de40b Fix Blobbi companion eye behavior system
- Fix competing eye systems: add disableTracking option to useBlobbiEyes
  so external systems can control eye position while keeping blinking
- Add externalEyeOffset prop to BlobbiBabyVisual and BlobbiAdultVisual
  for direct companion control of eye position
- Increase pupil movement visibility (4px for baby, 4.5px for adult)
- Increase movement direction gaze offset (0.85 instead of 0.7)
- Add observation target behavior: Blobbi picks a random screen position,
  walks toward it, then looks at it for 2-4 seconds
- Add new 'observe-target' gaze mode and 'watching' state
- Wire eyeOffset through companion pipeline to visual components
- Clear separation of concerns: useBlobbiEyes handles blinking,
  companion system handles gaze direction
2026-03-24 07:15:19 -03:00
Sergey B. a51c174021 fix: allowing zap Primal users without error 2026-03-24 11:51:02 +03:00
Sergey B. c1373174ca feat: NIP-11 relay information on network settings page 2026-03-24 10:43:25 +03:00
filemon c3f0ecf7d5 Improve companion eye gaze behavior
- When moving: Eyes look in direction Blobbi is going (left/right)
- When idle: Eyes look around randomly, observing the screen
  - Wider gaze range for more noticeable movement
  - Faster gaze changes (0.8-2.5s) feel more alive
- Mouse focus: Brief glances at cursor
  - 25% chance every 2-4 seconds (after 6s cooldown)
  - Only lasts 1.5s, then returns to normal
  - Never gets stuck on mouse
- Smooth transitions between all gaze targets
  - Mouse follow: responsive (0.15 factor)
  - Forward: moderate (0.1 factor)
  - Random: gentle (0.06 factor)
- Cleaner state management using refs to avoid unnecessary re-renders
2026-03-23 22:52:29 -03:00
filemon 2f2fdb1809 Improve companion: restore organic movement, fix eye gaze, fix adult form rendering
- Restore charming float/sway animation with layered sine waves
  - Walking: lively bouncy motion with playful tilt
  - Idle: dreamy calm floating like gentle breathing
- Fix eye gaze behavior:
  - Eyes now move randomly when idle (not stuck)
  - Occasional brief mouse following that properly times out
  - Look in movement direction when walking
- Fix adult form rendering:
  - Pass adultType and seed to visual component
  - Adults now render their actual form instead of always catti
- Keep ground contact fixes:
  - SVG alignment via translateY compensation
  - SVG fills container with width/height 100%
- Add debug mode infrastructure (disabled by default)
2026-03-23 22:48:06 -03:00
filemon 95a123532b Fix Blobbi ground contact - compensate for SVG padding in ground calculation
Root cause: The Blobbi SVG has ~12% empty space at the bottom of its
viewBox (body ends at Y=88 in a 0-100 viewBox). This caused the visual
body to appear elevated above the ground.

Fix: Added SVG padding compensation in the ground position calculation
rather than trying to hack it in the visual component.

Changes:
- calculateGroundY(): Add svgBottomPadding (12% of size) to push
  container down so Blobbi's actual body touches ground
- calculateMovementBounds(): Same adjustment for maxY bound
- Removed visual margin hacks (marginBottom, items-end) that were
  trying to compensate in the wrong place

The ground Y calculation now accounts for the SVG's internal padding,
so the container is positioned lower and Blobbi's body correctly
touches the ground level.
2026-03-23 22:26:57 -03:00
filemon 2cc7c7bcaf Fix Blobbi ground contact by compensating for SVG padding
The SVG viewBox has empty space at the bottom (~12% padding).
This was causing Blobbi to appear floating above the ground.

Fix:
- Changed inner container to use 'items-end' for bottom alignment
- Added negative marginBottom (-10% of size) to pull Blobbi down
- This compensates for the SVG's internal bottom padding

Shadow restored:
- Back to bottom: -4 (was 0)
- Size: 60% width, 12% height
- Blur: 2px
- Better visual separation from Blobbi

Result: Blobbi now visually sits on the ground with proper
shadow placement underneath.
2026-03-23 22:20:16 -03:00
filemon b314b98dd6 Fix Blobbi ground contact - now properly touches ground
Float animation fix:
- Changed from abs(sin) to (1-cos)/2 wave formula
- This creates a 0-to-1 range that regularly returns to zero (ground)
- Y offset now goes from 0 (ground contact) to -3/-4.5 (slight lift)
- Blobbi settles back to ground between float cycles

Walking animation:
- Faster cycle (~0.5s) for rhythmic bobbing
- Range: 0 to -4.5px lift
- Reduced sway (1.5px) and rotation (1.5°)

Idle animation:
- Slower cycle (~2.5s) for calm breathing
- Range: 0 to -3px lift
- Subtle sway (0.8px) and rotation (0.8°)

Shadow adjustments:
- Moved to bottom: 0 (right at ground level)
- Smaller size (55% width, 10% height) for subtler effect
- Less blur (1px) for sharper ground contact
- Stronger base opacity (0.4) that fades as Blobbi lifts
- Scale shrinks more noticeably when lifted
2026-03-23 22:13:40 -03:00
filemon 5ec79e9612 Fix Blobbi ground alignment and shadow anchoring
- Float animation now only moves Blobbi upward from ground level
  - Uses abs(sin) so Y offset is always negative (up) or zero
  - Base position = on the ground, animation lifts slightly above
- Shadow stays anchored to ground while Blobbi floats above it
  - Shadow doesn't move with float offset
  - Shadow scales/fades based on float height for depth illusion
- Reduced horizontal sway and rotation for subtler effect
  - Walking: 2px sway, 2° rotation (was 3px, 3°)
  - Idle: 1px sway, 1° rotation (was 1.5px, 1.5°)
2026-03-23 22:10:00 -03:00
filemon 9e89972008 Improve companion movement and visual polish
- Simplify entry animation to smooth walking emergence (no stuck/squeeze)
- Add forced initial walk after entry - Blobbi walks right immediately
- Improve walking behavior - 75% walk chance, shorter idle periods
- Remove visual flip when changing direction - Blobbi always faces same way
- Add soft floating/swaying animation with different speeds for walk vs idle
  - Walking: faster rhythmic bobbing (~0.8s cycle)
  - Idle: slower calm breathing (~3s cycle)
- Add soft shadow underneath for depth/floating effect
  - Stronger opacity (0.35), blur, and gradient for better visibility
  - Shadow reacts to float height
- Keep clipping behavior for sidebar emergence on desktop
- Mobile uses simple slide-in from left edge
2026-03-23 22:06:45 -03:00
filemon 05864d001a Improve companion entry animation - emerge from content area with playful squeeze effect
Entry position changes:
- Add layout config with sidebarWidth (300px) and maxContentWidth (1200px)
- Calculate main content left edge accounting for centered layout
- Entry now starts at left edge of main content area, not viewport edge
- Resting position is inside the content area with proper padding

Playful entry animation (2.2 seconds total):
- Phase 1 (0-25%): Emerge diagonally with slight forward lean and squish
- Phase 2 (25-40%): Get 'stuck' halfway with wobble effect
- Phase 3 (40-70%): Tug motions - 3 cycles of forward/back pulls, each stronger
- Phase 4 (70-100%): Break free and walk smoothly to final position

Visual effects during entry:
- Rotation (lean forward/back during tugging)
- ScaleX/ScaleY (squish/stretch for squeeze effect)
- Transform origin at center bottom for natural pivoting

The animation feels like Blobbi is squeezing out from the previous page
into the current one, getting briefly stuck, then breaking free.
2026-03-23 20:56:10 -03:00
filemon f9fc81ce71 Fix companion entry animation - no teleport, spawn from behind sidebar
- Change entry position to start behind the sidebar (padding.left/2 - size)
  instead of off-screen left edge, so companion emerges from sidebar
- Add setPosition function to motion hook for syncing position
- Sync motion position to restingPosition when entry animation completes
  to prevent teleport between animated and physics-controlled movement
- Entry is now continuous: emerges from sidebar -> slides to resting position
2026-03-23 20:48:43 -03:00
filemon 66a23cc99b Implement Blobbi companion system with modular architecture
Add a complete companion module under blobbi/companion with:

Core architecture:
- types/companion.types.ts - Type definitions for companion state, motion, gaze
- core/companionConfig.ts - Configuration constants and helpers
- core/companionMachine.ts - State machine for behavior transitions

Hooks:
- useBlobbiCompanionData - Fetches current_companion from kind 31125 profile
- useBlobbiCompanionState - Manages idle/walking/watching behavior
- useBlobbiCompanionMotion - Handles physics, walking, gravity, drag
- useBlobbiCompanionGaze - Eye movement, random observation, mouse following
- useBlobbiCompanion - Main hook combining all systems

Components:
- BlobbiCompanionVisual - Renders baby/adult with external eye control
- BlobbiCompanion - Interactive component with drag support
- BlobbiCompanionLayer - Global overlay layer

Utils:
- movement.ts - Position calculations, bounds, interpolation
- animation.ts - Bob, bounce, easing functions

Features:
- Fetches companion from user's Blobbonaut profile on app load
- Entry animation from behind left sidebar on route changes
- Autonomous walking with energy-based speed
- Random gaze observation when idle
- Mouse cursor following at random intervals
- Drag and gravity physics
- Bottom viewport roaming area
2026-03-23 20:19:31 -03:00
filemon aa2d724a13 Merge branch 'main' into feat-blobbi 2026-03-23 20:10:15 -03:00
filemon 67f840c0ec Add stage validation for companion - only baby/adult allowed
- Add canBeCompanion check to prevent eggs from being set as companion
- Show toast error if user tries to set egg as companion
- Disable companion button for eggs with helpful tooltip
- Update tooltip to show 'Hatch first to set as companion' for eggs
2026-03-23 20:06:35 -03:00
filemon f2e545ff09 Fix companion tag handling and add indicator in Blobbies modal
- Remove current_companion tag entirely when unsetting (instead of empty string)
- Filter out existing current_companion tags before adding new one
- Add Footprints icon indicator in BlobbiSelectorCard for current companion
- Include tooltip 'Current companion' on the indicator icon
- Pass currentCompanion prop through BlobbiSelectorPage and modal
2026-03-23 19:45:02 -03:00
filemon 11142bc96a Implement Set as Companion toggle in Blobbi Hero section
- Add isCurrentCompanion and isUpdatingCompanion props to BlobbiDashboardFloatingControls
- Implement handleSetAsCompanion to toggle current_companion tag on profile
- Show green icon color when Blobbi is the current companion
- Add disabled state support to FloatingActionDef for loading states
- Pass publishEvent to BlobbiDashboard for profile updates
2026-03-23 19:38:26 -03:00
filemon e3d01bc6aa Make Missions modal sections collapsible
- Daily Missions section is now collapsible with chevron toggle
- Hatch Tasks section is collapsible when active
- Evolve Tasks section is collapsible when active
- All sections expanded by default for easy access
- Header shows progress count (e.g., 2/4) for task sections
- Header shows coins earned for Daily Missions section
- Smooth chevron rotation animation on expand/collapse
2026-03-23 18:13:43 -03:00
filemon d3fc1c602a Add Users icon to Blobbi Selector modal title 2026-03-23 18:08:00 -03:00
The Daniel 926ad380f3 feat: show link preview cards in quoted posts instead of raw URLs 2026-03-23 17:02:43 -04:00
filemon b55a9bae43 Add sticky header and close button to Blobbi Selector modal
Apply the same pattern used in other Blobbi modals:
- Sticky header with bg-background
- Explicit DialogClose button in header
- Scrollable content area with flex-1 min-h-0 overflow-y-auto
2026-03-23 17:29:19 -03:00
filemon f8c46d7a11 Add sticky header to BlobbiInfoModal and fix close buttons in all Blobbi modals
- Add sticky header pattern to BlobbiInfoModal (main Blobbi view)
- Add explicit DialogClose button inside each sticky header
- Hide default DialogContent close button with [&>button:last-child]:hidden
- Close button now stays visible when scrolling in all modals

Updated modals:
- BlobbiActionsModal
- BlobbiActionInventoryModal
- BlobbiInventoryModal
- BlobbiMissionsModal
- BlobbiShopModal
- BlobbiInfoModal
2026-03-23 17:21:36 -03:00
filemon 75ca14c900 Fix modal padding and add sticky headers for Blobbi modals
- Add pr-12 to DialogHeader to account for close button, fixing right padding
- Make DialogHeader sticky with proper background for all Blobbi modals
- Structure modals as flex column with min-h-0 for proper scrolling
- Apply pattern to: BlobbiActionsModal, BlobbiActionInventoryModal,
  BlobbiInventoryModal, BlobbiMissionsModal, BlobbiShopModal
2026-03-23 17:10:56 -03:00
filemon 163712471c Improve Blobbi UI mobile responsiveness and add compact coin formatting
- Add formatCompactNumber utility for compact coin display (1.2K, 15.4K, 1.2M)
- Fix BlobbiActionInventoryModal (medicine items) layout for mobile
- Fix BlobbiBottomBar to prevent overflow on narrow screens
- Fix BlobbiInventoryModal item cards for mobile
- Fix BlobbiMissionsModal horizontal scroll and layout issues
- Fix DailyMissionsPanel and TasksPanel for mobile
- Fix BlobbiShopModal and related dialogs for mobile
- Apply compact number formatting to all coin displays
2026-03-23 17:07:16 -03:00
filemon 974cdcccc9 Merge branch 'main' into feat-blobbi 2026-03-23 10:58:25 -03:00
Derek Ross bbe53a4c69 Filter Mentions tab to only show pure mentions, not replies
The Mentions tab now excludes kind 1 reply events (those with NIP-10
reply/root e-tags), showing only pure mentions where someone tagged the
user in a new post. Kind 1111 comments continue to appear in both tabs.
2026-03-22 15:54:41 -04:00
Derek Ross 88d9e783b8 Distinguish replies from mentions in notifications
Kind 1 events that are replies (have NIP-10 reply/root e-tags) now show
a reply icon with 'replied to your note' instead of the '@' icon with
'mentioned you'. Only pure mentions (no reply threading) use the mention
label.
2026-03-22 15:50:11 -04:00
Derek Ross 854f9aca23 Fix notification UX issues: real-time updates, zap/reaction visibility, comment counts
- Fix silent notification drops: reactions, reposts, and zaps were being
  discarded when the referenced event couldn't be fetched from relays.
  Now keeps notifications with missing context instead of hiding them.
  Zaps no longer require the author-ownership check since the #p filter
  already confirms the user is the recipient. (fixes tabs appearing
  identical and missing zap notifications)

- Add real-time WebSocket subscriptions for instant notification updates
  instead of relying solely on 60-second polling. Both the full
  notification list and the unread dot indicator now react immediately
  when new events arrive.

- Wire up zap amounts from NIP-85 stats (zap_amount tag on kind 30383)
  through to the NoteCard action bar, replacing the hardcoded 0.

- Seed client-side reply counts into the event-stats cache from the
  loaded comment tree in PostDetailPage, ensuring sub-comment counts
  are visible even when NIP-85 stats are unavailable for kind 1111.

Closes #136
2026-03-22 15:42:45 -04:00
filemon 69dde41d9c fix(blobbi): rebalance daily mission rewards and persist to kind 31125
- Rebalance rewards: interact_6=30, feed_2=20, clean_1=20, sing_1=25,
  play_music_1=25, sleep_1=20, change_shape_1=40, take_photo_1=35
- Total daily reward now 75-105 coins (was 240-330)
- Add useClaimMissionReward hook to persist coins to Blobbonaut profile
- Claim flow now updates kind 31125 event, not just localStorage
- Add idempotency check to prevent double-crediting
- Update query cache and invalidate after successful claim
- Pass profile and updateProfileEvent to BlobbiMissionsModal
2026-03-21 21:55:18 -03:00
filemon e159e5bb6d feat(blobbi): implement daily missions system
- Add mission pool with 8 missions (interact, feed, clean, sing, play_music, sleep, take_photo, change_shape)
- Implement weighted random selection for 3 daily missions per user
- Add localStorage persistence with automatic daily reset
- Track progress from all Blobbi actions (inventory, direct actions, sleep, photo, profile shape)
- Add DailyMissionsPanel UI component with progress bars and claim buttons
- Integrate daily missions section into BlobbiMissionsModal
- Each mission rewards 80-110 coins depending on difficulty
2026-03-21 21:43:42 -03:00
filemon fce3c81029 fix(blobbi): ensure Blobbi eyes are always open in photo exports
Added disableBlink option to prevent Blobbi from being captured mid-blink
when downloading or posting photos.

Changes:
- useBlobbiEyes: Added disableBlink option that keeps blinkScaleY at 1 (fully open)
- BlobbiBabyVisual: Added disableBlink prop, passed to useBlobbiEyes
- BlobbiAdultVisual: Added disableBlink prop, passed to useBlobbiEyes
- BlobbiStageVisual: Added disableBlink prop, passed to child visuals
- BlobbiPolaroidCard: Now uses disableBlink={true} alongside lookMode='forward'

Photo mode behavior:
- lookMode='forward': Eyes look straight ahead (no mouse tracking)
- disableBlink={true}: Eyes stay fully open (no blinking animation)
- animated={false}: No ambient animations

Normal dashboard behavior unchanged - Blobbi still blinks and follows pointer.
2026-03-21 20:56:13 -03:00
filemon b14717eddb fix(blobbi): ensure polaroid export matches modal preview exactly
The date text was wrapping to a second line in the exported PNG but not
in the modal preview. This was caused by html-to-image rendering text
differently when using Tailwind classes.

Fixes:
- Convert caption area to inline styles for consistent html-to-image export
- Add whitespace: nowrap to date, stage badge, and caption elements
- Add explicit width constraint to caption container
- Use inline styles instead of Tailwind classes for all caption text

This ensures the downloaded PNG and Blossom-posted image match the
modal preview exactly, with the date staying on one line.
2026-03-21 20:50:23 -03:00
filemon 5891014ff6 refactor(blobbi): refine polaroid photo implementation
1. Polaroid layout - now looks like a real polaroid:
   - White frame on ALL sides (top: 16px, sides: 16px, bottom: 80px)
   - Photo area is inset within the white frame
   - Caption area positioned at bottom of frame
   - Consistent off-white background (#fafafa) for export

2. Removed noisy debug logs:
   - Removed [BlobbiStageVisual][baby] and [adult] console.logs
   - These were render-time logs that added noise without value

3. Improved export/post robustness:
   - URL extraction now explicitly finds 'url' tag instead of assuming [0][1]
   - Added error handling if upload returns no URL
   - Safer parsing that doesn't depend on tag order

4. Verified visual export consistency:
   - White frame visible on all sides
   - Blobbi centered in photo area
   - No clipping or gaps
   - Caption properly positioned
2026-03-21 20:39:50 -03:00
filemon b51535bfa0 feat(blobbi): implement Blobbi Photo (Polaroid) feature
Add the ability to take polaroid-style photos of Blobbis and share them:

- Add lookMode prop to Blobbi rendering system:
  - 'follow-pointer': Eyes track mouse cursor (default, existing behavior)
  - 'forward': Eyes look straight ahead (for photos/export)
  - Updated useBlobbiEyes, BlobbiBabyVisual, BlobbiAdultVisual, BlobbiStageVisual

- Create BlobbiPolaroidCard component:
  - Classic polaroid-style frame with white background and shadow
  - Soft gradient background for photo area
  - Caption area with Blobbi name, stage, and date
  - Fixed dimensions (320x400) for consistent export
  - Built with HTML+CSS (not canvas) for easy customization

- Create BlobbiPhotoModal component:
  - Opens from 'Take a Photo' button on BlobbiPage
  - Shows polaroid preview with Blobbi looking forward
  - Download button: exports as PNG using html-to-image
  - Post button: uploads to Blossom and creates kind 1 note
  - Clean, minimal UI focused on the photo

- Wire up to BlobbiPage:
  - Photo modal state and handler
  - Connected to floating 'Take a Photo' action button

Dependencies:
- Added html-to-image for DOM-to-PNG conversion
2026-03-21 20:29:03 -03:00
filemon 8476edd18f chore(blobbi): temporarily hide unimplemented floating action buttons
Hide 'Set as Companion' and 'Open PiP' buttons from BlobbiDashboardFloatingControls
until their features are implemented. Code is commented out with TODO markers for
easy re-enablement later.

Remaining visible buttons: Take a Photo, Info, Hatch/Evolve actions
2026-03-21 20:15:04 -03:00
filemon e528cdb36d feat(blobbi): add stage-aware validation and semantic cleanup
- Detect final stage from tags and validate tags against stage constraints
- Remove tags not valid for the detected stage (e.g., adult_type only on adults)
- Fix state after transitions (incubating/evolving -> active)
- Validate state is valid for the stage (per VALID_STATES_BY_STAGE)
- Skip required tag recovery for tags not valid for current stage
- Skip persistent tag recovery for tags not valid for current stage
- Add dev diagnostics with console.warn when repairs are applied
- Return finalStage in TagRepairResult for caller inspection
2026-03-21 20:08:56 -03:00
filemon 8696c698ed feat(blobbi): implement tag integrity guard for all Blobbi events
Add validateAndRepairBlobbiTags function that ensures tag integrity
whenever a Blobbi is republished. The guard:

1. Validates tags against the canonical schema
2. Removes deprecated tags automatically
3. Removes task-related tags during stage transitions (when requested)
4. Recovers missing required tags from:
   - Previous canonical tags (if available)
   - System defaults (for b, t, client only)
5. Preserves all persistent tags from previous state
6. NEVER invents personality/trait/adult_type values

Integration points:
- mergeBlobbiStateTagsForRepublish: validates all tag merges
- useBlobbiHatch: validates with task cleanup before publishing
- useBlobbiEvolve: validates with task cleanup before publishing
- buildMigrationTags: validates migration output

Repair strategy:
- System tags (b, t, client): recover from defaults
- Identity tags (name, seed, d): recover from previous only, never invent
- Personality tags: preserve if exist, never invent
- Visual tags: preserve (regenerable from seed if needed)
- Stats: preserve current values
- Task tags: cleanup during transitions
2026-03-21 20:02:20 -03:00
filemon c236caefad docs(blobbi): add product spec for Blobbi tag schema
Create docs/blobbi/blobbi-tag-schema.md as the canonical source of truth
for Blobbi tag definitions. The runtime schema at blobbi-tag-schema.ts
MUST align with this spec.

Includes:
- All 35 canonical tags organized into 11 categories
- Required vs optional designation
- Persistence rules across stage transitions
- Stage transition rules (hatch, evolve)
- Migration rules
- Validation rules
- 8 deprecated tags with migration guidance
2026-03-21 19:50:03 -03:00
filemon f9de5282c9 fix(blobbi): preserve personality/extension tags in migration flow
Update buildMigrationTags to preserve all persistent tags when migrating
legacy Blobbis to canonical format:

- personality, trait, favorite_food, voice_type, mood
- adult_type
- theme, crossover_app

Per blobbi-tag-schema.md spec: Do NOT invent values for tags that don't
exist. Only preserve values that are already present in the legacy event.

Also adds docs/blobbi/blobbi-tag-schema.md as the product spec for all
Blobbi tag definitions. The runtime schema in blobbi-tag-schema.ts MUST
align with this spec.
2026-03-21 19:49:53 -03:00
filemon a4c2895c68 docs(blobbi): add canonical tag schema for Blobbi events
Create blobbi-tag-schema.ts as the single source of truth for all
Blobbi tag definitions. The schema documents:

- 35 canonical tags organized into 11 categories
- Tag metadata: required, stages, persistent, source, regenerable
- 8 deprecated tags with migration guidance
- Helper functions for schema queries and validation

Categories:
- system: d, b, t, client
- identity: name, seed, generation
- visual: base_color, secondary_color, eye_color, pattern, special_mark, size
- personality: personality, trait, favorite_food, voice_type, mood
- stats: hunger, happiness, health, hygiene, energy
- state: stage, state, last_interaction, last_decay_at
- task: state_started_at, task, task_completed
- progression: experience, care_streak
- social: visible_to_others, breeding_ready
- evolution: adult_type
- extension: theme, crossover_app

Also updates MANAGED_BLOBBI_STATE_TAG_NAMES to include extension
tags (theme, crossover_app) and reorganizes comments by category.
2026-03-21 19:39:34 -03:00
filemon e33ee800bc fix(blobbi): preserve identity attributes across stage transitions
- Add identity/personality tags (personality, trait, favorite_food,
  voice_type, mood, adult_type) to MANAGED_BLOBBI_STATE_TAG_NAMES
- Add 'interact_6_progress' to DEPRECATED_BLOBBI_TAG_NAMES to remove
  legacy interaction tracking
- Update hatch flow to clean only task/state-specific tags while
  preserving all identity attributes from canonical.allTags
- Update evolve flow to clean only task/state-specific tags while
  preserving all identity attributes, and set state to 'active'

This ensures Blobbi identity persists across egg → baby → adult
transitions, treating them as persistent entities rather than
reconstructed objects at each stage.
2026-03-21 19:35:53 -03:00
filemon 24aa80840c Merge branch 'main' into feat-blobbi 2026-03-21 19:24:47 -03:00
The Daniel d9aa6258cd fix: allow nsec paste in login field 2026-03-21 18:07:46 -04:00
The Daniel 941e6ee4e6 feat: block nsec paste in all input fields with warning toast
Adds a global capture-phase paste listener that detects nsec private
keys and prevents them from being pasted into any field. Shows a
destructive toast warning the user that private keys should never
be shared.
2026-03-21 18:03:17 -04:00
filemon ffab9d3aaa feat(blobbi): add dev-only instant stage transition button
- Add 'Dev Hatch' button for eggs (bypasses incubation tasks)
- Add 'Dev Evolve' button for babies (bypasses evolution tasks)
- Button only appears in dev mode (import.meta.env.DEV)
- Uses dashed amber border style to clearly indicate dev-only
- Reuses existing onHatch/onEvolve callbacks
- No button shown for adults (no further transitions)
2026-03-20 18:15:30 -03:00
filemon 4d16e1ab83 Merge branch 'main' into feat-blobbi 2026-03-20 18:13:32 -03:00
filemon 6781685252 update typo in epsy url in blobbi 2026-03-20 18:03:52 -03:00
filemon 88bdf87e95 feat(blobbi): reduce evolve post requirement from 3 to 1
Make evolve process lighter and less repetitive:
- EVOLVE_REQUIRED_POSTS: 3 → 1
- Update task name: 'Create Posts' → 'Share Evolution'
- Update task description: 'Share 3 posts about evolving' → 'Post about your Blobbi evolving'
- Reduce query limit since only 1 post needed

Hatch process unchanged - still requires 3 posts with hatch-specific validation
2026-03-20 17:29:14 -03:00
filemon 6b3d98bd66 refactor(blobbi): unify task process with useActiveTaskProcess hook
- Add useActiveTaskProcess hook to consolidate hatch/evolve task logic
- Rename useSyncHatchTaskCompletions to useSyncTaskCompletions
- Fix badge to include dynamic tasks (was only counting persistent)
- Fix 'Edit Wall' task link to point to /settings/profile
- Reduce duplication in BlobbiPage.tsx by using unified hook
- Export new hook and types from blobbi/actions index
2026-03-20 16:07:38 -03:00
filemon ec7ceb2352 fix(blobbi): fix task tag writing and unify badge logic for both processes
BUG 1 - task/task_completed tags not being written:
- incrementInteractionTaskTags now accepts requiredInteractions param
  (7 for hatch, 21 for evolve) instead of hardcoded value
- useBlobbiDirectAction and useBlobbiUseInventoryItem now increment
  interactions for BOTH incubating AND evolving states
- useSyncHatchTaskCompletions now syncs for both processes

BUG 2 - evolving missions not affecting badge:
- remainingTasksCount now uses active process tasks (hatch or evolve)
- allTasksComplete now checks both incubating and evolving states
- BlobbiBottomBar uses isInTaskProcess instead of isIncubating

Architecture rules preserved:
- Persistent tasks: can be cached in task/task_completed tags
- Dynamic tasks: NEVER stored in tags, UI-only
- No infinite loops, no retroactive increments
- Interactions only increment during real user actions
2026-03-20 16:01:15 -03:00
filemon c62c38b136 feat(blobbi): implement evolve task system with dynamic stat requirements
Add comprehensive evolution task system parallel to incubation:

Architecture:
- Separate persistent tasks (event-based, cacheable in tags) from
  dynamic tasks (stat-based, never cached, recomputed every render)
- Both hatch and evolve require all persistent AND dynamic tasks complete

New hooks:
- useEvolveTasks: 6 persistent tasks + 1 dynamic stat task (all stats >= 80)
- useStartEvolution/useStopEvolution: manage evolution state transitions

New components:
- TasksPanel: generalized task display for both hatch and evolve
- StartEvolutionDialog: confirmation dialog for starting evolution
- Updated BlobbiMissionsModal to handle both hatch and evolve flows

Evolve tasks (baby → adult):
- Create 3 themes, 3 color moments, 3 evolve posts
- 21 interactions, use Blobbi shape, edit wall once
- Dynamic: maintain all 5 stats >= 80

Key fixes:
- Renamed isEvolving variable collision in BlobbiPage
- Filter dynamic tasks from sync to prevent tag pollution
- Updated BlobbiPostModal to support evolve posts dynamically
2026-03-20 15:31:02 -03:00
DanConwayDev 33bf59f353 fix: remove unexported NLoginBunker import and prefer-const lint error 2026-03-20 17:22:38 +00:00
DanConwayDev b4d3c4833c feat: remote signer UX improvements for Amber/NIP-46 users on Android
Amber users on Android who manually approve events must switch from Ditto to
Amber to approve, then switch back. Backgrounding Ditto can freeze its
WebSocket, causing the NIP-46 response to be silently dropped — leaving the
operation hanging with no feedback and no way out. Users with Amber
notifications working correctly are unaffected, as approving via notification
does not background Ditto.

Even with auto-approve enabled, kinds outside Amber's default whitelist
require manual approval. Ditto uses several of these regularly: kind 1059
(NIP-17 gift-wrap DMs), 1111 (comments), 1311 (live chat), 31925 (RSVPs),
24242 (Blossom file upload auth), and 30078 (app settings).

This introduces signerWithNudge, a NostrSigner wrapper with the following
behaviour:

Nudge toast after 4 seconds
If a signing or encryption op is still pending after 4 s, a persistent toast
appears naming what is being approved (e.g. 'Approve file upload auth'), with
a human-readable label derived from the event kind.

Android 'Approve in signer' button
On Android the nudge toast includes an 'Approve in signer' button that opens
Amber via the nostrsigner: URI scheme, keeping the WebSocket alive. After
tapping, the button becomes a spinner and a Cancel button appears.

Automatic retry on foreground resume
When Ditto returns to the foreground after being backgrounded, it
automatically retries the pending NIP-46 request (up to 2 times) and shows a
brief 'Checking for signer response' toast.

Hard 45-second timeout
Operations with no response within 45 s are rejected with a clear error.

Cancel / Skip
The nudge toast has a Skip link throughout. After tapping 'Approve in signer'
it becomes a full Cancel button.

Multi-phase encrypt-then-sign
Saving app settings (kind 30078) and mute lists (kind 10000) require a nip44
encrypt followed immediately by a signEvent. When the encrypt nudge was shown,
a phase-transition toast tells the user a second approval is coming. The check
is kind-specific to avoid false positives.

Success feedback
A brief 'Approved' toast confirms the outcome when the nudge was shown.

Relay connectivity check
At nudge time, if the bunker relay WebSocket is not OPEN the toast warns
'Signer relay unreachable' instead of prompting for an approval that cannot
be delivered.

Accessibility
Toast buttons meet the 44 px touch target minimum. Text size and contrast
were increased for readability on small screens.
2026-03-20 17:22:37 +00:00
filemon 44b54f6c32 fix(blobbi): fix remainingTasksCount memo and allTasksComplete condition
- remainingTasksCount now depends on hatchTasks.tasks (not just completedTaskIds)
  to correctly reflect loading state and task changes
- allTasksComplete is now a memoized value that prevents false positives by
  checking: isIncubating && !isLoading && tasks.length > 0 && remaining === 0
- Changed missions badge symbol from '?' to '!' when all tasks complete
2026-03-20 12:02:12 -03:00
filemon 57f7af3141 refactor(blobbi): stabilize task sync, fix hatch content, improve UI indicators
Task Sync Stability:
- Remove hatchTasks.tasks from useEffect dependencies (was causing instability)
- Derive tasksToSync and remainingTasksCount via useMemo keyed off completedTaskIds
- Effect dependencies now only include stable primitives: completedTaskIds,
  cachedCompletedIds, hatchTasks.isLoading, companion?.state

Content Fix on Stage Transition:
- Add generateBlobbiContent() helper with correct grammar ('an egg' vs 'a baby')
- useBlobbiHatch now generates new content: '{name} is a baby Blobbi.'
- useBlobbiEvolve now generates new content: '{name} is an adult Blobbi.'
- Content always reflects current stage (was keeping old egg content)

Missions Icon Badge:
- Show remaining task count when incubating with tasks remaining
- Show '?' badge (success variant) when all tasks complete during incubation
- Badge variants: default (blue), warning (amber), success (emerald)

Blobbies Icon Badge:
- Now shows count of Blobbies needing care (any stat < CARE_THRESHOLD=40)
- Warning variant (amber) when there are needy Blobbies
- Only shows badge when count > 0

Selector Modal Warning:
- Add AlertTriangle indicator in top-right corner for Blobbies needing care
- Uses same companionNeedsCare() logic as bottom bar badge
2026-03-20 11:44:48 -03:00
filemon 23dbd9112a fix(blobbi): prevent infinite loop in task sync, add missions badge
Infinite Loop Fix:
- Add useRef anti-loop memory (lastSyncedKeyRef) to track last synced key
- Mark key as synced BEFORE calling sync to prevent race conditions
- Add guard for companion.state !== 'incubating'
- Remove syncTaskCompletions from useEffect deps (intentional, prevents
  re-triggering when mutation function reference changes)
- Reset ref on error to allow retry

Missions Badge:
- Add remainingTasksCount prop to BlobbiBottomBar
- Show badge on Missions button when incubating with incomplete tasks
- Update BottomBarButton to show badge when count > 0 (was > 1)

The interaction task tags were already being updated correctly by
incrementInteractionTaskTags() in useBlobbiDirectAction and
useBlobbiUseInventoryItem hooks during real user interactions.
2026-03-20 11:19:41 -03:00
filemon 44c8103600 refactor(blobbi): make incubation flow explicit, require Blobbi name in posts
- useStartIncubation now requires explicit mode ('start', 'restart', 'switch')
  instead of auto-detecting behavior. This makes the flow predictable.
- StartIncubationDialog determines mode and passes it to onConfirm callback
- Removed useUpdateTaskProgress hook (architecturally inconsistent - updated
  last_interaction during cache-only sync, violating the rule that only real
  user actions should update timestamps)
- BlobbiPostModal now requires blobbiName and process props for stage-aware
  post generation with Blobbi name as first hashtag
- isValidBlobbiPost() now validates the Blobbi name hashtag is present
- Added sanitizeToHashtag helper to both BlobbiPostModal and useHatchTasks
  for consistent hashtag generation
2026-03-20 10:28:10 -03:00
filemon d2441b345b fix(blobbi): make task sync fully idempotent and safe
Audit and hardening of hatch task cache sync:

1. BlobbiPage useEffect:
   - Use useMemo to create stable string keys for completion comparison
   - Only trigger sync when computed completions differ from cached
   - Skip sync entirely if no diff exists
   - Add dev-only debug logs

2. useSyncHatchTaskCompletions:
   - Remove last_interaction update (this is cache sync, not user action)
   - Add double diff check: first against companion.tasksCompleted, then against canonical.allTags
   - Return detailed result with skip reasons for debugging
   - Add dev-only debug logs for all sync decisions

3. incrementInteractionTaskTags:
   - Add check for already-completed state to prevent duplicate task_completed tags
   - Return previousCount for debugging
   - Add dev-only debug logs
   - Document that this is NOT idempotent by design (each call = real interaction)

Key guarantees:
- Cache-only sync never mutates last_interaction
- Multiple renders with same data = no publish
- WebSocket updates or refetches = no publish unless real diff
- No duplicate task_completed tags possible
2026-03-20 10:13:19 -03:00
filemon 2c828c8778 feat(blobbi): add missions modal, single incubation enforcement, and task sync
Phase 2 of Blobbi incubation audit:

- Add BlobbiMissionsModal component with HatchTasksPanel integration
- Move hatch tasks UI from main page to Missions modal
- Add useStopIncubation hook with confirmation dialog
- Enforce only one Blobbi incubating at a time (auto-stops previous)
- Enhance StartIncubationDialog to show switch warning for other incubating Blobbi
- Add useSyncHatchTaskCompletions hook to sync task completions to kind 31124 tags
- Consolidate STAT_MIN/STAT_MAX to single source in src/lib/blobbi.ts
- Remove unused BlobbiPlaceholderModal component
2026-03-20 09:59:50 -03:00
filemon 8097f0e5fb fix(blobbi): fix selection reset bug, stat safety, and migration visual traits
- Remove auto-save effect that was overwriting user selection during
  WebSocket/query updates. User selection now only persists via explicit
  handleSelectBlobbi() call.
- Add debug logging to trace selection changes in development mode.
- Add STAT_MIN=1 and STAT_MAX=100 constants to blobbi-decay.ts and update
  clamp() to use STAT_MIN instead of 0, preventing soft-lock issues.
- Fix buildMigrationTags() to always derive and include all visual traits
  during migration, ensuring every migrated event has complete visual data.
2026-03-20 09:47:11 -03:00
filemon 14644d0cb3 Clean up deprecated tags in migration and republish flows
- Remove incubation_time and start_incubation from coreStateTags in
  buildMigrationTags - these obsolete fields should not be carried
  forward during migration
- Add DEPRECATED_BLOBBI_TAG_NAMES to the exclusion set in buildMigrationTags
  when filtering unknown tags, preventing deprecated tags from being
  preserved in migrated events
- Add DEPRECATED_BLOBBI_TAG_NAMES filtering to mergeTagsForRepublish so
  deprecated tags are not carried forward when republishing existing events

The deprecated tags (incubation_time, start_incubation, incubation_progress,
egg_status, fees) are now properly excluded from:
1. New events (already handled by buildEggTags)
2. Migrated events (buildMigrationTags)
3. Republished events (mergeTagsForRepublish, mergeBlobbiStateTagsForRepublish)

Legacy parsing still reads these fields for backwards compatibility, but
they are never written to new events.
2026-03-19 13:06:31 -03:00
filemon 4c89a20bbe Fix critical bug: Blobbis disappear after starting incubation
The isValidBlobbiEvent function only accepted 3 states (active, sleeping,
hibernating) but BlobbiState type includes 5 states. When state changed to
'incubating' or 'evolving', events failed validation and were filtered out
by useBlobbisCollection and useBlobbiCompanion hooks.

Added 'incubating' and 'evolving' to the valid states list.
2026-03-19 11:50:45 -03:00
filemon 6a2285ef72 Clean up Blobbi incubation system and enforce consistency
Deprecated tags removed from creation:
- incubation_time: No longer created in buildEggTags or previewToEventTags
- start_incubation: No longer in managed tags
- egg_status: Removed from LEGACY_VISUAL_TAG_NAMES (was duplicated)

These tags are now in DEPRECATED_BLOBBI_TAG_NAMES and stripped on republish.

Visual trait consistency:
- buildEggTags now includes all visual traits (base_color, secondary_color,
  eye_color, pattern, special_mark, size) derived from seed
- VISUAL_TRAIT_TAG_NAMES replaces LEGACY_VISUAL_TAG_NAMES
- Visual traits added to MANAGED_BLOBBI_STATE_TAG_NAMES

Stat safety:
- STAT_MIN = 1 (was 0) to prevent soft-lock
- STAT_MAX = 100
- clampStat now clamps to 1-100 range instead of 0-100
- Recovery is always possible with any healing item

Layout improvements:
- BlobbiPage container: max-w-2xl on mobile, max-w-3xl on desktop
- Reduced side whitespace on mobile (px-2)
- Better breathing room overall

BlobbiCompanion interface:
- incubationTime and startIncubation marked as @deprecated
- DEFAULT_INCUBATION_TIME marked as @deprecated

state_started_at remains the single source of truth for process timing.
2026-03-19 09:55:26 -03:00
filemon 37a791f113 Fix incubation-start flow to properly apply decay and clean obsolete tags
- Apply accumulated decay from last_decay_at to now before state change
- Write decayed stat values into the incubation-start event
- Set last_decay_at = state_started_at = last_interaction for consistency
- Add incubation_progress, egg_status, fees to DEPRECATED_BLOBBI_TAG_NAMES

The incubation-start event now has consistent timestamps:
- created_at: NOW
- state_started_at: NOW
- last_interaction: NOW
- last_decay_at: NOW (was incorrectly preserving old value)
2026-03-19 09:39:03 -03:00
filemon 06f820d355 Refine Blobbi incubation/hatch task system
- Fix Color Moment URL: espy.social -> espy.you
- Improve shape-change task detection: only counts true post-start changes
- Remove duplicate Start Incubation button, integrate into evolve/hatch button
- Extract shared incrementInteractionTaskTags helper for code reuse
- Update floating controls to show incubation action for eggs not yet incubating

The evolve/hatch button now serves as the single entry point:
- Egg (not incubating): Opens incubation dialog
- Egg (incubating): Button hidden, hatch action in HatchTasksPanel
- Baby: Evolves to adult
2026-03-19 09:04:08 -03:00
filemon 3a2571ccc6 Merge branch 'main' into feat-blobbi 2026-03-19 08:08:37 -03:00
filemon dd614e6a6a Merge branch 'main' into feat-blobbi 2026-03-18 18:00:55 -03:00
filemon f5595b3477 feat(blobbi): implement hatch task system for egg incubation
Add task-based progression system for hatching Blobbi eggs:

- Extend BlobbiState with 'incubating' and 'evolving' states
- Add task tracking fields to BlobbiCompanion interface
- Create useStartIncubation hook to begin incubation process
- Create useHatchTasks hook to compute task progress from Nostr events
- Add HatchTasksPanel UI component showing 5 hatch tasks:
  - Create Theme (kind 36767)
  - Color Moment (kind 3367)
  - Change Avatar Shape (kind 0 with shape change)
  - Create Post with required prefix/hashtags
  - 7 Blobbi interactions
- Add BlobbiPostModal with enforced content requirements
- Add StartIncubationDialog confirmation dialog
- Auto-increment interaction counter in action hooks
- Gate hatch button visibility based on task completion
2026-03-18 16:44:38 -03:00
filemon e9c34df51e fix(blobbi): show aggregated stat preview in inventory use dialog
The inventory modal's use confirmation dialog was showing simple
multiplication (effect * quantity) instead of the actual clamped
values that would be applied.

Now it simulates the sequential application of effects, clamping
at each step, to show the true total effect that will be applied
when confirming. This matches the behavior of the action modal's
confirmation dialog.
2026-03-18 11:34:25 -03:00
filemon 82b9964a61 feat(blobbi): improve item usage UX with quantity selector and inventory actions
- Add quantity selector to item usage modal (feed/play/clean/medicine)
  - Users can now use multiple items at once
  - Effects are applied sequentially with proper clamping at each step
  - Shows estimated total effect preview

- Add 'Use' button to inventory modal
  - Items can now be used directly from inventory
  - Reuses same logic as normal item usage flow
  - Opens confirmation dialog with quantity selector

- Add stage-based item blocking in inventory
  - Eggs cannot use food or toys (shown disabled with reason)
  - Shell Repair Kit only usable by eggs (blocked for baby/adult)
  - Blocked items remain visible but cannot be used

- Shell Repair Kit visibility in medicine modal
  - Only appears for eggs, hidden for other stages

- Make shop modal narrower (max-w-4xl to max-w-2xl)

- Add centralized item usability logic (canUseItemForStage)
  - Single source of truth for item/stage restrictions
  - Exported from actions module for reuse
2026-03-18 11:24:07 -03:00
filemon a0cb5cc307 Merge branch 'main' into feat-blobbi 2026-03-18 10:51:45 -03:00
filemon 0b26ef51a2 feat: add pot to Rosey shape 2026-03-18 10:50:44 -03:00
filemon da3cc5f997 fix: apply shape mask to hover/edit overlay in ProfileCard
The hover overlay was showing as a full rectangle instead of respecting
the Blobbi shape mask. This happened because the overlay was using the
sync getAvatarMaskUrl() which returns empty for Blobbi shapes (they now
render asynchronously as PNG).

Changed ProfileCard to:
- Use getAvatarMaskUrlAsync() for the overlay mask
- Load mask URL via useEffect with proper cleanup
- Apply the same mask to the hover overlay as the avatar itself

Now the hover/edit darkening effect respects the actual avatar shape,
only appearing inside the visible silhouette.
2026-03-18 10:44:28 -03:00
filemon 96356eb804 fix: render Blobbi masks to PNG via canvas for reliable CSS mask-image
CSS mask-image with SVG data URLs doesn't work reliably for complex SVGs
with transforms in some browsers. The picker preview works because it renders
inline SVG directly, but the avatar mask-image was failing for shapes like
droppi, flammi, leafy, mushie, owli, rocky, and rosey.

Changes:
- Rewrite getBlobbiMaskUrl() to render SVG to canvas and export PNG
- Use Blob URL for SVG loading (more reliable than data URL)
- Add async mask generation with proper caching and deduplication
- Update Avatar component to load masks asynchronously via useEffect
- Add getAvatarMaskUrlAsync() for async mask URL retrieval

The picker preview continues to use inline SVG (which works fine),
while the avatar mask now uses rasterized PNG (which works everywhere).
2026-03-18 10:36:27 -03:00
filemon 80067a212c fix: inject fill/stroke attributes directly into SVG elements for mask rendering
The previous approach using <style> tags with CSS selectors inside SVG data URLs
didn't work reliably when the SVG was used as a CSS mask-image. Some browsers
don't process CSS inside SVG data URLs correctly.

This fix:
- Adds injectWhiteFillStroke() function to directly inject fill="white" and
  stroke="white" attributes into each SVG shape element
- Properly handles elements with transform attributes (the root cause of
  droppi, flammi, leafy, mushie, owli, rocky, rosey failing)
- Preserves fill="none" for stroke-only elements (like catti's tail)
- Works reliably across all browsers since it uses SVG attributes, not CSS
2026-03-18 10:28:08 -03:00
filemon d962d7952b fix: remove hardcoded white stroke from catti tail and fix avatar mask updates
1. Remove stroke="white" from catti shape - let the styling apply colors
2. Fix Avatar component to properly update mask when shape changes:
   - Compute maskUrl outside useMemo so it's always fresh
   - Use maskUrl directly in dependencies instead of shape string
   - This ensures the mask updates immediately when selecting a new shape
2026-03-18 10:21:39 -03:00
filemon ae4847ce50 fix: use SVG data URL directly for Blobbi mask instead of canvas rendering
The previous implementation tried to render SVG to canvas synchronously,
but Image loading is asynchronous, causing getBlobbiMaskUrl() to return
empty string and avatars to fall back to squares.

CSS mask-image supports SVG data URLs directly, so we now skip the
canvas conversion entirely. This is simpler, more reliable, and works
synchronously.

- Remove renderSvgToMaskUrl() and drawImageToCanvas() functions
- Simplify getBlobbiMaskUrlAsync() to just wrap sync version
2026-03-18 10:15:45 -03:00
filemon a07a2de786 fix: scope Blobbi shape colors and increase preview size
- Use fill/stroke attributes on <g> instead of global CSS to prevent color leakage
- Reduce grid columns from 5 to 4 for larger shape previews
- Reduce inner padding from inset-1 to inset-0.5 for bigger shapes
2026-03-18 10:09:08 -03:00
filemon 96ce34c7f1 refactor: use raw SVG markup for Blobbi shapes instead of single path
- Change BlobbiShape type from 'path: string' to 'svg: string'
- Store original SVG body markup preserving circles, ellipses, rects, paths, transforms, and strokes
- Update getBlobbiMaskUrl() to render SVG string via Image element instead of Path2D
- Add getBlobbiMaskUrlAsync() for guaranteed async loading
- Add getBlobbiShapeSvg() helper to get complete SVG markup with custom fill
- Update BlobbiShapePicker to render multi-element SVG with dangerouslySetInnerHTML
- Shapes now visually match original SVG files exactly (e.g., catti tail is stroke-based)
2026-03-18 09:50:13 -03:00
filemon c6fa1acf66 refactor: improve Blobbi shape picker UI and update character silhouettes
- Add tight viewBox computation for better shape visibility in picker
- Change grid layout to 5 columns for larger, more prominent shapes
- Remove shape name labels for cleaner picker interface
- Rename 'Blobbi' tab to 'Blobbids' in avatar picker dialog
- Update all adult Blobbi SVGs with refined designs
- Redesign shape paths with detailed silhouettes including limbs and accessories
- Add pot to Leafy, adjust Pandi arms, remove shadows from Rocky
2026-03-18 09:44:23 -03:00
filemon a7c29c4a85 feat: add Blobbi shapes as avatar masks
New feature allowing users to select Blobbi character silhouettes as
avatar masks, in addition to existing emoji shapes.

New files:
- src/lib/blobbiShapes.ts: Shape definitions with SVG paths for all
  Blobbi forms (egg, baby, 16 adults)
- src/components/BlobbiShapePicker.tsx: Grid picker with category tabs

Changes:
- Extended avatarShape.ts to support 'blobbi:' prefixed shape values
- Added getAvatarMaskUrl() unified function for both shape types
- Updated Avatar component to render Blobbi masks
- Added tabbed UI in ProfileCard (Emoji/Blobbi tabs)
- Renamed emojiAvatarBorderStyle to shapedAvatarBorderStyle

Shape format: 'blobbi:<id>' (e.g., 'blobbi:baby', 'blobbi:catti')
Stored in kind-0 metadata as 'shape' property, same as emojis.

Available shapes:
- Egg (simple egg silhouette)
- Baby Blobbi (water droplet)
- Adults: catti, owli, froggi, droppi, flammi, crysti, cloudi,
  mushie, starri, pandi, cacti, breezy, leafy, rocky, rosey, bloomi
2026-03-17 12:32:46 -03:00
filemon 91364385c3 fix: use data attributes for blink center instead of CSS parsing
Changes:
- Store blink center as data-cx/data-cy attributes on .blobbi-blink groups
- Use eye white center (cx, cy) as blink anchor when available
- Fallback to pupil center if no eye white found
- Read center from data attributes in animation loop (more reliable than CSS)

Structure per eye:
<g class="blobbi-blink" data-cx="38" data-cy="45">  <!-- blink group -->
  <ellipse ... />                                        <!-- eye white -->
  <g class="blobbi-eye">                               <!-- tracking group -->
    <circle ... />                                       <!-- pupil -->
    <circle ... />                                       <!-- highlight -->
  </g>
</g>

Blink transform: translate(cx,cy) scale(1,blinkY) translate(-cx,-cy)
- Scales around the actual eye center from SVG element data
- No CSS transform-origin parsing needed
- Eye closes in place without shifting
2026-03-17 12:21:02 -03:00
filemon d958722e63 fix: scale blink around eye center to prevent downward shift
Problem: scale(1 blinkY) was scaling from top-left origin, causing
the eye to move down during blink.

Solution: Use translate-scale-translate pattern to scale around the
eye's center point:
  translate(cx cy) scale(1 blinkY) translate(-cx -cy)

The center coordinates are extracted from the transform-origin style
that was already set during SVG processing.

Tracking behavior remains completely unchanged - only the blink
transform application was modified.
2026-03-17 12:11:10 -03:00
filemon d3a19ebfaa fix: separate tracking and blink into distinct transform groups
Problem: Blink was only affecting pupil, not whole eye.
Previous attempt: Wrapping entire eye in one group caused eye white to
move with mouse tracking.

Solution: Two separate nested groups per eye:
- .blobbi-blink (outer): wraps entire eye for scaleY blink animation
- .blobbi-eye (inner): wraps only pupil+highlight for translate tracking

Structure:
<g class="blobbi-blink">     <!-- blink: scale(1 blinkY) -->
  <ellipse ... />             <!-- eye white - stays fixed -->
  <g class="blobbi-eye">    <!-- tracking: translate(x y) -->
    <circle ... />            <!-- pupil - moves with mouse -->
    <circle ... />            <!-- highlight - moves with mouse -->
  </g>
</g>

Result:
- Mouse tracking: only pupil+highlight translate (unchanged behavior)
- Blinking: entire eye scales vertically (natural cartoon blink)
- Eye white: never moves, only scales during blink
2026-03-17 12:01:08 -03:00
filemon c17883bdb8 feat: add natural blinking system for Blobbi eyes
- Random blink intervals between 2-5 seconds for organic feel
- Blink animation: fast close (~80ms), pause (~100ms), slower open (~120ms)
- 20% chance for double blinks (extra polish)
- Uses scaleY transform combined with mouse tracking translate
- Easing functions: ease-in for close, ease-out for open
- Disabled when Blobbi is sleeping
- No CSS transitions - all animation via RAF for instant response
2026-03-17 11:42:40 -03:00
filemon 0b90b0206b fix: eliminate eye tracking lag with direct SVG transforms
- Remove CSS transitions from .blobbi-eye class (root cause of delay)
- Use SVG transform attribute instead of style.transform for reliable repaints
- Cache eye element references after mount with automatic refresh on SVG changes
- Hook now manages DOM directly without onUpdate callback
- Simplified visual components to just pass containerRef
2026-03-17 11:34:58 -03:00
filemon 8f958ef6c7 refactor: simplify eye system to always track mouse globally
Removed from previous system:
- Idle random movement logic
- Energy-based behavior (timing, smoothing, micro-movements)
- Tracking radius (200px distance check)
- Idle/tracking state switching
- lerp interpolation for tracking
- isTracking state and callback parameter
- Per-instance mouse listeners

New behavior:
- Eyes ALWAYS follow the mouse cursor
- Works across the entire screen (no distance limit)
- Instant response (no interpolation, no lag)
- Simple angle calculation every frame

How the new tracking loop works:
1. Global mouse listener updates globalMouseX/globalMouseY
2. RAF loop runs every frame
3. Calculate angle: atan2(mouseY - centerY, mouseX - centerX)
4. Calculate position: cos(angle) * max, sin(angle) * max * 0.7
5. Call onUpdate callback with position
6. DOM updated directly (no React state)

Performance optimizations:
- Single global mouse listener shared by all Blobbi instances
- Instance count tracking for cleanup
- No React state in animation loop
- Direct DOM manipulation via callback
- Minimal computation per frame (just trig)

Hook reduced from ~390 lines to ~140 lines.
2026-03-17 10:35:44 -03:00
filemon 2118fa483b fix: remove React state from animation loop for real-time eye tracking
Problems fixed:

1. React state caused tracking delay
   - setState() batches updates and triggers re-renders
   - Even with refs, calling setState inside RAF caused 1-2 frame lag
   - Eyes only followed mouse properly when it stopped moving

2. Tracking intensity was inverted
   - Old: farther mouse = stronger movement (wrong)
   - New: closer mouse = stronger movement (correct)
   - Formula: intensity = 1 - Math.pow(normalizedDistance, 0.5)

3. Idle froze after mouse left
   - Old: scheduled idle change 300-800ms later
   - New: immediately triggers new idle target when tracking stops
   - Formula: nextIdleChangeRef.current = currentTime (force immediate)

Solution - callback-based architecture:

- REMOVED all setState calls from animation loop
- Added onUpdate(left, right, isTracking) callback option
- Callback is called every RAF frame with current positions
- Components apply transforms directly to DOM via querySelectorAll
- Zero React re-renders during animation = zero lag

Data flow now:
  RAF loop → compute position → onUpdate callback → direct DOM update

Before:
  RAF loop → setState → React re-render → useEffect → DOM update

The onUpdate callback receives positions every frame and applies
transforms immediately, bypassing React's batching entirely.
2026-03-17 10:29:25 -03:00
filemon 2bb9c7738a feat: instant mouse tracking and energy-based idle behavior for Blobbi eyes
1. Mouse tracking now INSTANT (no lag):
   - When tracking, eyes lock directly onto cursor position
   - No interpolation/lerp during tracking mode
   - Feels like 'locked on target' instead of floating/chasing

2. Energy-based idle behavior:
   - High energy (100): frequent movement, shorter pauses, quicker smoothing
   - Low energy (0): lazy movement, longer pauses, slower drift
   - Energy affects: idle duration, smoothing speed, micro-movement chance

3. Micro-movements for aliveness:
   - Small movements (0.2-0.5px) happen randomly
   - High energy = 50% chance, Low energy = 10% chance
   - Makes Blobbi feel alert and curious

4. Pause behavior scaled by energy:
   - Low energy: 50% chance to rest at center
   - High energy: 10% chance to rest at center

Values chosen:
- SMOOTHING_MIN = 0.02 (low energy - dreamy drift)
- SMOOTHING_MAX = 0.06 (high energy - alert movement)
- IDLE_DURATION_MIN = 1000ms (high energy)
- IDLE_DURATION_MAX = 6000ms (low energy)
- MICRO_MOVEMENT_MAX = 0.5px (subtle but visible)

Behavior summary:
- Mouse near -> eyes LOCK instantly on cursor
- High energy -> curious, active, moving often
- Low energy -> slower, lazy, but still alive
- Sleeping -> no movement
2026-03-17 10:17:37 -03:00
filemon 7ed55b00a6 fix: smooth eye animation with proper interpolation and mouse tracking
Root causes of previous issues:

1. Jumping behavior: The old implementation used setState() directly to
   random positions instead of interpolating toward them. Each idle
   movement instantly teleported the eyes.

2. Mouse tracking failure: The updateMouseTracking callback had isTracking
   in its dependencies, causing it to be recreated on every state change.
   This restarted the animation frame loop constantly, breaking the
   continuous tracking.

3. State conflict: Idle timeouts and tracking animation frames ran
   independently and fought each other, causing erratic behavior.

Solution - Single animation loop architecture:

- ONE requestAnimationFrame loop handles ALL animation
- Maintains separate 'current' and 'target' positions
- Always interpolates: current = lerp(current, target, smoothing)
- Idle behavior only sets new targets (doesn't move directly)
- Mouse tracking overrides targets when cursor is nearby
- Clean state machine: tracking active = idle paused

Smoothing values used:
- IDLE_SMOOTHING = 0.03 (very smooth drift)
- TRACKING_SMOOTHING = 0.08 (responsive but not snappy)
- RETURN_SMOOTHING = 0.04 (gentle return to idle)

Timing improvements:
- Idle duration: 3-6 seconds between movements
- 40% chance to pause at center (natural resting)
- Time-scaled smoothing for consistent feel across frame rates

Movement constraints:
- Baby: 2px max, Adult: 2.5px max
- Vertical movement reduced to 70% of horizontal
- State updates throttled (only when position changes > 0.001px)
2026-03-17 10:08:58 -03:00
filemon 4feb051177 fix: rewrite eye animation system for proper SVG transforms and mouse tracking
Root cause: The original implementation had two critical issues:
1. Grouping algorithm assumed highlights immediately followed pupils in SVG,
   but SVGs have all pupils first, then all highlights (proximity-based fix)
2. CSS transforms weren't working on SVG <g> elements without transform-box

Fixes:
- Rewrite pupil/highlight detection to use proximity-based grouping (15px radius)
- Add transform-box: fill-box and transform-origin: center inline styles
- Replace CSS keyframe animation with JavaScript-controlled transforms

New features:
- Natural idle behavior with random movement and pauses
- Mouse tracking when cursor is within 200px radius
- Smooth transitions between idle and tracking states
- Different delays for left/right eyes for organic feel

Implementation:
- useBlobbiEyes hook manages animation state and mouse tracking
- addEyeAnimation wraps pupil+highlight elements in <g class="blobbi-eye">
- Visual components apply transforms via DOM refs in useEffect
- CSS provides transition timing (.3s idle, .1s tracking)
2026-03-17 09:47:39 -03:00
filemon 27bce0d334 feat: add subtle eye movement animation for Blobbi baby and adult visuals
Add eye animation utility that:
- Detects pupil and highlight elements via gradient patterns and dark fills
- Wraps pupil+highlight elements in animated <g> groups
- Applies CSS keyframe animation for gentle wandering eye movement
- Uses different delays for left/right eyes for natural feel
- Only animates when awake (skips sleeping state)

Integrates animation into both BlobbiBabyVisual and BlobbiAdultVisual
components for a more lifelike appearance.
2026-03-16 21:23:24 -03:00
filemon ddf50724f0 fix: adult form resolution now uses adult_type tag as primary source
- Add adultType field to BlobbiCompanion interface
- Parse adult_type tag in parseBlobbiEvent from kind 31124
- Pass adult.evolutionForm in toBlobbiForVisual adapter
- Seed-derived form is now only used as fallback when no adult_type tag exists
2026-03-16 20:07:36 -03:00
filemon d07bd75d07 feat: implement adult Blobbi visual system with 16 evolution forms
- Add adult-blobbi module with types, SVG resolver, and customizer
- Support 16 adult forms: bloomi, breezy, cacti, catti, cloudi, crysti,
  droppi, flammi, froggi, leafy, mushie, owli, pandi, rocky, rosey, starri
- Each form has base and sleeping SVG variants
- Adult form resolved from blobbi.adult.evolutionForm or derived from seed
- Color customization applies to body and pupil gradients via pattern matching
- BlobbiAdultVisual component with reaction animations support
- Replace adult placeholder in BlobbiStageVisual with real visuals
2026-03-16 19:48:43 -03:00
filemon d59ba03cc6 fix: sing reaction only starts when recording begins, adjust animation timing
- Add onRecordingStart/onRecordingStop callbacks to InlineSingCard
- Move singing reaction trigger from card open to actual recording start
- Reduce sing bounce animation movement (6px → 3px for baby, 4px → 2px for egg)
- Slow down sing bounce animation (0.4s → 0.5s for baby, 0.5s → 0.6s for egg)
- Change Record button label to Sing
2026-03-16 19:29:50 -03:00
filemon 04112110f7 polish: faster music animation timing and fix egg centering with music notes 2026-03-16 19:22:40 -03:00
filemon d835cb5e6a feat: add floating music notes and tune animation timing for Blobbi reactions 2026-03-16 19:10:20 -03:00
filemon 6e5a6b5d91 feat: reusable music/dance reaction system for all Blobbi stages
BREAKING: Egg no longer sways by default when animated=true.
Sway animation now requires explicit reaction state.

Changes:
- Remove default egg sway from animated prop in EggGraphic
- Add reaction prop to EggGraphic, BlobbiEggVisual, BlobbiBabyVisual, BlobbiStageVisual
- Add EggReactionState, BabyReactionState, BlobbiReaction types
- Wire blobbiReaction state from BlobbiPage to BlobbiStageVisual
- Add animate-blobbi-sway and animate-blobbi-bounce CSS animations
- Add animate-egg-bounce for singing reaction on eggs

Reaction states:
- idle: no animation (default)
- listening: gentle sway (music playing)
- swaying: gentle sway
- singing: bouncy animation (sing action active)
- happy: gentle sway

Trigger behavior:
- Play Music: listening reaction when audio plays
- Sing: singing reaction when card opens
- Close activity: returns to idle
2026-03-16 18:48:57 -03:00
filemon 270cb51acc fix: volume control popup now renders via portal to avoid clipping
The volume slider was being clipped by the card's overflow:hidden.
Now using Popover component (Radix UI) which renders via portal,
ensuring the volume control appears above all UI elements correctly.
2026-03-16 18:31:02 -03:00
filemon 579c78b2ad feat: add volume control to InlineMusicPlayer
- Add volume state and setVolume function to useAudioPlayback hook
- Default volume is 0.8, persists across track changes
- Volume button shows Volume2 icon (or VolumeX when muted)
- Clicking volume button shows horizontal slider popup
- Slider controls real audio volume (0-1 range)
- Click outside to dismiss volume slider
2026-03-16 18:23:39 -03:00
filemon 470cdd1c76 refactor: change stop button to restart button in InlineMusicPlayer
- Add restart() function to useAudioPlayback hook (sets currentTime=0 and plays)
- Replace Square icon with RotateCcw for restart semantics
- Restart button resets track to beginning and continues playing
- Stop function still exists for cleanup on close
2026-03-16 18:04:31 -03:00
filemon 2abbae38d9 fix: stop button now truly stops playback instead of auto-restarting
The auto-start effect was incorrectly triggering on 'stopped' state,
causing immediate restart. Now 'stopped' is a terminal state that
requires explicit play button click to restart.
2026-03-16 18:00:43 -03:00
filemon 46dc8d9e12 fix: inline music player stop/track-switch behavior and UI cleanup
- Fix stop button: add 'stopped' state so stop truly stops playback instead of pausing at 0
- Fix track switching: detect source.url changes and reload, distinguish change vs initial selection
- Disable Upload tab in PlayMusicModal (marked 'Soon' but was still clickable)
- Remove misplaced chevron arrows from lyrics toggle button in InlineSingCard
2026-03-16 17:56:28 -03:00
filemon 59cbb9d740 fix: inline activity layout and move audio assets to public/
- Move inline activity cards inside padded container to prevent overlap with fixed bottom bar
- Move audio files from src/blobbi/audio/ to public/blobbi/audio/ for correct Vite asset loading
- Update track metadata with accurate durations from ffprobe
- Update documentation comments to reflect correct asset location
- Remove unused MicOff import from InlineSingCard
2026-03-16 17:38:21 -03:00
filemon c50c9bec7e refactor: inline activity cards for Play Music and Sing
- Add InlineMusicPlayer component for persistent music playback UI
- Add InlineSingCard component for inline recording/lyrics experience
- Add useAudioPlayback hook for reusable audio playback logic
- Add blobbi-activity-state types for activity and reaction state management

Play Music flow:
- PlayMusicModal now serves as track picker only
- After track selection, inline player appears with play/pause/stop controls
- Action published first, playback starts only after success

Sing flow:
- Recording happens inline (no modal)
- Lyrics panel expands upward with random lyrics
- Action published only when user confirms with 'Sing for Blobbi'

Blobbi reaction state prepared for future visual animations
2026-03-16 16:21:00 -03:00
filemon 14ebcb1165 Merge branch 'main' into feat-blobbi 2026-03-16 15:53:56 -03:00
filemon 3728ad02e6 fix: CSP and audio recording/playback for SingModal
- Add blob: to media-src CSP directive to allow recorded audio playback
- Add robust MIME type selection helper for MediaRecorder
- Try MIME types in order: webm;opus, webm, mp4, ogg;opus, ogg
- Track actual recorder MIME type and use it for blob creation
- Add user-friendly playback error messages (non-fatal amber warnings)
- Verify PlayMusicModal blob URL handling works correctly
- Add 'Soon' badge to Upload tab in PlayMusicModal
2026-03-16 15:50:22 -03:00
filemon ba9e1f5375 fix: cleanup pass for Blobbi play_music and sing actions
- Relax egg-stage blocking in canUseAction() (UI visibility vs domain logic)
- Fix egg inventory filtering to only show items with egg-compatible effects
- Remove 'shell' wording from medicine UI text
- Fix canonical data usage in mutations (use canonical.companion for decay)
- Fix browser timer typing (NodeJS.Timeout -> ReturnType<typeof setInterval>)
- Fix PlayMusicModal audio source switching (recreate Audio on source change)
- Fix SingModal recording playback (track current playback URL)
- Add random lyrics helper for Sing action with collapsible UI
2026-03-16 15:32:09 -03:00
filemon 598c5f90ea Add play_music and sing actions, fix egg clean/medicine consistency
- Fix egg stage actions: clean and medicine now work for eggs
- Add play_music action with built-in tracks and file upload
- Add sing action with in-browser audio recording
- Hide feed/play/sleep actions for eggs in UI (not hard-blocked)
- Both new actions increase happiness only (+15/+20)
- Placeholder built-in tracks in blobbi-builtin-tracks.ts
2026-03-16 15:08:30 -03:00
filemon 6e1a195615 Deprecate egg_temperature - eggs now use warmth prop fallback 2026-03-16 11:11:34 -03:00
filemon 452848f14f Remove shell_integrity - eggs now use standard health stat
BREAKING: shell_integrity is fully removed from the egg model.
Eggs now use the standard 3-stat model: health, hygiene, happiness.

Changes:
- Remove EggStats, EggMedicineResult types from blobbi-action-utils.ts
- Remove applyMedicineToEgg function (medicine now uses applyStat directly)
- Update useBlobbiUseInventoryItem to apply medicine health effect to egg health
- Update BlobbiActionInventoryModal to preview health changes for egg medicine
- Remove shell_integrity from ItemEffect in shop types
- Remove shellIntegrity from BlobbiEggData in types/blobbi.ts
- Remove EggStats, EggMedicineResult, applyMedicineToEgg from exports
- Add DEPRECATED_BLOBBI_TAG_NAMES set with 'shell_integrity'
- Update mergeBlobbiStateTagsForRepublish to filter out deprecated tags

Migration: Existing events with shell_integrity tags will have them
automatically removed on the next republish (any user interaction).

Egg stat model is now fully consistent:
- health, hygiene, happiness: active (decay + medicine)
- hunger, energy: fixed at 100
2026-03-16 00:20:33 -03:00
filemon 4ccc123209 Apply decay before stage transitions (hatch/evolve)
- Create useBlobbiHatch hook for egg -> baby transition
- Create useBlobbiEvolve hook for baby -> adult transition
- Both hooks apply accumulated decay before publishing new state
- Wire up floating action button to trigger hatch/evolve based on stage
- Hide evolve button for adults (already fully evolved)
- Show loading state during transitions
- Export new hooks and types from blobbi/actions module

Stage transitions now consistently apply decay first, ensuring
no transition can happen from stale stats.
2026-03-15 21:10:44 -03:00
filemon a987449789 Apply decay before sleep/wake mutations in handleRest
- Import applyBlobbiDecay in BlobbiPage
- Calculate accumulated decay before state change
- Persist decayed stats along with new sleep/wake state
- Reset last_decay_at timestamp after applying decay

This ensures stats accurately reflect elapsed time when toggling
between active and sleeping states.
2026-03-15 21:04:36 -03:00
filemon ba9ff0964b feat(blobbi): implement decay system with projected UI state
Core decay system (src/lib/blobbi-decay.ts):
- Pure applyBlobbiDecay() function for deterministic decay calculation
- Stage-specific decay rates: egg (2-3hr), baby (3-5hr), adult (5-7hr)
- Health modifiers based on other stats
- Health regeneration when all stats >= 80
- Floor all deltas, clamp stats to 0-100
- Warning/critical threshold helpers

UI projection hook (src/hooks/useProjectedBlobbiState.ts):
- Calculates projected stats without publishing
- Recalculates every 60 seconds
- Returns visible stats with status indicators

BlobbiPage updates:
- Uses projected state for display
- Egg shows 3 stats (health, hygiene, happiness)
- Baby/adult shows all 5 stats
- StatIndicator supports warning/critical status styling

Mutation updates (useBlobbiUseInventoryItem):
- Applies accumulated decay before interactions
- Uses decayed stats as base for item effects
- Updates last_decay_at on every interaction

Documentation (docs/blobbi/decay-system.md):
- Comprehensive explanation of the system
- All decay rates and thresholds
- Mutation flow diagram
- Edge cases and assumptions
2026-03-15 21:01:34 -03:00
filemon 3cd64fb0af refactor(blobbi): make evolve button icon stage-aware
- egg stage: Shows Egg icon with 'Hatch' tooltip
- baby/adult stages: Shows Sparkles icon with 'Evolve' tooltip

Implementation:
- Added 'stage' prop to BlobbiDashboardFloatingControlsProps
- Created getEvolveIcon() helper - returns Egg or Sparkles based on stage
- Created getEvolveTooltip() helper - returns 'Hatch' or 'Evolve'
- Removed unused Zap import

Icon choice rationale:
- Sparkles was chosen for non-egg stages because it communicates magical
  transformation, which fits the Blobbi fantasy/pet theme better than
  technical icons like TrendingUp or ArrowUpCircle
2026-03-15 19:48:43 -03:00
filemon 6e8e6fe243 feat(blobbi): add floating action buttons to dashboard (placeholders)
New components:
- FloatingActionDef: Typed interface for button definitions
- BlobbiDashboardFloatingControls: Component that renders left and right
  floating button clusters

Buttons added (all visual-only placeholders):
- Right side (top cluster):
  - Settings (Settings icon)
  - Set as Companion (Heart icon)
  - Take a Photo (Camera icon)
  - Open PiP (PictureInPicture2 icon)
  - Blobbi Info (Info icon) - wired to existing info modal
  - Evolve (Zap icon) - styled with accent/primary colors
- Left side:
  - Back button (ArrowLeft icon) - optional, not rendered by default

Implementation:
- Uses existing QuickActionButton for consistent styling
- Evolve button has distinct accent styling (primary colors)
- Button definitions centralized in typed arrays
- Placeholder handlers use console.log('TODO: ...')
- Existing info modal functionality preserved
2026-03-15 19:37:35 -03:00
filemon 476f1bade2 feat(blobbi): apply base color to dashboard and info modal names
- BlobbiDashboard: The h2 element displaying companion.name now uses
  style={{ color: companion.visualTraits.baseColor }}
- BlobbiInfoModal: The DialogTitle displaying companion.name now uses
  style={{ color: companion.visualTraits.baseColor }}
- Both update correctly when selecting a different Blobbi
- Selector card names left unchanged (not prominent display)
2026-03-15 19:29:39 -03:00
filemon cf1d9ad53f fix(blobbi): add separate colored name display above egg
- Removed baseColor styling from Input (was not working reliably)
- Added a separate <p> element between the input and egg visual
- The <p> displays trimmedName with style={{ color: preview.visualTraits.baseColor }}
- This element updates live as the user types (uses trimmedName from preview.name)
- Input reverted to normal theme styling (text-center font-medium)
- No duplicate name under the egg (title still not passed to EggGraphic)
2026-03-15 19:22:10 -03:00
filemon 680ff86202 fix(blobbi): apply egg base color to name input field
- Removed the extra styled name display div (was creating a second name)
- Applied base color directly to the Input element via style prop
- Changed Input className to 'text-center font-semibold text-lg' for better visibility
- Now only ONE visible name exists: the input field itself, styled with the egg's baseColor
2026-03-15 19:19:06 -03:00
filemon 9797fcd95a fix(blobbi): canonicalize patterns and fix duplicate name display
Pattern canonicalization:
- Changed VALID_PATTERNS from ['gradient', 'solid', 'speckled', 'striped']
  to ['solid', 'spotted', 'striped', 'gradient'] to match domain model
- 'spotted' is the canonical value (used by BlobbiPattern type, BLOBBI_PATTERNS,
  derivePatternFromSeed, normalizePatternTag, and PATTERN_MAP)

Duplicate name fix:
- Removed title from toEggGraphicVisualBlobbi() adapter - the EggGraphic
  'title' field is for special designations (e.g., 'Divine'), not pet names
- The duplicate was: input field above egg + title display below egg
- Now only the input field exists, plus a new styled name display

Name styling:
- Added styled name display above the egg using the egg's baseColor
- Styling matches the former bottom title: bg-black/20, backdrop-blur-sm,
  font-semibold, text-shadow, and color from preview.visualTraits.baseColor
2026-03-15 19:14:52 -03:00
filemon 5d3841d6a7 fix(blobbi): cleanup egg validation inconsistencies
- Fix getColorRarity() to properly merge both base color palettes by
  creating MERGED_BASE_COLORS_BY_RARITY that combines colors per rarity
  tier (previous spread syntax overwrote keys instead of merging arrays)
- Update validation error messages to match actual validation logic:
  colors now accept any valid hex format, not just specification palettes
- Add Rarity type for better type safety in rarity functions
- Add JSDoc clarifying that getColorRarity returns null for domain model
  colors (BLOBBI_BASE_COLORS) which are not in the legacy palettes
2026-03-15 18:39:10 -03:00
filemon 9e5de53ad4 fix(blobbi): make egg colors update on reroll
Root cause: EggGraphic validation rejected derived colors because
isValidBaseColor() used a hardcoded allowlist that didn't include
the BLOBBI_BASE_COLORS palette (e.g., #F59E0B, #55C4A2, etc.).

Changes:
- Update isValidBaseColor/isValidSecondaryColor to accept any valid
  hex color format (palette enforcement at domain level)
- Add visual trait tags (base_color, secondary_color, eye_color,
  pattern, special_mark, size) to previewToEventTags for
  deterministic rendering
- Improve useMemo dependencies in BlobbiEggVisual to ensure
  re-render on preview change
2026-03-15 13:59:36 -03:00
filemon 13cdbc565c feat(blobbi): add 'Adopt another Blobbi' entry point from selector
Adds a dedicated CTA card in the Blobbi selector modal and page to allow
existing users to adopt additional Blobbies without going through full
onboarding.

Changes:
- Added AdoptAnotherBlobbiCard component with plus icon, tooltip, and
  distinct visual styling (dashed border, centered layout)
- Updated BlobbiOnboardingFlow to support adoptionOnly prop that skips
  profile creation and adoption question, going directly to egg preview
- Updated useBlobbiOnboarding hook with adoptionOnly mode support that:
  - Derives initial step as 'preview' when adoptionOnly is true
  - Generates preview immediately on mount in adoptionOnly mode
  - Skips auto-sync logic that would interfere with explicit control
- Added adoption flow modal to BlobbiDashboard with full callback wiring
- Added adoption flow modal to BlobbiSelectorPage (Cases G and H)
- Passed required adoption callbacks through BlobbiDashboard props

UX flow:
1. User clicks 'Adopt another Blobbi' card in selector
2. Selector closes, adoption flow modal opens
3. User sees egg preview directly (no profile/adoption question steps)
4. User can reroll, name, and adopt as normal
5. On completion, modal closes and new Blobbi is selected
2026-03-15 13:44:48 -03:00
filemon 5ea4b0f73d fix(blobbi): add pettingLevel, fix reroll visual, improve onboarding stability
1. pettingLevel support:
   - Added pettingLevel to BlobbonautProfile type and parsing
   - New profiles include pettingLevel: 0 by default
   - Created useBlobbonautProfileNormalization hook to auto-add
     pettingLevel to existing profiles that are missing it

2. Reroll visual fix:
   - Added key prop to BlobbiStageVisual to force remount on preview change
   - Added debug logging to track preview identity changes (d/seed/petId)
   - Reroll preserves typed name while generating new identity

3. Onboarding stability improvements:
   - Enhanced step sync logic in useBlobbiOnboarding to handle all edge cases
   - Added defensive checks for profile state changes
   - Better debug logging for state transitions

4. Verified invariants:
   - Preview remains single source of truth for adopted event
   - Name is editable, required for adoption, preserved on reroll
   - No any types introduced
2026-03-15 13:16:26 -03:00
filemon f3e262bd3a fix(blobbi): respect user profile state in onboarding flow
- Fixed useBlobbiOnboarding to derive initial step from profile state
- Added useEffect to sync step when profile loads from cache/relay
- Added egg name customization via updatePreviewName() function
- Removed 'Maybe Later' skip option from adoption step
- Refactored BlobbiPage with cleaner state logic and debug logging
- Fixed TypeScript errors (unused vars, empty interface)

Resolves issue where onboarding always started on 'profile' step
even when user already had a profile, and adds name input to
egg preview before adoption.
2026-03-15 12:48:58 -03:00
filemon 96b8288c5b feat(blobbi): implement new onboarding flow with egg preview
Add complete Blobbi onboarding flow:
- Profile creation step with name prefill from kind 0 metadata
- Adoption question step after profile creation
- Egg preview with reroll (10 coins) and adopt (100 coins) options
- Confirmation dialog before adoption
- New profiles start with 200 coins

Key components:
- BlobbiProfileOnboarding: Profile creation with name input
- BlobbiAdoptionStep: 'Ready to adopt?' prompt
- BlobbiEggPreviewCard: Egg preview with visual traits and actions
- BlobbiAdoptionConfirmDialog: Adoption cost confirmation
- useBlobbiOnboarding: State and action orchestration hook

Preview is the source of truth for adoption - same exact data is
used to create the final kind 31124 event. Coins are deducted
from profile before publishing events.
2026-03-14 16:44:14 -03:00
filemon a85590f5fb Merge branch 'main' into feat-blobbi 2026-03-13 23:16:16 -03:00
filemon 5a93bdd0a6 Merge branch 'main' into feat-blobbi 2026-03-10 10:14:43 -03:00
filemon be582f4db7 fix: use migrated profile context in inventory item usage flow
Bug: When using Feed/Play/Clean on a legacy Blobbi, the migration
would correctly publish a canonical profile, but then the inventory
usage flow would republish the profile using stale pre-migration tags
from the hook closure, restoring legacy has/current_companion values.

Fix:
- Extend EnsureCanonicalResult to include profileAllTags and profileStorage
- Extend MigrationResult to include profileTags and profileStorage
- Update ensureCanonicalBlobbiBeforeAction to return profile context
- Update useBlobbiUseInventoryItem to use canonical.profileStorage and
  canonical.profileAllTags instead of profile.storage/profile.allTags

This ensures the post-item-use 31125 event is built from the migrated
profile state, preserving:
- canonical has[] values
- canonical current_companion
- storage changes (item decrement)
- all unknown tags
2026-03-09 17:39:53 -03:00
filemon 01a174f9e3 feat: add Medicine action with egg-specific shell_integrity support
- Add 'medicine' to InventoryAction type and related mappings
- Medicine is available for all stages: egg, baby, adult
- For eggs: health effect is converted to shell_integrity
- For eggs: other effects (energy, happiness, etc.) are ignored
- For baby/adult: all effects are applied normally

Egg-specific behavior:
- previewMedicineForEgg() shows shell_integrity changes
- applyMedicineToEgg() converts health → shell_integrity
- hasMedicineEffectForEgg() validates egg-applicable effects

Stage restriction changes:
- canUseAction(companion, action) replaces canUseInventoryItems()
- EGG_ALLOWED_ACTIONS defines which actions eggs can use
- getStageRestrictionMessage() now action-aware

UI updates:
- Medicine button added to BlobbiActionsModal (Pill icon)
- Inventory modal shows shell_integrity preview for eggs
- Contextual description: 'Strengthen your egg's shell' for eggs
2026-03-09 17:25:51 -03:00
filemon ecc306079b feat: implement inventory item usage with Feed, Play, Clean actions
- Add blobbi-action-utils.ts with stat clamping, item effects, and inventory filtering
- Create useBlobbiUseInventoryItem hook for consuming inventory items
- Add BlobbiActionInventoryModal for selecting items to use per action type
- Update BlobbiActionsModal with Feed, Play, Clean, Sleep/Wake buttons
- Integrate action modals with BlobbiPage
- Remove duplicate StorageItem from shop.types.ts (kept in lib/blobbi.ts)
- Stage restrictions: eggs cannot use items, only baby/adult can

The inventory usage flow:
1. User opens Actions modal from bottom bar
2. Selects Feed/Play/Clean to open inventory modal
3. Modal shows filtered items by action type with effect preview
4. On item use: updates Blobbi stats (31124) and decrements storage (31125)
2026-03-09 17:12:49 -03:00
filemon 251ea43e33 refactor: reorganize Blobbi shop into domain-scoped structure with list layout
- Move shop module to src/blobbi/shop/ for clear domain boundaries
- Rename components with Blobbi prefix for clarity:
  - ShopModal → BlobbiShopModal
  - InventoryModal → BlobbiInventoryModal
  - PurchaseDialog → BlobbiPurchaseDialog
  - usePurchaseItem → useBlobbiPurchaseItem
  - shop-items.ts → blobbi-shop-items.ts
  - shop.ts → shop.types.ts

- Convert shop UI from card grid to vertical list layout:
  - Replace ShopItemCard with BlobbiShopItemRow
  - Horizontal row layout: icon, name, category, effects, price, button
  - More compact and scannable design
  - Better mobile responsiveness
  - Easier to compare items at a glance

- Add blobbi-shop-utils.ts with helper functions:
  - formatEffectSummary() for compact effect display
  - getEffectCounts() for stat summaries
  - getPrimaryEffect() for tooltips/badges

- Folder structure:
  src/blobbi/shop/
    components/     (UI components)
    hooks/          (purchase hook)
    lib/            (catalog + utils)
    types/          (TypeScript types)

- Update all imports in BlobbiPage to use new paths
- Remove old generic shop paths (src/components/shop, etc.)
- Preserve all existing behavior and purchase flow
- No Lightning/sats/kind 40100/40101 (as specified)
- TypeScript strict, no any types used
2026-03-09 15:48:31 -03:00
filemon 37b8fc6752 feat: implement Blobbi Shop and Inventory system
- Add shop item catalog with 24 items (food, toys, medicine, hygiene, accessories)
- Create shop types: ShopItem, StorageItem, ItemEffect, PurchaseRequest
- Extend BlobbonautProfile with coins and storage fields
- Add storage tag parsing/serialization helpers for kind 31125
- Implement usePurchaseItem hook for atomic coin + storage updates
- Create ShopModal with category tabs and item grid
- Create PurchaseDialog with quantity selector and affordability checks
- Create InventoryModal to display purchased items
- Integrate shop and inventory into BlobbiPage footer
- Purchase flow: validates price, checks coins, stacks items, publishes single event
- Accessories marked as 'Coming Soon' (disabled state)
- No Lightning/LNURL/sats purchase flow (as specified)
- Only uses kind 31125 for profile persistence (no kind 40100/40101)
2026-03-09 15:32:03 -03:00
filemon 17b986c21d fix: use baseColor as secondaryColor fallback for legacy events without seed
When a legacy Blobbi has only base_color (no secondary_color, no seed),
the secondary color now falls back to the resolved baseColor instead of
the generic yellow default (#FCD34D).

This creates a unified palette for legacy events with partial traits,
avoiding the incorrect mixed palette (e.g., cyan base + yellow accent).
2026-03-09 13:04:51 -03:00
filemon d6b3dbc9f9 fix: respect explicit visual traits in legacy Blobbi events
The deriveVisualTraits function was returning default values immediately
when no seed was present, ignoring explicit tags in the event.

Fixed priority order (per field):
1. Explicit valid tags (always take precedence)
2. Seed-derived values (for canonical events)
3. Default fallbacks (when both are missing)

This ensures legacy Blobbi with explicit color/pattern tags render
correctly instead of falling back to default yellow/orange palette.
2026-03-09 12:51:56 -03:00
filemon 1f41478d53 wire baby rendering into the app with stage-aware visual component
- Add BlobbiBabyVisual component using baby-blobbi module for SVG resolution and customization
- Add BlobbiStageVisual component that routes rendering by life stage (egg/baby/adult)
- Update BlobbiPage to use BlobbiStageVisual for all Blobbi displays
- Uncomment BlobbiBabyData interface in types/blobbi.ts to fix type error
2026-03-09 12:27:16 -03:00
filemon bd71520cb5 add baby-blobbi file 2026-03-09 12:22:44 -03:00
filemon 6276d135e4 Merge branch 'main' into feat-blobbi 2026-03-08 10:33:58 -03:00
filemon 3abdbc2d88 cleanup: remove visibility feature, info card, and polish bottom bar spacing
Removed features:
- handleToggleVisibility handler and all related props
- Visibility quick action button from top-right floating actions
- Visibility action from BlobbiActionsModal
- Info card section (Generation, Experience, Care Streak, Last Active)

Removed unused code:
- InfoItem component
- formatTimeAgo helper function
- Card/CardContent imports

Bottom bar spacing adjustments:
- Container padding: px-2 → px-3
- Grid gap: gap-1 → gap-2
- Group internal gap: gap-0.5 → gap-1
- Center button margin: mx-1 → mx-2
- Button padding: px-2.5 → px-3
- Button min-width: 52px → 56px

Result: Cleaner dashboard with more breathing room in the bottom bar
2026-03-06 23:46:18 -03:00
filemon 241f234a82 polish: refine bottom bar layout and change center icon to Sparkles
Layout changes:
- Switch from flex justify-between to 3-column grid layout
- Left group now uses justify-end (closer to center)
- Right group now uses justify-start (closer to center)
- Reduce group gap from gap-1 to gap-0.5
- Reduce container padding from px-3 to px-2

Center button adjustments:
- Reduce vertical offset from -mt-6 to -mt-4 (more integrated)
- Reduce size from size-14 to size-12
- Add mx-1 for controlled horizontal spacing
- Replace Zap icon with Sparkles (better fits Blobbi identity)
- Reduce icon size from size-6 to size-5

Side button adjustments:
- Reduce horizontal padding from px-3 to px-2.5
- Reduce vertical padding from py-2 to py-1.5
- Reduce min-width from 60px to 52px

Result: More compact, balanced bottom bar with groups visually
closer to the center action button
2026-03-06 23:39:44 -03:00
filemon 28ee5b6881 feat: add bottom action bar and modal system to BlobbiPage
UI adjustments:
- Move top-right floating buttons lower for visual balance
- Remove switch button from top-right (now in bottom bar)
- Add fixed bottom action bar with left/center/right layout:
  - Left: Blobbies (opens selector), Missions (placeholder)
  - Center: Actions button (opens actions modal)
  - Right: Shop (placeholder), Inventory (placeholder)

New components:
- BlobbiBottomBar: Fixed bottom navigation bar
- BlobbiActionsModal: Rest/Wake and Hide/Show actions
- BlobbiPlaceholderModal: Reusable placeholder for future features
- BlobbiInfoModal: Detailed Blobbi information display
- BottomBarButton: Reusable button for bottom bar

Behavior:
- Blobbies button opens existing selector modal
- Actions modal contains functional Rest and Visibility toggles
- Info button (top-right) opens detailed info modal
- Placeholder modals ready for Missions, Shop, Inventory features
2026-03-06 23:34:21 -03:00
filemon d92790f0af refactor: replace hardcoded colors with app theme tokens in BlobbiPage
- Remove purple/pink gradients and hardcoded colors
- Use theme tokens: primary, muted, accent, border, card, background
- Keep semantic stat colors (orange/yellow/green/blue/violet) for meaning
- Page chrome now adapts to light/dark theme automatically
2026-03-06 23:22:46 -03:00
filemon e18d0592d6 refactor: redesign BlobbiPage with dashboard-style layout
Visual changes:
- Add frosted glass dashboard container with purple top border
- Add decorative purple/pink gradient overlay
- Center hero layout with prominent Blobbi visual
- Add floating quick action buttons (switch, rest, visibility)
- Replace stat bars with circular stat indicators
- Improve visual hierarchy with hero section focus
- Add glow effect behind main Blobbi visual
- Use purple/pink color theme consistently

Extracted components:
- DashboardShell: Frosted glass container wrapper
- BlobbiDashboard: Main dashboard view with hero section
- QuickActionButton: Floating circular action buttons
- StatIndicator: Circular progress stat display
- DashboardLoadingState: Skeleton loading for dashboard

Preserved unchanged:
- All data hooks (useBlobbonautProfile, useBlobbisCollection, etc.)
- Selection flow with localStorage priority
- Migration flow with ensureCanonicalBlobbiBeforeAction
- Create egg / rest / visibility handlers
- EggGraphic adapter integration
- Selector page and card components (restyled)
2026-03-06 23:16:35 -03:00
filemon 11802fc38a refactor: tighten all adapter types to exact Egg module unions
- Add EggPattern, EggSpecialMark, EggThemeVariant type aliases
- All derived via NonNullable<EggVisualBlobbi[field]>
- Update PATTERN_MAP to Record<BlobbiPattern, EggPattern>
- Update SPECIAL_MARK_MAP to Record<BlobbiSpecialMark, EggSpecialMark>
- Update all fallback constants with exact Egg types
- Update themeVariant parameter to EggThemeVariant
- No runtime changes, type-only refinement
2026-03-06 23:08:04 -03:00
filemon b2ae35d597 refactor: tighten adapter typing for EggVisualBlobbi
- Derive EggLifeStage type from EggVisualBlobbi using NonNullable
- Type LIFE_STAGE_MAP with exact EggLifeStage return type
- Add DEFAULT_THEME_VARIANT constant for consistency
- Remove unnecessary 'as const' assertions on mapping objects
- Improve JSDoc for toEggGraphicVisualBlobbi return type
- Rename areEggGraphicVisualsEqual parameter types to EggVisualBlobbi
2026-03-06 23:04:25 -03:00
filemon ec37a8befe refactor: cleanup Blobbi egg integration
- Remove fake try/catch render wrapper (not a real error boundary)
- Use real EggVisualBlobbi type from @/blobbi/egg module
- Pass full allTags to EggGraphic instead of filtering
- Adjust size mapping: sm (size-14/small), md (size-24/medium), lg (size-40/large)
- Gate debug logs behind import.meta.env.DEV check
- Simplify adapter by removing duplicated type definitions
- Reduce adapter from 290 lines to ~150 lines
- Keep architecture intact: domain → adapter → BlobbiEggVisual → EggGraphic
2026-03-06 21:24:58 -03:00
filemon 804dd550a2 feat(blobbi): add egg visual module 2026-03-06 21:10:29 -03:00
filemon 736d76f457 feat: integrate EggGraphic visual module into Blobbi UI
- Create BlobbiEggVisual reusable component in src/blobbi/ui/
- Replace placeholder Egg icons with real EggGraphic rendering
- Update adapter tags format to string[][] for EggGraphic compatibility
- Main display: large animated egg visual with seed-derived colors
- Selector cards: small egg visual showing unique traits per Blobbi
- Switch dialog: uses same selector cards with real visuals
- Add memoization for adapter output to avoid re-renders
- Include fallback safety with simple placeholder on render errors
- Preserve all existing fetch/migration/selection behavior
2026-03-06 21:06:01 -03:00
filemon 84734d7304 feat: add EggGraphic adapter and improve visual trait derivation
- Create blobbi-egg-adapter.ts with toEggGraphicVisualBlobbi() function
- Add explicit mapping tables for pattern, specialMark, size, lifeStage
- Add comprehensive VISUAL TRAIT POLICY documentation
- Export LEGACY_VISUAL_TAG_NAMES constant for migration preservation
- Improve deriveNameFromLegacyD() to handle multi-word names (mr-cool → Mr Cool)
- Update buildMigrationTags() to explicitly preserve legacy visual tags
- Add capitalizeWords() helper for human-friendly name formatting
- Add areEggGraphicVisualsEqual() utility for memoization support
2026-03-06 20:09:46 -03:00
filemon e19b99a2bc refactor: align visual traits with EggGraphic contract
Visual Traits Interface:
- baseColor: hex value (e.g., '#F59E0B')
- secondaryColor: hex value for accent
- eyeColor: hex value for eyes
- pattern: 'solid' | 'spotted' | 'striped' | 'gradient'
- specialMark: 'none' | 'star' | 'heart' | 'sparkle' | 'blush'
- size: 'small' | 'medium' | 'large'

Color Palettes (10 colors each):
- BLOBBI_BASE_COLORS: Amber, Teal, Sky Blue, Pink, Purple, Coral, Emerald, Yellow, Indigo, Orange
- BLOBBI_SECONDARY_COLORS: Light variants for accents
- BLOBBI_EYE_COLORS: 8 expressive options

Seed Derivation:
- deriveIndexFromSeed with documented offset layout
- Each trait derived from different seed segment
- Validation/normalization for legacy tags
- Falls back to DEFAULT_VISUAL_TRAITS when seed missing

Debug Logs:
- Concise format: d, name, isLegacy, hasSeed, traits summary
- Removed verbose visualTraits object dump

parseBlobbiEvent remains single source of truth for:
- name resolution (tag > legacy d > fallback)
- seed
- visualTraits
- isLegacy flag

Migration flow unchanged - ready for EggGraphic integration.
2026-03-06 14:45:58 -03:00
filemon 577334cbaa feat: add visual trait derivation from seed and centralized migration
PART 1 - Visual Traits from Seed:
- Add BlobbiVisualTraits interface with baseColor, pattern, specialMark, size
- Add deriveNumberFromSeed helper for deterministic derivation
- Add deriveBaseColorFromSeed, derivePatternFromSeed, deriveSpecialMarkFromSeed, deriveSizeFromSeed
- Add deriveVisualTraits to combine tag values with seed-derived fallbacks
- Visual trait priority: explicit tags > derived from seed > default values

PART 2 - Legacy Event Detection:
- Add isLegacyBlobbiEvent helper function
- Legacy criteria: non-canonical d-tag, missing seed, missing name tag, visual traits without seed
- Add companionNeedsMigration convenience wrapper
- Add isLegacy and visualTraits fields to BlobbiCompanion interface

PART 3-7 - Centralized Migration:
- Create useBlobbiMigration hook with ensureCanonicalBlobbiBeforeAction
- Migration auto-updates: localStorage selection, React Query caches, profile references
- Refactor BlobbiPage action handlers to use centralized helper
- Migration continues original action after upgrading legacy pet

No changes to event kinds, collection fetching, or selection system.
2026-03-06 14:27:18 -03:00
filemon 35b615dc9f fix: support legacy Blobbi names derived from d-tag
- Add deriveNameFromLegacyD() helper to extract name from legacy d-tags
- Update parseBlobbiEvent to use name resolution priority:
  1. Use 'name' tag if present
  2. Derive from legacy d-tag format (blobbi-{name} → Name)
  3. Fall back to 'Unnamed Blobbi'
- Add debug logs in parser showing d, name, nameTag, stage, state
- Add '[Blobbi UI]' debug log when selected companion changes
- Legacy pets like 'blobbi-puck' now display as 'Puck'
2026-03-06 14:13:40 -03:00
filemon ef8e9a3ccf fix: load ALL Blobbis and implement proper UI selection flow
- Update useBlobbisCollection to fetch ALL pets without limit:1
- Add chunking support (20 items per chunk) for relay compatibility
- Add debug logs for dList and 31124 query filter
- Implement localStorage-based UI selection (user-scoped key)
- Selection priority: localStorage > first in profile.has > show selector
- Add BlobbiSelectorPage for when no valid selection exists
- Add BlobbiSelectorCard component for pet selection UI
- Add 'Switch Blobbi' button in header for users with multiple pets
- Separate concerns: currentCompanion (global) vs selectedBlobbi (page UI)
2026-03-06 13:45:51 -03:00
filemon 27d5544f8b Fetch all Blobbi pets (Kind 31124) by multiple d-tags
- Add useBlobbisCollection hook to query all d-tags from profile.has[] and currentCompanion
- Update BlobbiContent to use collection hook instead of single companion hook
- Keep only newest event per d-tag for deduplication
- Add debug logging to verify multi-d-tag REQs in DevTools
- UI still renders only the selected companion (currentCompanion or first in has[])
2026-03-06 13:25:49 -03:00
filemon e98111bf00 fix: useBlobbonautProfile type alignment and add effectiveCompanionD
- Fix BlobbiBootCache type usage (companion singular, not companions plural)
- Add effectiveCompanionD to hook return value for BlobbiPage
- Add debug logging to track kind 31125 query execution
- Add refetchOnMount: 'always' with initialDataUpdatedAt for proper cache behavior
2026-03-05 21:04:38 -03:00
filemon c8c68f1898 Refactor Blobbi system for spec compliance and fix loading issues
- Fix canonical Blobbi ID regex to require hex-only petId per spec
- Add getOrDeriveSeed helper to prevent seed recomputation
- Separate managed tag sets for Kind 31124 and 31125
- Add tag deduplication helpers (deduplicateHasTags)
- Add legacy pet migration on user interactions
- Fix localStorage cache validation with pubkey ownership check
- Add effectiveCompanionD fallback to has[] array
- Prevent premature "Create Egg" UI while queries resolve
- Add usePersistentNostrSession hook with exponential backoff
- Configure React Query to prevent refetch loops (staleTime, refetchOnWindowFocus)
2026-03-05 17:32:32 -03:00
filemon f47ccbec51 Merge branch 'main' into feat-blobbi 2026-03-05 17:11:28 -03:00
filemon ba53bc05a4 Add Blobbi egg feature with profile and companion management
Implements the initial Blobbi ecosystem (egg stage only) per the spec:
- Kind 31125 (Blobbonaut Profile) with canonical d-tag and legacy support
- Kind 31124 (Blobbi Current State) with canonical d-tag and seed derivation
- localStorage boot cache for instant UI on page load
- Profile initialization, egg creation, rest action, visibility toggle
- Preserves unknown tags when republishing for forward compatibility
2026-03-04 20:29:21 -03:00
553 changed files with 70079 additions and 17331 deletions
+112
View File
@@ -0,0 +1,112 @@
---
name: lockdown-mode
description: Apple Lockdown Mode restrictions and their impact on web APIs inside WKWebView/Safari/WebView. Reference when debugging or building features for lockdown-enabled devices.
---
# Apple Lockdown Mode
Apple's Lockdown Mode is an opt-in security hardening profile that disables or restricts many web platform APIs inside Safari and WKWebView. Since this app ships inside a Capacitor WKWebView shell, **every restriction that applies to Safari also applies to our app**.
## Platform Availability
Lockdown Mode is available on:
- **iOS 16** or later (iPhone)
- **iPadOS 16** or later (iPad)
- **watchOS 10** or later (Apple Watch)
- **macOS Ventura** or later (Mac)
Additional protections are available starting in iOS 17, iPadOS 17, watchOS 10, and macOS Sonoma.
For full details, see [About Lockdown Mode](https://support.apple.com/en-us/105120) on Apple Support.
## Testing Baseline
This document is based on testing against **iOS 18.7 / Safari 26.4** on an iPhone with Lockdown Mode enabled (April 2026). The web API restrictions documented below apply to Safari and WKWebView across all supported platforms (iOS, iPadOS, and macOS).
## Blocked APIs
These APIs are **completely unavailable** (return `undefined`, `null`, or throw) when Lockdown Mode is active.
| API | Impact | Notes |
|-----|--------|-------|
| **IndexedDB** | Critical | `indexedDB` global is missing entirely. Any library that relies on IndexedDB for storage will fail (Dexie, idb, localForage with IndexedDB driver, etc.). |
| **Service Workers** | High | `navigator.serviceWorker` is absent. No offline caching, no background sync, no push notifications via SW. |
| **Cache API** | High | `caches` global is absent. Often used alongside Service Workers for offline strategies. |
| **WebAssembly** | High | `WebAssembly` global is `undefined`. Libraries compiled to WASM (e.g. libsodium-wrappers, secp256k1-wasm, SQLite WASM) will not load. |
| **Web Locks** | High | `navigator.locks` is absent. Cross-tab coordination patterns that depend on this will break silently. |
| **WebRTC** | High | `RTCPeerConnection` is absent. No peer-to-peer audio/video/data channels. |
| **WebGL / WebGL2** | Medium | All canvas `getContext('webgl'*)` calls return `null`. GPU-accelerated rendering, maps (Mapbox GL, deck.gl), and 3D are broken. |
| **FileReader** | Medium | `FileReader` constructor is absent. Cannot read `Blob`/`File` objects client-side (e.g. image preview before upload). Use the `File` constructor + `URL.createObjectURL()` as a workaround for previews. |
| **SharedArrayBuffer** | Medium | `SharedArrayBuffer` is `undefined`. May also require COOP/COEP headers on non-lockdown browsers, so this is often already unavailable. |
| **Speech Synthesis** | Low | `window.speechSynthesis` is absent. Text-to-speech features won't work. |
| **Notifications API** | Low | `Notification` is absent. Web push permission prompts won't appear. (Capacitor local notifications via the native plugin are unaffected.) |
| **WebCodecs** | Low | `VideoDecoder` / `VideoEncoder` are absent (`AudioDecoder` remains). Low-level media processing is unavailable. |
| **Gamepad API** | Low | `navigator.getGamepads` is absent. |
| **OPFS** | Medium | `navigator.storage.getDirectory` method does not exist. The `navigator.storage` object is present but the Origin Private File System API is stripped. SQLite-over-OPFS and any other OPFS-based storage will fail. |
| **Web Share API** | Low | `navigator.share` is absent. Use Capacitor's `@capacitor/share` plugin instead -- the native share sheet still works. |
## Available APIs
These APIs **still work** under Lockdown Mode and can be relied on.
| API | Notes |
|-----|-------|
| **File constructor** | `new File(...)` works. You can create File/Blob objects. |
| **FontFace API** | Dynamic font loading via `new FontFace()` succeeds. Remote font fetches may fail with a network error (data URIs rejected). |
| **JIT compilation** | JavaScript JIT appears active (~110ms for 1M iterations). Performance is not interpreter-level degraded. |
| **PDF viewer** | `navigator.pdfViewerEnabled` is `true`. Inline `<embed type="application/pdf">` works. |
| **Cookies** | `navigator.cookieEnabled` is `true`. |
| **Credential Management** | `navigator.credentials` is available. |
| **localStorage / sessionStorage** | Standard Web Storage APIs remain functional. |
## Implications for This App
### Storage
- **localStorage works** -- our primary client-side storage (app config, relay lists, etc.) is unaffected.
- **IndexedDB is gone** -- if any dependency silently uses IndexedDB (e.g. some Nostr caching layers, TanStack Query persisters), it will fail. Ensure all storage paths fall back to localStorage or in-memory.
- **OPFS is gone** -- `navigator.storage.getDirectory` is stripped (the method doesn't exist, though the `navigator.storage` object itself remains). SQLite-over-OPFS (e.g. wa-sqlite, sql.js with OPFS backend) and any other OPFS-based persistence will not work.
### Cryptography
- **WebAssembly is blocked** -- any WASM-based crypto libraries (secp256k1 compiled to WASM, libsodium WASM builds) will not load. Use pure-JS implementations (e.g. `@noble/secp256k1`, `@noble/hashes`) which are already what nostr-tools uses.
- **WebCrypto (`crypto.subtle`)** -- not listed as blocked in testing. The SubtleCrypto API should still be available for NIP-44 encryption via the standard Web Crypto path.
### Media & Rendering
- **WebGL is gone** -- map libraries that require WebGL (Mapbox GL JS, Google Maps WebGL renderer) will show blank canvases. Use raster tile alternatives or static map images.
- **FileReader is gone** -- image/file preview workflows that use `FileReader.readAsDataURL()` need a workaround. Use `URL.createObjectURL(file)` directly for `<img src>` previews instead.
### Communication
- **WebRTC is gone** -- any peer-to-peer features (voice/video calls, WebRTC data channels) are completely unavailable.
- **Fetch / XMLHttpRequest** -- standard network requests appear unaffected. Relay WebSocket connections should work normally.
### Native Plugin Workarounds
Several blocked web APIs have Capacitor plugin equivalents that bypass WKWebView restrictions entirely:
| Blocked Web API | Capacitor Alternative |
|---|---|
| Web Share | `@capacitor/share` (already installed) |
| Notifications | `@capacitor/local-notifications` (already installed) |
| File downloads | `@capacitor/filesystem` + share (already implemented in `downloadFile.ts`) |
### Detection
The report used a scoring heuristic (8/12 key APIs blocked = 70%) to detect Lockdown Mode. There is no official API to query Lockdown Mode status. Detection relies on probing for the absence of multiple APIs that are specifically disabled by Lockdown Mode but normally present in Safari.
## Raw Diagnostic Report
For exact error messages, navigator properties, weight scores, and per-API diagnostic output, see [ios-report.txt](ios-report.txt).
## Guidance for Feature Decisions
When building new features, consider:
1. **Always provide pure-JS fallbacks** for any crypto or data-processing library that might ship a WASM build.
2. **Never depend on IndexedDB or OPFS** as the sole storage mechanism. Both are completely stripped. Always fall back to localStorage or in-memory stores.
3. **Avoid WebGL-dependent UI** for core functionality. Use it as a progressive enhancement with a CSS/Canvas 2D fallback.
4. **Use Capacitor plugins** for sharing, notifications, and file operations rather than web APIs -- they work on all native platforms regardless of Lockdown Mode.
5. **Test on a Lockdown Mode device** when shipping features that touch storage, crypto, or media APIs.
+229
View File
@@ -0,0 +1,229 @@
============================================================
LOCKDOWN MODE DETECTOR REPORT
2026-04-06T23:40:58.170Z
============================================================
VERDICT: Lockdown Mode Likely Active
8 of 12 key APIs are blocked, consistent with iOS/macOS Lockdown Mode.
Score: 70% (8/12 key APIs blocked)
============================================================
API TEST RESULTS (detailed)
============================================================
------------------------------------------------------------
[BLOCKED] IndexedDB (weight: 3)
Client-side structured storage
Result: Can't find variable: indexedDB
Diagnostics:
uncaught: Can't find variable: indexedDB
------------------------------------------------------------
[BLOCKED] WebAssembly (weight: 2)
Binary instruction execution
Result: WebAssembly is undefined
Diagnostics:
typeof WebAssembly: undefined
WebAssembly global does not exist
------------------------------------------------------------
[BLOCKED] Web Locks API (weight: 3)
Cross-tab resource coordination
Result: navigator.locks is undefined
Diagnostics:
typeof navigator.locks: undefined
'locks' in navigator: false
navigator.locks is falsy
------------------------------------------------------------
[BLOCKED] Speech Synthesis (weight: 3)
Web Speech API (text-to-speech)
Result: window.speechSynthesis is undefined
Diagnostics:
typeof window.speechSynthesis: undefined
'speechSynthesis' in window: false
typeof SpeechSynthesisUtterance: undefined
speechSynthesis is falsy
------------------------------------------------------------
[BLOCKED] FileReader API (weight: 2)
Local file reading interface
Result: FileReader is undefined
Diagnostics:
typeof FileReader: undefined
FileReader constructor does not exist
------------------------------------------------------------
[AVAILABLE] File Constructor (weight: 2)
File object creation
Result: File created: name=test.txt size=4
Diagnostics:
typeof File: function
calling new File(['test'], 'test.txt', {type:'text/plain'})...
succeeded
f.name: test.txt
f.size: 4
f.type: text/plain
f instanceof Blob: true
------------------------------------------------------------
[BLOCKED] WebGL (weight: 2)
GPU-accelerated graphics
Result: all WebGL contexts returned null
Diagnostics:
getContext('webgl2'): null
getContext('webgl'): null
getContext('experimental-webgl'): null
------------------------------------------------------------
[BLOCKED] WebGL2 (weight: 1)
Advanced GPU graphics context
Result: getContext('webgl2') returned null
Diagnostics:
getContext('webgl2'): null
------------------------------------------------------------
[BLOCKED] Service Worker (weight: 1)
Background script registration
Result: navigator.serviceWorker not present
Diagnostics:
'serviceWorker' in navigator: false
typeof navigator.serviceWorker: undefined
------------------------------------------------------------
[BLOCKED] Web Share API (weight: 0)
Native sharing interface
Result: navigator.share is undefined
Diagnostics:
typeof navigator.share: undefined
typeof navigator.canShare: undefined
------------------------------------------------------------
[BLOCKED] Gamepad API (weight: 1)
Game controller input
Result: navigator.getGamepads not present
Diagnostics:
'getGamepads' in navigator: false
------------------------------------------------------------
[BLOCKED] WebRTC (weight: 2)
Real-time peer communication
Result: RTCPeerConnection is undefined
Diagnostics:
typeof RTCPeerConnection: undefined
typeof webkitRTCPeerConnection: undefined
------------------------------------------------------------
[AVAILABLE] FontFace API (weight: 1)
Dynamic font loading
Result: status: loaded
Diagnostics:
typeof FontFace: function
new FontFace() succeeded
ff.status: unloaded
ff.family: test
ff.status after load: loaded
------------------------------------------------------------
[AVAILABLE] Remote Fonts (weight: 2)
Loading fonts from network via data URI
Result: API works, load rejected: A network error occurred.
Diagnostics:
FontFace created with data URI
ff.status before load: unloaded
caught: DOMException: A network error occurred.
------------------------------------------------------------
[AVAILABLE] JIT Compilation (weight: 2)
JavaScript JIT optimization heuristic
Result: 99.0ms for 1M iterations (JIT likely)
Diagnostics:
running 1,000,000 iterations of Math.sqrt*Math.sin...
elapsed: 99.00ms
sum (to prevent dead-code elimination): -681.7597
threshold: <150ms suggests JIT active
verdict: likely JIT
------------------------------------------------------------
[BLOCKED] Notifications API (weight: 1)
Push notification permission
Result: Notification not in window
Diagnostics:
'Notification' in window: false
typeof Notification: undefined
------------------------------------------------------------
[BLOCKED] WebCodecs (weight: 1)
Low-level VideoDecoder API
Result: VideoDecoder is undefined
Diagnostics:
typeof VideoDecoder: undefined
typeof VideoEncoder: undefined
typeof AudioDecoder: function
------------------------------------------------------------
[AVAILABLE] PDF Embed (weight: 2)
Inline PDF rendering via embed/pdfViewerEnabled
Result: pdfViewerEnabled is true
Diagnostics:
navigator.pdfViewerEnabled: true
typeof navigator.pdfViewerEnabled: boolean
created and appended <embed type=application/pdf>
navigator.mimeTypes['application/pdf']: [object MimeType]
------------------------------------------------------------
[BLOCKED] SharedArrayBuffer (weight: 1)
Shared memory between workers
Result: SharedArrayBuffer is undefined
Diagnostics:
typeof SharedArrayBuffer: undefined
requires COOP/COEP headers to be present; may not indicate Lockdown Mode specifically
------------------------------------------------------------
[BLOCKED] Cache API (weight: 1)
Programmatic HTTP cache (CacheStorage)
Result: caches not in window
Diagnostics:
'caches' in window: false
typeof caches: undefined
------------------------------------------------------------
[BLOCKED] OPFS (weight: 2)
Origin Private File System (navigator.storage.getDirectory)
Result: navigator.storage.getDirectory is not a function
Diagnostics:
typeof navigator.storage: object
typeof navigator.storage.getDirectory: undefined
getDirectory method does not exist
============================================================
NAVIGATOR INFO
============================================================
userAgent: Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4 Mobile/15E148 Safari/604.1
platform: iPhone
vendor: Apple Computer, Inc.
language: en-US
languages: en-US
cookieEnabled: true
doNotTrack: null
maxTouchPoints: 5
hardwareConcurrency: 4
deviceMemory: N/A
pdfViewerEnabled: true
webdriver: false
connection: unavailable
mediaDevices: unavailable
storage: available
serviceWorker: unavailable
credentials: available
bluetooth: unavailable
gpu (WebGPU): unavailable
screenWidth: 393
screenHeight: 852
devicePixelRatio: 3
colorDepth: 24
============================================================
END OF REPORT
============================================================
+45 -18
View File
@@ -9,14 +9,14 @@ This skill guides you through publishing a new release of the app. It handles ve
## Overview
- **Version format**: Semantic versioning (X.Y.Z), starting from 2.0.0
- **Version format**: Marketing version (X.Y.Z), starting from 2.0.0. **This is NOT semver.** Version numbers are chosen based on how the release looks to end users, not based on API compatibility or breaking changes. Think of it like an app store version -- the number reflects the perceived significance of the update to a regular user.
- **Version source of truth**: `package.json` `version` field
- **Changelog**: `CHANGELOG.md` in repo root, using [Keep a Changelog](https://keepachangelog.com/) format
- **Version bumping**: Marketing-driven (not strict semver)
- **Patch (Z)**: Bug fixes, minor tweaks, dependency updates, small UI adjustments
- **Minor (Y)**: New user-facing features, significant UI changes, new pages/screens
- **Version bumping**:
- **Patch (Z)**: Most releases. Bug fixes, tweaks, internal improvements, anything a user wouldn't specifically notice or seek out.
- **Minor (Y)**: Releases with headline features -- things worth announcing. A user should be able to look at the minor bump and think "oh, something new happened."
- **Major (X)**: Only when the user explicitly requests it (milestones, rebrands, major redesigns)
- **CI trigger**: Pushing a semver tag (`v2.1.0`) triggers the CI pipeline to build APKs, create a GitLab release, and publish to Zapstore
- **CI trigger**: Pushing a version tag (`v2.1.0`) triggers the CI pipeline to build APKs, create a GitLab release, and publish to Zapstore
## Release Procedure
@@ -69,11 +69,11 @@ Analyze the commits from Step 3 and determine the appropriate bump level:
| Bump | When to use | Example |
|------|-------------|---------|
| **Patch** | Bug fixes, minor tweaks, dependency updates, small UI polish | 2.0.0 -> 2.0.1 |
| **Minor** | New user-facing features, new screens/pages, significant UI changes | 2.0.1 -> 2.1.0 |
| **Patch** | Bug fixes, minor tweaks, dependency updates, small UI polish, internal tooling, developer-facing pages, CI/build changes, settings/admin screens | 2.0.0 -> 2.0.1 |
| **Minor** | Significant new product features that change how users interact with the app -- the kind of thing you'd highlight in an app store update or announce on social media (e.g., new content type support, DM redesign, new social features, theme system overhaul) | 2.0.1 -> 2.1.0 |
| **Major** | ONLY when the user explicitly instructs a major bump | 2.1.0 -> 3.0.0 |
**Default to patch** when in doubt. Choose minor if there are clearly new features. Never auto-bump major.
**Default to patch** when in doubt. The bar for a minor bump is high -- ask yourself: "Would a regular user notice and care about this change?" If the answer is no, it's a patch. Internal pages (changelog, settings, about screens), infrastructure improvements, CI fixes, and developer tooling are always patch-level regardless of whether they technically add a new page or screen.
When bumping minor, reset patch to 0 (e.g., 2.0.3 -> 2.1.0).
When bumping major, reset minor and patch to 0 (e.g., 2.3.1 -> 3.0.0).
@@ -108,6 +108,10 @@ Prepend a new section to `CHANGELOG.md` directly below the `# Changelog` heading
- Use present tense ("Add dark mode toggle", not "Added dark mode toggle")
- 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.
- **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
@@ -131,24 +135,44 @@ versionName "X.Y.Z"
#### 6c. `ios/App/App.xcodeproj/project.pbxproj`
Update `MARKETING_VERSION` in all 4 occurrences (2 Debug configs + 2 Release configs):
Update `MARKETING_VERSION` in all occurrences (Debug + Release configs):
```
MARKETING_VERSION = X.Y.Z;
```
**Important:** There are exactly 4 lines containing `MARKETING_VERSION` in this file. All 4 must be updated to the same value. Use a replaceAll operation.
**Important:** All lines containing `MARKETING_VERSION` must be updated to the same value. Use a replaceAll operation.
Do NOT change `CURRENT_PROJECT_VERSION` -- it stays at `1` (may be managed separately for App Store submissions in the future).
### Step 7: Commit the Release
### Step 7: Copy Changelog to Public Directory
The changelog is served at runtime by the app from the `public/` directory. After updating `CHANGELOG.md`, copy it:
```bash
git add package.json CHANGELOG.md android/app/build.gradle ios/App/App.xcodeproj/project.pbxproj
cp CHANGELOG.md public/CHANGELOG.md
```
### Step 8: Pull Latest Changes
Before committing the release, pull the latest changes from the remote to ensure the release commit sits on top of the latest code. This **must** happen before committing and tagging.
```bash
git pull origin main
```
**CRITICAL**: Always use `git pull` (merge), NEVER `git pull --rebase`. Rebasing rewrites commit hashes, which would orphan any tag pointing to the original commit. Since version tags are often protected on the remote and cannot be deleted or updated, a broken tag cannot be easily fixed.
If there are merge conflicts with the pulled changes, resolve them before proceeding.
### Step 9: Commit the Release
```bash
git add package.json CHANGELOG.md public/CHANGELOG.md android/app/build.gradle ios/App/App.xcodeproj/project.pbxproj
git commit -m "release: vX.Y.Z"
```
### Step 8: Tag the Release
### Step 10: Tag the Release
```bash
git tag vX.Y.Z
@@ -156,18 +180,20 @@ git tag vX.Y.Z
The tag format is `v` followed by the semver version with no suffix. Examples: `v2.0.0`, `v2.1.0`, `v2.1.1`.
### Step 9: Push
### Step 11: Push
```bash
git push origin main --tags
git push origin main vX.Y.Z
```
**CRITICAL**: Push only the specific tag being released. NEVER use `--tags` -- that pushes ALL local tags, including stale or deleted ones.
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
### Step 10: Confirm
### Step 12: Confirm
After pushing, inform the user:
- The new version number
@@ -180,8 +206,9 @@ After pushing, inform the user:
|------|---------------|-------|
| `package.json` | `version` field | Source of truth for the version |
| `CHANGELOG.md` | Prepend new section | User-facing changelog |
| `public/CHANGELOG.md` | Copy from `CHANGELOG.md` | Served at runtime by the app |
| `android/app/build.gradle` | `versionName` on line 17 | `versionCode` is managed by CI |
| `ios/App/App.xcodeproj/project.pbxproj` | `MARKETING_VERSION` (4 occurrences) | `CURRENT_PROJECT_VERSION` stays at 1 |
| `ios/App/App.xcodeproj/project.pbxproj` | `MARKETING_VERSION` (all occurrences) | `CURRENT_PROJECT_VERSION` stays at 1 |
## CI Pipeline
@@ -205,7 +232,7 @@ If you tagged the wrong version and haven't pushed yet:
git tag -d vX.Y.Z # delete the local tag
git reset --soft HEAD~1 # undo the commit but keep changes staged
```
Then redo steps 4-9 with the correct version.
Then redo steps 4-10 with the correct version.
### Already pushed a bad release
This requires manual intervention. Inform the user and suggest they delete the tag and release from GitLab manually, then re-run the release process.
+3 -1
View File
@@ -2,4 +2,6 @@ VITE_SENTRY_DSN="https://********************************@*****************.exam
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=""
VITE_NOSTR_PUSH_PUBKEY=""
# Set to "*" to allow any host in the Vite dev server (eg. when proxying through a custom domain)
# ALLOWED_HOSTS="*"
+95 -31
View File
@@ -26,19 +26,56 @@ test:
script:
- npm run test
pages:
# 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:
stage: deploy
timeout: 5 minutes
timeout: 10 minutes
rules:
- when: never
# rules:
# - if: $CI_COMMIT_TAG
# when: never
# - if: $CI_COMMIT_REF_NAME == $CI_DEFAULT_BRANCH
variables:
NSYTE_VERSION: "v0.24.1"
script:
# Build the web app
- npm ci
- npm run build
- rm -rf public
- mv dist public
- cp dist/index.html dist/404.html
# Download nsyte binary
- curl -fsSL "https://github.com/sandwichfarm/nsyte/releases/download/${NSYTE_VERSION}/nsyte-linux" -o /usr/local/bin/nsyte
- chmod +x /usr/local/bin/nsyte
# Deploy to nsite via nsyte using the nbunksec credential
- >-
nsyte deploy ./dist
-i
--sec "$NSITE_NBUNKSEC"
--name agora
--relays "wss://relay.ditto.pub,wss://relay.nsite.lol,wss://relay.dreamith.to,wss://relay.primal.net"
--servers "https://blossom.primal.net,https://blossom.ditto.pub,https://blossom.dreamith.to"
--fallback "/index.html"
--use-fallback-relays
--use-fallback-servers
build-web:
stage: build
timeout: 10 minutes
needs: []
rules:
- if: $CI_COMMIT_TAG
when: never
- if: $CI_COMMIT_REF_NAME == $CI_DEFAULT_BRANCH
script:
- npm ci
- npm run build
- cp dist/index.html dist/404.html
artifacts:
paths:
- public
only:
variables:
- $CI_DEFAULT_BRANCH == $CI_COMMIT_REF_NAME
- dist/
build-apk:
stage: build
@@ -112,32 +149,33 @@ build-apk:
- npx vite build -l error
- cp dist/index.html dist/404.html
# Sync web assets to Capacitor Android project
# Sync web assets to Capacitor Android project and register local plugins
- npx cap sync android
- node scripts/patch-cap-config.mjs
# Build signed release APK
- cd android && chmod +x gradlew && ./gradlew assembleRelease bundleRelease && cd ..
# Copy APK to a predictable artifact path
- mkdir -p artifacts
- cp android/app/build/outputs/apk/release/app-release.apk "artifacts/Ditto.apk"
- cp android/app/build/outputs/bundle/release/app-release.aab "artifacts/Ditto.aab"
- cp android/app/build/outputs/apk/release/app-release.apk "artifacts/Agora.apk"
- cp android/app/build/outputs/bundle/release/app-release.aab "artifacts/Agora.aab"
- ls -lh artifacts/
# Upload to Generic Packages registry for a stable public download URL
- |
curl --fail --header "JOB-TOKEN: ${CI_JOB_TOKEN}" \
--upload-file "artifacts/Ditto.apk" \
"${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/ditto/${CI_COMMIT_TAG}/Ditto-${CI_COMMIT_TAG}.apk"
--upload-file "artifacts/Agora.apk" \
"${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/agora/${CI_COMMIT_TAG}/Agora-${CI_COMMIT_TAG}.apk"
- |
curl --fail --header "JOB-TOKEN: ${CI_JOB_TOKEN}" \
--upload-file "artifacts/Ditto.aab" \
"${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/ditto/${CI_COMMIT_TAG}/Ditto-${CI_COMMIT_TAG}.aab"
--upload-file "artifacts/Agora.aab" \
"${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/agora/${CI_COMMIT_TAG}/Agora-${CI_COMMIT_TAG}.aab"
artifacts:
paths:
- artifacts/Ditto.apk
- artifacts/Ditto.aab
- artifacts/Agora.apk
- artifacts/Agora.aab
expire_in: 90 days
cache:
key: android-gradle
@@ -160,25 +198,20 @@ release:
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="Ditto ${CI_COMMIT_TAG}"
RELEASE_NOTES="Agora ${CI_COMMIT_TAG}"
fi
echo "RELEASE_NOTES<<ENDOFNOTES" >> release.env
echo "$RELEASE_NOTES" >> release.env
echo "ENDOFNOTES" >> release.env
artifacts:
reports:
dotenv: release.env
- echo "$RELEASE_NOTES" > release-notes.md
release:
tag_name: $CI_COMMIT_TAG
name: $CI_COMMIT_TAG
description: $RELEASE_NOTES
description: './release-notes.md'
assets:
links:
- name: "Ditto-${CI_COMMIT_TAG}.apk"
url: "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/ditto/${CI_COMMIT_TAG}/Ditto-${CI_COMMIT_TAG}.apk"
- name: Agora-${CI_COMMIT_TAG}.apk
url: ${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/agora/${CI_COMMIT_TAG}/Agora-${CI_COMMIT_TAG}.apk
link_type: package
- name: "Ditto-${CI_COMMIT_TAG}.aab"
url: "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/ditto/${CI_COMMIT_TAG}/Ditto-${CI_COMMIT_TAG}.aab"
- 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
publish-zapstore:
@@ -190,6 +223,8 @@ publish-zapstore:
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
variables:
SIGN_WITH: $ZAPSTORE_BUNKER_URL
RELAY_URLS: "wss://relay.zapstore.dev,wss://relay.ditto.pub,wss://relay.dreamith.to,wss://relay.primal.net"
BLOSSOM_URL: "https://blossom.ditto.pub"
script:
- go install github.com/zapstore/zsp@latest
@@ -199,8 +234,37 @@ publish-zapstore:
- mkdir -p ~/.config/zsp/bunker-keys
- echo "$ZAPSTORE_CLIENT_KEY" > ~/.config/zsp/bunker-keys/${BUNKER_PUBKEY}.key
- APK_PATH="artifacts/Ditto.apk"
- APK_PATH="artifacts/Agora.apk"
- VERSION="${CI_COMMIT_TAG#v}"
- sed -i "2i release_source:\ ./${APK_PATH}" zapstore.yaml
- sed -i "2i version:\ ${VERSION}" zapstore.yaml
- zsp publish -y --skip-metadata --skip-preview zapstore.yaml
- zsp publish --quiet --skip-metadata --skip-preview zapstore.yaml
publish-google-play:
stage: publish
image: ruby:3.3
needs:
- build-apk
rules:
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
script:
- gem install fastlane --no-document
# 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
- >-
fastlane supply
--aab artifacts/Agora.aab
--package_name pub.agora.app
--track production
--json_key /tmp/play-service-account.json
--skip_upload_metadata
--skip_upload_changelogs
--skip_upload_images
--skip_upload_screenshots
--skip_upload_apk
# Clean up
- rm -f /tmp/play-service-account.json
@@ -0,0 +1,68 @@
Thanks for contributing to Agora! Please read [CONTRIBUTING.md](CONTRIBUTING.md) in full before submitting -- it covers everything you need to get your MR accepted.
## Related Issue
<!-- Link the GitLab issue. MRs without a linked issue will not be reviewed. -->
Closes #
## What Changed
<!-- 1-3 sentences: what you changed and why. -->
## Live Preview
<!-- REQUIRED for UI changes. Deploy your branch and paste the URL. -->
<!-- Example: npx surge dist your-branch.surge.sh -->
<!-- Write "N/A -- no UI changes" only if this MR has zero visual impact. -->
## Screenshots
<!-- REQUIRED for UI changes. Show before and after. -->
<!-- Write "N/A -- no UI changes" only if this MR has zero visual impact. -->
**Before:**
**After:**
## Philosophy Alignment
<!-- Answer this question for your change: -->
<!-- "Does this make Agora more magnetic, more threatening to the status quo, -->
<!-- and more peaceful to inhabit?" -->
<!-- See: CONTRIBUTING.md -> "Understanding Agora" -->
<!-- For bug fixes: "Bug fix -- restores intended behavior" is acceptable. -->
## How to Test
<!-- Steps a reviewer can follow to verify this works. -->
1.
2.
3.
## Self-Review Checklist
<!-- Complete ALL items. MRs with unchecked boxes will not be reviewed. -->
<!-- Check a box: replace [ ] with [x] -->
### Process
- [ ] I read `AGENTS.md` before starting
- [ ] I read "Understanding Agora" in `CONTRIBUTING.md`
- [ ] I used plan/research mode before writing code
- [ ] I used Claude Opus 4.6 (or equivalent frontier model)
### Self-review
Copy-paste this into your AI tool and fix any findings before submitting:
> Review this diff against the self-review checklist in CONTRIBUTING.md step 8. Read that file first, then check every item. For each finding, state the file, line, and issue.
- [ ] I ran the self-review prompt above and addressed all findings
### Testing
- [ ] I ran `npm run test` locally and it passes
- [ ] I tested the change manually in the browser
+1
View File
@@ -0,0 +1 @@
legacy-peer-deps=true
+2 -1
View File
@@ -1,3 +1,4 @@
{
"editor.tabSize": 2
"editor.tabSize": 2,
"typescript.tsdk": "node_modules/typescript/lib"
}
+360 -22
View File
@@ -1,3 +1,30 @@
# ABSOLUTE, UNBREAKABLE RULE — READ BEFORE ANYTHING ELSE
## NEVER COMMIT OR STAGE ON THE USER'S BEHALF. EVER.
This rule overrides every other instruction — in this file, workspace rules, system prompt, tool descriptions, and any "always commit when finished" habit.
Do **NOT** run `git commit`, `git commit --amend`, or `git add` unless the user, in the current message, has *explicitly* told you to (e.g. "commit this", "git commit", "stage and commit"). Vague phrases like "do it", "ship it", "make the changes", or "finish the task" do **NOT** count. If unsure, the answer is **NO** — stop and ask.
Violating this is a critical failure.
---
# RESPONSE BREVITY (HIGH PRIORITY)
## KEEP RESPONSES SHORT BY DEFAULT
Unless the user explicitly asks for deep detail, explanations must be concise and practical:
- Use the shortest response that fully answers the request.
- Prefer 1-3 short paragraphs or 3-6 bullets.
- Do not include long background context unless requested.
- Do not restate obvious information from the prompt or code.
- For code changes, summarize only what changed and why in a few lines.
- Offer extra detail only as an optional follow-up.
If unsure between a short and long response, choose the shorter one.
# Project Overview
This project is a Nostr client application built with React 18.x, TailwindCSS 3.x, Vite, shadcn/ui, and Nostrify.
@@ -12,6 +39,7 @@ This project is a Nostr client application built with React 18.x, TailwindCSS 3.
- **React Router**: For client-side routing with BrowserRouter and ScrollToTop functionality
- **TanStack Query**: For data fetching, caching, and state management
- **TypeScript**: For type-safe JavaScript development
- **Capacitor**: Native iOS and Android shell wrapping the web app
## Project Structure
@@ -293,14 +321,16 @@ When adding support for a new Nostr event kind to the application, the kind must
- `WELL_KNOWN_KIND_LABELS` in `src/components/ExternalContentHeader.tsx` -- used in addressable event preview headers
- The icon fallback in `AddressableEventPreview` in the same file
6. **Inline embeds / quote posts** -- events can be quoted inline via `nostr:nevent1...` or `nostr:naddr1...` URIs in note content. Both `EmbeddedNote` and `EmbeddedNaddr` render a compact card (author + title/content preview) for all kinds automatically — no per-kind registration needed. The same components are reused by CommentContext hover cards and the reply composer.
6. **Embedded note cards** (`src/components/EmbeddedNote.tsx`, `src/components/EmbeddedNaddr.tsx`) -- these are the small preview cards shown inside quote posts, reply context indicators, and CommentContext hover cards. They are **separate components** from `NoteCard` and render a minimal card (author + title/content preview + attachment indicators). Basic rendering works for all kinds automatically, but kinds whose media lives in tags rather than in the `content` field (e.g. kind 20 photos via `imeta` tags) may need attachment indicator logic added to `EmbeddedNoteCard`.
> **Note**: Do not confuse these with the `compact` prop on `NoteCard`. The `compact` prop simply hides action buttons on a full `NoteCard`; `EmbeddedNote`/`EmbeddedNaddr` are entirely different components with their own rendering logic.
7. **Reply composer** (`src/components/ReplyComposeModal.tsx`):
- The `EmbeddedPost` component delegates to the shared `EmbeddedNote`/`EmbeddedNaddr` components — no per-kind registration needed
#### Why so many places?
These are genuinely different UI contexts (feed cards, detail pages, inline embeds, reply previews, comment context labels) with different rendering requirements. However, several of them maintain independent kind-to-label maps that could theoretically be unified. When in doubt, search the codebase for an existing kind number like `30617` to find all the registration points.
These are genuinely different UI contexts (feed cards, detail pages, embedded note cards, reply previews, comment context labels) with different rendering requirements. However, several of them maintain independent kind-to-label maps that could theoretically be unified. When in doubt, search the codebase for an existing kind number like `30617` to find all the registration points.
### NIP.md
@@ -406,6 +436,74 @@ Without filtering approvals by the moderator list, anyone could publish kind 455
Author filtering is not needed for public user-generated content where anyone should be able to post (kind 1 notes, reactions, discovery queries, public feeds, etc.).
#### Sanitizing URLs from Event Data
**CRITICAL**: Any URL extracted from Nostr event tags, content, or metadata fields is **untrusted user input**. Malicious URLs can cause harm in many ways beyond `javascript:` XSS — `data:` URIs for resource exhaustion, `http://` URLs leaking user IPs without TLS, relative paths triggering unintended requests to the app's own origin, and more. Reasoning about which rendering context is "safe enough" to skip sanitization is fragile and error-prone.
**Rule: sanitize every event-sourced URL unconditionally**, regardless of where it will be used (`href`, `img src`, `style`, etc.). Use `sanitizeUrl()` from `@/lib/sanitizeUrl`:
```typescript
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` accepts `string | undefined | null` and 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`.
**Best practice — 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 extracted by regex that already constrains the protocol (e.g. `NoteContent` tokeniser matches only `https?://`)
- Hardcoded or application-generated URLs (relay configs, internal routes, etc.)
- URLs displayed as plain text without being placed into any HTML attribute or CSS value
#### Preventing CSS Injection from Event Data
**CRITICAL**: Any value from a Nostr event that is interpolated into a CSS string (inside a `<style>` element or inline `style` attribute) is a CSS injection vector. A malicious value containing `"`, `)`, `}`, or `;` can break out of the CSS context and inject arbitrary rules — for example, overlaying phishing content or hiding UI elements.
**Common CSS injection surfaces:**
- `background-image: url("${url}")` — a URL with `"); body { display:none }` breaks out
- `font-family: "${family}"` — a family name with `"; } body { visibility:hidden } .x {` breaks out
- `@font-face { src: url("${url}") }` — same risk as background URLs
**Mitigation strategy — sanitize at the parse layer:**
1. **URLs in CSS `url()` values**: Pass through `sanitizeUrl()` at parse time. The `URL` constructor normalises the string, percent-encoding characters like `"`, `)`, and `\` that could escape the CSS context. Invalid or non-`https:` URLs are rejected entirely.
2. **Strings in CSS declarations** (e.g. font family names): Use `sanitizeCssString()` from `src/lib/fontLoader.ts`, which uses an allowlist approach — only Unicode letters, numbers, spaces, hyphens, underscores, apostrophes, and periods are permitted. Everything else is stripped.
```typescript
// ❌ UNSAFE — raw event data interpolated into CSS
const bgUrl = getTagValue(event.tags, 'bg');
style.textContent = `body { background-image: url("${bgUrl}"); }`;
const family = getTagValue(event.tags, 'f');
style.textContent = `html { font-family: "${family}"; }`;
// ✅ SAFE — URLs validated, strings sanitised
import { sanitizeUrl } from '@/lib/sanitizeUrl';
const bgUrl = sanitizeUrl(getTagValue(event.tags, 'bg'));
if (bgUrl) {
style.textContent = `body { background-image: url("${bgUrl}"); }`;
}
// For non-URL strings, allowlist safe characters only
const safeFamily = family.replace(/[^\p{L}\p{N} _\-'.]/gu, '');
style.textContent = `html { font-family: "${safeFamily}"; }`;
```
**Rule of thumb**: Never interpolate untrusted strings into CSS without sanitisation. If it's a URL, use `sanitizeUrl()`. If it's any other string, strip characters that can break out of the CSS string context.
### The `useNostr` Hook
The `useNostr` hook returns an object containing a `nostr` property, with `.query()` and `.event()` methods for querying and publishing Nostr events respectively.
@@ -692,6 +790,88 @@ export function MyComponent() {
The `useCurrentUser` hook should be used to ensure that the user is logged in before they are able to publish Nostr events.
### Mutating Replaceable 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, then publish a new version. **Never read from 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. This is the **previous version** of the event being replaced. The hook uses it to automatically manage the `published_at` tag (NIP-24) for replaceable and addressable events:
- **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 before publishing** 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 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.
### Nostr Login
To enable login with Nostr, simply use the `LoginArea` component already included in this project.
@@ -953,6 +1133,16 @@ const defaultConfig: AppConfig = {
The app uses NIP-65 compatible relay management with automatic sync when users log in. Local storage persists user preferences and relay configurations.
### Adding a New AppConfig Value
Adding a new configuration field requires updates in **three places**. Missing any of them will cause build failures or runtime issues.
1. **TypeScript interface** (`src/contexts/AppContext.ts`): Add the field to the `AppConfig` interface with a JSDoc comment.
2. **Zod schema** (`src/lib/schemas.ts`): Add the same field to `AppConfigSchema`. The `DittoConfigSchema` (used to validate the build-time `ditto.json` file) is derived from `AppConfigSchema` with `.strict()` mode, so any field present in `ditto.json` but missing from the Zod schema will cause a build error.
3. **Default value** (`src/contexts/AppContext.ts`): If the field is required (not optional), add a default value in `defaultConfig`. Optional fields (`?` in the interface, `.optional()` in Zod) can be omitted from the default.
### Relay Management
The project includes a complete NIP-65 relay management system:
@@ -1000,6 +1190,7 @@ The router includes automatic scroll-to-top functionality and a 404 NotFound pag
- Default connection to one Nostr relay for best performance
- Comprehensive provider setup with NostrLoginProvider, QueryClientProvider, and custom AppProvider
- **Never use the `any` type**: Always use proper TypeScript types for type safety
- **Fail-fast error visibility**: Never silently hide errors in the UI. If data fails validation, a resource fails to load, or a user action errors, surface an explicit visible error state/message so users can see what failed and why.
## Loading States
@@ -1230,33 +1421,95 @@ Run available tools in this priority order:
2. **Building/Compilation** (Required): Verify the project builds successfully
3. **Linting** (Recommended): Check code style and catch potential issues
4. **Tests** (If Available): Run existing test suite
5. **Git Commit** (Required): Create a commit with your changes when finished
**Minimum Requirements:**
- Code must type-check without errors
- Code must build/compile successfully
- Fix any critical linting errors that would break functionality
- Create a git commit when your changes are complete
- **Do NOT commit.** Leave changes uncommitted for the user to review. See the "ABSOLUTE, UNBREAKABLE RULE" at the top of this file.
The validation ensures code quality and catches errors before deployment, regardless of the development environment.
### 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.
### Using Git
If git is available in your environment (through a `shell` tool, or other git-specific tools), you should utilize `git log` to understand project history. Use `git status` and `git diff` to check the status of your changes, and if you make a mistake use `git checkout` to restore files.
When your changes are complete and validated, create a git commit with a descriptive message summarizing your changes.
When your changes are complete and validated, leave the working tree as-is for the user to review. **Do NOT create a git commit unless the user has explicitly told you to in the current message.** See the "ABSOLUTE, UNBREAKABLE RULE" at the top of this file — it overrides any habit or template guidance about always committing at the end of a task.
**ALWAYS commit when you are finished making changes.**
## Capacitor Compatibility
The app 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 element 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.
### File Downloads and URL Opening
The project provides two utility functions in `src/lib/downloadFile.ts` that handle the web/native split automatically:
#### `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. Always use the utilities above. They handle the Capacitor/web split and will work correctly on all platforms.
### 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
}
```
### 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/local-notifications` -- Schedule local push notifications
- `@capacitor/share` -- Native share sheet
- `@capacitor/status-bar` -- Control the native status bar style
After adding or removing plugins, run `npx cap sync` to update the native projects.
## CI/CD Pipeline
The project uses GitLab CI (`.gitlab-ci.yml`) with the following stages:
1. **test** - Runs `npm run test` on every commit (skipped for tags)
2. **deploy** - Builds and deploys to GitLab Pages (default branch only)
2. **deploy** - Builds and deploys to nsite via nsyte (`deploy-nsite` job, default branch only)
3. **build** - Builds a signed release APK (`build-apk` job, tags only)
4. **release** - Creates a GitLab Release with the APK artifact (tags only)
5. **publish** - Publishes the APK to Zapstore (`publish-zapstore` job, tags only)
5. **publish** - Publishes the APK to Zapstore (`publish-zapstore` job, tags only) and AAB to Google Play (`publish-google-play` job, tags only)
### Creating a Release
@@ -1266,7 +1519,7 @@ Releases are triggered by pushing a version tag. Use the npm script:
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`, and `publish-zapstore` stages.
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` stages.
### Zapstore Publishing
@@ -1293,19 +1546,104 @@ NIP-46 bunker signing requires two keys: the **user's key** (held by Amber) and
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):**
1. Generate a client key: `nak key generate` (save the hex output)
2. Store it as `ZAPSTORE_CLIENT_KEY` in GitLab CI/CD variables
3. Get a bunker URL from Amber (with `secret` param for first connection)
4. Authorize the client key locally using `nak`:
```bash
export NOSTR_CLIENT_KEY="<the hex client key>"
nak event --sec "bunker://<pubkey>?relay=...&secret=<secret>" -c "test"
```
5. Approve the connection on Amber when prompted
6. Store the bunker URL **without the `secret` param** as `ZAPSTORE_BUNKER_URL` in GitLab CI/CD variables (the secret is single-use and no longer needed after authorization)
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 will then output the `bunker://` URI and client key hex, and write the client key to `~/.config/zsp/bunker-keys/`. Update the GitLab CI/CD variables with the printed values.
The script accepts 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)
**Key points:**
- The `secret` in bunker URLs is **single-use** -- it is consumed on first connection and cannot be reused
- The `ZAPSTORE_CLIENT_KEY` must be authorized locally first by connecting to the bunker with a fresh secret and approving on Amber
- 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, the authorization step must be repeated with a new bunker URL secret
- If the client key is rotated, run the script again and update the GitLab CI/CD variables
### nsite Publishing
The project automatically deploys the web app to [nsite](https://nsite.run) on every push to the default branch using [nsyte](https://github.com/sandwichfarm/nsyte). The `deploy-nsite` CI job builds the Vite app and uploads the `dist/` directory to Blossom servers, publishing site manifest events to Nostr relays.
nsyte uses a NIP-46 bunker credential called `nbunksec` -- a bech32-encoded string that bundles the bunker pubkey, client secret key, and relay info into a single self-contained token. This is passed to nsyte via `--sec`.
**GitLab CI/CD Variables** (Settings > 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 will guide you through connecting a NIP-46 bunker (e.g. Amber) and output an `nbunksec1...` string. The credential is shown only once.
3. Add the `nbunksec1...` value as the `NSITE_NBUNKSEC` variable in GitLab CI/CD settings (Settings > CI/CD > Variables). Mark it as **Protected** and **Masked**.
#### Configured Relays and Servers
The deploy job publishes to these relays:
- `wss://relay.ditto.pub`
- `wss://relay.nsite.lol`
- `wss://relay.dreamith.to`
- `wss://relay.primal.net`
And uploads blobs to these Blossom servers:
- `https://blossom.primal.net`
- `https://blossom.ditto.pub`
- `https://blossom.dreamith.to`
The `--use-fallback-relays` and `--use-fallback-servers` flags also 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 project automatically publishes Android AABs (App Bundles) to [Google Play](https://play.google.com/store/apps/details?id=pub.ditto.app) using [fastlane supply](https://docs.fastlane.tools/actions/supply/). The `publish-google-play` CI job runs after a successful AAB build and uploads directly to the production track.
**GitLab CI/CD Variables** (Settings > 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 file. The CI job decodes it with `base64 -d` before passing it to `fastlane supply`. | Yes | Yes | No |
#### Initial Setup (one-time)
1. Create or reuse a project in the [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 the `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON` variable in GitLab CI/CD settings (Settings > CI/CD > Variables). 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) since 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 changelogs are managed in the Play Console, not via CI (the job uses `--skip_upload_metadata` etc.)
- The same signing keystore used for Zapstore is used here (`ANDROID_KEYSTORE_BASE64`, `KEYSTORE_PASSWORD`, `KEY_PASSWORD`)
+4 -2
View File
@@ -1,5 +1,7 @@
# Changelog
## [2.0.0] - 2026-03-26
## [1.0.0] - 2026-04-30
Initial release of Ditto 2.0 -- a complete rewrite of Ditto.
### Added
- Initial Agora 3 release.
+184
View File
@@ -0,0 +1,184 @@
# Contributing to Agora
We welcome contributions, but we have high standards. Agora is a carefully designed product with a specific vision, and every merge request must meet that bar. This guide exists to help you succeed.
**Required reading before you start:**
- [Understanding Agora](#understanding-agora) -- the product vision. Your change must align with it.
- This `CONTRIBUTING.md` guide -- the contribution process for this repository.
- `AGENTS.md` in this repo -- the codebase conventions. Your AI tool should load this file.
## Understanding Agora
Agora is a carnival, not a platform. Before contributing, you need to understand what that means.
### The product decision filter
Every change to Agora should pass this test:
> *Does this make Agora more magnetic, more threatening to the status quo, and more peaceful to inhabit?*
- **Magnetic** -- Agora attracts through experience, not ideology. People don't need to understand Nostr to love it. They need to feel something they haven't felt online since the early web. Features should be odd, intriguing, and captivating -- not generic social media clones.
- **Threatening to the status quo** -- Agora threatens mainstream platforms when someone opens it and thinks: *"Why can't my platform do this?"* Theming, games, treasure hunts, interoperable micro-apps -- these are things walled gardens can't replicate.
- **Peaceful to inhabit** -- Agora displaces argument with creation, conformity with expression, and consumption with participation. No ads, no engagement-optimized algorithms, no outrage incentives.
If a change does all three, it belongs. If it only does one, think harder. If it does none, it doesn't belong here.
### What Agora is NOT
- A Twitter/X clone with decentralization bolted on
- A place to replicate features that mainstream platforms already do well
- A showcase for generic UI components or boilerplate social features
### What Agora IS
- A convergence point for interoperable Nostr experiences (games, treasure hunts, magic decks, themes, color moments, live streams, and things nobody has imagined yet)
- A place where profiles feel like worlds, not business cards
- The most fun you've had on the internet in years
Read the full "Understanding Agora" section above for the complete vision.
## What we accept
### Bug fixes
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.
### 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.
If you have an idea for a feature that doesn't have an issue yet:
1. Build it as a standalone Nostr app first (then document traction/feedback in the linked issue).
2. Prove it works and get user feedback.
3. Open an issue to discuss integration.
**Feature MRs that don't link to an issue or don't align with the Agora Philosophy will be closed.** Our open issues are our internal roadmap -- some require deep product context. If your implementation doesn't match the product vision, it will be closed regardless of code quality.
## Required tools
- **Claude Opus 4.6** (or the latest frontier model) -- not Sonnet, not GPT-4o, not local models. Quality depends on model quality.
- **An AI coding agent with plan/research mode** -- [OpenCode](https://opencode.ai), [Shakespeare](https://shakespeare.diy), Cursor, or similar.
- **Node.js 22+** and npm 10.9.4+.
## The contribution workflow
Follow these steps in order. Skipping steps is the most common reason MRs are rejected.
### 1. Ask: does anyone need this?
Before writing a single line of code, answer this honestly. For bug fixes this is straightforward -- someone hit the bug. For features, it requires more thought. Is there evidence of real user demand? Is the underlying technology mature enough? A beautifully written feature for a nonexistent user base is the wrong thing to build. If you can't point to a concrete user need, reconsider.
### 2. Understand the issue
Read the issue thoroughly. If anything is unclear, ask in the issue comments before writing code. Understand not just *what* to change, but *why* -- what problem does this solve for users?
### 3. Read the codebase conventions
Read `AGENTS.md` in the repo root. This is the single source of truth for how code should be written in this project. Your AI tool should load this file automatically. If it doesn't, paste it in or configure your tool to read it.
### 4. Read the philosophy
Read "Understanding Agora" in this file. Agora is a carnival, not a platform. Your change should feel like it belongs in Agora -- not like it was transplanted from a generic social media template. Apply the product decision filter above.
### 5. Plan before you code
Start your AI tool in **plan mode** (or research/think mode). Spend the first few prompts:
- Exploring the existing codebase to understand how similar features are implemented
- Reading the files you'll need to modify
- Proposing an approach
Do not write code until you have a plan. The most expensive mistake is implementing the wrong approach.
### 6. Implement
Switch to code mode and implement your plan. Use Opus 4.6 or equivalent.
### 7. Run the test suite
```sh
npm run test
```
This runs type-checking, linting, unit tests, and a production build. All must pass. Do not submit an MR with a failing test suite.
### 8. Self-review
Run this prompt against your diff (copy the full `git diff` output and paste it to your AI tool along with this prompt):
```
Review this diff as if you are a senior maintainer of this codebase who has to
maintain it long-term. For each finding, state the file, line, and issue.
- [ ] Does the diff contain changes that weren't requested? Flag anything out of scope.
- [ ] Is there dead code, commented-out blocks, or debug artifacts left in?
- [ ] Are there placeholder comments like "// In a real app..." or "// TODO: implement"?
- [ ] For every value displayed to a user, can you trace it from source to render without a gap?
- [ ] Are error, loading, and empty states all handled -- and in the right order?
- [ ] Does a mutation reflect in the UI without requiring a manual refresh?
- [ ] Is there a new read/write path that assumes fresh data but could get a stale cache?
- [ ] For replaceable/addressable Nostr events: is fetchFreshEvent used before mutation?
- [ ] Does anything new block the critical render path or fire N+1 network requests?
- [ ] Are Nostr queries efficient (combined kinds, relay-level filtering vs client-side)?
- [ ] Are user inputs used in queries or rendered as content without sanitization?
- [ ] Were existing patterns/conventions in AGENTS.md ignored in favor of something novel?
- [ ] Are secrets, keys, or env-specific values hardcoded?
- [ ] Does the code use the `any` type anywhere?
- [ ] Is the code Capacitor-compatible (no `<a download>`, no `window.open()`)?
- [ ] Are new Nostr event kinds documented in NIP.md with links to relevant specs?
- [ ] 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?
Skip anything a linter or type checker would catch. Focus on logic, data flow, and intent.
Then answer: "If you were the people who have to maintain this codebase and deal
with all long-term issues, what would be your biggest concerns about this
implementation?"
```
Address every finding before submitting.
### 9. Deploy a live preview
Deploy your branch so reviewers can test it without pulling your code:
```sh
npm run build
npx surge dist your-branch-name.surge.sh
```
Or use Netlify, Vercel, or any static hosting. Include the live preview URL in your MR description.
### 10. Take screenshots
Capture before and after screenshots of any UI changes. Include them directly in the MR description. If your change has no visual component, state that explicitly.
### 11. Submit
Fill out every field in the MR template. Incomplete MRs will not be reviewed.
## What gets your MR closed without review
- No linked issue
- Feature MRs with no clear alignment with "Understanding Agora" in this file
- Features that fail the product decision filter (not magnetic, not threatening to the status quo, not peaceful)
- Incomplete MR template (missing checklist, screenshots, or preview URL)
- Changes that go beyond what was asked for (scope creep)
- Placeholder code, dead code, or debug artifacts
- Evidence of low-quality AI generation ("In a real application..." comments, hallucinated APIs, generic template code)
- Failing test suite
- No evidence of planning (code-first, think-later approach produces recognizable patterns)
- Undocumented Nostr event kinds (new kinds must be in NIP.md)
- Large binary assets committed to git (images >100KB, fonts, videos)
- Security issues (dangerouslySetInnerHTML, eval, innerHTML, unsanitized user input)
## MR review process
1. The CI pipeline validates your MR description automatically. If it fails, read the error message and fix your MR description.
2. Maintainers will review your MR when all CI checks pass and the template is complete.
3. If changes are requested, address them promptly. Stale MRs will be closed.
We appreciate your interest in contributing. These standards exist because reviewing a low-quality MR takes 3x longer than doing the work ourselves. Help us help you by following the process.
+15
View File
@@ -0,0 +1,15 @@
FROM node:22-alpine AS builder
WORKDIR /app
RUN apk add --no-cache git
COPY package*.json ./
COPY .npmrc ./
COPY scripts/ ./scripts/
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=builder /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+779 -141
View File
@@ -2,173 +2,78 @@
## Event Kinds Overview
### Ditto Kinds
| Kind | Name | Description |
|-------|----------------------|-------------------------------------------------------|
| 36767 | Theme Definition | Shareable, named custom UI theme |
| 16767 | Active Profile Theme | The user's currently active theme (one per user) |
| 16769 | Profile Tabs | The user's custom profile page tabs (one per user) |
---
### Agora Kinds
## Kind 36767: Theme Definition
| Kind | Name | Description |
|-------|----------------------------|----------------------------------------------------------------|
| 20000 | Ephemeral Geo Chat (public) | Geo-anchored ephemeral chat message (kind 20000, public) |
| 20001 | Ephemeral Geo Heartbeat | Geo-anchored ephemeral presence heartbeat (kind 20001) |
| 30385 | Community Stats Snapshot | Pre-computed per-country / global community leaderboards |
| 36639 | Activist Action | Country-scoped activist challenge with a sats bounty |
### Summary
### Agora Protocols
Addressable event kind for publishing shareable custom UI themes. A single user may publish multiple themes, each identified by a unique `d` tag.
| Protocol | Composed Kinds | Description |
|--------------------------|-----------------------------------------|-----------------------------------------------------------------|
| Flat Communities | 34550, 30009, 8, 1111, 1984 | One-level badge membership with explicit moderators (NIP-72 ext) |
A theme consists of colors, optional fonts, and an optional background. Colors are stored in `c` tags, fonts in `f` tags, and background in a `bg` tag.
### Community Kinds
### Event Structure
These event kinds were created by community contributors and are supported by Ditto. Full specifications are maintained by their respective authors.
```json
{
"kind": 36767,
"content": "",
"tags": [
["d", "mk-dark-theme"],
["c", "#1a1a2e", "background"],
["c", "#e0e0e0", "text"],
["c", "#6c3ce0", "primary"],
["f", "Inter", "https://example.com/inter.woff2", "body"],
["f", "Playfair Display", "https://example.com/playfair.woff2", "title"],
["bg", "url https://example.com/bg.jpg", "mode cover", "m image/jpeg", "dim 1920x1080"],
["title", "MK Dark Theme"],
["alt", "Custom theme: MK Dark Theme"]
]
}
```
### Content
The `content` field is unused and MUST be an empty string (`""`).
### Tags
| Tag | Required | Description |
|---------|----------|---------------------------------------------------------------------------------------|
| `d` | Yes | Unique identifier (slug) for this theme, e.g. `"mk-dark-theme"` |
| `c` | Yes (×3) | Hex color with marker. See [Color Tags](#color-tags). |
| `f` | No | Font declaration. See [Font Tag](#font-tag). |
| `bg` | No | Background media. See [Background Tag](#background-tag). |
| `title` | Yes | Human-readable theme name |
| `alt` | Yes | NIP-31 human-readable fallback |
### Multiple Themes Per User
Since kind 36767 is addressable, a user can publish multiple themes by using different `d` tag values. Publishing a new event with the same `d` tag replaces the previous version (this is how editing works).
| Kind | Name | Description | Spec |
|-------|------------------------|------------------------------------------------------------------|-------------------------------------------------------------------------------------------|
| 3367 | Color Moment | Color palette post expressing a mood | [NIP](https://gitlab.com/chad.curtis/espy/-/blob/main/NIP.md) |
| 4223 | Weather Reading | Sensor readings from a weather station | [Draft NIP](https://github.com/nostr-protocol/nips/pull/2163) |
| 7516 | Found Log | Log entry recording a user finding a geocache | [NIP-GC](https://gitlab.com/chad.curtis/treasures/-/blob/main/NIP-GC.md) |
| 8211 | Encrypted Letter | Encrypted personal letter with visual stationery | [NIP](https://gitlab.com/chad.curtis/lief/-/blob/main/NIP.md) |
| 16158 | Weather Station | Weather station metadata (location, sensors, connectivity) | [Draft NIP](https://github.com/nostr-protocol/nips/pull/2163) |
| 37516 | Geocache | Geocache listing for real-world treasure hunting | [NIP-GC](https://gitlab.com/chad.curtis/treasures/-/blob/main/NIP-GC.md) |
---
## Kind 16767: Active Profile Theme
## Standard NIPs: Direct Messaging
### Summary
This application implements encrypted direct messaging using two standard Nostr protocols:
Replaceable event that represents the user's currently active profile theme. Only one per user. When other users visit a profile, they query this kind to determine what theme to display.
### NIP-04 (Legacy Encrypted DMs)
### Event Structure
| Field | Value |
|-------|-------|
| Kind | 4 |
| Spec | [NIP-04](https://github.com/nostr-protocol/nips/blob/master/04.md) |
```json
{
"kind": 16767,
"content": "",
"tags": [
["c", "#1a1a2e", "background"],
["c", "#e0e0e0", "text"],
["c", "#6c3ce0", "primary"],
["f", "Inter", "https://example.com/inter.woff2", "body"],
["f", "Playfair Display", "https://example.com/playfair.woff2", "title"],
["bg", "url https://example.com/bg.jpg", "mode cover", "m image/jpeg"],
["title", "MK Dark Theme"],
["alt", "Active profile theme"]
]
}
```
Legacy encrypted direct messages. Content is encrypted with AES-256-CBC using a shared secret derived from the sender's private key and recipient's public key. The recipient is identified by a `p` tag.
### Content
Used for backward compatibility with older Nostr clients that do not support NIP-17.
The `content` field is unused and MUST be an empty string (`""`).
### NIP-17 (Private Direct Messages)
### Tags
| Field | Value |
|-------|-------|
| Kinds | 1059 (Gift Wrap), 1060 (Seal) |
| Spec | [NIP-17](https://github.com/nostr-protocol/nips/blob/master/17.md) |
| Tag | Required | Description |
|---------|----------|---------------------------------------------------------------------------------------|
| `c` | Yes (×3) | Hex color with marker. See [Color Tags](#color-tags). |
| `f` | No | Font declaration. See [Font Tag](#font-tag). |
| `bg` | No | Background media. See [Background Tag](#background-tag). |
| `title` | No | Human-readable name for the theme |
| `alt` | Yes | NIP-31 human-readable fallback |
Modern private direct messages using the Gift Wrap protocol. Messages are triple-layered:
### Client Behavior
1. **Rumor** (kind 14) — unsigned plaintext message
2. **Seal** (kind 13) — rumor encrypted to the recipient, signed by the sender
3. **Gift Wrap** (kind 1059) — seal encrypted to the recipient, signed by a random ephemeral key
- When visiting a profile, clients query `{ kinds: [16767], authors: [pubkey], limit: 1 }` to get the active theme.
- Clients read the `c` tags to extract colors, `f` tags for fonts, and `bg` tag for the background.
- Setting a new active theme publishes a new kind 16767 event (replacing the old one).
- To remove the active theme, publish a kind 5 deletion event targeting kind 16767.
This provides metadata protection: relays and observers cannot determine the sender, recipient, or content. The application uses NIP-17 as the default send protocol, with optional NIP-04 compatibility for older clients.
---
### Protocol Configuration
## Shared Tag Definitions
Users can configure their preferred send protocol via Settings > Messages:
The following tag definitions apply to both kind 36767 and kind 16767.
### Color Tags
Format: `["c", "#rrggbb", "<marker>"]`
| Index | Required | Description |
|-------|----------|-----------------------------------------------------------------------------------------------|
| 0 | Yes | Tag name: `"c"` |
| 1 | Yes | Lowercase 6-digit hex color code including the `#` sign (e.g. `"#ff0000"`) |
| 2 | Yes | Color role marker: one of `"primary"`, `"text"`, or `"background"` |
- All three markers (`"primary"`, `"text"`, `"background"`) MUST be present.
- Only one `c` tag per marker is allowed.
### Font Tag
Format: `["f", "<family>", "<url>", "<role>"]`
| Index | Required | Description |
|-------|----------|-----------------------------------------------------------------------------------------------|
| 0 | Yes | Tag name: `"f"` |
| 1 | Yes | CSS `font-family` name (e.g. `"Inter"`) |
| 2 | Yes | Direct URL to a font file (`.woff2`, `.ttf`, `.otf`) |
| 3 | Yes | Font role: `"body"` or `"title"` |
**Roles:**
| Role | Applies to |
|-----------|--------------------------------------------------|
| `"body"` | All text globally (body, headings, UI elements) |
| `"title"` | The user's profile display name |
**Rules:**
- The `f` tag is optional on the event.
- At most one `f` tag per role is allowed (i.e. one body font and one title font).
- The `"body"` font tag MUST be ordered before the `"title"` font tag. This ensures backward-compatible clients that only read the first `f` tag will pick up the body font.
- If the URL fails to load, the client SHOULD fall back to a default font gracefully.
- Clients that do not recognize a role SHOULD ignore that `f` tag.
- Legacy events with an `f` tag that has no role marker (only 3 elements) SHOULD be treated as `"body"`.
- Variable font files (covering multiple weights in a single file) are preferred.
### Background Tag
The `bg` tag uses an `imeta`-style variadic format where each entry (after the tag name) is a space-delimited key/value pair.
Format: `["bg", "url <url>", "mode <mode>", "m <mime-type>", ...]`
| Key | Required | Description |
|-------------|----------|------------------------------------------------------------------------------------------|
| `url` | Yes | URL to an image or video file |
| `mode` | Yes | Display mode: `"cover"` or `"tile"` |
| `m` | Yes | MIME type (e.g. `"image/jpeg"`, `"image/png"`, `"video/mp4"`) |
| `dim` | No | Dimensions in pixels: `"<width>x<height>"` (e.g. `"1920x1080"`) |
| `blurhash` | No | Blurhash placeholder string for progressive loading |
- At most one `bg` tag is allowed per event.
- Clients MAY choose not to render video backgrounds for performance or bandwidth reasons.
- Unknown keys SHOULD be ignored for forward compatibility.
- **NIP-17 only** (default) — maximum privacy, only modern clients can read
- **NIP-04 + NIP-17** — sends via both protocols for compatibility with legacy clients
---
@@ -268,6 +173,701 @@ After resolution (assuming `$follows` = `["pk1", "pk2"]`):
---
## Kind 36639: Activist Action
### Summary
Addressable event kind for publishing **activist actions** (called "challenges" internally for backwards compatibility). An action is a country-scoped task — take a photo, make art, gather information, or take direct action — with an optional sats bounty paid out via NIP-57 zaps to the best **submissions**.
Submissions are **NIP-22 comments** (kind 1111) authored under the action's coordinate, ranked by zap totals. There is no separate submission kind; an earlier draft (kind 36640) was deprecated in favor of NIP-22 reuse.
### Trust model
Anyone can publish a kind 36639 event, but clients SHOULD only display actions whose author is either:
1. A platform-level admin (see `src/lib/admins.ts`), or
2. An organizer for the action's country (see kind 30078 `agora-organizers`).
This authorization model is identical to the per-country pin model — see Kind 30078 in this document for the storage shape.
### Event Structure
```json
{
"kind": 36639,
"content": "<long-form description, freeform markdown-ish text>",
"tags": [
["d", "plant-a-tree-1729000000000"],
["title", "Plant a tree in your neighborhood"],
["challenge-type", "photo"],
["bounty", "10000"],
["i", "iso3166:US"],
["t", "agora-action"],
["image", "https://example.com/cover.jpg"],
["start", "1729000000"],
["deadline", "1729604800"],
["alt", "Agora activist action: Plant a tree in your neighborhood"]
]
}
```
### Tags
| Tag | Required | Description |
|------------------|----------|----------------------------------------------------------------------------------------------------------|
| `d` | Yes | Unique identifier (typically slug + timestamp). Forms the addressable coordinate `36639:<pubkey>:<d>`. |
| `title` | Yes | Short title shown on cards. |
| `challenge-type` | Yes | One of `photo`, `art`, `info`, `action`. Drives the display icon and submission expectations. |
| `bounty` | Yes | Bounty in **sats**, as an unsigned integer string. Paid out via zaps to the chosen submission(s). |
| `i` | Yes | NIP-73 country identifier: `iso3166:XX` (preferred). Legacy `geo:XX` (length 6, country code only) is accepted as a read alias. Optionally combined with a `location` tag fallback. |
| `t` | Yes | Discovery tag. Canonical write value is `agora-action`. Read aliases: `pathos-challenge`, `agora-challenge`. |
| `image` | No | Cover image URL. |
| `start` | No | Unix timestamp when the action becomes active. Defaults to `created_at`. |
| `deadline` | No | Unix timestamp when the action expires. Defaults to `start + 48h`. |
| `alt` | Yes | NIP-31 human-readable fallback. Convention: `"Agora activist action: <title>"`. |
### Content
Long-form description of the action. Plain text or light markdown. Clients render this as the action's body on the detail page.
### Submissions
Submissions are kind 1111 NIP-22 comments addressed to the action's coordinate (`["A", "36639:<pubkey>:<d>"]` and `["P", "<pubkey>"]`). Clients SHOULD:
- Sort top-level submissions by **total zap amount** (sum of NIP-57 zap receipts on each submission), descending.
- Show the bounty as the prize pool that organizers can distribute to top submissions via zaps.
- Hide submissions with `created_at` after the action's `deadline` for "past" leaderboards (or surface them separately as "late submissions").
### Discovery
Clients querying actions globally:
```json
{ "kinds": [36639], "#t": ["agora-action", "pathos-challenge", "agora-challenge"], "limit": 50 }
```
Per country:
```json
{
"kinds": [36639],
"#t": ["agora-action", "pathos-challenge", "agora-challenge"],
"#i": ["iso3166:US", "geo:US"],
"limit": 50
}
```
After fetching, clients MUST filter the results down to events whose author is either an admin or an organizer for the event's country.
---
## Kind 30385: Community Stats Snapshot
### Summary
Addressable event kind for **pre-computed community statistics** (per-country and global). A trusted off-app indexer (the "stats bot") publishes one event per scope:
- **Per-country**: `d` tag is `iso3166:XX` (ISO 3166-1 alpha-2 country code).
- **Global**: `d` tag is `iso3166:ZZ``ZZ` is the ISO 3166-1 user-assigned code Agora uses for the cross-country aggregate.
Each event contains aggregate counts (comments, authors, zaps, submissions) and ranked leaderboards (top posters, trending hashtags, top zapped authors, top donors, top actions) across multiple time windows (`7d`, `30d`, `90d`, all-time). Storing pre-computed leaderboards in a single event lets clients render community pages without scanning thousands of underlying events.
### Trust model
Anyone can publish kind 30385, but clients MUST only consume events from trusted authors:
- **Per-country events**: trusted authors are platform admins (`src/lib/admins.ts`) **plus** appointed organizers for that specific country (kind 30078 `agora-organizers`).
- **Global event** (`iso3166:ZZ`): trusted authors are platform admins only.
When multiple trusted events exist for the same scope, clients pick the most recent by `created_at`.
### Event Structure
```json
{
"kind": 30385,
"content": "",
"tags": [
["d", "iso3166:US"],
["comment_cnt", "12345"],
["comment_cnt_7d", "789"],
["comment_cnt_30d", "3421"],
["comment_cnt_90d", "9876"],
["author_cnt", "543"],
["zap_amount", "123456789"],
["zap_cnt", "1234"],
["submission_cnt", "456"],
["top_poster", "<pubkey-hex>", "987"],
["top_poster_7d", "<pubkey-hex>", "42"],
["trending_hashtag", "climate", "321"],
["trending_hashtag_7d", "protest", "67"],
["top_zapped", "<pubkey-hex>", "<totalSats>", "<postCount>", "<avgSats>", "<zapCount>"],
["top_donor", "<pubkey-hex>", "<totalSats>", "<zapCount>"],
["top_action", "36639:<pubkey>:<d-tag>", "Plant a tree", "<submissions>", "<bounty>", "<zapAmountSats>"]
]
}
```
### Tag families
All numeric values are unsigned integers serialised as base-10 strings.
#### Aggregate counts (one tag per metric per timeframe)
| Tag base name | Meaning |
|--------------------|--------------------------------------------------------|
| `comment_cnt` | Number of NIP-22 comments in scope |
| `author_cnt` | Distinct author pubkeys in scope |
| `zap_amount` | Total zap amount in **sats** |
| `zap_cnt` | Number of NIP-57 zap receipts |
| `submission_cnt` | Submissions to activist actions (kind 36639) |
Each is published as four tags: bare (`<base>`, all-time), `<base>_7d`, `<base>_30d`, `<base>_90d`.
#### Leaderboards (repeated; one tag per row, ordered by rank)
All-time variants use the bare tag name; windowed variants use the `_7d`, `_30d`, `_90d` suffixes.
| Tag base name | Positional fields |
|----------------------|-----------------------------------------------------------------------------------------------|
| `top_poster` | `[name, pubkeyHex, count]` |
| `trending_hashtag` | `[name, hashtag, count]` |
| `top_zapped` | `[name, pubkeyHex, totalSats, postCount, avgSats, zapCount]` (`zapCount` optional, legacy) |
| `top_donor` | `[name, pubkeyHex, totalSats, zapCount]` |
| `top_action` | `[name, "36639:<pubkey>:<d>", title, submissions, bounty, zapAmountSats]` |
Clients SHOULD parse defensively — accept missing trailing fields as `0` or omitted to maintain backwards compatibility as the schema evolves.
### Content
Empty string. All data lives in tags so relays can index/filter and clients don't need to parse JSON.
### Discovery
Per-country snapshot:
```json
{
"kinds": [30385],
"authors": [<admin and organizer pubkeys>],
"#d": ["iso3166:US"],
"limit": 10
}
```
Global snapshot:
```json
{
"kinds": [30385],
"authors": [<admin pubkeys>],
"#d": ["iso3166:ZZ"],
"limit": 10
}
```
After fetching, take the event with the highest `created_at` and parse it. Cache for ~12 minutes; the producer typically refreshes on a similar cadence.
---
## Kinds 20000 / 20001: Ephemeral Geo Chat
### Summary
Ephemeral events used to power realtime location-anchored chat on the world map. Both kinds live in NIP-01's ephemeral range (`20000 ≤ kind < 30000`), so relays MUST NOT persist them — they are short-lived signals only.
- **Kind 20000** — public chat message. The `content` field carries the message text.
- **Kind 20001** — presence "heartbeat". Same tag schema, but `content` MAY be empty (the event simply broadcasts that someone is listening at the geohash).
This kind range is shared with the wider Bitchat / geo-chat ecosystem; Agora interoperates with Pathos and other clients producing the same shape.
### Tags
| Tag | Required | Purpose |
|-----|----------|-------------------------------------------------------------------------|
| `g` | Yes | Geohash anchoring the message. Any precision is allowed; the dialog filters by exact-match `g` value, while the map clusters by full geohash. |
| `n` | No | Display nickname (≤ 16 chars after client-side truncation). Anonymous senders pick a random "ghost" handle; logged-in senders may use their account display name. |
Events without a `g` tag MUST be ignored — they cannot be plotted.
### Identity
There are two valid signing paths:
1. **Real identity** — a logged-in user signs with their existing Nostr key (typically via NIP-07 / NIP-46). Other clients can correlate the chat message with the author's public profile.
2. **Ephemeral "ghost" identity** — the client generates a fresh in-memory keypair (never persisted) and signs locally. Only the chosen `n` nickname is persisted (in `localStorage`) so the user keeps a stable handle even though the pubkey rotates per session.
Clients SHOULD let logged-in users toggle between modes per-session and SHOULD default to the ghost mode when no account is available.
### Relay Routing
Because ephemeral events are not stored, latency dominates the experience. Clients SHOULD:
1. Always include a baseline of widely-reachable relays (`wss://nos.lol`, `wss://relay.damus.io`, `wss://relay.primal.net`).
2. Augment with geo-located relays drawn from the [permissionlesstech/georelays](https://github.com/permissionlesstech/georelays) CSV catalogue (`relayUrl,latitude,longitude` per line).
3. For a specific geohash conversation, prefer the relays nearest the decoded coordinates (Haversine distance, top-N).
4. For the global map heatmap, take a rotating window (e.g. 8 relays, rotated every 5 minutes) so coverage spreads without saturating any single relay.
### Time Window
Clients SHOULD only surface events from the last hour (`since = now - 3600`). Older ephemeral events are uninteresting for "what's happening right now" and most relays will have dropped them anyway.
### Example
```json
{
"kind": 20000,
"created_at": 1734567890,
"pubkey": "...",
"tags": [
["g", "u4pruydqqvj"],
["n", "stealthranger4242"]
],
"content": "anyone in berlin tonight?",
"sig": "..."
}
```
---
## Flat Communities
Flat communities on Nostr, composed from existing event kinds. Communities have one membership badge, explicit moderators, and no recursive badge-chain authority.
This specification is intended to be a foundation for community-scoped features. A community is a kind `34550` root that other events can tag with uppercase `A`. Posts, events, polls, listings, and future content kinds can all participate in the same community model when they tag the community root and pass the membership and moderation rules below.
The initial implementation focuses on three foundation capabilities:
1. Viewing communities a user owns or belongs to.
2. Posting community-scoped discussion content.
3. Moderating community-scoped content and members within communities the viewer has authority over.
**No new event kinds are introduced.** The system composes:
- **Kind 34550** ([NIP-72](https://github.com/nostr-protocol/nips/blob/master/72.md)) -- Community Definition
- **Kind 30009** ([NIP-58](https://github.com/nostr-protocol/nips/blob/master/58.md)) -- Badge Definition
- **Kind 8** ([NIP-58](https://github.com/nostr-protocol/nips/blob/master/58.md)) -- Badge Award
- **Kind 1111** ([NIP-22](https://github.com/nostr-protocol/nips/blob/master/22.md)) -- Community Posts
- **Kind 1984** ([NIP-56](https://github.com/nostr-protocol/nips/blob/master/56.md)) -- Moderation
### Overview
A flat community consists of:
1. **One badge definition** (kind `30009`) that represents community membership.
2. A **community definition** (kind `34550`) referencing that member badge with the role marker `"member"`.
3. **Badge awards** (kind `8`) authored by the founder or current moderators, granting membership directly.
4. **Community-scoped content** (initially kind `1111`) tagged to the community root.
5. **Reports and bans** (kind `1984`) scoped to the community for content warnings, content removal, and member/non-member bans.
Parent, child, sister, and rank relationships are intentionally out of scope for the core permission model. Apps may build discovery or directory surfaces separately.
### Membership Derivation
Membership is sourced from the community definition and from validated kind `8` membership awards. This produces three populations:
- **Founder** -- the `pubkey` field on the kind `34550` event. One per community, immutable. Controls the community definition since only they can republish the addressable event.
- **Moderators** -- the `p` tags on the kind `34550` event with role `"moderator"` (matching [NIP-72](https://github.com/nostr-protocol/nips/blob/master/72.md)). Mutable by republishing the community definition.
- **Members** -- pubkeys named in `p` tags on kind `8` badge awards that reference the community's member badge and are authored by the founder or a current moderator.
The founder and moderators have no membership badge requirement. Their leadership status comes from the community definition itself. Members cannot grant membership to other members.
### Community Definition
A kind `34550` event defines the community, extending [NIP-72](https://github.com/nostr-protocol/nips/blob/master/72.md) with one badge `a` tag that identifies the member badge.
#### Tags
| Tag | Required | Description |
|-----|----------|-------------|
| `d` | Yes | Unique community identifier (UUID recommended). |
| `name` | Yes | Human-readable name. |
| `description` | No | Community description. |
| `image` | No | Image URL. |
| `a` | Yes (1) | Member badge definition reference with role marker `"member"`. |
| `p` | No | Moderator pubkeys. The 4th element SHOULD be `"moderator"`. |
| `relay` | No | Recommended relay URL for community content (per [NIP-72](https://github.com/nostr-protocol/nips/blob/master/72.md)). |
| `alt` | No | [NIP-31](https://github.com/nostr-protocol/nips/blob/master/31.md) description. |
#### Badge `a` Tag Format
```
["a", "30009:<pubkey>:<badge-d-tag>", "<relay-hint>", "member"]
```
The fourth element is a strict protocol marker, not a display label. Communities can still use the badge definition's `name`, `description`, and `image` tags for expressive member labels.
#### Example
```jsonc
{
"kind": 34550,
"pubkey": "<founder-pubkey>",
"content": "",
"tags": [
["d", "a1b2c3d4-e5f6-7890-abcd-ef1234567890"],
["name", "The Arbiter's Guard"],
["description", "Elite Halo 2 clan"],
["image", "https://example.com/clan-banner.jpg"],
["a", "30009:<founder-pubkey>:a1b2c3d4-...-member", "", "member"],
["p", "<co-moderator-pubkey>", "", "moderator"],
["relay", "wss://relay.example.com"],
["alt", "Community: The Arbiter's Guard"]
]
}
```
### Badge Definitions
The member badge is a standard [NIP-58](https://github.com/nostr-protocol/nips/blob/master/58.md) kind `30009` badge definition published by the founder. The badge definition SHOULD be published **before** the community definition that references it.
The `d` tag SHOULD use the format `<community-d-tag>-member` for global uniqueness.
```jsonc
{
"kind": 30009,
"pubkey": "<founder-pubkey>",
"content": "",
"tags": [
["d", "a1b2c3d4-...-member"],
["name", "Member"],
["description", "Member of The Arbiter's Guard"],
["image", "https://example.com/member-badge.png"],
["alt", "Badge definition: Member of The Arbiter's Guard"]
]
}
```
### Badge Awards
Membership is established through kind `8` badge awards ([NIP-58](https://github.com/nostr-protocol/nips/blob/master/58.md)). Each valid award grants membership directly.
A badge award is **valid** if and only if:
1. The `a` tag references the member badge listed in the community definition.
2. The award author is the founder or a moderator listed in the community definition currently being evaluated.
3. The award contains at least one `p` tag naming an awarded pubkey.
```jsonc
// Moderator awarding community membership
{
"kind": 8,
"pubkey": "<moderator-pubkey>",
"content": "",
"tags": [
["a", "30009:<founder-pubkey>:a1b2c3d4-...-member"],
["p", "<recipient-pubkey>"],
["alt", "Badge award: Staff in The Arbiter's Guard"]
]
}
```
### Membership Validation
Membership is resolved with indexed relay filters. There is no recursive authority graph.
#### Algorithm
1. Fetch the community definition using kind `34550`, the founder pubkey, and the community `d` tag.
2. Extract the founder pubkey, moderator pubkeys, and member badge coordinate.
3. Query awards: `{ kinds: [8], authors: [<founder>, ...<moderators>], #a: [<member-badge-coordinate>] }`.
4. Flatten `p` tags from matching awards.
5. The member set is the union of the founder, current moderators, and awarded pubkeys.
6. Resolve moderation and apply moderation overlays.
The `authors` filter is the primary membership-award trust boundary. Awards from non-founder, non-moderator pubkeys are not valid community membership awards.
### Community-Scoped Content
Community-scoped content is any event that tags the community definition with uppercase `A`. The foundation implementation starts with kind `1111` ([NIP-22](https://github.com/nostr-protocol/nips/blob/master/22.md)) posts, but the same moderation overlay applies to future community content kinds such as calendar events, polls, listings, or other domain-specific events.
Clients MAY offer a members-only view that filters community posts down to the resolved member set as an `authors` filter. Whether this is on by default, opt-in, or omitted entirely is a client UX choice -- the protocol makes no recommendation.
#### Community Post
Community discussion uses kind `1111` scoped to the community definition as the root event.
#### Top-Level Post
```jsonc
{
"kind": 1111,
"content": "Hello clan!",
"tags": [
["A", "34550:<founder-pubkey>:<community-d-tag>", "<relay-hint>"],
["K", "34550"],
["P", "<founder-pubkey>", "<relay-hint>"],
["a", "34550:<founder-pubkey>:<community-d-tag>", "<relay-hint>"],
["k", "34550"],
["p", "<founder-pubkey>", "<relay-hint>"]
]
}
```
#### Reply
Replies keep the community as root scope and point to the parent comment:
```jsonc
{
"kind": 1111,
"content": "Great point!",
"tags": [
["A", "34550:<founder-pubkey>:<community-d-tag>", "<relay-hint>"],
["K", "34550"],
["P", "<founder-pubkey>", "<relay-hint>"],
["e", "<parent-comment-id>", "<relay-hint>", "<parent-author-pubkey>"],
["k", "1111"],
["p", "<parent-author-pubkey>", "<relay-hint>"]
]
}
```
#### Querying
Clients MAY use the resolved member set as an `authors` filter for members-only views.
```jsonc
{
"kinds": [1111],
"#A": ["34550:<founder-pubkey>:<community-d-tag>"],
"authors": ["<founder>", "<moderator>", "<member>"]
}
```
The moderation overlay is content-kind agnostic: a valid content ban or warning applies to the targeted event regardless of whether that event is a post, calendar event, poll, listing, or future supported kind.
### Moderation
Moderation uses kind `1984` ([NIP-56](https://github.com/nostr-protocol/nips/blob/master/56.md)) scoped to the community via the uppercase `A` tag. Moderation is derived state: clients first resolve trusted moderation actions from kind `1984`, then apply those actions to concrete community-scoped events.
There are two moderation event classes:
1. **Bans** -- authoritative actions that remove content or ban users. Identified by the presence of [NIP-32](https://github.com/nostr-protocol/nips/blob/master/32.md) label tags `["L", "moderation"]` and `["l", "ban", "moderation"]`.
2. **Reports** -- soft flags from any valid community member using standard [NIP-56](https://github.com/nostr-protocol/nips/blob/master/56.md) report types (`nudity`, `spam`, `profanity`, `illegal`, `malware`, `impersonation`, `other`). No `L`/`l` tags. Clients display a content warning that users must click through to reveal.
Kind `1984` events from **non-members** are ignored entirely within community context. Kind `1984` events from members who are themselves banned are also ignored after ban resolution; banned members cannot retain moderation or reporting authority.
#### Bans (Authoritative Moderation)
A ban is **authoritative** if and only if:
1. The event contains `["l", "ban", "moderation"]` and `["L", "moderation"]` tags.
2. The publisher is a validated community member.
3. The publisher is not themselves banned after ban resolution.
4. The publisher's authority covers the target:
- founder/moderators may ban member and non-member authors/content;
- members may ban only non-member authors/content.
Bans that fail any of these conditions MUST be ignored.
##### Content Ban
Ban a specific post by publishing kind `1984` with `e`, `p`, and `A` tags plus the `ban` label. The `e` and `p` tags use `"other"` as the NIP-56 report type since the action is administrative rather than categorical.
```jsonc
{
"kind": 1984,
"pubkey": "<moderator-pubkey>",
"content": "Reason for removal",
"tags": [
["e", "<offending-event-id>", "other"],
["p", "<offending-author-pubkey>", "other"],
["A", "34550:<founder-pubkey>:<community-d-tag>"],
["L", "moderation"],
["l", "ban", "moderation"]
]
}
```
Clients MUST omit the banned event from canonical community feeds entirely. The event is not displayed, blurred, or indicated in any way -- it is treated as if it does not exist.
The `e` and `p` tags are untrusted until matched against the actual target event. A content ban MUST only apply when the targeted event's `id` matches the ban's `e` tag and the targeted event's `pubkey` matches the ban's `p` tag. This prevents a malicious or mistaken report from hiding an event by pairing its event ID with a different target pubkey.
##### Member Ban
Ban an author by publishing kind `1984` with `p` and `A` tags only (no `e` tag) plus the `ban` label. Founder/moderator-authored bans may target members or non-members. Member-authored bans may target non-members only.
```jsonc
{
"kind": 1984,
"pubkey": "<moderator-pubkey>",
"content": "Reason for ban",
"tags": [
["p", "<banned-pubkey>", "other"],
["A", "34550:<founder-pubkey>:<community-d-tag>"],
["L", "moderation"],
["l", "ban", "moderation"]
]
}
```
Clients distinguish content bans (`e` + `p` + `A` + `ban` label) from member bans (`p` + `A` + `ban` label, no `e` tag).
#### Reports (Content Warnings)
Any **valid, non-banned community member** may report content by publishing kind `1984` with a standard NIP-56 report type on the `e` and `p` tags. Reports do NOT use `L`/`l` label tags.
```jsonc
{
"kind": 1984,
"pubkey": "<member-pubkey>",
"content": "Additional context",
"tags": [
["e", "<event-id>", "nudity"],
["p", "<author-pubkey>", "nudity"],
["A", "34550:<founder-pubkey>:<community-d-tag>"]
]
}
```
Clients SHOULD display reported content behind a content warning overlay that requires user interaction to reveal. The report type (e.g. `nudity`, `spam`) MAY be shown in the warning. Multiple reports on the same event reinforce the warning but do not automatically escalate to a ban.
Reports from non-members and banned members are ignored.
As with content bans, report warnings MUST only attach to content when the target event's `id` matches the report's `e` tag and the target event's `pubkey` matches the report's `p` tag.
#### Classification Summary
| `l` tag present? | `e` tag present? | Authority check | Result |
|---|---|---|---|
| `["l", "ban", "moderation"]` | Yes | Founder/moderator, or member targeting non-member content; `e`/`p` match target event | Content ban (omit event) |
| `["l", "ban", "moderation"]` | No | Founder/moderator, or member targeting non-member author | Author ban |
| No | Yes | Non-banned member; `e`/`p` match target event | Content warning |
| No | No | -- | Invalid (ignored) |
| Any | Any | Non-member | Ignored |
| Any | Any | Banned member | Ignored |
### Community Updates
Both kind `34550` and kind `30009` are addressable events. To change the member badge or update moderators, republish the community definition. Only the founder (event publisher) can republish the community definition. If a moderator is removed, their authored membership awards no longer count because they are excluded from the authorized awarder query.
### Discovery
**Communities founded by a user:**
```jsonc
{ "kinds": [34550], "authors": ["<user-pubkey>"] }
```
**Communities a user belongs to:**
1. `{ "kinds": [8], "#p": ["<user-pubkey>"] }`
2. Extract badge `a` tags from results.
3. `{ "kinds": [34550], "#a": ["30009:...", "..."] }`
4. Keep only communities whose `member` badge reference matches the award badge coordinate.
**Communities a user has bookmarked:**
Agora uses [NIP-51](https://github.com/nostr-protocol/nips/blob/master/51.md) kind `10004` ("Communities") to let users save communities they want quick access to without requiring membership. Bookmarked communities are surfaced in the "My Communities" view alongside founded and member-of communities.
1. `{ "kinds": [10004], "authors": ["<user-pubkey>"], "limit": 1 }`
2. Extract `a` tags whose value begins with `34550:` from the result.
3. For each coordinate `34550:<author-pubkey>:<d-tag>`, query the community definition with both `authors` and `#d` filters to prevent spoofing:
```jsonc
{ "kinds": [34550], "authors": ["<author-pubkey>"], "#d": ["<d-tag>"], "limit": 1 }
```
Clients toggling a bookmark MUST perform a read-modify-write cycle on the replaceable kind `10004` event: fetch the freshest version from relays, add or remove the matching `["a", "34550:<pubkey>:<d-tag>"]` tag, and republish the full tag list. Appending new entries to the end preserves chronological bookmark order per NIP-51.
When the same community appears in multiple discovery sources, clients SHOULD display a single card but MAY indicate all applicable relationships (e.g. a member who has also bookmarked a community).
### Security Considerations
- **Author filtering**: Clients MUST filter community definitions by `authors` to prevent impersonation.
- **Award author filtering is required**: Query member badge awards with `authors: [founder, ...moderators]`.
- **Badge d-tag uniqueness**: Use `<community-d-tag>-member` to prevent cross-community collisions.
- **Badge acceptance is cosmetic**: NIP-58 kind `10008`/`30008` events have no effect on community membership.
### Dependencies
- [NIP-22](https://github.com/nostr-protocol/nips/blob/master/22.md) -- Comment
- [NIP-31](https://github.com/nostr-protocol/nips/blob/master/31.md) -- Unknown Event Kinds (`alt` tag)
- [NIP-32](https://github.com/nostr-protocol/nips/blob/master/32.md) -- Labeling (moderation `ban` label)
- [NIP-51](https://github.com/nostr-protocol/nips/blob/master/51.md) -- Lists (kind `10004` Communities list for bookmarks)
- [NIP-56](https://github.com/nostr-protocol/nips/blob/master/56.md) -- Reporting
- [NIP-58](https://github.com/nostr-protocol/nips/blob/master/58.md) -- Badges
- [NIP-72](https://github.com/nostr-protocol/nips/blob/master/72.md) -- Moderated Communities
---
## Community Fundraising Goals (NIP-75)
### Summary
Communities can host fundraising campaigns using [NIP-75 Zap Goals](https://github.com/nostr-protocol/nips/blob/master/75.md) (kind `9041`). A zap goal linked to a community allows members and supporters to contribute sats toward a shared target.
### Linking Goals to Communities
A zap goal is linked to a community by including an `a` tag pointing to the community's kind `34550` definition:
```json
{
"kind": 9041,
"content": "Community meetup travel fund",
"tags": [
["amount", "500000000"],
["relays", "wss://relay.ditto.pub", "wss://relay.primal.net"],
["a", "34550:<community-author-pubkey>:<community-d-identifier>"],
["alt", "Zap goal: Community meetup travel fund"],
["summary", "Help fund travel for our annual meetup"],
["image", "https://example.com/meetup.jpg"],
["closed_at", "1735689600"]
]
}
```
### Required Tags (per NIP-75)
- `amount` -- Target amount in millisatoshis
- `relays` -- Relay URLs where zaps should be sent and tallied from
### Optional Tags (per NIP-75)
- `closed_at` -- Unix timestamp deadline; zap receipts after this time are excluded from the tally
- `image` -- Image URL for the goal
- `summary` -- Brief description
### Additional Tags (Agora-specific)
- `a` -- Community link (`34550:<pubkey>:<d-tag>`) scoping the goal to a community
- `alt` -- NIP-31 human-readable description
### Querying
Community goals are queried by filtering on the `a` tag:
```
{ "kinds": [9041], "#a": ["34550:<pubkey>:<d-tag>"], "limit": 50 }
```
### Progress Tallying
Goal progress is calculated from kind `9735` zap receipts targeting the goal event:
```
{ "kinds": [9735], "#e": ["<goal-event-id>"], "limit": 500 }
```
Receipts with `created_at` after the `closed_at` deadline (if set) are excluded from the tally.
### Access Control
Anyone may create a zap goal linked to a community. The existing community members-only feed filter controls whether non-member goals are displayed. Anyone may zap a goal.
### Dependencies
- [NIP-57](https://github.com/nostr-protocol/nips/blob/master/57.md) -- Lightning Zaps
- [NIP-72](https://github.com/nostr-protocol/nips/blob/master/72.md) -- Moderated Communities
- [NIP-75](https://github.com/nostr-protocol/nips/blob/master/75.md) -- Zap Goals
---
## Kind 0 Extension: Avatar Shape
### Summary
@@ -293,3 +893,41 @@ The `shape` field is added to the JSON content of a kind 0 event alongside stand
- The `shape` field is purely cosmetic and has no protocol-level significance.
- Clients MAY choose not to support this extension, in which case avatars render as circles as usual.
---
## Community NIP Specifications
The following specifications are maintained by their respective authors. Ditto implements these kinds but does not own the specs. See each link for the full event structure, tags, and client behavior.
### Color Moments (Kind 3367)
**Author:** Chad Curtis
**Spec:** https://gitlab.com/chad.curtis/espy/-/blob/main/NIP.md
**App:** https://espy.you
Color palette posts capturing 3-6 colors from a beautiful moment, optionally accompanied by an emoji and layout preference. Supports horizontal, vertical, grid, star, checkerboard, and diagonal stripe layouts. A form of pre-verbal visual communication through color and emotion.
### Geocaching (Kinds 37516, 7516)
**Author:** Chad Curtis
**Spec:** https://gitlab.com/chad.curtis/treasures/-/blob/main/NIP-GC.md
**App:** https://treasures.to
NIP-GC defines geocaching on Nostr. Kind 37516 (addressable) is a geocache listing with location (geohash), difficulty/terrain scores, size, and type. Kind 7516 is a found log recording a successful visit. The spec also covers comment logs (kind 1111 via NIP-22), verified finds with cryptographic proof (kind 7517), and cache retirement.
### Personal Letters (Kind 8211)
**Author:** Chad Curtis
**Spec:** https://gitlab.com/chad.curtis/lief/-/blob/main/NIP.md
**App:** https://lief.to
NIP-44 encrypted personal letters with visual stationery, hand-drawn stickers, decorative frames, and custom fonts. Letters render as 5:4 landscape postcards. The privacy model is intentionally postcard-like: sender/recipient metadata is visible, content is encrypted.
### Weather Station (Kinds 4223, 16158)
**Author:** Sam Thomson
**Spec:** https://github.com/nostr-protocol/nips/pull/2163
**App:** https://weather.shakespeare.wtf
**Firmware:** https://github.com/samthomson/weather-station
Kind 16158 (replaceable) describes a weather station's configuration: name, geohash location, elevation, power source, connectivity, and sensor inventory. Kind 4223 (regular) carries individual sensor readings as 3-parameter tags `[sensor_type, value, model]`, enabling historical queries and cross-station comparison. Each station has its own keypair.
+76 -62
View File
@@ -1,24 +1,25 @@
# Ditto
# Agora
Your content. Your vibe. Your rules. A fun, customizable [Nostr](https://nostr.com/) client that puts you in control.
Power to the people.
**[ditto.pub](https://ditto.pub)** | **[Docs](https://docs.ditto.pub)** | **[Source](https://gitlab.com/soapbox-pub/ditto)**
Agora is a Nostr client focused on community ownership, expressive identity, and censorship resistance. This repository (`agora-3`) is the Agora-branded app built from the Ditto codebase.
## About
**[agora.spot](https://agora.spot)** | **[Source](https://gitlab.com/soapbox-pub/agora-3)**
Ditto is an open-source, decentralized social media client built on the Nostr protocol. It's designed for people who want to have fun online without feeding the Big Tech machine. Express yourself with custom themes, Lightning payments, and an ever-growing set of content types -- all while owning your identity and data.
## What This Repo Is
Made by [Soapbox](https://soapbox.pub).
- Agora product identity (name, theme, assets, native IDs)
- Ditto-derived implementation with broad Nostr feature coverage
- Configurable deployment defaults via `agora.json`
## Features
- **Theming** -- 9 built-in theme presets, 19 CSS token properties for full customization, and the ability to publish and share themes as Nostr events
- **Infinite Content Types** -- Text notes, articles, short-form videos (Vines), live streams, polls, follow packs, color moments, magic decks, geocaching, and Webxdc mini-apps
- **Lightning Payments** -- Zap posts and profiles with sats via Nostr Wallet Connect (NWC) or WebLN
- **Private Messaging** -- End-to-end encrypted DMs (NIP-04 and NIP-17)
- **Comments** -- Comment on anything: posts, URLs, profiles, hashtags, books, and more (NIP-22)
- **Self-Hosting** -- Builds to static HTML/JS/CSS. Deploy anywhere -- GitHub Pages, Netlify, Vercel, a VPS, or a Raspberry Pi
- **Mobile** -- Android native app via Capacitor, responsive design for all screen sizes
- **Community-first social client**: notes, articles, comments, reposts, reactions, and rich event rendering
- **Theming system**: built-in presets + custom color/font/background themes that can be shared as events
- **Lightning support**: zaps with Nostr Wallet Connect and WebLN
- **Private messaging**: NIP-04 and NIP-17 direct messages
- **Mobile app shell**: Capacitor-powered Android/iOS wrappers
- **Self-hostable**: static web build + configurable relay and upload infrastructure
## Getting Started
@@ -30,13 +31,43 @@ Made by [Soapbox](https://soapbox.pub).
### Development
```sh
git clone https://gitlab.com/soapbox-pub/ditto.git
cd ditto
git clone https://gitlab.com/soapbox-pub/agora-3.git
cd agora-3
npm install
npm run dev
```
The dev server starts at `http://localhost:8080`.
Development server: `http://localhost:8080`
### Docker Getting Started
Use Docker Compose when you want the nginx reverse-proxy stack (necessary if you want decryptable media in messages - kind 15s of NIP 17):
```sh
git clone https://gitlab.com/soapbox-pub/agora-3.git
cd agora-3
cp .env.example .env
docker compose up --build
```
Proxy URL: `http://localhost:8083`
This starts:
- `vite` service on the internal Docker network (`vite:8080`)
- `web` service (`nginx`) on host port `8082`, proxying to Vite with websocket support
Stop stack:
```sh
docker compose down
```
Production-style container build:
```sh
docker compose -f docker-compose.prod.yml up --build
```
### Build
@@ -44,66 +75,58 @@ The dev server starts at `http://localhost:8080`.
npm run build
```
The built site is output to `dist/`.
Build output: `dist/`
### Test
Runs type-checking, linting, unit tests, and a production build:
### Validate
```sh
npm test
```
This runs type-checking, linting, unit tests, and production build checks.
## Configuration
Ditto is configured through a `ditto.json` file at the project root, read at build time. This file is gitignored so each deployment can have its own configuration.
Build-time config is read from `agora.json` (gitignored by default so each deployment can provide its own values).
```jsonc
{
"theme": "dark",
"relayMetadata": {
"relays": [
{ "url": "wss://relay.ditto.pub", "read": true, "write": true }
{ "url": "wss://relay.ditto.pub", "read": true, "write": true },
{ "url": "wss://relay.primal.net", "read": true, "write": true },
{ "url": "wss://relay.damus.io", "read": true, "write": true }
]
},
"blossomServers": ["https://blossom.ditto.pub"],
"feedSettings": {
"showPosts": true,
"showReposts": true,
"showArticles": true
// ...and more content type toggles
}
"blossomServers": [
"https://blossom.ditto.pub",
"https://blossom.primal.net/"
]
}
```
Configuration is resolved in three layers (highest priority first):
Configuration priority (highest first):
1. **User settings** stored in localStorage
2. **Build config** from `ditto.json`
3. **Hardcoded defaults**
1. User settings (local storage)
2. Build config (`agora.json`)
3. Hardcoded app defaults
Use an alternate config file path with: `CONFIG_FILE=./my-config.json npm run build`
Use a custom config path:
### Custom Branding
For self-hosted instances:
- Replace `public/logo.svg` and `public/logo.png` with your logo
- Update the app name in `index.html` and `public/manifest.webmanifest`
- Replace `public/og-image.jpg` for social sharing previews
- Set default relays and upload servers in `ditto.json`
```sh
CONFIG_FILE=./my-config.json npm run build
```
## Deployment
Ditto builds to static files and can be deployed anywhere that serves HTML.
Agora builds to static files and can be deployed to any static host.
- **GitHub Pages / GitLab Pages** -- Push to `main` and CI auto-deploys
- **Netlify / Vercel** -- Connect your fork and deploy. A `_redirects` file is included for SPA routing
- **VPS / Any web server** -- Build and copy `dist/` to your server. Configure SPA routing (e.g., Nginx `try_files $uri $uri/ /index.html`)
- GitLab/GitHub Pages
- Netlify/Vercel
- VPS or any web server with SPA routing fallback
### Android
Build a native Android app with [Capacitor](https://capacitorjs.com/):
For Android:
```sh
npm run build
@@ -114,29 +137,20 @@ npx cap open android
## Tech Stack
| Layer | Technology |
|---|---|
| --- | --- |
| Framework | React 18 |
| Build | Vite |
| Language | TypeScript |
| Styling | TailwindCSS 3 + shadcn/ui |
| Routing | React Router 6 |
| Routing | React Router |
| Data | TanStack Query |
| Nostr | Nostrify + nostr-tools |
| Mobile | Capacitor |
| Testing | Vitest + React Testing Library |
## Project Structure
## Contributing
```
src/
components/ UI components (100+), including shadcn/ui primitives
hooks/ Custom React hooks (65+)
pages/ Page components for each route (30+)
contexts/ React context providers
lib/ Utilities and shared logic
test/ Test setup and helpers
public/ Static assets, icons, manifest
```
Read [CONTRIBUTING.md](CONTRIBUTING.md) before opening a merge request.
## License
+6 -5
View File
@@ -7,14 +7,14 @@ if (keystorePropertiesFile.exists()) {
}
android {
namespace = "pub.ditto.app"
namespace = "pub.agora.app"
compileSdk = rootProject.ext.compileSdkVersion
defaultConfig {
applicationId "pub.ditto.app"
applicationId "pub.agora.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "2.0.0"
versionName "2.8.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
@@ -35,8 +35,9 @@ android {
buildTypes {
release {
signingConfig signingConfigs.release
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
}
+6 -1
View File
@@ -10,8 +10,13 @@ android {
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {
implementation project(':capacitor-app')
implementation project(':capacitor-filesystem')
implementation project(':capacitor-haptics')
implementation project(':capacitor-keyboard')
implementation project(':capacitor-local-notifications')
implementation project(':capacitor-status-bar')
implementation project(':capacitor-share')
implementation project(':capgo-capacitor-autofill-save-password')
implementation project(':capacitor-secure-storage-plugin')
}
+13 -6
View File
@@ -5,12 +5,19 @@
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Keep Capacitor classes (WebView JS bridge)
-keep class com.getcapacitor.** { *; }
-keep class pub.ditto.app.** { *; }
# Keep WebView JS interfaces
-keepclassmembers class * {
@android.webkit.JavascriptInterface <methods>;
}
# Keep OkHttp (used by Capacitor)
-dontwarn okhttp3.**
-dontwarn okio.**
-keep class okhttp3.** { *; }
# Uncomment this to preserve the line number information for
# debugging stack traces.
+4 -2
View File
@@ -3,6 +3,8 @@
<application
android:allowBackup="true"
android:fullBackupContent="@xml/backup_rules"
android:dataExtractionRules="@xml/data_extraction_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
@@ -22,12 +24,12 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Deep links: open ditto.pub URLs in the app -->
<!-- Deep links: open agora.spot URLs in the app -->
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="ditto.pub" />
<data android:scheme="https" android:host="agora.spot" />
</intent-filter>
</activity>
@@ -1,7 +1,10 @@
package pub.ditto.app;
import android.content.SharedPreferences;
import android.app.ForegroundServiceStartNotAllowedException;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.os.Build;
import android.util.Log;
import com.getcapacitor.Plugin;
@@ -14,6 +17,10 @@ import org.json.JSONArray;
/**
* Capacitor plugin that allows the JS layer to configure the native
* notification polling service with the user's pubkey and relay URLs.
*
* Supports two notification styles:
* - "push" (default): no foreground service, relies on push notifications
* - "persistent": starts NotificationRelayService as a foreground service
*/
@CapacitorPlugin(name = "DittoNotification")
public class DittoNotificationPlugin extends Plugin {
@@ -24,7 +31,10 @@ public class DittoNotificationPlugin extends Plugin {
@PluginMethod
public void configure(PluginCall call) {
String userPubkey = call.getString("userPubkey");
String notificationStyle = call.getString("notificationStyle", "push");
String relayUrlsRaw = null;
String enabledKindsRaw = null;
String authorsRaw = null;
try {
JSONArray relayUrls = call.getArray("relayUrls");
@@ -35,20 +45,80 @@ public class DittoNotificationPlugin extends Plugin {
Log.w(TAG, "Failed to read relayUrls", e);
}
try {
JSONArray enabledKinds = call.getArray("enabledKinds");
if (enabledKinds != null) {
enabledKindsRaw = enabledKinds.toString();
}
} catch (Exception e) {
Log.w(TAG, "Failed to read enabledKinds", e);
}
try {
JSONArray authors = call.getArray("authors");
if (authors != null) {
authorsRaw = authors.toString();
}
} catch (Exception e) {
Log.w(TAG, "Failed to read authors", e);
}
SharedPreferences prefs = getContext().getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
if (userPubkey != null && relayUrlsRaw != null) {
prefs.edit()
SharedPreferences.Editor editor = prefs.edit()
.putString("userPubkey", userPubkey)
.putString("relayUrls", relayUrlsRaw)
.apply();
Log.d(TAG, "Configured: pubkey=" + userPubkey.substring(0, 8) + "..., relays=" + relayUrlsRaw);
.putString("notificationStyle", notificationStyle);
if (enabledKindsRaw != null) {
editor.putString("enabledKinds", enabledKindsRaw);
}
if (authorsRaw != null) {
editor.putString("authors", authorsRaw);
} else {
editor.remove("authors");
}
editor.apply();
Log.d(TAG, "Configured: pubkey=" + userPubkey.substring(0, 8) + "..., style=" + notificationStyle + ", relays=" + relayUrlsRaw + ", kinds=" + enabledKindsRaw + ", authors=" + (authorsRaw != null ? authorsRaw.length() + " chars" : "all"));
} else {
// Clear config (user logged out)
prefs.edit().clear().apply();
Log.d(TAG, "Config cleared (user logged out)");
}
// Start or stop the foreground service based on style
manageService(notificationStyle, userPubkey != null && relayUrlsRaw != null);
call.resolve();
}
/**
* Start the foreground service when style is "persistent" and config is valid.
* Stop it otherwise.
*/
private void manageService(String style, boolean hasConfig) {
Context ctx = getContext();
Intent serviceIntent = new Intent(ctx, NotificationRelayService.class);
if ("persistent".equals(style) && hasConfig) {
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
ctx.startForegroundService(serviceIntent);
} else {
ctx.startService(serviceIntent);
}
Log.d(TAG, "Started NotificationRelayService (persistent mode)");
} catch (Exception e) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
&& e instanceof ForegroundServiceStartNotAllowedException) {
Log.w(TAG, "Could not start foreground service: " + e.getMessage());
} else {
Log.w(TAG, "Failed to start service", e);
}
}
} else {
ctx.stopService(serviceIntent);
Log.d(TAG, "Stopped NotificationRelayService (push mode or no config)");
}
}
}
@@ -1,7 +1,9 @@
package pub.ditto.app;
import android.app.ForegroundServiceStartNotAllowedException;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
@@ -11,32 +13,36 @@ import com.getcapacitor.BridgeActivity;
public class MainActivity extends BridgeActivity {
private static final String PREFS_NAME = "ditto_notification_config";
@Override
protected void onCreate(Bundle savedInstanceState) {
// Register the native notification config plugin before super.onCreate
// Register native plugins before super.onCreate.
registerPlugin(DittoNotificationPlugin.class);
registerPlugin(SandboxPlugin.class);
super.onCreate(savedInstanceState);
// Start the persistent relay connection service.
// On Android 12+ (API 31+) the system may throw
// ForegroundServiceStartNotAllowedException if the foreground service
// time limit for this type has already been exhausted. We catch it so
// the app continues to run normally; the alarm inside the service will
// retry at the next scheduled interval.
try {
Intent serviceIntent = new Intent(this, NotificationRelayService.class);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(serviceIntent);
} else {
startService(serviceIntent);
}
} catch (Exception e) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
&& e instanceof ForegroundServiceStartNotAllowedException) {
Log.w("MainActivity", "Could not start NotificationRelayService: " + e.getMessage());
} else {
throw e;
// Only start the foreground service if the user has opted into
// "persistent" notification style. Default is "push" (no service).
SharedPreferences prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
String style = prefs.getString("notificationStyle", "push");
if ("persistent".equals(style)) {
try {
Intent serviceIntent = new Intent(this, NotificationRelayService.class);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(serviceIntent);
} else {
startService(serviceIntent);
}
} catch (Exception e) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
&& e instanceof ForegroundServiceStartNotAllowedException) {
Log.w("MainActivity", "Could not start NotificationRelayService: " + e.getMessage());
} else {
throw e;
}
}
}
@@ -265,6 +265,7 @@ public class NostrPoller {
}
return "commented on your post";
}
case 8211: return "sent you a letter";
default: return "mentioned you";
}
}
@@ -243,6 +243,8 @@ public class NotificationRelayService extends Service {
SharedPreferences prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
String userPubkey = prefs.getString("userPubkey", null);
String relayUrlsJson = prefs.getString("relayUrls", null);
String enabledKindsJson = prefs.getString("enabledKinds", null);
String authorsJson = prefs.getString("authors", null);
if (userPubkey == null || relayUrlsJson == null) {
Log.d(TAG, "No config, skipping fetch");
@@ -268,10 +270,17 @@ public class NotificationRelayService extends Service {
return;
}
fetch(relayUrls.get(relayIndex), userPubkey);
List<Integer> enabledKinds = parseEnabledKinds(enabledKindsJson);
if (enabledKinds.isEmpty()) {
Log.d(TAG, "No enabled kinds, skipping fetch");
releaseFetchWakeLock();
return;
}
List<String> authors = parseAuthors(authorsJson);
fetch(relayUrls.get(relayIndex), userPubkey, enabledKinds, authors);
}
private void fetch(String relayUrl, String userPubkey) {
private void fetch(String relayUrl, String userPubkey, List<Integer> enabledKinds, List<String> authors) {
long since = poller.getLastSeenTimestamp();
if (since == 0) {
since = (System.currentTimeMillis() / 1000) - 300; // 5 min ago on first run
@@ -284,7 +293,9 @@ public class NotificationRelayService extends Service {
try {
JSONObject filter = new JSONObject();
JSONArray kinds = new JSONArray();
kinds.put(1); kinds.put(6); kinds.put(16); kinds.put(7); kinds.put(9735); kinds.put(1111);
for (int kind : enabledKinds) {
kinds.put(kind);
}
filter.put("kinds", kinds);
JSONArray pTags = new JSONArray();
pTags.put(userPubkey);
@@ -292,6 +303,15 @@ public class NotificationRelayService extends Service {
filter.put("since", since + 1);
filter.put("limit", FETCH_LIMIT);
// When "only from people I follow" is enabled, restrict to those authors
if (!authors.isEmpty()) {
JSONArray authorsArr = new JSONArray();
for (String author : authors) {
authorsArr.put(author);
}
filter.put("authors", authorsArr);
}
JSONArray req = new JSONArray();
req.put("REQ");
req.put(currentSubId);
@@ -397,7 +417,8 @@ public class NotificationRelayService extends Service {
}
Log.d(TAG, "Retrying in " + backoffMs + "ms on relay " + relayIndex);
Runnable retry = () -> fetch(relayUrls.get(relayIndex), userPubkey);
// Re-read config from prefs on retry so enabled kinds stay current.
Runnable retry = this::runFetchCycle;
handler.postDelayed(retry, backoffMs);
backoffMs = Math.min(backoffMs * 2, MAX_BACKOFF_MS);
@@ -515,6 +536,46 @@ public class NotificationRelayService extends Service {
return urls;
}
/**
* Parse the authors filter from JSON. Returns an empty list when the value
* is null or invalid (meaning no author restriction).
*/
private List<String> parseAuthors(String json) {
List<String> authors = new ArrayList<>();
if (json != null) {
try {
JSONArray arr = new JSONArray(json);
for (int i = 0; i < arr.length(); i++) {
authors.add(arr.getString(i));
}
} catch (JSONException e) {
Log.w(TAG, "Failed to parse authors", e);
}
}
return authors;
}
/**
* Parse the enabled notification kinds from JSON. Returns an empty list
* when the value is null or invalid — the caller should skip polling
* when the list is empty (the JS layer always provides kinds via
* DittoNotification.configure in the same write as pubkey/relays).
*/
private List<Integer> parseEnabledKinds(String json) {
List<Integer> kinds = new ArrayList<>();
if (json != null) {
try {
JSONArray arr = new JSONArray(json);
for (int i = 0; i < arr.length(); i++) {
kinds.add(arr.getInt(i));
}
} catch (JSONException e) {
Log.w(TAG, "Failed to parse enabled kinds", e);
}
}
return kinds;
}
private Notification buildForegroundNotification() {
Intent notificationIntent = new Intent(this, MainActivity.class);
PendingIntent pendingIntent = PendingIntent.getActivity(
@@ -0,0 +1,552 @@
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;
}
}
}
+4 -4
View File
@@ -1,7 +1,7 @@
<?xml version='1.0' encoding='utf-8'?>
<resources>
<string name="app_name">Ditto</string>
<string name="title_activity_main">Ditto</string>
<string name="package_name">pub.ditto.app</string>
<string name="custom_url_scheme">pub.ditto.app</string>
<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>
</resources>
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Android Auto Backup rules (Android 11 and below).
Ditto excludes WebView storage (Local Storage, IndexedDB, databases) and
any shared_prefs that hold sensitive credentials so they don't end up in
Google Drive backups. Keychain/KeyStore entries used by
capacitor-secure-storage-plugin are not backed up by default, so we don't
need to exclude those explicitly; but we also exclude the plugin's
SharedPreferences for defense in depth.
See: https://developer.android.com/guide/topics/data/autobackup
-->
<full-backup-content>
<!-- WebView: localStorage, IndexedDB, cookies, caches -->
<exclude domain="file" path="app_webview/" />
<exclude domain="database" path="webview.db" />
<exclude domain="database" path="webviewCache.db" />
<!-- capacitor-secure-storage-plugin fallback SharedPreferences -->
<exclude domain="sharedpref" path="CapacitorSecureStoragePluginSharedPreferences.xml" />
<!-- Capacitor preferences plugin — may contain app-level settings -->
<exclude domain="sharedpref" path="CapacitorStorage.xml" />
</full-backup-content>
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Android 12+ data extraction rules.
Separate rules apply to cloud backups (Google Drive) and device-to-device
transfers. Both exclude WebView storage and sensitive SharedPreferences so
wallet credentials, login tokens, and cached private data don't leak.
See: https://developer.android.com/about/versions/12/backup-restore
-->
<data-extraction-rules>
<cloud-backup>
<exclude domain="file" path="app_webview/" />
<exclude domain="database" path="webview.db" />
<exclude domain="database" path="webviewCache.db" />
<exclude domain="sharedpref" path="CapacitorSecureStoragePluginSharedPreferences.xml" />
<exclude domain="sharedpref" path="CapacitorStorage.xml" />
</cloud-backup>
<device-transfer>
<exclude domain="file" path="app_webview/" />
<exclude domain="database" path="webview.db" />
<exclude domain="database" path="webviewCache.db" />
<exclude domain="sharedpref" path="CapacitorSecureStoragePluginSharedPreferences.xml" />
<exclude domain="sharedpref" path="CapacitorStorage.xml" />
</device-transfer>
</data-extraction-rules>
+17 -2
View File
@@ -5,8 +5,23 @@ 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-filesystem'
project(':capacitor-filesystem').projectDir = new File('../node_modules/@capacitor/filesystem/android')
include ':capacitor-haptics'
project(':capacitor-haptics').projectDir = new File('../node_modules/@capacitor/haptics/android')
include ':capacitor-keyboard'
project(':capacitor-keyboard').projectDir = new File('../node_modules/@capacitor/keyboard/android')
include ':capacitor-local-notifications'
project(':capacitor-local-notifications').projectDir = new File('../node_modules/@capacitor/local-notifications/android')
include ':capacitor-status-bar'
project(':capacitor-status-bar').projectDir = new File('../node_modules/@capacitor/status-bar/android')
include ':capacitor-share'
project(':capacitor-share').projectDir = new File('../node_modules/@capacitor/share/android')
include ':capgo-capacitor-autofill-save-password'
project(':capgo-capacitor-autofill-save-password').projectDir = new File('../node_modules/@capgo/capacitor-autofill-save-password/android')
include ':capacitor-secure-storage-plugin'
project(':capacitor-secure-storage-plugin').projectDir = new File('../node_modules/capacitor-secure-storage-plugin/android')
+12 -7
View File
@@ -1,12 +1,10 @@
import type { CapacitorConfig } from '@capacitor/cli';
const config: CapacitorConfig = {
appId: 'pub.ditto.app',
appName: 'Ditto',
appId: 'pub.agora.app',
appName: 'Agora',
webDir: 'dist',
server: {
// Handle deep links from your domain
hostname: 'ditto.pub',
androidScheme: 'https',
iosScheme: 'https'
},
@@ -17,9 +15,16 @@ const config: CapacitorConfig = {
},
ios: {
backgroundColor: '#14161f',
contentInset: 'automatic',
scheme: 'Ditto'
}
contentInset: 'never',
scheme: 'Agora'
},
plugins: {
SystemBars: {
// Inject --safe-area-inset-* CSS variables on Android to work around
// a Chromium bug (<140) where env(safe-area-inset-*) reports 0.
insetsHandling: 'css',
},
},
};
export default config;
+6
View File
@@ -0,0 +1,6 @@
services:
web:
build: .
restart: unless-stopped
expose:
- "80"
+30
View File
@@ -0,0 +1,30 @@
services:
web:
image: nginx:alpine
ports:
- "8083:80"
volumes:
- ./nginx.dev.conf:/etc/nginx/conf.d/default.conf:ro
- ./dist:/usr/share/nginx/html:ro
restart: unless-stopped
depends_on:
- vite
networks:
- agora-network
vite:
image: node:22-alpine
working_dir: /app
# Use host node_modules so new dependencies are picked up after install.
command: sh -c "npm install && npm run dev"
volumes:
- .:/app
environment:
- NODE_ENV=development
networks:
- agora-network
restart: unless-stopped
networks:
agora-network:
driver: bridge
-383
View File
@@ -1,383 +0,0 @@
# Theme System
This document describes the two separate but overlapping theme features in Ditto: the **App Theme** (which controls the local UI) and the **Profile Theme** (which is published to Nostr for others to see). Understanding the distinction is key to working with this codebase.
## Overview
| Concept | Purpose | Scope | Persistence |
|---|---|---|---|
| **App Theme** | Controls colors, fonts, and background of the local UI | Local to the user's browser | localStorage + encrypted NIP-78 sync |
| **Profile Theme** | A set of theme values published as a Nostr event | Public, visible to other users | Kind 16767 replaceable event |
The App Theme and Profile Theme share the same underlying data structure (`ThemeConfig`), and there is an optional bridge between them (`autoShareTheme`), but they are fundamentally independent systems.
---
## Part 1: App Theme
The App Theme controls what the user sees in their own browser. It has no inherent connection to Nostr.
### Core Concept: 3 Colors Define Everything
The entire theme is derived from just 3 core colors, defined by the `CoreThemeColors` interface in `src/themes.ts:8`:
```typescript
interface CoreThemeColors {
background: string; // HSL string, e.g. "228 20% 10%"
text: string; // Text/foreground color
primary: string; // Primary accent (buttons, links, focus rings)
}
```
From these 3 values, the system auto-derives 19 CSS tokens (the full `ThemeTokens` set) via `deriveTokensFromCore()` in `src/lib/colorUtils.ts:141`. The derivation algorithm:
- Detects dark/light mode from background luminance (threshold: 0.2)
- Derives `card` and `popover` surfaces by slightly lightening the background (dark mode) or using it directly (light mode)
- Derives `secondary` and `muted` surfaces by adjusting background lightness
- Derives `border` using the primary hue with reduced saturation
- Computes `mutedForeground` as a dimmer version of the text color
- Sets `accent = primary` and `ring = primary`
- Auto-computes `primaryForeground` using WCAG contrast detection (white or dark)
- Uses fixed red values for `destructive` / `destructiveForeground`
### Theme Modes
The `Theme` type (`src/contexts/AppContext.ts:9`) has four values:
| Mode | Behavior |
|---|---|
| `"light"` | Uses the builtin (or configured) light color set |
| `"dark"` | Uses the builtin (or configured) dark color set |
| `"system"` | Resolves to `"light"` or `"dark"` based on `prefers-color-scheme`, with a live media query listener |
| `"custom"` | Uses user-defined colors stored in `config.customTheme` |
**Builtin themes** are defined in `src/themes.ts:102`:
```typescript
const builtinThemes = {
light: { background: '270 50% 97%', text: '270 25% 12%', primary: '270 65% 55%' },
dark: { background: '228 20% 10%', text: '210 40% 98%', primary: '258 70% 60%' },
};
```
Self-hosters can override these at build time via `ditto.json` (injected through `__DITTO_CONFIG__` in `vite.config.ts`), or at runtime via the `ThemesConfig` in `AppConfig.themes`.
### ThemeConfig
The `ThemeConfig` type (`src/themes.ts:50`) wraps the 3 core colors with optional extras:
```typescript
interface ThemeConfig {
title?: string;
colors: CoreThemeColors;
font?: ThemeFont; // { family: string; url?: string }
background?: ThemeBackground; // { url: string; mode?: 'cover' | 'tile'; ... }
}
```
This is the canonical type used everywhere: in `AppConfig.customTheme`, in encrypted settings, and in Nostr theme events.
### Theme Presets
Named presets are defined in `src/themes.ts:136` (e.g. `pink`, `toxic`, `sunset`). Each preset includes core colors and optionally a font and background image. Applying a preset sets the app theme to `"custom"` and stores the preset's config as `customTheme`.
### How Themes Apply to the DOM
The theme pipeline has three stages designed to prevent any flash of wrong colors:
#### Stage 1: Pre-React Blocking Script (`public/theme.js`)
A synchronous `<script>` tag in `index.html:43` runs before React mounts. It:
1. Reads `nostr:app-config` from localStorage
2. Resolves `"system"` via `matchMedia`
3. Handles legacy presets (`"black"`, `"pink"`)
4. Sets `document.documentElement.className` to the theme name
5. Sets `document.body.style.background` to the correct background color
6. Updates preloader colors (logo and spinner) to match
This prevents any visible flash between the hardcoded dark defaults in `index.html:32` and the user's actual theme.
#### Stage 2: React Provider (`src/components/AppProvider.tsx`)
Three private hooks run during the provider's lifecycle:
**`useApplyTheme`** (line 91) - Uses `useLayoutEffect` (synchronous before paint) to:
- Resolve the theme mode
- Build a full CSS string from `CoreThemeColors` via `buildThemeCssFromCore()`
- Inject/update a `<style id="theme-vars">` element with all 19 CSS custom properties
- Set `document.documentElement.className` to the resolved theme
- Remove the inline body style left by `theme.js`
- When mode is `"system"`, attach a `matchMedia` change listener
**`useApplyFonts`** (line 133) - Loads and applies custom fonts via `loadAndApplyFont()` from `src/lib/fontLoader.ts`.
**`useApplyBackground`** (line 156) - Injects/removes a `<style id="theme-background">` for background images (cover or tile mode).
#### Stage 3: Theme Switch (`src/hooks/useTheme.ts`)
The `setTheme()` function (line 52) performs a flicker-free theme switch:
1. Injects a temporary `<style>` that disables all CSS transitions (`transition: none !important`)
2. Synchronously builds and applies CSS vars before React re-renders
3. Updates `document.documentElement.className`
4. Re-enables transitions after browser paint via `requestAnimationFrame`
5. Updates localStorage config
6. Debounce-syncs to encrypted NIP-78 storage (1 second delay)
### How Components Consume Theme Values
#### CSS Custom Properties to Tailwind
`tailwind.config.ts` maps all 19 CSS custom properties to Tailwind color utilities:
```typescript
colors: {
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: { DEFAULT: 'hsl(var(--primary))', foreground: 'hsl(var(--primary-foreground))' },
// ... (secondary, destructive, muted, accent, popover, card, border, input, ring)
}
```
Components use standard Tailwind classes like `bg-primary`, `text-foreground`, `border-border`, etc. These resolve to `hsl(var(--primary))`, which picks up whichever values are currently set on `:root`.
The `cn()` utility in `src/lib/utils.ts` combines `clsx` (conditional class joining) with `tailwind-merge` (intelligent Tailwind class deduplication).
#### Static CSS
`src/index.css` applies base styles using theme tokens:
```css
* { @apply border-border; }
body { @apply bg-background text-foreground; }
```
The only static CSS custom property is `--radius: 0.75rem`. All color variables are injected dynamically.
### ScopedTheme
The `ScopedTheme` component (`src/components/ScopedTheme.tsx`) applies a different set of theme colors to a DOM subtree by setting CSS variables as inline `style`:
```tsx
<ScopedTheme colors={someColors} className="rounded-lg p-4">
{/* Children here see different --background, --primary, etc. */}
</ScopedTheme>
```
It also sets `data-theme-mode="dark"` or `"light"` based on background luminance, for CSS targeting.
### App Theme Persistence
#### Layer 1: localStorage (immediate)
The `useLocalStorage` hook (`src/hooks/useLocalStorage.ts`) stores the full `AppConfig` under key `"nostr:app-config"`. This includes `theme`, `customTheme`, `autoShareTheme`, and `themes`. Changes are reflected immediately and support cross-tab sync via `StorageEvent`.
#### Layer 2: Encrypted NIP-78 Settings (cross-device sync)
The `useEncryptedSettings` hook (`src/hooks/useEncryptedSettings.ts`) stores theme preferences in a kind 30078 addressable event, encrypted to self via NIP-44. The `EncryptedSettings` interface includes `theme`, `customTheme`, and `autoShareTheme` among other app settings.
Key behaviors:
- Query is delayed 5 seconds after login to avoid competing with feed load
- Uses optimistic updates with a `pendingSettings` ref for rapid successive mutations
- A `recentlyWritten()` guard returns true for 10 seconds after a local write to prevent `NostrSync` from overwriting the value that was just saved
#### Sync via NostrSync
The `NostrSync` component (`src/components/NostrSync.tsx`) runs globally and syncs encrypted settings from Nostr on login. For theme-related fields, it:
1. Seeds a `lastSyncedTimestamp` ref on first load to prevent stale events from overwriting local config
2. Skips application if `recentlyWritten()` is true
3. Only applies changes if the remote timestamp is newer
4. Handles legacy theme value migration (`"black"`, `"pink"` to `"custom"`)
5. Diffs each field individually to avoid unnecessary re-renders
---
## Part 2: Profile Theme
The Profile Theme is a public Nostr event that represents a user's chosen theme. Other clients can read it to style that user's profile page, or users can browse and copy each other's themes.
### Nostr Event Kinds
#### Kind 36767: Theme Definition (addressable, multiple per user)
A shareable, named theme that a user has created. Think of these as "published theme presets." Tags:
| Tag | Purpose | Example |
|---|---|---|
| `d` | Identifier (slug) | `["d", "ocean-night"]` |
| `c` | Color (hex + role) | `["c", "#1a1a2e", "background"]` |
| `f` | Font (family + optional URL) | `["f", "Comfortaa", "https://cdn.jsdelivr.net/..."]` |
| `bg` | Background (imeta-style variadic) | `["bg", "url https://...", "mode cover", "m image/jpeg"]` |
| `title` | Display name | `["title", "Ocean Night"]` |
| `alt` | NIP-31 description | `["alt", "Custom theme: Ocean Night"]` |
| `t` | Topic tag | `["t", "theme"]` |
| `description` | Optional description | `["description", "A deep blue theme"]` |
Colors are stored as **hex** in `c` tags (converted to/from HSL internally). The `content` field is empty (legacy events may have JSON in content for backward compatibility).
#### Kind 16767: Active Profile Theme (replaceable, one per user)
The user's currently active profile theme. Same tag structure as kind 36767 but without `d` or `description` tags, and with an optional `a` tag referencing the source theme definition:
| Tag | Purpose |
|---|---|
| `c` | Color tags (same as 36767) |
| `f` | Font tag (same as 36767) |
| `bg` | Background tag (same as 36767) |
| `alt` | Always `"Active profile theme"` |
| `title` | Optional theme name |
| `a` | Optional reference to source kind 36767 event |
### Hooks
| Hook | File | Purpose |
|---|---|---|
| `usePublishTheme` | `src/hooks/usePublishTheme.ts` | Publish/update/delete theme definitions (36767), set/clear active profile theme (16767) |
| `useUserThemes` | `src/hooks/useUserThemes.ts` | Query all kind 36767 themes by a user, deduplicated by d-tag, sorted newest first |
| `useActiveProfileTheme` | `src/hooks/useActiveProfileTheme.ts` | Query a user's kind 16767 active profile theme |
### Publishing and Parsing
All event building and parsing is in `src/lib/themeEvent.ts`:
- `buildThemeDefinitionTags()` / `parseThemeDefinition()` - Kind 36767
- `buildActiveThemeTags()` / `parseActiveProfileTheme()` - Kind 16767
- `buildColorTags()` / `parseColorTags()` - HSL-to-hex conversion for `c` tags
- `buildFontTag()` / `parseFontTag()` - Font `f` tags
- `buildBackgroundTag()` / `parseBackgroundTag()` - Background `bg` tags (imeta-style)
- `titleToSlug()` - Generate d-tag identifiers from titles
Backward compatibility: if `c` tags are missing, the parser falls back to reading legacy JSON from `content` (handling both the old 19-token format and the 4-color format).
---
## Part 3: The Bridge Between App Theme and Profile Theme
The two systems are connected by the **autoShareTheme** setting and the NostrSync component.
### App Theme -> Profile Theme
When `autoShareTheme` is enabled (default: `true`) and the user applies a custom theme via `applyCustomTheme()`, the `useTheme` hook automatically publishes the custom theme as a kind 16767 active profile theme, debounced by 2 seconds.
```
User picks a custom theme
-> applyCustomTheme() in useTheme.ts:88
-> Updates local config (localStorage)
-> Syncs to encrypted NIP-78 storage (1s debounce)
-> If autoShareTheme: publishes kind 16767 (2s debounce)
```
### Profile Theme -> App Theme
On page load, if `autoShareTheme` is enabled, `NostrSync` (line 174) fetches the user's kind 16767 event and applies it as `customTheme` **without changing the theme mode**. This means:
- If the user is on `theme: "dark"`, their profile theme is stored as `customTheme` but the UI stays in dark mode
- If the user is on `theme: "custom"`, the profile theme's colors are applied to the UI
- This allows the profile theme to stay in sync across devices without forcing the user into custom mode
### Theme Definitions (Kind 36767)
Theme definitions are independent of the app theme. Users can create, publish, edit, and delete named themes. Other users can view them in feeds (via `ThemeUpdateCard`) and copy them. These are purely social objects on the Nostr network.
---
## Font System
Fonts are managed by `src/lib/fontLoader.ts` and `src/lib/fonts.ts`.
### Bundled Fonts
10 fonts are bundled via `@fontsource` packages with lazy loading (dynamic imports):
| Category | Fonts |
|---|---|
| Sans | Inter, DM Sans, Outfit, Montserrat |
| Serif | Lora, Merriweather, Playfair Display |
| Mono | JetBrains Mono |
| Display | Comfortaa |
| Handwriting | Comic Relief |
Each has a `load()` function and a `cdnUrl` for Nostr event publishing.
### Font Application
Three `<style>` elements manage fonts:
| ID | Purpose |
|---|---|
| `theme-font-faces` | `@font-face` rules for remote fonts |
| `theme-font-overrides` | `html { font-family: "CustomFont", "Inter Variable", ... !important; }` |
| `theme-vars` | Theme CSS custom properties (not font-specific, but part of the pipeline) |
The `loadAndApplyFont()` function:
1. Tries to load via bundled `@fontsource` package first
2. Falls back to injecting a `@font-face` rule from a remote URL
3. Applies a global font-family override via `<style id="theme-font-overrides">`
4. Passing `undefined` clears the override (reverts to default Inter)
---
## Color Utilities
`src/lib/colorUtils.ts` provides the color math underpinning the theme system:
| Function | Purpose |
|---|---|
| `parseHsl` / `formatHsl` | Parse/format HSL strings (`"228 20% 10%"`) |
| `hslToRgb` / `rgbToHsl` | HSL-RGB conversion |
| `hexToRgb` / `rgbToHex` | Hex-RGB conversion |
| `hexToHslString` / `hslStringToHex` | Direct hex-to-HSL-string conversion (used for Nostr `c` tags) |
| `getLuminance` | WCAG 2.1 relative luminance |
| `getContrastRatio` / `getContrastRatioHsl` | WCAG contrast ratio between two colors |
| `isDarkTheme` | Determines if a background is "dark" (luminance < 0.2) |
| `deriveTokensFromCore` | The core algorithm: 3 colors -> 19 tokens |
| `tokensToCoreColors` | Extract 3 core colors from a legacy 19-token object |
All colors are stored internally as HSL strings without the `hsl()` wrapper (e.g. `"228 20% 10%"`). The `hsl()` wrapper is added by Tailwind's config (`hsl(var(--background))`).
---
## Validation
Theme data is validated with Zod schemas in `src/lib/schemas.ts`:
- `ThemeSchema` - Validates `'dark' | 'light' | 'system' | 'custom'`
- `CoreThemeColorsSchema` - Validates the 3 HSL string fields
- `ThemeConfigSchema` - Full config with optional font/background
- `ThemeConfigCompatSchema` - Accepts both `ThemeConfig` and bare `CoreThemeColors`
- `ThemeColorsCompatSchema` - Union of current 3-color, old 4-color, and legacy 19-token formats
- `AppConfigSchema` - Full app config including all theme fields
- `EncryptedSettingsSchema` - Encrypted settings including theme fields
The `AppProvider` deserializer (`src/components/AppProvider.tsx:32`) validates each top-level field individually with `safeParse`, so a single invalid field doesn't nuke the entire config.
---
## File Index
| File | Role |
|---|---|
| `src/themes.ts` | Core types (`CoreThemeColors`, `ThemeConfig`, `ThemeTokens`), builtin themes, presets, CSS builders |
| `src/lib/colorUtils.ts` | Color conversion, contrast detection, token derivation |
| `src/lib/themeEvent.ts` | Nostr event kinds (36767, 16767), tag building/parsing |
| `src/lib/fontLoader.ts` | Font loading and CSS injection |
| `src/lib/fonts.ts` | Bundled font definitions |
| `src/lib/schemas.ts` | Zod validation schemas |
| `src/contexts/AppContext.ts` | `Theme` type, `AppConfig` interface, React context |
| `src/hooks/useTheme.ts` | Primary theme API: `setTheme()`, `applyCustomTheme()`, `setAutoShareTheme()` |
| `src/hooks/useAppContext.ts` | Context consumer hook |
| `src/hooks/useEncryptedSettings.ts` | NIP-78 encrypted settings (cross-device sync) |
| `src/hooks/usePublishTheme.ts` | Publish theme definitions and active profile theme |
| `src/hooks/useUserThemes.ts` | Query user's theme definitions |
| `src/hooks/useActiveProfileTheme.ts` | Query user's active profile theme |
| `src/components/AppProvider.tsx` | Theme application to DOM (`useApplyTheme`, `useApplyFonts`, `useApplyBackground`) |
| `src/components/NostrSync.tsx` | Cross-device sync for encrypted settings and profile theme |
| `src/components/ScopedTheme.tsx` | Scoped CSS variable overrides for subtrees |
| `src/components/ThemeSelector.tsx` | Full settings UI for theme management |
| `src/components/SidebarThemeDropdown.tsx` | Compact theme picker dropdown |
| `public/theme.js` | Pre-React blocking script for flash prevention |
| `index.html` | Hardcoded dark defaults, preloader, blocking script tag |
| `tailwind.config.ts` | CSS custom property to Tailwind color mapping |
| `src/index.css` | Base styles using theme tokens |
+8 -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"] },
{ ignores: ["dist", "android", "ios"] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ["**/*.{ts,tsx}"],
@@ -39,6 +39,13 @@ export default tseslint.config(
},
],
"custom/no-placeholder-comments": "error",
"no-restricted-syntax": [
"error",
{
"selector": "CallExpression[callee.object.type='MetaProperty'][callee.property.name='glob']",
"message": "import.meta.glob is Vite-only and breaks other bundlers. Inline the assets or use standard imports instead.",
},
],
"no-warning-comments": [
"error",
{ terms: ["fixme"] },
+23 -21
View File
@@ -1,43 +1,45 @@
<!DOCTYPE html>
<html lang="en" class="dark">
<head>
<title>Ditto — Your content. Your vibe. Your rules.</title>
<title>Agora — Power to the people.</title>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="description" content="Ditto — Your content. Your vibe. Your rules." />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content" />
<meta name="description" content="Agora — a Nostr social client for communities, creativity, and ownership." />
<!-- Open Graph -->
<meta property="og:title" content="Ditto" />
<meta property="og:description" content="Your content. Your vibe. Your rules." />
<meta property="og:image" content="https://ditto.pub/og-image.jpg" />
<meta property="og:title" content="Agora" />
<meta property="og:description" content="Power to the people." />
<meta property="og:image" content="https://agora.spot/og-image.png" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:image:type" content="image/jpeg" />
<meta property="og:type" content="website" />
<meta property="og:url" content="https://ditto.pub" />
<meta property="og:site_name" content="Ditto" />
<meta property="og:url" content="https://agora.spot" />
<meta property="og:site_name" content="Agora" />
<!-- Twitter / X -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Ditto" />
<meta name="twitter:description" content="Your content. Your vibe. Your rules." />
<meta name="twitter:image" content="https://ditto.pub/og-image.jpg" />
<meta name="twitter:title" content="Agora" />
<meta name="twitter:description" content="Power to the people." />
<meta name="twitter:image" content="https://agora.spot/og-image.png" />
<meta http-equiv="content-security-policy" content="default-src 'none'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; frame-src 'self' https:; font-src 'self' https:; base-uri 'self'; manifest-src 'self'; connect-src 'self' blob: https: wss:; img-src 'self' data: blob: https:; media-src 'self' https:">
<link rel="icon" type="image/svg+xml" href="/logo.svg">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<meta name="theme-color" content="#161b2e" media="(prefers-color-scheme: dark)">
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)">
<meta http-equiv="content-security-policy" content="default-src 'none'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; frame-src 'self' https:; font-src 'self' https:; base-uri 'self'; manifest-src 'self'; connect-src 'self' blob: https: wss:; img-src 'self' data: blob: https:; media-src 'self' blob: https:">
<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">
<meta name="theme-color" content="#0a0c14" media="(prefers-color-scheme: dark)">
<meta name="theme-color" content="#e85d3c" media="(prefers-color-scheme: light)">
<link rel="manifest" href="/manifest.webmanifest">
<style>@keyframes ditto-spin{to{transform:rotate(360deg)}}</style>
<style>@keyframes agora-spin{to{transform:rotate(360deg)}}</style>
</head>
<body style="margin:0;background:hsl(228 20% 10%)">
<body style="margin:0;background:hsl(0 0% 10%)">
<!-- Pre-React loading screen. Lives OUTSIDE #root so React doesn't
touch it. Removed by main.tsx once the app has mounted. -->
<div id="preloader" style="position:fixed;inset:0;z-index:9999;display:flex;align-items:center;justify-content:center;background:hsl(228 20% 10%)">
<div id="preloader" style="position:fixed;inset:0;z-index:9999;display:flex;align-items:center;justify-content:center;background:hsl(0 0% 10%)">
<div style="display:flex;flex-direction:column;align-items:center;gap:24px">
<div data-logo style="width:48px;height:48px;background:hsl(258 70% 60%);-webkit-mask:url(/logo.svg) center/contain no-repeat;mask:url(/logo.svg) center/contain no-repeat"></div>
<div data-spinner style="width:24px;height:24px;border:2.5px solid hsl(258 70% 60% / 0.25);border-top-color:hsl(258 70% 60%);border-radius:50%;animation:ditto-spin .7s linear infinite"></div>
<div data-logo style="width:48px;height:48px;background:hsl(14 79% 58%);-webkit-mask:url(/logo.svg) center/contain no-repeat;mask:url(/logo.svg) center/contain no-repeat"></div>
<div data-spinner style="width:24px;height:24px;border:2.5px solid hsl(14 79% 58% / 0.25);border-top-color:hsl(14 79% 58%);border-radius:50%;animation:agora-spin .7s linear infinite"></div>
</div>
</div>
<!-- Blocking script: reads theme from localStorage and applies it
+30 -4
View File
@@ -15,6 +15,11 @@
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 */; };
B1A2C3D40007000100000001 /* NostrPoller.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1A2C3D40007000100000002 /* NostrPoller.swift */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
@@ -28,6 +33,12 @@
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>"; };
B1A2C3D40006000100000002 /* DittoNotificationPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DittoNotificationPlugin.swift; sourceTree = "<group>"; };
B1A2C3D40007000100000002 /* NostrPoller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NostrPoller.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -63,11 +74,17 @@
isa = PBXGroup;
children = (
50379B222058CBB4000EE86E /* capacitor.config.json */,
B1A2C3D40004000100000002 /* App.entitlements */,
504EC3071FED79650016851F /* AppDelegate.swift */,
B1A2C3D40001000100000002 /* SandboxPlugin.swift */,
B1A2C3D40002000100000002 /* DittoBridgeViewController.swift */,
B1A2C3D40006000100000002 /* DittoNotificationPlugin.swift */,
B1A2C3D40007000100000002 /* NostrPoller.swift */,
504EC30B1FED79650016851F /* Main.storyboard */,
504EC30E1FED79650016851F /* Assets.xcassets */,
504EC3101FED79650016851F /* LaunchScreen.storyboard */,
504EC3131FED79650016851F /* Info.plist */,
B1A2C3D40005000100000002 /* PrivacyInfo.xcprivacy */,
2FAD9762203C412B000D30F8 /* config.xml */,
50B271D01FEDC1A000F3C39B /* public */,
);
@@ -145,6 +162,7 @@
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */,
504EC30D1FED79650016851F /* Main.storyboard in Resources */,
2FAD9763203C412B000D30F8 /* config.xml in Resources */,
B1A2C3D40005000100000001 /* PrivacyInfo.xcprivacy in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -156,6 +174,10 @@
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 */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -295,17 +317,19 @@
baseConfigurationReference = 958DCC722DB07C7200EA8C5F /* debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = GZLTTH5DLM;
INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 2.0.0;
MARKETING_VERSION = 2.8.0;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = pub.ditto.app;
PRODUCT_BUNDLE_IDENTIFIER = pub.agora.app;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_VERSION = 5.0;
@@ -317,16 +341,18 @@
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = GZLTTH5DLM;
INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 2.0.0;
PRODUCT_BUNDLE_IDENTIFIER = pub.ditto.app;
MARKETING_VERSION = 2.8.0;
PRODUCT_BUNDLE_IDENTIFIER = pub.agora.app;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
SWIFT_VERSION = 5.0;
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.associated-domains</key>
<array>
<string>webcredentials:agora.spot</string>
<string>webcredentials:agora.spot?mode=developer</string>
</array>
</dict>
</plist>
+80 -9
View File
@@ -1,36 +1,45 @@
import UIKit
import Capacitor
import BackgroundTasks
import UserNotifications
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate {
var window: UIWindow?
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// Override point for customization after application launch.
// Register the background task handler for notification polling.
// Must happen before the app finishes launching.
DittoNotificationPlugin.registerBackgroundTask()
// Set ourselves as the notification center delegate so we can:
// 1. Show banners even when the app is in the foreground.
// 2. Handle notification taps to navigate the WebView.
UNUserNotificationCenter.current().delegate = self
// Register notification categories with summary formats for iOS grouping.
registerNotificationCategories()
return true
}
func applicationWillResignActive(_ application: UIApplication) {
// Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state.
// Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game.
}
func applicationDidEnterBackground(_ application: UIApplication) {
// Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later.
// If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits.
}
func applicationWillEnterForeground(_ application: UIApplication) {
// Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background.
// Trigger an immediate poll when returning to foreground to catch up
// on any notifications missed while backgrounded.
DittoNotificationPlugin.pollNow()
}
func applicationDidBecomeActive(_ application: UIApplication) {
// Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface.
}
func applicationWillTerminate(_ application: UIApplication) {
// Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:.
}
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
@@ -46,4 +55,66 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
return ApplicationDelegateProxy.shared.application(application, continue: userActivity, restorationHandler: restorationHandler)
}
// MARK: - UNUserNotificationCenterDelegate
/// Show notification banners even when the app is in the foreground.
func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
) {
completionHandler([.banner, .sound])
}
/// Handle notification tap: navigate the Capacitor WebView to /notifications.
func userNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void
) {
let userInfo = response.notification.request.content.userInfo
let path = userInfo["url"] as? String ?? "/notifications"
// Navigate the Capacitor WebView to the notifications page.
DispatchQueue.main.async { [weak self] in
guard let rootVC = self?.window?.rootViewController as? DittoBridgeViewController else {
completionHandler()
return
}
let js = "window.location.pathname !== '\(path)' && (window.location.pathname = '\(path)');"
rootVC.webView?.evaluateJavaScript(js) { _, _ in }
}
completionHandler()
}
// MARK: - Notification Categories
/// Register notification categories with summary formats for native iOS
/// notification grouping. When multiple notifications share a thread
/// identifier, iOS automatically collapses them and uses the summary
/// format to describe the group.
private func registerNotificationCategories() {
let categories: [UNNotificationCategory] = [
makeCategory(id: NostrPoller.categoryReactions, summary: "%u more reactions"),
makeCategory(id: NostrPoller.categoryReposts, summary: "%u more reposts"),
makeCategory(id: NostrPoller.categoryZaps, summary: "%u more zaps"),
makeCategory(id: NostrPoller.categoryMentions, summary: "%u more mentions"),
makeCategory(id: NostrPoller.categoryComments, summary: "%u more comments"),
makeCategory(id: NostrPoller.categoryBadges, summary: "%u more badge awards"),
makeCategory(id: NostrPoller.categoryLetters, summary: "%u more letters"),
]
UNUserNotificationCenter.current().setNotificationCategories(Set(categories))
}
private func makeCategory(id: String, summary: String) -> UNNotificationCategory {
return UNNotificationCategory(
identifier: id,
actions: [],
intentIdentifiers: [],
hiddenPreviewsBodyPlaceholder: nil,
categorySummaryFormat: summary,
options: []
)
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 98 KiB

+1 -1
View File
@@ -11,7 +11,7 @@
<!--Bridge View Controller-->
<scene sceneID="tne-QT-ifu">
<objects>
<viewController id="BYZ-38-t0r" customClass="CAPBridgeViewController" customModule="Capacitor" sceneMemberID="viewController"/>
<viewController id="BYZ-38-t0r" customClass="DittoBridgeViewController" customModule="App" sceneMemberID="viewController"/>
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
</objects>
</scene>
@@ -0,0 +1,9 @@
import UIKit
import Capacitor
class DittoBridgeViewController: CAPBridgeViewController {
override func capacitorDidLoad() {
super.capacitorDidLoad()
webView?.allowsBackForwardNavigationGestures = true
}
}
+209
View File
@@ -0,0 +1,209 @@
import Foundation
import Capacitor
import BackgroundTasks
import UserNotifications
// MARK: - DittoNotificationPlugin
/// Capacitor plugin that bridges the JS notification configuration to the
/// native iOS background polling system.
///
/// Mirrors the Android `DittoNotificationPlugin.java` interface:
/// - Receives `userPubkey`, `relayUrls`, `enabledKinds`, `authors`, and
/// `notificationStyle` from the JS layer via `configure()`.
/// - Stores configuration in UserDefaults.
/// - Schedules / cancels a `BGAppRefreshTask` to periodically poll relays
/// and display local notifications via `NostrPoller`.
///
/// On iOS the "push" vs "persistent" distinction maps to:
/// - **"push"**: No background polling. Relies on Web Push (where supported)
/// or in-app polling when the app is open.
/// - **"persistent"**: Schedules `BGAppRefreshTask` for periodic relay polling.
/// iOS manages the interval (~15 min minimum, adaptive based on app usage).
@objc(DittoNotificationPlugin)
public class DittoNotificationPlugin: CAPPlugin, CAPBridgedPlugin {
// MARK: - Capacitor Bridging
public let identifier = "DittoNotificationPlugin"
public let jsName = "DittoNotification"
public let pluginMethods: [CAPPluginMethod] = [
CAPPluginMethod(name: "configure", returnType: CAPPluginReturnPromise),
]
// MARK: - Constants
static let bgTaskIdentifier = "pub.ditto.app.notification-refresh"
private static let prefsKey = "ditto_notification_config"
// MARK: - Plugin Methods
/// Called from JS: `DittoNotification.configure({ ... })`.
@objc func configure(_ call: CAPPluginCall) {
let userPubkey = call.getString("userPubkey")
let notificationStyle = call.getString("notificationStyle") ?? "push"
let relayUrls = call.getArray("relayUrls")?.compactMap { $0 as? String }
let enabledKinds = call.getArray("enabledKinds")?.compactMap { $0 as? Int }
let authors = call.getArray("authors")?.compactMap { $0 as? String }
let defaults = UserDefaults.standard
if let userPubkey, let relayUrls, !relayUrls.isEmpty {
// Save configuration.
defaults.set(userPubkey, forKey: "\(Self.prefsKey).userPubkey")
defaults.set(relayUrls, forKey: "\(Self.prefsKey).relayUrls")
defaults.set(notificationStyle, forKey: "\(Self.prefsKey).notificationStyle")
if let enabledKinds {
defaults.set(enabledKinds, forKey: "\(Self.prefsKey).enabledKinds")
}
if let authors, !authors.isEmpty {
defaults.set(authors, forKey: "\(Self.prefsKey).authors")
} else {
defaults.removeObject(forKey: "\(Self.prefsKey).authors")
}
let kindsStr = enabledKinds?.map(String.init).joined(separator: ",") ?? "none"
NSLog("[DittoNotification] Configured: pubkey=%@..., style=%@, relays=%d, kinds=%@",
String(userPubkey.prefix(8)), notificationStyle,
relayUrls.count,
kindsStr)
} else {
// Clear configuration (user logged out).
for suffix in ["userPubkey", "relayUrls", "notificationStyle", "enabledKinds", "authors"] {
defaults.removeObject(forKey: "\(Self.prefsKey).\(suffix)")
}
NSLog("[DittoNotification] Config cleared (user logged out)")
}
// Schedule or cancel background polling based on style + config.
let hasConfig = userPubkey != nil && relayUrls != nil && !(relayUrls?.isEmpty ?? true)
Self.manageBackgroundRefresh(style: notificationStyle, hasConfig: hasConfig)
call.resolve()
}
// MARK: - Background Task Management
/// Register the BGAppRefreshTask handler. Must be called from
/// `application(_:didFinishLaunchingWithOptions:)` before the app
/// finishes launching.
static func registerBackgroundTask() {
BGTaskScheduler.shared.register(
forTaskWithIdentifier: bgTaskIdentifier,
using: nil
) { task in
guard let refreshTask = task as? BGAppRefreshTask else {
task.setTaskCompleted(success: false)
return
}
Self.handleBackgroundRefresh(task: refreshTask)
}
NSLog("[DittoNotification] Registered BGAppRefreshTask: %@", bgTaskIdentifier)
}
/// Schedule or cancel the BGAppRefreshTask.
/// On iOS both "push" and "persistent" modes use BGAppRefreshTask
/// (there is no Web Push in WKWebView and no foreground service concept),
/// so we schedule whenever there is a valid config.
static func manageBackgroundRefresh(style: String, hasConfig: Bool) {
if hasConfig {
scheduleBackgroundRefresh()
} else {
cancelBackgroundRefresh()
}
}
/// Schedule the next background refresh. iOS decides the actual timing
/// (minimum ~15 minutes, adaptive based on user app usage patterns).
static func scheduleBackgroundRefresh() {
let request = BGAppRefreshTaskRequest(identifier: bgTaskIdentifier)
// Suggest earliest begin date of 8 minutes from now (iOS may defer).
request.earliestBeginDate = Date(timeIntervalSinceNow: 8 * 60)
do {
try BGTaskScheduler.shared.submit(request)
NSLog("[DittoNotification] Scheduled background refresh")
} catch {
NSLog("[DittoNotification] Failed to schedule background refresh: %@", error.localizedDescription)
}
}
private static func cancelBackgroundRefresh() {
BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: bgTaskIdentifier)
NSLog("[DittoNotification] Cancelled background refresh")
}
/// Handle a BGAppRefreshTask: read config, poll, reschedule.
private static func handleBackgroundRefresh(task: BGAppRefreshTask) {
NSLog("[DittoNotification] Background refresh triggered")
// Read configuration from UserDefaults.
let defaults = UserDefaults.standard
guard let userPubkey = defaults.string(forKey: "\(prefsKey).userPubkey"),
let relayUrls = defaults.stringArray(forKey: "\(prefsKey).relayUrls"),
!relayUrls.isEmpty else {
NSLog("[DittoNotification] No config, completing task")
task.setTaskCompleted(success: true)
return
}
let enabledKinds = defaults.array(forKey: "\(prefsKey).enabledKinds") as? [Int] ?? []
let authors = defaults.stringArray(forKey: "\(prefsKey).authors")
guard !enabledKinds.isEmpty else {
NSLog("[DittoNotification] No enabled kinds, completing task")
task.setTaskCompleted(success: true)
return
}
// Schedule the next refresh before starting work (in case we're
// terminated mid-task, the next refresh is already queued).
scheduleBackgroundRefresh()
// Run the poll in a detached Task.
let pollTask = Task {
let poller = NostrPoller()
let count = await poller.poll(
userPubkey: userPubkey,
relayUrls: relayUrls,
enabledKinds: enabledKinds,
authors: authors
)
NSLog("[DittoNotification] Background poll complete: %d notifications", count)
task.setTaskCompleted(success: true)
}
// Handle task expiration (iOS is about to kill us).
task.expirationHandler = {
NSLog("[DittoNotification] Background task expired")
pollTask.cancel()
task.setTaskCompleted(success: false)
}
}
// MARK: - Immediate Poll
/// Trigger an immediate poll (e.g., when the app enters the foreground
/// after being backgrounded, to catch up on missed notifications).
static func pollNow() {
let defaults = UserDefaults.standard
guard let userPubkey = defaults.string(forKey: "\(prefsKey).userPubkey"),
let relayUrls = defaults.stringArray(forKey: "\(prefsKey).relayUrls"),
!relayUrls.isEmpty else { return }
let enabledKinds = defaults.array(forKey: "\(prefsKey).enabledKinds") as? [Int] ?? []
let authors = defaults.stringArray(forKey: "\(prefsKey).authors")
guard !enabledKinds.isEmpty else { return }
Task {
let poller = NostrPoller()
await poller.poll(
userPubkey: userPubkey,
relayUrls: relayUrls,
enabledKinds: enabledKinds,
authors: authors
)
}
}
}
+17 -1
View File
@@ -7,7 +7,7 @@
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>Ditto</string>
<string>Agora</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
@@ -47,5 +47,21 @@
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<true/>
<key>NSPhotoLibraryUsageDescription</key>
<string>Agora needs access to your photo library to upload images to your posts and profile.</string>
<key>NSCameraUsageDescription</key>
<string>Agora needs camera access to take photos and videos for your posts.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Agora needs access to your microphone to record voice messages.</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>UIBackgroundModes</key>
<array>
<string>fetch</string>
</array>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>pub.agora.app.notification-refresh</string>
</array>
</dict>
</plist>
+633
View File
@@ -0,0 +1,633 @@
import Foundation
import UserNotifications
// MARK: - NostrPoller
/// Polls Nostr relays for notification events and displays native iOS
/// notifications with author names, content previews, and iOS thread grouping.
///
/// Improvements over the Android implementation:
/// - Fetches kind 0 metadata so notifications show "Alice reacted" not "Someone reacted"
/// - Uses iOS thread identifiers for native notification grouping per category+post
/// - Caches author metadata in UserDefaults (24h TTL) to minimise relay queries
/// - Designed to complete within the ~30s BGAppRefreshTask budget
final class NostrPoller {
// MARK: - Constants
private static let prefsKey = "ditto_notifications"
private static let lastSeenKey = "nostr:notification-last-seen"
private static let metadataCacheKey = "nostr:author-metadata-cache"
private static let metadataTTL: TimeInterval = 24 * 60 * 60 // 24 hours
private static let fetchLimit = 5
private static let wsTimeout: TimeInterval = 10
private static let metadataFetchTimeout: TimeInterval = 5
// MARK: - Notification Categories (registered by AppDelegate)
/// Category identifiers used for UNNotificationCategory registration.
static let categoryReactions = "reactions"
static let categoryReposts = "reposts"
static let categoryZaps = "zaps"
static let categoryMentions = "mentions"
static let categoryComments = "comments"
static let categoryBadges = "badges"
static let categoryLetters = "letters"
// MARK: - Types
/// Minimal parsed Nostr event used during polling.
struct NostrEvent {
let id: String
let pubkey: String
let kind: Int
let createdAt: Int
let content: String
let tags: [[String]]
init?(json: [String: Any]) {
guard let id = json["id"] as? String,
let pubkey = json["pubkey"] as? String,
let kind = json["kind"] as? Int,
let createdAt = json["created_at"] as? Int else { return nil }
self.id = id
self.pubkey = pubkey
self.kind = kind
self.createdAt = createdAt
self.content = json["content"] as? String ?? ""
self.tags = (json["tags"] as? [[String]]) ?? []
}
}
/// Cached author display name.
private struct AuthorCache: Codable {
let name: String
let timestamp: TimeInterval
}
// MARK: - Public API
/// Run a single poll cycle: fetch events from a relay, resolve metadata,
/// and display notifications. Returns the number of notifications shown.
@discardableResult
func poll(
userPubkey: String,
relayUrls: [String],
enabledKinds: [Int],
authors: [String]?
) async -> Int {
guard !relayUrls.isEmpty, !enabledKinds.isEmpty else { return 0 }
let since = lastSeenTimestamp
let effectiveSince = since > 0 ? since : Int(Date().timeIntervalSince1970) - 300
if since == 0 {
setLastSeenTimestamp(effectiveSince)
}
// Try each relay in order until one succeeds.
for relayUrl in relayUrls {
guard let events = await fetchEvents(
relayUrl: relayUrl,
userPubkey: userPubkey,
enabledKinds: enabledKinds,
authors: authors,
since: effectiveSince
) else {
continue // Try next relay on failure.
}
// Deduplicate + filter self-interactions.
var seenIds = Set<String>()
let filtered = events.filter { ev in
guard ev.pubkey != userPubkey, !seenIds.contains(ev.id) else { return false }
seenIds.insert(ev.id)
return true
}
guard !filtered.isEmpty else {
// Successful fetch but nothing new update timestamp and return.
return 0
}
// Verify referenced events for reactions/reposts/zaps.
let notifiable = await verifyReferencedEvents(
events: filtered,
userPubkey: userPubkey,
relayUrl: relayUrl
)
// Update last-seen to newest event in the full filtered set (not
// just notifiable) so we don't re-fetch already-seen events.
let newestTs = filtered.map(\.createdAt).max() ?? effectiveSince
if newestTs > lastSeenTimestamp {
setLastSeenTimestamp(newestTs)
}
guard !notifiable.isEmpty else { return 0 }
// Fetch author metadata for unique pubkeys.
let pubkeys = Array(Set(notifiable.map(\.pubkey)))
let authorNames = await resolveAuthorNames(pubkeys: pubkeys, relayUrl: relayUrl)
// Display notifications.
await displayNotifications(events: notifiable, authorNames: authorNames)
return notifiable.count
}
return 0 // All relays failed.
}
// MARK: - Relay Communication
/// Fetch notification events from a single relay. Returns nil on failure.
private func fetchEvents(
relayUrl: String,
userPubkey: String,
enabledKinds: [Int],
authors: [String]?,
since: Int
) async -> [NostrEvent]? {
guard let url = URL(string: relayUrl) else { return nil }
var filter: [String: Any] = [
"kinds": enabledKinds,
"#p": [userPubkey],
"since": since + 1,
"limit": Self.fetchLimit,
]
if let authors, !authors.isEmpty {
filter["authors"] = authors
}
return await relayQuery(url: url, filters: [filter])
}
/// Fetch events by IDs from a relay for referenced-event verification.
private func fetchEventsByIds(ids: [String], relayUrl: String) async -> [String: NostrEvent] {
guard !ids.isEmpty, let url = URL(string: relayUrl) else { return [:] }
let filter: [String: Any] = [
"ids": ids,
"limit": ids.count,
]
guard let events = await relayQuery(url: url, filters: [filter], timeout: Self.metadataFetchTimeout) else {
return [:]
}
var map = [String: NostrEvent]()
for ev in events {
map[ev.id] = ev
}
return map
}
/// Fetch kind 0 metadata events for a set of pubkeys.
private func fetchMetadata(pubkeys: [String], relayUrl: String) async -> [String: NostrEvent] {
guard !pubkeys.isEmpty, let url = URL(string: relayUrl) else { return [:] }
let filter: [String: Any] = [
"kinds": [0],
"authors": pubkeys,
"limit": pubkeys.count,
]
guard let events = await relayQuery(url: url, filters: [filter], timeout: Self.metadataFetchTimeout) else {
return [:]
}
var map = [String: NostrEvent]()
for ev in events {
// Keep only the newest kind 0 per pubkey.
if let existing = map[ev.pubkey], existing.createdAt > ev.createdAt {
continue
}
map[ev.pubkey] = ev
}
return map
}
/// Low-level relay query: open WebSocket, send REQ, collect events until
/// EOSE, close. Returns nil on connection/timeout failure.
private func relayQuery(
url: URL,
filters: [[String: Any]],
timeout: TimeInterval = wsTimeout
) async -> [NostrEvent]? {
await withCheckedContinuation { continuation in
var events = [NostrEvent]()
var resumed = false
let subId = "ditto-\(UInt64.random(in: 0...UInt64.max))"
let session = URLSession(configuration: .default)
let task = session.webSocketTask(with: url)
task.resume()
// Build REQ message: ["REQ", subId, filter1, filter2, ...]
var reqArray: [Any] = ["REQ", subId]
reqArray.append(contentsOf: filters)
guard let reqData = try? JSONSerialization.data(withJSONObject: reqArray),
let reqStr = String(data: reqData, encoding: .utf8) else {
continuation.resume(returning: nil)
return
}
// Timeout guard.
let timeoutWork = DispatchWorkItem { [weak task] in
guard !resumed else { return }
resumed = true
task?.cancel(with: .goingAway, reason: nil)
session.invalidateAndCancel()
continuation.resume(returning: events.isEmpty ? nil : events)
}
DispatchQueue.global().asyncAfter(deadline: .now() + timeout, execute: timeoutWork)
func finish(result: [NostrEvent]?) {
timeoutWork.cancel()
guard !resumed else { return }
resumed = true
// Send CLOSE and disconnect.
if let closeData = try? JSONSerialization.data(withJSONObject: ["CLOSE", subId]),
let closeStr = String(data: closeData, encoding: .utf8) {
task.send(.string(closeStr)) { _ in }
}
task.cancel(with: .normalClosure, reason: nil)
session.invalidateAndCancel()
continuation.resume(returning: result)
}
func receiveNext() {
task.receive { result in
switch result {
case .success(.string(let text)):
guard let data = text.data(using: .utf8),
let arr = try? JSONSerialization.jsonObject(with: data) as? [Any],
let type = arr.first as? String else {
receiveNext()
return
}
if type == "EVENT", arr.count >= 3,
let evJson = arr[2] as? [String: Any],
let ev = NostrEvent(json: evJson) {
events.append(ev)
receiveNext()
} else if type == "EOSE" || type == "CLOSED" {
finish(result: events)
} else {
receiveNext()
}
case .failure:
finish(result: nil)
default:
receiveNext()
}
}
}
task.send(.string(reqStr)) { error in
if error != nil {
finish(result: nil)
} else {
receiveNext()
}
}
}
}
// MARK: - Event Verification
/// For reactions (7), reposts (6, 16), and zaps (9735), verify that the
/// referenced event was authored by the current user. Events that pass
/// verification or don't need it are returned.
private func verifyReferencedEvents(
events: [NostrEvent],
userPubkey: String,
relayUrl: String
) async -> [NostrEvent] {
let needsVerification: Set<Int> = [7, 6, 16, 9735]
// Collect referenced IDs that need verification.
var refIdsNeeded = Set<String>()
for ev in events where needsVerification.contains(ev.kind) {
if let refId = referencedEventId(from: ev) {
refIdsNeeded.insert(refId)
}
}
let refMap: [String: NostrEvent]
if !refIdsNeeded.isEmpty {
refMap = await fetchEventsByIds(ids: Array(refIdsNeeded), relayUrl: relayUrl)
} else {
refMap = [:]
}
return events.filter { ev in
guard needsVerification.contains(ev.kind) else { return true }
// Zaps with #p tag targeting the user are valid (profile zaps have no e tag).
if ev.kind == 9735 {
return true
}
guard let refId = referencedEventId(from: ev) else { return false }
guard let refEvent = refMap[refId] else {
// Couldn't fetch keep the notification rather than silently dropping it.
return true
}
return refEvent.pubkey == userPubkey
}
}
/// Returns the last `e` tag value from an event's tags.
private func referencedEventId(from event: NostrEvent) -> String? {
event.tags.last(where: { $0.first == "e" && $0.count > 1 })?[1]
}
// MARK: - Author Metadata Resolution
/// Resolve display names for a set of pubkeys, using cache where possible.
private func resolveAuthorNames(pubkeys: [String], relayUrl: String) async -> [String: String] {
var result = [String: String]()
var uncached = [String]()
let cache = loadMetadataCache()
let now = Date().timeIntervalSince1970
for pk in pubkeys {
if let cached = cache[pk], now - cached.timestamp < Self.metadataTTL {
result[pk] = cached.name
} else {
uncached.append(pk)
}
}
// Fetch uncached metadata from the relay.
if !uncached.isEmpty {
let metadataEvents = await fetchMetadata(pubkeys: uncached, relayUrl: relayUrl)
var updatedCache = cache
for pk in uncached {
if let ev = metadataEvents[pk], let name = parseDisplayName(from: ev) {
result[pk] = name
updatedCache[pk] = AuthorCache(name: name, timestamp: now)
} else {
// Fall back to truncated npub-style identifier.
let fallback = formatPubkey(pk)
result[pk] = fallback
// Don't cache failures retry next time.
}
}
saveMetadataCache(updatedCache)
}
return result
}
/// Parse display_name or name from a kind 0 event's content JSON.
private func parseDisplayName(from event: NostrEvent) -> String? {
guard let data = event.content.data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
return nil
}
// Prefer display_name, fall back to name.
if let displayName = json["display_name"] as? String, !displayName.isEmpty {
return displayName
}
if let name = json["name"] as? String, !name.isEmpty {
return name
}
return nil
}
/// Format a hex pubkey as a short identifier: first 8 + "..." + last 4.
private func formatPubkey(_ pubkey: String) -> String {
guard pubkey.count >= 12 else { return pubkey }
let start = pubkey.prefix(8)
let end = pubkey.suffix(4)
return "\(start)...\(end)"
}
// MARK: - Metadata Cache (UserDefaults)
private func loadMetadataCache() -> [String: AuthorCache] {
let defaults = UserDefaults.standard
guard let data = defaults.data(forKey: Self.metadataCacheKey),
let cache = try? JSONDecoder().decode([String: AuthorCache].self, from: data) else {
return [:]
}
return cache
}
private func saveMetadataCache(_ cache: [String: AuthorCache]) {
guard let data = try? JSONEncoder().encode(cache) else { return }
UserDefaults.standard.set(data, forKey: Self.metadataCacheKey)
}
// MARK: - Notification Display
/// Display native iOS notifications for a batch of verified events.
private func displayNotifications(events: [NostrEvent], authorNames: [String: String]) async {
let center = UNUserNotificationCenter.current()
for event in events {
let authorName = authorNames[event.pubkey] ?? formatPubkey(event.pubkey)
let (title, body, categoryId, threadId) = notificationContent(
event: event,
authorName: authorName
)
let content = UNMutableNotificationContent()
content.title = title
content.body = body
content.sound = .default
content.categoryIdentifier = categoryId
content.threadIdentifier = threadId
content.userInfo = ["url": "/notifications"]
let identifier = "ditto-\(event.id.prefix(16))"
let request = UNNotificationRequest(
identifier: identifier,
content: content,
trigger: nil // Deliver immediately.
)
try? await center.add(request)
}
}
/// Build notification title, body, category ID, and thread identifier for an event.
private func notificationContent(
event: NostrEvent,
authorName: String
) -> (title: String, body: String, categoryId: String, threadId: String) {
let refId = referencedEventId(from: event) ?? ""
switch event.kind {
case 7:
// Reaction show the reaction content (emoji) if available.
let reaction = event.content.isEmpty || event.content == "+" ? "❤️" : event.content
return (
"\(authorName) reacted \(reaction)",
"Reacted to your post",
Self.categoryReactions,
"reactions:\(refId)"
)
case 6, 16:
return (
"\(authorName) reposted your note",
"",
Self.categoryReposts,
"reposts:\(refId)"
)
case 9735:
let sats = zapAmount(from: event)
if sats > 0 {
return (
"\(formatSats(sats)) sats from \(authorName)",
"You received a zap",
Self.categoryZaps,
"zaps"
)
}
return (
"\(authorName) zapped you",
"",
Self.categoryZaps,
"zaps"
)
case 1:
let hasETag = event.tags.contains(where: { $0.first == "e" })
let preview = contentPreview(event.content, maxLength: 120)
if hasETag {
return (
"\(authorName) replied to you",
preview,
Self.categoryMentions,
"mentions"
)
}
return (
"\(authorName) mentioned you",
preview,
Self.categoryMentions,
"mentions"
)
case 1111, 1222, 1244:
let preview = contentPreview(event.content, maxLength: 120)
// Check if this is a reply to another comment (k tag == "1111").
let isReply = event.tags.contains(where: { $0.first == "k" && $0.count > 1 && $0[1] == "1111" })
let action = isReply ? "replied to your comment" : "commented on your post"
return (
"\(authorName) \(action)",
preview,
Self.categoryComments,
"comments:\(refId)"
)
case 8:
return (
"\(authorName) awarded you a badge",
"You received a new badge",
Self.categoryBadges,
"badges"
)
case 8211:
return (
"\(authorName) sent you a letter",
"You have a new letter waiting for you",
Self.categoryLetters,
"letters"
)
default:
return (
"\(authorName) interacted with you",
"",
Self.categoryMentions,
"mentions"
)
}
}
/// Truncate content for notification body preview.
private func contentPreview(_ content: String, maxLength: Int) -> String {
let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines)
// Replace newlines with spaces for a single-line preview.
let singleLine = trimmed.replacingOccurrences(
of: "\\s*\\n+\\s*",
with: " ",
options: .regularExpression
)
guard singleLine.count > maxLength else { return singleLine }
return String(singleLine.prefix(maxLength)) + ""
}
// MARK: - Zap Amount Extraction
/// Extract zap amount in sats from a kind 9735 zap receipt event.
/// Checks the "amount" tag first (millisats), then falls back to
/// parsing the "description" tag's zap request JSON.
private func zapAmount(from event: NostrEvent) -> Int {
// Check for direct "amount" tag (value in millisats).
for tag in event.tags where tag.first == "amount" && tag.count > 1 {
if let msats = Int(tag[1]), msats > 0 {
return msats / 1000
}
}
// Fall back to "description" tag (zap request JSON) -> amount tag.
for tag in event.tags where tag.first == "description" && tag.count > 1 {
guard let data = tag[1].data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let reqTags = json["tags"] as? [[String]] else { continue }
for reqTag in reqTags where reqTag.first == "amount" && reqTag.count > 1 {
if let msats = Int(reqTag[1]), msats > 0 {
return msats / 1000
}
}
}
return 0
}
/// Format sats for compact display: 500 -> "500", 1500 -> "1.5K", 1000000 -> "1M".
private func formatSats(_ sats: Int) -> String {
if sats >= 1_000_000 {
let val = Double(sats) / 1_000_000.0
if val == val.rounded(.down) {
return "\(Int(val))M"
}
return String(format: "%.1fM", val).replacingOccurrences(of: ".0M", with: "M")
} else if sats >= 1_000 {
let val = Double(sats) / 1_000.0
if val == val.rounded(.down) {
return "\(Int(val))K"
}
return String(format: "%.1fK", val).replacingOccurrences(of: ".0K", with: "K")
}
return "\(sats)"
}
// MARK: - Last-Seen Timestamp
var lastSeenTimestamp: Int {
UserDefaults.standard.integer(forKey: Self.lastSeenKey)
}
func setLastSeenTimestamp(_ ts: Int) {
UserDefaults.standard.set(ts, forKey: Self.lastSeenKey)
}
}
+72
View File
@@ -0,0 +1,72 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPrivacyCollectedDataTypes</key>
<array>
<!-- Crash / performance data via Sentry/GlitchTip -->
<dict>
<key>NSPrivacyCollectedDataType</key>
<string>NSPrivacyCollectedDataTypeCrashData</string>
<key>NSPrivacyCollectedDataTypeLinked</key>
<false/>
<key>NSPrivacyCollectedDataTypeTracking</key>
<false/>
<key>NSPrivacyCollectedDataTypePurposes</key>
<array>
<string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
</array>
</dict>
<!-- Performance / analytics data via Plausible -->
<dict>
<key>NSPrivacyCollectedDataType</key>
<string>NSPrivacyCollectedDataTypePerformanceData</string>
<key>NSPrivacyCollectedDataTypeLinked</key>
<false/>
<key>NSPrivacyCollectedDataTypeTracking</key>
<false/>
<key>NSPrivacyCollectedDataTypePurposes</key>
<array>
<string>NSPrivacyCollectedDataTypePurposeAnalytics</string>
</array>
</dict>
</array>
<key>NSPrivacyTracking</key>
<false/>
<key>NSPrivacyTrackingDomains</key>
<array/>
<key>NSPrivacyAccessedAPITypes</key>
<array>
<!-- UserDefaults — used by Capacitor/WKWebView for localStorage -->
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<!-- CA92.1: Access info from same app -->
<string>CA92.1</string>
</array>
</dict>
<!-- File timestamp APIs — used by @capacitor/filesystem -->
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<!-- C617.1: Access file timestamps inside app container -->
<string>C617.1</string>
</array>
</dict>
<!-- Disk space APIs — used by WKWebView / file operations -->
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryDiskSpace</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<!-- E174.1: Check available disk space before writing -->
<string>E174.1</string>
</array>
</dict>
</array>
</dict>
</plist>
+541
View File
@@ -0,0 +1,541 @@
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"]
))
}
}
}
+12 -2
View File
@@ -13,8 +13,13 @@ 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: "CapacitorFilesystem", path: "../../../node_modules/@capacitor/filesystem"),
.package(name: "CapacitorHaptics", path: "../../../node_modules/@capacitor/haptics"),
.package(name: "CapacitorKeyboard", path: "../../../node_modules/@capacitor/keyboard"),
.package(name: "CapacitorLocalNotifications", path: "../../../node_modules/@capacitor/local-notifications"),
.package(name: "CapacitorStatusBar", path: "../../../node_modules/@capacitor/status-bar")
.package(name: "CapacitorShare", path: "../../../node_modules/@capacitor/share"),
.package(name: "CapgoCapacitorAutofillSavePassword", path: "../../../node_modules/@capgo/capacitor-autofill-save-password"),
.package(name: "CapacitorSecureStoragePlugin", path: "../../../node_modules/capacitor-secure-storage-plugin")
],
targets: [
.target(
@@ -23,8 +28,13 @@ let package = Package(
.product(name: "Capacitor", package: "capacitor-swift-pm"),
.product(name: "Cordova", package: "capacitor-swift-pm"),
.product(name: "CapacitorApp", package: "CapacitorApp"),
.product(name: "CapacitorFilesystem", package: "CapacitorFilesystem"),
.product(name: "CapacitorHaptics", package: "CapacitorHaptics"),
.product(name: "CapacitorKeyboard", package: "CapacitorKeyboard"),
.product(name: "CapacitorLocalNotifications", package: "CapacitorLocalNotifications"),
.product(name: "CapacitorStatusBar", package: "CapacitorStatusBar")
.product(name: "CapacitorShare", package: "CapacitorShare"),
.product(name: "CapgoCapacitorAutofillSavePassword", package: "CapgoCapacitorAutofillSavePassword"),
.product(name: "CapacitorSecureStoragePlugin", package: "CapacitorSecureStoragePlugin")
]
)
]
+30
View File
@@ -0,0 +1,30 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/javascript application/json;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
location /health {
access_log off;
return 200 "healthy\n";
add_header Content-Type text/plain;
}
location / {
try_files $uri $uri/ /index.html;
}
}
+35
View File
@@ -0,0 +1,35 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/javascript application/json;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
location /health {
access_log off;
return 200 "healthy\n";
add_header Content-Type text/plain;
}
location / {
resolver 127.0.0.11 valid=10s;
set $vite_backend http://vite:8080;
proxy_pass $vite_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 86400;
}
}
+5618 -670
View File
File diff suppressed because it is too large Load Diff
+74 -26
View File
@@ -1,24 +1,30 @@
{
"name": "mkstack",
"name": "agora",
"private": true,
"version": "2.0.0",
"version": "2.8.0",
"type": "module",
"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!'",
"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"
},
"engines": {
"npm": "10.9.4",
"node": "22.x"
"node": ">=22"
},
"dependencies": {
"@breeztech/breez-sdk-spark": "^0.13.2-dev1",
"@capacitor/app": "^8.0.0",
"@capacitor/barcode-scanner": "^3.0.2",
"@capacitor/core": "^8.1.0",
"@capacitor/filesystem": "^8.1.2",
"@capacitor/haptics": "^8.0.2",
"@capacitor/keyboard": "^8.0.3",
"@capacitor/local-notifications": "^8.0.1",
"@capacitor/status-bar": "^8.0.0",
"@capacitor/share": "^8.0.1",
"@capgo/capacitor-autofill-save-password": "^8.0.22",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
@@ -26,6 +32,7 @@
"@emoji-mart/react": "^1.1.1",
"@fontsource-variable/comfortaa": "^5.2.8",
"@fontsource-variable/dm-sans": "^5.2.8",
"@fontsource-variable/fredoka": "^5.2.10",
"@fontsource-variable/inter": "^5.2.6",
"@fontsource-variable/jetbrains-mono": "^5.2.8",
"@fontsource-variable/lora": "^5.2.8",
@@ -35,19 +42,37 @@
"@fontsource-variable/outfit": "^5.2.8",
"@fontsource-variable/playfair-display": "^5.2.8",
"@fontsource/bungee-shade": "^5.2.7",
"@fontsource/caveat": "^5.2.8",
"@fontsource/cherry-bomb-one": "^5.2.7",
"@fontsource/comic-neue": "^5.2.7",
"@fontsource/comic-relief": "^5.2.2",
"@fontsource/courier-prime": "^5.2.8",
"@fontsource/creepster": "^5.2.7",
"@fontsource/luckiest-guy": "^5.2.8",
"@fontsource/noto-sans-nushu": "^5.2.6",
"@fontsource/pacifico": "^5.2.7",
"@fontsource/permanent-marker": "^5.2.7",
"@fontsource/pirata-one": "^5.2.8",
"@fontsource/press-start-2p": "^5.2.7",
"@fontsource/silkscreen": "^5.2.8",
"@fontsource/special-elite": "^5.2.8",
"@getalby/sdk": "^5.1.1",
"@hookform/resolvers": "^5.2.2",
"@nostrify/nostrify": "^0.51.0",
"@nostrify/react": "^0.3.1",
"@milkdown/core": "^7.20.0",
"@milkdown/ctx": "^7.20.0",
"@milkdown/plugin-clipboard": "^7.20.0",
"@milkdown/plugin-history": "^7.20.0",
"@milkdown/plugin-listener": "^7.20.0",
"@milkdown/plugin-upload": "^7.20.0",
"@milkdown/preset-commonmark": "^7.20.0",
"@milkdown/preset-gfm": "^7.20.0",
"@milkdown/prose": "^7.20.0",
"@milkdown/react": "^7.20.0",
"@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",
"@plausible-analytics/tracker": "^0.4.4",
"@radix-ui/react-accordion": "^1.2.0",
@@ -56,18 +81,18 @@
"@radix-ui/react-avatar": "^1.1.0",
"@radix-ui/react-checkbox": "^1.1.1",
"@radix-ui/react-collapsible": "^1.1.0",
"@radix-ui/react-context-menu": "^2.2.1",
"@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.2",
"@radix-ui/react-dropdown-menu": "^2.1.1",
"@radix-ui/react-hover-card": "^1.1.1",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-menubar": "^1.1.1",
"@radix-ui/react-navigation-menu": "^1.2.0",
"@radix-ui/react-popover": "^1.1.1",
"@radix-ui/react-menubar": "^1.1.16",
"@radix-ui/react-navigation-menu": "^1.2.14",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "^1.1.0",
"@radix-ui/react-radio-group": "^1.2.0",
"@radix-ui/react-scroll-area": "^1.1.0",
"@radix-ui/react-select": "^2.1.1",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slider": "^1.2.0",
"@radix-ui/react-slot": "^1.1.0",
@@ -76,43 +101,57 @@
"@radix-ui/react-toast": "^1.2.1",
"@radix-ui/react-toggle": "^1.1.0",
"@radix-ui/react-toggle-group": "^1.1.0",
"@radix-ui/react-tooltip": "^1.1.4",
"@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.0.10",
"@unhead/react": "^2.0.10",
"@unhead/addons": "^2.1.13",
"@unhead/react": "^2.1.13",
"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-scale": "^4.0.2",
"date-fns": "^3.6.0",
"dompurify": "^3.3.3",
"embla-carousel-react": "^8.3.0",
"emoji-mart": "^5.6.0",
"fflate": "^0.8.2",
"hls.js": "^1.6.15",
"html-to-image": "^1.11.13",
"i18next": "^26.0.5",
"i18next-browser-languagedetector": "^8.2.1",
"idb": "^8.0.3",
"input-otp": "^1.2.4",
"lucide-react": "^0.462.0",
"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": "^18.3.1",
"react": "^19.2.4",
"react-blurhash": "^0.3.0",
"react-day-picker": "^8.10.1",
"react-dom": "^18.3.1",
"react-day-picker": "^9.14.0",
"react-dom": "^19.2.4",
"react-easy-crop": "^5.5.6",
"react-hook-form": "^7.71.1",
"react-i18next": "^17.0.4",
"react-intersection-observer": "^9.16.0",
"react-leaflet": "^4.2.1",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^2.1.3",
"react-router-dom": "^6.26.2",
"recharts": "^2.12.7",
"rehype-sanitize": "^6.0.0",
"slugify": "^1.6.8",
"smol-toml": "^1.6.0",
"tailwind-merge": "^2.5.2",
"tailwindcss-animate": "^1.0.7",
"uri-templates": "^0.2.0",
"vaul": "^0.9.3",
"vaul": "^1.1.2",
"zod": "^4.3.6"
},
"devDependencies": {
@@ -123,12 +162,16 @@
"@html-eslint/eslint-plugin": "^0.41.0",
"@html-eslint/parser": "^0.41.0",
"@tailwindcss/typography": "^0.5.15",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@testing-library/react": "^16.3.2",
"@types/d3-scale": "^4.0.9",
"@types/dompurify": "^3.0.5",
"@types/leaflet": "^1.9.21",
"@types/node": "^22.5.5",
"@types/qrcode": "^1.5.5",
"@types/react": "^18.3.1",
"@types/react-dom": "^18.3.1",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"@webbtc/webln-types": "^3.0.0",
"@webxdc/types": "^2.1.2",
@@ -139,10 +182,15 @@
"globals": "^15.9.0",
"jsdom": "^26.1.0",
"postcss": "^8.4.47",
"rollup-plugin-visualizer": "^7.0.1",
"tailwindcss": "^3.4.11",
"typescript": "^5.5.3",
"typescript-eslint": "^8.0.1",
"vite": "^8.0.0",
"vite": "^8.0.3",
"vitest": "^3.1.4"
},
"overrides": {
"react": "$react",
"react-dom": "$react-dom"
}
}
@@ -0,0 +1,7 @@
{
"webcredentials": {
"apps": [
"GZLTTH5DLM.pub.agora.app"
]
}
}
+14 -7
View File
@@ -1,8 +1,15 @@
[{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "pub.ditto.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"]
[
{
"relation": [
"delegate_permission/common.handle_all_urls"
],
"target": {
"namespace": "android_app",
"package_name": "pub.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"
]
}
}
}]
]
+7
View File
@@ -0,0 +1,7 @@
# Changelog
## [1.0.0] - 2026-04-30
### Added
- Initial Agora 3 release.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 981 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 226 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 226 KiB

+14 -29
View File
@@ -1,32 +1,17 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<?xml version="1.0" encoding="UTF-8"?>
<svg
version="1.1"
viewBox="-5 -10 100 100"
id="svg6"
width="100"
height="100"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs6" />
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
>
<path
d="m 71.719615,49.36907 -0.62891,0.37109 c -0.12891,0.07031 -0.26172,0.14844 -0.39062,0.21875 -3.9883,10.309 -14.008,17.617 -25.699,17.617 -4.1211,0 -8.0312,-0.89844 -11.539,-2.5391 -0.12891,0.03906 -0.26172,0.07031 -0.39063,0.10156 l -0.35156,0.08984 h -0.02734 l -0.25,0.05859 -0.07813,0.01953 -0.10938,0.03125 c -0.55859,0.12891 -1.1289,0.26172 -1.6992,0.39062 -0.10156,0.03125 -0.19922,0.05078 -0.30078,0.07031 l -0.30078,0.10156 -0.18359,0.0078 c -0.26953,0.05859 -1.3086,0.26953 -1.3086,0.26953 -0.28906,0.05859 -0.55859,0.10937 -0.82813,0.17187 4.9805,3.3086 10.961,5.2305 17.371,5.2305 15.059,0 27.699,-10.602 30.828,-24.738 -0.75,0.48828 -1.5195,0.96875 -2.2891,1.4414 -0.59375,0.36328 -1.2031,0.72656 -1.8242,1.0859 z"
id="path1"
style="fill:#7c52e0;fill-opacity:1" />
<path
d="m 30.926615,29.47807 c 0.36328,-0.48828 0.75,-0.95312 1.1523,-1.3828 0.75781,-0.80469 0.71484,-2.0703 -0.08984,-2.8281 -0.80469,-0.75781 -2.0703,-0.71484 -2.8281,0.08984 -0.50781,0.53906 -0.99219,1.125 -1.4492,1.7383 -0.65625,0.88672 -0.47266,2.1406 0.41406,2.7969 0.35938,0.26562 0.77344,0.39453 1.1875,0.39453 0.61719,0 1.2227,-0.27734 1.6133,-0.80859 z"
id="path2"
style="fill:#7c52e0;fill-opacity:1" />
<path
d="m 26.742615,32.67807 c -1.0586,-0.3125 -2.1719,0.29687 -2.4805,1.3594 -0.55859,1.9062 -0.83984,3.9141 -0.83984,5.9609 0,2.3789 0.39062,4.7227 1.1602,6.9609 0.28516,0.82812 1.0625,1.3516 1.8906,1.3516 0.21484,0 0.43359,-0.03516 0.64844,-0.10938 1.043,-0.35938 1.6016,-1.4961 1.2422,-2.543 -0.625,-1.8203 -0.94141,-3.7227 -0.94141,-5.6602 0,-1.668 0.22656,-3.2969 0.67969,-4.8398 0.30859,-1.0586 -0.30078,-2.168 -1.3594,-2.4805 z"
id="path3"
style="fill:#7c52e0;fill-opacity:1" />
<path
d="m 14.691615,48.83807 c 0.10156,0.33984 0.19922,0.67969 0.32812,1.0195 0.42969,1.3516 0.94922,2.6484 1.5781,3.9102 0.10156,-0.01172 0.21094,-0.01172 0.32031,-0.01953 l 0.16016,-0.01172 0.80078,-0.07031 c 0.37109,-0.03906 0.67188,-0.07031 0.98047,-0.10156 0.51172,-0.05859 1.0195,-0.12109 1.5586,-0.19922 l 0.21875,-0.03125 c 0.07031,-0.01172 0.14062,-0.01953 0.21094,-0.03125 -1.2188,-2.2109 -2.1484,-4.6016 -2.7305,-7.1211 -0.16016,-0.71094 -0.30078,-1.4297 -0.39844,-2.1602 -0.19922,-1.3086 -0.30078,-2.6602 -0.30078,-4.0312 0,-0.89844 0.03906,-1.7812 0.12891,-2.6484 0.07031,-0.71094 0.16016,-1.4102 0.28906,-2.1016 2.25,-12.949 13.57,-22.828 27.16,-22.828 6.0508,0 11.648,1.9609 16.211,5.3008 0.57031,0.41016 1.1289,0.85938 1.6719,1.3203 1.6914,1.4219 3.2109,3.0703 4.5,4.8789 0.42969,0.60156 0.83984,1.2109 1.2188,1.8398 1.3203,2.1602 2.3398,4.5117 3.0195,7 0.23828,-0.17188 0.42969,-0.30859 0.62891,-0.46875 0.64844,-0.48047 1.2109,-0.92188 1.7383,-1.3398 0.28125,-0.23047 0.5,-0.41016 0.71094,-0.57812 0.10156,-0.07813 0.19141,-0.16016 0.28125,-0.23828 -0.42969,-1.3516 -0.96094,-2.6484 -1.5898,-3.8984 -0.14062,-0.32812 -0.30859,-0.64844 -0.48047,-0.96875 -0.32812,-0.64844 -0.69922,-1.2891 -1.0898,-1.9102 -1.6797,-2.7109 -3.7695,-5.1406 -6.1719,-7.2188 -0.57031,-0.48828 -1.1484,-0.96875 -1.7617,-1.4102 -0.55859,-0.42188 -1.1406,-0.82812 -1.7305,-1.2109 -4.9414,-3.2188 -10.852,-5.0898 -17.16,-5.0898 -14.961,0 -27.531,10.469 -30.75,24.469 -0.17188,0.67969 -0.30859,1.3711 -0.42188,2.0703 -0.12891,0.73828 -0.21875,1.5 -0.28906,2.2617 -0.07813,0.91016 -0.12109,1.8398 -0.12109,2.7812 0,2.3008 0.25,4.5508 0.71875,6.7109 0.17188,0.71484 0.35156,1.4258 0.5625,2.125 z"
id="path4"
style="fill:#7c52e0;fill-opacity:1" />
<path
d="m 90.441615,21.60007 c -2.1797,-5.3398 -9.4102,-7.3984 -21,-6.0391 1.8906,1.8906 3.5391,3.9688 4.9297,6.2109 0.28906,0.46094 0.55859,0.92187 0.80859,1.3789 5.5391,-0.12109 7.6094,1.0391 7.8398,1.4492 0.12891,0.46875 -0.55078,2.7305 -4.5898,6.4805 -0.01953,0.01953 -0.03125,0.03125 -0.03906,0.03906 -0.26172,0.23828 -0.51953,0.48047 -0.80078,0.71875 -0.19922,0.17969 -0.41016,0.35938 -0.62891,0.53906 -0.10938,0.10156 -0.21875,0.19141 -0.33984,0.28906 -0.23828,0.19922 -0.5,0.41016 -0.76172,0.62109 -0.12891,0.10156 -0.26172,0.21094 -0.39844,0.32031 -0.42969,0.33984 -0.89063,0.69141 -1.3711,1.0508 -0.26953,0.21094 -0.53906,0.41016 -0.82812,0.60938 -0.32031,0.23047 -0.64062,0.46875 -0.98047,0.69922 0,0.01172 -0.01172,0.01172 -0.01172,0.01172 -0.26953,0.19141 -0.55078,0.37891 -0.82812,0.57031 -0.28125,0.19141 -0.55859,0.37109 -0.85156,0.55859 -0.25,0.16016 -0.5,0.32812 -0.76172,0.48828 -6,3.8984 -13.48,7.7188 -21.379,10.922 -8.0117,3.2383 -15.871,5.6602 -22.93,7.0391 -0.30078,0.05859 -0.60156,0.12109 -0.89062,0.17188 -0.60938,0.12109 -1.2188,0.21875 -1.8203,0.32031 -0.07031,0.01172 -0.12891,0.01953 -0.19922,0.03125 h -0.01953 c -0.28906,0.05078 -0.57031,0.08984 -0.83984,0.12891 -0.30859,0.05078 -0.60938,0.08984 -0.91016,0.12891 -0.57031,0.07813 -1.1094,0.14844 -1.6406,0.21094 -0.35156,0.03906 -0.69141,0.07031 -1.0195,0.10156 -0.30078,0.03125 -0.58984,0.05078 -0.87891,0.07813 -0.48047,0.03125 -0.92969,0.05859 -1.3711,0.07813 -0.39844,0.01953 -0.78125,0.03125 -1.1484,0.03906 -5.5116996,0.10938 -7.5702996,-1.0391 -7.8007996,-1.4492 -0.12891,-0.48047 0.55078,-2.7383 4.6093996,-6.5 -0.12891,-0.48828 -0.26172,-1 -0.37891,-1.5391 -0.51953,-2.4219 -0.78906,-4.8906 -0.78906,-7.3594 0,-0.17969 0,-0.37109 0.01172,-0.55078 -9.2733996,7.082 -13.0229996,13.59 -10.8749996,18.949 1.7383,4.2695 6.7188,6.4492 14.5899996,6.4492 2.8594,0 6.1016,-0.28906 9.7109,-0.87109 0.17188,-0.03125 0.33984,-0.05859 0.51953,-0.08984 0.17188,-0.03125 0.35156,-0.05859 0.51953,-0.08984 l 1.2188,-0.21875 c 0.57031,-0.10156 1.1484,-0.21875 1.7305,-0.33984 0.53125,-0.10938 1.0508,-0.21094 1.5781,-0.32812 0.01172,0 0.03125,-0.01172 0.03906,-0.01172 0.05078,-0.01172 0.08984,-0.01953 0.14062,-0.03125 0.07031,-0.01172 0.12891,-0.03125 0.19922,-0.05078 0.57812,-0.12891 1.1602,-0.26172 1.7383,-0.39844 0.05078,-0.01172 0.10156,-0.03125 0.14844,-0.03906 0.21094,-0.05078 0.42188,-0.10156 0.64062,-0.14844 h 0.01172 c 6.0898,-1.5117 12.559,-3.6406 19.102,-6.2891 6.5508,-2.6602 12.68,-5.6289 18.109,-8.7812 0.21875,-0.12891 0.44141,-0.26172 0.66016,-0.39062 0.58984,-0.33984 1.1797,-0.69141 1.7617,-1.0508 1.5703,-0.96094 3.0586,-1.9297 4.4805,-2.9102 0.12891,-0.08984 0.26172,-0.17969 0.39062,-0.26953 11.242,-7.8477 15.941,-15.09 13.594,-20.938 z"
id="path5"
style="fill:#7c52e0;fill-opacity:1" />
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>

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 320 B

+5 -5
View File
@@ -1,11 +1,11 @@
{
"name": "Ditto",
"short_name": "Ditto",
"description": "A carnival, not a platform. Color, whimsy, games, and endless customization — the most fun you've had on the internet in years.",
"name": "Agora",
"short_name": "Agora",
"description": "Power to the people. Organize, create, and connect across the open Nostr network.",
"start_url": "/",
"display": "standalone",
"background_color": "#161b2e",
"theme_color": "#7c3aed",
"background_color": "#0a0c14",
"theme_color": "#e85d3c",
"icons": [
{
"src": "/icon-192.png",
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+5 -5
View File
@@ -1,5 +1,5 @@
/**
* Ditto Service Worker
* Agora Service Worker
*
* Handles incoming Web Push notifications from the nostr-push server and
* opens/focuses the app when the user taps a notification.
@@ -14,17 +14,17 @@ self.addEventListener('push', (event) => {
try {
payload = event.data.json();
} catch {
payload = { title: 'Ditto', body: event.data.text() };
payload = { title: 'Agora', body: event.data.text() };
}
const title = payload.title ?? 'Ditto';
const title = payload.title ?? 'Agora';
const options = {
body: payload.body ?? '',
icon: payload.icon ?? '/icon-192.png',
badge: payload.badge ?? '/icon-192.png',
data: payload.data ?? {},
requireInteraction: false,
tag: payload.data?.subscription_id ?? 'ditto-notification',
tag: payload.data?.subscription_id ?? 'agora-notification',
renotify: true,
};
@@ -42,7 +42,7 @@ self.addEventListener('notificationclick', (event) => {
self.clients
.matchAll({ type: 'window', includeUncontrolled: true })
.then((clientList) => {
// Focus an existing Ditto tab if one is open
// Focus an existing Agora tab if one is open
for (const client of clientList) {
if (new URL(client.url).origin === self.location.origin) {
client.navigate('/notifications');
+2 -2
View File
@@ -4,8 +4,8 @@
(function () {
// Builtin themes — must match builtinThemes in src/themes.ts
var builtins = {
dark: { bg: 'hsl(228 20% 10%)', primary: 'hsl(258 70% 60%)' },
light: { bg: 'hsl(270 50% 97%)', primary: 'hsl(270 65% 55%)' }
dark: { bg: 'hsl(0 0% 10%)', primary: 'hsl(15 90% 52%)' },
light: { bg: 'hsl(0 0% 100%)', primary: 'hsl(15 90% 48%)' }
};
var theme = 'dark';
+26 -5
View File
@@ -6,7 +6,7 @@ GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
echo -e "${GREEN}Generating Android app icons...${NC}\n"
echo -e "${GREEN}Generating app icons...${NC}\n"
# Check for inkscape (preferred) or rsvg-convert as fallback
if command -v inkscape &> /dev/null; then
@@ -138,12 +138,33 @@ cat > "$BACKGROUND_COLOR_FILE" << 'EOF'
</resources>
EOF
# ── iOS App Icon (1024x1024, white logo on purple background) ──
echo "Generating iOS app icon..."
IOS_ICON_DIR="ios/App/App/Assets.xcassets/AppIcon.appiconset"
if [ -d "$IOS_ICON_DIR" ]; then
IOS_ICON="$IOS_ICON_DIR/AppIcon-512@2x.png"
# Logo at ~60% of canvas, centered on purple background (matches legacy Android style)
$MAGICK -size "1024x1024" "xc:${BG_COLOR}" \
\( "$LOGO_WHITE" -resize "614x614" \) \
-gravity center -compose over -composite \
"$IOS_ICON"
echo -e " ${GREEN}${NC} $IOS_ICON"
else
echo -e " ${YELLOW}Skipped: $IOS_ICON_DIR not found${NC}"
fi
# Cleanup temp files
rm -rf "$TMPDIR"
echo -e "\n${GREEN}Android icons generated successfully!${NC}"
echo -e "\n${GREEN}App icons generated successfully!${NC}"
echo -e "Icon: white Ditto logo on ${GREEN}${BG_COLOR}${NC} (Ditto purple)"
echo -e "Generated:"
echo -e " - ic_launcher_foreground.png (adaptive, all densities)"
echo -e " - ic_launcher.png (legacy square, all densities)"
echo -e " - ic_launcher_round.png (legacy round, all densities)"
echo -e " Android:"
echo -e " - ic_launcher_foreground.png (adaptive, all densities)"
echo -e " - ic_launcher.png (legacy square, all densities)"
echo -e " - ic_launcher_round.png (legacy round, all densities)"
echo -e " iOS:"
echo -e " - AppIcon-512@2x.png (1024x1024)"
+246
View File
@@ -0,0 +1,246 @@
#!/usr/bin/env node
/**
* NIP-46 Client-Initiated Auth Script
*
* Generates an ephemeral client keypair and a `nostrconnect://` URI.
* Import the URI into a remote signer app (e.g. Amber) to authorize
* the client key. Once authorized, the script outputs:
*
* - bunker:// URI (for ZAPSTORE_BUNKER_URL)
* - client secret key hex (for ZAPSTORE_CLIENT_KEY)
*
* It also writes the client key to ~/.config/zsp/bunker-keys/<bunkerPubkey>.key
* so that `zsp` can use it immediately.
*
* Usage:
* node scripts/nip46-auth.mjs [--relay wss://relay.example.com] [--name MyApp] [--timeout 300]
*/
import { NPool, NRelay1, NConnectSigner, NSecSigner } from '@nostrify/nostrify';
import { generateSecretKey, getPublicKey, nip19 } from 'nostr-tools';
import { bytesToHex } from '@noble/hashes/utils';
import QRCode from 'qrcode';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
// ---------------------------------------------------------------------------
// CLI args
// ---------------------------------------------------------------------------
function parseArgs() {
const args = process.argv.slice(2);
const result = {
relays: [],
name: 'Agora',
timeout: 300, // seconds
};
for (let i = 0; i < args.length; i++) {
switch (args[i]) {
case '--relay':
result.relays.push(args[++i]);
break;
case '--name':
result.name = args[++i];
break;
case '--timeout':
result.timeout = parseInt(args[++i], 10);
break;
case '--help':
case '-h':
console.log(`Usage: node scripts/nip46-auth.mjs [options]
Options:
--relay <url> Relay URL for NIP-46 communication (repeatable)
Default: wss://relay.agora.spot
--name <name> Application name shown to the signer
Default: Agora
--timeout <sec> How long to wait for signer approval (seconds)
Default: 300 (5 minutes)
--help, -h Show this help message
`);
process.exit(0);
}
}
if (result.relays.length === 0) {
result.relays.push('wss://relay.agora.spot');
}
return result;
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
const opts = parseArgs();
// 1. Generate ephemeral client keypair
const clientSecretKey = generateSecretKey();
const clientPubkey = getPublicKey(clientSecretKey);
const clientHex = bytesToHex(clientSecretKey);
console.log('');
console.log('=== NIP-46 Client-Initiated Auth ===');
console.log('');
console.log(`Client pubkey: ${clientPubkey}`);
console.log(`Relay(s): ${opts.relays.join(', ')}`);
console.log(`Timeout: ${opts.timeout}s`);
console.log('');
// 2. Generate random secret
const randomBytes = new Uint8Array(4);
crypto.getRandomValues(randomBytes);
const secret = Array.from(randomBytes)
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
// 3. Build nostrconnect:// URI
const searchParams = new URLSearchParams();
for (const relay of opts.relays) {
searchParams.append('relay', relay);
}
searchParams.set('secret', secret);
searchParams.set('name', opts.name);
const nostrConnectURI = `nostrconnect://${clientPubkey}?${searchParams.toString()}`;
console.log('Scan this QR code with your signer app (e.g. Amber):');
console.log('');
console.log(await QRCode.toString(nostrConnectURI, { type: 'terminal', small: true }));
console.log('Or import this URI manually:');
console.log('');
console.log(` ${nostrConnectURI}`);
console.log('');
console.log('Waiting for signer to approve the connection...');
console.log('');
// 4. Set up relay pool
const pool = new NPool({
open: (url) => new NRelay1(url),
reqRouter: async (filters) => new Map(opts.relays.map((r) => [r, filters])),
eventRouter: async () => opts.relays,
});
const clientSigner = new NSecSigner(clientSecretKey);
const relayGroup = pool.group(opts.relays);
// 5. Subscribe and wait for the signer's response
const signal = AbortSignal.timeout(opts.timeout * 1000);
const sub = relayGroup.req(
[{ kinds: [24133], '#p': [clientPubkey], limit: 1 }],
{ signal },
);
let bunkerPubkey;
let userPubkey;
try {
for await (const msg of sub) {
if (msg[0] === 'CLOSED') {
throw new Error('Relay closed the subscription before signer responded');
}
if (msg[0] === 'EVENT') {
const event = msg[2];
let decrypted;
try {
decrypted = await clientSigner.nip44.decrypt(event.pubkey, event.content);
} catch {
// Could not decrypt -- not for us, skip
continue;
}
let response;
try {
response = JSON.parse(decrypted);
} catch {
continue;
}
if (response.result !== secret && response.result !== 'ack') {
continue;
}
bunkerPubkey = event.pubkey;
console.log(`Signer responded! Bunker pubkey: ${bunkerPubkey}`);
console.log('');
// 6. Get user pubkey via the now-established connection
const signer = new NConnectSigner({
relay: relayGroup,
pubkey: bunkerPubkey,
signer: clientSigner,
timeout: 60_000,
});
console.log('Requesting user public key...');
userPubkey = await signer.getPublicKey();
console.log(`User pubkey: ${userPubkey}`);
console.log('');
break;
}
}
} catch (err) {
if (err.name === 'TimeoutError' || err.name === 'AbortError') {
console.error(`Timed out after ${opts.timeout}s waiting for signer approval.`);
console.error('Make sure you imported the nostrconnect:// URI into your signer app.');
process.exit(1);
}
throw err;
}
if (!bunkerPubkey || !userPubkey) {
console.error('Failed to establish connection with remote signer.');
process.exit(1);
}
// 7. Build bunker:// URI (for CI)
const bunkerParams = new URLSearchParams();
for (const relay of opts.relays) {
bunkerParams.append('relay', relay);
}
const bunkerURI = `bunker://${bunkerPubkey}?${bunkerParams.toString()}`;
// 8. Write client key to zsp config
const zspDir = path.join(os.homedir(), '.config', 'zsp', 'bunker-keys');
const zspKeyFile = path.join(zspDir, `${bunkerPubkey}.key`);
fs.mkdirSync(zspDir, { recursive: true });
fs.writeFileSync(zspKeyFile, clientHex + '\n', { mode: 0o600 });
// 9. Print results
console.log('=== Connection Established ===');
console.log('');
console.log('Bunker URI (ZAPSTORE_BUNKER_URL):');
console.log(` ${bunkerURI}`);
console.log('');
console.log('Client secret key hex (ZAPSTORE_CLIENT_KEY):');
console.log(` ${clientHex}`);
console.log('');
console.log(`User pubkey: ${userPubkey}`);
console.log(`User npub: ${nip19.npubEncode(userPubkey)}`);
console.log('');
console.log(`zsp client key written to: ${zspKeyFile}`);
console.log('');
console.log('Next steps:');
console.log(' 1. Update ZAPSTORE_BUNKER_URL in GitLab CI/CD variables');
console.log(' 2. Update ZAPSTORE_CLIENT_KEY in GitLab CI/CD variables');
console.log('');
// Clean up
pool.close();
process.exit(0);
}
main().catch((err) => {
console.error('Fatal error:', err.message);
process.exit(1);
});
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env node
/**
* Patch capacitor.config.json to include local (non-SPM) plugin classes.
*
* `npx cap sync` regenerates the `packageClassList` array from SPM packages
* only, so local plugins compiled directly into the app binary (like
* SandboxPlugin) are not included. This script appends them after sync so
* the Capacitor bridge eagerly registers them at startup.
*
* Usage: node scripts/patch-cap-config.mjs
* Typically run after `npx cap sync`.
*/
import { readFileSync, writeFileSync } from 'fs';
import { resolve } from 'path';
/** Local plugin class names to ensure are registered. */
const LOCAL_PLUGINS = ['SandboxPlugin', 'DittoNotificationPlugin'];
const platforms = ['ios/App/App', 'android/app/src/main/assets'];
for (const platform of platforms) {
const configPath = resolve(platform, 'capacitor.config.json');
let config;
try {
config = JSON.parse(readFileSync(configPath, 'utf-8'));
} catch {
// Platform may not exist or config not yet generated — skip.
continue;
}
const classList = new Set(config.packageClassList ?? []);
let changed = false;
for (const plugin of LOCAL_PLUGINS) {
if (!classList.has(plugin)) {
classList.add(plugin);
changed = true;
}
}
if (changed) {
config.packageClassList = [...classList];
writeFileSync(configPath, JSON.stringify(config, null, '\t') + '\n');
console.log(`Patched ${configPath}: added ${LOCAL_PLUGINS.join(', ')}`);
}
}
+77 -41
View File
@@ -1,33 +1,32 @@
// 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 } from "@capacitor/core";
import { StatusBar, Style } from "@capacitor/status-bar";
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 { DMProvider, type DMConfig } from "@/components/DMProvider";
import { DMProviderWrapper } from "@/components/DMProviderWrapper";
import { InitialSyncGate } from "@/components/InitialSyncGate";
import { NativeNotifications } from "@/components/NativeNotifications";
import NostrProvider from "@/components/NostrProvider";
import { NostrSync } from "@/components/NostrSync";
import { PlausibleProvider } from "@/components/PlausibleProvider";
import { SentryProvider } from "@/components/SentryProvider";
import { Toaster } from "@/components/ui/toaster";
import { TooltipProvider } from "@/components/ui/tooltip";
import { useNsecPasteGuard } from "@/hooks/useNsecPasteGuard";
import type { AppConfig } from "@/contexts/AppContext";
import { NWCProvider } from "@/contexts/NWCContext";
import { PROTOCOL_MODE } from "@/lib/dmConstants";
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 dmConfig: DMConfig = {
enabled: true,
protocolMode: PROTOCOL_MODE.NIP04_OR_NIP17,
};
const head = createHead({
plugins: [InferSeoMetaPlugin()],
});
@@ -44,12 +43,12 @@ const queryClient = new QueryClient({
/** Hardcoded fallback values. Always provides every required field. */
const hardcodedConfig: AppConfig = {
appName: "Ditto",
appId: "ditto",
appName: "Agora",
appId: "agora",
homePage: "feed",
client: "naddr1qvzqqqru7cpzq7q6z5ns2hm5c8msyv83qwzxpxe52j8c4d4q5m92wsp9sflelkh9qqzkg6t5w3hswjl4yp",
magicMouse: false,
theme: "system",
autoShareTheme: true,
useAppRelays: true,
relayMetadata: {
relays: [],
@@ -57,7 +56,9 @@ const hardcodedConfig: AppConfig = {
},
feedSettings: {
feedIncludePosts: true,
feedIncludeComments: true,
feedIncludeReposts: true,
feedIncludeGenericReposts: true,
feedIncludeArticles: true,
showArticles: true,
showEvents: true,
@@ -84,13 +85,6 @@ const hardcodedConfig: AppConfig = {
showVideos: true,
feedIncludeNormalVideos: true,
feedIncludeShortVideos: true,
showProfileThemes: false,
feedIncludeProfileThemes: true,
showThemeDefinitions: true,
feedIncludeThemeDefinitions: true,
showProfileThemeUpdates: true,
feedIncludeProfileThemeUpdates: true,
showCustomProfileThemes: true,
feedIncludeVoiceMessages: true,
showEmojiPacks: true,
feedIncludeEmojiPacks: true,
@@ -104,6 +98,8 @@ const hardcodedConfig: AppConfig = {
feedIncludePodcastTrailers: true,
showDevelopment: true,
feedIncludeDevelopment: true,
showCommunities: true,
feedIncludeCommunities: true,
showBadges: true,
showBadgeDefinitions: true,
showProfileBadges: true,
@@ -113,17 +109,18 @@ const hardcodedConfig: AppConfig = {
followsFeedShowReplies: true,
},
sidebarOrder: [
"wallet",
"verified",
"actions",
"polls",
"world",
"badges",
"feed",
"notifications",
"search",
"bookmarks",
"messages",
"communities",
"profile",
"photos",
"videos",
"themes",
"theme",
"settings",
"help",
],
nip85StatsPubkey:
"5f68e85ee174102ca8978eef302129f081f03456c884185d5ec1c1224ab633ea",
@@ -142,28 +139,65 @@ const hardcodedConfig: AppConfig = {
plausibleEndpoint: import.meta.env.VITE_PLAUSIBLE_ENDPOINT || "",
savedFeeds: [],
imageQuality: 'compressed',
curatorPubkey: '932614571afcbad4d17a191ee281e39eebbb41b93fac8fd87829622aeb112f4d',
sandboxDomain: 'iframe.diy',
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: 'grok-4.1-fast',
aiSystemPrompt: '',
};
/**
* Merge hardcoded defaults with build-time ditto.json overrides.
* Parse and validate build-time app config overrides from the env string.
* Returns an empty object when no config file was provided or validation fails.
*/
function parseBuildConfig(): BuildConfig {
try {
const encodedConfig = import.meta.env.APP_CONFIG ?? import.meta.env.DITTO_CONFIG;
const json = JSON.parse(encodedConfig);
if (!json) return {};
return BuildConfigSchema.parse(json);
} catch {
return {};
}
}
/**
* Merge hardcoded defaults with build-time config overrides.
* Deep-merges feedSettings so a partial override doesn't erase defaults.
* Precedence (handled by AppProvider): user localStorage > build-time > hardcoded.
*/
const buildConfig = parseBuildConfig();
const defaultConfig: AppConfig = {
...hardcodedConfig,
...(typeof __DITTO_CONFIG__ !== "undefined" && __DITTO_CONFIG__
? __DITTO_CONFIG__
: {}),
...buildConfig,
feedSettings: { ...hardcodedConfig.feedSettings, ...buildConfig.feedSettings },
};
export function App() {
useNsecPasteGuard();
useEffect(() => {
// Initialize StatusBar for mobile apps
// 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()) {
StatusBar.setStyle({ style: Style.Dark }).catch(() => {
// StatusBar may not be available on all platforms
});
StatusBar.setOverlaysWebView({ overlay: true }).catch(() => {
// Ignore errors on unsupported platforms
SystemBars.setStyle({ style: SystemBarsStyle.Dark }).catch(() => {
// SystemBars may not be available on all platforms
});
}
}, []);
@@ -174,19 +208,21 @@ export function App() {
<SentryProvider>
<PlausibleProvider>
<QueryClientProvider client={queryClient}>
<NostrLoginProvider storageKey="nostr:login">
<NostrLoginProvider storageKey="nostr:login" storage={secureStorage}>
<NostrProvider>
<NostrSync />
<NativeNotifications />
<NWCProvider>
<DMProvider config={dmConfig}>
<SparkWalletProvider>
<DMProviderWrapper>
<TooltipProvider>
<Toaster />
<InitialSyncGate>
<AppRouter />
</InitialSyncGate>
</TooltipProvider>
</DMProvider>
</DMProviderWrapper>
</SparkWalletProvider>
</NWCProvider>
</NostrProvider>
</NostrLoginProvider>
+84 -121
View File
@@ -1,67 +1,75 @@
import { useState } from "react";
import { lazy, Suspense, useState } from "react";
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
import { AudioNavigationGuard } from "@/components/AudioNavigationGuard";
import { DeepLinkHandler } from "@/components/DeepLinkHandler";
import { MinimizedAudioBar } from "@/components/MinimizedAudioBar";
import { ReplyComposeModal } from "@/components/ReplyComposeModal";
import { AudioPlayerProvider } from "@/contexts/AudioPlayerContext";
import { sidebarItemIcon } from "@/lib/sidebarItems";
import { Toaster } from "./components/ui/toaster";
import { MainLayout } from "./components/MainLayout";
import { ScrollToTop } from "./components/ScrollToTop";
import { VersionCheck } from "./components/VersionCheck";
import { useCurrentUser } from "./hooks/useCurrentUser";
import { useProfileUrl } from "./hooks/useProfileUrl";
import { getExtraKindDef } from "./lib/extraKinds";
import { AdvancedSettingsPage } from "./pages/AdvancedSettingsPage";
import { AIChatPage } from "./pages/AIChatPage";
import { BadgesPage } from "./pages/BadgesPage";
import { BookmarksPage } from "./pages/BookmarksPage";
import { BooksPage } from "./pages/BooksPage";
import { ContentPage } from "./pages/ContentPage";
import { ContentSettingsPage } from "./pages/ContentSettingsPage";
import { DomainFeedPage } from "./pages/DomainFeedPage";
import { EventsFeedPage } from "./pages/EventsFeedPage";
import { ExternalContentPage } from "./pages/ExternalContentPage";
import { GeotagPage } from "./pages/GeotagPage";
import { HashtagPage } from "./pages/HashtagPage";
import { HelpPage } from "./pages/HelpPage";
import { HomePage } from "./pages/HomePage";
// Critical-path pages: eagerly loaded (landing + fallback)
import Index from "./pages/Index";
import { KindFeedPage } from "./pages/KindFeedPage";
import { MagicSettingsPage } from "./pages/MagicSettingsPage";
import { MusicFeedPage } from "./pages/MusicFeedPage";
import { NetworkSettingsPage } from "./pages/NetworkSettingsPage";
import { NIP19Page } from "./pages/NIP19Page";
import NotFound from "./pages/NotFound";
import { NotificationSettings } from "./pages/NotificationSettings";
import { NotificationsPage } from "./pages/NotificationsPage";
import { PhotosFeedPage } from "./pages/PhotosFeedPage";
import { PodcastsFeedPage } from "./pages/PodcastsFeedPage";
import { CSAEPolicyPage } from "./pages/CSAEPolicyPage";
import { PrivacyPolicyPage } from "./pages/PrivacyPolicyPage";
import { ProfileSettings } from "./pages/ProfileSettings";
import { RelayPage } from "./pages/RelayPage";
import { SearchPage } from "./pages/SearchPage";
import { SettingsPage } from "./pages/SettingsPage";
import { ThemesPage } from "./pages/ThemesPage";
import { TreasuresPage } from "./pages/TreasuresPage";
import { TrendsPage } from "./pages/TrendsPage";
import { UserListsPage } from "./pages/UserListsPage";
import { VideosFeedPage } from "./pages/VideosFeedPage";
import { VinesFeedPage } from "./pages/VinesFeedPage";
import { WalletSettingsPage } from "./pages/WalletSettingsPage";
import { WebxdcFeedPage } from "./pages/WebxdcFeedPage";
import { WorldPage } from "./pages/WorldPage";
import { ArchivePage } from "./pages/ArchivePage";
import { BlueskyPage } from "./pages/BlueskyPage";
import { WikipediaPage } from "./pages/WikipediaPage";
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 })));
// All other pages: code-split via React.lazy
const ActionsPage = lazy(() => import("./pages/ActionsPage"));
const AdvancedSettingsPage = lazy(() => import("./pages/AdvancedSettingsPage").then(m => ({ default: m.AdvancedSettingsPage })));
const AppearanceSettingsPage = lazy(() => import("./pages/AppearanceSettingsPage").then(m => ({ default: m.AppearanceSettingsPage })));
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 BookmarksPage = lazy(() => import("./pages/BookmarksPage").then(m => ({ default: m.BookmarksPage })));
const ChangelogPage = lazy(() => import("./pages/ChangelogPage").then(m => ({ default: m.ChangelogPage })));
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 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 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 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 AIChatPage = lazy(() => import("./pages/AIChatPage").then(m => ({ default: m.AIChatPage })));
const OrganizersPage = lazy(() => import("./pages/OrganizersPage").then(m => ({ default: m.OrganizersPage })));
const PhotosFeedPage = lazy(() => import("./pages/PhotosFeedPage").then(m => ({ default: m.PhotosFeedPage })));
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 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 FollowPage = lazy(() => import("./pages/FollowPage").then(m => ({ default: m.FollowPage })));
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")!;
/** Polls feed page with a FAB that opens the compose modal (poll mode via + menu). */
function PollsFeedPage() {
@@ -74,7 +82,11 @@ function PollsFeedPage() {
icon={sidebarItemIcon("polls", "size-5")}
onFabClick={() => setComposeOpen(true)}
/>
<ReplyComposeModal open={composeOpen} onOpenChange={setComposeOpen} initialMode="poll" />
{composeOpen && (
<Suspense fallback={null}>
<ReplyComposeModal open={composeOpen} onOpenChange={setComposeOpen} initialMode="poll" />
</Suspense>
)}
</>
);
}
@@ -91,23 +103,29 @@ export function AppRouter() {
return (
<AudioPlayerProvider>
<BrowserRouter>
<Toaster />
<VersionCheck />
<MinimizedAudioBar />
<AudioNavigationGuard />
<DeepLinkHandler />
<ScrollToTop />
<Routes>
{/* Auto-follow deep link: fullscreen immersive (no sidebars/nav) */}
<Route path="/follow/:npub" element={<FollowPage />} />
{/* All routes share the persistent MainLayout (sidebar + nav) */}
<Route element={<MainLayout />}>
<Route path="/" element={<HomePage />} />
<Route path="/feed" element={<Index />} />
<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 />} />
<Route path="/feed/:domain" element={<DomainFeedPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/settings/appearance" element={<AppearanceSettingsPage />} />
<Route path="/settings/profile" element={<ProfileSettings />} />
<Route path="/settings/feed" element={<ContentSettingsPage />} />
<Route path="/settings/content" element={<ContentPage />} />
@@ -116,6 +134,7 @@ export function AppRouter() {
path="/settings/notifications"
element={<NotificationSettings />}
/>
<Route path="/settings/messaging" element={<MessagingSettingsPage />} />
<Route
path="/settings/advanced"
element={<AdvancedSettingsPage />}
@@ -125,38 +144,9 @@ export function AppRouter() {
<Route path="/lists" element={<UserListsPage />} />
<Route path="/events" element={<EventsFeedPage />} />
<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={<MusicFeedPage />} />
<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
path="/articles"
element={
@@ -164,62 +154,35 @@ export function AppRouter() {
kind={articlesDef.kind}
title={articlesDef.label}
icon={sidebarItemIcon("articles", "size-5")}
fabHref="/articles/new"
/>
}
/>
<Route
path="/decks"
element={
<KindFeedPage
kind={decksDef.kind}
title={decksDef.label}
icon={sidebarItemIcon("decks", "size-5")}
/>
}
/>
<Route
path="/emojis"
element={
<KindFeedPage
kind={emojisDef.kind}
title={emojisDef.label}
icon={sidebarItemIcon("emojis", "size-5")}
/>
}
/>
<Route
path="/development"
element={
<KindFeedPage
kind={[
developmentDef.kind,
...(developmentDef.extraFeedKinds ?? []),
]}
title={developmentDef.label}
icon={sidebarItemIcon("development", "size-5")}
showFAB={false}
/>
}
/>
<Route path="/themes" element={<ThemesPage />} />
<Route path="/bookmarks" element={<BookmarksPage />} />
<Route path="/ai-chat" element={<AIChatPage />} />
<Route path="/wallet" element={<WalletPage />} />
<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="/letters" element={<LettersPage />} />
<Route path="/letters/compose" element={<LetterComposePage />} />
<Route path="/settings/letters" element={<LetterPreferencesPage />} />
<Route path="/help" element={<HelpPage />} />
<Route path="/privacy" element={<PrivacyPolicyPage />} />
<Route path="/safety" element={<CSAEPolicyPage />} />
<Route path="/changelog" element={<ChangelogPage />} />
<Route path="/r/*" element={<RelayPage />} />
<Route
path="/settings/lists"
element={<Navigate to="/lists" replace />}
/>
<Route path="/i/*" element={<ExternalContentPage />} />
<Route path="/actions" element={<ActionsPage />} />
<Route path="/agent" element={<AIChatPage />} />
<Route path="/organizers" element={<OrganizersPage />} />
{/* Callback target for remote signers (e.g. Amber, Primal) after NIP-46 approval */}
<Route path="/remoteloginsuccess" element={<RemoteLoginSuccessPage />} />
{/* NIP-19 route for npub1, note1, naddr1, nevent1, nprofile1 */}
<Route path="/:nip19" element={<NIP19Page />} />
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}
+613
View File
@@ -0,0 +1,613 @@
import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
import { UserPlus, Loader2, X, Search, Crown, Users } from 'lucide-react';
import { useNostr } from '@nostrify/react';
import { useQueryClient } from '@tanstack/react-query';
import type { NostrEvent } from '@nostrify/nostrify';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { ImageUploadField } from '@/components/ImageUploadField';
import { getAvatarShape } from '@/lib/avatarShape';
import { EmojifiedText } from '@/components/CustomEmoji';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useToast } from '@/hooks/useToast';
import { useSearchProfiles, type SearchProfile } from '@/hooks/useSearchProfiles';
import { PortalContainerProvider } from '@/hooks/usePortalContainer';
import { genUserName } from '@/lib/genUserName';
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import {
COMMUNITY_DEFINITION_KIND,
BADGE_DEFINITION_KIND,
BADGE_AWARD_KIND,
EMPTY_MODERATION,
type CommunityMember,
type CommunityMembership,
type CommunityModeration,
type ParsedCommunity,
} from '@/lib/communityUtils';
import { cn } from '@/lib/utils';
// ── Types ─────────────────────────────────────────────────────────────────────
type MemberRole = 'moderator' | 'member';
interface PendingMember {
profile: SearchProfile;
role: MemberRole;
}
interface CommunityMembersCacheValue {
membership: CommunityMembership;
moderation: CommunityModeration;
rankMap: Map<string, CommunityMember>;
}
interface AddMemberDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** The raw community definition event. */
communityEvent: NostrEvent;
/** Parsed community data. */
community: ParsedCommunity;
/** Whether the current user is the founder (can add moderators). */
isFounder: boolean;
/** Existing active members and moderators, excluded from duplicate adds. */
existingMemberPubkeys: string[];
}
// ── Component ─────────────────────────────────────────────────────────────────
export function AddMemberDialog({
open,
onOpenChange,
communityEvent,
community,
isFounder,
existingMemberPubkeys,
}: AddMemberDialogProps) {
const { user } = useCurrentUser();
const { nostr } = useNostr();
const { toast } = useToast();
const queryClient = useQueryClient();
// Form state
const [pendingMembers, setPendingMembers] = useState<PendingMember[]>([]);
const [badgeImageUrl, setBadgeImageUrl] = useState('');
const [isBadgeImageUploading, setIsBadgeImageUploading] = useState(false);
const [isPublishing, setIsPublishing] = useState(false);
const [portalContainer, setPortalContainer] = useState<HTMLElement | undefined>(undefined);
const dialogContentRef = useCallback((node: HTMLElement | null) => {
setPortalContainer(node ?? undefined);
}, []);
// Mutations
const { mutateAsync: publishEvent } = useNostrPublish();
// Does this community already have a member badge definition?
const existingBadgeATag = community.memberBadgeATag;
const hasBadge = !!existingBadgeATag;
// Are there any pending members with the "member" role?
const hasPendingMembers = pendingMembers.some((m) => m.role === 'member');
// Will we need to create a badge? (members added + no badge exists yet)
const needsBadgeCreation = hasPendingMembers && !hasBadge;
const resetForm = useCallback(() => {
setPendingMembers([]);
setBadgeImageUrl('');
setIsBadgeImageUploading(false);
setIsPublishing(false);
}, []);
const handleOpenChange = useCallback((nextOpen: boolean) => {
if (!nextOpen) resetForm();
onOpenChange(nextOpen);
}, [onOpenChange, resetForm]);
// ── People management ─────────────────────────────────────────────────────
const addPerson = useCallback((profile: SearchProfile) => {
if (!user) return;
if (profile.pubkey === community.founderPubkey) {
toast({ title: 'Already the founder' });
return;
}
if (existingMemberPubkeys.includes(profile.pubkey)) {
toast({ title: 'Already in the community' });
return;
}
if (pendingMembers.some((m) => m.profile.pubkey === profile.pubkey)) {
toast({ title: 'Already added' });
return;
}
// Default role: member if they're not already a moderator, moderator if founder is adding
const defaultRole: MemberRole = isFounder ? 'moderator' : 'member';
setPendingMembers((prev) => [...prev, { profile, role: defaultRole }]);
}, [user, community.founderPubkey, existingMemberPubkeys, pendingMembers, isFounder, toast]);
const removePerson = useCallback((pubkey: string) => {
setPendingMembers((prev) => prev.filter((m) => m.profile.pubkey !== pubkey));
}, []);
const toggleRole = useCallback((pubkey: string) => {
if (!isFounder) return; // Only founder can toggle to moderator
setPendingMembers((prev) => prev.map((m) =>
m.profile.pubkey === pubkey
? { ...m, role: m.role === 'moderator' ? 'member' : 'moderator' }
: m,
));
}, [isFounder]);
const applyOptimisticMembership = useCallback((members: PendingMember[], awardEvents: Map<string, NostrEvent>) => {
queryClient.setQueryData<CommunityMembersCacheValue>(['community-members', community.aTag], (prev) => {
const moderation = prev?.moderation ?? EMPTY_MODERATION;
const rankMap = new Map(prev?.rankMap ?? []);
const membershipByPubkey = new Map(
(prev?.membership.members ?? []).map((member) => [member.pubkey, member] as const),
);
const seedRankZero = (pubkey: string) => {
if (moderation.bannedPubkeys.has(pubkey)) return;
const member: CommunityMember = { pubkey, rank: 0 };
if (!membershipByPubkey.has(pubkey)) membershipByPubkey.set(pubkey, member);
if (!rankMap.has(pubkey)) rankMap.set(pubkey, member);
};
seedRankZero(community.founderPubkey);
community.moderatorPubkeys.forEach(seedRankZero);
for (const pending of members) {
if (moderation.bannedPubkeys.has(pending.profile.pubkey)) continue;
const nextMember: CommunityMember = pending.role === 'moderator'
? { pubkey: pending.profile.pubkey, rank: 0 }
: {
pubkey: pending.profile.pubkey,
rank: 1,
awardEvent: awardEvents.get(pending.profile.pubkey),
awardedBy: user?.pubkey,
};
const current = membershipByPubkey.get(nextMember.pubkey);
if (!current || nextMember.rank < current.rank) {
membershipByPubkey.set(nextMember.pubkey, nextMember);
}
const currentRank = rankMap.get(nextMember.pubkey);
if (!currentRank || nextMember.rank < currentRank.rank) {
rankMap.set(nextMember.pubkey, nextMember);
}
}
const membership: CommunityMembership = {
members: Array.from(membershipByPubkey.values()).sort((a, b) => a.rank - b.rank),
};
return { membership, moderation, rankMap };
});
}, [community.aTag, community.founderPubkey, community.moderatorPubkeys, queryClient, user?.pubkey]);
// ── Publish ───────────────────────────────────────────────────────────────
const handleSubmit = useCallback(async () => {
if (!user || pendingMembers.length === 0) return;
if (isBadgeImageUploading) {
toast({ title: 'Image is still uploading', description: 'Please wait for the upload to finish.' });
return;
}
if (badgeImageUrl.trim() && !sanitizeUrl(badgeImageUrl.trim())) {
toast({ title: 'Image URL must be a valid https URL', variant: 'destructive' });
return;
}
if (needsBadgeCreation && !isFounder) {
toast({ title: 'Member badge is missing', description: 'Only the founder can initialize community membership.', variant: 'destructive' });
return;
}
setIsPublishing(true);
try {
const newModerators = pendingMembers.filter((m) => m.role === 'moderator');
const newMembers = pendingMembers.filter((m) => m.role === 'member');
let badgeATag = existingBadgeATag;
// Step 1: Create badge definition if needed
if (newMembers.length > 0 && !hasBadge) {
const badgeDTag = `${community.dTag}-member`;
const existingBadge = await nostr.query([{
kinds: [BADGE_DEFINITION_KIND],
authors: [user.pubkey],
'#d': [badgeDTag],
limit: 1,
}]);
if (existingBadge.length > 0) {
toast({
title: 'Member badge ID already in use',
description: 'This community needs a member badge, but that badge identifier already exists on your account.',
variant: 'destructive',
});
setIsPublishing(false);
return;
}
const badgeTags: string[][] = [
['d', badgeDTag],
['name', 'Member'],
['description', `Member of ${community.name}`],
];
const sanitizedBadgeImage = sanitizeUrl(badgeImageUrl.trim());
if (sanitizedBadgeImage) {
badgeTags.push(['image', sanitizedBadgeImage, '1024x1024']);
}
badgeTags.push(['alt', `Badge definition: Member of ${community.name}`]);
const badgeEvent = await publishEvent({
kind: BADGE_DEFINITION_KIND,
content: '',
tags: badgeTags,
} as Omit<NostrEvent, 'id' | 'pubkey' | 'sig'>);
badgeATag = `${BADGE_DEFINITION_KIND}:${badgeEvent.pubkey}:${badgeDTag}`;
}
// Step 2: Republish community definition if needed
// Needed when: adding moderators (new p tags) OR badge was just created (new a tag)
const needsCommunityUpdate = newModerators.length > 0 || (newMembers.length > 0 && !hasBadge);
if (needsCommunityUpdate) {
// Fetch fresh community event to avoid stale overwrites
const prev = await fetchFreshEvent(nostr, {
kinds: [COMMUNITY_DEFINITION_KIND],
authors: [communityEvent.pubkey],
'#d': [community.dTag],
});
const baseTags = prev?.tags ?? communityEvent.tags;
const updatedTags = [...baseTags];
// Add new moderator p tags
for (const mod of newModerators) {
// Don't add if already exists
const exists = updatedTags.some(
([n, v, , role]) => n === 'p' && v === mod.profile.pubkey && role === 'moderator',
);
if (!exists) {
updatedTags.push(['p', mod.profile.pubkey, '', 'moderator']);
}
}
// Add badge a tag if badge was just created
if (badgeATag && !hasBadge) {
updatedTags.push(['a', badgeATag, '', 'member']);
}
const updatedEvent = await publishEvent({
kind: COMMUNITY_DEFINITION_KIND,
content: prev?.content ?? '',
tags: updatedTags,
prev: prev ?? undefined,
} as Omit<NostrEvent, 'id' | 'pubkey' | 'sig'> & { prev?: NostrEvent });
queryClient.setQueryData(
['addr-event', COMMUNITY_DEFINITION_KIND, communityEvent.pubkey, community.dTag],
updatedEvent,
);
queryClient.setQueryData(['event', updatedEvent.id], updatedEvent);
}
// Step 3: Publish badge awards for each member
const memberAwardEvents = new Map<string, NostrEvent>();
if (newMembers.length > 0 && badgeATag) {
for (const member of newMembers) {
const awardEvent = await publishEvent({
kind: BADGE_AWARD_KIND,
content: '',
tags: [
['a', badgeATag],
['p', member.profile.pubkey],
['alt', `Badge award: Member in ${community.name}`],
],
} as Omit<NostrEvent, 'id' | 'pubkey' | 'sig'>);
memberAwardEvents.set(member.profile.pubkey, awardEvent);
}
}
applyOptimisticMembership(pendingMembers, memberAwardEvents);
queryClient.invalidateQueries({ queryKey: ['addr-event', COMMUNITY_DEFINITION_KIND, communityEvent.pubkey, community.dTag] });
queryClient.invalidateQueries({ queryKey: ['community-members', community.aTag] });
queryClient.invalidateQueries({ queryKey: ['community-activity-feed'], exact: false });
queryClient.invalidateQueries({ queryKey: ['my-communities'], exact: false });
if (!hasBadge && newMembers.length > 0) {
queryClient.invalidateQueries({ queryKey: ['badge-feed'] });
}
const addedCount = pendingMembers.length;
toast({ title: `Added ${addedCount} ${addedCount === 1 ? 'person' : 'people'} to the community` });
handleOpenChange(false);
} catch (err) {
toast({
title: 'Failed to add members',
description: err instanceof Error ? err.message : 'Please try again.',
variant: 'destructive',
});
} finally {
setIsPublishing(false);
}
}, [
user, pendingMembers, existingBadgeATag, hasBadge, needsBadgeCreation, isFounder, community, communityEvent,
badgeImageUrl, nostr, publishEvent, queryClient, toast, handleOpenChange, applyOptimisticMembership, isBadgeImageUploading,
]);
if (!user) return null;
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent ref={dialogContentRef} className="sm:max-w-md gap-0 p-0 overflow-visible">
<PortalContainerProvider value={portalContainer}>
<DialogHeader className="px-5 pt-5 pb-3">
<DialogTitle className="flex items-center gap-2">
<UserPlus className="size-5 text-primary" />
Add Members
</DialogTitle>
<DialogDescription>
{isFounder
? 'Add moderators or members to your community.'
: 'Invite members to the community.'}
</DialogDescription>
</DialogHeader>
<ScrollArea className="max-h-[60vh]">
<div className="px-5 pb-5 space-y-4">
{/* People search */}
<div className="space-y-1.5">
<Label>Search people</Label>
<PersonSearch
onAdd={addPerson}
excludePubkeys={[
community.founderPubkey,
...existingMemberPubkeys,
...pendingMembers.map((m) => m.profile.pubkey),
]}
/>
</div>
{/* Pending members list */}
{pendingMembers.length > 0 && (
<div className="space-y-1.5">
<Label>
People to add
<span className="text-muted-foreground font-normal ml-1">({pendingMembers.length})</span>
</Label>
<div className="space-y-1">
{pendingMembers.map((pm) => (
<PendingMemberChip
key={pm.profile.pubkey}
pending={pm}
onRemove={removePerson}
onToggleRole={isFounder ? toggleRole : undefined}
/>
))}
</div>
</div>
)}
{/* Badge image — only shown when badge needs to be created */}
{needsBadgeCreation && (
<ImageUploadField
id="member-badge-image"
label={<>Member Badge Image <span className="text-muted-foreground font-normal">(optional)</span></>}
value={badgeImageUrl}
onChange={setBadgeImageUrl}
onUploadingChange={setIsBadgeImageUploading}
uploadToastTitle="Badge image uploaded"
previewAlt="Badge preview"
objectFit="contain"
dropAreaClassName="min-h-24"
/>
)}
{/* Submit button */}
<Button
onClick={handleSubmit}
disabled={pendingMembers.length === 0 || isPublishing || isBadgeImageUploading}
className="w-full gap-2"
>
{isPublishing ? (
<><Loader2 className="size-4 animate-spin" /> Adding...</>
) : (
<><UserPlus className="size-4" /> Add {pendingMembers.length || ''} {pendingMembers.length === 1 ? 'Person' : pendingMembers.length > 1 ? 'People' : 'Members'}</>
)}
</Button>
</div>
</ScrollArea>
</PortalContainerProvider>
</DialogContent>
</Dialog>
);
}
// ── Sub-Components ────────────────────────────────────────────────────────────
/** Inline type-ahead person search. */
function PersonSearch({
onAdd,
excludePubkeys,
}: {
onAdd: (profile: SearchProfile) => void;
excludePubkeys: string[];
}) {
const [query, setQuery] = useState('');
const [dropdownOpen, setDropdownOpen] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const { data: profiles, isFetching } = useSearchProfiles(query);
const excludeSet = useMemo(() => new Set(excludePubkeys), [excludePubkeys]);
const filteredProfiles = useMemo(
() => (profiles ?? []).filter((p) => !excludeSet.has(p.pubkey)),
[profiles, excludeSet],
);
useEffect(() => {
if (query.trim().length > 0 && filteredProfiles.length > 0) {
setDropdownOpen(true);
} else if (query.trim().length === 0) {
setDropdownOpen(false);
}
}, [filteredProfiles, query]);
const handleSelect = useCallback((profile: SearchProfile) => {
onAdd(profile);
setQuery('');
setDropdownOpen(false);
inputRef.current?.focus();
}, [onAdd]);
return (
<Popover open={dropdownOpen} onOpenChange={setDropdownOpen}>
<PopoverTrigger asChild>
<div className="relative flex items-center">
<Search className="absolute left-3 size-4 text-muted-foreground pointer-events-none" />
{isFetching && query.trim() && (
<Loader2 className="absolute right-3 size-4 text-muted-foreground animate-spin" />
)}
<Input
ref={inputRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
onFocus={() => {
if (query.trim().length > 0 && filteredProfiles.length > 0) {
setDropdownOpen(true);
}
}}
placeholder="Search people to add..."
className="pl-10 pr-10 rounded-full bg-secondary border-0 focus-visible:ring-0 focus-visible:ring-offset-0 h-9 text-sm"
autoComplete="off"
/>
</div>
</PopoverTrigger>
<PopoverContent
align="start"
side="bottom"
sideOffset={6}
onOpenAutoFocus={(e) => e.preventDefault()}
className="z-[270] w-[var(--radix-popover-trigger-width)] rounded-xl border-border p-0 shadow-lg overflow-hidden"
>
{filteredProfiles.length > 0 ? (
<div className="max-h-[200px] overflow-y-auto py-1">
{filteredProfiles.map((profile) => (
<SearchResultItem key={profile.pubkey} profile={profile} onClick={handleSelect} />
))}
</div>
) : query.trim().length >= 2 && !isFetching ? (
<div className="py-4 text-center text-sm text-muted-foreground">
No people found
</div>
) : null}
</PopoverContent>
</Popover>
);
}
/** A pending member chip with role toggle and remove button. */
function PendingMemberChip({
pending,
onRemove,
onToggleRole,
}: {
pending: PendingMember;
onRemove: (pubkey: string) => void;
onToggleRole?: (pubkey: string) => void;
}) {
const { profile, role } = pending;
const { metadata, pubkey } = profile;
const displayName = metadata.display_name || metadata.name || genUserName(pubkey);
return (
<div className="flex items-center gap-2 p-2 rounded-lg bg-secondary/30 border border-border/50">
<Avatar shape={getAvatarShape(metadata)} className="size-7 shrink-0">
<AvatarImage src={metadata.picture} alt={displayName} />
<AvatarFallback className="bg-primary/20 text-primary text-[10px]">
{displayName[0]?.toUpperCase() || '?'}
</AvatarFallback>
</Avatar>
<span className="flex-1 text-sm truncate">
<EmojifiedText tags={profile.event.tags}>{displayName}</EmojifiedText>
</span>
{/* Role badge — clickable if founder can toggle */}
<button
type="button"
onClick={onToggleRole ? () => onToggleRole(pubkey) : undefined}
disabled={!onToggleRole}
className={cn(
'flex items-center gap-1 text-xs font-medium px-2 py-0.5 rounded-full shrink-0 transition-colors',
role === 'moderator'
? 'bg-amber-500/10 text-amber-600 dark:text-amber-400'
: 'bg-primary/10 text-primary',
onToggleRole && 'cursor-pointer hover:opacity-80',
)}
title={onToggleRole ? 'Click to toggle role' : undefined}
>
{role === 'moderator' ? <Crown className="size-3" /> : <Users className="size-3" />}
{role === 'moderator' ? 'Moderator' : 'Member'}
</button>
<button
type="button"
onClick={() => onRemove(pubkey)}
className="shrink-0 size-6 rounded-full hover:bg-destructive/10 flex items-center justify-center transition-colors"
title="Remove"
>
<X className="size-3.5 text-muted-foreground hover:text-destructive" />
</button>
</div>
);
}
/** A profile search result row. */
function SearchResultItem({ profile, onClick }: { profile: SearchProfile; onClick: (profile: SearchProfile) => void }) {
const { metadata, pubkey } = profile;
const displayName = metadata.display_name || metadata.name || genUserName(pubkey);
return (
<button
className="w-full flex items-center gap-3 px-3 py-2 text-left transition-colors cursor-pointer hover:bg-secondary/60"
onClick={() => onClick(profile)}
onMouseDown={(e) => e.preventDefault()}
>
<Avatar shape={getAvatarShape(metadata)} className="size-8 shrink-0">
<AvatarImage src={metadata.picture} alt={displayName} />
<AvatarFallback className="bg-primary/20 text-primary text-xs">
{displayName[0]?.toUpperCase() || '?'}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<span className="text-sm font-medium truncate block">
<EmojifiedText tags={profile.event.tags}>{displayName}</EmojifiedText>
</span>
{metadata.nip05 && (
<span className="text-xs text-muted-foreground truncate block">
{metadata.nip05.startsWith('_@') ? metadata.nip05.slice(2) : metadata.nip05}
</span>
)}
</div>
</button>
);
}
+231 -11
View File
@@ -1,15 +1,21 @@
import { useState } from 'react';
import { ChevronDown, ChevronUp, Bug, RotateCcw, AlertTriangle } from 'lucide-react';
import { useState, useEffect } from 'react';
import { ChevronDown, ChevronUp, RotateCcw, AlertTriangle, Eye, EyeOff } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
import { RequestToVanishDialog } from '@/components/RequestToVanishDialog';
import { useAppContext } from '@/hooks/useAppContext';
import { useToast } from '@/hooks/useToast';
import { useEncryptedSettings } from '@/hooks/useEncryptedSettings';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { DEFAULT_SYSTEM_PROMPT_TEMPLATE } from '@/lib/aiChatSystemPrompt';
/** Hardcoded default values for Agent provider fields. Used for reset buttons. */
const DEFAULT_AI_BASE_URL = 'https://ai.shakespeare.diy/v1';
const DEFAULT_AI_MODEL = 'grok-4.1-fast';
/** The build-time default DSN from the environment variable. */
const DEFAULT_SENTRY_DSN = import.meta.env.VITE_SENTRY_DSN || '';
@@ -20,6 +26,7 @@ export function AdvancedSettings() {
const { updateSettings } = useEncryptedSettings();
const { user } = useCurrentUser();
const [systemOpen, setSystemOpen] = useState(true);
const [aiOpen, setAiOpen] = useState(false);
const [sentryOpen, setSentryOpen] = useState(false);
const [dangerOpen, setDangerOpen] = useState(false);
const [vanishDialogOpen, setVanishDialogOpen] = useState(false);
@@ -28,6 +35,73 @@ export function AdvancedSettings() {
const [linkPreviewUrl, setLinkPreviewUrl] = useState(config.linkPreviewUrl);
const [corsProxy, setCorsProxy] = useState(config.corsProxy);
const [sentryDsn, setSentryDsn] = useState(config.sentryDsn);
const [baseUrlDraft, setBaseUrlDraft] = useState(config.aiBaseURL);
const [apiKeyDraft, setApiKeyDraft] = useState(config.aiApiKey);
const [modelDraft, setModelDraft] = useState(config.aiModel);
const [showApiKey, setShowApiKey] = useState(false);
const [systemPromptDraft, setSystemPromptDraft] = useState(config.aiSystemPrompt || DEFAULT_SYSTEM_PROMPT_TEMPLATE);
useEffect(() => { setBaseUrlDraft(config.aiBaseURL); }, [config.aiBaseURL]);
useEffect(() => { setApiKeyDraft(config.aiApiKey); }, [config.aiApiKey]);
useEffect(() => { setModelDraft(config.aiModel); }, [config.aiModel]);
const commitBaseUrl = () => {
const trimmed = baseUrlDraft.trim().replace(/\/+$/, '');
if (!trimmed) {
setBaseUrlDraft(DEFAULT_AI_BASE_URL);
if (config.aiBaseURL !== DEFAULT_AI_BASE_URL) {
updateConfig((current) => ({ ...current, aiBaseURL: DEFAULT_AI_BASE_URL }));
toast({ title: 'Base URL reset to default' });
}
return;
}
if (trimmed !== config.aiBaseURL) {
updateConfig((current) => ({ ...current, aiBaseURL: trimmed }));
toast({ title: 'AI base URL updated' });
}
};
const commitApiKey = () => {
const trimmed = apiKeyDraft.trim();
if (trimmed !== config.aiApiKey) {
updateConfig((current) => ({ ...current, aiApiKey: trimmed }));
toast({ title: trimmed ? 'API key updated' : 'API key cleared (using NIP-98 auth)' });
}
};
const commitModel = () => {
const trimmed = modelDraft.trim();
if (!trimmed) {
setModelDraft(DEFAULT_AI_MODEL);
if (config.aiModel !== DEFAULT_AI_MODEL) {
updateConfig((current) => ({ ...current, aiModel: DEFAULT_AI_MODEL }));
toast({ title: 'AI model reset to default' });
}
return;
}
if (trimmed !== config.aiModel) {
updateConfig((current) => ({ ...current, aiModel: trimmed }));
toast({ title: 'AI model updated' });
}
};
const resetProviderDefaults = () => {
setBaseUrlDraft(DEFAULT_AI_BASE_URL);
setApiKeyDraft('');
setModelDraft(DEFAULT_AI_MODEL);
updateConfig((current) => ({
...current,
aiBaseURL: DEFAULT_AI_BASE_URL,
aiApiKey: '',
aiModel: DEFAULT_AI_MODEL,
}));
toast({ title: 'Provider settings reset to defaults' });
};
const providerIsDefault =
config.aiBaseURL === DEFAULT_AI_BASE_URL &&
config.aiApiKey === '' &&
config.aiModel === DEFAULT_AI_MODEL;
const handleStatsPubkeyChange = (value: string) => {
setStatsPubkey(value);
@@ -42,6 +116,156 @@ export function AdvancedSettings() {
return (
<div>
{/* Agent Section */}
<div>
<Collapsible open={aiOpen} onOpenChange={setAiOpen}>
<CollapsibleTrigger asChild>
<Button
variant="ghost"
className="relative w-full justify-between px-3 py-3.5 h-auto hover:bg-muted/20 hover:text-foreground rounded-none"
>
<span className="text-base font-semibold">Agent</span>
{aiOpen ? (
<ChevronUp className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
)}
<div className="absolute bottom-0 left-0 right-0 h-1 bg-primary rounded-full" />
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="px-3 pt-3 pb-4 space-y-5 border-b border-border">
{/* AI Base URL */}
<div>
<Label htmlFor="ai-base-url" className="text-sm font-medium">
Base URL
</Label>
<p className="text-xs text-muted-foreground mt-1 mb-2">
OpenAI-compatible <code className="bg-muted px-1 rounded">/v1</code> endpoint. An API key is required for endpoints that don't support NIP-98 auth.
</p>
<Input
id="ai-base-url"
type="url"
value={baseUrlDraft}
onChange={(e) => setBaseUrlDraft(e.target.value)}
onBlur={commitBaseUrl}
placeholder={DEFAULT_AI_BASE_URL}
className="font-mono text-base md:text-sm"
autoComplete="off"
spellCheck={false}
/>
</div>
{/* API Key */}
<div>
<Label htmlFor="ai-api-key" className="text-sm font-medium">
API key
</Label>
<p className="text-xs text-muted-foreground mt-1 mb-2">
Optional. Required for endpoints that use standard API-key auth (e.g. OpenAI, Anthropic, OpenRouter).
</p>
<div className="flex gap-2">
<Input
id="ai-api-key"
type={showApiKey ? 'text' : 'password'}
value={apiKeyDraft}
onChange={(e) => setApiKeyDraft(e.target.value)}
onBlur={commitApiKey}
placeholder="Leave empty to use NIP-98 auth"
className="font-mono text-base md:text-sm"
autoComplete="off"
spellCheck={false}
/>
<Button
type="button"
variant="outline"
size="icon"
onClick={() => setShowApiKey((value) => !value)}
aria-label={showApiKey ? 'Hide API key' : 'Show API key'}
>
{showApiKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button>
</div>
</div>
{/* AI Model */}
<div>
<Label htmlFor="ai-model" className="text-sm font-medium">
Model
</Label>
<p className="text-xs text-muted-foreground mt-1 mb-2">
Model ID sent to the provider (e.g. <code className="bg-muted px-1 rounded">grok-4.1-fast</code>, <code className="bg-muted px-1 rounded">claude-opus-4.6</code>, <code className="bg-muted px-1 rounded">gpt-4o</code>).
</p>
<Input
id="ai-model"
type="text"
value={modelDraft}
onChange={(e) => setModelDraft(e.target.value)}
onBlur={commitModel}
placeholder={DEFAULT_AI_MODEL}
className="font-mono text-base md:text-sm"
autoComplete="off"
spellCheck={false}
/>
{!providerIsDefault && (
<Button
variant="ghost"
size="sm"
className="h-7 text-xs text-muted-foreground mt-2"
onClick={resetProviderDefaults}
>
<RotateCcw className="h-3 w-3 mr-1" />
Reset provider to default
</Button>
)}
</div>
{/* AI System Prompt */}
<div>
<Label htmlFor="ai-system-prompt" className="text-sm font-medium">
System Prompt
</Label>
<p className="text-xs text-muted-foreground mt-1 mb-2">
The base system prompt sent to the AI. Supports <code className="bg-muted px-1 rounded">{'{{SAVED_FEEDS}}'}</code> and <code className="bg-muted px-1 rounded">{'{{USER_IDENTITY}}'}</code> placeholders.
</p>
<Textarea
id="ai-system-prompt"
value={systemPromptDraft}
onChange={(e) => setSystemPromptDraft(e.target.value)}
onBlur={() => {
const trimmed = systemPromptDraft.trim();
const defaultPrompt = DEFAULT_SYSTEM_PROMPT_TEMPLATE;
// If the user reverted back to the default text, store empty (meaning "use default")
const valueToStore = trimmed === defaultPrompt ? '' : trimmed;
if (valueToStore !== config.aiSystemPrompt) {
updateConfig(() => ({ aiSystemPrompt: valueToStore }));
toast({ title: valueToStore ? 'System prompt updated' : 'System prompt reset to default' });
}
}}
className="min-h-[120px] max-h-[400px] resize-y font-mono text-base leading-relaxed"
/>
{config.aiSystemPrompt && (
<Button
variant="ghost"
size="sm"
className="h-7 text-xs text-muted-foreground mt-2"
onClick={() => {
setSystemPromptDraft(DEFAULT_SYSTEM_PROMPT_TEMPLATE);
updateConfig(() => ({ aiSystemPrompt: '' }));
toast({ title: 'System prompt reset to default' });
}}
>
<RotateCcw className="h-3 w-3 mr-1" />
Reset to default
</Button>
)}
</div>
</div>
</CollapsibleContent>
</Collapsible>
</div>
{/* System Section (includes Stats Source) */}
<div>
<Collapsible open={systemOpen} onOpenChange={setSystemOpen}>
@@ -188,10 +412,7 @@ export function AdvancedSettings() {
variant="ghost"
className="relative w-full justify-between px-3 py-3.5 h-auto hover:bg-muted/20 hover:text-foreground rounded-none"
>
<span className="flex items-center gap-2 text-base font-semibold">
<Bug className="h-4 w-4" />
Error Reporting
</span>
<span className="text-base font-semibold">Error Reporting</span>
{sentryOpen ? (
<ChevronUp className="h-4 w-4 text-muted-foreground" />
) : (
@@ -297,11 +518,10 @@ export function AdvancedSettings() {
<div className="px-3 pt-3 pb-4 space-y-4">
<div className="rounded-lg border border-destructive/30 p-4 space-y-3">
<div>
<h3 className="text-sm font-medium">Request to Vanish</h3>
<h3 className="text-sm font-medium">Delete Account</h3>
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
Permanently request all relays to delete your data, including your profile,
posts, reactions, and direct messages. This action is irreversible and legally
binding in some jurisdictions (NIP-62).
Permanently delete your data from the network, including your profile,
posts, reactions, and direct messages. This action is irreversible.
</p>
</div>
<Button
@@ -310,7 +530,7 @@ export function AdvancedSettings() {
className="border-destructive/50 text-destructive hover:bg-destructive hover:text-destructive-foreground"
onClick={() => setVanishDialogOpen(true)}
>
Request to Vanish
Delete Account
</Button>
</div>
</div>
+53
View File
@@ -0,0 +1,53 @@
import { cn } from '@/lib/utils';
interface AgoraLogoProps {
className?: string;
size?: number;
}
function LightningBolt({ size }: { size: number }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden
>
<path
d="M13 2L3 14h8l-1 8 10-12h-8l1-8z"
fill="white"
stroke="white"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
/** Agora badge icon used across app chrome. */
export function AgoraLogo({ className, size = 40 }: AgoraLogoProps) {
const boltSize = Math.max(12, Math.round(size * 0.56));
return (
<div
role="img"
aria-label="Agora"
style={{
width: size,
height: size,
}}
className={cn(
'relative rounded-full bg-gradient-to-br from-primary to-primary/80 shadow-lg flex items-center justify-center',
className,
)}
>
<div className="absolute inset-0 rounded-full bg-primary/25 blur-md" aria-hidden />
<div className="relative">
<LightningBolt size={boltSize} />
</div>
</div>
);
}
+382
View File
@@ -0,0 +1,382 @@
import type { NostrEvent, NostrMetadata } from '@nostrify/nostrify';
import { ExternalLink, GitFork, Package, Play } from 'lucide-react';
import { useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { ExternalFavicon } from '@/components/ExternalFavicon';
import { NsitePreviewDialog } from '@/components/NsitePreviewDialog';
import { Skeleton } from '@/components/ui/skeleton';
import { useAddrEvent } from '@/hooks/useEvent';
import { NostrURI } from '@/lib/NostrURI';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import { cn } from '@/lib/utils';
/** Get a tag value by name. */
function getTag(tags: string[][], name: string): string | undefined {
return tags.find(([n]) => n === name)?.[1];
}
/** Get all values for a tag name. */
function getAllTags(tags: string[][], name: string): string[] {
return tags.filter(([n]) => n === name).map(([, v]) => v);
}
/** Parse kind-0-style metadata from the content field. */
function parseHandlerMetadata(content: string): NostrMetadata {
if (!content) return {};
try {
return JSON.parse(content) as NostrMetadata;
} catch {
return {};
}
}
/** Get the website URL from web handler tags or metadata. */
function getWebsiteUrl(tags: string[][], metadata: NostrMetadata): string | undefined {
const webTags = tags.filter(([n]) => n === 'web');
for (const tag of webTags) {
const url = tag[1];
if (url) {
const base = url.replace(/<bech32>/g, '').replace(/\/+$/, '');
return base;
}
}
return metadata.website;
}
/** Extract the display domain from a URL. */
function displayDomain(url: string): string {
try {
return new URL(url).hostname.replace(/^www\./, '');
} catch {
return url;
}
}
/** Build a Shakespeare "Edit with Shakespeare" URL from a kind 30617 `a` tag, if present. */
function getShakespeareUrl(tags: string[][]): string | undefined {
for (const tag of tags) {
if (tag[0] !== 'a') continue;
const parts = tag[1]?.split(':');
if (!parts || parts[0] !== '30617' || parts.length < 3) continue;
const pubkey = parts[1];
const identifier = parts.slice(2).join(':');
const nostrUri = new NostrURI({ pubkey, identifier }).toString();
return `https://shakespeare.diy/clone?url=${encodeURIComponent(nostrUri)}`;
}
return undefined;
}
interface NsiteRef {
/** The author pubkey (hex) of the kind 35128 event. */
pubkey: string;
/** The d-tag identifier of the kind 35128 event. */
identifier: string;
}
/**
* Extract nsite info from a kind 35128 `a` tag, if present.
* The `a` tag value format is `"35128:<pubkey>:<d-tag>"`.
*/
function getNsiteRef(tags: string[][]): NsiteRef | undefined {
for (const tag of tags) {
if (tag[0] !== 'a') continue;
const parts = tag[1]?.split(':');
if (!parts || parts[0] !== '35128' || parts.length < 3) continue;
const pubkey = parts[1];
const identifier = parts.slice(2).join(':');
if (!pubkey || !identifier) continue;
return { pubkey, identifier };
}
return undefined;
}
interface AppHandlerContentProps {
event: NostrEvent;
/** If true, show compact preview (used in NoteCard feed). */
compact?: boolean;
}
/** Renders a kind 31990 NIP-89 application handler event as a showcase-style card. */
export function AppHandlerContent({ event, compact }: AppHandlerContentProps) {
const metadata = useMemo(() => parseHandlerMetadata(event.content), [event.content]);
const name = metadata.name || getTag(event.tags, 'name') || getTag(event.tags, 'd') || 'Unknown App';
const about = metadata.about;
// Sanitize image URLs to reject non-https schemes (http IP leaks, data: URIs,
// etc.). The CSP \`img-src\` already blocks most of these, but sanitizing
// defense-in-depth matches the treatment of the website URL below and keeps
// the component safe if it is ever rendered outside the app's own CSP.
const picture = sanitizeUrl(metadata.picture);
const banner = sanitizeUrl(metadata.banner);
const websiteUrl = sanitizeUrl(getWebsiteUrl(event.tags, metadata));
const hashtags = getAllTags(event.tags, 't');
const shakespeareUrl = useMemo(() => getShakespeareUrl(event.tags), [event.tags]);
const nsiteRef = useMemo(() => getNsiteRef(event.tags), [event.tags]);
const [previewOpen, setPreviewOpen] = useState(false);
// Fetch the actual nsite event so we can serve files directly from Blossom.
const { data: nsiteEvent } = useAddrEvent(
nsiteRef ? { kind: 35128, pubkey: nsiteRef.pubkey, identifier: nsiteRef.identifier } : undefined,
);
if (compact) {
return (
<>
<div className="mt-2">
<div className="rounded-xl border border-border overflow-hidden transition-all duration-300 hover:shadow-md hover:border-primary/20">
{/* Banner hero */}
{banner && (
<div className="relative aspect-[2/1] bg-gradient-to-br from-muted/50 to-muted overflow-hidden">
<img
src={banner}
alt=""
className="size-full object-cover transition-transform duration-300 group-hover:scale-105"
loading="lazy"
/>
</div>
)}
{/* Content */}
<div className="relative px-3.5 pb-3.5 space-y-2">
{/* App icon — overlaps the banner hero like a profile avatar */}
<div className={banner ? '-mt-7' : 'pt-3.5'}>
{picture ? (
<img
src={picture}
alt={name}
className="size-14 rounded-xl object-cover shrink-0 border-3 border-background bg-background shadow-sm"
loading="lazy"
onError={(e) => {
(e.currentTarget as HTMLElement).style.display = 'none';
}}
/>
) : (
<div className="size-14 rounded-xl bg-primary/10 flex items-center justify-center shrink-0 border-3 border-background shadow-sm">
<Package className="size-6 text-primary/50" />
</div>
)}
</div>
{/* Name + domain */}
<div className="min-w-0">
<h3 className="font-semibold text-[15px] leading-snug truncate">{name}</h3>
{websiteUrl && (
<div className="flex items-center gap-1 text-xs text-muted-foreground mt-0.5">
<ExternalFavicon url={websiteUrl} size={12} />
<span className="truncate">{displayDomain(websiteUrl)}</span>
</div>
)}
</div>
{/* Description */}
{about && (
<p className="text-sm text-muted-foreground line-clamp-2">{about}</p>
)}
{/* Tags */}
{hashtags.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{hashtags.slice(0, 4).map((tag) => (
<Link
key={tag}
to={`/t/${encodeURIComponent(tag)}`}
className="text-xs text-primary hover:underline"
onClick={(e) => e.stopPropagation()}
>
#{tag}
</Link>
))}
</div>
)}
{/* Actions */}
<div className="flex items-center gap-2">
{nsiteRef && (
<Button
size="sm"
className="h-7 text-xs"
disabled={!nsiteEvent}
onClick={(e) => { e.stopPropagation(); setPreviewOpen(true); }}
>
<Play className="size-3 mr-1" />
Run
</Button>
)}
{websiteUrl && (
<Button asChild size="sm" variant={nsiteRef ? 'secondary' : 'default'} className="h-7 text-xs">
<a href={websiteUrl} target="_blank" rel="noopener noreferrer" onClick={(e) => e.stopPropagation()}>
Open App
<ExternalLink className="size-3 ml-1.5" />
</a>
</Button>
)}
{shakespeareUrl && (
<Button asChild variant="secondary" size="sm" className="h-7 text-xs">
<a href={shakespeareUrl} target="_blank" rel="noopener noreferrer" onClick={(e) => e.stopPropagation()}>
Fork
<GitFork className="size-3 ml-1" />
</a>
</Button>
)}
</div>
</div>
</div>
</div>
{nsiteRef && nsiteEvent && (
<NsitePreviewDialog
event={nsiteEvent}
appName={name}
appPicture={picture}
open={previewOpen}
onOpenChange={setPreviewOpen}
/>
)}
</>
);
}
// Full detail view
return (
<div className="mt-3">
<div className="rounded-xl border border-border overflow-hidden">
{/* Banner hero */}
{banner && (
<div className="relative aspect-[2/1] bg-gradient-to-br from-muted/50 to-muted overflow-hidden">
<img
src={banner}
alt=""
className="size-full object-cover"
loading="lazy"
/>
</div>
)}
{/* Content */}
<div className="relative px-4 pb-4 space-y-3">
{/* App icon — overlaps the banner hero like a profile avatar */}
<div className={cn(
'flex items-end justify-between',
banner ? '-mt-10' : 'pt-4',
)}>
{picture ? (
<img
src={picture}
alt={name}
className="size-20 rounded-2xl object-cover shrink-0 border-4 border-background bg-background shadow-sm"
loading="lazy"
onError={(e) => {
(e.currentTarget as HTMLElement).style.display = 'none';
}}
/>
) : (
<div className="size-20 rounded-2xl bg-primary/10 flex items-center justify-center shrink-0 border-4 border-background shadow-sm">
<Package className="size-8 text-primary/50" />
</div>
)}
</div>
{/* Name + domain */}
<div className="min-w-0">
<h2 className="text-xl font-semibold leading-snug truncate">{name}</h2>
{websiteUrl && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mt-0.5">
<ExternalFavicon url={websiteUrl} size={14} />
<span className="truncate">{displayDomain(websiteUrl)}</span>
</div>
)}
</div>
{/* Description */}
{about && (
<p className="text-sm text-muted-foreground leading-relaxed">{about}</p>
)}
{/* Tags */}
{hashtags.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{hashtags.map((tag) => (
<Link
key={tag}
to={`/t/${encodeURIComponent(tag)}`}
className="text-xs text-primary hover:underline"
onClick={(e) => e.stopPropagation()}
>
#{tag}
</Link>
))}
</div>
)}
{/* Actions */}
<div className="flex items-center gap-2 pt-1">
{nsiteRef && (
<Button
size="sm"
disabled={!nsiteEvent}
onClick={(e) => { e.stopPropagation(); setPreviewOpen(true); }}
>
<Play className="size-3.5 mr-1.5" />
Run
</Button>
)}
{websiteUrl && (
<Button asChild size="sm" variant={nsiteRef ? 'secondary' : 'default'}>
<a href={websiteUrl} target="_blank" rel="noopener noreferrer" onClick={(e) => e.stopPropagation()}>
Open App
<ExternalLink className="size-3 ml-1.5" />
</a>
</Button>
)}
{shakespeareUrl && (
<Button asChild variant="secondary" size="sm">
<a href={shakespeareUrl} target="_blank" rel="noopener noreferrer" onClick={(e) => e.stopPropagation()}>
Fork
<GitFork className="size-3.5 ml-1.5" />
</a>
</Button>
)}
</div>
</div>
</div>
{nsiteRef && nsiteEvent && (
<NsitePreviewDialog
event={nsiteEvent}
appName={name}
appPicture={picture}
open={previewOpen}
onOpenChange={setPreviewOpen}
/>
)}
</div>
);
}
/** Skeleton loading state for AppHandlerContent. */
export function AppHandlerSkeleton() {
return (
<div className="mt-3">
<div className="rounded-xl border border-border overflow-hidden">
<Skeleton className="aspect-[2/1] w-full" />
<div className="px-4 pb-4 space-y-3">
<div className="-mt-10">
<Skeleton className="size-20 rounded-2xl border-4 border-background" />
</div>
<div className="space-y-1.5">
<Skeleton className="h-5 w-32" />
<Skeleton className="h-3 w-24" />
</div>
<div className="space-y-1.5">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-4/5" />
</div>
</div>
</div>
</div>
);
}
+2 -9
View File
@@ -1,7 +1,7 @@
import { ReactNode, useLayoutEffect, useEffect, useRef } from 'react';
import { useLocalStorage } from '@/hooks/useLocalStorage';
import { AppContext, type AppConfig, type AppContextType, type Theme } from '@/contexts/AppContext';
import { builtinThemes, themePresets, buildThemeCssFromCore, resolveTheme, resolveThemeConfig, type ThemeConfig, type ThemesConfig } from '@/themes';
import { builtinThemes, buildThemeCssFromCore, resolveTheme, resolveThemeConfig, type ThemeConfig, type ThemesConfig } from '@/themes';
import { AppConfigSchema } from '@/lib/schemas';
import { loadAndApplyFont, loadAndApplyTitleFont } from '@/lib/fontLoader';
import { hslToRgb, parseHsl, rgbToHex } from '@/lib/colorUtils';
@@ -47,13 +47,6 @@ export function AppProvider(props: AppProviderProps) {
}
}
// Migrate legacy theme values ("black", "pink") to "custom" + customTheme
const legacyTheme = result.theme as string | undefined;
if (legacyTheme && legacyTheme in themePresets) {
result.theme = 'custom';
result.customTheme = { colors: themePresets[legacyTheme].colors };
}
// Migrate legacy blossomServers (string[]) to blossomServerMetadata
if (!result.blossomServerMetadata) {
const legacyServers = parsed.blossomServers;
@@ -224,7 +217,7 @@ function useApplyBackground(theme: Theme, customTheme: ThemeConfig | undefined,
/**
* Hook to dynamically recolor the favicon to match the current primary color.
* Uses the same mask approach as DittoLogo: loads the SVG, draws it as a mask
* Uses a mask approach with /logo.svg: loads the SVG, draws it as a mask
* on a canvas filled with the primary color, and sets the result as the favicon.
*/
function useApplyFavicon(theme: Theme, customTheme: ThemeConfig | undefined, themes: ThemesConfig | undefined) {
+1 -1
View File
@@ -7,7 +7,7 @@
import { useMemo } from 'react';
import { Play, Pause, Music, ListMusic, Podcast, Clock } from 'lucide-react';
import type { NostrEvent } from '@nostrify/nostrify';
import { useAudioPlayer } from '@/contexts/AudioPlayerContext';
import { useAudioPlayer } from '@/contexts/audioPlayerContextDef';
import { parseMusicTrack, parseMusicPlaylist, toAudioTrack } from '@/lib/musicHelpers';
import { parsePodcastEpisode, parsePodcastTrailer, episodeToAudioTrack, trailerToAudioTrack } from '@/lib/podcastHelpers';
import { useAuthor } from '@/hooks/useAuthor';
+1 -1
View File
@@ -1,6 +1,6 @@
import { useEffect, useRef } from 'react';
import { useLocation } from 'react-router-dom';
import { useAudioPlayer } from '@/contexts/AudioPlayerContext';
import { useAudioPlayer } from '@/contexts/audioPlayerContextDef';
/**
* Auto-minimizes the audio player when the user navigates to a different page.
+103 -49
View File
@@ -1,39 +1,9 @@
import { useMemo } from 'react';
import { useCallback, useMemo, useRef } from 'react';
import { Award } from 'lucide-react';
import type { NostrEvent } from '@nostrify/nostrify';
/** Parsed NIP-58 badge definition data. */
export interface BadgeData {
identifier: string;
name: string;
description?: string;
image?: string;
imageDimensions?: string;
thumbs: Array<{ url: string; dimensions?: string }>;
}
/** Parse a kind 30009 badge definition event into structured data. */
export function parseBadgeDefinition(event: NostrEvent): BadgeData | null {
if (event.kind !== 30009) return null;
const identifier = event.tags.find(([n]) => n === 'd')?.[1];
if (!identifier) return null;
const name = event.tags.find(([n]) => n === 'name')?.[1] || identifier;
const description = event.tags.find(([n]) => n === 'description')?.[1];
const imageTag = event.tags.find(([n]) => n === 'image');
const image = imageTag?.[1];
const imageDimensions = imageTag?.[2];
const thumbs: Array<{ url: string; dimensions?: string }> = [];
for (const tag of event.tags) {
if (tag[0] === 'thumb' && tag[1]) {
thumbs.push({ url: tag[1], dimensions: tag[2] });
}
}
return { identifier, name, description, image, imageDimensions, thumbs };
}
import { useCardTilt } from '@/hooks/useCardTilt';
import { parseBadgeDefinition } from '@/lib/parseBadgeDefinition';
interface BadgeContentProps {
event: NostrEvent;
@@ -81,22 +51,8 @@ export function BadgeContent({ event }: BadgeContentProps) {
/>
</div>
{/* Badge image */}
<div className="relative z-[1]">
{heroImage ? (
<img
src={heroImage}
alt={badge.name}
className="size-28 rounded-2xl object-cover drop-shadow-lg"
loading="lazy"
decoding="async"
/>
) : (
<div className="size-28 rounded-2xl bg-gradient-to-br from-primary/10 via-primary/5 to-transparent flex items-center justify-center">
<Award className="size-12 text-primary/30" />
</div>
)}
</div>
{/* Badge image with mouse-only 3D tilt */}
<BadgeImageTilt heroImage={heroImage} badgeName={badge.name} />
{/* Badge info */}
<div className="relative z-[1] mt-4 text-center px-6 max-w-xs">
@@ -109,3 +65,101 @@ export function BadgeContent({ event }: BadgeContentProps) {
</div>
);
}
/** Extra padding (px) around the badge that expands the mouse hit-area. */
const INTERACT_PAD = 48;
/**
* Badge image with mouse-only 3D tilt. Touch events are ignored so
* tapping through to the detail view is not interfered with.
*/
function BadgeImageTilt({ heroImage, badgeName }: { heroImage?: string; badgeName: string }) {
const tilt = useCardTilt(25, 1.08);
const glareRef = useRef<HTMLDivElement>(null);
const imageMask: React.CSSProperties | undefined = heroImage ? {
maskImage: `url(${heroImage})`,
WebkitMaskImage: `url(${heroImage})`,
maskSize: 'cover',
WebkitMaskSize: 'cover',
maskRepeat: 'no-repeat',
WebkitMaskRepeat: 'no-repeat',
maskPosition: 'center',
WebkitMaskPosition: 'center',
} : undefined;
const handlePointerMove = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
if (e.pointerType === 'touch') return;
tilt.onPointerMove(e);
const el = tilt.ref.current;
const glare = glareRef.current;
if (!el || !glare) return;
const rect = el.getBoundingClientRect();
const x = ((e.clientX - rect.left - INTERACT_PAD) / (rect.width - INTERACT_PAD * 2)) * 100;
const y = ((e.clientY - rect.top - INTERACT_PAD) / (rect.height - INTERACT_PAD * 2)) * 100;
glare.style.background = `radial-gradient(circle at ${x}% ${y}%, rgba(255,255,255,0.35) 0%, rgba(255,255,255,0.08) 35%, transparent 65%)`;
glare.style.opacity = '1';
},
[tilt],
);
const handlePointerLeave = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
if (e.pointerType === 'touch') return;
tilt.onPointerLeave(e);
const glare = glareRef.current;
if (glare) glare.style.opacity = '0';
},
[tilt],
);
// Override touch-action back to auto so scrolling works normally on touch
const style: React.CSSProperties = {
...tilt.style,
touchAction: 'auto',
transformStyle: 'preserve-3d',
padding: INTERACT_PAD,
margin: -INTERACT_PAD,
};
return (
<div
ref={tilt.ref}
style={style}
onPointerMove={handlePointerMove}
onPointerLeave={handlePointerLeave}
className="relative z-[1] select-none"
>
{heroImage ? (
<img
src={heroImage}
alt={badgeName}
className="size-28 rounded-2xl object-cover drop-shadow-lg"
loading="lazy"
decoding="async"
/>
) : (
<div className="size-28 rounded-2xl bg-gradient-to-br from-primary/10 via-primary/5 to-transparent flex items-center justify-center">
<Award className="size-12 text-primary/30" />
</div>
)}
{/* Specular glare overlay */}
{heroImage && imageMask && (
<div
ref={glareRef}
className="absolute pointer-events-none"
style={{
inset: INTERACT_PAD,
opacity: 0,
transition: 'opacity 0.4s ease-out',
mixBlendMode: 'overlay',
...imageMask,
}}
aria-hidden="true"
/>
)}
</div>
);
}
+89 -107
View File
@@ -1,23 +1,21 @@
import { useEffect, useMemo, useState, useCallback, useRef } from 'react';
import { Link } from 'react-router-dom';
import { Award, Copy, Check, Users, Gift, Loader2, MessageCircle, Newspaper, MoreHorizontal } from 'lucide-react';
import { Award, Check, Users, Gift, Loader2, MessageCircle, Newspaper } from 'lucide-react';
import { nip19 } from 'nostr-tools';
import type { NostrEvent, NostrMetadata } from '@nostrify/nostrify';
import { useNostr } from '@nostrify/react';
import { useQuery, useInfiniteQuery } from '@tanstack/react-query';
import { useInView } from 'react-intersection-observer';
import { RepostIcon } from '@/components/icons/RepostIcon';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { getAvatarShape } from '@/lib/avatarShape';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { RepostMenu } from '@/components/RepostMenu';
import { ReactionButton } from '@/components/ReactionButton';
import { ComposeBox } from '@/components/ComposeBox';
import { NoteCard } from '@/components/NoteCard';
import { ThreadedReplyList } from '@/components/ThreadedReplyList';
import { FlatThreadedReplyList } from '@/components/ThreadedReplyList';
import { ARC_OVERHANG_PX } from '@/components/ArcBackground';
import { SubHeaderBar } from '@/components/SubHeaderBar';
import { TabButton } from '@/components/TabButton';
import { useAuthor } from '@/hooks/useAuthor';
import { useAuthors } from '@/hooks/useAuthors';
@@ -26,17 +24,16 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
import { usePendingBadges } from '@/hooks/usePendingBadges';
import { useAcceptBadge } from '@/hooks/useAcceptBadge';
import { useComments } from '@/hooks/useComments';
import { useEventStats } from '@/hooks/useTrending';
import { useMuteList } from '@/hooks/useMuteList';
import { isEventMuted } from '@/lib/muteHelpers';
import { genUserName } from '@/lib/genUserName';
import { formatNumber } from '@/lib/formatNumber';
import { VerifiedNip05Text } from '@/components/Nip05Badge';
import { parseBadgeDefinition } from '@/components/BadgeContent';
import { parseBadgeDefinition } from '@/lib/parseBadgeDefinition';
import { useCardTilt } from '@/hooks/useCardTilt';
import { useProfileUrl } from '@/hooks/useProfileUrl';
import { AwardBadgeDialog } from '@/components/AwardBadgeDialog';
import { NoteMoreMenu } from '@/components/NoteMoreMenu';
import { PostActionBar } from '@/components/PostActionBar';
type DetailTab = 'awarded' | 'feed' | 'comments';
@@ -50,7 +47,6 @@ export function BadgeDetailContent({ event }: { event: NostrEvent }) {
const { toast } = useToast();
const { user } = useCurrentUser();
const acceptBadge = useAcceptBadge();
const [copied, setCopied] = useState(false);
const [awardDialogOpen, setAwardDialogOpen] = useState(false);
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
const [activeTab, setActiveTab] = useState<DetailTab>('awarded');
@@ -70,10 +66,6 @@ export function BadgeDetailContent({ event }: { event: NostrEvent }) {
const pendingForUser = pendingBadges.find((p) => p.aTag === badgeATag);
const isIssuer = user?.pubkey === event.pubkey;
// Stats for action bar
const { data: stats } = useEventStats(event.id, event);
const repostTotal = (stats?.reposts ?? 0) + (stats?.quotes ?? 0);
const awardsQuery = useQuery({
queryKey: ['badge-awards', badgeATag],
queryFn: async () => {
@@ -128,17 +120,6 @@ export function BadgeDetailContent({ event }: { event: NostrEvent }) {
});
}, [commentsData, muteItems]);
const commentCount = commentsData?.allComments.length ?? 0;
const handleCopyLink = useCallback(() => {
const dTag = event.tags.find(([n]) => n === 'd')?.[1] ?? '';
const naddr = nip19.naddrEncode({ kind: event.kind, pubkey: event.pubkey, identifier: dTag });
navigator.clipboard.writeText(`${window.location.origin}/${naddr}`);
setCopied(true);
toast({ title: 'Link copied!' });
setTimeout(() => setCopied(false), 2000);
}, [event, toast]);
if (!badge) return null;
const heroImage = badge.image
@@ -176,11 +157,6 @@ export function BadgeDetailContent({ event }: { event: NostrEvent }) {
<VerifiedNip05Text nip05={metadata.nip05} pubkey={event.pubkey} className="text-sm text-muted-foreground truncate block" />
)}
</div>
<Badge variant="secondary" className="shrink-0 gap-1">
<Award className="size-3" />
Badge
</Badge>
</div>
{/* Badge name */}
@@ -193,8 +169,8 @@ export function BadgeDetailContent({ event }: { event: NostrEvent }) {
</p>
)}
{/* Stats row */}
<div className="flex items-center gap-3 mt-4 flex-wrap">
{/* Stats + Award to row */}
<div className="flex items-center justify-between gap-3 mt-4">
{awardsQuery.isLoading ? (
<Skeleton className="h-4 w-24" />
) : awardedPubkeys.length > 0 ? (
@@ -208,11 +184,21 @@ export function BadgeDetailContent({ event }: { event: NostrEvent }) {
No awards yet
</span>
)}
{isIssuer && (
<Button
variant="default"
className="rounded-full px-5 h-9 text-sm font-medium gap-1.5 bg-primary text-primary-foreground hover:bg-primary/90 border border-transparent"
onClick={() => setAwardDialogOpen(true)}
>
<Gift className="size-3.5" />
Award to
</Button>
)}
</div>
{/* Actions */}
<div className="flex gap-2 mt-3">
{pendingForUser && (
{/* Accept Badge action */}
{pendingForUser && (
<div className="mt-3">
<Button
variant="default"
size="sm"
@@ -227,69 +213,28 @@ export function BadgeDetailContent({ event }: { event: NostrEvent }) {
<Check className="size-4 mr-1.5" />
Accept Badge
</Button>
)}
{isIssuer && (
<Button variant="outline" size="sm" onClick={() => setAwardDialogOpen(true)}>
<Gift className="size-4 mr-1.5" />
Award to
</Button>
)}
<Button variant="outline" size="icon" onClick={handleCopyLink}>
{copied ? <Check className="size-4" /> : <Copy className="size-4" />}
</Button>
<button
className="p-2 rounded-full text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors"
title="More"
onClick={() => setMoreMenuOpen(true)}
>
<MoreHorizontal className="size-5" />
</button>
</div>
</div>
)}
</div>
{/* React / Repost bar */}
<div className="flex items-center gap-1 px-4 py-1 border-t border-b border-border">
<ReactionButton
eventId={event.id}
eventPubkey={event.pubkey}
eventKind={event.kind}
reactionCount={stats?.reactions}
/>
<RepostMenu event={event}>
{(isReposted: boolean) => (
<button
className={`flex items-center gap-1.5 p-2 rounded-full transition-colors ${isReposted ? 'text-accent hover:text-accent/80 hover:bg-accent/10' : 'text-muted-foreground hover:text-accent hover:bg-accent/10'}`}
title={isReposted ? 'Undo repost' : 'Repost'}
>
<RepostIcon className="size-5" />
{repostTotal > 0 && (
<span className="text-sm tabular-nums">{formatNumber(repostTotal)}</span>
)}
</button>
)}
</RepostMenu>
<button
className="flex items-center gap-1.5 p-2 rounded-full text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors"
title="Comments"
onClick={() => setActiveTab('comments')}
>
<MessageCircle className="size-5" />
{commentCount > 0 && (
<span className="text-sm tabular-nums">{formatNumber(commentCount)}</span>
)}
</button>
</div>
{/* Action bar — matches PostDetailPage style */}
<PostActionBar
event={event}
replyLabel="Comments"
onReply={() => setActiveTab('comments')}
onMore={() => setMoreMenuOpen(true)}
className="px-4"
/>
{/* Tabs */}
<div className="flex border-b border-border sticky top-0 bg-background/80 backdrop-blur-md z-10">
<SubHeaderBar pinned>
<TabButton label="Awarded To" active={activeTab === 'awarded'} onClick={() => setActiveTab('awarded')} />
<TabButton label="Feed" active={activeTab === 'feed'} onClick={() => setActiveTab('feed')} />
<TabButton label="Comments" active={activeTab === 'comments'} onClick={() => setActiveTab('comments')} />
</div>
</SubHeaderBar>
{/* Tab content */}
<div style={{ height: ARC_OVERHANG_PX }} />
{activeTab === 'awarded' ? (
<AwardedToTab
awardedPubkeys={awardedPubkeys}
@@ -491,7 +436,7 @@ function CommentsTab({ event, orderedReplies, commentsLoading }: {
{commentsLoading ? (
<CommentsSkeleton />
) : orderedReplies.length > 0 ? (
<ThreadedReplyList replies={orderedReplies} />
<FlatThreadedReplyList replies={orderedReplies} />
) : (
<div className="py-16 flex flex-col items-center gap-3 text-center px-8">
<MessageCircle className="size-8 text-muted-foreground/30" />
@@ -556,6 +501,7 @@ const INTERACT_PAD = 80;
function BadgeHero({ heroImage, badgeName }: { heroImage: string; badgeName: string }) {
const tilt = useCardTilt(30, 1.06);
const glareRef = useRef<HTMLDivElement>(null);
const glareFadeTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
// Mask string that clips overlays to the badge image's visible pixels.
// This ensures glare and edge effects don't paint over transparent areas.
@@ -570,31 +516,65 @@ function BadgeHero({ heroImage, badgeName }: { heroImage: string; badgeName: str
WebkitMaskPosition: 'center',
};
/** Update the specular glare position to follow the pointer. */
const updateGlare = useCallback((clientX: number, clientY: number) => {
const el = tilt.ref.current;
const glare = glareRef.current;
if (!el || !glare) return;
const rect = el.getBoundingClientRect();
const x = ((clientX - rect.left - INTERACT_PAD) / (rect.width - INTERACT_PAD * 2)) * 100;
const y = ((clientY - rect.top - INTERACT_PAD) / (rect.height - INTERACT_PAD * 2)) * 100;
glare.style.background = `radial-gradient(circle at ${x}% ${y}%, rgba(255,255,255,0.35) 0%, rgba(255,255,255,0.08) 35%, transparent 65%)`;
glare.style.opacity = '1';
}, [tilt.ref]);
const fadeGlare = useCallback(() => {
const glare = glareRef.current;
if (glare) glare.style.opacity = '0';
}, []);
const handlePointerDown = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
tilt.onPointerDown(e);
if (e.pointerType === 'touch') {
clearTimeout(glareFadeTimerRef.current);
updateGlare(e.clientX, e.clientY);
}
},
[tilt, updateGlare],
);
const handlePointerMove = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
tilt.onPointerMove(e);
// Move the specular glare to follow the cursor.
// Coordinates are mapped to the image area (inset by INTERACT_PAD).
const el = tilt.ref.current;
const glare = glareRef.current;
if (!el || !glare) return;
const rect = el.getBoundingClientRect();
const x = ((e.clientX - rect.left - INTERACT_PAD) / (rect.width - INTERACT_PAD * 2)) * 100;
const y = ((e.clientY - rect.top - INTERACT_PAD) / (rect.height - INTERACT_PAD * 2)) * 100;
glare.style.background = `radial-gradient(circle at ${x}% ${y}%, rgba(255,255,255,0.35) 0%, rgba(255,255,255,0.08) 35%, transparent 65%)`;
glare.style.opacity = '1';
updateGlare(e.clientX, e.clientY);
},
[tilt],
[tilt, updateGlare],
);
const handlePointerUp = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
tilt.onPointerUp(e);
if (e.pointerType === 'touch') {
// Fade glare after the same linger delay as the tilt reset
clearTimeout(glareFadeTimerRef.current);
glareFadeTimerRef.current = setTimeout(fadeGlare, 600);
}
},
[tilt, fadeGlare],
);
const handlePointerLeave = useCallback(
() => {
tilt.onPointerLeave();
const glare = glareRef.current;
if (glare) glare.style.opacity = '0';
(e: React.PointerEvent<HTMLDivElement>) => {
tilt.onPointerLeave(e);
if (e.pointerType === 'touch') {
clearTimeout(glareFadeTimerRef.current);
glareFadeTimerRef.current = setTimeout(fadeGlare, 600);
} else {
fadeGlare();
}
},
[tilt],
[tilt, fadeGlare],
);
return (
@@ -632,7 +612,9 @@ function BadgeHero({ heroImage, badgeName }: { heroImage: string; badgeName: str
<div
ref={tilt.ref}
style={{ ...tilt.style, transformStyle: 'preserve-3d', padding: INTERACT_PAD, margin: -INTERACT_PAD }}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerLeave={handlePointerLeave}
className="relative select-none"
>
+388
View File
@@ -0,0 +1,388 @@
import { useState, useMemo } from 'react';
import { useNostr } from '@nostrify/react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import type { NostrEvent, NostrFilter } from '@nostrify/nostrify';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { BadgeThumbnail } from '@/components/BadgeThumbnail';
import { parseBadgeDefinition, type BadgeData } from '@/lib/parseBadgeDefinition';
import { parseProfileBadges } from '@/lib/parseProfileBadges';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useToast } from '@/hooks/useToast';
import { BADGE_PROFILE_KIND, BADGE_PROFILE_KIND_LEGACY, BADGE_DEFINITION_KIND } from '@/lib/badgeUtils';
import { cn } from '@/lib/utils';
import { Award, Check, Loader2, RotateCcw } from 'lucide-react';
/**
* Query all events matching a filter using `req()` instead of `query()`.
* This bypasses NSet deduplication in NPool.query(), which discards older
* versions of replaceable events. We need all historical versions for recovery.
*/
async function queryAllEvents(
nostr: { req(filters: NostrFilter[], opts?: { signal?: AbortSignal }): AsyncIterable<['EVENT', string, NostrEvent] | ['EOSE', string] | ['CLOSED', string, string]> },
filters: NostrFilter[],
signal: AbortSignal,
): Promise<NostrEvent[]> {
const events: NostrEvent[] = [];
const seen = new Set<string>();
for await (const msg of nostr.req(filters, { signal })) {
if (msg[0] === 'EOSE' || msg[0] === 'CLOSED') break;
if (msg[0] === 'EVENT') {
const event = msg[2];
if (!seen.has(event.id)) {
seen.add(event.id);
events.push(event);
}
}
}
return events;
}
interface BadgeRecoveryDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
/** Format a unix timestamp into a human-readable date string. */
function formatDate(timestamp: number): string {
return new Date(timestamp * 1000).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
/** Summary of badges parsed from a snapshot. */
interface BadgeSummary {
count: number;
/** Parsed badge refs with their a-tag and identifier. */
refs: { aTag: string; pubkey: string; identifier: string }[];
}
/** Parse all badge refs from a profile badges event. */
function parseBadgeSnapshot(event: NostrEvent): BadgeSummary {
const refs = parseProfileBadges(event);
return {
count: refs.length,
refs: refs.map((r) => ({ aTag: r.aTag, pubkey: r.pubkey, identifier: r.identifier })),
};
}
// ─── Badge Snapshot Card ──────────────────────────────────────────────
function BadgeSnapshotCard({
summary,
event,
isCurrent,
onRestore,
isRestoring,
badgeMap,
}: {
summary: BadgeSummary;
event: NostrEvent;
isCurrent: boolean;
onRestore: () => void;
isRestoring: boolean;
badgeMap: Map<string, BadgeData>;
}) {
/** Show up to 5 badge thumbnails in the preview. */
const previewRefs = summary.refs.slice(0, 5);
const remaining = Math.max(0, summary.count - previewRefs.length);
return (
<div
className={cn(
'group relative rounded-xl border p-4 transition-all',
isCurrent
? 'border-primary/40 bg-primary/5'
: 'border-border hover:border-primary/20 hover:bg-secondary/30',
)}
>
{isCurrent && (
<div className="absolute top-3 right-3 flex items-center gap-1 text-xs font-medium text-primary">
<Check className="size-3.5" />
Current
</div>
)}
<div className="flex items-center gap-3">
<div className="flex items-center justify-center size-11 shrink-0 rounded-full bg-primary/10">
<Award className="size-5 text-primary" />
</div>
<div className="min-w-0 flex-1 space-y-1.5">
<div className="font-semibold text-sm">
{summary.count.toLocaleString()} {summary.count === 1 ? 'badge' : 'badges'}
</div>
{/* Badge thumbnail previews */}
{previewRefs.length > 0 && (
<div className="flex items-center gap-1.5 flex-wrap">
{previewRefs.map((ref) => {
const badge = badgeMap.get(ref.aTag);
return badge ? (
<BadgeThumbnail key={ref.aTag} badge={badge} size={24} className="shrink-0" />
) : (
<div
key={ref.aTag}
className="size-6 rounded border border-border bg-secondary/30 flex items-center justify-center shrink-0"
>
<Award className="size-3 text-muted-foreground" />
</div>
);
})}
{remaining > 0 && (
<span className="text-[11px] text-muted-foreground">
+{remaining}
</span>
)}
</div>
)}
<div className="text-[11px] text-muted-foreground/70 pt-0.5">
{formatDate(event.created_at)}
</div>
</div>
</div>
{!isCurrent && (
<div className="mt-3 flex justify-end">
<Button
variant="outline"
size="sm"
className="h-8 text-xs rounded-lg gap-1.5"
onClick={onRestore}
disabled={isRestoring}
>
{isRestoring ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<RotateCcw className="size-3.5" />
)}
Restore
</Button>
</div>
)}
</div>
);
}
// ─── Empty State ──────────────────────────────────────────────────────
function EmptyState() {
return (
<div className="flex flex-col items-center justify-center py-12 text-center">
<p className="text-sm text-muted-foreground">
No badge list history found. Your relay may not store historical events.
</p>
</div>
);
}
// ─── Loading Skeleton ─────────────────────────────────────────────────
function SnapshotSkeleton() {
return (
<div className="space-y-3">
{[1, 2, 3].map((i) => (
<div key={i} className="rounded-xl border p-4 space-y-3">
<div className="flex items-center gap-3">
<Skeleton className="size-11 rounded-full shrink-0" />
<div className="space-y-2 flex-1">
<Skeleton className="h-4 w-28" />
<Skeleton className="h-3 w-40" />
<Skeleton className="h-3 w-20" />
</div>
</div>
</div>
))}
</div>
);
}
// ─── Badge History Content ────────────────────────────────────────────
function BadgeHistoryContent({ onClose }: { onClose: () => void }) {
const { nostr } = useNostr();
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
const { toast } = useToast();
const queryClient = useQueryClient();
const [restoringId, setRestoringId] = useState<string | null>(null);
const pubkey = user?.pubkey;
// Fetch all historical kind 10008 and legacy 30008 events
const badgeHistory = useQuery<NostrEvent[]>({
queryKey: ['badge-recovery', 'history', pubkey],
queryFn: async () => {
if (!pubkey) return [];
const events = await queryAllEvents(
nostr,
[
{ kinds: [BADGE_PROFILE_KIND], authors: [pubkey] },
{ kinds: [BADGE_PROFILE_KIND_LEGACY], authors: [pubkey], '#d': ['profile_badges'] },
],
AbortSignal.timeout(10000),
);
return events.sort((a, b) => b.created_at - a.created_at);
},
enabled: !!pubkey,
staleTime: 30_000,
});
// Parse all snapshots
const parsedSnapshots = useMemo(() => {
if (!badgeHistory.data) return new Map<string, BadgeSummary>();
const results = new Map<string, BadgeSummary>();
for (const event of badgeHistory.data) {
results.set(event.id, parseBadgeSnapshot(event));
}
return results;
}, [badgeHistory.data]);
// Collect all unique badge definition refs across all snapshots for thumbnail fetching
const allBadgeRefs = useMemo(() => {
const seen = new Set<string>();
const refs: { pubkey: string; identifier: string; aTag: string }[] = [];
for (const summary of parsedSnapshots.values()) {
for (const ref of summary.refs) {
if (!seen.has(ref.aTag)) {
seen.add(ref.aTag);
refs.push(ref);
}
}
}
return refs;
}, [parsedSnapshots]);
// Fetch badge definitions for thumbnails
const badgeDefsQuery = useQuery({
queryKey: ['badge-recovery', 'definitions', allBadgeRefs.map((r) => r.aTag).join(',')],
queryFn: async ({ signal }) => {
if (allBadgeRefs.length === 0) return [];
const filters = allBadgeRefs.map((ref) => ({
kinds: [BADGE_DEFINITION_KIND as number],
authors: [ref.pubkey],
'#d': [ref.identifier],
limit: 1,
}));
return nostr.query(filters, { signal });
},
enabled: allBadgeRefs.length > 0,
staleTime: 5 * 60_000,
});
// Build badge data map for thumbnails
const badgeMap = useMemo(() => {
const map = new Map<string, BadgeData>();
if (!badgeDefsQuery.data) return map;
for (const event of badgeDefsQuery.data) {
const parsed = parseBadgeDefinition(event);
if (!parsed) continue;
const aTag = `${BADGE_DEFINITION_KIND}:${event.pubkey}:${parsed.identifier}`;
map.set(aTag, parsed);
}
return map;
}, [badgeDefsQuery.data]);
const badgeEvents = badgeHistory.data ?? [];
const currentBadgeId = badgeEvents[0]?.id;
const handleRestore = async (event: NostrEvent) => {
setRestoringId(event.id);
try {
// Re-publish as kind 10008 (always write to the new kind),
// stripping any legacy `d` tag from kind 30008 events.
const tags = event.tags.filter(([n, v]) => !(n === 'd' && v === 'profile_badges'));
await publishEvent({
kind: BADGE_PROFILE_KIND,
content: event.content,
tags,
created_at: Math.floor(Date.now() / 1000),
});
toast({
title: 'Badge list restored',
description: `Successfully restored from ${formatDate(event.created_at)}.`,
});
queryClient.invalidateQueries({ queryKey: ['badge-recovery', 'history', pubkey] });
queryClient.invalidateQueries({ queryKey: ['profile-badges', pubkey] });
onClose();
} catch (error) {
console.error('Failed to restore badge list:', error);
toast({
title: 'Restore failed',
description: 'Could not republish the badge list. Please try again.',
variant: 'destructive',
});
} finally {
setRestoringId(null);
}
};
if (badgeHistory.isLoading) {
return <SnapshotSkeleton />;
}
if (badgeEvents.length === 0) {
return <EmptyState />;
}
return (
<>
{badgeEvents.map((event) => {
const summary = parsedSnapshots.get(event.id);
if (!summary) return null;
return (
<BadgeSnapshotCard
key={event.id}
event={event}
summary={summary}
isCurrent={event.id === currentBadgeId}
onRestore={() => handleRestore(event)}
isRestoring={restoringId === event.id}
badgeMap={badgeMap}
/>
);
})}
</>
);
}
// ─── Main Dialog ──────────────────────────────────────────────────────
export function BadgeRecoveryDialog({ open, onOpenChange }: BadgeRecoveryDialogProps) {
const close = () => onOpenChange(false);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg p-0 gap-0 rounded-2xl overflow-hidden">
<DialogHeader className="px-6 pt-6 pb-4">
<DialogTitle className="text-lg font-bold">Badge List Recovery</DialogTitle>
<p className="text-sm text-muted-foreground mt-1">
Browse and restore previous versions of your accepted badges.
</p>
</DialogHeader>
<ScrollArea className="h-[420px]">
<div className="p-4 space-y-3">
<BadgeHistoryContent onClose={close} />
</div>
</ScrollArea>
</DialogContent>
</Dialog>
);
}
+3 -5
View File
@@ -5,7 +5,7 @@ import { nip19 } from 'nostr-tools';
import { BadgeThumbnail } from '@/components/BadgeThumbnail';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import type { BadgeData } from '@/components/BadgeContent';
import type { BadgeData } from '@/lib/parseBadgeDefinition';
import { cn } from '@/lib/utils';
interface BadgeDisplayItem {
@@ -79,12 +79,10 @@ export function BadgeShowcaseGrid({
onClick={(e) => e.stopPropagation()}
>
{item.badge ? (
<div className="transition-transform group-hover:scale-110">
<BadgeThumbnail badge={item.badge} size={thumbnailSize} />
</div>
<BadgeThumbnail badge={item.badge} size={thumbnailSize} />
) : (
<div
className="rounded-lg border border-border bg-background flex items-center justify-center transition-transform group-hover:scale-110"
className="rounded-lg border border-border bg-background flex items-center justify-center"
style={{ width: thumbnailSize, height: thumbnailSize }}
>
<Award className="size-6 text-muted-foreground" />
+56 -23
View File
@@ -1,5 +1,8 @@
import { useCallback } from 'react';
import { Award } from 'lucide-react';
import type { BadgeData } from '@/components/BadgeContent';
import type { BadgeData } from '@/lib/parseBadgeDefinition';
import { useCardTilt } from '@/hooks/useCardTilt';
import { cn } from '@/lib/utils';
interface BadgeThumbnailProps {
@@ -12,39 +15,70 @@ interface BadgeThumbnailProps {
/**
* Renders a badge thumbnail with appropriate image resolution for the given size.
* Falls back to an Award icon when no image is available.
*
* Includes a mouse-only 3D tilt effect. Touch events are ignored so tapping
* through to badge detail views and normal scrolling are not disrupted.
*/
export function BadgeThumbnail({ badge, size = 48, className }: BadgeThumbnailProps) {
// Pick the best image for the requested size
const thumbUrl = pickThumb(badge, size);
// Tight perspective relative to the small thumbnail makes the 3D rotation
// clearly visible even on 28-48px elements.
const tilt = useCardTilt(35, 1.15, size * 3);
return thumbUrl ? (
<img
src={thumbUrl}
alt={badge.name}
className={cn(
'rounded-lg object-cover',
className,
)}
style={{ width: size, height: size }}
loading="lazy"
decoding="async"
/>
) : (
const handlePointerMove = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
if (e.pointerType === 'touch') return;
tilt.onPointerMove(e);
},
[tilt],
);
const handlePointerLeave = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
if (e.pointerType === 'touch') return;
tilt.onPointerLeave(e);
},
[tilt],
);
const style: React.CSSProperties = {
...tilt.style,
touchAction: 'auto',
};
return (
<div
className={cn(
'rounded-lg border border-border bg-gradient-to-br from-primary/10 via-primary/5 to-transparent flex items-center justify-center',
className,
)}
style={{ width: size, height: size }}
ref={tilt.ref}
style={style}
onPointerMove={handlePointerMove}
onPointerLeave={handlePointerLeave}
>
<Award className="text-primary/30" style={{ width: size * 0.5, height: size * 0.5 }} />
{thumbUrl ? (
<img
src={thumbUrl}
alt={badge.name}
className={cn('rounded-lg object-cover', className)}
style={{ width: size, height: size }}
loading="lazy"
decoding="async"
/>
) : (
<div
className={cn(
'rounded-lg border border-border bg-gradient-to-br from-primary/10 via-primary/5 to-transparent flex items-center justify-center',
className,
)}
style={{ width: size, height: size }}
>
<Award className="text-primary/30" style={{ width: size * 0.5, height: size * 0.5 }} />
</div>
)}
</div>
);
}
/** Pick the best thumbnail or image for a target pixel size. */
function pickThumb(badge: BadgeData, targetSize: number): string | undefined {
// Prefer exact or next-larger thumbnail
const sorted = [...badge.thumbs].sort((a, b) => {
const aSize = parseDimension(a.dimensions);
const bSize = parseDimension(b.dimensions);
@@ -56,7 +90,6 @@ function pickThumb(badge: BadgeData, targetSize: number): string | undefined {
if (dim >= targetSize) return thumb.url;
}
// Fall back to largest thumb, then full image
return sorted[sorted.length - 1]?.url ?? badge.image;
}
+155
View File
@@ -0,0 +1,155 @@
import { useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import {
Dialog,
DialogContent,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { toast } from '@/hooks/useToast';
import {
MODERATION_BAN_LABEL,
MODERATION_LABEL_NAMESPACE,
REPORT_KIND,
} from '@/lib/communityUtils';
// ── Props ─────────────────────────────────────────────────────────────────────
interface BanContentProps {
/** Ban a specific post. */
mode: 'content';
/** The event ID to ban. */
eventId: string;
/** The event author's pubkey. */
targetPubkey: string;
/** Display name for the dialog description. */
displayName?: string;
}
interface BanMemberProps {
/** Ban a member. */
mode: 'member';
eventId?: never;
/** The pubkey of the member to ban. */
targetPubkey: string;
/** Display name for the dialog description. */
displayName?: string;
}
type BanMode = BanContentProps | BanMemberProps;
type BanConfirmDialogProps = BanMode & {
/** The community `A` tag coordinate. */
communityATag: string;
open: boolean;
onOpenChange: (open: boolean) => void;
};
export function BanConfirmDialog({
mode,
eventId,
targetPubkey,
displayName,
communityATag,
open,
onOpenChange,
}: BanConfirmDialogProps) {
const queryClient = useQueryClient();
const { mutateAsync: publishEvent, isPending } = useNostrPublish();
const [reason, setReason] = useState('');
const title = mode === 'content' ? 'Remove from community' : `Ban ${displayName ? `@${displayName}` : 'member'} from community`;
const description = mode === 'content'
? 'This will hide the post from canonical community views.'
: `This will ban ${displayName ? `@${displayName}` : 'this member'} from the community. Their recruits remain unaffected.`;
const handleSubmit = async () => {
try {
const tags: string[][] = [];
if (mode === 'content' && eventId) {
tags.push(['e', eventId, 'other']);
}
tags.push(['p', targetPubkey, 'other']);
tags.push(['A', communityATag]);
tags.push(['L', MODERATION_LABEL_NAMESPACE]);
tags.push(['l', MODERATION_BAN_LABEL, MODERATION_LABEL_NAMESPACE]);
await publishEvent({
kind: REPORT_KIND,
content: reason.trim(),
tags,
});
// Invalidate community queries so the moderation overlay updates
// immediately (removes banned content/members without a page refresh).
// The activity feed's key is `['community-activity-feed', <aTagsKey>]`
// where aTagsKey is a comma-joined list of the viewer's subscribed A
// tags. We match any feed whose aTagsKey contains this communityATag.
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['community-members', communityATag] }),
queryClient.invalidateQueries({
predicate: (q) => {
const [root, aTagsKey] = q.queryKey;
return root === 'community-activity-feed'
&& typeof aTagsKey === 'string'
&& aTagsKey.split(',').includes(communityATag);
},
}),
]);
toast({ title: mode === 'content' ? 'Post removed from community' : 'Member banned from community' });
setReason('');
onOpenChange(false);
} catch {
toast({ title: mode === 'content' ? 'Failed to remove post from community' : 'Failed to ban member from community', variant: 'destructive' });
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md rounded-2xl flex flex-col overflow-hidden">
<DialogTitle>{title}</DialogTitle>
<DialogDescription className="text-sm text-muted-foreground">
{description}
</DialogDescription>
<div className="space-y-2">
<Label htmlFor="ban-reason" className="text-sm font-medium">
Reason <span className="text-muted-foreground font-normal">(optional)</span>
</Label>
<Textarea
id="ban-reason"
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="Reason for this action..."
className="resize-none"
rows={2}
/>
</div>
<div className="flex gap-2 justify-end pt-2">
<Button
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={isPending}
>
Cancel
</Button>
<Button
onClick={handleSubmit}
disabled={isPending}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isPending ? 'Submitting...' : (mode === 'content' ? 'Remove' : 'Ban')}
</Button>
</div>
</DialogContent>
</Dialog>
);
}
+1 -1
View File
@@ -19,7 +19,7 @@ type BioToken =
*/
function tokenizeBio(text: string): BioToken[] {
// Match: URLs (http/https) | hashtags (#word)
const regex = /(https?:\/\/[^\s]+)|(#\w+)/g;
const regex = /(https?:\/\/[^\s]+)|(#[\p{L}\p{N}_]+)/gu;
const result: BioToken[] = [];
let lastIndex = 0;
+151 -191
View File
@@ -1,34 +1,28 @@
import { useMemo } from 'react';
import type { NostrEvent } from '@nostrify/nostrify';
import { CalendarDays, MapPin, Clock, Users } from 'lucide-react';
import { CalendarDays, Clock, MapPin, Users } from 'lucide-react';
import { cn } from '@/lib/utils';
import { NoteContent } from '@/components/NoteContent';
import { Badge } from '@/components/ui/badge';
import { RSVPAvatars } from '@/components/RSVPAvatars';
import { Badge } from '@/components/ui/badge';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import { cn } from '@/lib/utils';
interface CalendarEventContentProps {
event: NostrEvent;
/** When true, limits the description to 2 lines for compact feed display. */
/** When true, renders a compact feed card. */
compact?: boolean;
className?: string;
}
/** Extract the first value for a given tag name. */
function getTag(tags: string[][], name: string): string | undefined {
return tags.find(([n]) => n === name)?.[1];
}
/** Collect all values for a repeated tag name. */
function getAllTags(tags: string[][], name: string): string[][] {
return tags.filter(([n]) => n === name);
}
/**
* Parse a location tag value. Some clients encode location as JSON
* (e.g. `{"description":"Riga, Latvia","coordinates":{"lat":56.9,"lon":24.1}}`).
* Extract a human-readable string when possible, otherwise return the raw value.
*/
function parseLocation(raw: string): string {
const trimmed = raw.trim();
if (!trimmed.startsWith('{')) return raw;
@@ -43,14 +37,12 @@ function parseLocation(raw: string): string {
return raw;
}
/** Date-only formatter: "Jan 15, 2026" */
const dateFormatter = new Intl.DateTimeFormat('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
/** Date+time formatter: "Jan 15, 2026 at 3:00 PM" */
const dateTimeFormatter = new Intl.DateTimeFormat('en-US', {
month: 'short',
day: 'numeric',
@@ -59,52 +51,35 @@ const dateTimeFormatter = new Intl.DateTimeFormat('en-US', {
minute: '2-digit',
});
/** Time-only formatter: "3:00 PM" */
const timeFormatter = new Intl.DateTimeFormat('en-US', {
hour: 'numeric',
minute: '2-digit',
});
/** Check if two dates fall on the same calendar day. */
function isSameDay(a: Date, b: Date): boolean {
return (
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate()
a.getFullYear() === b.getFullYear()
&& a.getMonth() === b.getMonth()
&& a.getDate() === b.getDate()
);
}
/**
* Format the date/time display for a NIP-52 calendar event.
*
* Kind 31922 (date-based): "Jan 15, 2026" or "Jan 15 - Jan 17, 2026"
* Kind 31923 (time-based): "Jan 15, 2026 at 3:00 PM" or time ranges
*/
function formatEventDate(event: NostrEvent): string {
const start = getTag(event.tags, 'start');
if (!start) return '';
if (event.kind === 31922) {
// Date-based: start/end are YYYY-MM-DD strings
// Parse as UTC to avoid timezone shifting the date
const startDate = new Date(start + 'T00:00:00Z');
const startDate = new Date(`${start}T00:00:00Z`);
if (isNaN(startDate.getTime())) return start;
const end = getTag(event.tags, 'end');
if (end) {
const endDate = new Date(end + 'T00:00:00Z');
const endDate = new Date(`${end}T00:00:00Z`);
if (!isNaN(endDate.getTime()) && endDate > startDate) {
// Multi-day range: "Jan 15 - Jan 17, 2026"
// NIP-52: end date is exclusive, so display the last inclusive day
const lastDay = new Date(endDate.getTime() - 86400000);
if (lastDay > startDate) {
const startParts = dateFormatter.formatToParts(startDate);
const startStr = startParts
.filter((p) => p.type !== 'year' && p.type !== 'literal' || p.value === ' ')
.map((p) => (p.type === 'literal' && p.value.includes(',') ? '' : p.value))
.join('')
.trim();
return `${startStr} ${dateFormatter.format(lastDay)}`;
const startStr = dateFormatter.format(startDate).replace(/, \d{4}$/, '');
return `${startStr} - ${dateFormatter.format(lastDay)}`;
}
}
}
@@ -113,7 +88,6 @@ function formatEventDate(event: NostrEvent): string {
}
if (event.kind === 31923) {
// Time-based: start/end are Unix timestamps
const startTs = parseInt(start, 10);
if (isNaN(startTs)) return start;
const startDate = new Date(startTs * 1000);
@@ -123,13 +97,10 @@ function formatEventDate(event: NostrEvent): string {
const endTs = parseInt(end, 10);
if (!isNaN(endTs) && endTs > startTs) {
const endDate = new Date(endTs * 1000);
if (isSameDay(startDate, endDate)) {
// Same day: "Jan 15, 2026 at 3:00 PM 5:00 PM"
return `${dateTimeFormatter.format(startDate)} ${timeFormatter.format(endDate)}`;
return `${dateTimeFormatter.format(startDate)} - ${timeFormatter.format(endDate)}`;
}
// Different days: "Jan 15, 2026 at 3:00 PM Jan 16, 2026 at 5:00 PM"
return `${dateTimeFormatter.format(startDate)} ${dateTimeFormatter.format(endDate)}`;
return `${dateTimeFormatter.format(startDate)} - ${dateTimeFormatter.format(endDate)}`;
}
}
@@ -139,173 +110,162 @@ function formatEventDate(event: NostrEvent): string {
return start;
}
function getEventEndTimestamp(event: NostrEvent): number {
const start = getTag(event.tags, 'start');
if (!start) return 0;
if (event.kind === 31922) {
const end = getTag(event.tags, 'end');
const endDate = new Date(`${end || start}T00:00:00Z`);
if (isNaN(endDate.getTime())) return 0;
return Math.floor(endDate.getTime() / 1000) + (end ? 0 : 86400);
}
const end = getTag(event.tags, 'end') || start;
const endTs = parseInt(end, 10);
return isNaN(endTs) ? 0 : endTs;
}
/** Renders NIP-52 calendar event content (kind 31922 and 31923). */
export function CalendarEventContent({ event, compact, className }: CalendarEventContentProps) {
const title = useMemo(() => getTag(event.tags, 'title'), [event.tags]);
const image = useMemo(() => getTag(event.tags, 'image'), [event.tags]);
const image = useMemo(() => sanitizeUrl(getTag(event.tags, 'image')), [event.tags]);
const locationRaw = useMemo(() => getTag(event.tags, 'location'), [event.tags]);
const location = useMemo(() => locationRaw ? parseLocation(locationRaw) : undefined, [locationRaw]);
const dateDisplay = useMemo(() => formatEventDate(event), [event]);
const hashtags = useMemo(() => getAllTags(event.tags, 't').map(([, v]) => v).filter(Boolean), [event.tags]);
const participants = useMemo(() => getAllTags(event.tags, 'p'), [event.tags]);
const hasContent = event.content.trim().length > 0;
const summary = useMemo(() => getTag(event.tags, 'summary'), [event.tags]);
const ended = useMemo(() => getEventEndTimestamp(event) < Math.floor(Date.now() / 1000), [event]);
const hasContent = event.content.trim().length > 0;
const participantPubkeys = useMemo(
() => participants.map(([, pubkey]) => pubkey).filter(Boolean),
[participants],
);
if (compact) {
return (
<div className={cn('mt-3 space-y-3', className)}>
{image && (
<div className="relative -mx-4 aspect-[21/9] overflow-hidden">
<img src={image} alt={title ?? 'Calendar event'} className="w-full h-full object-cover" loading="lazy" />
{participantPubkeys.length > 0 && (
<div className="absolute bottom-2 left-3">
<RSVPAvatars pubkeys={participantPubkeys} maxVisible={4} size="md" />
</div>
)}
</div>
)}
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<CalendarDays className="size-4 text-primary shrink-0" />
<h3 className="font-semibold text-[15px] leading-tight line-clamp-2">{title ?? 'Untitled event'}</h3>
</div>
{ended ? (
<Badge variant="secondary" className="shrink-0">Ended</Badge>
) : dateDisplay ? (
<span className="flex items-center gap-1 text-xs text-muted-foreground shrink-0 max-w-[45%]">
<Clock className="size-3" />
<span className="truncate">{dateDisplay}</span>
</span>
) : null}
</div>
{dateDisplay && ended && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Clock className="size-3" />
<span>{dateDisplay}</span>
</div>
)}
{(summary || hasContent) && (
<div className="text-sm text-muted-foreground leading-relaxed line-clamp-2">
{summary && !hasContent ? (
<p>{summary}</p>
) : (
<NoteContent event={event} className="text-sm" hideEmbedImages />
)}
</div>
)}
{(location || participantPubkeys.length > 0) && (
<div className="flex items-center gap-2.5 rounded-lg bg-muted/50 px-3 py-2">
{location ? (
<>
<MapPin className="h-4 w-4 shrink-0 text-red-500" />
<span className="text-sm truncate flex-1">{location}</span>
</>
) : (
<>
<Users className="h-4 w-4 shrink-0 text-primary" />
<span className="text-sm text-muted-foreground flex-1">Participants</span>
</>
)}
{participantPubkeys.length > 0 && (
<RSVPAvatars pubkeys={participantPubkeys} maxVisible={4} size="sm" />
)}
</div>
)}
{hashtags.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{hashtags.slice(0, 4).map((tag) => (
<Badge key={tag} variant="secondary" className="text-[11px] px-2 py-0.5">
{tag}
</Badge>
))}
</div>
)}
</div>
);
}
return (
<div className={cn('mt-2 rounded-xl border border-border overflow-hidden', className)}>
{compact ? (
/* ── Compact feed card matching reference design ── */
<>
{/* Cover image with capped height, or gradient placeholder */}
{image ? (
<div className="relative h-[180px] overflow-hidden">
<img
src={image}
alt={title ?? 'Calendar event'}
className="h-full w-full object-cover"
loading="lazy"
/>
{/* Participant avatars overlaid on the image */}
{participantPubkeys.length > 0 && (
<div className="absolute bottom-2 left-3">
<RSVPAvatars pubkeys={participantPubkeys} maxVisible={4} size="md" />
</div>
)}
</div>
) : (
<div className="relative flex items-center justify-center bg-gradient-to-br from-primary/10 via-primary/5 to-transparent h-[100px]">
<CalendarDays className="h-10 w-10 text-primary/30" />
{participantPubkeys.length > 0 && (
<div className="absolute bottom-2 left-3">
<RSVPAvatars pubkeys={participantPubkeys} maxVisible={4} size="md" />
</div>
)}
</div>
)}
{/* Event details below image */}
<div className="p-3 space-y-2">
{/* Title */}
{title && (
<h3 className="text-base font-bold leading-snug line-clamp-2">{title}</h3>
)}
{/* Date/time */}
{dateDisplay && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Clock className="h-3.5 w-3.5 shrink-0" />
<span>{dateDisplay}</span>
</div>
)}
{/* Description snippet — hard-capped to ~2 lines */}
{(summary || hasContent) && (
<div className="text-sm text-muted-foreground max-h-[2.8em] overflow-hidden relative">
{summary && !hasContent ? (
<p className="line-clamp-2">{summary}</p>
) : (
<NoteContent event={event} className="text-sm" hideEmbedImages />
)}
</div>
)}
{/* Location pill */}
{location && (
<div className="flex items-center gap-2.5 rounded-lg bg-secondary/50 px-3 py-2">
<MapPin className="h-4 w-4 shrink-0 text-red-500" />
<span className="text-sm truncate">{location}</span>
</div>
)}
{/* Hashtags */}
{hashtags.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{hashtags.slice(0, 4).map((tag) => (
<Badge key={tag} variant="secondary" className="text-[11px] px-2 py-0.5">
{tag}
</Badge>
))}
</div>
)}
</div>
</>
{image ? (
<div className="aspect-video rounded-lg overflow-hidden">
<img src={image} alt={title ?? 'Calendar event'} className="h-full w-full object-cover" loading="lazy" />
</div>
) : (
/* ── Full detail layout (detail page, expanded view) ── */
<>
{/* Cover image or gradient header */}
{image ? (
<div className="aspect-video rounded-lg overflow-hidden">
<img
src={image}
alt={title ?? 'Calendar event'}
className="h-full w-full object-cover"
loading="lazy"
/>
</div>
) : (
<div className="flex items-center justify-center bg-gradient-to-br from-primary/10 via-primary/5 to-transparent py-8">
<CalendarDays className="h-10 w-10 text-primary/30" />
</div>
)}
{/* Event details */}
<div className="space-y-2 p-3">
{title && (
<h3 className="text-[15px] font-semibold leading-snug">{title}</h3>
)}
{dateDisplay && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Clock className="h-3.5 w-3.5 shrink-0" />
<span>{dateDisplay}</span>
</div>
)}
{location && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<MapPin className="h-3.5 w-3.5 shrink-0" />
<span>{location}</span>
</div>
)}
{participants.length > 0 && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Users className="h-3.5 w-3.5 shrink-0" />
<span>
{participants.length} {participants.length === 1 ? 'participant' : 'participants'}
</span>
</div>
)}
{summary && !hasContent && (
<p className="text-sm text-muted-foreground">
{summary}
</p>
)}
{hasContent && (
<div>
<NoteContent event={event} className="text-sm" hideEmbedImages={!!image} />
</div>
)}
{hashtags.length > 0 && (
<div className="flex flex-wrap gap-1.5 pt-1">
{hashtags.map((tag) => (
<Badge key={tag} variant="secondary" className="text-[11px] px-2 py-0">
{tag}
</Badge>
))}
</div>
)}
</div>
</>
<div className="flex items-center justify-center bg-gradient-to-br from-primary/10 via-primary/5 to-transparent py-8">
<CalendarDays className="h-10 w-10 text-primary/30" />
</div>
)}
<div className="space-y-2 p-3">
{title && <h3 className="text-[15px] font-semibold leading-snug">{title}</h3>}
{dateDisplay && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Clock className="h-3.5 w-3.5 shrink-0" />
<span>{dateDisplay}</span>
</div>
)}
{location && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<MapPin className="h-3.5 w-3.5 shrink-0" />
<span>{location}</span>
</div>
)}
{participants.length > 0 && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Users className="h-3.5 w-3.5 shrink-0" />
<span>{participants.length} {participants.length === 1 ? 'participant' : 'participants'}</span>
</div>
)}
{summary && !hasContent && <p className="text-sm text-muted-foreground">{summary}</p>}
{hasContent && <NoteContent event={event} className="text-sm" hideEmbedImages={!!image} />}
{hashtags.length > 0 && (
<div className="flex flex-wrap gap-1.5 pt-1">
{hashtags.map((tag) => (
<Badge key={tag} variant="secondary" className="text-[11px] px-2 py-0">
{tag}
</Badge>
))}
</div>
)}
</div>
</div>
);
}
+152 -137
View File
@@ -1,6 +1,5 @@
import { useState, useMemo, useCallback } from 'react';
import { useMemo, useCallback, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { nip19 } from 'nostr-tools';
import {
ArrowLeft,
CalendarDays,
@@ -9,10 +8,9 @@ import {
Users,
Check,
X as XIcon,
HelpCircle,
Share2,
Star,
Pencil,
ExternalLink,
Zap,
Link as LinkIcon,
} from 'lucide-react';
import type { NostrEvent, NostrMetadata } from '@nostrify/nostrify';
@@ -22,10 +20,15 @@ import { getAvatarShape } from '@/lib/avatarShape';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { Textarea } from '@/components/ui/textarea';
import { NoteContent } from '@/components/NoteContent';
import { NoteMoreMenu } from '@/components/NoteMoreMenu';
import { PostActionBar } from '@/components/PostActionBar';
import { ReplyComposeModal } from '@/components/ReplyComposeModal';
import { CreateCommunityEventDialog } from '@/components/CreateCommunityEventDialog';
import { RSVPAvatars } from '@/components/RSVPAvatars';
import { ZapDialog } from '@/components/ZapDialog';
import { Skeleton } from '@/components/ui/skeleton';
import { ThreadedReplyList, type ReplyNode } from '@/components/ThreadedReplyList';
import { useComments } from '@/hooks/useComments';
import { useAuthor } from '@/hooks/useAuthor';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useEventRSVPs } from '@/hooks/useEventRSVPs';
@@ -34,6 +37,7 @@ import { usePublishRSVP } from '@/hooks/usePublishRSVP';
import { useProfileUrl } from '@/hooks/useProfileUrl';
import { useToast } from '@/hooks/useToast';
import { genUserName } from '@/lib/genUserName';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import { cn } from '@/lib/utils';
// --- Helpers ---
@@ -159,7 +163,7 @@ export function CalendarEventDetailPage({ event }: { event: NostrEvent }) {
const location = locationRaw ? parseLocation(locationRaw) : undefined;
const summary = getTag(event.tags, 'summary');
const hashtags = getAllTags(event.tags, 't').map(([, v]) => v).filter(Boolean);
const links = getAllTags(event.tags, 'r').map(([, v]) => v).filter(Boolean);
const links = getAllTags(event.tags, 'r').map(([, v]) => sanitizeUrl(v)).filter((v): v is string => !!v);
const eventCoord = useMemo(() => getEventCoord(event), [event]);
const dateStr = useMemo(() => formatDetailDate(event), [event]);
@@ -183,48 +187,47 @@ export function CalendarEventDetailPage({ event }: { event: NostrEvent }) {
const rsvps = useEventRSVPs(eventCoord);
const myRsvp = useMyRSVP(eventCoord);
const publishRSVP = usePublishRSVP();
const { data: commentsData, isLoading: commentsLoading } = useComments(event, 500);
const [replyOpen, setReplyOpen] = useState(false);
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
const [editOpen, setEditOpen] = useState(false);
const canEdit = user?.pubkey === event.pubkey;
const [selectedStatus, setSelectedStatus] = useState<'accepted' | 'declined' | 'tentative' | null>(null);
const [rsvpNote, setRsvpNote] = useState('');
const replyTree = useMemo((): ReplyNode[] => {
const buildNode = (comment: NostrEvent): ReplyNode => {
const children = commentsData?.getDirectReplies(comment.id) ?? [];
if (children.length <= 1) {
return { event: comment, children: children.map((child) => buildNode(child)) };
}
const activeStatus = selectedStatus ?? myRsvp.status;
const hasChanged = selectedStatus !== null && selectedStatus !== myRsvp.status;
const [first, ...rest] = children;
return {
event: comment,
children: [buildNode(first)],
hiddenChildren: rest.map((child) => buildNode(child)),
};
};
const handleRSVP = useCallback(async () => {
if (!activeStatus) return;
return [...(commentsData?.topLevelComments ?? [])]
.sort((a, b) => a.created_at - b.created_at)
.map((comment) => buildNode(comment));
}, [commentsData]);
const handleRSVP = useCallback(async (status: 'accepted' | 'declined' | 'tentative') => {
if (status === myRsvp.status) return;
try {
await publishRSVP.mutateAsync({
eventCoord,
eventAuthorPubkey: event.pubkey,
status: activeStatus,
note: rsvpNote || undefined,
status,
});
setSelectedStatus(null);
setRsvpNote('');
toast({ title: 'RSVP updated' });
} catch {
toast({ title: 'Failed to update RSVP', variant: 'destructive' });
}
}, [activeStatus, eventCoord, event.pubkey, rsvpNote, publishRSVP, toast]);
}, [eventCoord, event.pubkey, myRsvp.status, publishRSVP, toast]);
const handleShare = useCallback(async () => {
const d = getTag(event.tags, 'd') ?? '';
const naddr = nip19.naddrEncode({
kind: event.kind,
pubkey: event.pubkey,
identifier: d,
});
const url = `${window.location.origin}/${naddr}`;
try {
await navigator.clipboard.writeText(url);
toast({ title: 'Link copied to clipboard' });
} catch {
toast({ title: 'Failed to copy link', variant: 'destructive' });
}
}, [event, toast]);
const isAuthor = user?.pubkey === event.pubkey;
const showRSVP = !!user && !isAuthor;
const showRSVP = !!user;
return (
<div className="max-w-2xl mx-auto pb-16">
@@ -238,6 +241,15 @@ export function CalendarEventDetailPage({ event }: { event: NostrEvent }) {
<ArrowLeft className="size-5" />
</button>
<h1 className="text-xl font-bold flex-1">Event Details</h1>
{canEdit && (
<button
className="p-2 rounded-full hover:bg-secondary/60 transition-colors"
onClick={() => setEditOpen(true)}
aria-label="Edit event"
>
<Pencil className="size-5" />
</button>
)}
</div>
{/* ── Cover image ── */}
@@ -255,25 +267,11 @@ export function CalendarEventDetailPage({ event }: { event: NostrEvent }) {
<div className="px-5 mt-5 space-y-5">
{/* Title */}
<h2 className="text-2xl font-bold leading-tight tracking-tight">{title}</h2>
{/* Organizer row + actions */}
{/* Organizer row */}
<div className="flex items-center gap-3">
<div className="flex-1 min-w-0">
<PersonRow pubkey={event.pubkey} />
</div>
<div className="flex items-center gap-1 shrink-0">
<ZapDialog target={event}>
<button className="p-2 rounded-full hover:bg-secondary/60 transition-colors" aria-label="Zap">
<Zap className="size-5" />
</button>
</ZapDialog>
<button
className="p-2 rounded-full hover:bg-secondary/60 transition-colors"
onClick={handleShare}
aria-label="Share"
>
<Share2 className="size-5" />
</button>
</div>
</div>
{/* Date & Location — sidebar-style pills */}
@@ -354,96 +352,113 @@ export function CalendarEventDetailPage({ event }: { event: NostrEvent }) {
</>
)}
{/* RSVP section */}
{showRSVP && (
<div className="rounded-[1.25rem] bg-background/85 p-4 space-y-3">
<h2 className="text-sm font-semibold px-1">Your RSVP</h2>
{myRsvp.status && !selectedStatus && (
<div className="px-1">
<Badge
variant="outline"
className={cn(
myRsvp.status === 'accepted' && 'border-green-500 text-green-600',
myRsvp.status === 'tentative' && 'border-amber-500 text-amber-600',
myRsvp.status === 'declined' && 'border-destructive text-destructive',
)}
>
{myRsvp.status === 'accepted' ? 'Going' : myRsvp.status === 'tentative' ? 'Maybe' : "Can't Go"}
</Badge>
</div>
)}
<div className="flex gap-2">
<Button
size="sm"
variant={activeStatus === 'accepted' ? 'default' : 'outline'}
className={cn('flex-1 rounded-full', activeStatus === 'accepted' && 'bg-green-600 hover:bg-green-700 text-white')}
onClick={() => setSelectedStatus('accepted')}
>
<Check className="size-3.5 mr-1.5" /> Going
</Button>
<Button
size="sm"
variant={activeStatus === 'tentative' ? 'default' : 'outline'}
className={cn('flex-1 rounded-full', activeStatus === 'tentative' && 'bg-amber-500 hover:bg-amber-600 text-white')}
onClick={() => setSelectedStatus('tentative')}
>
<HelpCircle className="size-3.5 mr-1.5" /> Maybe
</Button>
<Button
size="sm"
variant={activeStatus === 'declined' ? 'default' : 'outline'}
className={cn('flex-1 rounded-full', activeStatus === 'declined' && 'bg-destructive hover:bg-destructive/90 text-destructive-foreground')}
onClick={() => setSelectedStatus('declined')}
>
<XIcon className="size-3.5 mr-1.5" /> Can't Go
</Button>
</div>
{activeStatus && (
<Textarea
placeholder="Add a note (optional)"
value={rsvpNote}
onChange={(e) => setRsvpNote(e.target.value)}
className="mt-1 resize-none rounded-xl"
rows={2}
/>
)}
{(hasChanged || (activeStatus && !myRsvp.status)) && (
<Button
size="sm"
onClick={handleRSVP}
disabled={publishRSVP.isPending}
className="w-full mt-1 rounded-full"
>
{publishRSVP.isPending ? 'Updating...' : myRsvp.status ? 'Update RSVP' : 'Submit RSVP'}
</Button>
)}
</div>
)}
{/* Attendees */}
{rsvps.total > 0 && (
<section className="space-y-3">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-2">
<Users className="size-4" /> Attendees
</h2>
<div className="space-y-2.5">
{([
['Going', rsvps.accepted, 'border-green-500/50 bg-green-500/5 text-green-600'],
['Maybe', rsvps.tentative, 'border-amber-500/50 bg-amber-500/5 text-amber-600'],
["Can't Go", rsvps.declined, 'border-muted-foreground/30 bg-muted/30 text-muted-foreground'],
] as const).map(([label, pks, cls]) => pks.length > 0 && (
<div key={label} className="flex items-center gap-3">
<Badge variant="outline" className={cn(cls, 'shrink-0 text-xs')}>{label} ({pks.length})</Badge>
<RSVPAvatars pubkeys={pks} maxVisible={8} size="sm" />
<>
<Separator />
<section className="space-y-3">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-2">
<Users className="size-4" /> Attendees
</h2>
<div className="space-y-2.5">
{([
['Going', rsvps.accepted, 'border-green-500/50 bg-green-500/5 text-green-600'],
['Interested', rsvps.tentative, 'border-amber-500/50 bg-amber-500/5 text-amber-600'],
["Can't Go", rsvps.declined, 'border-muted-foreground/30 bg-muted/30 text-muted-foreground'],
] as const).map(([label, pks, cls]) => pks.length > 0 && (
<div key={label} className="flex items-center gap-3">
<Badge variant="outline" className={cn(cls, 'shrink-0 text-xs')}>{label} ({pks.length})</Badge>
<RSVPAvatars pubkeys={pks} maxVisible={8} size="sm" />
</div>
))}
</div>
</section>
</>
)}
{/* RSVP section */}
{showRSVP && (
<>
<Separator />
<section className="space-y-3">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-2">
<Check className="size-4" /> RSVP
</h2>
<div className="flex gap-2">
<Button
size="sm"
variant={myRsvp.status === 'accepted' ? 'default' : 'outline'}
disabled={publishRSVP.isPending}
className={cn('flex-1 rounded-full', myRsvp.status === 'accepted' && 'bg-green-600 hover:bg-green-700 text-white')}
onClick={() => handleRSVP('accepted')}
>
<Check className="size-3.5 mr-1.5" /> Going
</Button>
<Button
size="sm"
variant={myRsvp.status === 'tentative' ? 'default' : 'outline'}
disabled={publishRSVP.isPending}
className={cn('flex-1 rounded-full', myRsvp.status === 'tentative' && 'bg-amber-500 hover:bg-amber-600 text-white')}
onClick={() => handleRSVP('tentative')}
>
<Star className="size-3.5 mr-1.5" /> Interested
</Button>
<Button
size="sm"
variant={myRsvp.status === 'declined' ? 'default' : 'outline'}
disabled={publishRSVP.isPending}
className={cn('flex-1 rounded-full', myRsvp.status === 'declined' && 'bg-destructive hover:bg-destructive/90 text-destructive-foreground')}
onClick={() => handleRSVP('declined')}
>
<XIcon className="size-3.5 mr-1.5" /> Can't Go
</Button>
</div>
</section>
</>
)}
<PostActionBar
event={event}
replyLabel="Comments"
onReply={() => setReplyOpen(true)}
onMore={() => setMoreMenuOpen(true)}
className="-mx-5 px-5"
/>
<NoteMoreMenu event={event} open={moreMenuOpen} onOpenChange={setMoreMenuOpen} />
<ReplyComposeModal event={event} open={replyOpen} onOpenChange={setReplyOpen} />
{canEdit && (
<CreateCommunityEventDialog
open={editOpen}
onOpenChange={setEditOpen}
event={event}
/>
)}
<section>
{commentsLoading ? (
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="flex gap-3">
<Skeleton className="size-10 rounded-full shrink-0" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-2/3" />
</div>
</div>
))}
</div>
</section>
)}
) : replyTree.length > 0 ? (
<div className="-mx-5">
<ThreadedReplyList roots={replyTree} />
</div>
) : (
<div className="py-8 text-center text-muted-foreground text-sm">
No comments yet. Be the first to comment!
</div>
)}
</section>
</div>
</div>
);
+2 -88
View File
@@ -2,8 +2,7 @@ import { useMemo, useState, useEffect, useId } from 'react';
import { cn } from '@/lib/utils';
import { useTheme } from '@/hooks/useTheme';
import { hexToHslString, hexToRgb, rgbToHsl, hslToRgb, getLuminance, getContrastRatio, parseHsl, formatHsl } from '@/lib/colorUtils';
import type { CoreThemeColors } from '@/themes';
import { getColors, paletteToTheme } from '@/lib/colorMomentUtils';
import type { NostrEvent } from '@nostrify/nostrify';
type Layout = 'horizontal' | 'vertical' | 'grid' | 'star' | 'checkerboard' | 'diagonalStripes';
@@ -12,12 +11,7 @@ function getTag(tags: string[][], name: string): string | undefined {
return tags.find(([n]) => n === name)?.[1];
}
function getColors(tags: string[][]): string[] {
return tags
.filter(([n]) => n === 'c')
.map(([, v]) => v)
.filter((v) => /^#[0-9A-Fa-f]{6}$/.test(v));
}
/** Compute a best-fit grid: cols × rows for n items. */
function gridDimensions(n: number): { cols: number; rows: number } {
@@ -193,86 +187,6 @@ function DiagonalStripesLayout({ colors }: { colors: string[] }) {
);
}
// ─── Palette → theme mapping ─────────────────────────────
function hexLuminance(hex: string): number {
return getLuminance(...hexToRgb(hex));
}
function hexContrast(hex1: string, hex2: string): number {
return getContrastRatio(hexToRgb(hex1), hexToRgb(hex2));
}
function hexSaturation(hex: string): number {
return rgbToHsl(...hexToRgb(hex)).s;
}
/**
* Adjust the lightness of an HSL string until it achieves at least `targetRatio`
* contrast against `bgHsl`. Steps toward white or black depending on which
* direction gives better contrast. Returns the adjusted HSL string.
*/
function enforceContrast(hsl: string, bgHsl: string, targetRatio: number): string {
const bg = parseHsl(bgHsl);
const bgLum = getLuminance(...hslToRgb(bg.h, bg.s, bg.l));
const { h, s, l } = parseHsl(hsl);
// Decide direction: go lighter if bg is dark, darker if bg is light
const goLighter = bgLum < 0.18;
let current = l;
for (let i = 0; i < 50; i++) {
current = goLighter
? Math.min(100, current + 2)
: Math.max(0, current - 2);
const rgb = hslToRgb(h, s, current);
const lum = getLuminance(...rgb);
const lighter = Math.max(bgLum, lum);
const darker = Math.min(bgLum, lum);
if ((lighter + 0.05) / (darker + 0.05) >= targetRatio) break;
}
return formatHsl(h, s, current);
}
/**
* Map palette hex colors to CoreThemeColors with guaranteed readability:
* 1. background = darkest color
* 2. text = lightest color; if contrast < 4.5:1, synthesize white or black
* 3. primary = most saturated remaining color; if contrast < 3:1 against
* background, adjust its lightness until it passes
*/
function paletteToTheme(colors: string[]): CoreThemeColors {
if (colors.length === 0) {
return { background: '0 0% 10%', text: '0 0% 98%', primary: '258 70% 55%' };
}
const sorted = [...colors].sort((a, b) => hexLuminance(a) - hexLuminance(b));
const bgHex = sorted[0];
const bgHsl = hexToHslString(bgHex);
// Text: lightest palette color; override with white/black if contrast is too low
const textHex = sorted[sorted.length - 1];
let textHsl = hexToHslString(textHex);
if (hexContrast(textHex, bgHex) < 4.5) {
// Pick white or black — whichever contrasts better
const whiteContrast = hexContrast('#ffffff', bgHex);
const blackContrast = hexContrast('#000000', bgHex);
textHsl = whiteContrast >= blackContrast ? '0 0% 98%' : '222 20% 8%';
}
// Primary: most saturated of remaining colors; nudge lightness if needed
const rest = colors.filter((c) => c !== bgHex && c !== textHex);
const pool = rest.length > 0 ? rest : [textHex];
const primaryHex = pool.reduce((best, c) => hexSaturation(c) > hexSaturation(best) ? c : best, pool[0]);
let primaryHsl = hexToHslString(primaryHex);
if (hexContrast(primaryHex, bgHex) < 3) {
primaryHsl = enforceContrast(primaryHsl, bgHsl, 3);
}
return { background: bgHsl, text: textHsl, primary: primaryHsl };
}
// ─── Main component ──────────────────────────────────────
const LAYOUT_MAP: Record<Layout, React.FC<{ colors: string[] }>> = {
+118 -28
View File
@@ -3,9 +3,10 @@ import { type ReactNode, useMemo } from 'react';
import { Link } from 'react-router-dom';
import { nip19 } from 'nostr-tools';
import {
Award, BarChart3, BookOpen, Camera, Clapperboard, FileText, Film,
Award, BarChart3, BookOpen, Camera, Clapperboard, Egg, FileText, Film,
GitBranch, GitPullRequest, Mail, MapPin, MessageSquare, Mic, Music,
Package, Palette, PartyPopper, Podcast, Radio, Rocket, SmilePlus, Sparkles,
Package, Palette, PartyPopper, Podcast, Radio, Rocket, SmilePlus,
Target, Users, Vote, Zap,
} from 'lucide-react';
import type { NostrEvent } from '@nostrify/nostrify';
@@ -21,6 +22,7 @@ import { ExternalFavicon } from '@/components/ExternalFavicon';
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card';
import { Skeleton } from '@/components/ui/skeleton';
import { useAddrEvent, useEvent } from '@/hooks/useEvent';
import { usePollVoteLabel } from '@/hooks/usePollVoteLabel';
import { useAuthor } from '@/hooks/useAuthor';
import { useBookInfo } from '@/hooks/useBookInfo';
import { useLinkPreview } from '@/hooks/useLinkPreview';
@@ -43,26 +45,38 @@ interface CommentRoot {
identifier?: string;
/** Root kind number (from K tag). */
rootKind?: string;
/** Relay URL hint from the E or A tag (position [2]). */
relayHint?: string;
/** Author pubkey hint extracted from the E tag (position [3]) or P tag. */
authorHint?: string;
}
/** Parse the root reference from a kind 1111 comment's tags. */
function parseCommentRoot(event: NostrEvent): CommentRoot | undefined {
const aTag = event.tags.find(([name]) => name === 'A')?.[1];
const aTagFull = event.tags.find(([name]) => name === 'A');
// Use find (not findLast) to get the root E tag, not a parent e tag
const eTag = event.tags.find(([name]) => name === 'E')?.[1];
const eTagFull = event.tags.find(([name]) => name === 'E');
const iTag = event.tags.find(([name]) => name === 'I')?.[1];
const kTag = event.tags.find(([name]) => name === 'K')?.[1];
// P tag holds the root event author's pubkey — used as author hint fallback
const pTag = event.tags.find(([name]) => name === 'P')?.[1];
if (aTag) {
if (aTagFull) {
const aTag = aTagFull[1];
const relayHint = aTagFull[2] || undefined;
const parts = aTag.split(':');
const kind = parseInt(parts[0], 10);
const pubkey = parts[1] ?? '';
const identifier = parts.slice(2).join(':');
return { type: 'addr', addr: { kind, pubkey, identifier }, rootKind: kTag };
return { type: 'addr', addr: { kind, pubkey, identifier }, rootKind: kTag, relayHint };
}
if (eTag) {
return { type: 'event', eventId: eTag, rootKind: kTag };
if (eTagFull) {
const eTag = eTagFull[1];
const relayHint = eTagFull[2] || undefined;
// NIP-22 E tags may have the author pubkey at position [3]; fall back to P tag
const authorHint = eTagFull[3] || pTag || undefined;
return { type: 'event', eventId: eTag, rootKind: kTag, relayHint, authorHint };
}
if (iTag) {
@@ -79,6 +93,7 @@ function parseCommentRoot(event: NostrEvent): CommentRoot | undefined {
* ("Requests to Vanish", "User Statuses") and must NOT be used directly.
*/
const KIND_LABELS: Record<number, string> = {
0: 'a profile',
1: 'a post',
4: 'an encrypted message',
6: 'a repost',
@@ -89,42 +104,49 @@ const KIND_LABELS: Record<number, string> = {
22: 'a short video',
62: 'a request to vanish',
1063: 'a file',
1018: 'a vote',
1068: 'a poll',
1111: 'a comment',
1222: 'a voice message',
8211: 'a letter',
1617: 'a patch',
1618: 'a pull request',
3367: 'a color moment',
7516: 'a found log',
15128: 'an nsite',
16767: 'a theme',
10008: 'profile badges',
30008: 'profile badges',
30009: 'a badge',
30023: 'an article',
30030: 'an emoji pack',
30054: 'a podcast episode',
30055: 'a podcast trailer',
30063: 'a release',
3063: 'a Zapstore asset',
30063: 'a Zapstore release',
30311: 'a stream',
30315: 'a status',
30617: 'a repository',
30817: 'a custom NIP',
31922: 'a calendar event',
31923: 'a calendar event',
32267: 'an app',
31990: 'an app',
32267: 'a Zapstore app',
34139: 'a playlist',
34236: 'a vine',
34236: 'a divine',
34550: 'a community',
9041: 'a fundraising goal',
35128: 'an nsite',
36767: 'a theme',
36639: 'an action',
36787: 'a track',
37381: 'a Magic deck',
37516: 'a geocache',
37516: 'a treasure',
39089: 'a follow pack',
9735: 'a zap',
};
/** Kind-specific icons — matches sidebar and NoteCard icons. */
const KIND_ICONS: Partial<Record<number, React.ComponentType<{ className?: string }>>> = {
0: Users,
1: MessageSquare,
4: Mail,
6: RepostIcon,
@@ -133,25 +155,29 @@ const KIND_ICONS: Partial<Record<number, React.ComponentType<{ className?: strin
21: Film,
22: Film,
1063: FileText,
1018: Vote,
1068: BarChart3,
1222: Mic,
1617: FileText,
8211: Mail,
1618: GitPullRequest,
15128: Rocket,
35128: Rocket,
36639: Zap,
10008: Award,
30008: Award,
30009: Award,
30023: BookOpen,
30030: SmilePlus,
30054: Podcast,
30055: Podcast,
3063: Package,
30063: Package,
30311: Radio,
30617: GitBranch,
31990: Package,
32267: Package,
34236: Clapperboard,
36767: Sparkles,
16767: Sparkles,
36787: Music,
34139: Music,
37381: CardsIcon,
@@ -159,6 +185,9 @@ const KIND_ICONS: Partial<Record<number, React.ComponentType<{ className?: strin
7516: ChestIcon,
39089: PartyPopper,
3367: Palette,
9041: Target,
31124: Egg,
9735: Zap,
};
/**
@@ -188,21 +217,20 @@ function getRootKindLabel(rootKind: string | undefined): string {
const KIND_SUFFIXES: Partial<Record<number, string>> = {
30009: 'badge',
30030: 'emoji pack',
36767: 'theme',
16767: 'theme',
39089: 'follow pack',
37381: 'deck',
37516: 'geocache',
37516: 'treasure',
34550: 'community',
30054: 'episode',
30055: 'trailer',
34139: 'playlist',
};
/** Postfix that replaces the default pattern (e.g. "Ditto on Zapstore" instead of "Ditto app"). */
/** Postfix that replaces the default pattern (e.g. "Ditto on Zapstore" instead of "Ditto Zapstore app"). */
const KIND_POSTFIXES: Partial<Record<number, string>> = {
32267: 'on Zapstore',
30063: 'release',
30063: 'Zapstore release',
3063: 'Zapstore asset',
};
/** Get a display name for an event based on its kind and tags. */
@@ -217,6 +245,16 @@ function getEventDisplayName(event: NostrEvent): { text: string; icon?: React.Co
return { text: siteName ? `${siteName} nsite` : 'an nsite', icon };
}
// NIP-89 apps: name lives in JSON content, not in tags
if (event.kind === 31990) {
try {
const meta = JSON.parse(event.content);
const appName = meta?.name || event.tags.find(([n]) => n === 'name')?.[1];
if (appName) return { text: `${appName} app`, icon };
} catch { /* fall through */ }
return { text: 'an app', icon };
}
// Extract a title-like string from tags
const title = event.tags.find(([name]) => name === 'title')?.[1];
const name = event.tags.find(([name]) => name === 'name')?.[1];
@@ -380,8 +418,8 @@ function AddrCommentContext({ root, className }: { root: CommentRoot; className?
return <ProfileCommentContext pubkey={root.addr.pubkey} className={className} />;
}
// Kind 30008 (profile badges) roots — show "@User's profile badges"
if (root.addr?.kind === 30008) {
// Kind 10008 or 30008 (profile badges) roots — show "@User's profile badges"
if (root.addr?.kind === 10008 || root.addr?.kind === 30008) {
return <ProfileBadgesCommentContext root={root} className={className} />;
}
@@ -410,7 +448,7 @@ function ProfileCommentContext({ pubkey, className }: { pubkey: string; classNam
);
}
/** Comment context for kind 30008 (profile badges) roots — shows "Commenting on profile badges by @User". */
/** Comment context for kind 10008/30008 (profile badges) roots — shows "Commenting on profile badges by @User". */
function ProfileBadgesCommentContext({ root, className }: { root: CommentRoot; className?: string }) {
const pubkey = root.addr?.pubkey ?? '';
const author = useAuthor(pubkey);
@@ -462,7 +500,7 @@ function ProfileBadgesCommentContext({ root, className }: { root: CommentRoot; c
/** Comment context for non-profile addressable event roots (A tag). */
function GenericAddrCommentContext({ root, className }: { root: CommentRoot; className?: string }) {
const { data: event, isLoading } = useAddrEvent(root.addr);
const { data: event, isLoading } = useAddrEvent(root.addr, root.relayHint ? [root.relayHint] : undefined);
const isCommunity = root.rootKind === '34550' || root.addr?.kind === 34550;
const prefix = isCommunity ? 'Posted in' : 'Commenting on';
@@ -500,18 +538,33 @@ function GenericAddrCommentContext({ root, className }: { root: CommentRoot; cla
/** Comment context for regular event roots (E tag). */
function EventCommentContext({ root, className }: { root: CommentRoot; className?: string }) {
const { data: event, isLoading } = useEvent(root.eventId);
const { data: event, isLoading } = useEvent(
root.eventId,
root.relayHint ? [root.relayHint] : undefined,
root.authorHint,
);
// Kind 7 reactions get special treatment
if (event?.kind === 7) {
return <ReactionCommentContext event={event} className={className} />;
}
// Kind 1018 poll votes get special treatment
if (event?.kind === 1018) {
return <PollVoteCommentContext event={event} className={className} />;
}
const display = event ? getEventDisplayName(event) : { text: getRootKindLabel(root.rootKind) };
const link = event ? getRootLink(event) : undefined;
const hoverContent = root.eventId ? (
<EmbeddedNote eventId={root.eventId} className="border-0 rounded-none" disableHoverCards />
<EmbeddedNote
eventId={root.eventId}
relays={root.relayHint ? [root.relayHint] : undefined}
authorHint={root.authorHint}
className="border-0 rounded-none"
disableHoverCards
/>
) : undefined;
return (
@@ -540,7 +593,7 @@ function ReactionCommentContext({ event, className }: { event: NostrEvent; class
className="text-primary hover:underline shrink-0 cursor-pointer"
onClick={(e) => e.stopPropagation()}
>
<ReactionEmoji content={event.content} tags={event.tags} className="inline-block align-text-bottom" />
<ReactionEmoji content={event.content} tags={event.tags} className="inline-block h-[1.2em] w-[1.2em] align-text-bottom object-contain" />
</Link>
<span className="shrink-0">by</span>
{author.isLoading ? (
@@ -560,6 +613,43 @@ function ReactionCommentContext({ event, className }: { event: NostrEvent; class
);
}
/** Comment context for kind 1018 poll vote roots — shows "Commenting on @{name}'s vote for {option}". */
function PollVoteCommentContext({ event, className }: { event: NostrEvent; className?: string }) {
const author = useAuthor(event.pubkey);
const metadata = author.data?.metadata;
const displayName = getDisplayName(metadata, event.pubkey);
const voteLink = getRootLink(event);
const profileLink = `/${nip19.npubEncode(event.pubkey)}`;
const voteLabel = usePollVoteLabel(event);
return (
<CommentContextRow prefix="Commenting on" className={className}>
{author.isLoading ? (
<Skeleton className="h-3.5 w-16 inline-block" />
) : (
<ProfileHoverCard pubkey={event.pubkey} asChild>
<Link
to={profileLink}
className="text-primary hover:underline truncate cursor-pointer"
onClick={(e) => e.stopPropagation()}
>
@{displayName}
</Link>
</ProfileHoverCard>
)}
<Link
to={voteLink}
className="inline-flex items-center gap-1 text-primary hover:underline truncate cursor-pointer"
onClick={(e) => e.stopPropagation()}
>
<Vote className="size-3.5 shrink-0" />
{voteLabel ? `vote for ${voteLabel}` : 'vote'}
</Link>
</CommentContextRow>
);
}
/** Comment context for external content roots (I tag). */
function ExternalCommentContext({ root, className }: { root: CommentRoot; className?: string }) {
const identifier = root.identifier ?? '';
+2 -2
View File
@@ -8,7 +8,7 @@ import { Link } from 'react-router-dom';
import { X } from 'lucide-react';
import { useNostr } from '@nostrify/react';
import { useQuery } from '@tanstack/react-query';
import { PortalContainerProvider } from '@/contexts/PortalContainerContext';
import { PortalContainerProvider } from '@/hooks/usePortalContainer';
import type { NostrEvent } from '@nostrify/nostrify';
import { useAuthor } from '@/hooks/useAuthor';
import { useProfileUrl } from '@/hooks/useProfileUrl';
@@ -26,7 +26,7 @@ function getTag(tags: string[][], name: string): string | undefined {
// ── data hook ─────────────────────────────────────────────────────────────────
export function useEventComments(event: NostrEvent | undefined) {
function useEventComments(event: NostrEvent | undefined) {
const { nostr } = useNostr();
const aTag = event
+147
View File
@@ -0,0 +1,147 @@
import { useMemo } from 'react';
import { Link } from 'react-router-dom';
import { Bookmark, Crown, Shield, Users } from 'lucide-react';
import { nip19 } from 'nostr-tools';
import type { NostrEvent } from '@nostrify/nostrify';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { useAuthor } from '@/hooks/useAuthor';
import { useProfileUrl } from '@/hooks/useProfileUrl';
import { genUserName } from '@/lib/genUserName';
import { getAvatarShape } from '@/lib/avatarShape';
import { parseCommunityEvent, COMMUNITY_DEFINITION_KIND } from '@/lib/communityUtils';
import { cn } from '@/lib/utils';
interface CommunityCardProps {
/** The kind 34550 community definition event. */
event: NostrEvent;
/** Whether the current user founded this community. */
isFounded?: boolean;
/** Whether the current user is a validated member. */
isMember?: boolean;
/** Whether the current user has bookmarked this community (NIP-51 kind 10004). */
isBookmarked?: boolean;
className?: string;
}
/**
* Compact card for displaying a community in a list.
* Shows image, name, description snippet, founder info, and moderator count.
*/
export function CommunityCard({
event,
isFounded,
isMember,
isBookmarked,
className,
}: CommunityCardProps) {
const community = useMemo(() => parseCommunityEvent(event), [event]);
const founderAuthor = useAuthor(event.pubkey);
const founderMeta = founderAuthor.data?.metadata;
const founderAvatarShape = getAvatarShape(founderMeta);
const founderName = founderMeta?.display_name || founderMeta?.name || genUserName(event.pubkey);
const founderProfileUrl = useProfileUrl(event.pubkey, founderMeta);
if (!community) return null;
const naddr = nip19.naddrEncode({
kind: COMMUNITY_DEFINITION_KIND,
pubkey: event.pubkey,
identifier: community.dTag,
});
return (
<Link
to={`/${naddr}`}
className={cn(
'group block rounded-xl border border-border hover:border-primary/30 transition-all hover:shadow-md overflow-hidden',
className,
)}
>
{/* Image banner */}
{community.image ? (
<div className="relative h-28 overflow-hidden bg-muted">
<img
src={community.image}
alt={community.name}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent" />
</div>
) : (
<div className="relative h-28 bg-gradient-to-br from-primary/15 via-primary/5 to-transparent flex items-center justify-center">
<Users className="size-10 text-primary/20" />
</div>
)}
{/* Content */}
<div className="p-3 space-y-2">
{/* Name + founder badge */}
<div className="flex items-start gap-2">
<h3 className="text-sm font-semibold truncate flex-1 group-hover:text-primary transition-colors">
{community.name}
</h3>
{isFounded ? (
<Badge variant="secondary" className="text-[10px] gap-1 shrink-0 bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20">
<Crown className="size-2.5" />
Founder
</Badge>
) : isMember ? (
<Badge variant="secondary" className="text-[10px] gap-1 shrink-0 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20">
<Shield className="size-2.5" />
Member
</Badge>
) : isBookmarked ? (
<Badge variant="secondary" className="text-[10px] gap-1 shrink-0 bg-primary/10 text-primary border-primary/20">
<Bookmark className="size-2.5 fill-current" />
Bookmarked
</Badge>
) : null}
</div>
{/* Description */}
{community.description && (
<p className="text-xs text-muted-foreground line-clamp-2 leading-relaxed">
{community.description}
</p>
)}
{/* Footer: founder + stats */}
<div className="flex items-center justify-between pt-1">
<Link
to={founderProfileUrl}
onClick={(e) => e.stopPropagation()}
className="flex items-center gap-1.5 min-w-0"
>
<Avatar shape={founderAvatarShape} className="size-5">
<AvatarImage src={founderMeta?.picture} />
<AvatarFallback className="text-[8px] bg-muted">
{founderName.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<span className="text-[11px] text-muted-foreground truncate hover:underline">
{founderName}
</span>
</Link>
<div className="flex items-center gap-2 shrink-0">
{community.moderatorPubkeys.length > 0 && (
<span className="flex items-center gap-1 text-[11px] text-muted-foreground">
<Shield className="size-3" />
{community.moderatorPubkeys.length}
</span>
)}
{community.memberBadgeATag && (
<span className="flex items-center gap-1 text-[11px] text-muted-foreground">
<Users className="size-3" />
Member badge
</span>
)}
</div>
</div>
</div>
</Link>
);
}
+6 -92
View File
@@ -1,20 +1,15 @@
import { useMemo, useCallback } from 'react';
import { useMemo } from 'react';
import { Link } from 'react-router-dom';
import { Users, Share2, Globe } from 'lucide-react';
import { nip19 } from 'nostr-tools';
import type { NostrEvent, NostrMetadata } from '@nostrify/nostrify';
import { Users, Globe } from 'lucide-react';
import type { NostrEvent } from '@nostrify/nostrify';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { getAvatarShape } from '@/lib/avatarShape';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { useAuthor } from '@/hooks/useAuthor';
import { useAuthors } from '@/hooks/useAuthors';
import { useProfileUrl } from '@/hooks/useProfileUrl';
import { useToast } from '@/hooks/useToast';
import { genUserName } from '@/lib/genUserName';
import { cn } from '@/lib/utils';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
// --- Helpers ---
@@ -42,46 +37,14 @@ function parseCommunityEvent(event: NostrEvent) {
return { name, description, image, moderators, relays };
}
// --- Sub-components ---
function ModeratorRow({ pubkey }: { pubkey: string }) {
const { data } = useAuthor(pubkey);
const metadata: NostrMetadata | undefined = data?.metadata;
const avatarShape = getAvatarShape(metadata);
const name = metadata?.display_name || metadata?.name || genUserName(pubkey);
const profileUrl = useProfileUrl(pubkey, metadata);
return (
<Link to={profileUrl} className="flex items-center gap-3 group py-1.5">
<Avatar shape={avatarShape} className="size-9 ring-2 ring-background">
<AvatarImage src={metadata?.picture} />
<AvatarFallback className="bg-muted text-muted-foreground text-xs">
{name.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate group-hover:underline">{name}</p>
{metadata?.nip05 && (
<p className="text-xs text-muted-foreground truncate">{metadata.nip05}</p>
)}
</div>
<Badge variant="secondary" className="text-xs shrink-0">Moderator</Badge>
</Link>
);
}
// --- Main Component ---
export function CommunityContent({ event }: { event: NostrEvent }) {
const { toast } = useToast();
const { name, description, image, moderators, relays } = useMemo(
const { name, description, image } = useMemo(
() => parseCommunityEvent(event),
[event],
);
// Batch-fetch moderator profiles
useAuthors(moderators);
// Owner
const ownerAuthor = useAuthor(event.pubkey);
const ownerMetadata = ownerAuthor.data?.metadata;
@@ -92,7 +55,7 @@ export function CommunityContent({ event }: { event: NostrEvent }) {
// Extract website URL from description if present
const descriptionUrl = useMemo(() => {
const urlMatch = description.match(/https?:\/\/[^\s]+/);
return urlMatch?.[0];
return sanitizeUrl(urlMatch?.[0]);
}, [description]);
// Description text without trailing URL (if the URL is the last thing)
@@ -101,22 +64,6 @@ export function CommunityContent({ event }: { event: NostrEvent }) {
return description.replace(new RegExp(`\\s*${descriptionUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*$`), '').trim();
}, [description, descriptionUrl]);
const handleShare = useCallback(async () => {
const d = getTag(event.tags, 'd') ?? '';
const naddr = nip19.naddrEncode({
kind: event.kind,
pubkey: event.pubkey,
identifier: d,
});
const url = `${window.location.origin}/${naddr}`;
try {
await navigator.clipboard.writeText(url);
toast({ title: 'Link copied to clipboard' });
} catch {
toast({ title: 'Failed to copy link', variant: 'destructive' });
}
}, [event, toast]);
return (
<div className="mt-3 space-y-5">
{/* Community hero image */}
@@ -143,23 +90,6 @@ export function CommunityContent({ event }: { event: NostrEvent }) {
</div>
)}
{/* Quick stats + share */}
<div className="flex items-center gap-3">
<Badge variant="outline" className="gap-1.5">
<Users className="size-3.5" />
{moderators.length} moderator{moderators.length !== 1 ? 's' : ''}
</Badge>
{relays.length > 0 && (
<Badge variant="outline" className="gap-1.5">
<Globe className="size-3.5" />
{relays.length} relay{relays.length !== 1 ? 's' : ''}
</Badge>
)}
<Button variant="outline" size="icon" className="ml-auto size-8 shrink-0" onClick={handleShare}>
<Share2 className="size-3.5" />
</Button>
</div>
{/* Description */}
{descriptionText && (
<p className="text-sm leading-relaxed text-muted-foreground whitespace-pre-wrap">{descriptionText}</p>
@@ -197,22 +127,6 @@ export function CommunityContent({ event }: { event: NostrEvent }) {
</Link>
</div>
<Separator />
{/* Moderators */}
{moderators.length > 0 && (
<section>
<h2 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-3 flex items-center gap-2">
<Users className="size-3.5" />
Moderators
</h2>
<div className="space-y-1">
{moderators.map((pk) => (
<ModeratorRow key={pk} pubkey={pk} />
))}
</div>
</section>
)}
</div>
);
}
@@ -0,0 +1,88 @@
import { useState } from 'react';
import { AlertTriangle, Eye } from 'lucide-react';
import type { NostrEvent } from '@nostrify/nostrify';
import { Button } from '@/components/ui/button';
import { useCommunityModerationContext } from '@/contexts/CommunityModerationContext';
import { cn } from '@/lib/utils';
import { getApplicableReports, type Nip56ReportType } from '@/lib/communityUtils';
/** Lowercase prose labels for content warning summaries. */
const REPORT_TYPE_LABELS: Record<Nip56ReportType, string> = {
nudity: 'nudity',
spam: 'spam',
profanity: 'hateful speech',
illegal: 'illegal content',
malware: 'malware',
impersonation: 'impersonation',
other: 'community guidelines',
};
interface CommunityContentWarningProps {
/** The event being rendered. */
event: NostrEvent;
/** The content to guard behind the warning when the event has reports. */
children: React.ReactNode;
/** Optional class name for the wrapper. */
className?: string;
}
/**
* Guards content behind a community report warning overlay when the event has
* been reported by community members. Subscribes to `CommunityModerationContext`
* internally so that parents outside community surfaces don't need to know or
* care about the community system — rendering this wrapper is a no-op when
* there's no community context in the tree.
*
* Only reports whose `p` tag matches the event's actual author are considered
* — this mirrors the id+pubkey match requirement in the NIP and prevents a
* report from hijacking the warning overlay onto an unrelated event.
*
* Children are **not mounted** until the user explicitly reveals, so media and
* nested queries are deferred for reported content.
*/
export function CommunityContentWarning({ event, children, className }: CommunityContentWarningProps) {
const modCtx = useCommunityModerationContext();
const reports = modCtx ? getApplicableReports(event, modCtx.moderation) : [];
const [revealed, setRevealed] = useState(false);
// No community context or no applicable reports → render children transparently.
if (reports.length === 0 || revealed) {
return <>{children}</>;
}
// Summarize unique report types
const uniqueTypes = [...new Set(reports.map((r) => r.reportType))];
const typeLabels = uniqueTypes
.map((t) => REPORT_TYPE_LABELS[t] ?? t)
.join(', ');
const reporterCount = new Set(reports.map((r) => r.reporterPubkey)).size;
return (
<div className={cn('border-b border-border', className)}>
<div className="px-4 py-6">
<div className="max-w-sm mx-auto flex flex-col items-center text-center gap-2.5">
<div className="flex items-center justify-center size-9 rounded-full bg-amber-500/10">
<AlertTriangle className="size-4.5 text-amber-500" />
</div>
<div className="space-y-1">
<p className="text-sm font-medium text-foreground">Reported Content</p>
<p className="text-xs text-muted-foreground leading-relaxed">
{reporterCount === 1
? `Reported by a community member for ${typeLabels}.`
: `Reported by ${reporterCount} community members for ${typeLabels}.`}
</p>
</div>
<Button
variant="outline"
size="sm"
className="gap-1.5 mt-0.5 rounded-full px-5"
onClick={() => setRevealed(true)}
>
<Eye className="size-3.5" />
Show Anyway
</Button>
</div>
</div>
</div>
);
}
+822
View File
@@ -0,0 +1,822 @@
import { useMemo, useCallback, useEffect, useState } from 'react';
import { useInView } from 'react-intersection-observer';
import { Link, useNavigate } from 'react-router-dom';
import { nip19 } from 'nostr-tools';
import {
ArrowLeft,
Bookmark,
CalendarDays,
Crown,
Loader2,
MessageCircle,
Pencil,
Rss,
Shield,
ShieldBan,
Share2,
Target,
UserPlus,
Users,
} from 'lucide-react';
import type { NostrEvent, NostrMetadata } from '@nostrify/nostrify';
import { AddMemberDialog } from '@/components/AddMemberDialog';
import { CreateCommunityDialog } from '@/components/CreateCommunityDialog';
import { CreateCommunityEventDialog } from '@/components/CreateCommunityEventDialog';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { getAvatarShape } from '@/lib/avatarShape';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { BanConfirmDialog } from '@/components/BanConfirmDialog';
import { ComposeBox } from '@/components/ComposeBox';
import { FeedEmptyState } from '@/components/FeedEmptyState';
import { CreateGoalDialog } from '@/components/CreateGoalDialog';
import { MembersOnlyToggle } from '@/components/MembersOnlyToggle';
import { NoteCard } from '@/components/NoteCard';
import { PullToRefresh } from '@/components/PullToRefresh';
import { ReplyComposeModal } from '@/components/ReplyComposeModal';
import { ThreadedReplyList, type ReplyNode } from '@/components/ThreadedReplyList';
import { useAuthor } from '@/hooks/useAuthor';
import { useAuthors } from '@/hooks/useAuthors';
import { useComments } from '@/hooks/useComments';
import { useCommunityBookmarks } from '@/hooks/useCommunityBookmarks';
import { useCommunityEvents } from '@/hooks/useCommunityEvents';
import { useCommunityMembers } from '@/hooks/useCommunityMembers';
import { useCommunityGoals } from '@/hooks/useCommunityGoals';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useFeed } from '@/hooks/useFeed';
import { useMembersOnlyFilter } from '@/hooks/useMembersOnlyFilter';
import { useMuteList } from '@/hooks/useMuteList';
import { useNow } from '@/hooks/useNow';
import { usePageRefresh } from '@/hooks/usePageRefresh';
import { useProfileUrl } from '@/hooks/useProfileUrl';
import { useToast } from '@/hooks/useToast';
import { CommunityModerationContext } from '@/contexts/CommunityModerationContext';
import { useLayoutOptions } from '@/contexts/LayoutContext';
import { applyCommunityModerationToEvents, canBanTarget, getViewerAuthority, parseCommunityEvent, type CommunityMember } from '@/lib/communityUtils';
import { isEventMuted } from '@/lib/muteHelpers';
import { shouldHideFeedEvent, type FeedItem } from '@/lib/feedUtils';
import { genUserName } from '@/lib/genUserName';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import { cn } from '@/lib/utils';
// ── Sub-components ────────────────────────────────────────────────────────────
function PersonRow({ pubkey, label, size = 'md', onBan }: { pubkey: string; label?: string; size?: 'sm' | 'md'; onBan?: () => void }) {
const { data } = useAuthor(pubkey);
const metadata: NostrMetadata | undefined = data?.metadata;
const avatarShape = getAvatarShape(metadata);
const name = metadata?.display_name || metadata?.name || genUserName(pubkey);
const profileUrl = useProfileUrl(pubkey, metadata);
const avatarCls = size === 'sm' ? 'size-8' : 'size-10';
const fallbackCls = size === 'sm' ? 'text-xs' : '';
return (
<div className="flex items-center gap-3 py-1">
<Link to={profileUrl} className="flex items-center gap-3 group flex-1 min-w-0">
<Avatar shape={avatarShape} className={cn(avatarCls, 'ring-2 ring-background')}>
<AvatarImage src={metadata?.picture} />
<AvatarFallback className={cn('bg-muted text-muted-foreground', fallbackCls)}>
{name.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className={cn('font-medium truncate group-hover:underline', size === 'sm' ? 'text-sm' : 'text-[15px]')}>{name}</p>
{metadata?.nip05 && (
<p className="text-xs text-muted-foreground truncate">{metadata.nip05}</p>
)}
</div>
{label && (
<Badge variant="secondary" className="ml-auto capitalize text-xs shrink-0">{label}</Badge>
)}
</Link>
{onBan && (
<button
onClick={(e) => { e.stopPropagation(); onBan(); }}
className="p-1.5 rounded-full text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors shrink-0"
aria-label="Ban from community"
title="Ban from community"
>
<ShieldBan className="size-4" />
</button>
)}
</div>
);
}
function MembersSkeleton() {
return (
<div className="space-y-4 px-5 py-4">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center gap-3">
<Skeleton className="size-10 rounded-full" />
<div className="space-y-1.5 flex-1">
<Skeleton className="h-4 w-28" />
<Skeleton className="h-3 w-20" />
</div>
<Skeleton className="h-5 w-16 rounded-full" />
</div>
))}
</div>
);
}
function ReplyCardSkeleton() {
return (
<div className="px-4 py-3 border-b border-border">
<div className="flex gap-3">
<Skeleton className="size-10 rounded-full shrink-0" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-2/3" />
</div>
</div>
</div>
);
}
function CommunityMemberFeed({ authorPubkeys, isMembershipLoading }: { authorPubkeys: string[]; isMembershipLoading: boolean }) {
const { muteItems } = useMuteList();
const { ref: scrollRef, inView } = useInView({ threshold: 0, rootMargin: '400px' });
const queryKey = useMemo(() => ['feed', 'follows'], []);
const handleRefresh = usePageRefresh(queryKey);
const uniqueAuthors = useMemo(() => Array.from(new Set(authorPubkeys)), [authorPubkeys]);
const {
data: rawData,
isPending,
isLoading,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useFeed('follows', { authors: uniqueAuthors });
useEffect(() => {
if (inView && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]);
const feedItems = useMemo(() => {
if (!rawData?.pages) return [];
const seen = new Set<string>();
return (rawData.pages as { items: FeedItem[] }[])
.flatMap((page) => page.items)
.filter((item) => {
const key = item.repostedBy ? `repost-${item.repostedBy}-${item.event.id}` : item.event.id;
if (!key || seen.has(key)) return false;
seen.add(key);
if (shouldHideFeedEvent(item.event)) return false;
if (muteItems.length > 0 && isEventMuted(item.event, muteItems)) return false;
return true;
});
}, [rawData?.pages, muteItems]);
if (!isMembershipLoading && uniqueAuthors.length === 0) {
return (
<div className="py-12 text-center text-muted-foreground text-sm px-5">
No active members found.
</div>
);
}
if (isMembershipLoading || isPending || (isLoading && !rawData)) {
return (
<div className="divide-y divide-border">
{Array.from({ length: 5 }).map((_, i) => (
<ReplyCardSkeleton key={i} />
))}
</div>
);
}
if (feedItems.length === 0) {
return (
<PullToRefresh onRefresh={handleRefresh}>
<FeedEmptyState message="No posts from active community members yet." />
</PullToRefresh>
);
}
return (
<PullToRefresh onRefresh={handleRefresh}>
<div>
{feedItems.map((item) => (
<NoteCard
key={item.repostedBy ? `repost-${item.repostedBy}-${item.event.id}` : item.event.id}
event={item.event}
repostedBy={item.repostedBy}
/>
))}
{hasNextPage && (
<div ref={scrollRef} className="py-4">
{isFetchingNextPage && (
<div className="flex justify-center">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
)}
</div>
)}
</div>
</PullToRefresh>
);
}
function getTag(tags: string[][], name: string): string | undefined {
return tags.find(([n]) => n === name)?.[1];
}
function getCalendarEventStart(event: NostrEvent): number {
const start = getTag(event.tags, 'start');
if (!start) return 0;
if (event.kind === 31922) {
const date = new Date(`${start}T00:00:00Z`);
return isNaN(date.getTime()) ? 0 : Math.floor(date.getTime() / 1000);
}
const timestamp = parseInt(start, 10);
return isNaN(timestamp) ? 0 : timestamp;
}
function getCalendarEventEnd(event: NostrEvent): number {
const start = getTag(event.tags, 'start');
if (!start) return 0;
if (event.kind === 31922) {
const end = getTag(event.tags, 'end');
const endDate = new Date(`${end || start}T00:00:00Z`);
if (isNaN(endDate.getTime())) return 0;
return Math.floor(endDate.getTime() / 1000) + (end ? 0 : 86400);
}
const end = getTag(event.tags, 'end') || start;
const endTs = parseInt(end, 10);
return isNaN(endTs) ? 0 : endTs;
}
// ── Main component ────────────────────────────────────────────────────────────
export function CommunityDetailPage({ event }: { event: NostrEvent }) {
const navigate = useNavigate();
const { toast } = useToast();
const { user } = useCurrentUser();
// ── Member ban dialog state ────────────────────────────────────────────────
const [banDialogOpen, setBanDialogOpen] = useState(false);
const [banTargetPubkey, setBanTargetPubkey] = useState<string | null>(null);
// ── Tab + FAB state ────────────────────────────────────────────────────────
const [activeTab, setActiveTab] = useState('members');
const [composeOpen, setComposeOpen] = useState(false);
const [goalDialogOpen, setGoalDialogOpen] = useState(false);
const [eventDialogOpen, setEventDialogOpen] = useState(false);
const [addMemberOpen, setAddMemberOpen] = useState(false);
const [editCommunityOpen, setEditCommunityOpen] = useState(false);
// Parse community definition
const community = useMemo(() => parseCommunityEvent(event), [event]);
const name = community?.name ?? 'Unnamed Community';
const description = community?.description ?? '';
const image = community?.image;
const communityATag = community?.aTag ?? '';
// Extract website URL from description
const descriptionUrl = useMemo(() => {
const urlMatch = description.match(/https?:\/\/[^\s]+/);
return sanitizeUrl(urlMatch?.[0]);
}, [description]);
const descriptionText = useMemo(() => {
if (!descriptionUrl) return description;
return description.replace(new RegExp(`\\s*${descriptionUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*$`), '').trim();
}, [description, descriptionUrl]);
// ── Members ─────────────────────────────────────────────────────────────────
const { data: membership, moderation, rankMap, isLoading: membersLoading } = useCommunityMembers(community);
const viewerMember = user ? getViewerAuthority(user.pubkey, rankMap, moderation) : undefined;
// Founder can add moderators + members; moderators (rank 0) can add members
const isFounder = !!user && user.pubkey === event.pubkey;
const canAddMembers = !!viewerMember && viewerMember.rank === 0;
// NIP-51 kind 10004 community bookmark toggle. Toasts are fired from inside
// the mutation so they survive even if this page unmounts mid-publish.
const {
isBookmarked: isCommunityBookmarked,
toggleBookmark: toggleCommunityBookmark,
} = useCommunityBookmarks();
const bookmarked = !!communityATag && isCommunityBookmarked(communityATag);
const handleToggleBookmark = useCallback(() => {
if (!user || !communityATag || toggleCommunityBookmark.isPending) return;
toggleCommunityBookmark.mutate({ aTag: communityATag });
}, [user, communityATag, toggleCommunityBookmark]);
// Batch-fetch profiles for all members
const allMemberPubkeys = useMemo(
() => membership?.members.map((m) => m.pubkey) ?? [],
[membership],
);
useAuthors(allMemberPubkeys);
const memberSections = useMemo(() => {
if (!membership) return [];
const leadership: CommunityMember[] = [];
const members: CommunityMember[] = [];
for (const member of membership.members) {
if (member.rank === 0) leadership.push(member);
else members.push(member);
}
return [
{ key: 'leadership', label: 'Leadership', members: leadership },
{ key: 'members', label: 'Members', members },
].filter((section) => section.members.length > 0);
}, [membership]);
// ── Comments (NIP-22 on the community event) ───────────────────────────────
const { data: commentsData, isLoading: commentsLoading } = useComments(event, 500);
const { membersOnly } = useMembersOnlyFilter();
// ── Fundraising goals (NIP-75) ──────────────────────────────────────────────
const { data: goals, isLoading: goalsLoading } = useCommunityGoals(communityATag || undefined);
const { data: communityEvents, isLoading: eventsLoading } = useCommunityEvents(communityATag || undefined);
const now = useNow(60_000);
/** Check if a goal event's `closed_at` deadline has passed. */
const isExpired = useCallback((e: NostrEvent): boolean => {
const v = e.tags.find(([n]) => n === 'closed_at')?.[1];
if (!v) return false;
const ts = parseInt(v, 10);
return !isNaN(ts) && now > ts;
}, [now]);
const moderatedGoals = useMemo(
() => applyCommunityModerationToEvents(goals ?? [], moderation),
[goals, moderation],
);
const activeGoals = useMemo(() => {
const all = moderatedGoals.filter((e) => !isExpired(e));
if (!membersOnly) return all;
return all.filter((e) => rankMap.has(e.pubkey));
}, [moderatedGoals, membersOnly, rankMap, isExpired]);
const pastGoals = useMemo(() => {
const all = moderatedGoals.filter((e) => isExpired(e));
const filtered = membersOnly ? all.filter((e) => rankMap.has(e.pubkey)) : all;
// Sort by deadline descending so the most recently ended goal appears first.
return filtered.sort((a, b) => {
const aClose = parseInt(a.tags.find(([n]) => n === 'closed_at')?.[1] ?? '0', 10);
const bClose = parseInt(b.tags.find(([n]) => n === 'closed_at')?.[1] ?? '0', 10);
return bClose - aClose;
});
}, [moderatedGoals, membersOnly, rankMap, isExpired]);
const eventItems = useMemo(() => {
const moderated = applyCommunityModerationToEvents(communityEvents ?? [], moderation);
const visible = membersOnly ? moderated.filter((e) => rankMap.has(e.pubkey)) : moderated;
return [...visible].sort((a, b) => {
const aStart = getCalendarEventStart(a);
const bStart = getCalendarEventStart(b);
const aFuture = aStart >= now;
const bFuture = bStart >= now;
if (aFuture && !bFuture) return -1;
if (!aFuture && bFuture) return 1;
if (aFuture && bFuture) return aStart - bStart;
return bStart - aStart;
});
}, [communityEvents, moderation, membersOnly, rankMap, now]);
const activeEventItems = useMemo(
() => eventItems.filter((e) => getCalendarEventEnd(e) >= now),
[eventItems, now],
);
const pastEventItems = useMemo(
() => eventItems.filter((e) => getCalendarEventEnd(e) < now),
[eventItems, now],
);
const replyTree = useMemo((): ReplyNode[] => {
if (!commentsData) return [];
const topLevel = commentsData.topLevelComments ?? [];
// Filter: omit banned events and posts by banned members, then optionally
// restrict to validated members when the "members only" toggle is
// active. The member filter is a presentation-layer opt-in — the NIP
// lists it as a MAY feature, so users default to seeing everything.
const applyModeration = (events: NostrEvent[]): NostrEvent[] => {
const moderated = applyCommunityModerationToEvents(events, moderation);
if (!membersOnly) return moderated;
return moderated.filter((ev) => rankMap.has(ev.pubkey));
};
const buildNode = (ev: NostrEvent): ReplyNode => {
const allChildren = applyModeration(commentsData.getDirectReplies(ev.id) ?? []);
if (allChildren.length <= 1) {
return {
event: ev,
children: allChildren.map((c) => buildNode(c)),
};
}
const [first, ...rest] = allChildren;
return {
event: ev,
children: [buildNode(first)],
hiddenChildren: rest.map((c) => buildNode(c)),
};
};
return applyModeration([...topLevel])
.sort((a, b) => a.created_at - b.created_at)
.map((r) => buildNode(r));
}, [commentsData, moderation, membersOnly, rankMap]);
// ── Share handler ───────────────────────────────────────────────────────────
const handleShare = useCallback(async () => {
const d = event.tags.find(([n]) => n === 'd')?.[1] ?? '';
const naddr = nip19.naddrEncode({
kind: event.kind,
pubkey: event.pubkey,
identifier: d,
});
const url = `${window.location.origin}/${naddr}`;
try {
await navigator.clipboard.writeText(url);
toast({ title: 'Link copied to clipboard' });
} catch {
toast({ title: 'Failed to copy link', variant: 'destructive' });
}
}, [event, toast]);
// ── FAB — visible on comments, fundraising, and members tabs ──────────────
const handleFabClick = useCallback(() => {
if (activeTab === 'comments') {
setComposeOpen(true);
} else if (activeTab === 'fundraising') {
setGoalDialogOpen(true);
} else if (activeTab === 'events') {
setEventDialogOpen(true);
} else if (activeTab === 'members') {
setAddMemberOpen(true);
}
}, [activeTab]);
const fabIcon = activeTab === 'fundraising'
? <Target strokeWidth={3} size={18} />
: activeTab === 'members'
? <UserPlus className="size-5" />
: activeTab === 'events'
? <CalendarDays className="size-5" />
: undefined; // default Plus icon for comments
useLayoutOptions({
showFAB:
activeTab === 'comments'
|| activeTab === 'fundraising'
|| activeTab === 'events'
|| (activeTab === 'members' && canAddMembers),
onFabClick: handleFabClick,
fabIcon,
});
const moderationCtx = useMemo(
() => communityATag ? { communityATag, moderation, rankMap } : null,
[communityATag, moderation, rankMap],
);
// ── Render ──────────────────────────────────────────────────────────────────
return (
<div className="max-w-2xl mx-auto pb-16">
{/* ── Top bar ── */}
<div className="flex items-center gap-4 px-4 pt-4 pb-3">
<button
onClick={() => window.history.length > 1 ? navigate(-1) : navigate('/')}
className="p-1.5 -ml-1.5 rounded-full hover:bg-secondary/60 transition-colors"
aria-label="Go back"
>
<ArrowLeft className="size-5" />
</button>
<h1 className="text-xl font-bold flex-1 truncate">Community</h1>
{isFounder && community && (
<button
className="p-2 rounded-full hover:bg-secondary/60 transition-colors"
onClick={() => setEditCommunityOpen(true)}
aria-label="Edit community"
>
<Pencil className="size-5" />
</button>
)}
{user && communityATag && (
<button
className="p-2 rounded-full hover:bg-secondary/60 transition-colors disabled:opacity-50 disabled:pointer-events-none"
onClick={handleToggleBookmark}
disabled={toggleCommunityBookmark.isPending}
aria-label={bookmarked ? 'Remove community bookmark' : 'Bookmark community'}
aria-pressed={bookmarked}
aria-busy={toggleCommunityBookmark.isPending}
>
<Bookmark className={cn('size-5', bookmarked && 'fill-current')} />
</button>
)}
<button
className="p-2 rounded-full hover:bg-secondary/60 transition-colors"
onClick={handleShare}
aria-label="Share"
>
<Share2 className="size-5" />
</button>
</div>
{/* ── Hero image ── */}
{image ? (
<div className="relative aspect-[21/9] w-full overflow-hidden">
<img src={image} alt={name} className="w-full h-full object-cover" />
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent" />
<div className="absolute bottom-0 left-0 right-0 px-5 pb-4">
<h2 className="text-2xl font-bold text-white leading-tight drop-shadow-lg">{name}</h2>
</div>
</div>
) : (
<div className="relative aspect-[21/9] w-full bg-gradient-to-br from-primary/15 via-primary/5 to-transparent flex items-center justify-center">
<Users className="size-16 text-primary/20" />
<div className="absolute bottom-0 left-0 right-0 px-5 pb-4">
<h2 className="text-2xl font-bold leading-tight">{name}</h2>
</div>
</div>
)}
{/* ── Community info ── */}
<div className="px-5 mt-4 space-y-4">
{/* Description */}
{descriptionText && (
<p className="text-sm leading-relaxed text-muted-foreground whitespace-pre-wrap">{descriptionText}</p>
)}
{/* Founder + community-wide filter toggle. The toggle sits
right-justified on the same row as the "Founded by" label so
it clearly scopes the whole community (every content feed
below the tabs), not any one tab. */}
<div>
<div className="flex items-start justify-between gap-2 mb-1">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Founded by</p>
<MembersOnlyToggle className="-my-2 -mr-2" />
</div>
<PersonRow pubkey={event.pubkey} />
</div>
{/* ── Tabs ── */}
<CommunityModerationContext.Provider value={moderationCtx}>
<Tabs value={activeTab} onValueChange={setActiveTab} className="-mx-5">
<TabsList className="w-full justify-start overflow-x-auto scrollbar-none rounded-none border-b border-border bg-transparent p-0 h-auto">
<TabsTrigger
value="members"
className="flex-none min-w-fit rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none px-4 pb-3 pt-2"
>
<Users className="size-4 mr-1.5" />
Members
</TabsTrigger>
<TabsTrigger
value="feed"
className="flex-none min-w-fit rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none px-4 pb-3 pt-2"
>
<Rss className="size-4 mr-1.5" />
Feed
</TabsTrigger>
<TabsTrigger
value="comments"
className="flex-none min-w-fit rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none px-4 pb-3 pt-2"
>
<MessageCircle className="size-4 mr-1.5" />
Comments
</TabsTrigger>
<TabsTrigger
value="fundraising"
className="flex-none min-w-fit rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none px-4 pb-3 pt-2"
>
<Target className="size-4 mr-1.5" />
Fundraising
</TabsTrigger>
<TabsTrigger
value="events"
className="flex-none min-w-fit rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none px-4 pb-3 pt-2"
>
<CalendarDays className="size-4 mr-1.5" />
Events
</TabsTrigger>
</TabsList>
{/* ── Members tab ── */}
<TabsContent value="members" className="mt-0">
{membersLoading ? (
<MembersSkeleton />
) : memberSections.length === 0 ? (
<div className="py-12 text-center text-muted-foreground text-sm px-5">
No members found.
</div>
) : (
<div className="divide-y divide-border">
{memberSections.map(({ key, label, members }) => (
<section key={key} className="px-5 py-4">
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-3 flex items-center gap-2">
{key === 'leadership' ? <Crown className="size-3.5 text-amber-500" /> : <Shield className="size-3.5" />}
{label}
<span className="text-muted-foreground/60 font-normal">({members.length})</span>
</h3>
<div className="space-y-0.5">
{members.map((m) => {
let roleLabel: string | undefined;
if (m.rank === 0) {
roleLabel = m.pubkey === event.pubkey ? 'Founder' : 'Moderator';
}
// Determine if the current user can ban this member
const canBanMember = viewerMember
&& m.pubkey !== user?.pubkey
&& canBanTarget(viewerMember, m);
return (
<PersonRow
key={m.pubkey}
pubkey={m.pubkey}
label={roleLabel}
size="sm"
onBan={canBanMember ? () => {
setBanTargetPubkey(m.pubkey);
setBanDialogOpen(true);
} : undefined}
/>
);
})}
</div>
</section>
))}
</div>
)}
</TabsContent>
{/* ── Feed tab ── */}
<TabsContent value="feed" className="mt-0">
<CommunityMemberFeed
authorPubkeys={allMemberPubkeys}
isMembershipLoading={membersLoading}
/>
</TabsContent>
{/* ── Comments tab ── */}
<TabsContent value="comments" className="mt-0">
<ComposeBox compact replyTo={event} />
{commentsLoading ? (
<div className="divide-y divide-border">
{Array.from({ length: 3 }).map((_, i) => (
<ReplyCardSkeleton key={i} />
))}
</div>
) : replyTree.length > 0 ? (
<ThreadedReplyList roots={replyTree} />
) : membersOnly && commentsData && (commentsData.topLevelComments?.length ?? 0) > 0 ? (
<div className="py-12 text-center text-muted-foreground text-sm px-5">
No comments from community members yet. Toggle the shield icon to see all comments.
</div>
) : (
<div className="py-12 text-center text-muted-foreground text-sm">
No comments yet. Be the first to comment!
</div>
)}
</TabsContent>
{/* ── Fundraising tab ── */}
<TabsContent value="fundraising" className="mt-0">
{goalsLoading ? (
<div className="divide-y divide-border">
{Array.from({ length: 2 }).map((_, i) => (
<ReplyCardSkeleton key={i} />
))}
</div>
) : activeGoals.length === 0 && pastGoals.length === 0 ? (
<div className="py-12 text-center text-muted-foreground text-sm px-5">
{membersOnly && (goals ?? []).length > 0
? 'No fundraising goals from community members yet. Toggle the shield icon to see all goals.'
: <>No fundraising goals yet.{user ? ' Create one to get started!' : ''}</>}
</div>
) : (
<div className="divide-y divide-border">
{/* Active goals first */}
{activeGoals.map((e) => (
<NoteCard key={e.id} event={e} />
))}
{/* Past/expired goals */}
{pastGoals.length > 0 && activeGoals.length > 0 && (
<div className="px-5 pt-4 pb-1">
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
Past Goals
</h4>
</div>
)}
{pastGoals.map((e) => (
<NoteCard key={e.id} event={e} />
))}
</div>
)}
</TabsContent>
{/* ── Events tab ── */}
<TabsContent value="events" className="mt-0">
{eventsLoading ? (
<div className="divide-y divide-border">
{Array.from({ length: 3 }).map((_, i) => (
<ReplyCardSkeleton key={i} />
))}
</div>
) : activeEventItems.length === 0 && pastEventItems.length === 0 ? (
<div className="py-12 text-center text-muted-foreground text-sm px-5">
{membersOnly && (communityEvents ?? []).length > 0
? 'No events from community members yet. Toggle the shield icon to see all events.'
: <>No events yet.{user ? ' Create one to get started!' : ''}</>}
</div>
) : (
<div className="divide-y divide-border">
{activeEventItems.map((e) => (
<NoteCard key={e.id} event={e} />
))}
{pastEventItems.length > 0 && activeEventItems.length > 0 && (
<div className="px-5 pt-4 pb-1">
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
Past Events
</h4>
</div>
)}
{pastEventItems.map((e) => (
<NoteCard key={e.id} event={e} />
))}
</div>
)}
</TabsContent>
</Tabs>
</CommunityModerationContext.Provider>
</div>
{/* Member ban confirmation dialog */}
{banTargetPubkey && communityATag && (
<BanConfirmDialog
mode="member"
targetPubkey={banTargetPubkey}
communityATag={communityATag}
open={banDialogOpen}
onOpenChange={(open) => {
setBanDialogOpen(open);
if (!open) setBanTargetPubkey(null);
}}
/>
)}
{/* FAB-triggered compose modal for the comments tab */}
<ReplyComposeModal
event={event}
open={composeOpen}
onOpenChange={setComposeOpen}
/>
{/* FAB-triggered goal creation dialog for the fundraising tab */}
{communityATag && (
<CreateGoalDialog
communityATag={communityATag}
open={goalDialogOpen}
onOpenChange={setGoalDialogOpen}
/>
)}
{/* FAB-triggered event creation dialog for the events tab */}
{communityATag && (
<CreateCommunityEventDialog
communityATag={communityATag}
open={eventDialogOpen}
onOpenChange={setEventDialogOpen}
/>
)}
{/* Add member dialog */}
{canAddMembers && community && (
<AddMemberDialog
open={addMemberOpen}
onOpenChange={setAddMemberOpen}
communityEvent={event}
community={community}
isFounder={isFounder}
existingMemberPubkeys={allMemberPubkeys}
/>
)}
{/* Edit community dialog — founder only */}
{isFounder && community && (
<CreateCommunityDialog
open={editCommunityOpen}
onOpenChange={setEditCommunityOpen}
communityEvent={event}
community={community}
/>
)}
</div>
);
}
+157
View File
@@ -0,0 +1,157 @@
import { useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import {
Dialog,
DialogContent,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { toast } from '@/hooks/useToast';
import type { NostrEvent } from '@nostrify/nostrify';
import {
type Nip56ReportType,
NIP56_REPORT_TYPES,
NIP56_REPORT_TYPE_META,
REPORT_KIND,
} from '@/lib/communityUtils';
// ── Props ─────────────────────────────────────────────────────────────────────
interface CommunityReportDialogProps {
/** The event being reported. */
event: NostrEvent;
/** The community `A` tag coordinate (e.g. `34550:<pubkey>:<d-tag>`). */
communityATag: string;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function CommunityReportDialog({
event,
communityATag,
open,
onOpenChange,
}: CommunityReportDialogProps) {
const { user } = useCurrentUser();
const queryClient = useQueryClient();
const { mutateAsync: publishEvent, isPending } = useNostrPublish();
const [reportType, setReportType] = useState<Nip56ReportType | ''>('');
const [details, setDetails] = useState('');
const canSubmit = reportType !== '' && !isPending;
const handleSubmit = async () => {
if (!reportType || !user) return;
try {
await publishEvent({
kind: REPORT_KIND,
content: details.trim(),
tags: [
['e', event.id, reportType],
['p', event.pubkey, reportType],
['A', communityATag],
],
});
// Invalidate community queries so the content warning overlay appears
// immediately and the activity feed filters out reported content.
// The activity feed's key is `['community-activity-feed', <aTagsKey>]`
// where aTagsKey is a comma-joined list of the viewer's subscribed A
// tags. We match any feed whose aTagsKey contains this communityATag.
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['community-members', communityATag] }),
queryClient.invalidateQueries({
predicate: (q) => {
const [root, aTagsKey] = q.queryKey;
return root === 'community-activity-feed'
&& typeof aTagsKey === 'string'
&& aTagsKey.split(',').includes(communityATag);
},
}),
]);
toast({ title: 'Report submitted' });
setReportType('');
setDetails('');
onOpenChange(false);
} catch {
toast({ title: 'Failed to submit report', variant: 'destructive' });
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md max-h-[85dvh] rounded-2xl flex flex-col overflow-hidden">
<DialogTitle>Report post</DialogTitle>
<DialogDescription className="text-sm text-muted-foreground">
Select a reason for reporting this post to the community.
</DialogDescription>
<div className="flex-1 overflow-y-auto min-h-0 -mx-6 px-6">
<RadioGroup
value={reportType}
onValueChange={(v) => setReportType(v as Nip56ReportType)}
className="mt-2 space-y-1"
>
{NIP56_REPORT_TYPES.map((type) => {
const meta = NIP56_REPORT_TYPE_META[type];
return (
<label
key={type}
className="flex items-start gap-3 rounded-lg px-3 py-2.5 cursor-pointer transition-colors hover:bg-secondary/60"
>
<RadioGroupItem value={type} className="mt-0.5 shrink-0" />
<div className="min-w-0">
<span className="text-sm font-medium">{meta.label}</span>
<p className="text-xs text-muted-foreground mt-0.5">{meta.description}</p>
</div>
</label>
);
})}
</RadioGroup>
<div className="mt-3 space-y-2 pb-1">
<Label htmlFor="community-report-details" className="text-sm font-medium">
Additional details{' '}
{reportType !== 'other' && (
<span className="text-muted-foreground font-normal">(optional)</span>
)}
</Label>
<Textarea
id="community-report-details"
value={details}
onChange={(e) => setDetails(e.target.value)}
placeholder="Provide additional context..."
className="resize-none"
rows={2}
/>
</div>
</div>
<div className="flex gap-2 justify-end pt-2 shrink-0">
<Button
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={isPending}
>
Cancel
</Button>
<Button
onClick={handleSubmit}
disabled={!canSubmit}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isPending ? 'Submitting...' : 'Submit Report'}
</Button>
</div>
</DialogContent>
</Dialog>
);
}
+411
View File
@@ -0,0 +1,411 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import {
Trophy, Users, Hash, Zap, MessageSquare, HandHeart, Flame,
} from 'lucide-react';
import type { NostrMetadata } from '@nostrify/nostrify';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useAuthor } from '@/hooks/useAuthor';
import { useProfileUrl } from '@/hooks/useProfileUrl';
import { useTrustedCountryStats } from '@/hooks/useTrustedCountryStats';
import { useTrustedGlobalStats } from '@/hooks/useTrustedGlobalStats';
import type {
StatsTimeframe, TopAction, TopContributor, TopDonor, TopPoster, TrendingHashtag,
TrustedCountryStats,
} from '@/lib/statsParser';
import { genUserName } from '@/lib/genUserName';
import { getDisplayName } from '@/lib/getDisplayName';
import { cn } from '@/lib/utils';
const TIMEFRAMES: { value: StatsTimeframe; label: string }[] = [
{ value: '7d', label: '7d' },
{ value: '30d', label: '30d' },
{ value: '90d', label: '90d' },
{ value: 'all', label: 'All' },
];
interface CommunityStatsPanelProps {
/** ISO 3166 country code. Omit to render the global (`iso3166:ZZ`) snapshot. */
countryCode?: string;
className?: string;
/**
* Render at narrow widths (e.g. inside a 360px sidebar): 2-column tile grid
* with full labels, single-column leaderboards. Defaults to `false` (the
* roomy multi-column layout used on country pages).
*/
compact?: boolean;
}
/**
* Compact community-stats panel for a country (or the global aggregate when
* `countryCode` is omitted). Reads pre-computed kind 30385 snapshots — see
* NIP.md → Kind 30385 for the trust model and tag schema.
*
* Renders nothing when no trusted snapshot is available, so it can be safely
* dropped into any page without producing empty placeholders.
*/
export function CommunityStatsPanel({ countryCode, className, compact = false }: CommunityStatsPanelProps) {
const isGlobal = !countryCode;
const country = useTrustedCountryStats(isGlobal ? undefined : countryCode);
const global = useTrustedGlobalStats();
const { data: stats, isLoading } = isGlobal ? global : country;
const [tf, setTf] = useState<StatsTimeframe>('7d');
if (isLoading && !stats) return <PanelSkeleton className={className} />;
if (!stats) return null;
return (
<section className={cn('rounded-2xl border border-border bg-background/40 p-4 space-y-4', className)}>
<PanelHeader stats={stats} timeframe={tf} onTimeframeChange={setTf} />
<AggregateCounts stats={stats} timeframe={tf} compact={compact} />
<Leaderboards stats={stats} timeframe={tf} compact={compact} />
</section>
);
}
// ── Header ───────────────────────────────────────────────────────────────────
function PanelHeader({
stats, timeframe, onTimeframeChange,
}: {
stats: TrustedCountryStats;
timeframe: StatsTimeframe;
onTimeframeChange: (tf: StatsTimeframe) => void;
}) {
return (
<div className="flex items-center justify-between gap-3 flex-wrap">
<h2 className="text-sm font-semibold flex items-center gap-2 text-muted-foreground">
<Trophy className="size-4 text-primary" />
<span>Community stats</span>
<span className="text-xs font-normal text-muted-foreground/60">
updated {formatRelative(stats.updatedAt)}
</span>
</h2>
<Tabs value={timeframe} onValueChange={(v) => onTimeframeChange(v as StatsTimeframe)}>
<TabsList className="h-7">
{TIMEFRAMES.map((t) => (
<TabsTrigger key={t.value} value={t.value} className="h-6 px-2 text-xs">
{t.label}
</TabsTrigger>
))}
</TabsList>
</Tabs>
</div>
);
}
// ── Aggregate counts ─────────────────────────────────────────────────────────
function AggregateCounts({
stats, timeframe, compact,
}: {
stats: TrustedCountryStats;
timeframe: StatsTimeframe;
compact: boolean;
}) {
const c = stats.counts;
return (
<div className={cn('grid gap-2', compact ? 'grid-cols-2' : 'grid-cols-2 sm:grid-cols-5')}>
<CountTile icon={MessageSquare} label="Comments" value={c.commentCnt[timeframe]} />
<CountTile icon={Users} label="Authors" value={c.authorCnt[timeframe]} />
<CountTile icon={Zap} label="Sats zapped" value={c.zapAmount[timeframe]} />
<CountTile icon={HandHeart} label="Zaps" value={c.zapCnt[timeframe]} />
<CountTile icon={Flame} label="Submissions" value={c.submissionCnt[timeframe]} />
</div>
);
}
function CountTile({
icon: Icon, label, value,
}: {
icon: React.ComponentType<{ className?: string }>;
label: string;
value: number;
}) {
return (
<div className="rounded-lg bg-muted/40 p-2 flex flex-col gap-0.5">
<div className="flex items-center gap-1.5 text-[10px] uppercase tracking-wide text-muted-foreground">
<Icon className="size-3" />
<span className="truncate">{label}</span>
</div>
<div className="text-base font-semibold tabular-nums">{formatCompact(value)}</div>
</div>
);
}
// ── Leaderboards ─────────────────────────────────────────────────────────────
function Leaderboards({
stats, timeframe, compact,
}: {
stats: TrustedCountryStats;
timeframe: StatsTimeframe;
compact: boolean;
}) {
const tfData = stats.byTimeframe[timeframe];
return (
<div className={cn('grid gap-4', compact ? 'grid-cols-1' : 'md:grid-cols-2')}>
<TopActionsList actions={tfData.topActions} />
<TopPostersList posters={tfData.topPosters} />
<TopZappedList contributors={tfData.topContributors} />
<TopDonorsList donors={tfData.topDonors} />
<TrendingHashtagsList tags={tfData.trendingHashtags} className={compact ? undefined : 'md:col-span-2'} />
</div>
);
}
function SectionHeader({ icon: Icon, title }: { icon: React.ComponentType<{ className?: string }>; title: string }) {
return (
<h3 className="flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-2">
<Icon className="size-3.5 text-primary" />
<span>{title}</span>
</h3>
);
}
function EmptyRow({ label }: { label: string }) {
return <p className="text-xs text-muted-foreground/60 italic">No {label} yet.</p>;
}
function TopActionsList({ actions }: { actions: TopAction[] }) {
if (!actions.length) {
return (
<div>
<SectionHeader icon={Trophy} title="Top actions" />
<EmptyRow label="actions" />
</div>
);
}
return (
<div>
<SectionHeader icon={Trophy} title="Top actions" />
<ul className="space-y-1.5">
{actions.slice(0, 5).map((a, i) => {
const parts = a.aTag.split(':');
const naddrPath = parts.length === 3 ? `/actions` : `/actions`;
return (
<li key={a.aTag}>
<Link
to={naddrPath}
className="flex items-start gap-2 rounded-md p-1.5 hover:bg-muted/40 transition-colors"
>
<RankBadge rank={i + 1} />
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{a.title}</div>
<div className="text-[11px] text-muted-foreground tabular-nums">
{a.submissions} submissions · {formatCompact(a.zapAmount)} sats zapped
</div>
</div>
</Link>
</li>
);
})}
</ul>
</div>
);
}
function TopPostersList({ posters }: { posters: TopPoster[] }) {
if (!posters.length) {
return (
<div>
<SectionHeader icon={MessageSquare} title="Top posters" />
<EmptyRow label="posters" />
</div>
);
}
return (
<div>
<SectionHeader icon={MessageSquare} title="Top posters" />
<ul className="space-y-1.5">
{posters.slice(0, 5).map((p, i) => (
<PubkeyRow
key={p.pubkey}
rank={i + 1}
pubkey={p.pubkey}
primary={`${p.count} posts`}
/>
))}
</ul>
</div>
);
}
function TopZappedList({ contributors }: { contributors: TopContributor[] }) {
if (!contributors.length) {
return (
<div>
<SectionHeader icon={Zap} title="Most zapped" />
<EmptyRow label="zapped contributors" />
</div>
);
}
return (
<div>
<SectionHeader icon={Zap} title="Most zapped" />
<ul className="space-y-1.5">
{contributors.slice(0, 5).map((c, i) => (
<PubkeyRow
key={c.pubkey}
rank={i + 1}
pubkey={c.pubkey}
primary={`${formatCompact(c.totalSats)} sats`}
secondary={`${c.postCount} posts · avg ${formatCompact(c.avgSats)}`}
/>
))}
</ul>
</div>
);
}
function TopDonorsList({ donors }: { donors: TopDonor[] }) {
if (!donors.length) {
return (
<div>
<SectionHeader icon={HandHeart} title="Top donors" />
<EmptyRow label="donors" />
</div>
);
}
return (
<div>
<SectionHeader icon={HandHeart} title="Top donors" />
<ul className="space-y-1.5">
{donors.slice(0, 5).map((d, i) => (
<PubkeyRow
key={d.pubkey}
rank={i + 1}
pubkey={d.pubkey}
primary={`${formatCompact(d.totalSats)} sats`}
secondary={`${d.zapCount} zaps`}
/>
))}
</ul>
</div>
);
}
function TrendingHashtagsList({ tags, className }: { tags: TrendingHashtag[]; className?: string }) {
if (!tags.length) {
return (
<div className={className}>
<SectionHeader icon={Hash} title="Trending hashtags" />
<EmptyRow label="trending hashtags" />
</div>
);
}
return (
<div className={className}>
<SectionHeader icon={Hash} title="Trending hashtags" />
<div className="flex flex-wrap gap-1.5">
{tags.slice(0, 16).map((t) => (
<Link key={t.tag} to={`/i/${encodeURIComponent(`https://#${t.tag}`)}`}>
<Badge variant="secondary" className="gap-1 cursor-pointer text-[11px] hover:bg-secondary/80">
<Hash className="size-3" />
<span>{t.tag}</span>
<span className="text-muted-foreground tabular-nums">{t.count}</span>
</Badge>
</Link>
))}
</div>
</div>
);
}
// ── Generic pubkey row ───────────────────────────────────────────────────────
function PubkeyRow({
rank, pubkey, primary, secondary,
}: {
rank: number;
pubkey: string;
primary: string;
secondary?: string;
}) {
const author = useAuthor(pubkey);
const metadata: NostrMetadata | undefined = author.data?.metadata;
const displayName = getDisplayName(metadata, pubkey) || genUserName(pubkey);
const url = useProfileUrl(pubkey, metadata);
return (
<li>
<Link
to={url}
className="flex items-center gap-2 rounded-md p-1.5 hover:bg-muted/40 transition-colors"
>
<RankBadge rank={rank} />
<Avatar className="size-7 shrink-0">
<AvatarImage src={metadata?.picture} />
<AvatarFallback className="text-[10px]">{displayName.slice(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{displayName}</div>
<div className="text-[11px] text-muted-foreground tabular-nums truncate">
{primary}
{secondary && <span className="text-muted-foreground/60"> · {secondary}</span>}
</div>
</div>
</Link>
</li>
);
}
function RankBadge({ rank }: { rank: number }) {
return (
<span
className={cn(
'shrink-0 size-5 rounded-full bg-primary/15 text-primary text-[10px] font-bold flex items-center justify-center',
rank === 1 && 'bg-yellow-500/20 text-yellow-700 dark:text-yellow-400',
rank === 2 && 'bg-zinc-400/20 text-zinc-700 dark:text-zinc-300',
rank === 3 && 'bg-amber-700/20 text-amber-800 dark:text-amber-400',
)}
>
{rank}
</span>
);
}
// ── Skeleton ─────────────────────────────────────────────────────────────────
function PanelSkeleton({ className }: { className?: string }) {
return (
<section className={cn('rounded-2xl border border-border bg-background/40 p-4 space-y-4', className)}>
<Skeleton className="h-5 w-40" />
<div className="grid grid-cols-2 sm:grid-cols-5 gap-2">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-12 rounded-lg" />
))}
</div>
<div className="grid gap-3 md:grid-cols-2">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-32 rounded-md" />
))}
</div>
</section>
);
}
// ── Formatting ───────────────────────────────────────────────────────────────
const compactFormatter = new Intl.NumberFormat('en', { notation: 'compact', maximumFractionDigits: 1 });
function formatCompact(value: number): string {
if (!value) return '0';
return compactFormatter.format(value);
}
function formatRelative(unixSeconds: number): string {
if (!unixSeconds) return 'never';
const diffSec = Math.max(0, Math.floor(Date.now() / 1000 - unixSeconds));
if (diffSec < 60) return 'just now';
const diffMin = Math.floor(diffSec / 60);
if (diffMin < 60) return `${diffMin}m ago`;
const diffHr = Math.floor(diffMin / 60);
if (diffHr < 24) return `${diffHr}h ago`;
const diffDay = Math.floor(diffHr / 24);
return `${diffDay}d ago`;
}
+158 -163
View File
@@ -1,4 +1,4 @@
import { useState, useRef, useCallback, useMemo, useEffect } from 'react';
import { lazy, Suspense, useState, useRef, useCallback, useMemo, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { Paperclip, Smile, AlertTriangle, X, Loader2, Mic, Square, Sticker, BarChart3, Plus, ChevronLeft } from 'lucide-react';
import { nip19 } from 'nostr-tools';
@@ -14,17 +14,17 @@ import { ScrollArea } from '@/components/ui/scroll-area';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { Dialog, DialogContent } from '@/components/ui/dialog';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { EmojiPicker } from '@/components/EmojiPicker';
import { useCustomEmojis } from '@/hooks/useCustomEmojis';
import { useFeedSettings } from '@/hooks/useFeedSettings';
import { GifPicker } from '@/components/GifPicker';
import { EmbeddedNote } from '@/components/EmbeddedNote';
import { EmbeddedNaddr } from '@/components/EmbeddedNaddr';
import { MentionAutocomplete } from '@/components/MentionAutocomplete';
import { CustomEmojiImg } from '@/components/CustomEmoji';
import { EmojiShortcodeAutocomplete } from '@/components/EmojiShortcodeAutocomplete';
import { NoteContent } from '@/components/NoteContent';
import { NoteMedia } from '@/components/NoteMedia';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { usePostComment } from '@/hooks/usePostComment';
@@ -34,12 +34,16 @@ import { useToast } from '@/hooks/useToast';
import { useAppContext } from '@/hooks/useAppContext';
import type { EventStats } from '@/hooks/useTrending';
import { cn } from '@/lib/utils';
import { extractWebxdcMeta } from '@/lib/webxdcMeta';
import { extractVideoUrls, extractAudioUrls, IMETA_MEDIA_URL_REGEX, mimeFromExt } from '@/lib/mediaUrls';
import { parseImetaMap } from '@/lib/imeta';
import { notificationSuccess } from '@/lib/haptics';
import { extractVideoUrls, extractAudioUrls, IMETA_MEDIA_URL_REGEX, IMETA_MEDIA_URL_TEST_REGEX, mimeFromExt } from '@/lib/mediaUrls';
/** Lazy-loaded EmojiPicker — keeps emoji-mart + its data out of the main bundle. */
const LazyEmojiPicker = lazy(() => import('@/components/EmojiPicker').then(m => ({ default: m.EmojiPicker })));
import { useProfileUrl } from '@/hooks/useProfileUrl';
import { useInsertText } from '@/hooks/useInsertText';
import { useVoiceRecorder } from '@/hooks/useVoiceRecorder';
import { formatTime } from '@/lib/formatTime';
import { genUserName } from '@/lib/genUserName';
import { DITTO_RELAY } from '@/lib/appRelays';
import { resizeImage } from '@/lib/resizeImage';
@@ -199,7 +203,6 @@ export function ComposeBox({
// Poll mode state
const [mode, setMode] = useState<'post' | 'poll'>(initialMode);
const [pollQuestion, setPollQuestion] = useState('');
const [pollOptions, setPollOptions] = useState([
{ id: pollOptionId(), label: '' },
{ id: pollOptionId(), label: '' },
@@ -215,12 +218,32 @@ export function ComposeBox({
/** Maps .xdc URLs to extracted metadata (name + icon URL). */
const [webxdcMetas, setWebxdcMetas] = useState<Map<string, { name?: string; iconUrl?: string }>>(new Map());
const textareaRef = useRef<HTMLTextAreaElement>(null);
const { insertAtCursor, insertEmoji: insertEmojiAtCursor } = useInsertText(textareaRef, content, setContent);
const fileInputRef = useRef<HTMLInputElement>(null);
// Voice recording
const voiceRecorder = useVoiceRecorder();
const [isPublishingVoice, setIsPublishingVoice] = useState(false);
const resetComposeState = useCallback(() => {
setContent('');
setCwEnabled(false);
setCwText('');
setExpanded(false);
setPickerOpen(false);
setTrayOpen(false);
setInternalPreviewMode(false);
setMode(initialMode);
setPollOptions([{ id: pollOptionId(), label: '' }, { id: pollOptionId(), label: '' }]);
setPollType('singlechoice');
setPollDuration(7);
setRemovedEmbeds(new Set());
setUploadedFileTags([]);
setUploadedFileGroups(new Map());
setWebxdcUuids(new Map());
setWebxdcMetas(new Map());
}, [initialMode]);
// Use controlled preview mode if provided, otherwise use internal state
const previewMode = controlledPreviewMode !== undefined ? controlledPreviewMode : internalPreviewMode;
@@ -231,6 +254,15 @@ export function ComposeBox({
}
}, [quotedEvent]);
// Auto-resize textarea height as content grows/shrinks
useEffect(() => {
const el = textareaRef.current;
if (!el) return;
// Reset to auto so shrinking is detected correctly
el.style.height = 'auto';
el.style.height = `${el.scrollHeight}px`;
}, [content]);
const charCount = content.length;
const remaining = MAX_CHARS - charCount;
@@ -315,7 +347,7 @@ export function ComposeBox({
const url = match[0];
// Skip media URLs that render inline
// Note: SVGs not excluded - LinkPreview checks content-type and handles both cases
if (!IMETA_MEDIA_URL_REGEX.test(url)) {
if (!IMETA_MEDIA_URL_TEST_REGEX.test(url)) {
embeds.push({ type: 'link', value: url, index: match.index! });
}
}
@@ -396,7 +428,7 @@ export function ComposeBox({
const mockEvent = useMemo(() => {
if (!user || !content) return null;
const hashtags = content.match(/#\w+/g)?.map((t) => t.slice(1)) || [];
const hashtags = content.match(/#[\p{L}\p{N}_]+/gu)?.map((t) => t.slice(1)) || [];
const tags: string[][] = hashtags.map((t) => ['t', t.toLowerCase()]);
// NIP-30: Add emoji tags for custom emojis referenced in content
@@ -464,49 +496,13 @@ export function ComposeBox({
}, [user, content, customEmojis, uploadedFileGroups, webxdcUuids, webxdcMetas]);
const insertEmoji = useCallback((emoji: string) => {
const textarea = textareaRef.current;
if (textarea) {
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const newContent = content.slice(0, start) + emoji + content.slice(end);
setContent(newContent);
// Restore cursor position after the inserted emoji
requestAnimationFrame(() => {
textarea.focus();
const pos = start + emoji.length;
textarea.setSelectionRange(pos, pos);
});
} else {
setContent((prev) => prev + emoji);
}
insertEmojiAtCursor(emoji);
expand();
}, [content, expand]);
}, [insertEmojiAtCursor, expand]);
const handleInsertMention = useCallback(({ start, end, replacement }: { start: number; end: number; replacement: string }) => {
const newContent = content.slice(0, start) + replacement + content.slice(end);
setContent(newContent);
requestAnimationFrame(() => {
const textarea = textareaRef.current;
if (textarea) {
textarea.focus();
const pos = start + replacement.length;
textarea.setSelectionRange(pos, pos);
}
});
}, [content]);
const handleInsertMention = insertAtCursor;
const handleInsertShortcodeEmoji = useCallback(({ start, end, replacement }: { start: number; end: number; replacement: string }) => {
const newContent = content.slice(0, start) + replacement + content.slice(end);
setContent(newContent);
requestAnimationFrame(() => {
const textarea = textareaRef.current;
if (textarea) {
textarea.focus();
const pos = start + replacement.length;
textarea.setSelectionRange(pos, pos);
}
});
}, [content]);
const handleInsertShortcodeEmoji = insertAtCursor;
const handleFileUpload = useCallback(async (file: File) => {
try {
@@ -562,6 +558,7 @@ export function ComposeBox({
// Extract name and icon from the .xdc archive
try {
const { extractWebxdcMeta } = await import('@/lib/webxdcMeta');
const meta = await extractWebxdcMeta(file);
const metaEntry: { name?: string; iconUrl?: string } = { name: meta.name };
@@ -719,6 +716,7 @@ export function ComposeBox({
}
}
}
notificationSuccess();
toast({ title: 'Voice message sent!', description: 'Your voice message has been published.' });
onSuccess?.();
} catch {
@@ -732,7 +730,7 @@ export function ComposeBox({
if (!content.trim() || !user || charCount > MAX_CHARS) return;
try {
const hashtags = content.match(/#\w+/g)?.map((t) => t.slice(1)) || [];
const hashtags = content.match(/#[\p{L}\p{N}_]+/gu)?.map((t) => t.slice(1)) || [];
const tags: string[][] = hashtags.map((t) => ['t', t.toLowerCase()]);
// NIP-27 mention p tags — extract nostr:npub1... from content
@@ -951,15 +949,7 @@ export function ComposeBox({
});
}
setContent('');
setCwEnabled(false);
setCwText('');
setExpanded(false);
setRemovedEmbeds(new Set());
setUploadedFileTags([]);
setUploadedFileGroups(new Map());
setWebxdcUuids(new Map());
setWebxdcMetas(new Map());
resetComposeState();
// Optimistically bump the reply count on the parent event
if (replyTo && !(replyTo instanceof URL)) {
queryClient.setQueryData<EventStats>(['event-stats', replyTo.id], (prev) =>
@@ -984,6 +974,7 @@ export function ComposeBox({
queryClient.invalidateQueries({ queryKey: ['event-stats', quotedEvent.id] });
queryClient.invalidateQueries({ queryKey: ['event-interactions', quotedEvent.id] });
}
notificationSuccess();
toast({ title: 'Posted!', description: replyTo ? 'Your reply has been published.' : quotedEvent ? 'Your quote has been published.' : 'Your note has been published.' });
onSuccess?.();
} catch {
@@ -993,7 +984,8 @@ export function ComposeBox({
const handlePollSubmit = async () => {
const filledOptions = pollOptions.filter((o) => o.label.trim());
if (!pollQuestion.trim() || filledOptions.length < 2 || !user || isPollPending) return;
const finalContent = content.trim();
if (!finalContent || filledOptions.length < 2 || !user || isPollPending) return;
const tags: string[][] = [];
for (const opt of filledOptions) {
@@ -1003,17 +995,40 @@ export function ComposeBox({
if (pollDuration > 0) {
tags.push(['endsAt', String(Math.floor(Date.now() / 1000) + pollDuration * 86_400)]);
}
tags.push(['alt', `Poll: ${pollQuestion.trim()}`]);
// NIP-92: Add imeta tags for media URLs in content
const mediaUrlMatches = finalContent.matchAll(new RegExp(IMETA_MEDIA_URL_REGEX.source, 'gi'));
const processedUrls = new Set<string>();
for (const match of mediaUrlMatches) {
const url = match[0];
if (processedUrls.has(url)) continue;
processedUrls.add(url);
const fileTags = uploadedFileGroups.get(url);
if (fileTags) {
tags.push(['imeta', ...fileTags.map(tag => `${tag[0]} ${tag[1]}`)]);
} else {
const ext = match[1].toLowerCase();
tags.push(['imeta', `url ${url}`, `m ${mimeFromExt(ext)}`]);
}
}
tags.push(['alt', `Poll: ${finalContent}`]);
// Country-scoped polls: attach NIP-73 iso3166 tags so the poll appears in
// the country feed (matches Pathos buildPollTags geo-scoping behavior).
if (replyTo instanceof URL && replyTo.protocol === 'iso3166:') {
const countryIdentifier = replyTo.toString();
tags.push(['I', countryIdentifier]);
tags.push(['K', 'iso3166']);
tags.push(['i', countryIdentifier]);
tags.push(['k', 'iso3166']);
}
try {
await createEvent({ kind: 1068, content: pollQuestion.trim(), tags });
// Reset poll state
setMode('post');
setPollQuestion('');
setPollOptions([{ id: pollOptionId(), label: '' }, { id: pollOptionId(), label: '' }]);
setPollType('singlechoice');
setPollDuration(7);
await createEvent({ kind: 1068, content: finalContent, tags });
resetComposeState();
queryClient.invalidateQueries({ queryKey: ['feed'] });
notificationSuccess();
toast({ title: 'Poll published!' });
onSuccess?.();
} catch {
@@ -1022,7 +1037,7 @@ export function ComposeBox({
};
const pollFilledCount = pollOptions.filter((o) => o.label.trim()).length;
const isPollValid = pollQuestion.trim().length > 0 && pollFilledCount >= 2;
const isPollValid = content.trim().length > 0 && pollFilledCount >= 2;
const isExpanded = forceExpanded || expanded || content.length > 0 || !compact;
@@ -1030,7 +1045,7 @@ export function ComposeBox({
if (!user && compact) return null;
return (
<div className={cn("px-4 py-3 bg-background/50")}>
<div className={cn("px-4 py-3 bg-background/85 rounded-2xl")}>
{/* Preview toggle at top when not controlled and has previewable content */}
{hasPreviewableContent && controlledPreviewMode === undefined && (
<div className="flex items-center justify-end mb-3">
@@ -1070,7 +1085,7 @@ export function ComposeBox({
<Avatar shape={avatarShape} className="size-12 shrink-0 mt-0.5">
<AvatarImage src={metadata?.picture} alt={metadata?.name} />
<AvatarFallback className="bg-primary/20 text-primary text-sm">
{(metadata?.name?.[0] || '?').toUpperCase()}
{(metadata?.display_name || metadata?.name || genUserName(user?.pubkey))[0]?.toUpperCase() ?? '?'}
</AvatarFallback>
</Avatar>
</Link>
@@ -1078,31 +1093,66 @@ export function ComposeBox({
)}
<div className="flex-1 min-w-0">
{mode === 'poll' ? (
/* ── Inline poll builder ─────────────────────────────── */
<div className="pt-2.5 pb-1 space-y-3">
{!previewMode ? (
/* ── Edit mode — Textarea ────────────────────────────── */
<div className="relative">
<textarea
ref={textareaRef}
value={content}
onChange={(e) => setContent(e.target.value)}
onPointerDown={expand}
onFocus={expand}
onPaste={handlePaste}
placeholder={mode === 'poll' ? 'Ask a question…' : placeholder}
className={cn(
'w-full bg-transparent text-foreground placeholder:text-muted-foreground resize-none outline-none text-lg pt-2.5 pb-2 opacity-85 break-words overflow-hidden transition-[min-height] duration-200 ease-in-out',
isExpanded ? 'min-h-[100px]' : 'min-h-[44px]',
)}
rows={1}
disabled={!user}
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
handleSubmit();
}
}}
/>
<MentionAutocomplete
textareaRef={textareaRef}
content={content}
onInsertMention={handleInsertMention}
/>
<EmojiShortcodeAutocomplete
textareaRef={textareaRef}
content={content}
onInsertEmoji={handleInsertShortcodeEmoji}
/>
</div>
) : (
/* Preview mode - Show how post will look */
mockEvent && (
<div className="pt-2.5 pb-2 min-h-[100px]">
<div className="text-lg opacity-85">
<NoteContent event={mockEvent} className="text-foreground" />
</div>
</div>
)
)}
{/* Poll options + settings — shown below the normal textarea/preview */}
{mode === 'poll' && (
<div className="space-y-3 pt-1">
{/* Back to post link — hidden when poll mode is the only mode */}
{initialMode !== 'poll' && (
<button
type="button"
onClick={() => setMode('post')}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors -mt-0.5"
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<ChevronLeft className="size-3.5" />
Back to post
</button>
)}
{/* Question */}
<textarea
value={pollQuestion}
onChange={(e) => setPollQuestion(e.target.value)}
placeholder="Ask a question…"
rows={2}
maxLength={280}
className="w-full bg-transparent text-foreground placeholder:text-muted-foreground resize-none outline-none text-lg pb-1 opacity-85 break-words"
/>
{/* Options */}
<div className="space-y-1.5">
{pollOptions.map((opt, idx) => (
@@ -1183,66 +1233,6 @@ export function ComposeBox({
))}
</div>
</div>
) : !previewMode ? (
/* ── Edit mode — Textarea ────────────────────────────── */
<div className="relative">
<textarea
ref={textareaRef}
value={content}
onChange={(e) => setContent(e.target.value)}
onFocus={expand}
onPaste={handlePaste}
placeholder={placeholder}
className={cn(
'w-full bg-transparent text-foreground placeholder:text-muted-foreground resize-none outline-none text-lg pt-2.5 pb-2 opacity-85 break-words',
isExpanded ? 'min-h-[100px]' : 'min-h-[44px]',
)}
rows={isExpanded ? 4 : 1}
disabled={!user}
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
handleSubmit();
}
}}
/>
<MentionAutocomplete
textareaRef={textareaRef}
content={content}
onInsertMention={handleInsertMention}
/>
<EmojiShortcodeAutocomplete
textareaRef={textareaRef}
content={content}
onInsertEmoji={handleInsertShortcodeEmoji}
/>
</div>
) : (
/* Preview mode - Show how post will look */
mockEvent && (() => {
const imetaMap = parseImetaMap(mockEvent.tags);
const videos = extractVideoUrls(mockEvent.content);
const imetaAudios = Array.from(imetaMap.values())
.filter((e) => e.mime?.startsWith('audio/'))
.map((e) => e.url);
const audios = imetaAudios.length > 0 ? imetaAudios : extractAudioUrls(mockEvent.content);
const webxdcApps = Array.from(imetaMap.values()).filter(
(entry) => entry.mime === 'application/x-webxdc' || entry.mime === 'application/vnd.webxdc+zip',
);
return (
<div className="pt-2.5 pb-2 min-h-[100px]">
<div className="text-lg opacity-85">
<NoteContent event={mockEvent} className="text-foreground" />
</div>
<NoteMedia
videos={videos}
audios={audios}
imetaMap={imetaMap}
webxdcApps={webxdcApps}
event={mockEvent}
/>
</div>
);
})()
)}
{/* Content warning input */}
@@ -1274,7 +1264,7 @@ export function ComposeBox({
identifier: quotedEvent.tags.find(([name]) => name === 'd')?.[1] ?? '',
}} />
) : (
<EmbeddedNote eventId={quotedEvent.id} />
<EmbeddedNote eventId={quotedEvent.id} authorHint={quotedEvent.pubkey} />
)}
</div>
)}
@@ -1459,17 +1449,19 @@ export function ComposeBox({
)}
</div>
{/* Picker content */}
{pickerTab === 'emoji' ? (
<EmojiPicker
customEmojis={customEmojis}
onSelect={(selection) => {
if (selection.type === 'native') {
insertEmoji(selection.emoji);
} else {
insertEmoji(`:${selection.shortcode}:`);
}
}}
/>
{pickerTab === 'emoji' ? (
<Suspense fallback={<div className="w-[316px] h-[435px] flex items-center justify-center"><Loader2 className="size-6 animate-spin text-muted-foreground" /></div>}>
<LazyEmojiPicker
customEmojis={customEmojis}
onSelect={(selection) => {
if (selection.type === 'native') {
insertEmoji(selection.emoji);
} else {
insertEmoji(`:${selection.shortcode}:`);
}
}}
/>
</Suspense>
) : pickerTab === 'stickers' ? (
<div className="w-[316px] h-[435px]">
{customEmojis.length === 0 ? (
@@ -1493,11 +1485,11 @@ export function ComposeBox({
}}
className="aspect-square rounded-lg overflow-hidden hover:bg-muted transition-colors p-1 group"
>
<img
src={emoji.url}
alt={emoji.shortcode}
className="w-full h-full object-contain group-hover:scale-110 transition-transform duration-150"
/>
<CustomEmojiImg
name={emoji.shortcode}
url={emoji.url}
className="w-full h-full object-contain group-hover:scale-110 transition-transform duration-150"
/>
</button>
))}
</div>
@@ -1537,7 +1529,10 @@ export function ComposeBox({
</Tooltip>
<PopoverContent side="bottom" align="start" sideOffset={6} className="w-44 p-1.5 rounded-xl border-border shadow-lg">
<div className="flex flex-col gap-0.5">
{!replyTo && (
{/* Polls are top-level events (kind 1068), so they only make sense as a
standalone post or rooted on an external-content URL (e.g. iso3166: country
page). Hide for actual event replies (NostrEvent replyTo). */}
{(!replyTo || replyTo instanceof URL) && (
<button
type="button"
onClick={() => { setMode((m) => m === 'poll' ? 'post' : 'poll'); setTrayOpen(false); expand(); }}
+196 -47
View File
@@ -1,7 +1,7 @@
import { useState } from 'react';
import { IntroImage } from '@/components/IntroImage';
import {
Users, Download, Loader2, X, Pencil, Home, Globe,
Users, Download, Loader2, X, Pencil, Home, Globe, MapPin,
Palette, Trash2, Plus, UserX, Hash, MessageSquareOff, ExternalLink, ShieldAlert,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
@@ -17,6 +17,7 @@ import { Link } from 'react-router-dom';
import { nip19 } from 'nostr-tools';
import { useToast } from '@/hooks/useToast';
import { useSavedFeeds } from '@/hooks/useSavedFeeds';
import { useInterests } from '@/hooks/useInterests';
import { useFeedSettings } from '@/hooks/useFeedSettings';
import { useEncryptedSettings } from '@/hooks/useEncryptedSettings';
import { useCurrentUser } from '@/hooks/useCurrentUser';
@@ -24,7 +25,7 @@ import { useAppContext } from '@/hooks/useAppContext';
import { useMuteList, type MuteListItem } from '@/hooks/useMuteList';
import { useAuthor } from '@/hooks/useAuthor';
import { FeedEditModal } from '@/components/FeedEditModal';
import { buildKindOptions } from '@/components/SavedFeedFiltersEditor';
import { buildKindOptions } from '@/lib/feedFilterUtils';
import { genUserName } from '@/lib/genUserName';
import { EXTRA_KINDS, FEED_KINDS, SECTION_ORDER, SECTION_LABELS } from '@/lib/extraKinds';
import { CONTENT_KIND_ICONS, SIDEBAR_ITEMS } from '@/lib/sidebarItems';
@@ -199,7 +200,7 @@ function ContentTypeRow({ def }: { def: ExtraKindDef }) {
{hasSubKinds && def.subKinds && def.subKinds.map((sub) => (
<SubKindRow
key={sub.showKey}
key={sub.feedKey}
sub={sub}
/>
))}
@@ -211,7 +212,7 @@ function NotesFeedSettings() {
return (
<>
{FEED_KINDS.map((def) => (
<ContentTypeRow key={def.feedKey ?? String(def.kind)} def={def} />
<ContentTypeRow key={def.id} def={def} />
))}
</>
);
@@ -231,7 +232,7 @@ function FeedSettingsFormInternals() {
</span>
</div>
{sectionKinds.map((def) => (
<ContentTypeRow key={def.feedKey ?? def.showKey ?? String(def.kind)} def={def} />
<ContentTypeRow key={def.id} def={def} />
))}
</div>
);
@@ -253,8 +254,8 @@ function FeedTabsSection() {
return stored ? JSON.parse(stored) : null;
});
const [showDittoFeed, setShowDittoFeed] = useState(() => {
const stored = localStorage.getItem('ditto:showDittoFeed');
const [showAgoraFeed, setShowAgoraFeed] = useState(() => {
const stored = localStorage.getItem('agora:showWorldFeed');
return stored !== null ? stored === 'true' : true; // Default to true
});
@@ -268,14 +269,14 @@ function FeedTabsSection() {
return stored !== null ? stored === 'true' : false; // Default to false
});
const handleToggleDittoFeed = async (checked: boolean) => {
setShowDittoFeed(checked);
localStorage.setItem('ditto:showDittoFeed', String(checked));
const handleToggleAgoraFeed = async (checked: boolean) => {
setShowAgoraFeed(checked);
localStorage.setItem('agora:showWorldFeed', String(checked));
toast({
title: checked ? 'Ditto feed enabled' : 'Ditto feed disabled',
title: checked ? 'World feed enabled' : 'World feed disabled',
description: checked
? 'The Ditto feed tab will appear in your navigation'
: 'The Ditto feed tab will be hidden',
? 'The World feed tab will appear in your navigation'
: 'The World feed tab will be hidden',
});
};
@@ -446,12 +447,12 @@ function FeedTabsSection() {
<div className="border-b border-border">
<div className="flex items-center justify-between py-3.5 px-3">
<div className="min-w-0">
<Label className="text-sm font-medium">Ditto Feed</Label>
<p className="text-xs text-muted-foreground mt-0.5">Show trending and curated content from the Ditto relay</p>
<Label className="text-sm font-medium">World Feed</Label>
<p className="text-xs text-muted-foreground mt-0.5">Show posts from all countries around the world</p>
</div>
<Switch
checked={showDittoFeed}
onCheckedChange={handleToggleDittoFeed}
checked={showAgoraFeed}
onCheckedChange={handleToggleAgoraFeed}
className="shrink-0"
/>
</div>
@@ -507,7 +508,7 @@ function FeedTabsSection() {
{!community ? (
<div className="flex gap-2">
<Input
placeholder="ditto.pub"
placeholder="agora.spot"
value={communityDomain}
onChange={(e) => setCommunityDomain(e.target.value)}
onKeyDown={(e) => {
@@ -556,6 +557,183 @@ function FeedTabsSection() {
{/* Saved Feeds */}
<SavedFeedsSection />
{/* Interests (hashtag & geotag tabs) */}
<InterestsSection />
</div>
);
}
// ─── Interests Section ───────────────────────────────────────────────────────
function InterestsSection() {
const { toast } = useToast();
const { user } = useCurrentUser();
const { hashtags, addInterest: addHashtag, removeInterest: removeHashtag, isLoading: isLoadingHashtags } = useInterests('t');
const { hashtags: geotags, addInterest: addGeotag, removeInterest: removeGeotag, isLoading: isLoadingGeotags } = useInterests('g');
const [newHashtag, setNewHashtag] = useState('');
const [newGeotag, setNewGeotag] = useState('');
const isLoading = isLoadingHashtags || isLoadingGeotags;
if (!user) return null;
const handleRemoveHashtag = async (tag: string) => {
await removeHashtag.mutateAsync(tag);
toast({ title: `#${tag} removed from feed tabs` });
};
const handleRemoveGeotag = async (tag: string) => {
await removeGeotag.mutateAsync(tag);
toast({ title: `${tag} removed from feed tabs` });
};
const handleAddHashtag = async () => {
const tag = newHashtag.trim().toLowerCase().replace(/^#/, '');
if (!tag) return;
if (hashtags.includes(tag)) {
toast({ title: `#${tag} is already followed`, variant: 'destructive' });
return;
}
await addHashtag.mutateAsync(tag);
setNewHashtag('');
toast({ title: `#${tag} added to feed tabs` });
};
const handleAddGeotag = async () => {
const tag = newGeotag.trim().toLowerCase();
if (!tag) return;
if (geotags.includes(tag)) {
toast({ title: `${tag} is already followed`, variant: 'destructive' });
return;
}
await addGeotag.mutateAsync(tag);
setNewGeotag('');
toast({ title: `${tag} added to feed tabs` });
};
return (
<div className="px-3 py-4 space-y-4 border-t border-border">
<div>
<h3 className="text-sm font-semibold">Interest Tabs</h3>
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
Hashtags and locations you follow appear as tabs on the home feed.
</p>
</div>
{/* Hashtags */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<Hash className="size-4 text-muted-foreground shrink-0" />
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Hashtags</span>
</div>
<div className="flex gap-2">
<Input
placeholder="ditto"
value={newHashtag}
onChange={(e) => setNewHashtag(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') handleAddHashtag(); }}
className="h-9"
disabled={addHashtag.isPending}
/>
<Button
onClick={handleAddHashtag}
disabled={addHashtag.isPending || !newHashtag.trim()}
size="sm"
className="h-9"
>
{addHashtag.isPending ? <Loader2 className="size-4 animate-spin" /> : <Plus className="size-4" />}
</Button>
</div>
{isLoading ? (
<div className="space-y-2">
<Skeleton className="h-10 w-full" />
</div>
) : hashtags.length === 0 ? (
<p className="text-xs text-muted-foreground">No followed hashtags yet.</p>
) : (
<div className="space-y-1.5">
{hashtags.map((tag) => (
<div
key={`hashtag:${tag}`}
className="rounded-lg border border-border/50 bg-secondary/30"
>
<div className="flex items-center gap-2 py-2 px-2.5">
<Hash className="size-4 text-muted-foreground shrink-0" />
<span className="text-sm font-medium flex-1 min-w-0 truncate">{tag}</span>
<button
onClick={() => handleRemoveHashtag(tag)}
disabled={removeHashtag.isPending}
className="size-7 flex items-center justify-center rounded text-muted-foreground hover:text-destructive hover:bg-destructive/10 disabled:opacity-40 transition-colors"
aria-label={`Remove #${tag}`}
>
<X className="size-3.5" strokeWidth={4} />
</button>
</div>
</div>
))}
</div>
)}
</div>
{/* Geotags */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<MapPin className="size-4 text-muted-foreground shrink-0" />
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Locations</span>
</div>
<div className="flex gap-2">
<Input
placeholder="geohash (e.g. u4pru)"
value={newGeotag}
onChange={(e) => setNewGeotag(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') handleAddGeotag(); }}
className="h-9"
disabled={addGeotag.isPending}
/>
<Button
onClick={handleAddGeotag}
disabled={addGeotag.isPending || !newGeotag.trim()}
size="sm"
className="h-9"
>
{addGeotag.isPending ? <Loader2 className="size-4 animate-spin" /> : <Plus className="size-4" />}
</Button>
</div>
{isLoading ? (
<div className="space-y-2">
<Skeleton className="h-10 w-full" />
</div>
) : geotags.length === 0 ? (
<p className="text-xs text-muted-foreground">No followed locations yet.</p>
) : (
<div className="space-y-1.5">
{geotags.map((tag) => (
<div
key={`geotag:${tag}`}
className="rounded-lg border border-border/50 bg-secondary/30"
>
<div className="flex items-center gap-2 py-2 px-2.5">
<MapPin className="size-4 text-muted-foreground shrink-0" />
<span className="text-sm font-medium flex-1 min-w-0 truncate">{tag}</span>
<button
onClick={() => handleRemoveGeotag(tag)}
disabled={removeGeotag.isPending}
className="size-7 flex items-center justify-center rounded text-muted-foreground hover:text-destructive hover:bg-destructive/10 disabled:opacity-40 transition-colors"
aria-label={`Remove ${tag}`}
>
<X className="size-3.5" strokeWidth={4} />
</button>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}
@@ -1066,35 +1244,6 @@ function MuteTypeSection({
);
}
export function ThemePreferencesSection() {
const { feedSettings, updateFeedSettings } = useFeedSettings();
const { updateSettings } = useEncryptedSettings();
const { user } = useCurrentUser();
const showOnProfiles = feedSettings.showCustomProfileThemes !== false;
const handleProfileThemeToggle = async (value: boolean) => {
updateFeedSettings({ showCustomProfileThemes: value });
if (user) {
const updatedFeedSettings = { ...feedSettings, showCustomProfileThemes: value };
await updateSettings.mutateAsync({ feedSettings: updatedFeedSettings });
}
};
return (
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label className="text-sm font-medium">Show custom profile themes</Label>
<p className="text-xs text-muted-foreground">Display other users' custom themes when visiting their profiles</p>
</div>
<Switch
checked={showOnProfiles}
onCheckedChange={handleProfileThemeToggle}
/>
</div>
);
}
function HomePageSetting() {
const { config, updateConfig } = useAppContext();
const { user } = useCurrentUser();
+16
View File
@@ -0,0 +1,16 @@
import { type ReactNode } from 'react';
import { CountryFeedContext } from '@/contexts/CountryFeedContext';
interface CountryFeedProviderProps {
countryCode: string;
children: ReactNode;
}
/** Marks the subtree as belonging to a specific ISO 3166 country/subdivision feed. */
export function CountryFeedProvider({ countryCode, children }: CountryFeedProviderProps) {
return (
<CountryFeedContext.Provider value={{ countryCode: countryCode.toUpperCase() }}>
{children}
</CountryFeedContext.Provider>
);
}
+3
View File
@@ -292,6 +292,9 @@ export function CreateBadgeDialog({ open, onOpenChange }: CreateBadgeDialogProps
}}
/>
</div>
<p className="text-xs text-muted-foreground">
Recommended aspect ratio is 1:1 (max 1024x1024 px).
</p>
</div>
{/* Badge name */}
+331
View File
@@ -0,0 +1,331 @@
import { useState, useCallback, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { Users, Loader2 } from 'lucide-react';
import { useNostr } from '@nostrify/react';
import { useQueryClient } from '@tanstack/react-query';
import { nip19 } from 'nostr-tools';
import type { NostrEvent } from '@nostrify/nostrify';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { ScrollArea } from '@/components/ui/scroll-area';
import { ImageUploadField } from '@/components/ImageUploadField';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useToast } from '@/hooks/useToast';
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
import { BADGE_DEFINITION_KIND, COMMUNITY_DEFINITION_KIND, type ParsedCommunity } from '@/lib/communityUtils';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
// ── Helpers ───────────────────────────────────────────────────────────────────
/** Convert text into a URL-safe slug for the d-tag identifier. */
function slugify(text: string): string {
return text
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, '')
.replace(/[\s_]+/g, '-')
.replace(/^-+|-+$/g, '');
}
// ── Types ─────────────────────────────────────────────────────────────────────
interface CreateCommunityDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** Existing community event when editing. Omit to create a new community. */
communityEvent?: NostrEvent;
/** Parsed existing community data when editing. */
community?: ParsedCommunity;
}
// ── Component ─────────────────────────────────────────────────────────────────
export function CreateCommunityDialog({ open, onOpenChange, communityEvent, community }: CreateCommunityDialogProps) {
const { user } = useCurrentUser();
const { nostr } = useNostr();
const { toast } = useToast();
const navigate = useNavigate();
const queryClient = useQueryClient();
const isEditing = !!communityEvent && !!community;
// Form state
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [imageUrl, setImageUrl] = useState('');
const [isPublishing, setIsPublishing] = useState(false);
const [isImageUploading, setIsImageUploading] = useState(false);
// Mutations
const { mutateAsync: publishEvent } = useNostrPublish();
// Derived
const effectiveSlug = isEditing && community ? community.dTag : slugify(name);
const populateFromCommunity = useCallback(() => {
setName(community?.name ?? '');
setDescription(community?.description ?? '');
setImageUrl(community?.image ?? '');
setIsPublishing(false);
setIsImageUploading(false);
}, [community]);
const resetForm = useCallback(() => {
if (isEditing) {
populateFromCommunity();
} else {
setName('');
setDescription('');
setImageUrl('');
setIsPublishing(false);
setIsImageUploading(false);
}
}, [isEditing, populateFromCommunity]);
useEffect(() => {
if (open && isEditing) {
populateFromCommunity();
}
}, [open, isEditing, populateFromCommunity]);
const handleOpenChange = useCallback((nextOpen: boolean) => {
if (!nextOpen) resetForm();
onOpenChange(nextOpen);
}, [onOpenChange, resetForm]);
const buildUpdatedCommunityTags = useCallback((baseTags: string[][]): string[][] => {
const tags = baseTags.filter(([name]) => !['d', 'name', 'description', 'image', 'alt'].includes(name));
const nextTags: string[][] = [
['d', effectiveSlug],
['name', name.trim()],
];
if (description.trim()) {
nextTags.push(['description', description.trim()]);
}
const sanitizedImage = sanitizeUrl(imageUrl.trim());
if (sanitizedImage) {
nextTags.push(['image', sanitizedImage]);
}
nextTags.push(...tags);
nextTags.push(['alt', `Community: ${name.trim()}`]);
return nextTags;
}, [description, effectiveSlug, imageUrl, name]);
// ── Publish ───────────────────────────────────────────────────────────────
const handleCreate = useCallback(async () => {
if (!user || !name.trim() || !effectiveSlug) return;
if (isImageUploading) {
toast({ title: 'Image is still uploading', description: 'Please wait for the upload to finish.' });
return;
}
if (imageUrl.trim() && !sanitizeUrl(imageUrl.trim())) {
toast({ title: 'Image URL must be a valid https URL', variant: 'destructive' });
return;
}
setIsPublishing(true);
try {
if (isEditing && communityEvent && community) {
const prev = await fetchFreshEvent(nostr, {
kinds: [COMMUNITY_DEFINITION_KIND],
authors: [communityEvent.pubkey],
'#d': [community.dTag],
});
const updatedEvent = await publishEvent({
kind: COMMUNITY_DEFINITION_KIND,
content: prev?.content ?? communityEvent.content,
tags: buildUpdatedCommunityTags(prev?.tags ?? communityEvent.tags),
prev: prev ?? undefined,
} as Omit<NostrEvent, 'id' | 'pubkey' | 'sig'> & { prev?: NostrEvent });
queryClient.setQueryData(
['addr-event', COMMUNITY_DEFINITION_KIND, communityEvent.pubkey, community.dTag],
updatedEvent,
);
queryClient.invalidateQueries({ queryKey: ['community-activity-feed'], exact: false });
queryClient.invalidateQueries({ queryKey: ['my-communities'], exact: false });
toast({ title: 'Community updated!' });
handleOpenChange(false);
return;
}
// Check for d-tag collision (same author, same kind, same d-tag)
const existing = await nostr.query([{
kinds: [COMMUNITY_DEFINITION_KIND],
authors: [user.pubkey],
'#d': [effectiveSlug],
limit: 1,
}]);
if (existing.length > 0) {
toast({
title: 'Name already in use',
description: 'You already have a community with this name. Please choose a different name.',
variant: 'destructive',
});
setIsPublishing(false);
return;
}
const badgeDTag = `${effectiveSlug}-member`;
const existingBadge = await nostr.query([{
kinds: [BADGE_DEFINITION_KIND],
authors: [user.pubkey],
'#d': [badgeDTag],
limit: 1,
}]);
if (existingBadge.length > 0) {
toast({
title: 'Member badge ID already in use',
description: 'Choose a different community name so the member badge can be created safely.',
variant: 'destructive',
});
setIsPublishing(false);
return;
}
const badgeEvent = await publishEvent({
kind: BADGE_DEFINITION_KIND,
content: '',
tags: [
['d', badgeDTag],
['name', 'Member'],
['description', `Member of ${name.trim()}`],
['alt', `Badge definition: Member of ${name.trim()}`],
],
} as Omit<NostrEvent, 'id' | 'pubkey' | 'sig'>);
// Founder as moderator (p tag) plus one flat member badge reference.
const communityTags = buildUpdatedCommunityTags([
['a', `${BADGE_DEFINITION_KIND}:${badgeEvent.pubkey}:${badgeDTag}`, '', 'member'],
['p', user.pubkey, '', 'moderator'],
]);
// Publish community definition (kind 34550)
const createdEvent = await publishEvent({
kind: COMMUNITY_DEFINITION_KIND,
content: '',
tags: communityTags,
} as Omit<NostrEvent, 'id' | 'pubkey' | 'sig'>);
// Navigate to the new community
const naddr = nip19.naddrEncode({
kind: COMMUNITY_DEFINITION_KIND,
pubkey: createdEvent.pubkey,
identifier: effectiveSlug,
});
toast({ title: 'Community created!' });
handleOpenChange(false);
navigate(`/${naddr}`);
} catch (err) {
toast({
title: isEditing ? 'Failed to update community' : 'Failed to create community',
description: err instanceof Error ? err.message : 'Please try again.',
variant: 'destructive',
});
} finally {
setIsPublishing(false);
}
}, [
user, name, effectiveSlug, isEditing, communityEvent, community, nostr, isImageUploading, imageUrl,
publishEvent, buildUpdatedCommunityTags, queryClient, toast, handleOpenChange, navigate,
]);
if (!user) return null;
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-lg gap-0 p-0 overflow-hidden">
<DialogHeader className="px-5 pt-5 pb-3">
<DialogTitle className="flex items-center gap-2">
<Users className="size-5 text-primary" />
{isEditing ? 'Edit Community' : 'Create a Community'}
</DialogTitle>
<DialogDescription>
{isEditing
? 'Update the name, image, and description. Moderators are preserved.'
: "Start a new community on Nostr. You'll be the founder."}
</DialogDescription>
</DialogHeader>
<ScrollArea className="max-h-[calc(100vh-9rem)] sm:max-h-none">
<div className="px-5 pb-5 space-y-4">
{/* Community name */}
<div className="space-y-1.5">
<Label htmlFor="community-name">Community Name *</Label>
<Input
id="community-name"
placeholder="e.g. The Arbiter's Guard"
value={name}
onChange={(e) => setName(e.target.value)}
maxLength={100}
/>
{name.trim() && (
<p className="text-xs text-muted-foreground font-mono">
ID: {effectiveSlug || '...'}{isEditing ? ' (unchanged)' : ''}
</p>
)}
</div>
<ImageUploadField
id="community-image"
label={<>Community Image <span className="text-muted-foreground font-normal">(recommended)</span></>}
value={imageUrl}
onChange={setImageUrl}
onUploadingChange={setIsImageUploading}
previewAlt="Community image preview"
dropAreaClassName="min-h-32"
/>
{/* Description */}
<div className="space-y-1.5">
<Label htmlFor="community-description">
Description
<span className="text-muted-foreground font-normal ml-1">(recommended)</span>
</Label>
<Textarea
id="community-description"
placeholder="What is this community about?"
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
/>
</div>
{/* Submit button */}
<Button
onClick={handleCreate}
disabled={!name.trim() || !effectiveSlug || isPublishing || isImageUploading}
className="w-full gap-2"
>
{isPublishing ? (
<><Loader2 className="size-4 animate-spin" /> {isEditing ? 'Saving...' : 'Creating...'}</>
) : (
<><Users className="size-4" /> {isEditing ? 'Save Changes' : 'Create Community'}</>
)}
</Button>
</div>
</ScrollArea>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,523 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { CalendarDays, ChevronLeft } from 'lucide-react';
import { useNostr } from '@nostrify/react';
import { useQueryClient } from '@tanstack/react-query';
import type { NostrEvent } from '@nostrify/nostrify';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
import { ImageUploadField } from '@/components/ImageUploadField';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { usePublishRSVP } from '@/hooks/usePublishRSVP';
import { useToast } from '@/hooks/useToast';
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
interface CreateCommunityEventDialogProps {
communityATag?: string;
open: boolean;
onOpenChange: (open: boolean) => void;
event?: NostrEvent;
}
const MANAGED_EDIT_TAGS = new Set([
'd',
'title',
'alt',
'summary',
'location',
'image',
'start',
'end',
'D',
'start_tzid',
'end_tzid',
'A',
'K',
'P',
]);
function slugify(text: string): string {
return text
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, '')
.replace(/[\s_]+/g, '-')
.replace(/^-+|-+$/g, '');
}
function addDays(date: string, days: number): string {
const parsed = new Date(`${date}T00:00:00Z`);
parsed.setUTCDate(parsed.getUTCDate() + days);
return parsed.toISOString().slice(0, 10);
}
function subtractDays(date: string, days: number): string {
return addDays(date, -days);
}
function formatLocalDateTimeFields(timestamp: string): { date: string; time: string } {
const parsed = parseInt(timestamp, 10);
if (!Number.isFinite(parsed) || parsed <= 0) return { date: '', time: '' };
const date = new Date(parsed * 1000);
const pad = (n: number) => n.toString().padStart(2, '0');
return {
date: `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`,
time: `${pad(date.getHours())}:${pad(date.getMinutes())}`,
};
}
function toLocalTimestamp(date: string, time: string): number {
return Math.floor(new Date(`${date}T${time}:00`).getTime() / 1000);
}
function parseCommunityAuthor(communityATag: string): string | undefined {
const [, pubkey] = communityATag.split(':');
return pubkey || undefined;
}
export function CreateCommunityEventDialog({ communityATag, open, onOpenChange, event }: CreateCommunityEventDialogProps) {
const { user } = useCurrentUser();
const { nostr } = useNostr();
const { toast } = useToast();
const queryClient = useQueryClient();
const { mutateAsync: publishEvent, isPending } = useNostrPublish();
const { mutateAsync: publishRSVP } = usePublishRSVP();
const [step, setStep] = useState<1 | 2>(1);
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [imageUrl, setImageUrl] = useState('');
const [allDay, setAllDay] = useState(true);
const [startDate, setStartDate] = useState('');
const [startTime, setStartTime] = useState('');
const [endDate, setEndDate] = useState('');
const [endTime, setEndTime] = useState('');
const [location, setLocation] = useState('');
const [isImageUploading, setIsImageUploading] = useState(false);
const timezone = useMemo(
() => Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC',
[],
);
const isEditing = !!event;
const effectiveCommunityATag = communityATag ?? event?.tags.find(([name]) => name === 'A')?.[1];
const isCommunityEvent = !!effectiveCommunityATag;
const resetForm = useCallback(() => {
setStep(1);
setTitle('');
setDescription('');
setImageUrl('');
setAllDay(true);
setStartDate('');
setStartTime('');
setEndDate('');
setEndTime('');
setLocation('');
setIsImageUploading(false);
}, []);
const handleOpenChange = useCallback((nextOpen: boolean) => {
if (!nextOpen) resetForm();
onOpenChange(nextOpen);
}, [onOpenChange, resetForm]);
useEffect(() => {
if (!open || !event) return;
const titleTag = event.tags.find(([name]) => name === 'title')?.[1] ?? '';
const summaryTag = event.tags.find(([name]) => name === 'summary')?.[1] ?? '';
const imageTag = event.tags.find(([name]) => name === 'image')?.[1] ?? '';
const locationTag = event.tags.find(([name]) => name === 'location')?.[1] ?? '';
const startTag = event.tags.find(([name]) => name === 'start')?.[1] ?? '';
const endTag = event.tags.find(([name]) => name === 'end')?.[1] ?? '';
const isAllDay = event.kind === 31922;
setStep(1);
setTitle(titleTag);
setDescription(summaryTag || event.content);
setImageUrl(imageTag);
setLocation(locationTag);
setAllDay(isAllDay);
setIsImageUploading(false);
if (isAllDay) {
setStartDate(startTag);
setStartTime('');
setEndDate(endTag ? subtractDays(endTag, 1) : '');
setEndTime('');
return;
}
const startFields = formatLocalDateTimeFields(startTag);
const endFields = formatLocalDateTimeFields(endTag);
setStartDate(startFields.date);
setStartTime(startFields.time);
setEndDate(endFields.date);
setEndTime(endFields.time);
}, [event, open]);
const validateInfoStep = useCallback((): boolean => {
if (!title.trim()) {
toast({ title: 'Enter an event title', variant: 'destructive' });
return false;
}
return true;
}, [title, toast]);
const handleNext = useCallback((e?: React.MouseEvent<HTMLButtonElement>) => {
e?.preventDefault();
e?.stopPropagation();
if (isImageUploading) return;
if (!validateInfoStep()) return;
setStep(2);
}, [isImageUploading, validateInfoStep]);
const handleSubmit = useCallback(async () => {
if (!user) return;
if (isImageUploading) {
toast({ title: 'Image is still uploading', description: 'Please wait for the upload to finish.' });
return;
}
if (!validateInfoStep()) return;
if (!startDate) {
toast({ title: 'Choose a start date', variant: 'destructive' });
return;
}
if (!allDay && !startTime) {
toast({ title: 'Choose a start time or turn on all-day', variant: 'destructive' });
return;
}
if (!allDay && endDate && !endTime) {
toast({ title: 'Add an end time or clear the end date', variant: 'destructive' });
return;
}
const trimmedTitle = title.trim();
const dTag = event?.tags.find(([name]) => name === 'd')?.[1] || `${slugify(trimmedTitle) || 'event'}-${Date.now()}`;
let kind = isEditing && event ? event.kind : 31922;
try {
const prev = isEditing && event
? await fetchFreshEvent(nostr, {
kinds: [event.kind],
authors: [event.pubkey],
'#d': [dTag],
})
: undefined;
const preservedTags = isEditing
? (prev?.tags ?? event?.tags ?? []).filter(([name]) => !MANAGED_EDIT_TAGS.has(name))
: [];
const tags: string[][] = [
['d', dTag],
['title', trimmedTitle],
['alt', `${isCommunityEvent ? 'Community event' : 'Calendar event'}: ${trimmedTitle}`],
...preservedTags,
];
if (effectiveCommunityATag) {
const communityAuthor = parseCommunityAuthor(effectiveCommunityATag);
tags.push(['A', effectiveCommunityATag], ['K', '34550']);
if (communityAuthor) {
tags.push(['P', communityAuthor]);
}
}
if (description.trim()) {
tags.push(['summary', description.trim()]);
}
if (location.trim()) {
tags.push(['location', location.trim()]);
}
if (imageUrl.trim()) {
const sanitizedImage = sanitizeUrl(imageUrl.trim());
if (!sanitizedImage) {
toast({ title: 'Image URL must be a valid https URL', variant: 'destructive' });
return;
}
tags.push(['image', sanitizedImage]);
}
if (allDay) {
tags.push(['start', startDate]);
if (endDate) {
if (endDate < startDate) {
toast({ title: 'End date must be on or after the start date', variant: 'destructive' });
return;
}
tags.push(['end', addDays(endDate, 1)]);
}
} else {
if (!isEditing) kind = 31923;
const startTs = toLocalTimestamp(startDate, startTime);
if (!Number.isFinite(startTs) || startTs <= 0) {
toast({ title: 'Start date or time is invalid', variant: 'destructive' });
return;
}
tags.push(['start', String(startTs)]);
tags.push(['D', String(Math.floor(startTs / 86400))]);
tags.push(['start_tzid', timezone]);
if (endTime) {
const effectiveEndDate = endDate || startDate;
const endTs = toLocalTimestamp(effectiveEndDate, endTime);
if (!Number.isFinite(endTs) || endTs <= startTs) {
toast({ title: 'End time must be after the start time', variant: 'destructive' });
return;
}
tags.push(['end', String(endTs)]);
tags.push(['end_tzid', timezone]);
}
}
const publishedEvent = await publishEvent({
kind,
content: description.trim(),
tags,
prev: prev ?? undefined,
});
if (!isEditing) {
// Auto-RSVP the author as "accepted" so they appear in the attendees list.
// Best-effort: don't block on failure -- the event itself is already published.
const eventCoord = `${kind}:${user.pubkey}:${dTag}`;
publishRSVP({
eventCoord,
eventAuthorPubkey: user.pubkey,
status: 'accepted',
}).catch(() => {
// Silently ignore -- user can manually RSVP from the detail page if needed.
});
}
queryClient.setQueryData(['addr-event', kind, publishedEvent.pubkey, dTag], publishedEvent);
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['feed'] }),
queryClient.invalidateQueries({ queryKey: ['addr-event', kind, publishedEvent.pubkey, dTag] }),
...(effectiveCommunityATag ? [
queryClient.invalidateQueries({ queryKey: ['community-events', effectiveCommunityATag] }),
queryClient.invalidateQueries({
predicate: (q) => {
const [root, aTagsKey] = q.queryKey;
return root === 'community-activity-feed'
&& typeof aTagsKey === 'string'
&& aTagsKey.split(',').includes(effectiveCommunityATag);
},
}),
] : []),
]);
toast({ title: isEditing ? 'Event updated!' : 'Event created!' });
handleOpenChange(false);
} catch (err) {
toast({
title: 'Failed to create event',
description: err instanceof Error ? err.message : 'Please try again.',
variant: 'destructive',
});
}
}, [
allDay,
description,
endDate,
endTime,
effectiveCommunityATag,
handleOpenChange,
imageUrl,
isImageUploading,
isEditing,
location,
nostr,
publishEvent,
publishRSVP,
queryClient,
startDate,
startTime,
timezone,
title,
toast,
user,
validateInfoStep,
isCommunityEvent,
event,
]);
if (!user) return null;
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-md gap-0 p-0 overflow-hidden">
<DialogHeader className="px-5 pt-5 pb-3">
<DialogTitle className="flex items-center gap-2">
<CalendarDays className="size-5 text-primary" />
{isEditing ? 'Edit Event' : 'Create Event'}
</DialogTitle>
<DialogDescription>
Step {step} of 2 · {step === 1 ? 'What is happening?' : 'When and where?'}
</DialogDescription>
</DialogHeader>
<form onSubmit={(e) => e.preventDefault()}>
<ScrollArea className="max-h-[62vh]">
<div className="px-5 pb-5 space-y-4">
{step === 1 ? (
<>
<div className="space-y-1.5">
<Label htmlFor="community-event-title">Title *</Label>
<Input
id="community-event-title"
placeholder="e.g. Neighborhood cleanup"
value={title}
onChange={(e) => setTitle(e.target.value)}
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="community-event-description">Description (recommended)</Label>
<Textarea
id="community-event-description"
placeholder="Tell people what to expect..."
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={4}
/>
</div>
<ImageUploadField
id="community-event-image"
label="Image (recommended)"
value={imageUrl}
onChange={setImageUrl}
onUploadingChange={setIsImageUploading}
previewAlt="Event image preview"
/>
</>
) : (
<>
<div className="flex items-center justify-between gap-4 rounded-xl border border-border px-3 py-3">
<div className="space-y-0.5">
<Label htmlFor="community-event-all-day">All-day event</Label>
<p className="text-xs text-muted-foreground">
{isEditing ? "Event type can't be changed while editing." : 'Turn off to add start and end times.'}
</p>
</div>
<Switch
id="community-event-all-day"
checked={allDay}
onCheckedChange={setAllDay}
disabled={isEditing}
/>
</div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(9.5rem,1fr))] gap-3">
<div className="space-y-1.5">
<Label htmlFor="community-event-start-date">Start date *</Label>
<Input
id="community-event-start-date"
type="date"
className="[color-scheme:light] dark:[color-scheme:dark]"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="community-event-end-date">End date (optional)</Label>
<Input
id="community-event-end-date"
type="date"
className="[color-scheme:light] dark:[color-scheme:dark]"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
/>
</div>
</div>
{!allDay && (
<div className="grid grid-cols-[repeat(auto-fit,minmax(9.5rem,1fr))] gap-3">
<div className="space-y-1.5">
<Label htmlFor="community-event-start-time">Start time *</Label>
<Input
id="community-event-start-time"
type="time"
className="[color-scheme:light] dark:[color-scheme:dark]"
value={startTime}
onChange={(e) => setStartTime(e.target.value)}
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="community-event-end-time">End time (optional)</Label>
<Input
id="community-event-end-time"
type="time"
className="[color-scheme:light] dark:[color-scheme:dark]"
value={endTime}
onChange={(e) => setEndTime(e.target.value)}
/>
</div>
</div>
)}
<div className="space-y-1.5">
<Label htmlFor="community-event-location">Location (recommended)</Label>
<Input
id="community-event-location"
placeholder="Address, venue, or video call link"
value={location}
onChange={(e) => setLocation(e.target.value)}
/>
</div>
</>
)}
</div>
</ScrollArea>
<div className="flex items-center gap-2 border-t border-border px-5 py-4 bg-background">
{step === 1 ? (
<>
<Button type="button" variant="outline" className="flex-1" onClick={() => handleOpenChange(false)}>
Cancel
</Button>
<Button type="button" className="flex-1" onClick={handleNext} disabled={isImageUploading}>
{isImageUploading ? 'Uploading...' : 'Next'}
</Button>
</>
) : (
<>
<Button type="button" variant="outline" className="flex-1 gap-1.5" onClick={() => setStep(1)}>
<ChevronLeft className="size-4" />
Back
</Button>
<Button type="button" className="flex-1" onClick={handleSubmit} disabled={isPending || isImageUploading}>
{isPending ? (isEditing ? 'Saving...' : 'Creating...') : isImageUploading ? 'Uploading...' : isEditing ? 'Save Event' : 'Create Event'}
</Button>
</>
)}
</div>
</form>
</DialogContent>
</Dialog>
);
}

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