feat: search

This commit is contained in:
codytseng
2024-11-27 22:31:59 +08:00
parent 4f401cbef0
commit 292bc8f6ea
31 changed files with 1076 additions and 96 deletions

View File

@@ -56,9 +56,8 @@ export function PageManager({
const [secondaryStack, setSecondaryStack] = useState<TStackItem[]>([])
useEffect(() => {
const url = window.location.pathname
if (url !== '/') {
pushSecondary(url)
if (window.location.pathname !== '/') {
pushSecondary(window.location.pathname + window.location.search)
}
const onPopState = (e: PopStateEvent) => {
@@ -175,8 +174,9 @@ function isCurrentPage(stack: TStackItem[], url: string) {
}
function findAndCreateComponent(url: string) {
const path = url.split('?')[0]
for (const { matcher, element } of routes) {
const match = matcher(url)
const match = matcher(path)
if (!match) continue
if (!element) return <NotFoundPage />

View File

@@ -1,4 +1,4 @@
import { toHashtag } from '@renderer/lib/link'
import { toNoteList } from '@renderer/lib/link'
import { SecondaryPageLink } from '@renderer/PageManager'
import { TEmbeddedRenderer } from './types'
@@ -6,7 +6,7 @@ export function EmbeddedHashtag({ hashtag }: { hashtag: string }) {
return (
<SecondaryPageLink
className="text-highlight hover:underline"
to={toHashtag(hashtag)}
to={toNoteList({ hashtag })}
onClick={(e) => e.stopPropagation()}
>
#{hashtag}

View File

@@ -1,9 +1,10 @@
import { Button } from '@renderer/components/ui/button'
import { Input } from '@renderer/components/ui/input'
import { useFetchRelayInfos } from '@renderer/hooks'
import { isWebsocketUrl, normalizeUrl } from '@renderer/lib/url'
import { useRelaySettings } from '@renderer/providers/RelaySettingsProvider'
import client from '@renderer/services/client.service'
import { CircleX } from 'lucide-react'
import { CircleX, SearchCheck } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -111,6 +112,9 @@ function RelayUrl({
isConnected: boolean
onRemove: () => void
}) {
const { t } = useTranslation()
const [relayInfo] = useFetchRelayInfos([url])
return (
<div className="flex items-center justify-between">
<div className="flex gap-2 items-center">
@@ -122,6 +126,11 @@ function RelayUrl({
<div className="text-red-500 text-xs"></div>
)}
<div className="text-muted-foreground text-sm">{url}</div>
{relayInfo?.supported_nips?.includes(50) && (
<div title={t('supports search')} className="text-highlight">
<SearchCheck size={14} />
</div>
)}
</div>
<div>
<CircleX

View File

@@ -1,10 +1,13 @@
import { Button } from '@renderer/components/ui/button'
import { useFetchRelayInfos } from '@renderer/hooks'
import { useRelaySettings } from '@renderer/providers/RelaySettingsProvider'
import client from '@renderer/services/client.service'
import { Save } from 'lucide-react'
import { Save, SearchCheck } from 'lucide-react'
import { useEffect, useState } from 'react'
import { Button } from '../ui/button'
import { useTranslation } from 'react-i18next'
export default function TemporaryRelayGroup() {
const { t } = useTranslation()
const { temporaryRelayUrls, relayGroups, addRelayGroup, switchRelayGroup } = useRelaySettings()
const [relays, setRelays] = useState<
{
@@ -12,6 +15,7 @@ export default function TemporaryRelayGroup() {
isConnected: boolean
}[]
>(temporaryRelayUrls.map((url) => ({ url, isConnected: false })))
const relayInfos = useFetchRelayInfos(relays.map((relay) => relay.url))
useEffect(() => {
const interval = setInterval(() => {
@@ -64,6 +68,11 @@ export default function TemporaryRelayGroup() {
<div className="text-red-500 text-xs"></div>
)}
<div className="text-muted-foreground text-sm">{relay.url}</div>
{relayInfos[index]?.supported_nips?.includes(50) && (
<div title={t('supports search')} className="text-highlight">
<SearchCheck size={14} />
</div>
)}
</div>
</div>
))}

View File

@@ -0,0 +1,24 @@
import { Button } from '@renderer/components/ui/button'
import { Search } from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { SearchDialog } from '../SearchDialog'
export default function RefreshButton({
variant = 'titlebar'
}: {
variant?: 'titlebar' | 'sidebar'
}) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
return (
<>
<Button variant={variant} size={variant} onClick={() => setOpen(true)} title={t('Search')}>
<Search />
{variant === 'sidebar' && <div>{t('Search')}</div>}
</Button>
<SearchDialog open={open} setOpen={setOpen} />
</>
)
}

View File

@@ -0,0 +1,160 @@
import { SecondaryPageLink } from '@renderer/PageManager'
import { Avatar, AvatarFallback, AvatarImage } from '@renderer/components/ui/avatar'
import {
CommandDialog,
CommandInput,
CommandItem,
CommandList
} from '@renderer/components/ui/command'
import { useSearchProfiles } from '@renderer/hooks'
import { toNote, toNoteList, toProfile, toProfileList } from '@renderer/lib/link'
import { generateImageByPubkey } from '@renderer/lib/pubkey'
import { useRelaySettings } from '@renderer/providers/RelaySettingsProvider'
import { TProfile } from '@renderer/types'
import { Hash, Notebook, UserRound } from 'lucide-react'
import { nip19 } from 'nostr-tools'
import { Dispatch, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
export function SearchDialog({ open, setOpen }: { open: boolean; setOpen: Dispatch<boolean> }) {
const { t } = useTranslation()
const [input, setInput] = useState('')
const [debouncedInput, setDebouncedInput] = useState(input)
const { profiles } = useSearchProfiles(debouncedInput, 10)
const list = useMemo(() => {
const search = input.trim()
if (!search) return
if (/^[0-9a-f]{64}$/.test(search)) {
return (
<>
<NoteItem id={search} onClick={() => setOpen(false)} />
<ProfileIdItem id={search} onClick={() => setOpen(false)} />
</>
)
}
try {
let id = search
if (id.startsWith('nostr:')) {
id = id.slice(6)
}
const { type } = nip19.decode(id)
if (['nprofile', 'npub'].includes(type)) {
return <ProfileIdItem id={id} onClick={() => setOpen(false)} />
}
if (['nevent', 'naddr', 'note'].includes(type)) {
return <NoteItem id={id} onClick={() => setOpen(false)} />
}
} catch {
// ignore
}
return (
<>
<NormalItem search={search} onClick={() => setOpen(false)} />
<HashtagItem search={search} onClick={() => setOpen(false)} />
{profiles.map((profile) => (
<ProfileItem key={profile.pubkey} profile={profile} onClick={() => setOpen(false)} />
))}
{profiles.length >= 10 && (
<SecondaryPageLink to={toProfileList({ search })} onClick={() => setOpen(false)}>
<CommandItem onClick={() => setOpen(false)} className="text-center">
<div className="font-semibold">{t('Show more...')}</div>
</CommandItem>
</SecondaryPageLink>
)}
</>
)
}, [input, profiles])
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedInput(input)
}, 500)
return () => {
clearTimeout(handler)
}
}, [input])
return (
<CommandDialog open={open} onOpenChange={setOpen}>
<CommandInput value={input} onValueChange={setInput} />
<CommandList>{list}</CommandList>
</CommandDialog>
)
}
function NormalItem({ search, onClick }: { search: string; onClick?: () => void }) {
const { searchableRelayUrls } = useRelaySettings()
if (searchableRelayUrls.length === 0) {
return null
}
return (
<SecondaryPageLink to={toNoteList({ search })} onClick={onClick}>
<CommandItem>
<Notebook className="text-muted-foreground" />
<div className="font-semibold">{search}</div>
</CommandItem>
</SecondaryPageLink>
)
}
function HashtagItem({ search, onClick }: { search: string; onClick?: () => void }) {
const hashtag = search.match(/[\p{L}\p{N}\p{M}]+/u)?.[0].toLowerCase()
return (
<SecondaryPageLink to={toNoteList({ hashtag })} onClick={onClick}>
<CommandItem value={`hashtag-${hashtag}`}>
<Hash className="text-muted-foreground" />
<div className="font-semibold">{hashtag}</div>
</CommandItem>
</SecondaryPageLink>
)
}
function NoteItem({ id, onClick }: { id: string; onClick?: () => void }) {
return (
<SecondaryPageLink to={toNote(id)} onClick={onClick}>
<CommandItem>
<Notebook className="text-muted-foreground" />
<div className="font-semibold truncate">{id}</div>
</CommandItem>
</SecondaryPageLink>
)
}
function ProfileIdItem({ id, onClick }: { id: string; onClick?: () => void }) {
return (
<SecondaryPageLink to={toProfile(id)} onClick={onClick}>
<CommandItem>
<UserRound className="text-muted-foreground" />
<div className="font-semibold truncate">{id}</div>
</CommandItem>
</SecondaryPageLink>
)
}
function ProfileItem({ profile, onClick }: { profile: TProfile; onClick?: () => void }) {
return (
<SecondaryPageLink to={toProfile(profile.pubkey)} onClick={onClick}>
<CommandItem>
<div className="flex gap-2">
<Avatar>
<AvatarImage src={profile.avatar} alt={profile.username} />
<AvatarFallback>
<img src={generateImageByPubkey(profile.pubkey)} alt={profile.username} />
</AvatarFallback>
</Avatar>
<div>
<div className="font-semibold">{profile.username}</div>
<div className="line-clamp-1 text-muted-foreground">{profile.about}</div>
</div>
</div>
</CommandItem>
</SecondaryPageLink>
)
}

View File

@@ -3,12 +3,13 @@ import { IS_ELECTRON } from '@renderer/lib/env'
import { toHome } from '@renderer/lib/link'
import { SecondaryPageLink } from '@renderer/PageManager'
import { Info } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import AboutInfoDialog from '../AboutInfoDialog'
import AccountButton from '../AccountButton'
import PostButton from '../PostButton'
import RefreshButton from '../RefreshButton'
import RelaySettingsPopover from '../RelaySettingsPopover'
import { useTranslation } from 'react-i18next'
import SearchButton from '../SearchButton'
export default function PrimaryPageSidebar() {
const { t } = useTranslation()
@@ -20,6 +21,7 @@ export default function PrimaryPageSidebar() {
</div>
<PostButton variant="sidebar" />
<RelaySettingsPopover variant="sidebar" />
<SearchButton variant="sidebar" />
<RefreshButton variant="sidebar" />
{!IS_ELECTRON && (
<AboutInfoDialog>

View File

@@ -0,0 +1,22 @@
import FollowButton from '@renderer/components/FollowButton'
import Nip05 from '@renderer/components/Nip05'
import UserAvatar from '@renderer/components/UserAvatar'
import Username from '@renderer/components/Username'
import { useFetchProfile } from '@renderer/hooks'
export default function UserItem({ pubkey }: { pubkey: string }) {
const { profile } = useFetchProfile(pubkey)
const { nip05, about } = profile || {}
return (
<div className="flex gap-2 items-start">
<UserAvatar userId={pubkey} className="shrink-0" />
<div className="w-full overflow-hidden">
<Username userId={pubkey} className="font-semibold truncate" skeletonClassName="h-4" />
<Nip05 nip05={nip05} pubkey={pubkey} />
<div className="truncate text-muted-foreground text-sm">{about}</div>
</div>
<FollowButton pubkey={pubkey} />
</div>
)
}

View File

@@ -0,0 +1,143 @@
import { type DialogProps } from '@radix-ui/react-dialog'
import { Command as CommandPrimitive } from 'cmdk'
import { Search } from 'lucide-react'
import * as React from 'react'
import { Dialog, DialogContent } from '@renderer/components/ui/dialog'
import { ScrollArea } from '@renderer/components/ui/scroll-area'
import { cn } from '@renderer/lib/utils'
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
'flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground',
className
)}
{...props}
/>
))
Command.displayName = CommandPrimitive.displayName
const CommandDialog = ({ children, ...props }: DialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0 shadow-lg top-4 translate-y-0 data-[state=closed]:slide-out-to-top-0 data-[state=open]:slide-in-from-top-0">
<Command
shouldFilter={false}
className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5"
>
{children}
</Command>
</DialogContent>
</Dialog>
)
}
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
'flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50 pr-6',
className
)}
{...props}
/>
</div>
))
CommandInput.displayName = CommandPrimitive.Input.displayName
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<ScrollArea className="max-h-[80vh]">
<CommandPrimitive.List ref={ref} className={cn('overflow-x-hidden', className)} {...props} />
</ScrollArea>
))
CommandList.displayName = CommandPrimitive.List.displayName
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty ref={ref} className="py-6 text-center text-sm" {...props} />
))
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
'overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground',
className
)}
{...props}
/>
))
CommandGroup.displayName = CommandPrimitive.Group.displayName
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn('-mx-1 h-px bg-border', className)}
{...props}
/>
))
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-pointer gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected='true']:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
className
)}
{...props}
/>
))
CommandItem.displayName = CommandPrimitive.Item.displayName
const CommandShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn('ml-auto text-xs tracking-widest text-muted-foreground', className)}
{...props}
/>
)
}
CommandShortcut.displayName = 'CommandShortcut'
export {
Command,
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
CommandShortcut
}

View File

@@ -3,3 +3,6 @@ export * from './useFetchEventById'
export * from './useFetchFollowings'
export * from './useFetchNip05'
export * from './useFetchProfile'
export * from './useFetchRelayInfos'
export * from './useSearchParams'
export * from './useSearchProfiles'

View File

@@ -9,6 +9,7 @@ export function useFetchEventById(id?: string) {
useEffect(() => {
const fetchEvent = async () => {
setIsFetching(true)
if (!id) {
setIsFetching(false)
setError(new Error('No id provided'))

View File

@@ -9,6 +9,7 @@ export function useFetchProfile(id?: string) {
useEffect(() => {
const fetchProfile = async () => {
setIsFetching(true)
try {
if (!id) {
setIsFetching(false)

View File

@@ -0,0 +1,23 @@
import client from '@renderer/services/client.service'
import { TRelayInfo } from '@renderer/types'
import { useEffect, useState } from 'react'
export function useFetchRelayInfos(urls: string[]) {
const [relayInfos, setRelayInfos] = useState<(TRelayInfo | undefined)[]>([])
useEffect(() => {
const fetchRelayInfos = async () => {
if (urls.length === 0) return
try {
const relayInfos = await client.fetchRelayInfos(urls)
setRelayInfos(relayInfos)
} catch (err) {
console.error(err)
}
}
fetchRelayInfos()
}, [JSON.stringify(urls)])
return relayInfos
}

View File

@@ -0,0 +1,24 @@
export function useSearchParams() {
const searchParams = new URLSearchParams(window.location.search)
return {
searchParams,
get: (key: string) => searchParams.get(key),
set: (key: string, value: string) => {
searchParams.set(key, value)
window.history.replaceState(
null,
'',
`${window.location.pathname}?${searchParams.toString()}`
)
},
delete: (key: string) => {
searchParams.delete(key)
window.history.replaceState(
null,
'',
`${window.location.pathname}?${searchParams.toString()}`
)
}
}
}

View File

@@ -0,0 +1,39 @@
import { useRelaySettings } from '@renderer/providers/RelaySettingsProvider'
import client from '@renderer/services/client.service'
import { TProfile } from '@renderer/types'
import { useEffect, useState } from 'react'
export function useSearchProfiles(search: string, limit: number) {
const { searchableRelayUrls } = useRelaySettings()
const [isFetching, setIsFetching] = useState(true)
const [error, setError] = useState<Error | null>(null)
const [profiles, setProfiles] = useState<TProfile[]>([])
useEffect(() => {
const fetchProfiles = async () => {
setIsFetching(true)
setProfiles([])
if (searchableRelayUrls.length === 0) {
setIsFetching(false)
return
}
try {
const profiles = await client.fetchProfiles(searchableRelayUrls, {
search,
limit
})
if (profiles) {
setProfiles(profiles)
}
} catch (err) {
setError(err as Error)
} finally {
setIsFetching(false)
}
}
fetchProfiles()
}, [searchableRelayUrls, search, limit])
return { isFetching, error, profiles }
}

View File

@@ -62,6 +62,13 @@ export default {
'Lost in the void': 'Lost in the void',
'Carry me home': 'Carry me home',
'no replies': 'no replies',
'Reply to': 'Reply to'
'Reply to': 'Reply to',
Search: 'Search',
search: 'search',
'The relays you are connected to do not support search':
'The relays you are connected to do not support search',
'supports search': 'supports search',
'Show more...': 'Show more...',
'all users': 'all users'
}
}

View File

@@ -62,6 +62,12 @@ export default {
'Lost in the void': '迷失在虚空中',
'Carry me home': '带我回家',
'no replies': '暂无回复',
'Reply to': '回复'
'Reply to': '回复',
Search: '搜索',
search: '搜索',
'The relays you are connected to do not support search': '您连接的服务器不支持搜索',
'supports search': '支持搜索',
'Show more...': '查看更多...',
'all users': '所有用户'
}
}

View File

@@ -3,40 +3,33 @@ import PostButton from '@renderer/components/PostButton'
import RefreshButton from '@renderer/components/RefreshButton'
import RelaySettingsPopover from '@renderer/components/RelaySettingsPopover'
import ScrollToTopButton from '@renderer/components/ScrollToTopButton'
import SearchButton from '@renderer/components/SearchButton'
import { Titlebar } from '@renderer/components/Titlebar'
import { ScrollArea } from '@renderer/components/ui/scroll-area'
import { isMacOS } from '@renderer/lib/env'
import { forwardRef, useImperativeHandle, useRef } from 'react'
const PrimaryPageLayout = forwardRef(
(
{
children,
titlebarContent
}: { children?: React.ReactNode; titlebarContent?: React.ReactNode },
ref
) => {
const scrollAreaRef = useRef<HTMLDivElement>(null)
const PrimaryPageLayout = forwardRef(({ children }: { children?: React.ReactNode }, ref) => {
const scrollAreaRef = useRef<HTMLDivElement>(null)
useImperativeHandle(
ref,
() => ({
scrollToTop: () => {
scrollAreaRef.current?.scrollTo({ top: 0 })
}
}),
[]
)
useImperativeHandle(
ref,
() => ({
scrollToTop: () => {
scrollAreaRef.current?.scrollTo({ top: 0 })
}
}),
[]
)
return (
<ScrollArea ref={scrollAreaRef} className="h-full w-full" scrollBarClassName="pt-9 xl:pt-0">
<PrimaryPageTitlebar content={titlebarContent} />
<div className="px-4 pb-4 pt-11 xl:pt-4">{children}</div>
<ScrollToTopButton scrollAreaRef={scrollAreaRef} />
</ScrollArea>
)
}
)
return (
<ScrollArea ref={scrollAreaRef} className="h-full w-full" scrollBarClassName="pt-9 xl:pt-0">
<PrimaryPageTitlebar />
<div className="px-4 pb-4 pt-11 xl:pt-4">{children}</div>
<ScrollToTopButton scrollAreaRef={scrollAreaRef} />
</ScrollArea>
)
})
PrimaryPageLayout.displayName = 'PrimaryPageLayout'
export default PrimaryPageLayout
@@ -44,13 +37,13 @@ export type TPrimaryPageLayoutRef = {
scrollToTop: () => void
}
export function PrimaryPageTitlebar({ content }: { content?: React.ReactNode }) {
export function PrimaryPageTitlebar() {
return (
<Titlebar className={`justify-between xl:hidden ${isMacOS() ? 'pl-20' : ''}`}>
<div className="flex gap-2 items-center">
<AccountButton />
<PostButton />
{content}
<SearchButton />
</div>
<div className="flex gap-2 items-center">
<RefreshButton />

View File

@@ -1,7 +1,19 @@
export const toHome = () => '/'
export const toProfile = (pubkey: string) => `/user/${pubkey}`
export const toNote = (eventId: string) => `/note/${eventId}`
export const toHashtag = (hashtag: string) => `/hashtag/${hashtag}`
export const toNoteList = ({ hashtag, search }: { hashtag?: string; search?: string }) => {
const path = '/note'
const query = new URLSearchParams()
if (hashtag) query.set('t', hashtag.toLowerCase())
if (search) query.set('s', search)
return `${path}?${query.toString()}`
}
export const toProfile = (pubkey: string) => `/user/${pubkey}`
export const toProfileList = ({ search }: { search?: string }) => {
const path = '/user'
const query = new URLSearchParams()
if (search) query.set('s', search)
return `${path}?${query.toString()}`
}
export const toFollowingList = (pubkey: string) => `/user/${pubkey}/following`
export const toNoStrudelProfile = (id: string) => `https://nostrudel.ninja/#/u/${id}`

View File

@@ -1,7 +1,4 @@
import FollowButton from '@renderer/components/FollowButton'
import Nip05 from '@renderer/components/Nip05'
import UserAvatar from '@renderer/components/UserAvatar'
import Username from '@renderer/components/Username'
import UserItem from '@renderer/components/UserItem'
import { useFetchFollowings, useFetchProfile } from '@renderer/hooks'
import SecondaryPageLayout from '@renderer/layouts/SecondaryPageLayout'
import { useEffect, useRef, useState } from 'react'
@@ -56,27 +53,10 @@ export default function FollowingListPage({ id }: { id?: string }) {
>
<div className="space-y-2">
{visibleFollowings.map((pubkey, index) => (
<FollowingItem key={`${index}-${pubkey}`} pubkey={pubkey} />
<UserItem key={`${index}-${pubkey}`} pubkey={pubkey} />
))}
{followings.length > visibleFollowings.length && <div ref={bottomRef} />}
</div>
</SecondaryPageLayout>
)
}
function FollowingItem({ pubkey }: { pubkey: string }) {
const { profile } = useFetchProfile(pubkey)
const { nip05, about } = profile || {}
return (
<div className="flex gap-2 items-start">
<UserAvatar userId={pubkey} className="shrink-0" />
<div className="w-full overflow-hidden">
<Username userId={pubkey} className="font-semibold truncate" skeletonClassName="h-4" />
<Nip05 nip05={nip05} pubkey={pubkey} />
<div className="truncate text-muted-foreground text-sm">{about}</div>
</div>
<FollowButton pubkey={pubkey} />
</div>
)
}

View File

@@ -1,18 +0,0 @@
import NoteList from '@renderer/components/NoteList'
import SecondaryPageLayout from '@renderer/layouts/SecondaryPageLayout'
import { useRelaySettings } from '@renderer/providers/RelaySettingsProvider'
import NotFoundPage from '../NotFoundPage'
export default function HashtagPage({ id }: { id?: string }) {
const { relayUrls } = useRelaySettings()
if (!id) {
return <NotFoundPage />
}
const hashtag = id.toLowerCase()
return (
<SecondaryPageLayout titlebarContent={`# ${hashtag}`}>
<NoteList key={hashtag} filter={{ '#t': [hashtag] }} relayUrls={relayUrls} />
</SecondaryPageLayout>
)
}

View File

@@ -1,21 +1,14 @@
import { Button } from '@renderer/components/ui/button'
import SecondaryPageLayout from '@renderer/layouts/SecondaryPageLayout'
import { toHome } from '@renderer/lib/link'
import { useSecondaryPage } from '@renderer/PageManager'
import { useTranslation } from 'react-i18next'
export default function NotFoundPage() {
const { t } = useTranslation()
const { push } = useSecondaryPage()
return (
<SecondaryPageLayout hideBackButton>
<div className="text-muted-foreground w-full h-full flex flex-col items-center justify-center gap-2">
<div>{t('Lost in the void')} 🌌</div>
<div>(404)</div>
<Button variant="secondary" onClick={() => push(toHome())}>
{t('Carry me home')}
</Button>
</div>
</SecondaryPageLayout>
)

View File

@@ -0,0 +1,40 @@
import NoteList from '@renderer/components/NoteList'
import { useSearchParams } from '@renderer/hooks'
import SecondaryPageLayout from '@renderer/layouts/SecondaryPageLayout'
import { useRelaySettings } from '@renderer/providers/RelaySettingsProvider'
import { Filter } from 'nostr-tools'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
export default function NoteListPage() {
const { t } = useTranslation()
const { relayUrls, searchableRelayUrls } = useRelaySettings()
const { searchParams } = useSearchParams()
const [title, filter] = useMemo<[string, Filter] | [undefined, undefined]>(() => {
const hashtag = searchParams.get('t')
if (hashtag) {
return [`# ${hashtag}`, { '#t': [hashtag] }]
}
const search = searchParams.get('s')
if (search) {
return [`${t('search')}: ${search}`, { search }]
}
return [undefined, undefined]
}, [searchParams])
if (!filter || (filter.search && searchableRelayUrls.length === 0)) {
return (
<SecondaryPageLayout titlebarContent={title}>
<div className="text-center text-sm text-muted-foreground">
{t('The relays you are connected to do not support search')}
</div>
</SecondaryPageLayout>
)
}
return (
<SecondaryPageLayout titlebarContent={title}>
<NoteList key={title} filter={filter} relayUrls={relayUrls} />
</SecondaryPageLayout>
)
}

View File

@@ -0,0 +1,89 @@
import UserItem from '@renderer/components/UserItem'
import { useSearchParams } from '@renderer/hooks'
import SecondaryPageLayout from '@renderer/layouts/SecondaryPageLayout'
import { useRelaySettings } from '@renderer/providers/RelaySettingsProvider'
import client from '@renderer/services/client.service'
import dayjs from 'dayjs'
import { Filter } from 'nostr-tools'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
const LIMIT = 50
export default function ProfileListPage() {
const { t } = useTranslation()
const { searchParams } = useSearchParams()
const { relayUrls, searchableRelayUrls } = useRelaySettings()
const [until, setUntil] = useState<number>(() => dayjs().unix())
const [hasMore, setHasMore] = useState<boolean>(true)
const [pubkeySet, setPubkeySet] = useState(new Set<string>())
const observer = useRef<IntersectionObserver | null>(null)
const bottomRef = useRef<HTMLDivElement>(null)
const filter = useMemo(() => {
const f: Filter = { until }
const search = searchParams.get('s')
if (search) {
f.search = search
}
return f
}, [searchParams, until])
const urls = useMemo(() => {
return filter.search ? searchableRelayUrls : relayUrls
}, [relayUrls, searchableRelayUrls, filter])
const title = useMemo(() => {
return filter.search ? `${t('search')}: ${filter.search}` : t('all users')
}, [filter])
useEffect(() => {
if (!hasMore) return
const options = {
root: null,
rootMargin: '10px',
threshold: 1
}
observer.current = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && hasMore) {
loadMore()
}
}, options)
if (bottomRef.current) {
observer.current.observe(bottomRef.current)
}
return () => {
if (observer.current && bottomRef.current) {
observer.current.unobserve(bottomRef.current)
}
}
}, [filter, hasMore])
async function loadMore() {
if (urls.length === 0) {
return setHasMore(false)
}
const profiles = await client.fetchProfiles(urls, { ...filter, limit: LIMIT })
const newPubkeySet = new Set<string>()
profiles.forEach((profile) => {
if (!pubkeySet.has(profile.pubkey)) {
newPubkeySet.add(profile.pubkey)
}
})
setPubkeySet((prev) => new Set([...prev, ...newPubkeySet]))
setHasMore(profiles.length >= LIMIT)
const lastProfileCreatedAt = profiles[profiles.length - 1].created_at
setUntil(lastProfileCreatedAt ? lastProfileCreatedAt - 1 : 0)
}
return (
<SecondaryPageLayout titlebarContent={title}>
<div className="space-y-2">
{Array.from(pubkeySet).map((pubkey, index) => (
<UserItem key={`${index}-${pubkey}`} pubkey={pubkey} />
))}
{hasMore && <div ref={bottomRef} />}
</div>
</SecondaryPageLayout>
)
}

View File

@@ -19,11 +19,13 @@ import NotFoundPage from '../NotFoundPage'
import PubkeyCopy from './PubkeyCopy'
import QrCodePopover from './QrCodePopover'
import { useTranslation } from 'react-i18next'
import { useRelaySettings } from '@renderer/providers/RelaySettingsProvider'
export default function ProfilePage({ id }: { id?: string }) {
const { t } = useTranslation()
const { profile, isFetching } = useFetchProfile(id)
const relayList = useFetchRelayList(profile?.pubkey)
const { relayUrls: currentRelayUrls } = useRelaySettings()
const { pubkey: accountPubkey } = useNostr()
const { followings: selfFollowings } = useFollowList()
const { followings } = useFetchFollowings(profile?.pubkey)
@@ -96,7 +98,7 @@ export default function ProfilePage({ id }: { id?: string }) {
<NoteList
key={pubkey}
filter={{ authors: [pubkey] }}
relayUrls={relayList.write.slice(0, 5)}
relayUrls={relayList.write.slice(0, 5).concat(currentRelayUrls)}
/>
</SecondaryPageLayout>
)

View File

@@ -1,5 +1,6 @@
import { TRelayGroup } from '@common/types'
import { isWebsocketUrl, normalizeUrl } from '@renderer/lib/url'
import client from '@renderer/services/client.service'
import storage from '@renderer/services/storage.service'
import { createContext, Dispatch, useContext, useEffect, useState } from 'react'
@@ -7,6 +8,7 @@ type TRelaySettingsContext = {
relayGroups: TRelayGroup[]
temporaryRelayUrls: string[]
relayUrls: string[]
searchableRelayUrls: string[]
switchRelayGroup: (groupName: string) => void
renameRelayGroup: (oldGroupName: string, newGroupName: string) => string | null
deleteRelayGroup: (groupName: string) => void
@@ -33,6 +35,7 @@ export function RelaySettingsProvider({ children }: { children: React.ReactNode
? temporaryRelayUrls
: (relayGroups.find((group) => group.isActive)?.relayUrls ?? [])
)
const [searchableRelayUrls, setSearchableRelayUrls] = useState<string[]>([])
useEffect(() => {
const init = async () => {
@@ -67,6 +70,17 @@ export function RelaySettingsProvider({ children }: { children: React.ReactNode
)
}, [relayGroups, temporaryRelayUrls])
useEffect(() => {
const handler = async () => {
setSearchableRelayUrls([])
const relayInfos = await client.fetchRelayInfos(relayUrls)
setSearchableRelayUrls(
relayUrls.filter((_, index) => relayInfos[index]?.supported_nips?.includes(50))
)
}
handler()
}, [relayUrls])
const updateGroups = async (fn: (pre: TRelayGroup[]) => TRelayGroup[]) => {
let newGroups = relayGroups
setRelayGroups((pre) => {
@@ -147,6 +161,7 @@ export function RelaySettingsProvider({ children }: { children: React.ReactNode
relayGroups,
temporaryRelayUrls,
relayUrls,
searchableRelayUrls,
switchRelayGroup,
renameRelayGroup,
deleteRelayGroup,

View File

@@ -1,17 +1,19 @@
import { match } from 'path-to-regexp'
import { isValidElement } from 'react'
import FollowingListPage from './pages/secondary/FollowingListPage'
import HashtagPage from './pages/secondary/HashtagPage'
import HomePage from './pages/secondary/HomePage'
import NoteListPage from './pages/secondary/NoteListPage'
import NotePage from './pages/secondary/NotePage'
import ProfileListPage from './pages/secondary/ProfileListPage'
import ProfilePage from './pages/secondary/ProfilePage'
const ROUTES = [
{ path: '/', element: <HomePage /> },
{ path: '/note', element: <NoteListPage /> },
{ path: '/note/:id', element: <NotePage /> },
{ path: '/user', element: <ProfileListPage /> },
{ path: '/user/:id', element: <ProfilePage /> },
{ path: '/user/:id/following', element: <FollowingListPage /> },
{ path: '/hashtag/:id', element: <HashtagPage /> }
{ path: '/user/:id/following', element: <FollowingListPage /> }
]
export const routes = ROUTES.map(({ path, element }) => ({

View File

@@ -3,7 +3,7 @@ import { isReplyNoteEvent } from '@renderer/lib/event'
import { formatPubkey } from '@renderer/lib/pubkey'
import { tagNameEquals } from '@renderer/lib/tag'
import { isWebsocketUrl, normalizeUrl } from '@renderer/lib/url'
import { TProfile, TRelayList } from '@renderer/types'
import { TProfile, TRelayInfo, TRelayList } from '@renderer/types'
import DataLoader from 'dataloader'
import { LRUCache } from 'lru-cache'
import {
@@ -55,6 +55,19 @@ class ClientService {
cacheMap: new LRUCache<string, Promise<TRelayList>>({ max: 10000 })
}
)
private relayInfoDataLoader = new DataLoader<string, TRelayInfo | undefined>(async (urls) => {
return await Promise.all(
urls.map(async (url) => {
return (await (
await fetch(url.replace('ws://', 'http://').replace('wss://', 'https://'), {
headers: { Accept: 'application/nostr+json' }
})
)
.json()
.catch(() => undefined)) as TRelayInfo | undefined
})
)
})
private followListCache = new LRUCache<string, Promise<NEvent | undefined>>({
max: 10000,
fetchMethod: this._fetchFollowListEvent.bind(this)
@@ -329,6 +342,19 @@ class ClientService {
return this.profileDataloader.load(id)
}
async fetchProfiles(relayUrls: string[], filter: Filter): Promise<TProfile[]> {
const events = await this.pool.querySync(relayUrls, {
...filter,
kinds: [kinds.Metadata]
})
const profiles = events
.sort((a, b) => b.created_at - a.created_at)
.map((event) => this.parseProfileFromEvent(event))
profiles.forEach((profile) => this.profileDataloader.prime(profile.pubkey, profile))
return profiles
}
async fetchRelayList(pubkey: string): Promise<TRelayList> {
return this.relayListDataLoader.load(pubkey)
}
@@ -341,6 +367,11 @@ class ClientService {
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))
}
private async fetchEventById(relayUrls: string[], id: string): Promise<NEvent | undefined> {
const event = await this.fetchEventFromBigRelaysDataloader.load(id)
if (event) {
@@ -561,7 +592,8 @@ class ClientService {
profileObj.nip05?.split('@')[0]?.trim() ||
formatPubkey(event.pubkey),
nip05: profileObj.nip05,
about: profileObj.about
about: profileObj.about,
created_at: event.created_at
}
} catch (err) {
console.error(err)

View File

@@ -5,9 +5,14 @@ export type TProfile = {
avatar?: string
nip05?: string
about?: string
created_at?: number
}
export type TRelayList = {
write: string[]
read: string[]
}
export type TRelayInfo = {
supported_nips?: number[]
}