From 0a82415f7aa4d9fc6b537af1ffea2416e64bd302 Mon Sep 17 00:00:00 2001 From: Olaf Sulich Date: Tue, 14 Jan 2025 09:34:26 +0100 Subject: [PATCH 1/4] refactor: replace underscore debounce with use-debounce --- .../FadingScrollbar/FadingScrollbar.test.tsx | 47 ++++++---- .../FadingScrollbar/FadingScrollbar.tsx | 8 +- .../MessagesList/JumpToLastMessageButton.tsx | 18 ++-- .../UserSearchableList/UserSearchableList.tsx | 13 +-- src/script/hooks/useDebounce.test.tsx | 90 ------------------- src/script/hooks/useDebounce.ts | 29 ------ .../LeftSidebar/panels/StartUI/PeopleTab.tsx | 10 ++- .../panels/StartUI/ServicesTab.tsx | 15 ++-- .../panels/Collection/FullSearch.tsx | 11 ++- 9 files changed, 80 insertions(+), 161 deletions(-) delete mode 100644 src/script/hooks/useDebounce.test.tsx delete mode 100644 src/script/hooks/useDebounce.ts diff --git a/src/script/components/FadingScrollbar/FadingScrollbar.test.tsx b/src/script/components/FadingScrollbar/FadingScrollbar.test.tsx index 24dccb9976f..ddeed6bcbd8 100644 --- a/src/script/components/FadingScrollbar/FadingScrollbar.test.tsx +++ b/src/script/components/FadingScrollbar/FadingScrollbar.test.tsx @@ -17,48 +17,58 @@ * */ -import {fireEvent, render} from '@testing-library/react'; -import underscore from 'underscore'; +import {fireEvent, render, act} from '@testing-library/react'; import {FadingScrollbar, parseColor} from './FadingScrollbar'; +jest.useFakeTimers(); + describe('FadingScrollbar', () => { let step: () => void = () => {}; beforeEach(() => { jest.spyOn(window, 'requestAnimationFrame').mockImplementation((cb: any) => (step = cb)); }); + afterEach(() => { + jest.clearAllTimers(); + }); + const getAlpha = (element: HTMLElement) => { const [, , , a] = parseColor(element.style.getPropertyValue('--scrollbar-color')); return a; }; it('fade scrollbar in when mouse enters and fades out after debounce', () => { - let runDebounce = () => {}; - jest.spyOn(underscore, 'debounce').mockImplementation((cb: any): any => { - const callback = (args: any) => { - runDebounce = () => cb(args); - }; - return callback; - }); - const {getByTestId} = render(
hello
, ); const scrollingElement = getByTestId('fading-scrollbar'); - fireEvent.mouseEnter(scrollingElement); + + act(() => { + fireEvent.mouseEnter(scrollingElement); + }); expect(getAlpha(scrollingElement)).toEqual(0.05); + // Run fade in animation for (let i = 0; i < 20; i++) { - step(); + act(() => { + step(); + }); } expect(getAlpha(scrollingElement)).toBeGreaterThanOrEqual(1); - runDebounce(); + // Fast forward past the debounce time + act(() => { + jest.advanceTimersByTime(1000); + }); + + // Run fade out animation for (let i = 0; i < 20; i++) { - step(); + act(() => { + step(); + }); } expect(getAlpha(scrollingElement)).toBeLessThanOrEqual(0); }); @@ -71,12 +81,17 @@ describe('FadingScrollbar', () => { ); const scrollingElement = getByTestId('fading-scrollbar'); - fireEvent.mouseLeave(scrollingElement); + + act(() => { + fireEvent.mouseLeave(scrollingElement); + }); expect(getAlpha(scrollingElement)).toEqual(0.95); for (let i = 0; i < 20; i++) { - step(); + act(() => { + step(); + }); } expect(getAlpha(scrollingElement)).toBeLessThanOrEqual(0); }); diff --git a/src/script/components/FadingScrollbar/FadingScrollbar.tsx b/src/script/components/FadingScrollbar/FadingScrollbar.tsx index a245c36aeba..db9019db809 100644 --- a/src/script/components/FadingScrollbar/FadingScrollbar.tsx +++ b/src/script/components/FadingScrollbar/FadingScrollbar.tsx @@ -19,7 +19,7 @@ import React, {forwardRef, useRef} from 'react'; -import {debounce} from 'underscore'; +import {useDebouncedCallback} from 'use-debounce'; const config = { ANIMATION_STEP: 0.05, @@ -83,7 +83,11 @@ export const FadingScrollbar = forwardRef startAnimation('fadein', element); const fadeOut = (element: HTMLElement) => startAnimation('fadeout', element); - const debouncedFadeOut = debounce(fadeOut, config.DEBOUNCE_THRESHOLD); + + const debouncedFadeOut = useDebouncedCallback((element: HTMLElement) => fadeOut(element), config.DEBOUNCE_THRESHOLD, { + maxWait: config.DEBOUNCE_THRESHOLD * 2, + }); + const fadeInIdle = (element: HTMLElement) => { fadeIn(element); debouncedFadeOut(element); diff --git a/src/script/components/MessagesList/JumpToLastMessageButton.tsx b/src/script/components/MessagesList/JumpToLastMessageButton.tsx index 78b1dbba27b..354d174c18b 100644 --- a/src/script/components/MessagesList/JumpToLastMessageButton.tsx +++ b/src/script/components/MessagesList/JumpToLastMessageButton.tsx @@ -19,7 +19,7 @@ import {HTMLProps, useEffect, useState} from 'react'; -import {debounce} from 'underscore'; +import {useDebouncedCallback} from 'use-debounce'; import {ChevronIcon, IconButton} from '@wireapp/react-ui-kit'; @@ -39,16 +39,20 @@ export interface JumpToLastMessageButtonProps extends HTMLProps { export const JumpToLastMessageButton = ({onGoToLastMessage, conversation}: JumpToLastMessageButtonProps) => { const [isLastMessageVisible, setIsLastMessageVisible] = useState(conversation.isLastMessageVisible()); + const debouncedSetVisibility = useDebouncedCallback( + (value: boolean) => { + setIsLastMessageVisible(value); + }, + 200, + {maxWait: 1000}, + ); + useEffect(() => { - const subscription = conversation.isLastMessageVisible.subscribe( - debounce(value => { - setIsLastMessageVisible(value); - }, 200), - ); + const subscription = conversation.isLastMessageVisible.subscribe(debouncedSetVisibility); return () => { subscription.dispose(); }; - }, [conversation]); + }, [conversation, debouncedSetVisibility]); if (isLastMessageVisible) { return null; diff --git a/src/script/components/UserSearchableList/UserSearchableList.tsx b/src/script/components/UserSearchableList/UserSearchableList.tsx index e91d6bd4974..00578b6ad1b 100644 --- a/src/script/components/UserSearchableList/UserSearchableList.tsx +++ b/src/script/components/UserSearchableList/UserSearchableList.tsx @@ -17,11 +17,11 @@ * */ -import React, {useCallback, useEffect, useState} from 'react'; +import React, {useEffect, useState} from 'react'; import {QualifiedId} from '@wireapp/api-client/lib/user'; import {container} from 'tsyringe'; -import {debounce} from 'underscore'; +import {useDebouncedCallback} from 'use-debounce'; import {UserList} from 'Components/UserList'; import {partition} from 'Util/ArrayUtil'; @@ -83,8 +83,8 @@ export const UserSearchableList = ({ * Try to load additional members from the backend. * This is needed for large teams (>= 2000 members) */ - const fetchMembersFromBackend = useCallback( - debounce(async (query: string, ignoreMembers: User[]) => { + const fetchMembersFromBackend = useDebouncedCallback( + async (query: string, ignoreMembers: User[]) => { const resultUsers = await searchRepository.searchByName(query, selfUser.teamId); const selfTeamId = selfUser.teamId; const foundMembers = resultUsers.filter(user => user.teamId === selfTeamId); @@ -96,8 +96,9 @@ export const UserSearchableList = ({ setRemoteTeamMembers( filterRemoteTeamUsers ? await teamRepository.filterRemoteDomainUsers(nonExternalMembers) : nonExternalMembers, ); - }, 300), - [], + }, + 300, + {maxWait: 1000}, ); // Filter all list items if a filter is provided diff --git a/src/script/hooks/useDebounce.test.tsx b/src/script/hooks/useDebounce.test.tsx deleted file mode 100644 index 47d9b69ddd0..00000000000 --- a/src/script/hooks/useDebounce.test.tsx +++ /dev/null @@ -1,90 +0,0 @@ -/* - * Wire - * Copyright (C) 2023 Wire Swiss GmbH - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see http://www.gnu.org/licenses/. - * - */ - -import {renderHook} from '@testing-library/react'; - -import {useDebounce} from './useDebounce'; - -describe('useDebounce', () => { - beforeAll(() => { - jest.useFakeTimers(); - }); - - afterAll(() => { - jest.useRealTimers(); - }); - - it('should debounce the callback', () => { - const callback = jest.fn(); - const time = 1000; - - renderHook(() => useDebounce(callback, time)); - - jest.advanceTimersByTime(time - 1); - expect(callback).not.toHaveBeenCalled(); - - jest.advanceTimersByTime(1); - expect(callback).toHaveBeenCalled(); - }); - - it('should only call the callback once when multiple calls are made within the debounce time', () => { - const callback = jest.fn(); - const time = 1000; - - renderHook(() => useDebounce(callback, time)); - - jest.advanceTimersByTime(200); - jest.advanceTimersByTime(800); - jest.advanceTimersByTime(200); - expect(callback).toHaveBeenCalledTimes(1); - }); - - it('should not call the callback if it is unmounted before the debounce time is over', () => { - const callback = jest.fn(); - const time = 1000; - - const {unmount} = renderHook(() => useDebounce(callback, time)); - - unmount(); - jest.advanceTimersByTime(1500); - expect(callback).not.toHaveBeenCalled(); - }); - - it('should re-run the effect when the dependencies change', () => { - const callback = jest.fn(); - const time = 1000; - const deps = ['dep1']; - - const {rerender} = renderHook(({deps}) => useDebounce(callback, time, deps), { - initialProps: {deps}, - }); - - jest.advanceTimersByTime(500); - expect(callback).not.toHaveBeenCalled(); - - rerender({deps: ['dep2']}); - - // Re-render will clear the current timeout so need to wait for 1000ms for the callback to be called - jest.advanceTimersByTime(500); - expect(callback).not.toHaveBeenCalled(); - - jest.advanceTimersByTime(500); - expect(callback).toHaveBeenCalled(); - }); -}); diff --git a/src/script/hooks/useDebounce.ts b/src/script/hooks/useDebounce.ts deleted file mode 100644 index d89ab5a03a6..00000000000 --- a/src/script/hooks/useDebounce.ts +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Wire - * Copyright (C) 2021 Wire Swiss GmbH - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see http://www.gnu.org/licenses/. - * - */ - -import {useEffect, DependencyList} from 'react'; - -const useDebounce = (callback: () => void, time: number, deps?: DependencyList) => { - useEffect(() => { - const timeoutId = setTimeout(() => callback(), time); - return () => clearTimeout(timeoutId); - }, deps); -}; - -export {useDebounce}; diff --git a/src/script/page/LeftSidebar/panels/StartUI/PeopleTab.tsx b/src/script/page/LeftSidebar/panels/StartUI/PeopleTab.tsx index 20873606d70..3799582dc65 100644 --- a/src/script/page/LeftSidebar/panels/StartUI/PeopleTab.tsx +++ b/src/script/page/LeftSidebar/panels/StartUI/PeopleTab.tsx @@ -22,6 +22,7 @@ import {useEffect, useRef, useState} from 'react'; import {BackendErrorLabel} from '@wireapp/api-client/lib/http'; import {StatusCodes as HTTP_STATUS} from 'http-status-codes'; import {partition} from 'underscore'; +import {useDebounce} from 'use-debounce'; import * as Icon from 'Components/Icon'; import {UserList, UserlistMode} from 'Components/UserList'; @@ -38,7 +39,6 @@ import {ConversationRepository} from '../../../../conversation/ConversationRepos import {ConversationState} from '../../../../conversation/ConversationState'; import {User} from '../../../../entity/User'; import {getManageTeamUrl} from '../../../../externalRoute'; -import {useDebounce} from '../../../../hooks/useDebounce'; import {SearchRepository} from '../../../../search/SearchRepository'; import {TeamRepository} from '../../../../team/TeamRepository'; import {TeamState} from '../../../../team/TeamState'; @@ -156,7 +156,7 @@ export const PeopleTab = ({ } }, []); - useDebounce( + const [debouncedSearch] = useDebounce( async () => { setHasFederationError(false); const {query} = searchRepository.normalizeQuery(searchQuery); @@ -209,9 +209,13 @@ export const PeopleTab = ({ } }, 300, - [searchQuery], + {maxWait: 1000}, ); + useEffect(() => { + debouncedSearch(); + }, [searchQuery]); + useEffect(() => { // keep track of the most up to date value of the search query (in order to cancel outdated queries) currentSearchQuery.current = searchQuery; diff --git a/src/script/page/LeftSidebar/panels/StartUI/ServicesTab.tsx b/src/script/page/LeftSidebar/panels/StartUI/ServicesTab.tsx index 2bf2d88eee9..7cb6106fb27 100644 --- a/src/script/page/LeftSidebar/panels/StartUI/ServicesTab.tsx +++ b/src/script/page/LeftSidebar/panels/StartUI/ServicesTab.tsx @@ -17,7 +17,9 @@ * */ -import React, {useState} from 'react'; +import React, {useState, useEffect} from 'react'; + +import {useDebounce} from 'use-debounce'; import {Button, ButtonVariant} from '@wireapp/react-ui-kit'; @@ -29,7 +31,6 @@ import {t} from 'Util/LocalizerUtil'; import {safeWindowOpen} from 'Util/SanitizationUtil'; import {getManageServicesUrl} from '../../../../externalRoute'; -import {useDebounce} from '../../../../hooks/useDebounce'; export const ServicesTab: React.FC<{ canManageServices: boolean; @@ -41,9 +42,9 @@ export const ServicesTab: React.FC<{ const [services, setServices] = useState(integrationRepository.services()); const manageServicesUrl = getManageServicesUrl('client_landing'); - const openManageServices = () => safeWindowOpen(manageServicesUrl); + const openManageServices = () => safeWindowOpen(manageServicesUrl!); - useDebounce( + const [debouncedSearch] = useDebounce( async () => { const results = await integrationRepository.searchForServices(searchQuery); if (results) { @@ -51,9 +52,13 @@ export const ServicesTab: React.FC<{ } }, 300, - [searchQuery], + {maxWait: 1000}, ); + useEffect(() => { + debouncedSearch(); + }, [searchQuery]); + return ( <> {services.length > 0 && ( diff --git a/src/script/page/MainContent/panels/Collection/FullSearch.tsx b/src/script/page/MainContent/panels/Collection/FullSearch.tsx index 13744594b1e..e4724d10f2a 100644 --- a/src/script/page/MainContent/panels/Collection/FullSearch.tsx +++ b/src/script/page/MainContent/panels/Collection/FullSearch.tsx @@ -19,6 +19,8 @@ import React, {useEffect, useMemo, useRef, useState} from 'react'; +import {useDebounce} from 'use-debounce'; + import {CloseIcon, Input, InputSubmitCombo, SearchIcon} from '@wireapp/react-ui-kit'; import {t} from 'Util/LocalizerUtil'; @@ -30,7 +32,6 @@ import {FullSearchItem} from './fullSearch/FullSearchItem'; import {ContentMessage} from '../../../../entity/message/ContentMessage'; import type {Message} from '../../../../entity/message/Message'; -import {useDebounce} from '../../../../hooks/useDebounce'; import {getSearchRegex} from '../../../../search/FullTextSearch'; const MAX_VISIBLE_MESSAGES = 30; @@ -53,7 +54,7 @@ const FullSearch: React.FC = ({searchProvider, click = noop, ch const [hasNoResults, setHasNoResults] = useState(false); const [element, setElement] = useEffectRef(); - useDebounce( + const [debouncedSearch] = useDebounce( async () => { const trimmedInput = searchValue.trim(); change(trimmedInput); @@ -71,9 +72,13 @@ const FullSearch: React.FC = ({searchProvider, click = noop, ch } }, DEBOUNCE_TIME, - [searchValue], + {maxWait: 1000}, ); + useEffect(() => { + debouncedSearch(); + }, [searchValue]); + useEffect(() => { const parent = element?.closest('.collection-list') as HTMLDivElement; const onScroll = () => { From c136ca4a15069a2f9a8295fb3d718dce0affb4d1 Mon Sep 17 00:00:00 2001 From: Olaf Sulich Date: Tue, 14 Jan 2025 10:06:30 +0100 Subject: [PATCH 2/4] refactor: simplify debounced callbacks across components --- .../FadingScrollbar/FadingScrollbar.tsx | 4 +- .../MessagesList/JumpToLastMessageButton.tsx | 10 +- .../UserSearchableList/UserSearchableList.tsx | 30 +++--- .../LeftSidebar/panels/StartUI/PeopleTab.tsx | 92 +++++++++---------- .../panels/Collection/FullSearch.tsx | 36 ++++---- 5 files changed, 77 insertions(+), 95 deletions(-) diff --git a/src/script/components/FadingScrollbar/FadingScrollbar.tsx b/src/script/components/FadingScrollbar/FadingScrollbar.tsx index db9019db809..6bce619b361 100644 --- a/src/script/components/FadingScrollbar/FadingScrollbar.tsx +++ b/src/script/components/FadingScrollbar/FadingScrollbar.tsx @@ -84,9 +84,7 @@ export const FadingScrollbar = forwardRef startAnimation('fadein', element); const fadeOut = (element: HTMLElement) => startAnimation('fadeout', element); - const debouncedFadeOut = useDebouncedCallback((element: HTMLElement) => fadeOut(element), config.DEBOUNCE_THRESHOLD, { - maxWait: config.DEBOUNCE_THRESHOLD * 2, - }); + const debouncedFadeOut = useDebouncedCallback((element: HTMLElement) => fadeOut(element), config.DEBOUNCE_THRESHOLD); const fadeInIdle = (element: HTMLElement) => { fadeIn(element); diff --git a/src/script/components/MessagesList/JumpToLastMessageButton.tsx b/src/script/components/MessagesList/JumpToLastMessageButton.tsx index 354d174c18b..86fe86b1fc1 100644 --- a/src/script/components/MessagesList/JumpToLastMessageButton.tsx +++ b/src/script/components/MessagesList/JumpToLastMessageButton.tsx @@ -39,13 +39,9 @@ export interface JumpToLastMessageButtonProps extends HTMLProps { export const JumpToLastMessageButton = ({onGoToLastMessage, conversation}: JumpToLastMessageButtonProps) => { const [isLastMessageVisible, setIsLastMessageVisible] = useState(conversation.isLastMessageVisible()); - const debouncedSetVisibility = useDebouncedCallback( - (value: boolean) => { - setIsLastMessageVisible(value); - }, - 200, - {maxWait: 1000}, - ); + const debouncedSetVisibility = useDebouncedCallback((value: boolean) => { + setIsLastMessageVisible(value); + }, 200); useEffect(() => { const subscription = conversation.isLastMessageVisible.subscribe(debouncedSetVisibility); diff --git a/src/script/components/UserSearchableList/UserSearchableList.tsx b/src/script/components/UserSearchableList/UserSearchableList.tsx index 00578b6ad1b..17d98c9c0d8 100644 --- a/src/script/components/UserSearchableList/UserSearchableList.tsx +++ b/src/script/components/UserSearchableList/UserSearchableList.tsx @@ -83,23 +83,19 @@ export const UserSearchableList = ({ * Try to load additional members from the backend. * This is needed for large teams (>= 2000 members) */ - const fetchMembersFromBackend = useDebouncedCallback( - async (query: string, ignoreMembers: User[]) => { - const resultUsers = await searchRepository.searchByName(query, selfUser.teamId); - const selfTeamId = selfUser.teamId; - const foundMembers = resultUsers.filter(user => user.teamId === selfTeamId); - const ignoreIds = ignoreMembers.map(member => member.id); - const uniqueMembers = foundMembers.filter(member => !ignoreIds.includes(member.id)); - - // We shouldn't show any members that have the 'external' role and are not already locally known. - const nonExternalMembers = await teamRepository.filterExternals(uniqueMembers); - setRemoteTeamMembers( - filterRemoteTeamUsers ? await teamRepository.filterRemoteDomainUsers(nonExternalMembers) : nonExternalMembers, - ); - }, - 300, - {maxWait: 1000}, - ); + const fetchMembersFromBackend = useDebouncedCallback(async (query: string, ignoreMembers: User[]) => { + const resultUsers = await searchRepository.searchByName(query, selfUser.teamId); + const selfTeamId = selfUser.teamId; + const foundMembers = resultUsers.filter(user => user.teamId === selfTeamId); + const ignoreIds = ignoreMembers.map(member => member.id); + const uniqueMembers = foundMembers.filter(member => !ignoreIds.includes(member.id)); + + // We shouldn't show any members that have the 'external' role and are not already locally known. + const nonExternalMembers = await teamRepository.filterExternals(uniqueMembers); + setRemoteTeamMembers( + filterRemoteTeamUsers ? await teamRepository.filterRemoteDomainUsers(nonExternalMembers) : nonExternalMembers, + ); + }, 300); // Filter all list items if a filter is provided diff --git a/src/script/page/LeftSidebar/panels/StartUI/PeopleTab.tsx b/src/script/page/LeftSidebar/panels/StartUI/PeopleTab.tsx index 3799582dc65..29ac4b13f9b 100644 --- a/src/script/page/LeftSidebar/panels/StartUI/PeopleTab.tsx +++ b/src/script/page/LeftSidebar/panels/StartUI/PeopleTab.tsx @@ -156,61 +156,57 @@ export const PeopleTab = ({ } }, []); - const [debouncedSearch] = useDebounce( - async () => { - setHasFederationError(false); - const {query} = searchRepository.normalizeQuery(searchQuery); - if (!query) { - setResults({contacts: getLocalUsers(), others: []}); - onSearchResults(undefined); - return; - } - const localSearchSources = getLocalUsers(true); + const [debouncedSearch] = useDebounce(async () => { + setHasFederationError(false); + const {query} = searchRepository.normalizeQuery(searchQuery); + if (!query) { + setResults({contacts: getLocalUsers(), others: []}); + onSearchResults(undefined); + return; + } + const localSearchSources = getLocalUsers(true); - const contactResults = searchRepository.searchUserInSet(searchQuery, localSearchSources); - const filteredResults = contactResults.filter( - user => - conversationState.hasConversationWith(user) || - teamRepository.isSelfConnectedTo(user.id) || - user.username() === query, - ); + const contactResults = searchRepository.searchUserInSet(searchQuery, localSearchSources); + const filteredResults = contactResults.filter( + user => + conversationState.hasConversationWith(user) || + teamRepository.isSelfConnectedTo(user.id) || + user.username() === query, + ); - const localSearchResults: SearchResultsData = { - contacts: filteredResults, - others: [], - }; - setResults(localSearchResults); - onSearchResults(localSearchResults); - if (canSearchUnconnectedUsers) { - try { - const userEntities = await searchRepository.searchByName(searchQuery, selfUser.teamId); - const localUserIds = localSearchResults.contacts.map(({id}) => id); - const onlyRemoteUsers = userEntities.filter(user => !localUserIds.includes(user.id)); - const results = inTeam - ? await organizeTeamSearchResults(onlyRemoteUsers, localSearchResults, query) - : {...localSearchResults, others: onlyRemoteUsers}; + const localSearchResults: SearchResultsData = { + contacts: filteredResults, + others: [], + }; + setResults(localSearchResults); + onSearchResults(localSearchResults); + if (canSearchUnconnectedUsers) { + try { + const userEntities = await searchRepository.searchByName(searchQuery, selfUser.teamId); + const localUserIds = localSearchResults.contacts.map(({id}) => id); + const onlyRemoteUsers = userEntities.filter(user => !localUserIds.includes(user.id)); + const results = inTeam + ? await organizeTeamSearchResults(onlyRemoteUsers, localSearchResults, query) + : {...localSearchResults, others: onlyRemoteUsers}; - if (currentSearchQuery.current === searchQuery) { - // Only update the results if the query that has been processed correspond to the current search query - onSearchResults(results); - setResults(results); + if (currentSearchQuery.current === searchQuery) { + // Only update the results if the query that has been processed correspond to the current search query + onSearchResults(results); + setResults(results); + } + } catch (error) { + if (isBackendError(error)) { + if (error.code === HTTP_STATUS.UNPROCESSABLE_ENTITY) { + return setHasFederationError(true); } - } catch (error) { - if (isBackendError(error)) { - if (error.code === HTTP_STATUS.UNPROCESSABLE_ENTITY) { - return setHasFederationError(true); - } - if (error.code === HTTP_STATUS.BAD_REQUEST && error.label === BackendErrorLabel.FEDERATION_NOT_ALLOWED) { - return logger.warn(`Error searching for contacts: ${error.message}`); - } + if (error.code === HTTP_STATUS.BAD_REQUEST && error.label === BackendErrorLabel.FEDERATION_NOT_ALLOWED) { + return logger.warn(`Error searching for contacts: ${error.message}`); } - logger.error(`Error searching for contacts: ${(error as any).message}`, error); } + logger.error(`Error searching for contacts: ${(error as any).message}`, error); } - }, - 300, - {maxWait: 1000}, - ); + } + }, 300); useEffect(() => { debouncedSearch(); diff --git a/src/script/page/MainContent/panels/Collection/FullSearch.tsx b/src/script/page/MainContent/panels/Collection/FullSearch.tsx index e4724d10f2a..b42bc662239 100644 --- a/src/script/page/MainContent/panels/Collection/FullSearch.tsx +++ b/src/script/page/MainContent/panels/Collection/FullSearch.tsx @@ -54,26 +54,22 @@ const FullSearch: React.FC = ({searchProvider, click = noop, ch const [hasNoResults, setHasNoResults] = useState(false); const [element, setElement] = useEffectRef(); - const [debouncedSearch] = useDebounce( - async () => { - const trimmedInput = searchValue.trim(); - change(trimmedInput); - if (trimmedInput.length < 2) { - setMessages([]); - setMessageCount(0); - setHasNoResults(false); - return; - } - const {messageEntities, query} = await searchProvider(trimmedInput); - if (query === trimmedInput) { - setHasNoResults(messageEntities.length === 0); - setMessages(messageEntities as ContentMessage[]); - setMessageCount(MAX_VISIBLE_MESSAGES); - } - }, - DEBOUNCE_TIME, - {maxWait: 1000}, - ); + const [debouncedSearch] = useDebounce(async () => { + const trimmedInput = searchValue.trim(); + change(trimmedInput); + if (trimmedInput.length < 2) { + setMessages([]); + setMessageCount(0); + setHasNoResults(false); + return; + } + const {messageEntities, query} = await searchProvider(trimmedInput); + if (query === trimmedInput) { + setHasNoResults(messageEntities.length === 0); + setMessages(messageEntities as ContentMessage[]); + setMessageCount(MAX_VISIBLE_MESSAGES); + } + }, DEBOUNCE_TIME); useEffect(() => { debouncedSearch(); From 9b2f2836ab82476ee227eef78d2c10e3a9ce2644 Mon Sep 17 00:00:00 2001 From: Olaf Sulich Date: Tue, 14 Jan 2025 11:17:31 +0100 Subject: [PATCH 3/4] refactor(FullSearch): replace useDebounce with useDebouncedCallback for improved performance --- src/script/page/MainContent/panels/Collection/FullSearch.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/script/page/MainContent/panels/Collection/FullSearch.tsx b/src/script/page/MainContent/panels/Collection/FullSearch.tsx index b42bc662239..ea1002b3710 100644 --- a/src/script/page/MainContent/panels/Collection/FullSearch.tsx +++ b/src/script/page/MainContent/panels/Collection/FullSearch.tsx @@ -19,7 +19,7 @@ import React, {useEffect, useMemo, useRef, useState} from 'react'; -import {useDebounce} from 'use-debounce'; +import {useDebouncedCallback} from 'use-debounce'; import {CloseIcon, Input, InputSubmitCombo, SearchIcon} from '@wireapp/react-ui-kit'; @@ -54,7 +54,7 @@ const FullSearch: React.FC = ({searchProvider, click = noop, ch const [hasNoResults, setHasNoResults] = useState(false); const [element, setElement] = useEffectRef(); - const [debouncedSearch] = useDebounce(async () => { + const debouncedSearch = useDebouncedCallback(async () => { const trimmedInput = searchValue.trim(); change(trimmedInput); if (trimmedInput.length < 2) { From 4e8ada767b4e54262d0ff30c6fe3a80a208e0717 Mon Sep 17 00:00:00 2001 From: Olaf Sulich Date: Tue, 14 Jan 2025 11:22:26 +0100 Subject: [PATCH 4/4] refactor: replace useDebounce with useDebouncedCallback in PeopleTab and ServicesTab for improved performance --- .../LeftSidebar/panels/StartUI/PeopleTab.tsx | 4 ++-- .../LeftSidebar/panels/StartUI/ServicesTab.tsx | 18 +++++++----------- 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/src/script/page/LeftSidebar/panels/StartUI/PeopleTab.tsx b/src/script/page/LeftSidebar/panels/StartUI/PeopleTab.tsx index 29ac4b13f9b..563438123da 100644 --- a/src/script/page/LeftSidebar/panels/StartUI/PeopleTab.tsx +++ b/src/script/page/LeftSidebar/panels/StartUI/PeopleTab.tsx @@ -22,7 +22,7 @@ import {useEffect, useRef, useState} from 'react'; import {BackendErrorLabel} from '@wireapp/api-client/lib/http'; import {StatusCodes as HTTP_STATUS} from 'http-status-codes'; import {partition} from 'underscore'; -import {useDebounce} from 'use-debounce'; +import {useDebouncedCallback} from 'use-debounce'; import * as Icon from 'Components/Icon'; import {UserList, UserlistMode} from 'Components/UserList'; @@ -156,7 +156,7 @@ export const PeopleTab = ({ } }, []); - const [debouncedSearch] = useDebounce(async () => { + const debouncedSearch = useDebouncedCallback(async () => { setHasFederationError(false); const {query} = searchRepository.normalizeQuery(searchQuery); if (!query) { diff --git a/src/script/page/LeftSidebar/panels/StartUI/ServicesTab.tsx b/src/script/page/LeftSidebar/panels/StartUI/ServicesTab.tsx index 7cb6106fb27..2a617d6e318 100644 --- a/src/script/page/LeftSidebar/panels/StartUI/ServicesTab.tsx +++ b/src/script/page/LeftSidebar/panels/StartUI/ServicesTab.tsx @@ -19,7 +19,7 @@ import React, {useState, useEffect} from 'react'; -import {useDebounce} from 'use-debounce'; +import {useDebouncedCallback} from 'use-debounce'; import {Button, ButtonVariant} from '@wireapp/react-ui-kit'; @@ -44,16 +44,12 @@ export const ServicesTab: React.FC<{ const openManageServices = () => safeWindowOpen(manageServicesUrl!); - const [debouncedSearch] = useDebounce( - async () => { - const results = await integrationRepository.searchForServices(searchQuery); - if (results) { - setServices(results); - } - }, - 300, - {maxWait: 1000}, - ); + const debouncedSearch = useDebouncedCallback(async () => { + const results = await integrationRepository.searchForServices(searchQuery); + if (results) { + setServices(results); + } + }, 300); useEffect(() => { debouncedSearch();