diff --git a/spec/integ/sliding-sync-sdk.spec.ts b/spec/integ/sliding-sync-sdk.spec.ts index cf249b4b49..edb7972a0d 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]: { @@ -354,11 +360,28 @@ 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, ); }); + 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); diff --git a/src/sliding-sync-sdk.ts b/src/sliding-sync-sdk.ts index 9080db1d1d..9293bb3c3e 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 e2a791cdde..243232d699 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 { 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"; @@ -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 14633ae052..ce10880ad4 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-helpers.ts b/src/sync-helpers.ts new file mode 100644 index 0000000000..7ac5b53619 --- /dev/null +++ b/src/sync-helpers.ts @@ -0,0 +1,63 @@ +/* +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 { 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 + * `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(); + } +} diff --git a/src/sync.ts b/src/sync.ts index 1419d1ccf0..f1485974e1 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);