Compare commits

...

148 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
80 changed files with 7773 additions and 2852 deletions
+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.
---
+22
View File
@@ -21,6 +21,7 @@
"@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",
@@ -1297,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",
@@ -13411,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",
+1
View File
@@ -28,6 +28,7 @@
"@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",
+9 -1
View File
@@ -24,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";
@@ -121,9 +123,10 @@ const hardcodedConfig: AppConfig = {
followsFeedShowReplies: true,
},
sidebarOrder: [
"search",
"feed",
"notifications",
"search",
"discover",
"blobbi",
"badges",
"emojis",
@@ -156,6 +159,8 @@ const hardcodedConfig: AppConfig = {
{ id: 'hot-posts' },
{ id: 'wikipedia' },
],
aiModel: '',
aiSystemPrompt: '',
};
/**
@@ -212,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 />} />
+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 -286
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,287 +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">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>
</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} />
</>
);
}
+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'
+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>
);
}
+150 -132
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.
@@ -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} />
) : (
@@ -355,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();
@@ -372,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">
@@ -423,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)]),
});
@@ -522,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>
);
}
@@ -538,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>
);
}
+56 -121
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();
@@ -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 */}
@@ -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 */}
+148 -86
View File
@@ -1,19 +1,22 @@
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';
import { selectionChanged } from '@/lib/haptics';
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 = {
@@ -27,34 +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();
selectionChanged();
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}
@@ -63,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={() => { selectionChanged(); 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={() => { selectionChanged(); 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={() => { selectionChanged(); 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 */}
+76 -121
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">
@@ -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>
@@ -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>
+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>
);
}
+7 -2
View File
@@ -98,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 {
@@ -332,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();
};
+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" />
+91 -24
View File
@@ -1,4 +1,6 @@
import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import { useFloating, autoUpdate, flip, shift, size, offset, type Strategy } from '@floating-ui/react';
import { createPortal } from 'react-dom';
import { useNavigate } from 'react-router-dom';
import { Search, UserRoundCheck, MessageSquare, FileText, Hash, Archive } from 'lucide-react';
import { nip19 } from 'nostr-tools';
@@ -23,28 +25,42 @@ import { useWikipediaSearch, type WikipediaSearchResult } from '@/hooks/useWikip
import { useArchiveSearch, type ArchiveSearchResult } from '@/hooks/useArchiveSearch';
import { WikipediaIcon } from '@/components/icons/WikipediaIcon';
import { searchSidebarItems, type SidebarItemDef } from '@/lib/sidebarItems';
import { usePortalContainer } from '@/hooks/usePortalContainer';
import { cn } from '@/lib/utils';
/** Padding from viewport edge for dropdown max-height calculation. */
const DROPDOWN_VIEWPORT_PADDING = 16;
interface ProfileSearchDropdownProps {
placeholder?: string;
className?: string;
inputClassName?: string;
/** Inline styles applied directly to the <input> element (e.g. font-family overrides). */
inputStyle?: React.CSSProperties;
autoFocus?: boolean;
onSelect?: (profile: SearchProfile) => void;
/** When true, pressing Enter without a profile selected navigates to the search page */
enableTextSearch?: boolean;
/** When true, country suggestions are hidden from the dropdown */
hideCountry?: boolean;
/** When true, the search icon and loading spinner inside the input are hidden. */
hideIcon?: boolean;
/** Called when the dropdown wants to fully dismiss (Escape, outside click, selection).
* Useful for parent components that manage an expanded/collapsed state. */
onDismiss?: () => void;
}
export function ProfileSearchDropdown({
placeholder = 'Search people...',
className,
inputClassName,
inputStyle,
autoFocus,
onSelect,
enableTextSearch,
hideCountry = false,
hideIcon = false,
onDismiss,
}: ProfileSearchDropdownProps) {
const navigate = useNavigate();
const queryClient = useQueryClient();
@@ -55,6 +71,43 @@ export function ProfileSearchDropdown({
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLDivElement>(null);
// Portal target: when inside a PortalContainerProvider (e.g. MobileDrawer's
// Sheet), portal into that container so scroll events aren't blocked by Radix
// Dialog's RemoveScroll. Otherwise fall back to document.body.
const portalContainer = usePortalContainer();
const portalTarget = portalContainer ?? document.body;
// Use 'absolute' positioning when portaling into a container element (which
// may have CSS transforms from Sheet animations or dnd-kit), and 'fixed'
// when portaling to document.body where there's no transform ancestor.
const strategy: Strategy = portalContainer ? 'absolute' : 'fixed';
const { refs, floatingStyles, placement } = useFloating({
open,
strategy,
placement: 'bottom-start',
middleware: [
offset(6),
flip({ fallbackPlacements: ['top-start'], padding: DROPDOWN_VIEWPORT_PADDING }),
shift({ padding: DROPDOWN_VIEWPORT_PADDING }),
size({
padding: DROPDOWN_VIEWPORT_PADDING,
apply({ availableHeight, rects, elements }) {
Object.assign(elements.floating.style, {
maxHeight: `${Math.max(120, availableHeight)}px`,
width: `${rects.reference.width}px`,
});
},
}),
],
whileElementsMounted: autoUpdate,
});
const dropUp = placement.startsWith('top');
const dropdownBaseClass = dropUp
? 'z-[300] rounded-xl border border-border bg-popover shadow-lg overflow-y-auto overscroll-contain animate-in fade-in-0 zoom-in-95 slide-in-from-bottom-2 duration-150'
: 'z-[300] rounded-xl border border-border bg-popover shadow-lg overflow-y-auto overscroll-contain animate-in fade-in-0 zoom-in-95 slide-in-from-top-2 duration-150';
const { data: rawProfiles, isFetching, followedPubkeys } = useSearchProfiles(query);
// Wikipedia & Archive search (async, debounced by their hooks at >=2 chars)
@@ -114,16 +167,20 @@ export function ProfileSearchDropdown({
setSelectedIndex(-1);
}, [profiles]);
// Close dropdown on outside click
// Close dropdown on outside click (check both the input container and the
// portaled dropdown, since the dropdown renders outside the DOM tree).
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
}
const target = e.target as Node;
if (containerRef.current?.contains(target)) return;
const floating = refs.floating.current;
if (floating?.contains(target)) return;
setOpen(false);
onDismiss?.();
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
}, [refs.floating, onDismiss]);
const handleSelect = useCallback((profile: SearchProfile, profileUrl: string) => {
setOpen(false);
@@ -142,7 +199,7 @@ export function ProfileSearchDropdown({
inputRef.current?.blur();
if (!enableTextSearch) return;
navigate(`/search?q=${encodeURIComponent(query.trim())}`);
navigate(`/discover?tab=posts&q=${encodeURIComponent(query.trim())}`);
}, [enableTextSearch, query, navigate]);
// Total selectable items: navItems + identifier? + URL comment? + country?(top) + profiles + country?(bottom) + wikipedia? + archive?
@@ -216,6 +273,7 @@ export function ProfileSearchDropdown({
e.preventDefault();
setOpen(false);
inputRef.current?.blur();
onDismiss?.();
return;
}
@@ -272,12 +330,19 @@ export function ProfileSearchDropdown({
}
}, [selectedIndex]);
// Merge the container ref (for outside-click detection) and Floating UI reference ref.
const { setReference } = refs;
const setReferenceRef = useCallback((node: HTMLDivElement | null) => {
containerRef.current = node;
setReference(node);
}, [setReference]);
return (
<div ref={containerRef} className={cn('relative', className)}>
<div ref={setReferenceRef} className={cn('relative', className)}>
{/* Search input */}
<div className="relative flex items-center">
<Search className="absolute left-3 size-4 text-muted-foreground pointer-events-none" />
{isFetching && (
{!hideIcon && <Search className="absolute left-3 size-4 text-muted-foreground pointer-events-none" />}
{!hideIcon && isFetching && (
<svg
className="absolute right-3 size-4 text-muted-foreground"
style={{ animation: 'spin 1s linear infinite' }}
@@ -310,6 +375,7 @@ export function ProfileSearchDropdown({
'pl-10 pr-10 rounded-full bg-secondary border-0 focus-visible:ring-0 focus-visible:ring-offset-0',
inputClassName,
)}
style={inputStyle}
autoComplete="off"
role="combobox"
aria-expanded={open}
@@ -319,13 +385,14 @@ export function ProfileSearchDropdown({
</div>
{/* Dropdown results — only when text search is not enabled */}
{!enableTextSearch && open && (navItemCount > 0 || hasIdentifier || hasCountry || hasWikipedia || hasArchive || (profiles && profiles.length > 0)) && (
{!enableTextSearch && open && (navItemCount > 0 || hasIdentifier || hasCountry || hasWikipedia || hasArchive || (profiles && profiles.length > 0)) && createPortal(
<div
ref={listRef}
ref={refs.setFloating}
role="listbox"
className="absolute top-full left-0 right-0 mt-1.5 z-50 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"
className={dropdownBaseClass}
style={floatingStyles}
>
<div className="max-h-[320px] overflow-y-auto py-1">
<div ref={listRef} className="py-1">
{navItems.map((item, index) => (
<NavItem
key={item.id}
@@ -379,13 +446,13 @@ export function ProfileSearchDropdown({
/>
)}
</div>
</div>
)}
</div>,
portalTarget)}
{/* Text search option */}
{enableTextSearch && open && query.trim().length > 0 && (
<div className="absolute top-full left-0 right-0 mt-1.5 z-50 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">
<div ref={listRef} className="max-h-[320px] overflow-y-auto py-1">
{enableTextSearch && open && query.trim().length > 0 && createPortal(
<div ref={refs.setFloating} className={dropdownBaseClass} style={floatingStyles}>
<div ref={listRef} className="py-1">
{/* Search text option */}
<button
className={cn(
@@ -478,17 +545,17 @@ export function ProfileSearchDropdown({
/>
)}
</div>
</div>
)}
</div>,
portalTarget)}
{/* Empty state — only when text search is not enabled */}
{!enableTextSearch && open && query.trim().length > 0 && !isFetching && !hasIdentifier && !hasCountry && !hasWikipedia && !hasArchive && navItemCount === 0 && profiles && profiles.length === 0 && (
<div className="absolute top-full left-0 right-0 mt-1.5 z-50 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">
{!enableTextSearch && open && query.trim().length > 0 && !isFetching && !hasIdentifier && !hasCountry && !hasWikipedia && !hasArchive && navItemCount === 0 && profiles && profiles.length === 0 && createPortal(
<div ref={refs.setFloating} className={dropdownBaseClass} style={floatingStyles}>
<div className="py-6 text-center text-sm text-muted-foreground">
No profiles found
</div>
</div>
)}
</div>,
portalTarget)}
</div>
);
}
+29
View File
@@ -0,0 +1,29 @@
import { Loader2 } from 'lucide-react';
interface SaveDestinationRowProps {
icon: React.ReactNode;
label: string;
description: string;
onClick: () => void;
disabled: boolean;
loading: boolean;
}
/** A single row in the save-feed popover (Home feed / Profile tab / Share). */
export function SaveDestinationRow({
icon, label, description, onClick, disabled, loading,
}: SaveDestinationRowProps) {
return (
<button
onClick={onClick}
disabled={disabled}
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg hover:bg-secondary/60 disabled:opacity-40 disabled:pointer-events-none transition-colors text-left"
>
<span className="shrink-0">{loading ? <Loader2 className="size-4 animate-spin text-muted-foreground" /> : icon}</span>
<span className="flex-1 min-w-0">
<span className="block text-sm font-medium">{label}</span>
<span className="block text-xs text-muted-foreground">{description}</span>
</span>
</button>
);
}
+9 -239
View File
@@ -1,54 +1,33 @@
/**
* SavedFeedFiltersEditor
*
* A controlled component that renders filter controls for a standard
* NIP-01 filter object (TabFilter). Used on the Search page filter
* popover and in the Settings > Feed saved-feed edit panel.
*
* Edits the following filter fields:
* - `kinds` (array of kind numbers)
* - `authors` (array of pubkeys)
* - `search` (NIP-50 search string)
* Shared filter sub-components used by FeedEditModal, ProfileTabEditModal,
* ContentSettings, and the Search page. Includes MultiKindPicker, ScopeToggle,
* AuthorChip, AuthorFilterDropdown, and ListPackPicker.
*/
import React, { useState, useMemo, useCallback, useRef, useEffect } from 'react';
import {
Globe, UserSearch,
ChevronDown, ChevronUp,
Hash, Search as SearchIcon,
X, Check, User,
} from 'lucide-react';
import { nip19 } from 'nostr-tools';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Separator } from '@/components/ui/separator';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { ProfileSearchDropdown } from '@/components/ProfileSearchDropdown';
import { useAuthor } from '@/hooks/useAuthor';
import { useUserLists, useMatchedListId } from '@/hooks/useUserLists';
import { useFollowPacks } from '@/hooks/useFollowPacks';
import { cn } from '@/lib/utils';
import type { TabFilter } from '@/contexts/AppContext';
import type { SearchProfile } from '@/hooks/useSearchProfiles';
import type { UserList } from '@/hooks/useUserLists';
import type { FollowPack } from '@/hooks/useFollowPacks';
import type { KindOption } from '@/lib/feedFilterUtils';
// ─── Types ───────────────────────────────────────────────────────────────────
type KindOption = {
value: string;
label: string;
description: string;
parentId: string;
icon: React.ComponentType<{ className?: string }> | undefined;
};
// ─── Kind options (built once) ───────────────────────────────────────────────
import { buildKindOptions } from '@/lib/feedFilterUtils';
// Re-export for consumers that were importing from this file
export { buildKindOptions } from '@/lib/feedFilterUtils';
export type { KindOption } from '@/lib/feedFilterUtils';
// ─── useScrollCarets ─────────────────────────────────────────────────────────
function useScrollCarets() {
export function useScrollCarets() {
const scrollRef = useRef<HTMLDivElement>(null);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const roRef = useRef<ResizeObserver | null>(null);
@@ -501,9 +480,6 @@ export function ListPackPicker({ lists, followPacks, value, onSelectPubkeys, cla
);
}
// ─── parseSelectedKinds ───────────────────────────────────────────────────────
// ─── AuthorChip ───────────────────────────────────────────────────────────────
@@ -547,210 +523,4 @@ export function AuthorFilterDropdown({ onCommit }: { onCommit: (pubkey: string,
);
}
// ─── Helper: parse kinds from filter ──────────────────────────────────────────
/** Get the kindFilter string representation from a TabFilter's kinds array. */
function kindsToKindFilter(filter: TabFilter): string {
const kinds = filter.kinds;
if (!Array.isArray(kinds) || kinds.length === 0) return 'all';
return kinds.map(String).join(',');
}
/** Get the author scope from a TabFilter. */
function getAuthorScope(filter: TabFilter): 'anyone' | 'people' {
const authors = filter.authors;
if (Array.isArray(authors) && authors.length > 0) return 'people';
return 'anyone';
}
// ─── SavedFeedFiltersEditor ───────────────────────────────────────────────────
interface SavedFeedFiltersEditorProps {
/** Current filter values */
value: TabFilter;
/** Called on every field change with the updated filter */
onChange: (filter: TabFilter) => void;
/** When true, the query input is shown at the top (default: true) */
showQuery?: boolean;
/** Hide the From / author scope section (e.g. profile tabs where author is implicit) */
hideFrom?: boolean;
/** Optional: pre-built kind options (pass to avoid rebuilding) */
kindOptions?: KindOption[];
}
export function SavedFeedFiltersEditor({
value,
onChange,
showQuery = true,
hideFrom = false,
kindOptions: kindOptionsProp,
}: SavedFeedFiltersEditorProps) {
const kindOptions = useMemo(() => kindOptionsProp ?? buildKindOptions(), [kindOptionsProp]);
const { lists } = useUserLists();
const { data: followPacks = [] } = useFollowPacks();
const listPickerValue = useMatchedListId(
Array.isArray(value.authors) ? (value.authors as string[]) : [],
);
const search = typeof value.search === 'string' ? value.search : '';
const authorPubkeys = useMemo(() => Array.isArray(value.authors) ? (value.authors as string[]) : [], [value.authors]);
// Local scope state so clicking "People" immediately shows the panel,
// even before any authors have been added. Initialised from the filter value.
const [authorScope, setAuthorScopeState] = useState<'anyone' | 'people'>(
() => getAuthorScope(value),
);
const kindFilter = kindsToKindFilter(value);
const [customKindText, setCustomKindText] = useState('');
const addAuthor = useCallback((pubkey: string, _label: string) => {
const next = authorPubkeys.includes(pubkey) ? authorPubkeys : [...authorPubkeys, pubkey];
setAuthorScopeState('people');
onChange({ ...value, authors: next });
}, [authorPubkeys, onChange, value]);
const removeAuthor = useCallback((pubkey: string) => {
const next = authorPubkeys.filter((p) => p !== pubkey);
const updated = { ...value };
if (next.length > 0) {
updated.authors = next;
} else {
delete updated.authors;
}
onChange(updated);
}, [authorPubkeys, onChange, value]);
const setAuthorScope = useCallback((scope: 'anyone' | 'people') => {
setAuthorScopeState(scope);
if (scope === 'anyone') {
const updated = { ...value };
delete updated.authors;
onChange(updated);
}
}, [onChange, value]);
const handleKindChange = useCallback((v: string) => {
const updated = { ...value };
if (v === 'all') {
delete updated.kinds;
setCustomKindText('');
} else if (v === 'custom') {
setCustomKindText(Array.isArray(value.kinds) ? (value.kinds as number[]).join(', ') : '');
} else {
updated.kinds = [parseInt(v, 10)];
setCustomKindText('');
}
onChange(updated);
}, [onChange, value]);
const handleCustomKindChange = useCallback((text: string) => {
setCustomKindText(text);
const kinds = text.split(/[\s,]+/).map(Number).filter((n) => !isNaN(n) && n > 0);
if (kinds.length > 0) {
onChange({ ...value, kinds });
}
}, [onChange, value]);
const handleSearchChange = useCallback((newSearch: string) => {
const updated = { ...value };
if (newSearch.trim()) {
updated.search = newSearch;
} else {
delete updated.search;
}
onChange(updated);
}, [onChange, value]);
return (
<div className="space-y-3">
{/* Query */}
{showQuery && (
<>
<div className="space-y-1.5">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Search query</span>
<Input
value={search}
onChange={(e) => handleSearchChange(e.target.value)}
placeholder="e.g. bitcoin"
className="bg-secondary/50 border-border focus-visible:ring-1 h-8 text-base md:text-sm"
/>
</div>
<Separator />
</>
)}
{/* Author scope */}
{!hideFrom && (
<>
<div className="space-y-1.5">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">From</span>
<div className="flex rounded-lg border border-border overflow-hidden">
{([
['anyone', 'Anyone', Globe],
['people', 'People', UserSearch],
] as const).map(([scope, label, Icon]) => (
<button
key={scope}
onClick={() => setAuthorScope(scope)}
className={cn(
'flex-1 py-1.5 flex items-center justify-center gap-1 text-xs font-medium transition-colors',
authorScope === scope
? 'bg-primary text-primary-foreground'
: 'bg-secondary/40 text-muted-foreground hover:bg-secondary hover:text-foreground',
)}
>
<Icon className="size-3.5 shrink-0" />
{label}
</button>
))}
</div>
{authorScope === 'people' && (
<div className="space-y-1.5">
{authorPubkeys.length > 0 && (
<div className="flex flex-wrap gap-1">
{authorPubkeys.map((pk) => (
<AuthorChip key={pk} pubkey={pk} onRemove={() => removeAuthor(pk)} />
))}
</div>
)}
<AuthorFilterDropdown onCommit={addAuthor} />
<ListPackPicker
lists={lists}
followPacks={followPacks}
value={listPickerValue}
onSelectPubkeys={(pubkeys) => {
const updated = { ...value };
if (pubkeys.length > 0) {
updated.authors = pubkeys;
} else {
delete updated.authors;
}
onChange(updated);
}}
/>
</div>
)}
</div>
<Separator />
</>
)}
{/* Kind */}
<div className="space-y-1.5">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Kind</span>
<KindPicker value={kindFilter} options={kindOptions} onChange={handleKindChange} />
</div>
{kindFilter === 'custom' && (
<Input
type="text"
inputMode="numeric"
placeholder="e.g. 1, 30023"
value={customKindText}
onChange={(e) => handleCustomKindChange(e.target.value)}
className="bg-secondary/50 border-border focus-visible:ring-1 rounded-lg text-base md:text-xs h-8"
/>
)}
</div>
);
}
+20
View File
@@ -0,0 +1,20 @@
import { useScreenEffect } from '@/contexts/ScreenEffectContext';
import { PrecipitationEffect } from '@/components/PrecipitationEffect';
/**
* Reads the global screen effect state and renders the appropriate overlay.
* Must be placed inside a ScreenEffectProvider.
*/
export function ScreenEffectRenderer() {
const { screenEffect } = useScreenEffect();
if (!screenEffect) return null;
switch (screenEffect.type) {
case 'rain':
case 'snow':
return <PrecipitationEffect type={screenEffect.type} intensity={screenEffect.intensity} />;
default:
return null;
}
}
+52 -158
View File
@@ -1,9 +1,7 @@
import { Link } from 'react-router-dom';
import { Plus, Pencil, Check, SeparatorHorizontal, Search, ChevronDown, ChevronUp, LinkIcon } from 'lucide-react';
import React, { useState, useRef, useEffect, useCallback } from 'react';
import {
DropdownMenu, DropdownMenuContent, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { SeparatorHorizontal, ChevronDown, ChevronUp, LinkIcon, Pencil, Check } from 'lucide-react';
import { useState } from 'react';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { sidebarItemIcon, itemPath } from '@/lib/sidebarItems';
import type { HiddenSidebarItem } from '@/hooks/useFeedSettings';
import { nip19 } from 'nostr-tools';
@@ -16,105 +14,22 @@ interface SidebarMoreMenuProps {
onAdd: (id: string) => void;
onAddDivider: () => void;
onNavigate?: () => void;
open: boolean;
onOpenChange: (open: boolean) => void;
/** Extra classes on the link text. */
linkClassName?: string;
/** Sidebar item ID configured as the homepage. */
homePage?: string;
}
function useScrollCarets(centerOnOpen = false) {
const scrollRef = useRef<HTMLDivElement>(null);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const roRef = useRef<ResizeObserver | null>(null);
const [canScrollUp, setCanScrollUp] = useState(false);
const [canScrollDown, setCanScrollDown] = useState(false);
const update = useCallback(() => {
const el = scrollRef.current;
if (!el) return;
setCanScrollUp(el.scrollTop > 0);
setCanScrollDown(el.scrollTop + el.clientHeight < el.scrollHeight - 1);
}, []);
const refCallback = useCallback((el: HTMLDivElement | null) => {
// Disconnect previous observer if any
roRef.current?.disconnect();
roRef.current = null;
(scrollRef as React.MutableRefObject<HTMLDivElement | null>).current = el;
if (!el) return;
if (centerOnOpen) {
el.scrollTop = (el.scrollHeight - el.clientHeight) / 2;
}
const ro = new ResizeObserver(update);
ro.observe(el);
roRef.current = ro;
update();
}, [centerOnOpen, update]);
const stopScroll = useCallback(() => {
if (intervalRef.current) { clearInterval(intervalRef.current); intervalRef.current = null; }
}, []);
const startScroll = useCallback((direction: 'up' | 'down') => {
stopScroll();
intervalRef.current = setInterval(() => {
const el = scrollRef.current;
if (!el) return stopScroll();
el.scrollBy({ top: direction === 'up' ? -8 : 8 });
update();
// stop automatically when the limit is reached
const atLimit = direction === 'up' ? el.scrollTop <= 0 : el.scrollTop + el.clientHeight >= el.scrollHeight - 1;
if (atLimit) stopScroll();
}, 16);
}, [update, stopScroll]);
// clean up interval on unmount
useEffect(() => stopScroll, [stopScroll]);
return { scrollRef, refCallback, canScrollUp, canScrollDown, onScroll: update, startScroll, stopScroll };
}
function ScrollCaret({ direction, onMouseEnter, onMouseLeave }: { direction: 'up' | 'down'; onMouseEnter: () => void; onMouseLeave: () => void }) {
return (
<button className="flex cursor-default items-center justify-center py-1 w-full shrink-0" onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave}>
{direction === 'up' ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
</button>
);
}
function ItemRow({ item, onAdd, onClose }: { item: HiddenSidebarItem; onAdd: (id: string) => void; onClose: () => void }) {
return (
<div className="flex items-center">
<button
onClick={() => { onAdd(item.id); onClose(); }}
className="flex items-center gap-3 flex-1 min-w-0 px-2 py-2 rounded-sm text-sm hover:bg-secondary/60 transition-colors cursor-pointer"
>
{sidebarItemIcon(item.id, 'size-5 shrink-0')}
<span className="truncate" style={{ fontFamily: 'var(--title-font-family, inherit)' }}>{item.label}</span>
</button>
<button
onClick={() => { onAdd(item.id); onClose(); }}
className="size-8 flex items-center justify-center shrink-0 rounded-sm text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors"
title={`Add ${item.label} to sidebar`}
>
<Plus className="size-4" strokeWidth={4} />
</button>
</div>
);
}
export function SidebarMoreMenu({
editing, hiddenItems, onDoneEditing, onStartEditing, onAdd, onAddDivider, onNavigate, open, onOpenChange, homePage,
editing, hiddenItems, onDoneEditing, onStartEditing, onAdd, onAddDivider, onNavigate, linkClassName, homePage,
}: SidebarMoreMenuProps) {
const [query, setQuery] = useState('');
const [addMenuOpen, setAddMenuOpen] = useState(false);
const [addQuery, setAddQuery] = useState('');
const { user } = useCurrentUser();
const [expanded, setExpanded] = useState(false);
const [linkInput, setLinkInput] = useState(false);
const [linkValue, setLinkValue] = useState('');
const [linkError, setLinkError] = useState('');
const filtered = hiddenItems.filter((item) => item.label.toLowerCase().includes(query.toLowerCase()));
const addFiltered = hiddenItems.filter((item) => item.label.toLowerCase().includes(addQuery.toLowerCase()));
const sizeClass = linkClassName ?? 'text-lg';
const handleAddLink = () => {
const raw = linkValue.trim();
@@ -176,35 +91,11 @@ export function SidebarMoreMenu({
setLinkError('');
};
const main = useScrollCarets(true);
const add = useScrollCarets();
if (editing) {
return (
<div className="flex flex-col gap-0.5">
<DropdownMenu open={addMenuOpen} onOpenChange={(o) => { setAddMenuOpen(o); if (!o) setAddQuery(''); }}>
<DropdownMenuTrigger asChild>
<button className="flex items-center gap-4 px-4 py-2.5 rounded-full transition-colors text-sm text-muted-foreground hover:text-foreground hover:bg-secondary/60 bg-background/85">
<Plus className="size-4" />
<span>Add</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start" collisionPadding={8} className="w-[240px] p-1 flex flex-col max-h-[calc(var(--radix-dropdown-menu-content-available-height)-12px)]">
<div className="flex items-center gap-3 px-2 py-2 shrink-0">
<Search className="size-5 shrink-0" />
<input value={addQuery} onChange={(e) => setAddQuery(e.target.value)} placeholder="Search..." className="flex-1 min-w-0 bg-transparent text-base md:text-sm outline-none placeholder:text-muted-foreground/60" autoFocus />
</div>
<div className="h-px bg-border mb-1 shrink-0" />
{add.canScrollUp && <ScrollCaret direction="up" onMouseEnter={() => add.startScroll('up')} onMouseLeave={add.stopScroll} />}
<div ref={add.refCallback} className="overflow-y-auto flex-1 min-h-0" onScroll={add.onScroll}>
{addFiltered.map((item) => <ItemRow key={item.id} item={item} onAdd={onAdd} onClose={() => setAddMenuOpen(false)} />)}
{addFiltered.length === 0 && <p className="px-2 py-3 text-sm text-muted-foreground text-center">No results</p>}
</div>
{add.canScrollDown && <ScrollCaret direction="down" onMouseEnter={() => add.startScroll('down')} onMouseLeave={add.stopScroll} />}
</DropdownMenuContent>
</DropdownMenu>
<button onClick={onAddDivider} className="flex items-center gap-4 px-4 py-2.5 rounded-full transition-colors text-sm text-muted-foreground hover:text-foreground hover:bg-secondary/60 bg-background/85">
<SeparatorHorizontal className="size-4" />
<button onClick={onAddDivider} className={`flex items-center gap-4 px-3 py-3 rounded-full transition-colors text-muted-foreground hover:text-foreground hover:bg-secondary/40 bg-background/85 ${sizeClass}`}>
<SeparatorHorizontal className="size-6" />
<span>Add divider</span>
</button>
{linkInput ? (
@@ -248,56 +139,59 @@ export function SidebarMoreMenu({
) : (
<button
onClick={() => setLinkInput(true)}
className="flex items-center gap-4 px-4 py-2.5 rounded-full transition-colors text-sm text-muted-foreground hover:text-foreground hover:bg-secondary/60 bg-background/85"
className={`flex items-center gap-4 px-3 py-3 rounded-full transition-colors text-muted-foreground hover:text-foreground hover:bg-secondary/40 bg-background/85 ${sizeClass}`}
>
<LinkIcon className="size-4" />
<LinkIcon className="size-6" />
<span>Add link</span>
</button>
)}
<button onClick={onDoneEditing} className="flex items-center gap-4 px-4 py-2.5 rounded-full transition-colors text-sm text-primary font-medium hover:bg-primary/10 bg-background/85">
<Check className="size-4" />
<button onClick={onDoneEditing} className={`flex items-center gap-4 px-3 py-3 rounded-full transition-colors text-primary font-medium hover:bg-primary/10 bg-background/85 ${sizeClass}`}>
<Check className="size-6" />
<span>Done editing</span>
</button>
</div>
);
}
// Non-editing mode: inline collapsible list (no popover)
return (
<DropdownMenu open={open} onOpenChange={(o) => { onOpenChange(o); if (!o) setQuery(''); }}>
<DropdownMenuTrigger asChild>
<button className="flex items-center gap-4 px-4 py-2.5 rounded-full transition-colors text-sm text-muted-foreground/60 hover:text-muted-foreground hover:bg-secondary/40 bg-background/85">
{open ? <ChevronUp className="size-4" /> : <ChevronDown className="size-4" />}
<span>More...</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start" collisionPadding={8} className="w-[240px] p-1 flex flex-col max-h-[calc(var(--radix-dropdown-menu-content-available-height)-12px)]">
<div className="flex items-center gap-3 px-2 py-2 shrink-0">
<Search className="size-5 shrink-0" />
<input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search..." className="flex-1 min-w-0 bg-transparent text-base md:text-sm outline-none placeholder:text-muted-foreground/60" autoFocus />
</div>
<div className="h-px bg-border mb-1 shrink-0" />
{main.canScrollUp && <ScrollCaret direction="up" onMouseEnter={() => main.startScroll('up')} onMouseLeave={main.stopScroll} />}
<div ref={main.refCallback} className="overflow-y-auto flex-1 min-h-0" onScroll={main.onScroll}>
{filtered.map((item) => (
<div key={item.id} className="flex items-center">
<Link to={itemPath(item.id, undefined, homePage)} onClick={() => { onOpenChange(false); onNavigate?.(); }} className="flex items-center gap-3 flex-1 min-w-0 px-2 py-2 rounded-sm text-sm hover:bg-secondary/60 transition-colors">
{sidebarItemIcon(item.id, 'size-5 shrink-0')}
<span className="truncate" style={{ fontFamily: 'var(--title-font-family, inherit)' }}>{item.label}</span>
</Link>
<button onClick={() => { onAdd(item.id); onOpenChange(false); }} className="size-8 flex items-center justify-center shrink-0 rounded-sm text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors" title={`Add ${item.label} to sidebar`}>
<Plus className="size-4" strokeWidth={4} />
</button>
</div>
<div className="flex flex-col gap-0.5">
<button
onClick={() => setExpanded((v) => !v)}
className={`flex items-center gap-4 px-3 py-3 rounded-full transition-colors text-muted-foreground/60 hover:text-muted-foreground hover:bg-secondary/40 bg-background/85 ${sizeClass}`}
>
{expanded ? <ChevronUp className="size-6" /> : <ChevronDown className="size-6" />}
<span>{expanded ? 'Less...' : 'More...'}</span>
</button>
{expanded && (
<div className="flex flex-col gap-0.5">
{hiddenItems.map((item) => (
<Link
key={item.id}
to={itemPath(item.id, undefined, homePage)}
onClick={() => { setExpanded(false); onNavigate?.(); }}
className={`flex items-center gap-4 px-3 py-3 rounded-full font-normal text-foreground transition-colors hover:bg-secondary/40 bg-background/85 ${sizeClass}`}
>
{sidebarItemIcon(item.id)}
<span className="truncate">{item.label}</span>
</Link>
))}
{filtered.length === 0 && <p className="px-2 py-3 text-sm text-muted-foreground text-center">No results</p>}
{hiddenItems.length === 0 && (
<p className={`px-3 py-3 text-muted-foreground ${sizeClass}`}>All items are in the sidebar</p>
)}
{user && (
<button
onClick={() => { setExpanded(false); onStartEditing(); }}
className={`flex items-center gap-4 px-3 py-3 rounded-full transition-colors text-muted-foreground hover:text-foreground hover:bg-secondary/40 bg-background/85 ${sizeClass}`}
>
<Pencil className="size-6" />
<span>Edit sidebar</span>
</button>
)}
</div>
{main.canScrollDown && <ScrollCaret direction="down" onMouseEnter={() => main.startScroll('down')} onMouseLeave={main.stopScroll} />}
<div className="h-px bg-border my-1 shrink-0" />
<button onClick={() => { onStartEditing(); onOpenChange(false); }} className="flex items-center gap-3 w-full px-2 py-2 rounded-sm text-sm hover:bg-secondary/60 transition-colors cursor-pointer shrink-0">
<Pencil className="size-5" />
<span>Edit sidebar</span>
</button>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
);
}
+138 -32
View File
@@ -1,5 +1,5 @@
import { Link } from 'react-router-dom';
import { GripVertical, X } from 'lucide-react';
import { GripVertical, X, ChevronDown } from 'lucide-react';
import {
DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors,
type DragEndEvent,
@@ -8,11 +8,13 @@ import {
SortableContext, verticalListSortingStrategy, useSortable, arrayMove,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { sidebarItemIcon, itemLabel, itemPath, isSidebarDivider, isNostrUri, isExternalUri } from '@/lib/sidebarItems';
import { sidebarItemIcon, itemLabel, itemPath, isSidebarDivider, isSidebarSearch, isNostrUri, isExternalUri } from '@/lib/sidebarItems';
import { cn } from '@/lib/utils';
import { useCallback } from 'react';
import { useCallback, useState } from 'react';
import { NostrEventSidebarItem } from '@/components/NostrEventSidebarItem';
import { ExternalContentSidebarItem } from '@/components/ExternalContentSidebarItem';
import { ProfileSearchDropdown } from '@/components/ProfileSearchDropdown';
import { SortableItemShell } from '@/components/SortableItemShell';
// ── Sortable item ─────────────────────────────────────────────────────────────
@@ -21,6 +23,9 @@ export interface SidebarNavItemProps {
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;
profilePath?: string;
showIndicator?: boolean;
@@ -31,35 +36,19 @@ export interface SidebarNavItemProps {
}
export function SidebarNavItem({
id, active, editing, onRemove, onClick, profilePath, showIndicator, linkClassName, homePage,
id, active, editing, onRemove, onAdd, belowMore, onClick, profilePath, showIndicator, linkClassName, homePage,
}: SidebarNavItemProps) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id, disabled: !editing });
const style = { transform: CSS.Transform.toString(transform), transition };
const icon = sidebarItemIcon(id);
const label = itemLabel(id);
const path = itemPath(id, profilePath, homePage);
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} label={label}>
<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',
@@ -73,17 +62,69 @@ export function SidebarNavItem({
</span>
<span className="truncate" style={{ fontFamily: 'var(--title-font-family, inherit)' }}>{label}</span>
</Link>
</SortableItemShell>
);
}
{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 ${label}`}
// ── Search input item (sidebar inline search) ────────────────────────────────
interface SidebarSearchItemProps {
id: string;
editing: boolean;
onRemove: (id: string) => void;
onAdd?: (id: string) => void;
belowMore?: boolean;
linkClassName?: string;
}
function SidebarSearchItem({
id, editing, onRemove, onAdd, belowMore, linkClassName,
}: SidebarSearchItemProps) {
const [expanded, setExpanded] = useState(false);
const collapse = useCallback(() => setExpanded(false), []);
const icon = sidebarItemIcon(id);
const label = itemLabel(id);
// Font style matching: the collapsed label uses --title-font-family.
// Apply the same font to the expanded input so the row height stays constant.
const titleFontStyle: React.CSSProperties = { fontFamily: 'var(--title-font-family, inherit)' };
return (
<SortableItemShell id={id} editing={editing} onRemove={onRemove} onAdd={onAdd} belowMore={belowMore} label={label}>
<div className="flex-1 min-w-0 relative">
{/* Always render the sidebar-item-shaped row with a locked min-height
so toggling between label and input doesn't shift layout. */}
<div
role={expanded && !editing ? undefined : 'button'}
tabIndex={expanded && !editing ? undefined : 0}
onClick={() => { if (!editing && !expanded) setExpanded(true); }}
onKeyDown={(e) => { if (!editing && !expanded && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); setExpanded(true); } }}
className={cn(
'flex items-center gap-4 py-3 rounded-full transition-colors w-full text-left',
editing ? 'px-2' : 'px-3',
'font-normal text-foreground',
linkClassName ?? 'text-lg',
)}
>
<X className="size-4" />
</button>
)}
</div>
<span className="shrink-0">{icon}</span>
{expanded && !editing ? (
<ProfileSearchDropdown
placeholder="Search..."
autoFocus
enableTextSearch
hideIcon
onDismiss={collapse}
className="flex-1 min-w-0"
inputClassName="h-auto py-0 px-0 bg-transparent border-0 rounded-none shadow-none ring-0 ring-offset-0 focus-visible:ring-0 focus-visible:ring-offset-0 text-[length:inherit] md:text-[length:inherit]"
inputStyle={titleFontStyle}
/>
) : (
<span className="truncate" style={titleFontStyle}>{label}</span>
)}
</div>
</div>
</SortableItemShell>
);
}
@@ -130,12 +171,49 @@ function SidebarDividerItem({ sortableId, editing, onRemove }: SidebarDividerIte
);
}
// ── "More..." separator (draggable boundary in editing mode) ──────────────────
function MoreSeparatorItem({ sortableId, editing, linkClassName }: { sortableId: string; editing: boolean; linkClassName?: string }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: sortableId, disabled: !editing });
const style = { transform: CSS.Transform.toString(transform), transition };
return (
<div
ref={setNodeRef}
style={style}
className={cn('flex items-center rounded-full transition-colors relative bg-background/85 hover:bg-secondary/40', 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>
)}
<div className={cn(
'flex items-center gap-4 py-3 rounded-full flex-1 min-w-0 text-muted-foreground/60',
editing ? 'px-2' : 'px-3',
linkClassName ?? 'text-lg',
)}>
<ChevronDown className="size-6" />
<span>More...</span>
</div>
</div>
);
}
// ── DnD-aware nav list ────────────────────────────────────────────────────────
/** Sentinel ID representing the "More..." boundary in the editing list. */
export const MORE_SEPARATOR_ID = '__more__';
export interface SidebarNavListProps {
items: string[];
editing: boolean;
onRemove: (id: string, index?: number) => void;
onAdd?: (id: string) => void;
onReorder: (newOrder: string[]) => void;
isActive: (id: string) => boolean;
getOnClick?: (id: string) => ((e: React.MouseEvent) => void) | undefined;
@@ -147,7 +225,7 @@ export interface SidebarNavListProps {
}
export function SidebarNavList({
items, editing, onRemove, onReorder, isActive, getOnClick, getProfilePath, getShowIndicator, linkClassName, homePage,
items, editing, onRemove, onAdd, onReorder, isActive, getOnClick, getProfilePath, getShowIndicator, linkClassName, homePage,
}: SidebarNavListProps) {
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
@@ -157,6 +235,9 @@ export function SidebarNavList({
// Assign unique sortable IDs: regular items use their id, dividers get "divider-{index}"
const sortableIds = items.map((id, i) => isSidebarDivider(id) ? `divider-${i}` : id);
// Find the "More..." boundary to determine which items are below it
const moreIndex = items.indexOf(MORE_SEPARATOR_ID);
const handleDragEnd = useCallback((event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
@@ -171,6 +252,12 @@ export function SidebarNavList({
<SortableContext items={sortableIds} strategy={verticalListSortingStrategy}>
{items.map((id, i) => {
const sortableId = sortableIds[i];
const isBelowMore = moreIndex !== -1 && i > moreIndex;
if (id === MORE_SEPARATOR_ID) {
return <MoreSeparatorItem key={MORE_SEPARATOR_ID} sortableId={MORE_SEPARATOR_ID} editing={editing} linkClassName={linkClassName} />;
}
if (isSidebarDivider(id)) {
return (
<SidebarDividerItem
@@ -189,6 +276,8 @@ export function SidebarNavList({
active={isActive(id)}
editing={editing}
onRemove={(removeId) => onRemove(removeId, i)}
onAdd={onAdd}
belowMore={isBelowMore}
onClick={getOnClick?.(id)}
linkClassName={linkClassName}
/>
@@ -202,11 +291,26 @@ export function SidebarNavList({
active={isActive(id)}
editing={editing}
onRemove={(removeId) => onRemove(removeId, i)}
onAdd={onAdd}
belowMore={isBelowMore}
onClick={getOnClick?.(id)}
linkClassName={linkClassName}
/>
);
}
if (isSidebarSearch(id)) {
return (
<SidebarSearchItem
key={id}
id={id}
editing={editing}
onRemove={(removeId) => onRemove(removeId, i)}
onAdd={onAdd}
belowMore={isBelowMore}
linkClassName={linkClassName}
/>
);
}
return (
<SidebarNavItem
key={id}
@@ -214,6 +318,8 @@ export function SidebarNavList({
active={isActive(id)}
editing={editing}
onRemove={(removeId) => onRemove(removeId, i)}
onAdd={onAdd}
belowMore={isBelowMore}
onClick={getOnClick?.(id)}
profilePath={getProfilePath?.(id)}
showIndicator={getShowIndicator?.(id)}
+72
View File
@@ -0,0 +1,72 @@
import { GripVertical, X, Plus } from 'lucide-react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { cn } from '@/lib/utils';
export interface SortableItemShellProps {
/** The sortable ID (must be unique within the DnD context). */
id: string;
/** Whether the sidebar is in editing mode. */
editing: boolean;
/** Called when the remove (X) button is clicked. */
onRemove: (id: string) => void;
/** Called when the add (+) button is clicked (below-more items). */
onAdd?: (id: string) => void;
/** True when this item is below the "More..." separator (hidden zone). */
belowMore?: boolean;
/** Label for the add/remove button tooltip. */
label?: string;
/** The content to render inside the shell (the Link + icon + label). */
children: React.ReactNode;
}
/**
* Shared drag-sortable wrapper for sidebar items.
* Provides the grip handle, outer container, and add/remove action buttons.
*/
export function SortableItemShell({
id, editing, onRemove, onAdd, belowMore, label, children,
}: SortableItemShellProps) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id, disabled: !editing });
const style = { transform: CSS.Transform.toString(transform), transition };
return (
<div
ref={setNodeRef}
style={style}
className={cn('flex items-center rounded-full transition-colors relative bg-background/85 hover:bg-secondary/40', 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>
)}
{children}
{editing && (
belowMore ? (
<button
onClick={(e) => { e.stopPropagation(); onAdd?.(id); }}
className="flex items-center justify-center size-8 shrink-0 rounded-full transition-all text-muted-foreground hover:text-primary hover:bg-primary/10"
title={label ? `Add ${label}` : 'Add'}
>
<Plus className="size-4" />
</button>
) : (
<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={label ? `Remove ${label}` : 'Remove'}
>
<X className="size-4" />
</button>
)
)}
</div>
);
}
+178
View File
@@ -0,0 +1,178 @@
import type { NostrEvent } from '@nostrify/nostrify';
import { Badge } from '@/components/ui/badge';
import { Clock, Globe, Image, Languages, MessageSquareOff, Radio, Search, SortDesc, Terminal, Users, Video, WandSparkles } from 'lucide-react';
import { buildKindOptions } from '@/lib/feedFilterUtils';
/** Map from kind number string to friendly label like "Posts (1)". */
const KIND_LABEL_MAP: Map<string, string> = new Map(
buildKindOptions().map((o) => [o.value, o.label]),
);
/** Parse a spell timestamp value into human-readable text. */
function formatTimestamp(value: string): string {
const units: Record<string, string> = {
s: 'second', m: 'minute', h: 'hour', d: 'day',
w: 'week', mo: 'month', y: 'year',
};
if (value === 'now') return 'now';
const match = value.match(/^(\d+)(s|m|h|d|w|mo|y)$/);
if (match) {
const [, num, unit] = match;
const label = units[unit] ?? unit;
return `last ${num} ${label}${parseInt(num) !== 1 ? 's' : ''}`;
}
// Absolute timestamp
const ts = parseInt(value);
if (!isNaN(ts)) {
return new Date(ts * 1000).toLocaleDateString();
}
return value;
}
/** Friendly display name for tag filter letters. */
function tagFilterLabel(letter: string): string {
if (letter === 't') return '#';
return `#${letter}`;
}
interface SpellContentProps {
event: NostrEvent;
}
export function SpellContent({ event }: SpellContentProps) {
const { tags } = event;
const name = tags.find(([t]) => t === 'name')?.[1];
const cmd = tags.find(([t]) => t === 'cmd')?.[1];
const kinds = tags.filter(([t]) => t === 'k').map(([, v]) => v);
const authors = tags.find(([t]) => t === 'authors')?.slice(1) ?? [];
const search = tags.find(([t]) => t === 'search')?.[1];
const since = tags.find(([t]) => t === 'since')?.[1];
const until = tags.find(([t]) => t === 'until')?.[1];
const limit = tags.find(([t]) => t === 'limit')?.[1];
const relays = tags.find(([t]) => t === 'relays')?.slice(1) ?? [];
const tagFilters = tags.filter(([t]) => t === 'tag');
const closeOnEose = tags.some(([t]) => t === 'close-on-eose');
// Client-hint tags (NIP-50 extensions)
const media = tags.find(([t]) => t === 'media')?.[1];
const language = tags.find(([t]) => t === 'language')?.[1];
const platform = tags.find(([t]) => t === 'platform')?.[1];
const sort = tags.find(([t]) => t === 'sort')?.[1];
const includeReplies = tags.find(([t]) => t === 'include-replies')?.[1];
return (
<div className="mt-2">
{/* Title */}
{name && (
<div className="flex items-center gap-2 mb-2">
<WandSparkles className="size-4 text-primary shrink-0" />
<span className="text-[15px] font-semibold leading-snug">{name}</span>
</div>
)}
{/* Description from content */}
{event.content && (
<p className="text-[15px] leading-relaxed text-foreground/90 line-clamp-3 mb-3">
{event.content}
</p>
)}
{/* Badge row */}
<div className="flex flex-wrap gap-1.5">
{cmd && (
<Badge variant="outline" className="gap-1 text-xs">
<Terminal className="size-3" />
{cmd}
</Badge>
)}
{kinds.map((k) => (
<Badge key={k} variant="outline" className="text-xs">
{KIND_LABEL_MAP.get(k) ?? `Kind ${k}`}
</Badge>
))}
{authors
.filter((a) => a.startsWith('$'))
.map((a) => (
<Badge key={a} variant="outline" className="gap-1 text-xs">
<Users className="size-3" />
{a}
</Badge>
))}
{search && (
<Badge variant="secondary" className="gap-1 text-xs">
<Search className="size-3" />
{search}
</Badge>
)}
{since && (
<Badge variant="outline" className="gap-1 text-xs">
<Clock className="size-3" />
{formatTimestamp(since)}
</Badge>
)}
{until && (
<Badge variant="outline" className="gap-1 text-xs">
<Clock className="size-3" />
until {formatTimestamp(until)}
</Badge>
)}
{limit && (
<Badge variant="outline" className="text-xs">
limit: {limit}
</Badge>
)}
{closeOnEose && (
<Badge variant="outline" className="text-xs">
one-shot
</Badge>
)}
{tagFilters.map(([, letter, ...values], i) => (
<Badge key={i} variant="outline" className="text-xs">
{tagFilterLabel(letter)}: {values.join(', ')}
</Badge>
))}
{media && media !== 'all' && (
<Badge variant="secondary" className="gap-1 text-xs">
{media === 'images' ? <Image className="size-3" /> : media === 'videos' || media === 'vines' ? <Video className="size-3" /> : null}
{media}
</Badge>
)}
{language && (
<Badge variant="secondary" className="gap-1 text-xs">
<Languages className="size-3" />
{language}
</Badge>
)}
{platform && platform !== 'nostr' && (
<Badge variant="secondary" className="gap-1 text-xs">
<Globe className="size-3" />
{platform}
</Badge>
)}
{sort && sort !== 'recent' && (
<Badge variant="secondary" className="gap-1 text-xs">
<SortDesc className="size-3" />
{sort}
</Badge>
)}
{includeReplies === 'false' && (
<Badge variant="outline" className="gap-1 text-xs">
<MessageSquareOff className="size-3" />
no replies
</Badge>
)}
{relays.map((r) => (
<Badge key={r} variant="outline" className="gap-1 text-xs">
<Radio className="size-3" />
{r.replace('wss://', '')}
</Badge>
))}
</div>
</div>
);
}
+103
View File
@@ -0,0 +1,103 @@
import { useState } from 'react';
import { Loader2 } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { useUserStatus } from '@/hooks/useUserStatus';
import { usePublishStatus } from '@/hooks/usePublishStatus';
import { useToast } from '@/hooks/useToast';
interface StatusEditorProps {
pubkey: string;
/** Padding class for the editing form wrapper (e.g. "p-3" or "px-3 py-2"). */
formClassName?: string;
/** Padding class for the inactive "Set a status" button (e.g. "px-4" or "px-3"). */
buttonClassName?: string;
}
/**
* Inline NIP-38 status editor used in the account popover (LeftSidebar)
* and the mobile drawer (MobileDrawer).
*
* Renders either the current status (or "Set a status" placeholder) as a
* clickable button, or an input + Save/Clear/Cancel controls when editing.
*/
export function StatusEditor({ pubkey, formClassName = 'p-3', buttonClassName = 'px-4' }: StatusEditorProps) {
const userStatus = useUserStatus(pubkey);
const publishStatus = usePublishStatus();
const { toast } = useToast();
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState('');
const submitStatus = (text: string) => {
publishStatus.mutateAsync({ status: text }).then(() => {
setEditing(false);
setDraft('');
toast({ title: text ? 'Status updated' : 'Status cleared' });
}).catch(() => {
toast({ title: 'Failed to update status', variant: 'destructive' });
});
};
if (editing) {
return (
<div className={`${formClassName} space-y-2`}>
<Input
value={draft}
onChange={(e) => setDraft(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();
submitStatus(draft.trim());
} else if (e.key === 'Escape') {
setEditing(false);
setDraft('');
}
}}
/>
<div className="flex items-center gap-1.5">
<button
onClick={() => submitStatus(draft.trim())}
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={() => submitStatus('')}
disabled={publishStatus.isPending}
className="text-xs font-medium text-destructive hover:underline disabled:opacity-50"
>
Clear
</button>
)}
<button
onClick={() => { setEditing(false); setDraft(''); }}
className="text-xs text-muted-foreground hover:underline ml-auto"
>
Cancel
</button>
</div>
</div>
);
}
return (
<button
onClick={() => {
setEditing(true);
setDraft(userStatus.status ?? '');
}}
className={`flex items-center gap-3 w-full ${buttonClassName} 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>
);
}
+3 -3
View File
@@ -18,7 +18,7 @@ const conversationCache = new Map<string, ChatMessage[]>();
/** Compact AI chat widget for the sidebar. */
export function AIChatWidget() {
const { user } = useCurrentUser();
const { sendStreamingMessage, getAvailableModels, isLoading, isAuthenticated } = useShakespeare();
const { sendStreamingMessage, getAvailableModels, isLoading } = useShakespeare();
// Fetch available models and select the cheapest as default
const { data: defaultModelId } = useQuery({
@@ -88,7 +88,7 @@ export function AIChatWidget() {
}
}, [input, isLoading, messages, sendStreamingMessage, defaultModelId]);
if (!user || !isAuthenticated) {
if (!user) {
return (
<div className="flex flex-col items-center gap-2 py-4 px-2 text-center">
<Bot className="size-8 text-muted-foreground" />
@@ -156,7 +156,7 @@ export function AIChatWidget() {
function MessageBubble({ message }: { message: ChatMessage }) {
const isUser = message.role === 'user';
const content = typeof message.content === 'string' ? message.content : message.content.map((c) => c.text ?? '').join('');
const content = typeof message.content === 'string' ? message.content : Array.isArray(message.content) ? message.content.map((c) => c.text ?? '').join('') : '';
return (
<div className={cn('flex', isUser ? 'justify-end' : 'justify-start')}>
+8 -3
View File
@@ -16,9 +16,6 @@ export type Theme = "light" | "dark" | "system" | "custom";
*/
export type ContentWarningPolicy = "blur" | "hide" | "show";
/** How to handle events with a NIP-36 content-warning tag. */
export type NsfwPolicy = "blur" | "hide" | "show";
export interface RelayMetadata {
/** List of relays with read/write permissions */
relays: { url: string; read: boolean; write: boolean }[];
@@ -177,6 +174,10 @@ export interface SavedFeed {
filter: TabFilter;
vars: TabVarDef[];
createdAt: number;
/** Hex event ID of a kind:777 spell event. When present, the saved feed
* is rendered by fetching this spell and passing it to useStreamPosts({ spell }),
* which handles full resolution (variables, hints, tag filters, etc.). */
spellId?: string;
}
export interface AppConfig {
@@ -247,6 +248,10 @@ export interface AppConfig {
sandboxDomain: string;
/** Ordered list of right sidebar widget configs. Each entry is a widget type ID with optional display settings. */
sidebarWidgets: WidgetConfig[];
/** Selected AI model ID for Buddy chat. Empty string means "use cheapest available". */
aiModel: string;
/** Custom system prompt override for Buddy chat. Empty string means use the built-in default. */
aiSystemPrompt: string;
}
/** Configuration for a single widget in the right sidebar. */
+82
View File
@@ -0,0 +1,82 @@
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react';
import type { PrecipitationIntensity } from '@/hooks/useWeather';
// ─── Types ───
/** A visual effect rendered as a full-screen overlay. Extend this union to add new effects. */
export type ScreenEffect =
| { type: 'rain' | 'snow'; intensity: PrecipitationIntensity }
// Future effects can be added here:
// | { type: 'confetti'; duration?: number }
// | { type: 'fireworks' }
;
export interface ScreenEffectContextValue {
/** The currently active screen effect, or null if none. */
screenEffect: ScreenEffect | null;
/** Set or clear the screen effect. Pass null to stop. */
setScreenEffect: (effect: ScreenEffect | null) => void;
}
// ─── Context ───
const ScreenEffectCtx = createContext<ScreenEffectContextValue | null>(null);
// ─── Persistence ───
const STORAGE_KEY = 'ditto:screen-effect';
function loadEffect(): ScreenEffect | null {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw);
// Basic validation
if (parsed && typeof parsed.type === 'string') {
return parsed as ScreenEffect;
}
return null;
} catch {
return null;
}
}
function saveEffect(effect: ScreenEffect | null): void {
try {
if (effect) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(effect));
} else {
localStorage.removeItem(STORAGE_KEY);
}
} catch {
// Storage full or unavailable
}
}
// ─── Provider ───
export function ScreenEffectProvider({ children }: { children: ReactNode }) {
const [screenEffect, setScreenEffectRaw] = useState<ScreenEffect | null>(loadEffect);
const setScreenEffect = useCallback((effect: ScreenEffect | null) => {
setScreenEffectRaw(effect);
saveEffect(effect);
}, []);
return (
<ScreenEffectCtx.Provider value={{ screenEffect, setScreenEffect }}>
{children}
</ScreenEffectCtx.Provider>
);
}
// ─── Hook ───
export function useScreenEffect(): ScreenEffectContextValue {
const ctx = useContext(ScreenEffectCtx);
if (!ctx) {
throw new Error('useScreenEffect must be used within a ScreenEffectProvider');
}
return ctx;
}
+375
View File
@@ -0,0 +1,375 @@
import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import { nip19 } from 'nostr-tools';
import { z } from 'zod';
import { useShakespeare, type ChatMessage } from '@/hooks/useShakespeare';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAppContext } from '@/hooks/useAppContext';
import { useAIChatTools, TOOLS } from '@/hooks/useAIChatTools';
import { type DisplayMessage, type ToolCall } from '@/lib/aiChatTools';
import { buildSystemPrompt, type UserIdentity } from '@/lib/aiChatSystemPrompt';
import type { NostrEvent } from '@nostrify/nostrify';
/** Options for configuring the AI chat session with a buddy identity. */
export interface AIChatSessionOptions {
/** Buddy agent display name. When omitted, defaults to "Dork". */
buddyName?: string;
/** Buddy soul text injected into the system prompt. */
buddySoul?: string;
}
// ─── Persistence ───
const CHAT_STORAGE_KEY = 'ditto:ai-chat-messages';
/** Zod schema for a single persisted chat message. */
const StoredToolCallSchema = z.object({
id: z.string(),
name: z.string(),
arguments: z.record(z.string(), z.unknown()),
result: z.string().optional(),
});
const StoredMessageSchema = z.object({
id: z.string(),
role: z.enum(['user', 'assistant', 'system', 'tool_result']),
content: z.string(),
timestamp: z.string(),
toolCalls: z.array(StoredToolCallSchema).optional(),
toolCallId: z.string().optional(),
// nostrEvent is not validated in detail — just needs to be an object if present
nostrEvent: z.record(z.string(), z.unknown()).optional(),
});
const StoredMessagesSchema = z.array(StoredMessageSchema);
function loadMessages(): DisplayMessage[] {
try {
const raw = localStorage.getItem(CHAT_STORAGE_KEY);
if (!raw) return [];
const parsed = StoredMessagesSchema.safeParse(JSON.parse(raw));
if (!parsed.success) {
console.warn('Discarding corrupted AI chat history:', parsed.error.message);
localStorage.removeItem(CHAT_STORAGE_KEY);
return [];
}
return parsed.data.map((m) => ({
...m,
timestamp: new Date(m.timestamp),
nostrEvent: m.nostrEvent as NostrEvent | undefined,
toolCalls: m.toolCalls as ToolCall[] | undefined,
}));
} catch {
return [];
}
}
function saveMessages(messages: DisplayMessage[]): void {
try {
const stored = messages.map((m) => ({ ...m, timestamp: m.timestamp.toISOString() }));
localStorage.setItem(CHAT_STORAGE_KEY, JSON.stringify(stored));
} catch {
// Storage full or unavailable — silently ignore
}
}
// ─── Hook ───
export function useAIChatSession(options: AIChatSessionOptions = {}) {
const { buddyName, buddySoul } = options;
const { user, metadata } = useCurrentUser();
const { config } = useAppContext();
const { sendStreamingMessage, getAvailableModels, getCredits, isLoading: apiLoading, error: apiError, clearError } = useShakespeare();
const { executeToolCall, savedFeeds } = useAIChatTools();
const [messages, setMessages] = useState<DisplayMessage[]>(loadMessages);
const [input, setInput] = useState('');
const [isStreaming, setIsStreaming] = useState(false);
const [streamingText, setStreamingText] = useState('');
// Resolve the effective model: config value, or fetch the cheapest as default
const [defaultModel, setDefaultModel] = useState('');
const selectedModel = config.aiModel || defaultModel;
const messagesEndRef = useRef<HTMLDivElement>(null);
const abortRef = useRef<AbortController | null>(null);
// Persist messages to localStorage
useEffect(() => {
saveMessages(messages);
}, [messages]);
// Scroll to bottom on new messages or streaming text updates
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages, streamingText]);
// Fetch cheapest model as fallback when no model is configured
useEffect(() => {
if (!user || config.aiModel) return;
let cancelled = false;
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;
});
if (sorted.length > 0) {
setDefaultModel(sorted[0].id);
}
})
.catch(() => {});
return () => { cancelled = true; };
}, [user, config.aiModel, getAvailableModels]);
// Build the system prompt — dynamic based on buddy identity, saved feeds, user identity, + optional custom override
const savedFeedLabels = useMemo(() => savedFeeds.map((f) => f.label), [savedFeeds]);
const userIdentity = useMemo<UserIdentity | undefined>(() => {
if (!user) return undefined;
return {
npub: nip19.npubEncode(user.pubkey),
pubkey: user.pubkey,
displayName: metadata?.display_name || metadata?.name,
nip05: metadata?.nip05,
about: metadata?.about,
};
}, [user, metadata]);
const systemPrompt = useMemo(
() => buildSystemPrompt(buddyName, buddySoul, config.aiSystemPrompt || undefined, savedFeedLabels, userIdentity),
[buddyName, buddySoul, config.aiSystemPrompt, savedFeedLabels, userIdentity],
);
// Build the chat messages array for the API
const buildApiMessages = useCallback((displayMsgs: DisplayMessage[]): ChatMessage[] => {
const apiMessages: ChatMessage[] = [systemPrompt];
for (const msg of displayMsgs) {
if (msg.role === 'tool_result') {
apiMessages.push({
role: 'tool',
content: msg.content,
tool_call_id: msg.toolCallId,
});
} else if (msg.role === 'assistant' && msg.toolCalls && msg.toolCalls.length > 0) {
apiMessages.push({
role: 'assistant',
content: msg.content || null,
tool_calls: msg.toolCalls.map((tc) => ({
id: tc.id,
type: 'function' as const,
function: { name: tc.name, arguments: JSON.stringify(tc.arguments) },
})),
});
} else {
apiMessages.push({ role: msg.role as 'user' | 'assistant' | 'system', content: msg.content });
}
}
return apiMessages;
}, [systemPrompt]);
// Handle sending a message. Pass `override` to send arbitrary text (e.g. suggestion chips).
const handleSend = useCallback(async (override?: string) => {
const trimmed = (override ?? input).trim();
if (!trimmed || isStreaming) return;
// Slash commands — handled locally, never sent to the API
if (trimmed.startsWith('/')) {
const cmd = trimmed.toLowerCase();
if (cmd === '/new' || cmd === '/clear') {
handleClear();
setInput('');
return;
}
// Unknown command — ignore silently
setInput('');
return;
}
if (!selectedModel) return;
clearError();
setInput('');
const controller = new AbortController();
abortRef.current = controller;
const userMessage: DisplayMessage = {
id: crypto.randomUUID(),
role: 'user',
content: trimmed,
timestamp: new Date(),
};
const newMessages = [...messages, userMessage];
setMessages(newMessages);
setIsStreaming(true);
setStreamingText('');
try {
const MAX_TOOL_ROUNDS = 10;
let apiMessages = buildApiMessages(newMessages);
let currentMessages = newMessages;
for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
if (controller.signal.aborted) break;
// Stream the response — text chunks update streamingText in real-time
let streamAccumulator = '';
const response = await sendStreamingMessage(
apiMessages,
selectedModel,
(chunk) => {
streamAccumulator += chunk;
setStreamingText(streamAccumulator);
},
{ tools: TOOLS } as Partial<Record<string, unknown>>,
controller.signal,
);
// Stream finished — clear the streaming text
setStreamingText('');
const choice = response.choices[0];
const assistantMsg = choice.message;
// Check for tool calls
const rawMessage = assistantMsg as unknown as {
content?: string;
tool_calls?: Array<{
id: string;
function: { name: string; arguments: string };
}>;
};
if (!rawMessage.tool_calls || rawMessage.tool_calls.length === 0) {
const content = typeof assistantMsg.content === 'string' ? assistantMsg.content : '';
const assistantMessage: DisplayMessage = {
id: crypto.randomUUID(),
role: 'assistant',
content,
timestamp: new Date(),
};
setMessages((prev) => [...prev, assistantMessage]);
break;
}
// Execute tool calls
let nostrEvent: NostrEvent | undefined;
const toolCalls: ToolCall[] = [];
for (const tc of rawMessage.tool_calls) {
if (controller.signal.aborted) break;
let args: Record<string, unknown>;
try {
args = JSON.parse(tc.function.arguments);
} catch {
// Return an error to the AI so it can retry instead of silently running with empty args
toolCalls.push({
id: tc.id,
name: tc.function.name,
arguments: {},
result: JSON.stringify({ error: `Invalid tool call arguments: could not parse JSON for ${tc.function.name}` }),
});
continue;
}
const execResult = await executeToolCall(tc.function.name, args);
if (execResult.nostrEvent) {
nostrEvent = execResult.nostrEvent;
}
toolCalls.push({
id: tc.id,
name: tc.function.name,
arguments: args,
result: execResult.result,
});
}
if (controller.signal.aborted) break;
// Add assistant message with tool calls to display
const toolMsg: DisplayMessage = {
id: crypto.randomUUID(),
role: 'assistant',
content: rawMessage.content || '',
timestamp: new Date(),
toolCalls,
nostrEvent,
};
// Add tool result display messages (hidden in UI, used by buildApiMessages)
const toolResultMsgs: DisplayMessage[] = toolCalls.map((tc) => ({
id: crypto.randomUUID(),
role: 'tool_result' as const,
content: tc.result ?? '',
toolCallId: tc.id,
timestamp: new Date(),
}));
currentMessages = [...currentMessages, toolMsg, ...toolResultMsgs];
setMessages(currentMessages);
// Rebuild API messages
apiMessages = buildApiMessages(currentMessages);
}
} catch (err) {
// Silently handle user-initiated abort and other errors
// (API-level errors are surfaced via apiError from useShakespeare)
if (err instanceof DOMException && err.name === 'AbortError') return;
} finally {
abortRef.current = null;
setIsStreaming(false);
setStreamingText('');
}
}, [input, selectedModel, isStreaming, messages, buildApiMessages, sendStreamingMessage, executeToolCall, clearError]);
// Stop an in-flight generation
const handleStop = useCallback(() => {
abortRef.current?.abort();
}, []);
// Handle keyboard shortcuts
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
}, [handleSend]);
// Clear conversation
const handleClear = useCallback(() => {
setMessages([]);
localStorage.removeItem(CHAT_STORAGE_KEY);
clearError();
}, [clearError]);
return {
// State
messages,
input,
setInput,
isStreaming,
streamingText,
selectedModel,
apiLoading,
apiError,
messagesEndRef,
// Actions
handleSend,
handleStop,
handleKeyDown,
handleClear,
getCredits,
};
}
+104
View File
@@ -0,0 +1,104 @@
import { useCallback, useMemo } from 'react';
import { useNostr } from '@nostrify/react';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useBuddy } from '@/hooks/useBuddy';
import { useTheme } from '@/hooks/useTheme';
import { useAppContext } from '@/hooks/useAppContext';
import { useSavedFeeds } from '@/hooks/useSavedFeeds';
import { useScreenEffect } from '@/contexts/ScreenEffectContext';
import { truncateToolResult } from '@/lib/tools/truncateToolResult';
import { toolToOpenAI } from '@/lib/tools/toolToOpenAI';
import { SetThemeTool } from '@/lib/tools/SetThemeTool';
import { SearchUsersTool } from '@/lib/tools/SearchUsersTool';
import { SearchFollowPacksTool } from '@/lib/tools/SearchFollowPacksTool';
import { CreateSpellTool } from '@/lib/tools/CreateSpellTool';
import { FetchPageTool } from '@/lib/tools/FetchPageTool';
import { UploadFromUrlTool } from '@/lib/tools/UploadFromUrlTool';
import { CreateEmojiPackTool } from '@/lib/tools/CreateEmojiPackTool';
import { PublishEventsTool } from '@/lib/tools/PublishEventsTool';
import { FetchEventTool } from '@/lib/tools/FetchEventTool';
import { GetFeedTool } from '@/lib/tools/GetFeedTool';
import { CreateWebxdcTool } from '@/lib/tools/CreateWebxdcTool';
import { MakeItRainTool } from '@/lib/tools/MakeItRainTool';
import type { Tool, ToolContext } from '@/lib/tools/Tool';
import type { ToolExecutorResult } from '@/lib/aiChatTools';
// ─── Tool Registry ───
/** All registered tools, keyed by name. */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const TOOL_REGISTRY: Record<string, Tool<any>> = {
set_theme: SetThemeTool,
search_users: SearchUsersTool,
search_follow_packs: SearchFollowPacksTool,
create_spell: CreateSpellTool,
fetch_page: FetchPageTool,
upload_from_url: UploadFromUrlTool,
create_emoji_pack: CreateEmojiPackTool,
publish_events: PublishEventsTool,
fetch_event: FetchEventTool,
get_feed: GetFeedTool,
create_webxdc: CreateWebxdcTool,
make_it_rain: MakeItRainTool,
};
/** OpenAI-formatted tool definitions derived from the registry. */
export const TOOLS = Object.entries(TOOL_REGISTRY).map(
([name, tool]) => toolToOpenAI(name, tool),
);
// ─── Hook ───
export function useAIChatTools() {
const { applyCustomTheme } = useTheme();
const { nostr } = useNostr();
const { user } = useCurrentUser();
const { config } = useAppContext();
const { savedFeeds } = useSavedFeeds();
const { setScreenEffect } = useScreenEffect();
const { getBuddySecretKey } = useBuddy();
/** Build a ToolContext from current hook values. */
const buildContext = useCallback((): ToolContext => ({
nostr,
user: user ? { pubkey: user.pubkey, signer: user.signer } : undefined,
config: {
corsProxy: config.corsProxy,
blossomServerMetadata: config.blossomServerMetadata,
useAppBlossomServers: config.useAppBlossomServers,
},
getBuddySecretKey,
savedFeeds,
applyCustomTheme,
setScreenEffect,
}), [nostr, user, config, getBuddySecretKey, savedFeeds, applyCustomTheme, setScreenEffect]);
const executeToolCall = useCallback(async (name: string, rawArgs: Record<string, unknown>): Promise<ToolExecutorResult> => {
const tool = TOOL_REGISTRY[name];
if (!tool) {
return { result: JSON.stringify({ error: `Unknown tool: ${name}` }) };
}
try {
// Validate and parse args through the tool's Zod schema.
const args = tool.inputSchema.parse(rawArgs);
const ctx = buildContext();
const toolResult = await tool.execute(args, ctx);
return {
result: truncateToolResult(toolResult.result),
nostrEvent: toolResult.nostrEvent,
};
} catch (err) {
return { result: JSON.stringify({ error: `Tool "${name}" failed: ${err instanceof Error ? err.message : 'Unknown error'}` }) };
}
}, [buildContext]);
// Expose savedFeeds for the system prompt (saved feed labels)
const savedFeedsMemo = useMemo(() => savedFeeds, [savedFeeds]);
return { executeToolCall, savedFeeds: savedFeedsMemo };
}
+374
View File
@@ -0,0 +1,374 @@
import { useCallback, useMemo } from 'react';
import { useNostr } from '@nostrify/react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools';
import { bytesToHex, hexToBytes } from '@noble/hashes/utils';
import { z } from 'zod';
import type { NostrEvent, NostrFilter } from '@nostrify/nostrify';
import { useAppContext } from '@/hooks/useAppContext';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { toast } from '@/hooks/useToast';
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
// ─── Constants ────────────────────────────────────────────────────────────────
/** localStorage key for the buddy agent's secret key (hex-encoded). */
const BUDDY_NSEC_STORAGE = 'ditto:buddy-nsec';
/** Suffix appended to `config.appId` for the NIP-78 d-tag. */
const BUDDY_DTAG_SUFFIX = '/buddy';
// ─── Types ────────────────────────────────────────────────────────────────────
/** Encrypted content stored in the kind 30078 buddy event. */
export interface BuddySecrets {
/** Buddy agent secret key as hex string. */
nsec: string;
/** The buddy's canonical name (source of truth — kind 0 may use nicknames). */
name: string;
/** The buddy's soul — personality / behavior description injected into the system prompt. */
soul: string;
}
/** Public + decrypted buddy data returned by the hook. */
export interface BuddyIdentity {
/** Buddy agent's public key (hex). */
pubkey: string;
/** The buddy's canonical name. */
name: string;
/** The buddy's soul text. */
soul: string;
/** The raw kind 30078 event (for reference). */
event: NostrEvent;
}
/** Zod schema for validating decrypted buddy secrets. */
const BuddySecretsSchema = z.object({
nsec: z.string().min(1),
name: z.string().min(1),
soul: z.string().min(1),
});
// ─── localStorage helpers ─────────────────────────────────────────────────────
/** Read the buddy nsec from localStorage, or null if not present. */
function getStoredNsec(): Uint8Array | null {
const hex = localStorage.getItem(BUDDY_NSEC_STORAGE);
if (!hex) return null;
try {
return hexToBytes(hex);
} catch {
return null;
}
}
/** Persist the buddy nsec to localStorage. */
function storeNsec(sk: Uint8Array): void {
localStorage.setItem(BUDDY_NSEC_STORAGE, bytesToHex(sk));
}
/** Remove the buddy nsec from localStorage. */
function clearStoredNsec(): void {
localStorage.removeItem(BUDDY_NSEC_STORAGE);
}
// ─── Hook ─────────────────────────────────────────────────────────────────────
/**
* Manages the user's Buddy AI agent identity.
*
* - Reads buddy nsec from localStorage for fast access.
* - Queries the kind 30078 buddy event from relays as backup.
* - If localStorage is empty but a relay event exists, decrypts and restores the nsec.
* - Provides `createBuddy` to generate a keypair + publish identity events.
* - Provides `updateSoul` to change the buddy's soul text.
* - Provides `resetBuddy` to wipe the buddy entirely.
*/
export function useBuddy() {
const { config } = useAppContext();
const { nostr } = useNostr();
const { user } = useCurrentUser();
const queryClient = useQueryClient();
const { mutateAsync: publishEvent } = useNostrPublish();
const dTag = `${config.appId}${BUDDY_DTAG_SUFFIX}`;
// ── Query the kind 30078 buddy event from relays ────────────────────────
const buddyEventQuery = useQuery({
queryKey: ['buddy-event', user?.pubkey],
queryFn: async () => {
if (!user) return null;
const filter: NostrFilter = {
kinds: [30078],
authors: [user.pubkey],
'#d': [dTag],
limit: 1,
};
const events = await nostr.query([filter]);
return events.length > 0 ? events[0] : null;
},
enabled: !!user,
staleTime: 5 * 60 * 1000,
gcTime: 10 * 60 * 1000,
});
// ── Decrypt buddy secrets from the relay event ──────────────────────────
const buddyQuery = useQuery<BuddyIdentity | null>({
queryKey: ['buddy-identity', buddyEventQuery.data?.id],
queryFn: async () => {
const event = buddyEventQuery.data;
if (!event || !user) return null;
// Always need to decrypt to get name + soul
const secrets = await decryptSecrets(event, user);
if (!secrets) return null;
// Try localStorage nsec first
const localSk = getStoredNsec();
if (localSk) {
const pubkey = getPublicKey(localSk);
// Verify the localStorage key matches the event's p-tag
const eventPubkey = event.tags.find(([t]) => t === 'p')?.[1];
if (eventPubkey && eventPubkey !== pubkey) {
// Mismatch — restore from decrypted secrets
clearStoredNsec();
const sk = hexToBytes(secrets.nsec);
storeNsec(sk);
return { pubkey: getPublicKey(sk), name: secrets.name, soul: secrets.soul, event };
}
return { pubkey, name: secrets.name, soul: secrets.soul, event };
}
// localStorage empty — restore nsec from decrypted secrets
const sk = hexToBytes(secrets.nsec);
storeNsec(sk);
return { pubkey: getPublicKey(sk), name: secrets.name, soul: secrets.soul, event };
},
enabled: !!buddyEventQuery.data && !!user,
staleTime: Infinity,
gcTime: Infinity,
});
// ── Create a new buddy ──────────────────────────────────────────────────
const createBuddy = useMutation({
mutationFn: async ({ name, soul, picture }: { name: string; soul: string; picture?: string }) => {
if (!user) throw new Error('User not logged in');
if (!user.signer.nip44) throw new Error('NIP-44 encryption not supported by signer');
// Generate buddy keypair
const sk = generateSecretKey();
const pubkey = getPublicKey(sk);
// Persist nsec to localStorage
storeNsec(sk);
// Build kind 0 profile for the buddy agent
const profileContent = JSON.stringify({
name,
...(picture ? { picture } : {}),
about: soul,
bot: true,
});
const profileEvent = finalizeEvent({
kind: 0,
content: profileContent,
tags: [],
created_at: Math.floor(Date.now() / 1000),
}, sk) as NostrEvent;
// Build kind 30078 buddy identity event (signed by the user via useNostrPublish)
const secrets: BuddySecrets = {
nsec: bytesToHex(sk),
name,
soul,
};
const encrypted = await user.signer.nip44.encrypt(user.pubkey, JSON.stringify(secrets));
// Publish buddy profile (signed by buddy key) in background
nostr.event(profileEvent, { signal: AbortSignal.timeout(5000) }).catch(() => {
toast({ title: 'Buddy profile publish failed', description: 'The buddy\'s profile could not be published to relays.', variant: 'destructive' });
});
// Publish kind 30078 via useNostrPublish (handles client tag + published_at)
const buddyEvent = await publishEvent({
kind: 30078,
content: encrypted,
tags: [
['d', dTag],
['p', pubkey],
['alt', 'Buddy AI agent identity'],
],
});
return { pubkey, name, soul, event: buddyEvent } satisfies BuddyIdentity;
},
onSuccess: (identity) => {
// Update caches
queryClient.setQueryData(['buddy-event', user?.pubkey], identity.event);
queryClient.setQueryData(['buddy-identity', identity.event.id], identity);
},
});
// ── Update the buddy's soul ─────────────────────────────────────────────
const updateSoul = useMutation({
mutationFn: async (newSoul: string) => {
if (!user) throw new Error('User not logged in');
if (!user.signer.nip44) throw new Error('NIP-44 encryption not supported by signer');
// Get the current nsec (must exist if buddy exists)
const localSk = getStoredNsec();
if (!localSk) throw new Error('Buddy nsec not found in localStorage');
// Fetch fresh from relay — never read from cache for read-modify-write
const prev = await fetchFreshEvent(nostr, {
kinds: [30078],
authors: [user.pubkey],
'#d': [dTag],
});
if (!prev) throw new Error('No existing buddy identity to update');
const freshSecrets = await decryptSecrets(prev, user);
if (!freshSecrets) throw new Error('Failed to decrypt buddy secrets');
const pubkey = getPublicKey(localSk);
const currentName = freshSecrets.name;
// Encrypt updated secrets (preserve name from fresh event)
const secrets: BuddySecrets = {
nsec: bytesToHex(localSk),
name: currentName,
soul: newSoul,
};
const encrypted = await user.signer.nip44.encrypt(user.pubkey, JSON.stringify(secrets));
// Publish updated kind 30078 via useNostrPublish (handles client tag + published_at)
const buddyEvent = await publishEvent({
kind: 30078,
content: encrypted,
tags: [
['d', dTag],
['p', pubkey],
['alt', 'Buddy AI agent identity'],
],
prev,
});
// Also update the buddy's kind 0 about field (fire-and-forget)
const profileEvent = finalizeEvent({
kind: 0,
content: JSON.stringify({
name: currentName,
about: newSoul,
bot: true,
}),
tags: [],
created_at: Math.floor(Date.now() / 1000),
}, localSk) as NostrEvent;
nostr.event(profileEvent, { signal: AbortSignal.timeout(5000) }).catch(() => {
toast({ title: 'Buddy profile update failed', description: 'The buddy\'s updated profile could not be published to relays.', variant: 'destructive' });
});
return { pubkey, name: currentName, soul: newSoul, event: buddyEvent } satisfies BuddyIdentity;
},
onSuccess: (identity) => {
queryClient.setQueryData(['buddy-event', user?.pubkey], identity.event);
queryClient.setQueryData(['buddy-identity', identity.event.id], identity);
},
});
// ── Reset (wipe) the buddy ──────────────────────────────────────────────
const resetBuddy = useMutation({
mutationFn: async () => {
if (!user) throw new Error('User not logged in');
// Clear localStorage
clearStoredNsec();
// Fetch the current event so useNostrPublish can preserve published_at
const prev = await fetchFreshEvent(nostr, {
kinds: [30078],
authors: [user.pubkey],
'#d': [dTag],
});
// Publish an empty kind 30078 event to overwrite on relays
const emptyEvent = await publishEvent({
kind: 30078,
content: '',
tags: [
['d', dTag],
['alt', 'Buddy AI agent identity (cleared)'],
],
prev: prev ?? undefined,
});
return emptyEvent;
},
onSuccess: () => {
queryClient.setQueryData(['buddy-event', user?.pubkey], null);
// Clear all buddy-identity cache entries (the key includes a dynamic event ID)
queryClient.removeQueries({ queryKey: ['buddy-identity'] });
},
});
// ── Derived state ───────────────────────────────────────────────────────
const buddy = buddyQuery.data ?? null;
const isLoading = buddyEventQuery.isLoading || buddyQuery.isLoading;
const hasBuddy = buddy !== null;
/** Get the buddy's secret key from localStorage. Only call when buddy exists. */
const getBuddySecretKey = useCallback((): Uint8Array | null => {
return getStoredNsec();
}, []);
return useMemo(() => ({
/** The resolved buddy identity, or null if none configured. */
buddy,
/** True while loading from relays / decrypting. */
isLoading,
/** Whether a buddy has been configured. */
hasBuddy,
/** Create a new buddy agent. */
createBuddy,
/** Update the buddy's soul text. */
updateSoul,
/** Wipe the buddy identity entirely. */
resetBuddy,
/** Get the buddy's secret key from localStorage (for signing events). */
getBuddySecretKey,
}), [buddy, isLoading, hasBuddy, createBuddy, updateSoul, resetBuddy, getBuddySecretKey]);
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
/** Decrypt the buddy secrets from a kind 30078 event's encrypted content. */
async function decryptSecrets(
event: NostrEvent,
user: { pubkey: string; signer: { nip44?: { decrypt: (pubkey: string, ciphertext: string) => Promise<string> } } },
): Promise<BuddySecrets | null> {
if (!event.content || !user.signer.nip44) return null;
try {
const decrypted = await user.signer.nip44.decrypt(user.pubkey, event.content);
const parsed = BuddySecretsSchema.safeParse(JSON.parse(decrypted));
if (!parsed.success) {
console.warn('Buddy secrets failed validation:', parsed.error.message);
return null;
}
return parsed.data;
} catch {
return null;
}
}
+148
View File
@@ -0,0 +1,148 @@
import { useState, useCallback, useMemo } from 'react';
import type { DisplayMessage } from '@/lib/aiChatTools';
import { useBuddy } from '@/hooks/useBuddy';
// ─── Types ────────────────────────────────────────────────────────────────────
type OnboardingStep = 'intro' | 'name' | 'soul' | 'confirm' | 'creating' | 'done';
// ─── Static Dork messages ─────────────────────────────────────────────────────
function dorkMessage(content: string, id: string): DisplayMessage {
return { id, role: 'assistant', content, timestamp: new Date() };
}
const INTRO_MESSAGE = dorkMessage(
`Hey there! I'm **Dork**, your friendly setup assistant.\n\nI'm here to help you create your very own AI buddy — a personal agent with its own Nostr identity and personality.\n\nOnce set up, your buddy will replace me as your chat companion here. Don't worry, I won't be offended. Probably.\n\nLet's get started! **What should we name your buddy?**`,
'dork-intro',
);
const SOUL_PROMPT = dorkMessage(
`Great name! Now for the fun part — **describe your buddy's soul.**\n\nThis is their personality: how they think, talk, and vibe. It gets injected into their brain every time you chat.\n\nA few examples to spark ideas:\n- *"A witty space explorer who explains everything with cosmic analogies"*\n- *"A chill surfer dude who's secretly a philosophy professor"*\n- *"A sarcastic librarian who knows everything but judges your taste"*\n\nWrite as much or as little as you want:`,
'dork-soul-prompt',
);
function confirmMessage(name: string, soul: string): DisplayMessage {
return dorkMessage(
`Here's what we've got:\n\n**Name:** ${name}\n**Soul:** ${soul}\n\nLook good? Type **"yes"** to create your buddy, or **"no"** to start over.`,
'dork-confirm',
);
}
const CREATING_MESSAGE = dorkMessage(
`Creating your buddy's Nostr identity... one moment! ✨`,
'dork-creating',
);
// ─── Hook ─────────────────────────────────────────────────────────────────────
export function useBuddyOnboarding() {
const { createBuddy } = useBuddy();
const [step, setStep] = useState<OnboardingStep>('intro');
const [messages, setMessages] = useState<DisplayMessage[]>([INTRO_MESSAGE]);
const [buddyName, setBuddyName] = useState('');
const [buddySoul, setBuddySoul] = useState('');
const [error, setError] = useState<string | null>(null);
const addUserMessage = useCallback((content: string) => {
const msg: DisplayMessage = {
id: crypto.randomUUID(),
role: 'user',
content,
timestamp: new Date(),
};
setMessages((prev) => [...prev, msg]);
return msg;
}, []);
const addDorkMessage = useCallback((msg: DisplayMessage) => {
setMessages((prev) => [...prev, msg]);
}, []);
const handleSend = useCallback(async (input: string) => {
const trimmed = input.trim();
if (!trimmed) return;
setError(null);
switch (step) {
case 'intro': {
// User provided a name
addUserMessage(trimmed);
setBuddyName(trimmed);
addDorkMessage(SOUL_PROMPT);
setStep('name');
break;
}
case 'name': {
// User provided soul description
addUserMessage(trimmed);
setBuddySoul(trimmed);
addDorkMessage(confirmMessage(buddyName, trimmed));
setStep('soul');
break;
}
case 'soul': {
// User confirms or restarts
addUserMessage(trimmed);
const lower = trimmed.toLowerCase();
if (lower === 'yes' || lower === 'y' || lower === 'yep' || lower === 'looks good' || lower === 'confirm') {
setStep('creating');
addDorkMessage(CREATING_MESSAGE);
try {
await createBuddy.mutateAsync({ name: buddyName, soul: buddySoul });
setStep('done');
} catch {
setError('Failed to create buddy. Please try again.');
setStep('soul');
addDorkMessage(dorkMessage(
`Hmm, something went wrong. Type **"yes"** to try again.`,
`dork-error-${Date.now()}`,
));
}
} else if (lower === 'no' || lower === 'n' || lower === 'nope' || lower === 'start over' || lower === 'restart') {
setBuddyName('');
setBuddySoul('');
setMessages([INTRO_MESSAGE]);
setStep('intro');
} else {
addDorkMessage(dorkMessage(
`Just type **"yes"** to confirm or **"no"** to start over.`,
`dork-clarify-${Date.now()}`,
));
}
break;
}
default:
break;
}
}, [step, buddyName, buddySoul, addUserMessage, addDorkMessage, createBuddy]);
const isCreating = step === 'creating';
const isDone = step === 'done';
const placeholder = useMemo(() => {
switch (step) {
case 'intro': return 'Type a name...';
case 'name': return 'Describe their personality...';
case 'soul': return 'yes / no';
case 'creating': return 'Creating...';
default: return '';
}
}, [step]);
return {
messages,
handleSend,
isCreating,
isDone,
placeholder,
error,
};
}
+13 -106
View File
@@ -5,10 +5,9 @@ import { useFeedSettings } from './useFeedSettings';
import { useFollowList } from './useFollowActions';
import { parseAuthorEvent } from './useAuthor';
import { getEnabledFeedKinds } from '@/lib/extraKinds';
import { getPaginationCursor, parseRepostContent, isRepostKind, type FeedItem } from '@/lib/feedUtils';
import { getPaginationCursor, isRepostKind, unwrapReposts, deduplicateFeedItems, type FeedItem } from '@/lib/feedUtils';
import { isReplyEvent } from '@/lib/nostrEvents';
import { setProfileCached } from '@/lib/profileCache';
import type { NostrEvent } from '@nostrify/nostrify';
const PAGE_SIZE = 15;
@@ -173,59 +172,13 @@ export function useFeed(tab: 'follows' | 'global' | 'communities', options?: Use
const oldestQueryTimestamp = getPaginationCursor(validFilteredEvents);
// Process reposts same as follows feed
const items: FeedItem[] = [];
const repostMissingIds: string[] = [];
const repostMap = new Map<string, NostrEvent>();
const items = await unwrapReposts(
validFilteredEvents,
(ids) => nostr.query([{ ids, limit: ids.length }], { signal }),
now,
);
for (const ev of validFilteredEvents) {
if (isRepostKind(ev.kind)) {
// Handle reposts (kind 6 for notes, kind 16 for generic)
const embedded = parseRepostContent(ev);
if (embedded && embedded.created_at <= now) {
items.push({ event: embedded, repostedBy: ev.pubkey, sortTimestamp: ev.created_at });
} else {
const repostedId = ev.tags.find(([name]) => name === 'e')?.[1];
if (repostedId) {
repostMissingIds.push(repostedId);
repostMap.set(repostedId, ev);
}
}
} else {
// Kind 1 and extra kinds — direct post
items.push({ event: ev, sortTimestamp: ev.created_at });
}
}
// Fetch any missing reposted events in a single query
if (repostMissingIds.length > 0) {
try {
const originals = await nostr.query(
[{ ids: repostMissingIds, limit: repostMissingIds.length }],
{ signal },
);
for (const original of originals) {
const repost = repostMap.get(original.id);
if (repost && original.created_at <= now) {
items.push({ event: original, repostedBy: repost.pubkey, sortTimestamp: repost.created_at });
}
}
} catch {
// timeout or abort — just skip the missing reposts
}
}
// Deduplicate
const seen = new Map<string, FeedItem>();
for (const item of items) {
const existing = seen.get(item.event.id);
if (!existing) {
seen.set(item.event.id, item);
} else if (!item.repostedBy && existing.repostedBy) {
seen.set(item.event.id, item);
}
}
let dedupedItems = Array.from(seen.values()).sort((a, b) => b.sortTimestamp - a.sortTimestamp);
let dedupedItems = deduplicateFeedItems(items);
// Filter replies if the user has disabled them
if (!feedSettings.followsFeedShowReplies) {
@@ -258,59 +211,13 @@ export function useFeed(tab: 'follows' | 'global' | 'communities', options?: Use
const validEvents = rawEvents.filter((ev) => ev.created_at <= now);
const oldestQueryTimestamp = getPaginationCursor(validEvents);
const items: FeedItem[] = [];
const repostMissingIds: string[] = [];
const repostMap = new Map<string, NostrEvent>();
const items = await unwrapReposts(
validEvents,
(ids) => nostr.query([{ ids, limit: ids.length }], { signal }),
now,
);
for (const ev of validEvents) {
if (isRepostKind(ev.kind)) {
// Handle reposts (kind 6 for notes, kind 16 for generic)
const embedded = parseRepostContent(ev);
if (embedded && embedded.created_at <= now) {
items.push({ event: embedded, repostedBy: ev.pubkey, sortTimestamp: ev.created_at });
} else {
const repostedId = ev.tags.find(([name]) => name === 'e')?.[1];
if (repostedId) {
repostMissingIds.push(repostedId);
repostMap.set(repostedId, ev);
}
}
} else {
// Kind 1, 1068, 3367, 34236, 37516, etc. — direct post / extra kinds
items.push({ event: ev, sortTimestamp: ev.created_at });
}
}
// Fetch any missing reposted events in a single query
if (repostMissingIds.length > 0) {
try {
const originals = await nostr.query(
[{ ids: repostMissingIds, limit: repostMissingIds.length }],
{ signal },
);
for (const original of originals) {
const repost = repostMap.get(original.id);
if (repost && original.created_at <= now) {
items.push({ event: original, repostedBy: repost.pubkey, sortTimestamp: repost.created_at });
}
}
} catch {
// timeout or abort — just skip the missing reposts
}
}
// Deduplicate
const seen = new Map<string, FeedItem>();
for (const item of items) {
const existing = seen.get(item.event.id);
if (!existing) {
seen.set(item.event.id, item);
} else if (!item.repostedBy && existing.repostedBy) {
seen.set(item.event.id, item);
}
}
let dedupedItems = Array.from(seen.values()).sort((a, b) => b.sortTimestamp - a.sortTimestamp);
let dedupedItems = deduplicateFeedItems(items);
// Filter replies if the user has disabled them
if (!feedSettings.followsFeedShowReplies) {
+1 -1
View File
@@ -9,7 +9,7 @@ import { useCallback, useMemo } from "react";
/** Default sidebar order for fresh installs (system pages only). */
const DEFAULT_SIDEBAR_ORDER = SIDEBAR_ITEMS
.filter((s) => ['feed', 'notifications', 'search', 'trends', 'bookmarks', 'profile', 'settings', 'help', 'theme'].includes(s.id))
.filter((s) => ['search', 'feed', 'notifications', 'discover', 'trends', 'bookmarks', 'profile', 'settings', 'help', 'theme'].includes(s.id))
.map((s) => s.id);
/** Map of legacy sidebar item IDs to their current replacements. */
+3 -4
View File
@@ -1,5 +1,5 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useNostr } from '@nostrify/react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useCurrentUser } from '@/hooks/useCurrentUser';
@@ -19,16 +19,15 @@ interface PublishStatusParams {
* signals that the status is cleared.
*/
export function usePublishStatus() {
const queryClient = useQueryClient();
const { nostr } = useNostr();
const queryClient = useQueryClient();
const { mutateAsync: createEvent } = useNostrPublish();
const { user } = useCurrentUser();
return useMutation({
mutationFn: async ({ status, url }: PublishStatusParams) => {
if (!user?.pubkey) return;
if (!user) throw new Error('User not logged in');
// Fetch the previous event to preserve published_at (addressable event convention)
const prev = await fetchFreshEvent(nostr, {
kinds: [30315],
authors: [user.pubkey],
+2 -1
View File
@@ -41,7 +41,7 @@ export function useSavedFeeds() {
};
/** Add a new saved feed. Returns the created feed. */
const addSavedFeed = async (label: string, filter: TabFilter, vars: TabVarDef[]): Promise<SavedFeed> => {
const addSavedFeed = async (label: string, filter: TabFilter, vars: TabVarDef[], spellId?: string): Promise<SavedFeed> => {
if (!user) throw new Error('Must be logged in to save feeds');
const newFeed: SavedFeed = {
@@ -50,6 +50,7 @@ export function useSavedFeeds() {
filter,
vars,
createdAt: Date.now(),
...(spellId ? { spellId } : {}),
};
await persist([...savedFeeds, newFeed]);
+139 -123
View File
@@ -3,15 +3,25 @@ import { useCurrentUser } from './useCurrentUser';
import type { NUser } from '@nostrify/react/login';
// Types for Shakespeare API (compatible with OpenAI ChatCompletionMessageParam)
export interface ToolCallFunction {
id: string;
type: 'function';
function: { name: string; arguments: string };
}
export interface ChatMessage {
role: 'user' | 'assistant' | 'system';
content: string | Array<{
role: 'user' | 'assistant' | 'system' | 'tool';
content: string | null | Array<{
type: 'text' | 'image_url';
text?: string;
image_url?: {
url: string;
};
}>;
/** Present on assistant messages that invoke tools. */
tool_calls?: ToolCallFunction[];
/** Present on tool result messages — must match a tool_calls[].id from the preceding assistant message. */
tool_call_id?: string;
}
/** Tool function definition for chat completions. */
@@ -83,7 +93,9 @@ export interface ModelsResponse {
}
// Configuration
const SHAKESPEARE_API_URL = 'https://ai.shakespeare.diy/v1';
// REMOVE BEFORE ALEX SEE THIS
const SHAKESPEARE_API_URL = 'https://ai.pocketvibe.app/v1';
const SHAKESPEARE_SERVER_URL = 'https://ai.pocketvibe.app/v1';
// Helper function to create NIP-98 token
async function createNIP98Token(
@@ -146,7 +158,8 @@ async function handleAPIError(response: Response) {
}
}
throw new Error(`Invalid request: ${error.error?.message || error.details || error.error || 'Please check your request parameters.'}`);
} catch {
} catch (e) {
if (e instanceof Error) throw e;
throw new Error('Invalid request. Please check your parameters and try again.');
}
} else if (response.status === 404) {
@@ -157,7 +170,8 @@ async function handleAPIError(response: Response) {
try {
const errorData = await response.json();
throw new Error(`API error: ${errorData.error?.message || errorData.details || errorData.error || response.statusText}`);
} catch {
} catch (e) {
if (e instanceof Error) throw e;
throw new Error(`Network error: ${response.statusText}. Please check your connection and try again.`);
}
}
@@ -173,74 +187,17 @@ export function useShakespeare() {
setError(null);
}, []);
// Chat completion function
const sendChatMessage = useCallback(async (
messages: ChatMessage[],
model: string = 'shakespeare',
options?: Partial<ChatCompletionRequest>
): Promise<ChatCompletionResponse> => {
if (!user) {
throw new Error('User must be logged in to use AI features');
}
setIsLoading(true);
setError(null);
try {
const requestBody: ChatCompletionRequest = {
model,
messages,
...options
};
const token = await createNIP98Token(
'POST',
`${SHAKESPEARE_API_URL}/chat/completions`,
requestBody,
user
);
const response = await fetch(`${SHAKESPEARE_API_URL}/chat/completions`, {
method: 'POST',
headers: {
'Authorization': `Nostr ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
});
await handleAPIError(response);
return await response.json();
} catch (err) {
let errorMessage = 'An unexpected error occurred';
if (err instanceof Error) {
errorMessage = err.message;
} else if (typeof err === 'string') {
errorMessage = err;
}
// Add context for common issues
if (errorMessage.includes('Failed to fetch') || errorMessage.includes('Network')) {
errorMessage = 'Network error: Please check your internet connection and try again.';
} else if (errorMessage.includes('signer')) {
errorMessage = 'Authentication error: Please make sure you are logged in with a Nostr account that supports signing.';
}
setError(errorMessage);
throw new Error(errorMessage);
} finally {
setIsLoading(false);
}
}, [user]);
// Streaming chat completion function
// Streaming chat completion function.
// Streams text via `onChunk` and returns the fully-assembled response
// (including any tool_calls) so callers can use the same tool-loop logic
// as the non-streaming path.
const sendStreamingMessage = useCallback(async (
messages: ChatMessage[],
model: string = 'shakespeare',
onChunk: (chunk: string) => void,
options?: Partial<ChatCompletionRequest>
): Promise<void> => {
options?: Partial<ChatCompletionRequest>,
signal?: AbortSignal,
): Promise<ChatCompletionResponse> => {
if (!user) {
throw new Error('User must be logged in to use AI features');
}
@@ -258,7 +215,7 @@ export function useShakespeare() {
const token = await createNIP98Token(
'POST',
`${SHAKESPEARE_API_URL}/chat/completions`,
`${SHAKESPEARE_SERVER_URL}/chat/completions`,
requestBody,
user
);
@@ -270,6 +227,7 @@ export function useShakespeare() {
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
signal,
});
await handleAPIError(response);
@@ -281,34 +239,94 @@ export function useShakespeare() {
const reader = response.body.getReader();
const decoder = new TextDecoder();
// Accumulate the full response from stream deltas
let content = '';
let finishReason = 'stop';
let responseId = '';
let responseModel = model;
const toolCalls: Map<number, { id: string; type: 'function'; function: { name: string; arguments: string } }> = new Map();
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
const chunk = decoder.decode(value, { stream: true });
const lines = chunk.split('\n');
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6);
if (data === '[DONE]') return;
if (!line.startsWith('data: ')) continue;
const data = line.slice(6);
if (data === '[DONE]') break;
try {
const parsed = JSON.parse(data);
const content = parsed.choices?.[0]?.delta?.content;
if (content) {
onChunk(content);
}
} catch {
// Ignore parsing errors for incomplete chunks
try {
const parsed = JSON.parse(data);
const delta = parsed.choices?.[0]?.delta;
if (!delta) continue;
if (parsed.id) responseId = parsed.id;
if (parsed.model) responseModel = parsed.model;
if (parsed.choices?.[0]?.finish_reason) {
finishReason = parsed.choices[0].finish_reason;
}
// Accumulate text content and stream to UI
if (delta.content) {
content += delta.content;
onChunk(delta.content);
}
// Accumulate tool call deltas
if (delta.tool_calls) {
for (const tc of delta.tool_calls) {
const idx = tc.index ?? 0;
const existing = toolCalls.get(idx);
if (!existing) {
toolCalls.set(idx, {
id: tc.id ?? '',
type: 'function',
function: {
name: tc.function?.name ?? '',
arguments: tc.function?.arguments ?? '',
},
});
} else {
if (tc.id) existing.id = tc.id;
if (tc.function?.name) existing.function.name += tc.function.name;
if (tc.function?.arguments) existing.function.arguments += tc.function.arguments;
}
}
}
} catch {
// Ignore parsing errors for incomplete chunks
}
}
}
} finally {
reader.releaseLock();
}
// Assemble the full response in the same shape as the non-streaming endpoint
const assembledToolCalls = toolCalls.size > 0
? Array.from(toolCalls.values())
: undefined;
return {
id: responseId,
object: 'chat.completion',
created: Math.floor(Date.now() / 1000),
model: responseModel,
choices: [{
index: 0,
message: {
role: 'assistant',
content: content || undefined,
...(assembledToolCalls ? { tool_calls: assembledToolCalls } : {}),
},
finish_reason: finishReason,
}],
usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
};
} catch (err) {
let errorMessage = 'An unexpected error occurred';
@@ -332,65 +350,63 @@ export function useShakespeare() {
}
}, [user]);
// Get credits balance
const getCredits = useCallback(async (): Promise<{ amount: number }> => {
if (!user) {
throw new Error('User must be logged in to check credits');
}
const token = await createNIP98Token(
'GET',
`${SHAKESPEARE_SERVER_URL}/credits`,
undefined,
user
);
const response = await fetch(`${SHAKESPEARE_API_URL}/credits`, {
method: 'GET',
headers: {
'Authorization': `Nostr ${token}`,
},
});
await handleAPIError(response);
return await response.json();
}, [user]);
// Get available models
const getAvailableModels = useCallback(async (): Promise<ModelsResponse> => {
if (!user) {
throw new Error('User must be logged in to use AI features');
}
setIsLoading(true);
setError(null);
const token = await createNIP98Token(
'GET',
`${SHAKESPEARE_SERVER_URL}/models`,
undefined,
user
);
try {
const token = await createNIP98Token(
'GET',
`${SHAKESPEARE_API_URL}/models`,
undefined,
user
);
const response = await fetch(`${SHAKESPEARE_API_URL}/models`, {
method: 'GET',
headers: {
'Authorization': `Nostr ${token}`,
},
});
const response = await fetch(`${SHAKESPEARE_API_URL}/models`, {
method: 'GET',
headers: {
'Authorization': `Nostr ${token}`,
},
});
await handleAPIError(response);
return await response.json();
} catch (err) {
let errorMessage = 'An unexpected error occurred';
if (err instanceof Error) {
errorMessage = err.message;
} else if (typeof err === 'string') {
errorMessage = err;
}
// Add context for common issues
if (errorMessage.includes('Failed to fetch') || errorMessage.includes('Network')) {
errorMessage = 'Network error: Please check your internet connection and try again.';
} else if (errorMessage.includes('signer')) {
errorMessage = 'Authentication error: Please make sure you are logged in with a Nostr account that supports signing.';
}
setError(errorMessage);
throw new Error(errorMessage);
} finally {
setIsLoading(false);
}
await handleAPIError(response);
return await response.json();
}, [user]);
return {
// State
isLoading,
error,
isAuthenticated: !!user,
// Actions
sendChatMessage,
sendStreamingMessage,
getAvailableModels,
getCredits,
clearError,
};
}
+49
View File
@@ -0,0 +1,49 @@
import { useMemo, useCallback } from 'react';
import { MORE_SEPARATOR_ID } from '@/components/SidebarNavItem';
import type { HiddenSidebarItem } from '@/hooks/useFeedSettings';
/**
* Shared sidebar editing logic used by both LeftSidebar and MobileDrawer.
*
* Builds the combined editing list (visible + __more__ separator + hidden)
* and provides handlers for reordering and removing items.
*/
export function useSidebarEditing({
editing,
items,
hiddenItems,
updateSidebarOrder,
removeFromSidebar,
}: {
editing: boolean;
/** Visible sidebar item IDs (may be pre-filtered, e.g. MobileDrawer strips leading dividers). */
items: string[];
hiddenItems: HiddenSidebarItem[];
updateSidebarOrder: (newOrder: string[]) => void;
removeFromSidebar: (id: string, index?: number) => void;
}) {
/** Combined list for the drag-and-drop editor: visible items + separator + hidden items. */
const editingItems = useMemo(() => {
if (!editing) return [];
return [...items, MORE_SEPARATOR_ID, ...hiddenItems.map((h) => h.id)];
}, [editing, items, hiddenItems]);
/** Handle drag-and-drop reorder — extract items above the __more__ separator. */
const handleEditReorder = useCallback((newOrder: string[]) => {
const moreIdx = newOrder.indexOf(MORE_SEPARATOR_ID);
if (moreIdx === -1) return;
const newVisible = newOrder.slice(0, moreIdx);
updateSidebarOrder(newVisible);
}, [updateSidebarOrder]);
/** Remove a sidebar item; dividers require an index to identify which one. */
const handleEditRemove = useCallback((id: string, index?: number) => {
if (id === 'divider' && index !== undefined) {
removeFromSidebar(id, index);
} else {
removeFromSidebar(id);
}
}, [removeFromSidebar]);
return { editingItems, handleEditReorder, handleEditRemove };
}
+233 -68
View File
@@ -1,17 +1,20 @@
import { useNostr } from '@nostrify/react';
import { useState, useEffect, useMemo, useRef, useCallback } from 'react';
import { useFeedSettings } from './useFeedSettings';
import { useCurrentUser } from './useCurrentUser';
import { useFollowList } from './useFollowActions';
import { useMuteList } from './useMuteList';
import { useContentFilters } from './useContentFilters';
import { getEnabledFeedKinds } from '@/lib/extraKinds';
import { isRepostKind } from '@/lib/feedUtils';
import { isReplyEvent } from '@/lib/nostrEvents';
import { isEventMuted } from '@/lib/muteHelpers';
import { resolveSpell } from '@/lib/spellEngine';
import type { NostrEvent, NostrFilter } from '@nostrify/nostrify';
import { DITTO_RELAYS } from '@/lib/appRelays';
import { nip19 } from 'nostr-tools';
interface StreamPostsOptions {
export interface StreamPostsOptions {
includeReplies: boolean;
mediaType: 'all' | 'images' | 'videos' | 'vines' | 'none';
language?: string;
@@ -29,6 +32,12 @@ interface StreamPostsOptions {
authorPubkeys?: string[];
/** NIP-50 sort preference. 'recent' = default (no sort: term). */
sort?: 'recent' | 'hot' | 'trending';
/**
* When set, drives the entire stream from a kind:777 spell event.
* The spell is resolved internally (variables, timestamps, hints).
* All other options on this interface are ignored when spell is set.
*/
spell?: NostrEvent;
}
/** Check if an event has imeta tags with image MIME types. */
@@ -63,21 +72,14 @@ function filterEvent(
const protocols = options.protocols ?? ['nostr'];
if (!protocols.includes('nostr') || protocols.length > 1) {
const proxyTag = event.tags.find(([name]) => name === 'proxy');
if (protocols.includes('nostr') && !protocols.some(p => p !== 'nostr')) {
// nostr only: reject events with a proxy tag
if (proxyTag) return false;
} else {
// bridged protocol selected: only keep events that have a matching proxy tag
// and optionally native nostr events if 'nostr' is also in protocols
const hasProxy = !!proxyTag;
const isNative = !hasProxy;
if (isNative && !protocols.includes('nostr')) return false;
if (hasProxy) {
// proxy tag format: ['proxy', '<uri>', '<protocol>']
const proxyProtocol = proxyTag?.[2]?.toLowerCase();
const wantedBridged = protocols.filter(p => p !== 'nostr');
if (!wantedBridged.some(p => proxyProtocol?.includes(p))) return false;
}
const hasProxy = !!proxyTag;
const isNative = !hasProxy;
if (isNative && !protocols.includes('nostr')) return false;
if (hasProxy) {
// proxy tag format: ['proxy', '<uri>', '<protocol>']
const proxyProtocol = proxyTag?.[2]?.toLowerCase();
const wantedBridged = protocols.filter(p => p !== 'nostr');
if (!wantedBridged.some(p => proxyProtocol?.includes(p))) return false;
}
}
@@ -136,8 +138,67 @@ function filterEvent(
export function useStreamPosts(query: string, options: StreamPostsOptions) {
const { nostr } = useNostr();
const { feedSettings } = useFeedSettings();
const { user } = useCurrentUser();
const { data: followData } = useFollowList();
const { muteItems } = useMuteList();
const { shouldFilterEvent } = useContentFilters();
// ── Spell resolution ────────────────────────────────────────────────
// When a spell is provided, resolve it and derive effective options.
// All other option fields are ignored in spell mode.
const resolved = useMemo(() => {
if (!options.spell) return null;
try {
const contactPubkeys = followData?.pubkeys ?? [];
return resolveSpell(options.spell, user?.pubkey, contactPubkeys);
} catch {
return null;
}
}, [options.spell, user?.pubkey, followData?.pubkeys]);
// Derive effective options: spell-resolved values take priority
const effectiveQuery = resolved ? (resolved.filter.search ?? '') : query;
const effectiveOptions: StreamPostsOptions = useMemo(() => {
if (!resolved) return options;
const h = resolved.hints;
return {
includeReplies: h.includeReplies,
mediaType: h.mediaType,
language: h.language,
protocols: [h.platform],
kindsOverride: resolved.filter.kinds,
authorPubkeys: resolved.filter.authors,
sort: h.sort,
};
}, [resolved, options]);
// Whether the initial query should be routed exclusively to Ditto relays.
// True when NIP-50 extensions are used that only Ditto relays understand
// (sort:hot, language:en, protocol:activitypub, media filters).
// Applies to both spell-driven and direct option-driven queries.
const useDittoOnly = resolved?.needsDittoRelay ?? !!(
(effectiveOptions.sort && effectiveOptions.sort !== 'recent')
|| (effectiveOptions.language && effectiveOptions.language !== 'global')
|| (effectiveOptions.protocols && effectiveOptions.protocols.some(p => p !== 'nostr'))
);
// Extra filter fields from the spell (since, until, limit, tag filters)
const spellExtraFilter: Partial<NostrFilter> | undefined = useMemo(() => {
if (!resolved) return undefined;
const extra: Record<string, unknown> = {};
if (resolved.filter.since !== undefined) extra.since = resolved.filter.since;
if (resolved.filter.until !== undefined) extra.until = resolved.filter.until;
if (resolved.filter.limit !== undefined) extra.limit = resolved.filter.limit;
// Copy tag filters (#t, #e, #p, etc.)
for (const [key, val] of Object.entries(resolved.filter)) {
if (key.startsWith('#')) extra[key] = val;
}
return Object.keys(extra).length > 0 ? extra as Partial<NostrFilter> : undefined;
}, [resolved]);
// Stable key for the spell so the effect restarts when the spell changes
const spellKey = options.spell?.id ?? '';
const [allEvents, setAllEvents] = useState<NostrEvent[]>([]);
const [isLoading, setIsLoading] = useState(true);
// Buffer for streamed events — held separately until user scrolls back up
@@ -151,6 +212,17 @@ export function useStreamPosts(query: string, options: StreamPostsOptions) {
const [flushedIds, setFlushedIds] = useState<Set<string>>(new Set());
const flushedTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
// Pagination state for "load more" (infinite scroll)
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [hasMore, setHasMore] = useState(true);
// Stash the filter + store used by the initial query so loadMore can reuse it
const paginationRef = useRef<{
filter: NostrFilter;
store: { query: (filters: NostrFilter[], opts?: { signal?: AbortSignal }) => Promise<NostrEvent[]> };
knownIds: Set<string>;
eventMap: Map<string, NostrEvent>;
} | null>(null);
/** Merge buffered events into the main list and mark them as flushed. */
const doFlush = useCallback(() => {
if (streamBufferRef.current.length === 0) return;
@@ -171,6 +243,59 @@ export function useStreamPosts(query: string, options: StreamPostsOptions) {
// Clean up timer on unmount
useEffect(() => () => clearTimeout(flushedTimerRef.current), []);
/** Fetch the next page of older events (cursor-based pagination). */
const loadMore = useCallback(async () => {
const ctx = paginationRef.current;
if (!ctx || isLoadingMore || !hasMore) return;
// Find the oldest event timestamp for the cursor
const oldest = allEvents.length > 0
? Math.min(...allEvents.map((e) => e.created_at))
: undefined;
if (oldest === undefined) return;
setIsLoadingMore(true);
try {
const PAGE_SIZE = ctx.filter.limit ?? 40;
const events = await ctx.store.query(
[{ ...ctx.filter, until: oldest - 1, limit: PAGE_SIZE }],
{ signal: AbortSignal.timeout(8000) },
);
if (events.length < PAGE_SIZE) {
setHasMore(false);
}
if (events.length > 0) {
const now = Math.floor(Date.now() / 1000);
setAllEvents((prev) => {
const merged = [...prev];
for (const event of events) {
if (event.created_at > now) continue;
let dedupeKey: string;
if (event.kind >= 30000 && event.kind < 40000) {
const dTag = event.tags.find(([name]) => name === 'd')?.[1] ?? '';
dedupeKey = `${event.pubkey}:${event.kind}:${dTag}`;
} else {
dedupeKey = event.id;
}
if (ctx.knownIds.has(dedupeKey)) continue;
ctx.knownIds.add(dedupeKey);
ctx.eventMap.set(dedupeKey, event);
merged.push(event);
}
return merged.sort((a, b) => b.created_at - a.created_at);
});
}
} catch {
// timeout — don't break the UI
} finally {
setIsLoadingMore(false);
}
}, [allEvents, isLoadingMore, hasMore]);
// Monitor scroll position — only buffer when user is scrolled down
useEffect(() => {
const threshold = 200; // px from top
@@ -187,36 +312,39 @@ export function useStreamPosts(query: string, options: StreamPostsOptions) {
// Resolve authorPubkeys: accept hex or npub-encoded entries
const resolvedAuthorPubkeys = useMemo(() => {
if (!options.authorPubkeys || options.authorPubkeys.length === 0) return undefined;
const resolved: string[] = [];
for (const raw of options.authorPubkeys) {
if (!effectiveOptions.authorPubkeys || effectiveOptions.authorPubkeys.length === 0) return undefined;
const res: string[] = [];
for (const raw of effectiveOptions.authorPubkeys) {
const t = raw.trim();
if (/^[0-9a-f]{64}$/i.test(t)) {
resolved.push(t);
res.push(t);
} else {
try {
const decoded = nip19.decode(t);
if (decoded.type === 'npub') resolved.push(decoded.data);
if (decoded.type === 'npub') res.push(decoded.data);
} catch { /* ignore */ }
}
}
return resolved.length > 0 ? resolved : undefined;
}, [options.authorPubkeys]);
return res.length > 0 ? res : undefined;
}, [effectiveOptions.authorPubkeys]);
// These mediaTypes query dedicated event kinds rather than filtering kind 1
const isDedicatedKindQuery = !options.kindsOverride && (options.mediaType === 'vines' || options.mediaType === 'images' || options.mediaType === 'videos');
const isDedicatedKindQuery = !effectiveOptions.kindsOverride && (effectiveOptions.mediaType === 'vines' || effectiveOptions.mediaType === 'images' || effectiveOptions.mediaType === 'videos');
const enabledKinds = getEnabledFeedKinds(feedSettings);
const kindsKey = [...enabledKinds].sort().join(',');
// Stable key for protocols so it can be a useEffect dependency
const protocolsKey = [...(options.protocols ?? ['nostr'])].sort().join(',');
const protocolsKey = [...(effectiveOptions.protocols ?? ['nostr'])].sort().join(',');
// Stable key for kindsOverride
const kindsOverrideKey = options.kindsOverride ? [...options.kindsOverride].sort().join(',') : '';
const kindsOverrideKey = effectiveOptions.kindsOverride ? [...effectiveOptions.kindsOverride].sort().join(',') : '';
// Stable key for authorPubkeys (follows list)
const authorPubkeysKey = options.authorPubkeys ? [...options.authorPubkeys].sort().join(',') : '';
const authorPubkeysKey = effectiveOptions.authorPubkeys ? [...effectiveOptions.authorPubkeys].sort().join(',') : '';
// Stable key for spell extra filter
const spellExtraFilterKey = spellExtraFilter ? JSON.stringify(spellExtraFilter) : '';
useEffect(() => {
const ac = new AbortController();
@@ -224,7 +352,10 @@ export function useStreamPosts(query: string, options: StreamPostsOptions) {
setAllEvents([]);
setIsLoading(true);
setHasMore(true);
setIsLoadingMore(false);
initialLoadDoneRef.current = false;
paginationRef.current = null;
streamBufferRef.current = [];
setStreamBufferCount(0);
@@ -272,13 +403,13 @@ export function useStreamPosts(query: string, options: StreamPostsOptions) {
// Build the kinds list based on mediaType (or override entirely)
let kinds: number[];
if (options.kindsOverride && options.kindsOverride.length > 0) {
kinds = [...options.kindsOverride];
} else if (options.mediaType === 'vines') {
if (effectiveOptions.kindsOverride && effectiveOptions.kindsOverride.length > 0) {
kinds = [...effectiveOptions.kindsOverride];
} else if (effectiveOptions.mediaType === 'vines') {
kinds = [22, 34236]; // shorts + vines
} else if (options.mediaType === 'videos') {
} else if (effectiveOptions.mediaType === 'videos') {
kinds = [21, 22, ...enabledKinds.filter((k) => !isRepostKind(k))];
} else if (options.mediaType === 'images') {
} else if (effectiveOptions.mediaType === 'images') {
kinds = [20, ...enabledKinds.filter((k) => !isRepostKind(k))];
} else {
kinds = enabledKinds.filter((k) => !isRepostKind(k));
@@ -293,63 +424,94 @@ export function useStreamPosts(query: string, options: StreamPostsOptions) {
// protocol:nostr = native Nostr only (no bridged events).
// When bridged protocols are selected, omit protocol:nostr so the relay
// returns both native and bridged events matching the selected protocols.
const protocols = options.protocols ?? ['nostr'];
// When the caller doesn't explicitly pass protocols (e.g. Feeds/Packs tabs
// that query Nostr-native kinds only), skip the protocol term entirely so
// the relay doesn't filter through NIP-50 search for kinds it may not index.
const protocols = effectiveOptions.protocols ?? ['nostr'];
const bridged = protocols.filter(p => p !== 'nostr');
const searchParts: string[] = bridged.length > 0
? bridged.map(p => `protocol:${p}`)
: ['protocol:nostr'];
: effectiveOptions.protocols
? ['protocol:nostr']
: [];
if (query.trim()) {
searchParts.push(query.trim());
if (effectiveQuery.trim()) {
searchParts.push(effectiveQuery.trim());
}
// Add language filter (NIP-50 extension supported by Ditto)
if (options.language && options.language !== 'global') {
searchParts.push(`language:${options.language}`);
if (effectiveOptions.language && effectiveOptions.language !== 'global') {
searchParts.push(`language:${effectiveOptions.language}`);
}
// Add media filter (NIP-50 extension supported by Ditto)
// Skip for dedicated-kind queries — kind selection already scopes them
if (!isDedicatedKindQuery) {
if (options.mediaType === 'images') {
if (effectiveOptions.mediaType === 'images') {
searchParts.push('media:true');
searchParts.push('video:false');
} else if (options.mediaType === 'videos') {
} else if (effectiveOptions.mediaType === 'videos') {
searchParts.push('video:true');
} else if (options.mediaType === 'none') {
} else if (effectiveOptions.mediaType === 'none') {
searchParts.push('media:false');
}
// 'all' means no media filter
}
// Sort preference (NIP-50 extension)
if (effectiveOptions.sort === 'hot') {
searchParts.push('sort:hot');
} else if (effectiveOptions.sort === 'trending') {
searchParts.push('sort:trending');
}
const initialFilter: NostrFilter = { ...streamFilter };
if (searchParts.length > 0) {
initialFilter.search = searchParts.join(' ');
}
// Merge spell-specific filter fields (since, until, limit, tag filters)
if (spellExtraFilter) {
Object.assign(initialFilter, spellExtraFilter);
// Also apply tag filters and author scope to the stream filter
for (const [key, val] of Object.entries(spellExtraFilter)) {
if (key.startsWith('#')) {
(streamFilter as Record<string, unknown>)[key] = val;
}
}
}
// Author filter — scopes both the initial batch and streaming subscription.
if (resolvedAuthorPubkeys && resolvedAuthorPubkeys.length > 0) {
initialFilter.authors = resolvedAuthorPubkeys;
streamFilter.authors = resolvedAuthorPubkeys;
}
// Sort preference (NIP-50 extension)
if (options.sort === 'hot') {
searchParts.push('sort:hot');
} else if (options.sort === 'trending') {
searchParts.push('sort:trending');
}
// Determine relay routing for the initial query.
// Ditto relays are required when the NIP-50 search string contains
// extensions like `language:`, `protocol:`, `media:`, or `sort:` that
// standard relays don't support. When the query has none of these
// extensions the user's own relays are appropriate.
const initialStore = useDittoOnly ? nostr.group(DITTO_RELAYS) : nostr;
// 1. Fetch initial batch with search filters (uses pool, reuses existing connections)
(async () => {
// Stash for loadMore pagination
paginationRef.current = { filter: initialFilter, store: initialStore, knownIds, eventMap };
const PAGE_SIZE = initialFilter.limit ?? 40;
// 1. Fetch initial batch with search filters
async function fetchInitialBatch() {
try {
const events = await nostr.query(
[{ ...initialFilter, limit: 40 }],
const events = await initialStore.query(
[{ ...initialFilter, limit: PAGE_SIZE }],
{ signal: ac.signal },
);
for (const event of events) {
addEvent(event, false);
}
if (alive && events.length < PAGE_SIZE) {
setHasMore(false);
}
} catch {
// abort expected
}
@@ -357,27 +519,27 @@ export function useStreamPosts(query: string, options: StreamPostsOptions) {
initialLoadDoneRef.current = true;
setIsLoading(false);
}
})();
}
// 2. Stream new events WITHOUT search (relays don't support streaming search)
// Client-side filtering is applied via useMemo at the end
//
//
// CRITICAL: The pool has eoseTimeout: 500 which aborts req() subscriptions 500ms after
// the first EOSE. This kills streaming! Solution: Use relay() directly for one relay
// to avoid the pool's timeout logic.
(async () => {
async function startStreaming() {
try {
const now = Math.floor(Date.now() / 1000);
// Use Ditto relays directly for streaming to avoid pool's eoseTimeout
const dittoRelay = nostr.group(DITTO_RELAYS);
for await (const msg of dittoRelay.req(
[{ ...streamFilter, since: now, limit: 0 }],
{ signal: ac.signal }
)) {
if (!alive) break;
if (msg[0] === 'EVENT') {
addEvent(msg[2], true);
} else if (msg[0] === 'CLOSED') {
@@ -387,29 +549,32 @@ export function useStreamPosts(query: string, options: StreamPostsOptions) {
} catch {
// abort expected
}
})();
}
fetchInitialBatch();
startStreaming();
return () => {
alive = false;
ac.abort();
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- enabledKinds is stabilized via kindsKey; options.protocols is stabilized via protocolsKey; kindsOverride is stabilized via kindsOverrideKey; authorPubkeys is stabilized via authorPubkeysKey
}, [nostr, query, isDedicatedKindQuery, kindsKey, options.language, options.mediaType, protocolsKey, kindsOverrideKey, authorPubkeysKey, options.sort]);
// eslint-disable-next-line react-hooks/exhaustive-deps -- enabledKinds is stabilized via kindsKey; effectiveOptions fields are stabilized via their respective keys; spellExtraFilter is stabilized via spellExtraFilterKey
}, [nostr, effectiveQuery, isDedicatedKindQuery, kindsKey, effectiveOptions.language, effectiveOptions.mediaType, protocolsKey, kindsOverrideKey, authorPubkeysKey, effectiveOptions.sort, useDittoOnly, spellExtraFilterKey, spellKey]);
// Flush buffered streamed events into the main list (called by UI when user wants to see new posts)
const flushStreamBuffer = doFlush;
// Pre-compute author set outside the per-event callback
const authorSet = useMemo(() => resolvedAuthorPubkeys ? new Set(resolvedAuthorPubkeys) : null, [resolvedAuthorPubkeys]);
// Shared predicate for client-side filtering (mute, content, search, media, author, etc.)
const matchesFilters = useCallback((event: NostrEvent) => {
if (muteItems.length > 0 && isEventMuted(event, muteItems)) return false;
if (shouldFilterEvent(event)) return false;
if (resolvedAuthorPubkeys) {
const authorSet = new Set(resolvedAuthorPubkeys);
if (!authorSet.has(event.pubkey)) return false;
}
return filterEvent(event, options, query);
// eslint-disable-next-line react-hooks/exhaustive-deps -- using specific options fields instead of the whole object for granular reactivity
}, [options.includeReplies, options.mediaType, protocolsKey, query, muteItems, resolvedAuthorPubkeys, shouldFilterEvent, authorPubkeysKey]);
if (authorSet && !authorSet.has(event.pubkey)) return false;
return filterEvent(event, effectiveOptions, effectiveQuery);
// eslint-disable-next-line react-hooks/exhaustive-deps -- using specific option fields and stabilized keys for granular reactivity
}, [effectiveOptions.includeReplies, effectiveOptions.mediaType, protocolsKey, effectiveQuery, muteItems, authorSet, shouldFilterEvent, authorPubkeysKey]);
// Apply client-side filters (including mute filtering and content filters) without restarting the stream
const posts = useMemo(() => {
@@ -424,5 +589,5 @@ export function useStreamPosts(query: string, options: StreamPostsOptions) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [streamBufferCount, matchesFilters]);
return { posts, isLoading, newPostCount: filteredNewPostCount, flushStreamBuffer, flushedIds };
return { posts, isLoading, newPostCount: filteredNewPostCount, flushStreamBuffer, flushedIds, loadMore, hasMore, isLoadingMore };
}
+352
View File
@@ -0,0 +1,352 @@
import { bundledFonts } from '@/lib/fonts';
import type { ChatMessage } from '@/hooks/useShakespeare';
/** Comma-separated list of available bundled font names for the system prompt. */
const AVAILABLE_FONTS = bundledFonts.map((f) => f.family).join(', ');
/** Minimal profile fields injected into the system prompt so the AI knows who it's talking to. */
export interface UserIdentity {
/** The user's npub (bech32 public key). */
npub: string;
/** The user's hex public key. */
pubkey: string;
/** Display name from kind 0 metadata. */
displayName?: string;
/** NIP-05 identifier (e.g. "alice@example.com"). */
nip05?: string;
/** Short bio / about text. */
about?: string;
}
/**
* Build the AI chat system prompt.
*
* When a buddy is configured, `name` and `soul` are injected via the
* `{{NAME}}` and `{{SOUL}}` placeholders. Identity and personality are
* entirely determined by those values — the base template is purely
* functional (tool definitions, capabilities, formatting).
*
* `{{SAVED_FEEDS}}` is replaced with a list of the user's saved feed
* labels so the model knows which named feeds are available.
*
* `{{USER_IDENTITY}}` is replaced with a block describing the logged-in
* user so the AI can answer questions like "who am I?" or "show me my
* recent posts" without extra round-trips.
*
* If `customPrompt` is provided (from Advanced Settings), it replaces
* the entire base template. Placeholders are substituted in both cases.
*/
export function buildSystemPrompt(
name?: string,
soul?: string,
customPrompt?: string,
savedFeedLabels?: string[],
userIdentity?: UserIdentity,
): ChatMessage {
const agentName = name ?? 'Dork';
const soulText = soul ?? '';
const savedFeedsText = savedFeedLabels && savedFeedLabels.length > 0
? `**Saved feeds the user has created:** ${savedFeedLabels.map((l) => `"${l}"`).join(', ')}`
: '';
const userIdentityText = userIdentity ? buildUserIdentityBlock(userIdentity) : '';
const template = customPrompt || DEFAULT_TEMPLATE;
const resolved = template
.replace(/\{\{NAME\}\}/g, agentName)
.replace(/\{\{SOUL\}\}/g, soulText)
.replace(/\{\{SAVED_FEEDS\}\}/g, savedFeedsText)
.replace(/\{\{USER_IDENTITY\}\}/g, userIdentityText);
return { role: 'system', content: resolved };
}
/** Build a markdown block describing the current user. */
function buildUserIdentityBlock(identity: UserIdentity): string {
const lines: string[] = [
'# Current User',
`- **npub:** ${identity.npub}`,
`- **hex pubkey:** ${identity.pubkey}`,
];
if (identity.displayName) {
lines.push(`- **name:** ${identity.displayName}`);
}
if (identity.nip05) {
lines.push(`- **NIP-05:** ${identity.nip05}`);
}
if (identity.about) {
lines.push(`- **about:** ${identity.about}`);
}
lines.push('');
lines.push('Use this identity when the user asks "who am I?", "what\'s my npub?", or similar. To fetch their full profile, use `fetch_event` with their npub. To see their recent posts, use `get_feed` with `authors: ["$me"]`.');
return lines.join('\n');
}
// ─── Default template ─────────────────────────────────────────────────────────
const DEFAULT_TEMPLATE = `You are {{NAME}}, an AI assistant in Ditto, a Nostr social client.
{{SOUL}}
{{USER_IDENTITY}}
# Tools
## set_theme
Applies a full custom theme. Supports:
**Colors** (required): Three HSL values without the "hsl()" wrapper (e.g. "228 20% 10%"):
- background: page background color
- text: main text/foreground color (must contrast well with background)
- primary: accent color for buttons, links, and highlights
**Font** (optional): Choose from bundled fonts to match the theme's mood. Available: ${AVAILABLE_FONTS}
**Background image** (optional): A URL to a publicly accessible image. Set mode to "cover" for full-bleed or "tile" for repeating patterns.
When the user asks to change the theme, be creative — combine colors, fonts, and backgrounds to create a cohesive aesthetic. Always set colors. Add a font when it enhances the mood. Add a background image only when you have a suitable URL or the user requests one.
## create_spell vs get_feed — choosing the right tool
These two tools both deal with Nostr feeds but serve fundamentally different purposes:
- **create_spell** is a **write** operation. It creates a persistent feed (a kind:777 event) that appears in the user's UI. The user can view it, run it themselves, and save it to their sidebar. Use this when the user wants to **build**, **save**, or **set up** a feed for ongoing use. The user will browse the results in the app's feed viewer — you do NOT see the results.
- **get_feed** is a **read** operation. It fetches posts from Nostr and returns their content to YOU so you can summarize, analyze, or answer questions about what's happening. Use this when the user wants **information**, a **summary**, or is asking a question about recent activity. The user does NOT see the raw posts — they see your conversational summary.
**Decision guide:**
| User intent | Tool | Why |
|---|---|---|
| "make me a feed of bitcoin posts" | create_spell | They want a persistent feed to browse |
| "set up a feed for photos from my friends" | create_spell | They want to save it and view it in the UI |
| "what are my friends talking about?" | get_feed | They want you to summarize activity |
| "what's trending on nostr?" | get_feed | They want information, not a saved feed |
| "anything about bitcoin today?" | get_feed | They're asking a question about recent content |
| "show me what's happening in Japan" | get_feed | They want a summary of activity |
| "create a feed for Japanese posts" | create_spell | They want a persistent feed to browse later |
**Key signals:**
- Words like "make", "create", "set up", "build", "save" → create_spell
- Words like "what's going on", "tell me about", "summarize", "anything about", "what are people saying" → get_feed
- If ambiguous, prefer get_feed — it's less intrusive (read vs write) and you can always offer to create a spell afterward if the user wants to save the query
## create_spell
Creates Nostr spells (NIP-A7) — saved queries that act as custom feeds. When a user describes what they want to see, translate it into spell parameters.
**How spells work:**
- A spell is a kind:777 event encoding a Nostr relay filter
- The user can click the spell to run it and see results, then add it to their sidebar for quick access
- Spells are published with an ephemeral key, not the user's identity
**Runtime variables** (resolved when the spell runs, not when created):
- "$me" — the logged-in user's pubkey
- "$contacts" — all pubkeys from the user's kind:3 follow list
**Relative timestamps** (subtracted from now at execution time):
- "24h", "7d", "2w", "1mo", "3mo", "1y", etc.
**Common kinds:**
- 1 = text notes, 6 = reposts, 7 = reactions, 20 = photos
- 30023 = articles, 9735 = zap receipts, 5 = deletions, 30402 = classifieds
**Tag filters vs search:**
- Use search for broad topic matching in content text (e.g. search: "bitcoin")
- Use tag_filters with letter "t" for hashtag filtering (e.g. #bitcoin, #nostr)
- Note: search relies on NIP-50 relay support; hashtags are more universally supported
**Client hints** (NIP-50 extensions — routed to Ditto relay automatically):
- media: "images", "videos", "vines", "none" — filter by media type
- language: ISO 639-1 code (e.g. "en", "ja") — filter by language
- platform: "nostr" (default), "activitypub", "atproto" — filter by protocol
- sort: "recent" (default), "hot", "trending" — sort order
- include_replies: false — exclude reply posts (default: true, include everything)
**Spell examples:**
- "feed of my friends talking about bitcoin" → authors: ["$contacts"], kinds: [1], search: "bitcoin"
- "posts tagged nostr and dev" → kinds: [1], tag_filters: [{letter: "t", values: ["nostr", "dev"]}]
- "my mass deletions" → authors: ["$me"], kinds: [5]
- "photos from people I follow" → authors: ["$contacts"], kinds: [20], media: "images"
- "articles about nostr from the past month" → kinds: [30023], search: "nostr", since: "1mo"
- "trending posts this week" → since: "7d", sort: "trending"
- "zaps this week" → kinds: [9735], since: "7d"
- "what I've been posting lately" → authors: ["$me"], kinds: [1], since: "30d"
- "english posts from follows, no replies" → authors: ["$contacts"], language: "en", include_replies: false
Keep spell names short and descriptive (2-4 words). When you create a spell, briefly explain what it will show.
## search_users
Resolves names to Nostr pubkeys. When a user mentions a specific person by name (e.g. "Derek Ross", "fiatjaf"), use search_users to find their pubkey before creating a spell that references them. The search checks the user's contacts first, then does a broader relay search. If multiple matches are found, ask the user to confirm which one they meant. Use the hex pubkey from the results directly in the spell's authors array.
## search_follow_packs
Finds curated follow packs (starter packs). Follow packs are lists of people grouped by theme or community (e.g. "Team Soapbox", "Bitcoin Developers"). When a user mentions a follow pack or starter pack by name, use search_follow_packs to look it up. The tool returns the pack's title, description, and all member pubkeys. Use those pubkeys directly in the spell's authors array to create a feed based on the pack's members.
**Follow pack examples:**
- "feed from the team soapbox pack" → search_follow_packs("team soapbox") → use returned pubkeys as authors
- "photos from the bitcoin developers pack" → search_follow_packs("bitcoin developers") → use pubkeys as authors, kinds: [20]
## fetch_page
Fetches a URL and extracts text content and image URLs from the HTML. Use when a user provides a link and you need to discover what's on the page (images, content, file listings).
## upload_from_url
Downloads files from URLs and uploads them to Blossom file servers. Supports any file type — images, .xdc (WebXDC apps), .zip archives, video, audio, documents, etc. MIME types are detected automatically from file extensions. Returns Blossom URLs, detected MIME types, and auto-generated shortcodes. Max 50 files per call.
## create_emoji_pack
Publishes a NIP-30 custom emoji pack (kind 30030) as the logged-in user. Takes a pack name and array of {shortcode, url} pairs. The shortcodes must be alphanumeric (hyphens and underscores allowed). Use Blossom URLs from upload_from_url.
**Workflow for creating emoji packs from a webpage:**
1. fetch_page(url) → get image URLs from the page
2. upload_from_url(image_urls) → upload to Blossom, get URLs + shortcodes
3. create_emoji_pack(name, emojis) → publish the pack
When uploading emojis, use clean shortcodes. Strip file extensions, replace special characters with hyphens. If the user doesn't specify a pack name, derive one from the page title or context.
## publish_events
Publishes one or more Nostr events signed by your identity. Each event can specify a kind, content, and tags. Use this when the user asks you to post, publish, or broadcast something to Nostr.
**Common kinds:**
- 1 = text note (put post text in content)
- 7 = reaction (content is "+" or an emoji, add an "e" tag referencing the target event)
- 6 = repost (content is the JSON of the reposted event, add an "e" tag)
**Tag format:** Arrays of strings, e.g. \`[["t", "nostr"], ["p", "<hex-pubkey>"]]\`
**Examples:**
- Post a note: \`{ events: [{ content: "Hello Nostr!" }] }\`
- Post with hashtags: \`{ events: [{ content: "Building on Nostr", tags: [["t", "nostr"], ["t", "dev"]] }] }\`
Only publish events when the user explicitly asks you to. Never publish autonomously.
## create_webxdc
Creates and publishes a WebXDC mini-app from scratch. WebXDC apps are self-contained HTML5 apps (games, tools, widgets) that run in a sandboxed iframe with no internet access. Users can launch them directly from the feed.
You write the code, the tool handles the rest: packaging into a .xdc archive, uploading to Blossom, and publishing as a kind 1063 event.
**Two modes for source code:**
- **Simple (\`html\` param):** A single self-contained HTML string. Best for small apps.
- **Multi-file (\`files\` param):** A JSON object mapping filenames to content strings, e.g. \`{"index.html": "<!DOCTYPE html>...", "engine.js": "...", "levels.json": "..."}\`. Must include \`index.html\`. Other files are loaded via relative paths (\`<script src="engine.js">\` or \`fetch('levels.json')\`). Use this when the code is large enough that splitting into separate files improves clarity.
Only one of \`html\` or \`files\` is needed. If both are provided, \`files\` takes priority.
**Bundling binary assets (\`asset_urls\` param, optional):**
Include remote files (images, audio, ROMs, WASM, fonts, etc.) as binary assets in the archive. Provide a JSON object mapping filenames to URLs: \`{"game.gb": "https://blossom.example.com/abc123.bin"}\`. Each URL is fetched and bundled into the .xdc archive. The app loads them via relative paths at runtime (e.g. \`fetch('game.gb')\`, \`new Audio('sfx.wav')\`, \`<img src="cover.png">\`).
Use \`upload_from_url\` first to upload the asset to Blossom, then pass the Blossom URL here. This is useful for bundling emulator ROMs, sprite sheets, audio samples, or any binary content the app needs.
**Example workflow for a retro game:**
1. \`upload_from_url\` the ROM file → get Blossom URL
2. \`upload_from_url\` cover art → get Blossom URL
3. \`create_webxdc\` with \`files\` containing the emulator HTML/JS and \`asset_urls\` containing the ROM and art
**Critical constraints for the code you generate:**
- Must include a complete HTML document with \`<!DOCTYPE html>\`
- NO external resources of any kind: no CDN links, no external CSS/JS/fonts
- NO ES module imports — use plain \`<script>\` tags
- All graphics must be procedural (canvas, CSS shapes, SVG inline) or data: URIs
- Use system fonts only (e.g. \`system-ui, sans-serif\`)
- The sandbox blocks ALL network access — external requests to remote servers silently fail
- \`fetch()\` to relative paths within the .xdc archive DOES work (files are served from the unzipped archive)
- \`localStorage\` is available and scoped to the app — use it for save states, high scores, and user preferences
**What works well:**
- Canvas games: pong, snake, tetris, breakout, flappy bird, space invaders
- CSS/JS tools: calculators, timers, stopwatches, drawing apps, to-do lists
- Procedural art and generative visuals
- Web Audio API for sound effects
**Input handling — IMPORTANT:**
- The host app provides a built-in virtual gamepad (D-pad, A/B, Start/Select) that injects synthetic KeyboardEvents into the iframe
- **Do NOT build touch controls or on-screen gamepads into your HTML** — the host handles that
- Only add \`keydown\`/\`keyup\` event listeners for keyboard input
- The app canvas/UI should fill the entire viewport (no space reserved for controls)
- For games, use these exact key bindings to match the host gamepad: ArrowUp (38), ArrowDown (40), ArrowLeft (37), ArrowRight (39), \`x\` (88) = A button, \`z\` (90) = B button, Enter (13) = Start, Shift (16) = Select
**App icon (optional but recommended):** The \`image_url\` parameter sets a thumbnail shown on the app's launch card in the feed. Without it, a generic icon is displayed. To add one, use upload_from_url first to upload an image to Blossom, then pass the URL.
**Example use:** "Build me a pong game" → generate complete pong HTML → create_webxdc(name: "Pong", html: "<!DOCTYPE html>...")
## Publishing existing WebXDC apps from URLs
When a user shares a link to an existing .xdc file (from a Git repo or elsewhere), use upload_from_url + publish_events:
1. **Upload the .xdc file** using upload_from_url with the direct download URL
2. **Publish a kind 1063 event** using publish_events with these tags:
- \`["url", "<blossom-url>"]\` — must end with .xdc
- \`["m", "application/x-webxdc"]\` — MIME type
- \`["alt", "Webxdc app: <App Name>"]\` — human-readable description
- \`["webxdc", "<random-uuid>"]\` — unique session UUID (use UUID format like "a1b2c3d4-e5f6-7890-abcd-ef1234567890")
**Finding .xdc files from Git repositories:**
When a user shares a GitLab or GitHub repo URL, construct the raw download URL:
- **GitLab:** \`https://gitlab.com/<user>/<repo>/-/raw/main/<filename>.xdc\`
- **GitHub:** \`https://raw.githubusercontent.com/<user>/<repo>/main/<filename>.xdc\`
If the branch is \`master\` instead of \`main\`, adjust accordingly. If you don't know the exact filename, use fetch_page on the repo URL to discover it.
## make_it_rain
A fun easter egg! Triggers a visual rain or snow effect across the entire app. The effect persists across all pages until stopped.
Use this playfully and creatively:
- When the user says "make it rain" or asks for weather effects
- To celebrate achievements or exciting moments (heavy rain = hype)
- For cozy or moody vibes (light rain = ambiance)
- When discussing weather, seasons, or winter (snow is great here)
- Any moment where a visual surprise would delight the user
Stop the effect when the user asks. Be responsive — if they say "enough", "stop the rain", or seem annoyed, stop it immediately.
Pair it with set_theme for maximum atmosphere — dark theme + rain = moody, winter theme + snow = cozy.
## fetch_event
Fetches a Nostr event by its NIP-19 identifier. Use this when the user shares a Nostr link or identifier and you need to read its content.
**Supported identifiers:**
- npub1... → fetches the user's kind 0 profile
- note1... → fetches a specific event by ID
- nevent1... → fetches an event (may include relay hints)
- naddr1... → fetches an addressable event by kind+author+d-tag
- nprofile1... → fetches a user profile with relay hints
Returns the full event JSON. For profiles (kind 0), the content field contains JSON metadata (name, about, picture, etc.).
## get_feed
Reads posts from a feed and returns their content. Use this when the user asks what's going on, wants a summary of recent activity, or asks about a specific topic, person, or country.
**Built-in feeds:**
- "follows" — posts from people the user follows (requires login)
- "global" — recent posts from everyone
- "ditto" — curated trending posts
{{SAVED_FEEDS}}
**Country feeds:**
When the user asks about a country (e.g. "what's going on in Venezuela?", "anything happening in Japan?"), use the \`country\` parameter with the ISO 3166-1 alpha-2 code (e.g. "VE", "JP"). This queries NIP-73 geographic comments (kind 1111) for that country. You do NOT need to know the country code in advance — map the country name to its 2-letter code (e.g. Venezuela = VE, Brazil = BR, United States = US, Japan = JP, Germany = DE).
**Ad-hoc queries:**
When no existing feed matches, build a query using:
- kinds: event kinds (default [1] for text notes; use [20] for photos, [30023] for articles, etc.)
- authors: "$me", "$contacts", or hex pubkeys from search_users
- search: NIP-50 full-text search
- hashtag: filter by hashtag
**Time window:**
- hours: how far back to look (default 12). Use 1-6 for "what's happening right now", 12-24 for "today", 168 for "this week"
**Workflow:**
1. Determine the best feed source: named feed, country code, or ad-hoc query
2. Call get_feed with appropriate parameters
3. Summarize the results — highlight key topics, interesting conversations, and notable posts
4. Be conversational; don't just list posts, synthesize what's going on
**Examples:**
- "what are my friends talking about?" → get_feed(feed_name: "follows")
- "what's trending?" → get_feed(feed_name: "ditto")
- "what's going on in Venezuela?" → get_feed(country: "VE")
- "anything about bitcoin today?" → get_feed(search: "bitcoin", hours: 24)
- "what's #nostr been like this week?" → get_feed(hashtag: "nostr", hours: 168)`;
/** The raw default template with {{NAME}} and {{SOUL}} placeholders (for display in settings). */
export const DEFAULT_SYSTEM_PROMPT_TEMPLATE = DEFAULT_TEMPLATE;
+30
View File
@@ -0,0 +1,30 @@
import type { NostrEvent } from '@nostrify/nostrify';
// ─── Message Types ───
export interface DisplayMessage {
id: string;
role: 'user' | 'assistant' | 'system' | 'tool_result';
content: string;
timestamp: Date;
toolCalls?: ToolCall[];
/** For tool_result messages: the tool_call_id this result corresponds to. */
toolCallId?: string;
/** A Nostr event published by a tool, rendered inline in the chat. */
nostrEvent?: NostrEvent;
}
export interface ToolCall {
id: string;
name: string;
arguments: Record<string, unknown>;
result?: string;
}
/** Result returned by a tool executor. */
export interface ToolExecutorResult {
/** JSON string returned to the AI as the tool result. */
result: string;
/** A Nostr event published by the tool, to be rendered inline in the chat. */
nostrEvent?: NostrEvent;
}
+3 -4
View File
@@ -1,8 +1,7 @@
import { EXTRA_KINDS } from '@/lib/extraKinds';
import { CONTENT_KIND_ICONS } from '@/lib/sidebarItems';
import type { TabFilter } from '@/contexts/AppContext';
type KindOption = {
export type KindOption = {
value: string;
label: string;
description: string;
@@ -42,8 +41,8 @@ export function buildKindOptions(): KindOption[] {
});
}
/** Parse a TabFilter's kinds array into an array of string kind values. */
export function parseSelectedKinds(filter: TabFilter): string[] {
/** Extract selected kind strings from a TabFilter (for populating multi-select). */
export function parseSelectedKinds(filter: Record<string, unknown>): string[] {
const kinds = filter.kinds;
if (!Array.isArray(kinds) || kinds.length === 0) return [];
return kinds.map(String);
+74
View File
@@ -114,3 +114,77 @@ export function parseRepostContent(repost: NostrEvent): NostrEvent | undefined {
}
return undefined;
}
/**
* Unwraps kind 6/16 reposts from a list of events into FeedItems.
*
* For each repost event, attempts to extract the original from the JSON
* content. If the content is missing or unparseable, the original event ID
* is collected for a batch fetch via `fetchMissing`.
*
* Non-repost events are passed through as direct FeedItems.
*
* @param events - Pre-validated events (future timestamps already stripped).
* @param fetchMissing - Callback to fetch originals by ID (e.g. `nostr.query`).
* @param now - Current unix timestamp for future-event rejection.
*/
export async function unwrapReposts(
events: NostrEvent[],
fetchMissing: (ids: string[]) => Promise<NostrEvent[]>,
now: number,
): Promise<FeedItem[]> {
const items: FeedItem[] = [];
const repostMissingIds: string[] = [];
const repostMap = new Map<string, NostrEvent>();
for (const ev of events) {
if (isRepostKind(ev.kind)) {
const embedded = parseRepostContent(ev);
if (embedded && embedded.created_at <= now) {
items.push({ event: embedded, repostedBy: ev.pubkey, sortTimestamp: ev.created_at });
} else {
const repostedId = ev.tags.find(([name]) => name === 'e')?.[1];
if (repostedId) {
repostMissingIds.push(repostedId);
repostMap.set(repostedId, ev);
}
}
} else {
items.push({ event: ev, sortTimestamp: ev.created_at });
}
}
if (repostMissingIds.length > 0) {
try {
const originals = await fetchMissing(repostMissingIds);
for (const original of originals) {
const repost = repostMap.get(original.id);
if (repost && original.created_at <= now) {
items.push({ event: original, repostedBy: repost.pubkey, sortTimestamp: repost.created_at });
}
}
} catch {
// timeout or abort — just skip the missing reposts
}
}
return items;
}
/**
* Deduplicates FeedItems by event ID, preferring the original post over a
* repost when both are present. Returns items sorted newest-first by
* `sortTimestamp`.
*/
export function deduplicateFeedItems(items: FeedItem[]): FeedItem[] {
const seen = new Map<string, FeedItem>();
for (const item of items) {
const existing = seen.get(item.event.id);
if (!existing) {
seen.set(item.event.id, item);
} else if (!item.repostedBy && existing.repostedBy) {
seen.set(item.event.id, item);
}
}
return Array.from(seen.values()).sort((a, b) => b.sortTimestamp - a.sortTimestamp);
}
+15 -16
View File
@@ -196,8 +196,19 @@ export const SavedFeedSchema = z.object({
filter: TabFilterSchema,
vars: z.array(TabVarDefSchema).default([]),
createdAt: z.number(),
/** Hex event ID of a kind:777 spell event that drives this feed. */
spellId: z.string().optional(),
});
/** Shared transform for savedFeeds arrays: drops legacy destination items and validates each entry. */
const savedFeedsTransform = (arr: unknown[]) =>
arr.flatMap((item) => {
if (typeof item !== 'object' || item === null) return [];
if ((item as Record<string, unknown>).destination !== undefined) return [];
const result = SavedFeedSchema.safeParse(item);
return result.success ? [result.data] : [];
});
// ─── AppConfigSchema ─────────────────────────────────────────────────
/**
@@ -236,14 +247,7 @@ export const AppConfigSchema = z.object({
sentryEnabled: z.boolean(),
plausibleDomain: z.string(),
plausibleEndpoint: z.string(),
savedFeeds: z.array(z.unknown()).transform((arr) =>
arr.flatMap((item) => {
if (typeof item !== 'object' || item === null) return [];
if ((item as Record<string, unknown>).destination !== undefined) return [];
const result = SavedFeedSchema.safeParse(item);
return result.success ? [result.data] : [];
})
).optional().default([]),
savedFeeds: z.array(z.unknown()).transform(savedFeedsTransform).optional().default([]),
imageQuality: z.enum(['compressed', 'original']),
curatorPubkey: z.string().regex(/^[0-9a-f]{64}$/i).optional(),
sandboxDomain: z.string().optional(),
@@ -251,6 +255,8 @@ export const AppConfigSchema = z.object({
id: z.string(),
height: z.number().optional(),
})).optional(),
aiModel: z.string().optional().default(''),
aiSystemPrompt: z.string().optional().default(''),
});
// ─── DittoConfigSchema (build-time ditto.json) ───────────────────────
@@ -337,12 +343,5 @@ export const EncryptedSettingsSchema = z.looseObject({
faviconUrl: z.string().optional(),
linkPreviewUrl: z.string().optional(),
sentryDsn: z.string().optional(),
savedFeeds: z.array(z.unknown()).transform((arr) =>
arr.flatMap((item) => {
if (typeof item !== 'object' || item === null) return [];
if ((item as Record<string, unknown>).destination !== undefined) return [];
const result = SavedFeedSchema.safeParse(item);
return result.success ? [result.data] : [];
})
).optional(),
savedFeeds: z.array(z.unknown()).transform(savedFeedsTransform).optional(),
});
+38 -2
View File
@@ -11,6 +11,7 @@ import {
CalendarDays,
Camera,
Clapperboard,
Compass,
Code,
Earth,
Film,
@@ -35,6 +36,7 @@ import {
TrendingUp,
User,
} from "lucide-react";
import { nip19 } from 'nostr-tools';
import { CardsIcon } from "@/components/icons/CardsIcon";
import { ChestIcon } from "@/components/icons/ChestIcon";
import { PlanetIcon } from "@/components/icons/PlanetIcon";
@@ -54,6 +56,14 @@ export function isSidebarDivider(id: string): boolean {
return id === SIDEBAR_DIVIDER_ID;
}
/** The sidebar item ID for the inline search input. */
export const SIDEBAR_SEARCH_ID = 'search';
/** Returns true if the given sidebar order ID is the search input item. */
export function isSidebarSearch(id: string): boolean {
return id === SIDEBAR_SEARCH_ID;
}
/** Returns true if the given sidebar order ID is a `nostr:` URI. */
export function isNostrUri(id: string): boolean {
return id.startsWith("nostr:");
@@ -111,6 +121,7 @@ export const SIDEBAR_ITEMS: SidebarItemDef[] = [
requiresAuth: true,
},
{ id: "search", label: "Search", path: "/search", icon: Search },
{ id: "discover", label: "Discover", path: "/discover", icon: Compass },
{ id: "trends", label: "Trends", path: "/trends", icon: TrendingUp },
{
id: "bookmarks",
@@ -144,7 +155,7 @@ export const SIDEBAR_ITEMS: SidebarItemDef[] = [
},
{
id: "ai-chat",
label: "AI Chat",
label: "Buddy",
path: "/ai-chat",
icon: Bot,
requiresAuth: true,
@@ -275,7 +286,18 @@ export function isItemActive(
// Nostr URI items: active when pathname matches /<nip19>
if (isNostrUri(id)) {
const nip19Id = nostrUriToNip19(id);
return pathname === `/${nip19Id}`;
if (pathname === `/${nip19Id}`) return true;
// Different nevent encodings of the same event may produce different
// bech32 strings (different relay hints). Compare by decoded event ID.
const sidebarEventId = safeDecodeEventId(nip19Id);
if (sidebarEventId && pathname.startsWith('/')) {
const pathSegment = pathname.slice(1);
const pathEventId = safeDecodeEventId(pathSegment);
if (pathEventId && sidebarEventId === pathEventId) return true;
}
return false;
}
// External content items: active when pathname matches /i/<encoded-value>
@@ -295,3 +317,17 @@ export function isItemActive(
return pathname === itemPathname;
}
/** Safely decode a NIP-19 identifier and extract its event ID, or null on failure. */
export function safeDecodeEventId(bech32: string): string | null {
try {
const decoded = nip19.decode(bech32);
switch (decoded.type) {
case 'note': return decoded.data as string;
case 'nevent': return (decoded.data as { id: string }).id;
default: return null;
}
} catch {
return null;
}
}
+335
View File
@@ -0,0 +1,335 @@
import type { NostrEvent, NostrFilter } from '@nostrify/nostrify';
// ─── Constants ───────────────────────────────────────────────────────────────
/** Unit multipliers for relative timestamps (in seconds). */
const UNIT_SECONDS: Record<string, number> = {
s: 1,
m: 60,
h: 3600,
d: 86400,
w: 604800,
mo: 2592000,
y: 31536000,
};
/** Valid media type values for the `media` tag. */
export const SPELL_MEDIA_TYPES = ['all', 'images', 'videos', 'vines', 'none'] as const;
export type SpellMediaType = typeof SPELL_MEDIA_TYPES[number];
/** Valid sort preference values for the `sort` tag. */
export const SPELL_SORT_VALUES = ['recent', 'hot', 'trending'] as const;
export type SpellSort = typeof SPELL_SORT_VALUES[number];
// ─── Helpers ─────────────────────────────────────────────────────────────────
/** Resolve a spell timestamp value to an absolute Unix timestamp. */
function resolveTimestamp(value: string): number {
if (value === 'now') return Math.floor(Date.now() / 1000);
const match = value.match(/^(\d+)(s|m|h|d|w|mo|y)$/);
if (match) {
const amount = parseInt(match[1]);
const unit = match[2];
const seconds = UNIT_SECONDS[unit];
if (seconds !== undefined) {
return Math.floor(Date.now() / 1000) - amount * seconds;
}
}
// Absolute timestamp
const ts = parseInt(value);
if (!isNaN(ts)) return ts;
throw new Error(`Invalid timestamp value: ${value}`);
}
/** Resolve runtime variables in an array of values. */
function resolveValues(
values: string[],
userPubkey: string | undefined,
contactPubkeys: string[],
): string[] {
return values.flatMap((v) => {
if (v === '$me') {
if (!userPubkey) throw new Error('Cannot resolve $me: no logged-in user');
return [userPubkey];
}
if (v === '$contacts') {
return contactPubkeys;
}
return [v];
});
}
// ─── Types ───────────────────────────────────────────────────────────────────
/** Client-hint fields parsed from spell metadata tags. These instruct the
* client how to build NIP-50 search extensions and apply client-side filters. */
export interface SpellClientHints {
/** Media filter: 'all' (default), 'images', 'videos', 'vines', 'none'. */
mediaType: SpellMediaType;
/** Whether to include reply events. Default true. */
includeReplies: boolean;
/** Language code for NIP-50 language: extension, e.g. 'en'. Undefined = no filter. */
language?: string;
/** Protocol filter, e.g. 'nostr', 'activitypub', 'atproto'. Default 'nostr'. */
platform: string;
/** Sort preference for NIP-50 sort: extension. Default 'recent' (no sort: term). */
sort: SpellSort;
}
export interface ResolvedSpell {
/** The command type: REQ or COUNT. */
cmd: 'REQ' | 'COUNT';
/** The resolved Nostr filter (kinds, authors, search text, since/until, etc.). */
filter: NostrFilter;
/** Client-hint fields for NIP-50 extensions and client-side filtering. */
hints: SpellClientHints;
/** Target relay URLs (if specified by the spell). */
relays: string[];
/** Whether the subscription should close after EOSE. */
closeOnEose: boolean;
/** Whether the spell uses NIP-50 extensions that require Ditto relay routing
* (media, language, platform, sort). Plain keyword search does not set this. */
needsDittoRelay: boolean;
}
// ─── Resolver ────────────────────────────────────────────────────────────────
/**
* Parse a kind:777 spell event into a resolved Nostr filter.
*
* Resolves runtime variables ($me, $contacts) and relative timestamps
* into concrete values ready to send as a REQ.
*/
export function resolveSpell(
event: NostrEvent,
userPubkey: string | undefined,
contactPubkeys: string[],
): ResolvedSpell {
const { tags } = event;
const cmd = (tags.find(([t]) => t === 'cmd')?.[1] ?? 'REQ') as 'REQ' | 'COUNT';
const filter: NostrFilter = {};
// Kinds
const kinds = tags.filter(([t]) => t === 'k').map(([, v]) => parseInt(v)).filter((n) => !isNaN(n));
if (kinds.length > 0) filter.kinds = kinds;
// Authors
const authorsTag = tags.find(([t]) => t === 'authors');
if (authorsTag) {
const resolved = resolveValues(authorsTag.slice(1), userPubkey, contactPubkeys);
if (resolved.length > 0) filter.authors = resolved;
}
// IDs
const idsTag = tags.find(([t]) => t === 'ids');
if (idsTag) {
filter.ids = idsTag.slice(1);
}
// Tag filters
const tagFilters = tags.filter(([t]) => t === 'tag');
for (const [, letter, ...values] of tagFilters) {
if (letter) {
const resolved = resolveValues(values, userPubkey, contactPubkeys);
if (resolved.length > 0) {
(filter as Record<string, unknown>)[`#${letter}`] = resolved;
}
}
}
// Limit
const limitTag = tags.find(([t]) => t === 'limit');
if (limitTag) {
const n = parseInt(limitTag[1]);
if (!isNaN(n)) filter.limit = n;
}
// Since
const sinceTag = tags.find(([t]) => t === 'since');
if (sinceTag) {
filter.since = resolveTimestamp(sinceTag[1]);
}
// Until
const untilTag = tags.find(([t]) => t === 'until');
if (untilTag) {
filter.until = resolveTimestamp(untilTag[1]);
}
// Search (NIP-50)
const searchTag = tags.find(([t]) => t === 'search');
if (searchTag) {
filter.search = searchTag[1];
}
// Relays
const relaysTag = tags.find(([t]) => t === 'relays');
const relays = relaysTag ? relaysTag.slice(1) : [];
// Close on EOSE
const closeOnEose = tags.some(([t]) => t === 'close-on-eose');
// ── Client hints (NIP-50 extensions) ──────────────────────────────────
const rawMedia = tags.find(([t]) => t === 'media')?.[1];
const mediaType: SpellMediaType = rawMedia && (SPELL_MEDIA_TYPES as readonly string[]).includes(rawMedia)
? rawMedia as SpellMediaType
: 'all';
const includeReplies = tags.find(([t]) => t === 'include-replies')?.[1] !== 'false';
const language = tags.find(([t]) => t === 'language')?.[1] || undefined;
const rawPlatform = tags.find(([t]) => t === 'platform')?.[1];
const platform = rawPlatform || 'nostr';
const rawSort = tags.find(([t]) => t === 'sort')?.[1];
const sort: SpellSort = rawSort && (SPELL_SORT_VALUES as readonly string[]).includes(rawSort)
? rawSort as SpellSort
: 'recent';
const hints: SpellClientHints = { mediaType, includeReplies, language, platform, sort };
// Determine if this spell needs Ditto relay routing.
// Plain keyword search works on any relay; NIP-50 extensions do not.
const needsDittoRelay = mediaType !== 'all'
|| (language !== undefined && language !== 'global')
|| platform !== 'nostr'
|| sort !== 'recent';
return { cmd, filter, hints, relays, closeOnEose, needsDittoRelay };
}
// ─── Builder ─────────────────────────────────────────────────────────────────
/** Build the kind:777 tags array from spell parameters.
* Used by both the AI tool handler and the manual spell builders. */
export function buildSpellTags(args: {
name?: string;
cmd?: string;
kinds?: number[];
authors?: string[];
tag_filters?: Array<{ letter: string; values: string[] }>;
since?: string;
until?: string;
limit?: number;
search?: string;
relays?: string[];
media?: string;
language?: string;
platform?: string;
sort?: string;
includeReplies?: boolean;
}): string[][] {
const tags: string[][] = [];
if (args.name) tags.push(['name', args.name]);
const cmd = args.cmd ?? 'REQ';
tags.push(['cmd', cmd]);
if (args.kinds) {
for (const k of args.kinds) {
tags.push(['k', String(k)]);
}
}
if (args.authors && args.authors.length > 0) {
tags.push(['authors', ...args.authors]);
}
if (args.tag_filters) {
for (const tf of args.tag_filters) {
if (tf.letter && Array.isArray(tf.values)) {
tags.push(['tag', tf.letter, ...tf.values]);
}
}
}
if (args.since) tags.push(['since', args.since]);
if (args.until) tags.push(['until', args.until]);
if (typeof args.limit === 'number') tags.push(['limit', String(args.limit)]);
if (args.search) tags.push(['search', args.search]);
if (args.relays && args.relays.length > 0) {
tags.push(['relays', ...args.relays]);
}
// Client-hint tags (NIP-50 extensions)
if (args.media && args.media !== 'all') tags.push(['media', args.media]);
if (args.language && args.language !== 'global') tags.push(['language', args.language]);
if (args.platform && args.platform !== 'nostr') tags.push(['platform', args.platform]);
if (args.sort && args.sort !== 'recent') tags.push(['sort', args.sort]);
if (args.includeReplies === false) tags.push(['include-replies', 'false']);
tags.push(['alt', `Spell: ${args.name ?? 'unnamed'}`]);
return tags;
}
/** Build an unsigned kind:777 spell event from pre-built tags.
* Useful when you need a spell event structure without signing. */
export function buildUnsignedSpell(tags: string[][]): NostrEvent {
return {
id: '',
pubkey: '',
created_at: Math.floor(Date.now() / 1000),
kind: 777,
tags,
content: '',
sig: '',
};
}
// ─── Spell Tag Parsers ───────────────────────────────────────────────────────
//
// Shared helpers for reading common fields out of a spell event's tags.
// Used by feed/tab edit modals to seed form state from an existing spell.
/** Extract the raw `authors` tag values. May contain `$me`, `$contacts`, or hex pubkeys. */
export function spellAuthors(spell: NostrEvent | undefined): string[] {
return spell?.tags.find(([t]) => t === 'authors')?.slice(1) ?? [];
}
/** Extract kind numbers from `k` tags as string values. */
export function spellKinds(spell: NostrEvent | undefined): string[] {
if (!spell) return [];
return spell.tags.filter(([t]) => t === 'k').map(([, v]) => v);
}
/** Extract the `search` tag value. */
export function spellSearch(spell: NostrEvent | undefined): string {
return spell?.tags.find(([t]) => t === 'search')?.[1] ?? '';
}
/**
* Extract explicit author pubkeys, filtering out runtime variables
* (`$me`, `$contacts`) and optionally a specific pubkey (e.g. profile owner).
*/
export function spellAuthorPubkeys(spell: NostrEvent | undefined, excludePubkey?: string): string[] {
return spellAuthors(spell).filter((a) => {
if (a.startsWith('$')) return false;
if (excludePubkey && a === excludePubkey) return false;
return true;
});
}
/**
* Stable semantic fingerprint for a spell event.
* Two spells with the same fingerprint represent the same query regardless
* of name, alt text, or event identity (id/pubkey/sig).
*/
export function spellFingerprint(spell: NostrEvent | undefined): string {
if (!spell) return '';
const METADATA_TAGS = new Set(['name', 'alt']);
const filterTags = spell.tags
.filter(([t]) => !METADATA_TAGS.has(t))
.map((tag) => tag.join('\x00'))
.sort();
return filterTags.join('\n');
}
+85
View File
@@ -0,0 +1,85 @@
import { z } from 'zod';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import { getBuddyOrEphemeralKey, signAndPublishWithProfile } from './helpers';
import type { Tool, ToolResult, ToolContext } from './Tool';
const inputSchema = z.object({
name: z.string().describe('Human-readable name for the emoji pack (e.g. "AIM Emoticons", "Retro Smileys").'),
emojis: z.array(z.object({
shortcode: z.string().describe('Shortcode for the emoji (alphanumeric, hyphens, underscores). E.g. "smiley", "heart-eyes".'),
url: z.string().describe('URL to the emoji image (should be a Blossom URL).'),
})).describe('Array of emoji entries to include in the pack.'),
});
type Params = z.infer<typeof inputSchema>;
export const CreateEmojiPackTool: Tool<Params> = {
description: `Create and publish a NIP-30 custom emoji pack (kind 30030 event). The pack is published as the logged-in user.
Takes a pack name and an array of emoji entries (shortcode + image URL). Shortcodes must be alphanumeric with hyphens and underscores only. The image URLs should be Blossom URLs from a prior upload_from_url call.
After publishing, the emoji pack appears in the user's feed and can be added to their emoji collection.`,
inputSchema,
async execute(args: Params, ctx: ToolContext): Promise<ToolResult> {
const packName = args.name.trim();
if (!packName) {
return { result: JSON.stringify({ error: 'A pack name is required.' }) };
}
if (args.emojis.length === 0) {
return { result: JSON.stringify({ error: 'At least one emoji is required.' }) };
}
for (const e of args.emojis) {
if (!/^[a-zA-Z0-9_-]+$/.test(e.shortcode)) {
return { result: JSON.stringify({ error: `Invalid shortcode "${e.shortcode}". Must be alphanumeric with hyphens and underscores only.` }) };
}
}
// Sanitize emoji URLs -- reject any that aren't valid HTTPS
const sanitizedEmojis = args.emojis
.map((e) => ({ shortcode: e.shortcode, url: sanitizeUrl(e.url) }))
.filter((e): e is { shortcode: string; url: string } => !!e.url);
if (sanitizedEmojis.length === 0) {
return { result: JSON.stringify({ error: 'No emojis had valid HTTPS URLs. All emoji image URLs must be HTTPS.' }) };
}
const dTag = packName
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, '')
.replace(/[\s_]+/g, '-')
.replace(/^-+|-+$/g, '');
const tags: string[][] = [
['d', dTag],
['title', packName],
...sanitizedEmojis.map((e) => ['emoji', e.shortcode, e.url]),
];
const { sk, pubkey, isBuddy } = getBuddyOrEphemeralKey(ctx.getBuddySecretKey);
const emojiPackEvent = await signAndPublishWithProfile(
ctx.nostr, sk, isBuddy,
{ kind: 30030, content: '', tags, created_at: Math.floor(Date.now() / 1000) },
{ name: 'Dork Emoji Maker', about: 'Emoji packs created by Dork AI' },
);
return {
result: JSON.stringify({
success: true,
event_id: emojiPackEvent.id,
pubkey,
name: packName,
slug: dTag,
emoji_count: sanitizedEmojis.length,
}),
nostrEvent: emojiPackEvent,
};
},
};
+93
View File
@@ -0,0 +1,93 @@
import { z } from 'zod';
import { buildSpellTags } from '@/lib/spellEngine';
import { getBuddyOrEphemeralKey, signAndPublishWithProfile } from './helpers';
import type { Tool, ToolResult, ToolContext } from './Tool';
const inputSchema = z.object({
name: z.string().describe('Short human-readable name for the spell (e.g. "fren bitcoin", "my mass deletions").'),
description: z.string().optional().describe('Optional longer description of what the spell does.'),
cmd: z.enum(['REQ', 'COUNT']).optional().describe('Command type. "REQ" returns matching events as a feed (default). "COUNT" returns just the count of matches.'),
kinds: z.array(z.number()).optional().describe('Event kind numbers to filter (e.g. [1] for text notes, [20] for photos, [30023] for articles, [9735] for zap receipts).'),
authors: z.array(z.string()).optional().describe('Author filter. Use "$me" for the logged-in user, "$contacts" for their follow list, or hex pubkeys.'),
tag_filters: z.array(z.object({
letter: z.string().describe('Single-letter tag name (e.g. "t" for hashtags, "e" for event references, "p" for pubkey references).'),
values: z.array(z.string()).describe('Tag values to match. Supports "$me" and "$contacts" variables.'),
})).optional().describe('Tag-based filters. Each entry becomes a #<letter> filter in the Nostr query.'),
since: z.string().optional().describe('Only include events after this time. Accepts relative durations ("7d", "2w", "1mo", "1y", "24h") or "now".'),
until: z.string().optional().describe('Only include events before this time. Same format as since.'),
limit: z.number().optional().describe('Maximum number of results to return.'),
search: z.string().optional().describe('Full-text search query (NIP-50). Filters events by content text.'),
relays: z.array(z.string()).optional().describe('Specific relay WebSocket URLs to query (e.g. ["wss://relay.damus.io"]). If omitted, uses the user\'s default relays.'),
media: z.enum(['all', 'images', 'videos', 'vines', 'none']).optional().describe('Media filter. "images" = only posts with images, "videos" = only videos, "vines" = short-form video, "none" = text only. Omit for all content.'),
language: z.string().optional().describe('Language filter (ISO 639-1 code, e.g. "en", "ja", "es"). Only returns posts in this language. Requires Ditto relay.'),
platform: z.enum(['nostr', 'activitypub', 'atproto']).optional().describe('Protocol filter. "nostr" = native Nostr only (default), "activitypub" = bridged from ActivityPub, "atproto" = bridged from AT Protocol.'),
sort: z.enum(['recent', 'hot', 'trending']).optional().describe('Sort order. "recent" = newest first (default), "hot" = trending recently, "trending" = most popular. Non-recent sorts require Ditto relay.'),
include_replies: z.boolean().optional().describe('Whether to include reply posts. Default true. Set false to exclude replies and show only top-level posts.'),
});
type Params = z.infer<typeof inputSchema>;
export const CreateSpellTool: Tool<Params> = {
description: `Create a Nostr spell — a saved query that acts as a custom feed. The spell is published as a kind:777 event and can be added to the sidebar for quick access.
Spells define a Nostr relay filter with optional runtime variables that resolve when executed:
- "$me" expands to the logged-in user's pubkey
- "$contacts" expands to the user's follow list (kind:3 contacts)
Timestamps can be relative durations subtracted from now: "7d" (7 days ago), "2w" (2 weeks), "1mo" (1 month), "1y" (1 year), "24h" (24 hours), or "now" for the current time.
Examples:
- "friends talking about bitcoin" → authors: ["$contacts"], tag_filters: [{letter: "t", values: ["bitcoin"]}]
- "my mass deletions" → authors: ["$me"], kinds: [5]
- "popular zap receipts this week" → kinds: [9735], since: "7d"
- "photos from people I follow" → authors: ["$contacts"], kinds: [20], media: "images"
- "trending posts this week" → since: "7d", sort: "trending"
- "articles mentioning nostr" → kinds: [30023], search: "nostr"
- "english posts from follows" → authors: ["$contacts"], language: "en"`,
inputSchema,
async execute(args: Params, ctx: ToolContext): Promise<ToolResult> {
if (!args.name.trim()) {
return { result: JSON.stringify({ error: 'A spell name is required.' }) };
}
const tags = buildSpellTags({
name: args.name,
cmd: args.cmd,
kinds: args.kinds,
authors: args.authors,
tag_filters: args.tag_filters,
since: args.since,
until: args.until,
limit: args.limit,
search: args.search,
relays: args.relays,
media: args.media,
language: args.language,
platform: args.platform,
sort: args.sort,
includeReplies: args.include_replies,
});
const content = args.description ?? '';
const { sk, pubkey, isBuddy } = getBuddyOrEphemeralKey(ctx.getBuddySecretKey);
const spellEvent = await signAndPublishWithProfile(
ctx.nostr, sk, isBuddy,
{ kind: 777, content, tags, created_at: Math.floor(Date.now() / 1000) },
{ name: 'Dork Spellcaster', about: 'Spells created by Dork AI' },
);
return {
result: JSON.stringify({
success: true,
event_id: spellEvent.id,
pubkey,
name: args.name,
}),
nostrEvent: spellEvent,
};
},
};
+168
View File
@@ -0,0 +1,168 @@
import { z } from 'zod';
import { zipSync, strToU8 } from 'fflate';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import { getBuddyOrEphemeralKey, signAndPublishWithProfile, createBuddyUploader } from './helpers';
import type { Tool, ToolResult, ToolContext } from './Tool';
const inputSchema = z.object({
name: z.string().describe('Human-readable app name (e.g. "Pong", "Snake", "Tic Tac Toe").'),
html: z.string().optional().describe('Complete HTML source code for a single-file app. Must be a full HTML document with <!DOCTYPE html>. Ignored if "files" is provided.'),
files: z.record(z.string(), z.string()).optional().describe('Map of filenames to text content for multi-file apps. Must include "index.html". Other files (e.g. "game.js", "style.css") are loaded via relative paths.'),
asset_urls: z.record(z.string(), z.string()).optional().describe('Map of filenames to remote URLs for binary assets to bundle into the archive. Each URL is fetched and included as a raw file.'),
description: z.string().optional().describe('Optional short description of the app.'),
image_url: z.string().optional().describe('Optional icon/thumbnail image URL for the app card in the feed.'),
});
type Params = z.infer<typeof inputSchema>;
export const CreateWebxdcTool: Tool<Params> = {
description: `Create and publish a WebXDC mini-app. WebXDC apps are self-contained HTML5 apps (games, tools, widgets) that run inside a sandboxed iframe with no internet access.
You provide the app name and source code. The tool handles everything else: packaging into a .xdc archive, uploading to Blossom, and publishing as a kind 1063 Nostr event that other users can launch directly from their feed.
**Two modes for source code:**
- **Simple (html param):** Provide a single self-contained HTML string. Best for small apps.
- **Multi-file (files param):** Provide a map of filenames to content strings. The archive can contain index.html plus separate .js, .css, .json, or .svg files. index.html loads them via relative paths (e.g. <script src="game.js">). Use this when the code is large enough that splitting into separate files improves clarity.
Only one of html or files is needed. If both are provided, files takes priority.
**Binary assets (asset_urls param, optional):** Include remote files as binary assets in the archive. Map filenames to Blossom URLs (from prior upload_from_url calls). Each URL is fetched and bundled into the .xdc. The app loads them via relative paths (e.g. fetch("game.gb"), new Audio("sfx.wav")). Works for ROMs, images, audio, WASM, fonts, or any binary content.
**Important constraints:**
- NO external resources: no CDN links, no external CSS/JS, no Google Fonts
- NO ES module imports — use plain <script> tags only
- All assets (images, sounds) must be generated procedurally (canvas drawing, CSS shapes, Web Audio API) or embedded as data: URIs
- The sandbox blocks all external network access — remote requests silently fail
- fetch() to relative paths within the archive DOES work; localStorage is available and scoped to the app
**Input handling:**
- The host app provides a built-in virtual gamepad — do NOT build touch controls or on-screen gamepads
- Only use keydown/keyup listeners. The host gamepad maps to: ArrowUp/Down/Left/Right for D-pad, x (88) = A, z (90) = B, Enter (13) = Start, Shift (16) = Select
- Fill the entire viewport with the app canvas — no space needed for controls
**Good patterns:**
- Canvas-based games (pong, snake, tetris, breakout, etc.)
- CSS + JS interactive toys (calculators, timers, drawing apps)
- Procedurally generated visuals
- Web Audio API for sound effects
**Example:** A simple game with inline CSS and JS, all graphics drawn on canvas, no external dependencies.`,
inputSchema,
async execute(args: Params, ctx: ToolContext): Promise<ToolResult> {
if (!ctx.user) {
return { result: JSON.stringify({ error: 'Must be logged in to create a WebXDC app.' }) };
}
const appName = args.name.trim();
const html = args.html ?? '';
const filesMap = args.files ?? null;
const description = (args.description ?? '').trim();
if (!appName) {
return { result: JSON.stringify({ error: 'An app name is required.' }) };
}
if (!filesMap && !html) {
return { result: JSON.stringify({ error: 'Either "html" or "files" is required.' }) };
}
// Build the .xdc archive in memory using fflate
const manifest = `name = "${appName.replace(/"/g, '\\"')}"\n`;
const entries: Record<string, Uint8Array> = {
'manifest.toml': strToU8(manifest),
};
if (filesMap) {
for (const [filename, content] of Object.entries(filesMap)) {
if (typeof content === 'string') {
entries[filename] = strToU8(content);
}
}
if (!entries['index.html']) {
return { result: JSON.stringify({ error: 'The "files" map must include an "index.html" entry.' }) };
}
} else if (html) {
entries['index.html'] = strToU8(html);
}
// Fetch binary assets from URLs and add to the archive
if (args.asset_urls) {
const assetEntries = await Promise.all(
Object.entries(args.asset_urls)
.filter(([, url]) => typeof url === 'string' && url.trim())
.map(async ([filename, url]) => {
const safeUrl = sanitizeUrl(url);
if (!safeUrl) throw new Error(`Invalid asset URL for "${filename}": must be a valid HTTPS URL.`);
const res = await globalThis.fetch(safeUrl, { signal: AbortSignal.timeout(60_000) });
if (!res.ok) throw new Error(`Failed to fetch asset "${filename}" from ${safeUrl}: ${res.status}`);
return [filename, new Uint8Array(await res.arrayBuffer())] as const;
}),
);
for (const [filename, bytes] of assetEntries) {
entries[filename] = bytes;
}
}
if (!entries['index.html']) {
return { result: JSON.stringify({ error: 'An "index.html" entry is required. Provide "html", or include "index.html" in "files".' }) };
}
const zipped = zipSync(entries);
const slug = appName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
const xdcFile = new File([zipped], `${slug}.xdc`, { type: 'application/x-webxdc' });
// Upload to Blossom
const uploader = createBuddyUploader(ctx.getBuddySecretKey, ctx.user.signer, ctx.config);
const uploadTags = await uploader.upload(xdcFile);
let blossomUrl = uploadTags[0][1];
if (!blossomUrl.endsWith('.xdc')) {
blossomUrl = blossomUrl + '.xdc';
}
const uuid = crypto.randomUUID();
const eventTags: string[][] = [
['url', blossomUrl],
['m', 'application/x-webxdc'],
['alt', `Webxdc app: ${appName}`],
['webxdc', uuid],
];
const hashTag = uploadTags.find(t => t[0] === 'x');
if (hashTag) eventTags.push(['x', hashTag[1]]);
const oxTag = uploadTags.find(t => t[0] === 'ox');
if (oxTag) eventTags.push(['ox', oxTag[1]]);
const sizeTag = uploadTags.find(t => t[0] === 'size');
if (sizeTag) eventTags.push(['size', sizeTag[1]]);
const imageUrl = sanitizeUrl((args.image_url ?? '').trim());
if (imageUrl) eventTags.push(['image', imageUrl]);
const { sk, pubkey, isBuddy } = getBuddyOrEphemeralKey(ctx.getBuddySecretKey);
const webxdcEvent = await signAndPublishWithProfile(
ctx.nostr, sk, isBuddy,
{ kind: 1063, content: description || appName, tags: eventTags, created_at: Math.floor(Date.now() / 1000) },
{ name: 'Dork App Maker', about: 'WebXDC apps created by Dork AI' },
);
return {
result: JSON.stringify({
success: true,
event_id: webxdcEvent.id,
pubkey,
name: appName,
url: blossomUrl,
size: xdcFile.size,
}),
nostrEvent: webxdcEvent,
};
},
};
+97
View File
@@ -0,0 +1,97 @@
import { z } from 'zod';
import { nip19 } from 'nostr-tools';
import type { NostrEvent } from '@nostrify/nostrify';
import type { Tool, ToolResult, ToolContext } from './Tool';
const inputSchema = z.object({
identifier: z.string().describe('NIP-19 identifier (npub1..., note1..., nevent1..., naddr1..., nprofile1...).'),
});
type Params = z.infer<typeof inputSchema>;
export const FetchEventTool: Tool<Params> = {
description: `Fetch a Nostr event by its NIP-19 identifier. Supports npub (fetches kind 0 profile), nprofile, note (fetches event by ID), nevent, and naddr (fetches addressable event by kind+author+d-tag).
Use this when the user shares a Nostr identifier and you need to read its content — for example, to see what a note says, look up a user's profile, or read an article.
Returns the full event JSON including kind, content, tags, pubkey, and timestamp.`,
inputSchema,
async execute(args: Params, ctx: ToolContext): Promise<ToolResult> {
const identifier = args.identifier.trim();
if (!identifier) {
return { result: JSON.stringify({ error: 'A NIP-19 identifier is required.' }) };
}
let decoded: nip19.DecodedResult;
try {
decoded = nip19.decode(identifier);
} catch {
return { result: JSON.stringify({ error: `Invalid NIP-19 identifier: ${identifier}` }) };
}
if (decoded.type === 'nsec') {
return { result: JSON.stringify({ error: 'nsec identifiers are not supported for security reasons.' }) };
}
let event: NostrEvent | undefined;
switch (decoded.type) {
case 'npub': {
const events = await ctx.nostr.query(
[{ kinds: [0], authors: [decoded.data], limit: 1 }],
{ signal: AbortSignal.timeout(8000) },
);
event = events[0];
break;
}
case 'nprofile': {
const events = await ctx.nostr.query(
[{ kinds: [0], authors: [decoded.data.pubkey], limit: 1 }],
{ signal: AbortSignal.timeout(8000) },
);
event = events[0];
break;
}
case 'note': {
const events = await ctx.nostr.query(
[{ ids: [decoded.data] }],
{ signal: AbortSignal.timeout(8000) },
);
event = events[0];
break;
}
case 'nevent': {
const events = await ctx.nostr.query(
[{ ids: [decoded.data.id] }],
{ signal: AbortSignal.timeout(8000) },
);
event = events[0];
break;
}
case 'naddr': {
const events = await ctx.nostr.query(
[{
kinds: [decoded.data.kind],
authors: [decoded.data.pubkey],
'#d': [decoded.data.identifier],
limit: 1,
}],
{ signal: AbortSignal.timeout(8000) },
);
event = events[0];
break;
}
default:
return { result: JSON.stringify({ error: `Unsupported identifier type: ${(decoded as { type: string }).type}` }) };
}
if (!event) {
return { result: JSON.stringify({ error: 'No event found for the provided identifier.' }) };
}
return { result: JSON.stringify(event) };
},
};
+66
View File
@@ -0,0 +1,66 @@
import { z } from 'zod';
import { proxyUrl } from '@/lib/proxyUrl';
import type { Tool, ToolResult, ToolContext } from './Tool';
const inputSchema = z.object({
url: z.string().describe('The URL to fetch (e.g. "https://www.jamfoo.com/aim-emoticons/").'),
});
type Params = z.infer<typeof inputSchema>;
export const FetchPageTool: Tool<Params> = {
description: `Fetch a web page and extract its content. Returns the page text and a list of image URLs found on the page. Use this when the user provides a URL and wants to download content from it — for example, to find emoji images on a page.
The page is fetched through a CORS proxy so it works in the browser. Images are extracted from <img> tags in the HTML. Relative URLs are resolved to absolute URLs.`,
inputSchema,
async execute(args: Params, ctx: ToolContext): Promise<ToolResult> {
const url = args.url.trim();
if (!url) {
return { result: JSON.stringify({ error: 'A URL is required.' }) };
}
const proxied = proxyUrl({ template: ctx.config.corsProxy, url });
const response = await fetch(proxied, { signal: AbortSignal.timeout(30_000) });
if (!response.ok) {
return { result: JSON.stringify({ error: `Fetch failed: ${response.status} ${response.statusText}` }) };
}
const html = await response.text();
const doc = new DOMParser().parseFromString(html, 'text/html');
const imgs = Array.from(doc.querySelectorAll('img'));
const baseUrl = new URL(url);
const imageUrls: string[] = [];
for (const img of imgs) {
const src = img.getAttribute('src');
if (!src) continue;
try {
const absolute = new URL(src, baseUrl).href;
if (/\.(png|jpe?g|gif|webp|svg|bmp|ico|avif)(\?.*)?$/i.test(absolute)) {
imageUrls.push(absolute);
}
} catch {
// Skip malformed URLs.
}
}
const uniqueImages = [...new Set(imageUrls)];
const title = doc.querySelector('title')?.textContent?.trim() || '';
return {
result: JSON.stringify({
success: true,
title,
image_count: uniqueImages.length,
images: uniqueImages.slice(0, 100),
text_preview: doc.body?.textContent?.slice(0, 500)?.trim() || '',
}),
};
},
};
+274
View File
@@ -0,0 +1,274 @@
import { z } from 'zod';
import { nip19 } from 'nostr-tools';
import { buildSpellTags, buildUnsignedSpell, resolveSpell } from '@/lib/spellEngine';
import { DITTO_RELAYS } from '@/lib/appRelays';
import type { NostrEvent, NostrFilter } from '@nostrify/nostrify';
import type { Tool, ToolResult, ToolContext } from './Tool';
const inputSchema = z.object({
feed_name: z.string().optional().describe('Name of an existing feed: "follows", "global", or a saved feed label.'),
kinds: z.array(z.number()).optional().describe('Event kind numbers to filter (e.g. [1] for text notes, [20] for photos, [30023] for articles).'),
authors: z.array(z.string()).optional().describe('Author filter. Use "$me" for the logged-in user, "$contacts" for their follow list, or hex pubkeys.'),
search: z.string().optional().describe('Full-text search query (NIP-50).'),
hashtag: z.string().optional().describe('Filter by hashtag (without the # symbol).'),
country: z.string().optional().describe('ISO 3166-1 alpha-2 country code (e.g. "VE", "US", "BR"). Queries NIP-73 geographic comments (kind 1111) for that country.'),
hours: z.number().optional().describe('How many hours back to look. Default 12, max 168 (1 week).'),
limit: z.number().optional().describe('Maximum number of posts to return. Default 50, max 100.'),
});
type Params = z.infer<typeof inputSchema>;
export const GetFeedTool: Tool<Params> = {
description: `Read posts from a feed and return their content. Use this when the user asks what people are talking about, wants a summary of recent activity, or asks about a specific topic or country.
You can reference an existing feed by name or build a query on the fly:
**Named feeds:**
- "follows" — posts from people the user follows
- "global" — recent posts from everyone
- Any saved feed label the user has created (check the system prompt for available feeds)
**Ad-hoc queries using spell parameters:**
- kinds: event kinds to include (default: [1] for text notes)
- authors: who to include — "$me", "$contacts", or hex pubkeys
- search: full-text NIP-50 search query
- hashtag: filter by hashtag (without #)
- country: ISO 3166-1 alpha-2 country code (e.g. "VE", "US") — queries the country activity feed (kind 1111 geographic comments)
**Time window:**
- hours: how far back to look (default: 12, max: 168)
When the user asks about a country (e.g. "what's going on in Venezuela?"), use the country parameter. When they ask about their friends or follows, use feed_name "follows". When they ask about a topic, use search or hashtag.
After receiving results, summarize the key topics, conversations, and notable posts for the user.`,
inputSchema,
async execute(args: Params, ctx: ToolContext): Promise<ToolResult> {
const feedName = (args.feed_name ?? '').trim().toLowerCase();
const country = (args.country ?? '').trim().toUpperCase();
const hours = Math.min(Math.max(1, args.hours ?? 12), 168);
const limit = Math.min(Math.max(1, args.limit ?? 50), 100);
const sinceTimestamp = Math.floor(Date.now() / 1000) - hours * 3600;
const contactPubkeys = await fetchContactPubkeys(ctx);
const resolved = resolveFilter(args, ctx, { feedName, country, hours, limit, sinceTimestamp, contactPubkeys });
if ('error' in resolved) {
return { result: JSON.stringify(resolved) };
}
const { filter, needsDittoRelay, feedLabel } = resolved;
const store = needsDittoRelay ? ctx.nostr.group(DITTO_RELAYS) : ctx.nostr;
const events = await store.query(
[filter],
{ signal: AbortSignal.timeout(10000) },
);
const sorted = events.sort((a: NostrEvent, b: NostrEvent) => b.created_at - a.created_at);
if (sorted.length === 0) {
return {
result: JSON.stringify({
success: true,
feed: feedLabel,
hours,
post_count: 0,
data: `No posts found in the "${feedLabel}" feed in the past ${hours} hours.`,
}),
};
}
const text = await formatEvents(sorted, feedLabel, hours, ctx);
return {
result: JSON.stringify({
success: true,
feed: feedLabel,
hours,
post_count: sorted.length,
data: text,
}),
};
},
};
// ── Helpers ───────────────────────────────────────────────────────────────────
/** Fetch the logged-in user's contact list pubkeys. */
async function fetchContactPubkeys(ctx: ToolContext): Promise<string[]> {
if (!ctx.user) return [];
try {
const contactEvents = await ctx.nostr.query(
[{ kinds: [3], authors: [ctx.user.pubkey], limit: 1 }],
{ signal: AbortSignal.timeout(5000) },
);
return contactEvents[0]?.tags
.filter(([t]) => t === 'p')
.map(([, pk]) => pk) ?? [];
} catch {
return [];
}
}
interface ResolveContext {
feedName: string;
country: string;
hours: number;
limit: number;
sinceTimestamp: number;
contactPubkeys: string[];
}
type ResolvedFilter =
| { filter: NostrFilter; needsDittoRelay: boolean; feedLabel: string }
| { error: string; available_feeds?: string };
/** Build the Nostr filter from the tool arguments. */
function resolveFilter(
args: Params, ctx: ToolContext,
{ feedName, country, limit, sinceTimestamp, contactPubkeys }: ResolveContext,
): ResolvedFilter {
if (country) {
return {
filter: { kinds: [1111], '#I': [`iso3166:${country}`], since: sinceTimestamp, limit } as NostrFilter,
needsDittoRelay: false,
feedLabel: `country: ${country}`,
};
}
if (feedName === 'follows') {
if (!ctx.user) return { error: 'Must be logged in to read the follows feed.' };
const authors = [ctx.user.pubkey, ...contactPubkeys];
if (authors.length <= 1) return { error: 'The user is not following anyone yet.' };
return { filter: { kinds: [1], authors, since: sinceTimestamp, limit }, needsDittoRelay: false, feedLabel: 'follows' };
}
if (feedName === 'global') {
return { filter: { kinds: [1], since: sinceTimestamp, limit }, needsDittoRelay: false, feedLabel: 'global' };
}
if (feedName === 'ditto') {
return { filter: { kinds: [1], since: sinceTimestamp, limit, search: 'sort:hot protocol:nostr' }, needsDittoRelay: true, feedLabel: 'ditto (hot)' };
}
if (feedName) {
const match = ctx.savedFeeds.find((f) => f.label.toLowerCase() === feedName);
if (!match) {
const available = ctx.savedFeeds.map((f) => f.label).join(', ');
return {
error: `No saved feed named "${args.feed_name}".`,
available_feeds: available ? `follows, global, ditto, ${available}` : 'follows, global, ditto',
};
}
try {
const sf = match.filter as Record<string, unknown>;
// Map the saved feed's NostrFilter shape into buildSpellTags args,
// translating $follows → $contacts so resolveSpell handles variable
// expansion and needsDittoRelay detection consistently.
const authors = Array.isArray(sf.authors)
? (sf.authors as string[]).map((a) => a === '$follows' ? '$contacts' : a)
: undefined;
const kinds = Array.isArray(sf.kinds) ? (sf.kinds as number[]) : undefined;
const tags = buildSpellTags({
name: match.label,
kinds,
authors,
search: typeof sf.search === 'string' ? sf.search : undefined,
});
const unsigned = buildUnsignedSpell(tags);
const resolved = resolveSpell(unsigned, ctx.user?.pubkey, contactPubkeys);
const filter = { ...resolved.filter, since: sinceTimestamp, limit } as NostrFilter;
return { filter, needsDittoRelay: resolved.needsDittoRelay, feedLabel: match.label };
} catch (err) {
return { error: `Failed to resolve saved feed "${match.label}": ${err instanceof Error ? err.message : 'Unknown error'}` };
}
}
// Ad-hoc query
const spellArgs: Parameters<typeof buildSpellTags>[0] = {
name: 'ad-hoc',
kinds: args.kinds,
authors: args.authors,
search: args.search,
};
if (args.hashtag?.trim()) {
spellArgs.tag_filters = [{ letter: 't', values: [args.hashtag.trim().toLowerCase()] }];
}
const tags = buildSpellTags(spellArgs);
const unsigned = buildUnsignedSpell(tags);
try {
const resolved = resolveSpell(unsigned, ctx.user?.pubkey, contactPubkeys);
const filter: NostrFilter = { ...resolved.filter, since: sinceTimestamp, limit };
if (!filter.kinds) filter.kinds = [1];
const feedLabel = args.search ? `search: ${args.search}` : args.hashtag ? `#${args.hashtag}` : 'ad-hoc';
return { filter, needsDittoRelay: resolved.needsDittoRelay, feedLabel };
} catch (err) {
return { error: `Failed to resolve query: ${err instanceof Error ? err.message : 'Unknown error'}` };
}
}
/** Format events into a markdown summary with author display names. */
async function formatEvents(
sorted: NostrEvent[], feedLabel: string, hours: number, ctx: ToolContext,
): Promise<string> {
const uniquePubkeys = [...new Set(sorted.map((e) => e.pubkey))];
const profileMap = new Map<string, { name?: string; display_name?: string; nip05?: string }>();
try {
const profiles = await ctx.nostr.query(
[{ kinds: [0], authors: uniquePubkeys }],
{ signal: AbortSignal.timeout(5000) },
);
for (const p of profiles) {
try {
const meta = JSON.parse(p.content);
profileMap.set(p.pubkey, {
name: meta.name,
display_name: meta.display_name,
nip05: meta.nip05,
});
} catch {
// Skip invalid metadata
}
}
} catch {
// Profiles unavailable — continue with pubkey-only display
}
const formatTimeAgo = (ts: number): string => {
const seconds = Math.floor(Date.now() / 1000) - ts;
if (seconds < 60) return 'just now';
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
return `${Math.floor(seconds / 86400)}d ago`;
};
let text = `## ${feedLabel} — past ${hours}h (${sorted.length} posts)\n\n`;
for (const event of sorted) {
const profile = profileMap.get(event.pubkey);
const displayName = profile?.display_name || profile?.name || nip19.npubEncode(event.pubkey).slice(0, 16) + '...';
const hashtags = event.tags
.filter(([t]) => t === 't')
.map(([, v]) => `#${v}`)
.join(' ');
text += `**${displayName}** (${formatTimeAgo(event.created_at)}):\n`;
text += `${event.content.slice(0, 500)}${event.content.length > 500 ? '...' : ''}\n`;
if (hashtags) text += `Tags: ${hashtags}\n`;
text += '\n---\n\n';
}
return text;
}
+46
View File
@@ -0,0 +1,46 @@
import { z } from 'zod';
import type { Tool, ToolResult, ToolContext } from './Tool';
const inputSchema = z.object({
action: z.enum(['start', 'stop']).describe('Whether to start or stop the effect.'),
type: z.enum(['rain', 'snow']).optional().describe('The type of precipitation. Defaults to "rain".'),
intensity: z.enum(['light', 'moderate', 'heavy']).optional().describe('How intense the effect should be. "light" for gentle ambiance, "moderate" for noticeable effect, "heavy" for dramatic downpour. Defaults to "moderate".'),
});
type Params = z.infer<typeof inputSchema>;
export const MakeItRainTool: Tool<Params> = {
description: `Trigger a fun visual weather effect on the user's screen. This is a playful easter egg — use it when the mood calls for it!
Use "start" to activate rain or snow, and "stop" to turn it off. The effect persists across the entire app (all pages) until the user asks to stop it.
**When to use this (be creative!):**
- The user literally says "make it rain" or asks for rain/snow
- Celebrating something (use heavy rain or snow for dramatic flair)
- The conversation has a moody, dramatic, or cozy vibe
- The user is feeling down (gentle rain can be soothing)
- Discussing weather, seasons, or nature
- Any moment where a visual flourish would delight
**When to stop:**
- The user asks to stop, turn off, or clear the effect
- The user says "enough" or seems annoyed by it`,
inputSchema,
async execute(args: Params, ctx: ToolContext): Promise<ToolResult> {
if (args.action === 'stop') {
ctx.setScreenEffect(null);
return { result: JSON.stringify({ success: true, message: 'Screen effect stopped.' }) };
}
const effectType = args.type ?? 'rain';
const intensity = args.intensity ?? 'moderate';
ctx.setScreenEffect({ type: effectType, intensity });
const label = `${intensity} ${effectType}`;
return { result: JSON.stringify({ success: true, message: `${label} effect activated!` }) };
},
};
+77
View File
@@ -0,0 +1,77 @@
import { z } from 'zod';
import { finalizeEvent } from 'nostr-tools';
import { getBuddyOrEphemeralKey } from './helpers';
import type { NostrEvent } from '@nostrify/nostrify';
import type { Tool, ToolResult, ToolContext } from './Tool';
const inputSchema = z.object({
events: z.array(z.object({
kind: z.number().optional().describe('Event kind number (default: 1).'),
content: z.string().optional().describe('Event content (default: empty string).'),
tags: z.array(z.array(z.string())).optional().describe('Event tags (default: empty array).'),
})).describe('Array of events to publish.'),
});
type Params = z.infer<typeof inputSchema>;
export const PublishEventsTool: Tool<Params> = {
description: `Publish one or more Nostr events signed by your identity. Each event can specify a kind, content, and tags. Defaults: kind 1 (text note), empty content, empty tags, current timestamp.
Common kinds: 1 = text note, 6 = repost, 7 = reaction (content is "+" or emoji), 30023 = long-form article.
For text notes (kind 1), put the post text in content. For reactions (kind 7), set content to "+" or an emoji and add an "e" tag referencing the target event.
Tags are arrays of strings, e.g. [["t", "nostr"], ["p", "<hex-pubkey>"]] for a hashtag and a mention.`,
inputSchema,
async execute(args: Params, ctx: ToolContext): Promise<ToolResult> {
if (args.events.length === 0) {
return { result: JSON.stringify({ error: 'At least one event is required.' }) };
}
const { sk, pubkey, isBuddy } = getBuddyOrEphemeralKey(ctx.getBuddySecretKey);
const currentTimestamp = Math.floor(Date.now() / 1000);
const finalized: NostrEvent[] = args.events.map((partial) =>
finalizeEvent({
kind: partial.kind ?? 1,
content: partial.content ?? '',
tags: partial.tags ?? [],
created_at: currentTimestamp,
}, sk) as NostrEvent,
);
if (!isBuddy) {
const profileEvent = finalizeEvent({
kind: 0,
content: JSON.stringify({ name: 'Dork Publisher', about: 'Events published by Dork AI' }),
tags: [],
created_at: currentTimestamp,
}, sk) as NostrEvent;
await ctx.nostr.event(profileEvent, { signal: AbortSignal.timeout(5000) });
}
await Promise.all(
finalized.map((event) => ctx.nostr.event(event, { signal: AbortSignal.timeout(5000) })),
);
const displayEvent = finalized.find((e) => e.kind === 1) ?? finalized[0];
return {
result: JSON.stringify({
success: true,
pubkey,
events_published: finalized.length,
events: finalized.map((e) => ({
id: e.id,
kind: e.kind,
content: e.content.length > 100 ? `${e.content.slice(0, 100)}...` : e.content,
})),
}),
nostrEvent: displayEvent,
};
},
};
+98
View File
@@ -0,0 +1,98 @@
import { z } from 'zod';
import type { Tool, ToolResult, ToolContext } from './Tool';
const inputSchema = z.object({
query: z.string().describe('The follow pack title to search for (e.g. "team soapbox", "bitcoin developers", "nostr OGs").'),
});
type Params = z.infer<typeof inputSchema>;
export const SearchFollowPacksTool: Tool<Params> = {
description: `Search for Nostr follow packs by title. Follow packs (kind 39089) are curated lists of people. Use this when the user mentions a follow pack or starter pack by name — for example, "team soapbox pack" or "bitcoin developers pack".
Returns matching packs with their title, description, member count, and the hex pubkeys of all members. Use the returned pubkeys directly in the spell's authors array to create a feed based on the pack's members.`,
inputSchema,
async execute(args: Params, ctx: ToolContext): Promise<ToolResult> {
const query = args.query.trim().toLowerCase();
if (!query) {
return { result: JSON.stringify({ error: 'A search query is required.' }) };
}
const filters: { kinds: number[]; limit: number; search?: string; authors?: string[] }[] = [
{ kinds: [39089], limit: 200 },
];
filters.push({ kinds: [39089], search: args.query, limit: 50 });
if (ctx.user) {
filters.push({ kinds: [39089], authors: [ctx.user.pubkey], limit: 50 });
}
const events = await ctx.nostr.query(
filters,
{ signal: AbortSignal.timeout(10000) },
);
// Deduplicate by event id
const seen = new Set<string>();
const uniqueEvents = events.filter((e) => {
if (seen.has(e.id)) return false;
seen.add(e.id);
return true;
});
interface PackMatch {
title: string;
description?: string;
member_count: number;
pubkeys: string[];
author: string;
}
const matches: PackMatch[] = [];
for (const event of uniqueEvents) {
const title = (event.tags.find(([t]) => t === 'title')?.[1]
?? event.tags.find(([t]) => t === 'name')?.[1]
?? '').trim();
if (!title) continue;
if (!title.toLowerCase().includes(query)) continue;
const description = event.tags.find(([t]) => t === 'description')?.[1]
?? event.tags.find(([t]) => t === 'summary')?.[1];
const pubkeys = event.tags
.filter(([t]) => t === 'p')
.map(([, pk]) => pk);
if (pubkeys.length === 0) continue;
matches.push({
title,
description: description ? description.slice(0, 150) : undefined,
member_count: pubkeys.length,
pubkeys,
author: event.pubkey,
});
}
matches.sort((a, b) => {
const aExact = a.title.toLowerCase() === query ? 1 : 0;
const bExact = b.title.toLowerCase() === query ? 1 : 0;
if (aExact !== bExact) return bExact - aExact;
return b.member_count - a.member_count;
});
const results = matches.slice(0, 5);
if (results.length === 0) {
return { result: JSON.stringify({ matches: [], message: `No follow packs found matching "${args.query}".` }) };
}
return { result: JSON.stringify({ matches: results }) };
},
};
+116
View File
@@ -0,0 +1,116 @@
import { z } from 'zod';
import type { Tool, ToolResult, ToolContext } from './Tool';
const inputSchema = z.object({
query: z.string().describe('The name or display name to search for (e.g. "Derek Ross", "fiatjaf", "jb55").'),
});
type Params = z.infer<typeof inputSchema>;
export const SearchUsersTool: Tool<Params> = {
description: `Search for Nostr users by name. Returns matching profiles with their pubkeys, display names, NIP-05 identifiers, and bios. Use this when you need to resolve a person's name to their Nostr pubkey — for example, when creating a spell that targets a specific author.
The search checks the user's follow list first (contacts), then falls back to a broader relay search. Results from contacts are prioritized since they're more likely to be the person the user means.`,
inputSchema,
async execute(args: Params, ctx: ToolContext): Promise<ToolResult> {
const query = args.query.trim().toLowerCase();
if (!query) {
return { result: JSON.stringify({ error: 'A search query is required.' }) };
}
interface ProfileMatch {
pubkey: string;
name?: string;
display_name?: string;
nip05?: string;
about?: string;
source: 'contacts' | 'relay';
}
const matches: ProfileMatch[] = [];
// Phase 1: Search user's contacts
if (ctx.user) {
const contactEvents = await ctx.nostr.query(
[{ kinds: [3], authors: [ctx.user.pubkey], limit: 1 }],
{ signal: AbortSignal.timeout(5000) },
);
const contactPubkeys = contactEvents[0]?.tags
.filter(([t]) => t === 'p')
.map(([, pk]) => pk) ?? [];
if (contactPubkeys.length > 0) {
const metaEvents = await ctx.nostr.query(
[{ kinds: [0], authors: contactPubkeys }],
{ signal: AbortSignal.timeout(8000) },
);
for (const event of metaEvents) {
if (matches.length >= 5) break;
try {
const meta = JSON.parse(event.content);
const name = (meta.name || '').toLowerCase();
const displayName = (meta.display_name || '').toLowerCase();
const nip05 = (meta.nip05 || '').toLowerCase();
if (name.includes(query) || displayName.includes(query) || nip05.includes(query)) {
matches.push({
pubkey: event.pubkey,
name: meta.name,
display_name: meta.display_name,
nip05: meta.nip05,
about: meta.about ? meta.about.slice(0, 100) : undefined,
source: 'contacts',
});
}
} catch {
// Skip events with invalid metadata JSON
}
}
}
}
// Phase 2: NIP-50 relay search (if contacts didn't yield enough results)
if (matches.length < 3) {
try {
const searchEvents = await ctx.nostr.query(
[{ kinds: [0], search: args.query, limit: 10 }],
{ signal: AbortSignal.timeout(8000) },
);
const existingPubkeys = new Set(matches.map((m) => m.pubkey));
for (const event of searchEvents) {
if (existingPubkeys.has(event.pubkey)) continue;
try {
const meta = JSON.parse(event.content);
matches.push({
pubkey: event.pubkey,
name: meta.name,
display_name: meta.display_name,
nip05: meta.nip05,
about: meta.about ? meta.about.slice(0, 100) : undefined,
source: 'relay',
});
} catch {
// Skip events with invalid metadata JSON
}
}
} catch {
// NIP-50 search may not be supported by all relays
}
}
const results = matches.slice(0, 5);
if (results.length === 0) {
return { result: JSON.stringify({ matches: [], message: `No users found matching "${args.query}". The user may need to provide an npub or NIP-05 address.` }) };
}
return { result: JSON.stringify({ matches: results }) };
},
};
+85
View File
@@ -0,0 +1,85 @@
import { z } from 'zod';
import { bundledFonts } from '@/lib/fonts';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import type { Tool, ToolResult, ToolContext } from './Tool';
import type { ThemeConfig } from '@/themes';
const AVAILABLE_FONTS = bundledFonts.map((f) => f.family).join(', ');
/** Simple HSL format check: "H S% L%" where H is 0-360, S and L are 0-100%. */
function isValidHsl(value: string): boolean {
return /^\d{1,3}\s+\d{1,3}%\s+\d{1,3}%$/.test(value.trim());
}
const inputSchema = z.object({
background: z.string().describe('Background color as an HSL string (e.g. "228 20% 10%" for dark blue, "0 0% 100%" for white). This is the main page background.'),
text: z.string().describe('Text/foreground color as an HSL string (e.g. "210 40% 98%" for near-white, "0 0% 10%" for near-black). Must contrast well with the background.'),
primary: z.string().describe('Primary accent color as an HSL string (e.g. "258 70% 60%" for purple, "142 70% 45%" for green). Used for buttons, links, and interactive elements.'),
font: z.string().optional().describe(`Optional font family name. Must be one of the available bundled fonts: ${AVAILABLE_FONTS}. Choose a font that matches the theme's mood and aesthetic.`),
background_url: z.string().optional().describe('Optional URL to a background image. Should be a direct link to a publicly accessible image file (JPEG, PNG, WebP, etc.).'),
background_mode: z.enum(['cover', 'tile']).optional().describe('How to display the background image. "cover" fills the viewport (good for photos/landscapes). "tile" repeats the image (good for patterns/textures). Defaults to "cover".'),
});
type Params = z.infer<typeof inputSchema>;
export const SetThemeTool: Tool<Params> = {
description: `Set a custom theme for the application. You can set colors, a font, and a background image — all in one call. Colors are required; font and background are optional.
Color values must be HSL strings WITHOUT the "hsl()" wrapper — just raw values like "228 20% 10%". Choose colors that work well together and ensure good contrast between background and text.
For fonts, choose from the available bundled fonts: ${AVAILABLE_FONTS}. Pick a font that matches the mood of the theme.
For backgrounds, provide a URL to a publicly accessible image. Choose images that complement the color scheme. Use mode "cover" for full-bleed backgrounds or "tile" for repeating patterns.`,
inputSchema,
async execute(args: Params, ctx: ToolContext): Promise<ToolResult> {
const { background, text, primary, font, background_url, background_mode } = args;
if (!isValidHsl(background) || !isValidHsl(text) || !isValidHsl(primary)) {
return { result: JSON.stringify({
error: 'Invalid HSL color values. Each must be a string like "228 20% 10%".',
received: { background, text, primary },
}) };
}
const themeConfig: ThemeConfig = {
colors: { background, text, primary },
};
if (font) {
const bundled = bundledFonts.find((f) => f.family.toLowerCase() === font.trim().toLowerCase());
if (bundled) {
themeConfig.font = { family: bundled.family };
} else {
return { result: JSON.stringify({
error: `Unknown font "${font}". Available fonts: ${AVAILABLE_FONTS}`,
}) };
}
}
if (background_url) {
const safeUrl = sanitizeUrl(background_url.trim());
if (!safeUrl) {
return { result: JSON.stringify({ error: 'Invalid background URL. Must be a valid HTTPS URL.' }) };
}
themeConfig.background = {
url: safeUrl,
mode: background_mode === 'tile' ? 'tile' : 'cover',
};
}
ctx.applyCustomTheme(themeConfig);
const resultData: Record<string, unknown> = {
success: true,
colors: { background, text, primary },
};
if (themeConfig.font) resultData.font = themeConfig.font.family;
if (themeConfig.background) resultData.background = { url: themeConfig.background.url, mode: themeConfig.background.mode };
return { result: JSON.stringify(resultData) };
},
};
+63
View File
@@ -0,0 +1,63 @@
import type { z } from 'zod';
import type { NostrEvent, NostrSigner } from '@nostrify/nostrify';
/** Result returned by a tool's execute method. */
export interface ToolResult {
/** JSON string returned to the AI as the tool result. */
result: string;
/** A Nostr event published by the tool, rendered inline in the chat. */
nostrEvent?: NostrEvent;
}
/** Tool interface — each tool defines its schema, description, and execution logic. */
export interface Tool<TParams = unknown> {
/** Human-readable description shown to the AI model. */
description: string;
/** Zod schema for validating and parsing tool arguments. */
inputSchema: z.ZodType<TParams>;
/** Execute the tool with validated arguments. */
execute(args: TParams, ctx: ToolContext): Promise<ToolResult>;
}
/**
* Runtime context injected into every tool execution.
*
* Holds the dependencies that come from React hooks (nostr, user, config, etc.)
* so that Tool classes remain plain objects without hook coupling.
*/
export interface ToolContext {
/** Nostr protocol client for querying and publishing events. */
nostr: {
query: (filters: import('@nostrify/nostrify').NostrFilter[], opts?: { signal?: AbortSignal }) => Promise<NostrEvent[]>;
event: (event: NostrEvent, opts?: { signal?: AbortSignal }) => Promise<void>;
group: (relays: string[]) => {
query: (filters: import('@nostrify/nostrify').NostrFilter[], opts?: { signal?: AbortSignal }) => Promise<NostrEvent[]>;
event: (event: NostrEvent, opts?: { signal?: AbortSignal }) => Promise<void>;
};
};
/** Currently logged-in user, or undefined if not logged in. */
user?: {
pubkey: string;
signer: NostrSigner;
};
/** App configuration values. */
config: {
corsProxy: string;
blossomServerMetadata: { servers: string[]; updatedAt: number };
useAppBlossomServers: boolean;
};
/** Get the buddy secret key (returns null if no buddy is configured). */
getBuddySecretKey: () => Uint8Array | null;
/** Saved feed definitions. */
savedFeeds: Array<{
id: string;
label: string;
filter: Record<string, unknown>;
vars: Array<{ name: string; tagName: string; pointer: string }>;
createdAt: number;
}>;
/** Apply a custom theme to the app. */
applyCustomTheme: (theme: import('@/themes').ThemeConfig) => void;
/** Set a screen effect (rain/snow) or null to clear. */
setScreenEffect: (effect: { type: 'rain' | 'snow'; intensity: 'light' | 'moderate' | 'heavy' } | null) => void;
}
+103
View File
@@ -0,0 +1,103 @@
import { z } from 'zod';
import { proxyUrl } from '@/lib/proxyUrl';
import { createBuddyUploader } from './helpers';
import type { Tool, ToolResult, ToolContext } from './Tool';
const inputSchema = z.object({
urls: z.array(z.string()).describe('Array of file URLs to download and upload (max 50).'),
});
type Params = z.infer<typeof inputSchema>;
const MIME_BY_EXT: Record<string, string> = {
xdc: 'application/x-webxdc',
zip: 'application/zip',
png: 'image/png',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
gif: 'image/gif',
webp: 'image/webp',
svg: 'image/svg+xml',
mp4: 'video/mp4',
webm: 'video/webm',
mp3: 'audio/mpeg',
ogg: 'audio/ogg',
pdf: 'application/pdf',
json: 'application/json',
};
export const UploadFromUrlTool: Tool<Params> = {
description: `Download files from URLs and upload them to Blossom file servers. Returns the resulting Blossom URLs.
Supports any file type: images (png, jpg, gif, webp, svg), WebXDC apps (.xdc), archives (.zip), video, audio, documents, etc. MIME types are detected from file extensions — .xdc files are uploaded as application/x-webxdc.
Use this after fetch_page to upload discovered files, or directly with known URLs. Each file is fetched via CORS proxy and uploaded to Blossom. The user must be logged in.
Handles up to 50 files per call. Returns an array of objects with the original URL, the Blossom URL, detected MIME type, and a suggested shortcode derived from the filename.`,
inputSchema,
async execute(args: Params, ctx: ToolContext): Promise<ToolResult> {
if (!ctx.user) {
return { result: JSON.stringify({ error: 'Must be logged in to upload files.' }) };
}
const urls = args.urls.slice(0, 50);
if (urls.length === 0) {
return { result: JSON.stringify({ error: 'At least one URL is required.' }) };
}
const uploader = createBuddyUploader(ctx.getBuddySecretKey, ctx.user.signer, ctx.config);
const results: Array<{ original_url: string; blossom_url?: string; shortcode: string; mime_type?: string; error?: string }> = [];
for (const fileUrl of urls) {
try {
const proxied = proxyUrl({ template: ctx.config.corsProxy, url: fileUrl });
const response = await fetch(proxied, { signal: AbortSignal.timeout(30_000) });
if (!response.ok) {
results.push({ original_url: fileUrl, shortcode: '', error: `HTTP ${response.status}` });
continue;
}
const blob = await response.blob();
const pathname = new URL(fileUrl).pathname;
const filename = pathname.split('/').pop() || 'file';
const dotIndex = filename.lastIndexOf('.');
const baseName = dotIndex > 0 ? filename.slice(0, dotIndex) : filename;
const ext = dotIndex > 0 ? filename.slice(dotIndex + 1).toLowerCase() : '';
const shortcode = baseName
.replace(/[^a-zA-Z0-9_-]/g, '_')
.replace(/_+/g, '_')
.replace(/^_|_$/g, '')
.toLowerCase();
const mimeType = blob.type && blob.type !== 'application/octet-stream'
? blob.type
: MIME_BY_EXT[ext] ?? 'application/octet-stream';
const file = new File([blob], filename, { type: mimeType });
const tags = await uploader.upload(file);
const blossomUrl = tags[0][1];
results.push({ original_url: fileUrl, blossom_url: blossomUrl, shortcode: shortcode || 'file', mime_type: mimeType });
} catch (err) {
results.push({ original_url: fileUrl, shortcode: '', error: err instanceof Error ? err.message : 'Upload failed' });
}
}
const successful = results.filter((r) => r.blossom_url);
return {
result: JSON.stringify({
success: true,
uploaded: successful.length,
failed: results.length - successful.length,
results,
}),
};
},
};
+65
View File
@@ -0,0 +1,65 @@
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools';
import { NSecSigner } from '@nostrify/nostrify';
import { BlossomUploader } from '@nostrify/nostrify/uploaders';
import { getEffectiveBlossomServers } from '@/lib/appBlossom';
import type { NostrEvent, NostrSigner } from '@nostrify/nostrify';
import type { ToolContext } from './Tool';
/** Get the buddy secret key or generate an ephemeral one. */
export function getBuddyOrEphemeralKey(getBuddySecretKey: () => Uint8Array | null) {
const buddySk = getBuddySecretKey();
const sk = buddySk ?? generateSecretKey();
return { sk, pubkey: getPublicKey(sk), isBuddy: !!buddySk };
}
/**
* Sign and publish a Nostr event, plus a throwaway kind-0 profile when using
* an ephemeral key (buddy already has a profile).
*/
export async function signAndPublishWithProfile(
nostr: ToolContext['nostr'],
sk: Uint8Array,
isBuddy: boolean,
event: { kind: number; content: string; tags: string[][]; created_at: number },
profileMeta: { name: string; about: string },
): Promise<NostrEvent> {
const signed = finalizeEvent(event, sk) as NostrEvent;
const publishes: Promise<void>[] = [
nostr.event(signed, { signal: AbortSignal.timeout(5000) }),
];
if (!isBuddy) {
const profileEvent = finalizeEvent({
kind: 0,
content: JSON.stringify(profileMeta),
tags: [],
created_at: event.created_at,
}, sk) as NostrEvent;
publishes.push(nostr.event(profileEvent, { signal: AbortSignal.timeout(5000) }));
}
await Promise.all(publishes);
return signed;
}
/** Create a BlossomUploader configured with buddy or user signer. */
export function createBuddyUploader(
getBuddySecretKey: () => Uint8Array | null,
userSigner: NostrSigner,
config: ToolContext['config'],
): BlossomUploader {
const buddySk = getBuddySecretKey();
const signer = buddySk ? new NSecSigner(buddySk) : userSigner;
const servers = getEffectiveBlossomServers(config.blossomServerMetadata, config.useAppBlossomServers);
return new BlossomUploader({
servers,
signer,
fetch: (input, init) => globalThis.fetch(input, {
...init,
signal: AbortSignal.any([
init?.signal ?? AbortSignal.timeout(30_000),
AbortSignal.timeout(30_000),
]),
}),
});
}
+28
View File
@@ -0,0 +1,28 @@
import type { Tool } from './Tool';
/** OpenAI-compatible function-calling tool definition. */
export interface OpenAITool {
type: 'function';
function: {
name: string;
description: string;
parameters: Record<string, unknown>;
};
}
/**
* Convert a Tool<T> to OpenAI's function-calling format.
*
* Uses Zod's `.toJSONSchema()` (available since zod v4 / zod-to-json-schema)
* to derive the JSON Schema from the tool's inputSchema.
*/
export function toolToOpenAI<T>(name: string, tool: Tool<T>): OpenAITool {
return {
type: 'function',
function: {
name,
description: tool.description,
parameters: tool.inputSchema.toJSONSchema() as Record<string, unknown>,
},
};
}
+42
View File
@@ -0,0 +1,42 @@
/** Maximum tool result size in bytes (50 KiB). */
const MAX_RESULT_BYTES = 50 * 1024;
/** Maximum tool result size in lines. */
const MAX_RESULT_LINES = 2000;
/**
* Truncate a tool result string if it exceeds size limits.
*
* Follows the same pattern as Shakespeare: when output is too large,
* replace it with a truncation notice so the AI knows to ask for a
* smaller result (e.g. fewer posts, shorter time window).
*/
export function truncateToolResult(result: string): string {
const bytes = new TextEncoder().encode(result).length;
const lines = result.split('\n').length;
if (bytes <= MAX_RESULT_BYTES && lines <= MAX_RESULT_LINES) {
return result;
}
// Truncate to the byte limit by slicing characters (approximate, since
// multi-byte chars may straddle the boundary, but close enough for a
// size hint to the model).
let truncated = result;
if (bytes > MAX_RESULT_BYTES) {
// TextEncoder gives exact byte length; slice conservatively
truncated = truncated.slice(0, MAX_RESULT_BYTES);
}
if (truncated.split('\n').length > MAX_RESULT_LINES) {
truncated = truncated.split('\n').slice(0, MAX_RESULT_LINES).join('\n');
}
const notice = [
'\n\n---',
`[Output truncated: original was ${bytes.toLocaleString()} bytes / ${lines.toLocaleString()} lines, ` +
`limits are ${MAX_RESULT_BYTES.toLocaleString()} bytes / ${MAX_RESULT_LINES.toLocaleString()} lines]`,
'Try requesting fewer results (e.g. smaller limit, shorter time window).',
].join('\n');
return truncated + notice;
}
+122 -524
View File
@@ -1,399 +1,36 @@
import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import { useMemo } from 'react';
import { useSeoMeta } from '@unhead/react';
import Markdown from 'react-markdown';
import rehypeSanitize from 'rehype-sanitize';
import { Bot, Send, Trash2, Palette, Type } from 'lucide-react';
import { Bot, Send, Square, Trash2 } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { PageHeader } from '@/components/PageHeader';
import { useShakespeare, type ChatMessage, type Model, type ChatCompletionTool } from '@/hooks/useShakespeare';
import { MessageBubble, BuddyThinking } from '@/components/AIChat/AIChatComponents';
import { BuddyOnboarding } from '@/components/AIChat/BuddyOnboarding';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAppContext } from '@/hooks/useAppContext';
import { useTheme } from '@/hooks/useTheme';
import { bundledFonts } from '@/lib/fonts';
import { useAIChatSession } from '@/hooks/useAIChatSession';
import { useBuddy } from '@/hooks/useBuddy';
import { LoginArea } from '@/components/auth/LoginArea';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { cn } from '@/lib/utils';
import { DorkThinking } from '@/components/DorkThinking';
import { useLayoutOptions } from '@/contexts/LayoutContext';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import type { ThemeConfig } from '@/themes';
// ─── Tool Definitions ───
/** Build the list of available bundled font names for the tool description. */
const AVAILABLE_FONTS = bundledFonts.map((f) => f.family).join(', ');
const TOOLS: ChatCompletionTool[] = [
{
type: 'function',
function: {
name: 'set_theme',
description: `Set a custom theme for the application. You can set colors, a font, and a background image — all in one call. Colors are required; font and background are optional.
Color values must be HSL strings WITHOUT the "hsl()" wrapper — just raw values like "228 20% 10%". Choose colors that work well together and ensure good contrast between background and text.
For fonts, choose from the available bundled fonts: ${AVAILABLE_FONTS}. Pick a font that matches the mood of the theme.
For backgrounds, provide a URL to a publicly accessible image. Choose images that complement the color scheme. Use mode "cover" for full-bleed backgrounds or "tile" for repeating patterns.`,
parameters: {
type: 'object' as const,
properties: {
background: {
type: 'string',
description: 'Background color as an HSL string (e.g. "228 20% 10%" for dark blue, "0 0% 100%" for white). This is the main page background.',
},
text: {
type: 'string',
description: 'Text/foreground color as an HSL string (e.g. "210 40% 98%" for near-white, "0 0% 10%" for near-black). Must contrast well with the background.',
},
primary: {
type: 'string',
description: 'Primary accent color as an HSL string (e.g. "258 70% 60%" for purple, "142 70% 45%" for green). Used for buttons, links, and interactive elements.',
},
font: {
type: 'string',
description: `Optional font family name. Must be one of the available bundled fonts: ${AVAILABLE_FONTS}. Choose a font that matches the theme's mood and aesthetic.`,
},
background_url: {
type: 'string',
description: 'Optional URL to a background image. Should be a direct link to a publicly accessible image file (JPEG, PNG, WebP, etc.).',
},
background_mode: {
type: 'string',
description: 'How to display the background image. "cover" fills the viewport (good for photos/landscapes). "tile" repeats the image (good for patterns/textures). Defaults to "cover".',
enum: ['cover', 'tile'],
},
},
required: ['background', 'text', 'primary'],
},
},
},
];
// ─── Message Types ───
interface DisplayMessage {
id: string;
role: 'user' | 'assistant' | 'system' | 'tool_result';
content: string;
timestamp: Date;
toolCalls?: ToolCall[];
}
interface ToolCall {
id: string;
name: string;
arguments: Record<string, unknown>;
result?: string;
}
// ─── Tool Executor Hook ───
/** Simple HSL format check: "H S% L%" where H is 0-360, S and L are 0-100%. */
function isValidHsl(value: unknown): value is string {
if (typeof value !== 'string') return false;
return /^\d{1,3}\s+\d{1,3}%\s+\d{1,3}%$/.test(value.trim());
}
function useToolExecutor() {
const { applyCustomTheme } = useTheme();
const executeToolCall = useCallback((name: string, args: Record<string, unknown>): string => {
switch (name) {
case 'set_theme': {
const { background, text, primary, font, background_url, background_mode } = args;
// Validate required color values
if (!isValidHsl(background) || !isValidHsl(text) || !isValidHsl(primary)) {
return JSON.stringify({
error: 'Invalid HSL color values. Each must be a string like "228 20% 10%".',
received: { background, text, primary },
});
}
// Build theme config
const themeConfig: ThemeConfig = {
colors: {
background: background as string,
text: text as string,
primary: primary as string,
},
};
// Add font if provided
if (typeof font === 'string' && font.trim()) {
const bundled = bundledFonts.find((f) => f.family.toLowerCase() === font.trim().toLowerCase());
if (bundled) {
themeConfig.font = { family: bundled.family };
} else {
return JSON.stringify({
error: `Unknown font "${font}". Available fonts: ${AVAILABLE_FONTS}`,
});
}
}
// Add background if provided (sanitize to prevent CSS injection via url())
if (typeof background_url === 'string' && background_url.trim()) {
const safeUrl = sanitizeUrl(background_url.trim());
if (safeUrl) {
themeConfig.background = {
url: safeUrl,
mode: background_mode === 'tile' ? 'tile' : 'cover',
};
}
}
applyCustomTheme(themeConfig);
// Build result summary
const result: Record<string, unknown> = {
success: true,
colors: { background, text, primary },
};
if (themeConfig.font) result.font = themeConfig.font.family;
if (themeConfig.background) result.background = { url: themeConfig.background.url, mode: themeConfig.background.mode };
return JSON.stringify(result);
}
default:
return JSON.stringify({ error: `Unknown tool: ${name}` });
}
}, [applyCustomTheme]);
return { executeToolCall };
}
// ─── System Prompt ───
const SYSTEM_PROMPT: ChatMessage = {
role: 'system',
content: `You are Dork, extraordinaire. You are an AI assistant integrated into Ditto, a Nostr social client. You can help users with questions, conversations, and tasks.
You have a set_theme tool that applies a full custom theme. It supports:
**Colors** (required): Three HSL values without the "hsl()" wrapper (e.g. "228 20% 10%"):
- background: page background color
- text: main text/foreground color (must contrast well with background)
- primary: accent color for buttons, links, and highlights
**Font** (optional): Choose from bundled fonts to match the theme's mood. Available: ${AVAILABLE_FONTS}
**Background image** (optional): A URL to a publicly accessible image. Set mode to "cover" for full-bleed or "tile" for repeating patterns.
When the user asks to change the theme, be creative — combine colors, fonts, and backgrounds to create a cohesive aesthetic. Always set colors. Add a font when it enhances the mood. Add a background image only when you have a suitable URL or the user requests one.
Be concise and friendly. When you use a tool, briefly describe the theme you created.`,
};
// ─── Page Component ───
export function AIChatPage() {
const { config } = useAppContext();
const { user } = useCurrentUser();
const { sendChatMessage, getAvailableModels, isLoading: apiLoading, error: apiError, clearError } = useShakespeare();
const { executeToolCall } = useToolExecutor();
const [messages, setMessages] = useState<DisplayMessage[]>([]);
const [input, setInput] = useState('');
const [isStreaming, setIsStreaming] = useState(false);
const [models, setModels] = useState<Model[]>([]);
const [selectedModel, setSelectedModel] = useState('');
const [modelsLoading, setModelsLoading] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const { buddy, isLoading: buddyLoading, hasBuddy } = useBuddy();
useSeoMeta({
title: `AI Chat | ${config.appName}`,
description: 'Chat with AI assistant',
title: `Buddy | ${config.appName}`,
description: 'Chat with your AI buddy',
});
useLayoutOptions({ noOverscroll: true });
// Scroll to bottom on new messages
const scrollToBottom = useCallback(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, []);
useEffect(() => {
scrollToBottom();
}, [messages, scrollToBottom]);
// Fetch available models on mount
useEffect(() => {
if (!user) return;
let cancelled = false;
setModelsLoading(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;
});
setModels(sorted);
if (sorted.length > 0 && !selectedModel) {
setSelectedModel(sorted[0].id);
}
})
.catch((err) => {
if (!cancelled) console.error('Failed to fetch models:', err);
})
.finally(() => {
if (!cancelled) setModelsLoading(false);
});
return () => { cancelled = true; };
}, [user, getAvailableModels]); // eslint-disable-line react-hooks/exhaustive-deps
// Build the chat messages array for the API (includes system prompt + conversation history)
const buildApiMessages = useCallback((displayMsgs: DisplayMessage[]): ChatMessage[] => {
const apiMessages: ChatMessage[] = [SYSTEM_PROMPT];
for (const msg of displayMsgs) {
if (msg.role === 'tool_result') continue; // Tool results are internal
apiMessages.push({ role: msg.role as 'user' | 'assistant' | 'system', content: msg.content });
}
return apiMessages;
}, []);
// Handle sending a message
const handleSend = useCallback(async () => {
const trimmed = input.trim();
if (!trimmed || !selectedModel || isStreaming) return;
clearError();
setInput('');
const userMessage: DisplayMessage = {
id: crypto.randomUUID(),
role: 'user',
content: trimmed,
timestamp: new Date(),
};
const newMessages = [...messages, userMessage];
setMessages(newMessages);
setIsStreaming(true);
try {
// Build API messages
const apiMessages = buildApiMessages(newMessages);
// Send with tools
const response = await sendChatMessage(apiMessages, selectedModel, {
tools: TOOLS,
});
const choice = response.choices[0];
const assistantMsg = choice.message;
if (assistantMsg.tool_calls && assistantMsg.tool_calls.length > 0) {
// Execute tool calls
const toolCalls: ToolCall[] = assistantMsg.tool_calls.map((tc) => {
let args: Record<string, unknown> = {};
try {
args = JSON.parse(tc.function.arguments);
} catch {
// If parsing fails, pass empty args
}
const result = executeToolCall(tc.function.name, args);
return {
id: tc.id,
name: tc.function.name,
arguments: args,
result,
};
});
// Add assistant message with tool calls noted
const toolMsg: DisplayMessage = {
id: crypto.randomUUID(),
role: 'assistant',
content: assistantMsg.content || '',
timestamp: new Date(),
toolCalls,
};
const messagesWithTool = [...newMessages, toolMsg];
setMessages(messagesWithTool);
// Build follow-up messages including tool results
const followUpMessages: ChatMessage[] = buildApiMessages(newMessages);
// Add the assistant message with tool_calls
followUpMessages.push({
role: 'assistant',
content: assistantMsg.content || '',
});
// Add tool results
for (const tc of toolCalls) {
followUpMessages.push({
role: 'user' as const,
content: `[Tool "${tc.name}" returned: ${tc.result}]`,
});
}
// Get follow-up response from AI
const followUp = await sendChatMessage(followUpMessages, selectedModel);
const followUpContent = followUp.choices[0]?.message?.content;
if (followUpContent) {
const followUpMsg: DisplayMessage = {
id: crypto.randomUUID(),
role: 'assistant',
content: typeof followUpContent === 'string' ? followUpContent : '',
timestamp: new Date(),
};
setMessages((prev) => [...prev, followUpMsg]);
}
} else {
// Normal response without tool calls
const content = typeof assistantMsg.content === 'string' ? assistantMsg.content : '';
const assistantMessage: DisplayMessage = {
id: crypto.randomUUID(),
role: 'assistant',
content,
timestamp: new Date(),
};
setMessages((prev) => [...prev, assistantMessage]);
}
} catch (err) {
console.error('Chat error:', err);
} finally {
setIsStreaming(false);
}
}, [input, selectedModel, isStreaming, messages, buildApiMessages, sendChatMessage, executeToolCall, clearError]);
// Handle keyboard shortcuts
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
}, [handleSend]);
// Clear conversation
const handleClear = useCallback(() => {
setMessages([]);
clearError();
}, [clearError]);
// ─── Render ───
if (!user) {
return (
<main className="flex flex-col items-center justify-center p-6 gap-6">
@@ -401,7 +38,7 @@ export function AIChatPage() {
<div className="size-16 rounded-2xl bg-primary/10 flex items-center justify-center">
<Bot className="size-8 text-primary" />
</div>
<h1 className="text-2xl font-bold">AI Chat</h1>
<h1 className="text-2xl font-bold">Buddy</h1>
<p className="text-muted-foreground">Log in with your Nostr account to start chatting with AI.</p>
<LoginArea className="mt-2" />
</div>
@@ -409,42 +46,50 @@ export function AIChatPage() {
);
}
if (buddyLoading) {
return (
<main className="flex flex-col overflow-hidden ai-chat-height sidebar:h-dvh bg-secondary/50">
<PageHeader title="Buddy" icon={<Bot className="size-5" />} className="shrink-0 py-3" />
<div className="flex-1 flex items-center justify-center">
<BuddyThinking />
</div>
</main>
);
}
if (!hasBuddy) {
return (
<main className="flex flex-col overflow-hidden ai-chat-height sidebar:h-dvh bg-secondary/50">
<PageHeader title="Buddy Setup" icon={<Bot className="size-5" />} className="shrink-0 py-3" />
<BuddyOnboarding className="flex-1" />
</main>
);
}
return <BuddyChatView buddy={buddy!} />;
}
// ─── Chat View (buddy exists) ───
import type { BuddyIdentity } from '@/hooks/useBuddy';
function BuddyChatView({ buddy }: { buddy: BuddyIdentity }) {
const {
messages, input, setInput, isStreaming, streamingText, selectedModel,
apiLoading, apiError, messagesEndRef,
handleSend, handleStop, handleKeyDown, handleClear, getCredits,
} = useAIChatSession({ buddyName: buddy.name, buddySoul: buddy.soul });
return (
<main className="flex flex-col ai-chat-height sidebar:h-dvh bg-secondary/50">
<main className="flex flex-col overflow-hidden ai-chat-height sidebar:h-dvh bg-secondary/50">
{/* Header */}
<div className="shrink-0 px-4 py-3 flex flex-col sidebar:flex-row sidebar:items-center sidebar:justify-between gap-2 sidebar:gap-3">
<PageHeader title="AI Chat" icon={<Bot className="size-5" />} className="px-0 mt-0 mb-0" />
<PageHeader title={buddy.name} icon={<Bot className="size-5" />} className="shrink-0 py-3">
<div className="flex items-center gap-2">
{/* Model selector */}
<Select value={selectedModel} onValueChange={setSelectedModel} disabled={modelsLoading}>
<SelectTrigger className="w-full sidebar:w-44 h-8 text-base md:text-xs">
<SelectValue placeholder={modelsLoading ? 'Loading models...' : 'Select model'} />
</SelectTrigger>
<SelectContent>
{models.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>
<CreditsBadge getCredits={getCredits} />
<Button
variant="ghost"
size="icon"
className="size-8"
className="size-8 shrink-0"
onClick={handleClear}
disabled={messages.length === 0}
title="Clear conversation"
@@ -452,22 +97,26 @@ export function AIChatPage() {
<Trash2 className="size-4" />
</Button>
</div>
</div>
</PageHeader>
{/* Messages Area */}
<ScrollArea className="flex-1" ref={scrollRef}>
<ScrollArea className="flex-1">
<div className="max-w-2xl mx-auto px-4 py-6 space-y-6">
{messages.length === 0 ? (
<EmptyState />
<EmptyState buddyName={buddy.name} onSuggestion={handleSend} />
) : (
messages.map((msg) => (
messages.filter((msg) => msg.role !== 'tool_result').map((msg) => (
<MessageBubble key={msg.id} message={msg} />
))
)}
{/* Loading indicator */}
{(isStreaming || apiLoading) && messages[messages.length - 1]?.role === 'user' && (
<DorkThinking className="text-sm" />
{/* Streaming / loading indicator */}
{(isStreaming || apiLoading) && (
streamingText ? (
<MessageBubble message={{ id: 'streaming', role: 'assistant', content: streamingText, timestamp: new Date() }} />
) : messages[messages.length - 1]?.role === 'user' ? (
<BuddyThinking />
) : null
)}
{/* Error display */}
@@ -485,23 +134,33 @@ export function AIChatPage() {
<div className="shrink-0 p-4">
<div className="max-w-2xl mx-auto flex items-end gap-2">
<Textarea
ref={textareaRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={!selectedModel ? 'Select a model first...' : 'Send a message...'}
placeholder={!selectedModel ? 'Select a model first...' : `Message ${buddy.name}...`}
disabled={!selectedModel || isStreaming}
className="min-h-[44px] max-h-40 resize-none bg-secondary/50 border-border focus-visible:ring-1"
rows={1}
/>
<Button
onClick={handleSend}
disabled={!input.trim() || !selectedModel || isStreaming}
size="icon"
className="size-11 shrink-0 rounded-xl"
>
<Send className="size-4" />
</Button>
{isStreaming ? (
<Button
onClick={handleStop}
variant="ghost"
size="icon"
className="size-11 shrink-0 rounded-full bg-foreground/10 hover:bg-foreground/20 [&_svg]:fill-foreground"
>
<Square className="size-3.5" />
</Button>
) : (
<Button
onClick={() => handleSend()}
disabled={!input.trim() || !selectedModel}
size="icon"
className="size-11 shrink-0 rounded-xl"
>
<Send className="size-4" />
</Button>
)}
</div>
</div>
</main>
@@ -510,120 +169,59 @@ export function AIChatPage() {
// ─── Sub-Components ───
// DorkThinking is imported from the shared component
const DORK_GREETINGS = [
"Hi, I'm Dork! What would you like me to do?",
"Dork here! What do you need?",
"Hey, it's Dork! What do you want to do?",
const SUGGESTIONS = [
'Create a feed of Alex Gleason talking about being Vegan',
'Make a feed of the team soapbox follow pack talking about ditto',
];
function EmptyState() {
const greeting = useMemo(() => DORK_GREETINGS[Math.floor(Math.random() * DORK_GREETINGS.length)], []);
function greetings(name: string): string[] {
return [
`Hi, I'm ${name}! What would you like me to do?`,
`${name} here! What do you need?`,
`Hey, it's ${name}! What do you want to do?`,
];
}
function EmptyState({ buddyName, onSuggestion }: { buddyName: string; onSuggestion: (text: string) => void }) {
const greeting = useMemo(() => {
const g = greetings(buddyName);
return g[Math.floor(Math.random() * g.length)];
}, [buddyName]);
return (
<div className="flex flex-col items-center justify-center py-20 gap-8 text-center select-none animate-in fade-in duration-500">
<div className="flex flex-col items-center justify-center py-12 gap-4 text-center select-none animate-in fade-in duration-500">
<pre className="text-4xl font-mono text-primary leading-none">{'<[o_o]>'}</pre>
<div className="space-y-2">
<h2 className="text-base font-semibold tracking-tight text-foreground">Dork AI</h2>
<p className="text-sm text-muted-foreground">{greeting}</p>
<p className="text-sm text-muted-foreground">{greeting}</p>
<div className="flex flex-col gap-2 w-full max-w-sm mt-2">
{SUGGESTIONS.map((text) => (
<button
key={text}
onClick={() => onSuggestion(text)}
className="px-4 py-2.5 rounded-xl border border-border bg-secondary/40 hover:bg-secondary/70 text-sm text-left text-foreground/80 transition-colors"
>
{text}
</button>
))}
</div>
</div>
);
}
function CreditsBadge({ getCredits }: { getCredits: () => Promise<{ amount: number }> }) {
const { data, isLoading } = useQuery({
queryKey: ['shakespeare-credits'],
queryFn: getCredits,
refetchInterval: 30_000,
staleTime: 10_000,
});
function MessageBubble({ message }: { message: DisplayMessage }) {
const isUser = message.role === 'user';
const formatted = data?.amount != null
? new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(data.amount)
: null;
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')}>
<div
className={cn(
'rounded-2xl px-4 py-2.5 text-sm',
isUser
? 'bg-primary text-primary-foreground rounded-tr-md'
: 'bg-secondary/60 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 text-foreground prose-headings:text-foreground prose-strong:text-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-a:text-primary">
<Markdown rehypePlugins={[rehypeSanitize]}>
{message.content}
</Markdown>
</div>
)}
</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>
);
}
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>
<Badge variant="secondary" className="text-xs tabular-nums shrink-0">
{isLoading ? '...' : formatted ?? '--'}
</Badge>
);
}
+1 -26
View File
@@ -27,6 +27,7 @@ import { BadgeContent } from "@/components/BadgeContent";
import { parseBadgeDefinition, type BadgeData } from "@/lib/parseBadgeDefinition";
import { BadgeRecoveryDialog } from "@/components/BadgeRecoveryDialog";
import { BadgeThumbnail } from "@/components/BadgeThumbnail";
import { NoteCardSkeleton } from "@/components/NoteCardSkeleton";
import { CreateBadgeDialog } from "@/components/CreateBadgeDialog";
import { FeedEmptyState } from "@/components/FeedEmptyState";
import { NoteCard } from "@/components/NoteCard";
@@ -92,32 +93,6 @@ interface ParsedBadge {
aTag: string;
}
// ─── NoteCard Skeleton ─────────────────────────────────────────────────────────
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>
);
}
// ─── Page ──────────────────────────────────────────────────────────────────────
export function BadgesPage() {
+2 -2
View File
@@ -3,7 +3,7 @@ import { createPortal } from 'react-dom';
import { Link, useNavigate } from 'react-router-dom';
import { useSeoMeta } from '@unhead/react';
import { nip19 } from 'nostr-tools';
import { Egg, Moon, Sun, RefreshCw, Check, Plus, Camera, Footprints, Wrench, Theater, ExternalLink, Utensils, Gamepad2, Sparkles, Pill, Music, Mic, Loader2, Target, Droplets, Heart, Zap, Refrigerator, ShowerHead, Candy, Shovel, TowelRack, X } from 'lucide-react';
import { Egg, Moon, Sun, RefreshCw, Check, Plus, Camera, Footprints, Wrench, Theater, ExternalLink, Utensils, Gamepad2, Sparkles, Pill, Music, Mic, Loader2, Target, Droplets, Heart, Zap, Refrigerator, ShowerHead, Candy, Shovel, Bath, X } from 'lucide-react';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useProjectedBlobbiState } from '@/blobbi/core/hooks/useProjectedBlobbiState';
@@ -2028,7 +2028,7 @@ function CareBar({
{isHygieneFocused ? (
towelItem ? (
<RoomActionButton
icon={<TowelRack className="size-7 sm:size-9" />}
icon={<Bath className="size-7 sm:size-9" />}
label="Towel"
color="text-cyan-500"
glowHex="#06b6d4"
+1
View File
@@ -10,6 +10,7 @@ import Index from './Index';
const PAGE_LOADERS: Record<string, React.LazyExoticComponent<React.ComponentType>> = {
'notifications': lazy(() => import('./NotificationsPage').then(m => ({ default: m.NotificationsPage }))),
'search': lazy(() => import('./SearchPage').then(m => ({ default: m.SearchPage }))),
'discover': lazy(() => import('./SearchPage').then(m => ({ default: m.SearchPage }))),
'trends': lazy(() => import('./TrendsPage').then(m => ({ default: m.TrendsPage }))),
'bookmarks': lazy(() => import('./BookmarksPage').then(m => ({ default: m.BookmarksPage }))),
'settings': lazy(() => import('./SettingsPage').then(m => ({ default: m.SettingsPage }))),
+8 -2
View File
@@ -2,11 +2,14 @@ import { nip19 } from 'nostr-tools';
import { useParams } from 'react-router-dom';
import { useNostr } from '@nostrify/react';
import { useQuery } from '@tanstack/react-query';
import { lazy } from 'react';
import NotFound from './NotFound';
import { ProfilePage } from './ProfilePage';
import { PostDetailPage, AddrPostDetailPage, PostDetailShell, PostDetailSkeleton } from './PostDetailPage';
import { ListDetailPage } from './ListDetailPage';
import type { AddressPointer } from 'nostr-tools/nip19';
import type { AddressPointer, EventPointer } from 'nostr-tools/nip19';
const SpellRunPage = lazy(() => import('./SpellRunPage').then(m => ({ default: m.SpellRunPage })));
const HEX_64_RE = /^[0-9a-f]{64}$/;
@@ -107,7 +110,10 @@ export function NIP19Page() {
return <PostDetailPage eventId={decoded.data as string} />;
case 'nevent': {
const neventData = decoded.data as { id: string; relays?: string[]; author?: string };
const neventData = decoded.data as EventPointer;
if (neventData.kind === 777) {
return <SpellRunPage />;
}
return <PostDetailPage eventId={neventData.id} relays={neventData.relays} authorHint={neventData.author} />;
}
+23 -23
View File
@@ -52,7 +52,7 @@ import { RepostIcon } from "@/components/icons/RepostIcon";
import { LiveStreamPage } from "@/components/LiveStreamPage";
import { MagicDeckContent } from "@/components/MagicDeckContent";
import { MusicDetailContent } from "@/components/MusicDetailContent";
import { ActivityCard, EventActionHeader, NoteCard } from "@/components/NoteCard";
import { EventActionHeader, NoteCard } from "@/components/NoteCard";
import { publishedAtAction } from "@/lib/publishedAtAction";
import { NoteContent } from "@/components/NoteContent";
import { NsiteCard } from "@/components/NsiteCard";
@@ -1942,32 +1942,32 @@ function PostDetailContent({ event }: { event: NostrEvent }) {
{/* Kind 1018 — Poll vote: compact activity-style card */}
{isPollVote && (
<div ref={focusedPostRef as React.RefObject<HTMLDivElement>}>
<ActivityCard
className="border-b-0 pb-0"
icon={
<ProfileHoverCard pubkey={event.pubkey} asChild>
<Link to={profileUrl} className="shrink-0">
<Avatar shape={avatarShape} className="size-10">
<AvatarImage src={metadata?.picture} alt={displayName} />
<AvatarFallback className="bg-primary/20 text-primary text-sm">{displayName[0]?.toUpperCase()}</AvatarFallback>
</Avatar>
</Link>
</ProfileHoverCard>
}
actorRow={
<div className="flex items-center gap-1.5">
<article className="px-4 py-3 border-b-0 pb-0 overflow-hidden">
<div className="flex gap-3">
<div className="flex flex-col items-center">
<ProfileHoverCard pubkey={event.pubkey} asChild>
<Link to={profileUrl} className="font-bold text-sm hover:underline truncate">
{author.data?.event ? <EmojifiedText tags={author.data.event.tags}>{displayName}</EmojifiedText> : displayName}
<Link to={profileUrl} className="shrink-0">
<Avatar shape={avatarShape} className="size-10">
<AvatarImage src={metadata?.picture} alt={displayName} />
<AvatarFallback className="bg-primary/20 text-primary text-sm">{displayName[0]?.toUpperCase()}</AvatarFallback>
</Avatar>
</Link>
</ProfileHoverCard>
<span className="text-sm text-muted-foreground shrink-0">voted</span>
<span className="text-xs text-muted-foreground ml-auto shrink-0">{formatFullDate(event.created_at)}</span>
</div>
}
>
{pollVoteLabel && <p className="text-sm font-semibold mt-0.5 truncate">{pollVoteLabel}</p>}
</ActivityCard>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<ProfileHoverCard pubkey={event.pubkey} asChild>
<Link to={profileUrl} className="font-bold text-sm hover:underline truncate">
{author.data?.event ? <EmojifiedText tags={author.data.event.tags}>{displayName}</EmojifiedText> : displayName}
</Link>
</ProfileHoverCard>
<span className="text-sm text-muted-foreground shrink-0">voted</span>
<span className="text-xs text-muted-foreground ml-auto shrink-0">{formatFullDate(event.created_at)}</span>
</div>
{pollVoteLabel && <p className="text-sm font-semibold mt-0.5 truncate">{pollVoteLabel}</p>}
</div>
</div>
</article>
<PostActionBar
event={event}
onReply={() => setReplyOpen(true)}
+90 -5
View File
@@ -36,7 +36,7 @@ import { usePinnedNotes } from '@/hooks/usePinnedNotes';
import { useFollowList, useFollowActions } from '@/hooks/useFollowActions';
import { useMuteList } from '@/hooks/useMuteList';
import { isEventMuted } from '@/lib/muteHelpers';
import { useProfileFeed, useProfileLikes as useProfileLikesInfinite, useTabFeed, filterByTab } from '@/hooks/useProfileFeed';
import { useProfileFeed, useProfileLikes as useProfileLikesInfinite, filterByTab } from '@/hooks/useProfileFeed';
import type { ProfileTab as CoreProfileTab } from '@/hooks/useProfileFeed';
import { useProfileMedia } from '@/hooks/useProfileMedia';
import { MediaCollage, MediaCollageSkeleton } from '@/components/MediaCollage';
@@ -78,6 +78,9 @@ import { useProfileBadges } from '@/hooks/useProfileBadges';
import { useBadgeDefinitions } from '@/hooks/useBadgeDefinitions';
import { ProfileTabEditModal } from '@/components/ProfileTabEditModal';
import { useResolveTabFilter } from '@/hooks/useResolveTabFilter';
import { useStreamPosts } from '@/hooks/useStreamPosts';
import { useTabFeed } from '@/hooks/useProfileFeed';
import { buildUnsignedSpell } from '@/lib/spellEngine';
import type { ProfileTab, ProfileTabsData, TabFilter, TabVarDef } from '@/lib/profileTabsEvent';
import {
DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors,
@@ -1208,7 +1211,6 @@ type EditableTab = { label: string; isCore: boolean; tab?: ProfileTab };
};
// Canonical NIP-01 filters for core tabs so other clients can interpret the event.
// Values are interpolated with the actual pubkey (not $me) since these are concrete filters.
const CORE_TAB_FILTERS: Record<string, TabFilter> = pubkey ? {
'Posts': { kinds: [1, 6], authors: [pubkey] },
'Posts & replies': { authors: [pubkey] },
@@ -3045,6 +3047,87 @@ function ProfileSavedFeedContent({ feed, vars, ownerPubkey }: {
feed: ProfileTab;
vars: TabVarDef[];
ownerPubkey: string;
}) {
// If the filter carries _spellTags, render via spell mode (same as SpellRunPage)
const rawFilter = feed.filter as Record<string, unknown>;
const spellTags = Array.isArray(rawFilter._spellTags) ? rawFilter._spellTags as string[][] : null;
if (spellTags) {
return <ProfileSpellFeedContent label={feed.label} spellTags={spellTags} />;
}
return <ProfileLegacyFeedContent feed={feed} vars={vars} ownerPubkey={ownerPubkey} />;
}
/** Spell-driven profile tab: reconstructs a spell from stored tags and streams results. */
function ProfileSpellFeedContent({ label, spellTags }: { label: string; spellTags: string[][] }) {
const spellEvent = useMemo(() => buildUnsignedSpell(spellTags), [spellTags]);
const { ref: tabScrollRef, inView: tabInView } = useInView({ threshold: 0, rootMargin: '400px' });
const { posts, isLoading, loadMore, hasMore, isLoadingMore } = useStreamPosts('', {
includeReplies: true,
mediaType: 'all',
spell: spellEvent,
});
useEffect(() => {
if (tabInView && hasMore && !isLoadingMore) {
loadMore();
}
}, [tabInView, hasMore, isLoadingMore, loadMore]);
if (isLoading && posts.length === 0) {
return (
<div className="space-y-0">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="px-4 py-3 border-b border-border">
<div className="flex gap-3">
<Skeleton className="size-11 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-48" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
</div>
</div>
</div>
))}
</div>
);
}
if (posts.length === 0) {
return (
<div className="py-12 text-center text-muted-foreground text-sm">
No posts found for &ldquo;{label}&rdquo;.
</div>
);
}
return (
<div>
{posts.map((event) => (
<NoteCard key={event.id} event={event} />
))}
{hasMore && (
<div ref={tabScrollRef} className="py-4">
{isLoadingMore && (
<div className="flex justify-center">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
)}
</div>
)}
</div>
);
}
/** Legacy profile tab without spell tags: resolves filter variables and queries directly. */
function ProfileLegacyFeedContent({ feed, vars, ownerPubkey }: {
feed: ProfileTab;
vars: TabVarDef[];
ownerPubkey: string;
}) {
const { filter: resolvedFilter, isLoading: isResolving } = useResolveTabFilter(feed.filter, vars, ownerPubkey);
@@ -3104,7 +3187,7 @@ function ProfileSavedFeedContent({ feed, vars, ownerPubkey }: {
if (items.length === 0) {
return (
<div className="py-12 text-center text-muted-foreground text-sm">
No posts found for "{feed.label}".
No posts found for &ldquo;{feed.label}&rdquo;.
</div>
);
}
@@ -3120,9 +3203,11 @@ function ProfileSavedFeedContent({ feed, vars, ownerPubkey }: {
))}
{hasNextPage && (
<div ref={tabScrollRef} className="flex justify-center py-6">
<div ref={tabScrollRef} className="py-4">
{isFetchingNextPage && (
<Loader2 className="size-5 animate-spin text-muted-foreground" />
<div className="flex justify-center">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
)}
</div>
)}
+469 -206
View File
@@ -2,6 +2,7 @@ import { useSeoMeta } from '@unhead/react';
import { useAppContext } from '@/hooks/useAppContext';
import {
SlidersHorizontal,
Compass,
Search as SearchIcon,
UserRoundCheck,
User,
@@ -9,13 +10,16 @@ import {
BookmarkPlus,
Check,
Loader2,
PanelLeft,
Globe, Users, UserSearch,
Clock, Flame, TrendingUp,
Share2,
} from 'lucide-react';
import { useState, useMemo, useEffect, useCallback, useRef } from 'react';
import { useInView } from 'react-intersection-observer';
import { Link, useSearchParams } from 'react-router-dom';
import { NoteCard } from '@/components/NoteCard';
import { NoteCardSkeleton } from '@/components/NoteCardSkeleton';
import { PullToRefresh } from '@/components/PullToRefresh';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { getAvatarShape } from '@/lib/avatarShape';
@@ -35,11 +39,15 @@ import { KindPicker, AuthorChip, AuthorFilterDropdown } from '@/components/Saved
import { useSearchProfiles } from '@/hooks/useSearchProfiles';
import { useAuthor } from '@/hooks/useAuthor';
import { useStreamPosts } from '@/hooks/useStreamPosts';
import { useFeedSettings } from '@/hooks/useFeedSettings';
import { useSavedFeeds } from '@/hooks/useSavedFeeds';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useProfileTabs } from '@/hooks/useProfileTabs';
import { usePublishProfileTabs } from '@/hooks/usePublishProfileTabs';
import { useFollowList } from '@/hooks/useFollowActions';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useToast } from '@/hooks/useToast';
import { useUserLists, useMatchedListId } from '@/hooks/useUserLists';
import { useFollowPacks } from '@/hooks/useFollowPacks';
@@ -51,18 +59,20 @@ import { SubHeaderBar } from '@/components/SubHeaderBar';
import { TabButton } from '@/components/TabButton';
import { ARC_OVERHANG_PX } from '@/components/ArcBackground';
import { cn, parseKindFilter } from '@/lib/utils';
import type { TabFilter } from '@/contexts/AppContext';
import { shareOrCopy } from '@/lib/share';
import { buildSpellTags } from '@/lib/spellEngine';
import { useLayoutOptions, useNavHidden } from '@/contexts/LayoutContext';
import { PageHeader } from '@/components/PageHeader';
import { SaveDestinationRow } from '@/components/SaveDestinationRow';
import { isRepostKind, parseRepostContent } from '@/lib/feedUtils';
import { nip19 } from 'nostr-tools';
type TabType = 'posts' | 'accounts';
type TabType = 'feeds' | 'packs' | 'posts' | 'accounts';
const VALID_TABS: TabType[] = ['posts', 'accounts'];
const VALID_TABS: TabType[] = ['feeds', 'packs', 'posts', 'accounts'];
function parseTab(value: string | null): TabType {
return VALID_TABS.includes(value as TabType) ? (value as TabType) : 'posts';
return VALID_TABS.includes(value as TabType) ? (value as TabType) : 'feeds';
}
const VALID_AUTHOR_SCOPES = ['anyone', 'follows', 'people'] as const;
@@ -92,8 +102,8 @@ export function SearchPage() {
const { config } = useAppContext();
useSeoMeta({
title: `Search | ${config.appName}`,
description: 'Search Nostr',
title: `Discover | ${config.appName}`,
description: 'Discover feeds, posts, and accounts on Nostr',
});
useLayoutOptions({ hasSubHeader: true });
@@ -199,7 +209,7 @@ export function SearchPage() {
const setActiveTab = useCallback((tab: TabType) => {
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
if (tab === 'posts') {
if (tab === 'feeds') {
next.delete('tab');
} else {
next.set('tab', tab);
@@ -346,36 +356,90 @@ export function SearchPage() {
const { lists } = useUserLists();
const { data: followPacks = [] } = useFollowPacks();
const { savedFeeds, addSavedFeed, isPending: isSavingFeed } = useSavedFeeds();
const { addToSidebar } = useFeedSettings();
const profileTabsQuery = useProfileTabs(user?.pubkey);
const { publishProfileTabs, isPending: isPublishingTabs } = usePublishProfileTabs();
const { mutateAsync: publishEvent } = useNostrPublish();
const { toast } = useToast();
const [savePopoverOpen, setSavePopoverOpen] = useState(false);
const [saveFeedLabel, setSaveFeedLabel] = useState('');
const [savedJustNow, setSavedJustNow] = useState(false);
const [isSharing, setIsSharing] = useState(false);
const [isAddingToSidebar, setIsAddingToSidebar] = useState(false);
const listPickerValue = useMatchedListId(authorPubkeys);
// Infinite scroll sentinels
const { ref: postsScrollRef, inView: postsInView } = useInView({ threshold: 0, rootMargin: '400px' });
const { ref: feedsScrollRef, inView: feedsInView } = useInView({ threshold: 0, rootMargin: '400px' });
const { ref: packsScrollRef, inView: packsInView } = useInView({ threshold: 0, rootMargin: '400px' });
// 'people' scope with explicit authors = user-specific; not eligible for profile tab
const isAuthorSpecific = authorScope === 'people' && authorPubkeys.length > 0;
// Build a standard NIP-01 TabFilter from the current search state
const currentFilter = useMemo<TabFilter>(() => {
const filter: TabFilter = {};
// Build spell tags from the current search state
const currentSpellTags = useMemo(() => {
let authors: string[] | undefined;
if (authorScope === 'follows') authors = ['$contacts'];
else if (authorScope === 'people' && authorPubkeys.length > 0) authors = authorPubkeys;
return buildSpellTags({
name: saveFeedLabel.trim() || 'Search',
kinds: kindsOverride && kindsOverride.length > 0 ? kindsOverride : undefined,
authors,
search: debouncedSearchQuery.trim() || undefined,
includeReplies: includeReplies ? undefined : false,
media: mediaType !== 'all' ? mediaType : undefined,
language: language !== 'global' ? language : undefined,
platform: platform !== 'nostr' ? platform : undefined,
sort: sort !== 'recent' ? sort : undefined,
});
}, [debouncedSearchQuery, kindsOverride, authorScope, authorPubkeys, includeReplies, mediaType, language, platform, sort, saveFeedLabel]);
// Build the current filter from the search state (for saving).
// Includes client-hint fields (_media, _language, _platform, _sort,
// _includeReplies) so SavedFeedContent can faithfully reproduce the query.
const currentFilter = useMemo(() => {
const filter: Record<string, unknown> = {};
if (debouncedSearchQuery.trim()) filter.search = debouncedSearchQuery.trim();
if (kindsOverride && kindsOverride.length > 0) filter.kinds = kindsOverride;
if (authorScope === 'people' && authorPubkeys.length > 0) filter.authors = authorPubkeys;
if (authorScope === 'follows') filter.authors = ['$follows'];
else if (authorScope === 'people' && authorPubkeys.length > 0) filter.authors = authorPubkeys;
// Persist client-hint fields so saved tabs reproduce the full query
if (mediaType !== 'all') filter._media = mediaType;
if (language !== 'global') filter._language = language;
if (platform !== 'nostr') filter._platform = platform;
if (sort !== 'recent') filter._sort = sort;
if (!includeReplies) filter._includeReplies = false;
return filter;
}, [debouncedSearchQuery, kindsOverride, authorScope, authorPubkeys]);
}, [debouncedSearchQuery, kindsOverride, authorScope, authorPubkeys, mediaType, language, platform, sort, includeReplies]);
const alreadySaved = savedFeeds.some(
(f) => JSON.stringify(f.filter) === JSON.stringify(currentFilter),
);
const currentFilterKey = useMemo(() => JSON.stringify(currentFilter), [currentFilter]);
const alreadySaved = savedFeeds.some((f) => JSON.stringify(f.filter) === currentFilterKey);
const handleSaveFeed = async () => {
if (!saveFeedLabel.trim() || isSavingFeed) return;
const varsToSave = authorScope === 'follows' && user
? [{ name: '$follows', tagName: 'p', pointer: `a:3:${user.pubkey}:` }]
: [];
await addSavedFeed(saveFeedLabel, currentFilter, varsToSave);
if (!saveFeedLabel.trim() || isSavingFeed || !user) return;
const vars: import('@/lib/profileTabsEvent').TabVarDef[] = [];
if (authorScope === 'follows' && user) {
vars.push({ name: '$follows', tagName: 'p', pointer: `a:3:${user.pubkey}:` });
}
// Publish a kind:777 spell event so the home feed can render it via
// useStreamPosts({ spell }), which handles full resolution of all filters.
try {
const tags = currentSpellTags.map(([t, ...rest]) =>
t === 'name' ? ['name', saveFeedLabel.trim()] :
t === 'alt' ? ['alt', `Spell: ${saveFeedLabel.trim()}`] :
[t, ...rest]
);
const event = await publishEvent({ kind: 777, content: '', tags, created_at: Math.floor(Date.now() / 1000) });
await addSavedFeed(saveFeedLabel.trim(), currentFilter, vars, event.id);
} catch (err) {
toast({ title: 'Failed to save feed', description: err instanceof Error ? err.message : undefined, variant: 'destructive' });
return;
}
setSavePopoverOpen(false);
setSaveFeedLabel('');
setSavedJustNow(true);
@@ -384,9 +448,20 @@ export function SearchPage() {
const handleSaveProfileTab = async () => {
if (!saveFeedLabel.trim() || isPublishingTabs || !user) return;
// Store spell tags in the filter so ProfileSavedFeedContent can reconstruct
// the spell and render via useStreamPosts({ spell }), preserving all filters.
const tags = currentSpellTags.map(([t, ...rest]) =>
t === 'name' ? ['name', saveFeedLabel.trim()] :
t === 'alt' ? ['alt', `Spell: ${saveFeedLabel.trim()}`] :
[t, ...rest]
);
const tabFilter: Record<string, unknown> = { _spellTags: tags };
const existing = profileTabsQuery.data ?? { tabs: [], vars: [] };
await publishProfileTabs({
tabs: [...existing.tabs, { label: saveFeedLabel.trim(), filter: currentFilter }],
tabs: [...existing.tabs, { label: saveFeedLabel.trim(), filter: tabFilter }],
vars: existing.vars,
});
setSavePopoverOpen(false);
@@ -395,6 +470,55 @@ export function SearchPage() {
setTimeout(() => setSavedJustNow(false), 2000);
};
const handleShareSpell = async () => {
if (!saveFeedLabel.trim() || isSharing || !user) return;
setIsSharing(true);
try {
const tags = currentSpellTags.map(([t, ...rest]) =>
t === 'name' ? ['name', saveFeedLabel.trim()] :
t === 'alt' ? ['alt', `Spell: ${saveFeedLabel.trim()}`] :
[t, ...rest]
);
const event = await publishEvent({ kind: 777, content: '', tags, created_at: Math.floor(Date.now() / 1000) });
const neventId = nip19.neventEncode({ id: event.id, author: event.pubkey, kind: event.kind });
const url = `${window.location.origin}/${neventId}`;
const result = await shareOrCopy(url, saveFeedLabel.trim());
if (result === 'copied') {
toast({ title: 'Link copied to clipboard' });
}
setSavePopoverOpen(false);
setSaveFeedLabel('');
} catch (err) {
toast({ title: 'Failed to share spell', description: err instanceof Error ? err.message : undefined, variant: 'destructive' });
} finally {
setIsSharing(false);
}
};
const handleAddToSidebar = async () => {
if (!saveFeedLabel.trim() || isAddingToSidebar || !user) return;
setIsAddingToSidebar(true);
try {
const tags = currentSpellTags.map(([t, ...rest]) =>
t === 'name' ? ['name', saveFeedLabel.trim()] :
t === 'alt' ? ['alt', `Spell: ${saveFeedLabel.trim()}`] :
[t, ...rest]
);
const event = await publishEvent({ kind: 777, content: '', tags, created_at: Math.floor(Date.now() / 1000) });
const neventId = nip19.neventEncode({ id: event.id, author: event.pubkey, kind: event.kind });
addToSidebar(`nostr:${neventId}`);
setSavePopoverOpen(false);
setSaveFeedLabel('');
setSavedJustNow(true);
setTimeout(() => setSavedJustNow(false), 2000);
toast({ title: 'Added to sidebar' });
} catch (err) {
toast({ title: 'Failed to add to sidebar', description: err instanceof Error ? err.message : undefined, variant: 'destructive' });
} finally {
setIsAddingToSidebar(false);
}
};
// Resolve author pubkeys for the stream
const streamAuthorPubkeys = authorScope === 'follows'
? followPubkeys
@@ -402,7 +526,10 @@ export function SearchPage() {
? authorPubkeys
: undefined;
const { posts, isLoading: postsLoading, newPostCount, flushStreamBuffer, flushedIds } = useStreamPosts(debouncedSearchQuery, {
const {
posts, isLoading: postsLoading, newPostCount, flushStreamBuffer, flushedIds,
loadMore: loadMorePosts, hasMore: hasMorePosts, isLoadingMore: isLoadingMorePosts,
} = useStreamPosts(debouncedSearchQuery, {
includeReplies,
mediaType,
language,
@@ -413,15 +540,70 @@ export function SearchPage() {
});
const { data: profiles, isLoading: profilesLoading, followedPubkeys } = useSearchProfiles(activeTab === 'accounts' ? debouncedSearchQuery : '');
// Feeds tab: stream kind:777 spell events with From + Sort filters only
const {
posts: feedSpells,
isLoading: feedsLoading,
newPostCount: feedsNewCount,
flushStreamBuffer: flushFeedsBuffer,
flushedIds: feedsFlushedIds,
loadMore: loadMoreFeeds, hasMore: hasMoreFeeds, isLoadingMore: isLoadingMoreFeeds,
} = useStreamPosts(activeTab === 'feeds' ? debouncedSearchQuery : '', {
includeReplies: true,
mediaType: 'all',
kindsOverride: [777],
authorPubkeys: activeTab === 'feeds' ? streamAuthorPubkeys : undefined,
sort: activeTab === 'feeds' ? sort : 'recent',
});
// Packs tab: stream kind 39089/30000 with From + Sort filters
const {
posts: packPosts,
isLoading: packsLoading,
newPostCount: packsNewCount,
flushStreamBuffer: flushPacksBuffer,
flushedIds: packsFlushedIds,
loadMore: loadMorePacks,
hasMore: hasMorePacks,
isLoadingMore: isLoadingMorePacks,
} = useStreamPosts(activeTab === 'packs' ? debouncedSearchQuery : '', {
includeReplies: true,
mediaType: 'all',
kindsOverride: [39089, 30000],
authorPubkeys: activeTab === 'packs' ? streamAuthorPubkeys : undefined,
sort: activeTab === 'packs' ? sort : 'recent',
});
// Trigger infinite scroll when sentinels are visible
useEffect(() => {
if (postsInView && hasMorePosts && !isLoadingMorePosts) loadMorePosts();
}, [postsInView, hasMorePosts, isLoadingMorePosts, loadMorePosts]);
useEffect(() => {
if (feedsInView && hasMoreFeeds && !isLoadingMoreFeeds) loadMoreFeeds();
}, [feedsInView, hasMoreFeeds, isLoadingMoreFeeds, loadMoreFeeds]);
useEffect(() => {
if (packsInView && hasMorePacks && !isLoadingMorePacks) loadMorePacks();
}, [packsInView, hasMorePacks, isLoadingMorePacks, loadMorePacks]);
const handleRefresh = useCallback(async () => {
flushStreamBuffer();
if (activeTab === 'feeds') {
flushFeedsBuffer();
} else if (activeTab === 'packs') {
flushPacksBuffer();
} else {
flushStreamBuffer();
}
window.scrollTo({ top: 0, behavior: 'smooth' });
}, [flushStreamBuffer]);
}, [activeTab, flushStreamBuffer, flushFeedsBuffer, flushPacksBuffer]);
return (
<main className="flex-1 min-w-0">
<PageHeader title="Search" icon={<SearchIcon className="size-5" />} />
<PageHeader title="Discover" icon={<Compass className="size-5" />} />
<SubHeaderBar>
<TabButton label="Feeds" active={activeTab === 'feeds'} onClick={() => setActiveTab('feeds')} />
<TabButton label="Follow Packs" active={activeTab === 'packs'} onClick={() => setActiveTab('packs')} />
<TabButton label="Posts" active={activeTab === 'posts'} onClick={() => setActiveTab('posts')} />
<TabButton label="Accounts" active={activeTab === 'accounts'} onClick={() => setActiveTab('accounts')} />
</SubHeaderBar>
@@ -500,6 +682,22 @@ export function SearchPage() {
loading={isPublishingTabs}
/>
)}
<SaveDestinationRow
icon={<PanelLeft className="size-4 text-muted-foreground" />}
label="Sidebar"
description="Pin to your sidebar"
onClick={() => handleAddToSidebar()}
disabled={!saveFeedLabel.trim() || isSavingFeed || isPublishingTabs || isAddingToSidebar}
loading={isAddingToSidebar}
/>
<SaveDestinationRow
icon={<Share2 className="size-4 text-muted-foreground" />}
label="Share"
description="Publish and share a link"
onClick={() => handleShareSpell()}
disabled={!saveFeedLabel.trim() || isSavingFeed || isPublishingTabs || isSharing}
loading={isSharing}
/>
</div>
</>
)}
@@ -508,8 +706,8 @@ export function SearchPage() {
</div>
)}
{/* Filter popover (posts tab only) */}
{activeTab === 'posts' && (
{/* Filter popover (posts, feeds, and packs tabs) */}
{(activeTab === 'posts' || activeTab === 'feeds' || activeTab === 'packs') && (
<Popover open={filtersOpen} onOpenChange={setFiltersOpen}>
<PopoverTrigger asChild>
<button
@@ -629,89 +827,95 @@ export function SearchPage() {
))}
</div>
</div>
<Separator />
{/* Media + Protocol */}
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1.5">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide flex items-center gap-1">Media</span>
<Select value={mediaType} onValueChange={(v) => setMediaType(v)}>
<SelectTrigger className="w-full bg-secondary/50 h-8 text-base md:text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All</SelectItem>
<SelectItem value="images">Images</SelectItem>
<SelectItem value="videos">Videos</SelectItem>
<SelectItem value="vines">Shorts</SelectItem>
<SelectItem value="none">No media</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide flex items-center gap-1">Protocol <HelpTip faqId="vs-mastodon-bluesky" iconSize="size-3" /></span>
<Select value={platform} onValueChange={(v) => setPlatform(v)}>
<SelectTrigger className="w-full bg-secondary/50 h-8 text-base md:text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="nostr">Nostr</SelectItem>
<SelectItem value="activitypub">Mastodon</SelectItem>
<SelectItem value="atproto">Bluesky</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* Posts-only filters */}
{activeTab === 'posts' && (
<>
<Separator />
{/* Language + Kind */}
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1.5">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide flex items-center gap-1">Language</span>
<Select value={language} onValueChange={(v) => setLanguage(v)}>
<SelectTrigger className="w-full bg-secondary/50 h-8 text-base md:text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="global">Global</SelectItem>
<SelectItem value="en">English</SelectItem>
<SelectItem value="es">Spanish</SelectItem>
<SelectItem value="fr">French</SelectItem>
<SelectItem value="de">German</SelectItem>
<SelectItem value="ja">Japanese</SelectItem>
<SelectItem value="zh">Chinese</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide flex items-center gap-1">Kind</span>
<KindPicker value={kindFilter} options={kindOptions} onChange={(v) => setKindFilter(v)} />
</div>
</div>
{/* Media + Protocol */}
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1.5">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide flex items-center gap-1">Media</span>
<Select value={mediaType} onValueChange={(v) => setMediaType(v)}>
<SelectTrigger className="w-full bg-secondary/50 h-8 text-base md:text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All</SelectItem>
<SelectItem value="images">Images</SelectItem>
<SelectItem value="videos">Videos</SelectItem>
<SelectItem value="vines">Shorts</SelectItem>
<SelectItem value="none">No media</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide flex items-center gap-1">Protocol <HelpTip faqId="vs-mastodon-bluesky" iconSize="size-3" /></span>
<Select value={platform} onValueChange={(v) => setPlatform(v)}>
<SelectTrigger className="w-full bg-secondary/50 h-8 text-base md:text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="nostr">Nostr</SelectItem>
<SelectItem value="activitypub">Mastodon</SelectItem>
<SelectItem value="atproto">Bluesky</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{kindFilter === 'custom' && (
<Input
type="text"
inputMode="numeric"
placeholder="e.g. 1, 30023"
value={customKindText}
onChange={(e) => setCustomKindText(e.target.value)}
className="bg-secondary/50 border-border focus-visible:ring-1 rounded-lg text-base md:text-xs h-8"
/>
{/* Language + Kind */}
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1.5">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide flex items-center gap-1">Language</span>
<Select value={language} onValueChange={(v) => setLanguage(v)}>
<SelectTrigger className="w-full bg-secondary/50 h-8 text-base md:text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="global">Global</SelectItem>
<SelectItem value="en">English</SelectItem>
<SelectItem value="es">Spanish</SelectItem>
<SelectItem value="fr">French</SelectItem>
<SelectItem value="de">German</SelectItem>
<SelectItem value="ja">Japanese</SelectItem>
<SelectItem value="zh">Chinese</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide flex items-center gap-1">Kind</span>
<KindPicker value={kindFilter} options={kindOptions} onChange={(v) => setKindFilter(v)} />
</div>
</div>
{kindFilter === 'custom' && (
<Input
type="text"
inputMode="numeric"
placeholder="e.g. 1, 30023"
value={customKindText}
onChange={(e) => setCustomKindText(e.target.value)}
className="bg-secondary/50 border-border focus-visible:ring-1 rounded-lg text-base md:text-xs h-8"
/>
)}
{/* Include replies toggle */}
<Separator />
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground">Include replies</span>
<Switch checked={includeReplies} onCheckedChange={setIncludeReplies} className="scale-90" />
</div>
</>
)}
{/* Include replies toggle */}
<Separator />
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground">Include replies</span>
<Switch checked={includeReplies} onCheckedChange={setIncludeReplies} className="scale-90" />
</div>
</PopoverContent>
</Popover>
)}
</div>
{/* Active filter summary chips (posts tab only) */}
{activeTab === 'posts' && activeFilterLabels.length > 0 && (
{/* Active filter summary chips (posts, feeds, and packs tabs) */}
{(activeTab === 'posts' || activeTab === 'feeds' || activeTab === 'packs') && activeFilterLabels.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-2">
{activeFilterLabels.map((label) => (
<Badge key={label} variant="secondary" className="text-xs font-normal">
@@ -750,61 +954,24 @@ export function SearchPage() {
<PullToRefresh onRefresh={handleRefresh}>
{/* ─── Posts Tab ─── */}
{activeTab === 'posts' && (
<>
{/* New posts pill — sticks below the SubHeaderBar arc, hides with nav.
Mobile: top = MobileTopBar (2.5rem) + safe-area + SubHeaderBar (~2.5rem).
Desktop: top = SubHeaderBar only (~2.5rem), no MobileTopBar. */}
{newPostCount > 0 && (
<div
className={cn(
'sticky new-posts-pill z-10 flex justify-center pointer-events-none',
'max-sidebar:transition-opacity max-sidebar:duration-300 max-sidebar:ease-in-out',
navHidden && 'max-sidebar:opacity-0 max-sidebar:pointer-events-none',
)}
style={{ marginBottom: '-3rem' }}
>
<button
onClick={() => {
flushStreamBuffer();
window.scrollTo({ top: 0, behavior: 'smooth' });
}}
className="pointer-events-auto px-4 py-1.5 rounded-full bg-primary text-primary-foreground text-sm font-medium shadow-lg hover:bg-primary/90 transition-colors animate-in fade-in slide-in-from-top-2 duration-300"
>
{newPostCount} new post{newPostCount !== 1 ? 's' : ''}
</button>
</div>
)}
{/* Post results — stream */}
{postsLoading && posts.length === 0 ? (
<div className="divide-y divide-border">
{Array.from({ length: 5 }).map((_, i) => (
<PostSkeleton key={i} />
))}
</div>
) : posts.length > 0 ? (
<div>
{posts.map((event) => {
const isNew = flushedIds.has(event.id);
if (isRepostKind(event.kind)) {
const embedded = parseRepostContent(event);
if (embedded) {
return <NoteCard key={event.id} event={embedded} repostedBy={event.pubkey} highlight={isNew} />;
}
return null;
}
return <NoteCard key={event.id} event={event} highlight={isNew} />;
})}
</div>
) : debouncedSearchQuery.trim() ? (
<EmptyState
message="No posts found matching your search."
activeFilters={activeFilterLabels}
onResetFilters={hasActiveFilters ? resetFilters : undefined}
/>
) : (
<EmptyState message="Enter a search query to find posts." />
)}
</>
<StreamingFeed
posts={posts}
isLoading={postsLoading}
newPostCount={newPostCount}
flushStreamBuffer={flushStreamBuffer}
flushedIds={flushedIds}
hasMore={hasMorePosts}
isLoadingMore={isLoadingMorePosts}
scrollRef={postsScrollRef}
navHidden={navHidden}
itemLabel="post"
hasSearch={!!debouncedSearchQuery.trim()}
emptySearchMessage="No posts found matching your search."
emptyNoSearchMessage="Enter a search query to find posts."
activeFilters={activeFilterLabels}
onResetFilters={hasActiveFilters ? resetFilters : undefined}
unwrapReposts
/>
)}
{/* ─── Accounts Tab ─── */}
@@ -833,6 +1000,48 @@ export function SearchPage() {
</div>
</>
)}
{/* ─── Follow Packs Tab ─── */}
{activeTab === 'packs' && (
<StreamingFeed
posts={packPosts}
isLoading={packsLoading}
newPostCount={packsNewCount}
flushStreamBuffer={flushPacksBuffer}
flushedIds={packsFlushedIds}
hasMore={hasMorePacks}
isLoadingMore={isLoadingMorePacks}
scrollRef={packsScrollRef}
navHidden={navHidden}
itemLabel="follow pack"
hasSearch={!!debouncedSearchQuery.trim()}
emptySearchMessage="No follow packs found matching your search."
emptyNoSearchMessage="Enter a search query to find follow packs."
activeFilters={activeFilterLabels}
onResetFilters={hasActiveFilters ? resetFilters : undefined}
/>
)}
{/* ─── Feeds Tab ─── */}
{activeTab === 'feeds' && (
<StreamingFeed
posts={feedSpells}
isLoading={feedsLoading}
newPostCount={feedsNewCount}
flushStreamBuffer={flushFeedsBuffer}
flushedIds={feedsFlushedIds}
hasMore={hasMoreFeeds}
isLoadingMore={isLoadingMoreFeeds}
scrollRef={feedsScrollRef}
navHidden={navHidden}
itemLabel="feed"
hasSearch={!!debouncedSearchQuery.trim()}
emptySearchMessage="No feeds found matching your search."
emptyNoSearchMessage="Enter a search query to find feeds."
activeFilters={activeFilterLabels}
onResetFilters={hasActiveFilters ? resetFilters : undefined}
/>
)}
</PullToRefresh>
</main>
);
@@ -840,6 +1049,109 @@ export function SearchPage() {
/* ── Shared sub-components ── */
/** Reusable streaming feed panel used by the Posts, Feeds, and Follow Packs tabs. */
function StreamingFeed({
posts,
isLoading,
newPostCount,
flushStreamBuffer,
flushedIds,
hasMore,
isLoadingMore,
scrollRef,
navHidden,
itemLabel,
hasSearch,
emptySearchMessage,
emptyNoSearchMessage,
activeFilters,
onResetFilters,
unwrapReposts = false,
}: {
posts: import('@nostrify/nostrify').NostrEvent[];
isLoading: boolean;
newPostCount: number;
flushStreamBuffer: () => void;
flushedIds: Set<string>;
hasMore: boolean;
isLoadingMore: boolean;
scrollRef: (node?: Element | null) => void;
navHidden: boolean;
/** Singular label for the "N new ___" pill, e.g. "post", "feed", "follow pack". */
itemLabel: string;
/** Whether a search query is active (controls empty-state messaging). */
hasSearch: boolean;
emptySearchMessage: string;
emptyNoSearchMessage: string;
activeFilters?: string[];
onResetFilters?: () => void;
/** If true, kind 6/16 reposts are unwrapped into the embedded event. */
unwrapReposts?: boolean;
}) {
return (
<>
{newPostCount > 0 && (
<div
className={cn(
'sticky new-posts-pill z-10 flex justify-center pointer-events-none',
'max-sidebar:transition-opacity max-sidebar:duration-300 max-sidebar:ease-in-out',
navHidden && 'max-sidebar:opacity-0 max-sidebar:pointer-events-none',
)}
style={{ marginBottom: '-3rem' }}
>
<button
onClick={() => {
flushStreamBuffer();
window.scrollTo({ top: 0, behavior: 'smooth' });
}}
className="pointer-events-auto px-4 py-1.5 rounded-full bg-primary text-primary-foreground text-sm font-medium shadow-lg hover:bg-primary/90 transition-colors animate-in fade-in slide-in-from-top-2 duration-300"
>
{newPostCount} new {itemLabel}{newPostCount !== 1 ? 's' : ''}
</button>
</div>
)}
{isLoading && posts.length === 0 ? (
<div className="divide-y divide-border">
{Array.from({ length: 5 }).map((_, i) => (
<NoteCardSkeleton key={i} />
))}
</div>
) : posts.length > 0 ? (
<div>
{posts.map((event) => {
const isNew = flushedIds.has(event.id);
if (unwrapReposts && isRepostKind(event.kind)) {
const embedded = parseRepostContent(event);
if (embedded) {
return <NoteCard key={event.id} event={embedded} repostedBy={event.pubkey} highlight={isNew} />;
}
return null;
}
return <NoteCard key={event.id} event={event} highlight={isNew} />;
})}
{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>
) : hasSearch ? (
<EmptyState
message={emptySearchMessage}
activeFilters={activeFilters}
onResetFilters={onResetFilters}
/>
) : (
<EmptyState message={emptyNoSearchMessage} />
)}
</>
);
}
function AccountItem({ profile, isFollowed }: { profile: { pubkey: string; metadata: Record<string, unknown>; event?: { tags: string[][] } }; isFollowed: boolean }) {
const npub = useMemo(() => nip19.npubEncode(profile.pubkey), [profile.pubkey]);
const metadata = profile.metadata as { name?: string; nip05?: string; picture?: string; about?: string; bot?: boolean };
@@ -1012,32 +1324,6 @@ function EmptyState({
);
}
function PostSkeleton() {
return (
<div className="px-4 py-3">
{/* Header: avatar + stacked name/handle — matches NoteCard layout */}
<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>
{/* Content */}
<div className="mt-2 space-y-1.5">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-4/5" />
</div>
{/* Actions */}
<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" />
</div>
</div>
);
}
function AccountSkeleton() {
return (
<div className="flex items-center gap-3 px-4 py-3">
@@ -1093,29 +1379,6 @@ function SearchInput({
);
}
function SaveDestinationRow({
icon, label, description, onClick, disabled, loading,
}: {
icon: React.ReactNode;
label: string;
description: string;
onClick: () => void;
disabled: boolean;
loading: boolean;
}) {
return (
<button
onClick={onClick}
disabled={disabled}
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg hover:bg-secondary/60 disabled:opacity-40 disabled:pointer-events-none transition-colors text-left"
>
<span className="shrink-0">{loading ? <Loader2 className="size-4 animate-spin text-muted-foreground" /> : icon}</span>
<span className="flex-1 min-w-0">
<span className="block text-sm font-medium">{label}</span>
<span className="block text-xs text-muted-foreground">{description}</span>
</span>
</button>
);
}
+421
View File
@@ -0,0 +1,421 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useInView } from 'react-intersection-observer';
import { useParams } from 'react-router-dom';
import { nip19 } from 'nostr-tools';
import { useNostr } from '@nostrify/react';
import { useQuery } from '@tanstack/react-query';
import { useSeoMeta } from '@unhead/react';
import { AlertCircle, BookmarkPlus, Check, Loader2, PanelLeft, Share2, User, WandSparkles } from 'lucide-react';
import { NoteCard } from '@/components/NoteCard';
import { PageHeader } from '@/components/PageHeader';
import { SaveDestinationRow } from '@/components/SaveDestinationRow';
import { SpellContent } from '@/components/SpellContent';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Skeleton } from '@/components/ui/skeleton';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useFollowList } from '@/hooks/useFollowActions';
import { useProfileTabs } from '@/hooks/useProfileTabs';
import { usePublishProfileTabs } from '@/hooks/usePublishProfileTabs';
import { useFeedSettings } from '@/hooks/useFeedSettings';
import { useSavedFeeds } from '@/hooks/useSavedFeeds';
import { useStreamPosts } from '@/hooks/useStreamPosts';
import { useToast } from '@/hooks/useToast';
import { shareOrCopy } from '@/lib/share';
import { cn } from '@/lib/utils';
import { resolveSpell } from '@/lib/spellEngine';
import NotFound from './NotFound';
import type { NostrEvent } from '@nostrify/nostrify';
export function SpellRunPage() {
const params = useParams<{ nevent?: string; nip19?: string }>();
const nevent = params.nevent ?? params.nip19;
const { nostr } = useNostr();
const { user } = useCurrentUser();
const { data: followData } = useFollowList();
const contactPubkeys = useMemo(() => followData?.pubkeys ?? [], [followData?.pubkeys]);
// Decode the nevent identifier
const decoded = useMemo(() => {
if (!nevent) return null;
try {
const result = nip19.decode(nevent);
if (result.type === 'nevent') return result.data;
if (result.type === 'note') return { id: result.data, author: undefined, relays: undefined };
return null;
} catch {
return null;
}
}, [nevent]);
// Fetch the spell event
const { data: spellEvent, isLoading: isLoadingSpell, error: spellError } = useQuery<NostrEvent | null>({
queryKey: ['spell-event', decoded?.id],
queryFn: async ({ signal }) => {
if (!decoded) return null;
const events = await nostr.query(
[{ ids: [decoded.id], kinds: [777], limit: 1 }],
{ signal: AbortSignal.any([signal, AbortSignal.timeout(10000)]) },
);
return events[0] ?? null;
},
enabled: !!decoded,
staleTime: 5 * 60 * 1000,
});
// Resolve the spell for error checking and cmd detection
const resolved = useMemo(() => {
if (!spellEvent) return null;
try {
return resolveSpell(spellEvent, user?.pubkey, contactPubkeys);
} catch (err) {
return { error: err instanceof Error ? err.message : 'Failed to resolve spell' };
}
}, [spellEvent, user?.pubkey, contactPubkeys]);
const resolveError = resolved && 'error' in resolved ? resolved.error : null;
const cmd = resolved && !('error' in resolved) ? resolved.cmd : null;
// Execute the spell via useStreamPosts (live streaming + initial batch)
const { posts, isLoading: isLoadingResults, newPostCount, flushStreamBuffer, loadMore, hasMore, isLoadingMore } = useStreamPosts('', {
includeReplies: true,
mediaType: 'all',
spell: spellEvent ?? undefined,
});
const { ref: scrollRef, inView } = useInView({ threshold: 0, rootMargin: '400px' });
useEffect(() => {
if (inView && hasMore && !isLoadingMore) {
loadMore();
}
}, [inView, hasMore, isLoadingMore, loadMore]);
const spellName = spellEvent?.tags.find(([t]) => t === 'name')?.[1];
// ── Save popover state ───────────────────────────────────────────────
const { savedFeeds, addSavedFeed, removeSavedFeed } = useSavedFeeds();
const { addToSidebar, orderedItems } = useFeedSettings();
const profileTabsQuery = useProfileTabs(user?.pubkey);
const { publishProfileTabs, isPending: isPublishingTabs } = usePublishProfileTabs();
const { toast } = useToast();
const [savePopoverOpen, setSavePopoverOpen] = useState(false);
const [saveFeedLabel, setSaveFeedLabel] = useState('');
const [savedJustNow, setSavedJustNow] = useState(false);
const [isSharing, setIsSharing] = useState(false);
/** Convert a spell event to a TabFilter for saving.
* Includes client-hint fields (_media, _language, _platform, _sort,
* _includeReplies) so SavedFeedContent can faithfully reproduce the query. */
const spellAsFilter = useMemo(() => {
if (!spellEvent) return undefined;
try {
const resolved = resolveSpell(spellEvent, undefined, []);
const filter: Record<string, unknown> = { ...resolved.filter };
// Persist spell hints into the saved filter
const h = resolved.hints;
if (h.mediaType !== 'all') filter._media = h.mediaType;
if (h.language && h.language !== 'global') filter._language = h.language;
if (h.platform !== 'nostr') filter._platform = h.platform;
if (h.sort !== 'recent') filter._sort = h.sort;
if (!h.includeReplies) filter._includeReplies = false;
return filter;
} catch {
return undefined;
}
}, [spellEvent]);
/** Find an existing saved feed that matches the spell's filter or spell ID. */
const matchingSavedFeed = useMemo(() => {
if (!spellEvent && !spellAsFilter) return undefined;
// Prefer matching by spell event ID (exact match)
if (spellEvent) {
const bySpellId = savedFeeds.find((f) => f.spellId === spellEvent.id);
if (bySpellId) return bySpellId;
}
// Fall back to filter comparison for legacy saved feeds without spellId
if (!spellAsFilter) return undefined;
const filterKey = JSON.stringify(spellAsFilter);
return savedFeeds.find((f) => JSON.stringify(f.filter) === filterKey);
}, [savedFeeds, spellAsFilter, spellEvent]);
const alreadySaved = !!matchingSavedFeed;
/** The nostr: URI for this spell (used for sidebar). */
const sidebarId = nevent ? `nostr:${nevent}` : undefined;
const alreadyInSidebar = sidebarId ? orderedItems.includes(sidebarId) : false;
const handleSaveHomeFeed = useCallback(async () => {
if (!spellAsFilter || !saveFeedLabel.trim() || !spellEvent) return;
await addSavedFeed(saveFeedLabel.trim(), spellAsFilter as Record<string, unknown>, [], spellEvent.id);
setSavePopoverOpen(false);
setSaveFeedLabel('');
setSavedJustNow(true);
setTimeout(() => setSavedJustNow(false), 2000);
toast({ title: 'Added to home feed' });
}, [spellAsFilter, saveFeedLabel, addSavedFeed, toast, spellEvent]);
const handleSaveProfileTab = useCallback(async () => {
if (!spellEvent || !saveFeedLabel.trim() || !user) return;
// Store the spell event's tags so ProfileSavedFeedContent can reconstruct
// the spell and render via useStreamPosts({ spell }).
const tabFilter: Record<string, unknown> = { _spellTags: spellEvent.tags };
const existing = profileTabsQuery.data ?? { tabs: [], vars: [] };
await publishProfileTabs({
tabs: [...existing.tabs, { label: saveFeedLabel.trim(), filter: tabFilter }],
vars: existing.vars,
});
setSavePopoverOpen(false);
setSaveFeedLabel('');
setSavedJustNow(true);
setTimeout(() => setSavedJustNow(false), 2000);
toast({ title: 'Added to profile tabs' });
}, [spellEvent, saveFeedLabel, user, profileTabsQuery.data, publishProfileTabs, toast]);
const handleShare = useCallback(async () => {
if (!spellEvent || !nevent || !saveFeedLabel.trim()) return;
setIsSharing(true);
try {
const url = `${window.location.origin}/${nevent}`;
const result = await shareOrCopy(url, saveFeedLabel.trim());
if (result === 'copied') {
toast({ title: 'Link copied to clipboard' });
}
setSavePopoverOpen(false);
setSaveFeedLabel('');
} finally {
setIsSharing(false);
}
}, [spellEvent, nevent, saveFeedLabel, toast]);
const handleAddToSidebar = useCallback(() => {
if (!sidebarId) return;
addToSidebar(sidebarId);
setSavePopoverOpen(false);
setSaveFeedLabel('');
setSavedJustNow(true);
setTimeout(() => setSavedJustNow(false), 2000);
toast({ title: 'Added to sidebar' });
}, [sidebarId, addToSidebar, toast]);
const handleRemoveSaved = useCallback(async () => {
if (!matchingSavedFeed) return;
await removeSavedFeed(matchingSavedFeed.id);
toast({ title: 'Removed from home feed' });
}, [matchingSavedFeed, removeSavedFeed, toast]);
useSeoMeta({
title: spellName ? `${spellName} | Spell Results` : 'Spell Results',
});
// Invalid identifier
if (!decoded) return <NotFound />;
return (
<main className="">
<PageHeader
title={spellName ?? 'Spell Results'}
icon={<WandSparkles className="size-5 text-primary" />}
backTo="/discover"
>
{cmd && (
<Badge variant="secondary" className="text-xs font-mono shrink-0">
{cmd}
</Badge>
)}
</PageHeader>
{/* Spell summary card */}
{spellEvent && (
<div className="flex items-start gap-2 px-4 py-3 border-b border-border bg-muted/30">
<div className="flex-1 min-w-0">
<SpellContent event={spellEvent} />
</div>
{user && (
<Popover open={savePopoverOpen} onOpenChange={(o) => {
setSavePopoverOpen(o);
if (o && !saveFeedLabel) {
setSaveFeedLabel(spellName ?? '');
}
}}>
<PopoverTrigger asChild>
<button
className={cn(
'shrink-0 size-8 flex items-center justify-center rounded-md transition-colors',
alreadySaved || savedJustNow
? 'bg-primary/10 text-primary'
: 'text-muted-foreground hover:text-foreground',
)}
aria-label="Save spell"
>
{savedJustNow ? <Check className="size-4" /> : <BookmarkPlus className={cn(
'size-4',
alreadySaved && 'fill-primary text-primary',
)} />}
</button>
</PopoverTrigger>
<PopoverContent align="end" className="w-64 p-3 space-y-3">
<p className="font-semibold text-sm">Save as tab</p>
{alreadySaved ? (
<div className="space-y-2">
<p className="text-sm text-muted-foreground">Already saved to home feed.</p>
<button
onClick={handleRemoveSaved}
className="text-xs text-destructive hover:underline"
>
Remove
</button>
</div>
) : (
<>
<Input
placeholder="Tab name…"
value={saveFeedLabel}
onChange={(e) => setSaveFeedLabel(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') handleSaveHomeFeed(); }}
className="bg-secondary/50 border-border focus-visible:ring-1 text-base md:text-sm"
autoFocus
/>
<div className="space-y-1">
<SaveDestinationRow
icon={<BookmarkPlus className="size-4 text-muted-foreground" />}
label="Home feed"
description="Tab on your home page"
onClick={handleSaveHomeFeed}
disabled={!saveFeedLabel.trim()}
loading={false}
/>
<SaveDestinationRow
icon={<User className="size-4 text-muted-foreground" />}
label="Profile tab"
description="Tab on your profile"
onClick={handleSaveProfileTab}
disabled={!saveFeedLabel.trim() || isPublishingTabs}
loading={isPublishingTabs}
/>
<SaveDestinationRow
icon={<PanelLeft className="size-4 text-muted-foreground" />}
label="Sidebar"
description="Pin to your sidebar"
onClick={handleAddToSidebar}
disabled={!sidebarId || alreadyInSidebar}
loading={false}
/>
<SaveDestinationRow
icon={<Share2 className="size-4 text-muted-foreground" />}
label="Share"
description="Copy link to this spell"
onClick={handleShare}
disabled={!saveFeedLabel.trim() || isSharing}
loading={isSharing}
/>
</div>
</>
)}
</PopoverContent>
</Popover>
)}
</div>
)}
{/* New posts pill */}
{newPostCount > 0 && (
<button
onClick={flushStreamBuffer}
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>
)}
{/* Error states */}
{resolveError && (
<div className="p-4">
<Alert variant="destructive">
<AlertCircle className="size-4" />
<AlertDescription>{resolveError}</AlertDescription>
</Alert>
</div>
)}
{spellError && (
<div className="p-4">
<Alert variant="destructive">
<AlertCircle className="size-4" />
<AlertDescription>Failed to fetch spell event.</AlertDescription>
</Alert>
</div>
)}
{/* Loading states */}
{(isLoadingSpell || (isLoadingResults && posts.length === 0)) && (
<div className="divide-y divide-border">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="px-4 py-3">
<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>
))}
</div>
)}
{/* COUNT results */}
{cmd === 'COUNT' && !isLoadingResults && (
<div className="p-4">
<Card>
<CardContent className="py-8 text-center">
<p className="text-4xl font-bold">{posts.length}</p>
<p className="text-sm text-muted-foreground mt-1">events found</p>
</CardContent>
</Card>
</div>
)}
{/* REQ results */}
{cmd !== 'COUNT' && posts.length > 0 && (
<div>
{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>
)}
{/* Empty state */}
{!isLoadingSpell && !isLoadingResults && posts.length === 0 && !resolveError && !spellError && spellEvent && (
<div className="p-8 text-center">
<Card className="border-dashed">
<CardContent className="py-12 px-8">
<p className="text-muted-foreground">
No results found for this spell. The queried relays may not have matching events.
</p>
</CardContent>
</Card>
</div>
)}
</main>
);
}
+2 -24
View File
@@ -4,13 +4,13 @@ import { Loader2, Pencil, Sparkles } from "lucide-react";
import { useCallback, useState } from "react";
import { FeedEmptyState } from "@/components/FeedEmptyState";
import { NoteCard } from "@/components/NoteCard";
import { NoteCardSkeleton } from "@/components/NoteCardSkeleton";
import { PageHeader } from "@/components/PageHeader";
import { PullToRefresh } from "@/components/PullToRefresh";
import { SubHeaderBar } from "@/components/SubHeaderBar";
import { TabButton } from "@/components/TabButton";
import { ThemeSelector } from "@/components/ThemeSelector";
import { Label } from "@/components/ui/label";
import { Skeleton } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch";
import { useLayoutOptions } from "@/contexts/LayoutContext";
import { useAppContext } from "@/hooks/useAppContext";
@@ -187,26 +187,4 @@ export function ThemesPage() {
// Skeleton
// ---------------------------------------------------------------------------
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>
);
}
+3 -23
View File
@@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from "react";
import { useInView } from "react-intersection-observer";
import { Link } from "react-router-dom";
import { NoteCard } from "@/components/NoteCard";
import { NoteCardSkeleton } from "@/components/NoteCardSkeleton";
import { PageHeader } from "@/components/PageHeader";
import { PullToRefresh } from "@/components/PullToRefresh";
import { Skeleton } from "@/components/ui/skeleton";
@@ -125,7 +126,7 @@ export function TrendsPage() {
{(sortedPending || sortedLoading) && sortedPosts.length === 0 ? (
<div className="divide-y divide-border">
{Array.from({ length: 5 }).map((_, i) => (
<PostSkeleton key={i} />
<NoteCardSkeleton key={i} />
))}
</div>
) : sortedPosts.length > 0 ? (
@@ -203,28 +204,7 @@ function EmptyState({ message }: { message: string }) {
);
}
function PostSkeleton() {
return (
<div className="px-4 py-3">
<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" />
</div>
</div>
);
}
function TrendSkeleton() {
return (
+2
View File
@@ -113,6 +113,8 @@ export function TestApp({ children }: TestAppProps) {
imageQuality: 'compressed',
sandboxDomain: 'iframe.diy',
sidebarWidgets: [],
aiModel: '',
aiSystemPrompt: '',
};
return (
+7
View File
@@ -128,6 +128,13 @@ export default defineConfig(({ mode }) => {
host: "::",
port: 8080,
allowedHosts: env.ALLOWED_HOSTS === "*" ? true : undefined,
proxy: {
'/api/shakespeare': {
target: 'http://5.78.68.217:8000',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api\/shakespeare/, ''),
},
},
},
plugins: [
react(),