31 changed files with 1076 additions and 96 deletions
@ -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} /> |
||||||
|
</> |
||||||
|
) |
||||||
|
} |
||||||
@ -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> |
||||||
|
) |
||||||
|
} |
||||||
@ -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> |
||||||
|
) |
||||||
|
} |
||||||
@ -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 |
||||||
|
} |
||||||
@ -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 |
||||||
|
} |
||||||
@ -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()}` |
||||||
|
) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -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 } |
||||||
|
} |
||||||
@ -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> |
|
||||||
) |
|
||||||
} |
|
||||||
@ -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> |
||||||
|
) |
||||||
|
} |
||||||
@ -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> |
||||||
|
) |
||||||
|
} |
||||||
Loading…
Reference in new issue