From 65316ffb5c630a6fd98e1c3e266c1f32896e8977 Mon Sep 17 00:00:00 2001 From: Robin Date: Tue, 22 Mar 2022 18:14:23 -0400 Subject: [PATCH 01/28] Voice rooms prototype (#2249) * Support call room type from MSC3417 Signed-off-by: Robin Townsend * Make it more clear that call room type is unstable Signed-off-by: Robin Townsend --- src/@types/event.ts | 1 + src/models/room.ts | 10 +++++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/@types/event.ts b/src/@types/event.ts index 98c7de32f88..05f85cc4742 100644 --- a/src/@types/event.ts +++ b/src/@types/event.ts @@ -119,6 +119,7 @@ export const RoomCreateTypeField = "type"; export enum RoomType { Space = "m.space", + UnstableCall = "org.matrix.msc3417.call", } /** diff --git a/src/models/room.ts b/src/models/room.ts index 3da055e6ec6..283cf55bce9 100644 --- a/src/models/room.ts +++ b/src/models/room.ts @@ -2425,7 +2425,7 @@ export class Room extends TypedEventEmitter /** * Returns the type of the room from the `m.room.create` event content or undefined if none is set - * @returns {?string} the type of the room. Currently only RoomType.Space is known. + * @returns {?string} the type of the room. */ public getType(): RoomType | string | undefined { const createEvent = this.currentState.getStateEvents(EventType.RoomCreate, ""); @@ -2447,6 +2447,14 @@ export class Room extends TypedEventEmitter return this.getType() === RoomType.Space; } + /** + * Returns whether the room is a call-room as defined by MSC3417. + * @returns {boolean} true if the room's type is RoomType.UnstableCall + */ + public isCallRoom(): boolean { + return this.getType() === RoomType.UnstableCall; + } + /** * This is an internal method. Calculates the name of the room from the current * room state. From d0b964837f2820940bd93e718a2450b5f528bffc Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Tue, 22 Mar 2022 16:20:32 -0600 Subject: [PATCH 02/28] Remove groups (#2234) This API is due for removal in Synapse and has been deprecated for a very long time. People should move away from it soon, but just in case we'll declare this as a breaking change. There is no impact on sync storage here: we happen to store the data in a way that is backwards-compatible for group-supporting clients, and the code guards against missing data from the stores. So, if someone were to revert, they'd be "safe" (probably lose all their group info, but the app wouldn't crash). --- src/client.ts | 406 +-------------------------- src/matrix.ts | 1 - src/models/group.js | 100 ------- src/store/index.ts | 26 +- src/store/indexeddb-local-backend.ts | 6 +- src/store/memory.ts | 30 -- src/store/stub.ts | 27 -- src/sync-accumulator.ts | 50 ---- src/sync.ts | 76 +---- 9 files changed, 4 insertions(+), 718 deletions(-) delete mode 100644 src/models/group.js diff --git a/src/client.ts b/src/client.ts index 57c45778aec..640fb724dac 100644 --- a/src/client.ts +++ b/src/client.ts @@ -37,7 +37,6 @@ import { Filter, IFilterDefinition } from "./filter"; import { CallEventHandlerEvent, CallEventHandler, CallEventHandlerEventHandlerMap } from './webrtc/callEventHandler'; import * as utils from './utils'; import { sleep } from './utils'; -import { Group } from "./models/group"; import { Direction, EventTimeline } from "./models/event-timeline"; import { IActionsObject, PushProcessor } from "./pushprocessor"; import { AutoDiscovery, AutoDiscoveryAction } from "./autodiscovery"; @@ -783,11 +782,6 @@ export enum ClientEvent { DeleteRoom = "deleteRoom", SyncUnexpectedError = "sync.unexpectedError", ClientWellKnown = "WellKnown.client", - /* @deprecated */ - Group = "Group", - // The following enum members are both deprecated and in the wrong place, Groups haven't been TSified - GroupProfile = "Group.profile", - GroupMyMembership = "Group.myMembership", } type RoomEvents = RoomEvent.Name @@ -856,9 +850,6 @@ export type ClientEventHandlerMap = { [ClientEvent.DeleteRoom]: (roomId: string) => void; [ClientEvent.SyncUnexpectedError]: (error: Error) => void; [ClientEvent.ClientWellKnown]: (data: IClientWellKnown) => void; - [ClientEvent.Group]: (group: Group) => void; - [ClientEvent.GroupProfile]: (group: Group) => void; - [ClientEvent.GroupMyMembership]: (group: Group) => void; } & RoomEventHandlerMap & RoomStateEventHandlerMap & CryptoEventHandlerMap @@ -3241,27 +3232,6 @@ export class MatrixClient extends TypedEventEmitter { - // TODO: support groups + // TODO: support search groups const body = { search_categories: { @@ -8838,368 +8808,6 @@ export class MatrixClient extends TypedEventEmitter { - const path = utils.encodeUri("/groups/$groupId/summary", { $groupId: groupId }); - return this.http.authedRequest(undefined, Method.Get, path); - } - - /** - * @param {string} groupId - * @return {Promise} Resolves: Group profile object - * @return {module:http-api.MatrixError} Rejects: with an error response. - * @deprecated groups/communities never made it to the spec and support for them is being discontinued. - */ - public getGroupProfile(groupId: string): Promise { - const path = utils.encodeUri("/groups/$groupId/profile", { $groupId: groupId }); - return this.http.authedRequest(undefined, Method.Get, path); - } - - /** - * @param {string} groupId - * @param {Object} profile The group profile object - * @param {string=} profile.name Name of the group - * @param {string=} profile.avatar_url MXC avatar URL - * @param {string=} profile.short_description A short description of the room - * @param {string=} profile.long_description A longer HTML description of the room - * @return {Promise} Resolves: Empty object - * @return {module:http-api.MatrixError} Rejects: with an error response. - * @deprecated groups/communities never made it to the spec and support for them is being discontinued. - */ - public setGroupProfile(groupId: string, profile: any): Promise { - const path = utils.encodeUri("/groups/$groupId/profile", { $groupId: groupId }); - return this.http.authedRequest( - undefined, Method.Post, path, undefined, profile, - ); - } - - /** - * @param {string} groupId - * @param {object} policy The join policy for the group. Must include at - * least a 'type' field which is 'open' if anyone can join the group - * the group without prior approval, or 'invite' if an invite is - * required to join. - * @return {Promise} Resolves: Empty object - * @return {module:http-api.MatrixError} Rejects: with an error response. - * @deprecated groups/communities never made it to the spec and support for them is being discontinued. - */ - public setGroupJoinPolicy(groupId: string, policy: any): Promise { - const path = utils.encodeUri( - "/groups/$groupId/settings/m.join_policy", - { $groupId: groupId }, - ); - return this.http.authedRequest( - undefined, Method.Put, path, undefined, { - 'm.join_policy': policy, - }, - ); - } - - /** - * @param {string} groupId - * @return {Promise} Resolves: Group users list object - * @return {module:http-api.MatrixError} Rejects: with an error response. - * @deprecated groups/communities never made it to the spec and support for them is being discontinued. - */ - public getGroupUsers(groupId: string): Promise { - const path = utils.encodeUri("/groups/$groupId/users", { $groupId: groupId }); - return this.http.authedRequest(undefined, Method.Get, path); - } - - /** - * @param {string} groupId - * @return {Promise} Resolves: Group users list object - * @return {module:http-api.MatrixError} Rejects: with an error response. - * @deprecated groups/communities never made it to the spec and support for them is being discontinued. - */ - public getGroupInvitedUsers(groupId: string): Promise { - const path = utils.encodeUri("/groups/$groupId/invited_users", { $groupId: groupId }); - return this.http.authedRequest(undefined, Method.Get, path); - } - - /** - * @param {string} groupId - * @return {Promise} Resolves: Group rooms list object - * @return {module:http-api.MatrixError} Rejects: with an error response. - * @deprecated groups/communities never made it to the spec and support for them is being discontinued. - */ - public getGroupRooms(groupId: string): Promise { - const path = utils.encodeUri("/groups/$groupId/rooms", { $groupId: groupId }); - return this.http.authedRequest(undefined, Method.Get, path); - } - - /** - * @param {string} groupId - * @param {string} userId - * @return {Promise} Resolves: Empty object - * @return {module:http-api.MatrixError} Rejects: with an error response. - * @deprecated groups/communities never made it to the spec and support for them is being discontinued. - */ - public inviteUserToGroup(groupId: string, userId: string): Promise { - const path = utils.encodeUri( - "/groups/$groupId/admin/users/invite/$userId", - { $groupId: groupId, $userId: userId }, - ); - return this.http.authedRequest(undefined, Method.Put, path, undefined, {}); - } - - /** - * @param {string} groupId - * @param {string} userId - * @return {Promise} Resolves: Empty object - * @return {module:http-api.MatrixError} Rejects: with an error response. - * @deprecated groups/communities never made it to the spec and support for them is being discontinued. - */ - public removeUserFromGroup(groupId: string, userId: string): Promise { - const path = utils.encodeUri( - "/groups/$groupId/admin/users/remove/$userId", - { $groupId: groupId, $userId: userId }, - ); - return this.http.authedRequest(undefined, Method.Put, path, undefined, {}); - } - - /** - * @param {string} groupId - * @param {string} userId - * @param {string} roleId Optional. - * @return {Promise} Resolves: Empty object - * @return {module:http-api.MatrixError} Rejects: with an error response. - * @deprecated groups/communities never made it to the spec and support for them is being discontinued. - */ - public addUserToGroupSummary(groupId: string, userId: string, roleId: string): Promise { - const path = utils.encodeUri( - roleId ? - "/groups/$groupId/summary/$roleId/users/$userId" : - "/groups/$groupId/summary/users/$userId", - { $groupId: groupId, $roleId: roleId, $userId: userId }, - ); - return this.http.authedRequest(undefined, Method.Put, path, undefined, {}); - } - - /** - * @param {string} groupId - * @param {string} userId - * @return {Promise} Resolves: Empty object - * @return {module:http-api.MatrixError} Rejects: with an error response. - * @deprecated groups/communities never made it to the spec and support for them is being discontinued. - */ - public removeUserFromGroupSummary(groupId: string, userId: string): Promise { - const path = utils.encodeUri( - "/groups/$groupId/summary/users/$userId", - { $groupId: groupId, $userId: userId }, - ); - return this.http.authedRequest(undefined, Method.Delete, path, undefined, {}); - } - - /** - * @param {string} groupId - * @param {string} roomId - * @param {string} categoryId Optional. - * @return {Promise} Resolves: Empty object - * @return {module:http-api.MatrixError} Rejects: with an error response. - * @deprecated groups/communities never made it to the spec and support for them is being discontinued. - */ - public addRoomToGroupSummary(groupId: string, roomId: string, categoryId: string): Promise { - const path = utils.encodeUri( - categoryId ? - "/groups/$groupId/summary/$categoryId/rooms/$roomId" : - "/groups/$groupId/summary/rooms/$roomId", - { $groupId: groupId, $categoryId: categoryId, $roomId: roomId }, - ); - return this.http.authedRequest(undefined, Method.Put, path, undefined, {}); - } - - /** - * @param {string} groupId - * @param {string} roomId - * @return {Promise} Resolves: Empty object - * @return {module:http-api.MatrixError} Rejects: with an error response. - * @deprecated groups/communities never made it to the spec and support for them is being discontinued. - */ - public removeRoomFromGroupSummary(groupId: string, roomId: string): Promise { - const path = utils.encodeUri( - "/groups/$groupId/summary/rooms/$roomId", - { $groupId: groupId, $roomId: roomId }, - ); - return this.http.authedRequest(undefined, Method.Delete, path, undefined, {}); - } - - /** - * @param {string} groupId - * @param {string} roomId - * @param {boolean} isPublic Whether the room-group association is visible to non-members - * @return {Promise} Resolves: Empty object - * @return {module:http-api.MatrixError} Rejects: with an error response. - * @deprecated groups/communities never made it to the spec and support for them is being discontinued. - */ - public addRoomToGroup(groupId: string, roomId: string, isPublic: boolean): Promise { - if (isPublic === undefined) { - isPublic = true; - } - const path = utils.encodeUri( - "/groups/$groupId/admin/rooms/$roomId", - { $groupId: groupId, $roomId: roomId }, - ); - return this.http.authedRequest(undefined, Method.Put, path, undefined, - { "m.visibility": { type: isPublic ? "public" : "private" } }, - ); - } - - /** - * Configure the visibility of a room-group association. - * @param {string} groupId - * @param {string} roomId - * @param {boolean} isPublic Whether the room-group association is visible to non-members - * @return {Promise} Resolves: Empty object - * @return {module:http-api.MatrixError} Rejects: with an error response. - * @deprecated groups/communities never made it to the spec and support for them is being discontinued. - */ - public updateGroupRoomVisibility(groupId: string, roomId: string, isPublic: boolean): Promise { - // NB: The /config API is generic but there's not much point in exposing this yet as synapse - // is the only server to implement this. In future we should consider an API that allows - // arbitrary configuration, i.e. "config/$configKey". - - const path = utils.encodeUri( - "/groups/$groupId/admin/rooms/$roomId/config/m.visibility", - { $groupId: groupId, $roomId: roomId }, - ); - return this.http.authedRequest(undefined, Method.Put, path, undefined, - { type: isPublic ? "public" : "private" }, - ); - } - - /** - * @param {string} groupId - * @param {string} roomId - * @return {Promise} Resolves: Empty object - * @return {module:http-api.MatrixError} Rejects: with an error response. - * @deprecated groups/communities never made it to the spec and support for them is being discontinued. - */ - public removeRoomFromGroup(groupId: string, roomId: string): Promise { - const path = utils.encodeUri( - "/groups/$groupId/admin/rooms/$roomId", - { $groupId: groupId, $roomId: roomId }, - ); - return this.http.authedRequest(undefined, Method.Delete, path, undefined, {}); - } - - /** - * @param {string} groupId - * @param {Object} opts Additional options to send alongside the acceptance. - * @return {Promise} Resolves: Empty object - * @return {module:http-api.MatrixError} Rejects: with an error response. - * @deprecated groups/communities never made it to the spec and support for them is being discontinued. - */ - public acceptGroupInvite(groupId: string, opts = null): Promise { - const path = utils.encodeUri( - "/groups/$groupId/self/accept_invite", - { $groupId: groupId }, - ); - return this.http.authedRequest(undefined, Method.Put, path, undefined, opts || {}); - } - - /** - * @param {string} groupId - * @return {Promise} Resolves: Empty object - * @return {module:http-api.MatrixError} Rejects: with an error response. - * @deprecated groups/communities never made it to the spec and support for them is being discontinued. - */ - public joinGroup(groupId: string): Promise { - const path = utils.encodeUri( - "/groups/$groupId/self/join", - { $groupId: groupId }, - ); - return this.http.authedRequest(undefined, Method.Put, path, undefined, {}); - } - - /** - * @param {string} groupId - * @return {Promise} Resolves: Empty object - * @return {module:http-api.MatrixError} Rejects: with an error response. - * @deprecated groups/communities never made it to the spec and support for them is being discontinued. - */ - public leaveGroup(groupId: string): Promise { - const path = utils.encodeUri( - "/groups/$groupId/self/leave", - { $groupId: groupId }, - ); - return this.http.authedRequest(undefined, Method.Put, path, undefined, {}); - } - - /** - * @return {Promise} Resolves: The groups to which the user is joined - * @return {module:http-api.MatrixError} Rejects: with an error response. - * @deprecated groups/communities never made it to the spec and support for them is being discontinued. - */ - public getJoinedGroups(): Promise { - const path = utils.encodeUri("/joined_groups", {}); - return this.http.authedRequest(undefined, Method.Get, path); - } - - /** - * @param {Object} content Request content - * @param {string} content.localpart The local part of the desired group ID - * @param {Object} content.profile Group profile object - * @return {Promise} Resolves: Object with key group_id: id of the created group - * @return {module:http-api.MatrixError} Rejects: with an error response. - * @deprecated groups/communities never made it to the spec and support for them is being discontinued. - */ - public createGroup(content: any): Promise { - const path = utils.encodeUri("/create_group", {}); - return this.http.authedRequest( - undefined, Method.Post, path, undefined, content, - ); - } - - /** - * @param {string[]} userIds List of user IDs - * @return {Promise} Resolves: Object as exmaple below - * - * { - * "users": { - * "@bob:example.com": { - * "+example:example.com" - * } - * } - * } - * @return {module:http-api.MatrixError} Rejects: with an error response. - * @deprecated groups/communities never made it to the spec and support for them is being discontinued. - */ - public getPublicisedGroups(userIds: string[]): Promise { - const path = utils.encodeUri("/publicised_groups", {}); - return this.http.authedRequest( - undefined, Method.Post, path, undefined, { user_ids: userIds }, - ); - } - - /** - * @param {string} groupId - * @param {boolean} isPublic Whether the user's membership of this group is made public - * @return {Promise} Resolves: Empty object - * @return {module:http-api.MatrixError} Rejects: with an error response. - * @deprecated groups/communities never made it to the spec and support for them is being discontinued. - */ - public setGroupPublicity(groupId: string, isPublic: boolean): Promise { - const path = utils.encodeUri( - "/groups/$groupId/self/update_publicity", - { $groupId: groupId }, - ); - return this.http.authedRequest(undefined, Method.Put, path, undefined, { - publicise: isPublic, - }); - } - /** * @experimental */ @@ -9523,18 +9131,6 @@ export class MatrixClient extends TypedEventEmitterThis event - * is experimental and may change. - * @event module:client~MatrixClient#"Group" - * @param {Group} group The newly created, fully populated group. - * @deprecated groups/communities never made it to the spec and support for them is being discontinued. - * @example - * matrixClient.on("Group", function(group){ - * var groupId = group.groupId; - * }); - */ - /** * Fires whenever a new Room is added. This will fire when you are invited to a * room, as well as when you join a room. This event is experimental and diff --git a/src/matrix.ts b/src/matrix.ts index e687926f67f..5379b461acf 100644 --- a/src/matrix.ts +++ b/src/matrix.ts @@ -30,7 +30,6 @@ export * from "./errors"; export * from "./models/beacon"; export * from "./models/event"; export * from "./models/room"; -export * from "./models/group"; export * from "./models/event-timeline"; export * from "./models/event-timeline-set"; export * from "./models/room-member"; diff --git a/src/models/group.js b/src/models/group.js deleted file mode 100644 index 29f0fb3846c..00000000000 --- a/src/models/group.js +++ /dev/null @@ -1,100 +0,0 @@ -/* -Copyright 2017 New Vector Ltd -Copyright 2019 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. -*/ - -/** - * @module models/group - * @deprecated groups/communities never made it to the spec and support for them is being discontinued. - */ - -// eslint-disable-next-line no-restricted-imports -import { EventEmitter } from "events"; - -import * as utils from "../utils"; - -/** - * Construct a new Group. - * - * @param {string} groupId The ID of this group. - * - * @prop {string} groupId The ID of this group. - * @prop {string} name The human-readable display name for this group. - * @prop {string} avatarUrl The mxc URL for this group's avatar. - * @prop {string} myMembership The logged in user's membership of this group - * @prop {Object} inviter Infomation about the user who invited the logged in user - * to the group, if myMembership is 'invite'. - * @prop {string} inviter.userId The user ID of the inviter - * @deprecated groups/communities never made it to the spec and support for them is being discontinued. - */ -export function Group(groupId) { - this.groupId = groupId; - this.name = null; - this.avatarUrl = null; - this.myMembership = null; - this.inviter = null; -} -utils.inherits(Group, EventEmitter); - -Group.prototype.setProfile = function(name, avatarUrl) { - if (this.name === name && this.avatarUrl === avatarUrl) return; - - this.name = name || this.groupId; - this.avatarUrl = avatarUrl; - - this.emit("Group.profile", this); -}; - -Group.prototype.setMyMembership = function(membership) { - if (this.myMembership === membership) return; - - this.myMembership = membership; - - this.emit("Group.myMembership", this); -}; - -/** - * Sets the 'inviter' property. This does not emit an event (the inviter - * will only change when the user is revited / reinvited to a room), - * so set this before setting myMembership. - * @param {Object} inviter Infomation about who invited us to the room - */ -Group.prototype.setInviter = function(inviter) { - this.inviter = inviter; -}; - -/** - * Fires whenever a group's profile information is updated. - * This means the 'name' and 'avatarUrl' properties. - * @event module:client~MatrixClient#"Group.profile" - * @param {Group} group The group whose profile was updated. - * @deprecated groups/communities never made it to the spec and support for them is being discontinued. - * @example - * matrixClient.on("Group.profile", function(group){ - * var name = group.name; - * }); - */ - -/** - * Fires whenever the logged in user's membership status of - * the group is updated. - * @event module:client~MatrixClient#"Group.myMembership" - * @param {Group} group The group in which the user's membership changed - * @deprecated groups/communities never made it to the spec and support for them is being discontinued. - * @example - * matrixClient.on("Group.myMembership", function(group){ - * var myMembership = group.myMembership; - * }); - */ diff --git a/src/store/index.ts b/src/store/index.ts index f4bf214a7eb..a9966b4282d 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -15,19 +15,17 @@ limitations under the License. */ import { EventType } from "../@types/event"; -import { Group } from "../models/group"; import { Room } from "../models/room"; import { User } from "../models/user"; import { IEvent, MatrixEvent } from "../models/event"; import { Filter } from "../filter"; import { RoomSummary } from "../models/room-summary"; -import { IMinimalEvent, IGroups, IRooms, ISyncResponse } from "../sync-accumulator"; +import { IMinimalEvent, IRooms, ISyncResponse } from "../sync-accumulator"; import { IStartClientOpts } from "../client"; export interface ISavedSync { nextBatch: string; roomsData: IRooms; - groupsData: IGroups; accountData: IMinimalEvent[]; } @@ -53,28 +51,6 @@ export interface IStore { */ setSyncToken(token: string): void; - /** - * No-op. - * @param {Group} group - * @deprecated groups/communities never made it to the spec and support for them is being discontinued. - */ - storeGroup(group: Group): void; - - /** - * No-op. - * @param {string} groupId - * @return {null} - * @deprecated groups/communities never made it to the spec and support for them is being discontinued. - */ - getGroup(groupId: string): Group | null; - - /** - * No-op. - * @return {Array} An empty array. - * @deprecated groups/communities never made it to the spec and support for them is being discontinued. - */ - getGroups(): Group[]; - /** * No-op. * @param {Room} room diff --git a/src/store/indexeddb-local-backend.ts b/src/store/indexeddb-local-backend.ts index 1dd1a125bf9..72a287522fd 100644 --- a/src/store/indexeddb-local-backend.ts +++ b/src/store/indexeddb-local-backend.ts @@ -215,7 +215,6 @@ export class LocalIndexedDBStoreBackend implements IIndexedDBBackend { this.syncAccumulator.accumulate({ next_batch: syncData.nextBatch, rooms: syncData.roomsData, - groups: syncData.groupsData, account_data: { events: accountData, }, @@ -405,7 +404,7 @@ export class LocalIndexedDBStoreBackend implements IIndexedDBBackend { await Promise.all([ this.persistUserPresenceEvents(userTuples), this.persistAccountData(syncData.accountData), - this.persistSyncData(syncData.nextBatch, syncData.roomsData, syncData.groupsData), + this.persistSyncData(syncData.nextBatch, syncData.roomsData), ]); } @@ -413,13 +412,11 @@ export class LocalIndexedDBStoreBackend implements IIndexedDBBackend { * Persist rooms /sync data along with the next batch token. * @param {string} nextBatch The next_batch /sync value. * @param {Object} roomsData The 'rooms' /sync data from a SyncAccumulator - * @param {Object} groupsData The 'groups' /sync data from a SyncAccumulator * @return {Promise} Resolves if the data was persisted. */ private persistSyncData( nextBatch: string, roomsData: ISyncResponse["rooms"], - groupsData: ISyncResponse["groups"], ): Promise { logger.log("Persisting sync data up to", nextBatch); return utils.promiseTry(() => { @@ -429,7 +426,6 @@ export class LocalIndexedDBStoreBackend implements IIndexedDBBackend { clobber: "-", // constant key so will always clobber nextBatch, roomsData, - groupsData, }); // put == UPSERT return txnAsPromise(txn).then(); }); diff --git a/src/store/memory.ts b/src/store/memory.ts index b29d3d3647a..8fbbeec0b7a 100644 --- a/src/store/memory.ts +++ b/src/store/memory.ts @@ -20,7 +20,6 @@ limitations under the License. */ import { EventType } from "../@types/event"; -import { Group } from "../models/group"; import { Room } from "../models/room"; import { User } from "../models/user"; import { IEvent, MatrixEvent } from "../models/event"; @@ -53,7 +52,6 @@ export interface IOpts { */ export class MemoryStore implements IStore { private rooms: Record = {}; // roomId: Room - private groups: Record = {}; // groupId: Group private users: Record = {}; // userId: User private syncToken: string = null; // userId: { @@ -90,34 +88,6 @@ export class MemoryStore implements IStore { this.syncToken = token; } - /** - * Store the given room. - * @param {Group} group The group to be stored - * @deprecated groups/communities never made it to the spec and support for them is being discontinued. - */ - public storeGroup(group: Group) { - this.groups[group.groupId] = group; - } - - /** - * Retrieve a group by its group ID. - * @param {string} groupId The group ID. - * @return {Group} The group or null. - * @deprecated groups/communities never made it to the spec and support for them is being discontinued. - */ - public getGroup(groupId: string): Group | null { - return this.groups[groupId] || null; - } - - /** - * Retrieve all known groups. - * @return {Group[]} A list of groups, which may be empty. - * @deprecated groups/communities never made it to the spec and support for them is being discontinued. - */ - public getGroups(): Group[] { - return Object.values(this.groups); - } - /** * Store the given room. * @param {Room} room The room to be stored. All properties must be stored. diff --git a/src/store/stub.ts b/src/store/stub.ts index 95b231db142..c3a5b6347de 100644 --- a/src/store/stub.ts +++ b/src/store/stub.ts @@ -20,7 +20,6 @@ limitations under the License. */ import { EventType } from "../@types/event"; -import { Group } from "../models/group"; import { Room } from "../models/room"; import { User } from "../models/user"; import { IEvent, MatrixEvent } from "../models/event"; @@ -58,32 +57,6 @@ export class StubStore implements IStore { this.fromToken = token; } - /** - * No-op. - * @param {Group} group - * @deprecated groups/communities never made it to the spec and support for them is being discontinued. - */ - public storeGroup(group: Group) {} - - /** - * No-op. - * @param {string} groupId - * @return {null} - * @deprecated groups/communities never made it to the spec and support for them is being discontinued. - */ - public getGroup(groupId: string): Group | null { - return null; - } - - /** - * No-op. - * @return {Array} An empty array. - * @deprecated groups/communities never made it to the spec and support for them is being discontinued. - */ - public getGroups(): Group[] { - return []; - } - /** * No-op. * @param {Room} room diff --git a/src/sync-accumulator.ts b/src/sync-accumulator.ts index 65b5cf00ad5..f50f80b566a 100644 --- a/src/sync-accumulator.ts +++ b/src/sync-accumulator.ts @@ -128,12 +128,6 @@ interface IDeviceLists { left: string[]; } -export interface IGroups { - [Category.Join]: object; - [Category.Invite]: object; - [Category.Leave]: object; -} - export interface ISyncResponse { next_batch: string; rooms: IRooms; @@ -142,8 +136,6 @@ export interface ISyncResponse { to_device?: IToDevice; device_lists?: IDeviceLists; device_one_time_keys_count?: Record; - - groups: IGroups; // unspecced } /* eslint-enable camelcase */ @@ -174,7 +166,6 @@ export interface ISyncData { nextBatch: string; accountData: IMinimalEvent[]; roomsData: IRooms; - groupsData: IGroups; } /** @@ -197,13 +188,6 @@ export class SyncAccumulator { // streaming from without losing events. private nextBatch: string = null; - // { ('invite'|'join'|'leave'): $groupId: { ... sync 'group' data } } - private groups: Record = { - invite: {}, - join: {}, - leave: {}, - }; - /** * @param {Object} opts * @param {Number=} opts.maxTimelineEntries The ideal maximum number of @@ -219,7 +203,6 @@ export class SyncAccumulator { public accumulate(syncResponse: ISyncResponse, fromDatabase = false): void { this.accumulateRooms(syncResponse, fromDatabase); - this.accumulateGroups(syncResponse); this.accumulateAccountData(syncResponse); this.nextBatch = syncResponse.next_batch; } @@ -505,38 +488,6 @@ export class SyncAccumulator { } } - /** - * Accumulate incremental /sync group data. - * @param {Object} syncResponse the complete /sync JSON - */ - private accumulateGroups(syncResponse: ISyncResponse): void { - if (!syncResponse.groups) { - return; - } - if (syncResponse.groups.invite) { - Object.keys(syncResponse.groups.invite).forEach((groupId) => { - this.accumulateGroup(groupId, Category.Invite, syncResponse.groups.invite[groupId]); - }); - } - if (syncResponse.groups.join) { - Object.keys(syncResponse.groups.join).forEach((groupId) => { - this.accumulateGroup(groupId, Category.Join, syncResponse.groups.join[groupId]); - }); - } - if (syncResponse.groups.leave) { - Object.keys(syncResponse.groups.leave).forEach((groupId) => { - this.accumulateGroup(groupId, Category.Leave, syncResponse.groups.leave[groupId]); - }); - } - } - - private accumulateGroup(groupId: string, category: Category, data: object): void { - for (const cat of [Category.Invite, Category.Leave, Category.Join]) { - delete this.groups[cat][groupId]; - } - this.groups[category][groupId] = data; - } - /** * Return everything under the 'rooms' key from a /sync response which * represents all room data that should be stored. This should be paired @@ -694,7 +645,6 @@ export class SyncAccumulator { return { nextBatch: this.nextBatch, roomsData: data, - groupsData: this.groups, accountData: accData, }; } diff --git a/src/sync.ts b/src/sync.ts index 5d629b0172d..6299977397d 100644 --- a/src/sync.ts +++ b/src/sync.ts @@ -25,7 +25,6 @@ limitations under the License. import { User, UserEvent } from "./models/user"; import { NotificationCountType, Room, RoomEvent } from "./models/room"; -import { Group } from "./models/group"; import * as utils from "./utils"; import { IDeferred } from "./utils"; import { Filter } from "./filter"; @@ -35,7 +34,6 @@ import { logger } from './logger'; import { InvalidStoreError } from './errors'; import { ClientEvent, IStoredClientOpts, MatrixClient, PendingEventOrdering } from "./client"; import { - Category, IEphemeral, IInvitedRoom, IInviteState, @@ -213,21 +211,6 @@ export class SyncApi { return room; } - /** - * @param {string} groupId - * @return {Group} - */ - public createGroup(groupId: string): Group { - const client = this.client; - const group = new Group(groupId); - client.reEmitter.reEmit(group, [ - ClientEvent.GroupProfile, - ClientEvent.GroupMyMembership, - ]); - client.store.storeGroup(group); - return group; - } - /** * @param {Room} room * @private @@ -768,7 +751,6 @@ export class SyncApi { const data: ISyncResponse = { next_batch: nextSyncToken, rooms: savedSync.roomsData, - groups: savedSync.groupsData, account_data: { events: savedSync.accountData, }, @@ -1055,20 +1037,7 @@ export class SyncApi { // timeline: { events: [], prev_batch: $token } // } // } - // }, - // groups: { - // invite: { - // $groupId: { - // inviter: $inviter, - // profile: { - // avatar_url: $avatarUrl, - // name: $groupName, - // }, - // }, - // }, - // join: {}, - // leave: {}, - // }, + // } // } // TODO-arch: @@ -1172,20 +1141,6 @@ export class SyncApi { this.catchingUp = false; } - if (data.groups) { - if (data.groups.invite) { - this.processGroupSyncEntry(data.groups.invite, Category.Invite); - } - - if (data.groups.join) { - this.processGroupSyncEntry(data.groups.join, Category.Join); - } - - if (data.groups.leave) { - this.processGroupSyncEntry(data.groups.leave, Category.Leave); - } - } - // the returned json structure is a bit crap, so make it into a // nicer form (array) after applying sanity to make sure we don't fail // on missing keys (on the off chance) @@ -1542,35 +1497,6 @@ export class SyncApi { }); } - /** - * @param {Object} groupsSection Groups section object, eg. response.groups.invite - * @param {string} sectionName Which section this is ('invite', 'join' or 'leave') - */ - private processGroupSyncEntry(groupsSection: object, sectionName: Category) { - // Processes entries from 'groups' section of the sync stream - for (const groupId of Object.keys(groupsSection)) { - const groupInfo = groupsSection[groupId]; - let group = this.client.store.getGroup(groupId); - const isBrandNew = group === null; - if (group === null) { - group = this.createGroup(groupId); - } - if (groupInfo.profile) { - group.setProfile( - groupInfo.profile.name, groupInfo.profile.avatar_url, - ); - } - if (groupInfo.inviter) { - group.setInviter({ userId: groupInfo.inviter }); - } - group.setMyMembership(sectionName); - if (isBrandNew) { - // Now we've filled in all the fields, emit the Group event - this.client.emit(ClientEvent.Group, group); - } - } - } - /** * @param {Object} obj * @return {Object[]} From 6192325fe0b6d84174187f00b1a07dc1457b103d Mon Sep 17 00:00:00 2001 From: Germain Date: Wed, 23 Mar 2022 14:43:30 +0000 Subject: [PATCH 03/28] Thread list ordering by last reply (#2253) --- src/@types/event.ts | 9 +-- src/models/event.ts | 10 ++- src/models/room.ts | 168 ++++++++++++++++++++++++++++++++++---------- 3 files changed, 141 insertions(+), 46 deletions(-) diff --git a/src/@types/event.ts b/src/@types/event.ts index 05f85cc4742..25946a5ac93 100644 --- a/src/@types/event.ts +++ b/src/@types/event.ts @@ -93,14 +93,7 @@ export enum RelationType { Annotation = "m.annotation", Replace = "m.replace", Reference = "m.reference", - /** - * Note, "io.element.thread" is hardcoded - * Should be replaced with "m.thread" once MSC3440 lands - * Can not use `UnstableValue` as TypeScript does not - * allow computed values in enums - * https://github.com/microsoft/TypeScript/issues/27976 - */ - Thread = "io.element.thread", + Thread = "m.thread", } export enum MsgType { diff --git a/src/models/event.ts b/src/models/event.ts index a4d0340a039..5d1dc784e73 100644 --- a/src/models/event.ts +++ b/src/models/event.ts @@ -86,9 +86,17 @@ export interface IEvent { unsigned: IUnsigned; redacts?: string; - // v1 legacy fields + /** + * @deprecated + */ user_id?: string; + /** + * @deprecated + */ prev_content?: IContent; + /** + * @deprecated + */ age?: number; } diff --git a/src/models/room.ts b/src/models/room.ts index 283cf55bce9..2490ad7c950 100644 --- a/src/models/room.ts +++ b/src/models/room.ts @@ -23,7 +23,7 @@ import { Direction, EventTimeline } from "./event-timeline"; import { getHttpUriForMxc } from "../content-repo"; import * as utils from "../utils"; import { normalize } from "../utils"; -import { IEvent, MatrixEvent } from "./event"; +import { IEvent, IThreadBundledRelationship, MatrixEvent } from "./event"; import { EventStatus } from "./event-status"; import { RoomMember } from "./room-member"; import { IRoomSummary, RoomSummary } from "./room-summary"; @@ -32,6 +32,7 @@ import { TypedReEmitter } from '../ReEmitter'; import { EventType, RoomCreateTypeField, RoomType, UNSTABLE_ELEMENT_FUNCTIONAL_USERS, EVENT_VISIBILITY_CHANGE_TYPE, + RelationType, } from "../@types/event"; import { IRoomVersionsCapability, MatrixClient, PendingEventOrdering, RoomVersionStability } from "../client"; import { GuestAccess, HistoryVisibility, JoinRule, ResizeMethod } from "../@types/partials"; @@ -148,6 +149,7 @@ export interface ICreateFilterOpts { // timeline. Useful to disable for some filters that can't be achieved by the // client in an efficient manner prepopulateTimeline?: boolean; + useSyncEvents?: boolean; pendingEvents?: boolean; } @@ -167,6 +169,7 @@ export enum RoomEvent { type EmittedEvents = RoomEvent | ThreadEvent.New | ThreadEvent.Update + | ThreadEvent.NewReply | RoomEvent.Timeline | RoomEvent.TimelineReset; @@ -1346,6 +1349,7 @@ export class Room extends TypedEventEmitter filter: Filter, { prepopulateTimeline = true, + useSyncEvents = true, pendingEvents = true, }: ICreateFilterOpts = {}, ): EventTimelineSet { @@ -1358,8 +1362,10 @@ export class Room extends TypedEventEmitter RoomEvent.Timeline, RoomEvent.TimelineReset, ]); - this.filteredTimelineSets[filter.filterId] = timelineSet; - this.timelineSets.push(timelineSet); + if (useSyncEvents) { + this.filteredTimelineSets[filter.filterId] = timelineSet; + this.timelineSets.push(timelineSet); + } const unfilteredLiveTimeline = this.getLiveTimeline(); // Not all filter are possible to replicate client-side only @@ -1387,7 +1393,7 @@ export class Room extends TypedEventEmitter timeline.getPaginationToken(EventTimeline.BACKWARDS), EventTimeline.BACKWARDS, ); - } else { + } else if (useSyncEvents) { const livePaginationToken = unfilteredLiveTimeline.getPaginationToken(Direction.Forward); timelineSet .getLiveTimeline() @@ -1405,41 +1411,54 @@ export class Room extends TypedEventEmitter return timelineSet; } - private async createThreadTimelineSet(filterType?: ThreadFilterType): Promise { - let timelineSet: EventTimelineSet; - if (Thread.hasServerSideSupport) { - const myUserId = this.client.getUserId(); - const filter = new Filter(myUserId); + private async getThreadListFilter(filterType = ThreadFilterType.All): Promise { + const myUserId = this.client.getUserId(); + const filter = new Filter(myUserId); - const definition: IFilterDefinition = { - "room": { - "timeline": { - [FILTER_RELATED_BY_REL_TYPES.name]: [THREAD_RELATION_TYPE.name], - }, + const definition: IFilterDefinition = { + "room": { + "timeline": { + [FILTER_RELATED_BY_REL_TYPES.name]: [THREAD_RELATION_TYPE.name], }, - }; + }, + }; - if (filterType === ThreadFilterType.My) { - definition.room.timeline[FILTER_RELATED_BY_SENDERS.name] = [myUserId]; - } + if (filterType === ThreadFilterType.My) { + definition.room.timeline[FILTER_RELATED_BY_SENDERS.name] = [myUserId]; + } + + filter.setDefinition(definition); + const filterId = await this.client.getOrCreateFilter( + `THREAD_PANEL_${this.roomId}_${filterType}`, + filter, + ); + + filter.filterId = filterId; + + return filter; + } + + private async createThreadTimelineSet(filterType?: ThreadFilterType): Promise { + let timelineSet: EventTimelineSet; + if (Thread.hasServerSideSupport) { + const filter = await this.getThreadListFilter(filterType); - filter.setDefinition(definition); - const filterId = await this.client.getOrCreateFilter( - `THREAD_PANEL_${this.roomId}_${filterType}`, - filter, - ); - filter.filterId = filterId; timelineSet = this.getOrCreateFilteredTimelineSet( filter, { prepopulateTimeline: false, + useSyncEvents: false, pendingEvents: false, }, ); // An empty pagination token allows to paginate from the very bottom of // the timeline set. - timelineSet.getLiveTimeline().setPaginationToken("", EventTimeline.BACKWARDS); + // Right now we completely by-pass the pagination to be able to order + // the events by last reply to a thread + // Once the server can help us with that, we should uncomment the line + // below + // timelineSet.getLiveTimeline().setPaginationToken("", EventTimeline.BACKWARDS); } else { timelineSet = new EventTimelineSet(this, { pendingEvents: false, @@ -1460,6 +1479,78 @@ export class Room extends TypedEventEmitter return timelineSet; } + public threadsReady = false; + + public async fetchRoomThreads(): Promise { + if (this.threadsReady) { + return; + } + + const allThreadsFilter = await this.getThreadListFilter(); + + const { chunk: events } = await this.client.createMessagesRequest( + this.roomId, + "", + Number.MAX_SAFE_INTEGER, + Direction.Backward, + allThreadsFilter, + ); + + const orderedByLastReplyEvents = events + .map(this.client.getEventMapper()) + .sort((eventA, eventB) => { + /** + * `origin_server_ts` in a decentralised world is far from ideal + * but for lack of any better, we will have to use this + * Long term the sorting should be handled by homeservers and this + * is only meant as a short term patch + */ + const threadAMetadata = eventA + .getServerAggregatedRelation(RelationType.Thread); + const threadBMetadata = eventB + .getServerAggregatedRelation(RelationType.Thread); + return threadAMetadata.latest_event.origin_server_ts - threadBMetadata.latest_event.origin_server_ts; + }); + + const myThreads = orderedByLastReplyEvents.filter(event => { + const threadRelationship = event + .getServerAggregatedRelation(RelationType.Thread); + return threadRelationship.current_user_participated; + }); + + const roomState = this.getLiveTimeline().getState(EventTimeline.FORWARDS); + for (const event of orderedByLastReplyEvents) { + this.threadsTimelineSets[0].addLiveEvent( + event, + DuplicateStrategy.Ignore, + false, + roomState, + ); + } + for (const event of myThreads) { + this.threadsTimelineSets[1].addLiveEvent( + event, + DuplicateStrategy.Ignore, + false, + roomState, + ); + } + + this.client.decryptEventIfNeeded(orderedByLastReplyEvents[orderedByLastReplyEvents.length -1]); + this.client.decryptEventIfNeeded(myThreads[myThreads.length -1]); + + this.threadsReady = true; + + this.on(ThreadEvent.NewReply, this.onThreadNewReply); + } + + private onThreadNewReply(thread: Thread): void { + for (const timelineSet of this.threadsTimelineSets) { + timelineSet.removeEvent(thread.id); + timelineSet.addLiveEvent(thread.rootEvent); + } + } + /** * Forget the timelineSet for this room with the given filter * @@ -1550,6 +1641,7 @@ export class Room extends TypedEventEmitter this.threads.set(thread.id, thread); this.reEmitter.reEmit(thread, [ ThreadEvent.Update, + ThreadEvent.NewReply, RoomEvent.Timeline, RoomEvent.TimelineReset, ]); @@ -1560,19 +1652,21 @@ export class Room extends TypedEventEmitter this.emit(ThreadEvent.New, thread, toStartOfTimeline); - this.threadsTimelineSets.forEach(timelineSet => { - if (thread.rootEvent) { - if (Thread.hasServerSideSupport) { - timelineSet.addLiveEvent(thread.rootEvent); - } else { - timelineSet.addEventToTimeline( - thread.rootEvent, - timelineSet.getLiveTimeline(), - toStartOfTimeline, - ); + if (this.threadsReady) { + this.threadsTimelineSets.forEach(timelineSet => { + if (thread.rootEvent) { + if (Thread.hasServerSideSupport) { + timelineSet.addLiveEvent(thread.rootEvent); + } else { + timelineSet.addEventToTimeline( + thread.rootEvent, + timelineSet.getLiveTimeline(), + toStartOfTimeline, + ); + } } - } - }); + }); + } return thread; } From c541b3f1ce0faf242f9625af953092e998406ca0 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 24 Mar 2022 12:24:19 +0000 Subject: [PATCH 04/28] Fix issues with duplicated MatrixEvent objects around threads (#2256) --- spec/test-utils/test-utils.js | 2 +- spec/unit/event-mapper.spec.ts | 180 ++++++++++++++++++++++++++++++++ spec/unit/event.spec.js | 2 +- spec/unit/matrix-client.spec.ts | 16 +++ spec/unit/room.spec.ts | 49 --------- src/client.ts | 35 ++++--- src/event-mapper.ts | 18 +++- src/models/event.ts | 2 +- src/models/room.ts | 70 ++++++------- src/models/thread.ts | 44 ++++++-- 10 files changed, 301 insertions(+), 117 deletions(-) create mode 100644 spec/unit/event-mapper.spec.ts diff --git a/spec/test-utils/test-utils.js b/spec/test-utils/test-utils.js index df137ba6f53..b2c180205be 100644 --- a/spec/test-utils/test-utils.js +++ b/spec/test-utils/test-utils.js @@ -85,7 +85,7 @@ export function mkEvent(opts) { room_id: opts.room, sender: opts.sender || opts.user, // opts.user for backwards-compat content: opts.content, - unsigned: opts.unsigned, + unsigned: opts.unsigned || {}, event_id: "$" + Math.random() + "-" + Math.random(), }; if (opts.skey !== undefined) { diff --git a/spec/unit/event-mapper.spec.ts b/spec/unit/event-mapper.spec.ts new file mode 100644 index 00000000000..a46c955b7bd --- /dev/null +++ b/spec/unit/event-mapper.spec.ts @@ -0,0 +1,180 @@ +/* +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 { MatrixClient, MatrixEvent, MatrixEventEvent, MatrixScheduler, Room } from "../../src"; +import { eventMapperFor } from "../../src/event-mapper"; +import { IStore } from "../../src/store"; + +describe("eventMapperFor", function() { + let rooms: Room[] = []; + + const userId = "@test:example.org"; + + let client: MatrixClient; + + beforeEach(() => { + client = new MatrixClient({ + baseUrl: "https://my.home.server", + accessToken: "my.access.token", + request: function() {} as any, // NOP + store: { + getRoom(roomId: string): Room | null { + return rooms.find(r => r.roomId === roomId); + }, + } as IStore, + scheduler: { + setProcessFunction: jest.fn(), + } as unknown as MatrixScheduler, + userId: userId, + }); + + rooms = []; + }); + + it("should de-duplicate MatrixEvent instances by means of findEventById on the room object", async () => { + const roomId = "!room:example.org"; + const room = new Room(roomId, client, userId); + rooms.push(room); + + const mapper = eventMapperFor(client, { + preventReEmit: true, + decrypt: false, + }); + + const eventId = "$event1:server"; + const eventDefinition = { + type: "m.room.message", + room_id: roomId, + sender: userId, + content: { + body: "body", + }, + unsigned: {}, + event_id: eventId, + }; + + const event = mapper(eventDefinition); + expect(event).toBeInstanceOf(MatrixEvent); + + room.addLiveEvents([event]); + expect(room.findEventById(eventId)).toBe(event); + + const event2 = mapper(eventDefinition); + expect(event).toBe(event2); + }); + + it("should not de-duplicate state events due to directionality of sentinel members", async () => { + const roomId = "!room:example.org"; + const room = new Room(roomId, client, userId); + rooms.push(room); + + const mapper = eventMapperFor(client, { + preventReEmit: true, + decrypt: false, + }); + + const eventId = "$event1:server"; + const eventDefinition = { + type: "m.room.name", + room_id: roomId, + sender: userId, + content: { + name: "Room name", + }, + unsigned: {}, + event_id: eventId, + state_key: "", + }; + + const event = mapper(eventDefinition); + expect(event).toBeInstanceOf(MatrixEvent); + + room.oldState.setStateEvents([event]); + room.currentState.setStateEvents([event]); + room.addLiveEvents([event]); + expect(room.findEventById(eventId)).toBe(event); + + const event2 = mapper(eventDefinition); + expect(event).not.toBe(event2); + }); + + it("should decrypt appropriately", async () => { + const roomId = "!room:example.org"; + const room = new Room(roomId, client, userId); + rooms.push(room); + + const eventId = "$event1:server"; + const eventDefinition = { + type: "m.room.encrypted", + room_id: roomId, + sender: userId, + content: { + ciphertext: "", + }, + unsigned: {}, + event_id: eventId, + }; + + const decryptEventIfNeededSpy = jest.spyOn(client, "decryptEventIfNeeded"); + decryptEventIfNeededSpy.mockResolvedValue(); // stub it out + + const mapper = eventMapperFor(client, { + decrypt: true, + }); + const event = mapper(eventDefinition); + expect(event).toBeInstanceOf(MatrixEvent); + expect(decryptEventIfNeededSpy).toHaveBeenCalledWith(event); + }); + + it("should configure re-emitter appropriately", async () => { + const roomId = "!room:example.org"; + const room = new Room(roomId, client, userId); + rooms.push(room); + + const eventId = "$event1:server"; + const eventDefinition = { + type: "m.room.message", + room_id: roomId, + sender: userId, + content: { + body: "body", + }, + unsigned: {}, + event_id: eventId, + }; + + const evListener = jest.fn(); + client.on(MatrixEventEvent.Replaced, evListener); + + const noReEmitMapper = eventMapperFor(client, { + preventReEmit: true, + }); + const event1 = noReEmitMapper(eventDefinition); + expect(event1).toBeInstanceOf(MatrixEvent); + event1.emit(MatrixEventEvent.Replaced, event1); + expect(evListener).not.toHaveBeenCalled(); + + const reEmitMapper = eventMapperFor(client, { + preventReEmit: false, + }); + const event2 = reEmitMapper(eventDefinition); + expect(event2).toBeInstanceOf(MatrixEvent); + event2.emit(MatrixEventEvent.Replaced, event2); + expect(evListener.mock.calls[0][0]).toEqual(event2); + + expect(event1).not.toBe(event2); // the event wasn't added to a room so de-duplication wouldn't occur + }); +}); diff --git a/spec/unit/event.spec.js b/spec/unit/event.spec.js index a28b9224fcd..897d469b61f 100644 --- a/spec/unit/event.spec.js +++ b/spec/unit/event.spec.js @@ -1,6 +1,6 @@ /* Copyright 2017 New Vector Ltd -Copyright 2019 The Matrix.org Foundaction C.I.C. +Copyright 2019 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. diff --git a/spec/unit/matrix-client.spec.ts b/spec/unit/matrix-client.spec.ts index 67b922991ea..251ade94c48 100644 --- a/spec/unit/matrix-client.spec.ts +++ b/spec/unit/matrix-client.spec.ts @@ -1,3 +1,19 @@ +/* +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 { logger } from "../../src/logger"; import { MatrixClient } from "../../src/client"; import { Filter } from "../../src/filter"; diff --git a/spec/unit/room.spec.ts b/spec/unit/room.spec.ts index dbb5f33d50d..6977c862f89 100644 --- a/spec/unit/room.spec.ts +++ b/spec/unit/room.spec.ts @@ -26,7 +26,6 @@ import { 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"; -import { Thread } from "../../src/models/thread"; describe("Room", function() { const roomId = "!foo:bar"; @@ -1867,54 +1866,6 @@ describe("Room", function() { expect(() => room.createThread(rootEvent, [])).not.toThrow(); }); - - it("should not add events before server supports is known", function() { - Thread.hasServerSideSupport = undefined; - - const rootEvent = new MatrixEvent({ - event_id: "$666", - room_id: roomId, - content: {}, - unsigned: { - "age": 1, - "m.relations": { - "m.thread": { - latest_event: null, - count: 1, - current_user_participated: false, - }, - }, - }, - }); - - let age = 1; - function mkEvt(id): MatrixEvent { - return new MatrixEvent({ - event_id: id, - room_id: roomId, - content: { - "m.relates_to": { - "rel_type": "m.thread", - "event_id": "$666", - }, - }, - unsigned: { - "age": age++, - }, - }); - } - - const thread = room.createThread(rootEvent, []); - expect(thread.length).toBe(0); - - thread.addEvent(mkEvt("$1")); - expect(thread.length).toBe(0); - - Thread.hasServerSideSupport = true; - - thread.addEvent(mkEvt("$2")); - expect(thread.length).toBeGreaterThan(0); - }); }); }); }); diff --git a/src/client.ts b/src/client.ts index 640fb724dac..09d610b9449 100644 --- a/src/client.ts +++ b/src/client.ts @@ -3951,7 +3951,6 @@ export class MatrixClient extends TypedEventEmitter this.decryptEventIfNeeded(e))); @@ -6633,6 +6631,7 @@ export class MatrixClient extends TypedEventEmitter e.getType() === eventType); } } + if (originalEvent && relationType === RelationType.Replace) { events = events.filter(e => e.getSender() === originalEvent.getSender()); } @@ -8866,12 +8865,11 @@ export class MatrixClient extends TypedEventEmitter ( + const parentEvent = room?.findEventById(parentEventId) ?? events.find((mxEv: MatrixEvent) => ( mxEv.getId() === parentEventId )); - // A reaction targetting the thread root needs to be routed to both the - // the main timeline and the associated thread + // A reaction targeting the thread root needs to be routed to both the main timeline and the associated thread const targetingThreadRoot = parentEvent?.isThreadRoot || roots.has(event.relationEventId); if (targetingThreadRoot) { return { @@ -8887,18 +8885,21 @@ export class MatrixClient extends TypedEventEmitter) { - const event = new MatrixEvent(plainOldJsObject); + const room = client.getRoom(plainOldJsObject.room_id); + + let event: MatrixEvent; + // If the event is already known to the room, let's re-use the model rather than duplicating. + // We avoid doing this to state events as they may be forward or backwards looking which tweaks behaviour. + if (room && plainOldJsObject.state_key === undefined) { + event = room.findEventById(plainOldJsObject.event_id); + } + + if (!event || event.status) { + event = new MatrixEvent(plainOldJsObject); + } else { + // merge the latest unsigned data from the server + event.setUnsigned({ ...event.getUnsigned(), ...plainOldJsObject.unsigned }); + } - const room = client.getRoom(event.getRoomId()); if (room?.threads.has(event.getId())) { event.setThread(room.threads.get(event.getId())); } @@ -46,6 +59,7 @@ export function eventMapperFor(client: MatrixClient, options: MapperOpts): Event client.decryptEventIfNeeded(event); } } + if (!preventReEmit) { client.reEmitter.reEmit(event, [ MatrixEventEvent.Replaced, diff --git a/src/models/event.ts b/src/models/event.ts index 5d1dc784e73..d630c17ac2c 100644 --- a/src/models/event.ts +++ b/src/models/event.ts @@ -287,7 +287,7 @@ export class MatrixEvent extends TypedEventEmitter } /** - * Get an event which is stored in our unfiltered timeline set or in a thread + * Get an event which is stored in our unfiltered timeline set, or in a thread * - * @param {string} eventId event ID to look for + * @param {string} eventId event ID to look for * @return {?module:models/event.MatrixEvent} the given event, or undefined if unknown */ public findEventById(eventId: string): MatrixEvent | undefined { let event = this.getUnfilteredTimelineSet().findEventById(eventId); - if (event) { - return event; - } else { + if (!event) { const threads = this.getThreads(); for (let i = 0; i < threads.length; i++) { const thread = threads[i]; @@ -1025,6 +1024,8 @@ export class Room extends TypedEventEmitter } } } + + return event; } /** @@ -1204,10 +1205,7 @@ export class Room extends TypedEventEmitter timeline: EventTimeline, paginationToken?: string, ): void { - timeline.getTimelineSet().addEventsToTimeline( - events, toStartOfTimeline, - timeline, paginationToken, - ); + timeline.getTimelineSet().addEventsToTimeline(events, toStartOfTimeline, timeline, paginationToken); } /** @@ -1592,10 +1590,9 @@ export class Room extends TypedEventEmitter } else { const events = [event]; let rootEvent = this.findEventById(event.threadRootId); - // If the rootEvent does not exist in the current sync, then look for - // it over the network + // If the rootEvent does not exist in the current sync, then look for it over the network. try { - let eventData; + let eventData: IMinimalEvent; if (event.threadRootId) { eventData = await this.client.fetchRoomEvent(this.roomId, event.threadRootId); } @@ -1606,11 +1603,13 @@ export class Room extends TypedEventEmitter rootEvent.setUnsigned(eventData.unsigned); } } finally { - // The root event might be not be visible to the person requesting - // it. If it wasn't fetched successfully the thread will work - // in "limited" mode and won't benefit from all the APIs a homeserver - // can provide to enhance the thread experience + // The root event might be not be visible to the person requesting it. + // If it wasn't fetched successfully the thread will work in "limited" mode and won't + // benefit from all the APIs a homeserver can provide to enhance the thread experience thread = this.createThread(rootEvent, events, toStartOfTimeline); + if (thread) { + rootEvent.setThread(thread); + } } } @@ -1672,7 +1671,7 @@ export class Room extends TypedEventEmitter } } - applyRedaction(event: MatrixEvent): void { + private applyRedaction(event: MatrixEvent): void { if (event.isRedaction()) { const redactId = event.event.redacts; @@ -1888,6 +1887,14 @@ export class Room extends TypedEventEmitter } } + private shouldAddEventToMainTimeline(thread: Thread, event: MatrixEvent): boolean { + if (!thread) { + return true; + } + + return !event.isThreadRelation && thread.id === event.getAssociatedId(); + } + /** * Used to aggregate the local echo for a relation, and also * for re-applying a relation after it's redaction has been cancelled, @@ -1900,11 +1907,9 @@ export class Room extends TypedEventEmitter */ private aggregateNonLiveRelation(event: MatrixEvent): void { const thread = this.findThreadForEvent(event); - if (thread) { - thread.timelineSet.aggregateRelations(event); - } + thread?.timelineSet.aggregateRelations(event); - if (thread?.id === event.getAssociatedId() || !thread) { + if (this.shouldAddEventToMainTimeline(thread, event)) { // TODO: We should consider whether this means it would be a better // design to lift the relations handling up to the room instead. for (let i = 0; i < this.timelineSets.length; i++) { @@ -1961,11 +1966,9 @@ export class Room extends TypedEventEmitter localEvent.handleRemoteEcho(remoteEvent.event); const thread = this.findThreadForEvent(remoteEvent); - if (thread) { - thread.timelineSet.handleRemoteEcho(localEvent, oldEventId, newEventId); - } + thread?.timelineSet.handleRemoteEcho(localEvent, oldEventId, newEventId); - if (thread?.id === remoteEvent.getAssociatedId() || !thread) { + if (this.shouldAddEventToMainTimeline(thread, remoteEvent)) { for (let i = 0; i < this.timelineSets.length; i++) { const timelineSet = this.timelineSets[i]; @@ -2032,10 +2035,9 @@ export class Room extends TypedEventEmitter event.replaceLocalEventId(newEventId); const thread = this.findThreadForEvent(event); - if (thread) { - thread.timelineSet.replaceEventId(oldEventId, newEventId); - } - if (thread?.id === event.getAssociatedId() || !thread) { + thread?.timelineSet.replaceEventId(oldEventId, newEventId); + + if (this.shouldAddEventToMainTimeline(thread, event)) { // if the event was already in the timeline (which will be the case if // opts.pendingEventOrdering==chronological), we need to update the // timeline map. @@ -2046,12 +2048,10 @@ export class Room extends TypedEventEmitter } else if (newStatus == EventStatus.CANCELLED) { // remove it from the pending event list, or the timeline. if (this.pendingEventList) { - const idx = this.pendingEventList.findIndex(ev => ev.getId() === oldEventId); - if (idx !== -1) { - const [removedEvent] = this.pendingEventList.splice(idx, 1); - if (removedEvent.isRedaction()) { - this.revertRedactionLocalEcho(removedEvent); - } + const removedEvent = this.getPendingEvent(oldEventId); + this.removePendingEvent(oldEventId); + if (removedEvent.isRedaction()) { + this.revertRedactionLocalEcho(removedEvent); } } this.removeEvent(oldEventId); diff --git a/src/models/thread.ts b/src/models/thread.ts index 3f9266e69a6..6ace74042a2 100644 --- a/src/models/thread.ts +++ b/src/models/thread.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { MatrixClient, RoomEvent } from "../matrix"; +import { MatrixClient, RelationType, RoomEvent } from "../matrix"; import { TypedReEmitter } from "../ReEmitter"; import { IRelationsRequestOpts } from "../@types/requests"; import { IThreadBundledRelationship, MatrixEvent } from "./event"; @@ -24,6 +24,7 @@ import { Room } from './room'; import { TypedEventEmitter } from "./typed-event-emitter"; import { RoomState } from "./room-state"; import { ServerControlledNamespacedValue } from "../NamespacedValue"; +import { logger } from "../logger"; export enum ThreadEvent { New = "Thread.new", @@ -93,14 +94,9 @@ export class Thread extends TypedEventEmitter { RoomEvent.TimelineReset, ]); - // If we weren't able to find the root event, it's probably missing + // If we weren't able to find the root event, it's probably missing, // and we define the thread ID from one of the thread relation - if (!rootEvent) { - this.id = opts?.initialEvents - ?.find(event => event.isThreadRelation)?.relationEventId; - } else { - this.id = rootEvent.getId(); - } + this.id = rootEvent?.getId() ?? opts?.initialEvents?.find(event => event.isThreadRelation)?.relationEventId; this.initialiseThread(this.rootEvent); opts?.initialEvents?.forEach(event => this.addEvent(event, false)); @@ -169,10 +165,10 @@ export class Thread extends TypedEventEmitter { this.addEventToTimeline(event, toStartOfTimeline); await this.client.decryptEventIfNeeded(event, {}); - } + } else { + await this.fetchEditsWhereNeeded(event); - if (Thread.hasServerSideSupport && this.initialEventsFetched) { - if (event.localTimestamp > this.lastReply().localTimestamp) { + if (this.initialEventsFetched && event.localTimestamp > this.lastReply().localTimestamp) { this.addEventToTimeline(event, false); } } @@ -221,10 +217,28 @@ export class Thread extends TypedEventEmitter { const event = new MatrixEvent(bundledRelationship.latest_event); this.setEventMetadata(event); + event.setThread(this); this.lastEvent = event; + + this.fetchEditsWhereNeeded(event); } } + // XXX: Workaround for https://github.com/matrix-org/matrix-spec-proposals/pull/2676/files#r827240084 + private async fetchEditsWhereNeeded(...events: MatrixEvent[]): Promise { + return Promise.all(events.filter(e => e.isEncrypted()).map((event: MatrixEvent) => { + return this.client.relations(this.roomId, event.getId(), RelationType.Replace, event.getType(), { + limit: 1, + }).then(relations => { + if (relations.events.length) { + event.makeReplaced(relations.events[0]); + } + }).catch(e => { + logger.error("Failed to load edits for encrypted thread event", e); + }); + })); + } + public async fetchInitialEvents(): Promise<{ originalEvent: MatrixEvent; events: MatrixEvent[]; @@ -235,6 +249,7 @@ export class Thread extends TypedEventEmitter { this.initialEventsFetched = true; return null; } + try { const response = await this.fetchEvents(); this.initialEventsFetched = true; @@ -253,6 +268,11 @@ export class Thread extends TypedEventEmitter { * Finds an event by ID in the current thread */ public findEventById(eventId: string) { + // Check the lastEvent as it may have been created based on a bundled relationship and not in a timeline + if (this.lastEvent?.getId() === eventId) { + return this.lastEvent; + } + return this.timelineSet.findEventById(eventId); } @@ -329,6 +349,8 @@ export class Thread extends TypedEventEmitter { events = [...events, originalEvent]; } + await this.fetchEditsWhereNeeded(...events); + await Promise.all(events.map(event => { this.setEventMetadata(event); return this.client.decryptEventIfNeeded(event); From e90f12ee321a95c42294262e7e6845be00383374 Mon Sep 17 00:00:00 2001 From: Andy Balaam Date: Fri, 25 Mar 2022 09:09:35 +0000 Subject: [PATCH 05/28] Port codecov improvements matrix-react-sdk -> matrix-js-sdk (#2258) --- .github/codecov.yml | 6 ++++++ .github/workflows/test_coverage.yml | 7 +++++++ 2 files changed, 13 insertions(+) diff --git a/.github/codecov.yml b/.github/codecov.yml index 0cd4cec72de..449fa0a733a 100644 --- a/.github/codecov.yml +++ b/.github/codecov.yml @@ -1,3 +1,9 @@ +codecov: + allow_coverage_offsets: True +coverage: + status: + project: off + patch: off comment: layout: "diff, files" behavior: default diff --git a/.github/workflows/test_coverage.yml b/.github/workflows/test_coverage.yml index adf206ba04a..d1e599b2ad2 100644 --- a/.github/workflows/test_coverage.yml +++ b/.github/workflows/test_coverage.yml @@ -6,14 +6,21 @@ on: jobs: test-coverage: runs-on: ubuntu-latest + env: + # This must be set for fetchdep.sh to get the right branch + PR_NUMBER: ${{github.event.number}} steps: - name: Checkout code uses: actions/checkout@v2 + - name: Yarn cache + uses: c-hive/gha-yarn-cache@v2 + - name: Run tests with coverage run: "yarn install && yarn build && yarn coverage" - name: Upload coverage uses: codecov/codecov-action@v2 with: + fail_ci_if_error: false verbose: true From f03a391f807ac89ddf0c5506a4937c9f01789eea Mon Sep 17 00:00:00 2001 From: Andy Balaam Date: Mon, 28 Mar 2022 11:48:34 +0100 Subject: [PATCH 06/28] Prevent exception 'Unable to set up secret storage' (#2260) --- spec/unit/crypto.spec.js | 25 +++++++++++++++++++++++++ src/crypto/index.ts | 2 +- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/spec/unit/crypto.spec.js b/spec/unit/crypto.spec.js index 450a99af43e..3c3a738becd 100644 --- a/spec/unit/crypto.spec.js +++ b/spec/unit/crypto.spec.js @@ -13,6 +13,7 @@ import * as olmlib from "../../src/crypto/olmlib"; import { sleep } from "../../src/utils"; import { CRYPTO_ENABLED } from "../../src/client"; import { DeviceInfo } from "../../src/crypto/deviceinfo"; +import { logger } from '../../src/logger'; const Olm = global.Olm; @@ -400,4 +401,28 @@ describe("Crypto", function() { expect(aliceClient.sendToDevice.mock.calls[2][2]).not.toBe(txnId); }); }); + + describe('Secret storage', function() { + it("creates secret storage even if there is no keyInfo", async function() { + jest.spyOn(logger, 'log').mockImplementation(() => {}); + jest.setTimeout(10000); + const client = (new TestClient("@a:example.com", "dev")).client; + await client.initCrypto(); + client.crypto.getSecretStorageKey = async () => null; + client.crypto.isCrossSigningReady = async () => false; + client.crypto.baseApis.uploadDeviceSigningKeys = () => null; + client.crypto.baseApis.setAccountData = () => null; + client.crypto.baseApis.uploadKeySignatures = () => null; + client.crypto.baseApis.http.authedRequest = () => null; + const createSecretStorageKey = async () => { + return { + keyInfo: undefined, // Returning undefined here used to cause a crash + privateKey: Uint8Array.of(32, 33), + }; + }; + await client.crypto.bootstrapSecretStorage({ + createSecretStorageKey, + }); + }); + }); }); diff --git a/src/crypto/index.ts b/src/crypto/index.ts index f5676f37e65..116d3ea99a4 100644 --- a/src/crypto/index.ts +++ b/src/crypto/index.ts @@ -920,7 +920,7 @@ export class Crypto extends TypedEventEmitter Date: Mon, 28 Mar 2022 14:38:44 +0100 Subject: [PATCH 07/28] Resetting package fields for development --- package.json | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 0f5ae028bf6..2664aab382e 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ "keywords": [ "matrix-org" ], - "main": "./lib/index.js", + "main": "./src/index.ts", "browser": "./lib/browser-index.js", "matrix_src_main": "./src/index.ts", "matrix_src_browser": "./src/browser-index.js", @@ -116,6 +116,5 @@ "text", "json" ] - }, - "typings": "./lib/index.d.ts" + } } From 85b8d4f83ac135a304bf789db7931bcd5b26bcfc Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Tue, 29 Mar 2022 09:24:45 +0100 Subject: [PATCH 08/28] Fix issues with /search and /context API handling for threads (#2261) --- .../matrix-client-event-timeline.spec.js | 82 +++++++- spec/integ/matrix-client-methods.spec.js | 191 ++++++++++++++++-- src/client.ts | 120 ++++++----- src/models/event-context.ts | 2 +- src/models/room.ts | 48 +++-- src/models/search-result.ts | 13 +- src/models/thread.ts | 10 +- src/timeline-window.ts | 27 +-- 8 files changed, 372 insertions(+), 121 deletions(-) diff --git a/spec/integ/matrix-client-event-timeline.spec.js b/spec/integ/matrix-client-event-timeline.spec.js index 6499dad18bb..ad475f3fc5a 100644 --- a/spec/integ/matrix-client-event-timeline.spec.js +++ b/spec/integ/matrix-client-event-timeline.spec.js @@ -2,6 +2,7 @@ import * as utils from "../test-utils/test-utils"; import { EventTimeline } from "../../src/matrix"; import { logger } from "../../src/logger"; import { TestClient } from "../TestClient"; +import { Thread, THREAD_RELATION_TYPE } from "../../src/models/thread"; const userId = "@alice:localhost"; const userName = "Alice"; @@ -69,6 +70,27 @@ const EVENTS = [ }), ]; +const THREAD_ROOT = utils.mkMessage({ + room: roomId, + user: userId, + msg: "thread root", +}); + +const THREAD_REPLY = utils.mkEvent({ + room: roomId, + user: userId, + type: "m.room.message", + content: { + "body": "thread reply", + "msgtype": "m.text", + "m.relates_to": { + // We can't use the const here because we change server support mode for test + rel_type: "io.element.thread", + event_id: THREAD_ROOT.event_id, + }, + }, +}); + // start the client, and wait for it to initialise function startClient(httpBackend, client) { httpBackend.when("GET", "/versions").respond(200, {}); @@ -116,9 +138,7 @@ describe("getEventTimeline support", function() { return startClient(httpBackend, client).then(function() { const room = client.getRoom(roomId); const timelineSet = room.getTimelineSets()[0]; - expect(function() { - client.getEventTimeline(timelineSet, "event"); - }).toThrow(); + expect(client.getEventTimeline(timelineSet, "event")).rejects.toBeTruthy(); }); }); @@ -136,16 +156,12 @@ describe("getEventTimeline support", function() { return startClient(httpBackend, client).then(() => { const room = client.getRoom(roomId); const timelineSet = room.getTimelineSets()[0]; - expect(function() { - client.getEventTimeline(timelineSet, "event"); - }).not.toThrow(); + expect(client.getEventTimeline(timelineSet, "event")).rejects.toBeFalsy(); }); }); - it("scrollback should be able to scroll back to before a gappy /sync", - function() { + it("scrollback should be able to scroll back to before a gappy /sync", function() { // need a client with timelineSupport disabled to make this work - let room; return startClient(httpBackend, client).then(function() { @@ -229,6 +245,7 @@ describe("MatrixClient event timelines", function() { afterEach(function() { httpBackend.verifyNoOutstandingExpectation(); client.stopClient(); + Thread.setServerSideSupport(false); }); describe("getEventTimeline", function() { @@ -355,8 +372,7 @@ describe("MatrixClient event timelines", function() { ]); }); - it("should join timelines where they overlap a previous /context", - function() { + it("should join timelines where they overlap a previous /context", function() { const room = client.getRoom(roomId); const timelineSet = room.getTimelineSets()[0]; @@ -478,6 +494,50 @@ describe("MatrixClient event timelines", function() { httpBackend.flushAllExpected(), ]); }); + + it("should handle thread replies with server support by fetching a contiguous thread timeline", async () => { + Thread.setServerSideSupport(true); + client.stopClient(); // we don't need the client to be syncing at this time + const room = client.getRoom(roomId); + const timelineSet = room.getTimelineSets()[0]; + + httpBackend.when("GET", "/rooms/!foo%3Abar/context/" + encodeURIComponent(THREAD_REPLY.event_id)) + .respond(200, function() { + return { + start: "start_token0", + events_before: [], + event: THREAD_REPLY, + events_after: [], + end: "end_token0", + state: [], + }; + }); + + httpBackend.when("GET", "/rooms/!foo%3Abar/event/" + encodeURIComponent(THREAD_ROOT.event_id)) + .respond(200, function() { + return THREAD_ROOT; + }); + + httpBackend.when("GET", "/rooms/!foo%3Abar/relations/" + + encodeURIComponent(THREAD_ROOT.event_id) + "/" + + encodeURIComponent(THREAD_RELATION_TYPE.name) + "?limit=20") + .respond(200, function() { + return { + original_event: THREAD_ROOT, + chunk: [THREAD_REPLY], + next_batch: "next_batch_token0", + prev_batch: "prev_batch_token0", + }; + }); + + const timelinePromise = client.getEventTimeline(timelineSet, THREAD_REPLY.event_id); + await httpBackend.flushAllExpected(); + + const timeline = await timelinePromise; + + expect(timeline.getEvents().find(e => e.getId() === THREAD_ROOT.event_id)); + expect(timeline.getEvents().find(e => e.getId() === THREAD_REPLY.event_id)); + }); }); describe("paginateEventTimeline", function() { diff --git a/spec/integ/matrix-client-methods.spec.js b/spec/integ/matrix-client-methods.spec.js index bdb36e1e970..0df2dafd101 100644 --- a/spec/integ/matrix-client-methods.spec.js +++ b/spec/integ/matrix-client-methods.spec.js @@ -3,6 +3,7 @@ import { CRYPTO_ENABLED } from "../../src/client"; import { MatrixEvent } from "../../src/models/event"; import { Filter, MemoryStore, Room } from "../../src/matrix"; import { TestClient } from "../TestClient"; +import { THREAD_RELATION_TYPE } from "../../src/models/thread"; describe("MatrixClient", function() { let client = null; @@ -14,9 +15,7 @@ describe("MatrixClient", function() { beforeEach(function() { store = new MemoryStore(); - const testClient = new TestClient(userId, "aliceDevice", accessToken, undefined, { - store: store, - }); + const testClient = new TestClient(userId, "aliceDevice", accessToken, undefined, { store }); httpBackend = testClient.httpBackend; client = testClient.client; }); @@ -244,14 +243,15 @@ describe("MatrixClient", function() { }); describe("searching", function() { - const response = { - search_categories: { - room_events: { - count: 24, - results: { - "$flibble:localhost": { + it("searchMessageText should perform a /search for room_events", function() { + const response = { + search_categories: { + room_events: { + count: 24, + results: [{ rank: 0.1, result: { + event_id: "$flibble:localhost", type: "m.room.message", user_id: "@alice:localhost", room_id: "!feuiwhf:localhost", @@ -260,13 +260,11 @@ describe("MatrixClient", function() { msgtype: "m.text", }, }, - }, + }], }, }, - }, - }; + }; - it("searchMessageText should perform a /search for room_events", function(done) { client.searchMessageText({ query: "monkeys", }); @@ -280,8 +278,171 @@ describe("MatrixClient", function() { }); }).respond(200, response); - httpBackend.flush().then(function() { - done(); + return httpBackend.flush(); + }); + + describe("should filter out context from different timelines (threads)", () => { + it("filters out thread replies when result is in the main timeline", async () => { + const response = { + search_categories: { + room_events: { + count: 24, + results: [{ + rank: 0.1, + result: { + event_id: "$flibble:localhost", + type: "m.room.message", + user_id: "@alice:localhost", + room_id: "!feuiwhf:localhost", + content: { + body: "main timeline", + msgtype: "m.text", + }, + }, + context: { + events_after: [{ + event_id: "$ev-after:server", + type: "m.room.message", + user_id: "@alice:localhost", + room_id: "!feuiwhf:localhost", + content: { + "body": "thread reply", + "msgtype": "m.text", + "m.relates_to": { + "event_id": "$some-thread:server", + "rel_type": THREAD_RELATION_TYPE.name, + }, + }, + }], + events_before: [{ + event_id: "$ev-before:server", + type: "m.room.message", + user_id: "@alice:localhost", + room_id: "!feuiwhf:localhost", + content: { + body: "main timeline again", + msgtype: "m.text", + }, + }], + }, + }], + }, + }, + }; + + const data = { + results: [], + highlights: [], + }; + client.processRoomEventsSearch(data, response); + + expect(data.results).toHaveLength(1); + expect(data.results[0].context.timeline).toHaveLength(2); + expect(data.results[0].context.timeline.find(e => e.getId() === "$ev-after:server")).toBeFalsy(); + }); + + it("filters out thread replies from threads other than the thread the result replied to", () => { + const response = { + search_categories: { + room_events: { + count: 24, + results: [{ + rank: 0.1, + result: { + event_id: "$flibble:localhost", + type: "m.room.message", + user_id: "@alice:localhost", + room_id: "!feuiwhf:localhost", + content: { + "body": "thread 1 reply 1", + "msgtype": "m.text", + "m.relates_to": { + "event_id": "$thread1:server", + "rel_type": THREAD_RELATION_TYPE.name, + }, + }, + }, + context: { + events_after: [{ + event_id: "$ev-after:server", + type: "m.room.message", + user_id: "@alice:localhost", + room_id: "!feuiwhf:localhost", + content: { + "body": "thread 2 reply 2", + "msgtype": "m.text", + "m.relates_to": { + "event_id": "$thread2:server", + "rel_type": THREAD_RELATION_TYPE.name, + }, + }, + }], + events_before: [], + }, + }], + }, + }, + }; + + const data = { + results: [], + highlights: [], + }; + client.processRoomEventsSearch(data, response); + + expect(data.results).toHaveLength(1); + expect(data.results[0].context.timeline).toHaveLength(1); + expect(data.results[0].context.timeline.find(e => e.getId() === "$flibble:localhost")).toBeTruthy(); + }); + + it("filters out main timeline events when result is a thread reply", () => { + const response = { + search_categories: { + room_events: { + count: 24, + results: [{ + rank: 0.1, + result: { + event_id: "$flibble:localhost", + type: "m.room.message", + user_id: "@alice:localhost", + room_id: "!feuiwhf:localhost", + content: { + "body": "thread 1 reply 1", + "msgtype": "m.text", + "m.relates_to": { + "event_id": "$thread1:server", + "rel_type": THREAD_RELATION_TYPE.name, + }, + }, + }, + context: { + events_after: [{ + event_id: "$ev-after:server", + type: "m.room.message", + user_id: "@alice:localhost", + room_id: "!feuiwhf:localhost", + content: { + "body": "main timeline", + "msgtype": "m.text", + }, + }], + events_before: [], + }, + }], + }, + }, + }; + + const data = { + results: [], + highlights: [], + }; + client.processRoomEventsSearch(data, response); + + expect(data.results).toHaveLength(1); + expect(data.results[0].context.timeline).toHaveLength(1); + expect(data.results[0].context.timeline.find(e => e.getId() === "$flibble:localhost")).toBeTruthy(); }); }); }); diff --git a/src/client.ts b/src/client.ts index 09d610b9449..d1b6dff72a4 100644 --- a/src/client.ts +++ b/src/client.ts @@ -5231,19 +5231,17 @@ export class MatrixClient extends TypedEventEmitter { + public async getEventTimeline(timelineSet: EventTimelineSet, eventId: string): Promise { // don't allow any timeline support unless it's been enabled. if (!this.timelineSupport) { throw new Error("timeline support is disabled. Set the 'timelineSupport'" + - " parameter to true when creating MatrixClient to enable" + - " it."); + " parameter to true when creating MatrixClient to enable it."); } if (timelineSet.getTimelineForEvent(eventId)) { - return Promise.resolve(timelineSet.getTimelineForEvent(eventId)); + return timelineSet.getTimelineForEvent(eventId); } const path = utils.encodeUri( @@ -5253,56 +5251,82 @@ export class MatrixClient extends TypedEventEmitter = undefined; if (this.clientOpts.lazyLoadMembers) { params = { filter: JSON.stringify(Filter.LAZY_LOADING_MESSAGES_FILTER) }; } - // TODO: we should implement a backoff (as per scrollback()) to deal more - // nicely with HTTP errors. - const promise = this.http.authedRequest(undefined, Method.Get, path, params).then(async (res) => { // TODO types - if (!res.event) { - throw new Error("'event' not in '/context' result - homeserver too old?"); - } + // TODO: we should implement a backoff (as per scrollback()) to deal more nicely with HTTP errors. + const res = await this.http.authedRequest(undefined, Method.Get, path, params); // TODO types + if (!res.event) { + throw new Error("'event' not in '/context' result - homeserver too old?"); + } - // by the time the request completes, the event might have ended up in - // the timeline. - if (timelineSet.getTimelineForEvent(eventId)) { - return timelineSet.getTimelineForEvent(eventId); - } + // by the time the request completes, the event might have ended up in the timeline. + if (timelineSet.getTimelineForEvent(eventId)) { + return timelineSet.getTimelineForEvent(eventId); + } - // we start with the last event, since that's the point at which we - // have known state. + const mapper = this.getEventMapper(); + const event = mapper(res.event); + const events = [ + // we start with the last event, since that's the point at which we have known state. // events_after is already backwards; events_before is forwards. - res.events_after.reverse(); - const events = res.events_after - .concat([res.event]) - .concat(res.events_before); - const matrixEvents = events.map(this.getEventMapper()); - - let timeline = timelineSet.getTimelineForEvent(matrixEvents[0].getId()); - if (!timeline) { - timeline = timelineSet.addTimeline(); - timeline.initialiseState(res.state.map(this.getEventMapper())); - timeline.getState(EventTimeline.FORWARDS).paginationToken = res.end; - } else { - const stateEvents = res.state.map(this.getEventMapper()); - timeline.getState(EventTimeline.BACKWARDS).setUnknownStateEvents(stateEvents); + ...res.events_after.reverse().map(mapper), + event, + ...res.events_before.map(mapper), + ]; + + // Where the event is a thread reply (not a root) and running in MSC-enabled mode the Thread timeline only + // functions contiguously, so we have to jump through some hoops to get our target event in it. + // XXX: workaround for https://github.com/vector-im/element-meta/issues/150 + if (Thread.hasServerSideSupport && event.isRelation(THREAD_RELATION_TYPE.name)) { + const [, threadedEvents] = this.partitionThreadedEvents(events); + const thread = await timelineSet.room.createThreadFetchRoot(event.threadRootId, threadedEvents, true); + + let nextBatch: string; + const response = await thread.fetchInitialEvents(); + if (response?.nextBatch) { + nextBatch = response.nextBatch; } - const [timelineEvents, threadedEvents] = this.partitionThreadedEvents(matrixEvents); + const opts: IRelationsRequestOpts = { + limit: 50, + }; - timelineSet.addEventsToTimeline(timelineEvents, true, timeline, res.start); - await this.processThreadEvents(timelineSet.room, threadedEvents, true); + // Fetch events until we find the one we were asked for + while (!thread.findEventById(eventId)) { + if (nextBatch) { + opts.from = nextBatch; + } - // there is no guarantee that the event ended up in "timeline" (we - // might have switched to a neighbouring timeline) - so check the - // room's index again. On the other hand, there's no guarantee the - // event ended up anywhere, if it was later redacted, so we just - // return the timeline we first thought of. - return timelineSet.getTimelineForEvent(eventId) || timeline; - }); - return promise; + ({ nextBatch } = await thread.fetchEvents(opts)); + } + + return thread.liveTimeline; + } + + // Here we handle non-thread timelines only, but still process any thread events to populate thread summaries. + let timeline = timelineSet.getTimelineForEvent(events[0].getId()); + if (timeline) { + timeline.getState(EventTimeline.BACKWARDS).setUnknownStateEvents(res.state.map(mapper)); + } else { + timeline = timelineSet.addTimeline(); + timeline.initialiseState(res.state.map(mapper)); + timeline.getState(EventTimeline.FORWARDS).paginationToken = res.end; + } + + const [timelineEvents, threadedEvents] = this.partitionThreadedEvents(events); + timelineSet.addEventsToTimeline(timelineEvents, true, timeline, res.start); + // The target event is not in a thread but process the contextual events, so we can show any threads around it. + await this.processThreadEvents(timelineSet.room, threadedEvents, true); + + // There is no guarantee that the event ended up in "timeline" (we might have switched to a neighbouring + // timeline) - so check the room's index again. On the other hand, there's no guarantee the event ended up + // anywhere, if it was later redacted, so we just return the timeline we first thought of. + return timelineSet.getTimelineForEvent(eventId) + ?? timelineSet.room.findThreadForEvent(event)?.liveTimeline // for Threads degraded support + ?? timeline; } /** @@ -6026,10 +6050,12 @@ export class MatrixClient extends TypedEventEmitter } } - /** - * Add an event to a thread's timeline. Will fire "Thread.update" - * @experimental - */ - public async addThreadedEvent(event: MatrixEvent, toStartOfTimeline: boolean): Promise { - this.applyRedaction(event); - let thread = this.findThreadForEvent(event); - if (thread) { - thread.addEvent(event, toStartOfTimeline); - } else { - const events = [event]; - let rootEvent = this.findEventById(event.threadRootId); - // If the rootEvent does not exist in the current sync, then look for it over the network. + public async createThreadFetchRoot( + threadId: string, + events?: MatrixEvent[], + toStartOfTimeline?: boolean, + ): Promise { + let thread = this.getThread(threadId); + + if (!thread) { + let rootEvent = this.findEventById(threadId); + // If the rootEvent does not exist in the local stores, then fetch it from the server. try { - let eventData: IMinimalEvent; - if (event.threadRootId) { - eventData = await this.client.fetchRoomEvent(this.roomId, event.threadRootId); - } + const eventData = await this.client.fetchRoomEvent(this.roomId, threadId); if (!rootEvent) { rootEvent = new MatrixEvent(eventData); @@ -1613,6 +1606,22 @@ export class Room extends TypedEventEmitter } } + return thread; + } + + /** + * Add an event to a thread's timeline. Will fire "Thread.update" + * @experimental + */ + public async addThreadedEvent(event: MatrixEvent, toStartOfTimeline: boolean): Promise { + this.applyRedaction(event); + let thread = this.findThreadForEvent(event); + if (thread) { + await thread.addEvent(event, toStartOfTimeline); + } else { + thread = await this.createThreadFetchRoot(event.threadRootId, [event], toStartOfTimeline); + } + this.emit(ThreadEvent.Update, thread); } @@ -1634,8 +1643,7 @@ export class Room extends TypedEventEmitter room: this, client: this.client, }); - // If we managed to create a thread and figure out its `id` - // then we can use it + // If we managed to create a thread and figure out its `id` then we can use it if (thread.id) { this.threads.set(thread.id, thread); this.reEmitter.reEmit(thread, [ diff --git a/src/models/search-result.ts b/src/models/search-result.ts index 1dc16ea84ee..99f9c5ddaa3 100644 --- a/src/models/search-result.ts +++ b/src/models/search-result.ts @@ -33,14 +33,19 @@ export class SearchResult { public static fromJson(jsonObj: ISearchResult, eventMapper: EventMapper): SearchResult { const jsonContext = jsonObj.context || {} as IResultContext; - const eventsBefore = jsonContext.events_before || []; - const eventsAfter = jsonContext.events_after || []; + let eventsBefore = (jsonContext.events_before || []).map(eventMapper); + let eventsAfter = (jsonContext.events_after || []).map(eventMapper); const context = new EventContext(eventMapper(jsonObj.result)); + // Filter out any contextual events which do not correspond to the same timeline (thread or room) + const threadRootId = context.ourEvent.threadRootId; + eventsBefore = eventsBefore.filter(e => e.threadRootId === threadRootId); + eventsAfter = eventsAfter.filter(e => e.threadRootId === threadRootId); + context.setPaginateToken(jsonContext.start, true); - context.addEvents(eventsBefore.map(eventMapper), true); - context.addEvents(eventsAfter.map(eventMapper), false); + context.addEvents(eventsBefore, true); + context.addEvents(eventsAfter, false); context.setPaginateToken(jsonContext.end, false); return new SearchResult(jsonObj.rank, context); diff --git a/src/models/thread.ts b/src/models/thread.ts index 6ace74042a2..7879cc89f9c 100644 --- a/src/models/thread.ts +++ b/src/models/thread.ts @@ -165,12 +165,12 @@ export class Thread extends TypedEventEmitter { this.addEventToTimeline(event, toStartOfTimeline); await this.client.decryptEventIfNeeded(event, {}); - } else { + } else if (!toStartOfTimeline && + this.initialEventsFetched && + event.localTimestamp > this.lastReply().localTimestamp + ) { await this.fetchEditsWhereNeeded(event); - - if (this.initialEventsFetched && event.localTimestamp > this.lastReply().localTimestamp) { - this.addEventToTimeline(event, false); - } + this.addEventToTimeline(event, false); } if (!this._currentUserParticipated && event.getSender() === this.client.getUserId()) { diff --git a/src/timeline-window.ts b/src/timeline-window.ts index 936c910cf76..24c95fbcf07 100644 --- a/src/timeline-window.ts +++ b/src/timeline-window.ts @@ -99,11 +99,11 @@ export class TimelineWindow { * * @return {Promise} */ - public load(initialEventId?: string, initialWindowSize = 20): Promise { + public load(initialEventId?: string, initialWindowSize = 20): Promise { // given an EventTimeline, find the event we were looking for, and initialise our // fields so that the event in question is in the middle of the window. const initFields = (timeline: EventTimeline) => { - let eventIndex; + let eventIndex: number; const events = timeline.getEvents(); @@ -111,40 +111,31 @@ export class TimelineWindow { // we were looking for the live timeline: initialise to the end eventIndex = events.length; } else { - for (let i = 0; i < events.length; i++) { - if (events[i].getId() == initialEventId) { - eventIndex = i; - break; - } - } + eventIndex = events.findIndex(e => e.getId() === initialEventId); - if (eventIndex === undefined) { + if (eventIndex < 0) { throw new Error("getEventTimeline result didn't include requested event"); } } - const endIndex = Math.min(events.length, - eventIndex + Math.ceil(initialWindowSize / 2)); + const endIndex = Math.min(events.length, eventIndex + Math.ceil(initialWindowSize / 2)); const startIndex = Math.max(0, endIndex - initialWindowSize); this.start = new TimelineIndex(timeline, startIndex - timeline.getBaseIndex()); this.end = new TimelineIndex(timeline, endIndex - timeline.getBaseIndex()); this.eventCount = endIndex - startIndex; }; - // We avoid delaying the resolution of the promise by a reactor tick if - // we already have the data we need, which is important to keep room-switching - // feeling snappy. - // + // We avoid delaying the resolution of the promise by a reactor tick if we already have the data we need, + // which is important to keep room-switching feeling snappy. if (initialEventId) { const timeline = this.timelineSet.getTimelineForEvent(initialEventId); if (timeline) { // hot-path optimization to save a reactor tick by replicating the sync check getTimelineForEvent does. initFields(timeline); - return Promise.resolve(timeline); + return Promise.resolve(); } - const prom = this.client.getEventTimeline(this.timelineSet, initialEventId); - return prom.then(initFields); + return this.client.getEventTimeline(this.timelineSet, initialEventId).then(initFields); } else { const tl = this.timelineSet.getLiveTimeline(); initFields(tl); From 26cbe02a7f2e9e91f5bbdc5eff309614799ea2a4 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Tue, 29 Mar 2022 15:28:08 +0100 Subject: [PATCH 09/28] Instantiate Thread objects when running fetchRoomThreads (#2262) --- src/models/room.ts | 54 +++++++++++++++++++++++----------------------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/src/models/room.ts b/src/models/room.ts index 03bffab45e1..9cd000d66ac 100644 --- a/src/models/room.ts +++ b/src/models/room.ts @@ -1448,14 +1448,6 @@ export class Room extends TypedEventEmitter pendingEvents: false, }, ); - - // An empty pagination token allows to paginate from the very bottom of - // the timeline set. - // Right now we completely by-pass the pagination to be able to order - // the events by last reply to a thread - // Once the server can help us with that, we should uncomment the line - // below - // timelineSet.getLiveTimeline().setPaginationToken("", EventTimeline.BACKWARDS); } else { timelineSet = new EventTimelineSet(this, { pendingEvents: false, @@ -1493,7 +1485,10 @@ export class Room extends TypedEventEmitter allThreadsFilter, ); - const orderedByLastReplyEvents = events + if (!events.length) return; + + // Sorted by last_reply origin_server_ts + const threadRoots = events .map(this.client.getEventMapper()) .sort((eventA, eventB) => { /** @@ -1509,32 +1504,37 @@ export class Room extends TypedEventEmitter return threadAMetadata.latest_event.origin_server_ts - threadBMetadata.latest_event.origin_server_ts; }); - const myThreads = orderedByLastReplyEvents.filter(event => { - const threadRelationship = event - .getServerAggregatedRelation(RelationType.Thread); - return threadRelationship.current_user_participated; - }); - + let latestMyThreadsRootEvent: MatrixEvent; const roomState = this.getLiveTimeline().getState(EventTimeline.FORWARDS); - for (const event of orderedByLastReplyEvents) { + for (const rootEvent of threadRoots) { this.threadsTimelineSets[0].addLiveEvent( - event, - DuplicateStrategy.Ignore, - false, - roomState, - ); - } - for (const event of myThreads) { - this.threadsTimelineSets[1].addLiveEvent( - event, + rootEvent, DuplicateStrategy.Ignore, false, roomState, ); + + const threadRelationship = rootEvent + .getServerAggregatedRelation(RelationType.Thread); + if (threadRelationship.current_user_participated) { + this.threadsTimelineSets[1].addLiveEvent( + rootEvent, + DuplicateStrategy.Ignore, + false, + roomState, + ); + latestMyThreadsRootEvent = rootEvent; + } + + if (!this.getThread(rootEvent.getId())) { + this.createThread(rootEvent, [], true); + } } - this.client.decryptEventIfNeeded(orderedByLastReplyEvents[orderedByLastReplyEvents.length -1]); - this.client.decryptEventIfNeeded(myThreads[myThreads.length -1]); + this.client.decryptEventIfNeeded(threadRoots[threadRoots.length -1]); + if (latestMyThreadsRootEvent) { + this.client.decryptEventIfNeeded(latestMyThreadsRootEvent); + } this.threadsReady = true; From 4360ae7ff81743bbdb172349a565aeff72599b0b Mon Sep 17 00:00:00 2001 From: Robin Date: Wed, 30 Mar 2022 08:02:14 -0400 Subject: [PATCH 10/28] Fix coverage diffs for PRs that aren't up to date (#2263) --- .github/workflows/test_coverage.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/test_coverage.yml b/.github/workflows/test_coverage.yml index d1e599b2ad2..c0699cfe466 100644 --- a/.github/workflows/test_coverage.yml +++ b/.github/workflows/test_coverage.yml @@ -12,6 +12,11 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v2 + with: + # If this is a pull request, make sure we check out its head rather than the + # automatically generated merge commit, so that the coverage diff excludes + # unrelated changes in the base branch + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || '' }} - name: Yarn cache uses: c-hive/gha-yarn-cache@v2 From d6f1c6cfdc5a4f3d7b4ec67fe9f4d89d7319d8f7 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 31 Mar 2022 13:57:37 +0100 Subject: [PATCH 11/28] Fix thread & main timeline partitioning logic (#2264) --- spec/integ/matrix-client-methods.spec.js | 200 +++- spec/integ/matrix-client-syncing.spec.js | 3 +- spec/test-utils/test-utils.js | 369 ------- spec/test-utils/test-utils.ts | 282 ++++++ spec/unit/crypto/cross-signing.spec.js | 32 +- spec/unit/filter-component.spec.ts | 17 +- spec/unit/matrix-client.spec.ts | 8 +- spec/unit/room.spec.ts | 1124 +++++++++++++--------- src/client.ts | 75 +- src/models/room.ts | 107 +- src/sync.ts | 62 +- 11 files changed, 1238 insertions(+), 1041 deletions(-) delete mode 100644 spec/test-utils/test-utils.js create mode 100644 spec/test-utils/test-utils.ts diff --git a/spec/integ/matrix-client-methods.spec.js b/spec/integ/matrix-client-methods.spec.js index 0df2dafd101..7b6a7be0a06 100644 --- a/spec/integ/matrix-client-methods.spec.js +++ b/spec/integ/matrix-client-methods.spec.js @@ -556,9 +556,11 @@ describe("MatrixClient", function() { }); describe("partitionThreadedEvents", function() { + const room = new Room("!STrMRsukXHtqQdSeHa:matrix.org", client, userId); + it("returns empty arrays when given an empty arrays", function() { const events = []; - const [timeline, threaded] = client.partitionThreadedEvents(events); + const [timeline, threaded] = client.partitionThreadedEvents(room, events); expect(timeline).toEqual([]); expect(threaded).toEqual([]); }); @@ -566,24 +568,24 @@ describe("MatrixClient", function() { it("copies pre-thread in-timeline vote events onto both timelines", function() { client.clientOpts = { experimentalThreadSupport: true }; - const eventMessageInThread = buildEventMessageInThread(); const eventPollResponseReference = buildEventPollResponseReference(); const eventPollStartThreadRoot = buildEventPollStartThreadRoot(); + const eventMessageInThread = buildEventMessageInThread(eventPollStartThreadRoot); const events = [ + eventPollStartThreadRoot, eventMessageInThread, eventPollResponseReference, - eventPollStartThreadRoot, ]; // Vote has no threadId yet expect(eventPollResponseReference.threadId).toBeFalsy(); - const [timeline, threaded] = client.partitionThreadedEvents(events); + const [timeline, threaded] = client.partitionThreadedEvents(room, events); expect(timeline).toEqual([ // The message that was sent in a thread is missing - eventPollResponseReference, eventPollStartThreadRoot, + eventPollResponseReference, ]); // The vote event has been copied into the thread @@ -592,33 +594,34 @@ describe("MatrixClient", function() { expect(eventRefWithThreadId.threadId).toBeTruthy(); expect(threaded).toEqual([ + eventPollStartThreadRoot, eventMessageInThread, eventRefWithThreadId, - // Thread does not see thread root ]); }); it("copies pre-thread in-timeline reactions onto both timelines", function() { client.clientOpts = { experimentalThreadSupport: true }; - const eventMessageInThread = buildEventMessageInThread(); - const eventReaction = buildEventReaction(); const eventPollStartThreadRoot = buildEventPollStartThreadRoot(); + const eventMessageInThread = buildEventMessageInThread(eventPollStartThreadRoot); + const eventReaction = buildEventReaction(eventPollStartThreadRoot); const events = [ + eventPollStartThreadRoot, eventMessageInThread, eventReaction, - eventPollStartThreadRoot, ]; - const [timeline, threaded] = client.partitionThreadedEvents(events); + const [timeline, threaded] = client.partitionThreadedEvents(room, events); expect(timeline).toEqual([ - eventReaction, eventPollStartThreadRoot, + eventReaction, ]); expect(threaded).toEqual([ + eventPollStartThreadRoot, eventMessageInThread, withThreadId(eventReaction, eventPollStartThreadRoot.getId()), ]); @@ -628,23 +631,24 @@ describe("MatrixClient", function() { client.clientOpts = { experimentalThreadSupport: true }; const eventPollResponseReference = buildEventPollResponseReference(); - const eventMessageInThread = buildEventMessageInThread(); const eventPollStartThreadRoot = buildEventPollStartThreadRoot(); + const eventMessageInThread = buildEventMessageInThread(eventPollStartThreadRoot); const events = [ + eventPollStartThreadRoot, eventPollResponseReference, eventMessageInThread, - eventPollStartThreadRoot, ]; - const [timeline, threaded] = client.partitionThreadedEvents(events); + const [timeline, threaded] = client.partitionThreadedEvents(room, events); expect(timeline).toEqual([ - eventPollResponseReference, eventPollStartThreadRoot, + eventPollResponseReference, ]); expect(threaded).toEqual([ + eventPollStartThreadRoot, withThreadId(eventPollResponseReference, eventPollStartThreadRoot.getId()), eventMessageInThread, ]); @@ -653,26 +657,27 @@ describe("MatrixClient", function() { it("copies post-thread in-timeline reactions onto both timelines", function() { client.clientOpts = { experimentalThreadSupport: true }; - const eventReaction = buildEventReaction(); - const eventMessageInThread = buildEventMessageInThread(); const eventPollStartThreadRoot = buildEventPollStartThreadRoot(); + const eventMessageInThread = buildEventMessageInThread(eventPollStartThreadRoot); + const eventReaction = buildEventReaction(eventPollStartThreadRoot); const events = [ - eventReaction, - eventMessageInThread, eventPollStartThreadRoot, + eventMessageInThread, + eventReaction, ]; - const [timeline, threaded] = client.partitionThreadedEvents(events); + const [timeline, threaded] = client.partitionThreadedEvents(room, events); expect(timeline).toEqual([ - eventReaction, eventPollStartThreadRoot, + eventReaction, ]); expect(threaded).toEqual([ - withThreadId(eventReaction, eventPollStartThreadRoot.getId()), + eventPollStartThreadRoot, eventMessageInThread, + withThreadId(eventReaction, eventPollStartThreadRoot.getId()), ]); }); @@ -680,9 +685,9 @@ describe("MatrixClient", function() { client.clientOpts = { experimentalThreadSupport: true }; // This is based on recording the events in a real room: - const eventMessageInThread = buildEventMessageInThread(); - const eventPollResponseReference = buildEventPollResponseReference(); const eventPollStartThreadRoot = buildEventPollStartThreadRoot(); + const eventPollResponseReference = buildEventPollResponseReference(); + const eventMessageInThread = buildEventMessageInThread(eventPollStartThreadRoot); const eventRoomName = buildEventRoomName(); const eventEncryption = buildEventEncryption(); const eventGuestAccess = buildEventGuestAccess(); @@ -693,9 +698,9 @@ describe("MatrixClient", function() { const eventCreate = buildEventCreate(); const events = [ - eventMessageInThread, - eventPollResponseReference, eventPollStartThreadRoot, + eventPollResponseReference, + eventMessageInThread, eventRoomName, eventEncryption, eventGuestAccess, @@ -705,12 +710,12 @@ describe("MatrixClient", function() { eventMember, eventCreate, ]; - const [timeline, threaded] = client.partitionThreadedEvents(events); + const [timeline, threaded] = client.partitionThreadedEvents(room, events); expect(timeline).toEqual([ // The message that was sent in a thread is missing - eventPollResponseReference, eventPollStartThreadRoot, + eventPollResponseReference, eventRoomName, eventEncryption, eventGuestAccess, @@ -721,11 +726,95 @@ describe("MatrixClient", function() { eventCreate, ]); - // Thread should contain only stuff that happened in the thread - - // no thread root, and no room state events + // Thread should contain only stuff that happened in the thread - no room state events expect(threaded).toEqual([ - eventMessageInThread, + eventPollStartThreadRoot, withThreadId(eventPollResponseReference, eventPollStartThreadRoot.getId()), + eventMessageInThread, + ]); + }); + + it("sends redactions of reactions to thread responses to thread timeline only", () => { + client.clientOpts = { experimentalThreadSupport: true }; + + const threadRootEvent = buildEventPollStartThreadRoot(); + const eventMessageInThread = buildEventMessageInThread(threadRootEvent); + const threadedReaction = buildEventReaction(eventMessageInThread); + const threadedReactionRedaction = buildEventRedaction(threadedReaction); + + const events = [ + threadRootEvent, + eventMessageInThread, + threadedReaction, + threadedReactionRedaction, + ]; + + const [timeline, threaded] = client.partitionThreadedEvents(room, events); + + expect(timeline).toEqual([ + threadRootEvent, + ]); + + expect(threaded).toEqual([ + threadRootEvent, + eventMessageInThread, + threadedReaction, + threadedReactionRedaction, + ]); + }); + + it("sends reply to reply to thread root outside of thread to main timeline only", () => { + client.clientOpts = { experimentalThreadSupport: true }; + + const threadRootEvent = buildEventPollStartThreadRoot(); + const eventMessageInThread = buildEventMessageInThread(threadRootEvent); + const directReplyToThreadRoot = buildEventReply(threadRootEvent); + const replyToReply = buildEventReply(directReplyToThreadRoot); + + const events = [ + threadRootEvent, + eventMessageInThread, + directReplyToThreadRoot, + replyToReply, + ]; + + const [timeline, threaded] = client.partitionThreadedEvents(room, events); + + expect(timeline).toEqual([ + threadRootEvent, + directReplyToThreadRoot, + replyToReply, + ]); + + expect(threaded).toEqual([ + threadRootEvent, + eventMessageInThread, + ]); + }); + + it("sends reply to thread responses to thread timeline only", () => { + client.clientOpts = { experimentalThreadSupport: true }; + + const threadRootEvent = buildEventPollStartThreadRoot(); + const eventMessageInThread = buildEventMessageInThread(threadRootEvent); + const replyToThreadResponse = buildEventReply(eventMessageInThread); + + const events = [ + threadRootEvent, + eventMessageInThread, + replyToThreadResponse, + ]; + + const [timeline, threaded] = client.partitionThreadedEvents(room, events); + + expect(timeline).toEqual([ + threadRootEvent, + ]); + + expect(threaded).toEqual([ + threadRootEvent, + eventMessageInThread, + replyToThreadResponse, ]); }); }); @@ -737,16 +826,16 @@ function withThreadId(event, newThreadId) { return ret; } -const buildEventMessageInThread = () => new MatrixEvent({ +const buildEventMessageInThread = (root) => new MatrixEvent({ "age": 80098509, "content": { "algorithm": "m.megolm.v1.aes-sha2", "ciphertext": "ENCRYPTEDSTUFF", "device_id": "XISFUZSKHH", "m.relates_to": { - "event_id": "$VLS2ojbPmxb6x8ECetn45hmND6cRDcjgv-j-to9m7Vo", + "event_id": root.getId(), "m.in_reply_to": { - "event_id": "$VLS2ojbPmxb6x8ECetn45hmND6cRDcjgv-j-to9m7Vo", + "event_id": root.getId(), }, "rel_type": "m.thread", }, @@ -784,10 +873,10 @@ const buildEventPollResponseReference = () => new MatrixEvent({ "user_id": "@andybalaam-test1:matrix.org", }); -const buildEventReaction = () => new MatrixEvent({ +const buildEventReaction = (event) => new MatrixEvent({ "content": { "m.relates_to": { - "event_id": "$VLS2ojbPmxb6x8ECetn45hmND6cRDcjgv-j-to9m7Vo", + "event_id": event.getId(), "key": "🤗", "rel_type": "m.annotation", }, @@ -803,6 +892,22 @@ const buildEventReaction = () => new MatrixEvent({ "room_id": "!STrMRsukXHtqQdSeHa:matrix.org", }); +const buildEventRedaction = (event) => new MatrixEvent({ + "content": { + + }, + "origin_server_ts": 1643977249239, + "sender": "@andybalaam-test1:matrix.org", + "redacts": event.getId(), + "type": "m.room.redaction", + "unsigned": { + "age": 22597, + "transaction_id": "m1643977249073.17", + }, + "event_id": "$86B2b-x3LgE4DlV4y24b7UHnt72LIA3rzjvMysTtAfB", + "room_id": "!STrMRsukXHtqQdSeHa:matrix.org", +}); + const buildEventPollStartThreadRoot = () => new MatrixEvent({ "age": 80108647, "content": { @@ -821,6 +926,29 @@ const buildEventPollStartThreadRoot = () => new MatrixEvent({ "user_id": "@andybalaam-test1:matrix.org", }); +const buildEventReply = (target) => new MatrixEvent({ + "age": 80098509, + "content": { + "algorithm": "m.megolm.v1.aes-sha2", + "ciphertext": "ENCRYPTEDSTUFF", + "device_id": "XISFUZSKHH", + "m.relates_to": { + "m.in_reply_to": { + "event_id": target.getId(), + }, + }, + "sender_key": "i3N3CtG/CD2bGB8rA9fW6adLYSDvlUhf2iuU73L65Vg", + "session_id": "Ja11R/KG6ua0wdk8zAzognrxjio1Gm/RK2Gn6lFL804", + }, + "event_id": target.getId() + Math.random(), + "origin_server_ts": 1643815466378, + "room_id": "!STrMRsukXHtqQdSeHa:matrix.org", + "sender": "@andybalaam-test1:matrix.org", + "type": "m.room.encrypted", + "unsigned": { "age": 80098509 }, + "user_id": "@andybalaam-test1:matrix.org", +}); + const buildEventRoomName = () => new MatrixEvent({ "age": 80123249, "content": { diff --git a/spec/integ/matrix-client-syncing.spec.js b/spec/integ/matrix-client-syncing.spec.js index adeef9ddae4..6adb35a50c0 100644 --- a/spec/integ/matrix-client-syncing.spec.js +++ b/spec/integ/matrix-client-syncing.spec.js @@ -735,8 +735,7 @@ describe("MatrixClient syncing", function() { expect(tok).toEqual("pagTok"); }), - // first flush the filter request; this will make syncLeftRooms - // make its /sync call + // first flush the filter request; this will make syncLeftRooms make its /sync call httpBackend.flush("/filter").then(function() { return httpBackend.flushAllExpected(); }), diff --git a/spec/test-utils/test-utils.js b/spec/test-utils/test-utils.js deleted file mode 100644 index b2c180205be..00000000000 --- a/spec/test-utils/test-utils.js +++ /dev/null @@ -1,369 +0,0 @@ -// load olm before the sdk if possible -import '../olm-loader'; - -import { logger } from '../../src/logger'; -import { MatrixEvent } from "../../src/models/event"; - -/** - * Return a promise that is resolved when the client next emits a - * SYNCING event. - * @param {Object} client The client - * @param {Number=} count Number of syncs to wait for (default 1) - * @return {Promise} Resolves once the client has emitted a SYNCING event - */ -export function syncPromise(client, count) { - if (count === undefined) { - count = 1; - } - if (count <= 0) { - return Promise.resolve(); - } - - const p = new Promise((resolve, reject) => { - const cb = (state) => { - logger.log(`${Date.now()} syncPromise(${count}): ${state}`); - if (state === 'SYNCING') { - resolve(); - } else { - client.once('sync', cb); - } - }; - client.once('sync', cb); - }); - - return p.then(() => { - return syncPromise(client, count-1); - }); -} - -/** - * Create a spy for an object and automatically spy its methods. - * @param {*} constr The class constructor (used with 'new') - * @param {string} name The name of the class - * @return {Object} An instantiated object with spied methods/properties. - */ -export function mock(constr, name) { - // Based on - // http://eclipsesource.com/blogs/2014/03/27/mocks-in-jasmine-tests/ - const HelperConstr = new Function(); // jshint ignore:line - HelperConstr.prototype = constr.prototype; - const result = new HelperConstr(); - result.toString = function() { - return "mock" + (name ? " of " + name : ""); - }; - for (const key of Object.getOwnPropertyNames(constr.prototype)) { // eslint-disable-line guard-for-in - try { - if (constr.prototype[key] instanceof Function) { - result[key] = jest.fn(); - } - } catch (ex) { - // Direct access to some non-function fields of DOM prototypes may - // cause exceptions. - // Overwriting will not work either in that case. - } - } - return result; -} - -/** - * Create an Event. - * @param {Object} opts Values for the event. - * @param {string} opts.type The event.type - * @param {string} opts.room The event.room_id - * @param {string} opts.sender The event.sender - * @param {string} opts.skey Optional. The state key (auto inserts empty string) - * @param {Object} opts.content The event.content - * @param {boolean} opts.event True to make a MatrixEvent. - * @return {Object} a JSON object representing this event. - */ -export function mkEvent(opts) { - if (!opts.type || !opts.content) { - throw new Error("Missing .type or .content =>" + JSON.stringify(opts)); - } - const event = { - type: opts.type, - room_id: opts.room, - sender: opts.sender || opts.user, // opts.user for backwards-compat - content: opts.content, - unsigned: opts.unsigned || {}, - event_id: "$" + Math.random() + "-" + Math.random(), - }; - if (opts.skey !== undefined) { - event.state_key = opts.skey; - } else if (["m.room.name", "m.room.topic", "m.room.create", "m.room.join_rules", - "m.room.power_levels", "m.room.topic", - "com.example.state"].includes(opts.type)) { - event.state_key = ""; - } - return opts.event ? new MatrixEvent(event) : event; -} - -/** - * Create an m.presence event. - * @param {Object} opts Values for the presence. - * @return {Object|MatrixEvent} The event - */ -export function mkPresence(opts) { - if (!opts.user) { - throw new Error("Missing user"); - } - const event = { - event_id: "$" + Math.random() + "-" + Math.random(), - type: "m.presence", - sender: opts.sender || opts.user, // opts.user for backwards-compat - content: { - avatar_url: opts.url, - displayname: opts.name, - last_active_ago: opts.ago, - presence: opts.presence || "offline", - }, - }; - return opts.event ? new MatrixEvent(event) : event; -} - -/** - * Create an m.room.member event. - * @param {Object} opts Values for the membership. - * @param {string} opts.room The room ID for the event. - * @param {string} opts.mship The content.membership for the event. - * @param {string} opts.sender The sender user ID for the event. - * @param {string} opts.skey The target user ID for the event if applicable - * e.g. for invites/bans. - * @param {string} opts.name The content.displayname for the event. - * @param {string} opts.url The content.avatar_url for the event. - * @param {boolean} opts.event True to make a MatrixEvent. - * @return {Object|MatrixEvent} The event - */ -export function mkMembership(opts) { - opts.type = "m.room.member"; - if (!opts.skey) { - opts.skey = opts.sender || opts.user; - } - if (!opts.mship) { - throw new Error("Missing .mship => " + JSON.stringify(opts)); - } - opts.content = { - membership: opts.mship, - }; - if (opts.name) { - opts.content.displayname = opts.name; - } - if (opts.url) { - opts.content.avatar_url = opts.url; - } - return mkEvent(opts); -} - -/** - * Create an m.room.message event. - * @param {Object} opts Values for the message - * @param {string} opts.room The room ID for the event. - * @param {string} opts.user The user ID for the event. - * @param {string} opts.msg Optional. The content.body for the event. - * @param {boolean} opts.event True to make a MatrixEvent. - * @return {Object|MatrixEvent} The event - */ -export function mkMessage(opts) { - opts.type = "m.room.message"; - if (!opts.msg) { - opts.msg = "Random->" + Math.random(); - } - if (!opts.room || !opts.user) { - throw new Error("Missing .room or .user from %s", opts); - } - opts.content = { - msgtype: "m.text", - body: opts.msg, - }; - return mkEvent(opts); -} - -/** - * A mock implementation of webstorage - * - * @constructor - */ -export function MockStorageApi() { - this.data = {}; -} -MockStorageApi.prototype = { - get length() { - return Object.keys(this.data).length; - }, - key: function(i) { - return Object.keys(this.data)[i]; - }, - setItem: function(k, v) { - this.data[k] = v; - }, - getItem: function(k) { - return this.data[k] || null; - }, - removeItem: function(k) { - delete this.data[k]; - }, -}; - -/** - * If an event is being decrypted, wait for it to finish being decrypted. - * - * @param {MatrixEvent} event - * @returns {Promise} promise which resolves (to `event`) when the event has been decrypted - */ -export function awaitDecryption(event) { - // An event is not always decrypted ahead of time - // getClearContent is a good signal to know whether an event has been decrypted - // already - if (event.getClearContent() !== null) { - return event; - } else { - logger.log(`${Date.now()} event ${event.getId()} is being decrypted; waiting`); - - return new Promise((resolve, reject) => { - event.once('Event.decrypted', (ev) => { - logger.log(`${Date.now()} event ${event.getId()} now decrypted`); - resolve(ev); - }); - }); - } -} - -export function HttpResponse( - httpLookups, acceptKeepalives, ignoreUnhandledSync, -) { - this.httpLookups = httpLookups; - this.acceptKeepalives = acceptKeepalives === undefined ? true : acceptKeepalives; - this.ignoreUnhandledSync = ignoreUnhandledSync; - this.pendingLookup = null; -} - -HttpResponse.prototype.request = function( - cb, method, path, qp, data, prefix, -) { - if (path === HttpResponse.KEEP_ALIVE_PATH && this.acceptKeepalives) { - return Promise.resolve(); - } - const next = this.httpLookups.shift(); - const logLine = ( - "MatrixClient[UT] RECV " + method + " " + path + " " + - "EXPECT " + (next ? next.method : next) + " " + (next ? next.path : next) - ); - logger.log(logLine); - - if (!next) { // no more things to return - if (method === "GET" && path === "/sync" && this.ignoreUnhandledSync) { - logger.log("MatrixClient[UT] Ignoring."); - return new Promise(() => {}); - } - if (this.pendingLookup) { - if (this.pendingLookup.method === method - && this.pendingLookup.path === path) { - return this.pendingLookup.promise; - } - // >1 pending thing, and they are different, whine. - expect(false).toBe( - true, ">1 pending request. You should probably handle them. " + - "PENDING: " + JSON.stringify(this.pendingLookup) + " JUST GOT: " + - method + " " + path, - ); - } - this.pendingLookup = { - promise: new Promise(() => {}), - method: method, - path: path, - }; - return this.pendingLookup.promise; - } - if (next.path === path && next.method === method) { - logger.log( - "MatrixClient[UT] Matched. Returning " + - (next.error ? "BAD" : "GOOD") + " response", - ); - if (next.expectBody) { - expect(next.expectBody).toEqual(data); - } - if (next.expectQueryParams) { - Object.keys(next.expectQueryParams).forEach(function(k) { - expect(qp[k]).toEqual(next.expectQueryParams[k]); - }); - } - - if (next.thenCall) { - process.nextTick(next.thenCall, 0); // next tick so we return first. - } - - if (next.error) { - return Promise.reject({ - errcode: next.error.errcode, - httpStatus: next.error.httpStatus, - name: next.error.errcode, - message: "Expected testing error", - data: next.error, - }); - } - return Promise.resolve(next.data); - } else if (method === "GET" && path === "/sync" && this.ignoreUnhandledSync) { - logger.log("MatrixClient[UT] Ignoring."); - this.httpLookups.unshift(next); - return new Promise(() => {}); - } - expect(true).toBe(false, "Expected different request. " + logLine); - return new Promise(() => {}); -}; - -HttpResponse.KEEP_ALIVE_PATH = "/_matrix/client/versions"; - -HttpResponse.PUSH_RULES_RESPONSE = { - method: "GET", - path: "/pushrules/", - data: {}, -}; - -HttpResponse.PUSH_RULES_RESPONSE = { - method: "GET", - path: "/pushrules/", - data: {}, -}; - -HttpResponse.USER_ID = "@alice:bar"; - -HttpResponse.filterResponse = function(userId) { - const filterPath = "/user/" + encodeURIComponent(userId) + "/filter"; - return { - method: "POST", - path: filterPath, - data: { filter_id: "f1lt3r" }, - }; -}; - -HttpResponse.SYNC_DATA = { - next_batch: "s_5_3", - presence: { events: [] }, - rooms: {}, -}; - -HttpResponse.SYNC_RESPONSE = { - method: "GET", - path: "/sync", - data: HttpResponse.SYNC_DATA, -}; - -HttpResponse.defaultResponses = function(userId) { - return [ - HttpResponse.PUSH_RULES_RESPONSE, - HttpResponse.filterResponse(userId), - HttpResponse.SYNC_RESPONSE, - ]; -}; - -export function setHttpResponses( - httpBackend, responses, -) { - responses.forEach(response => { - httpBackend - .when(response.method, response.path) - .respond(200, response.data); - }); -} - -export const emitPromise = (e, k) => new Promise(r => e.once(k, r)); diff --git a/spec/test-utils/test-utils.ts b/spec/test-utils/test-utils.ts new file mode 100644 index 00000000000..24f7b9966ec --- /dev/null +++ b/spec/test-utils/test-utils.ts @@ -0,0 +1,282 @@ +// eslint-disable-next-line no-restricted-imports +import EventEmitter from "events"; + +// load olm before the sdk if possible +import '../olm-loader'; + +import { logger } from '../../src/logger'; +import { IContent, IEvent, IUnsigned, MatrixEvent, MatrixEventEvent } from "../../src/models/event"; +import { ClientEvent, EventType, MatrixClient } from "../../src"; +import { SyncState } from "../../src/sync"; + +/** + * Return a promise that is resolved when the client next emits a + * SYNCING event. + * @param {Object} client The client + * @param {Number=} count Number of syncs to wait for (default 1) + * @return {Promise} Resolves once the client has emitted a SYNCING event + */ +export function syncPromise(client: MatrixClient, count = 1): Promise { + if (count <= 0) { + return Promise.resolve(); + } + + const p = new Promise((resolve) => { + const cb = (state: SyncState) => { + logger.log(`${Date.now()} syncPromise(${count}): ${state}`); + if (state === SyncState.Syncing) { + resolve(); + } else { + client.once(ClientEvent.Sync, cb); + } + }; + client.once(ClientEvent.Sync, cb); + }); + + return p.then(() => { + return syncPromise(client, count - 1); + }); +} + +/** + * Create a spy for an object and automatically spy its methods. + * @param {*} constr The class constructor (used with 'new') + * @param {string} name The name of the class + * @return {Object} An instantiated object with spied methods/properties. + */ +export function mock(constr: { new(...args: any[]): T }, name: string): T { + // Based on http://eclipsesource.com/blogs/2014/03/27/mocks-in-jasmine-tests/ + const HelperConstr = new Function(); // jshint ignore:line + HelperConstr.prototype = constr.prototype; + // @ts-ignore + const result = new HelperConstr(); + result.toString = function() { + return "mock" + (name ? " of " + name : ""); + }; + for (const key of Object.getOwnPropertyNames(constr.prototype)) { // eslint-disable-line guard-for-in + try { + if (constr.prototype[key] instanceof Function) { + result[key] = jest.fn(); + } + } catch (ex) { + // Direct access to some non-function fields of DOM prototypes may + // cause exceptions. + // Overwriting will not work either in that case. + } + } + return result; +} + +interface IEventOpts { + type: EventType | string; + room: string; + sender?: string; + skey?: string; + content: IContent; + event?: boolean; + user?: string; + unsigned?: IUnsigned; + redacts?: string; +} + +/** + * Create an Event. + * @param {Object} opts Values for the event. + * @param {string} opts.type The event.type + * @param {string} opts.room The event.room_id + * @param {string} opts.sender The event.sender + * @param {string} opts.skey Optional. The state key (auto inserts empty string) + * @param {Object} opts.content The event.content + * @param {boolean} opts.event True to make a MatrixEvent. + * @return {Object} a JSON object representing this event. + */ +export function mkEvent(opts: IEventOpts): object | MatrixEvent { + if (!opts.type || !opts.content) { + throw new Error("Missing .type or .content =>" + JSON.stringify(opts)); + } + const event: Partial = { + type: opts.type as string, + room_id: opts.room, + sender: opts.sender || opts.user, // opts.user for backwards-compat + content: opts.content, + unsigned: opts.unsigned || {}, + event_id: "$" + Math.random() + "-" + Math.random(), + redacts: opts.redacts, + }; + if (opts.skey !== undefined) { + event.state_key = opts.skey; + } else if ([ + EventType.RoomName, + EventType.RoomTopic, + EventType.RoomCreate, + EventType.RoomJoinRules, + EventType.RoomPowerLevels, + EventType.RoomTopic, + "com.example.state", + ].includes(opts.type)) { + event.state_key = ""; + } + return opts.event ? new MatrixEvent(event) : event; +} + +interface IPresenceOpts { + user?: string; + sender?: string; + url: string; + name: string; + ago: number; + presence?: string; + event?: boolean; +} + +/** + * Create an m.presence event. + * @param {Object} opts Values for the presence. + * @return {Object|MatrixEvent} The event + */ +export function mkPresence(opts: IPresenceOpts): object | MatrixEvent { + const event = { + event_id: "$" + Math.random() + "-" + Math.random(), + type: "m.presence", + sender: opts.sender || opts.user, // opts.user for backwards-compat + content: { + avatar_url: opts.url, + displayname: opts.name, + last_active_ago: opts.ago, + presence: opts.presence || "offline", + }, + }; + return opts.event ? new MatrixEvent(event) : event; +} + +interface IMembershipOpts { + room: string; + mship: string; + sender?: string; + user?: string; + skey?: string; + name?: string; + url?: string; + event?: boolean; +} + +/** + * Create an m.room.member event. + * @param {Object} opts Values for the membership. + * @param {string} opts.room The room ID for the event. + * @param {string} opts.mship The content.membership for the event. + * @param {string} opts.sender The sender user ID for the event. + * @param {string} opts.skey The target user ID for the event if applicable + * e.g. for invites/bans. + * @param {string} opts.name The content.displayname for the event. + * @param {string} opts.url The content.avatar_url for the event. + * @param {boolean} opts.event True to make a MatrixEvent. + * @return {Object|MatrixEvent} The event + */ +export function mkMembership(opts: IMembershipOpts): object | MatrixEvent { + const eventOpts: IEventOpts = { + ...opts, + type: EventType.RoomMember, + content: { + membership: opts.mship, + }, + }; + + if (!opts.skey) { + eventOpts.skey = opts.sender || opts.user; + } + if (opts.name) { + eventOpts.content.displayname = opts.name; + } + if (opts.url) { + eventOpts.content.avatar_url = opts.url; + } + return mkEvent(eventOpts); +} + +interface IMessageOpts { + room: string; + user: string; + msg?: string; + event?: boolean; +} + +/** + * Create an m.room.message event. + * @param {Object} opts Values for the message + * @param {string} opts.room The room ID for the event. + * @param {string} opts.user The user ID for the event. + * @param {string} opts.msg Optional. The content.body for the event. + * @param {boolean} opts.event True to make a MatrixEvent. + * @return {Object|MatrixEvent} The event + */ +export function mkMessage(opts: IMessageOpts): object | MatrixEvent { + const eventOpts: IEventOpts = { + ...opts, + type: EventType.RoomMessage, + content: { + msgtype: "m.text", + body: opts.msg, + }, + }; + + if (!eventOpts.content.body) { + eventOpts.content.body = "Random->" + Math.random(); + } + return mkEvent(eventOpts); +} + +/** + * A mock implementation of webstorage + * + * @constructor + */ +export class MockStorageApi { + private data: Record = {}; + + public get length() { + return Object.keys(this.data).length; + } + + public key(i: number): any { + return Object.keys(this.data)[i]; + } + + public setItem(k: string, v: any): void { + this.data[k] = v; + } + + public getItem(k: string): any { + return this.data[k] || null; + } + + public removeItem(k: string): void { + delete this.data[k]; + } +} + +/** + * If an event is being decrypted, wait for it to finish being decrypted. + * + * @param {MatrixEvent} event + * @returns {Promise} promise which resolves (to `event`) when the event has been decrypted + */ +export async function awaitDecryption(event: MatrixEvent): Promise { + // An event is not always decrypted ahead of time + // getClearContent is a good signal to know whether an event has been decrypted + // already + if (event.getClearContent() !== null) { + return event; + } else { + logger.log(`${Date.now()} event ${event.getId()} is being decrypted; waiting`); + + return new Promise((resolve) => { + event.once(MatrixEventEvent.Decrypted, (ev) => { + logger.log(`${Date.now()} event ${event.getId()} now decrypted`); + resolve(ev); + }); + }); + } +} + +export const emitPromise = (e: EventEmitter, k: string): Promise => new Promise(r => e.once(k, r)); diff --git a/spec/unit/crypto/cross-signing.spec.js b/spec/unit/crypto/cross-signing.spec.js index 691c1612ff0..f8639781b80 100644 --- a/spec/unit/crypto/cross-signing.spec.js +++ b/spec/unit/crypto/cross-signing.spec.js @@ -20,11 +20,33 @@ import anotherjson from 'another-json'; import * as olmlib from "../../../src/crypto/olmlib"; import { TestClient } from '../../TestClient'; -import { HttpResponse, setHttpResponses } from '../../test-utils/test-utils'; import { resetCrossSigningKeys } from "./crypto-utils"; import { MatrixError } from '../../../src/http-api'; import { logger } from '../../../src/logger'; +const PUSH_RULES_RESPONSE = { + method: "GET", + path: "/pushrules/", + data: {}, +}; + +const filterResponse = function(userId) { + const filterPath = "/user/" + encodeURIComponent(userId) + "/filter"; + return { + method: "POST", + path: filterPath, + data: { filter_id: "f1lt3r" }, + }; +}; + +function setHttpResponses(httpBackend, responses) { + responses.forEach(response => { + httpBackend + .when(response.method, response.path) + .respond(200, response.data); + }); +} + async function makeTestClient(userInfo, options, keys) { if (!keys) keys = {}; @@ -237,7 +259,7 @@ describe("Cross Signing", function() { // feed sync result that includes master key, ssk, device key const responses = [ - HttpResponse.PUSH_RULES_RESPONSE, + PUSH_RULES_RESPONSE, { method: "POST", path: "/keys/upload", @@ -248,7 +270,7 @@ describe("Cross Signing", function() { }, }, }, - HttpResponse.filterResponse("@alice:example.com"), + filterResponse("@alice:example.com"), { method: "GET", path: "/sync", @@ -493,7 +515,7 @@ describe("Cross Signing", function() { // - master key signed by her usk (pretend that it was signed by another // of Alice's devices) const responses = [ - HttpResponse.PUSH_RULES_RESPONSE, + PUSH_RULES_RESPONSE, { method: "POST", path: "/keys/upload", @@ -504,7 +526,7 @@ describe("Cross Signing", function() { }, }, }, - HttpResponse.filterResponse("@alice:example.com"), + filterResponse("@alice:example.com"), { method: "GET", path: "/sync", diff --git a/spec/unit/filter-component.spec.ts b/spec/unit/filter-component.spec.ts index 6773556e4ba..47ffb37cf50 100644 --- a/spec/unit/filter-component.spec.ts +++ b/spec/unit/filter-component.spec.ts @@ -1,4 +1,5 @@ import { + MatrixEvent, RelationType, } from "../../src"; import { FilterComponent } from "../../src/filter-component"; @@ -13,7 +14,7 @@ describe("Filter Component", function() { content: { }, room: 'roomId', event: true, - }); + }) as MatrixEvent; const checkResult = filter.check(event); @@ -27,7 +28,7 @@ describe("Filter Component", function() { content: { }, room: 'roomId', event: true, - }); + }) as MatrixEvent; const checkResult = filter.check(event); @@ -54,7 +55,7 @@ describe("Filter Component", function() { }, }, }, - }); + }) as MatrixEvent; expect(filter.check(threadRootNotParticipated)).toBe(false); }); @@ -79,7 +80,7 @@ describe("Filter Component", function() { user: '@someone-else:server.org', room: 'roomId', event: true, - }); + }) as MatrixEvent; expect(filter.check(threadRootParticipated)).toBe(true); }); @@ -99,7 +100,7 @@ describe("Filter Component", function() { [RelationType.Reference]: {}, }, }, - }); + }) as MatrixEvent; expect(filter.check(referenceRelationEvent)).toBe(false); }); @@ -122,7 +123,7 @@ describe("Filter Component", function() { }, room: 'roomId', event: true, - }); + }) as MatrixEvent; const eventWithMultipleRelations = mkEvent({ "type": "m.room.message", @@ -147,7 +148,7 @@ describe("Filter Component", function() { }, "room": 'roomId', "event": true, - }); + }) as MatrixEvent; const noMatchEvent = mkEvent({ "type": "m.room.message", @@ -159,7 +160,7 @@ describe("Filter Component", function() { }, "room": 'roomId', "event": true, - }); + }) as MatrixEvent; expect(filter.check(threadRootEvent)).toBe(true); expect(filter.check(eventWithMultipleRelations)).toBe(true); diff --git a/spec/unit/matrix-client.spec.ts b/spec/unit/matrix-client.spec.ts index 251ade94c48..c49bdccc673 100644 --- a/spec/unit/matrix-client.spec.ts +++ b/spec/unit/matrix-client.spec.ts @@ -32,6 +32,7 @@ import { Preset } from "../../src/@types/partials"; import * as testUtils from "../test-utils/test-utils"; import { makeBeaconInfoContent } from "../../src/content-helpers"; import { M_BEACON_INFO } from "../../src/@types/beacon"; +import { Room } from "../../src"; jest.useFakeTimers(); @@ -957,6 +958,7 @@ describe("MatrixClient", function() { it("partitions root events to room timeline and thread timeline", () => { const supportsExperimentalThreads = client.supportsExperimentalThreads; client.supportsExperimentalThreads = () => true; + const room = new Room("!room1:matrix.org", client, userId); const rootEvent = new MatrixEvent({ "content": {}, @@ -979,9 +981,9 @@ describe("MatrixClient", function() { expect(rootEvent.isThreadRoot).toBe(true); - const [room, threads] = client.partitionThreadedEvents([rootEvent]); - expect(room).toHaveLength(1); - expect(threads).toHaveLength(1); + const [roomEvents, threadEvents] = client.partitionThreadedEvents(room, [rootEvent]); + expect(roomEvents).toHaveLength(1); + expect(threadEvents).toHaveLength(1); // Restore method client.supportsExperimentalThreads = supportsExperimentalThreads; diff --git a/spec/unit/room.spec.ts b/spec/unit/room.spec.ts index 6977c862f89..faa73ba29b9 100644 --- a/spec/unit/room.spec.ts +++ b/spec/unit/room.spec.ts @@ -20,7 +20,16 @@ limitations under the License. */ import * as utils from "../test-utils/test-utils"; -import { DuplicateStrategy, EventStatus, MatrixEvent, PendingEventOrdering, RoomEvent } from "../../src"; +import { + DuplicateStrategy, + EventStatus, + EventType, + JoinRule, + MatrixEvent, + PendingEventOrdering, + RelationType, + RoomEvent, +} from "../../src"; import { EventTimeline } from "../../src/models/event-timeline"; import { Room } from "../../src/models/room"; import { RoomState } from "../../src/models/room-state"; @@ -38,10 +47,8 @@ describe("Room", function() { beforeEach(function() { room = new Room(roomId, null, userA); // mock RoomStates - room.oldState = room.getLiveTimeline().startState = - utils.mock(RoomState, "oldState"); - room.currentState = room.getLiveTimeline().endState = - utils.mock(RoomState, "currentState"); + room.oldState = room.getLiveTimeline().startState = utils.mock(RoomState, "oldState"); + room.currentState = room.getLiveTimeline().endState = utils.mock(RoomState, "currentState"); }); describe("getAvatarUrl", function() { @@ -49,10 +56,10 @@ describe("Room", function() { it("should return the URL from m.room.avatar preferentially", function() { room.currentState.getStateEvents.mockImplementation(function(type, key) { - if (type === "m.room.avatar" && key === "") { + if (type === EventType.RoomAvatar && key === "") { return utils.mkEvent({ event: true, - type: "m.room.avatar", + type: EventType.RoomAvatar, skey: "", room: roomId, user: userA, @@ -97,20 +104,20 @@ describe("Room", function() { }); describe("addLiveEvents", function() { - const events = [ + const events: MatrixEvent[] = [ utils.mkMessage({ room: roomId, user: userA, msg: "changing room name", event: true, - }), + }) as MatrixEvent, utils.mkEvent({ - type: "m.room.name", room: roomId, user: userA, event: true, + type: EventType.RoomName, room: roomId, user: userA, event: true, content: { name: "New Room Name" }, - }), + }) as MatrixEvent, ]; it("should call RoomState.setTypingEvent on m.typing events", function() { const typing = utils.mkEvent({ room: roomId, - type: "m.typing", + type: EventType.Typing, event: true, content: { user_ids: [userA], @@ -130,7 +137,7 @@ describe("Room", function() { // make a duplicate const dupe = utils.mkMessage({ room: roomId, user: userA, msg: "dupe", event: true, - }); + }) as MatrixEvent; dupe.event.event_id = events[0].getId(); room.addLiveEvents(events); expect(room.timeline[0]).toEqual(events[0]); @@ -142,7 +149,7 @@ describe("Room", function() { // make a duplicate const dupe = utils.mkMessage({ room: roomId, user: userA, msg: "dupe", event: true, - }); + }) as MatrixEvent; dupe.event.event_id = events[0].getId(); room.addLiveEvents(events); expect(room.timeline[0]).toEqual(events[0]); @@ -166,16 +173,16 @@ describe("Room", function() { it("should call setStateEvents on the right RoomState with the right forwardLooking value for new events", function() { - const events = [ + const events: MatrixEvent[] = [ utils.mkMembership({ room: roomId, mship: "invite", user: userB, skey: userA, event: true, - }), + }) as MatrixEvent, utils.mkEvent({ - type: "m.room.name", room: roomId, user: userB, event: true, + type: EventType.RoomName, room: roomId, user: userB, event: true, content: { name: "New room", }, - }), + }) as MatrixEvent, ]; room.addLiveEvents(events); expect(room.currentState.setStateEvents).toHaveBeenCalledWith( @@ -208,13 +215,13 @@ describe("Room", function() { it("should emit Room.localEchoUpdated when a local echo is updated", function() { const localEvent = utils.mkMessage({ room: roomId, user: userA, event: true, - }); + }) as MatrixEvent; localEvent.status = EventStatus.SENDING; const localEventId = localEvent.getId(); const remoteEvent = utils.mkMessage({ room: roomId, user: userA, event: true, - }); + }) as MatrixEvent; remoteEvent.event.unsigned = { transaction_id: "TXN_ID" }; const remoteEventId = remoteEvent.getId(); @@ -259,7 +266,7 @@ describe("Room", function() { room: roomId, user: userA, msg: "changing room name", event: true, }), utils.mkEvent({ - type: "m.room.name", room: roomId, user: userA, event: true, + type: EventType.RoomName, room: roomId, user: userA, event: true, content: { name: "New Room Name" }, }), ]; @@ -318,13 +325,13 @@ describe("Room", function() { }); const newEv = utils.mkEvent({ - type: "m.room.name", room: roomId, user: userA, event: true, + type: EventType.RoomName, room: roomId, user: userA, event: true, content: { name: "New Room Name" }, - }); + }) as MatrixEvent; const oldEv = utils.mkEvent({ - type: "m.room.name", room: roomId, user: userA, event: true, + type: EventType.RoomName, room: roomId, user: userA, event: true, content: { name: "Old Room Name" }, - }); + }) as MatrixEvent; room.addLiveEvents([newEv]); expect(newEv.sender).toEqual(sentinel); room.addEventsToTimeline([oldEv], true, room.getLiveTimeline()); @@ -358,10 +365,10 @@ describe("Room", function() { const newEv = utils.mkMembership({ room: roomId, mship: "invite", user: userB, skey: userA, event: true, - }); + }) as MatrixEvent; const oldEv = utils.mkMembership({ room: roomId, mship: "ban", user: userB, skey: userA, event: true, - }); + }) as MatrixEvent; room.addLiveEvents([newEv]); expect(newEv.target).toEqual(sentinel); room.addEventsToTimeline([oldEv], true, room.getLiveTimeline()); @@ -370,16 +377,16 @@ describe("Room", function() { it("should call setStateEvents on the right RoomState with the right " + "forwardLooking value for old events", function() { - const events = [ + const events: MatrixEvent[] = [ utils.mkMembership({ room: roomId, mship: "invite", user: userB, skey: userA, event: true, - }), + }) as MatrixEvent, utils.mkEvent({ - type: "m.room.name", room: roomId, user: userB, event: true, + type: EventType.RoomName, room: roomId, user: userB, event: true, content: { name: "New room", }, - }), + }) as MatrixEvent, ]; room.addEventsToTimeline(events, true, room.getLiveTimeline()); @@ -407,11 +414,11 @@ describe("Room", function() { room: roomId, user: userA, msg: "A message", event: true, }), utils.mkEvent({ - type: "m.room.name", room: roomId, user: userA, event: true, + type: EventType.RoomName, room: roomId, user: userA, event: true, content: { name: "New Room Name" }, }), utils.mkEvent({ - type: "m.room.name", room: roomId, user: userA, event: true, + type: EventType.RoomName, room: roomId, user: userA, event: true, content: { name: "Another New Name" }, }), ]; @@ -426,8 +433,8 @@ describe("Room", function() { const oldState = room.getLiveTimeline().getState(EventTimeline.BACKWARDS); const newState = room.getLiveTimeline().getState(EventTimeline.FORWARDS); expect(room.getLiveTimeline().getEvents().length).toEqual(1); - expect(oldState.getStateEvents("m.room.name", "")).toEqual(events[1]); - expect(newState.getStateEvents("m.room.name", "")).toEqual(events[2]); + expect(oldState.getStateEvents(EventType.RoomName, "")).toEqual(events[1]); + expect(newState.getStateEvents(EventType.RoomName, "")).toEqual(events[2]); }); it("should reset the legacy timeline fields", function() { @@ -474,26 +481,24 @@ describe("Room", function() { }); }; - describe("resetLiveTimeline with timelinesupport enabled", - resetTimelineTests.bind(null, true)); - describe("resetLiveTimeline with timelinesupport disabled", - resetTimelineTests.bind(null, false)); + describe("resetLiveTimeline with timeline support enabled", resetTimelineTests.bind(null, true)); + describe("resetLiveTimeline with timeline support disabled", resetTimelineTests.bind(null, false)); describe("compareEventOrdering", function() { beforeEach(function() { room = new Room(roomId, null, null, { timelineSupport: true }); }); - const events = [ + const events: MatrixEvent[] = [ utils.mkMessage({ room: roomId, user: userA, msg: "1111", event: true, - }), + }) as MatrixEvent, utils.mkMessage({ room: roomId, user: userA, msg: "2222", event: true, - }), + }) as MatrixEvent, utils.mkMessage({ room: roomId, user: userA, msg: "3333", event: true, - }), + }) as MatrixEvent, ]; it("should handle events in the same timeline", function() { @@ -629,39 +634,39 @@ describe("Room", function() { }); describe("recalculate", function() { - const setJoinRule = function(rule) { + const setJoinRule = function(rule: JoinRule) { room.addLiveEvents([utils.mkEvent({ - type: "m.room.join_rules", room: roomId, user: userA, content: { + type: EventType.RoomJoinRules, room: roomId, user: userA, content: { join_rule: rule, }, event: true, - })]); + }) as MatrixEvent]); }; - const setAltAliases = function(aliases) { + const setAltAliases = function(aliases: string[]) { room.addLiveEvents([utils.mkEvent({ - type: "m.room.canonical_alias", room: roomId, skey: "", content: { + type: EventType.RoomCanonicalAlias, room: roomId, skey: "", content: { alt_aliases: aliases, }, event: true, - })]); + }) as MatrixEvent]); }; - const setAlias = function(alias) { + const setAlias = function(alias: string) { room.addLiveEvents([utils.mkEvent({ - type: "m.room.canonical_alias", room: roomId, skey: "", content: { alias }, event: true, - })]); + type: EventType.RoomCanonicalAlias, room: roomId, skey: "", content: { alias }, event: true, + }) as MatrixEvent]); }; - const setRoomName = function(name) { + const setRoomName = function(name: string) { room.addLiveEvents([utils.mkEvent({ - type: "m.room.name", room: roomId, user: userA, content: { + type: EventType.RoomName, room: roomId, user: userA, content: { name: name, }, event: true, - })]); + }) as MatrixEvent]); }; - const addMember = function(userId, state = "join", opts: any = {}) { + const addMember = function(userId: string, state = "join", opts: any = {}) { opts.room = roomId; opts.mship = state; opts.user = opts.user || userId; opts.skey = userId; opts.event = true; - const event = utils.mkMembership(opts); + const event = utils.mkMembership(opts) as MatrixEvent; room.addLiveEvents([event]); return event; }; @@ -678,15 +683,14 @@ describe("Room", function() { const event = addMember(userA, "invite"); event.event.unsigned = {}; - event.event.unsigned.invite_room_state = [ - { - type: "m.room.name", - state_key: "", - content: { - name: roomName, - }, + event.event.unsigned.invite_room_state = [{ + type: EventType.RoomName, + state_key: "", + content: { + name: roomName, }, - ]; + sender: "@bob:foobar", + }]; room.recalculate(); expect(room.name).toEqual(roomName); @@ -698,15 +702,14 @@ describe("Room", function() { setRoomName(roomName); const roomNameToIgnore = "ignoreme"; event.event.unsigned = {}; - event.event.unsigned.invite_room_state = [ - { - type: "m.room.name", - state_key: "", - content: { - name: roomNameToIgnore, - }, + event.event.unsigned.invite_room_state = [{ + type: EventType.RoomName, + state_key: "", + content: { + name: roomNameToIgnore, }, - ]; + sender: "@bob:foobar", + }]; room.recalculate(); expect(room.name).toEqual(roomName); @@ -798,7 +801,7 @@ describe("Room", function() { it("should return the names of members in a private (invite join_rules)" + " room if a room name and alias don't exist and there are >3 members.", function() { - setJoinRule("invite"); + setJoinRule(JoinRule.Invite); addMember(userA); addMember(userB); addMember(userC); @@ -818,9 +821,8 @@ describe("Room", function() { }); it("should return the names of members in a private (invite join_rules)" + - " room if a room name and alias don't exist and there are >2 members.", - function() { - setJoinRule("invite"); + " room if a room name and alias don't exist and there are >2 members.", function() { + setJoinRule(JoinRule.Invite); addMember(userA); addMember(userB); addMember(userC); @@ -831,9 +833,8 @@ describe("Room", function() { }); it("should return the names of members in a public (public join_rules)" + - " room if a room name and alias don't exist and there are >2 members.", - function() { - setJoinRule("public"); + " room if a room name and alias don't exist and there are >2 members.", function() { + setJoinRule(JoinRule.Public); addMember(userA); addMember(userB); addMember(userC); @@ -844,9 +845,8 @@ describe("Room", function() { }); it("should show the other user's name for public (public join_rules)" + - " rooms if a room name and alias don't exist and it is a 1:1-chat.", - function() { - setJoinRule("public"); + " rooms if a room name and alias don't exist and it is a 1:1-chat.", function() { + setJoinRule(JoinRule.Public); addMember(userA); addMember(userB); room.recalculate(); @@ -857,7 +857,7 @@ describe("Room", function() { it("should show the other user's name for private " + "(invite join_rules) rooms if a room name and alias don't exist and it" + " is a 1:1-chat.", function() { - setJoinRule("invite"); + setJoinRule(JoinRule.Invite); addMember(userA); addMember(userB); room.recalculate(); @@ -867,7 +867,7 @@ describe("Room", function() { it("should show the other user's name for private" + " (invite join_rules) rooms if you are invited to it.", function() { - setJoinRule("invite"); + setJoinRule(JoinRule.Invite); addMember(userA, "invite", { user: userB }); addMember(userB); room.recalculate(); @@ -878,7 +878,7 @@ describe("Room", function() { it("should show the room alias if one exists for private " + "(invite join_rules) rooms if a room name doesn't exist.", function() { const alias = "#room_alias:here"; - setJoinRule("invite"); + setJoinRule(JoinRule.Invite); setAlias(alias); room.recalculate(); const name = room.name; @@ -888,7 +888,7 @@ describe("Room", function() { it("should show the room alias if one exists for public " + "(public join_rules) rooms if a room name doesn't exist.", function() { const alias = "#room_alias:here"; - setJoinRule("public"); + setJoinRule(JoinRule.Public); setAlias(alias); room.recalculate(); const name = room.name; @@ -906,7 +906,7 @@ describe("Room", function() { it("should show the room name if one exists for private " + "(invite join_rules) rooms.", function() { const roomName = "A mighty name indeed"; - setJoinRule("invite"); + setJoinRule(JoinRule.Invite); setRoomName(roomName); room.recalculate(); const name = room.name; @@ -916,7 +916,7 @@ describe("Room", function() { it("should show the room name if one exists for public " + "(public join_rules) rooms.", function() { const roomName = "A mighty name indeed"; - setJoinRule("public"); + setJoinRule(JoinRule.Public); setRoomName(roomName); room.recalculate(); expect(room.name).toEqual(roomName); @@ -924,7 +924,7 @@ describe("Room", function() { it("should return 'Empty room' for private (invite join_rules) rooms if" + " a room name and alias don't exist and it is a self-chat.", function() { - setJoinRule("invite"); + setJoinRule(JoinRule.Invite); addMember(userA); room.recalculate(); expect(room.name).toEqual("Empty room"); @@ -932,7 +932,7 @@ describe("Room", function() { it("should return 'Empty room' for public (public join_rules) rooms if a" + " room name and alias don't exist and it is a self-chat.", function() { - setJoinRule("public"); + setJoinRule(JoinRule.Public); addMember(userA); room.recalculate(); const name = room.name; @@ -950,7 +950,7 @@ describe("Room", function() { it("should return '[inviter display name] if state event " + "available", function() { - setJoinRule("invite"); + setJoinRule(JoinRule.Invite); addMember(userB, 'join', { name: "Alice" }); addMember(userA, "invite", { user: userA }); room.recalculate(); @@ -960,7 +960,7 @@ describe("Room", function() { it("should return inviter mxid if display name not available", function() { - setJoinRule("invite"); + setJoinRule(JoinRule.Invite); addMember(userB); addMember(userA, "invite", { user: userA }); room.recalculate(); @@ -974,9 +974,9 @@ describe("Room", function() { const eventToAck = utils.mkMessage({ room: roomId, user: userA, msg: "PLEASE ACKNOWLEDGE MY EXISTENCE", event: true, - }); + }) as MatrixEvent; - function mkReceipt(roomId, records) { + function mkReceipt(roomId: string, records) { const content = {}; records.forEach(function(r) { if (!content[r.eventId]) { @@ -996,7 +996,7 @@ describe("Room", function() { }); } - function mkRecord(eventId, type, userId, ts) { + function mkRecord(eventId: string, type: string, userId: string, ts: number) { ts = ts || Date.now(); return { eventId: eventId, @@ -1007,20 +1007,19 @@ describe("Room", function() { } describe("addReceipt", function() { - it("should store the receipt so it can be obtained via getReceiptsForEvent", - function() { - const ts = 13787898424; - room.addReceipt(mkReceipt(roomId, [ - mkRecord(eventToAck.getId(), "m.read", userB, ts), - ])); - expect(room.getReceiptsForEvent(eventToAck)).toEqual([{ - type: "m.read", - userId: userB, - data: { - ts: ts, - }, - }]); - }); + it("should store the receipt so it can be obtained via getReceiptsForEvent", function() { + const ts = 13787898424; + room.addReceipt(mkReceipt(roomId, [ + mkRecord(eventToAck.getId(), "m.read", userB, ts), + ])); + expect(room.getReceiptsForEvent(eventToAck)).toEqual([{ + type: "m.read", + userId: userB, + data: { + ts: ts, + }, + }]); + }); it("should emit an event when a receipt is added", function() { @@ -1041,7 +1040,7 @@ describe("Room", function() { const nextEventToAck = utils.mkMessage({ room: roomId, user: userA, msg: "I AM HERE YOU KNOW", event: true, - }); + }) as MatrixEvent; const ts = 13787898424; room.addReceipt(mkReceipt(roomId, [ mkRecord(eventToAck.getId(), "m.read", userB, ts), @@ -1076,11 +1075,11 @@ describe("Room", function() { const eventTwo = utils.mkMessage({ room: roomId, user: userA, msg: "2222", event: true, - }); + }) as MatrixEvent; const eventThree = utils.mkMessage({ room: roomId, user: userA, msg: "3333", event: true, - }); + }) as MatrixEvent; const ts = 13787898424; room.addReceipt(mkReceipt(roomId, [ mkRecord(eventToAck.getId(), "m.read", userB, ts), @@ -1124,19 +1123,19 @@ describe("Room", function() { }); it("should prioritise the most recent event", function() { - const events = [ + const events: MatrixEvent[] = [ utils.mkMessage({ room: roomId, user: userA, msg: "1111", event: true, - }), + }) as MatrixEvent, utils.mkMessage({ room: roomId, user: userA, msg: "2222", event: true, - }), + }) as MatrixEvent, utils.mkMessage({ room: roomId, user: userA, msg: "3333", event: true, - }), + }) as MatrixEvent, ]; room.addLiveEvents(events); @@ -1162,19 +1161,19 @@ describe("Room", function() { }); it("should prioritise the most recent event even if it is synthetic", () => { - const events = [ + const events: MatrixEvent[] = [ utils.mkMessage({ room: roomId, user: userA, msg: "1111", event: true, - }), + }) as MatrixEvent, utils.mkMessage({ room: roomId, user: userA, msg: "2222", event: true, - }), + }) as MatrixEvent, utils.mkMessage({ room: roomId, user: userA, msg: "3333", event: true, - }), + }) as MatrixEvent, ]; room.addLiveEvents(events); @@ -1265,14 +1264,14 @@ describe("Room", function() { }); const eventA = utils.mkMessage({ room: roomId, user: userA, msg: "remote 1", event: true, - }); + }) as MatrixEvent; const eventB = utils.mkMessage({ room: roomId, user: userA, msg: "local 1", event: true, - }); + }) as MatrixEvent; eventB.status = EventStatus.SENDING; const eventC = utils.mkMessage({ room: roomId, user: userA, msg: "remote 2", event: true, - }); + }) as MatrixEvent; room.addLiveEvents([eventA]); room.addPendingEvent(eventB, "TXN1"); room.addLiveEvents([eventC]); @@ -1291,14 +1290,14 @@ describe("Room", function() { }); const eventA = utils.mkMessage({ room: roomId, user: userA, msg: "remote 1", event: true, - }); + }) as MatrixEvent; const eventB = utils.mkMessage({ room: roomId, user: userA, msg: "local 1", event: true, - }); + }) as MatrixEvent; eventB.status = EventStatus.SENDING; const eventC = utils.mkMessage({ room: roomId, user: userA, msg: "remote 2", event: true, - }); + }) as MatrixEvent; room.addLiveEvents([eventA]); room.addPendingEvent(eventB, "TXN1"); room.addLiveEvents([eventC]); @@ -1318,7 +1317,7 @@ describe("Room", function() { }); const eventA = utils.mkMessage({ room: roomId, user: userA, event: true, - }); + }) as MatrixEvent; eventA.status = EventStatus.SENDING; const eventId = eventA.getId(); @@ -1351,7 +1350,7 @@ describe("Room", function() { const room = new Room(roomId, null, userA); const eventA = utils.mkMessage({ room: roomId, user: userA, event: true, - }); + }) as MatrixEvent; eventA.status = EventStatus.SENDING; const eventId = eventA.getId(); @@ -1424,9 +1423,12 @@ describe("Room", function() { } const memberEvent = utils.mkMembership({ - user: "@user_a:bar", mship: "join", - room: roomId, event: true, name: "User A", - }); + user: "@user_a:bar", + mship: "join", + room: roomId, + event: true, + name: "User A", + }) as MatrixEvent; it("should load members from server on first call", async function() { const client = createClientMock([memberEvent]); @@ -1441,9 +1443,12 @@ describe("Room", function() { it("should take members from storage if available", async function() { const memberEvent2 = utils.mkMembership({ - user: "@user_a:bar", mship: "join", - room: roomId, event: true, name: "Ms A", - }); + user: "@user_a:bar", + mship: "join", + room: roomId, + event: true, + name: "Ms A", + }) as MatrixEvent; const client = createClientMock([memberEvent2], [memberEvent]); const room = new Room(roomId, client as any, null, { lazyLoadMembers: true }); @@ -1475,8 +1480,8 @@ describe("Room", function() { it("should return synced membership if membership isn't available yet", function() { const room = new Room(roomId, null, userA); - room.updateMyMembership("invite"); - expect(room.getMyMembership()).toEqual("invite"); + room.updateMyMembership(JoinRule.Invite); + expect(room.getMyMembership()).toEqual(JoinRule.Invite); }); it("should emit a Room.myMembership event on a change", function() { @@ -1485,11 +1490,11 @@ describe("Room", function() { room.on(RoomEvent.MyMembership, (_room, membership, oldMembership) => { events.push({ membership, oldMembership }); }); - room.updateMyMembership("invite"); - expect(room.getMyMembership()).toEqual("invite"); + room.updateMyMembership(JoinRule.Invite); + expect(room.getMyMembership()).toEqual(JoinRule.Invite); expect(events[0]).toEqual({ membership: "invite", oldMembership: null }); events.splice(0); //clear - room.updateMyMembership("invite"); + room.updateMyMembership(JoinRule.Invite); expect(events.length).toEqual(0); room.updateMyMembership("join"); expect(room.getMyMembership()).toEqual("join"); @@ -1498,374 +1503,537 @@ describe("Room", function() { }); describe("guessDMUserId", function() { - it("should return first hero id", - function() { - const room = new Room(roomId, null, userA); - room.setSummary({ - 'm.heroes': [userB], - 'm.joined_member_count': 1, - 'm.invited_member_count': 1, - }); - expect(room.guessDMUserId()).toEqual(userB); - }); - it("should return first member that isn't self", - function() { - const room = new Room(roomId, null, userA); - room.addLiveEvents([utils.mkMembership({ - user: userB, mship: "join", - room: roomId, event: true, - })]); - expect(room.guessDMUserId()).toEqual(userB); - }); - it("should return self if only member present", - function() { - const room = new Room(roomId, null, userA); - expect(room.guessDMUserId()).toEqual(userA); + it("should return first hero id", function() { + const room = new Room(roomId, null, userA); + room.setSummary({ + 'm.heroes': [userB], + 'm.joined_member_count': 1, + 'm.invited_member_count': 1, }); + expect(room.guessDMUserId()).toEqual(userB); + }); + it("should return first member that isn't self", function() { + const room = new Room(roomId, null, userA); + room.addLiveEvents([utils.mkMembership({ + user: userB, + mship: "join", + room: roomId, + event: true, + }) as MatrixEvent]); + expect(room.guessDMUserId()).toEqual(userB); + }); + it("should return self if only member present", function() { + const room = new Room(roomId, null, userA); + expect(room.guessDMUserId()).toEqual(userA); + }); }); describe("maySendMessage", function() { - it("should return false if synced membership not join", - function() { - const room = new Room(roomId, { isRoomEncrypted: () => false } as any, userA); - room.updateMyMembership("invite"); - expect(room.maySendMessage()).toEqual(false); - room.updateMyMembership("leave"); - expect(room.maySendMessage()).toEqual(false); - room.updateMyMembership("join"); - expect(room.maySendMessage()).toEqual(true); - }); + it("should return false if synced membership not join", function() { + const room = new Room(roomId, { isRoomEncrypted: () => false } as any, userA); + room.updateMyMembership(JoinRule.Invite); + expect(room.maySendMessage()).toEqual(false); + room.updateMyMembership("leave"); + expect(room.maySendMessage()).toEqual(false); + room.updateMyMembership("join"); + expect(room.maySendMessage()).toEqual(true); + }); }); describe("getDefaultRoomName", function() { - it("should return 'Empty room' if a user is the only member", - function() { - const room = new Room(roomId, null, userA); - expect(room.getDefaultRoomName(userA)).toEqual("Empty room"); - }); + it("should return 'Empty room' if a user is the only member", function() { + const room = new Room(roomId, null, userA); + expect(room.getDefaultRoomName(userA)).toEqual("Empty room"); + }); - it("should return a display name if one other member is in the room", - function() { - const room = new Room(roomId, null, userA); - room.addLiveEvents([ - utils.mkMembership({ - user: userA, mship: "join", - room: roomId, event: true, name: "User A", - }), - utils.mkMembership({ - user: userB, mship: "join", - room: roomId, event: true, name: "User B", - }), - ]); - expect(room.getDefaultRoomName(userA)).toEqual("User B"); - }); + it("should return a display name if one other member is in the room", function() { + const room = new Room(roomId, null, userA); + room.addLiveEvents([ + utils.mkMembership({ + user: userA, mship: "join", + room: roomId, event: true, name: "User A", + }) as MatrixEvent, + utils.mkMembership({ + user: userB, mship: "join", + room: roomId, event: true, name: "User B", + }) as MatrixEvent, + ]); + expect(room.getDefaultRoomName(userA)).toEqual("User B"); + }); - it("should return a display name if one other member is banned", - function() { - const room = new Room(roomId, null, userA); - room.addLiveEvents([ - utils.mkMembership({ - user: userA, mship: "join", - room: roomId, event: true, name: "User A", - }), - utils.mkMembership({ - user: userB, mship: "ban", - room: roomId, event: true, name: "User B", - }), - ]); - expect(room.getDefaultRoomName(userA)).toEqual("Empty room (was User B)"); - }); + it("should return a display name if one other member is banned", function() { + const room = new Room(roomId, null, userA); + room.addLiveEvents([ + utils.mkMembership({ + user: userA, mship: "join", + room: roomId, event: true, name: "User A", + }) as MatrixEvent, + utils.mkMembership({ + user: userB, mship: "ban", + room: roomId, event: true, name: "User B", + }) as MatrixEvent, + ]); + expect(room.getDefaultRoomName(userA)).toEqual("Empty room (was User B)"); + }); - it("should return a display name if one other member is invited", - function() { - const room = new Room(roomId, null, userA); - room.addLiveEvents([ - utils.mkMembership({ - user: userA, mship: "join", - room: roomId, event: true, name: "User A", - }), - utils.mkMembership({ - user: userB, mship: "invite", - room: roomId, event: true, name: "User B", - }), - ]); - expect(room.getDefaultRoomName(userA)).toEqual("User B"); - }); + it("should return a display name if one other member is invited", function() { + const room = new Room(roomId, null, userA); + room.addLiveEvents([ + utils.mkMembership({ + user: userA, mship: "join", + room: roomId, event: true, name: "User A", + }) as MatrixEvent, + utils.mkMembership({ + user: userB, mship: "invite", + room: roomId, event: true, name: "User B", + }) as MatrixEvent, + ]); + expect(room.getDefaultRoomName(userA)).toEqual("User B"); + }); - it("should return 'Empty room (was User B)' if User B left the room", - function() { - const room = new Room(roomId, null, userA); - room.addLiveEvents([ - utils.mkMembership({ - user: userA, mship: "join", - room: roomId, event: true, name: "User A", - }), - utils.mkMembership({ - user: userB, mship: "leave", - room: roomId, event: true, name: "User B", - }), - ]); - expect(room.getDefaultRoomName(userA)).toEqual("Empty room (was User B)"); - }); + it("should return 'Empty room (was User B)' if User B left the room", function() { + const room = new Room(roomId, null, userA); + room.addLiveEvents([ + utils.mkMembership({ + user: userA, mship: "join", + room: roomId, event: true, name: "User A", + }) as MatrixEvent, + utils.mkMembership({ + user: userB, mship: "leave", + room: roomId, event: true, name: "User B", + }) as MatrixEvent, + ]); + expect(room.getDefaultRoomName(userA)).toEqual("Empty room (was User B)"); + }); - it("should return 'User B and User C' if in a room with two other users", - function() { - const room = new Room(roomId, null, userA); - room.addLiveEvents([ - utils.mkMembership({ - user: userA, mship: "join", - room: roomId, event: true, name: "User A", - }), - utils.mkMembership({ - user: userB, mship: "join", - room: roomId, event: true, name: "User B", - }), - utils.mkMembership({ - user: userC, mship: "join", - room: roomId, event: true, name: "User C", - }), - ]); - expect(room.getDefaultRoomName(userA)).toEqual("User B and User C"); - }); + it("should return 'User B and User C' if in a room with two other users", function() { + const room = new Room(roomId, null, userA); + room.addLiveEvents([ + utils.mkMembership({ + user: userA, mship: "join", + room: roomId, event: true, name: "User A", + }) as MatrixEvent, + utils.mkMembership({ + user: userB, mship: "join", + room: roomId, event: true, name: "User B", + }) as MatrixEvent, + utils.mkMembership({ + user: userC, mship: "join", + room: roomId, event: true, name: "User C", + }) as MatrixEvent, + ]); + expect(room.getDefaultRoomName(userA)).toEqual("User B and User C"); + }); - it("should return 'User B and 2 others' if in a room with three other users", - function() { - const room = new Room(roomId, null, userA); - room.addLiveEvents([ - utils.mkMembership({ - user: userA, mship: "join", - room: roomId, event: true, name: "User A", - }), - utils.mkMembership({ - user: userB, mship: "join", - room: roomId, event: true, name: "User B", - }), - utils.mkMembership({ - user: userC, mship: "join", - room: roomId, event: true, name: "User C", - }), - utils.mkMembership({ - user: userD, mship: "join", - room: roomId, event: true, name: "User D", - }), - ]); - expect(room.getDefaultRoomName(userA)).toEqual("User B and 2 others"); - }); + it("should return 'User B and 2 others' if in a room with three other users", function() { + const room = new Room(roomId, null, userA); + room.addLiveEvents([ + utils.mkMembership({ + user: userA, mship: "join", + room: roomId, event: true, name: "User A", + }) as MatrixEvent, + utils.mkMembership({ + user: userB, mship: "join", + room: roomId, event: true, name: "User B", + }) as MatrixEvent, + utils.mkMembership({ + user: userC, mship: "join", + room: roomId, event: true, name: "User C", + }) as MatrixEvent, + utils.mkMembership({ + user: userD, mship: "join", + room: roomId, event: true, name: "User D", + }) as MatrixEvent, + ]); + expect(room.getDefaultRoomName(userA)).toEqual("User B and 2 others"); + }); + }); - describe("io.element.functional_users", function() { - it("should return a display name (default behaviour) if no one is marked as a functional member", - function() { - const room = new Room(roomId, null, userA); - room.addLiveEvents([ - utils.mkMembership({ - user: userA, mship: "join", - room: roomId, event: true, name: "User A", - }), - utils.mkMembership({ - user: userB, mship: "join", - room: roomId, event: true, name: "User B", - }), - utils.mkEvent({ - type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, skey: "", - room: roomId, event: true, - content: { - service_members: [], - }, - }), - ]); - expect(room.getDefaultRoomName(userA)).toEqual("User B"); - }); + describe("io.element.functional_users", function() { + it("should return a display name (default behaviour) if no one is marked as a functional member", function() { + const room = new Room(roomId, null, userA); + room.addLiveEvents([ + utils.mkMembership({ + user: userA, mship: "join", + room: roomId, event: true, name: "User A", + }) as MatrixEvent, + utils.mkMembership({ + user: userB, mship: "join", + room: roomId, event: true, name: "User B", + }) as MatrixEvent, + utils.mkEvent({ + type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, skey: "", + room: roomId, event: true, + content: { + service_members: [], + }, + }) as MatrixEvent, + ]); + expect(room.getDefaultRoomName(userA)).toEqual("User B"); + }); - it("should return a display name (default behaviour) if service members is a number (invalid)", - function() { - const room = new Room(roomId, null, userA); - room.addLiveEvents([ - utils.mkMembership({ - user: userA, mship: "join", - room: roomId, event: true, name: "User A", - }), - utils.mkMembership({ - user: userB, mship: "join", - room: roomId, event: true, name: "User B", - }), - utils.mkEvent({ - type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, skey: "", - room: roomId, event: true, - content: { - service_members: 1, - }, - }), - ]); - expect(room.getDefaultRoomName(userA)).toEqual("User B"); - }); + it("should return a display name (default behaviour) if service members is a number (invalid)", function() { + const room = new Room(roomId, null, userA); + room.addLiveEvents([ + utils.mkMembership({ + user: userA, mship: "join", + room: roomId, event: true, name: "User A", + }) as MatrixEvent, + utils.mkMembership({ + user: userB, mship: "join", + room: roomId, event: true, name: "User B", + }) as MatrixEvent, + utils.mkEvent({ + type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, + skey: "", + room: roomId, + event: true, + content: { + service_members: 1, + }, + }) as MatrixEvent, + ]); + expect(room.getDefaultRoomName(userA)).toEqual("User B"); + }); - it("should return a display name (default behaviour) if service members is a string (invalid)", - function() { - const room = new Room(roomId, null, userA); - room.addLiveEvents([ - utils.mkMembership({ - user: userA, mship: "join", - room: roomId, event: true, name: "User A", - }), - utils.mkMembership({ - user: userB, mship: "join", - room: roomId, event: true, name: "User B", - }), - utils.mkEvent({ - type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, skey: "", - room: roomId, event: true, - content: { - service_members: userB, - }, - }), - ]); - expect(room.getDefaultRoomName(userA)).toEqual("User B"); - }); + it("should return a display name (default behaviour) if service members is a string (invalid)", function() { + const room = new Room(roomId, null, userA); + room.addLiveEvents([ + utils.mkMembership({ + user: userA, mship: "join", + room: roomId, event: true, name: "User A", + }) as MatrixEvent, + utils.mkMembership({ + user: userB, mship: "join", + room: roomId, event: true, name: "User B", + }) as MatrixEvent, + utils.mkEvent({ + type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, skey: "", + room: roomId, event: true, + content: { + service_members: userB, + }, + }) as MatrixEvent, + ]); + expect(room.getDefaultRoomName(userA)).toEqual("User B"); + }); - it("should return 'Empty room' if the only other member is a functional member", - function() { - const room = new Room(roomId, null, userA); - room.addLiveEvents([ - utils.mkMembership({ - user: userA, mship: "join", - room: roomId, event: true, name: "User A", - }), - utils.mkMembership({ - user: userB, mship: "join", - room: roomId, event: true, name: "User B", - }), - utils.mkEvent({ - type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, skey: "", - room: roomId, event: true, - content: { - service_members: [userB], - }, - }), - ]); - expect(room.getDefaultRoomName(userA)).toEqual("Empty room"); - }); + it("should return 'Empty room' if the only other member is a functional member", function() { + const room = new Room(roomId, null, userA); + room.addLiveEvents([ + utils.mkMembership({ + user: userA, mship: "join", + room: roomId, event: true, name: "User A", + }) as MatrixEvent, + utils.mkMembership({ + user: userB, mship: "join", + room: roomId, event: true, name: "User B", + }) as MatrixEvent, + utils.mkEvent({ + type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, skey: "", + room: roomId, event: true, + content: { + service_members: [userB], + }, + }) as MatrixEvent, + ]); + expect(room.getDefaultRoomName(userA)).toEqual("Empty room"); + }); - it("should return 'User B' if User B is the only other member who isn't a functional member", - function() { - const room = new Room(roomId, null, userA); - room.addLiveEvents([ - utils.mkMembership({ - user: userA, mship: "join", - room: roomId, event: true, name: "User A", - }), - utils.mkMembership({ - user: userB, mship: "join", - room: roomId, event: true, name: "User B", - }), - utils.mkMembership({ - user: userC, mship: "join", - room: roomId, event: true, name: "User C", - }), - utils.mkEvent({ - type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, skey: "", - room: roomId, event: true, user: userA, - content: { - service_members: [userC], - }, - }), - ]); - expect(room.getDefaultRoomName(userA)).toEqual("User B"); - }); + it("should return 'User B' if User B is the only other member who isn't a functional member", function() { + const room = new Room(roomId, null, userA); + room.addLiveEvents([ + utils.mkMembership({ + user: userA, mship: "join", + room: roomId, event: true, name: "User A", + }) as MatrixEvent, + utils.mkMembership({ + user: userB, mship: "join", + room: roomId, event: true, name: "User B", + }) as MatrixEvent, + utils.mkMembership({ + user: userC, mship: "join", + room: roomId, event: true, name: "User C", + }) as MatrixEvent, + utils.mkEvent({ + type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, skey: "", + room: roomId, event: true, user: userA, + content: { + service_members: [userC], + }, + }) as MatrixEvent, + ]); + expect(room.getDefaultRoomName(userA)).toEqual("User B"); + }); - it("should return 'Empty room' if all other members are functional members", - function() { - const room = new Room(roomId, null, userA); - room.addLiveEvents([ - utils.mkMembership({ - user: userA, mship: "join", - room: roomId, event: true, name: "User A", - }), - utils.mkMembership({ - user: userB, mship: "join", - room: roomId, event: true, name: "User B", - }), - utils.mkMembership({ - user: userC, mship: "join", - room: roomId, event: true, name: "User C", - }), - utils.mkEvent({ - type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, skey: "", - room: roomId, event: true, user: userA, - content: { - service_members: [userB, userC], - }, - }), - ]); - expect(room.getDefaultRoomName(userA)).toEqual("Empty room"); - }); + it("should return 'Empty room' if all other members are functional members", function() { + const room = new Room(roomId, null, userA); + room.addLiveEvents([ + utils.mkMembership({ + user: userA, mship: "join", + room: roomId, event: true, name: "User A", + }) as MatrixEvent, + utils.mkMembership({ + user: userB, mship: "join", + room: roomId, event: true, name: "User B", + }) as MatrixEvent, + utils.mkMembership({ + user: userC, mship: "join", + room: roomId, event: true, name: "User C", + }) as MatrixEvent, + utils.mkEvent({ + type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, skey: "", + room: roomId, event: true, user: userA, + content: { + service_members: [userB, userC], + }, + }) as MatrixEvent, + ]); + expect(room.getDefaultRoomName(userA)).toEqual("Empty room"); + }); - it("should not break if an unjoined user is marked as a service user", - function() { - const room = new Room(roomId, null, userA); - room.addLiveEvents([ - utils.mkMembership({ - user: userA, mship: "join", - room: roomId, event: true, name: "User A", - }), - utils.mkMembership({ - user: userB, mship: "join", - room: roomId, event: true, name: "User B", - }), - utils.mkEvent({ - type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, skey: "", - room: roomId, event: true, user: userA, - content: { - service_members: [userC], - }, - }), - ]); - expect(room.getDefaultRoomName(userA)).toEqual("User B"); - }); + it("should not break if an unjoined user is marked as a service user", function() { + const room = new Room(roomId, null, userA); + room.addLiveEvents([ + utils.mkMembership({ + user: userA, mship: "join", + room: roomId, event: true, name: "User A", + }) as MatrixEvent, + utils.mkMembership({ + user: userB, mship: "join", + room: roomId, event: true, name: "User B", + }) as MatrixEvent, + utils.mkEvent({ + type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, skey: "", + room: roomId, event: true, user: userA, + content: { + service_members: [userC], + }, + }) as MatrixEvent, + ]); + expect(room.getDefaultRoomName(userA)).toEqual("User B"); + }); + }); + + describe("threads", function() { + beforeEach(() => { + const client = (new TestClient( + "@alice:example.com", "alicedevice", + )).client; + room = new Room(roomId, client, userA); }); - describe("threads", function() { - beforeEach(() => { - const client = (new TestClient( - "@alice:example.com", "alicedevice", - )).client; - room = new Room(roomId, client, userA); + it("allow create threads without a root event", function() { + const eventWithoutARootEvent = new MatrixEvent({ + event_id: "$123", + room_id: roomId, + content: { + "m.relates_to": { + "rel_type": "m.thread", + "event_id": "$000", + }, + }, + unsigned: { + "age": 1, + }, }); - it("allow create threads without a root event", function() { - const eventWithoutARootEvent = new MatrixEvent({ - event_id: "$123", - room_id: roomId, - content: { - "m.relates_to": { - "rel_type": "m.thread", - "event_id": "$000", + room.createThread(undefined, [eventWithoutARootEvent]); + + const rootEvent = new MatrixEvent({ + event_id: "$666", + room_id: roomId, + content: {}, + unsigned: { + "age": 1, + "m.relations": { + "m.thread": { + latest_event: null, + count: 1, + current_user_participated: false, }, }, - unsigned: { - "age": 1, + }, + }); + + expect(() => room.createThread(rootEvent, [])).not.toThrow(); + }); + }); + + describe("eventShouldLiveIn", () => { + const room = new Room(roomId, null, userA); + + const mkMessage = () => utils.mkMessage({ + event: true, + user: userA, + room: roomId, + }) as MatrixEvent; + + const mkReply = (target: MatrixEvent) => utils.mkEvent({ + event: true, + type: EventType.RoomMessage, + user: userA, + room: roomId, + content: { + "body": "Reply :: " + Math.random(), + "m.relates_to": { + "m.in_reply_to": { + "event_id": target.getId(), }, - }); + }, + }, + }) as MatrixEvent; - room.createThread(undefined, [eventWithoutARootEvent]); - - const rootEvent = new MatrixEvent({ - event_id: "$666", - room_id: roomId, - content: {}, - unsigned: { - "age": 1, - "m.relations": { - "m.thread": { - latest_event: null, - count: 1, - current_user_participated: false, - }, - }, + const mkThreadResponse = (root: MatrixEvent) => utils.mkEvent({ + event: true, + type: EventType.RoomMessage, + user: userA, + room: roomId, + content: { + "body": "Thread response :: " + Math.random(), + "m.relates_to": { + "event_id": root.getId(), + "m.in_reply_to": { + "event_id": root.getId(), }, - }); + "rel_type": "m.thread", + }, + }, + }) as MatrixEvent; - expect(() => room.createThread(rootEvent, [])).not.toThrow(); - }); + const mkReaction = (target: MatrixEvent) => utils.mkEvent({ + event: true, + type: EventType.Reaction, + user: userA, + room: roomId, + content: { + "m.relates_to": { + "rel_type": RelationType.Annotation, + "event_id": target.getId(), + "key": Math.random().toString(), + }, + }, + }) as MatrixEvent; + + const mkRedaction = (target: MatrixEvent) => utils.mkEvent({ + event: true, + type: EventType.RoomRedaction, + user: userA, + room: roomId, + redacts: target.getId(), + content: {}, + }) as MatrixEvent; + + it("thread root and its relations&redactions should be in both", () => { + const randomMessage = mkMessage(); + const threadRoot = mkMessage(); + const threadResponse1 = mkThreadResponse(threadRoot); + const threadReaction1 = mkReaction(threadRoot); + const threadReaction2 = mkReaction(threadRoot); + const threadReaction2Redaction = mkRedaction(threadReaction2); + + const roots = new Set([threadRoot.getId()]); + const events = [ + randomMessage, + threadRoot, + threadResponse1, + threadReaction1, + threadReaction2, + threadReaction2Redaction, + ]; + + expect(room.eventShouldLiveIn(randomMessage, events, roots).shouldLiveInRoom).toBeTruthy(); + expect(room.eventShouldLiveIn(randomMessage, events, roots).shouldLiveInThread).toBeFalsy(); + + expect(room.eventShouldLiveIn(threadRoot, events, roots).shouldLiveInRoom).toBeTruthy(); + expect(room.eventShouldLiveIn(threadRoot, events, roots).shouldLiveInThread).toBeTruthy(); + expect(room.eventShouldLiveIn(threadRoot, events, roots).threadId).toBe(threadRoot.getId()); + expect(room.eventShouldLiveIn(threadResponse1, events, roots).shouldLiveInRoom).toBeFalsy(); + expect(room.eventShouldLiveIn(threadResponse1, events, roots).shouldLiveInThread).toBeTruthy(); + expect(room.eventShouldLiveIn(threadResponse1, events, roots).threadId).toBe(threadRoot.getId()); + + expect(room.eventShouldLiveIn(threadReaction1, events, roots).shouldLiveInRoom).toBeTruthy(); + expect(room.eventShouldLiveIn(threadReaction1, events, roots).shouldLiveInThread).toBeTruthy(); + expect(room.eventShouldLiveIn(threadReaction1, events, roots).threadId).toBe(threadRoot.getId()); + expect(room.eventShouldLiveIn(threadReaction2, events, roots).shouldLiveInRoom).toBeTruthy(); + expect(room.eventShouldLiveIn(threadReaction2, events, roots).shouldLiveInThread).toBeTruthy(); + expect(room.eventShouldLiveIn(threadReaction2, events, roots).threadId).toBe(threadRoot.getId()); + expect(room.eventShouldLiveIn(threadReaction2Redaction, events, roots).shouldLiveInRoom).toBeTruthy(); + expect(room.eventShouldLiveIn(threadReaction2Redaction, events, roots).shouldLiveInThread).toBeTruthy(); + expect(room.eventShouldLiveIn(threadReaction2Redaction, events, roots).threadId).toBe(threadRoot.getId()); + }); + + it("thread response and its relations&redactions should be only in thread timeline", () => { + const threadRoot = mkMessage(); + const threadResponse1 = mkThreadResponse(threadRoot); + const threadReaction1 = mkReaction(threadResponse1); + const threadReaction2 = mkReaction(threadResponse1); + const threadReaction2Redaction = mkRedaction(threadReaction2); + + const roots = new Set([threadRoot.getId()]); + const events = [threadRoot, threadResponse1, threadReaction1, threadReaction2, threadReaction2Redaction]; + + expect(room.eventShouldLiveIn(threadReaction1, events, roots).shouldLiveInRoom).toBeFalsy(); + expect(room.eventShouldLiveIn(threadReaction1, events, roots).shouldLiveInThread).toBeTruthy(); + expect(room.eventShouldLiveIn(threadReaction1, events, roots).threadId).toBe(threadRoot.getId()); + expect(room.eventShouldLiveIn(threadReaction2, events, roots).shouldLiveInRoom).toBeFalsy(); + expect(room.eventShouldLiveIn(threadReaction2, events, roots).shouldLiveInThread).toBeTruthy(); + expect(room.eventShouldLiveIn(threadReaction2, events, roots).threadId).toBe(threadRoot.getId()); + expect(room.eventShouldLiveIn(threadReaction2Redaction, events, roots).shouldLiveInRoom).toBeFalsy(); + expect(room.eventShouldLiveIn(threadReaction2Redaction, events, roots).shouldLiveInThread).toBeTruthy(); + expect(room.eventShouldLiveIn(threadReaction2Redaction, events, roots).threadId).toBe(threadRoot.getId()); + }); + + it("reply to thread response and its relations&redactions should be only in thread timeline", () => { + const threadRoot = mkMessage(); + const threadResponse1 = mkThreadResponse(threadRoot); + const reply1 = mkReply(threadResponse1); + const threadReaction1 = mkReaction(reply1); + const threadReaction2 = mkReaction(reply1); + const threadReaction2Redaction = mkRedaction(reply1); + + const roots = new Set([threadRoot.getId()]); + const events = [ + threadRoot, + threadResponse1, + reply1, + threadReaction1, + threadReaction2, + threadReaction2Redaction, + ]; + + expect(room.eventShouldLiveIn(reply1, events, roots).shouldLiveInRoom).toBeFalsy(); + expect(room.eventShouldLiveIn(reply1, events, roots).shouldLiveInThread).toBeTruthy(); + expect(room.eventShouldLiveIn(reply1, events, roots).threadId).toBe(threadRoot.getId()); + expect(room.eventShouldLiveIn(threadReaction1, events, roots).shouldLiveInRoom).toBeFalsy(); + expect(room.eventShouldLiveIn(threadReaction1, events, roots).shouldLiveInThread).toBeTruthy(); + expect(room.eventShouldLiveIn(threadReaction1, events, roots).threadId).toBe(threadRoot.getId()); + expect(room.eventShouldLiveIn(threadReaction2, events, roots).shouldLiveInRoom).toBeFalsy(); + expect(room.eventShouldLiveIn(threadReaction2, events, roots).shouldLiveInThread).toBeTruthy(); + expect(room.eventShouldLiveIn(threadReaction2, events, roots).threadId).toBe(threadRoot.getId()); + expect(room.eventShouldLiveIn(threadReaction2Redaction, events, roots).shouldLiveInRoom).toBeFalsy(); + expect(room.eventShouldLiveIn(threadReaction2Redaction, events, roots).shouldLiveInThread).toBeTruthy(); + expect(room.eventShouldLiveIn(threadReaction2Redaction, events, roots).threadId).toBe(threadRoot.getId()); + }); + + it("reply to reply to thread root should only be in the main timeline", () => { + const threadRoot = mkMessage(); + const threadResponse1 = mkThreadResponse(threadRoot); + const reply1 = mkReply(threadRoot); + const reply2 = mkReply(reply1); + + const roots = new Set([threadRoot.getId()]); + const events = [ + threadRoot, + threadResponse1, + reply1, + reply2, + ]; + + expect(room.eventShouldLiveIn(reply1, events, roots).shouldLiveInRoom).toBeTruthy(); + expect(room.eventShouldLiveIn(reply1, events, roots).shouldLiveInThread).toBeFalsy(); + expect(room.eventShouldLiveIn(reply2, events, roots).shouldLiveInRoom).toBeTruthy(); + expect(room.eventShouldLiveIn(reply2, events, roots).shouldLiveInThread).toBeFalsy(); }); }); }); diff --git a/src/client.ts b/src/client.ts index d1b6dff72a4..11ec7fa4d56 100644 --- a/src/client.ts +++ b/src/client.ts @@ -5179,7 +5179,7 @@ export class MatrixClient extends TypedEventEmitter): { - shouldLiveInRoom: boolean; - shouldLiveInThread: boolean; - threadId?: string; - } { - if (event.isThreadRoot) { - return { - shouldLiveInRoom: true, - shouldLiveInThread: true, - threadId: event.getId(), - }; - } - - // A thread relation is always only shown in a thread - if (event.isThreadRelation) { - return { - shouldLiveInRoom: false, - shouldLiveInThread: true, - threadId: event.relationEventId, - }; - } - - const parentEventId = event.getAssociatedId(); - const parentEvent = room?.findEventById(parentEventId) ?? events.find((mxEv: MatrixEvent) => ( - mxEv.getId() === parentEventId - )); - - // A reaction targeting the thread root needs to be routed to both the main timeline and the associated thread - const targetingThreadRoot = parentEvent?.isThreadRoot || roots.has(event.relationEventId); - if (targetingThreadRoot) { - return { - shouldLiveInRoom: true, - shouldLiveInThread: true, - threadId: event.relationEventId, - }; - } - - // If the parent event also has an associated ID we want to re-run the - // computation for that parent event. - // In the case of the redaction of a reaction that targets a root event - // we want that redaction to be pushed to both timeline - if (parentEvent?.getAssociatedId()) { - return this.eventShouldLiveIn(parentEvent, room, events, roots); - } - - // We've exhausted all scenarios, can safely assume that this event - // should live in the room timeline - return { - shouldLiveInRoom: true, - shouldLiveInThread: false, - }; - } - - public partitionThreadedEvents(events: MatrixEvent[]): [ + public partitionThreadedEvents(room: Room, events: MatrixEvent[]): [ timelineEvents: MatrixEvent[], threadedEvents: MatrixEvent[], ] { @@ -8931,13 +8876,11 @@ export class MatrixClient extends TypedEventEmitter { - const room = this.getRoom(event.getRoomId()); - const { shouldLiveInRoom, shouldLiveInThread, threadId, - } = this.eventShouldLiveIn(event, room, events, threadRoots); + } = room.eventShouldLiveIn(event, events, threadRoots); if (shouldLiveInRoom) { memo[ROOM].push(event); diff --git a/src/models/room.ts b/src/models/room.ts index 9cd000d66ac..f6008d7b14c 100644 --- a/src/models/room.ts +++ b/src/models/room.ts @@ -1562,19 +1562,68 @@ export class Room extends TypedEventEmitter } } - public findThreadForEvent(event: MatrixEvent): Thread | null { - if (!event) { - return null; + public eventShouldLiveIn(event: MatrixEvent, events?: MatrixEvent[], roots?: Set): { + shouldLiveInRoom: boolean; + shouldLiveInThread: boolean; + threadId?: string; + } { + // A thread root is always shown in both timelines + if (event.isThreadRoot || roots?.has(event.getId())) { + return { + shouldLiveInRoom: true, + shouldLiveInThread: true, + threadId: event.getId(), + }; } + // A thread relation is always only shown in a thread if (event.isThreadRelation) { - return this.threads.get(event.threadRootId); - } else if (event.isThreadRoot) { - return this.threads.get(event.getId()); - } else { - const parentEvent = this.findEventById(event.getAssociatedId()); - return this.findThreadForEvent(parentEvent); + return { + shouldLiveInRoom: false, + shouldLiveInThread: true, + threadId: event.relationEventId, + }; + } + + const parentEventId = event.getAssociatedId(); + const parentEvent = this.findEventById(parentEventId) ?? events?.find(e => e.getId() === parentEventId); + + // Treat relations and redactions as extensions of their parents so evaluate parentEvent instead + if (parentEvent && (event.isRelation() || event.isRedaction())) { + return this.eventShouldLiveIn(parentEvent, events, roots); } + + // Edge case where we know the event is a relation but don't have the parentEvent + if (roots?.has(event.relationEventId)) { + return { + shouldLiveInRoom: true, + shouldLiveInThread: true, + threadId: event.relationEventId, + }; + } + + // A reply directly to a thread response is shown as part of the thread only, this is to provide a better + // experience when communicating with users using clients without full threads support + if (parentEvent?.isThreadRelation) { + return { + shouldLiveInRoom: false, + shouldLiveInThread: true, + threadId: parentEvent.threadRootId, + }; + } + + // We've exhausted all scenarios, can safely assume that this event should live in the room timeline only + return { + shouldLiveInRoom: true, + shouldLiveInThread: false, + }; + } + + public findThreadForEvent(event?: MatrixEvent): Thread | null { + if (!event) return null; + + const { threadId } = this.eventShouldLiveIn(event); + return threadId ? this.getThread(threadId) : null; } public async createThreadFetchRoot( @@ -1895,14 +1944,6 @@ export class Room extends TypedEventEmitter } } - private shouldAddEventToMainTimeline(thread: Thread, event: MatrixEvent): boolean { - if (!thread) { - return true; - } - - return !event.isThreadRelation && thread.id === event.getAssociatedId(); - } - /** * Used to aggregate the local echo for a relation, and also * for re-applying a relation after it's redaction has been cancelled, @@ -1914,10 +1955,11 @@ export class Room extends TypedEventEmitter * @param {module:models/event.MatrixEvent} event the relation event that needs to be aggregated. */ private aggregateNonLiveRelation(event: MatrixEvent): void { - const thread = this.findThreadForEvent(event); + const { shouldLiveInRoom, threadId } = this.eventShouldLiveIn(event); + const thread = this.getThread(threadId); thread?.timelineSet.aggregateRelations(event); - if (this.shouldAddEventToMainTimeline(thread, event)) { + if (shouldLiveInRoom) { // TODO: We should consider whether this means it would be a better // design to lift the relations handling up to the room instead. for (let i = 0; i < this.timelineSets.length; i++) { @@ -1973,10 +2015,11 @@ export class Room extends TypedEventEmitter // any, which is good, because we don't want to try decoding it again). localEvent.handleRemoteEcho(remoteEvent.event); - const thread = this.findThreadForEvent(remoteEvent); + const { shouldLiveInRoom, threadId } = this.eventShouldLiveIn(remoteEvent); + const thread = this.getThread(threadId); thread?.timelineSet.handleRemoteEcho(localEvent, oldEventId, newEventId); - if (this.shouldAddEventToMainTimeline(thread, remoteEvent)) { + if (shouldLiveInRoom) { for (let i = 0; i < this.timelineSets.length; i++) { const timelineSet = this.timelineSets[i]; @@ -2042,10 +2085,11 @@ export class Room extends TypedEventEmitter // update the event id event.replaceLocalEventId(newEventId); - const thread = this.findThreadForEvent(event); + const { shouldLiveInRoom, threadId } = this.eventShouldLiveIn(event); + const thread = this.getThread(threadId); thread?.timelineSet.replaceEventId(oldEventId, newEventId); - if (this.shouldAddEventToMainTimeline(thread, event)) { + if (shouldLiveInRoom) { // if the event was already in the timeline (which will be the case if // opts.pendingEventOrdering==chronological), we need to update the // timeline map. @@ -2105,13 +2149,12 @@ export class Room extends TypedEventEmitter * @throws If duplicateStrategy is not falsey, 'replace' or 'ignore'. */ public addLiveEvents(events: MatrixEvent[], duplicateStrategy?: DuplicateStrategy, fromCache = false): void { - let i; if (duplicateStrategy && ["replace", "ignore"].indexOf(duplicateStrategy) === -1) { throw new Error("duplicateStrategy MUST be either 'replace' or 'ignore'"); } // sanity check that the live timeline is still live - for (i = 0; i < this.timelineSets.length; i++) { + for (let i = 0; i < this.timelineSets.length; i++) { const liveTimeline = this.timelineSets[i].getLiveTimeline(); if (liveTimeline.getPaginationToken(EventTimeline.FORWARDS)) { throw new Error( @@ -2120,21 +2163,13 @@ export class Room extends TypedEventEmitter ); } if (liveTimeline.getNeighbouringTimeline(EventTimeline.FORWARDS)) { - throw new Error( - "live timeline " + i + " is no longer live - " + - "it has a neighbouring timeline", - ); + throw new Error(`live timeline ${i} is no longer live - it has a neighbouring timeline`); } } - for (i = 0; i < events.length; i++) { - // TODO: We should have a filter to say "only add state event - // types X Y Z to the timeline". + for (let i = 0; i < events.length; i++) { + // TODO: We should have a filter to say "only add state event types X Y Z to the timeline". this.addLiveEvent(events[i], duplicateStrategy, fromCache); - const thread = this.findThreadForEvent(events[i]); - if (thread) { - thread.addEvent(events[i], true); - } } } diff --git a/src/sync.ts b/src/sync.ts index 6299977397d..bab1da97040 100644 --- a/src/sync.ts +++ b/src/sync.ts @@ -276,15 +276,13 @@ export class SyncApi { return client.http.authedRequest( // TODO types undefined, Method.Get, "/sync", qps as any, undefined, localTimeoutMs, ); - }).then((data) => { + }).then(async (data) => { let leaveRooms = []; if (data.rooms?.leave) { leaveRooms = this.mapSyncResponseToRoomArray(data.rooms.leave); } - const rooms = []; - leaveRooms.forEach(async (leaveObj) => { + return Promise.all(leaveRooms.map(async (leaveObj) => { const room = leaveObj.room; - rooms.push(room); if (!leaveObj.isBrandNewRoom) { // the intention behind syncLeftRooms is to add in rooms which were // *omitted* from the initial /sync. Rooms the user were joined to @@ -298,25 +296,22 @@ export class SyncApi { } leaveObj.timeline = leaveObj.timeline || {}; const events = this.mapSyncEventsFormat(leaveObj.timeline, room); - const [timelineEvents, threadedEvents] = this.client.partitionThreadedEvents(events); const stateEvents = this.mapSyncEventsFormat(leaveObj.state, room); // set the back-pagination token. Do this *before* adding any // events so that clients can start back-paginating. - room.getLiveTimeline().setPaginationToken(leaveObj.timeline.prev_batch, - EventTimeline.BACKWARDS); + room.getLiveTimeline().setPaginationToken(leaveObj.timeline.prev_batch, EventTimeline.BACKWARDS); - this.processRoomEvents(room, stateEvents, timelineEvents); - await this.processThreadEvents(room, threadedEvents, false); + await this.processRoomEvents(room, stateEvents, events); room.recalculate(); client.store.storeRoom(room); client.emit(ClientEvent.Room, room); this.processEventsForNotifs(room, events); - }); - return rooms; + return room; + })); }); } @@ -759,7 +754,7 @@ export class SyncApi { try { await this.processSyncResponse(syncEventData, data); } catch (e) { - logger.error("Error processing cached sync", e.stack || e); + logger.error("Error processing cached sync", e); } // Don't emit a prepared if we've bailed because the store is invalid: @@ -834,7 +829,7 @@ export class SyncApi { } catch (e) { // log the exception with stack if we have it, else fall back // to the plain description - logger.error("Caught /sync error", e.stack || e); + logger.error("Caught /sync error", e); // Emit the exception for client handling this.client.emit(ClientEvent.SyncUnexpectedError, e); @@ -1087,9 +1082,7 @@ export class SyncApi { } // handle to-device events - if (data.to_device && Array.isArray(data.to_device.events) && - data.to_device.events.length > 0 - ) { + if (Array.isArray(data.to_device?.events) && data.to_device.events.length > 0) { const cancelledKeyVerificationTxns = []; data.to_device.events .map(client.getEventMapper()) @@ -1163,11 +1156,11 @@ export class SyncApi { this.notifEvents = []; // Handle invites - inviteRooms.forEach((inviteObj) => { + await utils.promiseMapSeries(inviteRooms, async (inviteObj) => { const room = inviteObj.room; const stateEvents = this.mapSyncEventsFormat(inviteObj.invite_state, room); - this.processRoomEvents(room, stateEvents); + await this.processRoomEvents(room, stateEvents); if (inviteObj.isBrandNewRoom) { room.recalculate(); client.store.storeRoom(room); @@ -1274,10 +1267,7 @@ export class SyncApi { } } - const [timelineEvents, threadedEvents] = this.client.partitionThreadedEvents(events); - - this.processRoomEvents(room, stateEvents, timelineEvents, syncEventData.fromCache); - await this.processThreadEvents(room, threadedEvents, false); + await this.processRoomEvents(room, stateEvents, events, syncEventData.fromCache); // set summary after processing events, // because it will trigger a name calculation @@ -1318,8 +1308,7 @@ export class SyncApi { }; await utils.promiseMapSeries(stateEvents, processRoomEvent); - await utils.promiseMapSeries(timelineEvents, processRoomEvent); - await utils.promiseMapSeries(threadedEvents, processRoomEvent); + await utils.promiseMapSeries(events, processRoomEvent); ephemeralEvents.forEach(function(e) { client.emit(ClientEvent.Event, e); }); @@ -1336,16 +1325,13 @@ export class SyncApi { }); // Handle leaves (e.g. kicked rooms) - leaveRooms.forEach(async (leaveObj) => { + await utils.promiseMapSeries(leaveRooms, async (leaveObj) => { const room = leaveObj.room; const stateEvents = this.mapSyncEventsFormat(leaveObj.state, room); const events = this.mapSyncEventsFormat(leaveObj.timeline, room); const accountDataEvents = this.mapSyncEventsFormat(leaveObj.account_data); - const [timelineEvents, threadedEvents] = this.client.partitionThreadedEvents(events); - - this.processRoomEvents(room, stateEvents, timelineEvents); - await this.processThreadEvents(room, threadedEvents, false); + await this.processRoomEvents(room, stateEvents, events); room.addAccountData(accountDataEvents); room.recalculate(); @@ -1359,10 +1345,7 @@ export class SyncApi { stateEvents.forEach(function(e) { client.emit(ClientEvent.Event, e); }); - timelineEvents.forEach(function(e) { - client.emit(ClientEvent.Event, e); - }); - threadedEvents.forEach(function(e) { + events.forEach(function(e) { client.emit(ClientEvent.Event, e); }); accountDataEvents.forEach(function(e) { @@ -1592,16 +1575,16 @@ export class SyncApi { * @param {Room} room * @param {MatrixEvent[]} stateEventList A list of state events. This is the state * at the *START* of the timeline list if it is supplied. - * @param {MatrixEvent[]} [timelineEventList] A list of timeline events. Lower index + * @param {MatrixEvent[]} [timelineEventList] A list of timeline events, including threaded. Lower index * @param {boolean} fromCache whether the sync response came from cache * is earlier in time. Higher index is later. */ - private processRoomEvents( + private async processRoomEvents( room: Room, stateEventList: MatrixEvent[], timelineEventList?: MatrixEvent[], fromCache = false, - ): void { + ): Promise { // If there are no events in the timeline yet, initialise it with // the given state events const liveTimeline = room.getLiveTimeline(); @@ -1651,11 +1634,14 @@ export class SyncApi { room.oldState.setStateEvents(stateEventList || []); room.currentState.setStateEvents(stateEventList || []); } - // execute the timeline events. This will continue to diverge the current state + + // Execute the timeline events. This will continue to diverge the current state // if the timeline has any state events in it. // This also needs to be done before running push rules on the events as they need // to be decorated with sender etc. - room.addLiveEvents(timelineEventList || [], null, fromCache); + const [mainTimelineEvents, threadedEvents] = this.client.partitionThreadedEvents(room, timelineEventList || []); + room.addLiveEvents(mainTimelineEvents, null, fromCache); + await this.processThreadEvents(room, threadedEvents, false); } /** From 106f7beb48fe57fac1892d52916949f207961f4e Mon Sep 17 00:00:00 2001 From: adamvy Date: Fri, 1 Apr 2022 18:56:07 -0700 Subject: [PATCH 12/28] Fix getSessionsNeedingBackup() limit support (#2270) --- src/crypto/store/localStorage-crypto-store.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/crypto/store/localStorage-crypto-store.ts b/src/crypto/store/localStorage-crypto-store.ts index bd0b87bc435..599e74cb9b2 100644 --- a/src/crypto/store/localStorage-crypto-store.ts +++ b/src/crypto/store/localStorage-crypto-store.ts @@ -325,7 +325,7 @@ export class LocalStorageCryptoStore extends MemoryCryptoStore { }); }, ); - if (limit && session.length >= limit) { + if (limit && sessions.length >= limit) { break; } } From 71b7521f4223696076cd7c3da6d6d575f066bbd0 Mon Sep 17 00:00:00 2001 From: Kerry Date: Mon, 4 Apr 2022 10:17:49 +0200 Subject: [PATCH 13/28] Live location sharing - handle redacted beacons (#2269) * emit beacon destroy event on destroy Signed-off-by: Kerry Archibald * handle redacted beacon events in room-state Signed-off-by: Kerry Archibald * empty line Signed-off-by: Kerry Archibald --- spec/unit/models/beacon.spec.ts | 8 ++- spec/unit/room-state.spec.js | 109 +++++++++++++++++++++----------- src/models/beacon.ts | 5 ++ src/models/room-state.ts | 15 ++++- src/sync.ts | 1 + 5 files changed, 98 insertions(+), 40 deletions(-) diff --git a/spec/unit/models/beacon.spec.ts b/spec/unit/models/beacon.spec.ts index 5f63f1bce8a..0e39e7c6961 100644 --- a/spec/unit/models/beacon.spec.ts +++ b/spec/unit/models/beacon.spec.ts @@ -256,7 +256,7 @@ describe('Beacon', () => { expect(beacon.livenessWatchInterval).not.toEqual(oldMonitor); }); - it('destroy kills liveness monitor', () => { + it('destroy kills liveness monitor and emits', () => { // live beacon was created an hour ago // and has a 3hr duration const beacon = new Beacon(liveBeaconEvent); @@ -267,10 +267,14 @@ describe('Beacon', () => { // destroy the beacon beacon.destroy(); + expect(emitSpy).toHaveBeenCalledWith(BeaconEvent.Destroy, beacon.identifier); + // live forced to false + expect(beacon.isLive).toBe(false); advanceDateAndTime(HOUR_MS * 2 + 1); - expect(emitSpy).not.toHaveBeenCalled(); + // no additional calls + expect(emitSpy).toHaveBeenCalledTimes(1); }); }); }); diff --git a/spec/unit/room-state.spec.js b/spec/unit/room-state.spec.js index e17f0bbba2c..fa00d21fc9a 100644 --- a/spec/unit/room-state.spec.js +++ b/spec/unit/room-state.spec.js @@ -252,56 +252,91 @@ describe("RoomState", function() { ); }); - it('adds new beacon info events to state and emits', () => { - const beaconEvent = makeBeaconInfoEvent(userA, roomId); - const emitSpy = jest.spyOn(state, 'emit'); + describe('beacon events', () => { + it('adds new beacon info events to state and emits', () => { + const beaconEvent = makeBeaconInfoEvent(userA, roomId); + const emitSpy = jest.spyOn(state, 'emit'); - state.setStateEvents([beaconEvent]); + state.setStateEvents([beaconEvent]); - expect(state.beacons.size).toEqual(1); - const beaconInstance = state.beacons.get(beaconEvent.getType()); - expect(beaconInstance).toBeTruthy(); - expect(emitSpy).toHaveBeenCalledWith(BeaconEvent.New, beaconEvent, beaconInstance); - }); + expect(state.beacons.size).toEqual(1); + const beaconInstance = state.beacons.get(beaconEvent.getType()); + expect(beaconInstance).toBeTruthy(); + expect(emitSpy).toHaveBeenCalledWith(BeaconEvent.New, beaconEvent, beaconInstance); + }); - it('updates existing beacon info events in state', () => { - const beaconId = '$beacon1'; - const beaconEvent = makeBeaconInfoEvent(userA, roomId, { isLive: true }, beaconId, beaconId); - const updatedBeaconEvent = makeBeaconInfoEvent(userA, roomId, { isLive: false }, beaconId, beaconId); + it('does not add redacted beacon info events to state', () => { + const redactedBeaconEvent = makeBeaconInfoEvent(userA, roomId); + const redactionEvent = { event: { type: 'm.room.redaction' } }; + redactedBeaconEvent.makeRedacted(redactionEvent); + const emitSpy = jest.spyOn(state, 'emit'); - state.setStateEvents([beaconEvent]); - const beaconInstance = state.beacons.get(beaconEvent.getType()); - expect(beaconInstance.isLive).toEqual(true); + state.setStateEvents([redactedBeaconEvent]); - state.setStateEvents([updatedBeaconEvent]); + // no beacon added + expect(state.beacons.size).toEqual(0); + expect(state.beacons.get(redactedBeaconEvent.getType)).toBeFalsy(); + // no new beacon emit + expect(filterEmitCallsByEventType(BeaconEvent.New, emitSpy).length).toBeFalsy(); + }); - // same Beacon - expect(state.beacons.get(beaconEvent.getType())).toBe(beaconInstance); - // updated liveness - expect(state.beacons.get(beaconEvent.getType()).isLive).toEqual(false); - }); + it('updates existing beacon info events in state', () => { + const beaconId = '$beacon1'; + const beaconEvent = makeBeaconInfoEvent(userA, roomId, { isLive: true }, beaconId, beaconId); + const updatedBeaconEvent = makeBeaconInfoEvent(userA, roomId, { isLive: false }, beaconId, beaconId); - it('updates live beacon ids once after setting state events', () => { - const liveBeaconEvent = makeBeaconInfoEvent(userA, roomId, { isLive: true }, '$beacon1', '$beacon1'); - const deadBeaconEvent = makeBeaconInfoEvent(userA, roomId, { isLive: false }, '$beacon2', '$beacon2'); + state.setStateEvents([beaconEvent]); + const beaconInstance = state.beacons.get(beaconEvent.getType()); + expect(beaconInstance.isLive).toEqual(true); - const emitSpy = jest.spyOn(state, 'emit'); + state.setStateEvents([updatedBeaconEvent]); - state.setStateEvents([liveBeaconEvent, deadBeaconEvent]); + // same Beacon + expect(state.beacons.get(beaconEvent.getType())).toBe(beaconInstance); + // updated liveness + expect(state.beacons.get(beaconEvent.getType()).isLive).toEqual(false); + }); - // called once - expect(filterEmitCallsByEventType(RoomStateEvent.BeaconLiveness, emitSpy).length).toBe(1); + it('destroys and removes redacted beacon events', () => { + const beaconId = '$beacon1'; + const beaconEvent = makeBeaconInfoEvent(userA, roomId, { isLive: true }, beaconId, beaconId); + const redactedBeaconEvent = makeBeaconInfoEvent(userA, roomId, { isLive: true }, beaconId, beaconId); + const redactionEvent = { event: { type: 'm.room.redaction' } }; + redactedBeaconEvent.makeRedacted(redactionEvent); - // live beacon is now not live - const updatedLiveBeaconEvent = makeBeaconInfoEvent( - userA, roomId, { isLive: false }, liveBeaconEvent.getId(), '$beacon1', - ); + state.setStateEvents([beaconEvent]); + const beaconInstance = state.beacons.get(beaconEvent.getType()); + const destroySpy = jest.spyOn(beaconInstance, 'destroy'); + expect(beaconInstance.isLive).toEqual(true); - state.setStateEvents([updatedLiveBeaconEvent]); + state.setStateEvents([redactedBeaconEvent]); + + expect(destroySpy).toHaveBeenCalled(); + expect(state.beacons.get(beaconEvent.getType())).toBe(undefined); + }); - expect(state.hasLiveBeacons).toBe(false); - expect(filterEmitCallsByEventType(RoomStateEvent.BeaconLiveness, emitSpy).length).toBe(2); - expect(emitSpy).toHaveBeenCalledWith(RoomStateEvent.BeaconLiveness, state, false); + it('updates live beacon ids once after setting state events', () => { + const liveBeaconEvent = makeBeaconInfoEvent(userA, roomId, { isLive: true }, '$beacon1', '$beacon1'); + const deadBeaconEvent = makeBeaconInfoEvent(userA, roomId, { isLive: false }, '$beacon2', '$beacon2'); + + const emitSpy = jest.spyOn(state, 'emit'); + + state.setStateEvents([liveBeaconEvent, deadBeaconEvent]); + + // called once + expect(filterEmitCallsByEventType(RoomStateEvent.BeaconLiveness, emitSpy).length).toBe(1); + + // live beacon is now not live + const updatedLiveBeaconEvent = makeBeaconInfoEvent( + userA, roomId, { isLive: false }, liveBeaconEvent.getId(), '$beacon1', + ); + + state.setStateEvents([updatedLiveBeaconEvent]); + + expect(state.hasLiveBeacons).toBe(false); + expect(filterEmitCallsByEventType(RoomStateEvent.BeaconLiveness, emitSpy).length).toBe(2); + expect(emitSpy).toHaveBeenCalledWith(RoomStateEvent.BeaconLiveness, state, false); + }); }); }); diff --git a/src/models/beacon.ts b/src/models/beacon.ts index d05647b81e0..329fc04e6bd 100644 --- a/src/models/beacon.ts +++ b/src/models/beacon.ts @@ -23,11 +23,13 @@ export enum BeaconEvent { New = "Beacon.new", Update = "Beacon.update", LivenessChange = "Beacon.LivenessChange", + Destroy = "Destroy", } export type BeaconEventHandlerMap = { [BeaconEvent.Update]: (event: MatrixEvent, beacon: Beacon) => void; [BeaconEvent.LivenessChange]: (isLive: boolean, beacon: Beacon) => void; + [BeaconEvent.Destroy]: (beaconIdentifier: string) => void; }; export const isTimestampInDuration = ( @@ -93,6 +95,9 @@ export class Beacon extends TypedEventEmitter */ private setBeacon(event: MatrixEvent): void { if (this.beacons.has(event.getType())) { - return this.beacons.get(event.getType()).update(event); + const beacon = this.beacons.get(event.getType()); + + if (event.isRedacted()) { + beacon.destroy(); + this.beacons.delete(event.getType()); + return; + } + + return beacon.update(event); + } + + if (event.isRedacted()) { + return; } const beacon = new Beacon(event); @@ -446,6 +458,7 @@ export class RoomState extends TypedEventEmitter this.reEmitter.reEmit(beacon, [ BeaconEvent.New, BeaconEvent.Update, + BeaconEvent.Destroy, BeaconEvent.LivenessChange, ]); diff --git a/src/sync.ts b/src/sync.ts index bab1da97040..8ac3949ba59 100644 --- a/src/sync.ts +++ b/src/sync.ts @@ -227,6 +227,7 @@ export class SyncApi { RoomStateEvent.Update, BeaconEvent.New, BeaconEvent.Update, + BeaconEvent.Destroy, BeaconEvent.LivenessChange, ]); From b8321290f82391efa70c38558437da9bd2537a12 Mon Sep 17 00:00:00 2001 From: Robin Date: Mon, 4 Apr 2022 10:29:35 -0400 Subject: [PATCH 14/28] Add Element video room type (#2273) --- src/@types/event.ts | 1 + src/models/room.ts | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/src/@types/event.ts b/src/@types/event.ts index 25946a5ac93..e5eac34f948 100644 --- a/src/@types/event.ts +++ b/src/@types/event.ts @@ -113,6 +113,7 @@ export const RoomCreateTypeField = "type"; export enum RoomType { Space = "m.space", UnstableCall = "org.matrix.msc3417.call", + ElementVideo = "io.element.video", } /** diff --git a/src/models/room.ts b/src/models/room.ts index f6008d7b14c..9acf3e81484 100644 --- a/src/models/room.ts +++ b/src/models/room.ts @@ -2592,6 +2592,14 @@ export class Room extends TypedEventEmitter return this.getType() === RoomType.UnstableCall; } + /** + * Returns whether the room is a video room. + * @returns {boolean} true if the room's type is RoomType.ElementVideo + */ + public isElementVideoRoom(): boolean { + return this.getType() === RoomType.ElementVideo; + } + /** * This is an internal method. Calculates the name of the room from the current * room state. From 03f4700bd7dde43aa60bac2a0a1b7449d03265aa Mon Sep 17 00:00:00 2001 From: RiotRobot Date: Tue, 5 Apr 2022 18:07:37 +0100 Subject: [PATCH 15/28] Prepare changelog for v16.0.2-rc.1 --- CHANGELOG.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0844fd97ed6..98d728752ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,18 @@ +Changes in [16.0.2-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v16.0.2-rc.1) (2022-04-05) +============================================================================================================ + +## 🚨 BREAKING CHANGES + * Remove groups and groups-related APIs ([\#2234](https://github.com/matrix-org/matrix-js-sdk/pull/2234)). + +## ✨ Features + * Add Element video room type ([\#2273](https://github.com/matrix-org/matrix-js-sdk/pull/2273)). + * Live location sharing - handle redacted beacons ([\#2269](https://github.com/matrix-org/matrix-js-sdk/pull/2269)). + +## 🐛 Bug Fixes + * Fix getSessionsNeedingBackup() limit support ([\#2270](https://github.com/matrix-org/matrix-js-sdk/pull/2270)). Contributed by @adamvy. + * Fix issues with /search and /context API handling for threads ([\#2261](https://github.com/matrix-org/matrix-js-sdk/pull/2261)). Fixes vector-im/element-web#21543. + * Prevent exception 'Unable to set up secret storage' ([\#2260](https://github.com/matrix-org/matrix-js-sdk/pull/2260)). + Changes in [16.0.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v16.0.1) (2022-03-28) ================================================================================================== From d457fd6db060c1e0b85417571181004fd4d0f2dd Mon Sep 17 00:00:00 2001 From: RiotRobot Date: Tue, 5 Apr 2022 18:07:38 +0100 Subject: [PATCH 16/28] v16.0.2-rc.1 --- package.json | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 2664aab382e..5e0995dfe9b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-js-sdk", - "version": "16.0.1", + "version": "16.0.2-rc.1", "description": "Matrix Client-Server SDK for Javascript", "scripts": { "prepublishOnly": "yarn build", @@ -29,7 +29,7 @@ "keywords": [ "matrix-org" ], - "main": "./src/index.ts", + "main": "./lib/index.js", "browser": "./lib/browser-index.js", "matrix_src_main": "./src/index.ts", "matrix_src_browser": "./src/browser-index.js", @@ -116,5 +116,6 @@ "text", "json" ] - } + }, + "typings": "./lib/index.d.ts" } From 872033a5522d8342f71ef6abd6028a845c581402 Mon Sep 17 00:00:00 2001 From: Germain Date: Fri, 8 Apr 2022 10:34:09 +0100 Subject: [PATCH 17/28] Port #2283 to release (#2284) --- src/client.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/client.ts b/src/client.ts index 11ec7fa4d56..de9f5a6a37b 100644 --- a/src/client.ts +++ b/src/client.ts @@ -5447,9 +5447,7 @@ export class MatrixClient extends TypedEventEmitter Date: Fri, 8 Apr 2022 10:36:13 +0100 Subject: [PATCH 18/28] Prepare changelog for v17.0.0-rc.1 --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 98d728752ae..b0f7ebcb5da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +Changes in [17.0.0-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v17.0.0-rc.1) (2022-04-08) +============================================================================================================ + Changes in [16.0.2-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v16.0.2-rc.1) (2022-04-05) ============================================================================================================ From 29b427aab7951c6723a44b5ebb4a397014357b74 Mon Sep 17 00:00:00 2001 From: RiotRobot Date: Fri, 8 Apr 2022 10:36:14 +0100 Subject: [PATCH 19/28] v17.0.0-rc.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 5e0995dfe9b..be6205313cc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-js-sdk", - "version": "16.0.2-rc.1", + "version": "17.0.0-rc.1", "description": "Matrix Client-Server SDK for Javascript", "scripts": { "prepublishOnly": "yarn build", From 04a6dbbedf4b0f96efc7cf7f96d09ef932d48f0a Mon Sep 17 00:00:00 2001 From: RiotRobot Date: Fri, 8 Apr 2022 10:40:56 +0100 Subject: [PATCH 20/28] Prepare changelog for v3.42.2-rc.3 --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b0f7ebcb5da..0da094cf00b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2006,6 +2006,12 @@ All Changes * [BREAKING] Refactor the entire build process [\#1113](https://github.com/matrix-org/matrix-js-sdk/pull/1113) +Changes in [3.42.2-rc.3](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v3.42.2-rc.3) (2022-04-08) +============================================================================================================ + +## 🐛 Bug Fixes + * Make self membership less prone to races ([\#2277](https://github.com/matrix-org/matrix-js-sdk/pull/2277)). Fixes vector-im/element-web#21661. + Changes in [3.0.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v3.0.0) (2020-01-13) ================================================================================================ [Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v3.0.0-rc.1...v3.0.0) From fe36dafcc749cf35baeb875113f2c81027460476 Mon Sep 17 00:00:00 2001 From: RiotRobot Date: Fri, 8 Apr 2022 10:40:56 +0100 Subject: [PATCH 21/28] v3.42.2-rc.3 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index be6205313cc..12eaae506b8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-js-sdk", - "version": "17.0.0-rc.1", + "version": "3.42.2-rc.3", "description": "Matrix Client-Server SDK for Javascript", "scripts": { "prepublishOnly": "yarn build", From 7023fb1c998490dd8c470ec08dc4ce7f553cda86 Mon Sep 17 00:00:00 2001 From: RiotRobot Date: Fri, 8 Apr 2022 12:08:03 +0100 Subject: [PATCH 22/28] Prepare changelog for v17.0.0-rc.2 --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0da094cf00b..3f3d2667d43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +Changes in [17.0.0-rc.2](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v17.0.0-rc.2) (2022-04-08) +============================================================================================================ + Changes in [17.0.0-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v17.0.0-rc.1) (2022-04-08) ============================================================================================================ From d705a0ed9e442018fa506a912fe76973e987235a Mon Sep 17 00:00:00 2001 From: RiotRobot Date: Fri, 8 Apr 2022 12:08:04 +0100 Subject: [PATCH 23/28] v17.0.0-rc.2 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 12eaae506b8..74287763eec 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-js-sdk", - "version": "3.42.2-rc.3", + "version": "17.0.0-rc.2", "description": "Matrix Client-Server SDK for Javascript", "scripts": { "prepublishOnly": "yarn build", From 877e3df71b0a0ed7bf6d9554a673a541ccecd109 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Mon, 11 Apr 2022 11:29:22 +0100 Subject: [PATCH 24/28] [Release] Port multiple threads fixes (#2292) Co-authored-by: Germain Souquet --- spec/integ/matrix-client-methods.spec.js | 27 +- spec/test-utils/test-utils.ts | 18 +- spec/unit/matrix-client.spec.ts | 2 +- spec/unit/room.spec.ts | 420 ++++++++++++++++------- src/client.ts | 74 +--- src/event-mapper.ts | 8 +- src/models/event-timeline-set.ts | 4 +- src/models/event.ts | 5 - src/models/relations.ts | 2 +- src/models/room.ts | 197 ++++++++--- src/models/thread.ts | 85 +++-- src/sync.ts | 29 +- 12 files changed, 548 insertions(+), 323 deletions(-) diff --git a/spec/integ/matrix-client-methods.spec.js b/spec/integ/matrix-client-methods.spec.js index 7b6a7be0a06..b56744cbabc 100644 --- a/spec/integ/matrix-client-methods.spec.js +++ b/spec/integ/matrix-client-methods.spec.js @@ -145,12 +145,14 @@ describe("MatrixClient", function() { describe("joinRoom", function() { it("should no-op if you've already joined a room", function() { const roomId = "!foo:bar"; - const room = new Room(roomId, userId); + const room = new Room(roomId, client, userId); + client.fetchRoomEvent = () => Promise.resolve({}); room.addLiveEvents([ utils.mkMembership({ user: userId, room: roomId, mship: "join", event: true, }), ]); + httpBackend.verifyNoOutstandingRequests(); store.storeRoom(room); client.joinRoom(roomId); httpBackend.verifyNoOutstandingRequests(); @@ -556,11 +558,14 @@ describe("MatrixClient", function() { }); describe("partitionThreadedEvents", function() { - const room = new Room("!STrMRsukXHtqQdSeHa:matrix.org", client, userId); + let room; + beforeEach(() => { + room = new Room("!STrMRsukXHtqQdSeHa:matrix.org", client, userId); + }); it("returns empty arrays when given an empty arrays", function() { const events = []; - const [timeline, threaded] = client.partitionThreadedEvents(room, events); + const [timeline, threaded] = room.partitionThreadedEvents(events); expect(timeline).toEqual([]); expect(threaded).toEqual([]); }); @@ -580,7 +585,7 @@ describe("MatrixClient", function() { // Vote has no threadId yet expect(eventPollResponseReference.threadId).toBeFalsy(); - const [timeline, threaded] = client.partitionThreadedEvents(room, events); + const [timeline, threaded] = room.partitionThreadedEvents(events); expect(timeline).toEqual([ // The message that was sent in a thread is missing @@ -613,7 +618,7 @@ describe("MatrixClient", function() { eventReaction, ]; - const [timeline, threaded] = client.partitionThreadedEvents(room, events); + const [timeline, threaded] = room.partitionThreadedEvents(events); expect(timeline).toEqual([ eventPollStartThreadRoot, @@ -640,7 +645,7 @@ describe("MatrixClient", function() { eventMessageInThread, ]; - const [timeline, threaded] = client.partitionThreadedEvents(room, events); + const [timeline, threaded] = room.partitionThreadedEvents(events); expect(timeline).toEqual([ eventPollStartThreadRoot, @@ -667,7 +672,7 @@ describe("MatrixClient", function() { eventReaction, ]; - const [timeline, threaded] = client.partitionThreadedEvents(room, events); + const [timeline, threaded] = room.partitionThreadedEvents(events); expect(timeline).toEqual([ eventPollStartThreadRoot, @@ -710,7 +715,7 @@ describe("MatrixClient", function() { eventMember, eventCreate, ]; - const [timeline, threaded] = client.partitionThreadedEvents(room, events); + const [timeline, threaded] = room.partitionThreadedEvents(events); expect(timeline).toEqual([ // The message that was sent in a thread is missing @@ -749,7 +754,7 @@ describe("MatrixClient", function() { threadedReactionRedaction, ]; - const [timeline, threaded] = client.partitionThreadedEvents(room, events); + const [timeline, threaded] = room.partitionThreadedEvents(events); expect(timeline).toEqual([ threadRootEvent, @@ -778,7 +783,7 @@ describe("MatrixClient", function() { replyToReply, ]; - const [timeline, threaded] = client.partitionThreadedEvents(room, events); + const [timeline, threaded] = room.partitionThreadedEvents(events); expect(timeline).toEqual([ threadRootEvent, @@ -805,7 +810,7 @@ describe("MatrixClient", function() { replyToThreadResponse, ]; - const [timeline, threaded] = client.partitionThreadedEvents(room, events); + const [timeline, threaded] = room.partitionThreadedEvents(events); expect(timeline).toEqual([ threadRootEvent, diff --git a/spec/test-utils/test-utils.ts b/spec/test-utils/test-utils.ts index 24f7b9966ec..5b4fb985063 100644 --- a/spec/test-utils/test-utils.ts +++ b/spec/test-utils/test-utils.ts @@ -8,6 +8,7 @@ import { logger } from '../../src/logger'; import { IContent, IEvent, IUnsigned, MatrixEvent, MatrixEventEvent } from "../../src/models/event"; import { ClientEvent, EventType, MatrixClient } from "../../src"; import { SyncState } from "../../src/sync"; +import { eventMapperFor } from "../../src/event-mapper"; /** * Return a promise that is resolved when the client next emits a @@ -79,6 +80,7 @@ interface IEventOpts { redacts?: string; } +let testEventIndex = 1; // counter for events, easier for comparison of randomly generated events /** * Create an Event. * @param {Object} opts Values for the event. @@ -88,9 +90,10 @@ interface IEventOpts { * @param {string} opts.skey Optional. The state key (auto inserts empty string) * @param {Object} opts.content The event.content * @param {boolean} opts.event True to make a MatrixEvent. + * @param {MatrixClient} client If passed along with opts.event=true will be used to set up re-emitters. * @return {Object} a JSON object representing this event. */ -export function mkEvent(opts: IEventOpts): object | MatrixEvent { +export function mkEvent(opts: IEventOpts, client?: MatrixClient): object | MatrixEvent { if (!opts.type || !opts.content) { throw new Error("Missing .type or .content =>" + JSON.stringify(opts)); } @@ -100,7 +103,8 @@ export function mkEvent(opts: IEventOpts): object | MatrixEvent { sender: opts.sender || opts.user, // opts.user for backwards-compat content: opts.content, unsigned: opts.unsigned || {}, - event_id: "$" + Math.random() + "-" + Math.random(), + event_id: "$" + testEventIndex++ + "-" + Math.random() + "-" + Math.random(), + txn_id: "~" + Math.random(), redacts: opts.redacts, }; if (opts.skey !== undefined) { @@ -116,6 +120,11 @@ export function mkEvent(opts: IEventOpts): object | MatrixEvent { ].includes(opts.type)) { event.state_key = ""; } + + if (opts.event && client) { + return eventMapperFor(client, {})(event); + } + return opts.event ? new MatrixEvent(event) : event; } @@ -208,9 +217,10 @@ interface IMessageOpts { * @param {string} opts.user The user ID for the event. * @param {string} opts.msg Optional. The content.body for the event. * @param {boolean} opts.event True to make a MatrixEvent. + * @param {MatrixClient} client If passed along with opts.event=true will be used to set up re-emitters. * @return {Object|MatrixEvent} The event */ -export function mkMessage(opts: IMessageOpts): object | MatrixEvent { +export function mkMessage(opts: IMessageOpts, client?: MatrixClient): object | MatrixEvent { const eventOpts: IEventOpts = { ...opts, type: EventType.RoomMessage, @@ -223,7 +233,7 @@ export function mkMessage(opts: IMessageOpts): object | MatrixEvent { if (!eventOpts.content.body) { eventOpts.content.body = "Random->" + Math.random(); } - return mkEvent(eventOpts); + return mkEvent(eventOpts, client); } /** diff --git a/spec/unit/matrix-client.spec.ts b/spec/unit/matrix-client.spec.ts index c49bdccc673..b363daa2326 100644 --- a/spec/unit/matrix-client.spec.ts +++ b/spec/unit/matrix-client.spec.ts @@ -981,7 +981,7 @@ describe("MatrixClient", function() { expect(rootEvent.isThreadRoot).toBe(true); - const [roomEvents, threadEvents] = client.partitionThreadedEvents(room, [rootEvent]); + const [roomEvents, threadEvents] = room.partitionThreadedEvents([rootEvent]); expect(roomEvents).toHaveLength(1); expect(threadEvents).toHaveLength(1); diff --git a/spec/unit/room.spec.ts b/spec/unit/room.spec.ts index faa73ba29b9..85f4c21d572 100644 --- a/spec/unit/room.spec.ts +++ b/spec/unit/room.spec.ts @@ -35,6 +35,8 @@ import { 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"; +import { emitPromise } from "../test-utils/test-utils"; +import { ThreadEvent } from "../../src/models/thread"; describe("Room", function() { const roomId = "!foo:bar"; @@ -44,8 +46,86 @@ describe("Room", function() { const userD = "@dorothy:bar"; let room; + const mkMessage = () => utils.mkMessage({ + event: true, + user: userA, + room: roomId, + }, room.client) as MatrixEvent; + + const mkReply = (target: MatrixEvent) => utils.mkEvent({ + event: true, + type: EventType.RoomMessage, + user: userA, + room: roomId, + content: { + "body": "Reply :: " + Math.random(), + "m.relates_to": { + "m.in_reply_to": { + "event_id": target.getId(), + }, + }, + }, + }, room.client) as MatrixEvent; + + const mkEdit = (target: MatrixEvent, salt = Math.random()) => utils.mkEvent({ + event: true, + type: EventType.RoomMessage, + user: userA, + room: roomId, + content: { + "body": "* Edit of :: " + target.getId() + " :: " + salt, + "m.new_content": { + body: "Edit of :: " + target.getId() + " :: " + salt, + }, + "m.relates_to": { + rel_type: RelationType.Replace, + event_id: target.getId(), + }, + }, + }, room.client) as MatrixEvent; + + const mkThreadResponse = (root: MatrixEvent) => utils.mkEvent({ + event: true, + type: EventType.RoomMessage, + user: userA, + room: roomId, + content: { + "body": "Thread response :: " + Math.random(), + "m.relates_to": { + "event_id": root.getId(), + "m.in_reply_to": { + "event_id": root.getId(), + }, + "rel_type": "m.thread", + }, + }, + }, room.client) as MatrixEvent; + + const mkReaction = (target: MatrixEvent) => utils.mkEvent({ + event: true, + type: EventType.Reaction, + user: userA, + room: roomId, + content: { + "m.relates_to": { + "rel_type": RelationType.Annotation, + "event_id": target.getId(), + "key": Math.random().toString(), + }, + }, + }, room.client) as MatrixEvent; + + const mkRedaction = (target: MatrixEvent) => utils.mkEvent({ + event: true, + type: EventType.RoomRedaction, + user: userA, + room: roomId, + redacts: target.getId(), + content: {}, + }, room.client) as MatrixEvent; + beforeEach(function() { - room = new Room(roomId, null, userA); + room = new Room(roomId, new TestClient(userA, "device").client, userA); // mock RoomStates room.oldState = room.getLiveTimeline().startState = utils.mock(RoomState, "oldState"); room.currentState = room.getLiveTimeline().endState = utils.mock(RoomState, "currentState"); @@ -157,19 +237,18 @@ describe("Room", function() { expect(room.timeline[0]).toEqual(events[0]); }); - it("should emit 'Room.timeline' events", - function() { - let callCount = 0; - room.on("Room.timeline", function(event, emitRoom, toStart) { - callCount += 1; - expect(room.timeline.length).toEqual(callCount); - expect(event).toEqual(events[callCount - 1]); - expect(emitRoom).toEqual(room); - expect(toStart).toBeFalsy(); - }); - room.addLiveEvents(events); - expect(callCount).toEqual(2); + it("should emit 'Room.timeline' events", function() { + let callCount = 0; + room.on("Room.timeline", function(event, emitRoom, toStart) { + callCount += 1; + expect(room.timeline.length).toEqual(callCount); + expect(event).toEqual(events[callCount - 1]); + expect(emitRoom).toEqual(room); + expect(toStart).toBeFalsy(); }); + room.addLiveEvents(events); + expect(callCount).toEqual(2); + }); it("should call setStateEvents on the right RoomState with the right forwardLooking value for new events", function() { @@ -338,43 +417,42 @@ describe("Room", function() { expect(oldEv.sender).toEqual(oldSentinel); }); - it("should set event.target for new and old m.room.member events", - function() { - const sentinel = { - userId: userA, - membership: "join", - name: "Alice", - }; - const oldSentinel = { - userId: userA, - membership: "join", - name: "Old Alice", - }; - room.currentState.getSentinelMember.mockImplementation(function(uid) { - if (uid === userA) { - return sentinel; - } - return null; - }); - room.oldState.getSentinelMember.mockImplementation(function(uid) { - if (uid === userA) { - return oldSentinel; - } - return null; - }); - - const newEv = utils.mkMembership({ - room: roomId, mship: "invite", user: userB, skey: userA, event: true, - }) as MatrixEvent; - const oldEv = utils.mkMembership({ - room: roomId, mship: "ban", user: userB, skey: userA, event: true, - }) as MatrixEvent; - room.addLiveEvents([newEv]); - expect(newEv.target).toEqual(sentinel); - room.addEventsToTimeline([oldEv], true, room.getLiveTimeline()); - expect(oldEv.target).toEqual(oldSentinel); + it("should set event.target for new and old m.room.member events", function() { + const sentinel = { + userId: userA, + membership: "join", + name: "Alice", + }; + const oldSentinel = { + userId: userA, + membership: "join", + name: "Old Alice", + }; + room.currentState.getSentinelMember.mockImplementation(function(uid) { + if (uid === userA) { + return sentinel; + } + return null; + }); + room.oldState.getSentinelMember.mockImplementation(function(uid) { + if (uid === userA) { + return oldSentinel; + } + return null; }); + const newEv = utils.mkMembership({ + room: roomId, mship: "invite", user: userB, skey: userA, event: true, + }) as MatrixEvent; + const oldEv = utils.mkMembership({ + room: roomId, mship: "ban", user: userB, skey: userA, event: true, + }) as MatrixEvent; + room.addLiveEvents([newEv]); + expect(newEv.target).toEqual(sentinel); + room.addEventsToTimeline([oldEv], true, room.getLiveTimeline()); + expect(oldEv.target).toEqual(oldSentinel); + }); + it("should call setStateEvents on the right RoomState with the right " + "forwardLooking value for old events", function() { const events: MatrixEvent[] = [ @@ -406,7 +484,7 @@ describe("Room", function() { let events = null; beforeEach(function() { - room = new Room(roomId, null, null, { timelineSupport: timelineSupport }); + room = new Room(roomId, new TestClient(userA).client, userA, { timelineSupport: timelineSupport }); // set events each time to avoid resusing Event objects (which // doesn't work because they get frozen) events = [ @@ -469,8 +547,7 @@ describe("Room", function() { expect(callCount).toEqual(1); }); - it("should " + (timelineSupport ? "remember" : "forget") + - " old timelines", function() { + it("should " + (timelineSupport ? "remember" : "forget") + " old timelines", function() { room.addLiveEvents([events[0]]); expect(room.timeline.length).toEqual(1); const firstLiveTimeline = room.getLiveTimeline(); @@ -486,7 +563,7 @@ describe("Room", function() { describe("compareEventOrdering", function() { beforeEach(function() { - room = new Room(roomId, null, null, { timelineSupport: true }); + room = new Room(roomId, new TestClient(userA).client, userA, { timelineSupport: true }); }); const events: MatrixEvent[] = [ @@ -673,7 +750,7 @@ describe("Room", function() { beforeEach(function() { // no mocking - room = new Room(roomId, null, userA); + room = new Room(roomId, new TestClient(userA).client, userA); }); describe("Room.recalculate => Stripped State Events", function() { @@ -1259,6 +1336,7 @@ describe("Room", function() { const client = (new TestClient( "@alice:example.com", "alicedevice", )).client; + client.supportsExperimentalThreads = () => true; const room = new Room(roomId, client, userA, { pendingEventOrdering: PendingEventOrdering.Detached, }); @@ -1285,7 +1363,7 @@ describe("Room", function() { it("should add pending events to the timeline if " + "pendingEventOrdering == 'chronological'", function() { - room = new Room(roomId, null, userA, { + const room = new Room(roomId, new TestClient(userA).client, userA, { pendingEventOrdering: PendingEventOrdering.Chronological, }); const eventA = utils.mkMessage({ @@ -1504,7 +1582,7 @@ describe("Room", function() { describe("guessDMUserId", function() { it("should return first hero id", function() { - const room = new Room(roomId, null, userA); + const room = new Room(roomId, new TestClient(userA).client, userA); room.setSummary({ 'm.heroes': [userB], 'm.joined_member_count': 1, @@ -1513,7 +1591,7 @@ describe("Room", function() { expect(room.guessDMUserId()).toEqual(userB); }); it("should return first member that isn't self", function() { - const room = new Room(roomId, null, userA); + const room = new Room(roomId, new TestClient(userA).client, userA); room.addLiveEvents([utils.mkMembership({ user: userB, mship: "join", @@ -1523,7 +1601,7 @@ describe("Room", function() { expect(room.guessDMUserId()).toEqual(userB); }); it("should return self if only member present", function() { - const room = new Room(roomId, null, userA); + const room = new Room(roomId, new TestClient(userA).client, userA); expect(room.guessDMUserId()).toEqual(userA); }); }); @@ -1542,12 +1620,12 @@ describe("Room", function() { describe("getDefaultRoomName", function() { it("should return 'Empty room' if a user is the only member", function() { - const room = new Room(roomId, null, userA); + const room = new Room(roomId, new TestClient(userA).client, userA); expect(room.getDefaultRoomName(userA)).toEqual("Empty room"); }); it("should return a display name if one other member is in the room", function() { - const room = new Room(roomId, null, userA); + const room = new Room(roomId, new TestClient(userA).client, userA); room.addLiveEvents([ utils.mkMembership({ user: userA, mship: "join", @@ -1562,7 +1640,7 @@ describe("Room", function() { }); it("should return a display name if one other member is banned", function() { - const room = new Room(roomId, null, userA); + const room = new Room(roomId, new TestClient(userA).client, userA); room.addLiveEvents([ utils.mkMembership({ user: userA, mship: "join", @@ -1577,7 +1655,7 @@ describe("Room", function() { }); it("should return a display name if one other member is invited", function() { - const room = new Room(roomId, null, userA); + const room = new Room(roomId, new TestClient(userA).client, userA); room.addLiveEvents([ utils.mkMembership({ user: userA, mship: "join", @@ -1592,7 +1670,7 @@ describe("Room", function() { }); it("should return 'Empty room (was User B)' if User B left the room", function() { - const room = new Room(roomId, null, userA); + const room = new Room(roomId, new TestClient(userA).client, userA); room.addLiveEvents([ utils.mkMembership({ user: userA, mship: "join", @@ -1607,7 +1685,7 @@ describe("Room", function() { }); it("should return 'User B and User C' if in a room with two other users", function() { - const room = new Room(roomId, null, userA); + const room = new Room(roomId, new TestClient(userA).client, userA); room.addLiveEvents([ utils.mkMembership({ user: userA, mship: "join", @@ -1626,7 +1704,7 @@ describe("Room", function() { }); it("should return 'User B and 2 others' if in a room with three other users", function() { - const room = new Room(roomId, null, userA); + const room = new Room(roomId, new TestClient(userA).client, userA); room.addLiveEvents([ utils.mkMembership({ user: userA, mship: "join", @@ -1651,7 +1729,7 @@ describe("Room", function() { describe("io.element.functional_users", function() { it("should return a display name (default behaviour) if no one is marked as a functional member", function() { - const room = new Room(roomId, null, userA); + const room = new Room(roomId, new TestClient(userA).client, userA); room.addLiveEvents([ utils.mkMembership({ user: userA, mship: "join", @@ -1673,7 +1751,7 @@ describe("Room", function() { }); it("should return a display name (default behaviour) if service members is a number (invalid)", function() { - const room = new Room(roomId, null, userA); + const room = new Room(roomId, new TestClient(userA).client, userA); room.addLiveEvents([ utils.mkMembership({ user: userA, mship: "join", @@ -1697,7 +1775,7 @@ describe("Room", function() { }); it("should return a display name (default behaviour) if service members is a string (invalid)", function() { - const room = new Room(roomId, null, userA); + const room = new Room(roomId, new TestClient(userA).client, userA); room.addLiveEvents([ utils.mkMembership({ user: userA, mship: "join", @@ -1719,7 +1797,7 @@ describe("Room", function() { }); it("should return 'Empty room' if the only other member is a functional member", function() { - const room = new Room(roomId, null, userA); + const room = new Room(roomId, new TestClient(userA).client, userA); room.addLiveEvents([ utils.mkMembership({ user: userA, mship: "join", @@ -1741,7 +1819,7 @@ describe("Room", function() { }); it("should return 'User B' if User B is the only other member who isn't a functional member", function() { - const room = new Room(roomId, null, userA); + const room = new Room(roomId, new TestClient(userA).client, userA); room.addLiveEvents([ utils.mkMembership({ user: userA, mship: "join", @@ -1767,7 +1845,7 @@ describe("Room", function() { }); it("should return 'Empty room' if all other members are functional members", function() { - const room = new Room(roomId, null, userA); + const room = new Room(roomId, new TestClient(userA).client, userA); room.addLiveEvents([ utils.mkMembership({ user: userA, mship: "join", @@ -1793,7 +1871,7 @@ describe("Room", function() { }); it("should not break if an unjoined user is marked as a service user", function() { - const room = new Room(roomId, null, userA); + const room = new Room(roomId, new TestClient(userA).client, userA); room.addLiveEvents([ utils.mkMembership({ user: userA, mship: "join", @@ -1821,6 +1899,7 @@ describe("Room", function() { "@alice:example.com", "alicedevice", )).client; room = new Room(roomId, client, userA); + client.getRoom = () => room; }); it("allow create threads without a root event", function() { @@ -1858,71 +1937,162 @@ describe("Room", function() { expect(() => room.createThread(rootEvent, [])).not.toThrow(); }); - }); - describe("eventShouldLiveIn", () => { - const room = new Room(roomId, null, userA); + it("Edits update the lastReply event", async () => { + room.client.supportsExperimentalThreads = () => true; - const mkMessage = () => utils.mkMessage({ - event: true, - user: userA, - room: roomId, - }) as MatrixEvent; + const randomMessage = mkMessage(); + const threadRoot = mkMessage(); + const threadResponse = mkThreadResponse(threadRoot); + threadResponse.localTimestamp += 1000; + const threadResponseEdit = mkEdit(threadResponse); + threadResponseEdit.localTimestamp += 2000; - const mkReply = (target: MatrixEvent) => utils.mkEvent({ - event: true, - type: EventType.RoomMessage, - user: userA, - room: roomId, - content: { - "body": "Reply :: " + Math.random(), - "m.relates_to": { - "m.in_reply_to": { - "event_id": target.getId(), + room.client.fetchRoomEvent = (eventId: string) => Promise.resolve({ + ...threadRoot.event, + unsigned: { + "age": 123, + "m.relations": { + "m.thread": { + latest_event: threadResponse.event, + count: 2, + current_user_participated: true, + }, }, }, - }, - }) as MatrixEvent; + }); - const mkThreadResponse = (root: MatrixEvent) => utils.mkEvent({ - event: true, - type: EventType.RoomMessage, - user: userA, - room: roomId, - content: { - "body": "Thread response :: " + Math.random(), - "m.relates_to": { - "event_id": root.getId(), - "m.in_reply_to": { - "event_id": root.getId(), + room.addLiveEvents([randomMessage, threadRoot, threadResponse]); + const thread = await emitPromise(room, ThreadEvent.New); + + expect(thread.replyToEvent).toBe(threadResponse); + expect(thread.replyToEvent.getContent().body).toBe(threadResponse.getContent().body); + + room.addLiveEvents([threadResponseEdit]); + await emitPromise(thread, ThreadEvent.Update); + expect(thread.replyToEvent.getContent().body).toBe(threadResponseEdit.getContent()["m.new_content"].body); + }); + + it("Redactions to thread responses decrement the length", async () => { + room.client.supportsExperimentalThreads = () => true; + + const threadRoot = mkMessage(); + const threadResponse1 = mkThreadResponse(threadRoot); + threadResponse1.localTimestamp += 1000; + const threadResponse2 = mkThreadResponse(threadRoot); + threadResponse2.localTimestamp += 2000; + + room.client.fetchRoomEvent = (eventId: string) => Promise.resolve({ + ...threadRoot.event, + unsigned: { + "age": 123, + "m.relations": { + "m.thread": { + latest_event: threadResponse2.event, + count: 2, + current_user_participated: true, + }, }, - "rel_type": "m.thread", }, - }, - }) as MatrixEvent; + }); - const mkReaction = (target: MatrixEvent) => utils.mkEvent({ - event: true, - type: EventType.Reaction, - user: userA, - room: roomId, - content: { - "m.relates_to": { - "rel_type": RelationType.Annotation, - "event_id": target.getId(), - "key": Math.random().toString(), + room.addLiveEvents([threadRoot, threadResponse1, threadResponse2]); + const thread = await emitPromise(room, ThreadEvent.New); + + expect(thread).toHaveLength(2); + expect(thread.replyToEvent.getId()).toBe(threadResponse2.getId()); + + const threadResponse1Redaction = mkRedaction(threadResponse1); + room.addLiveEvents([threadResponse1Redaction]); + await emitPromise(thread, ThreadEvent.Update); + expect(thread).toHaveLength(1); + expect(thread.replyToEvent.getId()).toBe(threadResponse2.getId()); + }); + + it("Redactions to reactions in threads do not decrement the length", async () => { + room.client.supportsExperimentalThreads = () => true; + + const threadRoot = mkMessage(); + const threadResponse1 = mkThreadResponse(threadRoot); + threadResponse1.localTimestamp += 1000; + const threadResponse2 = mkThreadResponse(threadRoot); + threadResponse2.localTimestamp += 2000; + const threadResponse2Reaction = mkReaction(threadResponse2); + + room.client.fetchRoomEvent = (eventId: string) => Promise.resolve({ + ...threadRoot.event, + unsigned: { + "age": 123, + "m.relations": { + "m.thread": { + latest_event: threadResponse2.event, + count: 2, + current_user_participated: true, + }, + }, }, - }, - }) as MatrixEvent; + }); - const mkRedaction = (target: MatrixEvent) => utils.mkEvent({ - event: true, - type: EventType.RoomRedaction, - user: userA, - room: roomId, - redacts: target.getId(), - content: {}, - }) as MatrixEvent; + room.addLiveEvents([threadRoot, threadResponse1, threadResponse2, threadResponse2Reaction]); + const thread = await emitPromise(room, ThreadEvent.New); + + expect(thread).toHaveLength(2); + expect(thread.replyToEvent.getId()).toBe(threadResponse2.getId()); + + const threadResponse2ReactionRedaction = mkRedaction(threadResponse2Reaction); + room.addLiveEvents([threadResponse2ReactionRedaction]); + await emitPromise(thread, ThreadEvent.Update); + expect(thread).toHaveLength(2); + expect(thread.replyToEvent.getId()).toBe(threadResponse2.getId()); + }); + + it("Redacting the lastEvent finds a new lastEvent", async () => { + room.client.supportsExperimentalThreads = () => true; + + const threadRoot = mkMessage(); + const threadResponse1 = mkThreadResponse(threadRoot); + threadResponse1.localTimestamp += 1000; + const threadResponse2 = mkThreadResponse(threadRoot); + threadResponse2.localTimestamp += 2000; + + room.client.fetchRoomEvent = (eventId: string) => Promise.resolve({ + ...threadRoot.event, + unsigned: { + "age": 123, + "m.relations": { + "m.thread": { + latest_event: threadResponse2.event, + count: 2, + current_user_participated: true, + }, + }, + }, + }); + + room.addLiveEvents([threadRoot, threadResponse1, threadResponse2]); + const thread = await emitPromise(room, ThreadEvent.New); + + expect(thread).toHaveLength(2); + expect(thread.replyToEvent.getId()).toBe(threadResponse2.getId()); + + const threadResponse2Redaction = mkRedaction(threadResponse2); + room.addLiveEvents([threadResponse2Redaction]); + await emitPromise(thread, ThreadEvent.Update); + expect(thread).toHaveLength(1); + expect(thread.replyToEvent.getId()).toBe(threadResponse1.getId()); + + const threadResponse1Redaction = mkRedaction(threadResponse1); + room.addLiveEvents([threadResponse1Redaction]); + await emitPromise(thread, ThreadEvent.Update); + expect(thread).toHaveLength(0); + expect(thread.replyToEvent.getId()).toBe(threadRoot.getId()); + }); + }); + + describe("eventShouldLiveIn", () => { + const client = new TestClient(userA).client; + client.supportsExperimentalThreads = () => true; + const room = new Room(roomId, client, userA); it("thread root and its relations&redactions should be in both", () => { const randomMessage = mkMessage(); diff --git a/src/client.ts b/src/client.ts index de9f5a6a37b..4a460ec1e1b 100644 --- a/src/client.ts +++ b/src/client.ts @@ -3771,9 +3771,8 @@ export class MatrixClient extends TypedEventEmitter { - const threadRoots = new Set(); - for (const event of events) { - if (event.isThreadRelation) { - threadRoots.add(event.relationEventId); - } - } - return threadRoots; - } - - public partitionThreadedEvents(room: Room, events: MatrixEvent[]): [ - timelineEvents: MatrixEvent[], - threadedEvents: MatrixEvent[], - ] { - // Indices to the events array, for readability - const ROOM = 0; - const THREAD = 1; - if (this.supportsExperimentalThreads()) { - const threadRoots = this.findThreadRoots(events); - return events.reduce((memo, event: MatrixEvent) => { - const { - shouldLiveInRoom, - shouldLiveInThread, - threadId, - } = room.eventShouldLiveIn(event, events, threadRoots); - - if (shouldLiveInRoom) { - memo[ROOM].push(event); - } - - if (shouldLiveInThread) { - event.setThreadId(threadId); - memo[THREAD].push(event); - } - - return memo; - }, [[], []]); - } else { - // When `experimentalThreadSupport` is disabled - // treat all events as timelineEvents - return [ - events, - [], - ]; - } - } - /** * @experimental */ @@ -8909,9 +8857,7 @@ export class MatrixClient extends TypedEventEmitter { - for (const event of threadedEvents) { - await room.addThreadedEvent(event, toStartOfTimeline); - } + await room.processThreadedEvents(threadedEvents, toStartOfTimeline); } /** diff --git a/src/event-mapper.ts b/src/event-mapper.ts index 7b4f106f884..92b2683046c 100644 --- a/src/event-mapper.ts +++ b/src/event-mapper.ts @@ -45,8 +45,9 @@ export function eventMapperFor(client: MatrixClient, options: MapperOpts): Event event.setUnsigned({ ...event.getUnsigned(), ...plainOldJsObject.unsigned }); } - if (room?.threads.has(event.getId())) { - event.setThread(room.threads.get(event.getId())); + const thread = room?.findThreadForEvent(event); + if (thread) { + event.setThread(thread); } if (event.isEncrypted()) { @@ -65,6 +66,9 @@ export function eventMapperFor(client: MatrixClient, options: MapperOpts): Event MatrixEventEvent.Replaced, MatrixEventEvent.VisibilityChange, ]); + room?.reEmitter.reEmit(event, [ + MatrixEventEvent.BeforeRedaction, + ]); } return event; } diff --git a/src/models/event-timeline-set.ts b/src/models/event-timeline-set.ts index 1fda0d977a4..13ea8c458f2 100644 --- a/src/models/event-timeline-set.ts +++ b/src/models/event-timeline-set.ts @@ -775,7 +775,7 @@ export class EventTimelineSet extends TypedEventEmitter(RelationType.Replace); - const minTs = replaceRelation && replaceRelation.origin_server_ts; + const minTs = replaceRelation?.origin_server_ts; const lastReplacement = this.getRelations().reduce((last, event) => { if (event.getSender() !== this.targetEvent.getSender()) { diff --git a/src/models/room.ts b/src/models/room.ts index 9acf3e81484..9f1d9afbc09 100644 --- a/src/models/room.ts +++ b/src/models/room.ts @@ -22,8 +22,8 @@ import { EventTimelineSet, DuplicateStrategy } from "./event-timeline-set"; import { Direction, EventTimeline } from "./event-timeline"; import { getHttpUriForMxc } from "../content-repo"; import * as utils from "../utils"; -import { normalize } from "../utils"; -import { IEvent, IThreadBundledRelationship, MatrixEvent } from "./event"; +import { defer, normalize } from "../utils"; +import { IEvent, IThreadBundledRelationship, MatrixEvent, MatrixEventEvent, MatrixEventHandlerMap } from "./event"; import { EventStatus } from "./event-status"; import { RoomMember } from "./room-member"; import { IRoomSummary, RoomSummary } from "./room-summary"; @@ -171,7 +171,8 @@ type EmittedEvents = RoomEvent | ThreadEvent.Update | ThreadEvent.NewReply | RoomEvent.Timeline - | RoomEvent.TimelineReset; + | RoomEvent.TimelineReset + | MatrixEventEvent.BeforeRedaction; export type RoomEventHandlerMap = { [RoomEvent.MyMembership]: (room: Room, membership: string, prevMembership?: string) => void; @@ -188,10 +189,10 @@ export type RoomEventHandlerMap = { oldStatus?: EventStatus, ) => void; [ThreadEvent.New]: (thread: Thread, toStartOfTimeline: boolean) => void; -} & ThreadHandlerMap; +} & ThreadHandlerMap & MatrixEventHandlerMap; export class Room extends TypedEventEmitter { - private readonly reEmitter: TypedReEmitter; + public readonly reEmitter: TypedReEmitter; private txnToEvent: Record = {}; // Pending in-flight requests { string: MatrixEvent } // receipts should clobber based on receipt_type and user_id pairs hence // the form of this structure. This is sub-optimal for the exposed APIs @@ -213,6 +214,8 @@ export class Room extends TypedEventEmitter private getTypeWarning = false; private getVersionWarning = false; private membersPromise?: Promise; + // Map from threadId to pending Thread instance created by createThreadFetchRoot + private threadPromises = new Map>(); // XXX: These should be read-only /** @@ -381,7 +384,7 @@ export class Room extends TypedEventEmitter return this.threadTimelineSetsPromise; } - if (this.client?.supportsExperimentalThreads) { + if (this.client?.supportsExperimentalThreads()) { try { this.threadTimelineSetsPromise = Promise.all([ this.createThreadTimelineSet(), @@ -1567,6 +1570,13 @@ export class Room extends TypedEventEmitter shouldLiveInThread: boolean; threadId?: string; } { + if (!this.client.supportsExperimentalThreads()) { + return { + shouldLiveInRoom: true, + shouldLiveInThread: false, + }; + } + // A thread root is always shown in both timelines if (event.isThreadRoot || roots?.has(event.getId())) { return { @@ -1581,7 +1591,7 @@ export class Room extends TypedEventEmitter return { shouldLiveInRoom: false, shouldLiveInThread: true, - threadId: event.relationEventId, + threadId: event.threadRootId, }; } @@ -1630,21 +1640,23 @@ export class Room extends TypedEventEmitter threadId: string, events?: MatrixEvent[], toStartOfTimeline?: boolean, - ): Promise { + ): Promise { let thread = this.getThread(threadId); if (!thread) { + const deferred = defer(); + this.threadPromises.set(threadId, deferred.promise); + let rootEvent = this.findEventById(threadId); // If the rootEvent does not exist in the local stores, then fetch it from the server. try { const eventData = await this.client.fetchRoomEvent(this.roomId, threadId); - - if (!rootEvent) { - rootEvent = new MatrixEvent(eventData); - } else { - rootEvent.setUnsigned(eventData.unsigned); - } + const mapper = this.client.getEventMapper(); + rootEvent = mapper(eventData); // will merge with existing event object if such is known + } catch (e) { + logger.error("Failed to fetch thread root to construct thread with", e); } finally { + this.threadPromises.delete(threadId); // The root event might be not be visible to the person requesting it. // If it wasn't fetched successfully the thread will work in "limited" mode and won't // benefit from all the APIs a homeserver can provide to enhance the thread experience @@ -1652,26 +1664,53 @@ export class Room extends TypedEventEmitter if (thread) { rootEvent.setThread(thread); } + deferred.resolve(thread); } } return thread; } + private async addThreadedEvents(events: MatrixEvent[], threadId: string, toStartOfTimeline = false): Promise { + let thread = this.getThread(threadId); + if (this.threadPromises.has(threadId)) { + thread = await this.threadPromises.get(threadId); + } + + events = events.filter(e => e.getId() !== threadId); // filter out any root events + + if (thread) { + for (const event of events) { + await thread.addEvent(event, toStartOfTimeline); + } + } else { + thread = await this.createThreadFetchRoot(threadId, events, toStartOfTimeline); + } + + if (thread) { + this.emit(ThreadEvent.Update, thread); + } + } + /** - * Add an event to a thread's timeline. Will fire "Thread.update" + * Adds events to a thread's timeline. Will fire "Thread.update" * @experimental */ - public async addThreadedEvent(event: MatrixEvent, toStartOfTimeline: boolean): Promise { - this.applyRedaction(event); - let thread = this.findThreadForEvent(event); - if (thread) { - await thread.addEvent(event, toStartOfTimeline); - } else { - thread = await this.createThreadFetchRoot(event.threadRootId, [event], toStartOfTimeline); + public async processThreadedEvents(events: MatrixEvent[], toStartOfTimeline: boolean): Promise { + events.forEach(this.applyRedaction); + + const eventsByThread: { [threadId: string]: MatrixEvent[] } = {}; + for (const event of events) { + const { threadId } = this.eventShouldLiveIn(event); + if (!eventsByThread[threadId]) { + eventsByThread[threadId] = []; + } + eventsByThread[threadId].push(event); } - this.emit(ThreadEvent.Update, thread); + return Promise.all(Object.entries(eventsByThread).map(([threadId, events]) => ( + this.addThreadedEvents(events, threadId, toStartOfTimeline) + ))); } public createThread( @@ -1728,7 +1767,7 @@ export class Room extends TypedEventEmitter } } - private applyRedaction(event: MatrixEvent): void { + private applyRedaction = (event: MatrixEvent): void => { if (event.isRedaction()) { const redactId = event.event.redacts; @@ -1738,7 +1777,7 @@ export class Room extends TypedEventEmitter redactedEvent.makeRedacted(event); // If this is in the current state, replace it with the redacted version - if (redactedEvent.getStateKey()) { + if (redactedEvent.isState()) { const currentStateEvent = this.currentState.getStateEvents( redactedEvent.getType(), redactedEvent.getStateKey(), @@ -1772,19 +1811,9 @@ export class Room extends TypedEventEmitter // clients can say "so and so redacted an event" if they wish to. Also // this may be needed to trigger an update. } - } + }; - /** - * Add an event to the end of this room's live timelines. Will fire - * "Room.timeline". - * - * @param {MatrixEvent} event Event to be added - * @param {string?} duplicateStrategy 'ignore' or 'replace' - * @param {boolean} fromCache whether the sync response came from cache - * @fires module:client~MatrixClient#event:"Room.timeline" - * @private - */ - private addLiveEvent(event: MatrixEvent, duplicateStrategy?: DuplicateStrategy, fromCache = false): void { + private processLiveEvent(event: MatrixEvent): void { this.applyRedaction(event); // Implement MSC3531: hiding messages. @@ -1801,10 +1830,21 @@ export class Room extends TypedEventEmitter if (existingEvent) { // remote echo of an event we sent earlier this.handleRemoteEcho(event, existingEvent); - return; } } + } + /** + * Add an event to the end of this room's live timelines. Will fire + * "Room.timeline". + * + * @param {MatrixEvent} event Event to be added + * @param {string?} duplicateStrategy 'ignore' or 'replace' + * @param {boolean} fromCache whether the sync response came from cache + * @fires module:client~MatrixClient#event:"Room.timeline" + * @private + */ + private addLiveEvent(event: MatrixEvent, duplicateStrategy: DuplicateStrategy, fromCache = false): void { // add to our timeline sets for (let i = 0; i < this.timelineSets.length; i++) { this.timelineSets[i].addLiveEvent(event, duplicateStrategy, fromCache); @@ -1998,10 +2038,7 @@ export class Room extends TypedEventEmitter const newEventId = remoteEvent.getId(); const oldStatus = localEvent.status; - logger.debug( - `Got remote echo for event ${oldEventId} -> ${newEventId} ` + - `old status ${oldStatus}`, - ); + logger.debug(`Got remote echo for event ${oldEventId} -> ${newEventId} old status ${oldStatus}`); // no longer pending delete this.txnToEvent[remoteEvent.getUnsigned().transaction_id]; @@ -2167,10 +2204,84 @@ export class Room extends TypedEventEmitter } } + const threadRoots = this.findThreadRoots(events); + const threadInfos = events.map(e => this.eventShouldLiveIn(e, events, threadRoots)); + const eventsByThread: { [threadId: string]: MatrixEvent[] } = {}; + for (let i = 0; i < events.length; i++) { // TODO: We should have a filter to say "only add state event types X Y Z to the timeline". - this.addLiveEvent(events[i], duplicateStrategy, fromCache); + this.processLiveEvent(events[i]); + + const { + shouldLiveInRoom, + shouldLiveInThread, + threadId, + } = threadInfos[i]; + + if (shouldLiveInThread) { + if (!eventsByThread[threadId]) { + eventsByThread[threadId] = []; + } + eventsByThread[threadId].push(events[i]); + } + + if (shouldLiveInRoom) { + this.addLiveEvent(events[i], duplicateStrategy, fromCache); + } + } + + Object.entries(eventsByThread).forEach(([threadId, threadEvents]) => { + this.addThreadedEvents(threadEvents, threadId, false); + }); + } + + public partitionThreadedEvents(events: MatrixEvent[]): [ + timelineEvents: MatrixEvent[], + threadedEvents: MatrixEvent[], + ] { + // Indices to the events array, for readability + const ROOM = 0; + const THREAD = 1; + if (this.client.supportsExperimentalThreads()) { + const threadRoots = this.findThreadRoots(events); + return events.reduce((memo, event: MatrixEvent) => { + const { + shouldLiveInRoom, + shouldLiveInThread, + threadId, + } = this.eventShouldLiveIn(event, events, threadRoots); + + if (shouldLiveInRoom) { + memo[ROOM].push(event); + } + + if (shouldLiveInThread) { + event.setThreadId(threadId); + memo[THREAD].push(event); + } + + return memo; + }, [[], []]); + } else { + // When `experimentalThreadSupport` is disabled treat all events as timelineEvents + return [ + events, + [], + ]; + } + } + + /** + * Given some events, find the IDs of all the thread roots that are referred to by them. + */ + private findThreadRoots(events: MatrixEvent[]): Set { + const threadRoots = new Set(); + for (const event of events) { + if (event.isThreadRelation) { + threadRoots.add(event.relationEventId); + } } + return threadRoots; } /** diff --git a/src/models/thread.ts b/src/models/thread.ts index 7879cc89f9c..549336b4f31 100644 --- a/src/models/thread.ts +++ b/src/models/thread.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { MatrixClient, RelationType, RoomEvent } from "../matrix"; +import { MatrixClient, MatrixEventEvent, RelationType, RoomEvent } from "../matrix"; import { TypedReEmitter } from "../ReEmitter"; import { IRelationsRequestOpts } from "../@types/requests"; import { IThreadBundledRelationship, MatrixEvent } from "./event"; @@ -94,15 +94,16 @@ export class Thread extends TypedEventEmitter { RoomEvent.TimelineReset, ]); + this.room.on(MatrixEventEvent.BeforeRedaction, this.onBeforeRedaction); + this.room.on(RoomEvent.LocalEchoUpdated, this.onEcho); + this.timelineSet.on(RoomEvent.Timeline, this.onEcho); + // If we weren't able to find the root event, it's probably missing, // and we define the thread ID from one of the thread relation this.id = rootEvent?.getId() ?? opts?.initialEvents?.find(event => event.isThreadRelation)?.relationEventId; this.initialiseThread(this.rootEvent); opts?.initialEvents?.forEach(event => this.addEvent(event, false)); - - this.room.on(RoomEvent.LocalEchoUpdated, this.onEcho); - this.room.on(RoomEvent.Timeline, this.onEcho); } public static setServerSideSupport(hasServerSideSupport: boolean, useStable: boolean): void { @@ -114,10 +115,50 @@ export class Thread extends TypedEventEmitter { } } - private onEcho = (event: MatrixEvent) => { - if (this.timelineSet.eventIdToTimeline(event.getId())) { + private onBeforeRedaction = (event: MatrixEvent) => { + if (event?.isRelation(THREAD_RELATION_TYPE.name) && + this.room.eventShouldLiveIn(event).threadId === this.id + ) { + this.replyCount--; this.emit(ThreadEvent.Update, this); } + + if (this.lastEvent?.getId() === event.getId()) { + const events = [...this.timelineSet.getLiveTimeline().getEvents()].reverse(); + this.lastEvent = events.find(e => ( + !e.isRedacted() && + e.getId() !== event.getId() && + e.isRelation(THREAD_RELATION_TYPE.name) + )) ?? this.rootEvent; + this.emit(ThreadEvent.NewReply, this, this.lastEvent); + } + }; + + private onEcho = (event: MatrixEvent) => { + if (event.threadRootId !== this.id) return; // ignore echoes for other timelines + if (this.lastEvent === event) return; + + // There is a risk that the `localTimestamp` approximation will not be accurate + // when threads are used over federation. That could result in the reply + // count value drifting away from the value returned by the server + const isThreadReply = event.isRelation(THREAD_RELATION_TYPE.name); + if (!this.lastEvent || (isThreadReply + && (event.getId() !== this.lastEvent.getId()) + && (event.localTimestamp > this.lastEvent.localTimestamp)) + ) { + this.lastEvent = event; + if (this.lastEvent.getId() !== this.id) { + // This counting only works when server side support is enabled as we started the counting + // from the value returned within the bundled relationship + if (Thread.hasServerSideSupport) { + this.replyCount++; + } + + this.emit(ThreadEvent.NewReply, this, event); + } + } + + this.emit(ThreadEvent.Update, this); }; public get roomState(): RoomState { @@ -125,15 +166,6 @@ export class Thread extends TypedEventEmitter { } private addEventToTimeline(event: MatrixEvent, toStartOfTimeline: boolean): void { - if (event.getUnsigned().transaction_id) { - const existingEvent = this.room.getEventForTxnId(event.getUnsigned().transaction_id); - if (existingEvent) { - // remote echo of an event we sent earlier - this.room.handleRemoteEcho(event, existingEvent); - return; - } - } - if (!this.findEventById(event.getId())) { this.timelineSet.addEventToTimeline( event, @@ -177,33 +209,12 @@ export class Thread extends TypedEventEmitter { this._currentUserParticipated = true; } - const isThreadReply = event.getRelation()?.rel_type === THREAD_RELATION_TYPE.name; // If no thread support exists we want to count all thread relation // added as a reply. We can't rely on the bundled relationships count - if (!Thread.hasServerSideSupport && isThreadReply) { + if (!Thread.hasServerSideSupport && event.isRelation(THREAD_RELATION_TYPE.name)) { this.replyCount++; } - // There is a risk that the `localTimestamp` approximation will not be accurate - // when threads are used over federation. That could results in the reply - // count value drifting away from the value returned by the server - if (!this.lastEvent || (isThreadReply - && (event.getId() !== this.lastEvent.getId()) - && (event.localTimestamp > this.lastEvent.localTimestamp)) - ) { - this.lastEvent = event; - if (this.lastEvent.getId() !== this.id) { - // This counting only works when server side support is enabled - // as we started the counting from the value returned in the - // bundled relationship - if (Thread.hasServerSideSupport) { - this.replyCount++; - } - - this.emit(ThreadEvent.NewReply, this, event); - } - } - this.emit(ThreadEvent.Update, this); } diff --git a/src/sync.ts b/src/sync.ts index 8ac3949ba59..1621c8f253b 100644 --- a/src/sync.ts +++ b/src/sync.ts @@ -1640,36 +1640,9 @@ export class SyncApi { // if the timeline has any state events in it. // This also needs to be done before running push rules on the events as they need // to be decorated with sender etc. - const [mainTimelineEvents, threadedEvents] = this.client.partitionThreadedEvents(room, timelineEventList || []); - room.addLiveEvents(mainTimelineEvents, null, fromCache); - await this.processThreadEvents(room, threadedEvents, false); + room.addLiveEvents(timelineEventList || [], null, fromCache); } - /** - * @experimental - */ - private processThreadEvents( - room: Room, - threadedEvents: MatrixEvent[], - toStartOfTimeline: boolean, - ): Promise { - return this.client.processThreadEvents(room, threadedEvents, toStartOfTimeline); - } - - // extractRelatedEvents(event: MatrixEvent, events: MatrixEvent[], relatedEvents: MatrixEvent[] = []): MatrixEvent[] { - // relatedEvents.push(event); - - // const parentEventId = event.getAssociatedId(); - // const parentEventIndex = events.findIndex(event => event.getId() === parentEventId); - - // if (parentEventIndex > -1) { - // const [relatedEvent] = events.splice(parentEventIndex, 1); - // return this.extractRelatedEvents(relatedEvent, events, relatedEvents); - // } else { - // return relatedEvents; - // } - // } - /** * Takes a list of timelineEvents and adds and adds to notifEvents * as appropriate. From 5305e373a0f899f5f9dd165313aa5d2d965a8f14 Mon Sep 17 00:00:00 2001 From: RiotRobot Date: Mon, 11 Apr 2022 11:32:30 +0100 Subject: [PATCH 25/28] Prepare changelog for v17.0.0-rc.3 --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f3d2667d43..c5cc0280946 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +Changes in [17.0.0-rc.3](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v17.0.0-rc.3) (2022-04-11) +============================================================================================================ + Changes in [17.0.0-rc.2](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v17.0.0-rc.2) (2022-04-08) ============================================================================================================ From c0e3ad4b835d282a32731dac480304ca472dc460 Mon Sep 17 00:00:00 2001 From: RiotRobot Date: Mon, 11 Apr 2022 11:32:30 +0100 Subject: [PATCH 26/28] v17.0.0-rc.3 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 74287763eec..deb3667129a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-js-sdk", - "version": "17.0.0-rc.2", + "version": "17.0.0-rc.3", "description": "Matrix Client-Server SDK for Javascript", "scripts": { "prepublishOnly": "yarn build", From 1375a4849c3d727622f3909309d8a6b3602fa3bb Mon Sep 17 00:00:00 2001 From: RiotRobot Date: Mon, 11 Apr 2022 16:21:09 +0100 Subject: [PATCH 27/28] Prepare changelog for v17.0.0 --- CHANGELOG.md | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c5cc0280946..93b09b018ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,17 @@ -Changes in [17.0.0-rc.3](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v17.0.0-rc.3) (2022-04-11) -============================================================================================================ +Changes in [17.0.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v17.0.0) (2022-04-11) +================================================================================================== -Changes in [17.0.0-rc.2](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v17.0.0-rc.2) (2022-04-08) -============================================================================================================ +## 🚨 BREAKING CHANGES + * Remove groups and groups-related APIs ([\#2234](https://github.com/matrix-org/matrix-js-sdk/pull/2234)). -Changes in [17.0.0-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v17.0.0-rc.1) (2022-04-08) -============================================================================================================ +## ✨ Features + * Add Element video room type ([\#2273](https://github.com/matrix-org/matrix-js-sdk/pull/2273)). + * Live location sharing - handle redacted beacons ([\#2269](https://github.com/matrix-org/matrix-js-sdk/pull/2269)). + +## 🐛 Bug Fixes + * Fix getSessionsNeedingBackup() limit support ([\#2270](https://github.com/matrix-org/matrix-js-sdk/pull/2270)). Contributed by @adamvy. + * Fix issues with /search and /context API handling for threads ([\#2261](https://github.com/matrix-org/matrix-js-sdk/pull/2261)). Fixes vector-im/element-web#21543. + * Prevent exception 'Unable to set up secret storage' ([\#2260](https://github.com/matrix-org/matrix-js-sdk/pull/2260)). Changes in [16.0.2-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v16.0.2-rc.1) (2022-04-05) ============================================================================================================ From 59763a84f803e27f48f5fc93f9456fe2c1ea9f46 Mon Sep 17 00:00:00 2001 From: RiotRobot Date: Mon, 11 Apr 2022 16:21:09 +0100 Subject: [PATCH 28/28] v17.0.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index deb3667129a..999911f7ef0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-js-sdk", - "version": "17.0.0-rc.3", + "version": "17.0.0", "description": "Matrix Client-Server SDK for Javascript", "scripts": { "prepublishOnly": "yarn build",