diff --git a/package-lock.json b/package-lock.json index bd2ee20a..1a57a13b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "imwald", - "version": "23.21.7", + "version": "23.21.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "imwald", - "version": "23.21.7", + "version": "23.21.8", "license": "MIT", "dependencies": { "@asciidoctor/core": "^3.0.4", diff --git a/package.json b/package.json index 9d9f645e..91db6cba 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "imwald", - "version": "23.21.7", + "version": "23.21.8", "description": "Imwald — a user-friendly Nostr client focused on relay feed browsing, publications, and relay discovery", "private": true, "type": "module", diff --git a/src/components/Library/LibrarySearchBar.tsx b/src/components/Library/LibrarySearchBar.tsx index 49745f22..bc0de1e6 100644 --- a/src/components/Library/LibrarySearchBar.tsx +++ b/src/components/Library/LibrarySearchBar.tsx @@ -1,13 +1,36 @@ +import SearchInput from '@/components/SearchInput' import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Switch } from '@/components/ui/switch' -import { Loader2, Search, Wifi } from 'lucide-react' +import { normalizeToDTag } from '@/lib/search-parser' +import type { LibraryPublicationRelaySearchAxis } from '@/lib/library-publication-index' +import { cn } from '@/lib/utils' +import { useScreenSize } from '@/providers/ScreenSizeProvider' +import modalManager from '@/services/modal-manager.service' +import { randomString } from '@/lib/random' +import { FileText, Loader2, Search, User, Wifi } from 'lucide-react' +import { + HTMLAttributes, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState +} from 'react' import { useTranslation } from 'react-i18next' +type LibrarySearchOption = { + axis: LibraryPublicationRelaySearchAxis | null + search: string + input?: string +} + export default function LibrarySearchBar({ searchQuery, onSearchQueryChange, + searchAxis, + onSearchAxisChange, showOnlyMine, onShowOnlyMineChange, mineFilterLoading, @@ -17,6 +40,8 @@ export default function LibrarySearchBar({ }: { searchQuery: string onSearchQueryChange: (value: string) => void + searchAxis: LibraryPublicationRelaySearchAxis | null + onSearchAxisChange: (axis: LibraryPublicationRelaySearchAxis | null) => void showOnlyMine: boolean onShowOnlyMineChange: (value: boolean) => void mineFilterLoading?: boolean @@ -25,22 +50,252 @@ export default function LibrarySearchBar({ disabled?: boolean }) { const { t } = useTranslation() + const { isSmallScreen } = useScreenSize() + const [searching, setSearching] = useState(false) + const [displayList, setDisplayList] = useState(false) + const [selectableOptions, setSelectableOptions] = useState([]) + const [selectedIndex, setSelectedIndex] = useState(-1) + const prevSelectableCountRef = useRef(0) + const searchInputRef = useRef(null) + const barContainerRef = useRef(null) + const [suggestPanelTop, setSuggestPanelTop] = useState(0) + const id = useMemo(() => `library-search-${randomString()}`, []) const canSearchRelays = searchQuery.trim().length > 0 && !relaySearchLoading + useEffect(() => { + const search = searchQuery.trim() + if (!search) { + setSelectableOptions([]) + setSelectedIndex(-1) + setSearching(false) + return + } + + const normalizedDTag = normalizeToDTag(search) + const options: LibrarySearchOption[] = [ + { axis: null, search }, + { axis: 'title', search }, + { axis: 'author', search }, + ...(normalizedDTag + ? [{ axis: 'd-tag' as const, search: normalizedDTag, input: search }] + : []) + ] + setSelectableOptions(options) + }, [searchQuery]) + + useEffect(() => { + setDisplayList(searching && !!searchQuery.trim()) + }, [searching, searchQuery]) + + useEffect(() => { + const trimmed = searchQuery.trim() + const len = selectableOptions.length + if (!trimmed) { + prevSelectableCountRef.current = 0 + return + } + if (len > 0 && prevSelectableCountRef.current === 0) { + const el = searchInputRef.current + if (el && document.activeElement !== el) { + queueMicrotask(() => { + el.focus({ preventScroll: true }) + }) + } + } + prevSelectableCountRef.current = len + }, [searchQuery, selectableOptions]) + + useEffect(() => { + if (displayList && selectableOptions.length > 0) { + modalManager.register(id, () => { + setDisplayList(false) + }) + } else { + modalManager.unregister(id) + } + }, [displayList, selectableOptions.length, id]) + + const blur = () => { + setSearching(false) + searchInputRef.current?.blur() + } + + const applyOption = (option: LibrarySearchOption) => { + onSearchAxisChange(option.axis) + if (option.input && option.input !== searchQuery) { + onSearchQueryChange(option.input) + } + blur() + } + + const updateSuggestPanelGeometry = useCallback(() => { + const el = barContainerRef.current + if (!el) return + setSuggestPanelTop(el.getBoundingClientRect().bottom) + }, []) + + useLayoutEffect(() => { + if (!displayList || selectableOptions.length === 0 || !isSmallScreen) return + updateSuggestPanelGeometry() + const onScrollOrResize = () => updateSuggestPanelGeometry() + window.addEventListener('scroll', onScrollOrResize, true) + window.addEventListener('resize', onScrollOrResize) + return () => { + window.removeEventListener('scroll', onScrollOrResize, true) + window.removeEventListener('resize', onScrollOrResize) + } + }, [displayList, selectableOptions.length, isSmallScreen, searchQuery, updateSuggestPanelGeometry]) + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.stopPropagation() + if (selectableOptions.length <= 0) return + applyOption(selectableOptions[selectedIndex >= 0 ? selectedIndex : 0]) + return + } + + if (e.key === 'ArrowDown') { + e.preventDefault() + if (selectableOptions.length <= 0) return + setSelectedIndex((prev) => (prev + 1) % selectableOptions.length) + return + } + + if (e.key === 'ArrowUp') { + e.preventDefault() + if (selectableOptions.length <= 0) return + setSelectedIndex((prev) => (prev - 1 + selectableOptions.length) % selectableOptions.length) + return + } + + if (e.key === 'Escape') { + blur() + } + }, + [selectableOptions, selectedIndex] + ) + + const list = useMemo(() => { + if (selectableOptions.length <= 0) return null + return ( + <> + {selectableOptions.map((option, index) => { + if (option.axis === null) { + return ( + applyOption(option)} + /> + ) + } + if (option.axis === 'title') { + return ( + applyOption(option)} + /> + ) + } + if (option.axis === 'author') { + return ( + applyOption(option)} + /> + ) + } + return ( + applyOption(option)} + /> + ) + })} + + ) + }, [selectableOptions, selectedIndex]) + + const suggestTopPx = Math.max(0, suggestPanelTop - 4) + const suggestionsPanel = list ? ( +
e.preventDefault()} + > +
{list}
+
+ ) : null + + const scopeLabel = + searchAxis === 'title' + ? t('Library search scope title') + : searchAxis === 'author' + ? t('Library search scope author') + : searchAxis === 'd-tag' + ? t('Library search scope dtag') + : null + return (
-
- - + {displayList && list && !isSmallScreen && ( + <> + {suggestionsPanel} +
blur()} aria-hidden /> + + )} + {displayList && list && isSmallScreen && ( + <> +
blur()} aria-hidden /> + {suggestionsPanel} + + )} + onSearchQueryChange(e.target.value)} + onChange={(e) => { + setSearching(true) + onSearchQueryChange(e.target.value) + }} + onPaste={() => setSearching(true)} + onKeyDown={handleKeyDown} + onFocus={() => setSearching(true)} + onBlur={() => setSearching(false)} placeholder={t('Library search placeholder')} - className="pl-9" + className={cn( + 'bg-surface-background pl-3', + displayList && isSmallScreen && 'relative z-[120]', + displayList && !isSmallScreen && 'z-50' + )} disabled={disabled} aria-label={t('Library search placeholder')} />
+ {scopeLabel ? ( +

{scopeLabel}

+ ) : null} {onSearchRelays ? (