feat: explore (#85)

This commit is contained in:
Cody Tseng
2025-02-11 16:33:31 +08:00
committed by GitHub
parent 80893ec033
commit b91f46723e
35 changed files with 811 additions and 179 deletions

View File

@@ -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>

View File

@@ -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>
)}
</>
)
}

View File

@@ -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"

View 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>
)
}

View File

@@ -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} />
</>
)
}

View File

@@ -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>

View File

@@ -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)]' : ''
)}
>

View File

@@ -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>
)
}

View File

@@ -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 =

View File

@@ -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, {

View 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>
)
}

View File

@@ -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>

View File

@@ -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}`
}

View 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>
)
}

View 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>
)
}

View File

@@ -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>

View 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>
)
}

View 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>
)
}

View File

@@ -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 />

View File

@@ -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
)}
>

View File

@@ -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/']

View File

@@ -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)

View File

@@ -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(

View File

@@ -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'
}
}

View File

@@ -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'
}
}

View File

@@ -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>

View File

@@ -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>
)

View 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>
)
}

View File

@@ -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} />
</>
)
}

View File

@@ -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

View File

@@ -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>
)
})

View File

@@ -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

View File

@@ -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))
}
}

View 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
}

View File

@@ -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
}