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.
This commit is contained in:
@@ -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' : ''}`
|
||||
|
||||
+29
-30
@@ -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 (
|
||||
<PullToRefresh onRefresh={handleRefresh}>
|
||||
<div>
|
||||
{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>
|
||||
)}
|
||||
{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 ref={scrollRef} className="py-2" />
|
||||
</div>
|
||||
</PullToRefresh>
|
||||
);
|
||||
|
||||
@@ -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<void>;
|
||||
onSave: (label: string, filter: TabFilter, vars: TabVarDef[]) => Promise<void>;
|
||||
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<AuthorScope>(() => draftToScope(spellAuthors(initialSpell)));
|
||||
const [authorPubkeys, setAuthorPubkeys] = useState<string[]>(() => spellAuthorPubkeys(initialSpell));
|
||||
const [selectedKinds, setSelectedKinds] = useState<string[]>(() => 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<AuthorScope>(() => initFrom(initialFilter).scope);
|
||||
const [authorPubkeys, setAuthorPubkeys] = useState<string[]>(() => initFrom(initialFilter).authors);
|
||||
const [selectedKinds, setSelectedKinds] = useState<string[]>(() => 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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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<TabFilter> {
|
||||
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<ProfileAuthorScope>[] = [
|
||||
@@ -83,11 +113,24 @@ export function ProfileTabEditModal({
|
||||
const { data: followPacks = [] } = useFollowPacks();
|
||||
const isNew = !tab;
|
||||
|
||||
const initialFilter = useMemo<TabFilter>(() => {
|
||||
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<ProfileAuthorScope>(() => draftToProfileScope(spellAuthors(tab?.spell), ownerPubkey));
|
||||
const [peoplePubkeys, setPeoplePubkeys] = useState<string[]>(() => spellAuthorPubkeys(tab?.spell, ownerPubkey));
|
||||
const [selectedKinds, setSelectedKinds] = useState<string[]>(() => spellKinds(tab?.spell));
|
||||
const [query, setQuery] = useState(
|
||||
typeof initialFilter.search === 'string' ? initialFilter.search : '',
|
||||
);
|
||||
const [authorScope, setAuthorScope] = useState<ProfileAuthorScope>(
|
||||
filterToScope(initialFilter, ownerPubkey),
|
||||
);
|
||||
const [peoplePubkeys, setPeoplePubkeys] = useState<string[]>(
|
||||
filterToPeoplePubkeys(initialFilter, ownerPubkey),
|
||||
);
|
||||
const [selectedKinds, setSelectedKinds] = useState<string[]>(
|
||||
parseSelectedKinds(initialFilter),
|
||||
);
|
||||
const [portalContainer, setPortalContainer] = useState<HTMLElement | undefined>(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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
|
||||
/** 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:<id>` or `a:<kind>:<pubkey>:<d-tag>`. 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;
|
||||
}
|
||||
|
||||
|
||||
+165
-7
@@ -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<string, NostrEvent>();
|
||||
|
||||
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<TabFeedPage, Error>({
|
||||
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<string, unknown> = {
|
||||
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<string, NostrEvent>();
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<NostrFilter | null>(() => {
|
||||
if (isLoading) return null;
|
||||
|
||||
// Build resolved vars map
|
||||
const resolvedVars: Record<string, string[]> = {};
|
||||
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 };
|
||||
}
|
||||
@@ -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<SavedFeed> => {
|
||||
/** Add a new saved feed. Returns the created feed. */
|
||||
const addSavedFeed = async (label: string, filter: TabFilter, vars: TabVarDef[]): Promise<SavedFeed> => {
|
||||
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<Pick<SavedFeed, 'label' | 'spell'>>): Promise<void> => {
|
||||
/** Rename a saved feed. */
|
||||
const renameSavedFeed = async (id: string, label: string): Promise<void> => {
|
||||
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<Pick<SavedFeed, 'label' | 'filter' | 'vars'>>): Promise<void> => {
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -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, unknown>): string[] {
|
||||
const kinds = filter.kinds;
|
||||
if (!Array.isArray(kinds) || kinds.length === 0) return [];
|
||||
return kinds.map(String);
|
||||
}
|
||||
|
||||
+129
-15
@@ -3,36 +3,64 @@
|
||||
*
|
||||
* Replaceable event. One per user.
|
||||
*
|
||||
* Each tab stores a kind:777 spell event (JSON-encoded):
|
||||
* ["tab", "<label>", "<spellJSON>"]
|
||||
* Each tab is a `tab` tag whose third element is a JSON-encoded NIP-01 filter:
|
||||
* ["tab", "<label>", "<filterJSON>"]
|
||||
*
|
||||
* Variables ($me, $contacts) live inside the spell event's tags and are
|
||||
* resolved at runtime by the spell engine — no more `var` tags needed.
|
||||
* Variables (`$name`) may appear in filter string values. They are defined by
|
||||
* `var` tags that extract values from referenced Nostr events:
|
||||
* ["var", "$name", "<tag-to-extract>", "<event-pointer>"]
|
||||
*
|
||||
* The only runtime variable is `$me` — the profile owner's pubkey.
|
||||
*/
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import { z } from 'zod';
|
||||
import type { NostrEvent, NostrFilter } from '@nostrify/nostrify';
|
||||
|
||||
export const PROFILE_TABS_KIND = 16769;
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/** A single profile tab backed by a spell event. */
|
||||
/** A variable definition parsed from a `var` tag. */
|
||||
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:<id>` or `a:<kind>:<pubkey>:<d-tag>`. May contain variables. */
|
||||
pointer: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A tab filter is a standard NIP-01 filter object that may contain
|
||||
* variable placeholders (`$name`) in string positions.
|
||||
* After variable resolution, it becomes a plain `NostrFilter`.
|
||||
*/
|
||||
export type TabFilter = Record<string, unknown>;
|
||||
|
||||
/** A single profile tab. */
|
||||
export interface ProfileTab {
|
||||
label: string;
|
||||
spell: NostrEvent;
|
||||
filter: TabFilter;
|
||||
}
|
||||
|
||||
/** The full parsed result of a kind 16769 event. */
|
||||
export interface ProfileTabsData {
|
||||
tabs: ProfileTab[];
|
||||
vars: TabVarDef[];
|
||||
}
|
||||
|
||||
// ─── Zod schemas for strict validation ───────────────────────────────────────
|
||||
|
||||
/** Schema for a NIP-01 filter object (lenient — allows variable strings). */
|
||||
const TabFilterSchema = z.record(z.string(), z.unknown());
|
||||
|
||||
// ─── Parsing ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Parse a kind 16769 event into ProfileTabsData. Discards malformed entries. */
|
||||
export function parseProfileTabs(event: NostrEvent): ProfileTabsData {
|
||||
if (event.kind !== PROFILE_TABS_KIND) return { tabs: [] };
|
||||
if (event.kind !== PROFILE_TABS_KIND) return { tabs: [], vars: [] };
|
||||
|
||||
const tabs: ProfileTab[] = [];
|
||||
const vars: TabVarDef[] = [];
|
||||
|
||||
for (const tag of event.tags) {
|
||||
if (tag[0] === 'tab' && tag.length >= 3) {
|
||||
@@ -40,17 +68,22 @@ export function parseProfileTabs(event: NostrEvent): ProfileTabsData {
|
||||
if (!label) continue;
|
||||
try {
|
||||
const raw = JSON.parse(tag[2]);
|
||||
// Validate it looks like a NostrEvent with kind 777
|
||||
if (raw && typeof raw === 'object' && raw.kind === 777 && Array.isArray(raw.tags)) {
|
||||
tabs.push({ label, spell: raw as NostrEvent });
|
||||
}
|
||||
const parsed = TabFilterSchema.safeParse(raw);
|
||||
if (!parsed.success) continue;
|
||||
tabs.push({ label, filter: parsed.data });
|
||||
} catch {
|
||||
// skip malformed JSON
|
||||
// skip malformed filter JSON
|
||||
}
|
||||
} else if (tag[0] === 'var' && tag.length >= 4) {
|
||||
const name = tag[1];
|
||||
const tagName = tag[2];
|
||||
const pointer = tag[3];
|
||||
if (!name?.startsWith('$') || !tagName || !pointer) continue;
|
||||
vars.push({ name, tagName, pointer });
|
||||
}
|
||||
}
|
||||
|
||||
return { tabs };
|
||||
return { tabs, vars };
|
||||
}
|
||||
|
||||
// ─── Building ────────────────────────────────────────────────────────────────
|
||||
@@ -61,9 +94,90 @@ export function buildProfileTabsTags(data: ProfileTabsData): string[][] {
|
||||
['alt', 'Custom profile tabs'],
|
||||
];
|
||||
|
||||
for (const v of data.vars) {
|
||||
tags.push(['var', v.name, v.tagName, v.pointer]);
|
||||
}
|
||||
|
||||
for (const tab of data.tabs) {
|
||||
tags.push(['tab', tab.label, JSON.stringify(tab.spell)]);
|
||||
tags.push(['tab', tab.label, JSON.stringify(tab.filter)]);
|
||||
}
|
||||
|
||||
return tags;
|
||||
}
|
||||
|
||||
// ─── Variable Resolution ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Resolve a single event pointer string, substituting runtime variables.
|
||||
* Returns `{ type: 'e', id }` or `{ type: 'a', kind, pubkey, dTag }`.
|
||||
*/
|
||||
export function resolvePointer(
|
||||
pointer: string,
|
||||
runtimeVars: Record<string, string>,
|
||||
): { type: 'e'; id: string } | { type: 'a'; kind: number; pubkey: string; dTag: string } | null {
|
||||
// Substitute runtime vars in the pointer string
|
||||
let resolved = pointer;
|
||||
for (const [name, value] of Object.entries(runtimeVars)) {
|
||||
resolved = resolved.replaceAll(name, value);
|
||||
}
|
||||
|
||||
if (resolved.startsWith('e:')) {
|
||||
return { type: 'e', id: resolved.slice(2) };
|
||||
}
|
||||
|
||||
if (resolved.startsWith('a:')) {
|
||||
const parts = resolved.slice(2).split(':');
|
||||
if (parts.length < 3) return null;
|
||||
const kind = parseInt(parts[0], 10);
|
||||
if (isNaN(kind)) return null;
|
||||
return { type: 'a', kind, pubkey: parts[1], dTag: parts[2] };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve all variables in a filter, producing a standard NostrFilter.
|
||||
*
|
||||
* @param filter - The tab filter with variable placeholders.
|
||||
* @param resolvedVars - Map from variable name (e.g. `"$follows"`) to resolved values.
|
||||
* @param runtimeVars - Map of runtime variables (e.g. `{ "$me": "pubkey" }`).
|
||||
*/
|
||||
export function resolveFilter(
|
||||
filter: TabFilter,
|
||||
resolvedVars: Record<string, string[]>,
|
||||
runtimeVars: Record<string, string>,
|
||||
): NostrFilter {
|
||||
const allVars: Record<string, string[]> = { ...resolvedVars };
|
||||
// Runtime scalar vars are treated as single-element arrays for expansion
|
||||
for (const [name, value] of Object.entries(runtimeVars)) {
|
||||
if (!(name in allVars)) {
|
||||
allVars[name] = [value];
|
||||
}
|
||||
}
|
||||
|
||||
const result: Record<string, unknown> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(filter)) {
|
||||
if (Array.isArray(value)) {
|
||||
// Expand variables in arrays (splice in-place)
|
||||
const expanded: unknown[] = [];
|
||||
for (const item of value) {
|
||||
if (typeof item === 'string' && item.startsWith('$') && item in allVars) {
|
||||
expanded.push(...allVars[item]);
|
||||
} else {
|
||||
expanded.push(item);
|
||||
}
|
||||
}
|
||||
result[key] = expanded;
|
||||
} else if (typeof value === 'string' && value.startsWith('$') && value in allVars) {
|
||||
// Scalar string variable — expand to array if multiple values, otherwise keep as string
|
||||
const vals = allVars[value];
|
||||
result[key] = vals.length === 1 ? vals[0] : vals;
|
||||
} else {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return result as NostrFilter;
|
||||
}
|
||||
|
||||
+10
-10
@@ -180,21 +180,21 @@ export const FeedSettingsSchema = z.looseObject({
|
||||
feedIncludeBlobbi: z.boolean().optional(),
|
||||
});
|
||||
|
||||
/** Minimal schema for a signed Nostr event (used inside SavedFeed). */
|
||||
const NostrEventSchema = z.object({
|
||||
id: z.string(),
|
||||
pubkey: z.string(),
|
||||
created_at: z.number(),
|
||||
kind: z.number(),
|
||||
tags: z.array(z.array(z.string())),
|
||||
content: z.string(),
|
||||
sig: z.string(),
|
||||
/** Schema for a NIP-01 filter object (lenient — allows variable placeholder strings). */
|
||||
export const TabFilterSchema = z.record(z.string(), z.unknown());
|
||||
|
||||
/** Schema for a variable definition. */
|
||||
export const TabVarDefSchema = z.object({
|
||||
name: z.string(),
|
||||
tagName: z.string(),
|
||||
pointer: z.string(),
|
||||
});
|
||||
|
||||
export const SavedFeedSchema = z.object({
|
||||
id: z.string(),
|
||||
label: z.string(),
|
||||
spell: NostrEventSchema,
|
||||
filter: TabFilterSchema,
|
||||
vars: z.array(TabVarDefSchema).default([]),
|
||||
createdAt: z.number(),
|
||||
});
|
||||
|
||||
|
||||
@@ -110,9 +110,15 @@ After receiving results, summarize the key topics, conversations, and notable po
|
||||
};
|
||||
}
|
||||
try {
|
||||
const resolved = resolveSpell(match.spell, ctx.user?.pubkey, contactPubkeys);
|
||||
filter = { ...resolved.filter, since: sinceTimestamp, limit };
|
||||
needsDittoRelay = resolved.needsDittoRelay;
|
||||
// Resolve the saved feed's TabFilter — substitute $follows with contact pubkeys
|
||||
const savedFilter = { ...match.filter } as Record<string, unknown>;
|
||||
if (Array.isArray(savedFilter.authors)) {
|
||||
savedFilter.authors = (savedFilter.authors as string[]).flatMap((a) =>
|
||||
a === '$follows' ? contactPubkeys : [a],
|
||||
);
|
||||
}
|
||||
filter = { ...savedFilter, since: sinceTimestamp, limit } as NostrFilter;
|
||||
needsDittoRelay = typeof savedFilter.search === 'string' && /sort:|protocol:|media:/.test(savedFilter.search as string);
|
||||
feedLabel = match.label;
|
||||
} catch (err) {
|
||||
return { result: JSON.stringify({ error: `Failed to resolve saved feed "${match.label}": ${err instanceof Error ? err.message : 'Unknown error'}` }) };
|
||||
|
||||
@@ -52,7 +52,8 @@ export interface ToolContext {
|
||||
savedFeeds: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
spell: NostrEvent;
|
||||
filter: Record<string, unknown>;
|
||||
vars: Array<{ name: string; tagName: string; pointer: string }>;
|
||||
createdAt: number;
|
||||
}>;
|
||||
/** Apply a custom theme to the app. */
|
||||
|
||||
+69
-47
@@ -77,9 +77,9 @@ import { BadgeThumbnail } from '@/components/BadgeThumbnail';
|
||||
import { useProfileBadges } from '@/hooks/useProfileBadges';
|
||||
import { useBadgeDefinitions } from '@/hooks/useBadgeDefinitions';
|
||||
import { ProfileTabEditModal } from '@/components/ProfileTabEditModal';
|
||||
import { useStreamPosts } from '@/hooks/useStreamPosts';
|
||||
import { buildSpellTags, buildUnsignedSpell } from '@/lib/spellEngine';
|
||||
import type { ProfileTab, ProfileTabsData } from '@/lib/profileTabsEvent';
|
||||
import { useResolveTabFilter } from '@/hooks/useResolveTabFilter';
|
||||
import { useTabFeed } from '@/hooks/useProfileFeed';
|
||||
import type { ProfileTab, ProfileTabsData, TabFilter, TabVarDef } from '@/lib/profileTabsEvent';
|
||||
import {
|
||||
DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors,
|
||||
type DragEndEvent,
|
||||
@@ -1010,6 +1010,8 @@ export function ProfilePage() {
|
||||
return profileTabsData?.tabs ?? [];
|
||||
}, [profileTabsData]);
|
||||
|
||||
const profileVars = useMemo(() => profileTabsData?.vars ?? [], [profileTabsData]);
|
||||
|
||||
const { publishProfileTabs, isPending: isPublishingTabs } = usePublishProfileTabs();
|
||||
|
||||
// Tab edit mode (inline reorder/remove/add)
|
||||
@@ -1206,28 +1208,23 @@ type EditableTab = { label: string; isCore: boolean; tab?: ProfileTab };
|
||||
setLocalTabs((prev) => prev.filter((t) => t.label !== label));
|
||||
};
|
||||
|
||||
// Build a spell event for a core tab so it can be stored in kind 16769
|
||||
const buildCoreTabSpell = useCallback((label: string): NostrEvent => {
|
||||
const coreDefs: Record<string, { kinds?: number[]; authors?: string[]; tag_filters?: Array<{ letter: string; values: string[] }> }> = pubkey ? {
|
||||
'Posts': { kinds: [1, 6], authors: [pubkey] },
|
||||
'Posts & replies': { authors: [pubkey] },
|
||||
'Media': { kinds: [1], authors: [pubkey] },
|
||||
'Badges': { kinds: [10008, 30008], authors: [pubkey] },
|
||||
'Likes': { kinds: [7], authors: [pubkey] },
|
||||
'Wall': { kinds: [1111], tag_filters: [{ letter: 'A', values: [`0:${pubkey}:`] }] },
|
||||
} : {};
|
||||
const def = coreDefs[label] ?? {};
|
||||
const tags = buildSpellTags({ name: label, ...def });
|
||||
return buildUnsignedSpell(tags);
|
||||
}, [pubkey]);
|
||||
// Canonical NIP-01 filters for core tabs so other clients can interpret the event.
|
||||
const CORE_TAB_FILTERS: Record<string, TabFilter> = pubkey ? {
|
||||
'Posts': { kinds: [1, 6], authors: [pubkey] },
|
||||
'Posts & replies': { authors: [pubkey] },
|
||||
'Media': { kinds: [1], authors: [pubkey] },
|
||||
'Badges': { kinds: [10008, 30008], authors: [pubkey] },
|
||||
'Likes': { kinds: [7], authors: [pubkey] },
|
||||
'Wall': { kinds: [1111], '#A': [`0:${pubkey}:`] },
|
||||
} : {};
|
||||
|
||||
const handleSaveTabEdit = async () => {
|
||||
// Publish ALL tabs in order — core tabs get spell events,
|
||||
// custom tabs keep their existing spell objects
|
||||
// Publish ALL tabs in order — core tabs get canonical filters,
|
||||
// custom tabs keep their full filter objects
|
||||
const allTabs: ProfileTab[] = localTabs.map((t) =>
|
||||
t.tab ?? { label: t.label, spell: buildCoreTabSpell(t.label) },
|
||||
t.tab ?? { label: t.label, filter: CORE_TAB_FILTERS[t.label] ?? {} },
|
||||
);
|
||||
await publishProfileTabs({ tabs: allTabs });
|
||||
await publishProfileTabs({ tabs: allTabs, vars: profileVars });
|
||||
// If the active tab was removed, fall back to the first remaining tab
|
||||
const remainingIds = localTabs.map((t) => CORE_TAB_IDS[t.label] ?? t.label);
|
||||
if (!remainingIds.includes(activeTab)) {
|
||||
@@ -1250,7 +1247,7 @@ type EditableTab = { label: string; isCore: boolean; tab?: ProfileTab };
|
||||
const base = editingTab
|
||||
? profileSavedTabs.map((t) => t.label === editingTab.label ? tab : t)
|
||||
: [...profileSavedTabs, tab];
|
||||
await publishProfileTabs({ tabs: base });
|
||||
await publishProfileTabs({ tabs: base, vars: profileVars });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2573,6 +2570,8 @@ type EditableTab = { label: string; isCore: boolean; tab?: ProfileTab };
|
||||
{hasTabs && !isCoreProfileTab && profileSavedTabs.find((t) => t.label === activeTab) && pubkey && (
|
||||
<ProfileSavedFeedContent
|
||||
feed={profileSavedTabs.find((t) => t.label === activeTab)!}
|
||||
vars={profileVars}
|
||||
ownerPubkey={pubkey}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -3042,22 +3041,48 @@ function ProfileBadgesTab({ pubkey, displayName }: { pubkey: string; displayName
|
||||
|
||||
// ─── Profile Saved Feed Tab ───────────────────────────────────────────────────
|
||||
|
||||
function ProfileSavedFeedContent({ feed }: { feed: ProfileTab }) {
|
||||
const { posts, isLoading, newPostCount, flushStreamBuffer, loadMore, hasMore, isLoadingMore } = useStreamPosts('', {
|
||||
includeReplies: true,
|
||||
mediaType: 'all',
|
||||
spell: feed.spell,
|
||||
});
|
||||
function ProfileSavedFeedContent({ feed, vars, ownerPubkey }: {
|
||||
feed: ProfileTab;
|
||||
vars: TabVarDef[];
|
||||
ownerPubkey: string;
|
||||
}) {
|
||||
const { filter: resolvedFilter, isLoading: isResolving } = useResolveTabFilter(feed.filter, vars, ownerPubkey);
|
||||
|
||||
const { ref: spellScrollRef, inView: spellInView } = useInView({ threshold: 0, rootMargin: '400px' });
|
||||
const {
|
||||
data,
|
||||
isPending,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
} = useTabFeed(resolvedFilter, feed.label, !isResolving);
|
||||
|
||||
const { ref: tabScrollRef, inView: tabInView } = useInView({ threshold: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
if (spellInView && hasMore && !isLoadingMore) {
|
||||
loadMore();
|
||||
if (tabInView && hasNextPage && !isFetchingNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}, [spellInView, hasMore, isLoadingMore, loadMore]);
|
||||
}, [tabInView, hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||
|
||||
if (isLoading && posts.length === 0) {
|
||||
const items = useMemo(() => {
|
||||
if (!data?.pages) return [];
|
||||
const seen = new Set<string>();
|
||||
const deduped: FeedItem[] = [];
|
||||
for (const page of data.pages) {
|
||||
for (const item of page.items) {
|
||||
const key = item.repostedBy ? `repost-${item.repostedBy}-${item.event.id}` : item.event.id;
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
deduped.push(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
return deduped;
|
||||
}, [data]);
|
||||
|
||||
const isLoading = isResolving || isPending;
|
||||
|
||||
if (isLoading && items.length === 0) {
|
||||
return (
|
||||
<div className="space-y-0">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
@@ -3076,7 +3101,7 @@ function ProfileSavedFeedContent({ feed }: { feed: ProfileTab }) {
|
||||
);
|
||||
}
|
||||
|
||||
if (posts.length === 0) {
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="py-12 text-center text-muted-foreground text-sm">
|
||||
No posts found for “{feed.label}”.
|
||||
@@ -3086,20 +3111,17 @@ function ProfileSavedFeedContent({ feed }: { feed: ProfileTab }) {
|
||||
|
||||
return (
|
||||
<div>
|
||||
{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>
|
||||
)}
|
||||
{posts.map((event) => (
|
||||
<NoteCard key={event.id} event={event} />
|
||||
{items.map((item) => (
|
||||
<NoteCard
|
||||
key={item.repostedBy ? `repost-${item.repostedBy}-${item.event.id}` : item.event.id}
|
||||
event={item.event}
|
||||
repostedBy={item.repostedBy}
|
||||
/>
|
||||
))}
|
||||
{hasMore && (
|
||||
<div ref={spellScrollRef} className="py-4">
|
||||
{isLoadingMore && (
|
||||
|
||||
{hasNextPage && (
|
||||
<div ref={tabScrollRef} className="py-4">
|
||||
{isFetchingNextPage && (
|
||||
<div className="flex justify-center">
|
||||
<Loader2 className="size-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
+30
-31
@@ -57,7 +57,7 @@ import { TabButton } from '@/components/TabButton';
|
||||
import { ARC_OVERHANG_PX } from '@/components/ArcBackground';
|
||||
import { cn, parseKindFilter } from '@/lib/utils';
|
||||
import { shareOrCopy } from '@/lib/share';
|
||||
import { buildSpellTags, buildUnsignedSpell, spellFingerprint } from '@/lib/spellEngine';
|
||||
import { buildSpellTags } from '@/lib/spellEngine';
|
||||
import { useLayoutOptions, useNavHidden } from '@/contexts/LayoutContext';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { isRepostKind, parseRepostContent, shouldHideFeedEvent } from '@/lib/feedUtils';
|
||||
@@ -387,25 +387,28 @@ export function SearchPage() {
|
||||
});
|
||||
}, [debouncedSearchQuery, kindsOverride, authorScope, authorPubkeys, includeReplies, mediaType, language, platform, sort, saveFeedLabel]);
|
||||
|
||||
// Stable fingerprint for dedup (ignore name/alt tags which vary with the label input)
|
||||
const currentSpellFP = useMemo(
|
||||
() => spellFingerprint(buildUnsignedSpell(currentSpellTags)),
|
||||
[currentSpellTags],
|
||||
);
|
||||
// Build the current filter from the search state (for saving)
|
||||
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 === 'follows') filter.authors = ['$follows'];
|
||||
else if (authorScope === 'people' && authorPubkeys.length > 0) filter.authors = authorPubkeys;
|
||||
return filter;
|
||||
}, [debouncedSearchQuery, kindsOverride, authorScope, authorPubkeys]);
|
||||
|
||||
const alreadySaved = savedFeeds.some((f) => spellFingerprint(f.spell) === currentSpellFP);
|
||||
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;
|
||||
|
||||
// Update name tag with the actual label
|
||||
const tags = currentSpellTags.map(([t, ...rest]) =>
|
||||
t === 'name' ? ['name', saveFeedLabel.trim()] :
|
||||
t === 'alt' ? ['alt', `Spell: ${saveFeedLabel.trim()}`] :
|
||||
[t, ...rest]
|
||||
);
|
||||
const vars: import('@/lib/profileTabsEvent').TabVarDef[] = [];
|
||||
if (authorScope === 'follows' && user) {
|
||||
vars.push({ name: '$follows', tagName: 'p', pointer: `a:3:${user.pubkey}:` });
|
||||
}
|
||||
|
||||
await addSavedFeed(saveFeedLabel.trim(), buildUnsignedSpell(tags));
|
||||
await addSavedFeed(saveFeedLabel.trim(), currentFilter, vars);
|
||||
setSavePopoverOpen(false);
|
||||
setSaveFeedLabel('');
|
||||
setSavedJustNow(true);
|
||||
@@ -415,26 +418,22 @@ export function SearchPage() {
|
||||
const handleSaveProfileTab = async () => {
|
||||
if (!saveFeedLabel.trim() || isPublishingTabs || !user) return;
|
||||
|
||||
// Build authors array
|
||||
let profileAuthors: string[] | undefined;
|
||||
if (authorScope === 'follows') profileAuthors = ['$contacts'];
|
||||
else if (authorScope === 'people' && authorPubkeys.length > 0) profileAuthors = authorPubkeys;
|
||||
// Build filter for the profile tab
|
||||
const tabFilter: Record<string, unknown> = {};
|
||||
if (debouncedSearchQuery.trim()) tabFilter.search = debouncedSearchQuery.trim();
|
||||
if (kindsOverride && kindsOverride.length > 0) tabFilter.kinds = kindsOverride;
|
||||
if (authorScope === 'follows') tabFilter.authors = ['$follows'];
|
||||
else if (authorScope === 'people' && authorPubkeys.length > 0) tabFilter.authors = authorPubkeys;
|
||||
|
||||
const tags = buildSpellTags({
|
||||
name: saveFeedLabel.trim(),
|
||||
kinds: kindsOverride && kindsOverride.length > 0 ? kindsOverride : undefined,
|
||||
authors: profileAuthors,
|
||||
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,
|
||||
});
|
||||
const existing = profileTabsQuery.data ?? { tabs: [], vars: [] };
|
||||
const newVars = [...existing.vars];
|
||||
if (authorScope === 'follows' && !newVars.find((v) => v.name === '$follows')) {
|
||||
newVars.push({ name: '$follows', tagName: 'p', pointer: `a:3:${user.pubkey}:` });
|
||||
}
|
||||
|
||||
const existing = profileTabsQuery.data ?? { tabs: [] };
|
||||
await publishProfileTabs({
|
||||
tabs: [...existing.tabs, { label: saveFeedLabel.trim(), spell: buildUnsignedSpell(tags) }],
|
||||
tabs: [...existing.tabs, { label: saveFeedLabel.trim(), filter: tabFilter }],
|
||||
vars: newVars,
|
||||
});
|
||||
setSavePopoverOpen(false);
|
||||
setSaveFeedLabel('');
|
||||
|
||||
@@ -19,7 +19,7 @@ import { useStreamPosts } from '@/hooks/useStreamPosts';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { shareOrCopy } from '@/lib/share';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { resolveSpell, spellFingerprint } from '@/lib/spellEngine';
|
||||
import { resolveSpell } from '@/lib/spellEngine';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useFollowList } from '@/hooks/useFollowActions';
|
||||
import NotFound from './NotFound';
|
||||
@@ -96,25 +96,36 @@ export function SpellRunPage() {
|
||||
const { savedFeeds, addSavedFeed, removeSavedFeed } = useSavedFeeds();
|
||||
const { toast } = useToast();
|
||||
|
||||
/** Find an existing saved feed whose spell has the same semantic filter. */
|
||||
const matchingSavedFeed = useMemo(() => {
|
||||
/** Convert a spell event to a TabFilter for saving. */
|
||||
const spellAsFilter = useMemo(() => {
|
||||
if (!spellEvent) return undefined;
|
||||
const fp = spellFingerprint(spellEvent);
|
||||
return savedFeeds.find((f) => spellFingerprint(f.spell) === fp);
|
||||
}, [savedFeeds, spellEvent]);
|
||||
try {
|
||||
const resolved = resolveSpell(spellEvent, undefined, []);
|
||||
return resolved.filter;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}, [spellEvent]);
|
||||
|
||||
/** Find an existing saved feed that matches the spell's filter. */
|
||||
const matchingSavedFeed = useMemo(() => {
|
||||
if (!spellAsFilter) return undefined;
|
||||
const filterKey = JSON.stringify(spellAsFilter);
|
||||
return savedFeeds.find((f) => JSON.stringify(f.filter) === filterKey);
|
||||
}, [savedFeeds, spellAsFilter]);
|
||||
|
||||
const isSaved = !!matchingSavedFeed;
|
||||
|
||||
const handleToggleSaved = useCallback(async () => {
|
||||
if (!spellEvent) return;
|
||||
if (!spellEvent || !spellAsFilter) return;
|
||||
if (isSaved && matchingSavedFeed) {
|
||||
await removeSavedFeed(matchingSavedFeed.id);
|
||||
toast({ title: 'Removed from home feed' });
|
||||
} else {
|
||||
await addSavedFeed(spellName ?? 'Spell', spellEvent);
|
||||
await addSavedFeed(spellName ?? 'Spell', spellAsFilter as Record<string, unknown>, []);
|
||||
toast({ title: 'Added to home feed' });
|
||||
}
|
||||
}, [spellEvent, isSaved, matchingSavedFeed, spellName, addSavedFeed, removeSavedFeed, toast]);
|
||||
}, [spellEvent, spellAsFilter, isSaved, matchingSavedFeed, spellName, addSavedFeed, removeSavedFeed, toast]);
|
||||
|
||||
const handleShare = useCallback(async () => {
|
||||
if (!spellEvent || !nevent) return;
|
||||
|
||||
Reference in New Issue
Block a user