RocketChat/Rocket.Chat

View on GitHub
apps/meteor/client/sidebarv2/search/SearchList.tsx

Summary

Maintainability
F
2 wks
Test Coverage
import type { IRoom, ISubscription } from '@rocket.chat/core-typings';
import { css } from '@rocket.chat/css-in-js';
import { Sidebar, TextInput, Box, Icon } from '@rocket.chat/fuselage';
import { useMutableCallback, useDebouncedValue, useAutoFocus, useUniqueId, useMergedRefs } from '@rocket.chat/fuselage-hooks';
import { escapeRegExp } from '@rocket.chat/string-helpers';
import { useUserPreference, useUserSubscriptions, useSetting, useTranslation, useMethod } from '@rocket.chat/ui-contexts';
import type { UseQueryResult } from '@tanstack/react-query';
import { useQuery } from '@tanstack/react-query';
import type {
    ReactElement,
    MutableRefObject,
    SetStateAction,
    Dispatch,
    FormEventHandler,
    Ref,
    MouseEventHandler,
    ForwardedRef,
} from 'react';
import React, { forwardRef, useState, useMemo, useEffect, useRef } from 'react';
import type { VirtuosoHandle } from 'react-virtuoso';
import { Virtuoso } from 'react-virtuoso';
import tinykeys from 'tinykeys';

import { VirtuosoScrollbars } from '../../components/CustomScrollbars';
import { getConfig } from '../../lib/utils/getConfig';
import { useAvatarTemplate } from '../hooks/useAvatarTemplate';
import { usePreventDefault } from '../hooks/usePreventDefault';
import { useTemplateByViewMode } from '../hooks/useTemplateByViewMode';
import Row from './Row';

const mobileCheck = function () {
    let check = false;
    (function (a: string) {
        if (
            /(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino/i.test(
                a,
            ) ||
            /1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i.test(
                a.substr(0, 4),
            )
        )
            check = true;
    })(navigator.userAgent || navigator.vendor || window.opera || '');
    return check;
};

declare global {
    // eslint-disable-next-line @typescript-eslint/naming-convention
    interface Window {
        opera?: string;
    }
    // eslint-disable-next-line @typescript-eslint/naming-convention
    interface Navigator {
        userAgentData?: {
            mobile: boolean;
        };
    }
}

const shortcut = ((): string => {
    if (navigator.userAgentData?.mobile || mobileCheck()) {
        return '';
    }
    if (window.navigator.platform.toLowerCase().includes('mac')) {
        return '(\u2318+K)';
    }
    return '(Ctrl+K)';
})();

const LIMIT = parseInt(String(getConfig('Sidebar_Search_Spotlight_LIMIT', 20)));

const options = {
    sort: {
        lm: -1,
        name: 1,
    },
    limit: LIMIT,
} as const;

const useSearchItems = (filterText: string): UseQueryResult<(ISubscription & IRoom)[] | undefined, Error> => {
    const [, mention, name] = useMemo(() => filterText.match(/(@|#)?(.*)/i) || [], [filterText]);
    const query = useMemo(() => {
        const filterRegex = new RegExp(escapeRegExp(name), 'i');

        return {
            $or: [{ name: filterRegex }, { fname: filterRegex }],
            ...(mention && {
                t: mention === '@' ? 'd' : { $ne: 'd' },
            }),
        };
    }, [name, mention]);

    const localRooms = useUserSubscriptions(query, options);

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

    const searchForChannels = mention === '#';
    const searchForDMs = mention === '@';

    const type = useMemo(() => {
        if (searchForChannels) {
            return { users: false, rooms: true, includeFederatedRooms: true };
        }
        if (searchForDMs) {
            return { users: true, rooms: false };
        }
        return { users: true, rooms: true, includeFederatedRooms: true };
    }, [searchForChannels, searchForDMs]);

    const getSpotlight = useMethod('spotlight');

    return useQuery(
        ['sidebar/search/spotlight', name, usernamesFromClient, type, localRooms.map(({ _id, name }) => _id + name)],
        async () => {
            if (localRooms.length === LIMIT) {
                return localRooms;
            }

            const spotlight = await getSpotlight(name, usernamesFromClient, 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;
                username: string;
                avatarETag?: string;
            }): {
                _id: string;
                t: string;
                name: string;
                fname: string;
                avatarETag?: string;
            } => ({
                _id: user._id,
                t: 'd',
                name: user.username,
                fname: user.name,
                avatarETag: user.avatarETag,
            });

            type resultsFromServerType = {
                _id: string;
                t: string;
                name: string;
                teamMain?: boolean;
                fname?: string;
                avatarETag?: string | undefined;
                uids?: string[] | undefined;
            }[];

            const resultsFromServer: resultsFromServerType = [];
            resultsFromServer.push(...spotlight.users.filter(filterUsersUnique).filter(usersFilter).map(userMap));
            resultsFromServer.push(...spotlight.rooms.filter(roomFilter));

            const exact = resultsFromServer?.filter((item) => [item.name, item.fname].includes(name));
            return Array.from(new Set([...exact, ...localRooms, ...resultsFromServer]));
        },
        {
            staleTime: 60_000,
            keepPreviousData: true,
            placeholderData: localRooms,
        },
    );
};

const useInput = (initial: string): { value: string; onChange: FormEventHandler; setValue: Dispatch<SetStateAction<string>> } => {
    const [value, setValue] = useState(initial);
    const onChange = useMutableCallback((e) => {
        setValue(e.currentTarget.value);
    });
    return { value, onChange, setValue };
};

const toggleSelectionState = (next: HTMLElement, current: HTMLElement | undefined, input: HTMLElement | undefined): void => {
    input?.setAttribute('aria-activedescendant', next.id);
    next.setAttribute('aria-selected', 'true');
    next.classList.add('rcx-sidebar-item--selected');
    if (current) {
        current.removeAttribute('aria-selected');
        current.classList.remove('rcx-sidebar-item--selected');
    }
};

type SearchListProps = {
    onClose: () => void;
};

const SearchList = forwardRef(function SearchList({ onClose }: SearchListProps, ref: ForwardedRef<any>) {
    const listId = useUniqueId();
    const t = useTranslation();
    const { setValue: setFilterValue, ...filter } = useInput('');

    const cursorRef = useRef<HTMLInputElement>(null);
    const autofocus: Ref<HTMLInputElement> = useMergedRefs(useAutoFocus<HTMLInputElement>(), cursorRef);

    const listRef = useRef<VirtuosoHandle>(null);
    const boxRef = useRef<HTMLDivElement>(null);

    const selectedElement: MutableRefObject<HTMLElement | null | undefined> = useRef(null);
    const itemIndexRef = useRef(0);

    const sidebarViewMode = useUserPreference('sidebarViewMode');
    const useRealName = useSetting('UI_Use_Real_Name');

    const sideBarItemTemplate = useTemplateByViewMode();
    const avatarTemplate = useAvatarTemplate();

    const extended = sidebarViewMode === 'extended';

    const filterText = useDebouncedValue(filter.value, 100);

    const placeholder = [t('Search'), shortcut].filter(Boolean).join(' ');

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

    const itemData = useMemo(
        () => ({
            items,
            t,
            SideBarItemTemplate: sideBarItemTemplate,
            avatarTemplate,
            useRealName,
            extended,
            sidebarViewMode,
        }),
        [avatarTemplate, extended, items, useRealName, sideBarItemTemplate, sidebarViewMode, t],
    );

    const changeSelection = useMutableCallback((dir) => {
        let nextSelectedElement = null;

        if (dir === 'up') {
            const potentialElement = selectedElement.current?.parentElement?.previousSibling as HTMLElement;
            if (potentialElement) {
                nextSelectedElement = potentialElement.querySelector('a');
            }
        } else {
            const potentialElement = selectedElement.current?.parentElement?.nextSibling as HTMLElement;
            if (potentialElement) {
                nextSelectedElement = potentialElement.querySelector('a');
            }
        }

        if (nextSelectedElement) {
            toggleSelectionState(nextSelectedElement, selectedElement.current || undefined, cursorRef?.current || undefined);
            return nextSelectedElement;
        }
        return selectedElement.current;
    });

    const resetCursor = useMutableCallback(() => {
        setTimeout(() => {
            itemIndexRef.current = 0;
            listRef.current?.scrollToIndex({ index: itemIndexRef.current });
            selectedElement.current = boxRef.current?.querySelector('a.rcx-sidebar-item');
            if (selectedElement.current) {
                toggleSelectionState(selectedElement.current, undefined, cursorRef?.current || undefined);
            }
        }, 0);
    });

    usePreventDefault(boxRef);

    useEffect(() => {
        resetCursor();
    });

    useEffect(() => {
        resetCursor();
    }, [filterText, resetCursor]);

    useEffect(() => {
        if (!cursorRef?.current) {
            return;
        }
        return tinykeys(cursorRef?.current, {
            Escape: (event) => {
                event.preventDefault();
                setFilterValue((value) => {
                    if (!value) {
                        onClose();
                    }
                    resetCursor();
                    return '';
                });
            },
            Tab: onClose,
            ArrowUp: () => {
                const currentElement = changeSelection('up');
                itemIndexRef.current = Math.max(itemIndexRef.current - 1, 0);
                listRef.current?.scrollToIndex({ index: itemIndexRef.current });
                selectedElement.current = currentElement;
            },
            ArrowDown: () => {
                const currentElement = changeSelection('down');
                itemIndexRef.current = Math.min(itemIndexRef.current + 1, items.length + 1);
                listRef.current?.scrollToIndex({ index: itemIndexRef.current });
                selectedElement.current = currentElement;
            },
            Enter: (event) => {
                event.preventDefault();
                if (selectedElement.current && items.length > 0) {
                    selectedElement.current.click();
                } else {
                    onClose();
                }
            },
        });
    }, [cursorRef, changeSelection, items.length, onClose, resetCursor, setFilterValue]);

    const handleClick: MouseEventHandler<HTMLElement> = (e): void => {
        if (e.target instanceof Element && [e.target.tagName, e.target.parentElement?.tagName].includes('BUTTON')) {
            return;
        }
        return onClose();
    };

    return (
        <Box
            position='absolute'
            rcx-sidebar
            h='full'
            display='flex'
            flexDirection='column'
            zIndex={99}
            w='full'
            className={css`
                left: 0;
                top: 0;
            `}
            ref={ref}
            role='search'
        >
            <Sidebar.TopBar.Section {...({ flexShrink: 0 } as any)} is='form'>
                <TextInput
                    aria-owns={listId}
                    ref={autofocus}
                    {...filter}
                    placeholder={placeholder}
                    role='searchbox'
                    addon={<Icon name='cross' size='x20' onClick={onClose} />}
                />
            </Sidebar.TopBar.Section>
            <Box
                ref={boxRef}
                role='listbox'
                id={listId}
                tabIndex={-1}
                flexShrink={1}
                h='full'
                w='full'
                aria-live='polite'
                aria-atomic='true'
                aria-busy={isLoading}
                onClick={handleClick}
            >
                <Virtuoso
                    style={{ height: '100%', width: '100%' }}
                    totalCount={items.length}
                    data={items}
                    components={{ Scroller: VirtuosoScrollbars }}
                    computeItemKey={(_, room) => room._id}
                    itemContent={(_, data): ReactElement => <Row data={itemData} item={data} />}
                    ref={listRef}
                />
            </Box>
        </Box>
    );
});

export default SearchList;