diff --git a/.github/workflows/preview_changelog.yaml b/.github/workflows/preview_changelog.yaml new file mode 100644 index 000000000..d68d19361 --- /dev/null +++ b/.github/workflows/preview_changelog.yaml @@ -0,0 +1,12 @@ +name: Preview Changelog +on: + pull_request_target: + types: [ opened, edited, labeled ] +jobs: + changelog: + runs-on: ubuntu-latest + steps: + - name: Preview Changelog + uses: matrix-org/allchange@main + with: + ghToken: ${{ secrets.GITHUB_TOKEN }} diff --git a/CHANGELOG.md b/CHANGELOG.md index a4f3eb273..541369b81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,28 @@ -Changes in [12.2.0](https://github.com/vector-im/element-desktop/releases/tag/v12.2.0) (2021-07-02) +Changes in [12.3.1](https://github.com/vector-im/element-desktop/releases/tag/v12.3.1) (2021-08-17) +=================================================================================================== + +## 🐛 Bug Fixes + * Fix multiple VoIP regressions ([\#1860](https://github.com/matrix-org/matrix-js-sdk/pull/1860)). + +Changes in [12.3.0](https://github.com/vector-im/element-desktop/releases/tag/v12.3.0) (2021-08-16) +=================================================================================================== + +## ✨ Features + * Support for MSC3291: Muting in VoIP calls ([\#1812](https://github.com/matrix-org/matrix-js-sdk/pull/1812)). Contributed by [SimonBrandner](https://github.com/SimonBrandner). + * Support for screen-sharing using multi-stream VoIP (MSC3077) ([\#1685](https://github.com/matrix-org/matrix-js-sdk/pull/1685)). Contributed by [SimonBrandner](https://github.com/SimonBrandner). + * Handle DTMF support ([\#1813](https://github.com/matrix-org/matrix-js-sdk/pull/1813)). Contributed by [SimonBrandner](https://github.com/SimonBrandner). + +## 🐛 Bug Fixes + * [Release] Fix glare related regressions ([\#1854](https://github.com/matrix-org/matrix-js-sdk/pull/1854)). Contributed by [SimonBrandner](https://github.com/SimonBrandner). + * Fix the types in shipped package ([\#1842](https://github.com/matrix-org/matrix-js-sdk/pull/1842)). Fixes vector-im/element-web#18503 and vector-im/element-web#18503. + * Fix error on turning off screensharing ([\#1833](https://github.com/matrix-org/matrix-js-sdk/pull/1833)). Fixes vector-im/element-web#18449. Contributed by [SimonBrandner](https://github.com/SimonBrandner). + * Fix blank profile in join events ([\#1837](https://github.com/matrix-org/matrix-js-sdk/pull/1837)). Fixes vector-im/element-web#18321. + * fix TURN by fixing regression preventing multiple ICE candidates from sending. ([\#1838](https://github.com/matrix-org/matrix-js-sdk/pull/1838)). + * Send `user_hangup` reason if the opponent supports it ([\#1820](https://github.com/matrix-org/matrix-js-sdk/pull/1820)). Fixes vector-im/element-web#18219. Contributed by [SimonBrandner](https://github.com/SimonBrandner). + * Apply hidden char check to rawDisplayName too ([\#1816](https://github.com/matrix-org/matrix-js-sdk/pull/1816)). + * Only clear bit 63 when we create the IV ([\#1819](https://github.com/matrix-org/matrix-js-sdk/pull/1819)). + +Changes in [12.2.0](https://github.com/vector-im/element-desktop/releases/tag/v12.2.0) (2021-08-02) =================================================================================================== ## ✨ Features diff --git a/package.json b/package.json index 607b21edb..aa06af85e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-js-sdk", - "version": "12.2.0", + "version": "12.3.1", "description": "Matrix Client-Server SDK for Javascript", "scripts": { "prepublishOnly": "yarn build", @@ -9,9 +9,9 @@ "clean": "rimraf lib dist", "build": "yarn build:dev && yarn build:compile-browser && yarn build:minify-browser", "build:dev": "yarn clean && git rev-parse HEAD > git-revision.txt && yarn build:compile && yarn build:types", - "build:types": "tsc --emitDeclarationOnly", + "build:types": "tsc -p tsconfig-build.json --emitDeclarationOnly", "build:compile": "babel -d lib --verbose --extensions \".ts,.js\" src", - "build:compile-browser": "mkdirp dist && browserify -d src/browser-index.js -p [ tsify -p ./tsconfig.json ] -t [ babelify --sourceMaps=inline --presets [ @babel/preset-env @babel/preset-typescript ] ] | exorcist dist/browser-matrix.js.map > dist/browser-matrix.js", + "build:compile-browser": "mkdirp dist && browserify -d src/browser-index.js -p [ tsify -p ./tsconfig-build.json ] -t [ babelify --sourceMaps=inline --presets [ @babel/preset-env @babel/preset-typescript ] ] | exorcist dist/browser-matrix.js.map > dist/browser-matrix.js", "build:minify-browser": "terser dist/browser-matrix.js --compress --mangle --source-map --output dist/browser-matrix.min.js", "gendoc": "jsdoc -c jsdoc.json -P package.json", "lint": "yarn lint:types && yarn lint:js", @@ -81,7 +81,7 @@ "@types/request": "^2.48.5", "@typescript-eslint/eslint-plugin": "^4.17.0", "@typescript-eslint/parser": "^4.17.0", - "allchange": "github:matrix-org/allchange", + "allchange": "^1.0.0", "babel-jest": "^26.6.3", "babelify": "^10.0.0", "better-docs": "^2.4.0-beta.9", diff --git a/release.sh b/release.sh index af42fe886..6754f18bb 100755 --- a/release.sh +++ b/release.sh @@ -191,7 +191,10 @@ git commit package.json $pkglock -m "$tag" # figure out if we should be signing this release signing_id= if [ -f release_config.yaml ]; then - signing_id=`cat release_config.yaml | python -c "import yaml; import sys; print yaml.load(sys.stdin)['signing_id']"` + result=`cat release_config.yaml | python -c "import yaml; import sys; print yaml.load(sys.stdin)['signing_id']" 2> /dev/null || true` + if [ "$?" -eq 0 ]; then + signing_id=$result + fi fi diff --git a/spec/unit/matrix-client.spec.js b/spec/unit/matrix-client.spec.js index 090ffeed1..82e166f67 100644 --- a/spec/unit/matrix-client.spec.js +++ b/spec/unit/matrix-client.spec.js @@ -237,6 +237,7 @@ describe("MatrixClient", function() { it("should get (unstable) file trees with valid state", async () => { const roomId = "!room:example.org"; const mockRoom = { + getMyMembership: () => "join", currentState: { getStateEvents: (eventType, stateKey) => { if (eventType === EventType.RoomCreate) { @@ -270,9 +271,33 @@ describe("MatrixClient", function() { expect(tree.room).toBe(mockRoom); }); + it("should not get (unstable) file trees if not joined", async () => { + const roomId = "!room:example.org"; + const mockRoom = { + getMyMembership: () => "leave", // "not join" + }; + client.getRoom = (getRoomId) => { + expect(getRoomId).toEqual(roomId); + return mockRoom; + }; + const tree = client.unstableGetFileTreeSpace(roomId); + expect(tree).toBeFalsy(); + }); + + it("should not get (unstable) file trees for unknown rooms", async () => { + const roomId = "!room:example.org"; + client.getRoom = (getRoomId) => { + expect(getRoomId).toEqual(roomId); + return null; // imply unknown + }; + const tree = client.unstableGetFileTreeSpace(roomId); + expect(tree).toBeFalsy(); + }); + it("should not get (unstable) file trees with invalid create contents", async () => { const roomId = "!room:example.org"; const mockRoom = { + getMyMembership: () => "join", currentState: { getStateEvents: (eventType, stateKey) => { if (eventType === EventType.RoomCreate) { @@ -307,6 +332,7 @@ describe("MatrixClient", function() { it("should not get (unstable) file trees with invalid purpose/subtype contents", async () => { const roomId = "!room:example.org"; const mockRoom = { + getMyMembership: () => "join", currentState: { getStateEvents: (eventType, stateKey) => { if (eventType === EventType.RoomCreate) { diff --git a/spec/unit/models/MSC3089Branch.spec.ts b/spec/unit/models/MSC3089Branch.spec.ts index fc8b35815..72454cc54 100644 --- a/spec/unit/models/MSC3089Branch.spec.ts +++ b/spec/unit/models/MSC3089Branch.spec.ts @@ -16,7 +16,6 @@ limitations under the License. import { MatrixClient } from "../../../src"; import { Room } from "../../../src/models/room"; -import { MatrixEvent } from "../../../src/models/event"; import { UNSTABLE_MSC3089_BRANCH } from "../../../src/@types/event"; import { EventTimelineSet } from "../../../src/models/event-timeline-set"; import { EventTimeline } from "../../../src/models/event-timeline"; @@ -25,7 +24,7 @@ import { MSC3089Branch } from "../../../src/models/MSC3089Branch"; describe("MSC3089Branch", () => { let client: MatrixClient; // @ts-ignore - TS doesn't know that this is a type - let indexEvent: MatrixEvent; + let indexEvent: any; let branch: MSC3089Branch; const branchRoomId = "!room:example.org"; @@ -47,10 +46,10 @@ describe("MSC3089Branch", () => { } }, }; - indexEvent = { + indexEvent = ({ getRoomId: () => branchRoomId, getStateKey: () => fileEventId, - }; + }); branch = new MSC3089Branch(client, indexEvent); }); diff --git a/spec/unit/models/MSC3089TreeSpace.spec.ts b/spec/unit/models/MSC3089TreeSpace.spec.ts index 951ab4c0e..ae9652e7d 100644 --- a/spec/unit/models/MSC3089TreeSpace.spec.ts +++ b/spec/unit/models/MSC3089TreeSpace.spec.ts @@ -29,7 +29,7 @@ import { MatrixError } from "../../../src/http-api"; describe("MSC3089TreeSpace", () => { let client: MatrixClient; - let room: Room; + let room: any; let tree: MSC3089TreeSpace; const roomId = "!tree:localhost"; const targetUser = "@target:example.org"; @@ -170,7 +170,7 @@ describe("MSC3089TreeSpace", () => { expect(userIds).toMatchObject([target]); return Promise.resolve(); }); - client.invite = () => Promise.resolve(); // we're not testing this here - see other tests + client.invite = () => Promise.resolve({}); // we're not testing this here - see other tests client.sendSharedHistoryKeys = sendKeysFn; // Mock the history check as best as possible @@ -198,7 +198,7 @@ describe("MSC3089TreeSpace", () => { expect(userIds).toMatchObject([target]); return Promise.resolve(); }); - client.invite = () => Promise.resolve(); // we're not testing this here - see other tests + client.invite = () => Promise.resolve({}); // we're not testing this here - see other tests client.sendSharedHistoryKeys = sendKeysFn; const historyVis = "joined"; // NOTE: Changed. @@ -446,9 +446,9 @@ describe("MSC3089TreeSpace", () => { // Danger: these are partial implementations for testing purposes only // @ts-ignore - "MatrixEvent is a value but used as a type", which is true but not important - let childState: { [roomId: string]: MatrixEvent[] } = {}; + let childState: { [roomId: string]: any[] } = {}; // @ts-ignore - "MatrixEvent is a value but used as a type", which is true but not important - let parentState: MatrixEvent[] = []; + let parentState: any[] = []; let parentRoom: Room; let childTrees: MSC3089TreeSpace[]; let rooms: { [roomId: string]: Room }; diff --git a/spec/unit/relations.spec.ts b/spec/unit/relations.spec.ts index 45c20c00c..27370fba0 100644 --- a/spec/unit/relations.spec.ts +++ b/spec/unit/relations.spec.ts @@ -16,11 +16,13 @@ limitations under the License. import { EventTimelineSet } from "../../src/models/event-timeline-set"; import { MatrixEvent } from "../../src/models/event"; +import { Room } from "../../src/models/room"; import { Relations } from "../../src/models/relations"; describe("Relations", function() { it("should deduplicate annotations", function() { - const relations = new Relations("m.annotation", "m.reaction"); + const room = new Room("room123", null, null); + const relations = new Relations("m.annotation", "m.reaction", room); // Create an instance of an annotation const eventData = { @@ -95,10 +97,8 @@ describe("Relations", function() { }); // Stub the room - const room = { - getPendingEvent() { return null; }, - getUnfilteredTimelineSet() { return null; }, - }; + + const room = new Room("room123", null, null); // Add the target event first, then the relation event { diff --git a/src/@types/PushRules.ts b/src/@types/PushRules.ts index a4eda1b48..fa404c43a 100644 --- a/src/@types/PushRules.ts +++ b/src/@types/PushRules.ts @@ -90,8 +90,9 @@ export interface ISenderNotificationPermissionCondition key: string; } -export type PushRuleCondition = IPushRuleCondition - | IEventMatchCondition +// XXX: custom conditions are possible but always fail, and break the typescript discriminated union so ignore them here +// IPushRuleCondition> unfortunately does not resolve this at the time of writing. +export type PushRuleCondition = IEventMatchCondition | IContainsDisplayNameCondition | IRoomMemberCountCondition | ISenderNotificationPermissionCondition; diff --git a/src/client.ts b/src/client.ts index ac7647479..058f22250 100644 --- a/src/client.ts +++ b/src/client.ts @@ -30,7 +30,7 @@ import * as utils from './utils'; import { sleep } from './utils'; import { Group } from "./models/group"; import { Direction, EventTimeline } from "./models/event-timeline"; -import { PushAction, PushProcessor } from "./pushprocessor"; +import { IActionsObject, PushProcessor } from "./pushprocessor"; import { AutoDiscovery } from "./autodiscovery"; import * as olmlib from "./crypto/olmlib"; import { decodeBase64, encodeBase64 } from "./crypto/olmlib"; @@ -2931,6 +2931,7 @@ export class MatrixClient extends EventEmitter { * has been emitted. * @param {string} groupId The group ID * @return {Group} The Group or null if the group is not known or there is no data store. + * @deprecated groups/communities never made it to the spec and support for them is being discontinued. */ public getGroup(groupId: string): Group { return this.store.getGroup(groupId); @@ -2939,6 +2940,7 @@ export class MatrixClient extends EventEmitter { /** * Retrieve all known groups. * @return {Group[]} A list of groups, or an empty list if there is no data store. + * @deprecated groups/communities never made it to the spec and support for them is being discontinued. */ public getGroups(): Group[] { return this.store.getGroups(); @@ -4340,7 +4342,7 @@ export class MatrixClient extends EventEmitter { * @param {MatrixEvent} event The event to get push actions for. * @return {module:pushprocessor~PushAction} A dict of actions to perform. */ - public getPushActionsForEvent(event: MatrixEvent): PushAction { + public getPushActionsForEvent(event: MatrixEvent): IActionsObject { if (!event.getPushActions()) { event.setPushActions(this.pushProcessor.actionsForEvent(event)); } @@ -5184,11 +5186,11 @@ export class MatrixClient extends EventEmitter { * The operation also updates MatrixClient.pushRules at the end. * @param {string} scope "global" or device-specific. * @param {string} roomId the id of the room. - * @param {string} mute the mute state. + * @param {boolean} mute the mute state. * @return {Promise} Resolves: result object * @return {module:http-api.MatrixError} Rejects: with an error response. */ - public setRoomMutePushRule(scope: string, roomId: string, mute: string): Promise | void { + public setRoomMutePushRule(scope: string, roomId: string, mute: boolean): Promise | void { let deferred; let hasDontNotifyRule; @@ -8060,7 +8062,7 @@ export class MatrixClient extends EventEmitter { */ public unstableGetFileTreeSpace(roomId: string): MSC3089TreeSpace { const room = this.getRoom(roomId); - if (!room) return null; + if (room?.getMyMembership() !== 'join') return null; const createEvent = room.currentState.getStateEvents(EventType.RoomCreate, ""); const purposeEvent = room.currentState.getStateEvents( @@ -8085,6 +8087,7 @@ export class MatrixClient extends EventEmitter { * @param {string} groupId * @return {Promise} Resolves: Group summary object * @return {module:http-api.MatrixError} Rejects: with an error response. + * @deprecated groups/communities never made it to the spec and support for them is being discontinued. */ public getGroupSummary(groupId: string): Promise { const path = utils.encodeUri("/groups/$groupId/summary", { $groupId: groupId }); @@ -8095,6 +8098,7 @@ export class MatrixClient extends EventEmitter { * @param {string} groupId * @return {Promise} Resolves: Group profile object * @return {module:http-api.MatrixError} Rejects: with an error response. + * @deprecated groups/communities never made it to the spec and support for them is being discontinued. */ public getGroupProfile(groupId: string): Promise { const path = utils.encodeUri("/groups/$groupId/profile", { $groupId: groupId }); @@ -8110,6 +8114,7 @@ export class MatrixClient extends EventEmitter { * @param {string=} profile.long_description A longer HTML description of the room * @return {Promise} Resolves: Empty object * @return {module:http-api.MatrixError} Rejects: with an error response. + * @deprecated groups/communities never made it to the spec and support for them is being discontinued. */ public setGroupProfile(groupId: string, profile: any): Promise { const path = utils.encodeUri("/groups/$groupId/profile", { $groupId: groupId }); @@ -8126,6 +8131,7 @@ export class MatrixClient extends EventEmitter { * required to join. * @return {Promise} Resolves: Empty object * @return {module:http-api.MatrixError} Rejects: with an error response. + * @deprecated groups/communities never made it to the spec and support for them is being discontinued. */ public setGroupJoinPolicy(groupId: string, policy: any): Promise { const path = utils.encodeUri( @@ -8143,6 +8149,7 @@ export class MatrixClient extends EventEmitter { * @param {string} groupId * @return {Promise} Resolves: Group users list object * @return {module:http-api.MatrixError} Rejects: with an error response. + * @deprecated groups/communities never made it to the spec and support for them is being discontinued. */ public getGroupUsers(groupId: string): Promise { const path = utils.encodeUri("/groups/$groupId/users", { $groupId: groupId }); @@ -8153,6 +8160,7 @@ export class MatrixClient extends EventEmitter { * @param {string} groupId * @return {Promise} Resolves: Group users list object * @return {module:http-api.MatrixError} Rejects: with an error response. + * @deprecated groups/communities never made it to the spec and support for them is being discontinued. */ public getGroupInvitedUsers(groupId: string): Promise { const path = utils.encodeUri("/groups/$groupId/invited_users", { $groupId: groupId }); @@ -8163,6 +8171,7 @@ export class MatrixClient extends EventEmitter { * @param {string} groupId * @return {Promise} Resolves: Group rooms list object * @return {module:http-api.MatrixError} Rejects: with an error response. + * @deprecated groups/communities never made it to the spec and support for them is being discontinued. */ public getGroupRooms(groupId: string): Promise { const path = utils.encodeUri("/groups/$groupId/rooms", { $groupId: groupId }); @@ -8174,6 +8183,7 @@ export class MatrixClient extends EventEmitter { * @param {string} userId * @return {Promise} Resolves: Empty object * @return {module:http-api.MatrixError} Rejects: with an error response. + * @deprecated groups/communities never made it to the spec and support for them is being discontinued. */ public inviteUserToGroup(groupId: string, userId: string): Promise { const path = utils.encodeUri( @@ -8188,6 +8198,7 @@ export class MatrixClient extends EventEmitter { * @param {string} userId * @return {Promise} Resolves: Empty object * @return {module:http-api.MatrixError} Rejects: with an error response. + * @deprecated groups/communities never made it to the spec and support for them is being discontinued. */ public removeUserFromGroup(groupId: string, userId: string): Promise { const path = utils.encodeUri( @@ -8203,6 +8214,7 @@ export class MatrixClient extends EventEmitter { * @param {string} roleId Optional. * @return {Promise} Resolves: Empty object * @return {module:http-api.MatrixError} Rejects: with an error response. + * @deprecated groups/communities never made it to the spec and support for them is being discontinued. */ public addUserToGroupSummary(groupId: string, userId: string, roleId: string): Promise { const path = utils.encodeUri( @@ -8219,6 +8231,7 @@ export class MatrixClient extends EventEmitter { * @param {string} userId * @return {Promise} Resolves: Empty object * @return {module:http-api.MatrixError} Rejects: with an error response. + * @deprecated groups/communities never made it to the spec and support for them is being discontinued. */ public removeUserFromGroupSummary(groupId: string, userId: string): Promise { const path = utils.encodeUri( @@ -8234,6 +8247,7 @@ export class MatrixClient extends EventEmitter { * @param {string} categoryId Optional. * @return {Promise} Resolves: Empty object * @return {module:http-api.MatrixError} Rejects: with an error response. + * @deprecated groups/communities never made it to the spec and support for them is being discontinued. */ public addRoomToGroupSummary(groupId: string, roomId: string, categoryId: string): Promise { const path = utils.encodeUri( @@ -8250,6 +8264,7 @@ export class MatrixClient extends EventEmitter { * @param {string} roomId * @return {Promise} Resolves: Empty object * @return {module:http-api.MatrixError} Rejects: with an error response. + * @deprecated groups/communities never made it to the spec and support for them is being discontinued. */ public removeRoomFromGroupSummary(groupId: string, roomId: string): Promise { const path = utils.encodeUri( @@ -8265,6 +8280,7 @@ export class MatrixClient extends EventEmitter { * @param {boolean} isPublic Whether the room-group association is visible to non-members * @return {Promise} Resolves: Empty object * @return {module:http-api.MatrixError} Rejects: with an error response. + * @deprecated groups/communities never made it to the spec and support for them is being discontinued. */ public addRoomToGroup(groupId: string, roomId: string, isPublic: boolean): Promise { if (isPublic === undefined) { @@ -8286,6 +8302,7 @@ export class MatrixClient extends EventEmitter { * @param {boolean} isPublic Whether the room-group association is visible to non-members * @return {Promise} Resolves: Empty object * @return {module:http-api.MatrixError} Rejects: with an error response. + * @deprecated groups/communities never made it to the spec and support for them is being discontinued. */ public updateGroupRoomVisibility(groupId: string, roomId: string, isPublic: boolean): Promise { // NB: The /config API is generic but there's not much point in exposing this yet as synapse @@ -8306,6 +8323,7 @@ export class MatrixClient extends EventEmitter { * @param {string} roomId * @return {Promise} Resolves: Empty object * @return {module:http-api.MatrixError} Rejects: with an error response. + * @deprecated groups/communities never made it to the spec and support for them is being discontinued. */ public removeRoomFromGroup(groupId: string, roomId: string): Promise { const path = utils.encodeUri( @@ -8320,6 +8338,7 @@ export class MatrixClient extends EventEmitter { * @param {Object} opts Additional options to send alongside the acceptance. * @return {Promise} Resolves: Empty object * @return {module:http-api.MatrixError} Rejects: with an error response. + * @deprecated groups/communities never made it to the spec and support for them is being discontinued. */ public acceptGroupInvite(groupId: string, opts = null): Promise { const path = utils.encodeUri( @@ -8333,6 +8352,7 @@ export class MatrixClient extends EventEmitter { * @param {string} groupId * @return {Promise} Resolves: Empty object * @return {module:http-api.MatrixError} Rejects: with an error response. + * @deprecated groups/communities never made it to the spec and support for them is being discontinued. */ public joinGroup(groupId: string): Promise { const path = utils.encodeUri( @@ -8346,6 +8366,7 @@ export class MatrixClient extends EventEmitter { * @param {string} groupId * @return {Promise} Resolves: Empty object * @return {module:http-api.MatrixError} Rejects: with an error response. + * @deprecated groups/communities never made it to the spec and support for them is being discontinued. */ public leaveGroup(groupId: string): Promise { const path = utils.encodeUri( @@ -8358,6 +8379,7 @@ export class MatrixClient extends EventEmitter { /** * @return {Promise} Resolves: The groups to which the user is joined * @return {module:http-api.MatrixError} Rejects: with an error response. + * @deprecated groups/communities never made it to the spec and support for them is being discontinued. */ public getJoinedGroups(): Promise { const path = utils.encodeUri("/joined_groups", {}); @@ -8370,6 +8392,7 @@ export class MatrixClient extends EventEmitter { * @param {Object} content.profile Group profile object * @return {Promise} Resolves: Object with key group_id: id of the created group * @return {module:http-api.MatrixError} Rejects: with an error response. + * @deprecated groups/communities never made it to the spec and support for them is being discontinued. */ public createGroup(content: any): Promise { const path = utils.encodeUri("/create_group", {}); @@ -8390,6 +8413,7 @@ export class MatrixClient extends EventEmitter { * } * } * @return {module:http-api.MatrixError} Rejects: with an error response. + * @deprecated groups/communities never made it to the spec and support for them is being discontinued. */ public getPublicisedGroups(userIds: string[]): Promise { const path = utils.encodeUri("/publicised_groups", {}); @@ -8403,6 +8427,7 @@ export class MatrixClient extends EventEmitter { * @param {boolean} isPublic Whether the user's membership of this group is made public * @return {Promise} Resolves: Empty object * @return {module:http-api.MatrixError} Rejects: with an error response. + * @deprecated groups/communities never made it to the spec and support for them is being discontinued. */ public setGroupPublicity(groupId: string, isPublic: boolean): Promise { const path = utils.encodeUri( @@ -8567,6 +8592,7 @@ export class MatrixClient extends EventEmitter { * is experimental and may change. * @event module:client~MatrixClient#"Group" * @param {Group} group The newly created, fully populated group. + * @deprecated groups/communities never made it to the spec and support for them is being discontinued. * @example * matrixClient.on("Group", function(group){ * var groupId = group.groupId; diff --git a/src/crypto/EncryptionSetup.ts b/src/crypto/EncryptionSetup.ts index faa4bd2e8..1a36efb70 100644 --- a/src/crypto/EncryptionSetup.ts +++ b/src/crypto/EncryptionSetup.ts @@ -356,7 +356,7 @@ class SSSSCryptoCallbacks { public async getSecretStorageKey( { keys }: { keys: Record }, name: string, - ): Promise<[string, Uint8Array]> { + ): Promise<[string, Uint8Array]|null> { for (const keyId of Object.keys(keys)) { const privateKey = this.privateKeys.get(keyId); if (privateKey) { @@ -374,6 +374,7 @@ class SSSSCryptoCallbacks { } return result; } + return null; } public addPrivateKey(keyId: string, keyInfo: ISecretStorageKeyInfo, privKey: Uint8Array): void { diff --git a/src/interactive-auth.js b/src/interactive-auth.ts similarity index 58% rename from src/interactive-auth.js rename to src/interactive-auth.ts index 1f247388f..28829e3f1 100644 --- a/src/interactive-auth.js +++ b/src/interactive-auth.ts @@ -20,10 +20,90 @@ limitations under the License. import * as utils from "./utils"; import { logger } from './logger'; +import { MatrixClient } from "./client"; +import { defer, IDeferred } from "./utils"; +import { MatrixError } from "./http-api"; const EMAIL_STAGE_TYPE = "m.login.email.identity"; const MSISDN_STAGE_TYPE = "m.login.msisdn"; +interface IFlow { + stages: AuthType[]; +} + +export interface IInputs { + emailAddress?: string; + phoneCountry?: string; + phoneNumber?: string; +} + +export interface IStageStatus { + emailSid?: string; + errcode?: string; + error?: string; +} + +export interface IAuthData { + session?: string; + completed?: string[]; + flows?: IFlow[]; + params?: Record>; + errcode?: string; + error?: MatrixError; +} + +export enum AuthType { + Password = "m.login.password", + Recaptcha = "m.login.recaptcha", + Terms = "m.login.terms", + Email = "m.login.email.identity", + Msisdn = "m.login.msisdn", + Sso = "m.login.sso", + SsoUnstable = "org.matrix.login.sso", + Dummy = "m.login.dummy", +} + +export interface IAuthDict { + // [key: string]: any; + type?: string; + // session?: string; // TODO + // TODO: Remove `user` once servers support proper UIA + // See https://github.com/vector-im/element-web/issues/10312 + user?: string; + identifier?: any; + password?: string; + response?: string; + // TODO: Remove `threepid_creds` once servers support proper UIA + // See https://github.com/vector-im/element-web/issues/10312 + // See https://github.com/matrix-org/matrix-doc/issues/2220 + // eslint-disable-next-line camelcase + threepid_creds?: any; + threepidCreds?: any; +} + +class NoAuthFlowFoundError extends Error { + public name = "NoAuthFlowFoundError"; + + // eslint-disable-next-line @typescript-eslint/naming-convention, camelcase + constructor(m: string, public readonly required_stages: string[], public readonly flows: IFlow[]) { + super(m); + } +} + +interface IOpts { + matrixClient: MatrixClient; + authData?: IAuthData; + inputs?: IInputs; + sessionId?: string; + clientSecret?: string; + emailSid?: string; + doRequest(auth: IAuthData, background: boolean): Promise; + stateUpdated(nextStage: AuthType, status: IStageStatus): void; + requestEmailToken(email: string, secret: string, attempt: number, session: string): Promise<{ sid: string }>; + busyChanged?(busy: boolean): void; + startAuthStage?(nextStage: string): Promise; // LEGACY +} + /** * Abstracts the logic used to drive the interactive auth process. * @@ -50,12 +130,12 @@ const MSISDN_STAGE_TYPE = "m.login.msisdn"; * called with the new auth dict to submit the request. Also passes a * second deprecated arg which is a flag set to true if this request * is a background request. The busyChanged callback should be used - * instead of the backfround flag. Should return a promise which resolves + * instead of the background flag. Should return a promise which resolves * to the successful response or rejects with a MatrixError. * - * @param {function(bool): Promise} opts.busyChanged + * @param {function(boolean): Promise} opts.busyChanged * called whenever the interactive auth logic becomes busy submitting - * information provided by the user or finsihes. After this has been + * information provided by the user or finishes. After this has been * called with true the UI should indicate that a request is in progress * until it is called again with false. * @@ -101,33 +181,41 @@ const MSISDN_STAGE_TYPE = "m.login.msisdn"; * attemptAuth promise. * */ -export function InteractiveAuth(opts) { - this._matrixClient = opts.matrixClient; - this._data = opts.authData || {}; - this._requestCallback = opts.doRequest; - this._busyChangedCallback = opts.busyChanged; - // startAuthStage included for backwards compat - this._stateUpdatedCallback = opts.stateUpdated || opts.startAuthStage; - this._resolveFunc = null; - this._rejectFunc = null; - this._inputs = opts.inputs || {}; - this._requestEmailTokenCallback = opts.requestEmailToken; +export class InteractiveAuth { + private readonly matrixClient: MatrixClient; + private readonly inputs: IInputs; + private readonly clientSecret: string; + private readonly requestCallback: IOpts["doRequest"]; + private readonly busyChangedCallback?: IOpts["busyChanged"]; + private readonly stateUpdatedCallback: IOpts["stateUpdated"]; + private readonly requestEmailTokenCallback: IOpts["requestEmailToken"]; - if (opts.sessionId) this._data.session = opts.sessionId; - this._clientSecret = opts.clientSecret || this._matrixClient.generateClientSecret(); - this._emailSid = opts.emailSid; - if (this._emailSid === undefined) this._emailSid = null; - this._requestingEmailToken = false; - - this._chosenFlow = null; - this._currentStage = null; + private data: IAuthData; + private emailSid?: string; + private requestingEmailToken = false; + private attemptAuthDeferred: IDeferred = null; + private chosenFlow: IFlow = null; + private currentStage: string = null; // if we are currently trying to submit an auth dict (which includes polling) // the promise the will resolve/reject when it completes - this._submitPromise = null; -} + private submitPromise: Promise = null; + + constructor(opts: IOpts) { + this.matrixClient = opts.matrixClient; + this.data = opts.authData || {}; + this.requestCallback = opts.doRequest; + this.busyChangedCallback = opts.busyChanged; + // startAuthStage included for backwards compat + this.stateUpdatedCallback = opts.stateUpdated || opts.startAuthStage; + this.requestEmailTokenCallback = opts.requestEmailToken; + this.inputs = opts.inputs || {}; + + if (opts.sessionId) this.data.session = opts.sessionId; + this.clientSecret = opts.clientSecret || this.matrixClient.generateClientSecret(); + this.emailSid = opts.emailSid ?? null; + } -InteractiveAuth.prototype = { /** * begin the authentication process. * @@ -135,58 +223,57 @@ InteractiveAuth.prototype = { * or rejects with the error on failure. Rejects with NoAuthFlowFoundError if * no suitable authentication flow can be found */ - attemptAuth: function() { + public attemptAuth(): Promise { // This promise will be quite long-lived and will resolve when the // request is authenticated and completes successfully. - return new Promise((resolve, reject) => { - this._resolveFunc = resolve; - this._rejectFunc = reject; + this.attemptAuthDeferred = defer(); + // pluck the promise out now, as doRequest may clear before we return + const promise = this.attemptAuthDeferred.promise; - const hasFlows = this._data && this._data.flows; - - // if we have no flows, try a request to acquire the flows - if (!hasFlows) { - if (this._busyChangedCallback) this._busyChangedCallback(true); - // use the existing sessionid, if one is present. - let auth = null; - if (this._data.session) { - auth = { - session: this._data.session, - }; - } - this._doRequest(auth).finally(() => { - if (this._busyChangedCallback) this._busyChangedCallback(false); - }); - } else { - this._startNextAuthStage(); + // if we have no flows, try a request to acquire the flows + if (!this.data?.flows) { + this.busyChangedCallback?.(true); + // use the existing sessionId, if one is present. + let auth = null; + if (this.data.session) { + auth = { + session: this.data.session, + }; } - }); - }, + this.doRequest(auth).finally(() => { + this.busyChangedCallback?.(false); + }); + } else { + this.startNextAuthStage(); + } + + return promise; + } /** * Poll to check if the auth session or current stage has been * completed out-of-band. If so, the attemptAuth promise will * be resolved. */ - poll: async function() { - if (!this._data.session) return; + public async poll(): Promise { + if (!this.data.session) return; // likewise don't poll if there is no auth session in progress - if (!this._resolveFunc) return; + if (!this.attemptAuthDeferred) return; // if we currently have a request in flight, there's no point making // another just to check what the status is - if (this._submitPromise) return; + if (this.submitPromise) return; - let authDict = {}; - if (this._currentStage == EMAIL_STAGE_TYPE) { + let authDict: IAuthDict = {}; + if (this.currentStage == EMAIL_STAGE_TYPE) { // The email can be validated out-of-band, but we need to provide the // creds so the HS can go & check it. - if (this._emailSid) { - const creds = { - sid: this._emailSid, - client_secret: this._clientSecret, + if (this.emailSid) { + const creds: Record = { + sid: this.emailSid, + client_secret: this.clientSecret, }; - if (await this._matrixClient.doesServerRequireIdServerParam()) { - const idServerParsedUrl = new URL(this._matrixClient.getIdentityServerUrl()); + if (await this.matrixClient.doesServerRequireIdServerParam()) { + const idServerParsedUrl = new URL(this.matrixClient.getIdentityServerUrl()); creds.id_server = idServerParsedUrl.host; } authDict = { @@ -201,16 +288,16 @@ InteractiveAuth.prototype = { } this.submitAuthDict(authDict, true); - }, + } /** * get the auth session ID * * @return {string} session id */ - getSessionId: function() { - return this._data ? this._data.session : undefined; - }, + public getSessionId(): string { + return this.data ? this.data.session : undefined; + } /** * get the client secret used for validation sessions @@ -218,9 +305,9 @@ InteractiveAuth.prototype = { * * @return {string} client secret */ - getClientSecret: function() { - return this._clientSecret; - }, + public getClientSecret(): string { + return this.clientSecret; + } /** * get the server params for a given stage @@ -228,17 +315,13 @@ InteractiveAuth.prototype = { * @param {string} loginType login type for the stage * @return {object?} any parameters from the server for this stage */ - getStageParams: function(loginType) { - let params = {}; - if (this._data && this._data.params) { - params = this._data.params; - } - return params[loginType]; - }, + public getStageParams(loginType: string): Record { + return this.data.params?.[loginType]; + } - getChosenFlow() { - return this._chosenFlow; - }, + public getChosenFlow(): IFlow { + return this.chosenFlow; + } /** * submit a new auth dict and fire off the request. This will either @@ -246,38 +329,38 @@ InteractiveAuth.prototype = { * to be called for a new stage. * * @param {object} authData new auth dict to send to the server. Should - * include a `type` propterty denoting the login type, as well as any + * include a `type` property denoting the login type, as well as any * other params for that stage. - * @param {bool} background If true, this request failing will not result + * @param {boolean} background If true, this request failing will not result * in the attemptAuth promise being rejected. This can be set to true * for requests that just poll to see if auth has been completed elsewhere. */ - submitAuthDict: async function(authData, background) { - if (!this._resolveFunc) { + public async submitAuthDict(authData: IAuthDict, background = false): Promise { + if (!this.attemptAuthDeferred) { throw new Error("submitAuthDict() called before attemptAuth()"); } - if (!background && this._busyChangedCallback) { - this._busyChangedCallback(true); + if (!background) { + this.busyChangedCallback?.(true); } // if we're currently trying a request, wait for it to finish // as otherwise we can get multiple 200 responses which can mean // things like multiple logins for register requests. - // (but discard any expections as we only care when its done, + // (but discard any exceptions as we only care when its done, // not whether it worked or not) - while (this._submitPromise) { + while (this.submitPromise) { try { - await this._submitPromise; + await this.submitPromise; } catch (e) { } } // use the sessionid from the last request, if one is present. let auth; - if (this._data.session) { + if (this.data.session) { auth = { - session: this._data.session, + session: this.data.session, }; utils.extend(auth, authData); } else { @@ -287,15 +370,15 @@ InteractiveAuth.prototype = { try { // NB. the 'background' flag is deprecated by the busyChanged // callback and is here for backwards compat - this._submitPromise = this._doRequest(auth, background); - await this._submitPromise; + this.submitPromise = this.doRequest(auth, background); + await this.submitPromise; } finally { - this._submitPromise = null; - if (!background && this._busyChangedCallback) { - this._busyChangedCallback(false); + this.submitPromise = null; + if (!background) { + this.busyChangedCallback?.(false); } } - }, + } /** * Gets the sid for the email validation session @@ -303,9 +386,9 @@ InteractiveAuth.prototype = { * * @returns {string} The sid of the email auth session */ - getEmailSid: function() { - return this._emailSid; - }, + public getEmailSid(): string { + return this.emailSid; + } /** * Sets the sid for the email validation session @@ -315,9 +398,9 @@ InteractiveAuth.prototype = { * * @param {string} sid The sid for the email validation session */ - setEmailSid: function(sid) { - this._emailSid = sid; - }, + public setEmailSid(sid: string): void { + this.emailSid = sid; + } /** * Fire off a request, and either resolve the promise, or call @@ -325,33 +408,29 @@ InteractiveAuth.prototype = { * * @private * @param {object?} auth new auth dict, including session id - * @param {bool?} background If true, this request is a background poll, so it + * @param {boolean?} background If true, this request is a background poll, so it * failing will not result in the attemptAuth promise being rejected. * This can be set to true for requests that just poll to see if auth has * been completed elsewhere. */ - _doRequest: async function(auth, background) { + private async doRequest(auth: IAuthData, background = false): Promise { try { - const result = await this._requestCallback(auth, background); - this._resolveFunc(result); - this._resolveFunc = null; - this._rejectFunc = null; + const result = await this.requestCallback(auth, background); + this.attemptAuthDeferred.resolve(result); + this.attemptAuthDeferred = null; } catch (error) { // sometimes UI auth errors don't come with flows - const errorFlows = error.data ? error.data.flows : null; - const haveFlows = this._data.flows || Boolean(errorFlows); + const errorFlows = error.data?.flows ?? null; + const haveFlows = this.data.flows || Boolean(errorFlows); if (error.httpStatus !== 401 || !error.data || !haveFlows) { // doesn't look like an interactive-auth failure. if (!background) { - this._rejectFunc(error); + this.attemptAuthDeferred?.reject(error); } else { // We ignore all failures here (even non-UI auth related ones) // since we don't want to suddenly fail if the internet connection // had a blip whilst we were polling - logger.log( - "Background poll request failed doing UI auth: ignoring", - error, - ); + logger.log("Background poll request failed doing UI auth: ignoring", error); } } // if the error didn't come with flows, completed flows or session ID, @@ -360,37 +439,36 @@ InteractiveAuth.prototype = { // has not yet been validated). This appears to be a Synapse bug, which // we workaround here. if (!error.data.flows && !error.data.completed && !error.data.session) { - error.data.flows = this._data.flows; - error.data.completed = this._data.completed; - error.data.session = this._data.session; + error.data.flows = this.data.flows; + error.data.completed = this.data.completed; + error.data.session = this.data.session; } - this._data = error.data; + this.data = error.data; try { - this._startNextAuthStage(); + this.startNextAuthStage(); } catch (e) { - this._rejectFunc(e); - this._resolveFunc = null; - this._rejectFunc = null; + this.attemptAuthDeferred.reject(e); + this.attemptAuthDeferred = null; } if ( - !this._emailSid && - !this._requestingEmailToken && - this._chosenFlow.stages.includes('m.login.email.identity') + !this.emailSid && + !this.requestingEmailToken && + this.chosenFlow.stages.includes(AuthType.Email) ) { // If we've picked a flow with email auth, we send the email // now because we want the request to fail as soon as possible // if the email address is not valid (ie. already taken or not // registered, depending on what the operation is). - this._requestingEmailToken = true; + this.requestingEmailToken = true; try { - const requestTokenResult = await this._requestEmailTokenCallback( - this._inputs.emailAddress, - this._clientSecret, + const requestTokenResult = await this.requestEmailTokenCallback( + this.inputs.emailAddress, + this.clientSecret, 1, // TODO: Multiple send attempts? - this._data.session, + this.data.session, ); - this._emailSid = requestTokenResult.sid; + this.emailSid = requestTokenResult.sid; // NB. promise is not resolved here - at some point, doRequest // will be called again and if the user has jumped through all // the hoops correctly, auth will be complete and the request @@ -404,15 +482,14 @@ InteractiveAuth.prototype = { // to do) or it could be a network failure. Either way, pass // the failure up as the user can't complete auth if we can't // send the email, for whatever reason. - this._rejectFunc(e); - this._resolveFunc = null; - this._rejectFunc = null; + this.attemptAuthDeferred.reject(e); + this.attemptAuthDeferred = null; } finally { - this._requestingEmailToken = false; + this.requestingEmailToken = false; } } } - }, + } /** * Pick the next stage and call the callback @@ -420,34 +497,34 @@ InteractiveAuth.prototype = { * @private * @throws {NoAuthFlowFoundError} If no suitable authentication flow can be found */ - _startNextAuthStage: function() { - const nextStage = this._chooseStage(); + private startNextAuthStage(): void { + const nextStage = this.chooseStage(); if (!nextStage) { throw new Error("No incomplete flows from the server"); } - this._currentStage = nextStage; + this.currentStage = nextStage; - if (nextStage === 'm.login.dummy') { + if (nextStage === AuthType.Dummy) { this.submitAuthDict({ type: 'm.login.dummy', }); return; } - if (this._data && this._data.errcode || this._data.error) { - this._stateUpdatedCallback(nextStage, { - errcode: this._data.errcode || "", - error: this._data.error || "", + if (this.data && this.data.errcode || this.data.error) { + this.stateUpdatedCallback(nextStage, { + errcode: this.data.errcode || "", + error: this.data.error || "", }); return; } - const stageStatus = {}; + const stageStatus: IStageStatus = {}; if (nextStage == EMAIL_STAGE_TYPE) { - stageStatus.emailSid = this._emailSid; + stageStatus.emailSid = this.emailSid; } - this._stateUpdatedCallback(nextStage, stageStatus); - }, + this.stateUpdatedCallback(nextStage, stageStatus); + } /** * Pick the next auth stage @@ -456,15 +533,15 @@ InteractiveAuth.prototype = { * @return {string?} login type * @throws {NoAuthFlowFoundError} If no suitable authentication flow can be found */ - _chooseStage: function() { - if (this._chosenFlow === null) { - this._chosenFlow = this._chooseFlow(); + private chooseStage(): AuthType { + if (this.chosenFlow === null) { + this.chosenFlow = this.chooseFlow(); } - logger.log("Active flow => %s", JSON.stringify(this._chosenFlow)); - const nextStage = this._firstUncompletedStage(this._chosenFlow); + logger.log("Active flow => %s", JSON.stringify(this.chosenFlow)); + const nextStage = this.firstUncompletedStage(this.chosenFlow); logger.log("Next stage: %s", nextStage); return nextStage; - }, + } /** * Pick one of the flows from the returned list @@ -472,7 +549,7 @@ InteractiveAuth.prototype = { * be returned, otherwise, null will be returned. * * Only flows using all given inputs are chosen because it - * is likley to be surprising if the user provides a + * is likely to be surprising if the user provides a * credential and it is not used. For example, for registration, * this could result in the email not being used which would leave * the account with no means to reset a password. @@ -481,14 +558,14 @@ InteractiveAuth.prototype = { * @return {object} flow * @throws {NoAuthFlowFoundError} If no suitable authentication flow can be found */ - _chooseFlow: function() { - const flows = this._data.flows || []; + private chooseFlow(): IFlow { + const flows = this.data.flows || []; // we've been given an email or we've already done an email part - const haveEmail = Boolean(this._inputs.emailAddress) || Boolean(this._emailSid); + const haveEmail = Boolean(this.inputs.emailAddress) || Boolean(this.emailSid); const haveMsisdn = ( - Boolean(this._inputs.phoneCountry) && - Boolean(this._inputs.phoneNumber) + Boolean(this.inputs.phoneCountry) && + Boolean(this.inputs.phoneNumber) ); for (const flow of flows) { @@ -506,16 +583,14 @@ InteractiveAuth.prototype = { return flow; } } + + const requiredStages: string[] = []; + if (haveEmail) requiredStages.push(EMAIL_STAGE_TYPE); + if (haveMsisdn) requiredStages.push(MSISDN_STAGE_TYPE); // Throw an error with a fairly generic description, but with more // information such that the app can give a better one if so desired. - const err = new Error("No appropriate authentication flow found"); - err.name = 'NoAuthFlowFoundError'; - err.required_stages = []; - if (haveEmail) err.required_stages.push(EMAIL_STAGE_TYPE); - if (haveMsisdn) err.required_stages.push(MSISDN_STAGE_TYPE); - err.available_flows = flows; - throw err; - }, + throw new NoAuthFlowFoundError("No appropriate authentication flow found", requiredStages, flows); + } /** * Get the first uncompleted stage in the given flow @@ -524,14 +599,13 @@ InteractiveAuth.prototype = { * @param {object} flow * @return {string} login type */ - _firstUncompletedStage: function(flow) { - const completed = (this._data || {}).completed || []; + private firstUncompletedStage(flow: IFlow): AuthType { + const completed = this.data.completed || []; for (let i = 0; i < flow.stages.length; ++i) { const stageType = flow.stages[i]; if (completed.indexOf(stageType) === -1) { return stageType; } } - }, -}; - + } +} diff --git a/src/models/MSC3089Branch.ts b/src/models/MSC3089Branch.ts index 7bb8c356e..875f9bd7d 100644 --- a/src/models/MSC3089Branch.ts +++ b/src/models/MSC3089Branch.ts @@ -82,6 +82,19 @@ export class MSC3089Branch { * @returns {Promise<{info: IEncryptedFile, httpUrl: string}>} Information about the file. */ public async getFileInfo(): Promise<{ info: IEncryptedFile, httpUrl: string }> { + const event = await this.getFileEvent(); + + const file = event.getContent()['file']; + const httpUrl = this.client.mxcUrlToHttp(file['url']); + + return { info: file, httpUrl: httpUrl }; + } + + /** + * Gets the event the file points to. + * @returns {Promise} Resolves to the file's event. + */ + public async getFileEvent(): Promise { const room = this.client.getRoom(this.roomId); if (!room) throw new Error("Unknown room"); @@ -94,9 +107,6 @@ export class MSC3089Branch { // Sometimes the event context doesn't decrypt for us, so do that. await this.client.decryptEventIfNeeded(event, { emit: false, isRetry: false }); - const file = event.getContent()['file']; - const httpUrl = this.client.mxcUrlToHttp(file['url']); - - return { info: file, httpUrl: httpUrl }; + return event; } } diff --git a/src/models/MSC3089TreeSpace.ts b/src/models/MSC3089TreeSpace.ts index 20585eab0..80ea2b365 100644 --- a/src/models/MSC3089TreeSpace.ts +++ b/src/models/MSC3089TreeSpace.ts @@ -193,6 +193,28 @@ export class MSC3089TreeSpace { await this.client.sendStateEvent(this.roomId, EventType.RoomPowerLevels, pls, ""); } + /** + * Gets the current permissions of a user. Note that any users missing explicit permissions (or not + * in the space) will be considered Viewers. Appropriate membership checks need to be performed + * elsewhere. + * @param {string} userId The user ID to check permissions of. + * @returns {TreePermissions} The permissions for the user, defaulting to Viewer. + */ + public getPermissions(userId: string): TreePermissions { + const currentPls = this.room.currentState.getStateEvents(EventType.RoomPowerLevels, ""); + if (Array.isArray(currentPls)) throw new Error("Unexpected return type for power levels"); + + const pls = currentPls.getContent() || {}; + const viewLevel = pls['users_default'] || 0; + const editLevel = pls['events_default'] || 50; + const adminLevel = pls['events']?.[EventType.RoomPowerLevels] || 100; + + const userLevel = pls['users']?.[userId] || viewLevel; + if (userLevel >= adminLevel) return TreePermissions.Owner; + if (userLevel >= editLevel) return TreePermissions.Editor; + return TreePermissions.Viewer; + } + /** * Creates a directory under this tree space, represented as another tree space. * @param {string} name The name for the directory. diff --git a/src/models/event-timeline.ts b/src/models/event-timeline.ts index c89da5c0b..819469bcd 100644 --- a/src/models/event-timeline.ts +++ b/src/models/event-timeline.ts @@ -50,13 +50,15 @@ export class EventTimeline { * @param {boolean} toStartOfTimeline if true the event's forwardLooking flag is set false */ static setEventMetadata(event: MatrixEvent, stateContext: RoomState, toStartOfTimeline: boolean): void { - // We always check if the event doesn't already have the property. We do - // this to avoid overriding non-sentinel members by sentinel ones when - // adding the event to a filtered timeline - if (!event.sender) { + // When we try to generate a sentinel member before we have that member + // in the members object, we still generate a sentinel but it doesn't + // have a membership event, so test to see if events.member is set. We + // check this to avoid overriding non-sentinel members by sentinel ones + // when adding the event to a filtered timeline + if (!event.sender?.events?.member) { event.sender = stateContext.getSentinelMember(event.getSender()); } - if (!event.target && event.getType() === EventType.RoomMember) { + if (!event.target?.events?.member && event.getType() === EventType.RoomMember) { event.target = stateContext.getSentinelMember(event.getStateKey()); } diff --git a/src/models/event.ts b/src/models/event.ts index 155581452..40d593898 100644 --- a/src/models/event.ts +++ b/src/models/event.ts @@ -29,6 +29,7 @@ import { Crypto } from "../crypto"; import { deepSortedObjectEntries } from "../utils"; import { RoomMember } from "./room-member"; import { Thread } from "./thread"; +import { IActionsObject } from '../pushprocessor'; /** * Enum for event statuses. @@ -149,7 +150,7 @@ export interface IDecryptOptions { } export class MatrixEvent extends EventEmitter { - private pushActions: object = null; + private pushActions: IActionsObject = null; private _replacingEvent: MatrixEvent = null; private _localRedactionEvent: MatrixEvent = null; private _isCancelled = false; @@ -960,7 +961,7 @@ export class MatrixEvent extends EventEmitter { * * @return {?Object} push actions */ - public getPushActions(): object | null { + public getPushActions(): IActionsObject | null { return this.pushActions; } @@ -969,7 +970,7 @@ export class MatrixEvent extends EventEmitter { * * @param {Object} pushActions push actions */ - public setPushActions(pushActions: object): void { + public setPushActions(pushActions: IActionsObject): void { this.pushActions = pushActions; } @@ -1247,10 +1248,15 @@ export class MatrixEvent extends EventEmitter { } /** - * Summarise the event as JSON for debugging. If encrypted, include both the - * decrypted and encrypted view of the event. This is named `toJSON` for use - * with `JSON.stringify` which checks objects for functions named `toJSON` - * and will call them to customise the output if they are defined. + * Summarise the event as JSON. This is currently used by React SDK's view + * event source feature and Seshat's event indexing, so take care when + * adjusting the output here. + * + * If encrypted, include both the decrypted and encrypted view of the event. + * + * This is named `toJSON` for use with `JSON.stringify` which checks objects + * for functions named `toJSON` and will call them to customise the output + * if they are defined. * * @return {Object} */ diff --git a/src/models/group.js b/src/models/group.js index e228aa1be..250e37733 100644 --- a/src/models/group.js +++ b/src/models/group.js @@ -17,6 +17,7 @@ limitations under the License. /** * @module models/group + * @deprecated groups/communities never made it to the spec and support for them is being discontinued. */ import * as utils from "../utils"; @@ -34,6 +35,7 @@ import { EventEmitter } from "events"; * @prop {Object} inviter Infomation about the user who invited the logged in user * to the group, if myMembership is 'invite'. * @prop {string} inviter.userId The user ID of the inviter + * @deprecated groups/communities never made it to the spec and support for them is being discontinued. */ export function Group(groupId) { this.groupId = groupId; @@ -76,6 +78,7 @@ Group.prototype.setInviter = function(inviter) { * This means the 'name' and 'avatarUrl' properties. * @event module:client~MatrixClient#"Group.profile" * @param {Group} group The group whose profile was updated. + * @deprecated groups/communities never made it to the spec and support for them is being discontinued. * @example * matrixClient.on("Group.profile", function(group){ * var name = group.name; @@ -87,6 +90,7 @@ Group.prototype.setInviter = function(inviter) { * the group is updated. * @event module:client~MatrixClient#"Group.myMembership" * @param {Group} group The group in which the user's membership changed + * @deprecated groups/communities never made it to the spec and support for them is being discontinued. * @example * matrixClient.on("Group.myMembership", function(group){ * var myMembership = group.myMembership; diff --git a/src/models/room-state.ts b/src/models/room-state.ts index 2b67ee79e..79ad48f8e 100644 --- a/src/models/room-state.ts +++ b/src/models/room-state.ts @@ -204,9 +204,9 @@ export class RoomState extends EventEmitter { * @return {MatrixEvent[]|MatrixEvent} A list of events if state_key was * undefined, else a single event (or null if no match found). */ - public getStateEvents(eventType: string): MatrixEvent[]; - public getStateEvents(eventType: string, stateKey: string): MatrixEvent; - public getStateEvents(eventType: string, stateKey?: string) { + public getStateEvents(eventType: EventType | string): MatrixEvent[]; + public getStateEvents(eventType: EventType | string, stateKey: string): MatrixEvent; + public getStateEvents(eventType: EventType | string, stateKey?: string) { if (!this.events.has(eventType)) { // no match return stateKey === undefined ? [] : null; diff --git a/src/models/search-result.ts b/src/models/search-result.ts index 83271fba2..1dc16ea84 100644 --- a/src/models/search-result.ts +++ b/src/models/search-result.ts @@ -31,7 +31,7 @@ export class SearchResult { * @return {SearchResult} */ - static fromJson(jsonObj: ISearchResult, eventMapper: EventMapper): SearchResult { + public static fromJson(jsonObj: ISearchResult, eventMapper: EventMapper): SearchResult { const jsonContext = jsonObj.context || {} as IResultContext; const eventsBefore = jsonContext.events_before || []; const eventsAfter = jsonContext.events_after || []; @@ -57,4 +57,3 @@ export class SearchResult { */ constructor(public readonly rank: number, public readonly context: EventContext) {} } - diff --git a/src/pushprocessor.js b/src/pushprocessor.ts similarity index 51% rename from src/pushprocessor.js rename to src/pushprocessor.ts index 4ab881623..7e551202c 100644 --- a/src/pushprocessor.js +++ b/src/pushprocessor.ts @@ -1,6 +1,5 @@ /* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2017 New Vector Ltd +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. @@ -17,12 +16,36 @@ limitations under the License. import { escapeRegExp, globToRegexp, isNullOrUndefined } from "./utils"; import { logger } from './logger'; +import { MatrixClient } from "./client"; +import { MatrixEvent } from "./models/event"; +import { + ConditionKind, + IAnnotatedPushRule, + IContainsDisplayNameCondition, + IEventMatchCondition, + IPushRule, + IPushRules, + IRoomMemberCountCondition, + ISenderNotificationPermissionCondition, + PushRuleAction, + PushRuleActionName, + PushRuleCondition, + PushRuleKind, + PushRuleSet, + TweakName, +} from "./@types/PushRules"; /** * @module pushprocessor */ -const RULEKINDS_IN_ORDER = ['override', 'content', 'room', 'sender', 'underride']; +const RULEKINDS_IN_ORDER = [ + PushRuleKind.Override, + PushRuleKind.ContentSpecific, + PushRuleKind.RoomSpecific, + PushRuleKind.SenderSpecific, + PushRuleKind.Underride, +]; // The default override rules to apply to the push rules that arrive from the server. // We do this for two reasons: @@ -31,7 +54,7 @@ const RULEKINDS_IN_ORDER = ['override', 'content', 'room', 'sender', 'underride' // more details. // 2. We often want to start using push rules ahead of the server supporting them, // and so we can put them here. -const DEFAULT_OVERRIDE_RULES = [ +const DEFAULT_OVERRIDE_RULES: IPushRule[] = [ { // For homeservers which don't support MSC1930 yet rule_id: ".m.rule.tombstone", @@ -39,20 +62,20 @@ const DEFAULT_OVERRIDE_RULES = [ enabled: true, conditions: [ { - kind: "event_match", + kind: ConditionKind.EventMatch, key: "type", pattern: "m.room.tombstone", }, { - kind: "event_match", + kind: ConditionKind.EventMatch, key: "state_key", pattern: "", }, ], actions: [ - "notify", + PushRuleActionName.Notify, { - set_tweak: "highlight", + set_tweak: TweakName.Highlight, value: true, }, ], @@ -64,31 +87,97 @@ const DEFAULT_OVERRIDE_RULES = [ enabled: true, conditions: [ { - kind: "event_match", + kind: ConditionKind.EventMatch, key: "type", pattern: "m.reaction", }, ], actions: [ - "dont_notify", + PushRuleActionName.DontNotify, ], }, ]; -/** - * Construct a Push Processor. - * @constructor - * @param {Object} client The Matrix client object to use - */ -export function PushProcessor(client) { - const cachedGlobToRegex = { - // $glob: RegExp, - }; +export interface IActionsObject { + notify: boolean; + tweaks: Partial>; +} - const matchingRuleFromKindSet = (ev, kindset) => { - for (let ruleKindIndex = 0; - ruleKindIndex < RULEKINDS_IN_ORDER.length; - ++ruleKindIndex) { +export class PushProcessor { + /** + * Construct a Push Processor. + * @constructor + * @param {Object} client The Matrix client object to use + */ + constructor(private readonly client: MatrixClient) {} + + /** + * Convert a list of actions into a object with the actions as keys and their values + * eg. [ 'notify', { set_tweak: 'sound', value: 'default' } ] + * becomes { notify: true, tweaks: { sound: 'default' } } + * @param {array} actionList The actions list + * + * @return {object} A object with key 'notify' (true or false) and an object of actions + */ + public static actionListToActionsObject(actionList: PushRuleAction[]): IActionsObject { + const actionObj: IActionsObject = { notify: false, tweaks: {} }; + for (let i = 0; i < actionList.length; ++i) { + const action = actionList[i]; + if (action === PushRuleActionName.Notify) { + actionObj.notify = true; + } else if (typeof action === 'object') { + if (action.value === undefined) { + action.value = true; + } + actionObj.tweaks[action.set_tweak] = action.value; + } + } + return actionObj; + } + + /** + * Rewrites conditions on a client's push rules to match the defaults + * where applicable. Useful for upgrading push rules to more strict + * conditions when the server is falling behind on defaults. + * @param {object} incomingRules The client's existing push rules + * @returns {object} The rewritten rules + */ + public static rewriteDefaultRules(incomingRules: IPushRules): IPushRules { + let newRules: IPushRules = JSON.parse(JSON.stringify(incomingRules)); // deep clone + + // These lines are mostly to make the tests happy. We shouldn't run into these + // properties missing in practice. + if (!newRules) newRules = {} as IPushRules; + if (!newRules.global) newRules.global = {} as PushRuleSet; + if (!newRules.global.override) newRules.global.override = []; + + // Merge the client-level defaults with the ones from the server + const globalOverrides = newRules.global.override; + for (const override of DEFAULT_OVERRIDE_RULES) { + const existingRule = globalOverrides + .find((r) => r.rule_id === override.rule_id); + + if (existingRule) { + // Copy over the actions, default, and conditions. Don't touch the user's + // preference. + existingRule.default = override.default; + existingRule.conditions = override.conditions; + existingRule.actions = override.actions; + } else { + // Add the rule + const ruleId = override.rule_id; + logger.warn(`Adding default global override for ${ruleId}`); + globalOverrides.push(override); + } + } + + return newRules; + } + + private static cachedGlobToRegex: Record = {}; // $glob: RegExp + + private matchingRuleFromKindSet(ev: MatrixEvent, kindset: PushRuleSet): IAnnotatedPushRule { + for (let ruleKindIndex = 0; ruleKindIndex < RULEKINDS_IN_ORDER.length; ++ruleKindIndex) { const kind = RULEKINDS_IN_ORDER[ruleKindIndex]; const ruleset = kindset[kind]; if (!ruleset) { @@ -101,89 +190,96 @@ export function PushProcessor(client) { continue; } - const rawrule = templateRuleToRaw(kind, rule); + const rawrule = this.templateRuleToRaw(kind, rule); if (!rawrule) { continue; } if (this.ruleMatchesEvent(rawrule, ev)) { - rule.kind = kind; - return rule; + return { + ...rule, + kind, + }; } } } return null; - }; + } - const templateRuleToRaw = function(kind, tprule) { + private templateRuleToRaw(kind: PushRuleKind, tprule: any): any { const rawrule = { 'rule_id': tprule.rule_id, 'actions': tprule.actions, 'conditions': [], }; switch (kind) { - case 'underride': - case 'override': + case PushRuleKind.Underride: + case PushRuleKind.Override: rawrule.conditions = tprule.conditions; break; - case 'room': + case PushRuleKind.RoomSpecific: if (!tprule.rule_id) { return null; } rawrule.conditions.push({ - 'kind': 'event_match', + 'kind': ConditionKind.EventMatch, 'key': 'room_id', 'value': tprule.rule_id, }); break; - case 'sender': + case PushRuleKind.SenderSpecific: if (!tprule.rule_id) { return null; } rawrule.conditions.push({ - 'kind': 'event_match', + 'kind': ConditionKind.EventMatch, 'key': 'user_id', 'value': tprule.rule_id, }); break; - case 'content': + case PushRuleKind.ContentSpecific: if (!tprule.pattern) { return null; } rawrule.conditions.push({ - 'kind': 'event_match', + 'kind': ConditionKind.EventMatch, 'key': 'content.body', 'pattern': tprule.pattern, }); break; } return rawrule; - }; + } - const eventFulfillsCondition = function(cond, ev) { - const condition_functions = { - "event_match": eventFulfillsEventMatchCondition, - "contains_display_name": eventFulfillsDisplayNameCondition, - "room_member_count": eventFulfillsRoomMemberCountCondition, - "sender_notification_permission": eventFulfillsSenderNotifPermCondition, - }; - if (condition_functions[cond.kind]) { - return condition_functions[cond.kind](cond, ev); + private eventFulfillsCondition(cond: PushRuleCondition, ev: MatrixEvent): boolean { + switch (cond.kind) { + case ConditionKind.EventMatch: + return this.eventFulfillsEventMatchCondition(cond, ev); + case ConditionKind.ContainsDisplayName: + return this.eventFulfillsDisplayNameCondition(cond, ev); + case ConditionKind.RoomMemberCount: + return this.eventFulfillsRoomMemberCountCondition(cond, ev); + case ConditionKind.SenderNotificationPermission: + return this.eventFulfillsSenderNotifPermCondition(cond, ev); } + // unknown conditions: we previously matched all unknown conditions, // but given that rules can be added to the base rules on a server, // it's probably better to not match unknown conditions. return false; - }; + } - const eventFulfillsSenderNotifPermCondition = function(cond, ev) { + private eventFulfillsSenderNotifPermCondition( + cond: ISenderNotificationPermissionCondition, + ev: MatrixEvent, + ): boolean { const notifLevelKey = cond['key']; if (!notifLevelKey) { return false; } - const room = client.getRoom(ev.getRoomId()); - if (!room || !room.currentState) { + const room = this.client.getRoom(ev.getRoomId()); + if (!room?.currentState) { return false; } @@ -191,14 +287,14 @@ export function PushProcessor(client) { // the point the event is in the DAG. Unfortunately the js-sdk does not store // this. return room.currentState.mayTriggerNotifOfType(notifLevelKey, ev.getSender()); - }; + } - const eventFulfillsRoomMemberCountCondition = function(cond, ev) { + private eventFulfillsRoomMemberCountCondition(cond: IRoomMemberCountCondition, ev: MatrixEvent): boolean { if (!cond.is) { return false; } - const room = client.getRoom(ev.getRoomId()); + const room = this.client.getRoom(ev.getRoomId()); if (!room || !room.currentState || !room.currentState.members) { return false; } @@ -229,9 +325,9 @@ export function PushProcessor(client) { default: return false; } - }; + } - const eventFulfillsDisplayNameCondition = function(cond, ev) { + private eventFulfillsDisplayNameCondition(cond: IContainsDisplayNameCondition, ev: MatrixEvent): boolean { let content = ev.getContent(); if (ev.isEncrypted() && ev.getClearContent()) { content = ev.getClearContent(); @@ -240,26 +336,26 @@ export function PushProcessor(client) { return false; } - const room = client.getRoom(ev.getRoomId()); + const room = this.client.getRoom(ev.getRoomId()); if (!room || !room.currentState || !room.currentState.members || - !room.currentState.getMember(client.credentials.userId)) { + !room.currentState.getMember(this.client.credentials.userId)) { return false; } - const displayName = room.currentState.getMember(client.credentials.userId).name; + const displayName = room.currentState.getMember(this.client.credentials.userId).name; // N.B. we can't use \b as it chokes on unicode. however \W seems to be okay // as shorthand for [^0-9A-Za-z_]. const pat = new RegExp("(^|\\W)" + escapeRegExp(displayName) + "(\\W|$)", 'i'); return content.body.search(pat) > -1; - }; + } - const eventFulfillsEventMatchCondition = function(cond, ev) { + private eventFulfillsEventMatchCondition(cond: IEventMatchCondition, ev: MatrixEvent): boolean { if (!cond.key) { return false; } - const val = valueForDottedKey(cond.key, ev); + const val = this.valueForDottedKey(cond.key, ev); if (typeof val !== 'string') { return false; } @@ -275,26 +371,26 @@ export function PushProcessor(client) { let regex; if (cond.key == 'content.body') { - regex = createCachedRegex('(^|\\W)', cond.pattern, '(\\W|$)'); + regex = this.createCachedRegex('(^|\\W)', cond.pattern, '(\\W|$)'); } else { - regex = createCachedRegex('^', cond.pattern, '$'); + regex = this.createCachedRegex('^', cond.pattern, '$'); } return !!val.match(regex); - }; + } - const createCachedRegex = function(prefix, glob, suffix) { - if (cachedGlobToRegex[glob]) { - return cachedGlobToRegex[glob]; + private createCachedRegex(prefix: string, glob: string, suffix: string): RegExp { + if (PushProcessor.cachedGlobToRegex[glob]) { + return PushProcessor.cachedGlobToRegex[glob]; } - cachedGlobToRegex[glob] = new RegExp( + PushProcessor.cachedGlobToRegex[glob] = new RegExp( prefix + globToRegexp(glob) + suffix, 'i', // Case insensitive ); - return cachedGlobToRegex[glob]; - }; + return PushProcessor.cachedGlobToRegex[glob]; + } - const valueForDottedKey = function(key, ev) { + private valueForDottedKey(key: string, ev: MatrixEvent): any { const parts = key.split('.'); let val; @@ -319,23 +415,23 @@ export function PushProcessor(client) { val = val[thisPart]; } return val; - }; + } - const matchingRuleForEventWithRulesets = function(ev, rulesets) { + private matchingRuleForEventWithRulesets(ev: MatrixEvent, rulesets): IAnnotatedPushRule { if (!rulesets) { return null; } - if (ev.getSender() === client.credentials.userId) { + if (ev.getSender() === this.client.credentials.userId) { return null; } - return matchingRuleFromKindSet(ev, rulesets.global); - }; + return this.matchingRuleFromKindSet(ev, rulesets.global); + } - const pushActionsForEventAndRulesets = function(ev, rulesets) { - const rule = matchingRuleForEventWithRulesets(ev, rulesets); + private pushActionsForEventAndRulesets(ev: MatrixEvent, rulesets): IActionsObject { + const rule = this.matchingRuleForEventWithRulesets(ev, rulesets); if (!rule) { - return {}; + return {} as IActionsObject; } const actionObj = PushProcessor.actionListToActionsObject(rule.actions); @@ -344,21 +440,22 @@ export function PushProcessor(client) { if (actionObj.tweaks.highlight === undefined) { // if it isn't specified, highlight if it's a content // rule but otherwise not - actionObj.tweaks.highlight = (rule.kind == 'content'); + actionObj.tweaks.highlight = (rule.kind == PushRuleKind.ContentSpecific); } return actionObj; - }; + } - this.ruleMatchesEvent = function(rule, ev) { + public ruleMatchesEvent(rule: IPushRule, ev: MatrixEvent): boolean { let ret = true; for (let i = 0; i < rule.conditions.length; ++i) { const cond = rule.conditions[i]; - ret &= eventFulfillsCondition(cond, ev); + // @ts-ignore + ret &= this.eventFulfillsCondition(cond, ev); } //console.log("Rule "+rule.rule_id+(ret ? " matches" : " doesn't match")); return ret; - }; + } /** * Get the user's push actions for the given event @@ -367,9 +464,9 @@ export function PushProcessor(client) { * * @return {PushAction} */ - this.actionsForEvent = function(ev) { - return pushActionsForEventAndRulesets(ev, client.pushRules); - }; + public actionsForEvent(ev: MatrixEvent): IActionsObject { + return this.pushActionsForEventAndRulesets(ev, this.client.pushRules); + } /** * Get one of the users push rules by its ID @@ -377,85 +474,22 @@ export function PushProcessor(client) { * @param {string} ruleId The ID of the rule to search for * @return {object} The push rule, or null if no such rule was found */ - this.getPushRuleById = function(ruleId) { + public getPushRuleById(ruleId: string): IPushRule { for (const scope of ['global']) { - if (client.pushRules[scope] === undefined) continue; + if (this.client.pushRules[scope] === undefined) continue; for (const kind of RULEKINDS_IN_ORDER) { - if (client.pushRules[scope][kind] === undefined) continue; + if (this.client.pushRules[scope][kind] === undefined) continue; - for (const rule of client.pushRules[scope][kind]) { + for (const rule of this.client.pushRules[scope][kind]) { if (rule.rule_id === ruleId) return rule; } } } return null; - }; + } } -/** - * Convert a list of actions into a object with the actions as keys and their values - * eg. [ 'notify', { set_tweak: 'sound', value: 'default' } ] - * becomes { notify: true, tweaks: { sound: 'default' } } - * @param {array} actionlist The actions list - * - * @return {object} A object with key 'notify' (true or false) and an object of actions - */ -PushProcessor.actionListToActionsObject = function(actionlist) { - const actionobj = { 'notify': false, 'tweaks': {} }; - for (let i = 0; i < actionlist.length; ++i) { - const action = actionlist[i]; - if (action === 'notify') { - actionobj.notify = true; - } else if (typeof action === 'object') { - if (action.value === undefined) { - action.value = true; - } - actionobj.tweaks[action.set_tweak] = action.value; - } - } - return actionobj; -}; - -/** - * Rewrites conditions on a client's push rules to match the defaults - * where applicable. Useful for upgrading push rules to more strict - * conditions when the server is falling behind on defaults. - * @param {object} incomingRules The client's existing push rules - * @returns {object} The rewritten rules - */ -PushProcessor.rewriteDefaultRules = function(incomingRules) { - let newRules = JSON.parse(JSON.stringify(incomingRules)); // deep clone - - // These lines are mostly to make the tests happy. We shouldn't run into these - // properties missing in practice. - if (!newRules) newRules = {}; - if (!newRules.global) newRules.global = {}; - if (!newRules.global.override) newRules.global.override = []; - - // Merge the client-level defaults with the ones from the server - const globalOverrides = newRules.global.override; - for (const override of DEFAULT_OVERRIDE_RULES) { - const existingRule = globalOverrides - .find((r) => r.rule_id === override.rule_id); - - if (existingRule) { - // Copy over the actions, default, and conditions. Don't touch the user's - // preference. - existingRule.default = override.default; - existingRule.conditions = override.conditions; - existingRule.actions = override.actions; - } else { - // Add the rule - const ruleId = override.rule_id; - logger.warn(`Adding default global override for ${ruleId}`); - globalOverrides.push(override); - } - } - - return newRules; -}; - /** * @typedef {Object} PushAction * @type {Object} diff --git a/src/scheduler.js b/src/scheduler.js deleted file mode 100644 index 37e231ce0..000000000 --- a/src/scheduler.js +++ /dev/null @@ -1,327 +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. -*/ - -/** - * This is an internal module which manages queuing, scheduling and retrying - * of requests. - * @module scheduler - */ -import * as utils from "./utils"; -import { logger } from './logger'; - -const DEBUG = false; // set true to enable console logging. - -/** - * Construct a scheduler for Matrix. Requires - * {@link module:scheduler~MatrixScheduler#setProcessFunction} to be provided - * with a way of processing events. - * @constructor - * @param {module:scheduler~retryAlgorithm} retryAlgorithm Optional. The retry - * algorithm to apply when determining when to try to send an event again. - * Defaults to {@link module:scheduler~MatrixScheduler.RETRY_BACKOFF_RATELIMIT}. - * @param {module:scheduler~queueAlgorithm} queueAlgorithm Optional. The queuing - * algorithm to apply when determining which events should be sent before the - * given event. Defaults to {@link module:scheduler~MatrixScheduler.QUEUE_MESSAGES}. - */ -export function MatrixScheduler(retryAlgorithm, queueAlgorithm) { - this.retryAlgorithm = retryAlgorithm || MatrixScheduler.RETRY_BACKOFF_RATELIMIT; - this.queueAlgorithm = queueAlgorithm || MatrixScheduler.QUEUE_MESSAGES; - this._queues = { - // queueName: [{ - // event: MatrixEvent, // event to send - // defer: Deferred, // defer to resolve/reject at the END of the retries - // attempts: Number // number of times we've called processFn - // }, ...] - }; - this._activeQueues = []; - this._procFn = null; -} - -/** - * Retrieve a queue based on an event. The event provided does not need to be in - * the queue. - * @param {MatrixEvent} event An event to get the queue for. - * @return {?Array} A shallow copy of events in the queue or null. - * Modifying this array will not modify the list itself. Modifying events in - * this array will modify the underlying event in the queue. - * @see MatrixScheduler.removeEventFromQueue To remove an event from the queue. - */ -MatrixScheduler.prototype.getQueueForEvent = function(event) { - const name = this.queueAlgorithm(event); - if (!name || !this._queues[name]) { - return null; - } - return this._queues[name].map(function(obj) { - return obj.event; - }); -}; - -/** - * Remove this event from the queue. The event is equal to another event if they - * have the same ID returned from event.getId(). - * @param {MatrixEvent} event The event to remove. - * @return {boolean} True if this event was removed. - */ -MatrixScheduler.prototype.removeEventFromQueue = function(event) { - const name = this.queueAlgorithm(event); - if (!name || !this._queues[name]) { - return false; - } - let removed = false; - utils.removeElement(this._queues[name], function(element) { - if (element.event.getId() === event.getId()) { - // XXX we should probably reject the promise? - // https://github.com/matrix-org/matrix-js-sdk/issues/496 - removed = true; - return true; - } - }); - return removed; -}; - -/** - * Set the process function. Required for events in the queue to be processed. - * If set after events have been added to the queue, this will immediately start - * processing them. - * @param {module:scheduler~processFn} fn The function that can process events - * in the queue. - */ -MatrixScheduler.prototype.setProcessFunction = function(fn) { - this._procFn = fn; - _startProcessingQueues(this); -}; - -/** - * Queue an event if it is required and start processing queues. - * @param {MatrixEvent} event The event that may be queued. - * @return {?Promise} A promise if the event was queued, which will be - * resolved or rejected in due time, else null. - */ -MatrixScheduler.prototype.queueEvent = function(event) { - const queueName = this.queueAlgorithm(event); - if (!queueName) { - return null; - } - // add the event to the queue and make a deferred for it. - if (!this._queues[queueName]) { - this._queues[queueName] = []; - } - const defer = utils.defer(); - this._queues[queueName].push({ - event: event, - defer: defer, - attempts: 0, - }); - debuglog( - "Queue algorithm dumped event %s into queue '%s'", - event.getId(), queueName, - ); - _startProcessingQueues(this); - return defer.promise; -}; - -/** - * Retries events up to 4 times using exponential backoff. This produces wait - * times of 2, 4, 8, and 16 seconds (30s total) after which we give up. If the - * failure was due to a rate limited request, the time specified in the error is - * waited before being retried. - * @param {MatrixEvent} event - * @param {Number} attempts - * @param {MatrixError} err - * @return {Number} - * @see module:scheduler~retryAlgorithm - */ -MatrixScheduler.RETRY_BACKOFF_RATELIMIT = function(event, attempts, err) { - if (err.httpStatus === 400 || err.httpStatus === 403 || err.httpStatus === 401) { - // client error; no amount of retrying with save you now. - return -1; - } - // we ship with browser-request which returns { cors: rejected } when trying - // with no connection, so if we match that, give up since they have no conn. - if (err.cors === "rejected") { - return -1; - } - - // if event that we are trying to send is too large in any way then retrying won't help - if (err.name === "M_TOO_LARGE") { - return -1; - } - - if (err.name === "M_LIMIT_EXCEEDED") { - const waitTime = err.data.retry_after_ms; - if (waitTime > 0) { - return waitTime; - } - } - if (attempts > 4) { - return -1; // give up - } - return (1000 * Math.pow(2, attempts)); -}; - -/** - * Queues m.room.message events and lets other events continue - * concurrently. - * @param {MatrixEvent} event - * @return {string} - * @see module:scheduler~queueAlgorithm - */ -MatrixScheduler.QUEUE_MESSAGES = function(event) { - // enqueue messages or events that associate with another event (redactions and relations) - if (event.getType() === "m.room.message" || event.hasAssocation()) { - // put these events in the 'message' queue. - return "message"; - } - // allow all other events continue concurrently. - return null; -}; - -function _startProcessingQueues(scheduler) { - if (!scheduler._procFn) { - return; - } - // for each inactive queue with events in them - Object.keys(scheduler._queues) - .filter(function(queueName) { - return scheduler._activeQueues.indexOf(queueName) === -1 && - scheduler._queues[queueName].length > 0; - }) - .forEach(function(queueName) { - // mark the queue as active - scheduler._activeQueues.push(queueName); - // begin processing the head of the queue - debuglog("Spinning up queue: '%s'", queueName); - _processQueue(scheduler, queueName); - }); -} - -function _processQueue(scheduler, queueName) { - // get head of queue - const obj = _peekNextEvent(scheduler, queueName); - if (!obj) { - // queue is empty. Mark as inactive and stop recursing. - const index = scheduler._activeQueues.indexOf(queueName); - if (index >= 0) { - scheduler._activeQueues.splice(index, 1); - } - debuglog("Stopping queue '%s' as it is now empty", queueName); - return; - } - debuglog( - "Queue '%s' has %s pending events", - queueName, scheduler._queues[queueName].length, - ); - // fire the process function and if it resolves, resolve the deferred. Else - // invoke the retry algorithm. - - // First wait for a resolved promise, so the resolve handlers for - // the deferred of the previously sent event can run. - // This way enqueued relations/redactions to enqueued events can receive - // the remove id of their target before being sent. - Promise.resolve().then(() => { - return scheduler._procFn(obj.event); - }).then(function(res) { - // remove this from the queue - _removeNextEvent(scheduler, queueName); - debuglog("Queue '%s' sent event %s", queueName, obj.event.getId()); - obj.defer.resolve(res); - // keep processing - _processQueue(scheduler, queueName); - }, function(err) { - obj.attempts += 1; - // ask the retry algorithm when/if we should try again - const waitTimeMs = scheduler.retryAlgorithm(obj.event, obj.attempts, err); - debuglog( - "retry(%s) err=%s event_id=%s waitTime=%s", - obj.attempts, err, obj.event.getId(), waitTimeMs, - ); - if (waitTimeMs === -1) { // give up (you quitter!) - debuglog( - "Queue '%s' giving up on event %s", queueName, obj.event.getId(), - ); - // remove this from the queue - _removeNextEvent(scheduler, queueName); - obj.defer.reject(err); - // process next event - _processQueue(scheduler, queueName); - } else { - setTimeout(function() { - _processQueue(scheduler, queueName); - }, waitTimeMs); - } - }); -} - -function _peekNextEvent(scheduler, queueName) { - const queue = scheduler._queues[queueName]; - if (!Array.isArray(queue)) { - return null; - } - return queue[0]; -} - -function _removeNextEvent(scheduler, queueName) { - const queue = scheduler._queues[queueName]; - if (!Array.isArray(queue)) { - return null; - } - return queue.shift(); -} - -function debuglog() { - if (DEBUG) { - logger.log(...arguments); - } -} - -/** - * The retry algorithm to apply when retrying events. To stop retrying, return - * -1. If this event was part of a queue, it will be removed from - * the queue. - * @callback retryAlgorithm - * @param {MatrixEvent} event The event being retried. - * @param {Number} attempts The number of failed attempts. This will always be - * >= 1. - * @param {MatrixError} err The most recent error message received when trying - * to send this event. - * @return {Number} The number of milliseconds to wait before trying again. If - * this is 0, the request will be immediately retried. If this is - * -1, the event will be marked as - * {@link module:models/event.EventStatus.NOT_SENT} and will not be retried. - */ - -/** - * The queuing algorithm to apply to events. This function must be idempotent as - * it may be called multiple times with the same event. All queues created are - * serviced in a FIFO manner. To send the event ASAP, return null - * which will not put this event in a queue. Events that fail to send that form - * part of a queue will be removed from the queue and the next event in the - * queue will be sent. - * @callback queueAlgorithm - * @param {MatrixEvent} event The event to be sent. - * @return {string} The name of the queue to put the event into. If a queue with - * this name does not exist, it will be created. If this is null, - * the event is not put into a queue and will be sent concurrently. - */ - - /** - * The function to invoke to process (send) events in the queue. - * @callback processFn - * @param {MatrixEvent} event The event to send. - * @return {Promise} Resolved/rejected depending on the outcome of the request. - */ - diff --git a/src/scheduler.ts b/src/scheduler.ts new file mode 100644 index 000000000..b83a57eba --- /dev/null +++ b/src/scheduler.ts @@ -0,0 +1,329 @@ +/* +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. +*/ + +/** + * This is an internal module which manages queuing, scheduling and retrying + * of requests. + * @module scheduler + */ +import * as utils from "./utils"; +import { logger } from './logger'; +import { MatrixEvent } from "./models/event"; +import { EventType } from "./@types/event"; +import { IDeferred } from "./utils"; +import { MatrixError } from "./http-api"; +import { ISendEventResponse } from "./@types/requests"; + +const DEBUG = false; // set true to enable console logging. + +interface IQueueEntry { + event: MatrixEvent; + defer: IDeferred; + attempts: number; +} + +type ProcessFunction = (event: MatrixEvent) => Promise; + +/** + * Construct a scheduler for Matrix. Requires + * {@link module:scheduler~MatrixScheduler#setProcessFunction} to be provided + * with a way of processing events. + * @constructor + * @param {module:scheduler~retryAlgorithm} retryAlgorithm Optional. The retry + * algorithm to apply when determining when to try to send an event again. + * Defaults to {@link module:scheduler~MatrixScheduler.RETRY_BACKOFF_RATELIMIT}. + * @param {module:scheduler~queueAlgorithm} queueAlgorithm Optional. The queuing + * algorithm to apply when determining which events should be sent before the + * given event. Defaults to {@link module:scheduler~MatrixScheduler.QUEUE_MESSAGES}. + */ +// eslint-disable-next-line camelcase +export class MatrixScheduler { + /** + * Retries events up to 4 times using exponential backoff. This produces wait + * times of 2, 4, 8, and 16 seconds (30s total) after which we give up. If the + * failure was due to a rate limited request, the time specified in the error is + * waited before being retried. + * @param {MatrixEvent} event + * @param {Number} attempts + * @param {MatrixError} err + * @return {Number} + * @see module:scheduler~retryAlgorithm + */ + // eslint-disable-next-line @typescript-eslint/naming-convention + public static RETRY_BACKOFF_RATELIMIT(event: MatrixEvent, attempts: number, err: MatrixError): number { + if (err.httpStatus === 400 || err.httpStatus === 403 || err.httpStatus === 401) { + // client error; no amount of retrying with save you now. + return -1; + } + // we ship with browser-request which returns { cors: rejected } when trying + // with no connection, so if we match that, give up since they have no conn. + if (err.cors === "rejected") { + return -1; + } + + // if event that we are trying to send is too large in any way then retrying won't help + if (err.name === "M_TOO_LARGE") { + return -1; + } + + if (err.name === "M_LIMIT_EXCEEDED") { + const waitTime = err.data.retry_after_ms; + if (waitTime > 0) { + return waitTime; + } + } + if (attempts > 4) { + return -1; // give up + } + return (1000 * Math.pow(2, attempts)); + } + + /** + * Queues m.room.message events and lets other events continue + * concurrently. + * @param {MatrixEvent} event + * @return {string} + * @see module:scheduler~queueAlgorithm + */ + // eslint-disable-next-line @typescript-eslint/naming-convention + public static QUEUE_MESSAGES(event: MatrixEvent) { + // enqueue messages or events that associate with another event (redactions and relations) + if (event.getType() === EventType.RoomMessage || event.hasAssocation()) { + // put these events in the 'message' queue. + return "message"; + } + // allow all other events continue concurrently. + return null; + } + + // queueName: [{ + // event: MatrixEvent, // event to send + // defer: Deferred, // defer to resolve/reject at the END of the retries + // attempts: Number // number of times we've called processFn + // }, ...] + private readonly queues: Record[]> = {}; + private activeQueues: string[] = []; + private procFn: ProcessFunction = null; + + constructor( + public readonly retryAlgorithm = MatrixScheduler.RETRY_BACKOFF_RATELIMIT, + public readonly queueAlgorithm = MatrixScheduler.QUEUE_MESSAGES, + ) {} + + /** + * Retrieve a queue based on an event. The event provided does not need to be in + * the queue. + * @param {MatrixEvent} event An event to get the queue for. + * @return {?Array} A shallow copy of events in the queue or null. + * Modifying this array will not modify the list itself. Modifying events in + * this array will modify the underlying event in the queue. + * @see MatrixScheduler.removeEventFromQueue To remove an event from the queue. + */ + public getQueueForEvent(event: MatrixEvent): MatrixEvent[] { + const name = this.queueAlgorithm(event); + if (!name || !this.queues[name]) { + return null; + } + return this.queues[name].map(function(obj) { + return obj.event; + }); + } + + /** + * Remove this event from the queue. The event is equal to another event if they + * have the same ID returned from event.getId(). + * @param {MatrixEvent} event The event to remove. + * @return {boolean} True if this event was removed. + */ + public removeEventFromQueue(event: MatrixEvent): boolean { + const name = this.queueAlgorithm(event); + if (!name || !this.queues[name]) { + return false; + } + let removed = false; + utils.removeElement(this.queues[name], (element) => { + if (element.event.getId() === event.getId()) { + // XXX we should probably reject the promise? + // https://github.com/matrix-org/matrix-js-sdk/issues/496 + removed = true; + return true; + } + }); + return removed; + } + + /** + * Set the process function. Required for events in the queue to be processed. + * If set after events have been added to the queue, this will immediately start + * processing them. + * @param {module:scheduler~processFn} fn The function that can process events + * in the queue. + */ + public setProcessFunction(fn: ProcessFunction): void { + this.procFn = fn; + this.startProcessingQueues(); + } + + /** + * Queue an event if it is required and start processing queues. + * @param {MatrixEvent} event The event that may be queued. + * @return {?Promise} A promise if the event was queued, which will be + * resolved or rejected in due time, else null. + */ + public queueEvent(event: MatrixEvent): Promise | null { + const queueName = this.queueAlgorithm(event); + if (!queueName) { + return null; + } + // add the event to the queue and make a deferred for it. + if (!this.queues[queueName]) { + this.queues[queueName] = []; + } + const defer = utils.defer(); + this.queues[queueName].push({ + event: event, + defer: defer, + attempts: 0, + }); + debuglog("Queue algorithm dumped event %s into queue '%s'", event.getId(), queueName); + this.startProcessingQueues(); + return defer.promise; + } + + private startProcessingQueues(): void { + if (!this.procFn) return; + // for each inactive queue with events in them + Object.keys(this.queues) + .filter((queueName) => { + return this.activeQueues.indexOf(queueName) === -1 && + this.queues[queueName].length > 0; + }) + .forEach((queueName) => { + // mark the queue as active + this.activeQueues.push(queueName); + // begin processing the head of the queue + debuglog("Spinning up queue: '%s'", queueName); + this.processQueue(queueName); + }); + } + + private processQueue = (queueName: string): void => { + // get head of queue + const obj = this.peekNextEvent(queueName); + if (!obj) { + // queue is empty. Mark as inactive and stop recursing. + const index = this.activeQueues.indexOf(queueName); + if (index >= 0) { + this.activeQueues.splice(index, 1); + } + debuglog("Stopping queue '%s' as it is now empty", queueName); + return; + } + debuglog("Queue '%s' has %s pending events", queueName, this.queues[queueName].length); + // fire the process function and if it resolves, resolve the deferred. Else + // invoke the retry algorithm. + + // First wait for a resolved promise, so the resolve handlers for + // the deferred of the previously sent event can run. + // This way enqueued relations/redactions to enqueued events can receive + // the remove id of their target before being sent. + Promise.resolve().then(() => { + return this.procFn(obj.event); + }).then((res) => { + // remove this from the queue + this.removeNextEvent(queueName); + debuglog("Queue '%s' sent event %s", queueName, obj.event.getId()); + obj.defer.resolve(res); + // keep processing + this.processQueue(queueName); + }, (err) => { + obj.attempts += 1; + // ask the retry algorithm when/if we should try again + const waitTimeMs = this.retryAlgorithm(obj.event, obj.attempts, err); + debuglog("retry(%s) err=%s event_id=%s waitTime=%s", obj.attempts, err, obj.event.getId(), waitTimeMs); + if (waitTimeMs === -1) { // give up (you quitter!) + debuglog("Queue '%s' giving up on event %s", queueName, obj.event.getId()); + // remove this from the queue + this.removeNextEvent(queueName); + obj.defer.reject(err); + // process next event + this.processQueue(queueName); + } else { + setTimeout(this.processQueue, waitTimeMs, queueName); + } + }); + }; + + private peekNextEvent(queueName: string): IQueueEntry { + const queue = this.queues[queueName]; + if (!Array.isArray(queue)) { + return null; + } + return queue[0]; + } + + private removeNextEvent(queueName: string): IQueueEntry { + const queue = this.queues[queueName]; + if (!Array.isArray(queue)) { + return null; + } + return queue.shift(); + } +} + +function debuglog(...args) { + if (DEBUG) { + logger.log(...args); + } +} + +/** + * The retry algorithm to apply when retrying events. To stop retrying, return + * -1. If this event was part of a queue, it will be removed from + * the queue. + * @callback retryAlgorithm + * @param {MatrixEvent} event The event being retried. + * @param {Number} attempts The number of failed attempts. This will always be + * >= 1. + * @param {MatrixError} err The most recent error message received when trying + * to send this event. + * @return {Number} The number of milliseconds to wait before trying again. If + * this is 0, the request will be immediately retried. If this is + * -1, the event will be marked as + * {@link module:models/event.EventStatus.NOT_SENT} and will not be retried. + */ + +/** + * The queuing algorithm to apply to events. This function must be idempotent as + * it may be called multiple times with the same event. All queues created are + * serviced in a FIFO manner. To send the event ASAP, return null + * which will not put this event in a queue. Events that fail to send that form + * part of a queue will be removed from the queue and the next event in the + * queue will be sent. + * @callback queueAlgorithm + * @param {MatrixEvent} event The event to be sent. + * @return {string} The name of the queue to put the event into. If a queue with + * this name does not exist, it will be created. If this is null, + * the event is not put into a queue and will be sent concurrently. + */ + +/** + * The function to invoke to process (send) events in the queue. + * @callback processFn + * @param {MatrixEvent} event The event to send. + * @return {Promise} Resolved/rejected depending on the outcome of the request. + */ + diff --git a/src/store/index.ts b/src/store/index.ts index 7e52d014e..ad25a1c7e 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -56,6 +56,7 @@ export interface IStore { /** * No-op. * @param {Group} group + * @deprecated groups/communities never made it to the spec and support for them is being discontinued. */ storeGroup(group: Group); @@ -63,12 +64,14 @@ export interface IStore { * No-op. * @param {string} groupId * @return {null} + * @deprecated groups/communities never made it to the spec and support for them is being discontinued. */ getGroup(groupId: string): Group | null; /** * No-op. * @return {Array} An empty array. + * @deprecated groups/communities never made it to the spec and support for them is being discontinued. */ getGroups(): Group[]; diff --git a/src/store/memory.ts b/src/store/memory.ts index 656bd1808..7effd9f61 100644 --- a/src/store/memory.ts +++ b/src/store/memory.ts @@ -93,6 +93,7 @@ export class MemoryStore implements IStore { /** * Store the given room. * @param {Group} group The group to be stored + * @deprecated groups/communities never made it to the spec and support for them is being discontinued. */ public storeGroup(group: Group) { this.groups[group.groupId] = group; @@ -102,6 +103,7 @@ export class MemoryStore implements IStore { * Retrieve a group by its group ID. * @param {string} groupId The group ID. * @return {Group} The group or null. + * @deprecated groups/communities never made it to the spec and support for them is being discontinued. */ public getGroup(groupId: string): Group | null { return this.groups[groupId] || null; @@ -110,6 +112,7 @@ export class MemoryStore implements IStore { /** * Retrieve all known groups. * @return {Group[]} A list of groups, which may be empty. + * @deprecated groups/communities never made it to the spec and support for them is being discontinued. */ public getGroups(): Group[] { return Object.values(this.groups); diff --git a/src/store/stub.ts b/src/store/stub.ts index c2b4ea933..95b231db1 100644 --- a/src/store/stub.ts +++ b/src/store/stub.ts @@ -61,6 +61,7 @@ export class StubStore implements IStore { /** * No-op. * @param {Group} group + * @deprecated groups/communities never made it to the spec and support for them is being discontinued. */ public storeGroup(group: Group) {} @@ -68,6 +69,7 @@ export class StubStore implements IStore { * No-op. * @param {string} groupId * @return {null} + * @deprecated groups/communities never made it to the spec and support for them is being discontinued. */ public getGroup(groupId: string): Group | null { return null; @@ -76,6 +78,7 @@ export class StubStore implements IStore { /** * No-op. * @return {Array} An empty array. + * @deprecated groups/communities never made it to the spec and support for them is being discontinued. */ public getGroups(): Group[] { return []; diff --git a/src/sync.ts b/src/sync.ts index f968ab882..22d81efb7 100644 --- a/src/sync.ts +++ b/src/sync.ts @@ -53,6 +53,8 @@ import { MatrixEvent } from "./models/event"; import { MatrixError } from "./http-api"; import { ISavedSync } from "./store"; import { Thread } from "./models/thread"; +import { EventType } from "./@types/event"; +import { IPushRules } from "./@types/PushRules"; const DEBUG = true; @@ -1075,8 +1077,8 @@ export class SyncApi { // honour push rules that were previously cached. Base rules // will be updated when we receive push rules via getPushRules // (see sync) before syncing over the network. - if (accountDataEvent.getType() === 'm.push_rules') { - const rules = accountDataEvent.getContent(); + if (accountDataEvent.getType() === EventType.PushRules) { + const rules = accountDataEvent.getContent(); client.pushRules = PushProcessor.rewriteDefaultRules(rules); } const prevEvent = prevEventsMap[accountDataEvent.getId()]; diff --git a/src/utils.ts b/src/utils.ts index 2c3380044..0a0f259e4 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -412,7 +412,7 @@ export function escapeRegExp(string: string): string { return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } -export function globToRegexp(glob: string, extended: any): string { +export function globToRegexp(glob: string, extended?: any): string { extended = typeof(extended) === 'boolean' ? extended : true; // From // https://github.com/matrix-org/synapse/blob/abbee6b29be80a77e05730707602f3bbfc3f38cb/synapse/push/__init__.py#L132 @@ -457,7 +457,7 @@ export interface IDeferred { } // Returns a Deferred -export function defer(): IDeferred { +export function defer(): IDeferred { let resolve; let reject; diff --git a/src/webrtc/call.ts b/src/webrtc/call.ts index dd0043d25..cfc19b2fe 100644 --- a/src/webrtc/call.ts +++ b/src/webrtc/call.ts @@ -535,6 +535,7 @@ export class MatrixCall extends EventEmitter { this.emit(CallEvent.FeedsChanged, this.feeds); } + // TODO: Find out what is going on here // why do we enable audio (and only audio) tracks here? -- matthew setTracksEnabled(stream.getAudioTracks(), true); @@ -708,8 +709,6 @@ export class MatrixCall extends EventEmitter { this.getUserMediaFailed(e); return; } - } else if (this.localUsermediaStream) { - this.gotUserMediaForAnswer(this.localUsermediaStream); } else if (this.waitForLocalAVStream) { this.setState(CallState.WaitLocalMedia); } @@ -721,14 +720,10 @@ export class MatrixCall extends EventEmitter { * @param {MatrixCall} newCall The new call. */ replacedBy(newCall: MatrixCall) { - logger.debug(this.callId + " being replaced by " + newCall.callId); if (this.state === CallState.WaitLocalMedia) { logger.debug("Telling new call to wait for local media"); newCall.waitForLocalAVStream = true; - } else if (this.state === CallState.CreateOffer) { - logger.debug("Handing local stream to new call"); - newCall.gotUserMediaForAnswer(this.localUsermediaStream); - } else if (this.state === CallState.InviteSent) { + } else if ([CallState.CreateOffer, CallState.InviteSent].includes(this.state)) { logger.debug("Handing local stream to new call"); newCall.gotUserMediaForAnswer(this.localUsermediaStream); } @@ -750,9 +745,10 @@ export class MatrixCall extends EventEmitter { // We don't want to send hangup here if we didn't even get to sending an invite if (this.state === CallState.WaitLocalMedia) return; const content = {}; - // Continue to send no reason for user hangups temporarily, until - // clients understand the user_hangup reason (voip v1) - if (reason !== CallErrorCode.UserHangup) content['reason'] = reason; + // Don't send UserHangup reason to older clients + if ((this.opponentVersion && this.opponentVersion >= 1) || reason !== CallErrorCode.UserHangup) { + content["reason"] = reason; + } this.sendVoipEvent(EventType.CallHangup, content); } @@ -836,10 +832,10 @@ export class MatrixCall extends EventEmitter { for (const sender of this.screensharingSenders) { this.peerConn.removeTrack(sender); } - this.deleteFeedByStream(this.localScreensharingStream); for (const track of this.localScreensharingStream.getTracks()) { track.stop(); } + this.deleteFeedByStream(this.localScreensharingStream); return false; } } @@ -887,10 +883,10 @@ export class MatrixCall extends EventEmitter { }); sender.replaceTrack(track); - this.deleteFeedByStream(this.localScreensharingStream); for (const track of this.localScreensharingStream.getTracks()) { track.stop(); } + this.deleteFeedByStream(this.localScreensharingStream); return false; } @@ -1028,7 +1024,6 @@ export class MatrixCall extends EventEmitter { this.pushLocalFeed(stream, SDPStreamMetadataPurpose.Usermedia); this.setState(CallState.CreateOffer); - logger.info("Got local AV stream with id " + this.localUsermediaStream.id); logger.debug("gotUserMediaForInvite -> " + this.type); // Now we wait for the negotiationneeded event }; @@ -1086,9 +1081,6 @@ export class MatrixCall extends EventEmitter { } this.pushLocalFeed(stream, SDPStreamMetadataPurpose.Usermedia); - - logger.info("Got local AV stream with id " + this.localUsermediaStream.id); - this.setState(CallState.CreateAnswer); let myAnswer; @@ -1285,7 +1277,7 @@ export class MatrixCall extends EventEmitter { // https://developer.mozilla.org/en-US/docs/Web/API/WebRTC_API/Perfect_negotiation const offerCollision = ( (description.type === 'offer') && - (this.makingOffer || this.peerConn.signalingState != 'stable') + (this.makingOffer || this.peerConn.signalingState !== 'stable') ); this.ignoreOffer = !polite && offerCollision; @@ -1639,8 +1631,15 @@ export class MatrixCall extends EventEmitter { } queueCandidate(content: RTCIceCandidate) { - // Sends candidates with are sent in a special way because we try to amalgamate - // them into one message + // We partially de-trickle candidates by waiting for `delay` before sending them + // amalgamated, in order to avoid sending too many m.call.candidates events and hitting + // rate limits in Matrix. + // In practice, it'd be better to remove rate limits for m.call.* + + // N.B. this deliberately lets you queue and send blank candidates, which MSC2746 + // currently proposes as the way to indicate that candidate gathering is complete. + // This will hopefully be changed to an explicit rather than implicit notification + // shortly. this.candidateSendQueue.push(content); // Don't send the ICE candidates yet if the call is in the ringing state: this @@ -1785,6 +1784,9 @@ export class MatrixCall extends EventEmitter { logger.debug("Attempting to send " + candidates.length + " candidates"); try { await this.sendVoipEvent(EventType.CallCandidates, content); + // reset our retry count if we have successfully sent our candidates + // otherwise queueCandidate() will refuse to try to flush the queue + this.candidateSendTries = 0; } catch (error) { // don't retry this event: we'll send another one later as we might // have more candidates by then. @@ -1924,6 +1926,10 @@ export class MatrixCall extends EventEmitter { } } } + + public get hasPeerConnection() { + return Boolean(this.peerConn); + } } async function getScreensharingStream( diff --git a/src/webrtc/callEventHandler.ts b/src/webrtc/callEventHandler.ts index 3e080c0eb..68aaffd0a 100644 --- a/src/webrtc/callEventHandler.ts +++ b/src/webrtc/callEventHandler.ts @@ -128,7 +128,7 @@ export class CallEventHandler { return type.startsWith("m.call.") || type.startsWith("org.matrix.call."); } - private handleCallEvent(event: MatrixEvent) { + private async handleCallEvent(event: MatrixEvent) { const content = event.getContent(); const type = event.getType() as EventType; const weSentTheEvent = event.getSender() === this.client.credentials.userId; @@ -169,7 +169,7 @@ export class CallEventHandler { } call.callId = content.call_id; - call.initWithInvite(event); + const initWithInvitePromise = call.initWithInvite(event); this.calls.set(call.callId, call); // if we stashed candidate events for that call ID, play them back now @@ -201,13 +201,17 @@ export class CallEventHandler { // we've got an invite, pick the incoming call because we know // we haven't sent our invite yet otherwise, pick whichever // call has the lowest call ID (by string comparison) - if (existingCall.state === CallState.WaitLocalMedia || - existingCall.state === CallState.CreateOffer || - existingCall.callId > call.callId) { + if ( + existingCall.state === CallState.WaitLocalMedia || + existingCall.state === CallState.CreateOffer || + existingCall.callId > call.callId + ) { logger.log( "Glare detected: answering incoming call " + call.callId + " and canceling outgoing call " + existingCall.callId, ); + // Await init with invite as we need a peerConn for the following methods + await initWithInvitePromise; existingCall.replacedBy(call); call.answer(); } else { @@ -220,6 +224,7 @@ export class CallEventHandler { } else { this.client.emit("Call.incoming", call); } + return; } else if (type === EventType.CallCandidates) { if (weSentTheEvent) return; @@ -232,6 +237,7 @@ export class CallEventHandler { } else { call.onRemoteIceCandidatesReceived(event); } + return; } else if ([EventType.CallHangup, EventType.CallReject].includes(type)) { // 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 @@ -255,10 +261,14 @@ export class CallEventHandler { this.calls.delete(content.call_id); } } + return; } - // The following events need a call - if (!call) return; + // The following events need a call and a peer connection + if (!call || !call.hasPeerConnection) { + logger.warn("Discarding an event, we don't have a call/peerConn", type); + return; + } // Ignore remote echo if (event.getContent().party_id === call.ourPartyId) return; diff --git a/tsconfig-build.json b/tsconfig-build.json new file mode 100644 index 000000000..2f59c9da8 --- /dev/null +++ b/tsconfig-build.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "sourceMap": true, + "noEmit": false, + "emitDecoratorMetadata": true, + "outDir": "./lib", + "rootDir": "src" + }, + "exclude": [ + "./spec/**/*.ts" + ] +} diff --git a/tsconfig.json b/tsconfig.json index 548bbe7fb..3a0e0cee7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,20 +1,16 @@ { "compilerOptions": { + "target": "es2016", "experimentalDecorators": true, - "emitDecoratorMetadata": true, "esModuleInterop": true, "module": "commonjs", "moduleResolution": "node", - "target": "es2016", "noImplicitAny": false, - "sourceMap": true, - "outDir": "./lib", - "declaration": true, - "types": [ - "node" - ] + "noEmit": true, + "declaration": true }, "include": [ - "./src/**/*.ts" + "./src/**/*.ts", + "./spec/**/*.ts", ] } diff --git a/yarn.lock b/yarn.lock index 2a431ea8e..737863b9d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2,6 +2,28 @@ # yarn lockfile v1 +"@actions/core@^1.4.0": + version "1.4.0" + resolved "https://registry.yarnpkg.com/@actions/core/-/core-1.4.0.tgz#cf2e6ee317e314b03886adfeb20e448d50d6e524" + integrity sha512-CGx2ilGq5i7zSLgiiGUtBCxhRRxibJYU6Fim0Q1Wg2aQL2LTnF27zbqZOrxfvFQ55eSBW0L8uVStgtKMpa0Qlg== + +"@actions/github@^5.0.0": + version "5.0.0" + resolved "https://registry.yarnpkg.com/@actions/github/-/github-5.0.0.tgz#1754127976c50bd88b2e905f10d204d76d1472f8" + integrity sha512-QvE9eAAfEsS+yOOk0cylLBIO/d6WyWIOvsxxzdrPFaud39G6BOkUwScXZn1iBzQzHyu9SBkkLSWlohDWdsasAQ== + dependencies: + "@actions/http-client" "^1.0.11" + "@octokit/core" "^3.4.0" + "@octokit/plugin-paginate-rest" "^2.13.3" + "@octokit/plugin-rest-endpoint-methods" "^5.1.1" + +"@actions/http-client@^1.0.11": + version "1.0.11" + resolved "https://registry.yarnpkg.com/@actions/http-client/-/http-client-1.0.11.tgz#c58b12e9aa8b159ee39e7dd6cbd0e91d905633c0" + integrity sha512-VRYHGQV1rqnROJqdMvGUbY/Kn8vriQe/F9HR2AlYHzmKuM/p3kjNuXhmdBfcVgsvRWTz5C5XW5xvndZrVBuAYg== + dependencies: + tunnel "0.0.6" + "@babel/cli@^7.12.10": version "7.14.8" resolved "https://registry.yarnpkg.com/@babel/cli/-/cli-7.14.8.tgz#fac73c0e2328a8af9fd3560c06b096bfa3730933" @@ -1235,7 +1257,7 @@ dependencies: "@octokit/types" "^6.0.3" -"@octokit/core@^3.5.0": +"@octokit/core@^3.4.0", "@octokit/core@^3.5.0": version "3.5.1" resolved "https://registry.yarnpkg.com/@octokit/core/-/core-3.5.1.tgz#8601ceeb1ec0e1b1b8217b960a413ed8e947809b" integrity sha512-omncwpLVxMP+GLpLPgeGJBF6IWJFjXDS5flY5VbppePYX9XehevbDykRH9PdCdvqt9TS5AOTiDide7h0qrkHjw== @@ -1271,6 +1293,18 @@ resolved "https://registry.yarnpkg.com/@octokit/openapi-types/-/openapi-types-9.3.0.tgz#160347858d727527901c6aae7f7d5c2414cc1f2e" integrity sha512-oz60hhL+mDsiOWhEwrj5aWXTOMVtQgcvP+sRzX4C3cH7WOK9QSAoEtjWh0HdOf6V3qpdgAmUMxnQPluzDWR7Fw== +"@octokit/openapi-types@^9.5.0": + version "9.7.0" + resolved "https://registry.yarnpkg.com/@octokit/openapi-types/-/openapi-types-9.7.0.tgz#9897cdefd629cd88af67b8dbe2e5fb19c63426b2" + integrity sha512-TUJ16DJU8mekne6+KVcMV5g6g/rJlrnIKn7aALG9QrNpnEipFc1xjoarh0PKaAWf2Hf+HwthRKYt+9mCm5RsRg== + +"@octokit/plugin-paginate-rest@^2.13.3": + version "2.15.1" + resolved "https://registry.yarnpkg.com/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-2.15.1.tgz#264189dd3ce881c6c33758824aac05a4002e056a" + integrity sha512-47r52KkhQDkmvUKZqXzA1lKvcyJEfYh3TKAIe5+EzMeyDM3d+/s5v11i2gTk8/n6No6DPi3k5Ind6wtDbo/AEg== + dependencies: + "@octokit/types" "^6.24.0" + "@octokit/plugin-paginate-rest@^2.6.2": version "2.15.0" resolved "https://registry.yarnpkg.com/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-2.15.0.tgz#9c956c3710b2bd786eb3814eaf5a2b17392c150d" @@ -1291,6 +1325,14 @@ "@octokit/types" "^6.23.0" deprecation "^2.3.1" +"@octokit/plugin-rest-endpoint-methods@^5.1.1": + version "5.8.0" + resolved "https://registry.yarnpkg.com/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-5.8.0.tgz#33b342fe41f2603fdf8b958e6652103bb3ea3f3b" + integrity sha512-qeLZZLotNkoq+it6F+xahydkkbnvSK0iDjlXFo3jNTB+Ss0qIbYQb9V/soKLMkgGw8Q2sHjY5YEXiA47IVPp4A== + dependencies: + "@octokit/types" "^6.25.0" + deprecation "^2.3.1" + "@octokit/request-error@^2.0.5", "@octokit/request-error@^2.1.0": version "2.1.0" resolved "https://registry.yarnpkg.com/@octokit/request-error/-/request-error-2.1.0.tgz#9e150357831bfc788d13a4fd4b1913d60c74d677" @@ -1329,6 +1371,13 @@ dependencies: "@octokit/openapi-types" "^9.3.0" +"@octokit/types@^6.24.0", "@octokit/types@^6.25.0": + version "6.25.0" + resolved "https://registry.yarnpkg.com/@octokit/types/-/types-6.25.0.tgz#c8e37e69dbe7ce55ed98ee63f75054e7e808bf1a" + integrity sha512-bNvyQKfngvAd/08COlYIN54nRgxskmejgywodizQNyiKoXmWRAjKup2/LYwm+T9V0gsKH6tuld1gM0PzmOiB4Q== + dependencies: + "@octokit/openapi-types" "^9.5.0" + "@sinonjs/commons@^1.7.0": version "1.8.3" resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-1.8.3.tgz#3802ddd21a50a949b6721ddd72da36e67e7f1b2d" @@ -1673,10 +1722,13 @@ align-text@^0.1.1, align-text@^0.1.3: longest "^1.0.1" repeat-string "^1.5.2" -"allchange@github:matrix-org/allchange": - version "0.0.1" - resolved "https://codeload.github.com/matrix-org/allchange/tar.gz/56b37b06339a3ac3fe771f3ec3d0bff798df8dab" +allchange@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/allchange/-/allchange-1.0.0.tgz#f5177b7d97f8e97a2d059a1524db9a72d94dc6d2" + integrity sha512-O0VIaMIORxOaReyYEijDfKdpudJhbzzVYLdJR1aROyUgOLBEp9e5V/TDXQpjX23W90IFCSRZxsDb3exLRD05HA== dependencies: + "@actions/core" "^1.4.0" + "@actions/github" "^5.0.0" "@octokit/rest" "^18.6.7" cli-color "^2.0.0" js-yaml "^4.1.0" @@ -7267,6 +7319,11 @@ tunnel-agent@^0.6.0: dependencies: safe-buffer "^5.0.1" +tunnel@0.0.6: + version "0.0.6" + resolved "https://registry.yarnpkg.com/tunnel/-/tunnel-0.0.6.tgz#72f1314b34a5b192db012324df2cc587ca47f92c" + integrity sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg== + tweetnacl@^0.14.3, tweetnacl@~0.14.0: version "0.14.5" resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64"