1
0
mirror of https://github.com/matrix-org/matrix-js-sdk.git synced 2025-08-06 12:02:40 +03:00

Merge remote-tracking branch 'origin/develop' into travis/event-fixes

This commit is contained in:
Travis Ralston
2021-06-18 11:15:00 -06:00
34 changed files with 2621 additions and 2145 deletions

View File

@@ -55,6 +55,7 @@
"bs58": "^4.0.1", "bs58": "^4.0.1",
"content-type": "^1.0.4", "content-type": "^1.0.4",
"loglevel": "^1.7.1", "loglevel": "^1.7.1",
"p-retry": "^4.5.0",
"qs": "^6.9.6", "qs": "^6.9.6",
"request": "^2.88.2", "request": "^2.88.2",
"unhomoglyph": "^1.0.6" "unhomoglyph": "^1.0.6"

View File

@@ -1012,7 +1012,7 @@ describe("megolm", function() {
}, },
event: true, event: true,
}); });
event._senderCurve25519Key = testSenderKey; event.senderCurve25519Key = testSenderKey;
return testClient.client.crypto._onRoomKeyEvent(event); return testClient.client.crypto._onRoomKeyEvent(event);
}).then(() => { }).then(() => {
const event = testUtils.mkEvent({ const event = testUtils.mkEvent({

View File

@@ -234,7 +234,7 @@ describe("Crypto", function() {
}, },
}); });
// make onRoomKeyEvent think this was an encrypted event // make onRoomKeyEvent think this was an encrypted event
ksEvent._senderCurve25519Key = "akey"; ksEvent.senderCurve25519Key = "akey";
return ksEvent; return ksEvent;
} }
@@ -274,9 +274,9 @@ describe("Crypto", function() {
// alice encrypts each event, and then bob tries to decrypt // alice encrypts each event, and then bob tries to decrypt
// them without any keys, so that they'll be in pending // them without any keys, so that they'll be in pending
await aliceClient.crypto.encryptEvent(event, aliceRoom); await aliceClient.crypto.encryptEvent(event, aliceRoom);
event._clearEvent = {}; event.clearEvent = {};
event._senderCurve25519Key = null; event.senderCurve25519Key = null;
event._claimedEd25519Key = null; event.claimedEd25519Key = null;
try { try {
await bobClient.crypto.decryptEvent(event); await bobClient.crypto.decryptEvent(event);
} catch (e) { } catch (e) {

View File

@@ -25,6 +25,7 @@ import {
} from "../../../src/models/MSC3089TreeSpace"; } from "../../../src/models/MSC3089TreeSpace";
import { DEFAULT_ALPHABET } from "../../../src/utils"; import { DEFAULT_ALPHABET } from "../../../src/utils";
import { MockBlob } from "../../MockBlob"; import { MockBlob } from "../../MockBlob";
import { MatrixError } from "../../../src/http-api";
describe("MSC3089TreeSpace", () => { describe("MSC3089TreeSpace", () => {
let client: MatrixClient; let client: MatrixClient;
@@ -93,7 +94,7 @@ describe("MSC3089TreeSpace", () => {
}); });
client.sendStateEvent = fn; client.sendStateEvent = fn;
await tree.setName(newName); await tree.setName(newName);
expect(fn.mock.calls.length).toBe(1); expect(fn).toHaveBeenCalledTimes(1);
}); });
it('should support inviting users to the space', async () => { it('should support inviting users to the space', async () => {
@@ -104,8 +105,62 @@ describe("MSC3089TreeSpace", () => {
return Promise.resolve(); return Promise.resolve();
}); });
client.invite = fn; client.invite = fn;
await tree.invite(target); await tree.invite(target, false);
expect(fn.mock.calls.length).toBe(1); 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) { async function evaluatePowerLevels(pls: any, role: TreePermissions, expectedPl: number) {

View File

@@ -1,6 +1,5 @@
import * as utils from "../test-utils"; import * as utils from "../test-utils";
import { RoomState } from "../../src/models/room-state"; import { RoomState } from "../../src/models/room-state";
import { RoomMember } from "../../src/models/room-member";
describe("RoomState", function() { describe("RoomState", function() {
const roomId = "!foo:bar"; const roomId = "!foo:bar";
@@ -193,12 +192,7 @@ describe("RoomState", function() {
expect(emitCount).toEqual(2); expect(emitCount).toEqual(2);
}); });
it("should call setPowerLevelEvent on each RoomMember for m.room.power_levels", it("should call setPowerLevelEvent on each RoomMember for m.room.power_levels", function() {
function() {
// mock up the room members
state.members[userA] = utils.mock(RoomMember);
state.members[userB] = utils.mock(RoomMember);
const powerLevelEvent = utils.mkEvent({ const powerLevelEvent = utils.mkEvent({
type: "m.room.power_levels", room: roomId, user: userA, event: true, type: "m.room.power_levels", room: roomId, user: userA, event: true,
content: { 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]); state.setStateEvents([powerLevelEvent]);
expect(state.members[userA].setPowerLevelEvent).toHaveBeenCalledWith( expect(state.members[userA].setPowerLevelEvent).toHaveBeenCalledWith(powerLevelEvent);
powerLevelEvent, expect(state.members[userB].setPowerLevelEvent).toHaveBeenCalledWith(powerLevelEvent);
);
expect(state.members[userB].setPowerLevelEvent).toHaveBeenCalledWith(
powerLevelEvent,
);
}); });
it("should call setPowerLevelEvent on a new RoomMember if power levels exist", it("should call setPowerLevelEvent on a new RoomMember if power levels exist", function() {
function() {
const memberEvent = utils.mkMembership({ const memberEvent = utils.mkMembership({
mship: "join", user: userC, room: roomId, event: true, mship: "join", user: userC, room: roomId, event: true,
}); });
@@ -243,13 +235,12 @@ describe("RoomState", function() {
}); });
it("should call setMembershipEvent on the right RoomMember", 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({ const memberEvent = utils.mkMembership({
user: userB, mship: "leave", room: roomId, event: true, 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]); state.setStateEvents([memberEvent]);
expect(state.members[userA].setMembershipEvent).not.toHaveBeenCalled(); expect(state.members[userA].setMembershipEvent).not.toHaveBeenCalled();
@@ -374,17 +365,13 @@ describe("RoomState", function() {
user_ids: [userA], user_ids: [userA],
}, },
}); });
// mock up the room members // spy on the room members
state.members[userA] = utils.mock(RoomMember); jest.spyOn(state.members[userA], "setTypingEvent");
state.members[userB] = utils.mock(RoomMember); jest.spyOn(state.members[userB], "setTypingEvent");
state.setTypingEvent(typingEvent); state.setTypingEvent(typingEvent);
expect(state.members[userA].setTypingEvent).toHaveBeenCalledWith( expect(state.members[userA].setTypingEvent).toHaveBeenCalledWith(typingEvent);
typingEvent, expect(state.members[userB].setTypingEvent).toHaveBeenCalledWith(typingEvent);
);
expect(state.members[userB].setTypingEvent).toHaveBeenCalledWith(
typingEvent,
);
}); });
}); });

View File

@@ -8,6 +8,7 @@ import {
lexicographicCompare, lexicographicCompare,
nextString, nextString,
prevString, prevString,
simpleRetryOperation,
stringToBase, stringToBase,
} from "../../src/utils"; } from "../../src/utils";
import { logger } from "../../src/logger"; import { logger } from "../../src/logger";
@@ -268,6 +269,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', () => { describe('DEFAULT_ALPHABET', () => {
it('should be usefully printable ASCII in order', () => { it('should be usefully printable ASCII in order', () => {
expect(DEFAULT_ALPHABET).toEqual( expect(DEFAULT_ALPHABET).toEqual(

View File

@@ -87,6 +87,11 @@ export enum EventType {
Dummy = "m.dummy", Dummy = "m.dummy",
} }
export enum RelationType {
Annotation = "m.annotation",
Replace = "m.replace",
}
export enum MsgType { export enum MsgType {
Text = "m.text", Text = "m.text",
Emote = "m.emote", Emote = "m.emote",

View File

@@ -21,7 +21,7 @@ limitations under the License.
import { EventEmitter } from "events"; import { EventEmitter } from "events";
import { SyncApi } from "./sync"; import { SyncApi } from "./sync";
import { EventStatus, MatrixEvent } from "./models/event"; import { EventStatus, IDecryptOptions, MatrixEvent } from "./models/event";
import { StubStore } from "./store/stub"; import { StubStore } from "./store/stub";
import { createNewMatrixCall, MatrixCall } from "./webrtc/call"; import { createNewMatrixCall, MatrixCall } from "./webrtc/call";
import { Filter } from "./filter"; import { Filter } from "./filter";
@@ -2730,7 +2730,7 @@ export class MatrixClient extends EventEmitter {
* @return {Promise} Resolves: TODO * @return {Promise} Resolves: TODO
* @return {module:http-api.MatrixError} Rejects: with an error response. * @return {module:http-api.MatrixError} Rejects: with an error response.
*/ */
public setAccountData(eventType: string, content: any, callback?: Callback): Promise<void> { public setAccountData(eventType: EventType | string, content: any, callback?: Callback): Promise<void> {
const path = utils.encodeUri("/user/$userId/account_data/$type", { const path = utils.encodeUri("/user/$userId/account_data/$type", {
$userId: this.credentials.userId, $userId: this.credentials.userId,
$type: eventType, $type: eventType,
@@ -2749,7 +2749,7 @@ export class MatrixClient extends EventEmitter {
* @param {string} eventType The event type * @param {string} eventType The event type
* @return {?object} The contents of the given account data event * @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); return this.store.getAccountData(eventType);
} }
@@ -3042,7 +3042,7 @@ export class MatrixClient extends EventEmitter {
if (event && event.getType() === "m.room.power_levels") { if (event && event.getType() === "m.room.power_levels") {
// take a copy of the content to ensure we don't corrupt // take a copy of the content to ensure we don't corrupt
// existing client state with a failed power level change // 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; content.users[userId] = powerLevel;
const path = utils.encodeUri("/rooms/$roomId/state/m.room.power_levels", { 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.isRetry True if this is a retry (enables more logging)
* @param {boolean} options.emit Emits "event.decrypted" if set to true * @param {boolean} options.emit Emits "event.decrypted" if set to true
*/ */
public decryptEventIfNeeded(event: MatrixEvent, options?: { emit: boolean, isRetry: boolean }): Promise<void> { public decryptEventIfNeeded(event: MatrixEvent, options?: IDecryptOptions): Promise<void> {
if (event.shouldAttemptDecryption()) { if (event.shouldAttemptDecryption()) {
event.attemptDecryption(this.crypto, options); event.attemptDecryption(this.crypto, options);
} }
if (event.isBeingDecrypted()) { if (event.isBeingDecrypted()) {
return event._decryptionPromise; return event.getDecryptionPromise();
} else { } else {
return Promise.resolve(); return Promise.resolve();
} }

View File

@@ -1,6 +1,6 @@
/* /*
Copyright 2018 New Vector Ltd 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"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with 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 * @param {string} htmlBody the HTML representation of the message
* @returns {{msgtype: string, format: string, body: string, formatted_body: string}} * @returns {{msgtype: string, format: string, body: string, formatted_body: string}}
*/ */
export function makeHtmlMessage(body, htmlBody) { export function makeHtmlMessage(body: string, htmlBody: string) {
return { return {
msgtype: "m.text", msgtype: "m.text",
format: "org.matrix.custom.html", format: "org.matrix.custom.html",
@@ -38,7 +38,7 @@ export function makeHtmlMessage(body, htmlBody) {
* @param {string} htmlBody the HTML representation of the notice * @param {string} htmlBody the HTML representation of the notice
* @returns {{msgtype: string, format: string, body: string, formatted_body: string}} * @returns {{msgtype: string, format: string, body: string, formatted_body: string}}
*/ */
export function makeHtmlNotice(body, htmlBody) { export function makeHtmlNotice(body: string, htmlBody: string) {
return { return {
msgtype: "m.notice", msgtype: "m.notice",
format: "org.matrix.custom.html", format: "org.matrix.custom.html",
@@ -53,7 +53,7 @@ export function makeHtmlNotice(body, htmlBody) {
* @param {string} htmlBody the HTML representation of the emote * @param {string} htmlBody the HTML representation of the emote
* @returns {{msgtype: string, format: string, body: string, formatted_body: string}} * @returns {{msgtype: string, format: string, body: string, formatted_body: string}}
*/ */
export function makeHtmlEmote(body, htmlBody) { export function makeHtmlEmote(body: string, htmlBody: string) {
return { return {
msgtype: "m.emote", msgtype: "m.emote",
format: "org.matrix.custom.html", format: "org.matrix.custom.html",
@@ -67,7 +67,7 @@ export function makeHtmlEmote(body, htmlBody) {
* @param {string} body the plaintext body of the emote * @param {string} body the plaintext body of the emote
* @returns {{msgtype: string, body: string}} * @returns {{msgtype: string, body: string}}
*/ */
export function makeTextMessage(body) { export function makeTextMessage(body: string) {
return { return {
msgtype: "m.text", msgtype: "m.text",
body: body, body: body,
@@ -79,7 +79,7 @@ export function makeTextMessage(body) {
* @param {string} body the plaintext body of the notice * @param {string} body the plaintext body of the notice
* @returns {{msgtype: string, body: string}} * @returns {{msgtype: string, body: string}}
*/ */
export function makeNotice(body) { export function makeNotice(body: string) {
return { return {
msgtype: "m.notice", msgtype: "m.notice",
body: body, body: body,
@@ -91,7 +91,7 @@ export function makeNotice(body) {
* @param {string} body the plaintext body of the emote * @param {string} body the plaintext body of the emote
* @returns {{msgtype: string, body: string}} * @returns {{msgtype: string, body: string}}
*/ */
export function makeEmoteMessage(body) { export function makeEmoteMessage(body: string) {
return { return {
msgtype: "m.emote", msgtype: "m.emote",
body: body, body: body,

View File

@@ -1,6 +1,5 @@
/* /*
Copyright 2015, 2016 OpenMarket Ltd Copyright 2015 - 2021 The Matrix.org Foundation C.I.C.
Copyright 2019 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with 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. * for such URLs.
* @return {string} The complete URL to the content. * @return {string} The complete URL to the content.
*/ */
export function getHttpUriForMxc(baseUrl, mxc, width, height, export function getHttpUriForMxc(
resizeMethod, allowDirectLinks) { baseUrl: string,
mxc: string,
width: number,
height: number,
resizeMethod: string,
allowDirectLinks: boolean,
): string {
if (typeof mxc !== "string" || !mxc) { if (typeof mxc !== "string" || !mxc) {
return ''; return '';
} }
@@ -51,13 +56,13 @@ export function getHttpUriForMxc(baseUrl, mxc, width, height,
const params = {}; const params = {};
if (width) { if (width) {
params.width = Math.round(width); params["width"] = Math.round(width);
} }
if (height) { if (height) {
params.height = Math.round(height); params["height"] = Math.round(height);
} }
if (resizeMethod) { if (resizeMethod) {
params.method = resizeMethod; params["method"] = resizeMethod;
} }
if (Object.keys(params).length > 0) { if (Object.keys(params).length > 0) {
// these are thumbnailing params so they probably want the // these are thumbnailing params so they probably want the
@@ -71,7 +76,7 @@ export function getHttpUriForMxc(baseUrl, mxc, width, height,
fragment = serverAndMediaId.substr(fragmentOffset); fragment = serverAndMediaId.substr(fragmentOffset);
serverAndMediaId = serverAndMediaId.substr(0, fragmentOffset); serverAndMediaId = serverAndMediaId.substr(0, fragmentOffset);
} }
return baseUrl + prefix + serverAndMediaId +
(Object.keys(params).length === 0 ? "" : const urlParams = (Object.keys(params).length === 0 ? "" : ("?" + utils.encodeParams(params)));
("?" + utils.encodeParams(params))) + fragment; return baseUrl + prefix + serverAndMediaId + urlParams + fragment;
} }

View File

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

141
src/filter-component.ts Normal file
View File

@@ -0,0 +1,141 @@
/*
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: string[] = this.filterJson[notName];
if (disallowedValues?.some(matchFunc)) {
return false;
}
const allowedValues: string[] = this.filterJson[name];
if (allowedValues && !allowedValues.some(matchFunc)) {
return false;
}
}
const containsUrlFilter = this.filterJson.contains_url;
if (containsUrlFilter !== undefined && 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;
}
}

View File

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

226
src/filter.ts Normal file
View File

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

View File

@@ -19,8 +19,16 @@ import { EventType, IEncryptedFile, MsgType, UNSTABLE_MSC3089_BRANCH, UNSTABLE_M
import { Room } from "./room"; import { Room } from "./room";
import { logger } from "../logger"; import { logger } from "../logger";
import { MatrixEvent } from "./event"; 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 { MSC3089Branch } from "./MSC3089Branch";
import promiseRetry from "p-retry";
/** /**
* The recommended defaults for a tree space's power levels. Note that this * 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 * Invites a user to the tree space. They will be given the default Viewer
* permission level unless specified elsewhere. * permission level unless specified elsewhere.
* @param {string} userId The user ID to invite. * @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<void>} Resolves when complete. * @returns {Promise<void>} Resolves when complete.
*/ */
public invite(userId: string): Promise<void> { public invite(userId: string, andSubspaces = true): Promise<void> {
// TODO: [@@TR] Reliable invites
// TODO: [@@TR] Share keys // TODO: [@@TR] Share keys
return this.client.invite(this.roomId, userId); const promises: Promise<void>[] = [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<void> {
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;
});
});
} }
/** /**

View File

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

123
src/models/event-context.ts Normal file
View File

@@ -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, string | null> = {
[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);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@@ -15,8 +15,11 @@ limitations under the License.
*/ */
import { EventEmitter } from 'events'; import { EventEmitter } from 'events';
import { EventStatus } from '../models/event';
import { EventStatus, MatrixEvent } from './event';
import { Room } from './room';
import { logger } from '../logger'; import { logger } from '../logger';
import { RelationType } from "../@types/event";
/** /**
* A container for relation events that supports easy access to common ways of * A container for relation events that supports easy access to common ways of
@@ -27,8 +30,16 @@ import { logger } from '../logger';
* EventTimelineSet#getRelationsForEvent. * EventTimelineSet#getRelationsForEvent.
*/ */
export class Relations extends EventEmitter { export class Relations extends EventEmitter {
private relationEventIds = new Set<string>();
private relations = new Set<MatrixEvent>();
private annotationsByKey: Record<string, Set<MatrixEvent>> = {};
private annotationsBySender: Record<string, Set<MatrixEvent>> = {};
private sortedAnnotationsByKey: [string, Set<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", * The type of relation involved, such as "m.annotation", "m.reference",
* "m.replace", etc. * "m.replace", etc.
* @param {String} eventType * @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 * Room for this container. May be null for non-room cases, such as the
* notification timeline. * notification timeline.
*/ */
constructor(relationType, eventType, room) { constructor(
public readonly relationType: RelationType,
public readonly eventType: string,
private readonly room: Room,
) {
super(); 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 * @param {MatrixEvent} event
* The new relation event to be added. * The new relation event to be added.
*/ */
async addEvent(event) { public async addEvent(event: MatrixEvent) {
if (this._relationEventIds.has(event.getId())) { if (this.relationEventIds.has(event.getId())) {
return; return;
} }
@@ -79,24 +84,24 @@ export class Relations extends EventEmitter {
// If the event is in the process of being sent, listen for cancellation // If the event is in the process of being sent, listen for cancellation
// so we can remove the event from the collection. // so we can remove the event from the collection.
if (event.isSending()) { if (event.isSending()) {
event.on("Event.status", this._onEventStatus); event.on("Event.status", this.onEventStatus);
} }
this._relations.add(event); this.relations.add(event);
this._relationEventIds.add(event.getId()); this.relationEventIds.add(event.getId());
if (this.relationType === "m.annotation") { if (this.relationType === RelationType.Annotation) {
this._addAnnotationToAggregation(event); this.addAnnotationToAggregation(event);
} else if (this.relationType === "m.replace" && this._targetEvent) { } else if (this.relationType === RelationType.Replace && this.targetEvent) {
const lastReplacement = await this.getLastReplacement(); 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.emit("Relations.add", event);
this._maybeEmitCreated(); this.maybeEmitCreated();
} }
/** /**
@@ -105,8 +110,8 @@ export class Relations extends EventEmitter {
* @param {MatrixEvent} event * @param {MatrixEvent} event
* The relation event to remove. * The relation event to remove.
*/ */
async _removeEvent(event) { private async removeEvent(event: MatrixEvent) {
if (!this._relations.has(event)) { if (!this.relations.has(event)) {
return; return;
} }
@@ -124,13 +129,13 @@ export class Relations extends EventEmitter {
return; return;
} }
this._relations.delete(event); this.relations.delete(event);
if (this.relationType === "m.annotation") { if (this.relationType === RelationType.Annotation) {
this._removeAnnotationFromAggregation(event); this.removeAnnotationFromAggregation(event);
} else if (this.relationType === "m.replace" && this._targetEvent) { } else if (this.relationType === RelationType.Replace && this.targetEvent) {
const lastReplacement = await this.getLastReplacement(); const lastReplacement = await this.getLastReplacement();
this._targetEvent.makeReplaced(lastReplacement); this.targetEvent.makeReplaced(lastReplacement);
} }
this.emit("Relations.remove", event); this.emit("Relations.remove", event);
@@ -142,19 +147,19 @@ export class Relations extends EventEmitter {
* @param {MatrixEvent} event The event whose status has changed * @param {MatrixEvent} event The event whose status has changed
* @param {EventStatus} status The new status * @param {EventStatus} status The new status
*/ */
_onEventStatus = (event, status) => { private onEventStatus = (event: MatrixEvent, status: EventStatus) => {
if (!event.isSending()) { if (!event.isSending()) {
// Sending is done, so we don't need to listen anymore // Sending is done, so we don't need to listen anymore
event.removeListener("Event.status", this._onEventStatus); event.removeListener("Event.status", this.onEventStatus);
return; return;
} }
if (status !== EventStatus.CANCELLED) { if (status !== EventStatus.CANCELLED) {
return; return;
} }
// Event was cancelled, remove from the collection // Event was cancelled, remove from the collection
event.removeListener("Event.status", this._onEventStatus); event.removeListener("Event.status", this.onEventStatus);
this._removeEvent(event); this.removeEvent(event);
} };
/** /**
* Get all relation events in this collection. * Get all relation events in this collection.
@@ -166,51 +171,51 @@ export class Relations extends EventEmitter {
* @return {Array} * @return {Array}
* Relation events in insertion order. * Relation events in insertion order.
*/ */
getRelations() { public getRelations() {
return [...this._relations]; return [...this.relations];
} }
_addAnnotationToAggregation(event) { private addAnnotationToAggregation(event: MatrixEvent) {
const { key } = event.getRelation(); const { key } = event.getRelation();
if (!key) { if (!key) {
return; return;
} }
let eventsForKey = this._annotationsByKey[key]; let eventsForKey = this.annotationsByKey[key];
if (!eventsForKey) { if (!eventsForKey) {
eventsForKey = this._annotationsByKey[key] = new Set(); eventsForKey = this.annotationsByKey[key] = new Set();
this._sortedAnnotationsByKey.push([key, eventsForKey]); this.sortedAnnotationsByKey.push([key, eventsForKey]);
} }
// Add the new event to the set for this key // Add the new event to the set for this key
eventsForKey.add(event); eventsForKey.add(event);
// Re-sort the [key, events] pairs in descending order of event count // 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 aEvents = a[1];
const bEvents = b[1]; const bEvents = b[1];
return bEvents.size - aEvents.size; return bEvents.size - aEvents.size;
}); });
const sender = event.getSender(); const sender = event.getSender();
let eventsFromSender = this._annotationsBySender[sender]; let eventsFromSender = this.annotationsBySender[sender];
if (!eventsFromSender) { if (!eventsFromSender) {
eventsFromSender = this._annotationsBySender[sender] = new Set(); eventsFromSender = this.annotationsBySender[sender] = new Set();
} }
// Add the new event to the set for this sender // Add the new event to the set for this sender
eventsFromSender.add(event); eventsFromSender.add(event);
} }
_removeAnnotationFromAggregation(event) { private removeAnnotationFromAggregation(event: MatrixEvent) {
const { key } = event.getRelation(); const { key } = event.getRelation();
if (!key) { if (!key) {
return; return;
} }
const eventsForKey = this._annotationsByKey[key]; const eventsForKey = this.annotationsByKey[key];
if (eventsForKey) { if (eventsForKey) {
eventsForKey.delete(event); eventsForKey.delete(event);
// Re-sort the [key, events] pairs in descending order of event count // 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 aEvents = a[1];
const bEvents = b[1]; const bEvents = b[1];
return bEvents.size - aEvents.size; return bEvents.size - aEvents.size;
@@ -218,7 +223,7 @@ export class Relations extends EventEmitter {
} }
const sender = event.getSender(); const sender = event.getSender();
const eventsFromSender = this._annotationsBySender[sender]; const eventsFromSender = this.annotationsBySender[sender];
if (eventsFromSender) { if (eventsFromSender) {
eventsFromSender.delete(event); eventsFromSender.delete(event);
} }
@@ -235,25 +240,25 @@ export class Relations extends EventEmitter {
* @param {MatrixEvent} redactedEvent * @param {MatrixEvent} redactedEvent
* The original relation event that is about to be redacted. * The original relation event that is about to be redacted.
*/ */
_onBeforeRedaction = async (redactedEvent) => { private onBeforeRedaction = async (redactedEvent: MatrixEvent) => {
if (!this._relations.has(redactedEvent)) { if (!this.relations.has(redactedEvent)) {
return; 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 // Remove the redacted annotation from aggregation by key
this._removeAnnotationFromAggregation(redactedEvent); this.removeAnnotationFromAggregation(redactedEvent);
} else if (this.relationType === "m.replace" && this._targetEvent) { } else if (this.relationType === RelationType.Replace && this.targetEvent) {
const lastReplacement = await this.getLastReplacement(); 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); this.emit("Relations.redaction", redactedEvent);
} };
/** /**
* Get all events in this collection grouped by key and sorted by descending * Get all events in this collection grouped by key and sorted by descending
@@ -265,13 +270,13 @@ export class Relations extends EventEmitter {
* An array of [key, events] pairs sorted by descending event count. * An array of [key, events] pairs sorted by descending event count.
* The events are stored in a Set (which preserves insertion order). * The events are stored in a Set (which preserves insertion order).
*/ */
getSortedAnnotationsByKey() { public getSortedAnnotationsByKey() {
if (this.relationType !== "m.annotation") { if (this.relationType !== RelationType.Annotation) {
// Other relation types are not grouped currently. // Other relation types are not grouped currently.
return null; 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 * An object with each relation sender as a key and the matching Set of
* events for that sender as a value. * events for that sender as a value.
*/ */
getAnnotationsBySender() { public getAnnotationsBySender() {
if (this.relationType !== "m.annotation") { if (this.relationType !== RelationType.Annotation) {
// Other relation types are not grouped currently. // Other relation types are not grouped currently.
return null; return null;
} }
return this._annotationsBySender; return this.annotationsBySender;
} }
/** /**
@@ -300,12 +305,12 @@ export class Relations extends EventEmitter {
* *
* @return {MatrixEvent?} * @return {MatrixEvent?}
*/ */
async getLastReplacement() { public async getLastReplacement(): Promise<MatrixEvent | null> {
if (this.relationType !== "m.replace") { if (this.relationType !== RelationType.Replace) {
// Aggregating on last only makes sense for this relation type // Aggregating on last only makes sense for this relation type
return null; return null;
} }
if (!this._targetEvent) { if (!this.targetEvent) {
// Don't know which replacements to accept yet. // Don't know which replacements to accept yet.
// This method shouldn't be called before the original // This method shouldn't be called before the original
// event is known anyway. // event is known anyway.
@@ -314,12 +319,11 @@ export class Relations extends EventEmitter {
// the all-knowning server tells us that the event at some point had // 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 // this timestamp for its replacement, so any following replacement should definitely not be less
const replaceRelation = const replaceRelation = this.targetEvent.getServerAggregatedRelation(RelationType.Replace);
this._targetEvent.getServerAggregatedRelation("m.replace");
const minTs = replaceRelation && replaceRelation.origin_server_ts; const minTs = replaceRelation && replaceRelation.origin_server_ts;
const lastReplacement = this.getRelations().reduce((last, event) => { const lastReplacement = this.getRelations().reduce((last, event) => {
if (event.getSender() !== this._targetEvent.getSender()) { if (event.getSender() !== this.targetEvent.getSender()) {
return last; return last;
} }
if (minTs && minTs > event.getTs()) { if (minTs && minTs > event.getTs()) {
@@ -332,9 +336,9 @@ export class Relations extends EventEmitter {
}, null); }, null);
if (lastReplacement?.shouldAttemptDecryption()) { if (lastReplacement?.shouldAttemptDecryption()) {
await lastReplacement.attemptDecryption(this._room._client.crypto); await lastReplacement.attemptDecryption(this.room._client.crypto);
} else if (lastReplacement?.isBeingDecrypted()) { } else if (lastReplacement?.isBeingDecrypted()) {
await lastReplacement._decryptionPromise; await lastReplacement.getDecryptionPromise();
} }
return lastReplacement; return lastReplacement;
@@ -343,38 +347,34 @@ export class Relations extends EventEmitter {
/* /*
* @param {MatrixEvent} targetEvent the event the relations are related to. * @param {MatrixEvent} targetEvent the event the relations are related to.
*/ */
async setTargetEvent(event) { public async setTargetEvent(event: MatrixEvent) {
if (this._targetEvent) { if (this.targetEvent) {
return; return;
} }
this._targetEvent = event; this.targetEvent = event;
if (this.relationType === "m.replace") { if (this.relationType === RelationType.Replace) {
const replacement = await this.getLastReplacement(); const replacement = await this.getLastReplacement();
// this is the initial update, so only call it if we already have something // this is the initial update, so only call it if we already have something
// to not emit Event.replaced needlessly // to not emit Event.replaced needlessly
if (replacement) { if (replacement) {
this._targetEvent.makeReplaced(replacement); this.targetEvent.makeReplaced(replacement);
} }
} }
this._maybeEmitCreated(); this.maybeEmitCreated();
} }
_maybeEmitCreated() { private maybeEmitCreated() {
if (this._creationEmitted) { if (this.creationEmitted) {
return; return;
} }
// Only emit we're "created" once we have a target event instance _and_ // Only emit we're "created" once we have a target event instance _and_
// at least one related event. // at least one related event.
if (!this._targetEvent || !this._relations.size) { if (!this.targetEvent || !this.relations.size) {
return; return;
} }
this._creationEmitted = true; this.creationEmitted = true;
this._targetEvent.emit( this.targetEvent.emit("Event.relationsCreated", this.relationType, this.eventType);
"Event.relationsCreated",
this.relationType,
this.eventType,
);
} }
} }

View File

@@ -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 <code>m.room.member</code> 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 <code>m.room.power_levels</code>
* 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 <i>before</i> 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;
* });
*/

411
src/models/room-member.ts Normal file
View File

@@ -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 <code>m.room.member</code> 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 <code>m.room.power_levels</code>
* 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 <i>before</i> 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;
* });
*/

View File

@@ -1,6 +1,5 @@
/* /*
Copyright 2015, 2016 OpenMarket Ltd Copyright 2015 - 2021 The Matrix.org Foundation C.I.C.
Copyright 2019 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with 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 * @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 * 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. * 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 {string[]} info.aliases The list of aliases for this room.
* @param {Number} info.timestamp The timestamp for this room. * @param {Number} info.timestamp The timestamp for this room.
*/ */
export function RoomSummary(roomId, info) { export class RoomSummary {
this.roomId = roomId; constructor(public readonly roomId: string, info?: IInfo) {}
this.info = info;
} }

View File

@@ -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 <code>m.presence</code> 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 <i>before</i> 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 <code>im.vector.user_status</code> 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;
* });
*/

274
src/models/user.ts Normal file
View File

@@ -0,0 +1,274 @@
/*
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,
};
// eslint-disable-next-line camelcase
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 <code>m.presence</code> 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 <i>before</i> 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 <code>im.vector.user_status</code> 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;
* });
*/

226
src/store/index.ts Normal file
View File

@@ -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<bool>} whether or not the database was newly created in this session. */
isNewlyCreated(): Promise<boolean>;
/**
* 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<MatrixEvent>} 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<MatrixEvent>} 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<void>;
/**
* 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<void>;
/**
* @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<object>;
/**
* @return {Promise} If there is a saved sync, the nextBatch token
* for this sync, otherwise null.
*/
getSavedSyncToken(): Promise<string | null>;
/**
* Delete all data from this store. Does nothing since this store
* doesn't store anything.
* @return {Promise} An immediately resolved promise.
*/
deleteAllData(): Promise<void>;
getOutOfBandMembers(roomId: string): Promise<MatrixEvent[] | null>;
setOutOfBandMembers(roomId: string, membershipEvents: MatrixEvent[]): Promise<void>;
clearOutOfBandMembers(roomId: string): Promise<void>;
getClientOptions(): Promise<object>;
storeClientOptions(options: object): Promise<void>;
}

View File

@@ -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
* <code>startup()</code>. 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 <code>startup()</code> is called.
* <pre>
* 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!");
* }
* });
* </pre>
*
* @constructor
* @extends MemoryStore
* @param {Object} opts Options object.
* @param {Object} opts.indexedDB The Indexed DB interface e.g.
* <code>window.indexedDB</code>
* @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<bool>} 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);
}
}
};
}

330
src/store/indexeddb.ts Normal file
View File

@@ -0,0 +1,330 @@
/*
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<string, number> = {}; // 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
* <code>startup()</code>. 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 <code>startup()</code> is called.
* <pre>
* 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!");
* }
* });
* </pre>
*
* @constructor
* @extends MemoryStore
* @param {Object} opts Options object.
* @param {Object} opts.indexedDB The Indexed DB interface e.g.
* <code>window.indexedDB</code>
* @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<void> {
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((): Promise<object> => {
return this.backend.getSavedSync();
}, "getSavedSync");
/** @return {Promise<boolean>} whether or not the database was newly created in this session. */
public isNewlyCreated = this.degradable((): Promise<boolean> => {
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((): Promise<string | null> => {
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((): Promise<void> => {
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<void> {
if (force || this.wantsSave()) {
return this.reallySave();
}
return Promise.resolve();
}
private reallySave = this.degradable((): Promise<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): Promise<void> => {
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): Promise<MatrixEvent[]> => {
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[]): Promise<void> => {
super.setOutOfBandMembers(roomId, membershipEvents);
return this.backend.setOutOfBandMembers(roomId, membershipEvents);
}, "setOutOfBandMembers");
public clearOutOfBandMembers = this.degradable((roomId: string) => {
super.clearOutOfBandMembers(roomId);
return this.backend.clearOutOfBandMembers(roomId);
}, "clearOutOfBandMembers");
public getClientOptions = this.degradable((): Promise<object> => {
return this.backend.getClientOptions();
}, "getClientOptions");
public storeClientOptions = this.degradable((options: object): Promise<void> => {
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<A extends Array<any>, R = void>(
func: DegradableFn<A, R>,
fallback?: string,
): DegradableFn<A, R> {
const fallbackFn = super[fallback];
return async (...args) => {
try {
return 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 (fallbackFn) {
return fallbackFn(...args);
}
}
};
}
}
type DegradableFn<A extends Array<any>, T> = (...args: A) => Promise<T>;

View File

@@ -1,8 +1,5 @@
/* /*
Copyright 2015, 2016 OpenMarket Ltd Copyright 2015 - 2021 The Matrix.org Foundation C.I.C.
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"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with 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 * @module store/memory
*/ */
import { EventType } from "../@types/event";
import { Group } from "../models/group";
import { Room } from "../models/room";
import { User } from "../models/user"; 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" && const isValidStr = typeof filterId === "string" &&
!!filterId && !!filterId &&
filterId !== "undefined" && // exclude these as we've serialized undefined in localStorage before filterId !== "undefined" && // exclude these as we've serialized undefined in localStorage before
@@ -33,6 +39,10 @@ function isValidFilterId(filterId) {
return isValidStr || typeof filterId === "number"; return isValidStr || typeof filterId === "number";
} }
export interface IOpts {
localStorage?: Storage;
}
/** /**
* Construct a new in-memory data store for the Matrix Client. * Construct a new in-memory data store for the Matrix Client.
* @constructor * @constructor
@@ -40,96 +50,84 @@ function isValidFilterId(filterId) {
* @param {LocalStorage} opts.localStorage The local storage instance to persist * @param {LocalStorage} opts.localStorage The local storage instance to persist
* some forms of data such as tokens. Rooms will NOT be stored. * some forms of data such as tokens. Rooms will NOT be stored.
*/ */
export function MemoryStore(opts) { export class MemoryStore implements IStore {
opts = opts || {}; private rooms: Record<string, Room> = {}; // roomId: Room
this.rooms = { private groups: Record<string, Group> = {}; // groupId: Group
// roomId: Room private users: Record<string, User> = {}; // userId: User
}; private syncToken: string = null;
this.groups = { // userId: {
// groupId: Group // filterId: Filter
}; // }
this.users = { private filters: Record<string, Record<string, Filter>> = {};
// userId: User private accountData: Record<string, MatrixEvent> = {}; // type : content
}; private readonly localStorage: Storage;
this.syncToken = null; private oobMembers: Record<string, MatrixEvent[]> = {}; // roomId: [member events]
this.filters = { private clientOptions = {};
// userId: {
// filterId: Filter
// }
};
this.accountData = {
// type : content
};
this.localStorage = opts.localStorage;
this._oobMembers = {
// roomId: [member events]
};
this._clientOptions = {};
}
MemoryStore.prototype = { constructor(opts: IOpts = {}) {
this.localStorage = opts.localStorage;
}
/** /**
* Retrieve the token to stream from. * Retrieve the token to stream from.
* @return {string} The token or null. * @return {string} The token or null.
*/ */
getSyncToken: function() { public getSyncToken(): string | null {
return this.syncToken; return this.syncToken;
}, }
/** @return {Promise<bool>} whether or not the database was newly created in this session. */ /** @return {Promise<boolean>} whether or not the database was newly created in this session. */
isNewlyCreated: function() { public isNewlyCreated(): Promise<boolean> {
return Promise.resolve(true); return Promise.resolve(true);
}, }
/** /**
* Set the token to stream from. * Set the token to stream from.
* @param {string} token The token to stream from. * @param {string} token The token to stream from.
*/ */
setSyncToken: function(token) { public setSyncToken(token: string) {
this.syncToken = token; this.syncToken = token;
}, }
/** /**
* Store the given room. * Store the given room.
* @param {Group} group The group to be stored * @param {Group} group The group to be stored
*/ */
storeGroup: function(group) { public storeGroup(group: Group) {
this.groups[group.groupId] = group; this.groups[group.groupId] = group;
}, }
/** /**
* Retrieve a group by its group ID. * Retrieve a group by its group ID.
* @param {string} groupId The group ID. * @param {string} groupId The group ID.
* @return {Group} The group or null. * @return {Group} The group or null.
*/ */
getGroup: function(groupId) { public getGroup(groupId: string): Group | null {
return this.groups[groupId] || null; return this.groups[groupId] || null;
}, }
/** /**
* Retrieve all known groups. * Retrieve all known groups.
* @return {Group[]} A list of groups, which may be empty. * @return {Group[]} A list of groups, which may be empty.
*/ */
getGroups: function() { public getGroups(): Group[] {
return Object.values(this.groups); return Object.values(this.groups);
}, }
/** /**
* Store the given room. * Store the given room.
* @param {Room} room The room to be stored. All properties must be stored. * @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; this.rooms[room.roomId] = room;
// add listeners for room member changes so we can keep the room member // add listeners for room member changes so we can keep the room member
// map up-to-date. // map up-to-date.
room.currentState.on("RoomState.members", this._onRoomMember.bind(this)); room.currentState.on("RoomState.members", this.onRoomMember);
// add existing members // add existing members
const self = this; room.currentState.getMembers().forEach((m) => {
room.currentState.getMembers().forEach(function(m) { this.onRoomMember(null, room.currentState, m);
self._onRoomMember(null, room.currentState, m);
}); });
}, }
/** /**
* Called when a room member in a room being tracked by this store has been * 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 {RoomState} state
* @param {RoomMember} member * @param {RoomMember} member
*/ */
_onRoomMember: function(event, state, member) { private onRoomMember = (event: MatrixEvent, state: RoomState, member: RoomMember) => {
if (member.membership === "invite") { if (member.membership === "invite") {
// We do NOT add invited members because people love to typo user IDs // We do NOT add invited members because people love to typo user IDs
// which would then show up in these lists (!) // which would then show up in these lists (!)
@@ -158,70 +156,70 @@ MemoryStore.prototype = {
user.setAvatarUrl(member.events.member.getContent().avatar_url); user.setAvatarUrl(member.events.member.getContent().avatar_url);
} }
this.users[user.userId] = user; this.users[user.userId] = user;
}, };
/** /**
* Retrieve a room by its' room ID. * Retrieve a room by its' room ID.
* @param {string} roomId The room ID. * @param {string} roomId The room ID.
* @return {Room} The room or null. * @return {Room} The room or null.
*/ */
getRoom: function(roomId) { public getRoom(roomId: string): Room | null {
return this.rooms[roomId] || null; return this.rooms[roomId] || null;
}, }
/** /**
* Retrieve all known rooms. * Retrieve all known rooms.
* @return {Room[]} A list of rooms, which may be empty. * @return {Room[]} A list of rooms, which may be empty.
*/ */
getRooms: function() { public getRooms(): Room[] {
return Object.values(this.rooms); return Object.values(this.rooms);
}, }
/** /**
* Permanently delete a room. * Permanently delete a room.
* @param {string} roomId * @param {string} roomId
*/ */
removeRoom: function(roomId) { public removeRoom(roomId: string): void {
if (this.rooms[roomId]) { if (this.rooms[roomId]) {
this.rooms[roomId].removeListener("RoomState.members", this._onRoomMember); this.rooms[roomId].removeListener("RoomState.members", this.onRoomMember);
} }
delete this.rooms[roomId]; delete this.rooms[roomId];
}, }
/** /**
* Retrieve a summary of all the rooms. * Retrieve a summary of all the rooms.
* @return {RoomSummary[]} A summary of each room. * @return {RoomSummary[]} A summary of each room.
*/ */
getRoomSummaries: function() { public getRoomSummaries(): RoomSummary[] {
return Object.values(this.rooms).map(function(room) { return Object.values(this.rooms).map(function(room) {
return room.summary; return room.summary;
}); });
}, }
/** /**
* Store a User. * Store a User.
* @param {User} user The user to store. * @param {User} user The user to store.
*/ */
storeUser: function(user) { public storeUser(user: User): void {
this.users[user.userId] = user; this.users[user.userId] = user;
}, }
/** /**
* Retrieve a User by its' user ID. * Retrieve a User by its' user ID.
* @param {string} userId The user ID. * @param {string} userId The user ID.
* @return {User} The user or null. * @return {User} The user or null.
*/ */
getUser: function(userId) { public getUser(userId: string): User | null {
return this.users[userId] || null; return this.users[userId] || null;
}, }
/** /**
* Retrieve all known users. * Retrieve all known users.
* @return {User[]} A list of users, which may be empty. * @return {User[]} A list of users, which may be empty.
*/ */
getUsers: function() { public getUsers(): User[] {
return Object.values(this.users); return Object.values(this.users);
}, }
/** /**
* Retrieve scrollback for this room. * Retrieve scrollback for this room.
@@ -230,9 +228,9 @@ MemoryStore.prototype = {
* @return {Array<Object>} An array of objects which will be at most 'limit' * @return {Array<Object>} An array of objects which will be at most 'limit'
* length and at least 0. The objects are the raw event JSON. * length and at least 0. The objects are the raw event JSON.
*/ */
scrollback: function(room, limit) { public scrollback(room: Room, limit: number): MatrixEvent[] {
return []; return [];
}, }
/** /**
* Store events for a room. The events have already been added to the timeline * 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 {string} token The token associated with these events.
* @param {boolean} toStart True if these are paginated results. * @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. // no-op because they've already been added to the room instance.
}, }
/** /**
* Store a filter. * Store a filter.
* @param {Filter} filter * @param {Filter} filter
*/ */
storeFilter: function(filter) { public storeFilter(filter: Filter): void {
if (!filter) { if (!filter) {
return; return;
} }
@@ -257,7 +255,7 @@ MemoryStore.prototype = {
this.filters[filter.userId] = {}; this.filters[filter.userId] = {};
} }
this.filters[filter.userId][filter.filterId] = filter; this.filters[filter.userId][filter.filterId] = filter;
}, }
/** /**
* Retrieve a filter. * Retrieve a filter.
@@ -265,19 +263,19 @@ MemoryStore.prototype = {
* @param {string} filterId * @param {string} filterId
* @return {?Filter} A filter or null. * @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]) { if (!this.filters[userId] || !this.filters[userId][filterId]) {
return null; return null;
} }
return this.filters[userId][filterId]; return this.filters[userId][filterId];
}, }
/** /**
* Retrieve a filter ID with the given name. * Retrieve a filter ID with the given name.
* @param {string} filterName The filter name. * @param {string} filterName The filter name.
* @return {?string} The filter ID or null. * @return {?string} The filter ID or null.
*/ */
getFilterIdByName: function(filterName) { public getFilterIdByName(filterName: string): string | null {
if (!this.localStorage) { if (!this.localStorage) {
return null; return null;
} }
@@ -294,14 +292,14 @@ MemoryStore.prototype = {
} }
} catch (e) {} } catch (e) {}
return null; return null;
}, }
/** /**
* Set a filter name to ID mapping. * Set a filter name to ID mapping.
* @param {string} filterName * @param {string} filterName
* @param {string} filterId * @param {string} filterId
*/ */
setFilterIdByName: function(filterName, filterId) { public setFilterIdByName(filterName: string, filterId: string) {
if (!this.localStorage) { if (!this.localStorage) {
return; return;
} }
@@ -313,7 +311,7 @@ MemoryStore.prototype = {
this.localStorage.removeItem(key); this.localStorage.removeItem(key);
} }
} catch (e) {} } catch (e) {}
}, }
/** /**
* Store user-scoped account data events. * Store user-scoped account data events.
@@ -321,21 +319,20 @@ MemoryStore.prototype = {
* events with the same type will replace each other. * events with the same type will replace each other.
* @param {Array<MatrixEvent>} events The events to store. * @param {Array<MatrixEvent>} events The events to store.
*/ */
storeAccountDataEvents: function(events) { public storeAccountDataEvents(events: MatrixEvent[]): void {
const self = this; events.forEach((event) => {
events.forEach(function(event) { this.accountData[event.getType()] = event;
self.accountData[event.getType()] = event;
}); });
}, }
/** /**
* Get account data event by event type * Get account data event by event type
* @param {string} eventType The event type being queried * @param {string} eventType The event type being queried
* @return {?MatrixEvent} the user account_data event of given type, if any * @return {?MatrixEvent} the user account_data event of given type, if any
*/ */
getAccountData: function(eventType) { public getAccountData(eventType: EventType | string): MatrixEvent | undefined {
return this.accountData[eventType]; return this.accountData[eventType];
}, }
/** /**
* setSyncData does nothing as there is no backing data store. * setSyncData does nothing as there is no backing data store.
@@ -343,56 +340,56 @@ MemoryStore.prototype = {
* @param {Object} syncData The sync data * @param {Object} syncData The sync data
* @return {Promise} An immediately resolved promise. * @return {Promise} An immediately resolved promise.
*/ */
setSyncData: function(syncData) { public setSyncData(syncData: object): Promise<void> {
return Promise.resolve(); return Promise.resolve();
}, }
/** /**
* We never want to save becase we have nothing to save to. * We never want to save becase we have nothing to save to.
* *
* @return {boolean} If the store wants to save * @return {boolean} If the store wants to save
*/ */
wantsSave: function() { public wantsSave(): boolean {
return false; return false;
}, }
/** /**
* Save does nothing as there is no backing data store. * Save does nothing as there is no backing data store.
* @param {bool} force True to force a save (but the memory * @param {bool} force True to force a save (but the memory
* store still can't save anything) * 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. * Startup does nothing as this store doesn't require starting up.
* @return {Promise} An immediately resolved promise. * @return {Promise} An immediately resolved promise.
*/ */
startup: function() { public startup(): Promise<void> {
return Promise.resolve(); return Promise.resolve();
}, }
/** /**
* @return {Promise} Resolves with a sync response to restore the * @return {Promise} Resolves with a sync response to restore the
* client state to where it was at the last save, or null if there * client state to where it was at the last save, or null if there
* is no saved sync data. * is no saved sync data.
*/ */
getSavedSync: function() { public getSavedSync(): Promise<object> {
return Promise.resolve(null); return Promise.resolve(null);
}, }
/** /**
* @return {Promise} If there is a saved sync, the nextBatch token * @return {Promise} If there is a saved sync, the nextBatch token
* for this sync, otherwise null. * for this sync, otherwise null.
*/ */
getSavedSyncToken: function() { public getSavedSyncToken(): Promise<string | null> {
return Promise.resolve(null); return Promise.resolve(null);
}, }
/** /**
* Delete all data from this store. * Delete all data from this store.
* @return {Promise} An immediately resolved promise. * @return {Promise} An immediately resolved promise.
*/ */
deleteAllData: function() { public deleteAllData(): Promise<void> {
this.rooms = { this.rooms = {
// roomId: Room // roomId: Room
}; };
@@ -409,7 +406,7 @@ MemoryStore.prototype = {
// type : content // type : content
}; };
return Promise.resolve(); return Promise.resolve();
}, }
/** /**
* Returns the out-of-band membership events for this room that * 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 {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 * @returns {null} in case the members for this room haven't been stored yet
*/ */
getOutOfBandMembers: function(roomId) { public getOutOfBandMembers(roomId: string): Promise<MatrixEvent[] | null> {
return Promise.resolve(this._oobMembers[roomId] || null); return Promise.resolve(this.oobMembers[roomId] || null);
}, }
/** /**
* Stores the out-of-band membership events for this room. Note that * 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 * @param {event[]} membershipEvents the membership events to store
* @returns {Promise} when all members have been stored * @returns {Promise} when all members have been stored
*/ */
setOutOfBandMembers: function(roomId, membershipEvents) { public setOutOfBandMembers(roomId: string, membershipEvents: MatrixEvent[]): Promise<void> {
this._oobMembers[roomId] = membershipEvents; this.oobMembers[roomId] = membershipEvents;
return Promise.resolve(); return Promise.resolve();
}, }
clearOutOfBandMembers: function() { public clearOutOfBandMembers(roomId: string): Promise<void> {
this._oobMembers = {}; this.oobMembers = {};
return Promise.resolve(); return Promise.resolve();
}, }
getClientOptions: function() { public getClientOptions(): Promise<object> {
return Promise.resolve(this._clientOptions); return Promise.resolve(this.clientOptions);
}, }
storeClientOptions: function(options) { public storeClientOptions(options: object): Promise<void> {
this._clientOptions = Object.assign({}, options); this.clientOptions = Object.assign({}, options);
return Promise.resolve(); return Promise.resolve();
}, }
}; }

View File

@@ -1,8 +1,5 @@
/* /*
Copyright 2015, 2016 OpenMarket Ltd Copyright 2015 - 2021 The Matrix.org Foundation C.I.C.
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"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with 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 * @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. * Construct a stub store. This does no-ops on most store methods.
* @constructor * @constructor
*/ */
export function StubStore() { export class StubStore implements IStore {
this.fromToken = null; private fromToken: string = null;
}
StubStore.prototype = {
/** @return {Promise<bool>} whether or not the database was newly created in this session. */ /** @return {Promise<bool>} whether or not the database was newly created in this session. */
isNewlyCreated: function() { public isNewlyCreated(): Promise<boolean> {
return Promise.resolve(true); return Promise.resolve(true);
}, }
/** /**
* Get the sync token. * Get the sync token.
* @return {string} * @return {string}
*/ */
getSyncToken: function() { public getSyncToken(): string | null {
return this.fromToken; return this.fromToken;
}, }
/** /**
* Set the sync token. * Set the sync token.
* @param {string} token * @param {string} token
*/ */
setSyncToken: function(token) { public setSyncToken(token: string) {
this.fromToken = token; this.fromToken = token;
}, }
/** /**
* No-op. * No-op.
* @param {Group} group * @param {Group} group
*/ */
storeGroup: function(group) { public storeGroup(group: Group) {}
},
/** /**
* No-op. * No-op.
* @param {string} groupId * @param {string} groupId
* @return {null} * @return {null}
*/ */
getGroup: function(groupId) { public getGroup(groupId: string): Group | null {
return null; return null;
}, }
/** /**
* No-op. * No-op.
* @return {Array} An empty array. * @return {Array} An empty array.
*/ */
getGroups: function() { public getGroups(): Group[] {
return []; return [];
}, }
/** /**
* No-op. * No-op.
* @param {Room} room * @param {Room} room
*/ */
storeRoom: function(room) { public storeRoom(room: Room) {}
},
/** /**
* No-op. * No-op.
* @param {string} roomId * @param {string} roomId
* @return {null} * @return {null}
*/ */
getRoom: function(roomId) { public getRoom(roomId: string): Room | null {
return null; return null;
}, }
/** /**
* No-op. * No-op.
* @return {Array} An empty array. * @return {Array} An empty array.
*/ */
getRooms: function() { public getRooms(): Room[] {
return []; return [];
}, }
/** /**
* Permanently delete a room. * Permanently delete a room.
* @param {string} roomId * @param {string} roomId
*/ */
removeRoom: function(roomId) { public removeRoom(roomId: string) {
return; return;
}, }
/** /**
* No-op. * No-op.
* @return {Array} An empty array. * @return {Array} An empty array.
*/ */
getRoomSummaries: function() { public getRoomSummaries(): RoomSummary[] {
return []; return [];
}, }
/** /**
* No-op. * No-op.
* @param {User} user * @param {User} user
*/ */
storeUser: function(user) { public storeUser(user: User) {}
},
/** /**
* No-op. * No-op.
* @param {string} userId * @param {string} userId
* @return {null} * @return {null}
*/ */
getUser: function(userId) { public getUser(userId: string): User | null {
return null; return null;
}, }
/** /**
* No-op. * No-op.
* @return {User[]} * @return {User[]}
*/ */
getUsers: function() { public getUsers(): User[] {
return []; return [];
}, }
/** /**
* No-op. * No-op.
@@ -147,9 +147,9 @@ StubStore.prototype = {
* @param {integer} limit * @param {integer} limit
* @return {Array} * @return {Array}
*/ */
scrollback: function(room, limit) { public scrollback(room: Room, limit: number): MatrixEvent[] {
return []; return [];
}, }
/** /**
* Store events for a room. * Store events for a room.
@@ -158,15 +158,13 @@ StubStore.prototype = {
* @param {string} token The token associated with these events. * @param {string} token The token associated with these events.
* @param {boolean} toStart True if these are paginated results. * @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. * Store a filter.
* @param {Filter} filter * @param {Filter} filter
*/ */
storeFilter: function(filter) { public storeFilter(filter: Filter) {}
},
/** /**
* Retrieve a filter. * Retrieve a filter.
@@ -174,43 +172,39 @@ StubStore.prototype = {
* @param {string} filterId * @param {string} filterId
* @return {?Filter} A filter or null. * @return {?Filter} A filter or null.
*/ */
getFilter: function(userId, filterId) { public getFilter(userId: string, filterId: string): Filter | null {
return null; return null;
}, }
/** /**
* Retrieve a filter ID with the given name. * Retrieve a filter ID with the given name.
* @param {string} filterName The filter name. * @param {string} filterName The filter name.
* @return {?string} The filter ID or null. * @return {?string} The filter ID or null.
*/ */
getFilterIdByName: function(filterName) { public getFilterIdByName(filterName: string): string | null {
return null; return null;
}, }
/** /**
* Set a filter name to ID mapping. * Set a filter name to ID mapping.
* @param {string} filterName * @param {string} filterName
* @param {string} filterId * @param {string} filterId
*/ */
setFilterIdByName: function(filterName, filterId) { public setFilterIdByName(filterName: string, filterId: string) {}
},
/** /**
* Store user-scoped account data events * Store user-scoped account data events
* @param {Array<MatrixEvent>} events The events to store. * @param {Array<MatrixEvent>} events The events to store.
*/ */
storeAccountDataEvents: function(events) { public storeAccountDataEvents(events: MatrixEvent[]) {}
},
/** /**
* Get account data event by event type * Get account data event by event type
* @param {string} eventType The event type being queried * @param {string} eventType The event type being queried
*/ */
getAccountData: function(eventType) { public getAccountData(eventType: EventType | string): MatrixEvent | undefined {
return undefined;
}, }
/** /**
* setSyncData does nothing as there is no backing data store. * setSyncData does nothing as there is no backing data store.
@@ -218,75 +212,75 @@ StubStore.prototype = {
* @param {Object} syncData The sync data * @param {Object} syncData The sync data
* @return {Promise} An immediately resolved promise. * @return {Promise} An immediately resolved promise.
*/ */
setSyncData: function(syncData) { public setSyncData(syncData: object): Promise<void> {
return Promise.resolve(); 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 * @return {boolean} If the store wants to save
*/ */
wantsSave: function() { public wantsSave(): boolean {
return false; return false;
}, }
/** /**
* Save does nothing as there is no backing data store. * Save does nothing as there is no backing data store.
*/ */
save: function() {}, public save() {}
/** /**
* Startup does nothing. * Startup does nothing.
* @return {Promise} An immediately resolved promise. * @return {Promise} An immediately resolved promise.
*/ */
startup: function() { public startup(): Promise<void> {
return Promise.resolve(); return Promise.resolve();
}, }
/** /**
* @return {Promise} Resolves with a sync response to restore the * @return {Promise} Resolves with a sync response to restore the
* client state to where it was at the last save, or null if there * client state to where it was at the last save, or null if there
* is no saved sync data. * is no saved sync data.
*/ */
getSavedSync: function() { public getSavedSync(): Promise<object> {
return Promise.resolve(null); return Promise.resolve(null);
}, }
/** /**
* @return {Promise} If there is a saved sync, the nextBatch token * @return {Promise} If there is a saved sync, the nextBatch token
* for this sync, otherwise null. * for this sync, otherwise null.
*/ */
getSavedSyncToken: function() { public getSavedSyncToken(): Promise<string | null> {
return Promise.resolve(null); return Promise.resolve(null);
}, }
/** /**
* Delete all data from this store. Does nothing since this store * Delete all data from this store. Does nothing since this store
* doesn't store anything. * doesn't store anything.
* @return {Promise} An immediately resolved promise. * @return {Promise} An immediately resolved promise.
*/ */
deleteAllData: function() { public deleteAllData(): Promise<void> {
return Promise.resolve(); return Promise.resolve();
}, }
getOutOfBandMembers: function() { public getOutOfBandMembers(): Promise<MatrixEvent[]> {
return Promise.resolve(null); return Promise.resolve(null);
}, }
setOutOfBandMembers: function() { public setOutOfBandMembers(roomId: string, membershipEvents: MatrixEvent[]): Promise<void> {
return Promise.resolve(); return Promise.resolve();
}, }
clearOutOfBandMembers: function() { public clearOutOfBandMembers(): Promise<void> {
return Promise.resolve(); return Promise.resolve();
}, }
getClientOptions: function() { public getClientOptions(): Promise<object> {
return Promise.resolve(); return Promise.resolve({});
}, }
storeClientOptions: function() { public storeClientOptions(options: object): Promise<void> {
return Promise.resolve(); return Promise.resolve();
}, }
}; }

View File

@@ -1272,10 +1272,10 @@ SyncApi.prototype._processSyncResponse = async function(
if (e.isState() && e.getType() === "im.vector.user_status") { if (e.isState() && e.getType() === "im.vector.user_status") {
let user = client.store.getUser(e.getStateKey()); let user = client.store.getUser(e.getStateKey());
if (user) { if (user) {
user._unstable_updateStatusMessage(e); user.unstable_updateStatusMessage(e);
} else { } else {
user = createNewUser(client, e.getStateKey()); user = createNewUser(client, e.getStateKey());
user._unstable_updateStatusMessage(e); user.unstable_updateStatusMessage(e);
client.store.storeUser(user); client.store.storeUser(user);
} }
} }

View File

@@ -20,7 +20,8 @@ limitations under the License.
* @module utils * @module utils
*/ */
import unhomoglyph from 'unhomoglyph'; import unhomoglyph from "unhomoglyph";
import promiseRetry from "p-retry";
/** /**
* Encode a dictionary of query parameters. * Encode a dictionary of query parameters.
@@ -469,6 +470,26 @@ export async function chunkPromises<T>(fns: (() => Promise<T>)[], chunkSize: num
return results; 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<T>} The promise for the retried operation.
*/
export function simpleRetryOperation<T>(promiseFn: (attempt: number) => Promise<T>): Promise<T> {
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 // 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 // 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 // browsers. So `index.ts` will call `setCrypto` to store it, and when we need

View File

@@ -24,7 +24,7 @@ limitations under the License.
import { logger } from '../logger'; import { logger } from '../logger';
import { EventEmitter } from 'events'; import { EventEmitter } from 'events';
import * as utils from '../utils'; import * as utils from '../utils';
import MatrixEvent from '../models/event'; import { MatrixEvent } from '../models/event';
import { EventType } from '../@types/event'; import { EventType } from '../@types/event';
import { RoomMember } from '../models/room-member'; import { RoomMember } from '../models/room-member';
import { randomString } from '../randomstring'; import { randomString } from '../randomstring';

View File

@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import MatrixEvent from '../models/event'; import { MatrixEvent } from '../models/event';
import { logger } from '../logger'; import { logger } from '../logger';
import { createNewMatrixCall, MatrixCall, CallErrorCode, CallState, CallDirection } from './call'; import { createNewMatrixCall, MatrixCall, CallErrorCode, CallState, CallDirection } from './call';
import { EventType } from '../@types/event'; import { EventType } from '../@types/event';
@@ -244,7 +244,7 @@ export class CallEventHandler {
} else { } else {
call.onRemoteIceCandidatesReceived(event); 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 // 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 we've already rejected a call that would otherwise be valid
if (!call) { if (!call) {

View File

@@ -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": "@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" 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" 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": "@nicolo-ribaudo/chokidar-2@2.1.8-no-fsevents":
@@ -1305,6 +1306,11 @@
"@types/tough-cookie" "*" "@types/tough-cookie" "*"
form-data "^2.5.0" 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": "@types/stack-utils@^2.0.0":
version "2.0.0" version "2.0.0"
resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.0.tgz#7036640b4e21cc2f259ae826ce843d277dad8cff" resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.0.tgz#7036640b4e21cc2f259ae826ce843d277dad8cff"
@@ -4699,12 +4705,7 @@ lodash.sortby@^4.7.0:
resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438" resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438"
integrity sha1-7dFMgk4sycHgsKG0K7UhBRakJDg= integrity sha1-7dFMgk4sycHgsKG0K7UhBRakJDg=
lodash@^4.17.14, lodash@^4.17.19, lodash@^4.17.20: lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.17.4:
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:
version "4.17.21" version "4.17.21"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
@@ -5220,6 +5221,14 @@ p-locate@^4.1.0:
dependencies: dependencies:
p-limit "^2.2.0" 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: p-try@^2.0.0:
version "2.2.0" version "2.2.0"
resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6" resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6"
@@ -5943,6 +5952,11 @@ ret@~0.1.10:
resolved "https://registry.yarnpkg.com/ret/-/ret-0.1.15.tgz#b8a4825d5bdb1fc3f6f53c2bc33f81388681c7bc" resolved "https://registry.yarnpkg.com/ret/-/ret-0.1.15.tgz#b8a4825d5bdb1fc3f6f53c2bc33f81388681c7bc"
integrity sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg== 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: reusify@^1.0.4:
version "1.0.4" version "1.0.4"
resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76" resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76"