From 9aa5724c4ec0e78f6d65a5534828a732ae2f9db0 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Thu, 4 Jun 2026 00:42:13 +0200 Subject: [PATCH] bug-fixes --- src/components/ProfileList/index.tsx | 32 ++++++- .../PubkeyListSearchField/index.tsx | 36 ++++++++ src/hooks/usePubkeyListSearchProfiles.ts | 58 +++++++++++++ src/i18n/locales/de.ts | 1 + src/i18n/locales/en.ts | 1 + .../filter-pubkeys-by-profile-search.test.ts | 39 +++++++++ src/lib/filter-pubkeys-by-profile-search.ts | 87 +++++++++++++++++++ .../secondary/FollowersListPage/index.tsx | 33 +++++-- .../secondary/FollowingListPage/index.tsx | 11 ++- src/pages/secondary/MuteListPage/index.tsx | 47 +++++++--- src/services/indexed-db.service.ts | 1 + 11 files changed, 320 insertions(+), 26 deletions(-) create mode 100644 src/components/PubkeyListSearchField/index.tsx create mode 100644 src/hooks/usePubkeyListSearchProfiles.ts create mode 100644 src/lib/filter-pubkeys-by-profile-search.test.ts create mode 100644 src/lib/filter-pubkeys-by-profile-search.ts diff --git a/src/components/ProfileList/index.tsx b/src/components/ProfileList/index.tsx index a1c7ec8f..7a09f87c 100644 --- a/src/components/ProfileList/index.tsx +++ b/src/components/ProfileList/index.tsx @@ -6,7 +6,14 @@ import UserItem from '../UserItem' const PROFILE_CHUNK = 80 -export default function ProfileList({ pubkeys }: { pubkeys: string[] }) { +export default function ProfileList({ + pubkeys, + seedProfiles +}: { + pubkeys: string[] + /** Profiles from list search (IndexedDB kind 0) — shown immediately without another fetch. */ + seedProfiles?: Map +}) { const [visiblePubkeys, setVisiblePubkeys] = useState([]) const [profilesByPubkey, setProfilesByPubkey] = useState>(() => new Map()) const bottomRef = useRef(null) @@ -104,8 +111,27 @@ export default function ProfileList({ pubkeys }: { pubkeys: string[] }) { useEffect(() => { batchGenRef.current += 1 loadedRef.current.clear() - setProfilesByPubkey(new Map()) - }, [pubkeysKey]) + const next = new Map() + if (seedProfiles) { + for (const [pk, p] of seedProfiles) { + next.set(pk.toLowerCase(), p) + loadedRef.current.add(pk.toLowerCase()) + } + } + setProfilesByPubkey(next) + }, [pubkeysKey, seedProfiles]) + + useEffect(() => { + if (!seedProfiles?.size) return + setProfilesByPubkey((prev) => { + const next = new Map(prev) + for (const [pk, p] of seedProfiles) { + const pkNorm = pk.toLowerCase() + if (!next.has(pkNorm)) next.set(pkNorm, p) + } + return next + }) + }, [seedProfiles]) return (
diff --git a/src/components/PubkeyListSearchField/index.tsx b/src/components/PubkeyListSearchField/index.tsx new file mode 100644 index 00000000..68ca15e1 --- /dev/null +++ b/src/components/PubkeyListSearchField/index.tsx @@ -0,0 +1,36 @@ +import { Input } from '@/components/ui/input' +import { cn } from '@/lib/utils' +import { Search } from 'lucide-react' +import { useTranslation } from 'react-i18next' + +export default function PubkeyListSearchField({ + value, + onChange, + className +}: { + value: string + onChange: (value: string) => void + className?: string +}) { + const { t } = useTranslation() + + return ( +
+ + onChange(e.target.value)} + className="w-full pl-8" + aria-label={t('Pubkey list search placeholder')} + /> +
+ ) +} diff --git a/src/hooks/usePubkeyListSearchProfiles.ts b/src/hooks/usePubkeyListSearchProfiles.ts new file mode 100644 index 00000000..041b77a2 --- /dev/null +++ b/src/hooks/usePubkeyListSearchProfiles.ts @@ -0,0 +1,58 @@ +import { getProfileFromEvent } from '@/lib/event-metadata' +import { filterPubkeysByListSearch } from '@/lib/filter-pubkeys-by-profile-search' +import indexedDb from '@/services/indexed-db.service' +import type { TProfile } from '@/types' +import { useEffect, useMemo, useState } from 'react' + +/** Filter a pubkey list by search; enriches matches from cached kind-0 rows in IndexedDB. */ +export function usePubkeyListSearchProfiles(pubkeys: string[]) { + const [searchQuery, setSearchQuery] = useState('') + const [searchProfileMap, setSearchProfileMap] = useState>(() => new Map()) + + const pubkeysKey = useMemo(() => pubkeys.join('\u0001'), [pubkeys]) + const pubkeysSet = useMemo( + () => new Set(pubkeys.map((pk) => pk.toLowerCase())), + [pubkeysKey] + ) + + useEffect(() => { + const q = searchQuery.trim() + if (!q) { + setSearchProfileMap(new Map()) + return + } + + let cancelled = false + void indexedDb + .searchProfileEventsInCache(q, Math.min(Math.max(pubkeys.length, 50), 500)) + .then((events) => { + if (cancelled) return + const next = new Map() + for (const ev of events) { + const pk = ev.pubkey.toLowerCase() + if (!pubkeysSet.has(pk)) continue + next.set(pk, { ...getProfileFromEvent(ev), pubkey: pk }) + } + setSearchProfileMap(next) + }) + .catch(() => { + if (!cancelled) setSearchProfileMap(new Map()) + }) + + return () => { + cancelled = true + } + }, [searchQuery, pubkeysSet, pubkeysKey, pubkeys.length]) + + const filteredPubkeys = useMemo( + () => filterPubkeysByListSearch(pubkeys, searchProfileMap, searchQuery), + [pubkeys, pubkeysKey, searchProfileMap, searchQuery] + ) + + return { + searchQuery, + setSearchQuery, + filteredPubkeys, + searchProfileMap + } +} diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index 668b38fa..6b93dc29 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -456,6 +456,7 @@ export default { 'Show more...': 'Mehr anzeigen...', 'Search dropdown profile search': 'PROFILES', 'Profile search no results': 'No matching profiles were found for this search.', + 'Pubkey list search placeholder': 'Nach Name, npub oder Pubkey suchen…', 'Profile search failed': 'Profile search could not complete. Check your connection or try again.', 'All users': 'Alle Benutzer', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index e1a5ca55..559aa7f5 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -459,6 +459,7 @@ export default { 'Show more...': 'Show more...', 'Search dropdown profile search': 'PROFILES', 'Profile search no results': 'No matching profiles were found for this search.', + 'Pubkey list search placeholder': 'Search by name, npub, or pubkey…', 'Profile search failed': 'Profile search could not complete. Check your connection or try again.', 'All users': 'All users', diff --git a/src/lib/filter-pubkeys-by-profile-search.test.ts b/src/lib/filter-pubkeys-by-profile-search.test.ts new file mode 100644 index 00000000..063c44ce --- /dev/null +++ b/src/lib/filter-pubkeys-by-profile-search.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest' +import { + filterPubkeysByListSearch, + profileMatchesListSearch, + pubkeyMatchesListSearch +} from './filter-pubkeys-by-profile-search' +import type { TProfile } from '@/types' + +const PK_A = 'a'.repeat(64) +const PK_B = 'b'.repeat(64) + +describe('pubkeyMatchesListSearch', () => { + it('matches hex pubkey prefix', () => { + expect(pubkeyMatchesListSearch(PK_A, PK_A.slice(0, 8))).toBe(true) + expect(pubkeyMatchesListSearch(PK_B, PK_A.slice(0, 8))).toBe(false) + }) +}) + +describe('profileMatchesListSearch', () => { + it('matches display name and nip05', () => { + const profile: TProfile = { + pubkey: PK_A, + npub: 'npub1test', + username: 'Alice Display', + original_username: 'Alice Display', + nip05: 'alice@example.com' + } + expect(profileMatchesListSearch(profile, 'alice')).toBe(true) + expect(profileMatchesListSearch(profile, 'example.com')).toBe(true) + expect(profileMatchesListSearch(profile, 'bob')).toBe(false) + }) +}) + +describe('filterPubkeysByListSearch', () => { + it('returns only exact pubkey when query decodes to hex', () => { + const out = filterPubkeysByListSearch([PK_A, PK_B], new Map(), PK_A) + expect(out).toEqual([PK_A]) + }) +}) diff --git a/src/lib/filter-pubkeys-by-profile-search.ts b/src/lib/filter-pubkeys-by-profile-search.ts new file mode 100644 index 00000000..0c03135e --- /dev/null +++ b/src/lib/filter-pubkeys-by-profile-search.ts @@ -0,0 +1,87 @@ +import { normalizeProfileSearchQueryForMatch } from '@/lib/profile-metadata-search' +import { decodeProfileSearchQueryToPubkeyHex } from '@/lib/profile-search-query' +import { formatPubkey, pubkeyToNpub } from '@/lib/pubkey' +import type { TProfile } from '@/types' + +function haystackIncludes(haystack: string, needle: string, needleNoAt: string): boolean { + const h = haystack.toLowerCase() + if (needle && h.includes(needle)) return true + if (needleNoAt.length > 0 && h.includes(needleNoAt)) return true + return false +} + +function searchNeedles(rawQuery: string): { needle: string; needleNoAt: string } | null { + const trimmed = rawQuery.trim() + if (!trimmed) return null + const needle = normalizeProfileSearchQueryForMatch(trimmed) + if (!needle) return null + const needleNoAt = needle.startsWith('@') ? needle.slice(1).trim() : needle + return { needle, needleNoAt } +} + +/** Match hex pubkey, npub, or formatted pubkey substring without loaded metadata. */ +export function pubkeyMatchesListSearch(pubkey: string, rawQuery: string): boolean { + const trimmed = rawQuery.trim() + if (!trimmed) return true + + const decoded = decodeProfileSearchQueryToPubkeyHex(trimmed) + const pkLower = pubkey.toLowerCase() + if (decoded && pkLower === decoded) return true + + const needles = searchNeedles(trimmed) + if (!needles) return true + const { needle, needleNoAt } = needles + + if (haystackIncludes(pkLower, needle, needleNoAt)) return true + + const npub = pubkeyToNpub(pkLower) + if (npub && haystackIncludes(npub, needle, needleNoAt)) return true + + if (haystackIncludes(formatPubkey(pkLower), needle, needleNoAt)) return true + + return false +} + +/** Match display name (`username`), name (`original_username`), or nip05. */ +export function profileMatchesListSearch(profile: TProfile, rawQuery: string): boolean { + if (!rawQuery.trim()) return true + if (pubkeyMatchesListSearch(profile.pubkey, rawQuery)) return true + + const needles = searchNeedles(rawQuery) + if (!needles) return true + const { needle, needleNoAt } = needles + + const blobs = [ + profile.username, + profile.original_username, + profile.nip05, + ...(profile.nip05List ?? []) + ].filter(Boolean) as string[] + + for (const b of blobs) { + if (haystackIncludes(b, needle, needleNoAt)) return true + } + return false +} + +export function filterPubkeysByListSearch( + pubkeys: string[], + profilesByPubkey: Map, + rawQuery: string +): string[] { + const q = rawQuery.trim() + if (!q) return pubkeys + + const decoded = decodeProfileSearchQueryToPubkeyHex(q) + if (decoded) { + const hit = pubkeys.find((pk) => pk.toLowerCase() === decoded) + return hit ? [hit] : [] + } + + return pubkeys.filter((pk) => { + const pkNorm = pk.toLowerCase() + if (pubkeyMatchesListSearch(pk, q)) return true + const profile = profilesByPubkey.get(pkNorm) + return profile ? profileMatchesListSearch(profile, q) : false + }) +} diff --git a/src/pages/secondary/FollowersListPage/index.tsx b/src/pages/secondary/FollowersListPage/index.tsx index b0c69c95..b51a8438 100644 --- a/src/pages/secondary/FollowersListPage/index.tsx +++ b/src/pages/secondary/FollowersListPage/index.tsx @@ -1,5 +1,6 @@ import JsonViewDialog from '@/components/JsonViewDialog' import ProfileList from '@/components/ProfileList' +import PubkeyListSearchField from '@/components/PubkeyListSearchField' import { RefreshButton } from '@/components/RefreshButton' import { Button } from '@/components/ui/button' import { @@ -10,11 +11,13 @@ import { } from '@/components/ui/dropdown-menu' import { useFetchProfile } from '@/hooks' import { useNostrArchivesAvailable } from '@/hooks/useNostrArchivesAvailable' +import { usePubkeyListSearchProfiles } from '@/hooks/usePubkeyListSearchProfiles' +import { userIdToPubkey } from '@/lib/pubkey' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import { usePrimaryNoteView } from '@/contexts/primary-note-view-context' import nostrArchivesApi from '@/services/nostr-archives-api.service' import { Code, MoreVertical } from 'lucide-react' -import { forwardRef, useCallback, useEffect, useRef, useState } from 'react' +import { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' const FOLLOWERS_PAGE_SIZE = 100 @@ -31,6 +34,11 @@ const FollowersListPage = forwardRef( const archivesAvailable = useNostrArchivesAvailable() const [listRefreshNonce, setListRefreshNonce] = useState(0) const { profile } = useFetchProfile(id) + const profilePubkey = useMemo(() => { + if (!id) return null + const pk = userIdToPubkey(id) + return pk.length === 64 && /^[0-9a-f]{64}$/i.test(pk) ? pk.toLowerCase() : null + }, [id]) const [followers, setFollowers] = useState([]) const [totalCount, setTotalCount] = useState(null) const [hasMore, setHasMore] = useState(false) @@ -40,6 +48,8 @@ const FollowersListPage = forwardRef( const bottomRef = useRef(null) const loadMoreInFlight = useRef(false) const offsetRef = useRef(0) + const { searchQuery, setSearchQuery, filteredPubkeys, searchProfileMap } = + usePubkeyListSearchProfiles(followers) const bumpList = useCallback(() => { offsetRef.current = 0 @@ -48,7 +58,7 @@ const FollowersListPage = forwardRef( const openFollowersJson = useCallback(() => { setFollowersJsonPayload({ - pubkey: profile?.pubkey ?? null, + pubkey: profilePubkey ?? profile?.pubkey ?? null, source: 'nostr-archives', endpoint: '/v1/social/{pubkey}', followersOffset: offsetRef.current, @@ -57,7 +67,7 @@ const FollowersListPage = forwardRef( totalCount }) setJsonOpen(true) - }, [profile?.pubkey, followers, totalCount]) + }, [profilePubkey, profile?.pubkey, followers, totalCount]) useEffect(() => { if (!hideTitlebar) { @@ -70,7 +80,7 @@ const FollowersListPage = forwardRef( const fetchPage = useCallback( async (offset: number, append: boolean) => { - const pk = profile?.pubkey + const pk = profilePubkey if (!pk || !archivesAvailable) return false const res = await nostrArchivesApi.getSocialGraph(pk, { @@ -101,12 +111,12 @@ const FollowersListPage = forwardRef( setHasMore(offsetRef.current < res.data.followers.count && batch.length > 0) return true }, - [profile?.pubkey, archivesAvailable] + [profilePubkey, archivesAvailable] ) useEffect(() => { let cancelled = false - const pk = profile?.pubkey + const pk = profilePubkey if (!pk) { setFollowers([]) @@ -136,7 +146,7 @@ const FollowersListPage = forwardRef( return () => { cancelled = true } - }, [profile?.pubkey, listRefreshNonce, archivesAvailable, fetchPage]) + }, [profilePubkey, listRefreshNonce, archivesAvailable, fetchPage]) useEffect(() => { const el = bottomRef.current @@ -201,9 +211,14 @@ const FollowersListPage = forwardRef( ) : ( <> {totalCount != null ? ( -

{t('Nostr Archives followers hint')}

+

{t('Nostr Archives followers hint')}

) : null} - + + {searchQuery.trim() && filteredPubkeys.length === 0 ? ( +

{t('Profile search no results')}

+ ) : ( + + )} )}
diff --git a/src/pages/secondary/FollowingListPage/index.tsx b/src/pages/secondary/FollowingListPage/index.tsx index 3f97f29a..c819b14c 100644 --- a/src/pages/secondary/FollowingListPage/index.tsx +++ b/src/pages/secondary/FollowingListPage/index.tsx @@ -1,5 +1,6 @@ import JsonViewDialog from '@/components/JsonViewDialog' import ProfileList from '@/components/ProfileList' +import PubkeyListSearchField from '@/components/PubkeyListSearchField' import { RefreshButton } from '@/components/RefreshButton' import { AlertDialog, @@ -19,6 +20,7 @@ import { DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import { useFetchFollowings, useFetchProfile } from '@/hooks' +import { usePubkeyListSearchProfiles } from '@/hooks/usePubkeyListSearchProfiles' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import { usePrimaryNoteView } from '@/contexts/primary-note-view-context' import { buildAccountListRelayUrlsForMerge } from '@/lib/account-list-relay-urls' @@ -40,6 +42,8 @@ const FollowingListPage = forwardRef(({ id, index, hideTitlebar = false }: { id? const [listRefreshNonce, setListRefreshNonce] = useState(0) const { profile } = useFetchProfile(id) const { followings, followListEvent } = useFetchFollowings(profile?.pubkey, listRefreshNonce) + const { searchQuery, setSearchQuery, filteredPubkeys, searchProfileMap } = + usePubkeyListSearchProfiles(followings) const [jsonOpen, setJsonOpen] = useState(false) const [followJsonPayload, setFollowJsonPayload] = useState(null) const [cleanConfirmOpen, setCleanConfirmOpen] = useState(false) @@ -156,7 +160,12 @@ const FollowingListPage = forwardRef(({ id, index, hideTitlebar = false }: { id? displayScrollToTopButton > setJsonOpen(false)} /> - + + {searchQuery.trim() && filteredPubkeys.length === 0 ? ( +

{t('Profile search no results')}

+ ) : ( + + )} diff --git a/src/pages/secondary/MuteListPage/index.tsx b/src/pages/secondary/MuteListPage/index.tsx index 55d589d8..31339fc4 100644 --- a/src/pages/secondary/MuteListPage/index.tsx +++ b/src/pages/secondary/MuteListPage/index.tsx @@ -1,5 +1,6 @@ import JsonViewDialog from '@/components/JsonViewDialog' import MuteButton from '@/components/MuteButton' +import PubkeyListSearchField from '@/components/PubkeyListSearchField' import Nip05 from '@/components/Nip05' import ProfileAbout from '@/components/ProfileAbout' import { RefreshButton } from '@/components/RefreshButton' @@ -24,6 +25,7 @@ import { Skeleton } from '@/components/ui/skeleton' import UserAvatar from '@/components/UserAvatar' import Username from '@/components/Username' import { useFetchProfile } from '@/hooks' +import { usePubkeyListSearchProfiles } from '@/hooks/usePubkeyListSearchProfiles' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import { usePrimaryNoteView } from '@/contexts/primary-note-view-context' import { buildAccountListRelayUrlsForMerge } from '@/lib/account-list-relay-urls' @@ -32,6 +34,7 @@ import { useMuteList } from '@/contexts/mute-list-context' import indexedDb from '@/services/indexed-db.service' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useNostr } from '@/providers/NostrProvider' +import type { TProfile } from '@/types' import { Code, Eraser, Lock, MoreVertical, Unlock } from 'lucide-react' import dayjs from 'dayjs' import { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from 'react' @@ -48,6 +51,8 @@ const MuteListPage = forwardRef(({ index, hideTitlebar = false }: { index?: numb const [jsonOpen, setJsonOpen] = useState(false) const [jsonPayload, setJsonPayload] = useState(null) const mutePubkeys = useMemo(() => getMutePubkeys(), [getMutePubkeys]) + const { searchQuery, setSearchQuery, filteredPubkeys, searchProfileMap } = + usePubkeyListSearchProfiles(mutePubkeys) const [visibleMutePubkeys, setVisibleMutePubkeys] = useState([]) const [listRefreshKey, setListRefreshKey] = useState(0) const [cleanConfirmOpen, setCleanConfirmOpen] = useState(false) @@ -86,8 +91,8 @@ const MuteListPage = forwardRef(({ index, hideTitlebar = false }: { index?: numb }, [hideTitlebar, registerPrimaryPanelRefresh, bumpList]) useEffect(() => { - setVisibleMutePubkeys(mutePubkeys.slice(0, 10)) - }, [mutePubkeys, listRefreshKey]) + setVisibleMutePubkeys(filteredPubkeys.slice(0, 10)) + }, [filteredPubkeys, listRefreshKey]) useEffect(() => { const options = { @@ -97,10 +102,10 @@ const MuteListPage = forwardRef(({ index, hideTitlebar = false }: { index?: numb } const observerInstance = new IntersectionObserver((entries) => { - if (entries[0].isIntersecting && mutePubkeys.length > visibleMutePubkeys.length) { + if (entries[0].isIntersecting && filteredPubkeys.length > visibleMutePubkeys.length) { setVisibleMutePubkeys((prev) => [ ...prev, - ...mutePubkeys.slice(prev.length, prev.length + 10) + ...filteredPubkeys.slice(prev.length, prev.length + 10) ]) } }, options) @@ -115,7 +120,7 @@ const MuteListPage = forwardRef(({ index, hideTitlebar = false }: { index?: numb observerInstance.unobserve(currentBottomRef) } } - }, [visibleMutePubkeys, mutePubkeys]) + }, [visibleMutePubkeys, filteredPubkeys]) const handleCleanList = useCallback(async () => { if (!pubkey || cleaning) return @@ -207,21 +212,37 @@ const MuteListPage = forwardRef(({ index, hideTitlebar = false }: { index?: numb -
- {visibleMutePubkeys.map((pubkey, index) => ( - - ))} - {mutePubkeys.length > visibleMutePubkeys.length &&
} -
+ + {searchQuery.trim() && filteredPubkeys.length === 0 ? ( +

{t('Profile search no results')}

+ ) : ( +
+ {visibleMutePubkeys.map((pubkey, index) => ( + + ))} + {filteredPubkeys.length > visibleMutePubkeys.length &&
} +
+ )} ) }) MuteListPage.displayName = 'MuteListPage' export default MuteListPage -function UserItem({ pubkey }: { pubkey: string }) { +function UserItem({ + pubkey, + prefetchedProfile +}: { + pubkey: string + prefetchedProfile?: TProfile +}) { const { changing, getMuteType, switchToPrivateMute, switchToPublicMute } = useMuteList() - const { profile } = useFetchProfile(pubkey) + const { profile: fetchedProfile } = useFetchProfile(pubkey) + const profile = prefetchedProfile ?? fetchedProfile const muteType = useMemo(() => getMuteType(pubkey), [pubkey, getMuteType]) const [switching, setSwitching] = useState(false) diff --git a/src/services/indexed-db.service.ts b/src/services/indexed-db.service.ts index 37eaebd4..59e0288e 100644 --- a/src/services/indexed-db.service.ts +++ b/src/services/indexed-db.service.ts @@ -1184,6 +1184,7 @@ class IndexedDbService { case 10001: // Pin list return StoreNames.PIN_LIST_EVENTS case ExtendedKind.PROFILE_BADGES_LIST: + case ExtendedKind.PROFILE_BADGES: // deprecated NIP-58 list (d=profile_badges); same store as 10008 return StoreNames.PROFILE_BADGES_LIST_EVENTS case 10015: // Interest list return StoreNames.INTEREST_LIST_EVENTS