24 changed files with 785 additions and 345 deletions
@ -1,22 +0,0 @@
@@ -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> |
||||
) |
||||
} |
||||
@ -0,0 +1,68 @@
@@ -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> |
||||
) |
||||
} |
||||
@ -0,0 +1,298 @@
@@ -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 @@
@@ -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 @@
@@ -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 [displayClear, setDisplayClear] = useState(false) |
||||
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]) |
||||
useEffect(() => { |
||||
setDisplayClear(!!value) |
||||
}, [value]) |
||||
|
||||
return ( |
||||
<div |
||||
tabIndex={0} |
||||
className={cn( |
||||
'flex h-9 w-full items-center rounded-md border border-input bg-transparent px-2 py-1 text-base shadow-sm transition-colors md:text-sm [&:has(:focus-visible)]:ring-ring [&:has(:focus-visible)]:ring-1 [&:has(:focus-visible)]:outline-none' |
||||
)} |
||||
> |
||||
<SearchIcon className="size-4 shrink-0 opacity-50" /> |
||||
<input |
||||
{...props} |
||||
value={value} |
||||
onChange={onChange} |
||||
className="size-full mx-2 border-none bg-transparent focus:outline-none placeholder:text-muted-foreground" |
||||
/> |
||||
{displayClear && ( |
||||
<button type="button" onClick={() => onChange?.({ target: { value: '' } } as any)}> |
||||
<X className="size-4 shrink-0 opacity-50 hover:opacity-100" /> |
||||
</button> |
||||
)} |
||||
</div> |
||||
) |
||||
} |
||||
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', |
||||
className |
||||
)} |
||||
> |
||||
<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" |
||||
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 |
||||
|
||||
@ -0,0 +1,34 @@
@@ -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 @@
@@ -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) |
||||
}} |
||||
> |
||||
<Search strokeWidth={3} /> |
||||
</SidebarItem> |
||||
<SearchDialog open={open} setOpen={setOpen} /> |
||||
</> |
||||
<SidebarItem |
||||
title="Search" |
||||
onClick={() => navigate('search')} |
||||
active={current === 'search' && display} |
||||
> |
||||
<Search strokeWidth={3} /> |
||||
</SidebarItem> |
||||
) |
||||
} |
||||
|
||||
@ -0,0 +1,92 @@
@@ -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> |
||||
) |
||||
} |
||||
@ -1,17 +0,0 @@
@@ -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} /> |
||||
</> |
||||
) |
||||
} |
||||
@ -0,0 +1,42 @@
@@ -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 |
||||
@ -0,0 +1,66 @@
@@ -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 |
||||
Loading…
Reference in new issue