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:
Lemon
2026-04-12 11:21:20 -07:00
parent cb2d6618e8
commit af0e028cde
16 changed files with 731 additions and 250 deletions
+10 -12
View File
@@ -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
View File
@@ -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>
);
+54 -33
View File
@@ -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);
};
+75 -41
View File
@@ -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);
};
+19 -3
View File
@@ -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
View File
@@ -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,
});
}
+88
View File
@@ -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 };
}
+15 -8
View File
@@ -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,
};
+7
View File
@@ -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
View File
@@ -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
View File
@@ -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(),
});
+9 -3
View File
@@ -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'}` }) };
+2 -1
View File
@@ -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
View File
@@ -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 &ldquo;{feed.label}&rdquo;.
@@ -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
View File
@@ -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('');
+20 -9
View File
@@ -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;