feat: explore

This commit is contained in:
codytseng
2025-09-07 22:23:01 +08:00
parent f2bb65acf0
commit ace4e94071
11 changed files with 240 additions and 233 deletions

View File

@@ -0,0 +1,85 @@
import { Skeleton } from '@/components/ui/skeleton'
import { useFetchRelayInfo } from '@/hooks'
import { toRelay } from '@/lib/link'
import { useSecondaryPage } from '@/PageManager'
import relayInfoService from '@/services/relay-info.service'
import { TAwesomeRelayCollection } from '@/types'
import { useEffect, useState } from 'react'
import RelaySimpleInfo, { RelaySimpleInfoSkeleton } from '../RelaySimpleInfo'
import { useDeepBrowsing } from '@/providers/DeepBrowsingProvider'
import { cn } from '@/lib/utils'
export default function Explore() {
const [collections, setCollections] = useState<TAwesomeRelayCollection[] | null>(null)
useEffect(() => {
relayInfoService.getAwesomeRelayCollections().then(setCollections)
}, [])
if (!collections) {
return (
<div>
<div className="p-4 max-md:border-b">
<Skeleton className="h-6 w-20" />
</div>
<div className="grid md:px-4 md:grid-cols-2 md:gap-2">
<RelaySimpleInfoSkeleton className="h-auto px-4 py-3 md:rounded-lg md:border" />
</div>
</div>
)
}
return (
<div className="space-y-6">
{collections.map((collection) => (
<RelayCollection key={collection.id} collection={collection} />
))}
</div>
)
}
function RelayCollection({ collection }: { collection: TAwesomeRelayCollection }) {
const { deepBrowsing } = useDeepBrowsing()
return (
<div>
<div
className={cn(
'sticky bg-background z-20 px-4 py-3 text-2xl font-semibold max-md:border-b',
deepBrowsing ? 'top-12' : 'top-24'
)}
>
{collection.name}
</div>
<div className="grid md:px-4 md:grid-cols-2 md:gap-3">
{collection.relays.map((url) => (
<RelayItem key={url} url={url} />
))}
</div>
</div>
)
}
function RelayItem({ url }: { url: string }) {
const { push } = useSecondaryPage()
const { relayInfo, isFetching } = useFetchRelayInfo(url)
if (isFetching) {
return <RelaySimpleInfoSkeleton className="h-auto px-4 py-3 border-b md:rounded-lg md:border" />
}
if (!relayInfo) {
return null
}
return (
<RelaySimpleInfo
key={relayInfo.url}
className="clickable h-auto px-4 py-3 border-b md:rounded-lg md:border"
relayInfo={relayInfo}
onClick={(e) => {
e.stopPropagation()
push(toRelay(relayInfo.url))
}}
/>
)
}

View File

@@ -62,7 +62,7 @@ export default function FollowingFavoriteRelayList() {
<RelayItem key={url} url={url} users={users} /> <RelayItem key={url} url={url} users={users} />
))} ))}
{showCount < relays.length && <div ref={bottomRef} />} {showCount < relays.length && <div ref={bottomRef} />}
{loading && <RelaySimpleInfoSkeleton />} {loading && <RelaySimpleInfoSkeleton className="p-4" />}
{!loading && ( {!loading && (
<div className="text-center text-muted-foreground text-sm mt-2"> <div className="text-center text-muted-foreground text-sm mt-2">
{relays.length === 0 ? t('no relays found') : t('no more relays')} {relays.length === 0 ? t('no relays found') : t('no more relays')}

View File

@@ -1,6 +1,6 @@
import { usePrimaryPage } from '@/PageManager' import { usePrimaryPage } from '@/PageManager'
import relayInfoService from '@/services/relay-info.service' import relayInfoService from '@/services/relay-info.service'
import { TNip66RelayInfo } from '@/types' import { TRelayInfo } from '@/types'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import RelaySimpleInfo, { RelaySimpleInfoSkeleton } from '../RelaySimpleInfo' import RelaySimpleInfo, { RelaySimpleInfoSkeleton } from '../RelaySimpleInfo'
@@ -10,7 +10,7 @@ export default function RelayList() {
const { t } = useTranslation() const { t } = useTranslation()
const { navigate } = usePrimaryPage() const { navigate } = usePrimaryPage()
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [relays, setRelays] = useState<TNip66RelayInfo[]>([]) const [relays, setRelays] = useState<TRelayInfo[]>([])
const [showCount, setShowCount] = useState(20) const [showCount, setShowCount] = useState(20)
const [input, setInput] = useState('') const [input, setInput] = useState('')
const [debouncedInput, setDebouncedInput] = useState(input) const [debouncedInput, setDebouncedInput] = useState(input)
@@ -82,7 +82,7 @@ export default function RelayList() {
/> />
))} ))}
{showCount < relays.length && <div ref={bottomRef} />} {showCount < relays.length && <div ref={bottomRef} />}
{loading && <RelaySimpleInfoSkeleton />} {loading && <RelaySimpleInfoSkeleton className="p-4" />}
{!loading && relays.length === 0 && ( {!loading && relays.length === 0 && (
<div className="text-center text-muted-foreground text-sm">{t('no relays found')}</div> <div className="text-center text-muted-foreground text-sm">{t('no relays found')}</div>
)} )}

View File

@@ -1,6 +1,6 @@
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { TNip66RelayInfo } from '@/types' import { TRelayInfo } from '@/types'
import { HTMLProps } from 'react' import { HTMLProps } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import RelayBadges from '../RelayBadges' import RelayBadges from '../RelayBadges'
@@ -15,7 +15,7 @@ export default function RelaySimpleInfo({
className, className,
...props ...props
}: HTMLProps<HTMLDivElement> & { }: HTMLProps<HTMLDivElement> & {
relayInfo?: TNip66RelayInfo relayInfo?: TRelayInfo
users?: string[] users?: string[]
hideBadge?: boolean hideBadge?: boolean
}) { }) {
@@ -36,7 +36,7 @@ export default function RelaySimpleInfo({
{relayInfo && <SaveRelayDropdownMenu urls={[relayInfo.url]} />} {relayInfo && <SaveRelayDropdownMenu urls={[relayInfo.url]} />}
</div> </div>
{!hideBadge && relayInfo && <RelayBadges relayInfo={relayInfo} />} {!hideBadge && relayInfo && <RelayBadges relayInfo={relayInfo} />}
{!!relayInfo?.description && <div className="line-clamp-4">{relayInfo.description}</div>} {!!relayInfo?.description && <div className="line-clamp-3">{relayInfo.description}</div>}
{!!users?.length && ( {!!users?.length && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="text-muted-foreground">{t('Favorited by')} </div> <div className="text-muted-foreground">{t('Favorited by')} </div>
@@ -56,9 +56,9 @@ export default function RelaySimpleInfo({
) )
} }
export function RelaySimpleInfoSkeleton() { export function RelaySimpleInfoSkeleton({ className }: { className?: string }) {
return ( return (
<div className="p-4 space-y-2"> <div className={cn('space-y-1', className)}>
<div className="flex items-center gap-2 w-full"> <div className="flex items-center gap-2 w-full">
<Skeleton className="h-9 w-9 rounded-full" /> <Skeleton className="h-9 w-9 rounded-full" />
<div className="flex-1 w-0 space-y-1"> <div className="flex-1 w-0 space-y-1">

View File

@@ -111,9 +111,6 @@ export const EMOJI_REGEX =
export const YOUTUBE_URL_REGEX = export const YOUTUBE_URL_REGEX =
/https?:\/\/(?:(?:www|m)\.)?(?:youtube\.com\/(?:watch\?[^#\s]*|embed\/[\w-]+|shorts\/[\w-]+|live\/[\w-]+)|youtu\.be\/[\w-]+)(?:\?[^#\s]*)?(?:#[^\s]*)?/g /https?:\/\/(?:(?:www|m)\.)?(?:youtube\.com\/(?:watch\?[^#\s]*|embed\/[\w-]+|shorts\/[\w-]+|live\/[\w-]+)|youtu\.be\/[\w-]+)(?:\?[^#\s]*)?(?:#[^\s]*)?/g
export const MONITOR = '9bbbb845e5b6c831c29789900769843ab43bb5047abe697870cb50b6fc9bf923'
export const MONITOR_RELAYS = ['wss://relay.nostr.watch/']
export const JUMBLE_PUBKEY = 'f4eb8e62add1340b9cadcd9861e669b2e907cea534e0f7f3ac974c11c758a51a' export const JUMBLE_PUBKEY = 'f4eb8e62add1340b9cadcd9861e669b2e907cea534e0f7f3ac974c11c758a51a'
export const CODY_PUBKEY = '8125b911ed0e94dbe3008a0be48cfe5cd0c0b05923cfff917ae7e87da8400883' export const CODY_PUBKEY = '8125b911ed0e94dbe3008a0be48cfe5cd0c0b05923cfff917ae7e87da8400883'

View File

@@ -1,10 +1,10 @@
import relayInfoService from '@/services/relay-info.service' import relayInfoService from '@/services/relay-info.service'
import { TNip66RelayInfo } from '@/types' import { TRelayInfo } from '@/types'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
export function useFetchRelayInfo(url?: string) { export function useFetchRelayInfo(url?: string) {
const [isFetching, setIsFetching] = useState(true) const [isFetching, setIsFetching] = useState(true)
const [relayInfo, setRelayInfo] = useState<TNip66RelayInfo | undefined>(undefined) const [relayInfo, setRelayInfo] = useState<TRelayInfo | undefined>(undefined)
useEffect(() => { useEffect(() => {
if (!url) return if (!url) return

View File

@@ -1,15 +1,15 @@
import Explore from '@/components/Explore'
import FollowingFavoriteRelayList from '@/components/FollowingFavoriteRelayList' import FollowingFavoriteRelayList from '@/components/FollowingFavoriteRelayList'
import RelayList from '@/components/RelayList'
import Tabs from '@/components/Tabs' import Tabs from '@/components/Tabs'
import PrimaryPageLayout from '@/layouts/PrimaryPageLayout' import PrimaryPageLayout from '@/layouts/PrimaryPageLayout'
import { Compass } from 'lucide-react' import { Compass } from 'lucide-react'
import { forwardRef, useState } from 'react' import { forwardRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
type TExploreTabs = 'following' | 'all' type TExploreTabs = 'following' | 'explore'
const ExplorePage = forwardRef((_, ref) => { const ExplorePage = forwardRef((_, ref) => {
const [tab, setTab] = useState<TExploreTabs>('following') const [tab, setTab] = useState<TExploreTabs>('explore')
return ( return (
<PrimaryPageLayout <PrimaryPageLayout
@@ -21,12 +21,12 @@ const ExplorePage = forwardRef((_, ref) => {
<Tabs <Tabs
value={tab} value={tab}
tabs={[ tabs={[
{ value: 'following', label: "Following's Favorites" }, { value: 'explore', label: 'Explore' },
{ value: 'all', label: 'All' } { value: 'following', label: "Following's Favorites" }
]} ]}
onTabChange={(tab) => setTab(tab as TExploreTabs)} onTabChange={(tab) => setTab(tab as TExploreTabs)}
/> />
{tab === 'following' ? <FollowingFavoriteRelayList /> : <RelayList />} {tab === 'following' ? <FollowingFavoriteRelayList /> : <Explore />}
</PrimaryPageLayout> </PrimaryPageLayout>
) )
}) })

View File

@@ -5,7 +5,7 @@ import { RECOMMENDED_RELAYS } from '@/constants'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { toRelay } from '@/lib/link' import { toRelay } from '@/lib/link'
import relayInfoService from '@/services/relay-info.service' import relayInfoService from '@/services/relay-info.service'
import { TNip66RelayInfo } from '@/types' import { TRelayInfo } from '@/types'
import { ArrowRight, Server } from 'lucide-react' import { ArrowRight, Server } from 'lucide-react'
import { forwardRef, useEffect, useState } from 'react' import { forwardRef, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@@ -14,13 +14,13 @@ const HomePage = forwardRef(({ index }: { index?: number }, ref) => {
const { t } = useTranslation() const { t } = useTranslation()
const { navigate } = usePrimaryPage() const { navigate } = usePrimaryPage()
const { push } = useSecondaryPage() const { push } = useSecondaryPage()
const [recommendedRelayInfos, setRecommendedRelayInfos] = useState<TNip66RelayInfo[]>([]) const [recommendedRelayInfos, setRecommendedRelayInfos] = useState<TRelayInfo[]>([])
useEffect(() => { useEffect(() => {
const init = async () => { const init = async () => {
try { try {
const relays = await relayInfoService.getRelayInfos(RECOMMENDED_RELAYS) const relays = await relayInfoService.getRelayInfos(RECOMMENDED_RELAYS)
setRecommendedRelayInfos(relays.filter(Boolean) as TNip66RelayInfo[]) setRecommendedRelayInfos(relays.filter(Boolean) as TRelayInfo[])
} catch (error) { } catch (error) {
console.error('Failed to fetch recommended relays:', error) console.error('Failed to fetch recommended relays:', error)
} }
@@ -56,7 +56,7 @@ const HomePage = forwardRef(({ index }: { index?: number }, ref) => {
{recommendedRelayInfos.map((relayInfo) => ( {recommendedRelayInfos.map((relayInfo) => (
<RelaySimpleInfo <RelaySimpleInfo
key={relayInfo.url} key={relayInfo.url}
className="clickable h-auto p-3 rounded-lg border" className="clickable h-auto px-4 py-3 rounded-lg border"
relayInfo={relayInfo} relayInfo={relayInfo}
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()

View File

@@ -1,5 +1,6 @@
import { ExtendedKind } from '@/constants' import { ExtendedKind } from '@/constants'
import { tagNameEquals } from '@/lib/tag' import { tagNameEquals } from '@/lib/tag'
import { TRelayInfo } from '@/types'
import { Event, kinds } from 'nostr-tools' import { Event, kinds } from 'nostr-tools'
type TValue<T = any> = { type TValue<T = any> = {
@@ -16,12 +17,13 @@ const StoreNames = {
BOOKMARK_LIST_EVENTS: 'bookmarkListEvents', BOOKMARK_LIST_EVENTS: 'bookmarkListEvents',
BLOSSOM_SERVER_LIST_EVENTS: 'blossomServerListEvents', BLOSSOM_SERVER_LIST_EVENTS: 'blossomServerListEvents',
MUTE_DECRYPTED_TAGS: 'muteDecryptedTags', MUTE_DECRYPTED_TAGS: 'muteDecryptedTags',
RELAY_INFO_EVENTS: 'relayInfoEvents',
USER_EMOJI_LIST_EVENTS: 'userEmojiListEvents', USER_EMOJI_LIST_EVENTS: 'userEmojiListEvents',
EMOJI_SET_EVENTS: 'emojiSetEvents', EMOJI_SET_EVENTS: 'emojiSetEvents',
FAVORITE_RELAYS: 'favoriteRelays', FAVORITE_RELAYS: 'favoriteRelays',
RELAY_SETS: 'relaySets', RELAY_SETS: 'relaySets',
FOLLOWING_FAVORITE_RELAYS: 'followingFavoriteRelays' FOLLOWING_FAVORITE_RELAYS: 'followingFavoriteRelays',
RELAY_INFOS: 'relayInfos',
RELAY_INFO_EVENTS: 'relayInfoEvents' // deprecated
} }
class IndexedDbService { class IndexedDbService {
@@ -40,7 +42,7 @@ class IndexedDbService {
init(): Promise<void> { init(): Promise<void> {
if (!this.initPromise) { if (!this.initPromise) {
this.initPromise = new Promise((resolve, reject) => { this.initPromise = new Promise((resolve, reject) => {
const request = window.indexedDB.open('jumble', 7) const request = window.indexedDB.open('jumble', 8)
request.onerror = (event) => { request.onerror = (event) => {
reject(event) reject(event)
@@ -71,9 +73,6 @@ class IndexedDbService {
if (!db.objectStoreNames.contains(StoreNames.MUTE_DECRYPTED_TAGS)) { if (!db.objectStoreNames.contains(StoreNames.MUTE_DECRYPTED_TAGS)) {
db.createObjectStore(StoreNames.MUTE_DECRYPTED_TAGS, { keyPath: 'key' }) db.createObjectStore(StoreNames.MUTE_DECRYPTED_TAGS, { keyPath: 'key' })
} }
if (!db.objectStoreNames.contains(StoreNames.RELAY_INFO_EVENTS)) {
db.createObjectStore(StoreNames.RELAY_INFO_EVENTS, { keyPath: 'key' })
}
if (!db.objectStoreNames.contains(StoreNames.FAVORITE_RELAYS)) { if (!db.objectStoreNames.contains(StoreNames.FAVORITE_RELAYS)) {
db.createObjectStore(StoreNames.FAVORITE_RELAYS, { keyPath: 'key' }) db.createObjectStore(StoreNames.FAVORITE_RELAYS, { keyPath: 'key' })
} }
@@ -92,6 +91,12 @@ class IndexedDbService {
if (!db.objectStoreNames.contains(StoreNames.EMOJI_SET_EVENTS)) { if (!db.objectStoreNames.contains(StoreNames.EMOJI_SET_EVENTS)) {
db.createObjectStore(StoreNames.EMOJI_SET_EVENTS, { keyPath: 'key' }) db.createObjectStore(StoreNames.EMOJI_SET_EVENTS, { keyPath: 'key' })
} }
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)
}
this.db = db this.db = db
} }
}) })
@@ -297,58 +302,6 @@ class IndexedDbService {
}) })
} }
async getAllRelayInfoEvents(): Promise<Event[]> {
await this.initPromise
return new Promise((resolve, reject) => {
if (!this.db) {
return reject('database not initialized')
}
const transaction = this.db.transaction(StoreNames.RELAY_INFO_EVENTS, 'readonly')
const store = transaction.objectStore(StoreNames.RELAY_INFO_EVENTS)
const request = store.getAll()
request.onsuccess = () => {
transaction.commit()
resolve(
((request.result as TValue<Event>[])
?.map((item) => item.value)
.filter(Boolean) as Event[]) ?? []
)
}
request.onerror = (event) => {
transaction.commit()
reject(event)
}
})
}
async putRelayInfoEvent(event: Event): Promise<void> {
await this.initPromise
return new Promise((resolve, reject) => {
if (!this.db) {
return reject('database not initialized')
}
const dValue = event.tags.find(tagNameEquals('d'))?.[1]
if (!dValue) {
return resolve()
}
const transaction = this.db.transaction(StoreNames.RELAY_INFO_EVENTS, 'readwrite')
const store = transaction.objectStore(StoreNames.RELAY_INFO_EVENTS)
const putRequest = store.put(this.formatValue(dValue, event))
putRequest.onsuccess = () => {
transaction.commit()
resolve()
}
putRequest.onerror = (event) => {
transaction.commit()
reject(event)
}
})
}
async iterateProfileEvents(callback: (event: Event) => Promise<void>): Promise<void> { async iterateProfileEvents(callback: (event: Event) => Promise<void>): Promise<void> {
await this.initPromise await this.initPromise
if (!this.db) { if (!this.db) {
@@ -424,6 +377,50 @@ class IndexedDbService {
}) })
} }
async putRelayInfo(relayInfo: TRelayInfo): Promise<void> {
await this.initPromise
return new Promise((resolve, reject) => {
if (!this.db) {
return reject('database not initialized')
}
const transaction = this.db.transaction(StoreNames.RELAY_INFOS, 'readwrite')
const store = transaction.objectStore(StoreNames.RELAY_INFOS)
const putRequest = store.put(this.formatValue(relayInfo.url, relayInfo))
putRequest.onsuccess = () => {
transaction.commit()
resolve()
}
putRequest.onerror = (event) => {
transaction.commit()
reject(event)
}
})
}
async getRelayInfo(url: string): Promise<TRelayInfo | null> {
await this.initPromise
return new Promise((resolve, reject) => {
if (!this.db) {
return reject('database not initialized')
}
const transaction = this.db.transaction(StoreNames.RELAY_INFOS, 'readonly')
const store = transaction.objectStore(StoreNames.RELAY_INFOS)
const request = store.get(url)
request.onsuccess = () => {
transaction.commit()
resolve((request.result as TValue<TRelayInfo>)?.value)
}
request.onerror = (event) => {
transaction.commit()
reject(event)
}
})
}
private getReplaceableEventKeyFromEvent(event: Event): string { private getReplaceableEventKeyFromEvent(event: Event): string {
if ( if (
[kinds.Metadata, kinds.Contacts].includes(event.kind) || [kinds.Metadata, kinds.Contacts].includes(event.kind) ||
@@ -491,6 +488,10 @@ class IndexedDbService {
{ {
name: StoreNames.BLOSSOM_SERVER_LIST_EVENTS, name: StoreNames.BLOSSOM_SERVER_LIST_EVENTS,
expirationTimestamp: Date.now() - 1000 * 60 * 60 * 24 // 1 days expirationTimestamp: Date.now() - 1000 * 60 * 60 * 24 // 1 days
},
{
name: StoreNames.RELAY_INFOS,
expirationTimestamp: Date.now() - 1000 * 60 * 60 * 24 // 1 days
} }
] ]
const transaction = this.db!.transaction( const transaction = this.db!.transaction(

View File

@@ -1,12 +1,8 @@
import { MONITOR, MONITOR_RELAYS } from '@/constants' import { simplifyUrl } from '@/lib/url'
import { tagNameEquals } from '@/lib/tag' import indexDb from '@/services/indexed-db.service'
import { isWebsocketUrl, simplifyUrl } from '@/lib/url' import { TAwesomeRelayCollection, TRelayInfo } from '@/types'
import { TNip66RelayInfo, TRelayInfo } from '@/types'
import DataLoader from 'dataloader' import DataLoader from 'dataloader'
import FlexSearch from 'flexsearch' import FlexSearch from 'flexsearch'
import { Event } from 'nostr-tools'
import client from './client.service'
import indexedDb from './indexed-db.service'
class RelayInfoService { class RelayInfoService {
static instance: RelayInfoService static instance: RelayInfoService
@@ -14,14 +10,13 @@ class RelayInfoService {
public static getInstance(): RelayInfoService { public static getInstance(): RelayInfoService {
if (!RelayInfoService.instance) { if (!RelayInfoService.instance) {
RelayInfoService.instance = new RelayInfoService() RelayInfoService.instance = new RelayInfoService()
RelayInfoService.instance.init()
} }
return RelayInfoService.instance return RelayInfoService.instance
} }
private initPromise: Promise<void> | null = null private initPromise: Promise<void> | null = null
private awesomeRelayCollections: Promise<TAwesomeRelayCollection[]> | null = null
private relayInfoMap = new Map<string, TNip66RelayInfo>() private relayInfoMap = new Map<string, TRelayInfo>()
private relayInfoIndex = new FlexSearch.Index({ private relayInfoIndex = new FlexSearch.Index({
tokenize: 'forward', tokenize: 'forward',
encode: (str) => encode: (str) =>
@@ -32,19 +27,15 @@ class RelayInfoService {
.toLocaleLowerCase() .toLocaleLowerCase()
.split(/\s+/) .split(/\s+/)
}) })
private fetchDataloader = new DataLoader<string, TNip66RelayInfo | undefined>( private fetchDataloader = new DataLoader<string, TRelayInfo | undefined>(
(urls) => Promise.all(urls.map((url) => this._getRelayInfo(url))), async (urls) => {
const results = await Promise.allSettled(urls.map((url) => this._getRelayInfo(url)))
return results.map((res) => (res.status === 'fulfilled' ? res.value : undefined))
},
{ maxBatchSize: 1 } { maxBatchSize: 1 }
) )
private relayUrlsForRandom: string[] = [] private relayUrlsForRandom: string[] = []
async init() {
if (!this.initPromise) {
this.initPromise = this.loadRelayInfos()
}
await this.initPromise
}
async search(query: string) { async search(query: string) {
if (this.initPromise) { if (this.initPromise) {
await this.initPromise await this.initPromise
@@ -60,9 +51,7 @@ class RelayInfoService {
} }
const result = await this.relayInfoIndex.searchAsync(query) const result = await this.relayInfoIndex.searchAsync(query)
return result return result.map((url) => this.relayInfoMap.get(url as string)).filter(Boolean) as TRelayInfo[]
.map((url) => this.relayInfoMap.get(url as string))
.filter(Boolean) as TNip66RelayInfo[]
} }
async getRelayInfos(urls: string[]) { async getRelayInfos(urls: string[]) {
@@ -82,7 +71,7 @@ class RelayInfoService {
await this.initPromise await this.initPromise
} }
const relayInfos: TNip66RelayInfo[] = [] const relayInfos: TRelayInfo[] = []
while (relayInfos.length < count) { while (relayInfos.length < count) {
const randomIndex = Math.floor(Math.random() * this.relayUrlsForRandom.length) const randomIndex = Math.floor(Math.random() * this.relayUrlsForRandom.length)
const url = this.relayUrlsForRandom[randomIndex] const url = this.relayUrlsForRandom[randomIndex]
@@ -99,146 +88,81 @@ class RelayInfoService {
return relayInfos return relayInfos
} }
async getAwesomeRelayCollections() {
if (this.awesomeRelayCollections) return this.awesomeRelayCollections
this.awesomeRelayCollections = (async () => {
try {
const res = await fetch(
'https://raw.githubusercontent.com/CodyTseng/awesome-nostr-relays/master/dist/collections.json'
)
if (!res.ok) {
throw new Error('Failed to fetch awesome relay collections')
}
const data = (await res.json()) as { collections: TAwesomeRelayCollection[] }
return data.collections
} catch (error) {
console.error('Error fetching awesome relay collections:', error)
return []
}
})()
return this.awesomeRelayCollections
}
private async _getRelayInfo(url: string) { private async _getRelayInfo(url: string) {
const exist = this.relayInfoMap.get(url) const exist = this.relayInfoMap.get(url)
if (exist && (exist.hasNip11 || exist.triedNip11)) { if (exist) {
return exist return exist
} }
const nip11 = await this.fetchRelayInfoByNip11(url) const storedRelayInfo = await indexDb.getRelayInfo(url)
const relayInfo = nip11 if (storedRelayInfo) {
? { return await this.addRelayInfo(storedRelayInfo)
...nip11, }
url,
shortUrl: simplifyUrl(url), const nip11 = await this.fetchRelayNip11(url)
hasNip11: Object.keys(nip11).length > 0, const relayInfo = {
triedNip11: true ...(nip11 ?? {}),
} url,
: { shortUrl: simplifyUrl(url)
url, }
shortUrl: simplifyUrl(url),
hasNip11: false,
triedNip11: true
}
return await this.addRelayInfo(relayInfo) return await this.addRelayInfo(relayInfo)
} }
private async fetchRelayInfoByNip11(url: string) { private async fetchRelayNip11(url: string) {
try { try {
console.log('Fetching NIP-11 for', url)
const res = await fetch(url.replace('ws://', 'http://').replace('wss://', 'https://'), { const res = await fetch(url.replace('ws://', 'http://').replace('wss://', 'https://'), {
headers: { Accept: 'application/nostr+json' } headers: { Accept: 'application/nostr+json' }
}) })
return res.json() as TRelayInfo return res.json() as Omit<TRelayInfo, 'url' | 'shortUrl'>
} catch { } catch {
return undefined return undefined
} }
} }
private async loadRelayInfos() { private async addRelayInfo(relayInfo: TRelayInfo) {
const localRelayInfos = await indexedDb.getAllRelayInfoEvents() if (!Array.isArray(relayInfo.supported_nips)) {
const relayInfos = formatRelayInfoEvents(localRelayInfos) relayInfo.supported_nips = []
relayInfos.forEach((relayInfo) => this.addRelayInfo(relayInfo))
this.relayUrlsForRandom = Array.from(this.relayInfoMap.keys())
const loadFromInternet = async (slowFetch: boolean = true) => {
let until: number = Math.round(Date.now() / 1000)
const since = until - 60 * 60 * 48
while (until) {
const relayInfoEvents = await client.fetchEvents(MONITOR_RELAYS, {
authors: [MONITOR],
kinds: [30166],
since,
until,
limit: slowFetch ? 100 : 1000
})
const events = relayInfoEvents.sort((a, b) => b.created_at - a.created_at)
if (events.length === 0) {
break
}
for (const event of events) {
await indexedDb.putRelayInfoEvent(event)
const relayInfo = formatRelayInfoEvents([event])[0]
await this.addRelayInfo(relayInfo)
}
until = events[events.length - 1].created_at - 1
if (slowFetch) {
await new Promise((resolve) => setTimeout(resolve, 1000))
}
}
this.relayUrlsForRandom = Array.from(this.relayInfoMap.keys())
}
if (localRelayInfos.length === 0) {
await loadFromInternet(false)
} else {
setTimeout(loadFromInternet, 1000 * 20) // 20 seconds
}
}
private async addRelayInfo(relayInfo: TNip66RelayInfo) {
const oldRelayInfo = this.relayInfoMap.get(relayInfo.url)
const newRelayInfo = oldRelayInfo
? {
...oldRelayInfo,
...relayInfo,
hasNip11: oldRelayInfo.hasNip11 || relayInfo.hasNip11,
triedNip11: oldRelayInfo.triedNip11 || relayInfo.triedNip11
}
: relayInfo
if (!Array.isArray(newRelayInfo.supported_nips)) {
newRelayInfo.supported_nips = []
} }
this.relayInfoMap.set(newRelayInfo.url, newRelayInfo) this.relayInfoMap.set(relayInfo.url, relayInfo)
await this.relayInfoIndex.addAsync( await Promise.allSettled([
newRelayInfo.url, this.relayInfoIndex.addAsync(
[ relayInfo.url,
newRelayInfo.shortUrl, [
...newRelayInfo.shortUrl.split('.'), relayInfo.shortUrl,
newRelayInfo.name ?? '', ...relayInfo.shortUrl.split('.'),
newRelayInfo.description ?? '' relayInfo.name ?? '',
].join(' ') relayInfo.description ?? ''
) ].join(' ')
return newRelayInfo ),
indexDb.putRelayInfo(relayInfo)
])
return relayInfo
} }
} }
const instance = RelayInfoService.getInstance() const instance = RelayInfoService.getInstance()
export default instance export default instance
function formatRelayInfoEvents(relayInfoEvents: Event[]) {
const urlSet = new Set<string>()
const relayInfos: TNip66RelayInfo[] = []
relayInfoEvents.forEach((event) => {
try {
const url = event.tags.find(tagNameEquals('d'))?.[1]
if (!url || urlSet.has(url) || !isWebsocketUrl(url)) {
return
}
urlSet.add(url)
const basicInfo = event.content ? (JSON.parse(event.content) as TRelayInfo) : {}
const tagInfo: Omit<TNip66RelayInfo, 'url' | 'shortUrl'> = {
hasNip11: Object.keys(basicInfo).length > 0,
triedNip11: false
}
event.tags.forEach((tag) => {
if (tag[0] === 'T') {
tagInfo.relayType = tag[1]
} else if (tag[0] === 'g' && tag[2] === 'countryCode') {
tagInfo.countryCode = tag[1]
}
})
relayInfos.push({
...basicInfo,
...tagInfo,
url,
shortUrl: simplifyUrl(url)
})
} catch (error) {
console.error(error)
}
})
return relayInfos
}

18
src/types/index.d.ts vendored
View File

@@ -35,6 +35,8 @@ export type TRelayList = {
} }
export type TRelayInfo = { export type TRelayInfo = {
url: string
shortUrl: string
name?: string name?: string
description?: string description?: string
icon?: string icon?: string
@@ -127,15 +129,6 @@ export type TNotificationType = 'all' | 'mentions' | 'reactions' | 'zaps'
export type TPageRef = { scrollToTop: (behavior?: ScrollBehavior) => void } export type TPageRef = { scrollToTop: (behavior?: ScrollBehavior) => void }
export type TNip66RelayInfo = TRelayInfo & {
url: string
shortUrl: string
hasNip11: boolean
triedNip11: boolean
relayType?: string
countryCode?: string
}
export type TEmoji = { export type TEmoji = {
shortcode: string shortcode: string
url: string url: string
@@ -185,3 +178,10 @@ export type TSearchParams = {
export type TNotificationStyle = export type TNotificationStyle =
(typeof NOTIFICATION_LIST_STYLE)[keyof typeof NOTIFICATION_LIST_STYLE] (typeof NOTIFICATION_LIST_STYLE)[keyof typeof NOTIFICATION_LIST_STYLE]
export type TAwesomeRelayCollection = {
id: string
name: string
description: string
relays: string[]
}