Browse Source

new, dynamic relay explore page

imwald
Silberengel 1 month ago
parent
commit
3ec73dfee4
  1. 314
      src/components/Explore/ExploreRelayDirectory.tsx
  2. 41
      src/components/Explore/ExploreRelayReviews.tsx
  3. 3
      src/constants.ts
  4. 2
      src/i18n/locales/en.ts
  5. 4
      src/lib/citation-picker-relays.ts
  6. 54
      src/lib/explore-relay-directory.test.ts
  7. 188
      src/lib/explore-relay-directory.ts
  8. 53
      src/lib/explore-relay-reviews.ts
  9. 128
      src/pages/primary/ExplorePage/index.tsx
  10. 122
      src/services/indexed-db.service.ts
  11. 3
      src/services/nip89.service.ts

314
src/components/Explore/ExploreRelayDirectory.tsx

@ -0,0 +1,314 @@ @@ -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 (
<article className="border-b px-4 py-4">
<RelaySimpleInfo
relayInfo={relayInfo}
users={favoritedBy.length > 0 ? favoritedBy : undefined}
className="clickable min-h-0"
onClick={(e) => {
e.stopPropagation()
navigateToRelay(toRelay(entry.url))
}}
/>
{badges.length > 0 ? (
<div className="mt-2 flex flex-wrap gap-1.5">
{badges.map((b) => (
<Badge key={b.key} variant="secondary" className="text-xs font-normal">
{b.label}
</Badge>
))}
</div>
) : null}
{visibleReviews.length > 0 ? (
<div className="mt-3 grid min-w-0 gap-2 md:grid-cols-2">
{visibleReviews.map((event) => (
<RelayReviewCard
key={event.id}
event={event}
showRelayInfo={false}
className="border md:border-border"
/>
))}
</div>
) : null}
</article>
)
}
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<string[]>([])
const [followingFavorites, setFollowingFavorites] = useState<[string, string[]][]>([])
const [followingLoading, setFollowingLoading] = useState(true)
const [reviewEvents, setReviewEvents] = useState<import('nostr-tools').Event[]>([])
const [reviewsLoading, setReviewsLoading] = useState(true)
const [showCount, setShowCount] = useState(SHOW_COUNT)
const bottomRef = useRef<HTMLDivElement>(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 (
<section className="min-w-0 pb-8" aria-label={t('Relays')}>
{Array.from({ length: 4 }).map((_, i) => (
<RelaySimpleInfoSkeleton key={i} className="border-b p-4" />
))}
</section>
)
}
if (filtered.length === 0) {
return (
<p className="px-4 py-6 text-center text-sm text-muted-foreground">
{listFilter.trim() ? t('no relays found') : t('No relays in your lists yet.')}
</p>
)
}
return (
<section className="min-w-0 pb-8" aria-label={t('Relays')}>
<p className="mb-3 px-4 text-sm text-muted-foreground">
{t('Your relays first, then those your network favors and reviews.')}
</p>
{visible.map((entry) => (
<ExploreRelayDirectoryCard key={entry.url} entry={entry} />
))}
{reviewsLoading && entries.length > 0 ? (
<div className="px-4 py-2" aria-busy="true">
<Skeleton className="h-8 w-48" />
</div>
) : null}
{showCount < filtered.length ? <div ref={bottomRef} className="h-4" aria-hidden /> : null}
{!followingLoading && !reviewsLoading && showCount >= filtered.length ? (
<p className="mt-3 text-center text-sm text-muted-foreground">{t('no more relays')}</p>
) : null}
</section>
)
}

41
src/components/Explore/ExploreRelayReviews.tsx

@ -3,8 +3,11 @@ import RelayReviewCard from '@/components/RelayInfo/RelayReviewCard' @@ -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' @@ -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 @@ -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<string>()
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<Event[]> {
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[],

3
src/constants.ts

@ -73,7 +73,8 @@ export const DESKTOP_APP_DOWNLOAD_URL_DEFAULT = @@ -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'
]
/**

2
src/i18n/locales/en.ts

@ -552,6 +552,8 @@ export default { @@ -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",

4
src/lib/citation-picker-relays.ts

@ -11,9 +11,6 @@ import { normalizeUrl } from '@/lib/url' @@ -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<string[]> { @@ -60,7 +57,6 @@ export async function buildCitationPickerSearchRelayUrls(): Promise<string[]> {
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)
],

54
src/lib/explore-relay-directory.test.ts

@ -0,0 +1,54 @@ @@ -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)
})
})

188
src/lib/explore-relay-directory.ts

@ -0,0 +1,188 @@ @@ -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<string, readonly Event[]>
max?: number
}
function normalizeBlocked(blockedRelays: readonly string[]): Set<string> {
return new Set(
blockedRelays.map((b) => normalizeAnyRelayUrl(b) || b.trim()).filter(Boolean)
)
}
function getOrCreateRow(
rows: Map<string, MutableRelayRow>,
raw: string,
blocked: Set<string>
): 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<string, MutableRelayRow>()
const rl = options.relayList
const touch = (raw: string, patch: Partial<ExploreRelaySourceFlags>) => {
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)
})
}

53
src/lib/explore-relay-reviews.ts

@ -0,0 +1,53 @@ @@ -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<string>()
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<Event[]> {
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<string, Event[]> {
const groups = new Map<string, Event[]>()
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
}

128
src/pages/primary/ExplorePage/index.tsx

@ -1,14 +1,5 @@ @@ -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 { @@ -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 @@ -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<TPageRef>((_, ref) => {
const { t } = useTranslation()
const { pubkey, relayList } = useNostr()
const [tab, setTab] = useState<TExploreTabs>('explore')
const layoutRef = useRef<TPageRef>(null)
const [contentRefreshKey, setContentRefreshKey] = useState(0)
const [listFilter, setListFilter] = useState('')
const bumpExploreContent = useCallback(() => {
void (async () => {
@ -90,19 +77,7 @@ const ExplorePage = forwardRef<TPageRef>((_, ref) => { @@ -90,19 +77,7 @@ const ExplorePage = forwardRef<TPageRef>((_, 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<TPageRef>((_, ref) => { @@ -110,43 +85,11 @@ const ExplorePage = forwardRef<TPageRef>((_, ref) => {
ref={layoutRef}
pageName="explore"
titlebar={<ExplorePageTitlebar onRefresh={bumpExploreContent} />}
subHeader={
<Tabs
value={tab}
tabs={[
{ value: 'explore', label: t('Explore') },
{ value: 'reviews', label: t('Relay reviews') },
{ value: 'following', label: t("Following's Favorites") }
]}
onTabChange={(next) => {
setTab(next as TExploreTabs)
window.dispatchEvent(
new CustomEvent('pageTabChanged', {
detail: { page: 'explore', tab: next }
})
)
}}
/>
}
displayScrollToTopButton
>
<div className="min-w-0 pt-2">
{tab === 'explore' && (
<div key={contentRefreshKey} className="min-w-0">
<ExploreRelaySearchSection />
<ExplorePopularRelays />
</div>
)}
{tab === 'reviews' && (
<div key={contentRefreshKey} className="min-w-0">
<ExploreRelayReviews />
</div>
)}
{tab === 'following' && (
<div key={contentRefreshKey} className="min-w-0">
<FollowingFavoriteRelayList />
</div>
)}
<div key={contentRefreshKey} className="min-w-0 pt-2">
<ExploreRelaySearchSection listFilter={listFilter} onListFilterChange={setListFilter} />
<ExploreRelayDirectory listFilter={listFilter} />
</div>
</PrimaryPageLayout>
)
@ -165,31 +108,36 @@ function ExplorePageTitlebar({ onRefresh }: { onRefresh: () => void }) { @@ -165,31 +108,36 @@ function ExplorePageTitlebar({ onRefresh }: { onRefresh: () => void }) {
</div>
<div className="flex shrink-0 items-center gap-1">
<RefreshButton onClick={onRefresh} />
<Button
variant="ghost"
size="titlebar-icon"
className="relative w-fit shrink-0 px-3"
onClick={() => {
window.open(
'https://github.com/CodyTseng/awesome-nostr-relays/issues/new?template=add-relay.md',
'_blank'
)
}}
>
<Plus size={16} />
{t('Submit Relay')}
</Button>
<Button
variant="ghost"
size="titlebar-icon"
className="relative w-fit shrink-0 px-3"
onClick={() => {
window.open(
'https://github.com/CodyTseng/awesome-nostr-relays/issues/new?template=add-relay.md',
'_blank'
)
}}
>
<Plus size={16} />
{t('Submit Relay')}
</Button>
</div>
</div>
)
}
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<ReturnType<typeof setTimeout> | null>(null)
@ -209,8 +157,8 @@ function ExploreRelaySearchSection() { @@ -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() { @@ -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() { @@ -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}

122
src/services/indexed-db.service.ts

@ -1,4 +1,7 @@ @@ -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 = { @@ -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<string> = new Set( @@ -172,8 +174,44 @@ const CACHE_BROWSER_EVENT_SEARCH_EXCLUDED_STORES: ReadonlySet<string> = 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<string> = 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<string> = 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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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<string>()
@ -2284,6 +2330,56 @@ class IndexedDbService { @@ -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<void> {
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<number>([
...CALENDAR_EVENT_KINDS,
ExtendedKind.CALENDAR_EVENT_RSVP
])
let removed = 0
const maxScanned = 80_000
await new Promise<void>((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) {

3
src/services/nip89.service.ts

@ -238,8 +238,7 @@ class Nip89Service { @@ -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'
]
}

Loading…
Cancel
Save