From b0b3dc176a5b44a40ab9601ef21aae207b8c24dd Mon Sep 17 00:00:00 2001 From: dergigi Date: Wed, 17 Jun 2026 02:20:32 +0200 Subject: [PATCH 1/4] fix: render muted search results as profiles --- src/lib/search/searchTypeDetection.ts | 2 +- .../search/strategies/authorSearchStrategy.ts | 48 ++++++++++++++++++- 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/src/lib/search/searchTypeDetection.ts b/src/lib/search/searchTypeDetection.ts index e7ce6866..2d597694 100644 --- a/src/lib/search/searchTypeDetection.ts +++ b/src/lib/search/searchTypeDetection.ts @@ -6,7 +6,7 @@ export function detectSearchType(query: string): 'profile' | 'media' | 'text' | const trimmedQuery = query.trim().toLowerCase(); // Check for profile searches (p: prefix) - if (trimmedQuery.includes('p:') || trimmedQuery.includes('by:')) { + if (trimmedQuery.includes('p:') || trimmedQuery.includes('by:') || trimmedQuery.includes('is:muted') || trimmedQuery.includes('kind:10000')) { return 'profile'; } diff --git a/src/lib/search/strategies/authorSearchStrategy.ts b/src/lib/search/strategies/authorSearchStrategy.ts index a3d7f7a7..bb9375a3 100644 --- a/src/lib/search/strategies/authorSearchStrategy.ts +++ b/src/lib/search/strategies/authorSearchStrategy.ts @@ -1,6 +1,6 @@ import { NDKEvent, NDKFilter, NDKRelaySet } from '@nostr-dev-kit/ndk'; import { ndk } from '../../ndk'; -import { resolveAuthor } from '../../vertex'; +import { profileEventFromPubkey, resolveAuthor } from '../../vertex'; import { RELAYS } from '../../relays'; import { applyDateFilter } from '../queryParsing'; import { buildSearchQueryWithExtensions } from '../searchUtils'; @@ -11,6 +11,46 @@ import { getBroadRelaySet } from '../relayManagement'; import { sortEventsNewestFirst } from '../../utils/searchUtils'; import { SearchContext } from '../types'; +function extractMuteListPubkeys(events: NDKEvent[]): string[] { + const newestByAuthor = new Map(); + + for (const event of sortEventsNewestFirst(events)) { + const authorPubkey = event.pubkey || event.author?.pubkey; + if (!authorPubkey || newestByAuthor.has(authorPubkey)) continue; + newestByAuthor.set(authorPubkey, event); + } + + const seen = new Set(); + const pubkeys: string[] = []; + + for (const event of newestByAuthor.values()) { + for (const tag of event.tags as string[][]) { + const rawPubkey = Array.isArray(tag) && tag[0] === 'p' && typeof tag[1] === 'string' ? tag[1] : ''; + const pubkey = rawPubkey.trim().toLowerCase(); + if (!/^[0-9a-f]{64}$/i.test(pubkey) || seen.has(pubkey)) continue; + seen.add(pubkey); + pubkeys.push(pubkey); + } + } + + return pubkeys; +} + +async function expandMuteListResults(events: NDKEvent[]): Promise { + const pubkeys = extractMuteListPubkeys(events); + if (pubkeys.length === 0) return []; + + const profiles = await Promise.all(pubkeys.map(async (pubkey) => { + try { + return await profileEventFromPubkey(pubkey); + } catch { + return null; + } + })); + + return profiles.filter((event): event is NDKEvent => event !== null); +} + /** * Handle author filter queries (by:) * Returns null if the query is not an author search @@ -139,7 +179,11 @@ export async function tryHandleAuthorSearch( mergedResults = Array.from(dedupe.values()); // Do not enforce additional client-side text match; rely on relay-side search const filtered = mergedResults; + + if (effectiveKinds.length === 1 && effectiveKinds[0] === 10000 && !termStr) { + const profiles = await expandMuteListResults(filtered); + if (profiles.length > 0) return profiles; + } return sortEventsNewestFirst(filtered).slice(0, limit); } - From 6d1484ba78a93da58f64201cd92ff85108f643dd Mon Sep 17 00:00:00 2001 From: dergigi Date: Thu, 18 Jun 2026 15:05:18 +0200 Subject: [PATCH 2/4] fix: address muted search review feedback --- src/lib/search/searchTypeDetection.ts | 9 +++-- .../search/strategies/authorSearchStrategy.ts | 33 ++++++++++++++----- 2 files changed, 31 insertions(+), 11 deletions(-) diff --git a/src/lib/search/searchTypeDetection.ts b/src/lib/search/searchTypeDetection.ts index 2d597694..585d1982 100644 --- a/src/lib/search/searchTypeDetection.ts +++ b/src/lib/search/searchTypeDetection.ts @@ -1,12 +1,17 @@ +const MUTED_TOKEN_RX = /(?:^|\s)is:muted(?=$|[\s),.;])/; +const KIND_10000_TOKEN_RX = /(?:^|\s)kind:10000(?=$|[\s),.;])/; + /** * Detects the type of search based on query patterns * Used to determine appropriate placeholder components */ export function detectSearchType(query: string): 'profile' | 'media' | 'text' | 'generic' { const trimmedQuery = query.trim().toLowerCase(); - + const hasMutedToken = MUTED_TOKEN_RX.test(trimmedQuery); + const hasKind10000Token = KIND_10000_TOKEN_RX.test(trimmedQuery); + // Check for profile searches (p: prefix) - if (trimmedQuery.includes('p:') || trimmedQuery.includes('by:') || trimmedQuery.includes('is:muted') || trimmedQuery.includes('kind:10000')) { + if (trimmedQuery.includes('p:') || trimmedQuery.includes('by:') || hasMutedToken || hasKind10000Token) { return 'profile'; } diff --git a/src/lib/search/strategies/authorSearchStrategy.ts b/src/lib/search/strategies/authorSearchStrategy.ts index bb9375a3..afde514b 100644 --- a/src/lib/search/strategies/authorSearchStrategy.ts +++ b/src/lib/search/strategies/authorSearchStrategy.ts @@ -11,6 +11,11 @@ import { getBroadRelaySet } from '../relayManagement'; import { sortEventsNewestFirst } from '../../utils/searchUtils'; import { SearchContext } from '../types'; +const MUTE_LIST_PROFILE_BATCH_SIZE = 20; + +/** + * Collect muted pubkeys from the newest mute list event per author. + */ function extractMuteListPubkeys(events: NDKEvent[]): string[] { const newestByAuthor = new Map(); @@ -36,19 +41,29 @@ function extractMuteListPubkeys(events: NDKEvent[]): string[] { return pubkeys; } +/** + * Resolve muted pubkeys into profile events without fanning out unbounded requests. + */ async function expandMuteListResults(events: NDKEvent[]): Promise { const pubkeys = extractMuteListPubkeys(events); if (pubkeys.length === 0) return []; - const profiles = await Promise.all(pubkeys.map(async (pubkey) => { - try { - return await profileEventFromPubkey(pubkey); - } catch { - return null; - } - })); + const results: NDKEvent[] = []; + + for (let i = 0; i < pubkeys.length; i += MUTE_LIST_PROFILE_BATCH_SIZE) { + const batch = pubkeys.slice(i, i + MUTE_LIST_PROFILE_BATCH_SIZE); + const profiles = await Promise.all(batch.map(async (pubkey) => { + try { + return await profileEventFromPubkey(pubkey); + } catch { + return null; + } + })); + + results.push(...profiles.filter((event): event is NDKEvent => event !== null)); + } - return profiles.filter((event): event is NDKEvent => event !== null); + return results; } /** @@ -182,7 +197,7 @@ export async function tryHandleAuthorSearch( if (effectiveKinds.length === 1 && effectiveKinds[0] === 10000 && !termStr) { const profiles = await expandMuteListResults(filtered); - if (profiles.length > 0) return profiles; + return profiles.slice(0, limit); } return sortEventsNewestFirst(filtered).slice(0, limit); From 31731dbfe0e4cc7195af4492bbe4cd2a9cc71040 Mon Sep 17 00:00:00 2001 From: dergigi Date: Thu, 18 Jun 2026 15:25:31 +0200 Subject: [PATCH 3/4] fix: expand all muted profile tags --- src/lib/search/strategies/authorSearchStrategy.ts | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/src/lib/search/strategies/authorSearchStrategy.ts b/src/lib/search/strategies/authorSearchStrategy.ts index afde514b..f0280316 100644 --- a/src/lib/search/strategies/authorSearchStrategy.ts +++ b/src/lib/search/strategies/authorSearchStrategy.ts @@ -14,21 +14,13 @@ import { SearchContext } from '../types'; const MUTE_LIST_PROFILE_BATCH_SIZE = 20; /** - * Collect muted pubkeys from the newest mute list event per author. + * Collect muted pubkeys from the fetched mute-list events. */ function extractMuteListPubkeys(events: NDKEvent[]): string[] { - const newestByAuthor = new Map(); - - for (const event of sortEventsNewestFirst(events)) { - const authorPubkey = event.pubkey || event.author?.pubkey; - if (!authorPubkey || newestByAuthor.has(authorPubkey)) continue; - newestByAuthor.set(authorPubkey, event); - } - const seen = new Set(); const pubkeys: string[] = []; - for (const event of newestByAuthor.values()) { + for (const event of sortEventsNewestFirst(events)) { for (const tag of event.tags as string[][]) { const rawPubkey = Array.isArray(tag) && tag[0] === 'p' && typeof tag[1] === 'string' ? tag[1] : ''; const pubkey = rawPubkey.trim().toLowerCase(); From 39bcac773e67659344d07e822086b44215934006 Mon Sep 17 00:00:00 2001 From: dergigi Date: Thu, 18 Jun 2026 18:51:31 +0200 Subject: [PATCH 4/4] fix: preserve raw mute list results --- src/components/MuteListCard.tsx | 136 ++++++++++++++++++ src/components/SearchResultsList.tsx | 9 ++ src/lib/search/muteListResultData.ts | 20 +++ .../search/strategies/authorSearchStrategy.ts | 59 ++++++-- 4 files changed, 211 insertions(+), 13 deletions(-) create mode 100644 src/components/MuteListCard.tsx create mode 100644 src/lib/search/muteListResultData.ts diff --git a/src/components/MuteListCard.tsx b/src/components/MuteListCard.tsx new file mode 100644 index 00000000..7b4ae69a --- /dev/null +++ b/src/components/MuteListCard.tsx @@ -0,0 +1,136 @@ +'use client'; + +import { useMemo, type ReactNode } from 'react'; +import { NDKEvent, NDKUser } from '@nostr-dev-kit/ndk'; +import { nip19 } from 'nostr-tools'; +import EventCard from '@/components/EventCard'; +import AuthorBadge from '@/components/AuthorBadge'; +import ProfileImage from '@/components/ProfileImage'; +import { ndk } from '@/lib/ndk'; +import { shortenNpub } from '@/lib/utils'; +import { prepareProfileEventForPrefetch, setPrefetchedProfile } from '@/lib/profile/prefetch'; +import { getMuteListResultData } from '@/lib/search/muteListResultData'; + +function buildUser(pubkey: string, profileEvent: NDKEvent | null): NDKUser { + const user = profileEvent?.author || new NDKUser({ pubkey }); + user.ndk = ndk; + + if (profileEvent?.author?.profile) { + user.profile = profileEvent.author.profile; + } + + return user; +} + +function MutedProfileRow({ + pubkey, + profileEvent, + onAuthorClick +}: { + pubkey: string; + profileEvent: NDKEvent | null; + onAuthorClick?: (npub: string) => void; +}) { + const user = useMemo(() => buildUser(pubkey, profileEvent), [profileEvent, pubkey]); + + const handleOpenProfile = () => { + try { + if (profileEvent) { + setPrefetchedProfile(pubkey, prepareProfileEventForPrefetch(profileEvent)); + } + } catch {} + + if (onAuthorClick) { + onAuthorClick(user.npub); + return; + } + + if (typeof window !== 'undefined') { + window.location.href = `/p/${user.npub}`; + } + }; + + let npub = user.npub; + if (!npub) { + try { + npub = nip19.npubEncode(pubkey); + } catch { + npub = pubkey; + } + } + + return ( +
+ +
+ +
+ +
+
+
+ ); +} + +type MuteListCardProps = { + event: NDKEvent; + onAuthorClick?: (npub: string) => void; + footerRight?: ReactNode; + className?: string; +}; + +export default function MuteListCard({ event, onAuthorClick, footerRight, className }: MuteListCardProps) { + const data = getMuteListResultData(event); + const pubkeys = data?.pubkeys || []; + const profilesByPubkey = useMemo(() => { + const map = new Map(); + for (const profile of data?.profiles || []) { + map.set(profile.pubkey.toLowerCase(), profile); + } + return map; + }, [data]); + + return ( + ( +
+
+ {pubkeys.length} muted {pubkeys.length === 1 ? 'profile' : 'profiles'} +
+ {pubkeys.length > 0 ? ( +
+ {pubkeys.map((pubkey) => ( + + ))} +
+ ) : ( +
(no muted profiles)
+ )} +
+ )} + /> + ); +} diff --git a/src/components/SearchResultsList.tsx b/src/components/SearchResultsList.tsx index 70312bd7..8b2b72c6 100644 --- a/src/components/SearchResultsList.tsx +++ b/src/components/SearchResultsList.tsx @@ -6,6 +6,7 @@ import { type SlashCommand } from '@/lib/slashCommands'; import EventCard from '@/components/EventCard'; import ArticleCard from '@/components/ArticleCard'; import ProfileCard from '@/components/ProfileCard'; +import MuteListCard from '@/components/MuteListCard'; import TruncatedText from '@/components/TruncatedText'; import ImageWithBlurhash from '@/components/ImageWithBlurhash'; import VideoWithBlurhash from '@/components/VideoWithBlurhash'; @@ -15,6 +16,7 @@ import NeventSearchButton from '@/components/NeventSearchButton'; import SearchCommandCard from '@/components/SearchCommandCard'; import { SearchResultsPlaceholder } from '@/components/Placeholder'; import { detectSearchType } from '@/lib/search/searchTypeDetection'; +import { getMuteListResultData } from '@/lib/search/muteListResultData'; import { extractImetaImageUrls, extractImetaVideoUrls, extractImetaBlurhashes, extractImetaDimensions, extractImetaHashes } from '@/lib/picture'; import { extractVideoUrls, getFilenameFromUrl } from '@/lib/utils/urlUtils'; import { trimImageUrl } from '@/lib/utils'; @@ -108,6 +110,13 @@ function SearchResultsList({ results, loading, query, isDirectQuery, topCommandT )} mediaRenderer={renderNoteMedia} /> + ) : event.kind === 10000 && getMuteListResultData(event) ? ( + } + /> ) : event.kind === 20 ? ( (); + +export function setMuteListResultData(event: NDKEvent, data: MuteListResultData): NDKEvent { + muteListResultData.set(event, { + pubkeys: [...data.pubkeys], + profiles: [...data.profiles] + }); + return event; +} + +export function getMuteListResultData(event: NDKEvent): MuteListResultData | null { + return muteListResultData.get(event) || null; +} diff --git a/src/lib/search/strategies/authorSearchStrategy.ts b/src/lib/search/strategies/authorSearchStrategy.ts index f0280316..d8b4de92 100644 --- a/src/lib/search/strategies/authorSearchStrategy.ts +++ b/src/lib/search/strategies/authorSearchStrategy.ts @@ -9,6 +9,7 @@ import { subscribeAndCollect } from '../subscriptions'; import { searchByAnyTerms } from '../termSearch'; import { getBroadRelaySet } from '../relayManagement'; import { sortEventsNewestFirst } from '../../utils/searchUtils'; +import { setMuteListResultData } from '../muteListResultData'; import { SearchContext } from '../types'; const MUTE_LIST_PROFILE_BATCH_SIZE = 20; @@ -36,15 +37,17 @@ function extractMuteListPubkeys(events: NDKEvent[]): string[] { /** * Resolve muted pubkeys into profile events without fanning out unbounded requests. */ -async function expandMuteListResults(events: NDKEvent[]): Promise { +async function expandMuteListResults(events: NDKEvent[]): Promise<{ pubkeys: string[]; profiles: NDKEvent[] }> { const pubkeys = extractMuteListPubkeys(events); - if (pubkeys.length === 0) return []; + if (pubkeys.length === 0) { + return { pubkeys: [], profiles: [] }; + } - const results: NDKEvent[] = []; + const profiles: NDKEvent[] = []; for (let i = 0; i < pubkeys.length; i += MUTE_LIST_PROFILE_BATCH_SIZE) { const batch = pubkeys.slice(i, i + MUTE_LIST_PROFILE_BATCH_SIZE); - const profiles = await Promise.all(batch.map(async (pubkey) => { + const resolvedProfiles = await Promise.all(batch.map(async (pubkey) => { try { return await profileEventFromPubkey(pubkey); } catch { @@ -52,10 +55,31 @@ async function expandMuteListResults(events: NDKEvent[]): Promise { } })); - results.push(...profiles.filter((event): event is NDKEvent => event !== null)); + profiles.push(...resolvedProfiles.filter((event): event is NDKEvent => event !== null)); } - return results; + return { pubkeys, profiles }; +} + +function isMuteListProfileSearch(effectiveKinds: number[], terms: string): boolean { + return effectiveKinds.length === 1 && effectiveKinds[0] === 10000 && !terms.trim(); +} + +function emitMuteListPartialResults(events: NDKEvent[], onPartialResults?: (results: NDKEvent[]) => void): void { + if (!onPartialResults) return; + + const representative = sortEventsNewestFirst(events)[0]; + if (!representative) { + onPartialResults([]); + return; + } + + setMuteListResultData(representative, { + pubkeys: extractMuteListPubkeys(events), + profiles: [] + }); + + onPartialResults([representative]); } /** @@ -90,6 +114,11 @@ export async function tryHandleAuthorSearch( return []; } + const isMuteListSearch = isMuteListProfileSearch(effectiveKinds, terms); + const partialResultsHandler = isMuteListSearch + ? (events: NDKEvent[]) => emitMuteListPartialResults(events, onPartialResults) + : onPartialResults; + const filters: NDKFilter = applyDateFilter({ kinds: effectiveKinds, authors: [pubkey], @@ -118,7 +147,7 @@ export async function tryHandleAuthorSearch( ? buildSearchQueryWithExtensions(seed, nip50Extensions) : seed; const f: NDKFilter = applyDateFilter({ kinds: effectiveKinds, authors: [pubkey], search: searchQuery, limit: Math.max(limit, 200) }, dateFilter) as NDKFilter; - return await subscribeAndCollect(f, { timeoutMs: 8000, relaySet: chosenRelaySet, abortSignal, onPartial: onPartialResults }); + return await subscribeAndCollect(f, { timeoutMs: 8000, relaySet: chosenRelaySet, abortSignal, onPartial: partialResultsHandler }); } catch { return []; } })); const seen = new Set(); @@ -126,10 +155,10 @@ export async function tryHandleAuthorSearch( for (const e of r) { if (!seen.has(e.id)) { seen.add(e.id); res.push(e); } } } } else { - res = await subscribeAndCollect(filters, { timeoutMs: 8000, relaySet: chosenRelaySet, abortSignal, onPartial: onPartialResults }); + res = await subscribeAndCollect(filters, { timeoutMs: 8000, relaySet: chosenRelaySet, abortSignal, onPartial: partialResultsHandler }); } } else { - res = await subscribeAndCollect(filters, { timeoutMs: 8000, relaySet: chosenRelaySet, abortSignal, onPartial: onPartialResults }); + res = await subscribeAndCollect(filters, { timeoutMs: 8000, relaySet: chosenRelaySet, abortSignal, onPartial: partialResultsHandler }); } // If the remaining terms contain parenthesized OR seeds like (a OR b), run a seeded OR search too @@ -162,7 +191,7 @@ export async function tryHandleAuthorSearch( const broadRelays = Array.from(new Set([...RELAYS.DEFAULT, ...RELAYS.SEARCH])); const broadRelaySet = NDKRelaySet.fromRelayUrls(broadRelays, ndk); if (res.length === 0) { - res = await subscribeAndCollect(filters, { timeoutMs: 10000, relaySet: broadRelaySet, abortSignal, onPartial: onPartialResults }); + res = await subscribeAndCollect(filters, { timeoutMs: 10000, relaySet: broadRelaySet, abortSignal, onPartial: partialResultsHandler }); } // Additional fallback for very short terms (e.g., "GM") or stubborn empties: // some relays require >=3 chars for NIP-50 search; fetch author-only and filter client-side @@ -187,9 +216,13 @@ export async function tryHandleAuthorSearch( // Do not enforce additional client-side text match; rely on relay-side search const filtered = mergedResults; - if (effectiveKinds.length === 1 && effectiveKinds[0] === 10000 && !termStr) { - const profiles = await expandMuteListResults(filtered); - return profiles.slice(0, limit); + if (isMuteListSearch) { + const representative = sortEventsNewestFirst(filtered)[0]; + if (!representative) return []; + + const muteListData = await expandMuteListResults(filtered); + setMuteListResultData(representative, muteListData); + return [representative]; } return sortEventsNewestFirst(filtered).slice(0, limit);