Compare commits

...

260 Commits

Author SHA1 Message Date
Lemon 54706b23f4 Fix profile tabs not applying spell filters
Store spell tags (_spellTags) in the profile tab filter when saving from
SearchPage or SpellRunPage. ProfileSavedFeedContent detects _spellTags,
reconstructs the spell via buildUnsignedSpell(), and renders with
useStreamPosts({ spell }) — the same path SpellRunPage uses.

Legacy profile tabs without _spellTags continue to use useTabFeed().
2026-04-14 12:11:43 -07:00
Lemon d41bbe34d8 Store spell event ID in saved feeds and render via spell mode
When saving a feed tab, publish a kind:777 spell event (or reference the
existing one from SpellRunPage) and store its ID in the SavedFeed. On the
home feed, SavedFeedContent fetches the spell event by ID and passes it to
useStreamPosts({ spell }), which is the same path SpellRunPage uses.

This ensures all filter properties (media, language, platform, sort,
includeReplies, tag filters, relative timestamps, etc.) are faithfully
resolved at render time rather than being decomposed and reconstructed.

Legacy saved feeds without a spellId continue to use the filter-based path.
2026-04-14 11:59:46 -07:00
Lemon 291d32aecc Fix TypeScript and ESLint errors breaking CI pipeline
- Remove non-existent 'isAuthenticated' destructure from useShakespeare
- Handle null message.content in MessageBubble component
- Use undefined instead of null for optional content field in streaming response
- Remove unused imports in NoteCard.tsx (usePollVoteLabel, ImetaEntry, parseImetaMap, extractAudioUrls, extractVideoUrls, getParentEventHints)
2026-04-14 11:48:48 -07:00
Lemon 10a835074e Fix saved feed tabs not applying spell filter hints (media, language, platform, sort, includeReplies)
Both SearchPage and SpellRunPage were only persisting search/kinds/authors
when saving a feed tab, dropping client-hint fields. SavedFeedContent was
hardcoding includeReplies=true and mediaType='all', ignoring any hints.

Store hint fields as underscore-prefixed keys (_media, _language, _platform,
_sort, _includeReplies) in the saved TabFilter and read them back in
SavedFeedContent to pass through to useStreamPosts.
2026-04-14 11:43:59 -07:00
Lemon 80fcdcc821 Replace TowelRack icon with Bath (unavailable in installed lucide-react version) 2026-04-14 11:32:41 -07:00
Lemon 0d3f374de0 Revert Shakespeare API URL to ai.pocketvibe.app 2026-04-14 11:30:06 -07:00
Lemon b1ed8a7796 Fix search dropdown z-index being hidden behind filter popover
The ProfileSearchDropdown portals to document.body with z-[100] but the
Radix Popover renders at z-[260], causing search results to appear behind
the filter panel on the Discover page. Bump to z-[300].
2026-04-14 11:30:06 -07:00
Lemon 8451236208 Share buddy onboarding flow between AIChatPage and MobileBuddySheet
Extract BuddyOnboarding into a reusable component so users who
open the mobile buddy sheet before visiting the AI chat page still
get the Dork-guided buddy creation walkthrough.
2026-04-14 11:30:06 -07:00
Lemon 41b0772c98 Point Shakespeare API to Hetzner demo server (REMOVE BEFORE ALEX SEE THIS) 2026-04-14 11:30:06 -07:00
Lemon 82188757fa Reuse resolveSpell in get_feed tool instead of hand-rolling filter resolution 2026-04-14 11:30:06 -07:00
Lemon 2c87f1c84c Replace hand-rolled dropdown positioning with Floating UI and fix search input bugs
- Replace manual portal positioning (scroll/resize listeners, fixed coords) with
  @floating-ui/react for robust dropdown placement that handles CSS-transformed
  ancestors (Sheet slide animations, dnd-kit sortable transforms)
- Fix dropdown not following input when sidebar scrolls on small/medium screens
  by using PortalContainerProvider in MobileDrawer (matching the established
  codebase pattern for popovers inside Sheet/Dialog), with strategy:'absolute'
  when portaling into a container and strategy:'fixed' at document.body level
- Fix input height change on focus by matching --title-font-family on the input
  via new inputStyle prop, with md:text-[length:inherit] to reliably override
  the Input component's md:text-sm responsive class
- Add onDismiss callback to ProfileSearchDropdown so parent components can react
  to outside clicks and Escape without duplicate document-level listeners
- Remove inlineSearch prop (always true at every call site) — search items now
  render inline by default in SidebarNavList
2026-04-14 11:30:06 -07:00
Lemon fb7923d8b2 Split search/discover sidebar items and make search an inline input
Separate the overloaded 'search' sidebar item into two items:
- 'search': label 'Search', magnifying glass icon, renders as an
  inline expandable input on desktop and mobile drawer sidebars
- 'discover': new item, label 'Discover', compass icon, navigates
  to /discover

The search item looks like a normal sidebar button (icon + label) in
its collapsed state. Clicking it expands into a ProfileSearchDropdown
inline, preserving the same row layout. It participates in
drag-and-drop reordering and can be moved to the More menu.

The search dropdown renders via createPortal at document.body to
escape overflow containers. It auto-flips upward when near the bottom
of the viewport and caps max-height to available space. On mobile
bottom nav, the existing search sheet behavior is preserved.
2026-04-14 11:30:06 -07:00
Lemon b58191ef58 Skip protocol:nostr search term when protocols not explicitly set
When the caller doesn't explicitly pass protocols (e.g. Feeds/Packs
tabs that query Nostr-native kinds only), skip the protocol:nostr
NIP-50 search term entirely. This prevents the relay from routing the
query through its search index for kinds it may not index well (like
addressable events 39089/30000), which was causing fewer results to be
returned and permanently disabling infinite scroll pagination.
2026-04-14 11:30:06 -07:00
Lemon 2f726d4022 Deduplicate streaming tab rendering with shared StreamingFeed component
Extract a reusable StreamingFeed component to eliminate copy-paste
across the Posts, Feeds, and Follow Packs tabs on the Discover page.
Rename 'Packs' tab to 'Follow Packs' and align empty-state messaging
across all three tabs to match the Posts tab pattern.
2026-04-14 11:30:06 -07:00
Lemon 8b4a222351 Harden AI chat and buddy identity against parse failures
- Validate localStorage chat history with Zod; discard corrupted data on load
- Return tool error to AI when tool call arguments fail to parse (prevents silent hallucination loops)
- Validate decrypted BuddySecrets with Zod schema instead of unchecked cast
- Surface buddy profile publish failures via destructive toast instead of silent catch
2026-04-14 11:30:06 -07:00
Lemon 7a597155af Deduplicate and decompose: 7 maintainability refactors
- Extract shared NoteCardSkeleton component, replacing 5 duplicate definitions
  (Feed, SearchPage, BadgesPage, TrendsPage, ThemesPage)
- Extract SortableItemShell component, deduplicating drag-handle + action-button
  chrome across SidebarNavItem, NostrEventSidebarItem, ExternalContentSidebarItem
- Unify HashtagFeedContent and GeotagFeedContent into single TagFeedContent
- Decompose AdvancedSettings (554 lines) into SettingsSection + 4 section components
- Split useStreamPosts 206-line useEffect into fetchInitialBatch/startStreaming
- Split GetFeedTool execute (184 lines) into resolveFilter + formatEvents helpers
- Extract shared savedFeeds Zod transform, eliminating verbatim duplication in schemas.ts

Net result: -418 lines, 2 new shared components, zero behavior changes.
2026-04-14 11:30:06 -07:00
Lemon 93da505ab7 Sanitize all user/AI-provided URLs in AI tool outputs with sanitizeUrl()
- SetThemeTool: validate background_url is HTTPS before flowing into CSS
- CreateWebxdcTool: validate asset_urls before fetch (prevents SSRF via non-HTTPS)
- CreateWebxdcTool: sanitize image_url before publishing to event tag
- CreateEmojiPackTool: sanitize emoji image URLs, filter out invalid entries
2026-04-14 11:30:06 -07:00
Lemon 6a15cfe643 Add sidebar destination to save popovers and restore NoteCard click guards
- Add 'Sidebar' option (PanelLeft icon) to save popover on SpellRunPage and SearchPage
- SearchPage publishes a kind:777 spell before adding to sidebar (same as Share flow)
- SpellRunPage adds directly since nevent is already available
- Restore target.closest('button') and target.closest('a') guards in NoteCard
  click handlers to prevent double-navigation when clicking interactive elements
2026-04-14 11:30:05 -07:00
Lemon 07f701a1c3 Unify spell save/share into popover on SpellRunPage
Replace the separate share button and star toggle on SpellRunPage with
a single save popover matching the Discover page pattern. The popover
offers three destinations: Home feed tab, Profile tab, and Share
(copy link). This gives spells the same save UX as search results.

Extract SaveDestinationRow into a shared component used by both
SearchPage and SpellRunPage.
2026-04-14 11:30:05 -07:00
Lemon af0e028cde Revert saved feeds and profile tabs to TabFilter format
Restore the original TabFilter-based storage for saved feeds and profile
tabs. The spell-based storage (kind:777 events embedded in SavedFeed and
ProfileTab) was causing data loss: existing saved feeds and profile tabs
were silently dropped because the Zod schema rejected the old format.

Restored from main:
- profileTabsEvent.ts: TabFilter/TabVarDef types, var tag parsing,
  resolvePointer/resolveFilter for variable substitution
- useResolveTabFilter.ts: hook for resolving tab variables
- useProfileFeed.ts: useTabFeed for custom profile tab feeds
- useSavedFeeds.ts: 3-arg addSavedFeed(label, filter, vars) API
- FeedEditModal.tsx / ProfileTabEditModal.tsx: produce TabFilters
- schemas.ts: SavedFeedSchema with filter/vars fields

Updated consumers:
- Feed.tsx SavedFeedContent: uses useResolveTabFilter + useStreamPosts
- ProfilePage ProfileSavedFeedContent: uses useResolveTabFilter + useTabFeed
- ContentSettings SavedFeedsSection: TabFilter-based CRUD
- SearchPage: builds TabFilters for saving, spell tags only for sharing
- SpellRunPage: converts spell to TabFilter when saving to home feed
- GetFeedTool: resolves saved feed TabFilters directly

Spells (kind:777) remain fully functional for their intended use cases:
standalone shareable feeds, SpellRunPage, Discover tab, sidebar items,
and the AI create_spell tool.
2026-04-14 11:30:05 -07:00
Lemon cb2d6618e8 Fix ESLint errors: remove unused useEffect and PrecipitationType imports from ScreenEffectContext 2026-04-14 11:30:05 -07:00
Lemon f0af458e10 Refactor AI tools into modular Tool<T> architecture with Zod schemas and output truncation
Extract 11 AI tools from the monolithic switch statement in useAIChatTools
into individual modules under src/lib/tools/, following Shakespeare's
Tool<T> pattern:

- Tool.ts: shared Tool<T> interface and ToolContext for dependency injection
- toolToOpenAI.ts: Zod-to-OpenAI JSON Schema adapter (27 lines)
- truncateToolResult.ts: 50KB/2000-line output cap for large results
- helpers.ts: shared buddy key, signing, and uploader utilities
- 11 tool files (SetTheme, SearchUsers, SearchFollowPacks, CreateSpell,
  FetchPage, UploadFromUrl, CreateEmojiPack, PublishEvents, FetchEvent,
  GetFeed, CreateWebxdc, MakeItRain)

Each tool defines its own Zod input schema, description, and execute()
method. The hook builds a ToolContext from React state and dispatches
through the registry with Zod validation, replacing 1,000+ lines of
hand-written typeof checks and JSON Schema definitions.

The get_feed tool now has output truncation so large feed results don't
blow up the conversation token budget.
2026-04-14 11:30:05 -07:00
Lemon ae5d26a84c Remove MCP server integration and @modelcontextprotocol/sdk dependency
Delete MCPClient library, useMCPTools hook, MCP settings UI in
AdvancedSettings, MCPServer type/schema, and mcpServers from AppConfig.
Remove MCP tool routing from useAIChatTools and tool merging from
useAIChatSession.

The SDK pulled in 79 transitive packages for an optional feature that
was not being used. Built-in buddy tools are unaffected.
2026-04-14 11:30:05 -07:00
Lemon 85bdefdbea Remove console.error debug statements; add missing aiModel to AppConfigSchema
Remove 3 console.error calls that leaked internal details to the browser
console in production (useAIChatSession, useBuddyOnboarding, MCPClient).
The 4th in useBuddy was already removed in a prior commit. All error
paths either surface feedback via toast/error state or are fire-and-forget.

Add aiModel to AppConfigSchema — it was defined in AppConfig, set in
hardcodedConfig, and read by useAIChatSession and AdvancedSettings, but
missing from the Zod schema. Without it, Zod validation would strip the
field when loading config from localStorage, preventing the user's model
selection from persisting.
2026-04-14 11:30:05 -07:00
Lemon d9c51e9a4d Replace sequential batch loops with single queries in AI chat tools
search_users: was fetching kind 0 metadata for contacts in sequential
batches of 100 (up to 5 round trips for 500 follows). Now sends all
pubkeys in a single query and searches results client-side.

get_feed: same fix for the profile batch fetch — one query with all
unique pubkeys instead of sequential 100-pubkey batches.
2026-04-14 11:29:39 -07:00
Lemon b4ffde202c Route useBuddy mutations through useNostrPublish; add fetchFreshEvent + prev to useBuddy and usePublishStatus
useBuddy: All three mutations (createBuddy, updateSoul, resetBuddy) now
publish the user-signed kind 30078 event via useNostrPublish instead of
manually signing and calling nostr.event(). This gives them the canonical
client tag format (NIP-89) and published_at management (NIP-24) for free.

updateSoul: Now fetches fresh from relay via fetchFreshEvent before
mutating, fixing the stale-cache read-modify-write bug where
buddyQuery.data.name could be outdated from another device.

usePublishStatus: Now fetches the previous kind 30315 event and passes
prev to useNostrPublish so published_at is preserved on updates.
2026-04-14 11:29:39 -07:00
Lemon d6934c7c02 Extract unwrapReposts() and deduplicateFeedItems() into feedUtils.ts
Replace ~120 lines of duplicated repost unwrapping logic (3 copies across
useFeed follows/communities branches and useProfileFeed) with a single
shared unwrapReposts() function. Extract the deduplication logic (prefer
original posts over reposts) into deduplicateFeedItems().

Both are pure functions with a fetchMissing callback, so callers retain
full control over signal/relay routing with zero behavioral changes.
2026-04-14 11:28:54 -07:00
Lemon b8b14ab4f0 Deduplicate buddy-key/ephemeral-profile pattern, extract StatusEditor component with .catch() on mutateAsync calls 2026-04-14 11:28:54 -07:00
Lemon 2558743dae Deduplicate buildKindOptions, AVAILABLE_FONTS, and sidebar editing logic into shared modules 2026-04-14 11:28:54 -07:00
Lemon 645560077f Remove dead code: SYSTEM_PROMPT, isAuthenticated, DORK_ANIMATION/DorkThinking aliases, NsfwPolicy type, pointless variable aliases in LeftSidebar/MobileDrawer 2026-04-14 11:28:54 -07:00
Lemon aaa69912c0 Fix bugs: error swallowing in handleAPIError, stale cache in resetBuddy, Set allocation per event in matchesFilters; remove dead code (sendChatMessage, parseSelectedKinds, TabFilter import); restore MobileSearchSheet open prop 2026-04-14 11:28:54 -07:00
Lemon 42be4051e4 Add make_it_rain AI tool: app-wide rain/snow screen effects as easter egg 2026-04-14 11:28:54 -07:00
Lemon 30043b1b1f Add asset_urls param to create_webxdc: bundle remote binary files (ROMs, images, audio, WASM) into .xdc archives 2026-04-14 11:28:54 -07:00
Lemon 14dd050386 Add multi-file support to create_webxdc: accept a files map for multi-file .xdc archives 2026-04-14 11:28:54 -07:00
Lemon a0ac403618 Improve webxdc AI prompts: add gamepad keybindings, sandbox capabilities, and input guidance 2026-04-14 11:28:54 -07:00
Lemon 2cd0387322 Add ox tag, optional image_url param to create_webxdc for app icon thumbnails 2026-04-14 11:28:54 -07:00
Lemon a54b5a6193 Add create_webxdc tool: AI buddy can build and publish WebXDC apps from scratch 2026-04-14 11:28:54 -07:00
Lemon ddd4dd7660 Generalize upload_from_url for any file type; add WebXDC publishing to buddy system prompt 2026-04-14 11:28:54 -07:00
Lemon 4ff1738aa6 Fix buddy sheet dismiss: tap empty space between messages to close
Move stopPropagation from the messages wrapper div to individual message
bubbles, so tapping gaps between messages propagates to the outer onClose
handler.
2026-04-14 11:28:54 -07:00
Lemon 4dfa978286 Show buddy avatar in mobile bottom nav when profile picture exists
Fetches the buddy's kind 0 metadata via useAuthor. When the buddy has a
profile picture, renders a shaped Avatar (matching the profile tab pattern)
with the buddy's name as the label. Falls back to the default Bot icon
with 'Buddy' text when no picture is set.
2026-04-14 11:28:53 -07:00
Lemon 6caf9ea668 Add create_spell vs get_feed decision guide to buddy system prompt
create_spell is a write operation (creates persistent feed for user to browse),
get_feed is a read operation (fetches content for the AI to summarize).
Added decision table, key signal words, and default-to-get_feed guidance.
2026-04-14 11:28:53 -07:00
Lemon ece1253903 Replace search icon with bot icon; make dork button interactive
- Swap Search (magnifying glass) for Bot icon in the buddy input bar
- Dork button no longer closes the sheet
- When streaming: clicking stops generation (like Escape)
- When idle: clicking advances the animation by one frame
2026-04-14 11:28:53 -07:00
Lemon 3a91073dbd Remove duplicate thinking animation from buddy chat pane
The dork animation was showing both in the messages area (BuddyThinking)
and in the input bar. Keep only the input bar animation.
2026-04-14 11:28:53 -07:00
Lemon d37345fa75 Fix blockquote text and border color in AI chat bubbles
Blockquote text was using the prose default color which is nearly
invisible on dark theme backgrounds. Override with muted-foreground
for the text and the theme border color for the left accent line.
2026-04-14 11:28:53 -07:00
Lemon 9ab186fd70 Fix AI chat: wrap long strings (npub) and use primary color for inline code
- Add break-words to the prose container so long unbroken strings like
  npub identifiers wrap instead of overflowing the bubble
- Set prose-code:text-primary so inline code uses the theme accent color
  instead of the prose default (which was dark-on-dark in dark themes)
- Remove prose backtick decorations around inline code elements
2026-04-14 11:28:53 -07:00
Lemon 31ee06dd3b Fix buddy sheet close-on-background-tap using stopPropagation
The previous e.target===e.currentTarget approach didn't work because the
scroll container's padding and gap areas are still within the element's
box. Instead, put onClick={onClose} on the outer fixed wrapper and
stopPropagation on the two interactive children (message content wrapper
and input bar). Clicks on dead space (padding, gaps, empty scroll area)
now bubble up to the outer div and close the sheet.
2026-04-14 11:28:53 -07:00
Lemon 9ff991671b Keep bottom nav visible when sheets are open; close buddy on background tap
- Bottom nav no longer hides when search/buddy sheets open, so the user
  can see the active tab and switch between them
- Buddy sheet closes when tapping empty background area (outside messages
  and input bar) via target===currentTarget check
- Search sheet positioned above the nav bar (bottom-mobile-nav) instead
  of bottom-0 since the nav is now visible
- Removed duplicate backdrop from MobileSearchSheet (parent provides it)
2026-04-14 11:28:53 -07:00
Lemon 08991a3ee5 Separate search and buddy into independent bottom nav sheets
The search sheet and buddy sheet were previously coupled — buddy was a
toggle mode inside the search sheet. Now they are fully independent:

- Search (Discover) tab opens the search sheet with no AI toggle
- Buddy (ai-chat) tab opens the buddy chat overlay directly
- Tapping one closes the other; backdrop closes both
- Removed buddyMode/onToggleBuddy plumbing from MobileSearchSheet
- Removed onToggleBuddy from MobileBuddySheet (close button instead)
- Removed the <[o_o]> toggle from the search input bar
2026-04-14 11:28:53 -07:00
Lemon 1b484df7c6 Fix missing ActivityCard export by inlining the markup
ActivityCard was removed from NoteCard.tsx in e7a17d3f but the
PostDetailPage poll vote detail view still imported it, causing a
runtime crash when visiting profile pages. Inline the equivalent
article/flex layout directly.
2026-04-14 11:28:53 -07:00
Lemon c9ed33b71f Inject logged-in user identity into AI chat system prompt
The buddy AI now knows who it's talking to via a {{USER_IDENTITY}} block
in the system prompt containing the user's npub, hex pubkey, display name,
NIP-05, and bio. This lets the AI answer questions like 'who am I?' or
'what's my npub?' directly, and guides it to use fetch_event/get_feed for
deeper profile or post lookups.
2026-04-14 11:28:53 -07:00
Lemon 5c051f0407 Add missing @modelcontextprotocol/sdk dependency 2026-04-14 11:28:53 -07:00
Lemon 5263b1fd01 Remove legacy fallback from parseProfileTabs 2026-04-14 11:28:53 -07:00
Lemon 07c03b1a37 Hide 'Edit sidebar' button when user is not logged in 2026-04-14 11:28:53 -07:00
Lemon 00252d7ab1 Unify sidebar hover styling: hover:bg-secondary/40 on outer pill for all items 2026-04-14 11:28:53 -07:00
Lemon b317fdb566 Sidebar UX: bottom nav shows first 4 sidebar items, More/Less inline toggle replacing popover, Edit/Done buttons inside sidebar, +/x buttons for items above/below More separator, Add link input for URLs/npub/iso3166/isbn 2026-04-14 11:28:53 -07:00
Lemon 0be2428678 Add get_feed tool for AI chat to read and summarize feeds
Adds a new get_feed tool that lets the AI assistant read posts from any
feed source and summarize activity for the user. Supports:

- Named feeds: follows, global, ditto, and user's saved feeds
- Country feeds: NIP-73 geographic comments via iso3166 codes
- Ad-hoc queries: kinds, authors, search, hashtags via spell resolution

The system prompt is now dynamic, injecting saved feed labels so the
model knows which feeds are available. buildSystemPrompt accepts an
optional savedFeedLabels parameter for this.
2026-04-14 11:28:53 -07:00
Lemon 9941d67810 Use buddy key for Blossom uploads in upload_from_url tool
Create NSecSigner from buddy's secret key for Blossom upload auth.
Falls back to user's signer when no buddy exists. Calls
BlossomUploader directly instead of going through useUploadFile hook,
using the same server config and timeout pattern.
2026-04-14 11:28:53 -07:00
Lemon c5f659eb0e Add publish_events and fetch_event tools ported from Shakespeare
publish_events: Publishes Nostr events signed by the buddy's key
(falls back to ephemeral when no buddy). Supports any event kind
with content and tags. Returns published events for inline display.
The agent only publishes when explicitly instructed by the user.

fetch_event: Fetches Nostr events by NIP-19 identifier (npub, note,
nevent, naddr, nprofile). Uses the existing nostr pool connection.
Allows the agent to read content the user references in chat.

Both tools adapted from Shakespeare's implementations to fit Ditto's
hook-based tool pattern. System prompt updated with documentation
for both new tools.
2026-04-14 11:28:53 -07:00
Lemon e01e266c31 Sign spells and emoji packs with buddy key instead of ephemeral key
When a buddy is configured, create_spell and create_emoji_pack tools
now sign events with the buddy's persistent keypair. This gives the
buddy a consistent Nostr identity across all created content.

Falls back to ephemeral keys when no buddy exists (preserving current
behavior). Ephemeral key profiles ('Dork Spellcaster', 'Dork Emoji
Maker') are only published in the fallback path since the buddy
already has its own kind 0 profile.
2026-04-14 11:28:53 -07:00
Lemon 48e8ee1de9 Show raw {{NAME}}/{{SOUL}} placeholders in system prompt settings
Settings textarea was showing the resolved prompt (with 'Dork' and
empty soul) because SYSTEM_PROMPT called buildSystemPrompt() which
substituted placeholders immediately.

Fix: export DEFAULT_SYSTEM_PROMPT_TEMPLATE as the raw template string
with {{NAME}} and {{SOUL}} intact. Settings UI displays this so users
can see and edit the actual placeholders. Substitution only happens
at chat time in buildSystemPrompt().

Also removes 'When you use a tool, briefly describe what you created'
-- personality directives belong in the soul, not the base template.
2026-04-14 11:28:53 -07:00
Lemon 6069811372 Rewrite system prompt to use {{NAME}}/{{SOUL}} placeholders throughout
The system prompt is now purely functional — identity and personality
are entirely governed by {{NAME}} and {{SOUL}} placeholders that get
replaced at runtime with the buddy's configured values.

Changes:
- Remove hardcoded 'You are Dork' intro and 'Be concise and friendly'
- Template starts with 'You are {{NAME}}' followed by {{SOUL}}
- Tool documentation reformatted with ## headers for clarity
- buildSystemPrompt() always does placeholder substitution (no
  separate code path for custom vs default)
- Soul textarea style matches system prompt (monospace, text-xs)
2026-04-14 11:28:53 -07:00
Lemon 203fe43ca9 Populate system prompt with default text and remove unnecessary icons
- System prompt textarea now shows the built-in default prompt
  instead of empty with placeholder, so users can directly edit it
- When text matches the default, stores empty string (no override)
- Reset button restores the default prompt text
- Remove Bot icon from Identity label
- Remove Server icon from MCP Servers label
2026-04-14 11:28:53 -07:00
Lemon 64c1cf3642 Add editable system prompt in Advanced Settings
Add aiSystemPrompt to AppConfig (interface, Zod schema, defaults)
for users to override the base system prompt template.

buildSystemPrompt() accepts optional customPrompt param. When set,
it replaces the entire default template while still substituting
{{NAME}} and {{SOUL}} placeholders.

Advanced Settings UI:
- System Prompt textarea below Soul (monospace, shows built-in
  default as placeholder when empty)
- Documents {{NAME}} and {{SOUL}} placeholder usage
- Reset to default button when custom prompt is active
- Saves on blur to AppConfig (persisted in localStorage)
2026-04-14 11:28:53 -07:00
Lemon 20e560a68c Add buddy soul editing and reset in Advanced Settings
Buddy section in Advanced Settings now shows:
- Identity info (name, npub) when buddy exists
- Soul textarea: pre-populated from buddy event, saves on blur
  via updateSoul mutation, re-encrypts and republishes kind 30078
- 'No buddy configured' hint when no buddy exists

Danger Zone now includes:
- Reset Buddy button: wipes localStorage nsec and publishes empty
  kind 30078 event to overwrite on relays
2026-04-14 11:28:29 -07:00
Lemon ff8898eecf Remove unused MobileDorkSheet.tsx (replaced by MobileBuddySheet) 2026-04-14 11:28:29 -07:00
Lemon 75e9f465c5 Rename Dork -> Buddy across sidebar, settings, and mobile UI
User-facing rename:
- Sidebar label: 'Dork' -> 'Buddy'
- AdvancedSettings section header and descriptions
- MobileSearchSheet/MobileBottomNav: dorkMode -> buddyMode
- MobileDorkSheet -> MobileBuddySheet (new file, uses buddy identity)
- Input placeholder: 'Ask Dork...' -> 'Ask {buddyName}...'
- AppContext JSDoc references

Internal rename:
- DorkThinking -> BuddyThinking (deprecated alias kept)
- DORK_ANIMATION -> BUDDY_ANIMATION (deprecated alias kept)

'Dork' is preserved only where it refers to the onboarding wizard
character (useBuddyOnboarding), the default no-buddy fallback persona
(aiChatSystemPrompt), and ephemeral key profiles (Dork Spellcaster).
2026-04-14 11:28:29 -07:00
Lemon b04be19f75 Add buddy onboarding flow and wire buddy identity into chat
Split AIChatPage into three views:
- Logged-out: login prompt
- No buddy: onboarding flow with Dork as static Clippy-like guide
- Buddy exists: full AI chat with buddy's name/soul in system prompt

Onboarding (useBuddyOnboarding):
- Scripted conversation state machine: intro -> name -> soul -> confirm
- Reuses existing MessageBubble components, no API calls
- Dork walks user through naming and describing their buddy's soul
- On confirmation, calls createBuddy() to generate keypair + publish

Chat mode (BuddyChatView):
- Passes buddyName and buddySoul to useAIChatSession
- Header shows buddy name instead of 'Dork'
- Empty state greetings use buddy name
- Input placeholder personalized to buddy name
2026-04-14 11:28:05 -07:00
Lemon dc9cba6651 Add name to buddy encrypted content as canonical source of truth
Move buddy name into the NIP-44 encrypted content alongside nsec
and soul. This avoids needing an extra kind 0 fetch to display the
buddy name on the chat page. The agent's kind 0 profile may use
nicknames, but the identity event name is authoritative.

- BuddySecrets: add required 'name' field
- BuddyIdentity: add 'name' field
- createBuddy: include name in encrypted payload
- updateSoul: preserve existing name when re-encrypting
- decryptSecrets: validate all three required fields
- NIP.md: document name in encrypted content schema
2026-04-14 11:28:05 -07:00
Lemon 09b15bce21 Make system prompt dynamic with {{SOUL}} and {{NAME}} placeholders
Refactor aiChatSystemPrompt.ts: convert static SYSTEM_PROMPT const
into buildSystemPrompt(name?, soul?) function. When a buddy is
configured, the agent name and soul text are injected into the
template. Falls back to 'Dork' persona when no buddy exists.

Update useAIChatSession to accept optional buddyName/buddySoul
params and build the system prompt dynamically via useMemo.
2026-04-14 11:28:04 -07:00
Lemon f6fc10324e Add useBuddy hook and NIP-78 buddy identity event schema
Introduce the foundation for user-created AI agent (Buddy) identities:

- useBuddy hook: manages buddy lifecycle (create, update soul, reset)
  - Generates agent keypair, caches nsec in localStorage
  - Publishes kind 30078 identity event (NIP-78) signed by owner
  - Publishes kind 0 profile for the agent with bot:true
  - NIP-44 encrypts nsec + soul to owner for relay backup
  - Restores from relay if localStorage is cleared

- NIP.md: document kind 30078 buddy event schema
  - Public tags: d-tag, p-tag (agent pubkey), alt, client
  - Encrypted content: nsec + soul
  - Agent has separate kind 0 profile
2026-04-14 11:28:04 -07:00
Lemon 3a742c19f7 Use ephemeral key for create_emoji_pack and return event for inline display
Match create_spell pattern: sign with ephemeral keypair, publish a
profile for the key, and return nostrEvent so the emoji pack card
renders inline in the Dork chat.
2026-04-14 11:28:04 -07:00
Lemon f8c5316eed Add fetch_page, upload_from_url, and create_emoji_pack tools to Dork
Dork can now fetch web pages, download and upload images to Blossom,
and publish NIP-30 emoji packs. This enables the workflow:
'download these emojis from <url> and make an emoji set'

- fetch_page: fetches URL via CORS proxy, extracts image URLs from HTML
- upload_from_url: downloads images and uploads to Blossom servers
- create_emoji_pack: publishes kind 30030 emoji pack as the user
2026-04-14 11:28:04 -07:00
Lemon fcc6d79bb7 Add MCP client support for Dork AI tool discovery
Dork can now connect to Streamable HTTP MCP servers to discover and
use external tools at runtime. This bridges the gap between dev-time
MCP configs (.mcp.json) and the production AI chat system.

- MCPClient class using @modelcontextprotocol/sdk for full spec support
- useMCPTools hook with TanStack Query caching (5-min stale time)
- MCP tools merged into Dork's tool array and routed through MCPClient
- Settings UI in Advanced Settings to add/remove MCP servers
- MCPServer type, Zod schema, and AppConfig integration
2026-04-14 11:28:04 -07:00
Lemon 57f1c912e0 Fix profile tabs disappearing after spell migration for users with legacy tab events 2026-04-14 11:27:41 -07:00
Lemon 1774741678 Stream Dork AI responses in real-time instead of waiting for full completion 2026-04-14 11:27:41 -07:00
Lemon 20d0594c47 Use useFeed for Packs tab to get full events with images 2026-04-14 11:27:41 -07:00
Lemon dc66420cce Add Packs tab to Discover page between Feeds and Posts 2026-04-14 11:27:41 -07:00
Lemon d350f4f271 Update spell card to content-first layout matching follow pack design 2026-04-14 11:27:41 -07:00
Lemon 012c84ab56 Add infinite scroll to spell-driven feed tabs
useStreamPosts now exposes loadMore(), hasMore, and isLoadingMore for
cursor-based pagination. loadMore fetches the next page using
until=oldest-1, same pattern as useFeed for follows/global tabs.

Wired up in all three spell consumers:
- SavedFeedContent (home feed tabs)
- ProfileSavedFeedContent (profile custom tabs)
- SpellRunPage (shared spell viewer)

Previously these only showed the initial batch of ~40 events with no
way to scroll back in time.
2026-04-14 11:27:07 -07:00
Lemon cf1baf2865 Centralize spell tag parsing and fingerprinting in spellEngine
Deduplicate spell-to-UI-state helpers (spellToScope, spellToKinds,
spellToSearch, spellToAuthorPubkeys) that were independently defined
in FeedEditModal, ProfileTabEditModal, and SearchPage.

New shared exports in spellEngine.ts:
- spellAuthors(): raw authors tag values
- spellKinds(): kind tag values as strings
- spellSearch(): search tag value
- spellAuthorPubkeys(): explicit pubkeys, filtering out variables
- spellFingerprint(): stable semantic identity for dedup

SpellRunPage now uses spellFingerprint for saved-feed detection instead
of event ID comparison, consistent with SearchPage's dedup logic.
2026-04-14 11:27:07 -07:00
Lemon de3a4dfb4f Use '#' instead of 'Hashtags' for t-tag filter label 2026-04-14 11:27:07 -07:00
Lemon f6677d1e5d Simplify SpellContent: plain text name, unified outline badges, friendly tag filter labels 2026-04-14 11:27:07 -07:00
Lemon 3e01e7f53d Show friendly kind labels in spell badges (e.g. 'Posts (1)' instead of 'kind:1') 2026-04-14 11:27:07 -07:00
Lemon 3fa00abaa0 Clicking a spell card runs the spell; 'View post details' in More menu goes to post detail 2026-04-14 11:27:07 -07:00
Lemon 8c2d95f9db Rename Search to Discover with compass icon; make Feeds the default tab; route /search to /discover 2026-04-14 11:27:07 -07:00
Lemon d6dc3546eb Rename sidebar Search to Discover with compass icon 2026-04-14 11:27:07 -07:00
Lemon bcc72c9159 Move spells to Feeds tab on Search page; route kind:777 nevent through NIP19Page 2026-04-14 11:27:07 -07:00
Lemon a6b241aecc Replace sidebar star with home feed tab toggle on SpellRunPage 2026-04-14 11:27:07 -07:00
Lemon 15557ef523 Add spell sharing: 'Share' option in search save popover and share button on SpellRunPage 2026-04-14 11:27:07 -07:00
Lemon 576473f1c2 Let $contacts resolve to empty array instead of throwing when user has no follows 2026-04-14 11:27:07 -07:00
Lemon 05a4ce6d68 Extract buildUnsignedSpell helper to deduplicate inline spell construction 2026-04-14 11:27:07 -07:00
Lemon cb6614b42e Cleanup: remove dead code, fix stale comment, clarify Ditto relay routing 2026-04-14 11:27:07 -07:00
Lemon d395ca2079 Fix profile tab $me regression: use literal owner pubkey for 'Me' scope
The spell engine's $me resolves to the logged-in viewer, but profile tabs
need 'Me' scope to mean the profile owner. Fixed by emitting the owner's
literal hex pubkey instead of $me in ProfileTabEditModal.

Also noted the $contacts behavioral change: profile tab 'Contacts' scope
now shows the viewer's contacts (not the owner's). This is intentional --
the old behavior fetched the owner's kind 3 event, but the spell engine
resolves $contacts from the viewer's follow list.
2026-04-14 11:27:07 -07:00
Lemon 0fbf59b436 Cleanup: remove dead code from SavedFeedFiltersEditor and useProfileFeed
- SavedFeedFiltersEditor: removed the main SavedFeedFiltersEditor component
  (unused after modals were rewritten), parseSelectedKinds, kindsToKindFilter,
  getAuthorScope, and the local TabFilter type. Removed unused imports (Globe,
  UserSearch, Input, Separator, useUserLists, useFollowPacks). The file now
  only exports the reusable sub-components (buildKindOptions, KindPicker,
  MultiKindPicker, ScopeToggle, ListPackPicker, AuthorChip, AuthorFilterDropdown).
- useProfileFeed: removed useTabFeed (120 lines) and TabFeedPage type. Profile
  tabs now use useStreamPosts with spell events.
- Removed unused NostrFilter import from useProfileFeed.
2026-04-14 11:27:07 -07:00
Lemon 32539c0aee Migrate profile tabs to spell events; delete useResolveTabFilter
Profile tabs (kind 16769) now store spell events instead of TabFilter objects.
Each tab tag contains a JSON-encoded kind:777 spell with all filter parameters,
runtime variables ($me, $contacts), and client hints in its tags. The old var
tag system is eliminated entirely.

Key changes:
- profileTabsEvent.ts: ProfileTab now has { label, spell } instead of
  { label, filter }. ProfileTabsData no longer has vars. Deleted TabFilter,
  TabVarDef, resolvePointer, resolveFilter (~100 lines removed).
- ProfilePage.tsx: ProfileSavedFeedContent uses useStreamPosts with spell
  instead of useResolveTabFilter + useTabFeed. Core tab spells built via
  buildSpellTags. Removed profileVars, useResolveTabFilter, useTabFeed imports.
- ProfileTabEditModal.tsx: Produces spell events. Parses spell tags to seed form.
  Uses $me for 'me' scope, $contacts for 'contacts' scope.
- SearchPage.tsx: handleSaveProfileTab builds spell events instead of TabFilter.
- Deleted useResolveTabFilter.ts (93 lines) — no longer needed anywhere.
- NIP.md: Updated kind 16769 documentation for spell-based tab format.
2026-04-14 11:27:07 -07:00
Lemon a64575a13c Change SavedFeed to store spell events; update all producers and consumers
SavedFeed now stores a kind:777 spell event instead of TabFilter + TabVarDef.
Old saved feeds in encrypted settings silently drop (Zod schema rejects them).

Key changes:
- AppContext: SavedFeed type changed to { id, label, spell: NostrEvent, createdAt }
  Removed TabFilter and TabVarDef types (kept in profileTabsEvent.ts for profile tabs)
- schemas.ts: SavedFeedSchema validates a NostrEvent in the spell field
- useSavedFeeds: addSavedFeed takes (label, spell) instead of (label, filter, vars)
- Feed.tsx SavedFeedContent: passes feed.spell to useStreamPosts, removed
  useResolveTabFilter usage and all manual option extraction (~40 lines deleted)
- SearchPage handleSaveFeed: builds spell tags via buildSpellTags(), creates an
  unsigned spell event, stores it. Profile tab save path kept as compat shim.
- FeedEditModal: produces spell events from UI state instead of TabFilter objects.
  Parses spell tags to seed the form in edit mode.
- ContentSettings: adapted handlers to new (label, spell) signature
- SavedFeedFiltersEditor: TabFilter defined locally (no longer in AppContext)
2026-04-14 11:27:07 -07:00
Lemon 9d9425c0b9 Add spell support to useStreamPosts; migrate SpellRunPage to use it
useStreamPosts now accepts an optional 'spell' (kind:777 NostrEvent) in its
options. When provided, the spell is resolved internally — variables ($me,
$contacts), relative timestamps, client hints (media, language, sort, etc.)
— and drives the entire stream. All other option fields are ignored in spell
mode.

Key changes to useStreamPosts:
- Resolves spell via resolveSpell() using useCurrentUser + useFollowList
- Derives effectiveOptions/effectiveQuery from spell hints
- Routes initial query to Ditto relays when needsDittoRelay is true
- Merges spell extra filter fields (since, until, tag filters) into queries
- Existing callers unchanged (spell option is optional)

SpellRunPage now uses useStreamPosts with the spell option instead of manual
useQuery + nostr.query, gaining live streaming and the new-posts pill for free.
2026-04-14 11:27:07 -07:00
Lemon 133b9a7227 Extend spell engine with NIP-50 client-hint tags (media, language, platform, sort, include-replies)
- Add client-hint tags to spell format: media, language, platform, sort,
  include-replies. These instruct clients how to build NIP-50 search
  extensions and apply client-side filters.
- Add SpellClientHints interface and needsDittoRelay flag to ResolvedSpell
  for downstream consumers to know when Ditto relay routing is required.
- Move buildSpellTags() from useAIChatTools into spellEngine.ts as the
  single source of truth for spell tag construction.
- Update AI tool schema and system prompt with new parameters and examples.
- Update SpellContent badge display for new tags.
- Document kind 777 in NIP.md.
2026-04-14 11:27:07 -07:00
Lemon 9c5807f49d Fix saved feed 'follows' filter not working: omit #d filter for non-addressable kinds
The $follows variable resolver was querying kind 3 (contact list) with a
'#d' filter, but kind 3 is a replaceable event that has no d tag. This
caused the query to return zero results, so the follows list was never
resolved and the author filter was silently dropped.

Only include '#d' in the query filter for addressable events (30000-39999).
Also fix pre-existing lint error (unused useMemo import) and missing
aiModel field in TestApp.
2026-04-14 11:27:07 -07:00
Lemon e27d928e38 Fix follows filter not saved: add $follows placeholder to authors in TabFilter 2026-04-14 11:26:41 -07:00
Lemon 7dfad82a9b Persist NIP-50 filter extensions (media, language, sort, protocol) in saved feeds and fix sort bug 2026-04-14 11:26:41 -07:00
Lemon 472c8e943f Add opaque background to inline NoteCard in Dork chat 2026-04-14 11:26:41 -07:00
Lemon b615e9d395 Make Dork sheet background transparent to match search sheet overlay style 2026-04-14 11:26:41 -07:00
Lemon 9a5944fff8 Animate Dork toggle button during streaming instead of spinner 2026-04-14 11:26:41 -07:00
Lemon a8a9cbcaf1 Add opaque background to Dork messages area 2026-04-14 11:26:41 -07:00
Lemon d4e518bcbe Add slash commands: /new and /clear to reset chat history 2026-04-14 11:26:41 -07:00
Lemon b55c37f510 Raise bottom nav z-index to match Dork sheet 2026-04-14 11:26:41 -07:00
Lemon 94f034c1f0 Fix Dork sheet scroll: contain scroll events, add bottom padding to clear input bar 2026-04-14 11:26:41 -07:00
Lemon 1daa9a2715 Refine mobile Dork sheet: bidirectional toggle, keyboard-only send/stop, full-screen transparent layout 2026-04-14 11:26:41 -07:00
Lemon 95eb55133f Add Dork chat to mobile search sheet with toggle
- Extract MessageBubble, DorkThinking, ToolCallBadge to shared AIChat components
- Add MobileDorkSheet with chat messages, send/stop, and search toggle
- Add Dork toggle (<[o_o]>) to MobileSearchSheet input bar
- MobileBottomNav manages shared backdrop and both sheets via hidden prop
- Both sheets stay mounted to preserve state when toggling
2026-04-14 11:26:41 -07:00
Lemon 4d4381310c Match Dork settings section style to System/Wallet sections 2026-04-14 11:26:08 -07:00
Lemon 4d2ae05c4b Reduce empty state padding to avoid premature scrollbar 2026-04-14 11:26:08 -07:00
Lemon 4ff019e9cf Move AI model selector from Dork chat to Settings > Advanced 2026-04-14 11:26:08 -07:00
Lemon 7cb244e8a9 Tighten spacing in Dork empty state 2026-04-14 11:25:30 -07:00
Lemon 5152ead795 Sort and deduplicate spell results 2026-04-14 11:25:30 -07:00
Lemon 31c4dd3f78 Add suggestion buttons to empty Dork chat state 2026-04-14 11:25:29 -07:00
Lemon 7b214ea5b2 Hide empty assistant message bubble on tool-only turns 2026-04-14 11:25:29 -07:00
Lemon 47044752b9 Fix follow pack search: use broad fetch with multiple filters instead of relying on NIP-50 tag indexing 2026-04-14 11:25:29 -07:00
Lemon f35b4e7bd2 Add search_follow_packs tool to Dork AI for pack-based spell creation 2026-04-14 11:25:29 -07:00
Lemon 8bc0393b51 Remove 'Dork AI' heading from empty chat state 2026-04-14 11:25:29 -07:00
Lemon 8164ccafa5 Rename AI Chat to Dork in all user-facing labels 2026-04-14 11:25:29 -07:00
Lemon e54b9908fa Move AI Chat controls into PageHeader as children 2026-04-14 11:25:29 -07:00
Lemon 98560717d6 Make PageHeader transparent to match note card opacity 2026-04-14 11:25:29 -07:00
Lemon b8124d5069 Add stop button during AI streaming, fix prose link color with inline style 2026-04-14 11:25:29 -07:00
Lemon 52b3755727 Fix prose link color using CSS variable override for theme compatibility 2026-04-14 11:25:29 -07:00
Lemon 31baa83fa3 Add overflow-hidden to AI chat main element 2026-04-14 11:25:29 -07:00
Lemon 41016780c2 Extract AI Chat into focused modules, fix page scroll layout
- Extract tool definitions and message types to src/lib/aiChatTools.ts
- Extract system prompt to src/lib/aiChatSystemPrompt.ts
- Extract tool executor hook to src/hooks/useAIChatTools.ts
- Extract session logic to src/hooks/useAIChatSession.ts
- Page reduced from 1086 to 321 lines (render + sub-components only)
- Fix layout: remove overflow-hidden and nested scroll, align with main branch
2026-04-14 11:25:29 -07:00
Lemon 01209dbce4 Use proper OpenAI tool protocol instead of fake user messages for tool results 2026-04-14 11:24:17 -07:00
Lemon f734d682fc Restore ai-chat-height class for proper viewport containment
The main element was using h-full min-h-0 which doesn't work because
the parent wrapper div has no fixed height. Restore the original
ai-chat-height class which uses calc(100dvh - ...) for an absolute
height constraint. Keep overflow-hidden to prevent outer page scroll.
2026-04-14 11:23:48 -07:00
Lemon 1512630878 Tighten AI chat viewport layout 2026-04-14 11:23:48 -07:00
Lemon bffa8c58b4 Move credits badge to model selector row, fix page overflow
CreditsBadge now sits left of the model dropdown in the controls row
instead of in the PageHeader, so it stays on the same line on small
screens. Added overflow-hidden to the main container so only the
ScrollArea chat pane scrolls, eliminating the extra space below the
input that the user could scroll to reveal.
2026-04-14 11:23:48 -07:00
Lemon 389962cb47 Add search_users tool and multi-turn tool call loop
search_users resolves names to Nostr pubkeys with a two-phase search:
contacts first (fetch kind:3, then kind:0 metadata matching the query),
then NIP-50 relay search as fallback. Returns up to 5 matches with
pubkey, name, display_name, nip05, and about.

The tool call loop now supports multiple rounds (up to 10): after each
tool execution, the follow-up request includes tools so the AI can
chain calls (e.g. search_users -> create_spell). The loop breaks when
the AI responds with plain text instead of another tool call.
2026-04-14 11:23:48 -07:00
Lemon 3db50579fd Add credits balance badge to AI Chat header
Add getCredits() to useShakespeare hook (GET /v1/credits with NIP-98
auth). CreditsBadge component uses TanStack Query to fetch and
auto-refresh the balance every 30s, formatted as USD currency. Shown
in the PageHeader next to the AI Chat title.
2026-04-14 11:23:08 -07:00
Lemon 6a8e2acf4b Robust sidebar star: compare by event ID, add toasts, hover fills star
The star button now compares sidebar items by decoded event ID instead
of nevent string equality, so it correctly detects spells added via
different paths (three-dot menu vs star button vs paste). When removing,
it uses the matched sidebar entry's actual string.

Also adds toast notifications for add/remove, and replaces the ghost
button hover with a star-fill hover effect using Tailwind group.
2026-04-14 11:22:37 -07:00
Lemon 4242aa2b50 Include event kind in nevent encoding for sidebar items
neventEncode({ id, author }) omits the kind, so when the sidebar
decodes it and the relay fetch hasn't completed (or fails), the kind
falls back to 1 (text note) showing the wrong icon. Including the
kind in the encoding lets the sidebar resolve the correct icon and
path immediately from the decode without waiting for the fetch.

Fixed in both SpellRunPage (star button) and NoteMoreMenu (three-dot
menu 'Add to sidebar').
2026-04-14 11:22:37 -07:00
Lemon dea74fa9ef Add star button to SpellRunPage for sidebar toggle
Show a star icon in the spell summary card that adds/removes the
spell from the sidebar. Filled primary star when in sidebar, muted
outline when not. Uses the same nostr: URI pattern as NoteMoreMenu.
2026-04-14 11:22:37 -07:00
Lemon df9755c6b3 Persist AI Chat session to localStorage
Messages survive page navigation and browser refresh. The session is
loaded from localStorage on mount and saved on every change. The clear
button removes the stored session. Timestamps are serialized as ISO
strings and NostrEvent objects serialize as plain JSON.
2026-04-14 11:22:37 -07:00
Lemon 575f65d803 Add spell creation instructions to AI Chat system prompt
Teach Dork how to translate natural language into spell parameters:
runtime variables ($me, $contacts), relative timestamps, common
event kinds, tag filters vs NIP-50 search, and translation examples.
References NIP-A7 spell spec.
2026-04-14 11:22:37 -07:00
Lemon 8c4a8f469d Render Nostr events inline in AI Chat messages
Add a generic nostrEvent field to DisplayMessage so any event published
by a tool can be rendered in the chat. Uses NoteCard with compact mode,
which automatically dispatches to the right content component for the
event kind (SpellContent for kind 777, etc.). Extensible to future
tools that publish other event kinds.
2026-04-14 11:22:37 -07:00
Lemon 7a43e418e2 Add create_spell tool executor with ephemeral key publishing
Build the tool execution handler for create_spell: converts structured
parameters into kind:777 tags, generates an ephemeral keypair (like
Shakespeare's NostrPublishEventsTool), signs and publishes the spell
event plus a minimal kind:0 profile to relays. The executor returns
the published NostrEvent on the DisplayMessage for inline rendering.

Also makes executeToolCall async and updates the tool call loop to
await each execution sequentially.
2026-04-14 11:22:37 -07:00
Lemon 44363efabd Add create_spell tool definition for AI Chat
Define the OpenAI function calling schema that lets Dork create
kind:777 spell events from natural language requests. Parameters
map to the spell tag schema: name, kinds, authors, tag_filters,
since/until, limit, search, and relays. Includes examples and
documentation of runtime variables ($me, $contacts) and relative
timestamp syntax in the tool description.
2026-04-14 11:19:43 -07:00
Lemon 1b9adf76ad Use PageHeader on SpellRunPage so title scrolls with content
Replace the custom sticky header with the shared PageHeader component,
matching the behavior of BookmarksPage and other sub-pages.
2026-04-14 11:19:43 -07:00
Lemon 5b2495bd63 Fix spell sidebar items using fetched kind when nevent lacks kind field
The nevent encoding may not include a kind (e.g. nak v0.18.6 doesn't
encode it). Refactor EventSidebarContent to use the kind returned by
useNostrEventSidebar after fetching the event, so the wand icon and
/spells/run/ path resolve correctly regardless of nevent encoding.
2026-04-14 11:19:43 -07:00
Lemon 5b7ac2c655 Move saved spells from feed tabs to sidebar items
Replace the bookmark-based spell tab system in Feed.tsx with sidebar
integration. Spell events (kind 777) added to the sidebar via nostr:
URIs now render with the wand icon, display the spell name, and
navigate to /spells/run/<nevent>. Active-route highlighting matches
the spell run page by comparing decoded event IDs.
2026-04-14 11:19:43 -07:00
Lemon 781593dc4f Replace Run Spell button with clickable spell name to run 2026-04-14 11:19:43 -07:00
Lemon b828f7bddb Add bookmarked spells as feed tabs that execute inline 2026-04-14 11:19:43 -07:00
Lemon 46ed3deb5c Add spell execution: Run button resolves variables/timestamps and displays results as a feed 2026-04-14 11:19:43 -07:00
Lemon 18019e7989 Add Spells page with kind:777 feed and custom card rendering 2026-04-14 11:19:43 -07: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
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 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
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
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
216 changed files with 16128 additions and 6492 deletions
+30 -1
View File
@@ -219,7 +219,7 @@ 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"
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
@@ -235,3 +235,32 @@ publish-zapstore:
- 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/Ditto.aab
--package_name pub.ditto.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 Ditto! 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 Ditto more magnetic, more threatening to the status quo, -->
<!-- and more peaceful to inhabit?" -->
<!-- See: https://about.ditto.pub/philosophy -->
<!-- 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 the [Ditto Philosophy](https://about.ditto.pub/philosophy)
- [ ] 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
+100 -3
View File
@@ -409,6 +409,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. This is already done for theme event background and font URLs in `src/lib/themeEvent.ts`.
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.
@@ -1335,6 +1403,10 @@ Run available tools in this priority order:
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.
@@ -1412,7 +1484,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
@@ -1422,7 +1494,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
@@ -1514,4 +1586,29 @@ 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` | Full JSON contents of the Google Play API service account key file | 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. Add the full JSON contents of the key file as the `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON` variable in GitLab CI/CD settings (Settings > CI/CD > Variables). Mark it as **Protected** and **Masked**.
#### 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`)
+48
View File
@@ -1,5 +1,53 @@
# Changelog
## [2.6.6] - 2026-04-12
### Fixed
- Emoji and mention autocomplete dropdowns no longer get clipped by the compose box
- Emoji shortcodes now render as color emoji instead of plain text glyphs
- Dialogs and input fields on Android are no longer obscured by the virtual keyboard
- Signing requests on Android are more reliable and no longer silently fail after switching apps
## [2.6.5] - 2026-04-11
### Changed
- Apps and games load significantly faster on Android with smarter prefetching and server affinity
- Native loading spinners replace HTML-based ones on iOS and Android for a smoother experience
### Fixed
- External API requests on Android no longer fail due to hostname restrictions
- iOS App Store compliance issues resolved
## [2.6.4] - 2026-04-11
### Added
- iCloud Keychain integration on iOS -- your login credentials are now saved and restored automatically across devices
### Changed
- Empty feeds show a friendlier state with a discover button to help you find people to follow
- Signup flow simplified -- cleaner profile step with a single Continue button
### Fixed
- Avatar fallback now shows the user's initial instead of a question mark
- Android 16+ devices no longer have content hidden behind system bars
- Signup dialog background clears properly when switching between light and dark themes
- Sticky compose button stays anchored to the bottom even on empty feeds
## [2.6.3] - 2026-04-10
### Added
- Lightning invoices embedded in posts now render as tappable payment cards
- Blobbi companions in the feed reflect their current condition and projected health
### Changed
- Profile headers are cleaner -- lightning addresses and verification badges moved out of the way, and website URLs no longer show a trailing slash
- Login credentials are saved to your browser's built-in password manager for easier sign-in across sessions
- "Request to Vanish" renamed to "Delete Account" for clarity
### Fixed
- Badge image uploads now show a recommended 1:1 aspect ratio hint so your badges don't get cropped unexpectedly
- Security hardening for URLs and styles sourced from the network
## [2.6.2] - 2026-04-08
### Added
+184
View File
@@ -0,0 +1,184 @@
# Contributing to Ditto
We welcome contributions, but we have high standards. Ditto 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:**
- [Ditto Philosophy](https://about.ditto.pub/philosophy) -- the product vision. Your change must align with it.
- [Contributing Guide](https://about.ditto.pub/contributing) -- the upstream contribution process.
- `AGENTS.md` in this repo -- the codebase conventions. Your AI tool should load this file.
## Understanding Ditto
Ditto is a carnival, not a platform. Before contributing, you need to understand what that means.
### The product decision filter
Every change to Ditto should pass this test:
> *Does this make Ditto more magnetic, more threatening to the status quo, and more peaceful to inhabit?*
- **Magnetic** -- Ditto 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** -- Ditto 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** -- Ditto 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 Ditto 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 Ditto 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 philosophy](https://about.ditto.pub/philosophy) 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 [Ditto Philosophy](https://about.ditto.pub/philosophy). The philosophy alignment section in the MR template is where you make the case for why your change belongs in Ditto. 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 (see [Contributing Guide](https://about.ditto.pub/contributing)).
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 Ditto 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 the [Ditto Philosophy](https://about.ditto.pub/philosophy). Ditto is a carnival, not a platform. Your change should feel like it belongs in Ditto -- 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 the [Ditto Philosophy](https://about.ditto.pub/philosophy)
- 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.
+176 -58
View File
@@ -6,6 +6,7 @@
| Kind | Name | Description |
|-------|----------------------|-------------------------------------------------------|
| 777 | Spell | Portable Nostr relay query (saved feed / custom feed) |
| 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) |
@@ -30,6 +31,91 @@ These event kinds were created by community contributors and are supported by Di
---
## Kind 777: Spell (NIP-A7)
### Summary
Regular (non-replaceable) event that encodes a Nostr relay query filter as a portable, shareable event. Spells function as saved feeds — users can publish, discover, and execute them across clients.
See [NIP-A7](https://github.com/nostr-protocol/nips) for the full specification.
### Event Structure
```json
{
"kind": 777,
"content": "Notes about Bitcoin from my contacts",
"tags": [
["cmd", "REQ"],
["name", "Bitcoin from contacts"],
["alt", "Spell: Bitcoin from contacts"],
["k", "1"],
["authors", "$contacts"],
["tag", "t", "bitcoin"],
["since", "7d"],
["limit", "50"],
["media", "images"],
["language", "en"],
["sort", "trending"]
]
}
```
### Content
The `content` field contains a human-readable description of the query in plain text. It MAY be an empty string.
### Filter Tags
| Tag | Values | Description |
|-----------|-----------------------------------------|------------------------------------------|
| `cmd` | `REQ` or `COUNT` | Query command type (required) |
| `k` | `<kind number>` | Kind filter — one tag per kind |
| `authors` | `<pubkey1>`, `<pubkey2>`, ... | Author filter (supports `$me`, `$contacts`) |
| `ids` | `<id1>`, `<id2>`, ... | Event ID filter |
| `tag` | `<letter>`, `<val1>`, `<val2>`, ... | Tag filter → `#<letter>` in NostrFilter |
| `limit` | `<integer>` | Max results |
| `since` | `<timestamp>` or `<relative>` | Start time (supports relative: `7d`, `2w`, `1mo`, `1y`) |
| `until` | `<timestamp>` or `<relative>` | End time (same format as since) |
| `search` | `<query string>` | NIP-50 full-text search |
| `relays` | `<wss://url1>`, `<wss://url2>`, ... | Target relay URLs |
### Client-Hint Tags
These tags instruct clients how to build NIP-50 search extensions. They are NOT part of the NIP-01 filter — they are metadata that clients use to construct search strings and apply client-side filters.
| Tag | Values | Description |
|------------------|-------------------------------------------|----------------------------------------------------|
| `media` | `images`, `videos`, `vines`, `none` | Media type filter (omit for all) |
| `language` | ISO 639-1 code (e.g. `en`, `ja`) | Language filter |
| `platform` | `nostr`, `activitypub`, `atproto` | Protocol filter (omit for `nostr`) |
| `sort` | `hot`, `trending` | Sort preference (omit for `recent`) |
| `include-replies`| `false` | Exclude replies (omit to include) |
Client-hint tags that use NIP-50 extensions (`media`, `language`, `platform`, `sort`) require a relay that supports these extensions (e.g. Ditto relay). Clients SHOULD route queries with these extensions to a compatible relay.
### Metadata Tags
| Tag | Values | Description |
|------------------|------------|------------------------------------------------|
| `name` | `<string>` | Human-readable spell name |
| `alt` | `<string>` | NIP-31 alternative text |
| `t` | `<topic>` | Topic tag for categorization |
| `close-on-eose` | none | Close subscription after EOSE |
### Runtime Variables
| Variable | Resolves to |
|-------------|-------------------------------------------------------|
| `$me` | The executing user's pubkey |
| `$contacts` | All pubkeys from the executing user's kind 3 contact list |
### Relative Timestamps
`since` and `until` support relative durations: `s` (seconds), `m` (minutes), `h` (hours), `d` (days), `w` (weeks), `mo` (months/30d), `y` (years/365d). `now` = current timestamp.
---
## Kind 36767: Theme Definition
### Summary
@@ -196,7 +282,7 @@ Format: `["bg", "url <url>", "mode <mode>", "m <mime-type>", ...]`
### Summary
Replaceable event kind for publishing a user's custom profile page tabs. Exactly one event per user (no `d` tag). Each tab defines a Nostr filter (NIP-01) that clients execute to populate the tab's content.
Replaceable event kind for publishing a user's custom profile page tabs. Exactly one event per user (no `d` tag). Each tab stores a kind:777 spell event (JSON-encoded) that clients execute to populate the tab's content.
Visitors who load a profile fetch this event to display the custom tabs alongside the standard Posts / Media / Likes / Wall tabs.
@@ -207,10 +293,9 @@ Visitors who load a profile fetch this event to display the custom tabs alongsid
"kind": 16769,
"content": "",
"tags": [
["var", "$follows", "p", "a:3:$me:"],
["tab", "Bitcoin Posts", "{\"kinds\":[1],\"authors\":[\"$me\"],\"search\":\"bitcoin\"}"],
["tab", "Feed", "{\"kinds\":[1,6],\"authors\":[\"$follows\"],\"limit\":40}"],
["alt", "Custom profile tabs"]
["alt", "Custom profile tabs"],
["tab", "Bitcoin Posts", "{\"kind\":777,\"tags\":[[\"cmd\",\"REQ\"],[\"name\",\"Bitcoin Posts\"],[\"k\",\"1\"],[\"authors\",\"$me\"],[\"search\",\"bitcoin\"],[\"alt\",\"Spell: Bitcoin Posts\"]],\"content\":\"\",\"id\":\"\",\"pubkey\":\"\",\"created_at\":0,\"sig\":\"\"}"],
["tab", "Feed", "{\"kind\":777,\"tags\":[[\"cmd\",\"REQ\"],[\"name\",\"Feed\"],[\"k\",\"1\"],[\"k\",\"6\"],[\"authors\",\"$contacts\"],[\"alt\",\"Spell: Feed\"]],\"content\":\"\",\"id\":\"\",\"pubkey\":\"\",\"created_at\":0,\"sig\":\"\"}"]
]
}
```
@@ -223,68 +308,101 @@ The `content` field is unused and MUST be an empty string (`""`).
| Tag | Format | Description |
|-------|------------------------------------------------|----------------------------------------------------------------|
| `tab` | `["tab", "<label>", "<filterJSON>"]` | One tag per custom tab. Order defines display order. |
| `var` | `["var", "<$name>", "<tag>", "<pointer>"]` | Variable definition. See [Variable Tags](#variable-tags). |
| `tab` | `["tab", "<label>", "<spellJSON>"]` | One tag per custom tab. Order defines display order. |
| `alt` | `["alt", "Custom profile tabs"]` | NIP-31 human-readable fallback. Required. |
### Tab Filter JSON
### Tab Spell JSON
The third element of each `tab` tag is a JSON-encoded **NIP-01 filter object**, optionally extended with the NIP-50 `search` field. Variable placeholders (strings starting with `$`) may appear wherever a string value is expected.
The third element of each `tab` tag is a JSON-encoded **kind:777 spell event** (see [Kind 777](#kind-777-spell-nip-a7)). The spell event contains all filter parameters, runtime variables (`$me`, `$contacts`), and client hints (media type, language, sort, etc.) in its tags.
```json
{
"kinds": [1],
"authors": ["$me"],
"search": "bitcoin",
"limit": 20
}
```
Supported filter fields: `ids`, `authors`, `kinds`, `#<tag>` (e.g. `#t`, `#e`, `#p`), `since`, `until`, `limit`, `search`.
### Variable Tags
Variable tags define named placeholders that are resolved before the filter is executed. Each `var` tag extracts tag values from a referenced Nostr event.
Format: `["var", "$name", "<tag-to-extract>", "<event-pointer>"]`
| Index | Description |
|-------|--------------------------------------------------------------------------------------------------|
| 0 | Tag name: `"var"` |
| 1 | Variable name, starting with `$` (e.g. `"$follows"`) |
| 2 | Tag name to extract values from in the referenced event (e.g. `"p"`) |
| 3 | Event pointer: `e:<event-id>` for a specific event, or `a:<kind>:<pubkey>:<d-tag>` for an addressable/replaceable event coordinate. Variables like `$me` may appear in the pubkey position. |
Example — extract follow list pubkeys:
```json
["var", "$follows", "p", "a:3:$me:"]
```
This means: fetch the kind 3 event authored by `$me`, extract all `p` tag values, and bind them to `$follows`.
### Reserved Variable: `$me`
The `$me` variable is the only runtime-provided variable. It resolves to the **profile owner's pubkey** (the author of the kind 16769 event). It does not require a `var` tag definition.
### Variable Resolution
When a variable appears in a filter field that expects an array (e.g. `authors`, `ids`, `#p`), the variable is **expanded in-place** (spliced into the array). Literal values may be mixed with variables.
```json
["tab", "Mixed", "{\"authors\":[\"$follows\",\"abc123...\"],\"kinds\":[1]}"]
```
After resolution (assuming `$follows` = `["pk1", "pk2"]`):
```json
{"authors": ["pk1", "pk2", "abc123..."], "kinds": [1]}
```
The spell does not need to be signed or published to relays -- it is stored inline as a structural template. The `id`, `pubkey`, `created_at`, and `sig` fields may be empty strings or zero.
### Behavior
- To **add or update** tabs: publish a new kind 16769 event with all current `tab` and `var` tags.
- To **add or update** tabs: publish a new kind 16769 event with all current `tab` tags.
- To **clear** all tabs: publish a kind 16769 event with no `tab` tags (only `alt`).
- Clients MUST filter by `authors: [pubkey]` when querying to prevent spoofing.
- `var` tags are shared across all `tab` tags in the same event.
---
## Kind 30078: Buddy AI Agent Identity
### Summary
Uses NIP-78 (Application-specific data, kind 30078) to store a user's personal AI agent ("Buddy") identity. The event is signed by the user (owner), linking the agent to their account. The agent has its own Nostr keypair and kind 0 profile.
Public metadata is in tags. The agent's secret key and soul (personality/behavior description) are NIP-44 encrypted to the owner in the `content` field.
### Event Structure
```json
{
"kind": 30078,
"pubkey": "<owner-pubkey>",
"tags": [
["d", "<appId>/buddy"],
["p", "<agent-pubkey>"],
["alt", "Buddy AI agent identity"],
["client", "Ditto", "<optional-nip89-addr>"]
],
"content": "<NIP-44 encrypted to owner: { nsec, soul }>"
}
```
### Content (Encrypted)
The `content` field contains a NIP-44 payload encrypted to the owner's own pubkey (encrypt-to-self). When decrypted, it yields a JSON object:
```json
{
"nsec": "<agent-secret-key-hex>",
"name": "Sparkles",
"soul": "A witty space explorer who explains everything with analogies and never takes itself too seriously."
}
```
| Field | Required | Description |
|--------|----------|-----------------------------------------------------------------------------|
| `nsec` | Yes | Agent's secret key as a 64-character hex string |
| `name` | Yes | The buddy's canonical name (source of truth; the agent's kind 0 may use nicknames) |
| `soul` | Yes | Free-form text describing the agent's personality, injected into the system prompt via `{{SOUL}}` |
### Tags
| Tag | Required | Description |
|----------|----------|-----------------------------------------------------------------------------|
| `d` | Yes | `<appId>/buddy` — one buddy per user per app (e.g. `ditto/buddy`) |
| `p` | Yes | Agent's public key (hex) — links to the agent's kind 0 profile |
| `alt` | Yes | NIP-31 human-readable fallback |
| `client` | Yes | NIP-89 client tag identifying the publishing application |
### Agent Profile (Kind 0)
The buddy agent has its own kind 0 event signed with its own keypair. This is a standard Nostr profile:
```json
{
"kind": 0,
"pubkey": "<agent-pubkey>",
"content": "{\"name\":\"Sparkles\",\"about\":\"A witty space explorer...\",\"bot\":true}"
}
```
The `bot` field SHOULD be set to `true` per NIP-24 to indicate the profile is automated.
### Client Behavior
- On **creation**: generate a keypair, store nsec in localStorage for fast access, publish kind 0 (agent profile) and kind 30078 (identity event).
- On **page load**: read nsec from localStorage first. If missing, fetch kind 30078 from relays, decrypt, and restore nsec to localStorage.
- On **soul update**: re-encrypt and republish the kind 30078 event. Also update the agent's kind 0 `about` field.
- On **reset**: clear localStorage, publish an empty kind 30078 event to overwrite on relays.
- The `soul` text is injected into the AI system prompt template at the `{{SOUL}}` placeholder. The base system prompt (tool instructions, etc.) is maintained in application code, not in the event.
### Security
- The kind 30078 event MUST be queried with `authors: [ownerPubkey]` to prevent spoofing.
- The nsec is NIP-44 encrypted — only the owner can decrypt it.
- The agent's keypair is separate from the user's keypair. Compromise of the agent key does not affect the user's identity.
---
+11
View File
@@ -138,6 +138,17 @@ src/
public/ Static assets, icons, manifest
```
## Contributing
We welcome contributions but have high standards. Please read the full [Contributing Guide](CONTRIBUTING.md) before submitting a merge request. The short version:
- **Bug fixes**: One bug, one MR. Keep it small and focused.
- **New features**: Must link to an existing issue and align with the [Ditto Philosophy](https://about.ditto.pub/philosophy).
- **Required**: Live preview URL, before/after screenshots, completed self-review checklist.
- **Required tools**: Claude Opus 4.6 (or latest frontier model), an AI coding agent with plan mode.
Read the [Ditto Philosophy](https://about.ditto.pub/philosophy) to understand what Ditto is and isn't.
## License
[AGPL-3.0](LICENSE)
+1 -1
View File
@@ -14,7 +14,7 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "2.6.2"
versionName "2.6.6"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
+2 -1
View File
@@ -11,10 +11,11 @@ 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')
}
@@ -1,10 +1,12 @@
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;
@@ -13,6 +15,8 @@ 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;
@@ -30,6 +34,8 @@ 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.
@@ -79,19 +85,41 @@ public class SandboxPlugin extends Plugin {
SandboxInstance sandbox = new SandboxInstance(sandboxId, this);
sandboxes.put(sandboxId, sandbox);
// Add the WebView on top of the Capacitor WebView.
// The parent is a CoordinatorLayout — using the wrong LayoutParams
// type causes a ClassCastException when it intercepts touch events.
// 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.webView, params);
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;
}
// Load the initial page.
sandbox.webView.loadUrl("https://" + sandboxId + ".sandbox.native/index.html");
call.resolve();
});
}
@@ -131,7 +159,7 @@ public class SandboxPlugin extends Plugin {
CoordinatorLayout.LayoutParams params = new CoordinatorLayout.LayoutParams(pxWidth, pxHeight);
params.leftMargin = pxX;
params.topMargin = pxY;
sandbox.webView.setLayoutParams(params);
sandbox.container.setLayoutParams(params);
call.resolve();
});
@@ -214,9 +242,9 @@ public class SandboxPlugin extends Plugin {
mainHandler.post(() -> {
SandboxInstance sandbox = sandboxes.remove(sandboxId);
if (sandbox != null) {
ViewGroup parent = (ViewGroup) sandbox.webView.getParent();
ViewGroup parent = (ViewGroup) sandbox.container.getParent();
if (parent != null) {
parent.removeView(sandbox.webView);
parent.removeView(sandbox.container);
}
sandbox.webView.destroy();
}
@@ -244,13 +272,19 @@ public class SandboxPlugin extends Plugin {
*/
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();
@@ -260,13 +294,53 @@ public class SandboxPlugin extends Plugin {
settings.setAllowContentAccess(false);
settings.setDatabaseEnabled(true);
webView.setBackgroundColor(Color.WHITE);
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) {
@@ -353,8 +427,11 @@ public class SandboxPlugin extends Plugin {
// Emit to JS.
sandbox.plugin.emitFetchRequest(sandbox.id, requestId, serialisedRequest);
// Block this thread until JS responds (with a timeout).
WebResourceResponse response = pending.awaitResponse(10000);
// 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;
@@ -377,6 +454,11 @@ public class SandboxPlugin extends Plugin {
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() {
@@ -446,11 +528,12 @@ public class SandboxPlugin extends Plugin {
}
/**
* A pending request that blocks the WebViewClient thread until resolved.
* A pending request that blocks the WebViewClient IO thread until JS
* responds with the complete resource.
*/
private static class PendingRequest {
private WebResourceResponse response;
private final java.util.concurrent.CountDownLatch latch = new java.util.concurrent.CountDownLatch(1);
private volatile WebResourceResponse response;
private final CountDownLatch latch = new CountDownLatch(1);
void resolve(WebResourceResponse response) {
this.response = response;
@@ -459,7 +542,7 @@ public class SandboxPlugin extends Plugin {
WebResourceResponse awaitResponse(long timeoutMs) {
try {
latch.await(timeoutMs, java.util.concurrent.TimeUnit.MILLISECONDS);
latch.await(timeoutMs, TimeUnit.MILLISECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
+5 -2
View File
@@ -8,6 +8,9 @@ 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')
@@ -17,8 +20,8 @@ project(':capacitor-local-notifications').projectDir = new File('../node_modules
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')
+4 -4
View File
@@ -5,8 +5,6 @@ const config: CapacitorConfig = {
appName: 'Ditto',
webDir: 'dist',
server: {
// Handle deep links from your domain
hostname: 'ditto.pub',
androidScheme: 'https',
iosScheme: 'https'
},
@@ -21,8 +19,10 @@ const config: CapacitorConfig = {
scheme: 'Ditto'
},
plugins: {
Keyboard: {
resizeOnFullScreen: true,
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',
},
},
};
+1 -1
View File
@@ -3,7 +3,7 @@
<head>
<title>Ditto — Your content. Your vibe. Your rules.</title>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content" />
<meta name="description" content="Ditto — Your content. Your vibe. Your rules." />
<!-- Open Graph -->
+12 -2
View File
@@ -17,6 +17,7 @@
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 */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
@@ -32,6 +33,8 @@
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>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -67,6 +70,7 @@
isa = PBXGroup;
children = (
50379B222058CBB4000EE86E /* capacitor.config.json */,
B1A2C3D40004000100000002 /* App.entitlements */,
504EC3071FED79650016851F /* AppDelegate.swift */,
B1A2C3D40001000100000002 /* SandboxPlugin.swift */,
B1A2C3D40002000100000002 /* DittoBridgeViewController.swift */,
@@ -74,6 +78,7 @@
504EC30E1FED79650016851F /* Assets.xcassets */,
504EC3101FED79650016851F /* LaunchScreen.storyboard */,
504EC3131FED79650016851F /* Info.plist */,
B1A2C3D40005000100000002 /* PrivacyInfo.xcprivacy */,
2FAD9762203C412B000D30F8 /* config.xml */,
50B271D01FEDC1A000F3C39B /* public */,
);
@@ -151,6 +156,7 @@
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */,
504EC30D1FED79650016851F /* Main.storyboard in Resources */,
2FAD9763203C412B000D30F8 /* config.xml in Resources */,
B1A2C3D40005000100000001 /* PrivacyInfo.xcprivacy in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -303,15 +309,17 @@
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.6.2;
MARKETING_VERSION = 2.6.6;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = pub.ditto.app;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -325,15 +333,17 @@
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.6.2;
MARKETING_VERSION = 2.6.6;
PRODUCT_BUNDLE_IDENTIFIER = pub.ditto.app;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
+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:ditto.pub</string>
<string>webcredentials:ditto.pub?mode=developer</string>
</array>
</dict>
</plist>
+4
View File
@@ -49,7 +49,11 @@
<true/>
<key>NSPhotoLibraryUsageDescription</key>
<string>Ditto needs access to your photo library to upload images to your posts and profile.</string>
<key>NSCameraUsageDescription</key>
<string>Ditto 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>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
</dict>
</plist>
+77 -11
View File
@@ -17,6 +17,7 @@ public class SandboxPlugin: CAPPlugin, CAPBridgedPlugin {
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),
@@ -58,16 +59,33 @@ public class SandboxPlugin: CAPPlugin, CAPBridgedPlugin {
)
self.sandboxes[sandboxId] = sandbox
// Add the WebView on top of the Capacitor WebView.
// 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.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")
@@ -87,7 +105,7 @@ public class SandboxPlugin: CAPPlugin, CAPBridgedPlugin {
call.reject("Sandbox not found: \(sandboxId)")
return
}
sandbox.webView.frame = CGRect(x: x, y: y, width: width, height: height)
sandbox.containerView.frame = CGRect(x: x, y: y, width: width, height: height)
call.resolve()
}
}
@@ -153,7 +171,7 @@ public class SandboxPlugin: CAPPlugin, CAPBridgedPlugin {
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
if let sandbox = self.sandboxes.removeValue(forKey: sandboxId) {
sandbox.webView.removeFromSuperview()
sandbox.containerView.removeFromSuperview()
sandbox.schemeHandler.cancelAll()
}
call.resolve()
@@ -183,13 +201,19 @@ public class SandboxPlugin: CAPPlugin, CAPBridgedPlugin {
// MARK: - SandboxInstance
/// Manages a single sandboxed WKWebView instance.
private class SandboxInstance: NSObject, WKScriptMessageHandler {
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
@@ -224,19 +248,54 @@ private class SandboxInstance: NSObject, WKScriptMessageHandler {
config.preferences.javaScriptCanOpenWindowsAutomatically = false
config.defaultWebpagePreferences.allowsContentJavaScript = true
self.webView = WKWebView(frame: frame, configuration: config)
// 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 = .white
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 after super.init().
// Register the message handler and navigation delegate after super.init().
userContentController.add(self, name: "sandboxBridge")
self.webView.navigationDelegate = self
}
// Load the initial page via the custom scheme.
let initialURL = URL(string: "\(self.customScheme)://app/index.html")!
self.webView.load(URLRequest(url: initialURL))
/// 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.
@@ -270,6 +329,13 @@ private class SandboxInstance: NSObject, WKScriptMessageHandler {
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:
+4 -2
View File
@@ -14,10 +14,11 @@ 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: [
@@ -28,10 +29,11 @@ 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")
]
)
+291 -111
View File
@@ -1,25 +1,27 @@
{
"name": "ditto",
"version": "2.6.1",
"version": "2.6.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ditto",
"version": "2.6.1",
"version": "2.6.6",
"dependencies": {
"@capacitor/app": "^8.0.0",
"@capacitor/core": "^8.1.0",
"@capacitor/filesystem": "^8.1.2",
"@capacitor/keyboard": "^8.0.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",
"@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1",
"@floating-ui/react": "^0.27.19",
"@fontsource-variable/comfortaa": "^5.2.8",
"@fontsource-variable/dm-sans": "^5.2.8",
"@fontsource-variable/fredoka": "^5.2.10",
@@ -60,7 +62,7 @@
"@milkdown/react": "^7.20.0",
"@milkdown/utils": "^7.20.0",
"@nostrify/nostrify": "^0.51.1",
"@nostrify/react": "^0.5.0",
"@nostrify/react": "^0.5.1",
"@nostrify/types": "^0.36.9",
"@plausible-analytics/tracker": "^0.4.4",
"@radix-ui/react-accordion": "^1.2.0",
@@ -92,8 +94,8 @@
"@radix-ui/react-tooltip": "^1.2.8",
"@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",
@@ -109,7 +111,7 @@
"html-to-image": "^1.11.13",
"idb": "^8.0.3",
"input-otp": "^1.2.4",
"lucide-react": "^0.462.0",
"lucide-react": "^1.8.0",
"nostr-tools": "^2.13.0",
"qrcode": "^1.5.4",
"react": "^19.2.4",
@@ -218,19 +220,66 @@
}
},
"node_modules/@babel/generator": {
"version": "7.27.5",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.27.5.tgz",
"integrity": "sha512-ZGhA37l0e/g2s1Cnzdix0O3aLYm66eF8aufiVteOgnwxgnRP8GoyMj7VWsgWnQbVKXyge7hqrFh2K2TQM6t1Hw==",
"version": "8.0.0-rc.3",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-8.0.0-rc.3.tgz",
"integrity": "sha512-em37/13/nR320G4jab/nIIHZgc2Wz2y/D39lxnTyxB4/D/omPQncl/lSdlnJY1OhQcRGugTSIF2l/69o31C9dA==",
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.27.5",
"@babel/types": "^7.27.3",
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.25",
"@babel/parser": "^8.0.0-rc.3",
"@babel/types": "^8.0.0-rc.3",
"@jridgewell/gen-mapping": "^0.3.12",
"@jridgewell/trace-mapping": "^0.3.28",
"@types/jsesc": "^2.5.0",
"jsesc": "^3.0.2"
},
"engines": {
"node": ">=6.9.0"
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@babel/generator/node_modules/@babel/helper-string-parser": {
"version": "8.0.0-rc.3",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.0-rc.3.tgz",
"integrity": "sha512-AmwWFx1m8G/a5cXkxLxTiWl+YEoWuoFLUCwqMlNuWO1tqAYITQAbCRPUkyBHv1VOFgfjVOqEj6L3u15J5ZCzTA==",
"license": "MIT",
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@babel/generator/node_modules/@babel/helper-validator-identifier": {
"version": "8.0.0-rc.3",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.0-rc.3.tgz",
"integrity": "sha512-8AWCJ2VJJyDFlGBep5GpaaQ9AAaE/FjAcrqI7jyssYhtL7WGV0DOKpJsQqM037xDbpRLHXsY8TwU7zDma7coOw==",
"license": "MIT",
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@babel/generator/node_modules/@babel/parser": {
"version": "8.0.0-rc.3",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.0.0-rc.3.tgz",
"integrity": "sha512-B20dvP3MfNc/XS5KKCHy/oyWl5IA6Cn9YjXRdDlCjNmUFrjvLXMNUfQq/QUy9fnG2gYkKKcrto2YaF9B32ToOQ==",
"license": "MIT",
"dependencies": {
"@babel/types": "^8.0.0-rc.3"
},
"bin": {
"parser": "bin/babel-parser.js"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@babel/generator/node_modules/@babel/types": {
"version": "8.0.0-rc.3",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.0-rc.3.tgz",
"integrity": "sha512-mOm5ZrYmphGfqVWoH5YYMTITb3cDXsFgmvFlvkvWDMsR9X8RFnt7a0Wb6yNIdoFsiMO9WjYLq+U/FMtqIYAF8Q==",
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^8.0.0-rc.3",
"@babel/helper-validator-identifier": "^8.0.0-rc.3"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@babel/helper-string-parser": {
@@ -371,6 +420,15 @@
"@capacitor/core": ">=8.0.0"
}
},
"node_modules/@capacitor/haptics": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@capacitor/haptics/-/haptics-8.0.2.tgz",
"integrity": "sha512-c2hZzRR5Fk1tbTvhG1jhh2XBAf3EhnIerMIb2sl7Mt41Gxx1fhBJFDa0/BI1IbY4loVepyyuqNC9820/GZuoWQ==",
"license": "MIT",
"peerDependencies": {
"@capacitor/core": ">=8.0.0"
}
},
"node_modules/@capacitor/ios": {
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/@capacitor/ios/-/ios-8.2.0.tgz",
@@ -382,9 +440,9 @@
}
},
"node_modules/@capacitor/keyboard": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@capacitor/keyboard/-/keyboard-8.0.2.tgz",
"integrity": "sha512-he6xKmTBp5AhVrWJeEi6RYkJ25FjLLdNruBU2wafpITk3Nb7UdzOj96x3K6etFuEj8/rtn9WXBTs1o2XA86A1A==",
"version": "8.0.3",
"resolved": "https://registry.npmjs.org/@capacitor/keyboard/-/keyboard-8.0.3.tgz",
"integrity": "sha512-27Bv5/2w1Ss2njguBgTS98O0Bb8DRJhAARyzXYib0JlT/n6BrJw/EZ0CokM4C8GFUjFDjJnEKF1Ie01buTMEXQ==",
"license": "MIT",
"peerDependencies": {
"@capacitor/core": ">=8.0.0"
@@ -408,21 +466,21 @@
"@capacitor/core": ">=8.0.0"
}
},
"node_modules/@capacitor/status-bar": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/@capacitor/status-bar/-/status-bar-8.0.1.tgz",
"integrity": "sha512-OR59dlbwvmrV5dKsC9lvwv48QaGbqcbSTBpk+9/WXWxXYSdXXdzJZU9p8oyNPAkuJhCdnSa3XmU43fZRPBJJ5w==",
"license": "MIT",
"peerDependencies": {
"@capacitor/core": ">=8.0.0"
}
},
"node_modules/@capacitor/synapse": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@capacitor/synapse/-/synapse-1.0.4.tgz",
"integrity": "sha512-/C1FUo8/OkKuAT4nCIu/34ny9siNHr9qtFezu4kxm6GY1wNFxrCFWjfYx5C1tUhVGz3fxBABegupkpjXvjCHrw==",
"license": "ISC"
},
"node_modules/@capgo/capacitor-autofill-save-password": {
"version": "8.0.22",
"resolved": "https://registry.npmjs.org/@capgo/capacitor-autofill-save-password/-/capacitor-autofill-save-password-8.0.22.tgz",
"integrity": "sha512-l6RvtTgdZWDx5fu74QcdV0NLioKmI4PwzCnscpl00ZjxHjecR/yVoB5ufsOYLAY2qyLP3jx9PUpFvEo2rPNHPA==",
"license": "MPL-2.0",
"peerDependencies": {
"@capacitor/core": ">=8.0.0"
}
},
"node_modules/@codemirror/autocomplete": {
"version": "6.20.1",
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.1.tgz",
@@ -1240,6 +1298,21 @@
"@floating-ui/utils": "^0.2.11"
}
},
"node_modules/@floating-ui/react": {
"version": "0.27.19",
"resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.19.tgz",
"integrity": "sha512-31B8h5mm8YxotlE7/AU/PhNAl8eWxAmjL/v2QOxroDNkTFLk3Uu82u63N3b6TXa4EGJeeZLVcd/9AlNlVqzeog==",
"license": "MIT",
"dependencies": {
"@floating-ui/react-dom": "^2.1.8",
"@floating-ui/utils": "^0.2.11",
"tabbable": "^6.0.0"
},
"peerDependencies": {
"react": ">=17.0.0",
"react-dom": ">=17.0.0"
}
},
"node_modules/@floating-ui/react-dom": {
"version": "2.1.8",
"resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz",
@@ -1800,17 +1873,23 @@
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.8",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz",
"integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==",
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
"license": "MIT",
"dependencies": {
"@jridgewell/set-array": "^1.2.1",
"@jridgewell/sourcemap-codec": "^1.4.10",
"@jridgewell/sourcemap-codec": "^1.5.0",
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"node_modules/@jridgewell/remapping": {
"version": "2.3.5",
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
"license": "MIT",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.24"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/resolve-uri": {
@@ -1822,15 +1901,6 @@
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/set-array": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz",
"integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==",
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
@@ -1838,9 +1908,9 @@
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.25",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz",
"integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==",
"version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
@@ -2540,9 +2610,9 @@
"license": "MIT"
},
"node_modules/@nostrify/react": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/@nostrify/react/-/react-0.5.0.tgz",
"integrity": "sha512-IQf74SSusSIyhI9FkUQSUTsX20yeww5xHIUeexvxcWXEpVhYJYCwduK2yRB75NvYgXjcqYeDUGA2RvzBhDc/eA==",
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/@nostrify/react/-/react-0.5.1.tgz",
"integrity": "sha512-gQUct8A7KLKvoLtv4bHpVDfmvzJlIHjZZI6DMui8vrSuzm8IqMRdAYADbR3ry1mlIQp8/c4EeR24piBpHK0WUw==",
"dependencies": {
"@nostrify/nostrify": "0.51.1",
"@nostrify/types": "0.36.9"
@@ -5668,9 +5738,9 @@
"license": "MIT"
},
"node_modules/@rollup/pluginutils": {
"version": "5.1.4",
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.1.4.tgz",
"integrity": "sha512-USm05zrsFxYLPdWWq+K3STlWiT/3ELn3RcV5hJMghpeAIhxfsUIg6mt12CBJBInWMV4VneoV7SfGv8xIwo2qNQ==",
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz",
"integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==",
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0",
@@ -6519,6 +6589,12 @@
"@types/unist": "*"
}
},
"node_modules/@types/jsesc": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@types/jsesc/-/jsesc-2.5.1.tgz",
"integrity": "sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==",
"license": "MIT"
},
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -6910,30 +6986,33 @@
"license": "ISC"
},
"node_modules/@unhead/addons": {
"version": "2.0.10",
"resolved": "https://registry.npmjs.org/@unhead/addons/-/addons-2.0.10.tgz",
"integrity": "sha512-9+w/m+X5e7CDKXKGTym1N4MpBjrRC89cfl95RDgKwBcFJfQ3pZu50llIjx/j462VqtrNMXddBKcUnfWvQyapuw==",
"version": "2.1.13",
"resolved": "https://registry.npmjs.org/@unhead/addons/-/addons-2.1.13.tgz",
"integrity": "sha512-xiM5ERU68FEuiBCCiPZ1EDkja+kH4hKKot/7dNJufneACtGoAFWnKUcmj/iB9BKjVwgBBF3sFYO3qXjkNFXWxA==",
"license": "MIT",
"dependencies": {
"@rollup/pluginutils": "^5.1.4",
"@rollup/pluginutils": "^5.3.0",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.17",
"mlly": "^1.7.4",
"ufo": "^1.6.1",
"unplugin": "^2.3.4",
"unplugin-ast": "^0.15.0"
"magic-string": "^0.30.21",
"mlly": "^1.8.0",
"ufo": "^1.6.3",
"unplugin": "^3.0.0",
"unplugin-ast": "^0.16.0"
},
"funding": {
"url": "https://github.com/sponsors/harlan-zw"
},
"peerDependencies": {
"unhead": "^2.1.13"
}
},
"node_modules/@unhead/react": {
"version": "2.1.12",
"resolved": "https://registry.npmjs.org/@unhead/react/-/react-2.1.12.tgz",
"integrity": "sha512-1xXFrxyw29f+kScXfEb0GxjlgtnHxoYau0qpW9k8sgWhQUNnE5gNaH3u+rNhd5IqhyvbdDRJpQ25zoz0HIyGaw==",
"version": "2.1.13",
"resolved": "https://registry.npmjs.org/@unhead/react/-/react-2.1.13.tgz",
"integrity": "sha512-gC48tNJ0UtbithkiKCc2WUlxbVVk5o171EtruS2w2hQUblfYFHzCPu2hljjT1e0tUHXXqN8EMv7mpxHddMB2sg==",
"license": "MIT",
"dependencies": {
"unhead": "2.1.12"
"unhead": "2.1.13"
},
"funding": {
"url": "https://github.com/sponsors/harlan-zw"
@@ -7207,9 +7286,9 @@
}
},
"node_modules/acorn": {
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"license": "MIT",
"bin": {
"acorn": "bin/acorn"
@@ -7344,21 +7423,68 @@
}
},
"node_modules/ast-kit": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/ast-kit/-/ast-kit-2.1.0.tgz",
"integrity": "sha512-ROM2LlXbZBZVk97crfw8PGDOBzzsJvN2uJCmwswvPUNyfH14eg90mSN3xNqsri1JS1G9cz0VzeDUhxJkTrr4Ew==",
"version": "3.0.0-beta.1",
"resolved": "https://registry.npmjs.org/ast-kit/-/ast-kit-3.0.0-beta.1.tgz",
"integrity": "sha512-trmleAnZ2PxN/loHWVhhx1qeOHSRXq4TDsBBxq3GqeJitfk3+jTQ+v/C1km/KYq9M7wKqCewMh+/NAvVH7m+bw==",
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.27.3",
"@babel/parser": "^8.0.0-beta.4",
"estree-walker": "^3.0.3",
"pathe": "^2.0.3"
},
"engines": {
"node": ">=20.18.0"
"node": ">=20.19.0"
},
"funding": {
"url": "https://github.com/sponsors/sxzz"
}
},
"node_modules/ast-kit/node_modules/@babel/helper-string-parser": {
"version": "8.0.0-rc.3",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.0-rc.3.tgz",
"integrity": "sha512-AmwWFx1m8G/a5cXkxLxTiWl+YEoWuoFLUCwqMlNuWO1tqAYITQAbCRPUkyBHv1VOFgfjVOqEj6L3u15J5ZCzTA==",
"license": "MIT",
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/ast-kit/node_modules/@babel/helper-validator-identifier": {
"version": "8.0.0-rc.3",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.0-rc.3.tgz",
"integrity": "sha512-8AWCJ2VJJyDFlGBep5GpaaQ9AAaE/FjAcrqI7jyssYhtL7WGV0DOKpJsQqM037xDbpRLHXsY8TwU7zDma7coOw==",
"license": "MIT",
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/ast-kit/node_modules/@babel/parser": {
"version": "8.0.0-rc.3",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.0.0-rc.3.tgz",
"integrity": "sha512-B20dvP3MfNc/XS5KKCHy/oyWl5IA6Cn9YjXRdDlCjNmUFrjvLXMNUfQq/QUy9fnG2gYkKKcrto2YaF9B32ToOQ==",
"license": "MIT",
"dependencies": {
"@babel/types": "^8.0.0-rc.3"
},
"bin": {
"parser": "bin/babel-parser.js"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/ast-kit/node_modules/@babel/types": {
"version": "8.0.0-rc.3",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.0-rc.3.tgz",
"integrity": "sha512-mOm5ZrYmphGfqVWoH5YYMTITb3cDXsFgmvFlvkvWDMsR9X8RFnt7a0Wb6yNIdoFsiMO9WjYLq+U/FMtqIYAF8Q==",
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^8.0.0-rc.3",
"@babel/helper-validator-identifier": "^8.0.0-rc.3"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/astral-regex": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz",
@@ -9987,12 +10113,12 @@
"license": "ISC"
},
"node_modules/lucide-react": {
"version": "0.462.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.462.0.tgz",
"integrity": "sha512-NTL7EbAao9IFtuSivSZgrAh4fZd09Lr+6MTkqIxuHaH2nnYiYIzXPo06cOxHg9wKLdj6LL8TByG4qpePqwgx/g==",
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.8.0.tgz",
"integrity": "sha512-WuvlsjngSk7TnTBJ1hsCy3ql9V9VOdcPkd3PKcSmM34vJD8KG6molxz7m7zbYFgICwsanQWmJ13JlYs4Zp7Arw==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc"
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/lz-string": {
@@ -10015,15 +10141,15 @@
}
},
"node_modules/magic-string-ast": {
"version": "0.9.1",
"resolved": "https://registry.npmjs.org/magic-string-ast/-/magic-string-ast-0.9.1.tgz",
"integrity": "sha512-18dv2ZlSSgJ/jDWlZGKfnDJx56ilNlYq9F7NnwuWTErsmYmqJ2TWE4l1o2zlUHBYUGBy3tIhPCC1gxq8M5HkMA==",
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/magic-string-ast/-/magic-string-ast-1.0.3.tgz",
"integrity": "sha512-CvkkH1i81zl7mmb94DsRiFeG9V2fR2JeuK8yDgS8oiZSFa++wWLEgZ5ufEOyLHbvSbD1gTRKv9NdX69Rnvr9JA==",
"license": "MIT",
"dependencies": {
"magic-string": "^0.30.17"
"magic-string": "^0.30.19"
},
"engines": {
"node": ">=20.18.0"
"node": ">=20.19.0"
},
"funding": {
"url": "https://github.com/sponsors/sxzz"
@@ -11006,15 +11132,15 @@
}
},
"node_modules/mlly": {
"version": "1.7.4",
"resolved": "https://registry.npmjs.org/mlly/-/mlly-1.7.4.tgz",
"integrity": "sha512-qmdSIPC4bDJXgZTCR7XosJiNKySV7O215tsPtDN9iEO/7q/76b/ijtgRu/+epFXSJhijtTCCGp3DWS549P3xKw==",
"version": "1.8.2",
"resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz",
"integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==",
"license": "MIT",
"dependencies": {
"acorn": "^8.14.0",
"pathe": "^2.0.1",
"pkg-types": "^1.3.0",
"ufo": "^1.5.4"
"acorn": "^8.16.0",
"pathe": "^2.0.3",
"pkg-types": "^1.3.1",
"ufo": "^1.6.3"
}
},
"node_modules/ms": {
@@ -13301,6 +13427,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/tabbable": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz",
"integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==",
"license": "MIT"
},
"node_modules/tailwind-merge": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.0.tgz",
@@ -13678,9 +13810,9 @@
}
},
"node_modules/ufo": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.1.tgz",
"integrity": "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==",
"version": "1.6.3",
"resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz",
"integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==",
"license": "MIT"
},
"node_modules/undici-types": {
@@ -13691,9 +13823,9 @@
"license": "MIT"
},
"node_modules/unhead": {
"version": "2.1.12",
"resolved": "https://registry.npmjs.org/unhead/-/unhead-2.1.12.tgz",
"integrity": "sha512-iTHdWD9ztTunOErtfUFk6Wr11BxvzumcYJ0CzaSCBUOEtg+DUZ9+gnE99i8QkLFT2q1rZD48BYYGXpOZVDLYkA==",
"version": "2.1.13",
"resolved": "https://registry.npmjs.org/unhead/-/unhead-2.1.13.tgz",
"integrity": "sha512-jO9M1sI6b2h/1KpIu4Jeu+ptumLmUKboRRLxys5pYHFeT+lqTzfNHbYUX9bxVDhC1FBszAGuWcUVlmvIPsah8Q==",
"license": "MIT",
"dependencies": {
"hookable": "^6.0.1"
@@ -13814,37 +13946,85 @@
}
},
"node_modules/unplugin": {
"version": "2.3.5",
"resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.5.tgz",
"integrity": "sha512-RyWSb5AHmGtjjNQ6gIlA67sHOsWpsbWpwDokLwTcejVdOjEkJZh7QKu14J00gDDVSh8kGH4KYC/TNBceXFZhtw==",
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/unplugin/-/unplugin-3.0.0.tgz",
"integrity": "sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg==",
"license": "MIT",
"dependencies": {
"acorn": "^8.14.1",
"picomatch": "^4.0.2",
"@jridgewell/remapping": "^2.3.5",
"picomatch": "^4.0.3",
"webpack-virtual-modules": "^0.6.2"
},
"engines": {
"node": ">=18.12.0"
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/unplugin-ast": {
"version": "0.15.0",
"resolved": "https://registry.npmjs.org/unplugin-ast/-/unplugin-ast-0.15.0.tgz",
"integrity": "sha512-3ReKQUmmYEcNhjoyiwfFuaJU0jkZNcNk8+iLdLVWk73iojVjJLiF/QhnpAFf3O7CJd6bqhWBzNyQ68Udp2fi5Q==",
"version": "0.16.0",
"resolved": "https://registry.npmjs.org/unplugin-ast/-/unplugin-ast-0.16.0.tgz",
"integrity": "sha512-1ow2FlRznoSKE7Fjk2bSxqDsvHyj/O876RqsNlipsM6A+I91t7Mi+jG7tCNNcl3vZx14z4pGXBLSl8KOPrMuFQ==",
"license": "MIT",
"dependencies": {
"@babel/generator": "^7.27.1",
"ast-kit": "^2.0.0",
"magic-string-ast": "^0.9.1",
"unplugin": "^2.3.2"
"@babel/generator": "^8.0.0-beta.4",
"@babel/parser": "^8.0.0-beta.4",
"@babel/types": "^8.0.0-beta.4",
"ast-kit": "^3.0.0-beta.1",
"magic-string-ast": "^1.0.3",
"unplugin": "^3.0.0"
},
"engines": {
"node": ">=20.18.0"
"node": ">=20.19.0"
},
"funding": {
"url": "https://github.com/sponsors/sxzz"
}
},
"node_modules/unplugin-ast/node_modules/@babel/helper-string-parser": {
"version": "8.0.0-rc.3",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.0-rc.3.tgz",
"integrity": "sha512-AmwWFx1m8G/a5cXkxLxTiWl+YEoWuoFLUCwqMlNuWO1tqAYITQAbCRPUkyBHv1VOFgfjVOqEj6L3u15J5ZCzTA==",
"license": "MIT",
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/unplugin-ast/node_modules/@babel/helper-validator-identifier": {
"version": "8.0.0-rc.3",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.0-rc.3.tgz",
"integrity": "sha512-8AWCJ2VJJyDFlGBep5GpaaQ9AAaE/FjAcrqI7jyssYhtL7WGV0DOKpJsQqM037xDbpRLHXsY8TwU7zDma7coOw==",
"license": "MIT",
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/unplugin-ast/node_modules/@babel/parser": {
"version": "8.0.0-rc.3",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.0.0-rc.3.tgz",
"integrity": "sha512-B20dvP3MfNc/XS5KKCHy/oyWl5IA6Cn9YjXRdDlCjNmUFrjvLXMNUfQq/QUy9fnG2gYkKKcrto2YaF9B32ToOQ==",
"license": "MIT",
"dependencies": {
"@babel/types": "^8.0.0-rc.3"
},
"bin": {
"parser": "bin/babel-parser.js"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/unplugin-ast/node_modules/@babel/types": {
"version": "8.0.0-rc.3",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.0-rc.3.tgz",
"integrity": "sha512-mOm5ZrYmphGfqVWoH5YYMTITb3cDXsFgmvFlvkvWDMsR9X8RFnt7a0Wb6yNIdoFsiMO9WjYLq+U/FMtqIYAF8Q==",
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^8.0.0-rc.3",
"@babel/helper-validator-identifier": "^8.0.0-rc.3"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/unplugin/node_modules/picomatch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
+9 -7
View File
@@ -1,7 +1,7 @@
{
"name": "ditto",
"private": true,
"version": "2.6.2",
"version": "2.6.6",
"type": "module",
"scripts": {
"dev": "npm i --silent && vite",
@@ -18,15 +18,17 @@
"@capacitor/app": "^8.0.0",
"@capacitor/core": "^8.1.0",
"@capacitor/filesystem": "^8.1.2",
"@capacitor/keyboard": "^8.0.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",
"@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1",
"@floating-ui/react": "^0.27.19",
"@fontsource-variable/comfortaa": "^5.2.8",
"@fontsource-variable/dm-sans": "^5.2.8",
"@fontsource-variable/fredoka": "^5.2.10",
@@ -67,7 +69,7 @@
"@milkdown/react": "^7.20.0",
"@milkdown/utils": "^7.20.0",
"@nostrify/nostrify": "^0.51.1",
"@nostrify/react": "^0.5.0",
"@nostrify/react": "^0.5.1",
"@nostrify/types": "^0.36.9",
"@plausible-analytics/tracker": "^0.4.4",
"@radix-ui/react-accordion": "^1.2.0",
@@ -99,8 +101,8 @@
"@radix-ui/react-tooltip": "^1.2.8",
"@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",
@@ -116,7 +118,7 @@
"html-to-image": "^1.11.13",
"idb": "^8.0.3",
"input-otp": "^1.2.4",
"lucide-react": "^0.462.0",
"lucide-react": "^1.8.0",
"nostr-tools": "^2.13.0",
"qrcode": "^1.5.4",
"react": "^19.2.4",
@@ -0,0 +1,7 @@
{
"webcredentials": {
"apps": [
"GZLTTH5DLM.pub.ditto.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.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",
"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"
]
}
}
}]
]
+48
View File
@@ -1,5 +1,53 @@
# Changelog
## [2.6.6] - 2026-04-12
### Fixed
- Emoji and mention autocomplete dropdowns no longer get clipped by the compose box
- Emoji shortcodes now render as color emoji instead of plain text glyphs
- Dialogs and input fields on Android are no longer obscured by the virtual keyboard
- Signing requests on Android are more reliable and no longer silently fail after switching apps
## [2.6.5] - 2026-04-11
### Changed
- Apps and games load significantly faster on Android with smarter prefetching and server affinity
- Native loading spinners replace HTML-based ones on iOS and Android for a smoother experience
### Fixed
- External API requests on Android no longer fail due to hostname restrictions
- iOS App Store compliance issues resolved
## [2.6.4] - 2026-04-11
### Added
- iCloud Keychain integration on iOS -- your login credentials are now saved and restored automatically across devices
### Changed
- Empty feeds show a friendlier state with a discover button to help you find people to follow
- Signup flow simplified -- cleaner profile step with a single Continue button
### Fixed
- Avatar fallback now shows the user's initial instead of a question mark
- Android 16+ devices no longer have content hidden behind system bars
- Signup dialog background clears properly when switching between light and dark themes
- Sticky compose button stays anchored to the bottom even on empty feeds
## [2.6.3] - 2026-04-10
### Added
- Lightning invoices embedded in posts now render as tappable payment cards
- Blobbi companions in the feed reflect their current condition and projected health
### Changed
- Profile headers are cleaner -- lightning addresses and verification badges moved out of the way, and website URLs no longer show a trailing slash
- Login credentials are saved to your browser's built-in password manager for easier sign-in across sessions
- "Request to Vanish" renamed to "Delete Account" for clarity
### Fixed
- Badge image uploads now show a recommended 1:1 aspect ratio hint so your badges don't get cropped unexpectedly
- Security hardening for URLs and styles sourced from the network
## [2.6.2] - 2026-04-08
### Added
+21 -9
View File
@@ -1,8 +1,7 @@
// 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";
@@ -25,6 +24,8 @@ import { NWCProvider } from "@/contexts/NWCContext";
import { PROTOCOL_MODE } from "@/lib/dmConstants";
import { DittoConfigSchema, type DittoConfig } from "@/lib/schemas";
import { secureStorage } from "@/lib/secureStorage";
import { ScreenEffectProvider } from "@/contexts/ScreenEffectContext";
import { ScreenEffectRenderer } from "@/components/ScreenEffectRenderer";
import { EmotionDevProvider } from "@/blobbi/dev/EmotionDevContext";
import AppRouter from "./AppRouter";
@@ -122,9 +123,10 @@ const hardcodedConfig: AppConfig = {
followsFeedShowReplies: true,
},
sidebarOrder: [
"search",
"feed",
"notifications",
"search",
"discover",
"blobbi",
"badges",
"emojis",
@@ -152,6 +154,13 @@ const hardcodedConfig: AppConfig = {
imageQuality: 'compressed',
curatorPubkey: '932614571afcbad4d17a191ee281e39eebbb41b93fac8fd87829622aeb112f4d',
sandboxDomain: 'iframe.diy',
sidebarWidgets: [
{ id: 'trends' },
{ id: 'hot-posts' },
{ id: 'wikipedia' },
],
aiModel: '',
aiSystemPrompt: '',
};
/**
@@ -184,13 +193,13 @@ 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
});
}
}, []);
@@ -208,13 +217,16 @@ export function App() {
<NWCProvider>
<DMProvider config={dmConfig}>
<ScreenEffectProvider>
<EmotionDevProvider>
<TooltipProvider>
<InitialSyncGate>
<ScreenEffectRenderer />
<AppRouter />
</InitialSyncGate>
</TooltipProvider>
</EmotionDevProvider>
</ScreenEffectProvider>
</DMProvider>
</NWCProvider>
</NostrProvider>
+4 -1
View File
@@ -67,6 +67,7 @@ const ProfileSettings = lazy(() => import("./pages/ProfileSettings").then(m => (
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 })));
@@ -160,7 +161,8 @@ export function AppRouter() {
<Route path="/" element={<HomePage />} />
<Route path="/feed" element={<Index />} />
<Route path="/notifications" element={<NotificationsPage />} />
<Route path="/search" element={<SearchPage />} />
<Route path="/discover" element={<SearchPage />} />
<Route path="/search" element={<Navigate to="/discover" replace />} />
<Route path="/trends" element={<TrendsPage />} />
<Route path="/profile" element={<ProfileRedirect />} />
<Route path="/t/:tag" element={<HashtagPage />} />
@@ -267,6 +269,7 @@ export function AppRouter() {
<Route path="/letters" element={<LettersPage />} />
<Route path="/letters/compose" element={<LetterComposePage />} />
<Route path="/settings/letters" element={<LetterPreferencesPage />} />
<Route path="/help" element={<HelpPage />} />
<Route path="/privacy" element={<PrivacyPolicyPage />} />
<Route path="/safety" element={<CSAEPolicyPage />} />
@@ -17,7 +17,6 @@ import {
Loader2,
XCircle,
AlertTriangle,
Coins,
X,
Eye,
Scroll,
@@ -25,7 +24,7 @@ import {
HelpCircle,
ChevronDown,
} from 'lucide-react';
import { formatCompactNumber, cn } from '@/lib/utils';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
@@ -46,14 +45,12 @@ import {
} 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 { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
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 ────────────────────────────────────────────────────────────────────
@@ -62,8 +59,6 @@ interface BlobbiMissionsModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
companion: BlobbiCompanion;
profile: BlobbonautProfile | null;
updateProfileEvent: (event: NostrEvent) => void;
hatchTasks: HatchTasksResult;
evolveTasks: EvolveTasksResult;
onOpenPostModal: () => void;
@@ -146,16 +141,12 @@ function MissionTypeLegend() {
// ─── 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,
@@ -164,23 +155,17 @@ function DailyMissionsSection({
const {
missions,
todayClaimedReward,
totalPotentialReward,
bonusAvailable,
bonusClaimed,
bonusReward,
todayXp,
allComplete,
bonusUnlocked,
bonusXp,
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;
const completedCount = missions.filter((m) => m.complete).length;
return (
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
@@ -194,13 +179,12 @@ function DailyMissionsSection({
<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)}
{completedCount} / {missions.length}
</span>
{claimableCount > 0 && (
{allComplete && (
<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>
@@ -213,13 +197,11 @@ function DailyMissionsSection({
<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}
todayXp={todayXp}
disabled={disabled || isRerolling}
bonusUnlocked={bonusUnlocked}
bonusXp={bonusXp}
noMissionsAvailable={noMissionsAvailable}
rerollsRemaining={rerollsRemaining}
isRerolling={isRerolling}
@@ -442,8 +424,6 @@ export function BlobbiMissionsModal({
open,
onOpenChange,
companion,
profile,
updateProfileEvent,
hatchTasks,
evolveTasks,
onOpenPostModal,
@@ -527,8 +507,6 @@ export function BlobbiMissionsModal({
{/* 2. Daily Bounties */}
<DailyMissionsSection
profile={profile}
updateProfileEvent={updateProfileEvent}
availableStages={availableStages}
disabled={isProcessBusy}
/>
@@ -2,16 +2,16 @@
* 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.
* Tapping a card expands it to show progress and reroll.
* Only one card expanded at a time.
* Completion is implicit (derived from progress vs target).
*/
import { useState } from 'react';
import {
Check,
Coins,
Gift,
Sparkles,
Gift,
Egg,
Trophy,
RefreshCw,
@@ -24,13 +24,13 @@ import {
Music,
Pill,
CircleDot,
Zap,
} 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 type { DailyMissionAction } from '../lib/daily-missions';
import type { DailyMissionView } from '../hooks/useDailyMissions';
import {
ExpandableMissionCard,
MissionDescription,
@@ -40,14 +40,12 @@ import {
// ─── Types ────────────────────────────────────────────────────────────────────
interface DailyMissionsPanelProps {
missions: DailyMission[];
onClaimReward: (missionId: string) => void;
missions: DailyMissionView[];
onRerollMission?: (missionId: string) => void;
todayCoins: number;
todayXp: number;
disabled?: boolean;
bonusAvailable?: boolean;
bonusClaimed?: boolean;
bonusReward?: number;
bonusUnlocked?: boolean;
bonusXp?: number;
noMissionsAvailable?: boolean;
rerollsRemaining?: number;
isRerolling?: boolean;
@@ -82,51 +80,34 @@ function DailyMissionIcon({ action }: { action: DailyMissionAction }) {
// ─── Bonus Card ───────────────────────────────────────────────────────────────
interface BonusCardProps {
isAvailable: boolean;
isClaimed: boolean;
reward: number;
onClaim: () => void;
disabled?: boolean;
isUnlocked: boolean;
xp: number;
isExpanded: boolean;
onToggle: (id: string) => void;
}
function BonusCard({ isAvailable, isClaimed, reward, onClaim, disabled, isExpanded, onToggle }: BonusCardProps) {
const progress = isClaimed ? 1 : isAvailable ? 1 : 0;
function BonusCard({ isUnlocked, xp, isExpanded, onToggle }: BonusCardProps) {
return (
<ExpandableMissionCard
id="bonus"
category="daily"
icon={<Trophy className="size-5" />}
title="Daily Champion"
completed={isClaimed}
progress={progress}
completed={isUnlocked}
progress={isUnlocked ? 1 : 0}
isExpanded={isExpanded}
onToggle={onToggle}
>
<MissionDescription>
{isAvailable || isClaimed
? 'Bonus reward for completing all daily missions!'
{isUnlocked
? 'Bonus XP 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 className="flex items-center gap-1 text-xs font-medium text-violet-600 dark:text-violet-400">
<Zap className="size-3" />
+{formatCompactNumber(xp)} XP
</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>
);
}
@@ -147,7 +128,7 @@ function NoMissionsState() {
);
}
function AllClaimedState({ todayCoins }: { todayCoins: number }) {
function AllCompleteState({ todayXp }: { todayXp: number }) {
return (
<div className="flex flex-col items-center gap-2 py-6 text-center">
<Sparkles className="size-5 text-primary/60" />
@@ -155,8 +136,8 @@ function AllClaimedState({ todayCoins }: { todayCoins: number }) {
<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 className="font-medium text-violet-600 dark:text-violet-400">
{formatCompactNumber(todayXp)} XP
</span>{' '}
come back tomorrow!
</p>
@@ -187,13 +168,11 @@ function RerollCounter({ remaining }: { remaining: number }) {
export function DailyMissionsPanel({
missions,
onClaimReward,
onRerollMission,
todayCoins,
todayXp,
disabled,
bonusAvailable = false,
bonusClaimed = false,
bonusReward = 50,
bonusUnlocked = false,
bonusXp = 50,
noMissionsAvailable = false,
rerollsRemaining = 0,
isRerolling = false,
@@ -202,10 +181,8 @@ export function DailyMissionsPanel({
if (noMissionsAvailable) return <NoMissionsState />;
const allRegularClaimed = missions.every((m) => m.claimed);
const allDone = allRegularClaimed && bonusClaimed;
if (allDone) return <AllClaimedState todayCoins={todayCoins} />;
const allComplete = missions.every((m) => m.complete);
if (allComplete && bonusUnlocked) return <AllCompleteState todayXp={todayXp} />;
const canReroll = rerollsRemaining > 0 && !!onRerollMission;
@@ -220,9 +197,8 @@ export function DailyMissionsPanel({
{/* 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;
const progressFrac = mission.target > 0 ? mission.progress / mission.target : 0;
const showReroll = onRerollMission && !mission.complete && canReroll;
return (
<ExpandableMissionCard
@@ -231,8 +207,8 @@ export function DailyMissionsPanel({
category="daily"
icon={<DailyMissionIcon action={mission.action} />}
title={mission.title}
completed={mission.claimed}
progress={Math.min(progress, 1)}
completed={mission.complete}
progress={Math.min(progressFrac, 1)}
isExpanded={expandedId === mission.id}
onToggle={handleToggle}
>
@@ -240,19 +216,19 @@ export function DailyMissionsPanel({
<MissionDescription>{mission.description}</MissionDescription>
{/* Progress */}
{!mission.claimed && (
{!mission.complete && (
<MissionProgress
current={mission.currentCount}
required={mission.requiredCount}
completed={mission.completed}
current={mission.progress}
required={mission.target}
completed={mission.complete}
/>
)}
{/* Reward + reroll row */}
{/* XP + 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 className="inline-flex items-center gap-0.5 text-xs font-medium text-violet-600 dark:text-violet-400">
<Zap className="size-3" />
{formatCompactNumber(mission.xp)} XP
</span>
{showReroll && (
@@ -277,7 +253,7 @@ export function DailyMissionsPanel({
</TooltipProvider>
)}
{mission.claimed && (
{mission.complete && (
<span className="inline-flex items-center gap-0.5 text-[10px] font-medium text-primary">
<Check className="size-2.5" />
Done
@@ -285,20 +261,12 @@ export function DailyMissionsPanel({
)}
</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>
{/* Complete indicator */}
{mission.complete && (
<div className="flex items-center gap-1 text-xs text-emerald-600 dark:text-emerald-400">
<Gift className="size-3.5" />
+{formatCompactNumber(mission.xp)} XP earned
</div>
)}
</ExpandableMissionCard>
);
@@ -306,11 +274,8 @@ export function DailyMissionsPanel({
{/* Bonus card */}
<BonusCard
isAvailable={bonusAvailable}
isClaimed={bonusClaimed}
reward={bonusReward}
onClaim={() => onClaimReward(BONUS_MISSION_ID)}
disabled={disabled}
isUnlocked={bonusUnlocked}
xp={bonusXp}
isExpanded={expandedId === 'bonus'}
onToggle={handleToggle}
/>
+68 -189
View File
@@ -1,242 +1,121 @@
/**
* 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
* useAwardDailyXp - Award XP for completed daily missions
*
* Completion is implicit (derived from progress vs target).
* This hook calculates the total XP earned today and persists
* the updated XP total to kind 11125 tags.
*
* Uses fetchFreshEvent to avoid stale-read overwrites when
* multiple mutations race (e.g. item use XP + daily XP).
*/
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useNostr } from '@nostrify/react';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { toast } from '@/hooks/useToast';
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
import type { BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
import {
KIND_BLOBBONAUT_PROFILE,
updateBlobbonautTags,
parseBlobbonautEvent,
} from '@/blobbi/core/lib/blobbi';
import {
type DailyMissionsState,
getTodayDateString,
needsDailyReset,
createDailyMissionsState,
isBonusMissionAvailable,
isBonusMissionClaimed,
BONUS_MISSION_DEFINITION,
} from '../lib/daily-missions';
import { buildXpTagUpdates } from '@/blobbi/core/lib/progression';
import { serializeProfileContent } from '@/blobbi/core/lib/missions';
import type { MissionsContent } from '@/blobbi/core/lib/missions';
import { totalDailyXp } from '../lib/daily-missions';
// ─── Types ────────────────────────────────────────────────────────────────────
export interface ClaimMissionRequest {
missionId: string;
export interface AwardDailyXpRequest {
/** Current missions state to calculate XP from */
missions: MissionsContent;
}
/** 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);
}
export interface AwardDailyXpResult {
xpAwarded: number;
newTotalXp: number;
}
// ─── 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
* Hook to award XP for completed daily missions.
*
* @param updateProfileEvent - Callback to update profile in query cache
*/
export function useClaimMissionReward(
currentProfile: BlobbonautProfile | null,
updateProfileEvent: (event: import('@nostrify/nostrify').NostrEvent) => void
export function useAwardDailyXp(
updateProfileEvent: (event: import('@nostrify/nostrify').NostrEvent) => void,
) {
const { user } = useCurrentUser();
const { nostr } = useNostr();
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');
}
mutationFn: async ({ missions }: AwardDailyXpRequest): Promise<AwardDailyXpResult> => {
if (!user?.pubkey) throw new Error('Must be logged in');
if (!currentProfile) {
throw new Error('Profile not found');
}
const xpToAward = totalDailyXp(missions);
if (xpToAward <= 0) return { xpAwarded: 0, newTotalXp: 0 };
// 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(),
// Fetch fresh profile from relays to avoid stale-read overwrites
const prev = await fetchFreshEvent(nostr, {
kinds: [KIND_BLOBBONAUT_PROFILE],
authors: [user.pubkey],
});
// Publish updated profile event to kind 11125
const event = await publishEvent({
kind: KIND_BLOBBONAUT_PROFILE,
content: '',
tags: updatedTags,
});
const freshProfile = prev ? parseBlobbonautEvent(prev) : undefined;
const currentXp = freshProfile?.xp ?? 0;
const newTotalXp = currentXp + xpToAward;
// 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
// Update XP and level tags on the fresh event's tags
const updatedTags = updateBlobbonautTags(
prev?.tags ?? [],
buildXpTagUpdates(newTotalXp),
);
const updatedState: DailyMissionsState = {
...missionsState!,
missions: updatedMissions,
totalCoinsEarned: missionsState!.totalCoinsEarned + coinsToAdd,
};
// Persist missions state to content field
const content = serializeProfileContent(
prev?.content ?? '',
{ missions },
);
writeMissionsState(updatedState);
const event = await publishEvent({
kind: KIND_BLOBBONAUT_PROFILE,
content,
tags: updatedTags,
prev: prev ?? undefined,
});
// Dispatch event for React components to re-render
window.dispatchEvent(new CustomEvent('daily-missions-updated', {
detail: { missionId, claimed: true }
}));
updateProfileEvent(event);
return {
missionId,
coinsEarned: coinsToAdd,
newTotalCoins,
};
return { xpAwarded: xpToAward, newTotalXp };
},
onSuccess: ({ coinsEarned }) => {
// Invalidate profile query to ensure fresh data
onSuccess: ({ xpAwarded }) => {
if (user?.pubkey) {
queryClient.invalidateQueries({ queryKey: ['blobbonaut-profile', user.pubkey] });
}
// Show success toast
toast({
title: 'Reward Claimed!',
description: `You earned ${coinsEarned} coins.`,
});
if (xpAwarded > 0) {
toast({
title: 'XP Earned!',
description: `You earned ${xpAwarded} XP from daily missions.`,
});
}
},
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',
title: 'Failed to Award XP',
description: error.message,
variant: 'destructive',
});
},
});
}
// Legacy export name for backward compatibility during migration
export const useClaimMissionReward = useAwardDailyXp;
export type ClaimMissionRequest = AwardDailyXpRequest;
export type ClaimMissionResult = AwardDailyXpResult;
+157 -149
View File
@@ -1,201 +1,209 @@
/**
* 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.
* useDailyMissions - Hook for reading daily mission state
*
* Provides reactive access to the current day's missions.
* Progress tracking is done via the tracker module (non-React).
* Completion is implicit (derived from count/events vs target).
* XP is awarded automatically when missions complete.
*
* State lives in a pubkey-scoped in-memory Map. On mount or account
* switch, hydrates from kind 11125 content JSON if the session store
* is empty. Completed missions are persisted by `useAwardDailyXp`;
* intermediate progress resets on page refresh.
*/
import { useMemo, useEffect, useState, useCallback } from 'react';
import { useMemo, useEffect, useState, useCallback, useRef } from 'react';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import type { MissionsContent } from '@/blobbi/core/lib/missions';
import { isMissionComplete, missionProgress } from '@/blobbi/core/lib/missions';
import { parseProfileContent } from '@/blobbi/core/lib/missions';
import {
type DailyMissionsState,
type DailyMission,
type BlobbiStage,
type DailyMissionAction,
getTodayDateString,
needsDailyReset,
createDailyMissionsState,
areAllMissionsCompleted,
areAllMissionsClaimed,
getTotalPotentialReward,
getTodayClaimedReward,
isBonusMissionAvailable,
isBonusMissionClaimed,
BONUS_MISSION_DEFINITION,
getRerollsRemaining,
createDailyMissionsContent,
areAllDailyComplete,
totalDailyXp,
getDefinition,
MAX_DAILY_REROLLS,
DAILY_BONUS_XP,
} from '../lib/daily-missions';
import {
readMissionsFromStorage,
writeMissionsToStorage,
hydrateFromPersisted,
} from '../lib/daily-mission-tracker';
// ─── Types ────────────────────────────────────────────────────────────────────
export interface DailyMissionView {
/** Mission ID (matches pool definition) */
id: string;
/** Display title */
title: string;
/** Description */
description: string;
/** Action type */
action: DailyMissionAction;
/** Required count */
target: number;
/** Current progress */
progress: number;
/** Whether mission is complete */
complete: boolean;
/** XP reward */
xp: number;
}
export interface UseDailyMissionsOptions {
/** Available Blobbi stages the user has (filters eligible missions) */
availableStages?: BlobbiStage[];
/**
* Raw content string from the kind 11125 profile event.
* Pass `profile.content` here. The hook parses it to extract
* persisted missions and hydrates the session store on first load.
*/
profileContent?: string;
}
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) */
/** Today's daily missions with computed progress */
missions: DailyMissionView[];
/** The raw missions content (for persistence/mutation hooks) */
raw: MissionsContent | undefined;
/** Whether all daily missions are complete */
allComplete: boolean;
/** Total XP earned today (completed missions + bonus) */
todayXp: number;
/** Whether the daily bonus is unlocked (all missions complete) */
bonusUnlocked: boolean;
/** Bonus XP amount */
bonusXp: number;
/** Whether user has no eligible missions */
noMissionsAvailable: boolean;
/** Number of rerolls remaining for today */
/** Rerolls remaining today */
rerollsRemaining: number;
/** Maximum rerolls allowed per day */
/** Max rerolls per day */
maxRerolls: number;
/** Force refresh missions (for testing or manual reset) */
/** Force refresh missions (testing) */
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 { availableStages, profileContent } = options;
const { user } = useCurrentUser();
const pubkey = user?.pubkey;
// Read state directly from localStorage, with a version counter to trigger re-reads
// Version counter to trigger re-reads from session store
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
// Track whether we've hydrated for this pubkey
const hydratedRef = useRef<string | null>(null);
// Hydrate session store from kind 11125 content on mount / account switch
useEffect(() => {
const handleExternalUpdate = () => {
// Bump version to trigger a re-read from localStorage
setVersion((v) => v + 1);
};
if (!pubkey || !profileContent) return;
if (hydratedRef.current === pubkey) return; // already hydrated this session
window.addEventListener('daily-missions-updated', handleExternalUpdate);
return () => window.removeEventListener('daily-missions-updated', handleExternalUpdate);
// Check if session store already has data for this pubkey
const existing = readMissionsFromStorage(pubkey);
if (existing) {
hydratedRef.current = pubkey;
return;
}
// Parse persisted missions from profile content
const parsed = parseProfileContent(profileContent);
if (parsed.missions && !needsDailyReset(parsed.missions)) {
hydrateFromPersisted(parsed.missions, pubkey);
hydratedRef.current = pubkey;
setVersion((v) => v + 1);
} else {
hydratedRef.current = pubkey;
}
}, [pubkey, profileContent]);
// Listen for tracker events
useEffect(() => {
const handler = () => setVersion((v) => v + 1);
window.addEventListener('daily-missions-updated', handler);
return () => window.removeEventListener('daily-missions-updated', handler);
}, []);
// Stable key for availableStages to use in dependencies
// Stable stages key for deps
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!;
// Read and ensure current state
const raw = useMemo((): MissionsContent | undefined => {
const stored = readMissionsFromStorage(pubkey);
if (!needsDailyReset(stored)) return stored;
// Reset for new day, preserve evolution missions
const fresh = createDailyMissionsContent(
getTodayDateString(),
stored?.evolution ?? [],
pubkey,
availableStages,
);
writeMissionsToStorage(fresh, pubkey);
return fresh;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state, pubkey, stagesKey]);
}, [version, pubkey, stagesKey]);
// Force reset missions (for testing)
const forceReset = () => {
const previousCoins = state?.totalCoinsEarned ?? 0;
const newState = createDailyMissionsState(getTodayDateString(), pubkey, previousCoins, availableStages);
setState(newState);
};
// Build view models
const missions: DailyMissionView[] = useMemo(() => {
if (!raw?.daily) return [];
return raw.daily.map((m) => {
const def = getDefinition(m.id);
return {
id: m.id,
title: def?.title ?? m.id,
description: def?.description ?? '',
action: def?.action ?? 'interact',
target: m.target,
progress: missionProgress(m),
complete: isMissionComplete(m),
xp: def?.xp ?? 0,
};
});
}, [raw]);
// 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 allComplete = raw ? areAllDailyComplete(raw) : false;
const todayXp = raw ? totalDailyXp(raw) : 0;
const bonusUnlocked = allComplete;
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;
const rerollsRemaining = raw?.rerolls ?? MAX_DAILY_REROLLS;
const forceReset = useCallback(() => {
const fresh = createDailyMissionsContent(
getTodayDateString(),
raw?.evolution ?? [],
pubkey,
availableStages,
);
writeMissionsToStorage(fresh, pubkey);
setVersion((v) => v + 1);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pubkey, stagesKey, raw?.evolution]);
return {
missions,
allCompleted,
allClaimed,
totalPotentialReward,
todayClaimedReward,
lifetimeCoinsEarned,
bonusAvailable,
bonusClaimed,
bonusReward,
raw,
allComplete,
todayXp,
bonusUnlocked,
bonusXp: DAILY_BONUS_XP,
noMissionsAvailable,
rerollsRemaining,
maxRerolls,
maxRerolls: MAX_DAILY_REROLLS,
forceReset,
};
}
@@ -0,0 +1,42 @@
/**
* useItemCooldown — React hook for per-item cooldown state.
*
* Subscribes to the shared item-cooldown singleton so components
* re-render when any item's cooldown starts or expires.
*
* Usage:
* ```tsx
* const { isOnCooldown } = useItemCooldown();
* <Button disabled={isOnCooldown(item.id)}>Use</Button>
* ```
*/
import { useCallback, useSyncExternalStore } from 'react';
import { isItemOnCooldown, subscribeCooldowns } from '../lib/item-cooldown';
/** Monotonic version counter bumped by the subscription callback. */
let snapshotVersion = 0;
function subscribe(onStoreChange: () => void): () => void {
// subscribeCooldowns returns an unsubscribe function.
// The callback bumps the version AND notifies React.
return subscribeCooldowns(() => {
snapshotVersion++;
onStoreChange();
});
}
function getSnapshot(): number {
return snapshotVersion;
}
export function useItemCooldown() {
useSyncExternalStore(subscribe, getSnapshot);
const isOnCooldown = useCallback((itemId: string): boolean => {
return isItemOnCooldown(itemId);
}, []);
return { isOnCooldown };
}
+31 -107
View File
@@ -1,11 +1,7 @@
/**
* 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
* useRerollMission - Replace a daily mission with a new one from the pool
*
* Updates the in-memory session store.
*/
import { useMutation } from '@tanstack/react-query';
@@ -13,17 +9,12 @@ import { useMutation } from '@tanstack/react-query';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { toast } from '@/hooks/useToast';
import type { BlobbiStage } from '../lib/daily-missions';
import { rerollMission, getDefinition } from '../lib/daily-missions';
import {
type DailyMissionsState,
type DailyMission,
type BlobbiStage,
getTodayDateString,
needsDailyReset,
createDailyMissionsState,
rerollMission,
canRerollMission,
getRerollsRemaining,
} from '../lib/daily-missions';
readMissionsFromStorage,
writeMissionsToStorage,
} from '../lib/daily-mission-tracker';
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -34,118 +25,51 @@ export interface RerollMissionRequest {
export interface RerollMissionResult {
oldMissionId: string;
newMission: DailyMission;
newMissionId: string;
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');
}
if (!user?.pubkey) throw new Error('Must be logged in');
// 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);
}
const current = readMissionsFromStorage(user.pubkey);
if (!current) throw new Error('No missions state');
// 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');
}
const updated = rerollMission(current, missionId, availableStages);
if (!updated) 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.');
}
writeMissionsToStorage(updated, user.pubkey);
// 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,
}
// Notify React
window.dispatchEvent(new CustomEvent('daily-missions-updated', {
detail: { missionId, rerolled: true },
}));
// Find the new mission ID at the same index
const oldIdx = current.daily.findIndex((m) => m.id === missionId);
const newMissionId = updated.daily[oldIdx]?.id ?? missionId;
return {
oldMissionId: missionId,
newMission: result.newMission,
rerollsRemaining: getRerollsRemaining(result.state),
newMissionId,
rerollsRemaining: updated.rerolls,
};
},
onSuccess: ({ newMission, rerollsRemaining }) => {
const rerollText = rerollsRemaining === 1
? '1 reroll left'
: rerollsRemaining === 0
? 'No rerolls left'
: `${rerollsRemaining} rerolls left`;
onSuccess: ({ newMissionId, rerollsRemaining }) => {
const def = getDefinition(newMissionId);
const rerollText = rerollsRemaining === 0
? 'No rerolls left'
: `${rerollsRemaining} reroll${rerollsRemaining === 1 ? '' : 's'} left`;
toast({
title: 'Mission Replaced',
description: `New mission: ${newMission.title}. ${rerollText}.`,
description: `New mission: ${def?.title ?? newMissionId}. ${rerollText}.`,
});
},
onError: (error: Error) => {
+50 -5
View File
@@ -155,20 +155,65 @@ export {
// 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 type { DailyMissionView, UseDailyMissionsResult } from './hooks/useDailyMissions';
export { useAwardDailyXp, useClaimMissionReward } from './hooks/useClaimMissionReward';
export type { AwardDailyXpRequest, AwardDailyXpResult, ClaimMissionRequest, ClaimMissionResult } from './hooks/useClaimMissionReward';
export { useRerollMission } from './hooks/useRerollMission';
export type { RerollMissionRequest, RerollMissionResult } from './hooks/useRerollMission';
export {
trackDailyMissionProgress,
trackDailyMissionEvent,
trackMultipleDailyMissionActions,
} from './lib/daily-mission-tracker';
export type {
DailyMission,
DailyMissionAction,
DailyMissionDefinition,
DailyMissionsState,
Mission,
TallyMission,
EventMission,
MissionsContent,
} from './lib/daily-missions';
// Progression
export {
xpToLevel,
levelToXp,
xpProgress,
xpToNextLevel,
getUnlocks,
buildXpTagUpdates,
MAX_LEVEL,
} from '@/blobbi/core/lib/progression';
export type { Unlocks } from '@/blobbi/core/lib/progression';
// Missions content model
export {
parseProfileContent,
serializeProfileContent,
isMissionComplete,
isTallyMission,
isEventMission,
missionProgress,
} from '@/blobbi/core/lib/missions';
export type { ProfileContent } from '@/blobbi/core/lib/missions';
// Item cooldown
export { isItemOnCooldown, setItemCooldown, subscribeCooldowns } from './lib/item-cooldown';
export { ITEM_COOLDOWN_SUCCESS_MS, ITEM_COOLDOWN_FAILURE_MS } from './lib/item-cooldown';
export { useItemCooldown } from './hooks/useItemCooldown';
// Action XP
export {
ACTION_XP,
INVENTORY_ACTION_XP,
DIRECT_ACTION_XP,
POOP_CLEANUP_XP,
calculateActionXP,
calculateInventoryActionXP,
applyXPGain,
formatXPGain,
} from './lib/blobbi-xp';
// Streak tracking
export {
calculateStreakUpdate,
+5
View File
@@ -44,6 +44,11 @@ export const ACTION_XP: Record<BlobbiAction, number> = {
...DIRECT_ACTION_XP,
};
/**
* XP awarded for cleaning up poop.
*/
export const POOP_CLEANUP_XP = 5;
// ─── XP Calculation Utilities ─────────────────────────────────────────────────
/**
+79 -73
View File
@@ -1,109 +1,115 @@
/**
* 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.
*
* Provides a way to record daily mission progress from anywhere
* (hooks, event handlers, etc.) without requiring React context.
*
* Uses a pubkey-scoped in-memory Map. Kind 11125 content JSON is the
* persistent source of truth. Completed missions are persisted by
* `useAwardDailyXp`; intermediate progress resets on page refresh.
*
* Dispatches 'daily-missions-updated' CustomEvent so React hooks re-render.
*/
import type { MissionsContent } from '@/blobbi/core/lib/missions';
import type { DailyMissionAction } from './daily-missions';
import {
type DailyMissionsState,
type DailyMissionAction,
getTodayDateString,
needsDailyReset,
createDailyMissionsState,
updateMissionProgress,
createDailyMissionsContent,
trackTally,
trackEvent,
} from './daily-missions';
// ─── Constants ────────────────────────────────────────────────────────────────
const STORAGE_KEY = 'blobbi:daily-missions';
// ─── Storage Utilities ────────────────────────────────────────────────────────
// ─── In-Memory Session Store ──────────────────────────────────────────────────
/**
* Read the current daily missions state from localStorage
* Pubkey-scoped session cache. Each logged-in user gets their own entry.
* Cleared on page refresh (intentional — kind 11125 is the persistent store).
*/
function readState(): DailyMissionsState | null {
try {
const stored = localStorage.getItem(STORAGE_KEY);
return stored ? JSON.parse(stored) : null;
} catch {
return null;
}
const sessionStore = new Map<string, MissionsContent>();
function key(pubkey: string | undefined): string {
return pubkey ?? '';
}
/**
* 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);
}
function ensureCurrent(pubkey?: string): MissionsContent {
const current = sessionStore.get(key(pubkey));
if (!needsDailyReset(current)) return current!;
const fresh = createDailyMissionsContent(
getTodayDateString(),
current?.evolution ?? [],
pubkey,
);
sessionStore.set(key(pubkey), fresh);
return fresh;
}
/**
* 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!;
function notify(detail?: Record<string, unknown>): void {
window.dispatchEvent(new CustomEvent('daily-missions-updated', { detail }));
}
// ─── 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
* Record a tally-based action (feed, clean, interact, etc.).
*/
export function trackDailyMissionProgress(
action: DailyMissionAction,
count: number = 1,
pubkey?: string
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 } }));
const current = ensureCurrent(pubkey);
const updated = trackTally(current, action, count);
sessionStore.set(key(pubkey), updated);
notify({ 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
* Record an event-based action (take_photo, etc.) with its Nostr event ID.
*/
export function trackDailyMissionEvent(
action: DailyMissionAction,
eventId: string,
pubkey?: string,
): void {
const current = ensureCurrent(pubkey);
const updated = trackEvent(current, action, eventId);
sessionStore.set(key(pubkey), updated);
notify({ action, eventId });
}
/**
* Track multiple tally actions at once.
*/
export function trackMultipleDailyMissionActions(
actions: DailyMissionAction[],
pubkey?: string
pubkey?: string,
): void {
let current = ensureCurrentState(pubkey);
let current = ensureCurrent(pubkey);
for (const action of actions) {
current = updateMissionProgress(current, action, 1);
current = trackTally(current, action, 1);
}
writeState(current);
window.dispatchEvent(new CustomEvent('daily-missions-updated', { detail: { actions } }));
sessionStore.set(key(pubkey), current);
notify({ actions });
}
/** Read current session state for a pubkey. */
export function readMissionsFromStorage(pubkey?: string): MissionsContent | undefined {
return sessionStore.get(key(pubkey));
}
/** Write state to session store for a pubkey. */
export function writeMissionsToStorage(missions: MissionsContent, pubkey?: string): void {
sessionStore.set(key(pubkey), missions);
}
/**
* Hydrate the session store from kind 11125 persisted data.
* Called once on mount / account switch when the session store is empty.
* No-op if the store already has data for this pubkey.
*/
export function hydrateFromPersisted(missions: MissionsContent, pubkey: string): void {
if (sessionStore.has(pubkey)) return;
sessionStore.set(pubkey, missions);
}
+267 -522
View File
@@ -1,36 +1,45 @@
/**
* 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.
*
* Defines the daily mission pool, selection logic, and state management.
* Missions use the tally/event model from missions.ts:
* - Tally missions: { id, target, count }
* - Event missions: { id, target, events }
* Completion is derived: count >= target or events.length >= target.
* No explicit completed/claimed flags.
*/
import type { Mission, TallyMission, EventMission, MissionsContent } from '@/blobbi/core/lib/missions';
import { isTallyMission, isEventMission, isMissionComplete } from '@/blobbi/core/lib/missions';
// ─── Types ────────────────────────────────────────────────────────────────────
/**
* Mission action types that can trigger progress
* Actions that can trigger daily mission progress.
* Tally actions increment a counter. Event actions append an event ID.
*/
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
export type DailyMissionAction =
| 'interact' // Any care interaction (tally)
| 'feed' // Feeding action (tally)
| 'clean' // Cleaning action (tally)
| 'sing' // Sing direct action (tally)
| 'play_music' // Play music direct action (tally)
| 'sleep' // Put Blobbi to sleep (tally)
| 'take_photo' // Take a photo (event)
| 'medicine'; // Give medicine (tally)
/**
* Blobbi stage type for filtering missions
*/
/** Whether a mission action tracks events or tallies */
export type MissionTrackingType = 'tally' | 'event';
/** Blobbi stage type for filtering missions */
export type BlobbiStage = 'egg' | 'baby' | 'adult';
/**
* Definition of a daily mission in the pool
* Definition of a daily mission in the pool.
* This is the static template -- not the runtime state.
*/
export interface DailyMissionDefinition {
/** Unique identifier for this mission type */
/** Unique identifier */
id: string;
/** Display title */
title: string;
@@ -39,277 +48,160 @@ export interface DailyMissionDefinition {
/** 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) */
target: number;
/** Whether this mission tracks events or tallies */
tracking: MissionTrackingType;
/** XP reward for completing this mission */
xp: number;
/** Selection weight (higher = more likely) */
weight: number;
/** Required stages to show this mission (if empty/undefined, requires baby or adult) */
/** Required stages to show this mission */
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;
/** Number of daily missions selected each day */
export const DAILY_MISSION_COUNT = 3;
/** XP bonus for completing all daily missions */
export const DAILY_BONUS_XP = 50;
// ─── 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) ───────────────────────────────────
// ── Baby/Adult only ──────────────────────────────────────────────────────
{
id: 'interact_3',
title: 'Quick Care',
id: 'interact_3', title: 'Quick Care',
description: 'Interact with your Blobbi 3 times',
action: 'interact',
requiredCount: 3,
reward: 30,
weight: 10,
action: 'interact', target: 3, tracking: 'tally', xp: 15, weight: 10,
requiredStages: ['baby', 'adult'],
},
{
id: 'interact_6',
title: 'Attentive Caretaker',
id: 'interact_6', title: 'Attentive Caretaker',
description: 'Interact with your Blobbi 6 times',
action: 'interact',
requiredCount: 6,
reward: 50,
weight: 8,
action: 'interact', target: 6, tracking: 'tally', xp: 30, weight: 8,
requiredStages: ['baby', 'adult'],
},
// ─── Feed Missions (Baby/Adult only) ───────────────────────────────────────
{
id: 'feed_1',
title: 'Snack Time',
id: 'feed_1', title: 'Snack Time',
description: 'Feed your Blobbi once',
action: 'feed',
requiredCount: 1,
reward: 25,
weight: 10,
action: 'feed', target: 1, tracking: 'tally', xp: 10, weight: 10,
requiredStages: ['baby', 'adult'],
},
{
id: 'feed_2',
title: 'Hungry Blobbi',
id: 'feed_2', title: 'Hungry Blobbi',
description: 'Feed your Blobbi 2 times',
action: 'feed',
requiredCount: 2,
reward: 45,
weight: 8,
action: 'feed', target: 2, tracking: 'tally', xp: 20, weight: 8,
requiredStages: ['baby', 'adult'],
},
{
id: 'feed_3',
title: 'Feast Day',
id: 'feed_3', title: 'Feast Day',
description: 'Feed your Blobbi 3 times',
action: 'feed',
requiredCount: 3,
reward: 60,
weight: 5,
action: 'feed', target: 3, tracking: 'tally', xp: 35, weight: 5,
requiredStages: ['baby', 'adult'],
},
// ─── Sleep Missions (Baby/Adult only) ──────────────────────────────────────
{
id: 'sleep_1',
title: 'Nap Time',
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,
action: 'sleep', target: 1, tracking: 'tally', xp: 15, weight: 6,
requiredStages: ['baby', 'adult'],
},
{
id: 'take_photo_2',
title: 'Photo Album',
id: 'take_photo_1', title: 'Snapshot',
description: 'Take a photo of your Blobbi',
action: 'take_photo', target: 1, tracking: 'event', xp: 25, 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,
action: 'take_photo', target: 2, tracking: 'event', xp: 40, weight: 2,
requiredStages: ['baby', 'adult'],
},
// ═══════════════════════════════════════════════════════════════════════════
// EGG + BABY + ADULT MISSIONS
// These actions are available for ALL stages including eggs
// ═══════════════════════════════════════════════════════════════════════════
// ─── Clean Missions (All stages) ───────────────────────────────────────────
// ── All stages ───────────────────────────────────────────────────────────
{
id: 'clean_1',
title: 'Quick Cleanup',
id: 'clean_1', title: 'Quick Cleanup',
description: 'Clean your Blobbi once',
action: 'clean',
requiredCount: 1,
reward: 25,
weight: 10,
action: 'clean', target: 1, tracking: 'tally', xp: 10, weight: 10,
requiredStages: ['egg', 'baby', 'adult'],
},
{
id: 'clean_2',
title: 'Squeaky Clean',
id: 'clean_2', title: 'Squeaky Clean',
description: 'Clean your Blobbi 2 times',
action: 'clean',
requiredCount: 2,
reward: 45,
weight: 6,
action: 'clean', target: 2, tracking: 'tally', xp: 20, weight: 6,
requiredStages: ['egg', 'baby', 'adult'],
},
// ─── Sing Missions (All stages) ────────────────────────────────────────────
{
id: 'sing_1',
title: 'Sing Along',
id: 'sing_1', title: 'Sing Along',
description: 'Sing a song to your Blobbi',
action: 'sing',
requiredCount: 1,
reward: 30,
weight: 6,
action: 'sing', target: 1, tracking: 'tally', xp: 15, weight: 6,
requiredStages: ['egg', 'baby', 'adult'],
},
{
id: 'sing_2',
title: 'Karaoke Session',
id: 'sing_2', title: 'Karaoke Session',
description: 'Sing 2 songs to your Blobbi',
action: 'sing',
requiredCount: 2,
reward: 50,
weight: 3,
action: 'sing', target: 2, tracking: 'tally', xp: 25, weight: 3,
requiredStages: ['egg', 'baby', 'adult'],
},
// ─── Play Music Missions (All stages) ──────────────────────────────────────
{
id: 'play_music_1',
title: 'DJ Time',
id: 'play_music_1', title: 'DJ Time',
description: 'Play a song for your Blobbi',
action: 'play_music',
requiredCount: 1,
reward: 30,
weight: 6,
action: 'play_music', target: 1, tracking: 'tally', xp: 15, weight: 6,
requiredStages: ['egg', 'baby', 'adult'],
},
{
id: 'play_music_2',
title: 'Music Marathon',
id: 'play_music_2', title: 'Music Marathon',
description: 'Play 2 songs for your Blobbi',
action: 'play_music',
requiredCount: 2,
reward: 50,
weight: 3,
action: 'play_music', target: 2, tracking: 'tally', xp: 25, 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',
id: 'medicine_1', title: 'Health Check',
description: 'Give medicine to your Blobbi',
action: 'medicine',
requiredCount: 1,
reward: 60,
weight: 5,
action: 'medicine', target: 1, tracking: 'tally', xp: 20, weight: 5,
requiredStages: ['egg', 'baby', 'adult'],
},
{
id: 'medicine_2',
title: 'Doctor Visit',
id: 'medicine_2', title: 'Doctor Visit',
description: 'Give medicine to your Blobbi 2 times',
action: 'medicine',
requiredCount: 2,
reward: 70,
weight: 3,
action: 'medicine', target: 2, tracking: 'tally', xp: 35, weight: 3,
requiredStages: ['egg', 'baby', 'adult'],
},
];
// ─── Utility Functions ────────────────────────────────────────────────────────
// ─── Lookup ──────────────────────────────────────────────────────────────────
/**
* Get the current date string in YYYY-MM-DD format (local timezone)
*/
const POOL_BY_ID = new Map(DAILY_MISSION_POOL.map((d) => [d.id, d]));
/** Look up a mission definition by ID */
export function getDefinition(id: string): DailyMissionDefinition | undefined {
return POOL_BY_ID.get(id);
}
// ─── Date Utilities ──────────────────────────────────────────────────────────
/** YYYY-MM-DD in 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);
/** Whether the missions content needs a daily reset */
export function needsDailyReset(missions: MissionsContent | undefined): boolean {
if (!missions) return true;
return missions.date !== getTodayDateString();
}
/**
* Seeded random number generator (Mulberry32)
*/
// ─── Selection ───────────────────────────────────────────────────────────────
/** Seeded PRNG (Mulberry32) */
function seededRandom(seed: number): () => number {
return function() {
return function () {
let t = seed += 0x6D2B79F5;
t = Math.imul(t ^ t >>> 15, t | 1);
t ^= t + Math.imul(t ^ t >>> 7, t | 61);
@@ -317,392 +209,245 @@ function seededRandom(seed: number): () => number {
};
}
/**
* 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));
function generateDailySeed(dateString: string, pubkey?: string): number {
const input = pubkey ? `${dateString}:${pubkey}` : dateString;
let hash = 0;
for (let i = 0; i < input.length; i++) {
hash = ((hash << 5) - hash) + input.charCodeAt(i);
hash = hash & hash;
}
return Math.abs(hash);
}
function isMissionAvailableForStages(def: DailyMissionDefinition, stages: BlobbiStage[]): boolean {
const required = def.requiredStages ?? ['baby', 'adult'];
return required.some((s) => stages.includes(s));
}
/**
* 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)
* Select N missions deterministically from the pool.
* Seeded by date + pubkey so the same user gets the same missions for a given day.
*/
export function selectDailyMissions(
count: number,
dateString: string,
pubkey?: string,
availableStages?: BlobbiStage[]
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 stages = availableStages ?? ['baby', 'adult'];
const eligible = DAILY_MISSION_POOL.filter((m) => isMissionAvailableForStages(m, stages));
if (eligible.length === 0) return [];
const random = seededRandom(generateDailySeed(dateString, pubkey));
const available = [...eligible];
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;
let idx = 0;
for (let i = 0; i < available.length; i++) {
pick -= available[i].weight;
if (pick <= 0) {
selectedIndex = i;
break;
}
if (pick <= 0) { idx = i; break; }
}
// Add to selected and remove from available
selected.push(available[selectedIndex]);
available.splice(selectedIndex, 1);
selected.push(available[idx]);
available.splice(idx, 1);
}
return selected;
}
/**
* Create a fresh DailyMission from a definition
*/
export function createMissionFromDefinition(def: DailyMissionDefinition): DailyMission {
return {
...def,
currentCount: 0,
completed: false,
claimed: false,
};
// ─── Mission Instantiation ───────────────────────────────────────────────────
/** Create a fresh Mission from a definition */
export function createMission(def: DailyMissionDefinition): Mission {
if (def.tracking === 'event') {
return { id: def.id, target: def.target, events: [] } satisfies EventMission;
}
return { id: def.id, target: def.target, count: 0 } satisfies TallyMission;
}
/**
* Create the initial daily missions state for a new day
*/
export function createDailyMissionsState(
/** Create a fresh MissionsContent for a new day, preserving evolution missions */
export function createDailyMissionsContent(
dateString: string,
existingEvolution: Mission[],
pubkey?: string,
previousTotalCoins: number = 0,
availableStages?: BlobbiStage[]
): DailyMissionsState {
const definitions = selectDailyMissions(3, dateString, pubkey, availableStages);
availableStages?: BlobbiStage[],
): MissionsContent {
const defs = selectDailyMissions(DAILY_MISSION_COUNT, dateString, pubkey, availableStages);
return {
date: dateString,
missions: definitions.map(createMissionFromDefinition),
totalCoinsEarned: previousTotalCoins,
rerollsRemaining: MAX_DAILY_REROLLS,
daily: defs.map(createMission),
evolution: existingEvolution,
rerolls: 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();
}
// ─── Progress Tracking ───────────────────────────────────────────────────────
/**
* Update mission progress for a given action
* Increment tally for all daily missions matching the given action.
* Returns a new missions content (immutable).
*/
export function updateMissionProgress(
state: DailyMissionsState,
export function trackTally(
missions: MissionsContent,
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,
};
incrementBy: number = 1,
): MissionsContent {
const updated = missions.daily.map((m) => {
const def = POOL_BY_ID.get(m.id);
if (!def || def.action !== action) return m;
if (!isTallyMission(m)) return m;
if (m.count >= m.target) return m; // already complete
return { ...m, count: Math.min(m.count + incrementBy, m.target) };
});
return {
...state,
missions: updatedMissions,
};
return { ...missions, daily: updated };
}
/**
* Claim reward for a completed mission
* Append an event ID to a daily mission.
* Deduplicates by event ID. Returns new missions content.
*/
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,
};
export function trackEvent(
missions: MissionsContent,
action: DailyMissionAction,
eventId: string,
): MissionsContent {
const updated = missions.daily.map((m) => {
const def = POOL_BY_ID.get(m.id);
if (!def || def.action !== action) return m;
if (!isEventMission(m)) return m;
if (m.events.length >= m.target) return m; // already complete
if (m.events.includes(eventId)) return m; // dedup
return { ...m, events: [...m.events, eventId] };
});
return {
state: {
...state,
missions: updatedMissions,
totalCoinsEarned: state.totalCoinsEarned + coinsEarned,
},
coinsEarned,
};
return { ...missions, daily: updated };
}
/**
* Get the total potential reward for all daily missions
* Track progress for an evolution mission by tally.
*/
export function getTotalPotentialReward(state: DailyMissionsState): number {
return state.missions.reduce((sum, m) => sum + m.reward, 0);
export function trackEvolutionTally(
missions: MissionsContent,
missionId: string,
incrementBy: number = 1,
): MissionsContent {
const updated = missions.evolution.map((m) => {
if (m.id !== missionId) return m;
if (!isTallyMission(m)) return m;
if (m.count >= m.target) return m;
return { ...m, count: Math.min(m.count + incrementBy, m.target) };
});
return { ...missions, evolution: updated };
}
/**
* Get the total claimed reward for today
* Append an event ID to an evolution mission.
*/
export function getTodayClaimedReward(state: DailyMissionsState): number {
return state.missions
.filter((m) => m.claimed)
.reduce((sum, m) => sum + m.reward, 0);
export function trackEvolutionEvent(
missions: MissionsContent,
missionId: string,
eventId: string,
): MissionsContent {
const updated = missions.evolution.map((m) => {
if (m.id !== missionId) return m;
if (!isEventMission(m)) return m;
if (m.events.length >= m.target) return m;
if (m.events.includes(eventId)) return m;
return { ...m, events: [...m.events, eventId] };
});
return { ...missions, evolution: updated };
}
/**
* Check if all daily missions are completed
*/
export function areAllMissionsCompleted(state: DailyMissionsState): boolean {
return state.missions.every((m) => m.completed);
// ─── Completion Queries ──────────────────────────────────────────────────────
/** Whether all daily missions are complete */
export function areAllDailyComplete(missions: MissionsContent): boolean {
return missions.daily.length > 0 && missions.daily.every(isMissionComplete);
}
/**
* Check if all daily missions are claimed
*/
export function areAllMissionsClaimed(state: DailyMissionsState): boolean {
return state.missions.every((m) => m.claimed);
/** Whether all evolution missions are complete */
export function areAllEvolutionComplete(missions: MissionsContent): boolean {
return missions.evolution.length > 0 && missions.evolution.every(isMissionComplete);
}
// ─── 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);
/** Total XP available from today's daily missions (including bonus if all complete) */
export function totalDailyXp(missions: MissionsContent): number {
const base = missions.daily.reduce((sum, m) => {
const def = POOL_BY_ID.get(m.id);
return sum + (def && isMissionComplete(m) ? def.xp : 0);
}, 0);
const bonus = areAllDailyComplete(missions) ? DAILY_BONUS_XP : 0;
return base + bonus;
}
/**
* Check if the bonus mission has been claimed today
*/
export function isBonusMissionClaimed(state: DailyMissionsState): boolean {
return state.bonusClaimed ?? false;
/** XP earned by a specific daily mission (0 if incomplete or unknown) */
export function missionXp(missionId: string, mission: Mission): number {
const def = POOL_BY_ID.get(missionId);
if (!def || !isMissionComplete(mission)) return 0;
return def.xp;
}
/**
* 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 ───────────────────────────────────────────────────────────
// ─── 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.
* Select a replacement mission not already in the current set.
* Uses Math.random (rerolls should feel random, not deterministic).
*/
export function selectReplacementMission(
currentMissions: DailyMission[],
missionToReplace: DailyMission,
availableStages?: BlobbiStage[]
currentMissions: Mission[],
missionToReplaceId: string,
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);
const stages = availableStages ?? ['baby', 'adult'];
const excludedIds = new Set(currentMissions.map((m) => m.id));
const eligible = DAILY_MISSION_POOL.filter((m) =>
m.id !== missionToReplaceId &&
!excludedIds.has(m.id) &&
isMissionAvailableForStages(m, stages),
);
if (eligible.length === 0) return null;
const totalWeight = eligible.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;
}
for (const def of eligible) {
pick -= def.weight;
if (pick <= 0) return def;
}
// Fallback to first eligible (shouldn't happen)
return eligibleMissions[0];
return eligible[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.
* Reroll a daily mission. Returns updated missions content or null if not possible.
*/
export function rerollMission(
state: DailyMissionsState,
missions: MissionsContent,
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;
availableStages?: BlobbiStage[],
): MissionsContent | null {
if (missions.rerolls <= 0) return null;
const idx = missions.daily.findIndex((m) => m.id === missionId);
if (idx === -1) return null;
const existing = missions.daily[idx];
if (isMissionComplete(existing)) return null; // can't reroll completed
const replacement = selectReplacementMission(missions.daily, missionId, availableStages);
if (!replacement) return null;
const updatedDaily = [...missions.daily];
updatedDaily[idx] = createMission(replacement);
return {
state: {
...state,
missions: updatedMissions,
rerollsRemaining: newRerollsRemaining,
},
newMission,
...missions,
daily: updatedDaily,
rerolls: missions.rerolls - 1,
};
}
// Re-export mission utilities for convenience
export { isTallyMission, isEventMission, isMissionComplete, missionProgress } from '@/blobbi/core/lib/missions';
export type { Mission, TallyMission, EventMission, MissionsContent } from '@/blobbi/core/lib/missions';
+68
View File
@@ -0,0 +1,68 @@
/**
* Centralized item-use cooldown tracking.
*
* Module-level singleton shared by every item-use path
* (dashboard, companion layer, shop modal, falling items).
*
* Keyed by item type ID (e.g. "food_apple"), not instance IDs.
* Separate durations for success (short) and failure (longer).
* Built-in subscriber system for React via useSyncExternalStore.
*/
// ─── Configuration ────────────────────────────────────────────────────────────
/** Cooldown after a successful item use (ms). */
export const ITEM_COOLDOWN_SUCCESS_MS = 400;
/** Cooldown after a failed item use (ms). */
export const ITEM_COOLDOWN_FAILURE_MS = 2000;
// ─── Singleton State ──────────────────────────────────────────────────────────
interface CooldownEntry {
expiresAt: number;
timerId: ReturnType<typeof setTimeout>;
}
const cooldowns = new Map<string, CooldownEntry>();
const subscribers = new Set<() => void>();
function notify(): void {
subscribers.forEach((cb) => cb());
}
// ─── Public API ───────────────────────────────────────────────────────────────
/** Check whether an item is currently on cooldown. */
export function isItemOnCooldown(itemId: string): boolean {
const entry = cooldowns.get(itemId);
if (!entry) return false;
if (Date.now() >= entry.expiresAt) {
clearTimeout(entry.timerId);
cooldowns.delete(itemId);
return false;
}
return true;
}
/** Put an item on cooldown. Notifies subscribers on start and expiry. */
export function setItemCooldown(itemId: string, success: boolean): void {
const prev = cooldowns.get(itemId);
if (prev) clearTimeout(prev.timerId);
const ms = success ? ITEM_COOLDOWN_SUCCESS_MS : ITEM_COOLDOWN_FAILURE_MS;
const timerId = setTimeout(() => {
cooldowns.delete(itemId);
notify();
}, ms);
cooldowns.set(itemId, { expiresAt: Date.now() + ms, timerId });
notify();
}
/** Subscribe to cooldown state changes. Returns unsubscribe function. */
export function subscribeCooldowns(callback: () => void): () => void {
subscribers.add(callback);
return () => { subscribers.delete(callback); };
}
+1 -1
View File
@@ -107,7 +107,7 @@ export const DEFAULT_COMPANION_CONFIG: CompanionConfig = {
pause2Duration: 100, // Short pause before falling
// Truly stuck behavior
trulyStuckChance: 0.30, // 30% chance to be truly stuck (needs user drag)
trulyStuckChance: 0.10, // 10% chance to be truly stuck (needs user drag)
fallDuration: 450, // Fall after getting loose
landingDuration: 200, // Brief squash on landing
@@ -79,6 +79,11 @@ export interface EnsureCanonicalResult {
* to avoid restoring stale/legacy values after migration.
*/
profileAllTags: string[][];
/**
* The previous profile event, for passing as `prev` to publishEvent
* to preserve `published_at` on replaceable events.
*/
profileEvent: NostrEvent;
/**
* The latest profile storage to use.
* Use this as the base for storage modifications.
@@ -347,6 +352,7 @@ export function useBlobbiMigration() {
allTags: migrationResult.event.tags,
content: migrationResult.event.content,
profileAllTags: migrationResult.profileTags,
profileEvent: migrationResult.profileEvent,
profileStorage: migrationResult.profileStorage,
};
}
@@ -358,6 +364,7 @@ export function useBlobbiMigration() {
allTags: companion.allTags,
content: companion.event.content,
profileAllTags: profile.allTags,
profileEvent: profile.event,
profileStorage: profile.storage,
};
}, [user?.pubkey, fetchFreshCompanion, fetchFreshProfile, migrateLegacyBlobbi]);
+16
View File
@@ -316,8 +316,16 @@ export interface BlobbonautProfile {
coins: number;
/** Petting level (interaction counter) */
pettingLevel: number;
/** Player lifetime XP (source of truth for progression) */
xp: number;
/** Player level (derived from xp, stored as queryable mirror) */
level: number;
/** Current room the player is in (persisted for cross-session continuity) */
room: string | undefined;
/** Purchased items storage */
storage: StorageItem[];
/** Raw content string for missions JSON */
content: string;
/** All tags preserved for republishing */
allTags: string[][];
}
@@ -982,7 +990,11 @@ export function parseBlobbonautEvent(event: NostrEvent): BlobbonautProfile | und
has: getTagValues(tags, 'has'),
coins: parseNumericTag(tags, 'coins') ?? 0,
pettingLevel: pettingLevelValue,
xp: parseNumericTag(tags, 'xp') ?? 0,
level: parseNumericTag(tags, 'level') ?? 1,
room: getTagValue(tags, 'room') ?? undefined,
storage: parseStorageTags(tags),
content: event.content,
allTags: tags,
};
}
@@ -1140,6 +1152,10 @@ export const DEPRECATED_BLOBBI_TAG_NAMES = new Set([
*/
export const MANAGED_BLOBBONAUT_PROFILE_TAG_NAMES = new Set([
'd', 'b', 'name', 'current_companion', 'blobbi_onboarding_done', 'onboarding_done', 'has', 'storage',
// Progression tags
'xp', 'level',
// Room persistence
'room',
// Legacy player progress tags (preserved for compatibility)
'coins', 'petting_level', 'pettingLevel', 'lifetime_blobbis', 'lifetimeBlobbis',
'starter_blobbi', 'starterBlobbi', 'favorite_blobbi', 'favoriteBlobbi',
+162
View File
@@ -0,0 +1,162 @@
/**
* Missions Content Model
*
* Defines the JSON shape stored in the kind 11125 content field.
* Two mission categories:
* - daily: reset each day, tally-based or event-based
* - evolution: persist across sessions until stage transition completes
*
* Tally missions track a `count` (no event IDs).
* Event missions track an `events` array of Nostr event IDs.
* Completion is derived: count >= target or events.length >= target.
*/
// ─── Mission Entry Types ─────────────────────────────────────────────────────
/** A mission tracked by a simple counter (feed, clean, interact, etc.) */
export interface TallyMission {
id: string;
target: number;
count: number;
}
/** A mission tracked by Nostr event IDs (post, photo, theme, etc.) */
export interface EventMission {
id: string;
target: number;
events: string[];
}
/** Union of both mission shapes */
export type Mission = TallyMission | EventMission;
/** Type guard: mission tracks events */
export function isEventMission(m: Mission): m is EventMission {
return 'events' in m;
}
/** Type guard: mission tracks a tally */
export function isTallyMission(m: Mission): m is TallyMission {
return 'count' in m;
}
/** Check if a mission is complete */
export function isMissionComplete(m: Mission): boolean {
if (isEventMission(m)) return m.events.length >= m.target;
return m.count >= m.target;
}
/** Get current progress numerator */
export function missionProgress(m: Mission): number {
if (isEventMission(m)) return m.events.length;
return m.count;
}
// ─── Content Shape ───────────────────────────────────────────────────────────
/** The full missions object stored in kind 11125 content JSON */
export interface MissionsContent {
date: string; // YYYY-MM-DD for daily reset detection
daily: Mission[]; // 3 daily missions, reset each day
evolution: Mission[]; // active evolution missions, cleared on stage transition
rerolls: number; // daily rerolls remaining (resets with date)
}
/**
* The top-level content JSON for kind 11125.
* Currently only `missions`. Future keys can be added alongside.
*/
export interface ProfileContent {
missions?: MissionsContent;
}
// ─── Parse / Serialize ───────────────────────────────────────────────────────
/**
* Parse the kind 11125 content field into a typed ProfileContent.
* Returns an empty object for empty/invalid content. Never throws.
*/
export function parseProfileContent(content: string): ProfileContent {
if (!content || !content.trim()) return {};
try {
const raw = JSON.parse(content);
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return {};
const result: ProfileContent = {};
if (raw.missions && typeof raw.missions === 'object') {
result.missions = parseMissionsContent(raw.missions);
}
return result;
} catch {
return {};
}
}
/**
* Serialize ProfileContent back to a JSON string for publishing.
* Preserves any unknown top-level keys from the existing content.
*/
export function serializeProfileContent(
existingContent: string,
updates: Partial<ProfileContent>,
): string {
let base: Record<string, unknown> = {};
if (existingContent && existingContent.trim()) {
try {
const parsed = JSON.parse(existingContent);
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
base = parsed;
}
} catch {
// corrupt content -- start fresh but don't lose updates
}
}
return JSON.stringify({ ...base, ...updates });
}
// ─── Internal Helpers ────────────────────────────────────────────────────────
function parseMissionsContent(raw: Record<string, unknown>): MissionsContent | undefined {
if (typeof raw.date !== 'string') return undefined;
return {
date: raw.date,
daily: parseMissionArray(raw.daily),
evolution: parseMissionArray(raw.evolution),
rerolls: typeof raw.rerolls === 'number' ? Math.max(0, Math.floor(raw.rerolls)) : 0,
};
}
function parseMissionArray(raw: unknown): Mission[] {
if (!Array.isArray(raw)) return [];
const result: Mission[] = [];
for (const entry of raw) {
const m = parseSingleMission(entry);
if (m) result.push(m);
}
return result;
}
function parseSingleMission(raw: unknown): Mission | undefined {
if (typeof raw !== 'object' || raw === null) return undefined;
const obj = raw as Record<string, unknown>;
if (typeof obj.id !== 'string' || typeof obj.target !== 'number') return undefined;
// Event-based mission
if (Array.isArray(obj.events)) {
return {
id: obj.id,
target: Math.max(1, Math.floor(obj.target)),
events: obj.events.filter((e): e is string => typeof e === 'string'),
};
}
// Tally-based mission
if (typeof obj.count === 'number') {
return {
id: obj.id,
target: Math.max(1, Math.floor(obj.target)),
count: Math.max(0, Math.floor(obj.count)),
};
}
return undefined;
}
+118
View File
@@ -0,0 +1,118 @@
/**
* Progression System
*
* Player-level XP and leveling. XP lives on kind 11125 as tags.
* Level is derived from XP. Unlocks are derived from level.
* No nested objects, no JSON content, no multi-game maps.
*/
// ─── XP Thresholds ───────────────────────────────────────────────────────────
/**
* Cumulative XP required to reach each level.
* Index 0 = level 1 (0 XP), index 1 = level 2 (100 XP), etc.
* Levels beyond the table cap at the last entry.
*/
const XP_THRESHOLDS: readonly number[] = [
0, // Level 1
100, // Level 2
250, // Level 3
500, // Level 4
850, // Level 5
1300, // Level 6
1900, // Level 7
2650, // Level 8
3600, // Level 9
4800, // Level 10
6300, // Level 11
8100, // Level 12
10200, // Level 13
12700, // Level 14
15600, // Level 15
19000, // Level 16
23000, // Level 17
27600, // Level 18
33000, // Level 19
39200, // Level 20
];
export const MAX_LEVEL = XP_THRESHOLDS.length;
// ─── Level Calculation ───────────────────────────────────────────────────────
/**
* Derive level from cumulative XP.
* Walks the threshold table to find the highest level the XP qualifies for.
*/
export function xpToLevel(xp: number): number {
const safeXp = Math.max(0, Math.floor(xp));
for (let i = XP_THRESHOLDS.length - 1; i >= 0; i--) {
if (safeXp >= XP_THRESHOLDS[i]) {
return i + 1; // levels are 1-indexed
}
}
return 1;
}
/**
* Get the cumulative XP required to reach a given level.
*/
export function levelToXp(level: number): number {
const idx = Math.max(0, Math.min(level - 1, XP_THRESHOLDS.length - 1));
return XP_THRESHOLDS[idx];
}
/**
* Get progress within the current level as a fraction [0, 1].
* Returns 1 at max level.
*/
export function xpProgress(xp: number): number {
const level = xpToLevel(xp);
if (level >= MAX_LEVEL) return 1;
const currentThreshold = XP_THRESHOLDS[level - 1];
const nextThreshold = XP_THRESHOLDS[level];
const range = nextThreshold - currentThreshold;
if (range <= 0) return 1;
return Math.min(1, (xp - currentThreshold) / range);
}
/**
* XP remaining to reach the next level. 0 at max level.
*/
export function xpToNextLevel(xp: number): number {
const level = xpToLevel(xp);
if (level >= MAX_LEVEL) return 0;
return XP_THRESHOLDS[level] - xp;
}
// ─── Unlocks ─────────────────────────────────────────────────────────────────
export interface Unlocks {
/** Maximum number of Blobbis the player can own */
maxBlobbis: number;
}
/**
* Derive unlocks from level. Pure function, no stored state.
*/
export function getUnlocks(level: number): Unlocks {
let maxBlobbis = 1;
if (level >= 5) maxBlobbis = 2;
if (level >= 10) maxBlobbis = 3;
if (level >= 15) maxBlobbis = 4;
if (level >= 20) maxBlobbis = 5;
return { maxBlobbis };
}
// ─── Tag Helpers ─────────────────────────────────────────────────────────────
/**
* Build XP and level tag updates for kind 11125.
* Level is always derived from XP -- never set independently.
*/
export function buildXpTagUpdates(xp: number): Record<string, string> {
return {
xp: Math.max(0, Math.floor(xp)).toString(),
level: xpToLevel(xp).toString(),
};
}
+3
View File
@@ -1,4 +1,5 @@
import React, { useState, useCallback, useEffect, useRef } from 'react';
import { impactLight } from '@/lib/haptics';
import type { EggVisualBlobbi } from '../types/egg.types';
import { isValidBaseColor, isValidSecondaryColor } from '../lib/blobbi-egg-validation';
import { SpecialMarkRenderer, SpecialMarkFallback } from './SpecialMarkRenderer';
@@ -184,10 +185,12 @@ export const EggGraphic: React.FC<EggGraphicProps> = ({
// Tour interactive steps: forward click to tour controller
if (onTourEggClick && (tourVisualState === 'glowing_waiting_click' || tourVisualState === 'crack_stage_1' || tourVisualState === 'crack_stage_2' || tourVisualState === 'crack_stage_3')) {
setIsTapWiggling(true);
impactLight();
onTourEggClick();
return;
}
if (isTapWiggling || cracking) return; // Don't re-trigger during animation or cracking
impactLight();
setIsTapWiggling(true);
}, [isTapWiggling, cracking, onTourEggClick, tourVisualState]);
@@ -18,6 +18,7 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAuthor } from '@/hooks/useAuthor';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { toast } from '@/hooks/useToast';
import { impactLight, impactMedium, impactHeavy, notificationSuccess } from '@/lib/haptics';
import { cn } from '@/lib/utils';
import { BlobbiStageVisual } from '@/blobbi/ui/BlobbiStageVisual';
@@ -412,15 +413,19 @@ export function BlobbiHatchingCeremony({
const handleEggClick = useCallback(() => {
if (phase === 'egg') {
triggerShake('animate-egg-onboard-shake-light');
impactLight();
setPhase('crack_1');
} else if (phase === 'crack_1') {
triggerShake('animate-egg-onboard-shake-medium');
impactMedium();
setPhase('crack_2');
} else if (phase === 'crack_2') {
triggerShake('animate-egg-onboard-shake-heavy');
impactHeavy();
setPhase('crack_3');
} else if (phase === 'crack_3') {
// Final click -> hatch!
notificationSuccess();
setPhase('hatching');
setShowFlash(true);
@@ -0,0 +1,299 @@
/**
* BlobbiRoomHero — Shared Blobbi visual display used in every room.
*
* Renders: stats crown (arced) + Blobbi visual + name.
* Does NOT clip or constrain — fills available flex space.
* Top padding accounts for the floating room header overlay.
*/
import { useMemo } from 'react';
import {
Utensils, Gamepad2, Heart, Droplets, Zap, AlertTriangle,
Footprints, Loader2,
} from 'lucide-react';
import { BlobbiStageVisual } from '@/blobbi/ui/BlobbiStageVisual';
import { getVisibleStats, getStatStatus } from '@/blobbi/core/lib/blobbi-decay';
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
import type { BlobbiEmotion } from '@/blobbi/ui/lib/emotion-types';
import type { BlobbiVisualRecipe } from '@/blobbi/ui/lib/recipe';
import type { BlobbiReactionState } from '@/blobbi/actions';
import type { BlobbiRoomId } from '../lib/room-config';
import { ROOM_META, DEFAULT_ROOM_ORDER, getRoomIndex } from '../lib/room-config';
import { cn } from '@/lib/utils';
// ─── Stat colour maps ─────────────────────────────────────────────────────────
const STAT_COLOR_MAP: Record<string, 'orange' | 'yellow' | 'green' | 'blue' | 'violet'> = {
hunger: 'orange',
happiness: 'yellow',
health: 'green',
hygiene: 'blue',
energy: 'violet',
};
const STAT_COLORS: Record<string, string> = {
orange: 'text-orange-500', yellow: 'text-yellow-500', green: 'text-green-500',
blue: 'text-blue-500', violet: 'text-violet-500',
};
const STAT_BG_COLORS: Record<string, string> = {
orange: 'bg-orange-500/10', yellow: 'bg-yellow-500/10', green: 'bg-green-500/10',
blue: 'bg-blue-500/10', violet: 'bg-violet-500/10',
};
const STAT_RING_HEX: Record<string, string> = {
orange: '#f97316', yellow: '#eab308', green: '#22c55e',
blue: '#3b82f6', violet: '#8b5cf6',
};
const STAT_ICON_MAP: Record<string, React.ComponentType<{ className?: string; strokeWidth?: number }>> = {
hunger: Utensils, happiness: Gamepad2, health: Heart, hygiene: Droplets, energy: Zap,
};
// ─── Props ────────────────────────────────────────────────────────────────────
export interface BlobbiRoomHeroProps {
companion: BlobbiCompanion;
currentStats: {
hunger: number;
happiness: number;
health: number;
hygiene: number;
energy: number;
};
isSleeping: boolean;
isEgg: boolean;
statusRecipe: BlobbiVisualRecipe | undefined;
statusRecipeLabel: string | undefined;
effectiveEmotion: BlobbiEmotion;
hasDevOverride: boolean;
blobbiReaction: BlobbiReactionState;
isActiveFloatingCompanion: boolean;
isUpdatingCompanion: boolean;
handleSetAsCompanion: () => Promise<void>;
heroRef: React.RefObject<HTMLDivElement | null>;
heroWidth: number;
/** Current room (for indicator below name) */
roomId: BlobbiRoomId;
/** Room order for dot indicators */
roomOrder?: BlobbiRoomId[];
className?: string;
}
// ─── Component ────────────────────────────────────────────────────────────────
export function BlobbiRoomHero({
companion,
currentStats,
isSleeping,
isEgg,
statusRecipe,
statusRecipeLabel,
effectiveEmotion,
hasDevOverride,
blobbiReaction,
isActiveFloatingCompanion,
isUpdatingCompanion,
handleSetAsCompanion,
heroRef,
heroWidth,
roomId,
roomOrder = DEFAULT_ROOM_ORDER,
className,
}: BlobbiRoomHeroProps) {
const roomMeta = ROOM_META[roomId];
const roomIndex = getRoomIndex(roomId, roomOrder);
if (isActiveFloatingCompanion) {
return (
<div className={cn('flex flex-col items-center justify-center gap-4 text-center flex-1 px-4', className)}>
<Footprints className="size-12 text-muted-foreground/30" />
<p className="text-muted-foreground text-sm">
{companion.name} is out exploring right now.
</p>
<button
onClick={handleSetAsCompanion}
disabled={isUpdatingCompanion}
className={cn(
'flex items-center justify-center gap-2 px-6 py-3 rounded-full text-white font-semibold transition-all duration-300 ease-out text-sm',
'hover:-translate-y-0.5 hover:scale-105 hover:brightness-110 active:scale-95',
isUpdatingCompanion && 'opacity-50 pointer-events-none',
)}
style={{ background: 'linear-gradient(135deg, #8b5cf6, #ec4899, #f59e0b)' }}
>
{isUpdatingCompanion ? <Loader2 className="size-4 animate-spin" /> : <Footprints className="size-4" />}
<span>Bring {companion.name} home</span>
</button>
</div>
);
}
return (
<div
ref={heroRef}
className={cn(
'relative flex flex-col items-center justify-center pt-10 px-4 sm:px-6 flex-1 min-h-0',
className,
)}
>
<div className="relative flex flex-col items-center">
<StatsCrown companion={companion} currentStats={currentStats} heroWidth={heroWidth} />
<div
className="relative transition-all duration-500"
style={!isSleeping ? {
animation: `blobbi-bob ${4 - (currentStats.happiness / 100) * 1.5}s ease-in-out infinite, blobbi-sway ${6 - (currentStats.happiness / 100) * 2}s ease-in-out infinite`,
} : undefined}
>
<div className="absolute inset-0 -m-16 sm:-m-20 bg-primary/5 rounded-full blur-3xl pointer-events-none" />
<BlobbiStageVisual
companion={companion}
size="lg"
animated={!isSleeping}
reaction={blobbiReaction}
recipe={hasDevOverride ? undefined : statusRecipe}
recipeLabel={hasDevOverride ? undefined : statusRecipeLabel}
emotion={effectiveEmotion}
className={isEgg
? 'size-36 min-[400px]:size-44 sm:size-56 md:size-64 lg:size-72'
: 'size-48 min-[400px]:size-60 sm:size-72 md:size-80 lg:size-96'
}
/>
</div>
{!isEgg && (
<h2
className="text-xl sm:text-2xl md:text-3xl font-bold text-center mt-1"
style={{ color: companion.visualTraits.baseColor }}
>
{companion.name}
</h2>
)}
{/* Room indicator */}
<div className="flex flex-col items-center mt-2">
<div className="flex items-center gap-1.5">
<roomMeta.icon className="size-3.5 sm:size-4 text-foreground/50" />
<span className="text-xs sm:text-sm font-semibold text-foreground/60">{roomMeta.label}</span>
</div>
<div className="flex items-center gap-1.5 mt-1">
{roomOrder.map((id, i) => (
<div
key={id}
className={cn(
'rounded-full transition-all duration-300',
i === roomIndex ? 'w-4 h-1 bg-primary' : 'w-1 h-1 bg-muted-foreground/20',
)}
/>
))}
</div>
</div>
</div>
</div>
);
}
// ─── Stats Crown ──────────────────────────────────────────────────────────────
function StatsCrown({
companion,
currentStats,
heroWidth,
}: {
companion: BlobbiCompanion;
currentStats: BlobbiRoomHeroProps['currentStats'];
heroWidth: number;
}) {
const allStats = useMemo(() =>
getVisibleStats(companion.stage).map(stat => ({
stat,
value: currentStats[stat] ?? 100,
status: getStatStatus(companion.stage, stat, currentStats[stat] ?? 100),
color: STAT_COLOR_MAP[stat],
})),
[companion.stage, currentStats]);
if (allStats.length === 0) return null;
const count = allStats.length;
const isSmall = heroWidth < 400;
const arcSpread = isSmall
? (count <= 2 ? 80 : count <= 3 ? 110 : 140)
: (count <= 2 ? 90 : count <= 3 ? 130 : 160);
const arcHalf = arcSpread / 2;
const angles = count === 1
? [0]
: allStats.map((_, i) => -arcHalf + (arcSpread / (count - 1)) * i);
return (
<div className="relative flex items-end justify-center w-full mb-4 sm:mb-8" style={{ height: 40 }}>
{allStats.map((s, i) => {
const angleDeg = angles[i];
const angleRad = (angleDeg * Math.PI) / 180;
const radius = Math.min(200, Math.max(110, (heroWidth - 340) / (640 - 340) * (200 - 110) + 110));
const x = Math.sin(angleRad) * radius;
const y = Math.cos(angleRad) * radius - radius;
return (
<div
key={s.stat}
className="absolute transition-all duration-500"
style={{
transform: 'translate(-50%, 0)',
left: `calc(50% + ${x.toFixed(1)}px)`,
bottom: `${y.toFixed(1)}px`,
}}
>
<StatIndicator stat={s.stat} value={s.value} color={s.color} status={s.status} />
</div>
);
})}
</div>
);
}
// ─── Stat Indicator ───────────────────────────────────────────────────────────
function StatIndicator({
stat,
value,
color,
status = 'normal',
}: {
stat: string;
value: number | undefined;
color: 'orange' | 'yellow' | 'green' | 'blue' | 'violet';
status?: 'normal' | 'warning' | 'critical';
}) {
const displayValue = value ?? 0;
const isLow = status === 'warning' || status === 'critical';
const ringHex = STAT_RING_HEX[color];
const IconComponent = STAT_ICON_MAP[stat];
return (
<div className={cn(
'relative size-14 sm:size-[4.5rem] rounded-full flex items-center justify-center',
STAT_BG_COLORS[color],
status === 'critical' && 'animate-pulse',
)}>
<svg className="absolute inset-0 -rotate-90" viewBox="0 0 36 36">
<circle cx="18" cy="18" r="15" fill="none" stroke="currentColor" strokeWidth="2.5" className="text-muted/15" />
<circle
cx="18" cy="18" r="15" fill="none" strokeWidth="2.5" strokeLinecap="round"
stroke={ringHex}
strokeDasharray={`${displayValue * 0.94} 100`}
className="transition-all duration-500"
/>
</svg>
<div className="relative">
{IconComponent && <IconComponent className={cn('size-5 sm:size-6', STAT_COLORS[color])} strokeWidth={2.5} />}
{isLow && (
<AlertTriangle
className={cn('absolute -top-1.5 -right-2 size-3', status === 'critical' ? 'text-red-500' : 'text-amber-500')}
strokeWidth={3}
/>
)}
</div>
</div>
);
}
@@ -0,0 +1,194 @@
/**
* BlobbiRoomShell — Outer layout for room-based navigation.
*
* Manages: room navigation (arrows + dots), sleep overlay, poop state.
* Renders children in a flex column with the hero above and children below.
* The parent decides what bottom bar to render based on the active room.
*/
import { useState, useCallback, useMemo, useEffect, useRef as useReactRef, type CSSProperties } from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { cn } from '@/lib/utils';
import { impactLight } from '@/lib/haptics';
import {
type BlobbiRoomId,
ROOM_META,
DEFAULT_ROOM_ORDER,
getNextRoom,
getPreviousRoom,
} from '../lib/room-config';
import {
generateInitialPoops,
removePoop,
type PoopInstance,
} from '../lib/poop-system';
// ─── Types ────────────────────────────────────────────────────────────────────
export interface PoopState {
poops: PoopInstance[];
shovelMode: boolean;
setShovelMode: React.Dispatch<React.SetStateAction<boolean>>;
onRemovePoop: (poopId: string) => void;
}
interface BlobbiRoomShellProps {
/** Current active room */
roomId: BlobbiRoomId;
/** Called when user navigates to a different room */
onChangeRoom: (roomId: BlobbiRoomId) => void;
/** Whether the Blobbi is sleeping (darkens the room) */
isSleeping: boolean;
/** Hero element (BlobbiRoomHero) rendered in the flex-1 area */
hero: React.ReactNode;
/** Bottom bar content (per-room actions + carousel) */
children: React.ReactNode;
/** Optional content between hero and bottom bar (inline music/sing) */
middleSlot?: React.ReactNode;
/** Room order (defaults to DEFAULT_ROOM_ORDER) */
roomOrder?: BlobbiRoomId[];
/** Poop generation params */
hunger: number;
lastFeedTimestamp: number | undefined;
/** Expose poop state to children via render prop or context */
poopStateRef?: React.MutableRefObject<PoopState | null>;
/** Called when a poop is cleaned. Parent handles toast/XP persistence. */
onPoopCleaned?: () => void;
}
// ─── Component ────────────────────────────────────────────────────────────────
/** Minimum horizontal swipe distance (px) to trigger room change */
const SWIPE_THRESHOLD = 50;
export function BlobbiRoomShell({
roomId,
onChangeRoom,
isSleeping,
hero,
children,
middleSlot,
roomOrder = DEFAULT_ROOM_ORDER,
hunger,
lastFeedTimestamp,
poopStateRef,
onPoopCleaned,
}: BlobbiRoomShellProps) {
const goLeft = useCallback(() => {
onChangeRoom(getPreviousRoom(roomId, roomOrder));
}, [roomId, roomOrder, onChangeRoom]);
const goRight = useCallback(() => {
onChangeRoom(getNextRoom(roomId, roomOrder));
}, [roomId, roomOrder, onChangeRoom]);
const leftDest = ROOM_META[getPreviousRoom(roomId, roomOrder)];
const rightDest = ROOM_META[getNextRoom(roomId, roomOrder)];
// ─── Touch swipe ───
const touchStartX = useReactRef<number | null>(null);
const onTouchStart = useCallback((e: React.TouchEvent) => {
touchStartX.current = e.touches[0].clientX;
}, [touchStartX]);
const onTouchEnd = useCallback((e: React.TouchEvent) => {
if (touchStartX.current === null) return;
const dx = e.changedTouches[0].clientX - touchStartX.current;
touchStartX.current = null;
if (Math.abs(dx) < SWIPE_THRESHOLD) return;
impactLight();
if (dx > 0) goLeft();
else goRight();
}, [touchStartX, goLeft, goRight]);
// ─── Poop system (ephemeral) ───
const [poops, setPoops] = useState<PoopInstance[]>([]);
const [shovelMode, setShovelMode] = useState(false);
useEffect(() => {
setPoops(generateInitialPoops(hunger, lastFeedTimestamp));
// Only on mount
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const onRemovePoop = useCallback((poopId: string) => {
setPoops(prev => {
const { remaining } = removePoop(prev, poopId);
if (remaining.length < prev.length) {
onPoopCleaned?.();
}
if (remaining.length === 0) setShovelMode(false);
return remaining;
});
}, [onPoopCleaned]);
const poopState: PoopState = useMemo(() => ({
poops, shovelMode, setShovelMode, onRemovePoop,
}), [poops, shovelMode, onRemovePoop]);
if (poopStateRef) poopStateRef.current = poopState;
return (
<div
className="flex flex-col flex-1 min-h-0 relative"
onTouchStart={onTouchStart}
onTouchEnd={onTouchEnd}
>
{/* Room content */}
<div className="flex-1 min-h-0 flex flex-col relative">
{hero}
{middleSlot}
{children}
</div>
{/* Sleep overlay */}
{isSleeping && (
<div
className="absolute inset-0 z-20 pointer-events-none transition-opacity duration-700"
style={{ background: 'radial-gradient(ellipse at 50% 40%, rgba(0,0,0,0.25) 0%, rgba(0,0,0,0.45) 100%)' }}
/>
)}
{/* Navigation arrows */}
<button
onClick={goLeft}
className={cn(
'group absolute left-1 top-1/2 -translate-y-1/2 z-40',
'flex items-center justify-center',
'size-10 sm:size-12 rounded-full',
'text-muted-foreground/30 hover:text-foreground/60 hover:bg-accent/40',
'transition-all duration-200 active:scale-90',
'cursor-pointer select-none',
)}
aria-label={`Go to ${leftDest.label}`}
>
<ChevronLeft
className="size-7 sm:size-8 shrink-0"
strokeWidth={4}
style={{ animation: 'room-arrow-nudge-left 2.5s ease-in-out infinite' } as CSSProperties}
/>
</button>
<button
onClick={goRight}
className={cn(
'group absolute right-1 top-1/2 -translate-y-1/2 z-40',
'flex items-center justify-center',
'size-10 sm:size-12 rounded-full',
'text-muted-foreground/30 hover:text-foreground/60 hover:bg-accent/40',
'transition-all duration-200 active:scale-90',
'cursor-pointer select-none',
)}
aria-label={`Go to ${rightDest.label}`}
>
<ChevronRight
className="size-7 sm:size-8 shrink-0"
strokeWidth={4}
style={{ animation: 'room-arrow-nudge-right 2.5s ease-in-out infinite' } as CSSProperties}
/>
</button>
</div>
);
}
@@ -0,0 +1,144 @@
/**
* ItemCarousel — Single-focus carousel for room items.
*
* Fixed-size slots prevent layout reflow on item switch.
* Mobile: focused item only. Desktop: prev/next previews.
*/
import { useState, useCallback, useEffect } from 'react';
import { ChevronLeft, ChevronRight, Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
// ─── Types ────────────────────────────────────────────────────────────────────
export interface CarouselEntry {
id: string;
icon: React.ReactNode;
label: string;
meta?: string;
}
interface ItemCarouselProps {
items: CarouselEntry[];
onUse: (id: string) => void;
activeItemId?: string | null;
disabled?: boolean;
onFocusChange?: (entry: CarouselEntry) => void;
className?: string;
}
// ─── Component ────────────────────────────────────────────────────────────────
export function ItemCarousel({
items,
onUse,
activeItemId,
disabled,
onFocusChange,
className,
}: ItemCarouselProps) {
const [index, setIndex] = useState(0);
const count = items.length;
// Reset index when items change to avoid out-of-bounds access
useEffect(() => {
setIndex(0);
}, [items]);
const prev = useCallback(() => {
setIndex(i => {
const n = (i - 1 + count) % count;
onFocusChange?.(items[n]);
return n;
});
}, [count, items, onFocusChange]);
const next = useCallback(() => {
setIndex(i => {
const n = (i + 1) % count;
onFocusChange?.(items[n]);
return n;
});
}, [count, items, onFocusChange]);
if (count === 0) {
return (
<div className={cn('flex items-center justify-center h-[4.5rem] sm:h-[5.5rem]', className)}>
<p className="text-xs text-muted-foreground/50">Nothing here yet</p>
</div>
);
}
const current = items[index];
const prevItem = items[(index - 1 + count) % count];
const nextItem = items[(index + 1) % count];
const isThisActive = activeItemId === current.id;
const showPreviews = count >= 3;
return (
<div className={cn('flex items-center justify-center', className)}>
<button
onClick={prev}
disabled={disabled}
className={cn(
'size-7 sm:size-8 rounded-full flex items-center justify-center shrink-0',
'text-muted-foreground/40 hover:text-foreground/70 hover:bg-accent/40',
'transition-all duration-200 active:scale-90',
disabled && 'opacity-30 pointer-events-none',
)}
aria-label="Previous item"
>
<ChevronLeft className="size-4" />
</button>
{showPreviews && (
<div className="hidden sm:flex items-center justify-center w-10 h-12 shrink-0 overflow-hidden pointer-events-none select-none">
<div className="opacity-20 scale-[0.6]">
<span className="text-2xl leading-none block">{prevItem.icon}</span>
</div>
</div>
)}
<button
onClick={() => onUse(current.id)}
disabled={disabled}
className={cn(
'relative flex flex-col items-center justify-center shrink-0 overflow-hidden',
'w-20 h-[4.5rem] sm:w-24 sm:h-[5.5rem] rounded-2xl',
'transition-colors duration-200',
'hover:bg-accent/20 active:scale-95',
isThisActive && 'bg-accent/40',
disabled && !isThisActive && 'opacity-50 pointer-events-none',
)}
>
<span className="text-4xl sm:text-5xl leading-none">{current.icon}</span>
<span className="text-[10px] sm:text-xs font-medium text-foreground/70 mt-0.5 w-16 sm:w-20 text-center truncate">
{current.label}
</span>
{isThisActive && <Loader2 className="size-3.5 animate-spin text-primary absolute bottom-0.5" />}
</button>
{showPreviews && (
<div className="hidden sm:flex items-center justify-center w-10 h-12 shrink-0 overflow-hidden pointer-events-none select-none">
<div className="opacity-20 scale-[0.6]">
<span className="text-2xl leading-none block">{nextItem.icon}</span>
</div>
</div>
)}
<button
onClick={next}
disabled={disabled}
className={cn(
'size-7 sm:size-8 rounded-full flex items-center justify-center shrink-0',
'text-muted-foreground/40 hover:text-foreground/70 hover:bg-accent/40',
'transition-all duration-200 active:scale-90',
disabled && 'opacity-30 pointer-events-none',
)}
aria-label="Next item"
>
<ChevronRight className="size-4" />
</button>
</div>
);
}
@@ -0,0 +1,58 @@
/**
* RoomActionButton — Unified circular action button for room bottom bars.
*
* Responsive: size-14/size-20 circle, size-7/size-9 icons.
*/
import { Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
interface RoomActionButtonProps {
icon: React.ReactNode;
label: string;
color: string;
glowHex: string;
onClick: () => void;
disabled?: boolean;
loading?: boolean;
badge?: React.ReactNode;
className?: string;
}
export function RoomActionButton({
icon,
label,
color,
glowHex,
onClick,
disabled,
loading,
badge,
className,
}: RoomActionButtonProps) {
return (
<button
onClick={onClick}
disabled={disabled}
className={cn(
'flex flex-col items-center gap-1 transition-all duration-300 ease-out shrink-0',
'hover:-translate-y-1 hover:scale-110 active:scale-95',
disabled && 'opacity-50 pointer-events-none',
className,
)}
>
<div className="relative">
<div
className={cn('size-14 sm:size-20 rounded-full flex items-center justify-center', color)}
style={{
background: `radial-gradient(circle at 40% 35%, color-mix(in srgb, ${glowHex} 14%, transparent), color-mix(in srgb, ${glowHex} 4%, transparent) 70%)`,
}}
>
{loading ? <Loader2 className="size-7 sm:size-9 animate-spin" /> : icon}
</div>
{badge && <div className="absolute -top-0.5 -right-0.5">{badge}</div>}
</div>
<span className="text-[10px] sm:text-xs font-medium text-muted-foreground">{label}</span>
</button>
);
}
+29
View File
@@ -0,0 +1,29 @@
// src/blobbi/rooms/index.ts — barrel export
export {
type BlobbiRoomId,
type BlobbiRoomMeta,
ROOM_META,
DEFAULT_ROOM_ORDER,
DEFAULT_INITIAL_ROOM,
isValidRoomId,
getNextRoom,
getPreviousRoom,
getRoomIndex,
} from './lib/room-config';
export { ROOM_BOTTOM_BAR_CLASS } from './lib/room-layout';
export {
type PoopInstance,
XP_PER_POOP,
generateInitialPoops,
getPoopsInRoom,
removePoop,
hasAnyPoop,
} from './lib/poop-system';
export { RoomActionButton } from './components/RoomActionButton';
export { ItemCarousel, type CarouselEntry } from './components/ItemCarousel';
export { BlobbiRoomHero, type BlobbiRoomHeroProps } from './components/BlobbiRoomHero';
export { BlobbiRoomShell, type PoopState } from './components/BlobbiRoomShell';
+101
View File
@@ -0,0 +1,101 @@
/**
* Ephemeral poop system.
*
* Generated on page mount based on hunger + time since last feed.
* No persistence -- purely local React state.
*/
import type { BlobbiRoomId } from './room-config';
// ─── Types ────────────────────────────────────────────────────────────────────
export interface PoopInstance {
id: string;
room: BlobbiRoomId;
source: 'overfeed' | 'time';
createdAt: number;
position: { bottom: number; left: number };
}
// ─── Constants ────────────────────────────────────────────────────────────────
const OVERFEED_THRESHOLD = 95;
const HOURS_PER_POOP = 2;
export const XP_PER_POOP = 5;
const POOP_ELIGIBLE_ROOMS: BlobbiRoomId[] = ['care', 'kitchen', 'home', 'rest'];
const SAFE_POSITIONS: Array<{ bottom: number; left: number }> = [
{ bottom: 22, left: 8 },
{ bottom: 18, left: 78 },
{ bottom: 28, left: 14 },
{ bottom: 25, left: 82 },
{ bottom: 15, left: 20 },
{ bottom: 20, left: 72 },
];
// ─── Generation ───────────────────────────────────────────────────────────────
let _idCounter = 0;
function nextPoopId(): string {
return `poop_${++_idCounter}_${Date.now()}`;
}
function pickPosition(index: number): { bottom: number; left: number } {
return SAFE_POSITIONS[index % SAFE_POSITIONS.length];
}
export function generateInitialPoops(
hunger: number,
lastFeedTimestamp: number | undefined,
): PoopInstance[] {
const poops: PoopInstance[] = [];
const now = Date.now();
let posIndex = 0;
if (hunger >= OVERFEED_THRESHOLD) {
poops.push({
id: nextPoopId(),
room: 'kitchen',
source: 'overfeed',
createdAt: now,
position: pickPosition(posIndex++),
});
}
if (lastFeedTimestamp) {
const hoursSinceFeed = (now - lastFeedTimestamp) / (1000 * 60 * 60);
const count = Math.min(Math.floor(hoursSinceFeed / HOURS_PER_POOP), 3);
for (let i = 0; i < count; i++) {
const room = POOP_ELIGIBLE_ROOMS[Math.floor(Math.random() * POOP_ELIGIBLE_ROOMS.length)];
poops.push({
id: nextPoopId(),
room,
source: 'time',
createdAt: now - i * 1000,
position: pickPosition(posIndex++),
});
}
}
return poops;
}
export function getPoopsInRoom(poops: PoopInstance[], room: BlobbiRoomId): PoopInstance[] {
return poops.filter(p => p.room === room);
}
export function removePoop(
poops: PoopInstance[],
poopId: string,
): { remaining: PoopInstance[]; xpReward: number } {
const remaining = poops.filter(p => p.id !== poopId);
return {
remaining,
xpReward: remaining.length < poops.length ? XP_PER_POOP : 0,
};
}
export function hasAnyPoop(poops: PoopInstance[]): boolean {
return poops.length > 0;
}
+99
View File
@@ -0,0 +1,99 @@
/**
* Blobbi Room System — IDs, metadata, ordering, navigation.
*
* Room order is data, not control flow, so it can be customised per-user later.
* The kind 11125 profile has a `room` tag for cross-session continuity.
* Currently read on mount but not yet written back on room change (session-local only).
*/
import { Home, Refrigerator, Cross, Moon, Shirt, type LucideIcon } from 'lucide-react';
// ─── Room IDs ─────────────────────────────────────────────────────────────────
export type BlobbiRoomId = 'home' | 'kitchen' | 'care' | 'rest' | 'closet';
// ─── Metadata ─────────────────────────────────────────────────────────────────
export interface BlobbiRoomMeta {
id: BlobbiRoomId;
label: string;
description: string;
icon: LucideIcon;
}
export const ROOM_META: Record<BlobbiRoomId, BlobbiRoomMeta> = {
home: {
id: 'home',
label: 'Home',
description: 'Main living room',
icon: Home,
},
kitchen: {
id: 'kitchen',
label: 'Kitchen',
description: 'Feed your Blobbi',
icon: Refrigerator,
},
care: {
id: 'care',
label: 'Care Room',
description: 'Hygiene, care, and medicine',
icon: Cross,
},
rest: {
id: 'rest',
label: 'Bedroom',
description: 'Rest and recharge',
icon: Moon,
},
closet: {
id: 'closet',
label: 'Closet',
description: 'Wardrobe and accessories',
icon: Shirt,
},
};
// ─── Default Order ────────────────────────────────────────────────────────────
export const DEFAULT_ROOM_ORDER: BlobbiRoomId[] = [
'care',
'kitchen',
'home',
'rest',
// 'closet', — re-enable when wardrobe is ready
];
export const DEFAULT_INITIAL_ROOM: BlobbiRoomId = 'home';
/** Validate a string as a room ID (for parsing persisted values) */
export function isValidRoomId(value: string | undefined): value is BlobbiRoomId {
return !!value && value in ROOM_META;
}
// ─── Navigation ───────────────────────────────────────────────────────────────
export function getNextRoom(
current: BlobbiRoomId,
order: BlobbiRoomId[] = DEFAULT_ROOM_ORDER,
): BlobbiRoomId {
const idx = order.indexOf(current);
if (idx === -1) return order[0];
return order[(idx + 1) % order.length];
}
export function getPreviousRoom(
current: BlobbiRoomId,
order: BlobbiRoomId[] = DEFAULT_ROOM_ORDER,
): BlobbiRoomId {
const idx = order.indexOf(current);
if (idx === -1) return order[order.length - 1];
return order[(idx - 1 + order.length) % order.length];
}
export function getRoomIndex(
room: BlobbiRoomId,
order: BlobbiRoomId[] = DEFAULT_ROOM_ORDER,
): number {
return order.indexOf(room);
}
+12
View File
@@ -0,0 +1,12 @@
/**
* Shared layout constants for Blobbi room components.
*/
/**
* CSS class for the bottom action bar in every room.
*
* On mobile (max-sidebar), adds extra bottom padding to clear the
* fixed bottom navigation bar. On desktop (sidebar:), uses normal padding.
*/
export const ROOM_BOTTOM_BAR_CLASS =
'relative z-10 px-3 sm:px-6 pt-1 pb-4 sm:pb-6 max-sidebar:pb-[calc(var(--bottom-nav-height)+env(safe-area-inset-bottom,0px)+1rem)]';
+60
View File
@@ -0,0 +1,60 @@
import { Footprints, Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
const SIZE_PRESETS = {
sm: {
wrapper: 'flex flex-col items-center gap-3 py-6',
icon: 'size-10 text-muted-foreground/30',
name: 'text-sm font-semibold',
description: 'text-xs text-muted-foreground',
button: 'flex items-center gap-2 px-4 py-2 rounded-full text-white text-xs font-semibold transition-all hover:-translate-y-0.5 hover:scale-105 active:scale-95',
buttonIcon: 'size-3.5',
buttonLabel: (_name: string) => 'Bring home',
descriptionText: (_name: string) => 'Out exploring with you',
},
md: {
wrapper: 'flex flex-col items-center justify-center gap-6 text-center',
icon: 'size-16 text-muted-foreground/30',
name: '', // not shown separately in md — name is inline in description
description: 'text-muted-foreground text-sm',
button: 'flex items-center justify-center gap-2.5 px-8 py-3.5 rounded-full text-white font-semibold transition-all duration-300 ease-out hover:-translate-y-0.5 hover:scale-105 hover:brightness-110 active:scale-95',
buttonIcon: 'size-5',
buttonLabel: (name: string) => `Bring ${name} home`,
descriptionText: (name: string) => `${name} is out exploring right now.`,
},
} as const;
export interface BlobbiAwayStateProps {
/** The Blobbi's name. */
name: string;
/** Visual size preset. 'md' for full page, 'sm' for widget. */
size?: 'sm' | 'md';
/** Whether the companion update is in progress. */
isUpdating: boolean;
/** Callback to bring the Blobbi home (unset as floating companion). */
onBringHome: () => void;
}
/** Shared "out exploring" state shown when a Blobbi is the active floating companion. */
export function BlobbiAwayState({ name, size = 'md', isUpdating, onBringHome }: BlobbiAwayStateProps) {
const preset = SIZE_PRESETS[size];
return (
<div className={preset.wrapper}>
<Footprints className={preset.icon} />
{size === 'sm' && <span className={preset.name}>{name}</span>}
<p className={preset.description}>{preset.descriptionText(name)}</p>
<button
onClick={onBringHome}
disabled={isUpdating}
className={cn(preset.button, isUpdating && 'opacity-50 pointer-events-none')}
style={{ background: 'linear-gradient(135deg, #8b5cf6, #ec4899, #f59e0b)' }}
>
{isUpdating
? <Loader2 className={cn(preset.buttonIcon, 'animate-spin')} />
: <Footprints className={preset.buttonIcon} />}
<span>{preset.buttonLabel(name)}</span>
</button>
</div>
);
}
+138
View File
@@ -0,0 +1,138 @@
import { AlertTriangle, Utensils, Gamepad2, Heart, Droplets, Zap } from 'lucide-react';
import { cn } from '@/lib/utils';
// ── Constants ─────────────────────────────────────────────────────────────────
const STAT_COLORS: Record<string, string> = {
orange: 'text-orange-500',
yellow: 'text-yellow-500',
green: 'text-green-500',
blue: 'text-blue-500',
violet: 'text-violet-500',
};
const STAT_BG_COLORS: Record<string, string> = {
orange: 'bg-orange-500/10',
yellow: 'bg-yellow-500/10',
green: 'bg-green-500/10',
blue: 'bg-blue-500/10',
violet: 'bg-violet-500/10',
};
const STAT_RING_HEX: Record<string, string> = {
orange: '#f97316',
yellow: '#eab308',
green: '#22c55e',
blue: '#3b82f6',
violet: '#8b5cf6',
};
/** Lucide icon component for each stat. */
const STAT_ICON_MAP: Record<string, React.ComponentType<{ className?: string; strokeWidth?: number }>> = {
hunger: Utensils,
happiness: Gamepad2,
health: Heart,
hygiene: Droplets,
energy: Zap,
};
// ── Size presets ──────────────────────────────────────────────────────────────
const SIZE_PRESETS = {
sm: {
container: 'size-9',
icon: 'size-3.5',
strokeWidth: 3,
alertSize: 'size-2.5',
alertPos: '-top-1 -right-1.5',
},
md: {
container: 'size-[4.5rem] sm:size-20',
icon: 'size-6 sm:size-7',
strokeWidth: 2.5,
alertSize: 'size-3.5',
alertPos: '-top-1.5 -right-2',
},
} as const;
// ── Component ─────────────────────────────────────────────────────────────────
export interface StatIndicatorProps {
stat: string;
value: number | undefined;
color: 'orange' | 'yellow' | 'green' | 'blue' | 'violet';
status?: 'normal' | 'warning' | 'critical';
/** Visual size preset. Default: 'md'. */
size?: 'sm' | 'md';
/** When provided, renders as a clickable button. */
onClick?: () => void;
/** Disable the button (only relevant when onClick is set). */
disabled?: boolean;
}
export function StatIndicator({
stat,
value,
color,
status = 'normal',
size = 'md',
onClick,
disabled,
}: StatIndicatorProps) {
const displayValue = value ?? 0;
const isLow = status === 'warning' || status === 'critical';
const ringHex = STAT_RING_HEX[color];
const IconComponent = STAT_ICON_MAP[stat];
const preset = SIZE_PRESETS[size];
const inner = (
<>
{/* Progress ring */}
<svg className="absolute inset-0 -rotate-90" viewBox="0 0 36 36">
<circle cx="18" cy="18" r="15" fill="none" stroke="currentColor" strokeWidth={preset.strokeWidth} className="text-muted/15" />
<circle
cx="18" cy="18" r="15" fill="none" strokeWidth={preset.strokeWidth} strokeLinecap="round"
stroke={ringHex}
strokeDasharray={`${displayValue * 0.94} 100`}
className="transition-all duration-500"
/>
</svg>
{/* Icon with warning badge */}
<div className="relative">
{IconComponent && <IconComponent className={cn(preset.icon, STAT_COLORS[color])} strokeWidth={2.5} />}
{isLow && (
<AlertTriangle
className={cn('absolute', preset.alertPos, preset.alertSize, status === 'critical' ? 'text-red-500' : 'text-amber-500')}
strokeWidth={3}
/>
)}
</div>
</>
);
const baseClass = cn(
'relative rounded-full flex items-center justify-center',
preset.container,
STAT_BG_COLORS[color],
status === 'critical' && 'animate-pulse',
);
if (onClick) {
return (
<button
onClick={onClick}
disabled={disabled}
className={cn(
baseClass,
'transition-transform hover:scale-110 active:scale-95',
disabled && 'opacity-40 pointer-events-none',
)}
aria-label={`${stat} ${displayValue}%`}
>
{inner}
</button>
);
}
return <div className={baseClass}>{inner}</div>;
}
+16 -18
View File
@@ -19,7 +19,6 @@ import {
ChevronRight,
Egg,
Sparkles,
Coins,
CircleDot,
X,
} from 'lucide-react';
@@ -29,7 +28,7 @@ import { Progress } from '@/components/ui/progress';
import { cn } from '@/lib/utils';
import type { HatchTask } from '@/blobbi/actions/hooks/useHatchTasks';
import type { DailyMission } from '@/blobbi/actions/lib/daily-missions';
import type { DailyMissionView } from '@/blobbi/actions/hooks/useDailyMissions';
// ─── Card Item Types ──────────────────────────────────────────────────────────
@@ -49,8 +48,8 @@ interface DailyCardItem {
description: string;
progress: number;
progressLabel: string;
reward: number;
claimed: boolean;
xp: number;
complete: boolean;
}
type CardItem = TaskCardItem | DailyCardItem;
@@ -65,7 +64,7 @@ interface MissionSurfaceCardProps {
/** Process type for badge label */
processType: 'hatch' | 'evolve' | null;
/** Daily missions */
dailyMissions: DailyMission[];
dailyMissions: DailyMissionView[];
/** Called when user taps "View all" */
onViewAll: () => void;
/** Called when user dismisses the card */
@@ -97,22 +96,22 @@ function buildTaskCards(
}));
}
function buildDailyCards(missions: DailyMission[]): DailyCardItem[] {
// Show unclaimed missions first, then claimed ones
const unclaimed = missions.filter((m) => !m.claimed);
const toShow = unclaimed.length > 0 ? unclaimed : [];
function buildDailyCards(missions: DailyMissionView[]): DailyCardItem[] {
// Show incomplete missions first
const incomplete = missions.filter((m) => !m.complete);
const toShow = incomplete.length > 0 ? incomplete : [];
return toShow.map((m) => ({
kind: 'daily',
badge: 'Daily',
title: m.title,
description: m.description,
progress: m.requiredCount > 0
? Math.min(100, Math.round((m.currentCount / m.requiredCount) * 100))
progress: m.target > 0
? Math.min(100, Math.round((m.progress / m.target) * 100))
: 0,
progressLabel: `${m.currentCount}/${m.requiredCount}`,
reward: m.reward,
claimed: m.claimed,
progressLabel: `${m.progress}/${m.target}`,
xp: m.xp,
complete: m.complete,
}));
}
@@ -279,10 +278,9 @@ export function MissionSurfaceCard({
<span className="text-[10px] text-muted-foreground font-mono shrink-0">
{card.progressLabel}
</span>
{card.kind === 'daily' && !card.claimed && (
<span className="flex items-center gap-0.5 text-[10px] text-amber-600 dark:text-amber-400 font-medium shrink-0">
<Coins className="size-2.5" />
{card.reward}
{card.kind === 'daily' && !card.complete && (
<span className="flex items-center gap-0.5 text-[10px] text-violet-600 dark:text-violet-400 font-medium shrink-0">
{card.xp} XP
</span>
)}
</div>
+48
View File
@@ -876,3 +876,51 @@ export const ACTION_EMOTION_MAP: Record<ActionType, BlobbiEmotion> = {
export function getActionEmotion(action: ActionType): BlobbiEmotion {
return ACTION_EMOTION_MAP[action];
}
// ─── Feed Attenuation ─────────────────────────────────────────────────────────
/**
* Produce a lighter version of a visual recipe suitable for feed cards.
*
* Feed Blobbis are rendered at a smaller size (size-48/56 vs size-64+) and
* need to remain readable at a glance. This function keeps all facial parts
* (eyes, mouth, eyebrows) and extras untouched — they are already sized
* relative to the SVG viewBox — but reduces body-effect particle counts
* and removes flies to prevent visual clutter at small sizes.
*
* The input recipe is produced by the same `resolveStatusRecipe()` used
* by the room view, so thresholds and priorities are identical.
*/
export function attenuateRecipeForFeed(recipe: BlobbiVisualRecipe): BlobbiVisualRecipe {
// Empty / no body effects → return as-is (stable reference path)
if (!recipe.bodyEffects) return recipe;
const { bodyEffects, ...rest } = recipe;
const attenuated: BodyEffectsRecipe = {};
// Dirt marks: reduce count by ~40%, lower intensity cap
if (bodyEffects.dirtMarks?.enabled) {
attenuated.dirtMarks = {
...bodyEffects.dirtMarks,
count: Math.max(1, Math.ceil((bodyEffects.dirtMarks.count ?? 3) * 0.6)),
intensity: Math.min(bodyEffects.dirtMarks.intensity ?? 0.6, 0.55),
};
}
// Stink clouds: reduce count, remove flies entirely
if (bodyEffects.stinkClouds?.enabled) {
attenuated.stinkClouds = {
...bodyEffects.stinkClouds,
count: Math.max(1, Math.ceil((bodyEffects.stinkClouds.count ?? 3) * 0.5)),
flies: false,
flyCount: 0,
};
}
// Anger rise: pass through unchanged (single overlay, scales with SVG)
if (bodyEffects.angerRise) {
attenuated.angerRise = bodyEffects.angerRise;
}
return { ...rest, bodyEffects: attenuated };
}
+144
View File
@@ -0,0 +1,144 @@
import { useState, useEffect } from 'react';
import Markdown from 'react-markdown';
import rehypeSanitize from 'rehype-sanitize';
import { Palette, Type } from 'lucide-react';
import { NoteCard } from '@/components/NoteCard';
import { cn } from '@/lib/utils';
import type { DisplayMessage, ToolCall } from '@/lib/aiChatTools';
// ─── Thinking Animation ───
export const BUDDY_ANIMATION = [
'<[o_o]>',
'>[-_-]<',
'<[0_0]>',
'>[-_-]<',
];
export function BuddyThinking() {
const [frame, setFrame] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
setFrame((f) => (f + 1) % BUDDY_ANIMATION.length);
}, 100);
return () => clearInterval(interval);
}, []);
return (
<pre className="text-sm font-mono text-muted-foreground leading-none">{BUDDY_ANIMATION[frame]}</pre>
);
}
// ─── Message Bubble ───
export function MessageBubble({ message }: { message: DisplayMessage }) {
const isUser = message.role === 'user';
return (
<div className={cn('flex items-start', isUser && 'justify-end')}>
<div className={cn('flex flex-col gap-1 max-w-[85%] min-w-0', isUser && 'items-end')}>
{/* Hide the bubble entirely when the assistant message is empty (tool-only turn) */}
{(isUser || message.content.trim()) && (
<div
className={cn(
'rounded-2xl px-4 py-2.5 text-sm',
isUser
? 'bg-primary text-primary-foreground rounded-tr-md'
: 'bg-secondary border border-border rounded-tl-md',
)}
>
{isUser ? (
<p className="whitespace-pre-wrap break-words">{message.content}</p>
) : (
<div
className="prose prose-sm max-w-none break-words text-foreground prose-headings:text-foreground prose-strong:text-foreground prose-blockquote:text-muted-foreground prose-p:my-1 prose-headings:my-2 prose-ul:my-1 prose-ol:my-1 prose-li:my-0.5 prose-pre:my-2 prose-code:text-xs prose-code:text-primary prose-code:before:content-none prose-code:after:content-none"
style={{ '--tw-prose-links': 'hsl(var(--primary))', '--tw-prose-quote-borders': 'hsl(var(--border))' } as React.CSSProperties}
>
<Markdown rehypePlugins={[rehypeSanitize]}>
{message.content}
</Markdown>
</div>
)}
</div>
)}
{/* Inline Nostr event (e.g. a spell created by a tool) */}
{message.nostrEvent && (
<div className="w-full rounded-xl overflow-hidden border border-border mt-1 bg-background">
<NoteCard event={message.nostrEvent} compact />
</div>
)}
{/* Tool call indicators */}
{message.toolCalls && message.toolCalls.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-1">
{message.toolCalls.map((tc) => (
<ToolCallBadge key={tc.id} toolCall={tc} />
))}
</div>
)}
<span className="text-[10px] text-muted-foreground/60 px-1">
{message.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</span>
</div>
</div>
);
}
// ─── Tool Call Badge ───
export function ToolCallBadge({ toolCall }: { toolCall: ToolCall }) {
let resultParsed: {
success?: boolean;
error?: string;
colors?: { background?: string; text?: string; primary?: string };
font?: string;
background?: { url?: string; mode?: string };
} = {};
try {
resultParsed = JSON.parse(toolCall.result || '{}');
} catch {
// ignore
}
const isSuccess = resultParsed.success === true;
const colors = resultParsed.colors;
if (toolCall.name !== 'set_theme' || !isSuccess) {
return (
<span className={cn(
'inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[11px] font-medium',
isSuccess
? 'bg-green-500/10 text-green-700 dark:text-green-400 border border-green-500/20'
: 'bg-orange-500/10 text-orange-700 dark:text-orange-400 border border-orange-500/20',
)}>
<Palette className="size-3" />
{resultParsed.error || toolCall.name}
</span>
);
}
return (
<span className="inline-flex items-center gap-2 px-2.5 py-1 rounded-full text-[11px] font-medium bg-green-500/10 text-green-700 dark:text-green-400 border border-green-500/20">
{/* Color swatches */}
{colors && (
<span className="flex items-center gap-0.5">
<span className="size-2.5 rounded-full border border-black/10" style={{ backgroundColor: `hsl(${colors.background})` }} />
<span className="size-2.5 rounded-full border border-black/10" style={{ backgroundColor: `hsl(${colors.text})` }} />
<span className="size-2.5 rounded-full border border-black/10" style={{ backgroundColor: `hsl(${colors.primary})` }} />
</span>
)}
Theme applied
{resultParsed.font && (
<span className="inline-flex items-center gap-0.5 opacity-80">
<Type className="size-2.5" />
{resultParsed.font}
</span>
)}
</span>
);
}
+102
View File
@@ -0,0 +1,102 @@
import { useCallback, useMemo, useState } from 'react';
import { Send } from 'lucide-react';
import { MessageBubble, BuddyThinking } from '@/components/AIChat/AIChatComponents';
import { useBuddyOnboarding } from '@/hooks/useBuddyOnboarding';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { cn } from '@/lib/utils';
interface BuddyOnboardingProps {
/** Additional class names for the outer wrapper. */
className?: string;
/** Inline styles for the outer wrapper (e.g. dynamic padding). */
style?: React.CSSProperties;
/**
* Called when buddy creation is complete.
* The parent can use this to close a sheet, navigate, etc.
* If not provided the component simply unmounts itself.
*/
onComplete?: () => void;
}
/**
* Conversational buddy-creation flow powered by "Dork".
*
* Renders the message list + input bar. Does NOT include a page shell
* or header — the parent is responsible for wrapping it in whatever
* layout it needs (full page, sheet, etc.).
*/
export function BuddyOnboarding({ className, style, onComplete }: BuddyOnboardingProps) {
const {
messages, handleSend, isCreating, isDone, placeholder, error,
} = useBuddyOnboarding();
const [input, setInput] = useState('');
const messagesEndRef = useMemo(() => ({ current: null as HTMLDivElement | null }), []);
const onSend = useCallback(() => {
if (!input.trim() || isCreating) return;
handleSend(input);
setInput('');
}, [input, isCreating, handleSend]);
const onKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
onSend();
}
}, [onSend]);
// Buddy created — notify parent or silently disappear
if (isDone) {
onComplete?.();
return null;
}
return (
<div className={cn('flex flex-col overflow-hidden', className)} style={style}>
{/* Messages */}
<div className="flex-1 overflow-y-auto">
<div className="max-w-2xl mx-auto px-4 py-6 space-y-6">
{messages.filter((msg) => msg.role !== 'tool_result').map((msg) => (
<MessageBubble key={msg.id} message={msg} />
))}
{isCreating && <BuddyThinking />}
{error && (
<div className="rounded-lg bg-destructive/10 border border-destructive/20 text-destructive text-sm px-4 py-3">
{error}
</div>
)}
<div ref={(el) => { messagesEndRef.current = el; }} />
</div>
</div>
{/* Input */}
<div className="shrink-0 p-4">
<div className="max-w-2xl mx-auto flex items-end gap-2">
<Textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={onKeyDown}
placeholder={placeholder}
disabled={isCreating}
className="min-h-[44px] max-h-40 resize-none bg-secondary/50 border-border focus-visible:ring-1"
rows={1}
/>
<Button
onClick={onSend}
disabled={!input.trim() || isCreating}
size="icon"
className="size-11 shrink-0 rounded-xl"
>
<Send className="size-4" />
</Button>
</div>
</div>
</div>
);
}
+146
View File
@@ -0,0 +1,146 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import { Bot } from 'lucide-react';
import { MessageBubble, BUDDY_ANIMATION } from '@/components/AIChat/AIChatComponents';
import { BuddyOnboarding } from '@/components/AIChat/BuddyOnboarding';
import { useAIChatSession } from '@/hooks/useAIChatSession';
import { useBuddy } from '@/hooks/useBuddy';
import { cn } from '@/lib/utils';
interface MobileBuddySheetProps {
hidden: boolean;
onClose: () => void;
}
export function MobileBuddySheet({ hidden, onClose }: MobileBuddySheetProps) {
const { buddy, hasBuddy } = useBuddy();
// Show the onboarding flow when no buddy exists yet
if (!hasBuddy) {
return (
<div className={cn('fixed inset-0 z-[49] sidebar:hidden flex flex-col overflow-hidden', hidden && 'hidden')}>
<BuddyOnboarding
className="flex-1"
style={{ paddingBottom: 'calc(var(--bottom-nav-height) + 28px + env(safe-area-inset-bottom, 0px))' }}
/>
</div>
);
}
return <MobileBuddyChat buddy={buddy!} hidden={hidden} onClose={onClose} />;
}
// ─── Chat View (buddy exists) ───
import type { BuddyIdentity } from '@/hooks/useBuddy';
function MobileBuddyChat({ buddy, hidden, onClose }: { buddy: BuddyIdentity; hidden: boolean; onClose: () => void }) {
const {
messages, input, setInput, isStreaming, streamingText, selectedModel,
apiLoading, messagesEndRef,
handleSend, handleStop,
} = useAIChatSession({ buddyName: buddy.name, buddySoul: buddy.soul });
const inputRef = useRef<HTMLInputElement>(null);
const [animFrame, setAnimFrame] = useState(0);
// Animate the toggle button when streaming
useEffect(() => {
if (!isStreaming) { setAnimFrame(0); return; }
const interval = setInterval(() => {
setAnimFrame((f) => (f + 1) % BUDDY_ANIMATION.length);
}, 100);
return () => clearInterval(interval);
}, [isStreaming]);
// Focus input when shown
useEffect(() => {
if (!hidden) {
const t = setTimeout(() => inputRef.current?.focus(), 80);
return () => clearTimeout(t);
}
}, [hidden]);
// Scroll to bottom when messages change or streaming text updates
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages, streamingText, messagesEndRef]);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
if (isStreaming) {
handleStop();
} else {
onClose();
}
return;
}
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
}, [onClose, handleSend, handleStop, isStreaming]);
const visibleMessages = messages.filter((msg) => msg.role !== 'tool_result');
const displayName = buddy.name;
return (
<div className={cn('fixed inset-0 z-[49] sidebar:hidden flex flex-col overflow-hidden', hidden && 'hidden')} onClick={onClose}>
{/* Messages area — fills from top, scrollable, padded at bottom to clear the fixed input bar.
stopPropagation on the content wrapper so clicking a bubble doesn't close the sheet. */}
<div
className="flex-1 overflow-y-auto overscroll-contain px-6 pt-4"
style={{ paddingBottom: 'calc(var(--bottom-nav-height) + 28px + env(safe-area-inset-bottom, 0px) + 70px)' }}
>
<div className="space-y-4">
{visibleMessages.map((msg) => (
<div key={msg.id} onClick={(e) => e.stopPropagation()}>
<MessageBubble message={msg} />
</div>
))}
{streamingText && (isStreaming || apiLoading) && (
<div onClick={(e) => e.stopPropagation()}>
<MessageBubble message={{ id: 'streaming', role: 'assistant', content: streamingText, timestamp: new Date() }} />
</div>
)}
<div ref={messagesEndRef} />
</div>
</div>
{/* Input bar — pinned to bottom-mobile-nav position */}
<div className="flex items-center px-6 py-3 bottom-mobile-nav fixed left-0 right-0 z-[49]" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center gap-2 flex-1 bg-secondary rounded-full px-4 py-2.5">
<Bot className="size-4 shrink-0 text-muted-foreground" />
<input
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={`Ask ${displayName}...`}
disabled={!selectedModel}
className="flex-1 bg-transparent text-base outline-none placeholder:text-muted-foreground disabled:opacity-50"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck={false}
/>
<button
onClick={() => {
if (isStreaming) {
handleStop();
} else {
setAnimFrame((f) => (f + 1) % BUDDY_ANIMATION.length);
}
}}
className="shrink-0 font-mono text-xs text-primary transition-colors"
onMouseDown={(e) => e.preventDefault()}
>
{BUDDY_ANIMATION[animFrame]}
</button>
</div>
</div>
</div>
);
}
+350 -287
View File
@@ -1,33 +1,254 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { ChevronDown, ChevronUp, Bug, RotateCcw, AlertTriangle } from 'lucide-react';
import { nip19 } from 'nostr-tools';
import { Button } from '@/components/ui/button';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { RequestToVanishDialog } from '@/components/RequestToVanishDialog';
import { useAppContext } from '@/hooks/useAppContext';
import { useShakespeare, type Model } from '@/hooks/useShakespeare';
import { useToast } from '@/hooks/useToast';
import { useEncryptedSettings } from '@/hooks/useEncryptedSettings';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useBuddy } from '@/hooks/useBuddy';
import { DEFAULT_SYSTEM_PROMPT_TEMPLATE } from '@/lib/aiChatSystemPrompt';
/** The build-time default DSN from the environment variable. */
const DEFAULT_SENTRY_DSN = import.meta.env.VITE_SENTRY_DSN || '';
export function AdvancedSettings() {
const { user } = useCurrentUser();
return (
<div>
{user && <BuddySettingsSection />}
<SystemSettingsSection />
<SentrySettingsSection />
{user && <DangerSettingsSection />}
</div>
);
}
// ── Section components ────────────────────────────────────────────────────────
function SettingsSection({
title, icon, open, onOpenChange, accentColor, children,
}: {
title: string; icon?: React.ReactNode; open: boolean; onOpenChange: (v: boolean) => void;
accentColor?: string; children: React.ReactNode;
}) {
return (
<div>
<Collapsible open={open} onOpenChange={onOpenChange}>
<CollapsibleTrigger asChild>
<Button
variant="ghost"
className="relative w-full justify-between px-3 py-3.5 h-auto hover:bg-muted/20 hover:text-foreground rounded-none"
>
<span className={`flex items-center gap-2 text-base font-semibold ${accentColor ?? ''}`}>
{icon}
{title}
</span>
{open ? (
<ChevronUp className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
)}
<div className={`absolute bottom-0 left-0 right-0 h-1 rounded-full ${accentColor === 'text-destructive' ? 'bg-destructive' : 'bg-primary'}`} />
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
{children}
</CollapsibleContent>
</Collapsible>
</div>
);
}
function BuddySettingsSection() {
const { config, updateConfig } = useAppContext();
const { toast } = useToast();
const { user } = useCurrentUser();
const { getAvailableModels } = useShakespeare();
const { buddy, hasBuddy, updateSoul } = useBuddy();
const [open, setOpen] = useState(false);
const [aiModels, setAiModels] = useState<Model[]>([]);
const [aiModelsLoading, setAiModelsLoading] = useState(false);
const [soulDraft, setSoulDraft] = useState('');
const [soulSaving, setSoulSaving] = useState(false);
const [systemPromptDraft, setSystemPromptDraft] = useState(config.aiSystemPrompt || DEFAULT_SYSTEM_PROMPT_TEMPLATE);
useEffect(() => {
if (buddy?.soul) setSoulDraft(buddy.soul);
}, [buddy?.soul]);
useEffect(() => {
if (!open || !user || aiModels.length > 0) return;
let cancelled = false;
setAiModelsLoading(true);
getAvailableModels()
.then((response) => {
if (cancelled) return;
const sorted = response.data.sort((a, b) => {
const costA = parseFloat(a.pricing.prompt) + parseFloat(a.pricing.completion);
const costB = parseFloat(b.pricing.prompt) + parseFloat(b.pricing.completion);
return costA - costB;
});
setAiModels(sorted);
})
.catch(() => {})
.finally(() => { if (!cancelled) setAiModelsLoading(false); });
return () => { cancelled = true; };
}, [open, user, aiModels.length, getAvailableModels]);
return (
<SettingsSection title="Buddy" open={open} onOpenChange={setOpen}>
<div className="px-4 py-4 space-y-4 border-b border-border">
<div className="space-y-2">
<Label htmlFor="ai-model">Model</Label>
<Select
value={config.aiModel || (aiModels.length > 0 ? aiModels[0].id : '')}
onValueChange={(value) => {
updateConfig(() => ({ aiModel: value }));
toast({ title: 'AI model updated' });
}}
disabled={aiModelsLoading || aiModels.length === 0}
>
<SelectTrigger id="ai-model">
<SelectValue placeholder={aiModelsLoading ? 'Loading models...' : 'Select model'} />
</SelectTrigger>
<SelectContent>
{aiModels.map((model) => {
const totalCost = parseFloat(model.pricing.prompt) + parseFloat(model.pricing.completion);
const isFree = totalCost === 0;
return (
<SelectItem key={model.id} value={model.id}>
<span className="flex items-center gap-1.5">
{model.name}
{isFree && (
<span className="text-[10px] font-medium text-green-600 dark:text-green-400 bg-green-500/10 px-1 rounded">
FREE
</span>
)}
</span>
</SelectItem>
);
})}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Choose which AI model your buddy uses for chat responses.
</p>
</div>
{hasBuddy && buddy && (
<div className="space-y-3 pt-2 border-t border-border">
<Label className="text-sm font-medium">Identity</Label>
<div className="space-y-1.5 text-sm">
<div className="flex items-center gap-2">
<span className="text-muted-foreground w-12 shrink-0">Name</span>
<span className="font-medium">{buddy.name}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground w-12 shrink-0">npub</span>
<span className="font-mono text-xs text-muted-foreground truncate">{nip19.npubEncode(buddy.pubkey)}</span>
</div>
</div>
<div className="space-y-2 pt-2">
<Label htmlFor="buddy-soul">Soul</Label>
<Textarea
id="buddy-soul"
value={soulDraft}
onChange={(e) => setSoulDraft(e.target.value)}
onBlur={async () => {
const trimmed = soulDraft.trim();
if (trimmed && trimmed !== buddy.soul) {
setSoulSaving(true);
try {
await updateSoul.mutateAsync(trimmed);
toast({ title: 'Buddy soul updated' });
} catch {
toast({ title: 'Failed to update soul', variant: 'destructive' });
} finally {
setSoulSaving(false);
}
}
}}
placeholder="Describe your buddy's personality..."
className="min-h-[100px] max-h-[400px] resize-y font-mono text-xs leading-relaxed"
disabled={soulSaving}
/>
<p className="text-xs text-muted-foreground">
Your buddy's personality and behavior. Changes are saved when you click away.
</p>
</div>
</div>
)}
{!hasBuddy && (
<div className="pt-2 border-t border-border">
<p className="text-xs text-muted-foreground">
No buddy configured. Visit the Buddy page to create one.
</p>
</div>
)}
<div className="space-y-2 pt-2 border-t border-border">
<Label htmlFor="ai-system-prompt">System Prompt</Label>
<Textarea
id="ai-system-prompt"
value={systemPromptDraft}
onChange={(e) => setSystemPromptDraft(e.target.value)}
onBlur={() => {
const trimmed = systemPromptDraft.trim();
const defaultPrompt = DEFAULT_SYSTEM_PROMPT_TEMPLATE;
const valueToStore = trimmed === defaultPrompt ? '' : trimmed;
if (valueToStore !== config.aiSystemPrompt) {
updateConfig(() => ({ aiSystemPrompt: valueToStore }));
toast({ title: valueToStore ? 'System prompt updated' : 'System prompt reset to default' });
}
}}
className="min-h-[120px] max-h-[400px] resize-y font-mono text-xs leading-relaxed"
/>
<p className="text-xs text-muted-foreground">
The base system prompt sent to the AI. Use <code className="bg-muted px-1 rounded">{'{{NAME}}'}</code> and <code className="bg-muted px-1 rounded">{'{{SOUL}}'}</code> as placeholders for your buddy's identity.
</p>
{config.aiSystemPrompt && (
<Button
variant="ghost"
size="sm"
className="h-7 text-xs text-muted-foreground"
onClick={() => {
setSystemPromptDraft(DEFAULT_SYSTEM_PROMPT_TEMPLATE);
updateConfig(() => ({ aiSystemPrompt: '' }));
toast({ title: 'System prompt reset to default' });
}}
>
<RotateCcw className="h-3 w-3 mr-1" />
Reset to default
</Button>
)}
</div>
</div>
</SettingsSection>
);
}
function SystemSettingsSection() {
const { config, updateConfig } = useAppContext();
const { toast } = useToast();
const { updateSettings } = useEncryptedSettings();
const { user } = useCurrentUser();
const [systemOpen, setSystemOpen] = useState(true);
const [sentryOpen, setSentryOpen] = useState(false);
const [dangerOpen, setDangerOpen] = useState(false);
const [vanishDialogOpen, setVanishDialogOpen] = useState(false);
const [open, setOpen] = useState(true);
const [statsPubkey, setStatsPubkey] = useState(config.nip85StatsPubkey);
const [faviconUrl, setFaviconUrl] = useState(config.faviconUrl);
const [linkPreviewUrl, setLinkPreviewUrl] = useState(config.linkPreviewUrl);
const [corsProxy, setCorsProxy] = useState(config.corsProxy);
const [sentryDsn, setSentryDsn] = useState(config.sentryDsn);
const handleStatsPubkeyChange = (value: string) => {
setStatsPubkey(value);
@@ -41,288 +262,130 @@ export function AdvancedSettings() {
};
return (
<div>
{/* System Section (includes Stats Source) */}
<div>
<Collapsible open={systemOpen} onOpenChange={setSystemOpen}>
<CollapsibleTrigger asChild>
<Button
variant="ghost"
className="relative w-full justify-between px-3 py-3.5 h-auto hover:bg-muted/20 hover:text-foreground rounded-none"
>
<span className="text-base font-semibold">System</span>
{systemOpen ? (
<ChevronUp className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
)}
<div className="absolute bottom-0 left-0 right-0 h-1 bg-primary rounded-full" />
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="px-3 pt-3 pb-4 space-y-5">
{/* Stats Source */}
<div>
<Label htmlFor="stats-pubkey" className="text-sm font-medium">
NIP-85 Stats Pubkey
</Label>
<p className="text-xs text-muted-foreground mt-1 mb-2">
Trusted pubkey for pre-computed engagement stats (likes, reposts, comments).
</p>
<Input
id="stats-pubkey"
value={statsPubkey}
onChange={(e) => handleStatsPubkeyChange(e.target.value)}
placeholder="Enter 64-character hex pubkey"
className="font-mono text-base md:text-sm"
maxLength={64}
/>
{statsPubkey && statsPubkey.length !== 64 && (
<p className="text-xs text-destructive mt-1">
Pubkey must be exactly 64 hexadecimal characters
</p>
)}
<div className="text-xs text-muted-foreground mt-2">
<span className="font-medium">Default: </span>
<span className="font-mono break-all">5f68e85ee174102ca8978eef302129f081f03456c884185d5ec1c1224ab633ea</span>
</div>
</div>
{/* Favicon URL */}
<div>
<Label htmlFor="favicon-url" className="text-sm font-medium">
Favicon URL
</Label>
<p className="text-xs text-muted-foreground mt-1 mb-2">
URI template for fetching site favicons. Supports RFC 6570 variables: <code className="bg-muted px-1 rounded">{'{href}'}</code>, <code className="bg-muted px-1 rounded">{'{hostname}'}</code>, <code className="bg-muted px-1 rounded">{'{origin}'}</code>, etc.
</p>
<Input
id="favicon-url"
value={faviconUrl}
onChange={(e) => setFaviconUrl(e.target.value)}
onBlur={async () => {
const trimmed = faviconUrl.trim();
if (trimmed && trimmed !== config.faviconUrl) {
updateConfig(() => ({ faviconUrl: trimmed }));
if (user) await updateSettings.mutateAsync({ faviconUrl: trimmed });
toast({ title: 'Favicon URL updated' });
}
}}
placeholder="https://ditto.pub/api/favicon/{hostname}"
className="font-mono text-base md:text-sm"
/>
<div className="text-xs text-muted-foreground mt-2">
<span className="font-medium">Default: </span>
<span className="font-mono break-all">https://ditto.pub/api/favicon/{'{hostname}'}</span>
</div>
</div>
{/* Link Preview URL */}
<div>
<Label htmlFor="link-preview-url" className="text-sm font-medium">
Link Preview URL
</Label>
<p className="text-xs text-muted-foreground mt-1 mb-2">
URI template for fetching link previews (returns OEmbed JSON). Supports RFC 6570 variables: <code className="bg-muted px-1 rounded">{'{url}'}</code>, <code className="bg-muted px-1 rounded">{'{hostname}'}</code>, <code className="bg-muted px-1 rounded">{'{origin}'}</code>, etc.
</p>
<Input
id="link-preview-url"
value={linkPreviewUrl}
onChange={(e) => setLinkPreviewUrl(e.target.value)}
onBlur={async () => {
const trimmed = linkPreviewUrl.trim();
if (trimmed && trimmed !== config.linkPreviewUrl) {
updateConfig(() => ({ linkPreviewUrl: trimmed }));
if (user) await updateSettings.mutateAsync({ linkPreviewUrl: trimmed });
toast({ title: 'Link preview URL updated' });
}
}}
placeholder="https://ditto.pub/api/link-preview/{url}"
className="font-mono text-base md:text-sm"
/>
<div className="text-xs text-muted-foreground mt-2">
<span className="font-medium">Default: </span>
<span className="font-mono break-all">https://ditto.pub/api/link-preview/{'{url}'}</span>
</div>
</div>
{/* CORS Proxy */}
<div>
<Label htmlFor="cors-proxy" className="text-sm font-medium">
CORS Proxy
</Label>
<p className="text-xs text-muted-foreground mt-1 mb-2">
Proxy for cross-origin requests (NIP-05 fallback). Use <code className="bg-muted px-1 rounded">{'{href}'}</code> as a placeholder for the target URL.
</p>
<Input
id="cors-proxy"
value={corsProxy}
onChange={(e) => setCorsProxy(e.target.value)}
onBlur={async () => {
const trimmed = corsProxy.trim();
if (trimmed && trimmed !== config.corsProxy) {
updateConfig(() => ({ corsProxy: trimmed }));
if (user) await updateSettings.mutateAsync({ corsProxy: trimmed });
toast({ title: 'CORS proxy updated' });
}
}}
placeholder="https://proxy.shakespeare.diy/?url={href}"
className="font-mono text-base md:text-sm"
/>
<div className="text-xs text-muted-foreground mt-2">
<span className="font-medium">Default: </span>
<span className="font-mono break-all">https://proxy.shakespeare.diy/?url={'{href}'}</span>
</div>
</div>
</div>
</CollapsibleContent>
</Collapsible>
</div>
{/* Error Reporting Section */}
<div>
<Collapsible open={sentryOpen} onOpenChange={setSentryOpen}>
<CollapsibleTrigger asChild>
<Button
variant="ghost"
className="relative w-full justify-between px-3 py-3.5 h-auto hover:bg-muted/20 hover:text-foreground rounded-none"
>
<span className="flex items-center gap-2 text-base font-semibold">
<Bug className="h-4 w-4" />
Error Reporting
</span>
{sentryOpen ? (
<ChevronUp className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
)}
<div className="absolute bottom-0 left-0 right-0 h-1 bg-primary rounded-full" />
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="px-3 pt-3 pb-4 space-y-5">
{/* Share error reports toggle */}
<div className="flex items-center justify-between">
<div className="space-y-1">
<Label htmlFor="sentry-enabled" className="text-sm font-medium">
Share error reports
</Label>
<p className="text-xs text-muted-foreground">
Help improve this app by automatically sending crash and error reports.
</p>
</div>
<Switch
id="sentry-enabled"
checked={config.sentryEnabled}
onCheckedChange={(checked) => {
updateConfig((current) => ({ ...current, sentryEnabled: checked }));
}}
/>
</div>
{/* Sentry DSN */}
<div>
<div className="flex items-center justify-between mb-1">
<Label htmlFor="sentry-dsn" className="text-sm font-medium">
Sentry DSN
{sentryDsn !== DEFAULT_SENTRY_DSN && (
<span className="ml-2 inline-block w-2 h-2 rounded-full bg-yellow-400" title="Modified from default" />
)}
</Label>
{sentryDsn !== DEFAULT_SENTRY_DSN && (
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
title="Restore to default"
onClick={async () => {
setSentryDsn(DEFAULT_SENTRY_DSN);
updateConfig((current) => ({ ...current, sentryDsn: DEFAULT_SENTRY_DSN }));
if (user) await updateSettings.mutateAsync({ sentryDsn: DEFAULT_SENTRY_DSN });
toast({ title: 'Sentry DSN restored to default' });
}}
>
<RotateCcw className="h-3.5 w-3.5" />
</Button>
)}
</div>
<p className="text-xs text-muted-foreground mt-1 mb-2">
Sentry Data Source Name (DSN) for error reporting. Leave empty to disable Sentry.
</p>
<Input
id="sentry-dsn"
value={sentryDsn}
onChange={(e) => setSentryDsn(e.target.value)}
onBlur={async () => {
const trimmed = sentryDsn.trim();
if (trimmed !== config.sentryDsn) {
updateConfig((current) => ({ ...current, sentryDsn: trimmed }));
if (user) await updateSettings.mutateAsync({ sentryDsn: trimmed });
toast({ title: trimmed ? 'Sentry DSN updated' : 'Sentry DSN cleared' });
}
}}
placeholder="https://examplePublicKey@o0.ingest.sentry.io/0"
className="font-mono text-base md:text-sm"
/>
</div>
</div>
</CollapsibleContent>
</Collapsible>
</div>
{/* Danger Zone Section — only when logged in */}
{user && (
<SettingsSection title="System" open={open} onOpenChange={setOpen}>
<div className="px-3 pt-3 pb-4 space-y-5">
<div>
<Collapsible open={dangerOpen} onOpenChange={setDangerOpen}>
<CollapsibleTrigger asChild>
<Button
variant="ghost"
className="relative w-full justify-between px-3 py-3.5 h-auto hover:bg-muted/20 hover:text-foreground rounded-none"
>
<span className="flex items-center gap-2 text-base font-semibold text-destructive">
<AlertTriangle className="h-4 w-4" />
Danger Zone
</span>
{dangerOpen ? (
<ChevronUp className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
)}
<div className="absolute bottom-0 left-0 right-0 h-1 bg-destructive rounded-full" />
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="px-3 pt-3 pb-4 space-y-4">
<div className="rounded-lg border border-destructive/30 p-4 space-y-3">
<div>
<h3 className="text-sm font-medium">Request to Vanish</h3>
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
Permanently request all relays to delete your data, including your profile,
posts, reactions, and direct messages. This action is irreversible and legally
binding in some jurisdictions (NIP-62).
</p>
</div>
<Button
variant="outline"
size="sm"
className="border-destructive/50 text-destructive hover:bg-destructive hover:text-destructive-foreground"
onClick={() => setVanishDialogOpen(true)}
>
Request to Vanish
</Button>
</div>
</div>
</CollapsibleContent>
</Collapsible>
<RequestToVanishDialog
open={vanishDialogOpen}
onOpenChange={setVanishDialogOpen}
/>
<Label htmlFor="stats-pubkey" className="text-sm font-medium">NIP-85 Stats Pubkey</Label>
<p className="text-xs text-muted-foreground mt-1 mb-2">Trusted pubkey for pre-computed engagement stats (likes, reposts, comments).</p>
<Input id="stats-pubkey" value={statsPubkey} onChange={(e) => handleStatsPubkeyChange(e.target.value)} placeholder="Enter 64-character hex pubkey" className="font-mono text-base md:text-sm" maxLength={64} />
{statsPubkey && statsPubkey.length !== 64 && <p className="text-xs text-destructive mt-1">Pubkey must be exactly 64 hexadecimal characters</p>}
<div className="text-xs text-muted-foreground mt-2"><span className="font-medium">Default: </span><span className="font-mono break-all">5f68e85ee174102ca8978eef302129f081f03456c884185d5ec1c1224ab633ea</span></div>
</div>
)}
</div>
<div>
<Label htmlFor="favicon-url" className="text-sm font-medium">Favicon URL</Label>
<p className="text-xs text-muted-foreground mt-1 mb-2">URI template for fetching site favicons. Supports RFC 6570 variables: <code className="bg-muted px-1 rounded">{'{href}'}</code>, <code className="bg-muted px-1 rounded">{'{hostname}'}</code>, <code className="bg-muted px-1 rounded">{'{origin}'}</code>, etc.</p>
<Input id="favicon-url" value={faviconUrl} onChange={(e) => setFaviconUrl(e.target.value)} onBlur={async () => { const trimmed = faviconUrl.trim(); if (trimmed && trimmed !== config.faviconUrl) { updateConfig(() => ({ faviconUrl: trimmed })); if (user) await updateSettings.mutateAsync({ faviconUrl: trimmed }); toast({ title: 'Favicon URL updated' }); } }} placeholder="https://ditto.pub/api/favicon/{hostname}" className="font-mono text-base md:text-sm" />
<div className="text-xs text-muted-foreground mt-2"><span className="font-medium">Default: </span><span className="font-mono break-all">https://ditto.pub/api/favicon/{'{hostname}'}</span></div>
</div>
<div>
<Label htmlFor="link-preview-url" className="text-sm font-medium">Link Preview URL</Label>
<p className="text-xs text-muted-foreground mt-1 mb-2">URI template for fetching link previews (returns OEmbed JSON). Supports RFC 6570 variables: <code className="bg-muted px-1 rounded">{'{url}'}</code>, <code className="bg-muted px-1 rounded">{'{hostname}'}</code>, <code className="bg-muted px-1 rounded">{'{origin}'}</code>, etc.</p>
<Input id="link-preview-url" value={linkPreviewUrl} onChange={(e) => setLinkPreviewUrl(e.target.value)} onBlur={async () => { const trimmed = linkPreviewUrl.trim(); if (trimmed && trimmed !== config.linkPreviewUrl) { updateConfig(() => ({ linkPreviewUrl: trimmed })); if (user) await updateSettings.mutateAsync({ linkPreviewUrl: trimmed }); toast({ title: 'Link preview URL updated' }); } }} placeholder="https://ditto.pub/api/link-preview/{url}" className="font-mono text-base md:text-sm" />
<div className="text-xs text-muted-foreground mt-2"><span className="font-medium">Default: </span><span className="font-mono break-all">https://ditto.pub/api/link-preview/{'{url}'}</span></div>
</div>
<div>
<Label htmlFor="cors-proxy" className="text-sm font-medium">CORS Proxy</Label>
<p className="text-xs text-muted-foreground mt-1 mb-2">Proxy for cross-origin requests (NIP-05 fallback). Use <code className="bg-muted px-1 rounded">{'{href}'}</code> as a placeholder for the target URL.</p>
<Input id="cors-proxy" value={corsProxy} onChange={(e) => setCorsProxy(e.target.value)} onBlur={async () => { const trimmed = corsProxy.trim(); if (trimmed && trimmed !== config.corsProxy) { updateConfig(() => ({ corsProxy: trimmed })); if (user) await updateSettings.mutateAsync({ corsProxy: trimmed }); toast({ title: 'CORS proxy updated' }); } }} placeholder="https://proxy.shakespeare.diy/?url={href}" className="font-mono text-base md:text-sm" />
<div className="text-xs text-muted-foreground mt-2"><span className="font-medium">Default: </span><span className="font-mono break-all">https://proxy.shakespeare.diy/?url={'{href}'}</span></div>
</div>
</div>
</SettingsSection>
);
}
function SentrySettingsSection() {
const { config, updateConfig } = useAppContext();
const { toast } = useToast();
const { updateSettings } = useEncryptedSettings();
const { user } = useCurrentUser();
const [open, setOpen] = useState(false);
const [sentryDsn, setSentryDsn] = useState(config.sentryDsn);
return (
<SettingsSection title="Error Reporting" icon={<Bug className="h-4 w-4" />} open={open} onOpenChange={setOpen}>
<div className="px-3 pt-3 pb-4 space-y-5">
<div className="flex items-center justify-between">
<div className="space-y-1">
<Label htmlFor="sentry-enabled" className="text-sm font-medium">Share error reports</Label>
<p className="text-xs text-muted-foreground">Help improve this app by automatically sending crash and error reports.</p>
</div>
<Switch id="sentry-enabled" checked={config.sentryEnabled} onCheckedChange={(checked) => { updateConfig((current) => ({ ...current, sentryEnabled: checked })); }} />
</div>
<div>
<div className="flex items-center justify-between mb-1">
<Label htmlFor="sentry-dsn" className="text-sm font-medium">
Sentry DSN
{sentryDsn !== DEFAULT_SENTRY_DSN && <span className="ml-2 inline-block w-2 h-2 rounded-full bg-yellow-400" title="Modified from default" />}
</Label>
{sentryDsn !== DEFAULT_SENTRY_DSN && (
<Button variant="ghost" size="sm" className="h-6 w-6 p-0" title="Restore to default" onClick={async () => { setSentryDsn(DEFAULT_SENTRY_DSN); updateConfig((current) => ({ ...current, sentryDsn: DEFAULT_SENTRY_DSN })); if (user) await updateSettings.mutateAsync({ sentryDsn: DEFAULT_SENTRY_DSN }); toast({ title: 'Sentry DSN restored to default' }); }}>
<RotateCcw className="h-3.5 w-3.5" />
</Button>
)}
</div>
<p className="text-xs text-muted-foreground mt-1 mb-2">Sentry Data Source Name (DSN) for error reporting. Leave empty to disable Sentry.</p>
<Input id="sentry-dsn" value={sentryDsn} onChange={(e) => setSentryDsn(e.target.value)} onBlur={async () => { const trimmed = sentryDsn.trim(); if (trimmed !== config.sentryDsn) { updateConfig((current) => ({ ...current, sentryDsn: trimmed })); if (user) await updateSettings.mutateAsync({ sentryDsn: trimmed }); toast({ title: trimmed ? 'Sentry DSN updated' : 'Sentry DSN cleared' }); } }} placeholder="https://examplePublicKey@o0.ingest.sentry.io/0" className="font-mono text-base md:text-sm" />
</div>
</div>
</SettingsSection>
);
}
function DangerSettingsSection() {
const { toast } = useToast();
const { hasBuddy, resetBuddy } = useBuddy();
const [open, setOpen] = useState(false);
const [vanishDialogOpen, setVanishDialogOpen] = useState(false);
return (
<>
<SettingsSection title="Danger Zone" icon={<AlertTriangle className="h-4 w-4" />} accentColor="text-destructive" open={open} onOpenChange={setOpen}>
<div className="px-3 pt-3 pb-4 space-y-4">
{hasBuddy && (
<div className="rounded-lg border border-destructive/30 p-4 space-y-3">
<div>
<h3 className="text-sm font-medium">Reset Buddy</h3>
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
Delete your buddy's identity and start over. The buddy's Nostr keypair and soul
will be wiped from this device and relays. This cannot be undone.
</p>
</div>
<Button
variant="outline" size="sm"
className="border-destructive/50 text-destructive hover:bg-destructive hover:text-destructive-foreground"
onClick={async () => { try { await resetBuddy.mutateAsync(); toast({ title: 'Buddy has been reset' }); } catch { toast({ title: 'Failed to reset buddy', variant: 'destructive' }); } }}
disabled={resetBuddy.isPending}
>
{resetBuddy.isPending ? 'Resetting...' : 'Reset Buddy'}
</Button>
</div>
)}
<div className="rounded-lg border border-destructive/30 p-4 space-y-3">
<div>
<h3 className="text-sm font-medium">Delete Account</h3>
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
Permanently delete your data from the network, including your profile,
posts, reactions, and direct messages. This action is irreversible.
</p>
</div>
<Button
variant="outline" size="sm"
className="border-destructive/50 text-destructive hover:bg-destructive hover:text-destructive-foreground"
onClick={() => setVanishDialogOpen(true)}
>
Delete Account
</Button>
</div>
</div>
</SettingsSection>
<RequestToVanishDialog open={vanishDialogOpen} onOpenChange={setVanishDialogOpen} />
</>
);
}
+2 -1
View File
@@ -9,6 +9,7 @@ import { NsitePreviewDialog } from '@/components/NsitePreviewDialog';
import { Skeleton } from '@/components/ui/skeleton';
import { useAddrEvent } from '@/hooks/useEvent';
import { NostrURI } from '@/lib/NostrURI';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import { cn } from '@/lib/utils';
/** Get a tag value by name. */
@@ -106,7 +107,7 @@ export function AppHandlerContent({ event, compact }: AppHandlerContentProps) {
const about = metadata.about;
const picture = metadata.picture;
const banner = metadata.banner;
const websiteUrl = getWebsiteUrl(event.tags, metadata);
const websiteUrl = sanitizeUrl(getWebsiteUrl(event.tags, metadata));
const hashtags = getAllTags(event.tags, 't');
const shakespeareUrl = useMemo(() => getShakespeareUrl(event.tags), [event.tags]);
+29 -3
View File
@@ -3,17 +3,41 @@ import type { NostrEvent } from '@nostrify/nostrify';
import { BlobbiStageVisual } from '@/blobbi/ui/BlobbiStageVisual';
import { parseBlobbiEvent } from '@/blobbi/core/lib/blobbi';
import { calculateProjectedDecay } from '@/blobbi/core/hooks/useProjectedBlobbiState';
import { resolveStatusRecipe, attenuateRecipeForFeed, EMPTY_RECIPE } from '@/blobbi/ui/lib/status-reactions';
import { buildSleepingRecipe } from '@/blobbi/ui/lib/recipe';
export function BlobbiStateCard({ event }: { event: NostrEvent }) {
const companion = useMemo(() => parseBlobbiEvent(event), [event]);
if (!companion) return null;
const isSleeping = companion?.state === 'sleeping';
const isEgg = companion?.stage === 'egg';
const isSleeping = companion.state === 'sleeping';
// ── Project stats forward in time, then resolve visual recipe ──
// Feed cards show a snapshot, not a live ticker, so we call the pure
// calculateProjectedDecay() once per render instead of using the
// interval-based useProjectedBlobbiState hook. This gives us the
// same decay math the room view uses (applyBlobbiDecay under the
// hood) without any per-card setInterval overhead.
const { recipe: feedRecipe, recipeLabel: feedRecipeLabel } = useMemo(() => {
if (!companion || isEgg) return { recipe: EMPTY_RECIPE, recipeLabel: 'neutral' };
const { stats } = calculateProjectedDecay(companion);
const result = resolveStatusRecipe(stats);
// Attenuate body effects for feed-card size, then apply sleep overlay
const attenuated = attenuateRecipeForFeed(result.recipe);
const final = isSleeping ? buildSleepingRecipe(attenuated) : attenuated;
return { recipe: final, recipeLabel: isSleeping ? 'sleeping' : result.label };
}, [companion, isEgg, isSleeping]);
if (!companion) return null;
return (
<div className="flex flex-col items-center py-4">
{/* Blobbi visual — same as /blobbi hero */}
{/* Blobbi visual — reflects current condition */}
<div className="relative">
<div className="absolute inset-0 -m-8 bg-primary/5 rounded-full blur-3xl" />
<BlobbiStageVisual
@@ -21,6 +45,8 @@ export function BlobbiStateCard({ event }: { event: NostrEvent }) {
size="lg"
animated={!isSleeping}
lookMode="forward"
recipe={feedRecipe}
recipeLabel={feedRecipeLabel}
className="size-48 sm:size-56"
/>
</div>
+2 -1
View File
@@ -34,6 +34,7 @@ import { usePublishRSVP } from '@/hooks/usePublishRSVP';
import { useProfileUrl } from '@/hooks/useProfileUrl';
import { useToast } from '@/hooks/useToast';
import { genUserName } from '@/lib/genUserName';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import { cn } from '@/lib/utils';
// --- Helpers ---
@@ -159,7 +160,7 @@ export function CalendarEventDetailPage({ event }: { event: NostrEvent }) {
const location = locationRaw ? parseLocation(locationRaw) : undefined;
const summary = getTag(event.tags, 'summary');
const hashtags = getAllTags(event.tags, 't').map(([, v]) => v).filter(Boolean);
const links = getAllTags(event.tags, 'r').map(([, v]) => v).filter(Boolean);
const links = getAllTags(event.tags, 'r').map(([, v]) => sanitizeUrl(v)).filter((v): v is string => !!v);
const eventCoord = useMemo(() => getEventCoord(event), [event]);
const dateStr = useMemo(() => formatDetailDate(event), [event]);
+2 -1
View File
@@ -15,6 +15,7 @@ import { useProfileUrl } from '@/hooks/useProfileUrl';
import { useToast } from '@/hooks/useToast';
import { genUserName } from '@/lib/genUserName';
import { cn } from '@/lib/utils';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
// --- Helpers ---
@@ -92,7 +93,7 @@ export function CommunityContent({ event }: { event: NostrEvent }) {
// Extract website URL from description if present
const descriptionUrl = useMemo(() => {
const urlMatch = description.match(/https?:\/\/[^\s]+/);
return urlMatch?.[0];
return sanitizeUrl(urlMatch?.[0]);
}, [description]);
// Description text without trailing URL (if the URL is the last thing)
+15 -29
View File
@@ -24,7 +24,7 @@ import { CustomEmojiImg } from '@/components/CustomEmoji';
import { EmojiShortcodeAutocomplete } from '@/components/EmojiShortcodeAutocomplete';
import { NoteContent } from '@/components/NoteContent';
import { NoteMedia } from '@/components/NoteMedia';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { usePostComment } from '@/hooks/usePostComment';
@@ -34,15 +34,16 @@ import { useToast } from '@/hooks/useToast';
import { useAppContext } from '@/hooks/useAppContext';
import type { EventStats } from '@/hooks/useTrending';
import { cn } from '@/lib/utils';
import { extractVideoUrls, extractAudioUrls, IMETA_MEDIA_URL_REGEX, mimeFromExt } from '@/lib/mediaUrls';
import { notificationSuccess } from '@/lib/haptics';
import { extractVideoUrls, extractAudioUrls, IMETA_MEDIA_URL_REGEX, IMETA_MEDIA_URL_TEST_REGEX, mimeFromExt } from '@/lib/mediaUrls';
/** Lazy-loaded EmojiPicker — keeps emoji-mart + its data out of the main bundle. */
const LazyEmojiPicker = lazy(() => import('@/components/EmojiPicker').then(m => ({ default: m.EmojiPicker })));
import { parseImetaMap } from '@/lib/imeta';
import { useProfileUrl } from '@/hooks/useProfileUrl';
import { useInsertText } from '@/hooks/useInsertText';
import { useVoiceRecorder } from '@/hooks/useVoiceRecorder';
import { formatTime } from '@/lib/formatTime';
import { genUserName } from '@/lib/genUserName';
import { DITTO_RELAY } from '@/lib/appRelays';
import { resizeImage } from '@/lib/resizeImage';
@@ -346,7 +347,7 @@ export function ComposeBox({
const url = match[0];
// Skip media URLs that render inline
// Note: SVGs not excluded - LinkPreview checks content-type and handles both cases
if (!IMETA_MEDIA_URL_REGEX.test(url)) {
if (!IMETA_MEDIA_URL_TEST_REGEX.test(url)) {
embeds.push({ type: 'link', value: url, index: match.index! });
}
}
@@ -715,6 +716,7 @@ export function ComposeBox({
}
}
}
notificationSuccess();
toast({ title: 'Voice message sent!', description: 'Your voice message has been published.' });
onSuccess?.();
} catch {
@@ -972,6 +974,7 @@ export function ComposeBox({
queryClient.invalidateQueries({ queryKey: ['event-stats', quotedEvent.id] });
queryClient.invalidateQueries({ queryKey: ['event-interactions', quotedEvent.id] });
}
notificationSuccess();
toast({ title: 'Posted!', description: replyTo ? 'Your reply has been published.' : quotedEvent ? 'Your quote has been published.' : 'Your note has been published.' });
onSuccess?.();
} catch {
@@ -1015,6 +1018,7 @@ export function ComposeBox({
await createEvent({ kind: 1068, content: finalContent, tags });
resetComposeState();
queryClient.invalidateQueries({ queryKey: ['feed'] });
notificationSuccess();
toast({ title: 'Poll published!' });
onSuccess?.();
} catch {
@@ -1071,7 +1075,7 @@ export function ComposeBox({
<Avatar shape={avatarShape} className="size-12 shrink-0 mt-0.5">
<AvatarImage src={metadata?.picture} alt={metadata?.name} />
<AvatarFallback className="bg-primary/20 text-primary text-sm">
{(metadata?.name?.[0] || '?').toUpperCase()}
{(metadata?.display_name || metadata?.name || genUserName(user?.pubkey))[0]?.toUpperCase() ?? '?'}
</AvatarFallback>
</Avatar>
</Link>
@@ -1115,31 +1119,13 @@ export function ComposeBox({
</div>
) : (
/* Preview mode - Show how post will look */
mockEvent && (() => {
const imetaMap = parseImetaMap(mockEvent.tags);
const videos = extractVideoUrls(mockEvent.content);
const imetaAudios = Array.from(imetaMap.values())
.filter((e) => e.mime?.startsWith('audio/'))
.map((e) => e.url);
const audios = imetaAudios.length > 0 ? imetaAudios : extractAudioUrls(mockEvent.content);
const webxdcApps = Array.from(imetaMap.values()).filter(
(entry) => entry.mime === 'application/x-webxdc' || entry.mime === 'application/vnd.webxdc+zip',
);
return (
<div className="pt-2.5 pb-2 min-h-[100px]">
<div className="text-lg opacity-85">
<NoteContent event={mockEvent} className="text-foreground" />
</div>
<NoteMedia
videos={videos}
audios={audios}
imetaMap={imetaMap}
webxdcApps={webxdcApps}
event={mockEvent}
/>
mockEvent && (
<div className="pt-2.5 pb-2 min-h-[100px]">
<div className="text-lg opacity-85">
<NoteContent event={mockEvent} className="text-foreground" />
</div>
);
})()
</div>
)
)}
{/* Poll options + settings — shown below the normal textarea/preview */}
+3 -3
View File
@@ -831,9 +831,9 @@ function SavedFeedRow({
onRemove: () => void;
isPending: boolean;
}) {
const search = typeof feed.filter.search === 'string' ? feed.filter.search : '';
const authors = Array.isArray(feed.filter.authors) ? feed.filter.authors as string[] : [];
const kinds = Array.isArray(feed.filter.kinds) ? feed.filter.kinds as number[] : [];
const search = typeof feed.filter?.search === 'string' ? feed.filter.search : '';
const authors = Array.isArray(feed.filter?.authors) ? feed.filter.authors as string[] : [];
const kinds = Array.isArray(feed.filter?.kinds) ? (feed.filter.kinds as number[]) : [];
const scopeLabel = authors.includes('$follows')
? 'Follows'
+3
View File
@@ -292,6 +292,9 @@ export function CreateBadgeDialog({ open, onOpenChange }: CreateBadgeDialogProps
}}
/>
</div>
<p className="text-xs text-muted-foreground">
Recommended aspect ratio is 1:1 (max 1024x1024 px).
</p>
</div>
{/* Badge name */}
+25
View File
@@ -0,0 +1,25 @@
import { useState, useEffect } from 'react';
import { cn } from '@/lib/utils';
const DORK_ANIMATION = [
'<[o_o]>',
'>[-_-]<',
'<[0_0]>',
'>[-_-]<',
];
/** Animated Dork face shown while the AI is thinking. */
export function DorkThinking({ className }: { className?: string }) {
const [frame, setFrame] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
setFrame((f) => (f + 1) % DORK_ANIMATION.length);
}, 100);
return () => clearInterval(interval);
}, []);
return (
<pre className={cn('font-mono text-muted-foreground leading-none', className)}>{DORK_ANIMATION[frame]}</pre>
);
}
+131
View File
@@ -0,0 +1,131 @@
import { type ReactNode } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { getAvatarShape } from '@/lib/avatarShape';
import { Skeleton } from '@/components/ui/skeleton';
import { EmojifiedText } from '@/components/CustomEmoji';
import { ProfileHoverCard } from '@/components/ProfileHoverCard';
import { useAuthor } from '@/hooks/useAuthor';
import { genUserName } from '@/lib/genUserName';
import { useProfileUrl } from '@/hooks/useProfileUrl';
import { timeAgo } from '@/lib/timeAgo';
import { cn } from '@/lib/utils';
interface EmbeddedCardShellProps {
/** Author pubkey — used for the author row. */
pubkey: string;
/** Timestamp of the event (unix seconds). */
createdAt: number;
/** The NIP-19 identifier to navigate to on click. */
navigateTo: string;
className?: string;
/** When true, ProfileHoverCards inside the card are disabled. */
disableHoverCards?: boolean;
children: ReactNode;
}
/**
* Shared clickable card shell with an author row used by all embedded
* note / naddr preview cards. Handles the outer border, hover style,
* click / keyboard navigation, avatar, display name, and timestamp.
*
* Pass card-specific content (text preview, blobbi visual, badge row, etc.)
* as `children`.
*/
export function EmbeddedCardShell({
pubkey,
createdAt,
navigateTo,
className,
disableHoverCards,
children,
}: EmbeddedCardShellProps) {
const navigate = useNavigate();
const author = useAuthor(pubkey);
const metadata = author.data?.metadata;
const avatarShape = getAvatarShape(metadata);
const displayName = metadata?.name || genUserName(pubkey);
const profileUrl = useProfileUrl(pubkey, metadata);
return (
<div
className={cn(
'group block rounded-2xl border border-border overflow-hidden',
'hover:bg-secondary/40 transition-colors cursor-pointer',
className,
)}
role="link"
tabIndex={0}
onClick={(e) => {
e.stopPropagation();
navigate(`/${navigateTo}`);
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
navigate(`/${navigateTo}`);
}
}}
>
<div className="px-3 py-2 space-y-1">
{/* Author row */}
<div className="flex items-center gap-2 min-w-0">
{author.isLoading ? (
<>
<Skeleton className="size-5 rounded-full shrink-0" />
<Skeleton className="h-3.5 w-24" />
</>
) : (
<>
<MaybeProfileHoverCard pubkey={pubkey} disabled={disableHoverCards}>
<Link
to={profileUrl}
className="shrink-0"
onClick={(e) => e.stopPropagation()}
>
<Avatar shape={avatarShape} className="size-5">
<AvatarImage src={metadata?.picture} alt={displayName} />
<AvatarFallback className="bg-primary/20 text-primary text-[10px]">
{displayName[0]?.toUpperCase()}
</AvatarFallback>
</Avatar>
</Link>
</MaybeProfileHoverCard>
<MaybeProfileHoverCard pubkey={pubkey} disabled={disableHoverCards}>
<Link
to={profileUrl}
className="text-sm font-semibold truncate hover:underline"
onClick={(e) => e.stopPropagation()}
>
{author.data?.event ? (
<EmojifiedText tags={author.data.event.tags}>{displayName}</EmojifiedText>
) : displayName}
</Link>
</MaybeProfileHoverCard>
</>
)}
<span className="text-xs text-muted-foreground shrink-0">
· {timeAgo(createdAt)}
</span>
</div>
{children}
</div>
</div>
);
}
/** Conditionally wraps children in a ProfileHoverCard. */
function MaybeProfileHoverCard({ pubkey, disabled, children }: { pubkey: string; disabled?: boolean; children: ReactNode }) {
if (disabled) {
return <>{children}</>;
}
return (
<ProfileHoverCard pubkey={pubkey} asChild>
{children}
</ProfileHoverCard>
);
}
+62 -109
View File
@@ -1,25 +1,28 @@
import { type ReactNode, useMemo } from 'react';
import { lazy, Suspense, useMemo } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useNostr } from '@nostrify/react';
import { useQuery } from '@tanstack/react-query';
import { nip19 } from 'nostr-tools';
import { Award, Image, MessageSquareOff } from 'lucide-react';
import type { NostrEvent } from '@nostrify/nostrify';
const BlobbiStateCard = lazy(() => import('@/components/BlobbiStateCard').then(m => ({ default: m.BlobbiStateCard })));
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { getAvatarShape } from '@/lib/avatarShape';
import { Skeleton } from '@/components/ui/skeleton';
import { EmojifiedText } from '@/components/CustomEmoji';
import { ProfileHoverCard } from '@/components/ProfileHoverCard';
import { EmbeddedCardShell } from '@/components/EmbeddedCardShell';
import { parseBadgeDefinition, type BadgeData } from '@/lib/parseBadgeDefinition';
import { BadgeThumbnail } from '@/components/BadgeThumbnail';
import { parseProfileBadges } from '@/lib/parseProfileBadges';
import { useAddrEvent, type AddrCoords } from '@/hooks/useEvent';
import { useAuthor } from '@/hooks/useAuthor';
import { genUserName } from '@/lib/genUserName';
import { useProfileUrl } from '@/hooks/useProfileUrl';
import { isProfileBadgesKind } from '@/lib/badgeUtils';
import { timeAgo } from '@/lib/timeAgo';
import { cn } from '@/lib/utils';
import { getKindLabel, getKindIcon } from '@/lib/extraKinds';
import type { NostrEvent } from '@nostrify/nostrify';
interface EmbeddedNaddrProps {
/** The decoded naddr coordinates. */
@@ -87,6 +90,11 @@ export function EmbeddedNaddr({ addr, className, disableHoverCards }: EmbeddedNa
return <EmbeddedProfileBadgesCard event={event} className={className} />;
}
// Blobbi state events render the pet visual inline
if (event.kind === 31124) {
return <EmbeddedBlobbiCard event={event} className={className} disableHoverCards={disableHoverCards} />;
}
return <EmbeddedNaddrCard event={event} className={className} disableHoverCards={disableHoverCards} />;
}
@@ -194,6 +202,7 @@ export function EmbeddedProfileBadgesCard({ event, className }: { event: NostrEv
const metadata = author.data?.metadata;
const avatarShape = getAvatarShape(metadata);
const displayName = metadata?.name || genUserName(event.pubkey);
const profileUrl = useProfileUrl(event.pubkey, metadata);
const badgeRefs = useMemo(() => parseProfileBadges(event), [event]);
@@ -265,7 +274,7 @@ export function EmbeddedProfileBadgesCard({ event, className }: { event: NostrEv
) : (
<>
<Link
to={`/${nip19.npubEncode(event.pubkey)}`}
to={profileUrl}
className="shrink-0"
onClick={(e) => e.stopPropagation()}
>
@@ -277,7 +286,7 @@ export function EmbeddedProfileBadgesCard({ event, className }: { event: NostrEv
</Avatar>
</Link>
<Link
to={`/${nip19.npubEncode(event.pubkey)}`}
to={profileUrl}
className="text-sm font-semibold truncate hover:underline"
onClick={(e) => e.stopPropagation()}
>
@@ -325,13 +334,6 @@ export function EmbeddedProfileBadgesCard({ event, className }: { event: NostrEv
}
function EmbeddedNaddrCard({ event, className, disableHoverCards }: { event: NostrEvent; className?: string; disableHoverCards?: boolean }) {
const navigate = useNavigate();
const author = useAuthor(event.pubkey);
const metadata = author.data?.metadata;
const avatarShape = getAvatarShape(metadata);
const displayName = metadata?.name || genUserName(event.pubkey);
const npub = useMemo(() => nip19.npubEncode(event.pubkey), [event.pubkey]);
const naddrId = useMemo(() => {
const dTag = event.tags.find(([n]) => n === 'd')?.[1] ?? '';
return nip19.naddrEncode({ kind: event.kind, pubkey: event.pubkey, identifier: dTag });
@@ -353,114 +355,65 @@ function EmbeddedNaddrCard({ event, className, disableHoverCards }: { event: Nos
}, [event.kind]);
return (
<div
className={cn(
'group block rounded-2xl border border-border overflow-hidden',
'hover:bg-secondary/40 transition-colors cursor-pointer',
className,
)}
role="link"
tabIndex={0}
onClick={(e) => {
e.stopPropagation();
navigate(`/${naddrId}`);
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
navigate(`/${naddrId}`);
}
}}
<EmbeddedCardShell
pubkey={event.pubkey}
createdAt={event.created_at}
navigateTo={naddrId}
className={className}
disableHoverCards={disableHoverCards}
>
{/* Text content */}
<div className="px-3 py-2 space-y-1">
{/* Author row */}
<div className="flex items-center gap-2 min-w-0">
{author.isLoading ? (
<>
<Skeleton className="size-5 rounded-full shrink-0" />
<Skeleton className="h-3.5 w-24" />
</>
) : (
<>
<MaybeProfileHoverCard pubkey={event.pubkey} disabled={disableHoverCards}>
<Link
to={`/${npub}`}
className="shrink-0"
onClick={(e) => e.stopPropagation()}
>
<Avatar shape={avatarShape} className="size-5">
<AvatarImage src={metadata?.picture} alt={displayName} />
<AvatarFallback className="bg-primary/20 text-primary text-[10px]">
{displayName[0]?.toUpperCase()}
</AvatarFallback>
</Avatar>
</Link>
</MaybeProfileHoverCard>
{/* Title */}
{title && (
<p className="text-sm font-semibold leading-snug line-clamp-2">
{title}
</p>
)}
<MaybeProfileHoverCard pubkey={event.pubkey} disabled={disableHoverCards}>
<Link
to={`/${npub}`}
className="text-sm font-semibold truncate hover:underline"
onClick={(e) => e.stopPropagation()}
>
{author.data?.event ? (
<EmojifiedText tags={author.data.event.tags}>{displayName}</EmojifiedText>
) : displayName}
</Link>
</MaybeProfileHoverCard>
</>
)}
{/* Description */}
{truncatedDesc && (
<p className="text-xs text-muted-foreground leading-relaxed line-clamp-3">
{truncatedDesc}
</p>
)}
<span className="text-xs text-muted-foreground shrink-0">
· {timeAgo(event.created_at)}
{/* Kind label and attachment indicators */}
<div className="flex items-center gap-2 flex-wrap">
{kindMeta && (
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
{kindMeta.Icon && <kindMeta.Icon className="size-3 shrink-0" />}
{kindMeta.label}
</span>
</div>
{/* Title */}
{title && (
<p className="text-sm font-semibold leading-snug line-clamp-2">
{title}
</p>
)}
{/* Description */}
{truncatedDesc && (
<p className="text-xs text-muted-foreground leading-relaxed line-clamp-3">
{truncatedDesc}
</p>
{image && (
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
<Image className="size-3" />
Image
</span>
)}
{/* Kind label and attachment indicators */}
<div className="flex items-center gap-2 flex-wrap">
{kindMeta && (
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
{kindMeta.Icon && <kindMeta.Icon className="size-3 shrink-0" />}
{kindMeta.label}
</span>
)}
{image && (
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
<Image className="size-3" />
Image
</span>
)}
</div>
</div>
</div>
</EmbeddedCardShell>
);
}
/** Conditionally wraps children in a ProfileHoverCard. When disabled, renders children directly. */
function MaybeProfileHoverCard({ pubkey, disabled, children }: { pubkey: string; disabled?: boolean; children: ReactNode }) {
if (disabled) {
return <>{children}</>;
}
/** Embedded card for kind 31124 Blobbi state events — renders the pet visual inline. */
function EmbeddedBlobbiCard({ event, className, disableHoverCards }: { event: NostrEvent; className?: string; disableHoverCards?: boolean }) {
const naddrId = useMemo(() => {
const dTag = event.tags.find(([n]) => n === 'd')?.[1] ?? '';
return nip19.naddrEncode({ kind: event.kind, pubkey: event.pubkey, identifier: dTag });
}, [event]);
return (
<ProfileHoverCard pubkey={pubkey} asChild>
{children}
</ProfileHoverCard>
<EmbeddedCardShell
pubkey={event.pubkey}
createdAt={event.created_at}
navigateTo={naddrId}
className={className}
disableHoverCards={disableHoverCards}
>
<Suspense fallback={<Skeleton className="h-24 w-full rounded-lg" />}>
<BlobbiStateCard event={event} />
</Suspense>
</EmbeddedCardShell>
);
}
+289 -289
View File
@@ -1,74 +1,39 @@
import { type ReactNode, useMemo } from 'react';
import { lazy, type ReactNode, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { nip19 } from 'nostr-tools';
import { Image, Film, Music, ExternalLink, Blocks, MessageSquareOff } from 'lucide-react';
import type { NostrEvent } from '@nostrify/nostrify';
import { Image, Film, Music, ExternalLink, Blocks, MessageSquareOff, Zap } from 'lucide-react';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { getAvatarShape } from '@/lib/avatarShape';
import { Skeleton } from '@/components/ui/skeleton';
import { EmojifiedText } from '@/components/CustomEmoji';
import { ProfileHoverCard } from '@/components/ProfileHoverCard';
import { EmbeddedCardShell } from '@/components/EmbeddedCardShell';
import { VanishCardCompact } from '@/components/VanishEventContent';
import { EncryptedMessageCompact } from '@/components/EncryptedMessageContent';
import { EncryptedLetterCompact } from '@/components/EncryptedLetterContent';
import { EmbeddedProfileBadgesCard } from '@/components/EmbeddedNaddr';
import { EmojifiedText } from '@/components/CustomEmoji';
import { ProfileHoverCard } from '@/components/ProfileHoverCard';
import { NoteContent } from '@/components/NoteContent';
import { useEvent } from '@/hooks/useEvent';
import { isProfileBadgesKind } from '@/lib/badgeUtils';
import { useAuthor } from '@/hooks/useAuthor';
import { genUserName } from '@/lib/genUserName';
import { useProfileUrl } from '@/hooks/useProfileUrl';
import { isProfileBadgesKind } from '@/lib/badgeUtils';
import { extractZapAmount, extractZapSender, extractZapMessage } from '@/hooks/useEventInteractions';
import { getAvatarShape } from '@/lib/avatarShape';
import { genUserName } from '@/lib/genUserName';
import { formatNumber } from '@/lib/formatNumber';
import { timeAgo } from '@/lib/timeAgo';
import { cn } from '@/lib/utils';
import { useAppContext } from '@/hooks/useAppContext';
import { LinkPreview } from '@/components/LinkPreview';
import { IMAGE_URL_REGEX, IMETA_MEDIA_URL_REGEX, extractVideoUrls, extractAudioUrls } from '@/lib/mediaUrls';
import { IMAGE_URL_REGEX, IMETA_MEDIA_URL_TEST_REGEX, extractVideoUrls, extractAudioUrls } from '@/lib/mediaUrls';
import { getKindLabel, getKindIcon } from '@/lib/extraKinds';
const BlobbiStateCard = lazy(() => import('@/components/BlobbiStateCard').then(m => ({ default: m.BlobbiStateCard })));
/** NIP-62 Request to Vanish. */
const VANISH_KIND = 62;
/** Bech32 charset used by NIP-19 identifiers. */
const B32 = '023456789acdefghjklmnpqrstuvwxyz';
/** Regex that matches nostr:npub1… and nostr:nprofile1… inside text. */
const MENTION_REGEX = new RegExp(`nostr:(npub1|nprofile1)[${B32}]+`, 'g');
/** A parsed segment of embedded-note text. */
type EmbedSegment =
| { type: 'text'; value: string }
| { type: 'mention'; pubkey: string; npub: string };
/** Split text into plain strings and mention segments. */
function parseEmbedSegments(text: string): EmbedSegment[] {
const segments: EmbedSegment[] = [];
let last = 0;
let m: RegExpExecArray | null;
MENTION_REGEX.lastIndex = 0;
while ((m = MENTION_REGEX.exec(text)) !== null) {
if (m.index > last) {
segments.push({ type: 'text', value: text.slice(last, m.index) });
}
try {
const bech32 = m[0].slice('nostr:'.length);
const decoded = nip19.decode(bech32);
const pubkey = decoded.type === 'npub'
? (decoded.data as string)
: (decoded.data as { pubkey: string }).pubkey;
const npub = nip19.npubEncode(pubkey);
segments.push({ type: 'mention', pubkey, npub });
} catch {
// If decode fails, keep as plain text
segments.push({ type: 'text', value: m[0] });
}
last = m.index + m[0].length;
}
if (last < text.length) {
segments.push({ type: 'text', value: text.slice(last) });
}
return segments;
}
/** Max-height (px) for the content area before it gets clipped. */
const EMBED_MAX_HEIGHT = 260;
interface EmbeddedNoteProps {
/** Hex event ID to fetch and display. */
@@ -82,9 +47,6 @@ interface EmbeddedNoteProps {
disableHoverCards?: boolean;
}
/** Maximum characters of note content to show in the embedded preview. */
const MAX_CONTENT_LENGTH = 280;
/** Inline embedded note card similar to a link preview but for Nostr events. */
export function EmbeddedNote({ eventId, relays, authorHint, className, disableHoverCards }: EmbeddedNoteProps) {
const { data: event, isLoading, isError } = useEvent(eventId, relays, authorHint);
@@ -117,99 +79,32 @@ export function EmbeddedNote({ eventId, relays, authorHint, className, disableHo
return <EmbeddedProfileBadgesCard event={event} className={className} />;
}
// Kind 9735 zap receipts get a compact zap card instead of rendering raw JSON
if (event.kind === 9735) {
return <EmbeddedZapCard event={event} className={className} disableHoverCards={disableHoverCards} />;
}
return <EmbeddedNoteCard event={event} className={className} disableHoverCards={disableHoverCards} />;
}
/** The actual card once the event has been fetched. */
function EmbeddedNoteCard({
event,
className,
disableHoverCards,
}: {
event: { id: string; kind: number; pubkey: string; content: string; created_at: number; tags: string[][] };
className?: string;
disableHoverCards?: boolean;
}) {
const { config } = useAppContext();
/** Compact inline card for kind 9735 zap receipts. */
function EmbeddedZapCard({ event, className, disableHoverCards }: { event: NostrEvent; className?: string; disableHoverCards?: boolean }) {
const navigate = useNavigate();
const author = useAuthor(event.pubkey);
const metadata = author.data?.metadata;
const avatarShape = getAvatarShape(metadata);
const displayName = metadata?.name || genUserName(event.pubkey);
const profileUrl = useProfileUrl(event.pubkey, metadata);
const neventId = useMemo(
() => nip19.neventEncode({ id: event.id, author: event.pubkey }),
[event.id, event.pubkey],
);
// Extract the first non-media URL for a link preview card
const firstLinkUrl = useMemo(() => {
const allUrls = event.content.match(/https?:\/\/[^\s]+/g) || [];
return allUrls.find((u) => !IMETA_MEDIA_URL_REGEX.test(u)) ?? null;
}, [event.content]);
const senderPubkey = useMemo(() => extractZapSender(event), [event]);
const amountSats = useMemo(() => Math.floor(extractZapAmount(event) / 1000), [event]);
const message = useMemo(() => extractZapMessage(event), [event]);
// Truncate long content, stripping media URLs, the previewed link, and nested nostr event references
const truncatedContent = useMemo(() => {
let text = event.content
// Strip media URLs (same extensions as NoteContent's MEDIA_URL_REGEX)
.replace(new RegExp(IMETA_MEDIA_URL_REGEX.source, 'gi'), '')
// Strip embedded event references (nevent / note) so they don't nest
.replace(/nostr:(nevent1|note1)[023456789acdefghjklmnpqrstuvwxyz]+/g, '');
// Strip the URL that will be shown as a link preview card
if (firstLinkUrl) {
text = text.replace(firstLinkUrl, '');
}
const cleaned = text
// Collapse leftover whitespace
.replace(/\n{3,}/g, '\n\n')
.trim();
if (cleaned.length <= MAX_CONTENT_LENGTH) return cleaned;
return cleaned.slice(0, MAX_CONTENT_LENGTH).trimEnd() + '…';
}, [event.content, firstLinkUrl]);
// For non-text kinds with empty content, extract title/description from tags
const tagMeta = useMemo(() => {
if (truncatedContent) return undefined;
const getTag = (name: string) => event.tags.find(([n]) => n === name)?.[1];
const title = getTag('title') || getTag('name') || getTag('d');
const description = getTag('summary') || getTag('description');
// Build a kind label line for context (e.g. "nsite")
const kindLabel = getKindLabel(event.kind);
const KindIcon = getKindIcon(event.kind);
if (!title && !description && !kindLabel) return undefined;
return { title, description, kindLabel, KindIcon };
}, [truncatedContent, event.tags, event.kind]);
// Detect stripped attachments to show indicator chips
const isPhoto = event.kind === 20;
const attachments = useMemo(() => {
// Kind 20 (NIP-68 photo events): count images from imeta tags instead of content
if (isPhoto) {
const photoCount = event.tags.filter(([n]) => n === 'imeta').length;
return { imgs: 0, vids: 0, auds: 0, apps: 0, links: 0, photos: photoCount };
}
const imgs = (event.content.match(new RegExp(IMAGE_URL_REGEX.source, 'gi')) || []).length;
const vids = extractVideoUrls(event.content).length;
const auds = extractAudioUrls(event.content).length;
const apps = (event.content.match(/https?:\/\/[^\s]+\.xdc(\?[^\s]*)?/gi) || []).length;
const allUrls = event.content.match(/https?:\/\/[^\s]+/g) || [];
const nonMediaLinks = allUrls.filter((u) => !IMETA_MEDIA_URL_REGEX.test(u)).length;
// Subtract 1 if we're showing a link preview card for the first URL
const links = firstLinkUrl ? nonMediaLinks - 1 : nonMediaLinks;
return { imgs, vids, auds, apps, links, photos: 0 };
}, [event.content, event.tags, isPhoto, firstLinkUrl]);
// NIP-36 content-warning check
const cwTag = event.tags.find(([name]) => name === 'content-warning');
const hasCW = !!cwTag;
// If policy is "hide", don't render the embedded note at all
if (hasCW && config.contentWarningPolicy === 'hide') {
return null;
}
const sender = useAuthor(senderPubkey || undefined);
const senderMeta = sender.data?.metadata;
const senderName = senderMeta?.name || (senderPubkey ? genUserName(senderPubkey) : 'Someone');
const senderShape = getAvatarShape(senderMeta);
const senderProfileUrl = useProfileUrl(senderPubkey, senderMeta);
return (
<div
@@ -232,178 +127,273 @@ function EmbeddedNoteCard({
}
}}
>
{/* Note content */}
<div className="px-3 py-2 space-y-1">
{/* Author row */}
<div className="flex items-center gap-2 min-w-0">
{author.isLoading ? (
<>
<Skeleton className="size-5 rounded-full shrink-0" />
<Skeleton className="h-3.5 w-24" />
</>
) : (
<>
<MaybeProfileHoverCard pubkey={event.pubkey} disabled={disableHoverCards}>
<Link
to={profileUrl}
className="shrink-0"
onClick={(e) => e.stopPropagation()}
>
<Avatar shape={avatarShape} className="size-5">
<AvatarImage src={metadata?.picture} alt={displayName} />
<AvatarFallback className="bg-primary/20 text-primary text-[10px]">
{displayName[0]?.toUpperCase()}
</AvatarFallback>
</Avatar>
</Link>
</MaybeProfileHoverCard>
<div className="px-3 py-2.5 flex items-center gap-2.5 min-w-0">
{/* Zap icon */}
<div className="flex items-center justify-center size-9 rounded-full bg-amber-500/10 shrink-0">
<Zap className="size-4 text-amber-500 fill-amber-500" />
</div>
<MaybeProfileHoverCard pubkey={event.pubkey} disabled={disableHoverCards}>
{/* Sender avatar */}
{senderPubkey && (
<MaybeHoverCard pubkey={senderPubkey} disabled={disableHoverCards}>
<Link to={senderProfileUrl} className="shrink-0" onClick={(e) => e.stopPropagation()}>
<Avatar shape={senderShape} className="size-5">
<AvatarImage src={senderMeta?.picture} alt={senderName} />
<AvatarFallback className="bg-primary/20 text-primary text-[10px]">
{senderName[0]?.toUpperCase()}
</AvatarFallback>
</Avatar>
</Link>
</MaybeHoverCard>
)}
{/* Text */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5 flex-wrap">
{senderPubkey ? (
<MaybeHoverCard pubkey={senderPubkey} disabled={disableHoverCards}>
<Link
to={profileUrl}
to={senderProfileUrl}
className="text-sm font-semibold truncate hover:underline"
onClick={(e) => e.stopPropagation()}
>
{author.data?.event ? (
<EmojifiedText tags={author.data.event.tags}>{displayName}</EmojifiedText>
) : displayName}
{sender.data?.event ? (
<EmojifiedText tags={sender.data.event.tags}>{senderName}</EmojifiedText>
) : senderName}
</Link>
</MaybeProfileHoverCard>
</>
</MaybeHoverCard>
) : (
<span className="text-sm font-semibold truncate">Someone</span>
)}
<span className="text-sm text-muted-foreground">zapped</span>
{amountSats > 0 && (
<span className="text-sm font-semibold text-amber-500 shrink-0">
{formatNumber(amountSats)} {amountSats === 1 ? 'sat' : 'sats'}
</span>
)}
<span className="text-xs text-muted-foreground shrink-0">
· {timeAgo(event.created_at)}
</span>
</div>
{message && (
<p className="text-xs text-muted-foreground italic mt-0.5 line-clamp-2">
&ldquo;{message}&rdquo;
</p>
)}
<span className="text-xs text-muted-foreground shrink-0">
· {timeAgo(event.created_at)}
</span>
</div>
{/* Content warning notice or text preview or tag-based metadata */}
{hasCW && config.contentWarningPolicy === 'blur' ? (
<p className="text-xs text-muted-foreground italic">
Content warning{cwTag?.[1] ? <>{' '}&ldquo;{cwTag[1]}&rdquo;</> : ''}
</p>
) : truncatedContent ? (
<EmbedContentPreview text={truncatedContent} disableHoverCards={disableHoverCards} />
) : tagMeta ? (
<>
{tagMeta.title && (
<p className="text-sm font-semibold leading-snug line-clamp-2">{tagMeta.title}</p>
)}
{tagMeta.description && (
<p className="text-xs text-muted-foreground leading-relaxed line-clamp-3">{tagMeta.description}</p>
)}
{tagMeta.kindLabel && (
<p className="flex items-center gap-1 text-xs text-muted-foreground">
{tagMeta.KindIcon && <tagMeta.KindIcon className="size-3 shrink-0" />}
{tagMeta.kindLabel}
</p>
)}
</>
) : null}
{/* Link preview card for the first non-media URL */}
{!hasCW && firstLinkUrl && (
<div onClick={(e) => e.stopPropagation()}>
<LinkPreview url={firstLinkUrl} className="mt-1.5" />
</div>
)}
{/* Attachment indicators for stripped media/links */}
{!hasCW && (attachments.photos > 0 || attachments.imgs > 0 || attachments.vids > 0 || attachments.auds > 0 || attachments.apps > 0 || attachments.links > 0) && (
<div className="flex items-center gap-2 flex-wrap">
{attachments.photos > 0 && (
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
<Image className="size-3" />
{attachments.photos > 1 ? `${attachments.photos} photos` : 'Photo'}
</span>
)}
{attachments.imgs > 0 && (
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
<Image className="size-3" />
{attachments.imgs > 1 ? `${attachments.imgs} images` : 'Image'}
</span>
)}
{attachments.vids > 0 && (
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
<Film className="size-3" />
{attachments.vids > 1 ? `${attachments.vids} videos` : 'Video'}
</span>
)}
{attachments.auds > 0 && (
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
<Music className="size-3" />
{attachments.auds > 1 ? `${attachments.auds} audio files` : 'Audio'}
</span>
)}
{attachments.apps > 0 && (
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
<Blocks className="size-3" />
App
</span>
)}
{attachments.links > 0 && (
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
<ExternalLink className="size-3" />
{attachments.links > 1 ? `${attachments.links} links` : 'Link'}
</span>
)}
</div>
)}
</div>
</div>
);
}
/** Renders embedded-note text with @mentions resolved inline. */
function EmbedContentPreview({ text, disableHoverCards }: { text: string; disableHoverCards?: boolean }) {
const segments = useMemo(() => parseEmbedSegments(text), [text]);
/** The actual card once the event has been fetched. */
function EmbeddedNoteCard({
event,
className,
disableHoverCards,
}: {
event: NostrEvent;
className?: string;
disableHoverCards?: boolean;
}) {
const { config } = useAppContext();
return (
<p className="text-sm leading-relaxed text-foreground whitespace-pre-wrap break-words overflow-hidden line-clamp-3">
{segments.map((seg, i) => {
if (seg.type === 'text') {
return <span key={i}>{seg.value}</span>;
}
return <EmbedMention key={i} pubkey={seg.pubkey} npub={seg.npub} disableHoverCards={disableHoverCards} />;
})}
</p>
const neventId = useMemo(
() => nip19.neventEncode({ id: event.id, author: event.pubkey }),
[event.id, event.pubkey],
);
}
/** Inline @mention inside an embedded note preview. */
function EmbedMention({ pubkey, disableHoverCards }: { pubkey: string; npub: string; disableHoverCards?: boolean }) {
const author = useAuthor(pubkey);
const hasRealName = !!author.data?.metadata?.name;
const displayName = author.data?.metadata?.name ?? genUserName(pubkey);
const profileUrl = useProfileUrl(pubkey, author.data?.metadata);
const [contentOverflows, setContentOverflows] = useState(false);
const [contentExpanded, setContentExpanded] = useState(false);
return (
<MaybeProfileHoverCard pubkey={pubkey} disabled={disableHoverCards}>
<Link
to={profileUrl}
className={cn(
'font-medium hover:underline',
hasRealName ? 'text-primary' : 'text-muted-foreground hover:text-foreground',
)}
onClick={(e) => e.stopPropagation()}
>
@{author.data?.event ? (
<EmojifiedText tags={author.data.event.tags}>{displayName}</EmojifiedText>
) : displayName}
</Link>
</MaybeProfileHoverCard>
);
}
const isBlobbiState = event.kind === 31124;
const isPhoto = event.kind === 20;
/** Conditionally wraps children in a ProfileHoverCard. When disabled, renders children directly. */
function MaybeProfileHoverCard({ pubkey, disabled, children }: { pubkey: string; disabled?: boolean; children: ReactNode }) {
if (disabled) {
return <>{children}</>;
// Attachment counts for indicator chips
const attachments = useMemo(() => {
if (isBlobbiState) return { imgs: 0, vids: 0, auds: 0, apps: 0, links: 0, photos: 0 };
if (isPhoto) {
const photoCount = event.tags.filter(([n]) => n === 'imeta').length;
return { imgs: 0, vids: 0, auds: 0, apps: 0, links: 0, photos: photoCount };
}
const imgs = (event.content.match(new RegExp(IMAGE_URL_REGEX.source, 'gi')) || []).length;
const vids = extractVideoUrls(event.content).length;
const auds = extractAudioUrls(event.content).length;
const apps = (event.content.match(/https?:\/\/[^\s]+\.xdc(\?[^\s]*)?/gi) || []).length;
const allUrls = event.content.match(/https?:\/\/[^\s]+/g) || [];
const links = allUrls.filter((u) => !IMETA_MEDIA_URL_TEST_REGEX.test(u)).length;
return { imgs, vids, auds, apps, links, photos: 0 };
}, [event.content, event.tags, isPhoto, isBlobbiState]);
// Kind label for non-text-note kinds
const kindMeta = useMemo(() => {
const label = getKindLabel(event.kind);
if (!label) return undefined;
return { label, Icon: getKindIcon(event.kind) };
}, [event.kind]);
// Tag-based fallback metadata for events with empty content (articles, custom kinds, etc.)
const hasContent = event.content.trim().length > 0;
const tagMeta = useMemo(() => {
if (hasContent) return undefined;
const getTag = (name: string) => event.tags.find(([n]) => n === name)?.[1];
const title = getTag('title') || getTag('name') || getTag('d');
const description = getTag('summary') || getTag('description');
if (!title && !description) return undefined;
return { title, description };
}, [hasContent, event.tags]);
// NIP-36 content-warning check
const cwTag = event.tags.find(([name]) => name === 'content-warning');
const hasCW = !!cwTag;
// If policy is "hide", don't render the embedded note at all
if (hasCW && config.contentWarningPolicy === 'hide') {
return null;
}
const hasChips = !hasCW && (
attachments.photos > 0 || attachments.imgs > 0 || attachments.vids > 0 ||
attachments.auds > 0 || attachments.apps > 0 || attachments.links > 0 || kindMeta
);
const hasFooter = hasChips || contentOverflows;
return (
<ProfileHoverCard pubkey={pubkey} asChild>
{children}
</ProfileHoverCard>
<EmbeddedCardShell
pubkey={event.pubkey}
createdAt={event.created_at}
navigateTo={neventId}
className={className}
disableHoverCards={disableHoverCards}
>
{/* Content — rendered identically to a normal NoteCard, just height-capped */}
{hasCW && config.contentWarningPolicy === 'blur' ? (
<p className="text-xs text-muted-foreground italic">
Content warning{cwTag?.[1] ? <>{' '}&ldquo;{cwTag[1]}&rdquo;</> : ''}
</p>
) : isBlobbiState ? (
<Suspense fallback={<Skeleton className="h-24 w-full rounded-lg" />}>
<BlobbiStateCard event={event} />
</Suspense>
) : tagMeta ? (
<>
{tagMeta.title && (
<p className="text-sm font-semibold leading-snug line-clamp-2">{tagMeta.title}</p>
)}
{tagMeta.description && (
<p className="text-xs text-muted-foreground leading-relaxed line-clamp-3">{tagMeta.description}</p>
)}
</>
) : (
<EmbedTruncatedContent event={event} expanded={contentExpanded} onOverflowChange={setContentOverflows} />
)}
{/* Attachment / kind indicator chips + Read more toggle */}
{hasFooter && (
<div className="flex items-center gap-2 flex-wrap">
{kindMeta && (
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
{kindMeta.Icon && <kindMeta.Icon className="size-3 shrink-0" />}
{kindMeta.label}
</span>
)}
{attachments.photos > 0 && (
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
<Image className="size-3" />
{attachments.photos > 1 ? `${attachments.photos} photos` : 'Photo'}
</span>
)}
{attachments.imgs > 0 && (
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
<Image className="size-3" />
{attachments.imgs > 1 ? `${attachments.imgs} images` : 'Image'}
</span>
)}
{attachments.vids > 0 && (
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
<Film className="size-3" />
{attachments.vids > 1 ? `${attachments.vids} videos` : 'Video'}
</span>
)}
{attachments.auds > 0 && (
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
<Music className="size-3" />
{attachments.auds > 1 ? `${attachments.auds} audio files` : 'Audio'}
</span>
)}
{attachments.apps > 0 && (
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
<Blocks className="size-3" />
App
</span>
)}
{attachments.links > 0 && (
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
<ExternalLink className="size-3" />
{attachments.links > 1 ? `${attachments.links} links` : 'Link'}
</span>
)}
{contentOverflows && (
<button
className="ml-auto text-xs text-primary hover:underline shrink-0"
onClick={(e) => {
e.stopPropagation();
setContentExpanded((v) => !v);
}}
>
{contentExpanded ? 'Show less' : 'Read more'}
</button>
)}
</div>
)}
</EmbeddedCardShell>
);
}
/** Truncated content area with overflow detection. Toggle is rendered externally. */
function EmbedTruncatedContent({ event, expanded, onOverflowChange }: {
event: NostrEvent;
expanded: boolean;
onOverflowChange: (overflows: boolean) => void;
}) {
const contentRef = useRef<HTMLDivElement>(null);
const [overflows, setOverflows] = useState(false);
const measure = useCallback(() => {
const el = contentRef.current;
if (!el) return;
const doesOverflow = el.scrollHeight > EMBED_MAX_HEIGHT;
setOverflows(doesOverflow);
onOverflowChange(doesOverflow);
}, [onOverflowChange]);
useEffect(() => {
measure();
window.addEventListener('resize', measure);
return () => window.removeEventListener('resize', measure);
}, [measure]);
// Re-measure after images load
useEffect(() => {
const el = contentRef.current;
if (!el) return;
const imgs = el.querySelectorAll('img');
if (imgs.length === 0) return;
imgs.forEach((img) => img.addEventListener('load', measure, { once: true }));
return () => imgs.forEach((img) => img.removeEventListener('load', measure));
}, [measure]);
return (
<div
ref={contentRef}
className="relative overflow-hidden"
style={!expanded && overflows ? { maxHeight: EMBED_MAX_HEIGHT } : undefined}
>
<NoteContent event={event} className="text-sm leading-relaxed" disableMediaEmbeds disableNoteEmbeds />
{!expanded && overflows && (
<div className="absolute bottom-0 left-0 right-0 h-10 bg-gradient-to-t from-background to-transparent pointer-events-none" />
)}
</div>
);
}
@@ -489,6 +479,16 @@ function EmbeddedNoteTombstone({ eventId, relays, authorHint, className }: { eve
);
}
/** Conditionally wraps children in a ProfileHoverCard. */
function MaybeHoverCard({ pubkey, disabled, children }: { pubkey: string; disabled?: boolean; children: ReactNode }) {
if (disabled) return <>{children}</>;
return (
<ProfileHoverCard pubkey={pubkey} asChild>
{children}
</ProfileHoverCard>
);
}
function EmbeddedNoteSkeleton({ className }: { className?: string }) {
return (
<div className={cn('rounded-2xl border border-border overflow-hidden', className)}>
+21 -10
View File
@@ -3,6 +3,7 @@ import data from '@emoji-mart/data';
import { CustomEmojiImg } from '@/components/CustomEmoji';
import { cn } from '@/lib/utils';
import { useCustomEmojis, type CustomEmoji } from '@/hooks/useCustomEmojis';
import { usePortalDropdown } from '@/hooks/usePortalDropdown';
interface EmojiData {
id: string;
@@ -186,6 +187,14 @@ export function EmojiShortcodeAutocomplete({
const dropdownRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const handleClose = useCallback(() => setIsOpen(false), []);
const { computePosition, renderPortal } = usePortalDropdown({
textareaRef,
isOpen,
onClose: handleClose,
dropdownHeight: 280, // must match max-h-[280px] below
});
const results = useMemo(() => searchEmojis(query, customEmojis), [query, customEmojis]);
// Detect :shortcode query at cursor
@@ -237,14 +246,11 @@ export function EmojiShortcodeAutocomplete({
setIsOpen(true);
setSelectedIndex(0);
// Position the dropdown below the : character
// Position the dropdown using fixed viewport coordinates so it isn't
// clipped by ancestor overflow containers (e.g. the compose modal).
const coords = getCaretCoordinates(textarea, colonPos);
const lineHeight = parseFloat(window.getComputedStyle(textarea).lineHeight) || 20;
setDropdownPos({
top: coords.top + lineHeight + 4,
left: Math.max(0, Math.min(coords.left, textarea.clientWidth - 280)),
});
}, [textareaRef]);
setDropdownPos(computePosition(coords));
}, [textareaRef, computePosition]);
// Listen for input/cursor changes on the textarea element
useEffect(() => {
@@ -357,10 +363,11 @@ export function EmojiShortcodeAutocomplete({
return null;
}
return (
const dropdown = (
<div
ref={dropdownRef}
className="absolute z-[100] w-[280px] rounded-xl border border-border bg-popover shadow-lg overflow-hidden animate-in fade-in-0 zoom-in-95 slide-in-from-top-2 duration-150"
data-autocomplete-dropdown
className="fixed z-[300] w-[280px] rounded-xl border border-border bg-popover shadow-lg overflow-hidden animate-in fade-in-0 zoom-in-95 slide-in-from-top-2 duration-150 pointer-events-auto"
style={{ top: dropdownPos.top, left: dropdownPos.left }}
>
<div ref={listRef} className="max-h-[280px] overflow-y-auto py-1">
@@ -382,7 +389,7 @@ export function EmojiShortcodeAutocomplete({
className="size-5 object-contain shrink-0"
/>
) : (
<span className="text-xl leading-none shrink-0">{emoji.native}</span>
<span className="text-xl leading-none shrink-0 font-emoji">{emoji.native}</span>
)}
<span className="text-sm truncate">
:{emoji.id.replace('custom:', '')}:
@@ -392,4 +399,8 @@ export function EmojiShortcodeAutocomplete({
</div>
</div>
);
// Portal to document.body so the dropdown escapes any ancestor overflow
// clipping and CSS transform containing blocks (e.g. Radix Dialog).
return renderPortal(dropdown, document.body);
}
+9 -34
View File
@@ -1,10 +1,9 @@
import { Link } from 'react-router-dom';
import { GripVertical, X, Globe, BookOpen } from 'lucide-react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { Globe, BookOpen } from 'lucide-react';
import { useMemo } from 'react';
import { cn } from '@/lib/utils';
import { SortableItemShell } from '@/components/SortableItemShell';
import { parseExternalUri, headerLabel } from '@/lib/externalContent';
import { getCountryInfo } from '@/lib/countries';
import { ExternalFavicon } from '@/components/ExternalFavicon';
@@ -18,6 +17,9 @@ export interface ExternalContentSidebarItemProps {
active: boolean;
editing: boolean;
onRemove: (id: string, index?: number) => void;
onAdd?: (id: string) => void;
/** True when this item is below the "More..." separator (hidden zone). */
belowMore?: boolean;
onClick?: (e: React.MouseEvent) => void;
/** Extra classes on the link. */
linkClassName?: string;
@@ -66,34 +68,17 @@ function ExternalSidebarLabel({ id }: { id: string }) {
// ── Main component ────────────────────────────────────────────────────────────
export function ExternalContentSidebarItem({
id, active, editing, onRemove, onClick, linkClassName,
id, active, editing, onRemove, onAdd, belowMore, onClick, linkClassName,
}: ExternalContentSidebarItemProps) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id, disabled: !editing });
const style = { transform: CSS.Transform.toString(transform), transition };
const path = `/i/${encodeURIComponent(id)}`;
return (
<div
ref={setNodeRef}
style={style}
className={cn('flex items-center rounded-full transition-colors relative bg-background/85', isDragging && 'z-10 opacity-80 shadow-lg')}
>
{editing && (
<button
className="flex items-center justify-center w-8 shrink-0 cursor-grab active:cursor-grabbing text-muted-foreground hover:text-foreground transition-colors"
{...attributes}
{...listeners}
>
<GripVertical className="size-4" />
</button>
)}
<SortableItemShell id={id} editing={editing} onRemove={onRemove} onAdd={onAdd} belowMore={belowMore}>
<Link
to={path}
onClick={onClick}
className={cn(
'flex items-center gap-4 py-3 rounded-full transition-colors hover:bg-secondary/60 flex-1 min-w-0',
'flex items-center gap-4 py-3 rounded-full transition-colors flex-1 min-w-0',
editing ? 'px-2' : 'px-3',
active ? 'font-bold text-primary' : 'font-normal text-foreground',
linkClassName ?? 'text-lg',
@@ -106,16 +91,6 @@ export function ExternalContentSidebarItem({
<ExternalSidebarLabel id={id} />
</span>
</Link>
{editing && (
<button
onClick={(e) => { e.stopPropagation(); onRemove(id); }}
className="flex items-center justify-center size-8 shrink-0 rounded-full transition-all text-muted-foreground hover:text-destructive hover:bg-destructive/10"
title="Remove"
>
<X className="size-4" />
</button>
)}
</div>
</SortableItemShell>
);
}
@@ -13,6 +13,7 @@ import {
useExternalReactionCount,
} from '@/hooks/useExternalReactions';
import { formatNumber } from '@/lib/formatNumber';
import { impactLight } from '@/lib/haptics';
import { cn } from '@/lib/utils';
import type { ExternalContent } from '@/lib/externalContent';
@@ -88,6 +89,7 @@ export function ExternalReactionButton({ content, iconSize = 'size-5', count, cl
// Publish kind 17 reaction
const handleReact = useCallback((emoji: string, emojiTag?: string[]) => {
if (!user) return;
impactLight();
const tags: string[][] = [
['k', getExternalKTag(content)],
+153 -134
View File
@@ -6,15 +6,15 @@ import { usePageRefresh } from '@/hooks/usePageRefresh';
import { ComposeBox } from '@/components/ComposeBox';
import { LandingHero } from '@/components/LandingHero';
import { NoteCard } from '@/components/NoteCard';
import { NoteCardSkeleton } from '@/components/NoteCardSkeleton';
import { PullToRefresh } from '@/components/PullToRefresh';
import { FeedEmptyState } from '@/components/FeedEmptyState';
import { Skeleton } from '@/components/ui/skeleton';
import { Loader2, MapPin } from 'lucide-react';
import LoginDialog from '@/components/auth/LoginDialog';
import { useOnboarding } from '@/hooks/useOnboarding';
import { useFeed } from '@/hooks/useFeed';
import { useFeedSettings } from '@/hooks/useFeedSettings';
import { DITTO_RELAYS } from '@/lib/appRelays';
import { useInfiniteHotFeed } from '@/hooks/useTrending';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useFeedTab } from '@/hooks/useFeedTab';
import { useInterests } from '@/hooks/useInterests';
@@ -22,15 +22,14 @@ import { useMuteList } from '@/hooks/useMuteList';
import { useSavedFeeds } from '@/hooks/useSavedFeeds';
import { useStreamPosts } from '@/hooks/useStreamPosts';
import { useResolveTabFilter } from '@/hooks/useResolveTabFilter';
import { useCuratorFollowList } from '@/hooks/useCuratorFollowList';
import { useCuratedDittoFeed } from '@/hooks/useCuratedDittoFeed';
import { getEnabledFeedKinds } from '@/lib/extraKinds';
import { diversifyFeedPages } from '@/lib/feedDiversity';
import { isRepostKind, shouldHideFeedEvent } from '@/lib/feedUtils';
import { isEventMuted } from '@/lib/muteHelpers';
import { SubHeaderBar } from '@/components/SubHeaderBar';
import { ARC_OVERHANG_PX } from '@/components/ArcBackground';
import { TabButton } from '@/components/TabButton';
import { DITTO_RELAYS } from '@/lib/appRelays';
import type { FeedItem } from '@/lib/feedUtils';
import type { NostrEvent } from '@nostrify/nostrify';
import type { SavedFeed } from '@/contexts/AppContext';
@@ -38,6 +37,22 @@ import type { SavedFeed } from '@/contexts/AppContext';
type CoreFeedTab = 'follows' | 'global' | 'communities' | 'ditto';
type FeedTab = CoreFeedTab | string; // string = saved feed id
/** Curated kinds for the logged-out homepage: unique Ditto content types. */
const LANDING_KINDS = [
36767, // Themes
37381, // Magic Decks
3367, // Color Moments
37516, // Treasures
7516, // Treasures (Found Logs)
30030, // Emoji Packs
30009, // Badge Definitions
10008, // Profile Badges
30008, // Profile Badges (legacy)
];
/** Webxdc needs a MIME-type tag filter, so it gets its own filter object. */
const LANDING_WEBXDC_FILTER = { kinds: [1063], '#m': ['application/x-webxdc'] };
interface FeedProps {
/** Override the kinds list instead of using feed settings. */
kinds?: number[];
@@ -59,8 +74,6 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
const { savedFeeds } = useSavedFeeds();
const { hashtags } = useInterests();
const { hashtags: geotags } = useInterests('g');
const { data: curatorFollowList, isError: isCuratorError } = useCuratorFollowList();
// Tab settings from localStorage
const showGlobalFeed = (() => {
const stored = localStorage.getItem('ditto:showGlobalFeed');
@@ -136,17 +149,21 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
(kinds || tagFilters) ? { kinds, tagFilters } : undefined,
);
// Curated Ditto feed: latest content from the curator's follow list.
const topQuery = useCuratedDittoFeed(
curatorFollowList,
// "Hot" sorted feed query (used when logged out on the home page, or on the Ditto tab)
// Shows curated "otherstuff" kinds instead of kind 1. Webxdc needs a
// separate filter with a MIME-type tag constraint.
const topQuery = useInfiniteHotFeed(
LANDING_KINDS,
useTopFeedForLoggedOut || !!useDittoTab,
undefined,
[LANDING_WEBXDC_FILTER],
);
// Unify the two query shapes behind a single interface
const useDittoQuery = useTopFeedForLoggedOut || useDittoTab;
const activeQuery = useDittoQuery ? topQuery : feedQuery;
const queryKey = useMemo(
() => useDittoQuery ? ['ditto-curated-feed'] : ['feed', activeTab],
() => useDittoQuery ? ['infinite-hot-feed', LANDING_KINDS.join(',')] : ['feed', activeTab],
[useDittoQuery, activeTab],
);
@@ -186,25 +203,16 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
const seen = new Set<string>();
if (useDittoQuery) {
// Deduplicate and filter each page independently, then diversify
// page-by-page so earlier pages never change when new pages arrive.
const dedupedPages = (rawData.pages as unknown as import('@nostrify/nostrify').NostrEvent[][])
.map((page) =>
page
.filter((event) => {
if (seen.has(event.id)) return false;
seen.add(event.id);
if (shouldHideFeedEvent(event)) return false;
if (muteItems.length > 0 && isEventMuted(event, muteItems)) return false;
return true;
})
.map((event): FeedItem => ({ event, sortTimestamp: event.created_at })),
);
// Reorder for content-type diversity: cap any single type at 20%
// per page and enforce a minimum gap of 4 positions between same-type
// items, with gap state carrying across page boundaries.
return diversifyFeedPages(dedupedPages);
return (rawData.pages as unknown as import('@nostrify/nostrify').NostrEvent[][])
.flat()
.filter((event) => {
if (seen.has(event.id)) return false;
seen.add(event.id);
if (shouldHideFeedEvent(event)) return false;
if (muteItems.length > 0 && isEventMuted(event, muteItems)) return false;
return true;
})
.map((event): FeedItem => ({ event, sortTimestamp: event.created_at }));
}
return (rawData.pages as unknown as { items: FeedItem[] }[])
@@ -219,9 +227,7 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
});
}, [rawData?.pages, muteItems, useDittoQuery]);
// Show skeletons while loading, but not if the curator list query errored
// (that would leave logged-out users staring at infinite skeletons).
const showSkeleton = (isPending || (isLoading && !rawData)) && !(useDittoQuery && isCuratorError);
const showSkeleton = isPending || (isLoading && !rawData);
// Kind-specific pages (e.g. Development, WebXDC) only show Follows + Global tabs.
// Extra tabs (Ditto, Community, saved feeds, hashtags) are only for the home feed.
@@ -229,7 +235,7 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
const showSavedFeedTabs = user && !isKindSpecificPage && !tagFilters;
return (
<main className="flex-1 min-w-0">
<main className="flex-1 min-w-0 min-h-dvh">
{/* CTA (logged out, main feed only) */}
{!user && !kinds && (
<LandingHero
@@ -290,9 +296,9 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
{/* Feed content — saved feed tab gets its own stream */}
{user && <div style={{ height: ARC_OVERHANG_PX }} />}
{activeHashtag ? (
<HashtagFeedContent tag={activeHashtag} />
<TagFeedContent tagKey="#t" tag={activeHashtag} emptyMessage={`No posts found with #${activeHashtag}.`} />
) : activeGeotag ? (
<GeotagFeedContent tag={activeGeotag} />
<TagFeedContent tagKey="#g" tag={activeGeotag} emptyMessage={`No posts found near ${activeGeotag}.`} />
) : activeSavedFeed ? (
<SavedFeedContent feed={activeSavedFeed} />
) : (
@@ -327,10 +333,11 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
message={
emptyMessage ?? (
activeTab === 'follows'
? 'No posts yet. Follow some people to see their content here.'
? 'Your feed is empty. Follow some people to see their posts here.'
: 'No posts found. Check your relay connections or come back soon.'
)
}
showDiscover={!emptyMessage && activeTab === 'follows'}
onSwitchToGlobal={
activeTab === 'follows' && showGlobalFeed
? () => handleSetActiveTab('global')
@@ -354,9 +361,97 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
);
}
/** Renders a saved search feed using useStreamPosts (live streaming). */
/** Renders a saved search feed using useStreamPosts (live streaming).
* When the feed has a spellId, the spell event is fetched and passed
* directly to useStreamPosts({ spell }) — the same path SpellRunPage uses —
* so all filter hints, tag filters, and variables resolve identically. */
function SavedFeedContent({ feed }: { feed: SavedFeed }) {
return feed.spellId
? <SpellFeedContent feed={feed} spellId={feed.spellId} />
: <LegacyFeedContent feed={feed} />;
}
/** Spell-driven saved feed: fetches the kind:777 event and streams via spell mode. */
function SpellFeedContent({ feed, spellId }: { feed: SavedFeed; spellId: string }) {
const { ref: scrollRef, inView } = useInView({ threshold: 0, rootMargin: '400px' });
const { nostr } = useNostr();
// Fetch the spell event by ID
const { data: spellEvent, isLoading: isLoadingSpell } = useQuery<NostrEvent | null>({
queryKey: ['spell-event', spellId],
queryFn: async ({ signal }) => {
const events = await nostr.query(
[{ ids: [spellId], kinds: [777], limit: 1 }],
{ signal: AbortSignal.any([signal, AbortSignal.timeout(10000)]) },
);
return events[0] ?? null;
},
staleTime: 5 * 60 * 1000,
});
// Use the exact same streaming path as SpellRunPage
const { posts, isLoading: isStreamLoading, newPostCount, flushStreamBuffer, loadMore, hasMore, isLoadingMore } = useStreamPosts('', {
includeReplies: true,
mediaType: 'all',
spell: spellEvent ?? undefined,
});
useEffect(() => {
if (inView && hasMore && !isLoadingMore) {
loadMore();
}
}, [inView, hasMore, isLoadingMore, loadMore]);
const isLoading = isLoadingSpell || (isStreamLoading && posts.length === 0);
if (isLoading) {
return (
<div className="divide-y divide-border">
{Array.from({ length: 5 }).map((_, i) => (
<NoteCardSkeleton key={i} />
))}
</div>
);
}
if (posts.length === 0) {
return (
<FeedEmptyState message={`No posts found for "${feed.label}". The search may return results as new content arrives.`} />
);
}
return (
<div>
{newPostCount > 0 && (
<button
onClick={() => {
flushStreamBuffer();
window.scrollTo({ top: 0, behavior: 'smooth' });
}}
className="w-full py-2 text-sm text-primary hover:bg-muted/50 border-b border-border transition-colors"
>
{newPostCount} new {newPostCount === 1 ? 'post' : 'posts'}
</button>
)}
{posts.map((event) => (
<NoteCard key={event.id} event={event} />
))}
{hasMore && (
<div ref={scrollRef} className="py-4">
{isLoadingMore && (
<div className="flex justify-center">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
)}
</div>
)}
</div>
);
}
/** Legacy saved feed without a spell ID: resolves filter variables and streams. */
function LegacyFeedContent({ feed }: { feed: SavedFeed }) {
const { ref: scrollRef } = useInView({ threshold: 0, rootMargin: '400px' });
const { user } = useCurrentUser();
const queryClient = useQueryClient();
@@ -371,27 +466,30 @@ function SavedFeedContent({ feed }: { feed: SavedFeed }) {
const kindsOverride = Array.isArray(resolvedFilter?.kinds) ? resolvedFilter.kinds as number[] : undefined;
const authorPubkeys = Array.isArray(resolvedFilter?.authors) ? resolvedFilter.authors as string[] : undefined;
// Read client-hint fields persisted by the save paths (_media, _language, etc.)
const rawFilter = feed.filter as Record<string, unknown>;
const mediaType = (typeof rawFilter._media === 'string' ? rawFilter._media : 'all') as 'all' | 'images' | 'videos' | 'vines' | 'none';
const language = typeof rawFilter._language === 'string' ? rawFilter._language : undefined;
const platform = typeof rawFilter._platform === 'string' ? rawFilter._platform : undefined;
const sort = (typeof rawFilter._sort === 'string' ? rawFilter._sort : undefined) as 'recent' | 'hot' | 'trending' | undefined;
const includeReplies = rawFilter._includeReplies === false ? false : true;
const { posts, isLoading: isStreamLoading } = useStreamPosts(search, {
includeReplies: true,
mediaType: 'all',
includeReplies,
mediaType,
language,
protocols: platform ? [platform] : undefined,
sort,
kindsOverride,
authorPubkeys: authorPubkeys && authorPubkeys.length > 0 ? authorPubkeys : undefined,
});
const isLoading = isResolving || isStreamLoading;
// useStreamPosts doesn't use TanStack Query, so refresh by invalidating the
// resolution query and letting the stream reconnect via remount.
const handleRefresh = useCallback(async () => {
await queryClient.invalidateQueries({ queryKey: ['resolve-tab-filter'] });
}, [queryClient]);
// Simple scroll-based load more isn't available with useStreamPosts (it's a stream),
// but we still wire the ref for future pagination support
useEffect(() => {
// intentionally empty — useStreamPosts handles its own streaming
}, [inView]);
if (isLoading && posts.length === 0) {
return (
<div className="divide-y divide-border">
@@ -422,80 +520,23 @@ function SavedFeedContent({ feed }: { feed: SavedFeed }) {
);
}
/** Renders a feed of posts tagged with a specific hashtag. */
function HashtagFeedContent({ tag }: { tag: string }) {
/** Renders a feed of posts matching a single-letter tag filter (#t for hashtags, #g for geotags). */
function TagFeedContent({ tagKey, tag, emptyMessage }: { tagKey: '#t' | '#g'; tag: string; emptyMessage: string }) {
const { nostr } = useNostr();
const { muteItems } = useMuteList();
const { feedSettings } = useFeedSettings();
const kinds = getEnabledFeedKinds(feedSettings).filter((k) => !isRepostKind(k));
const kindsKey = [...kinds].sort().join(',');
const queryKey = useMemo(() => ['hashtag-feed', tag, kindsKey], [tag, kindsKey]);
const feedType = tagKey === '#t' ? 'hashtag' : 'geotag';
const queryKey = useMemo(() => [`${feedType}-feed`, tag, kindsKey], [feedType, tag, kindsKey]);
const handleRefresh = usePageRefresh(queryKey);
const { data: events, isLoading } = useQuery<NostrEvent[]>({
queryKey,
queryFn: async ({ signal }) => {
const ditto = nostr.group(DITTO_RELAYS);
return ditto.query(
[{ kinds, '#t': [tag.toLowerCase()], limit: 40 }],
{ signal: AbortSignal.any([signal, AbortSignal.timeout(10000)]) },
);
},
});
const filteredEvents = useMemo((): NostrEvent[] => {
if (!events) return [];
if (muteItems.length === 0) return events;
return events.filter((e) => !isEventMuted(e, muteItems));
}, [events, muteItems]);
if (isLoading && filteredEvents.length === 0) {
return (
<div className="divide-y divide-border">
{Array.from({ length: 5 }).map((_, i) => (
<NoteCardSkeleton key={i} />
))}
</div>
);
}
if (filteredEvents.length === 0) {
return (
<PullToRefresh onRefresh={handleRefresh}>
<FeedEmptyState message={`No posts found with #${tag}.`} />
</PullToRefresh>
);
}
return (
<PullToRefresh onRefresh={handleRefresh}>
<div>
{filteredEvents.map((event) => (
<NoteCard key={event.id} event={event} />
))}
</div>
</PullToRefresh>
);
}
/** Renders a feed of posts tagged with a specific geohash. */
function GeotagFeedContent({ tag }: { tag: string }) {
const { nostr } = useNostr();
const { muteItems } = useMuteList();
const { feedSettings } = useFeedSettings();
const kinds = getEnabledFeedKinds(feedSettings).filter((k) => !isRepostKind(k));
const kindsKey = [...kinds].sort().join(',');
const queryKey = useMemo(() => ['geotag-feed', tag, kindsKey], [tag, kindsKey]);
const handleRefresh = usePageRefresh(queryKey);
const { data: events, isLoading } = useQuery<NostrEvent[]>({
queryKey,
queryFn: async ({ signal }) => {
const ditto = nostr.group(DITTO_RELAYS);
const filter = { kinds, limit: 40 } as Record<string, unknown>;
filter['#g'] = [tag];
const filter = { kinds, limit: 40, [tagKey]: [tagKey === '#t' ? tag.toLowerCase() : tag] } as Record<string, unknown>;
return ditto.query([filter as Parameters<typeof ditto.query>[0][number]], {
signal: AbortSignal.any([signal, AbortSignal.timeout(10000)]),
});
@@ -521,7 +562,7 @@ function GeotagFeedContent({ tag }: { tag: string }) {
if (filteredEvents.length === 0) {
return (
<PullToRefresh onRefresh={handleRefresh}>
<FeedEmptyState message={`No posts found near ${tag}.`} />
<FeedEmptyState message={emptyMessage} />
</PullToRefresh>
);
}
@@ -537,26 +578,4 @@ function GeotagFeedContent({ tag }: { tag: string }) {
);
}
function NoteCardSkeleton() {
return (
<div className="px-4 py-3 border-b border-border">
<div className="flex items-center gap-3">
<Skeleton className="size-11 rounded-full shrink-0" />
<div className="min-w-0 space-y-1.5">
<Skeleton className="h-4 w-28" />
<Skeleton className="h-3 w-36" />
</div>
</div>
<div className="mt-2 space-y-1.5">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-4/5" />
</div>
<div className="flex items-center gap-6 mt-3 -ml-2">
<Skeleton className="h-4 w-8" />
<Skeleton className="h-4 w-8" />
<Skeleton className="h-4 w-8" />
<Skeleton className="h-4 w-8" />
</div>
</div>
);
}
+28 -11
View File
@@ -1,3 +1,6 @@
import { Link } from 'react-router-dom';
import { Users } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
interface FeedEmptyStateProps {
@@ -5,31 +8,45 @@ interface FeedEmptyStateProps {
message: string;
/** Called when the user clicks "Switch to Global". Omit to hide the button. */
onSwitchToGlobal?: () => void;
/** Show a "Discover people" link to /packs. */
showDiscover?: boolean;
className?: string;
}
/**
* Consistent empty state for Follows/Global feed tabs across all feed pages.
*
* - Follows tab: pass `onSwitchToGlobal` to render a "Switch to Global" CTA.
* - Global tab: omit `onSwitchToGlobal`; the message should guide the user
* - Follows tab: pass `onSwitchToGlobal` and `showDiscover` to render CTAs.
* - Global tab: omit both; the message should guide the user
* to check their relay connections.
*/
export function FeedEmptyState({
message,
onSwitchToGlobal,
showDiscover,
className,
}: FeedEmptyStateProps) {
return (
<div className={cn('py-16 px-8 text-center space-y-3', className)}>
<p className="text-muted-foreground break-all">{message}</p>
{onSwitchToGlobal && (
<button
className="text-sm text-primary hover:underline"
onClick={onSwitchToGlobal}
>
Switch to Global
</button>
<div className={cn('py-20 px-8 flex flex-col items-center text-center', className)}>
<div className="size-12 rounded-full bg-muted flex items-center justify-center mb-4">
<Users className="size-6 text-muted-foreground" />
</div>
<p className="text-muted-foreground max-w-xs">{message}</p>
{(showDiscover || onSwitchToGlobal) && (
<div className="flex flex-col gap-2 mt-5 w-full max-w-xs">
{showDiscover && (
<Button asChild className="rounded-full">
<Link to="/packs">Discover people to follow</Link>
</Button>
)}
{onSwitchToGlobal && (
<Button variant="ghost" className="rounded-full" onClick={onSwitchToGlobal}>
Browse the Global feed
</Button>
)}
</div>
)}
</div>
);
+2 -1
View File
@@ -10,6 +10,7 @@ import { useAuthor } from '@/hooks/useAuthor';
import { getDisplayName } from '@/lib/getDisplayName';
import { genUserName } from '@/lib/genUserName';
import { getAvatarShape } from '@/lib/avatarShape';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
/** Extract the first value of a tag by name. */
function getTag(tags: string[][], name: string): string | undefined {
@@ -75,7 +76,7 @@ interface FileMetadataContentProps {
* rounded card below it (similar to YouTube's description box).
*/
export function FileMetadataContent({ event, compact }: FileMetadataContentProps) {
const url = getTag(event.tags, 'url');
const url = sanitizeUrl(getTag(event.tags, 'url'));
const mime = getTag(event.tags, 'm') ?? '';
const alt = getTag(event.tags, 'alt');
const webxdcId = getTag(event.tags, 'webxdc');
+3
View File
@@ -4,6 +4,7 @@ import { Button } from '@/components/ui/button';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useFollowList, useFollowActions } from '@/hooks/useFollowActions';
import { useToast } from '@/hooks/useToast';
import { impactMedium } from '@/lib/haptics';
import { cn } from '@/lib/utils';
interface FollowButtonProps {
@@ -39,9 +40,11 @@ export function FollowButton({ pubkey, className, size = 'sm' }: FollowButtonPro
try {
if (isFollowing) {
await unfollow(pubkey);
impactMedium();
toast({ title: 'Unfollowed' });
} else {
await follow(pubkey);
impactMedium();
toast({ title: 'Followed' });
}
} catch (err) {
+1 -119
View File
@@ -9,125 +9,7 @@ import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAuthor } from '@/hooks/useAuthor';
import { genUserName } from '@/lib/genUserName';
import { parseHsl, hslToRgb, rgbToHex, getContrastRatio, isDarkTheme } from '@/lib/colorUtils';
/** Minimum contrast ratio between QR modules and background for reliable scanning. */
const MIN_QR_CONTRAST = 3;
/** Saturation threshold (%) above which a color is considered "colorful". */
const COLORFUL_SAT_MIN = 15;
/** Lightness range within which a color appears visually colorful. */
const COLORFUL_L_MIN = 20;
const COLORFUL_L_MAX = 80;
/** Read a CSS custom property as a parsed HSL object, or null if unavailable. */
function readCssHsl(prop: string): { h: number; s: number; l: number } | null {
if (typeof document === 'undefined') return null;
const raw = getComputedStyle(document.documentElement).getPropertyValue(prop).trim();
if (!raw) return null;
const { h, s, l } = parseHsl(raw);
if ([h, s, l].some(isNaN)) return null;
return { h, s, l };
}
/**
* Darken an HSL color until it reaches the minimum contrast against a reference RGB.
* Returns the adjusted hex color.
*/
function darkenToContrast(
hsl: { h: number; s: number; l: number },
refRgb: [number, number, number],
): string {
let l = hsl.l;
let rgb = hslToRgb(hsl.h, hsl.s, l);
let ratio = getContrastRatio(rgb, refRgb);
while (l > 0 && ratio < MIN_QR_CONTRAST) {
l = Math.max(0, l - 2);
rgb = hslToRgb(hsl.h, hsl.s, l);
ratio = getContrastRatio(rgb, refRgb);
}
return rgbToHex(...rgb);
}
/**
* Lighten an HSL color until it reaches the minimum contrast against a reference RGB.
* Returns the adjusted hex color.
*/
function lightenToContrast(
hsl: { h: number; s: number; l: number },
refRgb: [number, number, number],
): string {
let l = hsl.l;
let rgb = hslToRgb(hsl.h, hsl.s, l);
let ratio = getContrastRatio(rgb, refRgb);
while (l < 100 && ratio < MIN_QR_CONTRAST) {
l = Math.min(100, l + 2);
rgb = hslToRgb(hsl.h, hsl.s, l);
ratio = getContrastRatio(rgb, refRgb);
}
return rgbToHex(...rgb);
}
/**
* Choose the best module color from primary and foreground.
*
* Strongly prefers primary since it carries the theme's brand identity.
* Only picks foreground if it is colorful (saturation > threshold) AND
* has significantly better contrast (> 1.5x) against the QR background.
*/
function pickModuleColor(
primary: { h: number; s: number; l: number },
foreground: { h: number; s: number; l: number } | null,
bgRgb: [number, number, number],
): { h: number; s: number; l: number } {
const fgIsColorful = foreground
&& foreground.s >= COLORFUL_SAT_MIN
&& foreground.l >= COLORFUL_L_MIN
&& foreground.l <= COLORFUL_L_MAX;
if (!fgIsColorful) return primary;
const primaryRgb = hslToRgb(primary.h, primary.s, primary.l);
const fgRgb = hslToRgb(foreground.h, foreground.s, foreground.l);
const primaryContrast = getContrastRatio(primaryRgb, bgRgb);
const fgContrast = getContrastRatio(fgRgb, bgRgb);
// Foreground must be significantly better to override primary
return fgContrast > primaryContrast * 1.5 ? foreground : primary;
}
/**
* Derive QR module and background hex colors from the active theme.
*
* Light themes: white background, best themed color as modules (darkened if needed).
* Dark themes: --background as QR background, best themed color as modules (lightened if needed).
*
* "Best themed color" is --primary by default. If --foreground is colorful
* (saturation > 15%) and offers better contrast, it wins instead.
*/
function getThemedQRColors(): { dark: string; light: string } {
const primary = readCssHsl('--primary');
const foreground = readCssHsl('--foreground');
const background = readCssHsl('--background');
if (!primary) return { dark: '#000000', light: '#ffffff' };
const isDark = background ? isDarkTheme(`${background.h} ${background.s}% ${background.l}%`) : false;
if (!isDark) {
const white: [number, number, number] = [255, 255, 255];
const module = pickModuleColor(primary, foreground, white);
return { dark: darkenToContrast(module, white), light: '#ffffff' };
}
if (!background) return { dark: '#ffffff', light: '#000000' };
const bgRgb = hslToRgb(background.h, background.s, background.l);
const module = pickModuleColor(primary, foreground, bgRgb);
return {
dark: lightenToContrast(module, bgRgb),
light: rgbToHex(...bgRgb),
};
}
import { getThemedQRColors } from '@/lib/qrColors';
interface FollowQRDialogProps {
open: boolean;
+3 -2
View File
@@ -1,5 +1,6 @@
import { useCallback, useRef } from 'react';
import { cn } from '@/lib/utils';
import { impactLight } from '@/lib/haptics';
import type { WebxdcHandle } from '@/components/Webxdc';
// ---------------------------------------------------------------------------
@@ -28,9 +29,9 @@ type GameButton = keyof typeof KEY_MAP;
/** Buttons that trigger haptic feedback on press. */
const HAPTIC_BUTTONS = new Set<GameButton>(['a', 'b']);
/** Trigger a short vibration if the Vibration API is available. */
/** Trigger a short vibration via the native haptic engine. */
function haptic() {
navigator.vibrate?.(25);
impactLight();
}
// ---------------------------------------------------------------------------
+2 -1
View File
@@ -3,6 +3,7 @@ import { BookMarked, Copy, Check, ExternalLink, Globe, Wand2 } from "lucide-reac
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { openUrl } from "@/lib/downloadFile";
import { sanitizeUrl } from "@/lib/sanitizeUrl";
import { NostrURI } from "@/lib/NostrURI";
interface GitRepoCardProps {
@@ -23,7 +24,7 @@ function getFaviconUrl(webUrl: string): string | undefined {
export function GitRepoCard({ event }: GitRepoCardProps) {
const name = event.tags.find(([n]) => n === "name")?.[1];
const description = event.tags.find(([n]) => n === "description")?.[1];
const webUrls = event.tags.filter(([n]) => n === "web").map(([, v]) => v);
const webUrls = event.tags.filter(([n]) => n === "web").map(([, v]) => sanitizeUrl(v)).filter((v): v is string => !!v);
const isPersonalFork = event.tags.some(
([n, v]) => n === "t" && v === "personal-fork",
);
+41 -61
View File
@@ -4,7 +4,6 @@ import { useQueryClient } from "@tanstack/react-query";
import {
Check,
ChevronRight,
Download,
Eye,
EyeOff,
Heart,
@@ -13,7 +12,7 @@ import {
Users,
} from "lucide-react";
import { generateSecretKey, getPublicKey, nip19 } from "nostr-tools";
import { downloadTextFile } from "@/lib/downloadFile";
import { saveNsec } from "@/lib/credentialManager";
import { fetchFreshEvent } from "@/lib/fetchFreshEvent";
import {
type ReactNode,
@@ -45,6 +44,7 @@ import { toast } from "@/hooks/useToast";
import { useUploadFile } from "@/hooks/useUploadFile";
import { genUserName } from "@/lib/genUserName";
import { getAvatarShape } from "@/lib/avatarShape";
import { resolveTheme, resolveThemeConfig } from "@/themes";
import { cn } from "@/lib/utils";
// ---------------------------------------------------------------------------
@@ -288,7 +288,8 @@ function SetupQuestionnaire({
}
}, [step, steps]);
// Keygen handler
// Keygen handler — generates the key and advances to the save step.
// The credential manager prompt is deferred until the user clicks "Continue".
const handleGenerate = useCallback(() => {
const sk = generateSecretKey();
const encoded = nip19.nsecEncode(sk);
@@ -296,26 +297,26 @@ function SetupQuestionnaire({
next();
}, [next]);
// Download + login handler
const handleDownloadAndLogin = useCallback(async () => {
// Continue handler for the download step — saves the key via the best
// available method (native credential manager on iOS/Android, file download
// on web), logs in, and advances to the next step.
const handleDownloadContinue = useCallback(async () => {
try {
const decoded = nip19.decode(nsec);
if (decoded.type !== "nsec") throw new Error("Invalid nsec");
const pubkey = getPublicKey(decoded.data);
const npub = nip19.npubEncode(pubkey);
const filename = `nostr-${location.hostname.replaceAll(/\./g, "-")}-${npub.slice(5, 9)}.nsec.txt`;
await downloadTextFile(filename, nsec);
await saveNsec(npub, nsec);
// Log in with the new key
login.nsec(nsec);
next();
} catch {
toast({
title: "Download failed",
title: "Save failed",
description:
"Could not download the key file. Please copy it manually.",
"Could not save the key. Please copy it manually.",
variant: "destructive",
});
}
@@ -447,7 +448,7 @@ function SetupQuestionnaire({
{step === "keygen" && <KeygenStep onGenerate={handleGenerate} />}
{step === "download" && (
<DownloadStep nsec={nsec} onDownload={handleDownloadAndLogin} />
<DownloadStep nsec={nsec} onContinue={handleDownloadContinue} />
)}
{step === "profile" && (
@@ -514,10 +515,10 @@ function KeygenStep({ onGenerate }: { onGenerate: () => void }) {
function DownloadStep({
nsec,
onDownload,
onContinue,
}: {
nsec: string;
onDownload: () => void;
onContinue: () => void;
}) {
const [showKey, setShowKey] = useState(false);
@@ -528,8 +529,7 @@ function DownloadStep({
Save your secret key
</h2>
<p className="text-sm text-muted-foreground">
This is your only way to access your account. Download it and keep it
somewhere safe.
This is your only way to access your account. Keep it somewhere safe.
</p>
</div>
@@ -561,17 +561,17 @@ function DownloadStep({
</p>
<p className="text-xs text-amber-900 dark:text-amber-300">
This key is your only means of accessing your account. If you lose it,
there is no way to recover it. Download it now to continue.
there is no way to recover it.
</p>
</div>
<Button
size="lg"
className="w-full gap-2 rounded-full h-12"
onClick={onDownload}
onClick={onContinue}
>
<Download className="w-4 h-4" />
Download and continue
Continue
<ChevronRight className="w-4 h-4" />
</Button>
</div>
);
@@ -599,9 +599,6 @@ function ProfileStep({
banner: "",
website: "",
});
const [extraFields, setExtraFields] = useState<
Array<{ label: string; value: string }>
>([]);
const [cropState, setCropState] = useState<{
imageSrc: string;
aspect: number;
@@ -656,17 +653,10 @@ function ProfileStep({
const handlePublishProfile = useCallback(async () => {
if (!user) return;
const hasData =
Object.values(profileData).some((v) => v) || extraFields.length > 0;
const hasData = Object.values(profileData).some((v) => v);
if (hasData) {
try {
const data: Record<string, unknown> = { ...profileData };
const validFields = extraFields.filter(
(f) => f.label.trim() && f.value.trim(),
);
if (validFields.length > 0)
data.fields = validFields.map((f) => [f.label, f.value]);
await publishEvent({ kind: 0, content: JSON.stringify(data), tags: [] });
await publishEvent({ kind: 0, content: JSON.stringify(profileData), tags: [] });
queryClient.invalidateQueries({ queryKey: ["logins"] });
queryClient.invalidateQueries({ queryKey: ["author", user.pubkey] });
} catch {
@@ -679,7 +669,7 @@ function ProfileStep({
}
}
onNext();
}, [user, profileData, extraFields, publishEvent, queryClient, onNext]);
}, [user, profileData, publishEvent, queryClient, onNext]);
return (
<div className="flex flex-col gap-6 animate-in fade-in slide-in-from-right-4 duration-400">
@@ -725,8 +715,6 @@ function ProfileStep({
}
onPickImage={handlePickImage}
showNip05={false}
extraFields={extraFields}
onExtraFieldsChange={setExtraFields}
/>
</div>
@@ -736,31 +724,21 @@ function ProfileStep({
</div>
)}
<div className="flex gap-3">
<Button
variant="ghost"
onClick={onNext}
className="flex-1 rounded-full h-11"
disabled={isPublishing || isSaving}
>
Skip
</Button>
<Button
onClick={handlePublishProfile}
className="flex-1 rounded-full h-11 gap-1.5"
disabled={isPublishing || isUploading || isSaving}
>
{isPublishing || isSaving ? (
<>
<Loader2 className="w-4 h-4 animate-spin" /> Saving…
</>
) : (
<>
Continue <ChevronRight className="w-4 h-4" />
</>
)}
</Button>
</div>
<Button
onClick={handlePublishProfile}
className="w-full rounded-full h-11 gap-1.5"
disabled={isPublishing || isUploading || isSaving}
>
{isPublishing || isSaving ? (
<>
<Loader2 className="w-4 h-4 animate-spin" /> Saving…
</>
) : (
<>
Continue <ChevronRight className="w-4 h-4" />
</>
)}
</Button>
</div>
);
}
@@ -780,8 +758,10 @@ function ThemeStep({
isFirst?: boolean;
isSaving?: boolean;
}) {
const { customTheme } = useTheme();
const bgUrl = customTheme?.background?.url;
const { theme, customTheme, themes } = useTheme();
const resolved = resolveTheme(theme);
const activeConfig = resolved === 'custom' ? customTheme : resolveThemeConfig(resolved, themes);
const bgUrl = activeConfig?.background?.url;
return (
<>
+58 -123
View File
@@ -1,8 +1,9 @@
import { useState, useCallback } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import {
UserPlus, LogOut,
Loader2, QrCode,
QrCode,
} from 'lucide-react';
import { Skeleton } from '@/components/ui/skeleton';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
@@ -10,9 +11,9 @@ import { getAvatarShape } from '@/lib/avatarShape';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { DittoLogo } from '@/components/DittoLogo';
import { EmojifiedText } from '@/components/CustomEmoji';
import { ProfileSearchDropdown } from '@/components/ProfileSearchDropdown';
import { SidebarNavList } from '@/components/SidebarNavItem';
import { SidebarMoreMenu } from '@/components/SidebarMoreMenu';
import { StatusEditor } from '@/components/StatusEditor';
import LoginDialog from '@/components/auth/LoginDialog';
import { FollowQRDialog } from '@/components/FollowQRDialog';
@@ -22,6 +23,7 @@ import { useLoggedInAccounts, type Account } from '@/hooks/useLoggedInAccounts';
import { useLoginActions } from '@/hooks/useLoginActions';
import { useFeedSettings } from '@/hooks/useFeedSettings';
import { useSidebarEditing } from '@/hooks/useSidebarEditing';
import { useAppContext } from '@/hooks/useAppContext';
import { useHasUnreadNotifications } from '@/hooks/useHasUnreadNotifications';
import { genUserName } from '@/lib/genUserName';
@@ -29,10 +31,7 @@ import { VerifiedNip05Text } from '@/components/Nip05Badge';
import { useProfileUrl } from '@/hooks/useProfileUrl';
import { isItemActive } from '@/lib/sidebarItems';
import { useUserStatus } from '@/hooks/useUserStatus';
import { usePublishStatus } from '@/hooks/usePublishStatus';
import { useToast } from '@/hooks/useToast';
import { Input } from '@/components/ui/input';
export function LeftSidebar() {
@@ -48,8 +47,7 @@ export function LeftSidebar() {
} = useFeedSettings();
const { config } = useAppContext();
const visibleItems = orderedItems;
const visibleHiddenItems = hiddenItems;
const hasUnread = useHasUnreadNotifications();
const userProfileUrl = useProfileUrl(user?.pubkey ?? '', metadata);
@@ -58,17 +56,13 @@ export function LeftSidebar() {
const [accountPopoverOpen, setAccountPopoverOpen] = useState(false);
const [followQROpen, setFollowQROpen] = useState(false);
const [editing, setEditing] = useState(false);
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
// NIP-38 status
const userStatus = useUserStatus(user?.pubkey);
const publishStatus = usePublishStatus();
const { toast } = useToast();
const [statusEditing, setStatusEditing] = useState(false);
const [statusDraft, setStatusDraft] = useState('');
const homePage = config.homePage;
const { editingItems, handleEditReorder, handleEditRemove } = useSidebarEditing({
editing, items: orderedItems, hiddenItems, updateSidebarOrder, removeFromSidebar,
});
const scrollToTopIfCurrent = useCallback((to: string) => (e: React.MouseEvent) => {
if (location.pathname === to) {
e.preventDefault();
@@ -76,7 +70,7 @@ export function LeftSidebar() {
}
}, [location.pathname]);
const getDisplayName = (account: Account) => account.metadata.name ?? genUserName(account.pubkey);
const getDisplayName = (account: Account) => account.metadata.display_name || account.metadata.name || genUserName(account.pubkey);
const handleLogout = async () => {
setAccountPopoverOpen(false);
@@ -95,36 +89,53 @@ export function LeftSidebar() {
</Link>
</div>
{/* Search */}
<div className="px-2 py-4">
<ProfileSearchDropdown placeholder="Search..." inputClassName="py-3.5" enableTextSearch />
</div>
{/* Nav */}
<nav className="flex flex-col gap-0.5 flex-1 min-h-0 overflow-y-auto overflow-x-hidden">
<SidebarNavList
items={visibleItems}
editing={editing}
onRemove={removeFromSidebar}
onReorder={updateSidebarOrder}
isActive={(id) => isItemActive(id, location.pathname, location.search, userProfileUrl, homePage)}
getOnClick={(id) => id === homePage ? scrollToTopIfCurrent('/') : undefined}
getProfilePath={(id) => id === 'profile' ? userProfileUrl : undefined}
getShowIndicator={(id) => id === 'notifications' ? hasUnread : undefined}
homePage={homePage}
/>
<SidebarMoreMenu
editing={editing}
hiddenItems={visibleHiddenItems}
onDoneEditing={() => setEditing(false)}
onStartEditing={() => setEditing(true)}
onAdd={addToSidebar}
onAddDivider={addDividerToSidebar}
open={moreMenuOpen}
onOpenChange={setMoreMenuOpen}
homePage={homePage}
/>
{editing ? (
<>
<SidebarNavList
items={editingItems}
editing
onRemove={handleEditRemove}
onAdd={addToSidebar}
onReorder={handleEditReorder}
isActive={() => false}
homePage={homePage}
/>
<SidebarMoreMenu
editing
hiddenItems={hiddenItems}
onDoneEditing={() => setEditing(false)}
onStartEditing={() => setEditing(true)}
onAdd={addToSidebar}
onAddDivider={addDividerToSidebar}
homePage={homePage}
/>
</>
) : (
<>
<SidebarNavList
items={orderedItems}
editing={false}
onRemove={removeFromSidebar}
onReorder={updateSidebarOrder}
isActive={(id) => isItemActive(id, location.pathname, location.search, userProfileUrl, homePage)}
getOnClick={(id) => id === homePage ? scrollToTopIfCurrent('/') : undefined}
getProfilePath={(id) => id === 'profile' ? userProfileUrl : undefined}
getShowIndicator={(id) => id === 'notifications' ? hasUnread : undefined}
homePage={homePage}
/>
<SidebarMoreMenu
editing={false}
hiddenItems={hiddenItems}
onDoneEditing={() => setEditing(false)}
onStartEditing={() => setEditing(true)}
onAdd={addToSidebar}
onAddDivider={addDividerToSidebar}
homePage={homePage}
/>
</>
)}
</nav>
{/* Logged-out join pill — same position as account button, pushed up from bottom */}
@@ -151,7 +162,7 @@ export function LeftSidebar() {
<Avatar shape={currentUserAvatarShape} className="size-10 shrink-0">
<AvatarImage src={metadata?.picture} alt={metadata?.name} />
<AvatarFallback className="bg-primary/20 text-primary text-sm">
{(metadata?.name?.[0] || '?').toUpperCase()}
{(metadata?.display_name || metadata?.name || genUserName(user.pubkey))[0]?.toUpperCase() ?? '?'}
</AvatarFallback>
</Avatar>
)}
@@ -194,83 +205,7 @@ export function LeftSidebar() {
{/* Status editor */}
<div className="border-b border-border">
{statusEditing ? (
<div className="p-3 space-y-2">
<Input
value={statusDraft}
onChange={(e) => setStatusDraft(e.target.value.slice(0, 80))}
placeholder="What are you up to?"
className="h-8 text-base md:text-sm"
maxLength={80}
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
const text = statusDraft.trim();
publishStatus.mutateAsync({ status: text }).then(() => {
setStatusEditing(false);
setStatusDraft('');
toast({ title: text ? 'Status updated' : 'Status cleared' });
});
} else if (e.key === 'Escape') {
setStatusEditing(false);
setStatusDraft('');
}
}}
/>
<div className="flex items-center gap-1.5">
<button
onClick={() => {
const text = statusDraft.trim();
publishStatus.mutateAsync({ status: text }).then(() => {
setStatusEditing(false);
setStatusDraft('');
toast({ title: text ? 'Status updated' : 'Status cleared' });
});
}}
disabled={publishStatus.isPending}
className="text-xs font-medium text-primary hover:underline disabled:opacity-50"
>
{publishStatus.isPending ? <Loader2 className="size-3 animate-spin" /> : 'Save'}
</button>
{userStatus.status && (
<button
onClick={() => {
publishStatus.mutateAsync({ status: '' }).then(() => {
setStatusEditing(false);
setStatusDraft('');
toast({ title: 'Status cleared' });
});
}}
disabled={publishStatus.isPending}
className="text-xs font-medium text-destructive hover:underline disabled:opacity-50"
>
Clear
</button>
)}
<button
onClick={() => { setStatusEditing(false); setStatusDraft(''); }}
className="text-xs text-muted-foreground hover:underline ml-auto"
>
Cancel
</button>
</div>
</div>
) : (
<button
onClick={() => {
setStatusEditing(true);
setStatusDraft(userStatus.status ?? '');
}}
className="flex items-center gap-3 w-full px-4 py-2.5 text-sm hover:bg-secondary/60 transition-colors"
>
{userStatus.status ? (
<span className="truncate text-muted-foreground italic text-xs pr-1">{userStatus.status}</span>
) : (
<span className="text-muted-foreground">Set a status</span>
)}
</button>
)}
<StatusEditor pubkey={user.pubkey} />
</div>
{/* Other accounts */}
+212
View File
@@ -0,0 +1,212 @@
import { useState, useCallback, useEffect } from 'react';
import { Zap, Copy, Check, ExternalLink } from 'lucide-react';
import QRCode from 'qrcode';
import { Button } from '@/components/ui/button';
import { useToast } from '@/hooks/useToast';
import { openUrl } from '@/lib/downloadFile';
import { getThemedQRColors } from '@/lib/qrColors';
import { cn } from '@/lib/utils';
interface LightningInvoiceCardProps {
invoice: string;
className?: string;
}
/** Parse the sats amount from a BOLT11 invoice's human-readable part. */
function parseBolt11Amount(bolt11: string): number | null {
const match = bolt11.toLowerCase().match(/^ln\w+?(\d+)([munp]?)1/);
if (!match) return null;
const value = parseInt(match[1], 10);
if (isNaN(value)) return null;
const multiplier = match[2];
switch (multiplier) {
case 'm': return value * 100_000; // milli-BTC → sats
case 'u': return value * 100; // micro-BTC → sats
case 'n': return value / 10; // nano-BTC → sats
case 'p': return value / 10_000; // pico-BTC → sats
default: return value * 100_000_000; // BTC → sats
}
}
/** Format sats with thousands separator. */
function formatSats(sats: number): string {
if (sats < 1) return '<1';
const rounded = Math.round(sats);
return rounded.toLocaleString();
}
/**
* Inline card for rendering a BOLT11 lightning invoice found in note content.
* Horizontal layout with theme-aware QR that expands on tap.
* Amount text scales to fit via container query units.
*/
export function LightningInvoiceCard({ invoice, className }: LightningInvoiceCardProps) {
const { toast } = useToast();
const [copied, setCopied] = useState(false);
const [paying, setPaying] = useState(false);
const [qrDataUrl, setQrDataUrl] = useState<string>('');
const [qrExpanded, setQrExpanded] = useState(false);
const amount = parseBolt11Amount(invoice);
// Generate theme-aware QR code
useEffect(() => {
let cancelled = false;
const { dark, light } = getThemedQRColors();
QRCode.toDataURL(invoice.toUpperCase(), {
width: 400,
margin: 2,
color: { dark, light },
errorCorrectionLevel: 'M',
}).then((url) => {
if (!cancelled) setQrDataUrl(url);
}).catch(() => {});
return () => { cancelled = true; };
}, [invoice]);
const handleCopy = useCallback(async (e: React.MouseEvent) => {
e.stopPropagation();
try {
await navigator.clipboard.writeText(invoice);
setCopied(true);
toast({ title: 'Copied', description: 'Lightning invoice copied to clipboard' });
} catch {
toast({ title: 'Failed to copy', variant: 'destructive' });
}
}, [invoice, toast]);
useEffect(() => {
if (!copied) return;
const t = setTimeout(() => setCopied(false), 2000);
return () => clearTimeout(t);
}, [copied]);
const handleOpenWallet = useCallback(async (e: React.MouseEvent) => {
e.stopPropagation();
await openUrl(`lightning:${invoice}`);
}, [invoice]);
const handlePayWebLN = useCallback(async (e: React.MouseEvent) => {
e.stopPropagation();
const webln = (globalThis as { webln?: { enable?: () => Promise<void>; sendPayment?: (invoice: string) => Promise<unknown> } }).webln;
if (!webln?.sendPayment) return;
try {
setPaying(true);
if (webln.enable) await webln.enable();
await webln.sendPayment(invoice);
toast({ title: 'Payment sent' });
} catch {
toast({ title: 'Payment failed', variant: 'destructive' });
} finally {
setPaying(false);
}
}, [invoice, toast]);
const toggleQr = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
setQrExpanded((v) => !v);
}, []);
const hasWebLN = typeof globalThis !== 'undefined' && !!(globalThis as { webln?: unknown }).webln;
const qrImage = qrDataUrl ? (
<img
src={qrDataUrl}
alt="Lightning Invoice QR"
className="rounded-xl"
style={{ imageRendering: 'pixelated' }}
/>
) : (
<div className="aspect-square rounded-xl bg-muted animate-pulse" />
);
return (
<div
className={cn(
'isolate my-2.5 relative rounded-2xl border border-border overflow-hidden @container',
className,
)}
onClick={(e) => e.stopPropagation()}
>
{/* Subtle accent glow behind QR area */}
<div className="absolute -z-10 top-0 left-0 w-44 h-44 bg-primary/[0.06] rounded-full blur-2xl" />
{/* Expanded QR -- square container that replaces the normal layout */}
{qrExpanded ? (
<button
onClick={toggleQr}
className="w-full aspect-square cursor-pointer p-5"
>
{qrDataUrl ? (
<img
src={qrDataUrl}
alt="Lightning Invoice QR"
className="w-full h-full rounded-xl"
style={{ imageRendering: 'pixelated' }}
/>
) : (
<div className="w-full h-full rounded-xl bg-muted animate-pulse" />
)}
</button>
) : (
<div className="flex gap-1">
{/* QR code -- tappable thumbnail */}
<button onClick={toggleQr} className="shrink-0 p-3 cursor-pointer">
<div className="size-28 sm:size-40">{qrImage}</div>
</button>
{/* Info column */}
<div className="flex flex-col justify-between py-3.5 pr-3.5 min-w-0 flex-1 gap-2">
{/* Label + amount */}
<div>
<div className="flex items-center gap-1.5 text-muted-foreground font-medium whitespace-nowrap" style={{ fontSize: 'clamp(0.8rem, 3.5cqw, 1.05rem)' }}>
<span className="flex items-center justify-center size-5 sm:size-6 rounded-full bg-primary/15 shrink-0">
<Zap className="size-3 sm:size-3.5 text-primary fill-primary" />
</span>
Lightning Invoice
</div>
{amount !== null && (
<div className="font-bold tracking-tight leading-none mt-1 whitespace-nowrap" style={{ fontSize: 'clamp(1.5rem, 8cqw, 2.5rem)' }}>
{formatSats(amount)}
<span className="font-normal text-muted-foreground ml-1" style={{ fontSize: 'clamp(0.75rem, 3.5cqw, 1.125rem)' }}>sats</span>
</div>
)}
</div>
{/* Invoice string with copy */}
<button
onClick={handleCopy}
className="flex items-center gap-1.5 group max-w-full"
>
<span className="truncate text-xs font-mono text-muted-foreground group-hover:text-foreground transition-colors">
{invoice}
</span>
{copied
? <Check className="size-3.5 text-primary shrink-0" />
: <Copy className="size-3.5 text-muted-foreground group-hover:text-foreground shrink-0 transition-colors" />}
</button>
{/* Action buttons */}
<div className="flex items-center gap-2">
{hasWebLN && (
<Button
size="sm"
onClick={handlePayWebLN}
disabled={paying}
className="gap-1.5 h-9 rounded-xl"
>
<Zap className="size-3.5" />
{paying ? 'Paying...' : 'Pay'}
</Button>
)}
<Button size="sm" variant="outline" onClick={handleOpenWallet} className="gap-1.5 h-9 rounded-xl">
<ExternalLink className="size-3.5" />
Open in Wallet
</Button>
</div>
</div>
</div>
)}
</div>
);
}
+7 -5
View File
@@ -1,4 +1,4 @@
import { Suspense, useState, useMemo, useCallback, useRef } from 'react';
import { Suspense, useState, useMemo, useCallback, useRef, lazy } from 'react';
import { Outlet } from 'react-router-dom';
import { LeftSidebar } from '@/components/LeftSidebar';
import { MobileTopBar } from '@/components/MobileTopBar';
@@ -12,6 +12,8 @@ import { useAppContext } from '@/hooks/useAppContext';
import { useScrollDirection } from '@/hooks/useScrollDirection';
import { cn } from '@/lib/utils';
const WidgetSidebar = lazy(() => import('@/components/WidgetSidebar').then((m) => ({ default: m.WidgetSidebar })));
/** Skeleton shown in the content area while a lazy page chunk is loading. */
function PageSkeleton() {
return (
@@ -49,7 +51,7 @@ function PageSkeleton() {
/** Inner component that reads layout options from the context store. */
function MainLayoutInner() {
const { showFAB = false, fabKind = 1, fabHref, onFabClick, fabIcon, wrapperClassName, noOverscroll, noMaxWidth, scrollContainer, hasSubHeader, hideTopBar, hideBottomNav } = useLayoutSnapshot();
const { rightSidebar, showFAB = false, fabKind = 1, fabHref, onFabClick, fabIcon, wrapperClassName, noOverscroll, noMaxWidth, scrollContainer, hasSubHeader, hideTopBar, hideBottomNav } = useLayoutSnapshot();
const [drawerOpen, setDrawerOpen] = useState(false);
const openDrawer = useCallback(() => setDrawerOpen(true), []);
const centerColumnRef = useRef<HTMLDivElement>(null);
@@ -104,8 +106,8 @@ function MainLayoutInner() {
</div>
)}
</div>
{/* Right sidebar placeholder — preserves layout width */}
<div className="w-[300px] shrink-0 hidden xl:block" />
{/* Right sidebar — render page-provided sidebar, or the widget sidebar */}
{rightSidebar ?? <Suspense fallback={<div className="w-[300px] shrink-0 hidden xl:block" />}><WidgetSidebar /></Suspense>}
</Suspense>
</div>
@@ -118,7 +120,7 @@ function MainLayoutInner() {
{showFAB && (
<div
className="fixed bottom-fab right-6 z-30 pointer-events-none transition-transform duration-300 ease-in-out sidebar:hidden"
style={navHidden ? { transform: `translateY(calc(var(--bottom-nav-height) + env(safe-area-inset-bottom, 0px)))` } : undefined}
style={navHidden ? { transform: `translateY(calc(var(--bottom-nav-height) + var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px))))` } : undefined}
>
<div className="pointer-events-auto">
<FloatingComposeButton kind={fabKind} href={fabHref} onFabClick={onFabClick} icon={fabIcon} />
+20 -10
View File
@@ -8,6 +8,7 @@ import { useSearchProfiles, type SearchProfile } from '@/hooks/useSearchProfiles
import { genUserName } from '@/lib/genUserName';
import { useNip05Verify } from '@/hooks/useNip05Verify';
import { cn } from '@/lib/utils';
import { usePortalDropdown } from '@/hooks/usePortalDropdown';
interface MentionAutocompleteProps {
textareaRef: React.RefObject<HTMLTextAreaElement | null>;
@@ -89,6 +90,14 @@ export function MentionAutocomplete({
const dropdownRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const handleClose = useCallback(() => setIsOpen(false), []);
const { computePosition, renderPortal } = usePortalDropdown({
textareaRef,
isOpen,
onClose: handleClose,
dropdownHeight: 240, // must match max-h-[240px] below
});
const { data: profiles, followedPubkeys } = useSearchProfiles(
isOpen ? mentionQuery : '',
);
@@ -140,15 +149,11 @@ export function MentionAutocomplete({
setIsOpen(true);
setSelectedIndex(0);
// Position the dropdown below the @ character, relative to the textarea's
// offsetParent (the `relative` wrapper div) so it stays inside the modal.
// Position the dropdown using fixed viewport coordinates so it isn't
// clipped by ancestor overflow containers (e.g. the compose modal).
const coords = getCaretCoordinates(textarea, atPos);
const lineHeight = parseFloat(window.getComputedStyle(textarea).lineHeight) || 20;
setDropdownPos({
top: coords.top + lineHeight + 4,
left: Math.max(0, Math.min(coords.left, textarea.clientWidth - 280)),
});
}, [textareaRef]);
setDropdownPos(computePosition(coords));
}, [textareaRef, computePosition]);
// Listen for input/cursor changes on the textarea element.
// Re-attaches whenever the underlying DOM element changes (e.g. after
@@ -254,10 +259,11 @@ export function MentionAutocomplete({
return null;
}
return (
const dropdown = (
<div
ref={dropdownRef}
className="absolute z-[100] w-[280px] rounded-xl border border-border bg-popover shadow-lg overflow-hidden animate-in fade-in-0 zoom-in-95 slide-in-from-top-2 duration-150"
data-autocomplete-dropdown
className="fixed z-[300] w-[280px] rounded-xl border border-border bg-popover shadow-lg overflow-hidden animate-in fade-in-0 zoom-in-95 slide-in-from-top-2 duration-150 pointer-events-auto"
style={{ top: dropdownPos.top, left: dropdownPos.left }}
>
<div ref={listRef} className="max-h-[240px] overflow-y-auto py-1">
@@ -273,6 +279,10 @@ export function MentionAutocomplete({
</div>
</div>
);
// Portal to document.body so the dropdown escapes any ancestor overflow
// clipping and CSS transform containing blocks (e.g. Radix Dialog).
return renderPortal(dropdown, document.body);
}
function MentionItem({
+148 -84
View File
@@ -1,6 +1,6 @@
import { useCallback, useState } from 'react';
import { useCallback, useState, useMemo } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { Bell, Home, Search, User } from 'lucide-react';
import { Bot, User } from 'lucide-react';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { getAvatarShape } from '@/lib/avatarShape';
import { cn } from '@/lib/utils';
@@ -8,11 +8,15 @@ import { useHasUnreadNotifications } from '@/hooks/useHasUnreadNotifications';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useScrollDirection } from '@/hooks/useScrollDirection';
import { useProfileUrl } from '@/hooks/useProfileUrl';
import { useAppContext } from '@/hooks/useAppContext';
import { useLayoutSnapshot } from '@/contexts/LayoutContext';
import { getSidebarItem } from '@/lib/sidebarItems';
import { ArcBackground, ARC_UP_OVERHANG_PX } from '@/components/ArcBackground';
import { MobileSearchSheet } from '@/components/MobileSearchSheet';
import { MobileBuddySheet } from '@/components/AIChat/MobileBuddySheet';
import { useFeedSettings } from '@/hooks/useFeedSettings';
import { useAppContext } from '@/hooks/useAppContext';
import { useAuthor } from '@/hooks/useAuthor';
import { useBuddy } from '@/hooks/useBuddy';
import { getSidebarItem, isSidebarDivider, sidebarItemIcon, itemLabel, itemPath, isItemActive } from '@/lib/sidebarItems';
/** Transform style applied when the bottom nav is hidden (scrolled away). */
const hiddenStyle: React.CSSProperties = {
@@ -26,33 +30,67 @@ export function MobileBottomNav() {
const { scrollContainer, noArcs } = useLayoutSnapshot();
const { hidden } = useScrollDirection(scrollContainer);
const profileUrl = useProfileUrl(user?.pubkey ?? '', metadata);
const { orderedItems } = useFeedSettings();
const { config } = useAppContext();
const homeItem = getSidebarItem(config.homePage);
const HomeIcon = homeItem?.icon ?? Home;
const homeLabel = homeItem?.label ?? 'Home';
const homePath = homeItem?.path;
const homePage = config.homePage;
const { buddy } = useBuddy();
const buddyAuthor = useAuthor(buddy?.pubkey);
const buddyMetadata = buddyAuthor.data?.metadata;
const [searchOpen, setSearchOpen] = useState(false);
const [buddyOpen, setBuddyOpen] = useState(false);
const handleSearchClick = useCallback((e: React.MouseEvent) => {
e.preventDefault();
setSearchOpen((v) => !v);
setBuddyOpen(false);
}, []);
// Hide the nav when search sheet is open so it doesn't compete for space
const isHidden = hidden || searchOpen;
const handleBuddyClick = useCallback((e: React.MouseEvent) => {
e.preventDefault();
setBuddyOpen((v) => !v);
setSearchOpen(false);
}, []);
const handleClose = useCallback(() => {
setSearchOpen(false);
setBuddyOpen(false);
}, []);
const sheetOpen = searchOpen || buddyOpen;
// Only hide nav on scroll — keep it visible when sheets are open so the
// user can see the active tab and tap between them.
const isHidden = hidden && !sheetOpen;
const displayName = metadata?.name || metadata?.display_name;
const isOnProfile = user && location.pathname === profileUrl;
// Show only the first 4 sidebar items (matching sidebar order), filtering out dividers and auth-gated items when logged out
const allItems = useMemo(() => {
return orderedItems.filter((id) => {
if (isSidebarDivider(id)) return false;
if (!user && getSidebarItem(id)?.requiresAuth) return false;
return true;
}).slice(0, 4);
}, [orderedItems, user]);
return (
<>
<MobileSearchSheet open={searchOpen} onClose={() => setSearchOpen(false)} />
{/* Shared backdrop for sheets */}
{sheetOpen && (
<div
className="fixed inset-0 z-40 bg-black/60 sidebar:hidden animate-in fade-in-0 duration-150"
onClick={handleClose}
/>
)}
{/* Search and buddy sheets are independent */}
{searchOpen && <MobileSearchSheet open onClose={handleClose} />}
{buddyOpen && <MobileBuddySheet hidden={false} onClose={handleClose} />}
<nav
className={cn(
'fixed bottom-0 left-0 right-0 z-40 sidebar:hidden will-change-transform',
'fixed bottom-0 left-0 right-0 z-[49] sidebar:hidden will-change-transform',
'transition-transform duration-300 ease-in-out',
)}
style={isHidden ? hiddenStyle : undefined}
@@ -61,80 +99,106 @@ export function MobileBottomNav() {
<div className="relative">
<ArcBackground variant={noArcs ? 'rect' : 'up'} />
<div className="h-11 flex items-center relative">
{allItems.map((id) => {
const isSearch = id === 'search';
const isBuddy = id === 'ai-chat';
const isProfile = id === 'profile';
const isNotifications = id === 'notifications';
const active = isSearch
? searchOpen
: isBuddy
? buddyOpen
: isItemActive(id, location.pathname, location.search, profileUrl, homePage);
const label = itemLabel(id);
const path = isProfile ? profileUrl : itemPath(id, undefined, homePage);
{/* Home */}
<Link
to="/"
onClick={() => setSearchOpen(false)}
className={cn(
'flex flex-col items-center justify-center gap-0.5 flex-1 py-2 transition-colors',
(location.pathname === '/' || location.pathname === homePath) ? 'text-primary' : 'text-muted-foreground',
)}
>
<HomeIcon className="size-5" />
<span className="text-[10px] font-medium">{homeLabel}</span>
</Link>
// Search opens the search sheet instead of navigating
if (isSearch) {
return (
<button
key={id}
onClick={handleSearchClick}
className={cn(
'flex flex-col items-center justify-center gap-0.5 flex-1 py-2 transition-colors',
active ? 'text-primary' : 'text-muted-foreground',
)}
>
{sidebarItemIcon(id, 'size-5')}
<span className="text-[10px] font-medium">{label}</span>
</button>
);
}
{/* Search */}
<button
onClick={handleSearchClick}
className={cn(
'flex flex-col items-center justify-center gap-0.5 flex-1 py-2 transition-colors',
searchOpen ? 'text-primary' : 'text-muted-foreground',
)}
>
<Search className="size-5" />
<span className="text-[10px] font-medium">Search</span>
</button>
// Buddy opens the AI chat sheet instead of navigating
if (isBuddy) {
const hasBuddyPicture = !!buddyMetadata?.picture;
return (
<button
key={id}
onClick={handleBuddyClick}
className={cn(
'flex flex-col items-center justify-center gap-0.5 flex-1 py-2 transition-colors',
active ? 'text-primary' : 'text-muted-foreground',
)}
>
{hasBuddyPicture ? (
<Avatar shape={getAvatarShape(buddyMetadata)} className="size-5">
<AvatarImage src={buddyMetadata.picture} alt={buddy?.name} />
<AvatarFallback className="bg-primary/20 text-primary text-[8px]">
<Bot className="size-3" />
</AvatarFallback>
</Avatar>
) : (
sidebarItemIcon(id, 'size-5')
)}
<span className="text-[10px] font-medium">{hasBuddyPicture ? buddy?.name ?? label : label}</span>
</button>
);
}
{/* Notifications */}
{user && (
<Link
to="/notifications"
onClick={() => setSearchOpen(false)}
className={cn(
'flex flex-col items-center justify-center gap-0.5 flex-1 py-2 transition-colors',
location.pathname === '/notifications' ? 'text-primary' : 'text-muted-foreground',
)}
>
<span className="relative">
<Bell className="size-5" />
{hasUnread && (
<span className="absolute -top-1 right-0 size-2 bg-primary rounded-full" />
)}
</span>
<span className="text-[10px] font-medium">Notifications</span>
</Link>
)}
{/* Profile */}
{user ? (
<Link
to={profileUrl}
onClick={() => setSearchOpen(false)}
className={cn(
'flex flex-col items-center justify-center gap-0.5 flex-1 py-2 transition-colors',
isOnProfile ? 'text-primary' : 'text-muted-foreground',
)}
>
<Avatar shape={getAvatarShape(metadata)} className="size-5">
<AvatarImage src={metadata?.picture} alt={displayName} />
<AvatarFallback className="bg-primary/20 text-primary text-[8px]">
{displayName?.[0]?.toUpperCase() || <User className="size-3" />}
</AvatarFallback>
</Avatar>
<span className="text-[10px] font-medium">Profile</span>
</Link>
) : (
<Link
to="/profile"
className="flex flex-col items-center justify-center gap-0.5 flex-1 py-2 transition-colors text-muted-foreground"
>
<User className="size-5" />
<span className="text-[10px] font-medium">Profile</span>
</Link>
)}
// Profile shows the user avatar
if (isProfile && user) {
return (
<Link
key={id}
to={path}
onClick={handleClose}
className={cn(
'flex flex-col items-center justify-center gap-0.5 flex-1 py-2 transition-colors',
active ? 'text-primary' : 'text-muted-foreground',
)}
>
<Avatar shape={getAvatarShape(metadata)} className="size-5">
<AvatarImage src={metadata?.picture} alt={displayName} />
<AvatarFallback className="bg-primary/20 text-primary text-[8px]">
{displayName?.[0]?.toUpperCase() || <User className="size-3" />}
</AvatarFallback>
</Avatar>
<span className="text-[10px] font-medium">{label}</span>
</Link>
);
}
return (
<Link
key={id}
to={path}
onClick={handleClose}
className={cn(
'flex flex-col items-center justify-center gap-0.5 flex-1 py-2 transition-colors',
active ? 'text-primary' : 'text-muted-foreground',
)}
>
<span className="relative">
{sidebarItemIcon(id, 'size-5')}
{isNotifications && hasUnread && (
<span className="absolute -top-1 right-0 size-2 bg-primary rounded-full" />
)}
</span>
<span className="text-[10px] font-medium">{label}</span>
</Link>
);
})}
</div>
</div>
{/* Safe area fill — matches the arc's semi-transparent background */}
+78 -123
View File
@@ -1,11 +1,13 @@
import { useState, useId, useMemo } from 'react';
import { useState, useCallback, useId, useMemo } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { ChevronDown, ChevronUp, LogOut, UserPlus, Loader2, QrCode } from 'lucide-react';
import { ChevronDown, ChevronUp, LogOut, UserPlus, QrCode } from 'lucide-react';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { getAvatarShape } from '@/lib/avatarShape';
import { Sheet, SheetContent, SheetTitle } from '@/components/ui/sheet';
import { SidebarNavList } from '@/components/SidebarNavItem';
import { useSidebarEditing } from '@/hooks/useSidebarEditing';
import { SidebarMoreMenu } from '@/components/SidebarMoreMenu';
import { StatusEditor } from '@/components/StatusEditor';
import { LoginArea } from '@/components/auth/LoginArea';
import { LinkFooter } from '@/components/LinkFooter';
@@ -24,10 +26,8 @@ import { useProfileUrl } from '@/hooks/useProfileUrl';
import { isItemActive } from '@/lib/sidebarItems';
import { useAppContext } from '@/hooks/useAppContext';
import { useTheme } from '@/hooks/useTheme';
import { useUserStatus } from '@/hooks/useUserStatus';
import { usePublishStatus } from '@/hooks/usePublishStatus';
import { useToast } from '@/hooks/useToast';
import { Input } from '@/components/ui/input';
import { PortalContainerProvider } from '@/hooks/usePortalContainer';
import { resolveTheme, resolveThemeConfig } from '@/themes';
/** Total width of the drawer background layer: 300px drawer + 36px arc overhang. */
@@ -58,19 +58,21 @@ export function MobileDrawer({ open, onOpenChange }: MobileDrawerProps) {
const homePage = config.homePage;
const hasUnread = useHasUnreadNotifications();
const [editing, setEditing] = useState(false);
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
const [accountExpanded, setAccountExpanded] = useState(false);
const [loginDialogOpen, setLoginDialogOpen] = useState(false);
const [followQROpen, setFollowQROpen] = useState(false);
const { startSignup } = useOnboarding();
const { theme, customTheme, themes } = useTheme();
// NIP-38 status
const userStatus = useUserStatus(user?.pubkey);
const publishStatus = usePublishStatus();
const { toast } = useToast();
const [statusEditing, setStatusEditing] = useState(false);
const [statusDraft, setStatusDraft] = useState('');
// Portal container for dropdown popovers inside the Sheet so they scroll
// correctly and aren't blocked by Radix Dialog's RemoveScroll.
const [portalContainer, setPortalContainer] = useState<HTMLElement | undefined>(undefined);
const sheetContentRef = useCallback((node: HTMLElement | null) => {
setPortalContainer(node ?? undefined);
}, []);
/** Compute the background image style for the drawer, mirroring the body background. */
const bgStyle = useMemo<React.CSSProperties>(() => {
@@ -96,17 +98,21 @@ export function MobileDrawer({ open, onOpenChange }: MobileDrawerProps) {
});
}, [orderedItems]);
const visibleHiddenItems = hiddenItems;
const handleClose = () => { onOpenChange(false); setMoreMenuOpen(false); };
const { editingItems, handleEditReorder, handleEditRemove } = useSidebarEditing({
editing, items: visibleItems, hiddenItems, updateSidebarOrder, removeFromSidebar,
});
const handleClose = () => { onOpenChange(false); };
const handleLogout = async () => { await logout(); handleClose(); navigate('/'); };
const getDisplayName = (account: Account) => account.metadata.name ?? genUserName(account.pubkey);
const displayName = metadata?.name || (user ? genUserName(user.pubkey) : 'Anonymous');
return (
<>
<Sheet open={open} onOpenChange={(v) => { if (!v) setMoreMenuOpen(false); onOpenChange(v); }}>
<SheetContent side="left" className="w-[300px] p-0 gap-0 border-r-border flex flex-col overflow-visible">
<Sheet open={open} onOpenChange={(v) => { if (!v) { setEditing(false); } onOpenChange(v); }}>
<SheetContent ref={sheetContentRef} side="left" className="w-[300px] p-0 gap-0 border-r-border flex flex-col overflow-visible">
{/* SVG clip path definition for the drawer + arc shape.
The clip path uses objectBoundingBox units so the arc scales with the
background layer. The 0.893 ratio ≈ DRAWER_WIDTH / DRAWER_BG_WIDTH
@@ -133,6 +139,7 @@ export function MobileDrawer({ open, onOpenChange }: MobileDrawerProps) {
/>
)}
<SheetTitle className="sr-only">Navigation menu</SheetTitle>
<PortalContainerProvider value={portalContainer}>
{user ? (
<div className="flex flex-col h-full relative">
@@ -140,7 +147,7 @@ export function MobileDrawer({ open, onOpenChange }: MobileDrawerProps) {
<button
onClick={() => setAccountExpanded((v) => !v)}
className="flex items-center gap-3 px-3 hover:bg-secondary/60 transition-colors w-full text-left"
style={{ minHeight: `calc(3rem + env(safe-area-inset-top, 0px))`, paddingTop: `env(safe-area-inset-top, 0px)` }}
style={{ minHeight: `calc(3rem + var(--safe-area-inset-top, env(safe-area-inset-top, 0px)))`, paddingTop: `var(--safe-area-inset-top, env(safe-area-inset-top, 0px))` }}
>
<Avatar shape={currentUserAvatarShape} className="size-7 shrink-0">
<AvatarImage src={metadata?.picture} alt={displayName} />
@@ -169,83 +176,7 @@ export function MobileDrawer({ open, onOpenChange }: MobileDrawerProps) {
<div>
{/* Status editor */}
<div className="border-b border-border">
{statusEditing ? (
<div className="px-3 py-2 space-y-2">
<Input
value={statusDraft}
onChange={(e) => setStatusDraft(e.target.value.slice(0, 80))}
placeholder="What are you up to?"
className="h-8 text-base md:text-sm"
maxLength={80}
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
const text = statusDraft.trim();
publishStatus.mutateAsync({ status: text }).then(() => {
setStatusEditing(false);
setStatusDraft('');
toast({ title: text ? 'Status updated' : 'Status cleared' });
});
} else if (e.key === 'Escape') {
setStatusEditing(false);
setStatusDraft('');
}
}}
/>
<div className="flex items-center gap-1.5">
<button
onClick={() => {
const text = statusDraft.trim();
publishStatus.mutateAsync({ status: text }).then(() => {
setStatusEditing(false);
setStatusDraft('');
toast({ title: text ? 'Status updated' : 'Status cleared' });
});
}}
disabled={publishStatus.isPending}
className="text-xs font-medium text-primary hover:underline disabled:opacity-50"
>
{publishStatus.isPending ? <Loader2 className="size-3 animate-spin" /> : 'Save'}
</button>
{userStatus.status && (
<button
onClick={() => {
publishStatus.mutateAsync({ status: '' }).then(() => {
setStatusEditing(false);
setStatusDraft('');
toast({ title: 'Status cleared' });
});
}}
disabled={publishStatus.isPending}
className="text-xs font-medium text-destructive hover:underline disabled:opacity-50"
>
Clear
</button>
)}
<button
onClick={() => { setStatusEditing(false); setStatusDraft(''); }}
className="text-xs text-muted-foreground hover:underline ml-auto"
>
Cancel
</button>
</div>
</div>
) : (
<button
onClick={() => {
setStatusEditing(true);
setStatusDraft(userStatus.status ?? '');
}}
className="flex items-center gap-3 w-full px-3 py-2.5 text-sm hover:bg-secondary/60 transition-colors"
>
{userStatus.status ? (
<span className="truncate text-muted-foreground italic text-xs pr-1">{userStatus.status}</span>
) : (
<span className="text-muted-foreground">Set a status</span>
)}
</button>
)}
<StatusEditor pubkey={user.pubkey} formClassName="px-3 py-2" buttonClassName="px-3" />
</div>
{otherUsers.map((account) => (
<button
@@ -300,30 +231,55 @@ export function MobileDrawer({ open, onOpenChange }: MobileDrawerProps) {
className="flex flex-col gap-0.5 flex-1 min-h-0 overflow-y-auto overflow-x-hidden p-1"
>
<div className="contents">
<SidebarNavList
items={visibleItems}
editing={editing}
onRemove={removeFromSidebar}
onReorder={updateSidebarOrder}
isActive={(id) => isItemActive(id, location.pathname, location.search, userProfileUrl, homePage)}
getOnClick={() => handleClose}
getProfilePath={(id) => id === 'profile' ? userProfileUrl : undefined}
getShowIndicator={(id) => id === 'notifications' ? hasUnread : undefined}
linkClassName="text-base"
homePage={homePage}
/>
<SidebarMoreMenu
editing={editing}
hiddenItems={visibleHiddenItems}
onDoneEditing={() => setEditing(false)}
onStartEditing={() => setEditing(true)}
onAdd={addToSidebar}
onAddDivider={addDividerToSidebar}
onNavigate={handleClose}
open={moreMenuOpen}
onOpenChange={setMoreMenuOpen}
homePage={homePage}
/>
{editing ? (
<>
<SidebarNavList
items={editingItems}
editing
onRemove={handleEditRemove}
onAdd={addToSidebar}
onReorder={handleEditReorder}
isActive={() => false}
linkClassName="text-base"
homePage={homePage}
/>
<SidebarMoreMenu
editing
hiddenItems={hiddenItems}
onDoneEditing={() => setEditing(false)}
onStartEditing={() => setEditing(true)}
onAdd={addToSidebar}
onAddDivider={addDividerToSidebar}
onNavigate={handleClose}
homePage={homePage}
/>
</>
) : (
<>
<SidebarNavList
items={visibleItems}
editing={false}
onRemove={removeFromSidebar}
onReorder={updateSidebarOrder}
isActive={(id) => isItemActive(id, location.pathname, location.search, userProfileUrl, homePage)}
getOnClick={() => handleClose}
getProfilePath={(id) => id === 'profile' ? userProfileUrl : undefined}
getShowIndicator={(id) => id === 'notifications' ? hasUnread : undefined}
linkClassName="text-base"
homePage={homePage}
/>
<SidebarMoreMenu
editing={false}
hiddenItems={hiddenItems}
onDoneEditing={() => setEditing(false)}
onStartEditing={() => setEditing(true)}
onAdd={addToSidebar}
onAddDivider={addDividerToSidebar}
onNavigate={handleClose}
homePage={homePage}
/>
</>
)}
</div>
</nav>
@@ -336,7 +292,7 @@ export function MobileDrawer({ open, onOpenChange }: MobileDrawerProps) {
{/* Login prompt */}
<div
className="flex items-center gap-3 px-4 border-b border-border"
style={{ minHeight: `calc(3rem + env(safe-area-inset-top, 0px))`, paddingTop: `env(safe-area-inset-top, 0px)` }}
style={{ minHeight: `calc(3rem + var(--safe-area-inset-top, env(safe-area-inset-top, 0px)))`, paddingTop: `var(--safe-area-inset-top, env(safe-area-inset-top, 0px))` }}
>
<LoginArea className="w-full flex" />
</div>
@@ -358,14 +314,12 @@ export function MobileDrawer({ open, onOpenChange }: MobileDrawerProps) {
/>
<SidebarMoreMenu
editing={false}
hiddenItems={visibleHiddenItems}
hiddenItems={hiddenItems}
onDoneEditing={() => setEditing(false)}
onStartEditing={() => setEditing(true)}
onAdd={addToSidebar}
onAddDivider={addDividerToSidebar}
onNavigate={handleClose}
open={moreMenuOpen}
onOpenChange={setMoreMenuOpen}
homePage={homePage}
/>
</div>
@@ -376,6 +330,7 @@ export function MobileDrawer({ open, onOpenChange }: MobileDrawerProps) {
</div>
</div>
)}
</PortalContainerProvider>
</SheetContent>
</Sheet>
+11 -20
View File
@@ -126,12 +126,8 @@ export function MobileSearchSheet({ open, onClose }: MobileSearchSheetProps) {
// Focus input when opened
useEffect(() => {
if (open) {
// Small delay to let the animation settle and keyboard to appear
const t = setTimeout(() => inputRef.current?.focus(), 80);
return () => clearTimeout(t);
} else {
setQuery('');
setSelectedIndex(-1);
}
}, [open]);
@@ -188,7 +184,7 @@ export function MobileSearchSheet({ open, onClose }: MobileSearchSheetProps) {
if (!query.trim()) return;
handleClose();
navigate(`/search?q=${encodeURIComponent(query.trim())}`);
navigate(`/discover?tab=posts&q=${encodeURIComponent(query.trim())}`);
}, [query, navigate, handleClose]);
const handleKeyDown = (e: React.KeyboardEvent) => {
@@ -236,18 +232,10 @@ export function MobileSearchSheet({ open, onClose }: MobileSearchSheetProps) {
const hasResults = query.trim().length > 0 && (navItemCount > 0 || hasIdentifier || hasUrlComment || hasCountry || hasWikipedia || hasArchive || (profiles && profiles.length > 0));
if (!open) return null;
return (
<>
{/* Backdrop */}
<div
className="fixed inset-0 z-40 bg-black/60 sidebar:hidden animate-in fade-in-0 duration-150"
onClick={handleClose}
/>
{/* Bottom sheet — sits at the bottom of the screen with safe area clearance */}
<div className="fixed left-0 right-0 bottom-0 z-[49] sidebar:hidden animate-in slide-in-from-bottom-4 duration-200 pb-6">
<div className={cn('fixed left-0 right-0 bottom-mobile-nav z-[49] sidebar:hidden animate-in slide-in-from-bottom-4 duration-200 pb-2', !open && 'hidden')}>
{/* Results list — reversed so closest to input = most relevant */}
{hasResults && (
@@ -343,12 +331,15 @@ export function MobileSearchSheet({ open, onClose }: MobileSearchSheetProps) {
autoCapitalize="off"
spellCheck={false}
/>
<button
onClick={handleClose}
className="size-5 shrink-0 flex items-center justify-center rounded-full bg-muted text-muted-foreground hover:bg-muted/80 transition-colors"
>
<X strokeWidth={4} className="size-3" />
</button>
{query.length > 0 && (
<button
onClick={() => setQuery('')}
className="size-5 shrink-0 flex items-center justify-center rounded-full bg-muted text-muted-foreground hover:bg-muted/80 transition-colors"
>
<X strokeWidth={4} className="size-3" />
</button>
)}
</div>
</div>
</div>
+2 -2
View File
@@ -25,12 +25,12 @@ export function MobileTopBar({ onAvatarClick, hasSubHeader }: MobileTopBarProps)
return (
<header
className="sticky top-0 z-20 sidebar:hidden safe-area-top transition-transform duration-300 ease-in-out"
style={navHidden ? { transform: 'translateY(calc(-100% - 20px - env(safe-area-inset-top, 0px)))' } : undefined}
style={navHidden ? { transform: 'translateY(calc(-100% - 20px - var(--safe-area-inset-top, env(safe-area-inset-top, 0px))))' } : undefined}
>
{/* Safe-area fill — only covers the padding zone above the content with a single layer of bg. */}
<div
className="absolute top-0 left-0 right-0 bg-background/85"
style={{ height: 'env(safe-area-inset-top, 0px)' }}
style={{ height: 'var(--safe-area-inset-top, env(safe-area-inset-top, 0px))' }}
/>
{/* Relative wrapper so ArcBackground only covers the content area, not the safe-area padding above it. */}
<div className="relative">
+74 -69
View File
@@ -1,13 +1,12 @@
import { Link } from 'react-router-dom';
import { GripVertical, X, FileText, Scroll } from 'lucide-react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { FileText, Scroll, WandSparkles } from 'lucide-react';
import { nip19 } from 'nostr-tools';
import type { NostrMetadata } from '@nostrify/nostrify';
import type { ComponentType } from 'react';
import { cn } from '@/lib/utils';
import { nostrUriToNip19 } from '@/lib/sidebarItems';
import { SortableItemShell } from '@/components/SortableItemShell';
import { useAuthor } from '@/hooks/useAuthor';
import { getKindIcon } from '@/lib/extraKinds';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
@@ -21,7 +20,8 @@ import { useNostrEventSidebar } from '@/hooks/useNostrEventSidebar';
* Used as a fallback when getKindIcon() returns undefined.
*/
const KNOWN_KIND_ICONS: Record<number, ComponentType<{ className?: string }>> = {
30000: Scroll, // NIP-51 lists
777: WandSparkles, // Spells
30000: Scroll, // NIP-51 lists
};
// ── Types ─────────────────────────────────────────────────────────────────────
@@ -32,6 +32,9 @@ export interface NostrEventSidebarItemProps {
active: boolean;
editing: boolean;
onRemove: (id: string, index?: number) => void;
onAdd?: (id: string) => void;
/** True when this item is below the "More..." separator (hidden zone). */
belowMore?: boolean;
onClick?: (e: React.MouseEvent) => void;
/** Extra classes on the link. */
linkClassName?: string;
@@ -71,41 +74,65 @@ function ProfileSidebarLabel({ pubkey }: { pubkey: string }) {
// ── Event sidebar item (non-profile) ──────────────────────────────────────────
function EventSidebarIcon({ kind, className }: { kind: number; className?: string }) {
const Icon = getKindIcon(kind) ?? KNOWN_KIND_ICONS[kind] ?? FileText;
return <Icon className={cn('size-6', className)} />;
function resolveKindIcon(kind: number): ComponentType<{ className?: string }> {
return getKindIcon(kind) ?? KNOWN_KIND_ICONS[kind] ?? FileText;
}
interface EventSidebarLabelProps {
/**
* Renders icon + label for a non-profile event sidebar item.
* Fetches the event to resolve the kind (needed when the nevent doesn't
* encode a kind) and derives the correct icon and navigation path.
*/
function EventSidebarContent({ decoded, nip19Id, linkClassName, active, editing, onClick }: {
decoded: DecodedNostrId;
}
function EventSidebarLabel({ decoded }: EventSidebarLabelProps) {
nip19Id: string;
linkClassName?: string;
active: boolean;
editing: boolean;
onClick?: (e: React.MouseEvent) => void;
}) {
const params = decoded.type === 'naddr' && decoded.identifier !== undefined
? { addr: { kind: decoded.kind!, pubkey: decoded.pubkey, identifier: decoded.identifier } }
: { eventId: decoded.eventId };
const { data, isLoading } = useNostrEventSidebar(params);
if (isLoading && !data) {
return <Skeleton className="h-4 w-20" />;
}
// Use fetched kind when available, fall back to decoded kind
const resolvedKind = data?.kind ?? decoded.kind ?? 1;
const Icon = resolveKindIcon(resolvedKind);
const path = `/${nip19Id}`;
return (
<span className="truncate">
{data?.label ?? 'Event'}
</span>
<Link
to={path}
onClick={onClick}
className={cn(
'flex items-center gap-4 py-3 rounded-full transition-colors flex-1 min-w-0',
editing ? 'px-2' : 'px-3',
active ? 'font-bold text-primary' : 'font-normal text-foreground',
linkClassName ?? 'text-lg',
)}
>
<span className="shrink-0">
<Icon className="size-6" />
</span>
<span className="truncate" style={{ fontFamily: 'var(--title-font-family, inherit)' }}>
{isLoading && !data ? (
<Skeleton className="h-4 w-20" />
) : (
data?.label ?? 'Event'
)}
</span>
</Link>
);
}
// ── Main component ────────────────────────────────────────────────────────────
export function NostrEventSidebarItem({
id, active, editing, onRemove, onClick, linkClassName,
id, active, editing, onRemove, onAdd, belowMore, onClick, linkClassName,
}: NostrEventSidebarItemProps) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id, disabled: !editing });
const style = { transform: CSS.Transform.toString(transform), transition };
const nip19Id = nostrUriToNip19(id);
const decoded = decodeNostrId(nip19Id);
@@ -114,61 +141,39 @@ export function NostrEventSidebarItem({
return null;
}
const path = `/${nip19Id}`;
const isProfile = decoded.type === 'npub' || decoded.type === 'nprofile';
return (
<div
ref={setNodeRef}
style={style}
className={cn('flex items-center rounded-full transition-colors relative bg-background/85', isDragging && 'z-10 opacity-80 shadow-lg')}
>
{editing && (
<button
className="flex items-center justify-center w-8 shrink-0 cursor-grab active:cursor-grabbing text-muted-foreground hover:text-foreground transition-colors"
{...attributes}
{...listeners}
<SortableItemShell id={id} editing={editing} onRemove={onRemove} onAdd={onAdd} belowMore={belowMore}>
{isProfile ? (
<Link
to={`/${nip19Id}`}
onClick={onClick}
className={cn(
'flex items-center gap-4 py-3 rounded-full transition-colors flex-1 min-w-0',
editing ? 'px-2' : 'px-3',
active ? 'font-bold text-primary' : 'font-normal text-foreground',
linkClassName ?? 'text-lg',
)}
>
<GripVertical className="size-4" />
</button>
)}
<Link
to={path}
onClick={onClick}
className={cn(
'flex items-center gap-4 py-3 rounded-full transition-colors hover:bg-secondary/60 flex-1 min-w-0',
editing ? 'px-2' : 'px-3',
active ? 'font-bold text-primary' : 'font-normal text-foreground',
linkClassName ?? 'text-lg',
)}
>
<span className="shrink-0">
{isProfile ? (
<span className="shrink-0">
<ProfileSidebarIcon pubkey={decoded.pubkey} />
) : (
<EventSidebarIcon kind={decoded.kind ?? 1} />
)}
</span>
<span className="truncate" style={{ fontFamily: 'var(--title-font-family, inherit)' }}>
{isProfile ? (
</span>
<span className="truncate" style={{ fontFamily: 'var(--title-font-family, inherit)' }}>
<ProfileSidebarLabel pubkey={decoded.pubkey} />
) : (
<EventSidebarLabel decoded={decoded} />
)}
</span>
</Link>
{editing && (
<button
onClick={(e) => { e.stopPropagation(); onRemove(id); }}
className="flex items-center justify-center size-8 shrink-0 rounded-full transition-all text-muted-foreground hover:text-destructive hover:bg-destructive/10"
title="Remove"
>
<X className="size-4" />
</button>
</span>
</Link>
) : (
<EventSidebarContent
decoded={decoded}
nip19Id={nip19Id}
linkClassName={linkClassName}
active={active}
editing={editing}
onClick={onClick}
/>
)}
</div>
</SortableItemShell>
);
}
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
import { Skeleton } from '@/components/ui/skeleton';
/** Reusable loading skeleton that matches the NoteCard layout. */
export function NoteCardSkeleton() {
return (
<div className="px-4 py-3 border-b border-border">
<div className="flex items-center gap-3">
<Skeleton className="size-11 rounded-full shrink-0" />
<div className="min-w-0 space-y-1.5">
<Skeleton className="h-4 w-28" />
<Skeleton className="h-3 w-36" />
</div>
</div>
<div className="mt-2 space-y-1.5">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-4/5" />
</div>
<div className="flex items-center gap-6 mt-3 -ml-2">
<Skeleton className="h-4 w-8" />
<Skeleton className="h-4 w-8" />
<Skeleton className="h-4 w-8" />
<Skeleton className="h-4 w-8" />
</div>
</div>
);
}
+146 -28
View File
@@ -4,10 +4,16 @@ import { Link } from 'react-router-dom';
import { nip19 } from 'nostr-tools';
import { useAuthor } from '@/hooks/useAuthor';
import { genUserName } from '@/lib/genUserName';
import { getDisplayName } from '@/lib/getDisplayName';
import { getAvatarShape } from '@/lib/avatarShape';
import { useProfileUrl } from '@/hooks/useProfileUrl';
import { LinkEmbed } from '@/components/LinkEmbed';
import { EmbeddedNote } from '@/components/EmbeddedNote';
import { EmbeddedNaddr } from '@/components/EmbeddedNaddr';
import { LightningInvoiceCard } from '@/components/LightningInvoiceCard';
import { VideoPlayer } from '@/components/VideoPlayer';
import { AudioVisualizer } from '@/components/AudioVisualizer';
import { WebxdcEmbed } from '@/components/WebxdcEmbed';
import { Lightbox, ImageGallery } from '@/components/ImageGallery';
import { ProfileHoverCard } from '@/components/ProfileHoverCard';
import { EmojifiedText, CustomEmojiImg } from '@/components/CustomEmoji';
@@ -16,6 +22,8 @@ import { useCustomEmojis } from '@/hooks/useCustomEmojis';
import { useBlossomFallback } from '@/hooks/useBlossomFallback';
import { COUNTRIES } from '@/lib/countries';
import { IMAGE_URL_REGEX, EMBED_MEDIA_URL_REGEX } from '@/lib/mediaUrls';
import { parseImetaMap } from '@/lib/imeta';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import { cn } from '@/lib/utils';
import type { AddrCoords } from '@/hooks/useEvent';
@@ -26,6 +34,13 @@ interface NoteContentProps {
disableEmbeds?: boolean;
/** When true, hides thumbnail images in link preview cards (useful when a cover image is already shown). */
hideEmbedImages?: boolean;
/** When true, nested nostr:nevent/note/naddr embeds render as inline links instead of cards.
* Used inside embedded quote cards to prevent unbounded recursive nesting. */
disableNoteEmbeds?: boolean;
/** When true, images, galleries, and video/audio players are suppressed (rendered as
* whitespace) while link preview cards and other non-media embeds are preserved.
* Used inside embedded quote cards to keep them lightweight. */
disableMediaEmbeds?: boolean;
}
/** Regex matching `:shortcode:` patterns in text. */
@@ -169,6 +184,7 @@ type ContentToken =
| { type: 'text'; value: string }
| { type: 'image-embed'; url: string }
| { type: 'image-gallery'; urls: string[] }
| { type: 'media-embed'; url: string }
| { type: 'link-embed'; url: string }
| { type: 'inline-link'; url: string }
| { type: 'mention'; pubkey: string }
@@ -176,7 +192,8 @@ type ContentToken =
| { type: 'naddr-embed'; addr: AddrCoords; url?: string }
| { type: 'nostr-link'; id: string; raw: string }
| { type: 'hashtag'; tag: string; raw: string }
| { type: 'relay-link'; url: string };
| { type: 'relay-link'; url: string }
| { type: 'lightning-invoice'; invoice: string };
/**
* Regex segment matching a single visual emoji unit, including:
@@ -231,12 +248,15 @@ export function NoteContent({
className,
disableEmbeds = false,
hideEmbedImages = false,
disableNoteEmbeds = false,
disableMediaEmbeds = false,
}: NoteContentProps) {
const tokens = useMemo(() => {
const text = event.content;
// Match: URLs | nostr:-prefixed NIP-19 ids | @-prefixed or bare NIP-19 ids | hashtags
// Match: BOLT11 invoices | URLs | nostr:-prefixed NIP-19 ids | @-prefixed or bare NIP-19 ids | hashtags
// BOLT11: optional "lightning:" prefix + lnbc/lntb/lnbcrt/lntbs + bech32 data (case-insensitive)
// NIP-19 ids can appear anywhere (with optional @ prefix that gets consumed)
const regex = /((?:https?|wss?):\/\/[^\s]+)|nostr:(npub1|note1|nprofile1|nevent1|naddr1)([023456789acdefghjklmnpqrstuvwxyz]+)|@?(npub1|note1|nprofile1|nevent1|naddr1)([023456789acdefghjklmnpqrstuvwxyz]+)|(#[\p{L}\p{N}_]+)/gu;
const regex = /(?:lightning:)?(ln(?:bc|tb|bcrt|tbs)\d*[munp]?1[023456789acdefghjklmnpqrstuvwxyz]+)|((?:https?|wss?):\/\/[^\s]+)|nostr:(npub1|note1|nprofile1|nevent1|naddr1)([023456789acdefghjklmnpqrstuvwxyz]+)|@?(npub1|note1|nprofile1|nevent1|naddr1)([023456789acdefghjklmnpqrstuvwxyz]+)|(#[\p{L}\p{N}_]+)/giu;
const result: ContentToken[] = [];
let lastIndex = 0;
@@ -244,9 +264,11 @@ export function NoteContent({
let hadMatches = false;
while ((match = regex.exec(text)) !== null) {
let [fullMatch, url] = match;
const hashtag = match[6];
const { 2: nostrPrefix, 3: nostrData, 4: barePrefix, 5: bareData } = match;
let [fullMatch] = match;
const bolt11 = match[1];
let url = match[2];
const hashtag = match[7];
const { 3: nostrPrefix, 4: nostrData, 5: barePrefix, 6: bareData } = match;
const index = match.index;
hadMatches = true;
@@ -255,7 +277,9 @@ export function NoteContent({
result.push({ type: 'text', value: text.substring(lastIndex, index) });
}
if (url) {
if (bolt11) {
result.push({ type: 'lightning-invoice', invoice: bolt11.toLowerCase() });
} else if (url) {
// Strip common trailing punctuation that's likely not part of the URL
// This handles cases like "(https://example.com)" or "Check this: https://example.com."
const trailingPunctMatch = url.match(/^(.*?)([.,;:!?)\]]+)$/);
@@ -295,7 +319,7 @@ export function NoteContent({
continue;
}
// Skip non-image media URLs — rendered as embedded media by the parent.
// Non-image media URLs (video, audio, webxdc) — render inline at their position.
if (EMBED_MEDIA_URL_REGEX.test(url)) {
if (result.length > 0) {
const prev = result[result.length - 1];
@@ -303,8 +327,9 @@ export function NoteContent({
prev.value = prev.value.replace(/\s+$/, '');
}
}
result.push({ type: 'media-embed', url });
lastIndex = index + fullMatch.length;
// Also strip leading whitespace that follows the skipped URL
// Strip leading whitespace that follows the media URL
const remaining = text.substring(lastIndex);
const leadingWs = remaining.match(/^\s+/);
if (leadingWs) {
@@ -404,12 +429,43 @@ export function NoteContent({
}
}
// Append media-embed tokens for imeta-declared media URLs not found in the content.
// Some clients attach audio/video/webxdc via imeta tags without including the URL in
// the content string. Without this, those attachments would be silently dropped.
// Only scan for text note kinds — other kinds (DMs, calendar events, etc.) may use
// imeta tags for different purposes.
const TEXT_NOTE_KINDS = new Set([1, 11, 1111]);
if (TEXT_NOTE_KINDS.has(event.kind)) {
const contentMediaUrls = new Set(
result.filter((t): t is { type: 'media-embed'; url: string } => t.type === 'media-embed').map((t) => t.url),
);
for (const tag of event.tags) {
if (tag[0] !== 'imeta') continue;
let rawUrl: string | undefined;
let mime: string | undefined;
for (let j = 1; j < tag.length; j++) {
const sp = tag[j].indexOf(' ');
if (sp === -1) continue;
const key = tag[j].slice(0, sp);
if (key === 'url') rawUrl = tag[j].slice(sp + 1);
else if (key === 'm') mime = tag[j].slice(sp + 1);
}
const url = sanitizeUrl(rawUrl);
if (!url || contentMediaUrls.has(url)) continue;
const isEmbeddableMedia = mime?.startsWith('audio/') || mime?.startsWith('video/')
|| mime === 'application/x-webxdc' || mime === 'application/vnd.webxdc+zip';
if (isEmbeddableMedia) {
result.push({ type: 'media-embed', url });
}
}
}
// Collapse excessive whitespace around block-level tokens (link-preview, youtube-embed)
// Preserve formatting but prevent too much stacking with the card's own spacing.
for (let i = 0; i < result.length; i++) {
const token = result[i];
const isBlock = token.type === 'image-embed' || token.type === 'link-embed' || token.type === 'nevent-embed'
|| (token.type === 'naddr-embed' && !token.url);
const isBlock = token.type === 'image-embed' || token.type === 'media-embed' || token.type === 'link-embed' || token.type === 'nevent-embed'
|| (token.type === 'naddr-embed' && !token.url) || token.type === 'lightning-invoice';
if (isBlock) {
// Strip all trailing whitespace from the preceding text token.
@@ -462,21 +518,17 @@ export function NoteContent({
return map;
}, [event.tags, viewerEmojis]);
// Parse imeta tags for dim/blurhash to pass to ImageGallery
const imetaMap = useMemo(() => {
const map = new Map<string, { dim?: string; blurhash?: string }>();
for (const tag of event.tags) {
if (tag[0] !== 'imeta') continue;
const parts: Record<string, string> = {};
for (let i = 1; i < tag.length; i++) {
const p = tag[i];
const sp = p.indexOf(' ');
if (sp !== -1) parts[p.slice(0, sp)] = p.slice(sp + 1);
}
if (parts.url) map.set(parts.url, { dim: parts.dim, blurhash: parts.blurhash });
}
return map;
}, [event.tags]);
// Parse imeta tags — used by ImageGallery (dim/blurhash) and inline media embeds
const imetaMap = useMemo(() => parseImetaMap(event.tags), [event.tags]);
// Only fetch author data when there are media embeds that need it (video artist, audio avatar)
const hasMedia = tokens.some((t) => t.type === 'media-embed');
const author = useAuthor(hasMedia ? event.pubkey : undefined);
const authorMetadata = author.data?.metadata;
const authorDisplayName = useMemo(
() => getDisplayName(authorMetadata, event.pubkey) ?? genUserName(event.pubkey),
[authorMetadata, event.pubkey],
);
// Group consecutive image-embed tokens (≥2) into image-gallery tokens
const groupedTokens = useMemo(() => {
@@ -547,7 +599,7 @@ export function NoteContent({
case 'text':
return <span key={i}>{linkifyFlags(emojify(token.value, emojiMap, isEmojiOnly ? 'inline h-12 w-12 object-contain align-text-bottom' : undefined))}</span>;
case 'image-embed': {
if (disableEmbeds) {
if (disableEmbeds || disableMediaEmbeds) {
// In preview contexts (e.g. triple-dot menu), replace image URLs
// with a newline so text flow is preserved without showing raw URLs.
return <span key={i}>{'\n'}</span>;
@@ -562,7 +614,7 @@ export function NoteContent({
);
}
case 'image-gallery': {
if (disableEmbeds) {
if (disableEmbeds || disableMediaEmbeds) {
return <span key={i}>{token.urls.map(() => '\n').join('')}</span>;
}
const galleryStartIndex = tokenImageIndex.get(i) ?? 0;
@@ -614,9 +666,70 @@ export function NoteContent({
{token.url}
</a>
);
case 'media-embed': {
if (disableEmbeds || disableMediaEmbeds) {
return <span key={i}>{'\n'}</span>;
}
const imeta = imetaMap.get(token.url);
const mime = imeta?.mime ?? '';
const isWebxdc = mime === 'application/x-webxdc' || mime === 'application/vnd.webxdc+zip' || token.url.endsWith('.xdc');
const isAudio = mime.startsWith('audio/') || /\.(mp3|wav|ogg|flac|m4a|aac|opus)(\?[^\s]*)?$/i.test(token.url);
if (isWebxdc && imeta) {
return <WebxdcEmbed key={i} url={token.url} uuid={imeta.webxdc} name={imeta.summary} icon={imeta.thumbnail} />;
}
if (isAudio) {
return (
<AudioVisualizer
key={i}
src={token.url}
mime={imeta?.mime}
avatarUrl={authorMetadata?.picture}
avatarFallback={authorDisplayName[0]?.toUpperCase() ?? '?'}
avatarShape={getAvatarShape(authorMetadata)}
/>
);
}
// Default: video
return (
<VideoPlayer
key={i}
src={token.url}
poster={imeta?.thumbnail}
dim={imeta?.dim}
blurhash={imeta?.blurhash}
artist={authorDisplayName}
/>
);
}
case 'nevent-embed':
if (disableNoteEmbeds) {
const neventId = nip19.neventEncode({ id: token.eventId, ...(token.author ? { author: token.author } : {}), ...(token.relays?.length ? { relays: token.relays } : {}) });
return (
<Link
key={i}
to={`/${neventId}`}
className="text-primary hover:underline break-all"
onClick={(e) => e.stopPropagation()}
>
{neventId.slice(0, 16)}
</Link>
);
}
return <EmbeddedNote key={i} eventId={token.eventId} relays={token.relays} authorHint={token.author} className="my-2.5" />;
case 'naddr-embed':
if (disableNoteEmbeds) {
const naddrId = nip19.naddrEncode({ kind: token.addr.kind, pubkey: token.addr.pubkey, identifier: token.addr.identifier });
return (
<Link
key={i}
to={`/${naddrId}`}
className="text-primary hover:underline break-all"
onClick={(e) => e.stopPropagation()}
>
{naddrId.slice(0, 16)}
</Link>
);
}
return (
<span key={i}>
{token.url && (
@@ -668,6 +781,11 @@ export function NoteContent({
{token.url}
</Link>
);
case 'lightning-invoice':
if (disableEmbeds) {
return <span key={i} className="text-primary break-all">{token.invoice}</span>;
}
return <LightningInvoiceCard key={i} invoice={token.invoice} />;
}
})}
-60
View File
@@ -1,60 +0,0 @@
import type { NostrEvent } from '@nostrify/nostrify';
import { VideoPlayer } from '@/components/VideoPlayer';
import { AudioVisualizer } from '@/components/AudioVisualizer';
import { WebxdcEmbed } from '@/components/WebxdcEmbed';
import { useAuthor } from '@/hooks/useAuthor';
import { getDisplayName } from '@/lib/getDisplayName';
import { genUserName } from '@/lib/genUserName';
import { getAvatarShape } from '@/lib/avatarShape';
import type { ImetaEntry } from '@/lib/imeta';
/** Media content for kind 1 text notes — renders videos, audio, and webxdc apps. */
export function NoteMedia({
videos,
audios = [],
imetaMap,
webxdcApps = [],
event,
}: {
videos: string[];
audios?: string[];
imetaMap: Map<string, ImetaEntry>;
webxdcApps?: ImetaEntry[];
event: NostrEvent;
}) {
const author = useAuthor(event.pubkey);
const metadata = author.data?.metadata;
const displayName = getDisplayName(metadata, event.pubkey) ?? genUserName(event.pubkey);
if (videos.length === 0 && audios.length === 0 && webxdcApps.length === 0) return null;
return (
<>
{/* Videos — each rendered with play/pause overlay */}
{videos.map((url, i) => (
<VideoPlayer key={`v-${i}`} src={url} poster={imetaMap.get(url)?.thumbnail} dim={imetaMap.get(url)?.dim} blurhash={imetaMap.get(url)?.blurhash} artist={displayName} />
))}
{/* Audio — rendered as visualizer with avatar */}
{audios.map((url, i) => {
const mime = imetaMap.get(url)?.mime;
return (
<AudioVisualizer
key={`a-${i}`}
src={url}
mime={mime}
avatarUrl={metadata?.picture}
avatarFallback={displayName[0]?.toUpperCase() ?? '?'}
avatarShape={getAvatarShape(metadata)}
/>
);
})}
{/* Webxdc apps */}
{webxdcApps.map((app) => (
<WebxdcEmbed key={app.url} url={app.url} uuid={app.webxdc} name={app.summary} icon={app.thumbnail} />
))}
</>
);
}
+11 -2
View File
@@ -55,6 +55,7 @@ import { useFeedSettings } from '@/hooks/useFeedSettings';
import { genUserName } from '@/lib/genUserName';
import { timeAgo } from '@/lib/timeAgo';
import { toast } from '@/hooks/useToast';
import { impactLight } from '@/lib/haptics';
import { cn } from '@/lib/utils';
import type { NostrEvent } from '@nostrify/nostrify';
@@ -97,7 +98,7 @@ function encodeEventNip19(event: NostrEvent): string {
return nip19.naddrEncode({ kind: event.kind, pubkey: event.pubkey, identifier: dTag });
}
}
return nip19.neventEncode({ id: event.id, author: event.pubkey });
return nip19.neventEncode({ id: event.id, author: event.pubkey, kind: event.kind });
}
interface EventJsonDialogProps {
@@ -331,7 +332,12 @@ function NoteMoreMenuContent({ event, open, onOpenChange, onReport, onMention, o
const close = () => onOpenChange(false);
const handleViewPostDetails = () => {
navigate(`/${nip19Id}`);
// For spells, use a nevent without kind hint so NIP19Page falls through
// to PostDetailPage instead of running the spell again.
const detailId = event.kind === 777
? nip19.neventEncode({ id: event.id, author: event.pubkey })
: nip19Id;
navigate(`/${detailId}`);
close();
};
@@ -343,6 +349,7 @@ function NoteMoreMenuContent({ event, open, onOpenChange, onReport, onMention, o
};
const handleBookmark = () => {
impactLight();
toggleBookmark.mutate(event.id);
close();
};
@@ -359,6 +366,7 @@ function NoteMoreMenuContent({ event, open, onOpenChange, onReport, onMention, o
};
const handleTogglePin = () => {
impactLight();
togglePin.mutate(event.id, {
onSuccess: () => {
toast({ title: pinned ? 'Unpinned from profile' : 'Pinned to profile' });
@@ -371,6 +379,7 @@ function NoteMoreMenuContent({ event, open, onOpenChange, onReport, onMention, o
};
const handleMuteConversation = () => {
impactLight();
const rootTag = event.tags.find(([name, , , marker]) => name === 'e' && marker === 'root');
const threadId = rootTag?.[1] ?? event.id;
addMute.mutate(
+2 -1
View File
@@ -8,6 +8,7 @@ import { NsitePreviewDialog } from "@/components/NsitePreviewDialog";
import { Skeleton } from "@/components/ui/skeleton";
import { useLinkPreview } from "@/hooks/useLinkPreview";
import { getNsiteSubdomain } from "@/lib/nsiteSubdomain";
import { sanitizeUrl } from "@/lib/sanitizeUrl";
import { cn } from "@/lib/utils";
interface NsiteCardProps {
@@ -24,7 +25,7 @@ export function NsiteCard({ event }: NsiteCardProps) {
const title = event.tags.find(([n]) => n === "title")?.[1];
const description = event.tags.find(([n]) => n === "description")?.[1];
const dTag = event.tags.find(([n]) => n === "d")?.[1];
const sourceUrl = event.tags.find(([n]) => n === "source")?.[1];
const sourceUrl = sanitizeUrl(event.tags.find(([n]) => n === "source")?.[1]);
const pathTags = event.tags.filter(([n]) => n === "path");
const serverTags = event.tags.filter(([n]) => n === "server");
+128 -6
View File
@@ -2,6 +2,7 @@ import type { NostrEvent } from '@nostrify/nostrify';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { Package, X } from 'lucide-react';
import { Capacitor } from '@capacitor/core';
import { Button } from '@/components/ui/button';
import { SandboxFrame } from '@/components/SandboxFrame';
@@ -68,25 +69,108 @@ function resolveServers(event: NostrEvent, appServers: string[]): string[] {
}
/**
* Fetch a blob from the given sha256 by trying each Blossom server in order.
* Returns a Response from the first server that responds successfully, or
* throws if all servers fail.
* Module-level preferred server. Once a Blossom server successfully serves
* a blob, it is promoted here so subsequent requests try it first — avoiding
* the round-trip penalty of 404s on servers that don't have the content.
*/
let preferredServer: string | null = null;
/**
* Fetch a blob from the given sha256 by trying Blossom servers.
*
* If a server previously succeeded (the "preferred" server), it is tried
* first. On success the preferred server is reinforced; on failure we fall
* through to the remaining servers in order. Whichever server ultimately
* succeeds is promoted to preferred for the next call.
*/
async function fetchFromBlossom(sha256: string, servers: string[]): Promise<Response> {
let lastError: unknown;
for (const server of servers) {
/** Try a single server. Returns the Response on success, or null. */
async function tryServer(server: string): Promise<Response | null> {
const base = server.replace(/\/+$/, '');
const url = `${base}/${sha256}`;
try {
const res = await fetch(url);
if (res.ok) return res;
if (res.ok) {
preferredServer = server;
return res;
}
} catch (err) {
lastError = err;
}
return null;
}
// Try the preferred server first if it's in the list.
if (preferredServer && servers.includes(preferredServer)) {
const res = await tryServer(preferredServer);
if (res) return res;
}
// Fall through to the full list, skipping the preferred (already tried).
for (const server of servers) {
if (server === preferredServer) continue;
const res = await tryServer(server);
if (res) return res;
}
throw lastError ?? new Error(`Failed to fetch blob ${sha256} from all servers`);
}
/** Max concurrent Blossom fetches during pre-fetch. */
const PREFETCH_CONCURRENCY = 12;
/**
* Pre-fetch all unique blobs from the manifest into an in-memory cache.
*
* **Android only.** Android's WebView uses `shouldInterceptRequest` which
* blocks a pool of ~6 IO threads via `CountDownLatch` until JS responds.
* If each response requires a network round-trip to Blossom, the 6-at-a-time
* serialisation makes loading 200+ files extremely slow. By downloading
* every blob *before* the WebView starts loading, each bridge round-trip
* drops from seconds (network) to ~1-5ms (memory).
*
* iOS does NOT need this — `WKURLSchemeHandler` is fully async and can
* handle many concurrent requests without any thread pool bottleneck.
*
* Uses bounded concurrency to saturate the network without overwhelming it.
*/
async function prefetchAllBlobs(
manifest: Map<string, string>,
servers: string[],
cache: Map<string, Uint8Array>,
): Promise<void> {
// Deduplicate — many paths may share the same hash (e.g. SPA fallbacks).
const uniqueHashes = [...new Set(manifest.values())];
// Skip hashes already in the cache (e.g. from a previous open).
const toFetch = uniqueHashes.filter((h) => !cache.has(h));
if (toFetch.length === 0) return;
let cursor = 0;
const total = toFetch.length;
async function worker(): Promise<void> {
while (cursor < total) {
const idx = cursor++;
const sha256 = toFetch[idx];
try {
const res = await fetchFromBlossom(sha256, servers);
const buffer = await res.arrayBuffer();
cache.set(sha256, new Uint8Array(buffer));
} catch {
// Non-fatal — resolveFile will fetch on demand for cache misses.
}
}
}
const workers = Array.from(
{ length: Math.min(PREFETCH_CONCURRENCY, total) },
() => worker(),
);
await Promise.all(workers);
}
interface NsitePreviewDialogProps {
/** The nsite event (kind 15128 or 35128) containing path and server tags. */
event: NostrEvent;
@@ -124,6 +208,13 @@ export function NsitePreviewDialog({ event, appName, appPicture, open, onOpenCha
const manifest = useRef<Map<string, string>>(new Map());
const servers = useRef<string[]>([]);
/**
* In-memory blob cache: sha256 → raw bytes.
* On Android, populated by a blocking pre-fetch in `onReady` so every
* `resolveFile` call is an instant cache hit with no network wait.
*/
const blobCache = useRef<Map<string, Uint8Array>>(new Map());
useEffect(() => {
manifest.current = buildManifest(event);
const appServers = getEffectiveBlossomServers(
@@ -139,6 +230,26 @@ export function NsitePreviewDialog({ event, appName, appPicture, open, onOpenCha
content: getPreviewInjectedScript(),
}], []);
/**
* Called by SandboxFrame before the native WebView is created.
*
* On Android: blocks until all blobs are pre-fetched. Android's WebView
* uses `shouldInterceptRequest` which blocks ~6 IO threads — if each
* response requires a network fetch the whole thing is painfully slow.
* The native ProgressBar spinner (render thread) stays visible and
* animating during the download. Once the WebView starts, every
* resolveFile call is an instant cache hit.
*
* On iOS: no-op. WKURLSchemeHandler is async and handles concurrent
* requests without a thread pool bottleneck.
*
* On web: no-op. iframe.diy's service worker handles fetches efficiently.
*/
const onReady = useCallback(async () => {
if (Capacitor.getPlatform() !== 'android') return;
await prefetchAllBlobs(manifest.current, servers.current, blobCache.current);
}, []);
/** Resolve a pathname to file content from the Blossom manifest. */
const resolveFile = useCallback(async (pathname: string): Promise<FileResponse | null> => {
// Look up the sha256 for this path in the manifest.
@@ -153,11 +264,21 @@ export function NsitePreviewDialog({ event, appName, appPicture, open, onOpenCha
if (!sha256) return null;
// Fetch the blob from Blossom, trying each server in order.
// Serve from cache if available (pre-fetched on Android).
const cached = blobCache.current.get(sha256);
if (cached) {
const contentType = getMimeType(servingPath);
return { status: 200, contentType, body: cached };
}
// Cache miss — fetch from Blossom (normal path on iOS/web).
const res = await fetchFromBlossom(sha256, servers.current);
const buffer = await res.arrayBuffer();
const body = new Uint8Array(buffer);
// Store in cache for future requests (e.g. SPA navigations).
blobCache.current.set(sha256, body);
// Always determine content type from the file extension.
// Blossom servers commonly return incorrect types (e.g. text/plain for .js
// files), which causes browsers to reject module scripts. The file path from
@@ -221,6 +342,7 @@ export function NsitePreviewDialog({ event, appName, appPicture, open, onOpenCha
key={`${previewSubdomain}-${open}`}
id={previewSubdomain}
resolveFile={resolveFile}
onReady={onReady}
injectedScripts={injectedScripts}
className="w-full h-full border-0"
title={`${appName} preview`}
+1 -1
View File
@@ -31,7 +31,7 @@ export function PageHeader({ title, icon, titleContent, backTo = '/', onBack, al
const backButtonClass = cn('p-2 -ml-2 rounded-full hover:bg-secondary transition-colors', !alwaysShowBack && 'sidebar:hidden');
return (
<div className={cn('flex items-center gap-4 px-4 py-4 bg-background/85', className)}>
<div className={cn('flex items-center gap-4 px-4 py-4', className)}>
{onBack ? (
<button onClick={onBack} className={backButtonClass} aria-label="Go back">
<ArrowLeft className="size-5" />
+10 -6
View File
@@ -206,9 +206,11 @@ export function ProfileCard({
<Pencil className="size-3.5" /> {metadata.banner ? 'Change banner' : 'Add banner'}
</span>
</div>
<div className="absolute bottom-2 right-2 size-7 rounded-full bg-background border border-border shadow-sm flex items-center justify-center transition-opacity">
<Pencil className="size-3.5 text-muted-foreground" />
</div>
{metadata.banner && (
<div className="absolute bottom-2 right-2 size-7 rounded-full bg-background border border-border shadow-sm flex items-center justify-center transition-opacity">
<Pencil className="size-3.5 text-muted-foreground" />
</div>
)}
</>
)}
</div>
@@ -240,9 +242,11 @@ export function ProfileCard({
>
<Pencil className="size-6 text-white opacity-0 group-hover:opacity-100 transition-opacity drop-shadow" />
</div>
<div className="absolute bottom-0 right-0 size-7 rounded-full bg-background border border-border shadow-sm flex items-center justify-center transition-opacity">
<Pencil className="size-3.5 text-muted-foreground" />
</div>
{metadata.picture && (
<div className="absolute bottom-0 right-0 size-7 rounded-full bg-background border border-border shadow-sm flex items-center justify-center transition-opacity">
<Pencil className="size-3.5 text-muted-foreground" />
</div>
)}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" sideOffset={6}>
+2
View File
@@ -8,6 +8,7 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useEmojiUsage } from '@/hooks/useEmojiUsage';
import { useToast } from '@/hooks/useToast';
import { impactLight } from '@/lib/haptics';
import type { NostrEvent } from '@nostrify/nostrify';
interface ProfileReactionButtonProps {
@@ -33,6 +34,7 @@ export function ProfileReactionButton({ profileEvent, className }: ProfileReacti
const handleReact = useCallback((emoji: string, emojiTag?: string[]) => {
if (!user) return;
impactLight();
trackEmojiUsage(emoji);

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