Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 136 additions & 0 deletions src/components/MuteListCard.tsx
Original file line number Diff line number Diff line change
@@ -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}`;
}
Comment on lines +43 to +50

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Use the resolved npub variable for click navigation/callbacks.

The row computes a fallback npub for rendering, but handleOpenProfile still uses user.npub. If fallback logic is hit, click behavior can point to a different/invalid target than what is displayed.

Suggested patch
-  const handleOpenProfile = () => {
+  const handleOpenProfile = () => {
@@
-    if (onAuthorClick) {
-      onAuthorClick(user.npub);
+    if (onAuthorClick) {
+      onAuthorClick(npub);
       return;
     }
@@
-      window.location.href = `/p/${user.npub}`;
+      window.location.href = `/p/${npub}`;
     }
   };

Also applies to: 53-60

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/MuteListCard.tsx` around lines 43 - 50, The click handler
logic in handleOpenProfile uses user.npub directly, but the component computes a
fallback npub variable for rendering. Replace all instances of user.npub with
the resolved npub variable in the onAuthorClick callback and
window.location.href navigation to ensure consistency between what is displayed
to the user and where the click navigation actually directs them. This applies
to both the initial click handler block (around lines 43-50) and the secondary
handler block (around lines 53-60).

};

let npub = user.npub;
if (!npub) {
try {
npub = nip19.npubEncode(pubkey);
} catch {
npub = pubkey;
}
}

return (
<div className="flex items-center gap-3 rounded-md border border-[#3d3d3d] bg-[#1f1f1f] px-3 py-2">
<button
type="button"
onClick={handleOpenProfile}
className="h-10 w-10 overflow-hidden rounded-full border border-[#3d3d3d] bg-[#2d2d2d] hover:opacity-80 transition-opacity"
title="Open profile"
>
<ProfileImage user={user} size={40} />
</button>
<div className="min-w-0 flex-1">
<AuthorBadge user={user} onAuthorClick={onAuthorClick} />
<div className="mt-1 min-w-0 text-xs text-gray-400">
<button
type="button"
onClick={handleOpenProfile}
className="truncate hover:underline"
title={npub}
>
{shortenNpub(npub)}
</button>
</div>
</div>
</div>
);
}

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<string, NDKEvent>();
for (const profile of data?.profiles || []) {
map.set(profile.pubkey.toLowerCase(), profile);
}
return map;
}, [data]);

return (
<EventCard
event={event}
onAuthorClick={onAuthorClick}
className={className}
footerRight={footerRight}
renderContent={() => (
<div className="space-y-3">
<div className="text-sm text-gray-300">
{pubkeys.length} muted {pubkeys.length === 1 ? 'profile' : 'profiles'}
</div>
{pubkeys.length > 0 ? (
<div className="grid grid-cols-1 gap-2">
{pubkeys.map((pubkey) => (
<MutedProfileRow
key={pubkey}
pubkey={pubkey}
profileEvent={profilesByPubkey.get(pubkey.toLowerCase()) || null}
onAuthorClick={onAuthorClick}
/>
))}
</div>
) : (
<div className="text-gray-400">(no muted profiles)</div>
)}
</div>
)}
/>
);
}
9 changes: 9 additions & 0 deletions src/components/SearchResultsList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -108,6 +110,13 @@ function SearchResultsList({ results, loading, query, isDirectQuery, topCommandT
)}
mediaRenderer={renderNoteMedia}
/>
) : event.kind === 10000 && getMuteListResultData(event) ? (
<MuteListCard
event={event}
onAuthorClick={goToProfile}
className={noteCardClasses}
footerRight={<NeventSearchButton eventId={event.id} timestamp={formatEventTimestamp(event)} onSearch={handleNeventSearch} />}
/>
) : event.kind === 20 ? (
<EventCard
{...getCommonEventCardProps(event, noteCardClasses)}
Expand Down
20 changes: 20 additions & 0 deletions src/lib/search/muteListResultData.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { NDKEvent } from '@nostr-dev-kit/ndk';

export type MuteListResultData = {
pubkeys: string[];
profiles: NDKEvent[];
};

const muteListResultData = new WeakMap<NDKEvent, MuteListResultData>();

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;
}
9 changes: 7 additions & 2 deletions src/lib/search/searchTypeDetection.ts
Original file line number Diff line number Diff line change
@@ -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';
}

Expand Down
96 changes: 90 additions & 6 deletions src/lib/search/strategies/authorSearchStrategy.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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<string>();
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:<author>)
* Returns null if the query is not an author search
Expand Down Expand Up @@ -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],
Expand Down Expand Up @@ -71,18 +147,18 @@ 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<string>();
for (const r of perSeed) {
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 });
Comment on lines +150 to +161

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Use getNip50SearchRelaySet() whenever filter.search is present.

These subscriptions can run with search filters but still pass chosenRelaySet/broadRelaySet directly. That violates the relay-selection contract for NIP-50 searches.

Suggested patch
+import { getNip50SearchRelaySet } from '`@/lib/relays/nip50`';
@@
-          return await subscribeAndCollect(f, { timeoutMs: 8000, relaySet: chosenRelaySet, abortSignal, onPartial: partialResultsHandler });
+          const relaySetForSearch = f.search ? await getNip50SearchRelaySet() : chosenRelaySet;
+          return await subscribeAndCollect(f, { timeoutMs: 8000, relaySet: relaySetForSearch, abortSignal, onPartial: partialResultsHandler });
@@
-      res = await subscribeAndCollect(filters, { timeoutMs: 8000, relaySet: chosenRelaySet, abortSignal, onPartial: partialResultsHandler });
+      const relaySetForSearch = filters.search ? await getNip50SearchRelaySet() : chosenRelaySet;
+      res = await subscribeAndCollect(filters, { timeoutMs: 8000, relaySet: relaySetForSearch, abortSignal, onPartial: partialResultsHandler });
@@
-    res = await subscribeAndCollect(filters, { timeoutMs: 10000, relaySet: broadRelaySet, abortSignal, onPartial: partialResultsHandler });
+    const relaySetForSearch = filters.search ? await getNip50SearchRelaySet() : broadRelaySet;
+    res = await subscribeAndCollect(filters, { timeoutMs: 10000, relaySet: relaySetForSearch, abortSignal, onPartial: partialResultsHandler });

As per coding guidelines, "Always pass an explicit NIP-50 relay set (via getNip50SearchRelaySet() in src/lib/relays/nip50.ts) when subscribing with a search filter" and "Never let a search subscription fall through to the default pool or an unfiltered relay set."

Also applies to: 194-194

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/search/strategies/authorSearchStrategy.ts` around lines 150 - 161,
The authorSearchStrategy function is passing chosenRelaySet or broadRelaySet
directly to subscribeAndCollect calls without checking if the filters contain a
search property. When filter.search is present, you must use
getNip50SearchRelaySet() instead of the default relay sets to comply with NIP-50
relay selection requirements. Update all three subscribeAndCollect calls in the
function (the one in the map callback around line 150, and the two else branches
around lines 156 and 159-160) to check whether the filters have a search
property and conditionally pass getNip50SearchRelaySet() as the relaySet
parameter when search is present, otherwise use the appropriate relay set.

Source: Coding guidelines

}

// If the remaining terms contain parenthesized OR seeds like (a OR b), run a seeded OR search too
Expand Down Expand Up @@ -115,7 +191,7 @@ export async function tryHandleAuthorSearch(
const broadRelays = Array.from(new Set<string>([...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
Expand All @@ -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);
}

Loading