Compare commits

...

393 Commits

Author SHA1 Message Date
lemon 344d3a0049 Fix duplicate AI chat error display 2026-05-06 23:14:44 -07:00
lemon f6f76d08d4 Configure AI provider settings 2026-05-04 22:24:57 -07:00
lemon 07f0e7d9b9 Add slash commands with autocomplete, /tools listing, and styled notice messages 2026-05-04 22:24:27 -07:00
lemon 9671da4267 Harden AI chat: SSRF protection, capacity tracking, scoped storage, and error handling 2026-05-04 22:24:27 -07:00
lemon f4875266a6 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-04 22:24:27 -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
638 changed files with 41991 additions and 68986 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
============================================================
+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="*"
+71 -20
View File
@@ -26,13 +26,17 @@ test:
script:
- npm run test
# 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: 10 minutes
rules:
- if: $CI_COMMIT_TAG
when: never
- if: $CI_COMMIT_REF_NAME == $CI_DEFAULT_BRANCH
- when: never
# rules:
# - if: $CI_COMMIT_TAG
# when: never
# - if: $CI_COMMIT_REF_NAME == $CI_DEFAULT_BRANCH
variables:
NSYTE_VERSION: "v0.24.1"
script:
@@ -50,14 +54,29 @@ deploy-nsite:
nsyte deploy ./dist
-i
--sec "$NSITE_NBUNKSEC"
--name ditto
--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"
--publish-server-list
--use-fallback-relays
--use-fallback-servers
build-web:
stage: build
timeout: 10 minutes
needs: []
rules:
- if: $CI_COMMIT_TAG
when: never
- if: $CI_COMMIT_REF_NAME == $CI_DEFAULT_BRANCH
script:
- npm ci
- npm run build
- cp dist/index.html dist/404.html
artifacts:
paths:
- dist/
build-apk:
stage: build
image: eclipse-temurin:21-jdk
@@ -130,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
@@ -178,7 +198,7 @@ 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" > release-notes.md
release:
@@ -187,11 +207,11 @@ release:
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:
@@ -203,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
@@ -212,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 --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"
}
+168 -12
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.
@@ -409,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.
@@ -699,23 +794,47 @@ The `useCurrentUser` hook should be used to ensure that the user is logged in be
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:
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 freshEvent = await fetchFreshEvent(nostr, {
const prev = await fetchFreshEvent(nostr, {
kinds: [10003],
authors: [user.pubkey],
});
const currentTags = freshEvent?.tags ?? [];
const currentTags = prev?.tags ?? [];
// ...modify tags...
await publishEvent({ kind: 10003, content: freshEvent?.content ?? '', tags: newTags });
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.).
@@ -1071,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
@@ -1301,23 +1421,24 @@ 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.
**ALWAYS commit when you are finished making changes. This is non-negotiable -- every completed task must end with a git commit. Never leave uncommitted 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.
## Capacitor Compatibility
@@ -1388,7 +1509,7 @@ The project uses GitLab CI (`.gitlab-ci.yml`) with the following stages:
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
@@ -1398,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
@@ -1490,4 +1611,39 @@ The `--use-fallback-relays` and `--use-fallback-servers` flags also include nsyt
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
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`)
+2 -246
View File
@@ -1,251 +1,7 @@
# Changelog
## [2.4.0] - 2026-04-02
## [1.0.0] - 2026-04-30
### Added
- First-hatch tour: a guided experience for hatching your very first Blobbi egg, with progressive crack animations, an inline card flow, and a reveal moment
- Customizable bottom bar: rearrange or hide any item in the navigation bar to make Ditto feel like yours
- Mission surface card in the feed that surfaces your active quests at a glance
### Changed
- Missions redesigned as a quest board with collapsible cards and a lighter aesthetic
- "Edit Profile" mission now completes when you update any profile field, not just wall-specific edits
- Media tab on profiles now shows only photos, videos, and other media -- not plain text posts
- Blobbi onboarding state now syncs to your profile so it follows you across devices
### Fixed
- Notification dot no longer reappears after you've already marked notifications as read
- Dialogs no longer fly up when the mobile keyboard opens
## [2.3.1] - 2026-04-02
### Changed
- Drafts now save instantly to your device and sync to relays in the background, with a cloud sync indicator so you always know the status
### Fixed
- Dialogs stay visible above the keyboard on mobile instead of getting hidden behind it
- Editing an existing article no longer incorrectly warns about a duplicate slug
- Switching between rich text and markdown source mode no longer clears your content
- Fix crash when editing in markdown source mode
## [2.3.0] - 2026-04-02
### Added
- In-app article editor with a rich text toolbar, image uploads, auto-saving drafts, and a "My Articles" tab to manage drafts and published articles
### Fixed
- Custom emoji no longer stretch to fill their container
- Mobile drawer now closes when tapping footer links like Changelog or Privacy
- Logged-out users now default to the global tab on content feeds instead of seeing an empty follows tab
## [2.2.11] - 2026-04-02
### Fixed
- Fix crash caused by the "What's new" toast firing outside the router
## [2.2.10] - 2026-04-02
### Added
- App cards for Nostr apps now display in feeds and detail pages with hero images, icons, and quick-launch buttons
- "What's new" toast appears after an app update with a changelog preview and link to the full changelog
### Changed
- Changelog page redesigned with a hero section for the latest release, collapsible older entries, and category icons inline with each item
### Fixed
- Compose box now fully resets to its collapsed state after posting, including poll options and media trays
## [2.2.9] - 2026-04-01
### Added
- Emoji pack creator and editor with drag-and-drop image upload, auto-generated identifiers, and description field
- Blobbi companions now appear in feeds and post detail pages
### Changed
- Blobbi shop redesigned with a tile layout and instant buy -- no more categories or accessory tabs
- Emoji packs without any valid emojis are now hidden from feeds
- Custom emoji shortcode collisions across packs are automatically resolved with pack-prefixed names
## [2.2.8] - 2026-04-01
### Added
- Full threaded reply trees on post detail pages with collapsible deep branches and "Show X more replies" for sibling threads
- Broadcast button in the Event JSON dialog to re-publish any event to your relays
### Changed
- My Badges tab overhauled with drag-and-drop reordering, a scrollable list, and a showcase-style carousel for pending badges
- Encrypted letter envelopes now show the mailing side first (sender and recipient), then flip to reveal the wax seal
- Blobbi companions are more expressive -- richer status reactions, sleeping visuals, and body effects like dirt and hunger cues
### Fixed
- Notification dot not clearing after marking notifications as read
- Followers/following modal staying open after navigating to a profile
## [2.2.7] - 2026-03-31
### Fixed
- Nushu script in encrypted letters now renders correctly on Android and iOS
## [2.2.6] - 2026-03-31
### Added
- Encrypted letters now appear as interactive 3D envelopes with Nushu script -- flip and open them to reveal the secret writing inside
- Zap receipts and profile metadata events now render in feeds and detail pages
- Remote signer callback page for login flows with Amber, Primal, and other signing apps
### Changed
- Post action buttons extracted into a reusable PostActionBar component
- Badge detail page streamlined with unified tab bar
### Fixed
- Hashtags now support accented and Unicode characters
- Letter compose opens correctly from notifications and the letters page
- Letter font picker loads fonts so each option previews in the correct typeface
- Zap comment positioned inside the right column instead of floating with offset
- Safe-area padding on pinned SubHeaderBar only applies when scrolled to top
## [2.2.5] - 2026-03-30
### Fixed
- Crash when dragging profile tabs to reorder them
## [2.2.4] - 2026-03-30
### Changed
- Profiles now have an emoji reaction button instead of a zap button -- express yourself with any emoji or custom emoji right on someone's profile
- Zap moved to the profile overflow menu so it's still one tap away
### Fixed
- Crash on the notifications page caused by malformed badge award tags
- Deleting a badge now also deletes all awards you issued for it
- Custom emoji reactions missing their image tag no longer render as broken shortcodes
- Deletion requests for addressable events now include both `e` and `a` tags for broader relay compatibility
- Profile reactions no longer collapse into a single grouped notification
- Oversized reaction emoji in comment context headers
## [2.2.3] - 2026-03-30
### Added
- Letters now have an overflow menu, reply button, and a grid layout for browsing
- Independent feed toggles for comments and generic reposts in content settings
- Sidebar items are now visible to logged-out users so newcomers can explore everything
### Changed
- Compose textarea expands smoothly as you type instead of snapping to a new height
- Blobbi stickers auto-shrink near card edges and clip cleanly at rounded boundaries
### Fixed
- Feed gaps when replies are disabled no longer cause missing posts
- Avatar shape no longer flashes on load
- Top bar arc no longer flickers during navigation transitions
- Letter drawing-only sends, sticker drag bounds, and theme event preservation
- Notification rendering for badges and letters
- Duplicate React keys in content settings
- Layout rendering warning when switching views
## [2.2.2] - 2026-03-29
### Added
- Dedicated photo upload flow for sharing photos
- Pull-to-refresh on all feed pages
- 3D tilt effect on badge images -- hover over badges to see them pop
- Multi-select badge awarding with indicators for already-sent badges
- Badge list recovery dialog for restoring profile badge lists
- Compact badge row preview in embedded profile badges events
- Custom emoji usage tracking so your most-used custom emojis appear in the quick-react bar
- Release notes now included in Zapstore publishing
- Changelog link in the app footer
### Changed
- "Vines" renamed to "Divines" everywhere in the app
- Custom emojis appear first in the emoji picker, right after recent
- Threaded comment view now shows the parent event as a NoteCard with kind action headers
### Fixed
- Delete post dialog no longer freezes the feed on desktop
- Amber login on Android now properly retries when returning from the background
- Key downloads on Android save to the correct location
- Custom emoji SVGs render correctly in the emoji-mart picker
- Double-tap reactions now properly show the emoji on the post
- Emoji shortcode autocomplete text and highlight colors
- Profile skeleton no longer flickers for brand-new users with no metadata
- Event links now route correctly for all event types
- Badge notifications are now clickable
- Custom profile tab form no longer retains fields from a previously edited tab
- Double line under profile tabs in edit mode
- Inconsistent use of "geocache" vs "treasures" terminology
- Search page "N new posts" pill no longer shows unfiltered count
- Stale-cache overwrites in replaceable event mutations
- Click-through on delete confirmation and note menu items
## [2.2.1] - 2026-03-28
### Fixed
- New posts no longer cause scroll jumps -- they buffer while you're reading and appear with a tap
- Mobile header no longer shows double-layered backgrounds on notched devices
- Pinned tabs stay properly positioned when scrolling on mobile
- Signer approval toasts no longer fire in rapid succession on unstable connections
- Toasts are easier to swipe away on mobile
- Content warnings now blur thumbnails in the media grid
## [2.2.0] - 2026-03-28
### Added
- Blobbi virtual pets -- adopt an egg, hatch it, evolve it into one of 16 adult forms, and care for it with feeding, cleaning, medicine, music, and singing
- Blobbi companion that follows you around the app, tracks your cursor, blinks, expresses emotions, and reacts to what you're doing
- Blobbi shop and inventory system with items that affect your pet's stats
- Daily missions with reroll, care streaks, and stage-based rewards
- Immersive full-screen divines experience on both mobile and desktop with floating controls
- Relay information panel on the network settings page
- Link preview cards now display inside quoted posts instead of raw URLs
- Nsec paste guard warns you before accidentally pasting private keys outside the login field
- Remote signer UX improvements for Amber users on Android
- Badge awards now trigger push notifications
- Badges display in profile bio section with a "Give badge" option in the profile menu
### Changed
- Notification "Mentions" tab now shows only pure mentions, filtering out replies
- Notification preferences ("only from people I follow") now properly apply to push notifications, native Android notifications, and the unread dot
- Upgraded from React 18 to React 19
- Reduced initial bundle size by ~50% with improved code splitting and lazy loading
### Fixed
- Zapping Primal users no longer produces an error
- Hashtag feeds now match case-insensitively for parity with search results
- Mobile top bar arc no longer lingers on pages without tabs
- Give Badge dialog and profile menu action handlers
## [2.1.1] - 2026-03-27
### Added
- Emoji picker and shortcode autocomplete in zap comment box
- Zap button on badge detail view
- Theme descriptions now display on "updated their theme" posts and detail pages
- Badge thumbnail previews in award notifications
- Letter notifications with envelope card preview
- Kind-specific labels in notification text instead of generic "post"
### Fixed
- Compose modal no longer closes when dismissing emoji picker on mobile
- Compose preview overflow is now scrollable in modal
- Toast notifications swipe up to dismiss on mobile instead of sideways
- File downloads and URL opening work correctly on iOS
- Badges page no longer shows infinite skeleton when logged out
## [2.1.0] - 2026-03-26
### Added
- Letters -- a Wii Mail-inspired inbox for sending decorated letters to friends, complete with custom stationery, hand-drawn stickers, emoji stickers, fonts, and a send animation with envelope and wax seal
- Attach a color moment or theme to your letter as a gift -- recipients can tap to apply it instantly
- Stationery picker pulls from your color moments, followed users' themes, and built-in presets
- Freehand drawing canvas for creating one-of-a-kind sticker doodles
- Letters page added to the sidebar with a custom mailbox icon
## [2.0.1] - 2026-03-26
### Added
- Tap the version number in settings to see what's new
## [2.0.0] - 2026-03-26
Initial release of Ditto 2.0 -- a complete rewrite of Ditto.
- 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;"]
+675 -163
View File
@@ -6,10 +6,23 @@
| 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 | 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 |
### Agora Protocols
| Protocol | Composed Kinds | Description |
|--------------------------|-----------------------------------------|-----------------------------------------------------------------|
| Hierarchical Communities | 34550, 30009, 8, 1111, 1984, 5 | Ranked community membership via badge award chains (NIP-72 ext) |
### Community Kinds
These event kinds were created by community contributors and are supported by Ditto. Full specifications are maintained by their respective authors.
@@ -20,175 +33,47 @@ These event kinds were created by community contributors and are supported by Di
| 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) |
| 11125 | Blobbonaut Profile | Owner profile with coins, achievements, and inventory | [NIP-BB](https://github.com/Danidfra/nostr-pet/blob/production/NIP.md) |
| 14919 | Blobbi Interaction | Individual pet interaction (feed, play, clean, etc.) | [NIP-BB](https://github.com/Danidfra/nostr-pet/blob/production/NIP.md) |
| 14920 | Blobbi Breeding | Breeding event between two adult Blobbis | [NIP-BB](https://github.com/Danidfra/nostr-pet/blob/production/NIP.md) |
| 14921 | Blobbi Record | Immutable lifecycle record (birth, evolution, adoption) | [NIP-BB](https://github.com/Danidfra/nostr-pet/blob/production/NIP.md) |
| 16158 | Weather Station | Weather station metadata (location, sensors, connectivity) | [Draft NIP](https://github.com/nostr-protocol/nips/pull/2163) |
| 31124 | Blobbi Pet State | Current state of a virtual Blobbi pet (addressable) | [NIP-BB](https://github.com/Danidfra/nostr-pet/blob/production/NIP.md) |
| 37516 | Geocache | Geocache listing for real-world treasure hunting | [NIP-GC](https://gitlab.com/chad.curtis/treasures/-/blob/main/NIP-GC.md) |
---
## Kind 36767: Theme Definition
## Standard NIPs: Direct Messaging
### Summary
This application implements encrypted direct messaging using two standard Nostr protocols:
Addressable event kind for publishing shareable custom UI themes. A single user may publish multiple themes, each identified by a unique `d` tag.
### NIP-04 (Legacy Encrypted DMs)
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.
| Field | Value |
|-------|-------|
| Kind | 4 |
| Spec | [NIP-04](https://github.com/nostr-protocol/nips/blob/master/04.md) |
### Event Structure
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.
```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"]
]
}
```
Used for backward compatibility with older Nostr clients that do not support NIP-17.
### Content
### NIP-17 (Private Direct Messages)
The `content` field is unused and MUST be an empty string (`""`).
| Field | Value |
|-------|-------|
| Kinds | 1059 (Gift Wrap), 1060 (Seal) |
| Spec | [NIP-17](https://github.com/nostr-protocol/nips/blob/master/17.md) |
### Tags
Modern private direct messages using the Gift Wrap protocol. Messages are triple-layered:
| 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 |
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
### Multiple Themes Per User
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.
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).
### Protocol Configuration
---
Users can configure their preferred send protocol via Settings > Messages:
## Kind 16767: Active Profile Theme
### Summary
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.
### Event Structure
```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"]
]
}
```
### Content
The `content` field is unused and MUST be an empty string (`""`).
### Tags
| 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 |
### Client Behavior
- 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.
---
## Shared Tag Definitions
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
---
@@ -288,6 +173,643 @@ 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": "..."
}
```
---
## Hierarchical Communities
Hierarchical communities on Nostr, composed from existing event kinds. Communities have ranked membership where authority flows downward through a chain of badge awards.
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
- **Kind 5** ([NIP-09](https://github.com/nostr-protocol/nips/blob/master/09.md)) -- Badge Award Revocation / Moderation Rescinding
### Overview
A hierarchical community consists of:
1. **Badge definitions** (kind `30009`), one per rank tier, published by the founder.
2. A **community definition** (kind `34550`) referencing those badges with rank indices.
3. **Badge awards** (kind `8`) forming a chain of trust -- each award grants a rank, validated by the awarder's rank.
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 bans.
6. **Deletion requests** (kind `5`) for revoking badge awards or rescinding moderation events.
### Membership Derivation
Community membership is derived from three distinct sources, each resolved differently:
- **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 (matching [NIP-72](https://github.com/nostr-protocol/nips/blob/master/72.md)). Mutable (the founder can add/remove by republishing). Share rank 0 with the founder.
- **Members** -- derived from kind `8` badge awards forming the authority chain. A member's rank is determined by the badge they were awarded (rank 1 and below).
The founder and moderators have no badge. Their rank 0 status comes from the community definition itself. Rank 0 cannot be awarded via kind `8` -- there is no rank 0 badge definition. Clients determine founder/moderator display from the community event directly.
Authority is **rank-based, not badge-specific**. A member at rank N can award any badge at rank M where M > N.
### Community Definition
A kind `34550` event defines the community, extending [NIP-72](https://github.com/nostr-protocol/nips/blob/master/72.md) with badge `a` tags that encode rank indices.
#### 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+) | Badge definition reference with rank index (see format below). |
| `p` | Yes (1+) | Moderator pubkeys. Implicitly rank 0. 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>", "<rank-index>"]
```
Rank `0` is reserved for the founder and moderators (derived from the community definition, not from badges). Badge `a` tags define awardable ranks starting from `1`. Higher numbers = lower authority. Indices MUST be contiguous starting from 1.
#### 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-...-staff", "", "1"],
["a", "30009:<founder-pubkey>:a1b2c3d4-...-member", "", "2"],
["a", "30009:<founder-pubkey>:a1b2c3d4-...-peon", "", "3"],
["p", "<founder-pubkey>", "", "moderator"],
["p", "<co-moderator-pubkey>", "", "moderator"],
["relay", "wss://relay.example.com"],
["alt", "Community: The Arbiter's Guard"]
]
}
```
### Badge Definitions
Each rank tier is a standard [NIP-58](https://github.com/nostr-protocol/nips/blob/master/58.md) kind `30009` badge definition published by the founder. Badge definitions MUST be published **before** the community definition that references them.
The `d` tag SHOULD use the format `<community-d-tag>-<rank-name>` for global uniqueness.
```jsonc
{
"kind": 30009,
"pubkey": "<founder-pubkey>",
"content": "",
"tags": [
["d", "a1b2c3d4-...-staff"],
["name", "Staff"],
["description", "Trusted officers who manage clan operations."],
["image", "https://example.com/staff-badge.png"],
["alt", "Badge definition: Staff"]
]
}
```
### Badge Awards
Membership is established through kind `8` badge awards ([NIP-58](https://github.com/nostr-protocol/nips/blob/master/58.md)). Each award forms a chain link.
A badge award is **valid** if and only if:
1. The `a` tag references a badge definition listed in the community definition.
2. The awarder is a validated member at a rank **strictly less than** the badge's rank index.
3. The awarder's chain can be walked upward to a founder or moderator.
```jsonc
// Moderator (rank 0) awarding Staff (rank 1)
{
"kind": 8,
"pubkey": "<founder-pubkey>",
"content": "",
"tags": [
["a", "30009:<founder-pubkey>:a1b2c3d4-...-staff"],
["p", "<recipient-pubkey>"],
["alt", "Badge award: Staff in The Arbiter's Guard"]
]
}
```
### Chain Validation
Membership is **derived state**. Clients compute effective membership by resolving the authority graph from badge awards, then applying moderation overlays.
#### Algorithm
1. **Seed rank 0**: The event publisher (founder) and all `p` tags (moderators) in the community definition are rank 0 members.
2. **Query awards**: `{ kinds: [8], #a: [<all badge coordinates>] }`
3. **Iteratively validate**: For each award, check if the awarder is a validated member with rank strictly less than the awarded rank. If valid, add the recipient. Repeat until no new members are discovered.
4. **Resolve moderation**: Query `{ kinds: [1984], #A: [<community-a-tag>] }`. Classify kind `1984` events into **bans** and **reports** (see [Moderation](#moderation)). Kind `1984` events from non-members and banned members are ignored. Ban attempts from insufficiently ranked members are ignored, such as a rank 2 member trying to ban a rank 0 founder or moderator.
5. **Apply moderation**: Remove banned members from effective membership. Omit content from banned authors, omit verified content bans, and attach report data to reported content for content-warning display.
Clients MUST NOT trust kind `8` events at face value. An attacker can publish awards for themselves, but these fail chain validation without a path to a founder or moderator.
### 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 SHOULD treat valid community members as the canonical authors for community views. Content from non-members MAY be shown in future review surfaces, but canonical community feeds SHOULD discard non-member content by default.
#### 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
Fetch community-scoped content and moderation data together when relay limits permit. The `kinds` list can expand as the application adds supported community content kinds.
```jsonc
{
"kinds": [1111, 1984],
"#A": ["34550:<founder-pubkey>:<community-d-tag>"]
}
```
Clients then filter client-side: discard unsupported kinds, discard non-member content from canonical community views, and process kind `1984` events per the moderation rules below. 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 tiers of moderation events:
1. **Bans** -- authoritative actions from higher-ranked members 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 rank is **strictly less than** the target's rank (or the target is a non-member).
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 lower-ranked or non-member pubkey.
##### Member Ban
Ban a member by publishing kind `1984` with `p` and `A` tags only (no `e` tag) plus the `ban` label. This is **non-cascading** -- only the targeted member is banned. Their kind `8` awards remain on relays, so downstream members whose chain passes through the banned member are still valid. For cascading removal, use badge revocation (kind `5`) instead.
```jsonc
{
"kind": 1984,
"pubkey": "<moderator-pubkey>",
"content": "Reason for ban",
"tags": [
["p", "<banned-member-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** (regardless of rank) 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 | Non-banned member; rank < target; `e`/`p` match target event | Content ban (omit event) |
| `["l", "ban", "moderation"]` | No | Non-banned member; rank < target | Member 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 |
| `["l", "ban", "moderation"]` | Any | Rank >= target | Ignored |
#### Rescinding Moderation
A kind `1984` ban or report can be rescinded by deleting the kind `1984` event via kind `5` ([NIP-09](https://github.com/nostr-protocol/nips/blob/master/09.md)). Per NIP-09, only the original author of the kind `1984` event can delete it.
```jsonc
{
"kind": 5,
"tags": [["e", "<kind-1984-event-id>"], ["k", "1984"]]
}
```
Clients that implement moderation rescinding SHOULD discard any kind `1984` event whose matching kind `5` deletion exists before resolving bans and reports. This branch does not implement moderation rescinding yet; it is retained here as part of the protocol foundation for future moderation extensions.
### Revocation
A badge awarder can revoke their own award via kind `5`:
```jsonc
{
"kind": 5,
"tags": [["e", "<kind-8-event-id>"], ["k", "8"]]
}
```
This is **cascading** -- the chain link is destroyed, so the revoked member and all downstream members whose chain depended on it lose validated status. Per NIP-09, only the original publisher of the kind `8` event can delete it.
**Ban vs revocation**: Use kind `1984` to ban a single member without affecting their downstream recruits. Use kind `5` revocation to remove a member and cascade to their entire subtree.
### Community Updates
Both kind `34550` and kind `30009` are addressable events. To add or remove ranks, republish the community definition with updated `a` tags. To update moderators, republish with updated `p` tags. Removing a moderator cascades to members they recruited (unless those members have another valid chain path). Only the founder (event publisher) can republish the community definition.
### 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:...", "..."] }`
### Security Considerations
- **Author filtering**: Clients MUST filter community definitions by `authors` to prevent impersonation.
- **Chain validation is required**: Never trust kind `8` events without walking the authority chain.
- **Badge d-tag uniqueness**: Use `<community-d-tag>-<rank-name>` to prevent cross-community collisions.
- **Badge acceptance is cosmetic**: NIP-58 kind `10008`/`30008` events have no effect on chain validation.
### Dependencies
- [NIP-09](https://github.com/nostr-protocol/nips/blob/master/09.md) -- Event Deletion Request
- [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-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
---
## Kind 0 Extension: Avatar Shape
### Summary
@@ -351,13 +873,3 @@ NIP-44 encrypted personal letters with visual stationery, hand-drawn stickers, d
**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.
### Blobbi Virtual Pet (Kinds 31124, 14919, 14920, 14921, 11125)
**Author:** Danifra
**Spec:** https://github.com/Danidfra/nostr-pet/blob/production/NIP.md
**App:** https://nostr-pet.vercel.app
**See also:** [Blobbi tag schema](docs/blobbi/blobbi-tag-schema.md) (Ditto-specific integration details)
NIP-BB defines a virtual pet lifecycle on Nostr. Kind 31124 (addressable) holds the current pet state across three stages (egg, baby, adult) with stats, appearance, and personality traits. Kind 14919 logs individual interactions, kind 14920 records breeding events, kind 14921 stores immutable lifecycle records, and kind 11125 (replaceable) holds the owner's profile with coins, achievements, and inventory.
+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 (Divines), 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
+3 -3
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.4.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.
+4 -1
View File
@@ -11,9 +11,12 @@ 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-share')
implementation project(':capacitor-status-bar')
implementation project(':capgo-capacitor-autofill-save-password')
implementation project(':capacitor-secure-storage-plugin')
}
+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,6 +31,7 @@ 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;
@@ -60,7 +68,8 @@ public class DittoNotificationPlugin extends Plugin {
if (userPubkey != null && relayUrlsRaw != null) {
SharedPreferences.Editor editor = prefs.edit()
.putString("userPubkey", userPubkey)
.putString("relayUrls", relayUrlsRaw);
.putString("relayUrls", relayUrlsRaw)
.putString("notificationStyle", notificationStyle);
if (enabledKindsRaw != null) {
editor.putString("enabledKinds", enabledKindsRaw);
}
@@ -70,13 +79,46 @@ public class DittoNotificationPlugin extends Plugin {
editor.remove("authors");
}
editor.apply();
Log.d(TAG, "Configured: pubkey=" + userPubkey.substring(0, 8) + "..., relays=" + relayUrlsRaw + ", kinds=" + enabledKindsRaw + ", authors=" + (authorsRaw != null ? authorsRaw.length() + " chars" : "all"));
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;
}
}
}
@@ -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>
+11 -2
View File
@@ -8,11 +8,20 @@ project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/
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-share'
project(':capacitor-share').projectDir = new File('../node_modules/@capacitor/share/android')
include ':capacitor-status-bar'
project(':capacitor-status-bar').projectDir = new File('../node_modules/@capacitor/status-bar/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 `import.meta.env.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 |
-254
View File
@@ -1,254 +0,0 @@
# Blobbi Tag Schema
> **Product Specification** - This document is the canonical source of truth for Blobbi tag definitions.
> The runtime schema at `src/lib/blobbi-tag-schema.ts` MUST align with this spec.
## Overview
Blobbi events (Kind 31124) use tags to store all state data. This document defines:
- All valid tags and their purposes
- Which tags are required vs optional
- Which tags persist across stage transitions
- Which tags should be removed during transitions
- Deprecated tags that should be filtered out
---
## Tag Categories
### 1. System / Metadata Tags
Core protocol-level tags required for event identification and ecosystem membership.
| Tag | Required | Stages | Persistent | Source | Format | Description |
|-----|----------|--------|------------|--------|--------|-------------|
| `d` | **Yes** | egg, baby, adult | Yes | system | `blobbi-{pubkeyPrefix12}-{petId10}` | Unique identifier (addressable event d-tag) |
| `b` | **Yes** | egg, baby, adult | Yes | system | `blobbi:ecosystem:v1` | Ecosystem namespace identifier |
| `t` | **Yes** | egg, baby, adult | Yes | system | `blobbi` | Topic tag for discoverability |
| `client` | No | egg, baby, adult | Yes | system | `blobbi` | Client identifier |
### 2. Core Identity Tags
Tags that define the Blobbi's unique identity. These MUST be preserved across all transitions.
| Tag | Required | Stages | Persistent | Source | Format | Description |
|-----|----------|--------|------------|--------|--------|-------------|
| `name` | **Yes** | egg, baby, adult | Yes | user | string | Display name (set during adoption) |
| `seed` | **Yes** | egg, baby, adult | Yes | system | 64 hex chars | Deterministic seed for visual traits |
| `generation` | No | egg, baby, adult | Yes | system | positive integer | Lineage generation (default: 1) |
**Important**: The `seed` is derived once at creation using `sha256("blobbi:v1|{pubkey}:{d}:{createdAt}")` and MUST NEVER be recomputed.
### 3. Visual Trait Tags
Tags derived deterministically from the seed. These are stored explicitly for fast rendering and compatibility.
| Tag | Required | Stages | Persistent | Source | Format | Description |
|-----|----------|--------|------------|--------|--------|-------------|
| `base_color` | No | egg, baby, adult | Yes | generated | CSS hex (e.g., `#F59E0B`) | Primary color |
| `secondary_color` | No | egg, baby, adult | Yes | generated | CSS hex | Secondary/accent color |
| `eye_color` | No | egg, baby, adult | Yes | generated | CSS hex | Eye color |
| `pattern` | No | egg, baby, adult | Yes | generated | `solid\|spotted\|striped\|gradient` | Visual pattern type |
| `special_mark` | No | egg, baby, adult | Yes | generated | `none\|star\|heart\|sparkle\|blush` | Special decoration |
| `size` | No | egg, baby, adult | Yes | generated | `small\|medium\|large` | Size category |
**Regenerable**: These tags CAN be regenerated from the seed if missing. However, they should be preserved when present.
### 4. Personality / Trait Tags
Character traits that define the Blobbi's personality. These are generated at creation and MUST persist.
| Tag | Required | Stages | Persistent | Source | Format | Description |
|-----|----------|--------|------------|--------|--------|-------------|
| `personality` | No | egg, baby, adult | Yes | generated | string | Core personality type |
| `trait` | No | egg, baby, adult | Yes | generated | string | Character trait modifier |
| `favorite_food` | No | egg, baby, adult | Yes | generated | string | Preferred food type |
| `voice_type` | No | egg, baby, adult | Yes | generated | string | Voice characteristic |
| `mood` | No | egg, baby, adult | Yes | computed | string | Current emotional state |
**Not Regenerable**: These tags are generated once and MUST be preserved. Do NOT invent values for existing Blobbis that lack these tags.
### 5. Stat Tags
Numeric values representing the Blobbi's current condition. These are actively computed and change frequently.
| Tag | Required | Stages | Persistent | Source | Format | Default | Description |
|-----|----------|--------|------------|--------|--------|---------|-------------|
| `hunger` | No | egg, baby, adult | No | computed | 1-100 | 100 | Fullness level |
| `happiness` | No | egg, baby, adult | No | computed | 1-100 | 100 | Happiness level |
| `health` | No | egg, baby, adult | No | computed | 1-100 | 100 | Health level |
| `hygiene` | No | egg, baby, adult | No | computed | 1-100 | 100 | Cleanliness level |
| `energy` | No | egg, baby, adult | No | computed | 1-100 | 100 | Energy level |
**Stage Transition Behavior**:
- **Hatch (egg → baby)**: `health` inherited from egg, others reset to 100
- **Evolve (baby → adult)**: All stats inherited from baby (after decay)
### 6. State / Lifecycle Tags
Tags that track the Blobbi's current lifecycle state.
| Tag | Required | Stages | Persistent | Source | Format | Description |
|-----|----------|--------|------------|--------|--------|-------------|
| `stage` | **Yes** | egg, baby, adult | No | system | `egg\|baby\|adult` | Current lifecycle stage |
| `state` | **Yes** | egg, baby, adult | No | system | `active\|sleeping\|hibernating\|incubating\|evolving` | Activity state |
| `last_interaction` | **Yes** | egg, baby, adult | No | system | Unix timestamp | Last user action |
| `last_decay_at` | No | egg, baby, adult | No | system | Unix timestamp | Decay checkpoint |
**State Constraints**:
- `incubating` is only valid for `stage: egg`
- `evolving` is only valid for `stage: baby`
- After hatch/evolve completes, `state` MUST be set to `active`
### 7. Task System Tags
Temporary tags used during incubation and evolution processes. These are REMOVED after stage transitions.
| Tag | Required | Stages | Persistent | Source | Format | Description |
|-----|----------|--------|------------|--------|--------|-------------|
| `state_started_at` | No | egg, baby | No | system | Unix timestamp | When incubating/evolving started |
| `task` | No | egg, baby | No | computed | `["task", "name:value"]` | Task progress (multiple allowed) |
| `task_completed` | No | egg, baby | No | computed | `["task_completed", "name"]` | Completed tasks (multiple allowed) |
**Transition Behavior**: ALL task system tags MUST be removed when hatch or evolve completes.
### 8. Progression Tags
Long-term progress tracking that persists across all stages.
| Tag | Required | Stages | Persistent | Source | Format | Default | Description |
|-----|----------|--------|------------|--------|--------|---------|-------------|
| `experience` | No | egg, baby, adult | Yes | computed | non-negative int | 0 | Total XP |
| `care_streak` | No | egg, baby, adult | Yes | computed | non-negative int | 0 | Consecutive care days |
### 9. Social / Flag Tags
User preferences and computed flags.
| Tag | Required | Stages | Persistent | Source | Format | Default | Description |
|-----|----------|--------|------------|--------|--------|---------|-------------|
| `breeding_ready` | No | egg, baby, adult | Yes | computed | `true\|false` | false | Breeding eligibility |
### 10. Evolution Tags
Tags specific to adult Blobbis.
| Tag | Required | Stages | Persistent | Source | Format | Description |
|-----|----------|--------|------------|--------|--------|-------------|
| `adult_type` | No | adult | Yes | computed | string | Evolution form type |
### 11. Extension Tags
Optional tags for themes and crossover features.
| Tag | Required | Stages | Persistent | Source | Format | Description |
|-----|----------|--------|------------|--------|--------|-------------|
| `theme` | No | egg, baby, adult | Yes | system | string (e.g., `divine`) | Theme variant |
| `crossover_app` | No | egg, baby, adult | Yes | system | string (e.g., `divine`) | Crossover app identifier |
---
## Deprecated Tags
These tags are from legacy versions and MUST be removed when republishing events.
| Tag | Reason | Replaced By |
|-----|--------|-------------|
| `shell_integrity` | Eggs use standard `health` stat | `health` |
| `egg_temperature` | Warmth handled via UI props | N/A |
| `incubation_progress` | Replaced by task system | `task`, `task_completed` |
| `egg_status` | Replaced by standard state | `state` |
| `fees` | Removed | N/A |
| `incubation_time` | Uses state_started_at | `state_started_at` |
| `start_incubation` | Uses state_started_at | `state_started_at` |
| `interact_6_progress` | Legacy interaction tracking | `["task", "interactions:N"]` |
---
## Stage Transition Rules
### Hatch (egg → baby)
**Tags to REMOVE**:
- `task`
- `task_completed`
- `state_started_at`
**Tags to UPDATE**:
- `stage``baby`
- `state``active`
- `hunger``100`
- `happiness``100`
- `hygiene``100`
- `energy``100`
- `health` → (inherited from egg after decay)
- `last_interaction` → current timestamp
- `last_decay_at` → current timestamp
**Tags to PRESERVE (all persistent tags)**:
- All system tags (`d`, `b`, `t`, `client`)
- All identity tags (`name`, `seed`, `generation`)
- All visual tags (colors, pattern, size)
- All personality tags (if present)
- All progression tags (`experience`, `care_streak`)
- All social tags (`breeding_ready`)
- All extension tags (`theme`, `crossover_app`)
### Evolve (baby → adult)
**Tags to REMOVE**:
- `task`
- `task_completed`
- `state_started_at`
**Tags to UPDATE**:
- `stage``adult`
- `state``active`
- All stats → (inherited from baby after decay)
- `last_interaction` → current timestamp
- `last_decay_at` → current timestamp
**Tags to PRESERVE (all persistent tags)**:
- Same as hatch, plus all stats are inherited (not reset)
**Tags to ADD (optional)**:
- `adult_type` → computed based on care history
---
## Migration Rules
When migrating legacy Blobbis to canonical format:
1. **Always preserve existing values** - Do not regenerate tags that already exist
2. **Generate missing required tags** - Derive `seed` if missing using the legacy event's `created_at`
3. **Remove deprecated tags** - Filter out all tags in the deprecated list
4. **Repair visual tags** - Regenerate from seed if missing (these are regenerable)
5. **Do NOT invent personality tags** - If `personality`, `trait`, etc. don't exist, leave them empty
---
## Validation Rules
A valid Blobbi event MUST have:
- `d` tag in canonical format
- `b` tag = `blobbi:ecosystem:v1`
- `t` tag = `blobbi`
- `name` tag (non-empty)
- `seed` tag (64 hex chars)
- `stage` tag (valid value)
- `state` tag (valid value)
- `last_interaction` tag (valid timestamp)
---
## Implementation Checklist
When implementing any flow that modifies Blobbi tags:
- [ ] Start from `canonical.allTags` as the base
- [ ] Remove only task-specific tags (`task`, `task_completed`, `state_started_at`)
- [ ] Preserve ALL persistent tags (identity, visual, personality, progression, social, extension)
- [ ] Filter out deprecated tags
- [ ] Update only the tags that need to change
- [ ] Validate required tags are present
+1 -1
View File
@@ -8,7 +8,7 @@ import htmlParser from "@html-eslint/parser";
import customRules from "./eslint-rules/index.js";
export default tseslint.config(
{ ignores: ["dist", "android"] },
{ ignores: ["dist", "android", "ios"] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ["**/*.{ts,tsx}"],
+22 -20
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' blob: 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)">
<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.4.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.4.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: []
)
}
}
+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
)
}
}
}
+15 -3
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>
@@ -48,8 +48,20 @@
<key>UIViewControllerBasedStatusBarAppearance</key>
<true/>
<key>NSPhotoLibraryUsageDescription</key>
<string>Ditto needs access to your photo library to upload images to your posts and profile.</string>
<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>Ditto needs access to your microphone to record voice messages.</string>
<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)
}
}
+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"]
))
}
}
}
+8 -2
View File
@@ -14,9 +14,12 @@ let package = Package(
.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: "CapacitorShare", path: "../../../node_modules/@capacitor/share"),
.package(name: "CapacitorStatusBar", path: "../../../node_modules/@capacitor/status-bar")
.package(name: "CapgoCapacitorAutofillSavePassword", path: "../../../node_modules/@capgo/capacitor-autofill-save-password"),
.package(name: "CapacitorSecureStoragePlugin", path: "../../../node_modules/capacitor-secure-storage-plugin")
],
targets: [
.target(
@@ -26,9 +29,12 @@ let package = Package(
.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: "CapacitorShare", package: "CapacitorShare"),
.product(name: "CapacitorStatusBar", package: "CapacitorStatusBar")
.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;
}
}
+1353 -260
View File
File diff suppressed because it is too large Load Diff
+28 -8
View File
@@ -1,12 +1,13 @@
{
"name": "ditto",
"name": "agora",
"private": true,
"version": "2.4.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"
},
@@ -14,12 +15,16 @@
"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/share": "^8.0.1",
"@capacitor/status-bar": "^8.0.0",
"@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",
@@ -64,8 +69,10 @@
"@milkdown/prose": "^7.20.0",
"@milkdown/react": "^7.20.0",
"@milkdown/utils": "^7.20.0",
"@nostrify/nostrify": "^0.51.0",
"@nostrify/react": "^0.4.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",
@@ -95,15 +102,19 @@
"@radix-ui/react-toggle": "^1.1.0",
"@radix-ui/react-toggle-group": "^1.1.0",
"@radix-ui/react-tooltip": "^1.2.8",
"@samthomson/nostr-messaging": "^0.17.1",
"@scure/bip39": "^1.6.0",
"@sentry/react": "^10.42.0",
"@tanstack/react-query": "^5.56.2",
"@unhead/addons": "^2.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",
@@ -111,9 +122,14 @@
"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": "^19.2.4",
@@ -122,7 +138,9 @@
"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",
@@ -147,7 +165,9 @@
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.6.3",
"@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": "^19.2.14",
@@ -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"
]
}
}
}]
]
+2 -246
View File
@@ -1,251 +1,7 @@
# Changelog
## [2.4.0] - 2026-04-02
## [1.0.0] - 2026-04-30
### Added
- First-hatch tour: a guided experience for hatching your very first Blobbi egg, with progressive crack animations, an inline card flow, and a reveal moment
- Customizable bottom bar: rearrange or hide any item in the navigation bar to make Ditto feel like yours
- Mission surface card in the feed that surfaces your active quests at a glance
### Changed
- Missions redesigned as a quest board with collapsible cards and a lighter aesthetic
- "Edit Profile" mission now completes when you update any profile field, not just wall-specific edits
- Media tab on profiles now shows only photos, videos, and other media -- not plain text posts
- Blobbi onboarding state now syncs to your profile so it follows you across devices
### Fixed
- Notification dot no longer reappears after you've already marked notifications as read
- Dialogs no longer fly up when the mobile keyboard opens
## [2.3.1] - 2026-04-02
### Changed
- Drafts now save instantly to your device and sync to relays in the background, with a cloud sync indicator so you always know the status
### Fixed
- Dialogs stay visible above the keyboard on mobile instead of getting hidden behind it
- Editing an existing article no longer incorrectly warns about a duplicate slug
- Switching between rich text and markdown source mode no longer clears your content
- Fix crash when editing in markdown source mode
## [2.3.0] - 2026-04-02
### Added
- In-app article editor with a rich text toolbar, image uploads, auto-saving drafts, and a "My Articles" tab to manage drafts and published articles
### Fixed
- Custom emoji no longer stretch to fill their container
- Mobile drawer now closes when tapping footer links like Changelog or Privacy
- Logged-out users now default to the global tab on content feeds instead of seeing an empty follows tab
## [2.2.11] - 2026-04-02
### Fixed
- Fix crash caused by the "What's new" toast firing outside the router
## [2.2.10] - 2026-04-02
### Added
- App cards for Nostr apps now display in feeds and detail pages with hero images, icons, and quick-launch buttons
- "What's new" toast appears after an app update with a changelog preview and link to the full changelog
### Changed
- Changelog page redesigned with a hero section for the latest release, collapsible older entries, and category icons inline with each item
### Fixed
- Compose box now fully resets to its collapsed state after posting, including poll options and media trays
## [2.2.9] - 2026-04-01
### Added
- Emoji pack creator and editor with drag-and-drop image upload, auto-generated identifiers, and description field
- Blobbi companions now appear in feeds and post detail pages
### Changed
- Blobbi shop redesigned with a tile layout and instant buy -- no more categories or accessory tabs
- Emoji packs without any valid emojis are now hidden from feeds
- Custom emoji shortcode collisions across packs are automatically resolved with pack-prefixed names
## [2.2.8] - 2026-04-01
### Added
- Full threaded reply trees on post detail pages with collapsible deep branches and "Show X more replies" for sibling threads
- Broadcast button in the Event JSON dialog to re-publish any event to your relays
### Changed
- My Badges tab overhauled with drag-and-drop reordering, a scrollable list, and a showcase-style carousel for pending badges
- Encrypted letter envelopes now show the mailing side first (sender and recipient), then flip to reveal the wax seal
- Blobbi companions are more expressive -- richer status reactions, sleeping visuals, and body effects like dirt and hunger cues
### Fixed
- Notification dot not clearing after marking notifications as read
- Followers/following modal staying open after navigating to a profile
## [2.2.7] - 2026-03-31
### Fixed
- Nushu script in encrypted letters now renders correctly on Android and iOS
## [2.2.6] - 2026-03-31
### Added
- Encrypted letters now appear as interactive 3D envelopes with Nushu script -- flip and open them to reveal the secret writing inside
- Zap receipts and profile metadata events now render in feeds and detail pages
- Remote signer callback page for login flows with Amber, Primal, and other signing apps
### Changed
- Post action buttons extracted into a reusable PostActionBar component
- Badge detail page streamlined with unified tab bar
### Fixed
- Hashtags now support accented and Unicode characters
- Letter compose opens correctly from notifications and the letters page
- Letter font picker loads fonts so each option previews in the correct typeface
- Zap comment positioned inside the right column instead of floating with offset
- Safe-area padding on pinned SubHeaderBar only applies when scrolled to top
## [2.2.5] - 2026-03-30
### Fixed
- Crash when dragging profile tabs to reorder them
## [2.2.4] - 2026-03-30
### Changed
- Profiles now have an emoji reaction button instead of a zap button -- express yourself with any emoji or custom emoji right on someone's profile
- Zap moved to the profile overflow menu so it's still one tap away
### Fixed
- Crash on the notifications page caused by malformed badge award tags
- Deleting a badge now also deletes all awards you issued for it
- Custom emoji reactions missing their image tag no longer render as broken shortcodes
- Deletion requests for addressable events now include both `e` and `a` tags for broader relay compatibility
- Profile reactions no longer collapse into a single grouped notification
- Oversized reaction emoji in comment context headers
## [2.2.3] - 2026-03-30
### Added
- Letters now have an overflow menu, reply button, and a grid layout for browsing
- Independent feed toggles for comments and generic reposts in content settings
- Sidebar items are now visible to logged-out users so newcomers can explore everything
### Changed
- Compose textarea expands smoothly as you type instead of snapping to a new height
- Blobbi stickers auto-shrink near card edges and clip cleanly at rounded boundaries
### Fixed
- Feed gaps when replies are disabled no longer cause missing posts
- Avatar shape no longer flashes on load
- Top bar arc no longer flickers during navigation transitions
- Letter drawing-only sends, sticker drag bounds, and theme event preservation
- Notification rendering for badges and letters
- Duplicate React keys in content settings
- Layout rendering warning when switching views
## [2.2.2] - 2026-03-29
### Added
- Dedicated photo upload flow for sharing photos
- Pull-to-refresh on all feed pages
- 3D tilt effect on badge images -- hover over badges to see them pop
- Multi-select badge awarding with indicators for already-sent badges
- Badge list recovery dialog for restoring profile badge lists
- Compact badge row preview in embedded profile badges events
- Custom emoji usage tracking so your most-used custom emojis appear in the quick-react bar
- Release notes now included in Zapstore publishing
- Changelog link in the app footer
### Changed
- "Vines" renamed to "Divines" everywhere in the app
- Custom emojis appear first in the emoji picker, right after recent
- Threaded comment view now shows the parent event as a NoteCard with kind action headers
### Fixed
- Delete post dialog no longer freezes the feed on desktop
- Amber login on Android now properly retries when returning from the background
- Key downloads on Android save to the correct location
- Custom emoji SVGs render correctly in the emoji-mart picker
- Double-tap reactions now properly show the emoji on the post
- Emoji shortcode autocomplete text and highlight colors
- Profile skeleton no longer flickers for brand-new users with no metadata
- Event links now route correctly for all event types
- Badge notifications are now clickable
- Custom profile tab form no longer retains fields from a previously edited tab
- Double line under profile tabs in edit mode
- Inconsistent use of "geocache" vs "treasures" terminology
- Search page "N new posts" pill no longer shows unfiltered count
- Stale-cache overwrites in replaceable event mutations
- Click-through on delete confirmation and note menu items
## [2.2.1] - 2026-03-28
### Fixed
- New posts no longer cause scroll jumps -- they buffer while you're reading and appear with a tap
- Mobile header no longer shows double-layered backgrounds on notched devices
- Pinned tabs stay properly positioned when scrolling on mobile
- Signer approval toasts no longer fire in rapid succession on unstable connections
- Toasts are easier to swipe away on mobile
- Content warnings now blur thumbnails in the media grid
## [2.2.0] - 2026-03-28
### Added
- Blobbi virtual pets -- adopt an egg, hatch it, evolve it into one of 16 adult forms, and care for it with feeding, cleaning, medicine, music, and singing
- Blobbi companion that follows you around the app, tracks your cursor, blinks, expresses emotions, and reacts to what you're doing
- Blobbi shop and inventory system with items that affect your pet's stats
- Daily missions with reroll, care streaks, and stage-based rewards
- Immersive full-screen divines experience on both mobile and desktop with floating controls
- Relay information panel on the network settings page
- Link preview cards now display inside quoted posts instead of raw URLs
- Nsec paste guard warns you before accidentally pasting private keys outside the login field
- Remote signer UX improvements for Amber users on Android
- Badge awards now trigger push notifications
- Badges display in profile bio section with a "Give badge" option in the profile menu
### Changed
- Notification "Mentions" tab now shows only pure mentions, filtering out replies
- Notification preferences ("only from people I follow") now properly apply to push notifications, native Android notifications, and the unread dot
- Upgraded from React 18 to React 19
- Reduced initial bundle size by ~50% with improved code splitting and lazy loading
### Fixed
- Zapping Primal users no longer produces an error
- Hashtag feeds now match case-insensitively for parity with search results
- Mobile top bar arc no longer lingers on pages without tabs
- Give Badge dialog and profile menu action handlers
## [2.1.1] - 2026-03-27
### Added
- Emoji picker and shortcode autocomplete in zap comment box
- Zap button on badge detail view
- Theme descriptions now display on "updated their theme" posts and detail pages
- Badge thumbnail previews in award notifications
- Letter notifications with envelope card preview
- Kind-specific labels in notification text instead of generic "post"
### Fixed
- Compose modal no longer closes when dismissing emoji picker on mobile
- Compose preview overflow is now scrollable in modal
- Toast notifications swipe up to dismiss on mobile instead of sideways
- File downloads and URL opening work correctly on iOS
- Badges page no longer shows infinite skeleton when logged out
## [2.1.0] - 2026-03-26
### Added
- Letters -- a Wii Mail-inspired inbox for sending decorated letters to friends, complete with custom stationery, hand-drawn stickers, emoji stickers, fonts, and a send animation with envelope and wax seal
- Attach a color moment or theme to your letter as a gift -- recipients can tap to apply it instantly
- Stationery picker pulls from your color moments, followed users' themes, and built-in presets
- Freehand drawing canvas for creating one-of-a-kind sticker doodles
- Letters page added to the sidebar with a custom mailbox icon
## [2.0.1] - 2026-03-26
### Added
- Tap the version number in settings to see what's new
## [2.0.0] - 2026-03-26
Initial release of Ditto 2.0 -- a complete rewrite of Ditto.
- 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';
+4 -4
View File
@@ -33,7 +33,7 @@ function parseArgs() {
const args = process.argv.slice(2);
const result = {
relays: [],
name: 'Ditto',
name: 'Agora',
timeout: 300, // seconds
};
@@ -54,9 +54,9 @@ function parseArgs() {
Options:
--relay <url> Relay URL for NIP-46 communication (repeatable)
Default: wss://relay.ditto.pub
Default: wss://relay.agora.spot
--name <name> Application name shown to the signer
Default: Ditto
Default: Agora
--timeout <sec> How long to wait for signer approval (seconds)
Default: 300 (5 minutes)
--help, -h Show this help message
@@ -66,7 +66,7 @@ Options:
}
if (result.relays.length === 0) {
result.relays.push('wss://relay.ditto.pub');
result.relays.push('wss://relay.agora.spot');
}
return result;
+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(', ')}`);
}
}
+64 -53
View File
@@ -1,15 +1,14 @@
// 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";
@@ -22,16 +21,12 @@ 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 { DittoConfigSchema, type DittoConfig } from "@/lib/schemas";
import { EmotionDevProvider } from "@/blobbi/dev/EmotionDevContext";
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: false,
protocolMode: PROTOCOL_MODE.NIP04_OR_NIP17,
};
const head = createHead({
plugins: [InferSeoMetaPlugin()],
});
@@ -48,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: [],
@@ -90,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,
@@ -110,26 +98,29 @@ const hardcodedConfig: AppConfig = {
feedIncludePodcastTrailers: true,
showDevelopment: true,
feedIncludeDevelopment: true,
showCommunities: true,
feedIncludeCommunities: true,
showBadges: true,
showBadgeDefinitions: true,
showProfileBadges: true,
feedIncludeBadgeDefinitions: true,
feedIncludeProfileBadges: true,
feedIncludeVanish: true,
feedIncludeBlobbi: true,
followsFeedShowReplies: true,
},
sidebarOrder: [
"wallet",
"verified",
"actions",
"polls",
"world",
"badges",
"feed",
"notifications",
"search",
"themes",
"letters",
"badges",
"blobbi",
"theme",
"messages",
"communities",
"profile",
"settings",
"help",
],
nip85StatsPubkey:
"5f68e85ee174102ca8978eef302129f081f03456c884185d5ec1c1224ab633ea",
@@ -148,45 +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: '',
};
/**
* Parse and validate build-time ditto.json overrides from the env string.
* 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 parseDittoConfig(): DittoConfig {
function parseBuildConfig(): BuildConfig {
try {
const json = JSON.parse(import.meta.env.DITTO_CONFIG);
const encodedConfig = import.meta.env.APP_CONFIG ?? import.meta.env.DITTO_CONFIG;
const json = JSON.parse(encodedConfig);
if (!json) return {};
return DittoConfigSchema.parse(json);
return BuildConfigSchema.parse(json);
} catch {
return {};
}
}
/**
* Merge hardcoded defaults with build-time ditto.json overrides.
* 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 dittoConfig = parseDittoConfig();
const buildConfig = parseBuildConfig();
const defaultConfig: AppConfig = {
...hardcodedConfig,
...dittoConfig,
feedSettings: { ...hardcodedConfig.feedSettings, ...dittoConfig.feedSettings },
...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
});
}
}, []);
@@ -197,21 +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}>
<EmotionDevProvider>
<TooltipProvider>
<InitialSyncGate>
<AppRouter />
</InitialSyncGate>
</TooltipProvider>
</EmotionDevProvider>
</DMProvider>
<SparkWalletProvider>
<DMProviderWrapper>
<TooltipProvider>
<InitialSyncGate>
<AppRouter />
</InitialSyncGate>
</TooltipProvider>
</DMProviderWrapper>
</SparkWalletProvider>
</NWCProvider>
</NostrProvider>
</NostrLoginProvider>
+22 -115
View File
@@ -4,7 +4,6 @@ import { AudioNavigationGuard } from "@/components/AudioNavigationGuard";
import { DeepLinkHandler } from "@/components/DeepLinkHandler";
import { MinimizedAudioBar } from "@/components/MinimizedAudioBar";
import { AudioPlayerProvider } from "@/contexts/AudioPlayerContext";
import { BlobbiActionsProvider } from "@/blobbi/companion/interaction/BlobbiActionsProvider";
import { sidebarItemIcon } from "@/lib/sidebarItems";
import { Toaster } from "./components/ui/toaster";
import { MainLayout } from "./components/MainLayout";
@@ -17,29 +16,22 @@ import { getExtraKindDef } from "./lib/extraKinds";
// Critical-path pages: eagerly loaded (landing + fallback)
import Index from "./pages/Index";
import NotFound from "./pages/NotFound";
// Lazy-loaded companion layer (~450K code-split)
const BlobbiCompanionLayer = lazy(() => import("@/blobbi/companion").then(m => ({ default: m.BlobbiCompanionLayer })));
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 })));
// Lazy-loaded emoji pack dialog
const EmojiPackDialog = lazy(() => import("@/components/EmojiPackDialog").then(m => ({ default: m.EmojiPackDialog })));
// HomePage eagerly imported all page components; now lazy-loaded
const HomePage = lazy(() => import("./pages/HomePage").then(m => ({ default: m.HomePage })));
// 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 AIChatPage = lazy(() => import("./pages/AIChatPage").then(m => ({ default: m.AIChatPage })));
const ArchivePage = lazy(() => import("./pages/ArchivePage").then(m => ({ default: m.ArchivePage })));
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 BlobbiPage = lazy(() => import("./pages/BlobbiPage").then(m => ({ default: m.BlobbiPage })));
const BlueskyPage = lazy(() => import("./pages/BlueskyPage").then(m => ({ default: m.BlueskyPage })));
const CommunitiesPage = lazy(() => import("./pages/CommunitiesPage").then(m => ({ default: m.CommunitiesPage })));
const BookmarksPage = lazy(() => import("./pages/BookmarksPage").then(m => ({ default: m.BookmarksPage })));
const BooksPage = lazy(() => import("./pages/BooksPage").then(m => ({ default: m.BooksPage })));
const ChangelogPage = lazy(() => import("./pages/ChangelogPage").then(m => ({ default: m.ChangelogPage })));
const ContentPage = lazy(() => import("./pages/ContentPage").then(m => ({ default: m.ContentPage })));
const ContentSettingsPage = lazy(() => import("./pages/ContentSettingsPage").then(m => ({ default: m.ContentSettingsPage })));
@@ -55,37 +47,29 @@ const LetterComposePage = lazy(() => import("./pages/LetterComposePage").then(m
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 MusicFeedPage = lazy(() => import("./pages/MusicFeedPage").then(m => ({ default: m.MusicFeedPage })));
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 PodcastsFeedPage = lazy(() => import("./pages/PodcastsFeedPage").then(m => ({ default: m.PodcastsFeedPage })));
const PrivacyPolicyPage = lazy(() => import("./pages/PrivacyPolicyPage").then(m => ({ default: m.PrivacyPolicyPage })));
const ProfileSettings = lazy(() => import("./pages/ProfileSettings").then(m => ({ default: m.ProfileSettings })));
const RelayPage = lazy(() => import("./pages/RelayPage").then(m => ({ default: m.RelayPage })));
const SearchPage = lazy(() => import("./pages/SearchPage").then(m => ({ default: m.SearchPage })));
const SettingsPage = lazy(() => import("./pages/SettingsPage").then(m => ({ default: m.SettingsPage })));
const ThemesPage = lazy(() => import("./pages/ThemesPage").then(m => ({ default: m.ThemesPage })));
const TreasuresPage = lazy(() => import("./pages/TreasuresPage").then(m => ({ default: m.TreasuresPage })));
const TrendsPage = lazy(() => import("./pages/TrendsPage").then(m => ({ default: m.TrendsPage })));
const UserListsPage = lazy(() => import("./pages/UserListsPage").then(m => ({ default: m.UserListsPage })));
const VideosFeedPage = lazy(() => import("./pages/VideosFeedPage").then(m => ({ default: m.VideosFeedPage })));
const VinesFeedPage = lazy(() => import("./pages/VinesFeedPage").then(m => ({ default: m.VinesFeedPage })));
const WalletSettingsPage = lazy(() => import("./pages/WalletSettingsPage").then(m => ({ default: m.WalletSettingsPage })));
const WebxdcFeedPage = lazy(() => import("./pages/WebxdcFeedPage").then(m => ({ default: m.WebxdcFeedPage })));
const WikipediaPage = lazy(() => import("./pages/WikipediaPage").then(m => ({ default: m.WikipediaPage })));
const 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() {
@@ -107,26 +91,6 @@ function PollsFeedPage() {
);
}
/** Emoji feed page with a FAB that opens the emoji pack creation dialog. */
function EmojiFeedPage() {
const [composeOpen, setComposeOpen] = useState(false);
return (
<>
<KindFeedPage
kind={emojisDef.kind}
title={emojisDef.label}
icon={sidebarItemIcon("emojis", "size-5")}
onFabClick={() => setComposeOpen(true)}
/>
{composeOpen && (
<Suspense fallback={null}>
<EmojiPackDialog open={composeOpen} onOpenChange={setComposeOpen} />
</Suspense>
)}
</>
);
}
/** Redirects /profile to the user's canonical profile URL (nip05 or npub). */
function ProfileRedirect() {
const { user, metadata } = useCurrentUser();
@@ -145,24 +109,23 @@ export function AppRouter() {
<AudioNavigationGuard />
<DeepLinkHandler />
<ScrollToTop />
<BlobbiActionsProvider>
<Suspense fallback={null}>
<BlobbiCompanionLayer />
</Suspense>
</BlobbiActionsProvider>
<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 />} />
@@ -171,6 +134,7 @@ export function AppRouter() {
path="/settings/notifications"
element={<NotificationSettings />}
/>
<Route path="/settings/messaging" element={<MessagingSettingsPage />} />
<Route
path="/settings/advanced"
element={<AdvancedSettingsPage />}
@@ -180,38 +144,7 @@ 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
@@ -225,41 +158,12 @@ export function AppRouter() {
/>
}
/>
<Route
path="/decks"
element={
<KindFeedPage
kind={decksDef.kind}
title={decksDef.label}
icon={sidebarItemIcon("decks", "size-5")}
/>
}
/>
<Route path="/emojis" element={<EmojiFeedPage />} />
<Route
path="/development"
element={
<KindFeedPage
kind={[
developmentDef.kind,
...(developmentDef.extraFeedKinds ?? []),
]}
title={developmentDef.label}
icon={sidebarItemIcon("development", "size-5")}
showFAB={false}
/>
}
/>
<Route path="/themes" element={<ThemesPage />} />
<Route path="/bookmarks" element={<BookmarksPage />} />
<Route path="/ai-chat" element={<AIChatPage />} />
<Route path="/blobbi" element={<BlobbiPage />} />
<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 />} />
@@ -273,6 +177,9 @@ export function AppRouter() {
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 />} />
@@ -1,614 +0,0 @@
// src/blobbi/actions/components/BlobbiActionInventoryModal.tsx
import { useMemo, useState } from 'react';
import { Loader2, ShoppingBag, Minus, Plus, X } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogClose,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import type { BlobbiCompanion, BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
import { cn } from '@/lib/utils';
import {
filterInventoryByAction,
previewStatChanges,
previewMedicineForEgg,
previewCleanForEgg,
canUseAction,
getStageRestrictionMessage,
ACTION_METADATA,
type InventoryAction,
type ResolvedInventoryItem,
type EggStatPreview,
} from '../lib/blobbi-action-utils';
interface BlobbiActionInventoryModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
action: InventoryAction;
companion: BlobbiCompanion;
profile: BlobbonautProfile | null;
/** Called when user confirms using item(s). Now accepts quantity. */
onUseItem: (itemId: string, quantity: number) => void;
onOpenShop: () => void;
isUsingItem: boolean;
usingItemId: string | null;
}
export function BlobbiActionInventoryModal({
open,
onOpenChange,
action,
companion,
profile,
onUseItem,
onOpenShop,
isUsingItem,
usingItemId,
}: BlobbiActionInventoryModalProps) {
const actionMeta = ACTION_METADATA[action];
// State for confirmation dialog
const [selectedItem, setSelectedItem] = useState<ResolvedInventoryItem | null>(null);
const [quantity, setQuantity] = useState(1);
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
// Filter inventory by action type, respecting egg-compatible effects
const availableItems = useMemo(() => {
if (!profile) return [];
return filterInventoryByAction(profile.storage, action, { stage: companion.stage });
}, [profile, action, companion.stage]);
// Check stage restrictions for this specific action
const canUse = canUseAction(companion, action);
const stageMessage = getStageRestrictionMessage(companion, action);
const isEmpty = availableItems.length === 0;
const handleSelectItem = (item: ResolvedInventoryItem) => {
if (isUsingItem) return;
setSelectedItem(item);
setQuantity(1);
setShowConfirmDialog(true);
};
const handleConfirmUse = () => {
if (!selectedItem || isUsingItem) return;
onUseItem(selectedItem.itemId, quantity);
// Reset after starting use
setShowConfirmDialog(false);
setSelectedItem(null);
setQuantity(1);
};
const handleCloseConfirmDialog = (isOpen: boolean) => {
if (!isOpen) {
setShowConfirmDialog(false);
setSelectedItem(null);
setQuantity(1);
}
};
const handleOpenShop = () => {
onOpenChange(false);
onOpenShop();
};
// Quantity controls
const maxQuantity = selectedItem?.quantity ?? 1;
const handleIncrease = () => setQuantity(q => Math.min(q + 1, maxQuantity));
const handleDecrease = () => setQuantity(q => Math.max(q - 1, 1));
const handleQuantityInput = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = parseInt(e.target.value, 10);
if (isNaN(value) || value < 1) {
setQuantity(1);
} else {
setQuantity(Math.min(value, maxQuantity));
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md w-[calc(100%-2rem)] max-h-[85vh] flex flex-col p-0 gap-0 [&>button:last-child]:hidden">
{/* Header - Sticky */}
<DialogHeader className="sticky top-0 z-10 bg-background px-4 sm:px-6 pt-4 sm:pt-6 pb-3 sm:pb-4 border-b">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-3 min-w-0">
<div className="size-9 sm:size-10 rounded-xl bg-gradient-to-br from-primary/20 to-primary/5 flex items-center justify-center text-xl sm:text-2xl shrink-0">
{actionMeta.icon}
</div>
<div className="min-w-0">
<DialogTitle className="text-lg sm:text-xl">{actionMeta.label}</DialogTitle>
<p className="text-xs sm:text-sm text-muted-foreground truncate">
{actionMeta.description}
</p>
</div>
</div>
<DialogClose className="rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 shrink-0">
<X className="size-5" />
<span className="sr-only">Close</span>
</DialogClose>
</div>
</DialogHeader>
{/* Content - Scrollable */}
<div className="flex-1 min-h-0 overflow-y-auto px-4 sm:px-6 py-3 sm:py-4">
{/* Stage Restriction Message */}
{!canUse && stageMessage && (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="size-16 rounded-2xl bg-amber-500/10 flex items-center justify-center mb-4">
<span className="text-3xl">🥚</span>
</div>
<h3 className="text-lg font-semibold mb-2">Not Available</h3>
<p className="text-sm text-muted-foreground max-w-sm">
{stageMessage}
</p>
</div>
)}
{/* Empty State */}
{canUse && isEmpty && (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="size-16 rounded-2xl bg-muted/50 flex items-center justify-center mb-4">
<span className="text-3xl">{actionMeta.icon}</span>
</div>
<h3 className="text-lg font-semibold mb-2">No Items</h3>
<p className="text-sm text-muted-foreground max-w-sm mb-4">
You don't have any items for this action. Visit the shop to get some!
</p>
<Button onClick={handleOpenShop} className="gap-2">
<ShoppingBag className="size-4" />
Open Shop
</Button>
</div>
)}
{/* Item List */}
{canUse && !isEmpty && (
<div className="grid gap-3">
{availableItems.map((item) => (
<BlobbiInventoryUseRow
key={item.itemId}
item={item}
companion={companion}
action={action}
onUse={() => handleSelectItem(item)}
isUsing={isUsingItem && usingItemId === item.itemId}
disabled={isUsingItem}
/>
))}
</div>
)}
</div>
</DialogContent>
{/* Confirmation Dialog with Quantity Selector */}
{selectedItem && (
<BlobbiUseItemConfirmDialog
open={showConfirmDialog}
onOpenChange={handleCloseConfirmDialog}
item={selectedItem}
companion={companion}
action={action}
quantity={quantity}
maxQuantity={maxQuantity}
onIncrease={handleIncrease}
onDecrease={handleDecrease}
onQuantityChange={handleQuantityInput}
onConfirm={handleConfirmUse}
isUsing={isUsingItem}
/>
)}
</Dialog>
);
}
// ─── Inventory Use Row ────────────────────────────────────────────────────────
interface BlobbiInventoryUseRowProps {
item: ResolvedInventoryItem;
companion: BlobbiCompanion;
action: InventoryAction;
onUse: () => void;
isUsing: boolean;
disabled: boolean;
}
function BlobbiInventoryUseRow({
item,
companion,
action,
onUse,
isUsing,
disabled,
}: BlobbiInventoryUseRowProps) {
const isEgg = companion.stage === 'egg';
const isMedicine = action === 'medicine';
const isClean = action === 'clean';
// Preview stat changes - handle egg-specific preview for medicine and clean
const { normalStatChanges, eggStatChanges } = useMemo(() => {
if (isEgg && isMedicine) {
// For eggs using medicine, show health preview
// Eggs use the 3-stat model: health, hygiene, happiness
return {
normalStatChanges: [],
eggStatChanges: previewMedicineForEgg(companion.stats.health, item.effect),
};
}
if (isEgg && isClean) {
// For eggs using hygiene items, show hygiene (and possibly happiness) preview
return {
normalStatChanges: [],
eggStatChanges: previewCleanForEgg(
{ hygiene: companion.stats.hygiene, happiness: companion.stats.happiness },
item.effect
),
};
}
// Normal stats preview
return {
normalStatChanges: previewStatChanges(companion.stats, item.effect),
eggStatChanges: [] as EggStatPreview[],
};
}, [companion.stats, item.effect, isEgg, isMedicine, isClean]);
const hasChanges = normalStatChanges.length > 0 || eggStatChanges.length > 0;
return (
<div className="flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-4 p-3 sm:p-4 rounded-xl border bg-card/60 backdrop-blur-sm hover:border-primary/30 transition-colors">
{/* Top row on mobile: Icon + Info + Button */}
<div className="flex items-center gap-3 sm:contents">
{/* Item Icon */}
<div className="relative shrink-0">
<div className="absolute inset-0 bg-gradient-to-br from-primary/20 to-primary/5 rounded-full blur-xl" />
<div className="relative size-10 sm:size-14 rounded-full bg-gradient-to-br from-primary/10 to-primary/5 flex items-center justify-center text-2xl sm:text-3xl">
{item.icon}
</div>
</div>
{/* Item Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5 sm:mb-1">
<h3 className="font-semibold text-sm sm:text-base truncate">{item.name}</h3>
<Badge variant="secondary" className="text-xs shrink-0">
x{item.quantity}
</Badge>
</div>
{/* Effect Preview - shown inline on desktop */}
<div className="hidden sm:block">
{hasChanges && (
<div className="flex flex-wrap gap-x-3 gap-y-1">
{/* Normal stat changes */}
{normalStatChanges.map(({ stat, delta }) => (
<span key={stat} className="text-xs">
<span
className={cn(
'font-medium',
delta > 0
? 'text-emerald-600 dark:text-emerald-400'
: 'text-red-600 dark:text-red-400'
)}
>
{delta > 0 ? '+' : ''}
{delta}
</span>{' '}
<span className="text-muted-foreground capitalize">
{stat.replace('_', ' ')}
</span>
</span>
))}
{/* Egg stat changes (health for medicine) */}
{eggStatChanges.map(({ stat, delta }) => (
<span key={stat} className="text-xs">
<span
className={cn(
'font-medium',
delta > 0
? 'text-emerald-600 dark:text-emerald-400'
: 'text-red-600 dark:text-red-400'
)}
>
{delta > 0 ? '+' : ''}
{delta}
</span>{' '}
<span className="text-muted-foreground capitalize">
{stat.replace('_', ' ')}
</span>
</span>
))}
</div>
)}
</div>
</div>
{/* Use Button */}
<Button
size="sm"
onClick={onUse}
disabled={disabled}
className="shrink-0"
>
{isUsing ? (
<Loader2 className="size-4 animate-spin" />
) : (
'Use'
)}
</Button>
</div>
{/* Effect Preview - shown below on mobile */}
{hasChanges && (
<div className="sm:hidden flex flex-wrap gap-x-3 gap-y-1 pl-13">
{/* Normal stat changes */}
{normalStatChanges.map(({ stat, delta }) => (
<span key={stat} className="text-xs">
<span
className={cn(
'font-medium',
delta > 0
? 'text-emerald-600 dark:text-emerald-400'
: 'text-red-600 dark:text-red-400'
)}
>
{delta > 0 ? '+' : ''}
{delta}
</span>{' '}
<span className="text-muted-foreground capitalize">
{stat.replace('_', ' ')}
</span>
</span>
))}
{/* Egg stat changes (health for medicine) */}
{eggStatChanges.map(({ stat, delta }) => (
<span key={stat} className="text-xs">
<span
className={cn(
'font-medium',
delta > 0
? 'text-emerald-600 dark:text-emerald-400'
: 'text-red-600 dark:text-red-400'
)}
>
{delta > 0 ? '+' : ''}
{delta}
</span>{' '}
<span className="text-muted-foreground capitalize">
{stat.replace('_', ' ')}
</span>
</span>
))}
</div>
)}
</div>
);
}
// ─── Use Item Confirmation Dialog ─────────────────────────────────────────────
interface BlobbiUseItemConfirmDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
item: ResolvedInventoryItem;
companion: BlobbiCompanion;
action: InventoryAction;
quantity: number;
maxQuantity: number;
onIncrease: () => void;
onDecrease: () => void;
onQuantityChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
onConfirm: () => void;
isUsing: boolean;
}
function BlobbiUseItemConfirmDialog({
open,
onOpenChange,
item,
companion,
action,
quantity,
maxQuantity,
onIncrease,
onDecrease,
onQuantityChange,
onConfirm,
isUsing,
}: BlobbiUseItemConfirmDialogProps) {
const actionMeta = ACTION_METADATA[action];
const isEgg = companion.stage === 'egg';
const isMedicine = action === 'medicine';
const isClean = action === 'clean';
// Preview stat changes for the selected quantity
const statPreview = useMemo(() => {
if (!item.effect) return { normalChanges: [], eggChanges: [] };
if (isEgg && isMedicine) {
// Calculate health change for N items
const healthDelta = item.effect.health ?? 0;
let currentHealth = companion.stats.health ?? 0;
for (let i = 0; i < quantity; i++) {
currentHealth = Math.max(0, Math.min(100, currentHealth + healthDelta));
}
const totalDelta = currentHealth - (companion.stats.health ?? 0);
return {
normalChanges: [],
eggChanges: totalDelta !== 0 ? [{ stat: 'health' as const, delta: totalDelta }] : [],
};
}
if (isEgg && isClean) {
// Calculate hygiene and happiness changes for N items
const hygieneDelta = item.effect.hygiene ?? 0;
const happinessDelta = item.effect.happiness ?? 0;
let currentHygiene = companion.stats.hygiene ?? 0;
let currentHappiness = companion.stats.happiness ?? 0;
for (let i = 0; i < quantity; i++) {
currentHygiene = Math.max(0, Math.min(100, currentHygiene + hygieneDelta));
currentHappiness = Math.max(0, Math.min(100, currentHappiness + happinessDelta));
}
const changes: Array<{ stat: 'health' | 'hygiene' | 'happiness'; delta: number }> = [];
const totalHygieneDelta = currentHygiene - (companion.stats.hygiene ?? 0);
const totalHappinessDelta = currentHappiness - (companion.stats.happiness ?? 0);
if (totalHygieneDelta !== 0) changes.push({ stat: 'hygiene', delta: totalHygieneDelta });
if (totalHappinessDelta !== 0) changes.push({ stat: 'happiness', delta: totalHappinessDelta });
return { normalChanges: [], eggChanges: changes };
}
// Normal stats preview - simulate N applications
const statKeys = ['hunger', 'happiness', 'energy', 'hygiene', 'health'] as const;
const currentStats = { ...companion.stats };
for (let i = 0; i < quantity; i++) {
for (const stat of statKeys) {
const delta = item.effect[stat];
if (delta !== undefined) {
currentStats[stat] = Math.max(0, Math.min(100, (currentStats[stat] ?? 0) + delta));
}
}
}
const changes: Array<{ stat: string; delta: number }> = [];
for (const stat of statKeys) {
const delta = (currentStats[stat] ?? 0) - (companion.stats[stat] ?? 0);
if (delta !== 0) {
changes.push({ stat, delta });
}
}
return { normalChanges: changes, eggChanges: [] };
}, [item.effect, companion.stats, quantity, isEgg, isMedicine, isClean]);
const hasChanges = statPreview.normalChanges.length > 0 || statPreview.eggChanges.length > 0;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-sm w-[calc(100%-2rem)]">
<DialogHeader>
<DialogTitle>{actionMeta.label}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
{/* Item Preview */}
<div className="flex items-center gap-3 sm:gap-4 p-3 sm:p-4 rounded-lg bg-muted/50">
<div className="text-3xl sm:text-4xl shrink-0">{item.icon}</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold truncate">{item.name}</h3>
<p className="text-sm text-muted-foreground">
{item.quantity} in inventory
</p>
</div>
</div>
{/* Quantity Selector */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium">Quantity</label>
<span className="text-xs text-muted-foreground">
Max: {maxQuantity}
</span>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon"
onClick={onDecrease}
disabled={quantity <= 1 || isUsing}
>
<Minus className="size-4" />
</Button>
<Input
type="number"
min="1"
max={maxQuantity}
value={quantity}
onChange={onQuantityChange}
disabled={isUsing}
className="text-center"
/>
<Button
variant="outline"
size="icon"
onClick={onIncrease}
disabled={quantity >= maxQuantity || isUsing}
>
<Plus className="size-4" />
</Button>
</div>
</div>
{/* Effects Summary */}
{hasChanges && (
<div className="p-4 rounded-lg bg-gradient-to-r from-emerald-500/10 to-green-500/10 border border-emerald-500/20">
<h4 className="text-sm font-medium mb-2">
Total effect{quantity > 1 ? ` (x${quantity})` : ''}
</h4>
<div className="flex flex-wrap gap-2">
{statPreview.normalChanges.map(({ stat, delta }) => (
<Badge
key={stat}
variant="secondary"
className={cn(
'text-xs',
delta > 0
? 'bg-green-500/20 text-green-700 dark:text-green-300'
: 'bg-red-500/20 text-red-700 dark:text-red-300'
)}
>
{delta > 0 ? '+' : ''}{delta} {stat}
</Badge>
))}
{statPreview.eggChanges.map(({ stat, delta }) => (
<Badge
key={stat}
variant="secondary"
className={cn(
'text-xs',
delta > 0
? 'bg-green-500/20 text-green-700 dark:text-green-300'
: 'bg-red-500/20 text-red-700 dark:text-red-300'
)}
>
{delta > 0 ? '+' : ''}{delta} {stat}
</Badge>
))}
</div>
</div>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isUsing}
>
Cancel
</Button>
<Button
onClick={onConfirm}
disabled={isUsing}
className="min-w-24"
>
{isUsing ? (
<>
<Loader2 className="size-4 mr-2 animate-spin" />
Using...
</>
) : (
`Use${quantity > 1 ? ` (x${quantity})` : ''}`
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -1,201 +0,0 @@
// src/blobbi/actions/components/BlobbiActionsModal.tsx
import { Loader2, Moon, Sun, Utensils, Gamepad2, Sparkles as SparklesIcon, Pill, Music, Mic, X } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogClose,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
import type { InventoryAction, DirectAction } from '../lib/blobbi-action-utils';
interface BlobbiActionsModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
companion: BlobbiCompanion;
onRest: () => void;
onInventoryAction: (action: InventoryAction) => void;
onDirectAction: (action: DirectAction) => void;
actionInProgress: string | null;
isPublishing: boolean;
}
export function BlobbiActionsModal({
open,
onOpenChange,
companion,
onRest,
onInventoryAction,
onDirectAction,
actionInProgress,
isPublishing,
}: BlobbiActionsModalProps) {
const isSleeping = companion.state === 'sleeping';
const isDisabled = isPublishing || actionInProgress !== null;
const isEgg = companion.stage === 'egg';
const handleAction = (action: () => void) => {
action();
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-sm w-[calc(100%-2rem)] max-h-[85vh] flex flex-col p-0 gap-0 [&>button:last-child]:hidden">
{/* Header - Sticky */}
<DialogHeader className="sticky top-0 z-10 bg-background px-4 sm:px-6 pt-4 sm:pt-6 pb-3 sm:pb-4 border-b">
<div className="flex items-start justify-between gap-4">
<div>
<DialogTitle>Blobbi Actions</DialogTitle>
<p className="text-sm text-muted-foreground">{companion.name}</p>
</div>
<DialogClose className="rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2">
<X className="size-5" />
<span className="sr-only">Close</span>
</DialogClose>
</div>
</DialogHeader>
{/* Content - Scrollable */}
<div className="flex-1 min-h-0 overflow-y-auto px-4 sm:px-6 py-3 sm:py-4">
<div className="grid gap-3">
{/* Feed Action - hidden for eggs */}
{!isEgg && (
<Button
variant="outline"
className="w-full justify-start gap-3 h-14"
onClick={() => handleAction(() => onInventoryAction('feed'))}
disabled={isDisabled}
>
<Utensils className="size-5 text-orange-500" />
<div className="text-left">
<p className="font-medium">Feed</p>
<p className="text-xs text-muted-foreground">
Give your Blobbi something to eat
</p>
</div>
</Button>
)}
{/* Play Action - hidden for eggs */}
{!isEgg && (
<Button
variant="outline"
className="w-full justify-start gap-3 h-14"
onClick={() => handleAction(() => onInventoryAction('play'))}
disabled={isDisabled}
>
<Gamepad2 className="size-5 text-yellow-500" />
<div className="text-left">
<p className="font-medium">Play</p>
<p className="text-xs text-muted-foreground">
Play with toys to make your Blobbi happy
</p>
</div>
</Button>
)}
{/* Clean Action - available for all stages */}
<Button
variant="outline"
className="w-full justify-start gap-3 h-14"
onClick={() => handleAction(() => onInventoryAction('clean'))}
disabled={isDisabled}
>
<SparklesIcon className="size-5 text-blue-500" />
<div className="text-left">
<p className="font-medium">Clean</p>
<p className="text-xs text-muted-foreground">
{isEgg
? 'Keep your egg clean and fresh'
: 'Keep your Blobbi clean and fresh'}
</p>
</div>
</Button>
{/* Medicine Action - available for all stages */}
<Button
variant="outline"
className="w-full justify-start gap-3 h-14"
onClick={() => handleAction(() => onInventoryAction('medicine'))}
disabled={isDisabled}
>
<Pill className="size-5 text-green-500" />
<div className="text-left">
<p className="font-medium">Medicine</p>
<p className="text-xs text-muted-foreground">
{isEgg
? 'Keep your egg healthy'
: 'Heal your Blobbi'}
</p>
</div>
</Button>
{/* Play Music Action - available for all stages */}
<Button
variant="outline"
className="w-full justify-start gap-3 h-14"
onClick={() => handleAction(() => onDirectAction('play_music'))}
disabled={isDisabled}
>
<Music className="size-5 text-pink-500" />
<div className="text-left">
<p className="font-medium">Play Music</p>
<p className="text-xs text-muted-foreground">
{isEgg
? 'Play soothing music for your egg'
: 'Play music for your Blobbi'}
</p>
</div>
</Button>
{/* Sing Action - available for all stages */}
<Button
variant="outline"
className="w-full justify-start gap-3 h-14"
onClick={() => handleAction(() => onDirectAction('sing'))}
disabled={isDisabled}
>
<Mic className="size-5 text-purple-500" />
<div className="text-left">
<p className="font-medium">Sing</p>
<p className="text-xs text-muted-foreground">
{isEgg
? 'Sing a lullaby to your egg'
: 'Sing to your Blobbi'}
</p>
</div>
</Button>
{/* Sleep/Wake Action - hidden for eggs */}
{!isEgg && (
<Button
variant="outline"
className="w-full justify-start gap-3 h-14"
onClick={() => handleAction(onRest)}
disabled={isDisabled}
>
{actionInProgress === 'rest' ? (
<Loader2 className="size-5 animate-spin" />
) : isSleeping ? (
<Sun className="size-5 text-amber-500" />
) : (
<Moon className="size-5 text-violet-500" />
)}
<div className="text-left">
<p className="font-medium">{isSleeping ? 'Wake Up' : 'Sleep'}</p>
<p className="text-xs text-muted-foreground">
{isSleeping ? 'Wake your Blobbi up' : 'Put your Blobbi to sleep'}
</p>
</div>
</Button>
)}
</div>
</div>
</DialogContent>
</Dialog>
);
}
@@ -1,560 +0,0 @@
// src/blobbi/actions/components/BlobbiMissionsModal.tsx
/**
* Missions modal for Blobbi — card-grid quest board.
*
* Layout:
* 1. Sticky header with title, subtitle, legend help button, close
* 2. Current Focus section (hatch / evolve) — collapsible, default open
* 3. Daily Bounties section — collapsible, default open
* 4. Settings row — low emphasis toggle (not collapsible)
*
* Both main sections use lightweight Radix Collapsible wrappers.
* Collapsed headers still show summary info (progress / coins).
*/
import {
Loader2,
XCircle,
AlertTriangle,
Coins,
X,
Eye,
Scroll,
Compass,
HelpCircle,
ChevronDown,
} from 'lucide-react';
import { formatCompactNumber, cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { Dialog, DialogContent, DialogClose } from '@/components/ui/dialog';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { useState } from 'react';
import type { BlobbiCompanion, BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
import type { NostrEvent } from '@nostrify/nostrify';
import type { HatchTasksResult } from '../hooks/useHatchTasks';
import type { EvolveTasksResult } from '../hooks/useEvolveTasks';
import { TasksPanel } from './TasksPanel';
import { DailyMissionsPanel } from './DailyMissionsPanel';
import { useDailyMissions } from '../hooks/useDailyMissions';
import { useClaimMissionReward } from '../hooks/useClaimMissionReward';
import { useRerollMission } from '../hooks/useRerollMission';
// ─── Types ────────────────────────────────────────────────────────────────────
interface BlobbiMissionsModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
companion: BlobbiCompanion;
profile: BlobbonautProfile | null;
updateProfileEvent: (event: NostrEvent) => void;
hatchTasks: HatchTasksResult;
evolveTasks: EvolveTasksResult;
onOpenPostModal: () => void;
onHatch: () => void;
isHatching: boolean;
onEvolve: () => void;
isEvolving: boolean;
onStopIncubation: () => Promise<void>;
isStoppingIncubation: boolean;
onStopEvolution: () => Promise<void>;
isStoppingEvolution: boolean;
availableStages?: ('egg' | 'baby' | 'adult')[];
showMissionCard?: boolean;
onToggleMissionCard?: (visible: boolean) => void;
}
// ─── Section Chevron ─────────────────────────────────────────────────────────
function SectionChevron({ open }: { open: boolean }) {
return (
<ChevronDown
className={cn(
'size-4 text-muted-foreground/60 transition-transform duration-200',
open && 'rotate-180',
)}
/>
);
}
// ─── Mission Type Legend ──────────────────────────────────────────────────────
function MissionTypeLegend() {
return (
<Popover>
<PopoverTrigger asChild>
<button
type="button"
className="rounded-full p-1.5 opacity-50 hover:opacity-100 hover:bg-muted transition-all"
aria-label="Mission types legend"
>
<HelpCircle className="size-4" />
</button>
</PopoverTrigger>
<PopoverContent side="bottom" align="end" className="w-56 p-3">
<p className="text-xs font-semibold mb-2">Mission Types</p>
<div className="space-y-2">
<div className="flex items-center gap-2">
<div className="size-5 rounded-full bg-amber-500/15 flex items-center justify-center shrink-0">
<Scroll className="size-3 text-amber-500" />
</div>
<div>
<p className="text-xs font-medium">Daily Bounty</p>
<p className="text-[10px] text-muted-foreground">Resets every day</p>
</div>
</div>
<div className="flex items-center gap-2">
<div className="size-5 rounded-full bg-sky-500/15 flex items-center justify-center shrink-0">
<span className="text-xs">🥚</span>
</div>
<div>
<p className="text-xs font-medium">Hatch Task</p>
<p className="text-[10px] text-muted-foreground">Egg progression</p>
</div>
</div>
<div className="flex items-center gap-2">
<div className="size-5 rounded-full bg-violet-500/15 flex items-center justify-center shrink-0">
<span className="text-xs">🐣</span>
</div>
<div>
<p className="text-xs font-medium">Evolve Task</p>
<p className="text-[10px] text-muted-foreground">Baby progression</p>
</div>
</div>
</div>
</PopoverContent>
</Popover>
);
}
// ─── Daily Missions Section ───────────────────────────────────────────────────
interface DailyMissionsSectionProps {
profile: BlobbonautProfile | null;
updateProfileEvent: (event: NostrEvent) => void;
availableStages?: ('egg' | 'baby' | 'adult')[];
disabled?: boolean;
defaultOpen?: boolean;
}
function DailyMissionsSection({
profile,
updateProfileEvent,
availableStages,
disabled,
defaultOpen = true,
}: DailyMissionsSectionProps) {
const [isOpen, setIsOpen] = useState(defaultOpen);
const {
missions,
todayClaimedReward,
totalPotentialReward,
bonusAvailable,
bonusClaimed,
bonusReward,
noMissionsAvailable,
rerollsRemaining,
} = useDailyMissions({ availableStages });
const { mutate: claimReward, isPending: isClaiming } = useClaimMissionReward(
profile,
updateProfileEvent,
);
const { mutate: rerollMission, isPending: isRerolling } = useRerollMission();
const claimableCount = missions.filter((m) => m.completed && !m.claimed).length;
return (
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
{/* Section header — tappable */}
<CollapsibleTrigger className="w-full">
<div className="flex items-center justify-between py-1 group">
<div className="flex items-center gap-2">
<Scroll className="size-4 text-amber-500 dark:text-amber-400 shrink-0" />
<h3 className="font-semibold text-sm">Daily Bounties</h3>
</div>
<div className="flex items-center gap-2">
{/* Summary pill — always visible */}
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Coins className="size-3 shrink-0 text-amber-500 dark:text-amber-400" />
<span className="tabular-nums">
{formatCompactNumber(todayClaimedReward)} / {formatCompactNumber(totalPotentialReward)}
</span>
{claimableCount > 0 && (
<span className="size-4 rounded-full bg-emerald-500 text-white text-[10px] font-bold flex items-center justify-center shrink-0">
{claimableCount}
</span>
)}
</div>
<SectionChevron open={isOpen} />
</div>
</div>
</CollapsibleTrigger>
<CollapsibleContent className="overflow-hidden data-[state=open]:animate-collapsible-down data-[state=closed]:animate-collapsible-up">
<div className="pt-3">
<DailyMissionsPanel
missions={missions}
onClaimReward={(id) => claimReward({ missionId: id })}
onRerollMission={(id) => rerollMission({ missionId: id, availableStages })}
todayCoins={todayClaimedReward}
disabled={disabled || isClaiming || isRerolling}
bonusAvailable={bonusAvailable}
bonusClaimed={bonusClaimed}
bonusReward={bonusReward}
noMissionsAvailable={noMissionsAvailable}
rerollsRemaining={rerollsRemaining}
isRerolling={isRerolling}
/>
</div>
</CollapsibleContent>
</Collapsible>
);
}
// ─── Stop Process Confirmation Dialog ─────────────────────────────────────────
interface StopConfirmationDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
companionName: string;
processType: 'incubation' | 'evolution';
onConfirm: () => Promise<void>;
isPending: boolean;
}
function StopConfirmationDialog({
open,
onOpenChange,
companionName,
processType,
onConfirm,
isPending,
}: StopConfirmationDialogProps) {
const handleConfirm = async () => {
await onConfirm();
onOpenChange(false);
};
const label = processType === 'incubation' ? 'Incubation' : 'Evolution';
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
<AlertTriangle className="size-5 text-amber-500" />
Stop {label}?
</AlertDialogTitle>
<AlertDialogDescription className="space-y-2">
<p>
Are you sure you want to stop {processType === 'incubation' ? 'incubating' : 'evolving'}{' '}
<strong>{companionName}</strong>?
</p>
<p>
This will interrupt the {processType} process and clear all task progress.
You can restart {processType} later, but you'll need to complete the tasks again.
</p>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isPending}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirm}
disabled={isPending}
className="bg-destructive hover:bg-destructive/90"
>
{isPending ? (
<>
<Loader2 className="size-4 mr-2 animate-spin" />
Stopping...
</>
) : (
`Stop ${label}`
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
// ─── Current Focus Section (Hatch / Evolve) ──────────────────────────────────
interface CurrentFocusSectionProps {
companion: BlobbiCompanion;
tasks: HatchTasksResult | EvolveTasksResult;
processType: 'incubation' | 'evolution';
onOpenPostModal: () => void;
onComplete: () => void;
isCompleting: boolean;
onStop: () => Promise<void>;
isStopping: boolean;
defaultOpen?: boolean;
}
function CurrentFocusSection({
companion,
tasks,
processType,
onOpenPostModal,
onComplete,
isCompleting,
onStop,
isStopping,
defaultOpen = true,
}: CurrentFocusSectionProps) {
const [isOpen, setIsOpen] = useState(defaultOpen);
const [showStopConfirmation, setShowStopConfirmation] = useState(false);
const isIncubation = processType === 'incubation';
const title = isIncubation ? 'Hatch Tasks' : 'Evolve Tasks';
const completeLabel = isIncubation ? 'Hatch Your Blobbi!' : 'Evolve Your Blobbi!';
const completingLabel = isIncubation ? 'Hatching...' : 'Evolving...';
const completeEmoji = isIncubation ? '🐣' : '';
const stopLabel = isIncubation ? 'Stop Incubation' : 'Stop Evolution';
const badgeLabel = isIncubation ? 'Hatch' : 'Evolve';
const category = isIncubation ? ('hatch' as const) : ('evolve' as const);
const completedCount = tasks.tasks.filter((t) => t.completed).length;
const totalTasks = tasks.tasks.length;
return (
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
{/* Section header — tappable */}
<CollapsibleTrigger className="w-full">
<div className="flex items-center justify-between py-1 group">
<div className="flex items-center gap-2">
<Badge
variant="secondary"
className={cn(
'text-xs font-semibold px-2 py-0.5',
isIncubation
? 'bg-sky-500/15 text-sky-600 dark:text-sky-400'
: 'bg-violet-500/15 text-violet-600 dark:text-violet-400',
)}
>
{badgeLabel}
</Badge>
<span className="text-sm font-semibold">{title}</span>
</div>
<div className="flex items-center gap-2">
<span
className={cn(
'text-xs font-medium tabular-nums',
tasks.allCompleted
? 'text-emerald-600 dark:text-emerald-400'
: 'text-muted-foreground',
)}
>
{completedCount} / {totalTasks}
</span>
<SectionChevron open={isOpen} />
</div>
</div>
</CollapsibleTrigger>
<CollapsibleContent className="overflow-hidden data-[state=open]:animate-collapsible-down data-[state=closed]:animate-collapsible-up">
<div className="pt-3">
{/* Task card grid */}
<TasksPanel
tasks={tasks.tasks}
allCompleted={tasks.allCompleted}
isLoading={tasks.isLoading}
onOpenPostModal={onOpenPostModal}
onComplete={onComplete}
isCompleting={isCompleting}
completeLabel={completeLabel}
completingLabel={completingLabel}
completeEmoji={completeEmoji}
category={category}
/>
{/* Stop process — low emphasis */}
<div className="mt-3 flex justify-center">
<Button
variant="ghost"
size="sm"
onClick={() => setShowStopConfirmation(true)}
disabled={isStopping || isCompleting}
className="text-xs text-muted-foreground hover:text-destructive hover:bg-destructive/10 h-8 px-3"
>
{isStopping ? (
<>
<Loader2 className="size-3.5 mr-1.5 animate-spin" />
Stopping...
</>
) : (
<>
<XCircle className="size-3.5 mr-1.5" />
{stopLabel}
</>
)}
</Button>
</div>
</div>
</CollapsibleContent>
<StopConfirmationDialog
open={showStopConfirmation}
onOpenChange={setShowStopConfirmation}
companionName={companion.name}
processType={processType}
onConfirm={onStop}
isPending={isStopping}
/>
</Collapsible>
);
}
// ─── Empty Focus State ────────────────────────────────────────────────────────
function EmptyFocusState() {
return (
<div className="py-6 text-center">
<Compass className="size-5 text-muted-foreground/50 mx-auto mb-2" />
<p className="text-sm text-muted-foreground">No active progression right now</p>
</div>
);
}
// ─── Main Modal ───────────────────────────────────────────────────────────────
export function BlobbiMissionsModal({
open,
onOpenChange,
companion,
profile,
updateProfileEvent,
hatchTasks,
evolveTasks,
onOpenPostModal,
onHatch,
isHatching,
onEvolve,
isEvolving,
onStopIncubation,
isStoppingIncubation,
onStopEvolution,
isStoppingEvolution,
availableStages,
showMissionCard,
onToggleMissionCard,
}: BlobbiMissionsModalProps) {
const isIncubating = companion.state === 'incubating';
const isEvolvingState = companion.state === 'evolving';
const isEgg = companion.stage === 'egg';
const isBaby = companion.stage === 'baby';
const hasActiveProcess = (isIncubating && isEgg) || (isEvolvingState && isBaby);
const isProcessBusy = isHatching || isEvolving || isStoppingIncubation || isStoppingEvolution;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg w-[calc(100%-2rem)] max-h-[85vh] flex flex-col p-0 gap-0 overflow-hidden [&>button:last-child]:hidden">
{/* ── Sticky Header ── */}
<div className="sticky top-0 z-10 bg-background px-4 sm:px-5 pt-4 pb-3 border-b border-border/60">
<div className="flex items-center justify-between">
<div className="min-w-0">
<h2 className="text-base font-bold tracking-tight">Missions</h2>
<p className="text-xs text-muted-foreground mt-0.5">
Quests & bounties for {companion.name}
</p>
</div>
<div className="flex items-center gap-0.5 shrink-0">
<MissionTypeLegend />
<DialogClose className="rounded-full p-1.5 opacity-60 hover:opacity-100 hover:bg-muted transition-all">
<X className="size-4" />
<span className="sr-only">Close</span>
</DialogClose>
</div>
</div>
</div>
{/* ── Scrollable Content ── */}
<div className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden px-4 sm:px-5 py-4 space-y-5">
{/* 1. Current Focus */}
{hasActiveProcess ? (
<>
{isIncubating && isEgg ? (
<CurrentFocusSection
companion={companion}
tasks={hatchTasks}
processType="incubation"
onOpenPostModal={onOpenPostModal}
onComplete={onHatch}
isCompleting={isHatching}
onStop={onStopIncubation}
isStopping={isStoppingIncubation}
/>
) : isEvolvingState && isBaby ? (
<CurrentFocusSection
companion={companion}
tasks={evolveTasks}
processType="evolution"
onOpenPostModal={onOpenPostModal}
onComplete={onEvolve}
isCompleting={isEvolving}
onStop={onStopEvolution}
isStopping={isStoppingEvolution}
/>
) : null}
</>
) : (
<EmptyFocusState />
)}
{/* Divider */}
<div className="h-px bg-border/60" />
{/* 2. Daily Bounties */}
<DailyMissionsSection
profile={profile}
updateProfileEvent={updateProfileEvent}
availableStages={availableStages}
disabled={isProcessBusy}
/>
{/* 3. Settings */}
{onToggleMissionCard !== undefined && showMissionCard !== undefined && (
<>
<div className="h-px bg-border/40" />
<div className="flex items-center justify-between py-1">
<Label
htmlFor="mission-card-toggle"
className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer"
>
<Eye className="size-3.5" />
Show mission card on main page
</Label>
<Switch
id="mission-card-toggle"
checked={showMissionCard}
onCheckedChange={onToggleMissionCard}
/>
</div>
</>
)}
</div>
</DialogContent>
</Dialog>
);
}
@@ -1,294 +0,0 @@
// src/blobbi/actions/components/BlobbiPostModal.tsx
/**
* Modal for creating a Blobbi post (hatch or evolve).
*
* Requirements:
* - Prefilled with stage-aware text:
* - Hatch: "Hello Nostr! Posting to hatch #<blobbiName> #blobbi #ditto #nostr"
* - Evolve: "Hello Nostr! Posting to evolve #<blobbiName> #blobbi #ditto #nostr"
* - User can ADD text but CANNOT delete the prefix or required hashtags
* - Blobbi name is sanitized into a valid hashtag format
* - Enforced programmatically
*/
import { useState, useCallback, useEffect, useMemo } from 'react';
import { X, Loader2, AlertCircle } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { toast } from '@/hooks/useToast';
import {
BLOBBI_POST_REQUIRED_HASHTAGS,
buildHatchPhrase,
} from '../hooks/useHatchTasks';
// ─── Types ────────────────────────────────────────────────────────────────────
/** The process type for the post */
export type BlobbiPostProcess = 'hatch' | 'evolve';
interface BlobbiPostModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** The Blobbi's name (will be converted to hashtag) */
blobbiName: string;
/** The process type - 'hatch' for incubation, 'evolve' for evolution */
process?: BlobbiPostProcess;
onSuccess?: () => void;
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
/**
* Build the required prefix text based on process type.
*/
function buildPrefix(process: BlobbiPostProcess): string {
return process === 'evolve'
? 'Posting to evolve'
: 'Posting to hatch';
}
// ─── Main Component ───────────────────────────────────────────────────────────
export function BlobbiPostModal({
open,
onOpenChange,
blobbiName,
process = 'hatch',
onSuccess,
}: BlobbiPostModalProps) {
const { user } = useCurrentUser();
const { mutateAsync: createEvent, isPending } = useNostrPublish();
// Compute the required elements based on props
const prefix = useMemo(() => buildPrefix(process), [process]);
const capitalizedName = useMemo(() => blobbiName.charAt(0).toUpperCase() + blobbiName.slice(1), [blobbiName]);
// The required phrase that must appear in the post
const requiredPhrase = useMemo(() =>
process === 'hatch'
? buildHatchPhrase(blobbiName)
: `${prefix} ${capitalizedName} #blobbi`,
[process, blobbiName, prefix, capitalizedName]
);
// Build default content (the phrase itself is enough)
const defaultContent = useMemo(() => requiredPhrase, [requiredPhrase]);
const [content, setContent] = useState(defaultContent);
const [validationError, setValidationError] = useState<string | null>(null);
// Reset content when modal opens or props change
useEffect(() => {
if (open) {
setContent(defaultContent);
setValidationError(null);
}
}, [open, defaultContent]);
/**
* Validate that the content contains the required phrase.
*/
const validateContent = useCallback((text: string): string | null => {
if (!text.includes(requiredPhrase)) {
return `The post must contain: "${requiredPhrase}"`;
}
return null;
}, [requiredPhrase]);
/**
* Handle content change with validation.
* Prevents deletion of required content.
*/
const handleContentChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newContent = e.target.value;
// Allow content changes only if it preserves the required elements
const error = validateContent(newContent);
if (error) {
setValidationError(error);
// Still update content but show error
// This allows the user to see what they're trying to do
// but the post button will be disabled
} else {
setValidationError(null);
}
setContent(newContent);
}, [validateContent]);
/**
* Handle post creation.
*/
const handlePost = useCallback(async () => {
if (!user?.pubkey) {
toast({
title: 'Not logged in',
description: 'Please log in to create a post',
variant: 'destructive',
});
return;
}
// Final validation
const error = validateContent(content);
if (error) {
setValidationError(error);
return;
}
try {
// Build tags for the post: extract all hashtags from content
const tags: string[][] = [];
const seen = new Set<string>();
// Always include BLOBBI_POST_REQUIRED_HASHTAGS as t tags
for (const hashtag of BLOBBI_POST_REQUIRED_HASHTAGS) {
const lower = hashtag.toLowerCase();
if (!seen.has(lower)) {
tags.push(['t', lower]);
seen.add(lower);
}
}
// Extract any additional hashtags from the content
const contentHashtags = content.match(/#(\w+)/g) || [];
for (const tag of contentHashtags) {
const tagValue = tag.slice(1).toLowerCase();
if (!seen.has(tagValue)) {
tags.push(['t', tagValue]);
seen.add(tagValue);
}
}
await createEvent({
kind: 1,
content,
tags,
});
toast({
title: 'Post created!',
description: process === 'evolve'
? 'Your Blobbi evolution post has been published.'
: 'Your Blobbi hatch post has been published.',
});
onOpenChange(false);
onSuccess?.();
} catch (error) {
toast({
title: 'Failed to create post',
description: error instanceof Error ? error.message : 'Unknown error',
variant: 'destructive',
});
}
}, [user, content, validateContent, createEvent, onOpenChange, onSuccess, process]);
const canPost = !validationError && content.trim().length > 0;
const dialogTitle = process === 'evolve' ? 'Blobbi Evolution Post' : 'Blobbi Hatch Post';
const alertText = process === 'evolve'
? "This special post announces your Blobbi's evolution! The highlighted text must remain in your post."
: "This special post announces your Blobbi's hatching journey! The highlighted text must remain in your post.";
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg p-0 gap-0">
{/* Header */}
<div className="flex items-center justify-between px-4 h-14 border-b">
<DialogTitle className="text-base font-semibold">
{dialogTitle}
</DialogTitle>
<button
onClick={() => onOpenChange(false)}
className="p-1.5 -mr-1.5 rounded-full text-muted-foreground hover:text-foreground hover:bg-secondary/60 transition-colors"
>
<X className="size-5" />
</button>
</div>
{/* Content */}
<div className="p-4 space-y-4">
{/* Info alert */}
<Alert className="border-primary/20 bg-primary/5">
<AlertDescription className="text-sm">
{alertText}
</AlertDescription>
</Alert>
{/* Textarea */}
<div className="space-y-2">
<Textarea
value={content}
onChange={handleContentChange}
placeholder="Write your post..."
className="min-h-[150px] resize-none"
disabled={isPending}
/>
{/* Character count and validation */}
<div className="flex items-center justify-between text-sm">
<div>
{validationError && (
<span className="text-destructive flex items-center gap-1">
<AlertCircle className="size-3.5" />
{validationError}
</span>
)}
</div>
<span className="text-muted-foreground">
{content.length} characters
</span>
</div>
</div>
{/* Preview of required content */}
<div className="p-3 rounded-lg bg-muted/50 border border-dashed">
<p className="text-xs text-muted-foreground mb-1">Required phrase:</p>
<p className="text-sm font-medium text-primary">
{requiredPhrase}
</p>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-2 px-4 py-3 border-t bg-muted/30">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isPending}
>
Cancel
</Button>
<Button
onClick={handlePost}
disabled={!canPost || isPending}
className="min-w-24"
>
{isPending ? (
<>
<Loader2 className="size-4 mr-2 animate-spin" />
Posting...
</>
) : (
'Post'
)}
</Button>
</div>
</DialogContent>
</Dialog>
);
}
@@ -1,319 +0,0 @@
/**
* DailyMissionsPanel — card-grid layout for daily bounties.
*
* Each mission is a compact card in a 2-col grid.
* Tapping a card expands it to show progress, claim button, and reroll.
* Only one card expanded at a time.
*/
import { useState } from 'react';
import {
Check,
Coins,
Gift,
Sparkles,
Egg,
Trophy,
RefreshCw,
Heart,
Utensils,
Droplets,
Moon,
Camera,
Mic,
Music,
Pill,
CircleDot,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { cn, formatCompactNumber } from '@/lib/utils';
import type { DailyMission, DailyMissionAction } from '../lib/daily-missions';
import { BONUS_MISSION_ID } from '../hooks/useClaimMissionReward';
import {
ExpandableMissionCard,
MissionDescription,
MissionProgress,
} from './ExpandableMissionCard';
// ─── Types ────────────────────────────────────────────────────────────────────
interface DailyMissionsPanelProps {
missions: DailyMission[];
onClaimReward: (missionId: string) => void;
onRerollMission?: (missionId: string) => void;
todayCoins: number;
disabled?: boolean;
bonusAvailable?: boolean;
bonusClaimed?: boolean;
bonusReward?: number;
noMissionsAvailable?: boolean;
rerollsRemaining?: number;
isRerolling?: boolean;
}
// ─── Daily Mission Icon Mapping ───────────────────────────────────────────────
function DailyMissionIcon({ action }: { action: DailyMissionAction }) {
const cls = 'size-5';
switch (action) {
case 'interact':
return <Heart className={cls} />;
case 'feed':
return <Utensils className={cls} />;
case 'clean':
return <Droplets className={cls} />;
case 'sleep':
return <Moon className={cls} />;
case 'take_photo':
return <Camera className={cls} />;
case 'sing':
return <Mic className={cls} />;
case 'play_music':
return <Music className={cls} />;
case 'medicine':
return <Pill className={cls} />;
default:
return <CircleDot className={cls} />;
}
}
// ─── Bonus Card ───────────────────────────────────────────────────────────────
interface BonusCardProps {
isAvailable: boolean;
isClaimed: boolean;
reward: number;
onClaim: () => void;
disabled?: boolean;
isExpanded: boolean;
onToggle: (id: string) => void;
}
function BonusCard({ isAvailable, isClaimed, reward, onClaim, disabled, isExpanded, onToggle }: BonusCardProps) {
const progress = isClaimed ? 1 : isAvailable ? 1 : 0;
return (
<ExpandableMissionCard
id="bonus"
category="daily"
icon={<Trophy className="size-5" />}
title="Daily Champion"
completed={isClaimed}
progress={progress}
isExpanded={isExpanded}
onToggle={onToggle}
>
<MissionDescription>
{isAvailable || isClaimed
? 'Bonus reward for completing all daily missions!'
: 'Complete all missions to unlock this bonus'}
</MissionDescription>
<div className="flex items-center gap-1 text-xs font-medium text-amber-600 dark:text-amber-400">
<Coins className="size-3" />
+{formatCompactNumber(reward)}
</div>
{isAvailable && !isClaimed && (
<Button
size="sm"
onClick={onClaim}
disabled={disabled}
className="w-full bg-gradient-to-r from-amber-500 to-orange-500 hover:from-amber-600 hover:to-orange-600 text-white h-8 text-xs"
>
<Trophy className="size-3.5 mr-1.5" />
Claim Bonus {formatCompactNumber(reward)} Coins
</Button>
)}
</ExpandableMissionCard>
);
}
// ─── Empty / Done States ──────────────────────────────────────────────────────
function NoMissionsState() {
return (
<div className="flex flex-col items-center gap-2 py-6 text-center">
<Egg className="size-5 text-muted-foreground/50" />
<div>
<p className="text-sm font-medium">Hatch your Blobbi first</p>
<p className="text-xs text-muted-foreground mt-0.5">
Daily missions unlock after hatching
</p>
</div>
</div>
);
}
function AllClaimedState({ todayCoins }: { todayCoins: number }) {
return (
<div className="flex flex-col items-center gap-2 py-6 text-center">
<Sparkles className="size-5 text-primary/60" />
<div>
<p className="text-sm font-medium">All done for today</p>
<p className="text-xs text-muted-foreground mt-0.5">
Earned{' '}
<span className="font-medium text-amber-600 dark:text-amber-400">
{formatCompactNumber(todayCoins)} coins
</span>{' '}
come back tomorrow!
</p>
</div>
</div>
);
}
// ─── Reroll Counter ───────────────────────────────────────────────────────────
function RerollCounter({ remaining }: { remaining: number }) {
const text =
remaining === 0
? 'No rerolls left'
: remaining === 1
? '1 reroll left'
: `${remaining} rerolls left`;
return (
<div className="flex items-center justify-end gap-1 text-[11px] text-muted-foreground col-span-full">
<RefreshCw className="size-2.5" />
<span>{text}</span>
</div>
);
}
// ─── Main Component ───────────────────────────────────────────────────────────
export function DailyMissionsPanel({
missions,
onClaimReward,
onRerollMission,
todayCoins,
disabled,
bonusAvailable = false,
bonusClaimed = false,
bonusReward = 50,
noMissionsAvailable = false,
rerollsRemaining = 0,
isRerolling = false,
}: DailyMissionsPanelProps) {
const [expandedId, setExpandedId] = useState<string | null>(null);
if (noMissionsAvailable) return <NoMissionsState />;
const allRegularClaimed = missions.every((m) => m.claimed);
const allDone = allRegularClaimed && bonusClaimed;
if (allDone) return <AllClaimedState todayCoins={todayCoins} />;
const canReroll = rerollsRemaining > 0 && !!onRerollMission;
const handleToggle = (id: string) => {
setExpandedId((prev) => (prev === id ? null : id));
};
return (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
{/* Reroll counter */}
{onRerollMission && <RerollCounter remaining={rerollsRemaining} />}
{/* Regular mission cards */}
{missions.map((mission) => {
const progress = mission.requiredCount > 0 ? mission.currentCount / mission.requiredCount : 0;
const canClaim = mission.completed && !mission.claimed;
const showReroll = onRerollMission && !mission.completed && !mission.claimed && canReroll;
return (
<ExpandableMissionCard
key={mission.id}
id={mission.id}
category="daily"
icon={<DailyMissionIcon action={mission.action} />}
title={mission.title}
completed={mission.claimed}
progress={Math.min(progress, 1)}
isExpanded={expandedId === mission.id}
onToggle={handleToggle}
>
{/* Description */}
<MissionDescription>{mission.description}</MissionDescription>
{/* Progress */}
{!mission.claimed && (
<MissionProgress
current={mission.currentCount}
required={mission.requiredCount}
completed={mission.completed}
/>
)}
{/* Reward + reroll row */}
<div className="flex items-center justify-between">
<span className="inline-flex items-center gap-0.5 text-xs font-medium text-amber-600 dark:text-amber-400">
<Coins className="size-3" />
{formatCompactNumber(mission.reward)}
</span>
{showReroll && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={(e) => {
e.stopPropagation();
onRerollMission(mission.id);
}}
disabled={disabled || isRerolling}
className="p-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors disabled:opacity-40"
>
<RefreshCw className={cn('size-3', isRerolling && 'animate-spin')} />
</button>
</TooltipTrigger>
<TooltipContent side="left">
<p>Replace mission</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{mission.claimed && (
<span className="inline-flex items-center gap-0.5 text-[10px] font-medium text-primary">
<Check className="size-2.5" />
Done
</span>
)}
</div>
{/* Claim button */}
{canClaim && (
<Button
size="sm"
onClick={(e) => {
e.stopPropagation();
onClaimReward(mission.id);
}}
disabled={disabled}
className="w-full bg-emerald-600 hover:bg-emerald-700 text-white h-8 text-xs"
>
<Gift className="size-3.5 mr-1.5" />
Claim {formatCompactNumber(mission.reward)} Coins
</Button>
)}
</ExpandableMissionCard>
);
})}
{/* Bonus card */}
<BonusCard
isAvailable={bonusAvailable}
isClaimed={bonusClaimed}
reward={bonusReward}
onClaim={() => onClaimReward(BONUS_MISSION_ID)}
disabled={disabled}
isExpanded={expandedId === 'bonus'}
onToggle={handleToggle}
/>
</div>
);
}
@@ -1,250 +0,0 @@
// src/blobbi/actions/components/ExpandableMissionCard.tsx
/**
* Expandable mission card for the quest-board grid.
*
* Collapsed: compact square-ish card showing icon, title, and a tiny
* progress ring / checkmark.
* Expanded: full-width row that reveals description, progress bar,
* action link, claim button, dynamic hints, etc.
*
* Only one card is expanded at a time per section (controlled by parent).
*/
import type { ReactNode } from 'react';
import { Check, ChevronRight, ExternalLink, AlertCircle } from 'lucide-react';
import { Progress } from '@/components/ui/progress';
import { cn } from '@/lib/utils';
// ─── Types ────────────────────────────────────────────────────────────────────
export type MissionCategory = 'daily' | 'hatch' | 'evolve';
export interface ExpandableMissionCardProps {
/** Unique id used to track which card is expanded */
id: string;
/** Mission category for visual styling */
category: MissionCategory;
/** Icon rendered in the compact card (ReactNode — usually a lucide icon or emoji span) */
icon: ReactNode;
/** Short title */
title: string;
/** Whether the mission is complete */
completed: boolean;
/** Progress fraction 0-1 (used for the tiny ring in compact mode) */
progress: number;
/** Whether this card is currently expanded */
isExpanded: boolean;
/** Parent calls this to toggle expansion */
onToggle: (id: string) => void;
/** Content rendered only when expanded */
children: ReactNode;
/** Optional extra className on the outer wrapper */
className?: string;
}
// ─── Tiny Progress Ring ───────────────────────────────────────────────────────
function ProgressRing({ progress, completed, category }: { progress: number; completed: boolean; category: MissionCategory }) {
const size = 28;
const stroke = 2.5;
const radius = (size - stroke) / 2;
const circumference = 2 * Math.PI * radius;
const offset = circumference - progress * circumference;
if (completed) {
return (
<div className="size-7 rounded-full bg-emerald-500/20 flex items-center justify-center">
<Check className="size-3.5 text-emerald-600 dark:text-emerald-400" />
</div>
);
}
const ringColor =
category === 'hatch'
? 'text-sky-500'
: category === 'evolve'
? 'text-violet-500'
: 'text-amber-500';
return (
<svg width={size} height={size} className={cn('shrink-0 -rotate-90', ringColor)}>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="currentColor"
strokeWidth={stroke}
opacity={0.15}
/>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="currentColor"
strokeWidth={stroke}
strokeDasharray={circumference}
strokeDashoffset={offset}
strokeLinecap="round"
className="transition-all duration-300"
/>
</svg>
);
}
// ─── Accent colors per category ───────────────────────────────────────────────
const CATEGORY_STYLES: Record<MissionCategory, { bg: string; expandedBg: string; border: string }> = {
daily: {
bg: 'bg-amber-500/[0.06] hover:bg-amber-500/10',
expandedBg: 'bg-amber-500/[0.06]',
border: 'ring-amber-500/20',
},
hatch: {
bg: 'bg-sky-500/[0.06] hover:bg-sky-500/10',
expandedBg: 'bg-sky-500/[0.06]',
border: 'ring-sky-500/20',
},
evolve: {
bg: 'bg-violet-500/[0.06] hover:bg-violet-500/10',
expandedBg: 'bg-violet-500/[0.06]',
border: 'ring-violet-500/20',
},
};
// ─── Component ────────────────────────────────────────────────────────────────
export function ExpandableMissionCard({
id,
category,
icon,
title,
completed,
progress,
isExpanded,
onToggle,
children,
className,
}: ExpandableMissionCardProps) {
const styles = CATEGORY_STYLES[category];
// ── Collapsed card ──
if (!isExpanded) {
return (
<button
type="button"
onClick={() => onToggle(id)}
className={cn(
'flex flex-col items-center gap-1.5 rounded-xl p-3 transition-all text-center cursor-pointer select-none',
'ring-1 ring-transparent',
completed ? 'bg-emerald-500/[0.06] hover:bg-emerald-500/10' : styles.bg,
className,
)}
>
{/* Icon */}
<div className="text-lg leading-none">{icon}</div>
{/* Title — 2 lines max */}
<span className={cn(
'text-[11px] font-medium leading-tight line-clamp-2 min-h-[2lh]',
completed && 'text-emerald-600 dark:text-emerald-400',
)}>
{title}
</span>
{/* Progress ring / check */}
<ProgressRing progress={progress} completed={completed} category={category} />
</button>
);
}
// ── Expanded card (spans full row) ──
return (
<div
className={cn(
'col-span-full rounded-xl ring-1 transition-all overflow-hidden',
completed ? 'bg-emerald-500/[0.06] ring-emerald-500/20' : cn(styles.expandedBg, styles.border),
className,
)}
>
{/* Compact header — click to collapse */}
<button
type="button"
onClick={() => onToggle(id)}
className="w-full flex items-center gap-3 p-3 text-left cursor-pointer select-none"
>
<div className="text-lg leading-none shrink-0">{icon}</div>
<span className={cn(
'text-sm font-medium flex-1 min-w-0',
completed && 'text-emerald-600 dark:text-emerald-400',
)}>
{title}
</span>
<ProgressRing progress={progress} completed={completed} category={category} />
</button>
{/* Expanded details */}
<div className="px-3 pb-3 pt-0 space-y-2">
{children}
</div>
</div>
);
}
// ─── Shared detail sub-components ─────────────────────────────────────────────
/** Description text */
export function MissionDescription({ children }: { children: ReactNode }) {
return <p className="text-xs text-muted-foreground leading-snug">{children}</p>;
}
/** Progress bar with fraction label */
export function MissionProgress({ current, required, completed }: { current: number; required: number; completed: boolean }) {
const pct = required > 0 ? Math.round((current / required) * 100) : 0;
return (
<div>
<div className="flex items-center justify-between text-[11px] text-muted-foreground mb-1">
<span className="tabular-nums">{current} / {required}</span>
<span className="tabular-nums">{pct}%</span>
</div>
<Progress value={pct} className={cn('h-1.5', completed && '[&>div]:bg-emerald-500')} />
</div>
);
}
/** Inline action link (navigate, external, modal) */
export function MissionAction({
label,
type,
onClick,
}: {
label: string;
type: 'navigate' | 'external_link' | 'open_modal';
onClick: () => void;
}) {
return (
<button
onClick={onClick}
className="inline-flex items-center gap-1 text-xs font-medium text-primary hover:underline"
>
{label}
{type === 'external_link' ? (
<ExternalLink className="size-3" />
) : (
<ChevronRight className="size-3" />
)}
</button>
);
}
/** Dynamic / live task hint */
export function DynamicHint({ current, required }: { current: number; required: number }) {
return (
<div className="flex items-center gap-1.5 text-[11px] text-amber-600/80 dark:text-amber-400/80">
<AlertCircle className="size-3 shrink-0" />
<span>Lowest stat: {current}% (need {required}%+)</span>
</div>
);
}
@@ -1,221 +0,0 @@
// src/blobbi/actions/components/HatchTasksPanel.tsx
/**
* UI component for displaying hatch task progress.
* Shows a list of tasks with progress indicators and action buttons.
*/
import { ExternalLink, Check, Loader2, ChevronRight } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { openUrl } from '@/lib/downloadFile';
import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
import type { HatchTask } from '../hooks/useHatchTasks';
// ─── Types ────────────────────────────────────────────────────────────────────
interface HatchTasksPanelProps {
tasks: HatchTask[];
allCompleted: boolean;
isLoading: boolean;
/** Called when user clicks "Create Post" action */
onOpenPostModal: () => void;
/** Called when all tasks are complete and user clicks "Hatch" */
onHatch: () => void;
/** Whether hatching is in progress */
isHatching?: boolean;
}
// ─── Task Row Component ───────────────────────────────────────────────────────
interface TaskRowProps {
task: HatchTask;
onOpenPostModal: () => void;
}
function TaskRow({ task, onOpenPostModal }: TaskRowProps) {
const navigate = useNavigate();
const handleAction = () => {
if (!task.action || !task.actionTarget) return;
switch (task.action) {
case 'navigate':
navigate(task.actionTarget);
break;
case 'external_link':
openUrl(task.actionTarget);
break;
case 'open_modal':
if (task.actionTarget === 'blobbi_post') {
onOpenPostModal();
}
break;
}
};
const progress = task.required > 1
? Math.round((task.current / task.required) * 100)
: task.completed ? 100 : 0;
return (
<div
className={cn(
"flex items-center gap-4 p-4 rounded-xl border transition-all",
task.completed
? "bg-emerald-500/5 border-emerald-500/20"
: "bg-card/60 border-border hover:border-primary/30"
)}
>
{/* Status indicator */}
<div className={cn(
"size-10 rounded-full flex items-center justify-center shrink-0",
task.completed
? "bg-emerald-500/20 text-emerald-600 dark:text-emerald-400"
: "bg-muted text-muted-foreground"
)}>
{task.completed ? (
<Check className="size-5" />
) : task.required > 1 ? (
<span className="text-sm font-medium">{task.current}/{task.required}</span>
) : (
<span className="text-lg"></span>
)}
</div>
{/* Task info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h4 className={cn(
"font-medium",
task.completed && "text-emerald-600 dark:text-emerald-400"
)}>
{task.name}
</h4>
{task.completed && (
<Badge variant="secondary" className="bg-emerald-500/20 text-emerald-700 dark:text-emerald-300 text-xs">
Complete
</Badge>
)}
</div>
<p className="text-sm text-muted-foreground">
{task.description}
</p>
{/* Progress bar for multi-step tasks */}
{task.required > 1 && !task.completed && (
<Progress value={progress} className="h-1.5 mt-2" />
)}
</div>
{/* Action button */}
{task.action && task.actionLabel && !task.completed && (
<Button
variant="outline"
size="sm"
onClick={handleAction}
className="shrink-0 gap-2"
>
{task.actionLabel}
{task.action === 'external_link' ? (
<ExternalLink className="size-3.5" />
) : (
<ChevronRight className="size-3.5" />
)}
</Button>
)}
</div>
);
}
// ─── Main Component ───────────────────────────────────────────────────────────
export function HatchTasksPanel({
tasks,
allCompleted,
isLoading,
onOpenPostModal,
onHatch,
isHatching = false,
}: HatchTasksPanelProps) {
const completedCount = tasks.filter(t => t.completed).length;
const totalTasks = tasks.length;
const overallProgress = Math.round((completedCount / totalTasks) * 100);
return (
<Card className="border-primary/20 bg-gradient-to-br from-primary/5 to-transparent">
<CardHeader className="pb-4">
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<span className="text-2xl">🥚</span>
Hatch Tasks
</CardTitle>
<CardDescription>
Complete these tasks to hatch your Blobbi
</CardDescription>
</div>
<Badge variant="outline" className="text-base px-3 py-1">
{completedCount}/{totalTasks}
</Badge>
</div>
{/* Overall progress */}
<div className="mt-4">
<div className="flex items-center justify-between text-sm mb-2">
<span className="text-muted-foreground">Overall progress</span>
<span className="font-medium">{overallProgress}%</span>
</div>
<Progress value={overallProgress} className="h-2" />
</div>
</CardHeader>
<CardContent className="space-y-3">
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
) : (
<>
{tasks.map(task => (
<TaskRow
key={task.id}
task={task}
onOpenPostModal={onOpenPostModal}
/>
))}
{/* Hatch button - only visible when all tasks complete */}
{allCompleted && (
<div className="pt-4 border-t border-border mt-4">
<Button
onClick={onHatch}
disabled={isHatching}
size="lg"
className="w-full gap-2 bg-gradient-to-r from-amber-500 to-orange-500 hover:from-amber-600 hover:to-orange-600 text-white"
>
{isHatching ? (
<>
<Loader2 className="size-5 animate-spin" />
Hatching...
</>
) : (
<>
<span className="text-xl">🐣</span>
Hatch Your Blobbi!
</>
)}
</Button>
</div>
)}
</>
)}
</CardContent>
</Card>
);
}
@@ -1,251 +0,0 @@
// src/blobbi/actions/components/InlineMusicPlayer.tsx
import { useCallback, useEffect } from 'react';
import { Music, Play, Pause, RotateCcw, MoreHorizontal, Loader2, AlertCircle, X, Volume2, VolumeX } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Slider } from '@/components/ui/slider';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { cn } from '@/lib/utils';
import { useAudioPlayback } from '../hooks/useAudioPlayback';
import type { SelectedTrack } from './PlayMusicModal';
// Re-export for external use
export type { SelectedTrack } from './PlayMusicModal';
interface InlineMusicPlayerProps {
/** The selected track */
selection: SelectedTrack;
/** Called when user wants to change the track */
onChangeTrack: () => void;
/** Called when user closes the player */
onClose: () => void;
/** Called when playback starts (for Blobbi reaction state) */
onPlaybackStart?: () => void;
/** Called when playback stops/pauses (for Blobbi reaction state) */
onPlaybackStop?: () => void;
/** Whether the action has been published (playback only starts after publish) */
isPublished: boolean;
/** Whether publishing is in progress */
isPublishing: boolean;
}
// ─── Component ────────────────────────────────────────────────────────────────
export function InlineMusicPlayer({
selection,
onChangeTrack,
onClose,
onPlaybackStart,
onPlaybackStop,
isPublished,
isPublishing,
}: InlineMusicPlayerProps) {
const {
state: playbackState,
error: playbackError,
load,
toggle,
restart,
stop,
isPlaying,
volume,
setVolume,
cleanup,
} = useAudioPlayback({
onEnded: () => {
onPlaybackStop?.();
},
});
// Auto-start playback when first published (idle -> playing)
// Note: 'stopped' state is NOT included here - stop is a terminal state
// that requires explicit user action (play button) to restart
useEffect(() => {
if (isPublished && playbackState === 'idle') {
load(selection.url, true);
onPlaybackStart?.();
}
}, [isPublished, playbackState, selection.url, load, onPlaybackStart]);
// Force reload when source URL changes while already playing/paused
useEffect(() => {
// Only trigger reload if we're in an active playback state with a different URL
if (isPublished && (playbackState === 'playing' || playbackState === 'paused')) {
// The load function will check if URL changed and reload if needed
load(selection.url, true);
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- Only react to selection.url changes
}, [selection.url]);
// Notify on playback state changes
useEffect(() => {
if (isPlaying) {
onPlaybackStart?.();
} else if (playbackState === 'paused' || playbackState === 'stopped') {
onPlaybackStop?.();
}
}, [isPlaying, playbackState, onPlaybackStart, onPlaybackStop]);
// Cleanup on close
const handleClose = useCallback(() => {
stop();
cleanup();
onPlaybackStop?.();
onClose();
}, [stop, cleanup, onPlaybackStop, onClose]);
// Handle play/pause toggle
const handleToggle = useCallback(async () => {
if (playbackState === 'idle' || playbackState === 'stopped') {
load(selection.url, true);
} else {
await toggle();
}
}, [playbackState, selection.url, load, toggle]);
// Track info
const trackTitle = selection.track.title;
const trackArtist = selection.track.artist;
const isLoading = playbackState === 'loading' || isPublishing;
const hasError = playbackState === 'error';
return (
<div className="mx-4 sm:mx-6 mb-4">
<div className={cn(
"rounded-xl border bg-card/80 backdrop-blur-sm overflow-hidden",
"shadow-sm transition-all",
isPlaying && "ring-2 ring-pink-500/30"
)}>
{/* Main content row */}
<div className="flex items-center gap-3 p-3">
{/* Music icon / Now Playing indicator */}
<div className={cn(
"size-10 rounded-lg flex items-center justify-center shrink-0",
isPlaying
? "bg-pink-500/20"
: "bg-muted"
)}>
<Music className={cn(
"size-5",
isPlaying ? "text-pink-500 animate-pulse" : "text-muted-foreground"
)} />
</div>
{/* Track info */}
<div className="flex-1 min-w-0">
<p className="font-medium text-sm truncate">{trackTitle}</p>
{trackArtist && (
<p className="text-xs text-muted-foreground truncate">{trackArtist}</p>
)}
{!trackArtist && (
<p className="text-xs text-muted-foreground">
{isPlaying ? 'Now playing...' : isPublishing ? 'Starting...' : 'Ready to play'}
</p>
)}
</div>
{/* Controls */}
<div className="flex items-center gap-1 shrink-0">
{/* Play/Pause button */}
<Button
size="icon"
variant="ghost"
onClick={handleToggle}
disabled={isLoading || !isPublished}
className="size-9 rounded-full"
>
{isLoading ? (
<Loader2 className="size-4 animate-spin" />
) : isPlaying ? (
<Pause className="size-4" />
) : (
<Play className="size-4 ml-0.5" />
)}
</Button>
{/* Restart button - only show when actively playing or paused */}
{isPublished && (playbackState === 'playing' || playbackState === 'paused') && (
<Button
size="icon"
variant="ghost"
onClick={() => {
restart();
}}
className="size-9 rounded-full"
title="Restart from beginning"
>
<RotateCcw className="size-3.5" />
</Button>
)}
{/* Volume control */}
<Popover>
<PopoverTrigger asChild>
<Button
size="icon"
variant="ghost"
className="size-9 rounded-full"
title={volume === 0 ? 'Unmute' : 'Volume'}
>
{volume === 0 ? (
<VolumeX className="size-4" />
) : (
<Volume2 className="size-4" />
)}
</Button>
</PopoverTrigger>
<PopoverContent
side="top"
align="center"
className="w-32 p-3"
>
<Slider
value={[volume * 100]}
onValueChange={([val]) => setVolume(val / 100)}
max={100}
step={1}
className="w-full"
/>
</PopoverContent>
</Popover>
{/* Change track button */}
<Button
size="icon"
variant="ghost"
onClick={onChangeTrack}
disabled={isPublishing}
className="size-9 rounded-full"
>
<MoreHorizontal className="size-4" />
</Button>
{/* Close button */}
<Button
size="icon"
variant="ghost"
onClick={handleClose}
disabled={isPublishing}
className="size-9 rounded-full text-muted-foreground hover:text-foreground"
>
<X className="size-4" />
</Button>
</div>
</div>
{/* Error message */}
{hasError && playbackError && (
<div className="px-3 pb-3">
<div className="flex items-start gap-2 p-2 rounded-lg bg-amber-500/10 text-amber-600 dark:text-amber-400">
<AlertCircle className="size-4 mt-0.5 shrink-0" />
<p className="text-xs">{playbackError.message}</p>
</div>
</div>
)}
</div>
</div>
);
}
@@ -1,487 +0,0 @@
// src/blobbi/actions/components/InlineSingCard.tsx
import { useState, useRef, useCallback, useEffect } from 'react';
import {
Mic,
Play,
Pause,
Square,
FileText,
Check,
X,
Loader2,
AlertCircle,
RefreshCw,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { useAudioPlayback } from '../hooks/useAudioPlayback';
import { getRandomLyrics, type LyricsEntry } from '../lib/blobbi-random-lyrics';
// ─── Types ────────────────────────────────────────────────────────────────────
type RecordingState = 'idle' | 'requesting' | 'recording' | 'recorded' | 'error';
interface InlineSingCardProps {
/** Called when user confirms the singing action (publish the action) */
onConfirm: () => Promise<void>;
/** Called when user closes the sing card */
onClose: () => void;
/** Called when recording starts (for Blobbi reaction) */
onRecordingStart?: () => void;
/** Called when recording stops (for Blobbi reaction) */
onRecordingStop?: () => void;
/** Whether publishing is in progress */
isPublishing: boolean;
}
// ─── MIME Type Selection ──────────────────────────────────────────────────────
const AUDIO_MIME_CANDIDATES = [
'audio/webm;codecs=opus',
'audio/webm',
'audio/mp4',
'audio/ogg;codecs=opus',
'audio/ogg',
] as const;
function getSupportedAudioMimeType(): string | undefined {
if (typeof MediaRecorder === 'undefined') {
return undefined;
}
for (const mimeType of AUDIO_MIME_CANDIDATES) {
if (MediaRecorder.isTypeSupported(mimeType)) {
return mimeType;
}
}
return undefined;
}
// ─── Component ────────────────────────────────────────────────────────────────
export function InlineSingCard({
onConfirm,
onClose,
onRecordingStart,
onRecordingStop,
isPublishing,
}: InlineSingCardProps) {
// Recording state
const [recordingState, setRecordingState] = useState<RecordingState>('idle');
const [recordingError, setRecordingError] = useState<string | null>(null);
const [recordingDuration, setRecordingDuration] = useState(0);
const [audioUrl, setAudioUrl] = useState<string | null>(null);
// Lyrics state
const [currentLyrics, setCurrentLyrics] = useState<LyricsEntry | null>(null);
const [showLyrics, setShowLyrics] = useState(false);
// Refs
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<Blob[]>([]);
const streamRef = useRef<MediaStream | null>(null);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const actualMimeTypeRef = useRef<string | undefined>(undefined);
// Audio playback for preview
const {
state: playbackState,
error: playbackError,
load: loadAudio,
toggle: togglePlayback,
stop: stopPlayback,
isPlaying,
cleanup: cleanupPlayback,
} = useAudioPlayback();
// Cleanup on unmount
useEffect(() => {
return () => {
cleanupAll();
};
}, []);
// Cleanup all resources
const cleanupAll = useCallback(() => {
// Stop timer
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
// Stop media recorder
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
try {
mediaRecorderRef.current.stop();
} catch {
// Ignore errors during cleanup
}
}
mediaRecorderRef.current = null;
// Stop stream tracks
if (streamRef.current) {
streamRef.current.getTracks().forEach(track => track.stop());
streamRef.current = null;
}
// Cleanup playback
cleanupPlayback();
// Revoke URL
if (audioUrl) {
URL.revokeObjectURL(audioUrl);
}
}, [audioUrl, cleanupPlayback]);
// Reset recording
const resetRecording = useCallback(() => {
cleanupAll();
setRecordingState('idle');
setRecordingError(null);
setRecordingDuration(0);
setAudioUrl(null);
chunksRef.current = [];
actualMimeTypeRef.current = undefined;
// Keep lyrics
}, [cleanupAll]);
// Check browser support
const checkRecordingSupport = (): boolean => {
if (typeof navigator === 'undefined') return false;
if (!navigator.mediaDevices) return false;
if (!navigator.mediaDevices.getUserMedia) return false;
if (typeof MediaRecorder === 'undefined') return false;
return true;
};
// Start recording
const startRecording = useCallback(async () => {
if (!checkRecordingSupport()) {
setRecordingError('Audio recording is not supported in this browser.');
setRecordingState('error');
return;
}
setRecordingState('requesting');
setRecordingError(null);
try {
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
}
});
streamRef.current = stream;
chunksRef.current = [];
// Get supported MIME type
const supportedMimeType = getSupportedAudioMimeType();
// Create MediaRecorder
let mediaRecorder: MediaRecorder;
if (supportedMimeType) {
mediaRecorder = new MediaRecorder(stream, { mimeType: supportedMimeType });
} else {
mediaRecorder = new MediaRecorder(stream);
}
actualMimeTypeRef.current = mediaRecorder.mimeType || supportedMimeType;
mediaRecorderRef.current = mediaRecorder;
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
chunksRef.current.push(event.data);
}
};
mediaRecorder.onstop = () => {
const blobMimeType = actualMimeTypeRef.current || 'audio/webm';
const blob = new Blob(chunksRef.current, { type: blobMimeType });
const url = URL.createObjectURL(blob);
setAudioUrl(url);
setRecordingState('recorded');
if (streamRef.current) {
streamRef.current.getTracks().forEach(track => track.stop());
streamRef.current = null;
}
};
mediaRecorder.onerror = () => {
setRecordingError('Recording failed. Please try again.');
setRecordingState('error');
};
mediaRecorder.start(100);
setRecordingState('recording');
setRecordingDuration(0);
// Notify parent that recording started (for Blobbi reaction)
onRecordingStart?.();
timerRef.current = setInterval(() => {
setRecordingDuration(prev => prev + 1);
}, 1000);
} catch (err) {
if (err instanceof Error) {
if (err.name === 'NotAllowedError' || err.name === 'PermissionDeniedError') {
setRecordingError('Microphone access was denied.');
} else if (err.name === 'NotFoundError') {
setRecordingError('No microphone found.');
} else {
setRecordingError(err.message);
}
} else {
setRecordingError('Failed to access microphone.');
}
setRecordingState('error');
}
}, [onRecordingStart]);
// Stop recording
const stopRecording = useCallback(() => {
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
mediaRecorderRef.current.stop();
}
// Notify parent that recording stopped (for Blobbi reaction)
onRecordingStop?.();
}, [onRecordingStop]);
// Handle preview playback
const handlePreview = useCallback(() => {
if (!audioUrl) return;
if (playbackState === 'idle') {
loadAudio(audioUrl, true);
} else {
togglePlayback();
}
}, [audioUrl, playbackState, loadAudio, togglePlayback]);
// Handle confirm
const handleConfirm = useCallback(async () => {
stopPlayback();
await onConfirm();
// After successful publish, close the card
onClose();
}, [stopPlayback, onConfirm, onClose]);
// Handle close
const handleClose = useCallback(() => {
cleanupAll();
onClose();
}, [cleanupAll, onClose]);
// Handle lyrics toggle
const handleLyricsToggle = useCallback(() => {
if (!currentLyrics && !showLyrics) {
// Generate lyrics on first open
setCurrentLyrics(getRandomLyrics());
}
setShowLyrics(!showLyrics);
}, [currentLyrics, showLyrics]);
// Get new lyrics
const handleNewLyrics = useCallback(() => {
setCurrentLyrics(getRandomLyrics());
}, []);
// Format duration
const formatDuration = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
const hasRecording = recordingState === 'recorded';
const isRecording = recordingState === 'recording';
const canConfirm = hasRecording && !isPublishing;
return (
<div className="mx-4 sm:mx-6 mb-4">
<div className={cn(
"rounded-xl border bg-card/80 backdrop-blur-sm overflow-hidden",
"shadow-sm transition-all",
isRecording && "ring-2 ring-red-500/30"
)}>
{/* Lyrics panel (expands upward visually by being above controls) */}
{showLyrics && currentLyrics && (
<div className="px-3 pt-3 pb-2 border-b border-border/50">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium">{currentLyrics.title}</span>
<Button
size="icon"
variant="ghost"
onClick={handleNewLyrics}
className="size-7 rounded-full"
>
<RefreshCw className="size-3" />
</Button>
</div>
<div className="p-3 rounded-lg bg-muted/50 text-sm leading-relaxed whitespace-pre-line max-h-32 overflow-y-auto">
{currentLyrics.lines.join('\n')}
</div>
</div>
)}
{/* Status row (recording/recorded info) */}
{(isRecording || hasRecording) && (
<div className="px-3 pt-3 pb-2 border-b border-border/50">
<div className="flex items-center justify-center gap-2">
{isRecording && (
<>
<div className="size-2 rounded-full bg-red-500 animate-pulse" />
<span className="text-sm font-mono font-medium text-red-500">
{formatDuration(recordingDuration)}
</span>
<span className="text-xs text-muted-foreground">Recording...</span>
</>
)}
{hasRecording && !isRecording && (
<>
<Check className="size-4 text-purple-500" />
<span className="text-sm font-mono font-medium text-purple-500">
{formatDuration(recordingDuration)}
</span>
<span className="text-xs text-muted-foreground">Recorded</span>
</>
)}
</div>
</div>
)}
{/* Error message */}
{(recordingError || playbackError) && (
<div className="px-3 pt-2">
<div className="flex items-start gap-2 p-2 rounded-lg bg-amber-500/10 text-amber-600 dark:text-amber-400">
<AlertCircle className="size-4 mt-0.5 shrink-0" />
<p className="text-xs">{recordingError || playbackError?.message}</p>
</div>
</div>
)}
{/* Main controls row */}
<div className="flex items-center justify-between gap-2 p-3">
{/* Left: Lyrics button */}
<Button
size="icon"
variant={showLyrics ? "secondary" : "ghost"}
onClick={handleLyricsToggle}
className="size-10 rounded-full shrink-0"
>
<FileText className="size-4" />
</Button>
{/* Center: Record/Stop button */}
<div className="flex items-center gap-2">
{!isRecording && !hasRecording && (
<Button
onClick={startRecording}
disabled={isPublishing}
className="rounded-full px-6 bg-purple-500 hover:bg-purple-600"
>
<Mic className="size-4 mr-2" />
Sing
</Button>
)}
{isRecording && (
<Button
onClick={stopRecording}
variant="destructive"
className="rounded-full px-6"
>
<Square className="size-4 mr-2" />
Stop
</Button>
)}
{hasRecording && !isRecording && (
<>
<Button
onClick={resetRecording}
variant="outline"
size="icon"
className="size-10 rounded-full"
>
<RefreshCw className="size-4" />
</Button>
<Button
onClick={handleConfirm}
disabled={!canConfirm}
className="rounded-full px-6 bg-purple-500 hover:bg-purple-600"
>
{isPublishing ? (
<Loader2 className="size-4 mr-2 animate-spin" />
) : (
<Check className="size-4 mr-2" />
)}
{isPublishing ? 'Singing...' : 'Sing for Blobbi'}
</Button>
</>
)}
</div>
{/* Right: Preview button (when recording exists) */}
{hasRecording ? (
<Button
size="icon"
variant="ghost"
onClick={handlePreview}
disabled={isPublishing}
className="size-10 rounded-full shrink-0"
>
{isPlaying ? (
<Pause className="size-4" />
) : (
<Play className="size-4 ml-0.5" />
)}
</Button>
) : (
/* Close button when no recording */
<Button
size="icon"
variant="ghost"
onClick={handleClose}
className="size-10 rounded-full shrink-0 text-muted-foreground hover:text-foreground"
>
<X className="size-4" />
</Button>
)}
</div>
{/* Close button row when recording exists */}
{hasRecording && (
<div className="px-3 pb-3 pt-0 flex justify-end">
<Button
size="sm"
variant="ghost"
onClick={handleClose}
disabled={isPublishing}
className="text-muted-foreground hover:text-foreground"
>
<X className="size-3 mr-1" />
Cancel
</Button>
</div>
)}
</div>
</div>
);
}
@@ -1,301 +0,0 @@
// src/blobbi/actions/components/PlayMusicModal.tsx
import { useState, useRef, useCallback, useEffect } from 'react';
import { Music, Play, Pause, Check, Loader2, Volume2, AlertCircle } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import {
getAllTracks,
formatTrackDuration,
type BlobbiTrack,
} from '../lib/blobbi-track-catalog';
// ─── Types ────────────────────────────────────────────────────────────────────
/**
* Selected track for the music player
*/
export interface SelectedTrack {
track: BlobbiTrack;
url: string;
}
interface PlayMusicModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** Called with the selected track when user confirms */
onConfirm: (selection: SelectedTrack) => void;
isLoading: boolean;
}
// ─── Component ────────────────────────────────────────────────────────────────
export function PlayMusicModal({
open,
onOpenChange,
onConfirm,
isLoading,
}: PlayMusicModalProps) {
const [selectedTrack, setSelectedTrack] = useState<SelectedTrack | null>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [error, setError] = useState<string | null>(null);
const audioRef = useRef<HTMLAudioElement | null>(null);
// Track the current audio source URL to detect changes
const currentAudioUrlRef = useRef<string | null>(null);
const tracks = getAllTracks();
// Cleanup audio on unmount
useEffect(() => {
return () => {
if (audioRef.current) {
audioRef.current.pause();
audioRef.current = null;
}
};
}, []);
// Reset state when modal opens
useEffect(() => {
if (open) {
setSelectedTrack(null);
setIsPlaying(false);
setError(null);
currentAudioUrlRef.current = null;
}
}, [open]);
// Handle selecting a track
const handleSelectTrack = useCallback((track: BlobbiTrack) => {
// Stop current playback
if (audioRef.current) {
audioRef.current.pause();
setIsPlaying(false);
}
setSelectedTrack({ track, url: track.url });
setError(null);
}, []);
// Handle play/pause preview
const handleTogglePlay = useCallback(() => {
if (!selectedTrack) return;
const audioUrl = selectedTrack.url;
// Check if we need to create a new Audio instance (source changed or first time)
const needsNewAudio = !audioRef.current || currentAudioUrlRef.current !== audioUrl;
if (needsNewAudio) {
// Stop and cleanup old audio if exists
if (audioRef.current) {
audioRef.current.pause();
audioRef.current.onended = null;
audioRef.current.onerror = null;
}
// Create new Audio instance with the correct source
audioRef.current = new Audio(audioUrl);
currentAudioUrlRef.current = audioUrl;
audioRef.current.onended = () => setIsPlaying(false);
audioRef.current.onerror = () => {
setError('Failed to load this track. Please try another one.');
setIsPlaying(false);
};
}
if (isPlaying && !needsNewAudio) {
// Pause current playback
audioRef.current?.pause();
setIsPlaying(false);
} else {
// Start playback (either new source or resuming)
audioRef.current?.play().catch(() => {
setError('Failed to play this track. Please try another one.');
setIsPlaying(false);
});
setIsPlaying(true);
}
}, [selectedTrack, isPlaying]);
// Handle confirm
const handleConfirm = useCallback(() => {
if (!selectedTrack) return;
// Stop playback
if (audioRef.current) {
audioRef.current.pause();
setIsPlaying(false);
}
onConfirm(selectedTrack);
}, [selectedTrack, onConfirm]);
// Handle close
const handleClose = useCallback((isOpen: boolean) => {
if (!isOpen && audioRef.current) {
audioRef.current.pause();
setIsPlaying(false);
}
onOpenChange(isOpen);
}, [onOpenChange]);
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-md max-h-[85vh] flex flex-col p-0">
{/* Header */}
<DialogHeader className="px-6 pt-6 pb-4 border-b">
<div className="flex items-center gap-3">
<div className="size-10 rounded-xl bg-gradient-to-br from-pink-500/20 to-pink-500/5 flex items-center justify-center">
<Music className="size-5 text-pink-500" />
</div>
<div>
<DialogTitle className="text-xl">Play Music</DialogTitle>
<p className="text-sm text-muted-foreground">
Choose a track to play for your Blobbi
</p>
</div>
</div>
</DialogHeader>
{/* Content - Track List */}
<div className="flex-1 overflow-y-auto px-6 py-4">
<div className="grid gap-2">
{tracks.map((track) => (
<TrackRow
key={track.id}
track={track}
isSelected={selectedTrack?.track.id === track.id}
onSelect={() => handleSelectTrack(track)}
/>
))}
</div>
{error && (
<div className="mt-4 p-3 rounded-lg bg-amber-500/10 border border-amber-500/30">
<div className="flex items-start gap-2">
<AlertCircle className="size-4 text-amber-500 mt-0.5 shrink-0" />
<p className="text-sm text-amber-600 dark:text-amber-400">{error}</p>
</div>
</div>
)}
</div>
{/* Footer */}
<div className="px-6 py-4 border-t bg-muted/30">
{/* Preview Controls */}
{selectedTrack && (
<div className="mb-4 p-3 rounded-lg bg-card border">
<div className="flex items-center gap-3">
<Button
size="icon"
variant="outline"
onClick={handleTogglePlay}
className="size-10 rounded-full shrink-0"
>
{isPlaying ? (
<Pause className="size-4" />
) : (
<Play className="size-4 ml-0.5" />
)}
</Button>
<div className="flex-1 min-w-0">
<p className="font-medium truncate text-sm">{selectedTrack.track.title}</p>
<p className="text-xs text-muted-foreground">
{isPlaying ? 'Now playing...' : 'Click to preview'}
</p>
</div>
{isPlaying && (
<Volume2 className="size-4 text-primary animate-pulse shrink-0" />
)}
</div>
</div>
)}
{/* Action Buttons */}
<div className="flex gap-3">
<Button
variant="outline"
onClick={() => handleClose(false)}
className="flex-1"
disabled={isLoading}
>
Cancel
</Button>
<Button
onClick={handleConfirm}
disabled={!selectedTrack || isLoading}
className="flex-1"
>
{isLoading ? (
<>
<Loader2 className="size-4 mr-2 animate-spin" />
Playing...
</>
) : (
<>
<Music className="size-4 mr-2" />
Play for Blobbi
</>
)}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
// ─── Track Row Component ──────────────────────────────────────────────────────
interface TrackRowProps {
track: BlobbiTrack;
isSelected: boolean;
onSelect: () => void;
}
function TrackRow({ track, isSelected, onSelect }: TrackRowProps) {
return (
<button
type="button"
onClick={onSelect}
className={cn(
"w-full p-3 rounded-xl text-left transition-all",
"border hover:border-primary/30",
isSelected
? "border-primary bg-primary/5 ring-2 ring-primary/20"
: "border-border bg-card/60"
)}
>
<div className="flex items-center gap-3">
<div className={cn(
"size-10 rounded-lg flex items-center justify-center",
isSelected ? "bg-primary/20" : "bg-muted"
)}>
<Music className={cn(
"size-5",
isSelected ? "text-primary" : "text-muted-foreground"
)} />
</div>
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{track.title}</p>
<p className="text-sm text-muted-foreground">{track.artist}</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="text-sm text-muted-foreground">
{formatTrackDuration(track.durationSeconds)}
</span>
{isSelected && <Check className="size-4 text-primary" />}
</div>
</div>
</button>
);
}
-601
View File
@@ -1,601 +0,0 @@
// src/blobbi/actions/components/SingModal.tsx
import { useState, useRef, useCallback, useEffect } from 'react';
import { Mic, MicOff, Play, Pause, Square, Loader2, AlertCircle, RotateCcw, Sparkles, ChevronDown, ChevronUp } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { getRandomLyrics, type LyricsEntry } from '../lib/blobbi-random-lyrics';
// ─── Types ────────────────────────────────────────────────────────────────────
interface SingModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: () => void;
isLoading: boolean;
}
type RecordingState = 'idle' | 'requesting' | 'recording' | 'recorded' | 'playing' | 'error';
// ─── MIME Type Selection Helper ───────────────────────────────────────────────
/**
* Ordered list of MIME types to try for audio recording.
* The first supported type will be used.
*/
const AUDIO_MIME_CANDIDATES = [
'audio/webm;codecs=opus',
'audio/webm',
'audio/mp4',
'audio/ogg;codecs=opus',
'audio/ogg',
] as const;
/**
* Get the first supported MIME type for MediaRecorder.
* Returns undefined if no explicit MIME type is supported (let browser decide).
*/
function getSupportedAudioMimeType(): string | undefined {
if (typeof MediaRecorder === 'undefined') {
return undefined;
}
for (const mimeType of AUDIO_MIME_CANDIDATES) {
if (MediaRecorder.isTypeSupported(mimeType)) {
return mimeType;
}
}
// No explicit MIME type supported, let browser use default
return undefined;
}
// ─── Component ────────────────────────────────────────────────────────────────
export function SingModal({
open,
onOpenChange,
onConfirm,
isLoading,
}: SingModalProps) {
const [recordingState, setRecordingState] = useState<RecordingState>('idle');
const [error, setError] = useState<string | null>(null);
const [playbackError, setPlaybackError] = useState<string | null>(null);
const [recordingDuration, setRecordingDuration] = useState(0);
const [audioUrl, setAudioUrl] = useState<string | null>(null);
const [currentLyrics, setCurrentLyrics] = useState<LyricsEntry | null>(null);
const [showLyrics, setShowLyrics] = useState(false);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<Blob[]>([]);
const streamRef = useRef<MediaStream | null>(null);
const audioRef = useRef<HTMLAudioElement | null>(null);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Track the actual MIME type used by the recorder
const actualMimeTypeRef = useRef<string | undefined>(undefined);
// Cleanup on unmount
useEffect(() => {
return () => {
cleanup();
};
}, []);
// Reset state when modal opens
useEffect(() => {
if (open) {
resetRecording();
} else {
cleanup();
}
}, [open]);
const cleanup = useCallback(() => {
// Stop timer
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
// Stop media recorder
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
mediaRecorderRef.current.stop();
}
mediaRecorderRef.current = null;
// Stop stream tracks
if (streamRef.current) {
streamRef.current.getTracks().forEach(track => track.stop());
streamRef.current = null;
}
// Stop audio playback
if (audioRef.current) {
audioRef.current.pause();
audioRef.current = null;
}
// Revoke URL
if (audioUrl) {
URL.revokeObjectURL(audioUrl);
}
}, [audioUrl]);
const resetRecording = useCallback(() => {
cleanup();
setRecordingState('idle');
setError(null);
setPlaybackError(null);
setRecordingDuration(0);
setAudioUrl(null);
chunksRef.current = [];
currentPlaybackUrlRef.current = null;
actualMimeTypeRef.current = undefined;
// Keep lyrics when re-recording so user can sing the same song
}, [cleanup]);
// Handle getting random lyrics
const handleRandomLyrics = useCallback(() => {
const lyrics = getRandomLyrics();
setCurrentLyrics(lyrics);
setShowLyrics(true);
}, []);
// Check if browser supports media recording
const checkRecordingSupport = (): boolean => {
if (typeof navigator === 'undefined') return false;
if (!navigator.mediaDevices) return false;
if (!navigator.mediaDevices.getUserMedia) return false;
if (typeof MediaRecorder === 'undefined') return false;
return true;
};
// Start recording
const startRecording = useCallback(async () => {
if (!checkRecordingSupport()) {
setError('Audio recording is not supported in this browser.');
setRecordingState('error');
return;
}
setRecordingState('requesting');
setError(null);
setPlaybackError(null);
try {
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
}
});
streamRef.current = stream;
chunksRef.current = [];
// Get the first supported MIME type using our helper
const supportedMimeType = getSupportedAudioMimeType();
// Create MediaRecorder with or without explicit MIME type
let mediaRecorder: MediaRecorder;
if (supportedMimeType) {
mediaRecorder = new MediaRecorder(stream, { mimeType: supportedMimeType });
} else {
// Let browser choose default MIME type
mediaRecorder = new MediaRecorder(stream);
}
// Store the actual MIME type being used (may differ from what we requested)
actualMimeTypeRef.current = mediaRecorder.mimeType || supportedMimeType;
mediaRecorderRef.current = mediaRecorder;
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
chunksRef.current.push(event.data);
}
};
mediaRecorder.onstop = () => {
// Create blob from chunks using the actual MIME type used by the recorder
const blobMimeType = actualMimeTypeRef.current || 'audio/webm';
const blob = new Blob(chunksRef.current, { type: blobMimeType });
const url = URL.createObjectURL(blob);
setAudioUrl(url);
setRecordingState('recorded');
// Stop stream tracks
if (streamRef.current) {
streamRef.current.getTracks().forEach(track => track.stop());
streamRef.current = null;
}
};
mediaRecorder.onerror = () => {
setError('Recording failed. Please try again.');
setRecordingState('error');
};
// Start recording
mediaRecorder.start(100); // Collect data every 100ms
setRecordingState('recording');
setRecordingDuration(0);
// Start timer
timerRef.current = setInterval(() => {
setRecordingDuration(prev => prev + 1);
}, 1000);
} catch (err) {
if (err instanceof Error) {
if (err.name === 'NotAllowedError' || err.name === 'PermissionDeniedError') {
setError('Microphone access was denied. Please allow microphone access and try again.');
} else if (err.name === 'NotFoundError') {
setError('No microphone found. Please connect a microphone and try again.');
} else {
setError(`Failed to access microphone: ${err.message}`);
}
} else {
setError('Failed to access microphone. Please try again.');
}
setRecordingState('error');
}
}, []);
// Stop recording
const stopRecording = useCallback(() => {
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
mediaRecorderRef.current.stop();
}
}, []);
// Track the current audio URL to detect changes
const currentPlaybackUrlRef = useRef<string | null>(null);
// Play/pause preview
const togglePlayback = useCallback(() => {
if (!audioUrl) return;
// Clear previous playback error when attempting to play
setPlaybackError(null);
if (recordingState === 'playing') {
if (audioRef.current) {
audioRef.current.pause();
}
setRecordingState('recorded');
} else {
// Check if we need to create a new Audio instance (URL changed or first time)
const needsNewAudio = !audioRef.current || currentPlaybackUrlRef.current !== audioUrl;
if (needsNewAudio) {
// Cleanup old audio if exists
if (audioRef.current) {
audioRef.current.pause();
audioRef.current.onended = null;
audioRef.current.onerror = null;
}
// Create new Audio instance with the recorded audio URL
audioRef.current = new Audio(audioUrl);
currentPlaybackUrlRef.current = audioUrl;
audioRef.current.onended = () => setRecordingState('recorded');
// Handle playback errors with user-visible message
audioRef.current.onerror = () => {
setPlaybackError('This browser could not play the recorded audio preview. Your recording was still created successfully.');
setRecordingState('recorded');
};
}
audioRef.current?.play()
.then(() => {
setRecordingState('playing');
})
.catch((err) => {
console.error('Failed to play recording:', err);
// Provide user-friendly error message
if (err.name === 'NotSupportedError') {
setPlaybackError('Recording was created, but playback preview is not supported in this browser.');
} else if (err.name === 'NotAllowedError') {
setPlaybackError('Playback was blocked. Try interacting with the page first.');
} else {
setPlaybackError('Could not play the recording preview. Your recording was still created successfully.');
}
setRecordingState('recorded');
});
}
}, [audioUrl, recordingState]);
// Handle confirm
const handleConfirm = useCallback(() => {
if (audioRef.current) {
audioRef.current.pause();
}
onConfirm();
}, [onConfirm]);
// Handle close
const handleClose = useCallback((isOpen: boolean) => {
if (!isOpen) {
cleanup();
}
onOpenChange(isOpen);
}, [onOpenChange, cleanup]);
// Format duration
const formatDuration = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
const hasRecording = recordingState === 'recorded' || recordingState === 'playing';
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-md max-h-[85vh] flex flex-col p-0">
{/* Header */}
<DialogHeader className="px-6 pt-6 pb-4 border-b">
<div className="flex items-center gap-3">
<div className="size-10 rounded-xl bg-gradient-to-br from-purple-500/20 to-purple-500/5 flex items-center justify-center">
<Mic className="size-5 text-purple-500" />
</div>
<div>
<DialogTitle className="text-xl">Sing</DialogTitle>
<p className="text-sm text-muted-foreground">
Record yourself singing for your Blobbi
</p>
</div>
</div>
</DialogHeader>
{/* Content */}
<div className="flex-1 px-6 py-8">
<div className="flex flex-col items-center justify-center gap-6">
{/* Recording Visualization */}
<div className={cn(
"relative size-40 rounded-full flex items-center justify-center transition-all",
recordingState === 'recording' && "animate-pulse",
recordingState === 'recording'
? "bg-red-500/10 ring-4 ring-red-500/30"
: hasRecording
? "bg-purple-500/10 ring-4 ring-purple-500/30"
: "bg-muted"
)}>
{/* Animated rings for recording */}
{recordingState === 'recording' && (
<>
<div className="absolute inset-0 rounded-full bg-red-500/10 animate-ping" />
<div className="absolute inset-4 rounded-full bg-red-500/10 animate-ping animation-delay-150" />
</>
)}
{/* Icon */}
<div className={cn(
"relative size-20 rounded-full flex items-center justify-center",
recordingState === 'recording'
? "bg-red-500 text-white"
: hasRecording
? "bg-purple-500 text-white"
: "bg-muted-foreground/20"
)}>
{recordingState === 'requesting' ? (
<Loader2 className="size-8 animate-spin" />
) : recordingState === 'recording' ? (
<Mic className="size-8" />
) : hasRecording ? (
recordingState === 'playing' ? (
<Pause className="size-8" />
) : (
<Play className="size-8 ml-1" />
)
) : (
<MicOff className="size-8 text-muted-foreground" />
)}
</div>
</div>
{/* Duration / Status */}
<div className="text-center">
{recordingState === 'idle' && (
<p className="text-muted-foreground">Tap the button below to start recording</p>
)}
{recordingState === 'requesting' && (
<p className="text-muted-foreground">Requesting microphone access...</p>
)}
{recordingState === 'recording' && (
<>
<p className="text-3xl font-mono font-bold text-red-500">
{formatDuration(recordingDuration)}
</p>
<p className="text-sm text-muted-foreground mt-1">Recording...</p>
</>
)}
{hasRecording && (
<>
<p className="text-3xl font-mono font-bold text-purple-500">
{formatDuration(recordingDuration)}
</p>
<p className="text-sm text-muted-foreground mt-1">
{recordingState === 'playing' ? 'Playing...' : 'Tap to preview'}
</p>
</>
)}
{recordingState === 'error' && (
<p className="text-destructive">Recording failed</p>
)}
</div>
{/* Error Message */}
{error && (
<div className="w-full p-3 rounded-lg bg-destructive/10 border border-destructive/30">
<div className="flex items-start gap-2">
<AlertCircle className="size-4 text-destructive mt-0.5 shrink-0" />
<p className="text-sm text-destructive">{error}</p>
</div>
</div>
)}
{/* Playback Error Message (non-fatal, recording still works) */}
{playbackError && (
<div className="w-full p-3 rounded-lg bg-amber-500/10 border border-amber-500/30">
<div className="flex items-start gap-2">
<AlertCircle className="size-4 text-amber-500 mt-0.5 shrink-0" />
<p className="text-sm text-amber-600 dark:text-amber-400">{playbackError}</p>
</div>
</div>
)}
{/* Lyrics Helper */}
<div className="w-full">
{!currentLyrics ? (
<Button
variant="outline"
size="sm"
onClick={handleRandomLyrics}
className="w-full gap-2"
>
<Sparkles className="size-4" />
Need lyrics? Get random lyrics
</Button>
) : (
<div className="rounded-lg border bg-card/60">
<button
type="button"
onClick={() => setShowLyrics(!showLyrics)}
className="w-full flex items-center justify-between p-3 text-left"
>
<div className="flex items-center gap-2">
<Sparkles className="size-4 text-purple-500" />
<span className="font-medium text-sm">{currentLyrics.title}</span>
</div>
{showLyrics ? (
<ChevronUp className="size-4 text-muted-foreground" />
) : (
<ChevronDown className="size-4 text-muted-foreground" />
)}
</button>
{showLyrics && (
<div className="px-3 pb-3 pt-0">
<div className="p-3 rounded-md bg-muted/50 text-sm leading-relaxed whitespace-pre-line">
{currentLyrics.lines.join('\n')}
</div>
<Button
variant="ghost"
size="sm"
onClick={handleRandomLyrics}
className="w-full mt-2 gap-2 text-muted-foreground"
>
<RotateCcw className="size-3" />
Get different lyrics
</Button>
</div>
)}
</div>
)}
</div>
{/* Recording Controls */}
<div className="flex items-center gap-3">
{recordingState === 'idle' || recordingState === 'error' ? (
<Button
size="lg"
onClick={startRecording}
className="rounded-full px-8 bg-purple-500 hover:bg-purple-600"
>
<Mic className="size-5 mr-2" />
Start Recording
</Button>
) : recordingState === 'recording' ? (
<Button
size="lg"
variant="destructive"
onClick={stopRecording}
className="rounded-full px-8"
>
<Square className="size-5 mr-2" />
Stop
</Button>
) : hasRecording ? (
<>
<Button
size="lg"
variant="outline"
onClick={togglePlayback}
className="rounded-full"
>
{recordingState === 'playing' ? (
<>
<Pause className="size-5 mr-2" />
Pause
</>
) : (
<>
<Play className="size-5 mr-2" />
Preview
</>
)}
</Button>
<Button
size="lg"
variant="ghost"
onClick={resetRecording}
className="rounded-full"
>
<RotateCcw className="size-5 mr-2" />
Re-record
</Button>
</>
) : null}
</div>
</div>
</div>
{/* Footer */}
<div className="px-6 py-4 border-t bg-muted/30">
<div className="flex gap-3">
<Button
variant="outline"
onClick={() => handleClose(false)}
className="flex-1"
disabled={isLoading}
>
Cancel
</Button>
<Button
onClick={handleConfirm}
disabled={!hasRecording || isLoading}
className="flex-1"
>
{isLoading ? (
<>
<Loader2 className="size-4 mr-2 animate-spin" />
Singing...
</>
) : (
<>
<Mic className="size-4 mr-2" />
Sing for Blobbi
</>
)}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
@@ -1,125 +0,0 @@
// src/blobbi/actions/components/StartEvolutionDialog.tsx
/**
* Dialog for confirming start of evolution.
*
* Evolution is simpler than incubation:
* - Only baby Blobbis can evolve
* - Shows restart confirmation if already evolving
* - Otherwise shows normal start confirmation
*/
import { Loader2, AlertTriangle, Sparkles } from 'lucide-react';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
// ─── Types ────────────────────────────────────────────────────────────────────
interface StartEvolutionDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** The companion to start evolving */
companion: BlobbiCompanion | null;
/** Called when confirmed */
onConfirm: () => void;
isPending: boolean;
}
// ─── Main Component ───────────────────────────────────────────────────────────
export function StartEvolutionDialog({
open,
onOpenChange,
companion,
onConfirm,
isPending,
}: StartEvolutionDialogProps) {
// Check if the current Blobbi is already evolving
const isAlreadyEvolving = companion?.state === 'evolving';
// Determine title and description based on state
const getDialogContent = () => {
if (isAlreadyEvolving) {
return {
title: 'Restart Evolution?',
icon: <AlertTriangle className="size-5 text-amber-500" />,
description: (
<>
<strong>{companion?.name}</strong> is already evolving. Starting over will{' '}
<strong>reset all task progress</strong> and begin from the beginning.
<br /><br />
Are you sure you want to restart?
</>
),
buttonText: 'Restart Evolution',
buttonClass: 'bg-amber-500 hover:bg-amber-600 text-white',
};
}
return {
title: 'Start Evolution',
icon: <Sparkles className="size-5 text-primary" />,
description: (
<>
Starting evolution begins <strong>{companion?.name}</strong>'s transformation journey.
Complete all the tasks to evolve your baby Blobbi into an adult!
<br /><br />
Ready to begin?
</>
),
buttonText: 'Start Evolution',
buttonClass: 'bg-gradient-to-r from-violet-500 to-purple-500 hover:from-violet-600 hover:to-purple-600 text-white',
};
};
const content = getDialogContent();
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
{content.icon}
{content.title}
</AlertDialogTitle>
<AlertDialogDescription>
{content.description}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isPending}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
onConfirm();
}}
disabled={isPending}
className={content.buttonClass}
>
{isPending ? (
<>
<Loader2 className="size-4 mr-2 animate-spin" />
Starting...
</>
) : (
content.buttonText
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
@@ -1,180 +0,0 @@
// src/blobbi/actions/components/StartIncubationDialog.tsx
/**
* Dialog for confirming start of incubation.
*
* Determines the mode and passes it explicitly to the confirm callback:
* - 'start': Normal start, no other Blobbi incubating
* - 'restart': Restart same Blobbi (already incubating)
* - 'switch': Stop another Blobbi first, then start this one
*
* The mode is determined by UI state, NOT auto-detected by the hook.
* This makes the flow explicit and predictable.
*/
import { useMemo } from 'react';
import { Loader2, AlertTriangle, ArrowRightLeft } from 'lucide-react';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
import type { StartIncubationMode } from '../hooks/useBlobbiIncubation';
// ─── Types ────────────────────────────────────────────────────────────────────
interface StartIncubationDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** The companion to start incubating */
companion: BlobbiCompanion | null;
/** All companions in the collection (to check for other incubating Blobbis) */
companions?: BlobbiCompanion[];
/** Called with explicit mode and optional stopOtherD when confirmed */
onConfirm: (mode: StartIncubationMode, stopOtherD?: string) => void;
isPending: boolean;
}
// ─── Main Component ───────────────────────────────────────────────────────────
export function StartIncubationDialog({
open,
onOpenChange,
companion,
companions = [],
onConfirm,
isPending,
}: StartIncubationDialogProps) {
// Check if the current Blobbi is already in a task state
const isAlreadyInTaskState = companion?.state === 'incubating' || companion?.state === 'evolving';
// Check if another Blobbi (not this one) is currently incubating
const otherIncubatingBlobbi = useMemo(() => {
if (!companion) return null;
return companions.find(c =>
c.d !== companion.d &&
c.state === 'incubating' &&
c.stage === 'egg'
) ?? null;
}, [companion, companions]);
// Determine the mode based on current state
const mode: StartIncubationMode = useMemo(() => {
if (isAlreadyInTaskState) return 'restart';
if (otherIncubatingBlobbi) return 'switch';
return 'start';
}, [isAlreadyInTaskState, otherIncubatingBlobbi]);
// Handle confirm with explicit mode
const handleConfirm = () => {
if (mode === 'switch' && otherIncubatingBlobbi) {
onConfirm(mode, otherIncubatingBlobbi.d);
} else {
onConfirm(mode);
}
};
// Determine title and description based on mode
const getDialogContent = () => {
if (mode === 'restart') {
return {
title: 'Restart Incubation?',
icon: <AlertTriangle className="size-5 text-amber-500" />,
description: (
<>
Your Blobbi is already {companion?.state}. Starting over will{' '}
<strong>reset all task progress</strong> and begin from the beginning.
<br /><br />
Are you sure you want to restart?
</>
),
buttonText: 'Restart Incubation',
buttonClass: 'bg-amber-500 hover:bg-amber-600 text-white',
};
}
if (mode === 'switch') {
return {
title: 'Switch Incubation?',
icon: <ArrowRightLeft className="size-5 text-amber-500" />,
description: (
<>
<strong>{otherIncubatingBlobbi?.name}</strong> is currently incubating.
Only one Blobbi can incubate at a time.
<br /><br />
Starting incubation for <strong>{companion?.name}</strong> will{' '}
<strong>stop {otherIncubatingBlobbi?.name}'s incubation</strong> and{' '}
reset their task progress.
<br /><br />
Do you want to switch?
</>
),
buttonText: 'Switch & Start',
buttonClass: 'bg-amber-500 hover:bg-amber-600 text-white',
};
}
return {
title: 'Start Incubation',
icon: null,
description: (
<>
Starting incubation begins your Blobbi's hatching journey.
Complete all the tasks to hatch your egg into a baby Blobbi!
<br /><br />
Ready to begin?
</>
),
buttonText: 'Start Incubation',
buttonClass: undefined,
};
};
const content = getDialogContent();
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
{content.icon}
{content.title}
</AlertDialogTitle>
<AlertDialogDescription>
{content.description}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isPending}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
handleConfirm();
}}
disabled={isPending}
className={content.buttonClass}
>
{isPending ? (
<>
<Loader2 className="size-4 mr-2 animate-spin" />
Starting...
</>
) : (
content.buttonText
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
@@ -1,195 +0,0 @@
// src/blobbi/actions/components/TasksPanel.tsx
/**
* Card-grid presentation for hatch / evolve tasks.
*
* Each task is a compact card in a 2-column grid.
* Tapping a card expands it inline (full row) to reveal details.
* Only one card is expanded at a time.
*/
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Palette,
Droplets,
MessageSquare,
Heart,
UserPen,
Activity,
Loader2,
HelpCircle,
} from 'lucide-react';
import { openUrl } from '@/lib/downloadFile';
import { Button } from '@/components/ui/button';
import type { HatchTask } from '../hooks/useHatchTasks';
import type { MissionCategory } from './ExpandableMissionCard';
import {
ExpandableMissionCard,
MissionDescription,
MissionProgress,
MissionAction,
DynamicHint,
} from './ExpandableMissionCard';
// ─── Types ────────────────────────────────────────────────────────────────────
interface TasksPanelProps {
tasks: HatchTask[];
allCompleted: boolean;
isLoading: boolean;
onOpenPostModal: () => void;
onComplete: () => void;
isCompleting?: boolean;
completeLabel: string;
completingLabel: string;
completeEmoji: string;
/** Mission category for styling the cards */
category?: MissionCategory;
}
// ─── Task Icon Mapping ────────────────────────────────────────────────────────
/** Map task ids to lucide icons. Falls back to a generic icon. */
function TaskIcon({ taskId }: { taskId: string }) {
const iconClass = 'size-5';
switch (taskId) {
case 'create_themes':
return <Palette className={iconClass} />;
case 'color_moments':
return <Droplets className={iconClass} />;
case 'create_posts':
return <MessageSquare className={iconClass} />;
case 'interactions':
return <Heart className={iconClass} />;
case 'edit_profile':
return <UserPen className={iconClass} />;
case 'maintain_stats':
return <Activity className={iconClass} />;
default:
return <HelpCircle className={iconClass} />;
}
}
// ─── Main Component ───────────────────────────────────────────────────────────
export function TasksPanel({
tasks,
allCompleted,
isLoading,
onOpenPostModal,
onComplete,
isCompleting = false,
completeLabel,
completingLabel,
completeEmoji,
category = 'hatch',
}: TasksPanelProps) {
const [expandedId, setExpandedId] = useState<string | null>(null);
const navigate = useNavigate();
const handleToggle = (id: string) => {
setExpandedId((prev) => (prev === id ? null : id));
};
if (isLoading) {
return (
<div className="flex items-center justify-center py-8">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="space-y-3">
{/* Card grid */}
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
{tasks.map((task) => {
const isDynamic = task.type === 'dynamic';
const progress =
task.required > 0 ? task.current / task.required : task.completed ? 1 : 0;
const handleAction = () => {
if (!task.action || !task.actionTarget) return;
switch (task.action) {
case 'navigate':
navigate(task.actionTarget);
break;
case 'external_link':
openUrl(task.actionTarget);
break;
case 'open_modal':
if (task.actionTarget === 'blobbi_post') onOpenPostModal();
break;
}
};
return (
<ExpandableMissionCard
key={task.id}
id={task.id}
category={category}
icon={<TaskIcon taskId={task.id} />}
title={task.name}
completed={task.completed}
progress={Math.min(progress, 1)}
isExpanded={expandedId === task.id}
onToggle={handleToggle}
>
{/* Expanded content */}
<MissionDescription>{task.description}</MissionDescription>
{/* Progress bar for multi-step tasks */}
{task.required > 1 && !isDynamic && (
<MissionProgress
current={task.current}
required={task.required}
completed={task.completed}
/>
)}
{/* Dynamic stat hint */}
{isDynamic && !task.completed && (
<DynamicHint current={task.current} required={task.required} />
)}
{/* Action link */}
{task.action && task.actionLabel && !task.completed && (
<MissionAction
label={task.actionLabel}
type={task.action}
onClick={handleAction}
/>
)}
</ExpandableMissionCard>
);
})}
</div>
{/* CTA button when all tasks are done */}
{allCompleted && (
<Button
onClick={onComplete}
disabled={isCompleting}
size="lg"
className="w-full gap-2 bg-gradient-to-r from-amber-500 to-orange-500 hover:from-amber-600 hover:to-orange-600 text-white shadow-sm"
>
{isCompleting ? (
<>
<Loader2 className="size-5 animate-spin" />
{completingLabel}
</>
) : (
<>
<span className="text-lg">{completeEmoji}</span>
{completeLabel}
</>
)}
</Button>
)}
</div>
);
}
@@ -1,232 +0,0 @@
// src/blobbi/actions/hooks/useActiveTaskProcess.ts
/**
* Central abstraction for the active task process (hatch or evolve).
*
* This hook consolidates all scattered if/else logic for determining:
* - Which process is active (incubating vs evolving)
* - Which tasks to use (hatch vs evolve)
* - Thresholds and configuration
* - Badge-related computed values
*
* ARCHITECTURE RULES:
* - Computed tasks remain the source of truth
* - Tags are cache only for PERSISTENT tasks
* - Dynamic tasks are NEVER persisted
* - Badge counts ALL incomplete tasks (persistent + dynamic)
*/
import { useMemo } from 'react';
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
import type { HatchTask, HatchTasksResult } from './useHatchTasks';
import type { EvolveTasksResult } from './useEvolveTasks';
import { HATCH_REQUIRED_INTERACTIONS } from './useHatchTasks';
import { EVOLVE_REQUIRED_INTERACTIONS } from './useEvolveTasks';
// ─── Types ────────────────────────────────────────────────────────────────────
/** The type of task process currently active */
export type TaskProcessType = 'hatch' | 'evolve' | null;
/**
* Configuration for the active task process.
* This provides a unified interface regardless of whether
* the process is hatch or evolve.
*/
export interface TaskProcessConfig {
/** The type of process ('hatch' | 'evolve' | null) */
type: TaskProcessType;
/** Whether there is an active task process */
isActive: boolean;
/** Required interactions threshold for the current process */
interactionThreshold: number;
}
/**
* Result of the active task process hook.
* Provides unified access to all task-related state.
*/
export interface ActiveTaskProcessResult {
/** Configuration for the current process */
config: TaskProcessConfig;
/** All tasks for the current process (empty if no active process) */
tasks: HatchTask[];
/** Whether tasks are still loading */
isLoading: boolean;
/** Whether all tasks (persistent + dynamic) are complete */
allCompleted: boolean;
/** Whether all persistent tasks are complete */
persistentTasksComplete: boolean;
/** Whether the dynamic task is complete */
dynamicTaskComplete: boolean;
/** Refetch function for current tasks */
refetch: () => void;
// ─── Badge-related computed values ───
/**
* Count of ALL remaining incomplete tasks (persistent + dynamic).
* This is used for the badge display.
* Dynamic tasks ARE counted here but are NEVER synced to tags.
*/
remainingTasksCount: number;
/**
* Only persistent tasks that are incomplete.
* Used for sync logic - dynamic tasks must NEVER be synced.
*/
incompletePersistentTasks: HatchTask[];
/**
* Only persistent tasks that are complete.
* Used for sync logic.
*/
completedPersistentTasks: HatchTask[];
/**
* Stable string key of completed persistent task IDs.
* Used for sync anti-loop protection.
*/
completedPersistentTaskIds: string;
/**
* Tasks to sync (persistent only, with completion status).
* Dynamic tasks are excluded.
*/
tasksToSync: Array<{ taskId: string; completed: boolean }>;
}
// ─── Helper Functions ─────────────────────────────────────────────────────────
/**
* Filter tasks to only persistent tasks.
* Dynamic tasks must NEVER be synced to tags.
*/
export function filterPersistentTasks(tasks: HatchTask[]): HatchTask[] {
return tasks.filter(t => t.type === 'persistent');
}
/**
* Filter tasks to only dynamic tasks.
*/
export function filterDynamicTasks(tasks: HatchTask[]): HatchTask[] {
return tasks.filter(t => t.type === 'dynamic');
}
// ─── Main Hook ────────────────────────────────────────────────────────────────
/**
* Hook that provides a unified interface for the active task process.
*
* Usage:
* ```ts
* const taskProcess = useActiveTaskProcess(companion, hatchTasks, evolveTasks);
*
* // Access unified data
* taskProcess.config.type // 'hatch' | 'evolve' | null
* taskProcess.tasks // current tasks
* taskProcess.remainingTasksCount // for badge (includes dynamic)
* taskProcess.tasksToSync // for sync (excludes dynamic)
* ```
*/
export function useActiveTaskProcess(
companion: BlobbiCompanion | null,
hatchTasks: HatchTasksResult,
evolveTasks: EvolveTasksResult
): ActiveTaskProcessResult {
// Determine which process is active
const processType = useMemo((): TaskProcessType => {
if (!companion) return null;
if (companion.state === 'incubating') return 'hatch';
if (companion.state === 'evolving') return 'evolve';
return null;
}, [companion]);
// Build configuration
const config = useMemo((): TaskProcessConfig => {
const isActive = processType !== null;
const interactionThreshold = processType === 'hatch'
? HATCH_REQUIRED_INTERACTIONS
: processType === 'evolve'
? EVOLVE_REQUIRED_INTERACTIONS
: 0;
return {
type: processType,
isActive,
interactionThreshold,
};
}, [processType]);
// Get the active tasks result based on process type
const activeResult = useMemo(() => {
if (processType === 'hatch') return hatchTasks;
if (processType === 'evolve') return evolveTasks;
return null;
}, [processType, hatchTasks, evolveTasks]);
// Extract tasks and state from active result
const tasks = activeResult?.tasks ?? [];
const isLoading = activeResult?.isLoading ?? false;
const allCompleted = activeResult?.allCompleted ?? false;
const persistentTasksComplete = activeResult?.persistentTasksComplete ?? false;
const dynamicTaskComplete = activeResult?.dynamicTaskComplete ?? false;
const refetch = activeResult?.refetch ?? (() => {});
// Compute persistent task list (dynamic tasks computed for badge count directly from tasks array)
const persistentTasks = useMemo(() => filterPersistentTasks(tasks), [tasks]);
// Compute incomplete tasks (for badge - includes BOTH persistent and dynamic)
const remainingTasksCount = useMemo(() => {
// Count ALL incomplete tasks - persistent AND dynamic
// Dynamic tasks are included in badge count but NEVER synced to tags
return tasks.filter(t => !t.completed).length;
}, [tasks]);
// Compute persistent task lists for sync
const incompletePersistentTasks = useMemo(() =>
persistentTasks.filter(t => !t.completed),
[persistentTasks]
);
const completedPersistentTasks = useMemo(() =>
persistentTasks.filter(t => t.completed),
[persistentTasks]
);
// Compute stable string key for completed persistent tasks (anti-loop)
const completedPersistentTaskIds = useMemo(() => {
if (!completedPersistentTasks.length) return '';
return completedPersistentTasks
.map(t => t.id)
.sort()
.join(',');
}, [completedPersistentTasks]);
// Compute tasks to sync (persistent only)
// CRITICAL: Dynamic tasks must NEVER be included here
const tasksToSync = useMemo(() => {
if (!persistentTasks.length) return [];
return persistentTasks.map(t => ({
taskId: t.id,
completed: t.completed,
}));
}, [persistentTasks]);
return {
config,
tasks,
isLoading,
allCompleted,
persistentTasksComplete,
dynamicTaskComplete,
refetch,
remainingTasksCount,
incompletePersistentTasks,
completedPersistentTasks,
completedPersistentTaskIds,
tasksToSync,
};
}
@@ -1,287 +0,0 @@
// src/blobbi/actions/hooks/useAudioPlayback.ts
import { useState, useRef, useCallback, useEffect } from 'react';
/**
* Audio playback state
* - idle: No audio loaded
* - loading: Audio is being loaded
* - playing: Audio is playing
* - paused: Audio is paused (can resume)
* - stopped: Audio was stopped (must reload to play again)
* - error: An error occurred
*/
export type PlaybackState = 'idle' | 'loading' | 'playing' | 'paused' | 'stopped' | 'error';
/**
* Audio playback error info
*/
export interface PlaybackError {
message: string;
code?: string;
}
/** Default volume level (0-1) */
const DEFAULT_VOLUME = 0.8;
/**
* Options for the useAudioPlayback hook
*/
export interface UseAudioPlaybackOptions {
/** Called when playback ends naturally */
onEnded?: () => void;
/** Called when an error occurs */
onError?: (error: PlaybackError) => void;
/** Initial volume level (0-1), defaults to 0.8 */
initialVolume?: number;
}
/**
* Return type for useAudioPlayback hook
*/
export interface UseAudioPlaybackReturn {
/** Current playback state */
state: PlaybackState;
/** Current error (if any) */
error: PlaybackError | null;
/** Current audio URL being played */
currentUrl: string | null;
/** Load and optionally start playing an audio URL */
load: (url: string, autoplay?: boolean) => void;
/** Play the current audio */
play: () => Promise<void>;
/** Pause the current audio */
pause: () => void;
/** Stop playback and reset */
stop: () => void;
/** Restart playback from the beginning */
restart: () => Promise<void>;
/** Toggle play/pause */
toggle: () => Promise<void>;
/** Whether audio is currently playing */
isPlaying: boolean;
/** Current volume level (0-1) */
volume: number;
/** Set volume level (0-1) */
setVolume: (volume: number) => void;
/** Cleanup function to release resources */
cleanup: () => void;
}
/**
* Reusable hook for audio playback.
* Handles Audio element lifecycle, error handling, and state management.
*/
export function useAudioPlayback(options: UseAudioPlaybackOptions = {}): UseAudioPlaybackReturn {
const { onEnded, onError, initialVolume = DEFAULT_VOLUME } = options;
const [state, setState] = useState<PlaybackState>('idle');
const [error, setError] = useState<PlaybackError | null>(null);
const [currentUrl, setCurrentUrl] = useState<string | null>(null);
const [volume, setVolumeState] = useState<number>(initialVolume);
const audioRef = useRef<HTMLAudioElement | null>(null);
const currentUrlRef = useRef<string | null>(null);
const volumeRef = useRef<number>(initialVolume);
// Cleanup audio element
const cleanup = useCallback(() => {
if (audioRef.current) {
audioRef.current.pause();
audioRef.current.onended = null;
audioRef.current.onerror = null;
audioRef.current.oncanplay = null;
audioRef.current.onplaying = null;
audioRef.current = null;
}
currentUrlRef.current = null;
setState('idle');
setCurrentUrl(null);
setError(null);
}, []);
// Cleanup on unmount
useEffect(() => {
return () => {
cleanup();
};
}, [cleanup]);
// Load audio from URL
const load = useCallback((url: string, autoplay = false) => {
// If same URL, don't reload
if (currentUrlRef.current === url && audioRef.current) {
if (autoplay) {
audioRef.current.play().catch(() => {});
}
return;
}
// Cleanup previous audio
if (audioRef.current) {
audioRef.current.pause();
audioRef.current.onended = null;
audioRef.current.onerror = null;
audioRef.current.oncanplay = null;
audioRef.current.onplaying = null;
}
setState('loading');
setError(null);
setCurrentUrl(url);
currentUrlRef.current = url;
const audio = new Audio(url);
audio.volume = volumeRef.current; // Apply current volume to new audio
audioRef.current = audio;
audio.oncanplay = () => {
if (autoplay) {
audio.play().catch((err) => {
const playbackError: PlaybackError = {
message: getPlaybackErrorMessage(err),
code: err.name,
};
setError(playbackError);
setState('error');
onError?.(playbackError);
});
} else {
setState('paused');
}
};
audio.onplaying = () => {
setState('playing');
};
audio.onpause = () => {
if (state === 'playing') {
setState('paused');
}
};
audio.onended = () => {
setState('paused');
onEnded?.();
};
audio.onerror = () => {
const playbackError: PlaybackError = {
message: 'Failed to load audio. The format may not be supported.',
code: 'MEDIA_ERR',
};
setError(playbackError);
setState('error');
onError?.(playbackError);
};
// Start loading
audio.load();
}, [onEnded, onError, state]);
// Play current audio
const play = useCallback(async () => {
if (!audioRef.current) return;
try {
setError(null);
await audioRef.current.play();
setState('playing');
} catch (err) {
const playbackError: PlaybackError = {
message: getPlaybackErrorMessage(err),
code: err instanceof Error ? err.name : 'UNKNOWN',
};
setError(playbackError);
setState('error');
onError?.(playbackError);
}
}, [onError]);
// Pause current audio
const pause = useCallback(() => {
if (!audioRef.current) return;
audioRef.current.pause();
setState('paused');
}, []);
// Stop playback completely (requires reload to play again)
const stop = useCallback(() => {
if (!audioRef.current) return;
audioRef.current.pause();
audioRef.current.currentTime = 0;
// Clear URL ref so next load() will actually reload
currentUrlRef.current = null;
setState('stopped');
}, []);
// Restart playback from the beginning
const restart = useCallback(async () => {
if (!audioRef.current) return;
audioRef.current.currentTime = 0;
try {
await audioRef.current.play();
setState('playing');
} catch (err) {
const playbackError: PlaybackError = {
message: getPlaybackErrorMessage(err),
code: err instanceof Error ? err.name : 'UNKNOWN',
};
setError(playbackError);
setState('error');
onError?.(playbackError);
}
}, [onError]);
// Toggle play/pause
const toggle = useCallback(async () => {
if (state === 'playing') {
pause();
} else {
await play();
}
}, [state, play, pause]);
// Set volume (0-1)
const setVolume = useCallback((newVolume: number) => {
const clampedVolume = Math.max(0, Math.min(1, newVolume));
volumeRef.current = clampedVolume;
setVolumeState(clampedVolume);
if (audioRef.current) {
audioRef.current.volume = clampedVolume;
}
}, []);
return {
state,
error,
currentUrl,
load,
play,
pause,
stop,
restart,
toggle,
isPlaying: state === 'playing',
volume,
setVolume,
cleanup,
};
}
/**
* Get a user-friendly error message for playback errors
*/
function getPlaybackErrorMessage(err: unknown): string {
if (err instanceof Error) {
if (err.name === 'NotSupportedError') {
return 'This audio format is not supported by your browser.';
}
if (err.name === 'NotAllowedError') {
return 'Playback was blocked. Try interacting with the page first.';
}
return err.message;
}
return 'An unknown error occurred during playback.';
}
@@ -1,189 +0,0 @@
/**
* useBlobbiCareActivity - Hook for registering care activity and updating streaks
*
* This hook provides a centralized way to register care activity for a Blobbi companion.
* It handles:
* - Calculating streak updates based on the last activity day
* - Publishing updated Blobbi state to Nostr
* - Updating local cache
*
* Use this hook whenever care activity should count toward the streak:
* - Opening the Blobbi page (page check-in)
* - Performing care actions (feed, clean, play, etc.)
* - Any other care interaction
*
* The streak only increments once per calendar day, regardless of how many
* activities are performed.
*/
import { useCallback, useRef } from 'react';
import { useMutation } from '@tanstack/react-query';
import type { NostrEvent } from '@nostrify/nostrify';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
import { KIND_BLOBBI_STATE, updateBlobbiTags } from '@/blobbi/core/lib/blobbi';
import { getStreakTagUpdates, calculateStreakUpdate, type StreakUpdateResult } from '../lib/blobbi-streak';
// ─── Types ────────────────────────────────────────────────────────────────────
export interface UseBlobbiCareActivityParams {
companion: BlobbiCompanion | null;
/** Update companion event in local cache */
updateCompanionEvent: (event: NostrEvent) => void;
/** Invalidate companion queries */
invalidateCompanion: () => void;
}
export interface CareActivityResult {
/** Whether the streak was updated */
wasUpdated: boolean;
/** The new streak value */
newStreak: number;
/** Description of what happened */
action: StreakUpdateResult['action'];
}
// ─── Hook ─────────────────────────────────────────────────────────────────────
/**
* Hook to register care activity and update streaks.
*
* Returns a function to register activity and a mutation for the actual update.
* The register function is idempotent - calling it multiple times on the same day
* will only update once.
*/
export function useBlobbiCareActivity({
companion,
updateCompanionEvent,
invalidateCompanion,
}: UseBlobbiCareActivityParams) {
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
// Track if we've already registered activity this session to avoid duplicate calls
// This is a performance optimization - the actual idempotency is handled by day comparison
const lastRegisteredDay = useRef<string | null>(null);
const mutation = useMutation({
mutationFn: async (): Promise<CareActivityResult> => {
if (!user?.pubkey) {
throw new Error('You must be logged in to register care activity');
}
if (!companion) {
throw new Error('No companion available');
}
const now = new Date();
// Calculate what the streak update should be
const result = calculateStreakUpdate(
companion.careStreak,
companion.careStreakLastDay,
now
);
// If no update needed (same day), return early without publishing
if (!result.wasUpdated) {
return {
wasUpdated: false,
newStreak: result.newStreak,
action: result.action,
};
}
// Get the tag updates
const streakUpdates = getStreakTagUpdates(companion, now);
if (!streakUpdates) {
// Shouldn't happen if wasUpdated is true, but handle gracefully
return {
wasUpdated: false,
newStreak: companion.careStreak ?? 0,
action: 'same_day',
};
}
// Build updated tags
const updatedTags = updateBlobbiTags(companion.allTags, streakUpdates);
// Publish the updated event
const event = await publishEvent({
kind: KIND_BLOBBI_STATE,
content: companion.event.content,
tags: updatedTags,
});
// Update local cache
updateCompanionEvent(event);
// Update session tracker
lastRegisteredDay.current = result.newLastDay;
// Log for debugging (dev only)
if (import.meta.env.DEV) {
console.log('[CareActivity] Streak updated:', {
action: result.action,
previousStreak: companion.careStreak,
newStreak: result.newStreak,
lastDay: companion.careStreakLastDay,
newDay: result.newLastDay,
});
}
return {
wasUpdated: true,
newStreak: result.newStreak,
action: result.action,
};
},
onSuccess: (result) => {
if (result.wasUpdated) {
invalidateCompanion();
}
},
onError: (error: Error) => {
console.error('[CareActivity] Failed to update streak:', error);
},
});
/**
* Register care activity. Call this when care-related activity happens.
* Safe to call multiple times - only updates streak once per day.
*
* @returns Promise with the result of the activity registration
*/
const registerCareActivity = useCallback(async (): Promise<CareActivityResult | null> => {
if (!companion) {
return null;
}
// Quick check if we've already registered for this companion's last day (session cache)
// This is an optimization to avoid unnecessary mutation calls
if (lastRegisteredDay.current === companion.careStreakLastDay) {
// Already processed this day in this session, skip
return {
wasUpdated: false,
newStreak: companion.careStreak ?? 0,
action: 'same_day',
};
}
return mutation.mutateAsync();
}, [companion, mutation]);
return {
/** Register care activity - call when page opens or care action happens */
registerCareActivity,
/** Whether an update is currently in progress */
isUpdating: mutation.isPending,
/** The last update result */
lastResult: mutation.data,
/** Any error from the last update attempt */
error: mutation.error,
};
}
@@ -1,228 +0,0 @@
// src/blobbi/actions/hooks/useBlobbiDirectAction.ts
import { useMutation } from '@tanstack/react-query';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { toast } from '@/hooks/useToast';
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
import {
KIND_BLOBBI_STATE,
updateBlobbiTags,
} from '@/blobbi/core/lib/blobbi';
import { applyBlobbiDecay } from '@/blobbi/core/lib/blobbi-decay';
import {
clampStat,
applyStat,
DIRECT_ACTION_METADATA,
incrementInteractionTaskTags,
type DirectAction,
} from '../lib/blobbi-action-utils';
import { trackMultipleDailyMissionActions } from '../lib/daily-mission-tracker';
import type { DailyMissionAction } from '../lib/daily-missions';
import { getStreakTagUpdates } from '../lib/blobbi-streak';
import { calculateActionXP, applyXPGain, formatXPGain } from '../lib/blobbi-xp';
import { HATCH_REQUIRED_INTERACTIONS } from './useHatchTasks';
import { EVOLVE_REQUIRED_INTERACTIONS } from './useEvolveTasks';
// Import NostrEvent type
import type { NostrEvent } from '@nostrify/nostrify';
/**
* Configuration for direct action happiness effects.
* These are the happiness deltas for each direct action.
*/
export const DIRECT_ACTION_HAPPINESS_EFFECTS: Record<DirectAction, number> = {
play_music: 15,
sing: 20,
};
/**
* Request payload for executing a direct action
*/
export interface DirectActionRequest {
action: DirectAction;
}
/**
* Result of executing a direct action
*/
export interface DirectActionResult {
action: DirectAction;
happinessChange: number;
xpGained: number;
newXP: number;
}
/**
* Parameters for the useBlobbiDirectAction hook
*/
export interface UseBlobbiDirectActionParams {
companion: BlobbiCompanion | null;
/** Called after ensuring companion is canonical (from migration helper) */
ensureCanonicalBeforeAction: () => Promise<{
companion: BlobbiCompanion;
content: string;
allTags: string[][];
wasMigrated: boolean;
} | null>;
/** Update companion event in local cache */
updateCompanionEvent: (event: NostrEvent) => void;
/** Invalidate companion queries */
invalidateCompanion: () => void;
/** Invalidate profile queries (needed if migration happened) */
invalidateProfile: () => void;
}
/**
* Hook to execute a direct action on a Blobbi companion.
* Direct actions (play_music, sing) don't consume inventory items.
* They directly affect happiness stat.
*
* This hook:
* 1. Validates the companion exists
* 2. Ensures canonical format before action
* 3. Applies accumulated decay
* 4. Applies happiness boost
* 5. Updates Blobbi state (kind 31124)
* 6. Invalidates relevant queries
*/
export function useBlobbiDirectAction({
companion,
ensureCanonicalBeforeAction,
updateCompanionEvent,
invalidateCompanion,
invalidateProfile,
}: UseBlobbiDirectActionParams) {
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
return useMutation({
mutationFn: async ({ action }: DirectActionRequest): Promise<DirectActionResult> => {
// ─── Validation ───
if (!user?.pubkey) {
throw new Error('You must be logged in to perform actions');
}
if (!companion) {
throw new Error('No companion selected');
}
// ─── Ensure Canonical Before Action ───
const canonical = await ensureCanonicalBeforeAction();
if (!canonical) {
throw new Error('Failed to prepare companion for action');
}
// ─── Apply Accumulated Decay First ───
// CRITICAL: Use canonical.companion for decay calculations, not the stale outer companion
const now = Math.floor(Date.now() / 1000);
const decayResult = applyBlobbiDecay({
stage: canonical.companion.stage,
state: canonical.companion.state,
stats: canonical.companion.stats,
lastDecayAt: canonical.companion.lastDecayAt,
now,
});
const statsAfterDecay = decayResult.stats;
// ─── Apply Happiness Effect ───
const happinessDelta = DIRECT_ACTION_HAPPINESS_EFFECTS[action];
const newHappiness = applyStat(statsAfterDecay.happiness, happinessDelta);
// Track if happiness actually changed
const happinessChanged = newHappiness !== statsAfterDecay.happiness;
// Build stats update
const isEgg = canonical.companion.stage === 'egg';
const statsUpdate: Record<string, string> = {
happiness: newHappiness.toString(),
health: statsAfterDecay.health.toString(),
hygiene: statsAfterDecay.hygiene.toString(),
};
if (isEgg) {
// Eggs have fixed hunger and energy
statsUpdate.hunger = '100';
statsUpdate.energy = '100';
} else {
statsUpdate.hunger = clampStat(statsAfterDecay.hunger).toString();
statsUpdate.energy = clampStat(statsAfterDecay.energy).toString();
}
// ─── Update Blobbi State Event (kind 31124) ───
const nowStr = now.toString();
// If incubating or evolving, increment the interaction counter for tasks
const companionState = canonical.companion.state;
let updatedTags = canonical.allTags;
if (companionState === 'incubating') {
updatedTags = incrementInteractionTaskTags(canonical.allTags, HATCH_REQUIRED_INTERACTIONS).updatedTags;
} else if (companionState === 'evolving') {
updatedTags = incrementInteractionTaskTags(canonical.allTags, EVOLVE_REQUIRED_INTERACTIONS).updatedTags;
}
// Get streak updates (will only update if needed based on day)
const streakUpdates = getStreakTagUpdates(canonical.companion) ?? {};
// ─── Apply XP Gain (ONLY if happiness actually changed) ───
// Direct actions modify happiness. Only grant XP if happiness actually increased.
const xpGained = happinessChanged ? calculateActionXP(action) : 0;
const currentXP = canonical.companion.experience ?? 0;
const newXP = applyXPGain(currentXP, xpGained);
const blobbiTags = updateBlobbiTags(updatedTags, {
...statsUpdate,
...streakUpdates,
experience: newXP.toString(),
last_interaction: nowStr,
last_decay_at: nowStr,
});
const blobbiEvent = await publishEvent({
kind: KIND_BLOBBI_STATE,
content: canonical.content,
tags: blobbiTags,
});
updateCompanionEvent(blobbiEvent);
// ─── Invalidate Queries ───
invalidateCompanion();
if (canonical.wasMigrated) {
invalidateProfile();
}
return {
action,
happinessChange: happinessDelta,
xpGained,
newXP,
};
},
onSuccess: ({ action, happinessChange, xpGained }) => {
const actionMeta = DIRECT_ACTION_METADATA[action];
const xpText = formatXPGain(xpGained);
toast({
title: `${actionMeta.label} complete!`,
description: `Your Blobbi's happiness increased by ${happinessChange}! ${xpText}`,
});
// Track daily mission progress
// 'interact' is always tracked, plus the specific action
const dailyActions: DailyMissionAction[] = ['interact'];
if (action === 'sing') dailyActions.push('sing');
if (action === 'play_music') dailyActions.push('play_music');
trackMultipleDailyMissionActions(dailyActions, user?.pubkey);
},
onError: (error: Error) => {
toast({
title: 'Action failed',
description: error.message,
variant: 'destructive',
});
},
});
}
@@ -1,939 +0,0 @@
// src/blobbi/actions/hooks/useBlobbiIncubation.ts
/**
* Hooks for Blobbi incubation task system.
*
* When a user starts incubation:
* 1. Apply accumulated decay from last_decay_at to now
* 2. Set state to 'incubating'
* 3. Add state_started_at timestamp
* 4. Update last_decay_at to the same timestamp
* 5. Clear any previous task progress
*
* Tasks are computed from Nostr events with created_at >= state_started_at
*/
import { useMutation } from '@tanstack/react-query';
import { useNostr } from '@nostrify/react';
import type { NostrEvent } from '@nostrify/nostrify';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { toast } from '@/hooks/useToast';
import type { BlobbiCompanion, BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
import {
KIND_BLOBBI_STATE,
updateBlobbiTags,
} from '@/blobbi/core/lib/blobbi';
import { applyBlobbiDecay } from '@/blobbi/core/lib/blobbi-decay';
// ─── Types ────────────────────────────────────────────────────────────────────
/**
* Mode for starting incubation.
* This makes the intent explicit rather than auto-detecting behavior.
*/
export type StartIncubationMode =
| 'start' // Normal start (no other Blobbi incubating)
| 'restart' // Restart same Blobbi (already incubating)
| 'switch'; // Switch from another incubating Blobbi
/**
* Request to start incubation with explicit mode.
*/
export interface StartIncubationRequest {
/** Explicit mode for this operation */
mode: StartIncubationMode;
/** The d-tag of the other Blobbi to stop (required when mode === 'switch') */
stopOtherD?: string;
}
/**
* Parameters for start incubation hook.
*/
export interface UseStartIncubationParams {
companion: BlobbiCompanion | null;
profile: BlobbonautProfile | null;
/** Called to ensure companion is canonical (from migration helper) */
ensureCanonicalBeforeAction: () => Promise<{
companion: BlobbiCompanion;
content: string;
allTags: string[][];
wasMigrated: boolean;
profileAllTags: string[][];
profileStorage: import('@/blobbi/core/lib/blobbi').StorageItem[];
} | null>;
/** Update companion event in local cache */
updateCompanionEvent: (event: NostrEvent) => void;
/** Invalidate companion queries */
invalidateCompanion: () => void;
/** Invalidate profile queries (needed if migration occurred) */
invalidateProfile: () => void;
}
/**
* Result of starting incubation.
*/
export interface StartIncubationResult {
/** The Blobbi's name */
name: string;
/** Timestamp when incubation started */
stateStartedAt: number;
/** Mode that was used */
mode: StartIncubationMode;
/** Name of other Blobbi that was stopped (if mode === 'switch') */
stoppedOtherName?: string;
}
// ─── Start Incubation Hook ────────────────────────────────────────────────────
/**
* Hook to start the incubation process for an egg.
*
* This sets the Blobbi state to 'incubating' and records the start timestamp.
* Tasks will be computed based on events created after this timestamp.
*
* IMPORTANT: The mode must be explicitly specified by the caller (UI).
* This hook does NOT auto-detect whether to switch or restart.
* The UI dialog determines the mode and passes it explicitly.
*
* Modes:
* - 'start': Normal start, no other Blobbi incubating
* - 'restart': Restart same Blobbi (already incubating), resets task progress
* - 'switch': Stop another Blobbi first, then start this one
*
* Requirements:
* - Blobbi must be in egg stage
* - User must be logged in
*/
export function useStartIncubation({
companion,
profile,
ensureCanonicalBeforeAction,
updateCompanionEvent,
invalidateCompanion,
invalidateProfile,
}: UseStartIncubationParams) {
const { user } = useCurrentUser();
const { nostr } = useNostr();
const { mutateAsync: publishEvent } = useNostrPublish();
return useMutation({
mutationFn: async (request: StartIncubationRequest): Promise<StartIncubationResult> => {
const { mode, stopOtherD } = request;
// ─── Validation ───
if (!user?.pubkey) {
throw new Error('You must be logged in to start incubation');
}
if (!companion) {
throw new Error('No companion selected');
}
if (!profile) {
throw new Error('Profile not found');
}
if (companion.stage !== 'egg') {
throw new Error('Only eggs can be incubated');
}
// Validate switch mode requires stopOtherD
if (mode === 'switch' && !stopOtherD) {
throw new Error('Switch mode requires stopOtherD parameter');
}
let stoppedOtherName: string | undefined;
// ─── Stop Other Incubating Blobbi (switch mode only) ───
if (mode === 'switch' && stopOtherD) {
// Fetch the current event for the other Blobbi
const [otherEvent] = await nostr.query([{
kinds: [KIND_BLOBBI_STATE],
authors: [user.pubkey],
'#d': [stopOtherD],
limit: 1,
}]);
if (otherEvent) {
// Get name from the event for the result
const nameTag = otherEvent.tags.find(t => t[0] === 'name');
stoppedOtherName = nameTag?.[1] ?? stopOtherD;
// Stop the other Blobbi's incubation
const now = Math.floor(Date.now() / 1000);
const nowStr = now.toString();
// Parse stats from the event
const getTagValue = (tags: string[][], name: string): number =>
parseInt(tags.find(t => t[0] === name)?.[1] ?? '50', 10);
const otherStats = {
hunger: getTagValue(otherEvent.tags, 'hunger'),
happiness: getTagValue(otherEvent.tags, 'happiness'),
health: getTagValue(otherEvent.tags, 'health'),
hygiene: getTagValue(otherEvent.tags, 'hygiene'),
energy: getTagValue(otherEvent.tags, 'energy'),
};
const otherLastDecayAt = getTagValue(otherEvent.tags, 'last_decay_at') || now;
// Apply decay to the other Blobbi
const otherDecayResult = applyBlobbiDecay({
stage: 'egg',
state: 'incubating',
stats: otherStats,
lastDecayAt: otherLastDecayAt,
now,
});
// Remove task tags and state_started_at from the other Blobbi
const otherCleanedTags = otherEvent.tags.filter(tag =>
tag[0] !== 'task' &&
tag[0] !== 'task_completed' &&
tag[0] !== 'state_started_at'
);
const otherNewTags = updateBlobbiTags(otherCleanedTags, {
health: otherDecayResult.stats.health.toString(),
hygiene: otherDecayResult.stats.hygiene.toString(),
happiness: otherDecayResult.stats.happiness.toString(),
hunger: '100',
energy: '100',
state: 'active',
last_interaction: nowStr,
last_decay_at: nowStr,
});
// Publish the stop event for the other Blobbi
const stopEvent = await publishEvent({
kind: KIND_BLOBBI_STATE,
content: otherEvent.content,
tags: otherNewTags,
});
// Update the cache for the stopped Blobbi
updateCompanionEvent(stopEvent);
}
}
// ─── Ensure Canonical Before Action ───
const canonical = await ensureCanonicalBeforeAction();
if (!canonical) {
throw new Error('Failed to prepare companion for incubation');
}
// ─── Apply Accumulated Decay ───
// CRITICAL: Apply decay from last_decay_at to now before changing state
const now = Math.floor(Date.now() / 1000);
const nowStr = now.toString();
const decayResult = applyBlobbiDecay({
stage: canonical.companion.stage,
state: canonical.companion.state,
stats: canonical.companion.stats,
lastDecayAt: canonical.companion.lastDecayAt,
now,
});
// ─── Build Updated Tags ───
// Remove any existing task tags when starting fresh (for all modes)
const cleanedTags = canonical.allTags.filter(tag =>
tag[0] !== 'task' && tag[0] !== 'task_completed'
);
// Build stats update with decayed values
// Eggs have fixed hunger and energy at 100
const statsUpdate: Record<string, string> = {
health: decayResult.stats.health.toString(),
hygiene: decayResult.stats.hygiene.toString(),
happiness: decayResult.stats.happiness.toString(),
hunger: '100',
energy: '100',
};
const newTags = updateBlobbiTags(cleanedTags, {
...statsUpdate,
state: 'incubating',
state_started_at: nowStr,
last_interaction: nowStr,
last_decay_at: nowStr,
});
// ─── Publish Event ───
const event = await publishEvent({
kind: KIND_BLOBBI_STATE,
content: canonical.content,
tags: newTags,
});
updateCompanionEvent(event);
invalidateCompanion();
// Invalidate profile if migration occurred
if (canonical.wasMigrated) {
invalidateProfile();
}
return {
name: canonical.companion.name,
stateStartedAt: now,
mode,
stoppedOtherName,
};
},
onSuccess: ({ name, mode, stoppedOtherName }) => {
if (mode === 'switch' && stoppedOtherName) {
toast({
title: 'Switched incubation!',
description: `Stopped ${stoppedOtherName}, now incubating ${name}.`,
});
} else if (mode === 'restart') {
toast({
title: 'Incubation restarted!',
description: `${name}'s task progress has been reset.`,
});
} else {
toast({
title: 'Incubation started!',
description: `${name} is now incubating. Complete the tasks to hatch!`,
});
}
},
onError: (error: Error) => {
toast({
title: 'Failed to start incubation',
description: error.message,
variant: 'destructive',
});
},
});
}
// ─── Stop Incubation Hook ─────────────────────────────────────────────────────
/**
* Parameters for stop incubation hook.
*/
export interface UseStopIncubationParams {
companion: BlobbiCompanion | null;
/** Called to ensure companion is canonical (from migration helper) */
ensureCanonicalBeforeAction: () => Promise<{
companion: BlobbiCompanion;
content: string;
allTags: string[][];
wasMigrated: boolean;
profileAllTags: string[][];
profileStorage: import('@/blobbi/core/lib/blobbi').StorageItem[];
} | null>;
/** Update companion event in local cache */
updateCompanionEvent: (event: NostrEvent) => void;
/** Invalidate companion queries */
invalidateCompanion: () => void;
/** Invalidate profile queries (needed if migration occurred) */
invalidateProfile: () => void;
}
/**
* Result of stopping incubation.
*/
export interface StopIncubationResult {
/** The Blobbi's name */
name: string;
}
/**
* Hook to stop/cancel the incubation process for a Blobbi.
*
* This resets the Blobbi state to 'active' and clears all task progress tags.
* The user can restart incubation later, but will need to complete tasks again.
*
* When stopping incubation:
* - Apply accumulated decay first
* - Set state back to 'active'
* - Remove state_started_at tag
* - Remove all task and task_completed tags
*
* Requirements:
* - Blobbi must be in incubating state
* - User must be logged in
*/
export function useStopIncubation({
companion,
ensureCanonicalBeforeAction,
updateCompanionEvent,
invalidateCompanion,
invalidateProfile,
}: UseStopIncubationParams) {
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
return useMutation({
mutationFn: async (): Promise<StopIncubationResult> => {
// ─── Validation ───
if (!user?.pubkey) {
throw new Error('You must be logged in to stop incubation');
}
if (!companion) {
throw new Error('No companion selected');
}
if (companion.state !== 'incubating') {
throw new Error('This Blobbi is not incubating');
}
// ─── Ensure Canonical Before Action ───
const canonical = await ensureCanonicalBeforeAction();
if (!canonical) {
throw new Error('Failed to prepare companion');
}
// ─── Apply Accumulated Decay ───
const now = Math.floor(Date.now() / 1000);
const nowStr = now.toString();
const decayResult = applyBlobbiDecay({
stage: canonical.companion.stage,
state: canonical.companion.state,
stats: canonical.companion.stats,
lastDecayAt: canonical.companion.lastDecayAt,
now,
});
// ─── Build Updated Tags ───
// Remove task tags and state_started_at
const cleanedTags = canonical.allTags.filter(tag =>
tag[0] !== 'task' &&
tag[0] !== 'task_completed' &&
tag[0] !== 'state_started_at'
);
// Build stats update with decayed values
// Eggs have fixed hunger and energy at 100
const statsUpdate: Record<string, string> = {
health: decayResult.stats.health.toString(),
hygiene: decayResult.stats.hygiene.toString(),
happiness: decayResult.stats.happiness.toString(),
hunger: '100',
energy: '100',
};
const newTags = updateBlobbiTags(cleanedTags, {
...statsUpdate,
state: 'active',
last_interaction: nowStr,
last_decay_at: nowStr,
});
// ─── Publish Event ───
const event = await publishEvent({
kind: KIND_BLOBBI_STATE,
content: canonical.content,
tags: newTags,
});
updateCompanionEvent(event);
invalidateCompanion();
// Invalidate profile if migration occurred
if (canonical.wasMigrated) {
invalidateProfile();
}
return {
name: canonical.companion.name,
};
},
onSuccess: ({ name }) => {
toast({
title: 'Incubation stopped',
description: `${name} is no longer incubating. Task progress has been reset.`,
});
},
onError: (error: Error) => {
toast({
title: 'Failed to stop incubation',
description: error.message,
variant: 'destructive',
});
},
});
}
// ─── Start Evolution Hook ─────────────────────────────────────────────────────
/**
* Parameters for start evolution hook.
*/
export interface UseStartEvolutionParams {
companion: BlobbiCompanion | null;
/** Called to ensure companion is canonical (from migration helper) */
ensureCanonicalBeforeAction: () => Promise<{
companion: BlobbiCompanion;
content: string;
allTags: string[][];
wasMigrated: boolean;
profileAllTags: string[][];
profileStorage: import('@/blobbi/core/lib/blobbi').StorageItem[];
} | null>;
/** Update companion event in local cache */
updateCompanionEvent: (event: NostrEvent) => void;
/** Invalidate companion queries */
invalidateCompanion: () => void;
/** Invalidate profile queries (needed if migration occurred) */
invalidateProfile: () => void;
}
/**
* Result of starting evolution.
*/
export interface StartEvolutionResult {
/** The Blobbi's name */
name: string;
/** Timestamp when evolution started */
stateStartedAt: number;
}
/**
* Hook to start the evolution process for a baby Blobbi.
*
* This sets the Blobbi state to 'evolving' and records the start timestamp.
* Tasks will be computed based on events created after this timestamp.
*
* Requirements:
* - Blobbi must be in baby stage
* - Blobbi must not already be evolving
* - User must be logged in
*/
export function useStartEvolution({
companion,
ensureCanonicalBeforeAction,
updateCompanionEvent,
invalidateCompanion,
invalidateProfile,
}: UseStartEvolutionParams) {
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
return useMutation({
mutationFn: async (): Promise<StartEvolutionResult> => {
// ─── Validation ───
if (!user?.pubkey) {
throw new Error('You must be logged in to start evolution');
}
if (!companion) {
throw new Error('No companion selected');
}
if (companion.stage !== 'baby') {
throw new Error('Only baby Blobbis can evolve');
}
if (companion.state === 'evolving') {
throw new Error('This Blobbi is already evolving');
}
// ─── Ensure Canonical Before Action ───
const canonical = await ensureCanonicalBeforeAction();
if (!canonical) {
throw new Error('Failed to prepare companion for evolution');
}
// ─── Apply Accumulated Decay ───
const now = Math.floor(Date.now() / 1000);
const nowStr = now.toString();
const decayResult = applyBlobbiDecay({
stage: canonical.companion.stage,
state: canonical.companion.state,
stats: canonical.companion.stats,
lastDecayAt: canonical.companion.lastDecayAt,
now,
});
// ─── Build Updated Tags ───
// Remove any existing task tags when starting fresh
const cleanedTags = canonical.allTags.filter(tag =>
tag[0] !== 'task' && tag[0] !== 'task_completed'
);
// Build stats update with decayed values
const statsUpdate: Record<string, string> = {
health: decayResult.stats.health.toString(),
hygiene: decayResult.stats.hygiene.toString(),
happiness: decayResult.stats.happiness.toString(),
hunger: decayResult.stats.hunger.toString(),
energy: decayResult.stats.energy.toString(),
};
const newTags = updateBlobbiTags(cleanedTags, {
...statsUpdate,
state: 'evolving',
state_started_at: nowStr,
last_interaction: nowStr,
last_decay_at: nowStr,
});
// ─── Publish Event ───
const event = await publishEvent({
kind: KIND_BLOBBI_STATE,
content: canonical.content,
tags: newTags,
});
updateCompanionEvent(event);
invalidateCompanion();
// Invalidate profile if migration occurred
if (canonical.wasMigrated) {
invalidateProfile();
}
return {
name: canonical.companion.name,
stateStartedAt: now,
};
},
onSuccess: ({ name }) => {
toast({
title: 'Evolution started!',
description: `${name} is now working towards evolution. Complete the tasks to evolve!`,
});
},
onError: (error: Error) => {
toast({
title: 'Failed to start evolution',
description: error.message,
variant: 'destructive',
});
},
});
}
// ─── Stop Evolution Hook ──────────────────────────────────────────────────────
/**
* Parameters for stop evolution hook.
*/
export interface UseStopEvolutionParams {
companion: BlobbiCompanion | null;
/** Called to ensure companion is canonical (from migration helper) */
ensureCanonicalBeforeAction: () => Promise<{
companion: BlobbiCompanion;
content: string;
allTags: string[][];
wasMigrated: boolean;
profileAllTags: string[][];
profileStorage: import('@/blobbi/core/lib/blobbi').StorageItem[];
} | null>;
/** Update companion event in local cache */
updateCompanionEvent: (event: NostrEvent) => void;
/** Invalidate companion queries */
invalidateCompanion: () => void;
/** Invalidate profile queries (needed if migration occurred) */
invalidateProfile: () => void;
}
/**
* Result of stopping evolution.
*/
export interface StopEvolutionResult {
/** The Blobbi's name */
name: string;
}
/**
* Hook to stop/cancel the evolution process for a Blobbi.
*
* This resets the Blobbi state to 'active' and clears all task progress tags.
* The user can restart evolution later, but will need to complete tasks again.
*
* When stopping evolution:
* - Apply accumulated decay first
* - Set state back to 'active'
* - Remove state_started_at tag
* - Remove all task and task_completed tags
*
* Requirements:
* - Blobbi must be in evolving state
* - User must be logged in
*/
export function useStopEvolution({
companion,
ensureCanonicalBeforeAction,
updateCompanionEvent,
invalidateCompanion,
invalidateProfile,
}: UseStopEvolutionParams) {
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
return useMutation({
mutationFn: async (): Promise<StopEvolutionResult> => {
// ─── Validation ───
if (!user?.pubkey) {
throw new Error('You must be logged in to stop evolution');
}
if (!companion) {
throw new Error('No companion selected');
}
if (companion.state !== 'evolving') {
throw new Error('This Blobbi is not evolving');
}
// ─── Ensure Canonical Before Action ───
const canonical = await ensureCanonicalBeforeAction();
if (!canonical) {
throw new Error('Failed to prepare companion');
}
// ─── Apply Accumulated Decay ───
const now = Math.floor(Date.now() / 1000);
const nowStr = now.toString();
const decayResult = applyBlobbiDecay({
stage: canonical.companion.stage,
state: canonical.companion.state,
stats: canonical.companion.stats,
lastDecayAt: canonical.companion.lastDecayAt,
now,
});
// ─── Build Updated Tags ───
// Remove task tags and state_started_at
const cleanedTags = canonical.allTags.filter(tag =>
tag[0] !== 'task' &&
tag[0] !== 'task_completed' &&
tag[0] !== 'state_started_at'
);
// Build stats update with decayed values
const statsUpdate: Record<string, string> = {
health: decayResult.stats.health.toString(),
hygiene: decayResult.stats.hygiene.toString(),
happiness: decayResult.stats.happiness.toString(),
hunger: decayResult.stats.hunger.toString(),
energy: decayResult.stats.energy.toString(),
};
const newTags = updateBlobbiTags(cleanedTags, {
...statsUpdate,
state: 'active',
last_interaction: nowStr,
last_decay_at: nowStr,
});
// ─── Publish Event ───
const event = await publishEvent({
kind: KIND_BLOBBI_STATE,
content: canonical.content,
tags: newTags,
});
updateCompanionEvent(event);
invalidateCompanion();
// Invalidate profile if migration occurred
if (canonical.wasMigrated) {
invalidateProfile();
}
return {
name: canonical.companion.name,
};
},
onSuccess: ({ name }) => {
toast({
title: 'Evolution stopped',
description: `${name} is no longer evolving. Task progress has been reset.`,
});
},
onError: (error: Error) => {
toast({
title: 'Failed to stop evolution',
description: error.message,
variant: 'destructive',
});
},
});
}
// ─── Sync Task Completions Hook ───────────────────────────────────────────────
/** Enable debug logging in development only */
const DEBUG_TASK_SYNC = import.meta.env.DEV;
/**
* Parameters for syncing task completions (works for both hatch and evolve).
*/
export interface UseSyncTaskCompletionsParams {
companion: BlobbiCompanion | null;
/** Called to ensure companion is canonical */
ensureCanonicalBeforeAction: () => Promise<{
companion: BlobbiCompanion;
content: string;
allTags: string[][];
wasMigrated: boolean;
profileAllTags: string[][];
profileStorage: import('@/blobbi/core/lib/blobbi').StorageItem[];
} | null>;
/** Update companion event in local cache */
updateCompanionEvent: (event: NostrEvent) => void;
/** Invalidate companion queries */
invalidateCompanion: () => void;
/** Invalidate profile queries */
invalidateProfile: () => void;
}
/**
* Task completions to sync (from useHatchTasks or useEvolveTasks).
*/
export interface TaskCompletionToSync {
taskId: string;
completed: boolean;
}
/**
* Result of sync operation.
*/
export interface SyncTaskCompletionsResult {
/** Task IDs that were synced (empty if nothing needed) */
synced: string[];
/** Whether sync was skipped (no diff) */
skipped: boolean;
/** Reason for skip (for debugging) */
skipReason?: string;
}
/**
* Hook to sync persistent task completions to kind 31124 tags.
* Works for both hatch (incubating) and evolve (evolving) processes.
*
* CRITICAL: This is a cache-only sync. It must be:
* 1. Fully idempotent - calling multiple times with same data = no-op
* 2. Diff-based - only publish when tags would actually change
* 3. Safe - no last_interaction update (this is cache sync, not user action)
* 4. Only sync PERSISTENT tasks - dynamic tasks must NEVER be synced
*
* Source of truth = computed task state from Nostr events.
* Tags = cache layer for faster access.
*/
export function useSyncTaskCompletions({
companion,
ensureCanonicalBeforeAction,
updateCompanionEvent,
invalidateCompanion,
invalidateProfile,
}: UseSyncTaskCompletionsParams) {
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
return useMutation({
mutationFn: async (tasksToSync: TaskCompletionToSync[]): Promise<SyncTaskCompletionsResult> => {
// ─── Early Guards ───
if (!user?.pubkey) {
return { synced: [], skipped: true, skipReason: 'no_user' };
}
if (!companion) {
return { synced: [], skipped: true, skipReason: 'no_companion' };
}
// Must be in an active task process (incubating or evolving)
if (companion.state !== 'incubating' && companion.state !== 'evolving') {
return { synced: [], skipped: true, skipReason: 'not_in_task_process' };
}
// ─── Compute Diff ───
// Get cached completions from companion.tasksCompleted (parsed from tags)
const cachedCompletions = new Set(companion.tasksCompleted);
// Get computed completions from tasks (works for both hatch and evolve)
const computedCompletions = tasksToSync
.filter(t => t.completed)
.map(t => t.taskId);
// Find tasks that are computed as complete but NOT in cache
const missingFromCache = computedCompletions.filter(id => !cachedCompletions.has(id));
if (DEBUG_TASK_SYNC) {
console.log('[TaskSync] Diff check:', {
cachedCompletions: Array.from(cachedCompletions),
computedCompletions,
missingFromCache,
});
}
// If no diff, skip entirely
if (missingFromCache.length === 0) {
if (DEBUG_TASK_SYNC) {
console.log('[TaskSync] Skipped: no diff between computed and cached');
}
return { synced: [], skipped: true, skipReason: 'no_diff' };
}
// ─── Ensure Canonical ───
const canonical = await ensureCanonicalBeforeAction();
if (!canonical) {
return { synced: [], skipped: true, skipReason: 'canonical_failed' };
}
// ─── Build Updated Tags ───
// Re-check against canonical.allTags (may have updated since companion was parsed)
const existingCompletionTags = new Set(
canonical.allTags
.filter(tag => tag[0] === 'task_completed')
.map(tag => tag[1])
);
// Filter to only truly missing tags
const tagsToAdd = missingFromCache.filter(id => !existingCompletionTags.has(id));
if (tagsToAdd.length === 0) {
if (DEBUG_TASK_SYNC) {
console.log('[TaskSync] Skipped: all tags already exist in canonical');
}
return { synced: [], skipped: true, skipReason: 'tags_already_exist' };
}
// Add only the missing task_completed tags
// CRITICAL: Do NOT update last_interaction - this is cache sync, not user action
const updatedTags = [
...canonical.allTags,
...tagsToAdd.map(id => ['task_completed', id]),
];
if (DEBUG_TASK_SYNC) {
console.log('[TaskSync] Publishing:', {
tagsToAdd,
totalTags: updatedTags.length,
});
}
// ─── Publish ───
const event = await publishEvent({
kind: KIND_BLOBBI_STATE,
content: canonical.content,
tags: updatedTags,
});
updateCompanionEvent(event);
invalidateCompanion();
if (canonical.wasMigrated) {
invalidateProfile();
}
if (DEBUG_TASK_SYNC) {
console.log('[TaskSync] Published successfully:', tagsToAdd);
}
return { synced: tagsToAdd, skipped: false };
},
});
}
@@ -1,407 +0,0 @@
// src/blobbi/actions/hooks/useBlobbiStageTransition.ts
/**
* Hooks for Blobbi stage transitions (hatch, evolve).
*
* Both transitions follow the same decay pattern:
* 1. Apply accumulated decay from `last_decay_at` to `now`
* 2. Use decayed stats as the source of truth for the transition
* 3. Publish new event with decayed stats + new stage
* 4. Reset `last_decay_at` to current timestamp
*
* @see docs/blobbi/decay-system.md
*/
import { useMutation } from '@tanstack/react-query';
import type { NostrEvent } from '@nostrify/nostrify';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { toast } from '@/hooks/useToast';
import type { BlobbiCompanion, BlobbonautProfile, BlobbiStage } from '@/blobbi/core/lib/blobbi';
import {
KIND_BLOBBI_STATE,
STAT_MAX,
updateBlobbiTags,
} from '@/blobbi/core/lib/blobbi';
import { applyBlobbiDecay } from '@/blobbi/core/lib/blobbi-decay';
import { validateAndRepairBlobbiTags } from '@/blobbi/core/lib/blobbi-tag-schema';
import { getStreakTagUpdates } from '../lib/blobbi-streak';
// ─── Content Helpers ──────────────────────────────────────────────────────────
/**
* Generate the content string for a Blobbi at a given stage.
* Format: "{name} is a {stage} Blobbi."
*
* Uses correct grammar: "an egg" vs "a baby/adult"
*/
function generateBlobbiContent(name: string, stage: BlobbiStage): string {
const article = stage === 'egg' ? 'an' : 'a';
return `${name} is ${article} ${stage} Blobbi.`;
}
// ─── Types ────────────────────────────────────────────────────────────────────
/**
* Result of ensuring canonical companion before action.
* This is the same interface used by useBlobbiUseInventoryItem.
*/
export interface CanonicalActionResult {
companion: BlobbiCompanion;
content: string;
allTags: string[][];
wasMigrated: boolean;
/** Latest profile tags after migration */
profileAllTags: string[][];
/** Latest profile storage after migration */
profileStorage: import('@/blobbi/core/lib/blobbi').StorageItem[];
}
/**
* Parameters for stage transition hooks.
*/
export interface UseBlobbiStageTransitionParams {
companion: BlobbiCompanion | null;
profile: BlobbonautProfile | null;
/** Called to ensure companion is canonical (from migration helper) */
ensureCanonicalBeforeAction: () => Promise<CanonicalActionResult | null>;
/** Update companion event in local cache */
updateCompanionEvent: (event: NostrEvent) => void;
/** Invalidate companion queries */
invalidateCompanion: () => void;
/** Invalidate profile queries (needed if migration occurred) */
invalidateProfile: () => void;
}
/**
* Result of a stage transition.
*/
export interface StageTransitionResult {
/** Previous stage before transition */
previousStage: BlobbiStage;
/** New stage after transition */
newStage: BlobbiStage;
/** The Blobbi's name */
name: string;
/** Stats after decay was applied (before any transition bonuses) */
decayedStats: {
hunger: number;
happiness: number;
health: number;
hygiene: number;
energy: number;
};
}
// ─── Hatch Hook ───────────────────────────────────────────────────────────────
/**
* Hook to hatch an egg into a baby Blobbi.
*
* Transition: egg -> baby
*
* Requirements:
* - Blobbi must be in egg stage
* - Applies accumulated decay before transition
* - Resets stats to healthy baby defaults (inherits health from egg)
* - Sets last_decay_at to current timestamp
*/
export function useBlobbiHatch({
companion,
profile,
ensureCanonicalBeforeAction,
updateCompanionEvent,
invalidateCompanion,
invalidateProfile,
}: UseBlobbiStageTransitionParams) {
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
return useMutation({
mutationFn: async (): Promise<StageTransitionResult> => {
// ─── Validation ───
if (!user?.pubkey) {
throw new Error('You must be logged in to hatch');
}
if (!companion) {
throw new Error('No companion selected');
}
if (!profile) {
throw new Error('Profile not found');
}
if (companion.stage !== 'egg') {
throw new Error('Only eggs can be hatched');
}
// ─── Ensure Canonical Before Action ───
const canonical = await ensureCanonicalBeforeAction();
if (!canonical) {
throw new Error('Failed to prepare companion for hatching');
}
// ─── Apply Accumulated Decay First ───
// Per decay-system.md: Always apply accumulated decay from persisted state
// before any stage transition.
const now = Math.floor(Date.now() / 1000);
const decayResult = applyBlobbiDecay({
stage: canonical.companion.stage,
state: canonical.companion.state,
stats: canonical.companion.stats,
lastDecayAt: canonical.companion.lastDecayAt,
now,
});
// ─── Calculate Baby Stats ───
// All stats reset to 100 when hatching — the baby starts fresh
const babyStats = {
hunger: STAT_MAX,
happiness: STAT_MAX,
health: STAT_MAX,
hygiene: STAT_MAX,
energy: STAT_MAX,
};
// ─── Build Updated Tags ───
// CRITICAL: Start from canonical.allTags and only remove task/state-specific tags
// This preserves ALL identity attributes (personality, trait, favorite_food, etc.)
const nowStr = now.toString();
// Build the updated tags using the central merge function
// Get streak updates (hatching counts as care activity!)
const streakUpdates = getStreakTagUpdates(canonical.companion) ?? {};
const mergedTags = updateBlobbiTags(canonical.allTags, {
stage: 'baby',
state: 'active', // Newly hatched babies are awake
hunger: babyStats.hunger.toString(),
happiness: babyStats.happiness.toString(),
health: babyStats.health.toString(),
hygiene: babyStats.hygiene.toString(),
energy: babyStats.energy.toString(),
...streakUpdates,
last_interaction: nowStr,
last_decay_at: nowStr,
});
// ─── Validate and Repair Tags ───
// Use the tag integrity guard to ensure all persistent tags are preserved
// and task-related tags are properly cleaned up for stage transitions
const repairResult = validateAndRepairBlobbiTags(
mergedTags,
canonical.allTags,
{ cleanupTaskTags: true }
);
if (repairResult.errors.length > 0) {
console.error('[Hatch] Tag validation errors:', repairResult.errors);
throw new Error(`Tag validation failed: ${repairResult.errors.join(', ')}`);
}
if (repairResult.repaired && import.meta.env.DEV) {
console.log('[Hatch] Tag repairs applied:', repairResult.repairs);
}
const newTags = repairResult.tags;
// ─── Generate New Content for Baby Stage ───
// CRITICAL: Content must reflect the new stage
const newContent = generateBlobbiContent(canonical.companion.name, 'baby');
// ─── Publish Event ───
const event = await publishEvent({
kind: KIND_BLOBBI_STATE,
content: newContent,
tags: newTags,
});
updateCompanionEvent(event);
invalidateCompanion();
// Invalidate profile if migration occurred
if (canonical.wasMigrated) {
invalidateProfile();
}
return {
previousStage: 'egg',
newStage: 'baby',
name: canonical.companion.name,
decayedStats: decayResult.stats,
};
},
onSuccess: ({ name }) => {
toast({
title: 'Your egg hatched!',
description: `${name} is now a baby Blobbi! Take good care of them.`,
});
},
onError: (error: Error) => {
toast({
title: 'Failed to hatch',
description: error.message,
variant: 'destructive',
});
},
});
}
// ─── Evolve Hook ──────────────────────────────────────────────────────────────
/**
* Hook to evolve a baby Blobbi into an adult.
*
* Transition: baby -> adult
*
* Requirements:
* - Blobbi must be in baby stage
* - Applies accumulated decay before transition
* - Preserves all stats (decay already applied)
* - Sets last_decay_at to current timestamp
*/
export function useBlobbiEvolve({
companion,
profile,
ensureCanonicalBeforeAction,
updateCompanionEvent,
invalidateCompanion,
invalidateProfile,
}: UseBlobbiStageTransitionParams) {
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
return useMutation({
mutationFn: async (): Promise<StageTransitionResult> => {
// ─── Validation ───
if (!user?.pubkey) {
throw new Error('You must be logged in to evolve');
}
if (!companion) {
throw new Error('No companion selected');
}
if (!profile) {
throw new Error('Profile not found');
}
if (companion.stage !== 'baby') {
if (companion.stage === 'egg') {
throw new Error('Eggs must hatch before they can evolve');
}
if (companion.stage === 'adult') {
throw new Error('This Blobbi is already fully evolved');
}
throw new Error('Only baby Blobbis can evolve');
}
// ─── Ensure Canonical Before Action ───
const canonical = await ensureCanonicalBeforeAction();
if (!canonical) {
throw new Error('Failed to prepare companion for evolution');
}
// ─── Apply Accumulated Decay First ───
// Per decay-system.md: Always apply accumulated decay from persisted state
// before any stage transition.
const now = Math.floor(Date.now() / 1000);
const decayResult = applyBlobbiDecay({
stage: canonical.companion.stage,
state: canonical.companion.state,
stats: canonical.companion.stats,
lastDecayAt: canonical.companion.lastDecayAt,
now,
});
// ─── Adult Stats ───
// Adult inherits all decayed stats from baby
// No stat reset - evolution preserves current condition
const adultStats = decayResult.stats;
// ─── Build Updated Tags ───
// CRITICAL: Start from canonical.allTags and only remove task/state-specific tags
// This preserves ALL identity attributes (personality, trait, favorite_food, etc.)
const nowStr = now.toString();
// Get streak updates (evolving counts as care activity!)
const streakUpdates = getStreakTagUpdates(canonical.companion) ?? {};
// Build the updated tags using the central merge function
const mergedTags = updateBlobbiTags(canonical.allTags, {
stage: 'adult',
state: 'active', // Evolution completes with active state
hunger: adultStats.hunger.toString(),
happiness: adultStats.happiness.toString(),
health: adultStats.health.toString(),
hygiene: adultStats.hygiene.toString(),
energy: adultStats.energy.toString(),
...streakUpdates,
last_interaction: nowStr,
last_decay_at: nowStr,
});
// ─── Validate and Repair Tags ───
// Use the tag integrity guard to ensure all persistent tags are preserved
// and task-related tags are properly cleaned up for stage transitions
const repairResult = validateAndRepairBlobbiTags(
mergedTags,
canonical.allTags,
{ cleanupTaskTags: true }
);
if (repairResult.errors.length > 0) {
console.error('[Evolve] Tag validation errors:', repairResult.errors);
throw new Error(`Tag validation failed: ${repairResult.errors.join(', ')}`);
}
if (repairResult.repaired && import.meta.env.DEV) {
console.log('[Evolve] Tag repairs applied:', repairResult.repairs);
}
const newTags = repairResult.tags;
// ─── Generate New Content for Adult Stage ───
// CRITICAL: Content must reflect the new stage
const newContent = generateBlobbiContent(canonical.companion.name, 'adult');
// ─── Publish Event ───
const event = await publishEvent({
kind: KIND_BLOBBI_STATE,
content: newContent,
tags: newTags,
});
updateCompanionEvent(event);
invalidateCompanion();
// Invalidate profile if migration occurred
if (canonical.wasMigrated) {
invalidateProfile();
}
return {
previousStage: 'baby',
newStage: 'adult',
name: canonical.companion.name,
decayedStats: decayResult.stats,
};
},
onSuccess: ({ name }) => {
toast({
title: 'Evolution complete!',
description: `${name} has evolved into an adult Blobbi!`,
});
},
onError: (error: Error) => {
toast({
title: 'Failed to evolve',
description: error.message,
variant: 'destructive',
});
},
});
}
@@ -1,466 +0,0 @@
// src/blobbi/actions/hooks/useBlobbiUseInventoryItem.ts
import { useMutation } from '@tanstack/react-query';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { toast } from '@/hooks/useToast';
import type { BlobbiCompanion, BlobbonautProfile, BlobbiStats } from '@/blobbi/core/lib/blobbi';
import {
KIND_BLOBBI_STATE,
KIND_BLOBBONAUT_PROFILE,
updateBlobbiTags,
updateBlobbonautTags,
createStorageTags,
} from '@/blobbi/core/lib/blobbi';
import { applyBlobbiDecay } from '@/blobbi/core/lib/blobbi-decay';
import { getShopItemById } from '@/blobbi/shop/lib/blobbi-shop-items';
import {
applyItemEffects,
decrementStorageItem,
canUseAction,
getStageRestrictionMessage,
clampStat,
applyStat,
hasMedicineEffectForEgg,
hasHygieneEffectForEgg,
incrementInteractionTaskTags,
type InventoryAction,
ACTION_METADATA,
} from '../lib/blobbi-action-utils';
import { trackMultipleDailyMissionActions } from '../lib/daily-mission-tracker';
import type { DailyMissionAction } from '../lib/daily-missions';
import { getStreakTagUpdates } from '../lib/blobbi-streak';
import { calculateInventoryActionXP, applyXPGain, formatXPGain } from '../lib/blobbi-xp';
import { HATCH_REQUIRED_INTERACTIONS } from './useHatchTasks';
import { EVOLVE_REQUIRED_INTERACTIONS } from './useEvolveTasks';
/**
* Request payload for using an inventory item
*/
export interface UseItemRequest {
itemId: string;
action: InventoryAction;
/** Number of items to use (defaults to 1) */
quantity?: number;
}
/**
* Result of using an inventory item
*/
export interface UseItemResult {
itemName: string;
action: InventoryAction;
quantity: number;
effectiveItemCount: number; // How many items actually changed stats (may be less than quantity due to caps)
statsChanged: Record<string, number>;
xpGained: number;
newXP: number;
}
/**
* Parameters for the useBlobbiUseInventoryItem hook
*/
export interface UseBlobbiUseInventoryItemParams {
companion: BlobbiCompanion | null;
profile: BlobbonautProfile | null;
/** Called after ensuring companion is canonical (from migration helper) */
ensureCanonicalBeforeAction: () => Promise<{
companion: BlobbiCompanion;
content: string;
allTags: string[][];
wasMigrated: boolean;
/** Latest profile tags after migration (use instead of profile.allTags) */
profileAllTags: string[][];
/** Latest profile storage after migration (use instead of profile.storage) */
profileStorage: import('@/blobbi/core/lib/blobbi').StorageItem[];
} | null>;
/** Update companion event in local cache */
updateCompanionEvent: (event: NostrEvent) => void;
/** Update profile event in local cache */
updateProfileEvent: (event: NostrEvent) => void;
/** Invalidate companion queries */
invalidateCompanion: () => void;
/** Invalidate profile queries */
invalidateProfile: () => void;
}
// Import NostrEvent type
import type { NostrEvent } from '@nostrify/nostrify';
/**
* Hook to use an inventory item on a Blobbi companion.
*
* This hook:
* 1. Validates the companion stage (eggs can't use items)
* 2. Validates the item exists in storage
* 3. Ensures canonical format before action
* 4. Applies item effects to Blobbi stats
* 5. Updates Blobbi state (kind 31124)
* 6. Decrements item from profile storage (kind 11125)
* 7. Invalidates relevant queries
*/
export function useBlobbiUseInventoryItem({
companion,
profile,
ensureCanonicalBeforeAction,
updateCompanionEvent,
updateProfileEvent,
invalidateCompanion,
invalidateProfile,
}: UseBlobbiUseInventoryItemParams) {
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
return useMutation({
mutationFn: async ({ itemId, action, quantity = 1 }: UseItemRequest): Promise<UseItemResult> => {
// ─── Validation ───
if (!user?.pubkey) {
throw new Error('You must be logged in to use items');
}
if (!companion) {
throw new Error('No companion selected');
}
if (!profile) {
throw new Error('Profile not found');
}
// Validate quantity
if (quantity < 1) {
throw new Error('Quantity must be at least 1');
}
// Check stage restrictions for this specific action
if (!canUseAction(companion, action)) {
const message = getStageRestrictionMessage(companion, action);
throw new Error(message ?? 'This companion cannot use this item');
}
// Validate item exists in shop catalog
const shopItem = getShopItemById(itemId);
if (!shopItem) {
throw new Error('Item not found in catalog');
}
// Validate item exists in storage with sufficient quantity
const storageItem = profile.storage.find(s => s.itemId === itemId);
if (!storageItem || storageItem.quantity <= 0) {
throw new Error('Item not found in your inventory');
}
if (storageItem.quantity < quantity) {
throw new Error(`Not enough items in inventory (have ${storageItem.quantity}, need ${quantity})`);
}
// Validate item has effects
if (!shopItem.effect) {
throw new Error('This item has no effect');
}
// For eggs, validate that items have applicable effects
const isEgg = companion.stage === 'egg';
if (isEgg && action === 'medicine' && !hasMedicineEffectForEgg(shopItem.effect)) {
throw new Error('This medicine has no effect on eggs');
}
if (isEgg && action === 'clean' && !hasHygieneEffectForEgg(shopItem.effect)) {
throw new Error('This item has no cleaning effect on eggs');
}
// ─── Ensure Canonical Before Action ───
const canonical = await ensureCanonicalBeforeAction();
if (!canonical) {
throw new Error('Failed to prepare companion for action');
}
// ─── Apply Accumulated Decay First ───
// Per decay-system.md: Always apply accumulated decay from persisted state
// before any user interaction updates stats.
// CRITICAL: Use canonical.companion for decay calculations, not the stale outer companion
const now = Math.floor(Date.now() / 1000);
const decayResult = applyBlobbiDecay({
stage: canonical.companion.stage,
state: canonical.companion.state,
stats: canonical.companion.stats,
lastDecayAt: canonical.companion.lastDecayAt,
now,
});
// Start with decayed stats as the base
const statsAfterDecay = decayResult.stats;
// ─── Validate Play Energy Requirements ───
// For play actions, validate the Blobbi has enough energy AFTER decay
if (action === 'play') {
const energyCost = Math.abs(shopItem.effect.energy ?? 0);
const currentEnergy = statsAfterDecay.energy;
if (energyCost > 0 && currentEnergy < energyCost) {
throw new Error(
`Your Blobbi needs at least ${energyCost} energy to play with this toy (current: ${currentEnergy})`
);
}
// Also check if playing would have any effect at all
// If happiness is maxed AND we can't spend energy, playing is pointless
const happinessGain = shopItem.effect.happiness ?? 0;
const currentHappiness = statsAfterDecay.happiness;
const wouldGainHappiness = happinessGain > 0 && currentHappiness < 100;
const wouldSpendEnergy = energyCost > 0 && currentEnergy >= energyCost;
if (!wouldGainHappiness && !wouldSpendEnergy) {
throw new Error(
'Playing would have no effect - your Blobbi is already at maximum happiness and has no energy to spend'
);
}
}
// ─── Apply Item Effects ───
// Apply effects multiple times (once per quantity) to simulate using items in sequence.
// This ensures proper clamping at each step, e.g., using 5 health items when at 90 health
// won't give more than 100 health total.
//
// CRITICAL: Track the number of items that actually produced INTENDED stat changes for XP.
// XP counting is action-aware - only count positive intended effects, NOT negative side effects:
// - feed: count when hunger/energy/health/happiness INCREASE (NOT when hygiene decreases)
// - clean: count when hygiene or happiness INCREASES
// - medicine: count when health/energy/happiness INCREASE (NOT negative side effects)
// - play: EXCEPTION - count when happiness increases OR energy decreases (both are intended effects)
//
// Use canonical companion stage for egg checks
const isEggCompanion = canonical.companion.stage === 'egg';
const statsUpdate: Record<string, string> = {};
const statsChanged: Record<string, number> = {};
let effectiveItemCount = 0; // Number of items that produced intended effects
if (isEggCompanion && action === 'medicine') {
// Egg medicine handling:
// Eggs use the 3-stat model: health, hygiene, happiness
// Medicine with health effect directly affects the egg's health stat
// hunger and energy remain fixed at 100 for eggs
const healthDelta = shopItem.effect.health ?? 0;
// Apply health effect N times in sequence with clamping at each step
// Only count items that actually INCREASED health (positive effect only)
let currentHealth = statsAfterDecay.health ?? 0;
for (let i = 0; i < quantity; i++) {
const prevHealth = currentHealth;
currentHealth = applyStat(currentHealth, healthDelta);
// Only count as effective if health increased (not just changed)
if (healthDelta > 0 && currentHealth > prevHealth) {
effectiveItemCount++;
}
}
statsUpdate.health = currentHealth.toString();
// Track total actual change (may be less than healthDelta * quantity due to clamping)
statsChanged.health = currentHealth - (statsAfterDecay.health ?? 0);
// Apply decayed values for other egg stats
statsUpdate.hygiene = (statsAfterDecay.hygiene ?? 0).toString();
statsUpdate.happiness = (statsAfterDecay.happiness ?? 0).toString();
// hunger and energy stay at 100 for eggs
statsUpdate.hunger = '100';
statsUpdate.energy = '100';
} else if (isEggCompanion && action === 'clean') {
// Egg clean/hygiene handling:
// Hygiene items affect the egg's hygiene stat
// Some hygiene items also give happiness (e.g., bubble bath)
// hunger and energy remain fixed at 100 for eggs
const hygieneDelta = shopItem.effect.hygiene ?? 0;
const happinessDelta = shopItem.effect.happiness ?? 0;
// Apply effects N times in sequence
// Only count items that INCREASED hygiene or happiness (positive effects only)
let currentHygiene = statsAfterDecay.hygiene ?? 0;
let currentHappiness = statsAfterDecay.happiness ?? 0;
for (let i = 0; i < quantity; i++) {
const prevHygiene = currentHygiene;
const prevHappiness = currentHappiness;
currentHygiene = applyStat(currentHygiene, hygieneDelta);
currentHappiness = applyStat(currentHappiness, happinessDelta);
// Count as effective if hygiene OR happiness increased (positive effects only)
const hygieneIncreased = hygieneDelta > 0 && currentHygiene > prevHygiene;
const happinessIncreased = happinessDelta > 0 && currentHappiness > prevHappiness;
if (hygieneIncreased || happinessIncreased) {
effectiveItemCount++;
}
}
statsUpdate.hygiene = currentHygiene.toString();
statsChanged.hygiene = currentHygiene - (statsAfterDecay.hygiene ?? 0);
statsUpdate.happiness = currentHappiness.toString();
const totalHappinessChange = currentHappiness - (statsAfterDecay.happiness ?? 0);
if (totalHappinessChange !== 0) {
statsChanged.happiness = totalHappinessChange;
}
// Apply decayed health
statsUpdate.health = (statsAfterDecay.health ?? 0).toString();
// hunger and energy stay at 100 for eggs
statsUpdate.hunger = '100';
statsUpdate.energy = '100';
} else {
// Normal stats application for baby/adult
// Apply item effects N times in sequence ON TOP of decayed stats
// Use action-aware effectiveness checking for XP calculation
let currentStats: Partial<BlobbiStats> = { ...statsAfterDecay };
const effect = shopItem.effect;
for (let i = 0; i < quantity; i++) {
const prevStats = { ...currentStats };
currentStats = applyItemEffects(currentStats, effect);
// Action-aware effectiveness check:
// Only count INTENDED positive effects, not negative side effects
let isEffective = false;
if (action === 'feed') {
// Feed: count when hunger/energy/health/happiness INCREASE
// Do NOT count hygiene decrease (that's a side effect)
const hungerIncreased = (effect.hunger ?? 0) > 0 && (currentStats.hunger ?? 0) > (prevStats.hunger ?? 0);
const energyIncreased = (effect.energy ?? 0) > 0 && (currentStats.energy ?? 0) > (prevStats.energy ?? 0);
const healthIncreased = (effect.health ?? 0) > 0 && (currentStats.health ?? 0) > (prevStats.health ?? 0);
const happinessIncreased = (effect.happiness ?? 0) > 0 && (currentStats.happiness ?? 0) > (prevStats.happiness ?? 0);
isEffective = hungerIncreased || energyIncreased || healthIncreased || happinessIncreased;
} else if (action === 'clean') {
// Clean: count when hygiene or happiness INCREASES
const hygieneIncreased = (effect.hygiene ?? 0) > 0 && (currentStats.hygiene ?? 0) > (prevStats.hygiene ?? 0);
const happinessIncreased = (effect.happiness ?? 0) > 0 && (currentStats.happiness ?? 0) > (prevStats.happiness ?? 0);
isEffective = hygieneIncreased || happinessIncreased;
} else if (action === 'medicine') {
// Medicine: count when health/energy/happiness INCREASE
// Do NOT count negative side effects (like happiness decrease on Super Medicine)
const healthIncreased = (effect.health ?? 0) > 0 && (currentStats.health ?? 0) > (prevStats.health ?? 0);
const energyIncreased = (effect.energy ?? 0) > 0 && (currentStats.energy ?? 0) > (prevStats.energy ?? 0);
const happinessIncreased = (effect.happiness ?? 0) > 0 && (currentStats.happiness ?? 0) > (prevStats.happiness ?? 0);
isEffective = healthIncreased || energyIncreased || happinessIncreased;
} else if (action === 'play') {
// Play: EXCEPTION - both happiness increase AND energy decrease are intended effects
// Playing naturally consumes energy, so energy decrease counts as valid
const happinessIncreased = (effect.happiness ?? 0) > 0 && (currentStats.happiness ?? 0) > (prevStats.happiness ?? 0);
const energyDecreased = (effect.energy ?? 0) < 0 && (currentStats.energy ?? 0) < (prevStats.energy ?? 0);
isEffective = happinessIncreased || energyDecreased;
}
if (isEffective) {
effectiveItemCount++;
}
}
statsUpdate.hunger = clampStat(currentStats.hunger).toString();
statsChanged.hunger = (currentStats.hunger ?? 0) - (statsAfterDecay.hunger ?? 0);
statsUpdate.happiness = clampStat(currentStats.happiness).toString();
statsChanged.happiness = (currentStats.happiness ?? 0) - (statsAfterDecay.happiness ?? 0);
statsUpdate.energy = clampStat(currentStats.energy).toString();
statsChanged.energy = (currentStats.energy ?? 0) - (statsAfterDecay.energy ?? 0);
statsUpdate.hygiene = clampStat(currentStats.hygiene).toString();
statsChanged.hygiene = (currentStats.hygiene ?? 0) - (statsAfterDecay.hygiene ?? 0);
statsUpdate.health = clampStat(currentStats.health).toString();
statsChanged.health = (currentStats.health ?? 0) - (statsAfterDecay.health ?? 0);
}
// ─── Update Blobbi State Event (kind 31124) ───
const nowStr = now.toString();
// If incubating or evolving, increment the interaction counter for tasks
const companionState = canonical.companion.state;
let updatedTags = canonical.allTags;
if (companionState === 'incubating') {
updatedTags = incrementInteractionTaskTags(canonical.allTags, HATCH_REQUIRED_INTERACTIONS).updatedTags;
} else if (companionState === 'evolving') {
updatedTags = incrementInteractionTaskTags(canonical.allTags, EVOLVE_REQUIRED_INTERACTIONS).updatedTags;
}
// Get streak updates (will only update if needed based on day)
const streakUpdates = getStreakTagUpdates(canonical.companion) ?? {};
// ─── Apply XP Gain (Based on effective item count) ───
// Only grant XP for items that actually changed stats.
// If user used 100 food items but hunger capped at item #4, only 4 items were effective.
// This prevents XP farming by mass-using items after stats are already maxed.
const xpGained = effectiveItemCount > 0 ? calculateInventoryActionXP(action, effectiveItemCount) : 0;
const currentXP = canonical.companion.experience ?? 0;
const newXP = applyXPGain(currentXP, xpGained);
const blobbiTags = updateBlobbiTags(updatedTags, {
...statsUpdate,
...streakUpdates,
experience: newXP.toString(),
last_interaction: nowStr,
last_decay_at: nowStr,
});
const blobbiEvent = await publishEvent({
kind: KIND_BLOBBI_STATE,
content: canonical.content,
tags: blobbiTags,
});
updateCompanionEvent(blobbiEvent);
// ─── Update Profile Storage (kind 11125) ───
// CRITICAL: Use canonical.profileStorage and canonical.profileAllTags
// instead of profile.storage/profile.allTags to avoid restoring
// stale/legacy values after migration
const newStorage = decrementStorageItem(canonical.profileStorage, itemId, quantity);
const storageValues = createStorageTags(newStorage).map(tag => tag[1]);
const profileTags = updateBlobbonautTags(canonical.profileAllTags, {
storage: storageValues,
});
const profileEvent = await publishEvent({
kind: KIND_BLOBBONAUT_PROFILE,
content: '',
tags: profileTags,
});
updateProfileEvent(profileEvent);
// ─── Invalidate Queries ───
invalidateCompanion();
invalidateProfile();
return {
itemName: shopItem.name,
action,
quantity,
effectiveItemCount, // How many items actually changed stats
statsChanged,
xpGained,
newXP,
};
},
onSuccess: ({ itemName, action, quantity, xpGained }) => {
const actionMeta = ACTION_METADATA[action];
const quantityText = quantity > 1 ? ` (x${quantity})` : '';
const xpText = formatXPGain(xpGained);
toast({
title: `${actionMeta.label} successful!`,
description: `Used ${itemName}${quantityText} on your Blobbi. ${xpText}`,
});
// Track daily mission progress
// 'interact' is always tracked, plus the specific action if it maps to a daily mission
const dailyActions: DailyMissionAction[] = ['interact'];
if (action === 'feed') dailyActions.push('feed');
if (action === 'clean') dailyActions.push('clean');
trackMultipleDailyMissionActions(dailyActions, user?.pubkey);
},
onError: (error: Error) => {
toast({
title: 'Failed to use item',
description: error.message,
variant: 'destructive',
});
},
});
}
@@ -1,242 +0,0 @@
/**
* useClaimMissionReward - Hook for claiming daily mission rewards
*
* Handles:
* - Persisting coin rewards to kind 11125 Blobbonaut profile
* - Updating localStorage mission state
* - Idempotent claiming (prevents double-credit)
* - Optimistic cache updates
*/
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { toast } from '@/hooks/useToast';
import type { BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
import {
KIND_BLOBBONAUT_PROFILE,
updateBlobbonautTags,
} from '@/blobbi/core/lib/blobbi';
import {
type DailyMissionsState,
getTodayDateString,
needsDailyReset,
createDailyMissionsState,
isBonusMissionAvailable,
isBonusMissionClaimed,
BONUS_MISSION_DEFINITION,
} from '../lib/daily-missions';
// ─── Types ────────────────────────────────────────────────────────────────────
export interface ClaimMissionRequest {
missionId: string;
}
/** Special ID for claiming the bonus mission */
export const BONUS_MISSION_ID = 'bonus_daily_complete';
export interface ClaimMissionResult {
missionId: string;
coinsEarned: number;
newTotalCoins: number;
}
// ─── Constants ────────────────────────────────────────────────────────────────
const STORAGE_KEY = 'blobbi:daily-missions';
// ─── Storage Utilities ────────────────────────────────────────────────────────
function readMissionsState(): DailyMissionsState | null {
try {
const stored = localStorage.getItem(STORAGE_KEY);
return stored ? JSON.parse(stored) : null;
} catch {
return null;
}
}
function writeMissionsState(state: DailyMissionsState): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} catch (error) {
console.warn('[useClaimMissionReward] Failed to write state:', error);
}
}
// ─── Hook ─────────────────────────────────────────────────────────────────────
/**
* Hook to claim daily mission rewards.
*
* This hook persists coin rewards to the kind 11125 Blobbonaut profile event,
* ensuring rewards are stored on-chain rather than just in localStorage.
*
* @param currentProfile - The current Blobbonaut profile (required for coin updates)
* @param updateProfileEvent - Callback to update the profile in the query cache
*/
export function useClaimMissionReward(
currentProfile: BlobbonautProfile | null,
updateProfileEvent: (event: import('@nostrify/nostrify').NostrEvent) => void
) {
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ missionId }: ClaimMissionRequest): Promise<ClaimMissionResult> => {
if (!user?.pubkey) {
throw new Error('You must be logged in to claim rewards');
}
if (!currentProfile) {
throw new Error('Profile not found');
}
// Read current missions state from localStorage
let missionsState = readMissionsState();
// Ensure we have valid state for today
if (needsDailyReset(missionsState)) {
const previousCoins = missionsState?.totalCoinsEarned ?? 0;
missionsState = createDailyMissionsState(getTodayDateString(), user.pubkey, previousCoins);
}
// Handle bonus mission claim
if (missionId === BONUS_MISSION_ID) {
// Check if bonus is available
if (!isBonusMissionAvailable(missionsState!)) {
throw new Error('Bonus mission not available yet');
}
// Check if already claimed
if (isBonusMissionClaimed(missionsState!)) {
throw new Error('Bonus reward already claimed');
}
const coinsToAdd = BONUS_MISSION_DEFINITION.reward;
const newTotalCoins = currentProfile.coins + coinsToAdd;
// Build updated tags with new coin balance
const updatedTags = updateBlobbonautTags(currentProfile.allTags, {
coins: newTotalCoins.toString(),
});
// Publish updated profile event
const event = await publishEvent({
kind: KIND_BLOBBONAUT_PROFILE,
content: '',
tags: updatedTags,
});
// Update the query cache
updateProfileEvent(event);
// Update localStorage to mark bonus as claimed
const updatedState: DailyMissionsState = {
...missionsState!,
bonusClaimed: true,
totalCoinsEarned: missionsState!.totalCoinsEarned + coinsToAdd,
};
writeMissionsState(updatedState);
// Dispatch event for React components to re-render
window.dispatchEvent(new CustomEvent('daily-missions-updated', {
detail: { missionId, claimed: true, isBonus: true }
}));
return {
missionId,
coinsEarned: coinsToAdd,
newTotalCoins,
};
}
// Handle regular mission claim
const mission = missionsState!.missions.find(m => m.id === missionId);
if (!mission) {
throw new Error('Mission not found');
}
// Check if already claimed (idempotency check)
if (mission.claimed) {
throw new Error('Reward already claimed');
}
// Check if mission is completed
if (!mission.completed) {
throw new Error('Mission not completed yet');
}
const coinsToAdd = mission.reward;
const newTotalCoins = currentProfile.coins + coinsToAdd;
// Build updated tags with new coin balance
const updatedTags = updateBlobbonautTags(currentProfile.allTags, {
coins: newTotalCoins.toString(),
});
// Publish updated profile event to kind 11125
const event = await publishEvent({
kind: KIND_BLOBBONAUT_PROFILE,
content: '',
tags: updatedTags,
});
// Update the query cache optimistically
updateProfileEvent(event);
// Now update localStorage to mark mission as claimed
const updatedMissions = missionsState!.missions.map(m =>
m.id === missionId ? { ...m, claimed: true } : m
);
const updatedState: DailyMissionsState = {
...missionsState!,
missions: updatedMissions,
totalCoinsEarned: missionsState!.totalCoinsEarned + coinsToAdd,
};
writeMissionsState(updatedState);
// Dispatch event for React components to re-render
window.dispatchEvent(new CustomEvent('daily-missions-updated', {
detail: { missionId, claimed: true }
}));
return {
missionId,
coinsEarned: coinsToAdd,
newTotalCoins,
};
},
onSuccess: ({ coinsEarned }) => {
// Invalidate profile query to ensure fresh data
if (user?.pubkey) {
queryClient.invalidateQueries({ queryKey: ['blobbonaut-profile', user.pubkey] });
}
// Show success toast
toast({
title: 'Reward Claimed!',
description: `You earned ${coinsEarned} coins.`,
});
},
onError: (error: Error) => {
// Don't show error for already claimed (user might have double-clicked)
if (error.message === 'Reward already claimed' || error.message === 'Bonus reward already claimed') {
return;
}
toast({
title: 'Failed to Claim Reward',
description: error.message,
variant: 'destructive',
});
},
});
}
@@ -1,201 +0,0 @@
/**
* useDailyMissions - Hook for managing Blobbi daily missions
*
* Provides:
* - Daily mission state management with localStorage persistence
* - Automatic daily reset
* - Progress tracking functions
* - Read-only access to mission state (claiming is handled by useClaimMissionReward)
* - Stage-based filtering (only shows missions user can complete)
* - Bonus mission tracking
*
* Note: Reward claiming should be done via useClaimMissionReward hook,
* which persists coins to the kind 11125 Blobbonaut profile.
*/
import { useMemo, useEffect, useState, useCallback } from 'react';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import {
type DailyMissionsState,
type DailyMission,
type BlobbiStage,
getTodayDateString,
needsDailyReset,
createDailyMissionsState,
areAllMissionsCompleted,
areAllMissionsClaimed,
getTotalPotentialReward,
getTodayClaimedReward,
isBonusMissionAvailable,
isBonusMissionClaimed,
BONUS_MISSION_DEFINITION,
getRerollsRemaining,
MAX_DAILY_REROLLS,
} from '../lib/daily-missions';
// ─── Types ────────────────────────────────────────────────────────────────────
export interface UseDailyMissionsOptions {
/** Available Blobbi stages the user has (filters eligible missions) */
availableStages?: BlobbiStage[];
}
export interface UseDailyMissionsResult {
/** Current daily missions state */
missions: DailyMission[];
/** Whether all missions are completed */
allCompleted: boolean;
/** Whether all missions are claimed */
allClaimed: boolean;
/** Total potential reward for today (including bonus if available) */
totalPotentialReward: number;
/** Total claimed reward for today */
todayClaimedReward: number;
/** Lifetime total coins earned from daily missions */
lifetimeCoinsEarned: number;
/** Whether the bonus mission is available (all regular missions completed) */
bonusAvailable: boolean;
/** Whether the bonus mission has been claimed */
bonusClaimed: boolean;
/** Bonus mission reward amount */
bonusReward: number;
/** Whether user has no eligible missions (e.g., only eggs) */
noMissionsAvailable: boolean;
/** Number of rerolls remaining for today */
rerollsRemaining: number;
/** Maximum rerolls allowed per day */
maxRerolls: number;
/** Force refresh missions (for testing or manual reset) */
forceReset: () => void;
}
// ─── Constants ────────────────────────────────────────────────────────────────
const STORAGE_KEY = 'blobbi:daily-missions';
// ─── Storage Utilities ────────────────────────────────────────────────────────
function readMissionsState(): DailyMissionsState | null {
try {
const stored = localStorage.getItem(STORAGE_KEY);
return stored ? JSON.parse(stored) : null;
} catch {
return null;
}
}
function writeMissionsState(state: DailyMissionsState): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} catch (error) {
console.warn('[useDailyMissions] Failed to write state:', error);
}
}
// ─── Hook ─────────────────────────────────────────────────────────────────────
export function useDailyMissions(options: UseDailyMissionsOptions = {}): UseDailyMissionsResult {
const { availableStages } = options;
const { user } = useCurrentUser();
const pubkey = user?.pubkey;
// Read state directly from localStorage, with a version counter to trigger re-reads
const [version, setVersion] = useState(0);
// Read from localStorage on every render when version changes
// eslint-disable-next-line react-hooks/exhaustive-deps -- version is intentionally used to force re-read
const state = useMemo(() => readMissionsState(), [version]);
// Wrapper to write state and update version
const setState = useCallback((newState: DailyMissionsState) => {
writeMissionsState(newState);
setVersion((v) => v + 1);
}, []);
// Listen for external updates from mutations (reroll, claim, progress tracking)
// This re-reads localStorage when other hooks modify it directly
useEffect(() => {
const handleExternalUpdate = () => {
// Bump version to trigger a re-read from localStorage
setVersion((v) => v + 1);
};
window.addEventListener('daily-missions-updated', handleExternalUpdate);
return () => window.removeEventListener('daily-missions-updated', handleExternalUpdate);
}, []);
// Stable key for availableStages to use in dependencies
const stagesKey = availableStages?.sort().join(',') ?? '';
// Ensure we have valid state for today
const currentState = useMemo(() => {
// Check if we need to reset for a new day
if (needsDailyReset(state)) {
const previousCoins = state?.totalCoinsEarned ?? 0;
const newState = createDailyMissionsState(getTodayDateString(), pubkey, previousCoins, availableStages);
// Persist the reset state (this will trigger version bump via setState)
writeMissionsState(newState);
return newState;
}
// Migration: ensure rerollsRemaining is set for old state
if (state && state.rerollsRemaining === undefined) {
const migratedState = {
...state,
rerollsRemaining: MAX_DAILY_REROLLS,
};
writeMissionsState(migratedState);
return migratedState;
}
return state!;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state, pubkey, stagesKey]);
// Force reset missions (for testing)
const forceReset = () => {
const previousCoins = state?.totalCoinsEarned ?? 0;
const newState = createDailyMissionsState(getTodayDateString(), pubkey, previousCoins, availableStages);
setState(newState);
};
// Computed values
const missions = currentState.missions;
const allCompleted = areAllMissionsCompleted(currentState);
const allClaimed = areAllMissionsClaimed(currentState);
const bonusAvailable = isBonusMissionAvailable(currentState);
const bonusClaimed = isBonusMissionClaimed(currentState);
const bonusReward = BONUS_MISSION_DEFINITION.reward;
const noMissionsAvailable = missions.length === 0;
const rerollsRemaining = getRerollsRemaining(currentState);
const maxRerolls = MAX_DAILY_REROLLS;
// Total potential includes bonus if regular missions exist
const basePotentialReward = getTotalPotentialReward(currentState);
const totalPotentialReward = missions.length > 0
? basePotentialReward + bonusReward
: 0;
// Today's claimed includes bonus if claimed
const baseTodayClaimedReward = getTodayClaimedReward(currentState);
const todayClaimedReward = baseTodayClaimedReward + (bonusClaimed ? bonusReward : 0);
const lifetimeCoinsEarned = currentState.totalCoinsEarned;
return {
missions,
allCompleted,
allClaimed,
totalPotentialReward,
todayClaimedReward,
lifetimeCoinsEarned,
bonusAvailable,
bonusClaimed,
bonusReward,
noMissionsAvailable,
rerollsRemaining,
maxRerolls,
forceReset,
};
}
-363
View File
@@ -1,363 +0,0 @@
// src/blobbi/actions/hooks/useEvolveTasks.ts
/**
* Hook to compute evolve task progress from Nostr events and current stats.
*
* CRITICAL ARCHITECTURE:
* - PERSISTENT TASKS: Based on Nostr events, can be cached in tags
* - DYNAMIC TASKS: Based on current stats, NEVER stored in tags
*
* Tags are only cache for persistent tasks. Source of truth = Nostr events.
*/
import { useQuery } from '@tanstack/react-query';
import { useNostr } from '@nostrify/react';
import type { NostrEvent, NostrFilter } from '@nostrify/nostrify';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
import {
KIND_THEME_DEFINITION,
KIND_COLOR_MOMENT,
KIND_PROFILE_METADATA,
KIND_SHORT_TEXT_NOTE,
BLOBBI_POST_REQUIRED_HASHTAGS,
sanitizeToHashtag,
type HatchTask,
type TaskType,
} from './useHatchTasks';
// ─── Constants ────────────────────────────────────────────────────────────────
/** Kind for custom profile tabs event */
export const KIND_PROFILE_TABS = 16769;
/** Required themes for evolve task */
export const EVOLVE_REQUIRED_THEMES = 3;
/** Required color moments for evolve task */
export const EVOLVE_REQUIRED_COLOR_MOMENTS = 3;
/** Required posts for evolve task (lighter than hatch - just 1 evolve-specific post) */
export const EVOLVE_REQUIRED_POSTS = 1;
/** Required interactions for evolve task */
export const EVOLVE_REQUIRED_INTERACTIONS = 21;
/** Prefix text for Blobbi evolve post */
export const BLOBBI_EVOLVE_POST_PREFIX = 'Hello Nostr! Posting to evolve';
/** Stat threshold for evolve dynamic task (all stats >= 80) */
export const EVOLVE_STAT_THRESHOLD = 80;
// ─── Types ────────────────────────────────────────────────────────────────────
// Re-export task types for convenience
export type { HatchTask as EvolveTask, TaskType };
/**
* Result of computing evolve tasks.
*/
export interface EvolveTasksResult {
tasks: HatchTask[];
/** All persistent tasks are complete */
persistentTasksComplete: boolean;
/** Dynamic stat task is complete */
dynamicTaskComplete: boolean;
/** All tasks (persistent + dynamic) are complete - required to evolve */
allCompleted: boolean;
isLoading: boolean;
error: Error | null;
/** Refetch task progress */
refetch: () => void;
}
// ─── Helper Functions ─────────────────────────────────────────────────────────
/**
* Check if a post is a valid Blobbi evolve post.
* Must contain the evolve prefix and all required hashtags including the Blobbi name.
*
* @param event - The Nostr event to validate
* @param blobbiName - The Blobbi's name (will be sanitized and checked as hashtag)
*/
export function isValidEvolvePost(event: NostrEvent, blobbiName: string): boolean {
// Check content starts with evolve prefix
if (!event.content.startsWith(BLOBBI_EVOLVE_POST_PREFIX)) {
return false;
}
// Check for required hashtags in tags
const hashtags = event.tags
.filter(tag => tag[0] === 't')
.map(tag => tag[1]?.toLowerCase());
// All required hashtags must be present
const hasRequiredHashtags = BLOBBI_POST_REQUIRED_HASHTAGS.every(required =>
hashtags.includes(required.toLowerCase())
);
if (!hasRequiredHashtags) {
return false;
}
// Blobbi name hashtag must also be present
const blobbiHashtag = sanitizeToHashtag(blobbiName);
return hashtags.includes(blobbiHashtag);
}
// ─── Main Hook ────────────────────────────────────────────────────────────────
/**
* Hook to compute evolve task progress from Nostr events and current stats.
*
* PERSISTENT TASKS (event-based, can be cached):
* 1. Create 3 Themes (kind 36767)
* 2. Create 3 Color Moments (kind 3367)
* 3. Create 1 Evolve Post (kind 1) - lighter than hatch, evolve-specific
* 4. Interact 21 times (tracked via companion.tasks cache)
* 5. Edit Profile once (kind 0 profile metadata OR kind 16769 custom tabs)
*
* DYNAMIC TASK (stat-based, NEVER cached):
* 6. Maintain All Stats >= 80
*
* @param companion - The Blobbi companion (must be in evolving state)
* @param interactionCount - Current interaction count from companion tasks cache
*/
export function useEvolveTasks(
companion: BlobbiCompanion | null,
interactionCount?: number
): EvolveTasksResult {
const { user } = useCurrentUser();
const { nostr } = useNostr();
const pubkey = user?.pubkey;
const stateStartedAt = companion?.stateStartedAt;
const isEvolving = companion?.state === 'evolving';
// Query for all relevant events
const { data, isLoading, error, refetch } = useQuery({
queryKey: ['evolve-tasks', pubkey, stateStartedAt],
queryFn: async () => {
if (!pubkey || !stateStartedAt) {
return null;
}
// Build filters for events we need
const filters: NostrFilter[] = [
// Theme definitions after start
{
kinds: [KIND_THEME_DEFINITION],
authors: [pubkey],
since: stateStartedAt,
},
// Color moments after start
{
kinds: [KIND_COLOR_MOMENT],
authors: [pubkey],
since: stateStartedAt,
},
// Posts after start (will filter for valid evolve posts)
{
kinds: [KIND_SHORT_TEXT_NOTE],
authors: [pubkey],
since: stateStartedAt,
limit: 50, // Only need 1 valid evolve post
},
// Custom profile tabs after start
{
kinds: [KIND_PROFILE_TABS],
authors: [pubkey],
since: stateStartedAt,
limit: 1, // Only need 1
},
// Profile metadata after start (for Blobbi shape check + profile edit mission)
{
kinds: [KIND_PROFILE_METADATA],
authors: [pubkey],
since: stateStartedAt,
limit: 1,
},
];
// Execute all queries
const events = await nostr.query(filters);
// Categorize events
const themeEvents = events.filter(e =>
e.kind === KIND_THEME_DEFINITION && e.created_at >= stateStartedAt
);
const colorMomentEvents = events.filter(e =>
e.kind === KIND_COLOR_MOMENT && e.created_at >= stateStartedAt
);
const postEvents = events.filter(e =>
e.kind === KIND_SHORT_TEXT_NOTE && e.created_at >= stateStartedAt
);
const profileTabsEvents = events.filter(e =>
e.kind === KIND_PROFILE_TABS && e.created_at >= stateStartedAt
);
// Get latest profile after start
const profileEvents = events.filter(e => e.kind === KIND_PROFILE_METADATA);
const profileAfter = profileEvents
.filter(e => e.created_at >= stateStartedAt)
.sort((a, b) => b.created_at - a.created_at)[0];
return {
themeEvents,
colorMomentEvents,
postEvents,
profileTabsEvents,
profileAfter,
};
},
enabled: !!pubkey && !!stateStartedAt && isEvolving,
staleTime: 30_000, // 30 seconds
refetchInterval: 60_000, // Refetch every minute
});
// ─── Compute PERSISTENT Tasks ───
const tasks: HatchTask[] = [];
// 1. Create 3 Themes (PERSISTENT)
const themeCount = data?.themeEvents?.length ?? 0;
const themesCompleted = themeCount >= EVOLVE_REQUIRED_THEMES;
tasks.push({
id: 'create_themes',
name: 'Create Themes',
description: `Create ${EVOLVE_REQUIRED_THEMES} custom themes`,
current: Math.min(themeCount, EVOLVE_REQUIRED_THEMES),
required: EVOLVE_REQUIRED_THEMES,
completed: themesCompleted,
type: 'persistent',
action: 'navigate',
actionTarget: '/themes',
actionLabel: 'Create Theme',
});
// 2. Create 3 Color Moments (PERSISTENT)
const colorMomentCount = data?.colorMomentEvents?.length ?? 0;
const colorMomentsCompleted = colorMomentCount >= EVOLVE_REQUIRED_COLOR_MOMENTS;
tasks.push({
id: 'color_moments',
name: 'Color Moments',
description: `Share ${EVOLVE_REQUIRED_COLOR_MOMENTS} color moments on espy`,
current: Math.min(colorMomentCount, EVOLVE_REQUIRED_COLOR_MOMENTS),
required: EVOLVE_REQUIRED_COLOR_MOMENTS,
completed: colorMomentsCompleted,
type: 'persistent',
action: 'external_link',
actionTarget: 'https://espy.you/',
actionLabel: 'Open espy',
});
// 3. Create 1 Evolve Post (PERSISTENT) - lighter than hatch
const blobbiName = companion?.name ?? '';
const validPosts = data?.postEvents?.filter(e => isValidEvolvePost(e, blobbiName)) ?? [];
const postCount = validPosts.length;
const postsCompleted = postCount >= EVOLVE_REQUIRED_POSTS;
tasks.push({
id: 'create_posts',
name: 'Share Evolution',
description: 'Post about your Blobbi evolving',
current: Math.min(postCount, EVOLVE_REQUIRED_POSTS),
required: EVOLVE_REQUIRED_POSTS,
completed: postsCompleted,
type: 'persistent',
action: 'open_modal',
actionTarget: 'blobbi_post',
actionLabel: 'Create Post',
});
// 4. Interact 21 times (PERSISTENT)
const interactions = interactionCount ?? 0;
const interactionsCompleted = interactions >= EVOLVE_REQUIRED_INTERACTIONS;
tasks.push({
id: 'interactions',
name: 'Interact with Blobbi',
description: `Care for your Blobbi ${EVOLVE_REQUIRED_INTERACTIONS} times`,
current: Math.min(interactions, EVOLVE_REQUIRED_INTERACTIONS),
required: EVOLVE_REQUIRED_INTERACTIONS,
completed: interactionsCompleted,
type: 'persistent',
// No action - just interact with Blobbi
});
// 5. Edit Profile once (PERSISTENT) — kind 0 profile metadata OR kind 16769 custom tabs
const hasTabsEdit = (data?.profileTabsEvents?.length ?? 0) >= 1;
const hasMetadataEdit = !!data?.profileAfter;
const hasProfileEdit = hasTabsEdit || hasMetadataEdit;
tasks.push({
id: 'edit_profile',
name: 'Edit Your Profile',
description: 'Update your profile info or customize your profile tabs',
current: hasProfileEdit ? 1 : 0,
required: 1,
completed: hasProfileEdit,
type: 'persistent',
action: 'navigate',
actionTarget: '/settings/profile',
actionLabel: 'Edit Profile',
});
// ─── Compute DYNAMIC Task (stat-based, NEVER cached) ───
// 7. Maintain All Stats >= 80
const stats = companion?.stats ?? {};
const hunger = stats.hunger ?? 0;
const happiness = stats.happiness ?? 0;
const health = stats.health ?? 0;
const hygiene = stats.hygiene ?? 0;
const energy = stats.energy ?? 0;
const statsOk =
hunger >= EVOLVE_STAT_THRESHOLD &&
happiness >= EVOLVE_STAT_THRESHOLD &&
health >= EVOLVE_STAT_THRESHOLD &&
hygiene >= EVOLVE_STAT_THRESHOLD &&
energy >= EVOLVE_STAT_THRESHOLD;
// Calculate minimum stat for progress display
const minStat = Math.min(hunger, happiness, health, hygiene, energy);
tasks.push({
id: 'maintain_stats',
name: 'Peak Condition',
description: `Keep all stats above ${EVOLVE_STAT_THRESHOLD}`,
current: statsOk ? EVOLVE_STAT_THRESHOLD : minStat,
required: EVOLVE_STAT_THRESHOLD,
completed: statsOk,
type: 'dynamic', // CRITICAL: Never persist this task
// No action - just care for your Blobbi
});
// ─── Compute Completion States ───
const persistentTasks = tasks.filter(t => t.type === 'persistent');
const dynamicTasks = tasks.filter(t => t.type === 'dynamic');
const persistentTasksComplete = persistentTasks.every(t => t.completed);
const dynamicTaskComplete = dynamicTasks.every(t => t.completed);
const allCompleted = persistentTasksComplete && dynamicTaskComplete;
return {
tasks,
persistentTasksComplete,
dynamicTaskComplete,
allCompleted,
isLoading,
error: error as Error | null,
refetch,
};
}
/**
* Get the current interaction count for evolve from companion task cache.
*/
export function getEvolveInteractionCount(companion: BlobbiCompanion | null): number {
if (!companion) return 0;
const interactionTask = companion.tasks.find(t => t.name === 'interactions');
return interactionTask?.value ?? 0;
}
-364
View File
@@ -1,364 +0,0 @@
// src/blobbi/actions/hooks/useHatchTasks.ts
/**
* Hook to compute hatch task progress from Nostr events.
*
* CRITICAL ARCHITECTURE:
* - PERSISTENT TASKS: Based on Nostr events, can be cached in tags
*
* Tags are only cache for persistent tasks. Source of truth = Nostr events.
* All persistent tasks are computed dynamically from events with created_at >= state_started_at.
*
* Note: Egg stats no longer decay, so there are no dynamic tasks for hatching.
*/
import { useQuery } from '@tanstack/react-query';
import { useNostr } from '@nostrify/react';
import type { NostrEvent, NostrFilter } from '@nostrify/nostrify';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
// ─── Constants ────────────────────────────────────────────────────────────────
/** Kind for theme definition events */
export const KIND_THEME_DEFINITION = 36767;
/** Kind for color moment events (espy.you) */
export const KIND_COLOR_MOMENT = 3367;
/** Kind for profile metadata */
export const KIND_PROFILE_METADATA = 0;
/** Kind for short text notes */
export const KIND_SHORT_TEXT_NOTE = 1;
/** Required interactions to complete the hatch interactions task */
export const HATCH_REQUIRED_INTERACTIONS = 7;
/** Required hashtags for the Blobbi post (excludes Blobbi name, which is dynamic) */
export const BLOBBI_POST_REQUIRED_HASHTAGS = ['blobbi'];
/** Prefix text for Blobbi hatch post (the Blobbi name is appended after this) */
export const BLOBBI_POST_PREFIX = 'Posting to hatch';
// Legacy export for backwards compatibility
export const REQUIRED_INTERACTIONS = HATCH_REQUIRED_INTERACTIONS;
/**
* Sanitize a name into a valid hashtag format.
* Must match the implementation in BlobbiPostModal.tsx.
*/
export function sanitizeToHashtag(name: string): string {
return name
.toLowerCase()
// Remove emojis and special characters, keep letters, numbers, underscores
.replace(/[^\p{L}\p{N}_]/gu, '')
// Ensure it starts with a letter (prepend 'blobbi' if it starts with number)
.replace(/^(\d)/, 'blobbi$1')
// Limit length
.slice(0, 30)
// Fallback if empty
|| 'myblobbi';
}
// ─── Types ────────────────────────────────────────────────────────────────────
/**
* Task type classification.
* - persistent: Based on Nostr events, can be cached in tags
* - dynamic: Based on current stats, NEVER stored in tags
*/
export type TaskType = 'persistent' | 'dynamic';
/**
* Individual task definition.
*/
export interface HatchTask {
id: string;
name: string;
description: string;
/** Current progress value */
current: number;
/** Required value for completion */
required: number;
/** Whether the task is complete */
completed: boolean;
/** Task type - persistent (event-based) or dynamic (stat-based) */
type: TaskType;
/** Action to perform (if applicable) */
action?: 'navigate' | 'open_modal' | 'external_link';
/** Target for the action */
actionTarget?: string;
/** Button label */
actionLabel?: string;
}
/**
* Result of computing hatch tasks.
*/
export interface HatchTasksResult {
tasks: HatchTask[];
/** All persistent tasks are complete */
persistentTasksComplete: boolean;
/** Dynamic stat task is complete */
dynamicTaskComplete: boolean;
/** All tasks (persistent + dynamic) are complete - required to hatch */
allCompleted: boolean;
isLoading: boolean;
error: Error | null;
/** Refetch task progress */
refetch: () => void;
}
// ─── Helper Functions ─────────────────────────────────────────────────────────
/**
* Build the required phrase for a hatch post.
* Format: "Posting to hatch {CapitalizedName} #blobbi"
*/
export function buildHatchPhrase(blobbiName: string): string {
const capitalized = blobbiName.charAt(0).toUpperCase() + blobbiName.slice(1);
return `${BLOBBI_POST_PREFIX} ${capitalized} #blobbi`;
}
/**
* Check if a post is a valid Blobbi hatch post.
* The post must contain the required phrase: "Posting to hatch {Name} #blobbi"
* The user may add extra text before or after it.
*
* @param event - The Nostr event to validate
* @param blobbiName - The Blobbi's name
*/
export function isValidHatchPost(event: NostrEvent, blobbiName: string): boolean {
const phrase = buildHatchPhrase(blobbiName);
// The phrase must appear somewhere in the content
if (!event.content.includes(phrase)) {
return false;
}
// Check for required hashtags in tags
const hashtags = event.tags
.filter(tag => tag[0] === 't')
.map(tag => tag[1]?.toLowerCase());
// All required hashtags must be present as t tags
const hasRequiredHashtags = BLOBBI_POST_REQUIRED_HASHTAGS.every(required =>
hashtags.includes(required.toLowerCase())
);
return hasRequiredHashtags;
}
// Legacy function name for backwards compatibility
export const isValidBlobbiPost = isValidHatchPost;
// ─── Main Hook ────────────────────────────────────────────────────────────────
/**
* Hook to compute hatch task progress from Nostr events and current stats.
*
* PERSISTENT TASKS (event-based, can be cached):
* 1. Create Theme (kind 36767) - ≥1 event after start
* 2. Color Moment (kind 3367) - ≥1 event after start
* 3. Create Post (kind 1) - ≥1 valid Blobbi hatch post after start
* 4. Interactions - 7 total (tracked via companion.tasks cache)
*
* Note: Egg stats no longer decay, so the "maintain stats" dynamic task
* has been removed. The baby/adult evolve equivalent is still in useEvolveTasks.
*
* @param companion - The Blobbi companion (must be incubating)
* @param interactionCount - Current interaction count from companion tasks cache
*/
export function useHatchTasks(
companion: BlobbiCompanion | null,
interactionCount?: number
): HatchTasksResult {
const { user } = useCurrentUser();
const { nostr } = useNostr();
const pubkey = user?.pubkey;
const stateStartedAt = companion?.stateStartedAt;
const isIncubating = companion?.state === 'incubating';
// Query for all relevant events
const { data, isLoading, error, refetch } = useQuery({
queryKey: ['hatch-tasks', pubkey, stateStartedAt],
queryFn: async () => {
if (!pubkey || !stateStartedAt) {
return null;
}
// Build filters for events we need
const filters: NostrFilter[] = [
// Theme definitions after start
{
kinds: [KIND_THEME_DEFINITION],
authors: [pubkey],
since: stateStartedAt,
},
// Color moments after start
{
kinds: [KIND_COLOR_MOMENT],
authors: [pubkey],
since: stateStartedAt,
},
// Posts after start (will filter for valid Blobbi posts)
{
kinds: [KIND_SHORT_TEXT_NOTE],
authors: [pubkey],
since: stateStartedAt,
limit: 50, // Reasonable limit
},
// Profile metadata - need both before and after start
// Get latest before start
{
kinds: [KIND_PROFILE_METADATA],
authors: [pubkey],
until: stateStartedAt,
limit: 1,
},
// Get latest after start
{
kinds: [KIND_PROFILE_METADATA],
authors: [pubkey],
since: stateStartedAt,
limit: 1,
},
];
// Execute all queries
const events = await nostr.query(filters);
// Categorize events
const themeEvents = events.filter(e =>
e.kind === KIND_THEME_DEFINITION && e.created_at >= stateStartedAt
);
const colorMomentEvents = events.filter(e =>
e.kind === KIND_COLOR_MOMENT && e.created_at >= stateStartedAt
);
const postEvents = events.filter(e =>
e.kind === KIND_SHORT_TEXT_NOTE && e.created_at >= stateStartedAt
);
// Separate profile events into before and after
const profileEvents = events.filter(e => e.kind === KIND_PROFILE_METADATA);
const profileBefore = profileEvents
.filter(e => e.created_at < stateStartedAt)
.sort((a, b) => b.created_at - a.created_at)[0];
const profileAfter = profileEvents
.filter(e => e.created_at >= stateStartedAt)
.sort((a, b) => b.created_at - a.created_at)[0];
return {
themeEvents,
colorMomentEvents,
postEvents,
profileBefore,
profileAfter,
};
},
enabled: !!pubkey && !!stateStartedAt && isIncubating,
staleTime: 30_000, // 30 seconds
refetchInterval: 60_000, // Refetch every minute
});
// ─── Compute PERSISTENT Tasks ───
const tasks: HatchTask[] = [];
// 1. Create Theme (PERSISTENT)
const hasTheme = (data?.themeEvents?.length ?? 0) >= 1;
tasks.push({
id: 'create_theme',
name: 'Create Theme',
description: 'Create a custom theme for your profile',
current: hasTheme ? 1 : 0,
required: 1,
completed: hasTheme,
type: 'persistent',
action: 'navigate',
actionTarget: '/themes',
actionLabel: 'Create Theme',
});
// 2. Color Moment (PERSISTENT)
const hasColorMoment = (data?.colorMomentEvents?.length ?? 0) >= 1;
tasks.push({
id: 'color_moment',
name: 'Color Moment',
description: 'Share a color moment on espy',
current: hasColorMoment ? 1 : 0,
required: 1,
completed: hasColorMoment,
type: 'persistent',
action: 'external_link',
actionTarget: 'https://espy.you/',
actionLabel: 'Open espy',
});
// 3. Create Post (PERSISTENT)
const blobbiName = companion?.name ?? '';
const validPosts = data?.postEvents?.filter(e => isValidHatchPost(e, blobbiName)) ?? [];
const hasValidPost = validPosts.length >= 1;
tasks.push({
id: 'create_post',
name: 'Create Post',
description: 'Share a post about hatching your Blobbi',
current: hasValidPost ? 1 : 0,
required: 1,
completed: hasValidPost,
type: 'persistent',
action: 'open_modal',
actionTarget: 'blobbi_post',
actionLabel: 'Create Post',
});
// 5. Interactions (PERSISTENT)
const interactions = interactionCount ?? 0;
const interactionsCompleted = interactions >= HATCH_REQUIRED_INTERACTIONS;
tasks.push({
id: 'interactions',
name: 'Interact with Blobbi',
description: `Care for your Blobbi ${HATCH_REQUIRED_INTERACTIONS} times`,
current: Math.min(interactions, HATCH_REQUIRED_INTERACTIONS),
required: HATCH_REQUIRED_INTERACTIONS,
completed: interactionsCompleted,
type: 'persistent',
// No action - just interact with Blobbi
});
// ─── Compute Completion States ───
const persistentTasks = tasks.filter(t => t.type === 'persistent');
const dynamicTasks = tasks.filter(t => t.type === 'dynamic');
const persistentTasksComplete = persistentTasks.every(t => t.completed);
const dynamicTaskComplete = dynamicTasks.every(t => t.completed);
const allCompleted = persistentTasksComplete && dynamicTaskComplete;
return {
tasks,
persistentTasksComplete,
dynamicTaskComplete,
allCompleted,
isLoading,
error: error as Error | null,
refetch,
};
}
/**
* Get the current interaction count from companion task cache.
*/
export function getInteractionCount(companion: BlobbiCompanion | null): number {
if (!companion) return 0;
const interactionTask = companion.tasks.find(t => t.name === 'interactions');
return interactionTask?.value ?? 0;
}
/**
* Filter tasks to only persistent tasks (for tag sync).
* CRITICAL: Dynamic tasks must NEVER be synced to tags.
*/
export function filterPersistentTasks(tasks: HatchTask[]): HatchTask[] {
return tasks.filter(t => t.type === 'persistent');
}
@@ -1,159 +0,0 @@
/**
* useRerollMission - Hook for rerolling daily missions
*
* Handles:
* - Replacing a mission with a new one from the pool
* - Tracking reroll usage (max 3 per day)
* - Respecting stage-based mission filtering
* - Persisting state to localStorage
*/
import { useMutation } from '@tanstack/react-query';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { toast } from '@/hooks/useToast';
import {
type DailyMissionsState,
type DailyMission,
type BlobbiStage,
getTodayDateString,
needsDailyReset,
createDailyMissionsState,
rerollMission,
canRerollMission,
getRerollsRemaining,
} from '../lib/daily-missions';
// ─── Types ────────────────────────────────────────────────────────────────────
export interface RerollMissionRequest {
missionId: string;
availableStages?: BlobbiStage[];
}
export interface RerollMissionResult {
oldMissionId: string;
newMission: DailyMission;
rerollsRemaining: number;
}
// ─── Constants ────────────────────────────────────────────────────────────────
const STORAGE_KEY = 'blobbi:daily-missions';
// ─── Storage Utilities ────────────────────────────────────────────────────────
function readMissionsState(): DailyMissionsState | null {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (!stored) return null;
const state = JSON.parse(stored) as DailyMissionsState;
// Migration: ensure rerollsRemaining is set for old state
if (state.rerollsRemaining === undefined) {
state.rerollsRemaining = 3; // MAX_DAILY_REROLLS
}
return state;
} catch {
return null;
}
}
function writeMissionsState(state: DailyMissionsState): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} catch (error) {
console.warn('[useRerollMission] Failed to write state:', error);
}
}
// ─── Hook ─────────────────────────────────────────────────────────────────────
/**
* Hook to reroll a daily mission.
*
* Replaces the specified mission with a new one from the pool,
* respecting stage-based filtering and avoiding duplicates.
*/
export function useRerollMission() {
const { user } = useCurrentUser();
return useMutation({
mutationFn: async ({ missionId, availableStages }: RerollMissionRequest): Promise<RerollMissionResult> => {
if (!user?.pubkey) {
throw new Error('You must be logged in to reroll missions');
}
// Read current missions state from localStorage
let missionsState = readMissionsState();
// Ensure we have valid state for today
if (needsDailyReset(missionsState)) {
const previousCoins = missionsState?.totalCoinsEarned ?? 0;
missionsState = createDailyMissionsState(getTodayDateString(), user.pubkey, previousCoins, availableStages);
}
// Check if reroll is allowed
if (!canRerollMission(missionsState!, missionId)) {
const rerollsLeft = getRerollsRemaining(missionsState!);
if (rerollsLeft <= 0) {
throw new Error('No rerolls remaining today');
}
const mission = missionsState!.missions.find(m => m.id === missionId);
if (mission?.completed || mission?.claimed) {
throw new Error('Cannot reroll completed or claimed missions');
}
throw new Error('Cannot reroll this mission');
}
// Perform the reroll
const result = rerollMission(missionsState!, missionId, availableStages);
if (!result) {
throw new Error('No replacement missions available. All alternative missions may already be in your daily list.');
}
// Persist the updated state
writeMissionsState(result.state);
// Dispatch event for React components to re-render
window.dispatchEvent(new CustomEvent('daily-missions-updated', {
detail: {
missionId,
rerolled: true,
newMissionId: result.newMission.id,
}
}));
return {
oldMissionId: missionId,
newMission: result.newMission,
rerollsRemaining: getRerollsRemaining(result.state),
};
},
onSuccess: ({ newMission, rerollsRemaining }) => {
const rerollText = rerollsRemaining === 1
? '1 reroll left'
: rerollsRemaining === 0
? 'No rerolls left'
: `${rerollsRemaining} rerolls left`;
toast({
title: 'Mission Replaced',
description: `New mission: ${newMission.title}. ${rerollText}.`,
});
},
onError: (error: Error) => {
toast({
title: 'Failed to Reroll',
description: error.message,
variant: 'destructive',
});
},
});
}
-191
View File
@@ -1,191 +0,0 @@
// src/blobbi/actions/index.ts
// Components
export { BlobbiActionsModal } from './components/BlobbiActionsModal';
export { BlobbiActionInventoryModal } from './components/BlobbiActionInventoryModal';
export { PlayMusicModal } from './components/PlayMusicModal';
export { SingModal } from './components/SingModal';
export { InlineMusicPlayer } from './components/InlineMusicPlayer';
export { InlineSingCard } from './components/InlineSingCard';
export { HatchTasksPanel } from './components/HatchTasksPanel';
export { TasksPanel } from './components/TasksPanel';
export { BlobbiPostModal } from './components/BlobbiPostModal';
export { StartIncubationDialog } from './components/StartIncubationDialog';
export { StartEvolutionDialog } from './components/StartEvolutionDialog';
export { BlobbiMissionsModal } from './components/BlobbiMissionsModal';
// Hooks
export { useBlobbiUseInventoryItem } from './hooks/useBlobbiUseInventoryItem';
export type { UseItemRequest, UseItemResult, UseBlobbiUseInventoryItemParams } from './hooks/useBlobbiUseInventoryItem';
export { useBlobbiHatch, useBlobbiEvolve } from './hooks/useBlobbiStageTransition';
export type {
UseBlobbiStageTransitionParams,
StageTransitionResult,
CanonicalActionResult,
} from './hooks/useBlobbiStageTransition';
export {
useStartIncubation,
useStopIncubation,
useStartEvolution,
useStopEvolution,
useSyncTaskCompletions,
} from './hooks/useBlobbiIncubation';
export type {
StartIncubationMode,
StartIncubationRequest,
UseStartIncubationParams,
StartIncubationResult,
UseStopIncubationParams,
StopIncubationResult,
UseStartEvolutionParams,
StartEvolutionResult,
UseStopEvolutionParams,
StopEvolutionResult,
UseSyncTaskCompletionsParams,
TaskCompletionToSync,
} from './hooks/useBlobbiIncubation';
export { useActiveTaskProcess, filterPersistentTasks as filterPersistentTasksFromProcess, filterDynamicTasks } from './hooks/useActiveTaskProcess';
export type { TaskProcessType, TaskProcessConfig, ActiveTaskProcessResult } from './hooks/useActiveTaskProcess';
export {
useHatchTasks,
getInteractionCount,
filterPersistentTasks,
sanitizeToHashtag,
isValidHatchPost,
isValidBlobbiPost, // Legacy export
buildHatchPhrase,
KIND_THEME_DEFINITION,
KIND_COLOR_MOMENT,
HATCH_REQUIRED_INTERACTIONS,
REQUIRED_INTERACTIONS, // Legacy export
BLOBBI_POST_PREFIX,
BLOBBI_POST_REQUIRED_HASHTAGS,
} from './hooks/useHatchTasks';
export type { HatchTask, HatchTasksResult, TaskType } from './hooks/useHatchTasks';
export {
useEvolveTasks,
getEvolveInteractionCount,
isValidEvolvePost,
KIND_PROFILE_TABS,
EVOLVE_REQUIRED_THEMES,
EVOLVE_REQUIRED_COLOR_MOMENTS,
EVOLVE_REQUIRED_POSTS,
EVOLVE_REQUIRED_INTERACTIONS,
EVOLVE_STAT_THRESHOLD,
BLOBBI_EVOLVE_POST_PREFIX,
} from './hooks/useEvolveTasks';
export type { EvolveTasksResult } from './hooks/useEvolveTasks';
export { useBlobbiDirectAction, DIRECT_ACTION_HAPPINESS_EFFECTS } from './hooks/useBlobbiDirectAction';
export type { DirectActionRequest, DirectActionResult, UseBlobbiDirectActionParams } from './hooks/useBlobbiDirectAction';
export { useAudioPlayback } from './hooks/useAudioPlayback';
export type { PlaybackState, PlaybackError, UseAudioPlaybackOptions, UseAudioPlaybackReturn } from './hooks/useAudioPlayback';
// Track catalog
export {
BLOBBI_TRACK_CATALOG,
getAllTracks,
getTrackById,
formatTrackDuration,
type BlobbiTrack,
} from './lib/blobbi-track-catalog';
// Activity state
export {
createMusicActivity,
createSingActivity,
createNoActivity,
type InlineActivityType,
type InlineActivityState,
type MusicActivityState,
type SingActivityState,
type NoActivityState,
type BlobbiReactionState,
type SelectedTrack,
} from './lib/blobbi-activity-state';
// Re-export stat bounds from canonical source
export { STAT_MIN, STAT_MAX } from '@/blobbi/core/lib/blobbi';
// Utilities
export {
// Types
type InventoryAction,
type DirectAction,
type BlobbiAction,
type ResolvedInventoryItem,
type EggStatPreview,
type ItemUsabilityResult,
type IncrementInteractionResult,
// Constants
ACTION_TO_ITEM_TYPE,
ACTION_METADATA,
DIRECT_ACTION_METADATA,
ALL_ACTION_METADATA,
GENERAL_ITEM_USABLE_STAGES,
EGG_ALLOWED_ACTIONS,
EGG_ALLOWED_INVENTORY_ACTIONS,
EGG_ALLOWED_DIRECT_ACTIONS,
EGG_VISIBLE_INVENTORY_ACTIONS,
EGG_VISIBLE_ACTIONS,
SHELL_REPAIR_KIT_ID,
// Functions
clampStat,
applyStat,
applyItemEffects,
filterInventoryByAction,
decrementStorageItem,
canUseAction,
canUseDirectAction,
isActionVisibleForStage,
canUseInventoryItems,
getStageRestrictionMessage,
previewStatChanges,
previewMedicineForEgg,
previewCleanForEgg,
hasMedicineEffectForEgg,
hasHygieneEffectForEgg,
canUseItemForStage,
getActionForItem,
incrementInteractionTaskTags,
} from './lib/blobbi-action-utils';
// Daily Missions
export { useDailyMissions } from './hooks/useDailyMissions';
export type { UseDailyMissionsResult } from './hooks/useDailyMissions';
export { useClaimMissionReward } from './hooks/useClaimMissionReward';
export type { ClaimMissionRequest, ClaimMissionResult } from './hooks/useClaimMissionReward';
export {
trackDailyMissionProgress,
trackMultipleDailyMissionActions,
} from './lib/daily-mission-tracker';
export type {
DailyMission,
DailyMissionAction,
DailyMissionDefinition,
DailyMissionsState,
} from './lib/daily-missions';
// Streak tracking
export {
calculateStreakUpdate,
getStreakTagUpdates,
needsStreakUpdate,
getStreakStatus,
} from './lib/blobbi-streak';
export type {
StreakUpdateResult,
StreakTagUpdates,
} from './lib/blobbi-streak';
export { useBlobbiCareActivity } from './hooks/useBlobbiCareActivity';
export type {
UseBlobbiCareActivityParams,
CareActivityResult,
} from './hooks/useBlobbiCareActivity';
@@ -1,634 +0,0 @@
// src/blobbi/actions/lib/blobbi-action-utils.ts
import { STAT_MIN, STAT_MAX, type BlobbiCompanion, type BlobbiStats, type StorageItem } from '@/blobbi/core/lib/blobbi';
import type { ItemEffect, ShopItemCategory } from '@/blobbi/shop/types/shop.types';
import { getShopItemById } from '@/blobbi/shop/lib/blobbi-shop-items';
// ─── Action Types ─────────────────────────────────────────────────────────────
/**
* Actions that consume inventory items
*/
export type InventoryAction = 'feed' | 'play' | 'clean' | 'medicine';
/**
* Non-inventory actions that don't consume items
* These actions affect stats directly without using shop items.
*/
export type DirectAction = 'play_music' | 'sing';
/**
* All Blobbi actions (inventory + direct)
*/
export type BlobbiAction = InventoryAction | DirectAction;
/**
* Mapping from action type to allowed item categories
*/
export const ACTION_TO_ITEM_TYPE: Record<InventoryAction, ShopItemCategory> = {
feed: 'food',
play: 'toy',
clean: 'hygiene',
medicine: 'medicine',
};
/**
* Action metadata for UI display (inventory actions)
*/
export const ACTION_METADATA: Record<InventoryAction, { label: string; description: string; icon: string }> = {
feed: {
label: 'Feed',
description: 'Feed your Blobbi',
icon: '🍎',
},
play: {
label: 'Play',
description: 'Play with your Blobbi',
icon: '⚽',
},
clean: {
label: 'Clean',
description: 'Clean your Blobbi',
icon: '🧼',
},
medicine: {
label: 'Medicine',
description: 'Heal your Blobbi',
icon: '💊',
},
};
/**
* Action metadata for direct actions (non-inventory)
*/
export const DIRECT_ACTION_METADATA: Record<DirectAction, { label: string; description: string; icon: string }> = {
play_music: {
label: 'Play Music',
description: 'Play music for your Blobbi',
icon: '🎵',
},
sing: {
label: 'Sing',
description: 'Sing to your Blobbi',
icon: '🎤',
},
};
/**
* Combined action metadata for all action types
*/
export const ALL_ACTION_METADATA: Record<BlobbiAction, { label: string; description: string; icon: string }> = {
...ACTION_METADATA,
...DIRECT_ACTION_METADATA,
};
// ─── Stat Helpers ─────────────────────────────────────────────────────────────
// STAT_MIN and STAT_MAX are imported from @/lib/blobbi (single source of truth)
/**
* Clamp a stat value between STAT_MIN (1) and STAT_MAX (100).
* Safe for undefined values (returns STAT_MIN).
*
* The minimum of 1 (instead of 0) ensures:
* - Blobbi is never in an unrecoverable state
* - Visual feedback shows critical state without being "dead"
* - Recovery is always possible with any healing item
*/
export function clampStat(value: number | undefined): number {
if (value === undefined) return STAT_MIN;
return Math.max(STAT_MIN, Math.min(STAT_MAX, Math.round(value)));
}
/**
* Apply a delta to a stat, clamping the result to STAT_MIN-STAT_MAX.
*/
export function applyStat(current: number | undefined, delta: number): number {
const currentValue = current ?? STAT_MIN;
return clampStat(currentValue + delta);
}
/**
* Apply item effects to current stats.
* Returns a new partial stats object with all affected stats clamped.
* Only modifies stats that have corresponding effects.
*/
export function applyItemEffects(
currentStats: Partial<BlobbiStats>,
effects: ItemEffect
): Partial<BlobbiStats> {
const newStats: Partial<BlobbiStats> = { ...currentStats };
if (effects.hunger !== undefined) {
newStats.hunger = applyStat(currentStats.hunger, effects.hunger);
}
if (effects.happiness !== undefined) {
newStats.happiness = applyStat(currentStats.happiness, effects.happiness);
}
if (effects.energy !== undefined) {
newStats.energy = applyStat(currentStats.energy, effects.energy);
}
if (effects.hygiene !== undefined) {
newStats.hygiene = applyStat(currentStats.hygiene, effects.hygiene);
}
if (effects.health !== undefined) {
newStats.health = applyStat(currentStats.health, effects.health);
}
return newStats;
}
// ─── Egg-Specific Item Helpers ────────────────────────────────────────────────
/**
* The Shell Repair Kit is a special medicine item only usable by eggs.
*/
export const SHELL_REPAIR_KIT_ID = 'med_shell_repair';
/**
* Result of checking if an item can be used by a specific Blobbi stage.
*/
export interface ItemUsabilityResult {
canUse: boolean;
reason?: string;
}
/**
* Check if a specific item can be used by a companion at the given stage.
*
* This is the centralized item usability logic:
* - Shell Repair Kit: Only usable by eggs
* - Food items: Only usable by baby/adult (not eggs)
* - Toy items: Only usable by baby/adult (not eggs)
* - Medicine items (except Shell Repair Kit): Usable by all stages with health effect
* - Hygiene items: Usable by all stages
*
* @param itemId - The shop item ID
* @param stage - The companion's life stage
* @returns Object with canUse boolean and optional reason string
*/
export function canUseItemForStage(
itemId: string,
stage: 'egg' | 'baby' | 'adult'
): ItemUsabilityResult {
const shopItem = getShopItemById(itemId);
if (!shopItem) {
return { canUse: false, reason: 'Item not found' };
}
const isEgg = stage === 'egg';
// Shell Repair Kit special case: only for eggs
if (itemId === SHELL_REPAIR_KIT_ID) {
if (!isEgg) {
return { canUse: false, reason: 'Only usable for eggs' };
}
return { canUse: true };
}
// Food items: not usable by eggs
if (shopItem.type === 'food') {
if (isEgg) {
return { canUse: false, reason: 'Eggs cannot eat food' };
}
return { canUse: true };
}
// Toy items: not usable by eggs
if (shopItem.type === 'toy') {
if (isEgg) {
return { canUse: false, reason: 'Eggs cannot use toys' };
}
return { canUse: true };
}
// Medicine items (except Shell Repair Kit): check for health effect
if (shopItem.type === 'medicine') {
if (!hasMedicineEffectForEgg(shopItem.effect)) {
return { canUse: false, reason: 'This medicine has no effect' };
}
return { canUse: true };
}
// Hygiene items: all stages can use
if (shopItem.type === 'hygiene') {
if (!hasHygieneEffectForEgg(shopItem.effect) && !hasHappinessEffectForEgg(shopItem.effect)) {
return { canUse: false, reason: 'This item has no cleaning effect' };
}
return { canUse: true };
}
return { canUse: true };
}
/**
* Get the action type for a given item.
*/
export function getActionForItem(itemId: string): InventoryAction | null {
const shopItem = getShopItemById(itemId);
if (!shopItem) return null;
const typeToAction: Record<string, InventoryAction> = {
food: 'feed',
toy: 'play',
hygiene: 'clean',
medicine: 'medicine',
};
return typeToAction[shopItem.type] ?? null;
}
/**
* Check if a medicine item has any effect on an egg.
*
* Eggs use the standard 3-stat model:
* - health
* - hygiene
* - happiness
*
* Medicine with a health effect will directly affect the egg's health stat.
*/
export function hasMedicineEffectForEgg(effects: ItemEffect | undefined): boolean {
if (!effects) return false;
return effects.health !== undefined && effects.health !== 0;
}
/**
* Check if a hygiene item has any effect on an egg.
* Hygiene items with a hygiene effect will directly affect the egg's hygiene stat.
*/
export function hasHygieneEffectForEgg(effects: ItemEffect | undefined): boolean {
if (!effects) return false;
return effects.hygiene !== undefined && effects.hygiene !== 0;
}
/**
* Check if an item has a happiness effect for an egg.
* Some items (like bubble bath) give happiness bonus in addition to primary effects.
*/
export function hasHappinessEffectForEgg(effects: ItemEffect | undefined): boolean {
if (!effects) return false;
return effects.happiness !== undefined && effects.happiness !== 0;
}
// ─── Inventory Helpers ────────────────────────────────────────────────────────
/**
* Resolved inventory item with shop metadata
*/
export interface ResolvedInventoryItem {
itemId: string;
quantity: number;
name: string;
icon: string;
type: ShopItemCategory;
effect?: ItemEffect;
}
/**
* Options for filtering inventory by action
*/
export interface FilterInventoryOptions {
/** Companion stage - used to filter items by egg-compatible effects */
stage?: 'egg' | 'baby' | 'adult';
}
/**
* Filter inventory items by action type.
* Returns resolved items with shop metadata.
*
* Filtering rules:
* - Only items matching the action's item type are included
* - Shell Repair Kit only appears in medicine modal for eggs
* - For eggs: only items with egg-compatible effects are returned
* - medicine action: only items with health effect
* - clean action: only items with hygiene or happiness effect
*/
export function filterInventoryByAction(
storage: StorageItem[],
action: InventoryAction,
options: FilterInventoryOptions = {}
): ResolvedInventoryItem[] {
const allowedType = ACTION_TO_ITEM_TYPE[action];
const result: ResolvedInventoryItem[] = [];
const isEgg = options.stage === 'egg';
for (const storageItem of storage) {
const shopItem = getShopItemById(storageItem.itemId);
if (!shopItem) continue;
if (shopItem.type !== allowedType) continue;
if (storageItem.quantity <= 0) continue;
// Shell Repair Kit: only show for eggs in medicine modal
if (storageItem.itemId === SHELL_REPAIR_KIT_ID && !isEgg) {
continue;
}
// For eggs, filter items by egg-compatible effects
if (isEgg) {
if (action === 'medicine' && !hasMedicineEffectForEgg(shopItem.effect)) {
continue; // Skip medicine without health effect
}
if (action === 'clean' && !hasHygieneEffectForEgg(shopItem.effect) && !hasHappinessEffectForEgg(shopItem.effect)) {
continue; // Skip hygiene items without hygiene or happiness effect
}
}
result.push({
itemId: storageItem.itemId,
quantity: storageItem.quantity,
name: shopItem.name,
icon: shopItem.icon,
type: shopItem.type,
effect: shopItem.effect,
});
}
return result;
}
/**
* Decrement item quantity in storage array.
* If quantity becomes 0, removes the item entirely.
* Returns a new storage array (immutable).
*/
export function decrementStorageItem(
storage: StorageItem[],
itemId: string,
amount = 1
): StorageItem[] {
const result: StorageItem[] = [];
for (const item of storage) {
if (item.itemId !== itemId) {
result.push(item);
continue;
}
const newQuantity = item.quantity - amount;
if (newQuantity > 0) {
result.push({ ...item, quantity: newQuantity });
}
// If newQuantity <= 0, we don't add it (remove item)
}
return result;
}
// ─── Stage Restriction Helpers ────────────────────────────────────────────────
/**
* Stages that can use general inventory items (food, toys, hygiene)
*/
export const GENERAL_ITEM_USABLE_STAGES = ['baby', 'adult'] as const;
/**
* Inventory actions that are allowed for eggs.
* Eggs can use: medicine (health), clean (hygiene)
*/
export const EGG_ALLOWED_INVENTORY_ACTIONS: InventoryAction[] = ['medicine', 'clean'];
/**
* Direct actions that are allowed for eggs.
* All direct actions work on eggs.
*/
export const EGG_ALLOWED_DIRECT_ACTIONS: DirectAction[] = ['play_music', 'sing'];
/**
* Inventory actions visible in the egg UI.
* Note: feed, play, sleep are hidden in the UI for eggs but not hard-blocked.
*/
export const EGG_VISIBLE_INVENTORY_ACTIONS: InventoryAction[] = ['clean', 'medicine'];
/**
* All actions visible in the egg UI.
*/
export const EGG_VISIBLE_ACTIONS: BlobbiAction[] = ['clean', 'medicine', 'play_music', 'sing'];
/**
* @deprecated Use EGG_ALLOWED_INVENTORY_ACTIONS instead
*/
export const EGG_ALLOWED_ACTIONS = EGG_ALLOWED_INVENTORY_ACTIONS;
/**
* Check if a companion can use a specific inventory action.
*
* Note: This function no longer hard-blocks egg actions at the domain layer.
* UI visibility is handled separately by `isActionVisibleForStage()`.
* The domain layer allows all actions - UI chooses what to show.
*/
export function canUseAction(_companion: BlobbiCompanion, _action: InventoryAction): boolean {
// All stages can technically use all inventory actions at the domain layer.
// UI filtering determines what actions are shown to users.
return true;
}
/**
* Check if a companion can use a specific direct action.
* Direct actions (play_music, sing) are available for all stages.
*/
export function canUseDirectAction(_companion: BlobbiCompanion, _action: DirectAction): boolean {
// All stages can use direct actions
return true;
}
/**
* Check if an action should be visible in the UI for a given stage.
* This is for UI filtering only - some actions are hidden but not blocked.
*/
export function isActionVisibleForStage(stage: 'egg' | 'baby' | 'adult', action: BlobbiAction): boolean {
if (stage === 'egg') {
return EGG_VISIBLE_ACTIONS.includes(action);
}
return true; // baby and adult see all actions
}
/**
* Check if a companion can use general inventory items (feed, play, clean).
* Eggs cannot use food, toys, or hygiene items.
* @deprecated Use canUseAction(companion, action) for action-specific checks
*/
export function canUseInventoryItems(companion: BlobbiCompanion): boolean {
return GENERAL_ITEM_USABLE_STAGES.includes(companion.stage as typeof GENERAL_ITEM_USABLE_STAGES[number]);
}
/**
* Get a user-friendly message explaining why an action can't be used.
*/
export function getStageRestrictionMessage(companion: BlobbiCompanion, action?: InventoryAction): string | null {
if (companion.stage === 'egg') {
if (action && EGG_ALLOWED_INVENTORY_ACTIONS.includes(action)) {
return null; // Medicine and clean are allowed for eggs
}
return 'Eggs cannot use this item. Wait for your Blobbi to hatch!';
}
return null;
}
// ─── Stats Preview ────────────────────────────────────────────────────────────
/**
* Preview stats after applying an item's effects.
* Useful for showing the user what will happen before confirming.
*/
export function previewStatChanges(
currentStats: Partial<BlobbiStats>,
effects: ItemEffect | undefined
): Array<{ stat: keyof BlobbiStats; current: number; after: number; delta: number }> {
if (!effects) return [];
const changes: Array<{ stat: keyof BlobbiStats; current: number; after: number; delta: number }> = [];
const statKeys: (keyof BlobbiStats)[] = ['hunger', 'happiness', 'energy', 'hygiene', 'health'];
for (const stat of statKeys) {
const delta = effects[stat];
if (delta !== undefined && delta !== 0) {
const current = currentStats[stat] ?? 0;
const after = clampStat(current + delta);
changes.push({ stat, current, after, delta });
}
}
return changes;
}
/**
* Preview stat change for an egg.
* Eggs use the 3-stat model: health, hygiene, happiness.
*/
export type EggStatPreview = { stat: 'health' | 'hygiene' | 'happiness'; current: number; after: number; delta: number };
/**
* Preview medicine effects for an egg.
* Medicine directly affects the egg's health stat.
*/
export function previewMedicineForEgg(
currentHealth: number | undefined,
effects: ItemEffect | undefined
): EggStatPreview[] {
if (!effects || effects.health === undefined || effects.health === 0) {
return [];
}
const current = currentHealth ?? 100;
const delta = effects.health;
const after = clampStat(current + delta);
return [{ stat: 'health', current, after, delta }];
}
/**
* Preview clean (hygiene) effects for an egg.
* Hygiene items directly affect the egg's hygiene stat.
* May also include happiness bonus if the item has one.
*/
export function previewCleanForEgg(
currentStats: { hygiene?: number; happiness?: number },
effects: ItemEffect | undefined
): EggStatPreview[] {
if (!effects) return [];
const results: EggStatPreview[] = [];
// Hygiene effect
if (effects.hygiene !== undefined && effects.hygiene !== 0) {
const current = currentStats.hygiene ?? 100;
const delta = effects.hygiene;
const after = clampStat(current + delta);
results.push({ stat: 'hygiene', current, after, delta });
}
// Happiness bonus (some hygiene items like bubble bath give happiness)
if (effects.happiness !== undefined && effects.happiness !== 0) {
const current = currentStats.happiness ?? 100;
const delta = effects.happiness;
const after = clampStat(current + delta);
results.push({ stat: 'happiness', current, after, delta });
}
return results;
}
// ─── Interaction Task Helpers ─────────────────────────────────────────────────
/** Enable debug logging in development only */
const DEBUG_INTERACTION_TASK = import.meta.env.DEV;
/**
* Result of incrementing interaction task tags
*/
export interface IncrementInteractionResult {
/** Updated tags array */
updatedTags: string[][];
/** New interaction count after increment */
newCount: number;
/** Whether the task is now complete */
isCompleted: boolean;
/** Previous count before increment */
previousCount: number;
}
/**
* Increment the interaction task counter in the tags array.
*
* This is used by both useBlobbiDirectAction and useBlobbiUseInventoryItem
* to track progress on interaction tasks for both hatch and evolve.
*
* CRITICAL: This function is called during actual user actions (not retroactive sync).
* It always increments by 1 because each call represents a real interaction.
*
* Tag format:
* - Progress: ["task", "interactions:N"]
* - Completion: ["task_completed", "interactions"]
*
* Idempotency notes:
* - This is NOT idempotent by design - each call = one interaction
* - Duplicate task_completed tags are prevented by filtering before add
* - Multiple task:interactions tags are prevented by filtering before add
*
* @param currentTags - Current tags array from the Blobbi state
* @param requiredInteractions - Threshold for completion (7 for hatch, 21 for evolve)
* @returns Updated tags array with incremented interaction count
*/
export function incrementInteractionTaskTags(
currentTags: string[][],
requiredInteractions: number
): IncrementInteractionResult {
// Get current interaction count from task tags
const interactionTag = currentTags.find(tag =>
tag[0] === 'task' && tag[1]?.startsWith('interactions:')
);
const previousCount = interactionTag
? parseInt(interactionTag[1].split(':')[1] || '0', 10)
: 0;
const newCount = previousCount + 1;
// Check if already completed (task_completed tag exists)
const alreadyCompleted = currentTags.some(tag =>
tag[0] === 'task_completed' && tag[1] === 'interactions'
);
// Remove old interaction task tag (prevent duplicates) and add new one
let updatedTags = currentTags.filter(tag =>
!(tag[0] === 'task' && tag[1]?.startsWith('interactions:'))
);
updatedTags = [...updatedTags, ['task', `interactions:${newCount}`]];
// Mark as completed if reached required count AND not already marked
const isCompleted = newCount >= requiredInteractions;
if (isCompleted && !alreadyCompleted) {
// Only add if not already present (handled by filter, but double-check)
updatedTags = [...updatedTags, ['task_completed', 'interactions']];
}
if (DEBUG_INTERACTION_TASK) {
console.log('[InteractionTask] Increment:', {
previousCount,
newCount,
requiredInteractions,
isCompleted,
alreadyCompleted,
addedCompletionTag: isCompleted && !alreadyCompleted,
});
}
return { updatedTags, newCount, isCompleted, previousCount };
}
@@ -1,81 +0,0 @@
// src/blobbi/actions/lib/blobbi-activity-state.ts
import type { SelectedTrack } from '../components/PlayMusicModal';
/**
* Types of inline activities that can be displayed in BlobbiPage
*/
export type InlineActivityType = 'none' | 'music' | 'sing';
// Re-export for convenience
export type { SelectedTrack } from '../components/PlayMusicModal';
/**
* State for the music inline activity
*/
export interface MusicActivityState {
type: 'music';
selection: SelectedTrack;
isPublished: boolean;
}
/**
* State for the sing inline activity
*/
export interface SingActivityState {
type: 'sing';
}
/**
* No active inline activity
*/
export interface NoActivityState {
type: 'none';
}
/**
* Union type for all inline activity states
*/
export type InlineActivityState =
| NoActivityState
| MusicActivityState
| SingActivityState;
/**
* Blobbi reaction state - indicates how Blobbi should visually react
*/
export type BlobbiReactionState =
| 'idle' // No special reaction
| 'listening' // Music is playing, Blobbi is listening
| 'swaying' // Blobbi is swaying to music
| 'singing' // User is singing, Blobbi is engaged
| 'happy'; // General happy reaction
/**
* Helper to create a music activity state
*/
export function createMusicActivity(selection: SelectedTrack): MusicActivityState {
return {
type: 'music',
selection,
isPublished: false,
};
}
/**
* Helper to create a sing activity state
*/
export function createSingActivity(): SingActivityState {
return {
type: 'sing',
};
}
/**
* Helper to create no activity state
*/
export function createNoActivity(): NoActivityState {
return {
type: 'none',
};
}
@@ -1,121 +0,0 @@
// src/blobbi/actions/lib/blobbi-random-lyrics.ts
/**
* Random lyrics for the Sing action.
* These are fun, simple lyrics that users can sing to their Blobbi.
*/
export interface LyricsEntry {
id: string;
title: string;
lines: string[];
}
/**
* Collection of placeholder lyrics for singing to a Blobbi.
* Simple, fun, and appropriate for all ages.
*/
export const BLOBBI_LYRICS: LyricsEntry[] = [
{
id: 'lullaby-1',
title: 'Blobbi Lullaby',
lines: [
'Little Blobbi, close your eyes,',
'Dream of stars up in the skies.',
'Safe and warm, you drift away,',
"We'll play again another day.",
],
},
{
id: 'happy-song-1',
title: 'Happy Blobbi Song',
lines: [
'Blobbi, Blobbi, jump around!',
"You're the happiest friend I've found!",
'Dancing, playing, full of cheer,',
"I'm so glad that you are here!",
],
},
{
id: 'adventure-1',
title: 'Adventure Time',
lines: [
"Let's go on an adventure today,",
'Through the clouds and far away!',
'Mountains high and valleys deep,',
'Memories to always keep.',
],
},
{
id: 'breakfast-song',
title: 'Breakfast Song',
lines: [
'Wake up, wake up, sleepy head,',
"Time to get out of your bed!",
"Breakfast's waiting, fresh and yummy,",
'Food to fill your happy tummy!',
],
},
{
id: 'rainy-day',
title: 'Rainy Day',
lines: [
'Pitter patter on the roof,',
'Rainy days can be so nice.',
"We'll stay cozy, me and you,",
'Watching raindrops, one by two.',
],
},
{
id: 'sunshine-song',
title: 'Sunshine Song',
lines: [
'Good morning, sunshine, bright and warm,',
'A brand new day is being born!',
'Blue sky smiling down on me,',
'Happy as can be, so free!',
],
},
{
id: 'bedtime-1',
title: 'Bedtime Blues',
lines: [
'The moon is up, the stars are bright,',
'Time to say a soft goodnight.',
'Snuggle up and close your eyes,',
'Sweet dreams under starry skies.',
],
},
{
id: 'play-time',
title: 'Play Time',
lines: [
"Bounce and jump and run around,",
"Spin and twirl without a sound!",
"Playing games is so much fun,",
"Laughing underneath the sun!",
],
},
];
/**
* Get a random lyrics entry.
*/
export function getRandomLyrics(): LyricsEntry {
const index = Math.floor(Math.random() * BLOBBI_LYRICS.length);
return BLOBBI_LYRICS[index];
}
/**
* Get all available lyrics entries.
*/
export function getAllLyrics(): LyricsEntry[] {
return BLOBBI_LYRICS;
}
/**
* Format lyrics for display (joined with newlines).
*/
export function formatLyrics(lyrics: LyricsEntry): string {
return lyrics.lines.join('\n');
}
-202
View File
@@ -1,202 +0,0 @@
/**
* Blobbi Care Streak Management
*
* This module provides centralized logic for tracking care streaks on Blobbi companions.
* A streak represents consecutive days of care activity (opening Blobbi page, performing
* care actions, etc.).
*
* Streak Rules:
* - Starts at 1 on first activity
* - Increments when activity happens on the NEXT local calendar day
* - Same-day activity does not increment (at most once per day)
* - Missing 2+ days resets streak to 1
*
* Tags managed:
* - care_streak: The current streak count (positive integer)
* - care_streak_last_at: Unix timestamp (seconds) of last streak update
* - care_streak_last_day: Local calendar day string (YYYY-MM-DD) of last update
*/
import {
getLocalDayString,
getDaysDifference,
type BlobbiCompanion,
} from '@/blobbi/core/lib/blobbi';
// ─── Types ────────────────────────────────────────────────────────────────────
/**
* Result of calculating a streak update.
*/
export interface StreakUpdateResult {
/** Whether the streak was updated (incremented or reset) */
wasUpdated: boolean;
/** The new streak value */
newStreak: number;
/** The new timestamp for care_streak_last_at */
newLastAt: number;
/** The new day string for care_streak_last_day */
newLastDay: string;
/** Description of what happened (for debugging/logging) */
action: 'initialized' | 'incremented' | 'reset' | 'same_day';
}
/**
* Tag updates to apply to the Blobbi event.
* Only present if wasUpdated is true.
* Uses index signature for compatibility with updateBlobbiTags.
*/
export interface StreakTagUpdates {
care_streak: string;
care_streak_last_at: string;
care_streak_last_day: string;
[key: string]: string;
}
// ─── Core Logic ───────────────────────────────────────────────────────────────
/**
* Calculate what the streak should be updated to based on current state and activity.
*
* This is a pure function that calculates the new streak state without side effects.
* Use this to determine if/how the streak should be updated.
*
* @param currentStreak - Current streak value (0 or undefined means no streak yet)
* @param lastDay - The last day string (YYYY-MM-DD) when streak was updated, or undefined
* @param now - Current timestamp (defaults to now)
* @returns StreakUpdateResult describing the update
*/
export function calculateStreakUpdate(
currentStreak: number | undefined,
lastDay: string | undefined,
now: Date = new Date()
): StreakUpdateResult {
const nowTimestamp = Math.floor(now.getTime() / 1000);
const todayString = getLocalDayString(now);
// Case 1: No existing streak - initialize to 1
if (currentStreak === undefined || currentStreak === 0 || !lastDay) {
return {
wasUpdated: true,
newStreak: 1,
newLastAt: nowTimestamp,
newLastDay: todayString,
action: 'initialized',
};
}
// Case 2: Activity on the same day - no update needed
if (lastDay === todayString) {
return {
wasUpdated: false,
newStreak: currentStreak,
newLastAt: nowTimestamp,
newLastDay: todayString,
action: 'same_day',
};
}
// Calculate days since last activity
const daysMissed = getDaysDifference(lastDay, todayString);
// Case 3: Next day (1 day difference) - increment streak
if (daysMissed === 1) {
return {
wasUpdated: true,
newStreak: currentStreak + 1,
newLastAt: nowTimestamp,
newLastDay: todayString,
action: 'incremented',
};
}
// Case 4: Missed 2+ days - reset to 1
return {
wasUpdated: true,
newStreak: 1,
newLastAt: nowTimestamp,
newLastDay: todayString,
action: 'reset',
};
}
/**
* Get the tag updates to apply to a Blobbi event for a streak update.
* Returns undefined if no update is needed (same day activity).
*
* @param companion - The current Blobbi companion state
* @param now - Current timestamp (defaults to now)
* @returns Tag updates to apply, or undefined if no update needed
*/
export function getStreakTagUpdates(
companion: BlobbiCompanion,
now: Date = new Date()
): StreakTagUpdates | undefined {
const result = calculateStreakUpdate(
companion.careStreak,
companion.careStreakLastDay,
now
);
if (!result.wasUpdated) {
return undefined;
}
return {
care_streak: result.newStreak.toString(),
care_streak_last_at: result.newLastAt.toString(),
care_streak_last_day: result.newLastDay,
};
}
/**
* Check if a streak update is needed for the companion.
*
* @param companion - The current Blobbi companion state
* @param now - Current timestamp (defaults to now)
* @returns true if the streak should be updated
*/
export function needsStreakUpdate(
companion: BlobbiCompanion,
now: Date = new Date()
): boolean {
const result = calculateStreakUpdate(
companion.careStreak,
companion.careStreakLastDay,
now
);
return result.wasUpdated;
}
/**
* Get the current streak status for display purposes.
*
* @param companion - The current Blobbi companion state
* @returns Object with streak info for UI display
*/
export function getStreakStatus(companion: BlobbiCompanion): {
streak: number;
lastDay: string | undefined;
isActive: boolean;
daysSinceLastActivity: number | undefined;
} {
const streak = companion.careStreak ?? 0;
const lastDay = companion.careStreakLastDay;
const today = getLocalDayString();
let daysSinceLastActivity: number | undefined;
let isActive = false;
if (lastDay) {
daysSinceLastActivity = getDaysDifference(lastDay, today);
// Streak is "active" if we've had activity today or yesterday
isActive = daysSinceLastActivity <= 1;
}
return {
streak,
lastDay,
isActive,
daysSinceLastActivity,
};
}
@@ -1,118 +0,0 @@
// src/blobbi/actions/lib/blobbi-track-catalog.ts
/**
* Blobbi Track Catalog
*
* Music tracks for the Blobbi "Play Music" action.
* All tracks are hosted on remote Blossom servers and streamed on-demand.
*
* ## Adding New Tracks
*
* 1. Convert the audio file to M4A (AAC-LC):
* `ffmpeg -i input.m4a -c:a aac -b:a 64k -ar 48000 output.m4a`
* 2. Upload the M4A file to a Blossom server
* 3. Add a new entry to `BLOBBI_TRACK_CATALOG` below
* 4. Set `url` to the full Blossom URL
* 5. Get the duration: `ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 <file>`
*
* ## Supported Formats
*
* M4A (AAC-LC) is required for iOS/Safari compatibility and small file size.
*/
export interface BlobbiTrack {
/** Unique identifier for the track (used in state/events) */
id: string;
/** Display title shown in the UI */
title: string;
/** Artist or source attribution */
artist: string;
/** Full URL to the remote audio file (Blossom server) */
url: string;
/** Duration in seconds (for display, get via ffprobe) */
durationSeconds: number;
/** Optional cover art URL */
coverArt?: string;
/** Optional tags for categorization/filtering */
tags?: string[];
}
/**
* Blobbi track catalog.
*
* All tracks are royalty-free/Creative Commons licensed.
* Audio files hosted on remote Blossom servers.
*/
export const BLOBBI_TRACK_CATALOG: BlobbiTrack[] = [
{
id: 'nap_in_the_meadow',
title: 'Nap in the Meadow',
artist: 'Chilltape FM',
url: 'https://blossom.ditto.pub/6be1c95e879187f83af2a661ccac2bd96196f7bc334af44529ede6270b2811fc.m4a',
durationSeconds: 240, // 4:00
tags: ['relaxing', 'nature'],
},
{
id: 'happy_kids',
title: 'Happy Kids',
artist: 'Dmitrii Kolesnikov',
url: 'https://blossom.ditto.pub/94d49abd178aa8afb14737a55e0a7143f6b337f618d74858d011232bb2db845d.m4a',
durationSeconds: 129, // 2:09
tags: ['upbeat', 'fun'],
},
{
id: 'soft_piano',
title: 'Soft Piano',
artist: 'Dmitrii Kolesnikov',
url: 'https://blossom.ditto.pub/5367242d3dc555c77f5c637fd153df1166708a24c5a4c222bb4dcaeabf740743.m4a',
durationSeconds: 124, // 2:04
tags: ['calming', 'sleep'],
},
{
id: 'epic_sacred_light',
title: 'Epic Sacred Light',
artist: 'Ura Megis',
url: 'https://blossom.dreamith.to/c22953791d686605958165fd44a84cd7d9fd3d4423ebf786e47891ed3a82c6db.m4a',
durationSeconds: 223, // 3:43
tags: ['energetic', 'adventure'],
},
{
id: 'split_memories',
title: 'Split Memories',
artist: 'ido berg',
url: 'https://blossom.ditto.pub/57ba2e2122a732449880ae531d4bfac9a580bc19693c7dda735afbfa336b35fe.m4a',
durationSeconds: 153, // 2:33
tags: ['ambient', 'relaxing'],
},
{
id: 'minhas_mensagens',
title: 'Minhas Mensagens',
artist: 'PReis',
url: 'https://blossom.ditto.pub/0945064dc8f946f3392be23629b166e72090cafca7cca865a20b5395dd83ff46.m4a',
durationSeconds: 248, // 4:08
tags: ['ambient', 'relaxing'],
},
];
/**
* Get a track by ID from the catalog
*/
export function getTrackById(id: string): BlobbiTrack | undefined {
return BLOBBI_TRACK_CATALOG.find(track => track.id === id);
}
/**
* Get all tracks from the catalog
*/
export function getAllTracks(): BlobbiTrack[] {
return BLOBBI_TRACK_CATALOG;
}
/**
* Format duration in seconds to MM:SS string
*/
export function formatTrackDuration(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
-135
View File
@@ -1,135 +0,0 @@
import { describe, it, expect } from 'vitest';
import {
calculateActionXP,
calculateInventoryActionXP,
applyXPGain,
getXPGainSummary,
formatXPGain,
getXPGainMessage,
ACTION_XP,
INVENTORY_ACTION_XP,
DIRECT_ACTION_XP,
} from './blobbi-xp';
describe('calculateActionXP', () => {
it('returns the correct XP for each inventory action', () => {
expect(calculateActionXP('feed')).toBe(5);
expect(calculateActionXP('play')).toBe(8);
expect(calculateActionXP('clean')).toBe(6);
expect(calculateActionXP('medicine')).toBe(10);
});
it('returns the correct XP for each direct action', () => {
expect(calculateActionXP('play_music')).toBe(7);
expect(calculateActionXP('sing')).toBe(9);
});
it('returns 0 for an unknown action', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect(calculateActionXP('unknown' as any)).toBe(0);
});
});
describe('calculateInventoryActionXP', () => {
it('returns base XP for quantity 1', () => {
expect(calculateInventoryActionXP('feed', 1)).toBe(5);
expect(calculateInventoryActionXP('medicine', 1)).toBe(10);
});
it('multiplies XP by quantity', () => {
expect(calculateInventoryActionXP('feed', 3)).toBe(15);
expect(calculateInventoryActionXP('play', 5)).toBe(40);
});
it('defaults to quantity 1 when not specified', () => {
expect(calculateInventoryActionXP('clean')).toBe(6);
});
it('returns 0 for quantity less than 1', () => {
expect(calculateInventoryActionXP('feed', 0)).toBe(0);
expect(calculateInventoryActionXP('feed', -1)).toBe(0);
});
});
describe('applyXPGain', () => {
it('adds XP to a current value', () => {
expect(applyXPGain(100, 25)).toBe(125);
});
it('treats undefined current XP as 0', () => {
expect(applyXPGain(undefined, 10)).toBe(10);
});
it('never returns a negative value', () => {
expect(applyXPGain(5, -20)).toBe(0);
expect(applyXPGain(0, -1)).toBe(0);
});
it('handles zero XP gain', () => {
expect(applyXPGain(50, 0)).toBe(50);
});
});
describe('getXPGainSummary', () => {
it('returns the correct xpGained and quantity', () => {
const result = getXPGainSummary('feed', 3);
expect(result).toEqual({ xpGained: 15, quantity: 3 });
});
it('defaults quantity to 1', () => {
const result = getXPGainSummary('sing');
expect(result).toEqual({ xpGained: 9, quantity: 1 });
});
});
describe('formatXPGain', () => {
it('formats positive XP as "+N XP"', () => {
expect(formatXPGain(15)).toBe('+15 XP');
expect(formatXPGain(1)).toBe('+1 XP');
});
it('returns empty string for zero or negative XP', () => {
expect(formatXPGain(0)).toBe('');
expect(formatXPGain(-5)).toBe('');
});
});
describe('getXPGainMessage', () => {
it('formats a message with action and XP earned', () => {
expect(getXPGainMessage('feed', 5)).toBe('+5 XP earned!');
});
it('includes total when provided', () => {
expect(getXPGainMessage('feed', 5, 105)).toBe('+5 XP earned! Total: 105 XP');
});
it('returns empty string for zero or negative XP', () => {
expect(getXPGainMessage('feed', 0)).toBe('');
expect(getXPGainMessage('feed', -1)).toBe('');
});
});
describe('XP constants', () => {
it('ACTION_XP contains all inventory and direct actions', () => {
for (const action of Object.keys(INVENTORY_ACTION_XP)) {
expect(ACTION_XP).toHaveProperty(action);
expect(ACTION_XP[action as keyof typeof ACTION_XP]).toBe(
INVENTORY_ACTION_XP[action as keyof typeof INVENTORY_ACTION_XP],
);
}
for (const action of Object.keys(DIRECT_ACTION_XP)) {
expect(ACTION_XP).toHaveProperty(action);
expect(ACTION_XP[action as keyof typeof ACTION_XP]).toBe(
DIRECT_ACTION_XP[action as keyof typeof DIRECT_ACTION_XP],
);
}
});
it('all XP values are positive integers', () => {
for (const xp of Object.values(ACTION_XP)) {
expect(xp).toBeGreaterThan(0);
expect(Number.isInteger(xp)).toBe(true);
}
});
});
-138
View File
@@ -1,138 +0,0 @@
/**
* Blobbi XP (Experience Points) System
*
* This module defines XP values for all Blobbi care actions and provides
* utilities for calculating and applying XP gains.
*
* Design Philosophy:
* - Different actions award different XP to reflect their complexity/value
* - XP values are balanced to encourage variety in care activities
* - Direct actions (sing, play_music) give moderate XP as they're free
* - Inventory actions (feed, play, clean, medicine) give varied XP based on resource cost
* - XP accumulates across all life stages and never resets
*/
import type { BlobbiAction, InventoryAction, DirectAction } from './blobbi-action-utils';
// ─── XP Values by Action ──────────────────────────────────────────────────────
/**
* Base XP values for inventory actions (feed, play, clean, medicine).
* These actions consume items from the player's storage.
*/
export const INVENTORY_ACTION_XP: Record<InventoryAction, number> = {
feed: 5, // Feeding is common and essential - moderate XP
play: 8, // Playing toys provides good interaction - higher XP
clean: 6, // Hygiene maintenance is important - moderate-high XP
medicine: 10, // Medicine is costly and critical - highest inventory XP
};
/**
* Base XP values for direct actions (play_music, sing).
* These actions don't consume items - they're free activities.
*/
export const DIRECT_ACTION_XP: Record<DirectAction, number> = {
play_music: 7, // Playing music is engaging - good XP
sing: 9, // Singing requires more user effort - higher XP
};
/**
* Combined XP lookup for all action types.
* Use this for a unified XP calculation interface.
*/
export const ACTION_XP: Record<BlobbiAction, number> = {
...INVENTORY_ACTION_XP,
...DIRECT_ACTION_XP,
};
// ─── XP Calculation Utilities ─────────────────────────────────────────────────
/**
* Calculate XP gain for a single action.
*
* @param action - The action performed
* @returns XP points earned
*/
export function calculateActionXP(action: BlobbiAction): number {
return ACTION_XP[action] ?? 0;
}
/**
* Calculate total XP gain for using multiple items.
* Each item use counts as a separate action for XP purposes.
*
* @param action - The action performed
* @param quantity - Number of items used (defaults to 1)
* @returns Total XP points earned
*/
export function calculateInventoryActionXP(action: InventoryAction, quantity: number = 1): number {
if (quantity < 1) return 0;
const baseXP = INVENTORY_ACTION_XP[action] ?? 0;
return baseXP * quantity;
}
/**
* Apply XP gain to current experience value.
*
* @param currentXP - Current experience points (undefined = 0)
* @param xpGain - XP points to add
* @returns New total XP (never negative)
*/
export function applyXPGain(currentXP: number | undefined, xpGain: number): number {
const current = currentXP ?? 0;
const newXP = current + xpGain;
return Math.max(0, newXP);
}
/**
* Get XP gain summary for displaying to the user.
*
* @param action - The action performed
* @param quantity - Number of times the action was performed (for inventory actions)
* @returns Object with xpGained and total quantity
*/
export function getXPGainSummary(
action: BlobbiAction,
quantity: number = 1
): { xpGained: number; quantity: number } {
const baseXP = ACTION_XP[action] ?? 0;
const xpGained = baseXP * quantity;
return { xpGained, quantity };
}
// ─── XP Display Utilities ─────────────────────────────────────────────────────
/**
* Format XP gain for display in toasts/notifications.
*
* @param xpGained - Amount of XP gained
* @returns Formatted string like "+15 XP"
*/
export function formatXPGain(xpGained: number): string {
if (xpGained <= 0) return '';
return `+${xpGained} XP`;
}
/**
* Get a descriptive message about XP gain.
*
* @param action - The action that earned XP
* @param xpGained - Amount of XP gained
* @param newTotal - New total XP (optional, for "You now have X XP" message)
* @returns Formatted message for user feedback
*/
export function getXPGainMessage(
action: BlobbiAction,
xpGained: number,
newTotal?: number
): string {
if (xpGained <= 0) return '';
const xpText = formatXPGain(xpGained);
if (newTotal !== undefined) {
return `${xpText} earned! Total: ${newTotal} XP`;
}
return `${xpText} earned!`;
}
@@ -1,109 +0,0 @@
/**
* Daily Mission Tracker - Standalone progress tracking utility
*
* This module provides a simple way to track daily mission progress
* without requiring React hooks or context. It directly manipulates
* localStorage for immediate persistence.
*
* This approach allows action hooks (which may be called outside of
* the daily missions hook context) to record progress.
*/
import {
type DailyMissionsState,
type DailyMissionAction,
getTodayDateString,
needsDailyReset,
createDailyMissionsState,
updateMissionProgress,
} from './daily-missions';
// ─── Constants ────────────────────────────────────────────────────────────────
const STORAGE_KEY = 'blobbi:daily-missions';
// ─── Storage Utilities ────────────────────────────────────────────────────────
/**
* Read the current daily missions state from localStorage
*/
function readState(): DailyMissionsState | null {
try {
const stored = localStorage.getItem(STORAGE_KEY);
return stored ? JSON.parse(stored) : null;
} catch {
return null;
}
}
/**
* Write the daily missions state to localStorage
*/
function writeState(state: DailyMissionsState): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} catch (error) {
console.warn('[DailyMissionTracker] Failed to write state:', error);
}
}
/**
* Ensure we have a valid state for today, creating one if necessary
*/
function ensureCurrentState(pubkey?: string): DailyMissionsState {
const current = readState();
if (needsDailyReset(current)) {
const previousCoins = current?.totalCoinsEarned ?? 0;
const newState = createDailyMissionsState(getTodayDateString(), pubkey, previousCoins);
writeState(newState);
return newState;
}
return current!;
}
// ─── Public API ───────────────────────────────────────────────────────────────
/**
* Record progress for a daily mission action.
* This function can be called from anywhere (hooks, event handlers, etc.)
* and will immediately persist to localStorage.
*
* @param action - The action type that was performed
* @param count - Number of times the action was performed (default: 1)
* @param pubkey - Optional user pubkey for personalized mission selection
*/
export function trackDailyMissionProgress(
action: DailyMissionAction,
count: number = 1,
pubkey?: string
): void {
const current = ensureCurrentState(pubkey);
const updated = updateMissionProgress(current, action, count);
writeState(updated);
// Dispatch a custom event so React components can re-render if needed
window.dispatchEvent(new CustomEvent('daily-missions-updated', { detail: { action, count } }));
}
/**
* Convenience function to track multiple actions at once.
* Useful when an action should count toward multiple missions.
*
* @param actions - Array of actions to track
* @param pubkey - Optional user pubkey
*/
export function trackMultipleDailyMissionActions(
actions: DailyMissionAction[],
pubkey?: string
): void {
let current = ensureCurrentState(pubkey);
for (const action of actions) {
current = updateMissionProgress(current, action, 1);
}
writeState(current);
window.dispatchEvent(new CustomEvent('daily-missions-updated', { detail: { actions } }));
}
-708
View File
@@ -1,708 +0,0 @@
/**
* Daily Missions System for Blobbi
*
* This module defines the daily mission pool, selection logic, and types.
* Daily missions are separate from hatch/evolve missions and provide
* daily engagement loops with coin rewards.
*/
// ─── Types ────────────────────────────────────────────────────────────────────
/**
* Mission action types that can trigger progress
*/
export type DailyMissionAction =
| 'interact' // Any interaction (feed, clean, play, etc.)
| 'feed' // Feeding action specifically
| 'clean' // Cleaning action specifically
| 'sing' // Sing direct action
| 'play_music' // Play music direct action
| 'sleep' // Put Blobbi to sleep
| 'take_photo' // Take a photo of Blobbi
| 'medicine'; // Give medicine to Blobbi
/**
* Blobbi stage type for filtering missions
*/
export type BlobbiStage = 'egg' | 'baby' | 'adult';
/**
* Definition of a daily mission in the pool
*/
export interface DailyMissionDefinition {
/** Unique identifier for this mission type */
id: string;
/** Display title */
title: string;
/** Description of what to do */
description: string;
/** Action that triggers progress */
action: DailyMissionAction;
/** Number of times the action must be performed */
requiredCount: number;
/** Coin reward for completing this mission */
reward: number;
/** Selection weight (higher = more likely to be selected) */
weight: number;
/** Required stages to show this mission (if empty/undefined, requires baby or adult) */
requiredStages?: BlobbiStage[];
}
/**
* A daily mission instance with progress tracking
*/
export interface DailyMission extends DailyMissionDefinition {
/** Current progress (how many times the action has been performed today) */
currentCount: number;
/** Whether the mission has been completed */
completed: boolean;
/** Whether the reward has been claimed */
claimed: boolean;
}
/**
* Stored state for daily missions (persisted in localStorage)
*/
export interface DailyMissionsState {
/** The date string (YYYY-MM-DD) when these missions were generated */
date: string;
/** The selected missions for this day */
missions: DailyMission[];
/** Total coins earned from daily missions (lifetime) */
totalCoinsEarned: number;
/** Whether the bonus mission has been claimed today */
bonusClaimed?: boolean;
/** Number of rerolls remaining for today (resets daily, max 3) */
rerollsRemaining?: number;
}
// ─── Constants ────────────────────────────────────────────────────────────────
/** Maximum number of mission rerolls allowed per day */
export const MAX_DAILY_REROLLS = 3;
// ─── Mission Pool ─────────────────────────────────────────────────────────────
/**
* The pool of available daily missions.
* Weights determine selection frequency:
* - High weight (10): Common missions (interact, feed, clean)
* - Medium weight (6): Regular missions (sing, play music, sleep)
* - Low weight (2): Uncommon missions (change shape)
* - Rare weight (1): Rare missions (take photo)
*/
export const DAILY_MISSION_POOL: DailyMissionDefinition[] = [
// ═══════════════════════════════════════════════════════════════════════════
// BABY/ADULT ONLY MISSIONS
// These actions are NOT available for eggs
// ═══════════════════════════════════════════════════════════════════════════
// ─── Interact Missions (Baby/Adult only) ───────────────────────────────────
{
id: 'interact_3',
title: 'Quick Care',
description: 'Interact with your Blobbi 3 times',
action: 'interact',
requiredCount: 3,
reward: 30,
weight: 10,
requiredStages: ['baby', 'adult'],
},
{
id: 'interact_6',
title: 'Attentive Caretaker',
description: 'Interact with your Blobbi 6 times',
action: 'interact',
requiredCount: 6,
reward: 50,
weight: 8,
requiredStages: ['baby', 'adult'],
},
// ─── Feed Missions (Baby/Adult only) ───────────────────────────────────────
{
id: 'feed_1',
title: 'Snack Time',
description: 'Feed your Blobbi once',
action: 'feed',
requiredCount: 1,
reward: 25,
weight: 10,
requiredStages: ['baby', 'adult'],
},
{
id: 'feed_2',
title: 'Hungry Blobbi',
description: 'Feed your Blobbi 2 times',
action: 'feed',
requiredCount: 2,
reward: 45,
weight: 8,
requiredStages: ['baby', 'adult'],
},
{
id: 'feed_3',
title: 'Feast Day',
description: 'Feed your Blobbi 3 times',
action: 'feed',
requiredCount: 3,
reward: 60,
weight: 5,
requiredStages: ['baby', 'adult'],
},
// ─── Sleep Missions (Baby/Adult only) ──────────────────────────────────────
{
id: 'sleep_1',
title: 'Nap Time',
description: 'Put your Blobbi to sleep',
action: 'sleep',
requiredCount: 1,
reward: 30,
weight: 6,
requiredStages: ['baby', 'adult'],
},
// ─── Photo Missions (Baby/Adult only) ──────────────────────────────────────
{
id: 'take_photo_1',
title: 'Snapshot',
description: 'Take a polaroid photo of your Blobbi',
action: 'take_photo',
requiredCount: 1,
reward: 55,
weight: 4,
requiredStages: ['baby', 'adult'],
},
{
id: 'take_photo_2',
title: 'Photo Album',
description: 'Take 2 photos of your Blobbi',
action: 'take_photo',
requiredCount: 2,
reward: 70,
weight: 2,
requiredStages: ['baby', 'adult'],
},
// ═══════════════════════════════════════════════════════════════════════════
// EGG + BABY + ADULT MISSIONS
// These actions are available for ALL stages including eggs
// ═══════════════════════════════════════════════════════════════════════════
// ─── Clean Missions (All stages) ───────────────────────────────────────────
{
id: 'clean_1',
title: 'Quick Cleanup',
description: 'Clean your Blobbi once',
action: 'clean',
requiredCount: 1,
reward: 25,
weight: 10,
requiredStages: ['egg', 'baby', 'adult'],
},
{
id: 'clean_2',
title: 'Squeaky Clean',
description: 'Clean your Blobbi 2 times',
action: 'clean',
requiredCount: 2,
reward: 45,
weight: 6,
requiredStages: ['egg', 'baby', 'adult'],
},
// ─── Sing Missions (All stages) ────────────────────────────────────────────
{
id: 'sing_1',
title: 'Sing Along',
description: 'Sing a song to your Blobbi',
action: 'sing',
requiredCount: 1,
reward: 30,
weight: 6,
requiredStages: ['egg', 'baby', 'adult'],
},
{
id: 'sing_2',
title: 'Karaoke Session',
description: 'Sing 2 songs to your Blobbi',
action: 'sing',
requiredCount: 2,
reward: 50,
weight: 3,
requiredStages: ['egg', 'baby', 'adult'],
},
// ─── Play Music Missions (All stages) ──────────────────────────────────────
{
id: 'play_music_1',
title: 'DJ Time',
description: 'Play a song for your Blobbi',
action: 'play_music',
requiredCount: 1,
reward: 30,
weight: 6,
requiredStages: ['egg', 'baby', 'adult'],
},
{
id: 'play_music_2',
title: 'Music Marathon',
description: 'Play 2 songs for your Blobbi',
action: 'play_music',
requiredCount: 2,
reward: 50,
weight: 3,
requiredStages: ['egg', 'baby', 'adult'],
},
// ─── Medicine Missions (All stages) ────────────────────────────────────────
// Medicine rewards are higher since medicine costs coins to use
{
id: 'medicine_1',
title: 'Health Check',
description: 'Give medicine to your Blobbi',
action: 'medicine',
requiredCount: 1,
reward: 60,
weight: 5,
requiredStages: ['egg', 'baby', 'adult'],
},
{
id: 'medicine_2',
title: 'Doctor Visit',
description: 'Give medicine to your Blobbi 2 times',
action: 'medicine',
requiredCount: 2,
reward: 70,
weight: 3,
requiredStages: ['egg', 'baby', 'adult'],
},
];
// ─── Utility Functions ────────────────────────────────────────────────────────
/**
* Get the current date string in YYYY-MM-DD format (local timezone)
*/
export function getTodayDateString(): string {
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
}
/**
* Generate a seed number from a date string and optional user pubkey.
* Used for deterministic daily mission selection.
*/
function generateDailySeed(dateString: string, pubkey?: string): number {
const input = pubkey ? `${dateString}:${pubkey}` : dateString;
let hash = 0;
for (let i = 0; i < input.length; i++) {
const char = input.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash; // Convert to 32-bit integer
}
return Math.abs(hash);
}
/**
* Seeded random number generator (Mulberry32)
*/
function seededRandom(seed: number): () => number {
return function() {
let t = seed += 0x6D2B79F5;
t = Math.imul(t ^ t >>> 15, t | 1);
t ^= t + Math.imul(t ^ t >>> 7, t | 61);
return ((t ^ t >>> 14) >>> 0) / 4294967296;
};
}
/**
* Check if a mission is available for the given stages.
* Missions with no requiredStages default to requiring baby or adult.
*/
function isMissionAvailableForStages(
mission: DailyMissionDefinition,
availableStages: BlobbiStage[]
): boolean {
const requiredStages = mission.requiredStages ?? ['baby', 'adult'];
return requiredStages.some((stage) => availableStages.includes(stage));
}
/**
* Select N missions from the pool using weighted random selection.
* Uses a seeded random generator for deterministic daily selection.
*
* @param count - Number of missions to select
* @param dateString - Date string for seeding (YYYY-MM-DD)
* @param pubkey - Optional user pubkey for seeding
* @param availableStages - Stages the user has available (filters eligible missions)
*/
export function selectDailyMissions(
count: number,
dateString: string,
pubkey?: string,
availableStages?: BlobbiStage[]
): DailyMissionDefinition[] {
const seed = generateDailySeed(dateString, pubkey);
const random = seededRandom(seed);
// Filter pool by available stages (default to baby/adult if not specified)
const stagesToCheck = availableStages ?? ['baby', 'adult'];
const eligibleMissions = DAILY_MISSION_POOL.filter((m) =>
isMissionAvailableForStages(m, stagesToCheck)
);
// If no missions are available for the user's stages, return empty
if (eligibleMissions.length === 0) {
return [];
}
// Create a copy of the eligible pool
const available = [...eligibleMissions];
const selected: DailyMissionDefinition[] = [];
while (selected.length < count && available.length > 0) {
// Calculate total weight of remaining missions
const totalWeight = available.reduce((sum, m) => sum + m.weight, 0);
// Pick a random value in [0, totalWeight)
let pick = random() * totalWeight;
// Find the mission that corresponds to this pick
let selectedIndex = 0;
for (let i = 0; i < available.length; i++) {
pick -= available[i].weight;
if (pick <= 0) {
selectedIndex = i;
break;
}
}
// Add to selected and remove from available
selected.push(available[selectedIndex]);
available.splice(selectedIndex, 1);
}
return selected;
}
/**
* Create a fresh DailyMission from a definition
*/
export function createMissionFromDefinition(def: DailyMissionDefinition): DailyMission {
return {
...def,
currentCount: 0,
completed: false,
claimed: false,
};
}
/**
* Create the initial daily missions state for a new day
*/
export function createDailyMissionsState(
dateString: string,
pubkey?: string,
previousTotalCoins: number = 0,
availableStages?: BlobbiStage[]
): DailyMissionsState {
const definitions = selectDailyMissions(3, dateString, pubkey, availableStages);
return {
date: dateString,
missions: definitions.map(createMissionFromDefinition),
totalCoinsEarned: previousTotalCoins,
rerollsRemaining: MAX_DAILY_REROLLS,
};
}
/**
* Check if the daily missions need to be reset (new day)
*/
export function needsDailyReset(state: DailyMissionsState | null): boolean {
if (!state) return true;
return state.date !== getTodayDateString();
}
/**
* Update mission progress for a given action
*/
export function updateMissionProgress(
state: DailyMissionsState,
action: DailyMissionAction,
incrementBy: number = 1
): DailyMissionsState {
const updatedMissions = state.missions.map((mission) => {
// Skip if not the matching action or already completed
if (mission.action !== action || mission.completed) {
return mission;
}
const newCount = Math.min(mission.currentCount + incrementBy, mission.requiredCount);
const nowCompleted = newCount >= mission.requiredCount;
return {
...mission,
currentCount: newCount,
completed: nowCompleted,
};
});
return {
...state,
missions: updatedMissions,
};
}
/**
* Claim reward for a completed mission
*/
export function claimMissionReward(
state: DailyMissionsState,
missionId: string
): { state: DailyMissionsState; coinsEarned: number } {
let coinsEarned = 0;
const updatedMissions = state.missions.map((mission) => {
if (mission.id !== missionId) return mission;
// Can only claim if completed and not yet claimed
if (!mission.completed || mission.claimed) return mission;
coinsEarned = mission.reward;
return {
...mission,
claimed: true,
};
});
return {
state: {
...state,
missions: updatedMissions,
totalCoinsEarned: state.totalCoinsEarned + coinsEarned,
},
coinsEarned,
};
}
/**
* Get the total potential reward for all daily missions
*/
export function getTotalPotentialReward(state: DailyMissionsState): number {
return state.missions.reduce((sum, m) => sum + m.reward, 0);
}
/**
* Get the total claimed reward for today
*/
export function getTodayClaimedReward(state: DailyMissionsState): number {
return state.missions
.filter((m) => m.claimed)
.reduce((sum, m) => sum + m.reward, 0);
}
/**
* Check if all daily missions are completed
*/
export function areAllMissionsCompleted(state: DailyMissionsState): boolean {
return state.missions.every((m) => m.completed);
}
/**
* Check if all daily missions are claimed
*/
export function areAllMissionsClaimed(state: DailyMissionsState): boolean {
return state.missions.every((m) => m.claimed);
}
// ─── Bonus Mission ────────────────────────────────────────────────────────────
/**
* The bonus mission that becomes available after completing all regular missions.
* This is a special mission that rewards extra coins for daily completion.
*/
export const BONUS_MISSION_DEFINITION: DailyMissionDefinition = {
id: 'bonus_daily_complete',
title: 'Daily Champion',
description: 'Complete all daily missions to claim this bonus reward',
action: 'interact', // Not actually used - bonus is auto-completed
requiredCount: 1,
reward: 80,
weight: 0, // Not part of random selection
};
/**
* Check if the bonus mission is available (all regular missions completed)
*/
export function isBonusMissionAvailable(state: DailyMissionsState): boolean {
// Bonus is available if there are regular missions and all are completed
return state.missions.length > 0 && areAllMissionsCompleted(state);
}
/**
* Check if the bonus mission has been claimed today
*/
export function isBonusMissionClaimed(state: DailyMissionsState): boolean {
return state.bonusClaimed ?? false;
}
/**
* Claim the bonus mission reward
*/
export function claimBonusMissionReward(
state: DailyMissionsState
): { state: DailyMissionsState; coinsEarned: number } {
// Can only claim if bonus is available and not yet claimed
if (!isBonusMissionAvailable(state) || isBonusMissionClaimed(state)) {
return { state, coinsEarned: 0 };
}
return {
state: {
...state,
bonusClaimed: true,
totalCoinsEarned: state.totalCoinsEarned + BONUS_MISSION_DEFINITION.reward,
},
coinsEarned: BONUS_MISSION_DEFINITION.reward,
};
}
// ─── Mission Reroll ───────────────────────────────────────────────────────────
/**
* Get the number of rerolls remaining for today.
* Returns MAX_DAILY_REROLLS if not set (for backward compatibility with old state).
*/
export function getRerollsRemaining(state: DailyMissionsState): number {
// If rerollsRemaining is not set (old state), default to max
if (state.rerollsRemaining === undefined || state.rerollsRemaining === null) {
return MAX_DAILY_REROLLS;
}
return state.rerollsRemaining;
}
/**
* Check if the user can reroll a mission
*/
export function canRerollMission(state: DailyMissionsState, missionId: string): boolean {
const rerollsRemaining = getRerollsRemaining(state);
if (rerollsRemaining <= 0) return false;
// Find the mission
const mission = state.missions.find((m) => m.id === missionId);
if (!mission) return false;
// Cannot reroll completed or claimed missions
if (mission.completed || mission.claimed) return false;
return true;
}
/**
* Select a replacement mission that:
* - Is not already in the current mission list
* - Is not the mission being replaced (avoid immediately giving back the same)
* - Respects the user's available stages
*
* Uses weighted random selection from eligible missions.
*/
export function selectReplacementMission(
currentMissions: DailyMission[],
missionToReplace: DailyMission,
availableStages?: BlobbiStage[]
): DailyMissionDefinition | null {
// Default to baby/adult if no stages provided (most common case)
const stagesToCheck = availableStages && availableStages.length > 0
? availableStages
: ['baby', 'adult'] as BlobbiStage[];
// Get IDs of missions that cannot be selected (current active missions)
const excludedIds = new Set<string>();
// Exclude all current missions EXCEPT the one being replaced
for (const m of currentMissions) {
if (m.id !== missionToReplace.id) {
excludedIds.add(m.id);
}
}
// Filter pool to eligible missions
const eligibleMissions = DAILY_MISSION_POOL.filter((m) => {
// Must not be an already-active mission (except the one being replaced)
if (excludedIds.has(m.id)) return false;
// Must not be the same mission being replaced
if (m.id === missionToReplace.id) return false;
// Must be available for user's stages
if (!isMissionAvailableForStages(m, stagesToCheck)) return false;
return true;
});
// If no eligible missions, return null
if (eligibleMissions.length === 0) {
return null;
}
// Use Math.random() for non-deterministic selection (rerolls should feel random)
const totalWeight = eligibleMissions.reduce((sum, m) => sum + m.weight, 0);
let pick = Math.random() * totalWeight;
for (const mission of eligibleMissions) {
pick -= mission.weight;
if (pick <= 0) {
return mission;
}
}
// Fallback to first eligible (shouldn't happen)
return eligibleMissions[0];
}
/**
* Reroll a mission, replacing it with a new one from the pool.
* Returns the updated state and the new mission, or null if reroll failed.
*/
export function rerollMission(
state: DailyMissionsState,
missionId: string,
availableStages?: BlobbiStage[]
): { state: DailyMissionsState; newMission: DailyMission } | null {
// Check if reroll is allowed
if (!canRerollMission(state, missionId)) {
return null;
}
// Find the mission index
const missionIndex = state.missions.findIndex((m) => m.id === missionId);
if (missionIndex === -1) {
return null;
}
const oldMission = state.missions[missionIndex];
// Select a replacement
const replacement = selectReplacementMission(state.missions, oldMission, availableStages);
if (!replacement) {
return null;
}
// Create the new mission instance
const newMission = createMissionFromDefinition(replacement);
// Update the missions array
const updatedMissions = [...state.missions];
updatedMissions[missionIndex] = newMission;
// Decrement rerolls remaining
const newRerollsRemaining = getRerollsRemaining(state) - 1;
return {
state: {
...state,
missions: updatedMissions,
rerollsRemaining: newRerollsRemaining,
},
newMission,
};
}
@@ -1,100 +0,0 @@
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<!-- Grupo das pétalas com rotação -->
<g transform="rotate(0 100 110)">
<animateTransform
attributeName="transform"
type="rotate"
from="0 100 110"
to="360 100 110"
dur="10s"
repeatCount="indefinite" />
<circle cx="100" cy="70" r="25" fill="url(#bloomiPetal1)" />
<circle cx="130" cy="90" r="25" fill="url(#bloomiPetal2)" />
<circle cx="130" cy="130" r="25" fill="url(#bloomiPetal3)" />
<circle cx="100" cy="150" r="25" fill="url(#bloomiPetal4)" />
<circle cx="70" cy="130" r="25" fill="url(#bloomiPetal5)" />
<circle cx="70" cy="90" r="25" fill="url(#bloomiPetal6)" />
</g>
<!-- Grupo das partículas giratórias -->
<g transform="rotate(0 100 110)">
<animateTransform
attributeName="transform"
type="rotate"
from="0 100 110"
to="-360 100 110"
dur="20s"
repeatCount="indefinite" />
<circle cx="60" cy="80" r="2" fill="url(#bloomiPollen)" opacity="0.8" />
<circle cx="140" cy="85" r="1.5" fill="url(#bloomiPollen)" opacity="0.6" />
<circle cx="55" cy="140" r="1" fill="url(#bloomiPollen)" opacity="0.7" />
<circle cx="145" cy="135" r="2" fill="url(#bloomiPollen)" opacity="0.5" />
<circle cx="75" cy="60" r="1.5" fill="url(#bloomiPollen)" opacity="0.9" />
</g>
<!-- Centro da flor -->
<circle cx="100" cy="110" r="35" fill="url(#bloomiCenter)" />
<circle cx="100" cy="110" r="28" fill="url(#bloomiCenterHighlight)" opacity="0.6" />
<!-- Eyes (white/base eye shapes) -->
<circle cx="88" cy="105" r="8" fill="white" />
<circle cx="112" cy="105" r="8" fill="white" />
<!-- Pupils (pupil + highlights) -->
<circle cx="88" cy="105" r="5" fill="#1f2937" />
<circle cx="112" cy="105" r="5" fill="#1f2937" />
<circle cx="90" cy="103" r="2" fill="white" />
<circle cx="114" cy="103" r="2" fill="white" />
<!-- Mouth -->
<path d="M 90 120 Q 100 128 110 120" stroke="#1f2937" stroke-width="3" fill="none" stroke-linecap="round" />
<!-- Bochechas -->
<circle cx="70" cy="115" r="8" fill="url(#bloomiBlush)" opacity="0.6" />
<circle cx="130" cy="115" r="8" fill="url(#bloomiBlush)" opacity="0.6" />
<!-- Gradientes -->
<defs>
<radialGradient id="bloomiPetal1" cx="0.3" cy="0.3">
<stop offset="0%" stop-color="#fef3c7" />
<stop offset="100%" stop-color="#fbbf24" />
</radialGradient>
<radialGradient id="bloomiPetal2" cx="0.3" cy="0.3">
<stop offset="0%" stop-color="#fed7d7" />
<stop offset="100%" stop-color="#f87171" />
</radialGradient>
<radialGradient id="bloomiPetal3" cx="0.3" cy="0.3">
<stop offset="0%" stop-color="#fce7f3" />
<stop offset="100%" stop-color="#f472b6" />
</radialGradient>
<radialGradient id="bloomiPetal4" cx="0.3" cy="0.3">
<stop offset="0%" stop-color="#e0e7ff" />
<stop offset="100%" stop-color="#8b5cf6" />
</radialGradient>
<radialGradient id="bloomiPetal5" cx="0.3" cy="0.3">
<stop offset="0%" stop-color="#dcfce7" />
<stop offset="100%" stop-color="#22c55e" />
</radialGradient>
<radialGradient id="bloomiPetal6" cx="0.3" cy="0.3">
<stop offset="0%" stop-color="#dbeafe" />
<stop offset="100%" stop-color="#3b82f6" />
</radialGradient>
<radialGradient id="bloomiCenter" cx="0.3" cy="0.2">
<stop offset="0%" stop-color="#fef3c7" />
<stop offset="50%" stop-color="#fbbf24" />
<stop offset="100%" stop-color="#f59e0b" />
</radialGradient>
<radialGradient id="bloomiCenterHighlight" cx="0.4" cy="0.3">
<stop offset="0%" stop-color="#ffffff" />
<stop offset="100%" stop-color="rgba(255,255,255,0.3)" />
</radialGradient>
<radialGradient id="bloomiBlush" cx="0.3" cy="0.3">
<stop offset="0%" stop-color="#fce7f3" />
<stop offset="100%" stop-color="#f472b6" />
</radialGradient>
<radialGradient id="bloomiPollen" cx="0.5" cy="0.5">
<stop offset="0%" stop-color="#fef3c7" />
<stop offset="100%" stop-color="#fbbf24" />
</radialGradient>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 4.0 KiB

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