From 045f31a0dcf13013be253bfbe23592e569f68173 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Tue, 15 Jun 2021 10:01:22 +0100 Subject: [PATCH 01/20] Convert some stores to typescript --- src/store/index.ts | 226 ++++++++++++++++++++++++++++ src/store/{memory.js => memory.ts} | 229 ++++++++++++++--------------- src/store/{stub.js => stub.ts} | 156 ++++++++++---------- 3 files changed, 413 insertions(+), 198 deletions(-) create mode 100644 src/store/index.ts rename src/store/{memory.js => memory.ts} (71%) rename src/store/{stub.js => stub.ts} (64%) diff --git a/src/store/index.ts b/src/store/index.ts new file mode 100644 index 000000000..3d570e64f --- /dev/null +++ b/src/store/index.ts @@ -0,0 +1,226 @@ +/* +Copyright 2015 - 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { EventType } from "../@types/event"; +import { Group } from "../models/group"; +import { Room } from "../models/room"; +import { User } from "../models/user"; +import { MatrixEvent } from "../models/event"; +import { Filter } from "../filter"; +import { RoomSummary } from "../models/room-summary"; + +/** + * Construct a stub store. This does no-ops on most store methods. + * @constructor + */ +export interface IStore { + /** @return {Promise} whether or not the database was newly created in this session. */ + isNewlyCreated(): Promise; + + /** + * Get the sync token. + * @return {string} + */ + getSyncToken(): string | null; + + /** + * Set the sync token. + * @param {string} token + */ + setSyncToken(token: string); + + /** + * No-op. + * @param {Group} group + */ + storeGroup(group: Group); + + /** + * No-op. + * @param {string} groupId + * @return {null} + */ + getGroup(groupId: string): Group | null; + + /** + * No-op. + * @return {Array} An empty array. + */ + getGroups(): Group[]; + + /** + * No-op. + * @param {Room} room + */ + storeRoom(room: Room); + + /** + * No-op. + * @param {string} roomId + * @return {null} + */ + getRoom(roomId: string): Room | null; + + /** + * No-op. + * @return {Array} An empty array. + */ + getRooms(): Room[]; + + /** + * Permanently delete a room. + * @param {string} roomId + */ + removeRoom(roomId: string); + + /** + * No-op. + * @return {Array} An empty array. + */ + getRoomSummaries(): RoomSummary[]; + + /** + * No-op. + * @param {User} user + */ + storeUser(user: User); + + /** + * No-op. + * @param {string} userId + * @return {null} + */ + getUser(userId: string): User | null; + + /** + * No-op. + * @return {User[]} + */ + getUsers(): User[]; + + /** + * No-op. + * @param {Room} room + * @param {integer} limit + * @return {Array} + */ + scrollback(room: Room, limit: number): MatrixEvent[]; + + /** + * Store events for a room. + * @param {Room} room The room to store events for. + * @param {Array} events The events to store. + * @param {string} token The token associated with these events. + * @param {boolean} toStart True if these are paginated results. + */ + storeEvents(room: Room, events: MatrixEvent[], token: string, toStart: boolean); + + /** + * Store a filter. + * @param {Filter} filter + */ + storeFilter(filter: Filter); + + /** + * Retrieve a filter. + * @param {string} userId + * @param {string} filterId + * @return {?Filter} A filter or null. + */ + getFilter(userId: string, filterId: string): Filter | null; + + /** + * Retrieve a filter ID with the given name. + * @param {string} filterName The filter name. + * @return {?string} The filter ID or null. + */ + getFilterIdByName(filterName: string): string | null; + + /** + * Set a filter name to ID mapping. + * @param {string} filterName + * @param {string} filterId + */ + setFilterIdByName(filterName: string, filterId: string); + + /** + * Store user-scoped account data events + * @param {Array} events The events to store. + */ + storeAccountDataEvents(events: MatrixEvent[]); + + /** + * Get account data event by event type + * @param {string} eventType The event type being queried + */ + getAccountData(eventType: EventType | string): MatrixEvent; + + /** + * setSyncData does nothing as there is no backing data store. + * + * @param {Object} syncData The sync data + * @return {Promise} An immediately resolved promise. + */ + setSyncData(syncData: object): Promise; + + /** + * We never want to save because we have nothing to save to. + * + * @return {boolean} If the store wants to save + */ + wantsSave(): boolean; + + /** + * Save does nothing as there is no backing data store. + */ + save(force: boolean): void; + + /** + * Startup does nothing. + * @return {Promise} An immediately resolved promise. + */ + startup(): Promise; + + /** + * @return {Promise} Resolves with a sync response to restore the + * client state to where it was at the last save, or null if there + * is no saved sync data. + */ + getSavedSync(): Promise; + + /** + * @return {Promise} If there is a saved sync, the nextBatch token + * for this sync, otherwise null. + */ + getSavedSyncToken(): Promise; + + /** + * Delete all data from this store. Does nothing since this store + * doesn't store anything. + * @return {Promise} An immediately resolved promise. + */ + deleteAllData(): Promise; + + getOutOfBandMembers(roomId: string): Promise; + + setOutOfBandMembers(roomId: string, membershipEvents: MatrixEvent[]): Promise; + + clearOutOfBandMembers(): Promise; + + getClientOptions(): Promise; + + storeClientOptions(options: object): Promise; +} diff --git a/src/store/memory.js b/src/store/memory.ts similarity index 71% rename from src/store/memory.js rename to src/store/memory.ts index 809696b25..63f03bc5d 100644 --- a/src/store/memory.js +++ b/src/store/memory.ts @@ -1,8 +1,5 @@ /* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2017 Vector Creations Ltd -Copyright 2018 New Vector Ltd -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2015 - 2021 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. @@ -22,9 +19,18 @@ limitations under the License. * @module store/memory */ +import { EventType } from "../@types/event"; +import { Group } from "../models/group"; +import { Room } from "../models/room"; import { User } from "../models/user"; +import { MatrixEvent } from "../models/event"; +import { RoomState } from "../models/room-state"; +import { RoomMember } from "../models/room-member"; +import { Filter } from "../filter"; +import { IStore } from "./index"; +import { RoomSummary } from "../models/room-summary"; -function isValidFilterId(filterId) { +function isValidFilterId(filterId: string): boolean { const isValidStr = typeof filterId === "string" && !!filterId && filterId !== "undefined" && // exclude these as we've serialized undefined in localStorage before @@ -33,6 +39,10 @@ function isValidFilterId(filterId) { return isValidStr || typeof filterId === "number"; } +interface IOpts { + localStorage?: Storage; +} + /** * Construct a new in-memory data store for the Matrix Client. * @constructor @@ -40,96 +50,84 @@ function isValidFilterId(filterId) { * @param {LocalStorage} opts.localStorage The local storage instance to persist * some forms of data such as tokens. Rooms will NOT be stored. */ -export function MemoryStore(opts) { - opts = opts || {}; - this.rooms = { - // roomId: Room - }; - this.groups = { - // groupId: Group - }; - this.users = { - // userId: User - }; - this.syncToken = null; - this.filters = { - // userId: { - // filterId: Filter - // } - }; - this.accountData = { - // type : content - }; - this.localStorage = opts.localStorage; - this._oobMembers = { - // roomId: [member events] - }; - this._clientOptions = {}; -} +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: { + // filterId: Filter + // } + private filters: Record> = {}; + private accountData: Record = {}; // type : content + private readonly localStorage: Storage; + private oobMembers: Record = {}; // roomId: [member events] + private clientOptions = {}; -MemoryStore.prototype = { + constructor(opts: IOpts = {}) { + this.localStorage = opts.localStorage; + } /** * Retrieve the token to stream from. * @return {string} The token or null. */ - getSyncToken: function() { + public getSyncToken(): string | null { return this.syncToken; - }, + } - /** @return {Promise} whether or not the database was newly created in this session. */ - isNewlyCreated: function() { + /** @return {Promise} whether or not the database was newly created in this session. */ + public isNewlyCreated(): Promise { return Promise.resolve(true); - }, + } /** * Set the token to stream from. * @param {string} token The token to stream from. */ - setSyncToken: function(token) { + public setSyncToken(token: string) { this.syncToken = token; - }, + } /** * Store the given room. * @param {Group} group The group to be stored */ - storeGroup: function(group) { + 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. */ - getGroup: function(groupId) { + 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. */ - getGroups: function() { + 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. */ - storeRoom: function(room) { + public storeRoom(room: Room) { this.rooms[room.roomId] = room; // add listeners for room member changes so we can keep the room member // map up-to-date. - room.currentState.on("RoomState.members", this._onRoomMember.bind(this)); + room.currentState.on("RoomState.members", this.onRoomMember); // add existing members - const self = this; - room.currentState.getMembers().forEach(function(m) { - self._onRoomMember(null, room.currentState, m); + room.currentState.getMembers().forEach((m) => { + this.onRoomMember(null, room.currentState, m); }); - }, + } /** * Called when a room member in a room being tracked by this store has been @@ -138,7 +136,7 @@ MemoryStore.prototype = { * @param {RoomState} state * @param {RoomMember} member */ - _onRoomMember: function(event, state, member) { + private onRoomMember = (event: MatrixEvent, state: RoomState, member: RoomMember) => { if (member.membership === "invite") { // We do NOT add invited members because people love to typo user IDs // which would then show up in these lists (!) @@ -158,70 +156,70 @@ MemoryStore.prototype = { user.setAvatarUrl(member.events.member.getContent().avatar_url); } this.users[user.userId] = user; - }, + }; /** * Retrieve a room by its' room ID. * @param {string} roomId The room ID. * @return {Room} The room or null. */ - getRoom: function(roomId) { + public getRoom(roomId: string): Room | null { return this.rooms[roomId] || null; - }, + } /** * Retrieve all known rooms. * @return {Room[]} A list of rooms, which may be empty. */ - getRooms: function() { + public getRooms(): Room[] { return Object.values(this.rooms); - }, + } /** * Permanently delete a room. * @param {string} roomId */ - removeRoom: function(roomId) { + public removeRoom(roomId: string): void { if (this.rooms[roomId]) { - this.rooms[roomId].removeListener("RoomState.members", this._onRoomMember); + this.rooms[roomId].removeListener("RoomState.members", this.onRoomMember); } delete this.rooms[roomId]; - }, + } /** * Retrieve a summary of all the rooms. * @return {RoomSummary[]} A summary of each room. */ - getRoomSummaries: function() { + public getRoomSummaries(): RoomSummary[] { return Object.values(this.rooms).map(function(room) { return room.summary; }); - }, + } /** * Store a User. * @param {User} user The user to store. */ - storeUser: function(user) { + public storeUser(user: User): void { this.users[user.userId] = user; - }, + } /** * Retrieve a User by its' user ID. * @param {string} userId The user ID. * @return {User} The user or null. */ - getUser: function(userId) { + public getUser(userId: string): User | null { return this.users[userId] || null; - }, + } /** * Retrieve all known users. * @return {User[]} A list of users, which may be empty. */ - getUsers: function() { + public getUsers(): User[] { return Object.values(this.users); - }, + } /** * Retrieve scrollback for this room. @@ -230,9 +228,9 @@ MemoryStore.prototype = { * @return {Array} An array of objects which will be at most 'limit' * length and at least 0. The objects are the raw event JSON. */ - scrollback: function(room, limit) { + public scrollback(room: Room, limit: number): MatrixEvent[] { return []; - }, + } /** * Store events for a room. The events have already been added to the timeline @@ -241,15 +239,15 @@ MemoryStore.prototype = { * @param {string} token The token associated with these events. * @param {boolean} toStart True if these are paginated results. */ - storeEvents: function(room, events, token, toStart) { + public storeEvents(room: Room, events: MatrixEvent[], token: string, toStart: boolean) { // no-op because they've already been added to the room instance. - }, + } /** * Store a filter. * @param {Filter} filter */ - storeFilter: function(filter) { + public storeFilter(filter: Filter): void { if (!filter) { return; } @@ -257,7 +255,7 @@ MemoryStore.prototype = { this.filters[filter.userId] = {}; } this.filters[filter.userId][filter.filterId] = filter; - }, + } /** * Retrieve a filter. @@ -265,19 +263,19 @@ MemoryStore.prototype = { * @param {string} filterId * @return {?Filter} A filter or null. */ - getFilter: function(userId, filterId) { + public getFilter(userId: string, filterId: string): Filter | null { if (!this.filters[userId] || !this.filters[userId][filterId]) { return null; } return this.filters[userId][filterId]; - }, + } /** * Retrieve a filter ID with the given name. * @param {string} filterName The filter name. * @return {?string} The filter ID or null. */ - getFilterIdByName: function(filterName) { + public getFilterIdByName(filterName: string): string | null { if (!this.localStorage) { return null; } @@ -294,14 +292,14 @@ MemoryStore.prototype = { } } catch (e) {} return null; - }, + } /** * Set a filter name to ID mapping. * @param {string} filterName * @param {string} filterId */ - setFilterIdByName: function(filterName, filterId) { + public setFilterIdByName(filterName: string, filterId: string) { if (!this.localStorage) { return; } @@ -313,7 +311,7 @@ MemoryStore.prototype = { this.localStorage.removeItem(key); } } catch (e) {} - }, + } /** * Store user-scoped account data events. @@ -321,21 +319,20 @@ MemoryStore.prototype = { * events with the same type will replace each other. * @param {Array} events The events to store. */ - storeAccountDataEvents: function(events) { - const self = this; - events.forEach(function(event) { - self.accountData[event.getType()] = event; + public storeAccountDataEvents(events: MatrixEvent[]): void { + events.forEach((event) => { + this.accountData[event.getType()] = event; }); - }, + } /** * Get account data event by event type * @param {string} eventType The event type being queried * @return {?MatrixEvent} the user account_data event of given type, if any */ - getAccountData: function(eventType) { + public getAccountData(eventType: EventType | string): MatrixEvent | null { return this.accountData[eventType]; - }, + } /** * setSyncData does nothing as there is no backing data store. @@ -343,56 +340,56 @@ MemoryStore.prototype = { * @param {Object} syncData The sync data * @return {Promise} An immediately resolved promise. */ - setSyncData: function(syncData) { + public setSyncData(syncData: object): Promise { return Promise.resolve(); - }, + } /** * We never want to save becase we have nothing to save to. * * @return {boolean} If the store wants to save */ - wantsSave: function() { + public wantsSave(): boolean { return false; - }, + } /** * Save does nothing as there is no backing data store. * @param {bool} force True to force a save (but the memory * store still can't save anything) */ - save: function(force) {}, + public save(force: boolean): void {} /** * Startup does nothing as this store doesn't require starting up. * @return {Promise} An immediately resolved promise. */ - startup: function() { + public startup(): Promise { return Promise.resolve(); - }, + } /** * @return {Promise} Resolves with a sync response to restore the * client state to where it was at the last save, or null if there * is no saved sync data. */ - getSavedSync: function() { + public getSavedSync(): Promise { return Promise.resolve(null); - }, + } /** * @return {Promise} If there is a saved sync, the nextBatch token * for this sync, otherwise null. */ - getSavedSyncToken: function() { + public getSavedSyncToken(): Promise { return Promise.resolve(null); - }, + } /** * Delete all data from this store. * @return {Promise} An immediately resolved promise. */ - deleteAllData: function() { + public deleteAllData(): Promise { this.rooms = { // roomId: Room }; @@ -409,7 +406,7 @@ MemoryStore.prototype = { // type : content }; return Promise.resolve(); - }, + } /** * Returns the out-of-band membership events for this room that @@ -418,9 +415,9 @@ MemoryStore.prototype = { * @returns {event[]} the events, potentially an empty array if OOB loading didn't yield any new members * @returns {null} in case the members for this room haven't been stored yet */ - getOutOfBandMembers: function(roomId) { - return Promise.resolve(this._oobMembers[roomId] || null); - }, + public getOutOfBandMembers(roomId: string): Promise { + return Promise.resolve(this.oobMembers[roomId] || null); + } /** * Stores the out-of-band membership events for this room. Note that @@ -430,22 +427,22 @@ MemoryStore.prototype = { * @param {event[]} membershipEvents the membership events to store * @returns {Promise} when all members have been stored */ - setOutOfBandMembers: function(roomId, membershipEvents) { - this._oobMembers[roomId] = membershipEvents; + public setOutOfBandMembers(roomId: string, membershipEvents: MatrixEvent[]): Promise { + this.oobMembers[roomId] = membershipEvents; return Promise.resolve(); - }, + } - clearOutOfBandMembers: function() { - this._oobMembers = {}; + public clearOutOfBandMembers(): Promise { + this.oobMembers = {}; return Promise.resolve(); - }, + } - getClientOptions: function() { - return Promise.resolve(this._clientOptions); - }, + public getClientOptions(): Promise { + return Promise.resolve(this.clientOptions); + } - storeClientOptions: function(options) { - this._clientOptions = Object.assign({}, options); + public storeClientOptions(options: object): Promise { + this.clientOptions = Object.assign({}, options); return Promise.resolve(); - }, -}; + } +} diff --git a/src/store/stub.js b/src/store/stub.ts similarity index 64% rename from src/store/stub.js rename to src/store/stub.ts index f94e97393..6270741a8 100644 --- a/src/store/stub.js +++ b/src/store/stub.ts @@ -1,8 +1,5 @@ /* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2017 Vector Creations Ltd -Copyright 2018 New Vector Ltd -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2015 - 2021 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. @@ -22,124 +19,127 @@ limitations under the License. * @module store/stub */ +import { EventType } from "../@types/event"; +import { Group } from "../models/group"; +import { Room } from "../models/room"; +import { User } from "../models/user"; +import { MatrixEvent } from "../models/event"; +import { Filter } from "../filter"; +import { IStore } from "./index"; +import { RoomSummary } from "../models/room-summary"; + /** * Construct a stub store. This does no-ops on most store methods. * @constructor */ -export function StubStore() { - this.fromToken = null; -} - -StubStore.prototype = { +export class StubStore implements IStore { + private fromToken: string = null; /** @return {Promise} whether or not the database was newly created in this session. */ - isNewlyCreated: function() { + public isNewlyCreated(): Promise { return Promise.resolve(true); - }, + } /** * Get the sync token. * @return {string} */ - getSyncToken: function() { + public getSyncToken(): string | null { return this.fromToken; - }, + } /** * Set the sync token. * @param {string} token */ - setSyncToken: function(token) { + public setSyncToken(token: string) { this.fromToken = token; - }, + } /** * No-op. * @param {Group} group */ - storeGroup: function(group) { - }, + public storeGroup(group: Group) {} /** * No-op. * @param {string} groupId * @return {null} */ - getGroup: function(groupId) { + public getGroup(groupId: string): Group | null { return null; - }, + } /** * No-op. * @return {Array} An empty array. */ - getGroups: function() { + public getGroups(): Group[] { return []; - }, + } /** * No-op. * @param {Room} room */ - storeRoom: function(room) { - }, + public storeRoom(room: Room) {} /** * No-op. * @param {string} roomId * @return {null} */ - getRoom: function(roomId) { + public getRoom(roomId: string): Room | null { return null; - }, + } /** * No-op. * @return {Array} An empty array. */ - getRooms: function() { + public getRooms(): Room[] { return []; - }, + } /** * Permanently delete a room. * @param {string} roomId */ - removeRoom: function(roomId) { + public removeRoom(roomId: string) { return; - }, + } /** * No-op. * @return {Array} An empty array. */ - getRoomSummaries: function() { + public getRoomSummaries(): RoomSummary[] { return []; - }, + } /** * No-op. * @param {User} user */ - storeUser: function(user) { - }, + public storeUser(user: User) {} /** * No-op. * @param {string} userId * @return {null} */ - getUser: function(userId) { + public getUser(userId: string): User | null { return null; - }, + } /** * No-op. * @return {User[]} */ - getUsers: function() { + public getUsers(): User[] { return []; - }, + } /** * No-op. @@ -147,9 +147,9 @@ StubStore.prototype = { * @param {integer} limit * @return {Array} */ - scrollback: function(room, limit) { + public scrollback(room: Room, limit: number): MatrixEvent[] { return []; - }, + } /** * Store events for a room. @@ -158,15 +158,13 @@ StubStore.prototype = { * @param {string} token The token associated with these events. * @param {boolean} toStart True if these are paginated results. */ - storeEvents: function(room, events, token, toStart) { - }, + public storeEvents(room: Room, events: MatrixEvent[], token: string, toStart: boolean) {} /** * Store a filter. * @param {Filter} filter */ - storeFilter: function(filter) { - }, + public storeFilter(filter: Filter) {} /** * Retrieve a filter. @@ -174,43 +172,37 @@ StubStore.prototype = { * @param {string} filterId * @return {?Filter} A filter or null. */ - getFilter: function(userId, filterId) { + public getFilter(userId: string, filterId: string): Filter | null { return null; - }, + } /** * Retrieve a filter ID with the given name. * @param {string} filterName The filter name. * @return {?string} The filter ID or null. */ - getFilterIdByName: function(filterName) { + public getFilterIdByName(filterName: string): Filter | null { return null; - }, + } /** * Set a filter name to ID mapping. * @param {string} filterName * @param {string} filterId */ - setFilterIdByName: function(filterName, filterId) { - - }, + public setFilterIdByName(filterName: string, filterId: string) {} /** * Store user-scoped account data events * @param {Array} events The events to store. */ - storeAccountDataEvents: function(events) { - - }, + public storeAccountDataEvents(events: MatrixEvent[]) {} /** * Get account data event by event type * @param {string} eventType The event type being queried */ - getAccountData: function(eventType) { - - }, + public getAccountData(eventType: EventType | string): MatrixEvent {} /** * setSyncData does nothing as there is no backing data store. @@ -218,75 +210,75 @@ StubStore.prototype = { * @param {Object} syncData The sync data * @return {Promise} An immediately resolved promise. */ - setSyncData: function(syncData) { + public setSyncData(syncData: object): Promise { return Promise.resolve(); - }, + } /** - * We never want to save becase we have nothing to save to. + * We never want to save because we have nothing to save to. * * @return {boolean} If the store wants to save */ - wantsSave: function() { + public wantsSave(): boolean { return false; - }, + } /** * Save does nothing as there is no backing data store. */ - save: function() {}, + public save() {} /** * Startup does nothing. * @return {Promise} An immediately resolved promise. */ - startup: function() { + public startup(): Promise { return Promise.resolve(); - }, + } /** * @return {Promise} Resolves with a sync response to restore the * client state to where it was at the last save, or null if there * is no saved sync data. */ - getSavedSync: function() { + public getSavedSync(): Promise { return Promise.resolve(null); - }, + } /** * @return {Promise} If there is a saved sync, the nextBatch token * for this sync, otherwise null. */ - getSavedSyncToken: function() { + public getSavedSyncToken(): Promise { return Promise.resolve(null); - }, + } /** * Delete all data from this store. Does nothing since this store * doesn't store anything. * @return {Promise} An immediately resolved promise. */ - deleteAllData: function() { + public deleteAllData(): Promise { return Promise.resolve(); - }, + } - getOutOfBandMembers: function() { + public getOutOfBandMembers(): Promise { return Promise.resolve(null); - }, + } - setOutOfBandMembers: function() { + public setOutOfBandMembers(roomId: string, membershipEvents: MatrixEvent[]): Promise { return Promise.resolve(); - }, + } - clearOutOfBandMembers: function() { + public clearOutOfBandMembers(): Promise { return Promise.resolve(); - }, + } - getClientOptions: function() { - return Promise.resolve(); - }, + public getClientOptions(): Promise { + return Promise.resolve({}); + } - storeClientOptions: function() { + public storeClientOptions(options: object): Promise { return Promise.resolve(); - }, -}; + } +} From 265802acb1ce1922590ca068270252c1d61f16b2 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Tue, 15 Jun 2021 10:01:38 +0100 Subject: [PATCH 02/20] Convert some models to typescript --- src/@types/event.ts | 5 + src/models/{relations.js => relations.ts} | 173 +++++++++--------- .../{room-summary.js => room-summary.ts} | 16 +- 3 files changed, 103 insertions(+), 91 deletions(-) rename src/models/{relations.js => relations.ts} (66%) rename src/models/{room-summary.js => room-summary.ts} (82%) diff --git a/src/@types/event.ts b/src/@types/event.ts index 6fd3c0338..f7b22992e 100644 --- a/src/@types/event.ts +++ b/src/@types/event.ts @@ -87,6 +87,11 @@ export enum EventType { Dummy = "m.dummy", } +export enum RelationType { + Annotation = "m.annotation", + Replace = "m.replace", +} + export enum MsgType { Text = "m.text", Emote = "m.emote", diff --git a/src/models/relations.js b/src/models/relations.ts similarity index 66% rename from src/models/relations.js rename to src/models/relations.ts index 50b4ffd65..99485562f 100644 --- a/src/models/relations.js +++ b/src/models/relations.ts @@ -1,5 +1,5 @@ /* -Copyright 2019 New Vector Ltd +Copyright 2019, 2021 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. @@ -14,9 +14,12 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { EventEmitter } from 'events'; -import { EventStatus } from '../models/event'; -import { logger } from '../logger'; +import {EventEmitter} from 'events'; + +import {EventStatus, MatrixEvent} from './event'; +import {Room} from './room'; +import {logger} from '../logger'; +import {RelationType} from "../@types/event"; /** * A container for relation events that supports easy access to common ways of @@ -27,8 +30,16 @@ import { logger } from '../logger'; * EventTimelineSet#getRelationsForEvent. */ export class Relations extends EventEmitter { + private relationEventIds = new Set(); + private relations = new Set(); + private annotationsByKey: Record> = {}; + private annotationsBySender: Record> = {}; + private sortedAnnotationsByKey: [string, MatrixEvent][] = []; + private targetEvent: MatrixEvent = null; + private creationEmitted = false; + /** - * @param {String} relationType + * @param {RelationType} relationType * The type of relation involved, such as "m.annotation", "m.reference", * "m.replace", etc. * @param {String} eventType @@ -37,18 +48,12 @@ export class Relations extends EventEmitter { * Room for this container. May be null for non-room cases, such as the * notification timeline. */ - constructor(relationType, eventType, room) { + constructor( + public readonly relationType: RelationType, + public readonly eventType: string, + private readonly room: Room, + ) { super(); - this.relationType = relationType; - this.eventType = eventType; - this._relationEventIds = new Set(); - this._relations = new Set(); - this._annotationsByKey = {}; - this._annotationsBySender = {}; - this._sortedAnnotationsByKey = []; - this._targetEvent = null; - this._room = room; - this._creationEmitted = false; } /** @@ -57,8 +62,8 @@ export class Relations extends EventEmitter { * @param {MatrixEvent} event * The new relation event to be added. */ - async addEvent(event) { - if (this._relationEventIds.has(event.getId())) { + public async addEvent(event: MatrixEvent) { + if (this.relationEventIds.has(event.getId())) { return; } @@ -79,24 +84,24 @@ export class Relations extends EventEmitter { // If the event is in the process of being sent, listen for cancellation // so we can remove the event from the collection. if (event.isSending()) { - event.on("Event.status", this._onEventStatus); + event.on("Event.status", this.onEventStatus); } - this._relations.add(event); - this._relationEventIds.add(event.getId()); + this.relations.add(event); + this.relationEventIds.add(event.getId()); - if (this.relationType === "m.annotation") { - this._addAnnotationToAggregation(event); - } else if (this.relationType === "m.replace" && this._targetEvent) { + if (this.relationType === RelationType.Annotation) { + this.addAnnotationToAggregation(event); + } else if (this.relationType === RelationType.Replace && this.targetEvent) { const lastReplacement = await this.getLastReplacement(); - this._targetEvent.makeReplaced(lastReplacement); + this.targetEvent.makeReplaced(lastReplacement); } - event.on("Event.beforeRedaction", this._onBeforeRedaction); + event.on("Event.beforeRedaction", this.onBeforeRedaction); this.emit("Relations.add", event); - this._maybeEmitCreated(); + this.maybeEmitCreated(); } /** @@ -105,8 +110,8 @@ export class Relations extends EventEmitter { * @param {MatrixEvent} event * The relation event to remove. */ - async _removeEvent(event) { - if (!this._relations.has(event)) { + private async removeEvent(event: MatrixEvent) { + if (!this.relations.has(event)) { return; } @@ -124,13 +129,13 @@ export class Relations extends EventEmitter { return; } - this._relations.delete(event); + this.relations.delete(event); - if (this.relationType === "m.annotation") { - this._removeAnnotationFromAggregation(event); - } else if (this.relationType === "m.replace" && this._targetEvent) { + if (this.relationType === RelationType.Annotation) { + this.removeAnnotationFromAggregation(event); + } else if (this.relationType === RelationType.Replace && this.targetEvent) { const lastReplacement = await this.getLastReplacement(); - this._targetEvent.makeReplaced(lastReplacement); + this.targetEvent.makeReplaced(lastReplacement); } this.emit("Relations.remove", event); @@ -142,18 +147,18 @@ export class Relations extends EventEmitter { * @param {MatrixEvent} event The event whose status has changed * @param {EventStatus} status The new status */ - _onEventStatus = (event, status) => { + private onEventStatus = (event: MatrixEvent, status: EventStatus) => { if (!event.isSending()) { // Sending is done, so we don't need to listen anymore - event.removeListener("Event.status", this._onEventStatus); + event.removeListener("Event.status", this.onEventStatus); return; } if (status !== EventStatus.CANCELLED) { return; } // Event was cancelled, remove from the collection - event.removeListener("Event.status", this._onEventStatus); - this._removeEvent(event); + event.removeListener("Event.status", this.onEventStatus); + this.removeEvent(event); } /** @@ -166,51 +171,51 @@ export class Relations extends EventEmitter { * @return {Array} * Relation events in insertion order. */ - getRelations() { - return [...this._relations]; + public getRelations() { + return [...this.relations]; } - _addAnnotationToAggregation(event) { + private addAnnotationToAggregation(event: MatrixEvent) { const { key } = event.getRelation(); if (!key) { return; } - let eventsForKey = this._annotationsByKey[key]; + let eventsForKey = this.annotationsByKey[key]; if (!eventsForKey) { - eventsForKey = this._annotationsByKey[key] = new Set(); - this._sortedAnnotationsByKey.push([key, eventsForKey]); + eventsForKey = this.annotationsByKey[key] = new Set(); + this.sortedAnnotationsByKey.push([key, eventsForKey]); } // Add the new event to the set for this key eventsForKey.add(event); // Re-sort the [key, events] pairs in descending order of event count - this._sortedAnnotationsByKey.sort((a, b) => { + this.sortedAnnotationsByKey.sort((a, b) => { const aEvents = a[1]; const bEvents = b[1]; return bEvents.size - aEvents.size; }); const sender = event.getSender(); - let eventsFromSender = this._annotationsBySender[sender]; + let eventsFromSender = this.annotationsBySender[sender]; if (!eventsFromSender) { - eventsFromSender = this._annotationsBySender[sender] = new Set(); + eventsFromSender = this.annotationsBySender[sender] = new Set(); } // Add the new event to the set for this sender eventsFromSender.add(event); } - _removeAnnotationFromAggregation(event) { + private removeAnnotationFromAggregation(event: MatrixEvent) { const { key } = event.getRelation(); if (!key) { return; } - const eventsForKey = this._annotationsByKey[key]; + const eventsForKey = this.annotationsByKey[key]; if (eventsForKey) { eventsForKey.delete(event); // Re-sort the [key, events] pairs in descending order of event count - this._sortedAnnotationsByKey.sort((a, b) => { + this.sortedAnnotationsByKey.sort((a, b) => { const aEvents = a[1]; const bEvents = b[1]; return bEvents.size - aEvents.size; @@ -218,7 +223,7 @@ export class Relations extends EventEmitter { } const sender = event.getSender(); - const eventsFromSender = this._annotationsBySender[sender]; + const eventsFromSender = this.annotationsBySender[sender]; if (eventsFromSender) { eventsFromSender.delete(event); } @@ -235,22 +240,22 @@ export class Relations extends EventEmitter { * @param {MatrixEvent} redactedEvent * The original relation event that is about to be redacted. */ - _onBeforeRedaction = async (redactedEvent) => { - if (!this._relations.has(redactedEvent)) { + private onBeforeRedaction = async (redactedEvent: MatrixEvent) => { + if (!this.relations.has(redactedEvent)) { return; } - this._relations.delete(redactedEvent); + this.relations.delete(redactedEvent); - if (this.relationType === "m.annotation") { + if (this.relationType === RelationType.Annotation) { // Remove the redacted annotation from aggregation by key - this._removeAnnotationFromAggregation(redactedEvent); - } else if (this.relationType === "m.replace" && this._targetEvent) { + this.removeAnnotationFromAggregation(redactedEvent); + } else if (this.relationType === RelationType.Replace && this.targetEvent) { const lastReplacement = await this.getLastReplacement(); - this._targetEvent.makeReplaced(lastReplacement); + this.targetEvent.makeReplaced(lastReplacement); } - redactedEvent.removeListener("Event.beforeRedaction", this._onBeforeRedaction); + redactedEvent.removeListener("Event.beforeRedaction", this.onBeforeRedaction); this.emit("Relations.redaction", redactedEvent); } @@ -265,13 +270,13 @@ export class Relations extends EventEmitter { * An array of [key, events] pairs sorted by descending event count. * The events are stored in a Set (which preserves insertion order). */ - getSortedAnnotationsByKey() { - if (this.relationType !== "m.annotation") { + public getSortedAnnotationsByKey() { + if (this.relationType !== RelationType.Annotation) { // Other relation types are not grouped currently. return null; } - return this._sortedAnnotationsByKey; + return this.sortedAnnotationsByKey; } /** @@ -283,13 +288,13 @@ export class Relations extends EventEmitter { * An object with each relation sender as a key and the matching Set of * events for that sender as a value. */ - getAnnotationsBySender() { - if (this.relationType !== "m.annotation") { + public getAnnotationsBySender() { + if (this.relationType !== RelationType.Annotation) { // Other relation types are not grouped currently. return null; } - return this._annotationsBySender; + return this.annotationsBySender; } /** @@ -300,12 +305,12 @@ export class Relations extends EventEmitter { * * @return {MatrixEvent?} */ - async getLastReplacement() { - if (this.relationType !== "m.replace") { + public async getLastReplacement(): Promise { + if (this.relationType !== RelationType.Replace) { // Aggregating on last only makes sense for this relation type return null; } - if (!this._targetEvent) { + if (!this.targetEvent) { // Don't know which replacements to accept yet. // This method shouldn't be called before the original // event is known anyway. @@ -315,11 +320,11 @@ export class Relations extends EventEmitter { // the all-knowning server tells us that the event at some point had // this timestamp for its replacement, so any following replacement should definitely not be less const replaceRelation = - this._targetEvent.getServerAggregatedRelation("m.replace"); + this.targetEvent.getServerAggregatedRelation(RelationType.Replace); const minTs = replaceRelation && replaceRelation.origin_server_ts; const lastReplacement = this.getRelations().reduce((last, event) => { - if (event.getSender() !== this._targetEvent.getSender()) { + if (event.getSender() !== this.targetEvent.getSender()) { return last; } if (minTs && minTs > event.getTs()) { @@ -332,7 +337,7 @@ export class Relations extends EventEmitter { }, null); if (lastReplacement?.shouldAttemptDecryption()) { - await lastReplacement.attemptDecryption(this._room._client.crypto); + await lastReplacement.attemptDecryption(this.room._client.crypto); } else if (lastReplacement?.isBeingDecrypted()) { await lastReplacement._decryptionPromise; } @@ -343,38 +348,34 @@ export class Relations extends EventEmitter { /* * @param {MatrixEvent} targetEvent the event the relations are related to. */ - async setTargetEvent(event) { - if (this._targetEvent) { + public async setTargetEvent(event: MatrixEvent) { + if (this.targetEvent) { return; } - this._targetEvent = event; + this.targetEvent = event; - if (this.relationType === "m.replace") { + if (this.relationType === RelationType.Replace) { const replacement = await this.getLastReplacement(); // this is the initial update, so only call it if we already have something // to not emit Event.replaced needlessly if (replacement) { - this._targetEvent.makeReplaced(replacement); + this.targetEvent.makeReplaced(replacement); } } - this._maybeEmitCreated(); + this.maybeEmitCreated(); } - _maybeEmitCreated() { - if (this._creationEmitted) { + private maybeEmitCreated() { + if (this.creationEmitted) { return; } // Only emit we're "created" once we have a target event instance _and_ // at least one related event. - if (!this._targetEvent || !this._relations.size) { + if (!this.targetEvent || !this.relations.size) { return; } - this._creationEmitted = true; - this._targetEvent.emit( - "Event.relationsCreated", - this.relationType, - this.eventType, - ); + this.creationEmitted = true; + this.targetEvent.emit("Event.relationsCreated", this.relationType, this.eventType); } } diff --git a/src/models/room-summary.js b/src/models/room-summary.ts similarity index 82% rename from src/models/room-summary.js rename to src/models/room-summary.ts index 037fe2bd6..f8327f798 100644 --- a/src/models/room-summary.js +++ b/src/models/room-summary.ts @@ -1,6 +1,5 @@ /* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2015 - 2021 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. @@ -19,6 +18,14 @@ limitations under the License. * @module models/room-summary */ +interface IInfo { + title: string; + desc: string; + numMembers: number; + aliases: string[]; + timestamp: number; +} + /** * Construct a new Room Summary. A summary can be used for display on a recent * list, without having to load the entire room list into memory. @@ -32,8 +39,7 @@ limitations under the License. * @param {string[]} info.aliases The list of aliases for this room. * @param {Number} info.timestamp The timestamp for this room. */ -export function RoomSummary(roomId, info) { - this.roomId = roomId; - this.info = info; +export class RoomSummary { + constructor(public readonly roomId: string, info?: IInfo) {} } From 2f0d96d030cda33add18eef31515b12f347da397 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Tue, 15 Jun 2021 10:01:51 +0100 Subject: [PATCH 03/20] Convert some utils to typescript --- ...{content-helpers.js => content-helpers.ts} | 14 +++++------ src/{content-repo.js => content-repo.ts} | 25 +++++++++++-------- 2 files changed, 22 insertions(+), 17 deletions(-) rename src/{content-helpers.js => content-helpers.ts} (86%) rename src/{content-repo.js => content-repo.ts} (79%) diff --git a/src/content-helpers.js b/src/content-helpers.ts similarity index 86% rename from src/content-helpers.js rename to src/content-helpers.ts index c82f808c5..061073c5e 100644 --- a/src/content-helpers.js +++ b/src/content-helpers.ts @@ -1,6 +1,6 @@ /* Copyright 2018 New Vector Ltd -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2018 - 2021 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. @@ -23,7 +23,7 @@ limitations under the License. * @param {string} htmlBody the HTML representation of the message * @returns {{msgtype: string, format: string, body: string, formatted_body: string}} */ -export function makeHtmlMessage(body, htmlBody) { +export function makeHtmlMessage(body: string, htmlBody: string) { return { msgtype: "m.text", format: "org.matrix.custom.html", @@ -38,7 +38,7 @@ export function makeHtmlMessage(body, htmlBody) { * @param {string} htmlBody the HTML representation of the notice * @returns {{msgtype: string, format: string, body: string, formatted_body: string}} */ -export function makeHtmlNotice(body, htmlBody) { +export function makeHtmlNotice(body: string, htmlBody: string) { return { msgtype: "m.notice", format: "org.matrix.custom.html", @@ -53,7 +53,7 @@ export function makeHtmlNotice(body, htmlBody) { * @param {string} htmlBody the HTML representation of the emote * @returns {{msgtype: string, format: string, body: string, formatted_body: string}} */ -export function makeHtmlEmote(body, htmlBody) { +export function makeHtmlEmote(body: string, htmlBody: string) { return { msgtype: "m.emote", format: "org.matrix.custom.html", @@ -67,7 +67,7 @@ export function makeHtmlEmote(body, htmlBody) { * @param {string} body the plaintext body of the emote * @returns {{msgtype: string, body: string}} */ -export function makeTextMessage(body) { +export function makeTextMessage(body: string) { return { msgtype: "m.text", body: body, @@ -79,7 +79,7 @@ export function makeTextMessage(body) { * @param {string} body the plaintext body of the notice * @returns {{msgtype: string, body: string}} */ -export function makeNotice(body) { +export function makeNotice(body: string) { return { msgtype: "m.notice", body: body, @@ -91,7 +91,7 @@ export function makeNotice(body) { * @param {string} body the plaintext body of the emote * @returns {{msgtype: string, body: string}} */ -export function makeEmoteMessage(body) { +export function makeEmoteMessage(body: string) { return { msgtype: "m.emote", body: body, diff --git a/src/content-repo.js b/src/content-repo.ts similarity index 79% rename from src/content-repo.js rename to src/content-repo.ts index 1b92d59ae..baa91879b 100644 --- a/src/content-repo.js +++ b/src/content-repo.ts @@ -1,6 +1,5 @@ /* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2015 - 2021 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. @@ -34,8 +33,14 @@ import * as utils from "./utils"; * for such URLs. * @return {string} The complete URL to the content. */ -export function getHttpUriForMxc(baseUrl, mxc, width, height, - resizeMethod, allowDirectLinks) { +export function getHttpUriForMxc( + baseUrl: string, + mxc: string, + width: number, + height: number, + resizeMethod: string, + allowDirectLinks: boolean, +): string { if (typeof mxc !== "string" || !mxc) { return ''; } @@ -51,13 +56,13 @@ export function getHttpUriForMxc(baseUrl, mxc, width, height, const params = {}; if (width) { - params.width = Math.round(width); + params["width"] = Math.round(width); } if (height) { - params.height = Math.round(height); + params["height"] = Math.round(height); } if (resizeMethod) { - params.method = resizeMethod; + params["method"] = resizeMethod; } if (Object.keys(params).length > 0) { // these are thumbnailing params so they probably want the @@ -71,7 +76,7 @@ export function getHttpUriForMxc(baseUrl, mxc, width, height, fragment = serverAndMediaId.substr(fragmentOffset); serverAndMediaId = serverAndMediaId.substr(0, fragmentOffset); } - return baseUrl + prefix + serverAndMediaId + - (Object.keys(params).length === 0 ? "" : - ("?" + utils.encodeParams(params))) + fragment; + + const urlParams = (Object.keys(params).length === 0 ? "" : ("?" + utils.encodeParams(params))); + return baseUrl + prefix + serverAndMediaId + urlParams + fragment; } From 913710dd99e8017ac635beeec2e5180598504487 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Tue, 15 Jun 2021 10:02:05 +0100 Subject: [PATCH 04/20] Convert filter classes to typescript --- src/filter-component.js | 145 -------------------------- src/filter-component.ts | 146 ++++++++++++++++++++++++++ src/filter.js | 199 ----------------------------------- src/filter.ts | 226 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 372 insertions(+), 344 deletions(-) delete mode 100644 src/filter-component.js create mode 100644 src/filter-component.ts delete mode 100644 src/filter.js create mode 100644 src/filter.ts diff --git a/src/filter-component.js b/src/filter-component.js deleted file mode 100644 index 8ff760673..000000000 --- a/src/filter-component.js +++ /dev/null @@ -1,145 +0,0 @@ -/* -Copyright 2016 OpenMarket 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 filter-component - */ - -/** - * Checks if a value matches a given field value, which may be a * terminated - * wildcard pattern. - * @param {String} actual_value The value to be compared - * @param {String} filter_value The filter pattern to be compared - * @return {bool} true if the actual_value matches the filter_value - */ -function _matches_wildcard(actual_value, filter_value) { - if (filter_value.endsWith("*")) { - const type_prefix = filter_value.slice(0, -1); - return actual_value.substr(0, type_prefix.length) === type_prefix; - } else { - return actual_value === filter_value; - } -} - -/** - * FilterComponent is a section of a Filter definition which defines the - * types, rooms, senders filters etc to be applied to a particular type of resource. - * This is all ported over from synapse's Filter object. - * - * N.B. that synapse refers to these as 'Filters', and what js-sdk refers to as - * 'Filters' are referred to as 'FilterCollections'. - * - * @constructor - * @param {Object} filter_json the definition of this filter JSON, e.g. { 'contains_url': true } - */ -export function FilterComponent(filter_json) { - this.filter_json = filter_json; - - this.types = filter_json.types || null; - this.not_types = filter_json.not_types || []; - - this.rooms = filter_json.rooms || null; - this.not_rooms = filter_json.not_rooms || []; - - this.senders = filter_json.senders || null; - this.not_senders = filter_json.not_senders || []; - - this.contains_url = filter_json.contains_url || null; -} - -/** - * Checks with the filter component matches the given event - * @param {MatrixEvent} event event to be checked against the filter - * @return {bool} true if the event matches the filter - */ -FilterComponent.prototype.check = function(event) { - return this._checkFields( - event.getRoomId(), - event.getSender(), - event.getType(), - event.getContent() ? event.getContent().url !== undefined : false, - ); -}; - -/** - * Checks whether the filter component matches the given event fields. - * @param {String} room_id the room_id for the event being checked - * @param {String} sender the sender of the event being checked - * @param {String} event_type the type of the event being checked - * @param {String} contains_url whether the event contains a content.url field - * @return {bool} true if the event fields match the filter - */ -FilterComponent.prototype._checkFields = - function(room_id, sender, event_type, contains_url) { - const literal_keys = { - "rooms": function(v) { - return room_id === v; - }, - "senders": function(v) { - return sender === v; - }, - "types": function(v) { - return _matches_wildcard(event_type, v); - }, - }; - - const self = this; - for (let n=0; n < Object.keys(literal_keys).length; n++) { - const name = Object.keys(literal_keys)[n]; - const match_func = literal_keys[name]; - const not_name = "not_" + name; - const disallowed_values = self[not_name]; - if (disallowed_values.filter(match_func).length > 0) { - return false; - } - - const allowed_values = self[name]; - if (allowed_values && allowed_values.length > 0) { - const anyMatch = allowed_values.some(match_func); - if (!anyMatch) { - return false; - } - } - } - - const contains_url_filter = this.filter_json.contains_url; - if (contains_url_filter !== undefined) { - if (contains_url_filter !== contains_url) { - return false; - } - } - - return true; -}; - -/** - * Filters a list of events down to those which match this filter component - * @param {MatrixEvent[]} events Events to be checked againt the filter component - * @return {MatrixEvent[]} events which matched the filter component - */ -FilterComponent.prototype.filter = function(events) { - return events.filter(this.check, this); -}; - -/** - * Returns the limit field for a given filter component, providing a default of - * 10 if none is otherwise specified. Cargo-culted from Synapse. - * @return {Number} the limit for this filter component. - */ -FilterComponent.prototype.limit = function() { - return this.filter_json.limit !== undefined ? this.filter_json.limit : 10; -}; diff --git a/src/filter-component.ts b/src/filter-component.ts new file mode 100644 index 000000000..70c9e2f29 --- /dev/null +++ b/src/filter-component.ts @@ -0,0 +1,146 @@ +/* +Copyright 2016 - 2021 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 { MatrixEvent } from "./models/event"; + +/** + * @module filter-component + */ + +/** + * Checks if a value matches a given field value, which may be a * terminated + * wildcard pattern. + * @param {String} actualValue The value to be compared + * @param {String} filterValue The filter pattern to be compared + * @return {bool} true if the actualValue matches the filterValue + */ +function matchesWildcard(actualValue: string, filterValue: string): boolean { + if (filterValue.endsWith("*")) { + const typePrefix = filterValue.slice(0, -1); + return actualValue.substr(0, typePrefix.length) === typePrefix; + } else { + return actualValue === filterValue; + } +} + +/* eslint-disable camelcase */ +export interface IFilterComponent { + types?: string[]; + not_types?: string[]; + rooms?: string[]; + not_rooms?: string[]; + senders?: string[]; + not_senders?: string[]; + contains_url?: boolean; + limit?: number; +} +/* eslint-enable camelcase */ + +/** + * FilterComponent is a section of a Filter definition which defines the + * types, rooms, senders filters etc to be applied to a particular type of resource. + * This is all ported over from synapse's Filter object. + * + * N.B. that synapse refers to these as 'Filters', and what js-sdk refers to as + * 'Filters' are referred to as 'FilterCollections'. + * + * @constructor + * @param {Object} filterJson the definition of this filter JSON, e.g. { 'contains_url': true } + */ +export class FilterComponent { + constructor(private filterJson: IFilterComponent) {} + + /** + * Checks with the filter component matches the given event + * @param {MatrixEvent} event event to be checked against the filter + * @return {bool} true if the event matches the filter + */ + check(event: MatrixEvent): boolean { + return this.checkFields( + event.getRoomId(), + event.getSender(), + event.getType(), + event.getContent() ? event.getContent().url !== undefined : false, + ); + } + + /** + * Checks whether the filter component matches the given event fields. + * @param {String} roomId the roomId for the event being checked + * @param {String} sender the sender of the event being checked + * @param {String} eventType the type of the event being checked + * @param {boolean} containsUrl whether the event contains a content.url field + * @return {boolean} true if the event fields match the filter + */ + private checkFields(roomId: string, sender: string, eventType: string, containsUrl: boolean): boolean { + const literalKeys = { + "rooms": function(v: string): boolean { + return roomId === v; + }, + "senders": function(v: string): boolean { + return sender === v; + }, + "types": function(v: string): boolean { + return matchesWildcard(eventType, v); + }, + }; + + for (let n = 0; n < Object.keys(literalKeys).length; n++) { + const name = Object.keys(literalKeys)[n]; + const matchFunc = literalKeys[name]; + const notName = "not_" + name; + const disallowedValues = this[notName]; + if (disallowedValues.filter(matchFunc).length > 0) { + return false; + } + + const allowedValues = this[name]; + if (allowedValues && allowedValues.length > 0) { + const anyMatch = allowedValues.some(matchFunc); + if (!anyMatch) { + return false; + } + } + } + + const containsUrlFilter = this.filterJson.contains_url; + if (containsUrlFilter !== undefined) { + if (containsUrlFilter !== containsUrl) { + return false; + } + } + + return true; + } + + /** + * Filters a list of events down to those which match this filter component + * @param {MatrixEvent[]} events Events to be checked againt the filter component + * @return {MatrixEvent[]} events which matched the filter component + */ + filter(events: MatrixEvent[]): MatrixEvent[] { + return events.filter(this.check, this); + } + + /** + * Returns the limit field for a given filter component, providing a default of + * 10 if none is otherwise specified. Cargo-culted from Synapse. + * @return {Number} the limit for this filter component. + */ + limit(): number { + return this.filterJson.limit !== undefined ? this.filterJson.limit : 10; + } +} diff --git a/src/filter.js b/src/filter.js deleted file mode 100644 index 08e747092..000000000 --- a/src/filter.js +++ /dev/null @@ -1,199 +0,0 @@ -/* -Copyright 2015, 2016 OpenMarket 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 filter - */ - -import { FilterComponent } from "./filter-component"; - -/** - * @param {Object} obj - * @param {string} keyNesting - * @param {*} val - */ -function setProp(obj, keyNesting, val) { - const nestedKeys = keyNesting.split("."); - let currentObj = obj; - for (let i = 0; i < (nestedKeys.length - 1); i++) { - if (!currentObj[nestedKeys[i]]) { - currentObj[nestedKeys[i]] = {}; - } - currentObj = currentObj[nestedKeys[i]]; - } - currentObj[nestedKeys[nestedKeys.length - 1]] = val; -} - -/** - * Construct a new Filter. - * @constructor - * @param {string} userId The user ID for this filter. - * @param {string=} filterId The filter ID if known. - * @prop {string} userId The user ID of the filter - * @prop {?string} filterId The filter ID - */ -export function Filter(userId, filterId) { - this.userId = userId; - this.filterId = filterId; - this.definition = {}; -} - -Filter.LAZY_LOADING_MESSAGES_FILTER = { - lazy_load_members: true, -}; - -/** - * Get the ID of this filter on your homeserver (if known) - * @return {?Number} The filter ID - */ -Filter.prototype.getFilterId = function() { - return this.filterId; -}; - -/** - * Get the JSON body of the filter. - * @return {Object} The filter definition - */ -Filter.prototype.getDefinition = function() { - return this.definition; -}; - -/** - * Set the JSON body of the filter - * @param {Object} definition The filter definition - */ -Filter.prototype.setDefinition = function(definition) { - this.definition = definition; - - // This is all ported from synapse's FilterCollection() - - // definitions look something like: - // { - // "room": { - // "rooms": ["!abcde:example.com"], - // "not_rooms": ["!123456:example.com"], - // "state": { - // "types": ["m.room.*"], - // "not_rooms": ["!726s6s6q:example.com"], - // "lazy_load_members": true, - // }, - // "timeline": { - // "limit": 10, - // "types": ["m.room.message"], - // "not_rooms": ["!726s6s6q:example.com"], - // "not_senders": ["@spam:example.com"] - // "contains_url": true - // }, - // "ephemeral": { - // "types": ["m.receipt", "m.typing"], - // "not_rooms": ["!726s6s6q:example.com"], - // "not_senders": ["@spam:example.com"] - // } - // }, - // "presence": { - // "types": ["m.presence"], - // "not_senders": ["@alice:example.com"] - // }, - // "event_format": "client", - // "event_fields": ["type", "content", "sender"] - // } - - const room_filter_json = definition.room; - - // consider the top level rooms/not_rooms filter - const room_filter_fields = {}; - if (room_filter_json) { - if (room_filter_json.rooms) { - room_filter_fields.rooms = room_filter_json.rooms; - } - if (room_filter_json.rooms) { - room_filter_fields.not_rooms = room_filter_json.not_rooms; - } - - this._include_leave = room_filter_json.include_leave || false; - } - - this._room_filter = new FilterComponent(room_filter_fields); - this._room_timeline_filter = new FilterComponent( - room_filter_json ? (room_filter_json.timeline || {}) : {}, - ); - - // don't bother porting this from synapse yet: - // this._room_state_filter = - // new FilterComponent(room_filter_json.state || {}); - // this._room_ephemeral_filter = - // new FilterComponent(room_filter_json.ephemeral || {}); - // this._room_account_data_filter = - // new FilterComponent(room_filter_json.account_data || {}); - // this._presence_filter = - // new FilterComponent(definition.presence || {}); - // this._account_data_filter = - // new FilterComponent(definition.account_data || {}); -}; - -/** - * Get the room.timeline filter component of the filter - * @return {FilterComponent} room timeline filter component - */ -Filter.prototype.getRoomTimelineFilterComponent = function() { - return this._room_timeline_filter; -}; - -/** - * Filter the list of events based on whether they are allowed in a timeline - * based on this filter - * @param {MatrixEvent[]} events the list of events being filtered - * @return {MatrixEvent[]} the list of events which match the filter - */ -Filter.prototype.filterRoomTimeline = function(events) { - return this._room_timeline_filter.filter(this._room_filter.filter(events)); -}; - -/** - * Set the max number of events to return for each room's timeline. - * @param {Number} limit The max number of events to return for each room. - */ -Filter.prototype.setTimelineLimit = function(limit) { - setProp(this.definition, "room.timeline.limit", limit); -}; - -Filter.prototype.setLazyLoadMembers = function(enabled) { - setProp(this.definition, "room.state.lazy_load_members", !!enabled); -}; - -/** - * Control whether left rooms should be included in responses. - * @param {boolean} includeLeave True to make rooms the user has left appear - * in responses. - */ -Filter.prototype.setIncludeLeaveRooms = function(includeLeave) { - setProp(this.definition, "room.include_leave", includeLeave); -}; - -/** - * Create a filter from existing data. - * @static - * @param {string} userId - * @param {string} filterId - * @param {Object} jsonObj - * @return {Filter} - */ -Filter.fromJson = function(userId, filterId, jsonObj) { - const filter = new Filter(userId, filterId); - filter.setDefinition(jsonObj); - return filter; -}; diff --git a/src/filter.ts b/src/filter.ts new file mode 100644 index 000000000..a73d91178 --- /dev/null +++ b/src/filter.ts @@ -0,0 +1,226 @@ +/* +Copyright 2015 - 2021 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 filter + */ + +import { FilterComponent, IFilterComponent } from "./filter-component"; +import { MatrixEvent } from "./models/event"; + +/** + * @param {Object} obj + * @param {string} keyNesting + * @param {*} val + */ +function setProp(obj: object, keyNesting: string, val: any) { + const nestedKeys = keyNesting.split("."); + let currentObj = obj; + for (let i = 0; i < (nestedKeys.length - 1); i++) { + if (!currentObj[nestedKeys[i]]) { + currentObj[nestedKeys[i]] = {}; + } + currentObj = currentObj[nestedKeys[i]]; + } + currentObj[nestedKeys[nestedKeys.length - 1]] = val; +} + +/* eslint-disable camelcase */ +interface IFilterDefinition { + event_fields?: string[]; + event_format?: "client" | "federation"; + presence?: IFilterComponent; + account_data?: IFilterComponent; + room?: IRoomFilter; +} + +interface IRoomEventFilter extends IFilterComponent { + lazy_load_members?: boolean; + include_redundant_members?: boolean; +} + +interface IStateFilter extends IRoomEventFilter {} + +interface IRoomFilter { + not_rooms?: string[]; + rooms?: string[]; + ephemeral?: IRoomEventFilter; + include_leave?: boolean; + state?: IStateFilter; + timeline?: IRoomEventFilter; + account_data?: IRoomEventFilter; +} +/* eslint-enable camelcase */ + +/** + * Construct a new Filter. + * @constructor + * @param {string} userId The user ID for this filter. + * @param {string=} filterId The filter ID if known. + * @prop {string} userId The user ID of the filter + * @prop {?string} filterId The filter ID + */ +export class Filter { + static LAZY_LOADING_MESSAGES_FILTER = { + lazy_load_members: true, + }; + + /** + * Create a filter from existing data. + * @static + * @param {string} userId + * @param {string} filterId + * @param {Object} jsonObj + * @return {Filter} + */ + static fromJson(userId: string, filterId: string, jsonObj: IFilterDefinition): Filter { + const filter = new Filter(userId, filterId); + filter.setDefinition(jsonObj); + return filter; + } + + private definition: IFilterDefinition = {}; + private roomFilter: FilterComponent; + private roomTimelineFilter: FilterComponent; + + constructor(public readonly userId: string, public readonly filterId: string) {} + + /** + * Get the ID of this filter on your homeserver (if known) + * @return {?string} The filter ID + */ + getFilterId(): string | null { + return this.filterId; + } + + /** + * Get the JSON body of the filter. + * @return {Object} The filter definition + */ + getDefinition(): IFilterDefinition { + return this.definition; + } + + /** + * Set the JSON body of the filter + * @param {Object} definition The filter definition + */ + setDefinition(definition: IFilterDefinition) { + this.definition = definition; + + // This is all ported from synapse's FilterCollection() + + // definitions look something like: + // { + // "room": { + // "rooms": ["!abcde:example.com"], + // "not_rooms": ["!123456:example.com"], + // "state": { + // "types": ["m.room.*"], + // "not_rooms": ["!726s6s6q:example.com"], + // "lazy_load_members": true, + // }, + // "timeline": { + // "limit": 10, + // "types": ["m.room.message"], + // "not_rooms": ["!726s6s6q:example.com"], + // "not_senders": ["@spam:example.com"] + // "contains_url": true + // }, + // "ephemeral": { + // "types": ["m.receipt", "m.typing"], + // "not_rooms": ["!726s6s6q:example.com"], + // "not_senders": ["@spam:example.com"] + // } + // }, + // "presence": { + // "types": ["m.presence"], + // "not_senders": ["@alice:example.com"] + // }, + // "event_format": "client", + // "event_fields": ["type", "content", "sender"] + // } + + const roomFilterJson = definition.room; + + // consider the top level rooms/not_rooms filter + const roomFilterFields: IRoomFilter = {}; + if (roomFilterJson) { + if (roomFilterJson.rooms) { + roomFilterFields.rooms = roomFilterJson.rooms; + } + if (roomFilterJson.rooms) { + roomFilterFields.not_rooms = roomFilterJson.not_rooms; + } + } + + this.roomFilter = new FilterComponent(roomFilterFields); + this.roomTimelineFilter = new FilterComponent( + roomFilterJson ? (roomFilterJson.timeline || {}) : {}, + ); + + // don't bother porting this from synapse yet: + // this._room_state_filter = + // new FilterComponent(roomFilterJson.state || {}); + // this._room_ephemeral_filter = + // new FilterComponent(roomFilterJson.ephemeral || {}); + // this._room_account_data_filter = + // new FilterComponent(roomFilterJson.account_data || {}); + // this._presence_filter = + // new FilterComponent(definition.presence || {}); + // this._account_data_filter = + // new FilterComponent(definition.account_data || {}); + } + + /** + * Get the room.timeline filter component of the filter + * @return {FilterComponent} room timeline filter component + */ + getRoomTimelineFilterComponent(): FilterComponent { + return this.roomTimelineFilter; + } + + /** + * Filter the list of events based on whether they are allowed in a timeline + * based on this filter + * @param {MatrixEvent[]} events the list of events being filtered + * @return {MatrixEvent[]} the list of events which match the filter + */ + filterRoomTimeline(events: MatrixEvent[]): MatrixEvent[] { + return this.roomTimelineFilter.filter(this.roomFilter.filter(events)); + } + + /** + * Set the max number of events to return for each room's timeline. + * @param {Number} limit The max number of events to return for each room. + */ + setTimelineLimit(limit: number) { + setProp(this.definition, "room.timeline.limit", limit); + } + + setLazyLoadMembers(enabled: boolean) { + setProp(this.definition, "room.state.lazy_load_members", !!enabled); + } + + /** + * Control whether left rooms should be included in responses. + * @param {boolean} includeLeave True to make rooms the user has left appear + * in responses. + */ + setIncludeLeaveRooms(includeLeave: boolean) { + setProp(this.definition, "room.include_leave", includeLeave); + } +} From e0c36498e6f7feb2c48fb63a9c8d941063b0adab Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Tue, 15 Jun 2021 10:06:30 +0100 Subject: [PATCH 05/20] delint --- src/models/relations.ts | 14 +++++++------- src/store/stub.ts | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/models/relations.ts b/src/models/relations.ts index 99485562f..de82ec6e1 100644 --- a/src/models/relations.ts +++ b/src/models/relations.ts @@ -14,12 +14,12 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {EventEmitter} from 'events'; +import { EventEmitter } from 'events'; -import {EventStatus, MatrixEvent} from './event'; -import {Room} from './room'; -import {logger} from '../logger'; -import {RelationType} from "../@types/event"; +import { EventStatus, MatrixEvent } from './event'; +import { Room } from './room'; +import { logger } from '../logger'; +import { RelationType } from "../@types/event"; /** * A container for relation events that supports easy access to common ways of @@ -159,7 +159,7 @@ export class Relations extends EventEmitter { // Event was cancelled, remove from the collection event.removeListener("Event.status", this.onEventStatus); this.removeEvent(event); - } + }; /** * Get all relation events in this collection. @@ -258,7 +258,7 @@ export class Relations extends EventEmitter { redactedEvent.removeListener("Event.beforeRedaction", this.onBeforeRedaction); this.emit("Relations.redaction", redactedEvent); - } + }; /** * Get all events in this collection grouped by key and sorted by descending diff --git a/src/store/stub.ts b/src/store/stub.ts index 6270741a8..a1775b3f9 100644 --- a/src/store/stub.ts +++ b/src/store/stub.ts @@ -181,7 +181,7 @@ export class StubStore implements IStore { * @param {string} filterName The filter name. * @return {?string} The filter ID or null. */ - public getFilterIdByName(filterName: string): Filter | null { + public getFilterIdByName(filterName: string): string | null { return null; } From eb5908d5d231ecd8bb48a5877f1decbbee0067a9 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Tue, 15 Jun 2021 10:15:43 +0100 Subject: [PATCH 06/20] fix tests --- src/filter-component.ts | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/src/filter-component.ts b/src/filter-component.ts index 70c9e2f29..2d8f7eb51 100644 --- a/src/filter-component.ts +++ b/src/filter-component.ts @@ -102,25 +102,20 @@ export class FilterComponent { const name = Object.keys(literalKeys)[n]; const matchFunc = literalKeys[name]; const notName = "not_" + name; - const disallowedValues = this[notName]; - if (disallowedValues.filter(matchFunc).length > 0) { + const disallowedValues: string[] = this.filterJson[notName]; + if (disallowedValues?.some(matchFunc)) { return false; } - const allowedValues = this[name]; - if (allowedValues && allowedValues.length > 0) { - const anyMatch = allowedValues.some(matchFunc); - if (!anyMatch) { - return false; - } + const allowedValues: string[] = this.filterJson[name]; + if (allowedValues && !allowedValues.some(matchFunc)) { + return false; } } const containsUrlFilter = this.filterJson.contains_url; - if (containsUrlFilter !== undefined) { - if (containsUrlFilter !== containsUrl) { - return false; - } + if (containsUrlFilter !== undefined && containsUrlFilter !== containsUrl) { + return false; } return true; From d9246176725dc2164fc201f76575119e4a5d1837 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Wed, 16 Jun 2021 20:24:35 -0600 Subject: [PATCH 07/20] Add invite retries to file trees --- package.json | 1 + spec/unit/models/MSC3089TreeSpace.spec.ts | 61 +++++++++++++++++++++-- spec/unit/utils.spec.ts | 29 +++++++++++ src/models/MSC3089TreeSpace.ts | 33 ++++++++++-- src/utils.ts | 23 ++++++++- yarn.lock | 19 +++++++ 6 files changed, 158 insertions(+), 8 deletions(-) diff --git a/package.json b/package.json index f3c06fb6b..ed4880c40 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "bs58": "^4.0.1", "content-type": "^1.0.4", "loglevel": "^1.7.1", + "p-retry": "^4.5.0", "qs": "^6.9.6", "request": "^2.88.2", "unhomoglyph": "^1.0.6" diff --git a/spec/unit/models/MSC3089TreeSpace.spec.ts b/spec/unit/models/MSC3089TreeSpace.spec.ts index a99140036..b79670a77 100644 --- a/spec/unit/models/MSC3089TreeSpace.spec.ts +++ b/spec/unit/models/MSC3089TreeSpace.spec.ts @@ -25,6 +25,7 @@ import { } from "../../../src/models/MSC3089TreeSpace"; import { DEFAULT_ALPHABET } from "../../../src/utils"; import { MockBlob } from "../../MockBlob"; +import { MatrixError } from "../../../src/http-api"; describe("MSC3089TreeSpace", () => { let client: MatrixClient; @@ -93,7 +94,7 @@ describe("MSC3089TreeSpace", () => { }); client.sendStateEvent = fn; await tree.setName(newName); - expect(fn.mock.calls.length).toBe(1); + expect(fn).toHaveBeenCalledTimes(1); }); it('should support inviting users to the space', async () => { @@ -104,8 +105,62 @@ describe("MSC3089TreeSpace", () => { return Promise.resolve(); }); client.invite = fn; - await tree.invite(target); - expect(fn.mock.calls.length).toBe(1); + await tree.invite(target, false); + expect(fn).toHaveBeenCalledTimes(1); + }); + + it('should retry invites to the space', async () => { + const target = targetUser; + const fn = jest.fn().mockImplementation((inviteRoomId: string, userId: string) => { + expect(inviteRoomId).toEqual(roomId); + expect(userId).toEqual(target); + if (fn.mock.calls.length === 1) return Promise.reject(new Error("Sample Failure")); + return Promise.resolve(); + }); + client.invite = fn; + await tree.invite(target, false); + expect(fn).toHaveBeenCalledTimes(2); + }); + + it('should not retry invite permission errors', async () => { + const target = targetUser; + const fn = jest.fn().mockImplementation((inviteRoomId: string, userId: string) => { + expect(inviteRoomId).toEqual(roomId); + expect(userId).toEqual(target); + return Promise.reject(new MatrixError({ errcode: "M_FORBIDDEN", error: "Sample Failure" })); + }); + client.invite = fn; + try { + await tree.invite(target, false); + + // noinspection ExceptionCaughtLocallyJS + throw new Error("Failed to fail"); + } catch (e) { + expect(e.errcode).toEqual("M_FORBIDDEN"); + } + + expect(fn).toHaveBeenCalledTimes(1); + }); + + it('should invite to subspaces', async () => { + const target = targetUser; + const fn = jest.fn().mockImplementation((inviteRoomId: string, userId: string) => { + expect(inviteRoomId).toEqual(roomId); + expect(userId).toEqual(target); + return Promise.resolve(); + }); + client.invite = fn; + tree.getDirectories = () => [ + // Bare minimum overrides. We proxy to our mock function manually so we can + // count the calls, not to ensure accuracy. The invite function behaving correctly + // is covered by another test. + {invite: (userId) => fn(tree.roomId, userId)} as MSC3089TreeSpace, + {invite: (userId) => fn(tree.roomId, userId)} as MSC3089TreeSpace, + {invite: (userId) => fn(tree.roomId, userId)} as MSC3089TreeSpace, + ]; + + await tree.invite(target, true); + expect(fn).toHaveBeenCalledTimes(4); }); async function evaluatePowerLevels(pls: any, role: TreePermissions, expectedPl: number) { diff --git a/spec/unit/utils.spec.ts b/spec/unit/utils.spec.ts index 76123d1ca..a062f7df4 100644 --- a/spec/unit/utils.spec.ts +++ b/spec/unit/utils.spec.ts @@ -7,6 +7,7 @@ import { lexicographicCompare, nextString, prevString, + simpleRetryOperation, stringToBase, } from "../../src/utils"; import { logger } from "../../src/logger"; @@ -267,6 +268,34 @@ describe("utils", function() { }); }); + describe('simpleRetryOperation', () => { + it('should retry', async () => { + let count = 0; + const val = {}; + const fn = (attempt) => { + count++; + + // If this expectation fails then it can appear as a Jest Timeout due to + // the retry running beyond the test limit. + expect(attempt).toEqual(count); + + if (count > 1) { + return Promise.resolve(val); + } else { + return Promise.reject(new Error("Iterative failure")); + } + }; + + const ret = await simpleRetryOperation(fn); + expect(ret).toBe(val); + expect(count).toEqual(2); + }); + + // We don't test much else of the function because then we're just testing that the + // underlying library behaves, which should be tested on its own. Our API surface is + // all that concerns us. + }); + describe('DEFAULT_ALPHABET', () => { it('should be usefully printable ASCII in order', () => { expect(DEFAULT_ALPHABET).toEqual( diff --git a/src/models/MSC3089TreeSpace.ts b/src/models/MSC3089TreeSpace.ts index f36642a8f..5b4771b27 100644 --- a/src/models/MSC3089TreeSpace.ts +++ b/src/models/MSC3089TreeSpace.ts @@ -19,8 +19,16 @@ import { EventType, IEncryptedFile, MsgType, UNSTABLE_MSC3089_BRANCH, UNSTABLE_M import { Room } from "./room"; import { logger } from "../logger"; import { MatrixEvent } from "./event"; -import { averageBetweenStrings, DEFAULT_ALPHABET, lexicographicCompare, nextString, prevString } from "../utils"; +import { + averageBetweenStrings, + DEFAULT_ALPHABET, + lexicographicCompare, + nextString, + prevString, + simpleRetryOperation, +} from "../utils"; import { MSC3089Branch } from "./MSC3089Branch"; +import promiseRetry from "p-retry"; /** * The recommended defaults for a tree space's power levels. Note that this @@ -110,12 +118,29 @@ export class MSC3089TreeSpace { * Invites a user to the tree space. They will be given the default Viewer * permission level unless specified elsewhere. * @param {string} userId The user ID to invite. + * @param {boolean} andSubspaces True (default) to invite the user to all + * directories/subspaces too, recursively. * @returns {Promise} Resolves when complete. */ - public invite(userId: string): Promise { - // TODO: [@@TR] Reliable invites + public invite(userId: string, andSubspaces = true): Promise { // TODO: [@@TR] Share keys - return this.client.invite(this.roomId, userId); + const promises: Promise[] = [this.retryInvite(userId)]; + if (andSubspaces) { + promises.push(...this.getDirectories().map(d => d.invite(userId, andSubspaces))); + } + return Promise.all(promises).then(); // .then() to coerce types + } + + private retryInvite(userId: string): Promise { + return simpleRetryOperation(() => { + return this.client.invite(this.roomId, userId).catch(e => { + // We don't want to retry permission errors forever... + if (e?.errcode === "M_FORBIDDEN") { + throw new promiseRetry.AbortError(e); + } + throw e; + }); + }); } /** diff --git a/src/utils.ts b/src/utils.ts index 50c5b9f39..a2504a011 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -20,7 +20,8 @@ limitations under the License. * @module utils */ -import unhomoglyph from 'unhomoglyph'; +import unhomoglyph from "unhomoglyph"; +import promiseRetry from "p-retry"; /** * Encode a dictionary of query parameters. @@ -443,6 +444,26 @@ export async function chunkPromises(fns: (() => Promise)[], chunkSize: num return results; } +/** + * Retries the function until it succeeds or is interrupted. The given function must return + * a promise which throws/rejects on error, otherwise the retry will assume the request + * succeeded. The promise chain returned will contain the successful promise. The given function + * should always return a new promise. + * @param {Function} promiseFn The function to call to get a fresh promise instance. Takes an + * attempt count as an argument, for logging/debugging purposes. + * @returns {Promise} The promise for the retried operation. + */ +export function simpleRetryOperation(promiseFn: (attempt: number) => Promise): Promise { + return promiseRetry((attempt: number) => { + return promiseFn(attempt); + }, { + forever: true, + factor: 2, + minTimeout: 3000, // ms + maxTimeout: 15000, // ms + }); +} + // We need to be able to access the Node.js crypto library from within the // Matrix SDK without needing to `require("crypto")`, which will fail in // browsers. So `index.ts` will call `setCrypto` to store it, and when we need diff --git a/yarn.lock b/yarn.lock index 04057af8b..b582d75fb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1132,6 +1132,7 @@ "@matrix-org/olm@https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.3.tgz": version "3.2.3" + uid cc332fdd25c08ef0e40f4d33fc3f822a0f98b6f4 resolved "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.3.tgz#cc332fdd25c08ef0e40f4d33fc3f822a0f98b6f4" "@nicolo-ribaudo/chokidar-2@2.1.8-no-fsevents": @@ -1305,6 +1306,11 @@ "@types/tough-cookie" "*" form-data "^2.5.0" +"@types/retry@^0.12.0": + version "0.12.0" + resolved "https://registry.yarnpkg.com/@types/retry/-/retry-0.12.0.tgz#2b35eccfcee7d38cd72ad99232fbd58bffb3c84d" + integrity sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA== + "@types/stack-utils@^2.0.0": version "2.0.0" resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.0.tgz#7036640b4e21cc2f259ae826ce843d277dad8cff" @@ -5220,6 +5226,14 @@ p-locate@^4.1.0: dependencies: p-limit "^2.2.0" +p-retry@^4.5.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/p-retry/-/p-retry-4.5.0.tgz#6685336b3672f9ee8174d3769a660cb5e488521d" + integrity sha512-5Hwh4aVQSu6BEP+w2zKlVXtFAaYQe1qWuVADSgoeVlLjwe/Q/AMSoRR4MDeaAfu8llT+YNbEijWu/YF3m6avkg== + dependencies: + "@types/retry" "^0.12.0" + retry "^0.12.0" + p-try@^2.0.0: version "2.2.0" resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6" @@ -5943,6 +5957,11 @@ ret@~0.1.10: resolved "https://registry.yarnpkg.com/ret/-/ret-0.1.15.tgz#b8a4825d5bdb1fc3f6f53c2bc33f81388681c7bc" integrity sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg== +retry@^0.12.0: + version "0.12.0" + resolved "https://registry.yarnpkg.com/retry/-/retry-0.12.0.tgz#1b42a6266a21f07421d1b0b54b7dc167b01c013b" + integrity sha1-G0KmJmoh8HQh0bC1S33BZ7AcATs= + reusify@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76" From fe5bfbf76f3a7bbe1f300055f529e8aae4af8db5 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Wed, 16 Jun 2021 20:27:03 -0600 Subject: [PATCH 08/20] The linter needed appeasing --- spec/unit/models/MSC3089TreeSpace.spec.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/spec/unit/models/MSC3089TreeSpace.spec.ts b/spec/unit/models/MSC3089TreeSpace.spec.ts index b79670a77..a7f42e07b 100644 --- a/spec/unit/models/MSC3089TreeSpace.spec.ts +++ b/spec/unit/models/MSC3089TreeSpace.spec.ts @@ -154,9 +154,9 @@ describe("MSC3089TreeSpace", () => { // Bare minimum overrides. We proxy to our mock function manually so we can // count the calls, not to ensure accuracy. The invite function behaving correctly // is covered by another test. - {invite: (userId) => fn(tree.roomId, userId)} as MSC3089TreeSpace, - {invite: (userId) => fn(tree.roomId, userId)} as MSC3089TreeSpace, - {invite: (userId) => fn(tree.roomId, userId)} as MSC3089TreeSpace, + { invite: (userId) => fn(tree.roomId, userId) } as MSC3089TreeSpace, + { invite: (userId) => fn(tree.roomId, userId) } as MSC3089TreeSpace, + { invite: (userId) => fn(tree.roomId, userId) } as MSC3089TreeSpace, ]; await tree.invite(target, true); From b780ee8373d28891eac4b7b55948dccd1776d003 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 17 Jun 2021 10:23:09 +0100 Subject: [PATCH 09/20] Update src/models/relations.ts Co-authored-by: J. Ryan Stinnett --- src/models/relations.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/models/relations.ts b/src/models/relations.ts index de82ec6e1..5d70cffee 100644 --- a/src/models/relations.ts +++ b/src/models/relations.ts @@ -34,7 +34,7 @@ export class Relations extends EventEmitter { private relations = new Set(); private annotationsByKey: Record> = {}; private annotationsBySender: Record> = {}; - private sortedAnnotationsByKey: [string, MatrixEvent][] = []; + private sortedAnnotationsByKey: [string, Set][] = []; private targetEvent: MatrixEvent = null; private creationEmitted = false; From 4b5653c09befd8b41ae177801dec0e3ba5334e49 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 17 Jun 2021 11:36:24 +0100 Subject: [PATCH 10/20] Convert IndexedDBStore to TS --- src/store/indexeddb.js | 319 ---------------------------------------- src/store/indexeddb.ts | 323 +++++++++++++++++++++++++++++++++++++++++ src/store/memory.ts | 2 +- 3 files changed, 324 insertions(+), 320 deletions(-) delete mode 100644 src/store/indexeddb.js create mode 100644 src/store/indexeddb.ts diff --git a/src/store/indexeddb.js b/src/store/indexeddb.js deleted file mode 100644 index e661c83a8..000000000 --- a/src/store/indexeddb.js +++ /dev/null @@ -1,319 +0,0 @@ -/* -Copyright 2017 Vector Creations Ltd -Copyright 2018 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. -*/ - -/* eslint-disable @babel/no-invalid-this */ - -import { MemoryStore } from "./memory"; -import * as utils from "../utils"; -import { EventEmitter } from 'events'; -import { LocalIndexedDBStoreBackend } from "./indexeddb-local-backend.js"; -import { RemoteIndexedDBStoreBackend } from "./indexeddb-remote-backend.js"; -import { User } from "../models/user"; -import { MatrixEvent } from "../models/event"; -import { logger } from '../logger'; - -/** - * This is an internal module. See {@link IndexedDBStore} for the public class. - * @module store/indexeddb - */ - -// If this value is too small we'll be writing very often which will cause -// noticable stop-the-world pauses. If this value is too big we'll be writing -// so infrequently that the /sync size gets bigger on reload. Writing more -// often does not affect the length of the pause since the entire /sync -// response is persisted each time. -const WRITE_DELAY_MS = 1000 * 60 * 5; // once every 5 minutes - -/** - * Construct a new Indexed Database store, which extends MemoryStore. - * - * This store functions like a MemoryStore except it periodically persists - * the contents of the store to an IndexedDB backend. - * - * All data is still kept in-memory but can be loaded from disk by calling - * startup(). This can make startup times quicker as a complete - * sync from the server is not required. This does not reduce memory usage as all - * the data is eagerly fetched when startup() is called. - *
- * let opts = { indexedDB: window.indexedDB, localStorage: window.localStorage };
- * let store = new IndexedDBStore(opts);
- * await store.startup(); // load from indexed db
- * let client = sdk.createClient({
- *     store: store,
- * });
- * client.startClient();
- * client.on("sync", function(state, prevState, data) {
- *     if (state === "PREPARED") {
- *         console.log("Started up, now with go faster stripes!");
- *     }
- * });
- * 
- * - * @constructor - * @extends MemoryStore - * @param {Object} opts Options object. - * @param {Object} opts.indexedDB The Indexed DB interface e.g. - * window.indexedDB - * @param {string=} opts.dbName Optional database name. The same name must be used - * to open the same database. - * @param {string=} opts.workerScript Optional URL to a script to invoke a web - * worker with to run IndexedDB queries on the web worker. The IndexedDbStoreWorker - * class is provided for this purpose and requires the application to provide a - * trivial wrapper script around it. - * @param {Object=} opts.workerApi The webWorker API object. If omitted, the global Worker - * object will be used if it exists. - * @prop {IndexedDBStoreBackend} backend The backend instance. Call through to - * this API if you need to perform specific indexeddb actions like deleting the - * database. - */ -export function IndexedDBStore(opts) { - MemoryStore.call(this, opts); - - if (!opts.indexedDB) { - throw new Error('Missing required option: indexedDB'); - } - - if (opts.workerScript) { - // try & find a webworker-compatible API - let workerApi = opts.workerApi; - if (!workerApi) { - // default to the global Worker object (which is where it in a browser) - workerApi = global.Worker; - } - this.backend = new RemoteIndexedDBStoreBackend( - opts.workerScript, opts.dbName, workerApi, - ); - } else { - this.backend = new LocalIndexedDBStoreBackend(opts.indexedDB, opts.dbName); - } - - this.startedUp = false; - this._syncTs = 0; - - // Records the last-modified-time of each user at the last point we saved - // the database, such that we can derive the set if users that have been - // modified since we last saved. - this._userModifiedMap = { - // user_id : timestamp - }; -} -utils.inherits(IndexedDBStore, MemoryStore); -utils.extend(IndexedDBStore.prototype, EventEmitter.prototype); - -IndexedDBStore.exists = function(indexedDB, dbName) { - return LocalIndexedDBStoreBackend.exists(indexedDB, dbName); -}; - -/** - * @return {Promise} Resolved when loaded from indexed db. - */ -IndexedDBStore.prototype.startup = function() { - if (this.startedUp) { - logger.log(`IndexedDBStore.startup: already started`); - return Promise.resolve(); - } - - logger.log(`IndexedDBStore.startup: connecting to backend`); - return this.backend.connect().then(() => { - logger.log(`IndexedDBStore.startup: loading presence events`); - return this.backend.getUserPresenceEvents(); - }).then((userPresenceEvents) => { - logger.log(`IndexedDBStore.startup: processing presence events`); - userPresenceEvents.forEach(([userId, rawEvent]) => { - const u = new User(userId); - if (rawEvent) { - u.setPresenceEvent(new MatrixEvent(rawEvent)); - } - this._userModifiedMap[u.userId] = u.getLastModifiedTime(); - this.storeUser(u); - }); - }); -}; - -/** - * @return {Promise} Resolves with a sync response to restore the - * client state to where it was at the last save, or null if there - * is no saved sync data. - */ -IndexedDBStore.prototype.getSavedSync = degradable(function() { - return this.backend.getSavedSync(); -}, "getSavedSync"); - -/** @return {Promise} whether or not the database was newly created in this session. */ -IndexedDBStore.prototype.isNewlyCreated = degradable(function() { - return this.backend.isNewlyCreated(); -}, "isNewlyCreated"); - -/** - * @return {Promise} If there is a saved sync, the nextBatch token - * for this sync, otherwise null. - */ -IndexedDBStore.prototype.getSavedSyncToken = degradable(function() { - return this.backend.getNextBatchToken(); -}, "getSavedSyncToken"), - -/** - * Delete all data from this store. - * @return {Promise} Resolves if the data was deleted from the database. - */ -IndexedDBStore.prototype.deleteAllData = degradable(function() { - MemoryStore.prototype.deleteAllData.call(this); - return this.backend.clearDatabase().then(() => { - logger.log("Deleted indexeddb data."); - }, (err) => { - logger.error(`Failed to delete indexeddb data: ${err}`); - throw err; - }); -}); - -/** - * Whether this store would like to save its data - * Note that obviously whether the store wants to save or - * not could change between calling this function and calling - * save(). - * - * @return {boolean} True if calling save() will actually save - * (at the time this function is called). - */ -IndexedDBStore.prototype.wantsSave = function() { - const now = Date.now(); - return now - this._syncTs > WRITE_DELAY_MS; -}; - -/** - * Possibly write data to the database. - * - * @param {bool} force True to force a save to happen - * @return {Promise} Promise resolves after the write completes - * (or immediately if no write is performed) - */ -IndexedDBStore.prototype.save = function(force) { - if (force || this.wantsSave()) { - return this._reallySave(); - } - return Promise.resolve(); -}; - -IndexedDBStore.prototype._reallySave = degradable(function() { - this._syncTs = Date.now(); // set now to guard against multi-writes - - // work out changed users (this doesn't handle deletions but you - // can't 'delete' users as they are just presence events). - const userTuples = []; - for (const u of this.getUsers()) { - if (this._userModifiedMap[u.userId] === u.getLastModifiedTime()) continue; - if (!u.events.presence) continue; - - userTuples.push([u.userId, u.events.presence.event]); - - // note that we've saved this version of the user - this._userModifiedMap[u.userId] = u.getLastModifiedTime(); - } - - return this.backend.syncToDatabase(userTuples); -}); - -IndexedDBStore.prototype.setSyncData = degradable(function(syncData) { - return this.backend.setSyncData(syncData); -}, "setSyncData"); - -/** - * Returns the out-of-band membership events for this room that - * were previously loaded. - * @param {string} roomId - * @returns {event[]} the events, potentially an empty array if OOB loading didn't yield any new members - * @returns {null} in case the members for this room haven't been stored yet - */ -IndexedDBStore.prototype.getOutOfBandMembers = degradable(function(roomId) { - return this.backend.getOutOfBandMembers(roomId); -}, "getOutOfBandMembers"); - -/** - * Stores the out-of-band membership events for this room. Note that - * it still makes sense to store an empty array as the OOB status for the room is - * marked as fetched, and getOutOfBandMembers will return an empty array instead of null - * @param {string} roomId - * @param {event[]} membershipEvents the membership events to store - * @returns {Promise} when all members have been stored - */ -IndexedDBStore.prototype.setOutOfBandMembers = degradable(function( - roomId, - membershipEvents, -) { - MemoryStore.prototype.setOutOfBandMembers.call(this, roomId, membershipEvents); - return this.backend.setOutOfBandMembers(roomId, membershipEvents); -}, "setOutOfBandMembers"); - -IndexedDBStore.prototype.clearOutOfBandMembers = degradable(function(roomId) { - MemoryStore.prototype.clearOutOfBandMembers.call(this); - return this.backend.clearOutOfBandMembers(roomId); -}, "clearOutOfBandMembers"); - -IndexedDBStore.prototype.getClientOptions = degradable(function() { - return this.backend.getClientOptions(); -}, "getClientOptions"); - -IndexedDBStore.prototype.storeClientOptions = degradable(function(options) { - MemoryStore.prototype.storeClientOptions.call(this, options); - return this.backend.storeClientOptions(options); -}, "storeClientOptions"); - -/** - * All member functions of `IndexedDBStore` that access the backend use this wrapper to - * watch for failures after initial store startup, including `QuotaExceededError` as - * free disk space changes, etc. - * - * When IndexedDB fails via any of these paths, we degrade this back to a `MemoryStore` - * in place so that the current operation and all future ones are in-memory only. - * - * @param {Function} func The degradable work to do. - * @param {String} fallback The method name for fallback. - * @returns {Function} A wrapped member function. - */ -function degradable(func, fallback) { - return async function(...args) { - try { - return await func.call(this, ...args); - } catch (e) { - logger.error("IndexedDBStore failure, degrading to MemoryStore", e); - this.emit("degraded", e); - try { - // We try to delete IndexedDB after degrading since this store is only a - // cache (the app will still function correctly without the data). - // It's possible that deleting repair IndexedDB for the next app load, - // potenially by making a little more space available. - logger.log("IndexedDBStore trying to delete degraded data"); - await this.backend.clearDatabase(); - logger.log("IndexedDBStore delete after degrading succeeeded"); - } catch (e) { - logger.warn("IndexedDBStore delete after degrading failed", e); - } - // Degrade the store from being an instance of `IndexedDBStore` to instead be - // an instance of `MemoryStore` so that future API calls use the memory path - // directly and skip IndexedDB entirely. This should be safe as - // `IndexedDBStore` already extends from `MemoryStore`, so we are making the - // store become its parent type in a way. The mutator methods of - // `IndexedDBStore` also maintain the state that `MemoryStore` uses (many are - // not overridden at all). - Object.setPrototypeOf(this, MemoryStore.prototype); - if (fallback) { - return await MemoryStore.prototype[fallback].call(this, ...args); - } - } - }; -} diff --git a/src/store/indexeddb.ts b/src/store/indexeddb.ts new file mode 100644 index 000000000..e268e5a11 --- /dev/null +++ b/src/store/indexeddb.ts @@ -0,0 +1,323 @@ +/* +Copyright 2017 - 2021 Vector Creations Ltd + +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. +*/ + +/* eslint-disable @babel/no-invalid-this */ + +import { EventEmitter } from 'events'; + +import { MemoryStore, IOpts as IBaseOpts } from "./memory"; +import { LocalIndexedDBStoreBackend } from "./indexeddb-local-backend.js"; +import { RemoteIndexedDBStoreBackend } from "./indexeddb-remote-backend.js"; +import { User } from "../models/user"; +import { MatrixEvent } from "../models/event"; +import { logger } from '../logger'; + +/** + * This is an internal module. See {@link IndexedDBStore} for the public class. + * @module store/indexeddb + */ + +// If this value is too small we'll be writing very often which will cause +// noticeable stop-the-world pauses. If this value is too big we'll be writing +// so infrequently that the /sync size gets bigger on reload. Writing more +// often does not affect the length of the pause since the entire /sync +// response is persisted each time. +const WRITE_DELAY_MS = 1000 * 60 * 5; // once every 5 minutes + +interface IOpts extends IBaseOpts { + indexedDB: IDBFactory; + dbName?: string; + workerScript?: string; + workerApi?: typeof Worker; +} + +export class IndexedDBStore extends MemoryStore { + static exists(indexedDB: IDBFactory, dbName: string): boolean { + return LocalIndexedDBStoreBackend.exists(indexedDB, dbName); + } + + // TODO these should conform to one interface + public readonly backend: LocalIndexedDBStoreBackend | RemoteIndexedDBStoreBackend; + + private startedUp = false; + private syncTs = 0; + // Records the last-modified-time of each user at the last point we saved + // the database, such that we can derive the set if users that have been + // modified since we last saved. + private userModifiedMap: Record = {}; // user_id : timestamp + private emitter = new EventEmitter(); + + /** + * Construct a new Indexed Database store, which extends MemoryStore. + * + * This store functions like a MemoryStore except it periodically persists + * the contents of the store to an IndexedDB backend. + * + * All data is still kept in-memory but can be loaded from disk by calling + * startup(). This can make startup times quicker as a complete + * sync from the server is not required. This does not reduce memory usage as all + * the data is eagerly fetched when startup() is called. + *
+     * let opts = { indexedDB: window.indexedDB, localStorage: window.localStorage };
+     * let store = new IndexedDBStore(opts);
+     * await store.startup(); // load from indexed db
+     * let client = sdk.createClient({
+     *     store: store,
+     * });
+     * client.startClient();
+     * client.on("sync", function(state, prevState, data) {
+     *     if (state === "PREPARED") {
+     *         console.log("Started up, now with go faster stripes!");
+     *     }
+     * });
+     * 
+ * + * @constructor + * @extends MemoryStore + * @param {Object} opts Options object. + * @param {Object} opts.indexedDB The Indexed DB interface e.g. + * window.indexedDB + * @param {string=} opts.dbName Optional database name. The same name must be used + * to open the same database. + * @param {string=} opts.workerScript Optional URL to a script to invoke a web + * worker with to run IndexedDB queries on the web worker. The IndexedDbStoreWorker + * class is provided for this purpose and requires the application to provide a + * trivial wrapper script around it. + * @param {Object=} opts.workerApi The webWorker API object. If omitted, the global Worker + * object will be used if it exists. + * @prop {IndexedDBStoreBackend} backend The backend instance. Call through to + * this API if you need to perform specific indexeddb actions like deleting the + * database. + */ + constructor(opts: IOpts) { + super(opts); + + if (!opts.indexedDB) { + throw new Error('Missing required option: indexedDB'); + } + + if (opts.workerScript) { + // try & find a webworker-compatible API + let workerApi = opts.workerApi; + if (!workerApi) { + // default to the global Worker object (which is where it in a browser) + workerApi = global.Worker; + } + this.backend = new RemoteIndexedDBStoreBackend( + opts.workerScript, opts.dbName, workerApi, + ); + } else { + this.backend = new LocalIndexedDBStoreBackend(opts.indexedDB, opts.dbName); + } + } + + public on = this.emitter.on.bind(this.emitter); + + /** + * @return {Promise} Resolved when loaded from indexed db. + */ + public startup(): Promise { + if (this.startedUp) { + logger.log(`IndexedDBStore.startup: already started`); + return Promise.resolve(); + } + + logger.log(`IndexedDBStore.startup: connecting to backend`); + return this.backend.connect().then(() => { + logger.log(`IndexedDBStore.startup: loading presence events`); + return this.backend.getUserPresenceEvents(); + }).then((userPresenceEvents) => { + logger.log(`IndexedDBStore.startup: processing presence events`); + userPresenceEvents.forEach(([userId, rawEvent]) => { + const u = new User(userId); + if (rawEvent) { + u.setPresenceEvent(new MatrixEvent(rawEvent)); + } + this.userModifiedMap[u.userId] = u.getLastModifiedTime(); + this.storeUser(u); + }); + }); + } + + /** + * @return {Promise} Resolves with a sync response to restore the + * client state to where it was at the last save, or null if there + * is no saved sync data. + */ + public getSavedSync = this.degradable(() => { + return this.backend.getSavedSync(); + }, "getSavedSync"); + + /** @return {Promise} whether or not the database was newly created in this session. */ + public isNewlyCreated = this.degradable(() => { + return this.backend.isNewlyCreated(); + }, "isNewlyCreated"); + + /** + * @return {Promise} If there is a saved sync, the nextBatch token + * for this sync, otherwise null. + */ + public getSavedSyncToken = this.degradable(() => { + return this.backend.getNextBatchToken(); + }, "getSavedSyncToken"); + + /** + * Delete all data from this store. + * @return {Promise} Resolves if the data was deleted from the database. + */ + public deleteAllData = this.degradable(() => { + super.deleteAllData(); + return this.backend.clearDatabase().then(() => { + logger.log("Deleted indexeddb data."); + }, (err) => { + logger.error(`Failed to delete indexeddb data: ${err}`); + throw err; + }); + }); + + /** + * Whether this store would like to save its data + * Note that obviously whether the store wants to save or + * not could change between calling this function and calling + * save(). + * + * @return {boolean} True if calling save() will actually save + * (at the time this function is called). + */ + public wantsSave(): boolean { + const now = Date.now(); + return now - this.syncTs > WRITE_DELAY_MS; + } + + /** + * Possibly write data to the database. + * + * @param {boolean} force True to force a save to happen + * @return {Promise} Promise resolves after the write completes + * (or immediately if no write is performed) + */ + public save(force = false): Promise { + if (force || this.wantsSave()) { + return this.reallySave(); + } + return Promise.resolve(); + } + + private reallySave = this.degradable((): void => { + this.syncTs = Date.now(); // set now to guard against multi-writes + + // work out changed users (this doesn't handle deletions but you + // can't 'delete' users as they are just presence events). + const userTuples = []; + for (const u of this.getUsers()) { + if (this.userModifiedMap[u.userId] === u.getLastModifiedTime()) continue; + if (!u.events.presence) continue; + + userTuples.push([u.userId, u.events.presence.event]); + + // note that we've saved this version of the user + this.userModifiedMap[u.userId] = u.getLastModifiedTime(); + } + + return this.backend.syncToDatabase(userTuples); + }); + + public setSyncData = this.degradable((syncData: object) => { + return this.backend.setSyncData(syncData); + }, "setSyncData"); + + /** + * Returns the out-of-band membership events for this room that + * were previously loaded. + * @param {string} roomId + * @returns {event[]} the events, potentially an empty array if OOB loading didn't yield any new members + * @returns {null} in case the members for this room haven't been stored yet + */ + public getOutOfBandMembers = this.degradable((roomId: string) => { + return this.backend.getOutOfBandMembers(roomId); + }, "getOutOfBandMembers"); + + /** + * Stores the out-of-band membership events for this room. Note that + * it still makes sense to store an empty array as the OOB status for the room is + * marked as fetched, and getOutOfBandMembers will return an empty array instead of null + * @param {string} roomId + * @param {event[]} membershipEvents the membership events to store + * @returns {Promise} when all members have been stored + */ + public setOutOfBandMembers = this.degradable((roomId: string, membershipEvents: MatrixEvent[]) => { + super.setOutOfBandMembers(roomId, membershipEvents); + return this.backend.setOutOfBandMembers(roomId, membershipEvents); + }, "setOutOfBandMembers"); + + public clearOutOfBandMembers = this.degradable((roomId: string) => { + super.clearOutOfBandMembers(); + return this.backend.clearOutOfBandMembers(roomId); + }, "clearOutOfBandMembers"); + + public getClientOptions = this.degradable(() => { + return this.backend.getClientOptions(); + }, "getClientOptions"); + + public storeClientOptions = this.degradable((options) => { + super.storeClientOptions(options); + return this.backend.storeClientOptions(options); + }, "storeClientOptions"); + + /** + * All member functions of `IndexedDBStore` that access the backend use this wrapper to + * watch for failures after initial store startup, including `QuotaExceededError` as + * free disk space changes, etc. + * + * When IndexedDB fails via any of these paths, we degrade this back to a `MemoryStore` + * in place so that the current operation and all future ones are in-memory only. + * + * @param {Function} func The degradable work to do. + * @param {String} fallback The method name for fallback. + * @returns {Function} A wrapped member function. + */ + private degradable(func, fallback?: string) { + return async (...args) => { + try { + return await func.call(this, ...args); + } catch (e) { + logger.error("IndexedDBStore failure, degrading to MemoryStore", e); + this.emitter.emit("degraded", e); + try { + // We try to delete IndexedDB after degrading since this store is only a + // cache (the app will still function correctly without the data). + // It's possible that deleting repair IndexedDB for the next app load, + // potentially by making a little more space available. + logger.log("IndexedDBStore trying to delete degraded data"); + await this.backend.clearDatabase(); + logger.log("IndexedDBStore delete after degrading succeeded"); + } catch (e) { + logger.warn("IndexedDBStore delete after degrading failed", e); + } + // Degrade the store from being an instance of `IndexedDBStore` to instead be + // an instance of `MemoryStore` so that future API calls use the memory path + // directly and skip IndexedDB entirely. This should be safe as + // `IndexedDBStore` already extends from `MemoryStore`, so we are making the + // store become its parent type in a way. The mutator methods of + // `IndexedDBStore` also maintain the state that `MemoryStore` uses (many are + // not overridden at all). + if (fallback) { + return await super[fallback](...args); + } + } + }; + } +} diff --git a/src/store/memory.ts b/src/store/memory.ts index 63f03bc5d..984e2f82b 100644 --- a/src/store/memory.ts +++ b/src/store/memory.ts @@ -39,7 +39,7 @@ function isValidFilterId(filterId: string): boolean { return isValidStr || typeof filterId === "number"; } -interface IOpts { +export interface IOpts { localStorage?: Storage; } From 64f369b5de76103ea66eabb4c2fadf75a748d28a Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 17 Jun 2021 12:10:48 +0100 Subject: [PATCH 11/20] Fix IndexedDBStore ts-ification --- src/store/index.ts | 2 +- src/store/indexeddb.ts | 37 ++++++++++++++++++++++--------------- src/store/memory.ts | 2 +- 3 files changed, 24 insertions(+), 17 deletions(-) diff --git a/src/store/index.ts b/src/store/index.ts index 3d570e64f..a49f0ad23 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -218,7 +218,7 @@ export interface IStore { setOutOfBandMembers(roomId: string, membershipEvents: MatrixEvent[]): Promise; - clearOutOfBandMembers(): Promise; + clearOutOfBandMembers(roomId: string): Promise; getClientOptions(): Promise; diff --git a/src/store/indexeddb.ts b/src/store/indexeddb.ts index e268e5a11..2e83d2ff1 100644 --- a/src/store/indexeddb.ts +++ b/src/store/indexeddb.ts @@ -157,12 +157,12 @@ export class IndexedDBStore extends MemoryStore { * client state to where it was at the last save, or null if there * is no saved sync data. */ - public getSavedSync = this.degradable(() => { + public getSavedSync = this.degradable((): Promise => { return this.backend.getSavedSync(); }, "getSavedSync"); /** @return {Promise} whether or not the database was newly created in this session. */ - public isNewlyCreated = this.degradable(() => { + public isNewlyCreated = this.degradable((): Promise => { return this.backend.isNewlyCreated(); }, "isNewlyCreated"); @@ -170,7 +170,7 @@ export class IndexedDBStore extends MemoryStore { * @return {Promise} If there is a saved sync, the nextBatch token * for this sync, otherwise null. */ - public getSavedSyncToken = this.degradable(() => { + public getSavedSyncToken = this.degradable((): Promise => { return this.backend.getNextBatchToken(); }, "getSavedSyncToken"); @@ -178,7 +178,7 @@ export class IndexedDBStore extends MemoryStore { * Delete all data from this store. * @return {Promise} Resolves if the data was deleted from the database. */ - public deleteAllData = this.degradable(() => { + public deleteAllData = this.degradable((): Promise => { super.deleteAllData(); return this.backend.clearDatabase().then(() => { logger.log("Deleted indexeddb data."); @@ -216,7 +216,7 @@ export class IndexedDBStore extends MemoryStore { return Promise.resolve(); } - private reallySave = this.degradable((): void => { + private reallySave = this.degradable((): Promise => { this.syncTs = Date.now(); // set now to guard against multi-writes // work out changed users (this doesn't handle deletions but you @@ -235,7 +235,7 @@ export class IndexedDBStore extends MemoryStore { return this.backend.syncToDatabase(userTuples); }); - public setSyncData = this.degradable((syncData: object) => { + public setSyncData = this.degradable((syncData: object): Promise => { return this.backend.setSyncData(syncData); }, "setSyncData"); @@ -246,7 +246,7 @@ export class IndexedDBStore extends MemoryStore { * @returns {event[]} the events, potentially an empty array if OOB loading didn't yield any new members * @returns {null} in case the members for this room haven't been stored yet */ - public getOutOfBandMembers = this.degradable((roomId: string) => { + public getOutOfBandMembers = this.degradable((roomId: string): Promise => { return this.backend.getOutOfBandMembers(roomId); }, "getOutOfBandMembers"); @@ -258,21 +258,21 @@ export class IndexedDBStore extends MemoryStore { * @param {event[]} membershipEvents the membership events to store * @returns {Promise} when all members have been stored */ - public setOutOfBandMembers = this.degradable((roomId: string, membershipEvents: MatrixEvent[]) => { + public setOutOfBandMembers = this.degradable((roomId: string, membershipEvents: MatrixEvent[]): Promise => { super.setOutOfBandMembers(roomId, membershipEvents); return this.backend.setOutOfBandMembers(roomId, membershipEvents); }, "setOutOfBandMembers"); public clearOutOfBandMembers = this.degradable((roomId: string) => { - super.clearOutOfBandMembers(); + super.clearOutOfBandMembers(roomId); return this.backend.clearOutOfBandMembers(roomId); }, "clearOutOfBandMembers"); - public getClientOptions = this.degradable(() => { + public getClientOptions = this.degradable((): Promise => { return this.backend.getClientOptions(); }, "getClientOptions"); - public storeClientOptions = this.degradable((options) => { + public storeClientOptions = this.degradable((options: object): Promise => { super.storeClientOptions(options); return this.backend.storeClientOptions(options); }, "storeClientOptions"); @@ -289,10 +289,15 @@ export class IndexedDBStore extends MemoryStore { * @param {String} fallback The method name for fallback. * @returns {Function} A wrapped member function. */ - private degradable(func, fallback?: string) { + private degradable, R = void>( + func: DegradableFn, + fallback?: string, + ): DegradableFn { + const fallbackFn = super[fallback]; + return async (...args) => { try { - return await func.call(this, ...args); + return func.call(this, ...args); } catch (e) { logger.error("IndexedDBStore failure, degrading to MemoryStore", e); this.emitter.emit("degraded", e); @@ -314,10 +319,12 @@ export class IndexedDBStore extends MemoryStore { // store become its parent type in a way. The mutator methods of // `IndexedDBStore` also maintain the state that `MemoryStore` uses (many are // not overridden at all). - if (fallback) { - return await super[fallback](...args); + if (fallbackFn) { + return fallbackFn(...args); } } }; } } + +type DegradableFn, T> = (...args: A) => Promise; diff --git a/src/store/memory.ts b/src/store/memory.ts index 984e2f82b..eda2adf3f 100644 --- a/src/store/memory.ts +++ b/src/store/memory.ts @@ -432,7 +432,7 @@ export class MemoryStore implements IStore { return Promise.resolve(); } - public clearOutOfBandMembers(): Promise { + public clearOutOfBandMembers(roomId: string): Promise { this.oobMembers = {}; return Promise.resolve(); } From c4664a185ff402528d85b52eb128a6effa3d21f2 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 17 Jun 2021 14:03:34 +0100 Subject: [PATCH 12/20] Convert Event Context to TS --- src/models/event-context.js | 115 --------------------------------- src/models/event-context.ts | 123 ++++++++++++++++++++++++++++++++++++ 2 files changed, 123 insertions(+), 115 deletions(-) delete mode 100644 src/models/event-context.js create mode 100644 src/models/event-context.ts diff --git a/src/models/event-context.js b/src/models/event-context.js deleted file mode 100644 index b04018aba..000000000 --- a/src/models/event-context.js +++ /dev/null @@ -1,115 +0,0 @@ -/* -Copyright 2015, 2016 OpenMarket 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/event-context - */ - -/** - * Construct a new EventContext - * - * An eventcontext is used for circumstances such as search results, when we - * have a particular event of interest, and a bunch of events before and after - * it. - * - * It also stores pagination tokens for going backwards and forwards in the - * timeline. - * - * @param {MatrixEvent} ourEvent the event at the centre of this context - * - * @constructor - */ -export function EventContext(ourEvent) { - this._timeline = [ourEvent]; - this._ourEventIndex = 0; - this._paginateTokens = { b: null, f: null }; - - // this is used by MatrixClient to keep track of active requests - this._paginateRequests = { b: null, f: null }; -} - -/** - * Get the main event of interest - * - * This is a convenience function for getTimeline()[getOurEventIndex()]. - * - * @return {MatrixEvent} The event at the centre of this context. - */ -EventContext.prototype.getEvent = function() { - return this._timeline[this._ourEventIndex]; -}; - -/** - * Get the list of events in this context - * - * @return {Array} An array of MatrixEvents - */ -EventContext.prototype.getTimeline = function() { - return this._timeline; -}; - -/** - * Get the index in the timeline of our event - * - * @return {Number} - */ -EventContext.prototype.getOurEventIndex = function() { - return this._ourEventIndex; -}; - -/** - * Get a pagination token. - * - * @param {boolean} backwards true to get the pagination token for going - * backwards in time - * @return {string} - */ -EventContext.prototype.getPaginateToken = function(backwards) { - return this._paginateTokens[backwards ? 'b' : 'f']; -}; - -/** - * Set a pagination token. - * - * Generally this will be used only by the matrix js sdk. - * - * @param {string} token pagination token - * @param {boolean} backwards true to set the pagination token for going - * backwards in time - */ -EventContext.prototype.setPaginateToken = function(token, backwards) { - this._paginateTokens[backwards ? 'b' : 'f'] = token; -}; - -/** - * Add more events to the timeline - * - * @param {Array} events new events, in timeline order - * @param {boolean} atStart true to insert new events at the start - */ -EventContext.prototype.addEvents = function(events, atStart) { - // TODO: should we share logic with Room.addEventsToTimeline? - // Should Room even use EventContext? - - if (atStart) { - this._timeline = events.concat(this._timeline); - this._ourEventIndex += events.length; - } else { - this._timeline = this._timeline.concat(events); - } -}; - diff --git a/src/models/event-context.ts b/src/models/event-context.ts new file mode 100644 index 000000000..95bc83e6c --- /dev/null +++ b/src/models/event-context.ts @@ -0,0 +1,123 @@ +/* +Copyright 2015 - 2021 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 { MatrixEvent } from "./event"; + +enum Direction { + Backward = "b", + Forward = "f", +} + +/** + * @module models/event-context + */ +export class EventContext { + private timeline: MatrixEvent[]; + private ourEventIndex = 0; + private paginateTokens: Record = { + [Direction.Backward]: null, + [Direction.Forward]: null, + }; + + /** + * Construct a new EventContext + * + * An eventcontext is used for circumstances such as search results, when we + * have a particular event of interest, and a bunch of events before and after + * it. + * + * It also stores pagination tokens for going backwards and forwards in the + * timeline. + * + * @param {MatrixEvent} ourEvent the event at the centre of this context + * + * @constructor + */ + constructor(ourEvent: MatrixEvent) { + this.timeline = [ourEvent]; + } + + /** + * Get the main event of interest + * + * This is a convenience function for getTimeline()[getOurEventIndex()]. + * + * @return {MatrixEvent} The event at the centre of this context. + */ + public getEvent(): MatrixEvent { + return this.timeline[this.ourEventIndex]; + } + + /** + * Get the list of events in this context + * + * @return {Array} An array of MatrixEvents + */ + public getTimeline(): MatrixEvent[] { + return this.timeline; + } + + /** + * Get the index in the timeline of our event + * + * @return {Number} + */ + public getOurEventIndex(): number { + return this.ourEventIndex; + } + + /** + * Get a pagination token. + * + * @param {boolean} backwards true to get the pagination token for going + * backwards in time + * @return {string} + */ + public getPaginateToken(backwards = false): string { + return this.paginateTokens[backwards ? Direction.Backward : Direction.Forward]; + } + + /** + * Set a pagination token. + * + * Generally this will be used only by the matrix js sdk. + * + * @param {string} token pagination token + * @param {boolean} backwards true to set the pagination token for going + * backwards in time + */ + public setPaginateToken(token: string, backwards = false): void { + this.paginateTokens[backwards ? Direction.Backward : Direction.Forward] = token; + } + + /** + * Add more events to the timeline + * + * @param {Array} events new events, in timeline order + * @param {boolean} atStart true to insert new events at the start + */ + public addEvents(events: MatrixEvent[], atStart = false): void { + // TODO: should we share logic with Room.addEventsToTimeline? + // Should Room even use EventContext? + + if (atStart) { + this.timeline = events.concat(this.timeline); + this.ourEventIndex += events.length; + } else { + this.timeline = this.timeline.concat(events); + } + } +} From f5e8fe836ee0393ae9e267f0d4c235e422b744f9 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 17 Jun 2021 14:03:52 +0100 Subject: [PATCH 13/20] Convert Room Member and User to TS --- src/models/room-member.js | 393 ------------------------------------ src/models/room-member.ts | 411 ++++++++++++++++++++++++++++++++++++++ src/models/user.js | 260 ------------------------ src/models/user.ts | 273 +++++++++++++++++++++++++ 4 files changed, 684 insertions(+), 653 deletions(-) delete mode 100644 src/models/room-member.js create mode 100644 src/models/room-member.ts delete mode 100644 src/models/user.js create mode 100644 src/models/user.ts diff --git a/src/models/room-member.js b/src/models/room-member.js deleted file mode 100644 index 63b6906ba..000000000 --- a/src/models/room-member.js +++ /dev/null @@ -1,393 +0,0 @@ -/* -Copyright 2015, 2016 OpenMarket 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/room-member - */ - -import { EventEmitter } from "events"; -import { getHttpUriForMxc } from "../content-repo"; -import * as utils from "../utils"; - -/** - * Construct a new room member. - * - * @constructor - * @alias module:models/room-member - * - * @param {string} roomId The room ID of the member. - * @param {string} userId The user ID of the member. - * @prop {string} roomId The room ID for this member. - * @prop {string} userId The user ID of this member. - * @prop {boolean} typing True if the room member is currently typing. - * @prop {string} name The human-readable name for this room member. This will be - * disambiguated with a suffix of " (@user_id:matrix.org)" if another member shares the - * same displayname. - * @prop {string} rawDisplayName The ambiguous displayname of this room member. - * @prop {Number} powerLevel The power level for this room member. - * @prop {Number} powerLevelNorm The normalised power level (0-100) for this - * room member. - * @prop {User} user The User object for this room member, if one exists. - * @prop {string} membership The membership state for this room member e.g. 'join'. - * @prop {Object} events The events describing this RoomMember. - * @prop {MatrixEvent} events.member The m.room.member event for this RoomMember. - * @prop {boolean} disambiguate True if the member's name is disambiguated. - */ -export function RoomMember(roomId, userId) { - this.roomId = roomId; - this.userId = userId; - this.typing = false; - this.name = userId; - this.rawDisplayName = userId; - this.powerLevel = 0; - this.powerLevelNorm = 0; - this.user = null; - this.membership = null; - this.events = { - member: null, - }; - this._isOutOfBand = false; - this._updateModifiedTime(); - this.disambiguate = false; -} -utils.inherits(RoomMember, EventEmitter); - -/** - * Mark the member as coming from a channel that is not sync - */ -RoomMember.prototype.markOutOfBand = function() { - this._isOutOfBand = true; -}; - -/** - * @return {bool} does the member come from a channel that is not sync? - * This is used to store the member seperately - * from the sync state so it available across browser sessions. - */ -RoomMember.prototype.isOutOfBand = function() { - return this._isOutOfBand; -}; - -/** - * Update this room member's membership event. May fire "RoomMember.name" if - * this event updates this member's name. - * @param {MatrixEvent} event The m.room.member event - * @param {RoomState} roomState Optional. The room state to take into account - * when calculating (e.g. for disambiguating users with the same name). - * @fires module:client~MatrixClient#event:"RoomMember.name" - * @fires module:client~MatrixClient#event:"RoomMember.membership" - */ -RoomMember.prototype.setMembershipEvent = function(event, roomState) { - const displayName = event.getDirectionalContent().displayname; - - if (event.getType() !== "m.room.member") { - return; - } - - this._isOutOfBand = false; - - this.events.member = event; - - const oldMembership = this.membership; - this.membership = event.getDirectionalContent().membership; - - this.disambiguate = shouldDisambiguate( - this.userId, - displayName, - roomState, - ); - - const oldName = this.name; - this.name = calculateDisplayName( - this.userId, - displayName, - roomState, - this.disambiguate, - ); - - this.rawDisplayName = event.getDirectionalContent().displayname || this.userId; - if (oldMembership !== this.membership) { - this._updateModifiedTime(); - this.emit("RoomMember.membership", event, this, oldMembership); - } - if (oldName !== this.name) { - this._updateModifiedTime(); - this.emit("RoomMember.name", event, this, oldName); - } -}; - -/** - * Update this room member's power level event. May fire - * "RoomMember.powerLevel" if this event updates this member's power levels. - * @param {MatrixEvent} powerLevelEvent The m.room.power_levels - * event - * @fires module:client~MatrixClient#event:"RoomMember.powerLevel" - */ -RoomMember.prototype.setPowerLevelEvent = function(powerLevelEvent) { - if (powerLevelEvent.getType() !== "m.room.power_levels") { - return; - } - - const evContent = powerLevelEvent.getDirectionalContent(); - - let maxLevel = evContent.users_default || 0; - const users = evContent.users || {}; - Object.values(users).forEach(function(lvl) { - maxLevel = Math.max(maxLevel, lvl); - }); - const oldPowerLevel = this.powerLevel; - const oldPowerLevelNorm = this.powerLevelNorm; - - if (users[this.userId] !== undefined) { - this.powerLevel = users[this.userId]; - } else if (evContent.users_default !== undefined) { - this.powerLevel = evContent.users_default; - } else { - this.powerLevel = 0; - } - this.powerLevelNorm = 0; - if (maxLevel > 0) { - this.powerLevelNorm = (this.powerLevel * 100) / maxLevel; - } - - // emit for changes in powerLevelNorm as well (since the app will need to - // redraw everyone's level if the max has changed) - if (oldPowerLevel !== this.powerLevel || oldPowerLevelNorm !== this.powerLevelNorm) { - this._updateModifiedTime(); - this.emit("RoomMember.powerLevel", powerLevelEvent, this); - } -}; - -/** - * Update this room member's typing event. May fire "RoomMember.typing" if - * this event changes this member's typing state. - * @param {MatrixEvent} event The typing event - * @fires module:client~MatrixClient#event:"RoomMember.typing" - */ -RoomMember.prototype.setTypingEvent = function(event) { - if (event.getType() !== "m.typing") { - return; - } - const oldTyping = this.typing; - this.typing = false; - const typingList = event.getContent().user_ids; - if (!Array.isArray(typingList)) { - // malformed event :/ bail early. TODO: whine? - return; - } - if (typingList.indexOf(this.userId) !== -1) { - this.typing = true; - } - if (oldTyping !== this.typing) { - this._updateModifiedTime(); - this.emit("RoomMember.typing", event, this); - } -}; - -/** - * Update the last modified time to the current time. - */ -RoomMember.prototype._updateModifiedTime = function() { - this._modified = Date.now(); -}; - -/** - * Get the timestamp when this RoomMember was last updated. This timestamp is - * updated when properties on this RoomMember are updated. - * It is updated before firing events. - * @return {number} The timestamp - */ -RoomMember.prototype.getLastModifiedTime = function() { - return this._modified; -}; - -RoomMember.prototype.isKicked = function() { - return this.membership === "leave" && - this.events.member.getSender() !== this.events.member.getStateKey(); -}; - -/** - * If this member was invited with the is_direct flag set, return - * the user that invited this member - * @return {string} user id of the inviter - */ -RoomMember.prototype.getDMInviter = function() { - // when not available because that room state hasn't been loaded in, - // we don't really know, but more likely to not be a direct chat - if (this.events.member) { - // TODO: persist the is_direct flag on the member as more member events - // come in caused by displayName changes. - - // the is_direct flag is set on the invite member event. - // This is copied on the prev_content section of the join member event - // when the invite is accepted. - - const memberEvent = this.events.member; - let memberContent = memberEvent.getContent(); - let inviteSender = memberEvent.getSender(); - - if (memberContent.membership === "join") { - memberContent = memberEvent.getPrevContent(); - inviteSender = memberEvent.getUnsigned().prev_sender; - } - - if (memberContent.membership === "invite" && memberContent.is_direct) { - return inviteSender; - } - } -}; - -/** - * Get the avatar URL for a room member. - * @param {string} baseUrl The base homeserver URL See - * {@link module:client~MatrixClient#getHomeserverUrl}. - * @param {Number} width The desired width of the thumbnail. - * @param {Number} height The desired height of the thumbnail. - * @param {string} resizeMethod The thumbnail resize method to use, either - * "crop" or "scale". - * @param {Boolean} allowDefault (optional) Passing false causes this method to - * return null if the user has no avatar image. Otherwise, a default image URL - * will be returned. Default: true. (Deprecated) - * @param {Boolean} allowDirectLinks (optional) If true, the avatar URL will be - * returned even if it is a direct hyperlink rather than a matrix content URL. - * If false, any non-matrix content URLs will be ignored. Setting this option to - * true will expose URLs that, if fetched, will leak information about the user - * to anyone who they share a room with. - * @return {?string} the avatar URL or null. - */ -RoomMember.prototype.getAvatarUrl = - function(baseUrl, width, height, resizeMethod, allowDefault, allowDirectLinks) { - if (allowDefault === undefined) { - allowDefault = true; - } - - const rawUrl = this.getMxcAvatarUrl(); - - if (!rawUrl && !allowDefault) { - return null; - } - const httpUrl = getHttpUriForMxc( - baseUrl, rawUrl, width, height, resizeMethod, allowDirectLinks, - ); - if (httpUrl) { - return httpUrl; - } - return null; -}; -/** - * get the mxc avatar url, either from a state event, or from a lazily loaded member - * @return {string} the mxc avatar url - */ -RoomMember.prototype.getMxcAvatarUrl = function() { - if (this.events.member) { - return this.events.member.getDirectionalContent().avatar_url; - } else if (this.user) { - return this.user.avatarUrl; - } - return null; -}; - -const MXID_PATTERN = /@.+:.+/; -const LTR_RTL_PATTERN = /[\u200E\u200F\u202A-\u202F]/; - -function shouldDisambiguate(selfUserId, displayName, roomState) { - if (!displayName || displayName === selfUserId) return false; - - // First check if the displayname is something we consider truthy - // after stripping it of zero width characters and padding spaces - if (!utils.removeHiddenChars(displayName)) return false; - - if (!roomState) return false; - - // Next check if the name contains something that look like a mxid - // If it does, it may be someone trying to impersonate someone else - // Show full mxid in this case - if (MXID_PATTERN.test(displayName)) return true; - - // Also show mxid if the display name contains any LTR/RTL characters as these - // make it very difficult for us to find similar *looking* display names - // E.g "Mark" could be cloned by writing "kraM" but in RTL. - if (LTR_RTL_PATTERN.test(displayName)) return true; - - // Also show mxid if there are other people with the same or similar - // displayname, after hidden character removal. - const userIds = roomState.getUserIdsWithDisplayName(displayName); - if (userIds.some((u) => u !== selfUserId)) return true; - - return false; -} - -function calculateDisplayName(selfUserId, displayName, roomState, disambiguate) { - if (disambiguate) return displayName + " (" + selfUserId + ")"; - - if (!displayName || displayName === selfUserId) return selfUserId; - - // First check if the displayname is something we consider truthy - // after stripping it of zero width characters and padding spaces - if (!utils.removeHiddenChars(displayName)) return selfUserId; - - return displayName; -} - -/** - * Fires whenever any room member's name changes. - * @event module:client~MatrixClient#"RoomMember.name" - * @param {MatrixEvent} event The matrix event which caused this event to fire. - * @param {RoomMember} member The member whose RoomMember.name changed. - * @param {string?} oldName The previous name. Null if the member didn't have a - * name previously. - * @example - * matrixClient.on("RoomMember.name", function(event, member){ - * var newName = member.name; - * }); - */ - -/** - * Fires whenever any room member's membership state changes. - * @event module:client~MatrixClient#"RoomMember.membership" - * @param {MatrixEvent} event The matrix event which caused this event to fire. - * @param {RoomMember} member The member whose RoomMember.membership changed. - * @param {string?} oldMembership The previous membership state. Null if it's a - * new member. - * @example - * matrixClient.on("RoomMember.membership", function(event, member, oldMembership){ - * var newState = member.membership; - * }); - */ - -/** - * Fires whenever any room member's typing state changes. - * @event module:client~MatrixClient#"RoomMember.typing" - * @param {MatrixEvent} event The matrix event which caused this event to fire. - * @param {RoomMember} member The member whose RoomMember.typing changed. - * @example - * matrixClient.on("RoomMember.typing", function(event, member){ - * var isTyping = member.typing; - * }); - */ - -/** - * Fires whenever any room member's power level changes. - * @event module:client~MatrixClient#"RoomMember.powerLevel" - * @param {MatrixEvent} event The matrix event which caused this event to fire. - * @param {RoomMember} member The member whose RoomMember.powerLevel changed. - * @example - * matrixClient.on("RoomMember.powerLevel", function(event, member){ - * var newPowerLevel = member.powerLevel; - * var newNormPowerLevel = member.powerLevelNorm; - * }); - */ diff --git a/src/models/room-member.ts b/src/models/room-member.ts new file mode 100644 index 000000000..de9437c07 --- /dev/null +++ b/src/models/room-member.ts @@ -0,0 +1,411 @@ +/* +Copyright 2015 - 2021 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/room-member + */ + +import { EventEmitter } from "events"; + +import { getHttpUriForMxc } from "../content-repo"; +import * as utils from "../utils"; +import { User } from "./user"; +import { MatrixEvent } from "./event"; +import { RoomState } from "./room-state"; + +export class RoomMember extends EventEmitter { + private _isOutOfBand = false; + private _modified: number; + + // XXX these should be read-only + public typing = false; + public name: string; + public rawDisplayName: string; + public powerLevel = 0; + public powerLevelNorm = 0; + public user?: User = null; + public membership: string = null; + public disambiguate = false; + public events: { + member?: MatrixEvent; + } = { + member: null, + }; + + /** + * Construct a new room member. + * + * @constructor + * @alias module:models/room-member + * + * @param {string} roomId The room ID of the member. + * @param {string} userId The user ID of the member. + * @prop {string} roomId The room ID for this member. + * @prop {string} userId The user ID of this member. + * @prop {boolean} typing True if the room member is currently typing. + * @prop {string} name The human-readable name for this room member. This will be + * disambiguated with a suffix of " (@user_id:matrix.org)" if another member shares the + * same displayname. + * @prop {string} rawDisplayName The ambiguous displayname of this room member. + * @prop {Number} powerLevel The power level for this room member. + * @prop {Number} powerLevelNorm The normalised power level (0-100) for this + * room member. + * @prop {User} user The User object for this room member, if one exists. + * @prop {string} membership The membership state for this room member e.g. 'join'. + * @prop {Object} events The events describing this RoomMember. + * @prop {MatrixEvent} events.member The m.room.member event for this RoomMember. + * @prop {boolean} disambiguate True if the member's name is disambiguated. + */ + constructor(public readonly roomId: string, public readonly userId: string) { + super(); + + this.name = userId; + this.rawDisplayName = userId; + this.updateModifiedTime(); + } + + /** + * Mark the member as coming from a channel that is not sync + */ + public markOutOfBand(): void { + this._isOutOfBand = true; + } + + /** + * @return {boolean} does the member come from a channel that is not sync? + * This is used to store the member seperately + * from the sync state so it available across browser sessions. + */ + public isOutOfBand(): boolean { + return this._isOutOfBand; + } + + /** + * Update this room member's membership event. May fire "RoomMember.name" if + * this event updates this member's name. + * @param {MatrixEvent} event The m.room.member event + * @param {RoomState} roomState Optional. The room state to take into account + * when calculating (e.g. for disambiguating users with the same name). + * @fires module:client~MatrixClient#event:"RoomMember.name" + * @fires module:client~MatrixClient#event:"RoomMember.membership" + */ + public setMembershipEvent(event: MatrixEvent, roomState: RoomState): void { + const displayName = event.getDirectionalContent().displayname; + + if (event.getType() !== "m.room.member") { + return; + } + + this._isOutOfBand = false; + + this.events.member = event; + + const oldMembership = this.membership; + this.membership = event.getDirectionalContent().membership; + + this.disambiguate = shouldDisambiguate( + this.userId, + displayName, + roomState, + ); + + const oldName = this.name; + this.name = calculateDisplayName( + this.userId, + displayName, + roomState, + this.disambiguate, + ); + + this.rawDisplayName = event.getDirectionalContent().displayname || this.userId; + if (oldMembership !== this.membership) { + this.updateModifiedTime(); + this.emit("RoomMember.membership", event, this, oldMembership); + } + if (oldName !== this.name) { + this.updateModifiedTime(); + this.emit("RoomMember.name", event, this, oldName); + } + } + + /** + * Update this room member's power level event. May fire + * "RoomMember.powerLevel" if this event updates this member's power levels. + * @param {MatrixEvent} powerLevelEvent The m.room.power_levels + * event + * @fires module:client~MatrixClient#event:"RoomMember.powerLevel" + */ + public setPowerLevelEvent(powerLevelEvent: MatrixEvent): void { + if (powerLevelEvent.getType() !== "m.room.power_levels") { + return; + } + + const evContent = powerLevelEvent.getDirectionalContent(); + + let maxLevel = evContent.users_default || 0; + const users = evContent.users || {}; + Object.values(users).forEach(function(lvl: number) { + maxLevel = Math.max(maxLevel, lvl); + }); + const oldPowerLevel = this.powerLevel; + const oldPowerLevelNorm = this.powerLevelNorm; + + if (users[this.userId] !== undefined) { + this.powerLevel = users[this.userId]; + } else if (evContent.users_default !== undefined) { + this.powerLevel = evContent.users_default; + } else { + this.powerLevel = 0; + } + this.powerLevelNorm = 0; + if (maxLevel > 0) { + this.powerLevelNorm = (this.powerLevel * 100) / maxLevel; + } + + // emit for changes in powerLevelNorm as well (since the app will need to + // redraw everyone's level if the max has changed) + if (oldPowerLevel !== this.powerLevel || oldPowerLevelNorm !== this.powerLevelNorm) { + this.updateModifiedTime(); + this.emit("RoomMember.powerLevel", powerLevelEvent, this); + } + } + + /** + * Update this room member's typing event. May fire "RoomMember.typing" if + * this event changes this member's typing state. + * @param {MatrixEvent} event The typing event + * @fires module:client~MatrixClient#event:"RoomMember.typing" + */ + public setTypingEvent(event: MatrixEvent): void { + if (event.getType() !== "m.typing") { + return; + } + const oldTyping = this.typing; + this.typing = false; + const typingList = event.getContent().user_ids; + if (!Array.isArray(typingList)) { + // malformed event :/ bail early. TODO: whine? + return; + } + if (typingList.indexOf(this.userId) !== -1) { + this.typing = true; + } + if (oldTyping !== this.typing) { + this.updateModifiedTime(); + this.emit("RoomMember.typing", event, this); + } + } + + /** + * Update the last modified time to the current time. + */ + private updateModifiedTime() { + this._modified = Date.now(); + } + + /** + * Get the timestamp when this RoomMember was last updated. This timestamp is + * updated when properties on this RoomMember are updated. + * It is updated before firing events. + * @return {number} The timestamp + */ + public getLastModifiedTime(): number { + return this._modified; + } + + public isKicked(): boolean { + return this.membership === "leave" && + this.events.member.getSender() !== this.events.member.getStateKey(); + } + + /** + * If this member was invited with the is_direct flag set, return + * the user that invited this member + * @return {string} user id of the inviter + */ + public getDMInviter(): string { + // when not available because that room state hasn't been loaded in, + // we don't really know, but more likely to not be a direct chat + if (this.events.member) { + // TODO: persist the is_direct flag on the member as more member events + // come in caused by displayName changes. + + // the is_direct flag is set on the invite member event. + // This is copied on the prev_content section of the join member event + // when the invite is accepted. + + const memberEvent = this.events.member; + let memberContent = memberEvent.getContent(); + let inviteSender = memberEvent.getSender(); + + if (memberContent.membership === "join") { + memberContent = memberEvent.getPrevContent(); + inviteSender = memberEvent.getUnsigned().prev_sender; + } + + if (memberContent.membership === "invite" && memberContent.is_direct) { + return inviteSender; + } + } + } + + /** + * Get the avatar URL for a room member. + * @param {string} baseUrl The base homeserver URL See + * {@link module:client~MatrixClient#getHomeserverUrl}. + * @param {Number} width The desired width of the thumbnail. + * @param {Number} height The desired height of the thumbnail. + * @param {string} resizeMethod The thumbnail resize method to use, either + * "crop" or "scale". + * @param {Boolean} allowDefault (optional) Passing false causes this method to + * return null if the user has no avatar image. Otherwise, a default image URL + * will be returned. Default: true. (Deprecated) + * @param {Boolean} allowDirectLinks (optional) If true, the avatar URL will be + * returned even if it is a direct hyperlink rather than a matrix content URL. + * If false, any non-matrix content URLs will be ignored. Setting this option to + * true will expose URLs that, if fetched, will leak information about the user + * to anyone who they share a room with. + * @return {?string} the avatar URL or null. + */ + public getAvatarUrl( + baseUrl: string, + width: number, + height: number, + resizeMethod: string, + allowDefault = true, + allowDirectLinks: boolean, + ): string | null { + const rawUrl = this.getMxcAvatarUrl(); + + if (!rawUrl && !allowDefault) { + return null; + } + const httpUrl = getHttpUriForMxc(baseUrl, rawUrl, width, height, resizeMethod, allowDirectLinks); + if (httpUrl) { + return httpUrl; + } + return null; + } + + /** + * get the mxc avatar url, either from a state event, or from a lazily loaded member + * @return {string} the mxc avatar url + */ + public getMxcAvatarUrl(): string | null { + if (this.events.member) { + return this.events.member.getDirectionalContent().avatar_url; + } else if (this.user) { + return this.user.avatarUrl; + } + return null; + } +} + +const MXID_PATTERN = /@.+:.+/; +const LTR_RTL_PATTERN = /[\u200E\u200F\u202A-\u202F]/; + +function shouldDisambiguate(selfUserId: string, displayName: string, roomState: RoomState): boolean { + if (!displayName || displayName === selfUserId) return false; + + // First check if the displayname is something we consider truthy + // after stripping it of zero width characters and padding spaces + if (!utils.removeHiddenChars(displayName)) return false; + + if (!roomState) return false; + + // Next check if the name contains something that look like a mxid + // If it does, it may be someone trying to impersonate someone else + // Show full mxid in this case + if (MXID_PATTERN.test(displayName)) return true; + + // Also show mxid if the display name contains any LTR/RTL characters as these + // make it very difficult for us to find similar *looking* display names + // E.g "Mark" could be cloned by writing "kraM" but in RTL. + if (LTR_RTL_PATTERN.test(displayName)) return true; + + // Also show mxid if there are other people with the same or similar + // displayname, after hidden character removal. + const userIds = roomState.getUserIdsWithDisplayName(displayName); + if (userIds.some((u) => u !== selfUserId)) return true; + + return false; +} + +function calculateDisplayName( + selfUserId: string, + displayName: string, + roomState: RoomState, + disambiguate: boolean, +): string { + if (disambiguate) return displayName + " (" + selfUserId + ")"; + + if (!displayName || displayName === selfUserId) return selfUserId; + + // First check if the displayname is something we consider truthy + // after stripping it of zero width characters and padding spaces + if (!utils.removeHiddenChars(displayName)) return selfUserId; + + return displayName; +} + +/** + * Fires whenever any room member's name changes. + * @event module:client~MatrixClient#"RoomMember.name" + * @param {MatrixEvent} event The matrix event which caused this event to fire. + * @param {RoomMember} member The member whose RoomMember.name changed. + * @param {string?} oldName The previous name. Null if the member didn't have a + * name previously. + * @example + * matrixClient.on("RoomMember.name", function(event, member){ + * var newName = member.name; + * }); + */ + +/** + * Fires whenever any room member's membership state changes. + * @event module:client~MatrixClient#"RoomMember.membership" + * @param {MatrixEvent} event The matrix event which caused this event to fire. + * @param {RoomMember} member The member whose RoomMember.membership changed. + * @param {string?} oldMembership The previous membership state. Null if it's a + * new member. + * @example + * matrixClient.on("RoomMember.membership", function(event, member, oldMembership){ + * var newState = member.membership; + * }); + */ + +/** + * Fires whenever any room member's typing state changes. + * @event module:client~MatrixClient#"RoomMember.typing" + * @param {MatrixEvent} event The matrix event which caused this event to fire. + * @param {RoomMember} member The member whose RoomMember.typing changed. + * @example + * matrixClient.on("RoomMember.typing", function(event, member){ + * var isTyping = member.typing; + * }); + */ + +/** + * Fires whenever any room member's power level changes. + * @event module:client~MatrixClient#"RoomMember.powerLevel" + * @param {MatrixEvent} event The matrix event which caused this event to fire. + * @param {RoomMember} member The member whose RoomMember.powerLevel changed. + * @example + * matrixClient.on("RoomMember.powerLevel", function(event, member){ + * var newPowerLevel = member.powerLevel; + * var newNormPowerLevel = member.powerLevelNorm; + * }); + */ diff --git a/src/models/user.js b/src/models/user.js deleted file mode 100644 index ec1127e84..000000000 --- a/src/models/user.js +++ /dev/null @@ -1,260 +0,0 @@ -/* -Copyright 2015, 2016 OpenMarket 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/user - */ - -import * as utils from "../utils"; -import { EventEmitter } from "events"; - -/** - * Construct a new User. A User must have an ID and can optionally have extra - * information associated with it. - * @constructor - * @param {string} userId Required. The ID of this user. - * @prop {string} userId The ID of the user. - * @prop {Object} info The info object supplied in the constructor. - * @prop {string} displayName The 'displayname' of the user if known. - * @prop {string} avatarUrl The 'avatar_url' of the user if known. - * @prop {string} presence The presence enum if known. - * @prop {string} presenceStatusMsg The presence status message if known. - * @prop {Number} lastActiveAgo The time elapsed in ms since the user interacted - * proactively with the server, or we saw a message from the user - * @prop {Number} lastPresenceTs Timestamp (ms since the epoch) for when we last - * received presence data for this user. We can subtract - * lastActiveAgo from this to approximate an absolute value for - * when a user was last active. - * @prop {Boolean} currentlyActive Whether we should consider lastActiveAgo to be - * an approximation and that the user should be seen as active 'now' - * @prop {string} _unstable_statusMessage The status message for the user, if known. This is - * different from the presenceStatusMsg in that this is not tied to - * the user's presence, and should be represented differently. - * @prop {Object} events The events describing this user. - * @prop {MatrixEvent} events.presence The m.presence event for this user. - */ -export function User(userId) { - this.userId = userId; - this.presence = "offline"; - this.presenceStatusMsg = null; - this._unstable_statusMessage = ""; - this.displayName = userId; - this.rawDisplayName = userId; - this.avatarUrl = null; - this.lastActiveAgo = 0; - this.lastPresenceTs = 0; - this.currentlyActive = false; - this.events = { - presence: null, - profile: null, - }; - this._updateModifiedTime(); -} -utils.inherits(User, EventEmitter); - -/** - * Update this User with the given presence event. May fire "User.presence", - * "User.avatarUrl" and/or "User.displayName" if this event updates this user's - * properties. - * @param {MatrixEvent} event The m.presence event. - * @fires module:client~MatrixClient#event:"User.presence" - * @fires module:client~MatrixClient#event:"User.displayName" - * @fires module:client~MatrixClient#event:"User.avatarUrl" - */ -User.prototype.setPresenceEvent = function(event) { - if (event.getType() !== "m.presence") { - return; - } - const firstFire = this.events.presence === null; - this.events.presence = event; - - const eventsToFire = []; - if (event.getContent().presence !== this.presence || firstFire) { - eventsToFire.push("User.presence"); - } - if (event.getContent().avatar_url && - event.getContent().avatar_url !== this.avatarUrl) { - eventsToFire.push("User.avatarUrl"); - } - if (event.getContent().displayname && - event.getContent().displayname !== this.displayName) { - eventsToFire.push("User.displayName"); - } - if (event.getContent().currently_active !== undefined && - event.getContent().currently_active !== this.currentlyActive) { - eventsToFire.push("User.currentlyActive"); - } - - this.presence = event.getContent().presence; - eventsToFire.push("User.lastPresenceTs"); - - if (event.getContent().status_msg) { - this.presenceStatusMsg = event.getContent().status_msg; - } - if (event.getContent().displayname) { - this.displayName = event.getContent().displayname; - } - if (event.getContent().avatar_url) { - this.avatarUrl = event.getContent().avatar_url; - } - this.lastActiveAgo = event.getContent().last_active_ago; - this.lastPresenceTs = Date.now(); - this.currentlyActive = event.getContent().currently_active; - - this._updateModifiedTime(); - - for (let i = 0; i < eventsToFire.length; i++) { - this.emit(eventsToFire[i], event, this); - } -}; - -/** - * Manually set this user's display name. No event is emitted in response to this - * as there is no underlying MatrixEvent to emit with. - * @param {string} name The new display name. - */ -User.prototype.setDisplayName = function(name) { - const oldName = this.displayName; - if (typeof name === "string") { - this.displayName = name; - } else { - this.displayName = undefined; - } - if (name !== oldName) { - this._updateModifiedTime(); - } -}; - -/** - * Manually set this user's non-disambiguated display name. No event is emitted - * in response to this as there is no underlying MatrixEvent to emit with. - * @param {string} name The new display name. - */ -User.prototype.setRawDisplayName = function(name) { - if (typeof name === "string") { - this.rawDisplayName = name; - } else { - this.rawDisplayName = undefined; - } -}; - -/** - * Manually set this user's avatar URL. No event is emitted in response to this - * as there is no underlying MatrixEvent to emit with. - * @param {string} url The new avatar URL. - */ -User.prototype.setAvatarUrl = function(url) { - const oldUrl = this.avatarUrl; - this.avatarUrl = url; - if (url !== oldUrl) { - this._updateModifiedTime(); - } -}; - -/** - * Update the last modified time to the current time. - */ -User.prototype._updateModifiedTime = function() { - this._modified = Date.now(); -}; - -/** - * Get the timestamp when this User was last updated. This timestamp is - * updated when this User receives a new Presence event which has updated a - * property on this object. It is updated before firing events. - * @return {number} The timestamp - */ -User.prototype.getLastModifiedTime = function() { - return this._modified; -}; - -/** - * Get the absolute timestamp when this User was last known active on the server. - * It is *NOT* accurate if this.currentlyActive is true. - * @return {number} The timestamp - */ -User.prototype.getLastActiveTs = function() { - return this.lastPresenceTs - this.lastActiveAgo; -}; - -/** - * Manually set the user's status message. - * @param {MatrixEvent} event The im.vector.user_status event. - * @fires module:client~MatrixClient#event:"User._unstable_statusMessage" - */ -User.prototype._unstable_updateStatusMessage = function(event) { - if (!event.getContent()) this._unstable_statusMessage = ""; - else this._unstable_statusMessage = event.getContent()["status"]; - this._updateModifiedTime(); - this.emit("User._unstable_statusMessage", this); -}; - -/** - * Fires whenever any user's lastPresenceTs changes, - * ie. whenever any presence event is received for a user. - * @event module:client~MatrixClient#"User.lastPresenceTs" - * @param {MatrixEvent} event The matrix event which caused this event to fire. - * @param {User} user The user whose User.lastPresenceTs changed. - * @example - * matrixClient.on("User.lastPresenceTs", function(event, user){ - * var newlastPresenceTs = user.lastPresenceTs; - * }); - */ - -/** - * Fires whenever any user's presence changes. - * @event module:client~MatrixClient#"User.presence" - * @param {MatrixEvent} event The matrix event which caused this event to fire. - * @param {User} user The user whose User.presence changed. - * @example - * matrixClient.on("User.presence", function(event, user){ - * var newPresence = user.presence; - * }); - */ - -/** - * Fires whenever any user's currentlyActive changes. - * @event module:client~MatrixClient#"User.currentlyActive" - * @param {MatrixEvent} event The matrix event which caused this event to fire. - * @param {User} user The user whose User.currentlyActive changed. - * @example - * matrixClient.on("User.currentlyActive", function(event, user){ - * var newCurrentlyActive = user.currentlyActive; - * }); - */ - -/** - * Fires whenever any user's display name changes. - * @event module:client~MatrixClient#"User.displayName" - * @param {MatrixEvent} event The matrix event which caused this event to fire. - * @param {User} user The user whose User.displayName changed. - * @example - * matrixClient.on("User.displayName", function(event, user){ - * var newName = user.displayName; - * }); - */ - -/** - * Fires whenever any user's avatar URL changes. - * @event module:client~MatrixClient#"User.avatarUrl" - * @param {MatrixEvent} event The matrix event which caused this event to fire. - * @param {User} user The user whose User.avatarUrl changed. - * @example - * matrixClient.on("User.avatarUrl", function(event, user){ - * var newUrl = user.avatarUrl; - * }); - */ diff --git a/src/models/user.ts b/src/models/user.ts new file mode 100644 index 000000000..da5c4d78d --- /dev/null +++ b/src/models/user.ts @@ -0,0 +1,273 @@ +/* +Copyright 2015 - 2021 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/user + */ + +import { EventEmitter } from "events"; + +import { MatrixEvent } from "./event"; + +export class User extends EventEmitter { + // eslint-disable-next-line camelcase + private modified: number; + + // XXX these should be read-only + public displayName: string; + public rawDisplayName: string; + public avatarUrl: string; + public presenceStatusMsg: string = null; + public presence = "offline"; + public lastActiveAgo = 0; + public lastPresenceTs = 0; + public currentlyActive = false; + public events: { + presence?: MatrixEvent; + profile?: MatrixEvent; + } = { + presence: null, + profile: null, + }; + public unstable_statusMessage = ""; + + /** + * Construct a new User. A User must have an ID and can optionally have extra + * information associated with it. + * @constructor + * @param {string} userId Required. The ID of this user. + * @prop {string} userId The ID of the user. + * @prop {Object} info The info object supplied in the constructor. + * @prop {string} displayName The 'displayname' of the user if known. + * @prop {string} avatarUrl The 'avatar_url' of the user if known. + * @prop {string} presence The presence enum if known. + * @prop {string} presenceStatusMsg The presence status message if known. + * @prop {Number} lastActiveAgo The time elapsed in ms since the user interacted + * proactively with the server, or we saw a message from the user + * @prop {Number} lastPresenceTs Timestamp (ms since the epoch) for when we last + * received presence data for this user. We can subtract + * lastActiveAgo from this to approximate an absolute value for + * when a user was last active. + * @prop {Boolean} currentlyActive Whether we should consider lastActiveAgo to be + * an approximation and that the user should be seen as active 'now' + * @prop {string} unstable_statusMessage The status message for the user, if known. This is + * different from the presenceStatusMsg in that this is not tied to + * the user's presence, and should be represented differently. + * @prop {Object} events The events describing this user. + * @prop {MatrixEvent} events.presence The m.presence event for this user. + */ + constructor(public readonly userId: string) { + super(); + this.displayName = userId; + this.rawDisplayName = userId; + this.avatarUrl = null; + this.updateModifiedTime(); + } + + /** + * Update this User with the given presence event. May fire "User.presence", + * "User.avatarUrl" and/or "User.displayName" if this event updates this user's + * properties. + * @param {MatrixEvent} event The m.presence event. + * @fires module:client~MatrixClient#event:"User.presence" + * @fires module:client~MatrixClient#event:"User.displayName" + * @fires module:client~MatrixClient#event:"User.avatarUrl" + */ + public setPresenceEvent(event: MatrixEvent): void { + if (event.getType() !== "m.presence") { + return; + } + const firstFire = this.events.presence === null; + this.events.presence = event; + + const eventsToFire = []; + if (event.getContent().presence !== this.presence || firstFire) { + eventsToFire.push("User.presence"); + } + if (event.getContent().avatar_url && + event.getContent().avatar_url !== this.avatarUrl) { + eventsToFire.push("User.avatarUrl"); + } + if (event.getContent().displayname && + event.getContent().displayname !== this.displayName) { + eventsToFire.push("User.displayName"); + } + if (event.getContent().currently_active !== undefined && + event.getContent().currently_active !== this.currentlyActive) { + eventsToFire.push("User.currentlyActive"); + } + + this.presence = event.getContent().presence; + eventsToFire.push("User.lastPresenceTs"); + + if (event.getContent().status_msg) { + this.presenceStatusMsg = event.getContent().status_msg; + } + if (event.getContent().displayname) { + this.displayName = event.getContent().displayname; + } + if (event.getContent().avatar_url) { + this.avatarUrl = event.getContent().avatar_url; + } + this.lastActiveAgo = event.getContent().last_active_ago; + this.lastPresenceTs = Date.now(); + this.currentlyActive = event.getContent().currently_active; + + this.updateModifiedTime(); + + for (let i = 0; i < eventsToFire.length; i++) { + this.emit(eventsToFire[i], event, this); + } + } + + /** + * Manually set this user's display name. No event is emitted in response to this + * as there is no underlying MatrixEvent to emit with. + * @param {string} name The new display name. + */ + public setDisplayName(name: string): void { + const oldName = this.displayName; + if (typeof name === "string") { + this.displayName = name; + } else { + this.displayName = undefined; + } + if (name !== oldName) { + this.updateModifiedTime(); + } + } + + /** + * Manually set this user's non-disambiguated display name. No event is emitted + * in response to this as there is no underlying MatrixEvent to emit with. + * @param {string} name The new display name. + */ + public setRawDisplayName(name: string): void { + if (typeof name === "string") { + this.rawDisplayName = name; + } else { + this.rawDisplayName = undefined; + } + } + + /** + * Manually set this user's avatar URL. No event is emitted in response to this + * as there is no underlying MatrixEvent to emit with. + * @param {string} url The new avatar URL. + */ + public setAvatarUrl(url: string): void { + const oldUrl = this.avatarUrl; + this.avatarUrl = url; + if (url !== oldUrl) { + this.updateModifiedTime(); + } + } + + /** + * Update the last modified time to the current time. + */ + private updateModifiedTime(): void { + this.modified = Date.now(); + } + + /** + * Get the timestamp when this User was last updated. This timestamp is + * updated when this User receives a new Presence event which has updated a + * property on this object. It is updated before firing events. + * @return {number} The timestamp + */ + public getLastModifiedTime(): number { + return this.modified; + } + + /** + * Get the absolute timestamp when this User was last known active on the server. + * It is *NOT* accurate if this.currentlyActive is true. + * @return {number} The timestamp + */ + public getLastActiveTs(): number { + return this.lastPresenceTs - this.lastActiveAgo; + } + + /** + * Manually set the user's status message. + * @param {MatrixEvent} event The im.vector.user_status event. + * @fires module:client~MatrixClient#event:"User.unstable_statusMessage" + */ + // eslint-disable-next-line camelcase + public _unstable_updateStatusMessage(event: MatrixEvent): void { + if (!event.getContent()) this.unstable_statusMessage = ""; + else this.unstable_statusMessage = event.getContent()["status"]; + this.updateModifiedTime(); + this.emit("User.unstable_statusMessage", this); + } +} + +/** + * Fires whenever any user's lastPresenceTs changes, + * ie. whenever any presence event is received for a user. + * @event module:client~MatrixClient#"User.lastPresenceTs" + * @param {MatrixEvent} event The matrix event which caused this event to fire. + * @param {User} user The user whose User.lastPresenceTs changed. + * @example + * matrixClient.on("User.lastPresenceTs", function(event, user){ + * var newlastPresenceTs = user.lastPresenceTs; + * }); + */ + +/** + * Fires whenever any user's presence changes. + * @event module:client~MatrixClient#"User.presence" + * @param {MatrixEvent} event The matrix event which caused this event to fire. + * @param {User} user The user whose User.presence changed. + * @example + * matrixClient.on("User.presence", function(event, user){ + * var newPresence = user.presence; + * }); + */ + +/** + * Fires whenever any user's currentlyActive changes. + * @event module:client~MatrixClient#"User.currentlyActive" + * @param {MatrixEvent} event The matrix event which caused this event to fire. + * @param {User} user The user whose User.currentlyActive changed. + * @example + * matrixClient.on("User.currentlyActive", function(event, user){ + * var newCurrentlyActive = user.currentlyActive; + * }); + */ + +/** + * Fires whenever any user's display name changes. + * @event module:client~MatrixClient#"User.displayName" + * @param {MatrixEvent} event The matrix event which caused this event to fire. + * @param {User} user The user whose User.displayName changed. + * @example + * matrixClient.on("User.displayName", function(event, user){ + * var newName = user.displayName; + * }); + */ + +/** + * Fires whenever any user's avatar URL changes. + * @event module:client~MatrixClient#"User.avatarUrl" + * @param {MatrixEvent} event The matrix event which caused this event to fire. + * @param {User} user The user whose User.avatarUrl changed. + * @example + * matrixClient.on("User.avatarUrl", function(event, user){ + * var newUrl = user.avatarUrl; + * }); + */ From bfea8824163951b01804773a644f263ade0529c8 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 17 Jun 2021 14:04:04 +0100 Subject: [PATCH 14/20] Convert MatrixEvent to TS --- src/models/{event.js => event.ts} | 681 +++++++++++++++++------------- 1 file changed, 379 insertions(+), 302 deletions(-) rename src/models/{event.js => event.ts} (70%) diff --git a/src/models/event.js b/src/models/event.ts similarity index 70% rename from src/models/event.js rename to src/models/event.ts index 1b3bec755..17c6be3ba 100644 --- a/src/models/event.js +++ b/src/models/event.ts @@ -1,6 +1,5 @@ /* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2015 - 2021 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. @@ -22,153 +21,168 @@ limitations under the License. */ import { EventEmitter } from 'events'; -import * as utils from '../utils'; + import { logger } from '../logger'; +import { VerificationRequest } from "../crypto/verification/request/VerificationRequest"; +import { EventType, RelationType } from "../@types/event"; +import { Crypto } from "../crypto"; /** * Enum for event statuses. * @readonly * @enum {string} */ -export const EventStatus = { +export enum EventStatus { /** The event was not sent and will no longer be retried. */ - NOT_SENT: "not_sent", + NOT_SENT = "not_sent", /** The message is being encrypted */ - ENCRYPTING: "encrypting", + ENCRYPTING = "encrypting", /** The event is in the process of being sent. */ - SENDING: "sending", + SENDING = "sending", + /** The event is in a queue waiting to be sent. */ - QUEUED: "queued", - /** The event has been sent to the server, but we have not yet received the - * echo. */ - SENT: "sent", + QUEUED = "queued", + + /** The event has been sent to the server, but we have not yet received the echo. */ + SENT = "sent", /** The event was cancelled before it was successfully sent. */ - CANCELLED: "cancelled", -}; + CANCELLED = "cancelled", +} -const interns = {}; -function intern(str) { +const interns: Record = {}; +function intern(str: string): string { if (!interns[str]) { interns[str] = str; } return interns[str]; } -/** - * Construct a Matrix Event object - * @constructor - * - * @param {Object} event The raw event to be wrapped in this DAO - * - * @prop {Object} event The raw (possibly encrypted) event. Do not access - * this property directly unless you absolutely have to. Prefer the getter - * methods defined on this class. Using the getter methods shields your app - * from changes to event JSON between Matrix versions. - * - * @prop {RoomMember} sender The room member who sent this event, or null e.g. - * this is a presence event. This is only guaranteed to be set for events that - * appear in a timeline, ie. do not guarantee that it will be set on state - * events. - * @prop {RoomMember} target The room member who is the target of this event, e.g. - * the invitee, the person being banned, etc. - * @prop {EventStatus} status The sending status of the event. - * @prop {Error} error most recent error associated with sending the event, if any - * @prop {boolean} forwardLooking True if this event is 'forward looking', meaning - * that getDirectionalContent() will return event.content and not event.prev_content. - * Default: true. This property is experimental and may change. - */ -export const MatrixEvent = function( - event, -) { - // intern the values of matrix events to force share strings and reduce the - // amount of needless string duplication. This can save moderate amounts of - // memory (~10% on a 350MB heap). - // 'membership' at the event level (rather than the content level) is a legacy - // field that Element never otherwise looks at, but it will still take up a lot - // of space if we don't intern it. - ["state_key", "type", "sender", "room_id", "membership"].forEach((prop) => { - if (!event[prop]) { - return; - } - event[prop] = intern(event[prop]); - }); +/* eslint-disable camelcase */ +interface IContent { + [key: string]: any; + msgtype?: string; + membership?: string; + avatar_url?: string; + displayname?: string; + "m.relates_to"?: IEventRelation; +} - ["membership", "avatar_url", "displayname"].forEach((prop) => { - if (!event.content || !event.content[prop]) { - return; - } - event.content[prop] = intern(event.content[prop]); - }); +interface IUnsigned { + age?: number; + prev_sender?: string; + prev_content?: IContent; + redacted_because?: IEvent; +} - ["rel_type"].forEach((prop) => { - if ( - !event.content || - !event.content["m.relates_to"] || - !event.content["m.relates_to"][prop] - ) { - return; - } - event.content["m.relates_to"][prop] = intern(event.content["m.relates_to"][prop]); - }); +interface IEvent { + event_id: string; + type: string; + content: IContent; + sender: string; + room_id: string; + origin_server_ts: number; + txn_id?: string; + state_key?: string; + membership?: string; + unsigned?: IUnsigned; + redacts?: string; - this.event = event || {}; + // v1 legacy fields + user_id?: string; + prev_content?: IContent; + age?: number; +} - this.sender = null; - this.target = null; - this.status = null; - this.error = null; - this.forwardLooking = true; - this._pushActions = null; - this._replacingEvent = null; - this._localRedactionEvent = null; - this._isCancelled = false; +interface IAggregatedRelation { + origin_server_ts: number; + event_id?: string; + sender?: string; + type?: string; + count?: number; + key?: string; +} - this._clearEvent = {}; +interface IEventRelation { + rel_type: string; + event_id: string; + key?: string; +} + +interface IDecryptionResult { + clearEvent: { + room_id?: string; + type: string, + content: IContent, + unsigned?: IUnsigned, + }; + forwardingCurve25519KeyChain?: string[]; + senderCurve25519Key?: string; + claimedEd25519Key?: string; + untrusted?: boolean; +} +/* eslint-enable camelcase */ + +interface IClearEvent { + type: string; + content: Omit; + unsigned?: IUnsigned; +} + +interface IKeyRequestRecipient { + userId: string; + deviceId: "*" | string; +} + +export interface IDecryptOptions { + emit?: boolean; + isRetry?: boolean; +} + +export class MatrixEvent extends EventEmitter { + private pushActions: object = null; + private _replacingEvent: MatrixEvent = null; + private _localRedactionEvent: MatrixEvent = null; + private _isCancelled = false; + private clearEvent: Partial = {}; /* curve25519 key which we believe belongs to the sender of the event. See * getSenderKey() */ - this._senderCurve25519Key = null; + private senderCurve25519Key: string = null; /* ed25519 key which the sender of this event (for olm) or the creator of * the megolm session (for megolm) claims to own. See getClaimedEd25519Key() */ - this._claimedEd25519Key = null; + private claimedEd25519Key: string = null; /* curve25519 keys of devices involved in telling us about the - * _senderCurve25519Key and _claimedEd25519Key. + * senderCurve25519Key and claimedEd25519Key. * See getForwardingCurve25519KeyChain(). */ - this._forwardingCurve25519KeyChain = []; + private forwardingCurve25519KeyChain: string[] = []; /* where the decryption key is untrusted */ - this._untrusted = null; + private untrusted: boolean = null; /* if we have a process decrypting this event, a Promise which resolves * when it is finished. Normally null. */ - this._decryptionPromise = null; + private _decryptionPromise: Promise = null; /* flag to indicate if we should retry decrypting this event after the * first attempt (eg, we have received new data which means that a second * attempt may succeed) */ - this._retryDecryption = false; - - /* If the event is a `m.key.verification.request` (or to_device `m.key.verification.start`) event, - * `Crypto` will set this the `VerificationRequest` for the event - * so it can be easily accessed from the timeline. - */ - this.verificationRequest = null; + private retryDecryption = false; /* The txnId with which this event was sent if it was during this session, - allows for a unique ID which does not change when the event comes back down sync. + * allows for a unique ID which does not change when the event comes back down sync. */ - this._txnId = event.txn_id || null; + private txnId: string = null; /* Set an approximate timestamp for the event relative the local clock. * This will inherently be approximate because it doesn't take into account @@ -176,36 +190,97 @@ export const MatrixEvent = function( * it to us and the time we're now constructing this event, but that's better * than assuming the local clock is in sync with the origin HS's clock. */ - this._localTimestamp = Date.now() - this.getAge(); -}; -utils.inherits(MatrixEvent, EventEmitter); + private readonly localTimestamp: number; + + // XXX: these should be read-only + public sender = null; + public target = null; + public status: EventStatus = null; + public error = null; + public forwardLooking = true; + + /* If the event is a `m.key.verification.request` (or to_device `m.key.verification.start`) event, + * `Crypto` will set this the `VerificationRequest` for the event + * so it can be easily accessed from the timeline. + */ + public verificationRequest = null; + + /** + * Construct a Matrix Event object + * @constructor + * + * @param {Object} event The raw event to be wrapped in this DAO + * + * @prop {Object} event The raw (possibly encrypted) event. Do not access + * this property directly unless you absolutely have to. Prefer the getter + * methods defined on this class. Using the getter methods shields your app + * from changes to event JSON between Matrix versions. + * + * @prop {RoomMember} sender The room member who sent this event, or null e.g. + * this is a presence event. This is only guaranteed to be set for events that + * appear in a timeline, ie. do not guarantee that it will be set on state + * events. + * @prop {RoomMember} target The room member who is the target of this event, e.g. + * the invitee, the person being banned, etc. + * @prop {EventStatus} status The sending status of the event. + * @prop {Error} error most recent error associated with sending the event, if any + * @prop {boolean} forwardLooking True if this event is 'forward looking', meaning + * that getDirectionalContent() will return event.content and not event.prev_content. + * Default: true. This property is experimental and may change. + */ + constructor(public event: Partial = {}) { + super(); + + // intern the values of matrix events to force share strings and reduce the + // amount of needless string duplication. This can save moderate amounts of + // memory (~10% on a 350MB heap). + // 'membership' at the event level (rather than the content level) is a legacy + // field that Element never otherwise looks at, but it will still take up a lot + // of space if we don't intern it. + ["state_key", "type", "sender", "room_id", "membership"].forEach((prop) => { + if (typeof event[prop] !== "string") return; + event[prop] = intern(event[prop]); + }); + + ["membership", "avatar_url", "displayname"].forEach((prop) => { + if (typeof event.content?.[prop] !== "string") return; + event.content[prop] = intern(event.content[prop]); + }); + + ["rel_type"].forEach((prop) => { + if (typeof event.content?.["m.relates_to"]?.[prop] !== "string") return; + event.content["m.relates_to"][prop] = intern(event.content["m.relates_to"][prop]); + }); + + this.txnId = event.txn_id || null; + this.localTimestamp = Date.now() - this.getAge(); + } -utils.extend(MatrixEvent.prototype, { /** * Get the event_id for this event. * @return {string} The event ID, e.g. $143350589368169JsLZx:localhost * */ - getId: function() { + public getId(): string { return this.event.event_id; - }, + } /** * Get the user_id for this event. * @return {string} The user ID, e.g. @alice:matrix.org */ - getSender: function() { + public getSender(): string { return this.event.sender || this.event.user_id; // v2 / v1 - }, + } /** * Get the (decrypted, if necessary) type of event. * * @return {string} The event type, e.g. m.room.message */ - getType: function() { - return this._clearEvent.type || this.event.type; - }, + public getType(): EventType | string { + return this.clearEvent.type || this.event.type; + } /** * Get the (possibly encrypted) type of the event that will be sent to the @@ -213,9 +288,9 @@ utils.extend(MatrixEvent.prototype, { * * @return {string} The event type. */ - getWireType: function() { + public getWireType(): EventType | string { return this.event.type; - }, + } /** * Get the room_id for this event. This will return undefined @@ -223,25 +298,25 @@ utils.extend(MatrixEvent.prototype, { * @return {string} The room ID, e.g. !cURbafjkfsMDVwdRDQ:matrix.org * */ - getRoomId: function() { + public getRoomId(): string { return this.event.room_id; - }, + } /** * Get the timestamp of this event. * @return {Number} The event timestamp, e.g. 1433502692297 */ - getTs: function() { + public getTs(): number { return this.event.origin_server_ts; - }, + } /** * Get the timestamp of this event, as a Date object. * @return {Date} The event date, e.g. new Date(1433502692297) */ - getDate: function() { + public getDate(): Date | null { return this.event.origin_server_ts ? new Date(this.event.origin_server_ts) : null; - }, + } /** * Get the (decrypted, if necessary) event content JSON, even if the event @@ -249,12 +324,12 @@ utils.extend(MatrixEvent.prototype, { * * @return {Object} The event content JSON, or an empty object. */ - getOriginalContent: function() { + public getOriginalContent(): IContent { if (this._localRedactionEvent) { return {}; } - return this._clearEvent.content || this.event.content || {}; - }, + return this.clearEvent.content || this.event.content || {}; + } /** * Get the (decrypted, if necessary) event content JSON, @@ -263,7 +338,7 @@ utils.extend(MatrixEvent.prototype, { * * @return {Object} The event content JSON, or an empty object. */ - getContent: function() { + public getContent(): IContent { if (this._localRedactionEvent) { return {}; } else if (this._replacingEvent) { @@ -271,7 +346,7 @@ utils.extend(MatrixEvent.prototype, { } else { return this.getOriginalContent(); } - }, + } /** * Get the (possibly encrypted) event content JSON that will be sent to the @@ -279,19 +354,19 @@ utils.extend(MatrixEvent.prototype, { * * @return {Object} The event content JSON, or an empty object. */ - getWireContent: function() { + public getWireContent(): IContent { return this.event.content || {}; - }, + } /** * Get the previous event content JSON. This will only return something for * state events which exist in the timeline. * @return {Object} The previous event content JSON, or an empty object. */ - getPrevContent: function() { + public getPrevContent(): IContent { // v2 then v1 then default return this.getUnsigned().prev_content || this.event.prev_content || {}; - }, + } /** * Get either 'content' or 'prev_content' depending on if this event is @@ -302,9 +377,9 @@ utils.extend(MatrixEvent.prototype, { * @return {Object} event.content if this event is forward-looking, else * event.prev_content. */ - getDirectionalContent: function() { + public getDirectionalContent(): IContent { return this.forwardLooking ? this.getContent() : this.getPrevContent(); - }, + } /** * Get the age of this event. This represents the age of the event when the @@ -312,9 +387,9 @@ utils.extend(MatrixEvent.prototype, { * function was called. * @return {Number} The age of this event in milliseconds. */ - getAge: function() { + public getAge(): number { return this.getUnsigned().age || this.event.age; // v2 / v1 - }, + } /** * Get the age of the event when this function was called. @@ -322,26 +397,26 @@ utils.extend(MatrixEvent.prototype, { * had the event. * @return {Number} The age of this event in milliseconds. */ - getLocalAge: function() { - return Date.now() - this._localTimestamp; - }, + public getLocalAge(): number { + return Date.now() - this.localTimestamp; + } /** * Get the event state_key if it has one. This will return undefined * for message events. * @return {string} The event's state_key. */ - getStateKey: function() { + public getStateKey(): string | undefined { return this.event.state_key; - }, + } /** * Check if this event is a state event. * @return {boolean} True if this is a state event. */ - isState: function() { + public isState(): boolean { return this.event.state_key !== undefined; - }, + } /** * Replace the content of this event with encrypted versions. @@ -349,10 +424,10 @@ utils.extend(MatrixEvent.prototype, { * * @internal * - * @param {string} crypto_type type of the encrypted event - typically + * @param {string} cryptoType type of the encrypted event - typically * "m.room.encrypted" * - * @param {object} crypto_content raw 'content' for the encrypted event. + * @param {object} cryptoContent raw 'content' for the encrypted event. * * @param {string} senderCurve25519Key curve25519 key to record for the * sender of this event. @@ -362,28 +437,35 @@ utils.extend(MatrixEvent.prototype, { * sender if this event. * See {@link module:models/event.MatrixEvent#getClaimedEd25519Key} */ - makeEncrypted: function( - crypto_type, crypto_content, senderCurve25519Key, claimedEd25519Key, - ) { + public makeEncrypted( + cryptoType: string, + cryptoContent: object, + senderCurve25519Key: string, + claimedEd25519Key: string, + ): void { // keep the plain-text data for 'view source' - this._clearEvent = { + this.clearEvent = { type: this.event.type, content: this.event.content, }; - this.event.type = crypto_type; - this.event.content = crypto_content; - this._senderCurve25519Key = senderCurve25519Key; - this._claimedEd25519Key = claimedEd25519Key; - }, + this.event.type = cryptoType; + this.event.content = cryptoContent; + this.senderCurve25519Key = senderCurve25519Key; + this.claimedEd25519Key = claimedEd25519Key; + } /** * Check if this event is currently being decrypted. * * @return {boolean} True if this event is currently being decrypted, else false. */ - isBeingDecrypted: function() { + public isBeingDecrypted(): boolean { return this._decryptionPromise != null; - }, + } + + public getDecryptionPromise(): Promise { + return this._decryptionPromise; + } /** * Check if this event is an encrypted event which we failed to decrypt @@ -393,16 +475,13 @@ utils.extend(MatrixEvent.prototype, { * @return {boolean} True if this event is an encrypted event which we * couldn't decrypt. */ - isDecryptionFailure: function() { - return this._clearEvent && this._clearEvent.content && - this._clearEvent.content.msgtype === "m.bad.encrypted"; - }, + public isDecryptionFailure(): boolean { + return this.clearEvent?.content?.msgtype === "m.bad.encrypted"; + } - shouldAttemptDecryption: function() { - return this.isEncrypted() - && !this.isBeingDecrypted() - && this.getClearContent() === null; - }, + public shouldAttemptDecryption() { + return this.isEncrypted() && !this.isBeingDecrypted() && this.getClearContent() === null; + } /** * Start the process of trying to decrypt this event. @@ -419,7 +498,7 @@ utils.extend(MatrixEvent.prototype, { * @returns {Promise} promise which resolves (to undefined) when the decryption * attempt is completed. */ - attemptDecryption: async function(crypto, options = {}) { + public async attemptDecryption(crypto: Crypto, options: IDecryptOptions = {}): Promise { // For backwards compatibility purposes // The function signature used to be attemptDecryption(crypto, isRetry) if (typeof options === "boolean") { @@ -434,8 +513,8 @@ utils.extend(MatrixEvent.prototype, { } if ( - this._clearEvent && this._clearEvent.content && - this._clearEvent.content.msgtype !== "m.bad.encrypted" + this.clearEvent && this.clearEvent.content && + this.clearEvent.content.msgtype !== "m.bad.encrypted" ) { // we may want to just ignore this? let's start with rejecting it. throw new Error( @@ -453,13 +532,13 @@ utils.extend(MatrixEvent.prototype, { logger.log( `Event ${this.getId()} already being decrypted; queueing a retry`, ); - this._retryDecryption = true; + this.retryDecryption = true; return this._decryptionPromise; } - this._decryptionPromise = this._decryptionLoop(crypto, options); + this._decryptionPromise = this.decryptionLoop(crypto, options); return this._decryptionPromise; - }, + } /** * Cancel any room key request for this event and resend another. @@ -469,7 +548,7 @@ utils.extend(MatrixEvent.prototype, { * * @returns {Promise} a promise that resolves when the request is queued */ - cancelAndResendKeyRequest: function(crypto, userId) { + public cancelAndResendKeyRequest(crypto: Crypto, userId: string): Promise { const wireContent = this.getWireContent(); return crypto.requestRoomKey({ algorithm: wireContent.algorithm, @@ -477,7 +556,7 @@ utils.extend(MatrixEvent.prototype, { session_id: wireContent.session_id, sender_key: wireContent.sender_key, }, this.getKeyRequestRecipients(userId), true); - }, + } /** * Calculate the recipients for keyshare requests. @@ -486,7 +565,7 @@ utils.extend(MatrixEvent.prototype, { * * @returns {Array} array of recipients */ - getKeyRequestRecipients: function(userId) { + public getKeyRequestRecipients(userId: string): IKeyRequestRecipient[] { // send the request to all of our own devices, and the // original sending device if it wasn't us. const wireContent = this.getWireContent(); @@ -500,23 +579,24 @@ utils.extend(MatrixEvent.prototype, { }); } return recipients; - }, + } - _decryptionLoop: async function(crypto, options = {}) { + private async decryptionLoop(crypto: Crypto, options: IDecryptOptions = {}): Promise { // make sure that this method never runs completely synchronously. // (doing so would mean that we would clear _decryptionPromise *before* // it is set in attemptDecryption - and hence end up with a stuck // `_decryptionPromise`). await Promise.resolve(); + // eslint-disable-next-line no-constant-condition while (true) { - this._retryDecryption = false; + this.retryDecryption = false; let res; let err; try { if (!crypto) { - res = this._badEncryptedMessage("Encryption not enabled"); + res = this.badEncryptedMessage("Encryption not enabled"); } else { res = await crypto.decryptEvent(this); if (options.isRetry === true) { @@ -533,7 +613,7 @@ utils.extend(MatrixEvent.prototype, { `(id=${this.getId()}): ${e.stack || e}`, ); this._decryptionPromise = null; - this._retryDecryption = false; + this.retryDecryption = false; return; } @@ -545,15 +625,15 @@ utils.extend(MatrixEvent.prototype, { // event loop as `_decryptionPromise = null` below - otherwise we // risk a race: // - // * A: we check _retryDecryption here and see that it is + // * A: we check retryDecryption here and see that it is // false // * B: we get a second call to attemptDecryption, which sees // that _decryptionPromise is set so sets - // _retryDecryption + // retryDecryption // * A: we continue below, clear _decryptionPromise, and // never do the retry. // - if (this._retryDecryption) { + if (this.retryDecryption) { // decryption error, but we have a retry queued. logger.log( `Got error decrypting event (id=${this.getId()}: ` + @@ -568,7 +648,7 @@ utils.extend(MatrixEvent.prototype, { `Error decrypting event (id=${this.getId()}): ${e.detailedString}`, ); - res = this._badEncryptedMessage(e.message); + res = this.badEncryptedMessage(e.message); } // at this point, we've either successfully decrypted the event, or have given up @@ -579,11 +659,11 @@ utils.extend(MatrixEvent.prototype, { // otherwise the app will be confused to see `isBeingDecrypted` still set when // there isn't an `Event.decrypted` on the way. // - // see also notes on _retryDecryption above. + // see also notes on retryDecryption above. // this._decryptionPromise = null; - this._retryDecryption = false; - this._setClearData(res); + this.retryDecryption = false; + this.setClearData(res); // Before we emit the event, clear the push actions so that they can be recalculated // by relevant code. We do this because the clear event has now changed, making it @@ -599,9 +679,9 @@ utils.extend(MatrixEvent.prototype, { return; } - }, + } - _badEncryptedMessage: function(reason) { + private badEncryptedMessage(reason: string): IDecryptionResult { return { clearEvent: { type: "m.room.message", @@ -611,7 +691,7 @@ utils.extend(MatrixEvent.prototype, { }, }, }; - }, + } /** * Update the cleartext data on this event. @@ -625,16 +705,16 @@ utils.extend(MatrixEvent.prototype, { * @param {module:crypto~EventDecryptionResult} decryptionResult * the decryption result, including the plaintext and some key info */ - _setClearData: function(decryptionResult) { - this._clearEvent = decryptionResult.clearEvent; - this._senderCurve25519Key = + private setClearData(decryptionResult: IDecryptionResult): void { + this.clearEvent = decryptionResult.clearEvent; + this.senderCurve25519Key = decryptionResult.senderCurve25519Key || null; - this._claimedEd25519Key = + this.claimedEd25519Key = decryptionResult.claimedEd25519Key || null; - this._forwardingCurve25519KeyChain = + this.forwardingCurve25519KeyChain = decryptionResult.forwardingCurve25519KeyChain || []; - this._untrusted = decryptionResult.untrusted || false; - }, + this.untrusted = decryptionResult.untrusted || false; + } /** * Gets the cleartext content for this event. If the event is not encrypted, @@ -642,18 +722,18 @@ utils.extend(MatrixEvent.prototype, { * * @returns {Object} The cleartext (decrypted) content for the event */ - getClearContent: function() { - const ev = this._clearEvent; + public getClearContent(): IContent | null { + const ev = this.clearEvent; return ev && ev.content ? ev.content : null; - }, + } /** * Check if the event is encrypted. * @return {boolean} True if this event is encrypted. */ - isEncrypted: function() { + public isEncrypted(): boolean { return !this.isState() && this.event.type === "m.room.encrypted"; - }, + } /** * The curve25519 key for the device that we think sent this event @@ -668,9 +748,9 @@ utils.extend(MatrixEvent.prototype, { * * @return {string} */ - getSenderKey: function() { - return this._senderCurve25519Key; - }, + public getSenderKey(): string | null { + return this.senderCurve25519Key; + } /** * The additional keys the sender of this encrypted event claims to possess. @@ -679,11 +759,11 @@ utils.extend(MatrixEvent.prototype, { * * @return {Object} */ - getKeysClaimed: function() { + public getKeysClaimed(): Record<"ed25519", string> { return { - ed25519: this._claimedEd25519Key, + ed25519: this.claimedEd25519Key, }; - }, + } /** * Get the ed25519 the sender of this event claims to own. @@ -702,9 +782,9 @@ utils.extend(MatrixEvent.prototype, { * * @return {string} */ - getClaimedEd25519Key: function() { - return this._claimedEd25519Key; - }, + public getClaimedEd25519Key(): string | null { + return this.claimedEd25519Key; + } /** * Get the curve25519 keys of the devices which were involved in telling us @@ -720,9 +800,9 @@ utils.extend(MatrixEvent.prototype, { * * @return {string[]} base64-encoded curve25519 keys, from oldest to newest. */ - getForwardingCurve25519KeyChain: function() { - return this._forwardingCurve25519KeyChain; - }, + public getForwardingCurve25519KeyChain(): string[] { + return this.forwardingCurve25519KeyChain; + } /** * Whether the decryption key was obtained from an untrusted source. If so, @@ -730,51 +810,49 @@ utils.extend(MatrixEvent.prototype, { * * @return {boolean} */ - isKeySourceUntrusted: function() { - return this._untrusted; - }, + public isKeySourceUntrusted(): boolean { + return this.untrusted; + } - getUnsigned: function() { + public getUnsigned(): IUnsigned { return this.event.unsigned || {}; - }, + } - unmarkLocallyRedacted: function() { + public unmarkLocallyRedacted(): boolean { const value = this._localRedactionEvent; this._localRedactionEvent = null; if (this.event.unsigned) { this.event.unsigned.redacted_because = null; } return !!value; - }, + } - markLocallyRedacted: function(redactionEvent) { - if (this._localRedactionEvent) { - return; - } + public markLocallyRedacted(redactionEvent: MatrixEvent): void { + if (this._localRedactionEvent) return; this.emit("Event.beforeRedaction", this, redactionEvent); this._localRedactionEvent = redactionEvent; if (!this.event.unsigned) { this.event.unsigned = {}; } - this.event.unsigned.redacted_because = redactionEvent.event; - }, + this.event.unsigned.redacted_because = redactionEvent.event as IEvent; + } /** * Update the content of an event in the same way it would be by the server * if it were redacted before it was sent to us * - * @param {module:models/event.MatrixEvent} redaction_event + * @param {module:models/event.MatrixEvent} redactionEvent * event causing the redaction */ - makeRedacted: function(redaction_event) { + public makeRedacted(redactionEvent: MatrixEvent): void { // quick sanity-check - if (!redaction_event.event) { - throw new Error("invalid redaction_event in makeRedacted"); + if (!redactionEvent.event) { + throw new Error("invalid redactionEvent in makeRedacted"); } this._localRedactionEvent = null; - this.emit("Event.beforeRedaction", this, redaction_event); + this.emit("Event.beforeRedaction", this, redactionEvent); this._replacingEvent = null; // we attempt to replicate what we would see from the server if @@ -786,19 +864,19 @@ utils.extend(MatrixEvent.prototype, { if (!this.event.unsigned) { this.event.unsigned = {}; } - this.event.unsigned.redacted_because = redaction_event.event; + this.event.unsigned.redacted_because = redactionEvent.event as IEvent; let key; for (key in this.event) { if (!this.event.hasOwnProperty(key)) { continue; } - if (!_REDACT_KEEP_KEY_MAP[key]) { + if (!REDACT_KEEP_KEYS.has(key)) { delete this.event[key]; } } - const keeps = _REDACT_KEEP_CONTENT_MAP[this.getType()] || {}; + const keeps = REDACT_KEEP_CONTENT_MAP[this.getType()] || {}; const content = this.getContent(); for (key in content) { if (!content.hasOwnProperty(key)) { @@ -808,25 +886,25 @@ utils.extend(MatrixEvent.prototype, { delete content[key]; } } - }, + } /** * Check if this event has been redacted * * @return {boolean} True if this event has been redacted */ - isRedacted: function() { + public isRedacted(): boolean { return Boolean(this.getUnsigned().redacted_because); - }, + } /** * Check if this event is a redaction of another event * * @return {boolean} True if this event is a redaction */ - isRedaction: function() { + public isRedaction(): boolean { return this.getType() === "m.room.redaction"; - }, + } /** * Get the (decrypted, if necessary) redaction event JSON @@ -834,41 +912,41 @@ utils.extend(MatrixEvent.prototype, { * * @returns {object} The redaction event JSON, or an empty object */ - getRedactionEvent: function() { + public getRedactionEvent(): object | null { if (!this.isRedacted()) return null; - if (this._clearEvent.unsigned) { - return this._clearEvent.unsigned.redacted_because; + if (this.clearEvent.unsigned) { + return this.clearEvent.unsigned.redacted_because; } else if (this.event.unsigned.redacted_because) { return this.event.unsigned.redacted_because; } else { return {}; } - }, + } /** * Get the push actions, if known, for this event * * @return {?Object} push actions */ - getPushActions: function() { - return this._pushActions; - }, + public getPushActions(): object | null { + return this.pushActions; + } /** * Set the push actions for this event. * * @param {Object} pushActions push actions */ - setPushActions: function(pushActions) { - this._pushActions = pushActions; - }, + public setPushActions(pushActions: object): void { + this.pushActions = pushActions; + } /** * Replace the `event` property and recalculate any properties based on it. * @param {Object} event the object to assign to the `event` property */ - handleRemoteEcho: function(event) { + public handleRemoteEcho(event: object): void { const oldUnsigned = this.getUnsigned(); const oldId = this.getId(); this.event = event; @@ -889,7 +967,7 @@ utils.extend(MatrixEvent.prototype, { // emit the event if it changed this.emit("Event.localEventIdReplaced", this); } - }, + } /** * Whether the event is in any phase of sending, send failure, waiting for @@ -897,24 +975,24 @@ utils.extend(MatrixEvent.prototype, { * * @return {boolean} */ - isSending() { + public isSending(): boolean { return !!this.status; - }, + } /** * Update the event's sending status and emit an event as well. * * @param {String} status The new status */ - setStatus(status) { + public setStatus(status: EventStatus): void { this.status = status; this.emit("Event.status", this, status); - }, + } - replaceLocalEventId(eventId) { + public replaceLocalEventId(eventId: string): void { this.event.event_id = eventId; this.emit("Event.localEventIdReplaced", this); - }, + } /** * Get whether the event is a relation event, and of a given type if @@ -924,26 +1002,26 @@ utils.extend(MatrixEvent.prototype, { * given type * @return {boolean} */ - isRelation(relType = undefined) { + public isRelation(relType: string = undefined): boolean { // Relation info is lifted out of the encrypted content when sent to // encrypted rooms, so we have to check `getWireContent` for this. const content = this.getWireContent(); const relation = content && content["m.relates_to"]; return relation && relation.rel_type && relation.event_id && ((relType && relation.rel_type === relType) || !relType); - }, + } /** * Get relation info for the event, if any. * * @return {Object} */ - getRelation() { + public getRelation(): IEventRelation | null { if (!this.isRelation()) { return null; } return this.getWireContent()["m.relates_to"]; - }, + } /** * Set an event that replaces the content of this event, through an m.replace relation. @@ -952,7 +1030,7 @@ utils.extend(MatrixEvent.prototype, { * * @param {MatrixEvent?} newEvent the event with the replacing content, if any. */ - makeReplaced(newEvent) { + public makeReplaced(newEvent?: MatrixEvent): void { // don't allow redacted events to be replaced. // if newEvent is null we allow to go through though, // as with local redaction, the replacing event might get @@ -964,44 +1042,44 @@ utils.extend(MatrixEvent.prototype, { this._replacingEvent = newEvent; this.emit("Event.replaced", this); } - }, + } /** * Returns the status of any associated edit or redaction - * (not for reactions/annotations as their local echo doesn't affect the orignal event), + * (not for reactions/annotations as their local echo doesn't affect the original event), * or else the status of the event. * * @return {EventStatus} */ - getAssociatedStatus() { + public getAssociatedStatus(): EventStatus | undefined { if (this._replacingEvent) { return this._replacingEvent.status; } else if (this._localRedactionEvent) { return this._localRedactionEvent.status; } return this.status; - }, + } - getServerAggregatedRelation(relType) { + public getServerAggregatedRelation(relType: RelationType): IAggregatedRelation { const relations = this.getUnsigned()["m.relations"]; if (relations) { return relations[relType]; } - }, + } /** * Returns the event ID of the event replacing the content of this event, if any. * * @return {string?} */ - replacingEventId() { - const replaceRelation = this.getServerAggregatedRelation("m.replace"); + public replacingEventId(): string | undefined { + const replaceRelation = this.getServerAggregatedRelation(RelationType.Replace); if (replaceRelation) { return replaceRelation.event_id; } else if (this._replacingEvent) { return this._replacingEvent.getId(); } - }, + } /** * Returns the event replacing the content of this event, if any. @@ -1010,17 +1088,17 @@ utils.extend(MatrixEvent.prototype, { * * @return {MatrixEvent?} */ - replacingEvent() { + public replacingEvent(): MatrixEvent | undefined { return this._replacingEvent; - }, + } /** * Returns the origin_server_ts of the event replacing the content of this event, if any. * * @return {Date?} */ - replacingEventDate() { - const replaceRelation = this.getServerAggregatedRelation("m.replace"); + public replacingEventDate(): Date | undefined { + const replaceRelation = this.getServerAggregatedRelation(RelationType.Replace); if (replaceRelation) { const ts = replaceRelation.origin_server_ts; if (Number.isFinite(ts)) { @@ -1029,38 +1107,38 @@ utils.extend(MatrixEvent.prototype, { } else if (this._replacingEvent) { return this._replacingEvent.getDate(); } - }, + } /** * Returns the event that wants to redact this event, but hasn't been sent yet. * @return {MatrixEvent} the event */ - localRedactionEvent() { + public localRedactionEvent(): MatrixEvent | undefined { return this._localRedactionEvent; - }, + } /** * For relations and redactions, returns the event_id this event is referring to. * * @return {string?} */ - getAssociatedId() { + public getAssociatedId(): string | undefined { const relation = this.getRelation(); if (relation) { return relation.event_id; } else if (this.isRedaction()) { return this.event.redacts; } - }, + } /** * Checks if this event is associated with another event. See `getAssociatedId`. * - * @return {bool} + * @return {boolean} */ - hasAssocation() { + public hasAssocation(): boolean { return !!this.getAssociatedId(); - }, + } /** * Update the related id with a new one. @@ -1070,14 +1148,14 @@ utils.extend(MatrixEvent.prototype, { * * @param {string} eventId the new event id */ - updateAssociatedId(eventId) { + public updateAssociatedId(eventId: string): void { const relation = this.getRelation(); if (relation) { relation.event_id = eventId; } else if (this.isRedaction()) { this.event.redacts = eventId; } - }, + } /** * Flags an event as cancelled due to future conditions. For example, a verification @@ -1085,18 +1163,18 @@ utils.extend(MatrixEvent.prototype, { * listeners that a cancellation event is coming down the same pipe shortly. * @param {boolean} cancelled Whether the event is to be cancelled or not. */ - flagCancelled(cancelled = true) { + public flagCancelled(cancelled = true): void { this._isCancelled = cancelled; - }, + } /** * Gets whether or not the event is flagged as cancelled. See flagCancelled() for * more information. * @returns {boolean} True if the event is cancelled, false otherwise. */ - isCancelled() { + isCancelled(): boolean { return this._isCancelled; - }, + } /** * Summarise the event as JSON for debugging. If encrypted, include both the @@ -1106,8 +1184,8 @@ utils.extend(MatrixEvent.prototype, { * * @return {Object} */ - toJSON() { - const event = { + public toJSON(): object { + const event: any = { type: this.getType(), sender: this.getSender(), content: this.getContent(), @@ -1130,22 +1208,22 @@ utils.extend(MatrixEvent.prototype, { decrypted: event, encrypted: this.event, }; - }, + } - setVerificationRequest: function(request) { + public setVerificationRequest(request: VerificationRequest): void { this.verificationRequest = request; - }, + } - setTxnId(txnId) { - this._txnId = txnId; - }, + public setTxnId(txnId: string): void { + this.txnId = txnId; + } - getTxnId() { - return this._txnId; - }, -}); + public getTxnId(): string | undefined { + return this.txnId; + } +} -/* _REDACT_KEEP_KEY_MAP gives the keys we keep when an event is redacted +/* REDACT_KEEP_KEYS gives the keys we keep when an event is redacted * * This is specified here: * http://matrix.org/speculator/spec/HEAD/client_server/latest.html#redactions @@ -1154,22 +1232,21 @@ utils.extend(MatrixEvent.prototype, { * - We keep 'unsigned' since that is created by the local server * - We keep user_id for backwards-compat with v1 */ -const _REDACT_KEEP_KEY_MAP = [ +const REDACT_KEEP_KEYS = new Set([ 'event_id', 'type', 'room_id', 'user_id', 'sender', 'state_key', 'prev_state', 'content', 'unsigned', 'origin_server_ts', -].reduce(function(ret, val) { - ret[val] = 1; return ret; -}, {}); +]); // a map from event type to the .content keys we keep when an event is redacted -const _REDACT_KEEP_CONTENT_MAP = { +const REDACT_KEEP_CONTENT_MAP = { 'm.room.member': { 'membership': 1 }, 'm.room.create': { 'creator': 1 }, 'm.room.join_rules': { 'join_rule': 1 }, - 'm.room.power_levels': { 'ban': 1, 'events': 1, 'events_default': 1, - 'kick': 1, 'redact': 1, 'state_default': 1, - 'users': 1, 'users_default': 1, - }, + 'm.room.power_levels': { + 'ban': 1, 'events': 1, 'events_default': 1, + 'kick': 1, 'redact': 1, 'state_default': 1, + 'users': 1, 'users_default': 1, + }, 'm.room.aliases': { 'aliases': 1 }, }; From 50a973409a13ff1c9112cd1f5dab636a4be7ab0b Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 17 Jun 2021 14:06:03 +0100 Subject: [PATCH 15/20] Typescript fixes due to MatrixEvent being TSified --- src/client.ts | 8 ++++---- src/models/relations.ts | 5 ++--- src/store/memory.ts | 4 ++-- src/store/stub.ts | 4 +++- src/webrtc/call.ts | 2 +- src/webrtc/callEventHandler.ts | 4 ++-- 6 files changed, 14 insertions(+), 13 deletions(-) diff --git a/src/client.ts b/src/client.ts index 95e7fbd7f..853d2c7ae 100644 --- a/src/client.ts +++ b/src/client.ts @@ -21,7 +21,7 @@ limitations under the License. import { EventEmitter } from "events"; import { SyncApi } from "./sync"; -import { EventStatus, MatrixEvent } from "./models/event"; +import { EventStatus, IDecryptOptions, MatrixEvent } from "./models/event"; import { StubStore } from "./store/stub"; import { createNewMatrixCall, MatrixCall } from "./webrtc/call"; import { Filter } from "./filter"; @@ -3042,7 +3042,7 @@ export class MatrixClient extends EventEmitter { if (event && event.getType() === "m.room.power_levels") { // take a copy of the content to ensure we don't corrupt // existing client state with a failed power level change - content = utils.deepCopy(event.getContent()); + content = utils.deepCopy(event.getContent()) as typeof content; } content.users[userId] = powerLevel; const path = utils.encodeUri("/rooms/$roomId/state/m.room.power_levels", { @@ -5707,13 +5707,13 @@ export class MatrixClient extends EventEmitter { * @param {boolean} options.isRetry True if this is a retry (enables more logging) * @param {boolean} options.emit Emits "event.decrypted" if set to true */ - public decryptEventIfNeeded(event: MatrixEvent, options?: { emit: boolean, isRetry: boolean }): Promise { + public decryptEventIfNeeded(event: MatrixEvent, options?: IDecryptOptions): Promise { if (event.shouldAttemptDecryption()) { event.attemptDecryption(this.crypto, options); } if (event.isBeingDecrypted()) { - return event._decryptionPromise; + return event.getDecryptionPromise(); } else { return Promise.resolve(); } diff --git a/src/models/relations.ts b/src/models/relations.ts index 5d70cffee..adefc71fe 100644 --- a/src/models/relations.ts +++ b/src/models/relations.ts @@ -319,8 +319,7 @@ export class Relations extends EventEmitter { // the all-knowning server tells us that the event at some point had // this timestamp for its replacement, so any following replacement should definitely not be less - const replaceRelation = - this.targetEvent.getServerAggregatedRelation(RelationType.Replace); + const replaceRelation = this.targetEvent.getServerAggregatedRelation(RelationType.Replace); const minTs = replaceRelation && replaceRelation.origin_server_ts; const lastReplacement = this.getRelations().reduce((last, event) => { @@ -339,7 +338,7 @@ export class Relations extends EventEmitter { if (lastReplacement?.shouldAttemptDecryption()) { await lastReplacement.attemptDecryption(this.room._client.crypto); } else if (lastReplacement?.isBeingDecrypted()) { - await lastReplacement._decryptionPromise; + await lastReplacement.getDecryptionPromise(); } return lastReplacement; diff --git a/src/store/memory.ts b/src/store/memory.ts index eda2adf3f..f682e10c1 100644 --- a/src/store/memory.ts +++ b/src/store/memory.ts @@ -59,7 +59,7 @@ export class MemoryStore implements IStore { // filterId: Filter // } private filters: Record> = {}; - private accountData: Record = {}; // type : content + private accountData: Record = {}; // type : content private readonly localStorage: Storage; private oobMembers: Record = {}; // roomId: [member events] private clientOptions = {}; @@ -330,7 +330,7 @@ export class MemoryStore implements IStore { * @param {string} eventType The event type being queried * @return {?MatrixEvent} the user account_data event of given type, if any */ - public getAccountData(eventType: EventType | string): MatrixEvent | null { + public getAccountData(eventType: EventType | string): MatrixEvent | undefined { return this.accountData[eventType]; } diff --git a/src/store/stub.ts b/src/store/stub.ts index a1775b3f9..c8dd293da 100644 --- a/src/store/stub.ts +++ b/src/store/stub.ts @@ -202,7 +202,9 @@ export class StubStore implements IStore { * Get account data event by event type * @param {string} eventType The event type being queried */ - public getAccountData(eventType: EventType | string): MatrixEvent {} + public getAccountData(eventType: EventType | string): MatrixEvent | undefined { + return undefined; + } /** * setSyncData does nothing as there is no backing data store. diff --git a/src/webrtc/call.ts b/src/webrtc/call.ts index 009bce309..18a9542a3 100644 --- a/src/webrtc/call.ts +++ b/src/webrtc/call.ts @@ -24,7 +24,7 @@ limitations under the License. import { logger } from '../logger'; import { EventEmitter } from 'events'; import * as utils from '../utils'; -import MatrixEvent from '../models/event'; +import { MatrixEvent } from '../models/event'; import { EventType } from '../@types/event'; import { RoomMember } from '../models/room-member'; import { randomString } from '../randomstring'; diff --git a/src/webrtc/callEventHandler.ts b/src/webrtc/callEventHandler.ts index 5394f1cbd..9d62375e9 100644 --- a/src/webrtc/callEventHandler.ts +++ b/src/webrtc/callEventHandler.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import MatrixEvent from '../models/event'; +import { MatrixEvent } from '../models/event'; import { logger } from '../logger'; import { createNewMatrixCall, MatrixCall, CallErrorCode, CallState, CallDirection } from './call'; import { EventType } from '../@types/event'; @@ -244,7 +244,7 @@ export class CallEventHandler { } else { call.onRemoteIceCandidatesReceived(event); } - } else if ([EventType.CallHangup, EventType.CallReject].includes(event.getType())) { + } else if ([EventType.CallHangup, EventType.CallReject].includes(event.getType() as EventType)) { // Note that we also observe our own hangups here so we can see // if we've already rejected a call that would otherwise be valid if (!call) { From 7c61b9cf7e57ed47af47ca43ead624927474ecc2 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 17 Jun 2021 14:24:53 +0100 Subject: [PATCH 16/20] Fix more type definitions --- src/client.ts | 4 ++-- src/models/event.ts | 2 +- src/models/user.ts | 3 ++- src/sync.js | 4 ++-- 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/client.ts b/src/client.ts index 853d2c7ae..f34044081 100644 --- a/src/client.ts +++ b/src/client.ts @@ -2730,7 +2730,7 @@ export class MatrixClient extends EventEmitter { * @return {Promise} Resolves: TODO * @return {module:http-api.MatrixError} Rejects: with an error response. */ - public setAccountData(eventType: string, content: any, callback?: Callback): Promise { + public setAccountData(eventType: EventType | string, content: any, callback?: Callback): Promise { const path = utils.encodeUri("/user/$userId/account_data/$type", { $userId: this.credentials.userId, $type: eventType, @@ -2749,7 +2749,7 @@ export class MatrixClient extends EventEmitter { * @param {string} eventType The event type * @return {?object} The contents of the given account data event */ - public getAccountData(eventType: string): any { + public getAccountData(eventType: string): MatrixEvent { return this.store.getAccountData(eventType); } diff --git a/src/models/event.ts b/src/models/event.ts index 17c6be3ba..9ad43fd97 100644 --- a/src/models/event.ts +++ b/src/models/event.ts @@ -77,7 +77,7 @@ interface IUnsigned { redacted_because?: IEvent; } -interface IEvent { +export interface IEvent { event_id: string; type: string; content: IContent; diff --git a/src/models/user.ts b/src/models/user.ts index da5c4d78d..5eff5062c 100644 --- a/src/models/user.ts +++ b/src/models/user.ts @@ -42,6 +42,7 @@ export class User extends EventEmitter { presence: null, profile: null, }; + // eslint-disable-next-line camelcase public unstable_statusMessage = ""; /** @@ -208,7 +209,7 @@ export class User extends EventEmitter { * @fires module:client~MatrixClient#event:"User.unstable_statusMessage" */ // eslint-disable-next-line camelcase - public _unstable_updateStatusMessage(event: MatrixEvent): void { + public unstable_updateStatusMessage(event: MatrixEvent): void { if (!event.getContent()) this.unstable_statusMessage = ""; else this.unstable_statusMessage = event.getContent()["status"]; this.updateModifiedTime(); diff --git a/src/sync.js b/src/sync.js index 9929629c2..9d114943f 100644 --- a/src/sync.js +++ b/src/sync.js @@ -1272,10 +1272,10 @@ SyncApi.prototype._processSyncResponse = async function( if (e.isState() && e.getType() === "im.vector.user_status") { let user = client.store.getUser(e.getStateKey()); if (user) { - user._unstable_updateStatusMessage(e); + user.unstable_updateStatusMessage(e); } else { user = createNewUser(client, e.getStateKey()); - user._unstable_updateStatusMessage(e); + user.unstable_updateStatusMessage(e); client.store.storeUser(user); } } From 608b0e7b936bb27f198d6b5bbba79b1eb25f8cf8 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 17 Jun 2021 14:49:27 +0100 Subject: [PATCH 17/20] Fix up some more type defs --- src/models/event.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/models/event.ts b/src/models/event.ts index 9ad43fd97..5438fa203 100644 --- a/src/models/event.ts +++ b/src/models/event.ts @@ -87,7 +87,7 @@ export interface IEvent { txn_id?: string; state_key?: string; membership?: string; - unsigned?: IUnsigned; + unsigned: IUnsigned; redacts?: string; // v1 legacy fields @@ -324,11 +324,11 @@ export class MatrixEvent extends EventEmitter { * * @return {Object} The event content JSON, or an empty object. */ - public getOriginalContent(): IContent { + public getOriginalContent(): T { if (this._localRedactionEvent) { - return {}; + return {} as T; } - return this.clearEvent.content || this.event.content || {}; + return (this.clearEvent.content || this.event.content || {}) as T; } /** @@ -338,9 +338,9 @@ export class MatrixEvent extends EventEmitter { * * @return {Object} The event content JSON, or an empty object. */ - public getContent(): IContent { + public getContent(): T { if (this._localRedactionEvent) { - return {}; + return {} as T; } else if (this._replacingEvent) { return this._replacingEvent.getContent()["m.new_content"] || {}; } else { From b1b7522b805707d7ec9329646e81604833659f6f Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 17 Jun 2021 15:18:52 +0100 Subject: [PATCH 18/20] Fix tests by updating private field names and spies --- spec/integ/megolm-integ.spec.js | 2 +- spec/unit/crypto.spec.js | 8 +++--- spec/unit/room-state.spec.js | 43 ++++++++++++--------------------- 3 files changed, 20 insertions(+), 33 deletions(-) diff --git a/spec/integ/megolm-integ.spec.js b/spec/integ/megolm-integ.spec.js index 513043410..d129590e1 100644 --- a/spec/integ/megolm-integ.spec.js +++ b/spec/integ/megolm-integ.spec.js @@ -1012,7 +1012,7 @@ describe("megolm", function() { }, event: true, }); - event._senderCurve25519Key = testSenderKey; + event.senderCurve25519Key = testSenderKey; return testClient.client.crypto._onRoomKeyEvent(event); }).then(() => { const event = testUtils.mkEvent({ diff --git a/spec/unit/crypto.spec.js b/spec/unit/crypto.spec.js index d1e707fd3..bda03089a 100644 --- a/spec/unit/crypto.spec.js +++ b/spec/unit/crypto.spec.js @@ -234,7 +234,7 @@ describe("Crypto", function() { }, }); // make onRoomKeyEvent think this was an encrypted event - ksEvent._senderCurve25519Key = "akey"; + ksEvent.senderCurve25519Key = "akey"; return ksEvent; } @@ -274,9 +274,9 @@ describe("Crypto", function() { // alice encrypts each event, and then bob tries to decrypt // them without any keys, so that they'll be in pending await aliceClient.crypto.encryptEvent(event, aliceRoom); - event._clearEvent = {}; - event._senderCurve25519Key = null; - event._claimedEd25519Key = null; + event.clearEvent = {}; + event.senderCurve25519Key = null; + event.claimedEd25519Key = null; try { await bobClient.crypto.decryptEvent(event); } catch (e) { diff --git a/spec/unit/room-state.spec.js b/spec/unit/room-state.spec.js index 182f38d12..31bf2e034 100644 --- a/spec/unit/room-state.spec.js +++ b/spec/unit/room-state.spec.js @@ -1,6 +1,5 @@ import * as utils from "../test-utils"; import { RoomState } from "../../src/models/room-state"; -import { RoomMember } from "../../src/models/room-member"; describe("RoomState", function() { const roomId = "!foo:bar"; @@ -193,12 +192,7 @@ describe("RoomState", function() { expect(emitCount).toEqual(2); }); - it("should call setPowerLevelEvent on each RoomMember for m.room.power_levels", - function() { - // mock up the room members - state.members[userA] = utils.mock(RoomMember); - state.members[userB] = utils.mock(RoomMember); - + it("should call setPowerLevelEvent on each RoomMember for m.room.power_levels", function() { const powerLevelEvent = utils.mkEvent({ type: "m.room.power_levels", room: roomId, user: userA, event: true, content: { @@ -208,18 +202,16 @@ describe("RoomState", function() { }, }); + // spy on the room members + jest.spyOn(state.members[userA], "setPowerLevelEvent"); + jest.spyOn(state.members[userB], "setPowerLevelEvent"); state.setStateEvents([powerLevelEvent]); - expect(state.members[userA].setPowerLevelEvent).toHaveBeenCalledWith( - powerLevelEvent, - ); - expect(state.members[userB].setPowerLevelEvent).toHaveBeenCalledWith( - powerLevelEvent, - ); + expect(state.members[userA].setPowerLevelEvent).toHaveBeenCalledWith(powerLevelEvent); + expect(state.members[userB].setPowerLevelEvent).toHaveBeenCalledWith(powerLevelEvent); }); - it("should call setPowerLevelEvent on a new RoomMember if power levels exist", - function() { + it("should call setPowerLevelEvent on a new RoomMember if power levels exist", function() { const memberEvent = utils.mkMembership({ mship: "join", user: userC, room: roomId, event: true, }); @@ -243,13 +235,12 @@ describe("RoomState", function() { }); it("should call setMembershipEvent on the right RoomMember", function() { - // mock up the room members - state.members[userA] = utils.mock(RoomMember); - state.members[userB] = utils.mock(RoomMember); - const memberEvent = utils.mkMembership({ user: userB, mship: "leave", room: roomId, event: true, }); + // spy on the room members + jest.spyOn(state.members[userA], "setMembershipEvent"); + jest.spyOn(state.members[userB], "setMembershipEvent"); state.setStateEvents([memberEvent]); expect(state.members[userA].setMembershipEvent).not.toHaveBeenCalled(); @@ -374,17 +365,13 @@ describe("RoomState", function() { user_ids: [userA], }, }); - // mock up the room members - state.members[userA] = utils.mock(RoomMember); - state.members[userB] = utils.mock(RoomMember); + // spy on the room members + jest.spyOn(state.members[userA], "setTypingEvent"); + jest.spyOn(state.members[userB], "setTypingEvent"); state.setTypingEvent(typingEvent); - expect(state.members[userA].setTypingEvent).toHaveBeenCalledWith( - typingEvent, - ); - expect(state.members[userB].setTypingEvent).toHaveBeenCalledWith( - typingEvent, - ); + expect(state.members[userA].setTypingEvent).toHaveBeenCalledWith(typingEvent); + expect(state.members[userB].setTypingEvent).toHaveBeenCalledWith(typingEvent); }); }); From a2449ff6a7f2efbb4968085f38ea1b35d25050b9 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 17 Jun 2021 15:23:40 +0100 Subject: [PATCH 19/20] Fix typos --- src/models/event.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/models/event.ts b/src/models/event.ts index 5438fa203..246390629 100644 --- a/src/models/event.ts +++ b/src/models/event.ts @@ -492,8 +492,8 @@ export class MatrixEvent extends EventEmitter { * * @param {module:crypto} crypto crypto module * @param {object} options - * @param {bool} options.isRetry True if this is a retry (enables more logging) - * @param {bool} options.emit Emits "event.decrypted" if set to true + * @param {boolean} options.isRetry True if this is a retry (enables more logging) + * @param {boolean} options.emit Emits "event.decrypted" if set to true * * @returns {Promise} promise which resolves (to undefined) when the decryption * attempt is completed. @@ -1258,6 +1258,6 @@ const REDACT_KEEP_CONTENT_MAP = { * @param {module:models/event.MatrixEvent} event * The matrix event which has been decrypted * @param {module:crypto/algorithms/base.DecryptionError?} err - * The error that occured during decryption, or `undefined` if no - * error occured. + * The error that occurred during decryption, or `undefined` if no + * error occurred. */ From 09b729beb550d0e1df16d4821811fce51586dfb4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 17 Jun 2021 17:07:56 +0000 Subject: [PATCH 20/20] Bump lodash from 4.17.20 to 4.17.21 Bumps [lodash](https://github.com/lodash/lodash) from 4.17.20 to 4.17.21. - [Release notes](https://github.com/lodash/lodash/releases) - [Commits](https://github.com/lodash/lodash/compare/4.17.20...4.17.21) --- updated-dependencies: - dependency-name: lodash dependency-type: indirect ... Signed-off-by: dependabot[bot] --- yarn.lock | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/yarn.lock b/yarn.lock index b582d75fb..43a37edbf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4705,12 +4705,7 @@ lodash.sortby@^4.7.0: resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438" integrity sha1-7dFMgk4sycHgsKG0K7UhBRakJDg= -lodash@^4.17.14, lodash@^4.17.19, lodash@^4.17.20: - version "4.17.20" - resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.20.tgz#b44a9b6297bcb698f1c51a3545a2b3b368d59c52" - integrity sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA== - -lodash@^4.17.15, lodash@^4.17.4: +lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.17.4: version "4.17.21" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==