diff --git a/src/components/Explore/ExploreRelayDirectory.tsx b/src/components/Explore/ExploreRelayDirectory.tsx new file mode 100644 index 00000000..97b1a06c --- /dev/null +++ b/src/components/Explore/ExploreRelayDirectory.tsx @@ -0,0 +1,314 @@ +import RelayReviewCard from '@/components/RelayInfo/RelayReviewCard' +import RelaySimpleInfo, { RelaySimpleInfoSkeleton } from '@/components/RelaySimpleInfo' +import { Badge } from '@/components/ui/badge' +import { Skeleton } from '@/components/ui/skeleton' +import { ExtendedKind } from '@/constants' +import { useFetchRelayInfo } from '@/hooks' +import { + buildExploreRelayDirectory, + filterExploreRelayDirectory, + type ExploreRelayEntry +} from '@/lib/explore-relay-directory' +import { + dedupeRelayReviewsNewestFirst, + groupRelayReviewsByUrl, + loadCachedRelayReviews +} from '@/lib/explore-relay-reviews' +import { getRelayUrlFromRelayReviewEvent } from '@/lib/event-metadata' +import { + getRelayUrlsWithFavoritesFastReadAndInbox, + userReadRelaysWithHttp +} from '@/lib/favorites-feed-relays' +import { toRelay } from '@/lib/link' +import { normalizeAnyRelayUrl } from '@/lib/url' +import { appendCuratedReadOnlyRelays } from '@/pages/primary/SpellsPage/fauxSpellFeeds' +import { useSmartRelayNavigation } from '@/PageManager' +import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' +import { useNostr } from '@/providers/NostrProvider' +import client from '@/services/client.service' +import indexedDb from '@/services/indexed-db.service' +import { useEffect, useMemo, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' + +const SHOW_COUNT = 12 +const REVIEW_QUERY_LIMIT = 100 +const EXPLORE_REVIEWS_MAX_RELAYS = 12 +const EXPLORE_REVIEWS_EOSE_TAIL_MS = 4500 +const MAX_REVIEWS_PER_CARD = 3 + +function stableRelayInputsKey( + favoriteRelays: string[], + blockedRelays: string[], + relayList: { read?: string[]; write?: string[]; httpRead?: string[] } | null | undefined +): string { + const normSortJoin = (urls: string[]) => + [...urls] + .map((u) => normalizeAnyRelayUrl(u) || u.trim()) + .filter(Boolean) + .sort((a, b) => a.localeCompare(b)) + .join('|') + return [ + normSortJoin(favoriteRelays), + normSortJoin(blockedRelays), + normSortJoin([...(relayList?.httpRead ?? []), ...(relayList?.read ?? [])]), + normSortJoin(relayList?.write ?? []) + ].join('::') +} + +function ExploreRelayDirectoryCard({ entry }: { entry: ExploreRelayEntry }) { + const { t } = useTranslation() + const { navigateToRelay } = useSmartRelayNavigation() + const { relayInfo } = useFetchRelayInfo(entry.url) + const { sourceFlags, favoritedBy, reviews } = entry + const visibleReviews = reviews.slice(0, MAX_REVIEWS_PER_CARD) + + const badges: { key: string; label: string }[] = [] + if (sourceFlags.inMailboxRead || sourceFlags.inMailboxWrite || sourceFlags.inMailboxHttpRead) { + badges.push({ key: 'inbox', label: t('Your inbox') }) + } + if (sourceFlags.inUserFavorites) { + badges.push({ key: 'favorite', label: t('Favorite') }) + } + if (reviews.length > 0) { + badges.push({ + key: 'reviews', + label: t('{{count}} reviews', { count: reviews.length }) + }) + } + + return ( +
+ 0 ? favoritedBy : undefined} + className="clickable min-h-0" + onClick={(e) => { + e.stopPropagation() + navigateToRelay(toRelay(entry.url)) + }} + /> + {badges.length > 0 ? ( +
+ {badges.map((b) => ( + + {b.label} + + ))} +
+ ) : null} + {visibleReviews.length > 0 ? ( +
+ {visibleReviews.map((event) => ( + + ))} +
+ ) : null} +
+ ) +} + +export default function ExploreRelayDirectory({ listFilter = '' }: { listFilter?: string }) { + const { t } = useTranslation() + const { pubkey, relayList } = useNostr() + const { favoriteRelays, blockedRelays } = useFavoriteRelays() + + const relayInputsKey = useMemo( + () => stableRelayInputsKey(favoriteRelays, blockedRelays, relayList), + [favoriteRelays, blockedRelays, relayList] + ) + + const reviewRelayUrls = useMemo(() => { + const stacked = appendCuratedReadOnlyRelays( + getRelayUrlsWithFavoritesFastReadAndInbox( + favoriteRelays, + blockedRelays, + userReadRelaysWithHttp(relayList), + { + userWriteRelays: relayList?.write ?? [], + maxRelays: EXPLORE_REVIEWS_MAX_RELAYS, + applySocialKindBlockedFilter: false + } + ), + blockedRelays + ) + return stacked + .slice(0, EXPLORE_REVIEWS_MAX_RELAYS) + .map((u) => normalizeAnyRelayUrl(u) || u.trim()) + .filter((u): u is string => Boolean(u)) + .sort((a, b) => a.localeCompare(b)) + // eslint-disable-next-line react-hooks/exhaustive-deps -- content hash of relay inputs + }, [relayInputsKey]) + + const [nip66Cached, setNip66Cached] = useState([]) + const [followingFavorites, setFollowingFavorites] = useState<[string, string[]][]>([]) + const [followingLoading, setFollowingLoading] = useState(true) + const [reviewEvents, setReviewEvents] = useState([]) + const [reviewsLoading, setReviewsLoading] = useState(true) + const [showCount, setShowCount] = useState(SHOW_COUNT) + const bottomRef = useRef(null) + const fetchGenRef = useRef(0) + + useEffect(() => { + client.scheduleNip66RelayDiscoveryFromExplore() + }, []) + + useEffect(() => { + let cancelled = false + void indexedDb + .getPublicLivelyRelayUrlsCache() + .then((c) => { + if (!cancelled && c?.urls?.length) setNip66Cached(c.urls) + }) + .catch(() => {}) + return () => { + cancelled = true + } + }, []) + + useEffect(() => { + let cancelled = false + setFollowingLoading(true) + void (async () => { + if (!pubkey) { + setFollowingFavorites([]) + return + } + const rows = (await client.fetchFollowingFavoriteRelays(pubkey)) ?? [] + if (!cancelled) setFollowingFavorites(rows) + })().finally(() => { + if (!cancelled) setFollowingLoading(false) + }) + return () => { + cancelled = true + } + }, [pubkey]) + + useEffect(() => { + const gen = ++fetchGenRef.current + let cancelled = false + setReviewsLoading(true) + setReviewEvents([]) + + void (async () => { + const cached = await loadCachedRelayReviews(REVIEW_QUERY_LIMIT) + if (!cancelled && fetchGenRef.current === gen && cached.length > 0) { + setReviewEvents(cached) + } + try { + const raw = await client.fetchEvents( + reviewRelayUrls, + { kinds: [ExtendedKind.RELAY_REVIEW], limit: REVIEW_QUERY_LIMIT }, + { + onevent: (e) => { + if (cancelled || fetchGenRef.current !== gen) return + if (e.kind === ExtendedKind.RELAY_REVIEW && getRelayUrlFromRelayReviewEvent(e)) { + setReviewEvents((prev) => dedupeRelayReviewsNewestFirst([...prev, e])) + } + }, + firstRelayResultGraceMs: false, + globalTimeout: 12_000, + eoseTimeout: EXPLORE_REVIEWS_EOSE_TAIL_MS, + cache: true + } + ) + if (cancelled || fetchGenRef.current !== gen) return + const withRelay = raw.filter( + (e) => e.kind === ExtendedKind.RELAY_REVIEW && getRelayUrlFromRelayReviewEvent(e) + ) + setReviewEvents((prev) => dedupeRelayReviewsNewestFirst([...prev, ...withRelay])) + } catch { + if (!cancelled && fetchGenRef.current === gen) setReviewEvents([]) + } finally { + if (!cancelled && fetchGenRef.current === gen) setReviewsLoading(false) + } + })() + + return () => { + cancelled = true + } + }, [relayInputsKey]) + + const reviewsByRelay = useMemo(() => groupRelayReviewsByUrl(reviewEvents), [reviewEvents]) + + const entries = useMemo( + () => + buildExploreRelayDirectory({ + relayList, + favoriteRelays, + blockedRelays, + nip66CachedUrls: nip66Cached, + followingFavorites, + reviewsByRelay + }), + [relayList, favoriteRelays, blockedRelays, nip66Cached, followingFavorites, reviewsByRelay] + ) + + const filtered = useMemo( + () => filterExploreRelayDirectory(entries, listFilter), + [entries, listFilter] + ) + + const visible = filtered.slice(0, showCount) + const showInitialSkeleton = filtered.length === 0 && (followingLoading || reviewsLoading) + + useEffect(() => { + setShowCount(SHOW_COUNT) + }, [listFilter, relayInputsKey]) + + useEffect(() => { + const options = { root: null, rootMargin: '120px', threshold: 0 } + const observer = new IntersectionObserver((entriesObs) => { + if (entriesObs[0]?.isIntersecting && showCount < filtered.length) { + setShowCount((prev) => prev + SHOW_COUNT) + } + }, options) + const el = bottomRef.current + if (el) observer.observe(el) + return () => { + if (el) observer.unobserve(el) + } + }, [showCount, filtered.length]) + + if (showInitialSkeleton) { + return ( +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+ ) + } + + if (filtered.length === 0) { + return ( +

+ {listFilter.trim() ? t('no relays found') : t('No relays in your lists yet.')} +

+ ) + } + + return ( +
+

+ {t('Your relays first, then those your network favors and reviews.')} +

+ {visible.map((entry) => ( + + ))} + {reviewsLoading && entries.length > 0 ? ( +
+ +
+ ) : null} + {showCount < filtered.length ?
: null} + {!followingLoading && !reviewsLoading && showCount >= filtered.length ? ( +

{t('no more relays')}

+ ) : null} +
+ ) +} diff --git a/src/components/Explore/ExploreRelayReviews.tsx b/src/components/Explore/ExploreRelayReviews.tsx index 30b42f2f..b849458d 100644 --- a/src/components/Explore/ExploreRelayReviews.tsx +++ b/src/components/Explore/ExploreRelayReviews.tsx @@ -3,8 +3,11 @@ import RelayReviewCard from '@/components/RelayInfo/RelayReviewCard' import { Skeleton } from '@/components/ui/skeleton' import { ExtendedKind } from '@/constants' import { useFetchRelayInfo } from '@/hooks' -import { getReplaceableCoordinateFromEvent, isReplaceableEvent } from '@/lib/event' import { getRelayUrlFromRelayReviewEvent } from '@/lib/event-metadata' +import { + dedupeRelayReviewsNewestFirst, + loadCachedRelayReviews +} from '@/lib/explore-relay-reviews' import { getRelayUrlsWithFavoritesFastReadAndInbox, userReadRelaysWithHttp @@ -17,7 +20,6 @@ import { useSmartRelayNavigation } from '@/PageManager' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useNostr } from '@/providers/NostrProvider' import client from '@/services/client.service' -import indexedDb, { StoreNames } from '@/services/indexed-db.service' import type { Event } from 'nostr-tools' import { useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -54,41 +56,6 @@ const EXPLORE_REVIEWS_MAX_RELAYS = 12 /** After all relays EOSE, wait longer than default so slow mirrors can flush events (default query eose is 500ms). */ const EXPLORE_REVIEWS_EOSE_TAIL_MS = 4500 -function dedupeRelayReviewsNewestFirst(events: Event[]): Event[] { - const sorted = [...events].sort((a, b) => b.created_at - a.created_at) - const seen = new Set() - const out: Event[] = [] - for (const evt of sorted) { - const key = isReplaceableEvent(evt.kind) ? getReplaceableCoordinateFromEvent(evt) : evt.id - if (seen.has(key)) continue - seen.add(key) - out.push(evt) - } - return out -} - -async function loadCachedRelayReviews(limit: number): Promise { - const fromSession = client - .getSessionEventsMatchingSearch('', Math.max(limit * 2, 200), [ExtendedKind.RELAY_REVIEW]) - .filter((e) => e.kind === ExtendedKind.RELAY_REVIEW && !!getRelayUrlFromRelayReviewEvent(e)) - if (fromSession.length >= limit) { - return dedupeRelayReviewsNewestFirst(fromSession).slice(0, limit) - } - - try { - const archiveRows = await indexedDb.getStoreItems(StoreNames.EVENT_ARCHIVE) - const fromArchive = archiveRows - .map((row) => row?.value as Event | undefined) - .filter( - (e): e is Event => - !!e && e.kind === ExtendedKind.RELAY_REVIEW && !!getRelayUrlFromRelayReviewEvent(e) - ) - return dedupeRelayReviewsNewestFirst([...fromSession, ...fromArchive]).slice(0, limit) - } catch { - return dedupeRelayReviewsNewestFirst(fromSession).slice(0, limit) - } -} - function stableRelayInputsKey( favoriteRelays: string[], blockedRelays: string[], diff --git a/src/constants.ts b/src/constants.ts index 8ec22d4c..dbcf1bd3 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -73,7 +73,8 @@ export const DESKTOP_APP_DOWNLOAD_URL_DEFAULT = export const DEFAULT_FAVORITE_RELAYS = [ 'wss://theforest.nostr1.com', - 'wss://nostr.land' + 'wss://nostr.land', + 'wss://relays.land/spatianostra' ] /** diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 16ce03f5..6025f8f3 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -552,6 +552,8 @@ export default { "Relay sets": "Relay sets", "Search for Relays": "Search for Relays", "Popular relays": "Popular relays", + "Your inbox": "Your inbox", + "Your relays first, then those your network favors and reviews.": "Your relays first, then those your network favors and reviews.", "From your mailbox, favorites, and cached relay lists on this device.": "From your mailbox, favorites, and cached relay lists on this device.", "No relays in your lists yet.": "No relays in your lists yet.", "Using app default relays": "Using app default relays", diff --git a/src/lib/citation-picker-relays.ts b/src/lib/citation-picker-relays.ts index e103875c..5689b65f 100644 --- a/src/lib/citation-picker-relays.ts +++ b/src/lib/citation-picker-relays.ts @@ -11,9 +11,6 @@ import { normalizeUrl } from '@/lib/url' import client from '@/services/client.service' import nip66Service from '@/services/nip66.service' -/** Broad NIP-50 / index relays not always present in {@link SEARCHABLE_RELAY_URLS}. */ -const CITATION_SEARCH_EXTRA_INDEX_RELAYS = ['wss://relay.nostr.band'] as const - /** Cap NIP-66 “supports search” relays so we do not open hundreds of sockets. */ const CITATION_SEARCH_NIP66_NIP50_CAP = 42 @@ -60,7 +57,6 @@ export async function buildCitationPickerSearchRelayUrls(): Promise { normList(DOCUMENT_RELAY_URLS), normList(NIP66_DISCOVERY_RELAY_URLS), normList(BOOKSTR_RELAY_URLS), - normList([...CITATION_SEARCH_EXTRA_INDEX_RELAYS]), nip66Search, normList(FAST_READ_RELAY_URLS) ], diff --git a/src/lib/explore-relay-directory.test.ts b/src/lib/explore-relay-directory.test.ts new file mode 100644 index 00000000..78a85dd5 --- /dev/null +++ b/src/lib/explore-relay-directory.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest' +import { buildExploreRelayDirectory, scoreExploreRelayEntry } from './explore-relay-directory' + +describe('scoreExploreRelayEntry', () => { + it('ranks mailbox read above following-only social proof', () => { + const mailboxOnly = scoreExploreRelayEntry( + { + inMailboxRead: true, + inMailboxWrite: false, + inMailboxHttpRead: false, + inUserFavorites: false, + inAppDefaults: false, + inFastRead: false, + inNip66Cache: false + }, + 0, + 0, + 1 + ) + const socialOnly = scoreExploreRelayEntry( + { + inMailboxRead: false, + inMailboxWrite: false, + inMailboxHttpRead: false, + inUserFavorites: false, + inAppDefaults: false, + inFastRead: false, + inNip66Cache: false + }, + 25, + 0, + 1 + ) + expect(mailboxOnly).toBeGreaterThan(socialOnly) + }) +}) + +describe('buildExploreRelayDirectory', () => { + it('dedupes URLs and sorts client inbox before following-only relays', () => { + const relay = 'wss://inbox.example.com/' + const entries = buildExploreRelayDirectory({ + relayList: { read: [relay], write: [], httpRead: [] }, + favoriteRelays: [], + blockedRelays: [], + followingFavorites: [['wss://social.example.com', ['aa', 'bb', 'cc']]], + max: 50 + }) + expect(entries[0]?.url).toBe(relay) + const social = entries.find((e) => e.url.includes('social.example.com')) + expect(social).toBeDefined() + expect(entries.find((e) => e.url === relay)?.favoritedBy).toEqual([]) + expect(social?.favoritedBy).toHaveLength(3) + }) +}) diff --git a/src/lib/explore-relay-directory.ts b/src/lib/explore-relay-directory.ts new file mode 100644 index 00000000..704ef418 --- /dev/null +++ b/src/lib/explore-relay-directory.ts @@ -0,0 +1,188 @@ +import { DEFAULT_FAVORITE_RELAYS, FAST_READ_RELAY_URLS } from '@/constants' +import { isExploreBrowsableRelayUrl } from '@/lib/explore-popular-relays' +import { normalizeAnyRelayUrl } from '@/lib/url' +import type { ViewerRelayListLike } from '@/lib/viewer-relay-defaults' +import type { Event } from 'nostr-tools' + +export type ExploreRelaySourceFlags = { + inMailboxRead: boolean + inMailboxWrite: boolean + inMailboxHttpRead: boolean + inUserFavorites: boolean + inAppDefaults: boolean + inFastRead: boolean + inNip66Cache: boolean +} + +export type ExploreRelayEntry = { + url: string + score: number + sourceFlags: ExploreRelaySourceFlags + /** Pubkeys from people you follow who favorited this relay (most first in UI). */ + favoritedBy: string[] + /** Newest-first relay reviews for this URL. */ + reviews: Event[] +} + +const SCORE_MAILBOX_READ = 10_000 +const SCORE_MAILBOX_WRITE = 8_000 +const SCORE_MAILBOX_HTTP = 2_000 +const SCORE_USER_FAVORITE = 5_000 +const SCORE_PER_FOLLOWING_FAVORITER = 100 +const SCORE_FOLLOWING_FAVORITERS_CAP = 2_000 +const SCORE_PER_REVIEW = 50 +const SCORE_REVIEWS_CAP = 500 +const SCORE_STACK_BUMP = 10 + +export function scoreExploreRelayEntry( + flags: ExploreRelaySourceFlags, + followingFavoriteCount: number, + reviewCount: number, + stackFrequency: number +): number { + let score = 0 + if (flags.inMailboxRead) score += SCORE_MAILBOX_READ + if (flags.inMailboxWrite) score += SCORE_MAILBOX_WRITE + if (flags.inMailboxHttpRead) score += SCORE_MAILBOX_HTTP + if (flags.inUserFavorites) score += SCORE_USER_FAVORITE + score += Math.min( + followingFavoriteCount * SCORE_PER_FOLLOWING_FAVORITER, + SCORE_FOLLOWING_FAVORITERS_CAP + ) + score += Math.min(reviewCount * SCORE_PER_REVIEW, SCORE_REVIEWS_CAP) + score += Math.min(stackFrequency * SCORE_STACK_BUMP, 60) + return score +} + +type MutableRelayRow = { + url: string + flags: ExploreRelaySourceFlags + stackFrequency: number + favoritedBy: string[] + reviews: Event[] +} + +export type BuildExploreRelayDirectoryOptions = { + relayList: ViewerRelayListLike + favoriteRelays: readonly string[] + blockedRelays: readonly string[] + nip66CachedUrls?: readonly string[] + followingFavorites?: readonly (readonly [string, readonly string[]])[] + reviewsByRelay?: ReadonlyMap + max?: number +} + +function normalizeBlocked(blockedRelays: readonly string[]): Set { + return new Set( + blockedRelays.map((b) => normalizeAnyRelayUrl(b) || b.trim()).filter(Boolean) + ) +} + +function getOrCreateRow( + rows: Map, + raw: string, + blocked: Set +): MutableRelayRow | undefined { + if (!isExploreBrowsableRelayUrl(raw)) return undefined + const url = normalizeAnyRelayUrl(raw) || raw.trim() + if (!url || blocked.has(url)) return undefined + let row = rows.get(url) + if (!row) { + row = { + url, + flags: { + inMailboxRead: false, + inMailboxWrite: false, + inMailboxHttpRead: false, + inUserFavorites: false, + inAppDefaults: false, + inFastRead: false, + inNip66Cache: false + }, + stackFrequency: 0, + favoritedBy: [], + reviews: [] + } + rows.set(url, row) + } + row.stackFrequency += 1 + return row +} + +/** Merge viewer lists, following favorites, and reviews into one scored directory. */ +export function buildExploreRelayDirectory( + options: BuildExploreRelayDirectoryOptions +): ExploreRelayEntry[] { + const blocked = normalizeBlocked(options.blockedRelays) + const rows = new Map() + const rl = options.relayList + + const touch = (raw: string, patch: Partial) => { + const row = getOrCreateRow(rows, raw, blocked) + if (!row) return + Object.assign(row.flags, patch) + } + + for (const u of rl?.read ?? []) touch(u, { inMailboxRead: true }) + for (const u of rl?.write ?? []) touch(u, { inMailboxWrite: true }) + for (const u of rl?.httpRead ?? []) touch(u, { inMailboxHttpRead: true }) + for (const u of options.favoriteRelays) touch(u, { inUserFavorites: true }) + for (const u of DEFAULT_FAVORITE_RELAYS) touch(u, { inAppDefaults: true }) + for (const u of FAST_READ_RELAY_URLS) touch(u, { inFastRead: true }) + for (const u of options.nip66CachedUrls ?? []) touch(u, { inNip66Cache: true }) + + for (const [raw, pubkeys] of options.followingFavorites ?? []) { + const row = getOrCreateRow(rows, raw, blocked) + if (!row) continue + const seen = new Set(row.favoritedBy) + for (const pk of pubkeys) { + if (!pk || seen.has(pk)) continue + seen.add(pk) + row.favoritedBy.push(pk) + } + } + + for (const [raw, events] of options.reviewsByRelay ?? []) { + const row = getOrCreateRow(rows, raw, blocked) + if (!row || !events.length) continue + row.reviews = [...events] + } + + const entries: ExploreRelayEntry[] = [] + for (const row of rows.values()) { + const followingCount = row.favoritedBy.length + const reviewCount = row.reviews.length + entries.push({ + url: row.url, + sourceFlags: row.flags, + favoritedBy: row.favoritedBy, + reviews: row.reviews, + score: scoreExploreRelayEntry(row.flags, followingCount, reviewCount, row.stackFrequency) + }) + } + + entries.sort((a, b) => { + if (b.score !== a.score) return b.score - a.score + if (b.favoritedBy.length !== a.favoritedBy.length) { + return b.favoritedBy.length - a.favoritedBy.length + } + if (b.reviews.length !== a.reviews.length) return b.reviews.length - a.reviews.length + return a.url.localeCompare(b.url) + }) + + const max = options.max ?? 200 + return entries.slice(0, max) +} + +/** Case-insensitive filter for the directory list (URL / simplified host). */ +export function filterExploreRelayDirectory( + entries: ExploreRelayEntry[], + rawQuery: string +): ExploreRelayEntry[] { + const q = rawQuery.trim().toLowerCase() + if (!q) return entries + return entries.filter((e) => { + const n = e.url.toLowerCase() + return n.includes(q) || n.replace(/^wss?:\/\//, '').includes(q) + }) +} diff --git a/src/lib/explore-relay-reviews.ts b/src/lib/explore-relay-reviews.ts new file mode 100644 index 00000000..1ccc4be6 --- /dev/null +++ b/src/lib/explore-relay-reviews.ts @@ -0,0 +1,53 @@ +import { ExtendedKind } from '@/constants' +import { getReplaceableCoordinateFromEvent, isReplaceableEvent } from '@/lib/event' +import { getRelayUrlFromRelayReviewEvent } from '@/lib/event-metadata' +import { isExploreBrowsableRelayUrl } from '@/lib/explore-popular-relays' +import client from '@/services/client.service' +import indexedDb, { StoreNames } from '@/services/indexed-db.service' +import type { Event } from 'nostr-tools' + +export function dedupeRelayReviewsNewestFirst(events: Event[]): Event[] { + const sorted = [...events].sort((a, b) => b.created_at - a.created_at) + const seen = new Set() + const out: Event[] = [] + for (const evt of sorted) { + const key = isReplaceableEvent(evt.kind) ? getReplaceableCoordinateFromEvent(evt) : evt.id + if (seen.has(key)) continue + seen.add(key) + out.push(evt) + } + return out +} + +export async function loadCachedRelayReviews(limit: number): Promise { + const fromSession = client + .getSessionEventsMatchingSearch('', Math.max(limit * 2, 200), [ExtendedKind.RELAY_REVIEW]) + .filter((e) => e.kind === ExtendedKind.RELAY_REVIEW && !!getRelayUrlFromRelayReviewEvent(e)) + if (fromSession.length >= limit) { + return dedupeRelayReviewsNewestFirst(fromSession).slice(0, limit) + } + + try { + const archiveRows = await indexedDb.getStoreItems(StoreNames.EVENT_ARCHIVE) + const fromArchive = archiveRows + .map((row) => row?.value as Event | undefined) + .filter( + (e): e is Event => + !!e && e.kind === ExtendedKind.RELAY_REVIEW && !!getRelayUrlFromRelayReviewEvent(e) + ) + return dedupeRelayReviewsNewestFirst([...fromSession, ...fromArchive]).slice(0, limit) + } catch { + return dedupeRelayReviewsNewestFirst(fromSession).slice(0, limit) + } +} + +export function groupRelayReviewsByUrl(events: Event[]): Map { + const groups = new Map() + for (const event of dedupeRelayReviewsNewestFirst(events)) { + const url = getRelayUrlFromRelayReviewEvent(event) + if (!url || !isExploreBrowsableRelayUrl(url)) continue + if (!groups.has(url)) groups.set(url, []) + groups.get(url)!.push(event) + } + return groups +} diff --git a/src/pages/primary/ExplorePage/index.tsx b/src/pages/primary/ExplorePage/index.tsx index 4edefad9..482239d8 100644 --- a/src/pages/primary/ExplorePage/index.tsx +++ b/src/pages/primary/ExplorePage/index.tsx @@ -1,14 +1,5 @@ -import ExplorePopularRelays from '@/components/Explore/ExplorePopularRelays' -import ExploreRelayReviews from '@/components/Explore/ExploreRelayReviews' +import ExploreRelayDirectory from '@/components/Explore/ExploreRelayDirectory' import { buildExplorePopularRelayUrls } from '@/lib/explore-popular-relays' -import FollowingFavoriteRelayList from '@/components/FollowingFavoriteRelayList' -import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' -import Tabs from '@/components/Tabs' -import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' -import { toRelay } from '@/lib/link' -import { cn } from '@/lib/utils' -import { isHttpRelayUrl, isWebsocketUrl, normalizeAnyRelayUrl, simplifyUrl } from '@/lib/url' import { RefreshButton } from '@/components/RefreshButton' import PrimaryPageLayout from '@/layouts/PrimaryPageLayout' import { syncUserDeletionTombstones } from '@/lib/sync-user-deletions' @@ -29,6 +20,12 @@ import { } from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' +import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { toRelay } from '@/lib/link' +import { cn } from '@/lib/utils' +import { isHttpRelayUrl, isWebsocketUrl, normalizeAnyRelayUrl, simplifyUrl } from '@/lib/url' const RELAY_SUGGESTION_LIMIT = 20 @@ -57,21 +54,11 @@ function filterMonitoringRelaySuggestions(urls: string[], rawQuery: string): str return matches.slice(0, RELAY_SUGGESTION_LIMIT) } -type TExploreTabs = 'explore' | 'reviews' | 'following' - -function normalizeHomeTab(restored: string): TExploreTabs { - if (restored === 'following') return 'following' - if (restored === 'reviews') return 'reviews' - // Removed "favorites" tab — treat saved state as Explore - return 'explore' -} - const ExplorePage = forwardRef((_, ref) => { - const { t } = useTranslation() const { pubkey, relayList } = useNostr() - const [tab, setTab] = useState('explore') const layoutRef = useRef(null) const [contentRefreshKey, setContentRefreshKey] = useState(0) + const [listFilter, setListFilter] = useState('') const bumpExploreContent = useCallback(() => { void (async () => { @@ -90,19 +77,7 @@ const ExplorePage = forwardRef((_, ref) => { ) useEffect(() => { - if (tab !== 'explore') return client.scheduleNip66RelayDiscoveryFromExplore() - }, [tab]) - - // Listen for tab restoration from PageManager - useEffect(() => { - const handleRestore = (e: CustomEvent<{ page: string; tab: string }>) => { - if (e.detail.page === 'explore' && e.detail.tab) { - setTab(normalizeHomeTab(e.detail.tab)) - } - } - window.addEventListener('restorePageTab', handleRestore as EventListener) - return () => window.removeEventListener('restorePageTab', handleRestore as EventListener) }, []) return ( @@ -110,43 +85,11 @@ const ExplorePage = forwardRef((_, ref) => { ref={layoutRef} pageName="explore" titlebar={} - subHeader={ - { - setTab(next as TExploreTabs) - window.dispatchEvent( - new CustomEvent('pageTabChanged', { - detail: { page: 'explore', tab: next } - }) - ) - }} - /> - } displayScrollToTopButton > -
- {tab === 'explore' && ( -
- - -
- )} - {tab === 'reviews' && ( -
- -
- )} - {tab === 'following' && ( -
- -
- )} +
+ +
) @@ -165,31 +108,36 @@ function ExplorePageTitlebar({ onRefresh }: { onRefresh: () => void }) {
- +
) } -function ExploreRelaySearchSection() { +function ExploreRelaySearchSection({ + listFilter, + onListFilterChange +}: { + listFilter: string + onListFilterChange: (value: string) => void +}) { const { t } = useTranslation() const { navigateToRelay } = useSmartRelayNavigation() const { relayList } = useNostr() const { favoriteRelays, blockedRelays } = useFavoriteRelays() - const [relayQuery, setRelayQuery] = useState('') const [suggestOpen, setSuggestOpen] = useState(false) const blurCloseTimer = useRef | null>(null) @@ -209,8 +157,8 @@ function ExploreRelaySearchSection() { }, []) const relaySuggestions = useMemo( - () => filterMonitoringRelaySuggestions(monitoringRelays, relayQuery), - [monitoringRelays, relayQuery] + () => filterMonitoringRelaySuggestions(monitoringRelays, listFilter), + [monitoringRelays, listFilter] ) const clearBlurTimer = () => { @@ -222,12 +170,12 @@ function ExploreRelaySearchSection() { const openRelayAndReset = (normalizedUrl: string) => { navigateToRelay(toRelay(normalizedUrl)) - setRelayQuery('') + onListFilterChange('') setSuggestOpen(false) } const tryOpenRelay = () => { - const trimmed = relayQuery.trim() + const trimmed = listFilter.trim() if (!trimmed) return const normalized = normalizeAnyRelayUrl(trimmed) if (!normalized || (!isHttpRelayUrl(normalized) && !isWebsocketUrl(normalized))) { @@ -254,8 +202,8 @@ function ExploreRelaySearchSection() { autoComplete="off" placeholder={t('Relay URL…')} className="h-9 w-full font-mono text-sm" - value={relayQuery} - onChange={(e) => setRelayQuery(e.target.value)} + value={listFilter} + onChange={(e) => onListFilterChange(e.target.value)} aria-label={t('Relay URL…')} aria-autocomplete="list" aria-expanded={suggestOpen && relaySuggestions.length > 0} diff --git a/src/services/indexed-db.service.ts b/src/services/indexed-db.service.ts index fe0cafc7..0d43f578 100644 --- a/src/services/indexed-db.service.ts +++ b/src/services/indexed-db.service.ts @@ -1,4 +1,7 @@ import { CALENDAR_EVENT_KINDS, ExtendedKind } from '@/constants' + +/** Legacy object store names removed in DB migrations (do not re-add to {@link StoreNames}). */ +const LEGACY_DELETED_OBJECT_STORES = ['relayInfoEvents', 'spellListSourceEvents'] as const import { publicationCoordinateLookupKeys, splitPublicationCoordinate @@ -107,7 +110,6 @@ export const StoreNames = { RELAY_SETS: 'relaySets', FOLLOWING_FAVORITE_RELAYS: 'followingFavoriteRelays', RELAY_INFOS: 'relayInfos', - RELAY_INFO_EVENTS: 'relayInfoEvents', // deprecated PUBLICATION_EVENTS: 'publicationEvents', /** NIP-66: cached list of public lively relay URLs (from 30166 discovery). */ PUBLIC_LIVELY_RELAYS: 'publicLivelyRelays', @@ -172,8 +174,44 @@ const CACHE_BROWSER_EVENT_SEARCH_EXCLUDED_STORES: ReadonlySet = new Set( StoreNames.CALENDAR_RSVP_EVENTS ]) +/** + * Replaceable list / profile / spell rows — still persisted for offline boot, but not timeline notes. + * {@link IndexedDbService.searchAllCachedEventsFullText} only scans {@link FULL_TEXT_NOTE_SEARCH_STORES}. + */ +const REPLACEABLE_METADATA_EVENT_STORES: ReadonlySet = new Set([ + StoreNames.PROFILE_EVENTS, + StoreNames.RELAY_LIST_EVENTS, + StoreNames.FOLLOW_LIST_EVENTS, + StoreNames.FOLLOW_SET_EVENTS, + StoreNames.MUTE_LIST_EVENTS, + StoreNames.BOOKMARK_LIST_EVENTS, + StoreNames.NOTIFICATION_THREAD_FOLLOW_EVENTS, + StoreNames.NOTIFICATION_THREAD_MUTE_EVENTS, + StoreNames.PIN_LIST_EVENTS, + StoreNames.INTEREST_LIST_EVENTS, + StoreNames.BLOSSOM_SERVER_LIST_EVENTS, + StoreNames.USER_EMOJI_LIST_EVENTS, + StoreNames.EMOJI_SET_EVENTS, + StoreNames.FAVORITE_RELAYS, + StoreNames.BLOCKED_RELAYS_EVENTS, + StoreNames.CACHE_RELAYS_EVENTS, + StoreNames.HTTP_RELAY_LIST_EVENTS, + StoreNames.RSS_FEED_LIST_EVENTS, + StoreNames.PAYMENT_INFO_EVENTS, + StoreNames.BADGE_DEFINITION_EVENTS, + StoreNames.SPELL_EVENTS +]) + +/** Stores that hold note-like bodies for local full-text search (not NIP-65 / kind-0 list rows). */ +const FULL_TEXT_NOTE_SEARCH_STORES: ReadonlySet = new Set([ + StoreNames.EVENT_ARCHIVE, + StoreNames.PUBLICATION_EVENTS +]) + +const ARCHIVE_CALENDAR_PURGE_SETTING_KEY = 'archiveCalendarPurgedV37' + /** Schema version we expect. When adding stores or migrations, bump this. */ -const DB_VERSION = 36 +const DB_VERSION = 37 /** Max age for profile and payment info cache before we refetch (5 min). */ const PROFILE_AND_PAYMENT_CACHE_MAX_AGE_MS = 5 * 60 * 1000 @@ -274,6 +312,9 @@ class IndexedDbService { openWithStored.onsuccess = () => { this.db = openWithStored.result this.scheduleNextCleanUp(IndexedDbService.CLEANUP_INITIAL_DELAY_MS) + void this.purgeLegacyArchivedCalendarEventsOnce().catch((e) => + logger.warn('[IndexedDB] Legacy calendar archive purge failed', { e }) + ) resolve() } openWithStored.onupgradeneeded = () => { @@ -290,16 +331,18 @@ class IndexedDbService { request.onsuccess = () => { this.db = request.result this.scheduleNextCleanUp(IndexedDbService.CLEANUP_INITIAL_DELAY_MS) + void this.purgeLegacyArchivedCalendarEventsOnce().catch((e) => + logger.warn('[IndexedDB] Legacy calendar archive purge failed', { e }) + ) resolve() } request.onupgradeneeded = (event) => { const db = (event.target as IDBOpenDBRequest).result - if ( - event.oldVersion < 26 && - db.objectStoreNames.contains('spellListSourceEvents') - ) { - db.deleteObjectStore('spellListSourceEvents') + for (const legacyName of LEGACY_DELETED_OBJECT_STORES) { + if (db.objectStoreNames.contains(legacyName)) { + db.deleteObjectStore(legacyName) + } } if (!db.objectStoreNames.contains(StoreNames.PROFILE_EVENTS)) { db.createObjectStore(StoreNames.PROFILE_EVENTS, { keyPath: 'key' }) @@ -358,9 +401,6 @@ class IndexedDbService { if (!db.objectStoreNames.contains(StoreNames.RELAY_INFOS)) { db.createObjectStore(StoreNames.RELAY_INFOS, { keyPath: 'key' }) } - if (db.objectStoreNames.contains(StoreNames.RELAY_INFO_EVENTS)) { - db.deleteObjectStore(StoreNames.RELAY_INFO_EVENTS) - } if (!db.objectStoreNames.contains(StoreNames.PUBLICATION_EVENTS)) { db.createObjectStore(StoreNames.PUBLICATION_EVENTS, { keyPath: 'key' }) } @@ -422,6 +462,9 @@ class IndexedDbService { rsvp.createIndex('parentCoordinate', 'parentCoordinate', { unique: false }) } } + if (event.oldVersion < 37) { + // v37: drop legacy object stores; calendar notes purged from EVENT_ARCHIVE post-open + } ensureMissingObjectStores(db) } } @@ -1911,8 +1954,8 @@ class IndexedDbService { } /** - * Scan object stores (excluding blobs, settings, and relay-only metadata) for rows that look like - * Nostr events. Case-insensitive match on id, pubkey, kind, content, and every tag cell. + * Full-text scan of note-like IndexedDB rows: {@link StoreNames.EVENT_ARCHIVE} and + * {@link StoreNames.PUBLICATION_EVENTS} only (not replaceable list / profile / spell stores). */ async searchAllCachedEventsFullText( query: string, @@ -1926,7 +1969,10 @@ class IndexedDbService { } const storeNames = Array.from(this.db.objectStoreNames).filter( - (name) => !CACHE_BROWSER_EVENT_SEARCH_EXCLUDED_STORES.has(name) + (name) => + FULL_TEXT_NOTE_SEARCH_STORES.has(name) && + !CACHE_BROWSER_EVENT_SEARCH_EXCLUDED_STORES.has(name) && + !REPLACEABLE_METADATA_EVENT_STORES.has(name) ) const results: TCachedEventSearchHit[] = [] const seen = new Set() @@ -2284,6 +2330,56 @@ class IndexedDbService { }) } + /** + * NIP-52 rows were once written to {@link StoreNames.EVENT_ARCHIVE}; ingest now uses dedicated calendar stores only. + * One-time purge so disk scans and cache search do not surface stale calendar bodies. + */ + private async purgeLegacyArchivedCalendarEventsOnce(): Promise { + await this.initPromise + if (!this.db?.objectStoreNames.contains(StoreNames.EVENT_ARCHIVE)) return + const done = await this.getSetting(ARCHIVE_CALENDAR_PURGE_SETTING_KEY) + if (done === '1') return + + const calendarKinds = new Set([ + ...CALENDAR_EVENT_KINDS, + ExtendedKind.CALENDAR_EVENT_RSVP + ]) + let removed = 0 + const maxScanned = 80_000 + + await new Promise((resolve, reject) => { + const tx = this.db!.transaction(StoreNames.EVENT_ARCHIVE, 'readwrite') + const store = tx.objectStore(StoreNames.EVENT_ARCHIVE) + const req = store.openCursor() + let scanned = 0 + req.onsuccess = () => { + const cursor = req.result as IDBCursorWithValue | null + if (!cursor || scanned >= maxScanned) { + tx.commit() + resolve() + return + } + scanned += 1 + const row = cursor.value as TArchivedEventRow + const ev = row?.value + if (ev && calendarKinds.has(ev.kind)) { + cursor.delete() + removed += 1 + } + cursor.continue() + } + req.onerror = (e) => { + tx.commit() + reject(idbEventToError(e)) + } + }) + + await this.setSetting(ARCHIVE_CALENDAR_PURGE_SETTING_KEY, '1') + if (removed > 0) { + logger.info('[IndexedDB] Purged legacy calendar rows from event archive', { removed }) + } + } + private scheduleNextCleanUp(delayMs: number): void { if (typeof window === 'undefined') return if (this.cleanupTimer !== null) { diff --git a/src/services/nip89.service.ts b/src/services/nip89.service.ts index e9e6492c..c6957a3c 100644 --- a/src/services/nip89.service.ts +++ b/src/services/nip89.service.ts @@ -238,8 +238,7 @@ class Nip89Service { relays: [ 'wss://relay.damus.io', 'wss://relay.snort.social', - 'wss://nos.lol', - 'wss://relay.nostr.band' + 'wss://nos.lol' ] }