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.") } + + { _t("Start at the sign in screen") } + { _t("Select 'Scan QR code'") } + { _t("Review and approve the sign in") } + + { 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 ( = ({ initialText = "", initialFilter = n aria-describedby={`mx_SpotlightDialog_button_result_${result.publicRoom.room_id}_alias`} aria-details={`mx_SpotlightDialog_button_result_${result.publicRoom.room_id}_details`} > - @@ -1060,7 +1042,7 @@ const SpotlightDialog: React.FC = ({ initialText = "", initialFilter = n { BreadcrumbsStore.instance.rooms - .filter(r => r.roomId !== RoomViewStore.instance.getRoomId()) + .filter(r => r.roomId !== SdkContextClass.instance.roomViewStore.getRoomId()) .map(room => ( = ({ initialText = "", initialFilter = n aria-label={_t("Search")} aria-describedby="mx_SpotlightDialog_keyboardPrompt" /> - { (publicRoomsLoading || peopleLoading || profileLoading || slidingSyncRoomSearchLoading) && ( + { (publicRoomsLoading || peopleLoading || profileLoading) && ( ) } diff --git a/src/components/views/elements/AccessibleButton.tsx b/src/components/views/elements/AccessibleButton.tsx index 7036575cd1e..6f11fa12bd5 100644 --- a/src/components/views/elements/AccessibleButton.tsx +++ b/src/components/views/elements/AccessibleButton.tsx @@ -75,7 +75,7 @@ type IProps = DynamicHtmlElementProps onClick: ((e: ButtonEvent) => void | Promise) | null; }; -interface IAccessibleButtonProps extends React.InputHTMLAttributes { +export interface IAccessibleButtonProps extends React.InputHTMLAttributes { ref?: React.Ref; } diff --git a/src/components/views/elements/AppTile.tsx b/src/components/views/elements/AppTile.tsx index ae37889180b..7480774c05a 100644 --- a/src/components/views/elements/AppTile.tsx +++ b/src/components/views/elements/AppTile.tsx @@ -43,13 +43,13 @@ import { IApp } from "../../../stores/WidgetStore"; import { Container, WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore"; import { OwnProfileStore } from '../../../stores/OwnProfileStore'; import { UPDATE_EVENT } from '../../../stores/AsyncStore'; -import { RoomViewStore } from '../../../stores/RoomViewStore'; import WidgetUtils from '../../../utils/WidgetUtils'; import MatrixClientContext from "../../../contexts/MatrixClientContext"; import { ActionPayload } from "../../../dispatcher/payloads"; import { Action } from '../../../dispatcher/actions'; import { ElementWidgetCapabilities } from '../../../stores/widgets/ElementWidgetCapabilities'; import { WidgetMessagingStore } from '../../../stores/widgets/WidgetMessagingStore'; +import { SdkContextClass } from '../../../contexts/SDKContext'; interface IProps { app: IApp; @@ -175,7 +175,7 @@ export default class AppTile extends React.Component { ); if (isActiveWidget) { // We just left the room that the active widget was from. - if (this.props.room && RoomViewStore.instance.getRoomId() !== this.props.room.roomId) { + if (this.props.room && SdkContextClass.instance.roomViewStore.getRoomId() !== this.props.room.roomId) { // If we are not actively looking at the room then destroy this widget entirely. this.endWidgetActions(); } else if (WidgetType.JITSI.matches(this.props.app.type)) { diff --git a/src/components/views/elements/DialogButtons.tsx b/src/components/views/elements/DialogButtons.tsx index bf018e14f49..522d847e1b1 100644 --- a/src/components/views/elements/DialogButtons.tsx +++ b/src/components/views/elements/DialogButtons.tsx @@ -82,7 +82,7 @@ export default class DialogButtons extends React.Component { cancelButton = { { cancelButton } { this.props.children } = ({ + title, + description, + ...rest +}) => { + const onClick = () => { + Modal.createDialog( + InfoDialog, + { + title, + description, + button: _t('Got it'), + hasCloseButton: true, + }, + ); + }; + + return + { _t('Learn more') } + ; +}; + +export default LearnMore; diff --git a/src/components/views/elements/PowerSelector.tsx b/src/components/views/elements/PowerSelector.tsx index 3a9e87d1581..8b251b91a51 100644 --- a/src/components/views/elements/PowerSelector.tsx +++ b/src/components/views/elements/PowerSelector.tsx @@ -44,14 +44,13 @@ interface IProps { } interface IState { - levelRoleMap: {}; + levelRoleMap: Partial>; // List of power levels to show in the drop-down options: number[]; customValue: number; selectValue: number | string; custom?: boolean; - customLevel?: number; } export default class PowerSelector extends React.Component { @@ -101,7 +100,7 @@ export default class PowerSelector extends React.Component { levelRoleMap, options, custom: isCustom, - customLevel: newProps.value, + customValue: newProps.value, selectValue: isCustom ? CUSTOM_VALUE : newProps.value, }); } @@ -125,7 +124,11 @@ export default class PowerSelector extends React.Component { event.preventDefault(); event.stopPropagation(); - this.props.onChange(this.state.customValue, this.props.powerLevelKey); + if (Number.isFinite(this.state.customValue)) { + this.props.onChange(this.state.customValue, this.props.powerLevelKey); + } else { + this.initStateFromProps(this.props); // reset, invalid input + } }; private onCustomKeyDown = (event: React.KeyboardEvent): void => { diff --git a/src/components/views/elements/SSOButtons.tsx b/src/components/views/elements/SSOButtons.tsx index 666e55eab45..4332c914e74 100644 --- a/src/components/views/elements/SSOButtons.tsx +++ b/src/components/views/elements/SSOButtons.tsx @@ -19,11 +19,11 @@ import { chunk } from "lodash"; import classNames from "classnames"; import { MatrixClient } from "matrix-js-sdk/src/client"; import { Signup } from "@matrix-org/analytics-events/types/typescript/Signup"; +import { IdentityProviderBrand, IIdentityProvider, ISSOFlow } from "matrix-js-sdk/src/@types/auth"; import PlatformPeg from "../../../PlatformPeg"; import AccessibleButton from "./AccessibleButton"; import { _t } from "../../../languageHandler"; -import { IdentityProviderBrand, IIdentityProvider, ISSOFlow } from "../../../Login"; import AccessibleTooltipButton from "./AccessibleTooltipButton"; import { mediaFromMxc } from "../../../customisations/Media"; import { PosthogAnalytics } from "../../../PosthogAnalytics"; diff --git a/src/components/views/elements/Spinner.tsx b/src/components/views/elements/Spinner.tsx index e8a3d9414b3..8aaee5d9a37 100644 --- a/src/components/views/elements/Spinner.tsx +++ b/src/components/views/elements/Spinner.tsx @@ -40,6 +40,7 @@ export default class Spinner extends React.PureComponent { w={this.props.w} h={this.props.h} className="mx_Spinner_icon" + data-testid="spinner" /> ); diff --git a/src/components/views/elements/Tooltip.tsx b/src/components/views/elements/Tooltip.tsx index ab27f4f9d8f..12013d58fc4 100644 --- a/src/components/views/elements/Tooltip.tsx +++ b/src/components/views/elements/Tooltip.tsx @@ -57,7 +57,7 @@ type State = Partial { private static container: HTMLElement; - private parent: Element; + private parent: Element | null = null; // XXX: This is because some components (Field) are unable to `import` the Tooltip class, // so we expose the Alignment options off of us statically. @@ -87,7 +87,7 @@ export default class Tooltip extends React.PureComponent { capture: true, }); - this.parent = ReactDOM.findDOMNode(this).parentNode as Element; + this.parent = ReactDOM.findDOMNode(this)?.parentNode as Element ?? null; this.updatePosition(); } @@ -109,7 +109,7 @@ export default class Tooltip extends React.PureComponent { // positioned, also taking into account any window zoom private updatePosition = (): void => { // When the tooltip is hidden, no need to thrash the DOM with `style` attribute updates (performance) - if (!this.props.visible) return; + if (!this.props.visible || !this.parent) return; const parentBox = this.parent.getBoundingClientRect(); const width = UIStore.instance.windowWidth; diff --git a/src/components/views/messages/MessageEvent.tsx b/src/components/views/messages/MessageEvent.tsx index 6a7f5329fd0..8d2c6dfcf31 100644 --- a/src/components/views/messages/MessageEvent.tsx +++ b/src/components/views/messages/MessageEvent.tsx @@ -43,8 +43,6 @@ import MjolnirBody from "./MjolnirBody"; import MBeaconBody from "./MBeaconBody"; import { IEventTileOps } from "../rooms/EventTile"; import { VoiceBroadcastBody, VoiceBroadcastInfoEventType, VoiceBroadcastInfoState } from '../../../voice-broadcast'; -import { Features } from '../../../settings/Settings'; -import { SettingLevel } from '../../../settings/SettingLevel'; // onMessageAllowed is handled internally interface IProps extends Omit { @@ -58,18 +56,10 @@ interface IProps extends Omit([ [MsgType.Text, TextualBody], [MsgType.Notice, TextualBody], @@ -87,7 +77,7 @@ const baseEvTypes = new Map>>([ [M_BEACON_INFO.altName, MBeaconBody], ]); -export default class MessageEvent extends React.Component implements IMediaBody, IOperableEventTile { +export default class MessageEvent extends React.Component implements IMediaBody, IOperableEventTile { private body: React.RefObject = createRef(); private mediaHelper: MediaEventHelper; private bodyTypes = new Map(baseBodyTypes.entries()); @@ -95,7 +85,6 @@ export default class MessageEvent extends React.Component impleme public static contextType = MatrixClientContext; public context!: React.ContextType; - private voiceBroadcastSettingWatcherRef: string; public constructor(props: IProps, context: React.ContextType) { super(props, context); @@ -105,29 +94,15 @@ export default class MessageEvent extends React.Component impleme } this.updateComponentMaps(); - - this.state = { - // only check voice broadcast settings for a voice broadcast event - voiceBroadcastEnabled: this.props.mxEvent.getType() === VoiceBroadcastInfoEventType - && SettingsStore.getValue(Features.VoiceBroadcast), - }; } public componentDidMount(): void { this.props.mxEvent.addListener(MatrixEventEvent.Decrypted, this.onDecrypted); - - if (this.props.mxEvent.getType() === VoiceBroadcastInfoEventType) { - this.watchVoiceBroadcastFeatureSetting(); - } } public componentWillUnmount() { this.props.mxEvent.removeListener(MatrixEventEvent.Decrypted, this.onDecrypted); this.mediaHelper?.destroy(); - - if (this.voiceBroadcastSettingWatcherRef) { - SettingsStore.unwatchSetting(this.voiceBroadcastSettingWatcherRef); - } } public componentDidUpdate(prevProps: Readonly) { @@ -171,16 +146,6 @@ export default class MessageEvent extends React.Component impleme this.forceUpdate(); }; - private watchVoiceBroadcastFeatureSetting(): void { - this.voiceBroadcastSettingWatcherRef = SettingsStore.watchSetting( - Features.VoiceBroadcast, - null, - (settingName: string, roomId: string, atLevel: SettingLevel, newValAtLevel, newValue: boolean) => { - this.setState({ voiceBroadcastEnabled: newValue }); - }, - ); - } - public render() { const content = this.props.mxEvent.getContent(); const type = this.props.mxEvent.getType(); @@ -209,8 +174,7 @@ export default class MessageEvent extends React.Component impleme } if ( - this.state.voiceBroadcastEnabled - && type === VoiceBroadcastInfoEventType + type === VoiceBroadcastInfoEventType && content?.state === VoiceBroadcastInfoState.Started ) { BodyType = VoiceBroadcastBody; diff --git a/src/components/views/messages/TextualBody.tsx b/src/components/views/messages/TextualBody.tsx index 9bb331ef04b..08eb0b95078 100644 --- a/src/components/views/messages/TextualBody.tsx +++ b/src/components/views/messages/TextualBody.tsx @@ -48,6 +48,7 @@ import RoomContext from "../../../contexts/RoomContext"; import AccessibleButton from '../elements/AccessibleButton'; import { options as linkifyOpts } from "../../../linkify-matrix"; import { getParentEventId } from '../../../utils/Reply'; +import { EditWysiwygComposer } from '../rooms/wysiwyg_composer'; const MAX_HIGHLIGHT_LENGTH = 4096; @@ -564,7 +565,10 @@ export default class TextualBody extends React.Component { render() { if (this.props.editState) { - return ; + const isWysiwygComposerEnabled = SettingsStore.getValue("feature_wysiwyg_composer"); + return isWysiwygComposerEnabled ? + : + ; } const mxEvent = this.props.mxEvent; const content = mxEvent.getContent(); diff --git a/src/components/views/right_panel/RoomHeaderButtons.tsx b/src/components/views/right_panel/RoomHeaderButtons.tsx index 262b8fc38d6..c6e012fff4c 100644 --- a/src/components/views/right_panel/RoomHeaderButtons.tsx +++ b/src/components/views/right_panel/RoomHeaderButtons.tsx @@ -20,7 +20,8 @@ limitations under the License. import React from "react"; import classNames from "classnames"; -import { Room } from "matrix-js-sdk/src/models/room"; +import { NotificationCountType, Room, RoomEvent } from "matrix-js-sdk/src/models/room"; +import { Feature, ServerSupport } from "matrix-js-sdk/src/feature"; import { _t } from '../../../languageHandler'; import HeaderButton from './HeaderButton'; @@ -43,6 +44,7 @@ import { SummarizedNotificationState } from "../../../stores/notifications/Summa import { NotificationStateEvents } from "../../../stores/notifications/NotificationState"; import PosthogTrackers from "../../../PosthogTrackers"; import { ButtonEvent } from "../elements/AccessibleButton"; +import { MatrixClientPeg } from "../../../MatrixClientPeg"; const ROOM_INFO_PHASES = [ RightPanelPhases.RoomSummary, @@ -136,32 +138,67 @@ export default class RoomHeaderButtons extends HeaderButtons { private threadNotificationState: ThreadsRoomNotificationState; private globalNotificationState: SummarizedNotificationState; + private get supportsThreadNotifications(): boolean { + const client = MatrixClientPeg.get(); + return client.canSupport.get(Feature.ThreadUnreadNotifications) !== ServerSupport.Unsupported; + } + constructor(props: IProps) { super(props, HeaderKind.Room); - this.threadNotificationState = RoomNotificationStateStore.instance.getThreadsRoomState(this.props.room); + if (!this.supportsThreadNotifications) { + this.threadNotificationState = RoomNotificationStateStore.instance.getThreadsRoomState(this.props.room); + } this.globalNotificationState = RoomNotificationStateStore.instance.globalState; } public componentDidMount(): void { super.componentDidMount(); - this.threadNotificationState.on(NotificationStateEvents.Update, this.onThreadNotification); + if (!this.supportsThreadNotifications) { + this.threadNotificationState?.on(NotificationStateEvents.Update, this.onNotificationUpdate); + } else { + this.props.room?.on(RoomEvent.UnreadNotifications, this.onNotificationUpdate); + } + this.onNotificationUpdate(); RoomNotificationStateStore.instance.on(UPDATE_STATUS_INDICATOR, this.onUpdateStatus); } public componentWillUnmount(): void { super.componentWillUnmount(); - this.threadNotificationState.off(NotificationStateEvents.Update, this.onThreadNotification); + if (!this.supportsThreadNotifications) { + this.threadNotificationState?.off(NotificationStateEvents.Update, this.onNotificationUpdate); + } else { + this.props.room?.off(RoomEvent.UnreadNotifications, this.onNotificationUpdate); + } RoomNotificationStateStore.instance.off(UPDATE_STATUS_INDICATOR, this.onUpdateStatus); } - private onThreadNotification = (): void => { + private onNotificationUpdate = (): void => { + let threadNotificationColor: NotificationColor; + if (!this.supportsThreadNotifications) { + threadNotificationColor = this.threadNotificationState.color; + } else { + threadNotificationColor = this.notificationColor; + } + + // console.log // XXX: why don't we read from this.state.threadNotificationColor in the render methods? this.setState({ - threadNotificationColor: this.threadNotificationState.color, + threadNotificationColor, }); }; + private get notificationColor(): NotificationColor { + switch (this.props.room.threadsAggregateNotificationType) { + case NotificationCountType.Highlight: + return NotificationColor.Red; + case NotificationCountType.Total: + return NotificationColor.Grey; + default: + return NotificationColor.None; + } + } + private onUpdateStatus = (notificationState: SummarizedNotificationState): void => { // XXX: why don't we read from this.state.globalNotificationCount in the render methods? this.globalNotificationState = notificationState; @@ -255,12 +292,13 @@ export default class RoomHeaderButtons extends HeaderButtons { ? 0} + isUnread={this.state.threadNotificationColor > 0} > - + : null, ); diff --git a/src/components/views/right_panel/TimelineCard.tsx b/src/components/views/right_panel/TimelineCard.tsx index 4e1aeea8d0d..ac5638e5b3d 100644 --- a/src/components/views/right_panel/TimelineCard.tsx +++ b/src/components/views/right_panel/TimelineCard.tsx @@ -33,7 +33,6 @@ import dis from '../../../dispatcher/dispatcher'; import { _t } from '../../../languageHandler'; import { ActionPayload } from '../../../dispatcher/payloads'; import { Action } from '../../../dispatcher/actions'; -import { RoomViewStore } from '../../../stores/RoomViewStore'; import ContentMessages from '../../../ContentMessages'; import UploadBar from '../../structures/UploadBar'; import SettingsStore from '../../../settings/SettingsStore'; @@ -42,6 +41,7 @@ import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; import Measured from '../elements/Measured'; import Heading from '../typography/Heading'; import { UPDATE_EVENT } from '../../../stores/AsyncStore'; +import { SdkContextClass } from '../../../contexts/SDKContext'; interface IProps { room: Room; @@ -91,7 +91,7 @@ export default class TimelineCard extends React.Component { } public componentDidMount(): void { - RoomViewStore.instance.addListener(UPDATE_EVENT, this.onRoomViewStoreUpdate); + SdkContextClass.instance.roomViewStore.addListener(UPDATE_EVENT, this.onRoomViewStoreUpdate); this.dispatcherRef = dis.register(this.onAction); this.readReceiptsSettingWatcher = SettingsStore.watchSetting("showReadReceipts", null, (...[,,, value]) => this.setState({ showReadReceipts: value as boolean }), @@ -102,7 +102,7 @@ export default class TimelineCard extends React.Component { } public componentWillUnmount(): void { - RoomViewStore.instance.removeListener(UPDATE_EVENT, this.onRoomViewStoreUpdate); + SdkContextClass.instance.roomViewStore.removeListener(UPDATE_EVENT, this.onRoomViewStoreUpdate); if (this.readReceiptsSettingWatcher) { SettingsStore.unwatchSetting(this.readReceiptsSettingWatcher); @@ -116,12 +116,9 @@ export default class TimelineCard extends React.Component { private onRoomViewStoreUpdate = async (initial?: boolean): Promise => { const newState: Pick = { - // roomLoading: RoomViewStore.instance.isRoomLoading(), - // roomLoadError: RoomViewStore.instance.getRoomLoadError(), - - initialEventId: RoomViewStore.instance.getInitialEventId(), - isInitialEventHighlighted: RoomViewStore.instance.isInitialEventHighlighted(), - replyToEvent: RoomViewStore.instance.getQuotingEvent(), + initialEventId: SdkContextClass.instance.roomViewStore.getInitialEventId(), + isInitialEventHighlighted: SdkContextClass.instance.roomViewStore.isInitialEventHighlighted(), + replyToEvent: SdkContextClass.instance.roomViewStore.getQuotingEvent(), }; this.setState(newState); diff --git a/src/components/views/right_panel/UserInfo.tsx b/src/components/views/right_panel/UserInfo.tsx index 810ae48dd71..49201d52bce 100644 --- a/src/components/views/right_panel/UserInfo.tsx +++ b/src/components/views/right_panel/UserInfo.tsx @@ -36,7 +36,6 @@ import { _t } from '../../../languageHandler'; import DMRoomMap from '../../../utils/DMRoomMap'; import AccessibleButton, { ButtonEvent } from '../elements/AccessibleButton'; import SdkConfig from '../../../SdkConfig'; -import { RoomViewStore } from "../../../stores/RoomViewStore"; import MultiInviter from "../../../utils/MultiInviter"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; import E2EIcon from "../rooms/E2EIcon"; @@ -77,6 +76,7 @@ import UserIdentifierCustomisations from '../../../customisations/UserIdentifier import PosthogTrackers from "../../../PosthogTrackers"; import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; import { DirectoryMember, startDmOnFirstMessage } from '../../../utils/direct-messages'; +import { SdkContextClass } from '../../../contexts/SDKContext'; export interface IDevice { deviceId: string; @@ -412,7 +412,7 @@ const UserOptionsSection: React.FC<{ } if (canInvite && (member?.membership ?? 'leave') === 'leave' && shouldShowComponent(UIComponent.InviteUsers)) { - const roomId = member && member.roomId ? member.roomId : RoomViewStore.instance.getRoomId(); + const roomId = member && member.roomId ? member.roomId : SdkContextClass.instance.roomViewStore.getRoomId(); const onInviteUserButton = async (ev: ButtonEvent) => { try { // We use a MultiInviter to re-use the invite logic, even though we're only inviting one user. diff --git a/src/components/views/rooms/BasicMessageComposer.tsx b/src/components/views/rooms/BasicMessageComposer.tsx index a4a0b9f993e..a8b5e70ea34 100644 --- a/src/components/views/rooms/BasicMessageComposer.tsx +++ b/src/components/views/rooms/BasicMessageComposer.tsx @@ -31,7 +31,6 @@ import Autocomplete, { generateCompletionDomId } from '../rooms/Autocomplete'; import { getAutoCompleteCreator, Part, Type } from '../../../editor/parts'; import { parseEvent, parsePlainTextMessage } from '../../../editor/deserialize'; import { renderModel } from '../../../editor/render'; -import TypingStore from "../../../stores/TypingStore"; import SettingsStore from "../../../settings/SettingsStore"; import { IS_MAC, Key } from "../../../Keyboard"; import { EMOTICON_TO_EMOJI, IEmoji } from "../../../emoji"; @@ -48,6 +47,7 @@ import { ALTERNATE_KEY_NAME, KeyBindingAction } from '../../../accessibility/Key import { _t } from "../../../languageHandler"; import { linkify } from '../../../linkify-matrix'; import { ICustomEmoji } from '../../../emojipicker/customemoji'; +import { SdkContextClass } from '../../../contexts/SDKContext'; // matches emoticons which follow the start of a line or whitespace const REGEX_EMOTICON_WHITESPACE = new RegExp('(?:^|\\s)(' + EMOTICON_REGEX.source + ')\\s|:^$'); @@ -247,7 +247,7 @@ export default class BasicMessageEditor extends React.Component isTyping = false; } } - TypingStore.sharedInstance().setSelfTyping( + SdkContextClass.instance.typingStore.setSelfTyping( this.props.room.roomId, this.props.threadId, isTyping, @@ -790,6 +790,7 @@ export default class BasicMessageEditor extends React.Component aria-activedescendant={activeDescendant} dir="auto" aria-disabled={this.props.disabled} + data-testid="basicmessagecomposer" /> ); } diff --git a/src/components/views/rooms/EventTile.tsx b/src/components/views/rooms/EventTile.tsx index 5c94d250bb5..56f291a11b2 100644 --- a/src/components/views/rooms/EventTile.tsx +++ b/src/components/views/rooms/EventTile.tsx @@ -27,6 +27,7 @@ import { NotificationCountType, Room, RoomEvent } from 'matrix-js-sdk/src/models import { CallErrorCode } from "matrix-js-sdk/src/webrtc/call"; import { CryptoEvent } from "matrix-js-sdk/src/crypto"; import { UserTrustLevel } from 'matrix-js-sdk/src/crypto/CrossSigning'; +import { Feature, ServerSupport } from 'matrix-js-sdk/src/feature'; import { Icon as LinkIcon } from '../../../../res/img/element-icons/link.svg'; import { Icon as ViewInRoomIcon } from '../../../../res/img/element-icons/view-in-room.svg'; @@ -85,6 +86,7 @@ import { ShowThreadPayload } from "../../../dispatcher/payloads/ShowThreadPayloa import { isLocalRoom } from '../../../utils/localRoom/isLocalRoom'; import { UserNameColorMode } from '../../../settings/enums/UserNameColorMode'; import { ElementCall } from "../../../models/Call"; +import { UnreadNotificationBadge } from './NotificationBadge/UnreadNotificationBadge'; export type GetRelationsForEvent = (eventId: string, relationType: string, eventType: string) => Relations; @@ -114,7 +116,7 @@ export interface IEventTileType extends React.Component { getEventTileOps?(): IEventTileOps; } -interface IProps { +export interface EventTileProps { // the MatrixEvent to show mxEvent: MatrixEvent; @@ -258,7 +260,7 @@ interface IState { } // MUST be rendered within a RoomContext with a set timelineRenderingType -export class UnwrappedEventTile extends React.Component { +export class UnwrappedEventTile extends React.Component { private suppressReadReceiptAnimation: boolean; private isListeningForReceipts: boolean; private tile = React.createRef(); @@ -277,7 +279,7 @@ export class UnwrappedEventTile extends React.Component { static contextType = RoomContext; public context!: React.ContextType; - constructor(props: IProps, context: React.ContextType) { + constructor(props: EventTileProps, context: React.ContextType) { super(props, context); const thread = this.thread; @@ -404,7 +406,7 @@ export class UnwrappedEventTile extends React.Component { if (SettingsStore.getValue("feature_thread")) { this.props.mxEvent.on(ThreadEvent.Update, this.updateThread); - if (this.thread) { + if (this.thread && !this.supportsThreadNotifications) { this.setupNotificationListener(this.thread); } } @@ -415,33 +417,40 @@ export class UnwrappedEventTile extends React.Component { room?.on(ThreadEvent.New, this.onNewThread); } - private setupNotificationListener(thread: Thread): void { - const notifications = RoomNotificationStateStore.instance.getThreadsRoomState(thread.room); - - this.threadState = notifications.getThreadRoomState(thread); + private get supportsThreadNotifications(): boolean { + const client = MatrixClientPeg.get(); + return client.canSupport.get(Feature.ThreadUnreadNotifications) !== ServerSupport.Unsupported; + } - this.threadState.on(NotificationStateEvents.Update, this.onThreadStateUpdate); - this.onThreadStateUpdate(); + private setupNotificationListener(thread: Thread): void { + if (!this.supportsThreadNotifications) { + const notifications = RoomNotificationStateStore.instance.getThreadsRoomState(thread.room); + this.threadState = notifications.getThreadRoomState(thread); + this.threadState.on(NotificationStateEvents.Update, this.onThreadStateUpdate); + this.onThreadStateUpdate(); + } } private onThreadStateUpdate = (): void => { - let threadNotification = null; - switch (this.threadState?.color) { - case NotificationColor.Grey: - threadNotification = NotificationCountType.Total; - break; - case NotificationColor.Red: - threadNotification = NotificationCountType.Highlight; - break; - } + if (!this.supportsThreadNotifications) { + let threadNotification = null; + switch (this.threadState?.color) { + case NotificationColor.Grey: + threadNotification = NotificationCountType.Total; + break; + case NotificationColor.Red: + threadNotification = NotificationCountType.Highlight; + break; + } - this.setState({ - threadNotification, - }); + this.setState({ + threadNotification, + }); + } }; private updateThread = (thread: Thread) => { - if (thread !== this.state.thread) { + if (thread !== this.state.thread && !this.supportsThreadNotifications) { if (this.threadState) { this.threadState.off(NotificationStateEvents.Update, this.onThreadStateUpdate); } @@ -454,7 +463,7 @@ export class UnwrappedEventTile extends React.Component { // TODO: [REACT-WARNING] Replace with appropriate lifecycle event // eslint-disable-next-line - UNSAFE_componentWillReceiveProps(nextProps: IProps) { + UNSAFE_componentWillReceiveProps(nextProps: EventTileProps) { // re-check the sender verification as outgoing events progress through // the send process. if (nextProps.eventSendStatus !== this.props.eventSendStatus) { @@ -462,7 +471,7 @@ export class UnwrappedEventTile extends React.Component { } } - shouldComponentUpdate(nextProps: IProps, nextState: IState): boolean { + shouldComponentUpdate(nextProps: EventTileProps, nextState: IState): boolean { if (objectHasDiff(this.state, nextState)) { return true; } @@ -491,7 +500,7 @@ export class UnwrappedEventTile extends React.Component { } } - componentDidUpdate(prevProps: IProps, prevState: IState, snapshot) { + componentDidUpdate() { // If we're not listening for receipts and expect to be, register a listener. if (!this.isListeningForReceipts && (this.shouldShowSentReceipt || this.shouldShowSendingReceipt)) { MatrixClientPeg.get().on(RoomEvent.Receipt, this.onRoomReceipt); @@ -541,7 +550,11 @@ export class UnwrappedEventTile extends React.Component { private renderThreadInfo(): React.ReactNode { if (this.state.thread?.id === this.props.mxEvent.getId()) { - return ; + return ; } if (this.context.timelineRenderingType === TimelineRenderingType.Search && this.props.mxEvent.threadRootId) { @@ -677,7 +690,7 @@ export class UnwrappedEventTile extends React.Component { }, this.props.onHeightChanged); // Decryption may have caused a change in size } - private propsEqual(objA: IProps, objB: IProps): boolean { + private propsEqual(objA: EventTileProps, objB: EventTileProps): boolean { const keysA = Object.keys(objA); const keysB = Object.keys(objB); @@ -948,6 +961,7 @@ export class UnwrappedEventTile extends React.Component { rightClick={true} reactions={this.state.reactions} link={this.state.contextMenu.link} + getRelationsForEvent={this.props.getRelationsForEvent} /> ); } @@ -1382,6 +1396,7 @@ export class UnwrappedEventTile extends React.Component { ]); } case TimelineRenderingType.ThreadsList: { + const room = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId()); // tab-index=-1 to allow it to be focusable but do not add tab stop for it, primarily for screen readers return ( React.createElement(this.props.as || "li", { @@ -1395,7 +1410,9 @@ export class UnwrappedEventTile extends React.Component { "data-shape": this.context.timelineRenderingType, "data-self": isOwnEvent, "data-has-reply": !!replyChain, - "data-notification": this.state.threadNotification, + "data-notification": !this.supportsThreadNotifications + ? this.state.threadNotification + : undefined, "onMouseEnter": () => this.setState({ hover: true }), "onMouseLeave": () => this.setState({ hover: false }), "onClick": (ev: MouseEvent) => { @@ -1443,6 +1460,9 @@ export class UnwrappedEventTile extends React.Component { { msgOption } + >) ); } @@ -1654,10 +1674,12 @@ export class UnwrappedEventTile extends React.Component { } // Wrap all event tiles with the tile error boundary so that any throws even during construction are captured -const SafeEventTile = forwardRef((props: IProps, ref: RefObject) => { - return - - ; +const SafeEventTile = forwardRef((props: EventTileProps, ref: RefObject) => { + return <> + + + + >; }); export default SafeEventTile; diff --git a/src/components/views/rooms/MessageComposer.tsx b/src/components/views/rooms/MessageComposer.tsx index 01c985bd8ab..807d6fb1047 100644 --- a/src/components/views/rooms/MessageComposer.tsx +++ b/src/components/views/rooms/MessageComposer.tsx @@ -62,7 +62,9 @@ import { startNewVoiceBroadcastRecording, VoiceBroadcastRecordingsStore, } from '../../../voice-broadcast'; -import { WysiwygComposer } from './wysiwyg_composer/WysiwygComposer'; +import { SendWysiwygComposer, sendMessage } from './wysiwyg_composer/'; +import { MatrixClientProps, withMatrixClientHOC } from '../../../contexts/MatrixClientContext'; +import { htmlToPlainText } from '../../../utils/room/htmlToPlaintext'; let instanceCount = 0; @@ -84,11 +86,12 @@ function SendButton(props: ISendButtonProps) { className={classes} onClick={props.onClick} title={props.title ?? _t('Send message')} + data-testid="sendmessagebtn" /> ); } -interface IProps { +interface IProps extends MatrixClientProps { room: Room; resizeNotifier: ResizeNotifier; permalinkCreator: RoomPermalinkCreator; @@ -98,10 +101,10 @@ interface IProps { layout?: Layout; userNameColorMode?: UserNameColorMode; compact?: boolean; - showVoiceBroadcastButton?: boolean; } interface IState { + composerContent: string; isComposerEmpty: boolean; haveRecording: boolean; recordingTimeLeftSeconds?: number; @@ -112,15 +115,17 @@ interface IState { collapseButtons: boolean; showPollsButton: boolean; showVoiceBroadcastButton: boolean; + isWysiwygLabEnabled: boolean; + isRichTextEnabled: boolean; + initialComposerContent: string; } -export default class MessageComposer extends React.Component { +export class MessageComposer extends React.Component { private dispatcherRef?: string; private messageComposerInput = createRef(); private voiceRecordingButton = createRef(); private ref: React.RefObject = createRef(); private instanceId: number; - private composerSendMessage?: () => void; private _voiceRecording: Optional; @@ -130,6 +135,7 @@ export default class MessageComposer extends React.Component { public static defaultProps = { compact: false, showVoiceBroadcastButton: false, + isRichTextEnabled: true, }; public constructor(props: IProps) { @@ -138,6 +144,7 @@ export default class MessageComposer extends React.Component { this.state = { isComposerEmpty: true, + composerContent: '', haveRecording: false, recordingTimeLeftSeconds: undefined, // when set to a number, shows a toast isMenuOpen: false, @@ -146,6 +153,9 @@ export default class MessageComposer extends React.Component { collapseButtons: SettingsStore.getValue("MessageComposerInput.collapseButtons"), showPollsButton: SettingsStore.getValue("MessageComposerInput.showPollsButton"), showVoiceBroadcastButton: SettingsStore.getValue(Features.VoiceBroadcast), + isWysiwygLabEnabled: SettingsStore.getValue("feature_wysiwyg_composer"), + isRichTextEnabled: true, + initialComposerContent: '', }; this.instanceId = instanceCount++; @@ -154,6 +164,7 @@ export default class MessageComposer extends React.Component { SettingsStore.monitorSetting("MessageComposerInput.collapseButtons", null); SettingsStore.monitorSetting("MessageComposerInput.showPollsButton", null); SettingsStore.monitorSetting(Features.VoiceBroadcast, null); + SettingsStore.monitorSetting("feature_wysiwyg_composer", null); } private get voiceRecording(): Optional { @@ -241,6 +252,12 @@ export default class MessageComposer extends React.Component { } break; } + case "feature_wysiwyg_composer": { + if (this.state.isWysiwygLabEnabled !== settingUpdatedPayload.newValue) { + this.setState({ isWysiwygLabEnabled: Boolean(settingUpdatedPayload.newValue) }); + } + break; + } } } } @@ -338,7 +355,15 @@ export default class MessageComposer extends React.Component { } this.messageComposerInput.current?.sendMessage(); - this.composerSendMessage?.(); + + if (this.state.isWysiwygLabEnabled) { + const { permalinkCreator, relation, replyToEvent } = this.props; + sendMessage(this.state.composerContent, + this.state.isRichTextEnabled, + { mxClient: this.props.mxClient, roomContext: this.context, permalinkCreator, relation, replyToEvent }); + dis.dispatch({ action: Action.ClearAndFocusSendMessageComposer }); + this.setState({ composerContent: '', initialComposerContent: '' }); + } }; private onChange = (model: EditorModel) => { @@ -349,10 +374,21 @@ export default class MessageComposer extends React.Component { private onWysiwygChange = (content: string) => { this.setState({ + composerContent: content, isComposerEmpty: content?.length === 0, }); }; + private onRichTextToggle = () => { + this.setState(state => ({ + isRichTextEnabled: !state.isRichTextEnabled, + initialComposerContent: !state.isRichTextEnabled ? + state.composerContent : + // TODO when available use rust model plain text + htmlToPlainText(state.composerContent), + })); + }; + private onVoiceStoreUpdate = () => { this.updateRecordingState(); }; @@ -407,12 +443,7 @@ export default class MessageComposer extends React.Component { return this.state.showStickersButton && !isLocalRoom(this.props.room); } - private get showVoiceBroadcastButton(): boolean { - return this.props.showVoiceBroadcastButton && this.state.showVoiceBroadcastButton; - } - public render() { - const isWysiwygComposerEnabled = SettingsStore.getValue("feature_wysiwyg_composer"); const controls = [ this.props.e2eStatus ? : @@ -427,18 +458,15 @@ export default class MessageComposer extends React.Component { const canSendMessages = this.context.canSendMessages && !this.context.tombstone; if (canSendMessages) { - if (isWysiwygComposerEnabled) { + if (this.state.isWysiwygLabEnabled) { controls.push( - - { (sendMessage) => { - this.composerSendMessage = sendMessage; - } } - , + onSend={this.sendMessage} + isRichTextEnabled={this.state.isRichTextEnabled} + initialContent={this.state.initialComposerContent} + />, ); } else { controls.push( @@ -530,7 +558,7 @@ export default class MessageComposer extends React.Component { "mx_MessageComposer--compact": this.props.compact, "mx_MessageComposer_e2eStatus": this.props.e2eStatus != undefined, - "mx_MessageComposer_wysiwyg": isWysiwygComposerEnabled, + "mx_MessageComposer_wysiwyg": this.state.isWysiwygLabEnabled && this.state.isRichTextEnabled, }); return ( @@ -561,11 +589,14 @@ export default class MessageComposer extends React.Component { showPollsButton={this.state.showPollsButton} showStickersButton={this.showStickersButton} collapseButtons={this.state.collapseButtons} + showComposerModeButton={this.state.isWysiwygLabEnabled} + isRichTextEnabled={this.state.isRichTextEnabled} + onComposerModeClick={this.onRichTextToggle} toggleButtonMenu={this.toggleButtonMenu} - showVoiceBroadcastButton={this.showVoiceBroadcastButton} + showVoiceBroadcastButton={this.state.showVoiceBroadcastButton} onStartVoiceBroadcastClick={() => { startNewVoiceBroadcastRecording( - this.props.room.roomId, + this.props.room, MatrixClientPeg.get(), VoiceBroadcastRecordingsStore.instance(), ); @@ -586,3 +617,6 @@ export default class MessageComposer extends React.Component { ); } } + +const MessageComposerWithMatrixClient = withMatrixClientHOC(MessageComposer); +export default MessageComposerWithMatrixClient; diff --git a/src/components/views/rooms/MessageComposerButtons.tsx b/src/components/views/rooms/MessageComposerButtons.tsx index 492a5130898..09b2520b684 100644 --- a/src/components/views/rooms/MessageComposerButtons.tsx +++ b/src/components/views/rooms/MessageComposerButtons.tsx @@ -17,7 +17,7 @@ limitations under the License. import classNames from 'classnames'; import { IEventRelation } from "matrix-js-sdk/src/models/event"; import { M_POLL_START } from "matrix-events-sdk"; -import React, { createContext, ReactElement, useContext, useRef } from 'react'; +import React, { createContext, MouseEventHandler, ReactElement, useContext, useRef } from 'react'; import { Room } from 'matrix-js-sdk/src/models/room'; import { MatrixClient } from 'matrix-js-sdk/src/client'; import { THREAD_RELATION_TYPE } from 'matrix-js-sdk/src/models/thread'; @@ -58,6 +58,9 @@ interface IProps { toggleButtonMenu: () => void; showVoiceBroadcastButton: boolean; onStartVoiceBroadcastClick: () => void; + isRichTextEnabled: boolean; + showComposerModeButton: boolean; + onComposerModeClick: () => void; } type OverflowMenuCloser = () => void; @@ -100,6 +103,8 @@ const MessageComposerButtons: React.FC = (props: IProps) => { } else { mainButtons = [ emojiButton(props, room), + props.showComposerModeButton && + , uploadButton(), // props passed via UploadButtonContext showStickersButton(props), voiceRecordingButton(props, narrow), @@ -413,4 +418,23 @@ function showLocationButton( ); } +interface WysiwygToggleButtonProps { + isRichTextEnabled: boolean; + onClick: MouseEventHandler; +} + +function ComposerModeButton({ isRichTextEnabled, onClick }: WysiwygToggleButtonProps) { + const title = isRichTextEnabled ? _t("Show plain text") : _t("Show formatting"); + + return ; +} + export default MessageComposerButtons; diff --git a/src/components/views/rooms/NewRoomIntro.tsx b/src/components/views/rooms/NewRoomIntro.tsx index 56c7d7224c0..371494c79ed 100644 --- a/src/components/views/rooms/NewRoomIntro.tsx +++ b/src/components/views/rooms/NewRoomIntro.tsx @@ -175,14 +175,22 @@ const NewRoomIntro = () => { } const avatarUrl = room.currentState.getStateEvents(EventType.RoomAvatar, "")?.getContent()?.url; - body = - + ); + + if (!avatarUrl) { + avatar = cli.sendStateEvent(roomId, EventType.RoomAvatar, { url }, '')} > - - + { avatar } + ; + } + + body = + { avatar } { room.name } diff --git a/src/components/views/rooms/NotificationBadge.tsx b/src/components/views/rooms/NotificationBadge.tsx index 51745209aae..dccf8b11906 100644 --- a/src/components/views/rooms/NotificationBadge.tsx +++ b/src/components/views/rooms/NotificationBadge.tsx @@ -15,16 +15,14 @@ limitations under the License. */ import React, { MouseEvent } from "react"; -import classNames from "classnames"; -import { formatCount } from "../../../utils/FormattingUtils"; import SettingsStore from "../../../settings/SettingsStore"; -import AccessibleButton from "../elements/AccessibleButton"; import { XOR } from "../../../@types/common"; import { NotificationState, NotificationStateEvents } from "../../../stores/notifications/NotificationState"; import Tooltip from "../elements/Tooltip"; import { _t } from "../../../languageHandler"; import { NotificationColor } from "../../../stores/notifications/NotificationColor"; +import { StatelessNotificationBadge } from "./NotificationBadge/StatelessNotificationBadge"; interface IProps { notification: NotificationState; @@ -113,61 +111,30 @@ export default class NotificationBadge extends React.PureComponent 0; - let isEmptyBadge = !hasAnySymbol || !notification.hasUnreadCount; if (forceCount) { - isEmptyBadge = false; if (!notification.hasUnreadCount) return null; // Can't render a badge } - let symbol = notification.symbol || formatCount(notification.count); - if (isEmptyBadge) symbol = ""; - - const classes = classNames({ - 'mx_NotificationBadge': true, - 'mx_NotificationBadge_visible': isEmptyBadge ? true : notification.hasUnreadCount, - 'mx_NotificationBadge_highlighted': notification.hasMentions, - 'mx_NotificationBadge_dot': isEmptyBadge, - 'mx_NotificationBadge_2char': symbol.length > 0 && symbol.length < 3, - 'mx_NotificationBadge_3char': symbol.length > 2, - }); - - if (onClick) { - let label: string; - let tooltip: JSX.Element; - if (showUnsentTooltip && this.state.showTooltip && notification.color === NotificationColor.Unsent) { - label = _t("Message didn't send. Click for info."); - tooltip = ; - } - - return ( - - { symbol } - { tooltip } - - ); + let label: string; + let tooltip: JSX.Element; + if (showUnsentTooltip && this.state.showTooltip && notification.color === NotificationColor.Unsent) { + label = _t("Message didn't send. Click for info."); + tooltip = ; } - return ( - - { symbol } - - ); + return + { tooltip } + ; } } diff --git a/src/components/views/rooms/NotificationBadge/StatelessNotificationBadge.tsx b/src/components/views/rooms/NotificationBadge/StatelessNotificationBadge.tsx new file mode 100644 index 00000000000..e9e97475f70 --- /dev/null +++ b/src/components/views/rooms/NotificationBadge/StatelessNotificationBadge.tsx @@ -0,0 +1,81 @@ +/* +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, { MouseEvent } from "react"; +import classNames from "classnames"; + +import { formatCount } from "../../../../utils/FormattingUtils"; +import AccessibleButton from "../../elements/AccessibleButton"; +import { NotificationColor } from "../../../../stores/notifications/NotificationColor"; + +interface Props { + symbol: string | null; + count: number; + color: NotificationColor; + onClick?: (ev: MouseEvent) => void; + onMouseOver?: (ev: MouseEvent) => void; + onMouseLeave?: (ev: MouseEvent) => void; + children?: React.ReactChildren | JSX.Element; + label?: string; +} + +export function StatelessNotificationBadge({ + symbol, + count, + color, + ...props }: Props) { + // Don't show a badge if we don't need to + if (color === NotificationColor.None) return null; + + const hasUnreadCount = color >= NotificationColor.Grey && (!!count || !!symbol); + + const isEmptyBadge = symbol === null && count === 0; + + if (symbol === null && count > 0) { + symbol = formatCount(count); + } + + const classes = classNames({ + 'mx_NotificationBadge': true, + 'mx_NotificationBadge_visible': isEmptyBadge ? true : hasUnreadCount, + 'mx_NotificationBadge_highlighted': color >= NotificationColor.Red, + 'mx_NotificationBadge_dot': isEmptyBadge, + 'mx_NotificationBadge_2char': symbol?.length > 0 && symbol?.length < 3, + 'mx_NotificationBadge_3char': symbol?.length > 2, + }); + + if (props.onClick) { + return ( + + { symbol } + { props.children } + + ); + } + + return ( + + { symbol } + + ); +} diff --git a/src/components/views/rooms/NotificationBadge/UnreadNotificationBadge.tsx b/src/components/views/rooms/NotificationBadge/UnreadNotificationBadge.tsx new file mode 100644 index 00000000000..a623daa716e --- /dev/null +++ b/src/components/views/rooms/NotificationBadge/UnreadNotificationBadge.tsx @@ -0,0 +1,36 @@ +/* +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 { Room } from "matrix-js-sdk/src/models/room"; +import React from "react"; + +import { useUnreadNotifications } from "../../../../hooks/useUnreadNotifications"; +import { StatelessNotificationBadge } from "./StatelessNotificationBadge"; + +interface Props { + room: Room; + threadId?: string; +} + +export function UnreadNotificationBadge({ room, threadId }: Props) { + const { symbol, count, color } = useUnreadNotifications(room, threadId); + + return ; +} diff --git a/src/components/views/rooms/RoomList.tsx b/src/components/views/rooms/RoomList.tsx index 4eb96019d26..50562945e37 100644 --- a/src/components/views/rooms/RoomList.tsx +++ b/src/components/views/rooms/RoomList.tsx @@ -39,7 +39,6 @@ import { ITagMap } from "../../../stores/room-list/algorithms/models"; import { DefaultTagID, TagID } from "../../../stores/room-list/models"; import { UPDATE_EVENT } from "../../../stores/AsyncStore"; import RoomListStore, { LISTS_UPDATE_EVENT } from "../../../stores/room-list/RoomListStore"; -import { RoomViewStore } from "../../../stores/RoomViewStore"; import { isMetaSpace, ISuggestedRoom, @@ -64,6 +63,7 @@ import IconizedContextMenu, { import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import ExtraTile from "./ExtraTile"; import RoomSublist, { IAuxButtonProps } from "./RoomSublist"; +import { SdkContextClass } from "../../../contexts/SDKContext"; interface IProps { onKeyDown: (ev: React.KeyboardEvent, state: IRovingTabIndexState) => void; @@ -484,7 +484,7 @@ export default class RoomList extends React.PureComponent { public componentDidMount(): void { this.dispatcherRef = defaultDispatcher.register(this.onAction); - RoomViewStore.instance.on(UPDATE_EVENT, this.onRoomViewStoreUpdate); + SdkContextClass.instance.roomViewStore.on(UPDATE_EVENT, this.onRoomViewStoreUpdate); SpaceStore.instance.on(UPDATE_SUGGESTED_ROOMS, this.updateSuggestedRooms); RoomListStore.instance.on(LISTS_UPDATE_EVENT, this.updateLists); this.favouriteMessageWatcher = @@ -500,7 +500,7 @@ export default class RoomList extends React.PureComponent { SettingsStore.unwatchSetting(this.favouriteMessageWatcher); defaultDispatcher.unregister(this.dispatcherRef); SettingsStore.unwatchSetting(this.unifiedRoomListWatcherRef); - RoomViewStore.instance.off(UPDATE_EVENT, this.onRoomViewStoreUpdate); + SdkContextClass.instance.roomViewStore.off(UPDATE_EVENT, this.onRoomViewStoreUpdate); } private onUnifiedRoomListChange = () => { @@ -511,14 +511,14 @@ export default class RoomList extends React.PureComponent { private onRoomViewStoreUpdate = () => { this.setState({ - currentRoomId: RoomViewStore.instance.getRoomId(), + currentRoomId: SdkContextClass.instance.roomViewStore.getRoomId(), }); }; private onAction = (payload: ActionPayload) => { if (payload.action === Action.ViewRoomDelta) { const viewRoomDeltaPayload = payload as ViewRoomDeltaPayload; - const currentRoomId = RoomViewStore.instance.getRoomId(); + const currentRoomId = SdkContextClass.instance.roomViewStore.getRoomId(); const room = this.getRoomDelta(currentRoomId, viewRoomDeltaPayload.delta, viewRoomDeltaPayload.unread); if (room) { defaultDispatcher.dispatch({ diff --git a/src/components/views/rooms/RoomListHeader.tsx b/src/components/views/rooms/RoomListHeader.tsx index d56b5d19d55..b81ea9dcdc1 100644 --- a/src/components/views/rooms/RoomListHeader.tsx +++ b/src/components/views/rooms/RoomListHeader.tsx @@ -382,7 +382,7 @@ const RoomListHeader = ({ onVisibilityChange }: IProps) => { isExpanded={mainMenuDisplayed} className="mx_RoomListHeader_contextMenuButton" title={activeSpace - ? _t("%(spaceName)s menu", { spaceName }) + ? _t("%(spaceName)s menu", { spaceName: spaceName ?? activeSpace.name }) : _t("Home options")} > { title } diff --git a/src/components/views/rooms/RoomPreviewBar.tsx b/src/components/views/rooms/RoomPreviewBar.tsx index e10daac1dbe..26615b0476c 100644 --- a/src/components/views/rooms/RoomPreviewBar.tsx +++ b/src/components/views/rooms/RoomPreviewBar.tsx @@ -263,9 +263,9 @@ export default class RoomPreviewBar extends React.Component { params: { email: this.props.invitedEmail, signurl: this.props.signUrl, - room_name: this.props.oobData ? this.props.oobData.room_name : null, - room_avatar_url: this.props.oobData ? this.props.oobData.avatarUrl : null, - inviter_name: this.props.oobData ? this.props.oobData.inviterName : null, + room_name: this.props.oobData?.name ?? null, + room_avatar_url: this.props.oobData?.avatarUrl ?? null, + inviter_name: this.props.oobData?.inviterName ?? null, }, }; } diff --git a/src/components/views/rooms/RoomSublist.tsx b/src/components/views/rooms/RoomSublist.tsx index 9512abdaeb3..f13a6f66b65 100644 --- a/src/components/views/rooms/RoomSublist.tsx +++ b/src/components/views/rooms/RoomSublist.tsx @@ -583,8 +583,7 @@ export default class RoomSublist extends React.Component { const slidingList = SlidingSyncManager.instance.slidingSync.getList(slidingSyncIndex); isAlphabetical = slidingList.sort[0] === "by_name"; isUnreadFirst = ( - slidingList.sort[0] === "by_highlight_count" || - slidingList.sort[0] === "by_notification_count" + slidingList.sort[0] === "by_notification_level" ); } diff --git a/src/components/views/rooms/RoomTile.tsx b/src/components/views/rooms/RoomTile.tsx index 8e51b5576f7..f65f432c166 100644 --- a/src/components/views/rooms/RoomTile.tsx +++ b/src/components/views/rooms/RoomTile.tsx @@ -44,10 +44,10 @@ import PosthogTrackers from "../../../PosthogTrackers"; import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts"; import { getKeyBindingsManager } from "../../../KeyBindingsManager"; -import { RoomViewStore } from "../../../stores/RoomViewStore"; import { RoomTileCallSummary } from "./RoomTileCallSummary"; import { RoomGeneralContextMenu } from "../context_menus/RoomGeneralContextMenu"; import { CallStore, CallStoreEvent } from "../../../stores/CallStore"; +import { SdkContextClass } from "../../../contexts/SDKContext"; interface IProps { room: Room; @@ -86,7 +86,7 @@ export default class RoomTile extends React.PureComponent { super(props); this.state = { - selected: RoomViewStore.instance.getRoomId() === this.props.room.roomId, + selected: SdkContextClass.instance.roomViewStore.getRoomId() === this.props.room.roomId, notificationsMenuPosition: null, generalMenuPosition: null, call: CallStore.instance.getCall(this.props.room.roomId), @@ -153,7 +153,7 @@ export default class RoomTile extends React.PureComponent { this.scrollIntoView(); } - RoomViewStore.instance.addRoomListener(this.props.room.roomId, this.onActiveRoomUpdate); + SdkContextClass.instance.roomViewStore.addRoomListener(this.props.room.roomId, this.onActiveRoomUpdate); this.dispatcherRef = defaultDispatcher.register(this.onAction); MessagePreviewStore.instance.on( MessagePreviewStore.getPreviewChangedEventName(this.props.room), @@ -170,7 +170,7 @@ export default class RoomTile extends React.PureComponent { } public componentWillUnmount() { - RoomViewStore.instance.removeRoomListener(this.props.room.roomId, this.onActiveRoomUpdate); + SdkContextClass.instance.roomViewStore.removeRoomListener(this.props.room.roomId, this.onActiveRoomUpdate); MessagePreviewStore.instance.off( MessagePreviewStore.getPreviewChangedEventName(this.props.room), this.onRoomPreviewChanged, diff --git a/src/components/views/rooms/ThreadSummary.tsx b/src/components/views/rooms/ThreadSummary.tsx index c14a4cc9e14..83dbf0d45a8 100644 --- a/src/components/views/rooms/ThreadSummary.tsx +++ b/src/components/views/rooms/ThreadSummary.tsx @@ -37,7 +37,7 @@ interface IProps { thread: Thread; } -const ThreadSummary = ({ mxEvent, thread }: IProps) => { +const ThreadSummary = ({ mxEvent, thread, ...props }: IProps) => { const roomContext = useContext(RoomContext); const cardContext = useContext(CardContext); const count = useTypedEventEmitterState(thread, ThreadEvent.Update, () => thread.length); @@ -50,6 +50,7 @@ const ThreadSummary = ({ mxEvent, thread }: IProps) => { return ( { defaultDispatcher.dispatch({ @@ -94,7 +95,9 @@ export const ThreadMessagePreview = ({ thread, showDisplayname = false }: IPrevi await cli.decryptEventIfNeeded(lastReply); return MessagePreviewStore.instance.generatePreviewForEvent(lastReply); }, [lastReply, content]); - if (!preview) return null; + if (!preview || !lastReply) { + return null; + } return <> ( + function Content({ disabled }: ContentProps, forwardRef: RefObject) { + useWysiwygEditActionHandler(disabled, forwardRef); + return null; + }, +); + +interface EditWysiwygComposerProps { + disabled?: boolean; + onChange?: (content: string) => void; + editorStateTransfer: EditorStateTransfer; + className?: string; +} + +export function EditWysiwygComposer({ editorStateTransfer, className, ...props }: EditWysiwygComposerProps) { + const initialContent = useInitialContent(editorStateTransfer); + const isReady = !editorStateTransfer || Boolean(initialContent); + + const { editMessage, endEditing, onChange, isSaveDisabled } = useEditing(initialContent, editorStateTransfer); + + return isReady && + { (ref) => ( + <> + + + >) + } + ; +} diff --git a/src/components/views/rooms/wysiwyg_composer/SendWysiwygComposer.tsx b/src/components/views/rooms/wysiwyg_composer/SendWysiwygComposer.tsx new file mode 100644 index 00000000000..380b0430cef --- /dev/null +++ b/src/components/views/rooms/wysiwyg_composer/SendWysiwygComposer.tsx @@ -0,0 +1,52 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React, { forwardRef, RefObject } from 'react'; + +import { useWysiwygSendActionHandler } from './hooks/useWysiwygSendActionHandler'; +import { WysiwygComposer } from './components/WysiwygComposer'; +import { PlainTextComposer } from './components/PlainTextComposer'; +import { ComposerFunctions } from './types'; + +interface ContentProps { + disabled: boolean; + composerFunctions: ComposerFunctions; +} + +const Content = forwardRef( + function Content({ disabled, composerFunctions }: ContentProps, forwardRef: RefObject) { + useWysiwygSendActionHandler(disabled, forwardRef, composerFunctions); + return null; + }, +); + +interface SendWysiwygComposerProps { + initialContent?: string; + isRichTextEnabled: boolean; + disabled?: boolean; + onChange: (content: string) => void; + onSend: () => void; +} + +export function SendWysiwygComposer({ isRichTextEnabled, ...props }: SendWysiwygComposerProps) { + const Composer = isRichTextEnabled ? WysiwygComposer : PlainTextComposer; + + return + { (ref, composerFunctions) => ( + + ) } + ; +} diff --git a/src/components/views/rooms/wysiwyg_composer/WysiwygComposer.tsx b/src/components/views/rooms/wysiwyg_composer/WysiwygComposer.tsx deleted file mode 100644 index 8701f5be778..00000000000 --- a/src/components/views/rooms/wysiwyg_composer/WysiwygComposer.tsx +++ /dev/null @@ -1,68 +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, { useCallback, useEffect } from 'react'; -import { IEventRelation, MatrixEvent } from 'matrix-js-sdk/src/models/event'; -import { useWysiwyg } from "@matrix-org/matrix-wysiwyg"; - -import { Editor } from './Editor'; -import { FormattingButtons } from './FormattingButtons'; -import { RoomPermalinkCreator } from '../../../../utils/permalinks/Permalinks'; -import { sendMessage } from './message'; -import { useMatrixClientContext } from '../../../../contexts/MatrixClientContext'; -import { useRoomContext } from '../../../../contexts/RoomContext'; -import { useWysiwygActionHandler } from './useWysiwygActionHandler'; - -interface WysiwygProps { - disabled?: boolean; - onChange: (content: string) => void; - relation?: IEventRelation; - replyToEvent?: MatrixEvent; - permalinkCreator: RoomPermalinkCreator; - includeReplyLegacyFallback?: boolean; - children?: (sendMessage: () => void) => void; -} - -export function WysiwygComposer( - { disabled = false, onChange, children, ...props }: WysiwygProps, -) { - const roomContext = useRoomContext(); - const mxClient = useMatrixClientContext(); - - const { ref, isWysiwygReady, content, formattingStates, wysiwyg } = useWysiwyg(); - - useEffect(() => { - if (!disabled && content !== null) { - onChange(content); - } - }, [onChange, content, disabled]); - - const memoizedSendMessage = useCallback(() => { - sendMessage(content, { mxClient, roomContext, ...props }); - wysiwyg.clear(); - ref.current?.focus(); - }, [content, mxClient, roomContext, wysiwyg, props, ref]); - - useWysiwygActionHandler(disabled, ref); - - return ( - - - - { children?.(memoizedSendMessage) } - - ); -} diff --git a/src/components/views/rooms/wysiwyg_composer/components/EditionButtons.tsx b/src/components/views/rooms/wysiwyg_composer/components/EditionButtons.tsx new file mode 100644 index 00000000000..4fdc99a79c7 --- /dev/null +++ b/src/components/views/rooms/wysiwyg_composer/components/EditionButtons.tsx @@ -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 React, { MouseEventHandler } from 'react'; + +import { _t } from '../../../../../languageHandler'; +import AccessibleButton from '../../../elements/AccessibleButton'; + +interface EditionButtonsProps { + onCancelClick: MouseEventHandler; + onSaveClick: MouseEventHandler; + isSaveDisabled?: boolean; +} + +export function EditionButtons({ onCancelClick, onSaveClick, isSaveDisabled = false }: EditionButtonsProps) { + return + + { _t("Cancel") } + + + { _t("Save") } + + ; +} diff --git a/src/components/views/rooms/wysiwyg_composer/Editor.tsx b/src/components/views/rooms/wysiwyg_composer/components/Editor.tsx similarity index 100% rename from src/components/views/rooms/wysiwyg_composer/Editor.tsx rename to src/components/views/rooms/wysiwyg_composer/components/Editor.tsx diff --git a/src/components/views/rooms/wysiwyg_composer/FormattingButtons.tsx b/src/components/views/rooms/wysiwyg_composer/components/FormattingButtons.tsx similarity index 84% rename from src/components/views/rooms/wysiwyg_composer/FormattingButtons.tsx rename to src/components/views/rooms/wysiwyg_composer/components/FormattingButtons.tsx index 19941ad3f94..00127e5e430 100644 --- a/src/components/views/rooms/wysiwyg_composer/FormattingButtons.tsx +++ b/src/components/views/rooms/wysiwyg_composer/components/FormattingButtons.tsx @@ -15,14 +15,14 @@ limitations under the License. */ import React, { MouseEventHandler } from "react"; -import { useWysiwyg } from "@matrix-org/matrix-wysiwyg"; +import { FormattingFunctions, FormattingStates } from "@matrix-org/matrix-wysiwyg"; import classNames from "classnames"; -import AccessibleTooltipButton from "../../elements/AccessibleTooltipButton"; -import { Alignment } from "../../elements/Tooltip"; -import { KeyboardShortcut } from "../../settings/KeyboardShortcut"; -import { KeyCombo } from "../../../../KeyBindingsManager"; -import { _td } from "../../../../languageHandler"; +import AccessibleTooltipButton from "../../../elements/AccessibleTooltipButton"; +import { Alignment } from "../../../elements/Tooltip"; +import { KeyboardShortcut } from "../../../settings/KeyboardShortcut"; +import { KeyCombo } from "../../../../../KeyBindingsManager"; +import { _td } from "../../../../../languageHandler"; interface TooltipProps { label: string; @@ -55,8 +55,8 @@ function Button({ label, keyCombo, onClick, isActive, className }: ButtonProps) } interface FormattingButtonsProps { - composer: ReturnType['wysiwyg']; - formattingStates: ReturnType['formattingStates']; + composer: FormattingFunctions; + formattingStates: FormattingStates; } export function FormattingButtons({ composer, formattingStates }: FormattingButtonsProps) { diff --git a/src/components/views/rooms/wysiwyg_composer/components/PlainTextComposer.tsx b/src/components/views/rooms/wysiwyg_composer/components/PlainTextComposer.tsx new file mode 100644 index 00000000000..e15b5ef57f7 --- /dev/null +++ b/src/components/views/rooms/wysiwyg_composer/components/PlainTextComposer.tsx @@ -0,0 +1,56 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React, { MutableRefObject, ReactNode } from 'react'; + +import { useComposerFunctions } from '../hooks/useComposerFunctions'; +import { usePlainTextInitialization } from '../hooks/usePlainTextInitialization'; +import { usePlainTextListeners } from '../hooks/usePlainTextListeners'; +import { useSetCursorPosition } from '../hooks/useSetCursorPosition'; +import { ComposerFunctions } from '../types'; +import { Editor } from "./Editor"; + +interface PlainTextComposerProps { + disabled?: boolean; + onChange?: (content: string) => void; + onSend: () => void; + initialContent?: string; + className?: string; + children?: ( + ref: MutableRefObject, + composerFunctions: ComposerFunctions, + ) => ReactNode; +} + +export function PlainTextComposer({ + className, disabled, onSend, onChange, children, initialContent }: PlainTextComposerProps, +) { + const { ref, onInput, onPaste, onKeyDown } = usePlainTextListeners(onChange, onSend); + const composerFunctions = useComposerFunctions(ref); + usePlainTextInitialization(initialContent, ref); + useSetCursorPosition(disabled, ref); + + return +
{ description }
{ cancellationMessage }
{ _t("Check that the code below matches with your other device:") }
{ _t("Scan the QR code below with your device that's signed out.") }