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 2016, 2019, 2023 The Matrix.org Foundation C.I.C.
Copyright 2019 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with 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 { PushProcessor } from "matrix-js-sdk/src/pushprocessor";
import { NotificationCountType, Room } from "matrix-js-sdk/src/models/room"; import { NotificationCountType } from "matrix-js-sdk/src/models/room";
import { import { ConditionKind, PushRuleActionName, PushRuleKind, TweakName } from "matrix-js-sdk/src/@types/PushRules";
ConditionKind,
IPushRule,
PushRuleActionName,
PushRuleKind,
TweakName,
} from "matrix-js-sdk/src/@types/PushRules";
import { EventType } from "matrix-js-sdk/src/@types/event"; 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 { 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 { export enum RoomNotifState {
AllMessagesLoud = "all_messages_loud", AllMessagesLoud = "all_messages_loud",
@ -36,7 +35,7 @@ export enum RoomNotifState {
Mute = "mute", 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; if (client.isGuest()) return RoomNotifState.AllMessages;
// look through the override rules for a rule affecting this room: // 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); return Promise.all(promises);
} }
function findOverrideMuteRule(roomId: string): IPushRule { function findOverrideMuteRule(roomId: string): IPushRule | null {
const cli = MatrixClientPeg.get(); const cli = MatrixClientPeg.get();
if (!cli?.pushRules?.global?.override) { if (!cli?.pushRules?.global?.override) {
return null; return null;
@ -201,3 +200,44 @@ function isRuleForRoom(roomId: string, rule: IPushRule): boolean {
function isMuteRule(rule: IPushRule): boolean { function isMuteRule(rule: IPushRule): boolean {
return rule.actions.length === 1 && rule.actions[0] === PushRuleActionName.DontNotify; 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"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with 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. limitations under the License.
*/ */
import { NotificationCount, NotificationCountType, Room, RoomEvent } from "matrix-js-sdk/src/models/room"; import { RoomEvent } from "matrix-js-sdk/src/models/room";
import { Thread } from "matrix-js-sdk/src/models/thread";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { getUnsentMessages } from "../components/structures/RoomStatusBar"; import type { NotificationCount, Room } from "matrix-js-sdk/src/models/room";
import { getRoomNotifsState, getUnreadNotificationCount, RoomNotifState } from "../RoomNotifs"; import { determineUnreadState } from "../RoomNotifs";
import { NotificationColor } from "../stores/notifications/NotificationColor"; import type { NotificationColor } from "../stores/notifications/NotificationColor";
import { doesRoomOrThreadHaveUnreadMessages } from "../Unread";
import { EffectiveMembership, getEffectiveMembership } from "../utils/membership";
import { useEventEmitter } from "./useEventEmitter"; import { useEventEmitter } from "./useEventEmitter";
export const useUnreadNotifications = ( export const useUnreadNotifications = (
@ -53,40 +50,10 @@ export const useUnreadNotifications = (
useEventEmitter(room, RoomEvent.MyMembership, () => updateNotificationState()); useEventEmitter(room, RoomEvent.MyMembership, () => updateNotificationState());
const updateNotificationState = useCallback(() => { const updateNotificationState = useCallback(() => {
if (getUnsentMessages(room, threadId).length > 0) { const { symbol, count, color } = determineUnreadState(room, threadId);
setSymbol("!"); setSymbol(symbol);
setCount(1); setCount(count);
setColor(NotificationColor.Unsent); setColor(color);
} 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);
}
}
}, [room, threadId]); }, [room, threadId]);
useEffect(() => { useEffect(() => {

View file

@ -58,10 +58,9 @@ export class RoomEchoChamber extends GenericEchoChamber<RoomEchoContext, CachedR
}; };
private updateNotificationVolume(): void { private updateNotificationVolume(): void {
this.properties.set( const state = getRoomNotifsState(this.matrixClient, this.context.room.roomId);
CachedRoomKey.NotificationVolume, if (state) this.properties.set(CachedRoomKey.NotificationVolume, state);
getRoomNotifsState(this.matrixClient, this.context.room.roomId), else this.properties.delete(CachedRoomKey.NotificationVolume);
);
this.markEchoReceived(CachedRoomKey.NotificationVolume); this.markEchoReceived(CachedRoomKey.NotificationVolume);
this.emit(PROPERTY_UPDATED, 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"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with 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. limitations under the License.
*/ */
import { MatrixEvent, MatrixEventEvent } from "matrix-js-sdk/src/models/event"; import { MatrixEventEvent } from "matrix-js-sdk/src/models/event";
import { NotificationCountType, Room, RoomEvent } from "matrix-js-sdk/src/models/room"; import { RoomEvent } from "matrix-js-sdk/src/models/room";
import { ClientEvent } from "matrix-js-sdk/src/client"; import { ClientEvent } from "matrix-js-sdk/src/client";
import { Feature, ServerSupport } from "matrix-js-sdk/src/feature"; import { Feature, ServerSupport } from "matrix-js-sdk/src/feature";
import { NotificationColor } from "./NotificationColor"; import type { Room } from "matrix-js-sdk/src/models/room";
import { IDestroyable } from "../../utils/IDestroyable"; import type { MatrixEvent } from "matrix-js-sdk/src/models/event";
import type { IDestroyable } from "../../utils/IDestroyable";
import { MatrixClientPeg } from "../../MatrixClientPeg"; import { MatrixClientPeg } from "../../MatrixClientPeg";
import { EffectiveMembership, getEffectiveMembership } from "../../utils/membership";
import { readReceiptChangeIsFor } from "../../utils/read-receipts"; import { readReceiptChangeIsFor } from "../../utils/read-receipts";
import * as RoomNotifs from "../../RoomNotifs"; import * as RoomNotifs from "../../RoomNotifs";
import * as Unread from "../../Unread";
import { NotificationState, NotificationStateEvents } from "./NotificationState"; import { NotificationState, NotificationStateEvents } from "./NotificationState";
import { getUnsentMessages } from "../../components/structures/RoomStatusBar"; import type { ThreadsRoomNotificationState } from "./ThreadsRoomNotificationState";
import { ThreadsRoomNotificationState } from "./ThreadsRoomNotificationState";
export class RoomNotificationState extends NotificationState implements IDestroyable { export class RoomNotificationState extends NotificationState implements IDestroyable {
public constructor(public readonly room: Room, private readonly threadsState?: ThreadsRoomNotificationState) { public constructor(public readonly room: Room, private readonly threadsState?: ThreadsRoomNotificationState) {
@ -49,10 +47,6 @@ export class RoomNotificationState extends NotificationState implements IDestroy
this.updateNotificationState(); this.updateNotificationState();
} }
private get roomIsInvite(): boolean {
return getEffectiveMembership(this.room.getMyMembership()) === EffectiveMembership.Invite;
}
public destroy(): void { public destroy(): void {
super.destroy(); super.destroy();
const cli = this.room.client; const cli = this.room.client;
@ -112,58 +106,10 @@ export class RoomNotificationState extends NotificationState implements IDestroy
private updateNotificationState(): void { private updateNotificationState(): void {
const snapshot = this.snapshot(); const snapshot = this.snapshot();
if (getUnsentMessages(this.room).length > 0) { const { color, symbol, count } = RoomNotifs.determineUnreadState(this.room);
// When there are unsent messages we show a red `!` this._color = color;
this._color = NotificationColor.Unsent; this._symbol = symbol;
this._symbol = "!"; this._count = count;
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;
}
}
// finally, publish an update if needed // finally, publish an update if needed
this.emitIfUpdated(snapshot); 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"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with 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 { 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 { 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 type { MatrixClient } from "matrix-js-sdk/src/matrix";
import { MatrixClientPeg } from "../src/MatrixClientPeg"; import { mkEvent, mkRoom, muteRoom, stubClient } from "./test-utils";
import { getRoomNotifsState, RoomNotifState, getUnreadNotificationCount } from "../src/RoomNotifs"; import {
getRoomNotifsState,
RoomNotifState,
getUnreadNotificationCount,
determineUnreadState,
} from "../src/RoomNotifs";
import { NotificationColor } from "../src/stores/notifications/NotificationColor";
describe("RoomNotifs test", () => { describe("RoomNotifs test", () => {
let client: jest.Mocked<MatrixClient>;
beforeEach(() => { beforeEach(() => {
stubClient(); client = stubClient() as jest.Mocked<MatrixClient>;
}); });
it("getRoomNotifsState handles rules with no conditions", () => { it("getRoomNotifsState handles rules with no conditions", () => {
const cli = MatrixClientPeg.get(); mocked(client).pushRules = {
mocked(cli).pushRules = {
global: { global: {
override: [ 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", () => { it("getRoomNotifsState handles guest users", () => {
const cli = MatrixClientPeg.get(); mocked(client).isGuest.mockReturnValue(true);
mocked(cli).isGuest.mockReturnValue(true); expect(getRoomNotifsState(client, "!roomId:server")).toBe(RoomNotifState.AllMessages);
expect(getRoomNotifsState(cli, "!roomId:server")).toBe(RoomNotifState.AllMessages);
}); });
it("getRoomNotifsState handles mute state", () => { it("getRoomNotifsState handles mute state", () => {
const cli = MatrixClientPeg.get(); const room = mkRoom(client, "!roomId:server");
cli.pushRules = { muteRoom(room);
global: { expect(getRoomNotifsState(client, room.roomId)).toBe(RoomNotifState.Mute);
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);
}); });
it("getRoomNotifsState handles mentions only", () => { it("getRoomNotifsState handles mentions only", () => {
const cli = MatrixClientPeg.get(); (client as any).getRoomPushRule = () => ({
cli.getRoomPushRule = () => ({
rule_id: "!roomId:server", rule_id: "!roomId:server",
enabled: true, enabled: true,
default: false, default: false,
actions: [PushRuleActionName.DontNotify], actions: [PushRuleActionName.DontNotify],
}); });
expect(getRoomNotifsState(cli, "!roomId:server")).toBe(RoomNotifState.MentionsOnly); expect(getRoomNotifsState(client, "!roomId:server")).toBe(RoomNotifState.MentionsOnly);
}); });
it("getRoomNotifsState handles noisy", () => { it("getRoomNotifsState handles noisy", () => {
const cli = MatrixClientPeg.get(); (client as any).getRoomPushRule = () => ({
cli.getRoomPushRule = () => ({
rule_id: "!roomId:server", rule_id: "!roomId:server",
enabled: true, enabled: true,
default: false, default: false,
actions: [{ set_tweak: TweakName.Sound, value: "default" }], 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", () => { describe("getUnreadNotificationCount", () => {
const ROOM_ID = "!roomId:example.org"; const ROOM_ID = "!roomId:example.org";
const THREAD_ID = "$threadId"; const THREAD_ID = "$threadId";
let cli;
let room: Room; let room: Room;
beforeEach(() => { beforeEach(() => {
cli = MatrixClientPeg.get(); room = new Room(ROOM_ID, client, client.getUserId()!);
room = new Room(ROOM_ID, cli, cli.getUserId());
}); });
it("counts room notification type", () => { it("counts room notification type", () => {
@ -125,19 +110,19 @@ describe("RoomNotifs test", () => {
room.setUnreadNotificationCount(NotificationCountType.Highlight, 1); room.setUnreadNotificationCount(NotificationCountType.Highlight, 1);
const OLD_ROOM_ID = "!oldRoomId:example.org"; 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.Total, 10);
oldRoom.setUnreadNotificationCount(NotificationCountType.Highlight, 6); oldRoom.setUnreadNotificationCount(NotificationCountType.Highlight, 6);
cli.getRoom.mockReset().mockReturnValue(oldRoom); client.getRoom.mockReset().mockReturnValue(oldRoom);
const predecessorEvent = mkEvent({ const predecessorEvent = mkEvent({
event: true, event: true,
type: "m.room.create", type: "m.room.create",
room: ROOM_ID, room: ROOM_ID,
user: cli.getUserId(), user: client.getUserId()!,
content: { content: {
creator: cli.getUserId(), creator: client.getUserId(),
room_version: "5", room_version: "5",
predecessor: { predecessor: {
room_id: OLD_ROOM_ID, room_id: OLD_ROOM_ID,
@ -165,4 +150,78 @@ describe("RoomNotifs test", () => {
expect(getUnreadNotificationCount(room, NotificationCountType.Highlight, THREAD_ID)).toBe(1); 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"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with 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 { EventStatus } from "matrix-js-sdk/src/models/event-status";
import { ReceiptType } from "matrix-js-sdk/src/@types/read_receipts"; 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 { mkThread } from "../../../../test-utils/threads";
import { UnreadNotificationBadge } from "../../../../../src/components/views/rooms/NotificationBadge/UnreadNotificationBadge"; import { UnreadNotificationBadge } from "../../../../../src/components/views/rooms/NotificationBadge/UnreadNotificationBadge";
import { mkEvent, mkMessage, stubClient } from "../../../../test-utils/test-utils"; import { mkEvent, mkMessage, muteRoom, stubClient } from "../../../../test-utils/test-utils";
import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg";
import * as RoomNotifs from "../../../../../src/RoomNotifs"; 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"; const ROOM_ID = "!roomId:example.org";
let THREAD_ID: string; let THREAD_ID: string;
describe("UnreadNotificationBadge", () => { describe("UnreadNotificationBadge", () => {
stubClient(); let client: MatrixClient;
const client = MatrixClientPeg.get();
let room: Room; let room: Room;
function getComponent(threadId?: string) { function getComponent(threadId?: string) {
return <UnreadNotificationBadge room={room} threadId={threadId} />; return <UnreadNotificationBadge room={room} threadId={threadId} />;
} }
beforeAll(() => {
client.supportsThreads = () => true;
});
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); client = stubClient();
client.supportsThreads = () => true;
room = new Room(ROOM_ID, client, client.getUserId()!, { room = new Room(ROOM_ID, client, client.getUserId()!, {
pendingEventOrdering: PendingEventOrdering.Detached, pendingEventOrdering: PendingEventOrdering.Detached,
@ -145,41 +135,39 @@ describe("UnreadNotificationBadge", () => {
}); });
it("adds a warning for invites", () => { it("adds a warning for invites", () => {
jest.spyOn(room, "getMyMembership").mockReturnValue("invite"); room.updateMyMembership("invite");
render(getComponent()); render(getComponent());
expect(screen.queryByText("!")).not.toBeNull(); expect(screen.queryByText("!")).not.toBeNull();
}); });
it("hides counter for muted rooms", () => { it("hides counter for muted rooms", () => {
jest.spyOn(RoomNotifs, "getRoomNotifsState").mockReset().mockReturnValue(RoomNotifs.RoomNotifState.Mute); muteRoom(room);
const { container } = render(getComponent()); const { container } = render(getComponent());
expect(container.querySelector(".mx_NotificationBadge")).toBeNull(); expect(container.querySelector(".mx_NotificationBadge")).toBeNull();
}); });
it("activity renders unread notification badge", () => { it("activity renders unread notification badge", () => {
act(() => { room.setThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Total, 0);
room.setThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Total, 0); room.setThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Highlight, 0);
room.setThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Highlight, 0);
// Add another event on the thread which is not sent by us. // Add another event on the thread which is not sent by us.
const event = mkEvent({ const event = mkEvent({
event: true, event: true,
type: "m.room.message", type: "m.room.message",
user: "@alice:server.org", user: "@alice:server.org",
room: room.roomId, room: room.roomId,
content: { content: {
"msgtype": MsgType.Text, "msgtype": MsgType.Text,
"body": "Hello from Bob", "body": "Hello from Bob",
"m.relates_to": { "m.relates_to": {
event_id: THREAD_ID, event_id: THREAD_ID,
rel_type: RelationType.Thread, rel_type: RelationType.Thread,
},
}, },
ts: 5, },
}); ts: 5,
room.addLiveEvents([event]);
}); });
room.addLiveEvents([event]);
const { container } = render(getComponent(THREAD_ID)); const { container } = render(getComponent(THREAD_ID));
expect(container.querySelector(".mx_NotificationBadge_dot")).toBeTruthy(); 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"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with 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 { 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 type { MatrixClient } from "matrix-js-sdk/src/matrix";
import { MatrixClientPeg } from "../../../src/MatrixClientPeg"; import { mkEvent, muteRoom, stubClient } from "../../test-utils";
import { RoomNotificationState } from "../../../src/stores/notifications/RoomNotificationState"; import { RoomNotificationState } from "../../../src/stores/notifications/RoomNotificationState";
import * as testUtils from "../../test-utils";
import { NotificationStateEvents } from "../../../src/stores/notifications/NotificationState"; import { NotificationStateEvents } from "../../../src/stores/notifications/NotificationState";
import { NotificationColor } from "../../../src/stores/notifications/NotificationColor";
import { createMessageEventContent } from "../../test-utils/events";
describe("RoomNotificationState", () => { describe("RoomNotificationState", () => {
let testRoom: Room; let room: Room;
let client: MatrixClient; let client: MatrixClient;
beforeEach(() => { beforeEach(() => {
stubClient(); client = stubClient();
client = MatrixClientPeg.get(); room = new Room("!room:example.com", client, "@user:example.org", {
testRoom = testUtils.mkStubRoom("$aroomid", "Test room", client); 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", () => { it("Updates on event decryption", () => {
const roomNotifState = new RoomNotificationState(testRoom as any as Room); const roomNotifState = new RoomNotificationState(room);
const listener = jest.fn(); const listener = jest.fn();
roomNotifState.addListener(NotificationStateEvents.Update, listener); roomNotifState.addListener(NotificationStateEvents.Update, listener);
const testEvent = { const testEvent = {
getRoomId: () => testRoom.roomId, getRoomId: () => room.roomId,
} as unknown as MatrixEvent; } as unknown as MatrixEvent;
testRoom.getUnreadNotificationCount = jest.fn().mockReturnValue(1); room.getUnreadNotificationCount = jest.fn().mockReturnValue(1);
client.emit(MatrixEventEvent.Decrypted, testEvent); client.emit(MatrixEventEvent.Decrypted, testEvent);
expect(listener).toHaveBeenCalled(); expect(listener).toHaveBeenCalled();
}); });
it("removes listeners", () => { it("removes listeners", () => {
const roomNotifState = new RoomNotificationState(testRoom as any as Room); const roomNotifState = new RoomNotificationState(room);
expect(() => roomNotifState.destroy()).not.toThrow(); 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"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -33,6 +33,9 @@ import {
IPusher, IPusher,
RoomType, RoomType,
KNOWN_SAFE_ROOM_VERSION, KNOWN_SAFE_ROOM_VERSION,
ConditionKind,
PushRuleActionName,
IPushRules,
} from "matrix-js-sdk/src/matrix"; } from "matrix-js-sdk/src/matrix";
import { normalize } from "matrix-js-sdk/src/utils"; import { normalize } from "matrix-js-sdk/src/utils";
import { ReEmitter } from "matrix-js-sdk/src/ReEmitter"; import { ReEmitter } from "matrix-js-sdk/src/ReEmitter";
@ -139,7 +142,7 @@ export function createTestClient(): MatrixClient {
getThirdpartyUser: jest.fn().mockResolvedValue([]), getThirdpartyUser: jest.fn().mockResolvedValue([]),
getAccountData: jest.fn().mockImplementation((type) => { getAccountData: jest.fn().mockImplementation((type) => {
return mkEvent({ return mkEvent({
user: undefined, user: "@user:example.com",
room: undefined, room: undefined,
type, type,
event: true, event: true,
@ -480,8 +483,12 @@ export function mkMessage({
return mkEvent(event); return mkEvent(event);
} }
export function mkStubRoom(roomId: string = null, name: string, client: MatrixClient): Room { export function mkStubRoom(
const stubTimeline = { getEvents: () => [] as MatrixEvent[] } as unknown as EventTimeline; roomId: string | null | undefined = null,
name: string | undefined,
client: MatrixClient | undefined,
): Room {
const stubTimeline = { getEvents: (): MatrixEvent[] => [] } as unknown as EventTimeline;
return { return {
canInvite: jest.fn(), canInvite: jest.fn(),
client, 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 // 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. // 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) => { export const setupAsyncStoreWithClient = async <T extends Object = any>(
// @ts-ignore store: AsyncStoreWithClient<T>,
client: MatrixClient,
) => {
// @ts-ignore protected access
store.readyStore.useUnitTestClient(client); store.readyStore.useUnitTestClient(client);
// @ts-ignore // @ts-ignore protected access
await store.onReady(); await store.onReady();
}; };
export const resetAsyncStoreWithClient = async <T = unknown>(store: AsyncStoreWithClient<T>) => { export const resetAsyncStoreWithClient = async <T extends Object = any>(store: AsyncStoreWithClient<T>) => {
// @ts-ignore // @ts-ignore protected access
await store.onNotReady(); await store.onNotReady();
}; };
export const mockStateEventImplementation = (events: MatrixEvent[]) => { export const mockStateEventImplementation = (events: MatrixEvent[]) => {
const stateMap = new EnhancedMap<string, Map<string, MatrixEvent>>(); const stateMap = new EnhancedMap<string, Map<string, MatrixEvent>>();
events.forEach((event) => { 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 // recreate the overloading in RoomState
@ -617,7 +627,7 @@ export const upsertRoomStateEvents = (room: Room, events: MatrixEvent[]): void =
if (!acc.has(eventType)) { if (!acc.has(eventType)) {
acc.set(eventType, new Map()); acc.set(eventType, new Map());
} }
acc.get(eventType).set(event.getStateKey(), event); acc.get(eventType)?.set(event.getStateKey()!, event);
return acc; return acc;
}, room.currentState.events || new Map<string, Map<string, MatrixEvent>>()); }, room.currentState.events || new Map<string, Map<string, MatrixEvent>>());
@ -674,3 +684,25 @@ export const mkPusher = (extra: Partial<IPusher> = {}): IPusher => ({
pushkey: "pushpush", pushkey: "pushpush",
...extra, ...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],
},
];
}