diff --git a/.github/workflows/static_analysis.yaml b/.github/workflows/static_analysis.yaml index 696faece561..e711182d193 100644 --- a/.github/workflows/static_analysis.yaml +++ b/.github/workflows/static_analysis.yaml @@ -27,9 +27,9 @@ jobs: run: "yarn run lint:types" - name: Switch js-sdk to release mode + working-directory: node_modules/matrix-js-sdk run: | - scripts/ci/js-sdk-to-release.js - cd node_modules/matrix-js-sdk + scripts/switch_package_to_release.js yarn install yarn run build:compile yarn run build:types diff --git a/.percy.yml b/.percy.yml index e50f0b0dbba..deca7f58f7c 100644 --- a/.percy.yml +++ b/.percy.yml @@ -3,3 +3,5 @@ snapshot: widths: - 1024 - 1920 +percy: + defer-uploads: true diff --git a/CHANGELOG.md b/CHANGELOG.md index 66cb4bd3b5e..2765dbe4507 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,88 @@ +Changes in [3.59.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.59.0) (2022-10-25) +===================================================================================================== + +## ✨ Features + * Include a file-safe room name and ISO date in chat exports ([\#9440](https://github.com/matrix-org/matrix-react-sdk/pull/9440)). Fixes vector-im/element-web#21812 and vector-im/element-web#19724. + * Room call banner ([\#9378](https://github.com/matrix-org/matrix-react-sdk/pull/9378)). Fixes vector-im/element-web#23453. Contributed by @toger5. + * Device manager - spinners while devices are signing out ([\#9433](https://github.com/matrix-org/matrix-react-sdk/pull/9433)). Fixes vector-im/element-web#15865. + * Device manager - silence call ringers when local notifications are silenced ([\#9420](https://github.com/matrix-org/matrix-react-sdk/pull/9420)). + * Pass the current language to Element Call ([\#9427](https://github.com/matrix-org/matrix-react-sdk/pull/9427)). + * Hide screen-sharing button in Element Call on desktop ([\#9423](https://github.com/matrix-org/matrix-react-sdk/pull/9423)). + * Add reply support to WysiwygComposer ([\#9422](https://github.com/matrix-org/matrix-react-sdk/pull/9422)). Contributed by @florianduros. + * Disconnect other connected devices (of the same user) when joining an Element call ([\#9379](https://github.com/matrix-org/matrix-react-sdk/pull/9379)). + * Device manager - device tile main click target ([\#9409](https://github.com/matrix-org/matrix-react-sdk/pull/9409)). + * Add formatting buttons to the rich text editor ([\#9410](https://github.com/matrix-org/matrix-react-sdk/pull/9410)). Contributed by @florianduros. + * Device manager - current session context menu ([\#9386](https://github.com/matrix-org/matrix-react-sdk/pull/9386)). + * Remove piwik config fallback for privacy policy URL ([\#9390](https://github.com/matrix-org/matrix-react-sdk/pull/9390)). + * Add the first step to integrate the matrix wysiwyg composer ([\#9374](https://github.com/matrix-org/matrix-react-sdk/pull/9374)). Contributed by @florianduros. + * Device manager - UA parsing tweaks ([\#9382](https://github.com/matrix-org/matrix-react-sdk/pull/9382)). + * Device manager - remove client information events when disabling setting ([\#9384](https://github.com/matrix-org/matrix-react-sdk/pull/9384)). + * Add Element Call participant limit ([\#9358](https://github.com/matrix-org/matrix-react-sdk/pull/9358)). + * Add Element Call room settings ([\#9347](https://github.com/matrix-org/matrix-react-sdk/pull/9347)). + * Device manager - render extended device information ([\#9360](https://github.com/matrix-org/matrix-react-sdk/pull/9360)). + * New group call experience: Room header and PiP designs ([\#9351](https://github.com/matrix-org/matrix-react-sdk/pull/9351)). + * Pass language to Jitsi Widget ([\#9346](https://github.com/matrix-org/matrix-react-sdk/pull/9346)). Contributed by @Fox32. + * Add notifications and toasts for Element Call calls ([\#9337](https://github.com/matrix-org/matrix-react-sdk/pull/9337)). + * Device manager - device type icon ([\#9355](https://github.com/matrix-org/matrix-react-sdk/pull/9355)). + * Delete the remainder of groups ([\#9357](https://github.com/matrix-org/matrix-react-sdk/pull/9357)). Fixes vector-im/element-web#22770. + * Device manager - display client information in device details ([\#9315](https://github.com/matrix-org/matrix-react-sdk/pull/9315)). + +## 🐛 Bug Fixes + * Send Content-Type: application/json header for integration manager /register API ([\#9490](https://github.com/matrix-org/matrix-react-sdk/pull/9490)). Fixes vector-im/element-web#23580. + * Device manager - put client/browser device metadata in correct section ([\#9447](https://github.com/matrix-org/matrix-react-sdk/pull/9447)). + * update the room unread notification counter when the server changes the value without any related read receipt ([\#9438](https://github.com/matrix-org/matrix-react-sdk/pull/9438)). + * Don't show call banners in video rooms ([\#9441](https://github.com/matrix-org/matrix-react-sdk/pull/9441)). + * Prevent useContextMenu isOpen from being true if the button ref goes away ([\#9418](https://github.com/matrix-org/matrix-react-sdk/pull/9418)). Fixes matrix-org/element-web-rageshakes#15637. + * Automatically focus the WYSIWYG composer when you enter a room ([\#9412](https://github.com/matrix-org/matrix-react-sdk/pull/9412)). + * Improve the tooltips on the call lobby join button ([\#9428](https://github.com/matrix-org/matrix-react-sdk/pull/9428)). + * Pass the homeserver's base URL to Element Call ([\#9429](https://github.com/matrix-org/matrix-react-sdk/pull/9429)). Fixes vector-im/element-web#23301. + * Better accommodate long room names in call toasts ([\#9426](https://github.com/matrix-org/matrix-react-sdk/pull/9426)). + * Hide virtual widgets from the room info panel ([\#9424](https://github.com/matrix-org/matrix-react-sdk/pull/9424)). Fixes vector-im/element-web#23494. + * Inhibit clicking on sender avatar in threads list ([\#9417](https://github.com/matrix-org/matrix-react-sdk/pull/9417)). Fixes vector-im/element-web#23482. + * Correct the dir parameter of MSC3715 ([\#9391](https://github.com/matrix-org/matrix-react-sdk/pull/9391)). Contributed by @dhenneke. + * Use a more correct subset of users in `/remakeolm` developer command ([\#9402](https://github.com/matrix-org/matrix-react-sdk/pull/9402)). + * use correct default for notification silencing ([\#9388](https://github.com/matrix-org/matrix-react-sdk/pull/9388)). Fixes vector-im/element-web#23456. + * Device manager - eagerly create `m.local_notification_settings` events ([\#9353](https://github.com/matrix-org/matrix-react-sdk/pull/9353)). + * Close incoming Element call toast when viewing the call lobby ([\#9375](https://github.com/matrix-org/matrix-react-sdk/pull/9375)). + * Always allow enabling sending read receipts ([\#9367](https://github.com/matrix-org/matrix-react-sdk/pull/9367)). Fixes vector-im/element-web#23433. + * Fixes (vector-im/element-web/issues/22609) where the white theme is not applied when `white -> dark -> white` sequence is done. ([\#9320](https://github.com/matrix-org/matrix-react-sdk/pull/9320)). Contributed by @florianduros. + * Fix applying programmatically set height for "top" room layout ([\#9339](https://github.com/matrix-org/matrix-react-sdk/pull/9339)). Contributed by @Fox32. + +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 7029f1c1909..00000000000 --- 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.config.ts b/cypress.config.ts index 9236ee2931d..bc64b7d726a 100644 --- a/cypress.config.ts +++ b/cypress.config.ts @@ -30,4 +30,8 @@ export default defineConfig({ experimentalSessionAndOrigin: true, specPattern: 'cypress/e2e/**/*.{js,jsx,ts,tsx}', }, + retries: { + runMode: 4, + openMode: 0, + }, }); diff --git a/cypress/e2e/crypto/crypto.spec.ts b/cypress/e2e/crypto/crypto.spec.ts index 99bde7ad1da..650f8d585c3 100644 --- a/cypress/e2e/crypto/crypto.spec.ts +++ b/cypress/e2e/crypto/crypto.spec.ts @@ -14,9 +14,10 @@ See the License for the specific language governing permissions and limitations under the License. */ +import { MatrixClient, Room } from "matrix-js-sdk/src/matrix"; + import type { VerificationRequest } from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest"; import type { ISasEvent } from "matrix-js-sdk/src/crypto/verification/SAS"; -import type { MatrixClient, Room } from "matrix-js-sdk/src/matrix"; import { SynapseInstance } from "../../plugins/synapsedocker"; import Chainable = Cypress.Chainable; @@ -60,7 +61,6 @@ const testMessages = function(this: CryptoTestContext) { // check the invite message cy.contains(".mx_EventTile_body", "Hey!").closest(".mx_EventTile").within(() => { cy.get(".mx_EventTile_e2eIcon_warning").should("not.exist"); - cy.get(".mx_EventTile_receiptSent").should("exist"); }); // Bob sends a response @@ -73,17 +73,31 @@ const testMessages = function(this: CryptoTestContext) { }; const bobJoin = function(this: CryptoTestContext) { - cy.botJoinRoomByName(this.bob, "Alice").as("bobsRoom"); + cy.window({ log: false }).then(async win => { + const bobRooms = this.bob.getRooms(); + if (!bobRooms.length) { + await new Promise(resolve => { + const onMembership = (_event) => { + this.bob.off(win.matrixcs.RoomMemberEvent.Membership, onMembership); + resolve(); + }; + this.bob.on(win.matrixcs.RoomMemberEvent.Membership, onMembership); + }); + } + }).then(() => { + cy.botJoinRoomByName(this.bob, "Alice").as("bobsRoom"); + }); + cy.contains(".mx_TextualEvent", "Bob joined the room").should("exist"); }; const handleVerificationRequest = (request: VerificationRequest): Chainable => { return cy.wrap(new Promise((resolve) => { const onShowSas = (event: ISasEvent) => { - resolve(event.sas.emoji); verifier.off("show_sas", onShowSas); event.confirm(); verifier.done(); + resolve(event.sas.emoji); }; const verifier = request.beginKeyVerification("m.sas.v1"); diff --git a/cypress/e2e/integration-manager/kick.spec.ts b/cypress/e2e/integration-manager/kick.spec.ts index 6901cd376b1..f31c4032fdb 100644 --- a/cypress/e2e/integration-manager/kick.spec.ts +++ b/cypress/e2e/integration-manager/kick.spec.ts @@ -84,15 +84,10 @@ function sendActionFromIntegrationManager(integrationManagerUrl: string, targetR function expectKickedMessage(shouldExist: boolean) { // Expand any event summaries - cy.get(".mx_RoomView_MessageList").within(roomView => { - if (roomView.find(".mx_GenericEventListSummary_toggle[aria-expanded=false]").length > 0) { - cy.get(".mx_GenericEventListSummary_toggle[aria-expanded=false]").click({ multiple: true }); - } - }); + cy.get(".mx_GenericEventListSummary_toggle[aria-expanded=false]").click({ multiple: true }); // Check for the event message (or lack thereof) - cy.get(".mx_EventTile_line") - .contains(`${USER_DISPLAY_NAME} removed ${BOT_DISPLAY_NAME}: ${KICK_REASON}`) + cy.contains(".mx_EventTile_line", `${USER_DISPLAY_NAME} removed ${BOT_DISPLAY_NAME}: ${KICK_REASON}`) .should(shouldExist ? "exist" : "not.exist"); } diff --git a/cypress/e2e/lazy-loading/lazy-loading.spec.ts b/cypress/e2e/lazy-loading/lazy-loading.spec.ts index 7d26b48676d..be27c193463 100644 --- a/cypress/e2e/lazy-loading/lazy-loading.spec.ts +++ b/cypress/e2e/lazy-loading/lazy-loading.spec.ts @@ -109,7 +109,7 @@ describe("Lazy Loading", () => { } function openMemberlist(): void { - cy.get('.mx_HeaderButtons [aria-label="Room Info"]').click(); + cy.get('.mx_HeaderButtons [aria-label="Room info"]').click(); cy.get(".mx_RoomSummaryCard").within(() => { cy.get(".mx_RoomSummaryCard_icon_people").click(); }); diff --git a/cypress/e2e/login/login.spec.ts b/cypress/e2e/login/login.spec.ts index ea3a5239f09..2ba2e33f9bd 100644 --- a/cypress/e2e/login/login.spec.ts +++ b/cypress/e2e/login/login.spec.ts @@ -58,7 +58,7 @@ describe("Login", () => { cy.startMeasuring("from-submit-to-home"); cy.get(".mx_Login_submit").click(); - cy.url().should('contain', '/#/home'); + cy.url().should('contain', '/#/home', { timeout: 30000 }); cy.stopMeasuring("from-submit-to-home"); }); }); diff --git a/cypress/e2e/polls/polls.spec.ts b/cypress/e2e/polls/polls.spec.ts index ecfd4af90e6..470c69d8cf3 100644 --- a/cypress/e2e/polls/polls.spec.ts +++ b/cypress/e2e/polls/polls.spec.ts @@ -105,6 +105,8 @@ describe("Polls", () => { roomId = _roomId; cy.inviteUser(roomId, bot.getUserId()); cy.visit('/#/room/' + roomId); + // wait until Bob joined + cy.contains(".mx_TextualEvent", "BotBob joined the room").should("exist"); }); cy.openMessageComposerOptions().within(() => { @@ -173,6 +175,8 @@ describe("Polls", () => { cy.inviteUser(roomId, botBob.getUserId()); cy.inviteUser(roomId, botCharlie.getUserId()); cy.visit('/#/room/' + roomId); + // wait until the bots joined + cy.contains(".mx_TextualEvent", "and one other were invited and joined").should("exist"); }); cy.openMessageComposerOptions().within(() => { diff --git a/cypress/e2e/register/register.spec.ts b/cypress/e2e/register/register.spec.ts index a0c120414d2..1945eb7fec0 100644 --- a/cypress/e2e/register/register.spec.ts +++ b/cypress/e2e/register/register.spec.ts @@ -72,7 +72,7 @@ describe("Registration", () => { cy.startMeasuring("from-submit-to-home"); cy.get(".mx_InteractiveAuthEntryComponents_termsSubmit").click(); - cy.get(".mx_UseCaseSelection_skip").should("exist"); + cy.get(".mx_UseCaseSelection_skip", { timeout: 30000 }).should("exist"); cy.percySnapshot("Use-case selection screen"); cy.checkA11y(); cy.get(".mx_UseCaseSelection_skip .mx_AccessibleButton").click(); diff --git a/cypress/e2e/settings/device-management.spec.ts b/cypress/e2e/settings/device-management.spec.ts new file mode 100644 index 00000000000..1709475e17c --- /dev/null +++ b/cypress/e2e/settings/device-management.spec.ts @@ -0,0 +1,114 @@ +/* +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 .mx_Checkbox').first().click(); + cy.get('.mx_FilteredDeviceList_list .mx_FilteredDeviceList_listItem .mx_Checkbox').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`; + // open 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(); + }); + + // 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 d4b2d2cf9b0..adcc141deb6 100644 --- a/cypress/e2e/spotlight/spotlight.spec.ts +++ b/cypress/e2e/spotlight/spotlight.spec.ts @@ -124,8 +124,9 @@ Cypress.Commands.add("startDM", (name: string) => { cy.get(".mx_BasicMessageComposer_input") .should("have.focus") .type("Hey!{enter}"); - cy.contains(".mx_EventTile_body", "Hey!"); - cy.get(".mx_RoomSublist[aria-label=People]").should("contain", name); + // 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); }); describe("Spotlight", () => { @@ -162,7 +163,7 @@ describe("Spotlight", () => { cy.window({ log: false }).then(({ matrixcs: { Visibility } }) => { cy.createRoom({ name: room1Name, visibility: Visibility.Public }).then(_room1Id => { room1Id = _room1Id; - cy.inviteUser(room1Id, bot1.getUserId()); + bot1.joinRoom(room1Id); cy.visit("/#/room/" + room1Id); }); bot2.createRoom({ name: room2Name, visibility: Visibility.Public }) @@ -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 @@ -365,7 +366,10 @@ describe("Spotlight", () => { cy.spotlightSearch().clear().type(bot1.getUserId()); cy.wait(1000); // wait for the dialog code to settle cy.spotlightResults().should("have.length", 2); - cy.spotlightResults().eq(0).should("contain", groupDm.name); + cy.contains( + ".mx_SpotlightDialog_section.mx_SpotlightDialog_results .mx_SpotlightDialog_option", + groupDm.name, + ); }); // Search for ByteBot by id, should return group DM and user @@ -374,7 +378,10 @@ describe("Spotlight", () => { cy.spotlightSearch().clear().type(bot2.getUserId()); cy.wait(1000); // wait for the dialog code to settle cy.spotlightResults().should("have.length", 2); - cy.spotlightResults().eq(0).should("contain", groupDm.name); + cy.contains( + ".mx_SpotlightDialog_section.mx_SpotlightDialog_results .mx_SpotlightDialog_option", + groupDm.name, + ); }); }); }); @@ -403,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"); @@ -425,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/threads/threads.spec.ts b/cypress/e2e/threads/threads.spec.ts index 1cc6848ec56..5af2d07d792 100644 --- a/cypress/e2e/threads/threads.spec.ts +++ b/cypress/e2e/threads/threads.spec.ts @@ -73,7 +73,10 @@ describe("Threads", () => { it("should be usable for a conversation", () => { let bot: MatrixClient; - cy.getBot(synapse, { displayName: "BotBob" }).then(_bot => { + cy.getBot(synapse, { + displayName: "BotBob", + autoAcceptInvites: false, + }).then(_bot => { bot = _bot; }); @@ -81,6 +84,7 @@ describe("Threads", () => { cy.createRoom({}).then(_roomId => { roomId = _roomId; cy.inviteUser(roomId, bot.getUserId()); + bot.joinRoom(roomId); cy.visit("/#/room/" + roomId); }); diff --git a/cypress/e2e/timeline/timeline.spec.ts b/cypress/e2e/timeline/timeline.spec.ts index 94b6ffaa425..6cebbfd1814 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/e2e/widgets/layout.spec.ts b/cypress/e2e/widgets/layout.spec.ts new file mode 100644 index 00000000000..25264c66224 --- /dev/null +++ b/cypress/e2e/widgets/layout.spec.ts @@ -0,0 +1,121 @@ +/* +Copyright 2022 Oliver Sand +Copyright 2022 Nordeck IT + Consulting GmbH. + +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 { IWidget } from "matrix-widget-api"; + +import { SynapseInstance } from "../../plugins/synapsedocker"; + +const ROOM_NAME = 'Test Room'; +const WIDGET_ID = "fake-widget"; +const WIDGET_HTML = ` + + + Fake Widget + + + Hello World + + +`; + +describe('Widget Layout', () => { + let widgetUrl: string; + let synapse: SynapseInstance; + let roomId: string; + + beforeEach(() => { + cy.startSynapse("default").then(data => { + synapse = data; + + cy.initTestUser(synapse, "Sally"); + }); + cy.serveHtmlFile(WIDGET_HTML).then(url => { + widgetUrl = url; + }); + + cy.createRoom({ + name: ROOM_NAME, + }).then((id) => { + roomId = id; + + // setup widget via state event + cy.getClient().then(async matrixClient => { + const content: IWidget = { + id: WIDGET_ID, + creatorUserId: 'somebody', + type: 'widget', + name: 'widget', + url: widgetUrl, + }; + await matrixClient.sendStateEvent(roomId, 'im.vector.modular.widgets', content, WIDGET_ID); + }).as('widgetEventSent'); + + // set initial layout + cy.getClient().then(async matrixClient => { + const content = { + widgets: { + [WIDGET_ID]: { + container: 'top', index: 1, width: 100, height: 0, + }, + }, + }; + await matrixClient.sendStateEvent(roomId, 'io.element.widgets.layout', content, ""); + }).as('layoutEventSent'); + }); + + cy.all([ + cy.get("@widgetEventSent"), + cy.get("@layoutEventSent"), + ]).then(() => { + // open the room + cy.viewRoomByName(ROOM_NAME); + }); + }); + + afterEach(() => { + cy.stopSynapse(synapse); + cy.stopWebServers(); + }); + + it('manually resize the height of the top container layout', () => { + cy.get('iframe[title="widget"]').invoke('height').should('be.lessThan', 250); + + cy.get('.mx_AppsContainer_resizerHandle') + .trigger('mousedown') + .trigger('mousemove', { clientX: 0, clientY: 550, force: true }) + .trigger('mouseup', { clientX: 0, clientY: 550, force: true }); + + cy.get('iframe[title="widget"]').invoke('height').should('be.greaterThan', 400); + }); + + it('programatically resize the height of the top container layout', () => { + cy.get('iframe[title="widget"]').invoke('height').should('be.lessThan', 250); + + cy.getClient().then(async matrixClient => { + const content = { + widgets: { + [WIDGET_ID]: { + container: 'top', index: 1, width: 100, height: 100, + }, + }, + }; + await matrixClient.sendStateEvent(roomId, 'io.element.widgets.layout', content, ""); + }); + + cy.get('iframe[title="widget"]').invoke('height').should('be.greaterThan', 400); + }); +}); diff --git a/cypress/support/bot.ts b/cypress/support/bot.ts index f724d6b3d36..26f0aa497e4 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(), @@ -128,7 +125,7 @@ Cypress.Commands.add("botJoinRoomByName", (cli: MatrixClient, roomName: string): return cy.botJoinRoom(cli, room.roomId); } - return cy.wrap(Promise.reject()); + return cy.wrap(Promise.reject(`Bot room join failed. Cannot find room '${roomName}'`)); }); Cypress.Commands.add("botSendMessage", ( diff --git a/cypress/support/client.ts b/cypress/support/client.ts index c3f3aab0eb6..e20c08a8139 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 4cdfd6a84df..e44be781231 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"); @@ -97,13 +121,14 @@ Cypress.Commands.add("initTestUser", (synapse: SynapseInstance, displayName: str return cy.visit("/").then(() => { // wait for the app to load - return cy.get(".mx_MatrixChat", { timeout: 15000 }); + 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 dae6eb22b81..884e4c8350a 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 dbd30cfd72f..a916425df58 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-react-sdk", - "version": "3.57.0", + "version": "3.59.0", "description": "SDK for matrix.org using React", "author": "matrix.org", "repository": { @@ -57,14 +57,14 @@ "dependencies": { "@babel/runtime": "^7.12.5", "@matrix-org/analytics-events": "^0.2.0", + "@matrix-org/matrix-wysiwyg": "^0.2.0", "@matrix-org/react-sdk-module-api": "^0.0.3", "@sentry/browser": "^6.11.0", "@sentry/tracing": "^6.11.0", - "@testing-library/react": "^12.1.5", "@types/geojson": "^7946.0.8", + "@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", @@ -93,7 +93,7 @@ "maplibre-gl": "^1.15.2", "matrix-encrypt-attachment": "^1.0.3", "matrix-events-sdk": "^0.0.1-beta.7", - "matrix-js-sdk": "20.0.0", + "matrix-js-sdk": "21.0.0", "matrix-widget-api": "^1.1.1", "minimist": "^1.2.5", "opus-recorder": "^8.0.3", @@ -112,8 +112,10 @@ "react-lite-youtube-embed": "^2.2.1-a", "react-transition-group": "^4.4.1", "rfc4648": "^1.4.0", + "sanitize-filename": "^1.6.3", "sanitize-html": "^2.3.2", "tar-js": "^0.3.0", + "ua-parser-js": "^1.0.2", "url": "^0.11.0", "what-input": "^5.2.10", "zxcvbn": "^4.4.2" @@ -136,10 +138,13 @@ "@babel/traverse": "^7.12.12", "@matrix-org/olm": "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.8.tgz", "@peculiar/webcrypto": "^1.1.4", - "@percy/cli": "^1.3.0", - "@percy/cypress": "^3.1.1", + "@percy/cli": "^1.11.0", + "@percy/cypress": "^3.1.2", "@sentry/types": "^6.10.0", "@sinonjs/fake-timers": "^9.1.2", + "@testing-library/jest-dom": "^5.16.5", + "@testing-library/react": "^12.1.5", + "@testing-library/user-event": "^14.4.3", "@types/classnames": "^2.2.11", "@types/commonmark": "^0.27.4", "@types/counterpart": "^0.18.1", @@ -186,17 +191,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/_common.pcss b/res/css/_common.pcss index e2f4c3f0373..6004b7edd42 100644 --- a/res/css/_common.pcss +++ b/res/css/_common.pcss @@ -454,7 +454,7 @@ legend { } @define-mixin customisedCancelButton { - mask: url('$(res)/img/feather-customised/cancel.svg'); + mask: url('$(res)/img/cancel.svg'); mask-repeat: no-repeat; mask-position: center; mask-size: cover; @@ -467,8 +467,8 @@ legend { .mx_Dialog_cancelButton { @mixin customisedCancelButton; - width: 14px; - height: 14px; + width: 18px; + height: 18px; position: absolute; top: 10px; right: 0; diff --git a/res/css/_components.pcss b/res/css/_components.pcss index e50d6854771..9f8e9024049 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -4,6 +4,7 @@ @import "./_font-sizes.pcss"; @import "./_font-weights.pcss"; @import "./_spacing.pcss"; +@import "./components/atoms/_Icon.pcss"; @import "./components/views/beacon/_BeaconListItem.pcss"; @import "./components/views/beacon/_BeaconStatus.pcss"; @import "./components/views/beacon/_BeaconStatusTooltip.pcss"; @@ -16,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"; @@ -33,11 +35,13 @@ @import "./components/views/settings/devices/_DeviceExpandDetailsButton.pcss"; @import "./components/views/settings/devices/_DeviceSecurityCard.pcss"; @import "./components/views/settings/devices/_DeviceTile.pcss"; -@import "./components/views/settings/devices/_DeviceType.pcss"; +@import "./components/views/settings/devices/_DeviceTypeIcon.pcss"; @import "./components/views/settings/devices/_FilteredDeviceList.pcss"; +@import "./components/views/settings/devices/_FilteredDeviceListHeader.pcss"; @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"; @@ -72,7 +76,6 @@ @import "./structures/_ToastContainer.pcss"; @import "./structures/_UploadBar.pcss"; @import "./structures/_UserMenu.pcss"; -@import "./structures/_VideoRoomView.pcss"; @import "./structures/_ViewSource.pcss"; @import "./structures/auth/_CompleteSecurity.pcss"; @import "./structures/auth/_Login.pcss"; @@ -209,6 +212,7 @@ @import "./views/elements/_Validation.pcss"; @import "./views/emojipicker/_EmojiPicker.pcss"; @import "./views/location/_LocationPicker.pcss"; +@import "./views/messages/_CallEvent.pcss"; @import "./views/messages/_CreateEvent.pcss"; @import "./views/messages/_DateSeparator.pcss"; @import "./views/messages/_DisambiguatedProfile.pcss"; @@ -263,6 +267,7 @@ @import "./views/rooms/_JumpToBottomButton.pcss"; @import "./views/rooms/_LinkPreviewGroup.pcss"; @import "./views/rooms/_LinkPreviewWidget.pcss"; +@import "./views/rooms/_LiveContentSummary.pcss"; @import "./views/rooms/_MemberInfo.pcss"; @import "./views/rooms/_MemberList.pcss"; @import "./views/rooms/_MessageComposer.pcss"; @@ -276,6 +281,7 @@ @import "./views/rooms/_ReplyPreview.pcss"; @import "./views/rooms/_ReplyTile.pcss"; @import "./views/rooms/_RoomBreadcrumbs.pcss"; +@import "./views/rooms/_RoomCallBanner.pcss"; @import "./views/rooms/_RoomHeader.pcss"; @import "./views/rooms/_RoomInfoLine.pcss"; @import "./views/rooms/_RoomList.pcss"; @@ -284,7 +290,6 @@ @import "./views/rooms/_RoomPreviewCard.pcss"; @import "./views/rooms/_RoomSublist.pcss"; @import "./views/rooms/_RoomTile.pcss"; -@import "./views/rooms/_RoomTileCallSummary.pcss"; @import "./views/rooms/_RoomUpgradeWarningBar.pcss"; @import "./views/rooms/_SearchBar.pcss"; @import "./views/rooms/_SendMessageComposer.pcss"; @@ -293,6 +298,8 @@ @import "./views/rooms/_TopUnreadMessagesBar.pcss"; @import "./views/rooms/_VoiceRecordComposerTile.pcss"; @import "./views/rooms/_WhoIsTypingTile.pcss"; +@import "./views/rooms/wysiwyg_composer/_FormattingButtons.pcss"; +@import "./views/rooms/wysiwyg_composer/_WysiwygComposer.pcss"; @import "./views/settings/_AvatarSetting.pcss"; @import "./views/settings/_CrossSigningPanel.pcss"; @import "./views/settings/_CryptographyPanel.pcss"; @@ -335,6 +342,7 @@ @import "./views/spaces/_SpacePublicShare.pcss"; @import "./views/terms/_InlineTermsAgreement.pcss"; @import "./views/toasts/_AnalyticsToast.pcss"; +@import "./views/toasts/_IncomingCallToast.pcss"; @import "./views/toasts/_IncomingLegacyCallToast.pcss"; @import "./views/toasts/_NonUrgentEchoFailureToast.pcss"; @import "./views/typography/_Heading.pcss"; @@ -346,7 +354,8 @@ @import "./views/user-onboarding/_UserOnboardingTask.pcss"; @import "./views/verification/_VerificationShowSas.pcss"; @import "./views/voip/LegacyCallView/_LegacyCallViewButtons.pcss"; -@import "./views/voip/_CallLobby.pcss"; +@import "./views/voip/_CallDuration.pcss"; +@import "./views/voip/_CallView.pcss"; @import "./views/voip/_DialPad.pcss"; @import "./views/voip/_DialPadContextMenu.pcss"; @import "./views/voip/_DialPadModal.pcss"; @@ -357,3 +366,9 @@ @import "./views/voip/_LegacyCallViewSidebar.pcss"; @import "./views/voip/_PiPContainer.pcss"; @import "./views/voip/_VideoFeed.pcss"; +@import "./voice-broadcast/atoms/_LiveBadge.pcss"; +@import "./voice-broadcast/atoms/_PlaybackControlButton.pcss"; +@import "./voice-broadcast/atoms/_VoiceBroadcastHeader.pcss"; +@import "./voice-broadcast/molecules/_VoiceBroadcastPlaybackBody.pcss"; +@import "./voice-broadcast/molecules/_VoiceBroadcastRecordingBody.pcss"; +@import "./voice-broadcast/molecules/_VoiceBroadcastRecordingPip.pcss"; diff --git a/res/css/components/atoms/_Icon.pcss b/res/css/components/atoms/_Icon.pcss new file mode 100644 index 00000000000..b9d994e43f4 --- /dev/null +++ b/res/css/components/atoms/_Icon.pcss @@ -0,0 +1,42 @@ +/* +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_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 { + height: 16px; + width: 16px; +} + +.mx_Icon_accent { + background-color: $accent; +} + +.mx_Icon_live-badge { + background-color: #fff; +} + +.mx_Icon_compound-secondary-content { + background-color: $secondary-content; +} diff --git a/test/.eslintrc.js b/res/css/components/views/context_menus/_KebabContextMenu.pcss similarity index 79% rename from test/.eslintrc.js rename to res/css/components/views/context_menus/_KebabContextMenu.pcss index ee22692130f..1594420aea7 100644 --- a/test/.eslintrc.js +++ b/res/css/components/views/context_menus/_KebabContextMenu.pcss @@ -14,13 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -module.exports = { - env: { - mocha: true, - }, - - // mocha defines a 'this' - rules: { - "@babel/no-invalid-this": "off", - }, -}; +.mx_KebabContextMenu_icon { + width: 24px; + color: $secondary-content; +} diff --git a/res/css/components/views/settings/devices/_DeviceDetails.pcss b/res/css/components/views/settings/devices/_DeviceDetails.pcss index ebb725d28ea..754ee439982 100644 --- a/res/css/components/views/settings/devices/_DeviceDetails.pcss +++ b/res/css/components/views/settings/devices/_DeviceDetails.pcss @@ -46,6 +46,13 @@ limitations under the License. .mx_DeviceDetails_sectionHeading { margin: 0; + + .mx_DeviceDetails_sectionSubheading { + display: block; + font-size: $font-12px; + color: $secondary-content; + line-height: $font-14px; + } } .mx_DeviceDetails_metadataTable { @@ -81,3 +88,10 @@ limitations under the License. align-items: center; gap: $spacing-4; } + +.mx_DeviceDetails_pushNotifications { + display: block; + .mx_ToggleSwitch { + float: right; + } +} diff --git a/res/css/components/views/settings/devices/_DeviceTile.pcss b/res/css/components/views/settings/devices/_DeviceTile.pcss index d89fd9c76eb..18224362c22 100644 --- a/res/css/components/views/settings/devices/_DeviceTile.pcss +++ b/res/css/components/views/settings/devices/_DeviceTile.pcss @@ -21,6 +21,10 @@ limitations under the License. width: 100%; } +.mx_DeviceTile_interactive { + cursor: pointer; +} + .mx_DeviceTile_info { flex: 1 1 0; } diff --git a/res/css/components/views/settings/devices/_DeviceType.pcss b/res/css/components/views/settings/devices/_DeviceTypeIcon.pcss similarity index 86% rename from res/css/components/views/settings/devices/_DeviceType.pcss rename to res/css/components/views/settings/devices/_DeviceTypeIcon.pcss index 66372bbdea0..a092112d8a1 100644 --- a/res/css/components/views/settings/devices/_DeviceType.pcss +++ b/res/css/components/views/settings/devices/_DeviceTypeIcon.pcss @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_DeviceType { +.mx_DeviceTypeIcon { flex: 0 0 auto; position: relative; margin-right: $spacing-8; @@ -22,7 +22,7 @@ limitations under the License. padding: 0 $spacing-8 $spacing-8 0; } -.mx_DeviceType_deviceIcon { +.mx_DeviceTypeIcon_deviceIconWrapper { --background-color: $system; --icon-color: $secondary-content; @@ -36,12 +36,17 @@ limitations under the License. background-color: var(--background-color); } -.mx_DeviceType_selected .mx_DeviceType_deviceIcon { +.mx_DeviceTypeIcon_selected .mx_DeviceTypeIcon_deviceIconWrapper { --background-color: $primary-content; --icon-color: $background; } -.mx_DeviceType_verificationIcon { +.mx_DeviceTypeIcon_deviceIcon { + height: 24px; + width: 24px; +} + +.mx_DeviceTypeIcon_verificationIcon { position: absolute; bottom: 0; right: 0; diff --git a/res/css/components/views/settings/devices/_FilteredDeviceList.pcss b/res/css/components/views/settings/devices/_FilteredDeviceList.pcss index 01c8df787ef..d34270e8ad8 100644 --- a/res/css/components/views/settings/devices/_FilteredDeviceList.pcss +++ b/res/css/components/views/settings/devices/_FilteredDeviceList.pcss @@ -20,32 +20,12 @@ limitations under the License. } } -.mx_FilteredDeviceList_header { - display: flex; - flex-direction: row; - align-items: center; - box-sizing: border-box; - - width: 100%; - height: 48px; - padding: 0 $spacing-16; - margin-bottom: $spacing-32; - - background-color: $system; - border-radius: 8px; - color: $secondary-content; -} - -.mx_FilteredDeviceList_headerLabel { - flex: 1 1 100%; -} - .mx_FilteredDeviceList_list { list-style-type: none; display: grid; grid-gap: $spacing-16; margin: 0; - padding: 0 $spacing-8; + padding: 0 $spacing-16; } .mx_FilteredDeviceList_listItem { @@ -62,3 +42,11 @@ limitations under the License. text-align: center; margin-bottom: $spacing-32; } + +.mx_FilteredDeviceList_headerButton { + flex-shrink: 0; + // override inline button styling + display: flex !important; + flex-direction: row; + gap: $spacing-8; +} diff --git a/res/css/structures/_VideoRoomView.pcss b/res/css/components/views/settings/devices/_FilteredDeviceListHeader.pcss similarity index 56% rename from res/css/structures/_VideoRoomView.pcss rename to res/css/components/views/settings/devices/_FilteredDeviceListHeader.pcss index 6d758820bf7..3bba9d90b35 100644 --- a/res/css/structures/_VideoRoomView.pcss +++ b/res/css/components/views/settings/devices/_FilteredDeviceListHeader.pcss @@ -14,27 +14,23 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_VideoRoomView { - flex-grow: 1; - min-height: 0; - +.mx_FilteredDeviceListHeader { display: flex; - flex-direction: column; - margin: $container-gap-width; - margin-right: calc($container-gap-width / 2); + flex-direction: row; + align-items: center; + box-sizing: border-box; + gap: $spacing-16; - background-color: $header-panel-bg-color; - padding: 8px; - border-radius: 8px; + width: 100%; + height: 48px; + padding: 0 $spacing-16; + margin-bottom: $spacing-32; - .mx_AppTile { - width: auto; - height: 100%; - border: none; - } + background-color: $system; + border-radius: 8px; + color: $secondary-content; +} - /* While the lobby is shown, the widget needs to stay loaded but hidden in the background */ - .mx_CallLobby ~ .mx_AppTile { - display: none; - } +.mx_FilteredDeviceListHeader_label { + flex: 1 1 100%; } diff --git a/res/css/components/views/settings/devices/_SelectableDeviceTile.pcss b/res/css/components/views/settings/devices/_SelectableDeviceTile.pcss index 5d6a497e02c..aa0cf91a9cb 100644 --- a/res/css/components/views/settings/devices/_SelectableDeviceTile.pcss +++ b/res/css/components/views/settings/devices/_SelectableDeviceTile.pcss @@ -19,7 +19,6 @@ limitations under the License. flex-direction: row; align-items: center; width: 100%; - cursor: pointer; } .mx_SelectableDeviceTile_checkbox { diff --git a/res/css/components/views/settings/shared/_SettingsSubsection.pcss b/res/css/components/views/settings/shared/_SettingsSubsection.pcss index 9eb51696bab..2ba909aac1a 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/src/models/IUpload.ts b/res/css/components/views/settings/shared/_SettingsSubsectionHeading.pcss similarity index 58% rename from src/models/IUpload.ts rename to res/css/components/views/settings/shared/_SettingsSubsectionHeading.pcss index 715a71037f0..e6d4bf4be7c 100644 --- a/src/models/IUpload.ts +++ b/res/css/components/views/settings/shared/_SettingsSubsectionHeading.pcss @@ -1,5 +1,5 @@ /* -Copyright 2021 The Matrix.org Foundation C.I.C. +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. @@ -14,15 +14,14 @@ 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"; +.mx_SettingsSubsectionHeading { + display: flex; + flex-direction: row; + padding-bottom: $spacing-8; -export interface IUpload { - fileName: string; - roomId: string; - relation?: IEventRelation; - total: number; - loaded: number; - promise: IAbortablePromise; - canceled?: boolean; + gap: $spacing-8; +} + +.mx_SettingsSubsectionHeading_heading { + flex: 1 1 100%; } diff --git a/res/css/structures/_HeaderButtons.pcss b/res/css/structures/_HeaderButtons.pcss index 96f6f2e9f92..4a3de483762 100644 --- a/res/css/structures/_HeaderButtons.pcss +++ b/res/css/structures/_HeaderButtons.pcss @@ -17,20 +17,3 @@ limitations under the License. .mx_HeaderButtons { display: flex; } - -.mx_RoomHeader_buttons + .mx_HeaderButtons { - /* remove the | separator line for when next to RoomHeaderButtons */ - /* TODO: remove this once when we redo communities and make the right panel similar to the new rooms one */ - &::before { - content: unset; - } -} - -.mx_HeaderButtons::before { - content: ""; - background-color: $header-panel-text-primary-color; - opacity: 0.5; - margin: 6px 8px; - border-radius: 1px; - width: 1px; -} diff --git a/res/css/views/context_menus/_IconizedContextMenu.pcss b/res/css/views/context_menus/_IconizedContextMenu.pcss index c8ee42641d2..1de0219cb6d 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/views/dialogs/_RoomSettingsDialog.pcss b/res/css/views/dialogs/_RoomSettingsDialog.pcss index a242a99596b..8631ec5d7d5 100644 --- a/res/css/views/dialogs/_RoomSettingsDialog.pcss +++ b/res/css/views/dialogs/_RoomSettingsDialog.pcss @@ -21,6 +21,10 @@ limitations under the License. mask-image: url('$(res)/img/element-icons/settings.svg'); } +.mx_RoomSettingsDialog_voiceIcon::before { + mask-image: url('$(res)/img/element-icons/call/voice-call.svg'); +} + .mx_RoomSettingsDialog_securityIcon::before { mask-image: url('$(res)/img/element-icons/security.svg'); } diff --git a/res/css/views/elements/_AccessibleButton.pcss b/res/css/views/elements/_AccessibleButton.pcss index af516b6bac4..0c2f011e8c0 100644 --- a/res/css/views/elements/_AccessibleButton.pcss +++ b/res/css/views/elements/_AccessibleButton.pcss @@ -25,6 +25,8 @@ limitations under the License. &.mx_AccessibleButton_kind_primary_sm, &.mx_AccessibleButton_kind_link, &.mx_AccessibleButton_kind_link_inline, + &.mx_AccessibleButton_kind_danger_inline, + &.mx_AccessibleButton_kind_content_inline, &.mx_AccessibleButton_kind_link_sm { opacity: 0.4; } @@ -139,7 +141,8 @@ limitations under the License. &.mx_AccessibleButton_kind_link, &.mx_AccessibleButton_kind_link_inline, - &.mx_AccessibleButton_kind_danger_inline { + &.mx_AccessibleButton_kind_danger_inline, + &.mx_AccessibleButton_kind_content_inline { font-size: inherit; font-weight: normal; line-height: inherit; @@ -155,8 +158,13 @@ limitations under the License. color: $alert; } + &.mx_AccessibleButton_kind_content_inline { + color: $primary-content; + } + &.mx_AccessibleButton_kind_link_inline, - &.mx_AccessibleButton_kind_danger_inline { + &.mx_AccessibleButton_kind_danger_inline, + &.mx_AccessibleButton_kind_content_inline { display: inline; } diff --git a/res/css/views/elements/_FacePile.pcss b/res/css/views/elements/_FacePile.pcss index dd23eb37e4e..7f890a0bb73 100644 --- a/res/css/views/elements/_FacePile.pcss +++ b/res/css/views/elements/_FacePile.pcss @@ -22,6 +22,7 @@ limitations under the License. display: inline-flex; flex-direction: row-reverse; vertical-align: middle; + margin: 0 -1px; /* to cancel out the border on the edges */ /* Overlap the children */ > * + * { diff --git a/res/css/views/elements/_ToggleSwitch.pcss b/res/css/views/elements/_ToggleSwitch.pcss index 09c04f2c08e..c4ad3e0a28c 100644 --- a/res/css/views/elements/_ToggleSwitch.pcss +++ b/res/css/views/elements/_ToggleSwitch.pcss @@ -26,6 +26,10 @@ limitations under the License. background-color: $togglesw-off-color; opacity: 0.5; + + &[aria-disabled="true"] { + cursor: not-allowed; + } } .mx_ToggleSwitch_enabled { diff --git a/res/css/views/elements/_Tooltip.pcss b/res/css/views/elements/_Tooltip.pcss index f11ca82083c..edf7af96807 100644 --- a/res/css/views/elements/_Tooltip.pcss +++ b/res/css/views/elements/_Tooltip.pcss @@ -76,11 +76,6 @@ limitations under the License. border: 0; text-align: center; - &:not(.mx_Tooltip_noMargin) { - margin-left: 6px; - margin-right: 6px; - } - .mx_Tooltip_chevron { display: none; } diff --git a/res/css/views/messages/_CallEvent.pcss b/res/css/views/messages/_CallEvent.pcss new file mode 100644 index 00000000000..1a11beaa4d4 --- /dev/null +++ b/res/css/views/messages/_CallEvent.pcss @@ -0,0 +1,77 @@ +/* +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_CallEvent_wrapper { + display: flex; + width: 100%; +} + +.mx_CallEvent { + padding: 12px; + box-sizing: border-box; + min-height: 60px; + max-width: 600px; + width: 100%; + background-color: $system; + border-radius: 8px; + + display: flex; + align-items: center; + justify-content: space-between; + gap: $spacing-8; + + .mx_CallEvent_title { + font-size: $font-15px; + line-height: 24px; /* in px to match the avatar */ + } + + &.mx_CallEvent_inactive .mx_CallEvent_title::before { + display: inline-block; + vertical-align: middle; + content: ''; + background-color: $secondary-content; + mask-image: url('$(res)/img/element-icons/call/video-call.svg'); + mask-size: 16px; + width: 16px; + height: 16px; + margin-right: 8px; + } + + &.mx_CallEvent_active .mx_CallEvent_title { + font-weight: 600; + } + + > .mx_BaseAvatar { + align-self: flex-start; + } + + > .mx_CallEvent_infoRows { + flex-grow: 1; + + display: flex; + flex-direction: column; + gap: $spacing-4; + } + + > .mx_CallDuration { + padding: $spacing-4; + } + + > .mx_CallEvent_button { + box-sizing: border-box; + min-width: 120px; + } +} diff --git a/res/css/views/rooms/_EventBubbleTile.pcss b/res/css/views/rooms/_EventBubbleTile.pcss index 88368a4bea5..ca9ec513f87 100644 --- a/res/css/views/rooms/_EventBubbleTile.pcss +++ b/res/css/views/rooms/_EventBubbleTile.pcss @@ -523,7 +523,8 @@ limitations under the License. max-width: 100%; } - .mx_LegacyCallEvent_wrapper { + .mx_LegacyCallEvent_wrapper, + .mx_CallEvent_wrapper { justify-content: center; } } diff --git a/res/css/views/rooms/_JumpToBottomButton.pcss b/res/css/views/rooms/_JumpToBottomButton.pcss index 5a3d22540ef..06380bba4be 100644 --- a/res/css/views/rooms/_JumpToBottomButton.pcss +++ b/res/css/views/rooms/_JumpToBottomButton.pcss @@ -70,8 +70,10 @@ limitations under the License. bottom: 0; left: 0; right: 0; - mask-image: url('$(res)/img/feather-customised/chevron-down.svg'); + mask-image: url('$(res)/img/element-icons/message/chevron-up.svg'); mask-repeat: no-repeat; - mask-size: contain; + mask-size: 20px; + mask-position: center 7px; + transform: rotate(180deg); background: $button-fg-color; } diff --git a/res/css/views/rooms/_RoomTileCallSummary.pcss b/res/css/views/rooms/_LiveContentSummary.pcss similarity index 84% rename from res/css/views/rooms/_RoomTileCallSummary.pcss rename to res/css/views/rooms/_LiveContentSummary.pcss index 9c5e99c5ecb..c56026a829b 100644 --- a/res/css/views/rooms/_RoomTileCallSummary.pcss +++ b/res/css/views/rooms/_LiveContentSummary.pcss @@ -14,21 +14,26 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_RoomTileCallSummary { - .mx_RoomTileCallSummary_text { +.mx_LiveContentSummary { + color: $secondary-content; + + .mx_LiveContentSummary_text { &::before { display: inline-block; vertical-align: text-bottom; content: ''; background-color: $secondary-content; - mask-image: url('$(res)/img/element-icons/call/video-call.svg'); mask-size: 16px; width: 16px; height: 16px; margin-right: 4px; } - &.mx_RoomTileCallSummary_text_active { + &.mx_LiveContentSummary_text_video::before { + mask-image: url('$(res)/img/element-icons/call/video-call.svg'); + } + + &.mx_LiveContentSummary_text_active { color: $accent; &::before { @@ -37,7 +42,7 @@ limitations under the License. } } - .mx_RoomTileCallSummary_participants::before { + .mx_LiveContentSummary_participants::before { display: inline-block; vertical-align: text-bottom; content: ''; diff --git a/res/css/views/rooms/_MessageComposer.pcss b/res/css/views/rooms/_MessageComposer.pcss index 27f1c6556de..ca36e2275a9 100644 --- a/res/css/views/rooms/_MessageComposer.pcss +++ b/res/css/views/rooms/_MessageComposer.pcss @@ -251,6 +251,17 @@ limitations under the License. } } +/* + The wysisyg composer increase the size of the MessageComposer. We temporary move the buttons + Soon the dom structure of the MessageComposer will change with the next evolution of the wysiwyg composer + and this workaround will disappear +*/ +.mx_MessageComposer_wysiwyg { + .mx_MessageComposer_e2eIcon.mx_E2EIcon,.mx_MessageComposer_button, .mx_MessageComposer_sendMessage { + margin-top: 22px; + } +} + .mx_MessageComposer_upload::before { mask-image: url('$(res)/img/element-icons/room/composer/attach.svg'); } diff --git a/res/css/views/rooms/_RoomCallBanner.pcss b/res/css/views/rooms/_RoomCallBanner.pcss new file mode 100644 index 00000000000..4b05b72d91d --- /dev/null +++ b/res/css/views/rooms/_RoomCallBanner.pcss @@ -0,0 +1,54 @@ +/* +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_RoomCallBanner { + width: 100%; + display: flex; + flex-direction: row; + align-items: center; + + box-sizing: border-box; + padding: $spacing-12 $spacing-16; + + color: $primary-content; + background-color: $system; + cursor: pointer; +} + +.mx_RoomCallBanner_text { + display: flex; + flex: 1; + align-items: center; +} + +.mx_RoomCallBanner_label { + color: $primary-content; + font-weight: 600; + padding-right: $spacing-8; + + &::before { + display: inline-block; + vertical-align: text-top; + content: ""; + background-color: $secondary-content; + mask-size: 16px; + width: 16px; + height: 16px; + margin-right: 4px; + bottom: 2px; + mask-image: url("$(res)/img/element-icons/call/video-call.svg"); + } +} diff --git a/res/css/views/rooms/_RoomHeader.pcss b/res/css/views/rooms/_RoomHeader.pcss index efc39637a5f..9780329e65e 100644 --- a/res/css/views/rooms/_RoomHeader.pcss +++ b/res/css/views/rooms/_RoomHeader.pcss @@ -19,17 +19,28 @@ limitations under the License. border-bottom: 1px solid $primary-hairline-color; background-color: $background; - .mx_RoomHeader_e2eIcon { + .mx_RoomHeader_icon { height: 1.2rem; width: 1.2rem; - .mx_E2EIcon { + &.mx_RoomHeader_icon_video { + height: 14px; + width: 14px; + background-color: $secondary-content; + mask-image: url('$(res)/img/element-icons/call/video-call.svg'); + mask-size: 100%; + } + + &.mx_E2EIcon { margin: 0; - position: absolute; - height: 1.2rem; - width: 1.2rem; + height: 100%; /* To give the tooltip room to breathe */ } } + + .mx_CallDuration { + margin-top: calc(($font-15px - $font-13px) / 2); /* To align with the name */ + font-size: $font-13px; + } } .mx_RoomHeader_wrapper { @@ -38,7 +49,8 @@ limitations under the License. align-items: center; min-width: 0; margin: 0 20px 0 19px; - padding-top: 8px; + padding-top: 6px; + padding-bottom: 2px; border-bottom: 1px solid $system; .mx_InviteOnlyIcon_large { @@ -77,11 +89,6 @@ limitations under the License. padding-right: 12px; } -.mx_RoomHeader_buttons { - display: flex; - background-color: $background; -} - .mx_RoomHeader_info { display: flex; flex: 1; @@ -94,6 +101,8 @@ limitations under the License. color: $primary-content; font-weight: $font-semi-bold; font-size: $font-18px; + min-height: 24px; + align-items: center; border-radius: $border-radius-6px; margin: 0 3px; padding: 1px 4px; @@ -112,10 +121,10 @@ limitations under the License. .mx_RoomHeader_chevron { align-self: center; - width: 16px; - height: 16px; + width: 20px; + height: 20px; mask-position: center; - mask-size: contain; + mask-size: 20px; mask-repeat: no-repeat; mask-image: url('$(res)/img/feather-customised/chevron-down.svg'); background-color: $tertiary-content; @@ -178,7 +187,7 @@ limitations under the License. .mx_RoomHeader_avatar { flex: 0; - margin: 0 6px 0 7px; + margin: 0 7px; position: relative; // SC: downwards ... @@ -212,7 +221,7 @@ limitations under the License. mask-size: contain; } - &:hover { + &:not(.mx_RoomHeader_closeButton):hover { background: rgba($accent, 0.1); &::before { @@ -255,6 +264,37 @@ limitations under the License. mask-image: url('$(res)/img/element-icons/call/video-call.svg'); } +.mx_RoomHeader_layoutButton--freedom::before, +.mx_RoomHeader_freedomIcon::before { + mask-image: url('$(res)/img/element-icons/call/freedom.svg'); +} + +.mx_RoomHeader_layoutButton--spotlight::before, +.mx_RoomHeader_spotlightIcon::before { + mask-image: url('$(res)/img/element-icons/call/spotlight.svg'); +} + +.mx_RoomHeader_closeButton::before { + mask-image: url('$(res)/img/cancel.svg'); + mask-size: 20px; + mask-position: center; +} + +.mx_RoomHeader_minimiseButton::before { + mask-image: url('$(res)/img/element-icons/reduce.svg'); +} + +.mx_RoomHeader_layoutMenu .mx_IconizedContextMenu_icon::before { + content: ''; + width: 16px; + height: 16px; + display: block; + mask-position: center; + mask-size: 20px; + mask-repeat: no-repeat; + background: $primary-content; +} + @media only screen and (max-width: 480px) { .mx_RoomHeader_wrapper { padding: 0; diff --git a/res/css/views/rooms/_TopUnreadMessagesBar.pcss b/res/css/views/rooms/_TopUnreadMessagesBar.pcss index 6e119dd4df9..86ae7768750 100644 --- a/res/css/views/rooms/_TopUnreadMessagesBar.pcss +++ b/res/css/views/rooms/_TopUnreadMessagesBar.pcss @@ -52,11 +52,11 @@ limitations under the License. position: absolute; width: 36px; height: 36px; - mask-image: url('$(res)/img/feather-customised/chevron-down.svg'); + mask-image: url('$(res)/img/element-icons/message/chevron-up.svg'); mask-repeat: no-repeat; - mask-size: contain; + mask-size: 20px; + mask-position: center 7px; background: $button-fg-color; - transform: rotate(180deg); } .mx_TopUnreadMessagesBar_markAsRead { diff --git a/res/css/views/rooms/wysiwyg_composer/_FormattingButtons.pcss b/res/css/views/rooms/wysiwyg_composer/_FormattingButtons.pcss new file mode 100644 index 00000000000..36f84ae5f1f --- /dev/null +++ b/res/css/views/rooms/wysiwyg_composer/_FormattingButtons.pcss @@ -0,0 +1,118 @@ +/* +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_FormattingButtons { + display: flex; + justify-content: start; + + .mx_FormattingButtons_Button { + --size: 28px; + position: relative; + cursor: pointer; + height: var(--size); + line-height: var(--size); + width: auto; + padding-left: 22px; + margin-right: 8px; + background-color: transparent; + border: none; + + &:first-child { + margin-left: 12px; + } + + &:last-child { + margin-right: auto; + } + + &::before { + content: ''; + position: absolute; + top: 6px; + left: 6px; + height: 16px; + width: 16px; + background-color: $icon-button-color; + mask-repeat: no-repeat; + mask-size: contain; + mask-position: center; + } + + &::after { + content: ''; + position: absolute; + left: 0; + top: 0; + z-index: 0; + width: var(--size); + height: var(--size); + border-radius: 5px; + } + + &:hover { + &::after { + background: rgba($secondary-content, 0.1); + } + + &::before { + background-color: $secondary-content; + } + } + } + + .mx_FormattingButtons_active { + &::after { + background: rgba($accent, 0.1); + } + + &::before { + background-color: $accent; + } + } + + .mx_FormattingButtons_Button_bold::before { + mask-image: url('$(res)/img/element-icons/room/composer/bold.svg'); + } + + .mx_FormattingButtons_Button_italic::before { + mask-image: url('$(res)/img/element-icons/room/composer/italic.svg'); + } + + .mx_FormattingButtons_Button_underline::before { + mask-image: url('$(res)/img/element-icons/room/composer/underline.svg'); + } + + .mx_FormattingButtons_Button_strikethrough::before { + mask-image: url('$(res)/img/element-icons/room/composer/strikethrough.svg'); + } +} + +.mx_FormattingButtons_Tooltip { + padding: 0 2px 0 2px; + + .mx_FormattingButtons_Tooltip_KeyboardShortcut { + color: $tertiary-content; + + kbd { + margin-top: 2px; + text-align: center; + display: inline-block; + text-transform: capitalize; + font-size: 12px; + font-family: Inter, sans-serif; + } + } +} diff --git a/res/css/views/rooms/wysiwyg_composer/_WysiwygComposer.pcss b/res/css/views/rooms/wysiwyg_composer/_WysiwygComposer.pcss new file mode 100644 index 00000000000..133b66388e7 --- /dev/null +++ b/res/css/views/rooms/wysiwyg_composer/_WysiwygComposer.pcss @@ -0,0 +1,53 @@ +/* +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_WysiwygComposer { + flex: 1; + display: flex; + flex-direction: column; + font-size: $font-14px; + /* fixed line height to prevent emoji from being taller than text */ + line-height: $font-18px; + justify-content: center; + margin-right: 6px; + /* don't grow wider than available space */ + min-width: 0; + + .mx_WysiwygComposer_container { + flex: 1; + display: flex; + flex-direction: column; + /* min-height at this level so the mx_BasicMessageComposer_input */ + /* still stays vertically centered when less than 55px. */ + /* We also set this to ensure the voice message recording widget */ + /* doesn't cause a jump. */ + min-height: 55px; + + .mx_WysiwygComposer_content { + border: 1px solid; + border-radius: 20px; + padding: 8px 10px; + /* this will center the contenteditable */ + /* in it's parent vertically */ + /* while keeping the autocomplete at the top */ + /* of the composer. The parent needs to be a flex container for this to work. */ + margin: auto 0; + /* max-height at this level so autocomplete doesn't get scrolled too */ + max-height: 140px; + overflow-y: auto; + } + } +} diff --git a/res/css/views/settings/_DevicesPanel.pcss b/res/css/views/settings/_DevicesPanel.pcss index 23a737c9779..8a7842d4d0f 100644 --- a/res/css/views/settings/_DevicesPanel.pcss +++ b/res/css/views/settings/_DevicesPanel.pcss @@ -58,7 +58,7 @@ limitations under the License. min-height: 35px; padding: 0 $spacing-8; - .mx_DeviceType { + .mx_DeviceTypeIcon { /* hide the new device type in legacy device list for backwards compat reasons */ display: none; diff --git a/res/css/views/toasts/_IncomingCallToast.pcss b/res/css/views/toasts/_IncomingCallToast.pcss new file mode 100644 index 00000000000..f2d074f94b5 --- /dev/null +++ b/res/css/views/toasts/_IncomingCallToast.pcss @@ -0,0 +1,110 @@ +/* +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_IncomingCallToast { + position: relative; + display: flex; + flex-direction: row; + pointer-events: initial; /* restore pointer events so the user can accept/decline */ + width: 250px; + + $closeButtonSize: 16px; + + .mx_IncomingCallToast_content { + display: flex; + flex-direction: column; + margin-left: 8px; + width: 100%; + overflow: hidden; + + .mx_IncomingCallToast_info { + margin-bottom: $spacing-16; + + .mx_IncomingCallToast_room { + display: inline-block; + + font-weight: $font-semi-bold; + font-size: $font-15px; + line-height: $font-24px; + + /* Prevent overlap with the close button */ + width: calc(100% - $closeButtonSize - 2 * $spacing-4); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + + margin-bottom: $spacing-4; + } + + .mx_IncomingCallToast_message { + font-size: $font-12px; + line-height: $font-15px; + + margin-bottom: $spacing-4; + } + + .mx_LiveContentSummary { + font-size: $font-12px; + line-height: $font-15px; + + .mx_LiveContentSummary_participants::before { + width: 15px; + height: 15px; + } + } + } + + .mx_IncomingCallToast_joinButton { + position: relative; + + bottom: $spacing-4; + right: $spacing-4; + + align-self: flex-end; + + box-sizing: border-box; + min-width: 120px; + + padding: $spacing-4 0; + + line-height: $font-24px; + } + } + + .mx_IncomingCallToast_closeButton { + position: absolute; + + top: $spacing-4; + right: $spacing-4; + + display: flex; + height: $closeButtonSize; + width: $closeButtonSize; + + &::before { + content: ''; + + mask-image: url('$(res)/img/cancel.svg'); + + height: inherit; + width: inherit; + background-color: $secondary-content; + mask-repeat: no-repeat; + mask-size: contain; + mask-position: center; + } + } +} diff --git a/res/css/views/voip/_CallDuration.pcss b/res/css/views/voip/_CallDuration.pcss new file mode 100644 index 00000000000..c8dc07ef671 --- /dev/null +++ b/res/css/views/voip/_CallDuration.pcss @@ -0,0 +1,20 @@ +/* +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_CallDuration { + color: $secondary-content; + font-size: $font-12px; +} diff --git a/res/css/views/voip/_CallLobby.pcss b/res/css/views/voip/_CallLobby.pcss deleted file mode 100644 index 306ed8962b5..00000000000 --- a/res/css/views/voip/_CallLobby.pcss +++ /dev/null @@ -1,174 +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. -*/ - -.mx_CallLobby { - min-height: 0; - flex-grow: 1; - padding: $spacing-12; - color: $call-lobby-primary-content; - background-color: $call-lobby-background; - border-radius: 8px; - - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: $spacing-32; - - .mx_FacePile { - width: fit-content; - margin: $spacing-8 auto 0; - - .mx_FacePile_faces .mx_BaseAvatar_image { - border-color: $call-lobby-background; - } - } - - .mx_CallLobby_preview { - position: relative; - width: 100%; - max-width: 800px; - aspect-ratio: 1.5; - background-color: $call-lobby-system; - - border-radius: 20px; - overflow: hidden; - - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; - - .mx_BaseAvatar { - margin: $spacing-20; - - /* Override the explicit dimensions on the element so that this gets sized responsively */ - width: unset !important; - height: unset !important; - min-width: 0; - min-height: 0; - flex: 0 1 200px; - } - - video { - position: absolute; - top: 0; - width: 100%; - height: 100%; - object-fit: cover; - display: block; - transform: scaleX(-1); /* flip the image */ - background-color: black; - } - - .mx_CallLobby_controls { - position: absolute; - bottom: 0; - left: 0; - right: 0; - - background-color: rgba($call-lobby-background, 0.9); - - display: flex; - justify-content: center; - gap: $spacing-24; - - .mx_CallLobby_deviceButtonWrapper { - position: relative; - margin: 6px 0 10px; - - .mx_CallLobby_deviceButton { - $size: 50px; - - width: $size; - height: $size; - - background-color: $call-lobby-system; - border-radius: calc($size / 2); - - &::before { - content: ''; - display: inline-block; - mask-repeat: no-repeat; - mask-size: 20px; - mask-position: center; - background-color: $call-lobby-primary-content; - height: 100%; - width: 100%; - } - - &.mx_CallLobby_deviceButton_audio::before { - mask-image: url('$(res)/img/voip/call-view/mic-on.svg'); - } - - &.mx_CallLobby_deviceButton_video::before { - mask-image: url('$(res)/img/voip/call-view/cam-on.svg'); - } - } - - .mx_CallLobby_deviceListButton { - $size: 15px; - - position: absolute; - bottom: 0; - right: -2.5px; - width: $size; - height: $size; - - background-color: $call-lobby-system; - border-radius: calc($size / 2); - - &::before { - content: ''; - display: inline-block; - mask-image: url('$(res)/img/feather-customised/chevron-down.svg'); - mask-size: $size; - mask-position: center; - background-color: $call-lobby-primary-content; - height: 100%; - width: 100%; - } - } - - &.mx_CallLobby_deviceButtonWrapper_muted { - .mx_CallLobby_deviceButton, - .mx_CallLobby_deviceListButton { - background-color: $call-lobby-primary-content; - - &::before { - background-color: $call-lobby-system; - } - } - - .mx_CallLobby_deviceButton { - &.mx_CallLobby_deviceButton_audio::before { - mask-image: url('$(res)/img/voip/call-view/mic-off.svg'); - } - - &.mx_CallLobby_deviceButton_video::before { - mask-image: url('$(res)/img/voip/call-view/cam-off.svg'); - } - } - } - } - } - } - - .mx_CallLobby_connectButton { - padding-left: 50px; - padding-right: 50px; - } -} diff --git a/res/css/views/voip/_CallView.pcss b/res/css/views/voip/_CallView.pcss new file mode 100644 index 00000000000..0e75ae7aeac --- /dev/null +++ b/res/css/views/voip/_CallView.pcss @@ -0,0 +1,199 @@ +/* +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_CallView { + flex-grow: 1; + min-height: 0; + + display: flex; + flex-direction: column; + margin: $container-gap-width; + margin-right: calc($container-gap-width / 2); + + background-color: $header-panel-bg-color; + padding: 8px; + border-radius: 8px; + + .mx_AppTile { + width: auto; + height: 100%; + border: none; + } + + /* While the lobby is shown, the widget needs to stay loaded but hidden in the background */ + .mx_CallView_lobby ~ .mx_AppTile { + display: none; + } + + .mx_CallView_lobby { + min-height: 0; + flex-grow: 1; + padding: $spacing-12; + color: $call-lobby-primary-content; + background-color: $call-lobby-background; + border-radius: 8px; + + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: $spacing-32; + + .mx_FacePile { + width: fit-content; + margin: $spacing-8 auto 0; + + .mx_FacePile_faces .mx_BaseAvatar_image { + border-color: $call-lobby-background; + } + } + + .mx_CallView_preview { + position: relative; + width: 100%; + max-width: 800px; + aspect-ratio: 1.5; + background-color: $call-lobby-system; + + border-radius: 20px; + overflow: hidden; + + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + + .mx_BaseAvatar { + margin: $spacing-20; + + /* Override the explicit dimensions on the element so that this gets sized responsively */ + width: unset !important; + height: unset !important; + min-width: 0; + min-height: 0; + flex: 0 1 200px; + } + + video { + position: absolute; + top: 0; + width: 100%; + height: 100%; + object-fit: cover; + display: block; + transform: scaleX(-1); /* flip the image */ + background-color: black; + } + + .mx_CallView_controls { + position: absolute; + bottom: 0; + left: 0; + right: 0; + + background-color: rgba($call-lobby-background, 0.9); + + display: flex; + justify-content: center; + gap: $spacing-24; + + .mx_CallView_deviceButtonWrapper { + position: relative; + margin: 6px 0 10px; + + .mx_CallView_deviceButton { + $size: 50px; + + width: $size; + height: $size; + + background-color: $call-lobby-system; + border-radius: calc($size / 2); + + &::before { + content: ''; + display: inline-block; + mask-repeat: no-repeat; + mask-size: 20px; + mask-position: center; + background-color: $call-lobby-primary-content; + height: 100%; + width: 100%; + } + + &.mx_CallView_deviceButton_audio::before { + mask-image: url('$(res)/img/voip/call-view/mic-on.svg'); + } + + &.mx_CallView_deviceButton_video::before { + mask-image: url('$(res)/img/voip/call-view/cam-on.svg'); + } + } + + .mx_CallView_deviceListButton { + $size: 15px; + + position: absolute; + bottom: 0; + right: -2.5px; + width: $size; + height: $size; + + background-color: $call-lobby-system; + border-radius: calc($size / 2); + + &::before { + content: ''; + display: inline-block; + mask-image: url('$(res)/img/feather-customised/chevron-down.svg'); + mask-size: $size; + mask-position: center; + background-color: $call-lobby-primary-content; + height: 100%; + width: 100%; + } + } + + &.mx_CallView_deviceButtonWrapper_muted { + .mx_CallView_deviceButton, + .mx_CallView_deviceListButton { + background-color: $call-lobby-primary-content; + + &::before { + background-color: $call-lobby-system; + } + } + + .mx_CallView_deviceButton { + &.mx_CallView_deviceButton_audio::before { + mask-image: url('$(res)/img/voip/call-view/mic-off.svg'); + } + + &.mx_CallView_deviceButton_video::before { + mask-image: url('$(res)/img/voip/call-view/cam-off.svg'); + } + } + } + } + } + } + + .mx_CallView_connectButton { + padding-left: 50px; + padding-right: 50px; + } + } +} diff --git a/res/css/views/voip/_LegacyCallViewHeader.pcss b/res/css/views/voip/_LegacyCallViewHeader.pcss index 3d8d4d2fd92..9849cd1430e 100644 --- a/res/css/views/voip/_LegacyCallViewHeader.pcss +++ b/res/css/views/voip/_LegacyCallViewHeader.pcss @@ -25,7 +25,7 @@ limitations under the License. width: 100%; &.mx_LegacyCallViewHeader_pip { - cursor: pointer; + cursor: grab; } } diff --git a/res/css/voice-broadcast/atoms/_LiveBadge.pcss b/res/css/voice-broadcast/atoms/_LiveBadge.pcss new file mode 100644 index 00000000000..6da1f041a17 --- /dev/null +++ b/res/css/voice-broadcast/atoms/_LiveBadge.pcss @@ -0,0 +1,27 @@ +/* +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_LiveBadge { + align-items: center; + background-color: $alert; + border-radius: 2px; + color: $live-badge-color; + display: flex; + font-size: $font-12px; + font-weight: $font-semi-bold; + gap: $spacing-4; + padding: 2px 4px; +} diff --git a/res/css/voice-broadcast/atoms/_PlaybackControlButton.pcss b/res/css/voice-broadcast/atoms/_PlaybackControlButton.pcss new file mode 100644 index 00000000000..fd2c3ad73c5 --- /dev/null +++ b/res/css/voice-broadcast/atoms/_PlaybackControlButton.pcss @@ -0,0 +1,26 @@ +/* +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_BroadcastPlaybackControlButton { + align-items: center; + background-color: $background; + border-radius: 50%; + display: flex; + height: 32px; + justify-content: center; + margin-bottom: $spacing-8; + width: 32px; +} diff --git a/res/css/voice-broadcast/atoms/_VoiceBroadcastHeader.pcss b/res/css/voice-broadcast/atoms/_VoiceBroadcastHeader.pcss new file mode 100644 index 00000000000..0e2395cacb8 --- /dev/null +++ b/res/css/voice-broadcast/atoms/_VoiceBroadcastHeader.pcss @@ -0,0 +1,52 @@ +/* +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-16; + width: 266px; +} + +.mx_VoiceBroadcastHeader_content { + flex-grow: 1; + min-width: 0; +} + +.mx_VoiceBroadcastHeader_room { + font-size: $font-12px; + font-weight: $font-semi-bold; + 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; + + i { + flex-shrink: 0; + } + + span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} diff --git a/res/css/voice-broadcast/molecules/_VoiceBroadcastPlaybackBody.pcss b/res/css/voice-broadcast/molecules/_VoiceBroadcastPlaybackBody.pcss new file mode 100644 index 00000000000..11921e1f950 --- /dev/null +++ b/res/css/voice-broadcast/molecules/_VoiceBroadcastPlaybackBody.pcss @@ -0,0 +1,27 @@ +/* +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_VoiceBroadcastPlaybackBody { + background-color: $quinary-content; + border-radius: 8px; + display: inline-block; + padding: 12px; +} + +.mx_VoiceBroadcastPlaybackBody_controls { + display: flex; + justify-content: center; +} diff --git a/res/css/voice-broadcast/molecules/_VoiceBroadcastRecordingBody.pcss b/res/css/voice-broadcast/molecules/_VoiceBroadcastRecordingBody.pcss new file mode 100644 index 00000000000..13e3104c9ac --- /dev/null +++ b/res/css/voice-broadcast/molecules/_VoiceBroadcastRecordingBody.pcss @@ -0,0 +1,29 @@ +/* +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_VoiceBroadcastRecordingBody { + align-items: flex-start; + background-color: $quinary-content; + border-radius: 8px; + display: inline-flex; + gap: $spacing-8; + padding: 12px; +} + +.mx_VoiceBroadcastRecordingBody_title { + font-size: $font-12px; + font-weight: $font-semi-bold; +} diff --git a/res/css/voice-broadcast/molecules/_VoiceBroadcastRecordingPip.pcss b/res/css/voice-broadcast/molecules/_VoiceBroadcastRecordingPip.pcss new file mode 100644 index 00000000000..b01b1b80db0 --- /dev/null +++ b/res/css/voice-broadcast/molecules/_VoiceBroadcastRecordingPip.pcss @@ -0,0 +1,35 @@ +/* +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_VoiceBroadcastRecordingPip { + background-color: $system; + border-radius: 8px; + box-shadow: 0 2px 8px 0 #0000004a; + display: inline-block; + padding: $spacing-12; +} + +.mx_VoiceBroadcastRecordingPip_divider { + background-color: $quinary-content; + border: 0; + height: 1px; + margin: $spacing-12 0; +} + +.mx_VoiceBroadcastRecordingPip_controls { + display: flex; + justify-content: center; +} diff --git a/res/img/cancel.svg b/res/img/cancel.svg index 7da59a8c07e..2b7083e875d 100644 --- a/res/img/cancel.svg +++ b/res/img/cancel.svg @@ -1,10 +1,3 @@ - - - - Slice 1 - Created with Sketch. - - - - - \ No newline at end of file + + + diff --git a/res/img/element-icons/Stop.svg b/res/img/element-icons/Stop.svg new file mode 100644 index 00000000000..29c7a0cef7b --- /dev/null +++ b/res/img/element-icons/Stop.svg @@ -0,0 +1,3 @@ + + + diff --git a/res/img/element-icons/call/freedom.svg b/res/img/element-icons/call/freedom.svg new file mode 100644 index 00000000000..0a883b78339 --- /dev/null +++ b/res/img/element-icons/call/freedom.svg @@ -0,0 +1,3 @@ + + + diff --git a/res/img/element-icons/call/spotlight.svg b/res/img/element-icons/call/spotlight.svg new file mode 100644 index 00000000000..f9d96a1e85a --- /dev/null +++ b/res/img/element-icons/call/spotlight.svg @@ -0,0 +1,3 @@ + + + diff --git a/res/img/element-icons/reduce.svg b/res/img/element-icons/reduce.svg new file mode 100644 index 00000000000..3179e33a232 --- /dev/null +++ b/res/img/element-icons/reduce.svg @@ -0,0 +1,4 @@ + + + + diff --git a/res/img/element-icons/room/composer/bold.svg b/res/img/element-icons/room/composer/bold.svg new file mode 100644 index 00000000000..4a5cb184cfb --- /dev/null +++ b/res/img/element-icons/room/composer/bold.svg @@ -0,0 +1,3 @@ + + + diff --git a/res/img/element-icons/room/composer/italic.svg b/res/img/element-icons/room/composer/italic.svg new file mode 100644 index 00000000000..2299fc1fa29 --- /dev/null +++ b/res/img/element-icons/room/composer/italic.svg @@ -0,0 +1,3 @@ + + + diff --git a/res/img/element-icons/room/composer/strikethrough.svg b/res/img/element-icons/room/composer/strikethrough.svg new file mode 100644 index 00000000000..0ed63b21393 --- /dev/null +++ b/res/img/element-icons/room/composer/strikethrough.svg @@ -0,0 +1,4 @@ + + + + diff --git a/res/img/element-icons/room/composer/underline.svg b/res/img/element-icons/room/composer/underline.svg new file mode 100644 index 00000000000..b74e562ec8a --- /dev/null +++ b/res/img/element-icons/room/composer/underline.svg @@ -0,0 +1,3 @@ + + + diff --git a/res/img/element-icons/settings/desktop.svg b/res/img/element-icons/settings/desktop.svg new file mode 100644 index 00000000000..7d6ca100792 --- /dev/null +++ b/res/img/element-icons/settings/desktop.svg @@ -0,0 +1,3 @@ + + + diff --git a/res/img/element-icons/settings/mobile.svg b/res/img/element-icons/settings/mobile.svg new file mode 100644 index 00000000000..45170b2c15b --- /dev/null +++ b/res/img/element-icons/settings/mobile.svg @@ -0,0 +1,3 @@ + + + diff --git a/res/img/element-icons/settings/web.svg b/res/img/element-icons/settings/web.svg new file mode 100644 index 00000000000..95bd1ba24ee --- /dev/null +++ b/res/img/element-icons/settings/web.svg @@ -0,0 +1,3 @@ + + + diff --git a/res/img/feather-customised/cancel.svg b/res/img/feather-customised/cancel.svg deleted file mode 100644 index bc3b1b4e385..00000000000 --- a/res/img/feather-customised/cancel.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - Slice 1 - Created with Sketch. - - - - - \ No newline at end of file diff --git a/res/img/feather-customised/chevron-down-thin.svg b/res/img/feather-customised/chevron-down-thin.svg deleted file mode 100644 index 109c83def63..00000000000 --- a/res/img/feather-customised/chevron-down-thin.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/res/themes/dark/css/_dark.pcss b/res/themes/dark/css/_dark.pcss index 8d1e31b731c..cc9dc939565 100644 --- a/res/themes/dark/css/_dark.pcss +++ b/res/themes/dark/css/_dark.pcss @@ -205,6 +205,11 @@ $location-live-secondary-color: #e0e0e0; } /* ******************** */ +/* Voice Broadcast */ +/* ******************** */ +$live-badge-color: #ffffff; +/* ******************** */ + /* One-off colors */ /* ******************** */ $progressbar-bg-color: $system; diff --git a/res/themes/legacy-dark/css/_legacy-dark.pcss b/res/themes/legacy-dark/css/_legacy-dark.pcss index 756b50d9d68..fb088e290cd 100644 --- a/res/themes/legacy-dark/css/_legacy-dark.pcss +++ b/res/themes/legacy-dark/css/_legacy-dark.pcss @@ -203,6 +203,11 @@ $location-live-color: #8BC34A; $location-live-secondary-color: #e0e0e0; /* ******************** */ +/* Voice Broadcast */ +/* ******************** */ +$live-badge-color: #ffffff; +/* ******************** */ + /* ***** Mixins! ***** */ @define-mixin mx_DialogButton { diff --git a/res/themes/legacy-light/css/_legacy-light.pcss b/res/themes/legacy-light/css/_legacy-light.pcss index 6ac0e4b726e..c22f3ce3363 100644 --- a/res/themes/legacy-light/css/_legacy-light.pcss +++ b/res/themes/legacy-light/css/_legacy-light.pcss @@ -325,6 +325,11 @@ $location-live-color: #8BC34A; $location-live-secondary-color: #e0e0e0; /* ******************** */ +/* Voice Broadcast */ +/* ******************** */ +$live-badge-color: #ffffff; +/* ******************** */ + /* ***** Mixins! ***** */ @define-mixin mx_DialogButton { diff --git a/res/themes/light/css/_light.pcss b/res/themes/light/css/_light.pcss index 586525b3313..f37672e545a 100644 --- a/res/themes/light/css/_light.pcss +++ b/res/themes/light/css/_light.pcss @@ -345,6 +345,11 @@ $location-live-color: #8BC34A; $location-live-secondary-color: #e0e0e0; /* ******************** */ +/* Voice Broadcast */ +/* ******************** */ +$live-badge-color: #ffffff; +/* ******************** */ + /* Mixins */ /* ******************** */ @define-mixin mx_DialogButton { diff --git a/scripts/ci/js-sdk-to-release.js b/scripts/ci/js-sdk-to-release.js deleted file mode 100755 index e1fecfde034..00000000000 --- a/scripts/ci/js-sdk-to-release.js +++ /dev/null @@ -1,17 +0,0 @@ -#!/usr/bin/env node - -const fsProm = require('fs/promises'); - -const PKGJSON = 'node_modules/matrix-js-sdk/package.json'; - -async function main() { - const pkgJson = JSON.parse(await fsProm.readFile(PKGJSON, 'utf8')); - for (const field of ['main', 'typings']) { - if (pkgJson["matrix_lib_"+field] !== undefined) { - pkgJson[field] = pkgJson["matrix_lib_"+field]; - } - } - await fsProm.writeFile(PKGJSON, JSON.stringify(pkgJson, null, 2)); -} - -main(); diff --git a/scripts/ci/layered.sh b/scripts/ci/layered.sh index fdde9dd8286..bb002bd3abf 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/@types/common.ts b/src/@types/common.ts index b4d01a75a54..b18fefc2536 100644 --- a/src/@types/common.ts +++ b/src/@types/common.ts @@ -49,3 +49,8 @@ export type KeysWithObjectShape = { ? (Input[P] extends Array ? never : P) : never; }[keyof Input]; + +export type KeysStartingWith = { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + [P in keyof Input]: P extends `${Str}${infer _X}` ? P : never; // we don't use _X +}[keyof Input]; diff --git a/src/AddThreepid.ts b/src/AddThreepid.ts index 415d6d7ad0e..95ebbec0f57 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 1da1bfe1d6f..d4cf3cc0ab5 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(); - - // Pass the encrypted data as a Blob to the uploader. - const blob = new Blob([encryptResult.data]); - uploadPromise = matrixClient.uploadContent(blob, { - progressHandler, - includeFilename: false, - }); + const data = await readFileAsArrayBuffer(file); + if (abortController.signal.aborted) throw new UploadCanceledError(); - return uploadPromise.then(url => { - if (canceled) 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 IAbortablePromise<{ file: IEncryptedFile }>; - prom.abort = () => { - canceled = true; - if (uploadPromise) matrixClient.cancelUpload(uploadPromise); + // Then encrypt the file. + const encryptResult = await encrypt.encryptAttachment(data); + if (abortController.signal.aborted) throw new UploadCanceledError(); + + // Pass the encrypted data as a Blob to the uploader. + const blob = new Blob([encryptResult.data]); + + 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) { + const upload = new RoomUpload(roomId, fileName, relation, file.size); + this.inprogress.push(upload); + dis.dispatch({ action: Action.UploadStarted, upload }); + + function onProgress(progress: UploadProgress) { + upload.onProgress(progress); + dis.dispatch({ action: Action.UploadProgress, upload }); + } + + try { + if (file.type.startsWith('image/')) { content.msgtype = MsgType.Image; - infoForImageFile(matrixClient, roomId, file).then((imageInfo) => { + try { + const imageInfo = await infoForImageFile(matrixClient, roomId, file); Object.assign(content.info, imageInfo); - resolve(); - }, (e) => { + } catch (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) => { + try { + const videoInfo = await infoForVideoFile(matrixClient, roomId, file); Object.assign(content.info, videoInfo); - resolve(); - }, (e) => { + } catch (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; - }; + if (upload.cancelled) throw new UploadCanceledError(); + const result = await uploadFile(matrixClient, roomId, file, onProgress, upload.abortController); + content.file = result.file; + content.url = result.url; - const upload: IUpload = { - fileName: file.name || 'Attachment', - roomId, - relation, - total: file.size, - loaded: 0, - promise: prom, - }; - this.inprogress.push(upload); - dis.dispatch({ action: Action.UploadStarted, upload }); + if (upload.cancelled) throw new UploadCanceledError(); + // Await previous message being sent into the room + if (promBefore) await promBefore; - function onProgress(ev) { - upload.total = ev.total; - upload.loaded = ev.loaded; - dis.dispatch({ action: Action.UploadProgress, upload }); - } + 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); - 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); - }); + sendRoundTripMetric(matrixClient, roomId, response.event_id); } - return prom; - }, function(err: MatrixError) { - error = err; - if (!upload.canceled) { + + 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/DateUtils.ts b/src/DateUtils.ts index 20227354f02..2b50bddb858 100644 --- a/src/DateUtils.ts +++ b/src/DateUtils.ts @@ -1,6 +1,7 @@ /* Copyright 2015, 2016 OpenMarket Ltd Copyright 2017 Vector Creations Ltd +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. @@ -177,6 +178,16 @@ export function formatFullDateNoDay(date: Date) { }); } +/** + * Returns an ISO date string without textual description of the date (ie: no "Wednesday" or + * similar) + * @param date The date to format. + * @returns The date string in ISO format. + */ +export function formatFullDateNoDayISO(date: Date): string { + return date.toISOString(); +} + export function formatFullDateNoDayNoTime(date: Date) { return ( date.getFullYear() + diff --git a/src/DeviceListener.ts b/src/DeviceListener.ts index cf9af5befc4..1f49c3b34d6 100644 --- a/src/DeviceListener.ts +++ b/src/DeviceListener.ts @@ -40,6 +40,13 @@ import { isSecureBackupRequired } from './utils/WellKnownUtils'; import { ActionPayload } from "./dispatcher/payloads"; import { Action } from "./dispatcher/actions"; import { isLoggedIn } from "./utils/login"; +import SdkConfig from "./SdkConfig"; +import PlatformPeg from "./PlatformPeg"; +import { + recordClientInformation, + removeClientInformation, +} from "./utils/device/clientInformation"; +import SettingsStore, { CallbackFn } from "./settings/SettingsStore"; const KEY_BACKUP_POLL_INTERVAL = 5 * 60 * 1000; @@ -60,6 +67,8 @@ export default class DeviceListener { // The set of device IDs we're currently displaying toasts for private displayingToastsForDeviceIds = new Set(); private running = false; + private shouldRecordClientInformation = false; + private deviceClientInformationSettingWatcherRef: string | undefined; public static sharedInstance() { if (!window.mxDeviceListener) window.mxDeviceListener = new DeviceListener(); @@ -76,8 +85,15 @@ export default class DeviceListener { MatrixClientPeg.get().on(ClientEvent.AccountData, this.onAccountData); MatrixClientPeg.get().on(ClientEvent.Sync, this.onSync); MatrixClientPeg.get().on(RoomStateEvent.Events, this.onRoomStateEvents); + this.shouldRecordClientInformation = SettingsStore.getValue('deviceClientInformationOptIn'); + this.deviceClientInformationSettingWatcherRef = SettingsStore.watchSetting( + 'deviceClientInformationOptIn', + null, + this.onRecordClientInformationSettingChange, + ); this.dispatcherRef = dis.register(this.onAction); this.recheck(); + this.updateClientInformation(); } public stop() { @@ -95,6 +111,9 @@ export default class DeviceListener { MatrixClientPeg.get().removeListener(ClientEvent.Sync, this.onSync); MatrixClientPeg.get().removeListener(RoomStateEvent.Events, this.onRoomStateEvents); } + if (this.deviceClientInformationSettingWatcherRef) { + SettingsStore.unwatchSetting(this.deviceClientInformationSettingWatcherRef); + } if (this.dispatcherRef) { dis.unregister(this.dispatcherRef); this.dispatcherRef = null; @@ -200,6 +219,7 @@ export default class DeviceListener { private onAction = ({ action }: ActionPayload) => { if (action !== Action.OnLoggedIn) return; this.recheck(); + this.updateClientInformation(); }; // The server doesn't tell us when key backup is set up, so we poll @@ -343,4 +363,34 @@ export default class DeviceListener { dis.dispatch({ action: Action.ReportKeyBackupNotEnabled }); } }; + + private onRecordClientInformationSettingChange: CallbackFn = ( + _originalSettingName, _roomId, _level, _newLevel, newValue, + ) => { + const prevValue = this.shouldRecordClientInformation; + + this.shouldRecordClientInformation = !!newValue; + + if (this.shouldRecordClientInformation !== prevValue) { + this.updateClientInformation(); + } + }; + + private updateClientInformation = async () => { + try { + if (this.shouldRecordClientInformation) { + await recordClientInformation( + MatrixClientPeg.get(), + SdkConfig.get(), + PlatformPeg.get(), + ); + } else { + await removeClientInformation(MatrixClientPeg.get()); + } + } catch (error) { + // this is a best effort operation + // log the error without rethrowing + logger.error('Failed to update client information', error); + } + }; } diff --git a/src/IConfigOptions.ts b/src/IConfigOptions.ts index 837d3050f35..b45461618e1 100644 --- a/src/IConfigOptions.ts +++ b/src/IConfigOptions.ts @@ -117,7 +117,10 @@ export interface IConfigOptions { obey_asserted_identity?: boolean; // MSC3086 }; element_call: { - url: string; + url?: string; + use_exclusively?: boolean; + participant_limit?: number; + brand?: string; }; logout_redirect_url?: string; @@ -138,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 @@ -179,8 +179,10 @@ export interface IConfigOptions { sync_timeline_limit?: number; dangerously_allow_unsafe_and_insecure_passwords?: boolean; // developer option - // XXX: Undocumented URL for the "Learn more about spaces" link in the "Communities don't exist" messaging. - spaces_learn_more_url?: string; + voice_broadcast?: { + // length per voice chunk in seconds + chunk_length?: number; + }; } export interface ISsoRedirectOptions { diff --git a/src/LegacyCallHandler.tsx b/src/LegacyCallHandler.tsx index 624dc86a33f..41098dcb4db 100644 --- a/src/LegacyCallHandler.tsx +++ b/src/LegacyCallHandler.tsx @@ -62,6 +62,7 @@ import { KIND_CALL_TRANSFER } from "./components/views/dialogs/InviteDialogTypes import { OpenInviteDialogPayload } from "./dispatcher/payloads/OpenInviteDialogPayload"; import { findDMForUser } from './utils/dm/findDMForUser'; import { getJoinedNonFunctionalMembers } from './utils/room/getJoinedNonFunctionalMembers'; +import { localNotificationsAreSilenced } from './utils/notifications'; export const PROTOCOL_PSTN = 'm.protocol.pstn'; export const PROTOCOL_PSTN_PREFIXED = 'im.vector.protocol.pstn'; @@ -184,6 +185,11 @@ export default class LegacyCallHandler extends EventEmitter { } } + public isForcedSilent(): boolean { + const cli = MatrixClientPeg.get(); + return localNotificationsAreSilenced(cli); + } + public silenceCall(callId: string): void { this.silencedCalls.add(callId); this.emit(LegacyCallHandlerEvent.SilencedCallsChanged, this.silencedCalls); @@ -194,13 +200,14 @@ export default class LegacyCallHandler extends EventEmitter { } public unSilenceCall(callId: string): void { + if (this.isForcedSilent) return; this.silencedCalls.delete(callId); this.emit(LegacyCallHandlerEvent.SilencedCallsChanged, this.silencedCalls); this.play(AudioID.Ring); } public isCallSilenced(callId: string): boolean { - return this.silencedCalls.has(callId); + return this.isForcedSilent() || this.silencedCalls.has(callId); } /** @@ -582,7 +589,7 @@ export default class LegacyCallHandler extends EventEmitter { action.value === "ring" )); - if (pushRuleEnabled && tweakSetToRing) { + if (pushRuleEnabled && tweakSetToRing && !this.isForcedSilent()) { this.play(AudioID.Ring); } else { this.silenceCall(call.callId); @@ -820,10 +827,10 @@ export default class LegacyCallHandler extends EventEmitter { } } - public placeCall(roomId: string, type?: CallType, transferee?: MatrixCall): void { + public async placeCall(roomId: string, type?: CallType, transferee?: MatrixCall): Promise { // We might be using managed hybrid widgets if (isManagedHybridWidgetEnabled()) { - addManagedHybridWidget(roomId); + await addManagedHybridWidget(roomId); return; } @@ -870,9 +877,9 @@ export default class LegacyCallHandler extends EventEmitter { } else if (members.length === 2) { logger.info(`Place ${type} call in ${roomId}`); - this.placeMatrixCall(roomId, type, transferee); + await this.placeMatrixCall(roomId, type, transferee); } else { // > 2 - this.placeJitsiCall(roomId, type); + await this.placeJitsiCall(roomId, type); } } diff --git a/src/Lifecycle.ts b/src/Lifecycle.ts index 1e7fae8136e..64d1d9b5fdf 100644 --- a/src/Lifecycle.ts +++ b/src/Lifecycle.ts @@ -699,7 +699,7 @@ async function persistCredentials(credentials: IMatrixClientCreds): Promise { + 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 a16f570fa90..c36f5770b92 100644 --- a/src/Login.ts +++ b/src/Login.ts @@ -19,54 +19,22 @@ limitations under the License. import { createClient } from "matrix-js-sdk/src/matrix"; import { MatrixClient } from "matrix-js-sdk/src/client"; import { logger } from "matrix-js-sdk/src/logger"; +import { ILoginParams, LoginFlow } from "matrix-js-sdk/src/@types/auth"; import { IMatrixClientCreds } from "./MatrixClientPeg"; import SecurityCustomisations from "./customisations/Security"; +export { + IdentityProviderBrand, + IIdentityProvider, + ISSOFlow, + LoginFlow, +} from "matrix-js-sdk/src/@types/auth"; + interface ILoginOptions { defaultDeviceDisplayName?: string; } -// TODO: Move this to JS SDK -interface IPasswordFlow { - type: "m.login.password"; -} - -export enum IdentityProviderBrand { - Gitlab = "gitlab", - Github = "github", - Apple = "apple", - Google = "google", - Facebook = "facebook", - Twitter = "twitter", -} - -export interface IIdentityProvider { - id: string; - name: string; - icon?: string; - brand?: IdentityProviderBrand | string; -} - -export interface ISSOFlow { - type: "m.login.sso" | "m.login.cas"; - // eslint-disable-next-line camelcase - identity_providers?: IIdentityProvider[]; -} - -export type LoginFlow = ISSOFlow | IPasswordFlow; - -// TODO: Move this to JS SDK -/* eslint-disable camelcase */ -interface ILoginParams { - identifier?: object; - password?: string; - token?: string; - device_id?: string; - initial_device_display_name?: string; -} -/* eslint-enable camelcase */ - export default class Login { private hsUrl: string; private isUrl: string; @@ -201,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/MediaDeviceHandler.ts b/src/MediaDeviceHandler.ts index a5160484c8f..0e6d2b98bc7 100644 --- a/src/MediaDeviceHandler.ts +++ b/src/MediaDeviceHandler.ts @@ -50,10 +50,20 @@ export default class MediaDeviceHandler extends EventEmitter { return devices.some(d => Boolean(d.label)); } + /** + * Gets the available audio input/output and video input devices + * from the browser: a thin wrapper around mediaDevices.enumerateDevices() + * that also returns results by type of devices. Note that this requires + * user media permissions and an active stream, otherwise you'll get blank + * device labels. + * + * Once the Permissions API + * (https://developer.mozilla.org/en-US/docs/Web/API/Permissions_API) + * is ready for primetime, it might help make this simpler. + * + * @return Promise The available media devices + */ public static async getDevices(): Promise { - // Only needed for Electron atm, though should work in modern browsers - // once permission has been granted to the webapp - try { const devices = await navigator.mediaDevices.enumerateDevices(); const output = { diff --git a/src/Notifier.ts b/src/Notifier.ts index 80a6661055a..dd0ebc296a2 100644 --- a/src/Notifier.ts +++ b/src/Notifier.ts @@ -26,6 +26,7 @@ import { M_LOCATION } from "matrix-js-sdk/src/@types/location"; import { PermissionChanged as PermissionChangedEvent, } from "@matrix-org/analytics-events/types/typescript/PermissionChanged"; +import { ISyncStateData, SyncState } from "matrix-js-sdk/src/sync"; import { MatrixClientPeg } from './MatrixClientPeg'; import { PosthogAnalytics } from "./PosthogAnalytics"; @@ -46,6 +47,11 @@ import { mediaFromMxc } from "./customisations/Media"; import ErrorDialog from "./components/views/dialogs/ErrorDialog"; import LegacyCallHandler from "./LegacyCallHandler"; import VoipUserMapper from "./VoipUserMapper"; +import { localNotificationsAreSilenced } from "./utils/notifications"; +import { getIncomingCallToastKey, IncomingCallToast } from "./toasts/IncomingCallToast"; +import ToastStore from "./stores/ToastStore"; +import { ElementCall } from "./models/Call"; +import { createLocalNotificationSettingsIfNeeded } from './utils/notifications'; /* * Dispatches: @@ -90,8 +96,9 @@ export const Notifier = { return TextForEvent.textForEvent(ev); }, - _displayPopupNotification: function(ev: MatrixEvent, room: Room) { + _displayPopupNotification: function(ev: MatrixEvent, room: Room): void { const plaf = PlatformPeg.get(); + const cli = MatrixClientPeg.get(); if (!plaf) { return; } @@ -99,6 +106,10 @@ export const Notifier = { return; } + if (localNotificationsAreSilenced(cli)) { + return; + } + let msg = this.notificationMessageForEvent(ev); if (!msg) return; @@ -170,7 +181,12 @@ export const Notifier = { }; }, - _playAudioNotification: async function(ev: MatrixEvent, room: Room) { + _playAudioNotification: async function(ev: MatrixEvent, room: Room): Promise { + const cli = MatrixClientPeg.get(); + if (localNotificationsAreSilenced(cli)) { + return; + } + const sound = this.getSoundForRoom(room.roomId); logger.log(`Got sound ${sound && sound.name || "default"} for ${room.roomId}`); @@ -325,7 +341,7 @@ export const Notifier = { } const isGuest = client.isGuest(); return !isGuest && this.supportsDesktopNotifications() && !isPushNotifyDisabled() && - !this.isEnabled() && !this._isPromptHidden(); + !this.isEnabled() && !this._isPromptHidden(); }, _isPromptHidden: function() { @@ -337,17 +353,25 @@ export const Notifier = { return this.toolbarHidden; }, - onSyncStateChange: function(state: string) { - if (state === "SYNCING") { + onSyncStateChange: function(state: SyncState, prevState?: SyncState, data?: ISyncStateData) { + if (state === SyncState.Syncing) { this.isSyncing = true; - } else if (state === "STOPPED" || state === "ERROR") { + } else if (state === SyncState.Stopped || state === SyncState.Error) { this.isSyncing = false; } + + // wait for first non-cached sync to complete + if ( + ![SyncState.Stopped, SyncState.Error].includes(state) && + !data?.fromCache + ) { + createLocalNotificationSettingsIfNeeded(MatrixClientPeg.get()); + } }, onEvent: function(ev: MatrixEvent) { if (!this.isSyncing) return; // don't alert for any messages initially - if (ev.getSender() === MatrixClientPeg.get().credentials.userId) return; + if (ev.getSender() === MatrixClientPeg.get().getUserId()) return; MatrixClientPeg.get().decryptEventIfNeeded(ev); @@ -407,7 +431,10 @@ export const Notifier = { const room = MatrixClientPeg.get().getRoom(roomId); const actions = MatrixClientPeg.get().getPushActionsForEvent(ev); + if (actions?.notify) { + this._performCustomEventHandling(ev); + if (RoomViewStore.instance.getRoomId() === room.roomId && UserActivity.sharedInstance().userActiveRecently() && !Modal.hasDialogs() @@ -425,6 +452,24 @@ export const Notifier = { } } }, + + /** + * Some events require special handling such as showing in-app toasts + */ + _performCustomEventHandling: function(ev: MatrixEvent) { + if ( + ElementCall.CALL_EVENT_TYPE.names.includes(ev.getType()) + && SettingsStore.getValue("feature_group_calls") + ) { + ToastStore.sharedInstance().addOrReplaceToast({ + key: getIncomingCallToastKey(ev.getStateKey()), + priority: 100, + component: IncomingCallToast, + bodyClassName: "mx_IncomingCallToast", + props: { callEvent: ev }, + }); + } + }, }; if (!window.mxNotifier) { diff --git a/src/PageTypes.ts b/src/PageTypes.ts index fb0424f6e05..1e181b4e3f1 100644 --- a/src/PageTypes.ts +++ b/src/PageTypes.ts @@ -20,7 +20,6 @@ enum PageType { HomePage = "home_page", RoomView = "room_view", UserView = "user_view", - LegacyGroupView = "legacy_group_view", } export default PageType; diff --git a/src/PosthogTrackers.ts b/src/PosthogTrackers.ts index 0422f0bf9b6..4814daa6f47 100644 --- a/src/PosthogTrackers.ts +++ b/src/PosthogTrackers.ts @@ -41,7 +41,6 @@ const loggedInPageTypeMap: Record = { [PageType.HomePage]: "Home", [PageType.RoomView]: "Room", [PageType.UserView]: "User", - [PageType.LegacyGroupView]: "Group", }; export default class PosthogTrackers { diff --git a/src/ScalarAuthClient.ts b/src/ScalarAuthClient.ts index 5af56de9283..34740863517 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 { Service, startTermsFlow, TermsInteractionCallback, TermsNotSignedError } from './Terms'; import { MatrixClientPeg } from "./MatrixClientPeg"; @@ -103,29 +103,29 @@ export default class ScalarAuthClient { } } - private getAccountName(token: string): Promise { - const url = this.apiUrl + "/account"; - - 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); - } - }); + private async getAccountName(token: string): Promise { + const url = new URL(this.apiUrl + "/account"); + url.searchParams.set("scalar_token", token); + url.searchParams.set("v", imApiVersion); + + 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,44 @@ export default class ScalarAuthClient { }); } - exchangeForScalarToken(openidTokenObject: any): Promise { - const scalarRestUrl = this.apiUrl; - - 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); - } - }); + public async exchangeForScalarToken(openidTokenObject: IOpenIDToken): Promise { + const scalarRestUrl = new URL(this.apiUrl + "/register"); + scalarRestUrl.searchParams.set("v", imApiVersion); + + const res = await fetch(scalarRestUrl, { + method: "POST", + body: JSON.stringify(openidTokenObject), + headers: { + "Content-Type": "application/json", + }, }); + + 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); - - 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); - } - }); + public async getScalarPageTitle(url: string): Promise { + const scalarPageLookupUrl = new URL(this.getStarterLink(this.apiUrl + '/widgets/title_lookup')); + scalarPageLookupUrl.searchParams.set("curl", encodeURIComponent(url)); + + 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 +231,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 56f9cd2e4e3..2a09e8bb36b 100644 --- a/src/SdkConfig.ts +++ b/src/SdkConfig.ts @@ -32,6 +32,9 @@ export const DEFAULTS: IConfigOptions = { }, element_call: { url: "https://call.element.io", + use_exclusively: false, + participant_limit: 8, + brand: "Element Call", }, // @ts-ignore - we deliberately use the camelCase version here so we trigger @@ -43,7 +46,9 @@ export const DEFAULTS: IConfigOptions = { logo: require("../res/img/element-desktop-logo.svg").default, url: "https://schildi.chat/desktop", }, - spaces_learn_more_url: "https://element.io/blog/spaces-blast-out-of-beta/", + voice_broadcast: { + chunk_length: 60, // one minute + }, }; export default class SdkConfig { diff --git a/src/SlashCommands.tsx b/src/SlashCommands.tsx index e731840397f..bbd936ce756 100644 --- a/src/SlashCommands.tsx +++ b/src/SlashCommands.tsx @@ -711,7 +711,7 @@ export const Commands = [ runFn: function(roomId, args) { const cli = MatrixClientPeg.get(); - let targetRoomId; + let targetRoomId: string; if (args) { const matches = args.match(/^(\S+)$/); if (matches) { @@ -725,16 +725,11 @@ export const Commands = [ // Try to find a room with this alias const rooms = cli.getRooms(); for (let i = 0; i < rooms.length; i++) { - const aliasEvents = rooms[i].currentState.getStateEvents('m.room.aliases'); - for (let j = 0; j < aliasEvents.length; j++) { - const aliases = aliasEvents[j].getContent().aliases || []; - for (let k = 0; k < aliases.length; k++) { - if (aliases[k] === roomAlias) { - targetRoomId = rooms[i].roomId; - break; - } - } - if (targetRoomId) break; + if (rooms[i].getCanonicalAlias() === roomAlias || + rooms[i].getAltAliases().includes(roomAlias) + ) { + targetRoomId = rooms[i].roomId; + break; } if (targetRoomId) break; } @@ -1104,12 +1099,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/TextForEvent.tsx b/src/TextForEvent.tsx index 9310391e3e2..361edcb1e22 100644 --- a/src/TextForEvent.tsx +++ b/src/TextForEvent.tsx @@ -43,9 +43,9 @@ import { MatrixClientPeg } from "./MatrixClientPeg"; import { ROOM_SECURITY_TAB } from "./components/views/dialogs/RoomSettingsDialog"; import AccessibleButton from './components/views/elements/AccessibleButton'; import RightPanelStore from './stores/right-panel/RightPanelStore'; -import UserIdentifierCustomisations from './customisations/UserIdentifier'; import { ViewRoomPayload } from "./dispatcher/payloads/ViewRoomPayload"; import { isLocationEvent } from './utils/EventUtils'; +import { ElementCall } from "./models/Call"; export function getSenderName(event: MatrixEvent): string { return event.sender?.name ?? event.getSender() ?? _t("Someone"); @@ -55,7 +55,16 @@ function getRoomMemberDisplayname(event: MatrixEvent, userId = event.getSender() const client = MatrixClientPeg.get(); const roomId = event.getRoomId(); const member = client.getRoom(roomId)?.getMember(userId); - return member?.rawDisplayName || userId || _t("Someone"); + return member?.name || member?.rawDisplayName || userId || _t("Someone"); +} + +function textForCallEvent(event: MatrixEvent): () => string { + const roomName = MatrixClientPeg.get().getRoom(event.getRoomId()!).name; + const isSupported = MatrixClientPeg.get().supportsVoip(); + + return isSupported + ? () => _t("Video call started in %(roomName)s.", { roomName }) + : () => _t("Video call started in %(roomName)s. (not supported by this browser)", { roomName }); } // These functions are frequently used just to check whether an event has @@ -467,7 +476,7 @@ function textForPowerEvent(event: MatrixEvent): () => string | null { } if (from === previousUserDefault && to === currentUserDefault) { return; } if (to !== from) { - const name = UserIdentifierCustomisations.getDisplayUserIdentifier(userId, { roomId: event.getRoomId() }); + const name = getRoomMemberDisplayname(event, userId); diffs.push({ userId, name, from, to }); } }); @@ -799,6 +808,11 @@ for (const evType of ALL_RULE_TYPES) { stateHandlers[evType] = textForMjolnirEvent; } +// Add both stable and unstable m.call events +for (const evType of ElementCall.CALL_EVENT_TYPE.names) { + stateHandlers[evType] = textForCallEvent; +} + /** * Determines whether the given event has text to display. * @param ev The event diff --git a/src/Unread.ts b/src/Unread.ts index b9b3409c66c..1804ddefb71 100644 --- a/src/Unread.ts +++ b/src/Unread.ts @@ -42,7 +42,6 @@ export function eventTriggersUnreadCount(ev: MatrixEvent): boolean { case EventType.RoomThirdPartyInvite: case EventType.CallAnswer: case EventType.CallHangup: - case EventType.RoomAliases: case EventType.RoomCanonicalAlias: case EventType.RoomServerAcl: case M_BEACON.name: diff --git a/src/accessibility/KeyboardShortcuts.ts b/src/accessibility/KeyboardShortcuts.ts index 1a7f45404cf..6ea84326224 100644 --- a/src/accessibility/KeyboardShortcuts.ts +++ b/src/accessibility/KeyboardShortcuts.ts @@ -339,6 +339,7 @@ export const KEYBOARD_SHORTCUTS: IKeyboardShortcuts = { [KeyBindingAction.FormatQuote]: { default: { ctrlOrCmdKey: true, + shiftKey: true, key: Key.GREATER_THAN, }, displayName: _td("Toggle Quote"), diff --git a/src/actions/handlers/viewUserDeviceSettings.ts b/src/actions/handlers/viewUserDeviceSettings.ts new file mode 100644 index 00000000000..e1dc7b3f268 --- /dev/null +++ b/src/actions/handlers/viewUserDeviceSettings.ts @@ -0,0 +1,30 @@ +/* +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 { 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/async-components/views/dialogs/security/ExportE2eKeysDialog.tsx b/src/async-components/views/dialogs/security/ExportE2eKeysDialog.tsx index 2f2bc36ec1f..e020dbeea1d 100644 --- a/src/async-components/views/dialogs/security/ExportE2eKeysDialog.tsx +++ b/src/async-components/views/dialogs/security/ExportE2eKeysDialog.tsx @@ -1,5 +1,6 @@ /* Copyright 2017 Vector Creations Ltd +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. @@ -15,7 +16,7 @@ limitations under the License. */ import FileSaver from 'file-saver'; -import React, { createRef } from 'react'; +import React from 'react'; import { MatrixClient } from 'matrix-js-sdk/src/client'; import { logger } from "matrix-js-sdk/src/logger"; @@ -23,6 +24,8 @@ import { _t } from '../../../../languageHandler'; import * as MegolmExportEncryption from '../../../../utils/MegolmExportEncryption'; import { IDialogProps } from "../../../../components/views/dialogs/IDialogProps"; import BaseDialog from "../../../../components/views/dialogs/BaseDialog"; +import Field from "../../../../components/views/elements/Field"; +import { KeysStartingWith } from "../../../../@types/common"; enum Phase { Edit = "edit", @@ -36,12 +39,14 @@ interface IProps extends IDialogProps { interface IState { phase: Phase; errStr: string; + passphrase1: string; + passphrase2: string; } +type AnyPassphrase = KeysStartingWith; + export default class ExportE2eKeysDialog extends React.Component { private unmounted = false; - private passphrase1 = createRef(); - private passphrase2 = createRef(); constructor(props: IProps) { super(props); @@ -49,6 +54,8 @@ export default class ExportE2eKeysDialog extends React.Component this.state = { phase: Phase.Edit, errStr: null, + passphrase1: "", + passphrase2: "", }; } @@ -59,8 +66,8 @@ export default class ExportE2eKeysDialog extends React.Component private onPassphraseFormSubmit = (ev: React.FormEvent): boolean => { ev.preventDefault(); - const passphrase = this.passphrase1.current.value; - if (passphrase !== this.passphrase2.current.value) { + const passphrase = this.state.passphrase1; + if (passphrase !== this.state.passphrase2) { this.setState({ errStr: _t('Passphrases must match') }); return false; } @@ -112,6 +119,12 @@ export default class ExportE2eKeysDialog extends React.Component return false; }; + private onPassphraseChange = (ev: React.ChangeEvent, phrase: AnyPassphrase) => { + this.setState({ + [phrase]: ev.target.value, + } as Pick); + }; + public render(): JSX.Element { const disableForm = (this.state.phase === Phase.Exporting); @@ -146,36 +159,25 @@ export default class ExportE2eKeysDialog extends React.Component
-
- -
-
- -
+ this.onPassphraseChange(e, "passphrase1")} + autoFocus={true} + size={64} + type="password" + disabled={disableForm} + />
-
- -
-
- -
+ this.onPassphraseChange(e, "passphrase2")} + size={64} + type="password" + disabled={disableForm} + />
diff --git a/src/async-components/views/dialogs/security/ImportE2eKeysDialog.tsx b/src/async-components/views/dialogs/security/ImportE2eKeysDialog.tsx index 65bbe0a70ef..7c710cf6765 100644 --- a/src/async-components/views/dialogs/security/ImportE2eKeysDialog.tsx +++ b/src/async-components/views/dialogs/security/ImportE2eKeysDialog.tsx @@ -1,5 +1,6 @@ /* Copyright 2017 Vector Creations Ltd +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. @@ -22,6 +23,7 @@ import * as MegolmExportEncryption from '../../../../utils/MegolmExportEncryptio import { _t } from '../../../../languageHandler'; import { IDialogProps } from "../../../../components/views/dialogs/IDialogProps"; import BaseDialog from "../../../../components/views/dialogs/BaseDialog"; +import Field from "../../../../components/views/elements/Field"; function readFileAsArrayBuffer(file: File): Promise { return new Promise((resolve, reject) => { @@ -48,12 +50,12 @@ interface IState { enableSubmit: boolean; phase: Phase; errStr: string; + passphrase: string; } export default class ImportE2eKeysDialog extends React.Component { private unmounted = false; private file = createRef(); - private passphrase = createRef(); constructor(props: IProps) { super(props); @@ -62,6 +64,7 @@ export default class ImportE2eKeysDialog extends React.Component enableSubmit: false, phase: Phase.Edit, errStr: null, + passphrase: "", }; } @@ -69,16 +72,22 @@ export default class ImportE2eKeysDialog extends React.Component this.unmounted = true; } - private onFormChange = (ev: React.FormEvent): void => { + private onFormChange = (): void => { const files = this.file.current.files || []; this.setState({ - enableSubmit: (this.passphrase.current.value !== "" && files.length > 0), + enableSubmit: (this.state.passphrase !== "" && files.length > 0), }); }; + private onPassphraseChange = (ev: React.ChangeEvent): void => { + this.setState({ passphrase: ev.target.value }); + this.onFormChange(); // update general form state too + }; + private onFormSubmit = (ev: React.FormEvent): boolean => { ev.preventDefault(); - this.startImport(this.file.current.files[0], this.passphrase.current.value); + // noinspection JSIgnoredPromiseFromCall + this.startImport(this.file.current.files[0], this.state.passphrase); return false; }; @@ -161,20 +170,14 @@ export default class ImportE2eKeysDialog extends React.Component
-
- -
-
- -
+
diff --git a/src/audio/VoiceMessageRecording.ts b/src/audio/VoiceMessageRecording.ts new file mode 100644 index 00000000000..2e101f903a5 --- /dev/null +++ b/src/audio/VoiceMessageRecording.ts @@ -0,0 +1,164 @@ +/* +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 { IEncryptedFile, MatrixClient } from "matrix-js-sdk/src/matrix"; +import { SimpleObservable } from "matrix-widget-api"; + +import { uploadFile } from "../ContentMessages"; +import { concat } from "../utils/arrays"; +import { IDestroyable } from "../utils/IDestroyable"; +import { Singleflight } from "../utils/Singleflight"; +import { Playback } from "./Playback"; +import { IRecordingUpdate, RecordingState, VoiceRecording } from "./VoiceRecording"; + +export interface IUpload { + mxc?: string; // for unencrypted uploads + encrypted?: IEncryptedFile; +} + +/** + * This class can be used to record a single voice message. + */ +export class VoiceMessageRecording implements IDestroyable { + private lastUpload: IUpload; + private buffer = new Uint8Array(0); // use this.audioBuffer to access + private playback: Playback; + + public constructor( + private matrixClient: MatrixClient, + private voiceRecording: VoiceRecording, + ) { + this.voiceRecording.onDataAvailable = this.onDataAvailable; + } + + public async start(): Promise { + if (this.lastUpload || this.hasRecording) { + throw new Error("Recording already prepared"); + } + + return this.voiceRecording.start(); + } + + public async stop(): Promise { + await this.voiceRecording.stop(); + return this.audioBuffer; + } + + public on(event: string | symbol, listener: (...args: any[]) => void): this { + this.voiceRecording.on(event, listener); + return this; + } + + public off(event: string | symbol, listener: (...args: any[]) => void): this { + this.voiceRecording.off(event, listener); + return this; + } + + public emit(event: string, ...args: any[]): boolean { + return this.voiceRecording.emit(event, ...args); + } + + public get hasRecording(): boolean { + return this.buffer.length > 0; + } + + public get isRecording(): boolean { + return this.voiceRecording.isRecording; + } + + /** + * Gets a playback instance for this voice recording. Note that the playback will not + * have been prepared fully, meaning the `prepare()` function needs to be called on it. + * + * The same playback instance is returned each time. + * + * @returns {Playback} The playback instance. + */ + public getPlayback(): Playback { + this.playback = Singleflight.for(this, "playback").do(() => { + return new Playback(this.audioBuffer.buffer, this.voiceRecording.amplitudes); // cast to ArrayBuffer proper; + }); + return this.playback; + } + + public async upload(inRoomId: string): Promise { + if (!this.hasRecording) { + throw new Error("No recording available to upload"); + } + + if (this.lastUpload) return this.lastUpload; + + try { + this.emit(RecordingState.Uploading); + const { url: mxc, file: encrypted } = await uploadFile( + this.matrixClient, + inRoomId, + new Blob( + [this.audioBuffer], + { + type: this.contentType, + }, + ), + ); + this.lastUpload = { mxc, encrypted }; + this.emit(RecordingState.Uploaded); + } catch (e) { + this.emit(RecordingState.Ended); + throw e; + } + return this.lastUpload; + } + + public get durationSeconds(): number { + return this.voiceRecording.durationSeconds; + } + + public get contentType(): string { + return this.voiceRecording.contentType; + } + + public get contentLength(): number { + return this.buffer.length; + } + + public get liveData(): SimpleObservable { + return this.voiceRecording.liveData; + } + + public get isSupported(): boolean { + return this.voiceRecording.isSupported; + } + + destroy(): void { + this.playback?.destroy(); + this.voiceRecording.destroy(); + } + + private onDataAvailable = (data: ArrayBuffer) => { + const buf = new Uint8Array(data); + this.buffer = concat(this.buffer, buf); + }; + + private get audioBuffer(): Uint8Array { + // We need a clone of the buffer to avoid accidentally changing the position + // on the real thing. + return this.buffer.slice(0); + } +} + +export const createVoiceMessageRecording = (matrixClient: MatrixClient) => { + return new VoiceMessageRecording(matrixClient, new VoiceRecording()); +}; diff --git a/src/audio/VoiceRecording.ts b/src/audio/VoiceRecording.ts index d0b34493d86..0e18756fe56 100644 --- a/src/audio/VoiceRecording.ts +++ b/src/audio/VoiceRecording.ts @@ -16,10 +16,8 @@ limitations under the License. import * as Recorder from 'opus-recorder'; import encoderPath from 'opus-recorder/dist/encoderWorker.min.js'; -import { MatrixClient } from "matrix-js-sdk/src/client"; import { SimpleObservable } from "matrix-widget-api"; import EventEmitter from "events"; -import { IEncryptedFile } from "matrix-js-sdk/src/@types/event"; import { logger } from "matrix-js-sdk/src/logger"; import MediaDeviceHandler from "../MediaDeviceHandler"; @@ -27,9 +25,7 @@ import { IDestroyable } from "../utils/IDestroyable"; import { Singleflight } from "../utils/Singleflight"; import { PayloadEvent, WORKLET_NAME } from "./consts"; import { UPDATE_EVENT } from "../stores/AsyncStore"; -import { Playback } from "./Playback"; import { createAudioContext } from "./compat"; -import { uploadFile } from "../ContentMessages"; import { FixedRollingArray } from "../utils/FixedRollingArray"; import { clamp } from "../utils/numbers"; import mxRecorderWorkletPath from "./RecorderWorklet"; @@ -55,11 +51,6 @@ export enum RecordingState { Uploaded = "uploaded", } -export interface IUpload { - mxc?: string; // for unencrypted uploads - encrypted?: IEncryptedFile; -} - export class VoiceRecording extends EventEmitter implements IDestroyable { private recorder: Recorder; private recorderContext: AudioContext; @@ -67,26 +58,16 @@ export class VoiceRecording extends EventEmitter implements IDestroyable { private recorderStream: MediaStream; private recorderWorklet: AudioWorkletNode; private recorderProcessor: ScriptProcessorNode; - private buffer = new Uint8Array(0); // use this.audioBuffer to access - private lastUpload: IUpload; private recording = false; private observable: SimpleObservable; - private amplitudes: number[] = []; // at each second mark, generated - private playback: Playback; + public amplitudes: number[] = []; // at each second mark, generated private liveWaveform = new FixedRollingArray(RECORDING_PLAYBACK_SAMPLES, 0); - - public constructor(private client: MatrixClient) { - super(); - } + public onDataAvailable: (data: ArrayBuffer) => void; public get contentType(): string { return "audio/ogg"; } - public get contentLength(): number { - return this.buffer.length; - } - public get durationSeconds(): number { if (!this.recorder) throw new Error("Duration not available without a recording"); return this.recorderContext.currentTime; @@ -165,13 +146,9 @@ export class VoiceRecording extends EventEmitter implements IDestroyable { encoderComplexity: 3, // 0-10, 10 is slow and high quality. resampleQuality: 3, // 0-10, 10 is slow and high quality }); - this.recorder.ondataavailable = (a: ArrayBuffer) => { - const buf = new Uint8Array(a); - const newBuf = new Uint8Array(this.buffer.length + buf.length); - newBuf.set(this.buffer, 0); - newBuf.set(buf, this.buffer.length); - this.buffer = newBuf; - }; + + // not using EventEmitter here because it leads to detached bufferes + this.recorder.ondataavailable = (data: ArrayBuffer) => this?.onDataAvailable(data); } catch (e) { logger.error("Error starting recording: ", e); if (e instanceof DOMException) { // Unhelpful DOMExceptions are common - parse them sanely @@ -191,12 +168,6 @@ export class VoiceRecording extends EventEmitter implements IDestroyable { } } - private get audioBuffer(): Uint8Array { - // We need a clone of the buffer to avoid accidentally changing the position - // on the real thing. - return this.buffer.slice(0); - } - public get liveData(): SimpleObservable { if (!this.recording) throw new Error("No observable when not recording"); return this.observable; @@ -206,10 +177,6 @@ export class VoiceRecording extends EventEmitter implements IDestroyable { return !!Recorder.isRecordingSupported(); } - public get hasRecording(): boolean { - return this.buffer.length > 0; - } - private onAudioProcess = (ev: AudioProcessingEvent) => { this.processAudioUpdate(ev.playbackTime); @@ -236,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(); @@ -250,10 +215,14 @@ 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.lastUpload || this.hasRecording) { - throw new Error("Recording already prepared"); - } if (this.recording) { throw new Error("Recording already in progress"); } @@ -267,7 +236,7 @@ export class VoiceRecording extends EventEmitter implements IDestroyable { this.emit(RecordingState.Started); } - public async stop(): Promise { + public async stop(): Promise { return Singleflight.for(this, "stop").do(async () => { if (!this.recording) { throw new Error("No recording to stop"); @@ -293,54 +262,16 @@ export class VoiceRecording extends EventEmitter implements IDestroyable { this.recording = false; await this.recorder.close(); this.emit(RecordingState.Ended); - - return this.audioBuffer; }); } - /** - * Gets a playback instance for this voice recording. Note that the playback will not - * have been prepared fully, meaning the `prepare()` function needs to be called on it. - * - * The same playback instance is returned each time. - * - * @returns {Playback} The playback instance. - */ - public getPlayback(): Playback { - this.playback = Singleflight.for(this, "playback").do(() => { - return new Playback(this.audioBuffer.buffer, this.amplitudes); // cast to ArrayBuffer proper; - }); - return this.playback; - } - public destroy() { // noinspection JSIgnoredPromiseFromCall - not concerned about stop() being called async here this.stop(); this.removeAllListeners(); + this.onDataAvailable = undefined; Singleflight.forgetAllFor(this); // noinspection JSIgnoredPromiseFromCall - not concerned about being called async here - this.playback?.destroy(); this.observable.close(); } - - public async upload(inRoomId: string): Promise { - if (!this.hasRecording) { - throw new Error("No recording available to upload"); - } - - if (this.lastUpload) return this.lastUpload; - - try { - this.emit(RecordingState.Uploading); - const { url: mxc, file: encrypted } = await uploadFile(this.client, inRoomId, new Blob([this.audioBuffer], { - type: this.contentType, - })); - this.lastUpload = { mxc, encrypted }; - this.emit(RecordingState.Uploaded); - } catch (e) { - this.emit(RecordingState.Ended); - throw e; - } - return this.lastUpload; - } } diff --git a/src/components/atoms/Icon.tsx b/src/components/atoms/Icon.tsx new file mode 100644 index 00000000000..56d8236250d --- /dev/null +++ b/src/components/atoms/Icon.tsx @@ -0,0 +1,83 @@ +/* +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 liveIcon from "../../../res/img/element-icons/live.svg"; +import microphoneIcon from "../../../res/img/voip/call-view/mic-on.svg"; +import pauseIcon from "../../../res/img/element-icons/pause.svg"; +import playIcon from "../../../res/img/element-icons/play.svg"; +import stopIcon from "../../../res/img/element-icons/Stop.svg"; + +export enum IconType { + Live, + Microphone, + Pause, + Play, + Stop, +} + +const iconTypeMap = new Map([ + [IconType.Live, liveIcon], + [IconType.Microphone, microphoneIcon], + [IconType.Pause, pauseIcon], + [IconType.Play, playIcon], + [IconType.Stop, stopIcon], +]); + +export enum IconColour { + Accent = "accent", + LiveBadge = "live-badge", + CompoundSecondaryContent = "compound-secondary-content", +} + +export enum IconSize { + S16 = "16", +} + +interface IconProps { + colour?: IconColour; + size?: IconSize; + type: IconType; +} + +export const Icon: React.FC = ({ + size = IconSize.S16, + colour = IconColour.Accent, + type, + ...rest +}) => { + const classes = [ + "mx_Icon", + `mx_Icon_${size}`, + `mx_Icon_${colour}`, + ]; + + 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 c3f7d1c4347..b62c4b6d7be 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. @@ -558,8 +565,13 @@ type ContextMenuTuple = [ (val: boolean) => void, ]; // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-constraint -export const useContextMenu = (): ContextMenuTuple => { - const button = useRef(null); +export const useContextMenu = (inputRef?: RefObject): ContextMenuTuple => { + let button = useRef(null); + if (inputRef) { + // if we are given a ref, use it instead of ours + button = inputRef; + } + const [isOpen, setIsOpen] = useState(false); const open = (ev?: SyntheticEvent) => { ev?.preventDefault(); @@ -572,7 +584,7 @@ export const useContextMenu = (): ContextMenuTuple< setIsOpen(false); }; - return [isOpen, button, open, close, setIsOpen]; + return [button.current ? isOpen : false, button, open, close, setIsOpen]; }; // XXX: Deprecated, used only for dynamic Tooltips. Avoid using at all costs. diff --git a/src/components/structures/EmbeddedPage.tsx b/src/components/structures/EmbeddedPage.tsx index 2053140ba43..11f286edc24 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/LegacyGroupView.tsx b/src/components/structures/LegacyGroupView.tsx deleted file mode 100644 index 236c4f6ecde..00000000000 --- a/src/components/structures/LegacyGroupView.tsx +++ /dev/null @@ -1,51 +0,0 @@ -/* -Copyright 2020 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 * as React from "react"; - -import AutoHideScrollbar from './AutoHideScrollbar'; -import { _t } from "../../languageHandler"; -import SdkConfig, { DEFAULTS } from "../../SdkConfig"; - -interface IProps { - groupId: string; -} - -const LegacyGroupView: React.FC = ({ groupId }) => { - // XXX: Stealing classes from the HomePage component for CSS simplicity. - // XXX: Inline CSS because this is all temporary - const learnMoreUrl = SdkConfig.get().spaces_learn_more_url ?? DEFAULTS.spaces_learn_more_url; - return -
-

{ _t("That link is no longer supported") }

-

- { _t( - "You're trying to access a community link (%(groupId)s).
" + - "Communities are no longer supported and have been replaced by spaces." + - "Learn more about spaces here.", - { groupId }, - { - br: () =>
, - br2: () =>
, - a: (sub) => { sub }, - }, - ) } -

-
-
; -}; - -export default LegacyGroupView; diff --git a/src/components/structures/LoggedInView.tsx b/src/components/structures/LoggedInView.tsx index f38f821cee7..5b34c8602fe 100644 --- a/src/components/structures/LoggedInView.tsx +++ b/src/components/structures/LoggedInView.tsx @@ -70,7 +70,6 @@ import RightPanelStore from '../../stores/right-panel/RightPanelStore'; import { TimelineRenderingType } from "../../contexts/RoomContext"; import { KeyBindingAction } from "../../accessibility/KeyboardShortcuts"; import { SwitchSpacePayload } from "../../dispatcher/payloads/SwitchSpacePayload"; -import LegacyGroupView from "./LegacyGroupView"; import { IConfigOptions } from "../../IConfigOptions"; import LeftPanelLiveShareWarning from '../views/beacon/LeftPanelLiveShareWarning'; import { UserOnboardingPage } from '../views/user-onboarding/UserOnboardingPage'; @@ -106,8 +105,6 @@ interface IProps { justRegistered?: boolean; roomJustCreatedOpts?: IOpts; forceTimeline?: boolean; // see props on MatrixChat - - currentGroupId?: string; } interface IState { @@ -668,10 +665,6 @@ class LoggedInView extends React.Component { case PageTypes.UserView: pageElement = ; break; - - case PageTypes.LegacyGroupView: - pageElement = ; - break; } const wrapperClasses = classNames({ diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index 01e485e3042..31bfe71e6a4 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -1,5 +1,5 @@ /* -Copyright 2015-2021 The Matrix.org Foundation C.I.C. +Copyright 2015-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. @@ -138,6 +138,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"; @@ -189,8 +190,6 @@ interface IState { currentRoomId?: string; // If we're trying to just view a user ID (i.e. /user URL), this is it currentUserId?: string; - // Group ID for legacy "communities don't exist" page - currentGroupId?: string; // this is persisted as mx_lhs_size, loaded in LoggedInView collapseLhs: boolean; // Parameters used in the registration dance with the IS @@ -339,14 +338,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; + const restoreSuccess = await this.loadSession(); + if (restoreSuccess) { + return true; + } + if (firstScreen === 'login' || firstScreen === 'register' || - firstScreen === 'forgot_password') { + firstScreen === 'forgot_password' + ) { this.showScreenAfterLogin(); - return; } - return this.loadSession(); + return false; }); } @@ -472,7 +476,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(() => { @@ -492,6 +496,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 @@ -680,9 +685,10 @@ export default class MatrixChat extends React.PureComponent { } break; } - case 'view_legacy_group': - this.viewLegacyGroup(payload.groupId); + case Action.ViewUserDeviceSettings: { + viewUserDeviceSettings(SettingsStore.getValue("feature_new_device_manager")); break; + } case Action.ViewUserSettings: { const tabPayload = payload as OpenToTabPayload; Modal.createDialog(UserSettingsDialog, @@ -1024,16 +1030,6 @@ export default class MatrixChat extends React.PureComponent { }); } - private viewLegacyGroup(groupId: string) { - this.setStateForNewView({ - view: Views.LOGGED_IN, - currentRoomId: null, - currentGroupId: groupId, - }); - this.notifyNewScreen('group/' + groupId); - this.setPage(PageType.LegacyGroupView); - } - private async createRoom(defaultPublic = false, defaultName?: string, type?: RoomType) { const modal = Modal.createDialog(CreateRoomDialog, { type, @@ -1821,12 +1817,6 @@ export default class MatrixChat extends React.PureComponent { userId: userId, subAction: params.action, }); - } else if (screen.indexOf('group/') === 0) { - const groupId = screen.substring(6); - dis.dispatch({ - action: 'view_legacy_group', - groupId: groupId, - }); } else { logger.info("Ignoring showScreen for '%s'", screen); } diff --git a/src/components/structures/MessagePanel.tsx b/src/components/structures/MessagePanel.tsx index 94491212c1a..669e73ebfd2 100644 --- a/src/components/structures/MessagePanel.tsx +++ b/src/components/structures/MessagePanel.tsx @@ -25,6 +25,8 @@ import { logger } from 'matrix-js-sdk/src/logger'; import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state"; import { M_BEACON_INFO } from 'matrix-js-sdk/src/@types/beacon'; import { isSupportedReceiptType } from "matrix-js-sdk/src/utils"; +import { ReadReceipt } from 'matrix-js-sdk/src/models/read-receipt'; +import { ListenerMap } from 'matrix-js-sdk/src/models/typed-event-emitter'; import shouldHideEvent from '../../shouldHideEvent'; import { wantsDateSeparator } from '../../DateUtils'; @@ -58,6 +60,7 @@ import { IReadReceiptInfo } from "../views/rooms/ReadReceiptMarker"; import { haveRendererForEvent } from "../../events/EventTileFactory"; import { editorRoomKey } from "../../Editing"; import { hasThreadSummary } from "../../utils/EventUtils"; +import { VoiceBroadcastInfoEventType } from '../../voice-broadcast'; const CONTINUATION_MAX_INTERVAL = 5 * 60 * 1000; // 5 minutes const continuedTypes = [EventType.Sticker, EventType.RoomMessage]; @@ -136,7 +139,7 @@ interface IProps { showUrlPreview?: boolean; // event after which we should show a read marker - readMarkerEventId?: string; + readMarkerEventId?: string | null; // whether the read marker should be visible readMarkerVisible?: boolean; @@ -839,8 +842,13 @@ export default class MessagePanel extends React.Component { if (!room) { return null; } + + const receiptDestination: ReadReceipt> = this.context.threadId + ? room.getThread(this.context.threadId) + : room; + const receipts: IReadReceiptProps[] = []; - room.getReceiptsForEvent(event).forEach((r) => { + receiptDestination.getReceiptsForEvent(event).forEach((r) => { if ( !r.userId || !isSupportedReceiptType(r.type) || @@ -1097,11 +1105,20 @@ class CreationGrouper extends BaseGrouper { && (ev.getStateKey() !== createEvent.getSender() || ev.getContent()["membership"] !== "join")) { return false; } + + const eventType = ev.getType(); + // beacons are not part of room creation configuration // should be shown in timeline - if (M_BEACON_INFO.matches(ev.getType())) { + if (M_BEACON_INFO.matches(eventType)) { return false; } + + if (VoiceBroadcastInfoEventType === eventType) { + // always show voice broadcast info events in timeline + return false; + } + if (ev.isState() && ev.getSender() === createEvent.getSender()) { return true; } diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index f6a6ac96d15..d095f58e89a 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -24,14 +24,13 @@ import React, { createRef, ReactElement, ReactNode, RefObject, useContext } from import classNames from 'classnames'; import { IRecommendedVersion, NotificationCountType, Room, RoomEvent } from "matrix-js-sdk/src/models/room"; import { IThreadBundledRelationship, MatrixEvent, MatrixEventEvent } from "matrix-js-sdk/src/models/event"; -import { EventSubscription } from "fbemitter"; import { ISearchResults } from 'matrix-js-sdk/src/@types/search'; import { logger } from "matrix-js-sdk/src/logger"; import { EventTimeline } from 'matrix-js-sdk/src/models/event-timeline'; import { EventType } from 'matrix-js-sdk/src/@types/event'; import { RoomState, RoomStateEvent } from 'matrix-js-sdk/src/models/room-state'; import { EventTimelineSet } from "matrix-js-sdk/src/models/event-timeline-set"; -import { CallState, CallType, MatrixCall } from "matrix-js-sdk/src/webrtc/call"; +import { CallState, MatrixCall } from "matrix-js-sdk/src/webrtc/call"; import { throttle } from "lodash"; import { MatrixError } from 'matrix-js-sdk/src/http-api'; import { ClientEvent } from "matrix-js-sdk/src/client"; @@ -78,7 +77,7 @@ import EffectsOverlay from "../views/elements/EffectsOverlay"; import { containsEmoji } from '../../effects/utils'; import { CHAT_EFFECTS } from '../../effects'; import WidgetStore from "../../stores/WidgetStore"; -import { VideoRoomView } from "./VideoRoomView"; +import { CallView } from "../views/voip/CallView"; import { UPDATE_EVENT } from "../../stores/AsyncStore"; import Notifier from "../../Notifier"; import { showToast as showNotificationsToast } from "../../toasts/DesktopNotificationsToast"; @@ -123,6 +122,8 @@ import { RoomStatusBarUnsentMessages } from './RoomStatusBarUnsentMessages'; import { LargeLoader } from './LargeLoader'; import { VoiceBroadcastInfoEventType } from '../../voice-broadcast'; import { isVideoRoom } from '../../utils/video-rooms'; +import { CallStore, CallStoreEvent } from "../../stores/CallStore"; +import { Call } from "../../models/Call"; const DEBUG = false; let debuglog = function(msg: string) {}; @@ -152,7 +153,7 @@ interface IRoomProps extends MatrixClientProps { enum MainSplitContentType { Timeline, MaximisedWidget, - Video, // immersive voip + Call, } export interface IRoomState { room?: Room; @@ -180,6 +181,7 @@ export interface IRoomState { searchHighlights?: string[]; searchInProgress?: boolean; callState?: CallState; + activeCall: Call | null; canPeek: boolean; canSelfRedact: boolean; showApps: boolean; @@ -306,10 +308,11 @@ function LocalRoomView(props: LocalRoomViewProps): ReactElement { e2eStatus={E2EStatus.Normal} onAppsClick={null} appsShown={false} - onCallPlaced={null} excludedRightPanelPhaseButtons={[]} showButtons={false} enableRoomOptionsMenu={false} + viewingCall={false} + activeCall={null} />
@@ -357,10 +360,11 @@ function LocalRoomCreateLoader(props: ILocalRoomCreateLoaderProps): ReactElement e2eStatus={E2EStatus.Normal} onAppsClick={null} appsShown={false} - onCallPlaced={null} excludedRightPanelPhaseButtons={[]} showButtons={false} enableRoomOptionsMenu={false} + viewingCall={false} + activeCall={null} />
@@ -372,7 +376,6 @@ function LocalRoomCreateLoader(props: ILocalRoomCreateLoaderProps): ReactElement export class RoomView extends React.Component { private readonly dispatcherRef: string; - private readonly roomStoreToken: EventSubscription; private settingWatchers: string[]; private unmounted = false; @@ -400,6 +403,7 @@ export class RoomView extends React.Component { numUnreadMessages: 0, searchResults: null, callState: null, + activeCall: null, canPeek: false, canSelfRedact: false, showApps: false, @@ -450,13 +454,15 @@ export class RoomView extends React.Component { context.on(CryptoEvent.KeysChanged, this.onCrossSigningKeysChanged); context.on(MatrixEventEvent.Decrypted, this.onEventDecrypted); // Start listening for RoomViewStore updates - this.roomStoreToken = RoomViewStore.instance.addListener(this.onRoomViewStoreUpdate); + RoomViewStore.instance.on(UPDATE_EVENT, this.onRoomViewStoreUpdate); RightPanelStore.instance.on(UPDATE_EVENT, this.onRightPanelStoreUpdate); WidgetEchoStore.on(UPDATE_EVENT, this.onWidgetEchoStoreUpdate); WidgetStore.instance.on(UPDATE_EVENT, this.onWidgetStoreUpdate); + CallStore.instance.on(CallStoreEvent.ActiveCalls, this.onActiveCalls); + this.props.resizeNotifier.on("isResizing", this.onIsResizing); this.settingWatchers = [ @@ -530,13 +536,6 @@ export class RoomView extends React.Component { if (WidgetLayoutStore.instance.hasMaximisedWidget(this.state.room)) { // Show chat in right panel when a widget is maximised RightPanelStore.instance.setCard({ phase: RightPanelPhases.Timeline }); - } else if ( - RightPanelStore.instance.isOpen && - RightPanelStore.instance.roomPhaseHistory.some(card => (card.phase === RightPanelPhases.Timeline)) - ) { - // hide chat in right panel when the widget is minimized - RightPanelStore.instance.setCard({ phase: RightPanelPhases.RoomSummary }); - RightPanelStore.instance.togglePanel(this.state.roomId); } this.checkWidgets(this.state.room); }; @@ -550,8 +549,11 @@ export class RoomView extends React.Component { }; private getMainSplitContentType = (room: Room) => { - if (SettingsStore.getValue("feature_video_rooms") && isVideoRoom(room)) { - return MainSplitContentType.Video; + if ( + (SettingsStore.getValue("feature_group_calls") && RoomViewStore.instance.isViewingCall()) + || isVideoRoom(room) + ) { + return MainSplitContentType.Call; } if (WidgetLayoutStore.instance.hasMaximisedWidget(room)) { return MainSplitContentType.MaximisedWidget; @@ -580,6 +582,7 @@ export class RoomView extends React.Component { } const roomId = RoomViewStore.instance.getRoomId(); + const room = this.context.getRoom(roomId); // This convoluted type signature ensures we get IntelliSense *and* correct typing const newState: Partial & Pick = { @@ -597,13 +600,27 @@ export class RoomView extends React.Component { showAvatarChanges: SettingsStore.getValue("showAvatarChanges", roomId), showDisplaynameChanges: SettingsStore.getValue("showDisplaynameChanges", roomId), wasContextSwitch: RoomViewStore.instance.getWasContextSwitch(), + mainSplitContentType: room === null ? undefined : this.getMainSplitContentType(room), initialEventId: null, // default to clearing this, will get set later in the method if needed showRightPanel: RightPanelStore.instance.isOpenForRoom(roomId), + activeCall: CallStore.instance.getActiveCall(roomId), }; + if ( + this.state.mainSplitContentType !== MainSplitContentType.Timeline + && newState.mainSplitContentType === MainSplitContentType.Timeline + && RightPanelStore.instance.isOpen + && RightPanelStore.instance.currentCard.phase === RightPanelPhases.Timeline + && RightPanelStore.instance.roomPhaseHistory.some(card => (card.phase === RightPanelPhases.Timeline)) + ) { + // We're returning to the main timeline, so hide the right panel timeline + RightPanelStore.instance.setCard({ phase: RightPanelPhases.RoomSummary }); + RightPanelStore.instance.togglePanel(this.state.roomId ?? null); + newState.showRightPanel = false; + } + const initialEventId = RoomViewStore.instance.getInitialEventId(); if (initialEventId) { - const room = this.context.getRoom(roomId); let initialEvent = room?.findEventById(initialEventId); // The event does not exist in the current sync data // We need to fetch it to know whether to route this request @@ -729,6 +746,23 @@ export class RoomView extends React.Component { } }; + private onActiveCalls = () => { + if (this.state.roomId === undefined) return; + const activeCall = CallStore.instance.getActiveCall(this.state.roomId); + + if (activeCall === null) { + // We disconnected from the call, so stop viewing it + dis.dispatch({ + action: Action.ViewRoom, + room_id: this.state.roomId, + view_call: false, + metricsTrigger: undefined, + }, true); // Synchronous so that CallView disappears immediately + } + + this.setState({ activeCall }); + }; + private getRoomId = () => { // According to `onRoomViewStoreUpdate`, `state.roomId` can be null // if we have a room alias we haven't resolved yet. To work around this, @@ -929,10 +963,7 @@ export class RoomView extends React.Component { this.props.resizeNotifier.removeListener("middlePanelResized", this.onResize); } - // Remove RoomStore listener - if (this.roomStoreToken) { - this.roomStoreToken.remove(); - } + RoomViewStore.instance.off(UPDATE_EVENT, this.onRoomViewStoreUpdate); RightPanelStore.instance.off(UPDATE_EVENT, this.onRightPanelStoreUpdate); WidgetEchoStore.removeListener(UPDATE_EVENT, this.onWidgetEchoStoreUpdate); @@ -947,6 +978,7 @@ export class RoomView extends React.Component { ); } + CallStore.instance.off(CallStoreEvent.ActiveCalls, this.onActiveCalls); LegacyCallHandler.instance.off(LegacyCallHandlerEvent.CallState, this.onCallState); // cancel any pending calls to the throttled updated @@ -1756,10 +1788,6 @@ export class RoomView extends React.Component { return ret; } - private onCallPlaced = (type: CallType): void => { - LegacyCallHandler.instance.placeCall(this.state.room?.roomId, type); - }; - private onAppsClick = () => { dis.dispatch({ action: "appsDrawer", @@ -2454,7 +2482,7 @@ export class RoomView extends React.Component { const mainClasses = classNames("mx_RoomView", { mx_RoomView_inCall: Boolean(activeCall), - mx_RoomView_immersive: this.state.mainSplitContentType === MainSplitContentType.Video, + mx_RoomView_immersive: this.state.mainSplitContentType !== MainSplitContentType.Timeline, }); const showChatEffects = SettingsStore.getValue('showChatEffects'); @@ -2495,10 +2523,14 @@ export class RoomView extends React.Component { { previewBar } ; break; - case MainSplitContentType.Video: { - mainSplitContentClassName = "mx_MainSplit_video"; + case MainSplitContentType.Call: { + mainSplitContentClassName = "mx_MainSplit_call"; mainSplitBody = <> - + { previewBar } ; } @@ -2506,11 +2538,11 @@ export class RoomView extends React.Component { const mainSplitContentClasses = classNames("mx_RoomView_body", mainSplitContentClassName); let excludedRightPanelPhaseButtons = [RightPanelPhases.Timeline]; - let onCallPlaced = this.onCallPlaced; let onAppsClick = this.onAppsClick; let onForgetClick = this.onForgetClick; let onSearchClick = this.onSearchClick; let onInviteClick = null; + let viewingCall = false; // Simplify the header for other main split types switch (this.state.mainSplitContentType) { @@ -2523,19 +2555,25 @@ export class RoomView extends React.Component { onForgetClick = null; onSearchClick = null; break; - case MainSplitContentType.Video: + case MainSplitContentType.Call: excludedRightPanelPhaseButtons = [ RightPanelPhases.ThreadPanel, RightPanelPhases.PinnedMessages, RightPanelPhases.NotificationPanel, ]; - onCallPlaced = null; + if (!isVideoRoom(this.state.room)) { + excludedRightPanelPhaseButtons.push(RightPanelPhases.RoomSummary); + if (this.state.activeCall === null) { + excludedRightPanelPhaseButtons.push(RightPanelPhases.Timeline); + } + } onAppsClick = null; onForgetClick = null; onSearchClick = null; if (this.state.room.canInvite(this.context.credentials.userId)) { onInviteClick = this.onInviteClick; } + viewingCall = true; } return ( @@ -2556,10 +2594,11 @@ export class RoomView extends React.Component { e2eStatus={this.state.e2eStatus} onAppsClick={this.state.hasPinnedWidgets ? onAppsClick : null} appsShown={this.state.showApps} - onCallPlaced={onCallPlaced} excludedRightPanelPhaseButtons={excludedRightPanelPhaseButtons} showButtons={!this.viewsLocalRoom} enableRoomOptionsMenu={!this.viewsLocalRoom} + viewingCall={viewingCall} + activeCall={this.state.activeCall} />
diff --git a/src/components/structures/ThreadPanel.tsx b/src/components/structures/ThreadPanel.tsx index 0a073938635..245c604c0af 100644 --- a/src/components/structures/ThreadPanel.tsx +++ b/src/components/structures/ThreadPanel.tsx @@ -33,7 +33,6 @@ import Measured from '../views/elements/Measured'; import PosthogTrackers from "../../PosthogTrackers"; import AccessibleButton, { ButtonEvent } from "../views/elements/AccessibleButton"; import { BetaPill } from '../views/beta/BetaCard'; -import SdkConfig from '../../SdkConfig'; import Modal from '../../Modal'; import BetaFeedbackDialog from '../views/dialogs/BetaFeedbackDialog'; import { Action } from '../../dispatcher/actions'; @@ -41,6 +40,7 @@ import { UserTab } from '../views/dialogs/UserTab'; import dis from '../../dispatcher/dispatcher'; import Spinner from "../views/elements/Spinner"; import Heading from '../views/typography/Heading'; +import { shouldShowFeedback } from "../../utils/Feedback"; interface IProps { roomId: string; @@ -234,7 +234,7 @@ const ThreadPanel: React.FC = ({ } }, [timelineSet, timelinePanel]); - const openFeedback = SdkConfig.get().bug_report_endpoint_url ? () => { + const openFeedback = shouldShowFeedback() ? () => { Modal.createDialog(BetaFeedbackDialog, { featureId: "feature_thread", }); @@ -282,10 +282,10 @@ const ThreadPanel: React.FC = ({ ? { { disableGrouping: false, }; - private lastRRSentEventId: string = undefined; - private lastRMSentEventId: string = undefined; + private lastRRSentEventId: string | null | undefined = undefined; + private lastRMSentEventId: string | null | undefined = undefined; private readonly messagePanel = createRef(); private readonly dispatcherRef: string; @@ -261,7 +262,7 @@ class TimelinePanel extends React.Component { // XXX: we could track RM per TimelineSet rather than per Room. // but for now we just do it per room for simplicity. - let initialReadMarker = null; + let initialReadMarker: string | null = null; if (this.props.manageReadMarkers) { const readmarker = this.props.timelineSet.room.getAccountData('m.fully_read'); if (readmarker) { @@ -953,13 +954,13 @@ class TimelinePanel extends React.Component { if (lastReadEventIndex === null) { shouldSendRR = false; } - let lastReadEvent = this.state.events[lastReadEventIndex]; + let lastReadEvent: MatrixEvent | null = this.state.events[lastReadEventIndex ?? 0]; shouldSendRR = shouldSendRR && // Only send a RR if the last read event is ahead in the timeline relative to // the current RR event. lastReadEventIndex > currentRREventIndex && // Only send a RR if the last RR set != the one we would send - this.lastRRSentEventId != lastReadEvent.getId(); + this.lastRRSentEventId !== lastReadEvent?.getId(); // Only send a RM if the last RM sent != the one we would send const shouldSendRM = @@ -969,7 +970,7 @@ class TimelinePanel extends React.Component { // same one at the server repeatedly if (shouldSendRR || shouldSendRM) { if (shouldSendRR) { - this.lastRRSentEventId = lastReadEvent.getId(); + this.lastRRSentEventId = lastReadEvent?.getId(); } else { lastReadEvent = null; } @@ -985,48 +986,58 @@ class TimelinePanel extends React.Component { `prr=${lastReadEvent?.getId()}`, ); - MatrixClientPeg.get().setRoomReadMarkers( - roomId, - this.state.readMarkerEventId, - sendRRs ? lastReadEvent : null, // Public read receipt (could be null) - lastReadEvent, // Private read receipt (could be null) - ).catch(async (e) => { - // /read_markers API is not implemented on this HS, fallback to just RR - if (e.errcode === 'M_UNRECOGNIZED' && lastReadEvent) { - if ( - !sendRRs - && !MatrixClientPeg.get().doesServerSupportUnstableFeature("org.matrix.msc2285.stable") - ) return; - - try { - return await MatrixClientPeg.get().sendReadReceipt( - lastReadEvent, - sendRRs ? ReceiptType.Read : ReceiptType.ReadPrivate, - ); - } catch (error) { + + if (this.props.timelineSet.thread && sendRRs && lastReadEvent) { + // There's no support for fully read markers on threads + // as defined by MSC3771 + cli.sendReadReceipt( + lastReadEvent, + sendRRs ? ReceiptType.Read : ReceiptType.ReadPrivate, + ); + } else { + cli.setRoomReadMarkers( + roomId, + this.state.readMarkerEventId ?? "", + sendRRs ? (lastReadEvent ?? undefined) : undefined, // Public read receipt (could be null) + lastReadEvent ?? undefined, // Private read receipt (could be null) + ).catch(async (e) => { + // /read_markers API is not implemented on this HS, fallback to just RR + if (e.errcode === 'M_UNRECOGNIZED' && lastReadEvent) { + if ( + !sendRRs + && !(await cli.doesServerSupportUnstableFeature("org.matrix.msc2285.stable")) + && !(await cli.isVersionSupported("v1.4")) + ) return; + try { + return await cli.sendReadReceipt( + lastReadEvent, + sendRRs ? ReceiptType.Read : ReceiptType.ReadPrivate, + ); + } catch (error) { + logger.error(e); + this.lastRRSentEventId = undefined; + } + } else { logger.error(e); - this.lastRRSentEventId = undefined; } - } else { - logger.error(e); - } - // it failed, so allow retries next time the user is active - this.lastRRSentEventId = undefined; - this.lastRMSentEventId = undefined; - }); - - // do a quick-reset of our unreadNotificationCount to avoid having - // to wait from the remote echo from the homeserver. - // we only do this if we're right at the end, because we're just assuming - // that sending an RR for the latest message will set our notif counter - // to zero: it may not do this if we send an RR for somewhere before the end. - if (this.isAtEndOfLiveTimeline()) { - this.props.timelineSet.room.setUnreadNotificationCount(NotificationCountType.Total, 0); - this.props.timelineSet.room.setUnreadNotificationCount(NotificationCountType.Highlight, 0); - dis.dispatch({ - action: 'on_room_read', - roomId: this.props.timelineSet.room.roomId, + // it failed, so allow retries next time the user is active + this.lastRRSentEventId = undefined; + this.lastRMSentEventId = undefined; }); + + // do a quick-reset of our unreadNotificationCount to avoid having + // to wait from the remote echo from the homeserver. + // we only do this if we're right at the end, because we're just assuming + // that sending an RR for the latest message will set our notif counter + // to zero: it may not do this if we send an RR for somewhere before the end. + if (this.isAtEndOfLiveTimeline()) { + this.props.timelineSet.room.setUnreadNotificationCount(NotificationCountType.Total, 0); + this.props.timelineSet.room.setUnreadNotificationCount(NotificationCountType.Highlight, 0); + dis.dispatch({ + action: 'on_room_read', + roomId: this.props.timelineSet.room.roomId, + }); + } } } }; @@ -1174,7 +1185,7 @@ class TimelinePanel extends React.Component { const rmId = this.getCurrentReadReceipt(); // Look up the timestamp if we can find it - const tl = this.props.timelineSet.getTimelineForEvent(rmId); + const tl = this.props.timelineSet.getTimelineForEvent(rmId ?? ""); let rmTs: number; if (tl) { const event = tl.getEvents().find((e) => { return e.getId() == rmId; }); @@ -1377,7 +1388,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; @@ -1580,7 +1591,8 @@ class TimelinePanel extends React.Component { return 0; } - private indexForEventId(evId: string): number | null { + private indexForEventId(evId: string | null): number | null { + if (evId === null) { return null; } /* Threads do not have server side support for read receipts and the concept is very tied to the main room timeline, we are forcing the timeline to send read receipts for threaded events */ @@ -1681,7 +1693,7 @@ class TimelinePanel extends React.Component { * SDK. * @return {String} the event ID */ - private getCurrentReadReceipt(ignoreSynthesized = false): string { + private getCurrentReadReceipt(ignoreSynthesized = false): string | null { const client = MatrixClientPeg.get(); // the client can be null on logout if (client == null) { @@ -1689,21 +1701,23 @@ class TimelinePanel extends React.Component { } const myUserId = client.credentials.userId; - return this.props.timelineSet.room.getEventReadUpTo(myUserId, ignoreSynthesized); + const receiptStore: ReadReceipt = + this.props.timelineSet.thread ?? this.props.timelineSet.room; + return receiptStore?.getEventReadUpTo(myUserId, ignoreSynthesized); } - private setReadMarker(eventId: string, eventTs: number, inhibitSetState = false): void { - const roomId = this.props.timelineSet.room.roomId; + private setReadMarker(eventId: string | null, eventTs: number, inhibitSetState = false): void { + const roomId = this.props.timelineSet.room?.roomId; // don't update the state (and cause a re-render) if there is // no change to the RM. - if (eventId === this.state.readMarkerEventId) { + if (eventId === this.state.readMarkerEventId || eventId === null) { return; } // in order to later figure out if the read marker is // above or below the visible timeline, we stash the timestamp. - TimelinePanel.roomReadMarkerTsMap[roomId] = eventTs; + TimelinePanel.roomReadMarkerTsMap[roomId ?? ""] = eventTs; if (inhibitSetState) { return; diff --git a/src/components/structures/UploadBar.tsx b/src/components/structures/UploadBar.tsx index b2c7544f1fd..673afb1b134 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/VideoRoomView.tsx b/src/components/structures/VideoRoomView.tsx deleted file mode 100644 index d08ee53a468..00000000000 --- a/src/components/structures/VideoRoomView.tsx +++ /dev/null @@ -1,59 +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. -*/ - -import React, { FC, useContext, useEffect } from "react"; - -import type { Room } from "matrix-js-sdk/src/models/room"; -import type { Call } from "../../models/Call"; -import { useCall, useConnectionState } from "../../hooks/useCall"; -import { isConnected } from "../../models/Call"; -import MatrixClientContext from "../../contexts/MatrixClientContext"; -import AppTile from "../views/elements/AppTile"; -import { CallLobby } from "../views/voip/CallLobby"; - -interface Props { - room: Room; - resizing: boolean; -} - -const LoadedVideoRoomView: FC = ({ room, resizing, call }) => { - const cli = useContext(MatrixClientContext); - const connected = isConnected(useConnectionState(call)); - - // We'll take this opportunity to tidy up our room state - useEffect(() => { call?.clean(); }, [call]); - - if (!call) return null; - - return
- { connected ? null : } - { /* We render the widget even if we're disconnected, so it stays loaded */ } - -
; -}; - -export const VideoRoomView: FC = ({ room, resizing }) => { - const call = useCall(room.roomId); - return call ? : null; -}; diff --git a/src/components/structures/auth/Login.tsx b/src/components/structures/auth/Login.tsx index c00aa909d22..c9fc7e001d9 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/audio_messages/LiveRecordingClock.tsx b/src/components/views/audio_messages/LiveRecordingClock.tsx index 34e4c559fec..10005d8b9aa 100644 --- a/src/components/views/audio_messages/LiveRecordingClock.tsx +++ b/src/components/views/audio_messages/LiveRecordingClock.tsx @@ -16,12 +16,13 @@ limitations under the License. import React from "react"; -import { IRecordingUpdate, VoiceRecording } from "../../../audio/VoiceRecording"; +import { IRecordingUpdate } from "../../../audio/VoiceRecording"; import Clock from "./Clock"; import { MarkedExecution } from "../../../utils/MarkedExecution"; +import { VoiceMessageRecording } from "../../../audio/VoiceMessageRecording"; interface IProps { - recorder: VoiceRecording; + recorder: VoiceMessageRecording; } interface IState { diff --git a/src/components/views/audio_messages/LiveRecordingWaveform.tsx b/src/components/views/audio_messages/LiveRecordingWaveform.tsx index c9c122c98ac..3a546c2a6d5 100644 --- a/src/components/views/audio_messages/LiveRecordingWaveform.tsx +++ b/src/components/views/audio_messages/LiveRecordingWaveform.tsx @@ -16,13 +16,14 @@ limitations under the License. import React from "react"; -import { IRecordingUpdate, RECORDING_PLAYBACK_SAMPLES, VoiceRecording } from "../../../audio/VoiceRecording"; +import { IRecordingUpdate, RECORDING_PLAYBACK_SAMPLES } from "../../../audio/VoiceRecording"; import { arrayFastResample, arraySeed } from "../../../utils/arrays"; import Waveform from "./Waveform"; import { MarkedExecution } from "../../../utils/MarkedExecution"; +import { VoiceMessageRecording } from "../../../audio/VoiceMessageRecording"; interface IProps { - recorder: VoiceRecording; + recorder: VoiceMessageRecording; } interface IState { diff --git a/src/components/views/auth/InteractiveAuthEntryComponents.tsx b/src/components/views/auth/InteractiveAuthEntryComponents.tsx index b33e3d37477..ecddd435b9e 100644 --- a/src/components/views/auth/InteractiveAuthEntryComponents.tsx +++ b/src/components/views/auth/InteractiveAuthEntryComponents.tsx @@ -493,7 +493,6 @@ export class EmailIdentityAuthEntry extends ? _t("Resent!") : _t("Resend")} alignment={Alignment.Right} - tooltipClassName="mx_Tooltip_noMargin" onHideTooltip={this.state.requested ? () => this.setState({ requested: false }) : undefined} diff --git a/src/components/views/beacon/RoomCallBanner.tsx b/src/components/views/beacon/RoomCallBanner.tsx new file mode 100644 index 00000000000..736c88649f1 --- /dev/null +++ b/src/components/views/beacon/RoomCallBanner.tsx @@ -0,0 +1,131 @@ +/* +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, { useCallback } from "react"; +import { MatrixEvent, Room } from "matrix-js-sdk/src/matrix"; + +import { _t } from "../../../languageHandler"; +import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton"; +import dispatcher, { defaultDispatcher } from "../../../dispatcher/dispatcher"; +import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; +import { Action } from "../../../dispatcher/actions"; +import { Call, ConnectionState, ElementCall } from "../../../models/Call"; +import { useCall } from "../../../hooks/useCall"; +import { RoomViewStore } from "../../../stores/RoomViewStore"; +import { useEventEmitterState } from "../../../hooks/useEventEmitter"; +import { + OwnBeaconStore, + OwnBeaconStoreEvent, +} from "../../../stores/OwnBeaconStore"; +import { CallDurationFromEvent } from "../voip/CallDuration"; + +interface RoomCallBannerProps { + roomId: Room["roomId"]; + call: Call; +} + +const RoomCallBannerInner: React.FC = ({ + roomId, + call, +}) => { + const callEvent: MatrixEvent | null = (call as ElementCall)?.groupCall; + + const connect = useCallback( + (ev: ButtonEvent) => { + ev.preventDefault(); + defaultDispatcher.dispatch({ + action: Action.ViewRoom, + room_id: roomId, + view_call: true, + metricsTrigger: undefined, + }); + }, + [roomId], + ); + + const onClick = useCallback(() => { + dispatcher.dispatch({ + action: Action.ViewRoom, + room_id: roomId, + metricsTrigger: undefined, + event_id: callEvent.getId(), + scroll_into_view: true, + highlighted: true, + }); + }, [callEvent, roomId]); + + return ( +
+
+ { _t("Video call") } + +
+ + + { _t("Join") } + +
+ ); +}; + +interface Props { + roomId: Room["roomId"]; +} + +const RoomCallBanner: React.FC = ({ roomId }) => { + const call = useCall(roomId); + + // this section is to check if we have a live location share. If so, we dont show the call banner + const isMonitoringLiveLocation = useEventEmitterState( + OwnBeaconStore.instance, + OwnBeaconStoreEvent.MonitoringLivePosition, + () => OwnBeaconStore.instance.isMonitoringLiveLocation, + ); + + const liveBeaconIds = useEventEmitterState( + OwnBeaconStore.instance, + OwnBeaconStoreEvent.LivenessChange, + () => OwnBeaconStore.instance.getLiveBeaconIds(roomId), + ); + + if (isMonitoringLiveLocation && liveBeaconIds.length) { + return null; + } + + // Check if the call is already showing. No banner is needed in this case. + if (RoomViewStore.instance.isViewingCall()) { + return null; + } + + // Split into outer/inner to avoid watching various parts if there is no call + if (call) { + // No banner if the call is connected (or connecting/disconnecting) + if (call.connectionState !== ConnectionState.Disconnected) return null; + + return ; + } + return null; +}; + +export default RoomCallBanner; diff --git a/src/components/views/beacon/RoomLiveShareWarning.tsx b/src/components/views/beacon/RoomLiveShareWarning.tsx index f5b5c1a720d..ca94ea98f3f 100644 --- a/src/components/views/beacon/RoomLiveShareWarning.tsx +++ b/src/components/views/beacon/RoomLiveShareWarning.tsx @@ -141,6 +141,7 @@ const RoomLiveShareWarning: React.FC = ({ roomId }) => { ); if (!isMonitoringLiveLocation || !liveBeaconIds.length) { + // This logic is entangled with the RoomCallBanner-test's. The tests need updating if this logic changes. return null; } diff --git a/src/components/views/beta/BetaCard.tsx b/src/components/views/beta/BetaCard.tsx index 3db52a0b69d..82f00e16977 100644 --- a/src/components/views/beta/BetaCard.tsx +++ b/src/components/views/beta/BetaCard.tsx @@ -28,6 +28,7 @@ import SettingsFlag from "../elements/SettingsFlag"; import { useFeatureEnabled } from "../../../hooks/useSettings"; import InlineSpinner from "../elements/InlineSpinner"; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; +import { shouldShowFeedback } from "../../../utils/Feedback"; // XXX: Keep this around for re-use in future Betas @@ -88,7 +89,7 @@ const BetaCard = ({ title: titleOverride, featureId }: IProps) => { } = info; let feedbackButton; - if (value && feedbackLabel && feedbackSubheading && SdkConfig.get().bug_report_endpoint_url) { + if (value && feedbackLabel && feedbackSubheading && shouldShowFeedback()) { feedbackButton = { Modal.createDialog(BetaFeedbackDialog, { featureId }); diff --git a/src/components/views/context_menus/IconizedContextMenu.tsx b/src/components/views/context_menus/IconizedContextMenu.tsx index 9b7896790ef..ad8d97edd4d 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 00000000000..f385cc3c5ec --- /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/context_menus/MessageContextMenu.tsx b/src/components/views/context_menus/MessageContextMenu.tsx index 9452fa28268..eca720412d2 100644 --- a/src/components/views/context_menus/MessageContextMenu.tsx +++ b/src/components/views/context_menus/MessageContextMenu.tsx @@ -73,7 +73,7 @@ const ReplyInThreadButton = ({ mxEvent, closeMenu }: IReplyInThreadButton) => { const relationType = mxEvent?.getRelation()?.rel_type; // Can't create a thread from an event with an existing relation - if (Boolean(relationType) && relationType !== RelationType.Thread) return; + if (Boolean(relationType) && relationType !== RelationType.Thread) return null; const onClick = (): void => { if (!localStorage.getItem("mx_seen_feature_thread")) { diff --git a/src/components/views/dialogs/ChangelogDialog.tsx b/src/components/views/dialogs/ChangelogDialog.tsx index f759f043005..da5ea5d4902 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 178c521bdcb..30c1f4d1544 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 , "RoomSettingsGeneral", )); + if (SettingsStore.getValue("feature_group_calls")) { + tabs.push(new Tab( + ROOM_VOIP_TAB, + _td("Voice & Video"), + "mx_RoomSettingsDialog_voiceIcon", + , + )); + } tabs.push(new Tab( ROOM_SECURITY_TAB, _td("Security & Privacy"), diff --git a/src/components/views/dialogs/SlidingSyncOptionsDialog.tsx b/src/components/views/dialogs/SlidingSyncOptionsDialog.tsx index ed793231b21..ea5c77d7f71 100644 --- a/src/components/views/dialogs/SlidingSyncOptionsDialog.tsx +++ b/src/components/views/dialogs/SlidingSyncOptionsDialog.tsx @@ -15,7 +15,7 @@ limitations under the License. */ import React from 'react'; -import { MatrixClient } from 'matrix-js-sdk/src/matrix'; +import { MatrixClient, Method } from 'matrix-js-sdk/src/matrix'; import { logger } from 'matrix-js-sdk/src/logger'; import { _t } from '../../../languageHandler'; @@ -33,17 +33,10 @@ import { SettingLevel } from "../../../settings/SettingLevel"; * @throws if the proxy server is unreachable or not configured to the given homeserver */ async function syncHealthCheck(cli: MatrixClient): Promise { - 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/dialogs/spotlight/SpotlightDialog.tsx b/src/components/views/dialogs/spotlight/SpotlightDialog.tsx index 85f4e6ed500..b04299869c1 100644 --- a/src/components/views/dialogs/spotlight/SpotlightDialog.tsx +++ b/src/components/views/dialogs/spotlight/SpotlightDialog.tsx @@ -60,7 +60,6 @@ import Modal from "../../../../Modal"; import { PosthogAnalytics } from "../../../../PosthogAnalytics"; import { getCachedRoomIDForAlias } from "../../../../RoomAliasCache"; import { showStartChatInviteDialog } from "../../../../RoomInvite"; -import SdkConfig from "../../../../SdkConfig"; import { SettingLevel } from "../../../../settings/SettingLevel"; import SettingsStore from "../../../../settings/SettingsStore"; import { BreadcrumbsStore } from "../../../../stores/BreadcrumbsStore"; @@ -93,6 +92,7 @@ import { RoomContextDetails } from "../../rooms/RoomContextDetails"; import { TooltipOption } from "./TooltipOption"; import { isLocalRoom } from "../../../../utils/localRoom/isLocalRoom"; import { useSlidingSyncRoomSearch } from "../../../../hooks/useSlidingSyncRoomSearch"; +import { shouldShowFeedback } from "../../../../utils/Feedback"; const MAX_RECENT_SEARCHES = 10; const SECTION_LIMIT = 50; // only show 50 results per section for performance reasons @@ -1171,7 +1171,7 @@ const SpotlightDialog: React.FC = ({ initialText = "", initialFilter = n } }; - const openFeedback = SdkConfig.get().bug_report_endpoint_url ? () => { + const openFeedback = shouldShowFeedback() ? () => { Modal.createDialog(FeedbackDialog, { feature: "spotlight", }); diff --git a/src/components/views/elements/AccessibleButton.tsx b/src/components/views/elements/AccessibleButton.tsx index c90293aff4e..7036575cd1e 100644 --- a/src/components/views/elements/AccessibleButton.tsx +++ b/src/components/views/elements/AccessibleButton.tsx @@ -26,6 +26,7 @@ type AccessibleButtonKind = | 'primary' | 'primary_outline' | 'primary_sm' | 'secondary' + | 'content_inline' | 'danger' | 'danger_outline' | 'danger_sm' @@ -71,7 +72,7 @@ type IProps = DynamicHtmlElementProps disabled?: boolean; className?: string; triggerOnMouseDown?: boolean; - onClick(e?: ButtonEvent): void | Promise; + onClick: ((e: ButtonEvent) => void | Promise) | null; }; interface IAccessibleButtonProps extends React.InputHTMLAttributes { @@ -105,9 +106,9 @@ export default function AccessibleButton( newProps["disabled"] = true; } else { if (triggerOnMouseDown) { - newProps.onMouseDown = onClick; + newProps.onMouseDown = onClick ?? undefined; } else { - newProps.onClick = onClick; + newProps.onClick = onClick ?? undefined; } // We need to consume enter onKeyDown and space onKeyUp // otherwise we are risking also activating other keyboard focusable elements @@ -123,7 +124,7 @@ export default function AccessibleButton( case KeyBindingAction.Enter: e.stopPropagation(); e.preventDefault(); - return onClick(e); + return onClick?.(e); case KeyBindingAction.Space: e.stopPropagation(); e.preventDefault(); @@ -143,7 +144,7 @@ export default function AccessibleButton( case KeyBindingAction.Space: e.stopPropagation(); e.preventDefault(); - return onClick(e); + return onClick?.(e); default: onKeyUp?.(e); break; diff --git a/src/components/views/elements/AccessibleTooltipButton.tsx b/src/components/views/elements/AccessibleTooltipButton.tsx index 0f52879cc8d..8fd3a17096d 100644 --- a/src/components/views/elements/AccessibleTooltipButton.tsx +++ b/src/components/views/elements/AccessibleTooltipButton.tsx @@ -21,7 +21,7 @@ import AccessibleButton from "./AccessibleButton"; import Tooltip, { Alignment } from './Tooltip'; interface IProps extends React.ComponentProps { - title: string; + title?: string; tooltip?: React.ReactNode; label?: string; tooltipClassName?: string; @@ -78,7 +78,7 @@ export default class AccessibleTooltipButton extends React.PureComponent { children } { this.props.label } diff --git a/src/components/views/elements/LabelledToggleSwitch.tsx b/src/components/views/elements/LabelledToggleSwitch.tsx index 6df972440a9..eb251d1bd62 100644 --- a/src/components/views/elements/LabelledToggleSwitch.tsx +++ b/src/components/views/elements/LabelledToggleSwitch.tsx @@ -18,12 +18,17 @@ import React from "react"; import classNames from "classnames"; import ToggleSwitch from "./ToggleSwitch"; +import { Caption } from "../typography/Caption"; interface IProps { // The value for the toggle switch value: boolean; // The translated label for the switch label: string; + // The translated caption for the switch + caption?: string; + // Tooltip to display + tooltip?: string; // Whether or not to disable the toggle switch disabled?: boolean; // True to put the toggle in front of the label @@ -38,13 +43,20 @@ interface IProps { export default class LabelledToggleSwitch extends React.PureComponent { public render() { // This is a minimal version of a SettingsFlag - - let firstPart = { this.props.label }; + const { label, caption } = this.props; + let firstPart = + { label } + { caption && <> +
+ { caption } + } +
; let secondPart = ; if (this.props.toggleInFront) { @@ -57,7 +69,7 @@ export default class LabelledToggleSwitch extends React.PureComponent { "mx_SettingsFlag_toggleInFront": this.props.toggleInFront, }); return ( -
+
{ firstPart } { secondPart }
diff --git a/src/components/views/elements/LanguageDropdown.tsx b/src/components/views/elements/LanguageDropdown.tsx index cf1dfedcce7..06ffb232aa8 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 7e744591ec3..2e19a616e59 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/elements/SettingsFlag.tsx b/src/components/views/elements/SettingsFlag.tsx index e369b29c183..76348342a9b 100644 --- a/src/components/views/elements/SettingsFlag.tsx +++ b/src/components/views/elements/SettingsFlag.tsx @@ -114,7 +114,7 @@ export default class SettingsFlag extends React.Component { checked={this.state.value} onChange={this.onChange} disabled={this.props.disabled || !canChange} - aria-label={label} + title={label} />
); diff --git a/src/components/views/elements/ToggleSwitch.tsx b/src/components/views/elements/ToggleSwitch.tsx index f56633786a9..6a95b5d9a09 100644 --- a/src/components/views/elements/ToggleSwitch.tsx +++ b/src/components/views/elements/ToggleSwitch.tsx @@ -18,21 +18,27 @@ limitations under the License. import React from "react"; import classNames from "classnames"; -import AccessibleButton from "./AccessibleButton"; +import AccessibleTooltipButton from "./AccessibleTooltipButton"; interface IProps { // Whether or not this toggle is in the 'on' position. checked: boolean; + // Title to use + title?: string; + // Whether or not the user can interact with the switch disabled?: boolean; + // Tooltip to show + tooltip?: string; + // Called when the checked state changes. First argument will be the new state. onChange(checked: boolean): void; } // Controlled Toggle Switch element, written with Accessibility in mind -export default ({ checked, disabled = false, onChange, ...props }: IProps) => { +export default ({ checked, disabled = false, title, tooltip, onChange, ...props }: IProps) => { const _onClick = () => { if (disabled) return; onChange(!checked); @@ -45,14 +51,16 @@ export default ({ checked, disabled = false, onChange, ...props }: IProps) => { }); return ( -
- + ); }; diff --git a/src/components/views/elements/Tooltip.tsx b/src/components/views/elements/Tooltip.tsx index 2741a699360..ab27f4f9d8f 100644 --- a/src/components/views/elements/Tooltip.tsx +++ b/src/components/views/elements/Tooltip.tsx @@ -149,18 +149,24 @@ export default class Tooltip extends React.PureComponent { break; case Alignment.Top: style.top = baseTop - spacing; - style.left = horizontalCenter; - style.transform = "translate(-50%, -100%)"; + // Attempt to center the tooltip on the element while clamping + // its horizontal translation to keep it on screen + // eslint-disable-next-line max-len + style.transform = `translate(max(10px, min(calc(${horizontalCenter}px - 50%), calc(100vw - 100% - 10px))), -100%)`; break; case Alignment.Bottom: style.top = baseTop + parentBox.height + spacing; - style.left = horizontalCenter; - style.transform = "translate(-50%)"; + // Attempt to center the tooltip on the element while clamping + // its horizontal translation to keep it on screen + // eslint-disable-next-line max-len + style.transform = `translate(max(10px, min(calc(${horizontalCenter}px - 50%), calc(100vw - 100% - 10px))))`; break; case Alignment.InnerBottom: style.top = baseTop + parentBox.height - 50; - style.left = horizontalCenter; - style.transform = "translate(-50%)"; + // Attempt to center the tooltip on the element while clamping + // its horizontal translation to keep it on screen + // eslint-disable-next-line max-len + style.transform = `translate(max(10px, min(calc(${horizontalCenter}px - 50%), calc(100vw - 100% - 10px))))`; break; case Alignment.TopRight: style.top = baseTop - spacing; diff --git a/src/components/views/location/LocationButton.tsx b/src/components/views/location/LocationButton.tsx index 7efd350b6b9..6d9885cf915 100644 --- a/src/components/views/location/LocationButton.tsx +++ b/src/components/views/location/LocationButton.tsx @@ -69,6 +69,7 @@ export const LocationButton: React.FC = ({ roomId, sender, menuPosition, iconClassName="mx_MessageComposer_location" onClick={openMenu} title={_t("Location")} + inputRef={button} /> { contextMenu } diff --git a/src/components/views/messages/CallEvent.tsx b/src/components/views/messages/CallEvent.tsx new file mode 100644 index 00000000000..f3b79884697 --- /dev/null +++ b/src/components/views/messages/CallEvent.tsx @@ -0,0 +1,191 @@ +/* +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, { forwardRef, useCallback, useContext, useMemo } from "react"; + +import type { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import type { RoomMember } from "matrix-js-sdk/src/models/room-member"; +import { Call, ConnectionState } from "../../../models/Call"; +import { _t } from "../../../languageHandler"; +import { + useCall, + useConnectionState, + useJoinCallButtonDisabled, + useJoinCallButtonTooltip, + useParticipants, +} from "../../../hooks/useCall"; +import defaultDispatcher from "../../../dispatcher/dispatcher"; +import type { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; +import { Action } from "../../../dispatcher/actions"; +import type { ButtonEvent } from "../elements/AccessibleButton"; +import MemberAvatar from "../avatars/MemberAvatar"; +import { LiveContentSummary, LiveContentType } from "../rooms/LiveContentSummary"; +import FacePile from "../elements/FacePile"; +import MatrixClientContext from "../../../contexts/MatrixClientContext"; +import { CallDuration, CallDurationFromEvent } from "../voip/CallDuration"; +import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; + +const MAX_FACES = 8; + +interface ActiveCallEventProps { + mxEvent: MatrixEvent; + participants: Set; + buttonText: string; + buttonKind: string; + buttonTooltip?: string; + buttonDisabled?: boolean; + onButtonClick: ((ev: ButtonEvent) => void) | null; +} + +const ActiveCallEvent = forwardRef( + ( + { + mxEvent, + participants, + buttonText, + buttonKind, + buttonDisabled, + buttonTooltip, + onButtonClick, + }, + ref, + ) => { + const senderName = useMemo(() => mxEvent.sender?.name ?? mxEvent.getSender(), [mxEvent]); + + const facePileMembers = useMemo(() => [...participants].slice(0, MAX_FACES), [participants]); + const facePileOverflow = participants.size > facePileMembers.length; + + return
+
+ +
+ + { _t("%(name)s started a video call", { name: senderName }) } + + + +
+ + + { buttonText } + +
+
; + }, +); + +interface ActiveLoadedCallEventProps { + mxEvent: MatrixEvent; + call: Call; +} + +const ActiveLoadedCallEvent = forwardRef(({ mxEvent, call }, ref) => { + const connectionState = useConnectionState(call); + const participants = useParticipants(call); + const joinCallButtonTooltip = useJoinCallButtonTooltip(call); + const joinCallButtonDisabled = useJoinCallButtonDisabled(call); + + const connect = useCallback((ev: ButtonEvent) => { + ev.preventDefault(); + defaultDispatcher.dispatch({ + action: Action.ViewRoom, + room_id: mxEvent.getRoomId()!, + view_call: true, + metricsTrigger: undefined, + }); + }, [mxEvent]); + + const disconnect = useCallback((ev: ButtonEvent) => { + ev.preventDefault(); + call.disconnect(); + }, [call]); + + const [buttonText, buttonKind, onButtonClick] = useMemo(() => { + switch (connectionState) { + case ConnectionState.Disconnected: return [_t("Join"), "primary", connect]; + case ConnectionState.Connecting: return [_t("Join"), "primary", null]; + case ConnectionState.Connected: return [_t("Leave"), "danger", disconnect]; + case ConnectionState.Disconnecting: return [_t("Leave"), "danger", null]; + } + }, [connectionState, connect, disconnect]); + + return ; +}); + +interface CallEventProps { + mxEvent: MatrixEvent; +} + +/** + * An event tile representing an active or historical Element call. + */ +export const CallEvent = forwardRef(({ mxEvent }, ref) => { + const noParticipants = useMemo(() => new Set(), []); + const client = useContext(MatrixClientContext); + const call = useCall(mxEvent.getRoomId()!); + const latestEvent = client.getRoom(mxEvent.getRoomId())!.currentState + .getStateEvents(mxEvent.getType(), mxEvent.getStateKey()!); + + if ("m.terminated" in latestEvent.getContent()) { + // The call is terminated + return
+
+ { _t("Video call ended") } + +
+
; + } + + if (call === null) { + // There should be a call, but it hasn't loaded yet + return ; + } + + return ; +}); diff --git a/src/components/views/messages/MessageActionBar.tsx b/src/components/views/messages/MessageActionBar.tsx index c5108051160..c1637b9a0cd 100644 --- a/src/components/views/messages/MessageActionBar.tsx +++ b/src/components/views/messages/MessageActionBar.tsx @@ -83,7 +83,7 @@ const OptionsButton: React.FC = ({ getRelationsForEvent, }) => { const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu(); - const [onFocus, isActive, ref] = useRovingTabIndex(button); + const [onFocus, isActive] = useRovingTabIndex(button); useEffect(() => { onFocusChange(menuDisplayed); }, [onFocusChange, menuDisplayed]); @@ -123,7 +123,7 @@ const OptionsButton: React.FC = ({ onClick={onOptionsClick} onContextMenu={onOptionsClick} isExpanded={menuDisplayed} - inputRef={ref} + inputRef={button} onFocus={onFocus} tabIndex={isActive ? 0 : -1} > @@ -141,7 +141,7 @@ interface IReactButtonProps { const ReactButton: React.FC = ({ mxEvent, reactions, onFocusChange }) => { const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu(); - const [onFocus, isActive, ref] = useRovingTabIndex(button); + const [onFocus, isActive] = useRovingTabIndex(button); useEffect(() => { onFocusChange(menuDisplayed); }, [onFocusChange, menuDisplayed]); @@ -173,7 +173,7 @@ const ReactButton: React.FC = ({ mxEvent, reactions, onFocusC onClick={onClick} onContextMenu={onClick} isExpanded={menuDisplayed} - inputRef={ref} + inputRef={button} onFocus={onFocus} tabIndex={isActive ? 0 : -1} > diff --git a/src/components/views/messages/MessageEvent.tsx b/src/components/views/messages/MessageEvent.tsx index 3bb5eabbb25..6a7f5329fd0 100644 --- a/src/components/views/messages/MessageEvent.tsx +++ b/src/components/views/messages/MessageEvent.tsx @@ -42,6 +42,9 @@ import MLocationBody from "./MLocationBody"; 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 { @@ -55,10 +58,18 @@ interface IProps extends Omit([ [MsgType.Text, TextualBody], [MsgType.Notice, TextualBody], @@ -76,7 +87,7 @@ const baseEvTypes = new Map>>([ [M_BEACON_INFO.altName, MBeaconBody], ]); -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()); @@ -84,6 +95,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); @@ -93,15 +105,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) { @@ -145,6 +171,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(); @@ -171,6 +207,14 @@ export default class MessageEvent extends React.Component implements IMe ) { BodyType = MLocationBody; } + + if ( + this.state.voiceBroadcastEnabled + && type === VoiceBroadcastInfoEventType + && content?.state === VoiceBroadcastInfoState.Started + ) { + BodyType = VoiceBroadcastBody; + } } if (SettingsStore.getValue("feature_mjolnir")) { diff --git a/src/components/views/right_panel/HeaderButton.tsx b/src/components/views/right_panel/HeaderButton.tsx index d78dbb867d5..3e8aef65865 100644 --- a/src/components/views/right_panel/HeaderButton.tsx +++ b/src/components/views/right_panel/HeaderButton.tsx @@ -23,6 +23,7 @@ import classNames from 'classnames'; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import { ButtonEvent } from "../elements/AccessibleButton"; +import { Alignment } from "../elements/Tooltip"; interface IProps { // Whether this button is highlighted @@ -54,6 +55,7 @@ export default class HeaderButton extends React.Component { aria-selected={isHighlighted} role="tab" title={title} + alignment={Alignment.Bottom} className={classes} onClick={onClick} />; diff --git a/src/components/views/right_panel/RoomHeaderButtons.tsx b/src/components/views/right_panel/RoomHeaderButtons.tsx index d950177e06b..262b8fc38d6 100644 --- a/src/components/views/right_panel/RoomHeaderButtons.tsx +++ b/src/components/views/right_panel/RoomHeaderButtons.tsx @@ -282,7 +282,7 @@ export default class RoomHeaderButtons extends HeaderButtons { , diff --git a/src/components/views/right_panel/RoomSummaryCard.tsx b/src/components/views/right_panel/RoomSummaryCard.tsx index 7f2205e65f4..63dffee6d4f 100644 --- a/src/components/views/right_panel/RoomSummaryCard.tsx +++ b/src/components/views/right_panel/RoomSummaryCard.tsx @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { useCallback, useContext, useEffect, useState } from "react"; +import React, { useCallback, useContext, useEffect, useMemo, useState } from "react"; import classNames from "classnames"; import { Room } from "matrix-js-sdk/src/models/room"; @@ -77,7 +77,7 @@ const Button: React.FC = ({ children, className, onClick }) => { }; export const useWidgets = (room: Room) => { - const [apps, setApps] = useState(WidgetStore.instance.getApps(room.roomId)); + const [apps, setApps] = useState(() => WidgetStore.instance.getApps(room.roomId)); const updateApps = useCallback(() => { // Copy the array so that we always trigger a re-render, as some updates mutate the array of apps/settings @@ -198,6 +198,8 @@ const AppRow: React.FC = ({ app, room }) => { const AppsSection: React.FC = ({ room }) => { const apps = useWidgets(room); + // Filter out virtual widgets + const realApps = useMemo(() => apps.filter(app => app.eventId !== undefined), [apps]); const onManageIntegrations = () => { const managers = IntegrationManagers.sharedInstance(); @@ -209,8 +211,8 @@ const AppsSection: React.FC = ({ room }) => { } }; - let copyLayoutBtn = null; - if (apps.length > 0 && WidgetLayoutStore.instance.canCopyLayoutToRoom(room)) { + let copyLayoutBtn: JSX.Element | null = null; + if (realApps.length > 0 && WidgetLayoutStore.instance.canCopyLayoutToRoom(room)) { copyLayoutBtn = ( WidgetLayoutStore.instance.copyLayoutToRoom(room)}> { _t("Set my room layout for everyone") } @@ -219,10 +221,10 @@ const AppsSection: React.FC = ({ room }) => { } return - { apps.map(app => ) } + { realApps.map(app => ) } { copyLayoutBtn } - { apps.length > 0 ? _t("Edit widgets, bridges & bots") : _t("Add widgets, bridges & bots") } + { realApps.length > 0 ? _t("Edit widgets, bridges & bots") : _t("Add widgets, bridges & bots") } ; }; diff --git a/src/components/views/right_panel/TimelineCard.tsx b/src/components/views/right_panel/TimelineCard.tsx index afd6151e966..4e1aeea8d0d 100644 --- a/src/components/views/right_panel/TimelineCard.tsx +++ b/src/components/views/right_panel/TimelineCard.tsx @@ -15,7 +15,6 @@ limitations under the License. */ import React from 'react'; -import { EventSubscription } from "fbemitter"; import { IEventRelation, MatrixEvent } from 'matrix-js-sdk/src/models/event'; import { EventTimelineSet } from 'matrix-js-sdk/src/models/event-timeline-set'; import { NotificationCountType, Room } from 'matrix-js-sdk/src/models/room'; @@ -42,6 +41,7 @@ import JumpToBottomButton from '../rooms/JumpToBottomButton'; import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; import Measured from '../elements/Measured'; import Heading from '../typography/Heading'; +import { UPDATE_EVENT } from '../../../stores/AsyncStore'; interface IProps { room: Room; @@ -77,7 +77,6 @@ export default class TimelineCard extends React.Component { private layoutWatcherRef: string; private timelinePanel = React.createRef(); private card = React.createRef(); - private roomStoreToken: EventSubscription; private readReceiptsSettingWatcher: string; constructor(props: IProps) { @@ -92,7 +91,7 @@ export default class TimelineCard extends React.Component { } public componentDidMount(): void { - this.roomStoreToken = RoomViewStore.instance.addListener(this.onRoomViewStoreUpdate); + RoomViewStore.instance.addListener(UPDATE_EVENT, this.onRoomViewStoreUpdate); this.dispatcherRef = dis.register(this.onAction); this.readReceiptsSettingWatcher = SettingsStore.watchSetting("showReadReceipts", null, (...[,,, value]) => this.setState({ showReadReceipts: value as boolean }), @@ -103,9 +102,7 @@ export default class TimelineCard extends React.Component { } public componentWillUnmount(): void { - // Remove RoomStore listener - - this.roomStoreToken?.remove(); + RoomViewStore.instance.removeListener(UPDATE_EVENT, this.onRoomViewStoreUpdate); if (this.readReceiptsSettingWatcher) { SettingsStore.unwatchSetting(this.readReceiptsSettingWatcher); diff --git a/src/components/views/right_panel/UserInfo.tsx b/src/components/views/right_panel/UserInfo.tsx index 45489603ba8..810ae48dd71 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 86e266bc351..1c7b8d6e949 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/AppsDrawer.tsx b/src/components/views/rooms/AppsDrawer.tsx index 601cc9ee346..15eb0428779 100644 --- a/src/components/views/rooms/AppsDrawer.tsx +++ b/src/components/views/rooms/AppsDrawer.tsx @@ -31,7 +31,6 @@ import Resizer from "../../../resizer/resizer"; import PercentageDistributor from "../../../resizer/distributors/percentage"; import { Container, WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore"; import { clamp, percentageOf, percentageWithin } from "../../../utils/numbers"; -import { useStateCallback } from "../../../hooks/useStateCallback"; import UIStore from "../../../stores/UIStore"; import { IApp } from "../../../stores/WidgetStore"; import { ActionPayload } from "../../../dispatcher/payloads"; @@ -330,13 +329,8 @@ const PersistentVResizer: React.FC = ({ defaultHeight = 280; } - const [height, setHeight] = useStateCallback(defaultHeight, newHeight => { - newHeight = percentageOf(newHeight, minHeight, maxHeight) * 100; - WidgetLayoutStore.instance.setContainerHeight(room, Container.Top, newHeight); - }); - return { @@ -346,7 +340,15 @@ const PersistentVResizer: React.FC = ({ resizeNotifier.notifyTimelineHeightChanged(); }} onResizeStop={(e, dir, ref, d) => { - setHeight(height + d.height); + let newHeight = defaultHeight + d.height; + newHeight = percentageOf(newHeight, minHeight, maxHeight) * 100; + + WidgetLayoutStore.instance.setContainerHeight( + room, + Container.Top, + newHeight, + ); + resizeNotifier.stopResizing(); }} handleWrapperClass={handleWrapperClass} diff --git a/src/components/views/rooms/BasicMessageComposer.tsx b/src/components/views/rooms/BasicMessageComposer.tsx index be00584d7ce..a4a0b9f993e 100644 --- a/src/components/views/rooms/BasicMessageComposer.tsx +++ b/src/components/views/rooms/BasicMessageComposer.tsx @@ -755,7 +755,7 @@ export default class BasicMessageEditor extends React.Component [Formatting.Bold]: ctrlShortcutLabel("B"), [Formatting.Italics]: ctrlShortcutLabel("I"), [Formatting.Code]: ctrlShortcutLabel("E"), - [Formatting.Quote]: ctrlShortcutLabel(">"), + [Formatting.Quote]: ctrlShortcutLabel(">", true), [Formatting.InsertLink]: ctrlShortcutLabel("L", true), }; diff --git a/src/components/views/rooms/E2EIcon.tsx b/src/components/views/rooms/E2EIcon.tsx index 1a6db4606c9..5750febe0ed 100644 --- a/src/components/views/rooms/E2EIcon.tsx +++ b/src/components/views/rooms/E2EIcon.tsx @@ -20,7 +20,7 @@ import classNames from 'classnames'; import { _t, _td } from '../../../languageHandler'; import AccessibleButton from "../elements/AccessibleButton"; -import Tooltip from "../elements/Tooltip"; +import Tooltip, { Alignment } from "../elements/Tooltip"; import { E2EStatus } from "../../../utils/ShieldUtils"; export enum E2EState { @@ -49,10 +49,20 @@ interface IProps { size?: number; onClick?: () => void; hideTooltip?: boolean; + tooltipAlignment?: Alignment; bordered?: boolean; } -const E2EIcon: React.FC = ({ isUser, status, className, size, onClick, hideTooltip, bordered }) => { +const E2EIcon: React.FC = ({ + isUser, + status, + className, + size, + onClick, + hideTooltip, + tooltipAlignment, + bordered, +}) => { const [hover, setHover] = useState(false); const classes = classNames({ @@ -80,7 +90,7 @@ const E2EIcon: React.FC = ({ isUser, status, className, size, onClick, h let tip; if (hover && !hideTooltip) { - tip = ; + tip = ; } if (onClick) { diff --git a/src/components/views/rooms/EventTile.tsx b/src/components/views/rooms/EventTile.tsx index 00c5bfc9fda..618d5a183f0 100644 --- a/src/components/views/rooms/EventTile.tsx +++ b/src/components/views/rooms/EventTile.tsx @@ -84,6 +84,7 @@ import { useTooltip } from "../../../utils/useTooltip"; import { ShowThreadPayload } from "../../../dispatcher/payloads/ShowThreadPayload"; import { isLocalRoom } from '../../../utils/localRoom/isLocalRoom'; import { UserNameColorMode } from '../../../settings/enums/UserNameColorMode'; +import { ElementCall } from "../../../models/Call"; export type GetRelationsForEvent = (eventId: string, relationType: string, eventType: string) => Relations; @@ -953,7 +954,7 @@ export class UnwrappedEventTile extends React.Component { public render() { const msgtype = this.props.mxEvent.getContent().msgtype; - const eventType = this.props.mxEvent.getType() as EventType; + const eventType = this.props.mxEvent.getType(); const { hasRenderer, isBubbleMessage, @@ -1013,7 +1014,7 @@ export class UnwrappedEventTile extends React.Component { const isEditing = !!this.props.editState; const classes = classNames({ - mx_EventTile_bubbleContainer: isBubbleMessage && this.props.layout != Layout.Bubble, + mx_EventTile_bubbleContainer: isBubbleMessage && this.props.layout !== Layout.Bubble, mx_EventTile_leftAlignedBubble: isLeftAlignedBubbleMessage, mx_EventTile: true, mx_EventTile_isEditing: isEditing, @@ -1023,7 +1024,8 @@ export class UnwrappedEventTile extends React.Component { mx_EventTile_sending: !isEditing && isSending, mx_EventTile_highlight: this.shouldHighlight(), mx_EventTile_selected: this.props.isSelectedEvent || this.state.contextMenu, - mx_EventTile_continuation: isContinuation, + mx_EventTile_continuation: isContinuation || (this.props.layout !== Layout.Bubble && + (eventType === EventType.CallInvite || ElementCall.CALL_EVENT_TYPE.matches(eventType))), mx_EventTile_last: this.props.last, mx_EventTile_lastInSection: this.props.lastInSection, mx_EventTile_contextual: this.props.contextual, @@ -1081,8 +1083,9 @@ export class UnwrappedEventTile extends React.Component { avatarSize = 14; needsSenderProfile = true; } else if ( - (this.props.continuation && this.context.timelineRenderingType !== TimelineRenderingType.File) || - eventType === EventType.CallInvite + (this.props.continuation && this.context.timelineRenderingType !== TimelineRenderingType.File) + || eventType === EventType.CallInvite + || ElementCall.CALL_EVENT_TYPE.matches(eventType) ) { // no avatar or sender profile for continuation messages and call tiles avatarSize = 0; @@ -1102,13 +1105,15 @@ export class UnwrappedEventTile extends React.Component { } else { member = this.props.mxEvent.sender; } + // In the ThreadsList view we use the entire EventTile as a click target to open the thread instead + const viewUserOnClick = this.context.timelineRenderingType !== TimelineRenderingType.ThreadsList; avatar = (
@@ -1375,6 +1380,7 @@ export class UnwrappedEventTile extends React.Component { { timestamp } + { msgOption }
, reactionsRow, ]); diff --git a/src/components/views/rooms/LiveContentSummary.tsx b/src/components/views/rooms/LiveContentSummary.tsx new file mode 100644 index 00000000000..34ee8252687 --- /dev/null +++ b/src/components/views/rooms/LiveContentSummary.tsx @@ -0,0 +1,74 @@ +/* +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, { FC } from "react"; +import classNames from "classnames"; + +import { _t } from "../../../languageHandler"; +import { Call } from "../../../models/Call"; +import { useParticipants } from "../../../hooks/useCall"; + +export enum LiveContentType { + Video, + // More coming soon +} + +interface Props { + type: LiveContentType; + text: string; + active: boolean; + participantCount: number; +} + +/** + * Summary line used to call out live, interactive content such as calls. + */ +export const LiveContentSummary: FC = ({ type, text, active, participantCount }) => ( + + + { text } + + { participantCount > 0 && <> + { " • " } + + { participantCount } + + } + +); + +interface LiveContentSummaryWithCallProps { + call: Call; +} + +export function LiveContentSummaryWithCall({ call }: LiveContentSummaryWithCallProps) { + const participants = useParticipants(call); + + return ; +} diff --git a/src/components/views/rooms/MessageComposer.tsx b/src/components/views/rooms/MessageComposer.tsx index dddee62db48..01c985bd8ab 100644 --- a/src/components/views/rooms/MessageComposer.tsx +++ b/src/components/views/rooms/MessageComposer.tsx @@ -38,7 +38,7 @@ import { UPDATE_EVENT } from "../../../stores/AsyncStore"; import { Layout } from '../../../settings/enums/Layout'; import VoiceRecordComposerTile from "./VoiceRecordComposerTile"; import { VoiceRecordingStore } from "../../../stores/VoiceRecordingStore"; -import { RecordingState, VoiceRecording } from "../../../audio/VoiceRecording"; +import { RecordingState } from "../../../audio/VoiceRecording"; import Tooltip, { Alignment } from "../elements/Tooltip"; import ResizeNotifier from "../../../utils/ResizeNotifier"; import { E2EStatus } from '../../../utils/ShieldUtils'; @@ -57,6 +57,12 @@ import { IEmoji } from '../../../emoji'; import { ICustomEmoji } from '../../../emojipicker/customemoji'; import { isLocalRoom } from '../../../utils/localRoom/isLocalRoom'; import { Features } from '../../../settings/Settings'; +import { VoiceMessageRecording } from '../../../audio/VoiceMessageRecording'; +import { + startNewVoiceBroadcastRecording, + VoiceBroadcastRecordingsStore, +} from '../../../voice-broadcast'; +import { WysiwygComposer } from './wysiwyg_composer/WysiwygComposer'; let instanceCount = 0; @@ -114,8 +120,9 @@ export default class MessageComposer extends React.Component { private voiceRecordingButton = createRef(); private ref: React.RefObject = createRef(); private instanceId: number; + private composerSendMessage?: () => void; - private _voiceRecording: Optional; + private _voiceRecording: Optional; public static contextType = RoomContext; public context!: React.ContextType; @@ -149,11 +156,11 @@ export default class MessageComposer extends React.Component { SettingsStore.monitorSetting(Features.VoiceBroadcast, null); } - private get voiceRecording(): Optional { + private get voiceRecording(): Optional { return this._voiceRecording; } - private set voiceRecording(rec: Optional) { + private set voiceRecording(rec: Optional) { if (this._voiceRecording) { this._voiceRecording.off(RecordingState.Started, this.onRecordingStarted); this._voiceRecording.off(RecordingState.EndingSoon, this.onRecordingEndingSoon); @@ -331,6 +338,7 @@ export default class MessageComposer extends React.Component { } this.messageComposerInput.current?.sendMessage(); + this.composerSendMessage?.(); }; private onChange = (model: EditorModel) => { @@ -339,6 +347,12 @@ export default class MessageComposer extends React.Component { }); }; + private onWysiwygChange = (content: string) => { + this.setState({ + isComposerEmpty: content?.length === 0, + }); + }; + private onVoiceStoreUpdate = () => { this.updateRecordingState(); }; @@ -398,6 +412,7 @@ export default class MessageComposer extends React.Component { } public render() { + const isWysiwygComposerEnabled = SettingsStore.getValue("feature_wysiwyg_composer"); const controls = [ this.props.e2eStatus ? : @@ -412,20 +427,35 @@ export default class MessageComposer extends React.Component { const canSendMessages = this.context.canSendMessages && !this.context.tombstone; if (canSendMessages) { - controls.push( - , - ); + if (isWysiwygComposerEnabled) { + controls.push( + + { (sendMessage) => { + this.composerSendMessage = sendMessage; + } } + , + ); + } else { + controls.push( + , + ); + } controls.push( { "mx_MessageComposer--compact": this.props.compact, "mx_MessageComposer_e2eStatus": this.props.e2eStatus != undefined, + "mx_MessageComposer_wysiwyg": isWysiwygComposerEnabled, }); return ( @@ -533,11 +564,12 @@ export default class MessageComposer extends React.Component { toggleButtonMenu={this.toggleButtonMenu} showVoiceBroadcastButton={this.showVoiceBroadcastButton} onStartVoiceBroadcastClick={() => { - // Sends a voice message. To be replaced by voice broadcast during development. - this.voiceRecordingButton.current?.onRecordStartEndClick(); - if (this.context.narrow) { - this.toggleButtonMenu(); - } + startNewVoiceBroadcastRecording( + this.props.room.roomId, + MatrixClientPeg.get(), + VoiceBroadcastRecordingsStore.instance(), + ); + this.toggleButtonMenu(); }} /> } { showSendButton && ( diff --git a/src/components/views/rooms/MessageComposerButtons.tsx b/src/components/views/rooms/MessageComposerButtons.tsx index 8b2ea063de2..492a5130898 100644 --- a/src/components/views/rooms/MessageComposerButtons.tsx +++ b/src/components/views/rooms/MessageComposerButtons.tsx @@ -195,6 +195,7 @@ const EmojiButton: React.FC = ({ addEmoji, menuPosition, room iconClassName="mx_MessageComposer_emoji" onClick={openMenu} title={_t("Emoji")} + inputRef={button} /> { contextMenu } diff --git a/src/components/views/rooms/ReadReceiptGroup.tsx b/src/components/views/rooms/ReadReceiptGroup.tsx index 0eb99f0cddc..dab315758cc 100644 --- a/src/components/views/rooms/ReadReceiptGroup.tsx +++ b/src/components/views/rooms/ReadReceiptGroup.tsx @@ -224,7 +224,8 @@ export function ReadReceiptGroup( onMouseOver={showTooltip} onMouseLeave={hideTooltip} onFocus={showTooltip} - onBlur={hideTooltip}> + onBlur={hideTooltip} + > { remText } void; + behavior: DisabledWithReason | "legacy_or_jitsi"; +} + +/** + * Button for starting voice calls, supporting only legacy 1:1 calls and Jitsi + * widgets. + */ +const VoiceCallButton: FC = ({ room, busy, setBusy, behavior }) => { + const { onClick, tooltip, disabled } = useMemo(() => { + if (behavior instanceof DisabledWithReason) { + return { + onClick: () => {}, + tooltip: behavior.reason, + disabled: true, + }; + } else { // behavior === "legacy_or_jitsi" + return { + onClick: async (ev: ButtonEvent) => { + ev.preventDefault(); + setBusy(true); + await LegacyCallHandler.instance.placeCall(room.roomId, CallType.Voice); + setBusy(false); + }, + disabled: false, + }; + } + }, [behavior, room, setBusy]); + + return ; +}; + +interface VideoCallButtonProps { + room: Room; + busy: boolean; + setBusy: (value: boolean) => void; + behavior: DisabledWithReason | "legacy_or_jitsi" | "element" | "jitsi_or_element"; +} + +/** + * Button for starting video calls, supporting both legacy 1:1 calls, Jitsi + * widgets, and native group calls. If multiple calling options are available, + * this shows a menu to pick between them. + */ +const VideoCallButton: FC = ({ room, busy, setBusy, behavior }) => { + const [menuOpen, buttonRef, openMenu, closeMenu] = useContextMenu(); + + const startLegacyCall = useCallback(async () => { + setBusy(true); + await LegacyCallHandler.instance.placeCall(room.roomId, CallType.Video); + setBusy(false); + }, [setBusy, room]); + + const startElementCall = useCallback(() => { + setBusy(true); + defaultDispatcher.dispatch({ + action: Action.ViewRoom, + room_id: room.roomId, + view_call: true, + metricsTrigger: undefined, + }); + setBusy(false); + }, [setBusy, room]); + + const { onClick, tooltip, disabled } = useMemo(() => { + if (behavior instanceof DisabledWithReason) { + return { + onClick: () => {}, + tooltip: behavior.reason, + disabled: true, + }; + } else if (behavior === "legacy_or_jitsi") { + return { + onClick: async (ev: ButtonEvent) => { + ev.preventDefault(); + await startLegacyCall(); + }, + disabled: false, + }; + } else if (behavior === "element") { + return { + onClick: async (ev: ButtonEvent) => { + ev.preventDefault(); + startElementCall(); + }, + disabled: false, + }; + } else { // behavior === "jitsi_or_element" + return { + onClick: async (ev: ButtonEvent) => { + ev.preventDefault(); + openMenu(); + }, + disabled: false, + }; + } + }, [behavior, startLegacyCall, startElementCall, openMenu]); + + const onJitsiClick = useCallback(async (ev: ButtonEvent) => { + ev.preventDefault(); + closeMenu(); + await startLegacyCall(); + }, [closeMenu, startLegacyCall]); + + const onElementClick = useCallback((ev: ButtonEvent) => { + ev.preventDefault(); + closeMenu(); + startElementCall(); + }, [closeMenu, startElementCall]); + + let menu: JSX.Element | null = null; + if (menuOpen) { + const buttonRect = buttonRef.current!.getBoundingClientRect(); + const brand = SdkConfig.get("element_call").brand ?? DEFAULTS.element_call.brand; + menu = + + + + + ; + } + + return <> + + { menu } + ; +}; + +interface CallButtonsProps { + room: Room; +} + +// The header buttons for placing calls have become stupidly complex, so here +// they are as a separate component +const CallButtons: FC = ({ room }) => { + const [busy, setBusy] = useState(false); + const showButtons = useSettingValue("showCallButtonsInComposer"); + const groupCallsEnabled = useFeatureEnabled("feature_group_calls"); + const videoRoomsEnabled = useFeatureEnabled("feature_video_rooms"); + const isVideoRoom = useMemo(() => videoRoomsEnabled && calcIsVideoRoom(room), [videoRoomsEnabled, room]); + const useElementCallExclusively = useMemo(() => { + return SdkConfig.get("element_call").use_exclusively ?? DEFAULTS.element_call.use_exclusively; + }, []); + + const hasLegacyCall = useEventEmitterState( + LegacyCallHandler.instance, + LegacyCallHandlerEvent.CallsChanged, + useCallback(() => LegacyCallHandler.instance.getCallForRoom(room.roomId) !== null, [room]), + ); + + const widgets = useWidgets(room); + const hasJitsiWidget = useMemo(() => widgets.some(widget => WidgetType.JITSI.matches(widget.type)), [widgets]); + + const hasGroupCall = useCall(room.roomId) !== null; + + const [functionalMembers, mayEditWidgets, mayCreateElementCalls] = useTypedEventEmitterState( + room, + RoomStateEvent.Update, + useCallback(() => [ + getJoinedNonFunctionalMembers(room), + room.currentState.mayClientSendStateEvent("im.vector.modular.widgets", room.client), + room.currentState.mayClientSendStateEvent(ElementCall.CALL_EVENT_TYPE.name, room.client), + ], [room]), + ); + + const makeVoiceCallButton = (behavior: VoiceCallButtonProps["behavior"]): JSX.Element => + ; + const makeVideoCallButton = (behavior: VideoCallButtonProps["behavior"]): JSX.Element => + ; + + if (isVideoRoom || !showButtons) { + return null; + } else if (groupCallsEnabled) { + if (useElementCallExclusively) { + if (hasGroupCall) { + return makeVideoCallButton(new DisabledWithReason(_t("Ongoing call"))); + } else if (mayCreateElementCalls) { + return makeVideoCallButton("element"); + } else { + return makeVideoCallButton( + new DisabledWithReason(_t("You do not have permission to start video calls")), + ); + } + } else if (hasLegacyCall || hasJitsiWidget || hasGroupCall) { + return <> + { makeVoiceCallButton(new DisabledWithReason(_t("Ongoing call"))) } + { makeVideoCallButton(new DisabledWithReason(_t("Ongoing call"))) } + ; + } else if (functionalMembers.length <= 1) { + return <> + { makeVoiceCallButton(new DisabledWithReason(_t("There's no one here to call"))) } + { makeVideoCallButton(new DisabledWithReason(_t("There's no one here to call"))) } + ; + } else if (functionalMembers.length === 2) { + return <> + { makeVoiceCallButton("legacy_or_jitsi") } + { makeVideoCallButton("legacy_or_jitsi") } + ; + } else if (mayEditWidgets) { + return <> + { makeVoiceCallButton("legacy_or_jitsi") } + { makeVideoCallButton(mayCreateElementCalls ? "jitsi_or_element" : "legacy_or_jitsi") } + ; + } else { + const videoCallBehavior = mayCreateElementCalls + ? "element" + : new DisabledWithReason(_t("You do not have permission to start video calls")); + return <> + { makeVoiceCallButton(new DisabledWithReason(_t("You do not have permission to start voice calls"))) } + { makeVideoCallButton(videoCallBehavior) } + ; + } + } else if (hasLegacyCall || hasJitsiWidget) { + return <> + { makeVoiceCallButton(new DisabledWithReason(_t("Ongoing call"))) } + { makeVideoCallButton(new DisabledWithReason(_t("Ongoing call"))) } + ; + } else if (functionalMembers.length <= 1) { + return <> + { makeVoiceCallButton(new DisabledWithReason(_t("There's no one here to call"))) } + { makeVideoCallButton(new DisabledWithReason(_t("There's no one here to call"))) } + ; + } else if (functionalMembers.length === 2 || mayEditWidgets) { + return <> + { makeVoiceCallButton("legacy_or_jitsi") } + { makeVideoCallButton("legacy_or_jitsi") } + ; + } else { + return <> + { makeVoiceCallButton(new DisabledWithReason(_t("You do not have permission to start voice calls"))) } + { makeVideoCallButton(new DisabledWithReason(_t("You do not have permission to start video calls"))) } + ; + } +}; + +interface CallLayoutSelectorProps { + call: ElementCall; +} + +const CallLayoutSelector: FC = ({ call }) => { + const layout = useLayout(call); + const [menuOpen, buttonRef, openMenu, closeMenu] = useContextMenu(); + + const onClick = useCallback((ev: ButtonEvent) => { + ev.preventDefault(); + openMenu(); + }, [openMenu]); + + const onFreedomClick = useCallback((ev: ButtonEvent) => { + ev.preventDefault(); + closeMenu(); + call.setLayout(Layout.Tile); + }, [closeMenu, call]); + + const onSpotlightClick = useCallback((ev: ButtonEvent) => { + ev.preventDefault(); + closeMenu(); + call.setLayout(Layout.Spotlight); + }, [closeMenu, call]); + + let menu: JSX.Element | null = null; + if (menuOpen) { + const buttonRect = buttonRef.current!.getBoundingClientRect(); + menu = + + + + + ; + } + + return <> + + { menu } + ; +}; export interface ISearchInfo { searchTerm: string; @@ -55,21 +398,22 @@ export interface ISearchInfo { searchCount: number; } -interface IProps { +export interface IProps { room: Room; oobData?: IOOBData; inRoom: boolean; - onSearchClick: () => void; - onInviteClick: () => void; - onForgetClick: () => void; - onCallPlaced: (type: CallType) => void; - onAppsClick: () => void; + onSearchClick: (() => void) | null; + onInviteClick: (() => void) | null; + onForgetClick: (() => void) | null; + onAppsClick: (() => void) | null; e2eStatus: E2EStatus; appsShown: boolean; searchInfo: ISearchInfo; excludedRightPanelPhaseButtons?: Array; showButtons?: boolean; enableRoomOptionsMenu?: boolean; + viewingCall: boolean; + activeCall: Call | null; } interface IState { @@ -88,8 +432,9 @@ export default class RoomHeader extends React.Component { static contextType = RoomContext; public context!: React.ContextType; + private readonly client = this.props.room.client; - constructor(props, context) { + constructor(props: IProps, context: IState) { super(props, context); const notiStore = RoomNotificationStateStore.instance.getRoomState(props.room); notiStore.on(NotificationStateEvents.Update, this.onNotificationUpdate); @@ -99,14 +444,12 @@ export default class RoomHeader extends React.Component { } public componentDidMount() { - const cli = MatrixClientPeg.get(); - cli.on(RoomStateEvent.Events, this.onRoomStateEvents); + this.client.on(RoomStateEvent.Events, this.onRoomStateEvents); RightPanelStore.instance.on(UPDATE_EVENT, this.onRightPanelStoreUpdate); } public componentWillUnmount() { - const cli = MatrixClientPeg.get(); - cli?.removeListener(RoomStateEvent.Events, this.onRoomStateEvents); + this.client.removeListener(RoomStateEvent.Events, this.onRoomStateEvents); const notiStore = RoomNotificationStateStore.instance.getRoomState(this.props.room); notiStore.removeListener(NotificationStateEvents.Update, this.onNotificationUpdate); RightPanelStore.instance.off(UPDATE_EVENT, this.onRightPanelStoreUpdate); @@ -133,7 +476,7 @@ export default class RoomHeader extends React.Component { this.forceUpdate(); }, 500, { leading: true, trailing: true }); - private onContextMenuOpenClick = (ev: React.MouseEvent) => { + private onContextMenuOpenClick = (ev: ButtonEvent) => { ev.preventDefault(); ev.stopPropagation(); const target = ev.target as HTMLButtonElement; @@ -141,79 +484,105 @@ export default class RoomHeader extends React.Component { }; private onContextMenuCloseClick = () => { - this.setState({ contextMenuPosition: null }); + this.setState({ contextMenuPosition: undefined }); }; - private renderButtons(): JSX.Element[] { - const buttons: JSX.Element[] = []; - - if (this.props.inRoom && - this.props.onCallPlaced && - !this.context.tombstone && - SettingsStore.getValue("showCallButtonsInComposer") - ) { - const voiceCallButton = this.props.onCallPlaced(CallType.Voice)} - title={_t("Voice call")} - key="voice" - />; - const videoCallButton = this.props.onCallPlaced(CallType.Video)} - title={_t("Video call")} - key="video" - />; - buttons.push(voiceCallButton, videoCallButton); + private onHideCallClick = (ev: ButtonEvent) => { + ev.preventDefault(); + defaultDispatcher.dispatch({ + action: Action.ViewRoom, + room_id: this.props.room.roomId, + view_call: false, + metricsTrigger: undefined, + }); + }; + + private renderButtons(isVideoRoom: boolean): React.ReactNode { + const startButtons: JSX.Element[] = []; + + if (!this.props.viewingCall && this.props.inRoom && !this.context.tombstone) { + startButtons.push(); } - if (this.props.onForgetClick) { - const forgetButton = ); + } + + if (!this.props.viewingCall && this.props.onForgetClick) { + startButtons.push(; - buttons.push(forgetButton); + />); } - if (this.props.onAppsClick) { - const appsButton = ; - buttons.push(appsButton); + />); } - if (this.props.onSearchClick && this.props.inRoom) { - const searchButton = ; - buttons.push(searchButton); + />); } - if (this.props.onInviteClick && this.props.inRoom) { - const inviteButton = ; - buttons.push(inviteButton); + />); } - return buttons; + const endButtons: JSX.Element[] = []; + + if (this.props.viewingCall && !isVideoRoom) { + if (this.props.activeCall === null) { + endButtons.push(); + } else { + endButtons.push(); + } + } + + return <> + { startButtons } + + { endButtons } + ; } - private renderName(oobName) { - let contextMenu: JSX.Element; + private renderName(oobName: string) { + let contextMenu: JSX.Element | null = null; if (this.state.contextMenuPosition && this.props.room) { contextMenu = ( { let settingsHint = false; const members = this.props.room ? this.props.room.getJoinedMembers() : undefined; if (members) { - if (members.length === 1 && members[0].userId === MatrixClientPeg.get().credentials.userId) { + if (members.length === 1 && members[0].userId === this.client.credentials.userId) { const nameEvent = this.props.room.currentState.getStateEvents('m.room.name', ''); if (!nameEvent || !nameEvent.getContent().name) { settingsHint = true; @@ -253,6 +622,7 @@ export default class RoomHeader extends React.Component { onClick={this.onContextMenuOpenClick} isExpanded={!!this.state.contextMenuPosition} title={_t("Room options")} + alignment={Alignment.Bottom} > { roomName } { this.props.room &&
} @@ -267,7 +637,58 @@ export default class RoomHeader extends React.Component { } public render() { - let searchStatus = null; + const isVideoRoom = SettingsStore.getValue("feature_video_rooms") && calcIsVideoRoom(this.props.room); + + let roomAvatar: JSX.Element | null = null; + if (this.props.room) { + roomAvatar = ; + } + + const icon = this.props.viewingCall + ?
+ : this.props.e2eStatus + ? + // If we're expecting an E2EE status to come in, but it hasn't + // yet been loaded, insert a blank div to reserve space + : this.client.isRoomEncrypted(this.props.room.roomId) && this.client.isCryptoEnabled() + ?
+ : null; + + const buttons = this.props.showButtons ? this.renderButtons(isVideoRoom) : null; + + if (this.props.viewingCall && !isVideoRoom) { + return ( +
+
+
{ roomAvatar }
+ { icon } +
+ { _t("Video call") } +
+ { this.props.activeCall instanceof ElementCall && ( + + ) } + { /* Empty topic element to fill out space */ } +
+ { buttons } +
+
+ ); + } + + let searchStatus: JSX.Element | null = null; // don't display the search count until the search completes and // gives us a valid (possibly zero) searchCount. @@ -291,29 +712,6 @@ export default class RoomHeader extends React.Component { className="mx_RoomHeader_topic" />; - let roomAvatar; - if (this.props.room) { - roomAvatar = ; - } - - let buttons; - if (this.props.showButtons) { - buttons = -
- { this.renderButtons() } -
- -
; - } - - const e2eIcon = this.props.e2eStatus ? : undefined; - - const isVideoRoom = SettingsStore.getValue("feature_video_rooms") && calcIsVideoRoom(this.props.room); const viewLabs = () => defaultDispatcher.dispatch({ action: Action.ViewUserSettings, initialTabId: UserTab.Labs, @@ -329,13 +727,14 @@ export default class RoomHeader extends React.Component { aria-owns={this.state.rightPanelOpen ? "mx_RightPanel" : undefined} >
{ roomAvatar }
-
{ e2eIcon }
+ { icon } { name } { searchStatus } { topicElement } { betaPill } { buttons }
+ { !isVideoRoom && } ); diff --git a/src/components/views/rooms/RoomList.tsx b/src/components/views/rooms/RoomList.tsx index 2303314f6aa..4eb96019d26 100644 --- a/src/components/views/rooms/RoomList.tsx +++ b/src/components/views/rooms/RoomList.tsx @@ -14,7 +14,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -import * as fbEmitter from "fbemitter"; import { EventType, RoomType } from "matrix-js-sdk/src/@types/event"; import { Room } from "matrix-js-sdk/src/models/room"; import React, { ComponentType, createRef, ReactComponentElement, RefObject } from "react"; @@ -38,6 +37,7 @@ import { UIComponent } from "../../../settings/UIFeature"; import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore"; import { ITagMap } from "../../../stores/room-list/algorithms/models"; import { DefaultTagID, TagID } from "../../../stores/room-list/models"; +import { UPDATE_EVENT } from "../../../stores/AsyncStore"; import RoomListStore, { LISTS_UPDATE_EVENT } from "../../../stores/room-list/RoomListStore"; import { RoomViewStore } from "../../../stores/RoomViewStore"; import { @@ -461,7 +461,6 @@ const TAG_AESTHETICS: ITagAestheticsMap = { export default class RoomList extends React.PureComponent { private dispatcherRef; - private roomStoreToken: fbEmitter.EventSubscription; private readonly unifiedRoomListWatcherRef: string; private treeRef = createRef(); private favouriteMessageWatcher: string; @@ -485,7 +484,7 @@ export default class RoomList extends React.PureComponent { public componentDidMount(): void { this.dispatcherRef = defaultDispatcher.register(this.onAction); - this.roomStoreToken = RoomViewStore.instance.addListener(this.onRoomViewStoreUpdate); + RoomViewStore.instance.on(UPDATE_EVENT, this.onRoomViewStoreUpdate); SpaceStore.instance.on(UPDATE_SUGGESTED_ROOMS, this.updateSuggestedRooms); RoomListStore.instance.on(LISTS_UPDATE_EVENT, this.updateLists); this.favouriteMessageWatcher = @@ -500,9 +499,8 @@ export default class RoomList extends React.PureComponent { RoomListStore.instance.off(LISTS_UPDATE_EVENT, this.updateLists); SettingsStore.unwatchSetting(this.favouriteMessageWatcher); defaultDispatcher.unregister(this.dispatcherRef); - if (this.roomStoreToken) this.roomStoreToken.remove(); - SettingsStore.unwatchSetting(this.unifiedRoomListWatcherRef); + RoomViewStore.instance.off(UPDATE_EVENT, this.onRoomViewStoreUpdate); } private onUnifiedRoomListChange = () => { diff --git a/src/components/views/rooms/RoomPreviewBar.tsx b/src/components/views/rooms/RoomPreviewBar.tsx index 578608acd50..e10daac1dbe 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/rooms/RoomTile.tsx b/src/components/views/rooms/RoomTile.tsx index a7b23d0fe4a..8e51b5576f7 100644 --- a/src/components/views/rooms/RoomTile.tsx +++ b/src/components/views/rooms/RoomTile.tsx @@ -89,7 +89,7 @@ export default class RoomTile extends React.PureComponent { selected: RoomViewStore.instance.getRoomId() === this.props.room.roomId, notificationsMenuPosition: null, generalMenuPosition: null, - call: CallStore.instance.get(this.props.room.roomId), + call: CallStore.instance.getCall(this.props.room.roomId), // generatePreview() will return nothing if the user has previews disabled messagePreview: "", }; @@ -166,7 +166,7 @@ export default class RoomTile extends React.PureComponent { // Recalculate the call for this room, since it could've changed between // construction and mounting - this.setState({ call: CallStore.instance.get(this.props.room.roomId) }); + this.setState({ call: CallStore.instance.getCall(this.props.room.roomId) }); } public componentWillUnmount() { diff --git a/src/components/views/rooms/RoomTileCallSummary.tsx b/src/components/views/rooms/RoomTileCallSummary.tsx index 9af01f20d49..717ab5e36ff 100644 --- a/src/components/views/rooms/RoomTileCallSummary.tsx +++ b/src/components/views/rooms/RoomTileCallSummary.tsx @@ -15,12 +15,12 @@ limitations under the License. */ import React, { FC } from "react"; -import classNames from "classnames"; import type { Call } from "../../../models/Call"; -import { _t, TranslatedString } from "../../../languageHandler"; +import { _t } from "../../../languageHandler"; import { useConnectionState, useParticipants } from "../../../hooks/useCall"; import { ConnectionState } from "../../../models/Call"; +import { LiveContentSummary, LiveContentType } from "./LiveContentSummary"; interface Props { call: Call; @@ -30,7 +30,7 @@ export const RoomTileCallSummary: FC = ({ call }) => { const connectionState = useConnectionState(call); const participants = useParticipants(call); - let text: TranslatedString; + let text: string; let active: boolean; switch (connectionState) { @@ -49,23 +49,10 @@ export const RoomTileCallSummary: FC = ({ call }) => { break; } - return - - { text } - - { participants.size ? <> - { " · " } - - { participants.size } - - : null } - ; + return ; }; diff --git a/src/components/views/rooms/SendMessageComposer.tsx b/src/components/views/rooms/SendMessageComposer.tsx index 926a11979ff..f146fb3238c 100644 --- a/src/components/views/rooms/SendMessageComposer.tsx +++ b/src/components/views/rooms/SendMessageComposer.tsx @@ -88,6 +88,7 @@ export function createMessageContent( model = unescapeMessage(model); const body = textSerialize(model); + const content: IContent = { msgtype: isEmote ? "m.emote" : "m.text", body: body, diff --git a/src/components/views/rooms/VoiceRecordComposerTile.tsx b/src/components/views/rooms/VoiceRecordComposerTile.tsx index b25d87a0cef..c3fe0762c7c 100644 --- a/src/components/views/rooms/VoiceRecordComposerTile.tsx +++ b/src/components/views/rooms/VoiceRecordComposerTile.tsx @@ -16,14 +16,13 @@ limitations under the License. import React, { ReactNode } from "react"; import { Room } from "matrix-js-sdk/src/models/room"; -import { MsgType } from "matrix-js-sdk/src/@types/event"; import { logger } from "matrix-js-sdk/src/logger"; import { Optional } from "matrix-events-sdk"; import { IEventRelation, MatrixEvent } from "matrix-js-sdk/src/models/event"; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import { _t } from "../../../languageHandler"; -import { IUpload, RecordingState, VoiceRecording } from "../../../audio/VoiceRecording"; +import { RecordingState } from "../../../audio/VoiceRecording"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; import LiveRecordingWaveform from "../audio_messages/LiveRecordingWaveform"; import LiveRecordingClock from "../audio_messages/LiveRecordingClock"; @@ -44,6 +43,8 @@ import { attachRelation } from "./SendMessageComposer"; import { addReplyToMessageContent } from "../../../utils/Reply"; import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks"; import RoomContext from "../../../contexts/RoomContext"; +import { IUpload, VoiceMessageRecording } from "../../../audio/VoiceMessageRecording"; +import { createVoiceMessageContent } from "../../../utils/createVoiceMessageContent"; interface IProps { room: Room; @@ -53,7 +54,7 @@ interface IProps { } interface IState { - recorder?: VoiceRecording; + recorder?: VoiceMessageRecording; recordingPhase?: RecordingState; didUploadFail?: boolean; } @@ -121,36 +122,14 @@ export default class VoiceRecordComposerTile extends React.PureComponent Math.round(v * 1024)), - }, - "org.matrix.msc3245.voice": {}, // No content, this is a rendering hint - }; + const content = createVoiceMessageContent( + upload.mxc, + this.state.recorder.contentType, + Math.round(this.state.recorder.durationSeconds * 1000), + this.state.recorder.contentLength, + upload.encrypted, + this.state.recorder.getPlayback().thumbnailWaveform.map(v => Math.round(v * 1024)), + ); attachRelation(content, relation); if (replyToEvent) { @@ -250,7 +229,7 @@ export default class VoiceRecordComposerTile extends React.PureComponent) { + private bindNewRecorder(recorder: Optional) { if (this.state.recorder) { this.state.recorder.off(UPDATE_EVENT, this.onRecordingUpdate); } diff --git a/src/components/views/rooms/wysiwyg_composer/Editor.tsx b/src/components/views/rooms/wysiwyg_composer/Editor.tsx new file mode 100644 index 00000000000..cca66f6c387 --- /dev/null +++ b/src/components/views/rooms/wysiwyg_composer/Editor.tsx @@ -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 React, { forwardRef, memo } from 'react'; + +interface EditorProps { + disabled: boolean; +} + +export const Editor = memo( + forwardRef( + function Editor({ disabled }: EditorProps, ref, + ) { + return
+
+
; + }, + ), +); diff --git a/src/components/views/rooms/wysiwyg_composer/FormattingButtons.tsx b/src/components/views/rooms/wysiwyg_composer/FormattingButtons.tsx new file mode 100644 index 00000000000..19941ad3f94 --- /dev/null +++ b/src/components/views/rooms/wysiwyg_composer/FormattingButtons.tsx @@ -0,0 +1,69 @@ +/* +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, { MouseEventHandler } from "react"; +import { useWysiwyg } from "@matrix-org/matrix-wysiwyg"; +import classNames from "classnames"; + +import AccessibleTooltipButton from "../../elements/AccessibleTooltipButton"; +import { Alignment } from "../../elements/Tooltip"; +import { KeyboardShortcut } from "../../settings/KeyboardShortcut"; +import { KeyCombo } from "../../../../KeyBindingsManager"; +import { _td } from "../../../../languageHandler"; + +interface TooltipProps { + label: string; + keyCombo?: KeyCombo; +} + +function Tooltip({ label, keyCombo }: TooltipProps) { + return
+ { label } + { keyCombo && } +
; +} + +interface ButtonProps extends TooltipProps { + className: string; + isActive: boolean; + onClick: MouseEventHandler; +} + +function Button({ label, keyCombo, onClick, isActive, className }: ButtonProps) { + return } + alignment={Alignment.Top} + />; +} + +interface FormattingButtonsProps { + composer: ReturnType['wysiwyg']; + formattingStates: ReturnType['formattingStates']; +} + +export function FormattingButtons({ composer, formattingStates }: FormattingButtonsProps) { + return
+
; +} diff --git a/src/components/views/rooms/wysiwyg_composer/WysiwygComposer.tsx b/src/components/views/rooms/wysiwyg_composer/WysiwygComposer.tsx new file mode 100644 index 00000000000..8701f5be778 --- /dev/null +++ b/src/components/views/rooms/wysiwyg_composer/WysiwygComposer.tsx @@ -0,0 +1,68 @@ +/* +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, { useCallback, useEffect } from 'react'; +import { IEventRelation, MatrixEvent } from 'matrix-js-sdk/src/models/event'; +import { useWysiwyg } from "@matrix-org/matrix-wysiwyg"; + +import { Editor } from './Editor'; +import { FormattingButtons } from './FormattingButtons'; +import { RoomPermalinkCreator } from '../../../../utils/permalinks/Permalinks'; +import { sendMessage } from './message'; +import { useMatrixClientContext } from '../../../../contexts/MatrixClientContext'; +import { useRoomContext } from '../../../../contexts/RoomContext'; +import { useWysiwygActionHandler } from './useWysiwygActionHandler'; + +interface WysiwygProps { + disabled?: boolean; + onChange: (content: string) => void; + relation?: IEventRelation; + replyToEvent?: MatrixEvent; + permalinkCreator: RoomPermalinkCreator; + includeReplyLegacyFallback?: boolean; + children?: (sendMessage: () => void) => void; +} + +export function WysiwygComposer( + { disabled = false, onChange, children, ...props }: WysiwygProps, +) { + const roomContext = useRoomContext(); + const mxClient = useMatrixClientContext(); + + const { ref, isWysiwygReady, content, formattingStates, wysiwyg } = useWysiwyg(); + + useEffect(() => { + if (!disabled && content !== null) { + onChange(content); + } + }, [onChange, content, disabled]); + + const memoizedSendMessage = useCallback(() => { + sendMessage(content, { mxClient, roomContext, ...props }); + wysiwyg.clear(); + ref.current?.focus(); + }, [content, mxClient, roomContext, wysiwyg, props, ref]); + + useWysiwygActionHandler(disabled, ref); + + return ( +
+ + + { children?.(memoizedSendMessage) } +
+ ); +} diff --git a/src/components/views/rooms/wysiwyg_composer/message.ts b/src/components/views/rooms/wysiwyg_composer/message.ts new file mode 100644 index 00000000000..5569af02a95 --- /dev/null +++ b/src/components/views/rooms/wysiwyg_composer/message.ts @@ -0,0 +1,199 @@ +/* +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 { Composer as ComposerEvent } from "@matrix-org/analytics-events/types/typescript/Composer"; +import { IContent, IEventRelation, MatrixEvent } from 'matrix-js-sdk/src/models/event'; +import { MatrixClient } from "matrix-js-sdk/src/matrix"; +import { THREAD_RELATION_TYPE } from "matrix-js-sdk/src/models/thread"; + +import { PosthogAnalytics } from "../../../../PosthogAnalytics"; +import SettingsStore from "../../../../settings/SettingsStore"; +import { decorateStartSendingTime, sendRoundTripMetric } from "../../../../sendTimePerformanceMetrics"; +import { RoomPermalinkCreator } from "../../../../utils/permalinks/Permalinks"; +import { doMaybeLocalRoomAction } from "../../../../utils/local-room"; +import { CHAT_EFFECTS } from "../../../../effects"; +import { containsEmoji } from "../../../../effects/utils"; +import { IRoomState } from "../../../structures/RoomView"; +import dis from '../../../../dispatcher/dispatcher'; +import { addReplyToMessageContent } from "../../../../utils/Reply"; + +// Merges favouring the given relation +function attachRelation(content: IContent, relation?: IEventRelation): void { + if (relation) { + content['m.relates_to'] = { + ...(content['m.relates_to'] || {}), + ...relation, + }; + } +} + +interface SendMessageParams { + mxClient: MatrixClient; + relation?: IEventRelation; + replyToEvent?: MatrixEvent; + roomContext: IRoomState; + permalinkCreator: RoomPermalinkCreator; + includeReplyLegacyFallback?: boolean; +} + +// exported for tests +export function createMessageContent( + message: string, + { relation, replyToEvent, permalinkCreator, includeReplyLegacyFallback = true }: + Omit, +): IContent { + // TODO emote ? + + /*const isEmote = containsEmote(model); + if (isEmote) { + model = stripEmoteCommand(model); + } + if (startsWith(model, "//")) { + model = stripPrefix(model, "/"); + } + model = unescapeMessage(model);*/ + + // const body = textSerialize(model); + const body = message; + + const content: IContent = { + // TODO emote + // msgtype: isEmote ? "m.emote" : "m.text", + msgtype: "m.text", + body: body, + }; + + // TODO markdown support + + /*const formattedBody = htmlSerializeIfNeeded(model, { + forceHTML: !!replyToEvent, + useMarkdown: SettingsStore.getValue("MessageComposerInput.useMarkdown"), + });*/ + const formattedBody = message; + + if (formattedBody) { + content.format = "org.matrix.custom.html"; + content.formatted_body = formattedBody; + } + + attachRelation(content, relation); + + if (replyToEvent) { + addReplyToMessageContent(content, replyToEvent, { + permalinkCreator, + includeLegacyFallback: includeReplyLegacyFallback, + }); + } + + return content; +} + +export function sendMessage( + message: string, + { roomContext, mxClient, ...params }: SendMessageParams, +) { + const { relation, replyToEvent } = params; + const { room } = roomContext; + const { roomId } = room; + + const posthogEvent: ComposerEvent = { + eventName: "Composer", + isEditing: false, + isReply: Boolean(replyToEvent), + inThread: relation?.rel_type === THREAD_RELATION_TYPE.name, + }; + + // TODO thread + /*if (posthogEvent.inThread) { + const threadRoot = room.findEventById(relation?.event_id); + posthogEvent.startsThread = threadRoot?.getThread()?.events.length === 1; + }*/ + PosthogAnalytics.instance.trackEvent(posthogEvent); + + let content: IContent; + + // TODO slash comment + + // TODO replace emotion end of message ? + + // TODO quick reaction + + if (!content) { + content = createMessageContent( + message, + params, + ); + } + + // don't bother sending an empty message + if (!content.body.trim()) { + return; + } + + if (SettingsStore.getValue("Performance.addSendMessageTimingMetadata")) { + decorateStartSendingTime(content); + } + + const threadId = relation?.rel_type === THREAD_RELATION_TYPE.name + ? relation.event_id + : null; + + const prom = doMaybeLocalRoomAction( + roomId, + (actualRoomId: string) => mxClient.sendMessage(actualRoomId, threadId, content), + mxClient, + ); + + if (replyToEvent) { + // Clear reply_to_event as we put the message into the queue + // if the send fails, retry will handle resending. + dis.dispatch({ + action: 'reply_to_event', + event: null, + context: roomContext.timelineRenderingType, + }); + } + + dis.dispatch({ action: "message_sent" }); + CHAT_EFFECTS.forEach((effect) => { + if (containsEmoji(content, effect.emojis)) { + // For initial threads launch, chat effects are disabled + // see #19731 + const isNotThread = relation?.rel_type !== THREAD_RELATION_TYPE.name; + if (!SettingsStore.getValue("feature_thread") || isNotThread) { + dis.dispatch({ action: `effects.${effect.command}` }); + } + } + }); + if (SettingsStore.getValue("Performance.addSendMessageTimingMetadata")) { + prom.then(resp => { + sendRoundTripMetric(mxClient, roomId, resp.event_id); + }); + } + + // TODO save history + // TODO save local state + + //if (shouldSend && SettingsStore.getValue("scrollToBottomOnMessageSent")) { + if (SettingsStore.getValue("scrollToBottomOnMessageSent")) { + dis.dispatch({ + action: "scroll_to_bottom", + timelineRenderingType: roomContext.timelineRenderingType, + }); + } + + return prom; +} diff --git a/src/components/views/rooms/wysiwyg_composer/useWysiwygActionHandler.ts b/src/components/views/rooms/wysiwyg_composer/useWysiwygActionHandler.ts new file mode 100644 index 00000000000..683498d485e --- /dev/null +++ b/src/components/views/rooms/wysiwyg_composer/useWysiwygActionHandler.ts @@ -0,0 +1,73 @@ +/* +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 { useRef } from "react"; + +import defaultDispatcher from "../../../../dispatcher/dispatcher"; +import { Action } from "../../../../dispatcher/actions"; +import { ActionPayload } from "../../../../dispatcher/payloads"; +import { IRoomState } from "../../../structures/RoomView"; +import { TimelineRenderingType, useRoomContext } from "../../../../contexts/RoomContext"; +import { useDispatcher } from "../../../../hooks/useDispatcher"; + +export function useWysiwygActionHandler( + disabled: boolean, + composerElement: React.MutableRefObject, +) { + const roomContext = useRoomContext(); + const timeoutId = useRef(); + + useDispatcher(defaultDispatcher, (payload: ActionPayload) => { + // don't let the user into the composer if it is disabled - all of these branches lead + // to the cursor being in the composer + if (disabled) return; + + const context = payload.context ?? TimelineRenderingType.Room; + + switch (payload.action) { + case "reply_to_event": + case Action.FocusSendMessageComposer: + focusComposer(composerElement, context, roomContext, timeoutId); + break; + // TODO: case Action.ComposerInsert: - see SendMessageComposer + } + }); +} + +function focusComposer( + composerElement: React.MutableRefObject, + renderingType: TimelineRenderingType, + roomContext: IRoomState, + timeoutId: React.MutableRefObject, +) { + if (renderingType === roomContext.timelineRenderingType) { + // Immediately set the focus, so if you start typing it + // will appear in the composer + composerElement.current?.focus(); + // If we call focus immediate, the focus _is_ in the right + // place, but the cursor is invisible, presumably because + // some other event is still processing. + // The following line ensures that the cursor is actually + // visible in composer. + if (timeoutId.current) { + clearTimeout(timeoutId.current); + } + timeoutId.current = setTimeout( + () => composerElement.current?.focus(), + 200, + ); + } +} diff --git a/src/components/views/settings/ChangeAvatar.tsx b/src/components/views/settings/ChangeAvatar.tsx index b0645ac51b4..680291db4ce 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/DevicesPanelEntry.tsx b/src/components/views/settings/DevicesPanelEntry.tsx index 0109c37b9ba..0e2908c96a0 100644 --- a/src/components/views/settings/DevicesPanelEntry.tsx +++ b/src/components/views/settings/DevicesPanelEntry.tsx @@ -29,6 +29,7 @@ import VerificationRequestDialog from '../../views/dialogs/VerificationRequestDi import LogoutDialog from '../dialogs/LogoutDialog'; import DeviceTile from './devices/DeviceTile'; import SelectableDeviceTile from './devices/SelectableDeviceTile'; +import { DeviceType } from '../../../utils/device/parseUserAgent'; interface IProps { device: IMyDevice; @@ -153,9 +154,10 @@ export default class DevicesPanelEntry extends React.Component { ; - const deviceWithVerification = { + const extendedDevice = { ...this.props.device, isVerified: this.props.verified, + deviceType: DeviceType.Unknown, }; if (this.props.isOwnDevice) { @@ -163,7 +165,7 @@ export default class DevicesPanelEntry extends React.Component {
- + { buttons }
; @@ -171,7 +173,7 @@ export default class DevicesPanelEntry extends React.Component { return (
- + { buttons }
diff --git a/src/components/views/settings/KeyboardShortcut.tsx b/src/components/views/settings/KeyboardShortcut.tsx index 0f35ea0e7ac..fc183a4a02f 100644 --- a/src/components/views/settings/KeyboardShortcut.tsx +++ b/src/components/views/settings/KeyboardShortcut.tsx @@ -38,9 +38,10 @@ export const KeyboardKey: React.FC = ({ name, last }) => { interface IKeyboardShortcutProps { value: KeyCombo; + className?: string; } -export const KeyboardShortcut: React.FC = ({ value }) => { +export const KeyboardShortcut: React.FC = ({ value, className = 'mx_KeyboardShortcut' }) => { if (!value) return null; const modifiersElement = []; @@ -58,7 +59,7 @@ export const KeyboardShortcut: React.FC = ({ value }) => modifiersElement.push(); } - return
+ return
{ modifiersElement }
; diff --git a/src/components/views/settings/Notifications.tsx b/src/components/views/settings/Notifications.tsx index e28008449b4..a7ae465cbb4 100644 --- a/src/components/views/settings/Notifications.tsx +++ b/src/components/views/settings/Notifications.tsx @@ -18,6 +18,7 @@ import React from "react"; import { IAnnotatedPushRule, IPusher, PushRuleAction, PushRuleKind, RuleId } from "matrix-js-sdk/src/@types/PushRules"; import { IThreepid, ThreepidMedium } from "matrix-js-sdk/src/@types/threepids"; import { logger } from "matrix-js-sdk/src/logger"; +import { LocalNotificationSettings } from "matrix-js-sdk/src/@types/local_notifications"; import Spinner from "../elements/Spinner"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; @@ -42,6 +43,7 @@ import TagComposer from "../elements/TagComposer"; import { objectClone } from "../../../utils/objects"; import { arrayDiff } from "../../../utils/arrays"; import { SoundPack } from "../../../settings/enums/SoundPack"; +import { getLocalNotificationAccountDataEventType } from "../../../utils/notifications"; // TODO: this "view" component still has far too much application logic in it, // which should be factored out to other files. @@ -107,6 +109,7 @@ interface IState { pushers?: IPusher[]; threepids?: IThreepid[]; + deviceNotificationsEnabled: boolean; desktopNotifications: boolean; desktopShowBody: boolean; audioNotifications: boolean; @@ -121,6 +124,7 @@ export default class Notifications extends React.PureComponent { this.state = { phase: Phase.Loading, + deviceNotificationsEnabled: SettingsStore.getValue("deviceNotificationsEnabled") ?? true, desktopNotifications: SettingsStore.getValue("notificationsEnabled"), desktopShowBody: SettingsStore.getValue("notificationBodyEnabled"), audioNotifications: SettingsStore.getValue("audioNotificationsEnabled"), @@ -131,6 +135,9 @@ export default class Notifications extends React.PureComponent { SettingsStore.watchSetting("notificationsEnabled", null, (...[,,,, value]) => this.setState({ desktopNotifications: value as boolean }), ), + SettingsStore.watchSetting("deviceNotificationsEnabled", null, (...[,,,, value]) => { + this.setState({ deviceNotificationsEnabled: value as boolean }); + }), SettingsStore.watchSetting("notificationBodyEnabled", null, (...[,,,, value]) => this.setState({ desktopShowBody: value as boolean }), ), @@ -154,12 +161,19 @@ export default class Notifications extends React.PureComponent { public componentDidMount() { // noinspection JSIgnoredPromiseFromCall this.refreshFromServer(); + this.refreshFromAccountData(); } public componentWillUnmount() { this.settingWatchers.forEach(watcher => SettingsStore.unwatchSetting(watcher)); } + public componentDidUpdate(prevProps: Readonly, prevState: Readonly): void { + if (this.state.deviceNotificationsEnabled !== prevState.deviceNotificationsEnabled) { + this.persistLocalNotificationSettings(this.state.deviceNotificationsEnabled); + } + } + private async refreshFromServer() { try { const newState = (await Promise.all([ @@ -168,7 +182,9 @@ export default class Notifications extends React.PureComponent { this.refreshThreepids(), ])).reduce((p, c) => Object.assign(c, p), {}); - this.setState>({ + this.setState + >({ ...newState, phase: Phase.Ready, }); @@ -178,6 +194,22 @@ export default class Notifications extends React.PureComponent { } } + private async refreshFromAccountData() { + const cli = MatrixClientPeg.get(); + const settingsEvent = cli.getAccountData(getLocalNotificationAccountDataEventType(cli.deviceId)); + if (settingsEvent) { + const notificationsEnabled = !(settingsEvent.getContent() as LocalNotificationSettings).is_silenced; + await this.updateDeviceNotifications(notificationsEnabled); + } + } + + private persistLocalNotificationSettings(enabled: boolean): Promise<{}> { + const cli = MatrixClientPeg.get(); + return cli.setAccountData(getLocalNotificationAccountDataEventType(cli.deviceId), { + is_silenced: !enabled, + }); + } + private async refreshRules(): Promise> { const ruleSets = await MatrixClientPeg.get().getPushRules(); const categories = { @@ -303,6 +335,10 @@ export default class Notifications extends React.PureComponent { } }; + private updateDeviceNotifications = async (checked: boolean) => { + await SettingsStore.setValue("deviceNotificationsEnabled", null, SettingLevel.DEVICE, checked); + }; + private onEmailNotificationsChanged = async (email: string, checked: boolean) => { this.setState({ phase: Phase.Persisting }); @@ -410,12 +446,13 @@ export default class Notifications extends React.PureComponent { }; private onClearNotificationsClicked = () => { - MatrixClientPeg.get().getRooms().forEach(r => { + const client = MatrixClientPeg.get(); + client.getRooms().forEach(r => { if (r.getUnreadNotificationCount() > 0) { const events = r.getLiveTimeline().getEvents(); if (events.length) { // noinspection JSIgnoredPromiseFromCall - MatrixClientPeg.get().sendReadReceipt(events[events.length - 1]); + client.sendReadReceipt(events[events.length - 1]); } } }); @@ -508,7 +545,8 @@ export default class Notifications extends React.PureComponent { const masterSwitch = ; @@ -532,28 +570,36 @@ export default class Notifications extends React.PureComponent { { masterSwitch } this.updateDeviceNotifications(checked)} disabled={this.state.phase === Phase.Persisting} /> - - - + { this.state.deviceNotificationsEnabled && (<> + + + + ) } { emailSwitches } diff --git a/src/components/views/settings/ProfileSettings.tsx b/src/components/views/settings/ProfileSettings.tsx index a820b28d40a..fd3ed6c99d1 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 023d33b083c..af6a9501431 100644 --- a/src/components/views/settings/devices/CurrentDeviceSection.tsx +++ b/src/components/views/settings/devices/CurrentDeviceSection.tsx @@ -15,44 +15,97 @@ limitations under the License. */ 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 { DeviceWithVerification } from './types'; +import { ExtendedDevice } from './types'; +import { KebabContextMenu } from '../../context_menus/KebabContextMenu'; +import { IconizedContextMenuOption } from '../../context_menus/IconizedContextMenu'; interface Props { - device?: DeviceWithVerification; + device?: ExtendedDevice; isLoading: boolean; isSigningOut: boolean; + localNotificationSettings?: LocalNotificationSettings | undefined; + 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, isSigningOut, + localNotificationSettings, + setPushNotifications, onVerifyCurrentDevice, onSignOutCurrentDevice, + signOutAllOtherSessions, saveDeviceName, }) => { const [isExpanded, setIsExpanded] = useState(false); return } > { /* only show big spinner on first load */ } { isLoading && !device && } { !!device && <> setIsExpanded(!isExpanded)} > = ({ { isExpanded && Promise; } diff --git a/src/components/views/settings/devices/DeviceDetails.tsx b/src/components/views/settings/devices/DeviceDetails.tsx index 53c095a33bd..4330798dcab 100644 --- a/src/components/views/settings/devices/DeviceDetails.tsx +++ b/src/components/views/settings/devices/DeviceDetails.tsx @@ -15,39 +15,54 @@ limitations under the License. */ import React from 'react'; +import { IPusher } from 'matrix-js-sdk/src/@types/PushRules'; +import { PUSHER_ENABLED } from 'matrix-js-sdk/src/@types/event'; +import { LocalNotificationSettings } from 'matrix-js-sdk/src/@types/local_notifications'; import { formatDate } from '../../../../DateUtils'; import { _t } from '../../../../languageHandler'; import AccessibleButton from '../../elements/AccessibleButton'; import Spinner from '../../elements/Spinner'; +import ToggleSwitch from '../../elements/ToggleSwitch'; import { DeviceDetailHeading } from './DeviceDetailHeading'; import { DeviceVerificationStatusCard } from './DeviceVerificationStatusCard'; -import { DeviceWithVerification } from './types'; +import { ExtendedDevice } from './types'; interface Props { - device: DeviceWithVerification; + device: ExtendedDevice; + pusher?: IPusher | undefined; + localNotificationSettings?: LocalNotificationSettings | undefined; isSigningOut: boolean; onVerifyDevice?: () => void; onSignOutDevice: () => void; saveDeviceName: (deviceName: string) => Promise; + setPushNotifications?: (deviceId: string, enabled: boolean) => Promise | undefined; + supportsMSC3881?: boolean | undefined; } interface MetadataTable { + id: string; heading?: string; values: { label: string, value?: string | React.ReactNode }[]; } const DeviceDetails: React.FC = ({ device, + pusher, + localNotificationSettings, isSigningOut, onVerifyDevice, onSignOutDevice, saveDeviceName, + setPushNotifications, + supportsMSC3881, }) => { const metadata: MetadataTable[] = [ { + id: 'session', values: [ { label: _t('Session ID'), value: device.device_id }, + { label: _t('Client'), value: device.client }, { label: _t('Last activity'), value: device.last_seen_ts && formatDate(new Date(device.last_seen_ts)), @@ -55,12 +70,45 @@ const DeviceDetails: React.FC = ({ ], }, { + id: 'application', + heading: _t('Application'), + values: [ + { label: _t('Name'), value: device.appName }, + { label: _t('Version'), value: device.appVersion }, + { label: _t('URL'), value: device.url }, + ], + }, + { + id: 'device', heading: _t('Device'), values: [ + { label: _t('Model'), value: device.deviceModel }, + { label: _t('Operating system'), value: device.deviceOperatingSystem }, { label: _t('IP address'), value: device.last_seen_ip }, ], }, - ]; + ].map(section => + // filter out falsy values + ({ ...section, values: section.values.filter(row => !!row.value) })) + .filter(section => + // then filter out sections with no values + section.values.length, + ); + + const showPushNotificationSection = !!pusher || !!localNotificationSettings; + + function isPushNotificationsEnabled(pusher: IPusher, notificationSettings: LocalNotificationSettings): boolean { + if (pusher) return pusher[PUSHER_ENABLED.name]; + if (localNotificationSettings) return !localNotificationSettings.is_silenced; + return true; + } + + function isCheckboxDisabled(pusher: IPusher, notificationSettings: LocalNotificationSettings): boolean { + if (localNotificationSettings) return false; + if (pusher && !supportsMSC3881) return true; + return false; + } + return
= ({

{ _t('Session details') }

- { metadata.map(({ heading, values }, index) =>
{ heading && @@ -93,6 +142,28 @@ const DeviceDetails: React.FC = ({
, ) }
+ { showPushNotificationSection && ( +
+ setPushNotifications?.(device.device_id, checked)} + title={_t("Toggle push notifications on this session.")} + data-testid='device-detail-push-notification-checkbox' + /> +

+ { _t('Push notifications') } + + { _t('Receive push notifications on this session.') } + +

+
+ ) }
void; } -const DeviceTileName: React.FC<{ device: DeviceWithVerification }> = ({ device }) => { - if (device.display_name) { - return - - { device.display_name } - - ; - } +const DeviceTileName: React.FC<{ device: ExtendedDevice }> = ({ device }) => { return - { device.device_id } + { device.display_name || device.device_id } ; }; @@ -59,7 +50,7 @@ const formatLastActivity = (timestamp: number, now = new Date().getTime()): stri return formatRelativeTime(new Date(timestamp)); }; -const getInactiveMetadata = (device: DeviceWithVerification): { id: string, value: React.ReactNode } | undefined => { +const getInactiveMetadata = (device: ExtendedDevice): { id: string, value: React.ReactNode } | undefined => { const isInactive = isDeviceInactive(device); if (!isInactive) { @@ -80,7 +71,12 @@ const DeviceMetadata: React.FC<{ value: string | React.ReactNode, id: string }> value ? { value } : null ); -const DeviceTile: React.FC = ({ device, children, onClick }) => { +const DeviceTile: React.FC = ({ + device, + children, + isSelected, + onClick, +}) => { const inactive = getInactiveMetadata(device); const lastActivity = device.last_seen_ts && `${_t('Last activity')} ${formatLastActivity(device.last_seen_ts)}`; const verificationStatus = device.isVerified ? _t('Verified') : _t('Unverified'); @@ -91,11 +87,23 @@ const DeviceTile: React.FC = ({ device, children, onClick }) => { id: 'isVerified', value: verificationStatus }, { id: 'lastActivity', value: lastActivity }, { id: 'lastSeenIp', value: device.last_seen_ip }, + { id: 'deviceId', value: device.device_id }, ]; - return
- -
+ return
+ +
{ metadata.map(({ id, value }, index) => @@ -108,7 +116,7 @@ const DeviceTile: React.FC = ({ device, children, onClick }) => ) }
-
+
{})}> { children }
; diff --git a/src/components/views/settings/devices/DeviceType.tsx b/src/components/views/settings/devices/DeviceType.tsx deleted file mode 100644 index a0fbe75c565..00000000000 --- a/src/components/views/settings/devices/DeviceType.tsx +++ /dev/null @@ -1,56 +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. -*/ - -import React from 'react'; -import classNames from 'classnames'; - -import { Icon as UnknownDeviceIcon } from '../../../../../res/img/element-icons/settings/unknown-device.svg'; -import { Icon as VerifiedIcon } from '../../../../../res/img/e2e/verified.svg'; -import { Icon as UnverifiedIcon } from '../../../../../res/img/e2e/warning.svg'; -import { _t } from '../../../../languageHandler'; -import { DeviceWithVerification } from './types'; - -interface Props { - isVerified?: DeviceWithVerification['isVerified']; - isSelected?: boolean; -} - -export const DeviceType: React.FC = ({ isVerified, isSelected }) => ( -
- { /* TODO(kerrya) all devices have an unknown type until PSG-650 */ } - - { - isVerified - ? - : - } -
); - diff --git a/src/components/views/settings/devices/DeviceTypeIcon.tsx b/src/components/views/settings/devices/DeviceTypeIcon.tsx new file mode 100644 index 00000000000..5ae30485eb0 --- /dev/null +++ b/src/components/views/settings/devices/DeviceTypeIcon.tsx @@ -0,0 +1,83 @@ +/* +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 classNames from 'classnames'; + +import { Icon as UnknownDeviceIcon } from '../../../../../res/img/element-icons/settings/unknown-device.svg'; +import { Icon as DesktopIcon } from '../../../../../res/img/element-icons/settings/desktop.svg'; +import { Icon as WebIcon } from '../../../../../res/img/element-icons/settings/web.svg'; +import { Icon as MobileIcon } from '../../../../../res/img/element-icons/settings/mobile.svg'; +import { Icon as VerifiedIcon } from '../../../../../res/img/e2e/verified.svg'; +import { Icon as UnverifiedIcon } from '../../../../../res/img/e2e/warning.svg'; +import { _t } from '../../../../languageHandler'; +import { ExtendedDevice } from './types'; +import { DeviceType } from '../../../../utils/device/parseUserAgent'; + +interface Props { + isVerified?: ExtendedDevice['isVerified']; + isSelected?: boolean; + deviceType?: DeviceType; +} + +const deviceTypeIcon: Record>> = { + [DeviceType.Desktop]: DesktopIcon, + [DeviceType.Mobile]: MobileIcon, + [DeviceType.Web]: WebIcon, + [DeviceType.Unknown]: UnknownDeviceIcon, +}; +const deviceTypeLabel: Record = { + [DeviceType.Desktop]: _t('Desktop session'), + [DeviceType.Mobile]: _t('Mobile session'), + [DeviceType.Web]: _t('Web session'), + [DeviceType.Unknown]: _t('Unknown session type'), +}; + +export const DeviceTypeIcon: React.FC = ({ + isVerified, + isSelected, + deviceType, +}) => { + const Icon = deviceTypeIcon[deviceType] || deviceTypeIcon[DeviceType.Unknown]; + const label = deviceTypeLabel[deviceType] || deviceTypeLabel[DeviceType.Unknown]; + return ( +
+
+ +
+ { + isVerified + ? + : + } +
); +}; + diff --git a/src/components/views/settings/devices/DeviceVerificationStatusCard.tsx b/src/components/views/settings/devices/DeviceVerificationStatusCard.tsx index 11e806e54e4..127f5eedf60 100644 --- a/src/components/views/settings/devices/DeviceVerificationStatusCard.tsx +++ b/src/components/views/settings/devices/DeviceVerificationStatusCard.tsx @@ -21,11 +21,11 @@ import AccessibleButton from '../../elements/AccessibleButton'; import DeviceSecurityCard from './DeviceSecurityCard'; import { DeviceSecurityVariation, - DeviceWithVerification, + ExtendedDevice, } from './types'; interface Props { - device: DeviceWithVerification; + device: ExtendedDevice; onVerifyDevice?: () => void; } diff --git a/src/components/views/settings/devices/FilteredDeviceList.tsx b/src/components/views/settings/devices/FilteredDeviceList.tsx index 88b8886c04d..9bc216a086e 100644 --- a/src/components/views/settings/devices/FilteredDeviceList.tsx +++ b/src/components/views/settings/devices/FilteredDeviceList.tsx @@ -15,6 +15,9 @@ limitations under the License. */ import React, { ForwardedRef, forwardRef } from 'react'; +import { IPusher } from 'matrix-js-sdk/src/@types/PushRules'; +import { PUSHER_DEVICE_ID } from 'matrix-js-sdk/src/@types/event'; +import { LocalNotificationSettings } from 'matrix-js-sdk/src/@types/local_notifications'; import { _t } from '../../../../languageHandler'; import AccessibleButton from '../../elements/AccessibleButton'; @@ -22,32 +25,45 @@ import { FilterDropdown, FilterDropdownOption } from '../../elements/FilterDropd import DeviceDetails from './DeviceDetails'; import DeviceExpandDetailsButton from './DeviceExpandDetailsButton'; import DeviceSecurityCard from './DeviceSecurityCard'; -import DeviceTile from './DeviceTile'; import { filterDevicesBySecurityRecommendation, INACTIVE_DEVICE_AGE_DAYS, } from './filter'; +import SelectableDeviceTile from './SelectableDeviceTile'; import { DevicesDictionary, DeviceSecurityVariation, - DeviceWithVerification, + ExtendedDevice, } from './types'; import { DevicesState } from './useOwnDevices'; +import FilteredDeviceListHeader from './FilteredDeviceListHeader'; +import Spinner from '../../elements/Spinner'; interface Props { devices: DevicesDictionary; - expandedDeviceIds: DeviceWithVerification['device_id'][]; - signingOutDeviceIds: DeviceWithVerification['device_id'][]; + pushers: IPusher[]; + localNotificationSettings: Map; + expandedDeviceIds: ExtendedDevice['device_id'][]; + signingOutDeviceIds: ExtendedDevice['device_id'][]; + selectedDeviceIds: ExtendedDevice['device_id'][]; filter?: DeviceSecurityVariation; onFilterChange: (filter: DeviceSecurityVariation | undefined) => void; - onDeviceExpandToggle: (deviceId: DeviceWithVerification['device_id']) => void; - onSignOutDevices: (deviceIds: DeviceWithVerification['device_id'][]) => void; + onDeviceExpandToggle: (deviceId: ExtendedDevice['device_id']) => void; + onSignOutDevices: (deviceIds: ExtendedDevice['device_id'][]) => void; saveDeviceName: DevicesState['saveDeviceName']; - onRequestDeviceVerification?: (deviceId: DeviceWithVerification['device_id']) => void; + onRequestDeviceVerification?: (deviceId: ExtendedDevice['device_id']) => void; + setPushNotifications: (deviceId: string, enabled: boolean) => Promise; + setSelectedDeviceIds: (deviceIds: ExtendedDevice['device_id'][]) => void; + supportsMSC3881?: boolean | undefined; } +const isDeviceSelected = ( + deviceId: ExtendedDevice['device_id'], + selectedDeviceIds: ExtendedDevice['device_id'][], +) => selectedDeviceIds.includes(deviceId); + // devices without timestamp metadata should be sorted last -const sortDevicesByLatestActivity = (left: DeviceWithVerification, right: DeviceWithVerification) => +const sortDevicesByLatestActivity = (left: ExtendedDevice, right: ExtendedDevice) => (right.last_seen_ts || 0) - (left.last_seen_ts || 0); const getFilteredSortedDevices = (devices: DevicesDictionary, filter?: DeviceSecurityVariation) => @@ -134,38 +150,58 @@ const NoResults: React.FC = ({ filter, clearFilter }) =>
; const DeviceListItem: React.FC<{ - device: DeviceWithVerification; + device: ExtendedDevice; + pusher?: IPusher | undefined; + localNotificationSettings?: LocalNotificationSettings | undefined; isExpanded: boolean; isSigningOut: boolean; + isSelected: boolean; onDeviceExpandToggle: () => void; onSignOutDevice: () => void; saveDeviceName: (deviceName: string) => Promise; onRequestDeviceVerification?: () => void; + toggleSelected: () => void; + setPushNotifications: (deviceId: string, enabled: boolean) => Promise; + supportsMSC3881?: boolean | undefined; }> = ({ device, + pusher, + localNotificationSettings, isExpanded, isSigningOut, + isSelected, onDeviceExpandToggle, onSignOutDevice, saveDeviceName, onRequestDeviceVerification, + setPushNotifications, + toggleSelected, + supportsMSC3881, }) =>
  • - + { isSigningOut && } - + { isExpanded && }
  • ; @@ -177,17 +213,36 @@ const DeviceListItem: React.FC<{ export const FilteredDeviceList = forwardRef(({ devices, + pushers, + localNotificationSettings, filter, expandedDeviceIds, signingOutDeviceIds, + selectedDeviceIds, onFilterChange, onDeviceExpandToggle, saveDeviceName, onSignOutDevices, onRequestDeviceVerification, + setPushNotifications, + setSelectedDeviceIds, + supportsMSC3881, }: Props, ref: ForwardedRef) => { const sortedDevices = getFilteredSortedDevices(devices, filter); + function getPusherForDevice(device: ExtendedDevice): IPusher | undefined { + return pushers.find(pusher => pusher[PUSHER_DEVICE_ID.name] === device.device_id); + } + + const toggleSelection = (deviceId: ExtendedDevice['device_id']): void => { + if (isDeviceSelected(deviceId, selectedDeviceIds)) { + // remove from selection + setSelectedDeviceIds(selectedDeviceIds.filter(id => id !== deviceId)); + } else { + setSelectedDeviceIds([...selectedDeviceIds, deviceId]); + } + }; + const options: FilterDropdownOption[] = [ { id: ALL_FILTER_ID, label: _t('All') }, { @@ -214,20 +269,55 @@ export const FilteredDeviceList = onFilterChange(filterId === ALL_FILTER_ID ? undefined : filterId as DeviceSecurityVariation); }; + const isAllSelected = selectedDeviceIds.length >= sortedDevices.length; + const toggleSelectAll = () => { + if (isAllSelected) { + setSelectedDeviceIds([]); + } else { + setSelectedDeviceIds(sortedDevices.map(device => device.device_id)); + } + }; + + const isSigningOut = !!signingOutDeviceIds.length; + return
    -
    - - { _t('Sessions') } - - - id='device-list-filter' - label={_t('Filter devices')} - value={filter || ALL_FILTER_ID} - onOptionChange={onFilterOptionChange} - options={options} - selectedLabel={_t('Show')} - /> -
    + + { selectedDeviceIds.length + ? <> + onSignOutDevices(selectedDeviceIds)} + className='mx_FilteredDeviceList_headerButton' + > + { isSigningOut && } + { _t('Sign out') } + + setSelectedDeviceIds([])} + className='mx_FilteredDeviceList_headerButton' + > + { _t('Cancel') } + + + : + id='device-list-filter' + label={_t('Filter devices')} + value={filter || ALL_FILTER_ID} + onOptionChange={onFilterOptionChange} + options={options} + selectedLabel={_t('Show')} + /> + } + { !!sortedDevices.length ? : onFilterChange(undefined)} /> @@ -236,8 +326,11 @@ export const FilteredDeviceList = { sortedDevices.map((device) => onDeviceExpandToggle(device.device_id)} onSignOutDevice={() => onSignOutDevices([device.device_id])} saveDeviceName={(deviceName: string) => saveDeviceName(device.device_id, deviceName)} @@ -246,6 +339,9 @@ export const FilteredDeviceList = ? () => onRequestDeviceVerification(device.device_id) : undefined } + setPushNotifications={setPushNotifications} + toggleSelected={() => toggleSelection(device.device_id)} + supportsMSC3881={supportsMSC3881} />, ) } diff --git a/src/components/views/settings/devices/FilteredDeviceListHeader.tsx b/src/components/views/settings/devices/FilteredDeviceListHeader.tsx new file mode 100644 index 00000000000..561431d12ef --- /dev/null +++ b/src/components/views/settings/devices/FilteredDeviceListHeader.tsx @@ -0,0 +1,63 @@ +/* +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, { HTMLProps } from 'react'; + +import { _t } from '../../../../languageHandler'; +import StyledCheckbox, { CheckboxStyle } from '../../elements/StyledCheckbox'; +import { Alignment } from '../../elements/Tooltip'; +import TooltipTarget from '../../elements/TooltipTarget'; + +interface Props extends Omit, 'className'> { + selectedDeviceCount: number; + isAllSelected: boolean; + toggleSelectAll: () => void; + children?: React.ReactNode; +} + +const FilteredDeviceListHeader: React.FC = ({ + selectedDeviceCount, + isAllSelected, + toggleSelectAll, + children, + ...rest +}) => { + const checkboxLabel = isAllSelected ? _t('Deselect all') : _t('Select all'); + return
    + + + + + { selectedDeviceCount > 0 + ? _t('%(selectedDeviceCount)s sessions selected', { selectedDeviceCount }) + : _t('Sessions') + } + + { children } +
    ; +}; + +export default FilteredDeviceListHeader; diff --git a/src/components/views/settings/devices/SecurityRecommendations.tsx b/src/components/views/settings/devices/SecurityRecommendations.tsx index 3132eba38a3..ddeb2f2e2e8 100644 --- a/src/components/views/settings/devices/SecurityRecommendations.tsx +++ b/src/components/views/settings/devices/SecurityRecommendations.tsx @@ -23,13 +23,13 @@ import DeviceSecurityCard from './DeviceSecurityCard'; import { filterDevicesBySecurityRecommendation, INACTIVE_DEVICE_AGE_DAYS } from './filter'; import { DeviceSecurityVariation, - DeviceWithVerification, + ExtendedDevice, DevicesDictionary, } from './types'; interface Props { devices: DevicesDictionary; - currentDeviceId: DeviceWithVerification['device_id']; + currentDeviceId: ExtendedDevice['device_id']; goToFilteredList: (filter: DeviceSecurityVariation) => void; } @@ -38,7 +38,7 @@ const SecurityRecommendations: React.FC = ({ currentDeviceId, goToFilteredList, }) => { - const devicesArray = Object.values(devices); + const devicesArray = Object.values(devices); const unverifiedDevicesCount = filterDevicesBySecurityRecommendation( devicesArray, diff --git a/src/components/views/settings/devices/SelectableDeviceTile.tsx b/src/components/views/settings/devices/SelectableDeviceTile.tsx index e232e5ff50a..6cdadb51d69 100644 --- a/src/components/views/settings/devices/SelectableDeviceTile.tsx +++ b/src/components/views/settings/devices/SelectableDeviceTile.tsx @@ -21,19 +21,27 @@ import DeviceTile, { DeviceTileProps } from './DeviceTile'; interface Props extends DeviceTileProps { isSelected: boolean; - onClick: () => void; + onSelect: () => void; + onClick?: () => void; } -const SelectableDeviceTile: React.FC = ({ children, device, isSelected, onClick }) => { +const SelectableDeviceTile: React.FC = ({ + children, + device, + isSelected, + onSelect, + onClick, +}) => { return
    - + { children }
    ; diff --git a/src/components/views/settings/devices/filter.ts b/src/components/views/settings/devices/filter.ts index ad2bc92152c..05ceb9c6972 100644 --- a/src/components/views/settings/devices/filter.ts +++ b/src/components/views/settings/devices/filter.ts @@ -14,9 +14,9 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { DeviceWithVerification, DeviceSecurityVariation } from "./types"; +import { ExtendedDevice, DeviceSecurityVariation } from "./types"; -type DeviceFilterCondition = (device: DeviceWithVerification) => boolean; +type DeviceFilterCondition = (device: ExtendedDevice) => boolean; const MS_DAY = 24 * 60 * 60 * 1000; export const INACTIVE_DEVICE_AGE_MS = 7.776e+9; // 90 days @@ -32,7 +32,7 @@ const filters: Record = { }; export const filterDevicesBySecurityRecommendation = ( - devices: DeviceWithVerification[], + devices: ExtendedDevice[], securityVariations: DeviceSecurityVariation[], ) => { const activeFilters = securityVariations.map(variation => filters[variation]); diff --git a/src/components/views/settings/devices/types.ts b/src/components/views/settings/devices/types.ts index 1f3328c09ef..3fa125a09f8 100644 --- a/src/components/views/settings/devices/types.ts +++ b/src/components/views/settings/devices/types.ts @@ -16,8 +16,17 @@ limitations under the License. import { IMyDevice } from "matrix-js-sdk/src/matrix"; +import { ExtendedDeviceInformation } from "../../../../utils/device/parseUserAgent"; + export type DeviceWithVerification = IMyDevice & { isVerified: boolean | null }; -export type DevicesDictionary = Record; +export type ExtendedDeviceAppInfo = { + // eg Element Web + appName?: string; + appVersion?: string; + url?: string; +}; +export type ExtendedDevice = DeviceWithVerification & ExtendedDeviceAppInfo & ExtendedDeviceInformation; +export type DevicesDictionary = Record; export enum DeviceSecurityVariation { Verified = 'Verified', diff --git a/src/components/views/settings/devices/useOwnDevices.ts b/src/components/views/settings/devices/useOwnDevices.ts index 0f7d1044da6..c3b8cb0212a 100644 --- a/src/components/views/settings/devices/useOwnDevices.ts +++ b/src/components/views/settings/devices/useOwnDevices.ts @@ -15,15 +15,29 @@ limitations under the License. */ import { useCallback, useContext, useEffect, useState } from "react"; -import { IMyDevice, MatrixClient } from "matrix-js-sdk/src/matrix"; +import { + ClientEvent, + IMyDevice, + IPusher, + LOCAL_NOTIFICATION_SETTINGS_PREFIX, + MatrixClient, + MatrixEvent, + PUSHER_DEVICE_ID, + PUSHER_ENABLED, + UNSTABLE_MSC3852_LAST_SEEN_UA, +} from "matrix-js-sdk/src/matrix"; import { CrossSigningInfo } from "matrix-js-sdk/src/crypto/CrossSigning"; import { VerificationRequest } from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest"; import { MatrixError } from "matrix-js-sdk/src/http-api"; import { logger } from "matrix-js-sdk/src/logger"; +import { LocalNotificationSettings } from "matrix-js-sdk/src/@types/local_notifications"; import MatrixClientContext from "../../../../contexts/MatrixClientContext"; import { _t } from "../../../../languageHandler"; -import { DevicesDictionary, DeviceWithVerification } from "./types"; +import { getDeviceClientInformation } from "../../../../utils/device/clientInformation"; +import { DevicesDictionary, ExtendedDevice, ExtendedDeviceAppInfo } from "./types"; +import { useEventEmitter } from "../../../../hooks/useEventEmitter"; +import { parseUserAgent } from "../../../../utils/device/parseUserAgent"; const isDeviceVerified = ( matrixClient: MatrixClient, @@ -51,6 +65,16 @@ const isDeviceVerified = ( } }; +const parseDeviceExtendedInformation = (matrixClient: MatrixClient, device: IMyDevice): ExtendedDeviceAppInfo => { + const { name, version, url } = getDeviceClientInformation(matrixClient, device.device_id); + + return { + appName: name, + appVersion: version, + url, + }; +}; + const fetchDevicesWithVerification = async ( matrixClient: MatrixClient, userId: string, @@ -64,6 +88,8 @@ const fetchDevicesWithVerification = async ( [device.device_id]: { ...device, isVerified: isDeviceVerified(matrixClient, crossSigningInfo, device), + ...parseDeviceExtendedInformation(matrixClient, device), + ...parseUserAgent(device[UNSTABLE_MSC3852_LAST_SEEN_UA.name]), }, }), {}); @@ -76,13 +102,17 @@ export enum OwnDevicesError { } export type DevicesState = { devices: DevicesDictionary; + pushers: IPusher[]; + localNotificationSettings: Map; currentDeviceId: string; isLoadingDeviceList: boolean; // not provided when current session cannot request verification - requestDeviceVerification?: (deviceId: DeviceWithVerification['device_id']) => Promise; + requestDeviceVerification?: (deviceId: ExtendedDevice['device_id']) => Promise; refreshDevices: () => Promise; - saveDeviceName: (deviceId: DeviceWithVerification['device_id'], deviceName: string) => Promise; + saveDeviceName: (deviceId: ExtendedDevice['device_id'], deviceName: string) => Promise; + setPushNotifications: (deviceId: ExtendedDevice['device_id'], enabled: boolean) => Promise; error?: OwnDevicesError; + supportsMSC3881?: boolean | undefined; }; export const useOwnDevices = (): DevicesState => { const matrixClient = useContext(MatrixClientContext); @@ -91,10 +121,20 @@ export const useOwnDevices = (): DevicesState => { const userId = matrixClient.getUserId(); const [devices, setDevices] = useState({}); + const [pushers, setPushers] = useState([]); + const [localNotificationSettings, setLocalNotificationSettings] + = useState(new Map()); const [isLoadingDeviceList, setIsLoadingDeviceList] = useState(true); + const [supportsMSC3881, setSupportsMSC3881] = useState(true); // optimisticly saying yes! const [error, setError] = useState(); + useEffect(() => { + matrixClient.doesServerSupportUnstableFeature("org.matrix.msc3881").then(hasSupport => { + setSupportsMSC3881(hasSupport); + }); + }, [matrixClient]); + const refreshDevices = useCallback(async () => { setIsLoadingDeviceList(true); try { @@ -105,6 +145,23 @@ export const useOwnDevices = (): DevicesState => { } const devices = await fetchDevicesWithVerification(matrixClient, userId); setDevices(devices); + + const { pushers } = await matrixClient.getPushers(); + setPushers(pushers); + + const notificationSettings = new Map(); + Object.keys(devices).forEach((deviceId) => { + const eventType = `${LOCAL_NOTIFICATION_SETTINGS_PREFIX.name}.${deviceId}`; + const event = matrixClient.getAccountData(eventType); + if (event) { + notificationSettings.set( + deviceId, + event.getContent(), + ); + } + }); + setLocalNotificationSettings(notificationSettings); + setIsLoadingDeviceList(false); } catch (error) { if ((error as MatrixError).httpStatus == 404) { @@ -122,10 +179,20 @@ export const useOwnDevices = (): DevicesState => { refreshDevices(); }, [refreshDevices]); + useEventEmitter(matrixClient, ClientEvent.AccountData, (event: MatrixEvent): void => { + const type = event.getType(); + if (type.startsWith(LOCAL_NOTIFICATION_SETTINGS_PREFIX.name)) { + const newSettings = new Map(localNotificationSettings); + const deviceId = type.slice(type.lastIndexOf(".") + 1); + newSettings.set(deviceId, event.getContent()); + setLocalNotificationSettings(newSettings); + } + }); + const isCurrentDeviceVerified = !!devices[currentDeviceId]?.isVerified; const requestDeviceVerification = isCurrentDeviceVerified && userId - ? async (deviceId: DeviceWithVerification['device_id']) => { + ? async (deviceId: ExtendedDevice['device_id']) => { return await matrixClient.requestVerification( userId, [deviceId], @@ -134,7 +201,7 @@ export const useOwnDevices = (): DevicesState => { : undefined; const saveDeviceName = useCallback( - async (deviceId: DeviceWithVerification['device_id'], deviceName: string): Promise => { + async (deviceId: ExtendedDevice['device_id'], deviceName: string): Promise => { const device = devices[deviceId]; // no change @@ -154,13 +221,40 @@ export const useOwnDevices = (): DevicesState => { } }, [matrixClient, devices, refreshDevices]); + const setPushNotifications = useCallback( + async (deviceId: ExtendedDevice['device_id'], enabled: boolean): Promise => { + try { + const pusher = pushers.find(pusher => pusher[PUSHER_DEVICE_ID.name] === deviceId); + if (pusher) { + await matrixClient.setPusher({ + ...pusher, + [PUSHER_ENABLED.name]: enabled, + }); + } else if (localNotificationSettings.has(deviceId)) { + await matrixClient.setLocalNotificationSettings(deviceId, { + is_silenced: !enabled, + }); + } + } catch (error) { + logger.error("Error setting pusher state", error); + throw new Error(_t("Failed to set pusher state")); + } finally { + await refreshDevices(); + } + }, [matrixClient, pushers, localNotificationSettings, refreshDevices], + ); + return { devices, + pushers, + localNotificationSettings, currentDeviceId, isLoadingDeviceList, error, requestDeviceVerification, refreshDevices, saveDeviceName, + setPushNotifications, + supportsMSC3881, }; }; diff --git a/src/components/views/settings/shared/SettingsSubsection.tsx b/src/components/views/settings/shared/SettingsSubsection.tsx index 6d23a080caa..9ceff732cbc 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/components/views/settings/shared/SettingsSubsectionHeading.tsx b/src/components/views/settings/shared/SettingsSubsectionHeading.tsx new file mode 100644 index 00000000000..4a39ff72789 --- /dev/null +++ b/src/components/views/settings/shared/SettingsSubsectionHeading.tsx @@ -0,0 +1,31 @@ +/* +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, { 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/room/RolesRoomSettingsTab.tsx b/src/components/views/settings/tabs/room/RolesRoomSettingsTab.tsx index bab69042435..5da39961541 100644 --- a/src/components/views/settings/tabs/room/RolesRoomSettingsTab.tsx +++ b/src/components/views/settings/tabs/room/RolesRoomSettingsTab.tsx @@ -31,6 +31,8 @@ import PowerSelector from "../../../elements/PowerSelector"; import SettingsFieldset from '../../SettingsFieldset'; import SettingsStore from "../../../../../settings/SettingsStore"; import { VoiceBroadcastInfoEventType } from '../../../../../voice-broadcast'; +import { ElementCall } from "../../../../../models/Call"; +import SdkConfig, { DEFAULTS } from "../../../../../SdkConfig"; interface IEventShowOpts { isState?: boolean; @@ -60,6 +62,10 @@ const plEventsToShow: Record = { [EventType.Reaction]: { isState: false, hideForSpace: true }, [EventType.RoomRedaction]: { isState: false, hideForSpace: true }, + // MSC3401: Native Group VoIP signaling + [ElementCall.CALL_EVENT_TYPE.name]: { isState: true, hideForSpace: true }, + [ElementCall.MEMBER_EVENT_TYPE.name]: { isState: true, hideForSpace: true }, + // TODO: Enable support for m.widget event type (https://github.com/vector-im/element-web/issues/13111) "im.vector.modular.widgets": { isState: true, hideForSpace: true }, [VoiceBroadcastInfoEventType]: { isState: true, hideForSpace: true }, @@ -252,6 +258,11 @@ export default class RolesRoomSettingsTab extends React.Component { if (SettingsStore.getValue("feature_pinning")) { plEventsToLabels[EventType.RoomPinnedEvents] = _td("Manage pinned events"); } + // MSC3401: Native Group VoIP signaling + if (SettingsStore.getValue("feature_group_calls")) { + plEventsToLabels[ElementCall.CALL_EVENT_TYPE.name] = _td("Start %(brand)s calls"); + plEventsToLabels[ElementCall.MEMBER_EVENT_TYPE.name] = _td("Join %(brand)s calls"); + } const powerLevelDescriptors: Record = { "users_default": { @@ -435,7 +446,8 @@ export default class RolesRoomSettingsTab extends React.Component { let label = plEventsToLabels[eventType]; if (label) { - label = _t(label); + const brand = SdkConfig.get("element_call").brand ?? DEFAULTS.element_call.brand; + label = _t(label, { brand }); } else { label = _t("Send %(eventType)s events", { eventType }); } diff --git a/src/components/views/settings/tabs/room/VoipRoomSettingsTab.tsx b/src/components/views/settings/tabs/room/VoipRoomSettingsTab.tsx new file mode 100644 index 00000000000..b8dca6205c7 --- /dev/null +++ b/src/components/views/settings/tabs/room/VoipRoomSettingsTab.tsx @@ -0,0 +1,99 @@ +/* +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, { useCallback, useMemo, useState } from 'react'; +import { JoinRule } from "matrix-js-sdk/src/@types/partials"; +import { EventType } from "matrix-js-sdk/src/@types/event"; + +import { _t } from "../../../../../languageHandler"; +import { MatrixClientPeg } from "../../../../../MatrixClientPeg"; +import LabelledToggleSwitch from "../../../elements/LabelledToggleSwitch"; +import SettingsSubsection from "../../shared/SettingsSubsection"; +import SettingsTab from "../SettingsTab"; +import { ElementCall } from "../../../../../models/Call"; +import { useRoomState } from "../../../../../hooks/useRoomState"; +import SdkConfig, { DEFAULTS } from "../../../../../SdkConfig"; + +interface ElementCallSwitchProps { + roomId: string; +} + +const ElementCallSwitch: React.FC = ({ roomId }) => { + const room = useMemo(() => MatrixClientPeg.get().getRoom(roomId), [roomId]); + const isPublic = useMemo(() => room.getJoinRule() === JoinRule.Public, [room]); + const [content, events, maySend] = useRoomState(room, useCallback((state) => { + const content = state?.getStateEvents(EventType.RoomPowerLevels, "")?.getContent(); + return [ + content ?? {}, + content?.["events"] ?? {}, + state?.maySendStateEvent(EventType.RoomPowerLevels, MatrixClientPeg.get().getUserId()), + ]; + }, [])); + + const [elementCallEnabled, setElementCallEnabled] = useState(() => { + return events[ElementCall.MEMBER_EVENT_TYPE.name] === 0; + }); + + const onChange = useCallback((enabled: boolean): void => { + setElementCallEnabled(enabled); + + if (enabled) { + const userLevel = events[EventType.RoomMessage] ?? content.users_default ?? 0; + const moderatorLevel = content.kick ?? 50; + + events[ElementCall.CALL_EVENT_TYPE.name] = isPublic ? moderatorLevel : userLevel; + events[ElementCall.MEMBER_EVENT_TYPE.name] = userLevel; + } else { + const adminLevel = events[EventType.RoomPowerLevels] ?? content.state_default ?? 100; + + events[ElementCall.CALL_EVENT_TYPE.name] = adminLevel; + events[ElementCall.MEMBER_EVENT_TYPE.name] = adminLevel; + } + + MatrixClientPeg.get().sendStateEvent(roomId, EventType.RoomPowerLevels, { + "events": events, + ...content, + }); + }, [roomId, content, events, isPublic]); + + const brand = SdkConfig.get("element_call").brand ?? DEFAULTS.element_call.brand; + + return ; +}; + +interface Props { + roomId: string; +} + +export const VoipRoomSettingsTab: React.FC = ({ roomId }) => { + return + + + + ; +}; diff --git a/src/components/views/settings/tabs/user/LabsUserSettingsTab.tsx b/src/components/views/settings/tabs/user/LabsUserSettingsTab.tsx index 80e2ebb6cf1..60575876267 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/PreferencesUserSettingsTab.tsx b/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.tsx index 803a47d07f0..5ca2b744641 100644 --- a/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.tsx @@ -129,9 +129,12 @@ export default class PreferencesUserSettingsTab extends React.Component { + const cli = MatrixClientPeg.get(); + this.setState({ disablingReadReceiptsSupported: ( - await MatrixClientPeg.get().doesServerSupportUnstableFeature("org.matrix.msc2285.stable") + (await cli.doesServerSupportUnstableFeature("org.matrix.msc2285.stable")) + || (await cli.isVersionSupported("v1.4")) ), }); } @@ -215,7 +218,10 @@ export default class PreferencesUserSettingsTab extends React.Component ) }
    +
    + { _t("Sessions") } + +
    ; } @@ -340,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 bd26965451b..d1fbb6ce5c1 100644 --- a/src/components/views/settings/tabs/user/SessionManagerTab.tsx +++ b/src/components/views/settings/tabs/user/SessionManagerTab.tsx @@ -19,29 +19,29 @@ import { MatrixClient } from 'matrix-js-sdk/src/client'; import { logger } from 'matrix-js-sdk/src/logger'; import { _t } from "../../../../../languageHandler"; -import { DevicesState, useOwnDevices } from '../../devices/useOwnDevices'; -import SettingsSubsection from '../../shared/SettingsSubsection'; -import { FilteredDeviceList } from '../../devices/FilteredDeviceList'; -import CurrentDeviceSection from '../../devices/CurrentDeviceSection'; -import SecurityRecommendations from '../../devices/SecurityRecommendations'; -import { DeviceSecurityVariation, DeviceWithVerification } from '../../devices/types'; -import SettingsTab from '../SettingsTab'; +import MatrixClientContext from '../../../../../contexts/MatrixClientContext'; import Modal from '../../../../../Modal'; +import SettingsSubsection from '../../shared/SettingsSubsection'; import SetupEncryptionDialog from '../../../dialogs/security/SetupEncryptionDialog'; import VerificationRequestDialog from '../../../dialogs/VerificationRequestDialog'; import LogoutDialog from '../../../dialogs/LogoutDialog'; -import MatrixClientContext from '../../../../../contexts/MatrixClientContext'; +import { useOwnDevices } from '../../devices/useOwnDevices'; +import { FilteredDeviceList } from '../../devices/FilteredDeviceList'; +import CurrentDeviceSection from '../../devices/CurrentDeviceSection'; +import SecurityRecommendations from '../../devices/SecurityRecommendations'; +import { DeviceSecurityVariation, ExtendedDevice } from '../../devices/types'; import { deleteDevicesWithInteractiveAuth } from '../../devices/deleteDevices'; +import SettingsTab from '../SettingsTab'; const useSignOut = ( matrixClient: MatrixClient, - refreshDevices: DevicesState['refreshDevices'], + onSignoutResolvedCallback: () => Promise, ): { onSignOutCurrentDevice: () => void; - onSignOutOtherDevices: (deviceIds: DeviceWithVerification['device_id'][]) => Promise; - signingOutDeviceIds: DeviceWithVerification['device_id'][]; + onSignOutOtherDevices: (deviceIds: ExtendedDevice['device_id'][]) => Promise; + signingOutDeviceIds: ExtendedDevice['device_id'][]; } => { - const [signingOutDeviceIds, setSigningOutDeviceIds] = useState([]); + const [signingOutDeviceIds, setSigningOutDeviceIds] = useState([]); const onSignOutCurrentDevice = () => { Modal.createDialog( @@ -53,7 +53,7 @@ const useSignOut = ( ); }; - const onSignOutOtherDevices = async (deviceIds: DeviceWithVerification['device_id'][]) => { + const onSignOutOtherDevices = async (deviceIds: ExtendedDevice['device_id'][]) => { if (!deviceIds.length) { return; } @@ -64,9 +64,7 @@ const useSignOut = ( deviceIds, async (success) => { if (success) { - // @TODO(kerrya) clear selection if was bulk deletion - // when added in PSG-659 - await refreshDevices(); + await onSignoutResolvedCallback(); } setSigningOutDeviceIds(signingOutDeviceIds.filter(deviceId => !deviceIds.includes(deviceId))); }, @@ -87,14 +85,19 @@ const useSignOut = ( const SessionManagerTab: React.FC = () => { const { devices, + pushers, + localNotificationSettings, currentDeviceId, isLoadingDeviceList, requestDeviceVerification, refreshDevices, saveDeviceName, + setPushNotifications, + supportsMSC3881, } = useOwnDevices(); const [filter, setFilter] = useState(); - const [expandedDeviceIds, setExpandedDeviceIds] = useState([]); + const [expandedDeviceIds, setExpandedDeviceIds] = useState([]); + const [selectedDeviceIds, setSelectedDeviceIds] = useState([]); const filteredDeviceListRef = useRef(null); const scrollIntoViewTimeoutRef = useRef>(); @@ -102,7 +105,7 @@ const SessionManagerTab: React.FC = () => { const userId = matrixClient.getUserId(); const currentUserMember = userId && matrixClient.getUser(userId) || undefined; - const onDeviceExpandToggle = (deviceId: DeviceWithVerification['device_id']): void => { + const onDeviceExpandToggle = (deviceId: ExtendedDevice['device_id']): void => { if (expandedDeviceIds.includes(deviceId)) { setExpandedDeviceIds(expandedDeviceIds.filter(id => id !== deviceId)); } else { @@ -112,7 +115,6 @@ const SessionManagerTab: React.FC = () => { const onGoToFilteredList = (filter: DeviceSecurityVariation) => { setFilter(filter); - // @TODO(kerrya) clear selection when added in PSG-659 clearTimeout(scrollIntoViewTimeoutRef.current); // wait a tick for the filtered section to rerender with different height scrollIntoViewTimeoutRef.current = @@ -134,7 +136,7 @@ const SessionManagerTab: React.FC = () => { ); }; - const onTriggerDeviceVerification = useCallback((deviceId: DeviceWithVerification['device_id']) => { + const onTriggerDeviceVerification = useCallback((deviceId: ExtendedDevice['device_id']) => { if (!requestDeviceVerification) { return; } @@ -150,16 +152,29 @@ const SessionManagerTab: React.FC = () => { }); }, [requestDeviceVerification, refreshDevices, currentUserMember]); + const onSignoutResolvedCallback = async () => { + await refreshDevices(); + setSelectedDeviceIds([]); + }; const { onSignOutCurrentDevice, onSignOutOtherDevices, signingOutDeviceIds, - } = useSignOut(matrixClient, refreshDevices); + } = useSignOut(matrixClient, onSignoutResolvedCallback); useEffect(() => () => { clearTimeout(scrollIntoViewTimeoutRef.current); }, [scrollIntoViewTimeoutRef]); + // clear selection when filter changes + useEffect(() => { + setSelectedDeviceIds([]); + }, [filter, setSelectedDeviceIds]); + + const signOutAllOtherSessions = shouldShowOtherSessions ? () => { + onSignOutOtherDevices(Object.keys(otherDevices)); + }: undefined; + return { /> saveDeviceName(currentDevice?.device_id, deviceName)} + saveDeviceName={(deviceName) => saveDeviceName(currentDeviceId, deviceName)} onVerifyCurrentDevice={onVerifyCurrentDevice} onSignOutCurrentDevice={onSignOutCurrentDevice} + signOutAllOtherSessions={signOutAllOtherSessions} /> { shouldShowOtherSessions && @@ -186,15 +204,21 @@ const SessionManagerTab: React.FC = () => { > } diff --git a/src/components/views/spaces/SpaceCreateMenu.tsx b/src/components/views/spaces/SpaceCreateMenu.tsx index fee5f2190f3..f8c8e889ea2 100644 --- a/src/components/views/spaces/SpaceCreateMenu.tsx +++ b/src/components/views/spaces/SpaceCreateMenu.tsx @@ -31,13 +31,13 @@ import AccessibleButton from "../elements/AccessibleButton"; import Field from "../elements/Field"; import withValidation from "../elements/Validation"; import RoomAliasField from "../elements/RoomAliasField"; -import SdkConfig from "../../../SdkConfig"; import Modal from "../../../Modal"; import GenericFeatureFeedbackDialog from "../dialogs/GenericFeatureFeedbackDialog"; import SettingsStore from "../../../settings/SettingsStore"; import { getKeyBindingsManager } from "../../../KeyBindingsManager"; import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; +import { shouldShowFeedback } from "../../../utils/Feedback"; export const createSpace = async ( name: string, @@ -100,7 +100,7 @@ const nameToLocalpart = (name: string): string => { // XXX: Temporary for the Spaces release only export const SpaceFeedbackPrompt = ({ onClick }: { onClick?: () => void }) => { - if (!SdkConfig.get().bug_report_endpoint_url) return null; + if (!shouldShowFeedback()) return null; return
    { _t("Spaces are a new feature.") } diff --git a/src/components/views/spaces/SpacePanel.tsx b/src/components/views/spaces/SpacePanel.tsx index 129e6f3584e..e26a78b5a37 100644 --- a/src/components/views/spaces/SpacePanel.tsx +++ b/src/components/views/spaces/SpacePanel.tsx @@ -204,9 +204,7 @@ const CreateSpaceButton = ({ isPanelCollapsed, setPanelCollapsed, }: Pick) => { - // We don't need the handle as we position the menu in a constant location - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const [menuDisplayed, _handle, openMenu, closeMenu] = useContextMenu(); + const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu(); useEffect(() => { if (!isPanelCollapsed && menuDisplayed) { @@ -231,13 +229,14 @@ const CreateSpaceButton = ({ role="treeitem" > { contextMenu } diff --git a/src/components/views/spaces/SpaceTreeLevel.tsx b/src/components/views/spaces/SpaceTreeLevel.tsx index cab7bc3c76b..5952e877d9f 100644 --- a/src/components/views/spaces/SpaceTreeLevel.tsx +++ b/src/components/views/spaces/SpaceTreeLevel.tsx @@ -14,7 +14,16 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { MouseEvent, ComponentProps, ComponentType, createRef, InputHTMLAttributes, LegacyRef } from "react"; +import React, { + MouseEvent, + ComponentProps, + ComponentType, + createRef, + InputHTMLAttributes, + LegacyRef, + forwardRef, + RefObject, +} from "react"; import classNames from "classnames"; import { Room, RoomEvent } from "matrix-js-sdk/src/models/room"; import { DraggableProvidedDragHandleProps } from "react-beautiful-dnd"; @@ -54,7 +63,7 @@ interface IButtonProps extends Omit = ({ +export const SpaceButton = forwardRef(({ space, spaceKey, className, @@ -67,9 +76,9 @@ export const SpaceButton: React.FC = ({ children, ContextMenuComponent, ...props -}) => { - const [menuDisplayed, ref, openMenu, closeMenu] = useContextMenu(); - const [onFocus, isActive, handle] = useRovingTabIndex(ref); +}, ref: RefObject) => { + const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu(ref); + const [onFocus, isActive] = useRovingTabIndex(handle); const tabIndex = isActive ? 0 : -1; let avatar =
    ; @@ -150,7 +159,7 @@ export const SpaceButton: React.FC = ({
    ); -}; +}); interface IItemProps extends InputHTMLAttributes { space: Room; diff --git a/src/components/views/user-onboarding/UserOnboardingFeedback.tsx b/src/components/views/user-onboarding/UserOnboardingFeedback.tsx index b6bd03dfe86..a0b19c2fd87 100644 --- a/src/components/views/user-onboarding/UserOnboardingFeedback.tsx +++ b/src/components/views/user-onboarding/UserOnboardingFeedback.tsx @@ -22,9 +22,10 @@ import SdkConfig from "../../../SdkConfig"; import AccessibleButton from "../../views/elements/AccessibleButton"; import Heading from "../../views/typography/Heading"; import FeedbackDialog from "../dialogs/FeedbackDialog"; +import { shouldShowFeedback } from "../../../utils/Feedback"; export function UserOnboardingFeedback() { - if (!SdkConfig.get().bug_report_endpoint_url) { + if (!shouldShowFeedback()) { return null; } diff --git a/src/components/views/voip/CallDuration.tsx b/src/components/views/voip/CallDuration.tsx new file mode 100644 index 00000000000..38b30038ea7 --- /dev/null +++ b/src/components/views/voip/CallDuration.tsx @@ -0,0 +1,51 @@ +/* +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, { FC, useState, useEffect } from "react"; + +import type { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { formatCallTime } from "../../../DateUtils"; + +interface CallDurationProps { + delta: number; +} + +/** + * A call duration counter. + */ +export const CallDuration: FC = ({ delta }) => { + // Clock desync could lead to a negative duration, so just hide it if that happens + if (delta <= 0) return null; + return
    { formatCallTime(new Date(delta)) }
    ; +}; + +interface CallDurationFromEventProps { + mxEvent: MatrixEvent; +} + +/** + * A call duration counter that automatically counts up, given the event that + * started the call. + */ +export const CallDurationFromEvent: FC = ({ mxEvent }) => { + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + const timer = setInterval(() => setNow(Date.now()), 1000); + return () => clearInterval(timer); + }, []); + + return ; +}; diff --git a/src/components/views/voip/CallLobby.tsx b/src/components/views/voip/CallLobby.tsx deleted file mode 100644 index 39cef9e4061..00000000000 --- a/src/components/views/voip/CallLobby.tsx +++ /dev/null @@ -1,246 +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. -*/ - -import React, { FC, useState, useMemo, useRef, useEffect, useCallback } from "react"; -import classNames from "classnames"; -import { logger } from "matrix-js-sdk/src/logger"; -import { Room } from "matrix-js-sdk/src/models/room"; - -import { _t } from "../../../languageHandler"; -import { useAsyncMemo } from "../../../hooks/useAsyncMemo"; -import MediaDeviceHandler, { MediaDeviceKindEnum } from "../../../MediaDeviceHandler"; -import { useParticipants } from "../../../hooks/useCall"; -import { CallStore } from "../../../stores/CallStore"; -import { Call } from "../../../models/Call"; -import IconizedContextMenu, { - IconizedContextMenuOption, - IconizedContextMenuOptionList, -} from "../context_menus/IconizedContextMenu"; -import { aboveLeftOf, ContextMenuButton, useContextMenu } from "../../structures/ContextMenu"; -import { Alignment } from "../elements/Tooltip"; -import AccessibleButton from "../elements/AccessibleButton"; -import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; -import FacePile from "../elements/FacePile"; -import MemberAvatar from "../avatars/MemberAvatar"; - -interface DeviceButtonProps { - kind: string; - devices: MediaDeviceInfo[]; - setDevice: (device: MediaDeviceInfo) => void; - deviceListLabel: string; - fallbackDeviceLabel: (n: number) => string; - muted: boolean; - disabled: boolean; - toggle: () => void; - unmutedTitle: string; - mutedTitle: string; -} - -const DeviceButton: FC = ({ - kind, devices, setDevice, deviceListLabel, fallbackDeviceLabel, muted, disabled, toggle, unmutedTitle, mutedTitle, -}) => { - const [menuDisplayed, buttonRef, openMenu, closeMenu] = useContextMenu(); - let contextMenu; - if (menuDisplayed) { - const selectDevice = (device: MediaDeviceInfo) => { - setDevice(device); - closeMenu(); - }; - - const buttonRect = buttonRef.current!.getBoundingClientRect(); - contextMenu = - - { devices.map((d, index) => - selectDevice(d)} - />, - ) } - - ; - } - - if (!devices.length) return null; - - return
    - - { devices.length > 1 ? ( - - ) : null } - { contextMenu } -
    ; -}; - -const MAX_FACES = 8; - -interface Props { - room: Room; - call: Call; -} - -export const CallLobby: FC = ({ room, call }) => { - const [connecting, setConnecting] = useState(false); - const me = useMemo(() => room.getMember(room.myUserId)!, [room]); - const participants = useParticipants(call); - const videoRef = useRef(null); - - const [audioInputs, videoInputs] = useAsyncMemo(async () => { - try { - const devices = await MediaDeviceHandler.getDevices(); - return [devices[MediaDeviceKindEnum.AudioInput], devices[MediaDeviceKindEnum.VideoInput]]; - } catch (e) { - logger.warn(`Failed to get media device list`, e); - return [[], []]; - } - }, [], [[], []]); - - const [videoInputId, setVideoInputId] = useState(() => MediaDeviceHandler.getVideoInput()); - - const setAudioInput = useCallback((device: MediaDeviceInfo) => { - MediaDeviceHandler.instance.setAudioInput(device.deviceId); - }, []); - const setVideoInput = useCallback((device: MediaDeviceInfo) => { - MediaDeviceHandler.instance.setVideoInput(device.deviceId); - setVideoInputId(device.deviceId); - }, []); - - const [audioMuted, setAudioMuted] = useState(() => MediaDeviceHandler.startWithAudioMuted); - const [videoMuted, setVideoMuted] = useState(() => MediaDeviceHandler.startWithVideoMuted); - - const toggleAudio = useCallback(() => { - MediaDeviceHandler.startWithAudioMuted = !audioMuted; - setAudioMuted(!audioMuted); - }, [audioMuted, setAudioMuted]); - const toggleVideo = useCallback(() => { - MediaDeviceHandler.startWithVideoMuted = !videoMuted; - setVideoMuted(!videoMuted); - }, [videoMuted, setVideoMuted]); - - const videoStream = useAsyncMemo(async () => { - if (videoInputId && !videoMuted) { - try { - return await navigator.mediaDevices.getUserMedia({ - video: { deviceId: videoInputId }, - }); - } catch (e) { - logger.error(`Failed to get stream for device ${videoInputId}`, e); - } - } - return null; - }, [videoInputId, videoMuted]); - - useEffect(() => { - if (videoStream) { - const videoElement = videoRef.current!; - videoElement.srcObject = videoStream; - videoElement.play(); - - return () => { - videoStream?.getTracks().forEach(track => track.stop()); - videoElement.srcObject = null; - }; - } - }, [videoStream]); - - const connect = useCallback(async () => { - setConnecting(true); - try { - // Disconnect from any other active calls first, since we don't yet support holding - await Promise.all([...CallStore.instance.activeCalls].map(call => call.disconnect())); - await call.connect(); - } catch (e) { - logger.error(e); - setConnecting(false); - } - }, [call, setConnecting]); - - let facePile: JSX.Element | null = null; - if (participants.size) { - const shownMembers = [...participants].slice(0, MAX_FACES); - const overflow = participants.size > shownMembers.length; - - facePile =
    - { _t("%(count)s people joined", { count: participants.size }) } - -
    ; - } - - return
    - { facePile } -
    - -
    - - { _t("Join") } - -
    ; -}; diff --git a/src/components/views/voip/CallView.tsx b/src/components/views/voip/CallView.tsx new file mode 100644 index 00000000000..ea5d3c1e79d --- /dev/null +++ b/src/components/views/voip/CallView.tsx @@ -0,0 +1,403 @@ +/* +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, { FC, ReactNode, useState, useContext, useEffect, useMemo, useRef, useCallback } from "react"; +import classNames from "classnames"; +import { logger } from "matrix-js-sdk/src/logger"; +import { defer, IDeferred } from "matrix-js-sdk/src/utils"; + +import type { Room } from "matrix-js-sdk/src/models/room"; +import type { ConnectionState } from "../../../models/Call"; +import { Call, CallEvent, ElementCall, isConnected } from "../../../models/Call"; +import { + useCall, + useConnectionState, + useJoinCallButtonDisabled, + useJoinCallButtonTooltip, + useParticipants, +} from "../../../hooks/useCall"; +import MatrixClientContext from "../../../contexts/MatrixClientContext"; +import AppTile from "../elements/AppTile"; +import { _t } from "../../../languageHandler"; +import { useAsyncMemo } from "../../../hooks/useAsyncMemo"; +import MediaDeviceHandler, { MediaDeviceKindEnum } from "../../../MediaDeviceHandler"; +import { CallStore } from "../../../stores/CallStore"; +import IconizedContextMenu, { + IconizedContextMenuOption, + IconizedContextMenuOptionList, +} from "../context_menus/IconizedContextMenu"; +import { aboveLeftOf, ContextMenuButton, useContextMenu } from "../../structures/ContextMenu"; +import { Alignment } from "../elements/Tooltip"; +import { ButtonEvent } from "../elements/AccessibleButton"; +import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; +import FacePile from "../elements/FacePile"; +import MemberAvatar from "../avatars/MemberAvatar"; + +interface DeviceButtonProps { + kind: string; + devices: MediaDeviceInfo[]; + setDevice: (device: MediaDeviceInfo) => void; + deviceListLabel: string; + muted: boolean; + disabled: boolean; + toggle: () => void; + unmutedTitle: string; + mutedTitle: string; +} + +const DeviceButton: FC = ({ + kind, devices, setDevice, deviceListLabel, muted, disabled, toggle, unmutedTitle, mutedTitle, +}) => { + const [showMenu, buttonRef, openMenu, closeMenu] = useContextMenu(); + const selectDevice = useCallback((device: MediaDeviceInfo) => { + setDevice(device); + closeMenu(); + }, [setDevice, closeMenu]); + + let contextMenu: JSX.Element | null = null; + if (showMenu) { + const buttonRect = buttonRef.current!.getBoundingClientRect(); + contextMenu = + + { devices.map((d) => + selectDevice(d)} + />, + ) } + + ; + } + + if (!devices.length) return null; + + return
    + + { devices.length > 1 ? ( + + ) : null } + { contextMenu } +
    ; +}; + +const MAX_FACES = 8; + +interface LobbyProps { + room: Room; + connect: () => Promise; + joinCallButtonTooltip?: string; + joinCallButtonDisabled?: boolean; + children?: ReactNode; +} + +export const Lobby: FC = ({ room, joinCallButtonDisabled, joinCallButtonTooltip, connect, children }) => { + const [connecting, setConnecting] = useState(false); + const me = useMemo(() => room.getMember(room.myUserId)!, [room]); + const videoRef = useRef(null); + + const [videoInputId, setVideoInputId] = useState(() => MediaDeviceHandler.getVideoInput()); + + const [audioMuted, setAudioMuted] = useState(() => MediaDeviceHandler.startWithAudioMuted); + const [videoMuted, setVideoMuted] = useState(() => MediaDeviceHandler.startWithVideoMuted); + + const toggleAudio = useCallback(() => { + MediaDeviceHandler.startWithAudioMuted = !audioMuted; + setAudioMuted(!audioMuted); + }, [audioMuted, setAudioMuted]); + const toggleVideo = useCallback(() => { + MediaDeviceHandler.startWithVideoMuted = !videoMuted; + setVideoMuted(!videoMuted); + }, [videoMuted, setVideoMuted]); + + const [videoStream, audioInputs, videoInputs] = useAsyncMemo(async () => { + let previewStream: MediaStream; + try { + // We get the preview stream before requesting devices: this is because + // we need (in some browsers) an active media stream in order to get + // non-blank labels for the devices. According to the docs, we + // need a stream of each type (audio + video) if we want to enumerate + // audio & video devices, although this didn't seem to be the case + // in practice for me. We request both anyway. + // For similar reasons, we also request a stream even if video is muted, + // which could be a bit strange but allows us to get the device list + // reliably. One option could be to try & get devices without a stream, + // then try again with a stream if we get blank deviceids, but... ew. + previewStream = await navigator.mediaDevices.getUserMedia({ + video: { deviceId: videoInputId }, + audio: { deviceId: MediaDeviceHandler.getAudioInput() }, + }); + } catch (e) { + logger.error(`Failed to get stream for device ${videoInputId}`, e); + } + + const devices = await MediaDeviceHandler.getDevices(); + + // If video is muted, we don't actually want the stream, so we can get rid of + // it now. + if (videoMuted) { + previewStream.getTracks().forEach(t => t.stop()); + previewStream = undefined; + } + + return [previewStream, devices[MediaDeviceKindEnum.AudioInput], devices[MediaDeviceKindEnum.VideoInput]]; + }, [videoInputId, videoMuted], [null, [], []]); + + const setAudioInput = useCallback((device: MediaDeviceInfo) => { + MediaDeviceHandler.instance.setAudioInput(device.deviceId); + }, []); + const setVideoInput = useCallback((device: MediaDeviceInfo) => { + MediaDeviceHandler.instance.setVideoInput(device.deviceId); + setVideoInputId(device.deviceId); + }, []); + + useEffect(() => { + if (videoStream) { + const videoElement = videoRef.current!; + videoElement.srcObject = videoStream; + videoElement.play(); + + return () => { + videoStream?.getTracks().forEach(track => track.stop()); + videoElement.srcObject = null; + }; + } + }, [videoStream]); + + const onConnectClick = useCallback(async (ev: ButtonEvent) => { + ev.preventDefault(); + setConnecting(true); + try { + await connect(); + } catch (e) { + logger.error(e); + setConnecting(false); + } + }, [connect, setConnecting]); + + return
    + { children } +
    + +
    + +
    ; +}; + +interface StartCallViewProps { + room: Room; + resizing: boolean; + call: Call | null; + setStartingCall: (value: boolean) => void; +} + +const StartCallView: FC = ({ room, resizing, call, setStartingCall }) => { + const cli = useContext(MatrixClientContext); + + // Since connection has to be split across two different callbacks, we + // create a promise to communicate the results back to the caller + const connectDeferredRef = useRef>(); + if (connectDeferredRef.current === undefined) { + connectDeferredRef.current = defer(); + } + const connectDeferred = connectDeferredRef.current!; + + // Since the call might be null, we have to track connection state by hand. + // The alternative would be to split this component in two depending on + // whether we've received the call, so we could use the useConnectionState + // hook, but then React would remount the lobby when the call arrives. + const [connected, setConnected] = useState(() => call !== null && isConnected(call.connectionState)); + useEffect(() => { + if (call !== null) { + const onConnectionState = (state: ConnectionState) => setConnected(isConnected(state)); + call.on(CallEvent.ConnectionState, onConnectionState); + return () => { call.off(CallEvent.ConnectionState, onConnectionState); }; + } + }, [call]); + + const connect = useCallback(async () => { + setStartingCall(true); + await ElementCall.create(room); + await connectDeferred.promise; + }, [room, setStartingCall, connectDeferred]); + + useEffect(() => { + (async () => { + // If the call was successfully started, connect automatically + if (call !== null) { + try { + // Disconnect from any other active calls first, since we don't yet support holding + await Promise.all([...CallStore.instance.activeCalls].map(call => call.disconnect())); + await call.connect(); + connectDeferred.resolve(); + } catch (e) { + connectDeferred.reject(e); + } + } + })(); + }, [call, connectDeferred]); + + return
    + { connected ? null : } + { call !== null && } +
    ; +}; + +interface JoinCallViewProps { + room: Room; + resizing: boolean; + call: Call; +} + +const JoinCallView: FC = ({ room, resizing, call }) => { + const cli = useContext(MatrixClientContext); + const connected = isConnected(useConnectionState(call)); + const participants = useParticipants(call); + const joinCallButtonTooltip = useJoinCallButtonTooltip(call); + const joinCallButtonDisabled = useJoinCallButtonDisabled(call); + + const connect = useCallback(async () => { + // Disconnect from any other active calls first, since we don't yet support holding + await Promise.all([...CallStore.instance.activeCalls].map(call => call.disconnect())); + await call.connect(); + }, [call]); + + // We'll take this opportunity to tidy up our room state + useEffect(() => { call.clean(); }, [call]); + + let lobby: JSX.Element | null = null; + if (!connected) { + let facePile: JSX.Element | null = null; + if (participants.size) { + const shownMembers = [...participants].slice(0, MAX_FACES); + const overflow = participants.size > shownMembers.length; + + facePile =
    + { _t("%(count)s people joined", { count: participants.size }) } + +
    ; + } + + lobby = + { facePile } + ; + } + + return
    + { lobby } + { /* We render the widget even if we're disconnected, so it stays loaded */ } + +
    ; +}; + +interface CallViewProps { + room: Room; + resizing: boolean; + /** + * If true, the view will be blank until a call appears. Otherwise, the join + * button will create a call if there isn't already one. + */ + waitForCall: boolean; +} + +export const CallView: FC = ({ room, resizing, waitForCall }) => { + const call = useCall(room.roomId); + const [startingCall, setStartingCall] = useState(false); + + if (call === null || startingCall) { + if (waitForCall) return null; + return ; + } else { + return ; + } +}; diff --git a/src/components/views/voip/LegacyCallView/LegacyCallViewHeader.tsx b/src/components/views/voip/LegacyCallView/LegacyCallViewHeader.tsx index 6881be9cb6c..e7bc1c47396 100644 --- a/src/components/views/voip/LegacyCallView/LegacyCallViewHeader.tsx +++ b/src/components/views/voip/LegacyCallView/LegacyCallViewHeader.tsx @@ -32,7 +32,7 @@ const LegacyCallViewHeaderControls: React.FC = ({ onExp { onMaximize && } { onPin && { }; return ( -
    { onStartMoving: this.onStartMoving, onResize: this.onResize, }) } -
    + ); } } diff --git a/src/components/views/voip/PipView.tsx b/src/components/views/voip/PipView.tsx index be411bb1558..0bebfe1bf32 100644 --- a/src/components/views/voip/PipView.tsx +++ b/src/components/views/voip/PipView.tsx @@ -14,9 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { createRef } from 'react'; +import React, { createRef, useState } from 'react'; import { CallEvent, CallState, MatrixCall } from 'matrix-js-sdk/src/webrtc/call'; -import { EventSubscription } from 'fbemitter'; import { logger } from "matrix-js-sdk/src/logger"; import classNames from 'classnames'; import { Room } from "matrix-js-sdk/src/models/room"; @@ -25,7 +24,6 @@ import LegacyCallView from "./LegacyCallView"; import { RoomViewStore } from '../../../stores/RoomViewStore'; import LegacyCallHandler, { LegacyCallHandlerEvent } from '../../../LegacyCallHandler'; import PersistentApp from "../elements/PersistentApp"; -import SettingsStore from "../../../settings/SettingsStore"; import { MatrixClientPeg } from '../../../MatrixClientPeg'; import PictureInPictureDragger from './PictureInPictureDragger'; import dis from '../../../dispatcher/dispatcher'; @@ -35,6 +33,15 @@ import LegacyCallViewHeader from './LegacyCallView/LegacyCallViewHeader'; import ActiveWidgetStore, { ActiveWidgetStoreEvent } from '../../../stores/ActiveWidgetStore'; import WidgetStore, { IApp } from "../../../stores/WidgetStore"; import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; +import { UPDATE_EVENT } from '../../../stores/AsyncStore'; +import { CallStore } from "../../../stores/CallStore"; +import { + VoiceBroadcastRecording, + VoiceBroadcastRecordingPip, + VoiceBroadcastRecordingsStore, + VoiceBroadcastRecordingsStoreEvent, +} from '../../../voice-broadcast'; +import { useTypedEventEmitter } from '../../../hooks/useEventEmitter'; const SHOW_CALL_IN_STATES = [ CallState.Connected, @@ -46,6 +53,7 @@ const SHOW_CALL_IN_STATES = [ ]; interface IProps { + voiceBroadcastRecording?: VoiceBroadcastRecording; } interface IState { @@ -115,9 +123,7 @@ function getPrimarySecondaryCallsForPip(roomId: string): [MatrixCall, MatrixCall * and all widgets that are active but not shown in any other possible container. */ -export default class PipView extends React.Component { - private roomStoreToken: EventSubscription; - private settingsWatcherRef: string; +class PipView extends React.Component { private movePersistedElement = createRef<() => void>(); constructor(props: IProps) { @@ -141,7 +147,7 @@ export default class PipView extends React.Component { public componentDidMount() { LegacyCallHandler.instance.addListener(LegacyCallHandlerEvent.CallChangeRoom, this.updateCalls); LegacyCallHandler.instance.addListener(LegacyCallHandlerEvent.CallState, this.updateCalls); - this.roomStoreToken = RoomViewStore.instance.addListener(this.onRoomViewStoreUpdate); + RoomViewStore.instance.addListener(UPDATE_EVENT, this.onRoomViewStoreUpdate); MatrixClientPeg.get().on(CallEvent.RemoteHoldUnhold, this.onCallRemoteHold); const room = MatrixClientPeg.get()?.getRoom(this.state.viewedRoomId); if (room) { @@ -156,10 +162,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); - this.roomStoreToken?.remove(); - SettingsStore.unwatchSetting(this.settingsWatcherRef); - const room = MatrixClientPeg.get().getRoom(this.state.viewedRoomId); + const cli = MatrixClientPeg.get(); + cli?.removeListener(CallEvent.RemoteHoldUnhold, this.onCallRemoteHold); + RoomViewStore.instance.removeListener(UPDATE_EVENT, this.onRoomViewStoreUpdate); + const room = cli?.getRoom(this.state.viewedRoomId); if (room) { WidgetLayoutStore.instance.off(WidgetLayoutStore.emissionForRoom(room), this.updateCalls); } @@ -279,6 +285,14 @@ export default class PipView extends React.Component { }); }; + private onViewCall = (): void => + dis.dispatch({ + action: Action.ViewRoom, + room_id: this.state.persistentRoomId, + view_call: true, + metricsTrigger: undefined, + }); + // Accepts a persistentWidgetId to be able to skip awaiting the setState for persistentWidgetId public updateShowWidgetInPip( persistentWidgetId = this.state.persistentWidgetId, @@ -324,18 +338,19 @@ export default class PipView extends React.Component { mx_LegacyCallView_large: !pipMode, }); const roomId = this.state.persistentRoomId; - const roomForWidget = MatrixClientPeg.get().getRoom(roomId); + const roomForWidget = MatrixClientPeg.get().getRoom(roomId)!; const viewingCallRoom = this.state.viewedRoomId === roomId; + const isCall = CallStore.instance.getActiveCall(roomId) !== null; - pipContent = ({ onStartMoving, _onResize }) => + pipContent = ({ onStartMoving }) =>
    { onStartMoving(event); this.onStartMoving.bind(this)(); }} pipMode={pipMode} callRooms={[roomForWidget]} - onExpand={!viewingCallRoom && this.onExpand} - onPin={viewingCallRoom && this.onPin} - onMaximize={viewingCallRoom && this.onMaximize} + onExpand={!isCall && !viewingCallRoom ? this.onExpand : undefined} + onPin={!isCall && viewingCallRoom ? this.onPin : undefined} + onMaximize={isCall ? this.onViewCall : viewingCallRoom ? this.onMaximize : undefined} /> {
    ; } + if (this.props.voiceBroadcastRecording) { + pipContent = ({ onStartMoving }) =>
    + +
    ; + } + if (!!pipContent) { return { return null; } } + +const PipViewHOC: React.FC = (props) => { + // TODO Michael W: extract to custom hook + + const voiceBroadcastRecordingsStore = VoiceBroadcastRecordingsStore.instance(); + const [voiceBroadcastRecording, setVoiceBroadcastRecording] = useState( + voiceBroadcastRecordingsStore.getCurrent(), + ); + + useTypedEventEmitter( + voiceBroadcastRecordingsStore, + VoiceBroadcastRecordingsStoreEvent.CurrentChanged, + (recording: VoiceBroadcastRecording) => { + setVoiceBroadcastRecording(recording); + }, + ); + + return ; +}; + +export default PipViewHOC; diff --git a/src/contexts/MatrixClientContext.tsx b/src/contexts/MatrixClientContext.tsx index 292c1e34d8d..4b89bc32133 100644 --- a/src/contexts/MatrixClientContext.tsx +++ b/src/contexts/MatrixClientContext.tsx @@ -25,6 +25,10 @@ export interface MatrixClientProps { mxClient: MatrixClient; } +export function useMatrixClientContext() { + return useContext(MatrixClientContext); +} + const matrixHOC = ( ComposedComponent: ComponentClass, ) => { diff --git a/src/contexts/RoomContext.ts b/src/contexts/RoomContext.ts index 85cf1c9fdc4..8814c4b9efd 100644 --- a/src/contexts/RoomContext.ts +++ b/src/contexts/RoomContext.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { createContext } from "react"; +import { createContext, useContext } from "react"; import { IRoomState } from "../components/structures/RoomView"; import { Layout } from "../settings/enums/Layout"; @@ -67,6 +67,10 @@ const RoomContext = createContext({ threadId: undefined, liveTimeline: undefined, narrow: false, + activeCall: null, }); RoomContext.displayName = "RoomContext"; export default RoomContext; +export function useRoomContext() { + return useContext(RoomContext); +} diff --git a/src/createRoom.ts b/src/createRoom.ts index c1bcc122ca1..cc3cbc5373d 100644 --- a/src/createRoom.ts +++ b/src/createRoom.ts @@ -46,6 +46,7 @@ import { findDMForUser } from "./utils/dm/findDMForUser"; import { privateShouldBeEncrypted } from "./utils/rooms"; import { waitForMember } from "./utils/membership"; import { PreferredRoomVersions } from "./utils/PreferredRoomVersions"; +import SettingsStore from "./settings/SettingsStore"; // we define a number of interfaces which take their names from the js-sdk /* eslint-disable camelcase */ @@ -158,9 +159,9 @@ export default async function createRoom(opts: IOpts): Promise { events: { ...DEFAULT_EVENT_POWER_LEVELS, // Allow all users to send call membership updates - "org.matrix.msc3401.call.member": 0, + [ElementCall.MEMBER_EVENT_TYPE.name]: 0, // Make calls immutable, even to admins - "org.matrix.msc3401.call": 200, + [ElementCall.CALL_EVENT_TYPE.name]: 200, }, users: { // Temporarily give ourselves the power to set up a call @@ -168,6 +169,16 @@ export default async function createRoom(opts: IOpts): Promise { }, }; } + } else if (SettingsStore.getValue("feature_group_calls")) { + createOpts.power_level_content_override = { + events: { + ...DEFAULT_EVENT_POWER_LEVELS, + // Element Call should be disabled by default + [ElementCall.MEMBER_EVENT_TYPE.name]: 100, + // Make sure only admins can enable it + [ElementCall.CALL_EVENT_TYPE.name]: 100, + }, + }; } // By default, view the room after creating it @@ -235,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 6d9e9a8b62c..ae0daa53c51 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 d911a7cc3c1..a8dacd84aad 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 4e161a70051..2b2e443e81d 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 7db4a4a4d74..ac47596e55c 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/dispatcher/payloads/ViewRoomPayload.ts b/src/dispatcher/payloads/ViewRoomPayload.ts index cd62f7ca3fd..e497939ff04 100644 --- a/src/dispatcher/payloads/ViewRoomPayload.ts +++ b/src/dispatcher/payloads/ViewRoomPayload.ts @@ -47,6 +47,7 @@ export interface ViewRoomPayload extends Pick { forceTimeline?: boolean; // Whether to override default behaviour to end up at a timeline show_room_tile?: boolean; // Whether to ensure that the room tile is visible in the room list clear_search?: boolean; // Whether to clear the room list search + view_call?: boolean; // Whether to view the call or call lobby for the room deferred_action?: ActionPayload; // Action to fire after MatrixChat handles this ViewRoom action diff --git a/src/editor/parts.ts b/src/editor/parts.ts index e11aab3d966..95ef7db9063 100644 --- a/src/editor/parts.ts +++ b/src/editor/parts.ts @@ -450,7 +450,7 @@ class CustomEmojiPart extends PillPart implements IPillPart { } class RoomPillPart extends PillPart { - constructor(resourceId: string, label: string, private room: Room) { + constructor(resourceId: string, label: string, private room?: Room) { super(resourceId, label); } @@ -458,8 +458,8 @@ class RoomPillPart extends PillPart { let initialLetter = ""; let avatarUrl = Avatar.avatarUrlForRoom(this.room, 16, 16, "crop"); if (!avatarUrl) { - initialLetter = Avatar.getInitialLetter(this.room ? this.room.name : this.resourceId); - avatarUrl = Avatar.defaultAvatarUrlForString(this.room ? this.room.roomId : this.resourceId); + initialLetter = Avatar.getInitialLetter(this.room?.name || this.resourceId); + avatarUrl = Avatar.defaultAvatarUrlForString(this.room?.roomId ?? this.resourceId); } this.setAvatarVars(node, avatarUrl, initialLetter); } @@ -469,7 +469,7 @@ class RoomPillPart extends PillPart { } protected get className() { - return "mx_Pill " + (this.room.isSpaceRoom() ? "mx_SpacePill" : "mx_RoomPill"); + return "mx_Pill " + (this.room?.isSpaceRoom() ? "mx_SpacePill" : "mx_RoomPill"); } } @@ -651,7 +651,7 @@ export class PartCreator { } public roomPill(alias: string, roomId?: string): RoomPillPart { - let room; + let room: Room | undefined; if (roomId || alias[0] !== "#") { room = this.client.getRoom(roomId || alias); } else { diff --git a/src/events/EventTileFactory.tsx b/src/events/EventTileFactory.tsx index c19550a7d47..9c2f5738b66 100644 --- a/src/events/EventTileFactory.tsx +++ b/src/events/EventTileFactory.tsx @@ -28,6 +28,7 @@ import { TimelineRenderingType } from "../contexts/RoomContext"; import MessageEvent from "../components/views/messages/MessageEvent"; import MKeyVerificationConclusion from "../components/views/messages/MKeyVerificationConclusion"; import LegacyCallEvent from "../components/views/messages/LegacyCallEvent"; +import { CallEvent } from "../components/views/messages/CallEvent"; import TextualEvent from "../components/views/messages/TextualEvent"; import EncryptionEvent from "../components/views/messages/EncryptionEvent"; import RoomCreate from "../components/views/messages/RoomCreate"; @@ -45,6 +46,9 @@ import ViewSourceEvent from "../components/views/messages/ViewSourceEvent"; import { UserNameColorMode } from "../settings/enums/UserNameColorMode"; import { Layout } from "../settings/enums/Layout"; 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 { @@ -81,6 +85,7 @@ const KeyVerificationConclFactory: Factory = (ref, props) => = (ref, props) => ( ); +const CallEventFactory: Factory = (ref, props) => ; const TextualEventFactory: Factory = (ref, props) => ; const VerificationReqFactory: Factory = (ref, props) => ; const HiddenEventFactory: Factory = (ref, props) => ; @@ -120,6 +125,10 @@ const STATE_EVENT_TILE_TYPES = new Map([ [EventType.RoomGuestAccess, TextualEventFactory], ]); +for (const evType of ElementCall.CALL_EVENT_TYPE.names) { + STATE_EVENT_TILE_TYPES.set(evType, CallEventFactory); +} + // Add all the Mjolnir stuff to the renderer too for (const evType of ALL_RULE_TYPES) { STATE_EVENT_TILE_TYPES.set(evType, TextualEventFactory); @@ -228,6 +237,10 @@ export function pickFactory( return MessageEventFactory; } + if (shouldDisplayAsVoiceBroadcastTile(mxEvent)) { + return MessageEventFactory; + } + if (SINGULAR_STATE_EVENTS.has(evType) && mxEvent.getStateKey() !== '') { return noEventFactoryFactory(); // improper event type to render } @@ -248,6 +261,11 @@ export function pickFactory( return noEventFactoryFactory(); } + if (mxEvent.getContent()[VoiceBroadcastChunkEventType]) { + // hide voice broadcast chunks + return noEventFactoryFactory(); + } + return EVENT_TILE_TYPES.get(evType) ?? noEventFactoryFactory(); } @@ -430,6 +448,15 @@ export function haveRendererForEvent(mxEvent: MatrixEvent, showHiddenEvents: boo return hasText(mxEvent, showHiddenEvents); } else if (handler === STATE_EVENT_TILE_TYPES.get(EventType.RoomCreate)) { return Boolean(mxEvent.getContent()['predecessor']); + } else if (ElementCall.CALL_EVENT_TYPE.names.some(eventType => handler === STATE_EVENT_TILE_TYPES.get(eventType))) { + const intent = mxEvent.getContent()['m.intent']; + const prevContent = mxEvent.getPrevContent(); + // If the call became unterminated or previously had invalid contents, + // then this event marks the start of the call + const newlyStarted = 'm.terminated' in prevContent + || !('m.intent' in prevContent) || !('m.type' in prevContent); + // Only interested in events that mark the start of a non-room call + return typeof intent === 'string' && intent !== 'm.room' && newlyStarted; } else if (handler === JSONEventFactory) { return false; } else { diff --git a/src/events/RelationsHelper.ts b/src/events/RelationsHelper.ts new file mode 100644 index 00000000000..b211d03862d --- /dev/null +++ b/src/events/RelationsHelper.ts @@ -0,0 +1,98 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { MatrixClient, MatrixEvent, MatrixEventEvent, RelationType } from "matrix-js-sdk/src/matrix"; +import { Relations, RelationsEvent } from "matrix-js-sdk/src/models/relations"; +import { TypedEventEmitter } from "matrix-js-sdk/src/models/typed-event-emitter"; + +import { IDestroyable } from "../utils/IDestroyable"; + +export enum RelationsHelperEvent { + Add = "add", +} + +interface EventMap { + [RelationsHelperEvent.Add]: (event: MatrixEvent) => void; +} + +/** + * Helper class that manages a specific event type relation for an event. + * Just create an instance and listen for new events for that relation. + * Optionally receive the current events by calling emitCurrent(). + * Clean up everything by calling destroy(). + */ +export class RelationsHelper + extends TypedEventEmitter + implements IDestroyable { + private relations?: Relations; + + public constructor( + private event: MatrixEvent, + private relationType: RelationType, + private relationEventType: string, + private client: MatrixClient, + ) { + super(); + this.setUpRelations(); + } + + private setUpRelations = (): void => { + this.setRelations(); + + if (this.relations) { + this.relations.on(RelationsEvent.Add, this.onRelationsAdd); + } else { + this.event.once(MatrixEventEvent.RelationsCreated, this.onRelationsCreated); + } + }; + + private onRelationsCreated = (): void => { + this.setRelations(); + + if (this.relations) { + this.relations.on(RelationsEvent.Add, this.onRelationsAdd); + this.emitCurrent(); + } else { + this.event.once(MatrixEventEvent.RelationsCreated, this.onRelationsCreated); + } + }; + + private setRelations(): void { + const room = this.client.getRoom(this.event.getRoomId()); + this.relations = room?.getUnfilteredTimelineSet()?.relations?.getChildEventsForEvent( + this.event.getId(), + this.relationType, + this.relationEventType, + ); + } + + private onRelationsAdd = (event: MatrixEvent): void => { + this.emit(RelationsHelperEvent.Add, event); + }; + + public emitCurrent(): void { + this.relations?.getRelations()?.forEach(e => this.emit(RelationsHelperEvent.Add, e)); + } + + public destroy(): void { + this.removeAllListeners(); + this.event.off(MatrixEventEvent.RelationsCreated, this.onRelationsCreated); + + if (this.relations) { + this.relations.off(RelationsEvent.Add, this.onRelationsAdd); + } + } +} diff --git a/src/events/getReferenceRelationsForEvent.ts b/src/events/getReferenceRelationsForEvent.ts new file mode 100644 index 00000000000..7f68e9e5fd6 --- /dev/null +++ b/src/events/getReferenceRelationsForEvent.ts @@ -0,0 +1,33 @@ +/* +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 { EventType, MatrixClient, MatrixEvent, RelationType } from "matrix-js-sdk/src/matrix"; +import { Relations } from "matrix-js-sdk/src/models/relations"; + +import { VoiceBroadcastInfoEventType } from "../voice-broadcast"; + +export const getReferenceRelationsForEvent = ( + event: MatrixEvent, + messageType: EventType | typeof VoiceBroadcastInfoEventType, + client: MatrixClient, +): Relations | undefined => { + const room = client.getRoom(event.getRoomId()); + return room?.getUnfilteredTimelineSet()?.relations?.getChildEventsForEvent( + event.getId(), + RelationType.Reference, + messageType, + ); +}; diff --git a/src/events/index.ts b/src/events/index.ts index 67ebedbb4d3..8d47a94399e 100644 --- a/src/events/index.ts +++ b/src/events/index.ts @@ -16,3 +16,4 @@ limitations under the License. export { getForwardableEvent } from './forward/getForwardableEvent'; export { getShareableLocationEvent } from './location/getShareableLocationEvent'; +export * from "./getReferenceRelationsForEvent"; diff --git a/src/group_helpers.tsx b/src/group_helpers.tsx deleted file mode 100644 index 9e5a96aa193..00000000000 --- a/src/group_helpers.tsx +++ /dev/null @@ -1,45 +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. -*/ - -import * as React from "react"; - -import Modal from "./Modal"; -import QuestionDialog from "./components/views/dialogs/QuestionDialog"; -import { _t } from "./languageHandler"; -import SdkConfig, { DEFAULTS } from "./SdkConfig"; - -export function showGroupReplacedWithSpacesDialog(groupId: string) { - const learnMoreUrl = SdkConfig.get().spaces_learn_more_url ?? DEFAULTS.spaces_learn_more_url; - Modal.createDialog(QuestionDialog, { - title: _t("That link is no longer supported"), - description: <> -

    - { _t( - "You're trying to access a community link (%(groupId)s).
    " + - "Communities are no longer supported and have been replaced by spaces." + - "Learn more about spaces here.", - { groupId }, - { - br: () =>
    , - br2: () =>
    , - a: (sub) => { sub }, - }, - ) } -

    - , - hasCancelButton: false, - }); -} diff --git a/src/hooks/useCall.ts b/src/hooks/useCall.ts index 6a32ee1894c..cf9bbee0d0d 100644 --- a/src/hooks/useCall.ts +++ b/src/hooks/useCall.ts @@ -14,17 +14,20 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { useState, useCallback } from "react"; +import { useState, useCallback, useMemo } from "react"; import type { RoomMember } from "matrix-js-sdk/src/models/room-member"; -import type { Call, ConnectionState } from "../models/Call"; +import { Call, ConnectionState, ElementCall, Layout } from "../models/Call"; import { useTypedEventEmitterState } from "./useEventEmitter"; import { CallEvent } from "../models/Call"; import { CallStore, CallStoreEvent } from "../stores/CallStore"; import { useEventEmitter } from "./useEventEmitter"; +import SdkConfig, { DEFAULTS } from "../SdkConfig"; +import { _t } from "../languageHandler"; +import { MatrixClientPeg } from "../MatrixClientPeg"; export const useCall = (roomId: string): Call | null => { - const [call, setCall] = useState(() => CallStore.instance.get(roomId)); + const [call, setCall] = useState(() => CallStore.instance.getCall(roomId)); useEventEmitter(CallStore.instance, CallStoreEvent.Call, (call: Call | null, forRoomId: string) => { if (forRoomId === roomId) setCall(call); }); @@ -44,3 +47,46 @@ export const useParticipants = (call: Call): Set => CallEvent.Participants, useCallback(state => state ?? call.participants, [call]), ); + +export const useFull = (call: Call): boolean => { + const participants = useParticipants(call); + + return ( + participants.size + >= (SdkConfig.get("element_call").participant_limit ?? DEFAULTS.element_call.participant_limit) + ); +}; + +export const useIsAlreadyParticipant = (call: Call): boolean => { + const client = MatrixClientPeg.get(); + const participants = useParticipants(call); + + return useMemo(() => { + return participants.has(client.getRoom(call.roomId).getMember(client.getUserId())); + }, [participants, client, call]); +}; + +export const useJoinCallButtonTooltip = (call: Call): string | null => { + const isFull = useFull(call); + const state = useConnectionState(call); + const isAlreadyParticipant = useIsAlreadyParticipant(call); + + if (state === ConnectionState.Connecting) return _t("Connecting"); + if (isFull) return _t("Sorry — this call is currently full"); + if (isAlreadyParticipant) return _t("You have already joined this call from another device"); + return null; +}; + +export const useJoinCallButtonDisabled = (call: Call): boolean => { + const isFull = useFull(call); + const state = useConnectionState(call); + + return isFull || state === ConnectionState.Connecting; +}; + +export const useLayout = (call: ElementCall): Layout => + useTypedEventEmitterState( + call, + CallEvent.Layout, + useCallback(state => state ?? call.layout, [call]), + ); diff --git a/src/hooks/useEventEmitter.ts b/src/hooks/useEventEmitter.ts index 46a7d8f184c..ff1592028ae 100644 --- a/src/hooks/useEventEmitter.ts +++ b/src/hooks/useEventEmitter.ts @@ -87,7 +87,7 @@ export function useEventEmitterState( eventName: string | symbol, fn: Mapper, ): T { - const [value, setValue] = useState(fn()); + const [value, setValue] = useState(fn); const handler = useCallback((...args: any[]) => { setValue(fn(...args)); }, [fn]); diff --git a/src/languageHandler.tsx b/src/languageHandler.tsx index 2caf5c1639e..0fc5fbb4d2d 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/linkify-matrix.ts b/src/linkify-matrix.ts index 896784cb454..f2df222eec1 100644 --- a/src/linkify-matrix.ts +++ b/src/linkify-matrix.ts @@ -30,13 +30,11 @@ import dis from './dispatcher/dispatcher'; import { Action } from './dispatcher/actions'; import { ViewUserPayload } from './dispatcher/payloads/ViewUserPayload'; import { ViewRoomPayload } from "./dispatcher/payloads/ViewRoomPayload"; -import { showGroupReplacedWithSpacesDialog } from "./group_helpers"; export enum Type { URL = "url", UserId = "userid", RoomAlias = "roomalias", - GroupId = "groupid", } // Linkify stuff doesn't type scanner/parser/utils properly :/ @@ -115,11 +113,6 @@ function onUserClick(event: MouseEvent, userId: string) { }); } -function onGroupClick(event: MouseEvent, groupId: string) { - event.preventDefault(); - showGroupReplacedWithSpacesDialog(groupId); -} - function onAliasClick(event: MouseEvent, roomAlias: string) { event.preventDefault(); dis.dispatch({ @@ -192,15 +185,6 @@ export const options = { onAliasClick(e, alias); }, }; - - case Type.GroupId: - return { - // @ts-ignore see https://linkify.js.org/docs/options.html - click: function(e: MouseEvent) { - const groupId = parsePermalink(href).groupId; - onGroupClick(e, groupId); - }, - }; } }, @@ -208,7 +192,6 @@ export const options = { switch (type) { case Type.RoomAlias: case Type.UserId: - case Type.GroupId: default: { return tryTransformEntityToPermalink(href); } @@ -255,17 +238,6 @@ registerPlugin(Type.RoomAlias, ({ scanner, parser, utils }) => { }); }); -registerPlugin(Type.GroupId, ({ scanner, parser, utils }) => { - const token = scanner.tokens.PLUS as '+'; - matrixOpaqueIdLinkifyParser({ - scanner, - parser, - utils, - token, - name: Type.GroupId, - }); -}); - registerPlugin(Type.UserId, ({ scanner, parser, utils }) => { const token = scanner.tokens.AT as '@'; matrixOpaqueIdLinkifyParser({ diff --git a/src/models/Call.ts b/src/models/Call.ts index 9b11261e85d..fd207cf1bee 100644 --- a/src/models/Call.ts +++ b/src/models/Call.ts @@ -17,7 +17,7 @@ limitations under the License. import { TypedEventEmitter } from "matrix-js-sdk/src/models/typed-event-emitter"; import { logger } from "matrix-js-sdk/src/logger"; import { randomString } from "matrix-js-sdk/src/randomstring"; -import { MatrixClient } from "matrix-js-sdk/src/client"; +import { ClientEvent, MatrixClient } from "matrix-js-sdk/src/client"; import { RoomEvent } from "matrix-js-sdk/src/models/room"; import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state"; import { CallType } from "matrix-js-sdk/src/webrtc/call"; @@ -31,7 +31,7 @@ import type { Room } from "matrix-js-sdk/src/models/room"; import type { RoomMember } from "matrix-js-sdk/src/models/room-member"; import type { ClientWidgetApi } from "matrix-widget-api"; import type { IApp } from "../stores/WidgetStore"; -import SdkConfig from "../SdkConfig"; +import SdkConfig, { DEFAULTS } from "../SdkConfig"; import SettingsStore from "../settings/SettingsStore"; import MediaDeviceHandler, { MediaDeviceKindEnum } from "../MediaDeviceHandler"; import { timeout } from "../utils/promise"; @@ -41,6 +41,8 @@ import { ElementWidgetActions } from "../stores/widgets/ElementWidgetActions"; import WidgetStore from "../stores/WidgetStore"; import { WidgetMessagingStore, WidgetMessagingStoreEvent } from "../stores/widgets/WidgetMessagingStore"; import ActiveWidgetStore, { ActiveWidgetStoreEvent } from "../stores/ActiveWidgetStore"; +import PlatformPeg from "../PlatformPeg"; +import { getCurrentLanguage } from "../languageHandler"; const TIMEOUT_MS = 16000; @@ -71,15 +73,22 @@ export enum ConnectionState { export const isConnected = (state: ConnectionState): boolean => state === ConnectionState.Connected || state === ConnectionState.Disconnecting; +export enum Layout { + Tile = "tile", + Spotlight = "spotlight", +} + export enum CallEvent { ConnectionState = "connection_state", Participants = "participants", + Layout = "layout", Destroy = "destroy", } interface CallEventHandlerMap { [CallEvent.ConnectionState]: (state: ConnectionState, prevState: ConnectionState) => void; - [CallEvent.Participants]: (participants: Set) => void; + [CallEvent.Participants]: (participants: Set, prevParticipants: Set) => void; + [CallEvent.Layout]: (layout: Layout) => void; [CallEvent.Destroy]: () => void; } @@ -110,7 +119,7 @@ export abstract class Call extends TypedEventEmitter) { + const prevValue = this._participants; this._participants = value; - this.emit(CallEvent.Participants, value); + this.emit(CallEvent.Participants, value, prevValue); } constructor( @@ -598,13 +608,26 @@ export interface ElementCallMemberContent { export class ElementCall extends Call { public static readonly CALL_EVENT_TYPE = new NamespacedValue(null, "org.matrix.msc3401.call"); public static readonly MEMBER_EVENT_TYPE = new NamespacedValue(null, "org.matrix.msc3401.call.member"); + public static readonly DUPLICATE_CALL_DEVICE_EVENT_TYPE = "io.element.duplicate_call_device"; public readonly STUCK_DEVICE_TIMEOUT_MS = 1000 * 60 * 60; // 1 hour + private kickedOutByAnotherDevice = false; + private connectionTime: number | null = null; private participantsExpirationTimer: number | null = null; + private terminationTimer: number | null = null; + + private _layout = Layout.Tile; + public get layout(): Layout { + return this._layout; + } + protected set layout(value: Layout) { + this._layout = value; + this.emit(CallEvent.Layout, value); + } private constructor(public readonly groupCall: MatrixEvent, client: MatrixClient) { // Splice together the Element Call URL for this call - const url = new URL(SdkConfig.get("element_call").url); + const url = new URL(SdkConfig.get("element_call").url ?? DEFAULTS.element_call.url!); url.pathname = "/room"; const params = new URLSearchParams({ embed: "", @@ -613,7 +636,13 @@ export class ElementCall extends Call { userId: client.getUserId()!, deviceId: client.getDeviceId(), roomId: groupCall.getRoomId()!, + baseUrl: client.baseUrl, + lang: getCurrentLanguage().replace("_", "-"), }); + // Currently, the screen-sharing support is the same is it is for Jitsi + if (!PlatformPeg.get().supportsJitsiScreensharing()) { + params.append("hideScreensharing", ""); + } url.hash = `#?${params.toString()}`; // To use Element Call without touching room state, we create a virtual @@ -631,15 +660,18 @@ export class ElementCall extends Call { this.room.on(RoomStateEvent.Update, this.onRoomState); this.on(CallEvent.ConnectionState, this.onConnectionState); + this.on(CallEvent.Participants, this.onParticipants); this.updateParticipants(); } public static get(room: Room): ElementCall | null { - // Only supported in video rooms (for now) + // Only supported in the new group call experience or in video rooms if ( - SettingsStore.getValue("feature_video_rooms") - && SettingsStore.getValue("feature_element_call_video_rooms") - && room.isCallRoom() + SettingsStore.getValue("feature_group_calls") || ( + SettingsStore.getValue("feature_video_rooms") + && SettingsStore.getValue("feature_element_call_video_rooms") + && room.isCallRoom() + ) ) { const groupCalls = ElementCall.CALL_EVENT_TYPE.names.flatMap(eventType => room.currentState.getStateEvents(eventType), @@ -663,8 +695,12 @@ export class ElementCall extends Call { } public static async create(room: Room): Promise { + const isVideoRoom = SettingsStore.getValue("feature_video_rooms") + && SettingsStore.getValue("feature_element_call_video_rooms") + && room.isCallRoom(); + await room.client.sendStateEvent(room.roomId, ElementCall.CALL_EVENT_TYPE.name, { - "m.intent": "m.room", + "m.intent": isVideoRoom ? "m.room" : "m.prompt", "m.type": "m.video", }, randomString(24)); } @@ -760,16 +796,28 @@ export class ElementCall extends Call { audioInput: MediaDeviceInfo | null, videoInput: MediaDeviceInfo | null, ): Promise { + this.kickedOutByAnotherDevice = false; + this.client.on(ClientEvent.ToDeviceEvent, this.onToDeviceEvent); + + this.connectionTime = Date.now(); + await this.client.sendToDevice(ElementCall.DUPLICATE_CALL_DEVICE_EVENT_TYPE, { + [this.client.getUserId()]: { + "*": { device_id: this.client.getDeviceId(), timestamp: this.connectionTime }, + }, + }); + try { await this.messaging!.transport.send(ElementWidgetActions.JoinCall, { - audioInput: audioInput?.deviceId ?? null, - videoInput: videoInput?.deviceId ?? null, + audioInput: audioInput?.label ?? null, + videoInput: videoInput?.label ?? null, }); } catch (e) { throw new Error(`Failed to join call in room ${this.roomId}: ${e}`); } this.messaging!.on(`action:${ElementWidgetActions.HangupCall}`, this.onHangup); + this.messaging!.on(`action:${ElementWidgetActions.TileLayout}`, this.onTileLayout); + this.messaging!.on(`action:${ElementWidgetActions.SpotlightLayout}`, this.onSpotlightLayout); } protected async performDisconnection(): Promise { @@ -781,7 +829,10 @@ export class ElementCall extends Call { } public setDisconnected() { + this.client.off(ClientEvent.ToDeviceEvent, this.onToDeviceEvent); this.messaging!.off(`action:${ElementWidgetActions.HangupCall}`, this.onHangup); + this.messaging!.on(`action:${ElementWidgetActions.TileLayout}`, this.onTileLayout); + this.messaging!.on(`action:${ElementWidgetActions.SpotlightLayout}`, this.onSpotlightLayout); super.setDisconnected(); } @@ -789,17 +840,73 @@ export class ElementCall extends Call { WidgetStore.instance.removeVirtualWidget(this.widget.id, this.groupCall.getRoomId()!); this.room.off(RoomStateEvent.Update, this.onRoomState); this.off(CallEvent.ConnectionState, this.onConnectionState); + this.off(CallEvent.Participants, this.onParticipants); + if (this.participantsExpirationTimer !== null) { clearTimeout(this.participantsExpirationTimer); this.participantsExpirationTimer = null; } + if (this.terminationTimer !== null) { + clearTimeout(this.terminationTimer); + this.terminationTimer = null; + } super.destroy(); } - private onRoomState = () => this.updateParticipants(); + /** + * Sets the call's layout. + * @param layout The layout to switch to. + */ + public async setLayout(layout: Layout): Promise { + const action = layout === Layout.Tile + ? ElementWidgetActions.TileLayout + : ElementWidgetActions.SpotlightLayout; - private onConnectionState = async (state: ConnectionState, prevState: ConnectionState) => { + await this.messaging!.transport.send(action, {}); + } + + private get mayTerminate(): boolean { + if (this.kickedOutByAnotherDevice) return false; + if (this.groupCall.getContent()["m.intent"] === "m.room") return false; + if ( + !this.room.currentState.mayClientSendStateEvent(ElementCall.CALL_EVENT_TYPE.name, this.client) + ) return false; + + return true; + } + + private async terminate(): Promise { + await this.client.sendStateEvent( + this.roomId, + ElementCall.CALL_EVENT_TYPE.name, + { ...this.groupCall.getContent(), "m.terminated": "Call ended" }, + this.groupCall.getStateKey(), + ); + } + + private onRoomState = () => { + this.updateParticipants(); + + // Destroy the call if it's been terminated + const newGroupCall = this.room.currentState.getStateEvents( + this.groupCall.getType(), this.groupCall.getStateKey()!, + ); + if ("m.terminated" in newGroupCall.getContent()) this.destroy(); + }; + + private onToDeviceEvent = (event: MatrixEvent): void => { + const content = event.getContent(); + if (event.getType() !== ElementCall.DUPLICATE_CALL_DEVICE_EVENT_TYPE) return; + if (event.getSender() !== this.client.getUserId()) return; + if (content.device_id === this.client.getDeviceId()) return; + if (content.timestamp <= this.connectionTime) return; + + this.kickedOutByAnotherDevice = true; + this.disconnect(); + }; + + private onConnectionState = (state: ConnectionState, prevState: ConnectionState) => { if ( (state === ConnectionState.Connected && !isConnected(prevState)) || (state === ConnectionState.Disconnected && isConnected(prevState)) @@ -808,9 +915,40 @@ export class ElementCall extends Call { } }; + private onParticipants = async (participants: Set, prevParticipants: Set) => { + // If the last participant disconnected, terminate the call + if (participants.size === 0 && prevParticipants.size > 0 && this.mayTerminate) { + if (prevParticipants.has(this.room.getMember(this.client.getUserId()!)!)) { + // If we were that last participant, do the termination ourselves + await this.terminate(); + } else { + // We don't appear to have been the last participant, but because of + // the potential for races, users lacking permission, and a myriad of + // other reasons, we can't rely on other clients to terminate the call. + // Since it's likely that other clients are using this same logic, we wait + // randomly between 2 and 8 seconds before terminating the call, to + // probabilistically reduce event spam. If someone else beats us to it, + // this timer will be automatically cleared upon the call's destruction. + this.terminationTimer = setTimeout(() => this.terminate(), Math.random() * 6000 + 2000); + } + } + }; + private onHangup = async (ev: CustomEvent) => { ev.preventDefault(); await this.messaging!.transport.reply(ev.detail, {}); // ack this.setDisconnected(); }; + + private onTileLayout = async (ev: CustomEvent) => { + ev.preventDefault(); + this.layout = Layout.Tile; + await this.messaging!.transport.reply(ev.detail, {}); // ack + }; + + private onSpotlightLayout = async (ev: CustomEvent) => { + ev.preventDefault(); + this.layout = Layout.Spotlight; + await this.messaging!.transport.reply(ev.detail, {}); // ack + }; } diff --git a/src/models/RoomUpload.ts b/src/models/RoomUpload.ts new file mode 100644 index 00000000000..aa4d33d2eaa --- /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 99d29c28557..44b63b049aa 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -97,6 +97,7 @@ export enum LabGroup { Spaces, Widgets, Rooms, + VoiceAndVideo, Moderation, Analytics, MessagePreviews, @@ -116,6 +117,7 @@ export const labGroupNames: Record = { [LabGroup.Spaces]: _td("Spaces"), [LabGroup.Widgets]: _td("Widgets"), [LabGroup.Rooms]: _td("Rooms"), + [LabGroup.VoiceAndVideo]: _td("Voice & Video"), [LabGroup.Moderation]: _td("Moderation"), [LabGroup.Analytics]: _td("Analytics"), [LabGroup.MessagePreviews]: _td("Message Previews"), @@ -196,7 +198,7 @@ export type ISetting = IBaseSetting | IFeature; export const SETTINGS: {[setting: string]: ISetting} = { "feature_video_rooms": { isFeature: true, - labsGroup: LabGroup.Rooms, + labsGroup: LabGroup.VoiceAndVideo, displayName: _td("Video rooms"), supportedLevels: LEVELS_FEATURE, default: false, @@ -313,6 +315,13 @@ export const SETTINGS: {[setting: string]: ISetting} = { }, }, + "feature_wysiwyg_composer": { + isFeature: true, + labsGroup: LabGroup.Messaging, + displayName: _td("Try out the rich text editor (plain text mode coming soon)"), + supportedLevels: LEVELS_FEATURE, + default: false, + }, "feature_state_counters": { isFeature: true, labsGroup: LabGroup.Rooms, @@ -442,11 +451,19 @@ export const SETTINGS: {[setting: string]: ISetting} = { "feature_element_call_video_rooms": { isFeature: true, supportedLevels: LEVELS_FEATURE, - labsGroup: LabGroup.Rooms, + labsGroup: LabGroup.VoiceAndVideo, displayName: _td("Element Call video rooms"), controller: new ReloadOnChangeController(), default: false, }, + "feature_group_calls": { + isFeature: true, + supportedLevels: LEVELS_FEATURE, + labsGroup: LabGroup.VoiceAndVideo, + displayName: _td("New group call experience"), + controller: new ReloadOnChangeController(), + default: false, + }, "feature_location_share_live": { isFeature: true, labsGroup: LabGroup.Messaging, @@ -474,8 +491,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"), @@ -772,6 +805,14 @@ export const SETTINGS: {[setting: string]: ISetting} = { displayName: _td('Send analytics data'), default: null, }, + "deviceClientInformationOptIn": { + supportedLevels: [SettingLevel.ACCOUNT], + displayName: _td( + `Record the client name, version, and url ` + + `to recognise sessions more easily in session manager`, + ), + default: false, + }, "FTUE.useCaseSelection": { supportedLevels: LEVELS_ACCOUNT_SETTINGS, default: null, @@ -834,6 +875,10 @@ export const SETTINGS: {[setting: string]: ISetting} = { supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS_WITH_CONFIG, default: SoundPack.Schildi, }, + "deviceNotificationsEnabled": { + supportedLevels: [SettingLevel.DEVICE], + default: true, + }, "notificationSound": { supportedLevels: LEVELS_ROOM_OR_ACCOUNT, default: false, diff --git a/src/stores/CallStore.ts b/src/stores/CallStore.ts index 40b73d3114c..abfb6b54afe 100644 --- a/src/stores/CallStore.ts +++ b/src/stores/CallStore.ts @@ -73,7 +73,7 @@ export class CallStore extends AsyncStoreWithClient<{}> { await Promise.all([ ...uncleanlyDisconnectedRoomIds.map(async uncleanlyDisconnectedRoomId => { logger.log(`Cleaning up call state for room ${uncleanlyDisconnectedRoomId}`); - await this.get(uncleanlyDisconnectedRoomId)?.clean(); + await this.getCall(uncleanlyDisconnectedRoomId)?.clean(); }), SettingsStore.setValue("activeCallRoomIds", null, SettingLevel.DEVICE, []), ]); @@ -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); @@ -152,10 +152,20 @@ export class CallStore extends AsyncStoreWithClient<{}> { * @param {string} roomId The room's ID. * @returns {Call | null} The call. */ - public get(roomId: string): Call | null { + public getCall(roomId: string): Call | null { return this.calls.get(roomId) ?? null; } + /** + * Gets the active call associated with the given room, if any. + * @param roomId The room's ID. + * @returns The active call. + */ + public getActiveCall(roomId: string): Call | null { + const call = this.getCall(roomId); + return call !== null && this.activeCalls.has(call) ? call : null; + } + private onRoom = (room: Room) => this.updateRoom(room); private onRoomState = (event: MatrixEvent, state: RoomState) => { diff --git a/src/stores/RoomViewStore.tsx b/src/stores/RoomViewStore.tsx index 593dd7115f9..0a15ce18607 100644 --- a/src/stores/RoomViewStore.tsx +++ b/src/stores/RoomViewStore.tsx @@ -17,7 +17,6 @@ limitations under the License. */ import React, { ReactNode } from "react"; -import { Store } from 'flux/utils'; import { MatrixError } from "matrix-js-sdk/src/http-api"; import { logger } from "matrix-js-sdk/src/logger"; import { ViewRoom as ViewRoomEvent } from "@matrix-org/analytics-events/types/typescript/ViewRoom"; @@ -26,13 +25,13 @@ import { JoinRule } from "matrix-js-sdk/src/@types/partials"; import { Room } from "matrix-js-sdk/src/models/room"; import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { Optional } from "matrix-events-sdk"; +import EventEmitter from "events"; -import dis from '../dispatcher/dispatcher'; +import { defaultDispatcher, MatrixDispatcher } from '../dispatcher/dispatcher'; import { MatrixClientPeg } from '../MatrixClientPeg'; import Modal from '../Modal'; import { _t } from '../languageHandler'; import { getCachedRoomIDForAlias, storeRoomAliasInCache } from '../RoomAliasCache'; -import { ActionPayload } from "../dispatcher/payloads"; import { Action } from "../dispatcher/actions"; import { retry } from "../utils/promise"; import { TimelineRenderingType } from "../contexts/RoomContext"; @@ -50,90 +49,116 @@ import { ActiveRoomChangedPayload } from "../dispatcher/payloads/ActiveRoomChang import SettingsStore from "../settings/SettingsStore"; import { SlidingSyncManager } from "../SlidingSyncManager"; import { awaitRoomDownSync } from "../utils/RoomUpgrade"; +import { UPDATE_EVENT } from "./AsyncStore"; +import { CallStore } from "./CallStore"; const NUM_JOIN_RETRY = 5; -const INITIAL_STATE = { - // Whether we're joining the currently viewed room (see isJoining()) - joining: false, - // Any error that has occurred during joining - joinError: null as Error, - // The room ID of the room currently being viewed - roomId: null as string, - // The room ID being subscribed to (in Sliding Sync) - subscribingRoomId: null as string, +interface State { + /** + * Whether we're joining the currently viewed (see isJoining()) + */ + joining: boolean; + /** + * Any error that has occurred during joining + */ + joinError: Error | null; + /** + * The ID of the room currently being viewed + */ + roomId: string | null; + /** + * The ID of the room being subscribed to (in Sliding Sync) + */ + subscribingRoomId: string | null; + /** + * The event to scroll to when the room is first viewed + */ + initialEventId: string | null; + initialEventPixelOffset: number | null; + /** + * Whether to highlight the initial event + */ + isInitialEventHighlighted: boolean; + /** + * Whether to scroll the initial event into view + */ + initialEventScrollIntoView: boolean; + /** + * The alias of the room (or null if not originally specified in view_room) + */ + roomAlias: string | null; + /** + * Whether the current room is loading + */ + roomLoading: boolean; + /** + * Any error that has occurred during loading + */ + roomLoadError: MatrixError | null; + replyingToEvent: MatrixEvent | null; + shouldPeek: boolean; + viaServers: string[]; + wasContextSwitch: boolean; + /** + * Whether we're viewing a call or call lobby in this room + */ + viewingCall: boolean; +} - // The event to scroll to when the room is first viewed - initialEventId: null as string, - initialEventPixelOffset: null as number, - // Whether to highlight the initial event +const INITIAL_STATE: State = { + joining: false, + joinError: null, + roomId: null, + subscribingRoomId: null, + initialEventId: null, + initialEventPixelOffset: null, isInitialEventHighlighted: false, - // whether to scroll `event_id` into view initialEventScrollIntoView: true, - - // The room alias of the room (or null if not originally specified in view_room) - roomAlias: null as string, - // Whether the current room is loading + roomAlias: null, roomLoading: false, - // Any error that has occurred during loading - roomLoadError: null as MatrixError, - - replyingToEvent: null as MatrixEvent, - + roomLoadError: null, + replyingToEvent: null, shouldPeek: false, - - viaServers: [] as string[], - + viaServers: [], wasContextSwitch: false, + viewingCall: false, }; type Listener = (isActive: boolean) => void; /** - * A class for storing application state for RoomView. This is the RoomView's interface -* with a subset of the js-sdk. - * ``` + * A class for storing application state for RoomView. */ -export class RoomViewStore extends Store { +export class RoomViewStore extends EventEmitter { // Important: This cannot be a dynamic getter (lazily-constructed instance) because // otherwise we'll miss view_room dispatches during startup, breaking relaunches of // the app. We need to eagerly create the instance. - public static readonly instance = new RoomViewStore(); + public static readonly instance = new RoomViewStore(defaultDispatcher); - private state = INITIAL_STATE; // initialize state + private state: State = INITIAL_STATE; // initialize state - // Keep these out of state to avoid causing excessive/recursive updates - private roomIdActivityListeners: Record = {}; + private dis: MatrixDispatcher; + private dispatchToken: string; - public constructor() { - super(dis); + public constructor(dis: MatrixDispatcher) { + super(); + this.resetDispatcher(dis); } public addRoomListener(roomId: string, fn: Listener): void { - if (!this.roomIdActivityListeners[roomId]) this.roomIdActivityListeners[roomId] = []; - this.roomIdActivityListeners[roomId].push(fn); + this.on(roomId, fn); } public removeRoomListener(roomId: string, fn: Listener): void { - if (this.roomIdActivityListeners[roomId]) { - const i = this.roomIdActivityListeners[roomId].indexOf(fn); - if (i > -1) { - this.roomIdActivityListeners[roomId].splice(i, 1); - } - } else { - logger.warn("Unregistering unrecognised listener (roomId=" + roomId + ")"); - } + this.off(roomId, fn); } private emitForRoom(roomId: string, isActive: boolean): void { - if (!this.roomIdActivityListeners[roomId]) return; - - for (const fn of this.roomIdActivityListeners[roomId]) { - fn.call(null, isActive); - } + this.emit(roomId, isActive); } - private setState(newState: Partial): void { + private setState(newState: Partial): void { // If values haven't changed, there's nothing to do. // This only tries a shallow comparison, so unchanged objects will slip // through, but that's probably okay for now. @@ -156,17 +181,17 @@ export class RoomViewStore extends Store { // Fired so we can reduce dependency on event emitters to this store, which is relatively // central to the application and can easily cause import cycles. - dis.dispatch({ + this.dis.dispatch({ action: Action.ActiveRoomChanged, oldRoomId: lastRoomId, newRoomId: this.state.roomId, }); } - this.__emitChange(); + this.emit(UPDATE_EVENT); } - protected __onDispatch(payload): void { // eslint-disable-line @typescript-eslint/naming-convention + private onDispatch(payload): void { // eslint-disable-line @typescript-eslint/naming-convention switch (payload.action) { // view_room: // - room_alias: '#somealias:matrix.org' @@ -185,6 +210,7 @@ export class RoomViewStore extends Store { roomAlias: null, viaServers: [], wasContextSwitch: false, + viewingCall: false, }); break; case Action.ViewRoomError: @@ -243,7 +269,7 @@ export class RoomViewStore extends Store { // both room and search timeline rendering types, search will get auto-closed by RoomView at this time. if ([TimelineRenderingType.Room, TimelineRenderingType.Search].includes(payload.context)) { if (payload.event && payload.event.getRoomId() !== this.state.roomId) { - dis.dispatch({ + this.dis.dispatch({ action: Action.ViewRoom, room_id: payload.event.getRoomId(), replyingToEvent: payload.event, @@ -261,6 +287,8 @@ export class RoomViewStore extends Store { private async viewRoom(payload: ViewRoomPayload): Promise { if (payload.room_id) { + const room = MatrixClientPeg.get().getRoom(payload.room_id); + if (payload.metricsTrigger !== null && payload.room_id !== this.state.roomId) { let activeSpace: ViewRoomEvent["activeSpace"]; if (SpaceStore.instance.activeSpace === MetaSpace.Home) { @@ -278,14 +306,15 @@ export class RoomViewStore extends Store { trigger: payload.metricsTrigger, viaKeyboard: payload.metricsViaKeyboard, isDM: !!DMRoomMap.shared().getUserIdForRoomId(payload.room_id), - isSpace: MatrixClientPeg.get().getRoom(payload.room_id)?.isSpaceRoom(), + isSpace: room?.isSpaceRoom(), activeSpace, }); } + if (SettingsStore.getValue("feature_sliding_sync") && this.state.roomId !== payload.room_id) { - if (this.state.roomId) { + if (this.state.subscribingRoomId && this.state.subscribingRoomId !== payload.room_id) { // unsubscribe from this room, but don't await it as we don't care when this gets done. - SlidingSyncManager.instance.setRoomVisible(this.state.roomId, false); + SlidingSyncManager.instance.setRoomVisible(this.state.subscribingRoomId, false); } this.setState({ subscribingRoomId: payload.room_id, @@ -299,6 +328,7 @@ export class RoomViewStore extends Store { roomLoadError: null, viaServers: payload.via_servers, wasContextSwitch: payload.context_switch, + viewingCall: payload.view_call ?? false, }); // set this room as the room subscription. We need to await for it as this will fetch // all room state for this room, which is required before we get the state below. @@ -306,21 +336,21 @@ export class RoomViewStore extends Store { // Whilst we were subscribing another room was viewed, so stop what we're doing and // unsubscribe if (this.state.subscribingRoomId !== payload.room_id) { - SlidingSyncManager.instance.setRoomVisible(this.state.roomId, false); + SlidingSyncManager.instance.setRoomVisible(payload.room_id, false); return; } // Re-fire the payload: we won't re-process it because the prev room ID == payload room ID now - dis.dispatch({ + this.dis.dispatch({ ...payload, }); return; } - const newState = { + const newState: Partial = { roomId: payload.room_id, - roomAlias: payload.room_alias, - initialEventId: payload.event_id, - isInitialEventHighlighted: payload.highlighted, + roomAlias: payload.room_alias ?? null, + initialEventId: payload.event_id ?? null, + isInitialEventHighlighted: payload.highlighted ?? false, initialEventScrollIntoView: payload.scroll_into_view ?? true, roomLoading: false, roomLoadError: null, @@ -330,8 +360,13 @@ export class RoomViewStore extends Store { joining: payload.joining || false, // Reset replyingToEvent because we don't want cross-room because bad UX replyingToEvent: null, - viaServers: payload.via_servers, - wasContextSwitch: payload.context_switch, + viaServers: payload.via_servers ?? [], + wasContextSwitch: payload.context_switch ?? false, + viewingCall: payload.view_call ?? ( + payload.room_id === this.state.roomId + ? this.state.viewingCall + : CallStore.instance.getActiveCall(payload.room_id) !== null + ), }; // Allow being given an event to be replied to when switching rooms but sanity check its for this room @@ -346,7 +381,7 @@ export class RoomViewStore extends Store { this.setState(newState); if (payload.auto_join) { - dis.dispatch({ + this.dis.dispatch({ ...payload, action: Action.JoinRoom, roomId: payload.room_id, @@ -364,13 +399,14 @@ export class RoomViewStore extends Store { roomId: null, initialEventId: null, initialEventPixelOffset: null, - isInitialEventHighlighted: null, + isInitialEventHighlighted: false, initialEventScrollIntoView: true, roomAlias: payload.room_alias, roomLoading: true, roomLoadError: null, viaServers: payload.via_servers, wasContextSwitch: payload.context_switch, + viewingCall: payload.view_call ?? false, }); try { const result = await MatrixClientPeg.get().getRoomIdForAlias(payload.room_alias); @@ -378,7 +414,7 @@ export class RoomViewStore extends Store { roomId = result.room_id; } catch (err) { logger.error("RVS failed to get room id for alias: ", err); - dis.dispatch({ + this.dis.dispatch({ action: Action.ViewRoomError, room_id: null, room_alias: payload.room_alias, @@ -389,7 +425,7 @@ export class RoomViewStore extends Store { } // Re-fire the payload with the newly found room_id - dis.dispatch({ + this.dis.dispatch({ ...payload, room_id: roomId, }); @@ -427,13 +463,13 @@ export class RoomViewStore extends Store { // We do *not* clear the 'joining' flag because the Room object and/or our 'joined' member event may not // have come down the sync stream yet, and that's the point at which we'd consider the user joined to the // room. - dis.dispatch({ + this.dis.dispatch({ action: Action.JoinRoomReady, roomId, metricsTrigger: payload.metricsTrigger, }); } catch (err) { - dis.dispatch({ + this.dis.dispatch({ action: Action.JoinRoomError, roomId, err, @@ -493,6 +529,24 @@ export class RoomViewStore extends Store { this.state = Object.assign({}, INITIAL_STATE); } + /** + * Reset which dispatcher should be used to listen for actions. The old dispatcher will be + * unregistered. + * @param dis The new dispatcher to use. + */ + public resetDispatcher(dis: MatrixDispatcher) { + if (this.dispatchToken) { + this.dis.unregister(this.dispatchToken); + } + this.dis = dis; + if (dis) { + // Some tests mock the dispatcher file resulting in an empty defaultDispatcher + // so rather than dying here, just ignore it. When we no longer mock files like this, + // we should remove the null check. + this.dispatchToken = this.dis.register(this.onDispatch.bind(this)); + } + } + // The room ID of the room currently being viewed public getRoomId(): Optional { return this.state.roomId; @@ -572,4 +626,8 @@ export class RoomViewStore extends Store { public getWasContextSwitch(): boolean { return this.state.wasContextSwitch; } + + public isViewingCall(): boolean { + return this.state.viewingCall; + } } diff --git a/src/stores/VoiceRecordingStore.ts b/src/stores/VoiceRecordingStore.ts index 29af171f6ec..ed2b480255d 100644 --- a/src/stores/VoiceRecordingStore.ts +++ b/src/stores/VoiceRecordingStore.ts @@ -22,12 +22,12 @@ import { IEventRelation } from "matrix-js-sdk/src/models/event"; import { AsyncStoreWithClient } from "./AsyncStoreWithClient"; import defaultDispatcher from "../dispatcher/dispatcher"; import { ActionPayload } from "../dispatcher/payloads"; -import { VoiceRecording } from "../audio/VoiceRecording"; +import { createVoiceMessageRecording, VoiceMessageRecording } from "../audio/VoiceMessageRecording"; const SEPARATOR = "|"; interface IState { - [voiceRecordingId: string]: Optional; + [voiceRecordingId: string]: Optional; } export class VoiceRecordingStore extends AsyncStoreWithClient { @@ -63,7 +63,7 @@ export class VoiceRecordingStore extends AsyncStoreWithClient { * @param {string} voiceRecordingId The room ID (with optionally the thread ID if in one) to get the recording in. * @returns {Optional} The recording, if any. */ - public getActiveRecording(voiceRecordingId: string): Optional { + public getActiveRecording(voiceRecordingId: string): Optional { return this.state[voiceRecordingId]; } @@ -74,12 +74,12 @@ export class VoiceRecordingStore extends AsyncStoreWithClient { * @param {string} voiceRecordingId The room ID (with optionally the thread ID if in one) to start recording in. * @returns {VoiceRecording} The recording. */ - public startRecording(voiceRecordingId: string): VoiceRecording { + public startRecording(voiceRecordingId: string): VoiceMessageRecording { if (!this.matrixClient) throw new Error("Cannot start a recording without a MatrixClient"); if (!voiceRecordingId) throw new Error("Recording must be associated with a room"); if (this.state[voiceRecordingId]) throw new Error("A recording is already in progress"); - const recording = new VoiceRecording(this.matrixClient); + const recording = createVoiceMessageRecording(this.matrixClient); // noinspection JSIgnoredPromiseFromCall - we can safely run this async this.updateState({ ...this.state, [voiceRecordingId]: recording }); diff --git a/src/stores/WidgetEchoStore.ts b/src/stores/WidgetEchoStore.ts index 2923d46b09e..ac524eb4cf6 100644 --- a/src/stores/WidgetEchoStore.ts +++ b/src/stores/WidgetEchoStore.ts @@ -111,7 +111,7 @@ class WidgetEchoStore extends EventEmitter { } } -let singletonWidgetEchoStore = null; +let singletonWidgetEchoStore: WidgetEchoStore | null = null; if (!singletonWidgetEchoStore) { singletonWidgetEchoStore = new WidgetEchoStore(); } diff --git a/src/stores/room-list/RoomListStore.ts b/src/stores/room-list/RoomListStore.ts index 915e9d95913..254cdbe0f25 100644 --- a/src/stores/room-list/RoomListStore.ts +++ b/src/stores/room-list/RoomListStore.ts @@ -39,6 +39,7 @@ import { SpaceWatcher } from "./SpaceWatcher"; import { IRoomTimelineActionPayload } from "../../actions/MatrixActionCreators"; import { RoomListStore as Interface, RoomListStoreEvent } from "./Interface"; import { SlidingRoomListStoreClass } from "./SlidingRoomListStore"; +import { UPDATE_EVENT } from "../AsyncStore"; interface IState { // state is tracked in underlying classes @@ -104,7 +105,7 @@ export class RoomListStoreClass extends AsyncStoreWithClient implements this.readyStore.useUnitTestClient(forcedClient); } - RoomViewStore.instance.addListener(() => this.handleRVSUpdate({})); + RoomViewStore.instance.addListener(UPDATE_EVENT, () => this.handleRVSUpdate({})); this.algorithm.on(LIST_UPDATED_EVENT, this.onAlgorithmListUpdated); this.algorithm.on(FILTER_CHANGED, this.onAlgorithmFilterUpdated); this.setupWatchers(); diff --git a/src/stores/room-list/SlidingRoomListStore.ts b/src/stores/room-list/SlidingRoomListStore.ts index b8ba48659f1..3d532fe0c93 100644 --- a/src/stores/room-list/SlidingRoomListStore.ts +++ b/src/stores/room-list/SlidingRoomListStore.ts @@ -30,6 +30,7 @@ import SpaceStore from "../spaces/SpaceStore"; import { MetaSpace, SpaceKey, UPDATE_SELECTED_SPACE } from "../spaces"; import { LISTS_LOADING_EVENT } from "./RoomListStore"; import { RoomViewStore } from "../RoomViewStore"; +import { UPDATE_EVENT } from "../AsyncStore"; interface IState { // state is tracked in underlying classes @@ -313,7 +314,7 @@ export class SlidingRoomListStoreClass extends AsyncStoreWithClient impl logger.info("SlidingRoomListStore.onReady"); // permanent listeners: never get destroyed. Could be an issue if we want to test this in isolation. SlidingSyncManager.instance.slidingSync.on(SlidingSyncEvent.List, this.onSlidingSyncListUpdate.bind(this)); - RoomViewStore.instance.addListener(this.onRoomViewStoreUpdated.bind(this)); + RoomViewStore.instance.addListener(UPDATE_EVENT, this.onRoomViewStoreUpdated.bind(this)); SpaceStore.instance.on(UPDATE_SELECTED_SPACE, this.onSelectedSpaceUpdated.bind(this)); if (SpaceStore.instance.activeSpace) { this.onSelectedSpaceUpdated(SpaceStore.instance.activeSpace, false); diff --git a/src/stores/widgets/StopGapWidget.ts b/src/stores/widgets/StopGapWidget.ts index 40441a39e6c..9416caa93e2 100644 --- a/src/stores/widgets/StopGapWidget.ts +++ b/src/stores/widgets/StopGapWidget.ts @@ -64,6 +64,7 @@ import { arrayFastClone } from "../../utils/arrays"; import { ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload"; import Modal from "../../Modal"; import ErrorDialog from "../../components/views/dialogs/ErrorDialog"; +import { VoiceBroadcastRecordingsStore } from "../../voice-broadcast"; // TODO: Destroy all of this code @@ -266,6 +267,10 @@ export class StopGapWidget extends EventEmitter { }); this.messaging.on("capabilitiesNotified", () => this.emit("capabilitiesNotified")); this.messaging.on(`action:${WidgetApiFromWidgetAction.OpenModalWidget}`, this.onOpenModal); + this.messaging.on(`action:${ElementWidgetActions.JoinCall}`, () => { + // stop voice broadcast recording when any widget sends a "join" + VoiceBroadcastRecordingsStore.instance().getCurrent()?.stop(); + }); // Always attach a handler for ViewRoom, but permission check it internally this.messaging.on(`action:${ElementWidgetActions.ViewRoom}`, (ev: CustomEvent) => { diff --git a/src/stores/widgets/StopGapWidgetDriver.ts b/src/stores/widgets/StopGapWidgetDriver.ts index f0b496f0b7e..752d6d57e6f 100644 --- a/src/stores/widgets/StopGapWidgetDriver.ts +++ b/src/stores/widgets/StopGapWidgetDriver.ts @@ -40,7 +40,7 @@ import { logger } from "matrix-js-sdk/src/logger"; import { THREAD_RELATION_TYPE } from "matrix-js-sdk/src/models/thread"; import { Direction } from "matrix-js-sdk/src/matrix"; -import SdkConfig from "../../SdkConfig"; +import SdkConfig, { DEFAULTS } from "../../SdkConfig"; import { iterableDiff, iterableIntersection } from "../../utils/iterables"; import { MatrixClientPeg } from "../../MatrixClientPeg"; import Modal from "../../Modal"; @@ -104,7 +104,10 @@ export class StopGapWidgetDriver extends WidgetDriver { // Auto-approve the legacy visibility capability. We send it regardless of capability. // Widgets don't technically need to request this capability, but Scalar still does. this.allowedCapabilities.add("visibility"); - } else if (virtual && new URL(SdkConfig.get("element_call").url).origin === this.forWidget.origin) { + } else if ( + virtual + && new URL(SdkConfig.get("element_call").url ?? DEFAULTS.element_call.url).origin === this.forWidget.origin + ) { // This is a trusted Element Call widget that we control this.allowedCapabilities.add(MatrixCapabilities.AlwaysOnScreen); this.allowedCapabilities.add(MatrixCapabilities.MSC3846TurnServers); @@ -434,7 +437,6 @@ export class StopGapWidgetDriver extends WidgetDriver { } const { - originalEvent, events, nextBatch, prevBatch, @@ -447,11 +449,10 @@ export class StopGapWidgetDriver extends WidgetDriver { from, to, limit, - direction: dir, + dir, }); return { - originalEvent: originalEvent?.getEffectiveEvent(), chunk: events.map(e => e.getEffectiveEvent()), nextBatch, prevBatch, diff --git a/src/theme.ts b/src/theme.ts index 06f42a2ca0e..2bde2a29e99 100644 --- a/src/theme.ts +++ b/src/theme.ts @@ -237,13 +237,13 @@ export async function setTheme(theme?: string): Promise { // look for the stylesheet elements. // styleElements is a map from style name to HTMLLinkElement. - const styleElements = Object.create(null); - const themes = Array.from(document.querySelectorAll('[data-mx-theme]')); + const styleElements = new Map(); + const themes = Array.from(document.querySelectorAll('[data-mx-theme]')); themes.forEach(theme => { - styleElements[theme.attributes['data-mx-theme'].value.toLowerCase()] = theme; + styleElements.set(theme.attributes['data-mx-theme'].value.toLowerCase(), theme); }); - if (!(stylesheetName in styleElements)) { + if (!styleElements.has(stylesheetName)) { throw new Error("Unknown theme " + stylesheetName); } @@ -258,17 +258,18 @@ export async function setTheme(theme?: string): Promise { // having them interact badly... but this causes a flash of unstyled app // which is even uglier. So we don't. - styleElements[stylesheetName].disabled = false; + const styleSheet = styleElements.get(stylesheetName); + styleSheet.disabled = false; - return new Promise((resolve) => { + return new Promise(((resolve, reject) => { const switchTheme = function() { // we re-enable our theme here just in case we raced with another // theme set request as per https://github.com/vector-im/element-web/issues/5601. // We could alternatively lock or similar to stop the race, but // this is probably good enough for now. - styleElements[stylesheetName].disabled = false; - Object.values(styleElements).forEach((a: HTMLStyleElement) => { - if (a == styleElements[stylesheetName]) return; + styleSheet.disabled = false; + styleElements.forEach(a => { + if (a == styleSheet) return; a.disabled = true; }); const bodyStyles = global.getComputedStyle(document.body); @@ -279,26 +280,50 @@ export async function setTheme(theme?: string): Promise { resolve(); }; - // turns out that Firefox preloads the CSS for link elements with - // the disabled attribute, but Chrome doesn't. + const isStyleSheetLoaded = () => Boolean( + [...document.styleSheets] + .find(_styleSheet => _styleSheet?.href === styleSheet.href), + ); - let cssLoaded = false; + function waitForStyleSheetLoading() { + // turns out that Firefox preloads the CSS for link elements with + // the disabled attribute, but Chrome doesn't. + if (isStyleSheetLoaded()) { + switchTheme(); + return; + } - styleElements[stylesheetName].onload = () => { - switchTheme(); - }; + let counter = 0; - for (let i = 0; i < document.styleSheets.length; i++) { - const ss = document.styleSheets[i]; - if (ss && ss.href === styleElements[stylesheetName].href) { - cssLoaded = true; - break; - } - } + // In case of theme toggling (white => black => white) + // Chrome doesn't fire the `load` event when the white theme is selected the second times + const intervalId = setInterval(() => { + if (isStyleSheetLoaded()) { + clearInterval(intervalId); + styleSheet.onload = undefined; + styleSheet.onerror = undefined; + switchTheme(); + } - if (cssLoaded) { - styleElements[stylesheetName].onload = undefined; - switchTheme(); + // Avoid to be stuck in an endless loop if there is an issue in the stylesheet loading + counter++; + if (counter === 10) { + clearInterval(intervalId); + reject(); + } + }, 200); + + styleSheet.onload = () => { + clearInterval(intervalId); + switchTheme(); + }; + + styleSheet.onerror = (e) => { + clearInterval(intervalId); + reject(e); + }; } - }); + + waitForStyleSheetLoading(); + })); } diff --git a/src/toasts/AnalyticsToast.tsx b/src/toasts/AnalyticsToast.tsx index c7f2bdb59af..9a1ea008601 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 6ddb0d7db57..0113f2f030a 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/DesktopNotificationsToast.ts b/src/toasts/DesktopNotificationsToast.ts index e10a6d46c62..ad361174180 100644 --- a/src/toasts/DesktopNotificationsToast.ts +++ b/src/toasts/DesktopNotificationsToast.ts @@ -18,9 +18,16 @@ import { _t } from "../languageHandler"; import Notifier from "../Notifier"; import GenericToast from "../components/views/toasts/GenericToast"; import ToastStore from "../stores/ToastStore"; +import { MatrixClientPeg } from "../MatrixClientPeg"; +import { getLocalNotificationAccountDataEventType } from "../utils/notifications"; const onAccept = () => { Notifier.setEnabled(true); + const cli = MatrixClientPeg.get(); + const eventType = getLocalNotificationAccountDataEventType(cli.deviceId); + cli.setAccountData(eventType, { + is_silenced: false, + }); }; const onReject = () => { diff --git a/src/toasts/IncomingCallToast.tsx b/src/toasts/IncomingCallToast.tsx new file mode 100644 index 00000000000..c5e363089b7 --- /dev/null +++ b/src/toasts/IncomingCallToast.tsx @@ -0,0 +1,154 @@ +/* +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, { useCallback, useEffect } from 'react'; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; + +import { _t } from '../languageHandler'; +import RoomAvatar from '../components/views/avatars/RoomAvatar'; +import { MatrixClientPeg } from "../MatrixClientPeg"; +import defaultDispatcher from "../dispatcher/dispatcher"; +import { ViewRoomPayload } from "../dispatcher/payloads/ViewRoomPayload"; +import { Action } from "../dispatcher/actions"; +import ToastStore from "../stores/ToastStore"; +import AccessibleTooltipButton from "../components/views/elements/AccessibleTooltipButton"; +import { + LiveContentSummary, + LiveContentSummaryWithCall, + LiveContentType, +} from "../components/views/rooms/LiveContentSummary"; +import { useCall, useJoinCallButtonDisabled, useJoinCallButtonTooltip } from "../hooks/useCall"; +import { useRoomState } from "../hooks/useRoomState"; +import { ButtonEvent } from "../components/views/elements/AccessibleButton"; +import { useDispatcher } from "../hooks/useDispatcher"; +import { ActionPayload } from "../dispatcher/payloads"; +import { Call } from "../models/Call"; + +export const getIncomingCallToastKey = (stateKey: string) => `call_${stateKey}`; + +interface JoinCallButtonWithCallProps { + onClick: (e: ButtonEvent) => void; + call: Call; +} + +function JoinCallButtonWithCall({ onClick, call }: JoinCallButtonWithCallProps) { + const tooltip = useJoinCallButtonTooltip(call); + const disabled = useJoinCallButtonDisabled(call); + + return + { _t("Join") } + ; +} + +interface Props { + callEvent: MatrixEvent; +} + +export function IncomingCallToast({ callEvent }: Props) { + const roomId = callEvent.getRoomId()!; + const room = MatrixClientPeg.get().getRoom(roomId); + const call = useCall(roomId); + + const dismissToast = useCallback((): void => { + ToastStore.sharedInstance().dismissToast(getIncomingCallToastKey(callEvent.getStateKey()!)); + }, [callEvent]); + + const latestEvent = useRoomState(room, useCallback((state) => { + return state.getStateEvents(callEvent.getType(), callEvent.getStateKey()!); + }, [callEvent])); + + useEffect(() => { + if ("m.terminated" in latestEvent.getContent()) { + dismissToast(); + } + }, [latestEvent, dismissToast]); + + useDispatcher(defaultDispatcher, useCallback((payload: ActionPayload) => { + if ( + payload.action === Action.ViewRoom + && payload.room_id === roomId + && payload.view_call + ) { + dismissToast(); + } + }, [roomId, dismissToast])); + + const onJoinClick = useCallback((e: ButtonEvent): void => { + e.stopPropagation(); + + defaultDispatcher.dispatch({ + action: Action.ViewRoom, + room_id: room.roomId, + view_call: true, + metricsTrigger: undefined, + }); + dismissToast(); + }, [room, dismissToast]); + + const onCloseClick = useCallback((e: ButtonEvent): void => { + e.stopPropagation(); + + dismissToast(); + }, [dismissToast]); + + return + +
    +
    + + { room ? room.name : _t("Unknown room") } + +
    + { _t("Video call started") } +
    + { call + ? + : + } +
    + { call + ? + : + { _t("Join") } + + } +
    + +
    ; +} diff --git a/src/toasts/IncomingLegacyCallToast.tsx b/src/toasts/IncomingLegacyCallToast.tsx index ee640411ed9..fec3fae1e9a 100644 --- a/src/toasts/IncomingLegacyCallToast.tsx +++ b/src/toasts/IncomingLegacyCallToast.tsx @@ -85,6 +85,12 @@ export default class IncomingLegacyCallToast extends React.Component ; } diff --git a/src/toasts/UnverifiedSessionToast.ts b/src/toasts/UnverifiedSessionToast.ts index d0db97cd08f..f2d637ef0d9 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/EventRenderingUtils.ts b/src/utils/EventRenderingUtils.ts index ced38748049..3ba4ce5705c 100644 --- a/src/utils/EventRenderingUtils.ts +++ b/src/utils/EventRenderingUtils.ts @@ -23,6 +23,7 @@ import SettingsStore from "../settings/SettingsStore"; import { haveRendererForEvent, JitsiEventFactory, JSONEventFactory, pickFactory } from "../events/EventTileFactory"; import { MatrixClientPeg } from "../MatrixClientPeg"; import { getMessageModerationState, isLocationEvent, MessageModerationState } from "./EventUtils"; +import { ElementCall } from "../models/Call"; export function getEventDisplayInfo(mxEvent: MatrixEvent, showHiddenEvents: boolean, hideEvent?: boolean): { isInfoMessage: boolean; @@ -61,9 +62,8 @@ export function getEventDisplayInfo(mxEvent: MatrixEvent, showHiddenEvents: bool (eventType === EventType.RoomEncryption) || (factory === JitsiEventFactory) ); - const isLeftAlignedBubbleMessage = ( - !isBubbleMessage && - eventType === EventType.CallInvite + const isLeftAlignedBubbleMessage = !isBubbleMessage && ( + eventType === EventType.CallInvite || ElementCall.CALL_EVENT_TYPE.matches(eventType) ); let isInfoMessage = ( !isBubbleMessage && diff --git a/src/utils/Feedback.ts b/src/utils/Feedback.ts new file mode 100644 index 00000000000..1662fe3fe6a --- /dev/null +++ b/src/utils/Feedback.ts @@ -0,0 +1,23 @@ +/* +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 from "../SdkConfig"; +import SettingsStore from "../settings/SettingsStore"; +import { UIFeature } from "../settings/UIFeature"; + +export function shouldShowFeedback(): boolean { + return SdkConfig.get().bug_report_endpoint_url && SettingsStore.getValue(UIFeature.Feedback); +} diff --git a/src/utils/MultiInviter.ts b/src/utils/MultiInviter.ts index 3f565c079bd..3c539f7bf0c 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/WidgetUtils.ts b/src/utils/WidgetUtils.ts index 728a41f6871..964d795576a 100644 --- a/src/utils/WidgetUtils.ts +++ b/src/utils/WidgetUtils.ts @@ -512,6 +512,7 @@ export default class WidgetUtils { 'theme=$theme', 'roomName=$roomName', `supportsScreensharing=${PlatformPeg.get().supportsJitsiScreensharing()}`, + 'language=$org.matrix.msc2873.client_language', ]; if (opts.auth) { queryStringParts.push(`auth=${opts.auth}`); diff --git a/src/utils/arrays.ts b/src/utils/arrays.ts index a0fddde45cd..b82be21443a 100644 --- a/src/utils/arrays.ts +++ b/src/utils/arrays.ts @@ -304,3 +304,12 @@ export class GroupedArray { return new ArrayUtil(a); } } + +export const concat = (...arrays: Uint8Array[]): Uint8Array => { + return arrays.reduce((concatenatedSoFar: Uint8Array, toBeConcatenated: Uint8Array) => { + const concatenated = new Uint8Array(concatenatedSoFar.length + toBeConcatenated.length); + concatenated.set(concatenatedSoFar, 0); + concatenated.set(toBeConcatenated, concatenatedSoFar.length); + return concatenated; + }, new Uint8Array(0)); +}; diff --git a/src/utils/createVoiceMessageContent.ts b/src/utils/createVoiceMessageContent.ts new file mode 100644 index 00000000000..f406304c8c1 --- /dev/null +++ b/src/utils/createVoiceMessageContent.ts @@ -0,0 +1,64 @@ +/* +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 { IEncryptedFile, MsgType } from "matrix-js-sdk/src/matrix"; + +/** + * @param {string} mxc MXC URL of the file + * @param {string} mimetype + * @param {number} duration Duration in milliseconds + * @param {number} size + * @param {number[]} [waveform] + * @param {IEncryptedFile} [file] Encrypted file + */ +export const createVoiceMessageContent = ( + mxc: string, + mimetype: string, + duration: number, + size: number, + file?: IEncryptedFile, + waveform?: number[], +) => { + return { + "body": "Voice message", + //"msgtype": "org.matrix.msc2516.voice", + "msgtype": MsgType.Audio, + "url": mxc, + "file": file, + "info": { + duration, + mimetype, + size, + }, + + // MSC1767 + Ideals of MSC2516 as MSC3245 + // https://github.com/matrix-org/matrix-doc/pull/3245 + "org.matrix.msc1767.text": "Voice message", + "org.matrix.msc1767.file": { + url: mxc, + file, + name: "Voice message.ogg", + mimetype, + size, + }, + "org.matrix.msc1767.audio": { + duration, + // https://github.com/matrix-org/matrix-doc/pull/3246 + waveform, + }, + "org.matrix.msc3245.voice": {}, // No content, this is a rendering hint + }; +}; diff --git a/src/utils/device/clientInformation.ts b/src/utils/device/clientInformation.ts new file mode 100644 index 00000000000..5c9b65b54bc --- /dev/null +++ b/src/utils/device/clientInformation.ts @@ -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 { MatrixClient } from "matrix-js-sdk/src/client"; + +import BasePlatform from "../../BasePlatform"; +import { IConfigOptions } from "../../IConfigOptions"; + +export type DeviceClientInformation = { + name?: string; + version?: string; + url?: string; +}; + +const formatUrl = (): string | undefined => { + // don't record url for electron clients + if (window.electron) { + return undefined; + } + + // strip query-string and fragment from uri + const url = new URL(window.location.href); + + return [ + url.host, + url.pathname.replace(/\/$/, ""), // Remove trailing slash if present + ].join(""); +}; + +export const getClientInformationEventType = (deviceId: string): string => + `io.element.matrix_client_information.${deviceId}`; + +/** + * Record extra client information for the current device + * https://github.com/vector-im/element-meta/blob/develop/spec/matrix_client_information.md + */ +export const recordClientInformation = async ( + matrixClient: MatrixClient, + sdkConfig: IConfigOptions, + platform: BasePlatform, +): Promise => { + const deviceId = matrixClient.getDeviceId(); + const { brand } = sdkConfig; + const version = await platform.getAppVersion(); + const type = getClientInformationEventType(deviceId); + const url = formatUrl(); + + await matrixClient.setAccountData(type, { + name: brand, + version, + url, + }); +}; + +/** + * Remove extra client information + * @todo(kerrya) revisit after MSC3391: account data deletion is done + * (PSBE-12) + */ +export const removeClientInformation = async ( + matrixClient: MatrixClient, +): Promise => { + const deviceId = matrixClient.getDeviceId(); + const type = getClientInformationEventType(deviceId); + const clientInformation = getDeviceClientInformation(matrixClient, deviceId); + + // if a non-empty client info event exists, overwrite to remove the content + if (clientInformation.name || clientInformation.version || clientInformation.url) { + await matrixClient.setAccountData(type, {}); + } +}; + +const sanitizeContentString = (value: unknown): string | undefined => + value && typeof value === 'string' ? value : undefined; + +export const getDeviceClientInformation = (matrixClient: MatrixClient, deviceId: string): DeviceClientInformation => { + const event = matrixClient.getAccountData(getClientInformationEventType(deviceId)); + + if (!event) { + return {}; + } + + const { name, version, url } = event.getContent(); + + return { + name: sanitizeContentString(name), + version: sanitizeContentString(version), + url: sanitizeContentString(url), + }; +}; + diff --git a/src/utils/device/parseUserAgent.ts b/src/utils/device/parseUserAgent.ts new file mode 100644 index 00000000000..3eee6177652 --- /dev/null +++ b/src/utils/device/parseUserAgent.ts @@ -0,0 +1,124 @@ +/* +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 UAParser from 'ua-parser-js'; + +export enum DeviceType { + Desktop = 'Desktop', + Mobile = 'Mobile', + Web = 'Web', + Unknown = 'Unknown', +} +export type ExtendedDeviceInformation = { + deviceType: DeviceType; + // eg Google Pixel 6 + deviceModel?: string; + // eg Android 11 + deviceOperatingSystem?: string; + // eg Firefox 1.1.0 + client?: string; +}; + +// Element/1.8.21 (iPhone XS Max; iOS 15.2; Scale/3.00) +const IOS_KEYWORD = "; iOS "; +const BROWSER_KEYWORD = "Mozilla/"; + +const getDeviceType = ( + userAgent: string, + device: UAParser.IDevice, + browser: UAParser.IBrowser, + operatingSystem: UAParser.IOS, +): DeviceType => { + if (browser.name === 'Electron') { + return DeviceType.Desktop; + } + if (!!browser.name) { + return DeviceType.Web; + } + if ( + device.type === 'mobile' || + operatingSystem.name?.includes('Android') || + userAgent.indexOf(IOS_KEYWORD) > -1 + ) { + return DeviceType.Mobile; + } + return DeviceType.Unknown; +}; + +interface CustomValues { + customDeviceModel?: string; + customDeviceOS?: string; +} +/** + * Some mobile model and OS strings are not recognised + * by the UA parsing library + * check they exist by hand + */ +const checkForCustomValues = (userAgent: string): CustomValues => { + if (userAgent.includes(BROWSER_KEYWORD)) { + return {}; + } + + const mightHaveDevice = userAgent.includes('('); + if (!mightHaveDevice) { + return {}; + } + const deviceInfoSegments = userAgent.substring(userAgent.indexOf('(') + 1).split('; '); + const customDeviceModel = deviceInfoSegments[0] || undefined; + const customDeviceOS = deviceInfoSegments[1] || undefined; + return { customDeviceModel, customDeviceOS }; +}; + +const concatenateNameAndVersion = (name?: string, version?: string): string | undefined => + name && [name, version].filter(Boolean).join(' '); + +export const parseUserAgent = (userAgent?: string): ExtendedDeviceInformation => { + if (!userAgent) { + return { + deviceType: DeviceType.Unknown, + }; + } + + const parser = new UAParser(userAgent); + + const browser = parser.getBrowser(); + const device = parser.getDevice(); + const operatingSystem = parser.getOS(); + + const deviceType = getDeviceType(userAgent, device, browser, operatingSystem); + + // OSX versions are frozen at 10.15.17 in UA strings https://chromestatus.com/feature/5452592194781184 + // ignore OS version in browser based sessions + const shouldIgnoreOSVersion = deviceType === DeviceType.Web || deviceType === DeviceType.Desktop; + const deviceOperatingSystem = concatenateNameAndVersion( + operatingSystem.name, + shouldIgnoreOSVersion ? undefined : operatingSystem.version, + ); + const deviceModel = concatenateNameAndVersion(device.vendor, device.model); + const client = concatenateNameAndVersion(browser.name, browser.version); + + // only try to parse custom model and OS when device type is known + const { customDeviceModel, customDeviceOS } = deviceType !== DeviceType.Unknown + ? checkForCustomValues(userAgent) + : {} as CustomValues; + + return { + deviceType, + deviceModel: deviceModel || customDeviceModel, + deviceOperatingSystem: deviceOperatingSystem || customDeviceOS, + client, + }; +}; diff --git a/src/utils/exportUtils/Exporter.ts b/src/utils/exportUtils/Exporter.ts index 7f7f57f92ef..ec20f395e32 100644 --- a/src/utils/exportUtils/Exporter.ts +++ b/src/utils/exportUtils/Exporter.ts @@ -1,5 +1,5 @@ /* -Copyright 2021 The Matrix.org Foundation C.I.C. +Copyright 2021 - 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. @@ -20,12 +20,13 @@ import { MatrixClient } from "matrix-js-sdk/src/client"; import { Direction } from "matrix-js-sdk/src/models/event-timeline"; import { saveAs } from "file-saver"; import { logger } from "matrix-js-sdk/src/logger"; +import sanitizeFilename from "sanitize-filename"; import { MatrixClientPeg } from "../../MatrixClientPeg"; import { ExportType, IExportOptions } from "./exportUtils"; import { decryptFile } from "../DecryptFile"; import { mediaFromContent } from "../../customisations/Media"; -import { formatFullDateNoDay } from "../../DateUtils"; +import { formatFullDateNoDay, formatFullDateNoDayISO } from "../../DateUtils"; import { isVoiceMessage } from "../EventUtils"; import { IMediaEventContent } from "../../customisations/models/IMediaEventContent"; import { _t } from "../../languageHandler"; @@ -57,6 +58,10 @@ export default abstract class Exporter { window.addEventListener("beforeunload", this.onBeforeUnload); } + public get destinationFileName(): string { + return this.makeFileNameNoExtension(SdkConfig.get().brand) + ".zip"; + } + protected onBeforeUnload(e: BeforeUnloadEvent): string { e.preventDefault(); return e.returnValue = _t("Are you sure you want to exit during this export?"); @@ -75,10 +80,19 @@ export default abstract class Exporter { this.files.push(file); } + protected makeFileNameNoExtension(brand = "matrix"): string { + // First try to use the real name of the room, then a translated copy of a generic name, + // then finally hardcoded default to guarantee we'll have a name. + const safeRoomName = sanitizeFilename(this.room.name ?? _t("Unnamed Room")).trim() || "Unnamed Room"; + const safeDate = formatFullDateNoDayISO(new Date()) + .replace(/:/g, '-'); // ISO format automatically removes a lot of stuff for us + const safeBrand = sanitizeFilename(brand); + return `${safeBrand} - ${safeRoomName} - Chat Export - ${safeDate}`; + } + protected async downloadZIP(): Promise { - const brand = SdkConfig.get().brand; - const filenameWithoutExt = `${brand} - Chat Export - ${formatFullDateNoDay(new Date())}`; - const filename = `${filenameWithoutExt}.zip`; + const filename = this.destinationFileName; + const filenameWithoutExt = filename.substring(0, filename.length - 4); // take off the .zip const { default: JSZip } = await import('jszip'); const zip = new JSZip(); diff --git a/src/utils/exportUtils/JSONExport.ts b/src/utils/exportUtils/JSONExport.ts index b0b4b330b06..a0dc5e036e6 100644 --- a/src/utils/exportUtils/JSONExport.ts +++ b/src/utils/exportUtils/JSONExport.ts @@ -1,5 +1,5 @@ /* -Copyright 2021 The Matrix.org Foundation C.I.C. +Copyright 2021 - 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. @@ -20,7 +20,7 @@ import { EventType } from "matrix-js-sdk/src/@types/event"; import { logger } from "matrix-js-sdk/src/logger"; import Exporter from "./Exporter"; -import { formatFullDateNoDay, formatFullDateNoDayNoTime } from "../../DateUtils"; +import { formatFullDateNoDayNoTime } from "../../DateUtils"; import { ExportType, IExportOptions } from "./exportUtils"; import { _t } from "../../languageHandler"; import { haveRendererForEvent } from "../../events/EventTileFactory"; @@ -38,6 +38,10 @@ export default class JSONExporter extends Exporter { super(room, exportType, exportOptions, setProgressText); } + public get destinationFileName(): string { + return this.makeFileNameNoExtension() + ".json"; + } + protected createJSONString(): string { const exportDate = formatFullDateNoDayNoTime(new Date()); const creator = this.room.currentState.getStateEvents(EventType.RoomCreate, "")?.getSender(); @@ -108,7 +112,7 @@ export default class JSONExporter extends Exporter { this.addFile("export.json", new Blob([text])); await this.downloadZIP(); } else { - const fileName = `matrix-export-${formatFullDateNoDay(new Date())}.json`; + const fileName = this.destinationFileName; this.downloadPlainText(fileName, text); } diff --git a/src/utils/exportUtils/PlainTextExport.ts b/src/utils/exportUtils/PlainTextExport.ts index cc4cad18947..3150b15c642 100644 --- a/src/utils/exportUtils/PlainTextExport.ts +++ b/src/utils/exportUtils/PlainTextExport.ts @@ -1,5 +1,5 @@ /* -Copyright 2021 The Matrix.org Foundation C.I.C. +Copyright 2021 - 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. @@ -20,7 +20,6 @@ import { logger } from "matrix-js-sdk/src/logger"; import React from "react"; import Exporter from "./Exporter"; -import { formatFullDateNoDay } from "../../DateUtils"; import { _t } from "../../languageHandler"; import { ExportType, IExportOptions } from "./exportUtils"; import { textForEvent } from "../../TextForEvent"; @@ -43,6 +42,10 @@ export default class PlainTextExporter extends Exporter { : _t("Media omitted - file size limit exceeded"); } + public get destinationFileName(): string { + return this.makeFileNameNoExtension() + ".txt"; + } + public textForReplyEvent = (content: IContent) => { const REPLY_REGEX = /> <(.*?)>(.*?)\n\n(.*)/s; const REPLY_SOURCE_MAX_LENGTH = 32; @@ -137,7 +140,7 @@ export default class PlainTextExporter extends Exporter { this.addFile("export.txt", new Blob([text])); await this.downloadZIP(); } else { - const fileName = `matrix-export-${formatFullDateNoDay(new Date())}.txt`; + const fileName = this.destinationFileName; this.downloadPlainText(fileName, text); } diff --git a/src/utils/notifications.ts b/src/utils/notifications.ts new file mode 100644 index 00000000000..32296d62e6e --- /dev/null +++ b/src/utils/notifications.ts @@ -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 { MatrixClient } from "matrix-js-sdk/src/client"; +import { LOCAL_NOTIFICATION_SETTINGS_PREFIX } from "matrix-js-sdk/src/@types/event"; +import { LocalNotificationSettings } from "matrix-js-sdk/src/@types/local_notifications"; + +import SettingsStore from "../settings/SettingsStore"; + +export const deviceNotificationSettingsKeys = [ + "notificationsEnabled", + "notificationBodyEnabled", + "audioNotificationsEnabled", +]; + +export function getLocalNotificationAccountDataEventType(deviceId: string): string { + return `${LOCAL_NOTIFICATION_SETTINGS_PREFIX.name}.${deviceId}`; +} + +export async function createLocalNotificationSettingsIfNeeded(cli: MatrixClient): Promise { + if (cli.isGuest()) { + return; + } + const eventType = getLocalNotificationAccountDataEventType(cli.deviceId); + const event = cli.getAccountData(eventType); + // New sessions will create an account data event to signify they support + // remote toggling of push notifications on this device. Default `is_silenced=true` + // For backwards compat purposes, older sessions will need to check settings value + // to determine what the state of `is_silenced` + if (!event) { + // If any of the above is true, we fall in the "backwards compat" case, + // and `is_silenced` will be set to `false` + const isSilenced = !deviceNotificationSettingsKeys.some(key => SettingsStore.getValue(key)); + + await cli.setAccountData(eventType, { + is_silenced: isSilenced, + }); + } +} + +export function localNotificationsAreSilenced(cli: MatrixClient): boolean { + const eventType = getLocalNotificationAccountDataEventType(cli.deviceId); + const event = cli.getAccountData(eventType); + return event?.getContent()?.is_silenced ?? false; +} diff --git a/src/utils/permalinks/ElementPermalinkConstructor.ts b/src/utils/permalinks/ElementPermalinkConstructor.ts index 01525081a60..d66c3ae031c 100644 --- a/src/utils/permalinks/ElementPermalinkConstructor.ts +++ b/src/utils/permalinks/ElementPermalinkConstructor.ts @@ -43,17 +43,11 @@ export default class ElementPermalinkConstructor extends PermalinkConstructor { return `${this.elementUrl}/#/user/${userId}`; } - forGroup(groupId: string): string { - return `${this.elementUrl}/#/group/${groupId}`; - } - forEntity(entityId: string): string { if (entityId[0] === '!' || entityId[0] === '#') { return this.forRoom(entityId); } else if (entityId[0] === '@') { return this.forUser(entityId); - } else if (entityId[0] === '+') { - return this.forGroup(entityId); } else throw new Error("Unrecognized entity"); } @@ -107,8 +101,6 @@ export default class ElementPermalinkConstructor extends PermalinkConstructor { const eventId = parts.length > 2 ? parts.slice(2).join('/') : ""; const via = query.split(/&?via=/).filter(p => !!p); return PermalinkParts.forEvent(entity, eventId, via); - } else if (entityType === 'group') { - return PermalinkParts.forGroup(entity); } else { throw new Error("Unknown entity type in permalink"); } diff --git a/src/utils/permalinks/MatrixSchemePermalinkConstructor.ts b/src/utils/permalinks/MatrixSchemePermalinkConstructor.ts index 70622af7c27..904fbb89397 100644 --- a/src/utils/permalinks/MatrixSchemePermalinkConstructor.ts +++ b/src/utils/permalinks/MatrixSchemePermalinkConstructor.ts @@ -51,10 +51,6 @@ export default class MatrixSchemePermalinkConstructor extends PermalinkConstruct return `matrix:${this.encodeEntity(userId)}`; } - forGroup(groupId: string): string { - throw new Error("Deliberately not implemented"); - } - forEntity(entityId: string): string { return `matrix:${this.encodeEntity(entityId)}`; } diff --git a/src/utils/permalinks/MatrixToPermalinkConstructor.ts b/src/utils/permalinks/MatrixToPermalinkConstructor.ts index ab2de5959a0..3a57fc443f9 100644 --- a/src/utils/permalinks/MatrixToPermalinkConstructor.ts +++ b/src/utils/permalinks/MatrixToPermalinkConstructor.ts @@ -39,10 +39,6 @@ export default class MatrixToPermalinkConstructor extends PermalinkConstructor { return `${baseUrl}/#/${userId}`; } - forGroup(groupId: string): string { - return `${baseUrl}/#/${groupId}`; - } - forEntity(entityId: string): string { return `${baseUrl}/#/${entityId}`; } @@ -82,8 +78,6 @@ export default class MatrixToPermalinkConstructor extends PermalinkConstructor { const via = query.split(/&?via=/g).filter(p => !!p); return PermalinkParts.forEvent(entity, eventId, via); - } else if (entity[0] === '+') { - return PermalinkParts.forGroup(entity); } else { throw new Error("Unknown entity type in permalink"); } diff --git a/src/utils/permalinks/PermalinkConstructor.ts b/src/utils/permalinks/PermalinkConstructor.ts index 158df895e2e..f259094095f 100644 --- a/src/utils/permalinks/PermalinkConstructor.ts +++ b/src/utils/permalinks/PermalinkConstructor.ts @@ -27,10 +27,6 @@ export default class PermalinkConstructor { throw new Error("Not implemented"); } - forGroup(groupId: string): string { - throw new Error("Not implemented"); - } - forUser(userId: string): string { throw new Error("Not implemented"); } @@ -55,30 +51,24 @@ export class PermalinkParts { eventId: string; userId: string; viaServers: string[]; - groupId: string; - constructor(roomIdOrAlias: string, eventId: string, userId: string, groupId: string, viaServers: string[]) { + constructor(roomIdOrAlias: string, eventId: string, userId: string, viaServers: string[]) { this.roomIdOrAlias = roomIdOrAlias; this.eventId = eventId; this.userId = userId; - this.groupId = groupId; this.viaServers = viaServers; } static forUser(userId: string): PermalinkParts { - return new PermalinkParts(null, null, userId, null, null); - } - - static forGroup(groupId: string): PermalinkParts { - return new PermalinkParts(null, null, null, groupId, null); + return new PermalinkParts(null, null, userId, null); } static forRoom(roomIdOrAlias: string, viaServers: string[] = []): PermalinkParts { - return new PermalinkParts(roomIdOrAlias, null, null, null, viaServers); + return new PermalinkParts(roomIdOrAlias, null, null, viaServers); } static forEvent(roomId: string, eventId: string, viaServers: string[] = []): PermalinkParts { - return new PermalinkParts(roomId, eventId, null, null, viaServers); + return new PermalinkParts(roomId, eventId, null, viaServers); } get primaryEntityId(): string { diff --git a/src/utils/permalinks/Permalinks.ts b/src/utils/permalinks/Permalinks.ts index c7c7de9c534..ce2f8aeb1d3 100644 --- a/src/utils/permalinks/Permalinks.ts +++ b/src/utils/permalinks/Permalinks.ts @@ -295,10 +295,6 @@ export function makeRoomPermalink(roomId: string): string { return permalinkCreator.forShareableRoom(); } -export function makeGroupPermalink(groupId: string): string { - return getPermalinkConstructor().forGroup(groupId); -} - export function isPermalinkHost(host: string): boolean { // Always check if the permalink is a spec permalink (callers are likely to call // parsePermalink after this function). @@ -319,7 +315,6 @@ export function tryTransformEntityToPermalink(entity: string): string { // Check to see if it is a bare entity for starters if (entity[0] === '#' || entity[0] === '!') return makeRoomPermalink(entity); if (entity[0] === '@') return makeUserPermalink(entity); - if (entity[0] === '+') return makeGroupPermalink(entity); if (entity.slice(0, 7) === "matrix:") { try { @@ -332,8 +327,6 @@ export function tryTransformEntityToPermalink(entity: string): string { pl += new MatrixToPermalinkConstructor().encodeServerCandidates(permalinkParts.viaServers); } return pl; - } else if (permalinkParts.groupId) { - return matrixtoBaseUrl + `/#/${permalinkParts.groupId}`; } else if (permalinkParts.userId) { return matrixtoBaseUrl + `/#/${permalinkParts.userId}`; } @@ -381,8 +374,6 @@ export function tryTransformPermalinkToLocalHref(permalink: string): string { } } else if (permalinkParts.userId) { permalink = `#/user/${permalinkParts.userId}`; - } else if (permalinkParts.groupId) { - permalink = `#/group/${permalinkParts.groupId}`; } // else not a valid permalink for our purposes - do not handle } } catch (e) { @@ -410,7 +401,6 @@ export function getPrimaryPermalinkEntity(permalink: string): string { if (!permalinkParts) return null; // not processable if (permalinkParts.userId) return permalinkParts.userId; if (permalinkParts.roomIdOrAlias) return permalinkParts.roomIdOrAlias; - if (permalinkParts.groupId) return permalinkParts.groupId; } catch (e) { // no entity - not a permalink } diff --git a/src/voice-broadcast/audio/VoiceBroadcastRecorder.ts b/src/voice-broadcast/audio/VoiceBroadcastRecorder.ts new file mode 100644 index 00000000000..7f084f3f4ae --- /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 new file mode 100644 index 00000000000..b05c6c894b9 --- /dev/null +++ b/src/voice-broadcast/components/VoiceBroadcastBody.tsx @@ -0,0 +1,69 @@ +/* +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, { useEffect, useState } from "react"; +import { MatrixEvent, RelationType } from "matrix-js-sdk/src/matrix"; + +import { + VoiceBroadcastRecordingBody, + VoiceBroadcastRecordingsStore, + shouldDisplayAsVoiceBroadcastRecordingTile, + VoiceBroadcastInfoEventType, + VoiceBroadcastPlaybacksStore, + VoiceBroadcastPlaybackBody, + VoiceBroadcastInfoState, +} from ".."; +import { IBodyProps } from "../../components/views/messages/IBodyProps"; +import { MatrixClientPeg } from "../../MatrixClientPeg"; +import { RelationsHelper, RelationsHelperEvent } from "../../events/RelationsHelper"; + +export const VoiceBroadcastBody: React.FC = ({ mxEvent }) => { + const client = MatrixClientPeg.get(); + const [infoState, setInfoState] = useState(mxEvent.getContent()?.state || VoiceBroadcastInfoState.Stopped); + + useEffect(() => { + const onInfoEvent = (event: MatrixEvent) => { + if (event.getContent()?.state === VoiceBroadcastInfoState.Stopped) { + // only a stopped event can change the tile state + setInfoState(VoiceBroadcastInfoState.Stopped); + } + }; + + const relationsHelper = new RelationsHelper( + mxEvent, + RelationType.Reference, + VoiceBroadcastInfoEventType, + client, + ); + relationsHelper.on(RelationsHelperEvent.Add, onInfoEvent); + + return () => { + relationsHelper.destroy(); + }; + }); + + if (shouldDisplayAsVoiceBroadcastRecordingTile(infoState, client, mxEvent)) { + const recording = VoiceBroadcastRecordingsStore.instance().getByInfoEvent(mxEvent, client); + return ; + } + + const playback = VoiceBroadcastPlaybacksStore.instance().getByInfoEvent(mxEvent, client); + return ; +}; diff --git a/src/voice-broadcast/components/atoms/LiveBadge.tsx b/src/voice-broadcast/components/atoms/LiveBadge.tsx new file mode 100644 index 00000000000..cd2a16e797d --- /dev/null +++ b/src/voice-broadcast/components/atoms/LiveBadge.tsx @@ -0,0 +1,27 @@ +/* +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, IconColour, IconType } from "../../../components/atoms/Icon"; +import { _t } from "../../../languageHandler"; + +export const LiveBadge: React.FC = () => { + return
    + + { _t("Live") } +
    ; +}; diff --git a/src/voice-broadcast/components/atoms/PlaybackControlButton.tsx b/src/voice-broadcast/components/atoms/PlaybackControlButton.tsx new file mode 100644 index 00000000000..b67e6b3e24f --- /dev/null +++ b/src/voice-broadcast/components/atoms/PlaybackControlButton.tsx @@ -0,0 +1,53 @@ +/* +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 { VoiceBroadcastPlaybackState } from "../.."; +import { Icon, IconColour, IconType } from "../../../components/atoms/Icon"; +import AccessibleButton from "../../../components/views/elements/AccessibleButton"; +import { _t } from "../../../languageHandler"; + +const stateIconMap = new Map([ + [VoiceBroadcastPlaybackState.Playing, IconType.Pause], + [VoiceBroadcastPlaybackState.Paused, IconType.Play], + [VoiceBroadcastPlaybackState.Stopped, IconType.Play], +]); + +interface Props { + onClick: () => void; + state: VoiceBroadcastPlaybackState; +} + +export const PlaybackControlButton: React.FC = ({ + onClick, + state, +}) => { + const ariaLabel = state === VoiceBroadcastPlaybackState.Playing + ? _t("pause voice broadcast") + : _t("resume voice broadcast"); + + return + + ; +}; diff --git a/src/voice-broadcast/components/atoms/StopButton.tsx b/src/voice-broadcast/components/atoms/StopButton.tsx new file mode 100644 index 00000000000..50abb209d05 --- /dev/null +++ b/src/voice-broadcast/components/atoms/StopButton.tsx @@ -0,0 +1,40 @@ +/* +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, IconColour, IconType } from "../../../components/atoms/Icon"; +import AccessibleButton from "../../../components/views/elements/AccessibleButton"; +import { _t } from "../../../languageHandler"; + +interface Props { + onClick: () => void; +} + +export const StopButton: React.FC = ({ + onClick, +}) => { + return + + ; +}; diff --git a/src/voice-broadcast/components/atoms/VoiceBroadcastHeader.tsx b/src/voice-broadcast/components/atoms/VoiceBroadcastHeader.tsx new file mode 100644 index 00000000000..5abc4d21e41 --- /dev/null +++ b/src/voice-broadcast/components/atoms/VoiceBroadcastHeader.tsx @@ -0,0 +1,56 @@ +/* +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 { Room, RoomMember } from "matrix-js-sdk/src/matrix"; + +import { LiveBadge } from "../.."; +import { Icon, IconColour, IconType } from "../../../components/atoms/Icon"; +import { _t } from "../../../languageHandler"; +import RoomAvatar from "../../../components/views/avatars/RoomAvatar"; + +interface VoiceBroadcastHeaderProps { + live: boolean; + sender: RoomMember; + room: Room; + showBroadcast?: boolean; +} + +export const VoiceBroadcastHeader: React.FC = ({ + live, + sender, + room, + showBroadcast = false, +}) => { + const broadcast = showBroadcast + ?
    + + { _t("Voice broadcast") } +
    + : null; + const liveBadge = live ? : null; + return
    + +
    +
    + { room.name } +
    +
    + + { sender.name } +
    + { broadcast } +
    + { liveBadge } +
    ; +}; diff --git a/src/voice-broadcast/components/molecules/VoiceBroadcastPlaybackBody.tsx b/src/voice-broadcast/components/molecules/VoiceBroadcastPlaybackBody.tsx new file mode 100644 index 00000000000..035b3ce6e57 --- /dev/null +++ b/src/voice-broadcast/components/molecules/VoiceBroadcastPlaybackBody.tsx @@ -0,0 +1,60 @@ +/* +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 { + PlaybackControlButton, + VoiceBroadcastHeader, + VoiceBroadcastPlayback, + VoiceBroadcastPlaybackState, +} from "../.."; +import Spinner from "../../../components/views/elements/Spinner"; +import { useVoiceBroadcastPlayback } from "../../hooks/useVoiceBroadcastPlayback"; + +interface VoiceBroadcastPlaybackBodyProps { + playback: VoiceBroadcastPlayback; +} + +export const VoiceBroadcastPlaybackBody: React.FC = ({ + playback, +}) => { + const { + live, + room, + sender, + toggle, + playbackState, + } = useVoiceBroadcastPlayback(playback); + + const control = playbackState === VoiceBroadcastPlaybackState.Buffering + ? + : ; + + return ( +
    + +
    + { control } +
    +
    + ); +}; diff --git a/src/voice-broadcast/components/molecules/VoiceBroadcastRecordingBody.tsx b/src/voice-broadcast/components/molecules/VoiceBroadcastRecordingBody.tsx new file mode 100644 index 00000000000..b9721170eb0 --- /dev/null +++ b/src/voice-broadcast/components/molecules/VoiceBroadcastRecordingBody.tsx @@ -0,0 +1,38 @@ +/* +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 { useVoiceBroadcastRecording, VoiceBroadcastHeader, VoiceBroadcastRecording } from "../.."; + +interface VoiceBroadcastRecordingBodyProps { + recording: VoiceBroadcastRecording; +} + +export const VoiceBroadcastRecordingBody: React.FC = ({ recording }) => { + const { + live, + room, + sender, + } = useVoiceBroadcastRecording(recording); + + return ( +
    + +
    + ); +}; diff --git a/src/voice-broadcast/components/molecules/VoiceBroadcastRecordingPip.tsx b/src/voice-broadcast/components/molecules/VoiceBroadcastRecordingPip.tsx new file mode 100644 index 00000000000..c7604b7d900 --- /dev/null +++ b/src/voice-broadcast/components/molecules/VoiceBroadcastRecordingPip.tsx @@ -0,0 +1,51 @@ +/* +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 { + StopButton, + VoiceBroadcastRecording, +} from "../.."; +import { useVoiceBroadcastRecording } from "../../hooks/useVoiceBroadcastRecording"; +import { VoiceBroadcastHeader } from "../atoms/VoiceBroadcastHeader"; + +interface VoiceBroadcastRecordingPipProps { + recording: VoiceBroadcastRecording; +} + +export const VoiceBroadcastRecordingPip: React.FC = ({ recording }) => { + const { + live, + sender, + room, + stopRecording, + } = useVoiceBroadcastRecording(recording); + + return
    + +
    +
    + +
    +
    ; +}; diff --git a/src/voice-broadcast/hooks/useVoiceBroadcastPlayback.ts b/src/voice-broadcast/hooks/useVoiceBroadcastPlayback.ts new file mode 100644 index 00000000000..452035fbb96 --- /dev/null +++ b/src/voice-broadcast/hooks/useVoiceBroadcastPlayback.ts @@ -0,0 +1,60 @@ +/* +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 { useState } from "react"; + +import { useTypedEventEmitter } from "../../hooks/useEventEmitter"; +import { MatrixClientPeg } from "../../MatrixClientPeg"; +import { + VoiceBroadcastInfoState, + VoiceBroadcastPlayback, + VoiceBroadcastPlaybackEvent, + VoiceBroadcastPlaybackState, +} from ".."; + +export const useVoiceBroadcastPlayback = (playback: VoiceBroadcastPlayback) => { + const client = MatrixClientPeg.get(); + const room = client.getRoom(playback.infoEvent.getRoomId()); + const playbackToggle = () => { + playback.toggle(); + }; + + const [playbackState, setPlaybackState] = useState(playback.getState()); + useTypedEventEmitter( + playback, + VoiceBroadcastPlaybackEvent.StateChanged, + (state: VoiceBroadcastPlaybackState, _playback: VoiceBroadcastPlayback) => { + setPlaybackState(state); + }, + ); + + const [playbackInfoState, setPlaybackInfoState] = useState(playback.getInfoState()); + useTypedEventEmitter( + playback, + VoiceBroadcastPlaybackEvent.InfoStateChanged, + (state: VoiceBroadcastInfoState) => { + setPlaybackInfoState(state); + }, + ); + + return { + live: playbackInfoState !== VoiceBroadcastInfoState.Stopped, + room: room, + sender: playback.infoEvent.sender, + toggle: playbackToggle, + playbackState, + }; +}; diff --git a/src/voice-broadcast/hooks/useVoiceBroadcastRecording.tsx b/src/voice-broadcast/hooks/useVoiceBroadcastRecording.tsx new file mode 100644 index 00000000000..c0db5617461 --- /dev/null +++ b/src/voice-broadcast/hooks/useVoiceBroadcastRecording.tsx @@ -0,0 +1,76 @@ +/* +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, { useState } from "react"; + +import { + VoiceBroadcastInfoState, + VoiceBroadcastRecording, + VoiceBroadcastRecordingEvent, + VoiceBroadcastRecordingsStore, +} from ".."; +import QuestionDialog from "../../components/views/dialogs/QuestionDialog"; +import { useTypedEventEmitter } from "../../hooks/useEventEmitter"; +import { _t } from "../../languageHandler"; +import { MatrixClientPeg } from "../../MatrixClientPeg"; +import Modal from "../../Modal"; + +const showStopBroadcastingDialog = async (): Promise => { + const { finished } = Modal.createDialog( + QuestionDialog, + { + title: _t("Stop live broadcasting?"), + description: ( +

    + { _t("Are you sure you want to stop your live broadcast?" + + "This will end the broadcast and the full recording will be available in the room.") } +

    + ), + button: _t("Yes, stop broadcast"), + }, + ); + const [confirmed] = await finished; + return confirmed; +}; + +export const useVoiceBroadcastRecording = (recording: VoiceBroadcastRecording) => { + const client = MatrixClientPeg.get(); + const room = client.getRoom(recording.infoEvent.getRoomId()); + const stopRecording = async () => { + const confirmed = await showStopBroadcastingDialog(); + + if (confirmed) { + recording.stop(); + VoiceBroadcastRecordingsStore.instance().clearCurrent(); + } + }; + + const [live, setLive] = useState(recording.getState() === VoiceBroadcastInfoState.Started); + useTypedEventEmitter( + recording, + VoiceBroadcastRecordingEvent.StateChanged, + (state: VoiceBroadcastInfoState, _recording: VoiceBroadcastRecording) => { + setLive(state === VoiceBroadcastInfoState.Started); + }, + ); + + return { + live, + room, + sender: recording.infoEvent.sender, + stopRecording, + }; +}; diff --git a/src/voice-broadcast/index.ts b/src/voice-broadcast/index.ts index 473bbf73ef1..7262382b0c5 100644 --- a/src/voice-broadcast/index.ts +++ b/src/voice-broadcast/index.ts @@ -14,4 +14,47 @@ See the License for the specific language governing permissions and limitations under the License. */ +/** + * Voice Broadcast module + * {@link https://github.com/vector-im/element-meta/discussions/632} + */ + +import { RelationType } from "matrix-js-sdk/src/matrix"; + +export * from "./models/VoiceBroadcastPlayback"; +export * from "./models/VoiceBroadcastRecording"; +export * from "./audio/VoiceBroadcastRecorder"; +export * from "./components/VoiceBroadcastBody"; +export * from "./components/atoms/LiveBadge"; +export * from "./components/atoms/PlaybackControlButton"; +export * from "./components/atoms/StopButton"; +export * from "./components/atoms/VoiceBroadcastHeader"; +export * from "./components/molecules/VoiceBroadcastPlaybackBody"; +export * from "./components/molecules/VoiceBroadcastRecordingBody"; +export * from "./components/molecules/VoiceBroadcastRecordingPip"; +export * from "./hooks/useVoiceBroadcastRecording"; +export * from "./stores/VoiceBroadcastPlaybacksStore"; +export * from "./stores/VoiceBroadcastRecordingsStore"; +export * from "./utils/shouldDisplayAsVoiceBroadcastRecordingTile"; +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", + Paused = "paused", + Running = "running", + Stopped = "stopped", +} + +export interface VoiceBroadcastInfoEventContent { + device_id: string; + state: VoiceBroadcastInfoState; + chunk_length?: number; + ["m.relates_to"]?: { + rel_type: RelationType; + event_id: string; + }; +} diff --git a/src/voice-broadcast/models/VoiceBroadcastPlayback.ts b/src/voice-broadcast/models/VoiceBroadcastPlayback.ts new file mode 100644 index 00000000000..641deb66ad1 --- /dev/null +++ b/src/voice-broadcast/models/VoiceBroadcastPlayback.ts @@ -0,0 +1,290 @@ +/* +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 { + EventType, + MatrixClient, + MatrixEvent, + MsgType, + RelationType, +} from "matrix-js-sdk/src/matrix"; +import { TypedEventEmitter } from "matrix-js-sdk/src/models/typed-event-emitter"; + +import { Playback, PlaybackState } from "../../audio/Playback"; +import { PlaybackManager } from "../../audio/PlaybackManager"; +import { UPDATE_EVENT } from "../../stores/AsyncStore"; +import { MediaEventHelper } from "../../utils/MediaEventHelper"; +import { IDestroyable } from "../../utils/IDestroyable"; +import { VoiceBroadcastChunkEventType, VoiceBroadcastInfoEventType, VoiceBroadcastInfoState } from ".."; +import { RelationsHelper, RelationsHelperEvent } from "../../events/RelationsHelper"; +import { getReferenceRelationsForEvent } from "../../events"; + +export enum VoiceBroadcastPlaybackState { + Paused, + Playing, + Stopped, + Buffering, +} + +export enum VoiceBroadcastPlaybackEvent { + LengthChanged = "length_changed", + StateChanged = "state_changed", + InfoStateChanged = "info_state_changed", +} + +interface EventMap { + [VoiceBroadcastPlaybackEvent.LengthChanged]: (length: number) => void; + [VoiceBroadcastPlaybackEvent.StateChanged]: (state: VoiceBroadcastPlaybackState) => void; + [VoiceBroadcastPlaybackEvent.InfoStateChanged]: (state: VoiceBroadcastInfoState) => void; +} + +export class VoiceBroadcastPlayback + extends TypedEventEmitter + implements IDestroyable { + private state = VoiceBroadcastPlaybackState.Stopped; + private infoState: VoiceBroadcastInfoState; + private chunkEvents = new Map(); + private queue: Playback[] = []; + private currentlyPlaying: Playback; + private lastInfoEvent: MatrixEvent; + private chunkRelationHelper: RelationsHelper; + private infoRelationHelper: RelationsHelper; + + public constructor( + public readonly infoEvent: MatrixEvent, + private client: MatrixClient, + ) { + super(); + this.addInfoEvent(this.infoEvent); + this.setUpRelationsHelper(); + } + + private setUpRelationsHelper(): void { + this.infoRelationHelper = new RelationsHelper( + this.infoEvent, + RelationType.Reference, + VoiceBroadcastInfoEventType, + this.client, + ); + this.infoRelationHelper.on(RelationsHelperEvent.Add, this.addInfoEvent); + this.infoRelationHelper.emitCurrent(); + + this.chunkRelationHelper = new RelationsHelper( + this.infoEvent, + RelationType.Reference, + EventType.RoomMessage, + this.client, + ); + this.chunkRelationHelper.on(RelationsHelperEvent.Add, this.addChunkEvent); + this.chunkRelationHelper.emitCurrent(); + } + + private addChunkEvent = async (event: MatrixEvent): Promise => { + const eventId = event.getId(); + + if (!eventId + || eventId.startsWith("~!") // don't add local events + || event.getContent()?.msgtype !== MsgType.Audio // don't add non-audio event + || this.chunkEvents.has(eventId)) { + return false; + } + + this.chunkEvents.set(eventId, event); + + if (this.getState() !== VoiceBroadcastPlaybackState.Stopped) { + await this.enqueueChunk(event); + } + + if (this.getState() === VoiceBroadcastPlaybackState.Buffering) { + await this.start(); + } + + return true; + }; + + private addInfoEvent = (event: MatrixEvent): void => { + if (this.lastInfoEvent && this.lastInfoEvent.getTs() >= event.getTs()) { + // Only handle newer events + return; + } + + const state = event.getContent()?.state; + + if (!Object.values(VoiceBroadcastInfoState).includes(state)) { + // Do not handle unknown voice broadcast states + return; + } + + this.lastInfoEvent = event; + this.setInfoState(state); + }; + + private async loadChunks(): Promise { + const relations = getReferenceRelationsForEvent(this.infoEvent, EventType.RoomMessage, this.client); + const chunkEvents = relations?.getRelations(); + + if (!chunkEvents) { + return; + } + + for (const chunkEvent of chunkEvents) { + await this.enqueueChunk(chunkEvent); + } + } + + private async enqueueChunk(chunkEvent: MatrixEvent) { + const sequenceNumber = parseInt(chunkEvent.getContent()?.[VoiceBroadcastChunkEventType]?.sequence, 10); + if (isNaN(sequenceNumber) || sequenceNumber < 1) return; + + const helper = new MediaEventHelper(chunkEvent); + const blob = await helper.sourceBlob.value; + const buffer = await blob.arrayBuffer(); + const playback = PlaybackManager.instance.createPlaybackInstance(buffer); + await playback.prepare(); + playback.clockInfo.populatePlaceholdersFrom(chunkEvent); + this.queue[sequenceNumber - 1] = playback; // -1 because the sequence number starts at 1 + playback.on(UPDATE_EVENT, (state) => this.onPlaybackStateChange(playback, state)); + } + + private async onPlaybackStateChange(playback: Playback, newState: PlaybackState) { + if (newState !== PlaybackState.Stopped) { + return; + } + + await this.playNext(playback); + } + + private async playNext(current: Playback): Promise { + const next = this.queue[this.queue.indexOf(current) + 1]; + + if (next) { + this.setState(VoiceBroadcastPlaybackState.Playing); + this.currentlyPlaying = next; + await next.play(); + return; + } + + if (this.getInfoState() === VoiceBroadcastInfoState.Stopped) { + this.setState(VoiceBroadcastPlaybackState.Stopped); + } else { + // No more chunks available, although the broadcast is not finished → enter buffering state. + this.setState(VoiceBroadcastPlaybackState.Buffering); + } + } + + public async start(): Promise { + if (this.queue.length === 0) { + await this.loadChunks(); + } + + const toPlayIndex = this.getInfoState() === VoiceBroadcastInfoState.Stopped + ? 0 // start at the beginning for an ended voice broadcast + : this.queue.length - 1; // start at the current chunk for an ongoing voice broadcast + + if (this.queue[toPlayIndex]) { + this.setState(VoiceBroadcastPlaybackState.Playing); + this.currentlyPlaying = this.queue[toPlayIndex]; + await this.currentlyPlaying.play(); + return; + } + + this.setState(VoiceBroadcastPlaybackState.Buffering); + } + + public get length(): number { + return this.chunkEvents.size; + } + + public stop(): void { + this.setState(VoiceBroadcastPlaybackState.Stopped); + + if (this.currentlyPlaying) { + this.currentlyPlaying.stop(); + } + } + + public pause(): void { + if (!this.currentlyPlaying) return; + + this.setState(VoiceBroadcastPlaybackState.Paused); + this.currentlyPlaying.pause(); + } + + public resume(): void { + if (!this.currentlyPlaying) return; + + this.setState(VoiceBroadcastPlaybackState.Playing); + this.currentlyPlaying.play(); + } + + /** + * Toggles the playback: + * stopped → playing + * playing → paused + * paused → playing + */ + public async toggle() { + if (this.state === VoiceBroadcastPlaybackState.Stopped) { + await this.start(); + return; + } + + if (this.state === VoiceBroadcastPlaybackState.Paused) { + this.resume(); + return; + } + + this.pause(); + } + + public getState(): VoiceBroadcastPlaybackState { + return this.state; + } + + private setState(state: VoiceBroadcastPlaybackState): void { + if (this.state === state) { + return; + } + + this.state = state; + this.emit(VoiceBroadcastPlaybackEvent.StateChanged, state); + } + + public getInfoState(): VoiceBroadcastInfoState { + return this.infoState; + } + + private setInfoState(state: VoiceBroadcastInfoState): void { + if (this.infoState === state) { + return; + } + + this.infoState = state; + this.emit(VoiceBroadcastPlaybackEvent.InfoStateChanged, state); + } + + private destroyQueue(): void { + this.queue.forEach(p => p.destroy()); + this.queue = []; + } + + public destroy(): void { + this.chunkRelationHelper.destroy(); + this.infoRelationHelper.destroy(); + this.removeAllListeners(); + this.destroyQueue(); + } +} diff --git a/src/voice-broadcast/models/VoiceBroadcastRecording.ts b/src/voice-broadcast/models/VoiceBroadcastRecording.ts new file mode 100644 index 00000000000..96b62a670f3 --- /dev/null +++ b/src/voice-broadcast/models/VoiceBroadcastRecording.ts @@ -0,0 +1,186 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { logger } from "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 { + 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"; +import dis from "../../dispatcher/dispatcher"; +import { ActionPayload } from "../../dispatcher/payloads"; + +export enum VoiceBroadcastRecordingEvent { + StateChanged = "liveness_changed", +} + +interface EventMap { + [VoiceBroadcastRecordingEvent.StateChanged]: (state: VoiceBroadcastInfoState) => void; +} + +export class VoiceBroadcastRecording + extends TypedEventEmitter + implements IDestroyable { + private state: VoiceBroadcastInfoState; + private recorder: VoiceBroadcastRecorder; + private sequence = 1; + private dispatcherRef: string; + + public constructor( + public readonly infoEvent: MatrixEvent, + private client: MatrixClient, + ) { + super(); + + const room = this.client.getRoom(this.infoEvent.getRoomId()); + const relations = room?.getUnfilteredTimelineSet()?.relations?.getChildEventsForEvent( + this.infoEvent.getId(), + RelationType.Reference, + VoiceBroadcastInfoEventType, + ); + const relatedEvents = relations?.getRelations(); + this.state = !relatedEvents?.find((event: MatrixEvent) => { + return event.getContent()?.state === VoiceBroadcastInfoState.Stopped; + }) ? VoiceBroadcastInfoState.Started : VoiceBroadcastInfoState.Stopped; + // TODO Michael W: add listening for updates + + this.dispatcherRef = dis.register(this.onAction); + } + + 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(); + dis.unregister(this.dispatcherRef); + } + + private onAction = (payload: ActionPayload) => { + if (payload.action !== "call_state") return; + + // stop on any call action + this.stop(); + }; + + private setState(state: VoiceBroadcastInfoState): void { + this.state = state; + this.emit(VoiceBroadcastRecordingEvent.StateChanged, this.state); + } + + 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, + event_id: this.infoEvent.getId(), + }, + }, + this.client.getUserId(), + ); + } + + private async stopRecorder(): Promise { + 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/stores/VoiceBroadcastPlaybacksStore.ts b/src/voice-broadcast/stores/VoiceBroadcastPlaybacksStore.ts new file mode 100644 index 00000000000..38d774e088a --- /dev/null +++ b/src/voice-broadcast/stores/VoiceBroadcastPlaybacksStore.ts @@ -0,0 +1,71 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { MatrixClient, MatrixEvent } from "matrix-js-sdk/src/matrix"; +import { TypedEventEmitter } from "matrix-js-sdk/src/models/typed-event-emitter"; + +import { VoiceBroadcastPlayback } from ".."; + +export enum VoiceBroadcastPlaybacksStoreEvent { + CurrentChanged = "current_changed", +} + +interface EventMap { + [VoiceBroadcastPlaybacksStoreEvent.CurrentChanged]: (recording: VoiceBroadcastPlayback) => void; +} + +/** + * This store provides access to the current and specific Voice Broadcast playbacks. + */ +export class VoiceBroadcastPlaybacksStore extends TypedEventEmitter { + private current: VoiceBroadcastPlayback | null; + private playbacks = new Map(); + + public constructor() { + super(); + } + + public setCurrent(current: VoiceBroadcastPlayback): void { + if (this.current === current) return; + + this.current = current; + this.playbacks.set(current.infoEvent.getId(), current); + this.emit(VoiceBroadcastPlaybacksStoreEvent.CurrentChanged, current); + } + + public getCurrent(): VoiceBroadcastPlayback { + return this.current; + } + + public getByInfoEvent(infoEvent: MatrixEvent, client: MatrixClient): VoiceBroadcastPlayback { + const infoEventId = infoEvent.getId(); + + if (!this.playbacks.has(infoEventId)) { + this.playbacks.set(infoEventId, new VoiceBroadcastPlayback(infoEvent, client)); + } + + return this.playbacks.get(infoEventId); + } + + public static readonly _instance = new VoiceBroadcastPlaybacksStore(); + + /** + * TODO Michael W: replace when https://github.com/matrix-org/matrix-react-sdk/pull/9293 has been merged + */ + public static instance() { + return VoiceBroadcastPlaybacksStore._instance; + } +} diff --git a/src/voice-broadcast/stores/VoiceBroadcastRecordingsStore.ts b/src/voice-broadcast/stores/VoiceBroadcastRecordingsStore.ts new file mode 100644 index 00000000000..cc12b474e8d --- /dev/null +++ b/src/voice-broadcast/stores/VoiceBroadcastRecordingsStore.ts @@ -0,0 +1,78 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { MatrixClient, MatrixEvent } from "matrix-js-sdk/src/matrix"; +import { TypedEventEmitter } from "matrix-js-sdk/src/models/typed-event-emitter"; + +import { VoiceBroadcastRecording } from ".."; + +export enum VoiceBroadcastRecordingsStoreEvent { + CurrentChanged = "current_changed", +} + +interface EventMap { + [VoiceBroadcastRecordingsStoreEvent.CurrentChanged]: (recording: VoiceBroadcastRecording) => void; +} + +/** + * This store provides access to the current and specific Voice Broadcast recordings. + */ +export class VoiceBroadcastRecordingsStore extends TypedEventEmitter { + private current: VoiceBroadcastRecording | null; + private recordings = new Map(); + + public constructor() { + super(); + } + + public setCurrent(current: VoiceBroadcastRecording): void { + if (this.current === current) return; + + this.current = current; + this.recordings.set(current.infoEvent.getId(), current); + this.emit(VoiceBroadcastRecordingsStoreEvent.CurrentChanged, current); + } + + public getCurrent(): VoiceBroadcastRecording { + return this.current; + } + + public clearCurrent(): void { + if (this.current === null) return; + + this.current = null; + this.emit(VoiceBroadcastRecordingsStoreEvent.CurrentChanged, null); + } + + public getByInfoEvent(infoEvent: MatrixEvent, client: MatrixClient): VoiceBroadcastRecording { + const infoEventId = infoEvent.getId(); + + if (!this.recordings.has(infoEventId)) { + this.recordings.set(infoEventId, new VoiceBroadcastRecording(infoEvent, client)); + } + + return this.recordings.get(infoEventId); + } + + private static readonly cachedInstance = new VoiceBroadcastRecordingsStore(); + + /** + * TODO Michael W: replace when https://github.com/matrix-org/matrix-react-sdk/pull/9293 has been merged + */ + public static instance(): VoiceBroadcastRecordingsStore { + return VoiceBroadcastRecordingsStore.cachedInstance; + } +} diff --git a/src/voice-broadcast/utils/shouldDisplayAsVoiceBroadcastRecordingTile.ts b/src/voice-broadcast/utils/shouldDisplayAsVoiceBroadcastRecordingTile.ts new file mode 100644 index 00000000000..b9964b6f2a9 --- /dev/null +++ b/src/voice-broadcast/utils/shouldDisplayAsVoiceBroadcastRecordingTile.ts @@ -0,0 +1,30 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { MatrixClient, MatrixEvent } from "matrix-js-sdk/src/matrix"; + +import { VoiceBroadcastInfoState } from ".."; + +export const shouldDisplayAsVoiceBroadcastRecordingTile = ( + state: VoiceBroadcastInfoState, + client: MatrixClient, + event: MatrixEvent, +): boolean => { + const userId = client.getUserId(); + return !!userId + && userId === event.getSender() + && state !== VoiceBroadcastInfoState.Stopped; +}; diff --git a/src/voice-broadcast/utils/shouldDisplayAsVoiceBroadcastTile.ts b/src/voice-broadcast/utils/shouldDisplayAsVoiceBroadcastTile.ts new file mode 100644 index 00000000000..ef55eed3bbf --- /dev/null +++ b/src/voice-broadcast/utils/shouldDisplayAsVoiceBroadcastTile.ts @@ -0,0 +1,27 @@ +/* +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 { MatrixEvent } from "matrix-js-sdk/src/matrix"; + +import { VoiceBroadcastInfoEventType, VoiceBroadcastInfoState } from ".."; + +export const shouldDisplayAsVoiceBroadcastTile = (event: MatrixEvent) => ( + event.getType?.() === VoiceBroadcastInfoEventType + && ( + event.getContent?.()?.state === VoiceBroadcastInfoState.Started + || event.isRedacted() + ) +); diff --git a/src/voice-broadcast/utils/startNewVoiceBroadcastRecording.ts b/src/voice-broadcast/utils/startNewVoiceBroadcastRecording.ts new file mode 100644 index 00000000000..272958e5d0c --- /dev/null +++ b/src/voice-broadcast/utils/startNewVoiceBroadcastRecording.ts @@ -0,0 +1,76 @@ +/* +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 { ISendEventResponse, MatrixClient, RoomStateEvent } from "matrix-js-sdk/src/matrix"; +import { defer } from "matrix-js-sdk/src/utils"; + +import { + VoiceBroadcastInfoEventContent, + VoiceBroadcastInfoEventType, + VoiceBroadcastInfoState, + VoiceBroadcastRecordingsStore, + VoiceBroadcastRecording, +} from ".."; + +/** + * Starts a new Voice Broadcast Recording. + * Sends a voice_broadcast_info state event and waits for the event to actually appear in the room state. + */ +export const startNewVoiceBroadcastRecording = async ( + roomId: string, + client: MatrixClient, + recordingsStore: VoiceBroadcastRecordingsStore, +): Promise => { + const room = client.getRoom(roomId); + const { promise, resolve } = defer(); + let result: ISendEventResponse = null; + + const onRoomStateEvents = () => { + if (!result) return; + + const voiceBroadcastEvent = room.currentState.getStateEvents( + VoiceBroadcastInfoEventType, + client.getUserId(), + ); + + if (voiceBroadcastEvent?.getId() === result.event_id) { + room.off(RoomStateEvent.Events, onRoomStateEvents); + const recording = new VoiceBroadcastRecording( + voiceBroadcastEvent, + client, + ); + recordingsStore.setCurrent(recording); + recording.start(); + resolve(recording); + } + }; + + room.on(RoomStateEvent.Events, onRoomStateEvents); + + // XXX Michael W: refactor to live event + result = await client.sendStateEvent( + roomId, + VoiceBroadcastInfoEventType, + { + device_id: client.getDeviceId(), + state: VoiceBroadcastInfoState.Started, + chunk_length: 300, + } as VoiceBroadcastInfoEventContent, + client.getUserId(), + ); + + return promise; +}; diff --git a/test/ContentMessages-test.ts b/test/ContentMessages-test.ts index 8572fbe8790..1d03cfae892 100644 --- a/test/ContentMessages-test.ts +++ b/test/ContentMessages-test.ts @@ -15,15 +15,31 @@ limitations under the License. */ import { mocked } from "jest-mock"; -import { IImageInfo, ISendEventResponse, MatrixClient } from "matrix-js-sdk/src/matrix"; +import { IImageInfo, ISendEventResponse, MatrixClient, RelationType, UploadResponse } from "matrix-js-sdk/src/matrix"; +import { defer } from "matrix-js-sdk/src/utils"; +import encrypt, { IEncryptedFile } from "matrix-encrypt-attachment"; -import ContentMessages from "../src/ContentMessages"; +import ContentMessages, { UploadCanceledError, uploadFile } from "../src/ContentMessages"; import { doMaybeLocalRoomAction } from "../src/utils/local-room"; +import { createTestClient } from "./test-utils"; +import { BlurhashEncoder } from "../src/BlurhashEncoder"; + +jest.mock("matrix-encrypt-attachment", () => ({ 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/DeviceListener-test.ts b/test/DeviceListener-test.ts index 06405674416..8d0dd48570e 100644 --- a/test/DeviceListener-test.ts +++ b/test/DeviceListener-test.ts @@ -17,7 +17,8 @@ limitations under the License. import { EventEmitter } from "events"; import { mocked } from "jest-mock"; -import { Room } from "matrix-js-sdk/src/matrix"; +import { MatrixEvent, Room } from "matrix-js-sdk/src/matrix"; +import { logger } from "matrix-js-sdk/src/logger"; import DeviceListener from "../src/DeviceListener"; import { MatrixClientPeg } from "../src/MatrixClientPeg"; @@ -27,6 +28,9 @@ import * as BulkUnverifiedSessionsToast from "../src/toasts/BulkUnverifiedSessio import { isSecretStorageBeingAccessed } from "../src/SecurityManager"; import dis from "../src/dispatcher/dispatcher"; import { Action } from "../src/dispatcher/actions"; +import SettingsStore from "../src/settings/SettingsStore"; +import { SettingLevel } from "../src/settings/SettingLevel"; +import { mockPlatformPeg } from "./test-utils"; // don't litter test console with logs jest.mock("matrix-js-sdk/src/logger"); @@ -40,7 +44,10 @@ jest.mock("../src/SecurityManager", () => ({ isSecretStorageBeingAccessed: jest.fn(), accessSecretStorage: jest.fn(), })); +const deviceId = 'my-device-id'; + class MockClient extends EventEmitter { + isGuest = jest.fn(); getUserId = jest.fn(); getKeyBackupVersion = jest.fn().mockResolvedValue(undefined); getRooms = jest.fn().mockReturnValue([]); @@ -57,6 +64,9 @@ class MockClient extends EventEmitter { downloadKeys = jest.fn(); isRoomEncrypted = jest.fn(); getClientWellKnown = jest.fn(); + getDeviceId = jest.fn().mockReturnValue(deviceId); + setAccountData = jest.fn(); + getAccountData = jest.fn(); } const mockDispatcher = mocked(dis); const flushPromises = async () => await new Promise(process.nextTick); @@ -75,8 +85,12 @@ describe('DeviceListener', () => { beforeEach(() => { jest.resetAllMocks(); + mockPlatformPeg({ + getAppVersion: jest.fn().mockResolvedValue('1.2.3'), + }); mockClient = new MockClient(); jest.spyOn(MatrixClientPeg, 'get').mockReturnValue(mockClient); + jest.spyOn(SettingsStore, 'getValue').mockReturnValue(false); }); const createAndStart = async (): Promise => { @@ -86,6 +100,132 @@ describe('DeviceListener', () => { return instance; }; + describe('client information', () => { + it('watches device client information setting', async () => { + const watchSettingSpy = jest.spyOn(SettingsStore, 'watchSetting'); + const unwatchSettingSpy = jest.spyOn(SettingsStore, 'unwatchSetting'); + const deviceListener = await createAndStart(); + + expect(watchSettingSpy).toHaveBeenCalledWith( + 'deviceClientInformationOptIn', null, expect.any(Function), + ); + + deviceListener.stop(); + + expect(unwatchSettingSpy).toHaveBeenCalled(); + }); + + describe('when device client information feature is enabled', () => { + beforeEach(() => { + jest.spyOn(SettingsStore, 'getValue').mockImplementation( + settingName => settingName === 'deviceClientInformationOptIn', + ); + }); + it('saves client information on start', async () => { + await createAndStart(); + + expect(mockClient.setAccountData).toHaveBeenCalledWith( + `io.element.matrix_client_information.${deviceId}`, + { name: 'Element', url: 'localhost', version: '1.2.3' }, + ); + }); + + it('catches error and logs when saving client information fails', async () => { + const errorLogSpy = jest.spyOn(logger, 'error'); + const error = new Error('oups'); + mockClient.setAccountData.mockRejectedValue(error); + + // doesn't throw + await createAndStart(); + + expect(errorLogSpy).toHaveBeenCalledWith( + 'Failed to update client information', + error, + ); + }); + + it('saves client information on logged in action', async () => { + const instance = await createAndStart(); + + mockClient.setAccountData.mockClear(); + + // @ts-ignore calling private function + instance.onAction({ action: Action.OnLoggedIn }); + + await flushPromises(); + + expect(mockClient.setAccountData).toHaveBeenCalledWith( + `io.element.matrix_client_information.${deviceId}`, + { name: 'Element', url: 'localhost', version: '1.2.3' }, + ); + }); + }); + + describe('when device client information feature is disabled', () => { + const clientInfoEvent = new MatrixEvent({ type: `io.element.matrix_client_information.${deviceId}`, + content: { name: 'hello' }, + }); + const emptyClientInfoEvent = new MatrixEvent({ type: `io.element.matrix_client_information.${deviceId}` }); + beforeEach(() => { + jest.spyOn(SettingsStore, 'getValue').mockReturnValue(false); + + mockClient.getAccountData.mockReturnValue(undefined); + }); + + it('does not save client information on start', async () => { + await createAndStart(); + + expect(mockClient.setAccountData).not.toHaveBeenCalled(); + }); + + it('removes client information on start if it exists', async () => { + mockClient.getAccountData.mockReturnValue(clientInfoEvent); + await createAndStart(); + + expect(mockClient.setAccountData).toHaveBeenCalledWith( + `io.element.matrix_client_information.${deviceId}`, + {}, + ); + }); + + it('does not try to remove client info event that are already empty', async () => { + mockClient.getAccountData.mockReturnValue(emptyClientInfoEvent); + await createAndStart(); + + expect(mockClient.setAccountData).not.toHaveBeenCalled(); + }); + + it('does not save client information on logged in action', async () => { + const instance = await createAndStart(); + + // @ts-ignore calling private function + instance.onAction({ action: Action.OnLoggedIn }); + + await flushPromises(); + + expect(mockClient.setAccountData).not.toHaveBeenCalled(); + }); + + it('saves client information after setting is enabled', async () => { + const watchSettingSpy = jest.spyOn(SettingsStore, 'watchSetting'); + await createAndStart(); + + const [settingName, roomId, callback] = watchSettingSpy.mock.calls[0]; + expect(settingName).toEqual('deviceClientInformationOptIn'); + expect(roomId).toBeNull(); + + callback('deviceClientInformationOptIn', null, SettingLevel.DEVICE, SettingLevel.DEVICE, true); + + await flushPromises(); + + expect(mockClient.setAccountData).toHaveBeenCalledWith( + `io.element.matrix_client_information.${deviceId}`, + { name: 'Element', url: 'localhost', version: '1.2.3' }, + ); + }); + }); + }); + describe('recheck', () => { it('does nothing when cross signing feature is not supported', async () => { mockClient.doesServerSupportUnstableFeature.mockResolvedValue(false); diff --git a/test/LegacyCallHandler-test.ts b/test/LegacyCallHandler-test.ts index 8743c4cdf6d..2fd774ae509 100644 --- a/test/LegacyCallHandler-test.ts +++ b/test/LegacyCallHandler-test.ts @@ -14,10 +14,18 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { IProtocol } from 'matrix-js-sdk/src/matrix'; -import { CallEvent, CallState, CallType } from 'matrix-js-sdk/src/webrtc/call'; +import { + IProtocol, + LOCAL_NOTIFICATION_SETTINGS_PREFIX, + MatrixEvent, + PushRuleKind, + RuleId, + TweakName, +} from 'matrix-js-sdk/src/matrix'; +import { CallEvent, CallState, CallType, MatrixCall } from 'matrix-js-sdk/src/webrtc/call'; import EventEmitter from 'events'; import { mocked } from 'jest-mock'; +import { CallEventHandlerEvent } from 'matrix-js-sdk/src/webrtc/callEventHandler'; import LegacyCallHandler, { LegacyCallHandlerEvent, PROTOCOL_PSTN, PROTOCOL_PSTN_PREFIXED, PROTOCOL_SIP_NATIVE, PROTOCOL_SIP_VIRTUAL, @@ -28,6 +36,8 @@ import DMRoomMap from '../src/utils/DMRoomMap'; import SdkConfig from '../src/SdkConfig'; import { Action } from "../src/dispatcher/actions"; import { getFunctionalMembers } from "../src/utils/room/getFunctionalMembers"; +import SettingsStore from '../src/settings/SettingsStore'; +import { UIFeature } from '../src/settings/UIFeature'; jest.mock("../src/utils/room/getFunctionalMembers", () => ({ getFunctionalMembers: jest.fn(), @@ -126,6 +136,7 @@ describe('LegacyCallHandler', () => { // what addresses the app has looked up via pstn and native lookup let pstnLookup: string; let nativeLookup: string; + const deviceId = 'my-device'; beforeEach(async () => { stubClient(); @@ -136,6 +147,7 @@ describe('LegacyCallHandler', () => { fakeCall = new FakeCall(roomId); return fakeCall; }; + MatrixClientPeg.get().deviceId = deviceId; MatrixClientPeg.get().getThirdpartyProtocols = () => { return Promise.resolve({ @@ -426,4 +438,137 @@ describe('LegacyCallHandler without third party protocols', () => { // but it should appear to the user to be in thw native room for Bob expect(callHandler.roomIdForCall(fakeCall)).toEqual(NATIVE_ROOM_ALICE); }); + + describe('incoming calls', () => { + const roomId = 'test-room-id'; + + const mockAudioElement = { + play: jest.fn(), + pause: jest.fn(), + } as unknown as HTMLMediaElement; + beforeEach(() => { + jest.clearAllMocks(); + jest.spyOn(SettingsStore, 'getValue').mockImplementation(setting => + setting === UIFeature.Voip); + + jest.spyOn(MatrixClientPeg.get(), 'supportsVoip').mockReturnValue(true); + + MatrixClientPeg.get().isFallbackICEServerAllowed = jest.fn(); + MatrixClientPeg.get().prepareToEncrypt = jest.fn(); + + MatrixClientPeg.get().pushRules = { + global: { + [PushRuleKind.Override]: [{ + rule_id: RuleId.IncomingCall, + default: false, + enabled: true, + actions: [ + { + set_tweak: TweakName.Sound, + value: 'ring', + }, + ] + , + }], + }, + }; + + jest.spyOn(document, 'getElementById').mockReturnValue(mockAudioElement); + + // silence local notifications by default + jest.spyOn(MatrixClientPeg.get(), 'getAccountData').mockImplementation((eventType) => { + if (eventType.includes(LOCAL_NOTIFICATION_SETTINGS_PREFIX.name)) { + return new MatrixEvent({ + type: eventType, + content: { + is_silenced: true, + }, + }); + } + }); + }); + + it('listens for incoming call events when voip is enabled', () => { + const call = new MatrixCall({ + client: MatrixClientPeg.get(), + roomId, + }); + const cli = MatrixClientPeg.get(); + + cli.emit(CallEventHandlerEvent.Incoming, call); + + // call added to call map + expect(callHandler.getCallForRoom(roomId)).toEqual(call); + }); + + it('rings when incoming call state is ringing and notifications set to ring', () => { + // remove local notification silencing mock for this test + jest.spyOn(MatrixClientPeg.get(), 'getAccountData').mockReturnValue(undefined); + const call = new MatrixCall({ + client: MatrixClientPeg.get(), + roomId, + }); + const cli = MatrixClientPeg.get(); + + cli.emit(CallEventHandlerEvent.Incoming, call); + + // call added to call map + expect(callHandler.getCallForRoom(roomId)).toEqual(call); + call.emit(CallEvent.State, CallState.Ringing, CallState.Connected); + + // ringer audio element started + expect(mockAudioElement.play).toHaveBeenCalled(); + }); + + it('does not ring when incoming call state is ringing but local notifications are silenced', () => { + const call = new MatrixCall({ + client: MatrixClientPeg.get(), + roomId, + }); + const cli = MatrixClientPeg.get(); + + cli.emit(CallEventHandlerEvent.Incoming, call); + + // call added to call map + expect(callHandler.getCallForRoom(roomId)).toEqual(call); + call.emit(CallEvent.State, CallState.Ringing, CallState.Connected); + + // ringer audio element started + expect(mockAudioElement.play).not.toHaveBeenCalled(); + expect(callHandler.isCallSilenced(call.callId)).toEqual(true); + }); + + it('should force calls to silent when local notifications are silenced', async () => { + const call = new MatrixCall({ + client: MatrixClientPeg.get(), + roomId, + }); + const cli = MatrixClientPeg.get(); + + cli.emit(CallEventHandlerEvent.Incoming, call); + + expect(callHandler.isForcedSilent()).toEqual(true); + expect(callHandler.isCallSilenced(call.callId)).toEqual(true); + }); + + it('does not unsilence calls when local notifications are silenced', async () => { + const call = new MatrixCall({ + client: MatrixClientPeg.get(), + roomId, + }); + const cli = MatrixClientPeg.get(); + const callHandlerEmitSpy = jest.spyOn(callHandler, 'emit'); + + cli.emit(CallEventHandlerEvent.Incoming, call); + // reset emit call count + callHandlerEmitSpy.mockClear(); + + callHandler.unSilenceCall(call.callId); + expect(callHandlerEmitSpy).not.toHaveBeenCalled(); + // call still silenced + expect(callHandler.isCallSilenced(call.callId)).toEqual(true); + // ringer not played + expect(mockAudioElement.play).not.toHaveBeenCalled(); + }); + }); }); diff --git a/test/Notifier-test.ts b/test/Notifier-test.ts new file mode 100644 index 00000000000..224c1fec776 --- /dev/null +++ b/test/Notifier-test.ts @@ -0,0 +1,348 @@ +/* +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 { mocked, MockedObject } from "jest-mock"; +import { ClientEvent, MatrixClient } from "matrix-js-sdk/src/client"; +import { Room } from "matrix-js-sdk/src/models/room"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { SyncState } from "matrix-js-sdk/src/sync"; + +import BasePlatform from "../src/BasePlatform"; +import { ElementCall } from "../src/models/Call"; +import Notifier from "../src/Notifier"; +import SettingsStore from "../src/settings/SettingsStore"; +import ToastStore from "../src/stores/ToastStore"; +import { + createLocalNotificationSettingsIfNeeded, + getLocalNotificationAccountDataEventType, +} from "../src/utils/notifications"; +import { getMockClientWithEventEmitter, mkEvent, mkRoom, mockClientMethodsUser, mockPlatformPeg } from "./test-utils"; +import { IncomingCallToast } from "../src/toasts/IncomingCallToast"; + +jest.mock("../src/utils/notifications", () => ({ + // @ts-ignore + ...jest.requireActual("../src/utils/notifications"), + createLocalNotificationSettingsIfNeeded: jest.fn(), +})); + +describe("Notifier", () => { + const roomId = "!room1:server"; + const testEvent = mkEvent({ + event: true, + type: "m.room.message", + user: "@user1:server", + room: roomId, + content: {}, + }); + + let MockPlatform: MockedObject; + let mockClient: MockedObject; + let testRoom: MockedObject; + let accountDataEventKey: string; + let accountDataStore = {}; + + const userId = "@bob:example.org"; + + beforeEach(() => { + accountDataStore = {}; + mockClient = getMockClientWithEventEmitter({ + ...mockClientMethodsUser(userId), + isGuest: jest.fn().mockReturnValue(false), + getAccountData: jest.fn().mockImplementation(eventType => accountDataStore[eventType]), + setAccountData: jest.fn().mockImplementation((eventType, content) => { + 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); + + MockPlatform = mockPlatformPeg({ + supportsNotifications: jest.fn().mockReturnValue(true), + maySendNotifications: jest.fn().mockReturnValue(true), + displayNotification: jest.fn(), + loudNotification: jest.fn(), + }); + + Notifier.isBodyEnabled = jest.fn().mockReturnValue(true); + + 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([ + { 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); + }); + }); + + describe("_playAudioNotification", () => { + it.each([ + { 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, event); + Notifier._playAudioNotification(testEvent, testRoom); + expect(Notifier.getSoundForRoom).toHaveBeenCalledTimes(count); + }); + }); + + describe("group call notifications", () => { + beforeEach(() => { + jest.spyOn(SettingsStore, "getValue").mockReturnValue(true); + jest.spyOn(ToastStore.sharedInstance(), "addOrReplaceToast"); + + mockClient.getPushActionsForEvent.mockReturnValue({ + notify: true, + tweaks: {}, + }); + + Notifier.onSyncStateChange(SyncState.Syncing); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + const callOnEvent = (type?: string) => { + const callEvent = { + getContent: () => { }, + getRoomId: () => roomId, + isBeingDecrypted: () => false, + isDecryptionFailure: () => false, + getSender: () => "@alice:foo", + getType: () => type ?? ElementCall.CALL_EVENT_TYPE.name, + getStateKey: () => "state_key", + } as unknown as MatrixEvent; + + Notifier.onEvent(callEvent); + return callEvent; + }; + + const setGroupCallsEnabled = (val: boolean) => { + jest.spyOn(SettingsStore, "getValue").mockImplementation((name: string) => { + if (name === "feature_group_calls") return val; + }); + }; + + it("should show toast when group calls are supported", () => { + setGroupCallsEnabled(true); + + const callEvent = callOnEvent(); + + expect(ToastStore.sharedInstance().addOrReplaceToast).toHaveBeenCalledWith(expect.objectContaining({ + key: `call_${callEvent.getStateKey()}`, + priority: 100, + component: IncomingCallToast, + bodyClassName: "mx_IncomingCallToast", + props: { callEvent }, + })); + }); + + it("should not show toast when group calls are not supported", () => { + setGroupCallsEnabled(false); + + callOnEvent(); + + expect(ToastStore.sharedInstance().addOrReplaceToast).not.toHaveBeenCalled(); + }); + + it("should not show toast when calling with non-group call event", () => { + setGroupCallsEnabled(true); + + callOnEvent("event_type"); + + expect(ToastStore.sharedInstance().addOrReplaceToast).not.toHaveBeenCalled(); + }); + }); + + describe('local notification settings', () => { + const createLocalNotificationSettingsIfNeededMock = mocked(createLocalNotificationSettingsIfNeeded); + let hasStartedNotiferBefore = false; + 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; + createLocalNotificationSettingsIfNeededMock.mockClear(); + }); + + afterAll(() => { + Notifier.stop(); + }); + + it('does not create local notifications event after a sync error', () => { + mockClient.emit(ClientEvent.Sync, SyncState.Error, SyncState.Syncing); + expect(createLocalNotificationSettingsIfNeededMock).not.toHaveBeenCalled(); + }); + + it('does not create local notifications event after sync stops', () => { + mockClient.emit(ClientEvent.Sync, SyncState.Stopped, SyncState.Syncing); + expect(createLocalNotificationSettingsIfNeededMock).not.toHaveBeenCalled(); + }); + + it('does not create local notifications event after a cached sync', () => { + mockClient.emit(ClientEvent.Sync, SyncState.Syncing, SyncState.Syncing, { + fromCache: true, + }); + expect(createLocalNotificationSettingsIfNeededMock).not.toHaveBeenCalled(); + }); + + it('creates local notifications event after a non-cached sync', () => { + mockClient.emit(ClientEvent.Sync, SyncState.Syncing, SyncState.Syncing, {}); + expect(createLocalNotificationSettingsIfNeededMock).toHaveBeenCalled(); + }); + }); +}); diff --git a/test/ScalarAuthClient-test.ts b/test/ScalarAuthClient-test.ts index 3b6fcf77b2b..02edc2bd98c 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 00000000000..a497946b8fb --- /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/TextForEvent-test.ts b/test/TextForEvent-test.ts index c886c22b9ef..27f3090d3e4 100644 --- a/test/TextForEvent-test.ts +++ b/test/TextForEvent-test.ts @@ -14,15 +14,17 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { EventType, MatrixEvent } from "matrix-js-sdk/src/matrix"; +import { EventType, MatrixClient, MatrixEvent, Room, RoomMember } from "matrix-js-sdk/src/matrix"; import TestRenderer from 'react-test-renderer'; import { ReactElement } from "react"; +import { mocked } from "jest-mock"; import { getSenderName, textForEvent } from "../src/TextForEvent"; import SettingsStore from "../src/settings/SettingsStore"; -import { createTestClient } from './test-utils'; +import { createTestClient, stubClient } from './test-utils'; import { MatrixClientPeg } from '../src/MatrixClientPeg'; import UserIdentifierCustomisations from '../src/customisations/UserIdentifier'; +import { ElementCall } from "../src/models/Call"; jest.mock("../src/settings/SettingsStore"); jest.mock('../src/customisations/UserIdentifier', () => ({ @@ -174,14 +176,17 @@ describe('TextForEvent', () => { const userA = { id: '@a', name: 'Alice', + rawDisplayName: 'Alice', }; const userB = { id: '@b', - name: 'Bob', + name: 'Bob (@b)', + rawDisplayName: 'Bob', }; const userC = { id: '@c', - name: 'Carl', + name: 'Bob (@c)', + rawDisplayName: 'Bob', }; interface PowerEventProps { usersDefault?: number; @@ -191,19 +196,23 @@ describe('TextForEvent', () => { } const mockPowerEvent = ({ usersDefault, prevDefault, users, prevUsers, - }: PowerEventProps): MatrixEvent => new MatrixEvent({ - type: EventType.RoomPowerLevels, - sender: userA.id, - state_key: "", - content: { - users_default: usersDefault, - users, - }, - prev_content: { - users: prevUsers, - users_default: prevDefault, - }, - }); + }: PowerEventProps): MatrixEvent => { + const mxEvent = new MatrixEvent({ + type: EventType.RoomPowerLevels, + sender: userA.id, + state_key: "", + content: { + users_default: usersDefault, + users, + }, + prev_content: { + users: prevUsers, + users_default: prevDefault, + }, + }); + mxEvent.sender = { name: userA.name } as RoomMember; + return mxEvent; + }; beforeAll(() => { mockClient = createTestClient(); @@ -256,7 +265,7 @@ describe('TextForEvent', () => { [userB.id]: 50, }, }); - const expectedText = "@a changed the power level of @b from Moderator to Admin."; + const expectedText = "Alice changed the power level of Bob (@b) from Moderator to Admin."; expect(textForEvent(event)).toEqual(expectedText); }); @@ -271,7 +280,7 @@ describe('TextForEvent', () => { [userB.id]: 50, }, }); - const expectedText = "@a changed the power level of @b from Moderator to Default."; + const expectedText = "Alice changed the power level of Bob (@b) from Moderator to Default."; expect(textForEvent(event)).toEqual(expectedText); }); @@ -284,7 +293,7 @@ describe('TextForEvent', () => { [userB.id]: 50, }, }); - const expectedText = "@a changed the power level of @b from Moderator to Custom (-1)."; + const expectedText = "Alice changed the power level of Bob (@b) from Moderator to Custom (-1)."; expect(textForEvent(event)).toEqual(expectedText); }); @@ -299,28 +308,10 @@ describe('TextForEvent', () => { [userC.id]: 101, }, }); - const expectedText = - "@a changed the power level of @b from Moderator to Admin, @c from Custom (101) to Moderator."; + const expectedText = "Alice changed the power level of Bob (@b) from Moderator to Admin," + + " Bob (@c) from Custom (101) to Moderator."; expect(textForEvent(event)).toEqual(expectedText); }); - - it("uses userIdentifier customisation", () => { - (UserIdentifierCustomisations.getDisplayUserIdentifier as jest.Mock) - .mockImplementation(userId => 'customised ' + userId); - const event = mockPowerEvent({ - users: { - [userB.id]: 100, - }, - prevUsers: { - [userB.id]: 50, - }, - }); - // uses customised user id - const expectedText = "@a changed the power level of customised @b from Moderator to Admin."; - expect(textForEvent(event)).toEqual(expectedText); - expect(UserIdentifierCustomisations.getDisplayUserIdentifier) - .toHaveBeenCalledWith(userB.id, { roomId: event.getRoomId() }); - }); }); describe("textForCanonicalAliasEvent()", () => { @@ -455,4 +446,42 @@ describe('TextForEvent', () => { expect(textForEvent(messageEvent)).toEqual('@a: test message'); }); }); + + describe("textForCallEvent()", () => { + let mockClient: MatrixClient; + let callEvent: MatrixEvent; + + beforeEach(() => { + stubClient(); + mockClient = MatrixClientPeg.get(); + + mocked(mockClient.getRoom).mockReturnValue({ + name: "Test room", + } as unknown as Room); + + callEvent = { + getRoomId: jest.fn(), + getType: jest.fn(), + isState: jest.fn().mockReturnValue(true), + } as unknown as MatrixEvent; + }); + + describe.each(ElementCall.CALL_EVENT_TYPE.names)("eventType=%s", (eventType: string) => { + beforeEach(() => { + mocked(callEvent).getType.mockReturnValue(eventType); + }); + + it("returns correct message for call event when supported", () => { + expect(textForEvent(callEvent)).toEqual('Video call started in Test room.'); + }); + + it("returns correct message for call event when supported", () => { + mocked(mockClient).supportsVoip.mockReturnValue(false); + + expect(textForEvent(callEvent)).toEqual( + 'Video call started in Test room. (not supported by this browser)', + ); + }); + }); + }); }); diff --git a/test/Unread-test.ts b/test/Unread-test.ts index 1c1c469cba1..e96c1349312 100644 --- a/test/Unread-test.ts +++ b/test/Unread-test.ts @@ -103,7 +103,6 @@ describe('eventTriggersUnreadCount()', () => { EventType.RoomThirdPartyInvite, EventType.CallAnswer, EventType.CallHangup, - EventType.RoomAliases, EventType.RoomCanonicalAlias, EventType.RoomServerAcl, ]; diff --git a/test/actions/handlers/viewUserDeviceSettings-test.ts b/test/actions/handlers/viewUserDeviceSettings-test.ts new file mode 100644 index 00000000000..72d1db430db --- /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 new file mode 100644 index 00000000000..a49a480306f --- /dev/null +++ b/test/audio/VoiceMessageRecording-test.ts @@ -0,0 +1,221 @@ +/* +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 { mocked } from "jest-mock"; +import { IEncryptedFile, UploadOpts, MatrixClient } from "matrix-js-sdk/src/matrix"; + +import { createVoiceMessageRecording, VoiceMessageRecording } from "../../src/audio/VoiceMessageRecording"; +import { RecordingState, VoiceRecording } from "../../src/audio/VoiceRecording"; +import { uploadFile } from "../../src/ContentMessages"; +import { stubClient } from "../test-utils"; +import { Playback } from "../../src/audio/Playback"; + +jest.mock("../../src/ContentMessages", () => ({ + uploadFile: jest.fn(), +})); + +jest.mock("../../src/audio/Playback", () => ({ + Playback: jest.fn(), +})); + +describe("VoiceMessageRecording", () => { + const roomId = "!room:example.com"; + const contentType = "test content type"; + const durationSeconds = 23; + const testBuf = new Uint8Array([1, 2, 3]); + const testAmplitudes = [4, 5, 6]; + + let voiceRecording: VoiceRecording; + let voiceMessageRecording: VoiceMessageRecording; + let client: MatrixClient; + + beforeEach(() => { + client = stubClient(); + voiceRecording = { + contentType, + durationSeconds, + start: jest.fn().mockResolvedValue(undefined), + stop: jest.fn().mockResolvedValue(undefined), + on: jest.fn(), + off: jest.fn(), + emit: jest.fn(), + isRecording: true, + isSupported: true, + liveData: jest.fn(), + amplitudes: testAmplitudes, + } as unknown as VoiceRecording; + voiceMessageRecording = new VoiceMessageRecording( + client, + voiceRecording, + ); + }); + + it("hasRecording should return false", () => { + expect(voiceMessageRecording.hasRecording).toBe(false); + }); + + it("createVoiceMessageRecording should return a VoiceMessageRecording", () => { + expect(createVoiceMessageRecording(client)).toBeInstanceOf(VoiceMessageRecording); + }); + + it("durationSeconds should return the VoiceRecording value", () => { + expect(voiceMessageRecording.durationSeconds).toBe(durationSeconds); + }); + + it("contentType should return the VoiceRecording value", () => { + expect(voiceMessageRecording.contentType).toBe(contentType); + }); + + it.each([true, false])("isRecording should return %s from VoiceRecording", (value: boolean) => { + // @ts-ignore + voiceRecording.isRecording = value; + expect(voiceMessageRecording.isRecording).toBe(value); + }); + + it.each([true, false])("isSupported should return %s from VoiceRecording", (value: boolean) => { + // @ts-ignore + voiceRecording.isSupported = value; + expect(voiceMessageRecording.isSupported).toBe(value); + }); + + it("should return liveData from VoiceRecording", () => { + expect(voiceMessageRecording.liveData).toBe(voiceRecording.liveData); + }); + + it("start should forward the call to VoiceRecording.start", async () => { + await voiceMessageRecording.start(); + expect(voiceRecording.start).toHaveBeenCalled(); + }); + + it("on should forward the call to VoiceRecording", () => { + const callback = () => {}; + const result = voiceMessageRecording.on("test on", callback); + expect(voiceRecording.on).toHaveBeenCalledWith("test on", callback); + expect(result).toBe(voiceMessageRecording); + }); + + it("off should forward the call to VoiceRecording", () => { + const callback = () => {}; + const result = voiceMessageRecording.off("test off", callback); + expect(voiceRecording.off).toHaveBeenCalledWith("test off", callback); + expect(result).toBe(voiceMessageRecording); + }); + + it("emit should forward the call to VoiceRecording", () => { + voiceMessageRecording.emit("test emit", 42); + expect(voiceRecording.emit).toHaveBeenCalledWith("test emit", 42); + }); + + it("upload should raise an error", async () => { + await expect(voiceMessageRecording.upload(roomId)) + .rejects + .toThrow("No recording available to upload"); + }); + + describe("when the first data has been received", () => { + const uploadUrl = "https://example.com/content123"; + const encryptedFile = {} as unknown as IEncryptedFile; + + beforeEach(() => { + voiceRecording.onDataAvailable(testBuf); + }); + + it("contentLength should return the buffer length", () => { + expect(voiceMessageRecording.contentLength).toBe(testBuf.length); + }); + + it("stop should return a copy of the data buffer", async () => { + const result = await voiceMessageRecording.stop(); + expect(voiceRecording.stop).toHaveBeenCalled(); + expect(result).toEqual(testBuf); + }); + + it("hasRecording should return true", () => { + expect(voiceMessageRecording.hasRecording).toBe(true); + }); + + describe("upload", () => { + let uploadFileClient: MatrixClient; + let uploadFileRoomId: string; + let uploadBlob: Blob; + + beforeEach(() => { + uploadFileClient = null; + uploadFileRoomId = null; + uploadBlob = null; + + mocked(uploadFile).mockImplementation(( + matrixClient: MatrixClient, + roomId: string, + file: File | Blob, + _progressHandler?: UploadOpts["progressHandler"], + ): Promise<{ url?: string, file?: IEncryptedFile }> => { + uploadFileClient = matrixClient; + uploadFileRoomId = roomId; + uploadBlob = file; + // @ts-ignore + return Promise.resolve({ + url: uploadUrl, + file: encryptedFile, + }); + }); + }); + + it("should upload the file and trigger the upload events", async () => { + const result = await voiceMessageRecording.upload(roomId); + expect(voiceRecording.emit).toHaveBeenNthCalledWith(1, RecordingState.Uploading); + expect(voiceRecording.emit).toHaveBeenNthCalledWith(2, RecordingState.Uploaded); + + expect(result.mxc).toBe(uploadUrl); + expect(result.encrypted).toBe(encryptedFile); + + expect(mocked(uploadFile)).toHaveBeenCalled(); + expect(uploadFileClient).toBe(client); + expect(uploadFileRoomId).toBe(roomId); + expect(uploadBlob.type).toBe(contentType); + const blobArray = await uploadBlob.arrayBuffer(); + expect(new Uint8Array(blobArray)).toEqual(testBuf); + }); + + it("should reuse the result", async () => { + const result1 = await voiceMessageRecording.upload(roomId); + const result2 = await voiceMessageRecording.upload(roomId); + expect(result1).toBe(result2); + }); + }); + + describe("getPlayback", () => { + beforeEach(() => { + mocked(Playback).mockImplementation((buf: ArrayBuffer, seedWaveform) => { + expect(new Uint8Array(buf)).toEqual(testBuf); + expect(seedWaveform).toEqual(testAmplitudes); + return {} as Playback; + }); + }); + + it("should return a Playback with the data", () => { + voiceMessageRecording.getPlayback(); + expect(mocked(Playback)).toHaveBeenCalled(); + }); + + it("should reuse the result", () => { + const playback1 = voiceMessageRecording.getPlayback(); + const playback2 = voiceMessageRecording.getPlayback(); + expect(playback1).toBe(playback2); + }); + }); + }); +}); diff --git a/test/components/atoms/Icon-test.tsx b/test/components/atoms/Icon-test.tsx new file mode 100644 index 00000000000..57e6e3990c4 --- /dev/null +++ b/test/components/atoms/Icon-test.tsx @@ -0,0 +1,47 @@ +/* +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 { render } from "@testing-library/react"; + +import { Icon, IconColour, IconSize, IconType } from "../../../src/components/atoms/Icon"; + +describe("Icon", () => { + it.each([ + IconColour.Accent, + IconColour.LiveBadge, + ])("should render the colour %s", (colour: IconColour) => { + const { container } = render( + , + ); + expect(container).toMatchSnapshot(); + }); + + it.each([ + IconSize.S16, + ])("should render the size %s", (size: IconSize) => { + const { container } = render( + , + ); + expect(container).toMatchSnapshot(); + }); +}); diff --git a/test/components/atoms/__snapshots__/Icon-test.tsx.snap b/test/components/atoms/__snapshots__/Icon-test.tsx.snap new file mode 100644 index 00000000000..c30b4ba3323 --- /dev/null +++ b/test/components/atoms/__snapshots__/Icon-test.tsx.snap @@ -0,0 +1,34 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Icon should render the colour accent 1`] = ` +
    +