diff --git a/.node-version b/.node-version index 8351c19397f..b6a7d89c68e 100644 --- a/.node-version +++ b/.node-version @@ -1 +1 @@ -14 +16 diff --git a/CHANGELOG.md b/CHANGELOG.md index e5334d09b0e..56161174369 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,40 @@ +Changes in [3.60.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.60.0) (2022-11-08) +===================================================================================================== + +## ✨ Features + * Loading threads with server-side assistance ([\#9356](https://github.com/matrix-org/matrix-react-sdk/pull/9356)). Fixes vector-im/element-web#21807, vector-im/element-web#21799, vector-im/element-web#21911, vector-im/element-web#22141, vector-im/element-web#22157, vector-im/element-web#22641, vector-im/element-web#22501 vector-im/element-web#22438 and vector-im/element-web#21678. Contributed by @justjanne. + * Make thread replies trigger a room list re-ordering ([\#9510](https://github.com/matrix-org/matrix-react-sdk/pull/9510)). Fixes vector-im/element-web#21700. + * Device manager - add extra details to device security and renaming ([\#9501](https://github.com/matrix-org/matrix-react-sdk/pull/9501)). Contributed by @kerryarchibald. + * Add plain text mode to the wysiwyg composer ([\#9503](https://github.com/matrix-org/matrix-react-sdk/pull/9503)). Contributed by @florianduros. + * Sliding Sync: improve sort order, show subspace rooms, better tombstoned room handling ([\#9484](https://github.com/matrix-org/matrix-react-sdk/pull/9484)). + * Device manager - add learn more popups to filtered sessions section ([\#9497](https://github.com/matrix-org/matrix-react-sdk/pull/9497)). Contributed by @kerryarchibald. + * Show thread notification if thread timeline is closed ([\#9495](https://github.com/matrix-org/matrix-react-sdk/pull/9495)). Fixes vector-im/element-web#23589. + * Add message editing to wysiwyg composer ([\#9488](https://github.com/matrix-org/matrix-react-sdk/pull/9488)). Contributed by @florianduros. + * Device manager - confirm sign out of other sessions ([\#9487](https://github.com/matrix-org/matrix-react-sdk/pull/9487)). Contributed by @kerryarchibald. + * Automatically request logs from other users in a call when submitting logs ([\#9492](https://github.com/matrix-org/matrix-react-sdk/pull/9492)). + * Add thread notification with server assistance (MSC3773) ([\#9400](https://github.com/matrix-org/matrix-react-sdk/pull/9400)). Fixes vector-im/element-web#21114, vector-im/element-web#21413, vector-im/element-web#21416, vector-im/element-web#21433, vector-im/element-web#21481, vector-im/element-web#21798, vector-im/element-web#21823 vector-im/element-web#23192 and vector-im/element-web#21765. + * Support for login + E2EE set up with QR ([\#9403](https://github.com/matrix-org/matrix-react-sdk/pull/9403)). Contributed by @hughns. + * Allow pressing Enter to send messages in new composer ([\#9451](https://github.com/matrix-org/matrix-react-sdk/pull/9451)). Contributed by @andybalaam. + +## 🐛 Bug Fixes + * Fix regressions around media uploads failing and causing soft crashes ([\#9549](https://github.com/matrix-org/matrix-react-sdk/pull/9549)). Fixes matrix-org/element-web-rageshakes#16831, matrix-org/element-web-rageshakes#16824 matrix-org/element-web-rageshakes#16810 and vector-im/element-web#23641. + * Fix /myroomavatar slash command ([\#9536](https://github.com/matrix-org/matrix-react-sdk/pull/9536)). Fixes matrix-org/synapse#14321. + * Fix NotificationBadge unsent color ([\#9522](https://github.com/matrix-org/matrix-react-sdk/pull/9522)). Fixes vector-im/element-web#23646. + * Fix room list sorted by recent on app startup ([\#9515](https://github.com/matrix-org/matrix-react-sdk/pull/9515)). Fixes vector-im/element-web#23635. + * Reset custom power selector when blurred on empty ([\#9508](https://github.com/matrix-org/matrix-react-sdk/pull/9508)). Fixes vector-im/element-web#23481. + * Reinstate timeline/redaction callbacks when updating notification state ([\#9494](https://github.com/matrix-org/matrix-react-sdk/pull/9494)). Fixes vector-im/element-web#23554. + * Only render NotificationBadge when needed ([\#9493](https://github.com/matrix-org/matrix-react-sdk/pull/9493)). Fixes vector-im/element-web#23584. + * Fix embedded Element Call screen sharing ([\#9485](https://github.com/matrix-org/matrix-react-sdk/pull/9485)). Fixes vector-im/element-web#23571. + * 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. + * Fix joining calls without audio or video inputs ([\#9486](https://github.com/matrix-org/matrix-react-sdk/pull/9486)). Fixes vector-im/element-web#23511. + * Ensure spaces in the spotlight dialog have rounded square avatars ([\#9480](https://github.com/matrix-org/matrix-react-sdk/pull/9480)). Fixes vector-im/element-web#23515. + * Only show mini avatar uploader in room intro when no avatar yet exists ([\#9479](https://github.com/matrix-org/matrix-react-sdk/pull/9479)). Fixes vector-im/element-web#23552. + * Fix threads fallback incorrectly targets root event ([\#9229](https://github.com/matrix-org/matrix-react-sdk/pull/9229)). Fixes vector-im/element-web#23147. + * Align video call icon with banner text ([\#9460](https://github.com/matrix-org/matrix-react-sdk/pull/9460)). + * Set relations helper when creating event tile context menu ([\#9253](https://github.com/matrix-org/matrix-react-sdk/pull/9253)). Fixes vector-im/element-web#22018. + * Device manager - put client/browser device metadata in correct section ([\#9447](https://github.com/matrix-org/matrix-react-sdk/pull/9447)). Contributed by @kerryarchibald. + * 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)). + Changes in [3.59.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.59.1) (2022-11-01) ===================================================================================================== diff --git a/cypress.config.ts b/cypress.config.ts index bc64b7d726a..87f8952dbcc 100644 --- a/cypress.config.ts +++ b/cypress.config.ts @@ -30,6 +30,10 @@ export default defineConfig({ experimentalSessionAndOrigin: true, specPattern: 'cypress/e2e/**/*.{js,jsx,ts,tsx}', }, + env: { + // Docker tag to use for `ghcr.io/matrix-org/sliding-sync-proxy` image. + SLIDING_SYNC_PROXY_TAG: "v0.6.0", + }, retries: { runMode: 4, openMode: 0, diff --git a/cypress/e2e/composer/composer.spec.ts b/cypress/e2e/composer/composer.spec.ts new file mode 100644 index 00000000000..6fe562e12a7 --- /dev/null +++ b/cypress/e2e/composer/composer.spec.ts @@ -0,0 +1,155 @@ +/* +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 { SettingLevel } from "../../../src/settings/SettingLevel"; + +describe("Composer", () => { + let synapse: SynapseInstance; + + beforeEach(() => { + cy.startSynapse("default").then(data => { + synapse = data; + }); + }); + + afterEach(() => { + cy.stopSynapse(synapse); + }); + + describe("CIDER", () => { + beforeEach(() => { + cy.initTestUser(synapse, "Janet").then(() => { + cy.createRoom({ name: "Composing Room" }); + }); + cy.viewRoomByName("Composing Room"); + }); + + it("sends a message when you click send or press Enter", () => { + // Type a message + cy.get('div[contenteditable=true]').type('my message 0'); + // It has not been sent yet + cy.contains('.mx_EventTile_body', 'my message 0').should('not.exist'); + + // Click send + cy.get('div[aria-label="Send message"]').click(); + // It has been sent + cy.contains('.mx_EventTile_body', 'my message 0'); + + // Type another and press Enter afterwards + cy.get('div[contenteditable=true]').type('my message 1{enter}'); + // It was sent + cy.contains('.mx_EventTile_body', 'my message 1'); + }); + + it("can write formatted text", () => { + cy.get('div[contenteditable=true]').type('my bold{ctrl+b} message'); + cy.get('div[aria-label="Send message"]').click(); + // Note: both "bold" and "message" are bold, which is probably surprising + cy.contains('.mx_EventTile_body strong', 'bold message'); + }); + + it("should allow user to input emoji via graphical picker", () => { + cy.getComposer(false).within(() => { + cy.get('[aria-label="Emoji"]').click(); + }); + + cy.get('[data-testid="mx_EmojiPicker"]').within(() => { + cy.contains(".mx_EmojiPicker_item", "😇").click(); + }); + + cy.get(".mx_ContextualMenu_background").click(); // Close emoji picker + cy.get('div[contenteditable=true]').type("{enter}"); // Send message + + cy.contains(".mx_EventTile_body", "😇"); + }); + + describe("when Ctrl+Enter is required to send", () => { + beforeEach(() => { + cy.setSettingValue("MessageComposerInput.ctrlEnterToSend", null, SettingLevel.ACCOUNT, true); + }); + + it("only sends when you press Ctrl+Enter", () => { + // Type a message and press Enter + cy.get('div[contenteditable=true]').type('my message 3{enter}'); + // It has not been sent yet + cy.contains('.mx_EventTile_body', 'my message 3').should('not.exist'); + + // Press Ctrl+Enter + cy.get('div[contenteditable=true]').type('{ctrl+enter}'); + // It was sent + cy.contains('.mx_EventTile_body', 'my message 3'); + }); + }); + }); + + describe("WYSIWYG", () => { + beforeEach(() => { + cy.enableLabsFeature("feature_wysiwyg_composer"); + cy.initTestUser(synapse, "Janet").then(() => { + cy.createRoom({ name: "Composing Room" }); + }); + cy.viewRoomByName("Composing Room"); + }); + + it("sends a message when you click send or press Enter", () => { + // Type a message + cy.get('div[contenteditable=true]').type('my message 0'); + // It has not been sent yet + cy.contains('.mx_EventTile_body', 'my message 0').should('not.exist'); + + // Click send + cy.get('div[aria-label="Send message"]').click(); + // It has been sent + cy.contains('.mx_EventTile_body', 'my message 0'); + + // Type another + cy.get('div[contenteditable=true]').type('my message 1'); + // Press enter. Would be nice to just use {enter} but we can't because Cypress + // does not trigger an insertParagraph when you do that. + cy.get('div[contenteditable=true]').trigger('input', { inputType: "insertParagraph" }); + // It was sent + cy.contains('.mx_EventTile_body', 'my message 1'); + }); + + it("can write formatted text", () => { + cy.get('div[contenteditable=true]').type('my {ctrl+b}bold{ctrl+b} message'); + cy.get('div[aria-label="Send message"]').click(); + cy.contains('.mx_EventTile_body strong', 'bold'); + }); + + describe("when Ctrl+Enter is required to send", () => { + beforeEach(() => { + cy.setSettingValue("MessageComposerInput.ctrlEnterToSend", null, SettingLevel.ACCOUNT, true); + }); + + it("only sends when you press Ctrl+Enter", () => { + // Type a message and press Enter + cy.get('div[contenteditable=true]').type('my message 3'); + cy.get('div[contenteditable=true]').trigger('input', { inputType: "insertParagraph" }); + // It has not been sent yet + cy.contains('.mx_EventTile_body', 'my message 3').should('not.exist'); + + // Press Ctrl+Enter + cy.get('div[contenteditable=true]').type('{ctrl+enter}'); + // It was sent + cy.contains('.mx_EventTile_body', 'my message 3'); + }); + }); + }); +}); diff --git a/cypress/e2e/create-room/create-room.spec.ts b/cypress/e2e/create-room/create-room.spec.ts index 9bf38194d92..1217c917b64 100644 --- a/cypress/e2e/create-room/create-room.spec.ts +++ b/cypress/e2e/create-room/create-room.spec.ts @@ -54,13 +54,11 @@ describe("Create Room", () => { // Fill room address cy.get('[label="Room address"]').type("test-room-1"); // Submit - cy.startMeasuring("from-submit-to-room"); cy.get(".mx_Dialog_primary").click(); }); cy.url().should("contain", "/#/room/#test-room-1:localhost"); - cy.stopMeasuring("from-submit-to-room"); - cy.get(".mx_RoomHeader_nametext").contains(name); - cy.get(".mx_RoomHeader_topic").contains(topic); + cy.contains(".mx_RoomHeader_nametext", name); + cy.contains(".mx_RoomHeader_topic", topic); }); }); diff --git a/cypress/e2e/editing/editing.spec.ts b/cypress/e2e/editing/editing.spec.ts index 49e4ae79b34..f08466ab306 100644 --- a/cypress/e2e/editing/editing.spec.ts +++ b/cypress/e2e/editing/editing.spec.ts @@ -62,7 +62,7 @@ describe("Editing", () => { cy.get(".mx_BasicMessageComposer_input").type("Foo{backspace}{backspace}{backspace}{enter}"); cy.checkA11y(); }); - cy.get(".mx_RoomView_body .mx_EventTile").contains(".mx_EventTile[data-scroll-tokens]", "Message"); + cy.contains(".mx_RoomView_body .mx_EventTile[data-scroll-tokens]", "Message"); // Assert that the edit composer has gone away cy.get(".mx_EditMessageComposer").should("not.exist"); diff --git a/cypress/e2e/login/consent.spec.ts b/cypress/e2e/login/consent.spec.ts index a4cd31bd26c..c6af9eab22c 100644 --- a/cypress/e2e/login/consent.spec.ts +++ b/cypress/e2e/login/consent.spec.ts @@ -46,7 +46,7 @@ describe("Consent", () => { // Accept terms & conditions cy.get(".mx_QuestionDialog").within(() => { - cy.get("#mx_BaseDialog_title").contains("Terms and Conditions"); + cy.contains("#mx_BaseDialog_title", "Terms and Conditions"); cy.get(".mx_Dialog_primary").click(); }); @@ -58,7 +58,7 @@ describe("Consent", () => { cy.visit(url); cy.get('[type="submit"]').click(); - cy.get("p").contains("Danke schon"); + cy.contains("p", "Danke schon"); }); }); diff --git a/cypress/e2e/login/login.spec.ts b/cypress/e2e/login/login.spec.ts index 2ba2e33f9bd..10582870102 100644 --- a/cypress/e2e/login/login.spec.ts +++ b/cypress/e2e/login/login.spec.ts @@ -21,13 +21,6 @@ import { SynapseInstance } from "../../plugins/synapsedocker"; describe("Login", () => { let synapse: SynapseInstance; - beforeEach(() => { - cy.visit("/#/login"); - cy.startSynapse("consent").then(data => { - synapse = data; - }); - }); - afterEach(() => { cy.stopSynapse(synapse); }); @@ -37,7 +30,11 @@ describe("Login", () => { const password = "p4s5W0rD"; beforeEach(() => { - cy.registerUser(synapse, username, password); + cy.startSynapse("consent").then(data => { + synapse = data; + cy.registerUser(synapse, username, password); + cy.visit("/#/login"); + }); }); it("logs in with an existing account and lands on the home screen", () => { @@ -55,24 +52,25 @@ describe("Login", () => { cy.get("#mx_LoginForm_username").type(username); cy.get("#mx_LoginForm_password").type(password); - cy.startMeasuring("from-submit-to-home"); cy.get(".mx_Login_submit").click(); cy.url().should('contain', '/#/home', { timeout: 30000 }); - cy.stopMeasuring("from-submit-to-home"); }); }); describe("logout", () => { beforeEach(() => { - cy.initTestUser(synapse, "Erin"); + cy.startSynapse("consent").then(data => { + synapse = data; + cy.initTestUser(synapse, "Erin"); + }); }); it("should go to login page on logout", () => { cy.get('[aria-label="User menu"]').click(); // give a change for the outstanding requests queue to settle before logging out - cy.wait(500); + cy.wait(2000); cy.get(".mx_UserMenu_contextMenu").within(() => { cy.get(".mx_UserMenu_iconSignOut").click(); @@ -94,7 +92,7 @@ describe("Login", () => { cy.get('[aria-label="User menu"]').click(); // give a change for the outstanding requests queue to settle before logging out - cy.wait(500); + cy.wait(2000); cy.get(".mx_UserMenu_contextMenu").within(() => { cy.get(".mx_UserMenu_iconSignOut").click(); diff --git a/cypress/e2e/polls/polls.spec.ts b/cypress/e2e/polls/polls.spec.ts index 470c69d8cf3..00b944ce9d9 100644 --- a/cypress/e2e/polls/polls.spec.ts +++ b/cypress/e2e/polls/polls.spec.ts @@ -94,7 +94,7 @@ describe("Polls", () => { cy.stopSynapse(synapse); }); - it("Open polls can be created and voted in", () => { + it("should be creatable and votable", () => { let bot: MatrixClient; cy.getBot(synapse, { displayName: "BotBob" }).then(_bot => { bot = _bot; @@ -122,7 +122,7 @@ describe("Polls", () => { createPoll(pollParams); // Wait for message to send, get its ID and save as @pollId - cy.get(".mx_RoomView_body .mx_EventTile").contains(".mx_EventTile[data-scroll-tokens]", pollParams.title) + cy.contains(".mx_RoomView_body .mx_EventTile[data-scroll-tokens]", pollParams.title) .invoke("attr", "data-scroll-tokens").as("pollId"); cy.get("@pollId").then(pollId => { @@ -159,7 +159,95 @@ describe("Polls", () => { }); }); - it("displays polls correctly in thread panel", () => { + it("should be editable from context menu if no votes have been cast", () => { + let bot: MatrixClient; + cy.getBot(synapse, { displayName: "BotBob" }).then(_bot => { + bot = _bot; + }); + + let roomId: string; + cy.createRoom({}).then(_roomId => { + roomId = _roomId; + cy.inviteUser(roomId, bot.getUserId()); + cy.visit('/#/room/' + roomId); + }); + + cy.openMessageComposerOptions().within(() => { + cy.get('[aria-label="Poll"]').click(); + }); + + const pollParams = { + title: 'Does the polls feature work?', + options: ['Yes', 'No', 'Maybe'], + }; + createPoll(pollParams); + + // Wait for message to send, get its ID and save as @pollId + cy.get(".mx_RoomView_body .mx_EventTile").contains(".mx_EventTile[data-scroll-tokens]", pollParams.title) + .invoke("attr", "data-scroll-tokens").as("pollId"); + + cy.get("@pollId").then(pollId => { + // Open context menu + getPollTile(pollId).rightclick(); + + // Select edit item + cy.get('.mx_ContextualMenu').within(() => { + cy.get('[aria-label="Edit"]').click(); + }); + + // Expect poll editing dialog + cy.get('.mx_PollCreateDialog'); + }); + }); + + it("should not be editable from context menu if votes have been cast", () => { + let bot: MatrixClient; + cy.getBot(synapse, { displayName: "BotBob" }).then(_bot => { + bot = _bot; + }); + + let roomId: string; + cy.createRoom({}).then(_roomId => { + roomId = _roomId; + cy.inviteUser(roomId, bot.getUserId()); + cy.visit('/#/room/' + roomId); + }); + + cy.openMessageComposerOptions().within(() => { + cy.get('[aria-label="Poll"]').click(); + }); + + const pollParams = { + title: 'Does the polls feature work?', + options: ['Yes', 'No', 'Maybe'], + }; + createPoll(pollParams); + + // Wait for message to send, get its ID and save as @pollId + cy.get(".mx_RoomView_body .mx_EventTile").contains(".mx_EventTile[data-scroll-tokens]", pollParams.title) + .invoke("attr", "data-scroll-tokens").as("pollId"); + + cy.get("@pollId").then(pollId => { + // Bot votes 'Maybe' in the poll + botVoteForOption(bot, roomId, pollId, pollParams.options[2]); + + // wait for bot's vote to arrive + cy.get('.mx_MPollBody_totalVotes').should('contain', '1 vote cast'); + + // Open context menu + getPollTile(pollId).rightclick(); + + // Select edit item + cy.get('.mx_ContextualMenu').within(() => { + cy.get('[aria-label="Edit"]').click(); + }); + + // Expect error dialog + cy.get('.mx_ErrorDialog'); + }); + }); + + it("should be displayed correctly in thread panel", () => { let botBob: MatrixClient; let botCharlie: MatrixClient; cy.getBot(synapse, { displayName: "BotBob" }).then(_bot => { @@ -190,7 +278,7 @@ describe("Polls", () => { createPoll(pollParams); // Wait for message to send, get its ID and save as @pollId - cy.get(".mx_RoomView_body .mx_EventTile").contains(".mx_EventTile[data-scroll-tokens]", pollParams.title) + cy.contains(".mx_RoomView_body .mx_EventTile[data-scroll-tokens]", pollParams.title) .invoke("attr", "data-scroll-tokens").as("pollId"); cy.get("@pollId").then(pollId => { diff --git a/cypress/e2e/register/register.spec.ts b/cypress/e2e/register/register.spec.ts index 1945eb7fec0..98ef2bd7290 100644 --- a/cypress/e2e/register/register.spec.ts +++ b/cypress/e2e/register/register.spec.ts @@ -55,7 +55,6 @@ describe("Registration", () => { cy.get("#mx_RegistrationForm_username").type("alice"); cy.get("#mx_RegistrationForm_password").type("totally a great password"); cy.get("#mx_RegistrationForm_passwordConfirm").type("totally a great password"); - cy.startMeasuring("create-account"); cy.get(".mx_Login_submit").click(); cy.get(".mx_RegistrationEmailPromptDialog").should("be.visible"); @@ -63,13 +62,11 @@ describe("Registration", () => { cy.checkA11y(); cy.get(".mx_RegistrationEmailPromptDialog button.mx_Dialog_primary").click(); - cy.stopMeasuring("create-account"); cy.get(".mx_InteractiveAuthEntryComponents_termsPolicy").should("be.visible"); cy.percySnapshot("Registration terms prompt", { percyCSS }); cy.checkA11y(); cy.get(".mx_InteractiveAuthEntryComponents_termsPolicy input").click(); - cy.startMeasuring("from-submit-to-home"); cy.get(".mx_InteractiveAuthEntryComponents_termsSubmit").click(); cy.get(".mx_UseCaseSelection_skip", { timeout: 30000 }).should("exist"); @@ -78,7 +75,6 @@ describe("Registration", () => { cy.get(".mx_UseCaseSelection_skip .mx_AccessibleButton").click(); cy.url().should('contain', '/#/home'); - cy.stopMeasuring("from-submit-to-home"); cy.get('[aria-label="User menu"]').click(); cy.get('[aria-label="Security & Privacy"]').click(); diff --git a/cypress/e2e/room-directory/room-directory.spec.ts b/cypress/e2e/room-directory/room-directory.spec.ts index 18464e20712..f179b0988c2 100644 --- a/cypress/e2e/room-directory/room-directory.spec.ts +++ b/cypress/e2e/room-directory/room-directory.spec.ts @@ -93,7 +93,7 @@ describe("Room Directory", () => { cy.get(".mx_RoomDirectory_dialogWrapper").percySnapshotElement("Room Directory - filtered no results"); cy.get('.mx_RoomDirectory_dialogWrapper [name="dirsearch"]').type("{selectAll}{backspace}test1234"); - cy.get(".mx_RoomDirectory_dialogWrapper").contains(".mx_RoomDirectory_listItem", name) + cy.contains(".mx_RoomDirectory_dialogWrapper .mx_RoomDirectory_listItem", name) .should("exist").as("resultRow"); cy.get(".mx_RoomDirectory_dialogWrapper").percySnapshotElement("Room Directory - filtered one result"); cy.get("@resultRow").find(".mx_AccessibleButton").contains("Join").click(); diff --git a/cypress/e2e/settings/device-management.spec.ts b/cypress/e2e/settings/device-management.spec.ts index 1709475e17c..da4d12e35d9 100644 --- a/cypress/e2e/settings/device-management.spec.ts +++ b/cypress/e2e/settings/device-management.spec.ts @@ -78,6 +78,7 @@ describe("Device manager", () => { 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(); + cy.get('[data-testid="dialog-primary-button"]').click(); // list updated after sign out cy.get('.mx_FilteredDeviceList_list').find('.mx_FilteredDeviceList_listItem').should('have.length', 1); // security recommendation count updated @@ -106,6 +107,8 @@ describe("Device manager", () => { // sign out using the device details sign out cy.get('[data-testid="device-detail-sign-out-cta"]').click(); }); + // confirm the signout + cy.get('[data-testid="dialog-primary-button"]').click(); // no other sessions or security recommendations sections when only one session cy.contains('Other sessions').should('not.exist'); diff --git a/cypress/e2e/sliding-sync/sliding-sync.ts b/cypress/e2e/sliding-sync/sliding-sync.ts index cfd4fd41854..e0e7c974a77 100644 --- a/cypress/e2e/sliding-sync/sliding-sync.ts +++ b/cypress/e2e/sliding-sync/sliding-sync.ts @@ -293,7 +293,7 @@ describe("Sliding Sync", () => { ]); cy.contains(".mx_RoomTile", "Reject").click(); - cy.get(".mx_RoomView").contains(".mx_AccessibleButton", "Reject").click(); + cy.contains(".mx_RoomView .mx_AccessibleButton", "Reject").click(); // wait for the rejected room to disappear cy.get(".mx_RoomTile").should('have.length', 3); @@ -328,8 +328,8 @@ describe("Sliding Sync", () => { cy.getClient().then(cli => cli.setRoomTag(roomId, "m.favourite", { order: 0.5 })); }); - cy.get('.mx_RoomSublist[aria-label="Favourites"]').contains(".mx_RoomTile", "Favourite DM").should("exist"); - cy.get('.mx_RoomSublist[aria-label="People"]').contains(".mx_RoomTile", "Favourite DM").should("not.exist"); + cy.contains('.mx_RoomSublist[aria-label="Favourites"] .mx_RoomTile', "Favourite DM").should("exist"); + cy.contains('.mx_RoomSublist[aria-label="People"] .mx_RoomTile', "Favourite DM").should("not.exist"); }); // Regression test for a bug in SS mode, but would be useful to have in non-SS mode too. diff --git a/cypress/e2e/spaces/spaces.spec.ts b/cypress/e2e/spaces/spaces.spec.ts index e7767de9421..893f48239b4 100644 --- a/cypress/e2e/spaces/spaces.spec.ts +++ b/cypress/e2e/spaces/spaces.spec.ts @@ -83,26 +83,26 @@ describe("Spaces", () => { cy.get('input[label="Name"]').type("Let's have a Riot"); cy.get('input[label="Address"]').should("have.value", "lets-have-a-riot"); cy.get('textarea[label="Description"]').type("This is a space to reminisce Riot.im!"); - cy.get(".mx_AccessibleButton").contains("Create").click(); + cy.contains(".mx_AccessibleButton", "Create").click(); }); // Create the default General & Random rooms, as well as a custom "Jokes" room cy.get('input[label="Room name"][value="General"]').should("exist"); cy.get('input[label="Room name"][value="Random"]').should("exist"); cy.get('input[placeholder="Support"]').type("Jokes"); - cy.get(".mx_AccessibleButton").contains("Continue").click(); + cy.contains(".mx_AccessibleButton", "Continue").click(); // Copy matrix.to link cy.get(".mx_SpacePublicShare_shareButton").focus().realClick(); cy.getClipboardText().should("eq", "https://matrix.to/#/#lets-have-a-riot:localhost"); // Go to space home - cy.get(".mx_AccessibleButton").contains("Go to my first room").click(); + cy.contains(".mx_AccessibleButton", "Go to my first room").click(); // Assert rooms exist in the room list - cy.get(".mx_RoomList").contains(".mx_RoomTile", "General").should("exist"); - cy.get(".mx_RoomList").contains(".mx_RoomTile", "Random").should("exist"); - cy.get(".mx_RoomList").contains(".mx_RoomTile", "Jokes").should("exist"); + cy.contains(".mx_RoomList .mx_RoomTile", "General").should("exist"); + cy.contains(".mx_RoomList .mx_RoomTile", "Random").should("exist"); + cy.contains(".mx_RoomList .mx_RoomTile", "Jokes").should("exist"); }); it("should allow user to create private space", () => { @@ -113,7 +113,7 @@ describe("Spaces", () => { cy.get('input[label="Name"]').type("This is not a Riot"); cy.get('input[label="Address"]').should("not.exist"); cy.get('textarea[label="Description"]').type("This is a private space of mourning Riot.im..."); - cy.get(".mx_AccessibleButton").contains("Create").click(); + cy.contains(".mx_AccessibleButton", "Create").click(); }); cy.get(".mx_SpaceRoomView_privateScope_meAndMyTeammatesButton").click(); @@ -122,20 +122,20 @@ describe("Spaces", () => { cy.get('input[label="Room name"][value="General"]').should("exist"); cy.get('input[label="Room name"][value="Random"]').should("exist"); cy.get('input[placeholder="Support"]').type("Projects"); - cy.get(".mx_AccessibleButton").contains("Continue").click(); + cy.contains(".mx_AccessibleButton", "Continue").click(); cy.get(".mx_SpaceRoomView").should("contain", "Invite your teammates"); - cy.get(".mx_AccessibleButton").contains("Skip for now").click(); + cy.contains(".mx_AccessibleButton", "Skip for now").click(); // Assert rooms exist in the room list - cy.get(".mx_RoomList").contains(".mx_RoomTile", "General").should("exist"); - cy.get(".mx_RoomList").contains(".mx_RoomTile", "Random").should("exist"); - cy.get(".mx_RoomList").contains(".mx_RoomTile", "Projects").should("exist"); + cy.contains(".mx_RoomList .mx_RoomTile", "General").should("exist"); + cy.contains(".mx_RoomList .mx_RoomTile", "Random").should("exist"); + cy.contains(".mx_RoomList .mx_RoomTile", "Projects").should("exist"); // Assert rooms exist in the space explorer - cy.get(".mx_SpaceHierarchy_list").contains(".mx_SpaceHierarchy_roomTile", "General").should("exist"); - cy.get(".mx_SpaceHierarchy_list").contains(".mx_SpaceHierarchy_roomTile", "Random").should("exist"); - cy.get(".mx_SpaceHierarchy_list").contains(".mx_SpaceHierarchy_roomTile", "Projects").should("exist"); + cy.contains(".mx_SpaceHierarchy_list .mx_SpaceHierarchy_roomTile", "General").should("exist"); + cy.contains(".mx_SpaceHierarchy_list .mx_SpaceHierarchy_roomTile", "Random").should("exist"); + cy.contains(".mx_SpaceHierarchy_list .mx_SpaceHierarchy_roomTile", "Projects").should("exist"); }); it("should allow user to create just-me space", () => { @@ -155,10 +155,10 @@ describe("Spaces", () => { cy.get(".mx_SpaceRoomView_privateScope_justMeButton").click(); cy.get(".mx_AddExistingToSpace_entry").click(); - cy.get(".mx_AccessibleButton").contains("Add").click(); + cy.contains(".mx_AccessibleButton", "Add").click(); - cy.get(".mx_RoomList").contains(".mx_RoomTile", "Sample Room").should("exist"); - cy.get(".mx_SpaceHierarchy_list").contains(".mx_SpaceHierarchy_roomTile", "Sample Room").should("exist"); + cy.contains(".mx_RoomList .mx_RoomTile", "Sample Room").should("exist"); + cy.contains(".mx_SpaceHierarchy_list .mx_SpaceHierarchy_roomTile", "Sample Room").should("exist"); }); it("should allow user to invite another to a space", () => { @@ -186,7 +186,7 @@ describe("Spaces", () => { cy.get(".mx_InviteDialog_other").within(() => { cy.get('input[type="text"]').type(bot.getUserId()); - cy.get(".mx_AccessibleButton").contains("Invite").click(); + cy.contains(".mx_AccessibleButton", "Invite").click(); }); cy.get(".mx_InviteDialog_other").should("not.exist"); diff --git a/cypress/e2e/threads/threads.spec.ts b/cypress/e2e/threads/threads.spec.ts index 5af2d07d792..6aea5815e5c 100644 --- a/cypress/e2e/threads/threads.spec.ts +++ b/cypress/e2e/threads/threads.spec.ts @@ -53,6 +53,7 @@ describe("Threads", () => { cy.window().should("have.prop", "beforeReload", true); cy.leaveBeta("Threads"); + cy.wait(1000); // after reload the property should be gone cy.window().should("not.have.prop", "beforeReload"); }); @@ -66,6 +67,7 @@ describe("Threads", () => { cy.window().should("have.prop", "beforeReload", true); cy.joinBeta("Threads"); + cy.wait(1000); // after reload the property should be gone cy.window().should("not.have.prop", "beforeReload"); }); @@ -92,7 +94,7 @@ describe("Threads", () => { cy.get(".mx_RoomView_body .mx_BasicMessageComposer_input").type("Hello Mr. Bot{enter}"); // Wait for message to send, get its ID and save as @threadId - cy.get(".mx_RoomView_body .mx_EventTile").contains(".mx_EventTile[data-scroll-tokens]", "Hello Mr. Bot") + cy.contains(".mx_RoomView_body .mx_EventTile[data-scroll-tokens]", "Hello Mr. Bot") .invoke("attr", "data-scroll-tokens").as("threadId"); // Bot starts thread @@ -116,21 +118,21 @@ describe("Threads", () => { cy.get(".mx_RoomView_body .mx_ThreadSummary .mx_ThreadSummary_content").should("contain", "Test"); // User reacts to message instead - cy.get(".mx_ThreadView .mx_EventTile").contains(".mx_EventTile_line", "Hello there") + cy.contains(".mx_ThreadView .mx_EventTile .mx_EventTile_line", "Hello there") .find('[aria-label="React"]').click({ force: true }); // Cypress has no ability to hover cy.get(".mx_EmojiPicker").within(() => { cy.get('input[type="text"]').type("wave"); - cy.get('[role="menuitem"]').contains("👋").click(); + cy.contains('[role="menuitem"]', "👋").click(); }); // User redacts their prior response - cy.get(".mx_ThreadView .mx_EventTile").contains(".mx_EventTile_line", "Test") + cy.contains(".mx_ThreadView .mx_EventTile .mx_EventTile_line", "Test") .find('[aria-label="Options"]').click({ force: true }); // Cypress has no ability to hover cy.get(".mx_IconizedContextMenu").within(() => { - cy.get('[role="menuitem"]').contains("Remove").click(); + cy.contains('[role="menuitem"]', "Remove").click(); }); cy.get(".mx_TextInputDialog").within(() => { - cy.get(".mx_Dialog_primary").contains("Remove").click(); + cy.contains(".mx_Dialog_primary", "Remove").click(); }); // User asserts summary was updated correctly @@ -171,7 +173,7 @@ describe("Threads", () => { cy.get(".mx_RoomView_body .mx_ThreadSummary .mx_ThreadSummary_content").should("contain", "Great!"); // User edits & asserts - cy.get(".mx_ThreadView .mx_EventTile_last").contains(".mx_EventTile_line", "Great!").within(() => { + cy.contains(".mx_ThreadView .mx_EventTile_last .mx_EventTile_line", "Great!").within(() => { cy.get('[aria-label="Edit"]').click({ force: true }); // Cypress has no ability to hover cy.get(".mx_BasicMessageComposer_input").type(" How about yourself?{enter}"); }); @@ -234,7 +236,7 @@ describe("Threads", () => { cy.get(".mx_RoomView_body .mx_BasicMessageComposer_input").type("Hello Mr. Bot{enter}"); // Create thread - cy.get(".mx_RoomView_body .mx_EventTile").contains(".mx_EventTile[data-scroll-tokens]", "Hello Mr. Bot") + cy.contains(".mx_RoomView_body .mx_EventTile[data-scroll-tokens]", "Hello Mr. Bot") .realHover().find(".mx_MessageActionBar_threadButton").click(); cy.get(".mx_ThreadView_timelinePanelWrapper").should("have.length", 1); @@ -256,7 +258,7 @@ describe("Threads", () => { cy.get(".mx_RoomView_body .mx_BasicMessageComposer_input").type("Hello Mr. Bot{enter}"); // Create thread - cy.get(".mx_RoomView_body .mx_EventTile").contains(".mx_EventTile[data-scroll-tokens]", "Hello Mr. Bot") + cy.contains(".mx_RoomView_body .mx_EventTile[data-scroll-tokens]", "Hello Mr. Bot") .realHover().find(".mx_MessageActionBar_threadButton").click(); cy.get(".mx_ThreadView_timelinePanelWrapper").should("have.length", 1); @@ -268,7 +270,7 @@ describe("Threads", () => { cy.get(".mx_BaseCard_close").click(); // Open existing thread - cy.get(".mx_RoomView_body .mx_EventTile").contains(".mx_EventTile[data-scroll-tokens]", "Hello Mr. Bot") + cy.contains(".mx_RoomView_body .mx_EventTile[data-scroll-tokens]", "Hello Mr. Bot") .realHover().find(".mx_MessageActionBar_threadButton").click(); cy.get(".mx_ThreadView_timelinePanelWrapper").should("have.length", 1); cy.get(".mx_BaseCard .mx_EventTile").should("contain", "Hello Mr. Bot"); diff --git a/cypress/e2e/timeline/timeline.spec.ts b/cypress/e2e/timeline/timeline.spec.ts index 6cebbfd1814..68e0300ce35 100644 --- a/cypress/e2e/timeline/timeline.spec.ts +++ b/cypress/e2e/timeline/timeline.spec.ts @@ -329,7 +329,7 @@ describe("Timeline", () => { cy.getComposer().type(`${MESSAGE}{enter}`); // Reply to the message - cy.get(".mx_RoomView_body").contains(".mx_EventTile_line", "Hello world").within(() => { + cy.contains(".mx_RoomView_body .mx_EventTile_line", "Hello world").within(() => { cy.get('[aria-label="Reply"]').click({ force: true }); // Cypress has no ability to hover }); }; diff --git a/cypress/e2e/toasts/analytics-toast.ts b/cypress/e2e/toasts/analytics-toast.ts index 547e46bf687..518a544a1cb 100644 --- a/cypress/e2e/toasts/analytics-toast.ts +++ b/cypress/e2e/toasts/analytics-toast.ts @@ -24,7 +24,7 @@ function assertNoToasts(): void { } function getToast(expectedTitle: string): Chainable { - return cy.get(".mx_Toast_toast").contains("h2", expectedTitle).should("exist").closest(".mx_Toast_toast"); + return cy.contains(".mx_Toast_toast h2", expectedTitle).should("exist").closest(".mx_Toast_toast"); } function acceptToast(expectedTitle: string): void { diff --git a/cypress/plugins/index.ts b/cypress/plugins/index.ts index 09b2bdb53b5..ce154ee0bcd 100644 --- a/cypress/plugins/index.ts +++ b/cypress/plugins/index.ts @@ -18,7 +18,6 @@ limitations under the License. import PluginEvents = Cypress.PluginEvents; import PluginConfigOptions = Cypress.PluginConfigOptions; -import { performance } from "./performance"; import { synapseDocker } from "./synapsedocker"; import { slidingSyncProxyDocker } from "./sliding-sync"; import { webserver } from "./webserver"; @@ -30,7 +29,6 @@ import { log } from "./log"; */ export default function(on: PluginEvents, config: PluginConfigOptions) { docker(on, config); - performance(on, config); synapseDocker(on, config); slidingSyncProxyDocker(on, config); webserver(on, config); diff --git a/cypress/plugins/performance.ts b/cypress/plugins/performance.ts deleted file mode 100644 index c6bd3e4ce9f..00000000000 --- a/cypress/plugins/performance.ts +++ /dev/null @@ -1,47 +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 path from "path"; -import * as fse from "fs-extra"; - -import PluginEvents = Cypress.PluginEvents; -import PluginConfigOptions = Cypress.PluginConfigOptions; - -// This holds all the performance measurements throughout the run -let bufferedMeasurements: PerformanceEntry[] = []; - -function addMeasurements(measurements: PerformanceEntry[]): void { - bufferedMeasurements = bufferedMeasurements.concat(measurements); - return null; -} - -async function writeMeasurementsFile() { - try { - const measurementsPath = path.join("cypress", "performance", "measurements.json"); - await fse.outputJSON(measurementsPath, bufferedMeasurements, { - spaces: 4, - }); - } finally { - bufferedMeasurements = []; - } -} - -export function performance(on: PluginEvents, config: PluginConfigOptions) { - on("task", { addMeasurements }); - on("after:run", writeMeasurementsFile); -} diff --git a/cypress/plugins/sliding-sync/index.ts b/cypress/plugins/sliding-sync/index.ts index 61a62aad13c..44dcaecfcba 100644 --- a/cypress/plugins/sliding-sync/index.ts +++ b/cypress/plugins/sliding-sync/index.ts @@ -22,7 +22,8 @@ import { dockerExec, dockerIp, dockerRun, dockerStop } from "../docker"; import { getFreePort } from "../utils/port"; import { SynapseInstance } from "../synapsedocker"; -// A cypress plugins to add command to start & stop https://github.com/matrix-org/sliding-sync +// A cypress plugin to add command to start & stop https://github.com/matrix-org/sliding-sync +// SLIDING_SYNC_PROXY_TAG env used as the docker tag to use for `ghcr.io/matrix-org/sliding-sync-proxy` image. export interface ProxyInstance { containerId: string; @@ -34,7 +35,7 @@ const instances = new Map(); const PG_PASSWORD = "p4S5w0rD"; -async function proxyStart(synapse: SynapseInstance): Promise { +async function proxyStart(dockerTag: string, synapse: SynapseInstance): Promise { console.log(new Date(), "Starting sliding sync proxy..."); const postgresId = await dockerRun({ @@ -75,9 +76,9 @@ async function proxyStart(synapse: SynapseInstance): Promise { } const port = await getFreePort(); - console.log(new Date(), "starting proxy container..."); + console.log(new Date(), "starting proxy container...", dockerTag); const containerId = await dockerRun({ - image: "ghcr.io/matrix-org/sliding-sync-proxy:v0.4.0", + image: "ghcr.io/matrix-org/sliding-sync-proxy:" + dockerTag, containerName: "react-sdk-cypress-sliding-sync-proxy", params: [ "--rm", @@ -114,8 +115,10 @@ async function proxyStop(instance: ProxyInstance): Promise { * @type {Cypress.PluginConfig} */ export function slidingSyncProxyDocker(on: PluginEvents, config: PluginConfigOptions) { + const dockerTag = config.env["SLIDING_SYNC_PROXY_TAG"]; + on("task", { - proxyStart, + proxyStart: proxyStart.bind(null, dockerTag), proxyStop, }); diff --git a/cypress/support/e2e.ts b/cypress/support/e2e.ts index 899d41c5b8d..4470c2192e5 100644 --- a/cypress/support/e2e.ts +++ b/cypress/support/e2e.ts @@ -19,7 +19,6 @@ limitations under the License. import "@percy/cypress"; import "cypress-real-events"; -import "./performance"; import "./synapse"; import "./login"; import "./labs"; diff --git a/cypress/support/login.ts b/cypress/support/login.ts index e44be781231..6c441589415 100644 --- a/cypress/support/login.ts +++ b/cypress/support/login.ts @@ -91,7 +91,7 @@ Cypress.Commands.add("loginUser", (synapse: SynapseInstance, username: string, p 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 => { + win.indexedDB.databases()?.then(databases => { databases.forEach(database => { win.indexedDB.deleteDatabase(database.name); }); diff --git a/cypress/support/performance.ts b/cypress/support/performance.ts deleted file mode 100644 index bbd1fe217d4..00000000000 --- a/cypress/support/performance.ts +++ /dev/null @@ -1,74 +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 Chainable = Cypress.Chainable; -import AUTWindow = Cypress.AUTWindow; - -declare global { - // eslint-disable-next-line @typescript-eslint/no-namespace - namespace Cypress { - interface Chainable { - /** - * Start measuring the duration of some task. - * @param task The task name. - */ - startMeasuring(task: string): Chainable; - /** - * Stop measuring the duration of some task. - * The duration is reported in the Cypress log. - * @param task The task name. - */ - stopMeasuring(task: string): Chainable; - } - } -} - -function getPrefix(task: string): string { - return `cy:${Cypress.spec.name.split(".")[0]}:${task}`; -} - -function startMeasuring(task: string): Chainable { - return cy.window({ log: false }).then((win) => { - win.mxPerformanceMonitor.start(getPrefix(task)); - }); -} - -function stopMeasuring(task: string): Chainable { - return cy.window({ log: false }).then((win) => { - const measure = win.mxPerformanceMonitor.stop(getPrefix(task)); - cy.log(`**${task}** ${measure.duration} ms`); - }); -} - -Cypress.Commands.add("startMeasuring", startMeasuring); -Cypress.Commands.add("stopMeasuring", stopMeasuring); - -Cypress.on("window:before:unload", (event: BeforeUnloadEvent) => { - const doc = event.target as Document; - if (doc.location.href === "about:blank") return; - const win = doc.defaultView as AUTWindow; - if (!win.mxPerformanceMonitor) return; - const entries = win.mxPerformanceMonitor.getEntries().filter(entry => { - return entry.name.startsWith("cy:"); - }); - if (!entries || entries.length === 0) return; - cy.task("addMeasurements", entries); -}); - -// Needed to make this file a module -export { }; diff --git a/cypress/support/settings.ts b/cypress/support/settings.ts index 63c91ddda0a..42a78792a0f 100644 --- a/cypress/support/settings.ts +++ b/cypress/support/settings.ts @@ -153,7 +153,7 @@ Cypress.Commands.add("openRoomSettings", (tab?: string): Chainable> => { return cy.get(".mx_TabbedView_tabLabels").within(() => { - cy.get(".mx_TabbedView_tabLabel").contains(tab).click(); + cy.contains(".mx_TabbedView_tabLabel", tab).click(); }); }); @@ -162,13 +162,13 @@ Cypress.Commands.add("closeDialog", (): Chainable> => { }); Cypress.Commands.add("joinBeta", (name: string): Chainable> => { - return cy.get(".mx_BetaCard_title").contains(name).closest(".mx_BetaCard").within(() => { + return cy.contains(".mx_BetaCard_title", name).closest(".mx_BetaCard").within(() => { return cy.get(".mx_BetaCard_buttons").contains("Join the beta").click(); }); }); Cypress.Commands.add("leaveBeta", (name: string): Chainable> => { - return cy.get(".mx_BetaCard_title").contains(name).closest(".mx_BetaCard").within(() => { + return cy.contains(".mx_BetaCard_title", name).closest(".mx_BetaCard").within(() => { return cy.get(".mx_BetaCard_buttons").contains("Leave the beta").click(); }); }); diff --git a/package.json b/package.json index ec0c47f01ad..5ea415d523b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-react-sdk", - "version": "3.59.1", + "version": "3.60.0", "description": "SDK for matrix.org using React", "author": "matrix.org", "repository": { @@ -57,7 +57,7 @@ "dependencies": { "@babel/runtime": "^7.12.5", "@matrix-org/analytics-events": "^0.2.0", - "@matrix-org/matrix-wysiwyg": "^0.2.0", + "@matrix-org/matrix-wysiwyg": "^0.3.2", "@matrix-org/react-sdk-module-api": "^0.0.3", "@sentry/browser": "^6.11.0", "@sentry/tracing": "^6.11.0", @@ -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": "21.0.1", + "matrix-js-sdk": "21.1.0", "matrix-widget-api": "^1.1.1", "minimist": "^1.2.5", "opus-recorder": "^8.0.3", diff --git a/res/css/_components.pcss b/res/css/_components.pcss index 9f8e9024049..7c4ac418441 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -4,7 +4,6 @@ @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"; @@ -19,6 +18,7 @@ @import "./components/views/beacon/_StyledLiveBeaconIcon.pcss"; @import "./components/views/context_menus/_KebabContextMenu.pcss"; @import "./components/views/elements/_FilterDropdown.pcss"; +@import "./components/views/elements/_LearnMore.pcss"; @import "./components/views/location/_EnableLiveShare.pcss"; @import "./components/views/location/_LiveDurationDropdown.pcss"; @import "./components/views/location/_LocationShareMenu.pcss"; @@ -44,6 +44,7 @@ @import "./components/views/settings/shared/_SettingsSubsectionHeading.pcss"; @import "./components/views/spaces/_QuickThemeSwitcher.pcss"; @import "./components/views/typography/_Caption.pcss"; +@import "./compound/_Icon.pcss"; @import "./structures/_AutoHideScrollbar.pcss"; @import "./structures/_BackdropPanel.pcss"; @import "./structures/_CompatibilityPage.pcss"; @@ -96,6 +97,7 @@ @import "./views/auth/_CountryDropdown.pcss"; @import "./views/auth/_InteractiveAuthEntryComponents.pcss"; @import "./views/auth/_LanguageSelector.pcss"; +@import "./views/auth/_LoginWithQR.pcss"; @import "./views/auth/_PassphraseField.pcss"; @import "./views/auth/_Welcome.pcss"; @import "./views/avatars/_BaseAvatar.pcss"; @@ -298,8 +300,10 @@ @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/rooms/wysiwyg_composer/_EditWysiwygComposer.pcss"; +@import "./views/rooms/wysiwyg_composer/_SendWysiwygComposer.pcss"; +@import "./views/rooms/wysiwyg_composer/components/_Editor.pcss"; +@import "./views/rooms/wysiwyg_composer/components/_FormattingButtons.pcss"; @import "./views/settings/_AvatarSetting.pcss"; @import "./views/settings/_CrossSigningPanel.pcss"; @import "./views/settings/_CryptographyPanel.pcss"; @@ -368,7 +372,6 @@ @import "./views/voip/_VideoFeed.pcss"; @import "./voice-broadcast/atoms/_LiveBadge.pcss"; @import "./voice-broadcast/atoms/_PlaybackControlButton.pcss"; +@import "./voice-broadcast/atoms/_VoiceBroadcastControl.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"; +@import "./voice-broadcast/molecules/_VoiceBroadcastBody.pcss"; diff --git a/res/css/components/views/elements/_LearnMore.pcss b/res/css/components/views/elements/_LearnMore.pcss new file mode 100644 index 00000000000..97f3b4c527d --- /dev/null +++ b/res/css/components/views/elements/_LearnMore.pcss @@ -0,0 +1,19 @@ +/* +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_LearnMore_button { + margin-left: $spacing-4; +} diff --git a/res/css/voice-broadcast/molecules/_VoiceBroadcastPlaybackBody.pcss b/res/css/compound/_Icon.pcss similarity index 71% rename from res/css/voice-broadcast/molecules/_VoiceBroadcastPlaybackBody.pcss rename to res/css/compound/_Icon.pcss index 11921e1f950..88f49f9da06 100644 --- a/res/css/voice-broadcast/molecules/_VoiceBroadcastPlaybackBody.pcss +++ b/res/css/compound/_Icon.pcss @@ -14,14 +14,18 @@ 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; +/* + * Compound icon + + * {@link https://www.figma.com/file/X4XTH9iS2KGJ2wFKDqkyed} + */ + +.mx_Icon { + box-sizing: border-box; + padding: 1px; } -.mx_VoiceBroadcastPlaybackBody_controls { - display: flex; - justify-content: center; +.mx_Icon_16 { + height: 16px; + width: 16px; } diff --git a/res/css/views/auth/_LoginWithQR.pcss b/res/css/views/auth/_LoginWithQR.pcss new file mode 100644 index 00000000000..390cf8311d0 --- /dev/null +++ b/res/css/views/auth/_LoginWithQR.pcss @@ -0,0 +1,171 @@ +/* +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_LoginWithQRSection .mx_AccessibleButton { + margin-right: $spacing-12; +} + +.mx_AuthPage .mx_LoginWithQR { + .mx_AccessibleButton { + display: block !important; + } + + .mx_AccessibleButton + .mx_AccessibleButton { + margin-top: $spacing-8; + } + + .mx_LoginWithQR_separator { + display: flex; + align-items: center; + text-align: center; + + &::before, &::after { + content: ''; + flex: 1; + border-bottom: 1px solid $quinary-content; + } + + &:not(:empty) { + &::before { + margin-right: 1em; + } + &::after { + margin-left: 1em; + } + } + } + + font-size: $font-15px; +} + +.mx_UserSettingsDialog .mx_LoginWithQR { + .mx_AccessibleButton + .mx_AccessibleButton { + margin-left: $spacing-12; + } + + font-size: $font-14px; + + h1 { + font-size: $font-24px; + margin-bottom: 0; + } + + li { + line-height: 1.8; + } + + .mx_QRCode { + padding: $spacing-12 $spacing-40; + margin: $spacing-28 0; + } + + .mx_LoginWithQR_buttons { + text-align: center; + } + + .mx_LoginWithQR_qrWrapper { + display: flex; + } +} + +.mx_LoginWithQR { + min-height: 350px; + display: flex; + flex-direction: column; + + .mx_LoginWithQR_centreTitle { + h1 { + text-align: centre; + } + } + + h1 > svg { + &.normal { + color: $secondary-content; + } + &.error { + color: $alert; + } + &.success { + color: $accent; + } + height: 1.3em; + margin-right: $spacing-8; + vertical-align: middle; + } + + .mx_LoginWithQR_confirmationDigits { + text-align: center; + margin: $spacing-48 auto; + font-weight: 600; + font-size: $font-24px; + color: $primary-content; + } + + .mx_LoginWithQR_confirmationAlert { + border: 1px solid $quaternary-content; + border-radius: $spacing-8; + padding: $spacing-8; + line-height: 1.5em; + display: flex; + + svg { + height: 30px; + } + } + + .mx_LoginWithQR_separator { + margin: 1em 0; + } + + ol { + list-style-position: inside; + padding-inline-start: 0; + + li::marker { + color: $accent; + } + } + + .mx_LoginWithQR_BackButton { + height: $spacing-12; + margin-bottom: $spacing-24; + svg { + height: 100%; + } + } + + .mx_LoginWithQR_main { + display: flex; + flex-direction: column; + flex-grow: 1; + } + + .mx_QRCode { + border: 1px solid $quinary-content; + border-radius: $spacing-8; + display: flex; + justify-content: center; + } + + .mx_LoginWithQR_spinner { + flex-grow: 1; + display: flex; + justify-content: center; + align-items: center; + height: 100%; + } +} diff --git a/res/css/views/dialogs/_SpotlightDialog.pcss b/res/css/views/dialogs/_SpotlightDialog.pcss index c4186207b03..4edd00bace7 100644 --- a/res/css/views/dialogs/_SpotlightDialog.pcss +++ b/res/css/views/dialogs/_SpotlightDialog.pcss @@ -247,7 +247,7 @@ limitations under the License. } &.mx_SpotlightDialog_result_multiline { - align-items: start; + align-items: flex-start; .mx_AccessibleButton { padding: $spacing-4 $spacing-20; diff --git a/res/css/views/elements/_UseCaseSelection.pcss b/res/css/views/elements/_UseCaseSelection.pcss index 3daf15772f3..2b907e7b67d 100644 --- a/res/css/views/elements/_UseCaseSelection.pcss +++ b/res/css/views/elements/_UseCaseSelection.pcss @@ -65,7 +65,7 @@ limitations under the License. .mx_UseCaseSelection_skip { display: flex; flex-direction: column; - align-self: start; + align-self: flex-start; } } diff --git a/res/css/views/rooms/_EventTile.pcss b/res/css/views/rooms/_EventTile.pcss index 24c3f2653b9..bd7685f139d 100644 --- a/res/css/views/rooms/_EventTile.pcss +++ b/res/css/views/rooms/_EventTile.pcss @@ -426,7 +426,7 @@ $left-gutter: 64px; } &.mx_EventTile_selected .mx_EventTile_line { - // TODO: check if this would be necessary + /* TODO: check if this would be necessary; */ padding-inline-start: calc(var(--EventTile_group_line-spacing-inline-start) + 20px); } } @@ -906,15 +906,22 @@ $left-gutter: 64px; } /* Display notification dot */ - &[data-notification]::before { + &[data-notification]::before, + .mx_NotificationBadge { + position: absolute; $notification-inset-block-start: 14px; /* 14px: align the dot with the timestamp row */ - width: $notification-dot-size; - height: $notification-dot-size; + /* !important to fix overly specific CSS selector applied on mx_NotificationBadge */ + width: $notification-dot-size !important; + height: $notification-dot-size !important; border-radius: 50%; inset: $notification-inset-block-start $spacing-8 auto auto; } + .mx_NotificationBadge_count { + display: none; + } + &[data-notification="total"]::before { background-color: $room-icon-unread-color; } @@ -1313,7 +1320,8 @@ $left-gutter: 64px; } } - &[data-shape="ThreadsList"][data-notification]::before { + &[data-shape="ThreadsList"][data-notification]::before, + .mx_NotificationBadge { /* stylelint-disable-next-line declaration-colon-space-after */ inset-block-start: calc($notification-inset-block-start - var(--MatrixChat_useCompactLayout_group-padding-top)); diff --git a/res/css/views/rooms/_MessageComposer.pcss b/res/css/views/rooms/_MessageComposer.pcss index ca36e2275a9..2776304c8fd 100644 --- a/res/css/views/rooms/_MessageComposer.pcss +++ b/res/css/views/rooms/_MessageComposer.pcss @@ -258,7 +258,7 @@ limitations under the License. */ .mx_MessageComposer_wysiwyg { .mx_MessageComposer_e2eIcon.mx_E2EIcon,.mx_MessageComposer_button, .mx_MessageComposer_sendMessage { - margin-top: 22px; + margin-top: 28px; } } @@ -282,6 +282,14 @@ limitations under the License. mask-image: url('$(res)/img/element-icons/room/composer/emoji.svg'); } +.mx_MessageComposer_plain_text::before { + mask-image: url('$(res)/img/element-icons/room/composer/plain_text.svg'); +} + +.mx_MessageComposer_rich_text::before { + mask-image: url('$(res)/img/element-icons/room/composer/rich_text.svg'); +} + .mx_MessageComposer_location::before { mask-image: url('$(res)/img/element-icons/room/composer/location.svg'); } diff --git a/res/css/views/rooms/_RoomCallBanner.pcss b/res/css/views/rooms/_RoomCallBanner.pcss index 4b05b72d91d..ec26807bb18 100644 --- a/res/css/views/rooms/_RoomCallBanner.pcss +++ b/res/css/views/rooms/_RoomCallBanner.pcss @@ -41,14 +41,14 @@ limitations under the License. &::before { display: inline-block; - vertical-align: text-top; + vertical-align: middle; content: ""; background-color: $secondary-content; mask-size: 16px; + mask-position-y: center; width: 16px; - height: 16px; - margin-right: 4px; - bottom: 2px; + height: 1.2em; /* to match line height */ + margin-right: 8px; mask-image: url("$(res)/img/element-icons/call/video-call.svg"); } } diff --git a/res/css/views/rooms/wysiwyg_composer/_EditWysiwygComposer.pcss b/res/css/views/rooms/wysiwyg_composer/_EditWysiwygComposer.pcss new file mode 100644 index 00000000000..73e5fef6e9a --- /dev/null +++ b/res/css/views/rooms/wysiwyg_composer/_EditWysiwygComposer.pcss @@ -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. +*/ + +.mx_EditWysiwygComposer { + --EditWysiwygComposer-padding-inline: 3px; + + display: flex; + flex-direction: column; + max-width: 100%; /* disable overflow */ + width: auto; + gap: 8px; + padding: 8px var(--EditWysiwygComposer-padding-inline); + + .mx_WysiwygComposer_content { + border-radius: 4px; + border: solid 1px $primary-hairline-color; + background-color: $background; + max-height: 200px; + padding: 3px 6px; + + &:focus { + border-color: rgba($accent, 0.5); /* Only ever used here */ + } + } + + .mx_EditWysiwygComposer_buttons { + display: flex; + flex-flow: row wrap-reverse; /* display "Save" over "Cancel" */ + justify-content: flex-end; + gap: 5px; + margin-inline-start: auto; + + .mx_AccessibleButton { + flex: 1; + box-sizing: border-box; + min-width: 100px; /* magic number to align the edge of the button with the input area */ + } + } + + .mx_FormattingButtons_Button { + &:first-child { + margin-left: 0px; + } + } +} diff --git a/res/css/views/rooms/wysiwyg_composer/_WysiwygComposer.pcss b/res/css/views/rooms/wysiwyg_composer/_SendWysiwygComposer.pcss similarity index 98% rename from res/css/views/rooms/wysiwyg_composer/_WysiwygComposer.pcss rename to res/css/views/rooms/wysiwyg_composer/_SendWysiwygComposer.pcss index 133b66388e7..a00f8c7e113 100644 --- a/res/css/views/rooms/wysiwyg_composer/_WysiwygComposer.pcss +++ b/res/css/views/rooms/wysiwyg_composer/_SendWysiwygComposer.pcss @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_WysiwygComposer { +.mx_SendWysiwygComposer { flex: 1; display: flex; flex-direction: column; diff --git a/res/css/views/rooms/wysiwyg_composer/components/_Editor.pcss b/res/css/views/rooms/wysiwyg_composer/components/_Editor.pcss new file mode 100644 index 00000000000..6a6b68af7c6 --- /dev/null +++ b/res/css/views/rooms/wysiwyg_composer/components/_Editor.pcss @@ -0,0 +1,37 @@ +/* +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_container { + position: relative; + + @keyframes visualbell { + from { background-color: $visual-bell-bg-color; } + to { background-color: $background; } + } + + .mx_WysiwygComposer_content { + white-space: pre-wrap; + word-wrap: break-word; + outline: none; + overflow-x: hidden; + + /* Force caret nodes to be selected in full so that they can be */ + /* navigated through in a single keypress */ + .caretNode { + user-select: all; + } + } +} diff --git a/res/css/views/rooms/wysiwyg_composer/_FormattingButtons.pcss b/res/css/views/rooms/wysiwyg_composer/components/_FormattingButtons.pcss similarity index 97% rename from res/css/views/rooms/wysiwyg_composer/_FormattingButtons.pcss rename to res/css/views/rooms/wysiwyg_composer/components/_FormattingButtons.pcss index 36f84ae5f1f..cd0ac38e0ed 100644 --- a/res/css/views/rooms/wysiwyg_composer/_FormattingButtons.pcss +++ b/res/css/views/rooms/wysiwyg_composer/components/_FormattingButtons.pcss @@ -16,7 +16,7 @@ limitations under the License. .mx_FormattingButtons { display: flex; - justify-content: start; + justify-content: flex-start; .mx_FormattingButtons_Button { --size: 28px; @@ -45,7 +45,7 @@ limitations under the License. left: 6px; height: 16px; width: 16px; - background-color: $icon-button-color; + background-color: $tertiary-content; mask-repeat: no-repeat; mask-size: contain; mask-position: center; diff --git a/res/css/components/atoms/_Icon.pcss b/res/css/voice-broadcast/atoms/_VoiceBroadcastControl.pcss similarity index 57% rename from res/css/components/atoms/_Icon.pcss rename to res/css/voice-broadcast/atoms/_VoiceBroadcastControl.pcss index b9d994e43f4..f7cba048709 100644 --- a/res/css/components/atoms/_Icon.pcss +++ b/res/css/voice-broadcast/atoms/_VoiceBroadcastControl.pcss @@ -14,29 +14,18 @@ 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_VoiceBroadcastControl { + align-items: center; + background-color: $background; + border-radius: 50%; + color: $secondary-content; + display: flex; + height: 32px; + justify-content: center; + margin-bottom: $spacing-8; + width: 32px; } -.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; +.mx_VoiceBroadcastControl-recording { + color: $alert; } diff --git a/res/css/voice-broadcast/molecules/_VoiceBroadcastRecordingPip.pcss b/res/css/voice-broadcast/molecules/_VoiceBroadcastBody.pcss similarity index 71% rename from res/css/voice-broadcast/molecules/_VoiceBroadcastRecordingPip.pcss rename to res/css/voice-broadcast/molecules/_VoiceBroadcastBody.pcss index b01b1b80db0..c3992006385 100644 --- a/res/css/voice-broadcast/molecules/_VoiceBroadcastRecordingPip.pcss +++ b/res/css/voice-broadcast/molecules/_VoiceBroadcastBody.pcss @@ -14,22 +14,33 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_VoiceBroadcastRecordingPip { - background-color: $system; +.mx_VoiceBroadcastBody { + background-color: $quinary-content; border-radius: 8px; - box-shadow: 0 2px 8px 0 #0000004a; + color: $secondary-content; display: inline-block; + font-size: $font-12px; padding: $spacing-12; } -.mx_VoiceBroadcastRecordingPip_divider { +.mx_VoiceBroadcastBody--pip { + background-color: $system; + box-shadow: 0 2px 8px 0 #0000004a; +} + +.mx_VoiceBroadcastBody_divider { background-color: $quinary-content; border: 0; height: 1px; margin: $spacing-12 0; } -.mx_VoiceBroadcastRecordingPip_controls { +.mx_VoiceBroadcastBody_controls { + display: flex; + justify-content: space-around; +} + +.mx_VoiceBroadcastBody_timerow { display: flex; - justify-content: center; + justify-content: flex-end; } diff --git a/res/img/element-icons/Record.svg b/res/img/element-icons/Record.svg new file mode 100644 index 00000000000..a16ce774b04 --- /dev/null +++ b/res/img/element-icons/Record.svg @@ -0,0 +1,3 @@ + + + diff --git a/res/img/element-icons/Stop.svg b/res/img/element-icons/Stop.svg index 49f526895ee..d63459e1dbc 100644 --- a/res/img/element-icons/Stop.svg +++ b/res/img/element-icons/Stop.svg @@ -1,3 +1,3 @@ - + diff --git a/res/img/element-icons/back.svg b/res/img/element-icons/back.svg new file mode 100644 index 00000000000..62aef5df274 --- /dev/null +++ b/res/img/element-icons/back.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/res/img/element-icons/devices.svg b/res/img/element-icons/devices.svg new file mode 100644 index 00000000000..6c26cfe97ee --- /dev/null +++ b/res/img/element-icons/devices.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/res/img/element-icons/live.svg b/res/img/element-icons/live.svg index ae259d1ca36..31341f1ef68 100644 --- a/res/img/element-icons/live.svg +++ b/res/img/element-icons/live.svg @@ -5,54 +5,23 @@ viewBox="0 0 21.799 21.799" fill="none" version="1.1" - id="svg12" - sodipodi:docname="live.svg" - inkscape:version="1.2.1 (9c6d41e410, 2022-07-14)" - xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" - xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg"> - - + fill="currentColor" /> + fill="currentColor" /> + fill="currentColor" /> + fill="currentColor" /> + fill="currentColor" /> diff --git a/res/img/element-icons/pause.svg b/res/img/element-icons/pause.svg index 7c3adb3706c..4b7be99e3b9 100644 --- a/res/img/element-icons/pause.svg +++ b/res/img/element-icons/pause.svg @@ -1,4 +1,4 @@ - - + + diff --git a/res/img/element-icons/play.svg b/res/img/element-icons/play.svg index b130419ca32..3443ae01fa8 100644 --- a/res/img/element-icons/play.svg +++ b/res/img/element-icons/play.svg @@ -1,3 +1,3 @@ - + diff --git a/res/img/element-icons/qrcode.svg b/res/img/element-icons/qrcode.svg new file mode 100644 index 00000000000..7787141ad53 --- /dev/null +++ b/res/img/element-icons/qrcode.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/res/img/element-icons/room/composer/plain_text.svg b/res/img/element-icons/room/composer/plain_text.svg new file mode 100644 index 00000000000..d2da9d25516 --- /dev/null +++ b/res/img/element-icons/room/composer/plain_text.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/res/img/element-icons/room/composer/rich_text.svg b/res/img/element-icons/room/composer/rich_text.svg new file mode 100644 index 00000000000..7ff47fe085c --- /dev/null +++ b/res/img/element-icons/room/composer/rich_text.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/res/img/voip/call-view/mic-on.svg b/res/img/voip/call-view/mic-on.svg index 87e87d9365d..317d10b2962 100644 --- a/res/img/voip/call-view/mic-on.svg +++ b/res/img/voip/call-view/mic-on.svg @@ -1,3 +1,3 @@ - + diff --git a/src/ContentMessages.ts b/src/ContentMessages.ts index d4cf3cc0ab5..8135eaab0ef 100644 --- a/src/ContentMessages.ts +++ b/src/ContentMessages.ts @@ -43,7 +43,6 @@ import { RoomUpload } from "./models/RoomUpload"; import SettingsStore from "./settings/SettingsStore"; import { decorateStartSendingTime, sendRoundTripMetric } from "./sendTimePerformanceMetrics"; import { TimelineRenderingType } from "./contexts/RoomContext"; -import { RoomViewStore } from "./stores/RoomViewStore"; import { addReplyToMessageContent } from "./utils/Reply"; import ErrorDialog from "./components/views/dialogs/ErrorDialog"; import UploadFailureDialog from "./components/views/dialogs/UploadFailureDialog"; @@ -51,6 +50,7 @@ import UploadConfirmDialog from "./components/views/dialogs/UploadConfirmDialog" import { createThumbnail } from "./utils/image-media"; import { attachRelation } from "./components/views/rooms/SendMessageComposer"; import { doMaybeLocalRoomAction } from "./utils/local-room"; +import { SdkContextClass } from "./contexts/SDKContext"; // scraped out of a macOS hidpi (5660ppm) screenshot png // 5669 px (x-axis) , 5669 px (y-axis) , per metre @@ -361,7 +361,7 @@ export default class ContentMessages { return; } - const replyToEvent = RoomViewStore.instance.getQuotingEvent(); + const replyToEvent = SdkContextClass.instance.roomViewStore.getQuotingEvent(); if (!this.mediaConfig) { // hot-path optimization to not flash a spinner if we don't need to const modal = Modal.createDialog(Spinner, null, 'mx_Dialog_spinner'); await this.ensureMediaConfigFetched(matrixClient); diff --git a/src/Lifecycle.ts b/src/Lifecycle.ts index 64d1d9b5fdf..9351e91ae4d 100644 --- a/src/Lifecycle.ts +++ b/src/Lifecycle.ts @@ -39,7 +39,6 @@ import PlatformPeg from "./PlatformPeg"; import { sendLoginRequest } from "./Login"; import * as StorageManager from './utils/StorageManager'; import SettingsStore from "./settings/SettingsStore"; -import TypingStore from "./stores/TypingStore"; import ToastStore from "./stores/ToastStore"; import { IntegrationManagers } from "./integrations/IntegrationManagers"; import { Mjolnir } from "./mjolnir/Mjolnir"; @@ -62,6 +61,7 @@ import { DialogOpener } from "./utils/DialogOpener"; import { Action } from "./dispatcher/actions"; import AbstractLocalStorageSettingsHandler from "./settings/handlers/AbstractLocalStorageSettingsHandler"; import { OverwriteLoginPayload } from "./dispatcher/payloads/OverwriteLoginPayload"; +import { SdkContextClass } from './contexts/SDKContext'; const HOMESERVER_URL_KEY = "mx_hs_url"; const ID_SERVER_URL_KEY = "mx_is_url"; @@ -426,7 +426,7 @@ export async function restoreFromLocalStorage(opts?: { ignoreGuest?: boolean }): const { hsUrl, isUrl, hasAccessToken, accessToken, userId, deviceId, isGuest } = await getStoredSessionVars(); if (hasAccessToken && !accessToken) { - abortLogin(); + await abortLogin(); } if (accessToken && userId && hsUrl) { @@ -797,7 +797,7 @@ async function startMatrixClient(startSyncing = true): Promise { dis.dispatch({ action: 'will_start_client' }, true); // reset things first just in case - TypingStore.sharedInstance().reset(); + SdkContextClass.instance.typingStore.reset(); ToastStore.sharedInstance().reset(); DialogOpener.instance.prepare(); @@ -927,7 +927,7 @@ export function stopMatrixClient(unsetClient = true): void { Notifier.stop(); LegacyCallHandler.instance.stop(); UserActivity.sharedInstance().stop(); - TypingStore.sharedInstance().reset(); + SdkContextClass.instance.typingStore.reset(); Presence.stop(); ActiveWidgetStore.instance.stop(); IntegrationManagers.sharedInstance().stopWatching(); diff --git a/src/Login.ts b/src/Login.ts index c36f5770b92..4dc96bc17db 100644 --- a/src/Login.ts +++ b/src/Login.ts @@ -24,13 +24,6 @@ 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; } diff --git a/src/MatrixClientPeg.ts b/src/MatrixClientPeg.ts index 358a4f3decf..85a76ee4e10 100644 --- a/src/MatrixClientPeg.ts +++ b/src/MatrixClientPeg.ts @@ -249,6 +249,7 @@ class MatrixClientPegClass implements IMatrixClientPeg { this.matrixClient, proxyUrl || this.matrixClient.baseUrl, ); + SlidingSyncManager.instance.startSpidering(100, 50); // 100 rooms at a time, 50ms apart } // Connect the matrix client to the dispatcher and setting handlers diff --git a/src/Notifier.ts b/src/Notifier.ts index dd0ebc296a2..2f925699284 100644 --- a/src/Notifier.ts +++ b/src/Notifier.ts @@ -41,12 +41,12 @@ import SettingsStore from "./settings/SettingsStore"; import { hideToast as hideNotificationsToast } from "./toasts/DesktopNotificationsToast"; import { SettingLevel } from "./settings/SettingLevel"; import { isPushNotifyDisabled } from "./settings/controllers/NotificationControllers"; -import { RoomViewStore } from "./stores/RoomViewStore"; import UserActivity from "./UserActivity"; import { mediaFromMxc } from "./customisations/Media"; import ErrorDialog from "./components/views/dialogs/ErrorDialog"; import LegacyCallHandler from "./LegacyCallHandler"; import VoipUserMapper from "./VoipUserMapper"; +import { SdkContextClass } from "./contexts/SDKContext"; import { localNotificationsAreSilenced } from "./utils/notifications"; import { getIncomingCallToastKey, IncomingCallToast } from "./toasts/IncomingCallToast"; import ToastStore from "./stores/ToastStore"; @@ -161,8 +161,8 @@ export const Notifier = { return null; } - if (!content.url) { - logger.warn(`${roomId} has custom notification sound event, but no url key`); + if (typeof content.url !== "string") { + logger.warn(`${roomId} has custom notification sound event, but no url string`); return null; } @@ -435,7 +435,16 @@ export const Notifier = { if (actions?.notify) { this._performCustomEventHandling(ev); - if (RoomViewStore.instance.getRoomId() === room.roomId && + const store = SdkContextClass.instance.roomViewStore; + const isViewingRoom = store.getRoomId() === room.roomId; + const threadId: string | undefined = ev.getId() !== ev.threadRootId + ? ev.threadRootId + : undefined; + const isViewingThread = store.getThreadId() === threadId; + + const isViewingEventTimeline = isViewingRoom && (!threadId || isViewingThread); + + if (isViewingEventTimeline && UserActivity.sharedInstance().userActiveRecently() && !Modal.hasDialogs() ) { diff --git a/src/Roles.ts b/src/Roles.ts index ae0d316d301..77c50fe64c3 100644 --- a/src/Roles.ts +++ b/src/Roles.ts @@ -16,7 +16,7 @@ limitations under the License. import { _t } from './languageHandler'; -export function levelRoleMap(usersDefault: number) { +export function levelRoleMap(usersDefault: number): Record { return { undefined: _t('Default'), 0: _t('Restricted'), diff --git a/src/RoomNotifs.ts b/src/RoomNotifs.ts index 08c15970c56..6c1e07e66b2 100644 --- a/src/RoomNotifs.ts +++ b/src/RoomNotifs.ts @@ -78,15 +78,23 @@ export function setRoomNotifsState(roomId: string, newState: RoomNotifState): Pr } } -export function getUnreadNotificationCount(room: Room, type: NotificationCountType = null): number { - let notificationCount = room.getUnreadNotificationCount(type); +export function getUnreadNotificationCount( + room: Room, + type: NotificationCountType, + threadId?: string, +): number { + let notificationCount = (!!threadId + ? room.getThreadUnreadNotificationCount(threadId, type) + : room.getUnreadNotificationCount(type)); // Check notification counts in the old room just in case there's some lost // there. We only go one level down to avoid performance issues, and theory // is that 1st generation rooms will have already been read by the 3rd generation. const createEvent = room.currentState.getStateEvents(EventType.RoomCreate, ""); - if (createEvent && createEvent.getContent()['predecessor']) { - const oldRoomId = createEvent.getContent()['predecessor']['room_id']; + const predecessor = createEvent?.getContent().predecessor; + // Exclude threadId, as the same thread can't continue over a room upgrade + if (!threadId && predecessor) { + const oldRoomId = predecessor.room_id; const oldRoom = MatrixClientPeg.get().getRoom(oldRoomId); if (oldRoom) { // We only ever care if there's highlights in the old room. No point in diff --git a/src/ScalarMessaging.ts b/src/ScalarMessaging.ts index c511d291ce2..72ff94d4d3f 100644 --- a/src/ScalarMessaging.ts +++ b/src/ScalarMessaging.ts @@ -272,12 +272,12 @@ import { logger } from "matrix-js-sdk/src/logger"; import { MatrixClientPeg } from './MatrixClientPeg'; import dis from './dispatcher/dispatcher'; import WidgetUtils from './utils/WidgetUtils'; -import { RoomViewStore } from './stores/RoomViewStore'; import { _t } from './languageHandler'; import { IntegrationManagers } from "./integrations/IntegrationManagers"; import { WidgetType } from "./widgets/WidgetType"; import { objectClone } from "./utils/objects"; import { EffectiveMembership, getEffectiveMembership } from './utils/membership'; +import { SdkContextClass } from './contexts/SDKContext'; enum Action { CloseScalar = "close_scalar", @@ -721,7 +721,7 @@ const onMessage = function(event: MessageEvent): void { } } - if (roomId !== RoomViewStore.instance.getRoomId()) { + if (roomId !== SdkContextClass.instance.roomViewStore.getRoomId()) { sendError(event, _t('Room %(roomId)s not visible', { roomId: roomId })); return; } diff --git a/src/SdkConfig.ts b/src/SdkConfig.ts index 2a09e8bb36b..8e07db927d9 100644 --- a/src/SdkConfig.ts +++ b/src/SdkConfig.ts @@ -47,7 +47,7 @@ export const DEFAULTS: IConfigOptions = { url: "https://schildi.chat/desktop", }, voice_broadcast: { - chunk_length: 60, // one minute + chunk_length: 120, // two minutes }, }; diff --git a/src/SlashCommands.tsx b/src/SlashCommands.tsx index bbd936ce756..cce5c4ade40 100644 --- a/src/SlashCommands.tsx +++ b/src/SlashCommands.tsx @@ -62,7 +62,6 @@ import InfoDialog from "./components/views/dialogs/InfoDialog"; import SlashCommandHelpDialog from "./components/views/dialogs/SlashCommandHelpDialog"; import { shouldShowComponent } from "./customisations/helpers/UIComponents"; import { TimelineRenderingType } from './contexts/RoomContext'; -import { RoomViewStore } from "./stores/RoomViewStore"; import { XOR } from "./@types/common"; import { PosthogAnalytics } from "./PosthogAnalytics"; import { ViewRoomPayload } from "./dispatcher/payloads/ViewRoomPayload"; @@ -70,13 +69,14 @@ import VoipUserMapper from './VoipUserMapper'; import { htmlSerializeFromMdIfNeeded } from './editor/serialize'; import { leaveRoomBehaviour } from "./utils/leave-behaviour"; import { isLocalRoom } from './utils/localRoom/isLocalRoom'; +import { SdkContextClass } from './contexts/SDKContext'; // XXX: workaround for https://github.com/microsoft/TypeScript/issues/31816 interface HTMLInputEvent extends Event { target: HTMLInputElement & EventTarget; } -const singleMxcUpload = async (): Promise => { +const singleMxcUpload = async (): Promise => { return new Promise((resolve) => { const fileSelector = document.createElement('input'); fileSelector.setAttribute('type', 'file'); @@ -85,8 +85,13 @@ const singleMxcUpload = async (): Promise => { Modal.createDialog(UploadConfirmDialog, { file, - onFinished: (shouldContinue) => { - resolve(shouldContinue ? MatrixClientPeg.get().uploadContent(file) : null); + onFinished: async (shouldContinue) => { + if (shouldContinue) { + const { content_uri: uri } = await MatrixClientPeg.get().uploadContent(file); + resolve(uri); + } else { + resolve(null); + } }, }); }; @@ -209,7 +214,7 @@ function successSync(value: any) { const isCurrentLocalRoom = (): boolean => { const cli = MatrixClientPeg.get(); - const room = cli.getRoom(RoomViewStore.instance.getRoomId()); + const room = cli.getRoom(SdkContextClass.instance.roomViewStore.getRoomId()); return isLocalRoom(room); }; @@ -868,7 +873,7 @@ export const Commands = [ description: _td('Define the power level of a user'), isEnabled(): boolean { const cli = MatrixClientPeg.get(); - const room = cli.getRoom(RoomViewStore.instance.getRoomId()); + const room = cli.getRoom(SdkContextClass.instance.roomViewStore.getRoomId()); return room?.currentState.maySendStateEvent(EventType.RoomPowerLevels, cli.getUserId()) && !isLocalRoom(room); }, @@ -909,7 +914,7 @@ export const Commands = [ description: _td('Deops user with given id'), isEnabled(): boolean { const cli = MatrixClientPeg.get(); - const room = cli.getRoom(RoomViewStore.instance.getRoomId()); + const room = cli.getRoom(SdkContextClass.instance.roomViewStore.getRoomId()); return room?.currentState.maySendStateEvent(EventType.RoomPowerLevels, cli.getUserId()) && !isLocalRoom(room); }, diff --git a/src/SlidingSyncManager.ts b/src/SlidingSyncManager.ts index 0e5736465ea..3e79267ee2f 100644 --- a/src/SlidingSyncManager.ts +++ b/src/SlidingSyncManager.ts @@ -52,7 +52,7 @@ import { SlidingSync, } from 'matrix-js-sdk/src/sliding-sync'; import { logger } from "matrix-js-sdk/src/logger"; -import { IDeferred, defer } from 'matrix-js-sdk/src/utils'; +import { IDeferred, defer, sleep } from 'matrix-js-sdk/src/utils'; // how long to long poll for const SLIDING_SYNC_TIMEOUT_MS = 20 * 1000; @@ -63,6 +63,15 @@ const DEFAULT_ROOM_SUBSCRIPTION_INFO = { required_state: [ ["*", "*"], // all events ], + include_old_rooms: { + timeline_limit: 0, + required_state: [ // state needed to handle space navigation and tombstone chains + [EventType.RoomCreate, ""], + [EventType.RoomTombstone, ""], + [EventType.SpaceChild, "*"], + [EventType.SpaceParent, "*"], + ], + }, }; export type PartialSlidingSyncRequest = { @@ -100,6 +109,9 @@ export class SlidingSyncManager { public configure(client: MatrixClient, proxyUrl: string): SlidingSync { this.client = client; this.listIdToIndex = {}; + DEFAULT_ROOM_SUBSCRIPTION_INFO.include_old_rooms.required_state.push( + [EventType.RoomMember, client.getUserId()], + ); this.slidingSync = new SlidingSync( proxyUrl, [], DEFAULT_ROOM_SUBSCRIPTION_INFO, client, SLIDING_SYNC_TIMEOUT_MS, ); @@ -121,6 +133,16 @@ export class SlidingSyncManager { [EventType.SpaceParent, "*"], // all space parents [EventType.RoomMember, this.client.getUserId()!], // lets the client calculate that we are in fact in the room ], + include_old_rooms: { + timeline_limit: 0, + required_state: [ + [EventType.RoomCreate, ""], + [EventType.RoomTombstone, ""], // lets JS SDK hide rooms which are dead + [EventType.SpaceChild, "*"], // all space children + [EventType.SpaceParent, "*"], // all space parents + [EventType.RoomMember, this.client.getUserId()!], // lets the client calculate that we are in fact in the room + ], + }, filters: { room_types: ["m.space"], }, @@ -176,7 +198,7 @@ export class SlidingSyncManager { list = { ranges: [[0, 20]], sort: [ - "by_highlight_count", "by_notification_count", "by_recency", + "by_notification_level", "by_recency", ], timeline_limit: 1, // most recent message display: though this seems to only be needed for favourites? required_state: [ @@ -187,6 +209,16 @@ export class SlidingSyncManager { [EventType.RoomCreate, ""], // for isSpaceRoom checks [EventType.RoomMember, this.client.getUserId()], // lets the client calculate that we are in fact in the room ], + include_old_rooms: { + timeline_limit: 0, + required_state: [ + [EventType.RoomCreate, ""], + [EventType.RoomTombstone, ""], // lets JS SDK hide rooms which are dead + [EventType.SpaceChild, "*"], // all space children + [EventType.SpaceParent, "*"], // all space parents + [EventType.RoomMember, this.client.getUserId()!], // lets the client calculate that we are in fact in the room + ], + }, }; list = Object.assign(list, updateArgs); } else { @@ -233,4 +265,60 @@ export class SlidingSyncManager { } return roomId; } + + /** + * Retrieve all rooms on the user's account. Used for pre-populating the local search cache. + * Retrieval is gradual over time. + * @param batchSize The number of rooms to return in each request. + * @param gapBetweenRequestsMs The number of milliseconds to wait between requests. + */ + public async startSpidering(batchSize: number, gapBetweenRequestsMs: number) { + await sleep(gapBetweenRequestsMs); // wait a bit as this is called on first render so let's let things load + const listIndex = this.getOrAllocateListIndex(SlidingSyncManager.ListSearch); + let startIndex = batchSize; + let hasMore = true; + let firstTime = true; + while (hasMore) { + const endIndex = startIndex + batchSize-1; + try { + const ranges = [[0, batchSize-1], [startIndex, endIndex]]; + if (firstTime) { + await this.slidingSync.setList(listIndex, { + // e.g [0,19] [20,39] then [0,19] [40,59]. We keep [0,20] constantly to ensure + // any changes to the list whilst spidering are caught. + ranges: ranges, + sort: [ + "by_recency", // this list isn't shown on the UI so just sorting by timestamp is enough + ], + timeline_limit: 0, // we only care about the room details, not messages in the room + required_state: [ + [EventType.RoomJoinRules, ""], // the public icon on the room list + [EventType.RoomAvatar, ""], // any room avatar + [EventType.RoomTombstone, ""], // lets JS SDK hide rooms which are dead + [EventType.RoomEncryption, ""], // lets rooms be configured for E2EE correctly + [EventType.RoomCreate, ""], // for isSpaceRoom checks + [EventType.RoomMember, this.client.getUserId()!], // lets the client calculate that we are in fact in the room + ], + // we don't include_old_rooms here in an effort to reduce the impact of spidering all rooms + // on the user's account. This means some data in the search dialog results may be inaccurate + // e.g membership of space, but this will be corrected when the user clicks on the room + // as the direct room subscription does include old room iterations. + filters: { // we get spaces via a different list, so filter them out + not_room_types: ["m.space"], + }, + }); + } else { + await this.slidingSync.setListRanges(listIndex, ranges); + } + // gradually request more over time + await sleep(gapBetweenRequestsMs); + } catch (err) { + // do nothing, as we reject only when we get interrupted but that's fine as the next + // request will include our data + } + hasMore = (endIndex+1) < this.slidingSync.getListData(listIndex)?.joinedCount; + startIndex += batchSize; + firstTime = false; + } + } } diff --git a/src/Unread.ts b/src/Unread.ts index 1804ddefb71..60ef9ca19ed 100644 --- a/src/Unread.ts +++ b/src/Unread.ts @@ -23,7 +23,6 @@ import { MatrixClientPeg } from "./MatrixClientPeg"; import shouldHideEvent from './shouldHideEvent'; import { haveRendererForEvent } from "./events/EventTileFactory"; import SettingsStore from "./settings/SettingsStore"; -import { RoomNotificationStateStore } from "./stores/notifications/RoomNotificationStateStore"; /** * Returns true if this event arriving in a room should affect the room's @@ -77,11 +76,6 @@ export function doesRoomHaveUnreadMessages(room: Room): boolean { if (room.timeline.length && room.timeline[room.timeline.length - 1].getSender() === myUserId) { return false; } - } else { - const threadState = RoomNotificationStateStore.instance.getThreadsRoomState(room); - if (threadState.color > 0) { - return true; - } } // if the read receipt relates to an event is that part of a thread diff --git a/src/audio/PlaybackQueue.ts b/src/audio/PlaybackQueue.ts index 72ed8cf1691..c5a6ee64f29 100644 --- a/src/audio/PlaybackQueue.ts +++ b/src/audio/PlaybackQueue.ts @@ -25,7 +25,7 @@ import { MatrixClientPeg } from "../MatrixClientPeg"; import { arrayFastClone } from "../utils/arrays"; import { PlaybackManager } from "./PlaybackManager"; import { isVoiceMessage } from "../utils/EventUtils"; -import { RoomViewStore } from "../stores/RoomViewStore"; +import { SdkContextClass } from "../contexts/SDKContext"; /** * Audio playback queue management for a given room. This keeps track of where the user @@ -51,7 +51,7 @@ export class PlaybackQueue { constructor(private room: Room) { this.loadClocks(); - RoomViewStore.instance.addRoomListener(this.room.roomId, (isActive) => { + SdkContextClass.instance.roomViewStore.addRoomListener(this.room.roomId, (isActive) => { if (!isActive) return; // Reset the state of the playbacks before they start mounting and enqueuing updates. diff --git a/src/audio/VoiceRecording.ts b/src/audio/VoiceRecording.ts index 0e18756fe56..99f878868d5 100644 --- a/src/audio/VoiceRecording.ts +++ b/src/audio/VoiceRecording.ts @@ -60,6 +60,7 @@ export class VoiceRecording extends EventEmitter implements IDestroyable { private recorderProcessor: ScriptProcessorNode; private recording = false; private observable: SimpleObservable; + private targetMaxLength: number | null = TARGET_MAX_LENGTH; public amplitudes: number[] = []; // at each second mark, generated private liveWaveform = new FixedRollingArray(RECORDING_PLAYBACK_SAMPLES, 0); public onDataAvailable: (data: ArrayBuffer) => void; @@ -83,6 +84,10 @@ export class VoiceRecording extends EventEmitter implements IDestroyable { return true; // we don't ever care if the event had listeners, so just return "yes" } + public disableMaxLength(): void { + this.targetMaxLength = null; + } + private async makeRecorder() { try { this.recorderStream = await navigator.mediaDevices.getUserMedia({ @@ -203,6 +208,12 @@ 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. // + + if (!this.targetMaxLength) { + // skip time checks if max length has been disabled + return; + } + 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 diff --git a/src/components/atoms/Icon.tsx b/src/components/atoms/Icon.tsx deleted file mode 100644 index 56d8236250d..00000000000 --- a/src/components/atoms/Icon.tsx +++ /dev/null @@ -1,83 +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 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/FileDropTarget.tsx b/src/components/structures/FileDropTarget.tsx index f6572a05e85..a775017c201 100644 --- a/src/components/structures/FileDropTarget.tsx +++ b/src/components/structures/FileDropTarget.tsx @@ -19,7 +19,7 @@ import React, { useEffect, useState } from "react"; import { _t } from "../../languageHandler"; interface IProps { - parent: HTMLElement; + parent: HTMLElement | null; onFileDrop(dataTransfer: DataTransfer): void; } @@ -90,20 +90,20 @@ const FileDropTarget: React.FC = ({ parent, onFileDrop }) => { })); }; - parent.addEventListener("drop", onDrop); - parent.addEventListener("dragover", onDragOver); - parent.addEventListener("dragenter", onDragEnter); - parent.addEventListener("dragleave", onDragLeave); + parent?.addEventListener("drop", onDrop); + parent?.addEventListener("dragover", onDragOver); + parent?.addEventListener("dragenter", onDragEnter); + parent?.addEventListener("dragleave", onDragLeave); return () => { // disconnect the D&D event listeners from the room view. This // is really just for hygiene - we're going to be // deleted anyway, so it doesn't matter if the event listeners // don't get cleaned up. - parent.removeEventListener("drop", onDrop); - parent.removeEventListener("dragover", onDragOver); - parent.removeEventListener("dragenter", onDragEnter); - parent.removeEventListener("dragleave", onDragLeave); + parent?.removeEventListener("drop", onDrop); + parent?.removeEventListener("dragover", onDragOver); + parent?.removeEventListener("dragenter", onDragEnter); + parent?.removeEventListener("dragleave", onDragLeave); }; }, [parent, onFileDrop]); diff --git a/src/components/structures/LoggedInView.tsx b/src/components/structures/LoggedInView.tsx index 5b34c8602fe..379bda56a78 100644 --- a/src/components/structures/LoggedInView.tsx +++ b/src/components/structures/LoggedInView.tsx @@ -22,6 +22,7 @@ import classNames from 'classnames'; import { ISyncStateData, SyncState } from 'matrix-js-sdk/src/sync'; import { IUsageLimit } from 'matrix-js-sdk/src/@types/partials'; import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state"; +import { MatrixError } from 'matrix-js-sdk/src/matrix'; import { isOnlyCtrlOrCmdKeyEvent, Key } from '../../Keyboard'; import PageTypes from '../../PageTypes'; @@ -315,8 +316,8 @@ class LoggedInView extends React.Component { }; private onSync = (syncState: SyncState, oldSyncState?: SyncState, data?: ISyncStateData): void => { - const oldErrCode = this.state.syncErrorData?.error?.errcode; - const newErrCode = data && data.error && data.error.errcode; + const oldErrCode = (this.state.syncErrorData?.error as MatrixError)?.errcode; + const newErrCode = (data?.error as MatrixError)?.errcode; if (syncState === oldSyncState && oldErrCode === newErrCode) return; this.setState({ @@ -344,9 +345,9 @@ class LoggedInView extends React.Component { }; private calculateServerLimitToast(syncError: IState["syncErrorData"], usageLimitEventContent?: IUsageLimit) { - const error = syncError && syncError.error && syncError.error.errcode === "M_RESOURCE_LIMIT_EXCEEDED"; + const error = (syncError?.error as MatrixError)?.errcode === "M_RESOURCE_LIMIT_EXCEEDED"; if (error) { - usageLimitEventContent = syncError.error.data as IUsageLimit; + usageLimitEventContent = (syncError?.error as MatrixError).data as IUsageLimit; } // usageLimitDismissed is true when the user has explicitly hidden the toast diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index 31bfe71e6a4..832865451e8 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -24,7 +24,6 @@ import { MatrixEventEvent, } from 'matrix-js-sdk/src/matrix'; import { ISyncStateData, SyncState } from 'matrix-js-sdk/src/sync'; -import { MatrixError } from 'matrix-js-sdk/src/http-api'; import { InvalidStoreError } from "matrix-js-sdk/src/errors"; import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { defer, IDeferred, QueryDict } from "matrix-js-sdk/src/utils"; @@ -138,7 +137,9 @@ import { TimelineRenderingType } from "../../contexts/RoomContext"; import { UseCaseSelection } from '../views/elements/UseCaseSelection'; import { ValidatedServerConfig } from '../../utils/ValidatedServerConfig'; import { isLocalRoom } from '../../utils/localRoom/isLocalRoom'; +import { SdkContextClass, SDKContext } from '../../contexts/SDKContext'; import { viewUserDeviceSettings } from '../../actions/handlers/viewUserDeviceSettings'; +import { VoiceBroadcastResumer } from '../../voice-broadcast'; // legacy export export { default as Views } from "../../Views"; @@ -202,7 +203,7 @@ interface IState { // When showing Modal dialogs we need to set aria-hidden on the root app element // and disable it when there are no dialogs hideToSRUsers: boolean; - syncError?: MatrixError; + syncError?: Error; resizeNotifier: ResizeNotifier; serverConfig?: ValidatedServerConfig; ready: boolean; @@ -234,14 +235,18 @@ export default class MatrixChat extends React.PureComponent { private focusComposer: boolean; private subTitleStatus: string; private prevWindowWidth: number; + private voiceBroadcastResumer: VoiceBroadcastResumer; private readonly loggedInView: React.RefObject; private readonly dispatcherRef: string; private readonly themeWatcher: ThemeWatcher; private readonly fontWatcher: FontWatcher; + private readonly stores: SdkContextClass; constructor(props: IProps) { super(props); + this.stores = SdkContextClass.instance; + this.stores.constructEagerStores(); this.state = { view: Views.LOADING, @@ -430,6 +435,7 @@ export default class MatrixChat extends React.PureComponent { window.removeEventListener("resize", this.onWindowResized); if (this.accountPasswordTimer !== null) clearTimeout(this.accountPasswordTimer); + if (this.voiceBroadcastResumer) this.voiceBroadcastResumer.destroy(); } private onWindowResized = (): void => { @@ -763,6 +769,7 @@ export default class MatrixChat extends React.PureComponent { Modal.createDialog(DialPadModal, {}, "mx_Dialog_dialPadWrapper"); break; case Action.OnLoggedIn: + this.stores.client = MatrixClientPeg.get(); if ( // Skip this handling for token login as that always calls onLoggedIn itself !this.tokenLogin && @@ -1467,7 +1474,7 @@ export default class MatrixChat extends React.PureComponent { if (data.error instanceof InvalidStoreError) { Lifecycle.handleInvalidStoreError(data.error); } - this.setState({ syncError: data.error || {} as MatrixError }); + this.setState({ syncError: data.error }); } else if (this.state.syncError) { this.setState({ syncError: null }); } @@ -1631,6 +1638,8 @@ export default class MatrixChat extends React.PureComponent { }); } }); + + this.voiceBroadcastResumer = new VoiceBroadcastResumer(cli); } /** @@ -2105,7 +2114,9 @@ export default class MatrixChat extends React.PureComponent { } return - { view } + + { view } + ; } } diff --git a/src/components/structures/MessagePanel.tsx b/src/components/structures/MessagePanel.tsx index 669e73ebfd2..1483ae59f78 100644 --- a/src/components/structures/MessagePanel.tsx +++ b/src/components/structures/MessagePanel.tsx @@ -848,6 +848,13 @@ export default class MessagePanel extends React.Component { : room; const receipts: IReadReceiptProps[] = []; + + if (!receiptDestination) { + logger.debug("Discarding request, could not find the receiptDestination for event: " + + this.context.threadId); + return receipts; + } + receiptDestination.getReceiptsForEvent(event).forEach((r) => { if ( !r.userId || diff --git a/src/components/structures/RoomStatusBar.tsx b/src/components/structures/RoomStatusBar.tsx index d46ad12b50d..e7032525460 100644 --- a/src/components/structures/RoomStatusBar.tsx +++ b/src/components/structures/RoomStatusBar.tsx @@ -34,10 +34,12 @@ const STATUS_BAR_HIDDEN = 0; const STATUS_BAR_EXPANDED = 1; const STATUS_BAR_EXPANDED_LARGE = 2; -export function getUnsentMessages(room: Room): MatrixEvent[] { +export function getUnsentMessages(room: Room, threadId?: string): MatrixEvent[] { if (!room) { return []; } return room.getPendingEvents().filter(function(ev) { - return ev.status === EventStatus.NOT_SENT; + const isNotSent = ev.status === EventStatus.NOT_SENT; + const belongsToTheThread = threadId === ev.threadRootId; + return isNotSent && (!threadId || belongsToTheThread); }); } diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index d095f58e89a..d805d6f1b88 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -44,21 +44,18 @@ import { RoomPermalinkCreator } from '../../utils/permalinks/Permalinks'; import ResizeNotifier from '../../utils/ResizeNotifier'; import ContentMessages from '../../ContentMessages'; import Modal from '../../Modal'; -import LegacyCallHandler, { LegacyCallHandlerEvent } from '../../LegacyCallHandler'; +import { LegacyCallHandlerEvent } from '../../LegacyCallHandler'; import dis, { defaultDispatcher } from '../../dispatcher/dispatcher'; import * as Rooms from '../../Rooms'; import eventSearch, { searchPagination } from '../../Searching'; import MainSplit from './MainSplit'; import RightPanel from './RightPanel'; -import { RoomViewStore } from '../../stores/RoomViewStore'; import RoomScrollStateStore, { ScrollState } from '../../stores/RoomScrollStateStore'; import WidgetEchoStore from '../../stores/WidgetEchoStore'; import SettingsStore from "../../settings/SettingsStore"; import { Layout } from "../../settings/enums/Layout"; import AccessibleButton from "../views/elements/AccessibleButton"; -import RightPanelStore from "../../stores/right-panel/RightPanelStore"; import RoomContext, { TimelineRenderingType } from "../../contexts/RoomContext"; -import MatrixClientContext, { MatrixClientProps, withMatrixClientHOC } from "../../contexts/MatrixClientContext"; import { E2EStatus, shieldStatusForRoom } from '../../utils/ShieldUtils'; import { Action } from "../../dispatcher/actions"; import { IMatrixClientCreds } from "../../MatrixClientPeg"; @@ -76,12 +73,10 @@ import { IOOBData, IThreepidInvite } from "../../stores/ThreepidInviteStore"; import EffectsOverlay from "../views/elements/EffectsOverlay"; import { containsEmoji } from '../../effects/utils'; import { CHAT_EFFECTS } from '../../effects'; -import WidgetStore from "../../stores/WidgetStore"; import { CallView } from "../views/voip/CallView"; import { UPDATE_EVENT } from "../../stores/AsyncStore"; import Notifier from "../../Notifier"; import { showToast as showNotificationsToast } from "../../toasts/DesktopNotificationsToast"; -import { RoomNotificationStateStore } from "../../stores/notifications/RoomNotificationStateStore"; import { Container, WidgetLayoutStore } from "../../stores/widgets/WidgetLayoutStore"; import { getKeyBindingsManager } from '../../KeyBindingsManager'; import { objectHasDiff } from "../../utils/objects"; @@ -120,8 +115,8 @@ import { UserNameColorMode } from '../../settings/enums/UserNameColorMode'; import DMRoomMap from '../../utils/DMRoomMap'; import { RoomStatusBarUnsentMessages } from './RoomStatusBarUnsentMessages'; import { LargeLoader } from './LargeLoader'; -import { VoiceBroadcastInfoEventType } from '../../voice-broadcast'; import { isVideoRoom } from '../../utils/video-rooms'; +import { SDKContext } from '../../contexts/SDKContext'; import { CallStore, CallStoreEvent } from "../../stores/CallStore"; import { Call } from "../../models/Call"; @@ -135,7 +130,7 @@ if (DEBUG) { debuglog = logger.log.bind(console); } -interface IRoomProps extends MatrixClientProps { +interface IRoomProps { threepidInvite: IThreepidInvite; oobData?: IOOBData; @@ -205,7 +200,6 @@ export interface IRoomState { upgradeRecommendation?: IRecommendedVersion; canReact: boolean; canSendMessages: boolean; - canSendVoiceBroadcasts: boolean; tombstone?: MatrixEvent; resizing: boolean; layout: Layout; @@ -387,13 +381,13 @@ export class RoomView extends React.Component { private messagePanel: TimelinePanel; private roomViewBody = createRef(); - static contextType = MatrixClientContext; - public context!: React.ContextType; + static contextType = SDKContext; + public context!: React.ContextType; - constructor(props: IRoomProps, context: React.ContextType) { + constructor(props: IRoomProps, context: React.ContextType) { super(props, context); - const llMembers = context.hasLazyLoadMembersEnabled(); + const llMembers = context.client.hasLazyLoadMembersEnabled(); this.state = { roomId: null, roomLoading: true, @@ -414,7 +408,6 @@ export class RoomView extends React.Component { statusBarVisible: false, canReact: false, canSendMessages: false, - canSendVoiceBroadcasts: false, resizing: false, layout: SettingsStore.getValue("layout"), singleSideBubbles: SettingsStore.getValue("singleSideBubbles"), @@ -432,7 +425,7 @@ export class RoomView extends React.Component { showJoinLeaves: true, showAvatarChanges: true, showDisplaynameChanges: true, - matrixClientIsReady: context?.isInitialSyncComplete(), + matrixClientIsReady: context.client?.isInitialSyncComplete(), mainSplitContentType: MainSplitContentType.Timeline, timelineRenderingType: TimelineRenderingType.Room, liveTimeline: undefined, @@ -440,26 +433,26 @@ export class RoomView extends React.Component { }; this.dispatcherRef = dis.register(this.onAction); - context.on(ClientEvent.Room, this.onRoom); - context.on(RoomEvent.Timeline, this.onRoomTimeline); - context.on(RoomEvent.TimelineReset, this.onRoomTimelineReset); - context.on(RoomEvent.Name, this.onRoomName); - context.on(RoomStateEvent.Events, this.onRoomStateEvents); - context.on(RoomStateEvent.Update, this.onRoomStateUpdate); - context.on(RoomEvent.MyMembership, this.onMyMembership); - context.on(ClientEvent.AccountData, this.onAccountData); - context.on(CryptoEvent.KeyBackupStatus, this.onKeyBackupStatus); - context.on(CryptoEvent.DeviceVerificationChanged, this.onDeviceVerificationChanged); - context.on(CryptoEvent.UserTrustStatusChanged, this.onUserVerificationChanged); - context.on(CryptoEvent.KeysChanged, this.onCrossSigningKeysChanged); - context.on(MatrixEventEvent.Decrypted, this.onEventDecrypted); + context.client.on(ClientEvent.Room, this.onRoom); + context.client.on(RoomEvent.Timeline, this.onRoomTimeline); + context.client.on(RoomEvent.TimelineReset, this.onRoomTimelineReset); + context.client.on(RoomEvent.Name, this.onRoomName); + context.client.on(RoomStateEvent.Events, this.onRoomStateEvents); + context.client.on(RoomStateEvent.Update, this.onRoomStateUpdate); + context.client.on(RoomEvent.MyMembership, this.onMyMembership); + context.client.on(ClientEvent.AccountData, this.onAccountData); + context.client.on(CryptoEvent.KeyBackupStatus, this.onKeyBackupStatus); + context.client.on(CryptoEvent.DeviceVerificationChanged, this.onDeviceVerificationChanged); + context.client.on(CryptoEvent.UserTrustStatusChanged, this.onUserVerificationChanged); + context.client.on(CryptoEvent.KeysChanged, this.onCrossSigningKeysChanged); + context.client.on(MatrixEventEvent.Decrypted, this.onEventDecrypted); // Start listening for RoomViewStore updates - RoomViewStore.instance.on(UPDATE_EVENT, this.onRoomViewStoreUpdate); + context.roomViewStore.on(UPDATE_EVENT, this.onRoomViewStoreUpdate); - RightPanelStore.instance.on(UPDATE_EVENT, this.onRightPanelStoreUpdate); + context.rightPanelStore.on(UPDATE_EVENT, this.onRightPanelStoreUpdate); WidgetEchoStore.on(UPDATE_EVENT, this.onWidgetEchoStoreUpdate); - WidgetStore.instance.on(UPDATE_EVENT, this.onWidgetStoreUpdate); + context.widgetStore.on(UPDATE_EVENT, this.onWidgetStoreUpdate); CallStore.instance.on(CallStoreEvent.ActiveCalls, this.onActiveCalls); @@ -533,16 +526,16 @@ export class RoomView extends React.Component { action: "appsDrawer", show: true, }); - if (WidgetLayoutStore.instance.hasMaximisedWidget(this.state.room)) { + if (this.context.widgetLayoutStore.hasMaximisedWidget(this.state.room)) { // Show chat in right panel when a widget is maximised - RightPanelStore.instance.setCard({ phase: RightPanelPhases.Timeline }); + this.context.rightPanelStore.setCard({ phase: RightPanelPhases.Timeline }); } this.checkWidgets(this.state.room); }; private checkWidgets = (room: Room): void => { this.setState({ - hasPinnedWidgets: WidgetLayoutStore.instance.hasPinnedWidgets(room), + hasPinnedWidgets: this.context.widgetLayoutStore.hasPinnedWidgets(room), mainSplitContentType: this.getMainSplitContentType(room), showApps: this.shouldShowApps(room), }); @@ -550,12 +543,12 @@ export class RoomView extends React.Component { private getMainSplitContentType = (room: Room) => { if ( - (SettingsStore.getValue("feature_group_calls") && RoomViewStore.instance.isViewingCall()) + (SettingsStore.getValue("feature_group_calls") && this.context.roomViewStore.isViewingCall()) || isVideoRoom(room) ) { return MainSplitContentType.Call; } - if (WidgetLayoutStore.instance.hasMaximisedWidget(room)) { + if (this.context.widgetLayoutStore.hasMaximisedWidget(room)) { return MainSplitContentType.MaximisedWidget; } return MainSplitContentType.Timeline; @@ -566,7 +559,7 @@ export class RoomView extends React.Component { return; } - if (!initial && this.state.roomId !== RoomViewStore.instance.getRoomId()) { + if (!initial && this.state.roomId !== this.context.roomViewStore.getRoomId()) { // RoomView explicitly does not support changing what room // is being viewed: instead it should just be re-mounted when // switching rooms. Therefore, if the room ID changes, we @@ -581,45 +574,45 @@ export class RoomView extends React.Component { return; } - const roomId = RoomViewStore.instance.getRoomId(); - const room = this.context.getRoom(roomId); + const roomId = this.context.roomViewStore.getRoomId(); + const room = this.context.client.getRoom(roomId); // This convoluted type signature ensures we get IntelliSense *and* correct typing const newState: Partial & Pick = { roomId, - roomAlias: RoomViewStore.instance.getRoomAlias(), - roomLoading: RoomViewStore.instance.isRoomLoading(), - roomLoadError: RoomViewStore.instance.getRoomLoadError(), - joining: RoomViewStore.instance.isJoining(), - replyToEvent: RoomViewStore.instance.getQuotingEvent(), + roomAlias: this.context.roomViewStore.getRoomAlias(), + roomLoading: this.context.roomViewStore.isRoomLoading(), + roomLoadError: this.context.roomViewStore.getRoomLoadError(), + joining: this.context.roomViewStore.isJoining(), + replyToEvent: this.context.roomViewStore.getQuotingEvent(), // we should only peek once we have a ready client - shouldPeek: this.state.matrixClientIsReady && RoomViewStore.instance.shouldPeek(), + shouldPeek: this.state.matrixClientIsReady && this.context.roomViewStore.shouldPeek(), showReadReceipts: SettingsStore.getValue("showReadReceipts", roomId), showRedactions: SettingsStore.getValue("showRedactions", roomId), showJoinLeaves: SettingsStore.getValue("showJoinLeaves", roomId), showAvatarChanges: SettingsStore.getValue("showAvatarChanges", roomId), showDisplaynameChanges: SettingsStore.getValue("showDisplaynameChanges", roomId), - wasContextSwitch: RoomViewStore.instance.getWasContextSwitch(), + wasContextSwitch: this.context.roomViewStore.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), + showRightPanel: this.context.rightPanelStore.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)) + && this.context.rightPanelStore.isOpen + && this.context.rightPanelStore.currentCard.phase === RightPanelPhases.Timeline + && this.context.rightPanelStore.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); + this.context.rightPanelStore.setCard({ phase: RightPanelPhases.RoomSummary }); + this.context.rightPanelStore.togglePanel(this.state.roomId ?? null); newState.showRightPanel = false; } - const initialEventId = RoomViewStore.instance.getInitialEventId(); + const initialEventId = this.context.roomViewStore.getInitialEventId(); if (initialEventId) { let initialEvent = room?.findEventById(initialEventId); // The event does not exist in the current sync data @@ -632,7 +625,7 @@ export class RoomView extends React.Component { // becomes available to fetch a whole thread if (!initialEvent) { initialEvent = await fetchInitialEvent( - this.context, + this.context.client, roomId, initialEventId, ); @@ -648,21 +641,21 @@ export class RoomView extends React.Component { action: Action.ShowThread, rootEvent: thread.rootEvent, initialEvent, - highlighted: RoomViewStore.instance.isInitialEventHighlighted(), - scroll_into_view: RoomViewStore.instance.initialEventScrollIntoView(), + highlighted: this.context.roomViewStore.isInitialEventHighlighted(), + scroll_into_view: this.context.roomViewStore.initialEventScrollIntoView(), }); } else { newState.initialEventId = initialEventId; - newState.isInitialEventHighlighted = RoomViewStore.instance.isInitialEventHighlighted(); - newState.initialEventScrollIntoView = RoomViewStore.instance.initialEventScrollIntoView(); + newState.isInitialEventHighlighted = this.context.roomViewStore.isInitialEventHighlighted(); + newState.initialEventScrollIntoView = this.context.roomViewStore.initialEventScrollIntoView(); if (thread && initialEvent?.isThreadRoot) { dis.dispatch({ action: Action.ShowThread, rootEvent: thread.rootEvent, initialEvent, - highlighted: RoomViewStore.instance.isInitialEventHighlighted(), - scroll_into_view: RoomViewStore.instance.initialEventScrollIntoView(), + highlighted: this.context.roomViewStore.isInitialEventHighlighted(), + scroll_into_view: this.context.roomViewStore.initialEventScrollIntoView(), }); } } @@ -689,7 +682,7 @@ export class RoomView extends React.Component { if (!initial && this.state.shouldPeek && !newState.shouldPeek) { // Stop peeking because we have joined this room now - this.context.stopPeeking(); + this.context.client.stopPeeking(); } // Temporary logging to diagnose https://github.com/vector-im/element-web/issues/4307 @@ -706,7 +699,7 @@ export class RoomView extends React.Component { // NB: This does assume that the roomID will not change for the lifetime of // the RoomView instance if (initial) { - newState.room = this.context.getRoom(newState.roomId); + newState.room = this.context.client.getRoom(newState.roomId); if (newState.room) { newState.showApps = this.shouldShowApps(newState.room); this.onRoomLoaded(newState.room); @@ -816,7 +809,7 @@ export class RoomView extends React.Component { peekLoading: true, isPeeking: true, // this will change to false if peeking fails }); - this.context.peekInRoom(roomId).then((room) => { + this.context.client.peekInRoom(roomId).then((room) => { if (this.unmounted) { return; } @@ -849,7 +842,7 @@ export class RoomView extends React.Component { }); } else if (room) { // Stop peeking because we have joined this room previously - this.context.stopPeeking(); + this.context.client.stopPeeking(); this.setState({ isPeeking: false }); } } @@ -867,7 +860,7 @@ export class RoomView extends React.Component { // Otherwise (in case the user set hideWidgetDrawer by clicking the button) follow the parameter. const isManuallyShown = hideWidgetDrawer ? hideWidgetDrawer === "false": true; - const widgets = WidgetLayoutStore.instance.getContainerWidgets(room, Container.Top); + const widgets = this.context.widgetLayoutStore.getContainerWidgets(room, Container.Top); return isManuallyShown && widgets.length > 0; } @@ -880,7 +873,7 @@ export class RoomView extends React.Component { callState: callState, }); - LegacyCallHandler.instance.on(LegacyCallHandlerEvent.CallState, this.onCallState); + this.context.legacyCallHandler.on(LegacyCallHandlerEvent.CallState, this.onCallState); window.addEventListener('beforeunload', this.onPageUnload); if (this.props.resizeNotifier) { @@ -927,7 +920,7 @@ export class RoomView extends React.Component { // (We could use isMounted, but facebook have deprecated that.) this.unmounted = true; - LegacyCallHandler.instance.removeListener(LegacyCallHandlerEvent.CallState, this.onCallState); + this.context.legacyCallHandler.removeListener(LegacyCallHandlerEvent.CallState, this.onCallState); // update the scroll map before we get unmounted if (this.state.roomId) { @@ -935,7 +928,7 @@ export class RoomView extends React.Component { } if (this.state.shouldPeek) { - this.context.stopPeeking(); + this.context.client.stopPeeking(); } // stop tracking room changes to format permalinks @@ -943,19 +936,19 @@ export class RoomView extends React.Component { dis.unregister(this.dispatcherRef); if (this.context) { - this.context.removeListener(ClientEvent.Room, this.onRoom); - this.context.removeListener(RoomEvent.Timeline, this.onRoomTimeline); - this.context.removeListener(RoomEvent.TimelineReset, this.onRoomTimelineReset); - this.context.removeListener(RoomEvent.Name, this.onRoomName); - this.context.removeListener(RoomStateEvent.Events, this.onRoomStateEvents); - this.context.removeListener(RoomEvent.MyMembership, this.onMyMembership); - this.context.removeListener(RoomStateEvent.Update, this.onRoomStateUpdate); - this.context.removeListener(ClientEvent.AccountData, this.onAccountData); - this.context.removeListener(CryptoEvent.KeyBackupStatus, this.onKeyBackupStatus); - this.context.removeListener(CryptoEvent.DeviceVerificationChanged, this.onDeviceVerificationChanged); - this.context.removeListener(CryptoEvent.UserTrustStatusChanged, this.onUserVerificationChanged); - this.context.removeListener(CryptoEvent.KeysChanged, this.onCrossSigningKeysChanged); - this.context.removeListener(MatrixEventEvent.Decrypted, this.onEventDecrypted); + this.context.client.removeListener(ClientEvent.Room, this.onRoom); + this.context.client.removeListener(RoomEvent.Timeline, this.onRoomTimeline); + this.context.client.removeListener(RoomEvent.TimelineReset, this.onRoomTimelineReset); + this.context.client.removeListener(RoomEvent.Name, this.onRoomName); + this.context.client.removeListener(RoomStateEvent.Events, this.onRoomStateEvents); + this.context.client.removeListener(RoomEvent.MyMembership, this.onMyMembership); + this.context.client.removeListener(RoomStateEvent.Update, this.onRoomStateUpdate); + this.context.client.removeListener(ClientEvent.AccountData, this.onAccountData); + this.context.client.removeListener(CryptoEvent.KeyBackupStatus, this.onKeyBackupStatus); + this.context.client.removeListener(CryptoEvent.DeviceVerificationChanged, this.onDeviceVerificationChanged); + this.context.client.removeListener(CryptoEvent.UserTrustStatusChanged, this.onUserVerificationChanged); + this.context.client.removeListener(CryptoEvent.KeysChanged, this.onCrossSigningKeysChanged); + this.context.client.removeListener(MatrixEventEvent.Decrypted, this.onEventDecrypted); } window.removeEventListener('beforeunload', this.onPageUnload); @@ -963,23 +956,23 @@ export class RoomView extends React.Component { this.props.resizeNotifier.removeListener("middlePanelResized", this.onResize); } - RoomViewStore.instance.off(UPDATE_EVENT, this.onRoomViewStoreUpdate); + this.context.roomViewStore.off(UPDATE_EVENT, this.onRoomViewStoreUpdate); - RightPanelStore.instance.off(UPDATE_EVENT, this.onRightPanelStoreUpdate); + this.context.rightPanelStore.off(UPDATE_EVENT, this.onRightPanelStoreUpdate); WidgetEchoStore.removeListener(UPDATE_EVENT, this.onWidgetEchoStoreUpdate); - WidgetStore.instance.removeListener(UPDATE_EVENT, this.onWidgetStoreUpdate); + this.context.widgetStore.removeListener(UPDATE_EVENT, this.onWidgetStoreUpdate); this.props.resizeNotifier.off("isResizing", this.onIsResizing); if (this.state.room) { - WidgetLayoutStore.instance.off( + this.context.widgetLayoutStore.off( WidgetLayoutStore.emissionForRoom(this.state.room), this.onWidgetLayoutChange, ); } CallStore.instance.off(CallStoreEvent.ActiveCalls, this.onActiveCalls); - LegacyCallHandler.instance.off(LegacyCallHandlerEvent.CallState, this.onCallState); + this.context.legacyCallHandler.off(LegacyCallHandlerEvent.CallState, this.onCallState); // cancel any pending calls to the throttled updated this.updateRoomMembers.cancel(); @@ -990,13 +983,13 @@ export class RoomView extends React.Component { if (this.viewsLocalRoom) { // clean up if this was a local room - this.props.mxClient.store.removeRoom(this.state.room.roomId); + this.context.client.store.removeRoom(this.state.room.roomId); } } private onRightPanelStoreUpdate = () => { this.setState({ - showRightPanel: RightPanelStore.instance.isOpenForRoom(this.state.roomId), + showRightPanel: this.context.rightPanelStore.isOpenForRoom(this.state.roomId), }); }; @@ -1063,7 +1056,7 @@ export class RoomView extends React.Component { break; case 'picture_snapshot': ContentMessages.sharedInstance().sendContentListToRoom( - [payload.file], this.state.room.roomId, null, this.context); + [payload.file], this.state.room.roomId, null, this.context.client); break; case 'notifier_enabled': case Action.UploadStarted: @@ -1089,7 +1082,7 @@ export class RoomView extends React.Component { case 'MatrixActions.sync': if (!this.state.matrixClientIsReady) { this.setState({ - matrixClientIsReady: this.context?.isInitialSyncComplete(), + matrixClientIsReady: this.context.client?.isInitialSyncComplete(), }, () => { // send another "initial" RVS update to trigger peeking if needed this.onRoomViewStoreUpdate(true); @@ -1158,7 +1151,7 @@ export class RoomView extends React.Component { private onLocalRoomEvent(roomId: string) { if (roomId !== this.state.room.roomId) return; - createRoomFromLocalRoom(this.props.mxClient, this.state.room as LocalRoom); + createRoomFromLocalRoom(this.context.client, this.state.room as LocalRoom); } private onRoomTimeline = (ev: MatrixEvent, room: Room | null, toStartOfTimeline: boolean, removed, data) => { @@ -1191,7 +1184,7 @@ export class RoomView extends React.Component { this.handleEffects(ev); } - if (ev.getSender() !== this.context.credentials.userId) { + if (ev.getSender() !== this.context.client.credentials.userId) { // update unread count when scrolled up if (!this.state.searchResults && this.state.atEndOfLiveTimeline) { // no change @@ -1216,7 +1209,7 @@ export class RoomView extends React.Component { }; private handleEffects = (ev: MatrixEvent) => { - const notifState = RoomNotificationStateStore.instance.getRoomState(this.state.room); + const notifState = this.context.roomNotificationStateStore.getRoomState(this.state.room); if (!notifState.isUnread) return; CHAT_EFFECTS.forEach(effect => { @@ -1281,7 +1274,7 @@ export class RoomView extends React.Component { private onRoomLoaded = (room: Room) => { if (this.unmounted) return; // Attach a widget store listener only when we get a room - WidgetLayoutStore.instance.on(WidgetLayoutStore.emissionForRoom(room), this.onWidgetLayoutChange); + this.context.widgetLayoutStore.on(WidgetLayoutStore.emissionForRoom(room), this.onWidgetLayoutChange); this.calculatePeekRules(room); this.updatePreviewUrlVisibility(room); @@ -1293,10 +1286,10 @@ export class RoomView extends React.Component { if ( this.getMainSplitContentType(room) !== MainSplitContentType.Timeline - && RoomNotificationStateStore.instance.getRoomState(room).isUnread + && this.context.roomNotificationStateStore.getRoomState(room).isUnread ) { // Automatically open the chat panel to make unread messages easier to discover - RightPanelStore.instance.setCard({ phase: RightPanelPhases.Timeline }, true, room.roomId); + this.context.rightPanelStore.setCard({ phase: RightPanelPhases.Timeline }, true, room.roomId); } this.setState({ @@ -1323,7 +1316,7 @@ export class RoomView extends React.Component { private async loadMembersIfJoined(room: Room) { // lazy load members if enabled - if (this.context.hasLazyLoadMembersEnabled()) { + if (this.context.client.hasLazyLoadMembersEnabled()) { if (room && room.getMyMembership() === 'join') { try { await room.loadMembersIfNeeded(); @@ -1349,7 +1342,7 @@ export class RoomView extends React.Component { private updatePreviewUrlVisibility({ roomId }: Room) { // URL Previews in E2EE rooms can be a privacy leak so use a different setting which is per-room explicit - const key = this.context.isRoomEncrypted(roomId) ? 'urlPreviewsEnabled_e2ee' : 'urlPreviewsEnabled'; + const key = this.context.client.isRoomEncrypted(roomId) ? 'urlPreviewsEnabled_e2ee' : 'urlPreviewsEnabled'; this.setState({ showUrlPreview: SettingsStore.getValue(key, roomId), }); @@ -1362,7 +1355,7 @@ export class RoomView extends React.Component { // Detach the listener if the room is changing for some reason if (this.state.room) { - WidgetLayoutStore.instance.off( + this.context.widgetLayoutStore.off( WidgetLayoutStore.emissionForRoom(this.state.room), this.onWidgetLayoutChange, ); @@ -1399,15 +1392,15 @@ export class RoomView extends React.Component { }; private async updateE2EStatus(room: Room) { - if (!this.context.isRoomEncrypted(room.roomId)) return; + if (!this.context.client.isRoomEncrypted(room.roomId)) return; // If crypto is not currently enabled, we aren't tracking devices at all, // so we don't know what the answer is. Let's error on the safe side and show // a warning for this case. let e2eStatus = E2EStatus.Warning; - if (this.context.isCryptoEnabled()) { + if (this.context.client.isCryptoEnabled()) { /* At this point, the user has encryption on and cross-signing on */ - e2eStatus = await shieldStatusForRoom(this.context, room); + e2eStatus = await shieldStatusForRoom(this.context.client, room); } if (this.unmounted) return; @@ -1462,19 +1455,17 @@ export class RoomView extends React.Component { private updatePermissions(room: Room) { if (room) { - const me = this.context.getUserId(); + const me = this.context.client.getUserId(); const canReact = ( room.getMyMembership() === "join" && room.currentState.maySendEvent(EventType.Reaction, me) ); const canSendMessages = room.maySendMessage(); const canSelfRedact = room.currentState.maySendEvent(EventType.RoomRedaction, me); - const canSendVoiceBroadcasts = room.currentState.maySendEvent(VoiceBroadcastInfoEventType, me); this.setState({ canReact, canSendMessages, - canSendVoiceBroadcasts, canSelfRedact, }); } @@ -1530,7 +1521,7 @@ export class RoomView extends React.Component { private onJoinButtonClicked = () => { // If the user is a ROU, allow them to transition to a PWLU - if (this.context?.isGuest()) { + if (this.context.client?.isGuest()) { // Join this room once the user has registered and logged in // (If we failed to peek, we may not have a valid room object.) dis.dispatch>({ @@ -1587,13 +1578,13 @@ export class RoomView extends React.Component { }; private injectSticker(url: string, info: object, text: string, threadId: string | null) { - if (this.context.isGuest()) { + if (this.context.client.isGuest()) { dis.dispatch({ action: 'require_registration' }); return; } ContentMessages.sharedInstance() - .sendStickerContentToRoom(url, this.state.room.roomId, threadId, info, text, this.context) + .sendStickerContentToRoom(url, this.state.room.roomId, threadId, info, text, this.context.client) .then(undefined, (error) => { if (error.name === "UnknownDeviceError") { // Let the staus bar handle this @@ -1666,7 +1657,7 @@ export class RoomView extends React.Component { return b.length - a.length; }); - if (this.context.supportsExperimentalThreads()) { + if (this.context.client.supportsExperimentalThreads()) { // Process all thread roots returned in this batch of search results // XXX: This won't work for results coming from Seshat which won't include the bundled relationship for (const result of results.results) { @@ -1674,7 +1665,7 @@ export class RoomView extends React.Component { const bundledRelationship = event .getServerAggregatedRelation(THREAD_RELATION_TYPE.name); if (!bundledRelationship || event.getThread()) continue; - const room = this.context.getRoom(event.getRoomId()); + const room = this.context.client.getRoom(event.getRoomId()); const thread = room.findThreadForEvent(event); if (thread) { event.setThread(thread); @@ -1746,7 +1737,7 @@ export class RoomView extends React.Component { const mxEv = result.context.getEvent(); const roomId = mxEv.getRoomId(); - const room = this.context.getRoom(roomId); + const room = this.context.client.getRoom(roomId); if (!room) { // if we do not have the room in js-sdk stores then hide it as we cannot easily show it // As per the spec, an all rooms search can create this condition, @@ -1806,7 +1797,7 @@ export class RoomView extends React.Component { this.setState({ rejecting: true, }); - this.context.leave(this.state.roomId).then(() => { + this.context.client.leave(this.state.roomId).then(() => { dis.dispatch({ action: Action.ViewHomePage }); this.setState({ rejecting: false, @@ -1833,13 +1824,13 @@ export class RoomView extends React.Component { }); try { - const myMember = this.state.room.getMember(this.context.getUserId()); + const myMember = this.state.room.getMember(this.context.client.getUserId()); const inviteEvent = myMember.events.member; - const ignoredUsers = this.context.getIgnoredUsers(); + const ignoredUsers = this.context.client.getIgnoredUsers(); ignoredUsers.push(inviteEvent.getSender()); // de-duped internally in the js-sdk - await this.context.setIgnoredUsers(ignoredUsers); + await this.context.client.setIgnoredUsers(ignoredUsers); - await this.context.leave(this.state.roomId); + await this.context.client.leave(this.state.roomId); dis.dispatch({ action: Action.ViewHomePage }); this.setState({ rejecting: false, @@ -2021,7 +2012,7 @@ export class RoomView extends React.Component { if (!this.state.room) { return null; } - return LegacyCallHandler.instance.getCallForRoom(this.state.room.roomId); + return this.context.legacyCallHandler.getCallForRoom(this.state.room.roomId); } // this has to be a proper method rather than an unnamed function, @@ -2034,7 +2025,7 @@ export class RoomView extends React.Component { const createEvent = this.state.room.currentState.getStateEvents(EventType.RoomCreate, ""); if (!createEvent || !createEvent.getContent()['predecessor']) return null; - return this.context.getRoom(createEvent.getContent()['predecessor']['room_id']); + return this.context.client.getRoom(createEvent.getContent()['predecessor']['room_id']); } getHiddenHighlightCount() { @@ -2065,7 +2056,7 @@ export class RoomView extends React.Component { Array.from(dataTransfer.files), this.state.room?.roomId ?? this.state.roomId, null, - this.context, + this.context.client, TimelineRenderingType.Room, ); @@ -2082,7 +2073,7 @@ export class RoomView extends React.Component { } private renderLocalRoomCreateLoader(): ReactElement { - const names = this.state.room.getDefaultRoomName(this.props.mxClient.getUserId()); + const names = this.state.room.getDefaultRoomName(this.context.client.getUserId()); return { ); } else { - const myUserId = this.context.credentials.userId; + const myUserId = this.context.client.credentials.userId; const myMember = this.state.room.getMember(myUserId); const inviteEvent = myMember ? myMember.events.member : null; let inviterName = _t("Unknown"); @@ -2274,7 +2265,7 @@ export class RoomView extends React.Component { const showRoomUpgradeBar = ( roomVersionRecommendation && roomVersionRecommendation.needsUpgrade && - this.state.room.userMayUpgradeRoom(this.context.credentials.userId) + this.state.room.userMayUpgradeRoom(this.context.client.credentials.userId) ); const hiddenHighlightCount = this.getHiddenHighlightCount(); @@ -2286,7 +2277,7 @@ export class RoomView extends React.Component { searchInProgress={this.state.searchInProgress} onCancelClick={this.onCancelSearchClick} onSearch={this.onSearch} - isRoomEncrypted={this.context.isRoomEncrypted(this.state.room.roomId)} + isRoomEncrypted={this.context.client.isRoomEncrypted(this.state.room.roomId)} />; } else if (showRoomUpgradeBar) { aux = ; @@ -2348,7 +2339,7 @@ export class RoomView extends React.Component { const auxPanel = ( { permalinkCreator={this.permalinkCreator} layout={this.state.layout} userNameColorMode={this.state.userNameColorMode} - showVoiceBroadcastButton={this.state.canSendVoiceBroadcasts} />; } @@ -2516,7 +2506,7 @@ export class RoomView extends React.Component { mainSplitBody = <> @@ -2570,7 +2560,7 @@ export class RoomView extends React.Component { onAppsClick = null; onForgetClick = null; onSearchClick = null; - if (this.state.room.canInvite(this.context.credentials.userId)) { + if (this.state.room.canInvite(this.context.client.credentials.userId)) { onInviteClick = this.onInviteClick; } viewingCall = true; @@ -2612,5 +2602,4 @@ export class RoomView extends React.Component { } } -const RoomViewWithMatrixClient = withMatrixClientHOC(RoomView); -export default RoomViewWithMatrixClient; +export default RoomView; diff --git a/src/components/structures/SpaceHierarchy.tsx b/src/components/structures/SpaceHierarchy.tsx index 4b3fe3683a0..956ae8d04cf 100644 --- a/src/components/structures/SpaceHierarchy.tsx +++ b/src/components/structures/SpaceHierarchy.tsx @@ -60,13 +60,13 @@ import MatrixClientContext from "../../contexts/MatrixClientContext"; import { useTypedEventEmitterState } from "../../hooks/useEventEmitter"; import { IOOBData } from "../../stores/ThreepidInviteStore"; import { awaitRoomDownSync } from "../../utils/RoomUpgrade"; -import { RoomViewStore } from "../../stores/RoomViewStore"; import { ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload"; import { JoinRoomReadyPayload } from "../../dispatcher/payloads/JoinRoomReadyPayload"; import { KeyBindingAction } from "../../accessibility/KeyboardShortcuts"; import { getKeyBindingsManager } from "../../KeyBindingsManager"; import { Alignment } from "../views/elements/Tooltip"; import { getTopic } from "../../hooks/room/useTopic"; +import { SdkContextClass } from "../../contexts/SDKContext"; interface IProps { space: Room; @@ -378,7 +378,7 @@ export const joinRoom = (cli: MatrixClient, hierarchy: RoomHierarchy, roomId: st metricsTrigger: "SpaceHierarchy", }); }, err => { - RoomViewStore.instance.showJoinRoomError(err, roomId); + SdkContextClass.instance.roomViewStore.showJoinRoomError(err, roomId); }); return prom; diff --git a/src/components/structures/ThreadView.tsx b/src/components/structures/ThreadView.tsx index aa8bc00c1ba..c483a0c69d1 100644 --- a/src/components/structures/ThreadView.tsx +++ b/src/components/structures/ThreadView.tsx @@ -16,11 +16,8 @@ limitations under the License. import React, { createRef, KeyboardEvent } from 'react'; import { Thread, THREAD_RELATION_TYPE, ThreadEvent } from 'matrix-js-sdk/src/models/thread'; -import { Room } from 'matrix-js-sdk/src/models/room'; +import { Room, RoomEvent } from 'matrix-js-sdk/src/models/room'; import { IEventRelation, MatrixEvent } from 'matrix-js-sdk/src/models/event'; -import { TimelineWindow } from 'matrix-js-sdk/src/timeline-window'; -import { Direction } from 'matrix-js-sdk/src/models/event-timeline'; -import { IRelationsRequestOpts } from 'matrix-js-sdk/src/@types/requests'; import { logger } from 'matrix-js-sdk/src/logger'; import classNames from 'classnames'; @@ -51,10 +48,11 @@ import { KeyBindingAction } from "../../accessibility/KeyboardShortcuts"; import Measured from '../views/elements/Measured'; import PosthogTrackers from "../../PosthogTrackers"; import { ButtonEvent } from "../views/elements/AccessibleButton"; -import { RoomViewStore } from '../../stores/RoomViewStore'; import Spinner from "../views/elements/Spinner"; import { ComposerInsertPayload, ComposerType } from "../../dispatcher/payloads/ComposerInsertPayload"; import Heading from '../views/typography/Heading'; +import { SdkContextClass } from '../../contexts/SDKContext'; +import { ThreadPayload } from '../../dispatcher/payloads/ThreadPayload'; interface IProps { room: Room; @@ -70,6 +68,7 @@ interface IProps { interface IState { thread?: Thread; + lastReply?: MatrixEvent | null; layout: Layout; editState?: EditorStateTransfer; replyToEvent?: MatrixEvent; @@ -88,9 +87,16 @@ export default class ThreadView extends React.Component { constructor(props: IProps) { super(props); + const thread = this.props.room.getThread(this.props.mxEvent.getId()); + + this.setupThreadListeners(thread); this.state = { layout: SettingsStore.getValue("layout"), narrow: false, + thread, + lastReply: thread?.lastReply((ev: MatrixEvent) => { + return ev.isRelation(THREAD_RELATION_TYPE.name) && !ev.status; + }), }; this.layoutWatcherRef = SettingsStore.watchSetting("layout", null, (...[,,, value]) => @@ -99,6 +105,9 @@ export default class ThreadView extends React.Component { } public componentDidMount(): void { + if (this.state.thread) { + this.postThreadUpdate(this.state.thread); + } this.setupThread(this.props.mxEvent); this.dispatcherRef = dis.register(this.onAction); @@ -113,7 +122,7 @@ export default class ThreadView extends React.Component { room.removeListener(ThreadEvent.New, this.onNewThread); SettingsStore.unwatchSetting(this.layoutWatcherRef); - const hasRoomChanged = RoomViewStore.instance.getRoomId() !== roomId; + const hasRoomChanged = SdkContextClass.instance.roomViewStore.getRoomId() !== roomId; if (this.props.isInitialEventHighlighted && !hasRoomChanged) { dis.dispatch({ action: Action.ViewRoom, @@ -121,6 +130,11 @@ export default class ThreadView extends React.Component { metricsTrigger: undefined, // room doesn't change }); } + + dis.dispatch({ + action: Action.ViewThread, + thread_id: null, + }); } public componentDidUpdate(prevProps) { @@ -189,19 +203,51 @@ export default class ThreadView extends React.Component { } }; + private updateThreadRelation = (): void => { + this.setState({ + lastReply: this.threadLastReply, + }); + }; + + private get threadLastReply(): MatrixEvent | undefined { + return this.state.thread?.lastReply((ev: MatrixEvent) => { + return ev.isRelation(THREAD_RELATION_TYPE.name) && !ev.status; + }); + } + private updateThread = (thread?: Thread) => { - if (thread && this.state.thread !== thread) { + if (this.state.thread === thread) return; + + this.setupThreadListeners(thread, this.state.thread); + if (thread) { this.setState({ thread, - }, async () => { - thread.emit(ThreadEvent.ViewThread); - await thread.fetchInitialEvents(); - this.nextBatch = thread.liveTimeline.getPaginationToken(Direction.Backward); - this.timelinePanel.current?.refreshTimeline(); - }); + lastReply: this.threadLastReply, + }, async () => this.postThreadUpdate(thread)); } }; + private async postThreadUpdate(thread: Thread): Promise { + dis.dispatch({ + action: Action.ViewThread, + thread_id: thread.id, + }); + thread.emit(ThreadEvent.ViewThread); + this.updateThreadRelation(); + this.timelinePanel.current?.refreshTimeline(this.props.initialEvent?.getId()); + } + + private setupThreadListeners(thread?: Thread | undefined, oldThread?: Thread | undefined): void { + if (oldThread) { + this.state.thread.off(ThreadEvent.NewReply, this.updateThreadRelation); + this.props.room.off(RoomEvent.LocalEchoUpdated, this.updateThreadRelation); + } + if (thread) { + thread.on(ThreadEvent.NewReply, this.updateThreadRelation); + this.props.room.on(RoomEvent.LocalEchoUpdated, this.updateThreadRelation); + } + } + private resetJumpToEvent = (event?: string): void => { if (this.props.initialEvent && this.props.initialEventScrollIntoView && event === this.props.initialEvent?.getId()) { @@ -242,60 +288,36 @@ export default class ThreadView extends React.Component { } }; - private nextBatch: string; - - private onPaginationRequest = async ( - timelineWindow: TimelineWindow | null, - direction = Direction.Backward, - limit = 20, - ): Promise => { - if (!Thread.hasServerSideSupport) { - timelineWindow.extend(direction, limit); - return true; - } - - const opts: IRelationsRequestOpts = { - limit, - }; - - if (this.nextBatch) { - opts.from = this.nextBatch; - } - - const { nextBatch } = await this.state.thread.fetchEvents(opts); - - this.nextBatch = nextBatch; - - // Advances the marker on the TimelineWindow to define the correct - // window of events to display on screen - timelineWindow.extend(direction, limit); - - return !!nextBatch; - }; - private onFileDrop = (dataTransfer: DataTransfer) => { - ContentMessages.sharedInstance().sendContentListToRoom( - Array.from(dataTransfer.files), - this.props.mxEvent.getRoomId(), - this.threadRelation, - MatrixClientPeg.get(), - TimelineRenderingType.Thread, - ); + const roomId = this.props.mxEvent.getRoomId(); + if (roomId) { + ContentMessages.sharedInstance().sendContentListToRoom( + Array.from(dataTransfer.files), + roomId, + this.threadRelation, + MatrixClientPeg.get(), + TimelineRenderingType.Thread, + ); + } else { + console.warn("Unknwon roomId for event", this.props.mxEvent); + } }; private get threadRelation(): IEventRelation { - const lastThreadReply = this.state.thread?.lastReply((ev: MatrixEvent) => { - return ev.isRelation(THREAD_RELATION_TYPE.name) && !ev.status; - }); - - return { + const relation = { "rel_type": THREAD_RELATION_TYPE.name, "event_id": this.state.thread?.id, "is_falling_back": true, - "m.in_reply_to": { - "event_id": lastThreadReply?.getId() ?? this.state.thread?.id, - }, }; + + const fallbackEventId = this.state.lastReply?.getId() ?? this.state.thread?.id; + if (fallbackEventId) { + relation["m.in_reply_to"] = { + "event_id": fallbackEventId, + }; + } + + return relation; } private renderThreadViewHeader = (): JSX.Element => { @@ -314,7 +336,7 @@ export default class ThreadView extends React.Component { const threadRelation = this.threadRelation; - let timeline: JSX.Element; + let timeline: JSX.Element | null; if (this.state.thread) { if (this.props.initialEvent && this.props.initialEvent.getRoomId() !== this.state.thread.roomId) { logger.warn("ThreadView attempting to render TimelinePanel with mismatched initialEvent", @@ -348,7 +370,6 @@ export default class ThreadView extends React.Component { highlightedEventId={highlightedEventId} eventScrollIntoView={this.props.initialEventScrollIntoView} onEventScrolledIntoView={this.resetJumpToEvent} - onPaginationRequest={this.onPaginationRequest} /> ; } else { diff --git a/src/components/structures/TimelinePanel.tsx b/src/components/structures/TimelinePanel.tsx index 25d0080febc..ec924fb75f6 100644 --- a/src/components/structures/TimelinePanel.tsx +++ b/src/components/structures/TimelinePanel.tsx @@ -1435,24 +1435,28 @@ class TimelinePanel extends React.Component { // quite slow. So we detect that situation and shortcut straight to // calling _reloadEvents and updating the state. - const timeline = this.props.timelineSet.getTimelineForEvent(eventId); - if (timeline) { - // This is a hot-path optimization by skipping a promise tick - // by repeating a no-op sync branch in TimelineSet.getTimelineForEvent & MatrixClient.getEventTimeline - this.timelineWindow.load(eventId, INITIAL_SIZE); // in this branch this method will happen in sync time + // This is a hot-path optimization by skipping a promise tick + // by repeating a no-op sync branch in + // TimelineSet.getTimelineForEvent & MatrixClient.getEventTimeline + if (this.props.timelineSet.getTimelineForEvent(eventId)) { + // if we've got an eventId, and the timeline exists, we can skip + // the promise tick. + this.timelineWindow.load(eventId, INITIAL_SIZE); + // in this branch this method will happen in sync time onLoaded(); - } else { - const prom = this.timelineWindow.load(eventId, INITIAL_SIZE); - this.buildLegacyCallEventGroupers(); - this.setState({ - events: [], - liveEvents: [], - canBackPaginate: false, - canForwardPaginate: false, - timelineLoading: true, - }); - prom.then(onLoaded, onError); + return; } + + const prom = this.timelineWindow.load(eventId, INITIAL_SIZE); + this.buildLegacyCallEventGroupers(); + this.setState({ + events: [], + liveEvents: [], + canBackPaginate: false, + canForwardPaginate: false, + timelineLoading: true, + }); + prom.then(onLoaded, onError); } // handle the completion of a timeline load or localEchoUpdate, by @@ -1469,8 +1473,8 @@ class TimelinePanel extends React.Component { } // Force refresh the timeline before threads support pending events - public refreshTimeline(): void { - this.loadTimeline(); + public refreshTimeline(eventId?: string): void { + this.loadTimeline(eventId, undefined, undefined, false); this.reloadEvents(); } diff --git a/src/components/structures/auth/Login.tsx b/src/components/structures/auth/Login.tsx index c9fc7e001d9..7c1564c9d94 100644 --- a/src/components/structures/auth/Login.tsx +++ b/src/components/structures/auth/Login.tsx @@ -18,9 +18,10 @@ import React, { ReactNode } from 'react'; import { ConnectionError, MatrixError } from "matrix-js-sdk/src/http-api"; import classNames from "classnames"; import { logger } from "matrix-js-sdk/src/logger"; +import { ISSOFlow, LoginFlow } from "matrix-js-sdk/src/@types/auth"; import { _t, _td } from '../../../languageHandler'; -import Login, { ISSOFlow, LoginFlow } from '../../../Login'; +import Login from '../../../Login'; import SdkConfig from '../../../SdkConfig'; import { messageForResourceLimitError } from '../../../utils/ErrorUtils'; import AutoDiscoveryUtils from "../../../utils/AutoDiscoveryUtils"; diff --git a/src/components/structures/auth/Registration.tsx b/src/components/structures/auth/Registration.tsx index b5770110f66..c155b5acc25 100644 --- a/src/components/structures/auth/Registration.tsx +++ b/src/components/structures/auth/Registration.tsx @@ -19,6 +19,7 @@ import React, { Fragment, ReactNode } from 'react'; import { MatrixClient } from "matrix-js-sdk/src/client"; import classNames from "classnames"; import { logger } from "matrix-js-sdk/src/logger"; +import { ISSOFlow } from "matrix-js-sdk/src/@types/auth"; import { _t, _td } from '../../../languageHandler'; import { messageForResourceLimitError } from '../../../utils/ErrorUtils'; @@ -26,7 +27,7 @@ import AutoDiscoveryUtils from "../../../utils/AutoDiscoveryUtils"; import * as Lifecycle from '../../../Lifecycle'; import { IMatrixClientCreds, MatrixClientPeg } from "../../../MatrixClientPeg"; import AuthPage from "../../views/auth/AuthPage"; -import Login, { ISSOFlow } from "../../../Login"; +import Login from "../../../Login"; import dis from "../../../dispatcher/dispatcher"; import SSOButtons from "../../views/elements/SSOButtons"; import ServerPicker from '../../views/elements/ServerPicker'; diff --git a/src/components/structures/auth/SoftLogout.tsx b/src/components/structures/auth/SoftLogout.tsx index 64dcdce6453..7b946070ca2 100644 --- a/src/components/structures/auth/SoftLogout.tsx +++ b/src/components/structures/auth/SoftLogout.tsx @@ -17,13 +17,14 @@ limitations under the License. import React from 'react'; import { logger } from "matrix-js-sdk/src/logger"; import { Optional } from "matrix-events-sdk"; +import { ISSOFlow, LoginFlow } from "matrix-js-sdk/src/@types/auth"; import { _t } from '../../../languageHandler'; import dis from '../../../dispatcher/dispatcher'; import * as Lifecycle from '../../../Lifecycle'; import Modal from '../../../Modal'; import { MatrixClientPeg } from "../../../MatrixClientPeg"; -import { ISSOFlow, LoginFlow, sendLoginRequest } from "../../../Login"; +import { sendLoginRequest } from "../../../Login"; import AuthPage from "../../views/auth/AuthPage"; import { SSO_HOMESERVER_URL_KEY, SSO_ID_SERVER_URL_KEY } from "../../../BasePlatform"; import SSOButtons from "../../views/elements/SSOButtons"; diff --git a/src/components/views/auth/LoginWithQR.tsx b/src/components/views/auth/LoginWithQR.tsx new file mode 100644 index 00000000000..3d3f76be957 --- /dev/null +++ b/src/components/views/auth/LoginWithQR.tsx @@ -0,0 +1,396 @@ +/* +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 { MSC3906Rendezvous, MSC3906RendezvousPayload, RendezvousFailureReason } from 'matrix-js-sdk/src/rendezvous'; +import { MSC3886SimpleHttpRendezvousTransport } from 'matrix-js-sdk/src/rendezvous/transports'; +import { MSC3903ECDHPayload, MSC3903ECDHv1RendezvousChannel } from 'matrix-js-sdk/src/rendezvous/channels'; +import { logger } from 'matrix-js-sdk/src/logger'; +import { MatrixClient } from 'matrix-js-sdk/src/client'; + +import { _t } from "../../../languageHandler"; +import AccessibleButton from '../elements/AccessibleButton'; +import QRCode from '../elements/QRCode'; +import Spinner from '../elements/Spinner'; +import { Icon as BackButtonIcon } from "../../../../res/img/element-icons/back.svg"; +import { Icon as DevicesIcon } from "../../../../res/img/element-icons/devices.svg"; +import { Icon as WarningBadge } from "../../../../res/img/element-icons/warning-badge.svg"; +import { Icon as InfoIcon } from "../../../../res/img/element-icons/i.svg"; +import { wrapRequestWithDialog } from '../../../utils/UserInteractiveAuth'; + +/** + * The intention of this enum is to have a mode that scans a QR code instead of generating one. + */ +export enum Mode { + /** + * A QR code with be generated and shown + */ + Show = "show", +} + +enum Phase { + Loading, + ShowingQR, + Connecting, + Connected, + WaitingForDevice, + Verifying, + Error, +} + +interface IProps { + client: MatrixClient; + mode: Mode; + onFinished(...args: any): void; +} + +interface IState { + phase: Phase; + rendezvous?: MSC3906Rendezvous; + confirmationDigits?: string; + failureReason?: RendezvousFailureReason; + mediaPermissionError?: boolean; +} + +/** + * A component that allows sign in and E2EE set up with a QR code. + * + * It implements both `login.start` and `login-reciprocate` capabilities as well as both scanning and showing QR codes. + * + * This uses the unstable feature of MSC3906: https://github.com/matrix-org/matrix-spec-proposals/pull/3906 + */ +export default class LoginWithQR extends React.Component { + public constructor(props) { + super(props); + + this.state = { + phase: Phase.Loading, + }; + } + + public componentDidMount(): void { + this.updateMode(this.props.mode).then(() => {}); + } + + public componentDidUpdate(prevProps: Readonly): void { + if (prevProps.mode !== this.props.mode) { + this.updateMode(this.props.mode).then(() => {}); + } + } + + private async updateMode(mode: Mode) { + this.setState({ phase: Phase.Loading }); + if (this.state.rendezvous) { + this.state.rendezvous.onFailure = undefined; + await this.state.rendezvous.cancel(RendezvousFailureReason.UserCancelled); + this.setState({ rendezvous: undefined }); + } + if (mode === Mode.Show) { + await this.generateCode(); + } + } + + public componentWillUnmount(): void { + if (this.state.rendezvous) { + // eslint-disable-next-line react/no-direct-mutation-state + this.state.rendezvous.onFailure = undefined; + // calling cancel will call close() as well to clean up the resources + this.state.rendezvous.cancel(RendezvousFailureReason.UserCancelled).then(() => {}); + } + } + + private approveLogin = async (): Promise => { + if (!this.state.rendezvous) { + throw new Error('Rendezvous not found'); + } + this.setState({ phase: Phase.Loading }); + + try { + logger.info("Requesting login token"); + + const { login_token: loginToken } = await wrapRequestWithDialog(this.props.client.requestLoginToken, { + matrixClient: this.props.client, + title: _t("Sign in new device"), + })(); + + this.setState({ phase: Phase.WaitingForDevice }); + + const newDeviceId = await this.state.rendezvous.approveLoginOnExistingDevice(loginToken); + if (!newDeviceId) { + // user denied + return; + } + if (!this.props.client.crypto) { + // no E2EE to set up + this.props.onFinished(true); + return; + } + await this.state.rendezvous.verifyNewDeviceOnExistingDevice(); + this.props.onFinished(true); + } catch (e) { + logger.error('Error whilst approving sign in', e); + this.setState({ phase: Phase.Error, failureReason: RendezvousFailureReason.Unknown }); + } + }; + + private generateCode = async () => { + let rendezvous: MSC3906Rendezvous; + try { + const transport = new MSC3886SimpleHttpRendezvousTransport({ + onFailure: this.onFailure, + client: this.props.client, + }); + + const channel = new MSC3903ECDHv1RendezvousChannel( + transport, undefined, this.onFailure, + ); + + rendezvous = new MSC3906Rendezvous(channel, this.props.client, this.onFailure); + + await rendezvous.generateCode(); + this.setState({ + phase: Phase.ShowingQR, + rendezvous, + failureReason: undefined, + }); + } catch (e) { + logger.error('Error whilst generating QR code', e); + this.setState({ phase: Phase.Error, failureReason: RendezvousFailureReason.HomeserverLacksSupport }); + return; + } + + try { + const confirmationDigits = await rendezvous.startAfterShowingCode(); + this.setState({ phase: Phase.Connected, confirmationDigits }); + } catch (e) { + logger.error('Error whilst doing QR login', e); + // only set to error phase if it hasn't already been set by onFailure or similar + if (this.state.phase !== Phase.Error) { + this.setState({ phase: Phase.Error, failureReason: RendezvousFailureReason.Unknown }); + } + } + }; + + private onFailure = (reason: RendezvousFailureReason) => { + logger.info(`Rendezvous failed: ${reason}`); + this.setState({ phase: Phase.Error, failureReason: reason }); + }; + + public reset() { + this.setState({ + rendezvous: undefined, + confirmationDigits: undefined, + failureReason: undefined, + }); + } + + private cancelClicked = async (e: React.FormEvent) => { + e.preventDefault(); + await this.state.rendezvous?.cancel(RendezvousFailureReason.UserCancelled); + this.reset(); + this.props.onFinished(false); + }; + + private declineClicked = async (e: React.FormEvent) => { + e.preventDefault(); + await this.state.rendezvous?.declineLoginOnExistingDevice(); + this.reset(); + this.props.onFinished(false); + }; + + private tryAgainClicked = async (e: React.FormEvent) => { + e.preventDefault(); + this.reset(); + await this.updateMode(this.props.mode); + }; + + private onBackClick = async () => { + await this.state.rendezvous?.cancel(RendezvousFailureReason.UserCancelled); + + this.props.onFinished(false); + }; + + private cancelButton = () => + { _t("Cancel") } + ; + + private simpleSpinner = (description?: string): JSX.Element => { + return
+
+ + { description &&

{ description }

} +
+
; + }; + + public render() { + let title: string; + let titleIcon: JSX.Element | undefined; + let main: JSX.Element | undefined; + let buttons: JSX.Element | undefined; + let backButton = true; + let cancellationMessage: string | undefined; + let centreTitle = false; + + switch (this.state.phase) { + case Phase.Error: + switch (this.state.failureReason) { + case RendezvousFailureReason.Expired: + cancellationMessage = _t("The linking wasn't completed in the required time."); + break; + case RendezvousFailureReason.InvalidCode: + cancellationMessage = _t("The scanned code is invalid."); + break; + case RendezvousFailureReason.UnsupportedAlgorithm: + cancellationMessage = _t("Linking with this device is not supported."); + break; + case RendezvousFailureReason.UserDeclined: + cancellationMessage = _t("The request was declined on the other device."); + break; + case RendezvousFailureReason.OtherDeviceAlreadySignedIn: + cancellationMessage = _t("The other device is already signed in."); + break; + case RendezvousFailureReason.OtherDeviceNotSignedIn: + cancellationMessage = _t("The other device isn't signed in."); + break; + case RendezvousFailureReason.UserCancelled: + cancellationMessage = _t("The request was cancelled."); + break; + case RendezvousFailureReason.Unknown: + cancellationMessage = _t("An unexpected error occurred."); + break; + case RendezvousFailureReason.HomeserverLacksSupport: + cancellationMessage = _t("The homeserver doesn't support signing in another device."); + break; + default: + cancellationMessage = _t("The request was cancelled."); + break; + } + title = _t("Connection failed"); + centreTitle = true; + titleIcon = ; + backButton = false; + main =

{ cancellationMessage }

; + buttons = <> + + { _t("Try again") } + + { this.cancelButton() } + ; + break; + case Phase.Connected: + title = _t("Devices connected"); + titleIcon = ; + backButton = false; + main = <> +

{ _t("Check that the code below matches with your other device:") }

+
+ { this.state.confirmationDigits } +
+
+
+ +
+
{ _t("By approving access for this device, it will have full access to your account.") }
+
+ ; + + buttons = <> + + { _t("Cancel") } + + + { _t("Approve") } + + ; + break; + case Phase.ShowingQR: + title =_t("Sign in with QR code"); + if (this.state.rendezvous) { + const code =
+ +
; + main = <> +

{ _t("Scan the QR code below with your device that's signed out.") }

+
    +
  1. { _t("Start at the sign in screen") }
  2. +
  3. { _t("Select 'Scan QR code'") }
  4. +
  5. { _t("Review and approve the sign in") }
  6. +
+ { code } + ; + } else { + main = this.simpleSpinner(); + buttons = this.cancelButton(); + } + break; + case Phase.Loading: + main = this.simpleSpinner(); + break; + case Phase.Connecting: + main = this.simpleSpinner(_t("Connecting...")); + buttons = this.cancelButton(); + break; + case Phase.WaitingForDevice: + main = this.simpleSpinner(_t("Waiting for device to sign in")); + buttons = this.cancelButton(); + break; + case Phase.Verifying: + title = _t("Success"); + centreTitle = true; + main = this.simpleSpinner(_t("Completing set up of your new device")); + break; + } + + return ( +
+
+ { backButton ? + + + + : null } +

{ titleIcon }{ title }

+
+
+ { main } +
+
+ { buttons } +
+
+ ); + } +} diff --git a/src/components/views/avatars/RoomAvatar.tsx b/src/components/views/avatars/RoomAvatar.tsx index f50232fb220..c09b598ceec 100644 --- a/src/components/views/avatars/RoomAvatar.tsx +++ b/src/components/views/avatars/RoomAvatar.tsx @@ -16,11 +16,10 @@ limitations under the License. import React, { ComponentProps } from 'react'; import { Room } from 'matrix-js-sdk/src/models/room'; -import { ResizeMethod } from 'matrix-js-sdk/src/@types/partials'; import { MatrixEvent } from 'matrix-js-sdk/src/models/event'; import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state"; import classNames from "classnames"; -import { EventType } from "matrix-js-sdk/src/@types/event"; +import { EventType, RoomType } from "matrix-js-sdk/src/@types/event"; import BaseAvatar from './BaseAvatar'; import ImageView from '../elements/ImageView'; @@ -39,11 +38,7 @@ interface IProps extends Omit, "name" | "idNam oobData?: IOOBData & { roomId?: string; }; - width?: number; - height?: number; - resizeMethod?: ResizeMethod; viewAvatarOnClick?: boolean; - className?: string; onClick?(): void; } @@ -72,10 +67,7 @@ export default class RoomAvatar extends React.Component { } public componentWillUnmount() { - const cli = MatrixClientPeg.get(); - if (cli) { - cli.removeListener(RoomStateEvent.Events, this.onRoomStateEvents); - } + MatrixClientPeg.get()?.removeListener(RoomStateEvent.Events, this.onRoomStateEvents); } public static getDerivedStateFromProps(nextProps: IProps): IState { @@ -133,7 +125,7 @@ export default class RoomAvatar extends React.Component { public render() { const { room, oobData, viewAvatarOnClick, onClick, className, ...otherProps } = this.props; - const roomName = room ? room.name : oobData.name; + const roomName = room?.name ?? oobData.name; // If the room is a DM, we use the other user's ID for the color hash // in order to match the room avatar with their avatar const idName = room ? (DMRoomMap.shared().getUserIdForRoomId(room.roomId) ?? room.roomId) : oobData.roomId; @@ -142,7 +134,7 @@ export default class RoomAvatar extends React.Component { = ({ roomId }) => { } // Check if the call is already showing. No banner is needed in this case. - if (RoomViewStore.instance.isViewingCall()) { + if (SdkContextClass.instance.roomViewStore.isViewingCall()) { return null; } diff --git a/src/components/views/beacon/ShareLatestLocation.tsx b/src/components/views/beacon/ShareLatestLocation.tsx index 09c179f6d62..be8bc6f9776 100644 --- a/src/components/views/beacon/ShareLatestLocation.tsx +++ b/src/components/views/beacon/ShareLatestLocation.tsx @@ -47,7 +47,7 @@ const ShareLatestLocation: React.FC = ({ latestLocationState }) => { return <> public render(): JSX.Element { const cli = MatrixClientPeg.get(); const me = cli.getUserId(); - const { mxEvent, rightClick, link, eventTileOps, reactions, collapseReplyChain } = this.props; + const { + mxEvent, rightClick, link, eventTileOps, reactions, collapseReplyChain, + ...other + } = this.props; + delete other.getRelationsForEvent; + delete other.permalinkCreator; + const eventStatus = mxEvent.status; const unsentReactionsCount = this.getUnsentReactions().length; const contentActionable = isContentActionable(mxEvent); @@ -747,7 +753,7 @@ export default class MessageContextMenu extends React.Component return ( { }; const ensureViewingRoom = (ev: ButtonEvent) => { - if (RoomViewStore.instance.getRoomId() === room.roomId) return; + if (SdkContextClass.instance.roomViewStore.getRoomId() === room.roomId) return; dis.dispatch({ action: Action.ViewRoom, room_id: room.roomId, @@ -377,7 +377,7 @@ const RoomContextMenu = ({ room, onFinished, ...props }: IProps) => { ev.stopPropagation(); Modal.createDialog(DevtoolsDialog, { - roomId: RoomViewStore.instance.getRoomId(), + roomId: SdkContextClass.instance.roomViewStore.getRoomId(), }, "mx_DevtoolsDialog_wrapper"); onFinished(); }} diff --git a/src/components/views/context_menus/ThreadListContextMenu.tsx b/src/components/views/context_menus/ThreadListContextMenu.tsx index 73fa52ef3c4..3740327ca5c 100644 --- a/src/components/views/context_menus/ThreadListContextMenu.tsx +++ b/src/components/views/context_menus/ThreadListContextMenu.tsx @@ -29,9 +29,9 @@ import { WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; -interface IProps { +export interface ThreadListContextMenuProps { mxEvent: MatrixEvent; - permalinkCreator: RoomPermalinkCreator; + permalinkCreator?: RoomPermalinkCreator; onMenuToggle?: (open: boolean) => void; } @@ -43,7 +43,7 @@ const contextMenuBelow = (elementRect: DOMRect) => { return { left, top, chevronFace }; }; -const ThreadListContextMenu: React.FC = ({ +const ThreadListContextMenu: React.FC = ({ mxEvent, permalinkCreator, onMenuToggle, @@ -64,12 +64,14 @@ const ThreadListContextMenu: React.FC = ({ closeThreadOptions(); }, [mxEvent, closeThreadOptions]); - const copyLinkToThread = useCallback(async (evt: ButtonEvent) => { - evt.preventDefault(); - evt.stopPropagation(); - const matrixToUrl = permalinkCreator.forEvent(mxEvent.getId()); - await copyPlaintext(matrixToUrl); - closeThreadOptions(); + const copyLinkToThread = useCallback(async (evt: ButtonEvent | undefined) => { + if (permalinkCreator) { + evt?.preventDefault(); + evt?.stopPropagation(); + const matrixToUrl = permalinkCreator.forEvent(mxEvent.getId()); + await copyPlaintext(matrixToUrl); + closeThreadOptions(); + } }, [mxEvent, closeThreadOptions, permalinkCreator]); useEffect(() => { @@ -87,6 +89,7 @@ const ThreadListContextMenu: React.FC = ({ title={_t("Thread options")} isExpanded={menuDisplayed} inputRef={button} + data-testid="threadlist-dropdown-button" /> { menuDisplayed && ( = ({ label={_t("View in room")} iconClassName="mx_ThreadPanel_viewInRoom" /> } - copyLinkToThread(e)} - label={_t("Copy link to thread")} - iconClassName="mx_ThreadPanel_copyLinkToThread" - /> + { permalinkCreator && + copyLinkToThread(e)} + label={_t("Copy link to thread")} + iconClassName="mx_ThreadPanel_copyLinkToThread" + /> + } ) } ; diff --git a/src/components/views/dialogs/InteractiveAuthDialog.tsx b/src/components/views/dialogs/InteractiveAuthDialog.tsx index 6f10790811e..5d8fc2f952d 100644 --- a/src/components/views/dialogs/InteractiveAuthDialog.tsx +++ b/src/components/views/dialogs/InteractiveAuthDialog.tsx @@ -38,7 +38,7 @@ interface IDialogAesthetics { }; } -interface IProps extends IDialogProps { +export interface InteractiveAuthDialogProps extends IDialogProps { // matrix client to use for UI auth requests matrixClient: MatrixClient; @@ -82,8 +82,8 @@ interface IState { uiaStagePhase: number | string; } -export default class InteractiveAuthDialog extends React.Component { - constructor(props: IProps) { +export default class InteractiveAuthDialog extends React.Component { + constructor(props: InteractiveAuthDialogProps) { super(props); this.state = { diff --git a/src/components/views/dialogs/WidgetOpenIDPermissionsDialog.tsx b/src/components/views/dialogs/WidgetOpenIDPermissionsDialog.tsx index 68c2991ed8d..2d2d638af9a 100644 --- a/src/components/views/dialogs/WidgetOpenIDPermissionsDialog.tsx +++ b/src/components/views/dialogs/WidgetOpenIDPermissionsDialog.tsx @@ -21,10 +21,11 @@ import { logger } from "matrix-js-sdk/src/logger"; import { _t } from "../../../languageHandler"; import LabelledToggleSwitch from "../elements/LabelledToggleSwitch"; -import { OIDCState, WidgetPermissionStore } from "../../../stores/widgets/WidgetPermissionStore"; +import { OIDCState } from "../../../stores/widgets/WidgetPermissionStore"; import { IDialogProps } from "./IDialogProps"; import BaseDialog from "./BaseDialog"; import DialogButtons from "../elements/DialogButtons"; +import { SdkContextClass } from '../../../contexts/SDKContext'; interface IProps extends IDialogProps { widget: Widget; @@ -57,7 +58,7 @@ export default class WidgetOpenIDPermissionsDialog extends React.PureComponent = ({ initialText = "", initialFilter = n searchProfileInfo, searchParams, ); - const isSlidingSyncEnabled = SettingsStore.getValue("feature_sliding_sync"); - let { - loading: slidingSyncRoomSearchLoading, - rooms: slidingSyncRooms, - search: searchRoomsServerside, - } = useSlidingSyncRoomSearch(); - useDebouncedCallback(isSlidingSyncEnabled, searchRoomsServerside, searchParams); - if (!isSlidingSyncEnabled) { - slidingSyncRoomSearchLoading = false; - } const possibleResults = useMemo( () => { const userResults: IMemberResult[] = []; - let roomResults: IRoomResult[]; - let alreadyAddedUserIds: Set; - if (isSlidingSyncEnabled) { - // use the rooms sliding sync returned as the server has already worked it out for us - roomResults = slidingSyncRooms.map(toRoomResult); - } else { - roomResults = findVisibleRooms(cli).map(toRoomResult); - // If we already have a DM with the user we're looking for, we will - // show that DM instead of the user themselves - alreadyAddedUserIds = roomResults.reduce((userIds, result) => { - const userId = DMRoomMap.shared().getUserIdForRoomId(result.room.roomId); - if (!userId) return userIds; - if (result.room.getJoinedMemberCount() > 2) return userIds; - userIds.add(userId); - return userIds; - }, new Set()); - for (const user of [...findVisibleRoomMembers(cli), ...users]) { - // Make sure we don't have any user more than once - if (alreadyAddedUserIds.has(user.userId)) continue; - alreadyAddedUserIds.add(user.userId); - - userResults.push(toMemberResult(user)); - } + const roomResults = findVisibleRooms(cli).map(toRoomResult); + // If we already have a DM with the user we're looking for, we will + // show that DM instead of the user themselves + const alreadyAddedUserIds = roomResults.reduce((userIds, result) => { + const userId = DMRoomMap.shared().getUserIdForRoomId(result.room.roomId); + if (!userId) return userIds; + if (result.room.getJoinedMemberCount() > 2) return userIds; + userIds.add(userId); + return userIds; + }, new Set()); + for (const user of [...findVisibleRoomMembers(cli), ...users]) { + // Make sure we don't have any user more than once + if (alreadyAddedUserIds.has(user.userId)) continue; + alreadyAddedUserIds.add(user.userId); + + userResults.push(toMemberResult(user)); } return [ @@ -401,7 +384,7 @@ const SpotlightDialog: React.FC = ({ initialText = "", initialFilter = n ...publicRooms.map(toPublicRoomResult), ].filter(result => filter === null || result.filter.includes(filter)); }, - [cli, users, profile, publicRooms, slidingSyncRooms, isSlidingSyncEnabled, filter], + [cli, users, profile, publicRooms, filter], ); const results = useMemo>(() => { @@ -420,13 +403,10 @@ const SpotlightDialog: React.FC = ({ initialText = "", initialFilter = n possibleResults.forEach(entry => { if (isRoomResult(entry)) { - // sliding sync gives the correct rooms in the list so we don't need to filter - if (!isSlidingSyncEnabled) { - if (!entry.room.normalizedName?.includes(normalizedQuery) && - !entry.room.getCanonicalAlias()?.toLowerCase().includes(lcQuery) && - !entry.query?.some(q => q.includes(lcQuery)) - ) return; // bail, does not match query - } + if (!entry.room.normalizedName?.includes(normalizedQuery) && + !entry.room.getCanonicalAlias()?.toLowerCase().includes(lcQuery) && + !entry.query?.some(q => q.includes(lcQuery)) + ) return; // bail, does not match query } else if (isMemberResult(entry)) { if (!entry.query?.some(q => q.includes(lcQuery))) return; // bail, does not match query } else if (isPublicRoomResult(entry)) { @@ -477,7 +457,7 @@ const SpotlightDialog: React.FC = ({ initialText = "", initialFilter = n } return results; - }, [trimmedQuery, filter, cli, possibleResults, isSlidingSyncEnabled, memberComparator]); + }, [trimmedQuery, filter, cli, possibleResults, memberComparator]); const numResults = sum(Object.values(results).map(it => it.length)); useWebSearchMetrics(numResults, query.length, true); @@ -656,6 +636,7 @@ const SpotlightDialog: React.FC = ({ initialText = "", initialFilter = n shouldPeek: result.publicRoom.world_readable || cli.isGuest(), }, true, ev.type !== "click"); }; + return ( to continue.": "Voer uw veiligheidswachtwoord in of om door te gaan.", "Downloading": "Downloaden", - "They won't be able to access whatever you're not an admin of.": "Ze zullen geen toegang hebben tot alles waar u geen beheerder van bent.", + "They won't be able to access whatever you're not an admin of.": "Ze zullen geen toegang hebben tot alles waar je geen beheerder van bent.", "Ban them from specific things I'm able to": "Ban ze van specifieke plekken waar ik dit kan", "Unban them from specific things I'm able to": "Ontban ze van alles specifieke plekken waar ik dit kan", "Ban them from everything I'm able to": "Verban ze van alles waar ik dit kan", "Unban them from everything I'm able to": "Ontban ze van alles waar ik dit kan", "Ban from %(roomName)s": "Verban van %(roomName)s", "Unban from %(roomName)s": "Ontban van %(roomName)s", - "They'll still be able to access whatever you're not an admin of.": "Ze zullen nog steeds toegang hebben tot alles waar u geen beheerder bent.", + "They'll still be able to access whatever you're not an admin of.": "Ze zullen nog steeds toegang hebben tot alles waar je geen beheerder van bent.", "Disinvite from %(roomName)s": "Uitnodiging intrekken voor %(roomName)s", - "Threads": "Discussies", + "Threads": "Threads", "Create poll": "Poll aanmaken", "%(count)s reply|one": "%(count)s reactie", "%(count)s reply|other": "%(count)s reacties", @@ -2780,19 +2780,19 @@ "Shows all threads from current room": "Toon alle discussies van huidige kamer", "All threads": "Alle discussies", "My threads": "Mijn discussies", - "Please only proceed if you're sure you've lost all of your other devices and your security key.": "Ga alleen verder als u zeker weet dat u al uw andere apparaten en uw veiligheidssleutel kwijt bent.", - "Resetting your verification keys cannot be undone. After resetting, you won't have access to old encrypted messages, and any friends who have previously verified you will see security warnings until you re-verify with them.": "Het resetten van uw verificatiesleutels kan niet ongedaan worden gemaakt. Na het resetten hebt u geen toegang meer tot oude versleutelde berichten, en vrienden die u eerder hebben geverifieerd zullen veiligheidswaarschuwingen zien totdat u zich opnieuw bij hen verifieert.", + "Please only proceed if you're sure you've lost all of your other devices and your security key.": "Ga alleen verder als je zeker weet dat je al jouw andere apparaten en je veiligheidssleutel kwijt bent.", + "Resetting your verification keys cannot be undone. After resetting, you won't have access to old encrypted messages, and any friends who have previously verified you will see security warnings until you re-verify with them.": "Het resetten van je verificatiesleutels kan niet ongedaan worden gemaakt. Na het resetten heb je geen toegang meer tot oude versleutelde berichten, en vrienden die je eerder hebben geverifieerd zullen veiligheidswaarschuwingen zien totdat je opnieuw bij hen geverifieert bent.", "I'll verify later": "Ik verifieer het later", "Verify with Security Key": "Verifieer met veiligheidssleutel", "Verify with Security Key or Phrase": "Verifieer met veiligheidssleutel of -wachtwoord", "Proceed with reset": "Met reset doorgaan", - "Really reset verification keys?": "Echt uw verificatiesleutels resetten?", - "It looks like you don't have a Security Key or any other devices you can verify against. This device will not be able to access old encrypted messages. In order to verify your identity on this device, you'll need to reset your verification keys.": "Het lijkt erop dat u geen veiligheidssleutel hebt of andere apparaten waarmee u kunt verifiëren. Dit apparaat heeft geen toegang tot oude versleutelde berichten. Om uw identiteit op dit apparaat te verifiëren, moet u uw verificatiesleutels opnieuw instellen.", + "Really reset verification keys?": "Echt je verificatiesleutels resetten?", + "It looks like you don't have a Security Key or any other devices you can verify against. This device will not be able to access old encrypted messages. In order to verify your identity on this device, you'll need to reset your verification keys.": "Het lijkt erop dat je geen veiligheidssleutel hebt of andere apparaten waarmee je kunt verifiëren. Dit apparaat heeft geen toegang tot oude versleutelde berichten. Om je identiteit op dit apparaat te verifiëren, moet je jouw verificatiesleutels opnieuw instellen.", "That e-mail address is already in use.": "Dit e-mailadres is al in gebruik.", "The email address doesn't appear to be valid.": "Dit e-mailadres lijkt niet geldig te zijn.", "Skip verification for now": "Verificatie voorlopig overslaan", "Show:": "Toon:", - "What projects are your team working on?": "Aan welke projecten werkt uw team?", + "What projects are your team working on?": "Aan welke projecten werkt jouw team?", "Joined": "Toegetreden", "Insert link": "Koppeling invoegen", "%(senderDisplayName)s changed who can join this room.": "%(senderDisplayName)s veranderde wie lid kan worden van deze kamer.", @@ -2806,15 +2806,15 @@ "Sign out devices|other": "Apparaten uitloggen", "Click the button below to confirm signing out these devices.|one": "Klik op onderstaande knop om het uitloggen van dit apparaat te bevestigen.", "Click the button below to confirm signing out these devices.|other": "Klik op onderstaande knop om het uitloggen van deze apparaten te bevestigen.", - "Confirm logging out these devices by using Single Sign On to prove your identity.|one": "Bevestig uw identiteit met eenmalig inloggen om dit apparaat uit te loggen.", - "Confirm logging out these devices by using Single Sign On to prove your identity.|other": "Bevestig uw identiteit met eenmalig inloggen om deze apparaten uit te loggen.", + "Confirm logging out these devices by using Single Sign On to prove your identity.|one": "Bevestig je identiteit met eenmalig inloggen om dit apparaat uit te loggen.", + "Confirm logging out these devices by using Single Sign On to prove your identity.|other": "Bevestig je identiteit met eenmalig inloggen om deze apparaten uit te loggen.", "Unable to load device list": "Kan apparatenlijst niet laden", - "Your homeserver does not support device management.": "Uw homeserver ondersteunt geen apparaatbeheer.", + "Your homeserver does not support device management.": "Jouw homeserver ondersteunt geen apparaatbeheer.", "Use a more compact 'Modern' layout": "Compacte 'Moderne'-indeling gebruiken", "Sign Out": "Uitloggen", "Last seen %(date)s at %(ip)s": "Laatst gezien %(date)s via %(ip)s", "This device": "Dit apparaat", - "You aren't signed into any other devices.": "U bent niet ingelogd op andere apparaten.", + "You aren't signed into any other devices.": "Je bent niet ingelogd op andere apparaten.", "Sign out %(count)s selected devices|one": "%(count)s geselecteerd apparaat uitloggen", "Sign out %(count)s selected devices|other": "%(count)s geselecteerde apparaten uitloggen", "Devices without encryption support": "Apparaten zonder versleuteling ondersteuning", @@ -2823,30 +2823,30 @@ "Other rooms": "Andere kamers", "Automatically send debug logs on any error": "Automatisch foutenlogboek versturen bij een fout", "Rename": "Hernoemen", - "Show all threads": "Discussies weergeven", - "Keep discussions organised with threads": "Houd discussies georganiseerd", - "Shows all threads you've participated in": "Toon alle discussies waarin u heeft bijgedragen", - "You're all caught up": "U bent helemaal bij", - "Own your conversations.": "Gesprekken die helemaal van u zijn.", - "Someone already has that username. Try another or if it is you, sign in below.": "Iemand heeft die inlognaam al. Probeer een andere of als u het bent, log dan hieronder in.", + "Show all threads": "Threads weergeven", + "Keep discussions organised with threads": "Houd threads georganiseerd", + "Shows all threads you've participated in": "Toon alle threads waarin je hebt bijgedragen", + "You're all caught up": "Je bent helemaal bij", + "Own your conversations.": "Gesprekken die helemaal van jou zijn.", + "Someone already has that username. Try another or if it is you, sign in below.": "Iemand heeft die inlognaam al. Probeer een andere of als je het bent, log dan hieronder in.", "Copy link to thread": "Kopieer link naar draad", "Thread options": "Draad opties", "Copy link": "Link kopieren", "Mentions only": "Alleen vermeldingen", "Forget": "Vergeet", - "We call the places where you can host your account 'homeservers'.": "Wij noemen de plaatsen waar u uw account kunt hosten 'homeservers'.", + "We call the places where you can host your account 'homeservers'.": "Wij noemen de plaatsen waar je jouw account kunt hosten 'homeservers'.", "Matrix.org is the biggest public homeserver in the world, so it's a good place for many.": "Matrix.org is de grootste publieke homeserver ter wereld, dus het is een goede plek voor velen.", - "If you can't see who you're looking for, send them your invite link below.": "Als u niet kunt vinden wie u zoekt, stuur ze dan uw uitnodigingslink hieronder.", + "If you can't see who you're looking for, send them your invite link below.": "Als je niet kan vinden wie je zoekt, stuur ze dan je uitnodigingslink hieronder.", "Minimise dialog": "Dialoog minimaliseren", "Maximise dialog": "Dialoog maximaliseren", - "You can't disable this later. Bridges & most bots won't work yet.": "U kunt dit later niet uitschakelen. Bruggen en de meeste bots zullen nog niet werken.", + "You can't disable this later. Bridges & most bots won't work yet.": "Je kan dit later niet uitschakelen. Bruggen en de meeste bots zullen nog niet werken.", "Add option": "Optie toevoegen", "Write an option": "Schrijf een optie", "Option %(number)s": "Optie %(number)s", "Create options": "Opties maken", "Write something...": "Schrijf iets...", "Question or topic": "Vraag of onderwerp", - "What is your poll question or topic?": "Wat is uw poll vraag of onderwerp?", + "What is your poll question or topic?": "Wat is jouw poll vraag of onderwerp?", "Create Poll": "Poll aanmaken", "Based on %(count)s votes|one": "Gebaseerd op %(count)s stem", "Based on %(count)s votes|other": "Gebaseerd op %(count)s stemmen", @@ -2856,28 +2856,28 @@ "Files": "Bestanden", "Close this widget to view it in this panel": "Sluit deze widget om het in dit paneel weer te geven", "Unpin this widget to view it in this panel": "Maak deze widget los om het in dit paneel weer te geven", - "Yours, or the other users' session": "Uw sessie, of die van de andere personen", - "Yours, or the other users' internet connection": "Uw internetverbinding, of die van de andere personen", - "The homeserver the user you're verifying is connected to": "De homeserver waarmee de persoon die u verifieert verbonden is", + "Yours, or the other users' session": "Jouw sessie, of die van de andere personen", + "Yours, or the other users' internet connection": "Jouw internetverbinding, of die van de andere personen", + "The homeserver the user you're verifying is connected to": "De homeserver waarmee de persoon die jij verifieert verbonden is", "Can't see what you're looking for?": "Kunt u niet zien wat u zoekt?", - "You do not have permission to start polls in this room.": "U heeft geen toestemming om polls te starten in deze kamer.", + "You do not have permission to start polls in this room.": "Je hebt geen toestemming om polls te starten in deze kamer.", "Reply in thread": "Reageer in draad", "Manage rooms in this space": "Beheer kamers in deze space", - "You won't get any notifications": "U krijgt geen meldingen", - "Get notified only with mentions and keywords as set up in your settings": "Krijg alleen meldingen met vermeldingen en trefwoorden zoals ingesteld in uw instellingen", + "You won't get any notifications": "Je krijgt geen meldingen", + "Get notified only with mentions and keywords as set up in your settings": "Krijg alleen meldingen met vermeldingen en trefwoorden zoals ingesteld in je instellingen", "@mentions & keywords": "@vermeldingen & trefwoorden", "Get notified for every message": "Ontvang een melding bij elk bericht", "Get notifications as set up in your settings": "Ontvang de meldingen zoals ingesteld in uw instellingen", "This room isn't bridging messages to any platforms. Learn more.": "Deze kamer overbrugt geen berichten naar platformen. Lees meer.", "Rooms outside of a space": "Kamers buiten een space", - "Show all your rooms in Home, even if they're in a space.": "Toon al uw kamers in Home, zelfs als ze al in een space zitten.", + "Show all your rooms in Home, even if they're in a space.": "Toon al je kamers in Home, zelfs als ze al in een space zitten.", "Home is useful for getting an overview of everything.": "Home is handig om een overzicht van alles te krijgen.", "Spaces to show": "Spaces om te tonen", "Sidebar": "Zijbalk", - "Manage your signed-in devices below. A device's name is visible to people you communicate with.": "Beheer uw ingelogde apparaten hieronder. De naam van een apparaat is zichtbaar voor personen met wie u communiceert.", - "Where you're signed in": "Waar u bent ingelogd", + "Manage your signed-in devices below. A device's name is visible to people you communicate with.": "Beheer jouw ingelogde apparaten hieronder. De naam van een apparaat is zichtbaar voor personen met wie je communiceert.", + "Where you're signed in": "Waar je bent ingelogd", "Show tray icon and minimise window to it on close": "Geef een pictogram weer in de systeembalk en minimaliseer het venster wanneer het wordt gesloten", - "This room is in some spaces you're not an admin of. In those spaces, the old room will still be shown, but people will be prompted to join the new one.": "Deze kamer is in spaces waar u geen beheerder van bent. In deze spaces zal de oude kamer nog worden getoond, maar leden zullen een melding krijgen om deel te nemen aan de nieuwe kamer.", + "This room is in some spaces you're not an admin of. In those spaces, the old room will still be shown, but people will be prompted to join the new one.": "Deze kamer is in spaces waar je geen beheerder van bent. In deze spaces zal de oude kamer nog worden getoond, maar leden zullen een melding krijgen om deel te nemen aan de nieuwe kamer.", "Large": "Groot", "Image size in the timeline": "Afbeeldingformaat in de tijdlijn", "Sends the given message with rainfall": "Stuurt het bericht met neerslag", @@ -2886,15 +2886,15 @@ "%(spaceName)s and %(count)s others|one": "%(spaceName)s en %(count)s andere", "%(spaceName)s and %(count)s others|zero": "%(spaceName)s", "%(spaceName)s and %(count)s others|other": "%(spaceName)s en %(count)s andere", - "Enter a security phrase only you know, as it's used to safeguard your data. To be secure, you shouldn't re-use your account password.": "Voer een veiligheidswachtwoord in die alleen u kent, deze wordt gebruikt om al uw gegevens te versleutelen. Om echt veilig te zijn, moet u het wachtwoord van uw account niet hergebruiken.", - "We'll generate a Security Key for you to store somewhere safe, like a password manager or a safe.": "Wij maken een veiligheidssleutel voor u aan die u ergens veilig kunt opbergen, zoals in een wachtwoordmanager of een kluis.", - "Regain access to your account and recover encryption keys stored in this session. Without them, you won't be able to read all of your secure messages in any session.": "Ontvang toegang tot uw account en herstel de tijdens deze sessie opgeslagen versleutelingssleutels, zonder deze sleutels zijn sommige van uw versleutelde berichten in uw sessies onleesbaar.", - "Without verifying, you won't have access to all your messages and may appear as untrusted to others.": "Zonder verifiëren heeft u geen toegang tot al uw berichten en kan u als onvertrouwd aangemerkt staan bij anderen.", - "Store your Security Key somewhere safe, like a password manager or a safe, as it's used to safeguard your encrypted data.": "Bewaar uw veiligheidssleutel op een veilige plaats, zoals in een wachtwoordmanager of een kluis, aangezien hiermee uw versleutelde gegevens zijn beveiligd.", + "Enter a security phrase only you know, as it's used to safeguard your data. To be secure, you shouldn't re-use your account password.": "Voer een veiligheidswachtwoord in die alleen jij kent, deze wordt gebruikt om al je gegevens te versleutelen. Om echt veilig te zijn, moet je het wachtwoord van je account niet hergebruiken.", + "We'll generate a Security Key for you to store somewhere safe, like a password manager or a safe.": "Wij maken een veiligheidssleutel voor je aan die je ergens veilig kunt opbergen, zoals in een wachtwoordmanager of een kluis.", + "Regain access to your account and recover encryption keys stored in this session. Without them, you won't be able to read all of your secure messages in any session.": "Ontvang toegang tot je account en herstel de tijdens deze sessie opgeslagen versleutelingssleutels, zonder deze sleutels zijn sommige van je versleutelde berichten in je sessies onleesbaar.", + "Without verifying, you won't have access to all your messages and may appear as untrusted to others.": "Zonder verifiëren heb je geen toegang tot al je berichten en kan je als onvertrouwd aangemerkt staan bij anderen.", + "Store your Security Key somewhere safe, like a password manager or a safe, as it's used to safeguard your encrypted data.": "Bewaar je veiligheidssleutel op een veilige plaats, zoals in een wachtwoordmanager of een kluis, aangezien hiermee je versleutelde gegevens zijn beveiligd.", "Someone already has that username, please try another.": "Iemand heeft die inlognaam al, probeer een andere.", - "Sorry, the poll you tried to create was not posted.": "Sorry, de poll die u probeerde aan te maken is niet geplaatst.", + "Sorry, the poll you tried to create was not posted.": "Sorry, de poll die je probeerde aan te maken is niet geplaatst.", "Failed to post poll": "Poll plaatsen mislukt", - "Sorry, your vote was not registered. Please try again.": "Sorry, uw stem is niet geregistreerd. Probeer het alstublieft opnieuw.", + "Sorry, your vote was not registered. Please try again.": "Sorry, jouw stem is niet geregistreerd. Probeer het alstublieft opnieuw.", "Vote not registered": "Stem niet geregistreerd", "Pin to sidebar": "Vastprikken aan zijbalk", "Quick settings": "Snelle instellingen", @@ -2904,23 +2904,23 @@ "Message Previews": "Berichtvoorbeelden", "Moderation": "Moderatie", "Messaging": "Messaging", - "Spaces you know that contain this space": "Spaces die u kent met deze Space", + "Spaces you know that contain this space": "Spaces die je kent met deze Space", "Chat": "Chat", "Clear": "Wis", - "You may contact me if you want to follow up or to let me test out upcoming ideas": "U kunt contact met mij opnemen als u updates wil van of wilt deelnemen aan nieuwe ideeën", + "You may contact me if you want to follow up or to let me test out upcoming ideas": "Je kan contact met mij opnemen als je updates wil van of wilt deelnemen aan nieuwe ideeën", "Home options": "Home-opties", "%(spaceName)s menu": "%(spaceName)s-menu", "Join public room": "Publieke kamer toetreden", - "You do not have permissions to invite people to this space": "U heeft geen toestemming om personen in deze Space uit te nodigen", + "You do not have permissions to invite people to this space": "Je hebt geen toestemming om personen in deze Space uit te nodigen", "Add people": "Personen toevoegen", "Invite to space": "Voor Space uitnodigen", "Start new chat": "Nieuwe chat beginnen", "Recently viewed": "Recent bekeken", "Use new room breadcrumbs": "Gebruik nieuwe kamer broodkruimels", - "You can turn this off anytime in settings": "U kan dit elk moment uitzetten in instellingen", + "You can turn this off anytime in settings": "Je kan dit elk moment uitzetten in instellingen", "We don't share information with third parties": "We delen geen informatie met derde partijen", "We don't record or profile any account data": "We verwerken of bewaren geen accountgegevens", - "You can read all our terms here": "U kan alle voorwaarden hier lezen", + "You can read all our terms here": "Je kan alle voorwaarden hier lezen", "%(count)s votes cast. Vote to see the results|one": "%(count)s stem uitgebracht. Stem om de resultaten te zien", "%(count)s votes cast. Vote to see the results|other": "%(count)s stemmen uitgebracht. Stem om de resultaten te zien", "No votes cast": "Geen stemmen uitgebracht", @@ -2928,17 +2928,17 @@ "Okay": "Oké", "To view all keyboard shortcuts, click here.": "Om alle sneltoetsen te bekijken, klik hier.", "Share anonymous data to help us identify issues. Nothing personal. No third parties. Learn More": "Deel anonieme data om ons problemen te laten opsporen. Geen persoonsgegeven. Geen derde partijen. Lees Meer", - "You previously consented to share anonymous usage data with us. We're updating how that works.": "U heeft eerdere ingestemd met verzamelen van anonieme gedragsdata met ons. We updaten nu hoe dit werkt.", + "You previously consented to share anonymous usage data with us. We're updating how that works.": "Je hebt eerder ingestemd met het verzamelen van anonieme gedragsdata met ons. We updaten nu hoe dit werkt.", "Help improve %(analyticsOwner)s": "Help %(analyticsOwner)s verbeteren", "That's fine": "Dat is prima", "Share location": "Locatie delen", "Manage pinned events": "Vastgeprikte gebeurtenissen beheren", - "You cannot place calls without a connection to the server.": "U kunt geen oproepen plaatsen zonder een verbinding met de server.", + "You cannot place calls without a connection to the server.": "Je kan geen oproepen plaatsen zonder een verbinding met de server.", "Connectivity to the server has been lost": "De verbinding met de server is verbroken", - "You cannot place calls in this browser.": "U kunt geen oproepen plaatsen in deze browser.", + "You cannot place calls in this browser.": "Je kan geen oproepen plaatsen in deze browser.", "Calls are unsupported": "Oproepen worden niet ondersteund", "Toggle space panel": "Spacepaneel in- of uitschakelen", - "Are you sure you want to end this poll? This will show the final results of the poll and stop people from being able to vote.": "Weet u zeker dat u de poll wil sluiten? Dit zal zichtbaar zijn in de einduitslag van de poll en personen kunnen dan niet langer stemmen.", + "Are you sure you want to end this poll? This will show the final results of the poll and stop people from being able to vote.": "Weet je zeker dat je de poll wil sluiten? Dit zal zichtbaar zijn in de einduitslag van de poll en personen kunnen dan niet langer stemmen.", "End Poll": "Poll sluiten", "Sorry, the poll did not end. Please try again.": "Helaas, de poll is niet gesloten. Probeer het opnieuw.", "Failed to end poll": "Poll sluiten is mislukt", @@ -2956,11 +2956,11 @@ "Use \"%(query)s\" to search": "Gebruik \"%(query)s\" om te zoeken", "Other rooms in %(spaceName)s": "Andere kamers in %(spaceName)s", "Spaces you're in": "Spaces waar u in zit", - "This groups your chats with members of this space. Turning this off will hide those chats from your view of %(spaceName)s.": "Dit groepeert uw chats met leden van deze space. Als u dit uitschakelt, worden deze chats verborgen voor %(spaceName)s.", + "This groups your chats with members of this space. Turning this off will hide those chats from your view of %(spaceName)s.": "Dit groepeert jouw chats met leden van deze space. Als je dit uitschakelt, worden deze chats verborgen voor %(spaceName)s.", "Sections to show": "Te tonen secties", "Link to room": "Link naar kamer", "Processing...": "Verwerken...", - "Including you, %(commaSeparatedMembers)s": "Inclusief u, %(commaSeparatedMembers)s", + "Including you, %(commaSeparatedMembers)s": "Inclusief jij, %(commaSeparatedMembers)s", "Copy room link": "Kamerlink kopiëren", "Creating output...": "Uitvoer maken...", "Fetching events...": "Gebeurtenissen ophalen...", @@ -2978,8 +2978,8 @@ "Fetched %(count)s events out of %(total)s|one": "%(count)s gebeurtenis opgehaald van de %(total)s", "Fetched %(count)s events out of %(total)s|other": "%(count)s gebeurtenissen opgehaald van de %(total)s", "Generating a ZIP": "Genereer een ZIP", - "Your new device is now verified. Other users will see it as trusted.": "Uw nieuwe apparaat is nu geverifieerd. Andere personen zien het nu als vertrouwd.", - "Your new device is now verified. It has access to your encrypted messages, and other users will see it as trusted.": "Uw nieuwe apparaat is nu geverifieerd. Het heeft toegang tot uw versleutelde berichten en andere personen zien het als vertrouwd.", + "Your new device is now verified. Other users will see it as trusted.": "Jouw nieuwe apparaat is nu geverifieerd. Andere personen zien het nu als vertrouwd.", + "Your new device is now verified. It has access to your encrypted messages, and other users will see it as trusted.": "Jouw nieuwe apparaat is nu geverifieerd. Het heeft toegang tot je versleutelde berichten en andere personen zien het als vertrouwd.", "Verify with another device": "Verifieer met andere apparaat", "Device verified": "Apparaat geverifieerd", "Verify this device": "Verifieer dit apparaat", @@ -2990,14 +2990,14 @@ "Missing domain separator e.g. (:domain.org)": "Ontbrekend domeinscheidingsteken, bijv. (:voorbeeld.nl)", "Backspace": "Backspace", "toggle event": "wissel gebeurtenis", - "You cancelled verification on your other device.": "U heeft de verificatie geannuleerd op het andere apparaat.", - "Almost there! Is your other device showing the same shield?": "U bent er bijna! Toont het andere apparaat hetzelfde schild?", + "You cancelled verification on your other device.": "Je hebt de verificatie geannuleerd op het andere apparaat.", + "Almost there! Is your other device showing the same shield?": "Je bent er bijna! Toont het andere apparaat hetzelfde schild?", "Verify this device by completing one of the following:": "Verifieer dit apparaat door een van onderstaande methodes af te ronden:", - "The device you are trying to verify doesn't support scanning a QR code or emoji verification, which is what %(brand)s supports. Try with a different client.": "Het apparaat dat u probeert te verifiëren ondersteund niet de door %(brand)s ondersteunde methodes: scannen van een QR-code of emoji verificatie. Probeer het met een andere app.", - "To proceed, please accept the verification request on your other device.": "Om door te gaan, accepteer het verificatie verzoek op uw andere apparaat.", - "Waiting for you to verify on your other device…": "Wachten op uw verificatie op het andere apparaat…", - "Waiting for you to verify on your other device, %(deviceName)s (%(deviceId)s)…": "Wachten op uw verificatie op het andere apparaat, %(deviceName)s (%(deviceId)s)…", - "Verify this device by confirming the following number appears on its screen.": "Verifieer dit apparaat door te bevestigen vdat het volgende nummer zichtbaar is op het scherm.", + "The device you are trying to verify doesn't support scanning a QR code or emoji verification, which is what %(brand)s supports. Try with a different client.": "Het apparaat dat je probeert te verifiëren ondersteund niet de door %(brand)s ondersteunde methodes: scannen van een QR-code of emoji verificatie. Probeer het met een andere app.", + "To proceed, please accept the verification request on your other device.": "Om door te gaan, accepteer het verificatie verzoek op je andere apparaat.", + "Waiting for you to verify on your other device…": "Wachten op je verificatie op het andere apparaat…", + "Waiting for you to verify on your other device, %(deviceName)s (%(deviceId)s)…": "Wachten op je verificatie op het andere apparaat, %(deviceName)s (%(deviceId)s)…", + "Verify this device by confirming the following number appears on its screen.": "Verifieer dit apparaat door te bevestigen dat het volgende nummer zichtbaar is op het scherm.", "Confirm the emoji below are displayed on both devices, in the same order:": "Bevestig dat de onderstaande emoji zichtbaar zijn op beide apparaten en in dezelfde volgorde:", "Dial": "Bel", "Back to thread": "Terug naar draad", @@ -3021,8 +3021,8 @@ "were removed %(count)s times|one": "zijn verwijderd", "were removed %(count)s times|other": "werden %(count)s keer verwijderd", "Unknown error fetching location. Please try again later.": "Onbekende fout bij ophalen van locatie. Probeer het later opnieuw.", - "Timed out trying to fetch your location. Please try again later.": "Er is een time-out opgetreden bij het ophalen van uw locatie. Probeer het later opnieuw.", - "Failed to fetch your location. Please try again later.": "Kan uw locatie niet ophalen. Probeer het later opnieuw.", + "Timed out trying to fetch your location. Please try again later.": "Er is een time-out opgetreden bij het ophalen van jouw locatie. Probeer het later opnieuw.", + "Failed to fetch your location. Please try again later.": "Kan jouw locatie niet ophalen. Probeer het later opnieuw.", "Could not fetch location": "Kan locatie niet ophalen", "Message pending moderation": "Bericht in afwachting van moderatie", "Message pending moderation: %(reason)s": "Bericht in afwachting van moderatie: %(reason)s", @@ -3031,11 +3031,11 @@ "Remove them from specific things I'm able to": "Verwijder ze van specifieke dingen die ik kan", "Remove them from everything I'm able to": "Verwijder ze van alles wat ik kan", "Remove from %(roomName)s": "Verwijderen uit %(roomName)s", - "You were removed from %(roomName)s by %(memberName)s": "U bent verwijderd uit %(roomName)s door %(memberName)s", - "You can't see earlier messages": "U kunt eerdere berichten niet zien", + "You were removed from %(roomName)s by %(memberName)s": "Je bent verwijderd uit %(roomName)s door %(memberName)s", + "You can't see earlier messages": "Je kan eerdere berichten niet zien", "Encrypted messages before this point are unavailable.": "Versleutelde berichten vóór dit punt zijn niet beschikbaar.", - "You don't have permission to view messages from before you joined.": "U heeft geen toestemming om berichten te bekijken voor voordat u lid word.", - "You don't have permission to view messages from before you were invited.": "U bent niet gemachtigd om berichten te bekijken van voordat u werd uitgenodigd.", + "You don't have permission to view messages from before you joined.": "Je hebt geen toestemming om berichten te bekijken voor voordat je lid werd.", + "You don't have permission to view messages from before you were invited.": "Je bent niet gemachtigd om berichten te bekijken van voordat je werd uitgenodigd.", "From a thread": "Uit een conversatie", "Remove users": "Personen verwijderen", "Keyboard": "Toetsenbord", @@ -3076,8 +3076,8 @@ "Jump to last message": "Naar het laatste bericht springen", "Jump to first message": "Naar eerste bericht springen", "Toggle hidden event visibility": "Schakel verborgen gebeurteniszichtbaarheid in", - "If you know what you're doing, Element is open-source, be sure to check out our GitHub (https://github.com/vector-im/element-web/) and contribute!": "Als u weet wat u doet, Element is open-source, bekijk dan zeker onze GitHub (https://github.com/vector-im/element-web/) en draag bij!", - "If someone told you to copy/paste something here, there is a high likelihood you're being scammed!": "Als iemand u heeft gezegd iets hier te kopiëren/plakken, is de kans groot dat u wordt opgelicht!", + "If you know what you're doing, Element is open-source, be sure to check out our GitHub (https://github.com/vector-im/element-web/) and contribute!": "Als je weet wat je doet, Element is open-source, bekijk dan zeker onze GitHub (https://github.com/vector-im/element-web/) en draag bij!", + "If someone told you to copy/paste something here, there is a high likelihood you're being scammed!": "Als iemand je heeft gezegd iets hier te kopiëren/plakken, is de kans groot dat je wordt opgelicht!", "Wait!": "Wacht!", "Unable to check if username has been taken. Try again later.": "Kan niet controleren of inlognaam is gebruikt. Probeer het later nog eens.", "This address does not point at this room": "Dit adres verwijst niet naar deze kamer", @@ -3087,10 +3087,10 @@ "Last month": "Vorige maand", "Last week": "Vorige week", "Unable to find event at that date. (%(code)s)": "Kan gebeurtenis op die datum niet vinden. (%(code)s)", - "Group all your rooms that aren't part of a space in one place.": "Groepeer al uw kamers die geen deel uitmaken van een space op één plaats.", - "Group all your people in one place.": "Groepeer al uw mensen op één plek.", - "Group all your favourite rooms and people in one place.": "Groepeer al uw favoriete kamers en mensen op één plek.", - "Spaces are ways to group rooms and people. Alongside the spaces you're in, you can use some pre-built ones too.": "Spaces zijn manieren om kamers en mensen te groeperen. Naast de spaces waarin u zich bevindt, kunt u ook enkele kant-en-klare spaces gebruiken.", + "Group all your rooms that aren't part of a space in one place.": "Groepeer al je kamers die geen deel uitmaken van een space op één plaats.", + "Group all your people in one place.": "Groepeer al je mensen op één plek.", + "Group all your favourite rooms and people in one place.": "Groepeer al je favoriete kamers en mensen op één plek.", + "Spaces are ways to group rooms and people. Alongside the spaces you're in, you can use some pre-built ones too.": "Spaces zijn manieren om kamers en mensen te groeperen. Naast de spaces waarin jij je bevindt, kunt je ook enkele kant-en-klare spaces gebruiken.", "IRC (Experimental)": "IRC (Experimenteel)", "Jump to date (adds /jumptodate and jump to date headers)": "Ga naar datum (voegt /jumptodate en spring naar datum headers toe)", "Right panel stays open (defaults to room member list)": "Rechter paneel blijft open (standaard in lijst met gespreksleden)", @@ -3115,12 +3115,12 @@ "%(severalUsers)sremoved a message %(count)s times|one": "%(severalUsers)shebben een bericht verwijderd", "%(severalUsers)sremoved a message %(count)s times|other": "%(severalUsers)sverwijderde %(count)s berichten", "Maximise": "Maximaliseren", - "You do not have permissions to add spaces to this space": "U bent niet gemachtigd om spaces aan deze space toe te voegen", + "You do not have permissions to add spaces to this space": "Je bent niet gemachtigd om spaces aan deze space toe te voegen", "Automatically send debug logs when key backup is not functioning": "Automatisch foutopsporingslogboeken versturen wanneer de sleutelback-up niet werkt", "Thank you for trying the beta, please go into as much detail as you can so we can improve it.": "Bedankt voor het proberen van de bèta. Ga alsjeblieft zo gedetailleerd mogelijk in op de details zodat we deze kunnen verbeteren.", "How can I leave the beta?": "Hoe kan ik de bèta verlaten?", "%(space1Name)s and %(space2Name)s": "%(space1Name)s en %(space2Name)s", - "You do not have permission to invite people to this space.": "U bent niet gemachtigd om mensen voor deze space uit te nodigen.", + "You do not have permission to invite people to this space.": "Je bent niet gemachtigd om mensen voor deze space uit te nodigen.", "No virtual room for this room": "Geen virtuele ruimte voor deze ruimte", "Switches to this room's virtual room, if it has one": "Schakelt over naar de virtuele kamer van deze kamer, als die er is", "Failed to invite users to %(roomName)s": "Kan personen niet uitnodigen voor %(roomName)s", @@ -3169,14 +3169,14 @@ "Create room": "Ruimte aanmaken", "Create video room": "Videokamer maken", "Create a video room": "Creëer een videokamer", - "Uncheck if you also want to remove system messages on this user (e.g. membership change, profile change…)": "Schakel het vinkje uit als u ook systeemberichten van deze persoon wilt verwijderen (bijv. lidmaatschapswijziging, profielwijziging...)", + "Uncheck if you also want to remove system messages on this user (e.g. membership change, profile change…)": "Schakel het vinkje uit als je ook systeemberichten van deze persoon wil verwijderen (bijv. lidmaatschapswijziging, profielwijziging...)", "Preserve system messages": "Systeemberichten behouden", - "You are about to remove %(count)s messages by %(user)s. This will remove them permanently for everyone in the conversation. Do you wish to continue?|one": "U staat op het punt %(count)s bericht te verwijderen door %(user)s. Hierdoor worden ze permanent verwijderd voor iedereen in het gesprek. Wilt u doorgaan?", - "You are about to remove %(count)s messages by %(user)s. This will remove them permanently for everyone in the conversation. Do you wish to continue?|other": "U staat op het punt %(count)s berichten te verwijderen door %(user)s. Hierdoor worden ze permanent verwijderd voor iedereen in het gesprek. Wilt u doorgaan?", + "You are about to remove %(count)s messages by %(user)s. This will remove them permanently for everyone in the conversation. Do you wish to continue?|one": "Je staat op het punt %(count)s bericht te verwijderen door %(user)s. Hierdoor worden ze permanent verwijderd voor iedereen in het gesprek. Wil je doorgaan?", + "You are about to remove %(count)s messages by %(user)s. This will remove them permanently for everyone in the conversation. Do you wish to continue?|other": "Je staat op het punt %(count)s berichten te verwijderen door %(user)s. Hierdoor worden ze permanent verwijderd voor iedereen in het gesprek. Wil je doorgaan?", "%(featureName)s Beta feedback": "%(featureName)s Bèta-feedback", - "Help us identify issues and improve %(analyticsOwner)s by sharing anonymous usage data. To understand how people use multiple devices, we'll generate a random identifier, shared by your devices.": "Help ons problemen te identificeren en %(analyticsOwner)s te verbeteren door anonieme gebruiksgegevens te delen. Om inzicht te krijgen in hoe mensen meerdere apparaten gebruiken, genereren we een willekeurige identificatie die door uw apparaten wordt gedeeld.", - "You can use the custom server options to sign into other Matrix servers by specifying a different homeserver URL. This allows you to use %(brand)s with an existing Matrix account on a different homeserver.": "U kunt de aangepaste serveropties gebruiken om u aan te melden bij andere Matrix-servers door een andere server-URL op te geven. Hierdoor kunt u %(brand)s gebruiken met een bestaand Matrix-account op een andere thuisserver.", - "Results are only revealed when you end the poll": "Resultaten worden pas onthuld als u de poll beëindigt", + "Help us identify issues and improve %(analyticsOwner)s by sharing anonymous usage data. To understand how people use multiple devices, we'll generate a random identifier, shared by your devices.": "Help ons problemen te identificeren en %(analyticsOwner)s te verbeteren door anonieme gebruiksgegevens te delen. Om inzicht te krijgen in hoe mensen meerdere apparaten gebruiken, genereren we een willekeurige identificatie die door jouw apparaten wordt gedeeld.", + "You can use the custom server options to sign into other Matrix servers by specifying a different homeserver URL. This allows you to use %(brand)s with an existing Matrix account on a different homeserver.": "Je kan de aangepaste serveropties gebruiken om je aan te melden bij andere Matrix-servers door een andere server-URL op te geven. Hierdoor kan je %(brand)s gebruiken met een bestaand Matrix-account op een andere thuisserver.", + "Results are only revealed when you end the poll": "Resultaten worden pas onthuld als je de poll beëindigt", "Voters see results as soon as they have voted": "Kiezers zien resultaten zodra ze hebben gestemd", "Closed poll": "Gesloten poll", "Open poll": "Start poll", @@ -3186,19 +3186,19 @@ "%(oneUser)schanged the pinned messages for the room %(count)s times|other": "%(oneUser)sheeft de vastgezette berichten voor de kamer %(count)s keer gewijzigd", "%(severalUsers)schanged the pinned messages for the room %(count)s times|one": "%(severalUsers)shebben de vastgezette berichten voor de kamer gewijzigd", "%(severalUsers)schanged the pinned messages for the room %(count)s times|other": "%(severalUsers)sheeft de vastgezette berichten voor de kamer %(count)s keer gewijzigd", - "What location type do you want to share?": "Welk locatietype wilt u delen?", + "What location type do you want to share?": "Welk locatietype wil je delen?", "Drop a Pin": "Zet een pin neer", "My live location": "Mijn live locatie", "My current location": "Mijn huidige locatie", "%(displayName)s's live location": "De live locatie van %(displayName)s", - "We couldn't send your location": "We kunnen uw locatie niet versturen", - "%(brand)s could not send your location. Please try again later.": "%(brand)s kan uw locatie niet versturen. Probeer het later opnieuw.", - "%(brand)s was denied permission to fetch your location. Please allow location access in your browser settings.": "%(brand)s heeft geen toestemming gekregen om uw locatie op te halen. Sta locatietoegang toe in uw browserinstellingen.", + "We couldn't send your location": "We kunnen jouw locatie niet versturen", + "%(brand)s could not send your location. Please try again later.": "%(brand)s kan jouw locatie niet versturen. Probeer het later opnieuw.", + "%(brand)s was denied permission to fetch your location. Please allow location access in your browser settings.": "%(brand)s heeft geen toestemming gekregen om jouw locatie op te halen. Sta locatietoegang toe in je browserinstellingen.", "Click to drop a pin": "Klik om een pin neer te zetten", "Click to move the pin": "Klik om de pin te verplaatsen", "Share for %(duration)s": "Delen voor %(duration)s", "Results will be visible when the poll is ended": "Resultaten zijn zichtbaar wanneer de poll is afgelopen", - "Sorry, you can't edit a poll after votes have been cast.": "Sorry, u kunt een poll niet bewerken nadat er gestemd is.", + "Sorry, you can't edit a poll after votes have been cast.": "Sorry, je kan een poll niet bewerken nadat er gestemd is.", "Can't edit poll": "Kan poll niet bewerken", "Shared a location: ": "Een locatie gedeeld: ", "Shared their location: ": "Hun locatie gedeeld: ", @@ -3214,20 +3214,20 @@ "%(count)s participants|one": "1 deelnemer", "%(count)s participants|other": "%(count)s deelnemers", "Video": "Video", - "%(errcode)s was returned while trying to access the room or space. If you think you're seeing this message in error, please submit a bug report.": "%(errcode)s is geretourneerd tijdens een poging om toegang te krijgen tot de kamer of space. Als u denkt dat je dit bericht ten onrechte ziet, dien dan een bugrapport in.", - "Try again later, or ask a room or space admin to check if you have access.": "Probeer het later opnieuw of vraag een kamer- of space beheerder om te controleren of u toegang heeft.", + "%(errcode)s was returned while trying to access the room or space. If you think you're seeing this message in error, please submit a bug report.": "%(errcode)s is geretourneerd tijdens een poging om toegang te krijgen tot de kamer of space. Als je denkt dat je dit bericht ten onrechte ziet, dien dan een bugrapport in.", + "Try again later, or ask a room or space admin to check if you have access.": "Probeer het later opnieuw of vraag een kamer- of space beheerder om te controleren of je toegang hebt.", "This room or space is not accessible at this time.": "Deze kamer of space is op dit moment niet toegankelijk.", - "Are you sure you're at the right place?": "Weet u zeker dat je op de goede locatie bent?", + "Are you sure you're at the right place?": "Weet je zeker dat je op de goede locatie bent?", "This room or space does not exist.": "Deze kamer of space bestaat niet.", - "There's no preview, would you like to join?": "Er is geen preview, wilt u toetreden?", + "There's no preview, would you like to join?": "Er is geen preview, wil je toetreden?", "This invite was sent to %(email)s": "De uitnodiging is verzonden naar %(email)s", - "This invite was sent to %(email)s which is not associated with your account": "Deze uitnodiging is verzonden naar %(email)s die niet is gekoppeld aan uw account", - "You can still join here.": "U kunt hier nog toetreden.", - "An error (%(errcode)s) was returned while trying to validate your invite. You could try to pass this information on to the person who invited you.": "Er is een fout (%(errcode)s) geretourneerd tijdens het valideren van uw uitnodiging. U kunt proberen deze informatie door te geven aan de persoon die u heeft uitgenodigd.", - "Something went wrong with your invite.": "Er is iets misgegaan met uw uitnodiging.", - "You were banned by %(memberName)s": "U bent verbannen door %(memberName)s", + "This invite was sent to %(email)s which is not associated with your account": "Deze uitnodiging is verzonden naar %(email)s die niet is gekoppeld aan jouw account", + "You can still join here.": "Je kan hier nog toetreden.", + "An error (%(errcode)s) was returned while trying to validate your invite. You could try to pass this information on to the person who invited you.": "Er is een fout (%(errcode)s) geretourneerd tijdens het valideren van je uitnodiging. Je kan proberen deze informatie door te geven aan de persoon die je hebt uitgenodigd.", + "Something went wrong with your invite.": "Er is iets misgegaan met je uitnodiging.", + "You were banned by %(memberName)s": "Je bent verbannen door %(memberName)s", "Forget this space": "Vergeet deze space", - "You were removed by %(memberName)s": "U bent verwijderd door %(memberName)s", + "You were removed by %(memberName)s": "Je bent verwijderd door %(memberName)s", "Loading preview": "Voorbeeld laden", "Joining …": "Deelnemen…", "Currently removing messages in %(count)s rooms|one": "Momenteel berichten in %(count)s kamer aan het verwijderen", @@ -3238,9 +3238,9 @@ "Remove messages sent by me": "Door mij verzonden berichten verwijderen", "View older version of %(spaceName)s.": "Bekijk oudere versie van %(spaceName)s.", "Upgrade this space to the recommended room version": "Upgrade deze ruimte naar de aanbevolen kamerversie", - "Debug logs contain application usage data including your username, the IDs or aliases of the rooms you have visited, which UI elements you last interacted with, and the usernames of other users. They do not contain messages.": "Foutopsporingslogboeken bevatten toepassingsgebruiksgegevens, waaronder uw inlognaam, de ID's of aliassen van de kamers die u heeft bezocht, met welke UI-elementen u voor het laatst interactie heeft gehad en de inlognamen van andere personen. Ze bevatten geen berichten.", - "If you've submitted a bug via GitHub, debug logs can help us track down the problem. ": "Als u een bug via GitHub heeft ingediend, kunnen foutopsporingslogboeken ons helpen het probleem op te sporen. ", - "Spaces are a new way to group rooms and people. What kind of Space do you want to create? You can change this later.": "Spaces zijn een nieuwe manier om kamers en mensen te groeperen. Wat voor ruimte wilt u aanmaken? U kunt dit later wijzigen.", + "Debug logs contain application usage data including your username, the IDs or aliases of the rooms you have visited, which UI elements you last interacted with, and the usernames of other users. They do not contain messages.": "Foutopsporingslogboeken bevatten toepassingsgebruiksgegevens, waaronder je inlognaam, de ID's of aliassen van de kamers die je hebt bezocht, met welke UI-elementen je voor het laatst interactie hebt gehad en de inlognamen van andere personen. Ze bevatten geen berichten.", + "If you've submitted a bug via GitHub, debug logs can help us track down the problem. ": "Als je een bug via GitHub hebt ingediend, kunnen foutopsporingslogboeken ons helpen het probleem op te sporen. ", + "Spaces are a new way to group rooms and people. What kind of Space do you want to create? You can change this later.": "Spaces zijn een nieuwe manier om kamers en mensen te groeperen. Wat voor ruimte wil je aanmaken? Je kan dit later wijzigen.", "Match system": "Match systeem", "Developer tools": "Ontwikkelaarstools", "sends hearts": "stuurt hartjes", @@ -3248,13 +3248,13 @@ "Insert a trailing colon after user mentions at the start of a message": "Voeg een dubbele punt in nadat de persoon het aan het begin van een bericht heeft vermeld", "Show polls button": "Toon polls-knop", "Show current avatar and name for users in message history": "Toon huidige avatar en naam voor persoon in berichtgeschiedenis", - "How can I start a thread?": "Hoe kan ik een discussie starten?", - "Threads help keep conversations on-topic and easy to track. Learn more.": "Discussies helpen om gesprekken on-topic te houden en gemakkelijk te volgen. Meer informatie.", + "How can I start a thread?": "Hoe kan ik een thread starten?", + "Threads help keep conversations on-topic and easy to track. Learn more.": "Threads helpen om gesprekken on-topic te houden en gemakkelijk te volgen. Meer informatie.", "Keep discussions organised with threads.": "Houd discussies georganiseerd met discussielijnen.", "Failed to join": "Kan niet deelnemen", - "The person who invited you has already left, or their server is offline.": "De persoon die u heeft uitgenodigd is al vertrokken, of zijn server is offline.", - "The person who invited you has already left.": "De persoon die u heeft uitgenodigd is al vertrokken.", - "Sorry, your homeserver is too old to participate here.": "Sorry, uw server is te oud om hier deel te nemen.", + "The person who invited you has already left, or their server is offline.": "De persoon die je heeft uitgenodigd is al vertrokken, of zijn server is offline.", + "The person who invited you has already left.": "De persoon die je heeft uitgenodigd is al vertrokken.", + "Sorry, your homeserver is too old to participate here.": "Sorry, je server is te oud om hier aan deel te nemen.", "There was an error joining.": "Er is een fout opgetreden bij het deelnemen.", "%(brand)s is experimental on a mobile web browser. For a better experience and the latest features, use our free native app.": "%(brand)s is experimenteel in een mobiele webbrowser. Gebruik onze gratis native app voor een betere ervaring en de nieuwste functies.", "This homeserver is not configured correctly to display maps, or the configured map server may be unreachable.": "Deze server is niet correct geconfigureerd om kaarten weer te geven, of de geconfigureerde kaartserver is mogelijk onbereikbaar.", @@ -3271,16 +3271,16 @@ "Accessibility": "Toegankelijkheid", "Event ID: %(eventId)s": "Gebeurtenis ID: %(eventId)s", "Give feedback": "Feedback geven", - "Threads are a beta feature": "Discussies zijn een bètafunctie", - "Threads help keep your conversations on-topic and easy to track.": "Discussies helpen u gesprekken on-topic te houden en gemakkelijk bij te houden.", - "Reply to an ongoing thread or use “%(replyInThread)s” when hovering over a message to start a new one.": "Reageer op een lopende discussie of gebruik \"%(replyInThread)s\" wanneer u de muisaanwijzer op een bericht plaatst om een nieuwe te starten.", + "Threads are a beta feature": "Threads zijn een bètafunctie", + "Threads help keep your conversations on-topic and easy to track.": "Threads helpen jou gesprekken on-topic te houden en gemakkelijk bij te houden.", + "Reply to an ongoing thread or use “%(replyInThread)s” when hovering over a message to start a new one.": "Reageer op een lopende thread of gebruik \"%(replyInThread)s\" wanneer je de muisaanwijzer op een bericht plaatst om een nieuwe te starten.", "We'll create rooms for each of them.": "We zullen kamers voor elk van hen maken.", - "If you can't find the room you're looking for, ask for an invite or create a new room.": "Als u de kamer die u zoekt niet kunt vinden, vraag dan om een uitnodiging of maak een nieuwe kamer aan.", + "If you can't find the room you're looking for, ask for an invite or create a new room.": "Als je de kamer die je zoekt niet kan vinden, vraag dan om een uitnodiging of maak een nieuwe kamer aan.", "Stop sharing and close": "Stop met delen en sluit", "Stop sharing": "Stop delen", - "An error occurred while stopping your live location, please try again": "Er is een fout opgetreden bij het stoppen van uw live locatie, probeer het opnieuw", + "An error occurred while stopping your live location, please try again": "Er is een fout opgetreden bij het stoppen van je live locatie, probeer het opnieuw", "%(timeRemaining)s left": "%(timeRemaining)s over", - "You are sharing your live location": "U deelt uw live locatie", + "You are sharing your live location": "Je deelt je live locatie", "No verification requests found": "Geen verificatieverzoeken gevonden", "Open user settings": "Open persooninstellingen", "Switch to space by number": "Overschakelen naar space op nummer", @@ -3302,16 +3302,16 @@ "Confirm signing out these devices|one": "Uitloggen van dit apparaat bevestigen", "Confirm signing out these devices|other": "Uitloggen van deze apparaten bevestigen", "Yes, enable": "Ja, inschakelen", - "Do you want to enable threads anyway?": "Wil je toch discussies inschakelen?", - "Your homeserver does not currently support threads, so this feature may be unreliable. Some threaded messages may not be reliably available. Learn more.": "Uw server ondersteunt momenteel geen discussies, dus deze functie kan onbetrouwbaar zijn. Sommige berichten in een discussie zijn mogelijk niet betrouwbaar beschikbaar. Meer informatie.", - "Partial Support for Threads": "Gedeeltelijke ondersteuning voor Discussies", + "Do you want to enable threads anyway?": "Wil je toch threads inschakelen?", + "Your homeserver does not currently support threads, so this feature may be unreliable. Some threaded messages may not be reliably available. Learn more.": "Jouw server ondersteunt momenteel geen threads, dus deze functie kan onbetrouwbaar zijn. Sommige berichten in een thread zijn mogelijk niet betrouwbaar beschikbaar. Meer informatie.", + "Partial Support for Threads": "Gedeeltelijke ondersteuning voor Threads", "Right-click message context menu": "Rechtermuisknop op het bericht voor opties", "Jump to the given date in the timeline": "Spring naar de opgegeven datum in de tijdlijn", - "You have been logged out of all devices and will no longer receive push notifications. To re-enable notifications, sign in again on each device.": "U bent afgemeld op al uw apparaten en zult geen pushmeldingen meer ontvangen. Meld u op elk apparaat opnieuw aan om weer meldingen te ontvangen.", + "You have been logged out of all devices and will no longer receive push notifications. To re-enable notifications, sign in again on each device.": "Je bent afgemeld op al je apparaten en zal geen pushmeldingen meer ontvangen. Meld je op elk apparaat opnieuw aan om weer meldingen te ontvangen.", "Sign out all devices": "Apparaten uitloggen", - "If you want to retain access to your chat history in encrypted rooms, set up Key Backup or export your message keys from one of your other devices before proceeding.": "Als u toegang tot uw berichten wilt behouden, stel dan sleutelback-up in of exporteer uw sleutels vanaf een van uw andere apparaten voordat u verder gaat.", - "Signing out your devices will delete the message encryption keys stored on them, making encrypted chat history unreadable.": "Uw apparaten uitloggen zal de ertoe behorende encryptiesleutels verwijderen, wat versleutelde berichten onleesbaar zal maken.", - "Resetting your password on this homeserver will cause all of your devices to be signed out. This will delete the message encryption keys stored on them, making encrypted chat history unreadable.": "Uw wachtwoorden veranderen op deze homeserver zal u uit al uw andere apparaten uitloggen. Hierdoor zullen de encryptiesleutels van uw berichten verloren gaan, wat versleutelde berichten onleesbaar zal maken.", + "If you want to retain access to your chat history in encrypted rooms, set up Key Backup or export your message keys from one of your other devices before proceeding.": "Als je toegang tot je berichten wilt behouden, stel dan sleutelback-up in of exporteer je sleutels vanaf een van je andere apparaten voordat je verder gaat.", + "Signing out your devices will delete the message encryption keys stored on them, making encrypted chat history unreadable.": "Jouw apparaten uitloggen zal de ertoe behorende encryptiesleutels verwijderen, wat versleutelde berichten onleesbaar zal maken.", + "Resetting your password on this homeserver will cause all of your devices to be signed out. This will delete the message encryption keys stored on them, making encrypted chat history unreadable.": "Jouw wachtwoorden veranderen op deze homeserver zal je uit al je andere apparaten uitloggen. Hierdoor zullen de encryptiesleutels van je berichten verloren gaan, wat versleutelde berichten onleesbaar zal maken.", "Tip: Use “%(replyInThread)s” when hovering over a message.": "Tip: Gebruik “%(replyInThread)s” met de muiscursor boven een bericht.", "Close sidebar": "Zijbalk sluiten", "View List": "Toon Lijst", @@ -3319,21 +3319,21 @@ "No live locations": "Geen live locaties", "Updated %(humanizedUpdateTime)s": "%(humanizedUpdateTime)s bijgewerkt", "Hide my messages from new joiners": "Verberg mijn berichten voor nieuwe deelnemers", - "Your old messages will still be visible to people who received them, just like emails you sent in the past. Would you like to hide your sent messages from people who join rooms in the future?": "Zullen uw oude berichten nog steeds zichtbaar zijn voor de mensen die ze ontvangen hebben, net zoals e-mails die u in het verleden verstuurd hebt. Zou u uw verstuurde berichten willen verbergen voor mensen die kamers in de toekomst toetreden?", - "You will be removed from the identity server: your friends will no longer be able to find you with your email or phone number": "Zal u van de identiteitsserver worden verwijderd: uw vrienden zullen u niet meer kunnen vinden via uw e-mailadres of telefoonnummer", - "You will leave all rooms and DMs that you are in": "Zal u alle kamers en directe chats waar u zich in bevindt verlaten", - "No one will be able to reuse your username (MXID), including you: this username will remain unavailable": "Zal niemand uw gebruikersnaam (MXID) kunnen hergebruiken, inclusief u: deze gebruikersnaam zal onbeschikbaar blijven", - "You will no longer be able to log in": "Zal u niet meer kunnen inloggen", - "You will not be able to reactivate your account": "Zal u uw account niet kunnen heractiveren", - "Confirm that you would like to deactivate your account. If you proceed:": "Bevestig dat u uw account wilt deactiveren. Als u doorgaat:", - "To continue, please enter your account password:": "Voer uw wachtwoord in om verder te gaan:", + "Your old messages will still be visible to people who received them, just like emails you sent in the past. Would you like to hide your sent messages from people who join rooms in the future?": "Zullen jouw oude berichten nog steeds zichtbaar zijn voor de mensen die ze ontvangen hebben, net zoals e-mails die je in het verleden verstuurd hebt. Zou je jouw verstuurde berichten willen verbergen voor mensen die kamers in de toekomst toetreden?", + "You will be removed from the identity server: your friends will no longer be able to find you with your email or phone number": "Zal je van de identiteitsserver worden verwijderd: je vrienden zullen je niet meer kunnen vinden via je e-mailadres of telefoonnummer", + "You will leave all rooms and DMs that you are in": "Zal je alle kamers en directe chats waar jij je in bevindt verlaten", + "No one will be able to reuse your username (MXID), including you: this username will remain unavailable": "Zal niemand jouw gebruikersnaam (MXID) kunnen hergebruiken, inclusief jij: deze gebruikersnaam zal onbeschikbaar blijven", + "You will no longer be able to log in": "Zal je niet meer kunnen inloggen", + "You will not be able to reactivate your account": "Zal je jouw account niet kunnen heractiveren", + "Confirm that you would like to deactivate your account. If you proceed:": "Bevestig dat je jouw account wil deactiveren. Als je doorgaat:", + "To continue, please enter your account password:": "Voer je wachtwoord in om verder te gaan:", "Seen by %(count)s people|one": "Gezien door %(count)s persoon", "Seen by %(count)s people|other": "Gezien door %(count)s mensen", - "You will not receive push notifications on other devices until you sign back in to them.": "U zult geen pushmeldingen ontvangen op uw andere apparaten totdat u er weer op inlogt.", + "You will not receive push notifications on other devices until you sign back in to them.": "Je zal geen pushmeldingen ontvangen op je andere apparaten totdat je er weer op inlogt.", "Your password was successfully changed.": "Wachtwoord veranderen geslaagd.", - "Changing your password on this homeserver will cause all of your other devices to be signed out. This will delete the message encryption keys stored on them, and may make encrypted chat history unreadable.": "Uw wachtwoorden veranderen op deze homeserver zal u uit al uw andere apparaten uitloggen. Hierdoor zullen de encryptiesleutels van uw berichten verloren gaan, wat mogelijk versleutelde berichten onleesbaar kan maken.", - "If you want to retain access to your chat history in encrypted rooms you should first export your room keys and re-import them afterwards.": "Als u toegang tot uw versleutelde chatgeschiedenis wilt behouden kan u eerst uw encryptiesleutels exporteren en ze na afloop weer herimporteren.", - "You can also ask your homeserver admin to upgrade the server to change this behaviour.": "U kunt ook uw homeserveradministrator vragen de server te updateten om dit gedrag te veranderen.", + "Changing your password on this homeserver will cause all of your other devices to be signed out. This will delete the message encryption keys stored on them, and may make encrypted chat history unreadable.": "Jouw wachtwoorden veranderen op deze homeserver zal je uit al uw andere apparaten uitloggen. Hierdoor zullen de encryptiesleutels van je berichten verloren gaan, wat mogelijk versleutelde berichten onleesbaar kan maken.", + "If you want to retain access to your chat history in encrypted rooms you should first export your room keys and re-import them afterwards.": "Als je toegang tot je versleutelde chatgeschiedenis wilt behouden kan je eerst je encryptiesleutels exporteren en ze na afloop weer herimporteren.", + "You can also ask your homeserver admin to upgrade the server to change this behaviour.": "Je kan ook je homeserveradministrator vragen de server te updateten om dit gedrag te veranderen.", "Turn on camera": "Camera inschakelen", "Turn off camera": "Camera uitschakelen", "Video devices": "Video-apparaten", @@ -3346,11 +3346,11 @@ "Use “%(replyInThread)s” when hovering over a message.": "Houd de muiscursor boven een bericht en gebruik “%(replyInThread)s”.", "An error occurred while stopping your live location": "Er is een fout opgetreden bij het stoppen van je live locatie", "Enable live location sharing": "Live locatie delen inschakelen", - "Please note: this is a labs feature using a temporary implementation. This means you will not be able to delete your location history, and advanced users will be able to see your location history even after you stop sharing your live location with this room.": "Let op: dit is een labfunctie met een tijdelijke implementatie. Dit betekent dat u uw locatiegeschiedenis niet kunt verwijderen en dat geavanceerde gebruikers uw locatiegeschiedenis kunnen zien, zelfs nadat u stopt met het delen van uw live locatie met deze ruimte.", + "Please note: this is a labs feature using a temporary implementation. This means you will not be able to delete your location history, and advanced users will be able to see your location history even after you stop sharing your live location with this room.": "Let op: dit is een labfunctie met een tijdelijke implementatie. Dit betekent dat je jouw locatiegeschiedenis niet kunt verwijderen en dat geavanceerde gebruikers jouw locatiegeschiedenis kunnen zien, zelfs nadat je stopt met het delen van uw live locatie met deze ruimte.", "Live location sharing": "Live locatie delen", "Live Location Sharing (temporary implementation: locations persist in room history)": "Live Locatie delen (tijdelijke implementatie: locaties blijven bestaan in kamergeschiedenis)", "Location sharing - pin drop": "Locatie delen - pin neerzetten", - "Your message wasn't sent because this homeserver has been blocked by its administrator. Please contact your service administrator to continue using the service.": "Uw bericht is niet verzonden omdat deze server is geblokkeerd door de beheerder. Neem contact op met uw servicebeheerder om de service te blijven gebruiken.", + "Your message wasn't sent because this homeserver has been blocked by its administrator. Please contact your service administrator to continue using the service.": "Je bericht is niet verzonden omdat deze server is geblokkeerd door de beheerder. Neem contact op met je servicebeheerder om de service te blijven gebruiken.", "Cameras": "Camera's", "Output devices": "Uitvoerapparaten", "Input devices": "Invoer apparaten", @@ -3359,17 +3359,17 @@ "To join, please enable video rooms in Labs first": "Schakel eerst videokamers in Labs in om deel te nemen", "Open room": "Open kamer", "Show Labs settings": "Lab instellingen weergeven", - "To view %(roomName)s, you need an invite": "Om %(roomName)s te bekijken, heeft u een uitnodiging nodig", + "To view %(roomName)s, you need an invite": "Om %(roomName)s te bekijken, heb je een uitnodiging nodig", "Video room": "Video kamer", "%(members)s and %(last)s": "%(members)s en %(last)s", "%(members)s and more": "%(members)s en meer", "Resent!": "Opnieuw versturen!", "Did not receive it? Resend it": "Niet ontvangen? Stuur het opnieuw", - "To create your account, open the link in the email we just sent to %(emailAddress)s.": "Om uw account aan te maken, opent u de link in de e-mail die we zojuist naar %(emailAddress)s hebben gestuurd.", + "To create your account, open the link in the email we just sent to %(emailAddress)s.": "Om jouw account aan te maken, open je de link in de e-mail die we zojuist naar %(emailAddress)s hebben gestuurd.", "Unread email icon": "Ongelezen e-mailpictogram", - "Check your email to continue": "Controleer uw e-mail om door te gaan", - "An error occurred whilst sharing your live location, please try again": "Er is een fout opgetreden bij het delen van uw live locatie, probeer het opnieuw", - "An error occurred whilst sharing your live location": "Er is een fout opgetreden bij het delen van uw live locatie", + "Check your email to continue": "Controleer je e-mail om door te gaan", + "An error occurred whilst sharing your live location, please try again": "Er is een fout opgetreden bij het delen van je live locatie, probeer het opnieuw", + "An error occurred whilst sharing your live location": "Er is een fout opgetreden bij het delen van je live locatie", "View related event": "Bekijk gerelateerde gebeurtenis", "Check if you want to hide all current and future messages from this user.": "Vink aan als je alle huidige en toekomstige berichten van deze persoon wilt verbergen.", "Ignore user": "Negeer persoon", @@ -3385,9 +3385,9 @@ "Connection lost": "Verbinding verloren", "Minimise": "Minimaliseren", "Un-maximise": "Maximaliseren ongedaan maken", - "Deactivating your account is a permanent action — be careful!": "Het deactiveren van uw account is een permanente actie - wees voorzichtig!", + "Deactivating your account is a permanent action — be careful!": "Het deactiveren van je account is een permanente actie - wees voorzichtig!", "Joining the beta will reload %(brand)s.": "Door deel te nemen aan de bèta wordt %(brand)s opnieuw geladen.", - "Leaving the beta will reload %(brand)s.": "Als u de bèta verlaat, wordt %(brand)s opnieuw geladen.", + "Leaving the beta will reload %(brand)s.": "Als je de bèta verlaat, wordt %(brand)s opnieuw geladen.", "Remove search filter for %(filter)s": "Verwijder zoekfilter voor %(filter)s", "Start a group chat": "Start een groepsgesprek", "Other options": "Andere opties", @@ -3399,7 +3399,7 @@ "Search for": "Zoeken naar", "%(count)s Members|one": "%(count)s Lid", "%(count)s Members|other": "%(count)s Leden", - "When you sign out, these keys will be deleted from this device, which means you won't be able to read encrypted messages unless you have the keys for them on your other devices, or backed them up to the server.": "Wanneer u zich afmeldt, worden deze sleutels van dit apparaat verwijderd, wat betekent dat u geen versleutelde berichten kunt lezen, tenzij u de sleutels ervoor op uw andere apparaten heeft of er een back-up van heeft gemaakt op de server.", + "When you sign out, these keys will be deleted from this device, which means you won't be able to read encrypted messages unless you have the keys for them on your other devices, or backed them up to the server.": "Wanneer je jezelf afmeldt, worden deze sleutels van dit apparaat verwijderd, wat betekent dat je geen versleutelde berichten kunt lezen, tenzij je de sleutels ervoor op je andere apparaten hebt of er een back-up van hebt gemaakt op de server.", "Show: Matrix rooms": "Toon: Matrix kamers", "Show: %(instance)s rooms (%(server)s)": "Toon: %(instance)s kamers (%(server)s)", "Add new server…": "Nieuwe server toevoegen…", @@ -3411,7 +3411,7 @@ "Can I use text chat alongside the video call?": "Kan ik tekstberichten gebruiken naast het videogesprek?", "Use the “+” button in the room section of the left panel.": "Gebruik de knop \"+\" in het kamergedeelte van het linkerpaneel.", "How can I create a video room?": "Hoe kan ik een videokamer maken?", - "Video rooms are always-on VoIP channels embedded within a room in %(brand)s.": "Videoruimten zijn altijd-ann VoIP-kanalen die zijn geïntegreerd in een kamer in %(brand)s.", + "Video rooms are always-on VoIP channels embedded within a room in %(brand)s.": "Videoruimten zijn altijd-aan VoIP-kanalen die zijn geïntegreerd in een kamer in %(brand)s.", "A new way to chat over voice and video in %(brand)s.": "Een nieuwe manier om te chatten via spraak en video in %(brand)s.", "Video rooms": "Video kamers", "You cannot search for rooms that are neither a room nor a space": "U kunt niet zoeken naar kamers die geen kamer of een space zijn", @@ -3419,15 +3419,15 @@ "Show rooms": "Toon kamers", "Explore public spaces in the new search dialog": "Ontdek openbare ruimtes in het nieuwe zoekvenster", "Stop and close": "Stop en sluit", - "You can't disable this later. The room will be encrypted but the embedded call will not.": "U kunt dit later niet uitschakelen. De kamer wordt gecodeerd, maar de ingesloten oproep niet.", + "You can't disable this later. The room will be encrypted but the embedded call will not.": "Je kan dit later niet uitschakelen. De kamer wordt gecodeerd, maar de ingesloten oproep niet.", "Online community members": "Leden van online gemeenschap", "Coworkers and teams": "Collega's en teams", "Friends and family": "Vrienden en familie", - "We'll help you get connected.": "We helpen u om verbinding te maken.", - "Who will you chat to the most?": "Met wie gaat u het meest chatten?", - "You're in": "U bent binnen", - "You need to have the right permissions in order to share locations in this room.": "U dient de juiste rechten te hebben om locaties in deze ruimte te delen.", - "You don't have permission to share locations": "U bent niet gemachtigd om locaties te delen", + "We'll help you get connected.": "We helpen je om verbinding te maken.", + "Who will you chat to the most?": "Met wie ga je het meest chatten?", + "You're in": "Je bent binnen", + "You need to have the right permissions in order to share locations in this room.": "Je dient de juiste rechten te hebben om locaties in deze ruimte te delen.", + "You don't have permission to share locations": "Je bent niet gemachtigd om locaties te delen", "Join the room to participate": "Doe mee met de kamer om deel te nemen", "Reset bearing to north": "Indicatie naar het noorden resetten", "Mapbox logo": "Mapbox logo", @@ -3444,7 +3444,7 @@ "Developer command: Discards the current outbound group session and sets up new Olm sessions": "Opdracht voor ontwikkelaars: verwijdert de huidige uitgaande groepssessie en stelt nieuwe Olm-sessies in", "Messages in this chat will be end-to-end encrypted.": "Berichten in deze chat worden eind-tot-eind versleuteld.", "Saved Items": "Opgeslagen items", - "Send your first message to invite to chat": "Stuur uw eerste bericht om uit te nodigen om te chatten", + "Send your first message to invite to chat": "Stuur je eerste bericht om uit te nodigen om te chatten", "Favourite Messages (under active development)": "Favoriete berichten (in actieve ontwikkeling)", "We're creating a room with %(names)s": "We maken een kamer aan met %(names)s", "Google Play and the Google Play logo are trademarks of Google LLC.": "Google Play en het Google Play-logo zijn handelsmerken van Google LLC.", @@ -3460,16 +3460,16 @@ "Help": "Help", "Spell check": "Spellingscontrole", "Complete these to get the most out of %(brand)s": "Voltooi deze om het meeste uit %(brand)s te halen", - "You did it!": "Het is u gelukt!", + "You did it!": "Het is je gelukt!", "Only %(count)s steps to go|one": "Nog maar %(count)s stap te gaan", "Only %(count)s steps to go|other": "Nog maar %(count)s stappen te gaan", "Welcome to %(brand)s": "Welkom bij %(brand)s", - "Find your people": "Vind uw mensen", + "Find your people": "Vind je mensen", "Keep ownership and control of community discussion.\nScale to support millions, with powerful moderation and interoperability.": "Houd het eigendom en de controle over de discussie in de gemeenschap.\nSchaal om miljoenen te ondersteunen, met krachtige beheersbaarheid en interoperabiliteit.", "Community ownership": "Gemeenschapseigendom", - "Find your co-workers": "Vind uw collega's", + "Find your co-workers": "Vind je collega's", "Secure messaging for work": "Veilig berichten versturen voor werk", - "Start your first chat": "Start uw eerste chat", + "Start your first chat": "Start je eerste chat", "With free end-to-end encrypted messaging, and unlimited voice and video calls, %(brand)s is a great way to stay in touch.": "Met gratis eind-tot-eind versleutelde berichten en onbeperkte spraak- en video-oproepen, is %(brand)s een geweldige manier om in contact te blijven.", "Secure messaging for friends and family": "Veilig berichten versturen voor vrienden en familie", "We’d appreciate any feedback on how you’re finding Element.": "We stellen het op prijs als u feedback geeft over hoe u Element vindt.", @@ -3477,24 +3477,24 @@ "Enable notifications": "Meldingen inschakelen", "Don’t miss a reply or important message": "Mis geen antwoord of belangrijk bericht", "Turn on notifications": "Meldingen aanzetten", - "Your profile": "Uw profiel", - "Make sure people know it’s really you": "Zorg ervoor dat mensen weten dat u het echt bent", - "Set up your profile": "Stel uw profiel in", + "Your profile": "Jouw profiel", + "Make sure people know it’s really you": "Zorg ervoor dat mensen weten dat je het echt bent", + "Set up your profile": "Stel je profiel in", "Download apps": "Apps downloaden", "Don’t miss a thing by taking Element with you": "Mis niets door Element mee te nemen", "Download Element": "Element downloaden", - "Find and invite your community members": "Vind en nodig uw communityleden uit", + "Find and invite your community members": "Vind en nodig je communityleden uit", "Find people": "Zoek mensen", - "Get stuff done by finding your teammates": "Krijg dingen gedaan door uw teamgenoten te vinden", - "Find and invite your co-workers": "Vind en nodig uw collega's uit", + "Get stuff done by finding your teammates": "Krijg dingen gedaan door je teamgenoten te vinden", + "Find and invite your co-workers": "Vind en nodig je collega's uit", "Find friends": "Zoek vrienden", - "It’s what you’re here for, so lets get to it": "Daar bent u voor, dus laten we beginnen", - "Find and invite your friends": "Zoek uw vrienden en nodig ze uit", - "You made it!": "Het is u gelukt!", + "It’s what you’re here for, so lets get to it": "Daar ben je voor, dus laten we beginnen", + "Find and invite your friends": "Zoek je vrienden en nodig ze uit", + "You made it!": "Het is je gelukt!", "Interactively verify by emoji": "Interactief verifiëren door emoji", "Manually verify by text": "Handmatig verifiëren via tekst", "View all": "Bekijk alles", - "Improve your account security by following these recommendations": "Verbeter uw accountbeveiliging door deze aanbevelingen op te volgen", + "Improve your account security by following these recommendations": "Verbeter je accountbeveiliging door deze aanbevelingen op te volgen", "Security recommendations": "Beveiligingsaanbevelingen", "Filter devices": "Filter apparaten", "Inactive for %(inactiveAgeDays)s days or longer": "Inactief gedurende %(inactiveAgeDays)s dagen of langer", @@ -3506,13 +3506,13 @@ "No inactive sessions found.": "Geen inactieve sessies gevonden.", "No unverified sessions found.": "Geen niet-geverifieerde sessies gevonden.", "No verified sessions found.": "Geen geverifieerde sessies gevonden.", - "Consider signing out from old sessions (%(inactiveAgeDays)s days or older) you don't use anymore": "Overweeg om u af te melden bij oude sessies (%(inactiveAgeDays)s dagen of ouder) die u niet meer gebruikt", + "Consider signing out from old sessions (%(inactiveAgeDays)s days or older) you don't use anymore": "Overweeg om je af te melden bij oude sessies (%(inactiveAgeDays)s dagen of ouder) die je niet meer gebruikt", "Inactive sessions": "Inactieve sessies", - "Verify your sessions for enhanced secure messaging or sign out from those you don't recognize or use anymore.": "Verifieer uw sessies voor verbeterde beveiligde berichtenuitwisseling of meld u af bij sessies die u niet meer herkent of gebruikt.", + "Verify your sessions for enhanced secure messaging or sign out from those you don't recognize or use anymore.": "Verifieer je sessies voor verbeterde beveiligde berichtenuitwisseling of meld je af bij sessies die je niet meer herkent of gebruikt.", "Unverified sessions": "Niet geverifieerde sessies", - "For best security, sign out from any session that you don't recognize or use anymore.": "Meld u voor de beste beveiliging af bij elke sessie die u niet meer herkent of gebruikt.", + "For best security, sign out from any session that you don't recognize or use anymore.": "Meld je voor de beste beveiliging af bij elke sessie die je niet meer herkent of gebruikt.", "Verified sessions": "Geverifieerde sessies", - "Verify or sign out from this session for best security and reliability.": "Verifieer of meld u af bij deze sessie voor de beste beveiliging en betrouwbaarheid.", + "Verify or sign out from this session for best security and reliability.": "Verifieer of meld je af bij deze sessie voor de beste beveiliging en betrouwbaarheid.", "Unverified session": "Niet-geverifieerde sessie", "This session is ready for secure messaging.": "Deze sessie is klaar voor beveiligde berichtenuitwisseling.", "Verified session": "Geverifieerde sessie", @@ -3525,15 +3525,15 @@ "Device": "Apparaat", "Last activity": "Laatste activiteit", "Current session": "Huidige sessie", - "It's not recommended to add encryption to public rooms. Anyone can find and join public rooms, so anyone can read messages in them. You'll get none of the benefits of encryption, and you won't be able to turn it off later. Encrypting messages in a public room will make receiving and sending messages slower.": "Het wordt niet aanbevolen om versleuteling toe te voegen aan openbare ruimten. Iedereen kan openbare ruimten vinden en er lid van worden, dus iedereen kan berichten erin lezen. U profiteert niet van de voordelen van versleuteling en kunt deze later niet uitschakelen. Het versleutelen van berichten in een openbare ruimte zal het ontvangen en verzenden van berichten langzamer maken.", - "For best security, verify your sessions and sign out from any session that you don't recognize or use anymore.": "Voor de beste beveiliging verifieert u uw sessies en meldt u zich af bij elke sessie die u niet meer herkent of gebruikt.", + "It's not recommended to add encryption to public rooms. Anyone can find and join public rooms, so anyone can read messages in them. You'll get none of the benefits of encryption, and you won't be able to turn it off later. Encrypting messages in a public room will make receiving and sending messages slower.": "Het wordt niet aanbevolen om versleuteling toe te voegen aan openbare ruimten. Iedereen kan openbare ruimten vinden en er lid van worden, dus iedereen kan berichten erin lezen. Je profiteert niet van de voordelen van versleuteling en kunt deze later niet uitschakelen. Het versleutelen van berichten in een openbare ruimte zal het ontvangen en verzenden van berichten langzamer maken.", + "For best security, verify your sessions and sign out from any session that you don't recognize or use anymore.": "Voor de beste beveiliging verifieer je jouw sessies en meldt je jezelf af bij elke sessie die je niet meer herkent of gebruikt.", "Other sessions": "Andere sessies", "Sessions": "Sessies", - "Your server doesn't support disabling sending read receipts.": "Uw server biedt geen ondersteuning voor het uitschakelen van het verzenden van leesbevestigingen.", - "Share your activity and status with others.": "Deel uw activiteit en status met anderen.", + "Your server doesn't support disabling sending read receipts.": "Jouw server biedt geen ondersteuning voor het uitschakelen van het verzenden van leesbevestigingen.", + "Share your activity and status with others.": "Deel je activiteit en status met anderen.", "Presence": "Aanwezigheid", - "We’d appreciate any feedback on how you’re finding %(brand)s.": "We stellen het op prijs als u feedback geeft over hoe u %(brand)s vindt.", - "How are you finding %(brand)s so far?": "Hoe vind u tot nu toe %(brand)s?", + "We’d appreciate any feedback on how you’re finding %(brand)s.": "We stellen het op prijs als je feedback geeft over hoe je %(brand)s vindt.", + "How are you finding %(brand)s so far?": "Hoe vind je tot nu toe %(brand)s?", "Welcome": "Welkom", "Don’t miss a thing by taking %(brand)s with you": "Mis niets door %(brand)s mee te nemen", "Show shortcut to welcome checklist above the room list": "Toon snelkoppeling naar welkomstchecklist boven de kamer gids", @@ -3551,16 +3551,129 @@ "%(securityKey)s or %(recoveryFile)s": "%(securityKey)s of %(recoveryFile)s", "Proxy URL": "Proxy URL", "Proxy URL (optional)": "Proxy-URL (optioneel)", - "To disable you will need to log out and back in, use with caution!": "Om uit te schakelen moet u uitloggen en weer inloggen, wees voorzichtig!", + "To disable you will need to log out and back in, use with caution!": "Om uit te schakelen moet je uitloggen en weer inloggen, wees voorzichtig!", "Sliding Sync configuration": "Scrollende Synchronisatie-configuratie", - "Your server lacks native support, you must specify a proxy": "Uw server heeft geen native ondersteuning, u moet een proxy opgeven", - "Your server lacks native support": "Uw server heeft geen native ondersteuning", - "Your server has native support": "Uw server heeft native ondersteuning", + "Your server lacks native support, you must specify a proxy": "Jouw server heeft geen native ondersteuning, je moet een proxy opgeven", + "Your server lacks native support": "Jouw server heeft geen native ondersteuning", + "Your server has native support": "Jouw server heeft native ondersteuning", "Checking...": "Controleren...", "%(qrCode)s or %(appLinks)s": "%(qrCode)s of %(appLinks)s", "%(qrCode)s or %(emojiCompare)s": "%(qrCode)s of %(emojiCompare)s", "Show": "Toon", "Unknown device type": "Onbekend apparaattype", "Video input %(n)s": "Video input %(n)s", - "Audio input %(n)s": "Audio input %(n)s" + "Audio input %(n)s": "Audio input %(n)s", + "Completing set up of your new device": "De configuratie van je nieuwe apparaat voltooien", + "Waiting for device to sign in": "Wachten op apparaat om in te loggen", + "Connecting...": "Verbinden...", + "Review and approve the sign in": "Controleer en keur de aanmelding goed", + "Select 'Scan QR code'": "Selecteer 'QR-code scannen'", + "Start at the sign in screen": "Begin bij het inlogscherm", + "Scan the QR code below with your device that's signed out.": "Scan de onderstaande QR-code met je apparaat dat is uitgelogd.", + "By approving access for this device, it will have full access to your account.": "Door de toegang voor dit apparaat goed te keuren, heeft het volledige toegang tot jouw account.", + "Check that the code below matches with your other device:": "Controleer of de onderstaande code overeenkomt met je andere apparaat:", + "Devices connected": "Verbonden apparaten", + "The homeserver doesn't support signing in another device.": "De server ondersteunt het inloggen op een ander apparaat niet.", + "An unexpected error occurred.": "Er is een onverwachte fout opgetreden.", + "The request was cancelled.": "Het verzoek is geannuleerd.", + "The other device isn't signed in.": "Het andere apparaat is niet ingelogd.", + "The other device is already signed in.": "Het andere apparaat is al aangemeld.", + "The request was declined on the other device.": "Het verzoek is afgewezen op het andere apparaat.", + "Linking with this device is not supported.": "Koppelen met dit apparaat wordt niet ondersteund.", + "The scanned code is invalid.": "De gescande code is ongeldig.", + "The linking wasn't completed in the required time.": "De koppeling is niet binnen de vereiste tijd voltooid.", + "Sign in new device": "Aanmelden nieuw apparaat", + "Video call ended": "Video oproep beëindigd", + "%(name)s started a video call": "%(name)s is een videogesprek gestart", + "Room info": "Kamer informatie", + "Underline": "Onderstrepen", + "Italic": "Cursief", + "View chat timeline": "Gesprekstijdslijn bekijken", + "Close call": "Sluit oproep", + "Layout type": "Type lay-out", + "Spotlight": "Schijnwerper", + "Freedom": "Vrijheid", + "You do not have permission to start voice calls": "U heeft geen toestemming om spraakoproepen te starten", + "There's no one here to call": "Er is hier niemand om te bellen", + "You do not have permission to start video calls": "U heeft geen toestemming om videogesprekken te starten", + "Ongoing call": "Lopende oproep", + "Video call (%(brand)s)": "Videogesprek (%(brand)s)", + "Video call (Jitsi)": "Videogesprek (Jitsi)", + "Show formatting": "Opmaak tonen", + "Show plain text": "Toon platte tekst", + "Failed to set pusher state": "Kan de pusher status niet instellen", + "Show QR code": "QR-code tonen", + "You can use this device to sign in a new device with a QR code. You will need to scan the QR code shown on this device with your device that's signed out.": "U kunt dit apparaat gebruiken om in te loggen op een nieuw apparaat met een QR-code. U moet de QR-code die op dit apparaat wordt weergegeven, scannen met uw apparaat dat is uitgelogd.", + "Sign in with QR code": "Log in met QR-code", + "%(selectedDeviceCount)s sessions selected": "%(selectedDeviceCount)s sessies geselecteerd", + "Consider signing out from old sessions (%(inactiveAgeDays)s days or older) you don't use anymore.": "Overweeg om u af te melden bij oude sessies (%(inactiveAgeDays)s dagen of ouder) die u niet meer gebruikt.", + "Unknown session type": "Onbekende sessietype", + "Web session": "Web sessie", + "Mobile session": "Mobiele sessie", + "Desktop session": "Desktop sessie", + "Removing inactive sessions improves security and performance, and makes it easier for you to identify if a new session is suspicious.": "Het verwijderen van inactieve sessies verbetert de beveiliging en prestaties en maakt het voor u gemakkelijker om te identificeren of een nieuwe sessie verdacht is.", + "Inactive sessions are sessions you have not used in some time, but they continue to receive encryption keys.": "Inactieve sessies zijn sessies die u al een tijdje niet hebt gebruikt, maar ze blijven vesleutelingssleutels ontvangen.", + "You should make especially certain that you recognise these sessions as they could represent an unauthorised use of your account.": "U moet er vooral zeker van zijn dat u deze sessies herkent, omdat ze een ongeoorloofd gebruik van uw account kunnen vertegenwoordigen.", + "Unverified sessions are sessions that have logged in with your credentials but have not been cross-verified.": "Niet geverifieerde sessies zijn sessies die zijn aangemeld met uw inloggegevens, maar niet zijn geverifieerd.", + "This means they hold encryption keys for your previous messages, and confirm to other users you are communicating with that these sessions are really you.": "Dit betekent dat ze vesleutelingssleutel bevatten voor uw eerdere berichten en bevestigen aan andere gebruikers waarmee u communiceert dat deze sessies echt van u zijn.", + "Verified sessions have logged in with your credentials and then been verified, either using your secure passphrase or by cross-verifying.": "Geverifieerde sessies zijn ingelogd met uw inloggegevens en vervolgens geverifieerd, hetzij met uw veilige wachtwoordzin of door kruisverificatie.", + "Sign out of this session": "Afmelden voor deze sessie", + "Receive push notifications on this session.": "Ontvang pushmeldingen voor deze sessie.", + "Push notifications": "Pushmeldingen", + "Toggle push notifications on this session.": "Schakel pushmeldingen in voor deze sessie.", + "Browser": "Browser", + "Operating system": "Besturingssysteem", + "Model": "Model", + "URL": "URL", + "Version": "Versie", + "Application": "Toepassing", + "This provides them with confidence that they are really speaking to you, but it also means they can see the session name you enter here.": "Dit geeft ze het vertrouwen dat ze echt met u praten, maar het betekent ook dat ze de sessienaam kunnen zien die u hier invoert.", + "Other users in direct messages and rooms that you join are able to view a full list of your sessions.": "Andere gebruikers in privéchats en chatruimten waaraan u deelneemt, kunnen een volledige lijst van uw sessies bekijken.", + "Renaming sessions": "Sessies hernoemen", + "Please be aware that session names are also visible to people you communicate with.": "Houd er rekening mee dat sessienamen ook zichtbaar zijn voor mensen met wie u communiceert.", + "Rename session": "Sessie hernoemen", + "Sign out all other sessions": "Afmelden voor alle andere sessies", + "Call type": "Oproeptype", + "You do not have sufficient permissions to change this.": "U heeft niet voldoende rechten om dit te wijzigen.", + "%(brand)s is end-to-end encrypted, but is currently limited to smaller numbers of users.": "%(brand)s is eind-tot-eind versleuteld, maar is momenteel beperkt tot kleinere aantallen gebruikers.", + "Enable %(brand)s as an additional calling option in this room": "Schakel %(brand)s in als extra bel optie in deze kamer", + "Join %(brand)s calls": "Deelnemen aan %(brand)s gesprekken", + "Start %(brand)s calls": "%(brand)s oproepen starten", + "Voice broadcasts": "Spraakuitzendingen", + "Are you sure you want to sign out of %(count)s sessions?|one": "Weet u zeker dat u zich wilt afmelden bij %(count)s sessies?", + "Are you sure you want to sign out of %(count)s sessions?|other": "Weet u zeker dat u zich wilt afmelden bij %(count)s sessies?", + "Enable notifications for this device": "Meldingen inschakelen voor dit apparaat", + "Turn off to disable notifications on all your devices and sessions": "Schakel dit uit om meldingen op al je apparaten en sessies uit te schakelen", + "Enable notifications for this account": "Meldingen inschakelen voor dit account", + "Fill screen": "Scherm vullen", + "You have already joined this call from another device": "U heeft al deelgenomen aan dit gesprek vanaf een ander apparaat", + "Sorry — this call is currently full": "Sorry — dit gesprek is momenteel vol", + "Record the client name, version, and url to recognise sessions more easily in session manager": "Noteer de naam, versie en url van de applicatie om sessies gemakkelijker te herkennen in sessiebeheer", + "Allow a QR code to be shown in session manager to sign in another device (requires compatible homeserver)": "Toestaan dat een QR-code wordt weergegeven in sessiebeheer om in te loggen op een ander apparaat (compatibele server vereist)", + "Our new sessions manager provides better visibility of all your sessions, and greater control over them including the ability to remotely toggle push notifications.": "Onze nieuwe sessiemanager biedt een betere zichtbaarheid van al uw sessies en meer controle erover, inclusief de mogelijkheid om op afstand pushmeldingen in te schakelen.", + "Have greater visibility and control over all your sessions.": "Meer zichtbaarheid en controle over al uw sessies.", + "New session manager": "Nieuwe sessiemanager", + "Use new session manager": "Nieuwe sessiemanager gebruiken", + "Voice broadcast (under active development)": "Spraak uitzending (in actieve ontwikkeling)", + "New group call experience": "Nieuwe ervaring voor groepsgesprekken", + "Element Call video rooms": "Element Call videokamers", + "Try out the rich text editor (plain text mode coming soon)": "Probeer de rich-text-editor (platte tekst-modus komt binnenkort)", + "Notifications silenced": "Meldingen stilgezet", + "Video call started": "Videogesprek gestart", + "Unknown room": "Onbekende kamer", + "Voice broadcast": "Spraakuitzending", + "Live": "Live", + "pause voice broadcast": "spraakuitzending pauzeren", + "resume voice broadcast": "hervat spraakuitzending", + "play voice broadcast": "spraakuitzending afspelen", + "Yes, stop broadcast": "Ja, stop uitzending", + "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.": "Weet u zeker dat u de live uitzending wilt stoppen? Hiermee wordt de uitzending beëindigd en is de volledige opname beschikbaar in de kamer.", + "Stop live broadcasting?": "Live uitzending stoppen?", + "Someone else is already recording a voice broadcast. Wait for their voice broadcast to end to start a new one.": "Iemand anders neemt al een spraakuitzending op. Wacht tot de spraakuitzending is afgelopen om een nieuwe te starten.", + "You don't have the required permissions to start a voice broadcast in this room. Contact a room administrator to upgrade your permissions.": "U heeft niet de vereiste rechten om een spraakuitzending in deze kamer te starten. Neem contact op met een kamer beheerder om uw machtiging aan te passen.", + "You are already recording a voice broadcast. Please end your current voice broadcast to start a new one.": "U neemt al een spraakuitzending op. Beëindig uw huidige spraakuitzending om een nieuwe te starten.", + "Can't start a new voice broadcast": "Kan geen nieuwe spraakuitzending starten", + "Video call started in %(roomName)s. (not supported by this browser)": "Videogesprek gestart in %(roomName)s. (niet ondersteund door deze browser)", + "Video call started in %(roomName)s.": "Videogesprek gestart in %(roomName)s.", + "You need to be able to kick users to do that.": "U moet in staat zijn om gebruikers te verwijderen om dit te doen." } diff --git a/src/i18n/strings/pt_BR.json b/src/i18n/strings/pt_BR.json index 6b1bc3c576c..5a376037d73 100644 --- a/src/i18n/strings/pt_BR.json +++ b/src/i18n/strings/pt_BR.json @@ -2909,5 +2909,18 @@ "Exported %(count)s events in %(seconds)s seconds|other": "%(count)s eventos exportados em %(seconds)s segundos", "Creating output...": "Criando resultado...", "Fetching events...": "Buscando eventos...", - "Starting export process...": "Iniciando processo de exportação..." + "Starting export process...": "Iniciando processo de exportação...", + "Yes, stop broadcast": "Sim, interromper a transmissão", + "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.": "Tem certeza de que deseja interromper sua transmissão ao vivo? Isso encerrará a transmissão e a gravação completa estará disponível na sala.", + "Stop live broadcasting?": "Parar a transmissão ao vivo?", + "Someone else is already recording a voice broadcast. Wait for their voice broadcast to end to start a new one.": "Outra pessoa já está gravando uma transmissão de voz. Aguarde o término da transmissão de voz para iniciar uma nova.", + "You don't have the required permissions to start a voice broadcast in this room. Contact a room administrator to upgrade your permissions.": "Você não tem as permissões necessárias para iniciar uma transmissão de voz nesta sala. Entre em contato com um administrador de sala para atualizar suas permissões.", + "You are already recording a voice broadcast. Please end your current voice broadcast to start a new one.": "Você já está gravando uma transmissão de voz. Encerre sua transmissão de voz atual para iniciar uma nova.", + "Can't start a new voice broadcast": "Não é possível iniciar uma nova transmissão de voz", + "Remove, ban, or invite people to your active room, and make you leave": "Remover, banir ou convidar pessoas para sua sala ativa e fazer você sair", + "Remove, ban, or invite people to this room, and make you leave": "Remover, banir ou convidar pessoas para esta sala e fazer você sair", + "No virtual room for this room": "Nenhuma sala virtual para esta sala", + "Switches to this room's virtual room, if it has one": "Muda para a sala virtual desta sala, se houver uma", + "Developer command: Discards the current outbound group session and sets up new Olm sessions": "Comando do desenvolvedor: descarta a sessão de grupo de saída atual e configura novas sessões do Olm", + "Jump to the given date in the timeline": "Ir para a data especificada na linha do tempo" } diff --git a/src/i18n/strings/ru.json b/src/i18n/strings/ru.json index 6c7d506816b..eca85bd9ef9 100644 --- a/src/i18n/strings/ru.json +++ b/src/i18n/strings/ru.json @@ -1614,7 +1614,7 @@ "Verification Request": "Запрос на подтверждение", "Wrong file type": "Неправильный тип файла", "Looks good!": "Выглядит неплохо!", - "Security Phrase": "Секретная фраза", + "Security Phrase": "Мнемоническая фраза", "Security Key": "Бумажный ключ", "Use your Security Key to continue.": "Чтобы продолжить, используйте свой бумажный ключ.", "Restoring keys from backup": "Восстановление ключей из резервной копии", @@ -2212,7 +2212,7 @@ "Enter Security Key": "Введите ключ безопасности", "If you've forgotten your Security Phrase you can use your Security Key or set up new recovery options": "Если вы забыли секретную фразу, вы можете использовать ключ безопасности или настроить новые параметры восстановления", "Access your secure message history and set up secure messaging by entering your Security Phrase.": "Получите доступ к своей истории защищенных сообщений и настройте безопасный обмен сообщениями, введя секретную фразу.", - "Enter Security Phrase": "Введите секретную фразу", + "Enter Security Phrase": "Введите мнемоническую фразу", "Backup could not be decrypted with this Security Phrase: please verify that you entered the correct Security Phrase.": "Не удалось расшифровать резервную копию с помощью этой секретной фразы: убедитесь, что вы ввели правильную секретную фразу.", "Incorrect Security Phrase": "Неверная секретная фраза", "Backup could not be decrypted with this Security Key: please verify that you entered the correct Security Key.": "Не удалось расшифровать резервную копию с помощью этого ключа безопасности: убедитесь, что вы ввели правильный ключ безопасности.", @@ -3571,11 +3571,11 @@ "Model": "Модель", "Live": "В эфире", "Video call (%(brand)s)": "Видеозвонок (%(brand)s)", - "Voice broadcast (under active development)": "Аудиовещание (в активной разработке)", + "Voice broadcast (under active development)": "Голосовые трансляции (в активной разработке)", "Use new session manager": "Использовать новый менеджер сессий", "Sign out all other sessions": "Выйти из всех других сессий", "Voice broadcasts": "Аудиопередачи", - "Voice broadcast": "Аудиопередача", + "Voice broadcast": "Голосовое вещание", "Have greater visibility and control over all your sessions.": "Получите наилучшую видимость и контроль над всеми вашими сеансами.", "New group call experience": "Новый опыт группового вызова", "Sliding Sync mode (under active development, cannot be disabled)": "Скользящий режим синхронизации (в активной разработке, не может быть отключен)", diff --git a/src/i18n/strings/sk.json b/src/i18n/strings/sk.json index 0a7596d94e4..0e20b7ff41c 100644 --- a/src/i18n/strings/sk.json +++ b/src/i18n/strings/sk.json @@ -3632,5 +3632,53 @@ "stop voice broadcast": "zastaviť hlasové vysielanie", "resume voice broadcast": "obnoviť hlasové vysielanie", "pause voice broadcast": "pozastaviť hlasové vysielanie", - "Notifications silenced": "Oznámenia stlmené" + "Notifications silenced": "Oznámenia stlmené", + "Completing set up of your new device": "Dokončenie nastavenia nového zariadenia", + "Waiting for device to sign in": "Čaká sa na prihlásenie zariadenia", + "Connecting...": "Pripájanie…", + "Review and approve the sign in": "Skontrolujte a schváľte prihlásenie", + "Select 'Scan QR code'": "Vyberte možnosť \"Skenovať QR kód\"", + "Start at the sign in screen": "Začnite na prihlasovacej obrazovke", + "Scan the QR code below with your device that's signed out.": "Naskenujte nižšie uvedený QR kód pomocou zariadenia, ktoré je odhlásené.", + "By approving access for this device, it will have full access to your account.": "Schválením prístupu pre toto zariadenie bude mať plný prístup k vášmu účtu.", + "Check that the code below matches with your other device:": "Skontrolujte, či sa nižšie uvedený kód zhoduje s vaším druhým zariadením:", + "Devices connected": "Zariadenia pripojené", + "The homeserver doesn't support signing in another device.": "Domovský server nepodporuje prihlasovanie do iného zariadenia.", + "An unexpected error occurred.": "Vyskytla sa neočakávaná chyba.", + "The request was cancelled.": "Žiadosť bola zrušená.", + "The other device isn't signed in.": "Druhé zariadenie nie je prihlásené.", + "The other device is already signed in.": "Druhé zariadenie je už prihlásené.", + "The request was declined on the other device.": "Žiadosť bola na druhom zariadení zamietnutá.", + "Linking with this device is not supported.": "Prepojenie s týmto zariadením nie je podporované.", + "The scanned code is invalid.": "Naskenovaný kód je neplatný.", + "The linking wasn't completed in the required time.": "Prepojenie nebolo dokončené v požadovanom čase.", + "Sign in new device": "Prihlásiť nové zariadenie", + "Show QR code": "Zobraziť QR kód", + "You can use this device to sign in a new device with a QR code. You will need to scan the QR code shown on this device with your device that's signed out.": "Toto zariadenie môžete použiť na prihlásenie nového zariadenia pomocou QR kódu. QR kód zobrazený na tomto zariadení musíte naskenovať pomocou zariadenia, ktoré je odhlásené.", + "Sign in with QR code": "Prihlásiť sa pomocou QR kódu", + "Browser": "Prehliadač", + "Allow a QR code to be shown in session manager to sign in another device (requires compatible homeserver)": "Umožniť zobrazenie QR kódu v správcovi relácií na prihlásenie do iného zariadenia (vyžaduje kompatibilný domovský server)", + "Yes, stop broadcast": "Áno, zastaviť vysielanie", + "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.": "Určite chcete zastaviť vaše vysielanie naživo? Tým sa vysielanie ukončí a v miestnosti bude k dispozícii celý záznam.", + "Stop live broadcasting?": "Zastaviť vysielanie naživo?", + "Someone else is already recording a voice broadcast. Wait for their voice broadcast to end to start a new one.": "Niekto iný už nahráva hlasové vysielanie. Počkajte, kým sa skončí jeho hlasové vysielanie, a potom spustite nové.", + "You don't have the required permissions to start a voice broadcast in this room. Contact a room administrator to upgrade your permissions.": "Nemáte požadované oprávnenia na spustenie hlasového vysielania v tejto miestnosti. Obráťte sa na správcu miestnosti, aby vám rozšíril oprávnenia.", + "You are already recording a voice broadcast. Please end your current voice broadcast to start a new one.": "Už nahrávate hlasové vysielanie. Ukončite aktuálne hlasové vysielanie a spustite nové.", + "Can't start a new voice broadcast": "Nemôžete spustiť nové hlasové vysielanie", + "play voice broadcast": "spustiť hlasové vysielanie", + "Are you sure you want to sign out of %(count)s sessions?|one": "Ste si istí, že sa chcete odhlásiť z %(count)s relácie?", + "Are you sure you want to sign out of %(count)s sessions?|other": "Ste si istí, že sa chcete odhlásiť z %(count)s relácií?", + "Show formatting": "Zobraziť formátovanie", + "Show plain text": "Zobraziť obyčajný text", + "Consider signing out from old sessions (%(inactiveAgeDays)s days or older) you don't use anymore.": "Zvážte odhlásenie zo starých relácií (%(inactiveAgeDays)s dní alebo starších), ktoré už nepoužívate.", + "Removing inactive sessions improves security and performance, and makes it easier for you to identify if a new session is suspicious.": "Odstránenie neaktívnych relácií zvyšuje bezpečnosť a výkon a uľahčuje identifikáciu podozrivých nových relácií.", + "Inactive sessions are sessions you have not used in some time, but they continue to receive encryption keys.": "Neaktívne relácie sú relácie, ktoré ste určitý čas nepoužívali, ale naďalej dostávajú šifrovacie kľúče.", + "You should make especially certain that you recognise these sessions as they could represent an unauthorised use of your account.": "Mali by ste si byť obzvlášť istí, že tieto relácie rozpoznávate, pretože by mohli predstavovať neoprávnené používanie vášho účtu.", + "Unverified sessions are sessions that have logged in with your credentials but have not been cross-verified.": "Neoverené relácie sú relácie, ktoré sa prihlásili pomocou vašich poverení, ale neboli krížovo overené.", + "This means they hold encryption keys for your previous messages, and confirm to other users you are communicating with that these sessions are really you.": "To znamená, že uchovávajú šifrovacie kľúče pre vaše predchádzajúce správy a potvrdzujú ostatným používateľom, s ktorými komunikujete, že tieto relácie ste skutočne vy.", + "Verified sessions have logged in with your credentials and then been verified, either using your secure passphrase or by cross-verifying.": "Overené relácie, ktoré sú prihlásené pomocou vašich poverovacích údajov a následne overené buď pomocou vašej zabezpečenej prístupovej frázy, alebo krížovým overením.", + "This provides them with confidence that they are really speaking to you, but it also means they can see the session name you enter here.": "To im poskytuje istotu, že skutočne komunikujú s vami, ale zároveň to znamená, že vidia názov relácie, ktorý tu zadáte.", + "Other users in direct messages and rooms that you join are able to view a full list of your sessions.": "Ostatní používatelia v priamych správach a miestnostiach, ku ktorým sa pripojíte, môžu vidieť úplný zoznam vašich relácií.", + "Renaming sessions": "Premenovanie relácií", + "Please be aware that session names are also visible to people you communicate with.": "Uvedomte si, že názvy relácií sú viditeľné aj pre ľudí, s ktorými komunikujete." } diff --git a/src/i18n/strings/sv.json b/src/i18n/strings/sv.json index af6eb8f3e41..1118288b3e0 100644 --- a/src/i18n/strings/sv.json +++ b/src/i18n/strings/sv.json @@ -3445,5 +3445,47 @@ "Exit fullscreen": "Gå ur fullskärm", "Enter fullscreen": "Gå till fullskärm", "Map feedback": "Kartuåterkoppling", - "Toggle attribution": "Växla tillskrivning" + "Toggle attribution": "Växla tillskrivning", + "Can't start a new voice broadcast": "Kan inte starta en ny röstsändning", + "Video call started in %(roomName)s. (not supported by this browser)": "Videosamtal startade i %(roomName)s. (stöds inte av den här webbläsaren)", + "Video call started in %(roomName)s.": "Videosamtal startade i %(roomName)s.", + "You need to be able to kick users to do that.": "Du behöver kunna kicka användare för att göra det.", + "Empty room (was %(oldName)s)": "Tomt rum (var %(oldName)s)", + "Inviting %(user)s and %(count)s others|one": "Bjuder in %(user)s och 1 till", + "Inviting %(user)s and %(count)s others|other": "Bjuder in %(user)s och %(count)s till", + "Inviting %(user1)s and %(user2)s": "Bjuder in %(user1)s och %(user2)s", + "%(user)s and %(count)s others|one": "%(user)s och 1 till", + "%(user)s and %(count)s others|other": "%(user)s och %(count)s till", + "%(user1)s and %(user2)s": "%(user1)s och %(user2)s", + "Stop live broadcasting?": "Avsluta livesändning?", + "Someone else is already recording a voice broadcast. Wait for their voice broadcast to end to start a new one.": "Någon annan spelar redan in en röstsändning. Vänta på att deras röstsändning tar slut för att starta en ny.", + "You don't have the required permissions to start a voice broadcast in this room. Contact a room administrator to upgrade your permissions.": "Du är inte behörig att starta en röstsändning i det här rummet. Kontakta en rumsadministratör för att uppgradera dina behörigheter.", + "You are already recording a voice broadcast. Please end your current voice broadcast to start a new one.": "Du spelar redan in en röstsändning. Avsluta din nuvarande röstsändning för att påbörja en ny.", + "Element Call video rooms": "Element Call videorum", + "Sliding Sync mode (under active development, cannot be disabled)": "Glidande synk-läge (under aktiv utveckling, kan inte avaktiveras)", + "Send read receipts": "Skicka läskvitton", + "Try out the rich text editor (plain text mode coming soon)": "Pröva den nya riktextredigeraren (vanligt textläge kommer snart)", + "Notifications silenced": "Aviseringar tystade", + "Video call started": "Videosamtal startat", + "Unknown room": "Okänt rum", + "Voice broadcast": "Röstsändning", + "Live": "Live", + "pause voice broadcast": "pausa röstsändning", + "resume voice broadcast": "återuppta röstsändning", + "play voice broadcast": "spela röstsändning", + "Yes, stop broadcast": "Ja, avsluta sändning", + "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.": "Är du säker på att du vill avsluta din livesändning? Det här kommer att avsluta sändningen och den fulla inspelningen kommer att vara tillgänglig i rummet.", + "Allow a QR code to be shown in session manager to sign in another device (requires compatible homeserver)": "Låt en QR-kod visas i sessionshanteraren för att logga in en annan enhet (kräver en kompatibel hemserver)", + "Our new sessions manager provides better visibility of all your sessions, and greater control over them including the ability to remotely toggle push notifications.": "Vår nya sessionshanterare ger dig bättre insyn i alla dina sessioner, och större kontroll över dem, inklusive förmågan att växla pushnotiser på håll.", + "Have greater visibility and control over all your sessions.": "Ha bättre insyn och kontroll över alla dina sessioner.", + "New session manager": "Ny sessionshanterare", + "Use new session manager": "Använd ny sessionshanterare", + "Voice broadcast (under active development)": "Röstsändning (under aktiv utveckling)", + "New group call experience": "Ny gruppsamtalsupplevelse", + "Record the client name, version, and url to recognise sessions more easily in session manager": "Spara klientens namn, version och URL för att lättare känna igen sessioner i sessionshanteraren", + "Find and invite your friends": "Hitta och bjud in dina vänner", + "You made it!": "Du klarade det!", + "You have already joined this call from another device": "Du har redan gått med i det här samtalet från en annan enhet", + "Sorry — this call is currently full": "Tyvärr - det här samtalet är för närvarande fullt", + "Show shortcut to welcome checklist above the room list": "Visa genväg till välkomstchecklistan ovanför rumslistan" } diff --git a/src/i18n/strings/uk.json b/src/i18n/strings/uk.json index 04b1c056e9c..465fa071cb9 100644 --- a/src/i18n/strings/uk.json +++ b/src/i18n/strings/uk.json @@ -3567,10 +3567,10 @@ "Sign out of this session": "Вийти з цього сеансу", "Please be aware that session names are also visible to people you communicate with": "Зауважте, що назви сеансів також видно людям, з якими ви спілкуєтесь", "Rename session": "Перейменувати сеанс", - "Voice broadcast": "Голосове мовлення", - "Voice broadcast (under active development)": "Голосове мовлення (в активній розробці)", + "Voice broadcast": "Голосові повідомлення", + "Voice broadcast (under active development)": "Голосові повідомлення (в активній розробці)", "Element Call video rooms": "Відео кімнати Element Call", - "Voice broadcasts": "Голосове мовлення", + "Voice broadcasts": "Голосові повідомлення", "You do not have permission to start voice calls": "У вас немає дозволу розпочинати голосові виклики", "There's no one here to call": "Тут немає кого викликати", "You do not have permission to start video calls": "У вас немає дозволу розпочинати відеовиклики", @@ -3628,9 +3628,57 @@ "Underline": "Підкреслений", "Italic": "Курсив", "Try out the rich text editor (plain text mode coming soon)": "Спробуйте розширений текстовий редактор (незабаром з'явиться режим звичайного тексту)", - "resume voice broadcast": "поновити голосове мовлення", - "pause voice broadcast": "призупинити голосове мовлення", + "resume voice broadcast": "поновити голосове повідомлення", + "pause voice broadcast": "призупинити голосове повідомлення", "You have already joined this call from another device": "Ви вже приєдналися до цього виклику з іншого пристрою", "stop voice broadcast": "припинити голосове мовлення", - "Notifications silenced": "Сповіщення стишено" + "Notifications silenced": "Сповіщення стишено", + "Sign in with QR code": "Увійти за допомогою QR-коду", + "Browser": "Браузер", + "Allow a QR code to be shown in session manager to sign in another device (requires compatible homeserver)": "Дозволити показ QR-коду в менеджері сеансів для входу на іншому пристрої (потрібен сумісний домашній сервер)", + "Yes, stop broadcast": "Так, припинити трансляцію", + "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.": "Ви впевнені, що хочете припинити трансляцію голосового повідомлення? На цьому трансляція завершиться, і повний запис буде доступний у кімнаті.", + "Stop live broadcasting?": "Припинити трансляцію голосового повідомлення?", + "Someone else is already recording a voice broadcast. Wait for their voice broadcast to end to start a new one.": "Хтось інший вже записує голосове повідомлення. Зачекайте, поки запис завершиться, щоб розпочати новий.", + "You don't have the required permissions to start a voice broadcast in this room. Contact a room administrator to upgrade your permissions.": "Ви не маєте необхідних дозволів для початку передавання голосового повідомлення в цю кімнату. Зверніться до адміністратора кімнати, щоб оновити ваші дозволи.", + "You are already recording a voice broadcast. Please end your current voice broadcast to start a new one.": "Ви вже записуєте голосове повідомлення. Завершіть поточний запис, щоб розпочати новий.", + "Can't start a new voice broadcast": "Не вдалося розпочати передавання нового голосового повідомлення", + "Completing set up of your new device": "Завершення налаштування нового пристрою", + "Waiting for device to sign in": "Очікування входу з пристрою", + "Connecting...": "З'єднання...", + "Review and approve the sign in": "Розглянути та схвалити вхід", + "Select 'Scan QR code'": "Виберіть «Сканувати QR-код»", + "Start at the sign in screen": "Почніть з екрана входу", + "Scan the QR code below with your device that's signed out.": "Скануйте QR-код знизу своїм пристроєм, на якому ви вийшли.", + "By approving access for this device, it will have full access to your account.": "Затвердивши доступ для цього пристрою, ви надасте йому повний доступ до вашого облікового запису.", + "Check that the code below matches with your other device:": "Перевірте, чи збігається наведений внизу код з кодом на вашому іншому пристрої:", + "Devices connected": "Пристрої під'єднано", + "The homeserver doesn't support signing in another device.": "Домашній сервер не підтримує вхід на іншому пристрої.", + "An unexpected error occurred.": "Виникла непередбачувана помилка.", + "The request was cancelled.": "Запит було скасовано.", + "The other device isn't signed in.": "На іншому пристрої вхід не виконано.", + "The other device is already signed in.": "На іншому пристрої вхід було виконано.", + "The request was declined on the other device.": "На іншому пристрої запит відхилено.", + "Linking with this device is not supported.": "Зв'язок з цим пристроєм не підтримується.", + "The scanned code is invalid.": "Сканований код недійсний.", + "The linking wasn't completed in the required time.": "У встановлені терміни з'єднання не було виконано.", + "Sign in new device": "Увійти на новому пристрої", + "Show QR code": "Показати QR-код", + "You can use this device to sign in a new device with a QR code. You will need to scan the QR code shown on this device with your device that's signed out.": "Ви можете використовувати цей пристрій для входу на новому пристрої за допомогою QR-коду. Вам потрібно буде сканувати QR-код, показаний на цьому пристрої, своїм пристроєм, на якому ви вийшли.", + "play voice broadcast": "відтворити голосове повідомлення", + "Are you sure you want to sign out of %(count)s sessions?|one": "Ви впевнені, що хочете вийти з %(count)s сеансів?", + "Are you sure you want to sign out of %(count)s sessions?|other": "Ви впевнені, що хочете вийти з %(count)s сеансів?", + "Show formatting": "Показати форматування", + "Show plain text": "Показати звичайний текст", + "Removing inactive sessions improves security and performance, and makes it easier for you to identify if a new session is suspicious.": "Вилучення неактивних сеансів посилює безпеку і швидкодію, а також полегшує виявлення підозрілих нових сеансів.", + "Inactive sessions are sessions you have not used in some time, but they continue to receive encryption keys.": "Неактивні сеанси — це сеанси, які ви не використовували протягом певного часу, але вони продовжують отримувати ключі шифрування.", + "Consider signing out from old sessions (%(inactiveAgeDays)s days or older) you don't use anymore.": "Обміркуйте можливість виходу зі старих сеансів (%(inactiveAgeDays)s днів або більше), якими ви більше не користуєтесь.", + "You should make especially certain that you recognise these sessions as they could represent an unauthorised use of your account.": "Ви повинні бути впевнені, що розпізнаєте ці сеанси, оскільки вони можуть бути несанкціонованим використанням вашого облікового запису.", + "Unverified sessions are sessions that have logged in with your credentials but have not been cross-verified.": "Не звірені сеанси — це сеанси, які увійшли в систему з вашими обліковими даними, але не пройшли перехресну перевірку.", + "This means they hold encryption keys for your previous messages, and confirm to other users you are communicating with that these sessions are really you.": "Це означає, що вони зберігають ключі шифрування ваших попередніх повідомлень і підтверджують іншим користувачам, з якими ви спілкуєтеся, що ці сеанси дійсно належать вам.", + "Verified sessions have logged in with your credentials and then been verified, either using your secure passphrase or by cross-verifying.": "Звірені сеанси увійшли в систему з вашими обліковими даними, а потім були перевірені або за допомогою вашої безпечної парольної фрази, або за допомогою перехресної перевірки.", + "This provides them with confidence that they are really speaking to you, but it also means they can see the session name you enter here.": "Завдяки цьому у них з'являється впевненість, що вони дійсно розмовляють з вами, а також вони можуть бачити назву сеансу, яку ви вводите тут.", + "Other users in direct messages and rooms that you join are able to view a full list of your sessions.": "Інші користувачі в особистих повідомленнях і кімнатах, до яких ви приєдналися, можуть переглянути список усіх ваших сеансів.", + "Renaming sessions": "Перейменування сеансів", + "Please be aware that session names are also visible to people you communicate with.": "Зауважте, що назви сеансів також бачать люди, з якими ви спілкуєтесь." } diff --git a/src/i18n/strings/zh_Hans.json b/src/i18n/strings/zh_Hans.json index ab5301eb3d8..d13dfc6c0d5 100644 --- a/src/i18n/strings/zh_Hans.json +++ b/src/i18n/strings/zh_Hans.json @@ -235,7 +235,7 @@ "Do you want to set an email address?": "你想要设置一个邮箱地址吗?", "Upload new:": "上传新的:", "Verification Pending": "验证等待中", - "You cannot place a call with yourself.": "你无法向自己发起通话。", + "You cannot place a call with yourself.": "你不能打给自己。", "You have disabled URL previews by default.": "你已经默认禁用URL预览。", "You have enabled URL previews by default.": "你已经默认启用URL预览。", "This server does not support authentication with a phone number.": "此服务器不支持使用电话号码认证。", @@ -326,9 +326,9 @@ "Warning": "警告", "Room Notification": "房间通知", "%(weekDayName)s %(time)s": "%(weekDayName)s %(time)s", - "%(weekDayName)s, %(monthName)s %(day)s %(time)s": "%(monthName)s %(day)s %(time)s, %(weekDayName)s", - "%(weekDayName)s, %(monthName)s %(day)s %(fullYear)s": "%(fullYear)s %(monthName)s %(day)s, %(weekDayName)s", - "%(weekDayName)s, %(monthName)s %(day)s %(fullYear)s %(time)s": "%(fullYear)s %(monthName)s %(day)s %(time)s, %(weekDayName)s", + "%(weekDayName)s, %(monthName)s %(day)s %(time)s": "%(monthName)s %(day)s %(time)s,%(weekDayName)s", + "%(weekDayName)s, %(monthName)s %(day)s %(fullYear)s": "%(fullYear)s %(monthName)s %(day)s,%(weekDayName)s", + "%(weekDayName)s, %(monthName)s %(day)s %(fullYear)s %(time)s": "%(fullYear)s %(monthName)s %(day)s %(time)s,%(weekDayName)s", "Enable inline URL previews by default": "默认启用行内URL预览", "Send an encrypted reply…": "发送加密回复…", "Send an encrypted message…": "发送加密消息……", @@ -491,7 +491,7 @@ "Share room": "分享房间", "System Alerts": "系统警告", "Muted Users": "被禁言的用户", - "In encrypted rooms, like this one, URL previews are disabled by default to ensure that your homeserver (where the previews are generated) cannot gather information about links you see in this room.": "在加密的房间中,比如此房间,URL预览默认禁用,以确保主服务器(生成预览的地方)无法收集与你在此房间中看到的链接有关的信息。", + "In encrypted rooms, like this one, URL previews are disabled by default to ensure that your homeserver (where the previews are generated) cannot gather information about links you see in this room.": "在加密的房间中,比如此房间,URL预览默认是禁用的,以确保你的主服务器(生成预览的地方)无法收集与你在此房间中看到的链接有关的信息。", "When someone puts a URL in their message, a URL preview can be shown to give more information about that link such as the title, description, and an image from the website.": "当有人在他们的消息里放置URL时,可显示URL预览以给出更多有关链接的信息,如其网站的标题、描述以及图片。", "Are you sure you wish to remove (delete) this event? Note that if you delete a room name or topic change, it could undo the change.": "你确定要移除(删除)此事件吗?注意,如果删除房间名称或话题的更改,更改会被撤销。", "Clear Storage and Sign Out": "清除存储并登出", @@ -569,7 +569,7 @@ "Capitalization doesn't help very much": "大写字母并没有很大的作用", "All-uppercase is almost as easy to guess as all-lowercase": "全大写的密码通常比全小写的更容易猜测", "Reversed words aren't much harder to guess": "把单词倒过来不会比原来的难猜很多", - "The file '%(fileName)s' exceeds this homeserver's size limit for uploads": "文件 %(fileName)s 超过主服务器的文件大小限制", + "The file '%(fileName)s' exceeds this homeserver's size limit for uploads": "文件《%(fileName)s》超过了此主服务器的上传大小限制", "Upgrades a room to a new version": "将房间升级到新版本", "Gets or sets the room topic": "获取或设置房间话题", "This room has no topic.": "此房间没有话题。", @@ -852,22 +852,22 @@ "The homeserver may be unavailable or overloaded.": "主服务器似乎不可用或过载。", "You have %(count)s unread notifications in a prior version of this room.|other": "你在此房间的先前版本中有 %(count)s 条未读通知。", "You have %(count)s unread notifications in a prior version of this room.|one": "你在此房间的先前版本中有 %(count)s 条未读通知。", - "Add Email Address": "添加 Email 地址", + "Add Email Address": "添加邮箱", "Add Phone Number": "添加电话号码", - "Call failed due to misconfigured server": "因为服务器配置错误通话失败", - "Please ask the administrator of your homeserver (%(homeserverDomain)s) to configure a TURN server in order for calls to work reliably.": "请联系你主服务器(%(homeserverDomain)s)的管理员设置 TURN 服务器来确保通话运作稳定。", - "Alternatively, you can try to use the public server at turn.matrix.org, but this will not be as reliable, and it will share your IP address with that server. You can also manage this in Settings.": "你也可以尝试使用turn.matrix.org公共服务器,但通话质量稍差,并且其将会得知你的 IP。你可以在设置中更改此选项。", + "Call failed due to misconfigured server": "服务器配置错误导致通话失败", + "Please ask the administrator of your homeserver (%(homeserverDomain)s) to configure a TURN server in order for calls to work reliably.": "请联系你的主服务器(%(homeserverDomain)s)的管理员配置 TURN 服务器,以确保通话过程稳定。", + "Alternatively, you can try to use the public server at turn.matrix.org, but this will not be as reliable, and it will share your IP address with that server. You can also manage this in Settings.": "你也可以尝试使用 turn.matrix.org 公共服务器,但通话质量稍差,并且其将会得知你的 IP。你可以在设置中更改此选项。", "Try using turn.matrix.org": "尝试使用 turn.matrix.org", "Your %(brand)s is misconfigured": "你的 %(brand)s 配置有错误", "Use Single Sign On to continue": "使用单点登录继续", - "Confirm adding this email address by using Single Sign On to prove your identity.": "通过使用单点登录来证明你的身份,并确认添加此邮件地址。", + "Confirm adding this email address by using Single Sign On to prove your identity.": "通过使用单点登录来证明你的身份,并确认添加此邮箱地址。", "Single Sign On": "单点登录", - "Confirm adding email": "确认使用邮件", - "Click the button below to confirm adding this email address.": "点击下面的按钮以确认添加此邮箱地址。", + "Confirm adding email": "确认添加邮箱", + "Click the button below to confirm adding this email address.": "点击下面的按钮,以确认添加此邮箱地址。", "Confirm adding this phone number by using Single Sign On to prove your identity.": "通过单点登录以证明你的身份,并确认添加此电话号码。", "Confirm adding phone number": "确认添加电话号码", - "Click the button below to confirm adding this phone number.": "点击下面的按钮以确认添加此电话号码。", - "The file '%(fileName)s' failed to upload.": "上传文件 ‘%(fileName)s’ 失败。", + "Click the button below to confirm adding this phone number.": "点击下面的按钮,以确认添加此电话号码。", + "The file '%(fileName)s' failed to upload.": "文件《%(fileName)s》上传失败。", "The server does not support the room version specified.": "服务器不支持指定的房间版本。", "Cancel entering passphrase?": "取消输入口令词组?", "Setting up keys": "设置密钥", @@ -875,8 +875,8 @@ "Encryption upgrade available": "提供加密升级", "New login. Was this you?": "现在登录。请问是你本人吗?", "Identity server has no terms of service": "身份服务器无服务条款", - "This action requires accessing the default identity server to validate an email address or phone number, but the server does not have any terms of service.": "此操作需要访问默认的身份服务器 以验证邮箱地址或电话号码,但是此服务器无任何服务条款。", - "Only continue if you trust the owner of the server.": "只有你信任服务器所有者才能继续。", + "This action requires accessing the default identity server to validate an email address or phone number, but the server does not have any terms of service.": "此操作需要访问默认的身份服务器 以验证邮箱或电话号码,但此服务器无任何服务条款。", + "Only continue if you trust the owner of the server.": "只有在你信任服务器所有者后才能继续。", "Trust": "信任", "%(name)s is requesting verification": "%(name)s 正在请求验证", "Sign In or Create Account": "登录或创建账户", @@ -1075,12 +1075,12 @@ "Disconnect identity server": "断开身份服务器连接", "Disconnect from the identity server ?": "从身份服务器 断开连接吗?", "Disconnect": "断开连接", - "You should remove your personal data from identity server before disconnecting. Unfortunately, identity server is currently offline or cannot be reached.": "断开连接前,你应当从身份服务器删除你的个人数据。不幸的是,身份服务器当前处于离线状态或无法访问。", + "You should remove your personal data from identity server before disconnecting. Unfortunately, identity server is currently offline or cannot be reached.": "断开连接前,你应从身份服务器删除你的个人数据。不幸的是,身份服务器当前处于离线状态或无法访问。", "You should:": "你应该:", "contact the administrators of identity server ": "联系身份服务器 的管理员", "wait and try again later": "等待并稍后重试", "Disconnect anyway": "仍然断开连接", - "You are still sharing your personal data on the identity server .": "你仍然在身份服务器共享您的个人数据。", + "You are still sharing your personal data on the identity server .": "你仍然在身份服务器 共享你的个人数据。", "We recommend that you remove your email addresses and phone numbers from the identity server before disconnecting.": "我们推荐你在断开连接前从身份服务器上删除你的邮箱地址和电话号码。", "not stored": "未存储", "You are currently using to discover and be discoverable by existing contacts you know. You can change your identity server below.": "你正在使用来发现你认识的现存联系人,同时也让他们可以发现你。你可以在下方更改你的身份服务器。", @@ -1706,7 +1706,7 @@ "Toggle right panel": "切换右侧面板", "End": "End", "The server is not configured to indicate what the problem is (CORS).": "服务器没有配置为提示错误是什么(CORS)。", - "Activate selected button": "激活选择的按钮", + "Activate selected button": "激活选中的按钮", "Prepends ( ͡° ͜ʖ ͡°) to a plain-text message": "在纯文本消息开头添加 ( ͡° ͜ʖ ͡°)", "Unknown App": "未知应用", "Cross-signing is ready for use.": "交叉签名已可用。", @@ -1726,11 +1726,11 @@ "%(count)s results|one": "%(count)s 个结果", "Room Info": "房间信息", "No other application is using the webcam": "没有其他应用程序正在使用摄像头", - "Permission is granted to use the webcam": "授予使用网络摄像头的权限", - "A microphone and webcam are plugged in and set up correctly": "麦克风和摄像头已插入并正确设置", - "Unable to access webcam / microphone": "无法访问摄像头/麦克风", + "Permission is granted to use the webcam": "授权使用摄像头", + "A microphone and webcam are plugged in and set up correctly": "已插入并正确设置麦克风和摄像头", + "Unable to access webcam / microphone": "无法使用摄像头/麦克风", "Unable to access microphone": "无法使用麦克风", - "The call was answered on another device.": "在另一台设备上应答了此通话。", + "The call was answered on another device.": "已在另一台设备上接听了此通话。", "The call could not be established": "无法建立通话", "🎉 All servers are banned from participating! This room can no longer be used.": "🎉 所有服务器都已禁止参与!此房间不再可用。", "%(senderDisplayName)s changed the server ACLs for this room.": "%(senderDisplayName)s 为此房间更改了服务器 ACL。", @@ -1797,10 +1797,10 @@ "Try again": "重试", "We asked the browser to remember which homeserver you use to let you sign in, but unfortunately your browser has forgotten it. Go to the sign in page and try again.": "我们已要求浏览器记住你使用的主服务器,但不幸的是你的浏览器已忘记。请前往登录页面重试。", "We couldn't log you in": "我们无法使你登入", - "You've reached the maximum number of simultaneous calls.": "你已达到并行呼叫最大数量。", - "Too Many Calls": "太多呼叫", - "Call failed because webcam or microphone could not be accessed. Check that:": "通话失败,因为无法访问网络摄像头或麦克风。请检查:", - "Call failed because microphone could not be accessed. Check that a microphone is plugged in and set up correctly.": "呼叫失败,因为无法访问任何麦克风。 检查是否已插入麦克风并正确设置。", + "You've reached the maximum number of simultaneous calls.": "你已达到同时通话的最大数量。", + "Too Many Calls": "太多通话", + "Call failed because webcam or microphone could not be accessed. Check that:": "通话失败,因为无法使用摄像头或麦克风。请检查:", + "Call failed because microphone could not be accessed. Check that a microphone is plugged in and set up correctly.": "呼叫失败,因为无法使用任何麦克风。 检查是否已插入并正确设置麦克风。", "Answered Elsewhere": "已在别处接听", "Start a new chat": "开始新会话", "Room settings": "房间设置", @@ -1816,7 +1816,7 @@ "Public": "公共", "Delete": "删除", "Dial pad": "拨号盘", - "There was an error looking up the phone number": "查询电话号码的过程中发生错误", + "There was an error looking up the phone number": "查询电话号码时发生错误", "Unable to look up phone number": "无法查询电话号码", "Return to call": "返回通话", "%(peerName)s held the call": "%(peerName)s 挂起了通话", @@ -1856,8 +1856,8 @@ "Converts the DM to a room": "将此私聊会话转化为房间会话", "Prepends ┬──┬ ノ( ゜-゜ノ) to a plain-text message": "在纯文本消息开头添加 ┬──┬ ノ( ゜-゜ノ)", "Prepends (╯°□°)╯︵ ┻━┻ to a plain-text message": "在纯文本消息开头添加 (╯°□°)╯︵ ┻━┻", - "You're already in a call with this person.": "你正在与其通话中。", - "Already in call": "已在通话中", + "You're already in a call with this person.": "你正在与其通话。", + "Already in call": "正在通话中", "Go to Home View": "转到主视图", "Search (must be enabled)": "搜索(必须启用)", "Your Security Key": "你的安全密钥", @@ -2448,8 +2448,8 @@ "Currently joining %(count)s rooms|other": "目前正在加入 %(count)s 个房间", "Try different words or check for typos. Some results may not be visible as they're private and you need an invite to join them.": "尝试不同的单词或检查拼写错误。某些结果可能不可见,因为它们属于私有的,你需要一个邀请才能加入。", "No results for \"%(query)s\"": "「%(query)s」没有结果", - "The user you called is busy.": "你所拨打的用户正在忙碌中。", - "User Busy": "用户正在忙", + "The user you called is busy.": "你所呼叫的用户正忙。", + "User Busy": "用户正忙", "Or send invite link": "或发送邀请链接", "Some suggestions may be hidden for privacy.": "出于隐私考虑,部分建议可能会被隐藏。", "Search for rooms or people": "搜索房间或用户", @@ -2538,7 +2538,7 @@ "We sent the others, but the below people couldn't be invited to ": "我们已向其他人发送邀请,但无法邀请以下人员至", "Integration manager": "集成管理器", "Your %(brand)s doesn't allow you to use an integration manager to do this. Please contact an admin.": "你的 %(brand)s 不允许你使用集成管理器来完成此操作,请联系管理员。", - "Using this widget may share data with %(widgetDomain)s & your integration manager.": "使用此挂件可能会和 %(widgetDomain)s 及您的集成管理器共享数据 。", + "Using this widget may share data with %(widgetDomain)s & your integration manager.": "使用此挂件可能会与 %(widgetDomain)s 及您的集成管理器共享数据 。", "Identity server is": "身份服务器地址", "Integration managers receive configuration data, and can modify widgets, send room invites, and set power levels on your behalf.": "集成管理器接收配置数据,并可以以你的名义修改挂件、发送房间邀请及设置权力级别。", "Use an integration manager to manage bots, widgets, and sticker packs.": "使用集成管理器管理机器人、挂件和贴纸包。", @@ -2569,7 +2569,7 @@ "Message bubbles": "消息气泡", "Show all rooms": "显示所有房间", "Give feedback.": "给出反馈。", - "Thank you for trying Spaces. Your feedback will help inform the next versions.": "感谢您试用空间。你的反馈将有助于下一个版本。", + "Thank you for trying Spaces. Your feedback will help inform the next versions.": "感谢你试用《空间》。你的反馈将有助于改进下个版本。", "Spaces feedback": "空间反馈", "Spaces are a new feature.": "空间是一个新特性。", "Delete avatar": "删除头像", @@ -2676,7 +2676,7 @@ "Enable encryption in settings.": "在设置中启用加密。", "Your private messages are normally encrypted, but this room isn't. Usually this is due to an unsupported device or method being used, like email invites.": "你的私人消息通常是加密的,但此房间不是。这通常是因为使用了不受支持的设备或方法,例如电子邮件邀请。", "To avoid these issues, create a new public room for the conversation you plan to have.": "为避免这些问题,请为计划中的对话创建一个新的加密房间。", - "It's not recommended to make encrypted rooms public. It will mean anyone can find and join the room, so anyone can read messages. You'll get none of the benefits of encryption. Encrypting messages in a public room will make receiving and sending messages slower.": "不建议公开加密房间。这意味着任何人都可以找到并加入房间,因此任何人都可以阅读消息。你您将无法享受加密带来的任何好处。 在公共房间加密消息会导致接收和发送消息的速度变慢。", + "It's not recommended to make encrypted rooms public. It will mean anyone can find and join the room, so anyone can read messages. You'll get none of the benefits of encryption. Encrypting messages in a public room will make receiving and sending messages slower.": "不建议公开加密房间。这意味着任何人都可以找到并加入房间,因此任何人都可以阅读消息。你将不会得到任何加密带来的好处。在公共房间加密消息还会拖慢收发消息的速度。", "Are you sure you want to make this encrypted room public?": "你确定要公开此加密房间吗?", "To avoid these issues, create a new encrypted room for the conversation you plan to have.": "为避免这些问题,请为计划中的对话创建一个新的加密房间。", "It's not recommended to add encryption to public rooms.Anyone can find and join public rooms, so anyone can read messages in them. You'll get none of the benefits of encryption, and you won't be able to turn it off later. Encrypting messages in a public room will make receiving and sending messages slower.": "不建议为公开房间开启加密。任何人都可以找到并加入公开房间,因此任何人都可以阅读其中的消息。 你将无法从中体验加密的任何好处,且以后也无法将其关闭。 在公开房间中加密消息会导致接收和发送消息的速度变慢。", @@ -2686,7 +2686,7 @@ "Thread": "消息列", "Threaded messaging": "按主题排列的消息", "The above, but in as well": "以上,但也包括 ", - "The above, but in any room you are joined or invited to as well": "以上,但也包括您加入或被邀请加入的任何房间中", + "The above, but in any room you are joined or invited to as well": "以上,但也包括你加入或被邀请的任何房间中", "Autoplay videos": "自动播放视频", "Autoplay GIFs": "自动播放 GIF", "%(senderName)s unpinned a message from this room. See all pinned messages.": "%(senderName)s从此房间中取消固定了一条消息。查看所有固定消息。", @@ -2749,7 +2749,7 @@ "Plain Text": "纯文本", "JSON": "JSON", "HTML": "HTML", - "Are you sure you want to exit during this export?": "您确定要在导出未完成时退出吗?", + "Are you sure you want to exit during this export?": "你确定要在导出过程中退出吗?", "%(senderDisplayName)s sent a sticker.": "%(senderDisplayName)s 发送了一张贴纸。", "%(senderDisplayName)s changed the room avatar.": "%(senderDisplayName)s 更改了房间头像。", "%(date)s at %(time)s": "%(date)s 的 %(time)s", @@ -2801,7 +2801,7 @@ "Light high contrast": "浅色高对比", "Joining": "加入中", "Automatically send debug logs on any error": "遇到任何错误自动发送调试日志", - "Manage your signed-in devices below. A device's name is visible to people you communicate with.": "在下方管理您已登录的设备。 与您交流的人可以看到设备的名称。", + "Manage your signed-in devices below. A device's name is visible to people you communicate with.": "在下方管理你已登录的设备。与你交流的人可以看到设备的名称。", "Rename": "重命名", "Sign Out": "登出", "Last seen %(date)s at %(ip)s": "上次见到日期 %(date)s, IP %(ip)s", @@ -2819,10 +2819,10 @@ "Click the button below to confirm signing out these devices.|one": "单击下面的按钮以确认登出此设备。", "Click the button below to confirm signing out these devices.|other": "单击下面的按钮以确认登出这些设备。", "Confirm signing out these devices": "确认退出这些设备", - "Confirm logging out these devices by using Single Sign On to prove your identity.|one": "通过使用单点登录来证明您的身份,确认注销此设备。", - "Confirm logging out these devices by using Single Sign On to prove your identity.|other": "通过使用单点登录来证明您的身份,确认注销这些设备。", + "Confirm logging out these devices by using Single Sign On to prove your identity.|one": "确认注销此设备需要使用单点登录来证明您的身份。", + "Confirm logging out these devices by using Single Sign On to prove your identity.|other": "确认注销这些设备需要使用单点登录来证明你的身份。", "Unable to load device list": "无法加载设备列表", - "Your homeserver does not support device management.": "您的主服务器不支持设备管理。", + "Your homeserver does not support device management.": "你的主服务器不支持设备管理。", "Store your Security Key somewhere safe, like a password manager or a safe, as it's used to safeguard your encrypted data.": "将您的安全密钥存放在安全的地方,例如密码管理器或保险箱,因为它用于保护您的加密数据。", "Enter a security phrase only you know, as it's used to safeguard your data. To be secure, you shouldn't re-use your account password.": "输入只有您知道的安全短语,因为它用于保护您的数据。 为了安全起见,您不应重复使用您的账户密码。", "We'll generate a Security Key for you to store somewhere safe, like a password manager or a safe.": "我们将为您生成一个安全密钥,将其存储在安全的地方,例如密码管理器或保险箱。", @@ -2835,13 +2835,13 @@ "If you can't see who you're looking for, send them your invite link below.": "如果您看不到您要找的人,请将您的邀请链接发送给他们。", "You can't disable this later. Bridges & most bots won't work yet.": "之后你无法停用。桥接和大多数机器人也不能工作。", "In encrypted rooms, verify all users to ensure it's secure.": "在加密房间中,验证所有用户以确保其安全。", - "Yours, or the other users' session": "您的或其他用户的会话", - "Yours, or the other users' internet connection": "您或其他用户的互联网连接", - "The homeserver the user you're verifying is connected to": "您正在验证的用户所连接的主服务器", + "Yours, or the other users' session": "你或其他用户的会话", + "Yours, or the other users' internet connection": "你或其他用户的互联网连接", + "The homeserver the user you're verifying is connected to": "你正在验证的用户所连接的主服务器", "Can't see what you're looking for?": "看不到您要找的东西?", "This room isn't bridging messages to any platforms. Learn more.": "这个房间不会将消息桥接到任何平台。了解更多", "Where you're signed in": "登录地点", - "This room is in some spaces you're not an admin of. In those spaces, the old room will still be shown, but people will be prompted to join the new one.": "这个房间位于您不是管理员的某些空间中。 在这些空间中,旧房间仍将显示,但系统会提示人们加入新房间。", + "This room is in some spaces you're not an admin of. In those spaces, the old room will still be shown, but people will be prompted to join the new one.": "这个房间位于你不是管理员的某些空间中。 在这些空间中,旧房间仍将显示,但系统会提示人们加入新房间。", "Use a more compact 'Modern' layout": "使用更紧凑的“现代”布局", "Add option": "添加选项", "Write an option": "写个选项", @@ -2895,7 +2895,7 @@ "%(count)s votes|other": "%(count)s 票", "Sorry, the poll you tried to create was not posted.": "抱歉,您尝试创建的投票未被发布。", "Failed to post poll": "发布投票失败", - "Sorry, your vote was not registered. Please try again.": "抱歉,您的投票未登记。 请再试一次。", + "Sorry, your vote was not registered. Please try again.": "抱歉,你的投票未登记。请重试。", "Vote not registered": "投票未登记", "Developer": "开发者", "Experimental": "实验性", @@ -2913,7 +2913,7 @@ "%(spaceName)s menu": "%(spaceName)s菜单", "Join public room": "加入公共房间", "Add people": "加人", - "You do not have permissions to invite people to this space": "您无权邀请他人加入此空间", + "You do not have permissions to invite people to this space": "你无权邀请他人加入此空间", "Invite to space": "邀请到空间", "Start new chat": "开始新的聊天", "Recently viewed": "最近查看", @@ -2929,16 +2929,16 @@ "Share anonymous data to help us identify issues. Nothing personal. No third parties.": "共享匿名数据以帮助我们发现问题。 与个人无关。 没有第三方。", "Okay": "好", "Share anonymous data to help us identify issues. Nothing personal. No third parties. Learn More": "共享匿名数据帮助我们发现问题。无个人数据。 没有第三方。了解更多", - "You previously consented to share anonymous usage data with us. We're updating how that works.": "您之前同意与我们共享匿名使用数据。 我们正在更新它的工作原理。", + "You previously consented to share anonymous usage data with us. We're updating how that works.": "你之前同意与我们分享匿名使用数据。我们正在更新其工作方式。", "Help improve %(analyticsOwner)s": "帮助改进 %(analyticsOwner)s", "That's fine": "没问题", - "You cannot place calls without a connection to the server.": "您不能在没有连接到服务器的情况下拨打电话。", - "Connectivity to the server has been lost": "与服务器的连接已丢失", - "You cannot place calls in this browser.": "您无法在此浏览器中拨打电话。", + "You cannot place calls without a connection to the server.": "你不能在未连接到服务器时进行呼叫。", + "Connectivity to the server has been lost": "已丢失与服务器的连接", + "You cannot place calls in this browser.": "你无法在此浏览器中进行呼叫。", "Calls are unsupported": "不支持通话", "Share location": "共享位置", "Manage pinned events": "管理置顶事件", - "Toggle space panel": "切换空间仪表板", + "Toggle space panel": "切换空间仪表盘", "Are you sure you want to end this poll? This will show the final results of the poll and stop people from being able to vote.": "您确定要结束此投票吗? 这将显示投票的最终结果并阻止人们投票。", "End Poll": "结束投票", "Sorry, the poll did not end. Please try again.": "抱歉,投票没有结束。 请再试一次。", @@ -3204,8 +3204,8 @@ "Redo edit": "重做编辑", "Force complete": "强制完成", "Undo edit": "撤销编辑", - "Jump to last message": "跳至最后一条消息", - "Jump to first message": "跳至第一条消息", + "Jump to last message": "跳转至最后一条消息", + "Jump to first message": "跳转至第一条消息", "Toggle hidden event visibility": "切换隐藏事件可见性", "Unable to check if username has been taken. Try again later.": "无法检查用户名是否已被使用。稍后再试。", "Previous autocomplete suggestion": "上个自动完成建议", @@ -3465,8 +3465,8 @@ "Did not receive it? Resend it": "没收到吗?重新发送", "To create your account, open the link in the email we just sent to %(emailAddress)s.": "要创建账户,请打开我们刚刚发送到%(emailAddress)s的电子邮件里的链接。", "Toggle Link": "切换链接", - "Previous recently visited room or space": "上一个最近访问过的房间或空间", - "Next recently visited room or space": "下一个最近访问过的房间或空间", + "Previous recently visited room or space": "上个最近访问过的房间或空间", + "Next recently visited room or space": "下个最近访问过的房间或空间", "Open user settings": "打开用户设置", "Verified sessions": "已验证的会话", "For best security, sign out from any session that you don't recognize or use anymore.": "为了最佳安全性,请从任何不认识或不再使用的会话登出。", @@ -3537,5 +3537,26 @@ "Ongoing call": "正在进行的通话", "You do not have permission to start video calls": "你没有权限开始视频通话", "There's no one here to call": "这里没有人可以打电话", - "You do not have permission to start voice calls": "你没有权限开始语音通话" + "You do not have permission to start voice calls": "你没有权限开始语音通话", + "Can't start a new voice broadcast": "无法开始新的语音广播", + "Video call started in %(roomName)s. (not supported by this browser)": "%(roomName)s里的视频通话开始了。(此浏览器不支持)", + "Video call started in %(roomName)s.": "%(roomName)s里的视频通话开始了。", + "You need to be able to kick users to do that.": "你需要能够移除用户才能做到那件事。", + "Inviting %(user)s and %(count)s others|one": "正在邀请%(user)s和另外1个人", + "Inviting %(user)s and %(count)s others|other": "正在邀请%(user)s和其他%(count)s人", + "Turn off to disable notifications on all your devices and sessions": "关闭以在你全部设备和会话上停用通知", + "Enable notifications for this account": "为此账户启用通知", + "Enable notifications for this device": "为此设备启用通知", + "Record the client name, version, and url to recognise sessions more easily in session manager": "记录客户端名称、版本和url以便在会话管理器里更易识别", + "Room info": "房间信息", + "Switch to space by number": "按数字切换到空间", + "You don't have the required permissions to start a voice broadcast in this room. Contact a room administrator to upgrade your permissions.": "你没有必要的权限在这个房间开始语音广播。请联系房间管理员以提升你的权限。", + "You are already recording a voice broadcast. Please end your current voice broadcast to start a new one.": "你已经在录制一个语音广播。请结束你当前的语音广播以开始新的语音广播。", + "pause voice broadcast": "暂停语音广播", + "resume voice broadcast": "恢复语音广播", + "play voice broadcast": "播放语音广播", + "Yes, stop broadcast": "是的,停止广播", + "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.": "你确定要停止你的直播吗?这将结束直播,房间里将有完整的录音。", + "Stop live broadcasting?": "停止直播吗?", + "Someone else is already recording a voice broadcast. Wait for their voice broadcast to end to start a new one.": "别人已经在录制语音广播了。等到他们的语音广播结束后再开始新的广播。" } diff --git a/src/i18n/strings/zh_Hant.json b/src/i18n/strings/zh_Hant.json index 68647b9485f..2604c39ab25 100644 --- a/src/i18n/strings/zh_Hant.json +++ b/src/i18n/strings/zh_Hant.json @@ -3632,5 +3632,53 @@ "stop voice broadcast": "停止語音廣播", "resume voice broadcast": "恢復語音廣播", "pause voice broadcast": "暫停語音廣播", - "Notifications silenced": "通知已靜音" + "Notifications silenced": "通知已靜音", + "Completing set up of your new device": "完成您新裝置的設定", + "Waiting for device to sign in": "正在等待裝置登入", + "Connecting...": "正在連線……", + "Review and approve the sign in": "審閱並批准登入", + "Select 'Scan QR code'": "選取「掃描 QR code」", + "Start at the sign in screen": "從登入畫面開始", + "Scan the QR code below with your device that's signed out.": "使用已登出的裝置掃描下方的 QR code。", + "By approving access for this device, it will have full access to your account.": "透過批准此裝置的存取權限,其將對您的帳號有完全的存取權限。", + "Check that the code below matches with your other device:": "檢查以下代碼是否與您其他的裝置相符:", + "Devices connected": "裝置已連結", + "The homeserver doesn't support signing in another device.": "家伺服器不支援在其他裝置上登入。", + "An unexpected error occurred.": "發生預料之外的錯誤。", + "The request was cancelled.": "請求已取消。", + "The other device isn't signed in.": "其他裝置未登入。", + "The other device is already signed in.": "其他裝置已登入。", + "The request was declined on the other device.": "該請求在其他裝置上被拒絕。", + "Linking with this device is not supported.": "不支援與此裝置連結。", + "The scanned code is invalid.": "掃描的代碼無效。", + "The linking wasn't completed in the required time.": "連結未在要求的時間內完成。", + "Sign in new device": "登入新裝置", + "Show QR code": "顯示 QR code", + "You can use this device to sign in a new device with a QR code. You will need to scan the QR code shown on this device with your device that's signed out.": "您可以使用此裝置透過 QR code 登入新裝置。您將需要使用已登出的裝置掃描此裝置上顯示的 QR code。", + "Sign in with QR code": "使用 QR code 登入", + "Browser": "瀏覽器", + "Allow a QR code to be shown in session manager to sign in another device (requires compatible homeserver)": "允許在工作階段管理程式中顯示 QR code 以在另一台裝置上登入(需要相容的家伺服器)", + "Yes, stop broadcast": "是的,停止廣播", + "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.": "您確定您要停止您的即時廣播嗎?這將會結束廣播,聊天室內將會提供完整的錄製內容。", + "Stop live broadcasting?": "停止即時廣播?", + "Someone else is already recording a voice broadcast. Wait for their voice broadcast to end to start a new one.": "其他人已在錄製語音廣播。等待他們的語音廣播結束以開始新的語音廣播。", + "You don't have the required permissions to start a voice broadcast in this room. Contact a room administrator to upgrade your permissions.": "您沒有在此聊天室內開始語音廣播所需的權限。請聯絡聊天室管理員升級您的權限。", + "You are already recording a voice broadcast. Please end your current voice broadcast to start a new one.": "您已經開始錄製語音廣播。請結束您目前的語音廣播以開始新的語音廣播。", + "Can't start a new voice broadcast": "無法啟動新的語音廣播", + "play voice broadcast": "播放語音廣播", + "Show formatting": "顯示格式", + "Show plain text": "顯示純文字", + "Consider signing out from old sessions (%(inactiveAgeDays)s days or older) you don't use anymore.": "考慮登出您不再使用的舊工作階段 (%(inactiveAgeDays)s天或更舊)。", + "Removing inactive sessions improves security and performance, and makes it easier for you to identify if a new session is suspicious.": "刪除不活躍的工作階段可以改善安全性與效能,並讓您可以輕鬆識別新的工作階段是否可疑。", + "Inactive sessions are sessions you have not used in some time, but they continue to receive encryption keys.": "不活躍工作階段是您一段時間未使用的工作階段,但它們會繼續接收加密金鑰。", + "You should make especially certain that you recognise these sessions as they could represent an unauthorised use of your account.": "您應特別確定您可以識別這些工作階段,因為它們可能代表未經授權使用您的帳號。", + "Unverified sessions are sessions that have logged in with your credentials but have not been cross-verified.": "未經驗證的工作階段是使用您的憑證登入但尚未經過交叉驗證的工作階段。", + "This means they hold encryption keys for your previous messages, and confirm to other users you are communicating with that these sessions are really you.": "這代表了他們持有您之前訊息的加密金鑰,並向您正在與之通訊的其他使用者確認這些工作階段確實是您。", + "Verified sessions have logged in with your credentials and then been verified, either using your secure passphrase or by cross-verifying.": "已驗證的工作階段已使用您的憑證登入,然後使用您的安全密碼或透過交叉驗證進行驗證。", + "This provides them with confidence that they are really speaking to you, but it also means they can see the session name you enter here.": "這讓他們確定他們真的在與您交談,但這也代表了他們可以看到您在此處輸入的工作階段名稱。", + "Other users in direct messages and rooms that you join are able to view a full list of your sessions.": "您加入的直接訊息與聊天室中的其他使用者可以檢視您的工作階段的完整清單。", + "Renaming sessions": "重新命名工作階段", + "Please be aware that session names are also visible to people you communicate with.": "請注意,與您交談的人也可以看到工作階段名稱。", + "Are you sure you want to sign out of %(count)s sessions?|one": "您確定您想要登出 %(count)s 個工作階段嗎?", + "Are you sure you want to sign out of %(count)s sessions?|other": "您確定您想要登出 %(count)s 個工作階段嗎?" } diff --git a/src/models/Call.ts b/src/models/Call.ts index fd207cf1bee..c3ef2e6775d 100644 --- a/src/models/Call.ts +++ b/src/models/Call.ts @@ -43,6 +43,8 @@ import { WidgetMessagingStore, WidgetMessagingStoreEvent } from "../stores/widge import ActiveWidgetStore, { ActiveWidgetStoreEvent } from "../stores/ActiveWidgetStore"; import PlatformPeg from "../PlatformPeg"; import { getCurrentLanguage } from "../languageHandler"; +import DesktopCapturerSourcePicker from "../components/views/elements/DesktopCapturerSourcePicker"; +import Modal from "../Modal"; const TIMEOUT_MS = 16000; @@ -639,10 +641,6 @@ export class ElementCall extends Call { 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 @@ -818,6 +816,7 @@ export class ElementCall extends Call { this.messaging!.on(`action:${ElementWidgetActions.HangupCall}`, this.onHangup); this.messaging!.on(`action:${ElementWidgetActions.TileLayout}`, this.onTileLayout); this.messaging!.on(`action:${ElementWidgetActions.SpotlightLayout}`, this.onSpotlightLayout); + this.messaging!.on(`action:${ElementWidgetActions.ScreenshareRequest}`, this.onScreenshareRequest); } protected async performDisconnection(): Promise { @@ -831,8 +830,9 @@ 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); + this.messaging!.off(`action:${ElementWidgetActions.TileLayout}`, this.onTileLayout); + this.messaging!.off(`action:${ElementWidgetActions.SpotlightLayout}`, this.onSpotlightLayout); + this.messaging!.off(`action:${ElementWidgetActions.ScreenshareRequest}`, this.onScreenshareRequest); super.setDisconnected(); } @@ -951,4 +951,25 @@ export class ElementCall extends Call { this.layout = Layout.Spotlight; await this.messaging!.transport.reply(ev.detail, {}); // ack }; + + private onScreenshareRequest = async (ev: CustomEvent) => { + ev.preventDefault(); + + if (PlatformPeg.get().supportsDesktopCapturer()) { + await this.messaging!.transport.reply(ev.detail, { pending: true }); + + const { finished } = Modal.createDialog(DesktopCapturerSourcePicker); + const [source] = await finished; + + if (source) { + await this.messaging!.transport.send(ElementWidgetActions.ScreenshareStart, { + desktopCapturerSourceId: source, + }); + } else { + await this.messaging!.transport.send(ElementWidgetActions.ScreenshareStop, {}); + } + } else { + await this.messaging!.transport.reply(ev.detail, { pending: false }); + } + }; } diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index 44b63b049aa..3f176985930 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -510,6 +510,16 @@ export const SETTINGS: {[setting: string]: ISetting} = { , }, }, + "feature_qr_signin_reciprocate_show": { + isFeature: true, + labsGroup: LabGroup.Experimental, + supportedLevels: LEVELS_FEATURE, + displayName: _td( + "Allow a QR code to be shown in session manager to sign in another device " + + "(requires compatible homeserver)", + ), + default: false, + }, "baseFontSize": { displayName: _td("Font size"), supportedLevels: LEVELS_ACCOUNT_SETTINGS, diff --git a/src/stores/RoomViewStore.tsx b/src/stores/RoomViewStore.tsx index 0a15ce18607..13c1e09c764 100644 --- a/src/stores/RoomViewStore.tsx +++ b/src/stores/RoomViewStore.tsx @@ -17,6 +17,7 @@ limitations under the License. */ import React, { ReactNode } from "react"; +import * as utils from 'matrix-js-sdk/src/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"; @@ -27,7 +28,7 @@ import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { Optional } from "matrix-events-sdk"; import EventEmitter from "events"; -import { defaultDispatcher, MatrixDispatcher } from '../dispatcher/dispatcher'; +import { MatrixDispatcher } from '../dispatcher/dispatcher'; import { MatrixClientPeg } from '../MatrixClientPeg'; import Modal from '../Modal'; import { _t } from '../languageHandler'; @@ -35,10 +36,8 @@ import { getCachedRoomIDForAlias, storeRoomAliasInCache } from '../RoomAliasCach import { Action } from "../dispatcher/actions"; import { retry } from "../utils/promise"; import { TimelineRenderingType } from "../contexts/RoomContext"; -import { PosthogAnalytics } from "../PosthogAnalytics"; import { ViewRoomPayload } from "../dispatcher/payloads/ViewRoomPayload"; import DMRoomMap from "../utils/DMRoomMap"; -import SpaceStore from "./spaces/SpaceStore"; import { isMetaSpace, MetaSpace } from "./spaces"; import { JoinRoomPayload } from "../dispatcher/payloads/JoinRoomPayload"; import { JoinRoomReadyPayload } from "../dispatcher/payloads/JoinRoomReadyPayload"; @@ -47,10 +46,11 @@ import { ViewRoomErrorPayload } from "../dispatcher/payloads/ViewRoomErrorPayloa import ErrorDialog from "../components/views/dialogs/ErrorDialog"; import { ActiveRoomChangedPayload } from "../dispatcher/payloads/ActiveRoomChangedPayload"; import SettingsStore from "../settings/SettingsStore"; -import { SlidingSyncManager } from "../SlidingSyncManager"; import { awaitRoomDownSync } from "../utils/RoomUpgrade"; import { UPDATE_EVENT } from "./AsyncStore"; +import { SdkContextClass } from "../contexts/SDKContext"; import { CallStore } from "./CallStore"; +import { ThreadPayload } from "../dispatcher/payloads/ThreadPayload"; const NUM_JOIN_RETRY = 5; @@ -67,6 +67,10 @@ interface State { * The ID of the room currently being viewed */ roomId: string | null; + /** + * The ID of the thread currently being viewed + */ + threadId: string | null; /** * The ID of the room being subscribed to (in Sliding Sync) */ @@ -110,6 +114,7 @@ const INITIAL_STATE: State = { joining: false, joinError: null, roomId: null, + threadId: null, subscribingRoomId: null, initialEventId: null, initialEventPixelOffset: null, @@ -131,17 +136,16 @@ type Listener = (isActive: boolean) => void; * A class for storing application state for RoomView. */ 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(defaultDispatcher); - - private state: State = INITIAL_STATE; // initialize state + // initialize state as a copy of the initial state. We need to copy else one RVS can talk to + // another RVS via INITIAL_STATE as they share the same underlying object. Mostly relevant for tests. + private state = utils.deepCopy(INITIAL_STATE); private dis: MatrixDispatcher; private dispatchToken: string; - public constructor(dis: MatrixDispatcher) { + public constructor( + dis: MatrixDispatcher, private readonly stores: SdkContextClass, + ) { super(); this.resetDispatcher(dis); } @@ -202,6 +206,9 @@ export class RoomViewStore extends EventEmitter { case Action.ViewRoom: this.viewRoom(payload); break; + case Action.ViewThread: + this.viewThread(payload); + break; // for these events blank out the roomId as we are no longer in the RoomView case 'view_welcome_page': case Action.ViewHomePage: @@ -248,7 +255,7 @@ export class RoomViewStore extends EventEmitter { : numMembers > 1 ? "Two" : "One"; - PosthogAnalytics.instance.trackEvent({ + this.stores.posthogAnalytics.trackEvent({ eventName: "JoinedRoom", trigger: payload.metricsTrigger, roomSize, @@ -291,17 +298,17 @@ export class RoomViewStore extends EventEmitter { if (payload.metricsTrigger !== null && payload.room_id !== this.state.roomId) { let activeSpace: ViewRoomEvent["activeSpace"]; - if (SpaceStore.instance.activeSpace === MetaSpace.Home) { + if (this.stores.spaceStore.activeSpace === MetaSpace.Home) { activeSpace = "Home"; - } else if (isMetaSpace(SpaceStore.instance.activeSpace)) { + } else if (isMetaSpace(this.stores.spaceStore.activeSpace)) { activeSpace = "Meta"; } else { - activeSpace = SpaceStore.instance.activeSpaceRoom.getJoinRule() === JoinRule.Public + activeSpace = this.stores.spaceStore.activeSpaceRoom?.getJoinRule() === JoinRule.Public ? "Public" : "Private"; } - PosthogAnalytics.instance.trackEvent({ + this.stores.posthogAnalytics.trackEvent({ eventName: "ViewRoom", trigger: payload.metricsTrigger, viaKeyboard: payload.metricsViaKeyboard, @@ -314,7 +321,7 @@ export class RoomViewStore extends EventEmitter { if (SettingsStore.getValue("feature_sliding_sync") && this.state.roomId !== payload.room_id) { 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.subscribingRoomId, false); + this.stores.slidingSyncManager.setRoomVisible(this.state.subscribingRoomId, false); } this.setState({ subscribingRoomId: payload.room_id, @@ -332,11 +339,11 @@ export class RoomViewStore extends EventEmitter { }); // 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. - await SlidingSyncManager.instance.setRoomVisible(payload.room_id, true); + await this.stores.slidingSyncManager.setRoomVisible(payload.room_id, true); // 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(payload.room_id, false); + this.stores.slidingSyncManager.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 @@ -432,6 +439,12 @@ export class RoomViewStore extends EventEmitter { } } + private viewThread(payload: ThreadPayload): void { + this.setState({ + threadId: payload.thread_id, + }); + } + private viewRoomError(payload: ViewRoomErrorPayload): void { this.setState({ roomId: payload.room_id, @@ -552,6 +565,10 @@ export class RoomViewStore extends EventEmitter { return this.state.roomId; } + public getThreadId(): Optional { + return this.state.threadId; + } + // The event to scroll to when the room is first viewed public getInitialEventId(): Optional { return this.state.initialEventId; @@ -599,7 +616,7 @@ export class RoomViewStore extends EventEmitter { // // Not joined // } // } else { - // if (RoomViewStore.instance.isJoining()) { + // if (this.stores.roomViewStore.isJoining()) { // // show spinner // } else { // // show join prompt diff --git a/src/stores/ThreepidInviteStore.ts b/src/stores/ThreepidInviteStore.ts index 9b597ba877b..fe2df8f66e7 100644 --- a/src/stores/ThreepidInviteStore.ts +++ b/src/stores/ThreepidInviteStore.ts @@ -56,7 +56,7 @@ export interface IOOBData { inviterName?: string; // The display name of the person who invited us to the room // eslint-disable-next-line camelcase room_name?: string; // The name of the room, to be used until we are told better by the server - roomType?: RoomType; // The type of the room, to be used until we are told better by the server + roomType?: RoomType | string; // The type of the room, to be used until we are told better by the server } const STORAGE_PREFIX = "mx_threepid_invite_"; diff --git a/src/stores/TypingStore.ts b/src/stores/TypingStore.ts index d642f3fea7f..be17da6e4e6 100644 --- a/src/stores/TypingStore.ts +++ b/src/stores/TypingStore.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { MatrixClientPeg } from "../MatrixClientPeg"; +import { SdkContextClass } from "../contexts/SDKContext"; import SettingsStore from "../settings/SettingsStore"; import { isLocalRoom } from "../utils/localRoom/isLocalRoom"; import Timer from "../utils/Timer"; @@ -34,17 +34,10 @@ export default class TypingStore { }; }; - constructor() { + constructor(private readonly context: SdkContextClass) { this.reset(); } - public static sharedInstance(): TypingStore { - if (window.mxTypingStore === undefined) { - window.mxTypingStore = new TypingStore(); - } - return window.mxTypingStore; - } - /** * Clears all cached typing states. Intended to be called when the * MatrixClientPeg client changes. @@ -108,6 +101,6 @@ export default class TypingStore { } else currentTyping.userTimer.restart(); } - MatrixClientPeg.get().sendTyping(roomId, isTyping, TYPING_SERVER_TIMEOUT); + this.context.client?.sendTyping(roomId, isTyping, TYPING_SERVER_TIMEOUT); } } diff --git a/src/stores/notifications/RoomNotificationState.ts b/src/stores/notifications/RoomNotificationState.ts index c38d67ceb55..32be5129326 100644 --- a/src/stores/notifications/RoomNotificationState.ts +++ b/src/stores/notifications/RoomNotificationState.ts @@ -17,6 +17,7 @@ limitations under the License. import { MatrixEvent, MatrixEventEvent } from "matrix-js-sdk/src/models/event"; import { NotificationCountType, Room, RoomEvent } from "matrix-js-sdk/src/models/room"; import { ClientEvent } from "matrix-js-sdk/src/client"; +import { Feature, ServerSupport } from "matrix-js-sdk/src/feature"; import { NotificationColor } from "./NotificationColor"; import { IDestroyable } from "../../utils/IDestroyable"; @@ -35,23 +36,28 @@ export class RoomNotificationState extends NotificationState implements IDestroy private featureMarkedUnreadWatcherRef = null; constructor(public readonly room: Room, private readonly threadsState?: ThreadsRoomNotificationState) { super(); + const cli = this.room.client; this.room.on(RoomEvent.Receipt, this.handleReadReceipt); - this.room.on(RoomEvent.Timeline, this.handleRoomEventUpdate); - this.room.on(RoomEvent.Redaction, this.handleRoomEventUpdate); this.room.on(RoomEvent.MyMembership, this.handleMembershipUpdate); this.room.on(RoomEvent.LocalEchoUpdated, this.handleLocalEchoUpdated); - this.room.on(RoomEvent.AccountData, this.handleRoomAccountDataUpdate); - if (threadsState) { - threadsState.on(NotificationStateEvents.Update, this.handleThreadsUpdate); + this.room.on(RoomEvent.AccountData, this.handleRoomAccountDataUpdate); // SC: for mark unread + + this.room.on(RoomEvent.Timeline, this.handleRoomEventUpdate); + this.room.on(RoomEvent.Redaction, this.handleRoomEventUpdate); + + this.room.on(RoomEvent.UnreadNotifications, this.handleNotificationCountUpdate); // for server-sent counts + if (cli.canSupport.get(Feature.ThreadUnreadNotifications) === ServerSupport.Unsupported) { + this.threadsState?.on(NotificationStateEvents.Update, this.handleThreadsUpdate); } - MatrixClientPeg.get().on(MatrixEventEvent.Decrypted, this.onEventDecrypted); - MatrixClientPeg.get().on(ClientEvent.AccountData, this.handleAccountDataUpdate); + cli.on(MatrixEventEvent.Decrypted, this.onEventDecrypted); + cli.on(ClientEvent.AccountData, this.handleAccountDataUpdate); + + this.updateNotificationState(); + // SC: for mark unread this.featureMarkedUnreadWatcherRef = SettingsStore.watchSetting("feature_mark_unread", null, () => { this.updateNotificationState(); }); - - this.updateNotificationState(); } private get roomIsInvite(): boolean { @@ -60,20 +66,21 @@ export class RoomNotificationState extends NotificationState implements IDestroy public destroy(): void { super.destroy(); + const cli = this.room.client; this.room.removeListener(RoomEvent.Receipt, this.handleReadReceipt); - this.room.removeListener(RoomEvent.Timeline, this.handleRoomEventUpdate); - this.room.removeListener(RoomEvent.Redaction, this.handleRoomEventUpdate); this.room.removeListener(RoomEvent.MyMembership, this.handleMembershipUpdate); this.room.removeListener(RoomEvent.LocalEchoUpdated, this.handleLocalEchoUpdated); - this.room.removeListener(RoomEvent.AccountData, this.handleRoomAccountDataUpdate); - if (this.threadsState) { + this.room.removeListener(RoomEvent.AccountData, this.handleRoomAccountDataUpdate); // SC: for mark unread + this.room.removeListener(RoomEvent.Timeline, this.handleRoomEventUpdate); + this.room.removeListener(RoomEvent.Redaction, this.handleRoomEventUpdate); + if (cli.canSupport.get(Feature.ThreadUnreadNotifications) === ServerSupport.Unsupported) { + this.room.removeListener(RoomEvent.UnreadNotifications, this.handleNotificationCountUpdate); + } else if (this.threadsState) { this.threadsState.removeListener(NotificationStateEvents.Update, this.handleThreadsUpdate); } - if (MatrixClientPeg.get()) { - MatrixClientPeg.get().removeListener(MatrixEventEvent.Decrypted, this.onEventDecrypted); - MatrixClientPeg.get().removeListener(ClientEvent.AccountData, this.handleAccountDataUpdate); - } - SettingsStore.unwatchSetting(this.featureMarkedUnreadWatcherRef); + cli.removeListener(MatrixEventEvent.Decrypted, this.onEventDecrypted); + cli.removeListener(ClientEvent.AccountData, this.handleAccountDataUpdate); + SettingsStore.unwatchSetting(this.featureMarkedUnreadWatcherRef); // SC: for mark unread } private handleThreadsUpdate = () => { @@ -94,6 +101,10 @@ export class RoomNotificationState extends NotificationState implements IDestroy this.updateNotificationState(); }; + private handleNotificationCountUpdate = () => { + this.updateNotificationState(); + }; + private onEventDecrypted = (event: MatrixEvent) => { if (event.getRoomId() !== this.room.roomId) return; // ignore - not for us or notifications timeline @@ -102,7 +113,6 @@ export class RoomNotificationState extends NotificationState implements IDestroy private handleRoomEventUpdate = (event: MatrixEvent, room: Room | null) => { if (room?.roomId !== this.room.roomId) return; // ignore - not for us or notifications timeline - this.updateNotificationState(); }; diff --git a/src/stores/notifications/RoomNotificationStateStore.ts b/src/stores/notifications/RoomNotificationStateStore.ts index 48aa7e7c20f..ad9bd9f98d6 100644 --- a/src/stores/notifications/RoomNotificationStateStore.ts +++ b/src/stores/notifications/RoomNotificationStateStore.ts @@ -17,6 +17,7 @@ limitations under the License. import { Room } from "matrix-js-sdk/src/models/room"; import { ISyncStateData, SyncState } from "matrix-js-sdk/src/sync"; import { ClientEvent } from "matrix-js-sdk/src/client"; +import { Feature, ServerSupport } from "matrix-js-sdk/src/feature"; import { ActionPayload } from "../../dispatcher/payloads"; import { AsyncStoreWithClient } from "../AsyncStoreWithClient"; @@ -39,9 +40,9 @@ export class RoomNotificationStateStore extends AsyncStoreWithClient { instance.start(); return instance; })(); - private roomMap = new Map(); - private roomThreadsMap = new Map(); + + private roomThreadsMap: Map = new Map(); private listMap = new Map(); private _globalState = new SummarizedNotificationState(); @@ -86,18 +87,25 @@ export class RoomNotificationStateStore extends AsyncStoreWithClient { */ public getRoomState(room: Room): RoomNotificationState { if (!this.roomMap.has(room)) { - // Not very elegant, but that way we ensure that we start tracking - // threads notification at the same time at rooms. - // There are multiple entry points, and it's unclear which one gets - // called first - const threadState = new ThreadsRoomNotificationState(room); - this.roomThreadsMap.set(room, threadState); + let threadState; + if (room.client.canSupport.get(Feature.ThreadUnreadNotifications) === ServerSupport.Unsupported) { + // Not very elegant, but that way we ensure that we start tracking + // threads notification at the same time at rooms. + // There are multiple entry points, and it's unclear which one gets + // called first + const threadState = new ThreadsRoomNotificationState(room); + this.roomThreadsMap.set(room, threadState); + } this.roomMap.set(room, new RoomNotificationState(room, threadState)); } return this.roomMap.get(room); } - public getThreadsRoomState(room: Room): ThreadsRoomNotificationState { + public getThreadsRoomState(room: Room): ThreadsRoomNotificationState | null { + if (room.client.canSupport.get(Feature.ThreadUnreadNotifications) !== ServerSupport.Unsupported) { + return null; + } + if (!this.roomThreadsMap.has(room)) { this.roomThreadsMap.set(room, new ThreadsRoomNotificationState(room)); } diff --git a/src/stores/right-panel/RightPanelStore.ts b/src/stores/right-panel/RightPanelStore.ts index 327f82153ff..9aa4c1b27c8 100644 --- a/src/stores/right-panel/RightPanelStore.ts +++ b/src/stores/right-panel/RightPanelStore.ts @@ -34,7 +34,7 @@ import { import { ActionPayload } from "../../dispatcher/payloads"; import { Action } from "../../dispatcher/actions"; import { ActiveRoomChangedPayload } from "../../dispatcher/payloads/ActiveRoomChangedPayload"; -import { RoomViewStore } from "../RoomViewStore"; +import { SdkContextClass } from "../../contexts/SDKContext"; /** * A class for tracking the state of the right panel between layouts and @@ -64,7 +64,7 @@ export default class RightPanelStore extends ReadyWatchingStore { } protected async onReady(): Promise { - this.viewedRoomId = RoomViewStore.instance.getRoomId(); + this.viewedRoomId = SdkContextClass.instance.roomViewStore.getRoomId(); this.matrixClient.on(CryptoEvent.VerificationRequest, this.onVerificationRequestUpdate); this.loadCacheFromSettings(); this.emitAndUpdateSettings(); diff --git a/src/stores/room-list/RoomListStore.ts b/src/stores/room-list/RoomListStore.ts index 254cdbe0f25..ba9fc47ac20 100644 --- a/src/stores/room-list/RoomListStore.ts +++ b/src/stores/room-list/RoomListStore.ts @@ -24,10 +24,9 @@ import SettingsStore from "../../settings/SettingsStore"; import { DefaultTagID, OrderedDefaultTagIDs, RoomUpdateCause, TagID } from "./models"; import { IListOrderingMap, ITagMap, ITagSortingMap, ListAlgorithm, SortAlgorithm } from "./algorithms/models"; import { ActionPayload } from "../../dispatcher/payloads"; -import defaultDispatcher from "../../dispatcher/dispatcher"; +import defaultDispatcher, { MatrixDispatcher } from "../../dispatcher/dispatcher"; import { readReceiptChangeIsFor } from "../../utils/read-receipts"; import { FILTER_CHANGED, IFilterCondition } from "./filters/IFilterCondition"; -import { RoomViewStore } from "../RoomViewStore"; import { Algorithm, LIST_UPDATED_EVENT } from "./algorithms/Algorithm"; import { EffectiveMembership, getEffectiveMembership } from "../../utils/membership"; import RoomListLayoutStore from "./RoomListLayoutStore"; @@ -40,6 +39,7 @@ import { IRoomTimelineActionPayload } from "../../actions/MatrixActionCreators"; import { RoomListStore as Interface, RoomListStoreEvent } from "./Interface"; import { SlidingRoomListStoreClass } from "./SlidingRoomListStore"; import { UPDATE_EVENT } from "../AsyncStore"; +import { SdkContextClass } from "../../contexts/SDKContext"; interface IState { // state is tracked in underlying classes @@ -65,8 +65,8 @@ export class RoomListStoreClass extends AsyncStoreWithClient implements this.emit(LISTS_UPDATE_EVENT); }); - constructor() { - super(defaultDispatcher); + constructor(dis: MatrixDispatcher) { + super(dis); this.setMaxListeners(20); // RoomList + LeftPanel + 8xRoomSubList + spares this.algorithm.start(); } @@ -105,7 +105,7 @@ export class RoomListStoreClass extends AsyncStoreWithClient implements this.readyStore.useUnitTestClient(forcedClient); } - RoomViewStore.instance.addListener(UPDATE_EVENT, () => this.handleRVSUpdate({})); + SdkContextClass.instance.roomViewStore.addListener(UPDATE_EVENT, () => this.handleRVSUpdate({})); this.algorithm.on(LIST_UPDATED_EVENT, this.onAlgorithmListUpdated); this.algorithm.on(FILTER_CHANGED, this.onAlgorithmFilterUpdated); this.setupWatchers(); @@ -128,7 +128,7 @@ export class RoomListStoreClass extends AsyncStoreWithClient implements private handleRVSUpdate({ trigger = true }) { if (!this.matrixClient) return; // We assume there won't be RVS updates without a client - const activeRoomId = RoomViewStore.instance.getRoomId(); + const activeRoomId = SdkContextClass.instance.roomViewStore.getRoomId(); if (!activeRoomId && this.algorithm.stickyRoom) { this.algorithm.setStickyRoom(null); } else if (activeRoomId) { @@ -633,11 +633,11 @@ export default class RoomListStore { if (!RoomListStore.internalInstance) { if (SettingsStore.getValue("feature_sliding_sync")) { logger.info("using SlidingRoomListStoreClass"); - const instance = new SlidingRoomListStoreClass(); + const instance = new SlidingRoomListStoreClass(defaultDispatcher, SdkContextClass.instance); instance.start(); RoomListStore.internalInstance = instance; } else { - const instance = new RoomListStoreClass(); + const instance = new RoomListStoreClass(defaultDispatcher); instance.start(); RoomListStore.internalInstance = instance; } diff --git a/src/stores/room-list/SlidingRoomListStore.ts b/src/stores/room-list/SlidingRoomListStore.ts index 3d532fe0c93..1c5fd1adea2 100644 --- a/src/stores/room-list/SlidingRoomListStore.ts +++ b/src/stores/room-list/SlidingRoomListStore.ts @@ -21,16 +21,14 @@ import { MSC3575Filter, SlidingSyncEvent } from "matrix-js-sdk/src/sliding-sync" import { RoomUpdateCause, TagID, OrderedDefaultTagIDs, DefaultTagID } from "./models"; import { ITagMap, ListAlgorithm, SortAlgorithm } from "./algorithms/models"; import { ActionPayload } from "../../dispatcher/payloads"; -import defaultDispatcher from "../../dispatcher/dispatcher"; +import { MatrixDispatcher } from "../../dispatcher/dispatcher"; import { IFilterCondition } from "./filters/IFilterCondition"; import { AsyncStoreWithClient } from "../AsyncStoreWithClient"; import { RoomListStore as Interface, RoomListStoreEvent } from "./Interface"; -import { SlidingSyncManager } from "../../SlidingSyncManager"; -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"; +import { SdkContextClass } from "../../contexts/SDKContext"; interface IState { // state is tracked in underlying classes @@ -38,7 +36,7 @@ interface IState { export const SlidingSyncSortToFilter: Record = { [SortAlgorithm.Alphabetic]: ["by_name", "by_recency"], - [SortAlgorithm.Recent]: ["by_highlight_count", "by_notification_count", "by_recency"], + [SortAlgorithm.Recent]: ["by_notification_level", "by_recency"], [SortAlgorithm.Manual]: ["by_recency"], }; @@ -48,21 +46,18 @@ const filterConditions: Record = { }, [DefaultTagID.Favourite]: { tags: ["m.favourite"], - is_tombstoned: false, }, // TODO https://github.com/vector-im/element-web/issues/23207 // DefaultTagID.SavedItems, [DefaultTagID.DM]: { is_dm: true, is_invite: false, - is_tombstoned: false, // If a DM has a Favourite & Low Prio tag then it'll be shown in those lists instead not_tags: ["m.favourite", "m.lowpriority"], }, [DefaultTagID.Untagged]: { is_dm: false, is_invite: false, - is_tombstoned: false, not_room_types: ["m.space"], not_tags: ["m.favourite", "m.lowpriority"], // spaces filter added dynamically @@ -71,7 +66,6 @@ const filterConditions: Record = { tags: ["m.lowpriority"], // If a room has both Favourite & Low Prio tags then it'll be shown under Favourites not_tags: ["m.favourite"], - is_tombstoned: false, }, // TODO https://github.com/vector-im/element-web/issues/23207 // DefaultTagID.ServerNotice, @@ -87,25 +81,25 @@ export class SlidingRoomListStoreClass extends AsyncStoreWithClient impl private counts: Record = {}; private stickyRoomId: string | null; - public constructor() { - super(defaultDispatcher); + public constructor(dis: MatrixDispatcher, private readonly context: SdkContextClass) { + super(dis); this.setMaxListeners(20); // RoomList + LeftPanel + 8xRoomSubList + spares } public async setTagSorting(tagId: TagID, sort: SortAlgorithm) { logger.info("SlidingRoomListStore.setTagSorting ", tagId, sort); this.tagIdToSortAlgo[tagId] = sort; - const slidingSyncIndex = SlidingSyncManager.instance.getOrAllocateListIndex(tagId); + const slidingSyncIndex = this.context.slidingSyncManager.getOrAllocateListIndex(tagId); switch (sort) { case SortAlgorithm.Alphabetic: - await SlidingSyncManager.instance.ensureListRegistered( + await this.context.slidingSyncManager.ensureListRegistered( slidingSyncIndex, { sort: SlidingSyncSortToFilter[SortAlgorithm.Alphabetic], }, ); break; case SortAlgorithm.Recent: - await SlidingSyncManager.instance.ensureListRegistered( + await this.context.slidingSyncManager.ensureListRegistered( slidingSyncIndex, { sort: SlidingSyncSortToFilter[SortAlgorithm.Recent], }, @@ -174,10 +168,13 @@ export class SlidingRoomListStoreClass extends AsyncStoreWithClient impl // check all lists for each tag we know about and see if the room is there const tags: TagID[] = []; for (const tagId in this.tagIdToSortAlgo) { - const index = SlidingSyncManager.instance.getOrAllocateListIndex(tagId); - const { roomIndexToRoomId } = SlidingSyncManager.instance.slidingSync.getListData(index); - for (const roomIndex in roomIndexToRoomId) { - const roomId = roomIndexToRoomId[roomIndex]; + const index = this.context.slidingSyncManager.getOrAllocateListIndex(tagId); + const listData = this.context.slidingSyncManager.slidingSync.getListData(index); + if (!listData) { + continue; + } + for (const roomIndex in listData.roomIndexToRoomId) { + const roomId = listData.roomIndexToRoomId[roomIndex]; if (roomId === room.roomId) { tags.push(tagId); break; @@ -207,7 +204,7 @@ export class SlidingRoomListStoreClass extends AsyncStoreWithClient impl // this room will not move due to it being viewed: it is sticky. This can be null to indicate // no sticky room if you aren't viewing a room. - this.stickyRoomId = RoomViewStore.instance.getRoomId(); + this.stickyRoomId = this.context.roomViewStore.getRoomId(); let stickyRoomNewIndex = -1; const stickyRoomOldIndex = (tagMap[tagId] || []).findIndex((room) => { return room.roomId === this.stickyRoomId; @@ -264,7 +261,7 @@ export class SlidingRoomListStoreClass extends AsyncStoreWithClient impl } private onSlidingSyncListUpdate(listIndex: number, joinCount: number, roomIndexToRoomId: Record) { - const tagId = SlidingSyncManager.instance.listIdForIndex(listIndex); + const tagId = this.context.slidingSyncManager.listIdForIndex(listIndex); this.counts[tagId]= joinCount; this.refreshOrderedLists(tagId, roomIndexToRoomId); // let the UI update @@ -273,7 +270,7 @@ export class SlidingRoomListStoreClass extends AsyncStoreWithClient impl private onRoomViewStoreUpdated() { // we only care about this to know when the user has clicked on a room to set the stickiness value - if (RoomViewStore.instance.getRoomId() === this.stickyRoomId) { + if (this.context.roomViewStore.getRoomId() === this.stickyRoomId) { return; } @@ -296,14 +293,17 @@ export class SlidingRoomListStoreClass extends AsyncStoreWithClient impl if (room) { // resort it based on the slidingSync view of the list. This may cause this old sticky // room to cease to exist. - const index = SlidingSyncManager.instance.getOrAllocateListIndex(tagId); - const { roomIndexToRoomId } = SlidingSyncManager.instance.slidingSync.getListData(index); - this.refreshOrderedLists(tagId, roomIndexToRoomId); + const index = this.context.slidingSyncManager.getOrAllocateListIndex(tagId); + const listData = this.context.slidingSyncManager.slidingSync.getListData(index); + if (!listData) { + continue; + } + this.refreshOrderedLists(tagId, listData.roomIndexToRoomId); hasUpdatedAnyList = true; } } // in the event we didn't call refreshOrderedLists, it helps to still remember the sticky room ID. - this.stickyRoomId = RoomViewStore.instance.getRoomId(); + this.stickyRoomId = this.context.roomViewStore.getRoomId(); if (hasUpdatedAnyList) { this.emit(LISTS_UPDATE_EVENT); @@ -313,11 +313,11 @@ export class SlidingRoomListStoreClass extends AsyncStoreWithClient impl protected async onReady(): Promise { 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(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); + this.context.slidingSyncManager.slidingSync.on(SlidingSyncEvent.List, this.onSlidingSyncListUpdate.bind(this)); + this.context.roomViewStore.addListener(UPDATE_EVENT, this.onRoomViewStoreUpdated.bind(this)); + this.context.spaceStore.on(UPDATE_SELECTED_SPACE, this.onSelectedSpaceUpdated.bind(this)); + if (this.context.spaceStore.activeSpace) { + this.onSelectedSpaceUpdated(this.context.spaceStore.activeSpace, false); } // sliding sync has an initial response for spaces. Now request all the lists. @@ -332,8 +332,8 @@ export class SlidingRoomListStoreClass extends AsyncStoreWithClient impl const sort = SortAlgorithm.Recent; // default to recency sort, TODO: read from config this.tagIdToSortAlgo[tagId] = sort; this.emit(LISTS_LOADING_EVENT, tagId, true); - const index = SlidingSyncManager.instance.getOrAllocateListIndex(tagId); - SlidingSyncManager.instance.ensureListRegistered(index, { + const index = this.context.slidingSyncManager.getOrAllocateListIndex(tagId); + this.context.slidingSyncManager.ensureListRegistered(index, { filters: filter, sort: SlidingSyncSortToFilter[sort], }).then(() => { @@ -350,9 +350,18 @@ export class SlidingRoomListStoreClass extends AsyncStoreWithClient impl const oldSpace = filters.spaces?.[0]; filters.spaces = (activeSpace && activeSpace != MetaSpace.Home) ? [activeSpace] : undefined; if (oldSpace !== activeSpace) { + // include subspaces in this list + this.context.spaceStore.traverseSpace(activeSpace, (roomId: string) => { + if (roomId === activeSpace) { + return; + } + filters.spaces.push(roomId); // add subspace + }, false); + this.emit(LISTS_LOADING_EVENT, tagId, true); - SlidingSyncManager.instance.ensureListRegistered( - SlidingSyncManager.instance.getOrAllocateListIndex(tagId), + const index = this.context.slidingSyncManager.getOrAllocateListIndex(tagId); + this.context.slidingSyncManager.ensureListRegistered( + index, { filters: filters, }, diff --git a/src/stores/room-list/algorithms/tag-sorting/RecentAlgorithm.ts b/src/stores/room-list/algorithms/tag-sorting/RecentAlgorithm.ts index 9466a35940a..1066ee38a1a 100644 --- a/src/stores/room-list/algorithms/tag-sorting/RecentAlgorithm.ts +++ b/src/stores/room-list/algorithms/tag-sorting/RecentAlgorithm.ts @@ -73,7 +73,7 @@ export const sortRooms = (rooms: Room[]): Room[] => { }; const getLastTs = (r: Room, userId: string) => { - const ts = (() => { + const mainTimelineLastTs = (() => { // Apparently we can have rooms without timelines, at least under testing // environments. Just return MAX_INT when this happens. if (!r?.timeline) { @@ -108,7 +108,13 @@ const getLastTs = (r: Room, userId: string) => { // This is better than just assuming the last event was forever ago. return r.timeline[0]?.getTs() ?? Number.MAX_SAFE_INTEGER; })(); - return ts; + + const threadLastEventTimestamps = r.getThreads().map(thread => { + const event = thread.replyToEvent ?? thread.rootEvent; + return event?.getTs() ?? 0; + }); + + return Math.max(mainTimelineLastTs, ...threadLastEventTimestamps); }; /** diff --git a/src/stores/spaces/SpaceStore.ts b/src/stores/spaces/SpaceStore.ts index fc03cefc248..e8a8626934f 100644 --- a/src/stores/spaces/SpaceStore.ts +++ b/src/stores/spaces/SpaceStore.ts @@ -34,7 +34,6 @@ import { RoomNotificationStateStore } from "../notifications/RoomNotificationSta import { DefaultTagID } from "../room-list/models"; import { EnhancedMap, mapDiff } from "../../utils/maps"; import { setDiff, setHasDiff } from "../../utils/sets"; -import { RoomViewStore } from "../RoomViewStore"; import { Action } from "../../dispatcher/actions"; import { arrayHasDiff, arrayHasOrderChange } from "../../utils/arrays"; import { reorderLexicographically } from "../../utils/stringOrderField"; @@ -64,6 +63,7 @@ import { ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload"; import { ViewHomePagePayload } from "../../dispatcher/payloads/ViewHomePagePayload"; import { SwitchSpacePayload } from "../../dispatcher/payloads/SwitchSpacePayload"; import { AfterLeaveRoomPayload } from "../../dispatcher/payloads/AfterLeaveRoomPayload"; +import { SdkContextClass } from "../../contexts/SDKContext"; interface IState { } @@ -806,7 +806,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { this.updateNotificationStates(notificationStatesToUpdate); }; - private switchSpaceIfNeeded = (roomId = RoomViewStore.instance.getRoomId()) => { + private switchSpaceIfNeeded = (roomId = SdkContextClass.instance.roomViewStore.getRoomId()) => { if (!this.isRoomInSpace(this.activeSpace, roomId) && !this.matrixClient.getRoom(roomId)?.isSpaceRoom()) { this.switchToRelatedSpace(roomId); } @@ -857,7 +857,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { } // if the room currently being viewed was just joined then switch to its related space - if (newMembership === "join" && room.roomId === RoomViewStore.instance.getRoomId()) { + if (newMembership === "join" && room.roomId === SdkContextClass.instance.roomViewStore.getRoomId()) { this.switchSpaceIfNeeded(room.roomId); } } @@ -884,7 +884,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { this.emit(room.roomId); } - if (membership === "join" && room.roomId === RoomViewStore.instance.getRoomId()) { + if (membership === "join" && room.roomId === SdkContextClass.instance.roomViewStore.getRoomId()) { // if the user was looking at the space and then joined: select that space this.setActiveSpace(room.roomId, false); } else if (membership === "leave" && room.roomId === this.activeSpace) { diff --git a/src/stores/widgets/ElementWidgetActions.ts b/src/stores/widgets/ElementWidgetActions.ts index 5e9451efa03..1d0437a2cea 100644 --- a/src/stores/widgets/ElementWidgetActions.ts +++ b/src/stores/widgets/ElementWidgetActions.ts @@ -26,6 +26,20 @@ export enum ElementWidgetActions { MuteVideo = "io.element.mute_video", UnmuteVideo = "io.element.unmute_video", StartLiveStream = "im.vector.start_live_stream", + + // Element Call -> host requesting to start a screenshare + // (ie. expects a ScreenshareStart once the user has picked a source) + // replies with { pending } where pending is true if the host has asked + // the user to choose a window and false if not (ie. if the host isn't + // running within Electron) + ScreenshareRequest = "io.element.screenshare_request", + // host -> Element Call telling EC to start screen sharing with + // the given source + ScreenshareStart = "io.element.screenshare_start", + // host -> Element Call telling EC to stop screen sharing, or that + // the user cancelled when selecting a source after a ScreenshareRequest + ScreenshareStop = "io.element.screenshare_stop", + // Actions for switching layouts TileLayout = "io.element.tile_layout", SpotlightLayout = "io.element.spotlight_layout", diff --git a/src/stores/widgets/StopGapWidget.ts b/src/stores/widgets/StopGapWidget.ts index 9416caa93e2..f0c6a22d982 100644 --- a/src/stores/widgets/StopGapWidget.ts +++ b/src/stores/widgets/StopGapWidget.ts @@ -18,6 +18,7 @@ import { Room } from "matrix-js-sdk/src/models/room"; import { ClientWidgetApi, IModalWidgetOpenRequest, + IRoomEvent, IStickerActionRequest, IStickyActionRequest, ITemplateParams, @@ -41,7 +42,6 @@ import { ClientEvent } from "matrix-js-sdk/src/client"; import { _t } from "../../languageHandler"; import { StopGapWidgetDriver } from "./StopGapWidgetDriver"; import { WidgetMessagingStore } from "./WidgetMessagingStore"; -import { RoomViewStore } from "../RoomViewStore"; import { MatrixClientPeg } from "../../MatrixClientPeg"; import { OwnProfileStore } from "../OwnProfileStore"; import WidgetUtils from '../../utils/WidgetUtils'; @@ -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 { SdkContextClass } from "../../contexts/SDKContext"; import { VoiceBroadcastRecordingsStore } from "../../voice-broadcast"; // TODO: Destroy all of this code @@ -171,7 +172,7 @@ export class StopGapWidget extends EventEmitter { if (this.roomId) return this.roomId; - return RoomViewStore.instance.getRoomId(); + return SdkContextClass.instance.roomViewStore.getRoomId(); } public get widgetApi(): ClientWidgetApi { @@ -367,7 +368,7 @@ export class StopGapWidget extends EventEmitter { // noinspection JSIgnoredPromiseFromCall IntegrationManagers.sharedInstance().getPrimaryManager().open( - this.client.getRoom(RoomViewStore.instance.getRoomId()), + this.client.getRoom(SdkContextClass.instance.roomViewStore.getRoomId()), `type_${integType}`, integId, ); @@ -451,7 +452,7 @@ export class StopGapWidget extends EventEmitter { private onToDeviceEvent = async (ev: MatrixEvent) => { await this.client.decryptEventIfNeeded(ev); if (ev.isDecryptionFailure()) return; - await this.messaging.feedToDevice(ev.getEffectiveEvent(), ev.isEncrypted()); + await this.messaging.feedToDevice(ev.getEffectiveEvent() as IRoomEvent, ev.isEncrypted()); }; private feedEvent(ev: MatrixEvent) { @@ -495,7 +496,7 @@ export class StopGapWidget extends EventEmitter { this.readUpToMap[ev.getRoomId()] = ev.getId(); const raw = ev.getEffectiveEvent(); - this.messaging.feedEvent(raw, this.eventListenerRoomId).catch(e => { + this.messaging.feedEvent(raw as IRoomEvent, this.eventListenerRoomId).catch(e => { logger.error("Error sending event to widget: ", e); }); } diff --git a/src/stores/widgets/StopGapWidgetDriver.ts b/src/stores/widgets/StopGapWidgetDriver.ts index 752d6d57e6f..7bc85e02df3 100644 --- a/src/stores/widgets/StopGapWidgetDriver.ts +++ b/src/stores/widgets/StopGapWidgetDriver.ts @@ -34,7 +34,7 @@ import { } from "matrix-widget-api"; import { ClientEvent, ITurnServer as IClientTurnServer } from "matrix-js-sdk/src/client"; import { EventType } from "matrix-js-sdk/src/@types/event"; -import { IContent, IEvent, MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { IContent, MatrixEvent } from "matrix-js-sdk/src/models/event"; import { Room } from "matrix-js-sdk/src/models/room"; import { logger } from "matrix-js-sdk/src/logger"; import { THREAD_RELATION_TYPE } from "matrix-js-sdk/src/models/thread"; @@ -47,15 +47,15 @@ import Modal from "../../Modal"; import WidgetOpenIDPermissionsDialog from "../../components/views/dialogs/WidgetOpenIDPermissionsDialog"; import WidgetCapabilitiesPromptDialog from "../../components/views/dialogs/WidgetCapabilitiesPromptDialog"; import { WidgetPermissionCustomisations } from "../../customisations/WidgetPermissions"; -import { OIDCState, WidgetPermissionStore } from "./WidgetPermissionStore"; +import { OIDCState } from "./WidgetPermissionStore"; import { WidgetType } from "../../widgets/WidgetType"; import { CHAT_EFFECTS } from "../../effects"; import { containsEmoji } from "../../effects/utils"; import dis from "../../dispatcher/dispatcher"; import SettingsStore from "../../settings/SettingsStore"; -import { RoomViewStore } from "../RoomViewStore"; import { ElementWidgetCapabilities } from "./ElementWidgetCapabilities"; import { navigateToPermalink } from "../../utils/permalinks/navigator"; +import { SdkContextClass } from "../../contexts/SDKContext"; // TODO: Purge this from the universe @@ -113,6 +113,12 @@ export class StopGapWidgetDriver extends WidgetDriver { this.allowedCapabilities.add(MatrixCapabilities.MSC3846TurnServers); this.allowedCapabilities.add(`org.matrix.msc2762.timeline:${inRoomId}`); + this.allowedCapabilities.add( + WidgetEventCapability.forRoomEvent(EventDirection.Send, "org.matrix.rageshake_request").raw, + ); + this.allowedCapabilities.add( + WidgetEventCapability.forRoomEvent(EventDirection.Receive, "org.matrix.rageshake_request").raw, + ); this.allowedCapabilities.add( WidgetEventCapability.forStateEvent(EventDirection.Receive, EventType.RoomMember).raw, ); @@ -210,7 +216,7 @@ export class StopGapWidgetDriver extends WidgetDriver { targetRoomId: string = null, ): Promise { const client = MatrixClientPeg.get(); - const roomId = targetRoomId || RoomViewStore.instance.getRoomId(); + const roomId = targetRoomId || SdkContextClass.instance.roomViewStore.getRoomId(); if (!client || !roomId) throw new Error("Not in a room or not attached to a client"); @@ -291,7 +297,7 @@ export class StopGapWidgetDriver extends WidgetDriver { const targetRooms = roomIds ? (roomIds.includes(Symbols.AnyRoom) ? client.getVisibleRooms() : roomIds.map(r => client.getRoom(r))) - : [client.getRoom(RoomViewStore.instance.getRoomId())]; + : [client.getRoom(SdkContextClass.instance.roomViewStore.getRoomId())]; return targetRooms.filter(r => !!r); } @@ -304,7 +310,7 @@ export class StopGapWidgetDriver extends WidgetDriver { limitPerRoom = limitPerRoom > 0 ? Math.min(limitPerRoom, Number.MAX_SAFE_INTEGER) : Number.MAX_SAFE_INTEGER; // relatively arbitrary const rooms = this.pickRooms(roomIds); - const allResults: IEvent[] = []; + const allResults: IRoomEvent[] = []; for (const room of rooms) { const results: MatrixEvent[] = []; const events = room.getLiveTimeline().getEvents(); // timelines are most recent last @@ -317,7 +323,7 @@ export class StopGapWidgetDriver extends WidgetDriver { results.push(ev); } - results.forEach(e => allResults.push(e.getEffectiveEvent())); + results.forEach(e => allResults.push(e.getEffectiveEvent() as IRoomEvent)); } return allResults; } @@ -331,7 +337,7 @@ export class StopGapWidgetDriver extends WidgetDriver { limitPerRoom = limitPerRoom > 0 ? Math.min(limitPerRoom, Number.MAX_SAFE_INTEGER) : Number.MAX_SAFE_INTEGER; // relatively arbitrary const rooms = this.pickRooms(roomIds); - const allResults: IEvent[] = []; + const allResults: IRoomEvent[] = []; for (const room of rooms) { const results: MatrixEvent[] = []; const state: Map = room.currentState.events.get(eventType); @@ -344,13 +350,13 @@ export class StopGapWidgetDriver extends WidgetDriver { } } - results.slice(0, limitPerRoom).forEach(e => allResults.push(e.getEffectiveEvent())); + results.slice(0, limitPerRoom).forEach(e => allResults.push(e.getEffectiveEvent() as IRoomEvent)); } return allResults; } public async askOpenID(observer: SimpleObservable) { - const oidcState = WidgetPermissionStore.instance.getOIDCState( + const oidcState = SdkContextClass.instance.widgetPermissionStore.getOIDCState( this.forWidget, this.forWidgetKind, this.inRoomId, ); @@ -430,7 +436,7 @@ export class StopGapWidgetDriver extends WidgetDriver { ): Promise { const client = MatrixClientPeg.get(); const dir = direction as Direction; - roomId = roomId ?? RoomViewStore.instance.getRoomId() ?? undefined; + roomId = roomId ?? SdkContextClass.instance.roomViewStore.getRoomId() ?? undefined; if (typeof roomId !== "string") { throw new Error('Error while reading the current room'); @@ -445,15 +451,11 @@ export class StopGapWidgetDriver extends WidgetDriver { eventId, relationType ?? null, eventType ?? null, - { - from, - to, - limit, - dir, - }); + { from, to, limit, dir }, + ); return { - chunk: events.map(e => e.getEffectiveEvent()), + chunk: events.map(e => e.getEffectiveEvent() as IRoomEvent), nextBatch, prevBatch, }; diff --git a/src/stores/widgets/WidgetPermissionStore.ts b/src/stores/widgets/WidgetPermissionStore.ts index 246492333c3..fca018ca5c7 100644 --- a/src/stores/widgets/WidgetPermissionStore.ts +++ b/src/stores/widgets/WidgetPermissionStore.ts @@ -17,8 +17,8 @@ import { Widget, WidgetKind } from "matrix-widget-api"; import SettingsStore from "../../settings/SettingsStore"; -import { MatrixClientPeg } from "../../MatrixClientPeg"; import { SettingLevel } from "../../settings/SettingLevel"; +import { SdkContextClass } from "../../contexts/SDKContext"; export enum OIDCState { Allowed, // user has set the remembered value as allowed @@ -27,16 +27,7 @@ export enum OIDCState { } export class WidgetPermissionStore { - private static internalInstance: WidgetPermissionStore; - - private constructor() { - } - - public static get instance(): WidgetPermissionStore { - if (!WidgetPermissionStore.internalInstance) { - WidgetPermissionStore.internalInstance = new WidgetPermissionStore(); - } - return WidgetPermissionStore.internalInstance; + public constructor(private readonly context: SdkContextClass) { } // TODO (all functions here): Merge widgetKind with the widget definition @@ -44,7 +35,7 @@ export class WidgetPermissionStore { private packSettingKey(widget: Widget, kind: WidgetKind, roomId?: string): string { let location = roomId; if (kind !== WidgetKind.Room) { - location = MatrixClientPeg.get().getUserId(); + location = this.context.client?.getUserId(); } if (kind === WidgetKind.Modal) { location = '*MODAL*-' + location; // to guarantee differentiation from whatever spawned it @@ -71,7 +62,10 @@ export class WidgetPermissionStore { public setOIDCState(widget: Widget, kind: WidgetKind, roomId: string, newState: OIDCState) { const settingsKey = this.packSettingKey(widget, kind, roomId); - const currentValues = SettingsStore.getValue("widgetOpenIDPermissions"); + let currentValues = SettingsStore.getValue("widgetOpenIDPermissions"); + if (!currentValues) { + currentValues = {}; + } if (!currentValues.allow) currentValues.allow = []; if (!currentValues.deny) currentValues.deny = []; diff --git a/src/utils/DialogOpener.ts b/src/utils/DialogOpener.ts index 0e5a3d2b11a..82d16962b26 100644 --- a/src/utils/DialogOpener.ts +++ b/src/utils/DialogOpener.ts @@ -20,7 +20,6 @@ import defaultDispatcher from "../dispatcher/dispatcher"; import { ActionPayload } from "../dispatcher/payloads"; import Modal from "../Modal"; import RoomSettingsDialog from "../components/views/dialogs/RoomSettingsDialog"; -import { RoomViewStore } from "../stores/RoomViewStore"; import ForwardDialog from "../components/views/dialogs/ForwardDialog"; import { MatrixClientPeg } from "../MatrixClientPeg"; import { Action } from "../dispatcher/actions"; @@ -32,6 +31,7 @@ import AddExistingToSpaceDialog from "../components/views/dialogs/AddExistingToS import { ButtonEvent } from "../components/views/elements/AccessibleButton"; import PosthogTrackers from "../PosthogTrackers"; import { showAddExistingSubspace, showCreateNewRoom } from "./space"; +import { SdkContextClass } from "../contexts/SDKContext"; /** * Auxiliary class to listen for dialog opening over the dispatcher and @@ -58,7 +58,7 @@ export class DialogOpener { switch (payload.action) { case 'open_room_settings': Modal.createDialog(RoomSettingsDialog, { - roomId: payload.room_id || RoomViewStore.instance.getRoomId(), + roomId: payload.room_id || SdkContextClass.instance.roomViewStore.getRoomId(), initialTabId: payload.initial_tab_id, }, /*className=*/null, /*isPriority=*/false, /*isStatic=*/true); break; @@ -108,7 +108,7 @@ export class DialogOpener { onAddSubspaceClick: () => showAddExistingSubspace(space), space, onFinished: (added: boolean) => { - if (added && RoomViewStore.instance.getRoomId() === space.roomId) { + if (added && SdkContextClass.instance.roomViewStore.getRoomId() === space.roomId) { defaultDispatcher.fire(Action.UpdateSpaceHierarchy); } }, diff --git a/src/utils/ErrorUtils.tsx b/src/utils/ErrorUtils.tsx index 6af61aca2e1..ff78fe076c4 100644 --- a/src/utils/ErrorUtils.tsx +++ b/src/utils/ErrorUtils.tsx @@ -57,8 +57,8 @@ export function messageForResourceLimitError( } } -export function messageForSyncError(err: MatrixError): ReactNode { - if (err.errcode === 'M_RESOURCE_LIMIT_EXCEEDED') { +export function messageForSyncError(err: Error): ReactNode { + if (err instanceof MatrixError && err.errcode === 'M_RESOURCE_LIMIT_EXCEEDED') { const limitError = messageForResourceLimitError( err.data.limit_type, err.data.admin_contact, diff --git a/src/utils/EventUtils.ts b/src/utils/EventUtils.ts index d8cf66e5573..69e322ac6dc 100644 --- a/src/utils/EventUtils.ts +++ b/src/utils/EventUtils.ts @@ -238,8 +238,11 @@ export async function fetchInitialEvent( ) { const threadId = initialEvent.threadRootId; const room = client.getRoom(roomId); + const mapper = client.getEventMapper(); + const rootEvent = room.findEventById(threadId) + ?? mapper(await client.fetchRoomEvent(roomId, threadId)); try { - room.createThread(threadId, room.findEventById(threadId), [initialEvent], true); + room.createThread(threadId, rootEvent, [initialEvent], true); } catch (e) { logger.warn("Could not find root event: " + threadId); } diff --git a/src/utils/UserInteractiveAuth.ts b/src/utils/UserInteractiveAuth.ts new file mode 100644 index 00000000000..e3088fb3cb4 --- /dev/null +++ b/src/utils/UserInteractiveAuth.ts @@ -0,0 +1,55 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { IAuthData } from "matrix-js-sdk/src/interactive-auth"; +import { UIAResponse } from "matrix-js-sdk/src/@types/uia"; + +import Modal from "../Modal"; +import InteractiveAuthDialog, { InteractiveAuthDialogProps } from "../components/views/dialogs/InteractiveAuthDialog"; + +type FunctionWithUIA = (auth?: IAuthData, ...args: A[]) => Promise>; + +export function wrapRequestWithDialog( + requestFunction: FunctionWithUIA, + opts: Omit, +): ((...args: A[]) => Promise) { + return async function(...args): Promise { + return new Promise((resolve, reject) => { + const boundFunction = requestFunction.bind(opts.matrixClient) as FunctionWithUIA; + boundFunction(undefined, ...args) + .then((res) => resolve(res as R)) + .catch(error => { + if (error.httpStatus !== 401 || !error.data?.flows) { + // doesn't look like an interactive-auth failure + return reject(error); + } + + Modal.createDialog(InteractiveAuthDialog, { + ...opts, + authData: error.data, + makeRequest: (authData) => boundFunction(authData, ...args), + onFinished: (success, result) => { + if (success) { + resolve(result); + } else { + reject(result); + } + }, + }); + }); + }); + }; +} diff --git a/src/utils/leave-behaviour.ts b/src/utils/leave-behaviour.ts index a12cd70ebff..83054ce1b49 100644 --- a/src/utils/leave-behaviour.ts +++ b/src/utils/leave-behaviour.ts @@ -27,7 +27,6 @@ import { _t } from "../languageHandler"; import ErrorDialog from "../components/views/dialogs/ErrorDialog"; import { isMetaSpace } from "../stores/spaces"; import SpaceStore from "../stores/spaces/SpaceStore"; -import { RoomViewStore } from "../stores/RoomViewStore"; import dis from "../dispatcher/dispatcher"; import { ViewRoomPayload } from "../dispatcher/payloads/ViewRoomPayload"; import { Action } from "../dispatcher/actions"; @@ -35,6 +34,7 @@ import { ViewHomePagePayload } from "../dispatcher/payloads/ViewHomePagePayload" import LeaveSpaceDialog from "../components/views/dialogs/LeaveSpaceDialog"; import { AfterLeaveRoomPayload } from "../dispatcher/payloads/AfterLeaveRoomPayload"; import { bulkSpaceBehaviour } from "./space"; +import { SdkContextClass } from "../contexts/SDKContext"; export async function leaveRoomBehaviour(roomId: string, retry = true, spinner = true) { let spinnerModal: IHandle; @@ -130,7 +130,7 @@ export async function leaveRoomBehaviour(roomId: string, retry = true, spinner = if (!isMetaSpace(SpaceStore.instance.activeSpace) && SpaceStore.instance.activeSpace !== roomId && - RoomViewStore.instance.getRoomId() === roomId + SdkContextClass.instance.roomViewStore.getRoomId() === roomId ) { dis.dispatch({ action: Action.ViewRoom, diff --git a/src/utils/room/htmlToPlaintext.ts b/src/utils/room/htmlToPlaintext.ts new file mode 100644 index 00000000000..883db8d360d --- /dev/null +++ b/src/utils/room/htmlToPlaintext.ts @@ -0,0 +1,19 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +export function htmlToPlainText(html: string) { + return new DOMParser().parseFromString(html, 'text/html').documentElement.textContent; +} diff --git a/src/utils/space.tsx b/src/utils/space.tsx index 9e05f0444ba..1e30b7235aa 100644 --- a/src/utils/space.tsx +++ b/src/utils/space.tsx @@ -30,7 +30,6 @@ import { showRoomInviteDialog } from "../RoomInvite"; import CreateSubspaceDialog from "../components/views/dialogs/CreateSubspaceDialog"; import AddExistingSubspaceDialog from "../components/views/dialogs/AddExistingSubspaceDialog"; import defaultDispatcher from "../dispatcher/dispatcher"; -import { RoomViewStore } from "../stores/RoomViewStore"; import { Action } from "../dispatcher/actions"; import Spinner from "../components/views/elements/Spinner"; import { shouldShowComponent } from "../customisations/helpers/UIComponents"; @@ -38,6 +37,7 @@ import { UIComponent } from "../settings/UIFeature"; import { OpenSpacePreferencesPayload, SpacePreferenceTab } from "../dispatcher/payloads/OpenSpacePreferencesPayload"; import { OpenSpaceSettingsPayload } from "../dispatcher/payloads/OpenSpaceSettingsPayload"; import { OpenAddExistingToSpaceDialogPayload } from "../dispatcher/payloads/OpenAddExistingToSpaceDialogPayload"; +import { SdkContextClass } from "../contexts/SDKContext"; export const shouldShowSpaceSettings = (space: Room) => { const userId = space.client.getUserId(); @@ -113,7 +113,7 @@ export const showAddExistingSubspace = (space: Room): void => { space, onCreateSubspaceClick: () => showCreateNewSubspace(space), onFinished: (added: boolean) => { - if (added && RoomViewStore.instance.getRoomId() === space.roomId) { + if (added && SdkContextClass.instance.roomViewStore.getRoomId() === space.roomId) { defaultDispatcher.fire(Action.UpdateSpaceHierarchy); } }, @@ -125,7 +125,7 @@ export const showCreateNewSubspace = (space: Room): void => { space, onAddExistingSpaceClick: () => showAddExistingSubspace(space), onFinished: (added: boolean) => { - if (added && RoomViewStore.instance.getRoomId() === space.roomId) { + if (added && SdkContextClass.instance.roomViewStore.getRoomId() === space.roomId) { defaultDispatcher.fire(Action.UpdateSpaceHierarchy); } }, diff --git a/src/voice-broadcast/audio/VoiceBroadcastRecorder.ts b/src/voice-broadcast/audio/VoiceBroadcastRecorder.ts index 7f084f3f4ae..df7ae362d9d 100644 --- a/src/voice-broadcast/audio/VoiceBroadcastRecorder.ts +++ b/src/voice-broadcast/audio/VoiceBroadcastRecorder.ts @@ -17,10 +17,11 @@ limitations under the License. import { Optional } from "matrix-events-sdk"; import { TypedEventEmitter } from "matrix-js-sdk/src/models/typed-event-emitter"; +import { getChunkLength } from ".."; import { VoiceRecording } from "../../audio/VoiceRecording"; -import SdkConfig, { DEFAULTS } from "../../SdkConfig"; import { concat } from "../../utils/arrays"; import { IDestroyable } from "../../utils/IDestroyable"; +import { Singleflight } from "../../utils/Singleflight"; export enum VoiceBroadcastRecorderEvent { ChunkRecorded = "chunk_recorded", @@ -65,6 +66,8 @@ export class VoiceBroadcastRecorder */ public async stop(): Promise> { await this.voiceRecording.stop(); + // forget about that call, so that we can stop it again later + Singleflight.forgetAllFor(this.voiceRecording); return this.extractChunk(); } @@ -136,6 +139,7 @@ export class VoiceBroadcastRecorder } export const createVoiceBroadcastRecorder = (): VoiceBroadcastRecorder => { - const targetChunkLength = SdkConfig.get("voice_broadcast")?.chunk_length || DEFAULTS.voice_broadcast!.chunk_length; - return new VoiceBroadcastRecorder(new VoiceRecording(), targetChunkLength); + const voiceRecording = new VoiceRecording(); + voiceRecording.disableMaxLength(); + return new VoiceBroadcastRecorder(voiceRecording, getChunkLength()); }; diff --git a/src/voice-broadcast/components/VoiceBroadcastBody.tsx b/src/voice-broadcast/components/VoiceBroadcastBody.tsx index b05c6c894b9..95bc9fde065 100644 --- a/src/voice-broadcast/components/VoiceBroadcastBody.tsx +++ b/src/voice-broadcast/components/VoiceBroadcastBody.tsx @@ -49,6 +49,7 @@ export const VoiceBroadcastBody: React.FC = ({ mxEvent }) => { client, ); relationsHelper.on(RelationsHelperEvent.Add, onInfoEvent); + relationsHelper.emitCurrent(); return () => { relationsHelper.destroy(); diff --git a/src/voice-broadcast/components/atoms/LiveBadge.tsx b/src/voice-broadcast/components/atoms/LiveBadge.tsx index cd2a16e797d..ba94aa14a99 100644 --- a/src/voice-broadcast/components/atoms/LiveBadge.tsx +++ b/src/voice-broadcast/components/atoms/LiveBadge.tsx @@ -16,12 +16,12 @@ limitations under the License. import React from "react"; -import { Icon, IconColour, IconType } from "../../../components/atoms/Icon"; +import { Icon as LiveIcon } from "../../../../res/img/element-icons/live.svg"; 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 deleted file mode 100644 index b67e6b3e24f..00000000000 --- a/src/voice-broadcast/components/atoms/PlaybackControlButton.tsx +++ /dev/null @@ -1,53 +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 { 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/VoiceBroadcastControl.tsx similarity index 68% rename from src/voice-broadcast/components/atoms/StopButton.tsx rename to src/voice-broadcast/components/atoms/VoiceBroadcastControl.tsx index 50abb209d05..276282d1982 100644 --- a/src/voice-broadcast/components/atoms/StopButton.tsx +++ b/src/voice-broadcast/components/atoms/VoiceBroadcastControl.tsx @@ -14,27 +14,29 @@ See the License for the specific language governing permissions and limitations under the License. */ +import classNames from "classnames"; 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 { + className?: string; + icon: React.FC>; + label: string; onClick: () => void; } -export const StopButton: React.FC = ({ +export const VoiceBroadcastControl: React.FC = ({ + className = "", + icon: Icon, + label, onClick, }) => { return - + ; }; diff --git a/src/voice-broadcast/components/atoms/VoiceBroadcastHeader.tsx b/src/voice-broadcast/components/atoms/VoiceBroadcastHeader.tsx index 5abc4d21e41..c83e8e8a0c5 100644 --- a/src/voice-broadcast/components/atoms/VoiceBroadcastHeader.tsx +++ b/src/voice-broadcast/components/atoms/VoiceBroadcastHeader.tsx @@ -15,7 +15,8 @@ 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 { Icon as LiveIcon } from "../../../../res/img/element-icons/live.svg"; +import { Icon as MicrophoneIcon } from "../../../../res/img/voip/call-view/mic-on.svg"; import { _t } from "../../../languageHandler"; import RoomAvatar from "../../../components/views/avatars/RoomAvatar"; @@ -34,7 +35,7 @@ export const VoiceBroadcastHeader: React.FC = ({ }) => { const broadcast = showBroadcast ?
- + { _t("Voice broadcast") }
: null; @@ -46,7 +47,7 @@ export const VoiceBroadcastHeader: React.FC = ({ { room.name }
- + { sender.name }
{ broadcast } diff --git a/src/voice-broadcast/components/molecules/VoiceBroadcastPlaybackBody.tsx b/src/voice-broadcast/components/molecules/VoiceBroadcastPlaybackBody.tsx index 035b3ce6e57..1d6b89dca9f 100644 --- a/src/voice-broadcast/components/molecules/VoiceBroadcastPlaybackBody.tsx +++ b/src/voice-broadcast/components/molecules/VoiceBroadcastPlaybackBody.tsx @@ -17,13 +17,17 @@ limitations under the License. import React from "react"; import { - PlaybackControlButton, + VoiceBroadcastControl, VoiceBroadcastHeader, VoiceBroadcastPlayback, VoiceBroadcastPlaybackState, } from "../.."; import Spinner from "../../../components/views/elements/Spinner"; import { useVoiceBroadcastPlayback } from "../../hooks/useVoiceBroadcastPlayback"; +import { Icon as PlayIcon } from "../../../../res/img/element-icons/play.svg"; +import { Icon as PauseIcon } from "../../../../res/img/element-icons/pause.svg"; +import { _t } from "../../../languageHandler"; +import Clock from "../../../components/views/audio_messages/Clock"; interface VoiceBroadcastPlaybackBodyProps { playback: VoiceBroadcastPlayback; @@ -33,6 +37,7 @@ export const VoiceBroadcastPlaybackBody: React.FC { const { + length, live, room, sender, @@ -40,21 +45,52 @@ export const VoiceBroadcastPlaybackBody: React.FC - : ; + let control: React.ReactNode; + + if (playbackState === VoiceBroadcastPlaybackState.Buffering) { + control = ; + } else { + let controlIcon: React.FC>; + let controlLabel: string; + + switch (playbackState) { + case VoiceBroadcastPlaybackState.Stopped: + controlIcon = PlayIcon; + controlLabel = _t("play voice broadcast"); + break; + case VoiceBroadcastPlaybackState.Paused: + controlIcon = PlayIcon; + controlLabel = _t("resume voice broadcast"); + break; + case VoiceBroadcastPlaybackState.Playing: + controlIcon = PauseIcon; + controlLabel = _t("pause voice broadcast"); + break; + } + + control = ; + } + + const lengthSeconds = Math.round(length / 1000); return ( -
+
-
+
{ control }
+
+ +
); }; diff --git a/src/voice-broadcast/components/molecules/VoiceBroadcastRecordingBody.tsx b/src/voice-broadcast/components/molecules/VoiceBroadcastRecordingBody.tsx index b9721170eb0..1b13377da9d 100644 --- a/src/voice-broadcast/components/molecules/VoiceBroadcastRecordingBody.tsx +++ b/src/voice-broadcast/components/molecules/VoiceBroadcastRecordingBody.tsx @@ -27,7 +27,7 @@ export const VoiceBroadcastRecordingBody: React.FC +
= ({ recording }) => { const { live, - sender, + recordingState, room, + sender, stopRecording, + toggleRecording, } = useVoiceBroadcastRecording(recording); + const toggleControl = recordingState === VoiceBroadcastInfoState.Paused + ? + : ; + return
-
-
- +
+
+ { toggleControl } +
; }; diff --git a/src/voice-broadcast/hooks/useVoiceBroadcastPlayback.ts b/src/voice-broadcast/hooks/useVoiceBroadcastPlayback.ts index 452035fbb96..7ed2b5682f0 100644 --- a/src/voice-broadcast/hooks/useVoiceBroadcastPlayback.ts +++ b/src/voice-broadcast/hooks/useVoiceBroadcastPlayback.ts @@ -50,7 +50,15 @@ export const useVoiceBroadcastPlayback = (playback: VoiceBroadcastPlayback) => { }, ); + const [length, setLength] = useState(playback.getLength()); + useTypedEventEmitter( + playback, + VoiceBroadcastPlaybackEvent.LengthChanged, + length => setLength(length), + ); + return { + length, live: playbackInfoState !== VoiceBroadcastInfoState.Stopped, room: room, sender: playback.infoEvent.sender, diff --git a/src/voice-broadcast/hooks/useVoiceBroadcastRecording.tsx b/src/voice-broadcast/hooks/useVoiceBroadcastRecording.tsx index c0db5617461..209b539bf60 100644 --- a/src/voice-broadcast/hooks/useVoiceBroadcastRecording.tsx +++ b/src/voice-broadcast/hooks/useVoiceBroadcastRecording.tsx @@ -20,7 +20,6 @@ import { VoiceBroadcastInfoState, VoiceBroadcastRecording, VoiceBroadcastRecordingEvent, - VoiceBroadcastRecordingsStore, } from ".."; import QuestionDialog from "../../components/views/dialogs/QuestionDialog"; import { useTypedEventEmitter } from "../../hooks/useEventEmitter"; @@ -53,24 +52,31 @@ export const useVoiceBroadcastRecording = (recording: VoiceBroadcastRecording) = const confirmed = await showStopBroadcastingDialog(); if (confirmed) { - recording.stop(); - VoiceBroadcastRecordingsStore.instance().clearCurrent(); + await recording.stop(); } }; - const [live, setLive] = useState(recording.getState() === VoiceBroadcastInfoState.Started); + const [recordingState, setRecordingState] = useState(recording.getState()); useTypedEventEmitter( recording, VoiceBroadcastRecordingEvent.StateChanged, (state: VoiceBroadcastInfoState, _recording: VoiceBroadcastRecording) => { - setLive(state === VoiceBroadcastInfoState.Started); + setRecordingState(state); }, ); + const live = [ + VoiceBroadcastInfoState.Started, + VoiceBroadcastInfoState.Paused, + VoiceBroadcastInfoState.Resumed, + ].includes(recordingState); + return { live, + recordingState, room, sender: recording.infoEvent.sender, stopRecording, + toggleRecording: recording.toggle, }; }; diff --git a/src/voice-broadcast/index.ts b/src/voice-broadcast/index.ts index 7262382b0c5..c484f7af261 100644 --- a/src/voice-broadcast/index.ts +++ b/src/voice-broadcast/index.ts @@ -26,8 +26,7 @@ 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/VoiceBroadcastControl"; export * from "./components/atoms/VoiceBroadcastHeader"; export * from "./components/molecules/VoiceBroadcastPlaybackBody"; export * from "./components/molecules/VoiceBroadcastRecordingBody"; @@ -35,9 +34,13 @@ export * from "./components/molecules/VoiceBroadcastRecordingPip"; export * from "./hooks/useVoiceBroadcastRecording"; export * from "./stores/VoiceBroadcastPlaybacksStore"; export * from "./stores/VoiceBroadcastRecordingsStore"; +export * from "./utils/getChunkLength"; +export * from "./utils/hasRoomLiveVoiceBroadcast"; +export * from "./utils/findRoomLiveVoiceBroadcastFromUserAndDevice"; export * from "./utils/shouldDisplayAsVoiceBroadcastRecordingTile"; export * from "./utils/shouldDisplayAsVoiceBroadcastTile"; export * from "./utils/startNewVoiceBroadcastRecording"; +export * from "./utils/VoiceBroadcastResumer"; export const VoiceBroadcastInfoEventType = "io.element.voice_broadcast_info"; export const VoiceBroadcastChunkEventType = "io.element.voice_broadcast_chunk"; @@ -45,7 +48,7 @@ export const VoiceBroadcastChunkEventType = "io.element.voice_broadcast_chunk"; export enum VoiceBroadcastInfoState { Started = "started", Paused = "paused", - Running = "running", + Resumed = "resumed", Stopped = "stopped", } @@ -53,6 +56,7 @@ export interface VoiceBroadcastInfoEventContent { device_id: string; state: VoiceBroadcastInfoState; chunk_length?: number; + last_chunk_sequence?: 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 index 641deb66ad1..a3834a7e799 100644 --- a/src/voice-broadcast/models/VoiceBroadcastPlayback.ts +++ b/src/voice-broadcast/models/VoiceBroadcastPlayback.ts @@ -31,6 +31,7 @@ import { IDestroyable } from "../../utils/IDestroyable"; import { VoiceBroadcastChunkEventType, VoiceBroadcastInfoEventType, VoiceBroadcastInfoState } from ".."; import { RelationsHelper, RelationsHelperEvent } from "../../events/RelationsHelper"; import { getReferenceRelationsForEvent } from "../../events"; +import { VoiceBroadcastChunkEvents } from "../utils/VoiceBroadcastChunkEvents"; export enum VoiceBroadcastPlaybackState { Paused, @@ -47,7 +48,10 @@ export enum VoiceBroadcastPlaybackEvent { interface EventMap { [VoiceBroadcastPlaybackEvent.LengthChanged]: (length: number) => void; - [VoiceBroadcastPlaybackEvent.StateChanged]: (state: VoiceBroadcastPlaybackState) => void; + [VoiceBroadcastPlaybackEvent.StateChanged]: ( + state: VoiceBroadcastPlaybackState, + playback: VoiceBroadcastPlayback + ) => void; [VoiceBroadcastPlaybackEvent.InfoStateChanged]: (state: VoiceBroadcastInfoState) => void; } @@ -56,9 +60,9 @@ export class VoiceBroadcastPlayback implements IDestroyable { private state = VoiceBroadcastPlaybackState.Stopped; private infoState: VoiceBroadcastInfoState; - private chunkEvents = new Map(); - private queue: Playback[] = []; - private currentlyPlaying: Playback; + private chunkEvents = new VoiceBroadcastChunkEvents(); + private playbacks = new Map(); + private currentlyPlaying: MatrixEvent; private lastInfoEvent: MatrixEvent; private chunkRelationHelper: RelationsHelper; private infoRelationHelper: RelationsHelper; @@ -98,11 +102,12 @@ export class VoiceBroadcastPlayback 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); + this.chunkEvents.addEvent(event); + this.emit(VoiceBroadcastPlaybackEvent.LengthChanged, this.chunkEvents.getLength()); if (this.getState() !== VoiceBroadcastPlaybackState.Stopped) { await this.enqueueChunk(event); @@ -140,6 +145,8 @@ export class VoiceBroadcastPlayback return; } + this.chunkEvents.addEvents(chunkEvents); + for (const chunkEvent of chunkEvents) { await this.enqueueChunk(chunkEvent); } @@ -155,7 +162,7 @@ export class VoiceBroadcastPlayback 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 + this.playbacks.set(chunkEvent.getId(), playback); playback.on(UPDATE_EVENT, (state) => this.onPlaybackStateChange(playback, state)); } @@ -164,16 +171,18 @@ export class VoiceBroadcastPlayback return; } - await this.playNext(playback); + await this.playNext(); } - private async playNext(current: Playback): Promise { - const next = this.queue[this.queue.indexOf(current) + 1]; + private async playNext(): Promise { + if (!this.currentlyPlaying) return; + + const next = this.chunkEvents.getNext(this.currentlyPlaying); if (next) { this.setState(VoiceBroadcastPlaybackState.Playing); this.currentlyPlaying = next; - await next.play(); + await this.playbacks.get(next.getId())?.play(); return; } @@ -185,19 +194,25 @@ export class VoiceBroadcastPlayback } } + public getLength(): number { + return this.chunkEvents.getLength(); + } + public async start(): Promise { - if (this.queue.length === 0) { + if (this.playbacks.size === 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 + const chunkEvents = this.chunkEvents.getEvents(); + + const toPlay = this.getInfoState() === VoiceBroadcastInfoState.Stopped + ? chunkEvents[0] // start at the beginning for an ended voice broadcast + : chunkEvents[chunkEvents.length - 1]; // start at the current chunk for an ongoing voice broadcast - if (this.queue[toPlayIndex]) { + if (this.playbacks.has(toPlay?.getId())) { this.setState(VoiceBroadcastPlaybackState.Playing); - this.currentlyPlaying = this.queue[toPlayIndex]; - await this.currentlyPlaying.play(); + this.currentlyPlaying = toPlay; + await this.playbacks.get(toPlay.getId()).play(); return; } @@ -205,29 +220,35 @@ export class VoiceBroadcastPlayback } public get length(): number { - return this.chunkEvents.size; + return this.chunkEvents.getLength(); } public stop(): void { this.setState(VoiceBroadcastPlaybackState.Stopped); if (this.currentlyPlaying) { - this.currentlyPlaying.stop(); + this.playbacks.get(this.currentlyPlaying.getId()).stop(); } } public pause(): void { - if (!this.currentlyPlaying) return; + // stopped voice broadcasts cannot be paused + if (this.getState() === VoiceBroadcastPlaybackState.Stopped) return; this.setState(VoiceBroadcastPlaybackState.Paused); - this.currentlyPlaying.pause(); + if (!this.currentlyPlaying) return; + this.playbacks.get(this.currentlyPlaying.getId()).pause(); } public resume(): void { - if (!this.currentlyPlaying) return; + if (!this.currentlyPlaying) { + // no playback to resume, start from the beginning + this.start(); + return; + } this.setState(VoiceBroadcastPlaybackState.Playing); - this.currentlyPlaying.play(); + this.playbacks.get(this.currentlyPlaying.getId()).play(); } /** @@ -260,7 +281,7 @@ export class VoiceBroadcastPlayback } this.state = state; - this.emit(VoiceBroadcastPlaybackEvent.StateChanged, state); + this.emit(VoiceBroadcastPlaybackEvent.StateChanged, state, this); } public getInfoState(): VoiceBroadcastInfoState { @@ -276,15 +297,13 @@ export class VoiceBroadcastPlayback 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(); + + this.chunkEvents = new VoiceBroadcastChunkEvents(); + this.playbacks.forEach(p => p.destroy()); + this.playbacks = new Map(); } } diff --git a/src/voice-broadcast/models/VoiceBroadcastRecording.ts b/src/voice-broadcast/models/VoiceBroadcastRecording.ts index 96b62a670f3..dbab9fb6b83 100644 --- a/src/voice-broadcast/models/VoiceBroadcastRecording.ts +++ b/src/voice-broadcast/models/VoiceBroadcastRecording.ts @@ -15,12 +15,13 @@ limitations under the License. */ import { logger } from "matrix-js-sdk/src/logger"; -import { MatrixClient, MatrixEvent, RelationType } from "matrix-js-sdk/src/matrix"; +import { MatrixClient, MatrixEvent, MatrixEventEvent, RelationType } from "matrix-js-sdk/src/matrix"; import { TypedEventEmitter } from "matrix-js-sdk/src/models/typed-event-emitter"; import { ChunkRecordedPayload, createVoiceBroadcastRecorder, + VoiceBroadcastInfoEventContent, VoiceBroadcastInfoEventType, VoiceBroadcastInfoState, VoiceBroadcastRecorder, @@ -52,9 +53,23 @@ export class VoiceBroadcastRecording public constructor( public readonly infoEvent: MatrixEvent, private client: MatrixClient, + initialState?: VoiceBroadcastInfoState, ) { super(); + if (initialState) { + this.state = initialState; + } else { + this.setInitialStateFromInfoEvent(); + } + + // TODO Michael W: listen for state updates + // + this.infoEvent.on(MatrixEventEvent.BeforeRedaction, this.onBeforeRedaction); + this.dispatcherRef = dis.register(this.onAction); + } + + private setInitialStateFromInfoEvent(): void { const room = this.client.getRoom(this.infoEvent.getRoomId()); const relations = room?.getUnfilteredTimelineSet()?.relations?.getChildEventsForEvent( this.infoEvent.getId(), @@ -65,9 +80,6 @@ export class VoiceBroadcastRecording 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 { @@ -75,11 +87,38 @@ export class VoiceBroadcastRecording } public async stop(): Promise { + if (this.state === VoiceBroadcastInfoState.Stopped) return; + this.setState(VoiceBroadcastInfoState.Stopped); await this.stopRecorder(); - await this.sendStoppedStateEvent(); + await this.sendInfoStateEvent(VoiceBroadcastInfoState.Stopped); + } + + public async pause(): Promise { + // stopped or already paused recordings cannot be paused + if ([VoiceBroadcastInfoState.Stopped, VoiceBroadcastInfoState.Paused].includes(this.state)) return; + + this.setState(VoiceBroadcastInfoState.Paused); + await this.stopRecorder(); + await this.sendInfoStateEvent(VoiceBroadcastInfoState.Paused); + } + + public async resume(): Promise { + if (this.state !== VoiceBroadcastInfoState.Paused) return; + + this.setState(VoiceBroadcastInfoState.Resumed); + await this.getRecorder().start(); + await this.sendInfoStateEvent(VoiceBroadcastInfoState.Resumed); } + public toggle = async (): Promise => { + if (this.getState() === VoiceBroadcastInfoState.Paused) return this.resume(); + + if ([VoiceBroadcastInfoState.Started, VoiceBroadcastInfoState.Resumed].includes(this.getState())) { + return this.pause(); + } + }; + public getState(): VoiceBroadcastInfoState { return this.state; } @@ -99,10 +138,19 @@ export class VoiceBroadcastRecording this.recorder.stop(); } + this.infoEvent.off(MatrixEventEvent.BeforeRedaction, this.onBeforeRedaction); this.removeAllListeners(); dis.unregister(this.dispatcherRef); } + private onBeforeRedaction = () => { + if (this.getState() !== VoiceBroadcastInfoState.Stopped) { + this.setState(VoiceBroadcastInfoState.Stopped); + // destroy cleans up everything + this.destroy(); + } + }; + private onAction = (payload: ActionPayload) => { if (payload.action !== "call_state") return; @@ -152,19 +200,20 @@ export class VoiceBroadcastRecording await this.client.sendMessage(this.infoEvent.getRoomId(), content); } - private async sendStoppedStateEvent(): Promise { + private async sendInfoStateEvent(state: VoiceBroadcastInfoState): 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, + state, + last_chunk_sequence: this.sequence, ["m.relates_to"]: { rel_type: RelationType.Reference, event_id: this.infoEvent.getId(), }, - }, + } as VoiceBroadcastInfoEventContent, this.client.getUserId(), ); } diff --git a/src/voice-broadcast/stores/VoiceBroadcastPlaybacksStore.ts b/src/voice-broadcast/stores/VoiceBroadcastPlaybacksStore.ts index 38d774e088a..03378d9492a 100644 --- a/src/voice-broadcast/stores/VoiceBroadcastPlaybacksStore.ts +++ b/src/voice-broadcast/stores/VoiceBroadcastPlaybacksStore.ts @@ -17,7 +17,8 @@ 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 ".."; +import { VoiceBroadcastPlayback, VoiceBroadcastPlaybackEvent, VoiceBroadcastPlaybackState } from ".."; +import { IDestroyable } from "../../utils/IDestroyable"; export enum VoiceBroadcastPlaybacksStoreEvent { CurrentChanged = "current_changed", @@ -28,10 +29,16 @@ interface EventMap { } /** - * This store provides access to the current and specific Voice Broadcast playbacks. + * This store manages VoiceBroadcastPlaybacks: + * - access the currently playing voice broadcast + * - ensures that only once broadcast is playing at a time */ -export class VoiceBroadcastPlaybacksStore extends TypedEventEmitter { +export class VoiceBroadcastPlaybacksStore + extends TypedEventEmitter + implements IDestroyable { private current: VoiceBroadcastPlayback | null; + + /** Playbacks indexed by their info event id. */ private playbacks = new Map(); public constructor() { @@ -42,7 +49,7 @@ export class VoiceBroadcastPlaybacksStore extends TypedEventEmitter { + if ([ + VoiceBroadcastPlaybackState.Buffering, + VoiceBroadcastPlaybackState.Playing, + ].includes(state)) { + this.pauseExcept(playback); + } + }; + + private pauseExcept(playbackNotToPause: VoiceBroadcastPlayback): void { + for (const playback of this.playbacks.values()) { + if (playback !== playbackNotToPause) { + playback.pause(); + } + } + } + + public destroy(): void { + this.removeAllListeners(); + + for (const playback of this.playbacks.values()) { + playback.off(VoiceBroadcastPlaybackEvent.StateChanged, this.onPlaybackStateChanged); + } + + this.playbacks = new Map(); + } + public static readonly _instance = new VoiceBroadcastPlaybacksStore(); /** diff --git a/src/voice-broadcast/stores/VoiceBroadcastRecordingsStore.ts b/src/voice-broadcast/stores/VoiceBroadcastRecordingsStore.ts index cc12b474e8d..b5c78a1b0e5 100644 --- a/src/voice-broadcast/stores/VoiceBroadcastRecordingsStore.ts +++ b/src/voice-broadcast/stores/VoiceBroadcastRecordingsStore.ts @@ -17,7 +17,7 @@ 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 ".."; +import { VoiceBroadcastInfoState, VoiceBroadcastRecording, VoiceBroadcastRecordingEvent } from ".."; export enum VoiceBroadcastRecordingsStoreEvent { CurrentChanged = "current_changed", @@ -41,7 +41,12 @@ export class VoiceBroadcastRecordingsStore extends TypedEventEmitter { + if (state === VoiceBroadcastInfoState.Stopped) { + this.clearCurrent(); + } + }; + private static readonly cachedInstance = new VoiceBroadcastRecordingsStore(); /** diff --git a/src/voice-broadcast/utils/VoiceBroadcastChunkEvents.ts b/src/voice-broadcast/utils/VoiceBroadcastChunkEvents.ts new file mode 100644 index 00000000000..ac7e90361d5 --- /dev/null +++ b/src/voice-broadcast/utils/VoiceBroadcastChunkEvents.ts @@ -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 { MatrixEvent } from "matrix-js-sdk/src/matrix"; + +import { VoiceBroadcastChunkEventType } from ".."; + +/** + * Voice broadcast chunk collection. + * Orders chunks by sequence (if available) or timestamp. + */ +export class VoiceBroadcastChunkEvents { + private events: MatrixEvent[] = []; + + public getEvents(): MatrixEvent[] { + return [...this.events]; + } + + public getNext(event: MatrixEvent): MatrixEvent | undefined { + return this.events[this.events.indexOf(event) + 1]; + } + + public addEvent(event: MatrixEvent): void { + if (this.addOrReplaceEvent(event)) { + this.sort(); + } + } + + public addEvents(events: MatrixEvent[]): void { + const atLeastOneNew = events.reduce((newSoFar: boolean, event: MatrixEvent): boolean => { + return this.addOrReplaceEvent(event) || newSoFar; + }, false); + + if (atLeastOneNew) { + this.sort(); + } + } + + public includes(event: MatrixEvent): boolean { + return !!this.events.find(e => e.getId() === event.getId()); + } + + public getLength(): number { + return this.events.reduce((length: number, event: MatrixEvent) => { + return length + this.calculateChunkLength(event); + }, 0); + } + + private calculateChunkLength(event: MatrixEvent): number { + return event.getContent()?.["org.matrix.msc1767.audio"]?.duration + || event.getContent()?.info?.duration + || 0; + } + + private addOrReplaceEvent = (event: MatrixEvent): boolean => { + this.events = this.events.filter(e => e.getId() !== event.getId()); + this.events.push(event); + return true; + }; + + /** + * Sort by sequence, if available for all events. + * Else fall back to timestamp. + */ + private sort(): void { + const compareFn = this.allHaveSequence() ? this.compareBySequence : this.compareByTimestamp; + this.events.sort(compareFn); + } + + private compareBySequence = (a: MatrixEvent, b: MatrixEvent): number => { + const aSequence = a.getContent()?.[VoiceBroadcastChunkEventType]?.sequence || 0; + const bSequence = b.getContent()?.[VoiceBroadcastChunkEventType]?.sequence || 0; + return aSequence - bSequence; + }; + + private compareByTimestamp = (a: MatrixEvent, b: MatrixEvent): number => { + return a.getTs() - b.getTs(); + }; + + private allHaveSequence(): boolean { + return !this.events.some((event: MatrixEvent) => { + const sequence = event.getContent()?.[VoiceBroadcastChunkEventType]?.sequence; + return parseInt(sequence, 10) !== sequence; + }); + } +} diff --git a/src/voice-broadcast/utils/VoiceBroadcastResumer.ts b/src/voice-broadcast/utils/VoiceBroadcastResumer.ts new file mode 100644 index 00000000000..be949d0eabe --- /dev/null +++ b/src/voice-broadcast/utils/VoiceBroadcastResumer.ts @@ -0,0 +1,100 @@ +/* +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 { ClientEvent, MatrixClient, MatrixEvent, RelationType, Room } from "matrix-js-sdk/src/matrix"; +import { SyncState } from "matrix-js-sdk/src/sync"; + +import { VoiceBroadcastInfoEventContent, VoiceBroadcastInfoEventType, VoiceBroadcastInfoState } from ".."; +import { IDestroyable } from "../../utils/IDestroyable"; +import { findRoomLiveVoiceBroadcastFromUserAndDevice } from "./findRoomLiveVoiceBroadcastFromUserAndDevice"; + +/** + * Handles voice broadcasts on app resume (after logging in, reload, crash…). + */ +export class VoiceBroadcastResumer implements IDestroyable { + public constructor( + private client: MatrixClient, + ) { + if (client.isInitialSyncComplete()) { + this.resume(); + } else { + // wait for initial sync + client.on(ClientEvent.Sync, this.onClientSync); + } + } + + private onClientSync = () => { + if (this.client.getSyncState() === SyncState.Syncing) { + this.client.off(ClientEvent.Sync, this.onClientSync); + this.resume(); + } + }; + + private resume(): void { + const userId = this.client.getUserId(); + const deviceId = this.client.getDeviceId(); + + if (!userId || !deviceId) { + // Resuming a voice broadcast only makes sense if there is a user. + return; + } + + this.client.getRooms().forEach((room: Room) => { + const infoEvent = findRoomLiveVoiceBroadcastFromUserAndDevice(room, userId, deviceId); + + if (infoEvent) { + // Found a live broadcast event from current device; stop it. + // Stopping it is a temporary solution (see PSF-1669). + this.sendStopVoiceBroadcastStateEvent(infoEvent); + return false; + } + }); + } + + private sendStopVoiceBroadcastStateEvent(infoEvent: MatrixEvent): void { + const userId = this.client.getUserId(); + const deviceId = this.client.getDeviceId(); + const roomId = infoEvent.getRoomId(); + + if (!userId || !deviceId || !roomId) { + // We can only send a state event if we know all the IDs. + return; + } + + const content: VoiceBroadcastInfoEventContent = { + device_id: deviceId, + state: VoiceBroadcastInfoState.Stopped, + }; + + // all events should reference the started event + const referencedEventId = infoEvent.getContent()?.state === VoiceBroadcastInfoState.Started + ? infoEvent.getId() + : infoEvent.getContent()?.["m.relates_to"]?.event_id; + + if (referencedEventId) { + content["m.relates_to"] = { + rel_type: RelationType.Reference, + event_id: referencedEventId, + }; + } + + this.client.sendStateEvent(roomId, VoiceBroadcastInfoEventType, content, userId); + } + + destroy(): void { + this.client.off(ClientEvent.Sync, this.onClientSync); + } +} diff --git a/src/voice-broadcast/utils/findRoomLiveVoiceBroadcastFromUserAndDevice.ts b/src/voice-broadcast/utils/findRoomLiveVoiceBroadcastFromUserAndDevice.ts new file mode 100644 index 00000000000..61d54a76603 --- /dev/null +++ b/src/voice-broadcast/utils/findRoomLiveVoiceBroadcastFromUserAndDevice.ts @@ -0,0 +1,37 @@ +/* +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, Room } from "matrix-js-sdk/src/matrix"; + +import { VoiceBroadcastInfoEventType, VoiceBroadcastInfoState } from ".."; + +export const findRoomLiveVoiceBroadcastFromUserAndDevice = ( + room: Room, + userId: string, + deviceId: string, +): MatrixEvent | null => { + const stateEvent = room.currentState.getStateEvents(VoiceBroadcastInfoEventType, userId); + + // no broadcast from that user + if (!stateEvent) return null; + + const content = stateEvent.getContent() || {}; + + // stopped broadcast + if (content.state === VoiceBroadcastInfoState.Stopped) return null; + + return content.device_id === deviceId ? stateEvent : null; +}; diff --git a/src/voice-broadcast/utils/getChunkLength.ts b/src/voice-broadcast/utils/getChunkLength.ts new file mode 100644 index 00000000000..9eebfe49791 --- /dev/null +++ b/src/voice-broadcast/utils/getChunkLength.ts @@ -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. +*/ + +import SdkConfig, { DEFAULTS } from "../../SdkConfig"; + +/** + * Returns the target chunk length for voice broadcasts: + * - Tries to get the value from the voice_broadcast.chunk_length config + * - If that fails from DEFAULTS + * - If that fails fall back to 120 (two minutes) + */ +export const getChunkLength = (): number => { + return SdkConfig.get("voice_broadcast")?.chunk_length + || DEFAULTS.voice_broadcast?.chunk_length + || 120; +}; diff --git a/src/voice-broadcast/utils/hasRoomLiveVoiceBroadcast.ts b/src/voice-broadcast/utils/hasRoomLiveVoiceBroadcast.ts new file mode 100644 index 00000000000..577b9ed8805 --- /dev/null +++ b/src/voice-broadcast/utils/hasRoomLiveVoiceBroadcast.ts @@ -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. +*/ + +import { MatrixEvent, Room } from "matrix-js-sdk/src/matrix"; + +import { VoiceBroadcastInfoEventType, VoiceBroadcastInfoState } from ".."; + +interface Result { + hasBroadcast: boolean; + startedByUser: boolean; +} + +/** + * Finds out whether there is a live broadcast in a room. + * Also returns if the user started the broadcast (if any). + */ +export const hasRoomLiveVoiceBroadcast = (room: Room, userId: string): Result => { + let hasBroadcast = false; + let startedByUser = false; + + const stateEvents = room.currentState.getStateEvents(VoiceBroadcastInfoEventType); + stateEvents.forEach((event: MatrixEvent) => { + const state = event.getContent()?.state; + + if (state && state !== VoiceBroadcastInfoState.Stopped) { + hasBroadcast = true; + + // state key = sender's MXID + if (event.getStateKey() === userId) { + startedByUser = true; + // break here, because more than true / true is not possible + return false; + } + } + }); + + return { + hasBroadcast, + startedByUser, + }; +}; diff --git a/src/voice-broadcast/utils/startNewVoiceBroadcastRecording.ts b/src/voice-broadcast/utils/startNewVoiceBroadcastRecording.ts deleted file mode 100644 index 272958e5d0c..00000000000 --- a/src/voice-broadcast/utils/startNewVoiceBroadcastRecording.ts +++ /dev/null @@ -1,76 +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 { 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/src/voice-broadcast/utils/startNewVoiceBroadcastRecording.tsx b/src/voice-broadcast/utils/startNewVoiceBroadcastRecording.tsx new file mode 100644 index 00000000000..ec57ea5312a --- /dev/null +++ b/src/voice-broadcast/utils/startNewVoiceBroadcastRecording.tsx @@ -0,0 +1,142 @@ +/* +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 { ISendEventResponse, MatrixClient, Room, RoomStateEvent } from "matrix-js-sdk/src/matrix"; +import { defer } from "matrix-js-sdk/src/utils"; + +import { _t } from "../../languageHandler"; +import InfoDialog from "../../components/views/dialogs/InfoDialog"; +import Modal from "../../Modal"; +import { + VoiceBroadcastInfoEventContent, + VoiceBroadcastInfoEventType, + VoiceBroadcastInfoState, + VoiceBroadcastRecordingsStore, + VoiceBroadcastRecording, + hasRoomLiveVoiceBroadcast, + getChunkLength, +} from ".."; + +const startBroadcast = async ( + room: Room, + client: MatrixClient, + recordingsStore: VoiceBroadcastRecordingsStore, +): Promise => { + 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( + room.roomId, + VoiceBroadcastInfoEventType, + { + device_id: client.getDeviceId(), + state: VoiceBroadcastInfoState.Started, + chunk_length: getChunkLength(), + } as VoiceBroadcastInfoEventContent, + client.getUserId(), + ); + + return promise; +}; + +const showAlreadyRecordingDialog = () => { + Modal.createDialog(InfoDialog, { + title: _t("Can't start a new voice broadcast"), + description:

{ _t("You are already recording a voice broadcast. " + + "Please end your current voice broadcast to start a new one.") }

, + hasCloseButton: true, + }); +}; + +const showInsufficientPermissionsDialog = () => { + Modal.createDialog(InfoDialog, { + title: _t("Can't start a new voice broadcast"), + description:

{ _t("You don't have the required permissions to start a voice broadcast in this room. " + + "Contact a room administrator to upgrade your permissions.") }

, + hasCloseButton: true, + }); +}; + +const showOthersAlreadyRecordingDialog = () => { + Modal.createDialog(InfoDialog, { + title: _t("Can't start a new voice broadcast"), + description:

{ _t("Someone else is already recording a voice broadcast. " + + "Wait for their voice broadcast to end to start a new one.") }

, + hasCloseButton: true, + }); +}; + +/** + * Starts a new Voice Broadcast Recording, if + * - the user has the permissions to do so in the room + * - there is no other broadcast being recorded in the room, yet + * Sends a voice_broadcast_info state event and waits for the event to actually appear in the room state. + */ +export const startNewVoiceBroadcastRecording = async ( + room: Room, + client: MatrixClient, + recordingsStore: VoiceBroadcastRecordingsStore, +): Promise => { + if (recordingsStore.getCurrent()) { + showAlreadyRecordingDialog(); + return null; + } + + const currentUserId = client.getUserId(); + + if (!room.currentState.maySendStateEvent(VoiceBroadcastInfoEventType, currentUserId)) { + showInsufficientPermissionsDialog(); + return null; + } + + const { hasBroadcast, startedByUser } = hasRoomLiveVoiceBroadcast(room, currentUserId); + + if (hasBroadcast && startedByUser) { + showAlreadyRecordingDialog(); + return null; + } + + if (hasBroadcast) { + showOthersAlreadyRecordingDialog(); + return null; + } + + return startBroadcast(room, client, recordingsStore); +}; diff --git a/test/Avatar-test.ts b/test/Avatar-test.ts index 214ada9486a..0ff064ed57d 100644 --- a/test/Avatar-test.ts +++ b/test/Avatar-test.ts @@ -15,7 +15,7 @@ limitations under the License. */ import { mocked } from "jest-mock"; -import { Room, RoomMember } from "matrix-js-sdk/src/matrix"; +import { Room, RoomMember, RoomType } from "matrix-js-sdk/src/matrix"; import { avatarUrlForRoom } from "../src/Avatar"; import { Media, mediaFromMxc } from "../src/customisations/Media"; @@ -46,6 +46,7 @@ describe("avatarUrlForRoom", () => { roomId, getMxcAvatarUrl: jest.fn(), isSpaceRoom: jest.fn(), + getType: jest.fn(), getAvatarFallbackMember: jest.fn(), } as unknown as Room; dmRoomMap = { @@ -70,6 +71,7 @@ describe("avatarUrlForRoom", () => { it("should return null for a space room", () => { mocked(room.isSpaceRoom).mockReturnValue(true); + mocked(room.getType).mockReturnValue(RoomType.Space); expect(avatarUrlForRoom(room, 128, 128)).toBeNull(); }); diff --git a/test/Notifier-test.ts b/test/Notifier-test.ts index 224c1fec776..4c6100760ee 100644 --- a/test/Notifier-test.ts +++ b/test/Notifier-test.ts @@ -19,6 +19,7 @@ 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 { waitFor } from "@testing-library/react"; import BasePlatform from "../src/BasePlatform"; import { ElementCall } from "../src/models/Call"; @@ -29,8 +30,15 @@ import { createLocalNotificationSettingsIfNeeded, getLocalNotificationAccountDataEventType, } from "../src/utils/notifications"; -import { getMockClientWithEventEmitter, mkEvent, mkRoom, mockClientMethodsUser, mockPlatformPeg } from "./test-utils"; +import { getMockClientWithEventEmitter, mkEvent, mockClientMethodsUser, mockPlatformPeg } from "./test-utils"; import { IncomingCallToast } from "../src/toasts/IncomingCallToast"; +import { SdkContextClass } from "../src/contexts/SDKContext"; +import UserActivity from "../src/UserActivity"; +import Modal from "../src/Modal"; +import { mkThread } from "./test-utils/threads"; +import dis from "../src/dispatcher/dispatcher"; +import { ThreadPayload } from "../src/dispatcher/payloads/ThreadPayload"; +import { Action } from "../src/dispatcher/actions"; jest.mock("../src/utils/notifications", () => ({ // @ts-ignore @@ -50,10 +58,12 @@ describe("Notifier", () => { let MockPlatform: MockedObject; let mockClient: MockedObject; - let testRoom: MockedObject; + let testRoom: Room; let accountDataEventKey: string; let accountDataStore = {}; + let mockSettings: Record = {}; + const userId = "@bob:example.org"; beforeEach(() => { @@ -78,7 +88,7 @@ describe("Notifier", () => { }; accountDataEventKey = getLocalNotificationAccountDataEventType(mockClient.deviceId); - testRoom = mkRoom(mockClient, roomId); + testRoom = new Room(roomId, mockClient, mockClient.getUserId()); MockPlatform = mockPlatformPeg({ supportsNotifications: jest.fn().mockReturnValue(true), @@ -89,7 +99,9 @@ describe("Notifier", () => { Notifier.isBodyEnabled = jest.fn().mockReturnValue(true); - mockClient.getRoom.mockReturnValue(testRoom); + mockClient.getRoom.mockImplementation(id => { + return id === roomId ? testRoom : new Room(id, mockClient, mockClient.getUserId()); + }); }); describe('triggering notification from events', () => { @@ -121,13 +133,14 @@ describe("Notifier", () => { }, }); - const enabledSettings = [ - 'notificationsEnabled', - 'audioNotificationsEnabled', - ]; + mockSettings = { + 'notificationsEnabled': true, + 'audioNotificationsEnabled': true, + }; + // enable notifications by default - jest.spyOn(SettingsStore, "getValue").mockImplementation( - settingName => enabledSettings.includes(settingName), + jest.spyOn(SettingsStore, "getValue").mockReset().mockImplementation( + settingName => mockSettings[settingName] ?? false, ); }); @@ -219,6 +232,15 @@ describe("Notifier", () => { }); }); + describe("getSoundForRoom", () => { + it("should not explode if given invalid url", () => { + jest.spyOn(SettingsStore, "getValue").mockImplementation((name: string) => { + return { url: { content_uri: "foobar" } }; + }); + expect(Notifier.getSoundForRoom("!roomId:server")).toBeNull(); + }); + }); + describe("_playAudioNotification", () => { it.each([ { event: { is_silenced: true }, count: 0 }, @@ -253,16 +275,13 @@ describe("Notifier", () => { }); 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; - + const callEvent = mkEvent({ + type: type ?? ElementCall.CALL_EVENT_TYPE.name, + user: "@alice:foo", + room: roomId, + content: {}, + event: true, + }); Notifier.onEvent(callEvent); return callEvent; }; @@ -345,4 +364,72 @@ describe("Notifier", () => { expect(createLocalNotificationSettingsIfNeededMock).toHaveBeenCalled(); }); }); + + describe('_evaluateEvent', () => { + beforeEach(() => { + jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId") + .mockReturnValue(testRoom.roomId); + + jest.spyOn(UserActivity.sharedInstance(), "userActiveRecently") + .mockReturnValue(true); + + jest.spyOn(Modal, "hasDialogs").mockReturnValue(false); + + jest.spyOn(Notifier, "_displayPopupNotification").mockReset(); + jest.spyOn(Notifier, "isEnabled").mockReturnValue(true); + + mockClient.getPushActionsForEvent.mockReturnValue({ + notify: true, + tweaks: { + sound: true, + }, + }); + }); + + it("should show a pop-up", () => { + expect(Notifier._displayPopupNotification).toHaveBeenCalledTimes(0); + Notifier._evaluateEvent(testEvent); + expect(Notifier._displayPopupNotification).toHaveBeenCalledTimes(0); + + const eventFromOtherRoom = mkEvent({ + event: true, + type: "m.room.message", + user: "@user1:server", + room: "!otherroom:example.org", + content: {}, + }); + + Notifier._evaluateEvent(eventFromOtherRoom); + expect(Notifier._displayPopupNotification).toHaveBeenCalledTimes(1); + }); + + it("should a pop-up for thread event", async () => { + const { events, rootEvent } = mkThread({ + room: testRoom, + client: mockClient, + authorId: "@bob:example.org", + participantUserIds: ["@bob:example.org"], + }); + + expect(Notifier._displayPopupNotification).toHaveBeenCalledTimes(0); + + Notifier._evaluateEvent(rootEvent); + expect(Notifier._displayPopupNotification).toHaveBeenCalledTimes(0); + + Notifier._evaluateEvent(events[1]); + expect(Notifier._displayPopupNotification).toHaveBeenCalledTimes(1); + + dis.dispatch({ + action: Action.ViewThread, + thread_id: rootEvent.getId(), + }); + + await waitFor(() => + expect(SdkContextClass.instance.roomViewStore.getThreadId()).toBe(rootEvent.getId()), + ); + + Notifier._evaluateEvent(events[1]); + expect(Notifier._displayPopupNotification).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/test/RoomNotifs-test.ts b/test/RoomNotifs-test.ts index 3f486205dfc..8ab37e69450 100644 --- a/test/RoomNotifs-test.ts +++ b/test/RoomNotifs-test.ts @@ -16,10 +16,15 @@ limitations under the License. import { mocked } from 'jest-mock'; import { ConditionKind, PushRuleActionName, TweakName } from "matrix-js-sdk/src/@types/PushRules"; +import { NotificationCountType, Room } from 'matrix-js-sdk/src/models/room'; -import { stubClient } from "./test-utils"; +import { mkEvent, stubClient } from "./test-utils"; import { MatrixClientPeg } from "../src/MatrixClientPeg"; -import { getRoomNotifsState, RoomNotifState } from "../src/RoomNotifs"; +import { + getRoomNotifsState, + RoomNotifState, + getUnreadNotificationCount, +} from "../src/RoomNotifs"; describe("RoomNotifs test", () => { beforeEach(() => { @@ -83,4 +88,74 @@ describe("RoomNotifs test", () => { }); expect(getRoomNotifsState("!roomId:server")).toBe(RoomNotifState.AllMessagesLoud); }); + + describe("getUnreadNotificationCount", () => { + const ROOM_ID = "!roomId:example.org"; + const THREAD_ID = "$threadId"; + + let cli; + let room: Room; + beforeEach(() => { + cli = MatrixClientPeg.get(); + room = new Room(ROOM_ID, cli, cli.getUserId()); + }); + + it("counts room notification type", () => { + expect(getUnreadNotificationCount(room, NotificationCountType.Total)).toBe(0); + expect(getUnreadNotificationCount(room, NotificationCountType.Highlight)).toBe(0); + }); + + it("counts notifications type", () => { + room.setUnreadNotificationCount(NotificationCountType.Total, 2); + room.setUnreadNotificationCount(NotificationCountType.Highlight, 1); + + expect(getUnreadNotificationCount(room, NotificationCountType.Total)).toBe(2); + expect(getUnreadNotificationCount(room, NotificationCountType.Highlight)).toBe(1); + }); + + it("counts predecessor highlight", () => { + room.setUnreadNotificationCount(NotificationCountType.Total, 2); + room.setUnreadNotificationCount(NotificationCountType.Highlight, 1); + + const OLD_ROOM_ID = "!oldRoomId:example.org"; + const oldRoom = new Room(OLD_ROOM_ID, cli, cli.getUserId()); + oldRoom.setUnreadNotificationCount(NotificationCountType.Total, 10); + oldRoom.setUnreadNotificationCount(NotificationCountType.Highlight, 6); + + cli.getRoom.mockReset().mockReturnValue(oldRoom); + + const predecessorEvent = mkEvent({ + event: true, + type: "m.room.create", + room: ROOM_ID, + user: cli.getUserId(), + content: { + creator: cli.getUserId(), + room_version: "5", + predecessor: { + room_id: OLD_ROOM_ID, + event_id: "$someevent", + }, + }, + ts: Date.now(), + }); + room.addLiveEvents([predecessorEvent]); + + expect(getUnreadNotificationCount(room, NotificationCountType.Total)).toBe(8); + expect(getUnreadNotificationCount(room, NotificationCountType.Highlight)).toBe(7); + }); + + it("counts thread notification type", () => { + expect(getUnreadNotificationCount(room, NotificationCountType.Total, THREAD_ID)).toBe(0); + expect(getUnreadNotificationCount(room, NotificationCountType.Highlight, THREAD_ID)).toBe(0); + }); + + it("counts notifications type", () => { + room.setThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Total, 2); + room.setThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Highlight, 1); + + expect(getUnreadNotificationCount(room, NotificationCountType.Total, THREAD_ID)).toBe(2); + expect(getUnreadNotificationCount(room, NotificationCountType.Highlight, THREAD_ID)).toBe(1); + }); + }); }); diff --git a/test/SlashCommands-test.tsx b/test/SlashCommands-test.tsx index 39d3986270c..c31d6d70c12 100644 --- a/test/SlashCommands-test.tsx +++ b/test/SlashCommands-test.tsx @@ -21,9 +21,9 @@ import { Command, Commands, getCommand } from '../src/SlashCommands'; import { createTestClient } from './test-utils'; import { MatrixClientPeg } from '../src/MatrixClientPeg'; import { LocalRoom, LOCAL_ROOM_ID_PREFIX } from '../src/models/LocalRoom'; -import { RoomViewStore } from '../src/stores/RoomViewStore'; import SettingsStore from '../src/settings/SettingsStore'; import LegacyCallHandler from '../src/LegacyCallHandler'; +import { SdkContextClass } from '../src/contexts/SDKContext'; describe('SlashCommands', () => { let client: MatrixClient; @@ -38,14 +38,14 @@ describe('SlashCommands', () => { }; const setCurrentRoom = (): void => { - mocked(RoomViewStore.instance.getRoomId).mockReturnValue(roomId); + mocked(SdkContextClass.instance.roomViewStore.getRoomId).mockReturnValue(roomId); mocked(client.getRoom).mockImplementation((rId: string): Room => { if (rId === roomId) return room; }); }; const setCurrentLocalRoon = (): void => { - mocked(RoomViewStore.instance.getRoomId).mockReturnValue(localRoomId); + mocked(SdkContextClass.instance.roomViewStore.getRoomId).mockReturnValue(localRoomId); mocked(client.getRoom).mockImplementation((rId: string): Room => { if (rId === localRoomId) return localRoom; }); @@ -60,7 +60,7 @@ describe('SlashCommands', () => { room = new Room(roomId, client, client.getUserId()); localRoom = new LocalRoom(localRoomId, client, client.getUserId()); - jest.spyOn(RoomViewStore.instance, "getRoomId"); + jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId"); }); describe('/topic', () => { diff --git a/test/SlidingSyncManager-test.ts b/test/SlidingSyncManager-test.ts new file mode 100644 index 00000000000..40da54a7d1e --- /dev/null +++ b/test/SlidingSyncManager-test.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 { SlidingSync } from 'matrix-js-sdk/src/sliding-sync'; +import { mocked } from 'jest-mock'; + +import { SlidingSyncManager } from '../src/SlidingSyncManager'; +import { stubClient } from './test-utils'; + +jest.mock('matrix-js-sdk/src/sliding-sync'); +const MockSlidingSync = >SlidingSync; + +describe('SlidingSyncManager', () => { + let manager: SlidingSyncManager; + let slidingSync: SlidingSync; + + beforeEach(() => { + slidingSync = new MockSlidingSync(); + manager = new SlidingSyncManager(); + manager.configure(stubClient(), "invalid"); + manager.slidingSync = slidingSync; + }); + + describe("startSpidering", () => { + it("requests in batchSizes", async () => { + const gapMs = 1; + const batchSize = 10; + mocked(slidingSync.setList).mockResolvedValue("yep"); + mocked(slidingSync.setListRanges).mockResolvedValue("yep"); + mocked(slidingSync.getListData).mockImplementation((i) => { + return { + joinedCount: 64, + roomIndexToRoomId: {}, + }; + }); + await manager.startSpidering(batchSize, gapMs); + // we expect calls for 10,19 -> 20,29 -> 30,39 -> 40,49 -> 50,59 -> 60,69 + const wantWindows = [ + [10, 19], [20, 29], [30, 39], [40, 49], [50, 59], [60, 69], + ]; + expect(slidingSync.getListData).toBeCalledTimes(wantWindows.length); + expect(slidingSync.setList).toBeCalledTimes(1); + expect(slidingSync.setListRanges).toBeCalledTimes(wantWindows.length-1); + wantWindows.forEach((range, i) => { + if (i === 0) { + expect(slidingSync.setList).toBeCalledWith( + manager.getOrAllocateListIndex(SlidingSyncManager.ListSearch), + expect.objectContaining({ + ranges: [[0, batchSize-1], range], + }), + ); + return; + } + expect(slidingSync.setListRanges).toBeCalledWith( + manager.getOrAllocateListIndex(SlidingSyncManager.ListSearch), + [[0, batchSize-1], range], + ); + }); + }); + it("handles accounts with zero rooms", async () => { + const gapMs = 1; + const batchSize = 10; + mocked(slidingSync.setList).mockResolvedValue("yep"); + mocked(slidingSync.getListData).mockImplementation((i) => { + return { + joinedCount: 0, + roomIndexToRoomId: {}, + }; + }); + await manager.startSpidering(batchSize, gapMs); + expect(slidingSync.getListData).toBeCalledTimes(1); + expect(slidingSync.setList).toBeCalledTimes(1); + expect(slidingSync.setList).toBeCalledWith( + manager.getOrAllocateListIndex(SlidingSyncManager.ListSearch), + expect.objectContaining({ + ranges: [[0, batchSize-1], [batchSize, batchSize+batchSize-1]], + }), + ); + }); + it("continues even when setList rejects", async () => { + const gapMs = 1; + const batchSize = 10; + mocked(slidingSync.setList).mockRejectedValue("narp"); + mocked(slidingSync.getListData).mockImplementation((i) => { + return { + joinedCount: 0, + roomIndexToRoomId: {}, + }; + }); + await manager.startSpidering(batchSize, gapMs); + expect(slidingSync.getListData).toBeCalledTimes(1); + expect(slidingSync.setList).toBeCalledTimes(1); + expect(slidingSync.setList).toBeCalledWith( + manager.getOrAllocateListIndex(SlidingSyncManager.ListSearch), + expect.objectContaining({ + ranges: [[0, batchSize-1], [batchSize, batchSize+batchSize-1]], + }), + ); + }); + }); +}); diff --git a/test/TestSdkContext.ts b/test/TestSdkContext.ts new file mode 100644 index 00000000000..4ce9100a94d --- /dev/null +++ b/test/TestSdkContext.ts @@ -0,0 +1,46 @@ +/* +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 { SdkContextClass } from "../src/contexts/SDKContext"; +import { PosthogAnalytics } from "../src/PosthogAnalytics"; +import { SlidingSyncManager } from "../src/SlidingSyncManager"; +import { RoomNotificationStateStore } from "../src/stores/notifications/RoomNotificationStateStore"; +import RightPanelStore from "../src/stores/right-panel/RightPanelStore"; +import { RoomViewStore } from "../src/stores/RoomViewStore"; +import { SpaceStoreClass } from "../src/stores/spaces/SpaceStore"; +import { WidgetLayoutStore } from "../src/stores/widgets/WidgetLayoutStore"; +import { WidgetPermissionStore } from "../src/stores/widgets/WidgetPermissionStore"; +import WidgetStore from "../src/stores/WidgetStore"; + +/** + * A class which provides the same API as SdkContextClass but adds additional unsafe setters which can + * replace individual stores. This is useful for tests which need to mock out stores. + */ +export class TestSdkContext extends SdkContextClass { + public _RightPanelStore?: RightPanelStore; + public _RoomNotificationStateStore?: RoomNotificationStateStore; + public _RoomViewStore?: RoomViewStore; + public _WidgetPermissionStore?: WidgetPermissionStore; + public _WidgetLayoutStore?: WidgetLayoutStore; + public _WidgetStore?: WidgetStore; + public _PosthogAnalytics?: PosthogAnalytics; + public _SlidingSyncManager?: SlidingSyncManager; + public _SpaceStore?: SpaceStoreClass; + + constructor() { + super(); + } +} diff --git a/test/audio/VoiceRecording-test.ts b/test/audio/VoiceRecording-test.ts new file mode 100644 index 00000000000..ac4f52eabe2 --- /dev/null +++ b/test/audio/VoiceRecording-test.ts @@ -0,0 +1,105 @@ +/* +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 { VoiceRecording } from "../../src/audio/VoiceRecording"; + +/** + * The tests here are heavily using access to private props. + * While this is not so great, we can at lest test some behaviour easily this way. + */ +describe("VoiceRecording", () => { + let recording: VoiceRecording; + let recorderSecondsSpy: jest.SpyInstance; + + const itShouldNotCallStop = () => { + it("should not call stop", () => { + expect(recording.stop).not.toHaveBeenCalled(); + }); + }; + + const simulateUpdate = (recorderSeconds: number) => { + beforeEach(() => { + recorderSecondsSpy.mockReturnValue(recorderSeconds); + // @ts-ignore + recording.processAudioUpdate(recorderSeconds); + }); + }; + + beforeEach(() => { + recording = new VoiceRecording(); + // @ts-ignore + recording.observable = { + update: jest.fn(), + }; + jest.spyOn(recording, "stop").mockImplementation(); + recorderSecondsSpy = jest.spyOn(recording, "recorderSeconds", "get"); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe("when recording", () => { + beforeEach(() => { + // @ts-ignore + recording.recording = true; + }); + + describe("and there is an audio update and time left", () => { + simulateUpdate(42); + itShouldNotCallStop(); + }); + + describe("and there is an audio update and time is up", () => { + // one second above the limit + simulateUpdate(901); + + it("should call stop", () => { + expect(recording.stop).toHaveBeenCalled(); + }); + }); + + describe("and the max length limit has been disabled", () => { + beforeEach(() => { + recording.disableMaxLength(); + }); + + describe("and there is an audio update and time left", () => { + simulateUpdate(42); + itShouldNotCallStop(); + }); + + describe("and there is an audio update and time is up", () => { + // one second above the limit + simulateUpdate(901); + itShouldNotCallStop(); + }); + }); + }); + + describe("when not recording", () => { + describe("and there is an audio update and time left", () => { + simulateUpdate(42); + itShouldNotCallStop(); + }); + + describe("and there is an audio update and time is up", () => { + // one second above the limit + simulateUpdate(901); + itShouldNotCallStop(); + }); + }); +}); diff --git a/test/components/atoms/__snapshots__/Icon-test.tsx.snap b/test/components/atoms/__snapshots__/Icon-test.tsx.snap deleted file mode 100644 index c30b4ba3323..00000000000 --- a/test/components/atoms/__snapshots__/Icon-test.tsx.snap +++ /dev/null @@ -1,34 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Icon should render the colour accent 1`] = ` -
-