From e9aec4e5bae86c9d8f05ce6da6da061db35bfbdd Mon Sep 17 00:00:00 2001 From: David Baker Date: Fri, 28 Mar 2025 12:38:46 +0000 Subject: [PATCH 1/7] Support unread thread notifications in SSS mode --- src/sliding-sync-sdk.ts | 4 ++++ src/sliding-sync.ts | 3 ++- src/sync-accumulator.ts | 9 +++++++-- src/sync.ts | 30 ++---------------------------- 4 files changed, 15 insertions(+), 31 deletions(-) diff --git a/src/sliding-sync-sdk.ts b/src/sliding-sync-sdk.ts index 9080db1d1dc..9293bb3c3e0 100644 --- a/src/sliding-sync-sdk.ts +++ b/src/sliding-sync-sdk.ts @@ -53,6 +53,7 @@ import { type IPushRules } from "./@types/PushRules.ts"; import { RoomStateEvent } from "./models/room-state.ts"; import { RoomMemberEvent } from "./models/room-member.ts"; import { KnownMembership } from "./@types/membership.ts"; +import { updateRoomThreadNotifications } from "./sync-helpers.ts"; // Number of consecutive failed syncs that will lead to a syncState of ERROR as opposed // to RECONNECTING. This is needed to inform the client of server issues when the @@ -633,6 +634,9 @@ export class SlidingSyncSdk { room.setUnreadNotificationCount(NotificationCountType.Highlight, roomData.highlight_count); } } + + updateRoomThreadNotifications(room, encrypted, roomData.unread_thread_notifications); + if (roomData.bump_stamp) { room.setBumpStamp(roomData.bump_stamp); } diff --git a/src/sliding-sync.ts b/src/sliding-sync.ts index e2a791cdde8..db37909ca58 100644 --- a/src/sliding-sync.ts +++ b/src/sliding-sync.ts @@ -16,7 +16,7 @@ limitations under the License. import { logger } from "./logger.ts"; import { type MatrixClient } from "./client.ts"; -import { type IRoomEvent, type IStateEvent } from "./sync-accumulator.ts"; +import { UnreadNotificationCounts, type IRoomEvent, type IStateEvent } from "./sync-accumulator.ts"; import { TypedEventEmitter } from "./models/typed-event-emitter.ts"; import { sleep } from "./utils.ts"; import { type HTTPError } from "./http-api/index.ts"; @@ -101,6 +101,7 @@ export interface MSC3575RoomData { heroes?: MSC4186Hero[]; notification_count?: number; highlight_count?: number; + unread_thread_notifications: Record; joined_count?: number; invited_count?: number; invite_state?: IStateEvent[]; diff --git a/src/sync-accumulator.ts b/src/sync-accumulator.ts index 14633ae0528..ce10880ad4f 100644 --- a/src/sync-accumulator.ts +++ b/src/sync-accumulator.ts @@ -48,8 +48,13 @@ export interface IEphemeral { events: IMinimalEvent[]; } -/* eslint-disable camelcase */ -interface UnreadNotificationCounts { +/** + * The structure of the unread_notification_counts object in sync responses + * XXX: This is not sync-accumulator related and is used in general sync code. + * + * eslint-disable camelcase + */ +export interface UnreadNotificationCounts { highlight_count?: number; notification_count?: number; } diff --git a/src/sync.ts b/src/sync.ts index 1419d1ccf0f..f1485974e10 100644 --- a/src/sync.ts +++ b/src/sync.ts @@ -66,6 +66,7 @@ import { type IEventsResponse } from "./@types/requests.ts"; import { UNREAD_THREAD_NOTIFICATIONS } from "./@types/sync.ts"; import { Feature, ServerSupport } from "./feature.ts"; import { KnownMembership } from "./@types/membership.ts"; +import { updateRoomThreadNotifications } from "./sync-helpers.ts"; const DEBUG = true; @@ -1298,34 +1299,7 @@ export class SyncApi { const unreadThreadNotifications = joinObj[UNREAD_THREAD_NOTIFICATIONS.name] ?? joinObj[UNREAD_THREAD_NOTIFICATIONS.altName!]; - if (unreadThreadNotifications) { - // This mirrors the logic above for rooms: take the *total* notification count from - // the server for unencrypted rooms or is it's zero. Any threads not present in this - // object implicitly have zero notifications, so start by clearing the total counts - // for all such threads. - room.resetThreadUnreadNotificationCountFromSync(Object.keys(unreadThreadNotifications)); - for (const [threadId, unreadNotification] of Object.entries(unreadThreadNotifications)) { - if (!encrypted || unreadNotification.notification_count === 0) { - room.setThreadUnreadNotificationCount( - threadId, - NotificationCountType.Total, - unreadNotification.notification_count ?? 0, - ); - } - - const hasNoNotifications = - room.getThreadUnreadNotificationCount(threadId, NotificationCountType.Highlight) <= 0; - if (!encrypted || (encrypted && hasNoNotifications)) { - room.setThreadUnreadNotificationCount( - threadId, - NotificationCountType.Highlight, - unreadNotification.highlight_count ?? 0, - ); - } - } - } else { - room.resetThreadUnreadNotificationCountFromSync(); - } + updateRoomThreadNotifications(room, encrypted, unreadThreadNotifications); joinObj.timeline = joinObj.timeline || ({} as ITimeline); From e9a6b1d4ae7a55d4d349f8ed81176e695414664e Mon Sep 17 00:00:00 2001 From: David Baker Date: Fri, 28 Mar 2025 12:41:58 +0000 Subject: [PATCH 2/7] Every. Single. Time. --- src/sync-helpers.ts | 62 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 src/sync-helpers.ts diff --git a/src/sync-helpers.ts b/src/sync-helpers.ts new file mode 100644 index 00000000000..233a887ac6e --- /dev/null +++ b/src/sync-helpers.ts @@ -0,0 +1,62 @@ +/* +Copyright 2025 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 { NotificationCountType, Room, UnreadNotificationCounts } from "./matrix"; + +/** + * Updates the thread notification counts for a room based on the value of + * `unreadThreadNotifications` from a sync response. This is used in v2 sync + * and the same way in simplified sliding sync. + * + * @param room The room to update the notification counts for + * @param isEncrypted Whether the room is encrypted + * @param unreadThreadNotifications The value of `unread_thread_notifications` from the sync response. + * This may be undefined, in which case the room is updated accordingly to indicate no thread notifications. + */ +export function updateRoomThreadNotifications( + room: Room, + isEncrypted: boolean, + unreadThreadNotifications: Record | undefined, +): void { + if (unreadThreadNotifications) { + // This mirrors the logic above for rooms: take the *total* notification count from + // the server for unencrypted rooms or is it's zero. Any threads not present in this + // object implicitly have zero notifications, so start by clearing the total counts + // for all such threads. + room.resetThreadUnreadNotificationCountFromSync(Object.keys(unreadThreadNotifications)); + for (const [threadId, unreadNotification] of Object.entries(unreadThreadNotifications)) { + if (!isEncrypted || unreadNotification.notification_count === 0) { + room.setThreadUnreadNotificationCount( + threadId, + NotificationCountType.Total, + unreadNotification.notification_count ?? 0, + ); + } + + const hasNoNotifications = + room.getThreadUnreadNotificationCount(threadId, NotificationCountType.Highlight) <= 0; + if (!isEncrypted || (isEncrypted && hasNoNotifications)) { + room.setThreadUnreadNotificationCount( + threadId, + NotificationCountType.Highlight, + unreadNotification.highlight_count ?? 0, + ); + } + } + } else { + room.resetThreadUnreadNotificationCountFromSync(); + } +} From aa7b6ea1def01bf78c3168fa57643d741a4ae3a9 Mon Sep 17 00:00:00 2001 From: David Baker Date: Fri, 28 Mar 2025 13:14:09 +0000 Subject: [PATCH 3/7] Type imports --- src/sliding-sync.ts | 2 +- src/sync-helpers.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/sliding-sync.ts b/src/sliding-sync.ts index db37909ca58..d34f062720e 100644 --- a/src/sliding-sync.ts +++ b/src/sliding-sync.ts @@ -16,7 +16,7 @@ limitations under the License. import { logger } from "./logger.ts"; import { type MatrixClient } from "./client.ts"; -import { UnreadNotificationCounts, type IRoomEvent, type IStateEvent } from "./sync-accumulator.ts"; +import { type UnreadNotificationCounts, type IRoomEvent, type IStateEvent } from "./sync-accumulator.ts"; import { TypedEventEmitter } from "./models/typed-event-emitter.ts"; import { sleep } from "./utils.ts"; import { type HTTPError } from "./http-api/index.ts"; diff --git a/src/sync-helpers.ts b/src/sync-helpers.ts index 233a887ac6e..cfe27466369 100644 --- a/src/sync-helpers.ts +++ b/src/sync-helpers.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { NotificationCountType, Room, UnreadNotificationCounts } from "./matrix"; +import { NotificationCountType, type Room, type UnreadNotificationCounts } from "./matrix.ts"; /** * Updates the thread notification counts for a room based on the value of From fdd03c6f5150aef70b8964da4730f9a2a9bbb6ad Mon Sep 17 00:00:00 2001 From: David Baker Date: Fri, 28 Mar 2025 13:16:49 +0000 Subject: [PATCH 4/7] Fix type --- src/sliding-sync.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sliding-sync.ts b/src/sliding-sync.ts index d34f062720e..243232d6998 100644 --- a/src/sliding-sync.ts +++ b/src/sliding-sync.ts @@ -101,7 +101,7 @@ export interface MSC3575RoomData { heroes?: MSC4186Hero[]; notification_count?: number; highlight_count?: number; - unread_thread_notifications: Record; + unread_thread_notifications?: Record; joined_count?: number; invited_count?: number; invite_state?: IStateEvent[]; From 09b786ca24202ae874f0b6d595b4c1e449f84c87 Mon Sep 17 00:00:00 2001 From: David Baker Date: Fri, 28 Mar 2025 13:40:22 +0000 Subject: [PATCH 5/7] Fix imports --- src/sync-helpers.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/sync-helpers.ts b/src/sync-helpers.ts index cfe27466369..7ac5b536195 100644 --- a/src/sync-helpers.ts +++ b/src/sync-helpers.ts @@ -14,7 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { NotificationCountType, type Room, type UnreadNotificationCounts } from "./matrix.ts"; +import { type Room, NotificationCountType } from "./models/room.ts"; +import { type UnreadNotificationCounts } from "./sync-accumulator.ts"; /** * Updates the thread notification counts for a room based on the value of From c9b3f77a5f3a11fa6ad74265cdc200b964c38b9b Mon Sep 17 00:00:00 2001 From: David Baker Date: Fri, 28 Mar 2025 14:56:48 +0000 Subject: [PATCH 6/7] Add test --- spec/integ/sliding-sync-sdk.spec.ts | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/spec/integ/sliding-sync-sdk.spec.ts b/spec/integ/sliding-sync-sdk.spec.ts index cf249b4b49e..d1c10a637ac 100644 --- a/spec/integ/sliding-sync-sdk.spec.ts +++ b/spec/integ/sliding-sync-sdk.spec.ts @@ -200,7 +200,7 @@ describe("SlidingSyncSdk", () => { const roomE = "!e_with_invite:localhost"; const roomF = "!f_calc_room_name:localhost"; const roomG = "!g_join_invite_counts:localhost"; - const roomH = "!g_num_live:localhost"; + const roomH = "!h_num_live:localhost"; const data: Record = { [roomA]: { name: "A", @@ -252,6 +252,12 @@ describe("SlidingSyncSdk", () => { mkOwnEvent(EventType.RoomMessage, { body: "world D" }), ], notification_count: 5, + unread_thread_notifications: { + "!some_thread:localhost": { + notification_count: 3, + highlight_count: 1, + }, + }, initial: true, }, [roomE]: { @@ -359,6 +365,23 @@ describe("SlidingSyncSdk", () => { ); }); + it("can be created with a thread notification counts", async () => { + mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomD, data[roomD]); + await emitPromise(client!, ClientEvent.Room); + const gotRoom = client!.getRoom(roomD); + expect(gotRoom).toBeTruthy(); + + expect( + gotRoom!.getThreadUnreadNotificationCount("!some_thread:localhost", NotificationCountType.Total), + ).toEqual(3); + expect( + gotRoom!.getThreadUnreadNotificationCount( + "!some_thread:localhost", + NotificationCountType.Highlight, + ), + ).toEqual(1); + }); + it("can be created with an invited/joined_count", async () => { mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomG, data[roomG]); await emitPromise(client!, ClientEvent.Room); From f56c2c8ab40dcafb9b3fc118b3436afa3b764558 Mon Sep 17 00:00:00 2001 From: David Baker Date: Mon, 31 Mar 2025 14:06:32 +0100 Subject: [PATCH 7/7] Use room notification API in test to avoid including threads --- spec/integ/sliding-sync-sdk.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/integ/sliding-sync-sdk.spec.ts b/spec/integ/sliding-sync-sdk.spec.ts index d1c10a637ac..edb7972a0da 100644 --- a/spec/integ/sliding-sync-sdk.spec.ts +++ b/spec/integ/sliding-sync-sdk.spec.ts @@ -360,7 +360,7 @@ describe("SlidingSyncSdk", () => { await emitPromise(client!, ClientEvent.Room); const gotRoom = client!.getRoom(roomD); expect(gotRoom).toBeTruthy(); - expect(gotRoom!.getUnreadNotificationCount(NotificationCountType.Total)).toEqual( + expect(gotRoom!.getRoomUnreadNotificationCount(NotificationCountType.Total)).toEqual( data[roomD].notification_count, ); });