From b91f46723eca896e08c3d3cd3f3559f6811a1fde Mon Sep 17 00:00:00 2001 From: Cody Tseng Date: Tue, 11 Feb 2025 16:33:31 +0800 Subject: [PATCH] feat: explore (#85) --- src/PageManager.tsx | 24 +- src/components/BackButton/index.tsx | 32 +-- .../BottomNavigationBarItem.tsx | 2 +- .../BottomNavigationBar/ExploreButton.tsx | 13 ++ .../BottomNavigationBar/PostButton.tsx | 26 --- src/components/BottomNavigationBar/index.tsx | 4 +- src/components/NoteList/index.tsx | 5 +- src/components/OthersRelayList/index.tsx | 34 +-- .../PostEditor/NormalPostContent.tsx | 3 +- .../PostEditor/PicturePostContent.tsx | 4 +- src/components/RelayBadges/index.tsx | 40 ++++ src/components/RelayIcon/index.tsx | 5 +- src/components/RelayInfo/index.tsx | 70 ++++-- src/components/RelayList/index.tsx | 108 +++++++++ src/components/RelaySimpleInfo/index.tsx | 35 +++ .../SaveRelayDropdownMenu/index.tsx | 12 +- src/components/SearchInput/index.tsx | 33 +++ src/components/Sidebar/ExploreButton.tsx | 19 ++ src/components/Sidebar/index.tsx | 2 + src/components/Titlebar/index.tsx | 2 +- src/constants.ts | 3 + src/hooks/useFetchRelayInfo.tsx | 8 +- src/hooks/useFetchRelayInfos.tsx | 4 +- src/i18n/en.ts | 16 +- src/i18n/zh.ts | 16 +- src/layouts/PrimaryPageLayout/index.tsx | 10 +- src/layouts/SecondaryPageLayout/index.tsx | 12 +- src/pages/primary/ExplorePage/index.tsx | 31 +++ src/pages/primary/NoteListPage/index.tsx | 33 ++- src/pages/secondary/HomePage/index.tsx | 80 ++++++- src/pages/secondary/RelayPage/index.tsx | 34 ++- src/providers/DeepBrowsingProvider.tsx | 11 - src/services/client.service.ts | 33 +-- src/services/relay-info.service.ts | 216 ++++++++++++++++++ src/types.ts | 10 + 35 files changed, 811 insertions(+), 179 deletions(-) create mode 100644 src/components/BottomNavigationBar/ExploreButton.tsx delete mode 100644 src/components/BottomNavigationBar/PostButton.tsx create mode 100644 src/components/RelayBadges/index.tsx create mode 100644 src/components/RelayList/index.tsx create mode 100644 src/components/RelaySimpleInfo/index.tsx create mode 100644 src/components/SearchInput/index.tsx create mode 100644 src/components/Sidebar/ExploreButton.tsx create mode 100644 src/pages/primary/ExplorePage/index.tsx create mode 100644 src/services/relay-info.service.ts diff --git a/src/PageManager.tsx b/src/PageManager.tsx index 536ba85c..fdc60dd6 100644 --- a/src/PageManager.tsx +++ b/src/PageManager.tsx @@ -14,6 +14,7 @@ import { useEffect, useState } from 'react' +import ExplorePage from './pages/primary/ExplorePage' import MePage from './pages/primary/MePage' import NotificationListPage from './pages/primary/NotificationListPage' import { useScreenSize } from './providers/ScreenSizeProvider' @@ -41,12 +42,14 @@ type TStackItem = { const PRIMARY_PAGE_REF_MAP = { home: createRef(), + explore: createRef(), notifications: createRef(), me: createRef() } const PRIMARY_PAGE_MAP = { home: , + explore: , notifications: , me: } @@ -283,18 +286,17 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
- {secondaryStack.length ? ( - secondaryStack.map((item, index) => ( -
- {item.component} -
- )) - ) : ( + {secondaryStack.map((item, index) => ( +
+ {item.component} +
+ ))} +
- )} +
diff --git a/src/components/BackButton/index.tsx b/src/components/BackButton/index.tsx index f59023fb..8a214adb 100644 --- a/src/components/BackButton/index.tsx +++ b/src/components/BackButton/index.tsx @@ -3,30 +3,20 @@ import { useSecondaryPage } from '@/PageManager' import { ChevronLeft } from 'lucide-react' import { useTranslation } from 'react-i18next' -export default function BackButton({ - hide = false, - children -}: { - hide?: boolean - children?: React.ReactNode -}) { +export default function BackButton({ children }: { children?: React.ReactNode }) { const { t } = useTranslation() const { pop } = useSecondaryPage() return ( - <> - {!hide && ( - - )} - + ) } diff --git a/src/components/BottomNavigationBar/BottomNavigationBarItem.tsx b/src/components/BottomNavigationBar/BottomNavigationBarItem.tsx index 1ae8d428..16c79927 100644 --- a/src/components/BottomNavigationBar/BottomNavigationBarItem.tsx +++ b/src/components/BottomNavigationBar/BottomNavigationBarItem.tsx @@ -14,7 +14,7 @@ export default function BottomNavigationBarItem({ return ( + {atTitlebar ? ( + + ) : ( + + )} {t('Save to')} ... diff --git a/src/components/SearchInput/index.tsx b/src/components/SearchInput/index.tsx new file mode 100644 index 00000000..9f28e0d9 --- /dev/null +++ b/src/components/SearchInput/index.tsx @@ -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 ( +
+ + + {displayClear && ( + + )} +
+ ) +} diff --git a/src/components/Sidebar/ExploreButton.tsx b/src/components/Sidebar/ExploreButton.tsx new file mode 100644 index 00000000..8495d113 --- /dev/null +++ b/src/components/Sidebar/ExploreButton.tsx @@ -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 ( + navigate('explore')} + active={current === 'explore'} + > + + + ) +} diff --git a/src/components/Sidebar/index.tsx b/src/components/Sidebar/index.tsx index 4ed64954..48546000 100644 --- a/src/components/Sidebar/index.tsx +++ b/src/components/Sidebar/index.tsx @@ -4,6 +4,7 @@ import AccountButton from './AccountButton' import HomeButton from './HomeButton' import NotificationsButton from './NotificationButton' import PostButton from './PostButton' +import RelaysButton from './ExploreButton' import SearchButton from './SearchButton' import SettingsButton from './SettingsButton' @@ -16,6 +17,7 @@ export default function PrimaryPageSidebar() { + diff --git a/src/components/Titlebar/index.tsx b/src/components/Titlebar/index.tsx index c0197109..b18fede9 100644 --- a/src/components/Titlebar/index.tsx +++ b/src/components/Titlebar/index.tsx @@ -10,7 +10,7 @@ export function Titlebar({ return (
diff --git a/src/constants.ts b/src/constants.ts index 2eadaeb4..45000bf2 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -28,3 +28,6 @@ export const COMMENT_EVENT_KIND = 1111 export const URL_REGEX = /https?:\/\/[\w\p{L}\p{N}\p{M}&.-/?=#\-@%+_,:!~*]+/gu export const EMAIL_REGEX = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/ + +export const MONITOR = '9bbbb845e5b6c831c29789900769843ab43bb5047abe697870cb50b6fc9bf923' +export const MONITOR_RELAYS = ['wss://history.nostr.watch/'] diff --git a/src/hooks/useFetchRelayInfo.tsx b/src/hooks/useFetchRelayInfo.tsx index cb09cd70..70dc9abc 100644 --- a/src/hooks/useFetchRelayInfo.tsx +++ b/src/hooks/useFetchRelayInfo.tsx @@ -1,10 +1,10 @@ -import client from '@/services/client.service' -import { TRelayInfo } from '@/types' +import relayInfoService from '@/services/relay-info.service' +import { TNip66RelayInfo } from '@/types' import { useEffect, useState } from 'react' export function useFetchRelayInfo(url?: string) { const [isFetching, setIsFetching] = useState(true) - const [relayInfo, setRelayInfo] = useState(undefined) + const [relayInfo, setRelayInfo] = useState(undefined) useEffect(() => { if (!url) return @@ -14,7 +14,7 @@ export function useFetchRelayInfo(url?: string) { setIsFetching(false) }, 5000) try { - const [relayInfo] = await client.fetchRelayInfos([url]) + const relayInfo = await relayInfoService.getRelayInfo(url) setRelayInfo(relayInfo) } catch (err) { console.error(err) diff --git a/src/hooks/useFetchRelayInfos.tsx b/src/hooks/useFetchRelayInfos.tsx index bc9d61d2..705fa818 100644 --- a/src/hooks/useFetchRelayInfos.tsx +++ b/src/hooks/useFetchRelayInfos.tsx @@ -1,5 +1,5 @@ import { checkAlgoRelay } from '@/lib/relay' -import client from '@/services/client.service' +import relayInfoService from '@/services/relay-info.service' import { TRelayInfo } from '@/types' import { useEffect, useState } from 'react' @@ -20,7 +20,7 @@ export function useFetchRelayInfos(urls: string[]) { setIsFetching(false) }, 5000) try { - const relayInfos = await client.fetchRelayInfos(urls) + const relayInfos = await relayInfoService.getRelayInfos(urls) setRelayInfos(relayInfos) setAreAlgoRelays(relayInfos.every((relayInfo) => checkAlgoRelay(relayInfo))) setSearchableRelayUrls( diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 97fddb5f..a248d586 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -164,6 +164,20 @@ export default { 'Login to set': 'Login to set', 'Please login to view following feed': 'Please login to view following feed', 'Send only to r': 'Send only to {{r}}', - 'Send only to current relays': 'Send only to current relays' + 'Send only to current relays': 'Send only to current relays', + Explore: 'Explore', + 'Search relays': 'Search relays', + relayInfoBadgeAuth: 'Auth', + relayInfoBadgeSearch: 'Search', + relayInfoBadgePayment: 'Payment', + Operator: 'Operator', + Contact: 'Contact', + Software: 'Software', + Version: 'Version', + 'Random Relays': 'Random Relays', + randomRelaysRefresh: 'Refresh', + 'Explore more': 'Explore more', + 'Payment page': 'Payment page', + 'Supported NIPs': 'Supported NIPs' } } diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index 18aff9aa..c18e1553 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -165,6 +165,20 @@ export default { 'Login to set': '登录后设置', 'Please login to view following feed': '请登录以查看关注动态', 'Send only to r': '只发送到 {{r}}', - 'Send only to current relays': '只发送到当前服务器' + 'Send only to current relays': '只发送到当前服务器', + Explore: '探索', + 'Search relays': '搜索服务器', + relayInfoBadgeAuth: '需登陆', + relayInfoBadgeSearch: '支持搜索', + relayInfoBadgePayment: '需付费', + Operator: '管理员', + Contact: '联系方式', + Software: '软件', + Version: '版本', + 'Random Relays': '随机服务器', + randomRelaysRefresh: '换一批', + 'Explore more': '探索更多', + 'Payment page': '付款页面', + 'Supported NIPs': '支持的 NIP' } } diff --git a/src/layouts/PrimaryPageLayout/index.tsx b/src/layouts/PrimaryPageLayout/index.tsx index 255760b1..c23986dc 100644 --- a/src/layouts/PrimaryPageLayout/index.tsx +++ b/src/layouts/PrimaryPageLayout/index.tsx @@ -16,7 +16,7 @@ const PrimaryPageLayout = forwardRef( displayScrollToTopButton = false }: { children?: React.ReactNode - titlebar?: React.ReactNode + titlebar: React.ReactNode pageName: TPrimaryPageName displayScrollToTopButton?: boolean }, @@ -54,11 +54,11 @@ const PrimaryPageLayout = forwardRef( paddingBottom: 'calc(env(safe-area-inset-bottom) + 3rem)' }} > - {titlebar && {titlebar}} + {titlebar} {children} - {displayScrollToTopButton && }
+ {displayScrollToTopButton && } ) } @@ -67,10 +67,10 @@ const PrimaryPageLayout = forwardRef( - {titlebar && {titlebar}} + {titlebar} {children}
diff --git a/src/layouts/SecondaryPageLayout/index.tsx b/src/layouts/SecondaryPageLayout/index.tsx index 2b443ad6..eb343536 100644 --- a/src/layouts/SecondaryPageLayout/index.tsx +++ b/src/layouts/SecondaryPageLayout/index.tsx @@ -65,9 +65,9 @@ const SecondaryPageLayout = forwardRef( hideBackButton={hideBackButton} /> {children} - {displayScrollToTopButton && }
+ {displayScrollToTopButton && }
) } @@ -76,7 +76,7 @@ const SecondaryPageLayout = forwardRef( - {title} + {hideBackButton ? ( +
+ {title} +
+ ) : ( + {title} + )}
{controls}
) diff --git a/src/pages/primary/ExplorePage/index.tsx b/src/pages/primary/ExplorePage/index.tsx new file mode 100644 index 00000000..21f86740 --- /dev/null +++ b/src/pages/primary/ExplorePage/index.tsx @@ -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 ( + } + displayScrollToTopButton + > + + + ) +}) +ExplorePage.displayName = 'ExplorePage' +export default ExplorePage + +function ExplorePageTitlebar() { + const { t } = useTranslation() + + return ( +
+ +
{t('Explore')}
+
+ ) +} diff --git a/src/pages/primary/NoteListPage/index.tsx b/src/pages/primary/NoteListPage/index.tsx index 60e829c6..81e121ce 100644 --- a/src/pages/primary/NoteListPage/index.tsx +++ b/src/pages/primary/NoteListPage/index.tsx @@ -1,11 +1,14 @@ import NoteList from '@/components/NoteList' +import PostEditor from '@/components/PostEditor' import SaveRelayDropdownMenu from '@/components/SaveRelayDropdownMenu' import { Button } from '@/components/ui/button' import PrimaryPageLayout from '@/layouts/PrimaryPageLayout' import { useFeed } from '@/providers/FeedProvider' import { useNostr } from '@/providers/NostrProvider' +import { useScreenSize } from '@/providers/ScreenSizeProvider' import { TPageRef } from '@/types' -import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react' +import { PencilLine } from 'lucide-react' +import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import FeedButton from './FeedButton' import SearchButton from './SearchButton' @@ -59,15 +62,41 @@ NoteListPage.displayName = 'NoteListPage' export default NoteListPage function NoteListPageTitlebar({ temporaryRelayUrls = [] }: { temporaryRelayUrls?: string[] }) { + const { isSmallScreen } = useScreenSize() + return (
- {temporaryRelayUrls.length > 0 && ( )} + + {isSmallScreen && }
) } + +function PostButton() { + const { checkLogin } = useNostr() + const [open, setOpen] = useState(false) + + return ( + <> + + + + ) +} diff --git a/src/pages/secondary/HomePage/index.tsx b/src/pages/secondary/HomePage/index.tsx index 55d4f17c..057ecc9a 100644 --- a/src/pages/secondary/HomePage/index.tsx +++ b/src/pages/secondary/HomePage/index.tsx @@ -1,13 +1,85 @@ +import { usePrimaryPage, useSecondaryPage } from '@/PageManager' +import RelaySimpleInfo from '@/components/RelaySimpleInfo' +import { Button } from '@/components/ui/button' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' -import { forwardRef } from 'react' +import { toRelay } from '@/lib/link' +import relayInfoService from '@/services/relay-info.service' +import { TNip66RelayInfo } from '@/types' +import { ArrowRight, RefreshCcw, Server } from 'lucide-react' +import { forwardRef, useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' const HomePage = forwardRef(({ index }: { index?: number }, ref) => { const { t } = useTranslation() + const { navigate } = usePrimaryPage() + const { push } = useSecondaryPage() + const [randomRelayInfos, setRandomRelayInfos] = useState([]) + + const refresh = useCallback(async () => { + const relayInfos = await relayInfoService.getRandomRelayInfos(10) + const relayUrls = new Set() + const uniqueRelayInfos = relayInfos.filter((relayInfo) => { + if (relayUrls.has(relayInfo.url)) { + return false + } + relayUrls.add(relayInfo.url) + return true + }) + setRandomRelayInfos(uniqueRelayInfos) + }, []) + + useEffect(() => { + refresh() + }, []) + + if (!randomRelayInfos.length) { + return ( + +
+ {t('Welcome! 🥳')} +
+
+ ) + } + return ( - -
- {t('Welcome! 🥳')} + + +
{t('Random Relays')}
+ + } + controls={ + + } + hideBackButton + > +
+
+ {randomRelayInfos.map((relayInfo) => ( + { + e.stopPropagation() + push(toRelay(relayInfo.url)) + }} + /> + ))} +
+
+ +
) diff --git a/src/pages/secondary/RelayPage/index.tsx b/src/pages/secondary/RelayPage/index.tsx index be9fa665..57ffdff7 100644 --- a/src/pages/secondary/RelayPage/index.tsx +++ b/src/pages/secondary/RelayPage/index.tsx @@ -1,16 +1,33 @@ import NoteList from '@/components/NoteList' import RelayInfo from '@/components/RelayInfo' import SaveRelayDropdownMenu from '@/components/SaveRelayDropdownMenu' +import SearchInput from '@/components/SearchInput' import { Button } from '@/components/ui/button' +import { useFetchRelayInfo } from '@/hooks' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import { normalizeUrl, simplifyUrl } from '@/lib/url' import { Check, Copy } from 'lucide-react' -import { forwardRef, useMemo, useState } from 'react' +import { forwardRef, useEffect, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' import NotFoundPage from '../NotFoundPage' const RelayPage = forwardRef(({ url, index }: { url?: string; index?: number }, ref) => { + const { t } = useTranslation() const normalizedUrl = useMemo(() => (url ? normalizeUrl(url) : undefined), [url]) + const { relayInfo } = useFetchRelayInfo(normalizedUrl) const title = useMemo(() => (url ? simplifyUrl(url) : undefined), [url]) + const [searchInput, setSearchInput] = useState('') + const [debouncedInput, setDebouncedInput] = useState(searchInput) + + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedInput(searchInput) + }, 1000) + + return () => { + clearTimeout(handler) + } + }, [searchInput]) if (!normalizedUrl) { return @@ -25,7 +42,20 @@ const RelayPage = forwardRef(({ url, index }: { url?: string; index?: number }, displayScrollToTopButton > - + {relayInfo?.supported_nips?.includes(50) && ( +
+ setSearchInput(e.target.value)} + placeholder={t('Search')} + /> +
+ )} + ) }) diff --git a/src/providers/DeepBrowsingProvider.tsx b/src/providers/DeepBrowsingProvider.tsx index cffec94d..2096b119 100644 --- a/src/providers/DeepBrowsingProvider.tsx +++ b/src/providers/DeepBrowsingProvider.tsx @@ -35,17 +35,6 @@ export function DeepBrowsingProvider({ if (!active) return const handleScroll = () => { - const atBottom = !scrollAreaRef - ? window.innerHeight + window.scrollY >= document.body.offsetHeight - 20 - : scrollAreaRef.current - ? scrollAreaRef.current?.clientHeight + scrollAreaRef.current?.scrollTop >= - scrollAreaRef.current?.scrollHeight - 20 - : false - if (atBottom) { - setDeepBrowsing(false) - return - } - const scrollTop = (!scrollAreaRef ? window.scrollY : scrollAreaRef.current?.scrollTop) || 0 const diff = scrollTop - lastScrollTopRef.current lastScrollTopRef.current = scrollTop diff --git a/src/services/client.service.ts b/src/services/client.service.ts index cb872e89..91e5293b 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -3,7 +3,7 @@ import { getProfileFromProfileEvent, getRelayListFromRelayListEvent } from '@/li import { formatPubkey, userIdToPubkey } from '@/lib/pubkey' import { extractPubkeysFromEventTags } from '@/lib/tag' import { isLocalNetworkUrl } from '@/lib/url' -import { TDraftEvent, TProfile, TRelayInfo, TRelayList } from '@/types' +import { TDraftEvent, TProfile, TRelayList } from '@/types' import { sha256 } from '@noble/hashes/sha2' import DataLoader from 'dataloader' import FlexSearch from 'flexsearch' @@ -49,33 +49,19 @@ class ClientService extends EventTarget { private profileEventCache = new LRUCache>({ max: 10000 }) private profileEventDataloader = new DataLoader( (ids) => Promise.all(ids.map((id) => this._fetchProfileEvent(id))), - { cacheMap: this.profileEventCache, maxBatchSize: 10 } + { cacheMap: this.profileEventCache, maxBatchSize: 20 } ) private fetchProfileEventFromDefaultRelaysDataloader = new DataLoader( this.profileEventBatchLoadFn.bind(this), - { cache: false, maxBatchSize: 10 } + { cache: false, maxBatchSize: 20 } ) private relayListEventDataLoader = new DataLoader( this.relayListEventBatchLoadFn.bind(this), { cacheMap: new LRUCache>({ max: 10000 }), - maxBatchSize: 10 + maxBatchSize: 20 } ) - private relayInfoDataLoader = new DataLoader(async (urls) => { - return await Promise.all( - urls.map(async (url) => { - 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 followListCache = new LRUCache>({ max: 10000, fetchMethod: this._fetchFollowListEvent.bind(this) @@ -531,11 +517,6 @@ class ClientService extends EventTarget { this.followListCache.set(pubkey, Promise.resolve(event)) } - async fetchRelayInfos(urls: string[]) { - const infos = await this.relayInfoDataLoader.loadMany(urls) - return infos.map((info) => (info ? (info instanceof Error ? undefined : info) : undefined)) - } - async calculateOptimalReadRelays(pubkey: string) { const followings = await this.fetchFollowings(pubkey) const [selfRelayListEvent, ...relayListEvents] = await this.relayListEventDataLoader.loadMany([ @@ -597,9 +578,9 @@ class ClientService extends EventTarget { async initUserIndexFromFollowings(pubkey: string) { const followings = await this.fetchFollowings(pubkey) - for (let i = 0; i * 10 < followings.length; i++) { - await this.profileEventDataloader.loadMany(followings.slice(i * 10, (i + 1) * 10)) - await new Promise((resolve) => setTimeout(resolve, 100)) + for (let i = 0; i * 20 < followings.length; i++) { + await this.profileEventDataloader.loadMany(followings.slice(i * 20, (i + 1) * 20)) + await new Promise((resolve) => setTimeout(resolve, 200)) } } diff --git a/src/services/relay-info.service.ts b/src/services/relay-info.service.ts new file mode 100644 index 00000000..0b30803c --- /dev/null +++ b/src/services/relay-info.service.ts @@ -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 | null = null + + private relayInfoMap = new Map() + 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( + (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() + 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 = { + 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 +} diff --git a/src/types.ts b/src/types.ts index e2e95f75..35d4aee7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -32,6 +32,7 @@ export type TRelayInfo = { software?: string version?: string tags?: string[] + payments_url?: string limitation?: { auth_required?: boolean payment_required?: boolean @@ -98,3 +99,12 @@ export type TImageInfo = { url: string; blurHash?: string; dim?: { width: number export type TNoteListMode = 'posts' | 'postsAndReplies' | 'pictures' export type TPageRef = { scrollToTop: () => void } + +export type TNip66RelayInfo = TRelayInfo & { + url: string + shortUrl: string + hasNip11: boolean + triedNip11: boolean + relayType?: string + countryCode?: string +}