diff --git a/CHANGELOG.md b/CHANGELOG.md index 66cb4bd3b5..edb088cd64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,38 @@ +Changes in [3.58.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.58.1) (2022-10-11) +===================================================================================================== + +## 🐛 Bug Fixes + * Use correct default for notification silencing ([\#9388](https://github.com/matrix-org/matrix-react-sdk/pull/9388)). Fixes vector-im/element-web#23456. + +Changes in [3.58.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.58.0) (2022-10-11) +=============================================================================================================== + +## Deprecations + * Legacy Piwik config.json option `piwik.policy_url` is deprecated in favour of `privacy_policy_url`. Support will be removed in the next release. + +## ✨ Features + * Device manager - select all devices ([\#9330](https://github.com/matrix-org/matrix-react-sdk/pull/9330)). + * New group call experience: Call tiles ([\#9332](https://github.com/matrix-org/matrix-react-sdk/pull/9332)). + * Add Shift key to FormatQuote keyboard shortcut ([\#9298](https://github.com/matrix-org/matrix-react-sdk/pull/9298)). Contributed by @owi92. + * Device manager - sign out of multiple sessions ([\#9325](https://github.com/matrix-org/matrix-react-sdk/pull/9325)). + * Display push toggle for web sessions (MSC3890) ([\#9327](https://github.com/matrix-org/matrix-react-sdk/pull/9327)). + * Add device notifications enabled switch ([\#9324](https://github.com/matrix-org/matrix-react-sdk/pull/9324)). + * Implement push notification toggle in device detail ([\#9308](https://github.com/matrix-org/matrix-react-sdk/pull/9308)). + * New group call experience: Starting and ending calls ([\#9318](https://github.com/matrix-org/matrix-react-sdk/pull/9318)). + * New group call experience: Room header call buttons ([\#9311](https://github.com/matrix-org/matrix-react-sdk/pull/9311)). + * Make device ID copyable in device list ([\#9297](https://github.com/matrix-org/matrix-react-sdk/pull/9297)). + * Use display name instead of user ID when rendering power events ([\#9295](https://github.com/matrix-org/matrix-react-sdk/pull/9295)). + * Read receipts for threads ([\#9239](https://github.com/matrix-org/matrix-react-sdk/pull/9239)). Fixes vector-im/element-web#23191. + +## 🐛 Bug Fixes + * Use the correct sender key when checking shared secret ([\#2730](https://github.com/matrix-org/matrix-js-sdk/pull/2730)). Fixes vector-im/element-web#23374. + * Fix device selection in pre-join screen for Element Call video rooms ([\#9321](https://github.com/matrix-org/matrix-react-sdk/pull/9321)). Fixes vector-im/element-web#23331. + * Don't render a 1px high room topic if the room topic is empty ([\#9317](https://github.com/matrix-org/matrix-react-sdk/pull/9317)). Contributed by @Arnei. + * Don't show feedback prompts when that UIFeature is disabled ([\#9305](https://github.com/matrix-org/matrix-react-sdk/pull/9305)). Fixes vector-im/element-web#23327. + * Fix soft crash around unknown room pills ([\#9301](https://github.com/matrix-org/matrix-react-sdk/pull/9301)). Fixes matrix-org/element-web-rageshakes#15465. + * Fix spaces feedback prompt wrongly showing when feedback is disabled ([\#9302](https://github.com/matrix-org/matrix-react-sdk/pull/9302)). Fixes vector-im/element-web#23314. + * Fix tile soft crash in ReplyInThreadButton ([\#9300](https://github.com/matrix-org/matrix-react-sdk/pull/9300)). Fixes matrix-org/element-web-rageshakes#15493. + Changes in [3.57.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.57.0) (2022-09-28) ===================================================================================================== diff --git a/__mocks__/browser-request.js b/__mocks__/browser-request.js deleted file mode 100644 index 7029f1c190..0000000000 --- a/__mocks__/browser-request.js +++ /dev/null @@ -1,81 +0,0 @@ -/* -Copyright 2022 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -const en = require("../src/i18n/strings/en_EN"); -const de = require("../src/i18n/strings/de_DE"); -const lv = { - "Save": "Saglabāt", - "Uploading %(filename)s and %(count)s others|one": "Качване на %(filename)s и %(count)s друг", -}; - -function weblateToCounterpart(inTrs) { - const outTrs = {}; - - for (const key of Object.keys(inTrs)) { - const keyParts = key.split('|', 2); - if (keyParts.length === 2) { - let obj = outTrs[keyParts[0]]; - if (obj === undefined) { - obj = outTrs[keyParts[0]] = {}; - } else if (typeof obj === "string") { - // This is a transitional edge case if a string went from singular to pluralised and both still remain - // in the translation json file. Use the singular translation as `other` and merge pluralisation atop. - obj = outTrs[keyParts[0]] = { - "other": inTrs[key], - }; - console.warn("Found entry in i18n file in both singular and pluralised form", keyParts[0]); - } - obj[keyParts[1]] = inTrs[key]; - } else { - outTrs[key] = inTrs[key]; - } - } - - return outTrs; -} - -// Mock the browser-request for the languageHandler tests to return -// Fake languages.json containing references to en_EN, de_DE and lv -// en_EN.json -// de_DE.json -// lv.json - mock version with few translations, used to test fallback translation -module.exports = jest.fn((opts, cb) => { - const url = opts.url || opts.uri; - if (url && url.endsWith("languages.json")) { - cb(undefined, { status: 200 }, JSON.stringify({ - "en": { - "fileName": "en_EN.json", - "label": "English", - }, - "de": { - "fileName": "de_DE.json", - "label": "German", - }, - "lv": { - "fileName": "lv.json", - "label": "Latvian", - }, - })); - } else if (url && url.endsWith("en_EN.json")) { - cb(undefined, { status: 200 }, JSON.stringify(weblateToCounterpart(en))); - } else if (url && url.endsWith("de_DE.json")) { - cb(undefined, { status: 200 }, JSON.stringify(weblateToCounterpart(de))); - } else if (url && url.endsWith("lv.json")) { - cb(undefined, { status: 200 }, JSON.stringify(weblateToCounterpart(lv))); - } else { - cb(true, { status: 404 }, ""); - } -}); diff --git a/cypress/e2e/settings/device-management.spec.ts b/cypress/e2e/settings/device-management.spec.ts new file mode 100644 index 0000000000..c3ef4db838 --- /dev/null +++ b/cypress/e2e/settings/device-management.spec.ts @@ -0,0 +1,117 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/// + +import { SynapseInstance } from "../../plugins/synapsedocker"; +import type { UserCredentials } from "../../support/login"; + +describe("Device manager", () => { + let synapse: SynapseInstance | undefined; + let user: UserCredentials | undefined; + + beforeEach(() => { + cy.enableLabsFeature("feature_new_device_manager"); + cy.startSynapse("default").then(data => { + synapse = data; + + cy.initTestUser(synapse, "Alice").then(credentials => { + user = credentials; + }).then(() => { + // create some extra sessions to manage + return cy.loginUser(synapse, user.username, user.password); + }).then(() => { + return cy.loginUser(synapse, user.username, user.password); + }); + }); + }); + + afterEach(() => { + cy.stopSynapse(synapse!); + }); + + it("should display sessions", () => { + cy.openUserSettings("Sessions"); + cy.contains('Current session').should('exist'); + + cy.get('[data-testid="current-session-section"]').within(() => { + cy.contains('Unverified session').should('exist'); + cy.get('.mx_DeviceSecurityCard_actions [role="button"]').should('exist'); + }); + + // current session details opened + cy.get('[data-testid="current-session-toggle-details"]').click(); + cy.contains('Session details').should('exist'); + + // close current session details + cy.get('[data-testid="current-session-toggle-details"]').click(); + cy.contains('Session details').should('not.exist'); + + cy.get('[data-testid="security-recommendations-section"]').within(() => { + cy.contains('Security recommendations').should('exist'); + cy.get('[data-testid="unverified-devices-cta"]').should('have.text', 'View all (3)').click(); + }); + + /** + * Other sessions section + */ + cy.contains('Other sessions').should('exist'); + // filter applied after clicking through from security recommendations + cy.get('[aria-label="Filter devices"]').should('have.text', 'Show: Unverified'); + cy.get('.mx_FilteredDeviceList_list').find('.mx_FilteredDeviceList_listItem').should('have.length', 3); + + // select two sessions + cy.get('.mx_FilteredDeviceList_list .mx_FilteredDeviceList_listItem').first().click(); + cy.get('.mx_FilteredDeviceList_list .mx_FilteredDeviceList_listItem').last().click(); + // sign out from list selection action buttons + cy.get('[data-testid="sign-out-selection-cta"]').click(); + // list updated after sign out + cy.get('.mx_FilteredDeviceList_list').find('.mx_FilteredDeviceList_listItem').should('have.length', 1); + // security recommendation count updated + cy.get('[data-testid="unverified-devices-cta"]').should('have.text', 'View all (1)'); + + const sessionName = `Alice's device`; + // select the first session + cy.get('.mx_FilteredDeviceList_list .mx_FilteredDeviceList_listItem').first().within(() => { + cy.get('[aria-label="Toggle device details"]').click(); + + cy.contains('Session details').should('exist'); + + cy.get('[data-testid="device-heading-rename-cta"]').click(); + cy.get('[data-testid="device-rename-input"]').type(sessionName); + cy.get('[data-testid="device-rename-submit-cta"]').click(); + // there should be a spinner while device updates + cy.get(".mx_Spinner").should("exist"); + // wait for spinner to complete + cy.get(".mx_Spinner").should("not.exist"); + + // session name updated in details + cy.get('.mx_DeviceDetailHeading h3').should('have.text', sessionName); + // and main list item + cy.get('.mx_DeviceTile h4').should('have.text', sessionName); + + // sign out using the device details sign out + cy.get('[data-testid="device-detail-sign-out-cta"]').click(); + }); + + // list updated after sign out + cy.get('.mx_FilteredDeviceList_list').find('.mx_FilteredDeviceList_listItem').should('have.length', 1); + + // no other sessions or security recommendations sections when only one session + cy.contains('Other sessions').should('not.exist'); + cy.get('[data-testid="security-recommendations-section"]').should('not.exist'); + }); +}); diff --git a/cypress/e2e/spotlight/spotlight.spec.ts b/cypress/e2e/spotlight/spotlight.spec.ts index 71340d44f6..adcc141deb 100644 --- a/cypress/e2e/spotlight/spotlight.spec.ts +++ b/cypress/e2e/spotlight/spotlight.spec.ts @@ -124,7 +124,8 @@ Cypress.Commands.add("startDM", (name: string) => { cy.get(".mx_BasicMessageComposer_input") .should("have.focus") .type("Hey!{enter}"); - cy.contains(".mx_EventTile_body", "Hey!"); + // The DM room is created at this point, this can take a little bit of time + cy.contains(".mx_EventTile_body", "Hey!", { timeout: 30000 }); cy.contains(".mx_RoomSublist[aria-label=People]", name); }); @@ -217,7 +218,7 @@ describe("Spotlight", () => { it("should find joined rooms", () => { cy.openSpotlightDialog().within(() => { cy.spotlightSearch().clear().type(room1Name); - cy.wait(1000); // wait for the dialog code to settle + cy.wait(3000); // wait for the dialog code to settle cy.spotlightResults().should("have.length", 1); cy.spotlightResults().eq(0).should("contain", room1Name); cy.spotlightResults().eq(0).click(); @@ -231,7 +232,7 @@ describe("Spotlight", () => { cy.openSpotlightDialog().within(() => { cy.spotlightFilter(Filter.PublicRooms); cy.spotlightSearch().clear().type(room1Name); - cy.wait(1000); // wait for the dialog code to settle + cy.wait(3000); // wait for the dialog code to settle cy.spotlightResults().should("have.length", 1); cy.spotlightResults().eq(0).should("contain", room1Name); cy.spotlightResults().eq(0).should("contain", "View"); @@ -246,7 +247,7 @@ describe("Spotlight", () => { cy.openSpotlightDialog().within(() => { cy.spotlightFilter(Filter.PublicRooms); cy.spotlightSearch().clear().type(room2Name); - cy.wait(1000); // wait for the dialog code to settle + cy.wait(3000); // wait for the dialog code to settle cy.spotlightResults().should("have.length", 1); cy.spotlightResults().eq(0).should("contain", room2Name); cy.spotlightResults().eq(0).should("contain", "Join"); @@ -262,7 +263,7 @@ describe("Spotlight", () => { cy.openSpotlightDialog().within(() => { cy.spotlightFilter(Filter.PublicRooms); cy.spotlightSearch().clear().type(room3Name); - cy.wait(1000); // wait for the dialog code to settle + cy.wait(3000); // wait for the dialog code to settle cy.spotlightResults().should("have.length", 1); cy.spotlightResults().eq(0).should("contain", room3Name); cy.spotlightResults().eq(0).should("contain", "View"); @@ -301,7 +302,7 @@ describe("Spotlight", () => { cy.openSpotlightDialog().within(() => { cy.spotlightFilter(Filter.People); cy.spotlightSearch().clear().type(bot1Name); - cy.wait(1000); // wait for the dialog code to settle + cy.wait(3000); // wait for the dialog code to settle cy.spotlightResults().should("have.length", 1); cy.spotlightResults().eq(0).should("contain", bot1Name); cy.spotlightResults().eq(0).click(); @@ -314,7 +315,7 @@ describe("Spotlight", () => { cy.openSpotlightDialog().within(() => { cy.spotlightFilter(Filter.People); cy.spotlightSearch().clear().type(bot2Name); - cy.wait(1000); // wait for the dialog code to settle + cy.wait(3000); // wait for the dialog code to settle cy.spotlightResults().should("have.length", 1); cy.spotlightResults().eq(0).should("contain", bot2Name); cy.spotlightResults().eq(0).click(); @@ -331,7 +332,7 @@ describe("Spotlight", () => { cy.openSpotlightDialog().within(() => { cy.spotlightFilter(Filter.People); cy.spotlightSearch().clear().type(bot2Name); - cy.wait(1000); // wait for the dialog code to settle + cy.wait(3000); // wait for the dialog code to settle cy.spotlightResults().should("have.length", 1); cy.spotlightResults().eq(0).should("contain", bot2Name); cy.spotlightResults().eq(0).click(); @@ -345,7 +346,7 @@ describe("Spotlight", () => { .type("Hey!{enter}"); // Assert DM exists by checking for the first message and the room being in the room list - cy.contains(".mx_EventTile_body", "Hey!"); + cy.contains(".mx_EventTile_body", "Hey!", { timeout: 30000 }); cy.get(".mx_RoomSublist[aria-label=People]").should("contain", bot2Name); // Invite BotBob into existing DM with ByteBot @@ -409,7 +410,7 @@ describe("Spotlight", () => { cy.openSpotlightDialog().within(() => { cy.spotlightFilter(Filter.People); cy.spotlightSearch().clear().type(bot2Name); - cy.wait(1000); // wait for the dialog code to settle + cy.wait(3000); // wait for the dialog code to settle cy.spotlightResults().should("have.length", 1); cy.spotlightResults().eq(0).should("contain", bot2Name); cy.get(".mx_SpotlightDialog_startGroupChat").should("contain", "Start a group chat"); @@ -431,7 +432,7 @@ describe("Spotlight", () => { cy.openSpotlightDialog().within(() => { cy.spotlightFilter(Filter.People); cy.spotlightSearch().clear().type(bot1Name); - cy.wait(1000); // wait for the dialog code to settle + cy.wait(3000); // wait for the dialog code to settle cy.get(".mx_Spinner").should("not.exist"); cy.spotlightResults().should("have.length", 1); }); diff --git a/cypress/e2e/timeline/timeline.spec.ts b/cypress/e2e/timeline/timeline.spec.ts index 94b6ffaa42..6cebbfd181 100644 --- a/cypress/e2e/timeline/timeline.spec.ts +++ b/cypress/e2e/timeline/timeline.spec.ts @@ -91,11 +91,11 @@ describe("Timeline", () => { describe("useOnlyCurrentProfiles", () => { beforeEach(() => { - cy.uploadContent(OLD_AVATAR).then((url) => { + cy.uploadContent(OLD_AVATAR).then(({ content_uri: url }) => { oldAvatarUrl = url; cy.setAvatarUrl(url); }); - cy.uploadContent(NEW_AVATAR).then((url) => { + cy.uploadContent(NEW_AVATAR).then(({ content_uri: url }) => { newAvatarUrl = url; }); }); @@ -271,7 +271,7 @@ describe("Timeline", () => { cy.get(".mx_RoomHeader_searchButton").click(); cy.get(".mx_SearchBar_input input").type("Message{enter}"); - cy.get(".mx_EventTile:not(.mx_EventTile_contextual)").find(".mx_EventTile_searchHighlight").should("exist"); + cy.get(".mx_EventTile:not(.mx_EventTile_contextual) .mx_EventTile_searchHighlight").should("exist"); cy.get(".mx_RoomView_searchResultsPanel").percySnapshotElement("Highlighted search results"); }); diff --git a/cypress/support/bot.ts b/cypress/support/bot.ts index 35da14ebd7..26f0aa497e 100644 --- a/cypress/support/bot.ts +++ b/cypress/support/bot.ts @@ -16,8 +16,6 @@ limitations under the License. /// -import request from "browser-request"; - import type { ISendEventResponse, MatrixClient, Room } from "matrix-js-sdk/src/matrix"; import { SynapseInstance } from "../plugins/synapsedocker"; import Chainable = Cypress.Chainable; @@ -86,7 +84,6 @@ Cypress.Commands.add("getBot", (synapse: SynapseInstance, opts: CreateBotOpts): userId: credentials.userId, deviceId: credentials.deviceId, accessToken: credentials.accessToken, - request, store: new win.matrixcs.MemoryStore(), scheduler: new win.matrixcs.MatrixScheduler(), cryptoStore: new win.matrixcs.MemoryCryptoStore(), diff --git a/cypress/support/client.ts b/cypress/support/client.ts index c3f3aab0eb..e20c08a813 100644 --- a/cypress/support/client.ts +++ b/cypress/support/client.ts @@ -16,9 +16,8 @@ limitations under the License. /// -import type { FileType, UploadContentResponseType } from "matrix-js-sdk/src/http-api"; -import type { IAbortablePromise } from "matrix-js-sdk/src/@types/partials"; -import type { ICreateRoomOpts, ISendEventResponse, IUploadOpts } from "matrix-js-sdk/src/@types/requests"; +import type { FileType, Upload, UploadOpts } from "matrix-js-sdk/src/http-api"; +import type { ICreateRoomOpts, ISendEventResponse } from "matrix-js-sdk/src/@types/requests"; import type { MatrixClient } from "matrix-js-sdk/src/client"; import type { Room } from "matrix-js-sdk/src/models/room"; import type { IContent } from "matrix-js-sdk/src/models/event"; @@ -90,10 +89,10 @@ declare global { * can be sent to XMLHttpRequest.send (typically a File). Under node.js, * a a Buffer, String or ReadStream. */ - uploadContent( + uploadContent( file: FileType, - opts?: O, - ): IAbortablePromise>; + opts?: UploadOpts, + ): Chainable>; /** * Turn an MXC URL into an HTTP one. This method is experimental and * may change. @@ -203,9 +202,9 @@ Cypress.Commands.add("setDisplayName", (name: string): Chainable<{}> => { }); }); -Cypress.Commands.add("uploadContent", (file: FileType): Chainable<{}> => { +Cypress.Commands.add("uploadContent", (file: FileType, opts?: UploadOpts): Chainable> => { return cy.getClient().then(async (cli: MatrixClient) => { - return cli.uploadContent(file); + return cli.uploadContent(file, opts); }); }); diff --git a/cypress/support/login.ts b/cypress/support/login.ts index 46b1b7a89f..e44be78123 100644 --- a/cypress/support/login.ts +++ b/cypress/support/login.ts @@ -21,6 +21,7 @@ import { SynapseInstance } from "../plugins/synapsedocker"; export interface UserCredentials { accessToken: string; + username: string; userId: string; deviceId: string; password: string; @@ -42,26 +43,25 @@ declare global { displayName: string, prelaunchFn?: () => void, ): Chainable; + /** + * Logs into synapse with the given username/password + * @param synapse the synapse returned by startSynapse + * @param username login username + * @param password login password + */ + loginUser( + synapse: SynapseInstance, + username: string, + password: string, + ): Chainable; } } } // eslint-disable-next-line max-len -Cypress.Commands.add("initTestUser", (synapse: SynapseInstance, displayName: string, prelaunchFn?: () => void): Chainable => { - // XXX: work around Cypress not clearing IDB between tests - cy.window({ log: false }).then(win => { - win.indexedDB.databases().then(databases => { - databases.forEach(database => { - win.indexedDB.deleteDatabase(database.name); - }); - }); - }); - - const username = Cypress._.uniqueId("userId_"); - const password = Cypress._.uniqueId("password_"); - return cy.registerUser(synapse, username, password, displayName).then(() => { - const url = `${synapse.baseUrl}/_matrix/client/r0/login`; - return cy.request<{ +Cypress.Commands.add("loginUser", (synapse: SynapseInstance, username: string, password: string): Chainable => { + const url = `${synapse.baseUrl}/_matrix/client/r0/login`; + return cy.request<{ access_token: string; user_id: string; device_id: string; @@ -77,14 +77,38 @@ Cypress.Commands.add("initTestUser", (synapse: SynapseInstance, displayName: str }, "password": password, }, + }).then(response => ({ + password, + username, + accessToken: response.body.access_token, + userId: response.body.user_id, + deviceId: response.body.device_id, + homeServer: response.body.home_server, + })); +}); + +// eslint-disable-next-line max-len +Cypress.Commands.add("initTestUser", (synapse: SynapseInstance, displayName: string, prelaunchFn?: () => void): Chainable => { + // XXX: work around Cypress not clearing IDB between tests + cy.window({ log: false }).then(win => { + win.indexedDB.databases().then(databases => { + databases.forEach(database => { + win.indexedDB.deleteDatabase(database.name); + }); }); + }); + + const username = Cypress._.uniqueId("userId_"); + const password = Cypress._.uniqueId("password_"); + return cy.registerUser(synapse, username, password, displayName).then(() => { + return cy.loginUser(synapse, username, password); }).then(response => { cy.window({ log: false }).then(win => { // Seed the localStorage with the required credentials win.localStorage.setItem("mx_hs_url", synapse.baseUrl); - win.localStorage.setItem("mx_user_id", response.body.user_id); - win.localStorage.setItem("mx_access_token", response.body.access_token); - win.localStorage.setItem("mx_device_id", response.body.device_id); + win.localStorage.setItem("mx_user_id", response.userId); + win.localStorage.setItem("mx_access_token", response.accessToken); + win.localStorage.setItem("mx_device_id", response.deviceId); win.localStorage.setItem("mx_is_guest", "false"); win.localStorage.setItem("mx_has_pickle_key", "false"); win.localStorage.setItem("mx_has_access_token", "true"); @@ -100,10 +124,11 @@ Cypress.Commands.add("initTestUser", (synapse: SynapseInstance, displayName: str return cy.get(".mx_MatrixChat", { timeout: 30000 }); }).then(() => ({ password, - accessToken: response.body.access_token, - userId: response.body.user_id, - deviceId: response.body.device_id, - homeServer: response.body.home_server, + username, + accessToken: response.accessToken, + userId: response.userId, + deviceId: response.deviceId, + homeServer: response.homeServer, })); }); }); diff --git a/docs/settings.md b/docs/settings.md index dae6eb22b8..884e4c8350 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -110,7 +110,7 @@ Features can be controlled at the config level using the following structure: ``` When `true`, the user will see the feature as enabled. Similarly, when `false` the user will see the feature as disabled. -The user will only be able to change/see these states if `showLabsSettings: true` is in the config. +The user will only be able to change/see these states if `show_labs_settings: true` is in the config. ### Determining if a feature is enabled diff --git a/package.json b/package.json index a8bee96ee4..f5c817ffd8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-react-sdk", - "version": "3.57.0", + "version": "3.58.1", "description": "SDK for matrix.org using React", "author": "matrix.org", "repository": { @@ -65,7 +65,6 @@ "@types/ua-parser-js": "^0.7.36", "await-lock": "^2.1.0", "blurhash": "^1.1.3", - "browser-request": "^0.3.3", "cheerio": "^1.0.0-rc.9", "classnames": "^2.2.6", "commonmark": "^0.29.3", @@ -190,17 +189,16 @@ "eslint-plugin-matrix-org": "^0.6.1", "eslint-plugin-react": "^7.28.0", "eslint-plugin-react-hooks": "^4.3.0", + "fetch-mock-jest": "^1.5.1", "fs-extra": "^10.0.1", "glob": "^7.1.6", "jest": "^27.4.0", "jest-canvas-mock": "^2.3.0", "jest-environment-jsdom": "^27.0.6", - "jest-fetch-mock": "^3.0.3", "jest-mock": "^27.5.1", "jest-raw-loader": "^1.0.1", "jest-sonar-reporter": "^2.0.0", - "matrix-mock-request": "^2.0.0", - "matrix-react-test-utils": "^0.2.3", + "matrix-mock-request": "^2.5.0", "matrix-web-i18n": "^1.3.0", "postcss-scss": "^4.0.4", "raw-loader": "^4.0.2", diff --git a/res/css/_components.pcss b/res/css/_components.pcss index dd546efbd6..09ecee1afa 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -17,6 +17,7 @@ @import "./components/views/beacon/_RoomLiveShareWarning.pcss"; @import "./components/views/beacon/_ShareLatestLocation.pcss"; @import "./components/views/beacon/_StyledLiveBeaconIcon.pcss"; +@import "./components/views/context_menus/_KebabContextMenu.pcss"; @import "./components/views/elements/_FilterDropdown.pcss"; @import "./components/views/location/_EnableLiveShare.pcss"; @import "./components/views/location/_LiveDurationDropdown.pcss"; @@ -40,6 +41,7 @@ @import "./components/views/settings/devices/_SecurityRecommendations.pcss"; @import "./components/views/settings/devices/_SelectableDeviceTile.pcss"; @import "./components/views/settings/shared/_SettingsSubsection.pcss"; +@import "./components/views/settings/shared/_SettingsSubsectionHeading.pcss"; @import "./components/views/spaces/_QuickThemeSwitcher.pcss"; @import "./components/views/typography/_Caption.pcss"; @import "./structures/_AutoHideScrollbar.pcss"; @@ -364,4 +366,5 @@ @import "./views/voip/_PiPContainer.pcss"; @import "./views/voip/_VideoFeed.pcss"; @import "./voice-broadcast/atoms/_LiveBadge.pcss"; +@import "./voice-broadcast/atoms/_VoiceBroadcastHeader.pcss"; @import "./voice-broadcast/molecules/_VoiceBroadcastRecordingBody.pcss"; diff --git a/res/css/components/atoms/_Icon.pcss b/res/css/components/atoms/_Icon.pcss index 08a72d3d5b..b9d994e43f 100644 --- a/res/css/components/atoms/_Icon.pcss +++ b/res/css/components/atoms/_Icon.pcss @@ -15,9 +15,13 @@ limitations under the License. */ .mx_Icon { + box-sizing: border-box; + display: inline-block; + mask-origin: content-box; mask-position: center; mask-repeat: no-repeat; mask-size: contain; + padding: 1px; } .mx_Icon_16 { @@ -32,3 +36,7 @@ limitations under the License. .mx_Icon_live-badge { background-color: #fff; } + +.mx_Icon_compound-secondary-content { + background-color: $secondary-content; +} diff --git a/src/voice-broadcast/stores/index.ts b/res/css/components/views/context_menus/_KebabContextMenu.pcss similarity index 88% rename from src/voice-broadcast/stores/index.ts rename to res/css/components/views/context_menus/_KebabContextMenu.pcss index db63f1311e..1594420aea 100644 --- a/src/voice-broadcast/stores/index.ts +++ b/res/css/components/views/context_menus/_KebabContextMenu.pcss @@ -14,4 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -export * from "./VoiceBroadcastRecordingsStore"; +.mx_KebabContextMenu_icon { + width: 24px; + color: $secondary-content; +} diff --git a/res/css/components/views/settings/shared/_SettingsSubsection.pcss b/res/css/components/views/settings/shared/_SettingsSubsection.pcss index 9eb51696ba..2ba909aac1 100644 --- a/res/css/components/views/settings/shared/_SettingsSubsection.pcss +++ b/res/css/components/views/settings/shared/_SettingsSubsection.pcss @@ -19,10 +19,6 @@ limitations under the License. box-sizing: border-box; } -.mx_SettingsSubsection_heading { - padding-bottom: $spacing-8; -} - .mx_SettingsSubsection_description { width: 100%; box-sizing: inherit; diff --git a/test/.eslintrc.js b/res/css/components/views/settings/shared/_SettingsSubsectionHeading.pcss similarity index 74% rename from test/.eslintrc.js rename to res/css/components/views/settings/shared/_SettingsSubsectionHeading.pcss index ee22692130..e6d4bf4be7 100644 --- a/test/.eslintrc.js +++ b/res/css/components/views/settings/shared/_SettingsSubsectionHeading.pcss @@ -14,13 +14,14 @@ See the License for the specific language governing permissions and limitations under the License. */ -module.exports = { - env: { - mocha: true, - }, +.mx_SettingsSubsectionHeading { + display: flex; + flex-direction: row; + padding-bottom: $spacing-8; - // mocha defines a 'this' - rules: { - "@babel/no-invalid-this": "off", - }, -}; + gap: $spacing-8; +} + +.mx_SettingsSubsectionHeading_heading { + flex: 1 1 100%; +} diff --git a/res/css/views/context_menus/_IconizedContextMenu.pcss b/res/css/views/context_menus/_IconizedContextMenu.pcss index 48d2d72590..c34168b2bd 100644 --- a/res/css/views/context_menus/_IconizedContextMenu.pcss +++ b/res/css/views/context_menus/_IconizedContextMenu.pcss @@ -82,7 +82,8 @@ limitations under the License. display: flex; align-items: center; - &:hover { + &:hover, + &:focus { background-color: $menu-selected-color; } @@ -187,3 +188,7 @@ limitations under the License. color: $tertiary-content; } } + +.mx_IconizedContextMenu_item.mx_IconizedContextMenu_itemDestructive { + color: $alert !important; +} diff --git a/res/css/voice-broadcast/atoms/_VoiceBroadcastHeader.pcss b/res/css/voice-broadcast/atoms/_VoiceBroadcastHeader.pcss new file mode 100644 index 0000000000..dc1522811c --- /dev/null +++ b/res/css/voice-broadcast/atoms/_VoiceBroadcastHeader.pcss @@ -0,0 +1,44 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_VoiceBroadcastHeader { + align-items: flex-start; + display: flex; + gap: $spacing-8; + line-height: 20px; + margin-bottom: $spacing-8; + width: 266px; +} + +.mx_VoiceBroadcastHeader_sender { + font-size: $font-12px; + font-weight: $font-semi-bold; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.mx_VoiceBroadcastHeader_room { + font-size: $font-12px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.mx_VoiceBroadcastHeader_line { + align-items: center; + color: $secondary-content; + font-size: $font-12px; + display: flex; + gap: $spacing-4; +} diff --git a/scripts/ci/layered.sh b/scripts/ci/layered.sh index fdde9dd828..bb002bd3ab 100755 --- a/scripts/ci/layered.sh +++ b/scripts/ci/layered.sh @@ -1,5 +1,7 @@ #!/bin/bash +set -ex + # Creates a layered environment with the full repo for the app and SDKs cloned # and linked. This gives an element-web dev environment ready to build with # the current react-sdk branch and any matching branches of react-sdk's dependencies diff --git a/src/AddThreepid.ts b/src/AddThreepid.ts index 415d6d7ad0..95ebbec0f5 100644 --- a/src/AddThreepid.ts +++ b/src/AddThreepid.ts @@ -85,7 +85,7 @@ export default class AddThreepid { const identityAccessToken = await authClient.getAccessToken(); return MatrixClientPeg.get().requestEmailToken( emailAddress, this.clientSecret, 1, - undefined, undefined, identityAccessToken, + undefined, identityAccessToken, ).then((res) => { this.sessionId = res.sid; return res; @@ -142,7 +142,7 @@ export default class AddThreepid { const identityAccessToken = await authClient.getAccessToken(); return MatrixClientPeg.get().requestMsisdnToken( phoneCountry, phoneNumber, this.clientSecret, 1, - undefined, undefined, identityAccessToken, + undefined, identityAccessToken, ).then((res) => { this.sessionId = res.sid; return res; diff --git a/src/ContentMessages.ts b/src/ContentMessages.ts index 1da1bfe1d6..d4cf3cc0ab 100644 --- a/src/ContentMessages.ts +++ b/src/ContentMessages.ts @@ -17,16 +17,16 @@ limitations under the License. */ import { MatrixClient } from "matrix-js-sdk/src/client"; -import { IUploadOpts } from "matrix-js-sdk/src/@types/requests"; import { MsgType } from "matrix-js-sdk/src/@types/event"; import encrypt from "matrix-encrypt-attachment"; import extractPngChunks from "png-chunks-extract"; -import { IAbortablePromise, IImageInfo } from "matrix-js-sdk/src/@types/partials"; +import { IImageInfo } from "matrix-js-sdk/src/@types/partials"; import { logger } from "matrix-js-sdk/src/logger"; -import { IEventRelation, ISendEventResponse, MatrixError, MatrixEvent } from "matrix-js-sdk/src/matrix"; +import { IEventRelation, ISendEventResponse, MatrixEvent, UploadOpts, UploadProgress } from "matrix-js-sdk/src/matrix"; import { THREAD_RELATION_TYPE } from "matrix-js-sdk/src/models/thread"; +import { removeElement } from "matrix-js-sdk/src/utils"; -import { IEncryptedFile, IMediaEventInfo } from "./customisations/models/IMediaEventContent"; +import { IEncryptedFile, IMediaEventContent, IMediaEventInfo } from "./customisations/models/IMediaEventContent"; import dis from './dispatcher/dispatcher'; import { _t } from './languageHandler'; import Modal from './Modal'; @@ -39,7 +39,7 @@ import { UploadProgressPayload, UploadStartedPayload, } from "./dispatcher/payloads/UploadPayload"; -import { IUpload } from "./models/IUpload"; +import { RoomUpload } from "./models/RoomUpload"; import SettingsStore from "./settings/SettingsStore"; import { decorateStartSendingTime, sendRoundTripMetric } from "./sendTimePerformanceMetrics"; import { TimelineRenderingType } from "./contexts/RoomContext"; @@ -62,14 +62,6 @@ interface IMediaConfig { "m.upload.size"?: number; } -interface IContent { - body: string; - msgtype: string; - info: IMediaEventInfo; - file?: string; - url?: string; -} - /** * Load a file into a newly created image element. * @@ -78,7 +70,7 @@ interface IContent { */ async function loadImageElement(imageFile: File) { // Load the file into an html element - const img = document.createElement("img"); + const img = new Image(); const objectUrl = URL.createObjectURL(imageFile); const imgPromise = new Promise((resolve, reject) => { img.onload = function() { @@ -93,7 +85,7 @@ async function loadImageElement(imageFile: File) { // check for hi-dpi PNGs and fudge display resolution as needed. // this is mainly needed for macOS screencaps - let parsePromise; + let parsePromise: Promise; if (imageFile.type === "image/png") { // in practice macOS happens to order the chunks so they fall in // the first 0x1000 bytes (thanks to a massive ICC header). @@ -277,71 +269,58 @@ function readFileAsArrayBuffer(file: File | Blob): Promise { * @param {File} file The file to upload. * @param {Function?} progressHandler optional callback to be called when a chunk of * data is uploaded. + * @param {AbortController?} controller optional abortController to use for this upload. * @return {Promise} A promise that resolves with an object. * If the file is unencrypted then the object will have a "url" key. * If the file is encrypted then the object will have a "file" key. */ -export function uploadFile( +export async function uploadFile( matrixClient: MatrixClient, roomId: string, file: File | Blob, - progressHandler?: IUploadOpts["progressHandler"], -): IAbortablePromise<{ url?: string, file?: IEncryptedFile }> { - let canceled = false; + progressHandler?: UploadOpts["progressHandler"], + controller?: AbortController, +): Promise<{ url?: string, file?: IEncryptedFile }> { + const abortController = controller ?? new AbortController(); + + // If the room is encrypted then encrypt the file before uploading it. if (matrixClient.isRoomEncrypted(roomId)) { - // If the room is encrypted then encrypt the file before uploading it. // First read the file into memory. - let uploadPromise: IAbortablePromise; - const prom = readFileAsArrayBuffer(file).then(function(data) { - if (canceled) throw new UploadCanceledError(); - // Then encrypt the file. - return encrypt.encryptAttachment(data); - }).then(function(encryptResult) { - if (canceled) throw new UploadCanceledError(); + const data = await readFileAsArrayBuffer(file); + if (abortController.signal.aborted) throw new UploadCanceledError(); - // Pass the encrypted data as a Blob to the uploader. - const blob = new Blob([encryptResult.data]); - uploadPromise = matrixClient.uploadContent(blob, { - progressHandler, - includeFilename: false, - }); + // Then encrypt the file. + const encryptResult = await encrypt.encryptAttachment(data); + if (abortController.signal.aborted) throw new UploadCanceledError(); - return uploadPromise.then(url => { - if (canceled) throw new UploadCanceledError(); + // Pass the encrypted data as a Blob to the uploader. + const blob = new Blob([encryptResult.data]); - // If the attachment is encrypted then bundle the URL along - // with the information needed to decrypt the attachment and - // add it under a file key. - return { - file: { - ...encryptResult.info, - url, - }, - }; - }); - }) as IAbortablePromise<{ file: IEncryptedFile }>; - prom.abort = () => { - canceled = true; - if (uploadPromise) matrixClient.cancelUpload(uploadPromise); + const { content_uri: url } = await matrixClient.uploadContent(blob, { + progressHandler, + abortController, + includeFilename: false, + }); + if (abortController.signal.aborted) throw new UploadCanceledError(); + + // If the attachment is encrypted then bundle the URL along with the information + // needed to decrypt the attachment and add it under a file key. + return { + file: { + ...encryptResult.info, + url, + } as IEncryptedFile, }; - return prom; } else { - const basePromise = matrixClient.uploadContent(file, { progressHandler }); - const promise1 = basePromise.then(function(url) { - if (canceled) throw new UploadCanceledError(); - // If the attachment isn't encrypted then include the URL directly. - return { url }; - }) as IAbortablePromise<{ url: string }>; - promise1.abort = () => { - canceled = true; - matrixClient.cancelUpload(basePromise); - }; - return promise1; + const { content_uri: url } = await matrixClient.uploadContent(file, { progressHandler, abortController }); + if (abortController.signal.aborted) throw new UploadCanceledError(); + // If the attachment isn't encrypted then include the URL directly. + return { url }; } } export default class ContentMessages { - private inprogress: IUpload[] = []; + private inprogress: RoomUpload[] = []; private mediaConfig: IMediaConfig = null; public sendStickerContentToRoom( @@ -460,36 +439,33 @@ export default class ContentMessages { }); } - public getCurrentUploads(relation?: IEventRelation): IUpload[] { - return this.inprogress.filter(upload => { - const noRelation = !relation && !upload.relation; - const matchingRelation = relation && upload.relation - && relation.rel_type === upload.relation.rel_type - && relation.event_id === upload.relation.event_id; + public getCurrentUploads(relation?: IEventRelation): RoomUpload[] { + return this.inprogress.filter(roomUpload => { + const noRelation = !relation && !roomUpload.relation; + const matchingRelation = relation && roomUpload.relation + && relation.rel_type === roomUpload.relation.rel_type + && relation.event_id === roomUpload.relation.event_id; - return (noRelation || matchingRelation) && !upload.canceled; + return (noRelation || matchingRelation) && !roomUpload.cancelled; }); } - public cancelUpload(promise: IAbortablePromise, matrixClient: MatrixClient): void { - const upload = this.inprogress.find(item => item.promise === promise); - if (upload) { - upload.canceled = true; - matrixClient.cancelUpload(upload.promise); - dis.dispatch({ action: Action.UploadCanceled, upload }); - } + public cancelUpload(upload: RoomUpload): void { + upload.abort(); + dis.dispatch({ action: Action.UploadCanceled, upload }); } - private sendContentToRoom( + public async sendContentToRoom( file: File, roomId: string, relation: IEventRelation | undefined, matrixClient: MatrixClient, replyToEvent: MatrixEvent | undefined, - promBefore: Promise, + promBefore?: Promise, ) { - const content: Omit & { info: Partial } = { - body: file.name || 'Attachment', + const fileName = file.name || _t("Attachment"); + const content: Omit & { info: Partial } = { + body: fileName, info: { size: file.size, }, @@ -512,91 +488,72 @@ export default class ContentMessages { content.info.mimetype = file.type; } - const prom = new Promise((resolve) => { - if (file.type.indexOf('image/') === 0) { - content.msgtype = MsgType.Image; - infoForImageFile(matrixClient, roomId, file).then((imageInfo) => { - Object.assign(content.info, imageInfo); - resolve(); - }, (e) => { - // Failed to thumbnail, fall back to uploading an m.file - logger.error(e); - content.msgtype = MsgType.File; - resolve(); - }); - } else if (file.type.indexOf('audio/') === 0) { - content.msgtype = MsgType.Audio; - resolve(); - } else if (file.type.indexOf('video/') === 0) { - content.msgtype = MsgType.Video; - infoForVideoFile(matrixClient, roomId, file).then((videoInfo) => { - Object.assign(content.info, videoInfo); - resolve(); - }, (e) => { - // Failed to thumbnail, fall back to uploading an m.file - logger.error(e); - content.msgtype = MsgType.File; - resolve(); - }); - } else { - content.msgtype = MsgType.File; - resolve(); - } - }) as IAbortablePromise; - - // create temporary abort handler for before the actual upload gets passed off to js-sdk - prom.abort = () => { - upload.canceled = true; - }; - - const upload: IUpload = { - fileName: file.name || 'Attachment', - roomId, - relation, - total: file.size, - loaded: 0, - promise: prom, - }; + const upload = new RoomUpload(roomId, fileName, relation, file.size); this.inprogress.push(upload); dis.dispatch({ action: Action.UploadStarted, upload }); - function onProgress(ev) { - upload.total = ev.total; - upload.loaded = ev.loaded; + function onProgress(progress: UploadProgress) { + upload.onProgress(progress); dis.dispatch({ action: Action.UploadProgress, upload }); } - let error: MatrixError; - return prom.then(() => { - if (upload.canceled) throw new UploadCanceledError(); - // XXX: upload.promise must be the promise that - // is returned by uploadFile as it has an abort() - // method hacked onto it. - upload.promise = uploadFile(matrixClient, roomId, file, onProgress); - return upload.promise.then(function(result) { - content.file = result.file; - content.url = result.url; - }); - }).then(() => { - // Await previous message being sent into the room - return promBefore; - }).then(function() { - if (upload.canceled) throw new UploadCanceledError(); - const threadId = relation?.rel_type === THREAD_RELATION_TYPE.name - ? relation.event_id - : null; - const prom = matrixClient.sendMessage(roomId, threadId, content); - if (SettingsStore.getValue("Performance.addSendMessageTimingMetadata")) { - prom.then(resp => { - sendRoundTripMetric(matrixClient, roomId, resp.event_id); - }); + try { + if (file.type.startsWith('image/')) { + content.msgtype = MsgType.Image; + try { + const imageInfo = await infoForImageFile(matrixClient, roomId, file); + Object.assign(content.info, imageInfo); + } catch (e) { + // Failed to thumbnail, fall back to uploading an m.file + logger.error(e); + content.msgtype = MsgType.File; + } + } else if (file.type.indexOf('audio/') === 0) { + content.msgtype = MsgType.Audio; + } else if (file.type.indexOf('video/') === 0) { + content.msgtype = MsgType.Video; + try { + const videoInfo = await infoForVideoFile(matrixClient, roomId, file); + Object.assign(content.info, videoInfo); + } catch (e) { + // Failed to thumbnail, fall back to uploading an m.file + logger.error(e); + content.msgtype = MsgType.File; + } + } else { + content.msgtype = MsgType.File; } - return prom; - }, function(err: MatrixError) { - error = err; - if (!upload.canceled) { + + if (upload.cancelled) throw new UploadCanceledError(); + const result = await uploadFile(matrixClient, roomId, file, onProgress, upload.abortController); + content.file = result.file; + content.url = result.url; + + if (upload.cancelled) throw new UploadCanceledError(); + // Await previous message being sent into the room + if (promBefore) await promBefore; + + if (upload.cancelled) throw new UploadCanceledError(); + const threadId = relation?.rel_type === THREAD_RELATION_TYPE.name ? relation.event_id : null; + + const response = await matrixClient.sendMessage(roomId, threadId, content); + + if (SettingsStore.getValue("Performance.addSendMessageTimingMetadata")) { + sendRoundTripMetric(matrixClient, roomId, response.event_id); + } + + dis.dispatch({ action: Action.UploadFinished, upload }); + dis.dispatch({ action: 'message_sent' }); + } catch (error) { + // 413: File was too big or upset the server in some way: + // clear the media size limit so we fetch it again next time we try to upload + if (error?.httpStatus === 413) { + this.mediaConfig = null; + } + + if (!upload.cancelled) { let desc = _t("The file '%(fileName)s' failed to upload.", { fileName: upload.fileName }); - if (err.httpStatus === 413) { + if (error.httpStatus === 413) { desc = _t( "The file '%(fileName)s' exceeds this homeserver's size limit for uploads", { fileName: upload.fileName }, @@ -606,27 +563,11 @@ export default class ContentMessages { title: _t('Upload Failed'), description: desc, }); - } - }).finally(() => { - for (let i = 0; i < this.inprogress.length; ++i) { - if (this.inprogress[i].promise === upload.promise) { - this.inprogress.splice(i, 1); - break; - } - } - if (error) { - // 413: File was too big or upset the server in some way: - // clear the media size limit so we fetch it again next time - // we try to upload - if (error?.httpStatus === 413) { - this.mediaConfig = null; - } dis.dispatch({ action: Action.UploadFailed, upload, error }); - } else { - dis.dispatch({ action: Action.UploadFinished, upload }); - dis.dispatch({ action: 'message_sent' }); } - }); + } finally { + removeElement(this.inprogress, e => e.promise === upload.promise); + } } private isFileSizeAcceptable(file: File) { diff --git a/src/IConfigOptions.ts b/src/IConfigOptions.ts index 91391fc2a9..b45461618e 100644 --- a/src/IConfigOptions.ts +++ b/src/IConfigOptions.ts @@ -141,9 +141,6 @@ export interface IConfigOptions { servers: string[]; }; - piwik?: false | { - policy_url: string; // deprecated in favour of `privacy_policy_url` at root instead - }; posthog?: { project_api_key: string; api_host: string; // hostname @@ -181,6 +178,11 @@ export interface IConfigOptions { sync_timeline_limit?: number; dangerously_allow_unsafe_and_insecure_passwords?: boolean; // developer option + + voice_broadcast?: { + // length per voice chunk in seconds + chunk_length?: number; + }; } export interface ISsoRedirectOptions { diff --git a/src/Lifecycle.ts b/src/Lifecycle.ts index 1e7fae8136..cc1143ebba 100644 --- a/src/Lifecycle.ts +++ b/src/Lifecycle.ts @@ -739,7 +739,7 @@ export function logout(): void { _isLoggingOut = true; const client = MatrixClientPeg.get(); PlatformPeg.get().destroyPickleKey(client.getUserId(), client.getDeviceId()); - client.logout(undefined, true).then(onLoggedOut, (err) => { + client.logout(true).then(onLoggedOut, (err) => { // Just throwing an error here is going to be very unhelpful // if you're trying to log out because your server's down and // you want to log into a different server, so just forget the diff --git a/src/Login.ts b/src/Login.ts index a6104dfdaf..c36f5770b9 100644 --- a/src/Login.ts +++ b/src/Login.ts @@ -169,7 +169,7 @@ export default class Login { * @param {string} loginType the type of login to do * @param {ILoginParams} loginParams the parameters for the login * - * @returns {MatrixClientCreds} + * @returns {IMatrixClientCreds} */ export async function sendLoginRequest( hsUrl: string, diff --git a/src/Notifier.ts b/src/Notifier.ts index 64f4a6547f..dd0ebc296a 100644 --- a/src/Notifier.ts +++ b/src/Notifier.ts @@ -431,6 +431,7 @@ export const Notifier = { const room = MatrixClientPeg.get().getRoom(roomId); const actions = MatrixClientPeg.get().getPushActionsForEvent(ev); + if (actions?.notify) { this._performCustomEventHandling(ev); diff --git a/src/ScalarAuthClient.ts b/src/ScalarAuthClient.ts index b1fb6e44f4..5dacd07973 100644 --- a/src/ScalarAuthClient.ts +++ b/src/ScalarAuthClient.ts @@ -15,10 +15,10 @@ limitations under the License. */ import url from 'url'; -import request from "browser-request"; import { SERVICE_TYPES } from "matrix-js-sdk/src/service-types"; import { Room } from "matrix-js-sdk/src/models/room"; import { logger } from "matrix-js-sdk/src/logger"; +import { IOpenIDToken } from 'matrix-js-sdk/src/matrix'; import SettingsStore from "./settings/SettingsStore"; import { Service, startTermsFlow, TermsInteractionCallback, TermsNotSignedError } from './Terms'; @@ -103,29 +103,29 @@ export default class ScalarAuthClient { } } - private getAccountName(token: string): Promise { - const url = this.apiUrl + "/account"; + private async getAccountName(token: string): Promise { + const url = new URL(this.apiUrl + "/account"); + url.searchParams.set("scalar_token", token); + url.searchParams.set("v", imApiVersion); - return new Promise(function(resolve, reject) { - request({ - method: "GET", - uri: url, - qs: { scalar_token: token, v: imApiVersion }, - json: true, - }, (err, response, body) => { - if (err) { - reject(err); - } else if (body && body.errcode === 'M_TERMS_NOT_SIGNED') { - reject(new TermsNotSignedError()); - } else if (response.statusCode / 100 !== 2) { - reject(body); - } else if (!body || !body.user_id) { - reject(new Error("Missing user_id in response")); - } else { - resolve(body.user_id); - } - }); + const res = await fetch(url, { + method: "GET", }); + + const body = await res.json(); + if (body?.errcode === "M_TERMS_NOT_SIGNED") { + throw new TermsNotSignedError(); + } + + if (!res.ok) { + throw body; + } + + if (!body?.user_id) { + throw new Error("Missing user_id in response"); + } + + return body.user_id; } private checkToken(token: string): Promise { @@ -183,56 +183,41 @@ export default class ScalarAuthClient { }); } - exchangeForScalarToken(openidTokenObject: any): Promise { - const scalarRestUrl = this.apiUrl; + public async exchangeForScalarToken(openidTokenObject: IOpenIDToken): Promise { + const scalarRestUrl = new URL(this.apiUrl + "/register"); + scalarRestUrl.searchParams.set("v", imApiVersion); - return new Promise(function(resolve, reject) { - request({ - method: 'POST', - uri: scalarRestUrl + '/register', - qs: { v: imApiVersion }, - body: openidTokenObject, - json: true, - }, (err, response, body) => { - if (err) { - reject(err); - } else if (response.statusCode / 100 !== 2) { - reject(new Error(`Scalar request failed: ${response.statusCode}`)); - } else if (!body || !body.scalar_token) { - reject(new Error("Missing scalar_token in response")); - } else { - resolve(body.scalar_token); - } - }); + const res = await fetch(scalarRestUrl, { + method: "POST", + body: JSON.stringify(openidTokenObject), }); + + if (!res.ok) { + throw new Error(`Scalar request failed: ${res.status}`); + } + + const body = await res.json(); + if (!body?.scalar_token) { + throw new Error("Missing scalar_token in response"); + } + + return body.scalar_token; } - getScalarPageTitle(url: string): Promise { - let scalarPageLookupUrl = this.apiUrl + '/widgets/title_lookup'; - scalarPageLookupUrl = this.getStarterLink(scalarPageLookupUrl); - scalarPageLookupUrl += '&curl=' + encodeURIComponent(url); + public async getScalarPageTitle(url: string): Promise { + const scalarPageLookupUrl = new URL(this.getStarterLink(this.apiUrl + '/widgets/title_lookup')); + scalarPageLookupUrl.searchParams.set("curl", encodeURIComponent(url)); - return new Promise(function(resolve, reject) { - request({ - method: 'GET', - uri: scalarPageLookupUrl, - json: true, - }, (err, response, body) => { - if (err) { - reject(err); - } else if (response.statusCode / 100 !== 2) { - reject(new Error(`Scalar request failed: ${response.statusCode}`)); - } else if (!body) { - reject(new Error("Missing page title in response")); - } else { - let title = ""; - if (body.page_title_cache_item && body.page_title_cache_item.cached_title) { - title = body.page_title_cache_item.cached_title; - } - resolve(title); - } - }); + const res = await fetch(scalarPageLookupUrl, { + method: "GET", }); + + if (!res.ok) { + throw new Error(`Scalar request failed: ${res.status}`); + } + + const body = await res.json(); + return body?.page_title_cache_item?.cached_title; } /** @@ -243,31 +228,24 @@ export default class ScalarAuthClient { * @param {string} widgetId The widget ID to disable assets for * @return {Promise} Resolves on completion */ - disableWidgetAssets(widgetType: WidgetType, widgetId: string): Promise { - let url = this.apiUrl + '/widgets/set_assets_state'; - url = this.getStarterLink(url); - return new Promise((resolve, reject) => { - request({ - method: 'GET', // XXX: Actions shouldn't be GET requests - uri: url, - json: true, - qs: { - 'widget_type': widgetType.preferred, - 'widget_id': widgetId, - 'state': 'disable', - }, - }, (err, response, body) => { - if (err) { - reject(err); - } else if (response.statusCode / 100 !== 2) { - reject(new Error(`Scalar request failed: ${response.statusCode}`)); - } else if (!body) { - reject(new Error("Failed to set widget assets state")); - } else { - resolve(); - } - }); + public async disableWidgetAssets(widgetType: WidgetType, widgetId: string): Promise { + const url = new URL(this.getStarterLink(this.apiUrl + "/widgets/set_assets_state")); + url.searchParams.set("widget_type", widgetType.preferred); + url.searchParams.set("widget_id", widgetId); + url.searchParams.set("state", "disable"); + + const res = await fetch(url, { + method: "GET", // XXX: Actions shouldn't be GET requests }); + + if (!res.ok) { + throw new Error(`Scalar request failed: ${res.status}`); + } + + const body = await res.text(); + if (!body) { + throw new Error("Failed to set widget assets state"); + } } getScalarInterfaceUrlForRoom(room: Room, screen: string, id: string): string { diff --git a/src/SdkConfig.ts b/src/SdkConfig.ts index 235ada7382..0d3400f4bb 100644 --- a/src/SdkConfig.ts +++ b/src/SdkConfig.ts @@ -46,6 +46,9 @@ export const DEFAULTS: IConfigOptions = { logo: require("../res/img/element-desktop-logo.svg").default, url: "https://element.io/get-started", }, + voice_broadcast: { + chunk_length: 60 * 1000, // one minute + }, }; export default class SdkConfig { diff --git a/src/SlashCommands.tsx b/src/SlashCommands.tsx index e731840397..a5661876dc 100644 --- a/src/SlashCommands.tsx +++ b/src/SlashCommands.tsx @@ -1104,12 +1104,13 @@ export const Commands = [ MatrixClientPeg.get().forceDiscardSession(roomId); - // noinspection JSIgnoredPromiseFromCall - MatrixClientPeg.get().crypto.ensureOlmSessionsForUsers(room.getMembers().map(m => m.userId), true); + return success(room.getEncryptionTargetMembers().then(members => { + // noinspection JSIgnoredPromiseFromCall + MatrixClientPeg.get().crypto.ensureOlmSessionsForUsers(members.map(m => m.userId), true); + })); } catch (e) { return reject(e.message); } - return success(); }, category: CommandCategories.advanced, renderingTypes: [TimelineRenderingType.Room], diff --git a/src/voice-broadcast/utils/index.ts b/src/actions/handlers/viewUserDeviceSettings.ts similarity index 52% rename from src/voice-broadcast/utils/index.ts rename to src/actions/handlers/viewUserDeviceSettings.ts index 8d93b5f425..e1dc7b3f26 100644 --- a/src/voice-broadcast/utils/index.ts +++ b/src/actions/handlers/viewUserDeviceSettings.ts @@ -14,5 +14,17 @@ See the License for the specific language governing permissions and limitations under the License. */ -export * from "./shouldDisplayAsVoiceBroadcastTile"; -export * from "./startNewVoiceBroadcastRecording"; +import { UserTab } from "../../components/views/dialogs/UserTab"; +import { Action } from "../../dispatcher/actions"; +import defaultDispatcher from "../../dispatcher/dispatcher"; + +/** + * Redirect to the correct device manager section + * Based on the labs setting + */ +export const viewUserDeviceSettings = (isNewDeviceManagerEnabled: boolean) => { + defaultDispatcher.dispatch({ + action: Action.ViewUserSettings, + initialTabId: isNewDeviceManagerEnabled ? UserTab.SessionManager : UserTab.Security, + }); +}; diff --git a/src/audio/VoiceRecording.ts b/src/audio/VoiceRecording.ts index e98e85aba5..0e18756fe5 100644 --- a/src/audio/VoiceRecording.ts +++ b/src/audio/VoiceRecording.ts @@ -203,9 +203,7 @@ export class VoiceRecording extends EventEmitter implements IDestroyable { // In testing, recorder time and worker time lag by about 400ms, which is roughly the // time needed to encode a sample/frame. // - // Ref for recorderSeconds: https://github.com/chris-rudmin/opus-recorder#instance-fields - const recorderSeconds = this.recorder.encodedSamplePosition / 48000; - const secondsLeft = TARGET_MAX_LENGTH - recorderSeconds; + const secondsLeft = TARGET_MAX_LENGTH - this.recorderSeconds; if (secondsLeft < 0) { // go over to make sure we definitely capture that last frame // noinspection JSIgnoredPromiseFromCall - we aren't concerned with it overlapping this.stop(); @@ -217,6 +215,13 @@ export class VoiceRecording extends EventEmitter implements IDestroyable { } }; + /** + * {@link https://github.com/chris-rudmin/opus-recorder#instance-fields ref for recorderSeconds} + */ + public get recorderSeconds() { + return this.recorder.encodedSamplePosition / 48000; + } + public async start(): Promise { if (this.recording) { throw new Error("Recording already in progress"); diff --git a/src/components/atoms/Icon.tsx b/src/components/atoms/Icon.tsx index bb6ea61524..5778022764 100644 --- a/src/components/atoms/Icon.tsx +++ b/src/components/atoms/Icon.tsx @@ -29,6 +29,7 @@ const iconTypeMap = new Map([ export enum IconColour { Accent = "accent", LiveBadge = "live-badge", + CompoundSecondaryContent = "compound-secondary-content", } export enum IconSize { @@ -55,6 +56,7 @@ export const Icon: React.FC = ({ const styles: React.CSSProperties = { maskImage: `url("${iconTypeMap.get(type)}")`, + WebkitMaskImage: `url("${iconTypeMap.get(type)}")`, }; return ( diff --git a/src/components/structures/ContextMenu.tsx b/src/components/structures/ContextMenu.tsx index c3f7d1c434..9a13d62424 100644 --- a/src/components/structures/ContextMenu.tsx +++ b/src/components/structures/ContextMenu.tsx @@ -92,6 +92,9 @@ export interface IProps extends IPosition { // within an existing FocusLock e.g inside a modal. focusLock?: boolean; + // call onFinished on any interaction with the menu + closeOnInteraction?: boolean; + // Function to be called on menu close onFinished(); // on resize callback @@ -186,6 +189,10 @@ export default class ContextMenu extends React.PureComponent { private onClick = (ev: React.MouseEvent) => { // Don't allow clicks to escape the context menu wrapper ev.stopPropagation(); + + if (this.props.closeOnInteraction) { + this.props.onFinished?.(); + } }; // We now only handle closing the ContextMenu in this keyDown handler. diff --git a/src/components/structures/EmbeddedPage.tsx b/src/components/structures/EmbeddedPage.tsx index 2053140ba4..11f286edc2 100644 --- a/src/components/structures/EmbeddedPage.tsx +++ b/src/components/structures/EmbeddedPage.tsx @@ -17,7 +17,6 @@ limitations under the License. */ import React from 'react'; -import request from 'browser-request'; import sanitizeHtml from 'sanitize-html'; import classnames from 'classnames'; import { logger } from "matrix-js-sdk/src/logger"; @@ -61,6 +60,37 @@ export default class EmbeddedPage extends React.PureComponent { return sanitizeHtml(_t(s)); } + private async fetchEmbed() { + let res: Response; + + try { + res = await fetch(this.props.url, { method: "GET" }); + } catch (err) { + if (this.unmounted) return; + logger.warn(`Error loading page: ${err}`); + this.setState({ page: _t("Couldn't load page") }); + return; + } + + if (this.unmounted) return; + + if (!res.ok) { + logger.warn(`Error loading page: ${res.status}`); + this.setState({ page: _t("Couldn't load page") }); + return; + } + + let body = (await res.text()).replace(/_t\(['"]([\s\S]*?)['"]\)/mg, (match, g1) => this.translate(g1)); + + if (this.props.replaceMap) { + Object.keys(this.props.replaceMap).forEach(key => { + body = body.split(key).join(this.props.replaceMap[key]); + }); + } + + this.setState({ page: body }); + } + public componentDidMount(): void { this.unmounted = false; @@ -68,34 +98,10 @@ export default class EmbeddedPage extends React.PureComponent { return; } - // we use request() to inline the page into the react component + // We use fetch to inline the page into the react component // so that it can inherit CSS and theming easily rather than mess around // with iframes and trying to synchronise document.stylesheets. - - request( - { method: "GET", url: this.props.url }, - (err, response, body) => { - if (this.unmounted) { - return; - } - - if (err || response.status < 200 || response.status >= 300) { - logger.warn(`Error loading page: ${err}`); - this.setState({ page: _t("Couldn't load page") }); - return; - } - - body = body.replace(/_t\(['"]([\s\S]*?)['"]\)/mg, (match, g1) => this.translate(g1)); - - if (this.props.replaceMap) { - Object.keys(this.props.replaceMap).forEach(key => { - body = body.split(key).join(this.props.replaceMap[key]); - }); - } - - this.setState({ page: body }); - }, - ); + this.fetchEmbed(); this.dispatcherRef = dis.register(this.onAction); } diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index 6dd2820aa1..515355b63d 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -137,6 +137,7 @@ import { TimelineRenderingType } from "../../contexts/RoomContext"; import { UseCaseSelection } from '../views/elements/UseCaseSelection'; import { ValidatedServerConfig } from '../../utils/ValidatedServerConfig'; import { isLocalRoom } from '../../utils/localRoom/isLocalRoom'; +import { viewUserDeviceSettings } from '../../actions/handlers/viewUserDeviceSettings'; // legacy export export { default as Views } from "../../Views"; @@ -336,14 +337,19 @@ export default class MatrixChat extends React.PureComponent { // the old creds, but rather go straight to the relevant page const firstScreen = this.screenAfterLogin ? this.screenAfterLogin.screen : null; - if (firstScreen === 'login' || - firstScreen === 'register' || - firstScreen === 'forgot_password') { - this.showScreenAfterLogin(); - return; + const restoreSuccess = await this.loadSession(); + if (restoreSuccess) { + return true; } - return this.loadSession(); + if (firstScreen === 'login' || + firstScreen === 'register' || + firstScreen === 'forgot_password' + ) { + this.showScreenAfterLogin(); + } + + return false; }); } @@ -469,7 +475,7 @@ export default class MatrixChat extends React.PureComponent { return { serverConfig: props }; } - private loadSession() { + private loadSession(): Promise { // the extra Promise.resolve() ensures that synchronous exceptions hit the same codepath as // asynchronous ones. return Promise.resolve().then(() => { @@ -489,6 +495,7 @@ export default class MatrixChat extends React.PureComponent { dis.dispatch({ action: "view_welcome_page" }); } } + return loadedSession; }); // Note we don't catch errors from this: we catch everything within // loadSession as there's logic there to ask the user if they want @@ -677,6 +684,10 @@ export default class MatrixChat extends React.PureComponent { } break; } + case Action.ViewUserDeviceSettings: { + viewUserDeviceSettings(SettingsStore.getValue("feature_new_device_manager")); + break; + } case Action.ViewUserSettings: { const tabPayload = payload as OpenToTabPayload; Modal.createDialog(UserSettingsDialog, diff --git a/src/components/structures/TimelinePanel.tsx b/src/components/structures/TimelinePanel.tsx index 1e262014c2..7ddeca11bc 100644 --- a/src/components/structures/TimelinePanel.tsx +++ b/src/components/structures/TimelinePanel.tsx @@ -1362,7 +1362,7 @@ class TimelinePanel extends React.Component { if (this.unmounted) return; this.setState({ timelineLoading: false }); - logger.error(`Error loading timeline panel at ${this.props.timelineSet.room?.roomId}/${eventId}: ${error}`); + logger.error(`Error loading timeline panel at ${this.props.timelineSet.room?.roomId}/${eventId}`, error); let onFinished: () => void; diff --git a/src/components/structures/UploadBar.tsx b/src/components/structures/UploadBar.tsx index b2c7544f1f..673afb1b13 100644 --- a/src/components/structures/UploadBar.tsx +++ b/src/components/structures/UploadBar.tsx @@ -17,7 +17,7 @@ limitations under the License. import React from 'react'; import { Room } from "matrix-js-sdk/src/models/room"; import filesize from "filesize"; -import { IAbortablePromise, IEventRelation } from 'matrix-js-sdk/src/matrix'; +import { IEventRelation } from 'matrix-js-sdk/src/matrix'; import { Optional } from "matrix-events-sdk"; import ContentMessages from '../../ContentMessages'; @@ -26,8 +26,7 @@ import { _t } from '../../languageHandler'; import { Action } from "../../dispatcher/actions"; import ProgressBar from "../views/elements/ProgressBar"; import AccessibleButton, { ButtonEvent } from "../views/elements/AccessibleButton"; -import { IUpload } from "../../models/IUpload"; -import MatrixClientContext from "../../contexts/MatrixClientContext"; +import { RoomUpload } from "../../models/RoomUpload"; import { ActionPayload } from '../../dispatcher/payloads'; import { UploadPayload } from "../../dispatcher/payloads/UploadPayload"; @@ -38,7 +37,7 @@ interface IProps { interface IState { currentFile?: string; - currentPromise?: IAbortablePromise; + currentUpload?: RoomUpload; currentLoaded?: number; currentTotal?: number; countFiles: number; @@ -55,8 +54,6 @@ function isUploadPayload(payload: ActionPayload): payload is UploadPayload { } export default class UploadBar extends React.PureComponent { - static contextType = MatrixClientContext; - private dispatcherRef: Optional; private mounted = false; @@ -78,7 +75,7 @@ export default class UploadBar extends React.PureComponent { dis.unregister(this.dispatcherRef!); } - private getUploadsInRoom(): IUpload[] { + private getUploadsInRoom(): RoomUpload[] { const uploads = ContentMessages.sharedInstance().getCurrentUploads(this.props.relation); return uploads.filter(u => u.roomId === this.props.room.roomId); } @@ -86,8 +83,8 @@ export default class UploadBar extends React.PureComponent { private calculateState(): IState { const [currentUpload, ...otherUploads] = this.getUploadsInRoom(); return { + currentUpload, currentFile: currentUpload?.fileName, - currentPromise: currentUpload?.promise, currentLoaded: currentUpload?.loaded, currentTotal: currentUpload?.total, countFiles: otherUploads.length + 1, @@ -103,7 +100,7 @@ export default class UploadBar extends React.PureComponent { private onCancelClick = (ev: ButtonEvent) => { ev.preventDefault(); - ContentMessages.sharedInstance().cancelUpload(this.state.currentPromise!, this.context); + ContentMessages.sharedInstance().cancelUpload(this.state.currentUpload!); }; render() { diff --git a/src/components/structures/auth/Login.tsx b/src/components/structures/auth/Login.tsx index c00aa909d2..c9fc7e001d 100644 --- a/src/components/structures/auth/Login.tsx +++ b/src/components/structures/auth/Login.tsx @@ -15,7 +15,7 @@ limitations under the License. */ import React, { ReactNode } from 'react'; -import { MatrixError } from "matrix-js-sdk/src/http-api"; +import { ConnectionError, MatrixError } from "matrix-js-sdk/src/http-api"; import classNames from "classnames"; import { logger } from "matrix-js-sdk/src/logger"; @@ -453,7 +453,7 @@ export default class LoginComponent extends React.PureComponent let errorText: ReactNode = _t("There was a problem communicating with the homeserver, " + "please try again later.") + (errCode ? " (" + errCode + ")" : ""); - if (err["cors"] === 'rejected') { // browser-request specific error field + if (err instanceof ConnectionError) { if (window.location.protocol === 'https:' && (this.props.serverConfig.hsUrl.startsWith("http:") || !this.props.serverConfig.hsUrl.startsWith("http")) diff --git a/src/components/views/context_menus/IconizedContextMenu.tsx b/src/components/views/context_menus/IconizedContextMenu.tsx index 9b7896790e..ad8d97edd4 100644 --- a/src/components/views/context_menus/IconizedContextMenu.tsx +++ b/src/components/views/context_menus/IconizedContextMenu.tsx @@ -39,6 +39,7 @@ interface IOptionListProps { interface IOptionProps extends React.ComponentProps { iconClassName?: string; + isDestructive?: boolean; } interface ICheckboxProps extends React.ComponentProps { @@ -112,12 +113,14 @@ export const IconizedContextMenuOption: React.FC = ({ className, iconClassName, children, + isDestructive, ...props }) => { return diff --git a/src/components/views/context_menus/KebabContextMenu.tsx b/src/components/views/context_menus/KebabContextMenu.tsx new file mode 100644 index 0000000000..f385cc3c5e --- /dev/null +++ b/src/components/views/context_menus/KebabContextMenu.tsx @@ -0,0 +1,66 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from 'react'; + +import { Icon as ContextMenuIcon } from '../../../../res/img/element-icons/context-menu.svg'; +import { ChevronFace, ContextMenuButton, useContextMenu } from '../../structures/ContextMenu'; +import AccessibleButton from '../elements/AccessibleButton'; +import IconizedContextMenu, { IconizedContextMenuOptionList } from './IconizedContextMenu'; + +const contextMenuBelow = (elementRect: DOMRect) => { + // align the context menu's icons with the icon which opened the context menu + const left = elementRect.left + window.scrollX + elementRect.width; + const top = elementRect.bottom + window.scrollY; + const chevronFace = ChevronFace.None; + return { left, top, chevronFace }; +}; + +interface KebabContextMenuProps extends Partial> { + options: React.ReactNode[]; + title: string; +} + +export const KebabContextMenu: React.FC = ({ + options, + title, + ...props +}) => { + const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu(); + + return <> + + + + { menuDisplayed && ( + + { options } + + ) } + ; +}; diff --git a/src/components/views/dialogs/ChangelogDialog.tsx b/src/components/views/dialogs/ChangelogDialog.tsx index f759f04300..da5ea5d490 100644 --- a/src/components/views/dialogs/ChangelogDialog.tsx +++ b/src/components/views/dialogs/ChangelogDialog.tsx @@ -16,7 +16,6 @@ Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> */ import React from 'react'; -import request from 'browser-request'; import { _t } from '../../../languageHandler'; import QuestionDialog from "./QuestionDialog"; @@ -37,22 +36,33 @@ export default class ChangelogDialog extends React.Component { this.state = {}; } + private async fetchChanges(repo: string, oldVersion: string, newVersion: string): Promise { + const url = `https://riot.im/github/repos/${repo}/compare/${oldVersion}...${newVersion}`; + + try { + const res = await fetch(url); + + if (!res.ok) { + this.setState({ [repo]: res.statusText }); + return; + } + + const body = await res.json(); + this.setState({ [repo]: body.commits }); + } catch (err) { + this.setState({ [repo]: err.message }); + } + } + public componentDidMount() { const version = this.props.newVersion.split('-'); const version2 = this.props.version.split('-'); if (version == null || version2 == null) return; // parse versions of form: [vectorversion]-react-[react-sdk-version]-js-[js-sdk-version] - for (let i=0; i { - if (response.statusCode < 200 || response.statusCode >= 300) { - this.setState({ [REPOS[i]]: response.statusText }); - return; - } - this.setState({ [REPOS[i]]: JSON.parse(body).commits }); - }); + this.fetchChanges(REPOS[i], oldVersion, newVersion); } } diff --git a/src/components/views/dialogs/InviteDialog.tsx b/src/components/views/dialogs/InviteDialog.tsx index 178c521bdc..30c1f4d154 100644 --- a/src/components/views/dialogs/InviteDialog.tsx +++ b/src/components/views/dialogs/InviteDialog.tsx @@ -654,12 +654,7 @@ export default class InviteDialog extends React.PureComponent { - const controller = new AbortController(); - const id = setTimeout(() => controller.abort(), 10 * 1000); // 10s - const url = cli.http.getUrl("/sync", {}, "/_matrix/client/unstable/org.matrix.msc3575"); - const res = await fetch(url, { - signal: controller.signal, - method: "POST", + await cli.http.authedRequest(Method.Post, "/sync", undefined, undefined, { + localTimeoutMs: 10 * 1000, // 10s + prefix: "/_matrix/client/unstable/org.matrix.msc3575", }); - clearTimeout(id); - if (res.status != 200) { - throw new Error(`syncHealthCheck: server returned HTTP ${res.status}`); - } logger.info("server natively support sliding sync OK"); } diff --git a/src/components/views/elements/LanguageDropdown.tsx b/src/components/views/elements/LanguageDropdown.tsx index cf1dfedcce..06ffb232aa 100644 --- a/src/components/views/elements/LanguageDropdown.tsx +++ b/src/components/views/elements/LanguageDropdown.tsx @@ -38,7 +38,7 @@ interface IProps { interface IState { searchQuery: string; - langs: string[]; + langs: Awaited>; } export default class LanguageDropdown extends React.Component { @@ -60,7 +60,7 @@ export default class LanguageDropdown extends React.Component { }); this.setState({ langs }); }).catch(() => { - this.setState({ langs: ['en'] }); + this.setState({ langs: [{ value: 'en', label: "English" }] }); }); if (!this.props.value) { @@ -83,7 +83,7 @@ export default class LanguageDropdown extends React.Component { return ; } - let displayedLanguages; + let displayedLanguages: Awaited>; if (this.state.searchQuery) { displayedLanguages = this.state.langs.filter((lang) => { return languageMatchesSearchQuery(this.state.searchQuery, lang); diff --git a/src/components/views/elements/MiniAvatarUploader.tsx b/src/components/views/elements/MiniAvatarUploader.tsx index 7e744591ec..2e19a616e5 100644 --- a/src/components/views/elements/MiniAvatarUploader.tsx +++ b/src/components/views/elements/MiniAvatarUploader.tsx @@ -74,7 +74,7 @@ const MiniAvatarUploader: React.FC = ({ if (!ev.target.files?.length) return; setBusy(true); const file = ev.target.files[0]; - const uri = await cli.uploadContent(file); + const { content_uri: uri } = await cli.uploadContent(file); await setAvatarUrl(uri); setBusy(false); }} diff --git a/src/components/views/messages/MessageEvent.tsx b/src/components/views/messages/MessageEvent.tsx index f93cd71017..4e09bfac49 100644 --- a/src/components/views/messages/MessageEvent.tsx +++ b/src/components/views/messages/MessageEvent.tsx @@ -43,6 +43,8 @@ import MjolnirBody from "./MjolnirBody"; import MBeaconBody from "./MBeaconBody"; import { IEventTileOps } from "../rooms/EventTile"; import { VoiceBroadcastBody, VoiceBroadcastInfoEventType, VoiceBroadcastInfoState } from '../../../voice-broadcast'; +import { Features } from '../../../settings/Settings'; +import { SettingLevel } from '../../../settings/SettingLevel'; // onMessageAllowed is handled internally interface IProps extends Omit { @@ -60,6 +62,10 @@ export interface IOperableEventTile { getEventTileOps(): IEventTileOps; } +interface State { + voiceBroadcastEnabled: boolean; +} + const baseBodyTypes = new Map([ [MsgType.Text, TextualBody], [MsgType.Notice, TextualBody], @@ -78,7 +84,7 @@ const baseEvTypes = new Map>>([ [VoiceBroadcastInfoEventType, VoiceBroadcastBody], ]); -export default class MessageEvent extends React.Component implements IMediaBody, IOperableEventTile { +export default class MessageEvent extends React.Component implements IMediaBody, IOperableEventTile { private body: React.RefObject = createRef(); private mediaHelper: MediaEventHelper; private bodyTypes = new Map(baseBodyTypes.entries()); @@ -86,6 +92,7 @@ export default class MessageEvent extends React.Component implements IMe public static contextType = MatrixClientContext; public context!: React.ContextType; + private voiceBroadcastSettingWatcherRef: string; public constructor(props: IProps, context: React.ContextType) { super(props, context); @@ -95,15 +102,29 @@ export default class MessageEvent extends React.Component implements IMe } this.updateComponentMaps(); + + this.state = { + // only check voice broadcast settings for a voice broadcast event + voiceBroadcastEnabled: this.props.mxEvent.getType() === VoiceBroadcastInfoEventType + && SettingsStore.getValue(Features.VoiceBroadcast), + }; } public componentDidMount(): void { this.props.mxEvent.addListener(MatrixEventEvent.Decrypted, this.onDecrypted); + + if (this.props.mxEvent.getType() === VoiceBroadcastInfoEventType) { + this.watchVoiceBroadcastFeatureSetting(); + } } public componentWillUnmount() { this.props.mxEvent.removeListener(MatrixEventEvent.Decrypted, this.onDecrypted); this.mediaHelper?.destroy(); + + if (this.voiceBroadcastSettingWatcherRef) { + SettingsStore.unwatchSetting(this.voiceBroadcastSettingWatcherRef); + } } public componentDidUpdate(prevProps: Readonly) { @@ -147,6 +168,16 @@ export default class MessageEvent extends React.Component implements IMe this.forceUpdate(); }; + private watchVoiceBroadcastFeatureSetting(): void { + this.voiceBroadcastSettingWatcherRef = SettingsStore.watchSetting( + Features.VoiceBroadcast, + null, + (settingName: string, roomId: string, atLevel: SettingLevel, newValAtLevel, newValue: boolean) => { + this.setState({ voiceBroadcastEnabled: newValue }); + }, + ); + } + public render() { const content = this.props.mxEvent.getContent(); const type = this.props.mxEvent.getType(); @@ -174,7 +205,11 @@ export default class MessageEvent extends React.Component implements IMe BodyType = MLocationBody; } - if (type === VoiceBroadcastInfoEventType && content?.state === VoiceBroadcastInfoState.Started) { + if ( + this.state.voiceBroadcastEnabled + && type === VoiceBroadcastInfoEventType + && content?.state === VoiceBroadcastInfoState.Started + ) { BodyType = VoiceBroadcastBody; } } diff --git a/src/components/views/right_panel/UserInfo.tsx b/src/components/views/right_panel/UserInfo.tsx index 45489603ba..810ae48dd7 100644 --- a/src/components/views/right_panel/UserInfo.tsx +++ b/src/components/views/right_panel/UserInfo.tsx @@ -48,7 +48,6 @@ import EncryptionPanel from "./EncryptionPanel"; import { useAsyncMemo } from '../../../hooks/useAsyncMemo'; import { legacyVerifyUser, verifyDevice, verifyUser } from '../../../verification'; import { Action } from "../../../dispatcher/actions"; -import { UserTab } from "../dialogs/UserTab"; import { useIsEncrypted } from "../../../hooks/useIsEncrypted"; import BaseCard from "./BaseCard"; import { E2EStatus } from "../../../utils/ShieldUtils"; @@ -1331,8 +1330,7 @@ const BasicUserInfo: React.FC<{ className="mx_UserInfo_field" onClick={() => { dis.dispatch({ - action: Action.ViewUserSettings, - initialTabId: UserTab.Security, + action: Action.ViewUserDeviceSettings, }); }} > diff --git a/src/components/views/room_settings/RoomProfileSettings.tsx b/src/components/views/room_settings/RoomProfileSettings.tsx index 86e266bc35..1c7b8d6e94 100644 --- a/src/components/views/room_settings/RoomProfileSettings.tsx +++ b/src/components/views/room_settings/RoomProfileSettings.tsx @@ -134,7 +134,7 @@ export default class RoomProfileSettings extends React.Component } if (this.state.avatarFile) { - const uri = await client.uploadContent(this.state.avatarFile); + const { content_uri: uri } = await client.uploadContent(this.state.avatarFile); await client.sendStateEvent(this.props.roomId, 'm.room.avatar', { url: uri }, ''); newState.avatarUrl = mediaFromMxc(uri).getSquareThumbnailHttp(96); newState.originalAvatarUrl = newState.avatarUrl; diff --git a/src/components/views/rooms/RoomPreviewBar.tsx b/src/components/views/rooms/RoomPreviewBar.tsx index 6c724440cf..39c82e4eea 100644 --- a/src/components/views/rooms/RoomPreviewBar.tsx +++ b/src/components/views/rooms/RoomPreviewBar.tsx @@ -148,7 +148,6 @@ export default class RoomPreviewBar extends React.Component { const result = await MatrixClientPeg.get().lookupThreePid( 'email', this.props.invitedEmail, - undefined /* callback */, identityAccessToken, ); this.setState({ invitedEmailMxid: result.mxid }); diff --git a/src/components/views/settings/ChangeAvatar.tsx b/src/components/views/settings/ChangeAvatar.tsx index b0645ac51b..680291db4c 100644 --- a/src/components/views/settings/ChangeAvatar.tsx +++ b/src/components/views/settings/ChangeAvatar.tsx @@ -115,13 +115,13 @@ export default class ChangeAvatar extends React.Component { this.setState({ phase: Phases.Uploading, }); - const httpPromise = MatrixClientPeg.get().uploadContent(file).then((url) => { + const httpPromise = MatrixClientPeg.get().uploadContent(file).then(({ content_uri: url }) => { newUrl = url; if (this.props.room) { return MatrixClientPeg.get().sendStateEvent( this.props.room.roomId, 'm.room.avatar', - { url: url }, + { url }, '', ); } else { diff --git a/src/components/views/settings/ProfileSettings.tsx b/src/components/views/settings/ProfileSettings.tsx index a820b28d40..fd3ed6c99d 100644 --- a/src/components/views/settings/ProfileSettings.tsx +++ b/src/components/views/settings/ProfileSettings.tsx @@ -111,7 +111,7 @@ export default class ProfileSettings extends React.Component<{}, IState> { logger.log( `Uploading new avatar, ${this.state.avatarFile.name} of type ${this.state.avatarFile.type},` + ` (${this.state.avatarFile.size}) bytes`); - const uri = await client.uploadContent(this.state.avatarFile); + const { content_uri: uri } = await client.uploadContent(this.state.avatarFile); await client.setAvatarUrl(uri); newState.avatarUrl = mediaFromMxc(uri).getSquareThumbnailHttp(96); newState.originalAvatarUrl = newState.avatarUrl; diff --git a/src/components/views/settings/devices/CurrentDeviceSection.tsx b/src/components/views/settings/devices/CurrentDeviceSection.tsx index fc58617d31..f597086565 100644 --- a/src/components/views/settings/devices/CurrentDeviceSection.tsx +++ b/src/components/views/settings/devices/CurrentDeviceSection.tsx @@ -14,17 +14,20 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { LocalNotificationSettings } from 'matrix-js-sdk/src/@types/local_notifications'; import React, { useState } from 'react'; +import { LocalNotificationSettings } from 'matrix-js-sdk/src/@types/local_notifications'; import { _t } from '../../../../languageHandler'; import Spinner from '../../elements/Spinner'; import SettingsSubsection from '../shared/SettingsSubsection'; +import { SettingsSubsectionHeading } from '../shared/SettingsSubsectionHeading'; import DeviceDetails from './DeviceDetails'; import DeviceExpandDetailsButton from './DeviceExpandDetailsButton'; import DeviceTile from './DeviceTile'; import { DeviceVerificationStatusCard } from './DeviceVerificationStatusCard'; import { ExtendedDevice } from './types'; +import { KebabContextMenu } from '../../context_menus/KebabContextMenu'; +import { IconizedContextMenuOption } from '../../context_menus/IconizedContextMenu'; interface Props { device?: ExtendedDevice; @@ -34,9 +37,48 @@ interface Props { setPushNotifications?: (deviceId: string, enabled: boolean) => Promise | undefined; onVerifyCurrentDevice: () => void; onSignOutCurrentDevice: () => void; + signOutAllOtherSessions?: () => void; saveDeviceName: (deviceName: string) => Promise; } +type CurrentDeviceSectionHeadingProps = + Pick + & { disabled?: boolean }; + +const CurrentDeviceSectionHeading: React.FC = ({ + onSignOutCurrentDevice, + signOutAllOtherSessions, + disabled, +}) => { + const menuOptions = [ + , + ...(signOutAllOtherSessions + ? [ + , + ] + : [] + ), + ]; + return + + ; +}; + const CurrentDeviceSection: React.FC = ({ device, isLoading, @@ -45,13 +87,18 @@ const CurrentDeviceSection: React.FC = ({ setPushNotifications, onVerifyCurrentDevice, onSignOutCurrentDevice, + signOutAllOtherSessions, saveDeviceName, }) => { const [isExpanded, setIsExpanded] = useState(false); return } > { /* only show big spinner on first load */ } { isLoading && !device && } diff --git a/src/components/views/settings/shared/SettingsSubsection.tsx b/src/components/views/settings/shared/SettingsSubsection.tsx index 6d23a080ca..9ceff732cb 100644 --- a/src/components/views/settings/shared/SettingsSubsection.tsx +++ b/src/components/views/settings/shared/SettingsSubsection.tsx @@ -16,17 +16,22 @@ limitations under the License. import React, { HTMLAttributes } from "react"; -import Heading from "../../typography/Heading"; +import { SettingsSubsectionHeading } from "./SettingsSubsectionHeading"; export interface SettingsSubsectionProps extends HTMLAttributes { - heading: string; + heading: string | React.ReactNode; description?: string | React.ReactNode; children?: React.ReactNode; } const SettingsSubsection: React.FC = ({ heading, description, children, ...rest }) => (
- { heading } + { typeof heading === 'string' + ? + : <> + { heading } + + } { !!description &&
{ description }
}
{ children } diff --git a/src/voice-broadcast/components/index.ts b/src/components/views/settings/shared/SettingsSubsectionHeading.tsx similarity index 51% rename from src/voice-broadcast/components/index.ts rename to src/components/views/settings/shared/SettingsSubsectionHeading.tsx index e98500a5d7..4a39ff7278 100644 --- a/src/voice-broadcast/components/index.ts +++ b/src/components/views/settings/shared/SettingsSubsectionHeading.tsx @@ -14,6 +14,18 @@ See the License for the specific language governing permissions and limitations under the License. */ -export * from "./atoms/LiveBadge"; -export * from "./molecules/VoiceBroadcastRecordingBody"; -export * from "./VoiceBroadcastBody"; +import React, { HTMLAttributes } from "react"; + +import Heading from "../../typography/Heading"; + +export interface SettingsSubsectionHeadingProps extends HTMLAttributes { + heading: string; + children?: React.ReactNode; +} + +export const SettingsSubsectionHeading: React.FC = ({ heading, children, ...rest }) => ( +
+ { heading } + { children } +
+); diff --git a/src/components/views/settings/tabs/user/LabsUserSettingsTab.tsx b/src/components/views/settings/tabs/user/LabsUserSettingsTab.tsx index 80e2ebb6cf..6057587626 100644 --- a/src/components/views/settings/tabs/user/LabsUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/LabsUserSettingsTab.tsx @@ -80,7 +80,10 @@ export default class LabsUserSettingsTab extends React.Component<{}, IState> { let betaSection; if (betas.length) { - betaSection =
+ betaSection =
{ betas.map(f => ) }
; } @@ -137,7 +140,11 @@ export default class LabsUserSettingsTab extends React.Component<{}, IState> { labsSections = <> { sortBy(Array.from(groups.entries()), "0").map(([group, flags]) => ( -
+
{ _t(labGroupNames[group]) } { flags }
diff --git a/src/components/views/settings/tabs/user/SecurityUserSettingsTab.tsx b/src/components/views/settings/tabs/user/SecurityUserSettingsTab.tsx index 91b448eb3b..f4e4e55513 100644 --- a/src/components/views/settings/tabs/user/SecurityUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/SecurityUserSettingsTab.tsx @@ -346,19 +346,29 @@ export default class SecurityUserSettingsTab extends React.Component - { warning } + const useNewSessionManager = SettingsStore.getValue("feature_new_device_manager"); + const devicesSection = useNewSessionManager + ? null + : <>
{ _t("Where you're signed in") }
-
+
{ _t( "Manage your signed-in devices below. " + - "A device's name is visible to people you communicate with.", + "A device's name is visible to people you communicate with.", ) }
+ ; + + return ( +
+ { warning } + { devicesSection }
{ _t("Encryption") }
{ secureBackup } diff --git a/src/components/views/settings/tabs/user/SessionManagerTab.tsx b/src/components/views/settings/tabs/user/SessionManagerTab.tsx index 2c94d5a5c2..d1fbb6ce5c 100644 --- a/src/components/views/settings/tabs/user/SessionManagerTab.tsx +++ b/src/components/views/settings/tabs/user/SessionManagerTab.tsx @@ -171,6 +171,10 @@ const SessionManagerTab: React.FC = () => { setSelectedDeviceIds([]); }, [filter, setSelectedDeviceIds]); + const signOutAllOtherSessions = shouldShowOtherSessions ? () => { + onSignOutOtherDevices(Object.keys(otherDevices)); + }: undefined; + return { saveDeviceName={(deviceName) => saveDeviceName(currentDeviceId, deviceName)} onVerifyCurrentDevice={onVerifyCurrentDevice} onSignOutCurrentDevice={onSignOutCurrentDevice} + signOutAllOtherSessions={signOutAllOtherSessions} /> { shouldShowOtherSessions && diff --git a/src/components/views/voip/PipView.tsx b/src/components/views/voip/PipView.tsx index c6ce0da159..0c6feec0d5 100644 --- a/src/components/views/voip/PipView.tsx +++ b/src/components/views/voip/PipView.tsx @@ -154,9 +154,10 @@ export default class PipView extends React.Component { public componentWillUnmount() { LegacyCallHandler.instance.removeListener(LegacyCallHandlerEvent.CallChangeRoom, this.updateCalls); LegacyCallHandler.instance.removeListener(LegacyCallHandlerEvent.CallState, this.updateCalls); - MatrixClientPeg.get().removeListener(CallEvent.RemoteHoldUnhold, this.onCallRemoteHold); + const cli = MatrixClientPeg.get(); + cli?.removeListener(CallEvent.RemoteHoldUnhold, this.onCallRemoteHold); RoomViewStore.instance.removeListener(UPDATE_EVENT, this.onRoomViewStoreUpdate); - const room = MatrixClientPeg.get().getRoom(this.state.viewedRoomId); + const room = cli?.getRoom(this.state.viewedRoomId); if (room) { WidgetLayoutStore.instance.off(WidgetLayoutStore.emissionForRoom(room), this.updateCalls); } diff --git a/src/createRoom.ts b/src/createRoom.ts index 88e3f8ef9f..cc3cbc5373 100644 --- a/src/createRoom.ts +++ b/src/createRoom.ts @@ -246,7 +246,7 @@ export default async function createRoom(opts: IOpts): Promise { if (opts.avatar) { let url = opts.avatar; if (opts.avatar instanceof File) { - url = await client.uploadContent(opts.avatar); + ({ content_uri: url } = await client.uploadContent(opts.avatar)); } createOpts.initial_state.push({ diff --git a/src/customisations/Media.ts b/src/customisations/Media.ts index 6d9e9a8b62..ae0daa53c5 100644 --- a/src/customisations/Media.ts +++ b/src/customisations/Media.ts @@ -151,7 +151,7 @@ export class Media { * @param {MatrixClient} client? Optional client to use. * @returns {Media} The media object. */ -export function mediaFromContent(content: IMediaEventContent, client?: MatrixClient): Media { +export function mediaFromContent(content: Partial, client?: MatrixClient): Media { return new Media(prepEventContentAsMedia(content), client); } diff --git a/src/customisations/models/IMediaEventContent.ts b/src/customisations/models/IMediaEventContent.ts index d911a7cc3c..a8dacd84aa 100644 --- a/src/customisations/models/IMediaEventContent.ts +++ b/src/customisations/models/IMediaEventContent.ts @@ -46,6 +46,7 @@ export interface IMediaEventInfo { } export interface IMediaEventContent { + msgtype: string; body?: string; filename?: string; // `m.file` optional field url?: string; // required on unencrypted media @@ -69,7 +70,7 @@ export interface IMediaObject { * @returns {IPreparedMedia} A prepared media object. * @throws Throws if the given content cannot be packaged into a prepared media object. */ -export function prepEventContentAsMedia(content: IMediaEventContent): IPreparedMedia { +export function prepEventContentAsMedia(content: Partial): IPreparedMedia { let thumbnail: IMediaObject = null; if (content?.info?.thumbnail_url) { thumbnail = { diff --git a/src/dispatcher/actions.ts b/src/dispatcher/actions.ts index 4e161a7005..2b2e443e81 100644 --- a/src/dispatcher/actions.ts +++ b/src/dispatcher/actions.ts @@ -40,6 +40,11 @@ export enum Action { */ ViewUserSettings = "view_user_settings", + /** + * Open the user device settings. No additional payload information required. + */ + ViewUserDeviceSettings = "view_user_device_settings", + /** * Opens the room directory. No additional payload information required. */ diff --git a/src/dispatcher/payloads/UploadPayload.ts b/src/dispatcher/payloads/UploadPayload.ts index 7db4a4a4d7..ac47596e55 100644 --- a/src/dispatcher/payloads/UploadPayload.ts +++ b/src/dispatcher/payloads/UploadPayload.ts @@ -16,13 +16,13 @@ limitations under the License. import { ActionPayload } from "../payloads"; import { Action } from "../actions"; -import { IUpload } from "../../models/IUpload"; +import { RoomUpload } from "../../models/RoomUpload"; export interface UploadPayload extends ActionPayload { /** * The upload with fields representing the new upload state. */ - upload: IUpload; + upload: RoomUpload; } export interface UploadStartedPayload extends UploadPayload { diff --git a/src/events/EventTileFactory.tsx b/src/events/EventTileFactory.tsx index 9ff402f45d..89bf9cbd73 100644 --- a/src/events/EventTileFactory.tsx +++ b/src/events/EventTileFactory.tsx @@ -46,6 +46,7 @@ import ViewSourceEvent from "../components/views/messages/ViewSourceEvent"; import { shouldDisplayAsBeaconTile } from "../utils/beacon/timeline"; import { shouldDisplayAsVoiceBroadcastTile } from "../voice-broadcast/utils/shouldDisplayAsVoiceBroadcastTile"; import { ElementCall } from "../models/Call"; +import { VoiceBroadcastChunkEventType } from "../voice-broadcast"; // Subset of EventTile's IProps plus some mixins export interface EventTileTypeProps { @@ -252,6 +253,11 @@ export function pickFactory( return noEventFactoryFactory(); } + if (mxEvent.getContent()[VoiceBroadcastChunkEventType]) { + // hide voice broadcast chunks + return noEventFactoryFactory(); + } + return EVENT_TILE_TYPES.get(evType) ?? noEventFactoryFactory(); } diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index a5ddd5d707..9309f7ce30 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -16,6 +16,7 @@ "Error": "Error", "Unable to load! Check your network connectivity and try again.": "Unable to load! Check your network connectivity and try again.", "Dismiss": "Dismiss", + "Attachment": "Attachment", "The file '%(fileName)s' failed to upload.": "The file '%(fileName)s' failed to upload.", "The file '%(fileName)s' exceeds this homeserver's size limit for uploads": "The file '%(fileName)s' exceeds this homeserver's size limit for uploads", "Upload Failed": "Upload Failed", @@ -637,6 +638,7 @@ "See %(msgtype)s messages posted to this room": "See %(msgtype)s messages posted to this room", "See %(msgtype)s messages posted to your active room": "See %(msgtype)s messages posted to your active room", "Live": "Live", + "Voice broadcast": "Voice broadcast", "Cannot reach homeserver": "Cannot reach homeserver", "Ensure you have a stable internet connection, or get in touch with the server admin": "Ensure you have a stable internet connection, or get in touch with the server admin", "Your %(brand)s is misconfigured": "Your %(brand)s is misconfigured", @@ -653,7 +655,6 @@ "This homeserver has exceeded one of its resource limits.": "This homeserver has exceeded one of its resource limits.", "Please contact your service administrator to continue using the service.": "Please contact your service administrator to continue using the service.", "Unable to connect to Homeserver. Retrying...": "Unable to connect to Homeserver. Retrying...", - "Attachment": "Attachment", "%(items)s and %(count)s others|other": "%(items)s and %(count)s others", "%(items)s and %(count)s others|one": "%(items)s and one other", "%(items)s and %(lastItem)s": "%(items)s and %(lastItem)s", @@ -922,7 +923,10 @@ "Live Location Sharing (temporary implementation: locations persist in room history)": "Live Location Sharing (temporary implementation: locations persist in room history)", "Favourite Messages (under active development)": "Favourite Messages (under active development)", "Voice broadcast (under active development)": "Voice broadcast (under active development)", - "Use new session manager (under active development)": "Use new session manager (under active development)", + "Use new session manager": "Use new session manager", + "New session manager": "New session manager", + "Have greater visibility and control over all your sessions.": "Have greater visibility and control over all your sessions.", + "Our new sessions manager provides better visibility of all your sessions, and greater control over them including the ability to remotely toggle push notifications.": "Our new sessions manager provides better visibility of all your sessions, and greater control over them including the ability to remotely toggle push notifications.", "Font size": "Font size", "Use custom size": "Use custom size", "Enable Emoji suggestions while typing": "Enable Emoji suggestions while typing", @@ -1714,6 +1718,8 @@ "Please enter verification code sent via text.": "Please enter verification code sent via text.", "Verification code": "Verification code", "Discovery options will appear once you have added a phone number above.": "Discovery options will appear once you have added a phone number above.", + "Sign out": "Sign out", + "Sign out all other sessions": "Sign out all other sessions", "Current session": "Current session", "Confirm logging out these devices by using Single Sign On to prove your identity.|other": "Confirm logging out these devices by using Single Sign On to prove your identity.", "Confirm logging out these devices by using Single Sign On to prove your identity.|one": "Confirm logging out this device by using Single Sign On to prove your identity.", @@ -1770,7 +1776,6 @@ "Not ready for secure messaging": "Not ready for secure messaging", "Inactive": "Inactive", "Inactive for %(inactiveAgeDays)s days or longer": "Inactive for %(inactiveAgeDays)s days or longer", - "Sign out": "Sign out", "Filter devices": "Filter devices", "Show": "Show", "%(selectedDeviceCount)s sessions selected": "%(selectedDeviceCount)s sessions selected", @@ -1847,7 +1852,6 @@ "Emoji": "Emoji", "Hide stickers": "Hide stickers", "Sticker": "Sticker", - "Voice broadcast": "Voice broadcast", "Voice Message": "Voice Message", "You do not have permission to start polls in this room.": "You do not have permission to start polls in this room.", "Poll": "Poll", diff --git a/src/languageHandler.tsx b/src/languageHandler.tsx index 2caf5c1639..0fc5fbb4d2 100644 --- a/src/languageHandler.tsx +++ b/src/languageHandler.tsx @@ -17,7 +17,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -import request from 'browser-request'; import counterpart from 'counterpart'; import React from 'react'; import { logger } from "matrix-js-sdk/src/logger"; @@ -386,6 +385,13 @@ export function setMissingEntryGenerator(f: (value: string) => void) { counterpart.setMissingEntryGenerator(f); } +type Languages = { + [lang: string]: { + fileName: string; + label: string; + }; +}; + export function setLanguage(preferredLangs: string | string[]) { if (!Array.isArray(preferredLangs)) { preferredLangs = [preferredLangs]; @@ -396,8 +402,8 @@ export function setLanguage(preferredLangs: string | string[]) { plaf.setLanguage(preferredLangs); } - let langToUse; - let availLangs; + let langToUse: string; + let availLangs: Languages; return getLangsJson().then((result) => { availLangs = result; @@ -434,9 +440,14 @@ export function setLanguage(preferredLangs: string | string[]) { }); } -export function getAllLanguagesFromJson() { +type Language = { + value: string; + label: string; +}; + +export function getAllLanguagesFromJson(): Promise { return getLangsJson().then((langsObject) => { - const langs = []; + const langs: Language[] = []; for (const langKey in langsObject) { if (langsObject.hasOwnProperty(langKey)) { langs.push({ @@ -532,29 +543,21 @@ export function pickBestLanguage(langs: string[]): string { return langs[0]; } -function getLangsJson(): Promise { - return new Promise((resolve, reject) => { - let url; - if (typeof(webpackLangJsonUrl) === 'string') { // in Jest this 'url' isn't a URL, so just fall through - url = webpackLangJsonUrl; - } else { - url = i18nFolder + 'languages.json'; - } - request( - { method: "GET", url }, - (err, response, body) => { - if (err) { - reject(err); - return; - } - if (response.status < 200 || response.status >= 300) { - reject(new Error(`Failed to load ${url}, got ${response.status}`)); - return; - } - resolve(JSON.parse(body)); - }, - ); - }); +async function getLangsJson(): Promise { + let url: string; + if (typeof(webpackLangJsonUrl) === 'string') { // in Jest this 'url' isn't a URL, so just fall through + url = webpackLangJsonUrl; + } else { + url = i18nFolder + 'languages.json'; + } + + const res = await fetch(url, { method: "GET" }); + + if (!res.ok) { + throw new Error(`Failed to load ${url}, got ${res.status}`); + } + + return res.json(); } interface ICounterpartTranslation { @@ -571,23 +574,14 @@ async function getLanguageRetry(langPath: string, num = 3): Promise { - return new Promise((resolve, reject) => { - request( - { method: "GET", url: langPath }, - (err, response, body) => { - if (err) { - reject(err); - return; - } - if (response.status < 200 || response.status >= 300) { - reject(new Error(`Failed to load ${langPath}, got ${response.status}`)); - return; - } - resolve(JSON.parse(body)); - }, - ); - }); +async function getLanguage(langPath: string): Promise { + const res = await fetch(langPath, { method: "GET" }); + + if (!res.ok) { + throw new Error(`Failed to load ${langPath}, got ${res.status}`); + } + + return res.json(); } export interface ICustomTranslations { diff --git a/src/models/IUpload.ts b/src/models/IUpload.ts deleted file mode 100644 index 715a71037f..0000000000 --- a/src/models/IUpload.ts +++ /dev/null @@ -1,28 +0,0 @@ -/* -Copyright 2021 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import { IEventRelation } from "matrix-js-sdk/src/matrix"; -import { IAbortablePromise } from "matrix-js-sdk/src/@types/partials"; - -export interface IUpload { - fileName: string; - roomId: string; - relation?: IEventRelation; - total: number; - loaded: number; - promise: IAbortablePromise; - canceled?: boolean; -} diff --git a/src/models/RoomUpload.ts b/src/models/RoomUpload.ts new file mode 100644 index 0000000000..aa4d33d2ea --- /dev/null +++ b/src/models/RoomUpload.ts @@ -0,0 +1,53 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { IEventRelation, UploadProgress } from "matrix-js-sdk/src/matrix"; + +import { IEncryptedFile } from "../customisations/models/IMediaEventContent"; + +export class RoomUpload { + public readonly abortController = new AbortController(); + public promise: Promise<{ url?: string, file?: IEncryptedFile }>; + private uploaded = 0; + + constructor( + public readonly roomId: string, + public readonly fileName: string, + public readonly relation?: IEventRelation, + public fileSize = 0, + ) {} + + public onProgress(progress: UploadProgress) { + this.uploaded = progress.loaded; + this.fileSize = progress.total; + } + + public abort(): void { + this.abortController.abort(); + } + + public get cancelled(): boolean { + return this.abortController.signal.aborted; + } + + public get total(): number { + return this.fileSize; + } + + public get loaded(): number { + return this.uploaded; + } +} diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index 803823b262..9b6e09c772 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -475,8 +475,24 @@ export const SETTINGS: {[setting: string]: ISetting} = { isFeature: true, labsGroup: LabGroup.Experimental, supportedLevels: LEVELS_FEATURE, - displayName: _td("Use new session manager (under active development)"), + displayName: _td("Use new session manager"), default: false, + betaInfo: { + title: _td('New session manager'), + caption: () => <> +

+ { _td('Have greater visibility and control over all your sessions.') } +

+

+ { _td( + 'Our new sessions manager provides better visibility of all your sessions, ' + + 'and greater control over them including the ability to remotely toggle push notifications.', + ) + } +

+ + , + }, }, "baseFontSize": { displayName: _td("Font size"), diff --git a/src/stores/CallStore.ts b/src/stores/CallStore.ts index 9d8eeb8ff0..abfb6b54af 100644 --- a/src/stores/CallStore.ts +++ b/src/stores/CallStore.ts @@ -90,7 +90,7 @@ export class CallStore extends AsyncStoreWithClient<{}> { } this.callListeners.clear(); this.calls.clear(); - this.activeCalls = new Set(); + this._activeCalls.clear(); this.matrixClient.off(ClientEvent.Room, this.onRoom); this.matrixClient.off(RoomStateEvent.Events, this.onRoomState); diff --git a/src/stores/widgets/StopGapWidgetDriver.ts b/src/stores/widgets/StopGapWidgetDriver.ts index dd806dd6dd..752d6d57e6 100644 --- a/src/stores/widgets/StopGapWidgetDriver.ts +++ b/src/stores/widgets/StopGapWidgetDriver.ts @@ -449,7 +449,7 @@ export class StopGapWidgetDriver extends WidgetDriver { from, to, limit, - direction: dir, + dir, }); return { diff --git a/src/toasts/AnalyticsToast.tsx b/src/toasts/AnalyticsToast.tsx index c7f2bdb59a..9a1ea00860 100644 --- a/src/toasts/AnalyticsToast.tsx +++ b/src/toasts/AnalyticsToast.tsx @@ -28,8 +28,6 @@ import { showDialog as showAnalyticsLearnMoreDialog, } from "../components/views/dialogs/AnalyticsLearnMoreDialog"; import { Action } from "../dispatcher/actions"; -import { SnakedObject } from "../utils/SnakedObject"; -import { IConfigOptions } from "../IConfigOptions"; import SettingsStore from "../settings/SettingsStore"; const onAccept = () => { @@ -78,16 +76,7 @@ const onLearnMorePreviouslyOptedIn = () => { const TOAST_KEY = "analytics"; export function getPolicyUrl(): Optional { - const policyUrl = SdkConfig.get("privacy_policy_url"); - if (policyUrl) return policyUrl; - - // Try get from legacy config location - const piwikConfig = SdkConfig.get("piwik"); - let piwik: Optional>>; - if (typeof piwikConfig === 'object') { - piwik = new SnakedObject(piwikConfig); - } - return piwik?.get("policy_url"); + return SdkConfig.get("privacy_policy_url"); } export const showToast = (): void => { diff --git a/src/toasts/BulkUnverifiedSessionsToast.ts b/src/toasts/BulkUnverifiedSessionsToast.ts index 6ddb0d7db5..0113f2f030 100644 --- a/src/toasts/BulkUnverifiedSessionsToast.ts +++ b/src/toasts/BulkUnverifiedSessionsToast.ts @@ -20,7 +20,6 @@ import DeviceListener from '../DeviceListener'; import GenericToast from "../components/views/toasts/GenericToast"; import ToastStore from "../stores/ToastStore"; import { Action } from "../dispatcher/actions"; -import { UserTab } from "../components/views/dialogs/UserTab"; const TOAST_KEY = "reviewsessions"; @@ -29,8 +28,7 @@ export const showToast = (deviceIds: Set) => { DeviceListener.sharedInstance().dismissUnverifiedSessions(deviceIds); dis.dispatch({ - action: Action.ViewUserSettings, - initialTabId: UserTab.Security, + action: Action.ViewUserDeviceSettings, }); }; diff --git a/src/toasts/UnverifiedSessionToast.ts b/src/toasts/UnverifiedSessionToast.ts index d0db97cd08..f2d637ef0d 100644 --- a/src/toasts/UnverifiedSessionToast.ts +++ b/src/toasts/UnverifiedSessionToast.ts @@ -21,7 +21,6 @@ import DeviceListener from '../DeviceListener'; import ToastStore from "../stores/ToastStore"; import GenericToast from "../components/views/toasts/GenericToast"; import { Action } from "../dispatcher/actions"; -import { UserTab } from "../components/views/dialogs/UserTab"; function toastKey(deviceId: string) { return "unverified_session_" + deviceId; @@ -33,8 +32,7 @@ export const showToast = async (deviceId: string) => { const onAccept = () => { DeviceListener.sharedInstance().dismissUnverifiedSessions([deviceId]); dis.dispatch({ - action: Action.ViewUserSettings, - initialTabId: UserTab.Security, + action: Action.ViewUserDeviceSettings, }); }; diff --git a/src/utils/MultiInviter.ts b/src/utils/MultiInviter.ts index 3f565c079b..3c539f7bf0 100644 --- a/src/utils/MultiInviter.ts +++ b/src/utils/MultiInviter.ts @@ -184,7 +184,7 @@ export default class MultiInviter { } } - return this.matrixClient.invite(roomId, addr, undefined, this.reason); + return this.matrixClient.invite(roomId, addr, this.reason); } else { throw new Error('Unsupported address'); } diff --git a/src/utils/notifications.ts b/src/utils/notifications.ts index 7e8aff6d0b..0064eaf2bc 100644 --- a/src/utils/notifications.ts +++ b/src/utils/notifications.ts @@ -51,5 +51,5 @@ export async function createLocalNotificationSettingsIfNeeded(cli: MatrixClient) export function localNotificationsAreSilenced(cli: MatrixClient): boolean { const eventType = getLocalNotificationAccountDataEventType(cli.deviceId); const event = cli.getAccountData(eventType); - return event?.getContent()?.is_silenced ?? true; + return event?.getContent()?.is_silenced ?? false; } diff --git a/src/voice-broadcast/audio/VoiceBroadcastRecorder.ts b/src/voice-broadcast/audio/VoiceBroadcastRecorder.ts new file mode 100644 index 0000000000..7f084f3f4a --- /dev/null +++ b/src/voice-broadcast/audio/VoiceBroadcastRecorder.ts @@ -0,0 +1,141 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { Optional } from "matrix-events-sdk"; +import { TypedEventEmitter } from "matrix-js-sdk/src/models/typed-event-emitter"; + +import { VoiceRecording } from "../../audio/VoiceRecording"; +import SdkConfig, { DEFAULTS } from "../../SdkConfig"; +import { concat } from "../../utils/arrays"; +import { IDestroyable } from "../../utils/IDestroyable"; + +export enum VoiceBroadcastRecorderEvent { + ChunkRecorded = "chunk_recorded", +} + +interface EventMap { + [VoiceBroadcastRecorderEvent.ChunkRecorded]: (chunk: ChunkRecordedPayload) => void; +} + +export interface ChunkRecordedPayload { + buffer: Uint8Array; + length: number; +} + +/** + * This class provides the function to seamlessly record fixed length chunks. + * Subscribe with on(VoiceBroadcastRecordingEvents.ChunkRecorded, (payload: ChunkRecordedPayload) => {}) + * to retrieve chunks while recording. + */ +export class VoiceBroadcastRecorder + extends TypedEventEmitter + implements IDestroyable { + private headers = new Uint8Array(0); + private chunkBuffer = new Uint8Array(0); + private previousChunkEndTimePosition = 0; + private pagesFromRecorderCount = 0; + + public constructor( + private voiceRecording: VoiceRecording, + public readonly targetChunkLength: number, + ) { + super(); + this.voiceRecording.onDataAvailable = this.onDataAvailable; + } + + public async start(): Promise { + return this.voiceRecording.start(); + } + + /** + * Stops the recording and returns the remaining chunk (if any). + */ + public async stop(): Promise> { + await this.voiceRecording.stop(); + return this.extractChunk(); + } + + public get contentType(): string { + return this.voiceRecording.contentType; + } + + private get chunkLength(): number { + return this.voiceRecording.recorderSeconds - this.previousChunkEndTimePosition; + } + + private onDataAvailable = (data: ArrayBuffer): void => { + const dataArray = new Uint8Array(data); + this.pagesFromRecorderCount++; + + if (this.pagesFromRecorderCount <= 2) { + // first two pages contain the headers + this.headers = concat(this.headers, dataArray); + return; + } + + this.handleData(dataArray); + }; + + private handleData(data: Uint8Array): void { + this.chunkBuffer = concat(this.chunkBuffer, data); + this.emitChunkIfTargetLengthReached(); + } + + private emitChunkIfTargetLengthReached(): void { + if (this.chunkLength >= this.targetChunkLength) { + this.emitAndResetChunk(); + } + } + + /** + * Extracts the current chunk and resets the buffer. + */ + private extractChunk(): Optional { + if (this.chunkBuffer.length === 0) { + return null; + } + + const currentRecorderTime = this.voiceRecording.recorderSeconds; + const payload: ChunkRecordedPayload = { + buffer: concat(this.headers, this.chunkBuffer), + length: this.chunkLength, + }; + this.chunkBuffer = new Uint8Array(0); + this.previousChunkEndTimePosition = currentRecorderTime; + return payload; + } + + private emitAndResetChunk(): void { + if (this.chunkBuffer.length === 0) { + return; + } + + this.emit( + VoiceBroadcastRecorderEvent.ChunkRecorded, + this.extractChunk(), + ); + } + + public destroy(): void { + this.removeAllListeners(); + this.voiceRecording.destroy(); + } +} + +export const createVoiceBroadcastRecorder = (): VoiceBroadcastRecorder => { + const targetChunkLength = SdkConfig.get("voice_broadcast")?.chunk_length || DEFAULTS.voice_broadcast!.chunk_length; + return new VoiceBroadcastRecorder(new VoiceRecording(), targetChunkLength); +}; diff --git a/src/voice-broadcast/components/VoiceBroadcastBody.tsx b/src/voice-broadcast/components/VoiceBroadcastBody.tsx index 1a57b5c019..e36460b9f3 100644 --- a/src/voice-broadcast/components/VoiceBroadcastBody.tsx +++ b/src/voice-broadcast/components/VoiceBroadcastBody.tsx @@ -31,7 +31,7 @@ export const VoiceBroadcastBody: React.FC = ({ mxEvent }) => { const client = MatrixClientPeg.get(); const room = client.getRoom(mxEvent.getRoomId()); const recording = VoiceBroadcastRecordingsStore.instance().getByInfoEvent(mxEvent, client); - const [recordingState, setRecordingState] = useState(recording.state); + const [recordingState, setRecordingState] = useState(recording.getState()); useTypedEventEmitter( recording, @@ -46,13 +46,10 @@ export const VoiceBroadcastBody: React.FC = ({ mxEvent }) => { recording.stop(); }; - const senderId = mxEvent.getSender(); - const sender = mxEvent.sender; return ; }; diff --git a/src/voice-broadcast/components/atoms/VoiceBroadcastHeader.tsx b/src/voice-broadcast/components/atoms/VoiceBroadcastHeader.tsx new file mode 100644 index 0000000000..d7175db30b --- /dev/null +++ b/src/voice-broadcast/components/atoms/VoiceBroadcastHeader.tsx @@ -0,0 +1,55 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from "react"; +import { RoomMember } from "matrix-js-sdk/src/matrix"; + +import MemberAvatar from "../../../components/views/avatars/MemberAvatar"; +import { LiveBadge } from "../.."; +import { Icon, IconColour, IconType } from "../../../components/atoms/Icon"; +import { _t } from "../../../languageHandler"; + +interface VoiceBroadcastHeaderProps { + live: boolean; + sender: RoomMember; + roomName: string; + showBroadcast?: boolean; +} + +export const VoiceBroadcastHeader: React.FC = ({ + live, + sender, + roomName, + showBroadcast = false, +}) => { + const broadcast = showBroadcast + ?
+ + { _t("Voice broadcast") } +
+ : null; + const liveBadge = live ? : null; + return
+ +
+
+ { sender.name } +
+
+ { roomName } +
+ { broadcast } +
+ { liveBadge } +
; +}; diff --git a/src/voice-broadcast/components/molecules/VoiceBroadcastRecordingBody.tsx b/src/voice-broadcast/components/molecules/VoiceBroadcastRecordingBody.tsx index 13ea504ac4..0db9bb92e1 100644 --- a/src/voice-broadcast/components/molecules/VoiceBroadcastRecordingBody.tsx +++ b/src/voice-broadcast/components/molecules/VoiceBroadcastRecordingBody.tsx @@ -17,40 +17,31 @@ limitations under the License. import React, { MouseEventHandler } from "react"; import { RoomMember } from "matrix-js-sdk/src/matrix"; -import { LiveBadge } from "../.."; -import MemberAvatar from "../../../components/views/avatars/MemberAvatar"; +import { VoiceBroadcastHeader } from "../.."; interface VoiceBroadcastRecordingBodyProps { live: boolean; - member: RoomMember; onClick: MouseEventHandler; - title: string; - userId: string; + roomName: string; + sender: RoomMember; } export const VoiceBroadcastRecordingBody: React.FC = ({ live, - member, onClick, - title, - userId, + roomName, + sender, }) => { - const liveBadge = live - ? - : null; - return (
- -
-
- { title } -
-
- { liveBadge } +
); }; diff --git a/src/voice-broadcast/index.ts b/src/voice-broadcast/index.ts index 2ceca2d3ab..73f428fd34 100644 --- a/src/voice-broadcast/index.ts +++ b/src/voice-broadcast/index.ts @@ -21,12 +21,18 @@ limitations under the License. import { RelationType } from "matrix-js-sdk/src/matrix"; -export * from "./components"; -export * from "./models"; -export * from "./utils"; -export * from "./stores"; +export * from "./audio/VoiceBroadcastRecorder"; +export * from "./components/VoiceBroadcastBody"; +export * from "./components/atoms/LiveBadge"; +export * from "./components/atoms/VoiceBroadcastHeader"; +export * from "./components/molecules/VoiceBroadcastRecordingBody"; +export * from "./models/VoiceBroadcastRecording"; +export * from "./stores/VoiceBroadcastRecordingsStore"; +export * from "./utils/shouldDisplayAsVoiceBroadcastTile"; +export * from "./utils/startNewVoiceBroadcastRecording"; export const VoiceBroadcastInfoEventType = "io.element.voice_broadcast_info"; +export const VoiceBroadcastChunkEventType = "io.element.voice_broadcast_chunk"; export enum VoiceBroadcastInfoState { Started = "started", @@ -36,6 +42,7 @@ export enum VoiceBroadcastInfoState { } export interface VoiceBroadcastInfoEventContent { + device_id: string; state: VoiceBroadcastInfoState; chunk_length?: number; ["m.relates_to"]?: { diff --git a/src/voice-broadcast/models/VoiceBroadcastRecording.ts b/src/voice-broadcast/models/VoiceBroadcastRecording.ts index e949644dee..74b6f2e128 100644 --- a/src/voice-broadcast/models/VoiceBroadcastRecording.ts +++ b/src/voice-broadcast/models/VoiceBroadcastRecording.ts @@ -14,10 +14,22 @@ See the License for the specific language governing permissions and limitations under the License. */ +import { logger } from "matrix-js-sdk/src/logger"; import { MatrixClient, MatrixEvent, RelationType } from "matrix-js-sdk/src/matrix"; import { TypedEventEmitter } from "matrix-js-sdk/src/models/typed-event-emitter"; -import { VoiceBroadcastInfoEventType, VoiceBroadcastInfoState } from ".."; +import { + ChunkRecordedPayload, + createVoiceBroadcastRecorder, + VoiceBroadcastInfoEventType, + VoiceBroadcastInfoState, + VoiceBroadcastRecorder, + VoiceBroadcastRecorderEvent, +} from ".."; +import { uploadFile } from "../../ContentMessages"; +import { IEncryptedFile } from "../../customisations/models/IMediaEventContent"; +import { createVoiceMessageContent } from "../../utils/createVoiceMessageContent"; +import { IDestroyable } from "../../utils/IDestroyable"; export enum VoiceBroadcastRecordingEvent { StateChanged = "liveness_changed", @@ -27,8 +39,12 @@ interface EventMap { [VoiceBroadcastRecordingEvent.StateChanged]: (state: VoiceBroadcastInfoState) => void; } -export class VoiceBroadcastRecording extends TypedEventEmitter { - private _state: VoiceBroadcastInfoState; +export class VoiceBroadcastRecording + extends TypedEventEmitter + implements IDestroyable { + private state: VoiceBroadcastInfoState; + private recorder: VoiceBroadcastRecorder; + private sequence = 1; public constructor( public readonly infoEvent: MatrixEvent, @@ -43,25 +59,94 @@ export class VoiceBroadcastRecording extends TypedEventEmitter { + this.state = !relatedEvents?.find((event: MatrixEvent) => { return event.getContent()?.state === VoiceBroadcastInfoState.Stopped; }) ? VoiceBroadcastInfoState.Started : VoiceBroadcastInfoState.Stopped; // TODO Michael W: add listening for updates } + public async start(): Promise { + return this.getRecorder().start(); + } + + public async stop(): Promise { + this.setState(VoiceBroadcastInfoState.Stopped); + await this.stopRecorder(); + await this.sendStoppedStateEvent(); + } + + public getState(): VoiceBroadcastInfoState { + return this.state; + } + + private getRecorder(): VoiceBroadcastRecorder { + if (!this.recorder) { + this.recorder = createVoiceBroadcastRecorder(); + this.recorder.on(VoiceBroadcastRecorderEvent.ChunkRecorded, this.onChunkRecorded); + } + + return this.recorder; + } + + public destroy(): void { + if (this.recorder) { + this.recorder.off(VoiceBroadcastRecorderEvent.ChunkRecorded, this.onChunkRecorded); + this.recorder.stop(); + } + + this.removeAllListeners(); + } + private setState(state: VoiceBroadcastInfoState): void { - this._state = state; + this.state = state; this.emit(VoiceBroadcastRecordingEvent.StateChanged, this.state); } - public async stop() { - this.setState(VoiceBroadcastInfoState.Stopped); - // TODO Michael W: add error handling + private onChunkRecorded = async (chunk: ChunkRecordedPayload): Promise => { + const { url, file } = await this.uploadFile(chunk); + await this.sendVoiceMessage(chunk, url, file); + }; + + private uploadFile(chunk: ChunkRecordedPayload): ReturnType { + return uploadFile( + this.client, + this.infoEvent.getRoomId(), + new Blob( + [chunk.buffer], + { + type: this.getRecorder().contentType, + }, + ), + ); + } + + private async sendVoiceMessage(chunk: ChunkRecordedPayload, url: string, file: IEncryptedFile): Promise { + const content = createVoiceMessageContent( + url, + this.getRecorder().contentType, + Math.round(chunk.length * 1000), + chunk.buffer.length, + file, + ); + content["m.relates_to"] = { + rel_type: RelationType.Reference, + event_id: this.infoEvent.getId(), + }; + content["io.element.voice_broadcast_chunk"] = { + sequence: this.sequence++, + }; + + await this.client.sendMessage(this.infoEvent.getRoomId(), content); + } + + private async sendStoppedStateEvent(): Promise { + // TODO Michael W: add error handling for state event await this.client.sendStateEvent( this.infoEvent.getRoomId(), VoiceBroadcastInfoEventType, { + device_id: this.client.getDeviceId(), state: VoiceBroadcastInfoState.Stopped, ["m.relates_to"]: { rel_type: RelationType.Reference, @@ -72,7 +157,18 @@ export class VoiceBroadcastRecording extends TypedEventEmitter { + if (!this.recorder) { + return; + } + + try { + const lastChunk = await this.recorder.stop(); + if (lastChunk) { + await this.onChunkRecorded(lastChunk); + } + } catch (err) { + logger.warn("error stopping voice broadcast recorder", err); + } } } diff --git a/src/voice-broadcast/models/index.ts b/src/voice-broadcast/models/index.ts deleted file mode 100644 index 053c032156..0000000000 --- a/src/voice-broadcast/models/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* -Copyright 2022 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -export * from "./VoiceBroadcastRecording"; diff --git a/src/voice-broadcast/stores/VoiceBroadcastRecordingsStore.ts b/src/voice-broadcast/stores/VoiceBroadcastRecordingsStore.ts index a8fb681873..380fd1d318 100644 --- a/src/voice-broadcast/stores/VoiceBroadcastRecordingsStore.ts +++ b/src/voice-broadcast/stores/VoiceBroadcastRecordingsStore.ts @@ -31,7 +31,7 @@ interface EventMap { * This store provides access to the current and specific Voice Broadcast recordings. */ export class VoiceBroadcastRecordingsStore extends TypedEventEmitter { - private _current: VoiceBroadcastRecording | null; + private current: VoiceBroadcastRecording | null; private recordings = new Map(); public constructor() { @@ -39,15 +39,15 @@ export class VoiceBroadcastRecordingsStore extends TypedEventEmitter ({ encryptAttachment: jest.fn().mockResolvedValue({}) })); + +jest.mock("../src/BlurhashEncoder", () => ({ + BlurhashEncoder: { + instance: { + getBlurhash: jest.fn(), + }, + }, +})); jest.mock("../src/utils/local-room", () => ({ doMaybeLocalRoomAction: jest.fn(), })); +const createElement = document.createElement.bind(document); + describe("ContentMessages", () => { const stickerUrl = "https://example.com/sticker"; const roomId = "!room:example.com"; @@ -36,6 +52,9 @@ describe("ContentMessages", () => { beforeEach(() => { client = { sendStickerMessage: jest.fn(), + sendMessage: jest.fn(), + isRoomEncrypted: jest.fn().mockReturnValue(false), + uploadContent: jest.fn().mockResolvedValue({ content_uri: "mxc://server/file" }), } as unknown as MatrixClient; contentMessages = new ContentMessages(); prom = Promise.resolve(null); @@ -65,4 +84,226 @@ describe("ContentMessages", () => { expect(client.sendStickerMessage).toHaveBeenCalledWith(roomId, null, stickerUrl, imageInfo, text); }); }); + + describe("sendContentToRoom", () => { + const roomId = "!roomId:server"; + beforeEach(() => { + Object.defineProperty(global.Image.prototype, 'src', { + // Define the property setter + set(src) { + setTimeout(() => this.onload()); + }, + }); + Object.defineProperty(global.Image.prototype, 'height', { + get() { return 600; }, + }); + Object.defineProperty(global.Image.prototype, 'width', { + get() { return 800; }, + }); + mocked(doMaybeLocalRoomAction).mockImplementation(( + roomId: string, + fn: (actualRoomId: string) => Promise, + ) => fn(roomId)); + mocked(BlurhashEncoder.instance.getBlurhash).mockResolvedValue(undefined); + }); + + it("should use m.image for image files", async () => { + mocked(client.uploadContent).mockResolvedValue({ content_uri: "mxc://server/file" }); + const file = new File([], "fileName", { type: "image/jpeg" }); + await contentMessages.sendContentToRoom(file, roomId, undefined, client, undefined); + expect(client.sendMessage).toHaveBeenCalledWith(roomId, null, expect.objectContaining({ + url: "mxc://server/file", + msgtype: "m.image", + })); + }); + + it("should fall back to m.file for invalid image files", async () => { + mocked(client.uploadContent).mockResolvedValue({ content_uri: "mxc://server/file" }); + const file = new File([], "fileName", { type: "image/png" }); + await contentMessages.sendContentToRoom(file, roomId, undefined, client, undefined); + expect(client.sendMessage).toHaveBeenCalledWith(roomId, null, expect.objectContaining({ + url: "mxc://server/file", + msgtype: "m.file", + })); + }); + + it("should use m.video for video files", async () => { + jest.spyOn(document, "createElement").mockImplementation(tagName => { + const element = createElement(tagName); + if (tagName === "video") { + element.load = jest.fn(); + element.play = () => element.onloadeddata(new Event("loadeddata")); + element.pause = jest.fn(); + Object.defineProperty(element, 'videoHeight', { + get() { return 600; }, + }); + Object.defineProperty(element, 'videoWidth', { + get() { return 800; }, + }); + } + return element; + }); + + mocked(client.uploadContent).mockResolvedValue({ content_uri: "mxc://server/file" }); + const file = new File([], "fileName", { type: "video/mp4" }); + await contentMessages.sendContentToRoom(file, roomId, undefined, client, undefined); + expect(client.sendMessage).toHaveBeenCalledWith(roomId, null, expect.objectContaining({ + url: "mxc://server/file", + msgtype: "m.video", + })); + }); + + it("should use m.audio for audio files", async () => { + mocked(client.uploadContent).mockResolvedValue({ content_uri: "mxc://server/file" }); + const file = new File([], "fileName", { type: "audio/mp3" }); + await contentMessages.sendContentToRoom(file, roomId, undefined, client, undefined); + expect(client.sendMessage).toHaveBeenCalledWith(roomId, null, expect.objectContaining({ + url: "mxc://server/file", + msgtype: "m.audio", + })); + }); + + it("should default to name 'Attachment' if file doesn't have a name", async () => { + mocked(client.uploadContent).mockResolvedValue({ content_uri: "mxc://server/file" }); + const file = new File([], "", { type: "text/plain" }); + await contentMessages.sendContentToRoom(file, roomId, undefined, client, undefined); + expect(client.sendMessage).toHaveBeenCalledWith(roomId, null, expect.objectContaining({ + url: "mxc://server/file", + msgtype: "m.file", + body: "Attachment", + })); + }); + + it("should keep RoomUpload's total and loaded values up to date", async () => { + mocked(client.uploadContent).mockResolvedValue({ content_uri: "mxc://server/file" }); + const file = new File([], "", { type: "text/plain" }); + const prom = contentMessages.sendContentToRoom(file, roomId, undefined, client, undefined); + const [upload] = contentMessages.getCurrentUploads(); + + expect(upload.loaded).toBe(0); + expect(upload.total).toBe(file.size); + const { progressHandler } = mocked(client.uploadContent).mock.calls[0][1]; + progressHandler({ loaded: 123, total: 1234 }); + expect(upload.loaded).toBe(123); + expect(upload.total).toBe(1234); + await prom; + }); + }); + + describe("getCurrentUploads", () => { + const file1 = new File([], "file1"); + const file2 = new File([], "file2"); + const roomId = "!roomId:server"; + + beforeEach(() => { + mocked(doMaybeLocalRoomAction).mockImplementation(( + roomId: string, + fn: (actualRoomId: string) => Promise, + ) => fn(roomId)); + }); + + it("should return only uploads for the given relation", async () => { + const relation = { + rel_type: RelationType.Thread, + event_id: "!threadId:server", + }; + const p1 = contentMessages.sendContentToRoom(file1, roomId, relation, client, undefined); + const p2 = contentMessages.sendContentToRoom(file2, roomId, undefined, client, undefined); + + const uploads = contentMessages.getCurrentUploads(relation); + expect(uploads).toHaveLength(1); + expect(uploads[0].relation).toEqual(relation); + expect(uploads[0].fileName).toEqual("file1"); + await Promise.all([p1, p2]); + }); + + it("should return only uploads for no relation when not passed one", async () => { + const relation = { + rel_type: RelationType.Thread, + event_id: "!threadId:server", + }; + const p1 = contentMessages.sendContentToRoom(file1, roomId, relation, client, undefined); + const p2 = contentMessages.sendContentToRoom(file2, roomId, undefined, client, undefined); + + const uploads = contentMessages.getCurrentUploads(); + expect(uploads).toHaveLength(1); + expect(uploads[0].relation).toEqual(undefined); + expect(uploads[0].fileName).toEqual("file2"); + await Promise.all([p1, p2]); + }); + }); + + describe("cancelUpload", () => { + it("should cancel in-flight upload", async () => { + const deferred = defer(); + mocked(client.uploadContent).mockReturnValue(deferred.promise); + const file1 = new File([], "file1"); + const prom = contentMessages.sendContentToRoom(file1, roomId, undefined, client, undefined); + const { abortController } = mocked(client.uploadContent).mock.calls[0][1]; + expect(abortController.signal.aborted).toBeFalsy(); + const [upload] = contentMessages.getCurrentUploads(); + contentMessages.cancelUpload(upload); + expect(abortController.signal.aborted).toBeTruthy(); + deferred.resolve({} as UploadResponse); + await prom; + }); + }); +}); + +describe("uploadFile", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + const client = createTestClient(); + + it("should not encrypt the file if the room isn't encrypted", async () => { + mocked(client.isRoomEncrypted).mockReturnValue(false); + mocked(client.uploadContent).mockResolvedValue({ content_uri: "mxc://server/file" }); + const progressHandler = jest.fn(); + const file = new Blob([]); + + const res = await uploadFile(client, "!roomId:server", file, progressHandler); + + expect(res.url).toBe("mxc://server/file"); + expect(res.file).toBeFalsy(); + expect(encrypt.encryptAttachment).not.toHaveBeenCalled(); + expect(client.uploadContent).toHaveBeenCalledWith(file, expect.objectContaining({ progressHandler })); + }); + + it("should encrypt the file if the room is encrypted", async () => { + mocked(client.isRoomEncrypted).mockReturnValue(true); + mocked(client.uploadContent).mockResolvedValue({ content_uri: "mxc://server/file" }); + mocked(encrypt.encryptAttachment).mockResolvedValue({ + data: new ArrayBuffer(123), + info: {} as IEncryptedFile, + }); + const progressHandler = jest.fn(); + const file = new Blob(["123"]); + + const res = await uploadFile(client, "!roomId:server", file, progressHandler); + + expect(res.url).toBeFalsy(); + expect(res.file).toEqual(expect.objectContaining({ + url: "mxc://server/file", + })); + expect(encrypt.encryptAttachment).toHaveBeenCalled(); + expect(client.uploadContent).toHaveBeenCalledWith(expect.any(Blob), expect.objectContaining({ + progressHandler, + includeFilename: false, + })); + expect(mocked(client.uploadContent).mock.calls[0][0]).not.toBe(file); + }); + + it("should throw UploadCanceledError upon aborting the upload", async () => { + mocked(client.isRoomEncrypted).mockReturnValue(false); + const deferred = defer(); + mocked(client.uploadContent).mockReturnValue(deferred.promise); + const file = new Blob([]); + + const prom = uploadFile(client, "!roomId:server", file); + mocked(client.uploadContent).mock.calls[0][1].abortController.abort(); + deferred.resolve({ content_uri: "mxc://foo/bar" }); + await expect(prom).rejects.toThrowError(UploadCanceledError); + }); }); diff --git a/test/Notifier-test.ts b/test/Notifier-test.ts index eb5d5c7fce..224c1fec77 100644 --- a/test/Notifier-test.ts +++ b/test/Notifier-test.ts @@ -29,7 +29,7 @@ import { createLocalNotificationSettingsIfNeeded, getLocalNotificationAccountDataEventType, } from "../src/utils/notifications"; -import { getMockClientWithEventEmitter, mkEvent, mkRoom, mockPlatformPeg } from "./test-utils"; +import { getMockClientWithEventEmitter, mkEvent, mkRoom, mockClientMethodsUser, mockPlatformPeg } from "./test-utils"; import { IncomingCallToast } from "../src/toasts/IncomingCallToast"; jest.mock("../src/utils/notifications", () => ({ @@ -54,22 +54,28 @@ describe("Notifier", () => { let accountDataEventKey: string; let accountDataStore = {}; + const userId = "@bob:example.org"; + beforeEach(() => { accountDataStore = {}; mockClient = getMockClientWithEventEmitter({ - getUserId: jest.fn().mockReturnValue("@bob:example.org"), + ...mockClientMethodsUser(userId), isGuest: jest.fn().mockReturnValue(false), getAccountData: jest.fn().mockImplementation(eventType => accountDataStore[eventType]), setAccountData: jest.fn().mockImplementation((eventType, content) => { - accountDataStore[eventType] = new MatrixEvent({ + accountDataStore[eventType] = content ? new MatrixEvent({ type: eventType, content, - }); + }) : undefined; }), decryptEventIfNeeded: jest.fn(), getRoom: jest.fn(), getPushActionsForEvent: jest.fn(), }); + + mockClient.pushRules = { + global: undefined, + }; accountDataEventKey = getLocalNotificationAccountDataEventType(mockClient.deviceId); testRoom = mkRoom(mockClient, roomId); @@ -78,6 +84,7 @@ describe("Notifier", () => { supportsNotifications: jest.fn().mockReturnValue(true), maySendNotifications: jest.fn().mockReturnValue(true), displayNotification: jest.fn(), + loudNotification: jest.fn(), }); Notifier.isBodyEnabled = jest.fn().mockReturnValue(true); @@ -85,12 +92,128 @@ describe("Notifier", () => { mockClient.getRoom.mockReturnValue(testRoom); }); + describe('triggering notification from events', () => { + let hasStartedNotiferBefore = false; + + const event = new MatrixEvent({ + sender: '@alice:server.org', + type: 'm.room.message', + room_id: '!room:server.org', + content: { + body: 'hey', + }, + }); + + beforeEach(() => { + // notifier defines some listener functions in start + // and references them in stop + // so blows up if stopped before it was started + if (hasStartedNotiferBefore) { + Notifier.stop(); + } + Notifier.start(); + hasStartedNotiferBefore = true; + mockClient.getRoom.mockReturnValue(testRoom); + mockClient.getPushActionsForEvent.mockReturnValue({ + notify: true, + tweaks: { + sound: true, + }, + }); + + const enabledSettings = [ + 'notificationsEnabled', + 'audioNotificationsEnabled', + ]; + // enable notifications by default + jest.spyOn(SettingsStore, "getValue").mockImplementation( + settingName => enabledSettings.includes(settingName), + ); + }); + + afterAll(() => { + Notifier.stop(); + }); + + it('does not create notifications before syncing has started', () => { + mockClient!.emit(ClientEvent.Event, event); + + expect(MockPlatform.displayNotification).not.toHaveBeenCalled(); + expect(MockPlatform.loudNotification).not.toHaveBeenCalled(); + }); + + it('does not create notifications for own event', () => { + const ownEvent = new MatrixEvent({ sender: userId }); + + mockClient!.emit(ClientEvent.Sync, SyncState.Syncing); + mockClient!.emit(ClientEvent.Event, ownEvent); + + expect(MockPlatform.displayNotification).not.toHaveBeenCalled(); + expect(MockPlatform.loudNotification).not.toHaveBeenCalled(); + }); + + it('does not create notifications when event does not have notify push action', () => { + mockClient.getPushActionsForEvent.mockReturnValue({ + notify: false, + tweaks: { + sound: true, + }, + }); + + mockClient!.emit(ClientEvent.Sync, SyncState.Syncing); + mockClient!.emit(ClientEvent.Event, event); + + expect(MockPlatform.displayNotification).not.toHaveBeenCalled(); + expect(MockPlatform.loudNotification).not.toHaveBeenCalled(); + }); + + it('creates desktop notification when enabled', () => { + mockClient!.emit(ClientEvent.Sync, SyncState.Syncing); + mockClient!.emit(ClientEvent.Event, event); + + expect(MockPlatform.displayNotification).toHaveBeenCalledWith( + testRoom.name, + 'hey', + null, + testRoom, + event, + ); + }); + + it('creates a loud notification when enabled', () => { + mockClient!.emit(ClientEvent.Sync, SyncState.Syncing); + mockClient!.emit(ClientEvent.Event, event); + + expect(MockPlatform.loudNotification).toHaveBeenCalledWith( + event, testRoom, + ); + }); + + it('does not create loud notification when event does not have sound tweak in push actions', () => { + mockClient.getPushActionsForEvent.mockReturnValue({ + notify: true, + tweaks: { + sound: false, + }, + }); + + mockClient!.emit(ClientEvent.Sync, SyncState.Syncing); + mockClient!.emit(ClientEvent.Event, event); + + // desktop notification created + expect(MockPlatform.displayNotification).toHaveBeenCalled(); + // without noisy + expect(MockPlatform.loudNotification).not.toHaveBeenCalled(); + }); + }); + describe("_displayPopupNotification", () => { it.each([ - { silenced: true, count: 0 }, - { silenced: false, count: 1 }, - ])("does not dispatch when notifications are silenced", ({ silenced, count }) => { - mockClient.setAccountData(accountDataEventKey, { is_silenced: silenced }); + { event: { is_silenced: true }, count: 0 }, + { event: { is_silenced: false }, count: 1 }, + { event: undefined, count: 1 }, + ])("does not dispatch when notifications are silenced", ({ event, count }) => { + mockClient.setAccountData(accountDataEventKey, event); Notifier._displayPopupNotification(testEvent, testRoom); expect(MockPlatform.displayNotification).toHaveBeenCalledTimes(count); }); @@ -98,14 +221,15 @@ describe("Notifier", () => { describe("_playAudioNotification", () => { it.each([ - { silenced: true, count: 0 }, - { silenced: false, count: 1 }, - ])("does not dispatch when notifications are silenced", ({ silenced, count }) => { + { event: { is_silenced: true }, count: 0 }, + { event: { is_silenced: false }, count: 1 }, + { event: undefined, count: 1 }, + ])("does not dispatch when notifications are silenced", ({ event, count }) => { // It's not ideal to only look at whether this function has been called // but avoids starting to look into DOM stuff Notifier.getSoundForRoom = jest.fn(); - mockClient.setAccountData(accountDataEventKey, { is_silenced: silenced }); + mockClient.setAccountData(accountDataEventKey, event); Notifier._playAudioNotification(testEvent, testRoom); expect(Notifier.getSoundForRoom).toHaveBeenCalledTimes(count); }); diff --git a/test/ScalarAuthClient-test.ts b/test/ScalarAuthClient-test.ts index 3b6fcf77b2..02edc2bd98 100644 --- a/test/ScalarAuthClient-test.ts +++ b/test/ScalarAuthClient-test.ts @@ -14,47 +14,199 @@ See the License for the specific language governing permissions and limitations under the License. */ +import { mocked } from "jest-mock"; +import fetchMock from "fetch-mock-jest"; + import ScalarAuthClient from '../src/ScalarAuthClient'; -import { MatrixClientPeg } from '../src/MatrixClientPeg'; import { stubClient } from './test-utils'; +import SdkConfig from "../src/SdkConfig"; +import { WidgetType } from "../src/widgets/WidgetType"; describe('ScalarAuthClient', function() { - const apiUrl = 'test.com/api'; - const uiUrl = 'test.com/app'; + const apiUrl = 'https://test.com/api'; + const uiUrl = 'https:/test.com/app'; + const tokenObject = { + access_token: "token", + token_type: "Bearer", + matrix_server_name: "localhost", + expires_in: 999, + }; + + let client; beforeEach(function() { - window.localStorage.getItem = jest.fn((arg) => { - if (arg === "mx_scalar_token") return "brokentoken"; - }); - stubClient(); + jest.clearAllMocks(); + client = stubClient(); }); it('should request a new token if the old one fails', async function() { - const sac = new ScalarAuthClient(apiUrl, uiUrl); + const sac = new ScalarAuthClient(apiUrl + 0, uiUrl); - // @ts-ignore unhappy with Promise calls - jest.spyOn(sac, 'getAccountName').mockImplementation((arg: string) => { - switch (arg) { - case "brokentoken": - return Promise.reject({ - message: "Invalid token", - }); - case "wokentoken": - default: - return Promise.resolve(MatrixClientPeg.get().getUserId()); - } + fetchMock.get("https://test.com/api0/account?scalar_token=brokentoken&v=1.1", { + body: { message: "Invalid token" }, }); - MatrixClientPeg.get().getOpenIdToken = jest.fn().mockResolvedValue('this is your openid token'); + fetchMock.get("https://test.com/api0/account?scalar_token=wokentoken&v=1.1", { + body: { user_id: client.getUserId() }, + }); + + client.getOpenIdToken = jest.fn().mockResolvedValue(tokenObject); sac.exchangeForScalarToken = jest.fn((arg) => { - if (arg === "this is your openid token") return Promise.resolve("wokentoken"); + if (arg === tokenObject) return Promise.resolve("wokentoken"); }); await sac.connect(); - expect(sac.exchangeForScalarToken).toBeCalledWith('this is your openid token'); + expect(sac.exchangeForScalarToken).toBeCalledWith(tokenObject); expect(sac.hasCredentials).toBeTruthy(); // @ts-ignore private property expect(sac.scalarToken).toEqual('wokentoken'); }); + + describe("exchangeForScalarToken", () => { + it("should return `scalar_token` from API /register", async () => { + const sac = new ScalarAuthClient(apiUrl + 1, uiUrl); + + fetchMock.postOnce("https://test.com/api1/register?v=1.1", { + body: { scalar_token: "stoken" }, + }); + + await expect(sac.exchangeForScalarToken(tokenObject)).resolves.toBe("stoken"); + }); + + it("should throw upon non-20x code", async () => { + const sac = new ScalarAuthClient(apiUrl + 2, uiUrl); + + fetchMock.postOnce("https://test.com/api2/register?v=1.1", { + status: 500, + }); + + await expect(sac.exchangeForScalarToken(tokenObject)).rejects.toThrow("Scalar request failed: 500"); + }); + + it("should throw if scalar_token is missing in response", async () => { + const sac = new ScalarAuthClient(apiUrl + 3, uiUrl); + + fetchMock.postOnce("https://test.com/api3/register?v=1.1", { + body: {}, + }); + + await expect(sac.exchangeForScalarToken(tokenObject)).rejects.toThrow("Missing scalar_token in response"); + }); + }); + + describe("registerForToken", () => { + it("should call `termsInteractionCallback` upon M_TERMS_NOT_SIGNED error", async () => { + const sac = new ScalarAuthClient(apiUrl + 4, uiUrl); + const termsInteractionCallback = jest.fn(); + sac.setTermsInteractionCallback(termsInteractionCallback); + fetchMock.get("https://test.com/api4/account?scalar_token=testtoken1&v=1.1", { + body: { errcode: "M_TERMS_NOT_SIGNED" }, + }); + sac.exchangeForScalarToken = jest.fn(() => Promise.resolve("testtoken1")); + mocked(client.getTerms).mockResolvedValue({ policies: [] }); + + await expect(sac.registerForToken()).resolves.toBe("testtoken1"); + }); + + it("should throw upon non-20x code", async () => { + const sac = new ScalarAuthClient(apiUrl + 5, uiUrl); + fetchMock.get("https://test.com/api5/account?scalar_token=testtoken2&v=1.1", { + body: { errcode: "SERVER_IS_SAD" }, + status: 500, + }); + sac.exchangeForScalarToken = jest.fn(() => Promise.resolve("testtoken2")); + + await expect(sac.registerForToken()).rejects.toBeTruthy(); + }); + + it("should throw if user_id is missing from response", async () => { + const sac = new ScalarAuthClient(apiUrl + 6, uiUrl); + fetchMock.get("https://test.com/api6/account?scalar_token=testtoken3&v=1.1", { + body: {}, + }); + sac.exchangeForScalarToken = jest.fn(() => Promise.resolve("testtoken3")); + + await expect(sac.registerForToken()).rejects.toThrow("Missing user_id in response"); + }); + }); + + describe("getScalarPageTitle", () => { + let sac: ScalarAuthClient; + + beforeEach(async () => { + SdkConfig.put({ + integrations_rest_url: apiUrl + 7, + integrations_ui_url: uiUrl, + }); + + window.localStorage.setItem("mx_scalar_token_at_https://test.com/api7", "wokentoken1"); + fetchMock.get("https://test.com/api7/account?scalar_token=wokentoken1&v=1.1", { + body: { user_id: client.getUserId() }, + }); + + sac = new ScalarAuthClient(apiUrl + 7, uiUrl); + await sac.connect(); + }); + + it("should return `cached_title` from API /widgets/title_lookup", async () => { + const url = "google.com"; + fetchMock.get("https://test.com/api7/widgets/title_lookup?scalar_token=wokentoken1&curl=" + url, { + body: { + page_title_cache_item: { + cached_title: "Google", + }, + }, + }); + + await expect(sac.getScalarPageTitle(url)).resolves.toBe("Google"); + }); + + it("should throw upon non-20x code", async () => { + const url = "yahoo.com"; + fetchMock.get("https://test.com/api7/widgets/title_lookup?scalar_token=wokentoken1&curl=" + url, { + status: 500, + }); + + await expect(sac.getScalarPageTitle(url)).rejects.toThrow("Scalar request failed: 500"); + }); + }); + + describe("disableWidgetAssets", () => { + let sac: ScalarAuthClient; + + beforeEach(async () => { + SdkConfig.put({ + integrations_rest_url: apiUrl + 8, + integrations_ui_url: uiUrl, + }); + + window.localStorage.setItem("mx_scalar_token_at_https://test.com/api8", "wokentoken1"); + fetchMock.get("https://test.com/api8/account?scalar_token=wokentoken1&v=1.1", { + body: { user_id: client.getUserId() }, + }); + + sac = new ScalarAuthClient(apiUrl + 8, uiUrl); + await sac.connect(); + }); + + it("should send state=disable to API /widgets/set_assets_state", async () => { + fetchMock.get("https://test.com/api8/widgets/set_assets_state?scalar_token=wokentoken1" + + "&widget_type=m.custom&widget_id=id1&state=disable", { + body: "OK", + }); + + await expect(sac.disableWidgetAssets(WidgetType.CUSTOM, "id1")).resolves.toBeUndefined(); + }); + + it("should throw upon non-20x code", async () => { + fetchMock.get("https://test.com/api8/widgets/set_assets_state?scalar_token=wokentoken1" + + "&widget_type=m.custom&widget_id=id2&state=disable", { + status: 500, + }); + + await expect(sac.disableWidgetAssets(WidgetType.CUSTOM, "id2")) + .rejects.toThrow("Scalar request failed: 500"); + }); + }); }); diff --git a/test/SdkConfig-test.ts b/test/SdkConfig-test.ts new file mode 100644 index 0000000000..a497946b8f --- /dev/null +++ b/test/SdkConfig-test.ts @@ -0,0 +1,41 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import SdkConfig, { DEFAULTS } from "../src/SdkConfig"; + +describe("SdkConfig", () => { + describe("with default values", () => { + it("should return the default config", () => { + expect(SdkConfig.get()).toEqual(DEFAULTS); + }); + }); + + describe("with custom values", () => { + beforeEach(() => { + SdkConfig.put({ + voice_broadcast: { + chunk_length: 1337, + }, + }); + }); + + it("should return the custom config", () => { + const customConfig = JSON.parse(JSON.stringify(DEFAULTS)); + customConfig.voice_broadcast.chunk_length = 1337; + expect(SdkConfig.get()).toEqual(customConfig); + }); + }); +}); diff --git a/test/actions/handlers/viewUserDeviceSettings-test.ts b/test/actions/handlers/viewUserDeviceSettings-test.ts new file mode 100644 index 0000000000..72d1db430d --- /dev/null +++ b/test/actions/handlers/viewUserDeviceSettings-test.ts @@ -0,0 +1,48 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { viewUserDeviceSettings } from "../../../src/actions/handlers/viewUserDeviceSettings"; +import { UserTab } from "../../../src/components/views/dialogs/UserTab"; +import { Action } from "../../../src/dispatcher/actions"; +import defaultDispatcher from "../../../src/dispatcher/dispatcher"; + +describe('viewUserDeviceSettings()', () => { + const dispatchSpy = jest.spyOn(defaultDispatcher, 'dispatch'); + + beforeEach(() => { + dispatchSpy.mockClear(); + }); + + it('dispatches action to view new session manager when enabled', () => { + const isNewDeviceManagerEnabled = true; + viewUserDeviceSettings(isNewDeviceManagerEnabled); + + expect(dispatchSpy).toHaveBeenCalledWith({ + action: Action.ViewUserSettings, + initialTabId: UserTab.SessionManager, + }); + }); + + it('dispatches action to view old session manager when disabled', () => { + const isNewDeviceManagerEnabled = false; + viewUserDeviceSettings(isNewDeviceManagerEnabled); + + expect(dispatchSpy).toHaveBeenCalledWith({ + action: Action.ViewUserSettings, + initialTabId: UserTab.Security, + }); + }); +}); diff --git a/test/audio/VoiceMessageRecording-test.ts b/test/audio/VoiceMessageRecording-test.ts index 5114045c47..a49a480306 100644 --- a/test/audio/VoiceMessageRecording-test.ts +++ b/test/audio/VoiceMessageRecording-test.ts @@ -15,7 +15,7 @@ limitations under the License. */ import { mocked } from "jest-mock"; -import { IAbortablePromise, IEncryptedFile, IUploadOpts, MatrixClient } from "matrix-js-sdk/src/matrix"; +import { IEncryptedFile, UploadOpts, MatrixClient } from "matrix-js-sdk/src/matrix"; import { createVoiceMessageRecording, VoiceMessageRecording } from "../../src/audio/VoiceMessageRecording"; import { RecordingState, VoiceRecording } from "../../src/audio/VoiceRecording"; @@ -161,8 +161,8 @@ describe("VoiceMessageRecording", () => { matrixClient: MatrixClient, roomId: string, file: File | Blob, - _progressHandler?: IUploadOpts["progressHandler"], - ): IAbortablePromise<{ url?: string, file?: IEncryptedFile }> => { + _progressHandler?: UploadOpts["progressHandler"], + ): Promise<{ url?: string, file?: IEncryptedFile }> => { uploadFileClient = matrixClient; uploadFileRoomId = roomId; uploadBlob = file; diff --git a/test/components/views/context_menus/EmbeddedPage-test.tsx b/test/components/views/context_menus/EmbeddedPage-test.tsx new file mode 100644 index 0000000000..c550823113 --- /dev/null +++ b/test/components/views/context_menus/EmbeddedPage-test.tsx @@ -0,0 +1,58 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from "react"; +import fetchMock from "fetch-mock-jest"; +import { render, screen } from "@testing-library/react"; +import { mocked } from "jest-mock"; + +import { _t } from "../../../../src/languageHandler"; +import EmbeddedPage from "../../../../src/components/structures/EmbeddedPage"; + +jest.mock("../../../../src/languageHandler", () => ({ + _t: jest.fn(), +})); + +describe("", () => { + it("should translate _t strings", async () => { + mocked(_t).mockReturnValue("Przeglądaj pokoje"); + fetchMock.get("https://home.page", { + body: '

_t("Explore rooms")

', + }); + + const { asFragment } = render(); + await screen.findByText("Przeglądaj pokoje"); + expect(_t).toHaveBeenCalledWith("Explore rooms"); + expect(asFragment()).toMatchSnapshot(); + }); + + it("should show error if unable to load", async () => { + mocked(_t).mockReturnValue("Couldn't load page"); + fetchMock.get("https://other.page", { + status: 404, + }); + + const { asFragment } = render(); + await screen.findByText("Couldn't load page"); + expect(_t).toHaveBeenCalledWith("Couldn't load page"); + expect(asFragment()).toMatchSnapshot(); + }); + + it("should render nothing if no url given", () => { + const { asFragment } = render(); + expect(asFragment()).toMatchSnapshot(); + }); +}); diff --git a/test/components/views/context_menus/__snapshots__/EmbeddedPage-test.tsx.snap b/test/components/views/context_menus/__snapshots__/EmbeddedPage-test.tsx.snap new file mode 100644 index 0000000000..f5f874e275 --- /dev/null +++ b/test/components/views/context_menus/__snapshots__/EmbeddedPage-test.tsx.snap @@ -0,0 +1,43 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` should render nothing if no url given 1`] = ` + +
+
+
+ +`; + +exports[` should show error if unable to load 1`] = ` + +
+
+ Couldn't load page +
+
+
+`; + +exports[` should translate _t strings 1`] = ` + +
+
+

+ Przeglądaj pokoje +

+
+
+
+`; diff --git a/test/components/views/dialogs/ChangelogDialog-test.tsx b/test/components/views/dialogs/ChangelogDialog-test.tsx new file mode 100644 index 0000000000..f1c7800db5 --- /dev/null +++ b/test/components/views/dialogs/ChangelogDialog-test.tsx @@ -0,0 +1,104 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from "react"; +import fetchMock from "fetch-mock-jest"; +import { render, screen, waitForElementToBeRemoved } from "@testing-library/react"; + +import ChangelogDialog from "../../../../src/components/views/dialogs/ChangelogDialog"; + +describe("", () => { + it("should fetch github proxy url for each repo with old and new version strings", async () => { + const webUrl = "https://riot.im/github/repos/vector-im/element-web/compare/oldsha1...newsha1"; + fetchMock.get(webUrl, { + url: "https://api.github.com/repos/vector-im/element-web/compare/master...develop", + html_url: "https://github.com/vector-im/element-web/compare/master...develop", + permalink_url: "https://github.com/vector-im/element-web/compare/vector-im:72ca95e...vector-im:8891698", + diff_url: "https://github.com/vector-im/element-web/compare/master...develop.diff", + patch_url: "https://github.com/vector-im/element-web/compare/master...develop.patch", + base_commit: {}, + merge_base_commit: {}, + status: "ahead", + ahead_by: 24, + behind_by: 0, + total_commits: 24, + commits: [{ + sha: "commit-sha", + html_url: "https://api.github.com/repos/vector-im/element-web/commit/commit-sha", + commit: { message: "This is the first commit message" }, + }], + files: [], + }); + const reactUrl = "https://riot.im/github/repos/matrix-org/matrix-react-sdk/compare/oldsha2...newsha2"; + fetchMock.get(reactUrl, { + url: "https://api.github.com/repos/matrix-org/matrix-react-sdk/compare/master...develop", + html_url: "https://github.com/matrix-org/matrix-react-sdk/compare/master...develop", + permalink_url: "https://github.com/matrix-org/matrix-react-sdk/compare/matrix-org:cdb00...matrix-org:4a926", + diff_url: "https://github.com/matrix-org/matrix-react-sdk/compare/master...develop.diff", + patch_url: "https://github.com/matrix-org/matrix-react-sdk/compare/master...develop.patch", + base_commit: {}, + merge_base_commit: {}, + status: "ahead", + ahead_by: 83, + behind_by: 0, + total_commits: 83, + commits: [{ + sha: "commit-sha0", + html_url: "https://api.github.com/repos/matrix-org/matrix-react-sdk/commit/commit-sha", + commit: { message: "This is a commit message" }, + }], + files: [], + }); + const jsUrl = "https://riot.im/github/repos/matrix-org/matrix-js-sdk/compare/oldsha3...newsha3"; + fetchMock.get(jsUrl, { + url: "https://api.github.com/repos/matrix-org/matrix-js-sdk/compare/master...develop", + html_url: "https://github.com/matrix-org/matrix-js-sdk/compare/master...develop", + permalink_url: "https://github.com/matrix-org/matrix-js-sdk/compare/matrix-org:6166a8f...matrix-org:fec350", + diff_url: "https://github.com/matrix-org/matrix-js-sdk/compare/master...develop.diff", + patch_url: "https://github.com/matrix-org/matrix-js-sdk/compare/master...develop.patch", + base_commit: {}, + merge_base_commit: {}, + status: "ahead", + ahead_by: 48, + behind_by: 0, + total_commits: 48, + commits: [{ + sha: "commit-sha1", + html_url: "https://api.github.com/repos/matrix-org/matrix-js-sdk/commit/commit-sha1", + commit: { message: "This is a commit message" }, + }, { + sha: "commit-sha2", + html_url: "https://api.github.com/repos/matrix-org/matrix-js-sdk/commit/commit-sha2", + commit: { message: "This is another commit message" }, + }], + files: [], + }); + + const newVersion = "newsha1-react-newsha2-js-newsha3"; + const oldVersion = "oldsha1-react-oldsha2-js-oldsha3"; + const { asFragment } = render(( + + )); + + // Wait for spinners to go away + await waitForElementToBeRemoved(screen.getAllByRole("progressbar")); + + expect(fetchMock).toHaveFetched(webUrl); + expect(fetchMock).toHaveFetched(reactUrl); + expect(fetchMock).toHaveFetched(jsUrl); + expect(asFragment()).toMatchSnapshot(); + }); +}); diff --git a/test/components/views/dialogs/InviteDialog-test.tsx b/test/components/views/dialogs/InviteDialog-test.tsx index 8d10bb2357..469cbde96b 100644 --- a/test/components/views/dialogs/InviteDialog-test.tsx +++ b/test/components/views/dialogs/InviteDialog-test.tsx @@ -26,6 +26,11 @@ import SdkConfig from "../../../../src/SdkConfig"; import { ValidatedServerConfig } from "../../../../src/utils/ValidatedServerConfig"; import { IConfigOptions } from "../../../../src/IConfigOptions"; +const mockGetAccessToken = jest.fn().mockResolvedValue("getAccessToken"); +jest.mock("../../../../src/IdentityAuthClient", () => jest.fn().mockImplementation(() => ({ + getAccessToken: mockGetAccessToken, +}))); + describe("InviteDialog", () => { const roomId = "!111111111111111111:example.org"; const aliceId = "@alice:example.org"; @@ -42,6 +47,14 @@ describe("InviteDialog", () => { getProfileInfo: jest.fn().mockRejectedValue({ errcode: "" }), getIdentityServerUrl: jest.fn(), searchUserDirectory: jest.fn().mockResolvedValue({}), + lookupThreePid: jest.fn(), + registerWithIdentityServer: jest.fn().mockResolvedValue({ + access_token: "access_token", + token: "token", + }), + getOpenIdToken: jest.fn().mockResolvedValue({}), + getIdentityAccount: jest.fn().mockResolvedValue({}), + getTerms: jest.fn().mockResolvedValue({ policies: [] }), }); beforeEach(() => { @@ -85,7 +98,7 @@ describe("InviteDialog", () => { expect(screen.queryByText("Invite to Room")).toBeTruthy(); }); - it("should suggest valid MXIDs even if unknown", () => { + it("should suggest valid MXIDs even if unknown", async () => { render(( { /> )); - expect(screen.queryByText("@localpart:server.tld")).toBeFalsy(); + await screen.findAllByText("@localpart:server.tld"); // Using findAllByText as the MXID is used for name too }); it("should not suggest invalid MXIDs", () => { @@ -110,4 +123,48 @@ describe("InviteDialog", () => { expect(screen.queryByText("@localpart:server:tld")).toBeFalsy(); }); + + it("should lookup inputs which look like email addresses", async () => { + mockClient.getIdentityServerUrl.mockReturnValue("https://identity-server"); + mockClient.lookupThreePid.mockResolvedValue({ + address: "foobar@email.com", + medium: "email", + mxid: "@foobar:server", + }); + mockClient.getProfileInfo.mockResolvedValue({ + displayname: "Mr. Foo", + avatar_url: "mxc://foo/bar", + }); + + render(( + + )); + + await screen.findByText("Mr. Foo"); + await screen.findByText("@foobar:server"); + expect(mockClient.lookupThreePid).toHaveBeenCalledWith("email", "foobar@email.com", expect.anything()); + expect(mockClient.getProfileInfo).toHaveBeenCalledWith("@foobar:server"); + }); + + it("should suggest e-mail even if lookup fails", async () => { + mockClient.getIdentityServerUrl.mockReturnValue("https://identity-server"); + mockClient.lookupThreePid.mockResolvedValue({}); + + render(( + + )); + + await screen.findByText("foobar@email.com"); + await screen.findByText("Invite by email"); + }); }); diff --git a/test/components/views/dialogs/__snapshots__/ChangelogDialog-test.tsx.snap b/test/components/views/dialogs/__snapshots__/ChangelogDialog-test.tsx.snap new file mode 100644 index 0000000000..af044252bb --- /dev/null +++ b/test/components/views/dialogs/__snapshots__/ChangelogDialog-test.tsx.snap @@ -0,0 +1,135 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` should fetch github proxy url for each repo with old and new version strings 1`] = ` + +
+