From b71ced9b2e5873d9690a05bdc1c3fb94cb55bddf Mon Sep 17 00:00:00 2001 From: Hampus Date: Mon, 24 Aug 2026 12:42:20 +0200 Subject: [PATCH] fix(app): tell the user when a search query has nothing to search (#1901) --- .../channel/components/ChannelHeader.tsx | 3 - .../components/ChannelSearchResults.tsx | 287 ++++++++++-------- .../ChannelSearchResultsShared.tsx | 9 + .../SearchResultRowClick.ts | 21 ++ .../SearchResultsStateViews.tsx | 44 ++- .../useChannelSearchHighlight.ts | 38 +++ .../channel_view/useChannelSearchState.ts | 17 +- .../features/channel/state/ChannelSearch.ts | 20 ++ 8 files changed, 303 insertions(+), 136 deletions(-) create mode 100644 fluxer_app/src/features/channel/components/channel_search_results/SearchResultRowClick.ts create mode 100644 fluxer_app/src/features/channel/components/channel_search_results/useChannelSearchHighlight.ts diff --git a/fluxer_app/src/features/channel/components/ChannelHeader.tsx b/fluxer_app/src/features/channel/components/ChannelHeader.tsx index 226883fb5..8da994407 100644 --- a/fluxer_app/src/features/channel/components/ChannelHeader.tsx +++ b/fluxer_app/src/features/channel/components/ChannelHeader.tsx @@ -260,9 +260,6 @@ export const ChannelHeader = observer( ); const handleSearchSubmit = useCallback(() => { const query = latestSearchQueryRef.current; - if (!query.trim()) { - return; - } if (onSearchSubmit) { onSearchSubmit(query, latestSearchSegmentsRef.current); return; diff --git a/fluxer_app/src/features/channel/components/ChannelSearchResults.tsx b/fluxer_app/src/features/channel/components/ChannelSearchResults.tsx index 86f804842..e1207ae23 100644 --- a/fluxer_app/src/features/channel/components/ChannelSearchResults.tsx +++ b/fluxer_app/src/features/channel/components/ChannelSearchResults.tsx @@ -20,13 +20,16 @@ import { renderScopeIcon, renderSortIcon, } from '@app/features/channel/components/channel_search_results/ChannelSearchResultsShared'; +import {shouldSearchResultRowJump} from '@app/features/channel/components/channel_search_results/SearchResultRowClick'; import {SearchResultsHeader} from '@app/features/channel/components/channel_search_results/SearchResultsHeader'; import {SearchResultsPagination} from '@app/features/channel/components/channel_search_results/SearchResultsPagination'; import { SearchEmptyState, SearchErrorState, SearchIndexingState, + SearchUnappliedQueryState, } from '@app/features/channel/components/channel_search_results/SearchResultsStateViews'; +import {useChannelSearchHighlight} from '@app/features/channel/components/channel_search_results/useChannelSearchHighlight'; import type {MessageGroupRenderWrapperProps} from '@app/features/channel/components/MessageGroup'; import { buildSearchResultGroups, @@ -47,17 +50,13 @@ import * as MessageCommands from '@app/features/messaging/commands/MessageComman import {MessageContextPrefix} from '@app/features/messaging/components/message_context_prefix/MessageContextPrefix'; import {useMessageListKeyboardNavigation} from '@app/features/messaging/hooks/useMessageListKeyboardNavigation'; import {useMessageSelectionCopyForMessages} from '@app/features/messaging/hooks/useMessageSelectionCopy'; +import {NearViewportSurfaceContext} from '@app/features/messaging/hooks/useNearViewport'; import type {Message} from '@app/features/messaging/models/MessagingMessage'; -import { - applyChannelSearchHighlight, - clearChannelSearchHighlight, -} from '@app/features/messaging/utils/ChannelSearchHighlight'; import {focusChannelTextareaAfterNavigation} from '@app/features/messaging/utils/ChannelTextareaFocusUtils'; import {getCollapsedMessageGroupKey} from '@app/features/messaging/utils/MessageGroupingUtils'; import LocalUserSpamOverride from '@app/features/moderation/state/LocalUserSpamOverride'; import * as NavigationCommands from '@app/features/navigation/commands/NavigationCommands'; import {SearchResultOpenFailedModal} from '@app/features/search/components/alerts/SearchResultOpenFailedModal'; -import {tokenizeSearchQuery} from '@app/features/search/utils/SearchQueryTokenizer'; import type {SearchSegment} from '@app/features/search/utils/SearchSegmentManager'; import { isIndexing, @@ -95,6 +94,10 @@ export const ChannelSearchResults = observer( ({channel, searchQuery, searchSegments, onClose, refreshKey}: ChannelSearchResultsProps) => { const {i18n} = useLingui(); const scrollerRef = useRef(null); + const resolveSearchResultsScrollSurface = useMemo( + () => () => scrollerRef.current?.getViewportElement() ?? null, + [], + ); const pollingTimeout = useRef(null); const currentChannelId = useRef(channel.id); const currentSearchQuery = useRef(searchQuery); @@ -114,6 +117,7 @@ export const ChannelSearchResults = observer( const machineState = searchContext?.machineState ?? {status: 'loading' as const}; const scrollPosition = searchContext?.scrollPosition ?? 0; const lastKnownScrollPosition = useRef(scrollPosition); + const unsearchableQuery = searchContext?.unsearchableQuery ?? ''; const successMachineState = machineState.status === 'success' ? machineState : null; const indexingMachineState = machineState.status === 'indexing' ? machineState : null; const normalizedRefreshKey = refreshKey ?? null; @@ -559,6 +563,16 @@ export const ChannelSearchResults = observer( }, [ensureSearchChannelReady, navigateToSearchMessage, showSearchResultOpenFailedModal], ); + const handleResultRowClick = useCallback( + (event: React.MouseEvent, targetChannel: Channel, message: Message) => { + if (!shouldSearchResultRowJump(event.target as Node, event.currentTarget)) { + event.stopPropagation(); + return; + } + void handleSearchMessageJump(targetChannel, message); + }, + [handleSearchMessageJump], + ); const handlePaginationJump = useCallback( (page: number) => { resetScrollerToTop(); @@ -567,6 +581,14 @@ export const ChannelSearchResults = observer( [resetScrollerToTop, performSearch], ); const renderContent = useCallback(() => { + if (unsearchableQuery !== '') { + return ( + + ); + } switch (machineState.status) { case 'idle': case 'loading': @@ -592,132 +614,147 @@ export const ChannelSearchResults = observer( value={collapsedMessageVisibility} data-flx="channel.channel-search-results.render-content.collapsed-message-visibility-provider" > - - {resultGroups.map((resultGroup) => { - const renderData = getSearchResultChannelRenderData( - resultGroup.channelId, - searchChannelsById, - (activeScope ?? DEFAULT_SCOPE_VALUE) as MessageSearchScope, - ); - if (!renderData) return null; - const {messageChannel, showGuildMeta} = renderData; - const renderMessageActions = (message: Message) => ( - - - - ); - const renderMessageWrapper = ({ - message, - index, - isGroupStart, - children, - }: MessageGroupRenderWrapperProps) => ( -
- {children} -
- ); - return ( - - { - void handleSearchChannelOpen(messageChannel); + + + ); + const renderMessageWrapper = ({ + message, + index, + isGroupStart, + children, + }: MessageGroupRenderWrapperProps) => ( + // biome-ignore lint/a11y/noStaticElementInteractions: mouse convenience; the row's Jump button is the keyboard affordance. + // biome-ignore lint/a11y/useKeyWithClickEvents: mouse convenience; the row's Jump button is the keyboard affordance. +
{ + handleResultRowClick(event, messageChannel, message); }} - data-flx="channel.channel-search-results.render-content.message-context-prefix" - /> - ( -
- + data-flx="channel.channel-search-results.render-message-wrapper.message-item" + > + {children} +
+ ); + return ( + + { + void handleSearchChannelOpen(messageChannel); + }} + data-flx="channel.channel-search-results.render-content.message-context-prefix" + /> + ( + // biome-ignore lint/a11y/noStaticElementInteractions: mouse convenience; the row's Jump button is the keyboard affordance. + // biome-ignore lint/a11y/useKeyWithClickEvents: mouse convenience; the row's Jump button is the keyboard affordance.
{ + handleResultRowClick(event, messageChannel, message); + }} + data-flx="channel.channel-search-results.render-content.message-item" > - {renderMessageActions(message)} + +
+ {renderMessageActions(message)} +
-
- )} - data-flx="channel.channel-search-results.render-content.search-result-message-list" - /> -
- ); - })} -
- - + )} + data-flx="channel.channel-search-results.render-content.search-result-message-list" + /> + + ); + })} +
+ + + ); } } }, [ + unsearchableQuery, machineState, performSearch, setScrollerRef, + resolveSearchResultsScrollSurface, handleScrollerScroll, visiblePageSlots, activeScope, handleSearchChannelOpen, handleSearchMessageJump, + handleResultRowClick, handlePaginationJump, onCopySelectedMessages, collapsedMessageVisibility, @@ -822,24 +859,12 @@ export const ChannelSearchResults = observer( updateScrollPosition(lastKnownScrollPosition.current); }; }, [stopPolling, updateScrollPosition]); - useEffect(() => { - if (machineState.status !== 'success' || !searchQuery.trim()) { - clearChannelSearchHighlight(); - return; - } - const scrollerNode = scrollerRef.current?.getViewportElement(); - if (!scrollerNode) return; - const timer = setTimeout(() => { - const terms = tokenizeSearchQuery(searchQuery); - if (terms.length > 0) { - applyChannelSearchHighlight(scrollerNode, terms); - } - }, 50); - return () => { - clearTimeout(timer); - clearChannelSearchHighlight(); - }; - }, [machineState.status, searchQuery, successMachineState?.results]); + useChannelSearchHighlight({ + isSuccess: machineState.status === 'success', + searchQuery, + resultsRevision: successMachineState?.results, + getHighlightRoot: () => scrollerRef.current?.getViewportElement() ?? null, + }); return (
{ + const view = node.ownerDocument?.defaultView; + if (view == null) return false; + return node instanceof view.Element; +}; + +export const shouldSearchResultRowJump = (target: Node | null, container: Node | null): boolean => { + const selection = window?.getSelection(); + if (selection != null && !selection.isCollapsed) return false; + if (target == null || container == null) return true; + let node: Node | null = target; + while (node != null && isElementNode(node) && node !== container) { + const {tagName} = node as Element; + if (tagName === 'A' || tagName === 'BUTTON') return false; + if (tagName === 'IMG' && (node as Element).getAttribute('aria-hidden') !== 'true') return false; + node = node.parentNode; + } + return true; +}; diff --git a/fluxer_app/src/features/channel/components/channel_search_results/SearchResultsStateViews.tsx b/fluxer_app/src/features/channel/components/channel_search_results/SearchResultsStateViews.tsx index 6b0b9711c..b6a382bb4 100644 --- a/fluxer_app/src/features/channel/components/channel_search_results/SearchResultsStateViews.tsx +++ b/fluxer_app/src/features/channel/components/channel_search_results/SearchResultsStateViews.tsx @@ -4,14 +4,16 @@ import styles from '@app/features/channel/components/ChannelSearchResults.module import { ERROR_DESCRIPTOR, INDEXING_CHANNEL_DESCRIPTOR, + NO_PART_OF_THIS_QUERY_APPLIED_DESCRIPTOR, NO_RESULTS_DESCRIPTOR, + NOTHING_TO_SEARCH_FOR_DESCRIPTOR, TRY_A_DIFFERENT_SEARCH_QUERY_DESCRIPTOR, WE_RE_INDEXING_THIS_CHANNEL_FOR_THE_FIRST_DESCRIPTOR, } from '@app/features/channel/components/channel_search_results/ChannelSearchResultsShared'; import {TRY_AGAIN_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors'; import {Button} from '@app/features/ui/button/Button'; import {useLingui} from '@lingui/react/macro'; -import {CircleNotchIcon, MagnifyingGlassIcon} from '@phosphor-icons/react'; +import {CircleNotchIcon, MagnifyingGlassIcon, WarningCircleIcon} from '@phosphor-icons/react'; import type React from 'react'; export const SearchIndexingState: React.FC = () => { @@ -95,3 +97,43 @@ export const SearchEmptyState: React.FC = () => {
); }; + +interface SearchUnappliedQueryStateProps { + query: string; +} + +export const SearchUnappliedQueryState: React.FC = ({query}) => { + const {i18n} = useLingui(); + return ( +
+
+ +
+

+ {i18n._(NOTHING_TO_SEARCH_FOR_DESCRIPTOR)} +

+

+ {i18n._(NO_PART_OF_THIS_QUERY_APPLIED_DESCRIPTOR, {query})} +

+
+
+
+ ); +}; diff --git a/fluxer_app/src/features/channel/components/channel_search_results/useChannelSearchHighlight.ts b/fluxer_app/src/features/channel/components/channel_search_results/useChannelSearchHighlight.ts new file mode 100644 index 000000000..9fc863147 --- /dev/null +++ b/fluxer_app/src/features/channel/components/channel_search_results/useChannelSearchHighlight.ts @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { + applyChannelSearchHighlight, + clearChannelSearchHighlight, +} from '@app/features/messaging/utils/ChannelSearchHighlight'; +import {tokenizeSearchQuery} from '@app/features/search/utils/SearchQueryTokenizer'; +import {useLayoutEffect} from 'react'; + +interface UseChannelSearchHighlightRequest { + isSuccess: boolean; + searchQuery: string; + resultsRevision: unknown; + getHighlightRoot: () => HTMLElement | null; +} + +export function useChannelSearchHighlight({ + isSuccess, + searchQuery, + resultsRevision, + getHighlightRoot, +}: UseChannelSearchHighlightRequest): void { + useLayoutEffect(() => { + if (!isSuccess || !searchQuery.trim()) { + clearChannelSearchHighlight(); + return; + } + const root = getHighlightRoot(); + if (root == null) return; + const terms = tokenizeSearchQuery(searchQuery); + if (terms.length > 0) { + applyChannelSearchHighlight(root, terms); + } + return () => { + clearChannelSearchHighlight(); + }; + }, [isSuccess, searchQuery, resultsRevision]); +} diff --git a/fluxer_app/src/features/channel/components/channel_view/useChannelSearchState.ts b/fluxer_app/src/features/channel/components/channel_view/useChannelSearchState.ts index 89eb94f40..7d04e243e 100644 --- a/fluxer_app/src/features/channel/components/channel_view/useChannelSearchState.ts +++ b/fluxer_app/src/features/channel/components/channel_view/useChannelSearchState.ts @@ -4,6 +4,7 @@ import type {Channel} from '@app/features/channel/models/Channel'; import ChannelSearch, {getChannelSearchContextId} from '@app/features/channel/state/ChannelSearch'; import SelectedGuild from '@app/features/navigation/state/SelectedGuild'; import type {SearchSegment} from '@app/features/search/utils/SearchSegmentManager'; +import {hasSearchableParams, parseSearchQueryWithSegments} from '@app/features/search/utils/SearchUtils'; import {useCallback, useMemo} from 'react'; interface UseChannelSearchStateReturn { @@ -40,10 +41,24 @@ export const useChannelSearchState = (channel?: Channel): UseChannelSearchStateR if (!contextId) { return; } + const submitContext = ChannelSearch.getContext(contextId); + if (submitContext?.machineState.status === 'loading' && submitContext.activeSearchQuery === query) { + return; + } ChannelSearch.setSearchInput(contextId, query, segments); + const params = parseSearchQueryWithSegments(query, segments, { + historyKey: contextId, + guildId: channel?.guildId, + }); + if (!hasSearchableParams(params)) { + if (query.trim().length > 0) { + ChannelSearch.setUnsearchableSearch(contextId, query); + } + return; + } ChannelSearch.setActiveSearch(contextId, query, segments); }, - [contextId], + [contextId, channel?.id, channel?.guildId], ); const handleSearchClose = useCallback(() => { if (!contextId) { diff --git a/fluxer_app/src/features/channel/state/ChannelSearch.ts b/fluxer_app/src/features/channel/state/ChannelSearch.ts index 0fe3e232c..8d83c69e1 100644 --- a/fluxer_app/src/features/channel/state/ChannelSearch.ts +++ b/fluxer_app/src/features/channel/state/ChannelSearch.ts @@ -24,6 +24,7 @@ class ChannelSearchContext { activeSearchQuery: string = ''; activeSearchSegments: Array = []; isSearchActive = false; + unsearchableQuery = ''; isInputFocused = false; searchRefreshKey = 0; machineSnapshot: SearchMachineSnapshot = createSearchMachineSnapshot(); @@ -74,10 +75,28 @@ class ChannelSearch { const context = this.getContext(contextId); context.activeSearchQuery = query; context.activeSearchSegments = [...segments]; + context.unsearchableQuery = ''; context.isSearchActive = true; context.searchRefreshKey += 1; } + setUnsearchableSearch(contextId: string, query: string): void { + const context = this.getContext(contextId); + context.activeSearchQuery = ''; + context.activeSearchSegments = []; + context.unsearchableQuery = query; + context.isSearchActive = true; + context.searchRefreshKey += 1; + context.lastSearchQuery = ''; + context.lastSearchSegments = []; + context.lastSearchRefreshKey = null; + context.lastSearchScope = null; + context.lastSearchSortMode = null; + context.machineSnapshot = transitionSearchMachineSnapshot(context.machineSnapshot, { + type: 'channelSearch.reset', + }); + } + setIsSearchActive(contextId: string, value: boolean): void { const context = this.getContext(contextId); context.isSearchActive = value; @@ -94,6 +113,7 @@ class ChannelSearch { context.searchSegments = []; context.activeSearchQuery = ''; context.activeSearchSegments = []; + context.unsearchableQuery = ''; context.isSearchActive = false; context.searchRefreshKey = 0; context.lastSearchRefreshKey = null;