diff --git a/spec/integ/matrix-client-syncing.spec.ts b/spec/integ/matrix-client-syncing.spec.ts index 0135eb152..cdd38886f 100644 --- a/spec/integ/matrix-client-syncing.spec.ts +++ b/spec/integ/matrix-client-syncing.spec.ts @@ -46,6 +46,7 @@ import * as utils from "../test-utils/test-utils"; import { TestClient } from "../TestClient"; import { emitPromise, mkEvent, mkMessage } from "../test-utils/test-utils"; import { THREAD_RELATION_TYPE } from "../../src/models/thread"; +import { IActionsObject } from "../../src/pushprocessor"; import { KnownMembership } from "../../src/@types/membership"; describe("MatrixClient syncing", () => { @@ -1733,64 +1734,122 @@ describe("MatrixClient syncing", () => { }); }); - it("should apply encrypted notification logic for events within the same sync blob", async () => { - const roomId = "!room123:server"; - const syncData = { - rooms: { - join: { - [roomId]: { - ephemeral: { - events: [], - }, - timeline: { - events: [ - utils.mkEvent({ - room: roomId, - event: true, - skey: "", - type: EventType.RoomEncryption, - content: {}, - }), - utils.mkMessage({ - room: roomId, - user: otherUserId, - msg: "hello", - }), - ], - }, - state: { - events: [ - utils.mkMembership({ - room: roomId, - mship: KnownMembership.Join, - user: otherUserId, - }), - utils.mkMembership({ - room: roomId, - mship: KnownMembership.Join, - user: selfUserId, - }), - utils.mkEvent({ - type: "m.room.create", - room: roomId, - user: selfUserId, - content: {}, - }), - ], + describe("encrypted notification logic", () => { + let roomId: string; + let syncData: ISyncResponse; + + beforeEach(() => { + roomId = "!room123:server"; + syncData = { + rooms: { + join: { + [roomId]: { + ephemeral: { + events: [], + }, + timeline: { + events: [ + utils.mkEvent({ + room: roomId, + event: true, + skey: "", + type: EventType.RoomEncryption, + content: {}, + }), + utils.mkMessage({ + room: roomId, + user: otherUserId, + msg: "hello", + }), + ], + }, + state: { + events: [ + utils.mkMembership({ + room: roomId, + mship: KnownMembership.Join, + user: otherUserId, + }), + utils.mkMembership({ + room: roomId, + mship: KnownMembership.Join, + user: selfUserId, + }), + utils.mkEvent({ + type: "m.room.create", + room: roomId, + user: selfUserId, + content: {}, + }), + ], + }, }, }, }, - }, - } as unknown as ISyncResponse; + } as unknown as ISyncResponse; + }); - httpBackend!.when("GET", "/sync").respond(200, syncData); - client!.startClient(); + it("should apply encrypted notification logic for events within the same sync blob", async () => { + httpBackend!.when("GET", "/sync").respond(200, syncData); + client!.startClient(); - await Promise.all([httpBackend!.flushAllExpected(), awaitSyncEvent()]); + await Promise.all([httpBackend!.flushAllExpected(), awaitSyncEvent()]); - const room = client!.getRoom(roomId)!; - expect(room).toBeInstanceOf(Room); - expect(room.getRoomUnreadNotificationCount(NotificationCountType.Total)).toBe(0); + const room = client!.getRoom(roomId)!; + expect(room).toBeInstanceOf(Room); + expect(room.getRoomUnreadNotificationCount(NotificationCountType.Total)).toBe(0); + }); + + it("should recalculate highlights on receipt for encrypted rooms", async () => { + const myUserId = client!.getUserId()!; + + const firstEventId = syncData.rooms.join[roomId].timeline.events[1].event_id; + + // add a receipt for the first event in the room (let's say the user has already read that one) + syncData.rooms.join[roomId].ephemeral.events = [ + { + content: { + [firstEventId]: { + "m.read": { + [myUserId]: { ts: 1 }, + }, + }, + }, + type: "m.receipt", + }, + ]; + + // Now add a highlighting event after that receipt + const pingEvent = utils.mkMessage({ + room: roomId, + user: otherUserId, + msg: client?.getUserId() + " ping", + }) as IRoomEvent; + syncData.rooms.join[roomId].timeline.events.push(pingEvent); + + // fudge this to make it a highlight + client!.getPushActionsForEvent = (ev: MatrixEvent): IActionsObject | null => { + if (ev.getId() === pingEvent.event_id) { + return { + notify: true, + tweaks: { + highlight: true, + }, + }; + } + return null; + }; + + httpBackend!.when("GET", "/sync").respond(200, syncData); + client!.startClient(); + + await Promise.all([httpBackend!.flushAllExpected(), awaitSyncEvent()]); + + const room = client!.getRoom(roomId)!; + expect(room).toBeInstanceOf(Room); + // the room should now have one highlight since our receipt was before the ping message + expect(room.getRoomUnreadNotificationCount(NotificationCountType.Highlight)).toBe(1); + }); }); }); diff --git a/src/client.ts b/src/client.ts index 0233749ae..f2da061d8 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1446,55 +1446,6 @@ export class MatrixClient extends TypedEventEmitter { - if (room?.hasEncryptionStateEvent()) { - // Figure out if we've read something or if it's just informational - const content = event.getContent(); - const isSelf = - Object.keys(content).filter((eid) => { - for (const [key, value] of Object.entries(content[eid])) { - if (!utils.isSupportedReceiptType(key)) continue; - if (!value) continue; - - if (Object.keys(value).includes(this.getUserId()!)) return true; - } - - return false; - }).length > 0; - - if (!isSelf) return; - - // Work backwards to determine how many events are unread. We also set - // a limit for how back we'll look to avoid spinning CPU for too long. - // If we hit the limit, we assume the count is unchanged. - const maxHistory = 20; - const events = room.getLiveTimeline().getEvents(); - - let highlightCount = 0; - - for (let i = events.length - 1; i >= 0; i--) { - if (i === events.length - maxHistory) return; // limit reached - - const event = events[i]; - - if (room.hasUserReadEvent(this.getUserId()!, event.getId()!)) { - // If the user has read the event, then the counting is done. - break; - } - - const pushActions = this.getPushActionsForEvent(event); - highlightCount += pushActions?.tweaks?.highlight ? 1 : 0; - } - - // Note: we don't need to handle 'total' notifications because the counts - // will come from the server. - room.setUnreadNotificationCount(NotificationCountType.Highlight, highlightCount); - } - }); - this.ignoredInvites = new IgnoredInvites(this); this._secretStorage = new ServerSideSecretStorageImpl(this, opts.cryptoCallbacks ?? {}); diff --git a/src/models/room.ts b/src/models/room.ts index 356293138..8849df0ca 100644 --- a/src/models/room.ts +++ b/src/models/room.ts @@ -68,6 +68,7 @@ import { ReadReceipt, synthesizeReceipt } from "./read-receipt"; import { isPollEvent, Poll, PollEvent } from "./poll"; import { RoomReceipts } from "./room-receipts"; import { compareEventOrdering } from "./compare-event-ordering"; +import * as utils from "../utils"; import { KnownMembership, Membership } from "../@types/membership"; // These constants are used as sane defaults when the homeserver doesn't support @@ -474,6 +475,10 @@ export class Room extends ReadReceipt { this.name = roomId; this.normalizedName = roomId; + // Listen to our own receipt event as a more modular way of processing our own + // receipts. No need to remove the listener: it's on ourself anyway. + this.on(RoomEvent.Receipt, this.onReceipt); + // all our per-room timeline sets. the first one is the unfiltered ones; // the subsequent ones are the filtered ones in no particular order. this.timelineSets = [new EventTimelineSet(this, opts)]; @@ -1306,6 +1311,60 @@ export class Room extends ReadReceipt { } } + private onReceipt(event: MatrixEvent): void { + if (this.hasEncryptionStateEvent()) { + this.clearNotificationsOnReceipt(event); + } + } + + private clearNotificationsOnReceipt(event: MatrixEvent): void { + // Like above, we have to listen for read receipts from ourselves in order to + // correctly handle notification counts on encrypted rooms. + // This fixes https://github.com/vector-im/element-web/issues/9421 + + // Figure out if we've read something or if it's just informational + const content = event.getContent(); + const isSelf = + Object.keys(content).filter((eid) => { + for (const [key, value] of Object.entries(content[eid])) { + if (!utils.isSupportedReceiptType(key)) continue; + if (!value) continue; + + if (Object.keys(value).includes(this.client.getUserId()!)) return true; + } + + return false; + }).length > 0; + + if (!isSelf) return; + + // Work backwards to determine how many events are unread. We also set + // a limit for how back we'll look to avoid spinning CPU for too long. + // If we hit the limit, we assume the count is unchanged. + const maxHistory = 20; + const events = this.getLiveTimeline().getEvents(); + + let highlightCount = 0; + + for (let i = events.length - 1; i >= 0; i--) { + if (i === events.length - maxHistory) return; // limit reached + + const event = events[i]; + + if (this.hasUserReadEvent(this.client.getUserId()!, event.getId()!)) { + // If the user has read the event, then the counting is done. + break; + } + + const pushActions = this.client.getPushActionsForEvent(event); + highlightCount += pushActions?.tweaks?.highlight ? 1 : 0; + } + + // Note: we don't need to handle 'total' notifications because the counts + // will come from the server. + this.setUnreadNotificationCount(NotificationCountType.Highlight, highlightCount); + } + /** * Returns whether there are any devices in the room that are unverified *