From af0e028cdee9504cbb0d7d0074cb323ba362546d Mon Sep 17 00:00:00 2001 From: Lemon Date: Sun, 12 Apr 2026 11:21:20 -0700 Subject: [PATCH] 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. --- src/components/ContentSettings.tsx | 22 ++-- src/components/Feed.tsx | 59 +++++---- src/components/FeedEditModal.tsx | 87 ++++++++----- src/components/ProfileTabEditModal.tsx | 116 +++++++++++------ src/contexts/AppContext.ts | 22 +++- src/hooks/useProfileFeed.ts | 172 ++++++++++++++++++++++++- src/hooks/useResolveTabFilter.ts | 88 +++++++++++++ src/hooks/useSavedFeeds.ts | 23 ++-- src/lib/feedFilterUtils.ts | 7 + src/lib/profileTabsEvent.ts | 144 ++++++++++++++++++--- src/lib/schemas.ts | 20 +-- src/lib/tools/GetFeedTool.ts | 12 +- src/lib/tools/Tool.ts | 3 +- src/pages/ProfilePage.tsx | 116 ++++++++++------- src/pages/SearchPage.tsx | 61 +++++---- src/pages/SpellRunPage.tsx | 29 +++-- 16 files changed, 731 insertions(+), 250 deletions(-) create mode 100644 src/hooks/useResolveTabFilter.ts diff --git a/src/components/ContentSettings.tsx b/src/components/ContentSettings.tsx index 64ac261b..cf23f921 100644 --- a/src/components/ContentSettings.tsx +++ b/src/components/ContentSettings.tsx @@ -29,8 +29,7 @@ import { buildKindOptions } from '@/lib/feedFilterUtils'; import { genUserName } from '@/lib/genUserName'; import { EXTRA_KINDS, FEED_KINDS, SECTION_ORDER, SECTION_LABELS } from '@/lib/extraKinds'; import { CONTENT_KIND_ICONS, SIDEBAR_ITEMS } from '@/lib/sidebarItems'; -import type { SavedFeed, ContentWarningPolicy } from '@/contexts/AppContext'; -import type { NostrEvent } from '@nostrify/nostrify'; +import type { SavedFeed, TabFilter, ContentWarningPolicy } from '@/contexts/AppContext'; import type { ExtraKindDef, SubKindDef } from '@/lib/extraKinds'; export function ContentSettings() { @@ -749,14 +748,14 @@ function SavedFeedsSection() { const feedTabs = savedFeeds; - const handleAddFeed = async (label: string, spell: NostrEvent) => { - await addSavedFeed(label, spell); + const handleAddFeed = async (label: string, filter: TabFilter, vars: SavedFeed['vars']) => { + await addSavedFeed(label, filter, vars); toast({ title: `"${label}" added to home feed tabs` }); }; - const handleEditFeed = async (label: string, spell: NostrEvent) => { + const handleEditFeed = async (label: string, filter: TabFilter, vars: SavedFeed['vars']) => { if (!editingFeed) return; - await updateSavedFeed(editingFeed.id, { label, spell }); + await updateSavedFeed(editingFeed.id, { label, filter, vars }); toast({ title: 'Feed updated' }); setEditingFeed(null); }; @@ -811,7 +810,7 @@ function SavedFeedsSection() { open={editingFeed !== null} onOpenChange={(o) => { if (!o) setEditingFeed(null); }} initialLabel={editingFeed?.label} - initialSpell={editingFeed?.spell} + initialFilter={editingFeed?.filter} onSave={handleEditFeed} isPending={isPending} /> @@ -832,12 +831,11 @@ function SavedFeedRow({ onRemove: () => void; isPending: boolean; }) { - const tags = feed.spell?.tags ?? []; - const search = tags.find(([t]) => t === 'search')?.[1] ?? ''; - const authors = tags.find(([t]) => t === 'authors')?.slice(1) ?? []; - const kinds = tags.filter(([t]) => t === 'k').map(([, v]) => parseInt(v)).filter((n) => !isNaN(n)); + 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('$contacts') + const scopeLabel = authors.includes('$follows') ? 'Follows' : authors.length > 0 ? `${authors.length} author${authors.length > 1 ? 's' : ''}` diff --git a/src/components/Feed.tsx b/src/components/Feed.tsx index db9e3715..a8290711 100644 --- a/src/components/Feed.tsx +++ b/src/components/Feed.tsx @@ -1,7 +1,7 @@ import { useState, useEffect, useMemo, useCallback } from 'react'; import { useInView } from 'react-intersection-observer'; import { useNostr } from '@nostrify/react'; -import { useQuery } from '@tanstack/react-query'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; import { usePageRefresh } from '@/hooks/usePageRefresh'; import { ComposeBox } from '@/components/ComposeBox'; import { LandingHero } from '@/components/LandingHero'; @@ -21,6 +21,7 @@ import { useInterests } from '@/hooks/useInterests'; import { useMuteList } from '@/hooks/useMuteList'; import { useSavedFeeds } from '@/hooks/useSavedFeeds'; import { useStreamPosts } from '@/hooks/useStreamPosts'; +import { useResolveTabFilter } from '@/hooks/useResolveTabFilter'; import { getEnabledFeedKinds } from '@/lib/extraKinds'; import { isRepostKind, shouldHideFeedEvent } from '@/lib/feedUtils'; @@ -360,25 +361,39 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee ); } -/** Renders a saved search feed using useStreamPosts with a spell event. */ +/** Renders a saved search feed using useStreamPosts (live streaming). */ function SavedFeedContent({ feed }: { feed: SavedFeed }) { - const { posts, isLoading, newPostCount, flushStreamBuffer, loadMore, hasMore, isLoadingMore } = useStreamPosts('', { + const { ref: scrollRef, inView } = useInView({ threshold: 0, rootMargin: '400px' }); + const { user } = useCurrentUser(); + const queryClient = useQueryClient(); + + // Resolve variable placeholders ($follows etc.) the same way profile tabs do + const { filter: resolvedFilter, isLoading: isResolving } = useResolveTabFilter( + feed.filter, + feed.vars ?? [], + user?.pubkey ?? '', + ); + + const search = typeof resolvedFilter?.search === 'string' ? resolvedFilter.search : ''; + const kindsOverride = Array.isArray(resolvedFilter?.kinds) ? resolvedFilter.kinds as number[] : undefined; + const authorPubkeys = Array.isArray(resolvedFilter?.authors) ? resolvedFilter.authors as string[] : undefined; + + const { posts, isLoading: isStreamLoading } = useStreamPosts(search, { includeReplies: true, mediaType: 'all', - spell: feed.spell, + kindsOverride, + authorPubkeys: authorPubkeys && authorPubkeys.length > 0 ? authorPubkeys : undefined, }); - const { ref: scrollRef, inView } = useInView({ threshold: 0, rootMargin: '400px' }); - - useEffect(() => { - if (inView && hasMore && !isLoadingMore) { - loadMore(); - } - }, [inView, hasMore, isLoadingMore, loadMore]); + const isLoading = isResolving || isStreamLoading; const handleRefresh = useCallback(async () => { - flushStreamBuffer(); - }, [flushStreamBuffer]); + await queryClient.invalidateQueries({ queryKey: ['resolve-tab-filter'] }); + }, [queryClient]); + + useEffect(() => { + // intentionally empty — useStreamPosts handles its own streaming + }, [inView]); if (isLoading && posts.length === 0) { return ( @@ -401,26 +416,10 @@ function SavedFeedContent({ feed }: { feed: SavedFeed }) { return (
- {newPostCount > 0 && ( - - )} {posts.map((event) => ( ))} - {hasMore && ( -
- {isLoadingMore && ( -
- -
- )} -
- )} +
); diff --git a/src/components/FeedEditModal.tsx b/src/components/FeedEditModal.tsx index be251fea..4fc6b336 100644 --- a/src/components/FeedEditModal.tsx +++ b/src/components/FeedEditModal.tsx @@ -2,7 +2,9 @@ * FeedEditModal * * Modal for creating or editing a saved home feed tab. - * Produces a kind:777 spell event from the filter UI. + * Mirrors the structure of ProfileTabEditModal: direct state management, + * MultiKindPicker for multi-select kinds, and a 3-way author scope toggle + * (Anyone / Follows / People) with list/pack picker. */ import { useState, useMemo } from 'react'; import { Loader2, Check, Globe, Users, UserSearch } from 'lucide-react'; @@ -16,7 +18,7 @@ import { import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; import { Separator } from '@/components/ui/separator'; -import { buildKindOptions } from '@/lib/feedFilterUtils'; +import { buildKindOptions, parseSelectedKinds } from '@/lib/feedFilterUtils'; import { MultiKindPicker, AuthorChip, @@ -27,8 +29,9 @@ import { import type { ScopeOption } from '@/components/SavedFeedFiltersEditor'; import { useUserLists, useMatchedListId } from '@/hooks/useUserLists'; import { useFollowPacks } from '@/hooks/useFollowPacks'; -import { buildSpellTags, buildUnsignedSpell, spellAuthors, spellAuthorPubkeys, spellKinds, spellSearch } from '@/lib/spellEngine'; -import type { NostrEvent } from '@nostrify/nostrify'; +import { useCurrentUser } from '@/hooks/useCurrentUser'; +import type { TabVarDef } from '@/lib/profileTabsEvent'; +import type { TabFilter } from '@/contexts/AppContext'; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -39,18 +42,18 @@ interface FeedEditModalProps { onOpenChange: (open: boolean) => void; /** When provided, the modal is in "edit" mode. */ initialLabel?: string; - /** Initial spell event (for edit mode — tags are parsed to seed the form). */ - initialSpell?: NostrEvent; + /** Initial filter values (for edit mode). */ + initialFilter?: TabFilter; /** Called when the user confirms. */ - onSave: (label: string, spell: NostrEvent) => Promise; + onSave: (label: string, filter: TabFilter, vars: TabVarDef[]) => Promise; isPending?: boolean; } // ─── Helpers ────────────────────────────────────────────────────────────────── -/** Derive the author scope from a spell draft's authors array. */ -function draftToScope(authors: string[]): AuthorScope { - if (authors.includes('$contacts')) return 'follows'; +function filterToScope(filter: TabFilter): AuthorScope { + const authors = Array.isArray(filter.authors) ? (filter.authors as string[]) : []; + if (authors.includes('$follows')) return 'follows'; if (authors.length > 0) return 'people'; return 'anyone'; } @@ -67,7 +70,7 @@ export function FeedEditModal({ open, onOpenChange, initialLabel, - initialSpell, + initialFilter, onSave, isPending = false, }: FeedEditModalProps) { @@ -75,21 +78,33 @@ export function FeedEditModal({ const kindOptions = useMemo(() => buildKindOptions(), []); const { lists } = useUserLists(); const { data: followPacks = [] } = useFollowPacks(); + const { user } = useCurrentUser(); - const [label, setLabel] = useState(() => initialLabel ?? ''); - const [authorScope, setAuthorScope] = useState(() => draftToScope(spellAuthors(initialSpell))); - const [authorPubkeys, setAuthorPubkeys] = useState(() => spellAuthorPubkeys(initialSpell)); - const [selectedKinds, setSelectedKinds] = useState(() => spellKinds(initialSpell)); - const [search, setSearch] = useState(() => spellSearch(initialSpell)); + const initFrom = (filter: TabFilter | undefined) => ({ + label: initialLabel ?? '', + scope: filterToScope(filter ?? {}), + authors: Array.isArray(filter?.authors) + ? (filter.authors as string[]).filter((a) => a !== '$follows') + : [], + kinds: parseSelectedKinds(filter ?? {}), + search: typeof filter?.search === 'string' ? filter.search : '', + }); + + const [label, setLabel] = useState(() => initFrom(initialFilter).label); + const [authorScope, setAuthorScope] = useState(() => initFrom(initialFilter).scope); + const [authorPubkeys, setAuthorPubkeys] = useState(() => initFrom(initialFilter).authors); + const [selectedKinds, setSelectedKinds] = useState(() => initFrom(initialFilter).kinds); + const [search, setSearch] = useState(() => initFrom(initialFilter).search); // Reset all state when the modal opens const handleOpenChange = (o: boolean) => { if (o) { - setLabel(initialLabel ?? ''); - setAuthorScope(draftToScope(spellAuthors(initialSpell))); - setAuthorPubkeys(spellAuthorPubkeys(initialSpell)); - setSelectedKinds(spellKinds(initialSpell)); - setSearch(spellSearch(initialSpell)); + const init = initFrom(initialFilter); + setLabel(init.label); + setAuthorScope(init.scope); + setAuthorPubkeys(init.authors); + setSelectedKinds(init.kinds); + setSearch(init.search); } onOpenChange(o); }; @@ -107,24 +122,30 @@ export function FeedEditModal({ const handleSave = async () => { if (!label.trim() || isPending) return; - // Build authors array - let authors: string[] | undefined; + const filter: TabFilter = {}; + const vars: TabVarDef[] = []; + + if (search.trim()) filter.search = search.trim(); + if (authorScope === 'follows') { - authors = ['$contacts']; + filter.authors = ['$follows']; + // Emit a var definition so useResolveTabFilter can expand $follows + // via the current user's contact list (kind 3), matching profile tab behaviour. + if (user) { + vars.push({ + name: '$follows', + tagName: 'p', + pointer: `a:3:${user.pubkey}:`, + }); + } } else if (authorScope === 'people' && authorPubkeys.length > 0) { - authors = authorPubkeys; + filter.authors = authorPubkeys; } const kinds = selectedKinds.map(Number).filter((n) => !isNaN(n) && n > 0); + if (kinds.length > 0) filter.kinds = kinds; - const tags = buildSpellTags({ - name: label.trim(), - kinds: kinds.length > 0 ? kinds : undefined, - authors, - search: search.trim() || undefined, - }); - - await onSave(label.trim(), buildUnsignedSpell(tags)); + await onSave(label.trim(), filter, vars); onOpenChange(false); }; diff --git a/src/components/ProfileTabEditModal.tsx b/src/components/ProfileTabEditModal.tsx index f2f59751..f1b1806d 100644 --- a/src/components/ProfileTabEditModal.tsx +++ b/src/components/ProfileTabEditModal.tsx @@ -2,7 +2,10 @@ * ProfileTabEditModal * * Modal for adding or editing a custom profile tab (kind 16769). - * Produces a kind:777 spell event from the UI state. + * Opens with an optional existing tab to edit; otherwise creates a new one. + * + * Streamlined for profile tabs: only Search Query, Author Scope (Me / Contacts / People / Global), + * and multi-select Kind picker. */ import { useState, useMemo, useCallback, useEffect } from 'react'; import { @@ -18,7 +21,7 @@ import { import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; import { Separator } from '@/components/ui/separator'; -import { buildKindOptions } from '@/lib/feedFilterUtils'; +import { buildKindOptions, parseSelectedKinds } from '@/lib/feedFilterUtils'; import { MultiKindPicker, ScopeToggle, @@ -30,8 +33,7 @@ import type { ScopeOption } from '@/components/SavedFeedFiltersEditor'; import { PortalContainerProvider } from '@/hooks/usePortalContainer'; import { useUserLists, useMatchedListId } from '@/hooks/useUserLists'; import { useFollowPacks } from '@/hooks/useFollowPacks'; -import { buildSpellTags, buildUnsignedSpell, spellAuthors, spellAuthorPubkeys, spellKinds, spellSearch } from '@/lib/spellEngine'; -import type { ProfileTab } from '@/lib/profileTabsEvent'; +import type { ProfileTab, TabFilter } from '@/lib/profileTabsEvent'; interface ProfileTabEditModalProps { @@ -50,15 +52,43 @@ interface ProfileTabEditModalProps { type ProfileAuthorScope = 'me' | 'contacts' | 'people' | 'global'; -/** Derive the profile-specific author scope from a spell draft's authors array. */ -function draftToProfileScope(authors: string[], ownerPubkey: string): ProfileAuthorScope { - // Detect "me" scope: either the literal owner pubkey or the $me variable - if (authors.length === 1 && (authors[0] === ownerPubkey || authors[0] === '$me')) return 'me'; - if (authors.includes('$contacts')) return 'contacts'; - if (authors.length > 0) return 'people'; +/** Map from simplified scope to filter fields (excluding people's authors which are stored separately). */ +function scopeToFilter(scope: ProfileAuthorScope, ownerPubkey: string, peoplePubkeys: string[]): Partial { + switch (scope) { + case 'me': + return { authors: [ownerPubkey] }; + case 'contacts': + // Uses $follows variable — handled at event level via var tags + return { authors: ['$follows'] }; + case 'people': + return peoplePubkeys.length > 0 ? { authors: peoplePubkeys } : {}; + case 'global': + return {}; + } +} + +/** Derive the simplified scope from a TabFilter. */ +function filterToScope(filter: TabFilter, ownerPubkey: string): ProfileAuthorScope { + const authors = Array.isArray(filter.authors) ? filter.authors as string[] : []; + if (authors.length === 1 && authors[0] === ownerPubkey) return 'me'; + if (authors.includes('$follows')) return 'contacts'; + if (authors.length > 0) return 'people'; // has specific authors → people scope return 'global'; } +/** Extract people pubkeys from a TabFilter (non-variable, non-owner pubkeys). */ +function filterToPeoplePubkeys(filter: TabFilter, ownerPubkey: string): string[] { + const authors = Array.isArray(filter.authors) ? filter.authors as string[] : []; + if (authors.includes('$follows')) return []; + if (authors.length === 1 && authors[0] === ownerPubkey) return []; + return authors.filter((a) => a !== ownerPubkey && !a.startsWith('$')); +} + +/** Serialize selected kind values into a kinds array for the filter. */ +function serializeSelectedKinds(kinds: string[]): number[] { + return kinds.map(Number).filter((n) => !isNaN(n) && n > 0); +} + // ─── Author Scope Options ───────────────────────────────────────────────────── const PROFILE_SCOPE_OPTIONS: ScopeOption[] = [ @@ -83,11 +113,24 @@ export function ProfileTabEditModal({ const { data: followPacks = [] } = useFollowPacks(); const isNew = !tab; + const initialFilter = useMemo(() => { + if (tab) return tab.filter; + return { authors: [ownerPubkey] }; + }, [tab, ownerPubkey]); + const [label, setLabel] = useState(tab?.label ?? ''); - const [query, setQuery] = useState(() => spellSearch(tab?.spell)); - const [authorScope, setAuthorScope] = useState(() => draftToProfileScope(spellAuthors(tab?.spell), ownerPubkey)); - const [peoplePubkeys, setPeoplePubkeys] = useState(() => spellAuthorPubkeys(tab?.spell, ownerPubkey)); - const [selectedKinds, setSelectedKinds] = useState(() => spellKinds(tab?.spell)); + const [query, setQuery] = useState( + typeof initialFilter.search === 'string' ? initialFilter.search : '', + ); + const [authorScope, setAuthorScope] = useState( + filterToScope(initialFilter, ownerPubkey), + ); + const [peoplePubkeys, setPeoplePubkeys] = useState( + filterToPeoplePubkeys(initialFilter, ownerPubkey), + ); + const [selectedKinds, setSelectedKinds] = useState( + parseSelectedKinds(initialFilter), + ); const [portalContainer, setPortalContainer] = useState(undefined); const listPickerValue = useMatchedListId(peoplePubkeys); @@ -113,45 +156,36 @@ export function ProfileTabEditModal({ }, []); // Reset form state whenever the modal opens or the tab being edited changes. + // This runs as an effect rather than inside onOpenChange because the Dialog + // does not fire onOpenChange when opened programmatically via the `open` prop. useEffect(() => { if (open) { + const f = tab ? tab.filter : { authors: [ownerPubkey] }; setLabel(tab?.label ?? ''); - setQuery(spellSearch(tab?.spell)); - setAuthorScope(draftToProfileScope(spellAuthors(tab?.spell), ownerPubkey)); - setPeoplePubkeys(spellAuthorPubkeys(tab?.spell, ownerPubkey)); - setSelectedKinds(spellKinds(tab?.spell)); + setQuery(typeof f.search === 'string' ? f.search : ''); + setAuthorScope(filterToScope(f, ownerPubkey)); + setPeoplePubkeys(filterToPeoplePubkeys(f, ownerPubkey)); + setSelectedKinds(parseSelectedKinds(f)); } }, [open, tab, ownerPubkey]); const handleSave = async () => { if (!label.trim() || isPending) return; - // Build authors array based on scope - let authors: string[] | undefined; - if (authorScope === 'me') { - // Use the literal owner pubkey so the tab works correctly for visitors - // (spell engine's $me would resolve to the viewer, not the profile owner) - authors = [ownerPubkey]; - } else if (authorScope === 'contacts') { - // $contacts resolves to the viewer's follow list, which is intentional: - // "show posts from people I follow" changes per viewer. - // To show the owner's contacts instead, the owner's follow list pubkeys - // would need to be embedded directly (future enhancement). - authors = ['$contacts']; - } else if (authorScope === 'people' && peoplePubkeys.length > 0) { - authors = peoplePubkeys; + const filter: TabFilter = { + ...scopeToFilter(authorScope, ownerPubkey, peoplePubkeys), + }; + + if (query.trim()) { + filter.search = query.trim(); } - const kinds = selectedKinds.map(Number).filter((n) => !isNaN(n) && n > 0); + const kinds = serializeSelectedKinds(selectedKinds); + if (kinds.length > 0) { + filter.kinds = kinds; + } - const tags = buildSpellTags({ - name: label.trim(), - kinds: kinds.length > 0 ? kinds : undefined, - authors, - search: query.trim() || undefined, - }); - - await onSave({ label: label.trim(), spell: buildUnsignedSpell(tags) }); + await onSave({ label: label.trim(), filter }); onOpenChange(false); }; diff --git a/src/contexts/AppContext.ts b/src/contexts/AppContext.ts index 03853190..f0f9f55f 100644 --- a/src/contexts/AppContext.ts +++ b/src/contexts/AppContext.ts @@ -151,12 +151,28 @@ export interface FeedSettings { followsFeedShowReplies: boolean; } -/** A named feed tab stored as a kind:777 spell event. */ +/** + * A standard NIP-01 filter object that may contain variable placeholders + * (`$name`) in string positions. After resolution, becomes a `NostrFilter`. + */ +export type TabFilter = Record; + +/** A variable definition for tab filters (extracted from `var` tags). */ +export interface TabVarDef { + /** Variable name including the `$` prefix, e.g. `"$follows"`. */ + name: string; + /** Tag name to extract from the referenced event, e.g. `"p"`. */ + tagName: string; + /** Event pointer: `e:` or `a:::`. May contain variables. */ + pointer: string; +} + +/** A named feed tab saved from the search page. */ export interface SavedFeed { id: string; label: string; - /** The signed kind:777 spell event that drives this feed. */ - spell: import('@nostrify/nostrify').NostrEvent; + filter: TabFilter; + vars: TabVarDef[]; createdAt: number; } diff --git a/src/hooks/useProfileFeed.ts b/src/hooks/useProfileFeed.ts index 3212a468..bc34c5a2 100644 --- a/src/hooks/useProfileFeed.ts +++ b/src/hooks/useProfileFeed.ts @@ -2,9 +2,9 @@ import { useNostr } from '@nostrify/react'; import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query'; import { useFeedSettings } from './useFeedSettings'; import { getEnabledFeedKinds } from '@/lib/extraKinds'; -import { getPaginationCursor, unwrapReposts, type FeedItem } from '@/lib/feedUtils'; +import { getPaginationCursor, parseRepostContent, isRepostKind, type FeedItem } from '@/lib/feedUtils'; import { isReplyEvent } from '@/lib/nostrEvents'; -import type { NostrEvent } from '@nostrify/nostrify'; +import type { NostrEvent, NostrFilter } from '@nostrify/nostrify'; /** Extended FeedItem with pagination metadata. */ interface ProfileFeedPage { @@ -134,11 +134,46 @@ export function useProfileFeed(pubkey: string | undefined, activeTab: ProfileTab const oldestQueryTimestamp = getPaginationCursor(validEvents); // Process events into FeedItems, unwrapping kind 6/16 reposts - const items = await unwrapReposts( - validEvents, - (ids) => nostr.query([{ ids, limit: ids.length }], { signal: querySignal }), - now, - ); + const items: FeedItem[] = []; + const repostMissingIds: string[] = []; + const repostMap = new Map(); + + 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 { + // Direct post or other 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: querySignal }, + ); + 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 + } + } // Sort by timestamp const sorted = items.sort((a, b) => b.sortTimestamp - a.sortTimestamp); @@ -238,4 +273,127 @@ export function useProfileLikes(pubkey: string | undefined, active: boolean) { }); } +/** Page result for a custom tab feed query. */ +interface TabFeedPage { + items: FeedItem[]; + oldestQueryTimestamp: number; + rawCount: number; + /** The limit used for the relay query — compared against rawCount to detect exhaustion. */ + fetchLimit: number; +} +/** + * Infinite-scroll hook for a custom profile tab. + * + * Accepts a resolved NostrFilter (after variable substitution) and paginates + * through matching events using timestamp-based cursors — exactly like + * `useProfileFeed`, but with arbitrary filter parameters (kinds, authors, + * NIP-50 search, etc.). + * + * @param filter - The fully-resolved Nostr filter. May include `search` for NIP-50 queries. + * @param tabKey - A stable string key used to namespace the query cache (e.g. the tab label). + * @param enabled - Whether to start fetching (set to false while the filter is still resolving). + */ +export function useTabFeed( + filter: NostrFilter | null, + tabKey: string, + enabled = true, +) { + const { nostr } = useNostr(); + const { feedSettings } = useFeedSettings(); + + const defaultKinds = getEnabledFeedKinds(feedSettings); + const defaultKindsKey = [...defaultKinds].sort().join(','); + + // Stable string keys for query cache invalidation + const kindsKey = filter?.kinds ? [...filter.kinds].sort().join(',') : defaultKindsKey; + const authorsKey = filter?.authors ? [...filter.authors].sort().join(',') : ''; + const searchKey = filter?.search ?? ''; + + return useInfiniteQuery({ + queryKey: ['tab-feed', tabKey, kindsKey, authorsKey, searchKey], + queryFn: async ({ pageParam, signal }) => { + if (!filter) return { items: [], oldestQueryTimestamp: Math.floor(Date.now() / 1000), rawCount: 0, fetchLimit: PAGE_SIZE }; + + const querySignal = AbortSignal.any([signal, AbortSignal.timeout(8000)]); + const now = Math.floor(Date.now() / 1000); + + const kinds = (filter.kinds && filter.kinds.length > 0) ? filter.kinds : defaultKinds; + + const queryFilter: Record = { + kinds, + limit: PAGE_SIZE, + }; + + if (filter.authors && filter.authors.length > 0) { + queryFilter.authors = filter.authors; + } + + if (filter.search) { + queryFilter.search = filter.search; + } + + if (pageParam) { + queryFilter.until = pageParam; + } + + const allEvents = await nostr.query( + [queryFilter as NostrFilter], + { signal: querySignal }, + ); + + const validEvents = allEvents.filter((ev) => ev.created_at <= now); + const oldestQueryTimestamp = getPaginationCursor(validEvents); + + // Process events into FeedItems, unwrapping reposts + const items: FeedItem[] = []; + const repostMissingIds: string[] = []; + const repostMap = new Map(); + + for (const ev of validEvents) { + 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 nostr.query( + [{ ids: repostMissingIds, limit: repostMissingIds.length }], + { signal: querySignal }, + ); + 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 — skip missing reposts + } + } + + const sorted = items.sort((a, b) => b.sortTimestamp - a.sortTimestamp); + return { items: sorted, oldestQueryTimestamp, rawCount: validEvents.length, fetchLimit: PAGE_SIZE }; + }, + getNextPageParam: (lastPage) => { + // Relay exhausted: returned fewer events than requested. + if (lastPage.rawCount < lastPage.fetchLimit) return undefined; + return lastPage.oldestQueryTimestamp - 1; + }, + initialPageParam: undefined as number | undefined, + enabled: enabled && !!filter, + staleTime: 30 * 1000, + }); +} diff --git a/src/hooks/useResolveTabFilter.ts b/src/hooks/useResolveTabFilter.ts new file mode 100644 index 00000000..90756bf3 --- /dev/null +++ b/src/hooks/useResolveTabFilter.ts @@ -0,0 +1,88 @@ +/** + * useResolveTabFilter + * + * Resolves variable placeholders in a tab filter by: + * 1. Setting `$me` to the owner's pubkey + * 2. Fetching events referenced by `var` tags + * 3. Extracting tag values to populate variables + * 4. Substituting all variables into the filter + */ +import { useMemo } from 'react'; +import { useNostr } from '@nostrify/react'; +import { useQueries } from '@tanstack/react-query'; +import { resolvePointer, resolveFilter, type TabFilter, type TabVarDef } from '@/lib/profileTabsEvent'; +import type { NostrFilter } from '@nostrify/nostrify'; + +interface ResolvedTabFilter { + filter: NostrFilter | null; + isLoading: boolean; +} + +/** + * Resolve a tab filter with variable substitution. + * + * @param tabFilter - The filter with potential `$variable` placeholders + * @param vars - Variable definitions from the kind 16769 event + * @param ownerPubkey - The profile owner's pubkey (used as `$me`) + */ +export function useResolveTabFilter( + tabFilter: TabFilter, + vars: TabVarDef[], + ownerPubkey: string, +): ResolvedTabFilter { + const { nostr } = useNostr(); + + const runtimeVars = useMemo(() => ({ '$me': ownerPubkey }), [ownerPubkey]); + + // Build queries for each var definition + const varQueries = useQueries({ + queries: vars.map((v) => { + const pointer = resolvePointer(v.pointer, runtimeVars); + + const queryFilter: NostrFilter | null = pointer + ? pointer.type === 'e' + ? { ids: [pointer.id], limit: 1 } + : { kinds: [pointer.kind], authors: [pointer.pubkey], '#d': [pointer.dTag], limit: 1 } + : null; + + return { + queryKey: ['tab-var', v.name, v.pointer, ownerPubkey], + queryFn: async ({ signal }: { signal: AbortSignal }) => { + if (!queryFilter) return []; + const events = await nostr.query( + [queryFilter], + { signal: AbortSignal.any([signal, AbortSignal.timeout(5000)]) }, + ); + if (events.length === 0) return []; + // Extract all values of the specified tag + return events[0].tags + .filter(([name]) => name === v.tagName) + .map(([, value]) => value) + .filter(Boolean) as string[]; + }, + staleTime: 5 * 60 * 1000, + gcTime: 10 * 60 * 1000, + enabled: !!pointer, + }; + }), + }); + + const isLoading = varQueries.some((q) => q.isLoading); + + const resolvedFilter = useMemo(() => { + if (isLoading) return null; + + // Build resolved vars map + const resolvedVars: Record = {}; + vars.forEach((v, i) => { + const data = varQueries[i]?.data; + if (data) { + resolvedVars[v.name] = data; + } + }); + + return resolveFilter(tabFilter, resolvedVars, runtimeVars); + }, [isLoading, vars, varQueries, tabFilter, runtimeVars]); + + return { filter: resolvedFilter, isLoading }; +} diff --git a/src/hooks/useSavedFeeds.ts b/src/hooks/useSavedFeeds.ts index 8520b7e7..dfe6d317 100644 --- a/src/hooks/useSavedFeeds.ts +++ b/src/hooks/useSavedFeeds.ts @@ -2,8 +2,7 @@ import { useMemo } from 'react'; import { useEncryptedSettings } from './useEncryptedSettings'; import { useCurrentUser } from './useCurrentUser'; import { useAppContext } from './useAppContext'; -import type { SavedFeed } from '@/contexts/AppContext'; -import type { NostrEvent } from '@nostrify/nostrify'; +import type { SavedFeed, TabFilter, TabVarDef } from '@/contexts/AppContext'; /** * CRUD hook for saved feed tabs. @@ -41,14 +40,15 @@ export function useSavedFeeds() { } }; - /** Add a new saved feed from a spell event. Returns the created feed. */ - const addSavedFeed = async (label: string, spell: NostrEvent): Promise => { + /** Add a new saved feed. Returns the created feed. */ + const addSavedFeed = async (label: string, filter: TabFilter, vars: TabVarDef[]): Promise => { if (!user) throw new Error('Must be logged in to save feeds'); const newFeed: SavedFeed = { id: crypto.randomUUID(), label: label.trim(), - spell, + filter, + vars, createdAt: Date.now(), }; @@ -62,11 +62,17 @@ export function useSavedFeeds() { await persist(savedFeeds.filter((f) => f.id !== id)); }; - /** Update a saved feed's label and/or spell. */ - const updateSavedFeed = async (id: string, changes: Partial>): Promise => { + /** Rename a saved feed. */ + const renameSavedFeed = async (id: string, label: string): Promise => { + if (!user) throw new Error('Must be logged in to rename feeds'); + await persist(savedFeeds.map((f) => f.id === id ? { ...f, label: label.trim() } : f)); + }; + + /** Update a saved feed's label, filter, and/or vars. */ + const updateSavedFeed = async (id: string, changes: Partial>): Promise => { if (!user) throw new Error('Must be logged in to update feeds'); await persist(savedFeeds.map((f) => - f.id === id ? { ...f, ...changes, label: (changes.label ?? f.label).trim() } : f, + f.id === id ? { ...f, ...changes, label: (changes.label ?? f.label).trim(), vars: changes.vars ?? f.vars } : f, )); }; @@ -75,6 +81,7 @@ export function useSavedFeeds() { isLoading, addSavedFeed, removeSavedFeed, + renameSavedFeed, updateSavedFeed, isPending: updateSettings.isPending, }; diff --git a/src/lib/feedFilterUtils.ts b/src/lib/feedFilterUtils.ts index 59d1107b..ce1ee500 100644 --- a/src/lib/feedFilterUtils.ts +++ b/src/lib/feedFilterUtils.ts @@ -40,3 +40,10 @@ export function buildKindOptions(): KindOption[] { return true; }); } + +/** Extract selected kind strings from a TabFilter (for populating multi-select). */ +export function parseSelectedKinds(filter: Record): string[] { + const kinds = filter.kinds; + if (!Array.isArray(kinds) || kinds.length === 0) return []; + return kinds.map(String); +} diff --git a/src/lib/profileTabsEvent.ts b/src/lib/profileTabsEvent.ts index fc3943ef..8a931c33 100644 --- a/src/lib/profileTabsEvent.ts +++ b/src/lib/profileTabsEvent.ts @@ -3,36 +3,64 @@ * * Replaceable event. One per user. * - * Each tab stores a kind:777 spell event (JSON-encoded): - * ["tab", "