diff --git a/src/components/views/spaces/threads-activity-centre/useUnreadThreadRooms.ts b/src/components/views/spaces/threads-activity-centre/useUnreadThreadRooms.ts index c9c40b6cb6..addaeb97b8 100644 --- a/src/components/views/spaces/threads-activity-centre/useUnreadThreadRooms.ts +++ b/src/components/views/spaces/threads-activity-centre/useUnreadThreadRooms.ts @@ -16,8 +16,9 @@ * / */ -import { useEffect, useState } from "react"; -import { ClientEvent, MatrixClient, Room } from "matrix-js-sdk/src/matrix"; +import { useCallback, useEffect, useState } from "react"; +import { ClientEvent, MatrixClient, MatrixEventEvent, Room } from "matrix-js-sdk/src/matrix"; +import { throttle } from "lodash"; import { doesRoomHaveUnreadThreads } from "../../../../Unread"; import { NotificationLevel } from "../../../../stores/notifications/NotificationLevel"; @@ -27,6 +28,8 @@ import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext import { useEventEmitter } from "../../../../hooks/useEventEmitter"; import { VisibilityProvider } from "../../../../stores/room-list/filters/VisibilityProvider"; +const MIN_UPDATE_INTERVAL_MS = 500; + type Result = { greatestNotificationLevel: NotificationLevel; rooms: Array<{ room: Room; notificationLevel: NotificationLevel }>; @@ -44,17 +47,33 @@ export function useUnreadThreadRooms(forceComputation: boolean): Result { const [result, setResult] = useState({ greatestNotificationLevel: NotificationLevel.None, rooms: [] }); - // Listen to sync events to update the result - useEventEmitter(mxClient, ClientEvent.Sync, () => { + const doUpdate = useCallback(() => { setResult(computeUnreadThreadRooms(mxClient, msc3946ProcessDynamicPredecessor)); - }); + }, [mxClient, msc3946ProcessDynamicPredecessor]); + + // The exhautive deps lint rule can't compute dependencies here since it's not a plain inline func. + // We make this as simple as possible so its only dep is doUpdate itself. + // eslint-disable-next-line react-hooks/exhaustive-deps + const scheduleUpdate = useCallback( + throttle(doUpdate, MIN_UPDATE_INTERVAL_MS, { + leading: false, + trailing: true, + }), + [doUpdate], + ); + + // Listen to sync events to update the result + useEventEmitter(mxClient, ClientEvent.Sync, scheduleUpdate); + // and also when events get decrypted, since this will often happen after the sync + // event and may change notifications. + useEventEmitter(mxClient, MatrixEventEvent.Decrypted, scheduleUpdate); // Force the list computation useEffect(() => { if (forceComputation) { - setResult(computeUnreadThreadRooms(mxClient, msc3946ProcessDynamicPredecessor)); + doUpdate(); } - }, [mxClient, msc3946ProcessDynamicPredecessor, forceComputation]); + }, [doUpdate, forceComputation]); return result; } diff --git a/test/components/views/spaces/useUnreadThreadRooms-test.tsx b/test/components/views/spaces/useUnreadThreadRooms-test.tsx new file mode 100644 index 0000000000..f840d54d5d --- /dev/null +++ b/test/components/views/spaces/useUnreadThreadRooms-test.tsx @@ -0,0 +1,122 @@ +/* + * Copyright 2024 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from "react"; +import { renderHook } from "@testing-library/react-hooks"; +import { + MatrixClient, + MatrixEventEvent, + NotificationCountType, + PendingEventOrdering, + Room, +} from "matrix-js-sdk/src/matrix"; +import { act } from "@testing-library/react"; + +import MatrixClientContext from "../../../../src/contexts/MatrixClientContext"; +import { stubClient } from "../../../test-utils"; +import { populateThread } from "../../../test-utils/threads"; +import { NotificationLevel } from "../../../../src/stores/notifications/NotificationLevel"; +import { useUnreadThreadRooms } from "../../../../src/components/views/spaces/threads-activity-centre/useUnreadThreadRooms"; + +describe("useUnreadThreadRooms", () => { + let client: MatrixClient; + let room: Room; + + beforeEach(() => { + client = stubClient(); + client.supportsThreads = () => true; + room = new Room("!room1:example.org", client, "@fee:bar", { + pendingEventOrdering: PendingEventOrdering.Detached, + }); + }); + + it("has no notifications with no rooms", async () => { + const { result } = renderHook(() => useUnreadThreadRooms(false)); + const { greatestNotificationLevel, rooms } = result.current; + + expect(greatestNotificationLevel).toBe(NotificationLevel.None); + expect(rooms.length).toEqual(0); + }); + + it("a notification and a highlight summarise to a highlight", async () => { + const notifThreadInfo = await populateThread({ + room: room, + client: client, + authorId: "@foo:bar", + participantUserIds: ["@fee:bar"], + }); + room.setThreadUnreadNotificationCount(notifThreadInfo.thread.id, NotificationCountType.Total, 1); + + const highlightThreadInfo = await populateThread({ + room: room, + client: client, + authorId: "@foo:bar", + participantUserIds: ["@fee:bar"], + }); + room.setThreadUnreadNotificationCount(highlightThreadInfo.thread.id, NotificationCountType.Highlight, 1); + + client.getVisibleRooms = jest.fn().mockReturnValue([room]); + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + const { result } = renderHook(() => useUnreadThreadRooms(true), { wrapper }); + const { greatestNotificationLevel, rooms } = result.current; + + expect(greatestNotificationLevel).toBe(NotificationLevel.Highlight); + expect(rooms.length).toEqual(1); + }); + + describe("updates", () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it("updates on decryption within 1s", async () => { + const notifThreadInfo = await populateThread({ + room: room, + client: client, + authorId: "@foo:bar", + participantUserIds: ["@fee:bar"], + }); + room.setThreadUnreadNotificationCount(notifThreadInfo.thread.id, NotificationCountType.Total, 0); + + client.getVisibleRooms = jest.fn().mockReturnValue([room]); + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + const { result } = renderHook(() => useUnreadThreadRooms(true), { wrapper }); + + expect(result.current.greatestNotificationLevel).toBe(NotificationLevel.Activity); + + act(() => { + room.setThreadUnreadNotificationCount(notifThreadInfo.thread.id, NotificationCountType.Highlight, 1); + client.emit(MatrixEventEvent.Decrypted, notifThreadInfo.thread.events[0]); + + jest.advanceTimersByTime(1000); + }); + + expect(result.current.greatestNotificationLevel).toBe(NotificationLevel.Highlight); + }); + }); +});