35 changed files with 811 additions and 179 deletions
@ -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 @@ |
|||||||
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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