feat: explore (#85)
This commit is contained in:
@@ -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<TPageRef>(),
|
||||
explore: createRef<TPageRef>(),
|
||||
notifications: createRef<TPageRef>(),
|
||||
me: createRef<TPageRef>()
|
||||
}
|
||||
|
||||
const PRIMARY_PAGE_MAP = {
|
||||
home: <NoteListPage ref={PRIMARY_PAGE_REF_MAP.home} />,
|
||||
explore: <ExplorePage ref={PRIMARY_PAGE_REF_MAP.explore} />,
|
||||
notifications: <NotificationListPage ref={PRIMARY_PAGE_REF_MAP.notifications} />,
|
||||
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" />
|
||||
</div>
|
||||
<div>
|
||||
{secondaryStack.length ? (
|
||||
secondaryStack.map((item, index) => (
|
||||
{secondaryStack.map((item, index) => (
|
||||
<div
|
||||
key={item.index}
|
||||
style={{ display: index === secondaryStack.length - 1 ? 'block' : 'none' }}
|
||||
>
|
||||
{item.component}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
))}
|
||||
<div key="home" style={{ display: secondaryStack.length === 0 ? 'block' : 'none' }}>
|
||||
<HomePage />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,19 +3,11 @@ 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 && (
|
||||
<Button
|
||||
className="flex gap-1 items-center w-fit max-w-full justify-start pl-2 pr-3"
|
||||
variant="ghost"
|
||||
@@ -26,7 +18,5 @@ export default function BackButton({
|
||||
<ChevronLeft />
|
||||
<div className="truncate text-lg font-semibold">{children}</div>
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ export default function BottomNavigationBarItem({
|
||||
return (
|
||||
<Button
|
||||
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'
|
||||
)}
|
||||
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 AccountButton from './AccountButton'
|
||||
import ExploreButton from './ExploreButton'
|
||||
import HomeButton from './HomeButton'
|
||||
import NotificationsButton from './NotificationsButton'
|
||||
import PostButton from './PostButton'
|
||||
|
||||
export default function BottomNavigationBar() {
|
||||
return (
|
||||
@@ -16,7 +16,7 @@ export default function BottomNavigationBar() {
|
||||
}}
|
||||
>
|
||||
<HomeButton />
|
||||
<PostButton />
|
||||
<ExploreButton />
|
||||
<NotificationsButton />
|
||||
<AccountButton />
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useMuteList } from '@/providers/MuteListProvider'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||
import client from '@/services/client.service'
|
||||
import relayInfoService from '@/services/relay-info.service'
|
||||
import storage from '@/services/storage.service'
|
||||
import { TNoteListMode } from '@/types'
|
||||
import dayjs from 'dayjs'
|
||||
@@ -76,7 +77,7 @@ export default function NoteList({
|
||||
|
||||
let areAlgoRelays = false
|
||||
if (needCheckAlgoRelay) {
|
||||
const relayInfos = await client.fetchRelayInfos(relayUrls)
|
||||
const relayInfos = await relayInfoService.getRelayInfos(relayUrls)
|
||||
areAlgoRelays = relayInfos.every((relayInfo) => checkAlgoRelay(relayInfo))
|
||||
}
|
||||
const filter = areAlgoRelays ? { ...noteFilter, limit: ALGO_RELAY_LIMIT } : noteFilter
|
||||
@@ -255,7 +256,7 @@ function ListModeSwitch({
|
||||
return (
|
||||
<div
|
||||
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)]' : ''
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -3,12 +3,10 @@ import { Badge } from '@/components/ui/badge'
|
||||
import { useFetchRelayInfo, useFetchRelayList } from '@/hooks'
|
||||
import { toRelay } from '@/lib/link'
|
||||
import { userIdToPubkey } from '@/lib/pubkey'
|
||||
import { simplifyUrl } from '@/lib/url'
|
||||
import { TMailboxRelay } from '@/types'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import RelayIcon from '../RelayIcon'
|
||||
import SaveRelayDropdownMenu from '../SaveRelayDropdownMenu'
|
||||
import RelaySimpleInfo from '../RelaySimpleInfo'
|
||||
|
||||
export default function OthersRelayList({ userId }: { userId: string }) {
|
||||
const { t } = useTranslation()
|
||||
@@ -35,16 +33,8 @@ function RelayItem({ relay }: { relay: TMailboxRelay }) {
|
||||
const { url, scope } = relay
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-2 justify-between p-4 rounded-lg border clickable"
|
||||
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="p-4 rounded-lg border clickable space-y-1" onClick={() => push(toRelay(url))}>
|
||||
<RelaySimpleInfo relayInfo={relayInfo} hideBadge />
|
||||
<div className="flex gap-2">
|
||||
{['both', 'read'].includes(scope) && (
|
||||
<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 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 { useNostr } from '@/providers/NostrProvider'
|
||||
import client from '@/services/client.service'
|
||||
import relayInfoService from '@/services/relay-info.service'
|
||||
import { ChevronDown, ImageUp, LoaderCircle } from 'lucide-react'
|
||||
import { Event, kinds } from 'nostr-tools'
|
||||
import { useState } from 'react'
|
||||
@@ -54,7 +55,7 @@ export default function NormalPostContent({
|
||||
}
|
||||
let protectedEvent = false
|
||||
if (postOptions.sendOnlyToCurrentRelays) {
|
||||
const relayInfos = await client.fetchRelayInfos(relayUrls)
|
||||
const relayInfos = await relayInfoService.getRelayInfos(relayUrls)
|
||||
protectedEvent = relayInfos.every((info) => info?.supported_nips?.includes(70))
|
||||
}
|
||||
const draftEvent =
|
||||
|
||||
@@ -4,7 +4,7 @@ import { createPictureNoteDraftEvent } from '@/lib/draft-event'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useFeed } from '@/providers/FeedProvider.tsx'
|
||||
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 { Dispatch, SetStateAction, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -43,7 +43,7 @@ export default function PicturePostContent({ close }: { close: () => void }) {
|
||||
}
|
||||
let protectedEvent = false
|
||||
if (postOptions.sendOnlyToCurrentRelays) {
|
||||
const relayInfos = await client.fetchRelayInfos(relayUrls)
|
||||
const relayInfos = await relayInfoService.getRelayInfos(relayUrls)
|
||||
protectedEvent = relayInfos.every((info) => info?.supported_nips?.includes(70))
|
||||
}
|
||||
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',
|
||||
iconSize = 14
|
||||
}: {
|
||||
url: string
|
||||
url?: string
|
||||
className?: string
|
||||
iconSize?: number
|
||||
}) {
|
||||
@@ -17,13 +17,14 @@ export default function RelayIcon({
|
||||
if (relayInfo?.icon) {
|
||||
return relayInfo.icon
|
||||
}
|
||||
if (!url) return
|
||||
const u = new URL(url)
|
||||
return `${u.protocol === 'wss:' ? 'https:' : 'http:'}//${u.host}/favicon.ico`
|
||||
}, [url, relayInfo])
|
||||
|
||||
return (
|
||||
<Avatar className={className}>
|
||||
<AvatarImage src={iconUrl} />
|
||||
<AvatarImage src={iconUrl} className="object-cover object-center" />
|
||||
<AvatarFallback>
|
||||
<Server size={iconSize} />
|
||||
</AvatarFallback>
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { useFetchRelayInfo } from '@/hooks'
|
||||
import { TRelayInfo } from '@/types'
|
||||
import { normalizeHttpUrl } from '@/lib/url'
|
||||
import { GitBranch, Mail, SquareCode } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import RelayBadges from '../RelayBadges'
|
||||
import RelayIcon from '../RelayIcon'
|
||||
import UserAvatar from '../UserAvatar'
|
||||
import Username from '../Username'
|
||||
|
||||
export default function RelayInfo({ url }: { url: string }) {
|
||||
const { t } = useTranslation()
|
||||
const { relayInfo, isFetching } = useFetchRelayInfo(url)
|
||||
if (isFetching || !relayInfo) {
|
||||
return null
|
||||
@@ -33,10 +36,45 @@ export default function RelayInfo({ url }: { url: string }) {
|
||||
</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">
|
||||
{relayInfo.pubkey && (
|
||||
<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">
|
||||
<UserAvatar userId={relayInfo.pubkey} size="small" />
|
||||
<Username userId={relayInfo.pubkey} className="font-semibold" />
|
||||
@@ -45,7 +83,7 @@ export default function RelayInfo({ url }: { url: string }) {
|
||||
)}
|
||||
{relayInfo.contact && (
|
||||
<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">
|
||||
<Mail />
|
||||
{relayInfo.contact}
|
||||
@@ -54,7 +92,7 @@ export default function RelayInfo({ url }: { url: string }) {
|
||||
)}
|
||||
{relayInfo.software && (
|
||||
<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">
|
||||
<SquareCode />
|
||||
{formatSoftware(relayInfo.software)}
|
||||
@@ -63,7 +101,7 @@ export default function RelayInfo({ url }: { url: string }) {
|
||||
)}
|
||||
{relayInfo.version && (
|
||||
<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">
|
||||
<GitBranch />
|
||||
{relayInfo.version}
|
||||
@@ -80,21 +118,9 @@ function formatSoftware(software: string) {
|
||||
return parts[parts.length - 1]
|
||||
}
|
||||
|
||||
export function RelayBadges({ relayInfo }: { relayInfo: TRelayInfo }) {
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
{relayInfo.supported_nips?.includes(42) && (
|
||||
<Badge className="bg-green-400 hover:bg-green-400/80">Auth</Badge>
|
||||
)}
|
||||
{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>
|
||||
)
|
||||
function formatNip(nip: number) {
|
||||
if (nip < 10) {
|
||||
return `0${nip}`
|
||||
}
|
||||
return `${nip}`
|
||||
}
|
||||
|
||||
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 (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size={atTitlebar ? 'titlebar-icon' : 'icon'}>
|
||||
{atTitlebar ? (
|
||||
<Button variant="ghost" size="titlebar-icon">
|
||||
<Star className={alreadySaved ? 'fill-primary stroke-primary' : ''} />
|
||||
</Button>
|
||||
) : (
|
||||
<button className="enabled:hover:text-primary [&_svg]:size-5">
|
||||
<Star className={alreadySaved ? 'fill-primary stroke-primary' : ''} />
|
||||
</button>
|
||||
)}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<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 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() {
|
||||
<Logo className="max-xl:hidden" />
|
||||
</div>
|
||||
<HomeButton />
|
||||
<RelaysButton />
|
||||
<NotificationsButton />
|
||||
<SearchButton />
|
||||
<SettingsButton />
|
||||
|
||||
@@ -10,7 +10,7 @@ export function Titlebar({
|
||||
return (
|
||||
<div
|
||||
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
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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/']
|
||||
|
||||
@@ -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<TRelayInfo | undefined>(undefined)
|
||||
const [relayInfo, setRelayInfo] = useState<TNip66RelayInfo | undefined>(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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 && <PrimaryPageTitlebar>{titlebar}</PrimaryPageTitlebar>}
|
||||
<PrimaryPageTitlebar>{titlebar}</PrimaryPageTitlebar>
|
||||
{children}
|
||||
{displayScrollToTopButton && <ScrollToTopButton />}
|
||||
<BottomNavigationBar />
|
||||
</div>
|
||||
{displayScrollToTopButton && <ScrollToTopButton />}
|
||||
</DeepBrowsingProvider>
|
||||
)
|
||||
}
|
||||
@@ -67,10 +67,10 @@ const PrimaryPageLayout = forwardRef(
|
||||
<DeepBrowsingProvider active={current === pageName} scrollAreaRef={scrollAreaRef}>
|
||||
<ScrollArea
|
||||
className="h-screen overflow-auto"
|
||||
scrollBarClassName="z-20 pt-12"
|
||||
scrollBarClassName="z-50 pt-12"
|
||||
ref={scrollAreaRef}
|
||||
>
|
||||
{titlebar && <PrimaryPageTitlebar>{titlebar}</PrimaryPageTitlebar>}
|
||||
<PrimaryPageTitlebar>{titlebar}</PrimaryPageTitlebar>
|
||||
{children}
|
||||
<div className="h-4" />
|
||||
</ScrollArea>
|
||||
|
||||
@@ -65,9 +65,9 @@ const SecondaryPageLayout = forwardRef(
|
||||
hideBackButton={hideBackButton}
|
||||
/>
|
||||
{children}
|
||||
{displayScrollToTopButton && <ScrollToTopButton />}
|
||||
<BottomNavigationBar />
|
||||
</div>
|
||||
{displayScrollToTopButton && <ScrollToTopButton />}
|
||||
</DeepBrowsingProvider>
|
||||
)
|
||||
}
|
||||
@@ -76,7 +76,7 @@ const SecondaryPageLayout = forwardRef(
|
||||
<DeepBrowsingProvider active={currentIndex === index} scrollAreaRef={scrollAreaRef}>
|
||||
<ScrollArea
|
||||
className="h-screen overflow-auto"
|
||||
scrollBarClassName="z-20 pt-12"
|
||||
scrollBarClassName="z-50 pt-12"
|
||||
ref={scrollAreaRef}
|
||||
>
|
||||
<SecondaryPageTitlebar
|
||||
@@ -106,7 +106,13 @@ export function SecondaryPageTitlebar({
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<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>
|
||||
</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 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 (
|
||||
<div className="flex gap-1 items-center h-full justify-between">
|
||||
<FeedButton />
|
||||
<div>
|
||||
<SearchButton />
|
||||
{temporaryRelayUrls.length > 0 && (
|
||||
<SaveRelayDropdownMenu urls={temporaryRelayUrls} atTitlebar />
|
||||
)}
|
||||
<SearchButton />
|
||||
{isSmallScreen && <PostButton />}
|
||||
</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 { 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<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 (
|
||||
<SecondaryPageLayout ref={ref} index={index} hideBackButton>
|
||||
<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>
|
||||
</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'
|
||||
export default HomePage
|
||||
|
||||
@@ -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 <NotFoundPage ref={ref} />
|
||||
@@ -25,7 +42,20 @@ const RelayPage = forwardRef(({ url, index }: { url?: string; index?: number },
|
||||
displayScrollToTopButton
|
||||
>
|
||||
<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>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string, Promise<NEvent | undefined>>({ max: 10000 })
|
||||
private profileEventDataloader = new DataLoader<string, NEvent | undefined>(
|
||||
(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>(
|
||||
this.profileEventBatchLoadFn.bind(this),
|
||||
{ cache: false, maxBatchSize: 10 }
|
||||
{ cache: false, maxBatchSize: 20 }
|
||||
)
|
||||
private relayListEventDataLoader = new DataLoader<string, NEvent | undefined>(
|
||||
this.relayListEventBatchLoadFn.bind(this),
|
||||
{
|
||||
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>>({
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user