diff --git a/src/RoomNotifs.ts b/src/RoomNotifs.ts index d63baa3e0f..16dc84895a 100644 --- a/src/RoomNotifs.ts +++ b/src/RoomNotifs.ts @@ -1,6 +1,5 @@ /* -Copyright 2016 OpenMarket Ltd -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2016, 2019, 2023 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. @@ -16,18 +15,18 @@ limitations under the License. */ import { PushProcessor } from "matrix-js-sdk/src/pushprocessor"; -import { NotificationCountType, Room } from "matrix-js-sdk/src/models/room"; -import { - ConditionKind, - IPushRule, - PushRuleActionName, - PushRuleKind, - TweakName, -} from "matrix-js-sdk/src/@types/PushRules"; +import { NotificationCountType } from "matrix-js-sdk/src/models/room"; +import { ConditionKind, PushRuleActionName, PushRuleKind, TweakName } from "matrix-js-sdk/src/@types/PushRules"; import { EventType } from "matrix-js-sdk/src/@types/event"; -import { MatrixClient } from "matrix-js-sdk/src/matrix"; +import type { IPushRule } from "matrix-js-sdk/src/@types/PushRules"; +import type { Room } from "matrix-js-sdk/src/models/room"; +import type { MatrixClient } from "matrix-js-sdk/src/matrix"; import { MatrixClientPeg } from "./MatrixClientPeg"; +import { NotificationColor } from "./stores/notifications/NotificationColor"; +import { getUnsentMessages } from "./components/structures/RoomStatusBar"; +import { doesRoomHaveUnreadMessages, doesRoomOrThreadHaveUnreadMessages } from "./Unread"; +import { getEffectiveMembership, EffectiveMembership } from "./utils/membership"; export enum RoomNotifState { AllMessagesLoud = "all_messages_loud", @@ -36,7 +35,7 @@ export enum RoomNotifState { Mute = "mute", } -export function getRoomNotifsState(client: MatrixClient, roomId: string): RoomNotifState { +export function getRoomNotifsState(client: MatrixClient, roomId: string): RoomNotifState | null { if (client.isGuest()) return RoomNotifState.AllMessages; // look through the override rules for a rule affecting this room: @@ -177,7 +176,7 @@ function setRoomNotifsStateUnmuted(roomId: string, newState: RoomNotifState): Pr return Promise.all(promises); } -function findOverrideMuteRule(roomId: string): IPushRule { +function findOverrideMuteRule(roomId: string): IPushRule | null { const cli = MatrixClientPeg.get(); if (!cli?.pushRules?.global?.override) { return null; @@ -201,3 +200,44 @@ function isRuleForRoom(roomId: string, rule: IPushRule): boolean { function isMuteRule(rule: IPushRule): boolean { return rule.actions.length === 1 && rule.actions[0] === PushRuleActionName.DontNotify; } + +export function determineUnreadState( + room: Room, + threadId?: string, +): { color: NotificationColor; symbol: string | null; count: number } { + if (getUnsentMessages(room, threadId).length > 0) { + return { symbol: "!", count: 1, color: NotificationColor.Unsent }; + } + + if (getEffectiveMembership(room.getMyMembership()) === EffectiveMembership.Invite) { + return { symbol: "!", count: 1, color: NotificationColor.Red }; + } + + if (getRoomNotifsState(room.client, room.roomId) === RoomNotifState.Mute) { + return { symbol: null, count: 0, color: NotificationColor.None }; + } + + const redNotifs = getUnreadNotificationCount(room, NotificationCountType.Highlight, threadId); + const greyNotifs = getUnreadNotificationCount(room, NotificationCountType.Total, threadId); + + const trueCount = greyNotifs || redNotifs; + if (redNotifs > 0) { + return { symbol: null, count: trueCount, color: NotificationColor.Red }; + } + + if (greyNotifs > 0) { + return { symbol: null, count: trueCount, color: NotificationColor.Grey }; + } + + // We don't have any notified messages, but we might have unread messages. Let's + // find out. + let hasUnread = false; + if (threadId) hasUnread = doesRoomOrThreadHaveUnreadMessages(room.getThread(threadId)!); + else hasUnread = doesRoomHaveUnreadMessages(room); + + return { + symbol: null, + count: trueCount, + color: hasUnread ? NotificationColor.Bold : NotificationColor.None, + }; +} diff --git a/src/hooks/useUnreadNotifications.ts b/src/hooks/useUnreadNotifications.ts index 22236d832f..c5e0e3da16 100644 --- a/src/hooks/useUnreadNotifications.ts +++ b/src/hooks/useUnreadNotifications.ts @@ -1,5 +1,5 @@ /* -Copyright 2022 The Matrix.org Foundation C.I.C. +Copyright 2022 - 2023 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. @@ -14,15 +14,12 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { NotificationCount, NotificationCountType, Room, RoomEvent } from "matrix-js-sdk/src/models/room"; -import { Thread } from "matrix-js-sdk/src/models/thread"; +import { RoomEvent } from "matrix-js-sdk/src/models/room"; import { useCallback, useEffect, useState } from "react"; -import { getUnsentMessages } from "../components/structures/RoomStatusBar"; -import { getRoomNotifsState, getUnreadNotificationCount, RoomNotifState } from "../RoomNotifs"; -import { NotificationColor } from "../stores/notifications/NotificationColor"; -import { doesRoomOrThreadHaveUnreadMessages } from "../Unread"; -import { EffectiveMembership, getEffectiveMembership } from "../utils/membership"; +import type { NotificationCount, Room } from "matrix-js-sdk/src/models/room"; +import { determineUnreadState } from "../RoomNotifs"; +import type { NotificationColor } from "../stores/notifications/NotificationColor"; import { useEventEmitter } from "./useEventEmitter"; export const useUnreadNotifications = ( @@ -53,40 +50,10 @@ export const useUnreadNotifications = ( useEventEmitter(room, RoomEvent.MyMembership, () => updateNotificationState()); const updateNotificationState = useCallback(() => { - if (getUnsentMessages(room, threadId).length > 0) { - setSymbol("!"); - setCount(1); - setColor(NotificationColor.Unsent); - } else if (getEffectiveMembership(room.getMyMembership()) === EffectiveMembership.Invite) { - setSymbol("!"); - setCount(1); - setColor(NotificationColor.Red); - } else if (getRoomNotifsState(room.client, room.roomId) === RoomNotifState.Mute) { - setSymbol(null); - setCount(0); - setColor(NotificationColor.None); - } else { - const redNotifs = getUnreadNotificationCount(room, NotificationCountType.Highlight, threadId); - const greyNotifs = getUnreadNotificationCount(room, NotificationCountType.Total, threadId); - - const trueCount = greyNotifs || redNotifs; - setCount(trueCount); - setSymbol(null); - if (redNotifs > 0) { - setColor(NotificationColor.Red); - } else if (greyNotifs > 0) { - setColor(NotificationColor.Grey); - } else { - // We don't have any notified messages, but we might have unread messages. Let's - // find out. - let roomOrThread: Room | Thread = room; - if (threadId) { - roomOrThread = room.getThread(threadId)!; - } - const hasUnread = doesRoomOrThreadHaveUnreadMessages(roomOrThread); - setColor(hasUnread ? NotificationColor.Bold : NotificationColor.None); - } - } + const { symbol, count, color } = determineUnreadState(room, threadId); + setSymbol(symbol); + setCount(count); + setColor(color); }, [room, threadId]); useEffect(() => { diff --git a/src/stores/local-echo/RoomEchoChamber.ts b/src/stores/local-echo/RoomEchoChamber.ts index b49e4406ea..d58ebc61c4 100644 --- a/src/stores/local-echo/RoomEchoChamber.ts +++ b/src/stores/local-echo/RoomEchoChamber.ts @@ -58,10 +58,9 @@ export class RoomEchoChamber extends GenericEchoChamber 0) { - // When there are unsent messages we show a red `!` - this._color = NotificationColor.Unsent; - this._symbol = "!"; - this._count = 1; // not used, technically - } else if ( - RoomNotifs.getRoomNotifsState(this.room.client, this.room.roomId) === RoomNotifs.RoomNotifState.Mute - ) { - // When muted we suppress all notification states, even if we have context on them. - this._color = NotificationColor.None; - this._symbol = null; - this._count = 0; - } else if (this.roomIsInvite) { - this._color = NotificationColor.Red; - this._symbol = "!"; - this._count = 1; // not used, technically - } else { - const redNotifs = RoomNotifs.getUnreadNotificationCount(this.room, NotificationCountType.Highlight); - const greyNotifs = RoomNotifs.getUnreadNotificationCount(this.room, NotificationCountType.Total); - - // For a 'true count' we pick the grey notifications first because they include the - // red notifications. If we don't have a grey count for some reason we use the red - // count. If that count is broken for some reason, assume zero. This avoids us showing - // a badge for 'NaN' (which formats as 'NaNB' for NaN Billion). - const trueCount = greyNotifs ? greyNotifs : redNotifs ? redNotifs : 0; - - // Note: we only set the symbol if we have an actual count. We don't want to show - // zero on badges. - - if (redNotifs > 0) { - this._color = NotificationColor.Red; - this._count = trueCount; - this._symbol = null; // symbol calculated by component - } else if (greyNotifs > 0) { - this._color = NotificationColor.Grey; - this._count = trueCount; - this._symbol = null; // symbol calculated by component - } else { - // We don't have any notified messages, but we might have unread messages. Let's - // find out. - const hasUnread = Unread.doesRoomHaveUnreadMessages(this.room); - if (hasUnread) { - this._color = NotificationColor.Bold; - } else { - this._color = NotificationColor.None; - } - - // no symbol or count for this state - this._count = 0; - this._symbol = null; - } - } + const { color, symbol, count } = RoomNotifs.determineUnreadState(this.room); + this._color = color; + this._symbol = symbol; + this._count = count; // finally, publish an update if needed this.emitIfUpdated(snapshot); diff --git a/test/RoomNotifs-test.ts b/test/RoomNotifs-test.ts index 9b90c5b8a6..c50cdb8667 100644 --- a/test/RoomNotifs-test.ts +++ b/test/RoomNotifs-test.ts @@ -1,5 +1,5 @@ /* -Copyright 2022 The Matrix.org Foundation C.I.C. +Copyright 2022 - 2023 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. @@ -15,21 +15,29 @@ limitations under the License. */ import { mocked } from "jest-mock"; -import { ConditionKind, PushRuleActionName, TweakName } from "matrix-js-sdk/src/@types/PushRules"; +import { PushRuleActionName, TweakName } from "matrix-js-sdk/src/@types/PushRules"; import { NotificationCountType, Room } from "matrix-js-sdk/src/models/room"; +import { EventStatus, PendingEventOrdering } from "matrix-js-sdk/src/matrix"; -import { mkEvent, stubClient } from "./test-utils"; -import { MatrixClientPeg } from "../src/MatrixClientPeg"; -import { getRoomNotifsState, RoomNotifState, getUnreadNotificationCount } from "../src/RoomNotifs"; +import type { MatrixClient } from "matrix-js-sdk/src/matrix"; +import { mkEvent, mkRoom, muteRoom, stubClient } from "./test-utils"; +import { + getRoomNotifsState, + RoomNotifState, + getUnreadNotificationCount, + determineUnreadState, +} from "../src/RoomNotifs"; +import { NotificationColor } from "../src/stores/notifications/NotificationColor"; describe("RoomNotifs test", () => { + let client: jest.Mocked; + beforeEach(() => { - stubClient(); + client = stubClient() as jest.Mocked; }); it("getRoomNotifsState handles rules with no conditions", () => { - const cli = MatrixClientPeg.get(); - mocked(cli).pushRules = { + mocked(client).pushRules = { global: { override: [ { @@ -41,70 +49,47 @@ describe("RoomNotifs test", () => { ], }, }; - expect(getRoomNotifsState(cli, "!roomId:server")).toBe(null); + expect(getRoomNotifsState(client, "!roomId:server")).toBe(null); }); it("getRoomNotifsState handles guest users", () => { - const cli = MatrixClientPeg.get(); - mocked(cli).isGuest.mockReturnValue(true); - expect(getRoomNotifsState(cli, "!roomId:server")).toBe(RoomNotifState.AllMessages); + mocked(client).isGuest.mockReturnValue(true); + expect(getRoomNotifsState(client, "!roomId:server")).toBe(RoomNotifState.AllMessages); }); it("getRoomNotifsState handles mute state", () => { - const cli = MatrixClientPeg.get(); - cli.pushRules = { - global: { - override: [ - { - rule_id: "!roomId:server", - enabled: true, - default: false, - conditions: [ - { - kind: ConditionKind.EventMatch, - key: "room_id", - pattern: "!roomId:server", - }, - ], - actions: [PushRuleActionName.DontNotify], - }, - ], - }, - }; - expect(getRoomNotifsState(cli, "!roomId:server")).toBe(RoomNotifState.Mute); + const room = mkRoom(client, "!roomId:server"); + muteRoom(room); + expect(getRoomNotifsState(client, room.roomId)).toBe(RoomNotifState.Mute); }); it("getRoomNotifsState handles mentions only", () => { - const cli = MatrixClientPeg.get(); - cli.getRoomPushRule = () => ({ + (client as any).getRoomPushRule = () => ({ rule_id: "!roomId:server", enabled: true, default: false, actions: [PushRuleActionName.DontNotify], }); - expect(getRoomNotifsState(cli, "!roomId:server")).toBe(RoomNotifState.MentionsOnly); + expect(getRoomNotifsState(client, "!roomId:server")).toBe(RoomNotifState.MentionsOnly); }); it("getRoomNotifsState handles noisy", () => { - const cli = MatrixClientPeg.get(); - cli.getRoomPushRule = () => ({ + (client as any).getRoomPushRule = () => ({ rule_id: "!roomId:server", enabled: true, default: false, actions: [{ set_tweak: TweakName.Sound, value: "default" }], }); - expect(getRoomNotifsState(cli, "!roomId:server")).toBe(RoomNotifState.AllMessagesLoud); + expect(getRoomNotifsState(client, "!roomId:server")).toBe(RoomNotifState.AllMessagesLoud); }); describe("getUnreadNotificationCount", () => { const ROOM_ID = "!roomId:example.org"; const THREAD_ID = "$threadId"; - let cli; let room: Room; beforeEach(() => { - cli = MatrixClientPeg.get(); - room = new Room(ROOM_ID, cli, cli.getUserId()); + room = new Room(ROOM_ID, client, client.getUserId()!); }); it("counts room notification type", () => { @@ -125,19 +110,19 @@ describe("RoomNotifs test", () => { room.setUnreadNotificationCount(NotificationCountType.Highlight, 1); const OLD_ROOM_ID = "!oldRoomId:example.org"; - const oldRoom = new Room(OLD_ROOM_ID, cli, cli.getUserId()); + const oldRoom = new Room(OLD_ROOM_ID, client, client.getUserId()!); oldRoom.setUnreadNotificationCount(NotificationCountType.Total, 10); oldRoom.setUnreadNotificationCount(NotificationCountType.Highlight, 6); - cli.getRoom.mockReset().mockReturnValue(oldRoom); + client.getRoom.mockReset().mockReturnValue(oldRoom); const predecessorEvent = mkEvent({ event: true, type: "m.room.create", room: ROOM_ID, - user: cli.getUserId(), + user: client.getUserId()!, content: { - creator: cli.getUserId(), + creator: client.getUserId(), room_version: "5", predecessor: { room_id: OLD_ROOM_ID, @@ -165,4 +150,78 @@ describe("RoomNotifs test", () => { expect(getUnreadNotificationCount(room, NotificationCountType.Highlight, THREAD_ID)).toBe(1); }); }); + + describe("determineUnreadState", () => { + let room: Room; + + beforeEach(() => { + room = new Room("!room-id:example.com", client, "@user:example.com", { + pendingEventOrdering: PendingEventOrdering.Detached, + }); + }); + + it("shows nothing by default", async () => { + const { color, symbol, count } = determineUnreadState(room); + + expect(symbol).toBe(null); + expect(color).toBe(NotificationColor.None); + expect(count).toBe(0); + }); + + it("indicates if there are unsent messages", async () => { + const event = mkEvent({ + event: true, + type: "m.message", + user: "@user:example.org", + content: {}, + }); + event.status = EventStatus.NOT_SENT; + room.addPendingEvent(event, "txn"); + + const { color, symbol, count } = determineUnreadState(room); + + expect(symbol).toBe("!"); + expect(color).toBe(NotificationColor.Unsent); + expect(count).toBeGreaterThan(0); + }); + + it("indicates the user has been invited to a channel", async () => { + room.updateMyMembership("invite"); + + const { color, symbol, count } = determineUnreadState(room); + + expect(symbol).toBe("!"); + expect(color).toBe(NotificationColor.Red); + expect(count).toBeGreaterThan(0); + }); + + it("shows nothing for muted channels", async () => { + room.setUnreadNotificationCount(NotificationCountType.Highlight, 99); + room.setUnreadNotificationCount(NotificationCountType.Total, 99); + muteRoom(room); + + const { color, count } = determineUnreadState(room); + + expect(color).toBe(NotificationColor.None); + expect(count).toBe(0); + }); + + it("uses the correct number of unreads", async () => { + room.setUnreadNotificationCount(NotificationCountType.Total, 999); + + const { color, count } = determineUnreadState(room); + + expect(color).toBe(NotificationColor.Grey); + expect(count).toBe(999); + }); + + it("uses the correct number of highlights", async () => { + room.setUnreadNotificationCount(NotificationCountType.Highlight, 888); + + const { color, count } = determineUnreadState(room); + + expect(color).toBe(NotificationColor.Red); + expect(count).toBe(888); + }); + }); }); diff --git a/test/components/views/rooms/NotificationBadge/UnreadNotificationBadge-test.tsx b/test/components/views/rooms/NotificationBadge/UnreadNotificationBadge-test.tsx index 9c60d26e26..13285b8d1b 100644 --- a/test/components/views/rooms/NotificationBadge/UnreadNotificationBadge-test.tsx +++ b/test/components/views/rooms/NotificationBadge/UnreadNotificationBadge-test.tsx @@ -1,5 +1,5 @@ /* -Copyright 2022 The Matrix.org Foundation C.I.C. +Copyright 2022 - 2023 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. @@ -23,36 +23,26 @@ import { NotificationCountType, Room } from "matrix-js-sdk/src/models/room"; import { EventStatus } from "matrix-js-sdk/src/models/event-status"; import { ReceiptType } from "matrix-js-sdk/src/@types/read_receipts"; +import type { MatrixClient } from "matrix-js-sdk/src/client"; import { mkThread } from "../../../../test-utils/threads"; import { UnreadNotificationBadge } from "../../../../../src/components/views/rooms/NotificationBadge/UnreadNotificationBadge"; -import { mkEvent, mkMessage, stubClient } from "../../../../test-utils/test-utils"; -import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg"; +import { mkEvent, mkMessage, muteRoom, stubClient } from "../../../../test-utils/test-utils"; import * as RoomNotifs from "../../../../../src/RoomNotifs"; -jest.mock("../../../../../src/RoomNotifs"); -jest.mock("../../../../../src/RoomNotifs", () => ({ - ...(jest.requireActual("../../../../../src/RoomNotifs") as Object), - getRoomNotifsState: jest.fn(), -})); - const ROOM_ID = "!roomId:example.org"; let THREAD_ID: string; describe("UnreadNotificationBadge", () => { - stubClient(); - const client = MatrixClientPeg.get(); + let client: MatrixClient; let room: Room; function getComponent(threadId?: string) { return ; } - beforeAll(() => { - client.supportsThreads = () => true; - }); - beforeEach(() => { - jest.clearAllMocks(); + client = stubClient(); + client.supportsThreads = () => true; room = new Room(ROOM_ID, client, client.getUserId()!, { pendingEventOrdering: PendingEventOrdering.Detached, @@ -145,41 +135,39 @@ describe("UnreadNotificationBadge", () => { }); it("adds a warning for invites", () => { - jest.spyOn(room, "getMyMembership").mockReturnValue("invite"); + room.updateMyMembership("invite"); render(getComponent()); expect(screen.queryByText("!")).not.toBeNull(); }); it("hides counter for muted rooms", () => { - jest.spyOn(RoomNotifs, "getRoomNotifsState").mockReset().mockReturnValue(RoomNotifs.RoomNotifState.Mute); + muteRoom(room); const { container } = render(getComponent()); expect(container.querySelector(".mx_NotificationBadge")).toBeNull(); }); it("activity renders unread notification badge", () => { - act(() => { - room.setThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Total, 0); - room.setThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Highlight, 0); + room.setThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Total, 0); + room.setThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Highlight, 0); - // Add another event on the thread which is not sent by us. - const event = mkEvent({ - event: true, - type: "m.room.message", - user: "@alice:server.org", - room: room.roomId, - content: { - "msgtype": MsgType.Text, - "body": "Hello from Bob", - "m.relates_to": { - event_id: THREAD_ID, - rel_type: RelationType.Thread, - }, + // Add another event on the thread which is not sent by us. + const event = mkEvent({ + event: true, + type: "m.room.message", + user: "@alice:server.org", + room: room.roomId, + content: { + "msgtype": MsgType.Text, + "body": "Hello from Bob", + "m.relates_to": { + event_id: THREAD_ID, + rel_type: RelationType.Thread, }, - ts: 5, - }); - room.addLiveEvents([event]); + }, + ts: 5, }); + room.addLiveEvents([event]); const { container } = render(getComponent(THREAD_ID)); expect(container.querySelector(".mx_NotificationBadge_dot")).toBeTruthy(); diff --git a/test/hooks/useUnreadNotifications-test.ts b/test/hooks/useUnreadNotifications-test.ts new file mode 100644 index 0000000000..cda9fdf4a5 --- /dev/null +++ b/test/hooks/useUnreadNotifications-test.ts @@ -0,0 +1,110 @@ +/* +Copyright 2023 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 { renderHook } from "@testing-library/react-hooks"; +import { EventStatus, NotificationCountType, PendingEventOrdering } from "matrix-js-sdk/src/matrix"; +import { Room } from "matrix-js-sdk/src/matrix"; + +import type { MatrixClient } from "matrix-js-sdk/src/matrix"; +import { useUnreadNotifications } from "../../src/hooks/useUnreadNotifications"; +import { NotificationColor } from "../../src/stores/notifications/NotificationColor"; +import { mkEvent, muteRoom, stubClient } from "../test-utils"; + +describe("useUnreadNotifications", () => { + let client: MatrixClient; + let room: Room; + + beforeEach(() => { + client = stubClient(); + room = new Room("!room:example.org", client, "@user:example.org", { + pendingEventOrdering: PendingEventOrdering.Detached, + }); + }); + + function setUnreads(greys: number, reds: number): void { + room.setUnreadNotificationCount(NotificationCountType.Highlight, reds); + room.setUnreadNotificationCount(NotificationCountType.Total, greys); + } + + it("shows nothing by default", async () => { + const { result } = renderHook(() => useUnreadNotifications(room)); + const { color, symbol, count } = result.current; + + expect(symbol).toBe(null); + expect(color).toBe(NotificationColor.None); + expect(count).toBe(0); + }); + + it("indicates if there are unsent messages", async () => { + const event = mkEvent({ + event: true, + type: "m.message", + user: "@user:example.org", + content: {}, + }); + event.status = EventStatus.NOT_SENT; + room.addPendingEvent(event, "txn"); + + const { result } = renderHook(() => useUnreadNotifications(room)); + const { color, symbol, count } = result.current; + + expect(symbol).toBe("!"); + expect(color).toBe(NotificationColor.Unsent); + expect(count).toBeGreaterThan(0); + }); + + it("indicates the user has been invited to a channel", async () => { + room.updateMyMembership("invite"); + + const { result } = renderHook(() => useUnreadNotifications(room)); + const { color, symbol, count } = result.current; + + expect(symbol).toBe("!"); + expect(color).toBe(NotificationColor.Red); + expect(count).toBeGreaterThan(0); + }); + + it("shows nothing for muted channels", async () => { + setUnreads(999, 999); + muteRoom(room); + + const { result } = renderHook(() => useUnreadNotifications(room)); + const { color, count } = result.current; + + expect(color).toBe(NotificationColor.None); + expect(count).toBe(0); + }); + + it("uses the correct number of unreads", async () => { + setUnreads(999, 0); + + const { result } = renderHook(() => useUnreadNotifications(room)); + const { color, count } = result.current; + + expect(color).toBe(NotificationColor.Grey); + expect(count).toBe(999); + }); + + it("uses the correct number of highlights", async () => { + setUnreads(0, 888); + + const { result } = renderHook(() => useUnreadNotifications(room)); + const { color, count } = result.current; + + expect(color).toBe(NotificationColor.Red); + expect(count).toBe(888); + }); +}); diff --git a/test/stores/notifications/RoomNotificationState-test.ts b/test/stores/notifications/RoomNotificationState-test.ts index c9ee6dd497..840d2c12d6 100644 --- a/test/stores/notifications/RoomNotificationState-test.ts +++ b/test/stores/notifications/RoomNotificationState-test.ts @@ -1,5 +1,5 @@ /* -Copyright 2022 The Matrix.org Foundation C.I.C. +Copyright 2022 - 2023 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. @@ -15,38 +15,165 @@ limitations under the License. */ import { Room } from "matrix-js-sdk/src/models/room"; -import { MatrixEventEvent, MatrixEvent, MatrixClient } from "matrix-js-sdk/src/matrix"; +import { + MatrixEventEvent, + PendingEventOrdering, + EventStatus, + NotificationCountType, + EventType, +} from "matrix-js-sdk/src/matrix"; +import { MatrixEvent } from "matrix-js-sdk/src/matrix"; -import { stubClient } from "../../test-utils"; -import { MatrixClientPeg } from "../../../src/MatrixClientPeg"; +import type { MatrixClient } from "matrix-js-sdk/src/matrix"; +import { mkEvent, muteRoom, stubClient } from "../../test-utils"; import { RoomNotificationState } from "../../../src/stores/notifications/RoomNotificationState"; -import * as testUtils from "../../test-utils"; import { NotificationStateEvents } from "../../../src/stores/notifications/NotificationState"; +import { NotificationColor } from "../../../src/stores/notifications/NotificationColor"; +import { createMessageEventContent } from "../../test-utils/events"; describe("RoomNotificationState", () => { - let testRoom: Room; + let room: Room; let client: MatrixClient; beforeEach(() => { - stubClient(); - client = MatrixClientPeg.get(); - testRoom = testUtils.mkStubRoom("$aroomid", "Test room", client); + client = stubClient(); + room = new Room("!room:example.com", client, "@user:example.org", { + pendingEventOrdering: PendingEventOrdering.Detached, + }); }); + function addThread(room: Room): void { + const threadId = "thread_id"; + jest.spyOn(room, "eventShouldLiveIn").mockReturnValue({ + shouldLiveInRoom: true, + shouldLiveInThread: true, + threadId, + }); + const thread = room.createThread( + threadId, + new MatrixEvent({ + room_id: room.roomId, + event_id: "event_root_1", + type: EventType.RoomMessage, + sender: "userId", + content: createMessageEventContent("RootEvent"), + }), + [], + true, + ); + for (let i = 0; i < 10; i++) { + thread.addEvent( + new MatrixEvent({ + room_id: room.roomId, + event_id: "event_reply_1" + i, + type: EventType.RoomMessage, + sender: "userId", + content: createMessageEventContent("ReplyEvent" + 1), + }), + false, + ); + } + } + + function setUnreads(room: Room, greys: number, reds: number): void { + room.setUnreadNotificationCount(NotificationCountType.Highlight, reds); + room.setUnreadNotificationCount(NotificationCountType.Total, greys); + } + it("Updates on event decryption", () => { - const roomNotifState = new RoomNotificationState(testRoom as any as Room); + const roomNotifState = new RoomNotificationState(room); const listener = jest.fn(); roomNotifState.addListener(NotificationStateEvents.Update, listener); const testEvent = { - getRoomId: () => testRoom.roomId, + getRoomId: () => room.roomId, } as unknown as MatrixEvent; - testRoom.getUnreadNotificationCount = jest.fn().mockReturnValue(1); + room.getUnreadNotificationCount = jest.fn().mockReturnValue(1); client.emit(MatrixEventEvent.Decrypted, testEvent); expect(listener).toHaveBeenCalled(); }); it("removes listeners", () => { - const roomNotifState = new RoomNotificationState(testRoom as any as Room); + const roomNotifState = new RoomNotificationState(room); expect(() => roomNotifState.destroy()).not.toThrow(); }); + + it("suggests an 'unread' ! if there are unsent messages", () => { + const roomNotifState = new RoomNotificationState(room); + + const event = mkEvent({ + event: true, + type: "m.message", + user: "@user:example.org", + content: {}, + }); + event.status = EventStatus.NOT_SENT; + room.addPendingEvent(event, "txn"); + + expect(roomNotifState.color).toBe(NotificationColor.Unsent); + expect(roomNotifState.symbol).toBe("!"); + expect(roomNotifState.count).toBeGreaterThan(0); + }); + + it("suggests nothing if the room is muted", () => { + const roomNotifState = new RoomNotificationState(room); + + muteRoom(room); + setUnreads(room, 1234, 0); + room.updateMyMembership("join"); // emit + + expect(roomNotifState.color).toBe(NotificationColor.None); + expect(roomNotifState.symbol).toBe(null); + expect(roomNotifState.count).toBe(0); + }); + + it("suggests a red ! if the user has been invited to a room", () => { + const roomNotifState = new RoomNotificationState(room); + + room.updateMyMembership("invite"); // emit + + expect(roomNotifState.color).toBe(NotificationColor.Red); + expect(roomNotifState.symbol).toBe("!"); + expect(roomNotifState.count).toBeGreaterThan(0); + }); + + it("returns a proper count and color for regular unreads", () => { + const roomNotifState = new RoomNotificationState(room); + + setUnreads(room, 4321, 0); + room.updateMyMembership("join"); // emit + + expect(roomNotifState.color).toBe(NotificationColor.Grey); + expect(roomNotifState.symbol).toBe(null); + expect(roomNotifState.count).toBe(4321); + }); + + it("returns a proper count and color for highlights", () => { + const roomNotifState = new RoomNotificationState(room); + + setUnreads(room, 0, 69); + room.updateMyMembership("join"); // emit + + expect(roomNotifState.color).toBe(NotificationColor.Red); + expect(roomNotifState.symbol).toBe(null); + expect(roomNotifState.count).toBe(69); + }); + + it("includes threads", async () => { + const roomNotifState = new RoomNotificationState(room); + + room.timeline.push( + new MatrixEvent({ + room_id: room.roomId, + type: EventType.RoomMessage, + sender: "userId", + content: createMessageEventContent("timeline event"), + }), + ); + + addThread(room); + room.updateMyMembership("join"); // emit + + expect(roomNotifState.color).toBe(NotificationColor.Bold); + expect(roomNotifState.symbol).toBe(null); + }); }); diff --git a/test/test-utils/test-utils.ts b/test/test-utils/test-utils.ts index 59b07a4eb2..abe232de2d 100644 --- a/test/test-utils/test-utils.ts +++ b/test/test-utils/test-utils.ts @@ -1,5 +1,5 @@ /* -Copyright 2022 The Matrix.org Foundation C.I.C. +Copyright 2022 - 2023 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. @@ -33,6 +33,9 @@ import { IPusher, RoomType, KNOWN_SAFE_ROOM_VERSION, + ConditionKind, + PushRuleActionName, + IPushRules, } from "matrix-js-sdk/src/matrix"; import { normalize } from "matrix-js-sdk/src/utils"; import { ReEmitter } from "matrix-js-sdk/src/ReEmitter"; @@ -139,7 +142,7 @@ export function createTestClient(): MatrixClient { getThirdpartyUser: jest.fn().mockResolvedValue([]), getAccountData: jest.fn().mockImplementation((type) => { return mkEvent({ - user: undefined, + user: "@user:example.com", room: undefined, type, event: true, @@ -480,8 +483,12 @@ export function mkMessage({ return mkEvent(event); } -export function mkStubRoom(roomId: string = null, name: string, client: MatrixClient): Room { - const stubTimeline = { getEvents: () => [] as MatrixEvent[] } as unknown as EventTimeline; +export function mkStubRoom( + roomId: string | null | undefined = null, + name: string | undefined, + client: MatrixClient | undefined, +): Room { + const stubTimeline = { getEvents: (): MatrixEvent[] => [] } as unknown as EventTimeline; return { canInvite: jest.fn(), client, @@ -565,22 +572,25 @@ export function mkServerConfig(hsUrl: string, isUrl: string) { // These methods make some use of some private methods on the AsyncStoreWithClient to simplify getting into a consistent // ready state without needing to wire up a dispatcher and pretend to be a js-sdk client. -export const setupAsyncStoreWithClient = async (store: AsyncStoreWithClient, client: MatrixClient) => { - // @ts-ignore +export const setupAsyncStoreWithClient = async ( + store: AsyncStoreWithClient, + client: MatrixClient, +) => { + // @ts-ignore protected access store.readyStore.useUnitTestClient(client); - // @ts-ignore + // @ts-ignore protected access await store.onReady(); }; -export const resetAsyncStoreWithClient = async (store: AsyncStoreWithClient) => { - // @ts-ignore +export const resetAsyncStoreWithClient = async (store: AsyncStoreWithClient) => { + // @ts-ignore protected access await store.onNotReady(); }; export const mockStateEventImplementation = (events: MatrixEvent[]) => { const stateMap = new EnhancedMap>(); events.forEach((event) => { - stateMap.getOrCreate(event.getType(), new Map()).set(event.getStateKey(), event); + stateMap.getOrCreate(event.getType(), new Map()).set(event.getStateKey()!, event); }); // recreate the overloading in RoomState @@ -617,7 +627,7 @@ export const upsertRoomStateEvents = (room: Room, events: MatrixEvent[]): void = if (!acc.has(eventType)) { acc.set(eventType, new Map()); } - acc.get(eventType).set(event.getStateKey(), event); + acc.get(eventType)?.set(event.getStateKey()!, event); return acc; }, room.currentState.events || new Map>()); @@ -674,3 +684,25 @@ export const mkPusher = (extra: Partial = {}): IPusher => ({ pushkey: "pushpush", ...extra, }); + +/** Add a mute rule for a room. */ +export function muteRoom(room: Room): void { + const client = room.client!; + client.pushRules = client.pushRules ?? ({ global: [] } as IPushRules); + client.pushRules.global = client.pushRules.global ?? {}; + client.pushRules.global.override = [ + { + default: true, + enabled: true, + rule_id: "rule_id", + conditions: [ + { + kind: ConditionKind.EventMatch, + key: "room_id", + pattern: room.roomId, + }, + ], + actions: [PushRuleActionName.DontNotify], + }, + ]; +}