fix(app): tell the user when a search query has nothing to search (#1901)

This commit is contained in:
Hampus
2026-08-24 12:42:20 +02:00
committed by GitHub
parent bb34c73069
commit b71ced9b2e
8 changed files with 303 additions and 136 deletions
@@ -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;
@@ -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<ScrollerHandle | null>(null);
const resolveSearchResultsScrollSurface = useMemo(
() => () => scrollerRef.current?.getViewportElement() ?? null,
[],
);
const pollingTimeout = useRef<number | null>(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<HTMLDivElement>, 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 (
<SearchUnappliedQueryState
query={unsearchableQuery}
data-flx="channel.channel-search-results.render-content.search-unapplied-query-state"
/>
);
}
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"
>
<Scroller
ref={setScrollerRef}
className={styles.resultsScroller}
onScroll={handleScrollerScroll}
fade={false}
key="channel-search-results-scroller-desktop"
onCopy={onCopySelectedMessages}
data-message-selection-root="true"
data-flx="channel.channel-search-results.render-content.results-scroller"
>
{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) => (
<FocusRing
offset={-2}
ringClassName={styles.focusRingTight}
data-flx="channel.channel-search-results.render-message-actions.focus-ring"
>
<button
type="button"
className={styles.jumpButton}
onClick={() => {
void handleSearchMessageJump(messageChannel, message);
}}
data-flx="channel.channel-search-results.render-message-actions.jump-button"
<NearViewportSurfaceContext.Provider value={resolveSearchResultsScrollSurface}>
<Scroller
ref={setScrollerRef}
className={styles.resultsScroller}
onScroll={handleScrollerScroll}
fade={false}
key="channel-search-results-scroller-desktop"
onCopy={onCopySelectedMessages}
data-message-selection-root="true"
data-flx="channel.channel-search-results.render-content.results-scroller"
>
{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) => (
<FocusRing
offset={-2}
ringClassName={styles.focusRingTight}
data-flx="channel.channel-search-results.render-message-actions.focus-ring"
>
{i18n._(JUMP_DESCRIPTOR)}
</button>
</FocusRing>
);
const renderMessageWrapper = ({
message,
index,
isGroupStart,
children,
}: MessageGroupRenderWrapperProps) => (
<div
data-message-index={index}
data-message-id={message.id}
data-is-group-start={isGroupStart}
className={styles.messageItem}
data-flx="channel.channel-search-results.render-message-wrapper.message-item"
>
{children}
</div>
);
return (
<React.Fragment key={resultGroup.key}>
<MessageContextPrefix
channel={messageChannel}
showGuildMeta={showGuildMeta}
onClick={() => {
void handleSearchChannelOpen(messageChannel);
<button
type="button"
className={styles.jumpButton}
onClick={() => {
void handleSearchMessageJump(messageChannel, message);
}}
data-flx="channel.channel-search-results.render-message-actions.jump-button"
>
{i18n._(JUMP_DESCRIPTOR)}
</button>
</FocusRing>
);
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.
<div
data-message-index={index}
data-message-id={message.id}
data-is-group-start={isGroupStart}
className={styles.messageItem}
onClick={(event) => {
handleResultRowClick(event, messageChannel, message);
}}
data-flx="channel.channel-search-results.render-content.message-context-prefix"
/>
<SearchResultMessageList
channel={messageChannel}
messages={resultGroup.messages}
revealedGroupKeys={revealedGroupKeys}
onGroupRevealChange={handleCollapsedGroupRevealChange}
collapsedGroupClassName={styles.collapsedMessageGroup}
messagePreviewContext={MessagePreviewContext.LIST_POPOUT}
messageBehaviorOverrides={DETACHED_MESSAGE_BEHAVIOR}
messageActionsClassName={styles.actionButtons}
renderMessageActions={renderMessageActions}
renderMessageWrapper={renderMessageWrapper}
spammerOverrideVersion={spammerOverrideVersion}
renderMessage={(message) => (
<div
className={styles.messageItem}
data-message-id={message.id}
data-is-group-start="true"
data-flx="channel.channel-search-results.render-content.message-item"
>
<MessageComponent
message={message}
channel={messageChannel}
previewContext={MessagePreviewContext.LIST_POPOUT}
behaviorOverrides={DETACHED_MESSAGE_BEHAVIOR}
data-flx="channel.channel-search-results.render-content.message-component"
/>
data-flx="channel.channel-search-results.render-message-wrapper.message-item"
>
{children}
</div>
);
return (
<React.Fragment key={resultGroup.key}>
<MessageContextPrefix
channel={messageChannel}
showGuildMeta={showGuildMeta}
onClick={() => {
void handleSearchChannelOpen(messageChannel);
}}
data-flx="channel.channel-search-results.render-content.message-context-prefix"
/>
<SearchResultMessageList
channel={messageChannel}
messages={resultGroup.messages}
revealedGroupKeys={revealedGroupKeys}
onGroupRevealChange={handleCollapsedGroupRevealChange}
collapsedGroupClassName={styles.collapsedMessageGroup}
messagePreviewContext={MessagePreviewContext.LIST_POPOUT}
messageBehaviorOverrides={DETACHED_MESSAGE_BEHAVIOR}
messageActionsClassName={styles.actionButtons}
renderMessageActions={renderMessageActions}
renderMessageWrapper={renderMessageWrapper}
spammerOverrideVersion={spammerOverrideVersion}
renderMessage={(message) => (
// 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.
<div
className={styles.actionButtons}
data-flx="channel.channel-search-results.render-content.action-buttons"
className={styles.messageItem}
data-message-id={message.id}
data-is-group-start="true"
onClick={(event) => {
handleResultRowClick(event, messageChannel, message);
}}
data-flx="channel.channel-search-results.render-content.message-item"
>
{renderMessageActions(message)}
<MessageComponent
message={message}
channel={messageChannel}
previewContext={MessagePreviewContext.LIST_POPOUT}
behaviorOverrides={DETACHED_MESSAGE_BEHAVIOR}
data-flx="channel.channel-search-results.render-content.message-component"
/>
<div
className={styles.actionButtons}
data-flx="channel.channel-search-results.render-content.action-buttons"
>
{renderMessageActions(message)}
</div>
</div>
</div>
)}
data-flx="channel.channel-search-results.render-content.search-result-message-list"
/>
</React.Fragment>
);
})}
<div
className={styles.resultsSpacer}
data-flx="channel.channel-search-results.render-content.results-spacer"
/>
<SearchResultsPagination
currentPage={currentPage}
totalPages={totalPages}
visiblePageSlots={visiblePageSlots}
onJumpToPage={handlePaginationJump}
data-flx="channel.channel-search-results.render-content.search-results-pagination"
/>
</Scroller>
)}
data-flx="channel.channel-search-results.render-content.search-result-message-list"
/>
</React.Fragment>
);
})}
<div
className={styles.resultsSpacer}
data-flx="channel.channel-search-results.render-content.results-spacer"
/>
<SearchResultsPagination
currentPage={currentPage}
totalPages={totalPages}
visiblePageSlots={visiblePageSlots}
onJumpToPage={handlePaginationJump}
data-flx="channel.channel-search-results.render-content.search-results-pagination"
/>
</Scroller>
</NearViewportSurfaceContext.Provider>
</CollapsedMessageVisibilityProvider>
);
}
}
}, [
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 (
<div className={styles.container} data-flx="channel.channel-search-results.container">
<SearchResultsHeader
@@ -79,6 +79,15 @@ export const TRY_A_DIFFERENT_SEARCH_QUERY_DESCRIPTOR = msg({
message: 'Try a different search query.',
comment: 'Body copy under the no-results heading suggesting the user adjust their query.',
});
export const NOTHING_TO_SEARCH_FOR_DESCRIPTOR = msg({
message: 'Nothing to search for',
comment: 'Heading shown when the submitted search query produced no filter and no text the server could search on.',
});
export const NO_PART_OF_THIS_QUERY_APPLIED_DESCRIPTOR = msg({
message: 'No part of {query} could be applied. Fix the underlined value or add something to search for.',
comment:
'Body copy under the nothing-to-search-for heading. Preserve {query}; it is the text the user submitted and is inserted by code.',
});
export const GO_TO_PAGE_DESCRIPTOR = msg({
message: 'Go to page {page}',
comment: 'Accessible label of each pagination page button. page is the 1-indexed page number.',
@@ -0,0 +1,21 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
const isElementNode = (node: Node): boolean => {
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;
};
@@ -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 = () => {
</div>
);
};
interface SearchUnappliedQueryStateProps {
query: string;
}
export const SearchUnappliedQueryState: React.FC<SearchUnappliedQueryStateProps> = ({query}) => {
const {i18n} = useLingui();
return (
<div className={styles.emptyState} data-flx="channel.channel-search-results.render-content.unapplied-state">
<div
className={styles.emptyStateContent}
data-flx="channel.channel-search-results.render-content.unapplied-state-content"
>
<WarningCircleIcon
className={styles.emptyStateIcon}
data-flx="channel.channel-search-results.render-content.unapplied-state-icon"
/>
<div
className={styles.emptyStateTextWrapper}
data-flx="channel.channel-search-results.render-content.unapplied-state-text-wrapper"
>
<h3
className={styles.emptyStateHeading}
role="status"
aria-live="polite"
data-flx="channel.channel-search-results.render-content.unapplied-state-heading"
>
{i18n._(NOTHING_TO_SEARCH_FOR_DESCRIPTOR)}
</h3>
<p
className={styles.emptyStateText}
data-flx="channel.channel-search-results.render-content.unapplied-state-text"
>
{i18n._(NO_PART_OF_THIS_QUERY_APPLIED_DESCRIPTOR, {query})}
</p>
</div>
</div>
</div>
);
};
@@ -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]);
}
@@ -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) {
@@ -24,6 +24,7 @@ class ChannelSearchContext {
activeSearchQuery: string = '';
activeSearchSegments: Array<SearchSegment> = [];
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;