Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 19 additions & 5 deletions apps/meteor/client/components/ResultsLiveRegion.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,28 @@
import { VisuallyHidden } from '@react-aria/visually-hidden';
import { useTranslation } from 'react-i18next';

const ResultsLiveRegion = ({ shouldAnnounce, itemCount }: { shouldAnnounce: boolean; itemCount: number }) => {
const ResultsLiveRegion = ({
shouldAnnounce,
itemCount,
isLoading = false,
}: {
shouldAnnounce: boolean;
itemCount: number;
isLoading?: boolean;
}) => {
const { t } = useTranslation();

if (itemCount === 0) {
return <VisuallyHidden role='status'>{shouldAnnounce && t('No_results_found')}</VisuallyHidden>;
}
const message = (() => {
if (isLoading) {
return t('Loading');
}
if (!shouldAnnounce) {
return '';
}
return itemCount === 0 ? t('No_results_found') : t('__count__result_found', { count: itemCount });
})();

return <VisuallyHidden role='status'>{shouldAnnounce && t('__count__result_found', { count: itemCount })}</VisuallyHidden>;
return <VisuallyHidden role='status'>{message}</VisuallyHidden>;
};

export default ResultsLiveRegion;
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { SidebarV2Item, SidebarV2ItemAvatarWrapper, SidebarV2ItemTitle, Skeleton } from '@rocket.chat/fuselage';

// Placeholder row shown while the server spotlight results are still loading.
// Decorative only: it has no `role='option'` so keyboard navigation skips it
// (see useSearchNavigation), and `tabIndex={-1}` + `pointerEvents='none'` keep
// it out of reach of focus and mouse clicks. Loading is conveyed via aria-busy.
const NavBarSearchItemSkeleton = () => {
return (
<SidebarV2Item aria-hidden tabIndex={-1} style={{ pointerEvents: 'none' }}>
<SidebarV2ItemAvatarWrapper>
<Skeleton variant='rect' width={20} height={20} />
</SidebarV2ItemAvatarWrapper>
<SidebarV2ItemTitle>
<Skeleton />
</SidebarV2ItemTitle>
</SidebarV2Item>
);
};

export default NavBarSearchItemSkeleton;
10 changes: 5 additions & 5 deletions apps/meteor/client/navbar/NavBarSearch/NavBarSearchListbox.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import type { OverlayTriggerAria } from '@react-aria/overlays';
import type { OverlayTriggerState } from '@react-stately/overlays';
import { Box, Tile } from '@rocket.chat/fuselage';
import { useDebouncedValue, useStableCallback, useOutsideClick } from '@rocket.chat/fuselage-hooks';
import { useStableCallback, useOutsideClick } from '@rocket.chat/fuselage-hooks';
import { CustomScrollbars } from '@rocket.chat/ui-client';
import { useRef } from 'react';
import { useFormContext } from 'react-hook-form';
import { useTranslation } from 'react-i18next';

import NavBarSearchItemSkeleton from './NavBarSearchItemSkeleton';
import NavBarSearchNoResults from './NavBarSearchNoResults';
import NavBarSearchRow from './NavBarSearchRow';
import { useSearchItems } from './hooks/useSearchItems';
Expand All @@ -28,14 +29,12 @@ const NavBarSearchListBox = ({ state, overlayProps }: NavBarSearchListBoxProps)
const { resetField, watch } = useFormContext();
const { filterText } = watch();

const debouncedFilter = useDebouncedValue(filterText, 500);

const handleSelect = useStableCallback(() => {
state.close();
resetField('filterText');
});

const { data: items = [], isLoading } = useSearchItems(debouncedFilter);
const { items, isLoading } = useSearchItems(filterText);

return (
<Tile
Expand All @@ -51,7 +50,7 @@ const NavBarSearchListBox = ({ state, overlayProps }: NavBarSearchListBoxProps)
width='100%'
flexDirection='column'
>
<ResultsLiveRegion shouldAnnounce={!isLoading} itemCount={items.length} />
<ResultsLiveRegion shouldAnnounce={!isLoading} itemCount={items.length} isLoading={isLoading} />
<CustomScrollbars>
<div {...overlayProps} role='listbox' aria-label={t('Channels')} aria-busy={isLoading} tabIndex={-1} onKeyDown={handleKeyDown}>
{items.length === 0 && !isLoading && <NavBarSearchNoResults />}
Expand All @@ -63,6 +62,7 @@ const NavBarSearchListBox = ({ state, overlayProps }: NavBarSearchListBoxProps)
{items.map((item) => (
<NavBarSearchRow key={item._id} room={item} onClick={handleSelect} />
))}
{isLoading && Array.from({ length: 4 }, (_, index) => <NavBarSearchItemSkeleton key={`skeleton-${index}`} />)}
</div>
</CustomScrollbars>
</Tile>
Expand Down
88 changes: 64 additions & 24 deletions apps/meteor/client/navbar/NavBarSearch/hooks/useSearchItems.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { useDebouncedValue } from '@rocket.chat/fuselage-hooks';
import { escapeRegExp } from '@rocket.chat/string-helpers';
import type { SubscriptionWithRoom } from '@rocket.chat/ui-contexts';
import { useEndpoint, useUserSubscriptions } from '@rocket.chat/ui-contexts';
import { useQuery, type UseQueryResult } from '@tanstack/react-query';
import { useQuery } from '@tanstack/react-query';
import { useMemo } from 'react';

import { getConfig } from '../../../lib/utils/getConfig';
Expand All @@ -16,9 +17,9 @@ const options = {
limit: LIMIT,
} as const;

// FIXME: the return type is UTTERLY wrong, but I'm not sure what it should be
export const useSearchItems = (filterText: string): UseQueryResult<SubscriptionWithRoom[] | undefined, Error> => {
export const useSearchItems = (filterText: string): { items: SubscriptionWithRoom[]; isLoading: boolean } => {
const [, mention, name] = useMemo(() => filterText.match(/(@|#)?(.*)/i) || [], [filterText]);

const query = useMemo(() => {
const filterRegex = new RegExp(escapeRegExp(name), 'i');

Expand All @@ -30,8 +31,13 @@ export const useSearchItems = (filterText: string): UseQueryResult<SubscriptionW
};
}, [name, mention]);

// Local cached subscriptions are matched against the *immediate* filter text so joined
// rooms show up instantly, without waiting for the debounce or the server response.
const localRooms = useUserSubscriptions(query, options);

// Only the server spotlight call is debounced — the local results above stay instant.
const debouncedName = useDebouncedValue(name, 500);

const usernamesFromClient = [...localRooms?.map(({ t, name }) => (t === 'd' ? name : null))].filter(Boolean) as string[];

const searchForChannels = mention === '#';
Expand All @@ -49,32 +55,27 @@ export const useSearchItems = (filterText: string): UseQueryResult<SubscriptionW

const getSpotlight = useEndpoint('GET', '/v1/spotlight');

return useQuery({
queryKey: ['sidebar/search/spotlight', name, usernamesFromClient, type, localRooms.map(({ _id, name }) => _id + name)],
const {
data: serverResults,
isFetching,
isPlaceholderData,
} = useQuery({
// Keyed on the debounced term only, so typing doesn't refetch on every keystroke.
queryKey: ['sidebar/search/spotlight', debouncedName, mention, type],

queryFn: async () => {
if (localRooms.length === LIMIT) {
return localRooms;
}
// When local subscriptions already fill the limit there's nothing more to fetch.
enabled: localRooms.length < LIMIT,

queryFn: async () => {
const spotlight = await getSpotlight({
query: name,
query: debouncedName,
usernames: usernamesFromClient.join(','),
type: JSON.stringify(type),
});

const filterUsersUnique = ({ _id }: { _id: string }, index: number, arr: { _id: string }[]): boolean =>
index === arr.findIndex((user) => _id === user._id);

const roomFilter = (room: { t: string; uids?: string[]; _id: string; name?: string }): boolean =>
!localRooms.find(
(item) =>
(room.t === 'd' && room.uids && room.uids.length > 1 && room.uids?.includes(item._id)) ||
[item.rid, item._id].includes(room._id),
);
const usersFilter = (user: { _id: string }): boolean =>
!localRooms.find((room) => room.t === 'd' && room.uids && room.uids?.length === 2 && room.uids.includes(user._id));

const userMap = (user: {
_id: string;
name: string;
Expand Down Expand Up @@ -104,15 +105,54 @@ export const useSearchItems = (filterText: string): UseQueryResult<SubscriptionW
uids?: string[] | undefined;
}[];

// Local subscriptions are deduped reactively in the merge below (against the *current*
// localRooms), so server results aren't filtered against them here.
const resultsFromServer: resultsFromServerType = [];
resultsFromServer.push(...spotlight.users.filter(filterUsersUnique).filter(usersFilter).map(userMap));
resultsFromServer.push(...spotlight.rooms.filter(roomFilter));
resultsFromServer.push(...spotlight.users.filter(filterUsersUnique).map(userMap));
resultsFromServer.push(...spotlight.rooms);

const exact = resultsFromServer?.filter((item) => [item.name, item.fname].includes(name));
return Array.from(new Set([...exact, ...localRooms, ...resultsFromServer]));
return resultsFromServer;
},

staleTime: 60_000,
placeholderData: (previousData) => previousData ?? localRooms,
// Keep the previous server results visible while a new search is in flight.
placeholderData: (previousData) => previousData,
});

// Merge reactively (outside the query) so local results render the instant the user types
// and server results fold in — deduped — once they arrive.
const items = useMemo(() => {
// Server results are keyed on the *debounced* term, so while the user is still typing
// (or via placeholderData) they may belong to a previous search. Drop the ones that no
// longer match the current text so stale, non-matching results aren't rendered.
const filterRegex = new RegExp(escapeRegExp(name), 'i');
const matchesFilter = ({ name, fname }: { name?: string; fname?: string }) =>
(name && filterRegex.test(name)) || (fname && filterRegex.test(fname));

// Single source of truth for local/server dedup: drop any server result already present as a
// local subscription, checked against the *current* localRooms. The query isn't keyed on
// localRooms (to avoid refetching on every subscription change), so subscriptions that load
// in after the fetch would otherwise render twice — once from localRooms, once from server.
const isLocalDuplicate = (item: { _id: string; t?: string; uids?: string[] }): boolean =>
localRooms.some((room) => {
const sameRoom = [room.rid, room._id].includes(item._id);
const sameGroupDM = item.t === 'd' && !!item.uids && item.uids.length > 1 && item.uids.includes(room._id);
const sameDirectDM = item.t === 'd' && room.t === 'd' && !!room.uids && room.uids.length === 2 && room.uids.includes(item._id);
return sameRoom || sameGroupDM || sameDirectDM;
});

// When local subscriptions already fill the limit the server query is disabled, but React Query
// keeps the last results around — ignore them so a full local list isn't padded with stale rows.
const candidates = localRooms.length < LIMIT ? (serverResults ?? []) : [];
const fromServer = candidates.filter((item) => matchesFilter(item) && !isLocalDuplicate(item));
const exact = fromServer.filter((item) => [item.name, item.fname].includes(name));
return Array.from(new Set([...exact, ...localRooms, ...fromServer])) as SubscriptionWithRoom[];
}, [serverResults, localRooms, name]);

// `isFetching` is also true for silent background revalidations (after staleTime) of results we
// already show — only surface loading while there's no usable data for the current term yet, i.e.
// the very first fetch (serverResults undefined) or a new term still showing placeholder data.
const isLoading = isFetching && (isPlaceholderData || serverResults === undefined);

return { items, isLoading };
};
Loading