Unify unread notification state determination (#9941)

* Add tests for unread notification facilities

Add some tests to guarantee some consistency in `useUnreadNotifications` and
`RoomNotificationState`.

Signed-off-by: Clark Fischer <clark.fischer@gmail.com>

* Add RoomNotifs#determineUnreadState

Intended as a singular replacement for the divergent implementations before.

Signed-off-by: Clark Fischer <clark.fischer@gmail.com>

* Unify room unread state determination

Have both the class-based facility and the hook use the new unified logic in
`RoomNotifs#determineUnreadState`.

Addresses https://github.com/vector-im/element-web/issues/24229

Signed-off-by: Clark Fischer <clark.fischer@gmail.com>

---------

Signed-off-by: Clark Fischer <clark.fischer@gmail.com>
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
This commit is contained in:
Clark Fischer 2023-01-31 09:58:17 +00:00 committed by GitHub
parent 53a9b6447b
commit 431afaafc6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 499 additions and 231 deletions

View file

@ -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,
};
}

View file

@ -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(() => {

View file

@ -58,10 +58,9 @@ export class RoomEchoChamber extends GenericEchoChamber<RoomEchoContext, CachedR
};
private updateNotificationVolume(): void {
this.properties.set(
CachedRoomKey.NotificationVolume,
getRoomNotifsState(this.matrixClient, this.context.room.roomId),
);
const state = getRoomNotifsState(this.matrixClient, this.context.room.roomId);
if (state) this.properties.set(CachedRoomKey.NotificationVolume, state);
else this.properties.delete(CachedRoomKey.NotificationVolume);
this.markEchoReceived(CachedRoomKey.NotificationVolume);
this.emit(PROPERTY_UPDATED, CachedRoomKey.NotificationVolume);
}

View file

@ -1,5 +1,5 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Copyright 2020, 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,21 +14,19 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { MatrixEvent, MatrixEventEvent } from "matrix-js-sdk/src/models/event";
import { NotificationCountType, Room, RoomEvent } from "matrix-js-sdk/src/models/room";
import { MatrixEventEvent } from "matrix-js-sdk/src/models/event";
import { RoomEvent } from "matrix-js-sdk/src/models/room";
import { ClientEvent } from "matrix-js-sdk/src/client";
import { Feature, ServerSupport } from "matrix-js-sdk/src/feature";
import { NotificationColor } from "./NotificationColor";
import { IDestroyable } from "../../utils/IDestroyable";
import type { Room } from "matrix-js-sdk/src/models/room";
import type { MatrixEvent } from "matrix-js-sdk/src/models/event";
import type { IDestroyable } from "../../utils/IDestroyable";
import { MatrixClientPeg } from "../../MatrixClientPeg";
import { EffectiveMembership, getEffectiveMembership } from "../../utils/membership";
import { readReceiptChangeIsFor } from "../../utils/read-receipts";
import * as RoomNotifs from "../../RoomNotifs";
import * as Unread from "../../Unread";
import { NotificationState, NotificationStateEvents } from "./NotificationState";
import { getUnsentMessages } from "../../components/structures/RoomStatusBar";
import { ThreadsRoomNotificationState } from "./ThreadsRoomNotificationState";
import type { ThreadsRoomNotificationState } from "./ThreadsRoomNotificationState";
export class RoomNotificationState extends NotificationState implements IDestroyable {
public constructor(public readonly room: Room, private readonly threadsState?: ThreadsRoomNotificationState) {
@ -49,10 +47,6 @@ export class RoomNotificationState extends NotificationState implements IDestroy
this.updateNotificationState();
}
private get roomIsInvite(): boolean {
return getEffectiveMembership(this.room.getMyMembership()) === EffectiveMembership.Invite;
}
public destroy(): void {
super.destroy();
const cli = this.room.client;
@ -112,58 +106,10 @@ export class RoomNotificationState extends NotificationState implements IDestroy
private updateNotificationState(): void {
const snapshot = this.snapshot();
if (getUnsentMessages(this.room).length > 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);

View file

@ -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<MatrixClient>;
beforeEach(() => {
stubClient();
client = stubClient() as jest.Mocked<MatrixClient>;
});
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);
});
});
});

View file

@ -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 <UnreadNotificationBadge room={room} threadId={threadId} />;
}
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();

View file

@ -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);
});
});

View file

@ -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);
});
});

View file

@ -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 <T = unknown>(store: AsyncStoreWithClient<T>, client: MatrixClient) => {
// @ts-ignore
export const setupAsyncStoreWithClient = async <T extends Object = any>(
store: AsyncStoreWithClient<T>,
client: MatrixClient,
) => {
// @ts-ignore protected access
store.readyStore.useUnitTestClient(client);
// @ts-ignore
// @ts-ignore protected access
await store.onReady();
};
export const resetAsyncStoreWithClient = async <T = unknown>(store: AsyncStoreWithClient<T>) => {
// @ts-ignore
export const resetAsyncStoreWithClient = async <T extends Object = any>(store: AsyncStoreWithClient<T>) => {
// @ts-ignore protected access
await store.onNotReady();
};
export const mockStateEventImplementation = (events: MatrixEvent[]) => {
const stateMap = new EnhancedMap<string, Map<string, MatrixEvent>>();
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<string, Map<string, MatrixEvent>>());
@ -674,3 +684,25 @@ export const mkPusher = (extra: Partial<IPusher> = {}): 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],
},
];
}