Compare commits

...

7 Commits

Author SHA1 Message Date
RiotRobot b6369cc2bd v22.0.0 2022-12-06 12:32:58 +00:00
RiotRobot e6e079f487 Prepare changelog for v22.0.0 2022-12-06 12:32:58 +00:00
RiotRobot 83a1e07380 v22.0.0-rc.2 2022-12-02 16:23:53 +00:00
RiotRobot 7f3123ed65 Prepare changelog for v22.0.0-rc.2 2022-12-02 16:23:53 +00:00
ElementRobot 5a88a6c62a [Backport staging] Fix highlight notifications increasing when total notification is zero (#2939)
Co-authored-by: Germain <germains@element.io>
2022-12-02 16:16:41 +00:00
ElementRobot 12cecbdcf1 [Backport staging] Fix synthesizeReceipt (#2934)
(cherry picked from commit 3577aa98b5)

Co-authored-by: Germain <germains@element.io>
2022-12-02 16:09:05 +00:00
Robin c17deb0806 Backport "Make GroupCall work better with widgets" to staging (#2936) 2022-12-02 10:34:41 +00:00
11 changed files with 295 additions and 48 deletions
+5 -2
View File
@@ -1,5 +1,5 @@
Changes in [22.0.0-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v22.0.0-rc.1) (2022-11-29)
============================================================================================================
Changes in [22.0.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v22.0.0) (2022-12-06)
==================================================================================================
## 🚨 BREAKING CHANGES
* Enable users to join group calls from multiple devices ([\#2902](https://github.com/matrix-org/matrix-js-sdk/pull/2902)).
@@ -19,6 +19,9 @@ Changes in [22.0.0-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/ta
* Fix 3pid invite acceptance not working due to mxid being sent in body ([\#2907](https://github.com/matrix-org/matrix-js-sdk/pull/2907)). Fixes vector-im/element-web#23823.
* Don't hang up calls that haven't started yet ([\#2898](https://github.com/matrix-org/matrix-js-sdk/pull/2898)).
* Read receipt accumulation for threads ([\#2881](https://github.com/matrix-org/matrix-js-sdk/pull/2881)).
* Make GroupCall work better with widgets ([\#2935](https://github.com/matrix-org/matrix-js-sdk/pull/2935)).
* Fix highlight notifications increasing when total notification is zero ([\#2937](https://github.com/matrix-org/matrix-js-sdk/pull/2937)). Fixes vector-im/element-web#23885.
* Fix synthesizeReceipt ([\#2916](https://github.com/matrix-org/matrix-js-sdk/pull/2916)). Fixes vector-im/element-web#23827 vector-im/element-web#23754 and vector-im/element-web#23847.
Changes in [21.2.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v21.2.0) (2022-11-22)
==================================================================================================
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "matrix-js-sdk",
"version": "22.0.0-rc.1",
"version": "22.0.0",
"description": "Matrix Client-Server SDK for Javascript",
"engines": {
"node": ">=16.0.0"
+3
View File
@@ -416,6 +416,9 @@ export class MockCallMatrixClient extends TypedEventEmitter<EmittedEvents, Emitt
public getRooms = jest.fn<Room[], []>().mockReturnValue([]);
public getRoom = jest.fn();
public supportsExperimentalThreads(): boolean { return true; }
public async decryptEventIfNeeded(): Promise<void> {}
public typed(): MatrixClient { return this as unknown as MatrixClient; }
public emitRoomState(event: MatrixEvent, state: RoomState): void {
+22 -2
View File
@@ -37,7 +37,7 @@ let event: MatrixEvent;
let threadEvent: MatrixEvent;
const ROOM_ID = "!roomId:example.org";
let THREAD_ID;
let THREAD_ID: string;
function mkPushAction(notify, highlight): IActionsObject {
return {
@@ -76,7 +76,7 @@ describe("fixNotificationCountOnDecryption", () => {
event: true,
}, mockClient);
THREAD_ID = event.getId();
THREAD_ID = event.getId()!;
threadEvent = mkEvent({
type: EventType.RoomMessage,
content: {
@@ -108,6 +108,16 @@ describe("fixNotificationCountOnDecryption", () => {
expect(room.getUnreadNotificationCount(NotificationCountType.Highlight)).toBe(1);
});
it("does not change the room count when there's no unread count", () => {
room.setUnreadNotificationCount(NotificationCountType.Total, 0);
room.setUnreadNotificationCount(NotificationCountType.Highlight, 0);
fixNotificationCountOnDecryption(mockClient, event);
expect(room.getRoomUnreadNotificationCount(NotificationCountType.Total)).toBe(1);
expect(room.getRoomUnreadNotificationCount(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);
@@ -118,6 +128,16 @@ describe("fixNotificationCountOnDecryption", () => {
expect(room.getThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Highlight)).toBe(1);
});
it("does not change the room count when there's no unread count", () => {
room.setThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Total, 0);
room.setThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Highlight, 0);
fixNotificationCountOnDecryption(mockClient, event);
expect(room.getThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Total)).toBe(0);
expect(room.getThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Highlight)).toBe(0);
});
it("emits events", () => {
const cb = jest.fn();
room.on(RoomEvent.UnreadNotifications, cb);
+17
View File
@@ -20,6 +20,7 @@ import { MAIN_ROOM_TIMELINE, ReceiptType } from '../../src/@types/read_receipts'
import { MatrixClient } from "../../src/client";
import { Feature, ServerSupport } from '../../src/feature';
import { EventType } from '../../src/matrix';
import { synthesizeReceipt } from '../../src/models/read-receipt';
import { encodeUri } from '../../src/utils';
import * as utils from "../test-utils/test-utils";
@@ -175,4 +176,20 @@ describe("Read receipt", () => {
await flushPromises();
});
});
describe("synthesizeReceipt", () => {
it.each([
{ event: roomEvent, destinationId: MAIN_ROOM_TIMELINE },
{ event: threadEvent, destinationId: threadEvent.threadRootId! },
])("adds the receipt to $destinationId", ({ event, destinationId }) => {
const userId = "@bob:example.org";
const receiptType = ReceiptType.Read;
const fakeReadReceipt = synthesizeReceipt(userId, event, receiptType);
const content = fakeReadReceipt.getContent()[event.getId()!][receiptType][userId];
expect(content.thread_id).toEqual(destinationId);
});
});
});
+168 -13
View File
@@ -25,7 +25,7 @@ import {
} from '../../../src';
import { RoomStateEvent } from "../../../src/models/room-state";
import { GroupCall, GroupCallEvent, GroupCallState } from "../../../src/webrtc/groupCall";
import { MatrixClient } from "../../../src/client";
import { IMyDevice, MatrixClient } from "../../../src/client";
import {
installWebRTCMocks,
MockCallFeed,
@@ -180,13 +180,13 @@ describe('Group Call', function() {
room = new Room(FAKE_ROOM_ID, mockClient, FAKE_USER_ID_1);
groupCall = new GroupCall(mockClient, room, GroupCallType.Video, false, GroupCallIntent.Prompt);
room.currentState.members[FAKE_USER_ID_1] = {
userId: FAKE_USER_ID_1,
membership: "join",
} as unknown as RoomMember;
});
it("does not initialize local call feed, if it already is", async () => {
room.currentState.members[FAKE_USER_ID_1] = {
userId: FAKE_USER_ID_1,
} as unknown as RoomMember;
await groupCall.initLocalCallFeed();
jest.spyOn(groupCall, "initLocalCallFeed");
await groupCall.enter();
@@ -216,10 +216,6 @@ describe('Group Call', function() {
});
it("sends member state event to room on enter", async () => {
room.currentState.members[FAKE_USER_ID_1] = {
userId: FAKE_USER_ID_1,
} as unknown as RoomMember;
await groupCall.create();
try {
@@ -249,10 +245,6 @@ describe('Group Call', function() {
});
it("sends member state event to room on leave", async () => {
room.currentState.members[FAKE_USER_ID_1] = {
userId: FAKE_USER_ID_1,
} as unknown as RoomMember;
await groupCall.create();
await groupCall.enter();
mockSendState.mockClear();
@@ -267,6 +259,18 @@ describe('Group Call', function() {
);
});
it("includes local device in participants when entered via another session", async () => {
const hasLocalParticipant = () => groupCall.participants.get(
room.getMember(mockClient.getUserId()!)!,
)?.has(mockClient.getDeviceId()!) ?? false;
expect(groupCall.enteredViaAnotherSession).toBe(false);
expect(hasLocalParticipant()).toBe(false);
groupCall.enteredViaAnotherSession = true;
expect(hasLocalParticipant()).toBe(true);
});
it("starts with mic unmuted in regular calls", async () => {
try {
await groupCall.create();
@@ -1270,4 +1274,155 @@ describe('Group Call', function() {
});
});
});
describe("cleaning member state", () => {
const bobWeb: IMyDevice = {
device_id: "bobweb",
last_seen_ts: 0,
};
const bobDesktop: IMyDevice = {
device_id: "bobdesktop",
last_seen_ts: 0,
};
const bobDesktopOffline: IMyDevice = {
device_id: "bobdesktopoffline",
last_seen_ts: 1000 * 60 * 60 * -2, // 2 hours ago
};
const bobDesktopNeverOnline: IMyDevice = {
device_id: "bobdesktopneveronline",
};
const mkContent = (devices: IMyDevice[]) => ({
"m.calls": [{
"m.call_id": groupCall.groupCallId,
"m.devices": devices.map(d => ({
device_id: d.device_id, session_id: "1", feeds: [], expires_ts: 1000 * 60 * 10,
})),
}],
});
const expectDevices = (devices: IMyDevice[]) => expect(
room.currentState.getStateEvents(EventType.GroupCallMemberPrefix, FAKE_USER_ID_2)?.getContent(),
).toEqual({
"m.calls": [{
"m.call_id": groupCall.groupCallId,
"m.devices": devices.map(d => ({
device_id: d.device_id, session_id: "1", feeds: [], expires_ts: expect.any(Number),
})),
}],
});
let mockClient: MatrixClient;
let room: Room;
let groupCall: GroupCall;
beforeAll(() => {
jest.useFakeTimers();
jest.setSystemTime(0);
});
afterAll(() => jest.useRealTimers());
beforeEach(async () => {
const typedMockClient = new MockCallMatrixClient(
FAKE_USER_ID_2, bobWeb.device_id, FAKE_SESSION_ID_2,
);
jest.spyOn(typedMockClient, "sendStateEvent").mockImplementation(
async (roomId, eventType, content, stateKey) => {
const eventId = `$${Math.random()}`;
if (roomId === room.roomId) {
room.addLiveEvents([new MatrixEvent({
event_id: eventId,
type: eventType,
room_id: roomId,
sender: FAKE_USER_ID_2,
content,
state_key: stateKey,
})]);
}
return { event_id: eventId };
},
);
mockClient = typedMockClient as unknown as MatrixClient;
room = new Room(FAKE_ROOM_ID, mockClient, FAKE_USER_ID_2);
room.getMember = jest.fn().mockImplementation((userId) => ({ userId }));
groupCall = new GroupCall(
mockClient,
room,
GroupCallType.Video,
false,
GroupCallIntent.Prompt,
FAKE_CONF_ID,
);
await groupCall.create();
mockClient.getDevices = async () => ({
devices: [
bobWeb,
bobDesktop,
bobDesktopOffline,
bobDesktopNeverOnline,
],
});
});
afterEach(() => groupCall.leave());
it("doesn't clean up valid devices", async () => {
await groupCall.enter();
await mockClient.sendStateEvent(
room.roomId,
EventType.GroupCallMemberPrefix,
mkContent([bobWeb, bobDesktop]),
FAKE_USER_ID_2,
);
await groupCall.cleanMemberState();
expectDevices([bobWeb, bobDesktop]);
});
it("cleans up our own device if we're disconnected", async () => {
await mockClient.sendStateEvent(
room.roomId,
EventType.GroupCallMemberPrefix,
mkContent([bobWeb, bobDesktop]),
FAKE_USER_ID_2,
);
await groupCall.cleanMemberState();
expectDevices([bobDesktop]);
});
it("doesn't clean up the local device if entered via another session", async () => {
groupCall.enteredViaAnotherSession = true;
await mockClient.sendStateEvent(
room.roomId,
EventType.GroupCallMemberPrefix,
mkContent([bobWeb]),
FAKE_USER_ID_2,
);
await groupCall.cleanMemberState();
expectDevices([bobWeb]);
});
it("cleans up devices that have never been online", async () => {
await mockClient.sendStateEvent(
room.roomId,
EventType.GroupCallMemberPrefix,
mkContent([bobDesktop, bobDesktopNeverOnline]),
FAKE_USER_ID_2,
);
await groupCall.cleanMemberState();
expectDevices([bobDesktop]);
});
it("no-ops if there are no state events", async () => {
await groupCall.cleanMemberState();
expect(room.currentState.getStateEvents(EventType.GroupCallMemberPrefix, FAKE_USER_ID_2)).toBe(null);
});
});
});
+11 -9
View File
@@ -5830,8 +5830,14 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
).then(async (res) => {
const mapper = this.getEventMapper();
const matrixEvents = res.chunk.map(mapper);
for (const event of matrixEvents) {
await eventTimeline.getTimelineSet()?.thread?.processEvent(event);
// Process latest events first
for (const event of matrixEvents.slice().reverse()) {
await thread?.processEvent(event);
const sender = event.getSender()!;
if (!backwards || thread?.getEventReadUpTo(sender) === null) {
room.addLocalEchoReceipt(sender, event, ReceiptType.Read);
}
}
const newToken = res.next_batch;
@@ -9338,12 +9344,8 @@ export function fixNotificationCountOnDecryption(cli: MatrixClient, event: Matri
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;
const currentCount = room.getUnreadCountForEventContext(NotificationCountType.Highlight, event);
// 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
@@ -9375,7 +9377,7 @@ export function fixNotificationCountOnDecryption(cli: MatrixClient, event: Matri
// 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;
: room.getRoomUnreadNotificationCount(NotificationCountType.Total)) ?? 0;
if (totalCount < newCount) {
if (isThreadEvent) {
+1 -1
View File
@@ -33,7 +33,7 @@ export function synthesizeReceipt(userId: string, event: MatrixEvent, receiptTyp
[receiptType]: {
[userId]: {
ts: event.getTs(),
threadId: event.threadRootId ?? MAIN_ROOM_TIMELINE,
thread_id: event.threadRootId ?? MAIN_ROOM_TIMELINE,
},
},
},
+27 -9
View File
@@ -1183,7 +1183,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
* for this type.
*/
public getUnreadNotificationCount(type = NotificationCountType.Total): number {
let count = this.notificationCounts[type] ?? 0;
let count = this.getRoomUnreadNotificationCount(type);
if (this.client.canSupport.get(Feature.ThreadUnreadNotifications) !== ServerSupport.Unsupported) {
for (const threadNotification of this.threadNotifications.values()) {
count += threadNotification[type] ?? 0;
@@ -1192,6 +1192,27 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
return count;
}
/**
* Get the notification for the event context (room or thread timeline)
*/
public getUnreadCountForEventContext(type = NotificationCountType.Total, event: MatrixEvent): number {
const isThreadEvent = !!event.threadRootId && !event.isThreadRoot;
return (isThreadEvent
? this.getThreadUnreadNotificationCount(event.threadRootId, type)
: this.getRoomUnreadNotificationCount(type)) ?? 0;
}
/**
* Get one of the notification counts for this room
* @param {String} type The type of notification count to get. default: 'total'
* @return {Number} The notification count, or undefined if there is no count
* for this type.
*/
public getRoomUnreadNotificationCount(type = NotificationCountType.Total): number {
return this.notificationCounts[type] ?? 0;
}
/**
* @experimental
* Get one of the notification counts for a thread
@@ -1675,7 +1696,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
Array.from(this.threads)
.forEach(([, thread]) => {
if (thread.length === 0) return;
const currentUserParticipated = thread.events.some(event => {
const currentUserParticipated = thread.timeline.some(event => {
return event.getSender() === this.client.getUserId();
});
if (filterType !== ThreadFilterType.My || currentUserParticipated) {
@@ -1758,20 +1779,17 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
let latestMyThreadsRootEvent: MatrixEvent | undefined;
const roomState = this.getLiveTimeline().getState(EventTimeline.FORWARDS);
for (const rootEvent of threadRoots) {
this.threadsTimelineSets[0]?.addLiveEvent(rootEvent, {
const opts = {
duplicateStrategy: DuplicateStrategy.Ignore,
fromCache: false,
roomState,
});
};
this.threadsTimelineSets[0]?.addLiveEvent(rootEvent, opts);
const threadRelationship = rootEvent
.getServerAggregatedRelation<IThreadBundledRelationship>(THREAD_RELATION_TYPE.name);
if (threadRelationship?.current_user_participated) {
this.threadsTimelineSets[1]?.addLiveEvent(rootEvent, {
duplicateStrategy: DuplicateStrategy.Ignore,
fromCache: false,
roomState,
});
this.threadsTimelineSets[1]?.addLiveEvent(rootEvent, opts);
latestMyThreadsRootEvent = rootEvent;
}
}
+19 -8
View File
@@ -81,6 +81,7 @@ export class Thread extends ReadReceipt<EmittedEvents, EventHandlerMap> {
* A reference to all the events ID at the bottom of the threads
*/
public readonly timelineSet: EventTimelineSet;
public timeline: MatrixEvent[] = [];
private _currentUserParticipated = false;
@@ -123,7 +124,7 @@ export class Thread extends ReadReceipt<EmittedEvents, EventHandlerMap> {
this.room.on(MatrixEventEvent.BeforeRedaction, this.onBeforeRedaction);
this.room.on(RoomEvent.Redaction, this.onRedaction);
this.room.on(RoomEvent.LocalEchoUpdated, this.onEcho);
this.timelineSet.on(RoomEvent.Timeline, this.onEcho);
this.timelineSet.on(RoomEvent.Timeline, this.onTimelineEvent);
// even if this thread is thought to be originating from this client, we initialise it as we may be in a
// gappy sync and a thread around this event may already exist.
@@ -181,7 +182,7 @@ export class Thread extends ReadReceipt<EmittedEvents, EventHandlerMap> {
private onRedaction = async (event: MatrixEvent): Promise<void> => {
if (event.threadRootId !== this.id) return; // ignore redactions for other timelines
if (this.replyCount <= 0) {
for (const threadEvent of this.events) {
for (const threadEvent of this.timeline) {
this.clearEventMetadata(threadEvent);
}
this.lastEvent = this.rootEvent;
@@ -192,6 +193,18 @@ export class Thread extends ReadReceipt<EmittedEvents, EventHandlerMap> {
}
};
private onTimelineEvent = (
event: MatrixEvent,
room: Room | undefined,
toStartOfTimeline: boolean | undefined,
): void => {
// Add a synthesized receipt when paginating forward in the timeline
if (!toStartOfTimeline) {
room!.addLocalEchoReceipt(event.getSender()!, event, ReceiptType.Read);
}
this.onEcho(event);
};
private onEcho = async (event: MatrixEvent): Promise<void> => {
if (event.threadRootId !== this.id) return; // ignore echoes for other timelines
if (this.lastEvent === event) return;
@@ -216,6 +229,7 @@ export class Thread extends ReadReceipt<EmittedEvents, EventHandlerMap> {
roomState: this.roomState,
},
);
this.timeline = this.events;
}
}
@@ -275,6 +289,7 @@ export class Thread extends ReadReceipt<EmittedEvents, EventHandlerMap> {
this.setEventMetadata(event);
await this.fetchEditsWhereNeeded(event);
}
this.timeline = this.events;
}
private getRootEventBundledRelationship(rootEvent = this.rootEvent): IThreadBundledRelationship | undefined {
@@ -362,8 +377,8 @@ export class Thread extends ReadReceipt<EmittedEvents, EventHandlerMap> {
* Return last reply to the thread, if known.
*/
public lastReply(matches: (ev: MatrixEvent) => boolean = (): boolean => true): MatrixEvent | null {
for (let i = this.events.length - 1; i >= 0; i--) {
const event = this.events[i];
for (let i = this.timeline.length - 1; i >= 0; i--) {
const event = this.timeline[i];
if (matches(event)) {
return event;
}
@@ -411,10 +426,6 @@ export class Thread extends ReadReceipt<EmittedEvents, EventHandlerMap> {
return this.timelineSet;
}
public get timeline(): MatrixEvent[] {
return this.events;
}
public addReceipt(event: MatrixEvent, synthetic: boolean): void {
throw new Error("Unsupported function on the thread model");
}
+21 -3
View File
@@ -285,6 +285,21 @@ export class GroupCall extends TypedEventEmitter<
this._creationTs = value;
}
private _enteredViaAnotherSession = false;
/**
* Whether the local device has entered this call via another session, such
* as a widget.
*/
public get enteredViaAnotherSession(): boolean {
return this._enteredViaAnotherSession;
}
public set enteredViaAnotherSession(value: boolean) {
this._enteredViaAnotherSession = value;
this.updateParticipants();
}
/**
* Executes the given callback on all calls in this group call.
* @param f The callback.
@@ -1170,7 +1185,7 @@ export class GroupCall extends TypedEventEmitter<
const participants = new Map<RoomMember, Map<string, ParticipantState>>();
const now = Date.now();
const entered = this.state === GroupCallState.Entered;
const entered = this.state === GroupCallState.Entered || this.enteredViaAnotherSession;
let nextExpiration = Infinity;
for (const e of this.getMemberStateEvents()) {
@@ -1344,8 +1359,11 @@ export class GroupCall extends TypedEventEmitter<
await this.updateDevices(devices => {
const newDevices = devices.filter(d => {
const device = deviceMap.get(d.device_id);
return device?.last_seen_ts !== undefined
&& !(d.device_id === this.client.getDeviceId()! && this.state !== GroupCallState.Entered);
return device?.last_seen_ts !== undefined && !(
d.device_id === this.client.getDeviceId()!
&& this.state !== GroupCallState.Entered
&& !this.enteredViaAnotherSession
);
});
// Skip the update if the devices are unchanged