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..6bce619b361 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,9 @@ 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);
+
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..86fe86b1fc1 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,16 @@ 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);
+
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..17d98c9c0d8 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,22 +83,19 @@ 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 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),
- [],
- );
+ 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/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..563438123da 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 {useDebouncedCallback} 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,61 +156,61 @@ export const PeopleTab = ({
}
}, []);
- useDebounce(
- async () => {
- setHasFederationError(false);
- const {query} = searchRepository.normalizeQuery(searchQuery);
- if (!query) {
- setResults({contacts: getLocalUsers(), others: []});
- onSearchResults(undefined);
- return;
- }
- const localSearchSources = getLocalUsers(true);
+ const debouncedSearch = useDebouncedCallback(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,
- [searchQuery],
- );
+ }
+ }, 300);
+
+ useEffect(() => {
+ debouncedSearch();
+ }, [searchQuery]);
useEffect(() => {
// keep track of the most up to date value of the search query (in order to cancel outdated queries)
diff --git a/src/script/page/LeftSidebar/panels/StartUI/ServicesTab.tsx b/src/script/page/LeftSidebar/panels/StartUI/ServicesTab.tsx
index 2bf2d88eee9..2a617d6e318 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 {useDebouncedCallback} 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,18 +42,18 @@ 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(
- async () => {
- const results = await integrationRepository.searchForServices(searchQuery);
- if (results) {
- setServices(results);
- }
- },
- 300,
- [searchQuery],
- );
+ const debouncedSearch = useDebouncedCallback(async () => {
+ const results = await integrationRepository.searchForServices(searchQuery);
+ if (results) {
+ setServices(results);
+ }
+ }, 300);
+
+ useEffect(() => {
+ debouncedSearch();
+ }, [searchQuery]);
return (
<>
diff --git a/src/script/page/MainContent/panels/Collection/FullSearch.tsx b/src/script/page/MainContent/panels/Collection/FullSearch.tsx
index 13744594b1e..ea1002b3710 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 {useDebouncedCallback} 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,26 +54,26 @@ const FullSearch: React.FC = ({searchProvider, click = noop, ch
const [hasNoResults, setHasNoResults] = useState(false);
const [element, setElement] = useEffectRef();
- 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,
- [searchValue],
- );
+ const debouncedSearch = useDebouncedCallback(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();
+ }, [searchValue]);
useEffect(() => {
const parent = element?.closest('.collection-list') as HTMLDivElement;