-
Notifications
You must be signed in to change notification settings - Fork 5
fix: show muted search results as profile cards #296
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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}`; | ||
| } | ||
| }; | ||
|
|
||
| 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> | ||
| )} | ||
| /> | ||
| ); | ||
| } | ||
| 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; | ||
| } |
| 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'; | ||
|
|
@@ -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 | ||
|
|
@@ -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,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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Use These subscriptions can run with 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 Also applies to: 194-194 🤖 Prompt for AI AgentsSource: Coding guidelines |
||
| } | ||
|
|
||
| // 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<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 | ||
|
|
@@ -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); | ||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Use the resolved
npubvariable for click navigation/callbacks.The row computes a fallback
npubfor rendering, buthandleOpenProfilestill usesuser.npub. If fallback logic is hit, click behavior can point to a different/invalid target than what is displayed.Suggested patch
Also applies to: 53-60
🤖 Prompt for AI Agents