35 changed files with 811 additions and 179 deletions
@ -0,0 +1,13 @@
@@ -0,0 +1,13 @@
|
||||
import { usePrimaryPage } from '@/PageManager' |
||||
import { Compass } from 'lucide-react' |
||||
import BottomNavigationBarItem from './BottomNavigationBarItem' |
||||
|
||||
export default function ExploreButton() { |
||||
const { navigate, current } = usePrimaryPage() |
||||
|
||||
return ( |
||||
<BottomNavigationBarItem active={current === 'explore'} onClick={() => navigate('explore')}> |
||||
<Compass /> |
||||
</BottomNavigationBarItem> |
||||
) |
||||
} |
||||
@ -1,26 +0,0 @@
@@ -1,26 +0,0 @@
|
||||
import PostEditor from '@/components/PostEditor' |
||||
import { useNostr } from '@/providers/NostrProvider' |
||||
import { PencilLine } from 'lucide-react' |
||||
import { useState } from 'react' |
||||
import BottomNavigationBarItem from './BottomNavigationBarItem' |
||||
|
||||
export default function PostButton() { |
||||
const { checkLogin } = useNostr() |
||||
const [open, setOpen] = useState(false) |
||||
|
||||
return ( |
||||
<> |
||||
<BottomNavigationBarItem |
||||
onClick={(e) => { |
||||
e.stopPropagation() |
||||
checkLogin(() => { |
||||
setOpen(true) |
||||
}) |
||||
}} |
||||
> |
||||
<PencilLine /> |
||||
</BottomNavigationBarItem> |
||||
<PostEditor open={open} setOpen={setOpen} /> |
||||
</> |
||||
) |
||||
} |
||||
@ -0,0 +1,40 @@
@@ -0,0 +1,40 @@
|
||||
import { Badge } from '@/components/ui/badge' |
||||
import { TRelayInfo } from '@/types' |
||||
import { useMemo } from 'react' |
||||
import { useTranslation } from 'react-i18next' |
||||
|
||||
export default function RelayBadges({ relayInfo }: { relayInfo: TRelayInfo }) { |
||||
const { t } = useTranslation() |
||||
|
||||
const badges = useMemo(() => { |
||||
const b: string[] = [] |
||||
if (relayInfo.limitation?.auth_required) { |
||||
b.push('Auth') |
||||
} |
||||
if (relayInfo.supported_nips?.includes(50)) { |
||||
b.push('Search') |
||||
} |
||||
if (relayInfo.limitation?.payment_required) { |
||||
b.push('Payment') |
||||
} |
||||
return b |
||||
}, [relayInfo]) |
||||
|
||||
if (!badges.length) { |
||||
return null |
||||
} |
||||
|
||||
return ( |
||||
<div className="flex gap-2"> |
||||
{badges.includes('Auth') && ( |
||||
<Badge className="bg-green-400 hover:bg-green-400/80">{t('relayInfoBadgeAuth')}</Badge> |
||||
)} |
||||
{badges.includes('Search') && ( |
||||
<Badge className="bg-pink-400 hover:bg-pink-400/80">{t('relayInfoBadgeSearch')}</Badge> |
||||
)} |
||||
{badges.includes('Payment') && ( |
||||
<Badge className="bg-orange-400 hover:bg-orange-400/80">{t('relayInfoBadgePayment')}</Badge> |
||||
)} |
||||
</div> |
||||
) |
||||
} |
||||
@ -0,0 +1,108 @@
@@ -0,0 +1,108 @@
|
||||
import { Skeleton } from '@/components/ui/skeleton' |
||||
import { toRelay } from '@/lib/link' |
||||
import { useSecondaryPage } from '@/PageManager' |
||||
import relayInfoService from '@/services/relay-info.service' |
||||
import { TNip66RelayInfo } from '@/types' |
||||
import { useEffect, useRef, useState } from 'react' |
||||
import { useTranslation } from 'react-i18next' |
||||
import RelaySimpleInfo from '../RelaySimpleInfo' |
||||
import SearchInput from '../SearchInput' |
||||
|
||||
export default function RelayList() { |
||||
const { t } = useTranslation() |
||||
const { push } = useSecondaryPage() |
||||
const [loading, setLoading] = useState(true) |
||||
const [relays, setRelays] = useState<TNip66RelayInfo[]>([]) |
||||
const [showCount, setShowCount] = useState(20) |
||||
const [input, setInput] = useState('') |
||||
const [debouncedInput, setDebouncedInput] = useState(input) |
||||
const bottomRef = useRef<HTMLDivElement>(null) |
||||
|
||||
useEffect(() => { |
||||
const search = async () => { |
||||
const relayInfos = await relayInfoService.search(debouncedInput) |
||||
setShowCount(20) |
||||
setRelays(relayInfos) |
||||
setLoading(false) |
||||
} |
||||
search() |
||||
}, [debouncedInput]) |
||||
|
||||
useEffect(() => { |
||||
const handler = setTimeout(() => { |
||||
setDebouncedInput(input) |
||||
}, 1000) |
||||
|
||||
return () => { |
||||
clearTimeout(handler) |
||||
} |
||||
}, [input]) |
||||
|
||||
useEffect(() => { |
||||
const options = { |
||||
root: null, |
||||
rootMargin: '10px', |
||||
threshold: 1 |
||||
} |
||||
|
||||
const observerInstance = new IntersectionObserver((entries) => { |
||||
if (entries[0].isIntersecting && showCount < relays.length) { |
||||
setShowCount((prev) => prev + 20) |
||||
} |
||||
}, options) |
||||
|
||||
const currentBottomRef = bottomRef.current |
||||
if (currentBottomRef) { |
||||
observerInstance.observe(currentBottomRef) |
||||
} |
||||
|
||||
return () => { |
||||
if (observerInstance && currentBottomRef) { |
||||
observerInstance.unobserve(currentBottomRef) |
||||
} |
||||
} |
||||
}, [showCount, relays]) |
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { |
||||
setInput(e.target.value) |
||||
} |
||||
|
||||
return ( |
||||
<div> |
||||
<div className="px-4 py-2 sticky top-12 bg-background z-30"> |
||||
<SearchInput placeholder={t('Search relays')} value={input} onChange={handleInputChange} /> |
||||
</div> |
||||
{relays.slice(0, showCount).map((relay) => ( |
||||
<RelaySimpleInfo |
||||
key={relay.url} |
||||
relayInfo={relay} |
||||
className="clickable p-4 border-b" |
||||
onClick={(e) => { |
||||
e.stopPropagation() |
||||
push(toRelay(relay.url)) |
||||
}} |
||||
/> |
||||
))} |
||||
{showCount < relays.length && <div ref={bottomRef} />} |
||||
{loading && ( |
||||
<div className="p-4 space-y-2"> |
||||
<div className="flex items-start justify-between gap-2 w-full"> |
||||
<div className="flex flex-1 w-0 items-center gap-2"> |
||||
<Skeleton className="h-9 w-9 rounded-full" /> |
||||
<div className="flex-1 w-0 space-y-1"> |
||||
<Skeleton className="w-40 h-5" /> |
||||
<Skeleton className="w-20 h-4" /> |
||||
</div> |
||||
</div> |
||||
<Skeleton className="w-5 h-5 rounded-lg" /> |
||||
</div> |
||||
<Skeleton className="w-full h-4" /> |
||||
<Skeleton className="w-2/3 h-4" /> |
||||
</div> |
||||
)} |
||||
{!loading && relays.length === 0 && ( |
||||
<div className="text-center text-muted-foreground text-sm">{t('no relays found')}</div> |
||||
)} |
||||
</div> |
||||
) |
||||
} |
||||
@ -0,0 +1,35 @@
@@ -0,0 +1,35 @@
|
||||
import { cn } from '@/lib/utils' |
||||
import { TNip66RelayInfo } from '@/types' |
||||
import RelayBadges from '../RelayBadges' |
||||
import RelayIcon from '../RelayIcon' |
||||
import SaveRelayDropdownMenu from '../SaveRelayDropdownMenu' |
||||
import { HTMLProps } from 'react' |
||||
|
||||
export default function RelaySimpleInfo({ |
||||
relayInfo, |
||||
hideBadge = false, |
||||
className, |
||||
...props |
||||
}: HTMLProps<HTMLDivElement> & { |
||||
relayInfo?: TNip66RelayInfo |
||||
hideBadge?: boolean |
||||
}) { |
||||
return ( |
||||
<div className={cn('space-y-1', className)} {...props}> |
||||
<div className="flex items-start justify-between gap-2 w-full"> |
||||
<div className="flex flex-1 w-0 items-center gap-2"> |
||||
<RelayIcon url={relayInfo?.url} className="h-9 w-9" /> |
||||
<div className="flex-1 w-0"> |
||||
<div className="truncate font-semibold">{relayInfo?.name || relayInfo?.shortUrl}</div> |
||||
{relayInfo?.name && ( |
||||
<div className="text-xs text-muted-foreground truncate">{relayInfo?.shortUrl}</div> |
||||
)} |
||||
</div> |
||||
</div> |
||||
{relayInfo && <SaveRelayDropdownMenu urls={[relayInfo.url]} />} |
||||
</div> |
||||
{!hideBadge && relayInfo && <RelayBadges relayInfo={relayInfo} />} |
||||
{!!relayInfo?.description && <div className="line-clamp-4">{relayInfo.description}</div>} |
||||
</div> |
||||
) |
||||
} |
||||
@ -0,0 +1,33 @@
@@ -0,0 +1,33 @@
|
||||
import { cn } from '@/lib/utils' |
||||
import { SearchIcon, X } from 'lucide-react' |
||||
import { ComponentProps, useEffect, useState } from 'react' |
||||
|
||||
export default function SearchInput({ value, onChange, ...props }: ComponentProps<'input'>) { |
||||
const [displayClear, setDisplayClear] = useState(false) |
||||
|
||||
useEffect(() => { |
||||
setDisplayClear(!!value) |
||||
}, [value]) |
||||
|
||||
return ( |
||||
<div |
||||
tabIndex={0} |
||||
className={cn( |
||||
'flex h-9 w-full items-center rounded-md border border-input bg-transparent px-2 py-1 text-base shadow-sm transition-colors md:text-sm [&:has(:focus-visible)]:ring-ring [&:has(:focus-visible)]:ring-1 [&:has(:focus-visible)]:outline-none' |
||||
)} |
||||
> |
||||
<SearchIcon className="size-4 shrink-0 opacity-50" /> |
||||
<input |
||||
{...props} |
||||
value={value} |
||||
onChange={onChange} |
||||
className="size-full mx-2 border-none bg-transparent focus:outline-none placeholder:text-muted-foreground" |
||||
/> |
||||
{displayClear && ( |
||||
<button type="button" onClick={() => onChange?.({ target: { value: '' } } as any)}> |
||||
<X className="size-4 shrink-0 opacity-50 hover:opacity-100" /> |
||||
</button> |
||||
)} |
||||
</div> |
||||
) |
||||
} |
||||
@ -0,0 +1,19 @@
@@ -0,0 +1,19 @@
|
||||
import { usePrimaryPage } from '@/PageManager' |
||||
import { Compass } from 'lucide-react' |
||||
import { useTranslation } from 'react-i18next' |
||||
import SidebarItem from './SidebarItem' |
||||
|
||||
export default function RelaysButton() { |
||||
const { t } = useTranslation() |
||||
const { navigate, current } = usePrimaryPage() |
||||
|
||||
return ( |
||||
<SidebarItem |
||||
title={t('Explore')} |
||||
onClick={() => navigate('explore')} |
||||
active={current === 'explore'} |
||||
> |
||||
<Compass strokeWidth={3} /> |
||||
</SidebarItem> |
||||
) |
||||
} |
||||
@ -0,0 +1,31 @@
@@ -0,0 +1,31 @@
|
||||
import RelayList from '@/components/RelayList' |
||||
import PrimaryPageLayout from '@/layouts/PrimaryPageLayout' |
||||
import { Compass } from 'lucide-react' |
||||
import { forwardRef } from 'react' |
||||
import { useTranslation } from 'react-i18next' |
||||
|
||||
const ExplorePage = forwardRef((_, ref) => { |
||||
return ( |
||||
<PrimaryPageLayout |
||||
ref={ref} |
||||
pageName="explore" |
||||
titlebar={<ExplorePageTitlebar />} |
||||
displayScrollToTopButton |
||||
> |
||||
<RelayList /> |
||||
</PrimaryPageLayout> |
||||
) |
||||
}) |
||||
ExplorePage.displayName = 'ExplorePage' |
||||
export default ExplorePage |
||||
|
||||
function ExplorePageTitlebar() { |
||||
const { t } = useTranslation() |
||||
|
||||
return ( |
||||
<div className="flex gap-2 items-center h-full pl-3"> |
||||
<Compass /> |
||||
<div className="text-lg font-semibold">{t('Explore')}</div> |
||||
</div> |
||||
) |
||||
} |
||||
@ -0,0 +1,216 @@
@@ -0,0 +1,216 @@
|
||||
import { MONITOR, MONITOR_RELAYS } from '@/constants' |
||||
import { tagNameEquals } from '@/lib/tag' |
||||
import { isWebsocketUrl, simplifyUrl } from '@/lib/url' |
||||
import { TNip66RelayInfo, TRelayInfo } from '@/types' |
||||
import DataLoader from 'dataloader' |
||||
import FlexSearch from 'flexsearch' |
||||
import { Event } from 'nostr-tools' |
||||
import client from './client.service' |
||||
|
||||
class RelayInfoService { |
||||
static instance: RelayInfoService |
||||
|
||||
public static getInstance(): RelayInfoService { |
||||
if (!RelayInfoService.instance) { |
||||
RelayInfoService.instance = new RelayInfoService() |
||||
RelayInfoService.instance.init() |
||||
} |
||||
return RelayInfoService.instance |
||||
} |
||||
|
||||
private initPromise: Promise<void> | null = null |
||||
|
||||
private relayInfoMap = new Map<string, TNip66RelayInfo>() |
||||
private relayInfoIndex = new FlexSearch.Index({ |
||||
tokenize: 'forward', |
||||
encode: (str) => |
||||
str |
||||
// eslint-disable-next-line no-control-regex
|
||||
.replace(/[^\x00-\x7F]/g, (match) => ` ${match} `) |
||||
.trim() |
||||
.toLocaleLowerCase() |
||||
.split(/\s+/) |
||||
}) |
||||
private fetchDataloader = new DataLoader<string, TNip66RelayInfo | undefined>( |
||||
(urls) => Promise.all(urls.map((url) => this._getRelayInfo(url))), |
||||
{ |
||||
cache: false |
||||
} |
||||
) |
||||
private relayUrlsForRandom: string[] = [] |
||||
|
||||
async init() { |
||||
if (!this.initPromise) { |
||||
this.initPromise = this.loadRelayInfos() |
||||
} |
||||
await this.initPromise |
||||
} |
||||
|
||||
async search(query: string) { |
||||
if (this.initPromise) { |
||||
await this.initPromise |
||||
} |
||||
|
||||
if (!query) { |
||||
return Array.from(this.relayInfoMap.values()) |
||||
} |
||||
|
||||
const result = await this.relayInfoIndex.searchAsync(query) |
||||
return result |
||||
.map((url) => this.relayInfoMap.get(url as string)) |
||||
.filter(Boolean) as TNip66RelayInfo[] |
||||
} |
||||
|
||||
async getRelayInfos(urls: string[]) { |
||||
const relayInfos = await this.fetchDataloader.loadMany(urls) |
||||
return relayInfos.map((relayInfo) => (relayInfo instanceof Error ? undefined : relayInfo)) |
||||
} |
||||
|
||||
async getRelayInfo(url: string) { |
||||
return this.fetchDataloader.load(url) |
||||
} |
||||
|
||||
async getRandomRelayInfos(count: number) { |
||||
if (this.initPromise) { |
||||
await this.initPromise |
||||
} |
||||
|
||||
const relayInfos: TNip66RelayInfo[] = [] |
||||
while (relayInfos.length < count) { |
||||
const randomIndex = Math.floor(Math.random() * this.relayUrlsForRandom.length) |
||||
const url = this.relayUrlsForRandom[randomIndex] |
||||
this.relayUrlsForRandom.splice(randomIndex, 1) |
||||
if (this.relayUrlsForRandom.length === 0) { |
||||
this.relayUrlsForRandom = Array.from(this.relayInfoMap.keys()) |
||||
} |
||||
|
||||
const relayInfo = this.relayInfoMap.get(url) |
||||
if (relayInfo) { |
||||
relayInfos.push(relayInfo) |
||||
} |
||||
} |
||||
return relayInfos |
||||
} |
||||
|
||||
private async _getRelayInfo(url: string) { |
||||
const exist = this.relayInfoMap.get(url) |
||||
if (exist && (exist.hasNip11 || exist.triedNip11)) { |
||||
return exist |
||||
} |
||||
|
||||
const nip11 = await this.fetchRelayInfoByNip11(url) |
||||
const relayInfo = nip11 |
||||
? { |
||||
...nip11, |
||||
url, |
||||
shortUrl: simplifyUrl(url), |
||||
hasNip11: Object.keys(nip11).length > 0, |
||||
triedNip11: true |
||||
} |
||||
: { |
||||
url, |
||||
shortUrl: simplifyUrl(url), |
||||
hasNip11: false, |
||||
triedNip11: true |
||||
} |
||||
return await this.addRelayInfo(relayInfo) |
||||
} |
||||
|
||||
private async fetchRelayInfoByNip11(url: string) { |
||||
try { |
||||
const res = await fetch(url.replace('ws://', 'http://').replace('wss://', 'https://'), { |
||||
headers: { Accept: 'application/nostr+json' } |
||||
}) |
||||
return res.json() as TRelayInfo |
||||
} catch { |
||||
return undefined |
||||
} |
||||
} |
||||
|
||||
private async loadRelayInfos() { |
||||
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: 1000 |
||||
}) |
||||
const events = relayInfoEvents.sort((a, b) => b.created_at - a.created_at) |
||||
if (events.length === 0) { |
||||
break |
||||
} |
||||
until = events[events.length - 1].created_at - 1 |
||||
const relayInfos = formatRelayInfoEvents(events) |
||||
for (const relayInfo of relayInfos) { |
||||
await this.addRelayInfo(relayInfo) |
||||
} |
||||
} |
||||
this.relayUrlsForRandom = Array.from(this.relayInfoMap.keys()) |
||||
} |
||||
|
||||
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 |
||||
this.relayInfoMap.set(newRelayInfo.url, newRelayInfo) |
||||
await this.relayInfoIndex.addAsync( |
||||
newRelayInfo.url, |
||||
[ |
||||
newRelayInfo.shortUrl, |
||||
...newRelayInfo.shortUrl.split('.'), |
||||
newRelayInfo.name ?? '', |
||||
newRelayInfo.description ?? '' |
||||
].join(' ') |
||||
) |
||||
return newRelayInfo |
||||
} |
||||
} |
||||
|
||||
const instance = RelayInfoService.getInstance() |
||||
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 |
||||
} |
||||
Loading…
Reference in new issue