import './search.css'; import { useAutoAnimate } from '@formkit/auto-animate/preact'; import { t, Trans } from '@lingui/macro'; import { useEffect, useLayoutEffect, useRef, useState } from 'preact/hooks'; import { useHotkeys } from 'react-hotkeys-hook'; import { InView } from 'react-intersection-observer'; import { useParams, useSearchParams } from 'react-router-dom'; import AccountBlock from '../components/account-block'; import Icon from '../components/icon'; import Link from '../components/link'; import Loader from '../components/loader'; import NavMenu from '../components/nav-menu'; import SearchForm from '../components/search-form'; import Status from '../components/status'; import { api } from '../utils/api'; import { fetchRelationships } from '../utils/relationships'; import shortenNumber from '../utils/shorten-number'; import usePageVisibility from '../utils/usePageVisibility'; import useTitle from '../utils/useTitle'; const SHORT_LIMIT = 5; const LIMIT = 40; const emptySearchParams = new URLSearchParams(); const scrollIntoViewOptions = { block: 'nearest', inline: 'center', behavior: 'smooth', }; function Search({ columnMode, ...props }) { const params = columnMode ? {} : useParams(); const { masto, instance, authenticated } = api({ instance: params.instance, }); const [uiState, setUIState] = useState('default'); const [searchParams] = columnMode ? [emptySearchParams] : useSearchParams(); const searchFormRef = useRef(); const q = props?.query || searchParams.get('q'); const type = columnMode ? 'statuses' : props?.type || searchParams.get('type'); let title = t`Search`; if (q) { switch (type) { case 'statuses': title = t`Search: ${q} (Posts)`; break; case 'accounts': title = t`Search: ${q} (Accounts)`; break; case 'hashtags': title = t`Search: ${q} (Hashtags)`; break; default: title = t`Search: ${q}`; } } useTitle(title, `/search`); const [showMore, setShowMore] = useState(false); const offsetRef = useRef(0); useEffect(() => { offsetRef.current = 0; }, [q, type]); const scrollableRef = useRef(); useLayoutEffect(() => { scrollableRef.current?.scrollTo?.(0, 0); }, [q, type]); const [statusResults, setStatusResults] = useState([]); const [accountResults, setAccountResults] = useState([]); const [hashtagResults, setHashtagResults] = useState([]); useEffect(() => { setStatusResults([]); setAccountResults([]); setHashtagResults([]); }, [q]); const typeResults = { statuses: statusResults, accounts: accountResults, hashtags: hashtagResults, }; const setTypeResultsFunc = { statuses: setStatusResults, accounts: setAccountResults, hashtags: setHashtagResults, }; const [relationshipsMap, setRelationshipsMap] = useState({}); const loadRelationships = async (accounts) => { if (!accounts?.length) return; const relationships = await fetchRelationships(accounts, relationshipsMap); if (relationships) { setRelationshipsMap({ ...relationshipsMap, ...relationships, }); } }; function loadResults(firstLoad) { if (firstLoad) { offsetRef.current = 0; } if (!firstLoad && !authenticated) { // Search results pagination is only available to authenticated users return; } setUIState('loading'); if (firstLoad && !type) { setStatusResults(statusResults.slice(0, SHORT_LIMIT)); setAccountResults(accountResults.slice(0, SHORT_LIMIT)); setHashtagResults(hashtagResults.slice(0, SHORT_LIMIT)); } (async () => { const params = { q, resolve: authenticated, limit: SHORT_LIMIT, }; if (type) { params.limit = LIMIT; params.type = type; if (authenticated) params.offset = offsetRef.current; } try { const results = await masto.v2.search.fetch(params); console.log(results); if (type) { if (firstLoad) { setTypeResultsFunc[type](results[type]); const length = results[type]?.length; offsetRef.current = LIMIT; setShowMore(!!length); } else { // If first item is the same, it means API doesn't support offset // I know this is a very basic check, but it works for now if (results[type]?.[0]?.id === typeResults[type]?.[0]?.id) { setShowMore(false); } else { setTypeResultsFunc[type]((prev) => [...prev, ...results[type]]); const length = results[type]?.length; offsetRef.current = offsetRef.current + LIMIT; setShowMore(!!length); } } } else { setStatusResults(results.statuses || []); setAccountResults(results.accounts || []); setHashtagResults(results.hashtags || []); offsetRef.current = 0; setShowMore(false); } loadRelationships(results.accounts); setUIState('default'); } catch (err) { console.error(err); setUIState('error'); } })(); } const lastHiddenTime = useRef(); usePageVisibility((visible) => { const reachStart = scrollableRef.current?.scrollTop === 0; if (visible && reachStart) { const timeDiff = Date.now() - lastHiddenTime.current; if (!lastHiddenTime.current || timeDiff > 1000 * 3) { // 3 seconds loadResults(true); } else { lastHiddenTime.current = Date.now(); } } }); useEffect(() => { searchFormRef.current?.setValue?.(q || ''); if (q) { loadResults(true); } else { searchFormRef.current?.focus?.(); } }, [q, type, instance]); useHotkeys( ['/', 'Slash'], (e) => { searchFormRef.current?.focus?.(); searchFormRef.current?.select?.(); }, { preventDefault: true, }, ); const itemsSelector = '.timeline > li > a, .hashtag-list > li > a'; const jRef = useHotkeys('j', () => { const activeItem = document.activeElement.closest(itemsSelector); const activeItemRect = activeItem?.getBoundingClientRect(); const allItems = Array.from( scrollableRef.current.querySelectorAll(itemsSelector), ); if ( activeItem && activeItemRect.top < scrollableRef.current.clientHeight && activeItemRect.bottom > 0 ) { const activeItemIndex = allItems.indexOf(activeItem); let nextItem = allItems[activeItemIndex + 1]; if (nextItem) { nextItem.focus(); nextItem.scrollIntoView(scrollIntoViewOptions); } } else { const topmostItem = allItems.find((item) => { const itemRect = item.getBoundingClientRect(); return itemRect.top >= 44 && itemRect.left >= 0; }); if (topmostItem) { topmostItem.focus(); topmostItem.scrollIntoView(scrollIntoViewOptions); } } }); const kRef = useHotkeys('k', () => { // focus on previous status after active item const activeItem = document.activeElement.closest(itemsSelector); const activeItemRect = activeItem?.getBoundingClientRect(); const allItems = Array.from( scrollableRef.current.querySelectorAll(itemsSelector), ); if ( activeItem && activeItemRect.top < scrollableRef.current.clientHeight && activeItemRect.bottom > 0 ) { const activeItemIndex = allItems.indexOf(activeItem); let prevItem = allItems[activeItemIndex - 1]; if (prevItem) { prevItem.focus(); prevItem.scrollIntoView(scrollIntoViewOptions); } } else { const topmostItem = allItems.find((item) => { const itemRect = item.getBoundingClientRect(); return itemRect.top >= 44 && itemRect.left >= 0; }); if (topmostItem) { topmostItem.focus(); topmostItem.scrollIntoView(scrollIntoViewOptions); } } }); const [filterBarParent] = useAutoAnimate(); return (
{ scrollableRef.current = node; jRef(node); kRef(node); }} >
{!!q && !columnMode && (
{!!type && ( All )} {[ { label: t`Accounts`, type: 'accounts', to: `/search?q=${encodeURIComponent(q)}&type=accounts`, }, { label: t`Hashtags`, type: 'hashtags', to: `/search?q=${encodeURIComponent(q)}&type=hashtags`, }, { label: t`Posts`, type: 'statuses', to: `/search?q=${encodeURIComponent(q)}&type=statuses`, }, ] .sort((a, b) => { if (a.type === type) return -1; if (b.type === type) return 1; return 0; }) .map((link) => ( {link.label} ))}
)} {!!q ? ( <> {(!type || type === 'accounts') && ( <> {type !== 'accounts' && (

Accounts{' '}

)} {accountResults.length > 0 ? ( <>
    {accountResults.map((account) => (
  • ))}
{type !== 'accounts' && (
See more accounts{' '}
)} ) : ( !type && (uiState === 'loading' ? (

) : (

No accounts found.

)) )} )} {(!type || type === 'hashtags') && ( <> {type !== 'hashtags' && (

Hashtags{' '}

)} {hashtagResults.length > 0 ? ( <> {type !== 'hashtags' && (
See more hashtags{' '}
)} ) : ( !type && (uiState === 'loading' ? (

) : (

No hashtags found.

)) )} )} {(!type || type === 'statuses') && ( <> {type !== 'statuses' && (

Posts{' '}

)} {statusResults.length > 0 ? ( <>
    {statusResults.map((status) => (
  • ))}
{type !== 'statuses' && (
See more posts{' '}
)} ) : ( !type && (uiState === 'loading' ? (

) : (

No posts found.

)) )} )} {!!type && (uiState === 'default' ? ( showMore ? ( { if (inView) { loadResults(); } }} > ) : (

The end.

) ) : ( uiState === 'loading' && (

) ))} ) : uiState === 'loading' ? (

) : (

Enter your search term or paste a URL above to get started.

)}
); } export default Search;