diff --git a/package.json b/package.json index 8395a22659a..6be8b75c5b6 100644 --- a/package.json +++ b/package.json @@ -101,6 +101,7 @@ "fake-indexeddb": "^4.0.0", "jest": "^29.0.0", "jest-localstorage-mock": "^2.4.6", + "jest-mock": "^27.5.1", "jest-sonar-reporter": "^2.0.0", "jsdoc": "^3.6.6", "matrix-mock-request": "^2.1.2", diff --git a/spec/integ/matrix-client-syncing.spec.ts b/spec/integ/matrix-client-syncing.spec.ts index e07e52c7b83..f1c43c4f904 100644 --- a/spec/integ/matrix-client-syncing.spec.ts +++ b/spec/integ/matrix-client-syncing.spec.ts @@ -29,7 +29,9 @@ import { MatrixClient, ClientEvent, IndexedDBCryptoStore, + NotificationCountType, } from "../../src"; +import { UNREAD_THREAD_NOTIFICATIONS } from '../../src/@types/sync'; import * as utils from "../test-utils/test-utils"; import { TestClient } from "../TestClient"; @@ -1363,6 +1365,73 @@ describe("MatrixClient syncing", () => { }); }); + describe("unread notifications", () => { + const THREAD_ID = "$ThisIsARandomEventId"; + + const syncData = { + rooms: { + join: { + [roomOne]: { + timeline: { + events: [ + utils.mkMessage({ + room: roomOne, user: otherUserId, msg: "hello", + }), + utils.mkMessage({ + room: roomOne, user: otherUserId, msg: "world", + }), + ], + }, + state: { + events: [ + utils.mkEvent({ + type: "m.room.name", room: roomOne, user: otherUserId, + content: { + name: "Room name", + }, + }), + utils.mkMembership({ + room: roomOne, mship: "join", user: otherUserId, + }), + utils.mkMembership({ + room: roomOne, mship: "join", user: selfUserId, + }), + utils.mkEvent({ + type: "m.room.create", room: roomOne, user: selfUserId, + content: { + creator: selfUserId, + }, + }), + ], + }, + }, + }, + }, + }; + it("should sync unread notifications.", () => { + syncData.rooms.join[roomOne][UNREAD_THREAD_NOTIFICATIONS.name] = { + [THREAD_ID]: { + "highlight_count": 2, + "notification_count": 5, + }, + }; + + httpBackend!.when("GET", "/sync").respond(200, syncData); + + client!.startClient(); + + return Promise.all([ + httpBackend!.flushAllExpected(), + awaitSyncEvent(), + ]).then(() => { + const room = client!.getRoom(roomOne); + + expect(room.getThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Total)).toBe(5); + expect(room.getThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Highlight)).toBe(2); + }); + }); + }); + describe("of a room", () => { xit("should sync when a join event (which changes state) for the user" + " arrives down the event stream (e.g. join from another device)", () => { diff --git a/spec/test-utils/client.ts b/spec/test-utils/client.ts new file mode 100644 index 00000000000..3cacd179d14 --- /dev/null +++ b/spec/test-utils/client.ts @@ -0,0 +1,94 @@ +/* +Copyright 2022 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 { MethodKeysOf, mocked, MockedObject } from "jest-mock"; + +import { ClientEventHandlerMap, EmittedEvents, MatrixClient } from "../../src/client"; +import { TypedEventEmitter } from "../../src/models/typed-event-emitter"; +import { User } from "../../src/models/user"; + +/** + * Mock client with real event emitter + * useful for testing code that listens + * to MatrixClient events + */ +export class MockClientWithEventEmitter extends TypedEventEmitter { + constructor(mockProperties: Partial, unknown>> = {}) { + super(); + Object.assign(this, mockProperties); + } +} + +/** + * - make a mock client + * - cast the type to mocked(MatrixClient) + * - spy on MatrixClientPeg.get to return the mock + * eg + * ``` + * const mockClient = getMockClientWithEventEmitter({ + getUserId: jest.fn().mockReturnValue(aliceId), + }); + * ``` + */ +export const getMockClientWithEventEmitter = ( + mockProperties: Partial, unknown>>, +): MockedObject => { + const mock = mocked(new MockClientWithEventEmitter(mockProperties) as unknown as MatrixClient); + return mock; +}; + +/** + * Returns basic mocked client methods related to the current user + * ``` + * const mockClient = getMockClientWithEventEmitter({ + ...mockClientMethodsUser('@mytestuser:domain'), + }); + * ``` + */ +export const mockClientMethodsUser = (userId = '@alice:domain') => ({ + getUserId: jest.fn().mockReturnValue(userId), + getUser: jest.fn().mockReturnValue(new User(userId)), + isGuest: jest.fn().mockReturnValue(false), + mxcUrlToHttp: jest.fn().mockReturnValue('mock-mxcUrlToHttp'), + credentials: { userId }, + getThreePids: jest.fn().mockResolvedValue({ threepids: [] }), + getAccessToken: jest.fn(), +}); + +/** + * Returns basic mocked client methods related to rendering events + * ``` + * const mockClient = getMockClientWithEventEmitter({ + ...mockClientMethodsUser('@mytestuser:domain'), + }); + * ``` + */ +export const mockClientMethodsEvents = () => ({ + decryptEventIfNeeded: jest.fn(), + getPushActionsForEvent: jest.fn(), +}); + +/** + * Returns basic mocked client methods related to server support + */ +export const mockClientMethodsServer = (): Partial, unknown>> => ({ + doesServerSupportSeparateAddAndBind: jest.fn(), + getIdentityServerUrl: jest.fn(), + getHomeserverUrl: jest.fn(), + getCapabilities: jest.fn().mockReturnValue({}), + doesServerSupportUnstableFeature: jest.fn().mockResolvedValue(false), +}); + diff --git a/spec/unit/filter.spec.ts b/spec/unit/filter.spec.ts index faa0f53cad6..925915729c9 100644 --- a/spec/unit/filter.spec.ts +++ b/spec/unit/filter.spec.ts @@ -43,4 +43,17 @@ describe("Filter", function() { expect(filter.getDefinition()).toEqual(definition); }); }); + + describe("setUnreadThreadNotifications", function() { + it("setUnreadThreadNotifications", function() { + filter.setUnreadThreadNotifications(true); + expect(filter.getDefinition()).toEqual({ + room: { + timeline: { + unread_thread_notifications: true, + }, + }, + }); + }); + }); }); diff --git a/spec/unit/notifications.spec.ts b/spec/unit/notifications.spec.ts new file mode 100644 index 00000000000..89601327bc4 --- /dev/null +++ b/spec/unit/notifications.spec.ts @@ -0,0 +1,114 @@ +/* +Copyright 2022 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 { + EventType, + fixNotificationCountOnDecryption, + MatrixClient, + MatrixEvent, + MsgType, + NotificationCountType, + RelationType, + Room, +} from "../../src/matrix"; +import { IActionsObject } from "../../src/pushprocessor"; +import { ReEmitter } from "../../src/ReEmitter"; +import { getMockClientWithEventEmitter, mockClientMethodsUser } from "../test-utils/client"; +import { mkEvent, mock } from "../test-utils/test-utils"; + +let mockClient: MatrixClient; +let room: Room; +let event: MatrixEvent; +let threadEvent: MatrixEvent; + +const ROOM_ID = "!roomId:example.org"; +let THREAD_ID; + +function mkPushAction(notify, highlight): IActionsObject { + return { + notify, + tweaks: { + highlight, + }, + }; +} + +describe("fixNotificationCountOnDecryption", () => { + beforeEach(() => { + mockClient = getMockClientWithEventEmitter({ + ...mockClientMethodsUser(), + getPushActionsForEvent: jest.fn().mockReturnValue(mkPushAction(true, true)), + getRoom: jest.fn().mockImplementation(() => room), + decryptEventIfNeeded: jest.fn().mockResolvedValue(void 0), + supportsExperimentalThreads: jest.fn().mockReturnValue(true), + }); + mockClient.reEmitter = mock(ReEmitter, 'ReEmitter'); + + room = new Room(ROOM_ID, mockClient, mockClient.getUserId()); + room.setUnreadNotificationCount(NotificationCountType.Total, 1); + room.setUnreadNotificationCount(NotificationCountType.Highlight, 0); + + event = mkEvent({ + type: EventType.RoomMessage, + content: { + msgtype: MsgType.Text, + body: "Hello world!", + }, + event: true, + }, mockClient); + + THREAD_ID = event.getId(); + threadEvent = mkEvent({ + type: EventType.RoomMessage, + content: { + "m.relates_to": { + rel_type: RelationType.Thread, + event_id: THREAD_ID, + }, + "msgtype": MsgType.Text, + "body": "Thread reply", + }, + event: true, + }); + room.createThread(THREAD_ID, event, [threadEvent], false); + + room.setThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Total, 1); + room.setThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Highlight, 0); + + event.getPushActions = jest.fn().mockReturnValue(mkPushAction(false, false)); + threadEvent.getPushActions = jest.fn().mockReturnValue(mkPushAction(false, false)); + }); + + it("changes the room count to highlight on decryption", () => { + expect(room.getUnreadNotificationCount(NotificationCountType.Total)).toBe(1); + expect(room.getUnreadNotificationCount(NotificationCountType.Highlight)).toBe(0); + + fixNotificationCountOnDecryption(mockClient, event); + + expect(room.getUnreadNotificationCount(NotificationCountType.Total)).toBe(1); + expect(room.getUnreadNotificationCount(NotificationCountType.Highlight)).toBe(1); + }); + + it("changes the thread count to highlight on decryption", () => { + expect(room.getThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Total)).toBe(1); + expect(room.getThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Highlight)).toBe(0); + + fixNotificationCountOnDecryption(mockClient, threadEvent); + + expect(room.getThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Total)).toBe(1); + expect(room.getThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Highlight)).toBe(1); + }); +}); diff --git a/spec/unit/room.spec.ts b/spec/unit/room.spec.ts index e79fb7110cc..d6891f369e3 100644 --- a/spec/unit/room.spec.ts +++ b/spec/unit/room.spec.ts @@ -32,7 +32,7 @@ import { RoomEvent, } from "../../src"; import { EventTimeline } from "../../src/models/event-timeline"; -import { Room } from "../../src/models/room"; +import { NotificationCountType, Room } from "../../src/models/room"; import { RoomState } from "../../src/models/room-state"; import { UNSTABLE_ELEMENT_FUNCTIONAL_USERS } from "../../src/@types/event"; import { TestClient } from "../TestClient"; @@ -2562,4 +2562,40 @@ describe("Room", function() { expect(client.roomNameGenerator).toHaveBeenCalled(); }); }); + + describe("thread notifications", () => { + let room; + + beforeEach(() => { + const client = new TestClient(userA).client; + room = new Room(roomId, client, userA); + }); + + it("defaults to undefined", () => { + expect(room.getThreadUnreadNotificationCount("123", NotificationCountType.Total)).toBeUndefined(); + expect(room.getThreadUnreadNotificationCount("123", NotificationCountType.Highlight)).toBeUndefined(); + }); + + it("lets you set values", () => { + room.setThreadUnreadNotificationCount("123", NotificationCountType.Total, 1); + + expect(room.getThreadUnreadNotificationCount("123", NotificationCountType.Total)).toBe(1); + expect(room.getThreadUnreadNotificationCount("123", NotificationCountType.Highlight)).toBeUndefined(); + + room.setThreadUnreadNotificationCount("123", NotificationCountType.Highlight, 10); + + expect(room.getThreadUnreadNotificationCount("123", NotificationCountType.Total)).toBe(1); + expect(room.getThreadUnreadNotificationCount("123", NotificationCountType.Highlight)).toBe(10); + }); + + it("lets you reset threads notifications", () => { + room.setThreadUnreadNotificationCount("123", NotificationCountType.Total, 666); + room.setThreadUnreadNotificationCount("123", NotificationCountType.Highlight, 123); + + room.resetThreadUnreadNotificationCount(); + + expect(room.getThreadUnreadNotificationCount("123", NotificationCountType.Total)).toBeUndefined(); + expect(room.getThreadUnreadNotificationCount("123", NotificationCountType.Highlight)).toBeUndefined(); + }); + }); }); diff --git a/spec/unit/sync-accumulator.spec.ts b/spec/unit/sync-accumulator.spec.ts index 645efbfbba4..5618dbe2239 100644 --- a/spec/unit/sync-accumulator.spec.ts +++ b/spec/unit/sync-accumulator.spec.ts @@ -30,6 +30,12 @@ const RES_WITH_AGE = { account_data: { events: [] }, ephemeral: { events: [] }, unread_notifications: {}, + unread_thread_notifications: { + "$143273582443PhrSn:example.org": { + highlight_count: 0, + notification_count: 1, + }, + }, timeline: { events: [ Object.freeze({ @@ -439,6 +445,13 @@ describe("SyncAccumulator", function() { Object.keys(RES_WITH_AGE.rooms.join["!foo:bar"].timeline.events[0]), ); }); + + it("should retrieve unread thread notifications", () => { + sa.accumulate(RES_WITH_AGE); + const output = sa.getJSON(); + expect(output.roomsData.join["!foo:bar"] + .unread_thread_notifications["$143273582443PhrSn:example.org"]).not.toBeUndefined(); + }); }); }); diff --git a/src/@types/sync.ts b/src/@types/sync.ts new file mode 100644 index 00000000000..f25bbf2e497 --- /dev/null +++ b/src/@types/sync.ts @@ -0,0 +1,26 @@ +/* +Copyright 2022 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 { UnstableValue } from "matrix-events-sdk/lib/NamespacedValue"; + +/** + * https://github.com/matrix-org/matrix-doc/pull/3773 + * + * @experimental + */ +export const UNREAD_THREAD_NOTIFICATIONS = new UnstableValue( + "unread_thread_notifications", + "org.matrix.msc3773.unread_thread_notifications"); diff --git a/src/client.ts b/src/client.ts index e0e05112627..22101a9db45 100644 --- a/src/client.ts +++ b/src/client.ts @@ -865,7 +865,7 @@ type UserEvents = UserEvent.AvatarUrl | UserEvent.CurrentlyActive | UserEvent.LastPresenceTs; -type EmittedEvents = ClientEvent +export type EmittedEvents = ClientEvent | RoomEvents | RoomStateEvents | CryptoEvents @@ -1081,35 +1081,7 @@ export class MatrixClient extends TypedEventEmitter { - const oldActions = event.getPushActions(); - const actions = this.getPushActionsForEvent(event, true); - - const room = this.getRoom(event.getRoomId()); - if (!room) return; - - const currentCount = room.getUnreadNotificationCount(NotificationCountType.Highlight); - - // Ensure the unread counts are kept up to date if the event is encrypted - // We also want to make sure that the notification count goes up if we already - // have encrypted events to avoid other code from resetting 'highlight' to zero. - const oldHighlight = !!oldActions?.tweaks?.highlight; - const newHighlight = !!actions?.tweaks?.highlight; - if (oldHighlight !== newHighlight || currentCount > 0) { - // TODO: Handle mentions received while the client is offline - // See also https://github.com/vector-im/element-web/issues/9069 - if (!room.hasUserReadEvent(this.getUserId(), event.getId())) { - let newCount = currentCount; - if (newHighlight && !oldHighlight) newCount++; - if (!newHighlight && oldHighlight) newCount--; - room.setUnreadNotificationCount(NotificationCountType.Highlight, newCount); - - // Fix 'Mentions Only' rooms from not having the right badge count - const totalCount = room.getUnreadNotificationCount(NotificationCountType.Total); - if (totalCount < newCount) { - room.setUnreadNotificationCount(NotificationCountType.Total, newCount); - } - } - } + fixNotificationCountOnDecryption(this, event); }); // Like above, we have to listen for read receipts from ourselves in order to @@ -9226,6 +9198,73 @@ export class MatrixClient extends TypedEventEmitter 0) { + // TODO: Handle mentions received while the client is offline + // See also https://github.com/vector-im/element-web/issues/9069 + const hasReadEvent = isThreadEvent + ? room.getThread(event.threadRootId).hasUserReadEvent(cli.getUserId(), event.getId()) + : room.hasUserReadEvent(cli.getUserId(), event.getId()); + + if (!hasReadEvent) { + let newCount = currentCount; + if (newHighlight && !oldHighlight) newCount++; + if (!newHighlight && oldHighlight) newCount--; + + if (isThreadEvent) { + room.setThreadUnreadNotificationCount( + event.threadRootId, + NotificationCountType.Highlight, + newCount, + ); + } else { + room.setUnreadNotificationCount(NotificationCountType.Highlight, newCount); + } + + // Fix 'Mentions Only' rooms from not having the right badge count + const totalCount = (isThreadEvent + ? room.getThreadUnreadNotificationCount(event.threadRootId, NotificationCountType.Total) + : room.getUnreadNotificationCount(NotificationCountType.Total)) ?? 0; + + if (totalCount < newCount) { + if (isThreadEvent) { + room.setThreadUnreadNotificationCount( + event.threadRootId, + NotificationCountType.Total, + newCount, + ); + } else { + room.setUnreadNotificationCount(NotificationCountType.Total, newCount); + } + } + } + } +} + /** * Fires whenever the SDK receives a new event. *

diff --git a/src/filter.ts b/src/filter.ts index 663ba1bb932..0cf2d1c99e5 100644 --- a/src/filter.ts +++ b/src/filter.ts @@ -57,6 +57,8 @@ export interface IRoomEventFilter extends IFilterComponent { types?: Array; related_by_senders?: Array; related_by_rel_types?: string[]; + unread_thread_notifications?: boolean; + "org.matrix.msc3773.unread_thread_notifications"?: boolean; // Unstable values "io.element.relation_senders"?: Array; @@ -220,7 +222,15 @@ export class Filter { setProp(this.definition, "room.timeline.limit", limit); } - setLazyLoadMembers(enabled: boolean) { + /** + * Enable threads unread notification + * @param {boolean} enabled + */ + public setUnreadThreadNotifications(enabled: boolean): void { + setProp(this.definition, "room.timeline.unread_thread_notifications", !!enabled); + } + + setLazyLoadMembers(enabled: boolean): void { setProp(this.definition, "room.state.lazy_load_members", !!enabled); } diff --git a/src/models/read-receipt.ts b/src/models/read-receipt.ts index 1f7f5726f9e..e6d558766dc 100644 --- a/src/models/read-receipt.ts +++ b/src/models/read-receipt.ts @@ -282,7 +282,7 @@ export abstract class ReadReceipt< const readUpToId = this.getEventReadUpTo(userId, false); if (readUpToId === eventId) return true; - if (this.timeline.length + if (this.timeline?.length && this.timeline[this.timeline.length - 1].getSender() && this.timeline[this.timeline.length - 1].getSender() === userId) { // It doesn't matter where the event is in the timeline, the user has read @@ -290,7 +290,7 @@ export abstract class ReadReceipt< return true; } - for (let i = this.timeline.length - 1; i >= 0; --i) { + for (let i = this.timeline?.length - 1; i >= 0; --i) { const ev = this.timeline[i]; // If we encounter the target event first, the user hasn't read it diff --git a/src/models/room.ts b/src/models/room.ts index 75267fa2a29..c8a31f00da5 100644 --- a/src/models/room.ts +++ b/src/models/room.ts @@ -96,6 +96,8 @@ export interface IRecommendedVersion { // price to pay to keep matrix-js-sdk responsive. const MAX_NUMBER_OF_VISIBILITY_EVENTS_TO_SCAN_THROUGH = 30; +type NotificationCount = Partial>; + export enum NotificationCountType { Highlight = "highlight", Total = "total", @@ -183,7 +185,8 @@ export type RoomEventHandlerMap = { export class Room extends ReadReceipt { public readonly reEmitter: TypedReEmitter; private txnToEvent: Record = {}; // Pending in-flight requests { string: MatrixEvent } - private notificationCounts: Partial> = {}; + private notificationCounts: NotificationCount = {}; + private threadNotifications: Map = new Map(); private readonly timelineSets: EventTimelineSet[]; public readonly threadsTimelineSets: EventTimelineSet[] = []; // any filtered timeline sets we're maintaining for this room @@ -1180,6 +1183,37 @@ export class Room extends ReadReceipt { return this.notificationCounts[type]; } + /** + * Get one of the notification counts for a thread + * @param threadId the root event ID + * @param type The type of notification count to get. default: 'total' + * @returns The notification count, or undefined if there is no count + * for this type. + */ + public getThreadUnreadNotificationCount(threadId: string, type = NotificationCountType.Total): number | undefined { + return this.threadNotifications.get(threadId)?.[type]; + } + + /** + * Swet one of the notification count for a thread + * @param threadId the root event ID + * @param type The type of notification count to get. default: 'total' + * @returns {void} + */ + public setThreadUnreadNotificationCount(threadId: string, type: NotificationCountType, count: number): void { + this.threadNotifications.set(threadId, { + highlight: this.threadNotifications.get(threadId)?.highlight, + total: this.threadNotifications.get(threadId)?.total, + ...{ + [type]: count, + }, + }); + } + + public resetThreadUnreadNotificationCount(): void { + this.threadNotifications.clear(); + } + /** * Set one of the notification counts for this room * @param {String} type The type of notification count to set. diff --git a/src/sync-accumulator.ts b/src/sync-accumulator.ts index 037c9231b21..ec60c1c3a73 100644 --- a/src/sync-accumulator.ts +++ b/src/sync-accumulator.ts @@ -25,6 +25,7 @@ import { IContent, IUnsigned } from "./models/event"; import { IRoomSummary } from "./models/room-summary"; import { EventType } from "./@types/event"; import { ReceiptType } from "./@types/read_receipts"; +import { UNREAD_THREAD_NOTIFICATIONS } from './@types/sync'; interface IOpts { maxTimelineEntries?: number; @@ -41,7 +42,7 @@ export interface IEphemeral { } /* eslint-disable camelcase */ -interface IUnreadNotificationCounts { +interface UnreadNotificationCounts { highlight_count?: number; notification_count?: number; } @@ -75,7 +76,9 @@ export interface IJoinedRoom { timeline: ITimeline; ephemeral: IEphemeral; account_data: IAccountData; - unread_notifications: IUnreadNotificationCounts; + unread_notifications: UnreadNotificationCounts; + unread_thread_notifications?: Record; + "org.matrix.msc3773.unread_thread_notifications"?: Record; } export interface IStrippedState { @@ -153,7 +156,8 @@ interface IRoom { }[]; _summary: Partial; _accountData: { [eventType: string]: IMinimalEvent }; - _unreadNotifications: Partial; + _unreadNotifications: Partial; + _unreadThreadNotifications?: Record>; _readReceipts: { [userId: string]: { data: IMinimalEvent; @@ -362,6 +366,7 @@ export class SyncAccumulator { _timeline: [], _accountData: Object.create(null), _unreadNotifications: {}, + _unreadThreadNotifications: {}, _summary: {}, _readReceipts: {}, }; @@ -379,6 +384,10 @@ export class SyncAccumulator { if (data.unread_notifications) { currentData._unreadNotifications = data.unread_notifications; } + currentData._unreadThreadNotifications = data[UNREAD_THREAD_NOTIFICATIONS.stable] + ?? data[UNREAD_THREAD_NOTIFICATIONS.unstable] + ?? undefined; + if (data.summary) { const HEROES_KEY = "m.heroes"; const INVITED_COUNT_KEY = "m.invited_member_count"; @@ -537,6 +546,7 @@ export class SyncAccumulator { prev_batch: null, }, unread_notifications: roomData._unreadNotifications, + unread_thread_notifications: roomData._unreadThreadNotifications, summary: roomData._summary as IRoomSummary, }; // Add account data diff --git a/src/sync.ts b/src/sync.ts index 7685479499a..0026831d591 100644 --- a/src/sync.ts +++ b/src/sync.ts @@ -58,6 +58,7 @@ import { RoomMemberEvent } from "./models/room-member"; import { BeaconEvent } from "./models/beacon"; import { IEventsResponse } from "./@types/requests"; import { IAbortablePromise } from "./@types/partials"; +import { UNREAD_THREAD_NOTIFICATIONS } from "./@types/sync"; const DEBUG = true; @@ -705,6 +706,10 @@ export class SyncApi { const initialFilter = this.buildDefaultFilter(); initialFilter.setDefinition(filter.getDefinition()); initialFilter.setTimelineLimit(this.opts.initialSyncLimit); + const supportsThreadNotifications = + await this.client.doesServerSupportUnstableFeature("org.matrix.msc3773") + || await this.client.isVersionSupported("v1.4"); + initialFilter.setUnreadThreadNotifications(supportsThreadNotifications); // Use an inline filter, no point uploading it for a single usage firstSyncFilter = JSON.stringify(initialFilter.getDefinition()); } @@ -1264,6 +1269,29 @@ export class SyncApi { } } + room.resetThreadUnreadNotificationCount(); + const unreadThreadNotifications = joinObj[UNREAD_THREAD_NOTIFICATIONS.name] + ?? joinObj[UNREAD_THREAD_NOTIFICATIONS.altName]; + if (unreadThreadNotifications) { + Object.entries(unreadThreadNotifications).forEach(([threadId, unreadNotification]) => { + room.setThreadUnreadNotificationCount( + threadId, + NotificationCountType.Total, + unreadNotification.notification_count, + ); + + const hasNoNotifications = + room.getThreadUnreadNotificationCount(threadId, NotificationCountType.Highlight) <= 0; + if (!encrypted || (encrypted && hasNoNotifications)) { + room.setThreadUnreadNotificationCount( + threadId, + NotificationCountType.Highlight, + unreadNotification.highlight_count, + ); + } + }); + } + joinObj.timeline = joinObj.timeline || {} as ITimeline; if (joinObj.isBrandNewRoom) { diff --git a/src/utils.ts b/src/utils.ts index 2875cf3cfb0..818da7f647a 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -673,4 +673,3 @@ export function sortEventsByLatestContentTimestamp(left: MatrixEvent, right: Mat export function isSupportedReceiptType(receiptType: string): boolean { return [ReceiptType.Read, ReceiptType.ReadPrivate].includes(receiptType as ReceiptType); } - diff --git a/yarn.lock b/yarn.lock index 8965240fda9..72832e28d6c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1284,6 +1284,17 @@ slash "^3.0.0" write-file-atomic "^4.0.1" +"@jest/types@^27.5.1": + version "27.5.1" + resolved "https://registry.yarnpkg.com/@jest/types/-/types-27.5.1.tgz#3c79ec4a8ba61c170bf937bcf9e98a9df175ec80" + integrity sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw== + dependencies: + "@types/istanbul-lib-coverage" "^2.0.0" + "@types/istanbul-reports" "^3.0.0" + "@types/node" "*" + "@types/yargs" "^16.0.0" + chalk "^4.0.0" + "@jest/types@^28.1.3": version "28.1.3" resolved "https://registry.yarnpkg.com/@jest/types/-/types-28.1.3.tgz#b05de80996ff12512bc5ceb1d208285a7d11748b" @@ -1358,7 +1369,6 @@ "@matrix-org/olm@https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.12.tgz": version "3.2.12" - uid "0bce3c86f9d36a4984d3c3e07df1c3fb4c679bd9" resolved "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.12.tgz#0bce3c86f9d36a4984d3c3e07df1c3fb4c679bd9" "@nicolo-ribaudo/chokidar-2@2.1.8-no-fsevents.3": @@ -1695,6 +1705,13 @@ resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-21.0.0.tgz#0c60e537fa790f5f9472ed2776c2b71ec117351b" integrity sha512-iO9ZQHkZxHn4mSakYV0vFHAVDyEOIJQrV2uZ06HxEPcx+mt8swXoZHIbaaJ2crJYFfErySgktuTZ3BeLz+XmFA== +"@types/yargs@^16.0.0": + version "16.0.4" + resolved "https://registry.yarnpkg.com/@types/yargs/-/yargs-16.0.4.tgz#26aad98dd2c2a38e421086ea9ad42b9e51642977" + integrity sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw== + dependencies: + "@types/yargs-parser" "*" + "@types/yargs@^17.0.8": version "17.0.13" resolved "https://registry.yarnpkg.com/@types/yargs/-/yargs-17.0.13.tgz#34cced675ca1b1d51fcf4d34c3c6f0fa142a5c76" @@ -4471,6 +4488,14 @@ jest-message-util@^29.1.2: slash "^3.0.0" stack-utils "^2.0.3" +jest-mock@^27.5.1: + version "27.5.1" + resolved "https://registry.yarnpkg.com/jest-mock/-/jest-mock-27.5.1.tgz#19948336d49ef4d9c52021d34ac7b5f36ff967d6" + integrity sha512-K4jKbY1d4ENhbrG2zuPWaQBvDly+iZ2yAW+T1fATN78hc0sInwn7wZB8XtlNnvHug5RMwV897Xm4LqmPM4e2Og== + dependencies: + "@jest/types" "^27.5.1" + "@types/node" "*" + jest-mock@^29.1.2: version "29.1.2" resolved "https://registry.yarnpkg.com/jest-mock/-/jest-mock-29.1.2.tgz#de47807edbb9d4abf8423f1d8d308d670105678c"