You've already forked matrix-js-sdk
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:
@@ -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",
|
||||
|
@@ -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)", () => {
|
||||
|
94
spec/test-utils/client.ts
Normal file
94
spec/test-utils/client.ts
Normal 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),
|
||||
});
|
||||
|
@@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
114
spec/unit/notifications.spec.ts
Normal file
114
spec/unit/notifications.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
26
src/@types/sync.ts
Normal file
26
src/@types/sync.ts
Normal 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");
|
@@ -872,7 +872,7 @@ type UserEvents = UserEvent.AvatarUrl
|
||||
| UserEvent.CurrentlyActive
|
||||
| UserEvent.LastPresenceTs;
|
||||
|
||||
type EmittedEvents = ClientEvent
|
||||
export type EmittedEvents = ClientEvent
|
||||
| RoomEvents
|
||||
| RoomStateEvents
|
||||
| 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
|
||||
// state, such as highlights when the user's name is mentioned.
|
||||
this.on(MatrixEventEvent.Decrypted, (event) => {
|
||||
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
|
||||
@@ -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.
|
||||
* <p>
|
||||
|
@@ -57,6 +57,8 @@ export interface IRoomEventFilter extends IFilterComponent {
|
||||
types?: Array<EventType | string>;
|
||||
related_by_senders?: Array<RelationType | string>;
|
||||
related_by_rel_types?: string[];
|
||||
unread_thread_notifications?: boolean;
|
||||
"org.matrix.msc3773.unread_thread_notifications"?: boolean;
|
||||
|
||||
// Unstable values
|
||||
"io.element.relation_senders"?: Array<RelationType | string>;
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
@@ -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
|
||||
|
@@ -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<Record<NotificationCountType, number>>;
|
||||
|
||||
export enum NotificationCountType {
|
||||
Highlight = "highlight",
|
||||
Total = "total",
|
||||
@@ -183,7 +185,8 @@ export type RoomEventHandlerMap = {
|
||||
export class Room extends ReadReceipt<EmittedEvents, RoomEventHandlerMap> {
|
||||
public readonly reEmitter: TypedReEmitter<EmittedEvents, RoomEventHandlerMap>;
|
||||
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[];
|
||||
public readonly threadsTimelineSets: EventTimelineSet[] = [];
|
||||
// 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];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
|
@@ -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<string, UnreadNotificationCounts>;
|
||||
"org.matrix.msc3773.unread_thread_notifications"?: Record<string, UnreadNotificationCounts>;
|
||||
}
|
||||
|
||||
export interface IStrippedState {
|
||||
@@ -153,7 +156,8 @@ interface IRoom {
|
||||
}[];
|
||||
_summary: Partial<IRoomSummary>;
|
||||
_accountData: { [eventType: string]: IMinimalEvent };
|
||||
_unreadNotifications: Partial<IUnreadNotificationCounts>;
|
||||
_unreadNotifications: Partial<UnreadNotificationCounts>;
|
||||
_unreadThreadNotifications?: Record<string, Partial<UnreadNotificationCounts>>;
|
||||
_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
|
||||
|
28
src/sync.ts
28
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) {
|
||||
|
@@ -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);
|
||||
}
|
||||
|
||||
|
27
yarn.lock
27
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"
|
||||
|
Reference in New Issue
Block a user