refactor: search
This commit is contained in:
@@ -23,6 +23,7 @@ import MePage from './pages/primary/MePage'
|
||||
import NotificationListPage from './pages/primary/NotificationListPage'
|
||||
import ProfilePage from './pages/primary/ProfilePage'
|
||||
import RelayPage from './pages/primary/RelayPage'
|
||||
import SearchPage from './pages/primary/SearchPage'
|
||||
import { NotificationProvider } from './providers/NotificationProvider'
|
||||
import { useScreenSize } from './providers/ScreenSizeProvider'
|
||||
import { routes } from './routes'
|
||||
@@ -55,7 +56,8 @@ const PRIMARY_PAGE_REF_MAP = {
|
||||
notifications: createRef<TPageRef>(),
|
||||
me: createRef<TPageRef>(),
|
||||
profile: createRef<TPageRef>(),
|
||||
relay: createRef<TPageRef>()
|
||||
relay: createRef<TPageRef>(),
|
||||
search: createRef<TPageRef>()
|
||||
}
|
||||
|
||||
const PRIMARY_PAGE_MAP = {
|
||||
@@ -64,7 +66,8 @@ const PRIMARY_PAGE_MAP = {
|
||||
notifications: <NotificationListPage ref={PRIMARY_PAGE_REF_MAP.notifications} />,
|
||||
me: <MePage ref={PRIMARY_PAGE_REF_MAP.me} />,
|
||||
profile: <ProfilePage ref={PRIMARY_PAGE_REF_MAP.profile} />,
|
||||
relay: <RelayPage ref={PRIMARY_PAGE_REF_MAP.relay} />
|
||||
relay: <RelayPage ref={PRIMARY_PAGE_REF_MAP.relay} />,
|
||||
search: <SearchPage ref={PRIMARY_PAGE_REF_MAP.search} />
|
||||
}
|
||||
|
||||
const PrimaryPageContext = createContext<TPrimaryPageContext | undefined>(undefined)
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useSecondaryPage } from '@/PageManager'
|
||||
import { ChevronLeft } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function BackButton({ children }: { children?: React.ReactNode }) {
|
||||
const { t } = useTranslation()
|
||||
const { pop } = useSecondaryPage()
|
||||
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
68
src/components/ProfileListBySearch/index.tsx
Normal file
68
src/components/ProfileListBySearch/index.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { SEARCHABLE_RELAY_URLS } from '@/constants'
|
||||
import client from '@/services/client.service'
|
||||
import dayjs from 'dayjs'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import UserItem from '../UserItem'
|
||||
|
||||
const LIMIT = 50
|
||||
|
||||
export function ProfileListBySearch({ search }: { search: string }) {
|
||||
const [until, setUntil] = useState<number>(() => dayjs().unix())
|
||||
const [hasMore, setHasMore] = useState<boolean>(true)
|
||||
const [pubkeySet, setPubkeySet] = useState(new Set<string>())
|
||||
const bottomRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasMore) return
|
||||
const options = {
|
||||
root: null,
|
||||
rootMargin: '10px',
|
||||
threshold: 1
|
||||
}
|
||||
|
||||
const observerInstance = new IntersectionObserver((entries) => {
|
||||
if (entries[0].isIntersecting && hasMore) {
|
||||
loadMore()
|
||||
}
|
||||
}, options)
|
||||
|
||||
const currentBottomRef = bottomRef.current
|
||||
|
||||
if (currentBottomRef) {
|
||||
observerInstance.observe(currentBottomRef)
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (observerInstance && currentBottomRef) {
|
||||
observerInstance.unobserve(currentBottomRef)
|
||||
}
|
||||
}
|
||||
}, [hasMore, search, until])
|
||||
|
||||
async function loadMore() {
|
||||
const profiles = await client.searchProfiles(SEARCHABLE_RELAY_URLS, {
|
||||
search,
|
||||
until,
|
||||
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 (
|
||||
<div className="px-4">
|
||||
{Array.from(pubkeySet).map((pubkey, index) => (
|
||||
<UserItem key={`${index}-${pubkey}`} pubkey={pubkey} />
|
||||
))}
|
||||
{hasMore && <div ref={bottomRef} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import RelayInfo from '@/components/RelayInfo'
|
||||
import SearchInput from '@/components/SearchInput'
|
||||
import { useFetchRelayInfo } from '@/hooks'
|
||||
import { normalizeUrl } from '@/lib/url'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import NotFound from '../NotFound'
|
||||
@@ -29,7 +30,7 @@ export default function Relay({ url, className }: { url?: string; className?: st
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className={cn('pt-3', className)}>
|
||||
<RelayInfo url={normalizedUrl} />
|
||||
{relayInfo?.supported_nips?.includes(50) && (
|
||||
<div className="px-4 py-2">
|
||||
|
||||
298
src/components/SearchBar/index.tsx
Normal file
298
src/components/SearchBar/index.tsx
Normal file
@@ -0,0 +1,298 @@
|
||||
import Nip05 from '@/components/Nip05'
|
||||
import SearchInput from '@/components/SearchInput'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import UserAvatar from '@/components/UserAvatar'
|
||||
import Username from '@/components/Username'
|
||||
import { useSearchProfiles } from '@/hooks'
|
||||
import { toNote } from '@/lib/link'
|
||||
import { randomString } from '@/lib/random'
|
||||
import { normalizeUrl } from '@/lib/url'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useSecondaryPage } from '@/PageManager'
|
||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||
import modalManager from '@/services/modal-manager.service'
|
||||
import { TProfile, TSearchParams } from '@/types'
|
||||
import { Hash, Notebook, Server, UserRound } from 'lucide-react'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
import {
|
||||
forwardRef,
|
||||
HTMLAttributes,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const SearchBar = forwardRef<
|
||||
TSearchBarRef,
|
||||
{
|
||||
input: string
|
||||
setInput: (input: string) => void
|
||||
onSearch: (params: TSearchParams | null) => void
|
||||
}
|
||||
>(({ input, setInput, onSearch }, ref) => {
|
||||
const { t } = useTranslation()
|
||||
const { push } = useSecondaryPage()
|
||||
const { isSmallScreen } = useScreenSize()
|
||||
const [debouncedInput, setDebouncedInput] = useState(input)
|
||||
const { profiles } = useSearchProfiles(debouncedInput, 10)
|
||||
const [searching, setSearching] = useState(false)
|
||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||
const normalizedUrl = useMemo(() => {
|
||||
if (['w', 'ws', 'ws:', 'ws:/', 'wss', 'wss:', 'wss:/'].includes(input)) {
|
||||
return undefined
|
||||
}
|
||||
try {
|
||||
return normalizeUrl(input)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}, [input])
|
||||
const id = useMemo(() => `search-${randomString()}`, [])
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
focus: () => {
|
||||
searchInputRef.current?.focus()
|
||||
},
|
||||
blur: () => {
|
||||
searchInputRef.current?.blur()
|
||||
}
|
||||
}))
|
||||
|
||||
useEffect(() => {
|
||||
if (!input) {
|
||||
onSearch(null)
|
||||
}
|
||||
}, [input])
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedInput(input)
|
||||
}, 500)
|
||||
|
||||
return () => {
|
||||
clearTimeout(handler)
|
||||
}
|
||||
}, [input])
|
||||
|
||||
const blur = () => {
|
||||
setSearching(false)
|
||||
searchInputRef.current?.blur()
|
||||
}
|
||||
|
||||
const startSearch = () => {
|
||||
setSearching(true)
|
||||
}
|
||||
|
||||
const list = useMemo(() => {
|
||||
const search = input.trim()
|
||||
if (!search) return null
|
||||
|
||||
const updateSearch = (params: TSearchParams) => {
|
||||
blur()
|
||||
onSearch(params)
|
||||
}
|
||||
|
||||
if (/^[0-9a-f]{64}$/.test(search)) {
|
||||
return (
|
||||
<>
|
||||
<NoteItem
|
||||
id={search}
|
||||
onClick={() => {
|
||||
blur()
|
||||
push(toNote(search))
|
||||
}}
|
||||
/>
|
||||
<ProfileIdItem id={search} onClick={() => updateSearch({ type: 'profile', search })} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
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={() => updateSearch({ type: 'profile', search: id })} />
|
||||
)
|
||||
}
|
||||
if (['nevent', 'naddr', 'note'].includes(type)) {
|
||||
return (
|
||||
<NoteItem
|
||||
id={id}
|
||||
onClick={() => {
|
||||
blur()
|
||||
push(toNote(id))
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<NormalItem search={search} onClick={() => updateSearch({ type: 'notes', search })} />
|
||||
<HashtagItem
|
||||
search={search}
|
||||
onClick={() => updateSearch({ type: 'hashtag', search, input: `#${search}` })}
|
||||
/>
|
||||
{!!normalizedUrl && (
|
||||
<RelayItem
|
||||
url={normalizedUrl}
|
||||
onClick={() => updateSearch({ type: 'relay', search, input: normalizedUrl })}
|
||||
/>
|
||||
)}
|
||||
{profiles.map((profile) => (
|
||||
<ProfileItem
|
||||
key={profile.pubkey}
|
||||
profile={profile}
|
||||
onClick={() =>
|
||||
updateSearch({ type: 'profile', search: profile.npub, input: profile.username })
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{profiles.length >= 10 && (
|
||||
<Item onClick={() => updateSearch({ type: 'profiles', search })}>
|
||||
<div className="font-semibold">{t('Show more...')}</div>
|
||||
</Item>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}, [input, debouncedInput, profiles])
|
||||
|
||||
const showList = useMemo(() => searching && !!list, [searching, list])
|
||||
|
||||
useEffect(() => {
|
||||
if (showList) {
|
||||
modalManager.register(id, () => {
|
||||
blur()
|
||||
})
|
||||
} else {
|
||||
modalManager.unregister(id)
|
||||
}
|
||||
}, [showList])
|
||||
|
||||
return (
|
||||
<div className="relative flex gap-1 items-center h-full w-full">
|
||||
{showList && (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
'bg-surface-background rounded-b-lg shadow-lg',
|
||||
isSmallScreen
|
||||
? 'fixed top-12 inset-x-0'
|
||||
: 'absolute top-full -translate-y-1 inset-x-0 pt-1 ',
|
||||
searching ? 'z-50' : ''
|
||||
)}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<ScrollArea className="h-[60vh]">{list}</ScrollArea>
|
||||
</div>
|
||||
<div className="fixed inset-0 w-full h-full" onClick={() => blur()} />
|
||||
</>
|
||||
)}
|
||||
<SearchInput
|
||||
ref={searchInputRef}
|
||||
className={cn(
|
||||
'bg-surface-background shadow-inner h-full border-none',
|
||||
searching ? 'z-50' : ''
|
||||
)}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onFocus={() => startSearch()}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
SearchBar.displayName = 'SearchBar'
|
||||
export default SearchBar
|
||||
|
||||
export type TSearchBarRef = {
|
||||
focus: () => void
|
||||
blur: () => void
|
||||
}
|
||||
|
||||
function NormalItem({ search, onClick }: { search: string; onClick?: () => void }) {
|
||||
return (
|
||||
<Item onClick={onClick}>
|
||||
<Notebook className="text-muted-foreground" />
|
||||
<div className="font-semibold truncate">{search}</div>
|
||||
</Item>
|
||||
)
|
||||
}
|
||||
|
||||
function HashtagItem({ search, onClick }: { search: string; onClick?: () => void }) {
|
||||
const hashtag = search.match(/[\p{L}\p{N}\p{M}]+/u)?.[0].toLowerCase()
|
||||
return (
|
||||
<Item onClick={onClick}>
|
||||
<Hash className="text-muted-foreground" />
|
||||
<div className="font-semibold truncate">{hashtag}</div>
|
||||
</Item>
|
||||
)
|
||||
}
|
||||
|
||||
function NoteItem({ id, onClick }: { id: string; onClick?: () => void }) {
|
||||
return (
|
||||
<Item onClick={onClick}>
|
||||
<Notebook className="text-muted-foreground" />
|
||||
<div className="font-semibold truncate">{id}</div>
|
||||
</Item>
|
||||
)
|
||||
}
|
||||
|
||||
function ProfileIdItem({ id, onClick }: { id: string; onClick?: () => void }) {
|
||||
return (
|
||||
<Item onClick={onClick}>
|
||||
<UserRound className="text-muted-foreground" />
|
||||
<div className="font-semibold truncate">{id}</div>
|
||||
</Item>
|
||||
)
|
||||
}
|
||||
|
||||
function ProfileItem({ profile, onClick }: { profile: TProfile; onClick?: () => void }) {
|
||||
return (
|
||||
<div className="p-2 hover:bg-accent rounded-md cursor-pointer" onClick={onClick}>
|
||||
<div className="flex gap-2 items-center pointer-events-none h-11">
|
||||
<UserAvatar userId={profile.pubkey} className="shrink-0" />
|
||||
<div className="w-full overflow-hidden">
|
||||
<Username
|
||||
userId={profile.pubkey}
|
||||
className="font-semibold truncate max-w-full w-fit"
|
||||
skeletonClassName="h-4"
|
||||
/>
|
||||
<Nip05 pubkey={profile.pubkey} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RelayItem({ url, onClick }: { url: string; onClick?: () => void }) {
|
||||
return (
|
||||
<Item onClick={onClick}>
|
||||
<Server className="text-muted-foreground" />
|
||||
<div className="font-semibold truncate">{url}</div>
|
||||
</Item>
|
||||
)
|
||||
}
|
||||
|
||||
function Item({ className, children, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex gap-2 items-center px-2 py-3 hover:bg-accent rounded-md cursor-pointer',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,171 +0,0 @@
|
||||
import { SecondaryPageLink } from '@/PageManager'
|
||||
import { CommandDialog, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
|
||||
import { useSearchProfiles } from '@/hooks'
|
||||
import { toNote, toNoteList, toProfile, toProfileList, toRelay } from '@/lib/link'
|
||||
import { normalizeUrl } from '@/lib/url'
|
||||
import { TProfile } from '@/types'
|
||||
import { Hash, Notebook, Server, UserRound } from 'lucide-react'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
import { Dispatch, useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Nip05 from '../Nip05'
|
||||
import UserAvatar from '../UserAvatar'
|
||||
import Username from '../Username'
|
||||
|
||||
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 normalizedUrl = useMemo(() => {
|
||||
if (['w', 'ws', 'ws:', 'ws:/', 'wss', 'wss:', 'wss:/'].includes(input)) {
|
||||
return undefined
|
||||
}
|
||||
try {
|
||||
return normalizeUrl(input)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}, [input])
|
||||
|
||||
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)} />
|
||||
{!!normalizedUrl && <RelayItem url={normalizedUrl} 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 value="show-more" onClick={() => setOpen(false)} className="text-center">
|
||||
<div className="font-semibold">{t('Show more...')}</div>
|
||||
</CommandItem>
|
||||
</SecondaryPageLink>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}, [input, debouncedInput, profiles, setOpen])
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedInput(input)
|
||||
}, 500)
|
||||
|
||||
return () => {
|
||||
clearTimeout(handler)
|
||||
}
|
||||
}, [input])
|
||||
|
||||
return (
|
||||
<CommandDialog open={open} onOpenChange={setOpen} classNames={{ content: 'max-sm:top-0' }}>
|
||||
<CommandInput value={input} onValueChange={setInput} />
|
||||
<CommandList scrollAreaClassName="max-h-[80vh]">{list}</CommandList>
|
||||
</CommandDialog>
|
||||
)
|
||||
}
|
||||
|
||||
function NormalItem({ search, onClick }: { search: string; onClick?: () => void }) {
|
||||
return (
|
||||
<SecondaryPageLink to={toNoteList({ search })} onClick={onClick}>
|
||||
<CommandItem value={`search-${search}`}>
|
||||
<Notebook className="text-muted-foreground" />
|
||||
<div className="font-semibold truncate">{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 value={`note-id-${id}`}>
|
||||
<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 value={`profile-id-${id}`}>
|
||||
<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 value={`profile-${profile.pubkey}`}>
|
||||
<div className="flex gap-2 items-center pointer-events-none">
|
||||
<UserAvatar userId={profile.pubkey} className="shrink-0" />
|
||||
<div className="w-full overflow-hidden">
|
||||
<Username
|
||||
userId={profile.pubkey}
|
||||
className="font-semibold truncate max-w-full w-fit"
|
||||
skeletonClassName="h-4"
|
||||
/>
|
||||
<Nip05 pubkey={profile.pubkey} />
|
||||
</div>
|
||||
</div>
|
||||
</CommandItem>
|
||||
</SecondaryPageLink>
|
||||
)
|
||||
}
|
||||
|
||||
function RelayItem({ url, onClick }: { url: string; onClick?: () => void }) {
|
||||
return (
|
||||
<SecondaryPageLink to={toRelay(url)} onClick={onClick}>
|
||||
<CommandItem value={`relay-${url}`}>
|
||||
<Server className="text-muted-foreground" />
|
||||
<div className="font-semibold truncate">{url}</div>
|
||||
</CommandItem>
|
||||
</SecondaryPageLink>
|
||||
)
|
||||
}
|
||||
@@ -1,33 +1,54 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
import { SearchIcon, X } from 'lucide-react'
|
||||
import { ComponentProps, useEffect, useState } from 'react'
|
||||
import { ComponentProps, forwardRef, useEffect, useState } from 'react'
|
||||
|
||||
export default function SearchInput({ value, onChange, ...props }: ComponentProps<'input'>) {
|
||||
const SearchInput = forwardRef<HTMLInputElement, ComponentProps<'input'>>(
|
||||
({ value, onChange, className, ...props }, ref) => {
|
||||
const [displayClear, setDisplayClear] = useState(false)
|
||||
const [inputRef, setInputRef] = useState<HTMLInputElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setDisplayClear(!!value)
|
||||
}, [value])
|
||||
|
||||
function setRefs(el: HTMLInputElement) {
|
||||
setInputRef(el)
|
||||
if (typeof ref === 'function') {
|
||||
ref(el)
|
||||
} else if (ref) {
|
||||
;(ref as React.MutableRefObject<HTMLInputElement | null>).current = el
|
||||
}
|
||||
}
|
||||
|
||||
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'
|
||||
'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',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<SearchIcon className="size-4 shrink-0 opacity-50" />
|
||||
<SearchIcon className="size-4 shrink-0 opacity-50" onClick={() => inputRef?.focus()} />
|
||||
<input
|
||||
{...props}
|
||||
name="search-input"
|
||||
ref={setRefs}
|
||||
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
|
||||
type="button"
|
||||
className="rounded-full bg-foreground/40 hover:bg-foreground transition-opacity size-5 shrink-0 flex flex-col items-center justify-center"
|
||||
onClick={() => onChange?.({ target: { value: '' } } as any)}
|
||||
>
|
||||
<X className="!size-3 shrink-0 text-background" strokeWidth={4} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
SearchInput.displayName = 'SearchInput'
|
||||
export default SearchInput
|
||||
|
||||
34
src/components/SearchResult/index.tsx
Normal file
34
src/components/SearchResult/index.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { BIG_RELAY_URLS, SEARCHABLE_RELAY_URLS } from '@/constants'
|
||||
import { TSearchParams } from '@/types'
|
||||
import NormalFeed from '../NormalFeed'
|
||||
import Profile from '../Profile'
|
||||
import { ProfileListBySearch } from '../ProfileListBySearch'
|
||||
import Relay from '../Relay'
|
||||
import TrendingNotes from '../TrendingNotes'
|
||||
|
||||
export default function SearchResult({ searchParams }: { searchParams: TSearchParams | null }) {
|
||||
if (!searchParams) {
|
||||
return <TrendingNotes />
|
||||
}
|
||||
if (searchParams.type === 'profile') {
|
||||
return <Profile id={searchParams.search} />
|
||||
}
|
||||
if (searchParams.type === 'profiles') {
|
||||
return <ProfileListBySearch search={searchParams.search} />
|
||||
}
|
||||
if (searchParams.type === 'notes') {
|
||||
return (
|
||||
<NormalFeed
|
||||
subRequests={[{ urls: SEARCHABLE_RELAY_URLS, filter: { search: searchParams.search } }]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (searchParams.type === 'hashtag') {
|
||||
return (
|
||||
<NormalFeed
|
||||
subRequests={[{ urls: BIG_RELAY_URLS, filter: { '#t': [searchParams.search] } }]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return <Relay url={searchParams.search} />
|
||||
}
|
||||
@@ -1,24 +1,17 @@
|
||||
import { usePrimaryPage } from '@/PageManager'
|
||||
import { Search } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { SearchDialog } from '../SearchDialog'
|
||||
import SidebarItem from './SidebarItem'
|
||||
|
||||
export default function SearchButton() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const { navigate, current, display } = usePrimaryPage()
|
||||
|
||||
return (
|
||||
<>
|
||||
<SidebarItem
|
||||
title="Search"
|
||||
description="Search"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setOpen(true)
|
||||
}}
|
||||
onClick={() => navigate('search')}
|
||||
active={current === 'search' && display}
|
||||
>
|
||||
<Search strokeWidth={3} />
|
||||
</SidebarItem>
|
||||
<SearchDialog open={open} setOpen={setOpen} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
92
src/components/TrendingNotes/index.tsx
Normal file
92
src/components/TrendingNotes/index.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import NoteCard, { NoteCardLoadingSkeleton } from '@/components/NoteCard'
|
||||
import { getReplaceableCoordinateFromEvent, isReplaceableEvent } from '@/lib/event'
|
||||
import { useDeletedEvent } from '@/providers/DeletedEventProvider'
|
||||
import { useUserTrust } from '@/providers/UserTrustProvider'
|
||||
import client from '@/services/client.service'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const SHOW_COUNT = 10
|
||||
|
||||
export default function TrendingNotes() {
|
||||
const { t } = useTranslation()
|
||||
const { isEventDeleted } = useDeletedEvent()
|
||||
const { hideUntrustedNotes, isUserTrusted } = useUserTrust()
|
||||
const [trendingNotes, setTrendingNotes] = useState<NostrEvent[]>([])
|
||||
const [showCount, setShowCount] = useState(10)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const bottomRef = useRef<HTMLDivElement>(null)
|
||||
const filteredEvents = useMemo(() => {
|
||||
const idSet = new Set<string>()
|
||||
|
||||
return trendingNotes.slice(0, showCount).filter((evt) => {
|
||||
if (isEventDeleted(evt)) return false
|
||||
if (hideUntrustedNotes && !isUserTrusted(evt.pubkey)) return false
|
||||
|
||||
const id = isReplaceableEvent(evt.kind) ? getReplaceableCoordinateFromEvent(evt) : evt.id
|
||||
if (idSet.has(id)) {
|
||||
return false
|
||||
}
|
||||
idSet.add(id)
|
||||
return true
|
||||
})
|
||||
}, [trendingNotes, hideUntrustedNotes, showCount, isEventDeleted])
|
||||
|
||||
useEffect(() => {
|
||||
const fetchTrendingPosts = async () => {
|
||||
setLoading(true)
|
||||
const events = await client.fetchTrendingNotes()
|
||||
setTrendingNotes(events)
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
fetchTrendingPosts()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (showCount >= trendingNotes.length) return
|
||||
|
||||
const options = {
|
||||
root: null,
|
||||
rootMargin: '10px',
|
||||
threshold: 0.1
|
||||
}
|
||||
|
||||
const observerInstance = new IntersectionObserver((entries) => {
|
||||
if (entries[0].isIntersecting) {
|
||||
setShowCount((prev) => prev + SHOW_COUNT)
|
||||
}
|
||||
}, options)
|
||||
|
||||
const currentBottomRef = bottomRef.current
|
||||
|
||||
if (currentBottomRef) {
|
||||
observerInstance.observe(currentBottomRef)
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (observerInstance && currentBottomRef) {
|
||||
observerInstance.unobserve(currentBottomRef)
|
||||
}
|
||||
}
|
||||
}, [loading, trendingNotes, showCount])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<div className="sticky top-12 h-12 px-4 flex flex-col justify-center text-lg font-bold bg-background z-30 border-b">
|
||||
{t('Trending Notes')}
|
||||
</div>
|
||||
{filteredEvents.map((event) => (
|
||||
<NoteCard key={event.id} className="w-full" event={event} />
|
||||
))}
|
||||
{showCount < trendingNotes.length || loading ? (
|
||||
<div ref={bottomRef}>
|
||||
<NoteCardLoadingSkeleton />
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center text-sm text-muted-foreground mt-2">{t('no more notes')}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -24,7 +24,7 @@ const buttonVariants = cva(
|
||||
sm: 'h-8 rounded-md px-3 text-xs',
|
||||
lg: 'h-10 rounded-md px-8',
|
||||
icon: 'h-9 w-9',
|
||||
'titlebar-icon': 'h-10 w-10 rounded-lg [&_svg]:size-5'
|
||||
'titlebar-icon': 'h-10 w-10 shrink-0 rounded-lg [&_svg]:size-5'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
@@ -93,7 +93,9 @@ const PrimaryPageLayout = forwardRef(
|
||||
scrollBarClassName="z-50 pt-12"
|
||||
ref={scrollAreaRef}
|
||||
>
|
||||
<PrimaryPageTitlebar>{titlebar}</PrimaryPageTitlebar>
|
||||
<PrimaryPageTitlebar hideBottomBorder={hideTitlebarBottomBorder}>
|
||||
{titlebar}
|
||||
</PrimaryPageTitlebar>
|
||||
{children}
|
||||
<div className="h-4" />
|
||||
</ScrollArea>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import BackButton from '@/components/BackButton'
|
||||
import ScrollToTopButton from '@/components/ScrollToTopButton'
|
||||
import { Titlebar } from '@/components/Titlebar'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { useSecondaryPage } from '@/PageManager'
|
||||
import { DeepBrowsingProvider } from '@/providers/DeepBrowsingProvider'
|
||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||
import { ChevronLeft } from 'lucide-react'
|
||||
import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const SecondaryPageLayout = forwardRef(
|
||||
(
|
||||
@@ -16,7 +18,8 @@ const SecondaryPageLayout = forwardRef(
|
||||
controls,
|
||||
hideBackButton = false,
|
||||
hideTitlebarBottomBorder = false,
|
||||
displayScrollToTopButton = false
|
||||
displayScrollToTopButton = false,
|
||||
titlebar
|
||||
}: {
|
||||
children?: React.ReactNode
|
||||
index?: number
|
||||
@@ -25,6 +28,7 @@ const SecondaryPageLayout = forwardRef(
|
||||
hideBackButton?: boolean
|
||||
hideTitlebarBottomBorder?: boolean
|
||||
displayScrollToTopButton?: boolean
|
||||
titlebar?: React.ReactNode
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
@@ -67,6 +71,7 @@ const SecondaryPageLayout = forwardRef(
|
||||
controls={controls}
|
||||
hideBackButton={hideBackButton}
|
||||
hideBottomBorder={hideTitlebarBottomBorder}
|
||||
titlebar={titlebar}
|
||||
/>
|
||||
{children}
|
||||
</div>
|
||||
@@ -87,6 +92,7 @@ const SecondaryPageLayout = forwardRef(
|
||||
controls={controls}
|
||||
hideBackButton={hideBackButton}
|
||||
hideBottomBorder={hideTitlebarBottomBorder}
|
||||
titlebar={titlebar}
|
||||
/>
|
||||
{children}
|
||||
<div className="h-4" />
|
||||
@@ -103,13 +109,22 @@ export function SecondaryPageTitlebar({
|
||||
title,
|
||||
controls,
|
||||
hideBackButton = false,
|
||||
hideBottomBorder = false
|
||||
hideBottomBorder = false,
|
||||
titlebar
|
||||
}: {
|
||||
title?: React.ReactNode
|
||||
controls?: React.ReactNode
|
||||
hideBackButton?: boolean
|
||||
hideBottomBorder?: boolean
|
||||
titlebar?: React.ReactNode
|
||||
}): JSX.Element {
|
||||
if (titlebar) {
|
||||
return (
|
||||
<Titlebar className="p-1" hideBottomBorder={hideBottomBorder}>
|
||||
{titlebar}
|
||||
</Titlebar>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Titlebar
|
||||
className="flex gap-1 p-1 items-center justify-between font-semibold"
|
||||
@@ -128,3 +143,21 @@ export function SecondaryPageTitlebar({
|
||||
</Titlebar>
|
||||
)
|
||||
}
|
||||
|
||||
function BackButton({ children }: { children?: React.ReactNode }) {
|
||||
const { t } = useTranslation()
|
||||
const { pop } = useSecondaryPage()
|
||||
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Event, nip19 } from 'nostr-tools'
|
||||
import { getNoteBech32Id } from './event'
|
||||
import { TSearchParams } from '@/types'
|
||||
|
||||
export const toHome = () => '/'
|
||||
export const toNote = (eventOrId: Event | string) => {
|
||||
@@ -51,6 +52,16 @@ export const toOthersRelaySettings = (pubkey: string) => {
|
||||
const npub = nip19.npubEncode(pubkey)
|
||||
return `/users/${npub}/relays`
|
||||
}
|
||||
export const toSearch = (params?: TSearchParams) => {
|
||||
if (!params) return '/search'
|
||||
const query = new URLSearchParams()
|
||||
query.set('t', params.type)
|
||||
query.set('q', params.search)
|
||||
if (params.input) {
|
||||
query.set('i', params.input)
|
||||
}
|
||||
return `/search?${query.toString()}`
|
||||
}
|
||||
export const toSettings = () => '/settings'
|
||||
export const toRelaySettings = (tag?: 'mailbox' | 'favorite-relays') => {
|
||||
return '/settings/relays' + (tag ? '#' + tag : '')
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import { SearchDialog } from '@/components/SearchDialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Search } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
|
||||
export default function SearchButton() {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button variant="ghost" size="titlebar-icon" onClick={() => setOpen(true)}>
|
||||
<Search />
|
||||
</Button>
|
||||
<SearchDialog open={open} setOpen={setOpen} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,18 +1,19 @@
|
||||
import { useSecondaryPage } from '@/PageManager'
|
||||
import BookmarkList from '@/components/BookmarkList'
|
||||
import PostEditor from '@/components/PostEditor'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import PrimaryPageLayout from '@/layouts/PrimaryPageLayout'
|
||||
import { toSearch } from '@/lib/link'
|
||||
import { useFeed } from '@/providers/FeedProvider'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||
import { TPageRef } from '@/types'
|
||||
import { PencilLine } from 'lucide-react'
|
||||
import { PencilLine, Search } from 'lucide-react'
|
||||
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import FeedButton from './FeedButton'
|
||||
import FollowingFeed from './FollowingFeed'
|
||||
import RelaysFeed from './RelaysFeed'
|
||||
import SearchButton from './SearchButton'
|
||||
|
||||
const NoteListPage = forwardRef((_, ref) => {
|
||||
const { t } = useTranslation()
|
||||
@@ -76,10 +77,12 @@ function NoteListPageTitlebar() {
|
||||
return (
|
||||
<div className="flex gap-1 items-center h-full justify-between">
|
||||
<FeedButton className="flex-1 max-w-fit w-0" />
|
||||
{isSmallScreen && (
|
||||
<div className="shrink-0 flex gap-1 items-center">
|
||||
<SearchButton />
|
||||
{isSmallScreen && <PostButton />}
|
||||
<PostButton />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -106,3 +109,13 @@ function PostButton() {
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function SearchButton() {
|
||||
const { push } = useSecondaryPage()
|
||||
|
||||
return (
|
||||
<Button variant="ghost" size="titlebar-icon" onClick={() => push(toSearch())}>
|
||||
<Search />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ const RelayPage = forwardRef(({ url }: { url?: string }, ref) => {
|
||||
displayScrollToTopButton
|
||||
ref={ref}
|
||||
>
|
||||
<Relay url={normalizedUrl} className="pt-3" />
|
||||
<Relay url={normalizedUrl} />
|
||||
</PrimaryPageLayout>
|
||||
)
|
||||
})
|
||||
|
||||
42
src/pages/primary/SearchPage/index.tsx
Normal file
42
src/pages/primary/SearchPage/index.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import SearchBar, { TSearchBarRef } from '@/components/SearchBar'
|
||||
import SearchResult from '@/components/SearchResult'
|
||||
import PrimaryPageLayout from '@/layouts/PrimaryPageLayout'
|
||||
import { usePrimaryPage } from '@/PageManager'
|
||||
import { TSearchParams } from '@/types'
|
||||
import { forwardRef, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
const SearchPage = forwardRef((_, ref) => {
|
||||
const { current, display } = usePrimaryPage()
|
||||
const [input, setInput] = useState('')
|
||||
const [searchParams, setSearchParams] = useState<TSearchParams | null>(null)
|
||||
const searchBarRef = useRef<TSearchBarRef>(null)
|
||||
const isActive = useMemo(() => current === 'search' && display, [current, display])
|
||||
|
||||
useEffect(() => {
|
||||
if (isActive) {
|
||||
searchBarRef.current?.focus()
|
||||
}
|
||||
}, [isActive])
|
||||
|
||||
const onSearch = (params: TSearchParams | null) => {
|
||||
setSearchParams(params)
|
||||
if (params?.input) {
|
||||
setInput(params.input)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<PrimaryPageLayout
|
||||
ref={ref}
|
||||
pageName="search"
|
||||
titlebar={
|
||||
<SearchBar ref={searchBarRef} onSearch={onSearch} input={input} setInput={setInput} />
|
||||
}
|
||||
displayScrollToTopButton
|
||||
>
|
||||
<SearchResult searchParams={searchParams} />
|
||||
</PrimaryPageLayout>
|
||||
)
|
||||
})
|
||||
SearchPage.displayName = 'SearchPage'
|
||||
export default SearchPage
|
||||
@@ -1,18 +1,11 @@
|
||||
import { Favicon } from '@/components/Favicon'
|
||||
import ProfileList from '@/components/ProfileList'
|
||||
import UserItem from '@/components/UserItem'
|
||||
import { SEARCHABLE_RELAY_URLS } from '@/constants'
|
||||
import { useFetchRelayInfos } from '@/hooks'
|
||||
import { ProfileListBySearch } from '@/components/ProfileListBySearch'
|
||||
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
|
||||
import { fetchPubkeysFromDomain } from '@/lib/nip05'
|
||||
import { useFeed } from '@/providers/FeedProvider'
|
||||
import client from '@/services/client.service'
|
||||
import dayjs from 'dayjs'
|
||||
import { forwardRef, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { forwardRef, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const LIMIT = 50
|
||||
|
||||
const ProfileListPage = forwardRef(({ index }: { index?: number }, ref) => {
|
||||
const { t } = useTranslation()
|
||||
const [title, setTitle] = useState<React.ReactNode>()
|
||||
@@ -72,69 +65,3 @@ function ProfileListByDomain({ domain }: { domain: string }) {
|
||||
|
||||
return <ProfileList pubkeys={pubkeys} />
|
||||
}
|
||||
|
||||
function ProfileListBySearch({ search }: { search: string }) {
|
||||
const { relayUrls } = useFeed()
|
||||
const { searchableRelayUrls } = useFetchRelayInfos(relayUrls)
|
||||
const [until, setUntil] = useState<number>(() => dayjs().unix())
|
||||
const [hasMore, setHasMore] = useState<boolean>(true)
|
||||
const [pubkeySet, setPubkeySet] = useState(new Set<string>())
|
||||
const bottomRef = useRef<HTMLDivElement>(null)
|
||||
const filter = { until, search }
|
||||
const urls = useMemo(() => {
|
||||
return filter.search ? searchableRelayUrls.concat(SEARCHABLE_RELAY_URLS).slice(0, 4) : relayUrls
|
||||
}, [relayUrls, searchableRelayUrls, filter])
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasMore) return
|
||||
const options = {
|
||||
root: null,
|
||||
rootMargin: '10px',
|
||||
threshold: 1
|
||||
}
|
||||
|
||||
const observerInstance = new IntersectionObserver((entries) => {
|
||||
if (entries[0].isIntersecting && hasMore) {
|
||||
loadMore()
|
||||
}
|
||||
}, options)
|
||||
|
||||
const currentBottomRef = bottomRef.current
|
||||
|
||||
if (currentBottomRef) {
|
||||
observerInstance.observe(currentBottomRef)
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (observerInstance && currentBottomRef) {
|
||||
observerInstance.unobserve(currentBottomRef)
|
||||
}
|
||||
}
|
||||
}, [hasMore, filter, urls])
|
||||
|
||||
async function loadMore() {
|
||||
if (urls.length === 0) {
|
||||
return setHasMore(false)
|
||||
}
|
||||
const profiles = await client.searchProfiles(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 (
|
||||
<div className="px-4">
|
||||
{Array.from(pubkeySet).map((pubkey, index) => (
|
||||
<UserItem key={`${index}-${pubkey}`} pubkey={pubkey} />
|
||||
))}
|
||||
{hasMore && <div ref={bottomRef} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ const RelayPage = forwardRef(({ url, index }: { url?: string; index?: number },
|
||||
controls={<RelayPageControls url={normalizedUrl} />}
|
||||
displayScrollToTopButton
|
||||
>
|
||||
<Relay url={normalizedUrl} className="pt-3" />
|
||||
<Relay url={normalizedUrl} />
|
||||
</SecondaryPageLayout>
|
||||
)
|
||||
})
|
||||
|
||||
66
src/pages/secondary/SearchPage/index.tsx
Normal file
66
src/pages/secondary/SearchPage/index.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import SearchBar, { TSearchBarRef } from '@/components/SearchBar'
|
||||
import SearchResult from '@/components/SearchResult'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
|
||||
import { toSearch } from '@/lib/link'
|
||||
import { useSecondaryPage } from '@/PageManager'
|
||||
import { TSearchParams } from '@/types'
|
||||
import { ChevronLeft } from 'lucide-react'
|
||||
import { forwardRef, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
const SearchPage = forwardRef(({ index }: { index?: number }, ref) => {
|
||||
const { push, pop } = useSecondaryPage()
|
||||
const [input, setInput] = useState('')
|
||||
const searchBarRef = useRef<TSearchBarRef>(null)
|
||||
const searchParams = useMemo(() => {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const type = params.get('t')
|
||||
if (
|
||||
type !== 'profile' &&
|
||||
type !== 'profiles' &&
|
||||
type !== 'notes' &&
|
||||
type !== 'hashtag' &&
|
||||
type !== 'relay'
|
||||
) {
|
||||
return null
|
||||
}
|
||||
const search = params.get('q')
|
||||
if (!search) {
|
||||
return null
|
||||
}
|
||||
const input = params.get('i') ?? ''
|
||||
setInput(input || search)
|
||||
return { type, search, input } as TSearchParams
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!window.location.search) {
|
||||
searchBarRef.current?.focus()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const onSearch = (params: TSearchParams | null) => {
|
||||
if (params) {
|
||||
push(toSearch(params))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SecondaryPageLayout
|
||||
ref={ref}
|
||||
index={index}
|
||||
titlebar={
|
||||
<div className="flex items-center gap-1 h-full">
|
||||
<Button variant="ghost" size="titlebar-icon" onClick={() => pop()}>
|
||||
<ChevronLeft />
|
||||
</Button>
|
||||
<SearchBar ref={searchBarRef} input={input} setInput={setInput} onSearch={onSearch} />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<SearchResult searchParams={searchParams} />
|
||||
</SecondaryPageLayout>
|
||||
)
|
||||
})
|
||||
SearchPage.displayName = 'SearchPage'
|
||||
export default SearchPage
|
||||
@@ -12,6 +12,7 @@ import ProfileListPage from './pages/secondary/ProfileListPage'
|
||||
import ProfilePage from './pages/secondary/ProfilePage'
|
||||
import RelayPage from './pages/secondary/RelayPage'
|
||||
import RelaySettingsPage from './pages/secondary/RelaySettingsPage'
|
||||
import SearchPage from './pages/secondary/SearchPage'
|
||||
import SettingsPage from './pages/secondary/SettingsPage'
|
||||
import TranslationPage from './pages/secondary/TranslationPage'
|
||||
import WalletPage from './pages/secondary/WalletPage'
|
||||
@@ -24,6 +25,7 @@ const ROUTES = [
|
||||
{ path: '/users/:id/following', element: <FollowingListPage /> },
|
||||
{ path: '/users/:id/relays', element: <OthersRelaySettingsPage /> },
|
||||
{ path: '/relays/:url', element: <RelayPage /> },
|
||||
{ path: '/search', element: <SearchPage /> },
|
||||
{ path: '/settings', element: <SettingsPage /> },
|
||||
{ path: '/settings/relays', element: <RelaySettingsPage /> },
|
||||
{ path: '/settings/wallet', element: <WalletPage /> },
|
||||
|
||||
@@ -22,7 +22,9 @@ import {
|
||||
kinds,
|
||||
Event as NEvent,
|
||||
nip19,
|
||||
Relay,
|
||||
SimplePool,
|
||||
validateEvent,
|
||||
VerifiedEvent
|
||||
} from 'nostr-tools'
|
||||
import { AbstractRelay } from 'nostr-tools/abstract-relay'
|
||||
@@ -57,6 +59,7 @@ class ClientService extends EventTarget {
|
||||
this.fetchEventsFromBigRelays.bind(this),
|
||||
{ cache: false, batchScheduleFn: (callback) => setTimeout(callback, 50) }
|
||||
)
|
||||
private trendingNotesCache: NEvent[] | null = null
|
||||
|
||||
private userIndex = new FlexSearch.Index({
|
||||
tokenize: 'forward'
|
||||
@@ -709,6 +712,39 @@ class ClientService extends EventTarget {
|
||||
return this.eventDataLoader.load(id)
|
||||
}
|
||||
|
||||
async fetchTrendingNotes() {
|
||||
if (this.trendingNotesCache) {
|
||||
return this.trendingNotesCache
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('https://api.nostr.band/v0/trending/notes')
|
||||
const data = await response.json()
|
||||
const events: NEvent[] = []
|
||||
for (const note of data.notes ?? []) {
|
||||
if (validateEvent(note.event)) {
|
||||
events.push(note.event)
|
||||
this.addEventToCache(note.event)
|
||||
if (note.relays?.length) {
|
||||
note.relays.map((r: string) => {
|
||||
try {
|
||||
const relay = new Relay(r)
|
||||
this.trackEventSeenOn(note.event.id, relay)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
this.trendingNotesCache = events
|
||||
return this.trendingNotesCache
|
||||
} catch (error) {
|
||||
console.error('fetchTrendingNotes error', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
addEventToCache(event: NEvent) {
|
||||
this.eventDataLoader.prime(event.id, Promise.resolve(event))
|
||||
if (isReplaceableEvent(event.kind)) {
|
||||
|
||||
8
src/types/index.d.ts
vendored
8
src/types/index.d.ts
vendored
@@ -174,3 +174,11 @@ export type TPollCreateData = {
|
||||
relays: string[]
|
||||
endsAt?: number
|
||||
}
|
||||
|
||||
export type TSearchType = 'profile' | 'profiles' | 'notes' | 'hashtag' | 'relay'
|
||||
|
||||
export type TSearchParams = {
|
||||
type: TSearchType
|
||||
search: string
|
||||
input?: string
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user