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/searchTypeDetection.ts b/src/lib/search/searchTypeDetection.ts
index e7ce6866..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:')) {
+ 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 a3d7f7a7..d8b4de92 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';
@@ -9,8 +9,79 @@ 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;
+
+/**
+ * Collect muted pubkeys from the fetched mute-list events.
+ */
+function extractMuteListPubkeys(events: NDKEvent[]): string[] {
+ const seen = new Set();
+ const pubkeys: string[] = [];
+
+ 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();
+ if (!/^[0-9a-f]{64}$/i.test(pubkey) || seen.has(pubkey)) continue;
+ seen.add(pubkey);
+ pubkeys.push(pubkey);
+ }
+ }
+
+ return pubkeys;
+}
+
+/**
+ * Resolve muted pubkeys into profile events without fanning out unbounded requests.
+ */
+async function expandMuteListResults(events: NDKEvent[]): Promise<{ pubkeys: string[]; profiles: NDKEvent[] }> {
+ const pubkeys = extractMuteListPubkeys(events);
+ if (pubkeys.length === 0) {
+ return { pubkeys: [], profiles: [] };
+ }
+
+ 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 resolvedProfiles = await Promise.all(batch.map(async (pubkey) => {
+ try {
+ return await profileEventFromPubkey(pubkey);
+ } catch {
+ return null;
+ }
+ }));
+
+ profiles.push(...resolvedProfiles.filter((event): event is NDKEvent => event !== null));
+ }
+
+ 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]);
+}
+
/**
* Handle author filter queries (by:)
* Returns null if the query is not an author search
@@ -43,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],
@@ -71,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();
@@ -79,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
@@ -115,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
@@ -139,7 +215,15 @@ 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 (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);
}
-