diff --git a/src/PageManager.tsx b/src/PageManager.tsx index 2c4a84a0..a2a76a64 100644 --- a/src/PageManager.tsx +++ b/src/PageManager.tsx @@ -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(), me: createRef(), profile: createRef(), - relay: createRef() + relay: createRef(), + search: createRef() } const PRIMARY_PAGE_MAP = { @@ -64,7 +66,8 @@ const PRIMARY_PAGE_MAP = { notifications: , me: , profile: , - relay: + relay: , + search: } const PrimaryPageContext = createContext(undefined) diff --git a/src/components/BackButton/index.tsx b/src/components/BackButton/index.tsx deleted file mode 100644 index 8a214adb..00000000 --- a/src/components/BackButton/index.tsx +++ /dev/null @@ -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 ( - - ) -} diff --git a/src/components/ProfileListBySearch/index.tsx b/src/components/ProfileListBySearch/index.tsx new file mode 100644 index 00000000..b1bcf53a --- /dev/null +++ b/src/components/ProfileListBySearch/index.tsx @@ -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(() => dayjs().unix()) + const [hasMore, setHasMore] = useState(true) + const [pubkeySet, setPubkeySet] = useState(new Set()) + const bottomRef = useRef(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() + 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 ( +
+ {Array.from(pubkeySet).map((pubkey, index) => ( + + ))} + {hasMore &&
} +
+ ) +} diff --git a/src/components/Relay/index.tsx b/src/components/Relay/index.tsx index a0b0c7d6..8f7c945f 100644 --- a/src/components/Relay/index.tsx +++ b/src/components/Relay/index.tsx @@ -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 ( -
+
{relayInfo?.supported_nips?.includes(50) && (
diff --git a/src/components/SearchBar/index.tsx b/src/components/SearchBar/index.tsx new file mode 100644 index 00000000..b31aa8b6 --- /dev/null +++ b/src/components/SearchBar/index.tsx @@ -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(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 ( + <> + { + blur() + push(toNote(search)) + }} + /> + 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 ( + updateSearch({ type: 'profile', search: id })} /> + ) + } + if (['nevent', 'naddr', 'note'].includes(type)) { + return ( + { + blur() + push(toNote(id)) + }} + /> + ) + } + } catch { + // ignore + } + + return ( + <> + updateSearch({ type: 'notes', search })} /> + updateSearch({ type: 'hashtag', search, input: `#${search}` })} + /> + {!!normalizedUrl && ( + updateSearch({ type: 'relay', search, input: normalizedUrl })} + /> + )} + {profiles.map((profile) => ( + + updateSearch({ type: 'profile', search: profile.npub, input: profile.username }) + } + /> + ))} + {profiles.length >= 10 && ( + updateSearch({ type: 'profiles', search })}> +
{t('Show more...')}
+
+ )} + + ) + }, [input, debouncedInput, profiles]) + + const showList = useMemo(() => searching && !!list, [searching, list]) + + useEffect(() => { + if (showList) { + modalManager.register(id, () => { + blur() + }) + } else { + modalManager.unregister(id) + } + }, [showList]) + + return ( +
+ {showList && ( + <> +
e.preventDefault()} + > + {list} +
+
blur()} /> + + )} + setInput(e.target.value)} + onFocus={() => startSearch()} + /> +
+ ) +}) +SearchBar.displayName = 'SearchBar' +export default SearchBar + +export type TSearchBarRef = { + focus: () => void + blur: () => void +} + +function NormalItem({ search, onClick }: { search: string; onClick?: () => void }) { + return ( + + +
{search}
+
+ ) +} + +function HashtagItem({ search, onClick }: { search: string; onClick?: () => void }) { + const hashtag = search.match(/[\p{L}\p{N}\p{M}]+/u)?.[0].toLowerCase() + return ( + + +
{hashtag}
+
+ ) +} + +function NoteItem({ id, onClick }: { id: string; onClick?: () => void }) { + return ( + + +
{id}
+
+ ) +} + +function ProfileIdItem({ id, onClick }: { id: string; onClick?: () => void }) { + return ( + + +
{id}
+
+ ) +} + +function ProfileItem({ profile, onClick }: { profile: TProfile; onClick?: () => void }) { + return ( +
+
+ +
+ + +
+
+
+ ) +} + +function RelayItem({ url, onClick }: { url: string; onClick?: () => void }) { + return ( + + +
{url}
+
+ ) +} + +function Item({ className, children, ...props }: HTMLAttributes) { + return ( +
+ {children} +
+ ) +} diff --git a/src/components/SearchDialog/index.tsx b/src/components/SearchDialog/index.tsx deleted file mode 100644 index 4aeda5c6..00000000 --- a/src/components/SearchDialog/index.tsx +++ /dev/null @@ -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 }) { - 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 ( - <> - setOpen(false)} /> - 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 setOpen(false)} /> - } - if (['nevent', 'naddr', 'note'].includes(type)) { - return setOpen(false)} /> - } - } catch { - // ignore - } - - return ( - <> - setOpen(false)} /> - setOpen(false)} /> - {!!normalizedUrl && setOpen(false)} />} - {profiles.map((profile) => ( - setOpen(false)} /> - ))} - {profiles.length >= 10 && ( - setOpen(false)}> - setOpen(false)} className="text-center"> -
{t('Show more...')}
-
-
- )} - - ) - }, [input, debouncedInput, profiles, setOpen]) - - useEffect(() => { - const handler = setTimeout(() => { - setDebouncedInput(input) - }, 500) - - return () => { - clearTimeout(handler) - } - }, [input]) - - return ( - - - {list} - - ) -} - -function NormalItem({ search, onClick }: { search: string; onClick?: () => void }) { - return ( - - - -
{search}
-
-
- ) -} - -function HashtagItem({ search, onClick }: { search: string; onClick?: () => void }) { - const hashtag = search.match(/[\p{L}\p{N}\p{M}]+/u)?.[0].toLowerCase() - return ( - - - -
{hashtag}
-
-
- ) -} - -function NoteItem({ id, onClick }: { id: string; onClick?: () => void }) { - return ( - - - -
{id}
-
-
- ) -} - -function ProfileIdItem({ id, onClick }: { id: string; onClick?: () => void }) { - return ( - - - -
{id}
-
-
- ) -} - -function ProfileItem({ profile, onClick }: { profile: TProfile; onClick?: () => void }) { - return ( - - -
- -
- - -
-
-
-
- ) -} - -function RelayItem({ url, onClick }: { url: string; onClick?: () => void }) { - return ( - - - -
{url}
-
-
- ) -} diff --git a/src/components/SearchInput/index.tsx b/src/components/SearchInput/index.tsx index 9f28e0d9..0255c5e7 100644 --- a/src/components/SearchInput/index.tsx +++ b/src/components/SearchInput/index.tsx @@ -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>( + ({ value, onChange, className, ...props }, ref) => { + const [displayClear, setDisplayClear] = useState(false) + const [inputRef, setInputRef] = useState(null) - useEffect(() => { - setDisplayClear(!!value) - }, [value]) + useEffect(() => { + setDisplayClear(!!value) + }, [value]) - return ( -
- - - {displayClear && ( - - )} -
- ) -} + function setRefs(el: HTMLInputElement) { + setInputRef(el) + if (typeof ref === 'function') { + ref(el) + } else if (ref) { + ;(ref as React.MutableRefObject).current = el + } + } + + return ( +
+ inputRef?.focus()} /> + + {displayClear && ( + + )} +
+ ) + } +) +SearchInput.displayName = 'SearchInput' +export default SearchInput diff --git a/src/components/SearchResult/index.tsx b/src/components/SearchResult/index.tsx new file mode 100644 index 00000000..bf2e71a3 --- /dev/null +++ b/src/components/SearchResult/index.tsx @@ -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 + } + if (searchParams.type === 'profile') { + return + } + if (searchParams.type === 'profiles') { + return + } + if (searchParams.type === 'notes') { + return ( + + ) + } + if (searchParams.type === 'hashtag') { + return ( + + ) + } + return +} diff --git a/src/components/Sidebar/SearchButton.tsx b/src/components/Sidebar/SearchButton.tsx index 9abb5c6b..126a8911 100644 --- a/src/components/Sidebar/SearchButton.tsx +++ b/src/components/Sidebar/SearchButton.tsx @@ -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 ( - <> - { - e.stopPropagation() - setOpen(true) - }} - > - - - - + navigate('search')} + active={current === 'search' && display} + > + + ) } diff --git a/src/components/TrendingNotes/index.tsx b/src/components/TrendingNotes/index.tsx new file mode 100644 index 00000000..acea639c --- /dev/null +++ b/src/components/TrendingNotes/index.tsx @@ -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([]) + const [showCount, setShowCount] = useState(10) + const [loading, setLoading] = useState(true) + const bottomRef = useRef(null) + const filteredEvents = useMemo(() => { + const idSet = new Set() + + 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 ( +
+
+ {t('Trending Notes')} +
+ {filteredEvents.map((event) => ( + + ))} + {showCount < trendingNotes.length || loading ? ( +
+ +
+ ) : ( +
{t('no more notes')}
+ )} +
+ ) +} diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index 4d435f79..faad33f5 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -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: { diff --git a/src/layouts/PrimaryPageLayout/index.tsx b/src/layouts/PrimaryPageLayout/index.tsx index bf34a446..3ec745ed 100644 --- a/src/layouts/PrimaryPageLayout/index.tsx +++ b/src/layouts/PrimaryPageLayout/index.tsx @@ -93,7 +93,9 @@ const PrimaryPageLayout = forwardRef( scrollBarClassName="z-50 pt-12" ref={scrollAreaRef} > - {titlebar} + + {titlebar} + {children}
diff --git a/src/layouts/SecondaryPageLayout/index.tsx b/src/layouts/SecondaryPageLayout/index.tsx index bc90f4fb..29da578e 100644 --- a/src/layouts/SecondaryPageLayout/index.tsx +++ b/src/layouts/SecondaryPageLayout/index.tsx @@ -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}
@@ -87,6 +92,7 @@ const SecondaryPageLayout = forwardRef( controls={controls} hideBackButton={hideBackButton} hideBottomBorder={hideTitlebarBottomBorder} + titlebar={titlebar} /> {children}
@@ -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} + + ) + } return ( ) } + +function BackButton({ children }: { children?: React.ReactNode }) { + const { t } = useTranslation() + const { pop } = useSecondaryPage() + + return ( + + ) +} diff --git a/src/lib/link.ts b/src/lib/link.ts index 97575f66..4ecf1d69 100644 --- a/src/lib/link.ts +++ b/src/lib/link.ts @@ -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 : '') diff --git a/src/pages/primary/NoteListPage/SearchButton.tsx b/src/pages/primary/NoteListPage/SearchButton.tsx deleted file mode 100644 index 6b484bd7..00000000 --- a/src/pages/primary/NoteListPage/SearchButton.tsx +++ /dev/null @@ -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 ( - <> - - - - ) -} diff --git a/src/pages/primary/NoteListPage/index.tsx b/src/pages/primary/NoteListPage/index.tsx index 6259d269..4857ef59 100644 --- a/src/pages/primary/NoteListPage/index.tsx +++ b/src/pages/primary/NoteListPage/index.tsx @@ -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 (
-
- - {isSmallScreen && } -
+ {isSmallScreen && ( +
+ + +
+ )}
) } @@ -106,3 +109,13 @@ function PostButton() { ) } + +function SearchButton() { + const { push } = useSecondaryPage() + + return ( + + ) +} diff --git a/src/pages/primary/RelayPage/index.tsx b/src/pages/primary/RelayPage/index.tsx index 7f8a6bff..f8e4471a 100644 --- a/src/pages/primary/RelayPage/index.tsx +++ b/src/pages/primary/RelayPage/index.tsx @@ -23,7 +23,7 @@ const RelayPage = forwardRef(({ url }: { url?: string }, ref) => { displayScrollToTopButton ref={ref} > - + ) }) diff --git a/src/pages/primary/SearchPage/index.tsx b/src/pages/primary/SearchPage/index.tsx new file mode 100644 index 00000000..bc5273a7 --- /dev/null +++ b/src/pages/primary/SearchPage/index.tsx @@ -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(null) + const searchBarRef = useRef(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 ( + + } + displayScrollToTopButton + > + + + ) +}) +SearchPage.displayName = 'SearchPage' +export default SearchPage diff --git a/src/pages/secondary/ProfileListPage/index.tsx b/src/pages/secondary/ProfileListPage/index.tsx index 2b4b6bfa..84bf4982 100644 --- a/src/pages/secondary/ProfileListPage/index.tsx +++ b/src/pages/secondary/ProfileListPage/index.tsx @@ -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() @@ -72,69 +65,3 @@ function ProfileListByDomain({ domain }: { domain: string }) { return } - -function ProfileListBySearch({ search }: { search: string }) { - const { relayUrls } = useFeed() - const { searchableRelayUrls } = useFetchRelayInfos(relayUrls) - const [until, setUntil] = useState(() => dayjs().unix()) - const [hasMore, setHasMore] = useState(true) - const [pubkeySet, setPubkeySet] = useState(new Set()) - const bottomRef = useRef(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() - 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 ( -
- {Array.from(pubkeySet).map((pubkey, index) => ( - - ))} - {hasMore &&
} -
- ) -} diff --git a/src/pages/secondary/RelayPage/index.tsx b/src/pages/secondary/RelayPage/index.tsx index 264f748a..393d41d7 100644 --- a/src/pages/secondary/RelayPage/index.tsx +++ b/src/pages/secondary/RelayPage/index.tsx @@ -21,7 +21,7 @@ const RelayPage = forwardRef(({ url, index }: { url?: string; index?: number }, controls={} displayScrollToTopButton > - + ) }) diff --git a/src/pages/secondary/SearchPage/index.tsx b/src/pages/secondary/SearchPage/index.tsx new file mode 100644 index 00000000..55d12ecd --- /dev/null +++ b/src/pages/secondary/SearchPage/index.tsx @@ -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(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 ( + + + +
+ } + > + + + ) +}) +SearchPage.displayName = 'SearchPage' +export default SearchPage diff --git a/src/routes.tsx b/src/routes.tsx index 1c7ca6d6..16d863e0 100644 --- a/src/routes.tsx +++ b/src/routes.tsx @@ -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: }, { path: '/users/:id/relays', element: }, { path: '/relays/:url', element: }, + { path: '/search', element: }, { path: '/settings', element: }, { path: '/settings/relays', element: }, { path: '/settings/wallet', element: }, diff --git a/src/services/client.service.ts b/src/services/client.service.ts index 0909e411..752c2ce5 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -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)) { diff --git a/src/types/index.d.ts b/src/types/index.d.ts index 3fc5d389..71cec16e 100644 --- a/src/types/index.d.ts +++ b/src/types/index.d.ts @@ -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 +}