feat: explore (#85)
This commit is contained in:
@@ -14,6 +14,7 @@ import {
|
|||||||
useEffect,
|
useEffect,
|
||||||
useState
|
useState
|
||||||
} from 'react'
|
} from 'react'
|
||||||
|
import ExplorePage from './pages/primary/ExplorePage'
|
||||||
import MePage from './pages/primary/MePage'
|
import MePage from './pages/primary/MePage'
|
||||||
import NotificationListPage from './pages/primary/NotificationListPage'
|
import NotificationListPage from './pages/primary/NotificationListPage'
|
||||||
import { useScreenSize } from './providers/ScreenSizeProvider'
|
import { useScreenSize } from './providers/ScreenSizeProvider'
|
||||||
@@ -41,12 +42,14 @@ type TStackItem = {
|
|||||||
|
|
||||||
const PRIMARY_PAGE_REF_MAP = {
|
const PRIMARY_PAGE_REF_MAP = {
|
||||||
home: createRef<TPageRef>(),
|
home: createRef<TPageRef>(),
|
||||||
|
explore: createRef<TPageRef>(),
|
||||||
notifications: createRef<TPageRef>(),
|
notifications: createRef<TPageRef>(),
|
||||||
me: createRef<TPageRef>()
|
me: createRef<TPageRef>()
|
||||||
}
|
}
|
||||||
|
|
||||||
const PRIMARY_PAGE_MAP = {
|
const PRIMARY_PAGE_MAP = {
|
||||||
home: <NoteListPage ref={PRIMARY_PAGE_REF_MAP.home} />,
|
home: <NoteListPage ref={PRIMARY_PAGE_REF_MAP.home} />,
|
||||||
|
explore: <ExplorePage ref={PRIMARY_PAGE_REF_MAP.explore} />,
|
||||||
notifications: <NotificationListPage ref={PRIMARY_PAGE_REF_MAP.notifications} />,
|
notifications: <NotificationListPage ref={PRIMARY_PAGE_REF_MAP.notifications} />,
|
||||||
me: <MePage ref={PRIMARY_PAGE_REF_MAP.me} />
|
me: <MePage ref={PRIMARY_PAGE_REF_MAP.me} />
|
||||||
}
|
}
|
||||||
@@ -283,18 +286,17 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
|
|||||||
<Separator orientation="vertical" className="z-50" />
|
<Separator orientation="vertical" className="z-50" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
{secondaryStack.length ? (
|
{secondaryStack.map((item, index) => (
|
||||||
secondaryStack.map((item, index) => (
|
|
||||||
<div
|
<div
|
||||||
key={item.index}
|
key={item.index}
|
||||||
style={{ display: index === secondaryStack.length - 1 ? 'block' : 'none' }}
|
style={{ display: index === secondaryStack.length - 1 ? 'block' : 'none' }}
|
||||||
>
|
>
|
||||||
{item.component}
|
{item.component}
|
||||||
</div>
|
</div>
|
||||||
))
|
))}
|
||||||
) : (
|
<div key="home" style={{ display: secondaryStack.length === 0 ? 'block' : 'none' }}>
|
||||||
<HomePage />
|
<HomePage />
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,19 +3,11 @@ import { useSecondaryPage } from '@/PageManager'
|
|||||||
import { ChevronLeft } from 'lucide-react'
|
import { ChevronLeft } from 'lucide-react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
export default function BackButton({
|
export default function BackButton({ children }: { children?: React.ReactNode }) {
|
||||||
hide = false,
|
|
||||||
children
|
|
||||||
}: {
|
|
||||||
hide?: boolean
|
|
||||||
children?: React.ReactNode
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { pop } = useSecondaryPage()
|
const { pop } = useSecondaryPage()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
|
||||||
{!hide && (
|
|
||||||
<Button
|
<Button
|
||||||
className="flex gap-1 items-center w-fit max-w-full justify-start pl-2 pr-3"
|
className="flex gap-1 items-center w-fit max-w-full justify-start pl-2 pr-3"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -26,7 +18,5 @@ export default function BackButton({
|
|||||||
<ChevronLeft />
|
<ChevronLeft />
|
||||||
<div className="truncate text-lg font-semibold">{children}</div>
|
<div className="truncate text-lg font-semibold">{children}</div>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export default function BottomNavigationBarItem({
|
|||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex shadow-none items-center bg-transparent w-full h-12 xl:w-full xl:h-auto p-3 m-0 xl:py-2 xl:px-4 rounded-lg xl:justify-start text-lg font-semibold [&_svg]:size-full xl:[&_svg]:size-4',
|
'flex shadow-none items-center bg-transparent w-full h-12 p-3 m-0 rounded-lg [&_svg]:size-6',
|
||||||
active && 'text-primary hover:text-primary'
|
active && 'text-primary hover:text-primary'
|
||||||
)}
|
)}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
13
src/components/BottomNavigationBar/ExploreButton.tsx
Normal file
13
src/components/BottomNavigationBar/ExploreButton.tsx
Normal file
@@ -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} />
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import AccountButton from './AccountButton'
|
import AccountButton from './AccountButton'
|
||||||
|
import ExploreButton from './ExploreButton'
|
||||||
import HomeButton from './HomeButton'
|
import HomeButton from './HomeButton'
|
||||||
import NotificationsButton from './NotificationsButton'
|
import NotificationsButton from './NotificationsButton'
|
||||||
import PostButton from './PostButton'
|
|
||||||
|
|
||||||
export default function BottomNavigationBar() {
|
export default function BottomNavigationBar() {
|
||||||
return (
|
return (
|
||||||
@@ -16,7 +16,7 @@ export default function BottomNavigationBar() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<HomeButton />
|
<HomeButton />
|
||||||
<PostButton />
|
<ExploreButton />
|
||||||
<NotificationsButton />
|
<NotificationsButton />
|
||||||
<AccountButton />
|
<AccountButton />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { useMuteList } from '@/providers/MuteListProvider'
|
|||||||
import { useNostr } from '@/providers/NostrProvider'
|
import { useNostr } from '@/providers/NostrProvider'
|
||||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||||
import client from '@/services/client.service'
|
import client from '@/services/client.service'
|
||||||
|
import relayInfoService from '@/services/relay-info.service'
|
||||||
import storage from '@/services/storage.service'
|
import storage from '@/services/storage.service'
|
||||||
import { TNoteListMode } from '@/types'
|
import { TNoteListMode } from '@/types'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
@@ -76,7 +77,7 @@ export default function NoteList({
|
|||||||
|
|
||||||
let areAlgoRelays = false
|
let areAlgoRelays = false
|
||||||
if (needCheckAlgoRelay) {
|
if (needCheckAlgoRelay) {
|
||||||
const relayInfos = await client.fetchRelayInfos(relayUrls)
|
const relayInfos = await relayInfoService.getRelayInfos(relayUrls)
|
||||||
areAlgoRelays = relayInfos.every((relayInfo) => checkAlgoRelay(relayInfo))
|
areAlgoRelays = relayInfos.every((relayInfo) => checkAlgoRelay(relayInfo))
|
||||||
}
|
}
|
||||||
const filter = areAlgoRelays ? { ...noteFilter, limit: ALGO_RELAY_LIMIT } : noteFilter
|
const filter = areAlgoRelays ? { ...noteFilter, limit: ALGO_RELAY_LIMIT } : noteFilter
|
||||||
@@ -255,7 +256,7 @@ function ListModeSwitch({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'sticky top-12 bg-background z-10 duration-700 transition-transform',
|
'sticky top-12 bg-background z-30 duration-700 transition-transform',
|
||||||
deepBrowsing && lastScrollTop > 800 ? '-translate-y-[calc(100%+12rem)]' : ''
|
deepBrowsing && lastScrollTop > 800 ? '-translate-y-[calc(100%+12rem)]' : ''
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -3,12 +3,10 @@ import { Badge } from '@/components/ui/badge'
|
|||||||
import { useFetchRelayInfo, useFetchRelayList } from '@/hooks'
|
import { useFetchRelayInfo, useFetchRelayList } from '@/hooks'
|
||||||
import { toRelay } from '@/lib/link'
|
import { toRelay } from '@/lib/link'
|
||||||
import { userIdToPubkey } from '@/lib/pubkey'
|
import { userIdToPubkey } from '@/lib/pubkey'
|
||||||
import { simplifyUrl } from '@/lib/url'
|
|
||||||
import { TMailboxRelay } from '@/types'
|
import { TMailboxRelay } from '@/types'
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import RelayIcon from '../RelayIcon'
|
import RelaySimpleInfo from '../RelaySimpleInfo'
|
||||||
import SaveRelayDropdownMenu from '../SaveRelayDropdownMenu'
|
|
||||||
|
|
||||||
export default function OthersRelayList({ userId }: { userId: string }) {
|
export default function OthersRelayList({ userId }: { userId: string }) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
@@ -35,16 +33,8 @@ function RelayItem({ relay }: { relay: TMailboxRelay }) {
|
|||||||
const { url, scope } = relay
|
const { url, scope } = relay
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="p-4 rounded-lg border clickable space-y-1" onClick={() => push(toRelay(url))}>
|
||||||
className="flex items-center gap-2 justify-between p-4 rounded-lg border clickable"
|
<RelaySimpleInfo relayInfo={relayInfo} hideBadge />
|
||||||
onClick={() => push(toRelay(url))}
|
|
||||||
>
|
|
||||||
<div className="flex-1 w-0 space-y-2">
|
|
||||||
<div className="flex items-center gap-2 w-full">
|
|
||||||
<RelayIcon url={url} />
|
|
||||||
<div className="truncate font-semibold text-lg">{simplifyUrl(url)}</div>
|
|
||||||
</div>
|
|
||||||
{!!relayInfo?.description && <div className="line-clamp-2">{relayInfo.description}</div>}
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{['both', 'read'].includes(scope) && (
|
{['both', 'read'].includes(scope) && (
|
||||||
<Badge className="bg-blue-400 hover:bg-blue-400/80">{t('Read')}</Badge>
|
<Badge className="bg-blue-400 hover:bg-blue-400/80">{t('Read')}</Badge>
|
||||||
@@ -54,9 +44,5 @@ function RelayItem({ relay }: { relay: TMailboxRelay }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1 shrink-0" onClick={(e) => e.stopPropagation()}>
|
|
||||||
<SaveRelayDropdownMenu urls={[url]} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { createCommentDraftEvent, createShortTextNoteDraftEvent } from '@/lib/dr
|
|||||||
import { useFeed } from '@/providers/FeedProvider.tsx'
|
import { useFeed } from '@/providers/FeedProvider.tsx'
|
||||||
import { useNostr } from '@/providers/NostrProvider'
|
import { useNostr } from '@/providers/NostrProvider'
|
||||||
import client from '@/services/client.service'
|
import client from '@/services/client.service'
|
||||||
|
import relayInfoService from '@/services/relay-info.service'
|
||||||
import { ChevronDown, ImageUp, LoaderCircle } from 'lucide-react'
|
import { ChevronDown, ImageUp, LoaderCircle } from 'lucide-react'
|
||||||
import { Event, kinds } from 'nostr-tools'
|
import { Event, kinds } from 'nostr-tools'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
@@ -54,7 +55,7 @@ export default function NormalPostContent({
|
|||||||
}
|
}
|
||||||
let protectedEvent = false
|
let protectedEvent = false
|
||||||
if (postOptions.sendOnlyToCurrentRelays) {
|
if (postOptions.sendOnlyToCurrentRelays) {
|
||||||
const relayInfos = await client.fetchRelayInfos(relayUrls)
|
const relayInfos = await relayInfoService.getRelayInfos(relayUrls)
|
||||||
protectedEvent = relayInfos.every((info) => info?.supported_nips?.includes(70))
|
protectedEvent = relayInfos.every((info) => info?.supported_nips?.includes(70))
|
||||||
}
|
}
|
||||||
const draftEvent =
|
const draftEvent =
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { createPictureNoteDraftEvent } from '@/lib/draft-event'
|
|||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useFeed } from '@/providers/FeedProvider.tsx'
|
import { useFeed } from '@/providers/FeedProvider.tsx'
|
||||||
import { useNostr } from '@/providers/NostrProvider'
|
import { useNostr } from '@/providers/NostrProvider'
|
||||||
import client from '@/services/client.service'
|
import relayInfoService from '@/services/relay-info.service'
|
||||||
import { ChevronDown, Loader, LoaderCircle, Plus, X } from 'lucide-react'
|
import { ChevronDown, Loader, LoaderCircle, Plus, X } from 'lucide-react'
|
||||||
import { Dispatch, SetStateAction, useState } from 'react'
|
import { Dispatch, SetStateAction, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
@@ -43,7 +43,7 @@ export default function PicturePostContent({ close }: { close: () => void }) {
|
|||||||
}
|
}
|
||||||
let protectedEvent = false
|
let protectedEvent = false
|
||||||
if (postOptions.sendOnlyToCurrentRelays) {
|
if (postOptions.sendOnlyToCurrentRelays) {
|
||||||
const relayInfos = await client.fetchRelayInfos(relayUrls)
|
const relayInfos = await relayInfoService.getRelayInfos(relayUrls)
|
||||||
protectedEvent = relayInfos.every((info) => info?.supported_nips?.includes(70))
|
protectedEvent = relayInfos.every((info) => info?.supported_nips?.includes(70))
|
||||||
}
|
}
|
||||||
const draftEvent = await createPictureNoteDraftEvent(content, pictureInfos, {
|
const draftEvent = await createPictureNoteDraftEvent(content, pictureInfos, {
|
||||||
|
|||||||
40
src/components/RelayBadges/index.tsx
Normal file
40
src/components/RelayBadges/index.tsx
Normal file
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ export default function RelayIcon({
|
|||||||
className = 'w-6 h-6',
|
className = 'w-6 h-6',
|
||||||
iconSize = 14
|
iconSize = 14
|
||||||
}: {
|
}: {
|
||||||
url: string
|
url?: string
|
||||||
className?: string
|
className?: string
|
||||||
iconSize?: number
|
iconSize?: number
|
||||||
}) {
|
}) {
|
||||||
@@ -17,13 +17,14 @@ export default function RelayIcon({
|
|||||||
if (relayInfo?.icon) {
|
if (relayInfo?.icon) {
|
||||||
return relayInfo.icon
|
return relayInfo.icon
|
||||||
}
|
}
|
||||||
|
if (!url) return
|
||||||
const u = new URL(url)
|
const u = new URL(url)
|
||||||
return `${u.protocol === 'wss:' ? 'https:' : 'http:'}//${u.host}/favicon.ico`
|
return `${u.protocol === 'wss:' ? 'https:' : 'http:'}//${u.host}/favicon.ico`
|
||||||
}, [url, relayInfo])
|
}, [url, relayInfo])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Avatar className={className}>
|
<Avatar className={className}>
|
||||||
<AvatarImage src={iconUrl} />
|
<AvatarImage src={iconUrl} className="object-cover object-center" />
|
||||||
<AvatarFallback>
|
<AvatarFallback>
|
||||||
<Server size={iconSize} />
|
<Server size={iconSize} />
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { useFetchRelayInfo } from '@/hooks'
|
import { useFetchRelayInfo } from '@/hooks'
|
||||||
import { TRelayInfo } from '@/types'
|
import { normalizeHttpUrl } from '@/lib/url'
|
||||||
import { GitBranch, Mail, SquareCode } from 'lucide-react'
|
import { GitBranch, Mail, SquareCode } from 'lucide-react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import RelayBadges from '../RelayBadges'
|
||||||
import RelayIcon from '../RelayIcon'
|
import RelayIcon from '../RelayIcon'
|
||||||
import UserAvatar from '../UserAvatar'
|
import UserAvatar from '../UserAvatar'
|
||||||
import Username from '../Username'
|
import Username from '../Username'
|
||||||
|
|
||||||
export default function RelayInfo({ url }: { url: string }) {
|
export default function RelayInfo({ url }: { url: string }) {
|
||||||
|
const { t } = useTranslation()
|
||||||
const { relayInfo, isFetching } = useFetchRelayInfo(url)
|
const { relayInfo, isFetching } = useFetchRelayInfo(url)
|
||||||
if (isFetching || !relayInfo) {
|
if (isFetching || !relayInfo) {
|
||||||
return null
|
return null
|
||||||
@@ -33,10 +36,45 @@ export default function RelayInfo({ url }: { url: string }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{!!relayInfo.supported_nips?.length && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-sm font-semibold text-muted-foreground">{t('Supported NIPs')}</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{relayInfo.supported_nips
|
||||||
|
.sort((a, b) => a - b)
|
||||||
|
.map((nip) => (
|
||||||
|
<Badge
|
||||||
|
key={nip}
|
||||||
|
variant="secondary"
|
||||||
|
className="clickable"
|
||||||
|
onClick={() =>
|
||||||
|
window.open(
|
||||||
|
`https://github.com/nostr-protocol/nips/blob/master/${formatNip(nip)}.md`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{formatNip(nip)}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{relayInfo.payments_url && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-sm font-semibold text-muted-foreground">{t('Payment page')}:</div>
|
||||||
|
<a
|
||||||
|
href={normalizeHttpUrl(relayInfo.payments_url)}
|
||||||
|
target="_blank"
|
||||||
|
className="hover:underline text-primary"
|
||||||
|
>
|
||||||
|
{relayInfo.payments_url}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex flex-wrap gap-4">
|
<div className="flex flex-wrap gap-4">
|
||||||
{relayInfo.pubkey && (
|
{relayInfo.pubkey && (
|
||||||
<div className="space-y-2 flex-1">
|
<div className="space-y-2 flex-1">
|
||||||
<div className="text-sm font-semibold text-muted-foreground">Operator</div>
|
<div className="text-sm font-semibold text-muted-foreground">{t('Operator')}</div>
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex gap-2 items-center">
|
||||||
<UserAvatar userId={relayInfo.pubkey} size="small" />
|
<UserAvatar userId={relayInfo.pubkey} size="small" />
|
||||||
<Username userId={relayInfo.pubkey} className="font-semibold" />
|
<Username userId={relayInfo.pubkey} className="font-semibold" />
|
||||||
@@ -45,7 +83,7 @@ export default function RelayInfo({ url }: { url: string }) {
|
|||||||
)}
|
)}
|
||||||
{relayInfo.contact && (
|
{relayInfo.contact && (
|
||||||
<div className="space-y-2 flex-1">
|
<div className="space-y-2 flex-1">
|
||||||
<div className="text-sm font-semibold text-muted-foreground">Contact</div>
|
<div className="text-sm font-semibold text-muted-foreground">{t('Contact')}</div>
|
||||||
<div className="flex gap-2 items-center font-semibold">
|
<div className="flex gap-2 items-center font-semibold">
|
||||||
<Mail />
|
<Mail />
|
||||||
{relayInfo.contact}
|
{relayInfo.contact}
|
||||||
@@ -54,7 +92,7 @@ export default function RelayInfo({ url }: { url: string }) {
|
|||||||
)}
|
)}
|
||||||
{relayInfo.software && (
|
{relayInfo.software && (
|
||||||
<div className="space-y-2 flex-1">
|
<div className="space-y-2 flex-1">
|
||||||
<div className="text-sm font-semibold text-muted-foreground">Software</div>
|
<div className="text-sm font-semibold text-muted-foreground">{t('Software')}</div>
|
||||||
<div className="flex gap-2 items-center font-semibold">
|
<div className="flex gap-2 items-center font-semibold">
|
||||||
<SquareCode />
|
<SquareCode />
|
||||||
{formatSoftware(relayInfo.software)}
|
{formatSoftware(relayInfo.software)}
|
||||||
@@ -63,7 +101,7 @@ export default function RelayInfo({ url }: { url: string }) {
|
|||||||
)}
|
)}
|
||||||
{relayInfo.version && (
|
{relayInfo.version && (
|
||||||
<div className="space-y-2 flex-1">
|
<div className="space-y-2 flex-1">
|
||||||
<div className="text-sm font-semibold text-muted-foreground">Version</div>
|
<div className="text-sm font-semibold text-muted-foreground">{t('Version')}</div>
|
||||||
<div className="flex gap-2 items-center font-semibold">
|
<div className="flex gap-2 items-center font-semibold">
|
||||||
<GitBranch />
|
<GitBranch />
|
||||||
{relayInfo.version}
|
{relayInfo.version}
|
||||||
@@ -80,21 +118,9 @@ function formatSoftware(software: string) {
|
|||||||
return parts[parts.length - 1]
|
return parts[parts.length - 1]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RelayBadges({ relayInfo }: { relayInfo: TRelayInfo }) {
|
function formatNip(nip: number) {
|
||||||
return (
|
if (nip < 10) {
|
||||||
<div className="flex gap-2">
|
return `0${nip}`
|
||||||
{relayInfo.supported_nips?.includes(42) && (
|
}
|
||||||
<Badge className="bg-green-400 hover:bg-green-400/80">Auth</Badge>
|
return `${nip}`
|
||||||
)}
|
|
||||||
{relayInfo.supported_nips?.includes(50) && (
|
|
||||||
<Badge className="bg-pink-400 hover:bg-pink-400/80">Search</Badge>
|
|
||||||
)}
|
|
||||||
{relayInfo.limitation?.payment_required && (
|
|
||||||
<Badge className="bg-orange-400 hover:bg-orange-400/80">Payment</Badge>
|
|
||||||
)}
|
|
||||||
{relayInfo.supported_nips?.includes(29) && (
|
|
||||||
<Badge className="bg-blue-400 hover:bg-blue-400/80">Groups</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
108
src/components/RelayList/index.tsx
Normal file
108
src/components/RelayList/index.tsx
Normal file
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
35
src/components/RelaySimpleInfo/index.tsx
Normal file
35
src/components/RelaySimpleInfo/index.tsx
Normal file
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -32,9 +32,15 @@ export default function SaveRelayDropdownMenu({
|
|||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="ghost" size={atTitlebar ? 'titlebar-icon' : 'icon'}>
|
{atTitlebar ? (
|
||||||
|
<Button variant="ghost" size="titlebar-icon">
|
||||||
<Star className={alreadySaved ? 'fill-primary stroke-primary' : ''} />
|
<Star className={alreadySaved ? 'fill-primary stroke-primary' : ''} />
|
||||||
</Button>
|
</Button>
|
||||||
|
) : (
|
||||||
|
<button className="enabled:hover:text-primary [&_svg]:size-5">
|
||||||
|
<Star className={alreadySaved ? 'fill-primary stroke-primary' : ''} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent>
|
<DropdownMenuContent>
|
||||||
<DropdownMenuLabel>{t('Save to')} ...</DropdownMenuLabel>
|
<DropdownMenuLabel>{t('Save to')} ...</DropdownMenuLabel>
|
||||||
|
|||||||
33
src/components/SearchInput/index.tsx
Normal file
33
src/components/SearchInput/index.tsx
Normal file
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
19
src/components/Sidebar/ExploreButton.tsx
Normal file
19
src/components/Sidebar/ExploreButton.tsx
Normal file
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import AccountButton from './AccountButton'
|
|||||||
import HomeButton from './HomeButton'
|
import HomeButton from './HomeButton'
|
||||||
import NotificationsButton from './NotificationButton'
|
import NotificationsButton from './NotificationButton'
|
||||||
import PostButton from './PostButton'
|
import PostButton from './PostButton'
|
||||||
|
import RelaysButton from './ExploreButton'
|
||||||
import SearchButton from './SearchButton'
|
import SearchButton from './SearchButton'
|
||||||
import SettingsButton from './SettingsButton'
|
import SettingsButton from './SettingsButton'
|
||||||
|
|
||||||
@@ -16,6 +17,7 @@ export default function PrimaryPageSidebar() {
|
|||||||
<Logo className="max-xl:hidden" />
|
<Logo className="max-xl:hidden" />
|
||||||
</div>
|
</div>
|
||||||
<HomeButton />
|
<HomeButton />
|
||||||
|
<RelaysButton />
|
||||||
<NotificationsButton />
|
<NotificationsButton />
|
||||||
<SearchButton />
|
<SearchButton />
|
||||||
<SettingsButton />
|
<SettingsButton />
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export function Titlebar({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'sticky top-0 w-full z-20 bg-background [&_svg]:size-4 [&_svg]:shrink-0',
|
'sticky top-0 w-full z-40 bg-background [&_svg]:size-4 [&_svg]:shrink-0',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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 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 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/']
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import client from '@/services/client.service'
|
import relayInfoService from '@/services/relay-info.service'
|
||||||
import { TRelayInfo } from '@/types'
|
import { TNip66RelayInfo } 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<TRelayInfo | undefined>(undefined)
|
const [relayInfo, setRelayInfo] = useState<TNip66RelayInfo | undefined>(undefined)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!url) return
|
if (!url) return
|
||||||
@@ -14,7 +14,7 @@ export function useFetchRelayInfo(url?: string) {
|
|||||||
setIsFetching(false)
|
setIsFetching(false)
|
||||||
}, 5000)
|
}, 5000)
|
||||||
try {
|
try {
|
||||||
const [relayInfo] = await client.fetchRelayInfos([url])
|
const relayInfo = await relayInfoService.getRelayInfo(url)
|
||||||
setRelayInfo(relayInfo)
|
setRelayInfo(relayInfo)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { checkAlgoRelay } from '@/lib/relay'
|
import { checkAlgoRelay } from '@/lib/relay'
|
||||||
import client from '@/services/client.service'
|
import relayInfoService from '@/services/relay-info.service'
|
||||||
import { TRelayInfo } from '@/types'
|
import { TRelayInfo } from '@/types'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
@@ -20,7 +20,7 @@ export function useFetchRelayInfos(urls: string[]) {
|
|||||||
setIsFetching(false)
|
setIsFetching(false)
|
||||||
}, 5000)
|
}, 5000)
|
||||||
try {
|
try {
|
||||||
const relayInfos = await client.fetchRelayInfos(urls)
|
const relayInfos = await relayInfoService.getRelayInfos(urls)
|
||||||
setRelayInfos(relayInfos)
|
setRelayInfos(relayInfos)
|
||||||
setAreAlgoRelays(relayInfos.every((relayInfo) => checkAlgoRelay(relayInfo)))
|
setAreAlgoRelays(relayInfos.every((relayInfo) => checkAlgoRelay(relayInfo)))
|
||||||
setSearchableRelayUrls(
|
setSearchableRelayUrls(
|
||||||
|
|||||||
@@ -164,6 +164,20 @@ export default {
|
|||||||
'Login to set': 'Login to set',
|
'Login to set': 'Login to set',
|
||||||
'Please login to view following feed': 'Please login to view following feed',
|
'Please login to view following feed': 'Please login to view following feed',
|
||||||
'Send only to r': 'Send only to {{r}}',
|
'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'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -165,6 +165,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': '只发送到 {{r}}',
|
'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'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const PrimaryPageLayout = forwardRef(
|
|||||||
displayScrollToTopButton = false
|
displayScrollToTopButton = false
|
||||||
}: {
|
}: {
|
||||||
children?: React.ReactNode
|
children?: React.ReactNode
|
||||||
titlebar?: React.ReactNode
|
titlebar: React.ReactNode
|
||||||
pageName: TPrimaryPageName
|
pageName: TPrimaryPageName
|
||||||
displayScrollToTopButton?: boolean
|
displayScrollToTopButton?: boolean
|
||||||
},
|
},
|
||||||
@@ -54,11 +54,11 @@ const PrimaryPageLayout = forwardRef(
|
|||||||
paddingBottom: 'calc(env(safe-area-inset-bottom) + 3rem)'
|
paddingBottom: 'calc(env(safe-area-inset-bottom) + 3rem)'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{titlebar && <PrimaryPageTitlebar>{titlebar}</PrimaryPageTitlebar>}
|
<PrimaryPageTitlebar>{titlebar}</PrimaryPageTitlebar>
|
||||||
{children}
|
{children}
|
||||||
{displayScrollToTopButton && <ScrollToTopButton />}
|
|
||||||
<BottomNavigationBar />
|
<BottomNavigationBar />
|
||||||
</div>
|
</div>
|
||||||
|
{displayScrollToTopButton && <ScrollToTopButton />}
|
||||||
</DeepBrowsingProvider>
|
</DeepBrowsingProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -67,10 +67,10 @@ const PrimaryPageLayout = forwardRef(
|
|||||||
<DeepBrowsingProvider active={current === pageName} scrollAreaRef={scrollAreaRef}>
|
<DeepBrowsingProvider active={current === pageName} scrollAreaRef={scrollAreaRef}>
|
||||||
<ScrollArea
|
<ScrollArea
|
||||||
className="h-screen overflow-auto"
|
className="h-screen overflow-auto"
|
||||||
scrollBarClassName="z-20 pt-12"
|
scrollBarClassName="z-50 pt-12"
|
||||||
ref={scrollAreaRef}
|
ref={scrollAreaRef}
|
||||||
>
|
>
|
||||||
{titlebar && <PrimaryPageTitlebar>{titlebar}</PrimaryPageTitlebar>}
|
<PrimaryPageTitlebar>{titlebar}</PrimaryPageTitlebar>
|
||||||
{children}
|
{children}
|
||||||
<div className="h-4" />
|
<div className="h-4" />
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|||||||
@@ -65,9 +65,9 @@ const SecondaryPageLayout = forwardRef(
|
|||||||
hideBackButton={hideBackButton}
|
hideBackButton={hideBackButton}
|
||||||
/>
|
/>
|
||||||
{children}
|
{children}
|
||||||
{displayScrollToTopButton && <ScrollToTopButton />}
|
|
||||||
<BottomNavigationBar />
|
<BottomNavigationBar />
|
||||||
</div>
|
</div>
|
||||||
|
{displayScrollToTopButton && <ScrollToTopButton />}
|
||||||
</DeepBrowsingProvider>
|
</DeepBrowsingProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -76,7 +76,7 @@ const SecondaryPageLayout = forwardRef(
|
|||||||
<DeepBrowsingProvider active={currentIndex === index} scrollAreaRef={scrollAreaRef}>
|
<DeepBrowsingProvider active={currentIndex === index} scrollAreaRef={scrollAreaRef}>
|
||||||
<ScrollArea
|
<ScrollArea
|
||||||
className="h-screen overflow-auto"
|
className="h-screen overflow-auto"
|
||||||
scrollBarClassName="z-20 pt-12"
|
scrollBarClassName="z-50 pt-12"
|
||||||
ref={scrollAreaRef}
|
ref={scrollAreaRef}
|
||||||
>
|
>
|
||||||
<SecondaryPageTitlebar
|
<SecondaryPageTitlebar
|
||||||
@@ -106,7 +106,13 @@ export function SecondaryPageTitlebar({
|
|||||||
}): JSX.Element {
|
}): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<Titlebar className="h-12 flex gap-1 p-1 items-center justify-between font-semibold">
|
<Titlebar className="h-12 flex gap-1 p-1 items-center justify-between font-semibold">
|
||||||
<BackButton hide={hideBackButton}>{title}</BackButton>
|
{hideBackButton ? (
|
||||||
|
<div className="flex gap-2 items-center pl-3 w-fit truncate text-lg font-semibold">
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<BackButton>{title}</BackButton>
|
||||||
|
)}
|
||||||
<div className="flex-shrink-0">{controls}</div>
|
<div className="flex-shrink-0">{controls}</div>
|
||||||
</Titlebar>
|
</Titlebar>
|
||||||
)
|
)
|
||||||
|
|||||||
31
src/pages/primary/ExplorePage/index.tsx
Normal file
31
src/pages/primary/ExplorePage/index.tsx
Normal file
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
import NoteList from '@/components/NoteList'
|
import NoteList from '@/components/NoteList'
|
||||||
|
import PostEditor from '@/components/PostEditor'
|
||||||
import SaveRelayDropdownMenu from '@/components/SaveRelayDropdownMenu'
|
import SaveRelayDropdownMenu from '@/components/SaveRelayDropdownMenu'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import PrimaryPageLayout from '@/layouts/PrimaryPageLayout'
|
import PrimaryPageLayout from '@/layouts/PrimaryPageLayout'
|
||||||
import { useFeed } from '@/providers/FeedProvider'
|
import { useFeed } from '@/providers/FeedProvider'
|
||||||
import { useNostr } from '@/providers/NostrProvider'
|
import { useNostr } from '@/providers/NostrProvider'
|
||||||
|
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||||
import { TPageRef } from '@/types'
|
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 { useTranslation } from 'react-i18next'
|
||||||
import FeedButton from './FeedButton'
|
import FeedButton from './FeedButton'
|
||||||
import SearchButton from './SearchButton'
|
import SearchButton from './SearchButton'
|
||||||
@@ -59,15 +62,41 @@ NoteListPage.displayName = 'NoteListPage'
|
|||||||
export default NoteListPage
|
export default NoteListPage
|
||||||
|
|
||||||
function NoteListPageTitlebar({ temporaryRelayUrls = [] }: { temporaryRelayUrls?: string[] }) {
|
function NoteListPageTitlebar({ temporaryRelayUrls = [] }: { temporaryRelayUrls?: string[] }) {
|
||||||
|
const { isSmallScreen } = useScreenSize()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-1 items-center h-full justify-between">
|
<div className="flex gap-1 items-center h-full justify-between">
|
||||||
<FeedButton />
|
<FeedButton />
|
||||||
<div>
|
<div>
|
||||||
<SearchButton />
|
|
||||||
{temporaryRelayUrls.length > 0 && (
|
{temporaryRelayUrls.length > 0 && (
|
||||||
<SaveRelayDropdownMenu urls={temporaryRelayUrls} atTitlebar />
|
<SaveRelayDropdownMenu urls={temporaryRelayUrls} atTitlebar />
|
||||||
)}
|
)}
|
||||||
|
<SearchButton />
|
||||||
|
{isSmallScreen && <PostButton />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function PostButton() {
|
||||||
|
const { checkLogin } = useNostr()
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="titlebar-icon"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
checkLogin(() => {
|
||||||
|
setOpen(true)
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PencilLine />
|
||||||
|
</Button>
|
||||||
|
<PostEditor open={open} setOpen={setOpen} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,38 @@
|
|||||||
|
import { usePrimaryPage, useSecondaryPage } from '@/PageManager'
|
||||||
|
import RelaySimpleInfo from '@/components/RelaySimpleInfo'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
|
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'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
const HomePage = forwardRef(({ index }: { index?: number }, ref) => {
|
const HomePage = forwardRef(({ index }: { index?: number }, ref) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const { navigate } = usePrimaryPage()
|
||||||
|
const { push } = useSecondaryPage()
|
||||||
|
const [randomRelayInfos, setRandomRelayInfos] = useState<TNip66RelayInfo[]>([])
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
const relayInfos = await relayInfoService.getRandomRelayInfos(10)
|
||||||
|
const relayUrls = new Set<string>()
|
||||||
|
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 (
|
return (
|
||||||
<SecondaryPageLayout ref={ref} index={index} hideBackButton>
|
<SecondaryPageLayout ref={ref} index={index} hideBackButton>
|
||||||
<div className="text-muted-foreground w-full h-screen flex items-center justify-center">
|
<div className="text-muted-foreground w-full h-screen flex items-center justify-center">
|
||||||
@@ -11,6 +40,49 @@ const HomePage = forwardRef(({ index }: { index?: number }, ref) => {
|
|||||||
</div>
|
</div>
|
||||||
</SecondaryPageLayout>
|
</SecondaryPageLayout>
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SecondaryPageLayout
|
||||||
|
ref={ref}
|
||||||
|
index={index}
|
||||||
|
title={
|
||||||
|
<>
|
||||||
|
<Server />
|
||||||
|
<div>{t('Random Relays')}</div>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
controls={
|
||||||
|
<Button variant="ghost" className="h-10 [&_svg]:size-3" onClick={() => refresh()}>
|
||||||
|
<RefreshCcw />
|
||||||
|
<div>{t('randomRelaysRefresh')}</div>
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
hideBackButton
|
||||||
|
>
|
||||||
|
<div className="px-4">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{randomRelayInfos.map((relayInfo) => (
|
||||||
|
<RelaySimpleInfo
|
||||||
|
key={relayInfo.url}
|
||||||
|
className="clickable h-auto p-3 rounded-lg border"
|
||||||
|
relayInfo={relayInfo}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
push(toRelay(relayInfo.url))
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex mt-2 justify-center">
|
||||||
|
<Button variant="ghost" onClick={() => navigate('explore')}>
|
||||||
|
<div>{t('Explore more')}</div>
|
||||||
|
<ArrowRight />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SecondaryPageLayout>
|
||||||
|
)
|
||||||
})
|
})
|
||||||
HomePage.displayName = 'HomePage'
|
HomePage.displayName = 'HomePage'
|
||||||
export default HomePage
|
export default HomePage
|
||||||
|
|||||||
@@ -1,16 +1,33 @@
|
|||||||
import NoteList from '@/components/NoteList'
|
import NoteList from '@/components/NoteList'
|
||||||
import RelayInfo from '@/components/RelayInfo'
|
import RelayInfo from '@/components/RelayInfo'
|
||||||
import SaveRelayDropdownMenu from '@/components/SaveRelayDropdownMenu'
|
import SaveRelayDropdownMenu from '@/components/SaveRelayDropdownMenu'
|
||||||
|
import SearchInput from '@/components/SearchInput'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { useFetchRelayInfo } from '@/hooks'
|
||||||
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
|
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
|
||||||
import { normalizeUrl, simplifyUrl } from '@/lib/url'
|
import { normalizeUrl, simplifyUrl } from '@/lib/url'
|
||||||
import { Check, Copy } from 'lucide-react'
|
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'
|
import NotFoundPage from '../NotFoundPage'
|
||||||
|
|
||||||
const RelayPage = forwardRef(({ url, index }: { url?: string; index?: number }, ref) => {
|
const RelayPage = forwardRef(({ url, index }: { url?: string; index?: number }, ref) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
const normalizedUrl = useMemo(() => (url ? normalizeUrl(url) : undefined), [url])
|
const normalizedUrl = useMemo(() => (url ? normalizeUrl(url) : undefined), [url])
|
||||||
|
const { relayInfo } = useFetchRelayInfo(normalizedUrl)
|
||||||
const title = useMemo(() => (url ? simplifyUrl(url) : undefined), [url])
|
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) {
|
if (!normalizedUrl) {
|
||||||
return <NotFoundPage ref={ref} />
|
return <NotFoundPage ref={ref} />
|
||||||
@@ -25,7 +42,20 @@ const RelayPage = forwardRef(({ url, index }: { url?: string; index?: number },
|
|||||||
displayScrollToTopButton
|
displayScrollToTopButton
|
||||||
>
|
>
|
||||||
<RelayInfo url={normalizedUrl} />
|
<RelayInfo url={normalizedUrl} />
|
||||||
<NoteList relayUrls={[normalizedUrl]} needCheckAlgoRelay />
|
{relayInfo?.supported_nips?.includes(50) && (
|
||||||
|
<div className="px-4 py-2">
|
||||||
|
<SearchInput
|
||||||
|
value={searchInput}
|
||||||
|
onChange={(e) => setSearchInput(e.target.value)}
|
||||||
|
placeholder={t('Search')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<NoteList
|
||||||
|
relayUrls={[normalizedUrl]}
|
||||||
|
needCheckAlgoRelay
|
||||||
|
filter={debouncedInput ? { search: debouncedInput } : {}}
|
||||||
|
/>
|
||||||
</SecondaryPageLayout>
|
</SecondaryPageLayout>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -35,17 +35,6 @@ export function DeepBrowsingProvider({
|
|||||||
if (!active) return
|
if (!active) return
|
||||||
|
|
||||||
const handleScroll = () => {
|
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 scrollTop = (!scrollAreaRef ? window.scrollY : scrollAreaRef.current?.scrollTop) || 0
|
||||||
const diff = scrollTop - lastScrollTopRef.current
|
const diff = scrollTop - lastScrollTopRef.current
|
||||||
lastScrollTopRef.current = scrollTop
|
lastScrollTopRef.current = scrollTop
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { getProfileFromProfileEvent, getRelayListFromRelayListEvent } from '@/li
|
|||||||
import { formatPubkey, userIdToPubkey } from '@/lib/pubkey'
|
import { formatPubkey, userIdToPubkey } from '@/lib/pubkey'
|
||||||
import { extractPubkeysFromEventTags } from '@/lib/tag'
|
import { extractPubkeysFromEventTags } from '@/lib/tag'
|
||||||
import { isLocalNetworkUrl } from '@/lib/url'
|
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 { sha256 } from '@noble/hashes/sha2'
|
||||||
import DataLoader from 'dataloader'
|
import DataLoader from 'dataloader'
|
||||||
import FlexSearch from 'flexsearch'
|
import FlexSearch from 'flexsearch'
|
||||||
@@ -49,33 +49,19 @@ class ClientService extends EventTarget {
|
|||||||
private profileEventCache = new LRUCache<string, Promise<NEvent | undefined>>({ max: 10000 })
|
private profileEventCache = new LRUCache<string, Promise<NEvent | undefined>>({ max: 10000 })
|
||||||
private profileEventDataloader = new DataLoader<string, NEvent | undefined>(
|
private profileEventDataloader = new DataLoader<string, NEvent | undefined>(
|
||||||
(ids) => Promise.all(ids.map((id) => this._fetchProfileEvent(id))),
|
(ids) => Promise.all(ids.map((id) => this._fetchProfileEvent(id))),
|
||||||
{ cacheMap: this.profileEventCache, maxBatchSize: 10 }
|
{ cacheMap: this.profileEventCache, maxBatchSize: 20 }
|
||||||
)
|
)
|
||||||
private fetchProfileEventFromDefaultRelaysDataloader = new DataLoader<string, NEvent | undefined>(
|
private fetchProfileEventFromDefaultRelaysDataloader = new DataLoader<string, NEvent | undefined>(
|
||||||
this.profileEventBatchLoadFn.bind(this),
|
this.profileEventBatchLoadFn.bind(this),
|
||||||
{ cache: false, maxBatchSize: 10 }
|
{ cache: false, maxBatchSize: 20 }
|
||||||
)
|
)
|
||||||
private relayListEventDataLoader = new DataLoader<string, NEvent | undefined>(
|
private relayListEventDataLoader = new DataLoader<string, NEvent | undefined>(
|
||||||
this.relayListEventBatchLoadFn.bind(this),
|
this.relayListEventBatchLoadFn.bind(this),
|
||||||
{
|
{
|
||||||
cacheMap: new LRUCache<string, Promise<NEvent | undefined>>({ max: 10000 }),
|
cacheMap: new LRUCache<string, Promise<NEvent | undefined>>({ max: 10000 }),
|
||||||
maxBatchSize: 10
|
maxBatchSize: 20
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
private relayInfoDataLoader = new DataLoader<string, TRelayInfo | undefined>(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<string, Promise<NEvent | undefined>>({
|
private followListCache = new LRUCache<string, Promise<NEvent | undefined>>({
|
||||||
max: 10000,
|
max: 10000,
|
||||||
fetchMethod: this._fetchFollowListEvent.bind(this)
|
fetchMethod: this._fetchFollowListEvent.bind(this)
|
||||||
@@ -531,11 +517,6 @@ class ClientService extends EventTarget {
|
|||||||
this.followListCache.set(pubkey, Promise.resolve(event))
|
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) {
|
async calculateOptimalReadRelays(pubkey: string) {
|
||||||
const followings = await this.fetchFollowings(pubkey)
|
const followings = await this.fetchFollowings(pubkey)
|
||||||
const [selfRelayListEvent, ...relayListEvents] = await this.relayListEventDataLoader.loadMany([
|
const [selfRelayListEvent, ...relayListEvents] = await this.relayListEventDataLoader.loadMany([
|
||||||
@@ -597,9 +578,9 @@ class ClientService extends EventTarget {
|
|||||||
|
|
||||||
async initUserIndexFromFollowings(pubkey: string) {
|
async initUserIndexFromFollowings(pubkey: string) {
|
||||||
const followings = await this.fetchFollowings(pubkey)
|
const followings = await this.fetchFollowings(pubkey)
|
||||||
for (let i = 0; i * 10 < followings.length; i++) {
|
for (let i = 0; i * 20 < followings.length; i++) {
|
||||||
await this.profileEventDataloader.loadMany(followings.slice(i * 10, (i + 1) * 10))
|
await this.profileEventDataloader.loadMany(followings.slice(i * 20, (i + 1) * 20))
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
await new Promise((resolve) => setTimeout(resolve, 200))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
216
src/services/relay-info.service.ts
Normal file
216
src/services/relay-info.service.ts
Normal file
@@ -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
|
||||||
|
}
|
||||||
10
src/types.ts
10
src/types.ts
@@ -32,6 +32,7 @@ export type TRelayInfo = {
|
|||||||
software?: string
|
software?: string
|
||||||
version?: string
|
version?: string
|
||||||
tags?: string[]
|
tags?: string[]
|
||||||
|
payments_url?: string
|
||||||
limitation?: {
|
limitation?: {
|
||||||
auth_required?: boolean
|
auth_required?: boolean
|
||||||
payment_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 TNoteListMode = 'posts' | 'postsAndReplies' | 'pictures'
|
||||||
|
|
||||||
export type TPageRef = { scrollToTop: () => void }
|
export type TPageRef = { scrollToTop: () => void }
|
||||||
|
|
||||||
|
export type TNip66RelayInfo = TRelayInfo & {
|
||||||
|
url: string
|
||||||
|
shortUrl: string
|
||||||
|
hasNip11: boolean
|
||||||
|
triedNip11: boolean
|
||||||
|
relayType?: string
|
||||||
|
countryCode?: string
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user