1
0
mirror of https://github.com/matrix-org/matrix-js-sdk.git synced 2025-08-07 23:02:56 +03:00

Add support for unread thread notifications (#2726)

This commit is contained in:
Germain
2022-10-05 10:37:45 +01:00
committed by GitHub
parent ff720e3aa3
commit 21a6f61b7b
16 changed files with 551 additions and 40 deletions

View File

@@ -101,6 +101,7 @@
"fake-indexeddb": "^4.0.0", "fake-indexeddb": "^4.0.0",
"jest": "^29.0.0", "jest": "^29.0.0",
"jest-localstorage-mock": "^2.4.6", "jest-localstorage-mock": "^2.4.6",
"jest-mock": "^27.5.1",
"jest-sonar-reporter": "^2.0.0", "jest-sonar-reporter": "^2.0.0",
"jsdoc": "^3.6.6", "jsdoc": "^3.6.6",
"matrix-mock-request": "^2.1.2", "matrix-mock-request": "^2.1.2",

View File

@@ -29,7 +29,9 @@ import {
MatrixClient, MatrixClient,
ClientEvent, ClientEvent,
IndexedDBCryptoStore, IndexedDBCryptoStore,
NotificationCountType,
} from "../../src"; } from "../../src";
import { UNREAD_THREAD_NOTIFICATIONS } from '../../src/@types/sync';
import * as utils from "../test-utils/test-utils"; import * as utils from "../test-utils/test-utils";
import { TestClient } from "../TestClient"; 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", () => { describe("of a room", () => {
xit("should sync when a join event (which changes state) for the user" + xit("should sync when a join event (which changes state) for the user" +
" arrives down the event stream (e.g. join from another device)", () => { " arrives down the event stream (e.g. join from another device)", () => {

94
spec/test-utils/client.ts Normal file
View File

@@ -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<EmittedEvents, ClientEventHandlerMap> {
constructor(mockProperties: Partial<Record<MethodKeysOf<MatrixClient>, 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<Record<MethodKeysOf<MatrixClient>, unknown>>,
): MockedObject<MatrixClient> => {
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<Record<MethodKeysOf<MatrixClient>, unknown>> => ({
doesServerSupportSeparateAddAndBind: jest.fn(),
getIdentityServerUrl: jest.fn(),
getHomeserverUrl: jest.fn(),
getCapabilities: jest.fn().mockReturnValue({}),
doesServerSupportUnstableFeature: jest.fn().mockResolvedValue(false),
});

View File

@@ -43,4 +43,17 @@ describe("Filter", function() {
expect(filter.getDefinition()).toEqual(definition); expect(filter.getDefinition()).toEqual(definition);
}); });
}); });
describe("setUnreadThreadNotifications", function() {
it("setUnreadThreadNotifications", function() {
filter.setUnreadThreadNotifications(true);
expect(filter.getDefinition()).toEqual({
room: {
timeline: {
unread_thread_notifications: true,
},
},
});
});
});
}); });

View File

@@ -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);
});
});

View File

@@ -32,7 +32,7 @@ import {
RoomEvent, RoomEvent,
} from "../../src"; } from "../../src";
import { EventTimeline } from "../../src/models/event-timeline"; 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 { RoomState } from "../../src/models/room-state";
import { UNSTABLE_ELEMENT_FUNCTIONAL_USERS } from "../../src/@types/event"; import { UNSTABLE_ELEMENT_FUNCTIONAL_USERS } from "../../src/@types/event";
import { TestClient } from "../TestClient"; import { TestClient } from "../TestClient";
@@ -2562,4 +2562,40 @@ describe("Room", function() {
expect(client.roomNameGenerator).toHaveBeenCalled(); 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();
});
});
}); });

View File

@@ -30,6 +30,12 @@ const RES_WITH_AGE = {
account_data: { events: [] }, account_data: { events: [] },
ephemeral: { events: [] }, ephemeral: { events: [] },
unread_notifications: {}, unread_notifications: {},
unread_thread_notifications: {
"$143273582443PhrSn:example.org": {
highlight_count: 0,
notification_count: 1,
},
},
timeline: { timeline: {
events: [ events: [
Object.freeze({ Object.freeze({
@@ -439,6 +445,13 @@ describe("SyncAccumulator", function() {
Object.keys(RES_WITH_AGE.rooms.join["!foo:bar"].timeline.events[0]), 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();
});
}); });
}); });

26
src/@types/sync.ts Normal file
View File

@@ -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");

View File

@@ -872,7 +872,7 @@ type UserEvents = UserEvent.AvatarUrl
| UserEvent.CurrentlyActive | UserEvent.CurrentlyActive
| UserEvent.LastPresenceTs; | UserEvent.LastPresenceTs;
type EmittedEvents = ClientEvent export type EmittedEvents = ClientEvent
| RoomEvents | RoomEvents
| RoomStateEvents | RoomStateEvents
| CryptoEvents | CryptoEvents
@@ -1088,35 +1088,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
// We do this so that push rules are correctly executed on events in their decrypted // We do this so that push rules are correctly executed on events in their decrypted
// state, such as highlights when the user's name is mentioned. // state, such as highlights when the user's name is mentioned.
this.on(MatrixEventEvent.Decrypted, (event) => { this.on(MatrixEventEvent.Decrypted, (event) => {
const oldActions = event.getPushActions(); fixNotificationCountOnDecryption(this, event);
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);
}
}
}
}); });
// Like above, we have to listen for read receipts from ourselves in order to // Like above, we have to listen for read receipts from ourselves in order to
@@ -9236,6 +9208,73 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
} }
} }
/**
* recalculates an accurate notifications count on event decryption.
* Servers do not have enough knowledge about encrypted events to calculate an
* accurate notification_count
*/
export function fixNotificationCountOnDecryption(cli: MatrixClient, event: MatrixEvent): void {
const oldActions = event.getPushActions();
const actions = cli.getPushActionsForEvent(event, true);
const room = cli.getRoom(event.getRoomId());
if (!room || !cli.getUserId()) return;
const isThreadEvent = !!event.threadRootId && !event.isThreadRoot;
const currentCount = (isThreadEvent
? room.getThreadUnreadNotificationCount(
event.threadRootId,
NotificationCountType.Highlight,
)
: room.getUnreadNotificationCount(NotificationCountType.Highlight)) ?? 0;
// 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
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. * Fires whenever the SDK receives a new event.
* <p> * <p>

View File

@@ -57,6 +57,8 @@ export interface IRoomEventFilter extends IFilterComponent {
types?: Array<EventType | string>; types?: Array<EventType | string>;
related_by_senders?: Array<RelationType | string>; related_by_senders?: Array<RelationType | string>;
related_by_rel_types?: string[]; related_by_rel_types?: string[];
unread_thread_notifications?: boolean;
"org.matrix.msc3773.unread_thread_notifications"?: boolean;
// Unstable values // Unstable values
"io.element.relation_senders"?: Array<RelationType | string>; "io.element.relation_senders"?: Array<RelationType | string>;
@@ -220,7 +222,15 @@ export class Filter {
setProp(this.definition, "room.timeline.limit", limit); 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); setProp(this.definition, "room.state.lazy_load_members", !!enabled);
} }

View File

@@ -282,7 +282,7 @@ export abstract class ReadReceipt<
const readUpToId = this.getEventReadUpTo(userId, false); const readUpToId = this.getEventReadUpTo(userId, false);
if (readUpToId === eventId) return true; 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()
&& this.timeline[this.timeline.length - 1].getSender() === userId) { && this.timeline[this.timeline.length - 1].getSender() === userId) {
// It doesn't matter where the event is in the timeline, the user has read // 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; 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]; const ev = this.timeline[i];
// If we encounter the target event first, the user hasn't read it // If we encounter the target event first, the user hasn't read it

View File

@@ -96,6 +96,8 @@ export interface IRecommendedVersion {
// price to pay to keep matrix-js-sdk responsive. // price to pay to keep matrix-js-sdk responsive.
const MAX_NUMBER_OF_VISIBILITY_EVENTS_TO_SCAN_THROUGH = 30; const MAX_NUMBER_OF_VISIBILITY_EVENTS_TO_SCAN_THROUGH = 30;
type NotificationCount = Partial<Record<NotificationCountType, number>>;
export enum NotificationCountType { export enum NotificationCountType {
Highlight = "highlight", Highlight = "highlight",
Total = "total", Total = "total",
@@ -183,7 +185,8 @@ export type RoomEventHandlerMap = {
export class Room extends ReadReceipt<EmittedEvents, RoomEventHandlerMap> { export class Room extends ReadReceipt<EmittedEvents, RoomEventHandlerMap> {
public readonly reEmitter: TypedReEmitter<EmittedEvents, RoomEventHandlerMap>; public readonly reEmitter: TypedReEmitter<EmittedEvents, RoomEventHandlerMap>;
private txnToEvent: Record<string, MatrixEvent> = {}; // Pending in-flight requests { string: MatrixEvent } private txnToEvent: Record<string, MatrixEvent> = {}; // Pending in-flight requests { string: MatrixEvent }
private notificationCounts: Partial<Record<NotificationCountType, number>> = {}; private notificationCounts: NotificationCount = {};
private threadNotifications: Map<string, NotificationCount> = new Map();
private readonly timelineSets: EventTimelineSet[]; private readonly timelineSets: EventTimelineSet[];
public readonly threadsTimelineSets: EventTimelineSet[] = []; public readonly threadsTimelineSets: EventTimelineSet[] = [];
// any filtered timeline sets we're maintaining for this room // any filtered timeline sets we're maintaining for this room
@@ -1180,6 +1183,37 @@ export class Room extends ReadReceipt<EmittedEvents, RoomEventHandlerMap> {
return this.notificationCounts[type]; 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 * Set one of the notification counts for this room
* @param {String} type The type of notification count to set. * @param {String} type The type of notification count to set.

View File

@@ -25,6 +25,7 @@ import { IContent, IUnsigned } from "./models/event";
import { IRoomSummary } from "./models/room-summary"; import { IRoomSummary } from "./models/room-summary";
import { EventType } from "./@types/event"; import { EventType } from "./@types/event";
import { ReceiptType } from "./@types/read_receipts"; import { ReceiptType } from "./@types/read_receipts";
import { UNREAD_THREAD_NOTIFICATIONS } from './@types/sync';
interface IOpts { interface IOpts {
maxTimelineEntries?: number; maxTimelineEntries?: number;
@@ -41,7 +42,7 @@ export interface IEphemeral {
} }
/* eslint-disable camelcase */ /* eslint-disable camelcase */
interface IUnreadNotificationCounts { interface UnreadNotificationCounts {
highlight_count?: number; highlight_count?: number;
notification_count?: number; notification_count?: number;
} }
@@ -75,7 +76,9 @@ export interface IJoinedRoom {
timeline: ITimeline; timeline: ITimeline;
ephemeral: IEphemeral; ephemeral: IEphemeral;
account_data: IAccountData; account_data: IAccountData;
unread_notifications: IUnreadNotificationCounts; unread_notifications: UnreadNotificationCounts;
unread_thread_notifications?: Record<string, UnreadNotificationCounts>;
"org.matrix.msc3773.unread_thread_notifications"?: Record<string, UnreadNotificationCounts>;
} }
export interface IStrippedState { export interface IStrippedState {
@@ -153,7 +156,8 @@ interface IRoom {
}[]; }[];
_summary: Partial<IRoomSummary>; _summary: Partial<IRoomSummary>;
_accountData: { [eventType: string]: IMinimalEvent }; _accountData: { [eventType: string]: IMinimalEvent };
_unreadNotifications: Partial<IUnreadNotificationCounts>; _unreadNotifications: Partial<UnreadNotificationCounts>;
_unreadThreadNotifications?: Record<string, Partial<UnreadNotificationCounts>>;
_readReceipts: { _readReceipts: {
[userId: string]: { [userId: string]: {
data: IMinimalEvent; data: IMinimalEvent;
@@ -362,6 +366,7 @@ export class SyncAccumulator {
_timeline: [], _timeline: [],
_accountData: Object.create(null), _accountData: Object.create(null),
_unreadNotifications: {}, _unreadNotifications: {},
_unreadThreadNotifications: {},
_summary: {}, _summary: {},
_readReceipts: {}, _readReceipts: {},
}; };
@@ -379,6 +384,10 @@ export class SyncAccumulator {
if (data.unread_notifications) { if (data.unread_notifications) {
currentData._unreadNotifications = data.unread_notifications; currentData._unreadNotifications = data.unread_notifications;
} }
currentData._unreadThreadNotifications = data[UNREAD_THREAD_NOTIFICATIONS.stable]
?? data[UNREAD_THREAD_NOTIFICATIONS.unstable]
?? undefined;
if (data.summary) { if (data.summary) {
const HEROES_KEY = "m.heroes"; const HEROES_KEY = "m.heroes";
const INVITED_COUNT_KEY = "m.invited_member_count"; const INVITED_COUNT_KEY = "m.invited_member_count";
@@ -537,6 +546,7 @@ export class SyncAccumulator {
prev_batch: null, prev_batch: null,
}, },
unread_notifications: roomData._unreadNotifications, unread_notifications: roomData._unreadNotifications,
unread_thread_notifications: roomData._unreadThreadNotifications,
summary: roomData._summary as IRoomSummary, summary: roomData._summary as IRoomSummary,
}; };
// Add account data // Add account data

View File

@@ -58,6 +58,7 @@ import { RoomMemberEvent } from "./models/room-member";
import { BeaconEvent } from "./models/beacon"; import { BeaconEvent } from "./models/beacon";
import { IEventsResponse } from "./@types/requests"; import { IEventsResponse } from "./@types/requests";
import { IAbortablePromise } from "./@types/partials"; import { IAbortablePromise } from "./@types/partials";
import { UNREAD_THREAD_NOTIFICATIONS } from "./@types/sync";
const DEBUG = true; const DEBUG = true;
@@ -705,6 +706,10 @@ export class SyncApi {
const initialFilter = this.buildDefaultFilter(); const initialFilter = this.buildDefaultFilter();
initialFilter.setDefinition(filter.getDefinition()); initialFilter.setDefinition(filter.getDefinition());
initialFilter.setTimelineLimit(this.opts.initialSyncLimit); 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 // Use an inline filter, no point uploading it for a single usage
firstSyncFilter = JSON.stringify(initialFilter.getDefinition()); 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; joinObj.timeline = joinObj.timeline || {} as ITimeline;
if (joinObj.isBrandNewRoom) { if (joinObj.isBrandNewRoom) {

View File

@@ -673,4 +673,3 @@ export function sortEventsByLatestContentTimestamp(left: MatrixEvent, right: Mat
export function isSupportedReceiptType(receiptType: string): boolean { export function isSupportedReceiptType(receiptType: string): boolean {
return [ReceiptType.Read, ReceiptType.ReadPrivate].includes(receiptType as ReceiptType); return [ReceiptType.Read, ReceiptType.ReadPrivate].includes(receiptType as ReceiptType);
} }

View File

@@ -1284,6 +1284,17 @@
slash "^3.0.0" slash "^3.0.0"
write-file-atomic "^4.0.1" 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": "@jest/types@^28.1.3":
version "28.1.3" version "28.1.3"
resolved "https://registry.yarnpkg.com/@jest/types/-/types-28.1.3.tgz#b05de80996ff12512bc5ceb1d208285a7d11748b" 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": "@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" 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" 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": "@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" resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-21.0.0.tgz#0c60e537fa790f5f9472ed2776c2b71ec117351b"
integrity sha512-iO9ZQHkZxHn4mSakYV0vFHAVDyEOIJQrV2uZ06HxEPcx+mt8swXoZHIbaaJ2crJYFfErySgktuTZ3BeLz+XmFA== 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": "@types/yargs@^17.0.8":
version "17.0.13" version "17.0.13"
resolved "https://registry.yarnpkg.com/@types/yargs/-/yargs-17.0.13.tgz#34cced675ca1b1d51fcf4d34c3c6f0fa142a5c76" 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" slash "^3.0.0"
stack-utils "^2.0.3" 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: jest-mock@^29.1.2:
version "29.1.2" version "29.1.2"
resolved "https://registry.yarnpkg.com/jest-mock/-/jest-mock-29.1.2.tgz#de47807edbb9d4abf8423f1d8d308d670105678c" resolved "https://registry.yarnpkg.com/jest-mock/-/jest-mock-29.1.2.tgz#de47807edbb9d4abf8423f1d8d308d670105678c"