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
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 811 additions and 179 deletions

View file

@ -3,30 +3,20 @@ import { useSecondaryPage } from '@/PageManager'
import { ChevronLeft } from 'lucide-react'
import { useTranslation } from 'react-i18next'
export default function BackButton({
hide = false,
children
}: {
hide?: boolean
children?: React.ReactNode
}) {
export default function BackButton({ children }: { children?: React.ReactNode }) {
const { t } = useTranslation()
const { pop } = useSecondaryPage()
return (
<>
{!hide && (
<Button
className="flex gap-1 items-center w-fit max-w-full justify-start pl-2 pr-3"
variant="ghost"
size="titlebar-icon"
title={t('back')}
onClick={() => pop()}
>
<ChevronLeft />
<div className="truncate text-lg font-semibold">{children}</div>
</Button>
)}
</>
<Button
className="flex gap-1 items-center w-fit max-w-full justify-start pl-2 pr-3"
variant="ghost"
size="titlebar-icon"
title={t('back')}
onClick={() => pop()}
>
<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,27 +33,15 @@ 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="flex gap-2">
{['both', 'read'].includes(scope) && (
<Badge className="bg-blue-400 hover:bg-blue-400/80">{t('Read')}</Badge>
)}
{['both', 'write'].includes(scope) && (
<Badge className="bg-green-400 hover:bg-green-400/80">{t('Write')}</Badge>
)}
</div>
</div>
<div className="flex items-center gap-1 shrink-0" onClick={(e) => e.stopPropagation()}>
<SaveRelayDropdownMenu urls={[url]} />
<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>
)}
{['both', 'write'].includes(scope) && (
<Badge className="bg-green-400 hover:bg-green-400/80">{t('Write')}</Badge>
)}
</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'}>
<Star className={alreadySaved ? 'fill-primary stroke-primary' : ''} />
</Button>
{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
)}
>